From 31d3c23717dbdc49793910977eb423c55f52191b Mon Sep 17 00:00:00 2001 From: MajorTanya <39014446+MajorTanya@users.noreply.github.com> Date: Sat, 22 Aug 2026 00:46:35 +0200 Subject: [PATCH] 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. --- CHANGELOG.md | 1 + .../tachiyomi/data/track/kitsu/Kitsu.kt | 20 +- .../tachiyomi/data/track/kitsu/KitsuApi.kt | 550 ++++++++++++------ .../data/track/kitsu/KitsuDateHelper.kt | 25 - .../data/track/kitsu/KitsuInterceptor.kt | 1 - .../tachiyomi/data/track/kitsu/KitsuUtils.kt | 23 +- .../data/track/kitsu/dto/KitsuAddManga.kt | 16 +- .../data/track/kitsu/dto/KitsuCommon.kt | 18 + .../data/track/kitsu/dto/KitsuDeleteManga.kt | 23 + .../data/track/kitsu/dto/KitsuListSearch.kt | 103 ---- .../data/track/kitsu/dto/KitsuManga.kt | 85 +++ .../kitsu/dto/KitsuMangaWithLibraryEntry.kt | 74 +++ .../data/track/kitsu/dto/KitsuOAuth.kt | 7 +- .../data/track/kitsu/dto/KitsuSearch.kt | 55 -- .../data/track/kitsu/dto/KitsuSearchById.kt | 25 + .../data/track/kitsu/dto/KitsuSearchBySlug.kt | 13 + .../track/kitsu/dto/KitsuSearchByTitle.kt | 18 + .../track/kitsu/dto/KitsuSearchItemCover.kt | 8 - .../data/track/kitsu/dto/KitsuUpdateManga.kt | 23 + .../data/track/kitsu/dto/KitsuUser.kt | 17 +- 20 files changed, 704 insertions(+), 401 deletions(-) delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuDateHelper.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuCommon.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuDeleteManga.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuListSearch.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuManga.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuMangaWithLibraryEntry.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuSearch.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuSearchById.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuSearchBySlug.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuSearchByTitle.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuSearchItemCover.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuUpdateManga.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index b3f891a79..725e8b0fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co ## [Unreleased] ### 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)) + - 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)) ### Improved diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/Kitsu.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/Kitsu.kt index 8a5a35f0a..eea33c568 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/Kitsu.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/Kitsu.kt @@ -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 { 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)) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuApi.kt index b8b1b32dd..7da976f4f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuApi.kt @@ -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() - .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() - 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 { - return withIOContext { - with(json) { - authClient.newCall(GET(ALGOLIA_KEY_URL)) - .awaitSuccess() - .parseAs() - .let { - algoliaSearch(it.media.key, query) - } - } - } - } - - private suspend fun algoliaSearch(key: String, query: String): List { - 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() + + 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 { + 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() - .hits - .filter { it.subtype != "novel" } - .map { it.toTrack() } + .parseAs() + .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() - .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() - .let { - if (it.data.isNotEmpty() && it.included.isNotEmpty()) { - it.firstToTrack() - } else { - throw Exception("Could not find manga") - } - } + .parseAs() + .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() - .data[0] + .parseAs() + .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() - .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() + .data.findMangaById } else { - throw e + response + .parseAs() + .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() } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuDateHelper.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuDateHelper.kt deleted file mode 100644 index aee35d363..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuDateHelper.kt +++ /dev/null @@ -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 - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuInterceptor.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuInterceptor.kt index 0f7c1b7db..4cbb44e3a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuInterceptor.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuInterceptor.kt @@ -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 diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuUtils.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuUtils.kt index 8cea94bc3..277624424 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuUtils.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuUtils.kt @@ -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") } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuAddManga.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuAddManga.kt index 9ddec35e9..35d1b225a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuAddManga.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuAddManga.kt @@ -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?, + val error: KitsuErrorMessage?, ) @Serializable -data class KitsuAddMangaItem( - val id: Long, +data class KitsuAddMangaData( + val libraryEntry: KitsuAddMangaLibraryEntryWrapper, +) + +@Serializable +data class KitsuAddMangaLibraryEntryWrapper( + val create: KitsuLibraryEntryResult, ) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuCommon.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuCommon.kt new file mode 100644 index 000000000..42d7a3083 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuCommon.kt @@ -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, +) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuDeleteManga.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuDeleteManga.kt new file mode 100644 index 000000000..23b3090a9 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuDeleteManga.kt @@ -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?, + val error: KitsuErrorMessage?, +) + +@Serializable +data class KitsuDeleteMangaData( + val libraryEntry: KitsuDeleteMangaLibraryEntryWrapper, +) + +@Serializable +data class KitsuDeleteMangaLibraryEntryWrapper( + val delete: KitsuLibraryEntryResult, +) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuListSearch.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuListSearch.kt deleted file mode 100644 index d5143530c..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuListSearch.kt +++ /dev/null @@ -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, - val included: List = 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?, -) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuManga.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuManga.kt new file mode 100644 index 000000000..c9deb0ff1 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuManga.kt @@ -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, + 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, +) + +@Serializable +data class KitsuMangaStaff( + val role: String, + val person: KitsuMangaStaffPerson, +) + +@Serializable +data class KitsuMangaStaffPerson( + val name: String, +) + +@Serializable +data class KitsuMangaPosters( + val views: List, + 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, +) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuMangaWithLibraryEntry.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuMangaWithLibraryEntry.kt new file mode 100644 index 000000000..3f50ab4f1 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuMangaWithLibraryEntry.kt @@ -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, + 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?, +) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuOAuth.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuOAuth.kt index c5cab234a..c623c2dad 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuOAuth.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuOAuth.kt @@ -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) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuSearch.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuSearch.kt deleted file mode 100644 index c0f941673..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuSearch.kt +++ /dev/null @@ -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, -) - -@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)) - } ?: "" - } - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuSearchById.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuSearchById.kt new file mode 100644 index 000000000..bf98d5ec8 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuSearchById.kt @@ -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?, +) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuSearchBySlug.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuSearchBySlug.kt new file mode 100644 index 000000000..532ec754d --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuSearchBySlug.kt @@ -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?, +) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuSearchByTitle.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuSearchByTitle.kt new file mode 100644 index 000000000..6393eaf73 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuSearchByTitle.kt @@ -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, +) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuSearchItemCover.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuSearchItemCover.kt deleted file mode 100644 index 6c062bf52..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuSearchItemCover.kt +++ /dev/null @@ -1,8 +0,0 @@ -package eu.kanade.tachiyomi.data.track.kitsu.dto - -import kotlinx.serialization.Serializable - -@Serializable -data class KitsuSearchItemCover( - val original: String?, -) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuUpdateManga.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuUpdateManga.kt new file mode 100644 index 000000000..78344ab1b --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuUpdateManga.kt @@ -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?, + val error: KitsuErrorMessage?, +) + +@Serializable +data class KitsuUpdateMangaData( + val libraryEntry: KitsuUpdateMangaLibraryEntryWrapper, +) + +@Serializable +data class KitsuUpdateMangaLibraryEntryWrapper( + val update: KitsuLibraryEntryResult, +) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuUser.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuUser.kt index 547db25ba..86ec9dbbb 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuUser.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuUser.kt @@ -3,18 +3,23 @@ package eu.kanade.tachiyomi.data.track.kitsu.dto import kotlinx.serialization.Serializable @Serializable -data class KitsuCurrentUserResult( - val data: List, +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, )