Migrate to Kitsu's GraphQL API (#3792)

While there are still some nasty corners (multiple shapes of error
response, unhandled error types on their end, etc.), this is a
functional, feature-parity replacement for the JSON:API API we were
using before.

According to the GraphQL docs, searching by title still goes via
Algolia.
This commit is contained in:
MajorTanya
2026-08-22 00:46:35 +02:00
committed by GitHub
parent 497e266256
commit 31d3c23717
20 changed files with 704 additions and 401 deletions
@@ -68,7 +68,7 @@ class Kitsu(id: Long) : BaseTracker(id, "Kitsu"), DeletableTracker {
private val interceptor by lazy { KitsuInterceptor(this) }
private val api by lazy { KitsuApi(client, interceptor) }
private val api by lazy { KitsuApi(id, client, interceptor) }
private val scorePreference by lazy { trackPreferences.kitsuScoreType }
@@ -119,7 +119,7 @@ class Kitsu(id: Long) : BaseTracker(id, "Kitsu"), DeletableTracker {
}
private suspend fun add(track: Track): Track {
return api.addLibManga(track, getUserId())
return api.addLibManga(track)
}
override suspend fun update(track: Track, didReadChapter: Boolean): Track {
@@ -145,7 +145,7 @@ class Kitsu(id: Long) : BaseTracker(id, "Kitsu"), DeletableTracker {
}
override suspend fun bind(track: Track, hasReadChapters: Boolean): Track {
val remoteTrack = api.findLibManga(track, getUserId())
val remoteTrack = api.findLibManga(track)
return if (remoteTrack != null) {
track.copyPersonalFrom(remoteTrack, copyRemotePrivate = false)
track.remote_id = remoteTrack.remote_id
@@ -165,7 +165,7 @@ class Kitsu(id: Long) : BaseTracker(id, "Kitsu"), DeletableTracker {
override suspend fun search(query: String): List<TrackSearch> {
if (query.startsWith(SEARCH_ID_PREFIX)) {
query.substringAfter(SEARCH_ID_PREFIX).trim().toIntOrNull()?.let { id ->
query.substringAfter(SEARCH_ID_PREFIX).trim().let { id ->
return api.getMangaDetails(id)?.let { listOf(it) } ?: emptyList()
}
}
@@ -174,7 +174,7 @@ class Kitsu(id: Long) : BaseTracker(id, "Kitsu"), DeletableTracker {
}
override suspend fun refresh(track: Track): Track {
val remoteTrack = api.getLibManga(track)
val remoteTrack = api.findLibManga(track) ?: throw Exception("Could not find manga")
track.copyPersonalFrom(remoteTrack)
track.total_chapters = remoteTrack.total_chapters
return track
@@ -185,14 +185,14 @@ class Kitsu(id: Long) : BaseTracker(id, "Kitsu"), DeletableTracker {
interceptor.newAuth(token)
val currentUser = api.getCurrentUser()
val ratingSystem = currentUser.attributes.ratingSystem
if (ratingSystem in listOf(RATING_SIMPLE, RATING_REGULAR, RATING_ADVANCED)) {
val ratingSystem = currentUser.ratingSystem
if (ratingSystem.lowercase() in listOf(RATING_SIMPLE, RATING_REGULAR, RATING_ADVANCED)) {
scorePreference.set(ratingSystem)
} else {
logcat(LogPriority.ERROR) { "Unsupported Kitsu score type: $ratingSystem" }
scorePreference.set(RATING_ADVANCED)
}
saveDisplayUsername(currentUser.attributes.name)
saveDisplayUsername(currentUser.profile.name)
saveCredentials(username, currentUser.id)
}
@@ -201,10 +201,6 @@ class Kitsu(id: Long) : BaseTracker(id, "Kitsu"), DeletableTracker {
interceptor.newAuth(null)
}
private fun getUserId(): String {
return getPassword()
}
fun saveToken(oauth: KitsuOAuth?) {
trackPreferences.trackToken(this).set(json.encodeToString(oauth))
}
@@ -1,19 +1,17 @@
package eu.kanade.tachiyomi.data.track.kitsu
import androidx.core.net.toUri
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuAccount
import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuAddMangaResult
import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuAlgoliaSearchResult
import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuCurrentUserResult
import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuListSearchResult
import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuCurrentAccountResult
import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuDeleteMangaResult
import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuOAuth
import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuSearchResult
import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuSingleManga
import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuUser
import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuSearchByIdResult
import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuSearchByIdWithLibraryResult
import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuSearchBySlugResult
import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuSearchByTitleResult
import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuUpdateMangaResult
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.network.DELETE
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.HttpException
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.network.awaitSuccess
@@ -23,193 +21,320 @@ import kotlinx.serialization.json.Json
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonObject
import logcat.LogPriority
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 tachiyomi.core.common.util.lang.withIOContext
import tachiyomi.core.common.util.system.logcat
import uy.kohesive.injekt.injectLazy
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import kotlin.time.Instant
import tachiyomi.domain.track.model.Track as DomainTrack
class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor) {
class KitsuApi(
private val trackId: Long,
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 {
suspend fun addLibManga(track: Track): Track {
return withIOContext {
val data = buildJsonObject {
putJsonObject("data") {
put("type", "libraryEntries")
putJsonObject("attributes") {
put("status", track.toApiStatus())
put("progress", track.last_chapter_read.toInt())
put("private", track.private)
}
putJsonObject("relationships") {
putJsonObject("user") {
putJsonObject("data") {
put("id", userId)
put("type", "users")
}
}
putJsonObject("media") {
putJsonObject("data") {
put("id", track.remote_id)
put("type", "manga")
}
}
}
val query = $$"""
|mutation AddManga(
|$media_id: ID!
|$status: LibraryEntryStatusEnum!
|$progress: Int!
|$private: Boolean!
|$rating: Int
|) {
|libraryEntry {
|create(
|input: {
|mediaId: $media_id
|mediaType: MANGA
|status: $status
|progress: $progress
|private: $private
|rating: $rating
|}
|) {
|errors {
|message
|}
|libraryEntry {
|id
|}
|}
|}
|}
""".trimMargin()
val payload = buildJsonObject {
put("query", query)
putJsonObject("variables") {
put("media_id", track.remote_id)
put("status", track.toKitsuApiStatus())
put("progress", track.last_chapter_read.toInt())
put("private", track.private)
put("rating", track.score.toInt().takeIf { it > 0 })
}
}
with(json) {
authClient.newCall(
val parsed = authClient.newCall(
POST(
"${BASE_URL}library-entries",
headers = headersOf("Content-Type", VND_API_JSON),
body = data.toString().toRequestBody(VND_JSON_MEDIA_TYPE),
GRAPHQL_API_URL,
body = payload.toString().toRequestBody(jsonMime),
),
)
.awaitSuccess()
.parseAs<KitsuAddMangaResult>()
.let {
track.library_id = it.data.id
track
if (parsed.error != null) {
logcat(LogPriority.ERROR) { "Failed to add: ${parsed.error.message ?: "(none)"}" }
throw Exception("Failed to add manga")
} else if (parsed.errors != null) {
parsed.errors.forEach {
logcat(LogPriority.ERROR) { "Failed to add: ${it.message ?: "(none)"}" }
}
throw Exception("Failed to add manga")
} else if (parsed.data == null) {
logcat(LogPriority.ERROR) { "Kitsu error, errors, and data null?" }
throw Exception("Encountered unexpected error while adding manga")
}
parsed.data.libraryEntry.create.libraryEntry.id.let {
track.library_id = it.toLong()
track
}
}
}
}
suspend fun updateLibManga(track: Track): Track {
return withIOContext {
val data = buildJsonObject {
putJsonObject("data") {
put("type", "libraryEntries")
put("id", track.library_id)
putJsonObject("attributes") {
put("status", track.toApiStatus())
put("progress", track.last_chapter_read.toInt())
put("ratingTwenty", track.score.takeIf { it > 0 }?.toInt())
put("startedAt", KitsuDateHelper.convert(track.started_reading_date))
put("finishedAt", KitsuDateHelper.convert(track.finished_reading_date))
put("private", track.private)
}
val query = $$"""
|mutation UpdateManga(
|$library_id: ID!
|$status: LibraryEntryStatusEnum!
|$progress: Int!
|$private: Boolean!
|$rating: Int
|$startedAt: ISO8601DateTime
|$finishedAt: ISO8601DateTime
|) {
|libraryEntry {
|update(
|input: {
|id: $library_id
|status: $status
|progress: $progress
|private: $private
|rating: $rating
|startedAt: $startedAt
|finishedAt: $finishedAt
|}
|) {
|errors {
|message
|}
|libraryEntry {
|id
|}
|}
|}
|}
""".trimMargin()
val payload = buildJsonObject {
put("query", query)
putJsonObject("variables") {
put("library_id", track.library_id)
put("status", track.toKitsuApiStatus())
put("progress", track.last_chapter_read.toInt())
put("private", track.private)
put("rating", track.score.toInt().takeIf { it > 0 })
put(
"startedAt",
track.started_reading_date
.takeIf { it > 0 }
?.let { Instant.fromEpochMilliseconds(it).toString() },
)
put(
"finishedAt",
track.finished_reading_date
.takeIf { it > 0 }
?.let { Instant.fromEpochMilliseconds(it).toString() },
)
}
}
authClient.newCall(
Request.Builder()
.url("${BASE_URL}library-entries/${track.library_id}")
.headers(
headersOf("Content-Type", VND_API_JSON),
)
.patch(data.toString().toRequestBody(VND_JSON_MEDIA_TYPE))
.build(),
)
.awaitSuccess()
with(json) {
val parsed = authClient.newCall(
POST(
GRAPHQL_API_URL,
body = payload.toString().toRequestBody(jsonMime),
),
)
.awaitSuccess()
.parseAs<KitsuUpdateMangaResult>()
track
if (parsed.error != null) {
logcat(LogPriority.ERROR) { "Failed to update: ${parsed.error.message ?: "(none)"}" }
throw Exception("Failed to update manga")
} else if (parsed.errors != null) {
parsed.errors.forEach {
logcat(LogPriority.ERROR) { "Failed to update: ${it.message ?: "(none)"}" }
}
throw Exception("Failed to update manga")
} else if (parsed.data == null) {
logcat(LogPriority.ERROR) { "Kitsu error, errors, and data null?" }
throw Exception("Encountered unexpected error while updating manga")
}
track
}
}
}
suspend fun removeLibManga(track: DomainTrack) {
withIOContext {
authClient.newCall(
DELETE(
"${BASE_URL}library-entries/${track.libraryId}",
headers = headersOf("Content-Type", VND_API_JSON),
),
)
.awaitSuccess()
}
}
val query = $$"""|
|mutation DeleteLibEntry(
|$library_id: ID!
|) {
|libraryEntry {
|delete(
|input: {
|id: $library_id
|}
|) {
|errors {
|message
|}
|libraryEntry {
|id
|}
|}
|}
|}
""".trimMargin()
suspend fun search(query: String): List<TrackSearch> {
return withIOContext {
with(json) {
authClient.newCall(GET(ALGOLIA_KEY_URL))
.awaitSuccess()
.parseAs<KitsuSearchResult>()
.let {
algoliaSearch(it.media.key, query)
}
}
}
}
private suspend fun algoliaSearch(key: String, query: String): List<TrackSearch> {
return withIOContext {
val jsonObject = buildJsonObject {
put("params", "query=${URLEncoder.encode(query, StandardCharsets.UTF_8.name())}$ALGOLIA_FILTER")
val payload = buildJsonObject {
put("query", query)
putJsonObject("variables") {
put("library_id", track.libraryId)
}
}
with(json) {
client.newCall(
val parsed = authClient.newCall(
POST(
ALGOLIA_URL,
headers = headersOf(
"X-Algolia-Application-Id",
ALGOLIA_APP_ID,
"X-Algolia-API-Key",
key,
),
body = jsonObject.toString().toRequestBody(jsonMime),
GRAPHQL_API_URL,
body = payload.toString().toRequestBody(jsonMime),
),
)
// Deleting something not in the library returns a 500 with "Couldn't find LibraryEntry" msg
// awaitSuccess would throw with that but user gets their wish of "title not in library" so ignore it
.await()
.parseAs<KitsuDeleteMangaResult>()
if (parsed.error != null) {
logcat(LogPriority.ERROR) { "Failed to delete: ${parsed.error.message ?: "(none)"}" }
if (parsed.error.message != null && parsed.error.message.startsWith("Couldn't find")) {
return@with
}
throw Exception("Failed to delete manga")
} else if (parsed.errors != null) {
parsed.errors.forEach {
logcat(LogPriority.ERROR) { "Failed to delete: ${it.message ?: "(none)"}" }
}
throw Exception("Failed to delete manga")
} else if (parsed.data == null) {
logcat(LogPriority.ERROR) { "Kitsu error, errors, and data null?" }
throw Exception("Encountered unexpected error while deleting manga")
}
}
}
}
suspend fun search(search: String): List<TrackSearch> {
return withIOContext {
val query = $$"""
|query Query($query: String!) {
|searchMangaByTitle(title: $query, first: 20) {
|nodes {
$$COMMON_MANGA_DATA
|}
|}
|}
""".trimMargin()
val payload = buildJsonObject {
put("query", query)
putJsonObject("variables") {
put("query", search)
}
}
with(json) {
authClient.newCall(
POST(
GRAPHQL_API_URL,
body = payload.toString().toRequestBody(jsonMime),
),
)
.awaitSuccess()
.parseAs<KitsuAlgoliaSearchResult>()
.hits
.filter { it.subtype != "novel" }
.map { it.toTrack() }
.parseAs<KitsuSearchByTitleResult>()
.data.searchMangaByTitle.nodes
.map { it.toTrackSearch(trackId) }
}
}
}
suspend fun findLibManga(track: Track, userId: String): Track? {
suspend fun findLibManga(track: Track): Track? {
return withIOContext {
val url = "${BASE_URL}library-entries".toUri().buildUpon()
.encodedQuery("filter[manga_id]=${track.remote_id}&filter[user_id]=$userId")
.appendQueryParameter("include", "manga")
.build()
with(json) {
authClient.newCall(GET(url.toString()))
.awaitSuccess()
.parseAs<KitsuListSearchResult>()
.let {
if (it.data.isNotEmpty() && it.included.isNotEmpty()) {
it.firstToTrack()
} else {
null
}
}
}
}
}
val query = $$"""
|query Query($remote_id: ID!) {
|findMangaById(id: $remote_id) {
|$$COMMON_MANGA_DATA
|myLibraryEntry {
|id
|private
|progress
|rating
|reconsuming
|status
|startedAt
|finishedAt
|}
|}
|}
""".trimMargin()
val payload = buildJsonObject {
put("query", query)
putJsonObject("variables") {
put("remote_id", track.remote_id)
}
}
suspend fun getLibManga(track: Track): Track {
return withIOContext {
val url = "${BASE_URL}library-entries".toUri().buildUpon()
.encodedQuery("filter[id]=${track.library_id}")
.appendQueryParameter("include", "manga")
.build()
with(json) {
authClient.newCall(GET(url.toString()))
authClient.newCall(
POST(
GRAPHQL_API_URL,
body = payload.toString().toRequestBody(jsonMime),
),
)
.awaitSuccess()
.parseAs<KitsuListSearchResult>()
.let {
if (it.data.isNotEmpty() && it.included.isNotEmpty()) {
it.firstToTrack()
} else {
throw Exception("Could not find manga")
}
}
.parseAs<KitsuSearchByIdWithLibraryResult>()
.data.findMangaById
?.toTrackSearch(trackId)
}
}
}
@@ -231,36 +356,87 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
}
}
suspend fun getCurrentUser(): KitsuUser {
suspend fun getCurrentUser(): KitsuAccount {
return withIOContext {
val url = "${BASE_URL}users".toUri().buildUpon()
.encodedQuery("filter[self]=true")
.build()
val query = """
|query Query {
|currentAccount {
|id
|ratingSystem
|profile {
|name
|}
|}
|}
""".trimMargin()
val payload = buildJsonObject {
put("query", query)
}
with(json) {
authClient.newCall(GET(url.toString()))
authClient.newCall(
POST(
GRAPHQL_API_URL,
body = payload.toString().toRequestBody(jsonMime),
),
)
.awaitSuccess()
.parseAs<KitsuCurrentUserResult>()
.data[0]
.parseAs<KitsuCurrentAccountResult>()
.data.currentAccount
}
}
}
suspend fun getMangaDetails(id: Int): TrackSearch? {
suspend fun getMangaDetails(search: String): TrackSearch? {
val isSearchById = search.matches(Regex("\\d+"))
val query = if (isSearchById) {
$$"""
|query Query($query: ID!) {
|findMangaById(id: $query) {
|$$COMMON_MANGA_DATA
|}
|}
"""
} else {
$$"""
|query Query($query: String!) {
|findMangaBySlug(slug: $query) {
|$$COMMON_MANGA_DATA
|}
|}
"""
}
val payload = buildJsonObject {
put("query", query.trimMargin())
putJsonObject("variables") {
put("query", search)
}
}
return withIOContext {
val url = "$BASE_URL/manga/$id"
try {
with(json) {
authClient.newCall(GET(url))
.await()
.parseAs<KitsuSingleManga>()
.toTrackSearch()
}
} catch (e: HttpException) {
if (e.code == 404) {
null
with(json) {
val response = authClient.newCall(
POST(
GRAPHQL_API_URL,
body = payload.toString().toRequestBody(jsonMime),
),
)
.awaitSuccess()
val kitsuManga = if (isSearchById) {
response
.parseAs<KitsuSearchByIdResult>()
.data.findMangaById
} else {
throw e
response
.parseAs<KitsuSearchBySlugResult>()
.data.findMangaBySlug
}
kitsuManga?.toTrackSearch(trackId)
}
}
}
@@ -269,23 +445,8 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
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 GRAPHQL_API_URL = "https://kitsu.app/api/graphql"
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"
private const val VND_API_JSON = "application/vnd.api+json"
private val VND_JSON_MEDIA_TYPE = VND_API_JSON.toMediaType()
fun mangaUrl(remoteId: Long): String {
return BASE_MANGA_URL + remoteId
}
fun refreshTokenRequest(token: String) = POST(
LOGIN_URL,
@@ -296,5 +457,38 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
.add("client_secret", CLIENT_SECRET)
.build(),
)
private val COMMON_MANGA_DATA = """
|id
|titles {
|preferred
|}
|chapterCount
|staff(first: 5) {
|nodes {
|role
|person {
|name
|}
|}
|}
|posterImage {
|views(names: "small") {
|name
|url
|}
|original {
|name
|url
|}
|}
|description(locales: "en")
|status
|subtype
|startDate
|endDate
|slug
|averageRating
""".trimMargin()
}
}
@@ -1,25 +0,0 @@
package eu.kanade.tachiyomi.data.track.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 ?: 0
}
}
@@ -2,7 +2,6 @@ package eu.kanade.tachiyomi.data.track.kitsu
import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuOAuth
import eu.kanade.tachiyomi.data.track.kitsu.dto.isExpired
import kotlinx.serialization.json.Json
import okhttp3.Interceptor
import okhttp3.Response
@@ -2,11 +2,20 @@ package eu.kanade.tachiyomi.data.track.kitsu
import eu.kanade.tachiyomi.data.database.models.Track
fun Track.toApiStatus() = 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.toKitsuApiStatus() = 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: $status")
}
fun String.toKitsuLocalStatus() = when (this) {
"CURRENT" -> Kitsu.READING
"COMPLETED" -> Kitsu.COMPLETED
"ON_HOLD" -> Kitsu.ON_HOLD
"DROPPED" -> Kitsu.DROPPED
"PLANNED" -> Kitsu.PLAN_TO_READ
else -> throw Exception("Unknown status: $this")
}
@@ -4,10 +4,20 @@ import kotlinx.serialization.Serializable
@Serializable
data class KitsuAddMangaResult(
val data: KitsuAddMangaItem,
// yes there are two different error attributes and yes they have different structures
// it seems both are valid in different cases
// (500s send "error" for example, 200s with validation errors send "errors")
val data: KitsuAddMangaData?,
val errors: List<KitsuErrorMessage>?,
val error: KitsuErrorMessage?,
)
@Serializable
data class KitsuAddMangaItem(
val id: Long,
data class KitsuAddMangaData(
val libraryEntry: KitsuAddMangaLibraryEntryWrapper,
)
@Serializable
data class KitsuAddMangaLibraryEntryWrapper(
val create: KitsuLibraryEntryResult,
)
@@ -0,0 +1,18 @@
package eu.kanade.tachiyomi.data.track.kitsu.dto
import kotlinx.serialization.Serializable
@Serializable
data class KitsuErrorMessage(
val message: String?,
)
@Serializable
data class KitsuLibraryEntryResult(
val libraryEntry: KitsuSparseLibraryEntry,
)
@Serializable
data class KitsuSparseLibraryEntry(
val id: String,
)
@@ -0,0 +1,23 @@
package eu.kanade.tachiyomi.data.track.kitsu.dto
import kotlinx.serialization.Serializable
@Serializable
data class KitsuDeleteMangaResult(
// yes there are two different error attributes and yes they have different structures
// it seems both are valid in different cases
// (500s send "error" for example, 200s with validation errors send "errors")
val data: KitsuDeleteMangaData?,
val errors: List<KitsuErrorMessage>?,
val error: KitsuErrorMessage?,
)
@Serializable
data class KitsuDeleteMangaData(
val libraryEntry: KitsuDeleteMangaLibraryEntryWrapper,
)
@Serializable
data class KitsuDeleteMangaLibraryEntryWrapper(
val delete: KitsuLibraryEntryResult,
)
@@ -1,103 +0,0 @@
package eu.kanade.tachiyomi.data.track.kitsu.dto
import eu.kanade.tachiyomi.data.track.TrackerManager
import eu.kanade.tachiyomi.data.track.kitsu.Kitsu
import eu.kanade.tachiyomi.data.track.kitsu.KitsuApi
import eu.kanade.tachiyomi.data.track.kitsu.KitsuDateHelper
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import kotlinx.serialization.Serializable
@Serializable
data class KitsuListSearchResult(
val data: List<KitsuListSearchItemData>,
val included: List<KitsuListSearchItemIncluded> = emptyList(),
) {
fun firstToTrack(): TrackSearch {
require(data.isNotEmpty()) { "Missing User data from Kitsu" }
require(included.isNotEmpty()) { "Missing Manga data from Kitsu" }
val userData = data[0]
val userDataAttrs = userData.attributes
val manga = included[0].attributes
return TrackSearch.create(TrackerManager.KITSU).apply {
remote_id = included[0].id
library_id = userData.id
title = manga.canonicalTitle
total_chapters = manga.chapterCount ?: 0
cover_url = manga.posterImage?.original ?: ""
summary = manga.synopsis ?: ""
tracking_url = KitsuApi.mangaUrl(remote_id)
publishing_status = manga.status
publishing_type = manga.mangaType ?: ""
start_date = userDataAttrs.startedAt ?: ""
started_reading_date = KitsuDateHelper.parse(userDataAttrs.startedAt)
finished_reading_date = KitsuDateHelper.parse(userDataAttrs.finishedAt)
status = when (userDataAttrs.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")
}
score = userDataAttrs.ratingTwenty?.toDouble() ?: 0.0
last_chapter_read = userDataAttrs.progress.toDouble()
private = userDataAttrs.private
}
}
}
@Serializable
data class KitsuListSearchItemData(
val id: Long,
val attributes: KitsuListSearchItemDataAttributes,
)
@Serializable
data class KitsuListSearchItemDataAttributes(
val status: String,
val startedAt: String?,
val finishedAt: String?,
val ratingTwenty: Int?,
val progress: Int,
val private: Boolean,
)
@Serializable
data class KitsuSingleManga(
val data: KitsuListSearchItemIncluded,
) {
fun toTrackSearch(): TrackSearch {
return TrackSearch.create(TrackerManager.KITSU).apply {
remote_id = data.id
title = data.attributes.canonicalTitle
total_chapters = data.attributes.chapterCount ?: 0
cover_url = data.attributes.posterImage?.original ?: ""
summary = data.attributes.synopsis ?: ""
tracking_url = KitsuApi.mangaUrl(remote_id)
score = data.attributes.averageRating?.toDoubleOrNull() ?: -1.0
publishing_status = data.attributes.status
publishing_type = data.attributes.mangaType ?: ""
start_date = data.attributes.startDate ?: ""
}
}
}
@Serializable
data class KitsuListSearchItemIncluded(
val id: Long,
val attributes: KitsuListSearchItemIncludedAttributes,
)
@Serializable
data class KitsuListSearchItemIncludedAttributes(
val canonicalTitle: String,
val chapterCount: Long?,
val mangaType: String?,
val posterImage: KitsuSearchItemCover?,
val synopsis: String?,
val startDate: String?,
val status: String,
val averageRating: String?,
)
@@ -0,0 +1,85 @@
package eu.kanade.tachiyomi.data.track.kitsu.dto
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import kotlinx.serialization.Serializable
@Serializable
data class KitsuManga(
val id: String,
val titles: KitsuMangaTitles,
val chapterCount: Long?,
val staff: KitsuMangaStaffData,
val posterImage: KitsuMangaPosters,
val description: Map<String, String>,
val status: String,
val subtype: String,
val startDate: String?,
val endDate: String?,
val slug: String,
val averageRating: Double?,
) {
fun toTrackSearch(trackId: Long): TrackSearch {
return TrackSearch.create(trackId).apply {
remote_id = this@KitsuManga.id.toLong()
title = titles.preferred
total_chapters = chapterCount ?: 0
cover_url = posterImage.getPosterUrl()
summary = description["en"] ?: ""
tracking_url = "https://kitsu.app/manga/$slug"
score = averageRating ?: -1.0
publishing_status = when (this@KitsuManga.status) {
"TBA" -> "TBA"
"CURRENT" -> "Publishing"
else -> this@KitsuManga.status.lowercase().replaceFirstChar { it.uppercase() }
}
publishing_type = if (subtype != "OEL") {
subtype.lowercase().replaceFirstChar { it.uppercase() }
} else {
subtype
}
start_date = startDate ?: ""
authors = staff.nodes
.filter { it.role.contains("Story") }
.map { it.person.name }
artists = staff.nodes
.filter { it.role.contains("Art") }
.map { it.person.name }
}
}
}
@Serializable
data class KitsuMangaTitles(
val preferred: String,
)
@Serializable
data class KitsuMangaStaffData(
val nodes: List<KitsuMangaStaff>,
)
@Serializable
data class KitsuMangaStaff(
val role: String,
val person: KitsuMangaStaffPerson,
)
@Serializable
data class KitsuMangaStaffPerson(
val name: String,
)
@Serializable
data class KitsuMangaPosters(
val views: List<KitsuMangaPoster>,
val original: KitsuMangaPoster,
) {
// we only ask for the "small" poster in the query
fun getPosterUrl(): String = views.firstOrNull()?.url ?: original.url
}
@Serializable
data class KitsuMangaPoster(
val name: String,
val url: String,
)
@@ -0,0 +1,74 @@
package eu.kanade.tachiyomi.data.track.kitsu.dto
import eu.kanade.tachiyomi.data.track.kitsu.toKitsuLocalStatus
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import kotlinx.serialization.Serializable
import kotlin.time.Instant
// KitsuManga extended with KitsuLibraryEntryData
@Serializable
data class KitsuMangaWithLibraryEntry(
val id: String,
val titles: KitsuMangaTitles,
val chapterCount: Long?,
val staff: KitsuMangaStaffData,
val posterImage: KitsuMangaPosters,
val description: Map<String, String>,
val status: String,
val subtype: String,
val startDate: String?,
val endDate: String?,
val slug: String,
val averageRating: Double?,
val myLibraryEntry: KitsuLibraryEntryData?,
) {
fun toTrackSearch(trackId: Long): TrackSearch? {
if (myLibraryEntry == null) return null
return TrackSearch.create(trackId).apply {
remote_id = this@KitsuMangaWithLibraryEntry.id.toLong()
library_id = myLibraryEntry.id.toLong()
title = titles.preferred
total_chapters = chapterCount ?: 0
cover_url = posterImage.getPosterUrl()
summary = description["en"] ?: ""
tracking_url = "https://kitsu.app/manga/$slug"
publishing_status = when (this@KitsuMangaWithLibraryEntry.status) {
"TBA" -> "TBA"
"CURRENT" -> "Publishing"
else -> this@KitsuMangaWithLibraryEntry.status.lowercase().replaceFirstChar { it.uppercase() }
}
publishing_type = if (subtype != "OEL") {
subtype.lowercase().replaceFirstChar { it.uppercase() }
} else {
subtype
}
start_date = startDate ?: ""
authors = staff.nodes
.filter { it.role.contains("Story") }
.map { it.person.name }
artists = staff.nodes
.filter { it.role.contains("Art") }
.map { it.person.name }
started_reading_date = myLibraryEntry.startedAt?.let { Instant.parse(it).toEpochMilliseconds() } ?: 0
finished_reading_date = myLibraryEntry.finishedAt?.let { Instant.parse(it).toEpochMilliseconds() } ?: 0
status = myLibraryEntry.status.toKitsuLocalStatus()
score = myLibraryEntry.rating?.toDouble() ?: 0.0
last_chapter_read = myLibraryEntry.progress.toDouble()
private = myLibraryEntry.private
}
}
}
@Serializable
data class KitsuLibraryEntryData(
val id: String,
val private: Boolean,
val progress: Long,
val rating: Long?,
val reconsuming: Boolean,
val status: String,
val startedAt: String?,
val finishedAt: String?,
)
@@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.data.track.kitsu.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlin.time.Clock
@Serializable
data class KitsuOAuth(
@@ -15,6 +16,6 @@ data class KitsuOAuth(
val expiresIn: Long,
@SerialName("refresh_token")
val refreshToken: String?,
)
fun KitsuOAuth.isExpired() = (System.currentTimeMillis() / 1000) > (createdAt + expiresIn - 3600)
) {
fun isExpired(): Boolean = (Clock.System.now().toEpochMilliseconds() / 1000) > (createdAt + expiresIn - 3600)
}
@@ -1,55 +0,0 @@
package eu.kanade.tachiyomi.data.track.kitsu.dto
import eu.kanade.tachiyomi.data.track.TrackerManager
import eu.kanade.tachiyomi.data.track.kitsu.KitsuApi
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import kotlinx.serialization.Serializable
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@Serializable
data class KitsuSearchResult(
val media: KitsuSearchResultData,
)
@Serializable
data class KitsuSearchResultData(
val key: String,
)
@Serializable
data class KitsuAlgoliaSearchResult(
val hits: List<KitsuAlgoliaSearchItem>,
)
@Serializable
data class KitsuAlgoliaSearchItem(
val id: Long,
val canonicalTitle: String,
val chapterCount: Long?,
val subtype: String?,
val posterImage: KitsuSearchItemCover?,
val synopsis: String?,
val averageRating: Double?,
val startDate: Long?,
val endDate: Long?,
) {
fun toTrack(): TrackSearch {
return TrackSearch.create(TrackerManager.KITSU).apply {
remote_id = this@KitsuAlgoliaSearchItem.id
title = canonicalTitle
total_chapters = chapterCount ?: 0
cover_url = posterImage?.original ?: ""
summary = synopsis ?: ""
tracking_url = KitsuApi.mangaUrl(remote_id)
score = averageRating ?: -1.0
publishing_status = if (endDate == null) "Publishing" else "Finished"
publishing_type = subtype ?: ""
start_date = startDate?.let {
val outputDf = SimpleDateFormat("yyyy-MM-dd", Locale.US)
outputDf.format(Date(it * 1000))
} ?: ""
}
}
}
@@ -0,0 +1,25 @@
package eu.kanade.tachiyomi.data.track.kitsu.dto
import kotlinx.serialization.Serializable
// normal search
@Serializable
data class KitsuSearchByIdResult(
val data: KitsuSearchByIdData,
)
@Serializable
data class KitsuSearchByIdData(
val findMangaById: KitsuManga?,
)
// findLibManga (on tracker sheet refresh & when checking for remote track on binding)
@Serializable
data class KitsuSearchByIdWithLibraryResult(
val data: KitsuSearchByIdWithLibraryData,
)
@Serializable
data class KitsuSearchByIdWithLibraryData(
val findMangaById: KitsuMangaWithLibraryEntry?,
)
@@ -0,0 +1,13 @@
package eu.kanade.tachiyomi.data.track.kitsu.dto
import kotlinx.serialization.Serializable
@Serializable
data class KitsuSearchBySlugResult(
val data: KitsuSearchBySlugData,
)
@Serializable
data class KitsuSearchBySlugData(
val findMangaBySlug: KitsuManga?,
)
@@ -0,0 +1,18 @@
package eu.kanade.tachiyomi.data.track.kitsu.dto
import kotlinx.serialization.Serializable
@Serializable
data class KitsuSearchByTitleResult(
val data: KitsuSearchByTitleData,
)
@Serializable
data class KitsuSearchByTitleData(
val searchMangaByTitle: KitsuSearchNodes,
)
@Serializable
data class KitsuSearchNodes(
val nodes: List<KitsuManga>,
)
@@ -1,8 +0,0 @@
package eu.kanade.tachiyomi.data.track.kitsu.dto
import kotlinx.serialization.Serializable
@Serializable
data class KitsuSearchItemCover(
val original: String?,
)
@@ -0,0 +1,23 @@
package eu.kanade.tachiyomi.data.track.kitsu.dto
import kotlinx.serialization.Serializable
@Serializable
data class KitsuUpdateMangaResult(
// yes there are two different error attributes and yes they have different structures
// it seems both are valid in different cases
// (500s send "error" for example, 200s with validation errors send "errors")
val data: KitsuUpdateMangaData?,
val errors: List<KitsuErrorMessage>?,
val error: KitsuErrorMessage?,
)
@Serializable
data class KitsuUpdateMangaData(
val libraryEntry: KitsuUpdateMangaLibraryEntryWrapper,
)
@Serializable
data class KitsuUpdateMangaLibraryEntryWrapper(
val update: KitsuLibraryEntryResult,
)
@@ -3,18 +3,23 @@ package eu.kanade.tachiyomi.data.track.kitsu.dto
import kotlinx.serialization.Serializable
@Serializable
data class KitsuCurrentUserResult(
val data: List<KitsuUser>,
data class KitsuCurrentAccountResult(
val data: KitsuCurrentAccountData,
)
@Serializable
data class KitsuUser(
data class KitsuCurrentAccountData(
val currentAccount: KitsuAccount,
)
@Serializable
data class KitsuAccount(
val id: String,
val attributes: KitsuUserAttributes,
val ratingSystem: String,
val profile: KitsuProfile,
)
@Serializable
data class KitsuUserAttributes(
data class KitsuProfile(
val name: String,
val ratingSystem: String,
)