Add Kavita tracker (#7488)

* Added kavita tracker

* Changed api endpoint since tachiyomi has it's own. Moved some processing to backend

* Bugfix. Parsing to int instead of float

* Ignore DOH, update migration and cleanup

* Fix Unexpected JSON token
	modified:   app/src/main/java/eu/kanade/tachiyomi/data/track/TrackManager.kt
	modified:   app/src/main/java/eu/kanade/tachiyomi/data/track/kavita/KavitaApi.kt
	modified:   app/src/main/java/eu/kanade/tachiyomi/data/track/kavita/KavitaModels.kt

* Apply code format suggestions from code review

Co-authored-by: Andreas <andreas.everos@gmail.com>

* Apply simplified code suggestions from code review

Co-authored-by: Andreas <andreas.everos@gmail.com>

* Removed unused dtos

* Use setter instead of function to get apiurl

* Added Interceptor

* Handle not configured/not accesible sources

* Unused import

* Added kavita to new tracking settings screen

* Delete SettingsTrackingController.kt to solve conflict

* Review comments
* Removed break lines from log messages
* Fixed jwt typo

* Merged enhanced services compatibility warning message to be more generic.
* Updated Komga String res to use new formatted one
* Added Kavita String res to use formatted one

* Apply suggestions from code review - hardcoded strings to track name

Co-authored-by: Andreas <andreas.everos@gmail.com>

Co-authored-by: Andreas <andreas.everos@gmail.com>
This commit is contained in:
ThePromidius 2022-11-11 21:19:41 +01:00 committed by GitHub
parent acc65529a0
commit 92b039fac7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 443 additions and 4 deletions

View file

@ -164,11 +164,28 @@ class SettingsTrackingScreen : SearchableSettings {
if (hasValidSourceInstalled) { if (hasValidSourceInstalled) {
trackManager.komga.loginNoop() trackManager.komga.loginNoop()
} else { } else {
context.toast(R.string.tracker_komga_warning, Toast.LENGTH_LONG) context.toast(context.getString(R.string.enhanced_tracking_warning, context.getString(trackManager.komga.nameRes())), Toast.LENGTH_LONG)
} }
}, },
logout = trackManager.komga::logout, logout = trackManager.komga::logout,
), ),
Preference.PreferenceItem.TrackingPreference(
title = stringResource(trackManager.kavita.nameRes()),
service = trackManager.kavita,
login = {
val sourceManager = Injekt.get<SourceManager>()
val acceptedSources = trackManager.kavita.getAcceptedSources()
val hasValidSourceInstalled = sourceManager.getCatalogueSources()
.any { it::class.qualifiedName in acceptedSources }
if (hasValidSourceInstalled) {
trackManager.kavita.loginNoop()
} else {
context.toast(context.getString(R.string.enhanced_tracking_warning, context.getString(trackManager.kavita.nameRes())), Toast.LENGTH_LONG)
}
},
logout = trackManager.kavita::logout,
),
Preference.PreferenceItem.InfoPreference(stringResource(R.string.enhanced_tracking_info)), Preference.PreferenceItem.InfoPreference(stringResource(R.string.enhanced_tracking_info)),
), ),
), ),

View file

