From a81b7bce9aec7477cf26da0347314a70bb622e90 Mon Sep 17 00:00:00 2001 From: MajorTanya <39014446+MajorTanya@users.noreply.github.com> Date: Sat, 15 Aug 2026 23:51:54 +0200 Subject: [PATCH] Add `id:` prefix search for rest of third-party trackers (#3776) * Add id: prefix search to AniList * Add id: prefix search to Bangumi * Add id: prefix search to Kitsu * Add id: prefix search to MangaUpdates * Add id: prefix search to Shikimori * Add id: prefix search to Hikka Since Hikka doesn't use integer IDs and instead relies solely on a slug system, users have to supply the slug to the `id:` search (so not `id:123` but `id:sousou-no-frieren-97e05f` to get the Frieren manga specifically) It's asymmetrical to the other trackers, but I thought keeping the prefix consistent was more important than to be true to the tracker's internal workings. * Parse MU string IDs as base36 Longs Turns out that's what the random strings in the URL are. Co-authored-by: BrutuZ <7104931+BrutuZ@users.noreply.github.com> * [skip ci] Add Changelog --------- Co-authored-by: BrutuZ <7104931+BrutuZ@users.noreply.github.com> --- CHANGELOG.md | 3 + .../tachiyomi/data/track/anilist/Anilist.kt | 8 +++ .../data/track/anilist/AnilistApi.kt | 67 +++++++++++++++++++ .../tachiyomi/data/track/bangumi/Bangumi.kt | 8 +++ .../data/track/bangumi/BangumiApi.kt | 15 +++++ .../tachiyomi/data/track/hikka/Hikka.kt | 12 +++- .../tachiyomi/data/track/hikka/HikkaApi.kt | 20 ++++++ .../tachiyomi/data/track/kitsu/Kitsu.kt | 8 +++ .../tachiyomi/data/track/kitsu/KitsuApi.kt | 23 +++++++ .../data/track/kitsu/dto/KitsuListSearch.kt | 21 ++++++ .../data/track/mangaupdates/MangaUpdates.kt | 13 ++++ .../track/mangaupdates/MangaUpdatesApi.kt | 19 ++++++ .../data/track/shikimori/Shikimori.kt | 8 +++ .../data/track/shikimori/ShikimoriApi.kt | 51 ++++++++++++++ 14 files changed, 275 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d055a5b33..7b1d30b95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co - `Other` - for technical stuff. ## [Unreleased] +### Added +- Add `id:` prefix search to remaining trackers (AniList, Bangumi, Kitsu, MangaUpdates, Shikimori, and Hikka) ([@MajorTanya](https://github.com/MajorTanya)) ([#3776](https://github.com/mihonapp/mihon/pull/3776)) + ### Improved - Show updates and upcoming filter icon as active for categories ([@Secozzi](https://github.com/Secozzi)) ([#3772](https://github.com/mihonapp/mihon/pull/3772)) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/Anilist.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/Anilist.kt index 69abe41c4..03f4fa8c9 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/Anilist.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/Anilist.kt @@ -28,6 +28,8 @@ class Anilist(id: Long) : BaseTracker(id, "AniList"), DeletableTracker { const val POINT_10_DECIMAL = "POINT_10_DECIMAL" const val POINT_5 = "POINT_5" const val POINT_3 = "POINT_3" + + private const val SEARCH_ID_PREFIX = "id:" } private val json: Json by injectLazy() @@ -196,6 +198,12 @@ class Anilist(id: Long) : BaseTracker(id, "AniList"), DeletableTracker { } override suspend fun search(query: String): List { + if (query.startsWith(SEARCH_ID_PREFIX)) { + query.substringAfter(SEARCH_ID_PREFIX).trim().toIntOrNull()?.let { id -> + return api.getMangaDetails(id)?.let { listOf(it) } ?: emptyList() + } + } + return api.search(query) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistApi.kt index eed713629..be49fc432 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistApi.kt @@ -318,6 +318,73 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) { } } + suspend fun getMangaDetails(id: Int): TrackSearch? { + return withIOContext { + val query = $$""" + |query Search($manga_id: Int) { + |Page (perPage: 1) { + |media(id: $manga_id, type: MANGA, format_not_in: [NOVEL]) { + |id + |staff { + |edges { + |role + |id + |node { + |name { + |full + |userPreferred + |native + |} + |} + |} + |} + |title { + |userPreferred + |} + |coverImage { + |large + |} + |format + |countryOfOrigin + |status + |chapters + |description + |startDate { + |year + |month + |day + |} + |averageScore + |} + |} + |} + | + """.trimMargin() + + val payload = buildJsonObject { + put("query", query) + putJsonObject("variables") { + put("manga_id", id) + } + } + + with(json) { + authClient.newCall( + POST( + API_URL, + body = payload.toString().toRequestBody(jsonMime), + ), + ) + .awaitSuccess() + .parseAs() + .data.page.media + .firstOrNull() + ?.toALManga() + ?.toTrack() + } + } + } + private fun createDate(dateValue: Long): JsonObject { if (dateValue == 0L) { return buildJsonObject { diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/Bangumi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/Bangumi.kt index 1c8fd8c7f..a8d57f993 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/Bangumi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/Bangumi.kt @@ -67,6 +67,12 @@ class Bangumi(id: Long) : BaseTracker(id, "Bangumi") { } override suspend fun search(query: String): List { + if (query.startsWith(SEARCH_ID_PREFIX)) { + query.substringAfter(SEARCH_ID_PREFIX).trim().toIntOrNull()?.let { id -> + return api.getMangaDetails(id)?.let { listOf(it) } ?: emptyList() + } + } + return api.search(query) } @@ -141,5 +147,7 @@ class Bangumi(id: Long) : BaseTracker(id, "Bangumi") { private val SCORE_LIST = IntRange(0, 10) .map(Int::toString) + + private const val SEARCH_ID_PREFIX = "id:" } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiApi.kt index 1212232dd..ec7eda239 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiApi.kt @@ -6,6 +6,7 @@ import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.track.bangumi.dto.BGMCollectionResponse import eu.kanade.tachiyomi.data.track.bangumi.dto.BGMOAuth import eu.kanade.tachiyomi.data.track.bangumi.dto.BGMSearchResult +import eu.kanade.tachiyomi.data.track.bangumi.dto.BGMSubject import eu.kanade.tachiyomi.data.track.bangumi.dto.BGMUser import eu.kanade.tachiyomi.data.track.model.TrackSearch import eu.kanade.tachiyomi.network.GET @@ -110,6 +111,20 @@ class BangumiApi( } } + suspend fun getMangaDetails(id: Int): TrackSearch? { + return withIOContext { + val url = "$API_URL/v0/subjects/$id" + + with(json) { + authClient.newCall(GET(url, headers = headersOf("Content-Type", APP_JSON))) + .awaitSuccess() + .parseAs() + .takeIf { it.platform == null || it.platform == "漫画" } + ?.toTrackSearch(trackId) + } + } + } + suspend fun statusLibManga(track: Track, username: String): Track? { return withIOContext { val url = "$API_URL/v0/users/$username/collections/${track.remote_id}" diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/hikka/Hikka.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/hikka/Hikka.kt index dcac04b2f..39d7a0d15 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/hikka/Hikka.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/hikka/Hikka.kt @@ -27,6 +27,8 @@ class Hikka(id: Long) : BaseTracker(id, "Hikka"), DeletableTracker { private val SCORE_LIST = IntRange(0, 10) .map(Int::toString) .toImmutableList() + + private const val SEARCH_ID_PREFIX = "id:" } private val json: Json by injectLazy() @@ -118,7 +120,15 @@ class Hikka(id: Long) : BaseTracker(id, "Hikka"), DeletableTracker { } } - override suspend fun search(query: String): List = api.searchManga(query) + override suspend fun search(query: String): List { + if (query.startsWith(SEARCH_ID_PREFIX)) { + query.substringAfter(SEARCH_ID_PREFIX).trim().let { slug -> + return api.getMangaDetails(slug)?.let { listOf(it) } ?: emptyList() + } + } + + return api.searchManga(query) + } override suspend fun refresh(track: Track): Track { val remoteTrack = api.getManga(track) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/hikka/HikkaApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/hikka/HikkaApi.kt index d18fcfdb6..30277b843 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/hikka/HikkaApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/hikka/HikkaApi.kt @@ -14,6 +14,7 @@ import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.HttpException import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.network.PUT +import eu.kanade.tachiyomi.network.await import eu.kanade.tachiyomi.network.awaitSuccess import eu.kanade.tachiyomi.network.jsonMime import eu.kanade.tachiyomi.network.parseAs @@ -99,6 +100,25 @@ class HikkaApi( } } + suspend fun getMangaDetails(slug: String): TrackSearch? { + return withIOContext { + val url = "$BASE_API_URL/manga/$slug" + + with(json) { + val response = authClient.newCall(GET(url)) + .await() + + if (response.code == 404) { + null + } else { + response + .parseAs() + .toTrack(trackId) + } + } + } + } + suspend fun getRead(track: Track): HKRead? { return withIOContext { val slug = track.tracking_url.split("/")[4] diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/Kitsu.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/Kitsu.kt index 1fab07fdf..edd21e15f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/Kitsu.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/Kitsu.kt @@ -21,6 +21,8 @@ class Kitsu(id: Long) : BaseTracker(id, "Kitsu"), DeletableTracker { const val ON_HOLD = 3L const val DROPPED = 4L const val PLAN_TO_READ = 5L + + private const val SEARCH_ID_PREFIX = "id:" } override val supportsReadingDates: Boolean = true @@ -114,6 +116,12 @@ class Kitsu(id: Long) : BaseTracker(id, "Kitsu"), DeletableTracker { } override suspend fun search(query: String): List { + if (query.startsWith(SEARCH_ID_PREFIX)) { + query.substringAfter(SEARCH_ID_PREFIX).trim().toIntOrNull()?.let { id -> + return api.getMangaDetails(id)?.let { listOf(it) } ?: emptyList() + } + } + return api.search(query) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuApi.kt index a58129037..2c5b28de7 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuApi.kt @@ -8,11 +8,14 @@ import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuCurrentUserResult import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuListSearchResult import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuOAuth import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuSearchResult +import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuSingleManga import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuUser import eu.kanade.tachiyomi.data.track.model.TrackSearch import eu.kanade.tachiyomi.network.DELETE import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.HttpException import eu.kanade.tachiyomi.network.POST +import eu.kanade.tachiyomi.network.await import eu.kanade.tachiyomi.network.awaitSuccess import eu.kanade.tachiyomi.network.jsonMime import eu.kanade.tachiyomi.network.parseAs @@ -242,6 +245,26 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor) } } + suspend fun getMangaDetails(id: Int): TrackSearch? { + return withIOContext { + val url = "$BASE_URL/manga/$id" + try { + with(json) { + authClient.newCall(GET(url)) + .await() + .parseAs() + .toTrackSearch() + } + } catch (e: HttpException) { + if (e.code == 404) { + null + } else { + throw e + } + } + } + } + companion object { private const val CLIENT_ID = "dd031b32d2f56c990b1425efe6c42ad847e7fe3ab46bf1299f05ecd856bdb7dd" private const val CLIENT_SECRET = "54d7307928f63414defd96399fc31ba847961ceaecef3a5fd93144e960c0e151" diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuListSearch.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuListSearch.kt index ad715f12d..0fd42ce0b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuListSearch.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuListSearch.kt @@ -64,6 +64,26 @@ data class KitsuListSearchItemDataAttributes( val private: Boolean, ) +@Serializable +data class KitsuSingleManga( + val data: KitsuListSearchItemIncluded, +) { + fun toTrackSearch(): TrackSearch { + return TrackSearch.create(TrackerManager.KITSU).apply { + remote_id = data.id + title = data.attributes.canonicalTitle + total_chapters = data.attributes.chapterCount ?: 0 + cover_url = data.attributes.posterImage?.original ?: "" + summary = data.attributes.synopsis ?: "" + tracking_url = KitsuApi.mangaUrl(remote_id) + score = data.attributes.averageRating?.toDoubleOrNull() ?: -1.0 + publishing_status = data.attributes.status + publishing_type = data.attributes.mangaType ?: "" + start_date = data.attributes.startDate ?: "" + } + } +} + @Serializable data class KitsuListSearchItemIncluded( val id: Long, @@ -79,4 +99,5 @@ data class KitsuListSearchItemIncludedAttributes( val synopsis: String?, val startDate: String?, val status: String, + val averageRating: String?, ) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/MangaUpdates.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/MangaUpdates.kt index 3edde1b29..938d5571e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/MangaUpdates.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/MangaUpdates.kt @@ -32,6 +32,8 @@ class MangaUpdates(id: Long) : BaseTracker(id, "MangaUpdates"), DeletableTracker } } } + + private const val SEARCH_ID_PREFIX = "id:" } private val interceptor by lazy { MangaUpdatesInterceptor(this) } @@ -89,6 +91,17 @@ class MangaUpdates(id: Long) : BaseTracker(id, "MangaUpdates"), DeletableTracker } override suspend fun search(query: String): List { + if (query.startsWith(SEARCH_ID_PREFIX)) { + return try { + val stringId = query.substringAfter(SEARCH_ID_PREFIX).trim() + val searchId = stringId.toLongOrNull() ?: stringId.toLong(36) + + api.getSeriesDetails(searchId)?.let { listOf(it.toTrackSearch(id)) } ?: emptyList() + } catch (_: NumberFormatException) { + emptyList() + } + } + return api.search(query) .map { it.toTrackSearch(id) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/MangaUpdatesApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/MangaUpdatesApi.kt index b2f1d408a..ee1255d49 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/MangaUpdatesApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/MangaUpdatesApi.kt @@ -14,6 +14,7 @@ import eu.kanade.tachiyomi.network.DELETE import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.network.PUT +import eu.kanade.tachiyomi.network.await import eu.kanade.tachiyomi.network.awaitSuccess import eu.kanade.tachiyomi.network.parseAs import kotlinx.serialization.json.Json @@ -26,6 +27,7 @@ import kotlinx.serialization.json.putJsonObject import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.RequestBody.Companion.toRequestBody +import tachiyomi.core.common.util.lang.withIOContext import uy.kohesive.injekt.injectLazy import tachiyomi.domain.track.model.Track as DomainTrack @@ -173,6 +175,23 @@ class MangaUpdatesApi( } } + suspend fun getSeriesDetails(id: Long): MURecord? { + return withIOContext { + val url = "$BASE_URL/v1/series/$id" + + with(json) { + val response = client.newCall(GET(url)) + .await() + + if (response.code == 404) { + null + } else { + response.parseAs() + } + } + } + } + suspend fun authenticate(username: String, password: String): MUContext { val body = buildJsonObject { put("username", username) 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 32a5e6eab..689c89f2b 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 @@ -24,6 +24,8 @@ class Shikimori(id: Long) : BaseTracker(id, "Shikimori"), DeletableTracker { private val SCORE_LIST = IntRange(0, 10) .map(Int::toString) + + private const val SEARCH_ID_PREFIX = "id:" } private val json: Json by injectLazy() @@ -81,6 +83,12 @@ class Shikimori(id: Long) : BaseTracker(id, "Shikimori"), DeletableTracker { } override suspend fun search(query: String): List { + if (query.startsWith(SEARCH_ID_PREFIX)) { + query.substringAfter(SEARCH_ID_PREFIX).trim().toIntOrNull()?.let { id -> + return api.getMangaDetails(id)?.let { listOf(it) } ?: emptyList() + } + } + return api.search(query) } 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 e5edf7e7e..caab48cf1 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 @@ -124,6 +124,57 @@ class ShikimoriApi( } } + suspend fun getMangaDetails(id: Int): TrackSearch? { + return withIOContext { + val query = $$""" + |query($query: String) { + |mangas(ids: $query, limit: 1, 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", "$id") + } + } + + with(json) { + authClient.newCall( + POST( + GRAPHQL_API_URL, + body = payload.toString().toRequestBody(jsonMime), + ), + ) + .awaitSuccess() + .parseAs() + .data.mangas + .firstOrNull() + ?.toTrack(trackId) + } + } + } + suspend fun findLibManga(track: Track, isRefresh: Boolean = false): Track? { return withIOContext { val query = $$"""