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.
This commit is contained in:
MajorTanya 2025-02-25 00:21:22 +01:00 committed by GitHub
parent d8a530266f
commit a96fbba3dc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 164 additions and 169 deletions

View File

@ -19,6 +19,9 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co
### Changed ### Changed
- Apply "Downloaded only" filter to all entries regardless of favourite status ([@NGB-Was-Taken](https://github.com/NGB-Was-Taken)) ([#1603](https://github.com/mihonapp/mihon/pull/1603)) - Apply "Downloaded only" filter to all entries regardless of favourite status ([@NGB-Was-Taken](https://github.com/NGB-Was-Taken)) ([#1603](https://github.com/mihonapp/mihon/pull/1603))
- Ignore hidden files/folders for Local Source chapter list ([@BrutuZ](https://github.com/BrutuZ)) ([#1763](https://github.com/mihonapp/mihon/pull/1763)) - Ignore hidden files/folders for Local Source chapter list ([@BrutuZ](https://github.com/BrutuZ)) ([#1763](https://github.com/mihonapp/mihon/pull/1763))
- Migrate to newer Bangumi API ([@MajorTanya](https://github.com/MajorTanya)) ([#1748](https://github.com/mihonapp/mihon/pull/1748))
- Now showing manga starting dates in search
- Reduced request load by 2-4x in certain situations
### Fixed ### Fixed
- Fix MAL `main_picture` nullability breaking search if a result doesn't have a cover set ([@MajorTanya](https://github.com/MajorTanya)) ([#1618](https://github.com/mihonapp/mihon/pull/1618)) - Fix MAL `main_picture` nullability breaking search if a result doesn't have a cover set ([@MajorTanya](https://github.com/MajorTanya)) ([#1618](https://github.com/mihonapp/mihon/pull/1618))

View File

@ -9,7 +9,6 @@ import eu.kanade.tachiyomi.data.track.bangumi.dto.BGMOAuth
import eu.kanade.tachiyomi.data.track.model.TrackSearch import eu.kanade.tachiyomi.data.track.model.TrackSearch
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
@ -48,26 +47,23 @@ class Bangumi(id: Long) : BaseTracker(id, "Bangumi") {
} }
override suspend fun bind(track: Track, hasReadChapters: Boolean): Track { override suspend fun bind(track: Track, hasReadChapters: Boolean): Track {
val statusTrack = api.statusLibManga(track) val statusTrack = api.statusLibManga(track, getUsername())
val remoteTrack = api.findLibManga(track) return if (statusTrack != null) {
return if (remoteTrack != null && statusTrack != null) { track.copyPersonalFrom(statusTrack)
track.copyPersonalFrom(remoteTrack) track.library_id = statusTrack.library_id
track.library_id = remoteTrack.library_id track.score = statusTrack.score
track.last_chapter_read = statusTrack.last_chapter_read
track.total_chapters = statusTrack.total_chapters
if (track.status != COMPLETED) { if (track.status != COMPLETED) {
track.status = if (hasReadChapters) READING else statusTrack.status track.status = if (hasReadChapters) READING else statusTrack.status
} }
track.score = statusTrack.score track
track.last_chapter_read = statusTrack.last_chapter_read
track.total_chapters = remoteTrack.total_chapters
refresh(track)
} else { } else {
// Set default fields if it's not found in the list // Set default fields if it's not found in the list
track.status = if (hasReadChapters) READING else PLAN_TO_READ track.status = if (hasReadChapters) READING else PLAN_TO_READ
track.score = 0.0 track.score = 0.0
add(track) add(track)
update(track)
} }
} }
@ -76,11 +72,8 @@ class Bangumi(id: Long) : BaseTracker(id, "Bangumi") {
} }
override suspend fun refresh(track: Track): Track { 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) track.copyPersonalFrom(remoteStatusTrack)
api.findLibManga(track)?.let { remoteTrack ->
track.total_chapters = remoteTrack.total_chapters
}
return track return track
} }
@ -112,9 +105,13 @@ class Bangumi(id: Long) : BaseTracker(id, "Bangumi") {
suspend fun login(code: String) { suspend fun login(code: String) {
try { try {
val oauth = api.accessToken(code) 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) interceptor.newAuth(oauth)
saveCredentials(oauth.userId.toString(), oauth.accessToken) saveCredentials(username, oauth.accessToken)
} catch (e: Throwable) { } catch (_: Throwable) {
logout() logout()
} }
} }
@ -126,7 +123,7 @@ class Bangumi(id: Long) : BaseTracker(id, "Bangumi") {
fun restoreToken(): BGMOAuth? { fun restoreToken(): BGMOAuth? {
return try { return try {
json.decodeFromString<BGMOAuth>(trackPreferences.trackToken(this).get()) json.decodeFromString<BGMOAuth>(trackPreferences.trackToken(this).get())
} catch (e: Exception) { } catch (_: Exception) {
null null
} }
} }
@ -138,11 +135,11 @@ class Bangumi(id: Long) : BaseTracker(id, "Bangumi") {
} }
companion object { companion object {
const val READING = 3L const val PLAN_TO_READ = 1L
const val COMPLETED = 2L const val COMPLETED = 2L
const val READING = 3L
const val ON_HOLD = 4L const val ON_HOLD = 4L
const val DROPPED = 5L const val DROPPED = 5L
const val PLAN_TO_READ = 1L
private val SCORE_LIST = IntRange(0, 10) private val SCORE_LIST = IntRange(0, 10)
.map(Int::toString) .map(Int::toString)

View File

@ -5,22 +5,28 @@ import androidx.core.net.toUri
import eu.kanade.tachiyomi.data.database.models.Track 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.BGMSearchItem
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.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
import eu.kanade.tachiyomi.network.HttpException
import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.network.POST
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
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.CacheControl
import okhttp3.FormBody import okhttp3.FormBody
import okhttp3.Headers.Companion.headersOf
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import tachiyomi.core.common.util.lang.withIOContext import tachiyomi.core.common.util.lang.withIOContext
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
class BangumiApi( class BangumiApi(
private val trackId: Long, private val trackId: Long,
@ -34,11 +40,16 @@ class BangumiApi(
suspend fun addLibManga(track: Track): Track { suspend fun addLibManga(track: Track): Track {
return withIOContext { return withIOContext {
val body = FormBody.Builder() val url = "$API_URL/v0/users/-/collections/${track.remote_id}"
.add("rating", track.score.toInt().toString()) val body = buildJsonObject {
.add("status", track.toApiStatus()) put("type", track.toApiStatus())
.build() put("rate", track.score.toInt().coerceIn(0, 10))
authClient.newCall(POST("$API_URL/collection/${track.remote_id}/update", body = body)) 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() .awaitSuccess()
track track
} }
@ -46,107 +57,109 @@ class BangumiApi(
suspend fun updateLibManga(track: Track): Track { suspend fun updateLibManga(track: Track): Track {
return withIOContext { return withIOContext {
// read status update val url = "$API_URL/v0/users/-/collections/${track.remote_id}"
val sbody = FormBody.Builder() val body = buildJsonObject {
.add("rating", track.score.toInt().toString()) put("type", track.toApiStatus())
.add("status", track.toApiStatus()) put("rate", track.score.toInt().coerceIn(0, 10))
.build() put("ep_status", track.last_chapter_read.toInt())
authClient.newCall(POST("$API_URL/collection/${track.remote_id}/update", body = sbody)) }
.awaitSuccess() .toString()
.toRequestBody()
// chapter update val request = Request.Builder()
val body = FormBody.Builder() .url(url)
.add("watched_eps", track.last_chapter_read.toInt().toString()) .patch(body)
.headers(headersOf("Content-Type", APP_JSON))
.build() .build()
authClient.newCall( // Returns with 204 No Content
POST("$API_URL/subject/${track.remote_id}/update/watched_eps", body = body), authClient.newCall(request)
).awaitSuccess() .awaitSuccess()
track track
} }
} }
suspend fun search(search: String): List<TrackSearch> { suspend fun search(search: String): List<TrackSearch> {
// 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 { return withIOContext {
val url = "$API_URL/search/subject/${URLEncoder.encode(search, StandardCharsets.UTF_8.name())}" val url = "$API_URL/v0/search/subjects?limit=20"
.toUri() val body = buildJsonObject {
.buildUpon() put("keyword", search)
.appendQueryParameter("type", "1") put("sort", "match")
.appendQueryParameter("responseGroup", "large") putJsonObject("filter") {
.appendQueryParameter("max_results", "20") putJsonArray("type") {
.build() add(1) // "Book" (书籍) type
}
}
}
.toString()
.toRequestBody()
with(json) { with(json) {
authClient.newCall(GET(url.toString())) authClient.newCall(POST(url, body = body, headers = headersOf("Content-Type", APP_JSON)))
.awaitSuccess() .awaitSuccess()
.parseAs<BGMSearchResult>() .parseAs<BGMSearchResult>()
.let { result -> .data
if (result.code == 404) emptyList<TrackSearch>() .map { it.toTrackSearch(trackId) }
result.list
?.map { it.toTrackSearch(trackId) }
.orEmpty()
}
} }
} }
} }
suspend fun findLibManga(track: Track): Track? { suspend fun statusLibManga(track: Track, username: String): Track? {
return withIOContext { return withIOContext {
val url = "$API_URL/v0/users/$username/collections/${track.remote_id}"
with(json) { with(json) {
authClient.newCall(GET("$API_URL/subject/${track.remote_id}")) try {
.awaitSuccess() authClient.newCall(GET(url, cache = CacheControl.FORCE_NETWORK))
.parseAs<BGMSearchItem>() .awaitSuccess()
.toTrackSearch(trackId) .parseAs<BGMCollectionResponse>()
} .let {
} track.status = it.getStatus()
} track.last_chapter_read = it.epStatus?.toDouble() ?: 0.0
track.score = it.rate?.toDouble() ?: 0.0
suspend fun statusLibManga(track: Track): Track? { track.total_chapters = it.subject?.eps?.toLong() ?: 0L
return withIOContext { track
val urlUserRead = "$API_URL/collection/${track.remote_id}" }
val requestUserRead = Request.Builder() } catch (e: HttpException) {
.url(urlUserRead) if (e.code == 404) { // "subject is not collected by user"
.cacheControl(CacheControl.FORCE_NETWORK) null
.get() } else {
.build() throw e
// TODO: get user readed chapter here
with(json) {
authClient.newCall(requestUserRead)
.awaitSuccess()
.parseAs<BGMCollectionResponse>()
.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
} }
}
} }
} }
} }
suspend fun accessToken(code: String): BGMOAuth { suspend fun accessToken(code: String): BGMOAuth {
return withIOContext { 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) { with(json) {
client.newCall(accessTokenRequest(code)) client.newCall(POST(OAUTH_URL, body = body))
.awaitSuccess() .awaitSuccess()
.parseAs() .parseAs<BGMOAuth>()
} }
} }
} }
private fun accessTokenRequest(code: String) = POST( suspend fun getUsername(): String {
OAUTH_URL, return withIOContext {
body = FormBody.Builder() with(json) {
.add("grant_type", "authorization_code") authClient.newCall(GET("$API_URL/v0/me$"))
.add("client_id", CLIENT_ID) .awaitSuccess()
.add("client_secret", CLIENT_SECRET) .parseAs<BGMUser>()
.add("code", code) .username
.add("redirect_uri", REDIRECT_URL) }
.build(), }
) }
companion object { companion object {
private const val CLIENT_ID = "bgm291665acbd06a4c28" private const val CLIENT_ID = "bgm291665acbd06a4c28"
@ -158,6 +171,8 @@ class BangumiApi(
private const val REDIRECT_URL = "mihon://bangumi-auth" private const val REDIRECT_URL = "mihon://bangumi-auth"
private const val APP_JSON = "application/json"
fun authUrl(): Uri = fun authUrl(): Uri =
LOGIN_URL.toUri().buildUpon() LOGIN_URL.toUri().buildUpon()
.appendQueryParameter("client_id", CLIENT_ID) .appendQueryParameter("client_id", CLIENT_ID)

View File

@ -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.BGMOAuth
import eu.kanade.tachiyomi.data.track.bangumi.dto.isExpired import eu.kanade.tachiyomi.data.track.bangumi.dto.isExpired
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import okhttp3.FormBody
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.Response import okhttp3.Response
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
@ -39,14 +38,7 @@ class BangumiInterceptor(private val bangumi: Bangumi) : Interceptor {
"antsylich/Mihon/v${BuildConfig.VERSION_NAME} (Android) (http://github.com/mihonapp/mihon)", "antsylich/Mihon/v${BuildConfig.VERSION_NAME} (Android) (http://github.com/mihonapp/mihon)",
) )
.apply { .apply {
if (originalRequest.method == "GET") { addHeader("Authorization", "Bearer ${currAuth.accessToken}")
val newUrl = originalRequest.url.newBuilder()
.addQueryParameter("access_token", currAuth.accessToken)
.build()
url(newUrl)
} else {
post(addToken(currAuth.accessToken, originalRequest.body as FormBody))
}
} }
.build() .build()
.let(chain::proceed) .let(chain::proceed)
@ -68,13 +60,4 @@ class BangumiInterceptor(private val bangumi: Bangumi) : Interceptor {
bangumi.saveToken(oauth) bangumi.saveToken(oauth)
} }
private fun addToken(token: String, oidFormBody: FormBody): FormBody {
val newFormBody = FormBody.Builder()
for (i in 0..<oidFormBody.size) {
newFormBody.add(oidFormBody.name(i), oidFormBody.value(i))
}
newFormBody.add("access_token", token)
return newFormBody.build()
}
} }

View File

@ -3,10 +3,10 @@ package eu.kanade.tachiyomi.data.track.bangumi
import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.database.models.Track
fun Track.toApiStatus() = when (status) { fun Track.toApiStatus() = when (status) {
Bangumi.READING -> "do" Bangumi.PLAN_TO_READ -> 1
Bangumi.COMPLETED -> "collect" Bangumi.COMPLETED -> 2
Bangumi.ON_HOLD -> "on_hold" Bangumi.READING -> 3
Bangumi.DROPPED -> "dropped" Bangumi.ON_HOLD -> 4
Bangumi.PLAN_TO_READ -> "wish" Bangumi.DROPPED -> 5
else -> throw NotImplementedError("Unknown status: $status") else -> throw NotImplementedError("Unknown status: $status")
} }

View File

@ -1,28 +1,34 @@
package eu.kanade.tachiyomi.data.track.bangumi.dto package eu.kanade.tachiyomi.data.track.bangumi.dto
import eu.kanade.tachiyomi.data.track.bangumi.Bangumi
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
// Incomplete DTO with only our needed attributes
data class BGMCollectionResponse( data class BGMCollectionResponse(
val code: Int?, val rate: Int?,
val `private`: Int? = 0, val type: Int?,
val comment: String? = "",
@SerialName("ep_status") @SerialName("ep_status")
val epStatus: Int? = 0, val epStatus: Int? = 0,
@SerialName("lasttouch")
val lastTouch: Int? = 0,
val rating: Double? = 0.0,
val status: Status? = Status(),
val tag: List<String?>? = emptyList(),
val user: User? = User(),
@SerialName("vol_status") @SerialName("vol_status")
val volStatus: Int? = 0, 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 @Serializable
data class Status( // Incomplete DTO with only our needed attributes
val id: Long? = 0, data class BGMSlimSubject(
val name: String? = "", val volumes: Int?,
val type: String? = "", val eps: Int?,
) )

View File

@ -6,45 +6,50 @@ import kotlinx.serialization.Serializable
@Serializable @Serializable
data class BGMSearchResult( data class BGMSearchResult(
val list: List<BGMSearchItem>?, val total: Int,
val code: Int?, val limit: Int,
val offset: Int,
val data: List<BGMSubject> = emptyList(),
) )
@Serializable @Serializable
data class BGMSearchItem( // Incomplete DTO with only our needed attributes
data class BGMSubject(
val id: Long, val id: Long,
@SerialName("name_cn") @SerialName("name_cn")
val nameCn: String, val nameCn: String,
val name: String, val name: String,
val type: Int,
val summary: String?, val summary: String?,
val images: BGMSearchItemCovers?, val date: String?, // YYYY-MM-DD
@SerialName("eps_count") val images: BGMSubjectImages?,
val epsCount: Long?, val volumes: Long = 0,
val rating: BGMSearchItemRating?, val eps: Long = 0,
val url: String, val rating: BGMSubjectRating?,
) { ) {
fun toTrackSearch(trackId: Long): TrackSearch = TrackSearch.create(trackId).apply { fun toTrackSearch(trackId: Long): TrackSearch = TrackSearch.create(trackId).apply {
remote_id = this@BGMSearchItem.id remote_id = this@BGMSubject.id
title = nameCn.ifBlank { name } title = nameCn.ifBlank { name }
cover_url = images?.common.orEmpty() cover_url = images?.common.orEmpty()
summary = if (nameCn.isNotBlank()) { summary = if (nameCn.isNotBlank()) {
"作品原名:$name" + this@BGMSearchItem.summary?.let { "\n$it" }.orEmpty() "作品原名:$name" + this@BGMSubject.summary?.let { "\n${it.trim()}" }.orEmpty()
} else { } else {
this@BGMSearchItem.summary.orEmpty() this@BGMSubject.summary?.trim().orEmpty()
} }
score = rating?.score ?: -1.0 score = rating?.score ?: -1.0
tracking_url = url tracking_url = "https://bangumi.tv/subject/${this@BGMSubject.id}"
total_chapters = epsCount ?: 0 total_chapters = eps
start_date = date ?: ""
} }
} }
@Serializable @Serializable
data class BGMSearchItemCovers( // Incomplete DTO with only our needed attributes
data class BGMSubjectImages(
val common: String?, val common: String?,
) )
@Serializable @Serializable
data class BGMSearchItemRating( // Incomplete DTO with only our needed attributes
data class BGMSubjectRating(
val score: Double?, val score: Double?,
) )

View File

@ -1,23 +1,9 @@
package eu.kanade.tachiyomi.data.track.bangumi.dto package eu.kanade.tachiyomi.data.track.bangumi.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
data class Avatar( // Incomplete DTO with only our needed attributes
val large: String? = "", data class BGMUser(
val medium: String? = "", val username: 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? = "",
) )