diff --git a/CHANGELOG.md b/CHANGELOG.md index 49f10e4a8..b3e1df449 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/Bangumi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/Bangumi.kt index 8eb3ec776..083e03eab 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/Bangumi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/Bangumi.kt @@ -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(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) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiApi.kt index bba70d44a..4b102dd1a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiApi.kt @@ -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 { + // 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() - .let { result -> - if (result.code == 404) emptyList() - - 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() - .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() - .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() + .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() } } } - 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() + .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) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiInterceptor.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiInterceptor.kt index f9bad62e9..7829405fd 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiInterceptor.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiInterceptor.kt @@ -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.. "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") } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/dto/BGMCollectionResponse.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/dto/BGMCollectionResponse.kt index 85501934f..c98077850 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/dto/BGMCollectionResponse.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/dto/BGMCollectionResponse.kt @@ -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? = 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?, ) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/dto/BGMSearch.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/dto/BGMSearch.kt index 9c3151f49..72b17ea71 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/dto/BGMSearch.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/dto/BGMSearch.kt @@ -6,45 +6,50 @@ import kotlinx.serialization.Serializable @Serializable data class BGMSearchResult( - val list: List?, - val code: Int?, + val total: Int, + val limit: Int, + val offset: Int, + val data: List = 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?, ) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/dto/BGMUser.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/dto/BGMUser.kt index 375c39eb6..70bb96ee9 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/dto/BGMUser.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/dto/BGMUser.kt @@ -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, )