From 3be165a5514af306398d542118da40fab16da411 Mon Sep 17 00:00:00 2001 From: Constantin Piber <59023762+cpiber@users.noreply.github.com> Date: Sat, 8 Mar 2025 17:30:59 +0100 Subject: [PATCH] Initial import of Kitsu tracker (#1297) * Initial import of Kitsu tracker Based on Mihon 6c6ea84509cc1bd859c880bebbc69067a241b358 because its successor 9f99f03 relies on incompatible changes * Kitsu: Avoid stupid long/int cast --- .../impl/track/tracker/TrackerManager.kt | 6 +- .../manga/impl/track/tracker/kitsu/Kitsu.kt | 154 ++++++++ .../impl/track/tracker/kitsu/KitsuApi.kt | 330 ++++++++++++++++++ .../track/tracker/kitsu/KitsuDateHelper.kt | 24 ++ .../track/tracker/kitsu/KitsuInterceptor.kt | 53 +++ .../impl/track/tracker/kitsu/KitsuModels.kt | 147 ++++++++ .../main/resources/static/tracker/kitsu.png | Bin 0 -> 25572 bytes 7 files changed, 712 insertions(+), 2 deletions(-) create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/kitsu/Kitsu.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/kitsu/KitsuApi.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/kitsu/KitsuDateHelper.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/kitsu/KitsuInterceptor.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/kitsu/KitsuModels.kt create mode 100644 server/src/main/resources/static/tracker/kitsu.png diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/TrackerManager.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/TrackerManager.kt index feab34a3..6ca4ebca 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/TrackerManager.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/TrackerManager.kt @@ -1,6 +1,7 @@ package suwayomi.tachidesk.manga.impl.track.tracker import suwayomi.tachidesk.manga.impl.track.tracker.anilist.Anilist +import suwayomi.tachidesk.manga.impl.track.tracker.kitsu.Kitsu import suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.MangaUpdates import suwayomi.tachidesk.manga.impl.track.tracker.myanimelist.MyAnimeList @@ -18,7 +19,8 @@ object TrackerManager { val myAnimeList = MyAnimeList(MYANIMELIST) val aniList = Anilist(ANILIST) -// val kitsu = Kitsu(KITSU) + val kitsu = Kitsu(KITSU) + // val shikimori = Shikimori(SHIKIMORI) // val bangumi = Bangumi(BANGUMI) // val komga = Komga(KOMGA) @@ -26,7 +28,7 @@ object TrackerManager { // val kavita = Kavita(context, KAVITA) // val suwayomi = Suwayomi(SUWAYOMI) - val services: List = listOf(myAnimeList, aniList, mangaUpdates) + val services: List = listOf(myAnimeList, aniList, kitsu, mangaUpdates) fun getTracker(id: Int) = services.find { it.id == id } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/kitsu/Kitsu.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/kitsu/Kitsu.kt new file mode 100644 index 00000000..1ec59f65 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/kitsu/Kitsu.kt @@ -0,0 +1,154 @@ +package suwayomi.tachidesk.manga.impl.track.tracker.kitsu + +import android.annotation.StringRes +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import suwayomi.tachidesk.manga.impl.track.tracker.DeletableTrackService +import suwayomi.tachidesk.manga.impl.track.tracker.Tracker +import suwayomi.tachidesk.manga.impl.track.tracker.model.Track +import suwayomi.tachidesk.manga.impl.track.tracker.model.TrackSearch +import uy.kohesive.injekt.injectLazy +import java.text.DecimalFormat + +class Kitsu( + id: Int, +) : Tracker(id, "Kitsu"), + DeletableTrackService { + companion object { + const val READING = 1 + const val COMPLETED = 2 + const val ON_HOLD = 3 + const val DROPPED = 4 + const val PLAN_TO_READ = 5 + } + + override val supportsTrackDeletion: Boolean = true + + override val supportsReadingDates: Boolean = true + + private val json: Json by injectLazy() + + private val interceptor by lazy { KitsuInterceptor(this) } + + private val api by lazy { KitsuApi(client, interceptor) } + + override fun getLogo(): String = "/static/tracker/kitsu.png" + + override fun getStatusList(): List = listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLAN_TO_READ) + + @StringRes + override fun getStatus(status: Int): String? = + when (status) { + READING -> "Reading" + PLAN_TO_READ -> "Plan to read" + COMPLETED -> "Completed" + ON_HOLD -> "On hold" + DROPPED -> "Dropped" + else -> null + } + + override fun getReadingStatus(): Int = READING + + override fun getRereadingStatus(): Int = -1 + + override fun getCompletionStatus(): Int = COMPLETED + + override fun getScoreList(): List { + val df = DecimalFormat("0.#") + return listOf("0") + IntRange(2, 20).map { df.format(it / 2f) } + } + + override fun indexToScore(index: Int): Float = if (index > 0) (index + 1) / 2.0f else 0.0f + + override fun displayScore(track: Track): String { + val df = DecimalFormat("0.#") + return df.format(track.score) + } + + private suspend fun add(track: Track): Track = api.addLibManga(track, getUserId()) + + 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 + track.finished_reading_date = System.currentTimeMillis() + } else { + track.status = READING + if (track.last_chapter_read == 1.0f) { + track.started_reading_date = System.currentTimeMillis() + } + } + } + } + + return api.updateLibManga(track) + } + + override suspend fun delete(track: Track) { + api.removeLibManga(track) + } + + override suspend fun bind( + track: Track, + hasReadChapters: Boolean, + ): Track { + val remoteTrack = api.findLibManga(track, getUserId()) + return if (remoteTrack != null) { + track.copyPersonalFrom(remoteTrack) + track.media_id = remoteTrack.media_id + + if (track.status != COMPLETED) { + track.status = if (hasReadChapters) READING else track.status + } + + update(track) + } else { + track.status = if (hasReadChapters) READING else PLAN_TO_READ + track.score = 0.0f + add(track) + } + } + + override suspend fun search(query: String): List = api.search(query) + + override suspend fun refresh(track: Track): Track { + val remoteTrack = api.getLibManga(track) + track.copyPersonalFrom(remoteTrack) + track.total_chapters = remoteTrack.total_chapters + return track + } + + override suspend fun login( + username: String, + password: String, + ) { + val token = api.login(username, password) + interceptor.newAuth(token) + val userId = api.getCurrentUser() + saveCredentials(username, userId) + } + + override fun logout() { + super.logout() + interceptor.newAuth(null) + } + + private fun getUserId(): String = getPassword() + + // TODO: this seems to be called saveOAuth in other trackers + fun saveToken(oauth: OAuth?) { + trackPreferences.setTrackToken(this, json.encodeToString(oauth)) + } + + // TODO: this seems to be called loadOAuth in other trackers + fun restoreToken(): OAuth? = + try { + json.decodeFromString(trackPreferences.getTrackToken(this)!!) + } catch (e: Exception) { + null + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/kitsu/KitsuApi.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/kitsu/KitsuApi.kt new file mode 100644 index 00000000..6aee6e40 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/kitsu/KitsuApi.kt @@ -0,0 +1,330 @@ +package suwayomi.tachidesk.manga.impl.track.tracker.kitsu + +import androidx.core.net.toUri +import eu.kanade.tachiyomi.network.DELETE +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.POST +import eu.kanade.tachiyomi.network.awaitSuccess +import eu.kanade.tachiyomi.network.jsonMime +import eu.kanade.tachiyomi.network.parseAs +import eu.kanade.tachiyomi.util.lang.withIOContext +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.long +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonObject +import okhttp3.FormBody +import okhttp3.Headers.Companion.headersOf +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import suwayomi.tachidesk.manga.impl.track.tracker.model.Track +import suwayomi.tachidesk.manga.impl.track.tracker.model.TrackSearch +import uy.kohesive.injekt.injectLazy +import java.net.URLEncoder +import java.nio.charset.StandardCharsets + +class KitsuApi( + private val client: OkHttpClient, + interceptor: KitsuInterceptor, +) { + private val json: Json by injectLazy() + + private val authClient = client.newBuilder().addInterceptor(interceptor).build() + + suspend fun addLibManga( + track: Track, + userId: String, + ): Track = + withIOContext { + val data = + buildJsonObject { + putJsonObject("data") { + put("type", "libraryEntries") + putJsonObject("attributes") { + put("status", track.toKitsuStatus()) + put("progress", track.last_chapter_read.toInt()) + } + putJsonObject("relationships") { + putJsonObject("user") { + putJsonObject("data") { + put("id", userId) + put("type", "users") + } + } + putJsonObject("media") { + putJsonObject("data") { + put("id", track.media_id) + put("type", "manga") + } + } + } + } + } + + with(json) { + authClient + .newCall( + POST( + "${BASE_URL}library-entries", + headers = + headersOf( + "Content-Type", + "application/vnd.api+json", + ), + body = + data + .toString() + .toRequestBody("application/vnd.api+json".toMediaType()), + ), + ).awaitSuccess() + .parseAs() + .let { + track.media_id = it["data"]!!.jsonObject["id"]!!.jsonPrimitive.long + track + } + } + } + + suspend fun updateLibManga(track: Track): Track = + withIOContext { + val data = + buildJsonObject { + putJsonObject("data") { + put("type", "libraryEntries") + put("id", track.media_id) + putJsonObject("attributes") { + put("status", track.toKitsuStatus()) + put("progress", track.last_chapter_read.toInt()) + put("ratingTwenty", track.toKitsuScore()) + put("startedAt", KitsuDateHelper.convert(track.started_reading_date)) + put("finishedAt", KitsuDateHelper.convert(track.finished_reading_date)) + } + } + } + + with(json) { + authClient + .newCall( + Request + .Builder() + .url("${BASE_URL}library-entries/${track.media_id}") + .headers( + headersOf( + "Content-Type", + "application/vnd.api+json", + ), + ).patch( + data.toString().toRequestBody("application/vnd.api+json".toMediaType()), + ).build(), + ).awaitSuccess() + .parseAs() + .let { + track + } + } + } + + suspend fun removeLibManga(track: Track) { + withIOContext { + authClient + .newCall( + DELETE( + "${BASE_URL}library-entries/${track.media_id}", + headers = + headersOf( + "Content-Type", + "application/vnd.api+json", + ), + ), + ).awaitSuccess() + } + } + + suspend fun search(query: String): List = + withIOContext { + with(json) { + authClient + .newCall(GET(ALGOLIA_KEY_URL)) + .awaitSuccess() + .parseAs() + .let { + val key = it["media"]!!.jsonObject["key"]!!.jsonPrimitive.content + algoliaSearch(key, query) + } + } + } + + private suspend fun algoliaSearch( + key: String, + query: String, + ): List = + withIOContext { + val jsonObject = + buildJsonObject { + put("params", "query=${URLEncoder.encode(query, StandardCharsets.UTF_8.name())}$ALGOLIA_FILTER") + } + + with(json) { + client + .newCall( + POST( + ALGOLIA_URL, + headers = + headersOf( + "X-Algolia-Application-Id", + ALGOLIA_APP_ID, + "X-Algolia-API-Key", + key, + ), + body = jsonObject.toString().toRequestBody(jsonMime), + ), + ).awaitSuccess() + .parseAs() + .let { + it["hits"]!! + .jsonArray + .map { KitsuSearchManga(it.jsonObject) } + .filter { it.subType != "novel" } + .map { it.toTrack() } + } + } + } + + suspend fun findLibManga( + track: Track, + userId: String, + ): Track? = + withIOContext { + val url = + "${BASE_URL}library-entries" + .toUri() + .buildUpon() + .encodedQuery("filter[manga_id]=${track.media_id}&filter[user_id]=$userId") + .appendQueryParameter("include", "manga") + .build() + with(json) { + authClient + .newCall(GET(url.toString())) + .awaitSuccess() + .parseAs() + .let { + val data = it["data"]!!.jsonArray + if (data.size > 0) { + val manga = it["included"]!!.jsonArray[0].jsonObject + KitsuLibManga(data[0].jsonObject, manga).toTrack() + } else { + null + } + } + } + } + + suspend fun getLibManga(track: Track): Track = + withIOContext { + val url = + "${BASE_URL}library-entries" + .toUri() + .buildUpon() + .encodedQuery("filter[id]=${track.media_id}") + .appendQueryParameter("include", "manga") + .build() + with(json) { + authClient + .newCall(GET(url.toString())) + .awaitSuccess() + .parseAs() + .let { + val data = it["data"]!!.jsonArray + if (data.size > 0) { + val manga = it["included"]!!.jsonArray[0].jsonObject + KitsuLibManga(data[0].jsonObject, manga).toTrack() + } else { + throw Exception("Could not find manga") + } + } + } + } + + suspend fun login( + username: String, + password: String, + ): OAuth = + withIOContext { + val formBody: RequestBody = + FormBody + .Builder() + .add("username", username) + .add("password", password) + .add("grant_type", "password") + .add("client_id", CLIENT_ID) + .add("client_secret", CLIENT_SECRET) + .build() + with(json) { + client + .newCall(POST(LOGIN_URL, body = formBody)) + .awaitSuccess() + .parseAs() + } + } + + suspend fun getCurrentUser(): String = + withIOContext { + val url = + "${BASE_URL}users" + .toUri() + .buildUpon() + .encodedQuery("filter[self]=true") + .build() + with(json) { + authClient + .newCall(GET(url.toString())) + .awaitSuccess() + .parseAs() + .let { + it["data"]!! + .jsonArray[0] + .jsonObject["id"]!! + .jsonPrimitive.content + } + } + } + + companion object { + private const val CLIENT_ID = "dd031b32d2f56c990b1425efe6c42ad847e7fe3ab46bf1299f05ecd856bdb7dd" + private const val CLIENT_SECRET = "54d7307928f63414defd96399fc31ba847961ceaecef3a5fd93144e960c0e151" + + private const val BASE_URL = "https://kitsu.app/api/edge/" + private const val LOGIN_URL = "https://kitsu.app/api/oauth/token" + private const val BASE_MANGA_URL = "https://kitsu.app/manga/" + private const val ALGOLIA_KEY_URL = "https://kitsu.app/api/edge/algolia-keys/media/" + + private const val ALGOLIA_APP_ID = "AWQO5J657S" + private const val ALGOLIA_URL = "https://$ALGOLIA_APP_ID-dsn.algolia.net/1/indexes/production_media/query/" + private const val ALGOLIA_FILTER = + "&facetFilters=%5B%22kind%3Amanga%22%5D&attributesToRetrieve=" + + "%5B%22synopsis%22%2C%22averageRating%22%2C%22canonicalTitle%22%2C%22chapterCount%22%2C%22" + + "posterImage%22%2C%22startDate%22%2C%22subtype%22%2C%22endDate%22%2C%20%22id%22%5D" + + fun mangaUrl(remoteId: Long): String = BASE_MANGA_URL + remoteId + + fun refreshTokenRequest(token: String) = + POST( + LOGIN_URL, + body = + FormBody + .Builder() + .add("grant_type", "refresh_token") + .add("refresh_token", token) + .add("client_id", CLIENT_ID) + .add("client_secret", CLIENT_SECRET) + .build(), + ) + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/kitsu/KitsuDateHelper.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/kitsu/KitsuDateHelper.kt new file mode 100644 index 00000000..bcbf1601 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/kitsu/KitsuDateHelper.kt @@ -0,0 +1,24 @@ +package suwayomi.tachidesk.manga.impl.track.tracker.kitsu + +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +object KitsuDateHelper { + private const val PATTERN = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'" + private val formatter = SimpleDateFormat(PATTERN, Locale.ENGLISH) + + fun convert(dateValue: Long): String? { + if (dateValue == 0L) return null + + return formatter.format(Date(dateValue)) + } + + fun parse(dateString: String?): Long { + if (dateString == null) return 0L + + val dateValue = formatter.parse(dateString) + + return dateValue?.time ?: return 0 + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/kitsu/KitsuInterceptor.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/kitsu/KitsuInterceptor.kt new file mode 100644 index 00000000..c5f4265e --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/kitsu/KitsuInterceptor.kt @@ -0,0 +1,53 @@ +package suwayomi.tachidesk.manga.impl.track.tracker.kitsu + +import kotlinx.serialization.json.Json +import okhttp3.Interceptor +import okhttp3.Response +import suwayomi.tachidesk.server.generated.BuildConfig +import uy.kohesive.injekt.injectLazy + +class KitsuInterceptor( + private val kitsu: Kitsu, +) : Interceptor { + private val json: Json by injectLazy() + + /** + * OAuth object used for authenticated requests. + */ + private var oauth: OAuth? = kitsu.restoreToken() + + override fun intercept(chain: Interceptor.Chain): Response { + val originalRequest = chain.request() + + val currAuth = oauth ?: throw Exception("Not authenticated with Kitsu") + + val refreshToken = currAuth.refresh_token!! + + // Refresh access token if expired. + if (currAuth.isExpired()) { + val response = chain.proceed(KitsuApi.refreshTokenRequest(refreshToken)) + if (response.isSuccessful) { + newAuth(json.decodeFromString(response.body.string())) + } else { + response.close() + } + } + + // Add the authorization header to the original request. + val authRequest = + originalRequest + .newBuilder() + .addHeader("Authorization", "Bearer ${oauth!!.access_token}") + .header("User-Agent", "Suwayomi ${BuildConfig.VERSION} (${BuildConfig.REVISION})") + .header("Accept", "application/vnd.api+json") + .header("Content-Type", "application/vnd.api+json") + .build() + + return chain.proceed(authRequest) + } + + fun newAuth(oauth: OAuth?) { + this.oauth = oauth + kitsu.saveToken(oauth) + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/kitsu/KitsuModels.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/kitsu/KitsuModels.kt new file mode 100644 index 00000000..93304cd4 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/kitsu/KitsuModels.kt @@ -0,0 +1,147 @@ +package suwayomi.tachidesk.manga.impl.track.tracker.kitsu + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.int +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.long +import suwayomi.tachidesk.manga.impl.track.tracker.TrackerManager +import suwayomi.tachidesk.manga.impl.track.tracker.model.Track +import suwayomi.tachidesk.manga.impl.track.tracker.model.TrackSearch +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +class KitsuSearchManga( + obj: JsonObject, +) { + val id = obj["id"]!!.jsonPrimitive.long + private val canonicalTitle = obj["canonicalTitle"]!!.jsonPrimitive.content + private val chapterCount = obj["chapterCount"]?.jsonPrimitive?.intOrNull + val subType = obj["subtype"]?.jsonPrimitive?.contentOrNull + val original = + try { + obj["posterImage"] + ?.jsonObject + ?.get("original") + ?.jsonPrimitive + ?.content + } catch (e: IllegalArgumentException) { + // posterImage is sometimes a jsonNull object instead + null + } + private val synopsis = obj["synopsis"]?.jsonPrimitive?.contentOrNull + private val rating = obj["averageRating"]?.jsonPrimitive?.contentOrNull?.toDoubleOrNull() + private var startDate = + obj["startDate"]?.jsonPrimitive?.contentOrNull?.let { + val outputDf = SimpleDateFormat("yyyy-MM-dd", Locale.US) + outputDf.format(Date(it.toLong() * 1000)) + } + private val endDate = obj["endDate"]?.jsonPrimitive?.contentOrNull + + fun toTrack() = + TrackSearch.create(TrackerManager.KITSU).apply { + media_id = this@KitsuSearchManga.id + title = canonicalTitle + total_chapters = chapterCount ?: 0 + cover_url = original ?: "" + summary = synopsis ?: "" + tracking_url = KitsuApi.mangaUrl(media_id) + // score = rating ?: -1.0 + publishing_status = + if (endDate == null) { + "Publishing" + } else { + "Finished" + } + publishing_type = subType ?: "" + start_date = startDate ?: "" + } +} + +class KitsuLibManga( + obj: JsonObject, + manga: JsonObject, +) { + val id = manga["id"]!!.jsonPrimitive.int + private val canonicalTitle = manga["attributes"]!!.jsonObject["canonicalTitle"]!!.jsonPrimitive.content + private val chapterCount = manga["attributes"]!!.jsonObject["chapterCount"]?.jsonPrimitive?.intOrNull + val type = + manga["attributes"]!! + .jsonObject["mangaType"] + ?.jsonPrimitive + ?.contentOrNull + .orEmpty() + val original = + manga["attributes"]!! + .jsonObject["posterImage"]!! + .jsonObject["original"]!! + .jsonPrimitive.content + private val synopsis = manga["attributes"]!!.jsonObject["synopsis"]!!.jsonPrimitive.content + private val startDate = + manga["attributes"]!! + .jsonObject["startDate"] + ?.jsonPrimitive + ?.contentOrNull + .orEmpty() + private val startedAt = obj["attributes"]!!.jsonObject["startedAt"]?.jsonPrimitive?.contentOrNull + private val finishedAt = obj["attributes"]!!.jsonObject["finishedAt"]?.jsonPrimitive?.contentOrNull + private val libraryId = obj["id"]!!.jsonPrimitive.long + val status = obj["attributes"]!!.jsonObject["status"]!!.jsonPrimitive.content + private val ratingTwenty = obj["attributes"]!!.jsonObject["ratingTwenty"]?.jsonPrimitive?.contentOrNull + val progress = obj["attributes"]!!.jsonObject["progress"]!!.jsonPrimitive.int + + fun toTrack() = + Track.create(TrackerManager.KITSU).apply { + media_id = libraryId + title = canonicalTitle + total_chapters = chapterCount ?: 0 + // cover_url = original + // summary = synopsis + tracking_url = KitsuApi.mangaUrl(media_id) + // publishing_status = this@KitsuLibManga.status + // publishing_type = type + // start_date = startDate + started_reading_date = KitsuDateHelper.parse(startedAt) + finished_reading_date = KitsuDateHelper.parse(finishedAt) + status = toTrackStatus() + score = ratingTwenty?.let { it.toInt() / 2.0f } ?: 0.0f + last_chapter_read = progress.toFloat() + } + + private fun toTrackStatus() = + when (status) { + "current" -> Kitsu.READING + "completed" -> Kitsu.COMPLETED + "on_hold" -> Kitsu.ON_HOLD + "dropped" -> Kitsu.DROPPED + "planned" -> Kitsu.PLAN_TO_READ + else -> throw Exception("Unknown status") + } +} + +@Serializable +data class OAuth( + val access_token: String, + val token_type: String, + val created_at: Long, + val expires_in: Long, + val refresh_token: String?, +) + +fun OAuth.isExpired() = (System.currentTimeMillis() / 1000) > (created_at + expires_in - 3600) + +fun Track.toKitsuStatus() = + when (status) { + Kitsu.READING -> "current" + Kitsu.COMPLETED -> "completed" + Kitsu.ON_HOLD -> "on_hold" + Kitsu.DROPPED -> "dropped" + Kitsu.PLAN_TO_READ -> "planned" + else -> throw Exception("Unknown status") + } + +fun Track.toKitsuScore(): String? = if (score > 0) (score * 2).toInt().toString() else null diff --git a/server/src/main/resources/static/tracker/kitsu.png b/server/src/main/resources/static/tracker/kitsu.png new file mode 100644 index 0000000000000000000000000000000000000000..5f53b48ab7cc4f377852ca79f570c68c49d484a4 GIT binary patch literal 25572 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 Z;Mq4qqt{GljX=QfKOIBus=E%+{{x2uzfu4I literal 0 HcmV?d00001