[Feature] Support Bangumi Tracker (#1343)

* feat: Support Bangumi Tracker

Credits: Andreas, AntsyLich, Caleb Morris, Gauthier, MCAxiaz, MajorTanya, NarwhalHorns, arkon, fei long, jmir1, mutsumi, stevenyomi

* Use Suwayomi api keys

---------

Co-authored-by: Mitchell Syer <Syer10@users.noreply.github.com>
This commit is contained in:
KAAAsS
2025-04-13 07:34:04 +08:00
committed by GitHub
parent cbe26b7291
commit f8d73819ea
6 changed files with 537 additions and 2 deletions

View File

@@ -1,6 +1,7 @@
package suwayomi.tachidesk.manga.impl.track.tracker
import suwayomi.tachidesk.manga.impl.track.tracker.anilist.Anilist
import suwayomi.tachidesk.manga.impl.track.tracker.bangumi.Bangumi
import suwayomi.tachidesk.manga.impl.track.tracker.kitsu.Kitsu
import suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.MangaUpdates
import suwayomi.tachidesk.manga.impl.track.tracker.myanimelist.MyAnimeList
@@ -22,13 +23,14 @@ object TrackerManager {
val kitsu = Kitsu(KITSU)
// val shikimori = Shikimori(SHIKIMORI)
// val bangumi = Bangumi(BANGUMI)
val bangumi = Bangumi(BANGUMI)
// val komga = Komga(KOMGA)
val mangaUpdates = MangaUpdates(MANGA_UPDATES)
// val kavita = Kavita(context, KAVITA)
// val suwayomi = Suwayomi(SUWAYOMI)
val services: List<Tracker> = listOf(myAnimeList, aniList, kitsu, mangaUpdates)
val services: List<Tracker> = listOf(myAnimeList, aniList, kitsu, mangaUpdates, bangumi)
fun getTracker(id: Int) = services.find { it.id == id }

View File

@@ -0,0 +1,163 @@
package suwayomi.tachidesk.manga.impl.track.tracker.bangumi
import android.annotation.StringRes
import kotlinx.serialization.json.Json
import suwayomi.tachidesk.manga.impl.track.tracker.Tracker
import suwayomi.tachidesk.manga.impl.track.tracker.extractToken
import suwayomi.tachidesk.manga.impl.track.tracker.model.Track
import suwayomi.tachidesk.manga.impl.track.tracker.model.TrackSearch
import uy.kohesive.injekt.injectLazy
import java.io.IOException
class Bangumi(
id: Int,
) : Tracker(id, "Bangumi") {
companion object {
const val PLAN_TO_READ = 1
const val COMPLETED = 2
const val READING = 3
const val ON_HOLD = 4
const val DROPPED = 5
private val SCORE_LIST =
IntRange(0, 10)
.map(Int::toString)
}
override val supportsTrackDeletion: Boolean = false
private val json: Json by injectLazy()
private val interceptor by lazy { BangumiInterceptor(this) }
private val api by lazy { BangumiApi(id, client, interceptor) }
override fun getScoreList(): List<String> = SCORE_LIST
override fun displayScore(track: Track): String = track.score.toInt().toString()
private suspend fun add(track: Track): Track = api.addLibManga(track)
override suspend fun update(
track: Track,
didReadChapter: Boolean,
): Track {
if (track.status != COMPLETED) {
if (didReadChapter) {
if (track.last_chapter_read.toInt() == track.total_chapters && track.total_chapters > 0) {
track.status = COMPLETED
} else {
track.status = READING
}
}
}
return api.updateLibManga(track)
}
override suspend fun bind(
track: Track,
hasReadChapters: Boolean,
): Track {
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
}
update(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.0F
add(track)
}
}
override suspend fun search(query: String): List<TrackSearch> = api.search(query)
override suspend fun refresh(track: Track): Track {
val remoteStatusTrack = api.statusLibManga(track, getUsername()) ?: throw Exception("Could not find manga")
track.copyPersonalFrom(remoteStatusTrack)
return track
}
override fun authUrl(): String = BangumiApi.authUrl().toString()
override suspend fun authCallback(url: String) {
val code = url.extractToken("code") ?: throw IOException("cannot find token")
login(code)
}
override fun getLogo() = "/static/tracker/bangumi.png"
override fun getStatusList(): List<Int> = listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLAN_TO_READ)
@StringRes
override fun getStatus(status: Int): String? =
when (status) {
READING -> "Reading"
PLAN_TO_READ -> "Plan to read"
COMPLETED -> "Completed"
ON_HOLD -> "On hold"
DROPPED -> "Dropped"
else -> null
}
override fun getReadingStatus(): Int = READING
override fun getRereadingStatus(): Int = -1
override fun getCompletionStatus(): Int = COMPLETED
override suspend fun login(
username: String,
password: String,
) = login(password)
suspend fun login(code: String) {
try {
val oauth = api.accessToken(code)
interceptor.newAuth(oauth)
// 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
val username = api.getUsername()
saveCredentials(username, oauth.accessToken)
} catch (_: Throwable) {
logout()
}
}
fun saveToken(oauth: BGMOAuth?) {
trackPreferences.setTrackToken(this, json.encodeToString(oauth))
}
fun restoreToken(): BGMOAuth? =
try {
json.decodeFromString<BGMOAuth>(trackPreferences.getTrackToken(this)!!)
} catch (_: Exception) {
null
}
override fun logout() {
super.logout()
trackPreferences.setTrackToken(this, null)
interceptor.newAuth(null)
}
}
fun Track.toApiStatus() =
when (status) {
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

@@ -0,0 +1,193 @@
package suwayomi.tachidesk.manga.impl.track.tracker.bangumi
import android.net.Uri
import androidx.core.net.toUri
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 eu.kanade.tachiyomi.util.lang.withIOContext
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 suwayomi.tachidesk.manga.impl.track.tracker.model.Track
import suwayomi.tachidesk.manga.impl.track.tracker.model.TrackSearch
import uy.kohesive.injekt.injectLazy
class BangumiApi(
private val trackId: Int,
private val client: OkHttpClient,
interceptor: BangumiInterceptor,
) {
private val json: Json by injectLazy()
private val authClient = client.newBuilder().addInterceptor(interceptor).build()
suspend fun addLibManga(track: Track): Track =
withIOContext {
val url = "$API_URL/v0/users/-/collections/${track.media_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
}
suspend fun updateLibManga(track: Track): Track =
withIOContext {
val url = "$API_URL/v0/users/-/collections/${track.media_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()
val request =
Request
.Builder()
.url(url)
.patch(body)
.headers(headersOf("Content-Type", APP_JSON))
.build()
// 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/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(POST(url, body = body, headers = headersOf("Content-Type", APP_JSON)))
.awaitSuccess()
.parseAs<BGMSearchResult>()
.data
.filter { it.platform == null || it.platform == "漫画" }
.map { it.toTrackSearch(trackId) }
}
}
}
suspend fun statusLibManga(
track: Track,
username: String,
): Track? =
withIOContext {
val url = "$API_URL/v0/users/$username/collections/${track.media_id}"
with(json) {
try {
authClient
.newCall(GET(url, cache = CacheControl.FORCE_NETWORK))
.awaitSuccess()
.parseAs<BGMCollectionResponse>()
.let {
track.status = it.getStatus()
track.last_chapter_read = it.epStatus?.toFloat() ?: 0.0F
track.score = it.rate?.toFloat() ?: 0.0F
track.total_chapters = it.subject?.eps ?: 0
track
}
} catch (e: HttpException) {
if (e.code == 404) { // "subject is not collected by user"
null
} else {
throw e
}
}
}
}
suspend fun accessToken(code: String): BGMOAuth =
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(POST(OAUTH_URL, body = body)).awaitSuccess().parseAs<BGMOAuth>()
}
}
suspend fun getUsername(): String =
withIOContext {
with(json) {
authClient
.newCall(GET("$API_URL/v0/me"))
.awaitSuccess()
.parseAs<BGMUser>()
.username
}
}
companion object {
private const val CLIENT_ID = "bgm376667faf473119bb"
private const val CLIENT_SECRET = "d74caf0b874ddd18e6c6e7fb86d77a06"
private const val API_URL = "https://api.bgm.tv"
private const val OAUTH_URL = "https://bgm.tv/oauth/access_token"
private const val LOGIN_URL = "https://bgm.tv/oauth/authorize"
private const val REDIRECT_URL = "https://suwayomi.org/tracker-oauth"
private const val APP_JSON = "application/json"
fun authUrl(): Uri =
LOGIN_URL
.toUri()
.buildUpon()
.appendQueryParameter("client_id", CLIENT_ID)
.appendQueryParameter("response_type", "code")
.appendQueryParameter("redirect_uri", REDIRECT_URL)
.build()
fun refreshTokenRequest(token: String) =
POST(
OAUTH_URL,
body =
FormBody
.Builder()
.add("grant_type", "refresh_token")
.add("client_id", CLIENT_ID)
.add("client_secret", CLIENT_SECRET)
.add("refresh_token", token)
.add("redirect_uri", REDIRECT_URL)
.build(),
)
}
}

