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

(cherry picked from commit cc2877673539db779af00fbefab3802ac52a7719)

Co-authored-by: Constantin Piber <59023762+cpiber@users.noreply.github.com>
Co-authored-by: Cuong-Tran <16017808+cuong-tran@users.noreply.github.com>
This commit is contained in:
Constantin Piber 2025-12-31 11:31:23 +07:00 committed by Cuong-Tran
parent f8a5c3078f
commit 4293c00a50
No known key found for this signature in database
GPG key ID: 94EFFE320FE7A47C
3 changed files with 245 additions and 129 deletions

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)) {
@ -104,6 +103,16 @@ class Suwayomi(id: Long) : BaseTracker(id, "Suwayomi"), EnhancedTracker {
null null
} }
private fun String.getMangaId(): Long {
// Normalize URL: remove fragment, query, and trailing slashes, then parse last segment.
val normalized = this
.substringBefore('#')
.substringBefore('?')
.trimEnd('/')
return normalized.substringAfterLast('/').toLong()
}
// KMK --> // KMK -->
override fun hasNotStartedReading(status: Long): Boolean = status == UNREAD override fun hasNotStartedReading(status: Long): Boolean = status == UNREAD
// KMK <-- // KMK <--

View file

@ -2,19 +2,22 @@ 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.injectLazy import uy.kohesive.injekt.injectLazy
import java.nio.charset.Charset
import java.security.MessageDigest import java.security.MessageDigest
class SuwayomiApi(private val trackId: Long) { class SuwayomiApi(private val trackId: Long) {
@ -24,61 +27,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 = manga.thumbnailUrl?.let { "$baseUrl/$it" } ?: ""
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 {
@ -86,4 +170,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,91 @@
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
@Serializable @Serializable
data class SourceDataClass( public enum class MangaStatus(
val id: String, public val rawValue: String,
val name: String, ) {
val lang: String, UNKNOWN("UNKNOWN"),
val iconUrl: String, ONGOING("ONGOING"),
COMPLETED("COMPLETED"),
LICENSED("LICENSED"),
PUBLISHING_FINISHED("PUBLISHING_FINISHED"),
CANCELLED("CANCELLED"),
ON_HIATUS("ON_HIATUS"),
}
/** The Source provides a latest listing */ @Serializable
val supportsLatest: Boolean, 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<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 implements [ConfigurableSource] */ @Serializable
val isConfigurable: Boolean, public data class LatestUploadedChapter(
public val uploadDate: Long,
)
/** The Source class has a @Nsfw annotation */ @Serializable
val isNsfw: Boolean, public data class LatestFetchedChapter(
public val fetchedAt: Long,
)
/** A nicer version of [name] */ @Serializable
val displayName: String, public data class LatestReadChapter(
public val lastReadAt: Long,
public val chapterNumber: Double,
)
}
@Serializable
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>,
) )