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 6939777e0..ba04de027 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 e37be3622..ac78970b8 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 000000000..6a308e9db --- /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 000000000..fb351a12c --- /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 000000000..8b26c3e02 --- /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 000000000..abcc4d9c1 --- /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 000000000..426ba99b0 --- /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 000000000..7ab37be31 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_tracker_kavita.webp differ diff --git a/i18n/src/main/res/values/strings.xml b/i18n/src/main/res/values/strings.xml index 6df424e35..834850435 100644 --- a/i18n/src/main/res/values/strings.xml +++ b/i18n/src/main/res/values/strings.xml @@ -446,7 +446,8 @@ Services 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