From 92b039fac7b2a74aa15380a171b2f2b73dc47371 Mon Sep 17 00:00:00 2001 From: ThePromidius Date: Fri, 11 Nov 2022 21:19:41 +0100 Subject: [PATCH] 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 * Apply simplified code suggestions from code review Co-authored-by: Andreas * 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 Co-authored-by: Andreas --- .../settings/screen/SettingsTrackingScreen.kt | 19 ++- .../tachiyomi/data/track/TrackManager.kt | 5 +- .../tachiyomi/data/track/kavita/Kavita.kt | 146 ++++++++++++++++ .../tachiyomi/data/track/kavita/KavitaApi.kt | 157 ++++++++++++++++++ .../data/track/kavita/KavitaInterceptor.kt | 26 +++ .../data/track/kavita/KavitaModels.kt | 70 ++++++++ .../tachiyomi/data/track/kavita/OAuth.kt | 19 +++ .../res/drawable-nodpi/ic_tracker_kavita.webp | Bin 0 -> 3586 bytes i18n/src/main/res/values/strings.xml | 5 +- 9 files changed, 443 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/kavita/Kavita.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/kavita/KavitaApi.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/kavita/KavitaInterceptor.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/kavita/KavitaModels.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/kavita/OAuth.kt create mode 100644 app/src/main/res/drawable-nodpi/ic_tracker_kavita.webp diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsTrackingScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsTrackingScreen.kt index 6939777e05..ba04de027f 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsTrackingScreen.kt +++ b/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() + 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)), ), ), diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackManager.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackManager.kt index e37be36222..ac78970b8a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackManager.kt +++ b/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 } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/kavita/Kavita.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/kavita/Kavita.kt new file mode 100644 index 0000000000..6a308e9dbd --- /dev/null +++ b/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 = 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 { + 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().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 + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/kavita/KavitaApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/kavita/KavitaApi.kt new file mode 100644 index 0000000000..fb351a12ca --- /dev/null +++ b/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().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>() + 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().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() + + 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) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/kavita/KavitaInterceptor.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/kavita/KavitaInterceptor.kt new file mode 100644 index 0000000000..8b26c3e028 --- /dev/null +++ b/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) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/kavita/KavitaModels.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/kavita/KavitaModels.kt new file mode 100644 index 0000000000..abcc4d9c13 --- /dev/null +++ b/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 = 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 = "", +) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/kavita/OAuth.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/kavita/OAuth.kt new file mode 100644 index 0000000000..426ba99b0d --- /dev/null +++ b/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 = listOf( + SourceAuth(1), + SourceAuth(2), + SourceAuth(3), + ), +) { + + fun getToken(apiUrl: String): String? { + for (authentication in authentications) { + if (authentication.apiUrl == apiUrl) { + return authentication.jwtToken + } + } + return null + } +} diff --git a/app/src/main/res/drawable-nodpi/ic_tracker_kavita.webp b/app/src/main/res/drawable-nodpi/ic_tracker_kavita.webp new file mode 100644 index 0000000000000000000000000000000000000000..7ab37be31dff35846de4b0ccf590d5d54fa1e2c6 GIT binary patch literal 3586 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 One-way sync to update the chapter progress in tracking services. Set up tracking for individual entries from their tracking button. Enhanced services - Services that provide enhanced features for specific sources. Entries are automatically tracked when added to your library. + Services that provide enhanced features for specific sources. Manga are automatically tracked when added to your library. + This tracker is only compatible with the %1$s source. Track @@ -672,10 +673,10 @@ MyAnimeList Kitsu Komga - This tracker is only compatible with the Komga source. Bangumi Shikimori MangaUpdates + Kavita Tracking %d tracker