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>
This commit is contained in:
MajorTanya
2026-08-15 23:51:54 +02:00
committed by GitHub
parent abdecb3cb4
commit a81b7bce9a
14 changed files with 275 additions and 1 deletions
+3
View File
@@ -11,6 +11,9 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co
- `Other` - for technical stuff. - `Other` - for technical stuff.
## [Unreleased] ## [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 ### Improved
- Show updates and upcoming filter icon as active for categories ([@Secozzi](https://github.com/Secozzi)) ([#3772](https://github.com/mihonapp/mihon/pull/3772)) - Show updates and upcoming filter icon as active for categories ([@Secozzi](https://github.com/Secozzi)) ([#3772](https://github.com/mihonapp/mihon/pull/3772))
@@ -28,6 +28,8 @@ class Anilist(id: Long) : BaseTracker(id, "AniList"), DeletableTracker {
const val POINT_10_DECIMAL = "POINT_10_DECIMAL" const val POINT_10_DECIMAL = "POINT_10_DECIMAL"
const val POINT_5 = "POINT_5" const val POINT_5 = "POINT_5"
const val POINT_3 = "POINT_3" const val POINT_3 = "POINT_3"
private const val SEARCH_ID_PREFIX = "id:"
} }
private val json: Json by injectLazy() 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<TrackSearch> { override suspend fun search(query: String): List<TrackSearch> {
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) return api.search(query)
} }
@@ -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<ALSearchResult>()
.data.page.media
.firstOrNull()
?.toALManga()
?.toTrack()
}
}
}
private fun createDate(dateValue: Long): JsonObject { private fun createDate(dateValue: Long): JsonObject {
if (dateValue == 0L) { if (dateValue == 0L) {
return buildJsonObject { return buildJsonObject {
@@ -67,6 +67,12 @@ class Bangumi(id: Long) : BaseTracker(id, "Bangumi") {
} }
override suspend fun search(query: String): List<TrackSearch> { override suspend fun search(query: String): List<TrackSearch> {
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) return api.search(query)
} }
@@ -141,5 +147,7 @@ class Bangumi(id: Long) : BaseTracker(id, "Bangumi") {
private val SCORE_LIST = IntRange(0, 10) private val SCORE_LIST = IntRange(0, 10)
.map(Int::toString) .map(Int::toString)
private const val SEARCH_ID_PREFIX = "id:"
} }
} }
@@ -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.BGMCollectionResponse
import eu.kanade.tachiyomi.data.track.bangumi.dto.BGMOAuth 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.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.bangumi.dto.BGMUser
import eu.kanade.tachiyomi.data.track.model.TrackSearch import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.network.GET 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<BGMSubject>()
.takeIf { it.platform == null || it.platform == "漫画" }
?.toTrackSearch(trackId)
}
}
}
suspend fun statusLibManga(track: Track, username: String): Track? { suspend fun statusLibManga(track: Track, username: String): Track? {
return withIOContext { return withIOContext {
val url = "$API_URL/v0/users/$username/collections/${track.remote_id}" val url = "$API_URL/v0/users/$username/collections/${track.remote_id}"
@@ -27,6 +27,8 @@ class Hikka(id: Long) : BaseTracker(id, "Hikka"), DeletableTracker {
private val SCORE_LIST = IntRange(0, 10) private val SCORE_LIST = IntRange(0, 10)
.map(Int::toString) .map(Int::toString)
.toImmutableList() .toImmutableList()
private const val SEARCH_ID_PREFIX = "id:"
} }
private val json: Json by injectLazy() 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<TrackSearch> = api.searchManga(query) override suspend fun search(query: String): List<TrackSearch> {
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 { override suspend fun refresh(track: Track): Track {
val remoteTrack = api.getManga(track) val remoteTrack = api.getManga(track)
@@ -14,6 +14,7 @@ import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.HttpException import eu.kanade.tachiyomi.network.HttpException
import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.PUT import eu.kanade.tachiyomi.network.PUT
import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.network.awaitSuccess import eu.kanade.tachiyomi.network.awaitSuccess
import eu.kanade.tachiyomi.network.jsonMime import eu.kanade.tachiyomi.network.jsonMime
import eu.kanade.tachiyomi.network.parseAs 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<HKManga>()
.toTrack(trackId)
}
}
}
}
suspend fun getRead(track: Track): HKRead? { suspend fun getRead(track: Track): HKRead? {
return withIOContext { return withIOContext {
val slug = track.tracking_url.split("/")[4] val slug = track.tracking_url.split("/")[4]
@@ -21,6 +21,8 @@ class Kitsu(id: Long) : BaseTracker(id, "Kitsu"), DeletableTracker {
const val ON_HOLD = 3L const val ON_HOLD = 3L
const val DROPPED = 4L const val DROPPED = 4L
const val PLAN_TO_READ = 5L const val PLAN_TO_READ = 5L
private const val SEARCH_ID_PREFIX = "id:"
} }
override val supportsReadingDates: Boolean = true override val supportsReadingDates: Boolean = true
@@ -114,6 +116,12 @@ class Kitsu(id: Long) : BaseTracker(id, "Kitsu"), DeletableTracker {
} }
override suspend fun search(query: String): List<TrackSearch> { override suspend fun search(query: String): List<TrackSearch> {
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) return api.search(query)
} }
@@ -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.KitsuListSearchResult
import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuOAuth 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.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.kitsu.dto.KitsuUser
import eu.kanade.tachiyomi.data.track.model.TrackSearch import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.network.DELETE import eu.kanade.tachiyomi.network.DELETE
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.HttpException
import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.network.awaitSuccess import eu.kanade.tachiyomi.network.awaitSuccess
import eu.kanade.tachiyomi.network.jsonMime import eu.kanade.tachiyomi.network.jsonMime
import eu.kanade.tachiyomi.network.parseAs 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<KitsuSingleManga>()
.toTrackSearch()
}
} catch (e: HttpException) {
if (e.code == 404) {
null
} else {
throw e
}
}
}
}
companion object { companion object {
private const val CLIENT_ID = "dd031b32d2f56c990b1425efe6c42ad847e7fe3ab46bf1299f05ecd856bdb7dd" private const val CLIENT_ID = "dd031b32d2f56c990b1425efe6c42ad847e7fe3ab46bf1299f05ecd856bdb7dd"
private const val CLIENT_SECRET = "54d7307928f63414defd96399fc31ba847961ceaecef3a5fd93144e960c0e151" private const val CLIENT_SECRET = "54d7307928f63414defd96399fc31ba847961ceaecef3a5fd93144e960c0e151"
@@ -64,6 +64,26 @@ data class KitsuListSearchItemDataAttributes(
val private: Boolean, 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 @Serializable
data class KitsuListSearchItemIncluded( data class KitsuListSearchItemIncluded(
val id: Long, val id: Long,
@@ -79,4 +99,5 @@ data class KitsuListSearchItemIncludedAttributes(
val synopsis: String?, val synopsis: String?,
val startDate: String?, val startDate: String?,
val status: String, val status: String,
val averageRating: String?,
) )
@@ -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) } 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<TrackSearch> { override suspend fun search(query: String): List<TrackSearch> {
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) return api.search(query)
.map { .map {
it.toTrackSearch(id) it.toTrackSearch(id)
@@ -14,6 +14,7 @@ import eu.kanade.tachiyomi.network.DELETE
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.PUT import eu.kanade.tachiyomi.network.PUT
import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.network.awaitSuccess import eu.kanade.tachiyomi.network.awaitSuccess
import eu.kanade.tachiyomi.network.parseAs import eu.kanade.tachiyomi.network.parseAs
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@@ -26,6 +27,7 @@ import kotlinx.serialization.json.putJsonObject
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import tachiyomi.core.common.util.lang.withIOContext
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import tachiyomi.domain.track.model.Track as DomainTrack 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<MURecord>()
}
}
}
}
suspend fun authenticate(username: String, password: String): MUContext { suspend fun authenticate(username: String, password: String): MUContext {
val body = buildJsonObject { val body = buildJsonObject {
put("username", username) put("username", username)
@@ -24,6 +24,8 @@ class Shikimori(id: Long) : BaseTracker(id, "Shikimori"), DeletableTracker {
private val SCORE_LIST = IntRange(0, 10) private val SCORE_LIST = IntRange(0, 10)
.map(Int::toString) .map(Int::toString)
private const val SEARCH_ID_PREFIX = "id:"
} }
private val json: Json by injectLazy() 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<TrackSearch> { override suspend fun search(query: String): List<TrackSearch> {
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) return api.search(query)
} }
@@ -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<SMSearchResult>()
.data.mangas
.firstOrNull()
?.toTrack(trackId)
}
}
}
suspend fun findLibManga(track: Track, isRefresh: Boolean = false): Track? { suspend fun findLibManga(track: Track, isRefresh: Boolean = false): Track? {
return withIOContext { return withIOContext {
val query = $$""" val query = $$"""