@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.data.track
import android.content.Context import android.content.Context
import eu.kanade.tachiyomi.data.track.anilist.Anilist import eu.kanade.tachiyomi.data.track.anilist.Anilist
import eu.kanade.tachiyomi.data.track.bangumi.Bangumi import eu.kanade.tachiyomi.data.track.bangumi.Bangumi
import eu.kanade.tachiyomi.data.track.kavita.Kavita
import eu.kanade.tachiyomi.data.track.kitsu.Kitsu import eu.kanade.tachiyomi.data.track.kitsu.Kitsu
import eu.kanade.tachiyomi.data.track.komga.Komga import eu.kanade.tachiyomi.data.track.komga.Komga
import eu.kanade.tachiyomi.data.track.mangaupdates.MangaUpdates import eu.kanade.tachiyomi.data.track.mangaupdates.MangaUpdates
@ -19,6 +20,7 @@ class TrackManager(context: Context) {
const val BANGUMI = 5L const val BANGUMI = 5L
const val KOMGA = 6L const val KOMGA = 6L
const val MANGA_UPDATES = 7L const val MANGA_UPDATES = 7L
const val KAVITA = 8L
} }
val myAnimeList = MyAnimeList(context, MYANIMELIST) val myAnimeList = MyAnimeList(context, MYANIMELIST)
@ -28,8 +30,9 @@ class TrackManager(context: Context) {
val bangumi = Bangumi(context, BANGUMI) val bangumi = Bangumi(context, BANGUMI)
val komga = Komga(context, KOMGA) val komga = Komga(context, KOMGA)
val mangaUpdates = MangaUpdates(context, MANGA_UPDATES) val mangaUpdates = MangaUpdates(context, MANGA_UPDATES)
val kavita = Kavita(context, KAVITA)
val services = listOf(myAnimeList, aniList, kitsu, shikimori, bangumi, komga, mangaUpdates) val services = listOf(myAnimeList, aniList, kitsu, shikimori, bangumi, komga, mangaUpdates, kavita)
fun getService(id: Long) = services.find { it.id == id } fun getService(id: Long) = services.find { it.id == id }

View file

@ -0,0 +1,146 @@
package eu.kanade.tachiyomi.data.track.kavita
import android.app.Application
import android.content.Context
import android.content.SharedPreferences
import android.graphics.Color
import androidx.annotation.StringRes
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.EnhancedTrackService
import eu.kanade.tachiyomi.data.track.NoLoginTrackService
import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.source.Source
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.security.MessageDigest
class Kavita(private val context: Context, id: Long) : TrackService(id), EnhancedTrackService, NoLoginTrackService {
var authentications: OAuth? = null
companion object {
const val UNREAD = 1
const val READING = 2
const val COMPLETED = 3
}
private val interceptor by lazy { KavitaInterceptor(this) }
val api by lazy { KavitaApi(client, interceptor) }
@StringRes
override fun nameRes() = R.string.tracker_kavita
override fun getLogo(): Int = R.drawable.ic_tracker_kavita
override fun getLogoColor() = Color.rgb(74, 198, 148)
override fun getStatusList() = listOf(UNREAD, READING, COMPLETED)
override fun getStatus(status: Int): String = with(context) {
when (status) {
Kavita.UNREAD -> getString(R.string.unread)
Kavita.READING -> getString(R.string.reading)
Kavita.COMPLETED -> getString(R.string.completed)
else -> ""
}
}
override fun getReadingStatus(): Int = Kavita.READING
override fun getRereadingStatus(): Int = -1
override fun getCompletionStatus(): Int = Kavita.COMPLETED
override fun getScoreList(): List<String> = emptyList()
override fun displayScore(track: Track): String = ""
override suspend fun update(track: Track, didReadChapter: Boolean): Track {
if (track.status != COMPLETED) {
if (didReadChapter) {
if (track.last_chapter_read.toInt() == track.total_chapters && track.total_chapters > 0) {
track.status = COMPLETED
} else {
track.status = READING
}
}
}
return api.updateProgress(track)
}
override suspend fun bind(track: Track, hasReadChapters: Boolean): Track {
return track
}
override suspend fun search(query: String): List<TrackSearch> {
TODO("Not yet implemented: search")
}
override suspend fun refresh(track: Track): Track {
val remoteTrack = api.getTrackSearch(track.tracking_url)
track.copyPersonalFrom(remoteTrack)
track.total_chapters = remoteTrack.total_chapters
return track
}
override suspend fun login(username: String, password: String) {
saveCredentials("user", "pass")
}
// TrackService.isLogged works by checking that credentials are saved.
// By saving dummy, unused credentials, we can activate the tracker simply by login/logout
override fun loginNoop() {
saveCredentials("user", "pass")
}
override fun getAcceptedSources() = listOf("eu.kanade.tachiyomi.extension.all.kavita.Kavita")
override suspend fun match(manga: Manga): TrackSearch? =
try {
api.getTrackSearch(manga.url)
} catch (e: Exception) {
null
}
override fun isTrackFrom(track: eu.kanade.domain.track.model.Track, manga: eu.kanade.domain.manga.model.Manga, source: Source?): Boolean =
track.remoteUrl == manga.url && source?.let { accept(it) } == true
override fun migrateTrack(track: eu.kanade.domain.track.model.Track, manga: eu.kanade.domain.manga.model.Manga, newSource: Source): eu.kanade.domain.track.model.Track? =
if (accept(newSource)) {
track.copy(remoteUrl = manga.url)
} else {
null
}
fun loadOAuth() {
val oauth = OAuth()
for (sourceId in 1..3) {
val authentication = oauth.authentications[sourceId - 1]
val sourceSuffixID by lazy {
val key = "${"kavita_$sourceId"}/all/1" // Hardcoded versionID to 1
val bytes = MessageDigest.getInstance("MD5").digest(key.toByteArray())
(0..7).map { bytes[it].toLong() and 0xff shl 8 * (7 - it) }
.reduce(Long::or) and Long.MAX_VALUE
}
val preferences: SharedPreferences by lazy {
Injekt.get<Application>().getSharedPreferences("source_$sourceSuffixID", 0x0000)
}
val prefApiUrl = preferences.getString("APIURL", "")!!
if (prefApiUrl.isEmpty()) {
// Source not configured. Skip
continue
}
val prefApiKey = preferences.getString("APIKEY", "")!!
val token = api.getNewToken(apiUrl = prefApiUrl, apiKey = prefApiKey)
if (token.isNullOrEmpty()) {
// Source is not accessible. Skip
continue
}
authentication.apiUrl = prefApiUrl
authentication.jwtToken = token.toString()
}
authentications = oauth
}
}

View file

@ -0,0 +1,157 @@
package eu.kanade.tachiyomi.data.track.kavita
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.network.parseAs
import eu.kanade.tachiyomi.util.lang.withIOContext
import eu.kanade.tachiyomi.util.system.logcat
import logcat.LogPriority
import okhttp3.Dns
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.toRequestBody
import java.net.SocketTimeoutException
class KavitaApi(private val client: OkHttpClient, interceptor: KavitaInterceptor) {
private val authClient = client.newBuilder().dns(Dns.SYSTEM).addInterceptor(interceptor).build()
fun getApiFromUrl(url: String): String {
return url.split("/api/").first() + "/api"
}
fun getNewToken(apiUrl: String, apiKey: String): String? {
/*
* Uses url to compare against each source APIURL's to get the correct custom source preference.
* Now having source preference we can do getString("APIKEY")
* Authenticates to get the token
* Saves the token in the var jwtToken
*/
val request = POST(
"$apiUrl/Plugin/authenticate?apiKey=$apiKey&pluginName=Tachiyomi-Kavita",
body = "{}".toRequestBody("application/json; charset=utf-8".toMediaTypeOrNull()),
)
try {
client.newCall(request).execute().use {
if (it.code == 200) {
return it.parseAs<AuthenticationDto>().token
}
if (it.code == 401) {
logcat(LogPriority.WARN) { "Unauthorized / api key not valid:Cleaned api URL:${apiUrl}Api key is empty:${apiKey.isEmpty()}" }
throw Exception("Unauthorized / api key not valid")
}
if (it.code == 500) {
logcat(LogPriority.WARN) { "Error fetching jwt token. Cleaned api URL:$apiUrl Api key is empty:${apiKey.isEmpty()}" }
throw Exception("Error fetching jwt token")
}
}
// Not sure which one to cathc
} catch (e: SocketTimeoutException) {
logcat(LogPriority.WARN) {
"Could not fetch jwt token. Probably due to connectivity issue or the url '$apiUrl' is not available. Skipping"
}
return null
} catch (e: Exception) {
logcat(LogPriority.ERROR) {
"Unhandled Exception fetching jwt token for url: '$apiUrl'"
}
throw e
}
return null
}
private fun getApiVolumesUrl(url: String): String {
return "${getApiFromUrl(url)}/Series/volumes?seriesId=${getIdFromUrl(url)}"
}
private fun getIdFromUrl(url: String): Int {
/*Strips serie id from Url*/
return url.substringAfterLast("/").toInt()
}
private fun getTotalChapters(url: String): Int {
/*Returns total chapters in the series.
* Ignores volumes.
* Volumes consisting of 1 file treated as chapter
*/
val requestUrl = getApiVolumesUrl(url)
try {
val listVolumeDto = authClient.newCall(GET(requestUrl))
.execute()
.parseAs<List<VolumeDto>>()
var volumeNumber = 0
var maxChapterNumber = 0
for (volume in listVolumeDto) {
if (volume.chapters.maxOf { it.number!!.toFloat() } == 0f) {
volumeNumber++
} else if (maxChapterNumber < volume.chapters.maxOf { it.number!!.toFloat() }) {
maxChapterNumber = volume.chapters.maxOf { it.number!!.toFloat().toInt() }
}
}
return if (maxChapterNumber > volumeNumber) maxChapterNumber else volumeNumber
} catch (e: Exception) {
logcat(LogPriority.WARN, e) { "Exception fetching Total Chapters. Request:$requestUrl" }
throw e
}
}
private fun getLatestChapterRead(url: String): Float {
val serieId = getIdFromUrl(url)
val requestUrl = "${getApiFromUrl(url)}/Tachiyomi/latest-chapter?seriesId=$serieId"
try {
authClient.newCall(GET(requestUrl))
.execute().use {
if (it.code == 200) {
return it.parseAs<ChapterDto>().number!!.replace(",", ".").toFloat()
}
if (it.code == 204) {
return 0F
}
}
} catch (e: Exception) {
logcat(LogPriority.WARN, e) { "Exception getting latest chapter read. Could not get itemRequest:$requestUrl" }
throw e
}
return 0F
}
suspend fun getTrackSearch(url: String): TrackSearch =
withIOContext {
try {
val serieDto: SeriesDto =
authClient.newCall(GET(url))
.await()
.parseAs<SeriesDto>()
val track = serieDto.toTrack()
track.apply {
cover_url = serieDto.thumbnail_url.toString()
tracking_url = url
total_chapters = getTotalChapters(url)
title = serieDto.name
status = when (serieDto.pagesRead) {
serieDto.pages -> Kavita.COMPLETED
0 -> Kavita.UNREAD
else -> Kavita.READING
}
last_chapter_read = getLatestChapterRead(url)
}
} catch (e: Exception) {
logcat(LogPriority.WARN, e) { "Could not get item: $url" }
throw e
}
}
suspend fun updateProgress(track: Track): Track {
val requestUrl = "${getApiFromUrl(track.tracking_url)}/Tachiyomi/mark-chapter-until-as-read?seriesId=${getIdFromUrl(track.tracking_url)}&chapterNumber=${track.last_chapter_read}"
authClient.newCall(POST(requestUrl, body = "{}".toRequestBody("application/json; charset=utf-8".toMediaTypeOrNull())))
.await()
return getTrackSearch(track.tracking_url)
}
}

View file

@ -0,0 +1,26 @@
package eu.kanade.tachiyomi.data.track.kavita
import eu.kanade.tachiyomi.BuildConfig
import okhttp3.Interceptor
import okhttp3.Response
class KavitaInterceptor(private val kavita: Kavita) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
if (kavita.authentications == null) {
kavita.loadOAuth()
}
val jwtToken = kavita.authentications?.getToken(
kavita.api.getApiFromUrl(originalRequest.url.toString()),
)
// Add the authorization header to the original request.
val authRequest = originalRequest.newBuilder()
.addHeader("Authorization", "Bearer $jwtToken")
.header("User-Agent", "Tachiyomi Kavita v${BuildConfig.VERSION_NAME}")
.build()
return chain.proceed(authRequest)
}
}

