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:
@@ -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,
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user