From ab976d8b07934764cd7edcbf64544e8c2d19c221 Mon Sep 17 00:00:00 2001 From: MajorTanya <39014446+MajorTanya@users.noreply.github.com> Date: Tue, 25 Feb 2025 00:21:22 +0100 Subject: [PATCH] Migrate to Bangumi's newer v0 API (#1748) This comes with many benefits: - Starting dates are now available and shown to users - Lays groundwork to add private tracking for Bangumi, e.g. in #1736 - Mihon makes approximately 2-4 times fewer calls to Bangumi's API - Simplified interceptor for the access token addition - v0 does not allow access tokens in the query string - There is actively maintained documentation for it Also shrunk the DTOs for Bangumi by removing attributes we have no use for either now or in the foreseeable future. Volume data remains in case Mihon wants to ever support volumes. But attributes such as user avatars, nicknames, data relating to Bangumi's tag & meta-tag systems, etc. have been removed or just not added to the DTOs. (cherry picked from commit a96fbba3dc354e363b85923c52feceb88dc34447) # Conflicts: # CHANGELOG.md # app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiApi.kt --- .../tachiyomi/data/track/bangumi/Bangumi.kt | 39 ++-- .../data/track/bangumi/BangumiApi.kt | 171 ++++++++++-------- .../data/track/bangumi/BangumiInterceptor.kt | 19 +- .../data/track/bangumi/BangumiUtils.kt | 10 +- .../bangumi/dto/BGMCollectionResponse.kt | 34 ++-- .../data/track/bangumi/dto/BGMSearch.kt | 40 ++-- .../data/track/bangumi/dto/BGMUser.kt | 20 +- .../bangumi/dto/{BGMSubject.kt => Infobox.kt} | 11 -- 8 files changed, 164 insertions(+), 180 deletions(-) rename app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/dto/{BGMSubject.kt => Infobox.kt} (88%) 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 b8db13fa9..dc576ec3b 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 @@ -10,7 +10,6 @@ import eu.kanade.tachiyomi.data.track.model.TrackMangaMetadata import eu.kanade.tachiyomi.data.track.model.TrackSearch import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList -import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import tachiyomi.i18n.MR import uy.kohesive.injekt.injectLazy @@ -49,26 +48,23 @@ class Bangumi(id: Long) : BaseTracker(id, "Bangumi") { } override suspend fun bind(track: Track, hasReadChapters: Boolean): Track { - val statusTrack = api.statusLibManga(track) - val remoteTrack = api.findLibManga(track) - return if (remoteTrack != null && statusTrack != null) { - track.copyPersonalFrom(remoteTrack) - track.library_id = remoteTrack.library_id - + val statusTrack = api.statusLibManga(track, getUsername()) + return if (statusTrack != null) { + track.copyPersonalFrom(statusTrack) + track.library_id = statusTrack.library_id + track.score = statusTrack.score + track.last_chapter_read = statusTrack.last_chapter_read + track.total_chapters = statusTrack.total_chapters if (track.status != COMPLETED) { track.status = if (hasReadChapters) READING else statusTrack.status } - track.score = statusTrack.score - track.last_chapter_read = statusTrack.last_chapter_read - track.total_chapters = remoteTrack.total_chapters - refresh(track) + track } else { // Set default fields if it's not found in the list track.status = if (hasReadChapters) READING else PLAN_TO_READ track.score = 0.0 add(track) - update(track) } } @@ -81,11 +77,8 @@ class Bangumi(id: Long) : BaseTracker(id, "Bangumi") { } override suspend fun refresh(track: Track): Track { - val remoteStatusTrack = api.statusLibManga(track) ?: throw Exception("Could not find manga") + val remoteStatusTrack = api.statusLibManga(track, getUsername()) ?: throw Exception("Could not find manga") track.copyPersonalFrom(remoteStatusTrack) - api.findLibManga(track)?.let { remoteTrack -> - track.total_chapters = remoteTrack.total_chapters - } return track } @@ -117,9 +110,13 @@ class Bangumi(id: Long) : BaseTracker(id, "Bangumi") { suspend fun login(code: String) { try { val oauth = api.accessToken(code) + // Users can set a 'username' (not nickname) once which effectively + // replaces the stringified ID in certain queries. + // If no username is set, the API returns the user ID as a strings + var username = api.getUsername() interceptor.newAuth(oauth) - saveCredentials(oauth.userId.toString(), oauth.accessToken) - } catch (e: Throwable) { + saveCredentials(username, oauth.accessToken) + } catch (_: Throwable) { logout() } } @@ -131,7 +128,7 @@ class Bangumi(id: Long) : BaseTracker(id, "Bangumi") { fun restoreToken(): BGMOAuth? { return try { json.decodeFromString(trackPreferences.trackToken(this).get()) - } catch (e: Exception) { + } catch (_: Exception) { null } } @@ -143,11 +140,11 @@ class Bangumi(id: Long) : BaseTracker(id, "Bangumi") { } companion object { - const val READING = 3L + const val PLAN_TO_READ = 1L const val COMPLETED = 2L + const val READING = 3L const val ON_HOLD = 4L const val DROPPED = 5L - const val PLAN_TO_READ = 1L private val SCORE_LIST = IntRange(0, 10) .map(Int::toString) 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 ca7b16cb4..f05eaa20c 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 @@ -5,25 +5,31 @@ import androidx.core.net.toUri 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.BGMSearchItem 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.Infobox import eu.kanade.tachiyomi.data.track.model.TrackMangaMetadata import eu.kanade.tachiyomi.data.track.model.TrackSearch import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.HttpException import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.network.awaitSuccess import eu.kanade.tachiyomi.network.parseAs import kotlinx.serialization.json.Json +import kotlinx.serialization.json.add +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonArray +import kotlinx.serialization.json.putJsonObject import okhttp3.CacheControl import okhttp3.FormBody +import okhttp3.Headers.Companion.headersOf import okhttp3.OkHttpClient import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody import tachiyomi.core.common.util.lang.withIOContext import uy.kohesive.injekt.injectLazy -import java.net.URLEncoder -import java.nio.charset.StandardCharsets import tachiyomi.domain.track.model.Track as DomainTrack class BangumiApi( @@ -38,11 +44,16 @@ class BangumiApi( suspend fun addLibManga(track: Track): Track { return withIOContext { - val body = FormBody.Builder() - .add("rating", track.score.toInt().toString()) - .add("status", track.toApiStatus()) - .build() - authClient.newCall(POST("$API_URL/collection/${track.remote_id}/update", body = body)) + val url = "$API_URL/v0/users/-/collections/${track.remote_id}" + val body = buildJsonObject { + put("type", track.toApiStatus()) + put("rate", track.score.toInt().coerceIn(0, 10)) + put("ep_status", track.last_chapter_read.toInt()) + } + .toString() + .toRequestBody() + // Returns with 202 Accepted on success with no body + authClient.newCall(POST(url, body = body, headers = headersOf("Content-Type", APP_JSON))) .awaitSuccess() track } @@ -50,83 +61,78 @@ class BangumiApi( suspend fun updateLibManga(track: Track): Track { return withIOContext { - // read status update - val sbody = FormBody.Builder() - .add("rating", track.score.toInt().toString()) - .add("status", track.toApiStatus()) - .build() - authClient.newCall(POST("$API_URL/collection/${track.remote_id}/update", body = sbody)) - .awaitSuccess() + val url = "$API_URL/v0/users/-/collections/${track.remote_id}" + val body = buildJsonObject { + put("type", track.toApiStatus()) + put("rate", track.score.toInt().coerceIn(0, 10)) + put("ep_status", track.last_chapter_read.toInt()) + } + .toString() + .toRequestBody() - // chapter update - val body = FormBody.Builder() - .add("watched_eps", track.last_chapter_read.toInt().toString()) + val request = Request.Builder() + .url(url) + .patch(body) + .headers(headersOf("Content-Type", APP_JSON)) .build() - authClient.newCall( - POST("$API_URL/subject/${track.remote_id}/update/watched_eps", body = body), - ).awaitSuccess() + // Returns with 204 No Content + authClient.newCall(request) + .awaitSuccess() track } } suspend fun search(search: String): List { + // This API is marked as experimental in the documentation + // but that has been the case since 2022 with few significant + // changes to the schema for this endpoint since + // "实验性 API, 本 schema 和实际的 API 行为都可能随时发生改动" return withIOContext { - val url = "$API_URL/search/subject/${URLEncoder.encode(search, StandardCharsets.UTF_8.name())}" - .toUri() - .buildUpon() - .appendQueryParameter("type", "1") - .appendQueryParameter("responseGroup", "large") - .appendQueryParameter("max_results", "20") - .build() + val url = "$API_URL/v0/search/subjects?limit=20" + val body = buildJsonObject { + put("keyword", search) + put("sort", "match") + putJsonObject("filter") { + putJsonArray("type") { + add(1) // "Book" (书籍) type + } + } + } + .toString() + .toRequestBody() with(json) { - authClient.newCall(GET(url.toString())) + authClient.newCall(POST(url, body = body, headers = headersOf("Content-Type", APP_JSON))) .awaitSuccess() .parseAs() - .let { result -> - if (result.code == 404) emptyList() - - result.list - ?.map { it.toTrackSearch(trackId) } - .orEmpty() - } + .data + .map { it.toTrackSearch(trackId) } } } } - suspend fun findLibManga(track: Track): Track? { + suspend fun statusLibManga(track: Track, username: String): Track? { return withIOContext { + val url = "$API_URL/v0/users/$username/collections/${track.remote_id}" with(json) { - authClient.newCall(GET("$API_URL/subject/${track.remote_id}")) - .awaitSuccess() - .parseAs() - .toTrackSearch(trackId) - } - } - } - - suspend fun statusLibManga(track: Track): Track? { - return withIOContext { - val urlUserRead = "$API_URL/collection/${track.remote_id}" - val requestUserRead = Request.Builder() - .url(urlUserRead) - .cacheControl(CacheControl.FORCE_NETWORK) - .get() - .build() - - // TODO: get user readed chapter here - with(json) { - authClient.newCall(requestUserRead) - .awaitSuccess() - .parseAs() - .let { - if (it.code == 400) return@let null - - track.status = it.status?.id!! - track.last_chapter_read = it.epStatus!!.toDouble() - track.score = it.rating!! - track + try { + authClient.newCall(GET(url, cache = CacheControl.FORCE_NETWORK)) + .awaitSuccess() + .parseAs() + .let { + track.status = it.getStatus() + track.last_chapter_read = it.epStatus?.toDouble() ?: 0.0 + track.score = it.rate?.toDouble() ?: 0.0 + track.total_chapters = it.subject?.eps?.toLong() ?: 0L + track + } + } catch (e: HttpException) { + if (e.code == 404) { // "subject is not collected by user" + null + } else { + throw e } + } } } } @@ -161,24 +167,31 @@ class BangumiApi( suspend fun accessToken(code: String): BGMOAuth { return withIOContext { + val body = FormBody.Builder() + .add("grant_type", "authorization_code") + .add("client_id", CLIENT_ID) + .add("client_secret", CLIENT_SECRET) + .add("code", code) + .add("redirect_uri", REDIRECT_URL) + .build() with(json) { - client.newCall(accessTokenRequest(code)) + client.newCall(POST(OAUTH_URL, body = body)) .awaitSuccess() - .parseAs() + .parseAs() } } } - private fun accessTokenRequest(code: String) = POST( - OAUTH_URL, - body = FormBody.Builder() - .add("grant_type", "authorization_code") - .add("client_id", CLIENT_ID) - .add("client_secret", CLIENT_SECRET) - .add("code", code) - .add("redirect_uri", REDIRECT_URL) - .build(), - ) + suspend fun getUsername(): String { + return withIOContext { + with(json) { + authClient.newCall(GET("$API_URL/v0/me$")) + .awaitSuccess() + .parseAs() + .username + } + } + } companion object { private const val CLIENT_ID = "bgm291665acbd06a4c28" @@ -190,6 +203,8 @@ class BangumiApi( private const val REDIRECT_URL = "mihon://bangumi-auth" + private const val APP_JSON = "application/json" + fun authUrl(): Uri = LOGIN_URL.toUri().buildUpon() .appendQueryParameter("client_id", CLIENT_ID) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiInterceptor.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiInterceptor.kt index 9d6295a87..12baac6ad 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiInterceptor.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiInterceptor.kt @@ -4,7 +4,6 @@ import eu.kanade.tachiyomi.BuildConfig import eu.kanade.tachiyomi.data.track.bangumi.dto.BGMOAuth import eu.kanade.tachiyomi.data.track.bangumi.dto.isExpired import kotlinx.serialization.json.Json -import okhttp3.FormBody import okhttp3.Interceptor import okhttp3.Response import uy.kohesive.injekt.injectLazy @@ -39,14 +38,7 @@ class BangumiInterceptor(private val bangumi: Bangumi) : Interceptor { "jobobby04/TachiyomiSY/v${BuildConfig.VERSION_NAME} (Android) (http://github.com/jobobby04/tachiyomisy)", ) .apply { - if (originalRequest.method == "GET") { - val newUrl = originalRequest.url.newBuilder() - .addQueryParameter("access_token", currAuth.accessToken) - .build() - url(newUrl) - } else { - post(addToken(currAuth.accessToken, originalRequest.body as FormBody)) - } + addHeader("Authorization", "Bearer ${currAuth.accessToken}") } .build() .let(chain::proceed) @@ -68,13 +60,4 @@ class BangumiInterceptor(private val bangumi: Bangumi) : Interceptor { bangumi.saveToken(oauth) } - - private fun addToken(token: String, oidFormBody: FormBody): FormBody { - val newFormBody = FormBody.Builder() - for (i in 0.. "do" - Bangumi.COMPLETED -> "collect" - Bangumi.ON_HOLD -> "on_hold" - Bangumi.DROPPED -> "dropped" - Bangumi.PLAN_TO_READ -> "wish" + Bangumi.PLAN_TO_READ -> 1 + Bangumi.COMPLETED -> 2 + Bangumi.READING -> 3 + Bangumi.ON_HOLD -> 4 + Bangumi.DROPPED -> 5 else -> throw NotImplementedError("Unknown status: $status") } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/dto/BGMCollectionResponse.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/dto/BGMCollectionResponse.kt index 85501934f..c98077850 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/dto/BGMCollectionResponse.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/dto/BGMCollectionResponse.kt @@ -1,28 +1,34 @@ package eu.kanade.tachiyomi.data.track.bangumi.dto +import eu.kanade.tachiyomi.data.track.bangumi.Bangumi import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable +// Incomplete DTO with only our needed attributes data class BGMCollectionResponse( - val code: Int?, - val `private`: Int? = 0, - val comment: String? = "", + val rate: Int?, + val type: Int?, @SerialName("ep_status") val epStatus: Int? = 0, - @SerialName("lasttouch") - val lastTouch: Int? = 0, - val rating: Double? = 0.0, - val status: Status? = Status(), - val tag: List? = emptyList(), - val user: User? = User(), @SerialName("vol_status") val volStatus: Int? = 0, -) + val private: Boolean = false, + val subject: BGMSlimSubject? = null, +) { + fun getStatus(): Long = when (type) { + 1 -> Bangumi.PLAN_TO_READ + 2 -> Bangumi.COMPLETED + 3 -> Bangumi.READING + 4 -> Bangumi.ON_HOLD + 5 -> Bangumi.DROPPED + else -> throw NotImplementedError("Unknown status: $type") + } +} @Serializable -data class Status( - val id: Long? = 0, - val name: String? = "", - val type: String? = "", +// Incomplete DTO with only our needed attributes +data class BGMSlimSubject( + val volumes: Int?, + val eps: Int?, ) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/dto/BGMSearch.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/dto/BGMSearch.kt index 9c3151f49..fece43bd1 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/dto/BGMSearch.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/dto/BGMSearch.kt @@ -6,45 +6,53 @@ import kotlinx.serialization.Serializable @Serializable data class BGMSearchResult( - val list: List?, - val code: Int?, + val total: Int, + val limit: Int, + val offset: Int, + val data: List = emptyList(), ) @Serializable -data class BGMSearchItem( +// Incomplete DTO with only our needed attributes +data class BGMSubject( val id: Long, @SerialName("name_cn") val nameCn: String, val name: String, - val type: Int, val summary: String?, - val images: BGMSearchItemCovers?, - @SerialName("eps_count") - val epsCount: Long?, - val rating: BGMSearchItemRating?, - val url: String, + val date: String?, // YYYY-MM-DD + val images: BGMSubjectImages?, + val volumes: Long = 0, + val eps: Long = 0, + val rating: BGMSubjectRating?, + // SY --> + val infobox: List = emptyList(), + // SY <-- ) { fun toTrackSearch(trackId: Long): TrackSearch = TrackSearch.create(trackId).apply { - remote_id = this@BGMSearchItem.id + remote_id = this@BGMSubject.id title = nameCn.ifBlank { name } cover_url = images?.common.orEmpty() summary = if (nameCn.isNotBlank()) { - "作品原名:$name" + this@BGMSearchItem.summary?.let { "\n$it" }.orEmpty() + "作品原名:$name" + this@BGMSubject.summary?.let { "\n${it.trim()}" }.orEmpty() } else { - this@BGMSearchItem.summary.orEmpty() + this@BGMSubject.summary?.trim().orEmpty() } score = rating?.score ?: -1.0 - tracking_url = url - total_chapters = epsCount ?: 0 + tracking_url = "https://bangumi.tv/subject/${this@BGMSubject.id}" + total_chapters = eps + start_date = date ?: "" } } @Serializable -data class BGMSearchItemCovers( +// Incomplete DTO with only our needed attributes +data class BGMSubjectImages( val common: String?, ) @Serializable -data class BGMSearchItemRating( +// Incomplete DTO with only our needed attributes +data class BGMSubjectRating( val score: Double?, ) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/dto/BGMUser.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/dto/BGMUser.kt index 375c39eb6..70bb96ee9 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/dto/BGMUser.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/dto/BGMUser.kt @@ -1,23 +1,9 @@ package eu.kanade.tachiyomi.data.track.bangumi.dto -import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable -data class Avatar( - val large: String? = "", - val medium: String? = "", - val small: String? = "", -) - -@Serializable -data class User( - val avatar: Avatar? = Avatar(), - val id: Int? = 0, - val nickname: String? = "", - val sign: String? = "", - val url: String? = "", - @SerialName("usergroup") - val userGroup: Int? = 0, - val username: String? = "", +// Incomplete DTO with only our needed attributes +data class BGMUser( + val username: String, ) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/dto/BGMSubject.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/dto/Infobox.kt similarity index 88% rename from app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/dto/BGMSubject.kt rename to app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/dto/Infobox.kt index 0da7b0cfa..01f2c1eeb 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/dto/BGMSubject.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/dto/Infobox.kt @@ -10,17 +10,6 @@ import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive -@Serializable -data class BGMSubject( - val images: BGMSearchItemCovers?, - val summary: String, - val name: String, - @SerialName("name_cn") - val nameCn: String, - val infobox: List, - val id: Long, -) - // infobox deserializer and related classes courtesy of // https://github.com/Snd-R/komf/blob/4c260a3dcd326a5e1d74ac9662eec8124ab7e461/komf-core/src/commonMain/kotlin/snd/komf/providers/bangumi/model/BangumiSubject.kt#L53-L89 object InfoBoxSerializer : JsonContentPolymorphicSerializer(Infobox::class) {