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 lastChapterIndex = chapters.first { it.chapterNumber == track.last_chapter_read }.index }
val chaptersToMark = with(json) {
client.newCall( client.newCall(
PUT( POST(
"$url/chapter/$lastChapterIndex", apiUrl,
headers, body = chaptersPayload.toString().toRequestBody(jsonMime),
FormBody.Builder(Charset.forName("utf8"))
.add("markPrevRead", "true")
.add("read", "true")
.build(),
), ),
).awaitSuccess() )
.awaitSuccess()
.parseAs<GetMangaUnreadChaptersResult>()
.data
.entry
.nodes
.mapNotNull { n -> n.id.takeIf { n.chapterNumber <= track.last_chapter_read } }
}
return getTrackSearch(track.tracking_url) 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()
}
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,
/** The Source provides a latest listing */ public val thumbnailUrl: String?,
val supportsLatest: Boolean, public val title: String,
public val url: String,
/** The Source implements [ConfigurableSource] */ public val genre: List<String>,
val isConfigurable: Boolean, public val inLibraryAt: Long,
public val chapters: Chapters,
/** The Source class has a @Nsfw annotation */ public val latestUploadedChapter: LatestUploadedChapter?,
val isNsfw: Boolean, public val latestFetchedChapter: LatestFetchedChapter?,
public val latestReadChapter: LatestReadChapter?,
/** A nicer version of [name] */ public val unreadCount: Int,
val displayName: String, public val downloadCount: Int,
) {
@Serializable
public data class Chapters(
public val totalCount: Int,
) )
@Serializable @Serializable
data class MangaDataClass( public data class LatestUploadedChapter(
val id: Int, public val uploadDate: Long,
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<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 LatestFetchedChapter(
val id: Int, public val fetchedAt: Long,
val url: String, )
val name: String,
val uploadDate: Long, @Serializable
val chapterNumber: Double, public data class LatestReadChapter(
val scanlator: String?, public val lastReadAt: Long,
val mangaId: Int, public val chapterNumber: Double,
)
/** chapter is read */ }
val read: Boolean,
@Serializable
/** chapter is bookmarked */ public data class GetMangaResult(
val bookmarked: Boolean, public val data: GetMangaData,
)
/** last read page, zero means not read/no data */
val lastPageRead: Int, @Serializable
public data class GetMangaData(
/** last read page, zero means not read/no data */ @SerialName("manga")
val lastReadAt: Long, public val entry: MangaFragment,
)
/** this chapter's index, starts with 1 */
val index: Int, @Serializable
public data class GetMangaUnreadChaptersEntry(
/** the date we fist saw this chapter*/ public val nodes: List<GetMangaUnreadChaptersNode>,
val fetchedAt: Long, )
/** is chapter downloaded */ @Serializable
val downloaded: Boolean, public data class GetMangaUnreadChaptersNode(
public val id: Int,
/** used to construct pages in the front-end */ public val chapterNumber: Double,
val pageCount: Int, )
/** total chapter count, used to calculate if there's a next and prev chapter */ @Serializable
val chapterCount: Int?, public data class GetMangaUnreadChaptersResult(
public val data: GetMangaUnreadChaptersData,
/** used to store client specific values */ )
val meta: Map<String, String>,
@Serializable
public data class GetMangaUnreadChaptersData(
@SerialName("chapters")
public val entry: GetMangaUnreadChaptersEntry,
) )