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
@@ -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<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)
}
@@ -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 {
if (dateValue == 0L) {
return buildJsonObject {
@@ -67,6 +67,12 @@ class Bangumi(id: Long) : BaseTracker(id, "Bangumi") {
}
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)
}
@@ -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:"
}
}
@@ -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<BGMSubject>()
.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}"
@@ -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<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 {
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.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<HKManga>()
.toTrack(trackId)
}
}
}
}
suspend fun getRead(track: Track): HKRead? {
return withIOContext {
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 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<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)
}
@@ -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<KitsuSingleManga>()
.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"
@@ -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?,
)
@@ -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<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)
.map {
it.toTrackSearch(id)
@@ -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<MURecord>()
}
}
}
}
suspend fun authenticate(username: String, password: String): MUContext {
val body = buildJsonObject {
put("username", username)
@@ -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<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)
}
@@ -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? {
return withIOContext {
val query = $$"""