소스 검색

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 <[email protected]>

* Apply simplified code suggestions from code review

Co-authored-by: Andreas <[email protected]>

* 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 <[email protected]>

Co-authored-by: Andreas <[email protected]>
ThePromidius 2 년 전
부모
커밋
92b039fac7

+ 18 - 1
app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsTrackingScreen.kt

@@ -164,11 +164,28 @@ class SettingsTrackingScreen : SearchableSettings {
                             if (hasValidSourceInstalled) {
                                 trackManager.komga.loginNoop()
                             } 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,
                     ),
+                    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)),
                 ),
             ),

+ 4 - 1
app/src/main/java/eu/kanade/tachiyomi/data/track/TrackManager.kt

@@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.data.track
 import android.content.Context
 import eu.kanade.tachiyomi.data.track.anilist.Anilist
 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.komga.Komga
 import eu.kanade.tachiyomi.data.track.mangaupdates.MangaUpdates
@@ -19,6 +20,7 @@ class TrackManager(context: Context) {
         const val BANGUMI = 5L
         const val KOMGA = 6L
         const val MANGA_UPDATES = 7L
+        const val KAVITA = 8L
     }
 
     val myAnimeList = MyAnimeList(context, MYANIMELIST)
@@ -28,8 +30,9 @@ class TrackManager(context: Context) {
     val bangumi = Bangumi(context, BANGUMI)
     val komga = Komga(context, KOMGA)
     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 }
 

+ 146 - 0
app/src/main/java/eu/kanade/tachiyomi/data/track/kavita/Kavita.kt

@@ -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
+    }
+}

+ 157 - 0
app/src/main/java/eu/kanade/tachiyomi/data/track/kavita/KavitaApi.kt

@@ -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)
+    }
+}

+ 26 - 0
app/src/main/java/eu/kanade/tachiyomi/data/track/kavita/KavitaInterceptor.kt

@@ -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)
+    }
+}

+ 70 - 0
app/src/main/java/eu/kanade/tachiyomi/data/track/kavita/KavitaModels.kt

@@ -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 = "",
+)

+ 19 - 0
app/src/main/java/eu/kanade/tachiyomi/data/track/kavita/OAuth.kt

@@ -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
+    }
+}

BIN
app/src/main/res/drawable-nodpi/ic_tracker_kavita.webp


+ 3 - 2
i18n/src/main/res/values/strings.xml

@@ -446,7 +446,8 @@
     <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="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>
 
       <!-- Browse section -->
@@ -672,10 +673,10 @@
     <string name="tracker_myanimelist" translatable="false">MyAnimeList</string>
     <string name="tracker_kitsu" translatable="false">Kitsu</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_shikimori" translatable="false">Shikimori</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>
     <plurals name="num_trackers">
         <item quantity="one">%d tracker</item>