mirror of
https://github.com/Suwayomi/Tachidesk.git
synced 2025-12-10 14:52:05 +01:00
[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:
@@ -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 }
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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?,
|
||||
)
|
||||
BIN
server/src/main/resources/static/tracker/bangumi.png
Normal file
BIN
server/src/main/resources/static/tracker/bangumi.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
Reference in New Issue
Block a user