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
- 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))
- 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
- 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 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
@ -48,26 +47,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)
}
}
@ -76,11 +72,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
}
@ -112,9 +105,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()
}
}
@ -126,7 +123,7 @@ class Bangumi(id: Long) : BaseTracker(id, "Bangumi") {
fun restoreToken(): BGMOAuth? {
return try {
json.decodeFromString<BGMOAuth>(trackPreferences.trackToken(this).get())
} catch (e: Exception) {
} catch (_: Exception) {
null
}
}
@ -138,11 +135,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)

View File

@ -5,22 +5,28 @@ 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.BGMUser
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
class BangumiApi(
private val trackId: Long,
@ -34,11 +40,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
}
@ -46,107 +57,109 @@ 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<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 {
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<BGMSearchResult>()
.let { result ->
if (result.code == 404) emptyList<TrackSearch>()
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<BGMSearchItem>()
.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<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
try {
authClient.newCall(GET(url, cache = CacheControl.FORCE_NETWORK))
.awaitSuccess()
.parseAs<BGMCollectionResponse>()
.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
}
}
}
}
}
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<BGMOAuth>()
}
}
}
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<BGMUser>()
.username
}
}
}
companion object {
private const val CLIENT_ID = "bgm291665acbd06a4c28"
@ -158,6 +171,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)

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.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 {
"antsylich/Mihon/v${BuildConfig.VERSION_NAME} (Android) (http://github.com/mihonapp/mihon)",
)
.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..<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
fun Track.toApiStatus() = when (status) {
Bangumi.READING -> "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")
}

View File

@ -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<String?>? = 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?,
)

View File

@ -6,45 +6,50 @@ import kotlinx.serialization.Serializable
@Serializable
data class BGMSearchResult(
val list: List<BGMSearchItem>?,
val code: Int?,
val total: Int,
val limit: Int,
val offset: Int,
val data: List<BGMSubject> = 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?,
) {
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?,
)

View File

@ -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,
)