View file

@ -0,0 +1,70 @@
package eu.kanade.tachiyomi.data.track.kavita
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import kotlinx.serialization.Serializable
@Serializable
data class SeriesDto(
val id: Int,
val name: String,
val originalName: String = "",
val thumbnail_url: String? = "",
val localizedName: String? = "",
val sortName: String? = "",
val pages: Int,
val coverImageLocked: Boolean = true,
val pagesRead: Int,
val userRating: Int? = 0,
val userReview: String? = "",
val format: Int,
val created: String? = "",
val libraryId: Int,
val libraryName: String? = "",
) {
fun toTrack(): TrackSearch = TrackSearch.create(TrackManager.KAVITA).also {
it.title = name
it.summary = ""
}
}
@Serializable
data class VolumeDto(
val id: Int,
val number: Int,
val name: String,
val pages: Int,
val pagesRead: Int,
val lastModified: String,
val created: String,
val seriesId: Int,
val chapters: List<ChapterDto> = emptyList(),
)
@Serializable
data class ChapterDto(
val id: Int? = -1,
val range: String? = "",
val number: String? = "-1",
val pages: Int? = 0,
val isSpecial: Boolean? = false,
val title: String? = "",
val pagesRead: Int? = 0,
val coverImageLocked: Boolean? = false,
val volumeId: Int? = -1,
val created: String? = "",
)
@Serializable
data class AuthenticationDto(
val username: String,
val token: String,
val apiKey: String,
)
data class SourceAuth(
var sourceId: Int,
var apiUrl: String = "",
var jwtToken: String = "",
)

