diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/TrackerManager.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/TrackerManager.kt index 6ca4ebca..1091d6f5 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/TrackerManager.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/TrackerManager.kt @@ -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 = listOf(myAnimeList, aniList, kitsu, mangaUpdates) + val services: List = listOf(myAnimeList, aniList, kitsu, mangaUpdates, bangumi) fun getTracker(id: Int) = services.find { it.id == id } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/bangumi/Bangumi.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/bangumi/Bangumi.kt new file mode 100644 index 00000000..c68f6d28 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/bangumi/Bangumi.kt @@ -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 = 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 = 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 = 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(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") + } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/bangumi/BangumiApi.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/bangumi/BangumiApi.kt new file mode 100644 index 00000000..94a30b26 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/bangumi/BangumiApi.kt @@ -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 { + // 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() + .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() + .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() + } + } + + suspend fun getUsername(): String = + withIOContext { + with(json) { + authClient + .newCall(GET("$API_URL/v0/me")) + .awaitSuccess() + .parseAs() + .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(), + ) + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/bangumi/BangumiInterceptor.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/bangumi/BangumiInterceptor.kt new file mode 100644 index 00000000..cb80b252 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/bangumi/BangumiInterceptor.kt @@ -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(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) + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/bangumi/BangumiModels.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/bangumi/BangumiModels.kt new file mode 100644 index 00000000..6c10d7fc --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/bangumi/BangumiModels.kt @@ -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 = 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?, +) diff --git a/server/src/main/resources/static/tracker/bangumi.png b/server/src/main/resources/static/tracker/bangumi.png new file mode 100644 index 00000000..a91a545a Binary files /dev/null and b/server/src/main/resources/static/tracker/bangumi.png differ