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