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
This commit is contained in:
Constantin Piber
2025-03-08 17:30:59 +01:00
committed by GitHub
parent cb498e2128
commit 3be165a551
7 changed files with 712 additions and 2 deletions
@@ -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<Tracker> = listOf(myAnimeList, aniList, mangaUpdates)
val services: List<Tracker> = listOf(myAnimeList, aniList, kitsu, mangaUpdates)
fun getTracker(id: Int) = services.find { it.id == id }
@@ -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<Int> = 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<String> {
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<TrackSearch> = 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<OAuth>(trackPreferences.getTrackToken(this)!!)
} catch (e: Exception) {
null
}
}
@@ -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<JsonObject>()
.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<JsonObject>()
.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<TrackSearch> =
withIOContext {
with(json) {
authClient
.newCall(GET(ALGOLIA_KEY_URL))
.awaitSuccess()
.parseAs<JsonObject>()
.let {
val key = it["media"]!!.jsonObject["key"]!!.jsonPrimitive.content
algoliaSearch(key, query)
}
}
}
private suspend fun algoliaSearch(
key: String,
query: String,
): List<TrackSearch> =
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<JsonObject>()
.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<JsonObject>()
.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<JsonObject>()
.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<JsonObject>()
.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(),
)
}
}
@@ -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
}
}
@@ -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)
}
}
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB