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
+1
View File
@@ -13,6 +13,7 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co
## [Unreleased] ## [Unreleased]
### Added ### Added
- Add `id:` prefix search to remaining trackers (AniList, Bangumi, Kitsu, MangaUpdates, Shikimori, and Hikka) ([@MajorTanya](https://github.com/MajorTanya)) ([#3776](https://github.com/mihonapp/mihon/pull/3776)) - Add `id:` prefix search to remaining trackers (AniList, Bangumi, Kitsu, MangaUpdates, Shikimori, and Hikka) ([@MajorTanya](https://github.com/MajorTanya)) ([#3776](https://github.com/mihonapp/mihon/pull/3776))
- Allow `id:` to search for slugs on Kitsu ([@MajorTanya](https://github.com/MajorTanya)) ([#3792](https://github.com/mihonapp/mihon/pull/3792))
- Add support for using the user's chosen rating system for Kitsu ([@MajorTanya](https://github.com/MajorTanya)) ([#3818](https://github.com/mihonapp/mihon/pull/3818)) - Add support for using the user's chosen rating system for Kitsu ([@MajorTanya](https://github.com/MajorTanya)) ([#3818](https://github.com/mihonapp/mihon/pull/3818))
### Improved ### Improved
@@ -68,7 +68,7 @@ class Kitsu(id: Long) : BaseTracker(id, "Kitsu"), DeletableTracker {
private val interceptor by lazy { KitsuInterceptor(this) } 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 } 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 { 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 { 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 { override suspend fun bind(track: Track, hasReadChapters: Boolean): Track {
val remoteTrack = api.findLibManga(track, getUserId()) val remoteTrack = api.findLibManga(track)
return if (remoteTrack != null) { return if (remoteTrack != null) {
track.copyPersonalFrom(remoteTrack, copyRemotePrivate = false) track.copyPersonalFrom(remoteTrack, copyRemotePrivate = false)
track.remote_id = remoteTrack.remote_id 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> { override suspend fun search(query: String): List<TrackSearch> {
if (query.startsWith(SEARCH_ID_PREFIX)) { 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() 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 { 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.copyPersonalFrom(remoteTrack)
track.total_chapters = remoteTrack.total_chapters track.total_chapters = remoteTrack.total_chapters
return track return track
@@ -185,14 +185,14 @@ class Kitsu(id: Long) : BaseTracker(id, "Kitsu"), DeletableTracker {
interceptor.newAuth(token) interceptor.newAuth(token)
val currentUser = api.getCurrentUser() val currentUser = api.getCurrentUser()
val ratingSystem = currentUser.attributes.ratingSystem val ratingSystem = currentUser.ratingSystem
if (ratingSystem in listOf(RATING_SIMPLE, RATING_REGULAR, RATING_ADVANCED)) { if (ratingSystem.lowercase() in listOf(RATING_SIMPLE, RATING_REGULAR, RATING_ADVANCED)) {
scorePreference.set(ratingSystem) scorePreference.set(ratingSystem)
} else { } else {
logcat(LogPriority.ERROR) { "Unsupported Kitsu score type: $ratingSystem" } logcat(LogPriority.ERROR) { "Unsupported Kitsu score type: $ratingSystem" }
scorePreference.set(RATING_ADVANCED) scorePreference.set(RATING_ADVANCED)
} }
saveDisplayUsername(currentUser.attributes.name) saveDisplayUsername(currentUser.profile.name)
saveCredentials(username, currentUser.id) saveCredentials(username, currentUser.id)
} }
@@ -201,10 +201,6 @@ class Kitsu(id: Long) : BaseTracker(id, "Kitsu"), DeletableTracker {
interceptor.newAuth(null) interceptor.newAuth(null)
} }
private fun getUserId(): String {
return getPassword()
}
fun saveToken(oauth: KitsuOAuth?) { fun saveToken(oauth: KitsuOAuth?) {
trackPreferences.trackToken(this).set(json.encodeToString(oauth)) trackPreferences.trackToken(this).set(json.encodeToString(oauth))
} }
@@ -1,19 +1,17 @@
package eu.kanade.tachiyomi.data.track.kitsu 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.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.KitsuAddMangaResult
import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuAlgoliaSearchResult import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuCurrentAccountResult
import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuCurrentUserResult import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuDeleteMangaResult
import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuListSearchResult
import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuOAuth 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.KitsuSearchByIdResult
import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuSingleManga import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuSearchByIdWithLibraryResult
import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuUser 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.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.POST
import eu.kanade.tachiyomi.network.await import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.network.awaitSuccess import eu.kanade.tachiyomi.network.awaitSuccess
@@ -23,193 +21,320 @@ import kotlinx.serialization.json.Json
import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonObject import kotlinx.serialization.json.putJsonObject
import logcat.LogPriority
import okhttp3.FormBody import okhttp3.FormBody
import okhttp3.Headers.Companion.headersOf
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import tachiyomi.core.common.util.lang.withIOContext import tachiyomi.core.common.util.lang.withIOContext
import tachiyomi.core.common.util.system.logcat
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.net.URLEncoder import kotlin.time.Instant
import java.nio.charset.StandardCharsets
import tachiyomi.domain.track.model.Track as DomainTrack 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 json: Json by injectLazy()
private val authClient = client.newBuilder().addInterceptor(interceptor).build() private val authClient = client.newBuilder().addInterceptor(interceptor).build()
suspend fun addLibManga(track: Track, userId: String): Track { suspend fun addLibManga(track: Track): Track {
return withIOContext { return withIOContext {
val data = buildJsonObject { val query = $$"""
putJsonObject("data") { |mutation AddManga(
put("type", "libraryEntries") |$media_id: ID!
putJsonObject("attributes") { |$status: LibraryEntryStatusEnum!
put("status", track.toApiStatus()) |$progress: Int!
put("progress", track.last_chapter_read.toInt()) |$private: Boolean!
put("private", track.private) |$rating: Int
} |) {
putJsonObject("relationships") { |libraryEntry {
putJsonObject("user") { |create(
putJsonObject("data") { |input: {
put("id", userId) |mediaId: $media_id
put("type", "users") |mediaType: MANGA
} |status: $status
} |progress: $progress
putJsonObject("media") { |private: $private
putJsonObject("data") { |rating: $rating
put("id", track.remote_id) |}
put("type", "manga") |) {
} |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) { with(json) {
authClient.newCall( val parsed = authClient.newCall(
POST( POST(
"${BASE_URL}library-entries", GRAPHQL_API_URL,
headers = headersOf("Content-Type", VND_API_JSON), body = payload.toString().toRequestBody(jsonMime),
body = data.toString().toRequestBody(VND_JSON_MEDIA_TYPE),
), ),
) )
.awaitSuccess() .awaitSuccess()
.parseAs<KitsuAddMangaResult>() .parseAs<KitsuAddMangaResult>()
.let {
track.library_id = it.data.id if (parsed.error != null) {
track 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 { suspend fun updateLibManga(track: Track): Track {
return withIOContext { return withIOContext {
val data = buildJsonObject { val query = $$"""
putJsonObject("data") { |mutation UpdateManga(
put("type", "libraryEntries") |$library_id: ID!
put("id", track.library_id) |$status: LibraryEntryStatusEnum!
putJsonObject("attributes") { |$progress: Int!
put("status", track.toApiStatus()) |$private: Boolean!
put("progress", track.last_chapter_read.toInt()) |$rating: Int
put("ratingTwenty", track.score.takeIf { it > 0 }?.toInt()) |$startedAt: ISO8601DateTime
put("startedAt", KitsuDateHelper.convert(track.started_reading_date)) |$finishedAt: ISO8601DateTime
put("finishedAt", KitsuDateHelper.convert(track.finished_reading_date)) |) {
put("private", track.private) |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( with(json) {
Request.Builder() val parsed = authClient.newCall(
.url("${BASE_URL}library-entries/${track.library_id}") POST(
.headers( GRAPHQL_API_URL,
headersOf("Content-Type", VND_API_JSON), body = payload.toString().toRequestBody(jsonMime),
) ),
.patch(data.toString().toRequestBody(VND_JSON_MEDIA_TYPE)) )
.build(), .awaitSuccess()
) .parseAs<KitsuUpdateMangaResult>()
.awaitSuccess()
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) { suspend fun removeLibManga(track: DomainTrack) {
withIOContext { withIOContext {
authClient.newCall( val query = $$"""|
DELETE( |mutation DeleteLibEntry(
"${BASE_URL}library-entries/${track.libraryId}", |$library_id: ID!
headers = headersOf("Content-Type", VND_API_JSON), |) {
), |libraryEntry {
) |delete(
.awaitSuccess() |input: {
} |id: $library_id
} |}
|) {
|errors {
|message
|}
|libraryEntry {
|id
|}
|}
|}
|}
""".trimMargin()
suspend fun search(query: String): List<TrackSearch> { val payload = buildJsonObject {
return withIOContext { put("query", query)
with(json) { putJsonObject("variables") {
authClient.newCall(GET(ALGOLIA_KEY_URL)) put("library_id", track.libraryId)
.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")
} }
with(json) { with(json) {
client.newCall( val parsed = authClient.newCall(
POST( POST(
ALGOLIA_URL, GRAPHQL_API_URL,
headers = headersOf( body = payload.toString().toRequestBody(jsonMime),
"X-Algolia-Application-Id", ),
ALGOLIA_APP_ID, )
"X-Algolia-API-Key", // Deleting something not in the library returns a 500 with "Couldn't find LibraryEntry" msg
key, // awaitSuccess would throw with that but user gets their wish of "title not in library" so ignore it
), .await()
body = jsonObject.toString().toRequestBody(jsonMime), .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() .awaitSuccess()
.parseAs<KitsuAlgoliaSearchResult>() .parseAs<KitsuSearchByTitleResult>()
.hits .data.searchMangaByTitle.nodes
.filter { it.subtype != "novel" } .map { it.toTrackSearch(trackId) }
.map { it.toTrack() }
} }
} }
} }
suspend fun findLibManga(track: Track, userId: String): Track? { suspend fun findLibManga(track: Track): Track? {
return withIOContext { return withIOContext {
val url = "${BASE_URL}library-entries".toUri().buildUpon() val query = $$"""
.encodedQuery("filter[manga_id]=${track.remote_id}&filter[user_id]=$userId") |query Query($remote_id: ID!) {
.appendQueryParameter("include", "manga") |findMangaById(id: $remote_id) {
.build() |$$COMMON_MANGA_DATA
with(json) { |myLibraryEntry {
authClient.newCall(GET(url.toString())) |id
.awaitSuccess() |private
.parseAs<KitsuListSearchResult>() |progress
.let { |rating
if (it.data.isNotEmpty() && it.included.isNotEmpty()) { |reconsuming
it.firstToTrack() |status
} else { |startedAt
null |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) { with(json) {
authClient.newCall(GET(url.toString())) authClient.newCall(
POST(
GRAPHQL_API_URL,
body = payload.toString().toRequestBody(jsonMime),
),
)
.awaitSuccess() .awaitSuccess()
.parseAs<KitsuListSearchResult>() .parseAs<KitsuSearchByIdWithLibraryResult>()
.let { .data.findMangaById
if (it.data.isNotEmpty() && it.included.isNotEmpty()) { ?.toTrackSearch(trackId)
it.firstToTrack()
} else {
throw Exception("Could not find manga")
}
}
} }
} }
} }
@@ -231,36 +356,87 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
} }
} }
suspend fun getCurrentUser(): KitsuUser { suspend fun getCurrentUser(): KitsuAccount {
return withIOContext { return withIOContext {
val url = "${BASE_URL}users".toUri().buildUpon() val query = """
.encodedQuery("filter[self]=true") |query Query {
.build() |currentAccount {
|id
|ratingSystem
|profile {
|name
|}
|}
|}
""".trimMargin()
val payload = buildJsonObject {
put("query", query)
}
with(json) { with(json) {
authClient.newCall(GET(url.toString())) authClient.newCall(
POST(
GRAPHQL_API_URL,
body = payload.toString().toRequestBody(jsonMime),
),
)
.awaitSuccess() .awaitSuccess()
.parseAs<KitsuCurrentUserResult>() .parseAs<KitsuCurrentAccountResult>()
.data[0] .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 { return withIOContext {
val url = "$BASE_URL/manga/$id" with(json) {
try { val response = authClient.newCall(
with(json) { POST(
authClient.newCall(GET(url)) GRAPHQL_API_URL,
.await() body = payload.toString().toRequestBody(jsonMime),
.parseAs<KitsuSingleManga>() ),
.toTrackSearch() )
} .awaitSuccess()
} catch (e: HttpException) {
if (e.code == 404) { val kitsuManga = if (isSearchById) {
null response
.parseAs<KitsuSearchByIdResult>()
.data.findMangaById
} else { } 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_ID = "dd031b32d2f56c990b1425efe6c42ad847e7fe3ab46bf1299f05ecd856bdb7dd"
private const val CLIENT_SECRET = "54d7307928f63414defd96399fc31ba847961ceaecef3a5fd93144e960c0e151" 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 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( fun refreshTokenRequest(token: String) = POST(
LOGIN_URL, LOGIN_URL,
@@ -296,5 +457,38 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
.add("client_secret", CLIENT_SECRET) .add("client_secret", CLIENT_SECRET)
.build(), .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.BuildConfig
import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuOAuth import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuOAuth
import eu.kanade.tachiyomi.data.track.kitsu.dto.isExpired
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.Response import okhttp3.Response
@@ -2,11 +2,20 @@ package eu.kanade.tachiyomi.data.track.kitsu
import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.database.models.Track
fun Track.toApiStatus() = when (status) { fun Track.toKitsuApiStatus() = when (status) {
Kitsu.READING -> "current" Kitsu.READING -> "CURRENT"
Kitsu.COMPLETED -> "completed" Kitsu.COMPLETED -> "COMPLETED"
Kitsu.ON_HOLD -> "on_hold" Kitsu.ON_HOLD -> "ON_HOLD"
Kitsu.DROPPED -> "dropped" Kitsu.DROPPED -> "DROPPED"
Kitsu.PLAN_TO_READ -> "planned" Kitsu.PLAN_TO_READ -> "PLANNED"
else -> throw Exception("Unknown status") 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 @Serializable
data class KitsuAddMangaResult( 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 @Serializable
data class KitsuAddMangaItem( data class KitsuAddMangaData(
val id: Long, 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.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlin.time.Clock
@Serializable @Serializable
data class KitsuOAuth( data class KitsuOAuth(
@@ -15,6 +16,6 @@ data class KitsuOAuth(
val expiresIn: Long, val expiresIn: Long,
@SerialName("refresh_token") @SerialName("refresh_token")
val refreshToken: String?, val refreshToken: String?,
) ) {
fun isExpired(): Boolean = (Clock.System.now().toEpochMilliseconds() / 1000) > (createdAt + expiresIn - 3600)
fun KitsuOAuth.isExpired() = (System.currentTimeMillis() / 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 import kotlinx.serialization.Serializable
@Serializable @Serializable
data class KitsuCurrentUserResult( data class KitsuCurrentAccountResult(
val data: List<KitsuUser>, val data: KitsuCurrentAccountData,
) )
@Serializable @Serializable
data class KitsuUser( data class KitsuCurrentAccountData(
val currentAccount: KitsuAccount,
)
@Serializable
data class KitsuAccount(
val id: String, val id: String,
val attributes: KitsuUserAttributes, val ratingSystem: String,
val profile: KitsuProfile,
) )
@Serializable @Serializable
data class KitsuUserAttributes( data class KitsuProfile(
val name: String, val name: String,
val ratingSystem: String,
) )