Use Shikimori's GraphQL API where possible (#3499)

User list mutation aren't available via GraphQL, so that will still be
done with the v2 API.

Shikimori API docs say to prefer the GraphQL API when possible:
https://shikimori.io/api/doc

Allows adding some additional data in the search results, namely:
- Authors & Artists
- Description

As a nice bonus, this reduces the number of requests to Shikimori
because the findLibManga method no longer needs to do 2 calls to fetch
both list and title data.
This commit is contained in:
MajorTanya
2026-07-03 18:21:22 +02:00
committed by GitHub
parent f3c6789dfb
commit db095c87aa
6 changed files with 208 additions and 74 deletions
+1
View File
@@ -13,6 +13,7 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co
## [Unreleased] ## [Unreleased]
### Added ### Added
- Support resumable image downloads if supported by source ([@xMohnad](https://github.com/xMohnad)) ([#3167](https://github.com/mihonapp/mihon/pull/3167)) - Support resumable image downloads if supported by source ([@xMohnad](https://github.com/xMohnad)) ([#3167](https://github.com/mihonapp/mihon/pull/3167))
- Display authors and description in Shikimori search results ([@MajorTanya](https://github.com/MajorTanya)) ([#3499](https://github.com/mihonapp/mihon/pull/3499))
### Fixed ### Fixed
- Fix Shikimori tracking not working ([@MajorTanya](https://github.com/MajorTanya)) ([#3497](https://github.com/mihonapp/mihon/pull/3497)) - Fix Shikimori tracking not working ([@MajorTanya](https://github.com/MajorTanya)) ([#3497](https://github.com/mihonapp/mihon/pull/3497))
@@ -61,7 +61,7 @@ class Shikimori(id: Long) : BaseTracker(id, "Shikimori"), DeletableTracker {
} }
override suspend fun bind(track: Track, hasReadChapters: Boolean): Track { override suspend fun bind(track: Track, hasReadChapters: Boolean): Track {
val remoteTrack = api.findLibManga(track, getUsername()) val remoteTrack = api.findLibManga(track)
return if (remoteTrack != null) { return if (remoteTrack != null) {
track.copyPersonalFrom(remoteTrack) track.copyPersonalFrom(remoteTrack)
track.library_id = remoteTrack.library_id track.library_id = remoteTrack.library_id
@@ -85,7 +85,7 @@ class Shikimori(id: Long) : BaseTracker(id, "Shikimori"), DeletableTracker {
} }
override suspend fun refresh(track: Track): Track { override suspend fun refresh(track: Track): Track {
api.findLibManga(track, getUsername())?.let { remoteTrack -> api.findLibManga(track, isRefresh = true)?.let { remoteTrack ->
track.library_id = remoteTrack.library_id track.library_id = remoteTrack.library_id
track.copyPersonalFrom(remoteTrack) track.copyPersonalFrom(remoteTrack)
track.total_chapters = remoteTrack.total_chapters track.total_chapters = remoteTrack.total_chapters
@@ -5,12 +5,11 @@ import androidx.core.net.toUri
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.data.track.shikimori.dto.SMAddMangaResponse import eu.kanade.tachiyomi.data.track.shikimori.dto.SMAddMangaResponse
import eu.kanade.tachiyomi.data.track.shikimori.dto.SMManga
import eu.kanade.tachiyomi.data.track.shikimori.dto.SMOAuth import eu.kanade.tachiyomi.data.track.shikimori.dto.SMOAuth
import eu.kanade.tachiyomi.data.track.shikimori.dto.SMUser import eu.kanade.tachiyomi.data.track.shikimori.dto.SMSearchResult
import eu.kanade.tachiyomi.data.track.shikimori.dto.SMUserListEntry import eu.kanade.tachiyomi.data.track.shikimori.dto.SMUserListResult
import eu.kanade.tachiyomi.data.track.shikimori.dto.SMUserResult
import eu.kanade.tachiyomi.network.DELETE import eu.kanade.tachiyomi.network.DELETE
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.awaitSuccess import eu.kanade.tachiyomi.network.awaitSuccess
import eu.kanade.tachiyomi.network.jsonMime import eu.kanade.tachiyomi.network.jsonMime
@@ -77,58 +76,123 @@ class ShikimoriApi(
suspend fun search(search: String): List<TrackSearch> { suspend fun search(search: String): List<TrackSearch> {
return withIOContext { return withIOContext {
val url = "$API_URL/mangas".toUri().buildUpon() val query = $$"""
.appendQueryParameter("order", "popularity") |query($query: String) {
.appendQueryParameter("search", search) |mangas(search: $query, limit: 20, kind:"!light_novel,!novel") {
.appendQueryParameter("limit", "20") |id
.build() |name
|chapters
|kind
|poster {
|mainUrl
|}
|score
|url
|status
|airedOn {
|date
|}
|description
|personRoles {
|person {
|name
|}
|rolesEn
|}
|}
|}
""".trimMargin()
val payload = buildJsonObject {
put("query", query)
putJsonObject("variables") {
put("query", search)
}
}
with(json) { with(json) {
authClient.newCall(GET(url.toString())) authClient.newCall(
POST(
GRAPHQL_API_URL,
body = payload.toString().toRequestBody(jsonMime),
),
)
.awaitSuccess() .awaitSuccess()
.parseAs<List<SMManga>>() .parseAs<SMSearchResult>()
.data.mangas
.map { it.toTrack(trackId) } .map { it.toTrack(trackId) }
} }
} }
} }
suspend fun findLibManga(track: Track, userId: String): Track? { suspend fun findLibManga(track: Track, isRefresh: Boolean = false): Track? {
return withIOContext { return withIOContext {
val urlMangas = "$API_URL/mangas".toUri().buildUpon() val query = $$"""
.appendPath(track.remote_id.toString()) |query($id: String) {
.build() |mangas(ids: $id, limit: 1) {
val manga = with(json) { |id
authClient.newCall(GET(urlMangas.toString())) |url
.awaitSuccess() |name
.parseAs<SMManga>() |chapters
} |userRate {
|id
|chapters
|status
|score
|}
|}
|}
""".trimMargin()
val url = "$API_URL/v2/user_rates".toUri().buildUpon() val payload = buildJsonObject {
.appendQueryParameter("user_id", userId) put("query", query)
.appendQueryParameter("target_id", track.remote_id.toString()) putJsonObject("variables") {
.appendQueryParameter("target_type", "Manga") put("id", track.remote_id.toString())
.build() }
}
with(json) { with(json) {
authClient.newCall(GET(url.toString())) val listResult = authClient.newCall(
POST(
GRAPHQL_API_URL,
body = payload.toString().toRequestBody(jsonMime),
),
)
.awaitSuccess() .awaitSuccess()
.parseAs<List<SMUserListEntry>>() .parseAs<SMUserListResult>()
.let { entries -> .data.mangas
if (entries.size > 1) { .firstOrNull()
throw Exception("Too many manga in response")
} // Shikimori has no user list query that allows query by ID, so we go via the "mangas" query & include
entries // userRate data which will be null if the title is not in the user's list.
.map { it.toTrack(trackId, manga) } // If it was removed on Shikimori and is still linked in the app, notify user via returning null here
.firstOrNull() // which throws an exception at the Shikimori.refresh call
} if (isRefresh && listResult?.userRate == null) return@with null
listResult?.toTrack(trackId)
} }
} }
} }
suspend fun getCurrentUser(): Int { suspend fun getCurrentUser(): Int {
return with(json) { return with(json) {
authClient.newCall(GET("$API_URL/users/whoami")) val query = """
|{
|currentUser {
|id
|}
|}
""".trimMargin()
val payload = buildJsonObject {
put("query", query)
}
authClient.newCall(
POST(
GRAPHQL_API_URL,
body = payload.toString().toRequestBody(jsonMime),
),
)
.awaitSuccess() .awaitSuccess()
.parseAs<SMUser>() .parseAs<SMUserResult>()
.id .data.currentUser.id
.toInt()
} }
} }
@@ -154,8 +218,9 @@ class ShikimoriApi(
) )
companion object { companion object {
const val BASE_URL = "https://shikimori.io" private const val BASE_URL = "https://shikimori.io"
private const val API_URL = "$BASE_URL/api" private const val API_URL = "$BASE_URL/api"
private const val GRAPHQL_API_URL = "$BASE_URL/api/graphql"
private const val OAUTH_URL = "$BASE_URL/oauth/token" private const val OAUTH_URL = "$BASE_URL/oauth/token"
private const val LOGIN_URL = "$BASE_URL/oauth/authorize" private const val LOGIN_URL = "$BASE_URL/oauth/authorize"
@@ -1,40 +1,73 @@
package eu.kanade.tachiyomi.data.track.shikimori.dto package eu.kanade.tachiyomi.data.track.shikimori.dto
import eu.kanade.tachiyomi.data.track.model.TrackSearch import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.data.track.shikimori.ShikimoriApi
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable
data class SMSearchResult(
val data: SMMangaResults,
)
@Serializable
data class SMMangaResults(
val mangas: List<SMManga>,
)
@Serializable @Serializable
data class SMManga( data class SMManga(
val id: Long, val id: Long,
val name: String, val name: String,
val chapters: Long, val chapters: Long,
val image: SUMangaCover, val score: Double?,
val score: Double,
val url: String, val url: String,
val status: String, val status: String?,
val kind: String, val poster: SMPoster?,
@SerialName("aired_on") val airedOn: SMAiredDate?,
val airedOn: String?, val description: String?,
val kind: String?,
val personRoles: List<SMPersonRole>?,
) { ) {
fun toTrack(trackId: Long): TrackSearch { fun toTrack(trackId: Long): TrackSearch {
return TrackSearch.create(trackId).apply { return TrackSearch.create(trackId).apply {
remote_id = this@SMManga.id remote_id = this@SMManga.id
title = name title = name
total_chapters = chapters total_chapters = chapters
cover_url = ShikimoriApi.BASE_URL + image.preview cover_url = poster?.mainUrl.orEmpty()
summary = "" summary = description.orEmpty()
score = this@SMManga.score score = this@SMManga.score?.takeIf { it > 0.0 } ?: -1.0
tracking_url = ShikimoriApi.BASE_URL + url tracking_url = url
publishing_status = this@SMManga.status publishing_status = this@SMManga.status.orEmpty()
publishing_type = kind publishing_type = kind?.replace("one_shot", "oneshot").orEmpty()
start_date = airedOn ?: "" start_date = airedOn?.date.orEmpty()
personRoles?.forEach { personRole ->
personRole.roles.forEach { role ->
if ("Story" in role) authors += personRole.person.name
if ("Art" in role) artists += personRole.person.name
}
}
} }
} }
} }
@Serializable @Serializable
data class SUMangaCover( data class SMPoster(
val preview: String, val mainUrl: String,
)
@Serializable
data class SMAiredDate(
val date: String?,
)
@Serializable
data class SMPersonRole(
val person: SMPerson,
@SerialName("rolesEn")
val roles: List<String>,
)
@Serializable
data class SMPerson(
val name: String,
) )
@@ -3,6 +3,16 @@ package eu.kanade.tachiyomi.data.track.shikimori.dto
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
data class SMUser( data class SMUserResult(
val id: Int, val data: SMCurrentUser,
)
@Serializable
data class SMCurrentUser(
val currentUser: SMUser,
)
@Serializable
data class SMUser(
val id: String,
) )
@@ -1,27 +1,52 @@
package eu.kanade.tachiyomi.data.track.shikimori.dto package eu.kanade.tachiyomi.data.track.shikimori.dto
import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.shikimori.ShikimoriApi
import eu.kanade.tachiyomi.data.track.shikimori.toTrackStatus import eu.kanade.tachiyomi.data.track.shikimori.toTrackStatus
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
data class SMUserListEntry( data class SMUserListResult(
val id: Long, val data: SMUserListEntries,
val chapters: Double, )
val score: Int,
val status: String, @Serializable
data class SMUserListEntries(
val mangas: List<SMUserListManga>,
)
@Serializable
data class SMUserListManga(
val id: String,
val url: String,
val name: String,
@SerialName("chapters")
val totalChapters: Long, // the title's total chapters
val userRate: SMUserRate?,
) { ) {
fun toTrack(trackId: Long, manga: SMManga): Track { fun toTrack(trackId: Long): Track {
return Track.create(trackId).apply { return Track.create(trackId).apply {
title = manga.name title = name
remote_id = this@SMUserListEntry.id total_chapters = totalChapters
total_chapters = manga.chapters tracking_url = url
library_id = this@SMUserListEntry.id if (userRate != null) {
last_chapter_read = this@SMUserListEntry.chapters // null if not in user's list, must not throw here because it'd break adding titles
score = this@SMUserListEntry.score.toDouble() // throws in the findLibManga method of ShikimoriApi if null and shouldn't be
status = toTrackStatus(this@SMUserListEntry.status) remote_id = userRate.rateId.toLong()
tracking_url = ShikimoriApi.BASE_URL + manga.url library_id = userRate.rateId.toLong()
last_chapter_read = userRate.chapters.toDouble()
score = userRate.score
status = toTrackStatus(userRate.status)
}
} }
} }
} }
@Serializable
data class SMUserRate(
@SerialName("id")
val rateId: String, // ID of the list entry (NOT the title)
val chapters: Long, // the user's chapter progress
val status: String,
val score: Double,
)