Use Apollo GraphQL for Trackers with GraphQL APIs (#3827)

* Add Apollo for GraphQL interactions

* Migrate AniList to use Apollo

* Migrate Shikimori to use Apollo

Not all Shikimori requests can be served by their GraphQL API (notably
they do not offer any mutations), so those remain as-is.

* Migrate Suwayomi to use Apollo

Includes leveraging some slightly newer Suwayomi features,
specifically the Double/Float filter fixes first pre-released in
2.1.1985 on 2025-10-26 and first fully released in 2.1.2100 on
2026-05-08.

Considering the tachiyomix 1.6 extension lib changes were added in
Suwayomi 2.3.2223 on 2026-06-30, I think it is fair to depend on such
sorta-recent behaviour.

* Fix temp migration names and leftovers

* Create ApolloResponse.dataOrElse extension

Because Apollo doesn't throw exceptions and instead just provides them
as response attributes, the current ApolloResponse.awaitSuccess() is
useless and will be removed shortly.

This new extension function should wrap the basic handling of an
ApolloResponse object neatly.

Co-authored-by: Mitchell Syer <6090655+Syer10@users.noreply.github.com>

* Use dataOrElse in AnilistApi.kt

* Use dataOrElse in ShikimoriApi.kt

* Use dataOrElse in SuwayomiApi.kt

* Delete obsolete ApolloCall.awaitSuccess

* Migrate Kitsu to use Apollo

Kitsu has some unfortunate edges in its GraphQL API (such as returning
nonstandard bodies which don't play well with Apollo) but this should
be an equivalent implementation.

* Extract a MangaFragment for AniList

This simplifies the queries and the converters (which can now delegate
to the MangaFragment.toTrackSearch() method internally and adjust the
track as needed).

Also move the toALUser converter to AnilistUtils.kt to keep all
converters together.

Added a scalar-mapper for AL's CountryCode type.

* Minor consistency cleanup of the queries

* Store list entry ID not title ID for Shikimori

* Extract common MangaFragment for Shikimori

Also map the ISO8601Date to a Kotlin String in the service config.

* Map Kitsu's ISO8601DateTime to kotlin.time.Instant

Utilises Apollo's own adapter-core library which provides the mapper.

Requires opting in to ExperimentalTime until the library resolves the
outdated annotation.

Co-authored-by: Mitchell Syer <6090655+Syer10@users.noreply.github.com>

* Replace Kitsu TODO comment

So much for STOPSHIP preventing me shipping this...

* Bump Apollo version to 5.1.0

* Bump apollo-adapters-core to 0.8.0

Freshly released just earlier.
This resolves having to opt in to ExperimentalTime.

* Make new utils internal to align with #3908

* Fix leftover issues from merged #3828

This PR restructured the AniList and Kitsu user objects.

* Bump apollo to 5.2.0

* Remove unused attributes from queries

* Allow custom exception handler in dataOrElse

Enables callers to manually handle specific non-200 responses.

Callers MUST close the `ApolloHttpException.body` attached to the
passed exception to avoid resource and socket leaks.

Defaults to current behaviour of (re)throwing the status code with our
HttpException.

Utilized in KitsuApi.kt to specially handle Kitsu sending a 500 error
response when deleting library IDs not actually in a user's library
and falling back to the "throw as HttpException" if it's any other
error response.

* Rethrow networking/IO exceptions

This helps to avoid masking networking problems as "This isn't in your
list" problems.

Currently, the app shows "Failed to connect to ..." when the remote
server is unreachable (e.g. downtime, DNS block, etc) because that's
the message provided by the ConnectException thrown by Android's
network stack.

This change restores that behaviour, approximately.

Not the most elegant solution but it _should_ avoid false-positive
toasts.

---------

Co-authored-by: Mitchell Syer <6090655+Syer10@users.noreply.github.com>
This commit is contained in:
MajorTanya
2026-09-28 10:34:26 +06:00
committed by GitHub
co-authored by Mitchell Syer
parent 59fdf7e7fa
commit ec614bb4c3
69 changed files with 21163 additions and 1952 deletions
+75
View File
@@ -19,6 +19,7 @@ plugins {
alias(libs.plugins.aboutLibraries)
alias(libs.plugins.androidx.baselineProfile)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.apollo)
}
if (Config.includeTelemetry) {
@@ -318,6 +319,10 @@ dependencies {
// String similarity
implementation(libs.stringSimilarity)
// GraphQL generation
implementation(libs.apollo)
implementation(libs.apollo.adapters)
// Tests
testImplementation(libs.bundles.test)
testRuntimeOnly(libs.junit.platform.launcher)
@@ -329,6 +334,76 @@ dependencies {
testImplementation(libs.kotlinx.coroutines.test)
}
apollo {
val schemaBasePath = "src/main/graphql/mihon/graphql"
service("anilist") {
packageName.set("mihon.graphql.anilist")
val srcDir = "$schemaBasePath/anilist"
srcDir(file(srcDir))
introspection {
endpointUrl.set("https://graphql.anilist.co")
schemaFile.set(file("$srcDir/anilist.graphqls"))
}
// ISO 3166-1 alpha-2 country code
mapScalarToKotlinString("CountryCode")
}
service("kitsu") {
packageName.set("mihon.graphql.kitsu")
val srcDir = "$schemaBasePath/kitsu"
srcDir(file(srcDir))
android.defaultConfig.versionCode
introspection {
// Kitsu doesn't like requests without a UA
headers.put(
"User-Agent",
"Mihon v${android.defaultConfig.versionName} (${android.defaultConfig.applicationId})",
)
endpointUrl.set("https://kitsu.app/api/graphql")
schemaFile.set(file("$srcDir/kitsu.graphqls"))
}
// A date, expressed as an ISO8601 string
mapScalarToKotlinString("Date")
// An ISO 8601-encoded datetime
mapScalar("ISO8601DateTime", "kotlin.time.Instant", "com.apollographql.adapter.core.KotlinInstantAdapter")
// A loose key-value map in GraphQL
mapScalar(
"Map",
"kotlin.collections.Map<String, Any?>",
"mihon.graphql.kitsu.adapter.KitsuMapAdapter",
)
}
service("shikimori") {
packageName.set("mihon.graphql.shikimori")
val srcDir = "$schemaBasePath/shikimori"
srcDir(file(srcDir))
introspection {
endpointUrl.set("https://shikimori.io/api/graphql")
schemaFile.set(file("$srcDir/shikimori.graphqls"))
}
// An ISO 8601-encoded date
mapScalarToKotlinString("ISO8601Date")
}
service("suwayomi") {
packageName.set("mihon.graphql.suwayomi")
val srcDir = "$schemaBasePath/suwayomi"
srcDir(file(srcDir))
introspection {
endpointUrl.set("http://localhost:4567/api/graphql")
schemaFile.set(file("$srcDir/suwayomi.graphqls"))
}
}
}
val latestCommitCount = getLatestCommitCount()
val latestCommitSha = getLatestCommitSha()
val latestCommitTime = getLatestCommitTime()
@@ -0,0 +1,15 @@
mutation AniListAddManga(
$manga_id: Int!,
$progress: Int!,
$status: MediaListStatus!,
$private: Boolean!,
) {
SaveMediaListEntry (
mediaId: $manga_id,
progress: $progress,
status: $status,
private: $private,
) {
id
}
}
@@ -0,0 +1,9 @@
mutation AniListDeleteManga(
$library_id: Int!,
) {
DeleteMediaListEntry(
id: $library_id,
) {
deleted
}
}
@@ -0,0 +1,9 @@
query AniListGetCurrentUser {
Viewer {
id
name
mediaListOptions {
scoreFormat
}
}
}
@@ -0,0 +1,31 @@
query AniListGetLibManga(
$user_id: Int!,
$manga_id: Int!,
) {
Page {
mediaList(
userId: $user_id,
type: MANGA,
mediaId: $manga_id,
) {
id
status
scoreRaw: score(format: POINT_100)
progress
private
startedAt {
year
month
day
}
completedAt {
year
month
day
}
media {
...MangaFragment
}
}
}
}
@@ -0,0 +1,13 @@
query AniListGetMangaDetails(
$manga_id: Int!,
) {
Page (perPage: 1) {
media(
id: $manga_id,
type: MANGA,
format_not_in: [NOVEL],
) {
...MangaFragment
}
}
}
@@ -0,0 +1,13 @@
query AniListSearchManga(
$search: String!,
) {
Page (perPage: 50) {
media(
search: $search,
type: MANGA,
format_not_in: [NOVEL],
) {
...MangaFragment
}
}
}
@@ -0,0 +1,21 @@
mutation AniListUpdateManga(
$library_id: Int!,
$progress: Int!,
$status: MediaListStatus!,
$private: Boolean!,
$score: Int!,
$startedAt: FuzzyDateInput!,
$completedAt: FuzzyDateInput!,
) {
SaveMediaListEntry(
id: $library_id,
progress: $progress,
status: $status,
private: $private,
scoreRaw: $score,
startedAt: $startedAt,
completedAt: $completedAt,
) {
id
}
}
@@ -0,0 +1,30 @@
fragment MangaFragment on Media {
id
staff {
edges {
role
node {
name {
userPreferred
}
}
}
}
title {
userPreferred
}
coverImage {
large
}
format
countryOfOrigin
status
chapters
description
startDate {
year
month
day
}
averageScore
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,27 @@
mutation KitsuAddLibManga(
$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
}
}
}
}
@@ -0,0 +1,18 @@
mutation KitsuDeleteLibEntry(
$library_id: ID!,
) {
libraryEntry {
delete(
input: {
id: $library_id,
}
) {
errors {
message
}
libraryEntry {
id
}
}
}
}
@@ -0,0 +1,18 @@
query KitsuFindLibManga(
$remote_id: ID!,
) {
findMangaById(
id: $remote_id,
) {
...MangaFragment
myLibraryEntry {
id
private
progress
rating
status
startedAt
finishedAt
}
}
}
@@ -0,0 +1,9 @@
query KitsuGetCurrentAccount {
currentAccount {
id
ratingSystem
profile {
name
}
}
}
@@ -0,0 +1,9 @@
query KitsuGetMangaDetailsById(
$id: ID!,
) {
findMangaById(
id: $id,
) {
...MangaFragment
}
}
@@ -0,0 +1,9 @@
query KitsuGetMangaDetailsBySlug(
$slug: String!,
) {
findMangaBySlug(
slug: $slug,
) {
...MangaFragment
}
}
@@ -0,0 +1,12 @@
query KitsuSearchMangaByTitle(
$query: String!,
) {
searchMangaByTitle(
title: $query,
first: 20,
) {
nodes {
...MangaFragment
}
}
}
@@ -0,0 +1,30 @@
mutation KitsuUpdateLibManga(
$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
}
}
}
}
@@ -0,0 +1,31 @@
fragment MangaFragment on Manga {
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
slug
averageRating
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,22 @@
fragment MangaFragment on Manga {
id
name
chapters
kind
poster {
mainUrl
}
score
url
status
airedOn {
date
}
description
personRoles {
person {
name
}
rolesEn
}
}
@@ -0,0 +1,6 @@
query ShikimoriGetCurrentUser {
currentUser {
id
nickname
}
}
@@ -0,0 +1,19 @@
query ShikimoriGetLibManga(
$remote_id: String!,
) {
mangas(
ids: $remote_id,
limit: 1,
) {
id
url
name
chapters
userRate {
id
chapters
status
score
}
}
}
@@ -0,0 +1,11 @@
query ShikimoriGetMangaDetails(
$query: String!,
) {
mangas(
ids: $query,
limit: 1,
kind: "!light_novel,!novel",
) {
...MangaFragment
}
}
@@ -0,0 +1,11 @@
query ShikimoriSearchManga(
$search: String!
) {
mangas(
search: $search,
limit: 20,
kind: "!light_novel,!novel",
) {
...MangaFragment
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,15 @@
fragment MangaFragment on MangaType {
description
id
status
thumbnailUrl
title
url
chapters {
totalCount
}
latestReadChapter {
chapterNumber
}
unreadCount
}
@@ -0,0 +1,9 @@
query SuwayomiGetManga(
$mangaId: Int!,
) {
manga(
id: $mangaId,
) {
...MangaFragment
}
}
@@ -0,0 +1,30 @@
query SuwayomiGetMangaUnreadChapters(
$mangaId: Int!,
$chapterNumber: Float!,
) {
chapters(
condition: {
mangaId: $mangaId,
isRead: false,
},
filter: {
and: [
{
chapterNumber: {
lessThanOrEqualTo: $chapterNumber
}
},
{
chapterNumber: {
greaterThan: 0
}
},
]
},
){
nodes {
id
chapterNumber
}
}
}
@@ -0,0 +1,14 @@
mutation SuwayomiMarkChaptersRead(
$chapters: [Int!]!,
) {
updateChapters(
input: {
ids: $chapters,
patch: {
isRead: true,
},
},
) {
__typename
}
}
@@ -0,0 +1,22 @@
mutation SuwayomiMarkAndDeleteChapters(
$chapters: [Int!]!,
) {
updateChapters(
input: {
ids: $chapters,
patch: {
isRead: true,
},
},
) {
__typename
}
deleteDownloadedChapters(
input: {
ids: $chapters,
},
) {
__typename
}
}
@@ -0,0 +1,11 @@
mutation SuwayomiUpdateMangaProgress(
$mangaId: Int!,
) {
trackProgress(
input: {
mangaId: $mangaId,
},
) {
__typename
}
}
File diff suppressed because it is too large Load Diff
@@ -207,7 +207,7 @@ class Anilist(id: Long) : BaseTracker(id, "AniList"), DeletableTracker {
}
override suspend fun refresh(track: Track): Track {
val remoteTrack = api.getLibManga(track, getUsername().toInt())
val remoteTrack = api.findLibManga(track, getUsername().toInt()) ?: throw Exception("Could not find manga")
track.copyPersonalFrom(remoteTrack)
track.title = remoteTrack.title
track.total_chapters = remoteTrack.total_chapters
@@ -216,7 +216,7 @@ class Anilist(id: Long) : BaseTracker(id, "AniList"), DeletableTracker {
override suspend fun updateUserConfig() {
val currentUser = api.getCurrentUser()
scorePreference.set(currentUser.mediaListOptions.scoreFormat)
scorePreference.set(currentUser.scoreFormat)
saveDisplayUsername(currentUser.name)
}
@@ -227,7 +227,7 @@ class Anilist(id: Long) : BaseTracker(id, "AniList"), DeletableTracker {
val oauth = ALOAuth(token)
interceptor.setAuth(oauth)
val currentUser = api.getCurrentUser()
scorePreference.set(currentUser.mediaListOptions.scoreFormat)
scorePreference.set(currentUser.scoreFormat)
saveDisplayUsername(currentUser.name)
saveCredentials(currentUser.id.toString(), oauth.accessToken)
} catch (_: Throwable) {
@@ -2,31 +2,28 @@ package eu.kanade.tachiyomi.data.track.anilist
import android.net.Uri
import androidx.core.net.toUri
import com.apollographql.apollo.ApolloClient
import com.apollographql.apollo.api.Optional
import com.apollographql.apollo.network.okHttpClient
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.anilist.dto.ALAddMangaResult
import eu.kanade.tachiyomi.data.track.anilist.dto.ALCurrentUserResult
import eu.kanade.tachiyomi.data.track.anilist.dto.ALSearchResult
import eu.kanade.tachiyomi.data.track.anilist.dto.ALUserListMangaQueryResult
import eu.kanade.tachiyomi.data.track.anilist.dto.ALUserViewerData
import eu.kanade.tachiyomi.data.track.anilist.dto.ALUser
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.awaitSuccess
import eu.kanade.tachiyomi.network.dataOrElse
import eu.kanade.tachiyomi.network.interceptor.rateLimit
import eu.kanade.tachiyomi.network.jsonMime
import eu.kanade.tachiyomi.network.parseAs
import kotlinx.coroutines.Dispatchers
import kotlinx.datetime.TimeZone
import kotlinx.datetime.number
import kotlinx.datetime.toLocalDateTime
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonObject
import mihon.graphql.anilist.AniListAddMangaMutation
import mihon.graphql.anilist.AniListDeleteMangaMutation
import mihon.graphql.anilist.AniListGetCurrentUserQuery
import mihon.graphql.anilist.AniListGetLibMangaQuery
import mihon.graphql.anilist.AniListGetMangaDetailsQuery
import mihon.graphql.anilist.AniListSearchMangaQuery
import mihon.graphql.anilist.AniListUpdateMangaMutation
import mihon.graphql.anilist.type.FuzzyDateInput
import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.toRequestBody
import tachiyomi.core.common.util.lang.withIOContext
import uy.kohesive.injekt.injectLazy
import tachiyomi.core.common.util.system.logcat
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Instant
import tachiyomi.domain.track.model.Track as DomainTrack
@@ -37,381 +34,175 @@ class AnilistApi(
interceptor: AnilistInterceptor,
) {
private val json: Json by injectLazy()
private val authClient = client.newBuilder()
.addInterceptor(interceptor)
.rateLimit(permits = 25, period = 1.minutes)
.build()
private val graphQlClient by lazy {
ApolloClient.Builder()
.serverUrl("https://graphql.anilist.co")
.okHttpClient(authClient)
.dispatcher(Dispatchers.IO)
// required to log the error body in dataOrElse, which also properly closes it
.httpExposeErrorBody(true)
.build()
}
suspend fun addLibManga(track: Track): Track {
return withIOContext {
val query = $$"""
|mutation AddManga($mangaId: Int, $progress: Int, $status: MediaListStatus, $private: Boolean) {
|SaveMediaListEntry (mediaId: $mangaId, progress: $progress, status: $status, private: $private) {
| id
| status
|}
|}
|
""".trimMargin()
val payload = buildJsonObject {
put("query", query)
putJsonObject("variables") {
put("mangaId", track.remote_id)
put("progress", track.last_chapter_read.toInt())
put("status", track.toApiStatus())
put("private", track.private)
return graphQlClient
.mutation(
AniListAddMangaMutation(
manga_id = track.remote_id.toInt(),
progress = track.last_chapter_read.toInt(),
status = track.toApiStatus(),
private = track.private,
),
)
.execute()
.dataOrElse(
errorLog = "AniList: Failed to add manga",
default = { null },
) {
it.SaveMediaListEntry?.id?.let { libraryId ->
track.library_id = libraryId.toLong()
track
}
}
with(json) {
authClient.newCall(
POST(
API_URL,
body = payload.toString().toRequestBody(jsonMime),
),
)
.awaitSuccess()
.parseAs<ALAddMangaResult>()
.let {
track.library_id = it.data.entry.id
track
}
}
}
?: throw Exception("Failed to add manga")
}
suspend fun updateLibManga(track: Track): Track {
return withIOContext {
val query = $$"""
|mutation UpdateManga(
|$listId: Int, $progress: Int, $status: MediaListStatus, $private: Boolean,
|$score: Int, $startedAt: FuzzyDateInput, $completedAt: FuzzyDateInput
|) {
|SaveMediaListEntry(
|id: $listId, progress: $progress, status: $status, private: $private,
|scoreRaw: $score, startedAt: $startedAt, completedAt: $completedAt
|) {
|id
|status
|progress
|}
|}
|
""".trimMargin()
val payload = buildJsonObject {
put("query", query)
putJsonObject("variables") {
put("listId", track.library_id)
put("progress", track.last_chapter_read.toInt())
put("status", track.toApiStatus())
put("score", track.score.toInt())
put("startedAt", createDate(track.started_reading_date))
put("completedAt", createDate(track.finished_reading_date))
put("private", track.private)
val libraryId = track.library_id
requireNotNull(libraryId) { "AniList cannot update track with null library_id" }
return graphQlClient
.mutation(
AniListUpdateMangaMutation(
library_id = libraryId.toInt(),
progress = track.last_chapter_read.toInt(),
status = track.toApiStatus(),
private = track.private,
score = track.score.toInt(),
startedAt = createFuzzyDate(track.started_reading_date),
completedAt = createFuzzyDate(track.finished_reading_date),
),
)
.execute()
.dataOrElse(
errorLog = "AniList: Failed to update manga",
default = { null },
) {
it.SaveMediaListEntry?.id?.let { remoteLibraryId ->
track.library_id = remoteLibraryId.toLong()
track
}
}
authClient.newCall(POST(API_URL, body = payload.toString().toRequestBody(jsonMime)))
.awaitSuccess()
track
}
?: throw Exception("Failed to update manga")
}
suspend fun deleteLibManga(track: DomainTrack) {
withIOContext {
val query = $$"""
|mutation DeleteManga($listId: Int) {
|DeleteMediaListEntry(id: $listId) {
|deleted
|}
|}
|
""".trimMargin()
val payload = buildJsonObject {
put("query", query)
putJsonObject("variables") {
put("listId", track.libraryId)
val libraryId = track.libraryId
requireNotNull(libraryId) { "AniList cannot delete track with null library_id" }
graphQlClient
.mutation(
AniListDeleteMangaMutation(library_id = libraryId.toInt()),
)
.execute()
.dataOrElse(
errorLog = "AniList: Failed to delete manga",
default = { null },
) {
it.DeleteMediaListEntry?.deleted?.let { deleted ->
if (deleted) {
logcat { "AniList: Deleted manga ${track.libraryId} successfully" }
}
}
}
authClient.newCall(POST(API_URL, body = payload.toString().toRequestBody(jsonMime)))
.awaitSuccess()
}
?: throw Exception("Failed to delete manga")
}
suspend fun search(search: String): List<TrackSearch> {
return withIOContext {
val query = $$"""
|query Search($query: String) {
|Page (perPage: 50) {
|media(search: $query, type: MANGA, format_not_in: [NOVEL]) {
|id
|staff {
|edges {
|role
|id
|node {
|name {
|full
|userPreferred
|native
|}
|}
|}
|}
|title {
|userPreferred
|}
|coverImage {
|large
|}
|format
|countryOfOrigin
|status
|chapters
|description
|startDate {
|year
|month
|day
|}
|averageScore
|}
|}
|}
|
""".trimMargin()
val payload = buildJsonObject {
put("query", query)
putJsonObject("variables") {
put("query", search)
}
return graphQlClient
.query(
AniListSearchMangaQuery(search = search),
)
.execute()
.dataOrElse(
errorLog = "AniList: Search failed",
default = { emptyList() },
) {
it.Page?.media
?.mapNotNull { alManga -> alManga?.toTrackSearch(trackerId) }
?: emptyList()
}
with(json) {
authClient.newCall(
POST(
API_URL,
body = payload.toString().toRequestBody(jsonMime),
),
)
.awaitSuccess()
.parseAs<ALSearchResult>()
.data.page.media
.map { it.toALManga().toTrack(trackerId) }
}
}
}
suspend fun findLibManga(track: Track, userid: Int): Track? {
return withIOContext {
val query = $$"""
|query ($id: Int!, $manga_id: Int!) {
|Page {
|mediaList(userId: $id, type: MANGA, mediaId: $manga_id) {
|id
|status
|scoreRaw: score(format: POINT_100)
|progress
|private
|startedAt {
|year
|month
|day
|}
|completedAt {
|year
|month
|day
|}
|media {
|id
|title {
|userPreferred
|}
|coverImage {
|large
|}
|format
|status
|chapters
|description
|startDate {
|year
|month
|day
|}
|staff {
|edges {
|role
|id
|node {
|name {
|full
|userPreferred
|native
|}
|}
|}
|}
|}
|}
|}
|}
|
""".trimMargin()
val payload = buildJsonObject {
put("query", query)
putJsonObject("variables") {
put("id", userid)
put("manga_id", track.remote_id)
}
}
with(json) {
authClient.newCall(
POST(
API_URL,
body = payload.toString().toRequestBody(jsonMime),
),
)
.awaitSuccess()
.parseAs<ALUserListMangaQueryResult>()
.data.page.mediaList
.map { it.toALUserManga() }
.firstOrNull()
suspend fun findLibManga(track: Track, userId: Int): Track? {
return graphQlClient
.query(
AniListGetLibMangaQuery(
user_id = userId,
manga_id = track.remote_id.toInt(),
),
)
.execute()
.dataOrElse(
errorLog = "AniList: Failed to find manga in library",
default = { null },
) {
it.Page?.mediaList
?.firstOrNull()
?.toTrack(trackerId)
}
}
}
suspend fun getLibManga(track: Track, userId: Int): Track {
return findLibManga(track, userId) ?: throw Exception("Could not find manga")
}
suspend fun getCurrentUser(): ALUserViewerData {
return withIOContext {
val query = """
|query User {
|Viewer {
|id
|name
|mediaListOptions {
|scoreFormat
|}
|}
|}
|
""".trimMargin()
val payload = buildJsonObject {
put("query", query)
suspend fun getCurrentUser(): ALUser {
return graphQlClient
.query(AniListGetCurrentUserQuery())
.execute()
.dataOrElse(
errorLog = "AniList: Failed to get current user",
default = { null },
) {
it.Viewer?.toALUser()
}
with(json) {
authClient.newCall(
POST(
API_URL,
body = payload.toString().toRequestBody(jsonMime),
),
)
.awaitSuccess()
.parseAs<ALCurrentUserResult>()
.data.viewer
}
}
?: throw Exception("Failed to get AniList user data")
}
suspend fun getMangaDetails(id: Int): TrackSearch? {
return withIOContext {
val query = $$"""
|query Search($manga_id: Int) {
|Page (perPage: 1) {
|media(id: $manga_id, type: MANGA, format_not_in: [NOVEL]) {
|id
|staff {
|edges {
|role
|id
|node {
|name {
|full
|userPreferred
|native
|}
|}
|}
|}
|title {
|userPreferred
|}
|coverImage {
|large
|}
|format
|countryOfOrigin
|status
|chapters
|description
|startDate {
|year
|month
|day
|}
|averageScore
|}
|}
|}
|
""".trimMargin()
val payload = buildJsonObject {
put("query", query)
putJsonObject("variables") {
put("manga_id", id)
}
return graphQlClient
.query(
AniListGetMangaDetailsQuery(manga_id = id),
)
.execute()
.dataOrElse(
errorLog = "AniList: Failed to get manga details",
default = { null },
) {
it.Page?.media
?.firstOrNull()
?.toTrackSearch(trackerId)
}
with(json) {
authClient.newCall(
POST(
API_URL,
body = payload.toString().toRequestBody(jsonMime),
),
)
.awaitSuccess()
.parseAs<ALSearchResult>()
.data.page.media
.firstOrNull()
?.toALManga()
?.toTrack(trackerId)
}
}
}
private fun createDate(dateValue: Long): JsonObject {
if (dateValue == 0L) {
return buildJsonObject {
put("year", JsonNull)
put("month", JsonNull)
put("day", JsonNull)
}
}
private fun createFuzzyDate(dateValue: Long): FuzzyDateInput {
// all absent/null
if (dateValue == 0L) return FuzzyDateInput()
val dateTime = Instant.fromEpochMilliseconds(dateValue).toLocalDateTime(TimeZone.currentSystemDefault())
return buildJsonObject {
put("year", dateTime.year)
put("month", dateTime.month.number)
put("day", dateTime.day)
}
return FuzzyDateInput(
year = Optional.present(dateTime.year),
month = Optional.present(dateTime.month.number),
day = Optional.present(dateTime.day),
)
}
companion object {
private const val CLIENT_ID = "16329"
private const val API_URL = "https://graphql.anilist.co/"
private const val BASE_URL = "https://anilist.co/api/v2/"
private const val BASE_MANGA_URL = "https://anilist.co/manga/"
fun mangaUrl(mediaId: Long): String {
return BASE_MANGA_URL + mediaId
}
fun authUrl(): Uri = "${BASE_URL}oauth/authorize".toUri().buildUpon()
fun authUrl(): Uri = "https://anilist.co/api/v2/oauth/authorize".toUri().buildUpon()
.appendQueryParameter("client_id", CLIENT_ID)
.appendQueryParameter("response_type", "token")
.build()
@@ -2,15 +2,130 @@ package eu.kanade.tachiyomi.data.track.anilist
import eu.kanade.domain.track.service.TrackPreferences
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.anilist.dto.ALUser
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.util.lang.htmlDecode
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import mihon.graphql.anilist.AniListGetCurrentUserQuery
import mihon.graphql.anilist.AniListGetLibMangaQuery
import mihon.graphql.anilist.AniListGetMangaDetailsQuery
import mihon.graphql.anilist.AniListSearchMangaQuery
import mihon.graphql.anilist.fragment.MangaFragment
import mihon.graphql.anilist.type.MediaListStatus
import mihon.graphql.anilist.type.ScoreFormat
import tachiyomi.domain.track.model.Track as DomainTrack
internal fun Track.toApiStatus() = when (status) {
Anilist.READING -> "CURRENT"
Anilist.COMPLETED -> "COMPLETED"
Anilist.ON_HOLD -> "PAUSED"
Anilist.DROPPED -> "DROPPED"
Anilist.PLAN_TO_READ -> "PLANNING"
Anilist.REREADING -> "REPEATING"
internal fun AniListGetCurrentUserQuery.Viewer.toALUser(): ALUser {
return ALUser(
id = id,
name = name,
scoreFormat = mediaListOptions?.scoreFormat?.rawValue ?: ScoreFormat.POINT_10_DECIMAL.rawValue,
)
}
internal fun AniListGetLibMangaQuery.MediaList.toTrack(trackId: Long): Track {
val mediaList = this
requireNotNull(mediaList.media) { "Missing Media data from AniList" }
return mediaList.media.mangaFragment
.toTrackSearch(trackId)
.apply {
library_id = mediaList.id.toLong()
status = when (mediaList.status) {
MediaListStatus.CURRENT -> Anilist.READING
MediaListStatus.COMPLETED -> Anilist.COMPLETED
MediaListStatus.PAUSED -> Anilist.ON_HOLD
MediaListStatus.DROPPED -> Anilist.DROPPED
MediaListStatus.PLANNING -> Anilist.PLAN_TO_READ
MediaListStatus.REPEATING -> Anilist.REREADING
else -> throw NotImplementedError("Unknown status: ${mediaList.status?.rawValue}")
}
score = mediaList.scoreRaw ?: 0.0
last_chapter_read = mediaList.progress?.toDouble() ?: 0.0
started_reading_date = mediaList.startedAt.let {
if (it == null || it.year == null || it.month == null || it.day == null) {
0
} else {
LocalDate(it.year, it.month, it.day)
.atStartOfDayIn(TimeZone.currentSystemDefault())
.toEpochMilliseconds()
}
}
finished_reading_date = mediaList.completedAt.let {
if (it == null || it.year == null || it.month == null || it.day == null) {
0
} else {
LocalDate(it.year, it.month, it.day)
.atStartOfDayIn(TimeZone.currentSystemDefault())
.toEpochMilliseconds()
}
}
private = mediaList.private ?: false
}
}
internal fun AniListSearchMangaQuery.Medium.toTrackSearch(trackId: Long): TrackSearch {
return mangaFragment.toTrackSearch(trackId)
}
internal fun AniListGetMangaDetailsQuery.Medium.toTrackSearch(trackId: Long): TrackSearch {
return mangaFragment.toTrackSearch(trackId)
}
private fun MangaFragment.toTrackSearch(trackId: Long): TrackSearch {
val alManga = this
return TrackSearch.create(trackId).apply {
remote_id = alManga.id.toLong()
title = alManga.title?.userPreferred ?: "(no title)"
total_chapters = alManga.chapters?.toLong() ?: 0
cover_url = alManga.coverImage?.large ?: ""
summary = alManga.description?.htmlDecode() ?: ""
score = alManga.averageScore?.toDouble() ?: -1.0
tracking_url = "https://anilist.co/manga/$remote_id"
publishing_status = alManga.status?.rawValue ?: ""
publishing_type = if (alManga.format == null) {
"Manga"
} else if (alManga.format.rawValue != "MANGA") {
alManga.format.rawValue.replace("_", "-")
} else {
when (alManga.countryOfOrigin) {
"KR" -> "Manhwa"
"CN", "TW" -> "Manhua"
else -> "Manga"
}
}
start_date = alManga.startDate?.let {
buildString {
it.year?.let { year -> append(year) }
it.month?.let { month -> append("-$month") }
it.day?.let { day -> append("-$day") }
}.trim('-')
} ?: ""
authors = alManga.staff?.edges
?.filter { "Story" in (it?.role ?: "") }
?.mapNotNull { it?.node?.name?.userPreferred }
?: emptyList()
artists = alManga.staff?.edges
?.filter { "Art" in (it?.role ?: "") }
?.mapNotNull { it?.node?.name?.userPreferred }
?: emptyList()
}
}
internal fun Track.toApiStatus(): MediaListStatus = when (status) {
Anilist.READING -> MediaListStatus.CURRENT
Anilist.COMPLETED -> MediaListStatus.COMPLETED
Anilist.ON_HOLD -> MediaListStatus.PAUSED
Anilist.DROPPED -> MediaListStatus.DROPPED
Anilist.PLAN_TO_READ -> MediaListStatus.PLANNING
Anilist.REREADING -> MediaListStatus.REPEATING
else -> throw NotImplementedError("Unknown status: $status")
}
@@ -1,20 +0,0 @@
package eu.kanade.tachiyomi.data.track.anilist.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class ALAddMangaResult(
val data: ALAddMangaData,
)
@Serializable
data class ALAddMangaData(
@SerialName("SaveMediaListEntry")
val entry: ALAddMangaEntry,
)
@Serializable
data class ALAddMangaEntry(
val id: Long,
)
@@ -1,21 +0,0 @@
package eu.kanade.tachiyomi.data.track.anilist.dto
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.serialization.Serializable
@Serializable
data class ALFuzzyDate(
val year: Int?,
val month: Int?,
val day: Int?,
) {
fun toEpochMilli(): Long = try {
LocalDate(year!!, month!!, day!!)
.atStartOfDayIn(TimeZone.currentSystemDefault())
.toEpochMilliseconds()
} catch (_: Exception) {
0L
}
}
@@ -1,81 +0,0 @@
package eu.kanade.tachiyomi.data.track.anilist.dto
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.anilist.Anilist
import eu.kanade.tachiyomi.data.track.anilist.AnilistApi
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.util.lang.htmlDecode
import java.text.SimpleDateFormat
import java.util.Locale
data class ALManga(
val remoteId: Long,
val title: String,
val imageUrl: String,
val description: String?,
val format: String,
val publishingStatus: String,
val startDateFuzzy: Long,
val totalChapters: Long,
val averageScore: Int,
val staff: ALStaff,
) {
fun toTrack(trackerId: Long) = TrackSearch.create(trackerId).apply {
remote_id = remoteId
title = this@ALManga.title
total_chapters = totalChapters
cover_url = imageUrl
summary = description?.htmlDecode() ?: ""
score = averageScore.toDouble()
tracking_url = AnilistApi.mangaUrl(remote_id)
publishing_status = publishingStatus
publishing_type = format
if (startDateFuzzy != 0L) {
start_date = try {
val outputDf = SimpleDateFormat("yyyy-MM-dd", Locale.US)
outputDf.format(startDateFuzzy)
} catch (e: IllegalArgumentException) {
""
}
}
staff.edges.forEach {
val name = it.node.name() ?: return@forEach
if ("Story" in it.role) authors += name
if ("Art" in it.role) artists += name
}
}
}
data class ALUserManga(
val libraryId: Long,
val listStatus: String,
val scoreRaw: Int,
val chaptersRead: Int,
val startDateFuzzy: Long,
val completedDateFuzzy: Long,
val manga: ALManga,
val private: Boolean,
) {
fun toTrack(trackerId: Long) = Track.create(trackerId).apply {
remote_id = manga.remoteId
title = manga.title
status = toTrackStatus()
score = scoreRaw.toDouble()
started_reading_date = startDateFuzzy
finished_reading_date = completedDateFuzzy
last_chapter_read = chaptersRead.toDouble()
library_id = libraryId
total_chapters = manga.totalChapters
private = this@ALUserManga.private
}
private fun toTrackStatus() = when (listStatus) {
"CURRENT" -> Anilist.READING
"COMPLETED" -> Anilist.COMPLETED
"PAUSED" -> Anilist.ON_HOLD
"DROPPED" -> Anilist.DROPPED
"PLANNING" -> Anilist.PLAN_TO_READ
"REPEATING" -> Anilist.REREADING
else -> throw NotImplementedError("Unknown status: $listStatus")
}
}
@@ -1,20 +0,0 @@
package eu.kanade.tachiyomi.data.track.anilist.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class ALSearchResult(
val data: ALSearchPage,
)
@Serializable
data class ALSearchPage(
@SerialName("Page")
val page: ALSearchMedia,
)
@Serializable
data class ALSearchMedia(
val media: List<ALSearchItem>,
)
@@ -1,77 +0,0 @@
package eu.kanade.tachiyomi.data.track.anilist.dto
import kotlinx.serialization.Serializable
@Serializable
data class ALSearchItem(
val id: Long,
val title: ALItemTitle,
val coverImage: ItemCover,
val description: String?,
val format: String,
val status: String?,
val startDate: ALFuzzyDate,
val chapters: Long?,
val averageScore: Int?,
val staff: ALStaff,
val countryOfOrigin: String = "",
) {
fun toALManga(): ALManga = ALManga(
remoteId = id,
title = title.userPreferred,
imageUrl = coverImage.large,
description = description,
format = if (format != "MANGA") {
format.replace("_", "-")
} else {
when (countryOfOrigin) {
"KR" -> "Manhwa"
"CN", "TW" -> "Manhua"
else -> "Manga"
}
},
publishingStatus = status ?: "",
startDateFuzzy = startDate.toEpochMilli(),
totalChapters = chapters ?: 0,
averageScore = averageScore ?: -1,
staff = staff,
)
}
@Serializable
data class ALItemTitle(
val userPreferred: String,
)
@Serializable
data class ItemCover(
val large: String,
)
@Serializable
data class ALStaff(
val edges: List<ALEdge>,
)
@Serializable
data class ALEdge(
val role: String,
val id: Int,
val node: ALStaffNode,
)
@Serializable
data class ALStaffNode(
val name: ALStaffName,
)
@Serializable
data class ALStaffName(
val userPreferred: String?,
val native: String?,
val full: String?,
) {
operator fun invoke(): String? {
return userPreferred ?: full ?: native
}
}
@@ -1,27 +1,10 @@
package eu.kanade.tachiyomi.data.track.anilist.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class ALCurrentUserResult(
val data: ALUserViewer,
)
@Serializable
data class ALUserViewer(
@SerialName("Viewer")
val viewer: ALUserViewerData,
)
@Serializable
data class ALUserViewerData(
data class ALUser(
val id: Int,
val name: String,
val mediaListOptions: ALUserListOptions,
)
@Serializable
data class ALUserListOptions(
val scoreFormat: String,
)
@@ -1,45 +0,0 @@
package eu.kanade.tachiyomi.data.track.anilist.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class ALUserListMangaQueryResult(
val data: ALUserListMangaPage,
)
@Serializable
data class ALUserListMangaPage(
@SerialName("Page")
val page: ALUserListMediaList,
)
@Serializable
data class ALUserListMediaList(
val mediaList: List<ALUserListItem>,
)
@Serializable
data class ALUserListItem(
val id: Long,
val status: String,
val scoreRaw: Int,
val progress: Int,
val startedAt: ALFuzzyDate,
val completedAt: ALFuzzyDate,
val media: ALSearchItem,
val private: Boolean,
) {
fun toALUserManga(): ALUserManga {
return ALUserManga(
libraryId = this@ALUserListItem.id,
listStatus = status,
scoreRaw = scoreRaw,
chaptersRead = progress,
startDateFuzzy = startedAt.toEpochMilli(),
completedDateFuzzy = completedAt.toEpochMilli(),
manga = media.toALManga(),
private = private,
)
}
}
@@ -192,7 +192,7 @@ class Kitsu(id: Long) : BaseTracker(id, "Kitsu"), DeletableTracker {
logcat(LogPriority.ERROR) { "Unsupported Kitsu score type: $ratingSystem" }
scorePreference.set(RATING_ADVANCED)
}
saveDisplayUsername(currentUser.profile.name)
saveDisplayUsername(currentUser.name)
saveCredentials(username, currentUser.id)
}
@@ -206,7 +206,7 @@ class Kitsu(id: Long) : BaseTracker(id, "Kitsu"), DeletableTracker {
logcat(LogPriority.ERROR) { "Unsupported Kitsu score type: $ratingSystem" }
scorePreference.set(RATING_ADVANCED)
}
saveDisplayUsername(currentUser.profile.name)
saveDisplayUsername(currentUser.name)
}
override fun logout() {
@@ -1,31 +1,30 @@
package eu.kanade.tachiyomi.data.track.kitsu
import com.apollographql.apollo.ApolloClient
import com.apollographql.apollo.api.Optional
import com.apollographql.apollo.network.okHttpClient
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.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.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.kitsu.dto.KitsuUser
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.network.HttpException
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.network.awaitSuccess
import eu.kanade.tachiyomi.network.jsonMime
import eu.kanade.tachiyomi.network.dataOrElse
import eu.kanade.tachiyomi.network.parseAs
import kotlinx.coroutines.Dispatchers
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonObject
import logcat.LogPriority
import mihon.graphql.kitsu.KitsuAddLibMangaMutation
import mihon.graphql.kitsu.KitsuDeleteLibEntryMutation
import mihon.graphql.kitsu.KitsuFindLibMangaQuery
import mihon.graphql.kitsu.KitsuGetCurrentAccountQuery
import mihon.graphql.kitsu.KitsuGetMangaDetailsByIdQuery
import mihon.graphql.kitsu.KitsuGetMangaDetailsBySlugQuery
import mihon.graphql.kitsu.KitsuSearchMangaByTitleQuery
import mihon.graphql.kitsu.KitsuUpdateLibMangaMutation
import okhttp3.FormBody
import okhttp3.OkHttpClient
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
@@ -42,301 +41,143 @@ class KitsuApi(
private val authClient = client.newBuilder().addInterceptor(interceptor).build()
private val graphQlClient by lazy {
ApolloClient.Builder()
.serverUrl("https://kitsu.app/api/graphql")
.okHttpClient(authClient)
.dispatcher(Dispatchers.IO)
// required to log the error body in dataOrElse, which also properly closes it
.httpExposeErrorBody(true)
.build()
}
suspend fun addLibManga(track: Track): Track {
return withIOContext {
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) {
val parsed = authClient.newCall(
POST(
GRAPHQL_API_URL,
body = payload.toString().toRequestBody(jsonMime),
),
)
.awaitSuccess()
.parseAs<KitsuAddMangaResult>()
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()
return graphQlClient
.mutation(
KitsuAddLibMangaMutation(
media_id = track.remote_id.toString(),
status = track.toKitsuStatus(),
progress = track.last_chapter_read.toInt(),
private = track.private,
rating = Optional.present(track.score.toInt().takeIf { it > 0 }),
),
)
.execute()
.dataOrElse(
errorLog = "Kitsu: Failed to add manga",
default = { null },
) {
it.libraryEntry.create?.libraryEntry?.id?.let { libraryId ->
track.library_id = libraryId.toLong()
track
}
}
}
?: throw Exception("Failed to add manga")
}
suspend fun updateLibManga(track: Track): Track {
return withIOContext {
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 libraryId = track.library_id
requireNotNull(libraryId) { "Kitsu cannot update track with null library_id" }
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",
return graphQlClient
.mutation(
KitsuUpdateLibMangaMutation(
library_id = libraryId.toString(),
status = track.toKitsuStatus(),
progress = track.last_chapter_read.toInt(),
private = track.private,
rating = Optional.present(track.score.toInt().takeIf { it > 0 }),
startedAt = Optional.present(
track.started_reading_date
.takeIf { it > 0 }
?.let { Instant.fromEpochMilliseconds(it).toString() },
)
put(
"finishedAt",
?.let { Instant.fromEpochMilliseconds(it) },
),
finishedAt = Optional.present(
track.finished_reading_date
.takeIf { it > 0 }
?.let { Instant.fromEpochMilliseconds(it).toString() },
)
}
}
with(json) {
val parsed = authClient.newCall(
POST(
GRAPHQL_API_URL,
body = payload.toString().toRequestBody(jsonMime),
?.let { Instant.fromEpochMilliseconds(it) },
),
)
.awaitSuccess()
.parseAs<KitsuUpdateMangaResult>()
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")
),
)
.execute()
.dataOrElse(
errorLog = "Kitsu: Failed to update manga",
default = { null },
) {
it.libraryEntry.update?.libraryEntry?.id?.let { libraryId ->
logcat { "Kitsu: Updated library entry $libraryId" }
track.library_id = libraryId.toLong()
track
}
track
}
}
?: throw Exception("Failed to update manga")
}
suspend fun removeLibManga(track: DomainTrack) {
withIOContext {
val query = $$"""|
|mutation DeleteLibEntry(
|$library_id: ID!
|) {
|libraryEntry {
|delete(
|input: {
|id: $library_id
|}
|) {
|errors {
|message
|}
|libraryEntry {
|id
|}
|}
|}
|}
""".trimMargin()
val libraryId = track.libraryId
requireNotNull(libraryId) { "Kitsu cannot delete track with null library_id" }
val payload = buildJsonObject {
put("query", query)
putJsonObject("variables") {
put("library_id", track.libraryId)
}
}
with(json) {
val parsed = authClient.newCall(
POST(
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
graphQlClient
.mutation(
KitsuDeleteLibEntryMutation(
library_id = libraryId.toString(),
),
)
.execute()
.dataOrElse(
errorLog = "Kitsu: Failed to delete manga",
default = {},
onException = { e ->
val body = e.body?.use { it.readUtf8() }
if (
e.statusCode == 500 &&
body?.contains("Couldn't find LibraryEntry with 'id'=") == true
) {
// Deleting something not in the library (currently as of 2026-09-20) returns a 500 with a
// "Couldn't find LibraryEntry" error message at the error.message key --
// but the user gets their wish of "title not in library" so ignore it
return@dataOrElse
} else {
throw HttpException(e.statusCode).apply { stackTrace = e.stackTrace }
}
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")
}
},
) {
logcat { "Kitsu: Deleted library entry ${it.libraryEntry.delete?.libraryEntry?.id}" }
}
}
}
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)
}
return graphQlClient
.query(
KitsuSearchMangaByTitleQuery(
query = search,
),
)
.execute()
.dataOrElse(
errorLog = "Kitsu: Search failed",
default = { emptyList() },
) {
it.searchMangaByTitle.nodes
?.mapNotNull { node -> node?.toTrackSearch(trackerId) }
}
with(json) {
authClient.newCall(
POST(
GRAPHQL_API_URL,
body = payload.toString().toRequestBody(jsonMime),
),
)
.awaitSuccess()
.parseAs<KitsuSearchByTitleResult>()
.data.searchMangaByTitle.nodes
.map { it.toTrackSearch(trackerId) }
}
}
?: emptyList()
}
suspend fun findLibManga(track: Track): Track? {
return withIOContext {
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)
}
return graphQlClient
.query(
KitsuFindLibMangaQuery(
remote_id = track.remote_id.toString(),
),
)
.execute()
.dataOrElse(
errorLog = "Kitsu: Failed to find manga in library",
default = { null },
) {
it.findMangaById?.toTrackSearch(trackerId)
}
with(json) {
authClient.newCall(
POST(
GRAPHQL_API_URL,
body = payload.toString().toRequestBody(jsonMime),
),
)
.awaitSuccess()
.parseAs<KitsuSearchByIdWithLibraryResult>()
.data.findMangaById
?.toTrackSearch(trackerId)
}
}
}
suspend fun login(username: String, password: String): KitsuOAuth {
@@ -356,96 +197,67 @@ class KitsuApi(
}
}
suspend fun getCurrentUser(): KitsuAccount {
return withIOContext {
val query = """
|query Query {
|currentAccount {
|id
|ratingSystem
|profile {
|name
|}
|}
|}
""".trimMargin()
val payload = buildJsonObject {
put("query", query)
suspend fun getCurrentUser(): KitsuUser {
return graphQlClient
.query(
KitsuGetCurrentAccountQuery(),
)
.execute()
.dataOrElse(
errorLog = "Kitsu: Failed to get current user",
default = { null },
) {
it.currentAccount?.toKitsuUser()
}
with(json) {
authClient.newCall(
POST(
GRAPHQL_API_URL,
body = payload.toString().toRequestBody(jsonMime),
),
)
.awaitSuccess()
.parseAs<KitsuCurrentAccountResult>()
.data.currentAccount
}
}
?: throw Exception("Failed to get Kitsu user data")
}
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
|}
|}
"""
return if (isSearchById) {
getMangaDetailsById(search)
} else {
$$"""
|query Query($query: String!) {
|findMangaBySlug(slug: $query) {
|$$COMMON_MANGA_DATA
|}
|}
"""
getMangaDetailsBySlug(search)
}
}
val payload = buildJsonObject {
put("query", query.trimMargin())
putJsonObject("variables") {
put("query", search)
private suspend fun getMangaDetailsById(id: String): TrackSearch? {
return graphQlClient
.query(
KitsuGetMangaDetailsByIdQuery(
id = id,
),
)
.execute()
.dataOrElse(
errorLog = "Kitsu: Search by ID failed",
default = { null },
) {
it.findMangaById?.toTrackSearch(trackerId)
}
}
}
return withIOContext {
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 {
response
.parseAs<KitsuSearchBySlugResult>()
.data.findMangaBySlug
}
kitsuManga?.toTrackSearch(trackerId)
private suspend fun getMangaDetailsBySlug(slug: String): TrackSearch? {
return graphQlClient
.query(
KitsuGetMangaDetailsBySlugQuery(
slug = slug,
),
)
.execute()
.dataOrElse(
errorLog = "Kitsu: Search by Slug failed",
default = { null },
) {
it.findMangaBySlug?.toTrackSearch(trackerId)
}
}
}
companion object {
private const val CLIENT_ID = "dd031b32d2f56c990b1425efe6c42ad847e7fe3ab46bf1299f05ecd856bdb7dd"
private const val CLIENT_SECRET = "54d7307928f63414defd96399fc31ba847961ceaecef3a5fd93144e960c0e151"
private const val GRAPHQL_API_URL = "https://kitsu.app/api/graphql"
private const val LOGIN_URL = "https://kitsu.app/api/oauth/token"
fun refreshTokenRequest(token: String) = POST(
@@ -457,38 +269,5 @@ class KitsuApi(
.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,21 +1,98 @@
package eu.kanade.tachiyomi.data.track.kitsu
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuUser
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import mihon.graphql.kitsu.KitsuFindLibMangaQuery
import mihon.graphql.kitsu.KitsuGetCurrentAccountQuery
import mihon.graphql.kitsu.KitsuGetMangaDetailsByIdQuery
import mihon.graphql.kitsu.KitsuGetMangaDetailsBySlugQuery
import mihon.graphql.kitsu.KitsuSearchMangaByTitleQuery
import mihon.graphql.kitsu.fragment.MangaFragment
import mihon.graphql.kitsu.type.LibraryEntryStatusEnum
import mihon.graphql.kitsu.type.MangaSubtypeEnum
import mihon.graphql.kitsu.type.ReleaseStatusEnum
internal 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")
internal fun Track.toKitsuStatus() = when (this.status) {
Kitsu.READING -> LibraryEntryStatusEnum.CURRENT
Kitsu.PLAN_TO_READ -> LibraryEntryStatusEnum.PLANNED
Kitsu.COMPLETED -> LibraryEntryStatusEnum.COMPLETED
Kitsu.ON_HOLD -> LibraryEntryStatusEnum.ON_HOLD
Kitsu.DROPPED -> LibraryEntryStatusEnum.DROPPED
else -> throw Exception("Unknown status: ${this.status}")
}
internal fun String.toKitsuLocalStatus() = when (this) {
"CURRENT" -> Kitsu.READING
"COMPLETED" -> Kitsu.COMPLETED
"ON_HOLD" -> Kitsu.ON_HOLD
"DROPPED" -> Kitsu.DROPPED
"PLANNED" -> Kitsu.PLAN_TO_READ
internal fun LibraryEntryStatusEnum.toLocalStatus(): Long = when (this) {
LibraryEntryStatusEnum.CURRENT -> Kitsu.READING
LibraryEntryStatusEnum.PLANNED -> Kitsu.PLAN_TO_READ
LibraryEntryStatusEnum.COMPLETED -> Kitsu.COMPLETED
LibraryEntryStatusEnum.ON_HOLD -> Kitsu.ON_HOLD
LibraryEntryStatusEnum.DROPPED -> Kitsu.DROPPED
else -> throw Exception("Unknown status: $this")
}
internal fun KitsuGetCurrentAccountQuery.CurrentAccount.toKitsuUser(): KitsuUser {
return KitsuUser(
id = id,
name = profile.name,
ratingSystem = ratingSystem.rawValue,
)
}
internal fun KitsuGetMangaDetailsByIdQuery.FindMangaById.toTrackSearch(trackId: Long): TrackSearch {
return mangaFragment.toTrackSearch(trackId)
}
internal fun KitsuGetMangaDetailsBySlugQuery.FindMangaBySlug.toTrackSearch(trackId: Long): TrackSearch {
return mangaFragment.toTrackSearch(trackId)
}
internal fun KitsuSearchMangaByTitleQuery.Node.toTrackSearch(trackId: Long): TrackSearch {
return mangaFragment.toTrackSearch(trackId)
}
internal fun KitsuFindLibMangaQuery.FindMangaById.toTrackSearch(trackId: Long): TrackSearch? {
if (myLibraryEntry == null) return null
return mangaFragment.toTrackSearch(trackId).apply {
library_id = myLibraryEntry.id.toLong()
started_reading_date = myLibraryEntry.startedAt?.toEpochMilliseconds() ?: 0
finished_reading_date = myLibraryEntry.finishedAt?.toEpochMilliseconds() ?: 0
status = myLibraryEntry.status.toLocalStatus()
score = myLibraryEntry.rating?.toDouble() ?: 0.0
last_chapter_read = myLibraryEntry.progress.toDouble()
private = myLibraryEntry.private
}
}
private fun MangaFragment.toTrackSearch(trackId: Long): TrackSearch {
val kitsuManga = this
return TrackSearch.create(trackId).apply {
remote_id = kitsuManga.id.toLong()
title = kitsuManga.titles.preferred
total_chapters = kitsuManga.chapterCount?.toLong() ?: 0
cover_url = kitsuManga.posterImage?.views?.firstOrNull()?.url ?: kitsuManga.posterImage?.original?.url.orEmpty()
summary = (kitsuManga.description["en"] as? String).orEmpty()
tracking_url = "https://kitsu.app/manga/${kitsuManga.slug}"
score = kitsuManga.averageRating ?: -1.0
publishing_status = when (kitsuManga.status) {
ReleaseStatusEnum.TBA -> ReleaseStatusEnum.TBA.rawValue
else -> kitsuManga.status.rawValue.lowercase().replaceFirstChar { it.uppercase() }
}
publishing_type = when (kitsuManga.subtype) {
MangaSubtypeEnum.OEL -> MangaSubtypeEnum.OEL.rawValue
else -> kitsuManga.subtype.rawValue.lowercase().replaceFirstChar { it.uppercase() }
}
start_date = kitsuManga.startDate.orEmpty()
authors = kitsuManga.staff.nodes
?.filterNotNull()
?.filter { it.role.contains("Story") }
?.map { it.person.name }
?: emptyList()
artists = kitsuManga.staff.nodes
?.filterNotNull()
?.filter { it.role.contains("Art") }
?.map { it.person.name }
?: emptyList()
}
}
@@ -1,23 +0,0 @@
package eu.kanade.tachiyomi.data.track.kitsu.dto
import kotlinx.serialization.Serializable
@Serializable
data class KitsuAddMangaResult(
// 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 KitsuAddMangaData(
val libraryEntry: KitsuAddMangaLibraryEntryWrapper,
)
@Serializable
data class KitsuAddMangaLibraryEntryWrapper(
val create: KitsuLibraryEntryResult,
)
@@ -1,18 +0,0 @@
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,
)
@@ -1,23 +0,0 @@
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,85 +0,0 @@
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,
)
@@ -1,74 +0,0 @@
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?,
)
@@ -1,25 +0,0 @@
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?,
)
@@ -1,13 +0,0 @@
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?,
)
@@ -1,18 +0,0 @@
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,23 +0,0 @@
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,
)
@@ -1,25 +1,7 @@
package eu.kanade.tachiyomi.data.track.kitsu.dto
import kotlinx.serialization.Serializable
@Serializable
data class KitsuCurrentAccountResult(
val data: KitsuCurrentAccountData,
)
@Serializable
data class KitsuCurrentAccountData(
val currentAccount: KitsuAccount,
)
@Serializable
data class KitsuAccount(
data class KitsuUser(
val id: String,
val ratingSystem: String,
val profile: KitsuProfile,
)
@Serializable
data class KitsuProfile(
val name: String,
val ratingSystem: String,
)
@@ -2,24 +2,29 @@ package eu.kanade.tachiyomi.data.track.shikimori
import android.net.Uri
import androidx.core.net.toUri
import com.apollographql.apollo.ApolloClient
import com.apollographql.apollo.network.okHttpClient
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.data.track.shikimori.dto.SMLibraryIdResponse
import eu.kanade.tachiyomi.data.track.shikimori.dto.SMOAuth
import eu.kanade.tachiyomi.data.track.shikimori.dto.SMSearchResult
import eu.kanade.tachiyomi.data.track.shikimori.dto.SMUser
import eu.kanade.tachiyomi.data.track.shikimori.dto.SMUserListResult
import eu.kanade.tachiyomi.data.track.shikimori.dto.SMUserResult
import eu.kanade.tachiyomi.network.DELETE
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.PUT
import eu.kanade.tachiyomi.network.awaitSuccess
import eu.kanade.tachiyomi.network.dataOrElse
import eu.kanade.tachiyomi.network.jsonMime
import eu.kanade.tachiyomi.network.parseAs
import kotlinx.coroutines.Dispatchers
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonObject
import mihon.graphql.shikimori.ShikimoriGetCurrentUserQuery
import mihon.graphql.shikimori.ShikimoriGetLibMangaQuery
import mihon.graphql.shikimori.ShikimoriGetMangaDetailsQuery
import mihon.graphql.shikimori.ShikimoriSearchMangaQuery
import okhttp3.FormBody
import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.toRequestBody
@@ -37,6 +42,16 @@ class ShikimoriApi(
private val authClient = client.newBuilder().addInterceptor(interceptor).build()
private val graphQlClient by lazy {
ApolloClient.Builder()
.serverUrl("$API_URL/graphql")
.okHttpClient(authClient)
.dispatcher(Dispatchers.IO)
// required to log the error body in dataOrElse, which also properly closes it
.httpExposeErrorBody(true)
.build()
}
suspend fun addLibManga(track: Track, userId: String): Track {
return withIOContext {
with(json) {
@@ -102,178 +117,78 @@ class ShikimoriApi(
}
suspend fun search(search: String): List<TrackSearch> {
return withIOContext {
val query = $$"""
|query($query: String) {
|mangas(search: $query, limit: 20, kind:"!light_novel,!novel") {
|id
|name
|chapters
|kind
|poster {
|mainUrl
|}
|score
|url
|status
|airedOn {
|date
|}
|description
|personRoles {
|person {
|name
|}
|rolesEn
|}
|}
|}
""".trimMargin()
val payload = buildJsonObject {
put("query", query)
putJsonObject("variables") {
put("query", search)
return graphQlClient
.query(
ShikimoriSearchMangaQuery(search = search),
)
.execute()
.dataOrElse(
errorLog = "Shikimori: Search failed",
default = { emptyList() },
) {
it.mangas.map { manga ->
manga.toTrackSearch(trackerId)
}
}
with(json) {
authClient.newCall(
POST(
GRAPHQL_API_URL,
body = payload.toString().toRequestBody(jsonMime),
),
)
.awaitSuccess()
.parseAs<SMSearchResult>()
.data.mangas
.map { it.toTrack(trackerId) }
}
}
}
suspend fun getMangaDetails(id: Int): TrackSearch? {
return withIOContext {
val query = $$"""
|query($query: String) {
|mangas(ids: $query, limit: 1, kind:"!light_novel,!novel") {
|id
|name
|chapters
|kind
|poster {
|mainUrl
|}
|score
|url
|status
|airedOn {
|date
|}
|description
|personRoles {
|person {
|name
|}
|rolesEn
|}
|}
|}
""".trimMargin()
val payload = buildJsonObject {
put("query", query)
putJsonObject("variables") {
put("query", "$id")
}
}
with(json) {
authClient.newCall(
POST(
GRAPHQL_API_URL,
body = payload.toString().toRequestBody(jsonMime),
),
)
.awaitSuccess()
.parseAs<SMSearchResult>()
.data.mangas
return graphQlClient
.query(
ShikimoriGetMangaDetailsQuery(query = "$id"),
)
.execute()
.dataOrElse(
errorLog = "Shikimori: Failed to get manga details",
default = { null },
) {
it.mangas
.firstOrNull()
?.toTrack(trackerId)
?.toTrackSearch(trackerId)
}
}
}
suspend fun findLibManga(track: Track): Track? {
return withIOContext {
val query = $$"""
|query($id: String) {
|mangas(ids: $id, limit: 1) {
|id
|url
|name
|chapters
|userRate {
|id
|chapters
|status
|score
|}
|}
|}
""".trimMargin()
val payload = buildJsonObject {
put("query", query)
putJsonObject("variables") {
put("id", track.remote_id.toString())
}
}
with(json) {
val listResult = authClient.newCall(
POST(
GRAPHQL_API_URL,
body = payload.toString().toRequestBody(jsonMime),
),
)
.awaitSuccess()
.parseAs<SMUserListResult>()
.data.mangas
.firstOrNull()
return graphQlClient
.query(
ShikimoriGetLibMangaQuery(
remote_id = track.remote_id.toString(),
),
)
.execute()
.dataOrElse(
errorLog = "Shikimori: Failed to find manga in library",
default = { null },
) {
val mangaResult = it.mangas.firstOrNull()
// Shikimori has no user list query that allows query by ID, so we go via the "mangas" query & include
// userRate data which will be null if the title is not in the user's list.
// If it was removed on Shikimori and is still linked in the app, notify user via returning null here
// which throws an exception at the Shikimori.refresh call
if (listResult?.userRate == null) {
if (mangaResult?.userRate == null) {
null
} else {
listResult.toTrack(trackerId)
mangaResult.toTrack(trackerId)
}
}
}
}
suspend fun getCurrentUser(): SMUser {
return with(json) {
val query = """
|{
|currentUser {
|id
|nickname
|}
|}
""".trimMargin()
val payload = buildJsonObject {
put("query", query)
}
authClient.newCall(
POST(
GRAPHQL_API_URL,
body = payload.toString().toRequestBody(jsonMime),
),
return graphQlClient
.query(
ShikimoriGetCurrentUserQuery(),
)
.awaitSuccess()
.parseAs<SMUserResult>()
.data.currentUser
}
.execute()
.dataOrElse(
errorLog = "Shikimori: Failed to get current user",
default = { null },
) {
it.currentUser?.let { currentUser ->
SMUser(id = currentUser.id, nickname = currentUser.nickname)
}
}
?: throw Exception("Failed to get Shikimori user data")
}
suspend fun accessToken(code: String): SMOAuth {
@@ -300,7 +215,6 @@ class ShikimoriApi(
companion object {
private const val BASE_URL = "https://shikimori.io"
private const val API_URL = "$BASE_URL/api"
private const val GRAPHQL_API_URL = "$BASE_URL/api/graphql"
private const val OAUTH_URL = "$BASE_URL/oauth/token"
private const val LOGIN_URL = "$BASE_URL/oauth/authorize"
@@ -1,23 +1,97 @@
package eu.kanade.tachiyomi.data.track.shikimori
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import mihon.graphql.shikimori.ShikimoriGetLibMangaQuery
import mihon.graphql.shikimori.ShikimoriGetMangaDetailsQuery
import mihon.graphql.shikimori.ShikimoriSearchMangaQuery
import mihon.graphql.shikimori.fragment.MangaFragment
import mihon.graphql.shikimori.type.MangaKindEnum
import mihon.graphql.shikimori.type.MangaStatusEnum
import mihon.graphql.shikimori.type.UserRateStatusEnum
internal fun ShikimoriGetLibMangaQuery.Manga.toTrack(trackId: Long): Track {
val sManga = this
return Track.create(trackId).apply {
remote_id = sManga.id.toLong()
title = sManga.name
total_chapters = sManga.chapters.toLong()
tracking_url = sManga.url
if (sManga.userRate != null) {
// null if not in user's list, must not throw here because it'd break adding titles
// throws in the findLibManga method of ShikimoriApi if null and shouldn't be
library_id = sManga.userRate.id.toLong()
last_chapter_read = sManga.userRate.chapters.toDouble()
score = sManga.userRate.score.toDouble()
status = sManga.userRate.status.toLocalStatus()
}
}
}
internal fun ShikimoriGetMangaDetailsQuery.Manga.toTrackSearch(trackId: Long): TrackSearch {
return mangaFragment.toTrackSearch(trackId)
}
internal fun ShikimoriSearchMangaQuery.Manga.toTrackSearch(trackId: Long): TrackSearch {
return mangaFragment.toTrackSearch(trackId)
}
private fun MangaFragment.toTrackSearch(trackId: Long): TrackSearch {
val sManga = this
return TrackSearch.create(trackId).apply {
remote_id = sManga.id.toLong()
title = sManga.name
total_chapters = sManga.chapters.toLong()
cover_url = sManga.poster?.mainUrl.orEmpty()
summary = sManga.description.orEmpty()
score = sManga.score?.takeIf { it > 0.0 } ?: -1.0
tracking_url = sManga.url
publishing_status = sManga.status.toLocalString()
publishing_type = sManga.kind.toLocalString()
start_date = sManga.airedOn?.date.orEmpty()
authors = sManga.personRoles?.filter { it.rolesEn.contains("Story") }?.map { it.person.name } ?: emptyList()
artists = sManga.personRoles?.filter { it.rolesEn.contains("Art") }?.map { it.person.name } ?: emptyList()
}
}
private fun MangaKindEnum?.toLocalString() = when (this) {
MangaKindEnum.manga -> "Manga"
MangaKindEnum.manhwa -> "Manhwa"
MangaKindEnum.manhua -> "Manhua"
MangaKindEnum.one_shot -> "Oneshot"
MangaKindEnum.doujin -> "Doujin"
// light novel & novel omitted by query filter, fall back to empty if included
else -> ""
}
private fun MangaStatusEnum?.toLocalString() = when (this) {
MangaStatusEnum.anons -> "Planned"
MangaStatusEnum.ongoing -> "Ongoing"
MangaStatusEnum.released -> "Released"
MangaStatusEnum.paused -> "Paused"
MangaStatusEnum.discontinued -> "Discontinued"
else -> ""
}
private fun UserRateStatusEnum.toLocalStatus() = when (this) {
UserRateStatusEnum.planned -> Shikimori.PLAN_TO_READ
UserRateStatusEnum.watching -> Shikimori.READING
UserRateStatusEnum.rewatching -> Shikimori.REREADING
UserRateStatusEnum.completed -> Shikimori.COMPLETED
UserRateStatusEnum.on_hold -> Shikimori.ON_HOLD
UserRateStatusEnum.dropped -> Shikimori.DROPPED
else -> throw NotImplementedError("Unknown status: $this")
}
internal fun Track.toShikimoriStatus() = when (status) {
Shikimori.READING -> "watching"
Shikimori.COMPLETED -> "completed"
Shikimori.ON_HOLD -> "on_hold"
Shikimori.DROPPED -> "dropped"
Shikimori.PLAN_TO_READ -> "planned"
Shikimori.REREADING -> "rewatching"
else -> throw NotImplementedError("Unknown status: $status")
}
internal fun toTrackStatus(status: String) = when (status) {
"watching" -> Shikimori.READING
"completed" -> Shikimori.COMPLETED
"on_hold" -> Shikimori.ON_HOLD
"dropped" -> Shikimori.DROPPED
"planned" -> Shikimori.PLAN_TO_READ
"rewatching" -> Shikimori.REREADING
// rawValues because Shikimori doesn't have GraphQL mutations and this goes through the v2 API
// if/when Shikimori adds GraphQL mutations, this should return UserRateStatusEnum members
Shikimori.READING -> UserRateStatusEnum.watching.rawValue
Shikimori.COMPLETED -> UserRateStatusEnum.completed.rawValue
Shikimori.ON_HOLD -> UserRateStatusEnum.on_hold.rawValue
Shikimori.DROPPED -> UserRateStatusEnum.dropped.rawValue
Shikimori.PLAN_TO_READ -> UserRateStatusEnum.planned.rawValue
Shikimori.REREADING -> UserRateStatusEnum.rewatching.rawValue
else -> throw NotImplementedError("Unknown status: $status")
}
@@ -1,73 +0,0 @@
package eu.kanade.tachiyomi.data.track.shikimori.dto
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class SMSearchResult(
val data: SMMangaResults,
)
@Serializable
data class SMMangaResults(
val mangas: List<SMManga>,
)
@Serializable
data class SMManga(
val id: Long,
val name: String,
val chapters: Long,
val score: Double?,
val url: String,
val status: String?,
val poster: SMPoster?,
val airedOn: SMAiredDate?,
val description: String?,
val kind: String?,
val personRoles: List<SMPersonRole>?,
) {
fun toTrack(trackerId: Long): TrackSearch {
return TrackSearch.create(trackerId).apply {
remote_id = this@SMManga.id
title = name
total_chapters = chapters
cover_url = poster?.mainUrl.orEmpty()
summary = description.orEmpty()
score = this@SMManga.score?.takeIf { it > 0.0 } ?: -1.0
tracking_url = url
publishing_status = this@SMManga.status.orEmpty()
publishing_type = kind?.replace("one_shot", "oneshot").orEmpty()
start_date = airedOn?.date.orEmpty()
personRoles?.forEach { personRole ->
personRole.roles.forEach { role ->
if ("Story" in role) authors += personRole.person.name
if ("Art" in role) artists += personRole.person.name
}
}
}
}
}
@Serializable
data class SMPoster(
val mainUrl: String,
)
@Serializable
data class SMAiredDate(
val date: String?,
)
@Serializable
data class SMPersonRole(
val person: SMPerson,
@SerialName("rolesEn")
val roles: List<String>,
)
@Serializable
data class SMPerson(
val name: String,
)
@@ -1,18 +1,5 @@
package eu.kanade.tachiyomi.data.track.shikimori.dto
import kotlinx.serialization.Serializable
@Serializable
data class SMUserResult(
val data: SMCurrentUser,
)
@Serializable
data class SMCurrentUser(
val currentUser: SMUser,
)
@Serializable
data class SMUser(
val id: String,
val nickname: String,
@@ -1,52 +0,0 @@
package eu.kanade.tachiyomi.data.track.shikimori.dto
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.shikimori.toTrackStatus
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class SMUserListResult(
val data: SMUserListEntries,
)
@Serializable
data class SMUserListEntries(
val mangas: List<SMUserListManga>,
)
@Serializable
data class SMUserListManga(
val id: String,
val url: String,
val name: String,
@SerialName("chapters")
val totalChapters: Long, // the title's total chapters
val userRate: SMUserRate?,
) {
fun toTrack(trackerId: Long): Track {
return Track.create(trackerId).apply {
title = name
total_chapters = totalChapters
tracking_url = url
if (userRate != null) {
// null if not in user's list, must not throw here because it'd break adding titles
// throws in the findLibManga method of ShikimoriApi if null and shouldn't be
remote_id = userRate.rateId.toLong()
library_id = userRate.rateId.toLong()
last_chapter_read = userRate.chapters.toDouble()
score = userRate.score
status = toTrackStatus(userRate.status)
}
}
}
}
@Serializable
data class SMUserRate(
@SerialName("id")
val rateId: String, // ID of the list entry (NOT the title)
val chapters: Long, // the user's chapter progress
val status: String,
val score: Double,
)
@@ -56,7 +56,7 @@ class Suwayomi(id: Long) : BaseTracker(id, "Suwayomi"), EnhancedTracker {
}
}
return api.updateProgress(track, getPrefTrackerDelete())
return api.updateProgress(track, getPrefTrackerDelete()) ?: throw Exception("Could not update manga")
}
override suspend fun bind(track: Track, hasReadChapters: Boolean): Track {
@@ -68,7 +68,7 @@ class Suwayomi(id: Long) : BaseTracker(id, "Suwayomi"), EnhancedTracker {
}
override suspend fun refresh(track: Track): Track {
val remoteTrack = api.getTrackSearch(track.remote_id)
val remoteTrack = api.getTrackSearch(track.remote_id) ?: throw Exception("Could not find manga")
track.copyPersonalFrom(remoteTrack)
track.total_chapters = remoteTrack.total_chapters
return track
@@ -84,25 +84,27 @@ class Suwayomi(id: Long) : BaseTracker(id, "Suwayomi"), EnhancedTracker {
override fun getAcceptedSources(): List<String> = listOf("eu.kanade.tachiyomi.extension.all.tachidesk.Tachidesk")
override suspend fun match(manga: DomainManga): TrackSearch? =
try {
override suspend fun match(manga: DomainManga): TrackSearch? {
return try {
api.getTrackSearch(manga.url.getMangaId())
} catch (_: Exception) {
null
}
}
override fun isTrackFrom(track: DomainTrack, manga: DomainManga, source: Source?): Boolean =
track.remoteUrl == manga.url && source?.let { accept(it) } == true
override fun isTrackFrom(track: DomainTrack, manga: DomainManga, source: Source?): Boolean {
return track.remoteUrl == manga.url && source?.let { accept(it) } == true
}
override fun migrateTrack(track: DomainTrack, manga: DomainManga, newSource: Source): DomainTrack? =
if (accept(newSource)) {
override fun migrateTrack(track: DomainTrack, manga: DomainManga, newSource: Source): DomainTrack? {
return if (accept(newSource)) {
track.copy(remoteUrl = manga.url)
} else {
null
}
}
private fun String.getMangaId(): Long =
this.substringAfterLast('/').toLong()
private fun String.getMangaId(): Long = this.substringAfterLast('/').toLong()
private fun getPrefTrackerDelete(): Boolean {
val preferences = api.sourcePreferences()
@@ -1,187 +1,112 @@
package eu.kanade.tachiyomi.data.track.suwayomi
import android.content.SharedPreferences
import com.apollographql.apollo.ApolloClient
import com.apollographql.apollo.network.okHttpClient
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.awaitSuccess
import eu.kanade.tachiyomi.network.jsonMime
import eu.kanade.tachiyomi.network.parseAs
import eu.kanade.tachiyomi.network.dataOrElse
import eu.kanade.tachiyomi.source.ConfigurableSource
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.source.sourcePreferences
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.addAll
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonArray
import kotlinx.serialization.json.putJsonObject
import mihon.graphql.suwayomi.SuwayomiGetMangaQuery
import mihon.graphql.suwayomi.SuwayomiGetMangaUnreadChaptersQuery
import mihon.graphql.suwayomi.SuwayomiMarkAndDeleteChaptersMutation
import mihon.graphql.suwayomi.SuwayomiMarkChaptersReadMutation
import mihon.graphql.suwayomi.SuwayomiUpdateMangaProgressMutation
import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.toRequestBody
import tachiyomi.core.common.util.lang.withIOContext
import tachiyomi.domain.source.service.SourceManager
import uy.kohesive.injekt.injectLazy
import java.security.MessageDigest
class SuwayomiApi(
private val trackerId: Long,
private val sourceManager: SourceManager,
) {
private val json: Json by injectLazy()
// Blocking is fine here: these are only touched from OkHttp and tracker threads.
private val source: HttpSource by lazy { runBlocking { sourceManager.get(sourceId) as HttpSource } }
private val configurableSource: ConfigurableSource by lazy {
runBlocking { sourceManager.get(sourceId) as ConfigurableSource }
}
private val client: OkHttpClient by lazy { source.client }
private val baseUrl: String by lazy { source.baseUrl.trimEnd('/') }
private val source: Source by lazy { runBlocking { sourceManager.get(sourceId)!! } }
private val httpSource: HttpSource by lazy { source as HttpSource }
private val configurableSource: ConfigurableSource by lazy { source as ConfigurableSource }
private val client: OkHttpClient by lazy { httpSource.client }
private val baseUrl: String by lazy { httpSource.baseUrl.trimEnd('/') }
private val apiUrl: String by lazy { "$baseUrl/api/graphql" }
private val graphQlClient by lazy {
ApolloClient.Builder()
.serverUrl(apiUrl)
.okHttpClient(client)
.dispatcher(Dispatchers.IO)
// required to log the error body in dataOrElse, which also properly closes it
.httpExposeErrorBody(true)
.build()
}
fun sourcePreferences(): SharedPreferences = configurableSource.sourcePreferences()
suspend fun getTrackSearch(mangaId: Long): TrackSearch = withIOContext {
val query = $$"""
|query GetManga($mangaId: Int!) {
| manga(id: $mangaId) {
| ...MangaFragment
| }
|}
|
|$$MangaFragment
""".trimMargin()
val payload = buildJsonObject {
put("query", query)
putJsonObject("variables") {
put("mangaId", mangaId)
}
}
val manga = with(json) {
client.newCall(
POST(
apiUrl,
body = payload.toString().toRequestBody(jsonMime),
),
suspend fun getTrackSearch(mangaId: Long): TrackSearch? {
return graphQlClient
.query(
SuwayomiGetMangaQuery(mangaId = mangaId.toInt()),
)
.awaitSuccess()
.parseAs<GetMangaResult>()
.data
.entry
}
TrackSearch.create(trackerId).apply {
remote_id = mangaId
title = manga.title
cover_url = "$baseUrl/${manga.thumbnailUrl}"
summary = manga.description.orEmpty()
tracking_url = "$baseUrl/manga/$mangaId"
total_chapters = manga.chapters.totalCount.toLong()
publishing_status = manga.status.name
last_chapter_read = manga.latestReadChapter?.chapterNumber ?: 0.0
status = when (manga.unreadCount) {
manga.chapters.totalCount -> Suwayomi.UNREAD
0 -> Suwayomi.COMPLETED
else -> Suwayomi.READING
.execute()
.dataOrElse(
errorLog = "Suwayomi: Failed to find manga in library",
default = { null },
) {
it.manga.mangaFragment.toTrackSearch(trackerId, baseUrl)
}
}
}
suspend fun updateProgress(track: Track, deleteDownloadsOnServer: Boolean = false): Track {
suspend fun updateProgress(track: Track, deleteDownloadsOnServer: Boolean = false): Track? {
val mangaId = track.remote_id
// TODO: Include a filter on the chapter number here
// Below, we only consider older chapters; since v2.1.1985 filtering works properly in the query
val chaptersQuery = $$"""
|query GetMangaUnreadChapters($mangaId: Int!) {
| chapters(condition: {mangaId: $mangaId, isRead: false}) {
| nodes {
| id
| chapterNumber
| }
| }
|}
""".trimMargin()
val chaptersPayload = buildJsonObject {
put("query", chaptersQuery)
putJsonObject("variables") {
put("mangaId", mangaId)
}
}
val chaptersToMark = with(json) {
client.newCall(
POST(
apiUrl,
body = chaptersPayload.toString().toRequestBody(jsonMime),
val chaptersToMark = graphQlClient
.query(
SuwayomiGetMangaUnreadChaptersQuery(
mangaId = mangaId.toInt(),
chapterNumber = track.last_chapter_read,
),
)
.awaitSuccess()
.parseAs<GetMangaUnreadChaptersResult>()
.data
.entry
.nodes
.mapNotNull { n -> n.id.takeIf { n.chapterNumber <= track.last_chapter_read + 0.001 } }
.execute()
.dataOrElse(
errorLog = "Suwayomi: Failed to get chapters data",
default = { null },
) {
it.chapters.nodes.map { chapter -> chapter.id }
}
?: throw Exception("Could not get chapters data")
if (chaptersToMark.isEmpty()) {
return getTrackSearch(mangaId)
}
val markQuery = if (deleteDownloadsOnServer) {
$$"""
|mutation MarkChaptersRead($chapters: [Int!]!) {
| updateChapters(input: {ids: $chapters, patch: {isRead: true}}) {
| __typename
| }
| deleteDownloadedChapters(input: {ids: $chapters}) {
| __typename
| }
|}
""".trimMargin()
val markMutation = if (deleteDownloadsOnServer) {
SuwayomiMarkAndDeleteChaptersMutation(chapters = chaptersToMark)
} else {
$$"""
|mutation MarkChaptersRead($chapters: [Int!]!) {
| updateChapters(input: {ids: $chapters, patch: {isRead: true}}) {
| __typename
| }
|}
""".trimMargin()
SuwayomiMarkChaptersReadMutation(chapters = chaptersToMark)
}
val markPayload = buildJsonObject {
put("query", markQuery)
putJsonObject("variables") {
putJsonArray("chapters") {
addAll(chaptersToMark)
}
}
}
client.newCall(
POST(
apiUrl,
body = markPayload.toString().toRequestBody(jsonMime),
),
)
.awaitSuccess()
val trackQuery = $$"""
|mutation TrackManga($mangaId: Int!) {
| trackProgress(input: {mangaId: $mangaId}) {
| __typename
| }
|}
""".trimMargin()
val trackPayload = buildJsonObject {
put("query", trackQuery)
putJsonObject("variables") {
put("mangaId", mangaId)
}
}
client.newCall(
POST(
apiUrl,
body = trackPayload.toString().toRequestBody(jsonMime),
),
)
.awaitSuccess()
graphQlClient
.mutation(markMutation)
.execute()
.dataOrElse(
errorLog = "Suwayomi: Failed to mark chapters",
default = {},
) {}
return getTrackSearch(track.remote_id)
graphQlClient
.mutation(
SuwayomiUpdateMangaProgressMutation(mangaId = mangaId.toInt()),
)
.execute()
.dataOrElse(
errorLog = "Suwayomi: Failed to update progress",
default = {},
) {}
return getTrackSearch(mangaId)
}
private val sourceId by lazy {
@@ -189,36 +114,4 @@ class SuwayomiApi(
val bytes = MessageDigest.getInstance("MD5").digest(key.toByteArray())
(0..7).map { bytes[it].toLong() and 0xff shl 8 * (7 - it) }.reduce(Long::or) and Long.MAX_VALUE
}
companion object {
private val MangaFragment = """
|fragment MangaFragment on MangaType {
| artist
| author
| description
| id
| status
| thumbnailUrl
| title
| url
| genre
| inLibraryAt
| chapters {
| totalCount
| }
| latestUploadedChapter {
| uploadDate
| }
| latestFetchedChapter {
| fetchedAt
| }
| latestReadChapter {
| lastReadAt
| chapterNumber
| }
| unreadCount
| downloadCount
|}
""".trimMargin()
}
}
@@ -0,0 +1,23 @@
package eu.kanade.tachiyomi.data.track.suwayomi
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import mihon.graphql.suwayomi.fragment.MangaFragment
internal fun MangaFragment.toTrackSearch(trackId: Long, baseUrl: String): TrackSearch {
val manga = this
return TrackSearch.create(trackId).apply {
remote_id = manga.id.toLong()
title = manga.title
cover_url = "$baseUrl/${manga.thumbnailUrl}"
summary = manga.description.orEmpty()
tracking_url = "$baseUrl/manga/${manga.id}"
total_chapters = manga.chapters.totalCount.toLong()
publishing_status = manga.status.name
last_chapter_read = manga.latestReadChapter?.chapterNumber ?: 0.0
status = when (manga.unreadCount) {
manga.chapters.totalCount -> Suwayomi.UNREAD
0 -> Suwayomi.COMPLETED
else -> Suwayomi.READING
}
}
}
@@ -1,88 +0,0 @@
package eu.kanade.tachiyomi.data.track.suwayomi
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
enum class MangaStatus(
val rawValue: String,
) {
UNKNOWN("UNKNOWN"),
ONGOING("ONGOING"),
COMPLETED("COMPLETED"),
LICENSED("LICENSED"),
PUBLISHING_FINISHED("PUBLISHING_FINISHED"),
CANCELLED("CANCELLED"),
ON_HIATUS("ON_HIATUS"),
}
@Serializable
data class MangaFragment(
val artist: String?,
val author: String?,
val description: String?,
val id: Int,
val status: MangaStatus,
val thumbnailUrl: String?,
val title: String,
val url: String,
val genre: List<String>,
val inLibraryAt: Long,
val chapters: Chapters,
val latestUploadedChapter: LatestUploadedChapter?,
val latestFetchedChapter: LatestFetchedChapter?,
val latestReadChapter: LatestReadChapter?,
val unreadCount: Int,
val downloadCount: Int,
) {
@Serializable
data class Chapters(
val totalCount: Int,
)
@Serializable
data class LatestUploadedChapter(
val uploadDate: Long,
)
@Serializable
data class LatestFetchedChapter(
val fetchedAt: Long,
)
@Serializable
data class LatestReadChapter(
val lastReadAt: Long,
val chapterNumber: Double,
)
}
@Serializable
data class GetMangaResult(
val data: GetMangaData,
)
@Serializable
data class GetMangaData(
@SerialName("manga") val entry: MangaFragment,
)
@Serializable
data class GetMangaUnreadChaptersEntry(
val nodes: List<GetMangaUnreadChaptersNode>,
)
@Serializable
data class GetMangaUnreadChaptersNode(
val id: Int,
val chapterNumber: Double,
)
@Serializable
data class GetMangaUnreadChaptersResult(
val data: GetMangaUnreadChaptersData,
)
@Serializable
data class GetMangaUnreadChaptersData(
@SerialName("chapters") val entry: GetMangaUnreadChaptersEntry,
)
@@ -0,0 +1,26 @@
package mihon.graphql.kitsu.adapter
import com.apollographql.apollo.api.Adapter
import com.apollographql.apollo.api.AnyAdapter
import com.apollographql.apollo.api.CustomScalarAdapters
import com.apollographql.apollo.api.json.JsonReader
import com.apollographql.apollo.api.json.JsonWriter
object KitsuMapAdapter : Adapter<Map<String, Any?>> {
override fun fromJson(
reader: JsonReader,
customScalarAdapters: CustomScalarAdapters,
): Map<String, Any?> {
@Suppress("UNCHECKED_CAST")
return AnyAdapter.fromJson(reader, customScalarAdapters) as Map<String, Any?>
}
override fun toJson(
writer: JsonWriter,
customScalarAdapters: CustomScalarAdapters,
value: Map<String, Any?>,
) {
AnyAdapter.toJson(writer, customScalarAdapters, value)
}
}
+2
View File
@@ -33,6 +33,8 @@ dependencies {
api(libs.okhttp.dnsOverHttps)
api(libs.okio)
api(libs.apollo)
implementation(libs.image.decoder)
implementation(libs.unifile)
@@ -0,0 +1,56 @@
package eu.kanade.tachiyomi.network
import com.apollographql.apollo.api.ApolloResponse
import com.apollographql.apollo.api.Operation
import com.apollographql.apollo.exception.ApolloHttpException
import com.apollographql.apollo.exception.ApolloNetworkException
import logcat.LogPriority
import tachiyomi.core.common.util.system.logcat
/**
* Returns the result of calling [transform] with [data] if the GraphQL response had data present without errors,
* otherwise logs the failure under [errorLog] and falls back to [default].
*
* Any [ApolloHttpException] is re-thrown as [HttpException] after logging the response body.
* This behavior can be replaced with a custom handler by providing a non-null value to [onException]. Such a lambda
* MUST close the [ApolloHttpException.body] properly to avoid sockets and other resources leaking.
*
* Other failures are logged and the result of calling [default] is returned.
*
* @param errorLog A log message which will be included with any exceptions or errors being logged.
* @param default A lambda which provides a default value to be returned in place of the expected value when an
* exception or error is found.
* @param onException A handler for [ApolloHttpException]s. It MUST handle closing the response body to avoid resource
* leaks. Defaults to throwing a [HttpException] with the same status code.
* @param transform A transforming lambda for the GraphQL data received.
*/
fun <D : Operation.Data, R> ApolloResponse<D>.dataOrElse(
errorLog: String,
default: () -> R,
onException: ((ApolloHttpException) -> Unit)? = null,
transform: (D) -> R,
): R {
if (exception != null) {
val e = exception!!
if (e is ApolloHttpException) {
val body = e.body?.use { it.readUtf8() }
logcat(LogPriority.ERROR, throwable = e) { "$errorLog: $body" }
if (onException != null) {
onException(e)
} else {
throw HttpException(e.statusCode).apply { stackTrace = e.stackTrace }
}
}
logcat(LogPriority.ERROR, throwable = e) { errorLog }
// Don't mask connection/IO-related issues
if (e is ApolloNetworkException && e.cause != null) {
throw e.cause!!
}
} else if (!errors.isNullOrEmpty()) {
val errorMessages = errors!!.joinToString(separator = "\n ", prefix = "\n ") { it.message }
logcat(LogPriority.ERROR) { "$errorLog: $errorMessages" }
} else if (data != null) {
return transform(data!!)
}
return default()
}
+5
View File
@@ -26,6 +26,8 @@ androidx-test-uiautomator = "2.4.0"
androidx-viewPager = "1.1.0"
androidx-webkit = "1.17.1"
androidx-work = "2.12.0"
apollo = "5.2.0"
apollo-adapters = "0.8.0"
archive = "1.1.7"
coil = "3.6.3"
composeGrid = "2.8.0"
@@ -120,6 +122,8 @@ androidx-test-uiautomator = { module = "androidx.test.uiautomator:uiautomator",
androidx-viewPager = { module = "androidx.viewpager:viewpager", version.ref = "androidx-viewPager" }
androidx-webkit = { module = "androidx.webkit:webkit", version.ref = "androidx-webkit" }
androidx-work = { module = "androidx.work:work-runtime", version.ref = "androidx-work" }
apollo = { module = "com.apollographql.apollo:apollo-runtime", version.ref = "apollo" }
apollo-adapters = { module = "com.apollographql.adapters:apollo-adapters-core", version.ref = "apollo-adapters" }
archive = { module = "me.zhanghai.android.libarchive:library", version.ref = "archive" }
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
coil-core = { module = "io.coil-kt.coil3:coil", version.ref = "coil" }
@@ -204,6 +208,7 @@ android-kmp-library = { id = "com.android.kotlin.multiplatform.library", version
android-library = { id = "com.android.library", version.ref = "android-gradle" }
android-test = { id = "com.android.test", version.ref = "android-gradle" }
androidx-baselineProfile = { id = "androidx.baselineprofile", version.ref = "androidx-baselineProfile" }
apollo = { id = "com.apollographql.apollo", version.ref = "apollo" }
firebase-crashlytics = { id = "com.google.firebase.crashlytics", version.ref = "firebase-crashlytics" }
google-services = { id = "com.google.gms.google-services", version.ref = "google-services" }
kotlin-compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin-gradle" }