Convert Shikimori to kotlinx.serialization

with this, it is over. now for the final cleanup
This commit is contained in:
Jays2Kings 2022-04-25 23:47:27 -04:00
parent d2c1582a48
commit 33135f766d
6 changed files with 191 additions and 177 deletions

View file

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

View file

@ -3,23 +3,36 @@ package eu.kanade.tachiyomi.data.track.shikimori
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.myanimelist.MyAnimeList
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
class Shikimori(private val context: Context, id: Int) : TrackService(id) {
companion object {
const val READING = 1
const val COMPLETED = 2
const val ON_HOLD = 3
const val DROPPED = 4
const val PLAN_TO_READ = 5
const val REREADING = 6
const val DEFAULT_STATUS = READING
const val DEFAULT_SCORE = 0
}
@StringRes
override fun nameRes() = R.string.shikimori
private val gson: Gson by injectLazy()
private val json: Json by injectLazy()
private val interceptor by lazy { ShikimoriInterceptor(this, gson) }
private val interceptor by lazy { ShikimoriInterceptor(this) }
private val api by lazy { ShikimoriApi(client, interceptor) }
@ -28,14 +41,14 @@ class Shikimori(private val context: Context, id: Int) : TrackService(id) {
override fun getLogoColor() = Color.rgb(40, 40, 40)
override fun getStatusList(): List<Int> {
return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLANNING, REPEATING)
return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLAN_TO_READ, REREADING)
}
override fun isCompletedStatus(index: Int) = getStatusList()[index] == COMPLETED
override fun completedStatus(): Int = MyAnimeList.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) {
@ -43,23 +56,13 @@ class Shikimori(private val context: Context, id: Int) : TrackService(id) {
COMPLETED -> getString(R.string.completed)
ON_HOLD -> getString(R.string.on_hold)
DROPPED -> getString(R.string.dropped)
PLANNING -> getString(R.string.plan_to_read)
REPEATING -> getString(R.string.rereading)
PLAN_TO_READ -> getString(R.string.plan_to_read)
REREADING -> getString(R.string.rereading)
else -> ""
}
}
override fun getGlobalStatus(status: Int): String = with(context) {
when (status) {
READING -> getString(R.string.reading)
PLANNING -> getString(R.string.plan_to_read)
COMPLETED -> getString(R.string.completed)
ON_HOLD -> getString(R.string.on_hold)
DROPPED -> getString(R.string.dropped)
REPEATING -> getString(R.string.rereading)
else -> ""
}
}
override fun getGlobalStatus(status: Int): String = getStatus(status)
override fun getScoreList(): List<String> {
return IntRange(0, 10).map(Int::toString)
@ -77,12 +80,11 @@ class Shikimori(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)
return api.addLibManga(track, getUsername())
}
override suspend fun bind(track: Track): Track {
val remoteTrack = api.findLibManga(track, getUsername())
return if (remoteTrack != null) {
track.copyPersonalFrom(remoteTrack)
track.library_id = remoteTrack.library_id
@ -94,9 +96,7 @@ class Shikimori(private val context: Context, id: Int) : TrackService(id) {
override fun canRemoveFromService(): Boolean = true
override suspend fun removeFromService(track: Track): Boolean {
return api.remove(track, getUsername())
}
override suspend fun removeFromService(track: Track) = api.remove(track, getUsername())
override suspend fun search(query: String) = api.search(query)
@ -115,7 +115,6 @@ class Shikimori(private val context: Context, id: Int) : TrackService(id) {
suspend fun login(code: String): Boolean {
return try {
val oauth = api.accessToken(code)
interceptor.newAuth(oauth)
val user = api.getCurrentUser()
saveCredentials(user.toString(), oauth.access_token)
@ -128,13 +127,12 @@ class Shikimori(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
}
@ -145,16 +143,4 @@ class Shikimori(private val context: Context, id: Int) : TrackService(id) {
preferences.trackToken(this).delete()
interceptor.newAuth(null)
}
companion object {
const val READING = 1
const val COMPLETED = 2
const val ON_HOLD = 3
const val DROPPED = 4
const val PLANNING = 5
const val REPEATING = 6
const val DEFAULT_STATUS = READING
const val DEFAULT_SCORE = 0
}
}

View file

@ -1,115 +1,115 @@
package eu.kanade.tachiyomi.data.track.shikimori
import androidx.core.net.toUri
import com.github.salomonbrys.kotson.array
import com.github.salomonbrys.kotson.get
import com.github.salomonbrys.kotson.jsonObject
import com.github.salomonbrys.kotson.nullString
import com.github.salomonbrys.kotson.obj
import com.google.gson.Gson
import com.google.gson.JsonArray
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.DELETE
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.network.jsonMime
import eu.kanade.tachiyomi.network.parseAs
import eu.kanade.tachiyomi.util.system.withIOContext
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.float
import kotlinx.serialization.json.int
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonObject
import okhttp3.FormBody
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import timber.log.Timber
import uy.kohesive.injekt.injectLazy
class ShikimoriApi(private val client: OkHttpClient, interceptor: ShikimoriInterceptor) {
private val gson: Gson by injectLazy()
private val jsonime = "application/json; charset=utf-8".toMediaTypeOrNull()
private val authClient = client.newBuilder().addInterceptor(interceptor).build()
suspend fun updateLibManga(track: Track, user_id: String): Track = addLibManga(track, user_id)
suspend fun addLibManga(track: Track, user_id: String): Track {
return withContext(Dispatchers.IO) {
val payload = jsonObject(
"user_rate" to jsonObject(
"user_id" to user_id,
"target_id" to track.media_id,
"target_type" to "Manga",
"chapters" to track.last_chapter_read.toInt(),
"score" to track.score.toInt(),
"status" to track.toShikimoriStatus()
)
)
val body = payload.toString().toRequestBody(jsonime)
val request = Request.Builder().url("$apiUrl/v2/user_rates").post(body).build()
authClient.newCall(request).execute()
return withIOContext {
val payload = buildJsonObject {
putJsonObject("user_rate") {
put("user_id", user_id)
put("target_id", track.media_id)
put("target_type", "Manga")
put("chapters", track.last_chapter_read.toInt())
put("score", track.score.toInt())
put("status", track.toShikimoriStatus())
}
}
authClient.newCall(
POST(
"$apiUrl/v2/user_rates",
body = payload.toString().toRequestBody(jsonMime),
),
).await()
track
}
}
suspend fun updateLibManga(track: Track, user_id: String): Track = addLibManga(track, user_id)
suspend fun search(search: String): List<TrackSearch> {
return withContext(Dispatchers.IO) {
val url =
"$apiUrl/mangas".toUri().buildUpon().appendQueryParameter("order", "popularity")
.appendQueryParameter("search", search).appendQueryParameter("limit", "20")
.build()
val request = Request.Builder().url(url.toString()).get().build()
val netResponse = authClient.newCall(request).execute()
val responseBody = netResponse.body?.string().orEmpty()
if (responseBody.isEmpty()) {
throw Exception("Null Response")
}
val response = JsonParser.parseString(responseBody).array
response.map { jsonToSearch(it.obj) }
return withIOContext {
val url = "$apiUrl/mangas".toUri().buildUpon()
.appendQueryParameter("order", "popularity")
.appendQueryParameter("search", search)
.appendQueryParameter("limit", "20")
.build()
authClient.newCall(GET(url.toString()))
.await()
.parseAs<JsonArray>()
.let { response ->
response.map {
jsonToSearch(it.jsonObject)
}
}
}
}
suspend fun remove(track: Track, user_id: String): Boolean {
return withContext(Dispatchers.IO) {
return withIOContext {
try {
val rates = getUserRates(track, user_id)
val id = rates.last()["id"]
val id = rates.last().jsonObject["id"]!!.jsonPrimitive.content
val url = "$apiUrl/v2/user_rates/$id"
val request = Request.Builder().url(url).delete().build()
authClient.newCall(request).execute()
return@withContext true
authClient.newCall(DELETE(url)).await()
true
} catch (e: Exception) {
Timber.w(e)
false
}
return@withContext false
}
}
private fun jsonToSearch(obj: JsonObject): TrackSearch {
return TrackSearch.create(TrackManager.SHIKIMORI).apply {
media_id = obj["id"].asInt
title = obj["name"].asString
total_chapters = obj["chapters"].asInt
cover_url = baseUrl + obj["image"].obj["preview"].asString
media_id = obj["id"]!!.jsonPrimitive.int
title = obj["name"]!!.jsonPrimitive.content
total_chapters = obj["chapters"]!!.jsonPrimitive.int
cover_url = baseUrl + obj["image"]!!.jsonObject["preview"]!!.jsonPrimitive.content
summary = ""
tracking_url = baseUrl + obj["url"].asString
publishing_status = obj["status"].asString
publishing_type = obj["kind"].asString
start_date = obj.get("aired_on").nullString.orEmpty()
tracking_url = baseUrl + obj["url"]!!.jsonPrimitive.content
publishing_status = obj["status"]!!.jsonPrimitive.content
publishing_type = obj["kind"]!!.jsonPrimitive.content
start_date = obj["aired_on"]?.jsonPrimitive?.contentOrNull ?: ""
}
}
private fun jsonToTrack(obj: JsonObject, mangas: JsonObject): Track {
return Track.create(TrackManager.SHIKIMORI).apply {
title = mangas["name"].asString
media_id = obj["id"].asInt
total_chapters = mangas["chapters"].asInt
last_chapter_read = obj["chapters"].asFloat
score = (obj["score"].asInt).toFloat()
status = toTrackStatus(obj["status"].asString)
tracking_url = baseUrl + mangas["url"].asString
title = mangas["name"]!!.jsonPrimitive.content
media_id = obj["id"]!!.jsonPrimitive.int
total_chapters = mangas["chapters"]!!.jsonPrimitive.int
last_chapter_read = obj["chapters"]!!.jsonPrimitive.float
score = (obj["score"]!!.jsonPrimitive.int).toFloat()
status = toTrackStatus(obj["status"]!!.jsonPrimitive.content)
tracking_url = baseUrl + mangas["url"]!!.jsonPrimitive.content
}
}
@ -117,70 +117,70 @@ class ShikimoriApi(private val client: OkHttpClient, interceptor: ShikimoriInter
val url = "$apiUrl/v2/user_rates".toUri().buildUpon()
.appendQueryParameter("user_id", user_id)
.appendQueryParameter("target_id", track.media_id.toString())
.appendQueryParameter("target_type", "Manga").build()
val request = Request.Builder().url(url.toString()).get().build()
val requestResponse = authClient.newCall(request).execute()
val requestResponseBody = requestResponse.body?.string().orEmpty()
if (requestResponseBody.isEmpty()) {
throw Exception("Null Response")
}
return JsonParser.parseString(requestResponseBody).array
.appendQueryParameter("target_type", "Manga")
.build()
return authClient.newCall(GET(url.toString()))
.execute()
.parseAs()
}
suspend fun findLibManga(track: Track, user_id: String): Track? {
return withContext(Dispatchers.IO) {
val urlMangas = "$apiUrl/mangas".toUri().buildUpon().appendPath(track.media_id.toString())
return withIOContext {
val urlMangas = "$apiUrl/mangas".toUri().buildUpon()
.appendPath(track.media_id.toString())
.build()
val requestMangas = Request.Builder().url(urlMangas.toString()).get().build()
val requestMangasResponse = authClient.newCall(requestMangas).execute()
val requestMangasBody = requestMangasResponse.body?.string().orEmpty()
val mangas = JsonParser.parseString(requestMangasBody).obj
val mangas = authClient.newCall(GET(urlMangas.toString()))
.await()
.parseAs<JsonObject>()
val entry = getUserRates(track, user_id)
return@withContext entry.map {
jsonToTrack(it.obj, mangas)
if (entry.size > 1) {
throw Exception("Too much mangas in response")
}
entry.map {
jsonToTrack(it.jsonObject, mangas)
}.firstOrNull()
}
}
suspend fun getCurrentUser(): Int {
return withContext(Dispatchers.IO) {
val user = authClient.newCall(GET("$apiUrl/users/whoami")).execute().body?.string()
JsonParser.parseString(user).obj["id"].asInt
return withIOContext {
authClient.newCall(GET("$apiUrl/users/whoami"))
.await()
.parseAs<JsonObject>()
.let {
it["id"]!!.jsonPrimitive.int
}
}
}
suspend fun accessToken(code: String): OAuth {
return withContext(Dispatchers.IO) {
val netResponse = client.newCall(accessTokenRequest(code)).execute()
val responseBody = netResponse.body?.string().orEmpty()
if (responseBody.isEmpty()) {
throw Exception("Null Response")
}
gson.fromJson(responseBody, OAuth::class.java)
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 =
"1aaf4cf232372708e98b5abc813d795b539c5a916dbbfe9ac61bf02a360832cc"
private const val clientSecret =
"229942c742dd4cde803125d17d64501d91c0b12e14cb1e5120184d77d67024c0"
private const val clientId = "1aaf4cf232372708e98b5abc813d795b539c5a916dbbfe9ac61bf02a360832cc"
private const val clientSecret = "229942c742dd4cde803125d17d64501d91c0b12e14cb1e5120184d77d67024c0"
private const val baseUrl = "https://shikimori.one"
private const val apiUrl = "https://shikimori.one/api"
private const val oauthUrl = "https://shikimori.one/oauth/token"
private const val loginUrl = "https://shikimori.one/oauth/authorize"
private const val apiUrl = "$baseUrl/api"
private const val oauthUrl = "$baseUrl/oauth/token"
private const val loginUrl = "$baseUrl/oauth/authorize"
private const val redirectUrl = "tachiyomi://shikimori-auth"
private const val baseMangaUrl = "$apiUrl/mangas"
@ -189,14 +189,21 @@ class ShikimoriApi(private val client: OkHttpClient, interceptor: ShikimoriInter
return "$baseMangaUrl/$remoteId"
}
fun authUrl() = loginUrl.toUri().buildUpon().appendQueryParameter("client_id", clientId)
.appendQueryParameter("redirect_uri", redirectUrl)
.appendQueryParameter("response_type", "code").build()
fun authUrl() =
loginUrl.toUri().buildUpon()
.appendQueryParameter("client_id", clientId)
.appendQueryParameter("redirect_uri", redirectUrl)
.appendQueryParameter("response_type", "code")
.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).build()
body = FormBody.Builder()
.add("grant_type", "refresh_token")
.add("client_id", clientId)
.add("client_secret", clientSecret)
.add("refresh_token", token)
.build(),
)
}
}

View file

@ -1,10 +1,14 @@
package eu.kanade.tachiyomi.data.track.shikimori
import com.google.gson.Gson
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import okhttp3.Interceptor
import okhttp3.Response
import uy.kohesive.injekt.injectLazy
class ShikimoriInterceptor(val shikimori: Shikimori, val gson: Gson) : Interceptor {
class ShikimoriInterceptor(val shikimori: Shikimori) : Interceptor {
private val json: Json by injectLazy()
/**
* OAuth object used for authenticated requests.
@ -22,7 +26,7 @@ class ShikimoriInterceptor(val shikimori: Shikimori, val gson: Gson) : Intercept
if (currAuth.isExpired()) {
val response = chain.proceed(ShikimoriApi.refreshTokenRequest(refreshToken))
if (response.isSuccessful) {
newAuth(gson.fromJson(response.body!!.string(), OAuth::class.java))
newAuth(json.decodeFromString<OAuth>(response.body!!.string()))
} else {
response.close()
}

View file

@ -7,9 +7,9 @@ fun Track.toShikimoriStatus() = when (status) {
Shikimori.COMPLETED -> "completed"
Shikimori.ON_HOLD -> "on_hold"
Shikimori.DROPPED -> "dropped"
Shikimori.PLANNING -> "planned"
Shikimori.REPEATING -> "rewatching"
else -> throw NotImplementedError("Unknown status")
Shikimori.PLAN_TO_READ -> "planned"
Shikimori.REREADING -> "rewatching"
else -> throw NotImplementedError("Unknown status: $status")
}
fun toTrackStatus(status: String) = when (status) {
@ -17,20 +17,7 @@ fun toTrackStatus(status: String) = when (status) {
"completed" -> Shikimori.COMPLETED
"on_hold" -> Shikimori.ON_HOLD
"dropped" -> Shikimori.DROPPED
"planned" -> Shikimori.PLANNING
"rewatching" -> Shikimori.REPEATING
else -> throw Exception("Unknown status")
}
data class OAuth(
val access_token: String,
val token_type: String,
val created_at: Long,
val expires_in: Long,
val refresh_token: String?
) {
// Access token lives 1 day
fun isExpired() = (System.currentTimeMillis() / 1000) > (created_at + expires_in - 3600)
"planned" -> Shikimori.PLAN_TO_READ
"rewatching" -> Shikimori.REREADING
else -> throw NotImplementedError("Unknown status: $status")
}

View file

@ -14,7 +14,7 @@ private val DEFAULT_BODY: RequestBody = FormBody.Builder().build()
fun GET(
url: String,
headers: Headers = DEFAULT_HEADERS,
cache: CacheControl = DEFAULT_CACHE_CONTROL
cache: CacheControl = DEFAULT_CACHE_CONTROL,
): Request {
return Request.Builder()
.url(url)
@ -27,7 +27,7 @@ fun POST(
url: String,
headers: Headers = DEFAULT_HEADERS,
body: RequestBody = DEFAULT_BODY,
cache: CacheControl = DEFAULT_CACHE_CONTROL
cache: CacheControl = DEFAULT_CACHE_CONTROL,
): Request {
return Request.Builder()
.url(url)
@ -36,3 +36,17 @@ fun POST(
.cacheControl(cache)
.build()
}
fun DELETE(
url: String,
headers: Headers = DEFAULT_HEADERS,
body: RequestBody = DEFAULT_BODY,
cache: CacheControl = DEFAULT_CACHE_CONTROL,
): Request {
return Request.Builder()
.url(url)
.delete(body)
.headers(headers)
.cacheControl(cache)
.build()
}