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))
### 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

View File

@@ -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()
}

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.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<MangaDataClass>()
.parseAs<GetMangaResult>()
.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<List<ChapterDataClass>>()
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<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(
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()
}
}

View File

@@ -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<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 */
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<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?,
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<String, String>,
public data class GetMangaUnreadChaptersEntry(
public val nodes: List<GetMangaUnreadChaptersNode>,
)
@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,
)