mirror of
https://github.com/mihonapp/mihon.git
synced 2025-11-05 16:48:55 +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))
|
||||
|
||||
### 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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user