diff --git a/CHANGELOG.md b/CHANGELOG.md index 7667173da..e4fcccc6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co ## [Unreleased] ### Added - 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 - Fix Shikimori tracking not working ([@MajorTanya](https://github.com/MajorTanya)) ([#3497](https://github.com/mihonapp/mihon/pull/3497)) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/Shikimori.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/Shikimori.kt index 5b7240989..a49d0b898 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/Shikimori.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/Shikimori.kt @@ -61,7 +61,7 @@ class Shikimori(id: Long) : BaseTracker(id, "Shikimori"), DeletableTracker { } override suspend fun bind(track: Track, hasReadChapters: Boolean): Track { - val remoteTrack = api.findLibManga(track, getUsername()) + val remoteTrack = api.findLibManga(track) return if (remoteTrack != null) { track.copyPersonalFrom(remoteTrack) 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 { - api.findLibManga(track, getUsername())?.let { remoteTrack -> + api.findLibManga(track, isRefresh = true)?.let { remoteTrack -> track.library_id = remoteTrack.library_id track.copyPersonalFrom(remoteTrack) track.total_chapters = remoteTrack.total_chapters diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/ShikimoriApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/ShikimoriApi.kt index 831dfda0b..c0b826ae3 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/ShikimoriApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/ShikimoriApi.kt @@ -5,12 +5,11 @@ import androidx.core.net.toUri import eu.kanade.tachiyomi.data.database.models.Track 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.SMManga 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.SMUserListEntry +import eu.kanade.tachiyomi.data.track.shikimori.dto.SMSearchResult +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.GET import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.network.awaitSuccess import eu.kanade.tachiyomi.network.jsonMime @@ -77,58 +76,123 @@ class ShikimoriApi( suspend fun search(search: String): List { return withIOContext { - val url = "$API_URL/mangas".toUri().buildUpon() - .appendQueryParameter("order", "popularity") - .appendQueryParameter("search", search) - .appendQueryParameter("limit", "20") - .build() + val query = $$""" + |query($query: String) { + |mangas(search: $query, limit: 20, kind:"!light_novel,!novel") { + |id + |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) { - authClient.newCall(GET(url.toString())) + authClient.newCall( + POST( + GRAPHQL_API_URL, + body = payload.toString().toRequestBody(jsonMime), + ), + ) .awaitSuccess() - .parseAs>() + .parseAs() + .data.mangas .map { it.toTrack(trackId) } } } } - suspend fun findLibManga(track: Track, userId: String): Track? { + suspend fun findLibManga(track: Track, isRefresh: Boolean = false): Track? { return withIOContext { - val urlMangas = "$API_URL/mangas".toUri().buildUpon() - .appendPath(track.remote_id.toString()) - .build() - val manga = with(json) { - authClient.newCall(GET(urlMangas.toString())) - .awaitSuccess() - .parseAs() - } + val query = $$""" + |query($id: String) { + |mangas(ids: $id, limit: 1) { + |id + |url + |name + |chapters + |userRate { + |id + |chapters + |status + |score + |} + |} + |} + """.trimMargin() - val url = "$API_URL/v2/user_rates".toUri().buildUpon() - .appendQueryParameter("user_id", userId) - .appendQueryParameter("target_id", track.remote_id.toString()) - .appendQueryParameter("target_type", "Manga") - .build() + val payload = buildJsonObject { + put("query", query) + putJsonObject("variables") { + put("id", track.remote_id.toString()) + } + } with(json) { - authClient.newCall(GET(url.toString())) + val listResult = authClient.newCall( + POST( + GRAPHQL_API_URL, + body = payload.toString().toRequestBody(jsonMime), + ), + ) .awaitSuccess() - .parseAs>() - .let { entries -> - if (entries.size > 1) { - throw Exception("Too many manga in response") - } - entries - .map { it.toTrack(trackId, manga) } - .firstOrNull() - } + .parseAs() + .data.mangas + .firstOrNull() + + // Shikimori has no user list query that allows query by ID, so we go via the "mangas" query & include + // userRate data which will be null if the title is not in the user's list. + // If it was removed on Shikimori and is still linked in the app, notify user via returning null here + // which throws an exception at the Shikimori.refresh call + if (isRefresh && listResult?.userRate == null) return@with null + + listResult?.toTrack(trackId) } } } suspend fun getCurrentUser(): Int { 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() - .parseAs() - .id + .parseAs() + .data.currentUser.id + .toInt() } } @@ -154,8 +218,9 @@ class ShikimoriApi( ) 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 GRAPHQL_API_URL = "$BASE_URL/api/graphql" private const val OAUTH_URL = "$BASE_URL/oauth/token" private const val LOGIN_URL = "$BASE_URL/oauth/authorize" diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/dto/SMManga.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/dto/SMManga.kt index 00b7754a2..16a4f35dd 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/dto/SMManga.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/dto/SMManga.kt @@ -1,40 +1,73 @@ package eu.kanade.tachiyomi.data.track.shikimori.dto import eu.kanade.tachiyomi.data.track.model.TrackSearch -import eu.kanade.tachiyomi.data.track.shikimori.ShikimoriApi import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +@Serializable +data class SMSearchResult( + val data: SMMangaResults, +) + +@Serializable +data class SMMangaResults( + val mangas: List, +) + @Serializable data class SMManga( val id: Long, val name: String, val chapters: Long, - val image: SUMangaCover, - val score: Double, + val score: Double?, val url: String, - val status: String, - val kind: String, - @SerialName("aired_on") - val airedOn: String?, + val status: String?, + val poster: SMPoster?, + val airedOn: SMAiredDate?, + val description: String?, + val kind: String?, + val personRoles: List?, ) { fun toTrack(trackId: Long): TrackSearch { return TrackSearch.create(trackId).apply { remote_id = this@SMManga.id title = name total_chapters = chapters - cover_url = ShikimoriApi.BASE_URL + image.preview - summary = "" - score = this@SMManga.score - tracking_url = ShikimoriApi.BASE_URL + url - publishing_status = this@SMManga.status - publishing_type = kind - start_date = airedOn ?: "" + cover_url = poster?.mainUrl.orEmpty() + summary = description.orEmpty() + score = this@SMManga.score?.takeIf { it > 0.0 } ?: -1.0 + tracking_url = url + publishing_status = this@SMManga.status.orEmpty() + publishing_type = kind?.replace("one_shot", "oneshot").orEmpty() + 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 -data class SUMangaCover( - val preview: String, +data class SMPoster( + val mainUrl: String, +) + +@Serializable +data class SMAiredDate( + val date: String?, +) + +@Serializable +data class SMPersonRole( + val person: SMPerson, + @SerialName("rolesEn") + val roles: List, +) + +@Serializable +data class SMPerson( + val name: String, ) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/dto/SMUser.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/dto/SMUser.kt index 1b9ed6cdb..6b7492176 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/dto/SMUser.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/dto/SMUser.kt @@ -3,6 +3,16 @@ package eu.kanade.tachiyomi.data.track.shikimori.dto import kotlinx.serialization.Serializable @Serializable -data class SMUser( - val id: Int, +data class SMUserResult( + val data: SMCurrentUser, +) + +@Serializable +data class SMCurrentUser( + val currentUser: SMUser, +) + +@Serializable +data class SMUser( + val id: String, ) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/dto/SMUserListEntry.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/dto/SMUserListEntry.kt index e5e160560..e7bb37666 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/dto/SMUserListEntry.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/dto/SMUserListEntry.kt @@ -1,27 +1,52 @@ package eu.kanade.tachiyomi.data.track.shikimori.dto 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 kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable -data class SMUserListEntry( - val id: Long, - val chapters: Double, - val score: Int, - val status: String, +data class SMUserListResult( + val data: SMUserListEntries, +) + +@Serializable +data class SMUserListEntries( + val mangas: List, +) + +@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 { - title = manga.name - remote_id = this@SMUserListEntry.id - total_chapters = manga.chapters - library_id = this@SMUserListEntry.id - last_chapter_read = this@SMUserListEntry.chapters - score = this@SMUserListEntry.score.toDouble() - status = toTrackStatus(this@SMUserListEntry.status) - tracking_url = ShikimoriApi.BASE_URL + manga.url + title = name + total_chapters = totalChapters + tracking_url = url + if (userRate != null) { + // null if not in user's list, must not throw here because it'd break adding titles + // throws in the findLibManga method of ShikimoriApi if null and shouldn't be + remote_id = userRate.rateId.toLong() + 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, +)