mirror of
https://github.com/mihonapp/mihon.git
synced 2025-11-26 18:48:36 +01:00
Update Suwayomi tracker to use GraphQL API instead of REST API (#2585)
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>,
|
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user