View File

@@ -0,0 +1,62 @@
package suwayomi.tachidesk.manga.impl.track.tracker.bangumi
import kotlinx.serialization.json.Json
import okhttp3.Interceptor
import okhttp3.Response
import suwayomi.tachidesk.server.generated.BuildConfig
import uy.kohesive.injekt.injectLazy
class BangumiInterceptor(
private val bangumi: Bangumi,
) : Interceptor {
private val json: Json by injectLazy()
/**
* OAuth object used for authenticated requests.
*/
private var oauth: BGMOAuth? = bangumi.restoreToken()
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
var currAuth: BGMOAuth = oauth ?: throw Exception("Not authenticated with Bangumi")
if (currAuth.isExpired()) {
val response = chain.proceed(BangumiApi.refreshTokenRequest(currAuth.refreshToken!!))
if (response.isSuccessful) {
currAuth = json.decodeFromString<BGMOAuth>(response.body.string())
newAuth(currAuth)
} else {
response.close()
}
}
return originalRequest
.newBuilder()
.header(
"User-Agent",
"Suwayomi/Suwayomi-Server/v${BuildConfig.VERSION} (${BuildConfig.GITHUB})",
).apply {
addHeader("Authorization", "Bearer ${currAuth.accessToken}")
}.build()
.let(chain::proceed)
}
fun newAuth(oauth: BGMOAuth?) {
this.oauth =
if (oauth == null) {
null
} else {
BGMOAuth(
oauth.accessToken,
oauth.tokenType,
System.currentTimeMillis() / 1000,
oauth.expiresIn,
oauth.refreshToken,
this.oauth?.userId,
)
}
bangumi.saveToken(oauth)
}
}

