Convert Bangumi to kotlinx.serialization

This commit is contained in:
Jays2Kings 2022-04-25 23:09:57 -04:00
parent 07f5056b45
commit d2c1582a48
9 changed files with 226 additions and 170 deletions

View file

@ -0,0 +1,10 @@
package eu.kanade.tachiyomi.data.track.bangumi
import kotlinx.serialization.Serializable
@Serializable
data class Avatar(
val large: String? = "",
val medium: String? = "",
val small: String? = "",
)

View file

@ -3,12 +3,14 @@ package eu.kanade.tachiyomi.data.track.bangumi
import android.content.Context
import android.graphics.Color
import androidx.annotation.StringRes
import com.google.gson.Gson
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.data.track.updateNewTrackInfo
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import timber.log.Timber
import uy.kohesive.injekt.injectLazy
@ -17,9 +19,9 @@ class Bangumi(private val context: Context, id: Int) : TrackService(id) {
@StringRes
override fun nameRes() = R.string.bangumi
private val gson: Gson by injectLazy()
private val json: Json by injectLazy()
private val interceptor by lazy { BangumiInterceptor(this, gson) }
private val interceptor by lazy { BangumiInterceptor(this) }
private val api by lazy { BangumiApi(client, interceptor) }
@ -39,7 +41,7 @@ class Bangumi(private val context: Context, id: Int) : TrackService(id) {
override suspend fun add(track: Track): Track {
track.score = DEFAULT_SCORE.toFloat()
track.status = DEFAULT_STATUS
updateNewTrackInfo(track, PLANNING)
updateNewTrackInfo(track, PLAN_TO_READ)
api.addLibManga(track)
return update(track)
}
@ -78,22 +80,22 @@ class Bangumi(private val context: Context, id: Int) : TrackService(id) {
override fun getLogoColor() = Color.rgb(240, 145, 153)
override fun getStatusList(): List<Int> {
return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLANNING)
return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLAN_TO_READ)
}
override fun isCompletedStatus(index: Int) = getStatusList()[index] == COMPLETED
override fun completedStatus(): Int = COMPLETED
override fun readingStatus() = READING
override fun planningStatus() = PLANNING
override fun planningStatus() = PLAN_TO_READ
override fun getStatus(status: Int): String = with(context) {
when (status) {
READING -> getString(R.string.reading)
PLAN_TO_READ -> getString(R.string.plan_to_read)
COMPLETED -> getString(R.string.completed)
ON_HOLD -> getString(R.string.on_hold)
DROPPED -> getString(R.string.dropped)
PLANNING -> getString(R.string.plan_to_read)
else -> ""
}
}
@ -101,7 +103,7 @@ class Bangumi(private val context: Context, id: Int) : TrackService(id) {
override fun getGlobalStatus(status: Int): String = with(context) {
when (status) {
READING -> getString(R.string.reading)
PLANNING -> getString(R.string.plan_to_read)
PLAN_TO_READ -> getString(R.string.plan_to_read)
COMPLETED -> getString(R.string.completed)
ON_HOLD -> getString(R.string.on_hold)
DROPPED -> getString(R.string.dropped)
@ -109,7 +111,7 @@ class Bangumi(private val context: Context, id: Int) : TrackService(id) {
}
}
override suspend fun login(username: String, password: String): Boolean = login(password)
override suspend fun login(username: String, password: String) = login(password)
suspend fun login(code: String): Boolean {
try {
@ -125,13 +127,12 @@ class Bangumi(private val context: Context, id: Int) : TrackService(id) {
}
fun saveToken(oauth: OAuth?) {
val json = gson.toJson(oauth)
preferences.trackToken(this).set(json)
preferences.trackToken(this).set(json.encodeToString(oauth))
}
fun restoreToken(): OAuth? {
return try {
gson.fromJson(preferences.trackToken(this).get(), OAuth::class.java)
json.decodeFromString<OAuth>(preferences.trackToken(this).get())
} catch (e: Exception) {
null
}
@ -140,10 +141,11 @@ class Bangumi(private val context: Context, id: Int) : TrackService(id) {
override fun logout() {
super.logout()
preferences.trackToken(this).delete()
interceptor.newAuth(null)
}
companion object {
const val PLANNING = 1
const val PLAN_TO_READ = 1
const val COMPLETED = 2
const val READING = 3
const val ON_HOLD = 4

View file

@ -1,152 +1,180 @@
package eu.kanade.tachiyomi.data.track.bangumi
import android.net.Uri
import androidx.core.net.toUri
import com.github.salomonbrys.kotson.array
import com.github.salomonbrys.kotson.obj
import com.google.gson.Gson
import com.google.gson.JsonObject
import com.google.gson.JsonParser
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.await
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import eu.kanade.tachiyomi.network.parseAs
import eu.kanade.tachiyomi.util.system.withIOContext
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.int
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.CacheControl
import okhttp3.FormBody
import okhttp3.OkHttpClient
import okhttp3.Request
import uy.kohesive.injekt.injectLazy
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
class BangumiApi(private val client: OkHttpClient, interceptor: BangumiInterceptor) {
private val gson: Gson by injectLazy()
private val json: Json by injectLazy()
private val authClient = client.newBuilder().addInterceptor(interceptor).build()
suspend fun addLibManga(track: Track): Track {
val body = FormBody.Builder().add("rating", track.score.toInt().toString())
.add("status", track.toBangumiStatus()).build()
val request =
Request.Builder().url("$apiUrl/collection/${track.media_id}/update").post(body).build()
val response = authClient.newCall(request).await()
return track
return withIOContext {
val body = FormBody.Builder()
.add("rating", track.score.toInt().toString())
.add("status", track.toBangumiStatus())
.build()
authClient.newCall(POST("$apiUrl/collection/${track.media_id}/update", body = body))
.await()
track
}
}
suspend fun updateLibManga(track: Track): Track {
// chapter update
return withContext(Dispatchers.IO) {
val body =
FormBody.Builder().add("watched_eps", track.last_chapter_read.toInt().toString()).build()
val request =
Request.Builder().url("$apiUrl/subject/${track.media_id}/update/watched_eps")
.post(body).build()
return withIOContext {
// read status update
val sbody = FormBody.Builder().add("status", track.toBangumiStatus()).build()
val srequest =
Request.Builder().url("$apiUrl/collection/${track.media_id}/update").post(sbody)
.build()
authClient.newCall(srequest).execute()
authClient.newCall(request).execute()
val sbody = FormBody.Builder()
.add("rating", track.score.toInt().toString())
.add("status", track.toBangumiStatus())
.build()
authClient.newCall(POST("$apiUrl/collection/${track.media_id}/update", body = sbody))
.await()
// chapter update
val body = FormBody.Builder()
.add("watched_eps", track.last_chapter_read.toInt().toString())
.build()
authClient.newCall(
POST(
"$apiUrl/subject/${track.media_id}/update/watched_eps",
body = body,
),
).await()
track
}
}
suspend fun search(search: String): List<TrackSearch> {
return withContext(Dispatchers.IO) {
val url = "$apiUrl/search/subject/${URLEncoder.encode(search, Charsets.UTF_8.name())}"
.toUri().buildUpon().appendQueryParameter("max_results", "20").build()
val request = Request.Builder().url(url.toString()).get().build()
val netResponse = authClient.newCall(request).await()
var responseBody = netResponse.body?.string().orEmpty()
if (responseBody.isEmpty()) {
throw Exception("Null Response")
}
if (responseBody.contains("\"code\":404")) {
responseBody = "{\"results\":0,\"list\":[]}"
}
val response = JsonParser.parseString(responseBody).obj["list"]?.array
if (response != null) {
response.filter { it.obj["type"].asInt == 1 }?.map { jsonToSearch(it.obj) }
} else {
listOf()
}
return withIOContext {
val url = "$apiUrl/search/subject/${URLEncoder.encode(search, StandardCharsets.UTF_8.name())}"
.toUri()
.buildUpon()
.appendQueryParameter("max_results", "20")
.build()
authClient.newCall(GET(url.toString()))
.await()
.use {
var responseBody = it.body?.string().orEmpty()
if (responseBody.isEmpty()) {
throw Exception("Null Response")
}
if (responseBody.contains("\"code\":404")) {
responseBody = "{\"results\":0,\"list\":[]}"
}
val response = json.decodeFromString<JsonObject>(responseBody)["list"]?.jsonArray
response?.filter { it.jsonObject["type"]?.jsonPrimitive?.int == 1 }
?.map { jsonToSearch(it.jsonObject) }.orEmpty()
}
}
}
private fun jsonToSearch(obj: JsonObject): TrackSearch {
return TrackSearch.create(TrackManager.BANGUMI).apply {
media_id = obj["id"].asInt
title = obj["name_cn"].asString
cover_url = obj["images"].obj["common"].asString
summary = obj["name"].asString
tracking_url = obj["url"].asString
val coverUrl = if (obj["images"] is JsonObject) {
obj["images"]?.jsonObject?.get("common")?.jsonPrimitive?.contentOrNull ?: ""
} else {
// Sometimes JsonNull
""
}
}
private fun jsonToTrack(mangas: JsonObject): Track {
return Track.create(TrackManager.BANGUMI).apply {
title = mangas["name"].asString
media_id = mangas["id"].asInt
score =
if (mangas["rating"] != null) (if (mangas["rating"].isJsonObject) mangas["rating"].obj["score"].asFloat else 0f)
else 0f
status = Bangumi.DEFAULT_STATUS
tracking_url = mangas["url"].asString
val totalChapters = if (obj["eps_count"] != null) {
obj["eps_count"]!!.jsonPrimitive.int
} else {
0
}
return TrackSearch.create(TrackManager.BANGUMI).apply {
media_id = obj["id"]!!.jsonPrimitive.int
title = obj["name_cn"]!!.jsonPrimitive.content
cover_url = coverUrl
summary = obj["name"]!!.jsonPrimitive.content
tracking_url = obj["url"]!!.jsonPrimitive.content
total_chapters = totalChapters
}
}
suspend fun findLibManga(track: Track): Track? {
return withContext(Dispatchers.IO) {
val urlMangas = "$apiUrl/subject/${track.media_id}"
val requestMangas = Request.Builder().url(urlMangas).get().build()
val netResponse = authClient.newCall(requestMangas).execute()
val responseBody = netResponse.body?.string().orEmpty()
jsonToTrack(JsonParser.parseString(responseBody).obj)
return withIOContext {
authClient.newCall(GET("$apiUrl/subject/${track.media_id}"))
.await()
.parseAs<JsonObject>()
.let { jsonToSearch(it) }
}
}
suspend fun statusLibManga(track: Track): Track? {
val urlUserRead = "$apiUrl/collection/${track.media_id}"
val requestUserRead =
Request.Builder().url(urlUserRead).cacheControl(CacheControl.FORCE_NETWORK).get()
return withIOContext {
val urlUserRead = "$apiUrl/collection/${track.media_id}"
val requestUserRead = Request.Builder()
.url(urlUserRead)
.cacheControl(CacheControl.FORCE_NETWORK)
.get()
.build()
// todo get user readed chapter here
val response = authClient.newCall(requestUserRead).await()
val resp = response.body?.toString()
val coll = gson.fromJson(resp, Collection::class.java)
track.status = coll.status?.id!!
track.last_chapter_read = coll.ep_status!!.toFloat()
return track
}
suspend fun accessToken(code: String): OAuth {
return withContext(Dispatchers.IO) {
val netResponse = client.newCall(accessTokenRequest(code)).execute()
val responseBody = netResponse.body?.string().orEmpty()
// TODO: get user readed chapter here
var response = authClient.newCall(requestUserRead).await()
var responseBody = response.body?.string().orEmpty()
if (responseBody.isEmpty()) {
throw Exception("Null Response")
}
gson.fromJson(responseBody, OAuth::class.java)
if (responseBody.contains("\"code\":400")) {
null
} else {
json.decodeFromString<Collection>(responseBody).let {
track.status = it.status?.id!!
track.last_chapter_read = it.ep_status!!.toFloat()
track.score = it.rating!!
track
}
}
}
}
suspend fun accessToken(code: String): OAuth {
return withIOContext {
client.newCall(accessTokenRequest(code))
.await()
.parseAs()
}
}
private fun accessTokenRequest(code: String) = POST(
oauthUrl,
body = FormBody.Builder().add("grant_type", "authorization_code").add("client_id", clientId)
.add("client_secret", clientSecret).add("code", code).add("redirect_uri", redirectUrl)
.build()
body = FormBody.Builder()
.add("grant_type", "authorization_code")
.add("client_id", clientId)
.add("client_secret", clientSecret)
.add("code", code)
.add("redirect_uri", redirectUrl)
.build(),
)
companion object {
private const val clientId = "bgm10555cda0762e80ca"
private const val clientSecret = "8fff394a8627b4c388cbf349ec865775"
private const val baseUrl = "https://bangumi.org"
private const val apiUrl = "https://api.bgm.tv"
private const val oauthUrl = "https://bgm.tv/oauth/access_token"
private const val loginUrl = "https://bgm.tv/oauth/authorize"
@ -158,15 +186,22 @@ class BangumiApi(private val client: OkHttpClient, interceptor: BangumiIntercept
return "$baseMangaUrl/$remoteId"
}
fun authUrl() = loginUrl.toUri().buildUpon().appendQueryParameter("client_id", clientId)
.appendQueryParameter("response_type", "code")
.appendQueryParameter("redirect_uri", redirectUrl).build()
fun authUrl(): Uri =
loginUrl.toUri().buildUpon()
.appendQueryParameter("client_id", clientId)
.appendQueryParameter("response_type", "code")
.appendQueryParameter("redirect_uri", redirectUrl)
.build()
fun refreshTokenRequest(token: String) = POST(
oauthUrl,
body = FormBody.Builder().add("grant_type", "refresh_token").add("client_id", clientId)
.add("client_secret", clientSecret).add("refresh_token", token)
.add("redirect_uri", redirectUrl).build()
body = FormBody.Builder()
.add("grant_type", "refresh_token")
.add("client_id", clientId)
.add("client_secret", clientSecret)
.add("refresh_token", token)
.add("redirect_uri", redirectUrl)
.build(),
)
}
}

View file

@ -1,26 +1,21 @@
package eu.kanade.tachiyomi.data.track.bangumi
import com.google.gson.Gson
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import okhttp3.FormBody
import okhttp3.Interceptor
import okhttp3.Response
import uy.kohesive.injekt.injectLazy
class BangumiInterceptor(val bangumi: Bangumi, val gson: Gson) : Interceptor {
class BangumiInterceptor(val bangumi: Bangumi) : Interceptor {
private val json: Json by injectLazy()
/**
* OAuth object used for authenticated requests.
*/
private var oauth: OAuth? = bangumi.restoreToken()
fun addTocken(tocken: String, oidFormBody: FormBody): FormBody {
val newFormBody = FormBody.Builder()
for (i in 0 until oidFormBody.size) {
newFormBody.add(oidFormBody.name(i), oidFormBody.value(i))
}
newFormBody.add("access_token", tocken)
return newFormBody.build()
}
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
@ -29,7 +24,7 @@ class BangumiInterceptor(val bangumi: Bangumi, val gson: Gson) : Interceptor {
if (currAuth.isExpired()) {
val response = chain.proceed(BangumiApi.refreshTokenRequest(currAuth.refresh_token!!))
if (response.isSuccessful) {
newAuth(gson.fromJson(response.body!!.string(), OAuth::class.java))
newAuth(json.decodeFromString<OAuth>(response.body!!.string()))
} else {
response.close()
}
@ -39,30 +34,35 @@ class BangumiInterceptor(val bangumi: Bangumi, val gson: Gson) : Interceptor {
.header("User-Agent", "Tachiyomi")
.url(
originalRequest.url.newBuilder()
.addQueryParameter("access_token", currAuth.access_token).build()
.addQueryParameter("access_token", currAuth.access_token).build(),
)
.build() else originalRequest.newBuilder()
.post(addTocken(currAuth.access_token, originalRequest.body as FormBody))
.post(addToken(currAuth.access_token, originalRequest.body as FormBody))
.header("User-Agent", "Tachiyomi")
.build()
return chain.proceed(authRequest)
}
fun newAuth(oauth: OAuth) {
this.oauth = OAuth(
fun newAuth(oauth: OAuth?) {
this.oauth = if (oauth == null) null else OAuth(
oauth.access_token,
oauth.token_type,
System.currentTimeMillis() / 1000,
oauth.expires_in,
oauth.refresh_token,
this.oauth?.user_id
this.oauth?.user_id,
)
bangumi.saveToken(oauth)
}
fun clearOauth() {
bangumi.saveToken(null)
private fun addToken(token: String, oidFormBody: FormBody): FormBody {
val newFormBody = FormBody.Builder()
for (i in 0 until oidFormBody.size) {
newFormBody.add(oidFormBody.name(i), oidFormBody.value(i))
}
newFormBody.add("access_token", token)
return newFormBody.build()
}
}

View file

@ -7,8 +7,8 @@ fun Track.toBangumiStatus() = when (status) {
Bangumi.COMPLETED -> "collect"
Bangumi.ON_HOLD -> "on_hold"
Bangumi.DROPPED -> "dropped"
Bangumi.PLANNING -> "wish"
else -> throw NotImplementedError("Unknown status")
Bangumi.PLAN_TO_READ -> "wish"
else -> throw NotImplementedError("Unknown status: $status")
}
fun toTrackStatus(status: String) = when (status) {
@ -16,7 +16,6 @@ fun toTrackStatus(status: String) = when (status) {
"collect" -> Bangumi.COMPLETED
"on_hold" -> Bangumi.ON_HOLD
"dropped" -> Bangumi.DROPPED
"wish" -> Bangumi.PLANNING
else -> throw Exception("Unknown status")
"wish" -> Bangumi.PLAN_TO_READ
else -> throw NotImplementedError("Unknown status: $status")
}

View file

@ -1,47 +1,16 @@
package eu.kanade.tachiyomi.data.track.bangumi
import kotlinx.serialization.Serializable
@Serializable
data class Collection(
val `private`: Int? = 0,
val comment: String? = "",
val ep_status: Int? = 0,
val lasttouch: Int? = 0,
val rating: Int? = 0,
val rating: Float? = 0f,
val status: Status? = Status(),
val tag: List<String?>? = listOf(),
val user: User? = User(),
val vol_status: Int? = 0
)
data class OAuth(
val access_token: String,
val token_type: String,
val created_at: Long,
val expires_in: Long,
val refresh_token: String?,
val user_id: Long?
) {
// Access token refresh before expired
fun isExpired() = (System.currentTimeMillis() / 1000) > (created_at + expires_in - 3600)
}
data class Status(
val id: Int? = 0,
val name: String? = "",
val type: String? = ""
)
data class User(
val avatar: Avatar? = Avatar(),
val id: Int? = 0,
val nickname: String? = "",
val sign: String? = "",
val url: String? = "",
val usergroup: Int? = 0,
val username: String? = ""
)
data class Avatar(
val large: String? = "",
val medium: String? = "",
val small: String? = ""
val vol_status: Int? = 0,
)

View file

@ -0,0 +1,17 @@
package eu.kanade.tachiyomi.data.track.bangumi
import kotlinx.serialization.Serializable
@Serializable
data class OAuth(
val access_token: String,
val token_type: String,
val created_at: Long = System.currentTimeMillis() / 1000,
val expires_in: Long,
val refresh_token: String?,
val user_id: Long?,
) {
// Access token refresh before expired
fun isExpired() = (System.currentTimeMillis() / 1000) > (created_at + expires_in - 3600)
}

View file

@ -0,0 +1,10 @@
package eu.kanade.tachiyomi.data.track.bangumi
import kotlinx.serialization.Serializable
@Serializable
data class Status(
val id: Int? = 0,
val name: String? = "",
val type: String? = "",
)

View file

@ -0,0 +1,14 @@
package eu.kanade.tachiyomi.data.track.bangumi
import kotlinx.serialization.Serializable
@Serializable
data class User(
val avatar: Avatar? = Avatar(),
val id: Int? = 0,
val nickname: String? = "",
val sign: String? = "",
val url: String? = "",
val usergroup: Int? = 0,
val username: String? = "",
)