View file

@ -0,0 +1,19 @@
package eu.kanade.tachiyomi.data.track.kavita
class OAuth(
val authentications: List<SourceAuth> = listOf<SourceAuth>(
SourceAuth(1),
SourceAuth(2),
SourceAuth(3),
),
) {
fun getToken(apiUrl: String): String? {
for (authentication in authentications) {
if (authentication.apiUrl == apiUrl) {
return authentication.jwtToken
}
}
return null
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

View file

@ -446,7 +446,8 @@
<string name="services">Services</string> <string name="services">Services</string>
<string name="tracking_info">One-way sync to update the chapter progress in tracking services. Set up tracking for individual entries from their tracking button.</string> <string name="tracking_info">One-way sync to update the chapter progress in tracking services. Set up tracking for individual entries from their tracking button.</string>
<string name="enhanced_services">Enhanced services</string> <string name="enhanced_services">Enhanced services</string>
<string name="enhanced_tracking_info">Services that provide enhanced features for specific sources. Entries are automatically tracked when added to your library.</string> <string name="enhanced_tracking_info">Services that provide enhanced features for specific sources. Manga are automatically tracked when added to your library.</string>
<string name="enhanced_tracking_warning">This tracker is only compatible with the %1$s source.</string>
<string name="action_track">Track</string> <string name="action_track">Track</string>
<!-- Browse section --> <!-- Browse section -->
@ -672,10 +673,10 @@
<string name="tracker_myanimelist" translatable="false">MyAnimeList</string> <string name="tracker_myanimelist" translatable="false">MyAnimeList</string>
<string name="tracker_kitsu" translatable="false">Kitsu</string> <string name="tracker_kitsu" translatable="false">Kitsu</string>
<string name="tracker_komga" translatable="false">Komga</string> <string name="tracker_komga" translatable="false">Komga</string>
<string name="tracker_komga_warning">This tracker is only compatible with the Komga source.</string>
<string name="tracker_bangumi" translatable="false">Bangumi</string> <string name="tracker_bangumi" translatable="false">Bangumi</string>
<string name="tracker_shikimori" translatable="false">Shikimori</string> <string name="tracker_shikimori" translatable="false">Shikimori</string>
<string name="tracker_manga_updates" translatable="false">MangaUpdates</string> <string name="tracker_manga_updates" translatable="false">MangaUpdates</string>
<string name="tracker_kavita" translatable="false">Kavita</string>
<string name="manga_tracking_tab">Tracking</string> <string name="manga_tracking_tab">Tracking</string>
<plurals name="num_trackers"> <plurals name="num_trackers">
<item quantity="one">%d tracker</item> <item quantity="one">%d tracker</item>