View File

@@ -0,0 +1,115 @@
package suwayomi.tachidesk.manga.impl.track.tracker.bangumi
import kotlinx.serialization.EncodeDefault
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import suwayomi.tachidesk.manga.impl.track.tracker.TrackerManager
import suwayomi.tachidesk.manga.impl.track.tracker.model.TrackSearch
@Serializable
// Incomplete DTO with only our needed attributes
data class BGMUser(
val username: String,
)
@Serializable
data class BGMSearchResult(
val total: Int,
val limit: Int,
val offset: Int,
val data: List<BGMSubject> = emptyList(),
)
@Serializable
// Incomplete DTO with only our needed attributes
data class BGMSubject(
val id: Long,
@SerialName("name_cn")
val nameCn: String,
val name: String,
val summary: String?,
val date: String?, // YYYY-MM-DD
val images: BGMSubjectImages?,
val volumes: Long = 0,
val eps: Long = 0,
val rating: BGMSubjectRating?,
val platform: String?,
) {
fun toTrackSearch(trackId: Int): TrackSearch =
TrackSearch.create(TrackerManager.BANGUMI).apply {
media_id = this@BGMSubject.id
title = nameCn.ifBlank { name }
cover_url = images?.common.orEmpty()
summary =
if (nameCn.isNotBlank()) {
"作品原名:$name" + this@BGMSubject.summary?.let { "\n${it.trim()}" }.orEmpty()
} else {
this@BGMSubject.summary?.trim().orEmpty()
}
tracking_url = "https://bangumi.tv/subject/${this@BGMSubject.id}"
total_chapters = eps.toInt()
start_date = date ?: ""
}
}
@Serializable
// Incomplete DTO with only our needed attributes
data class BGMSubjectImages(
val common: String?,
)
@Serializable
// Incomplete DTO with only our needed attributes
data class BGMSubjectRating(
val score: Double?,
)
@Serializable
data class BGMOAuth(
@SerialName("access_token")
val accessToken: String,
@SerialName("token_type")
val tokenType: String,
@SerialName("created_at")
@EncodeDefault
val createdAt: Long = System.currentTimeMillis() / 1000,
@SerialName("expires_in")
val expiresIn: Long,
@SerialName("refresh_token")
val refreshToken: String?,
@SerialName("user_id")
val userId: Long?,
)
// Access token refresh before expired
fun BGMOAuth.isExpired() = (System.currentTimeMillis() / 1000) > (createdAt + expiresIn - 3600)
@Serializable
// Incomplete DTO with only our needed attributes
data class BGMCollectionResponse(
val rate: Int?,
val type: Int?,
@SerialName("ep_status")
val epStatus: Int? = 0,
@SerialName("vol_status")
val volStatus: Int? = 0,
val private: Boolean = false,
val subject: BGMSlimSubject? = null,
) {
fun getStatus(): Int =
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
// Incomplete DTO with only our needed attributes
data class BGMSlimSubject(
val volumes: Int?,
val eps: Int?,
)

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB