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 00000000..5f53b48a Binary files /dev/null and b/server/src/main/resources/static/tracker/kitsu.png differ