Update Suwayomi tracker to use GraphQL API instead of REST API (#2585)

This commit is contained in:
Constantin Piber
2025-11-02 07:26:48 +01:00
committed by GitHub
parent 6ab87c7931
commit cc28776735
4 changed files with 239 additions and 129 deletions

View File

@@ -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)) - 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 ### 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)) - Increased default concurrent page downloads to 5 ([@AntsyLich](https://github.com/AntsyLich)) ([#2637](https://github.com/mihonapp/mihon/pull/2637))
### Improved ### 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)) - 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 ### 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)) - 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 ## [v0.19.1] - 2025-08-07
### Changed ### Changed

View File

@@ -70,7 +70,7 @@ class Suwayomi(id: Long) : BaseTracker(id, "Suwayomi"), EnhancedTracker {
} }
override suspend fun refresh(track: Track): Track { 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.copyPersonalFrom(remoteTrack)
track.total_chapters = remoteTrack.total_chapters track.total_chapters = remoteTrack.total_chapters
return track return track
@@ -88,14 +88,13 @@ class Suwayomi(id: Long) : BaseTracker(id, "Suwayomi"), EnhancedTracker {
override suspend fun match(manga: DomainManga): TrackSearch? = override suspend fun match(manga: DomainManga): TrackSearch? =
try { try {
api.getTrackSearch(manga.url) api.getTrackSearch(manga.url.getMangaId())
} catch (e: Exception) { } catch (e: Exception) {
null null
} }
override fun isTrackFrom(track: DomainTrack, manga: DomainManga, source: Source?): Boolean = source?.let { override fun isTrackFrom(track: DomainTrack, manga: DomainManga, source: Source?): Boolean =
accept(it) track.remoteUrl == manga.url && source?.let { accept(it) } == true
} == true
override fun migrateTrack(track: DomainTrack, manga: DomainManga, newSource: Source): DomainTrack? = override fun migrateTrack(track: DomainTrack, manga: DomainManga, newSource: Source): DomainTrack? =
if (accept(newSource)) { if (accept(newSource)) {
@@ -103,4 +102,7 @@ class Suwayomi(id: Long) : BaseTracker(id, "Suwayomi"), EnhancedTracker {
} else { } else {
null null
} }
private fun String.getMangaId(): Long =
this.substringAfterLast('/').toLong()
} }

View File

@@ -2,15 +2,19 @@ package eu.kanade.tachiyomi.data.track.suwayomi
import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.model.TrackSearch import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.PUT
import eu.kanade.tachiyomi.network.awaitSuccess import eu.kanade.tachiyomi.network.awaitSuccess
import eu.kanade.tachiyomi.network.jsonMime
import eu.kanade.tachiyomi.network.parseAs import eu.kanade.tachiyomi.network.parseAs
import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.HttpSource
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import okhttp3.FormBody import kotlinx.serialization.json.addAll
import okhttp3.Headers 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.OkHttpClient
import okhttp3.RequestBody.Companion.toRequestBody
import tachiyomi.core.common.util.lang.withIOContext import tachiyomi.core.common.util.lang.withIOContext
import tachiyomi.domain.source.service.SourceManager import tachiyomi.domain.source.service.SourceManager
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
@@ -26,61 +30,142 @@ class SuwayomiApi(private val trackId: Long) {
private val sourceManager: SourceManager by injectLazy() private val sourceManager: SourceManager by injectLazy()
private val source: HttpSource by lazy { (sourceManager.get(sourceId) as HttpSource) } private val source: HttpSource by lazy { (sourceManager.get(sourceId) as HttpSource) }
private val client: OkHttpClient by lazy { source.client } 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 baseUrl: String by lazy { source.baseUrl.trimEnd('/') }
private val apiUrl: String by lazy { "$baseUrl/api/graphql" }
suspend fun getTrackSearch(trackUrl: String): TrackSearch = withIOContext { suspend fun getTrackSearch(mangaId: Long): TrackSearch = withIOContext {
val url = try { val query = """
// test if getting api url or manga id |query GetManga(${'$'}mangaId: Int!) {
val mangaId = trackUrl.toLong() | manga(id: ${'$'}mangaId) {
"$baseUrl/api/v1/manga/$mangaId" | ...MangaFragment
} catch (e: NumberFormatException) { | }
trackUrl |}
|
|$MangaFragment
""".trimMargin()
val payload = buildJsonObject {
put("query", query)
putJsonObject("variables") {
put("mangaId", mangaId)
}
} }
val manga = with(json) { val manga = with(json) {
client.newCall(GET("$url/full", headers)) client.newCall(
POST(
apiUrl,
body = payload.toString().toRequestBody(jsonMime),
),
)
.awaitSuccess() .awaitSuccess()
.parseAs<MangaDataClass>() .parseAs<GetMangaResult>()
.data
.entry
} }
TrackSearch.create(trackId).apply { TrackSearch.create(trackId).apply {
remote_id = mangaId
title = manga.title title = manga.title
cover_url = "$url/thumbnail" cover_url = "$baseUrl/${manga.thumbnailUrl}"
summary = manga.description.orEmpty() summary = manga.description.orEmpty()
tracking_url = url tracking_url = "$baseUrl/manga/$mangaId"
total_chapters = manga.chapterCount total_chapters = manga.chapters.totalCount.toLong()
publishing_status = manga.status publishing_status = manga.status.name
last_chapter_read = manga.lastChapterRead?.chapterNumber ?: 0.0 last_chapter_read = manga.latestReadChapter?.chapterNumber ?: 0.0
status = when (manga.unreadCount) { status = when (manga.unreadCount) {
manga.chapterCount -> Suwayomi.UNREAD manga.chapters.totalCount -> Suwayomi.UNREAD
0L -> Suwayomi.COMPLETED 0 -> Suwayomi.COMPLETED
else -> Suwayomi.READING else -> Suwayomi.READING
} }
} }
} }
suspend fun updateProgress(track: Track): Track { suspend fun updateProgress(track: Track): Track {
val url = track.tracking_url val mangaId = track.remote_id
val chapters = with(json) {
client.newCall(GET("$url/chapters", headers)) val chaptersQuery = """
.awaitSuccess() |query GetMangaUnreadChapters(${'$'}mangaId: Int!) {
.parseAs<List<ChapterDataClass>>() | 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<GetMangaUnreadChaptersResult>()
.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( val markQuery = """
PUT( |mutation MarkChaptersRead(${'$'}chapters: [Int!]!) {
"$url/chapter/$lastChapterIndex", | updateChapters(input: {ids: ${'$'}chapters, patch: {isRead: true}}) {
headers, | chapters {
FormBody.Builder(Charset.forName("utf8")) | id
.add("markPrevRead", "true") | }
.add("read", "true") | }
.build(), |}
), """.trimMargin()
).awaitSuccess() 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 { private val sourceId by lazy {
@@ -88,4 +173,36 @@ class SuwayomiApi(private val trackId: Long) {
val bytes = MessageDigest.getInstance("MD5").digest(key.toByteArray()) 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 (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()
}
} }

View File

@@ -1,100 +1,90 @@
package eu.kanade.tachiyomi.data.track.suwayomi package eu.kanade.tachiyomi.data.track.suwayomi
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable 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 @Serializable
data class SourceDataClass( public data class MangaFragment(
val id: String, public val artist: String?,
val name: String, public val author: String?,
val lang: String, public val description: String?,
val iconUrl: 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<String>,
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 */ @Serializable
val supportsLatest: Boolean, public data class LatestUploadedChapter(
public val uploadDate: Long,
)
/** The Source implements [ConfigurableSource] */ @Serializable
val isConfigurable: Boolean, public data class LatestFetchedChapter(
public val fetchedAt: Long,
)
/** The Source class has a @Nsfw annotation */ @Serializable
val isNsfw: Boolean, public data class LatestReadChapter(
public val lastReadAt: Long,
public val chapterNumber: Double,
)
}
/** A nicer version of [name] */ @Serializable
val displayName: String, public data class GetMangaResult(
public val data: GetMangaData,
) )
@Serializable @Serializable
data class MangaDataClass( public data class GetMangaData(
val id: Int, @SerialName("manga")
val sourceId: String, public val entry: MangaFragment,
val url: String,
val title: String,
val thumbnailUrl: String?,
val initialized: Boolean,
val artist: String?,
val author: String?,
val description: String?,
val genre: List<String>,
val status: String,
val inLibrary: Boolean,
val inLibraryAt: Long,
val source: SourceDataClass?,
val meta: Map<String, String>,
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?,
) )
@Serializable @Serializable
data class ChapterDataClass( public data class GetMangaUnreadChaptersEntry(
val id: Int, public val nodes: List<GetMangaUnreadChaptersNode>,
val url: String, )
val name: String,
val uploadDate: Long, @Serializable
val chapterNumber: Double, public data class GetMangaUnreadChaptersNode(
val scanlator: String?, public val id: Int,
val mangaId: Int, public val chapterNumber: Double,
)
/** chapter is read */
val read: Boolean, @Serializable
public data class GetMangaUnreadChaptersResult(
/** chapter is bookmarked */ public val data: GetMangaUnreadChaptersData,
val bookmarked: Boolean, )
/** last read page, zero means not read/no data */ @Serializable
val lastPageRead: Int, public data class GetMangaUnreadChaptersData(
@SerialName("chapters")
/** last read page, zero means not read/no data */ public val entry: GetMangaUnreadChaptersEntry,
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<String, String>,
) )