From cc2877673539db779af00fbefab3802ac52a7719 Mon Sep 17 00:00:00 2001 From: Constantin Piber <59023762+cpiber@users.noreply.github.com> Date: Sun, 2 Nov 2025 07:26:48 +0100 Subject: [PATCH] Update Suwayomi tracker to use GraphQL API instead of REST API (#2585) --- CHANGELOG.md | 3 +- .../tachiyomi/data/track/suwayomi/Suwayomi.kt | 12 +- .../data/track/suwayomi/SuwayomiApi.kt | 195 ++++++++++++++---- .../data/track/suwayomi/SuwayomiModels.kt | 158 +++++++------- 4 files changed, 239 insertions(+), 129 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8dffc3e83..3bf14b7cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,6 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co - Option to customize the number of concurrent source and page downloads ([@AntsyLich](https://github.com/AntsyLich)) ([#2637](https://github.com/mihonapp/mihon/pull/2637)) ### Changed -- Delegate Suwayomi tracker authentication to extension ([@cpiber](https://github.com/cpiber)) ([#2476](https://github.com/mihonapp/mihon/pull/2476)) - Increased default concurrent page downloads to 5 ([@AntsyLich](https://github.com/AntsyLich)) ([#2637](https://github.com/mihonapp/mihon/pull/2637)) ### Improved @@ -39,7 +38,9 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co - Fix date picker not allowing the same start and finish date in negative time zones ([@AntsyLich](https://github.com/AntsyLich), [@kashish-aggarwal21](https://github.com/kashish-aggarwal21)) ([#2617](https://github.com/mihonapp/mihon/pull/2617)) ### Other +- Delegate Suwayomi tracker authentication to extension ([@cpiber](https://github.com/cpiber)) ([#2476](https://github.com/mihonapp/mihon/pull/2476)) - Fix Kitsu tracker to conform to tracker data structure properly ([@cpiber](https://github.com/cpiber)) ([#2609](https://github.com/mihonapp/mihon/pull/2609)) +- Update Suwayomi tracker to use GraphQL API instead of REST API ([@cpiber](https://github.com/cpiber)) ([#2585](https://github.com/mihonapp/mihon/pull/2585)) ## [v0.19.1] - 2025-08-07 ### Changed diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/suwayomi/Suwayomi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/suwayomi/Suwayomi.kt index 6b2ca63f9..a0db6490b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/suwayomi/Suwayomi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/suwayomi/Suwayomi.kt @@ -70,7 +70,7 @@ class Suwayomi(id: Long) : BaseTracker(id, "Suwayomi"), EnhancedTracker { } override suspend fun refresh(track: Track): Track { - val remoteTrack = api.getTrackSearch(track.tracking_url) + val remoteTrack = api.getTrackSearch(track.remote_id) track.copyPersonalFrom(remoteTrack) track.total_chapters = remoteTrack.total_chapters return track @@ -88,14 +88,13 @@ class Suwayomi(id: Long) : BaseTracker(id, "Suwayomi"), EnhancedTracker { override suspend fun match(manga: DomainManga): TrackSearch? = try { - api.getTrackSearch(manga.url) + api.getTrackSearch(manga.url.getMangaId()) } catch (e: Exception) { null } - override fun isTrackFrom(track: DomainTrack, manga: DomainManga, source: Source?): Boolean = source?.let { - accept(it) - } == true + override fun isTrackFrom(track: DomainTrack, manga: DomainManga, source: Source?): Boolean = + track.remoteUrl == manga.url && source?.let { accept(it) } == true override fun migrateTrack(track: DomainTrack, manga: DomainManga, newSource: Source): DomainTrack? = if (accept(newSource)) { @@ -103,4 +102,7 @@ class Suwayomi(id: Long) : BaseTracker(id, "Suwayomi"), EnhancedTracker { } else { null } + + private fun String.getMangaId(): Long = + this.substringAfterLast('/').toLong() } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/suwayomi/SuwayomiApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/suwayomi/SuwayomiApi.kt index 516984842..1fd4f9cc4 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/suwayomi/SuwayomiApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/suwayomi/SuwayomiApi.kt @@ -2,15 +2,19 @@ package eu.kanade.tachiyomi.data.track.suwayomi import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.track.model.TrackSearch -import eu.kanade.tachiyomi.network.GET -import eu.kanade.tachiyomi.network.PUT +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.source.online.HttpSource import kotlinx.serialization.json.Json -import okhttp3.FormBody -import okhttp3.Headers +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 okhttp3.OkHttpClient +import okhttp3.RequestBody.Companion.toRequestBody import tachiyomi.core.common.util.lang.withIOContext import tachiyomi.domain.source.service.SourceManager import uy.kohesive.injekt.Injekt @@ -26,61 +30,142 @@ class SuwayomiApi(private val trackId: Long) { private val sourceManager: SourceManager by injectLazy() private val source: HttpSource by lazy { (sourceManager.get(sourceId) as HttpSource) } private val client: OkHttpClient by lazy { source.client } - private val headers: Headers by lazy { source.headers } private val baseUrl: String by lazy { source.baseUrl.trimEnd('/') } + private val apiUrl: String by lazy { "$baseUrl/api/graphql" } - suspend fun getTrackSearch(trackUrl: String): TrackSearch = withIOContext { - val url = try { - // test if getting api url or manga id - val mangaId = trackUrl.toLong() - "$baseUrl/api/v1/manga/$mangaId" - } catch (e: NumberFormatException) { - trackUrl + 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(GET("$url/full", headers)) + client.newCall( + POST( + apiUrl, + body = payload.toString().toRequestBody(jsonMime), + ), + ) .awaitSuccess() - .parseAs() + .parseAs() + .data + .entry } TrackSearch.create(trackId).apply { + remote_id = mangaId title = manga.title - cover_url = "$url/thumbnail" + cover_url = "$baseUrl/${manga.thumbnailUrl}" summary = manga.description.orEmpty() - tracking_url = url - total_chapters = manga.chapterCount - publishing_status = manga.status - last_chapter_read = manga.lastChapterRead?.chapterNumber ?: 0.0 + 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.chapterCount -> Suwayomi.UNREAD - 0L -> Suwayomi.COMPLETED + manga.chapters.totalCount -> Suwayomi.UNREAD + 0 -> Suwayomi.COMPLETED else -> Suwayomi.READING } } } suspend fun updateProgress(track: Track): Track { - val url = track.tracking_url - val chapters = with(json) { - client.newCall(GET("$url/chapters", headers)) - .awaitSuccess() - .parseAs>() + val mangaId = track.remote_id + + 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), + ), + ) + .awaitSuccess() + .parseAs() + .data + .entry + .nodes + .mapNotNull { n -> n.id.takeIf { n.chapterNumber <= track.last_chapter_read } } } - val lastChapterIndex = chapters.first { it.chapterNumber == track.last_chapter_read }.index - client.newCall( - PUT( - "$url/chapter/$lastChapterIndex", - headers, - FormBody.Builder(Charset.forName("utf8")) - .add("markPrevRead", "true") - .add("read", "true") - .build(), - ), - ).awaitSuccess() + val markQuery = """ + |mutation MarkChaptersRead(${'$'}chapters: [Int!]!) { + | updateChapters(input: {ids: ${'$'}chapters, patch: {isRead: true}}) { + | chapters { + | id + | } + | } + |} + """.trimMargin() + val markPayload = buildJsonObject { + put("query", markQuery) + putJsonObject("variables") { + putJsonArray("chapters") { + addAll(chaptersToMark) + } + } + } + with(json) { + client.newCall( + POST( + apiUrl, + body = markPayload.toString().toRequestBody(jsonMime), + ), + ) + .awaitSuccess() + } - return getTrackSearch(track.tracking_url) + val trackQuery = """ + |mutation TrackManga(${'$'}mangaId: Int!) { + | trackProgress(input: {mangaId: ${'$'}mangaId}) { + | trackRecords { + | lastChapterRead + | } + | } + |} + """.trimMargin() + val trackPayload = buildJsonObject { + put("query", trackQuery) + putJsonObject("variables") { + put("mangaId", mangaId) + } + } + with(json) { + client.newCall( + POST( + apiUrl, + body = trackPayload.toString().toRequestBody(jsonMime), + ), + ) + .awaitSuccess() + } + + return getTrackSearch(track.remote_id) } private val sourceId by lazy { @@ -88,4 +173,36 @@ class SuwayomiApi(private val trackId: Long) { 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() + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/suwayomi/SuwayomiModels.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/suwayomi/SuwayomiModels.kt index c3fb5023a..385b747c0 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/suwayomi/SuwayomiModels.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/suwayomi/SuwayomiModels.kt @@ -1,100 +1,90 @@ package eu.kanade.tachiyomi.data.track.suwayomi +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +public enum class MangaStatus( + public val rawValue: String, +) { + UNKNOWN("UNKNOWN"), + ONGOING("ONGOING"), + COMPLETED("COMPLETED"), + LICENSED("LICENSED"), + PUBLISHING_FINISHED("PUBLISHING_FINISHED"), + CANCELLED("CANCELLED"), + ON_HIATUS("ON_HIATUS"), +} + @Serializable -data class SourceDataClass( - val id: String, - val name: String, - val lang: String, - val iconUrl: String, +public data class MangaFragment( + public val artist: String?, + public val author: String?, + public val description: String?, + public val id: Int, + public val status: MangaStatus, + public val thumbnailUrl: String?, + public val title: String, + public val url: String, + public val genre: List, + public val inLibraryAt: Long, + public val chapters: Chapters, + public val latestUploadedChapter: LatestUploadedChapter?, + public val latestFetchedChapter: LatestFetchedChapter?, + public val latestReadChapter: LatestReadChapter?, + public val unreadCount: Int, + public val downloadCount: Int, +) { + @Serializable + public data class Chapters( + public val totalCount: Int, + ) - /** The Source provides a latest listing */ - val supportsLatest: Boolean, + @Serializable + public data class LatestUploadedChapter( + public val uploadDate: Long, + ) - /** The Source implements [ConfigurableSource] */ - val isConfigurable: Boolean, + @Serializable + public data class LatestFetchedChapter( + public val fetchedAt: Long, + ) - /** The Source class has a @Nsfw annotation */ - val isNsfw: Boolean, + @Serializable + public data class LatestReadChapter( + public val lastReadAt: Long, + public val chapterNumber: Double, + ) +} - /** A nicer version of [name] */ - val displayName: String, +@Serializable +public data class GetMangaResult( + public val data: GetMangaData, ) @Serializable -data class MangaDataClass( - val id: Int, - val sourceId: String, - - val url: String, - val title: String, - val thumbnailUrl: String?, - - val initialized: Boolean, - - val artist: String?, - val author: String?, - val description: String?, - val genre: List, - val status: String, - val inLibrary: Boolean, - val inLibraryAt: Long, - val source: SourceDataClass?, - - val meta: Map, - - val realUrl: String?, - val lastFetchedAt: Long?, - val chaptersLastFetchedAt: Long?, - - val freshData: Boolean, - val unreadCount: Long?, - val downloadCount: Long?, - val chapterCount: Long, // actually is nullable server side, but should be set at this time - val lastChapterRead: ChapterDataClass?, - - val age: Long?, - val chaptersAge: Long?, +public data class GetMangaData( + @SerialName("manga") + public val entry: MangaFragment, ) @Serializable -data class ChapterDataClass( - val id: Int, - val url: String, - val name: String, - val uploadDate: Long, - val chapterNumber: Double, - val scanlator: String?, - val mangaId: Int, - - /** chapter is read */ - val read: Boolean, - - /** chapter is bookmarked */ - val bookmarked: Boolean, - - /** last read page, zero means not read/no data */ - val lastPageRead: Int, - - /** last read page, zero means not read/no data */ - val lastReadAt: Long, - - /** this chapter's index, starts with 1 */ - val index: Int, - - /** the date we fist saw this chapter*/ - val fetchedAt: Long, - - /** is chapter downloaded */ - val downloaded: Boolean, - - /** used to construct pages in the front-end */ - val pageCount: Int, - - /** total chapter count, used to calculate if there's a next and prev chapter */ - val chapterCount: Int?, - - /** used to store client specific values */ - val meta: Map, +public data class GetMangaUnreadChaptersEntry( + public val nodes: List, +) + +@Serializable +public data class GetMangaUnreadChaptersNode( + public val id: Int, + public val chapterNumber: Double, +) + +@Serializable +public data class GetMangaUnreadChaptersResult( + public val data: GetMangaUnreadChaptersData, +) + +@Serializable +public data class GetMangaUnreadChaptersData( + @SerialName("chapters") + public val entry: GetMangaUnreadChaptersEntry, )