Add support for start/end fields for Kitsu (#5573)

Also shifting kitsu api to use kotlinx.serialization.json
This commit is contained in:
Andreas 2021-07-18 18:47:40 +02:00 committed by Jays2Kings
parent db82cdedcb
commit ba22641ff1
6 changed files with 315 additions and 235 deletions

View File

@ -3,13 +3,15 @@ package eu.kanade.tachiyomi.data.track.kitsu
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.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
import java.text.DecimalFormat
@ -30,19 +32,17 @@ class Kitsu(private val context: Context, id: Int) : TrackService(id) {
@StringRes
override fun nameRes() = R.string.kitsu
private val gson: Gson by injectLazy()
override val supportsReadingDates: Boolean = true
private val interceptor by lazy { KitsuInterceptor(this, gson) }
private val json: Json by injectLazy()
private val interceptor by lazy { KitsuInterceptor(this) }
private val api by lazy { KitsuApi(client, interceptor) }
override fun getLogo(): Int {
return R.drawable.ic_tracker_kitsu
}
override fun getLogo() = R.drawable.ic_tracker_kitsu
override fun getLogoColor(): Int {
return Color.rgb(51, 37, 50)
}
override fun getLogoColor() = Color.rgb(51, 37, 50)
override fun getStatusList(): List<Int> {
return listOf(READING, PLAN_TO_READ, COMPLETED, ON_HOLD, DROPPED)
@ -135,15 +135,15 @@ class Kitsu(private val context: Context, id: Int) : TrackService(id) {
}
override suspend fun login(username: String, password: String): Boolean {
try {
return try {
val oauth = api.login(username, password)
interceptor.newAuth(oauth)
val userId = api.getCurrentUser()
saveCredentials(username, userId)
return true
true
} catch (e: Exception) {
Timber.e(e)
return false
false
}
}
@ -157,13 +157,12 @@ class Kitsu(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
}

View File

@ -1,230 +1,269 @@
package eu.kanade.tachiyomi.data.track.kitsu
import com.github.salomonbrys.kotson.array
import androidx.core.net.toUri
import com.github.salomonbrys.kotson.get
import com.github.salomonbrys.kotson.int
import com.github.salomonbrys.kotson.jsonObject
import com.github.salomonbrys.kotson.obj
import com.github.salomonbrys.kotson.string
import com.google.gson.GsonBuilder
import com.google.gson.JsonObject
import eu.kanade.tachiyomi.data.database.models.Track
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 eu.kanade.tachiyomi.network.jsonMime
import eu.kanade.tachiyomi.network.parseAs
import eu.kanade.tachiyomi.util.system.withIOContext
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
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 kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonObject
import okhttp3.FormBody
import okhttp3.Headers.Companion.headersOf
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.Field
import retrofit2.http.FormUrlEncoded
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.Headers
import retrofit2.http.PATCH
import retrofit2.http.POST
import retrofit2.http.Path
import retrofit2.http.Query
import timber.log.Timber
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor) {
private val authClient = client.newBuilder().addInterceptor(interceptor).build()
private val rest = Retrofit.Builder()
.baseUrl(baseUrl)
.client(authClient)
.addConverterFactory(GsonConverterFactory.create(GsonBuilder().serializeNulls().create()))
.build()
.create(KitsuApi.Rest::class.java)
private val searchRest = Retrofit.Builder()
.baseUrl(algoliaKeyUrl)
.client(authClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(KitsuApi.SearchKeyRest::class.java)
private val algoliaRest = Retrofit.Builder()
.baseUrl(algoliaUrl)
.client(client)
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(KitsuApi.AgoliaSearchRest::class.java)
suspend fun addLibManga(track: Track, userId: String): Track {
// @formatter:off
val data = jsonObject(
"type" to "libraryEntries",
"attributes" to jsonObject(
"status" to track.toKitsuStatus(),
"progress" to track.last_chapter_read
),
"relationships" to jsonObject(
"user" to jsonObject(
"data" to jsonObject(
"id" to userId,
"type" to "users"
)
),
"media" to jsonObject(
"data" to jsonObject(
"id" to track.media_id,
"type" to "manga"
)
return withIOContext {
val data = buildJsonObject {
putJsonObject("data") {
put("type", "libraryEntries")
putJsonObject("attributes") {
put("status", track.toKitsuStatus())
put("progress", track.last_chapter_read)
}
putJsonObject("relationships") {
putJsonObject("user") {
putJsonObject("data") {
put("id", userId)
put("type", "users")
}
}
putJsonObject("media") {
putJsonObject("data") {
put("id", track.media_id)
put("type", "manga")
}
}
}
}
}
authClient.newCall(
POST(
"${baseUrl}library-entries",
headers = headersOf(
"Content-Type",
"application/vnd.api+json"
),
body = data.toString().toRequestBody("application/vnd.api+json".toMediaType())
)
)
)
val json = rest.addLibManga(jsonObject("data" to data))
track.media_id = json["data"]["id"].int
return track
.await()
.parseAs<JsonObject>()
.let {
track.media_id = it["data"]!!.jsonObject["id"]!!.jsonPrimitive.int
track
}
}
}
suspend fun updateLibManga(track: Track): Track {
// @formatter:off
val data = jsonObject(
"type" to "libraryEntries",
"id" to track.media_id,
"attributes" to jsonObject(
"status" to track.toKitsuStatus(),
"progress" to track.last_chapter_read,
"ratingTwenty" to track.toKitsuScore()
)
)
// @formatter:on
return withIOContext {
val data = buildJsonObject {
putJsonObject("data") {
put("type", "libraryEntries")
put("id", track.media_id)
putJsonObject("attributes") {
put("status", track.toKitsuStatus())
put("progress", track.last_chapter_read)
put("ratingTwenty", track.toKitsuScore())
put("startedAt", KitsuDateHelper.convert(track.started_reading_date))
put("finishedAt", KitsuDateHelper.convert(track.finished_reading_date))
}
}
}
rest.updateLibManga(track.media_id, jsonObject("data" to data))
return track
authClient.newCall(
Request.Builder()
.url("${baseUrl}library-entries/${track.media_id}")
.headers(
headersOf(
"Content-Type",
"application/vnd.api+json"
)
)
.patch(data.toString().toRequestBody("application/vnd.api+json".toMediaType()))
.build()
)
.await()
.parseAs<JsonObject>()
.let {
val manga = it["data"]?.jsonObject
if (manga != null) {
val startedAt = manga["attributes"]!!.jsonObject["startedAt"]?.jsonPrimitive?.contentOrNull
val finishedAt = manga["attributes"]!!.jsonObject["finishedAt"]?.jsonPrimitive?.contentOrNull
val startedDate = KitsuDateHelper.parse(startedAt)
if (track.started_reading_date <= 0L || startedDate > 0) {
track.started_reading_date = startedDate
}
val finishedDate = KitsuDateHelper.parse(finishedAt)
if (track.finished_reading_date <= 0L || finishedDate > 0) {
track.finished_reading_date = finishedDate
}
}
track.apply {
}
}
}
}
suspend fun remove(track: Track): Boolean {
try {
rest.deleteLibManga(track.media_id)
return true
} catch (e: Exception) {
Timber.w(e)
return withIOContext {
val data = buildJsonObject {
putJsonObject("data") {
put("type", "libraryEntries")
put("id", track.media_id)
}
}
authClient.newCall(
Request.Builder()
.url("${baseUrl}library-entries/${track.media_id}")
.headers(
headersOf(
"Content-Type",
"application/vnd.api+json"
)
)
.delete(data.toString().toRequestBody("application/vnd.api+json".toMediaType()))
.build()
)
.await()
.let {
true
}
}
return false
}
suspend fun search(query: String): List<TrackSearch> {
val key = searchRest.getKey()["media"].asJsonObject["key"].string
return algoliaSearch(key, query)
return withIOContext {
authClient.newCall(GET(algoliaKeyUrl))
.await()
.parseAs<JsonObject>()
.let {
val key = it["media"]!!.jsonObject["key"]!!.jsonPrimitive.content
algoliaSearch(key, query)
}
}
}
private suspend fun algoliaSearch(key: String, query: String): List<TrackSearch> {
val jsonObject = jsonObject("params" to "query=$query$algoliaFilter")
val json = algoliaRest.getSearchQuery(algoliaAppId, key, jsonObject)
val data = json["hits"].array
return data.map { KitsuSearchManga(it.obj) }
.filter { it.subType != "novel" }
.map { it.toTrack() }
return withIOContext {
val jsonObject = buildJsonObject {
put("params", "query=$query$algoliaFilter")
}
client.newCall(
POST(
algoliaUrl,
headers = headersOf(
"X-Algolia-Application-Id",
algoliaAppId,
"X-Algolia-API-Key",
key,
),
body = jsonObject.toString().toRequestBody(jsonMime)
)
)
.await()
.parseAs<JsonObject>()
.let {
it["hits"]!!.jsonArray
.map { KitsuSearchManga(it.jsonObject) }
.filter { it.subType != "novel" }
.map { it.toTrack() }
}
}
}
suspend fun findLibManga(track: Track, userId: String): Track? {
val json = rest.findLibManga(track.media_id, userId)
val data = json["data"].array
return if (data.size() > 0) {
val manga = json["included"].array[0].obj
KitsuLibManga(data[0].obj, manga).toTrack()
} else {
null
return withIOContext {
val url = "${baseUrl}library-entries".toUri().buildUpon()
.encodedQuery("filter[manga_id]=${track.media_id}&filter[user_id]=$userId")
.appendQueryParameter("include", "manga")
.build()
authClient.newCall(GET(url.toString()))
.await()
.parseAs<JsonObject>()
.let {
val data = it["data"]!!.jsonArray
if (data.size > 0) {
val manga = it["included"]!!.jsonArray[0].jsonObject
KitsuLibManga(data[0].jsonObject, manga).toTrack()
} else {
null
}
}
}
}
suspend fun getLibManga(track: Track): Track {
val json = rest.getLibManga(track.media_id)
val data = json["data"].array
if (data.size() > 0) {
val manga = json["included"].array[0].obj
return KitsuLibManga(data[0].obj, manga).toTrack()
} else {
throw Exception("Could not find manga")
return withIOContext {
val url = "${baseUrl}library-entries".toUri().buildUpon()
.encodedQuery("filter[id]=${track.media_id}")
.appendQueryParameter("include", "manga")
.build()
authClient.newCall(GET(url.toString()))
.await()
.parseAs<JsonObject>()
.let {
val data = it["data"]!!.jsonArray
if (data.size > 0) {
val manga = it["included"]!!.jsonArray[0].jsonObject
KitsuLibManga(data[0].jsonObject, manga).toTrack()
} else {
throw Exception("Could not find manga")
}
}
}
}
suspend fun login(username: String, password: String): OAuth {
return Retrofit.Builder()
.baseUrl(loginUrl)
.client(client)
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(KitsuApi.LoginRest::class.java)
.requestAccessToken(username, password)
return withIOContext {
val formBody: RequestBody = FormBody.Builder()
.add("username", username)
.add("password", password)
.add("grant_type", "password")
.add("client_id", clientId)
.add("client_secret", clientSecret)
.build()
client.newCall(POST(loginUrl, body = formBody))
.await()
.parseAs()
}
}
suspend fun getCurrentUser(): String {
val currentUser = rest.getCurrentUser()
return currentUser["data"].array[0]["id"].string
}
private interface Rest {
@Headers("Content-Type: application/vnd.api+json")
@POST("library-entries")
suspend fun addLibManga(
@Body data: JsonObject
): JsonObject
@Headers("Content-Type: application/vnd.api+json")
@DELETE("library-entries/{id}")
suspend fun deleteLibManga(
@Path("id") remoteId: Int
): JsonObject
@Headers("Content-Type: application/vnd.api+json")
@PATCH("library-entries/{id}")
suspend fun updateLibManga(
@Path("id") remoteId: Int,
@Body data: JsonObject
): JsonObject
@GET("library-entries")
suspend fun findLibManga(
@Query("filter[manga_id]", encoded = true) remoteId: Int,
@Query("filter[user_id]", encoded = true) userId: String,
@Query("include") includes: String = "manga"
): JsonObject
@GET("library-entries")
suspend fun getLibManga(
@Query("filter[id]", encoded = true) remoteId: Int,
@Query("include") includes: String = "manga"
): JsonObject
@GET("users")
suspend fun getCurrentUser(
@Query("filter[self]", encoded = true) self: Boolean = true
): JsonObject
}
private interface SearchKeyRest {
@GET("media/")
suspend fun getKey(): JsonObject
}
private interface AgoliaSearchRest {
@POST("query/")
suspend fun getSearchQuery(
@Header("X-Algolia-Application-Id") appid: String,
@Header("X-Algolia-API-Key") key: String,
@Body json: JsonObject
): JsonObject
}
private interface LoginRest {
@FormUrlEncoded
@POST("oauth/token")
suspend fun requestAccessToken(
@Field("username") username: String,
@Field("password") password: String,
@Field("grant_type") grantType: String = "password",
@Field("client_id") client_id: String = clientId,
@Field("client_secret") client_secret: String = clientSecret
): OAuth
return withIOContext {
val url = "${baseUrl}users".toUri().buildUpon()
.encodedQuery("filter[self]=true")
.build()
authClient.newCall(GET(url.toString()))
.await()
.parseAs<JsonObject>()
.let {
it["data"]!!.jsonArray[0].jsonObject["id"]!!.jsonPrimitive.content
}
}
}
companion object {
@ -232,12 +271,14 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
"dd031b32d2f56c990b1425efe6c42ad847e7fe3ab46bf1299f05ecd856bdb7dd"
private const val clientSecret =
"54d7307928f63414defd96399fc31ba847961ceaecef3a5fd93144e960c0e151"
private const val baseUrl = "https://kitsu.io/api/edge/"
private const val loginUrl = "https://kitsu.io/api/"
private const val loginUrl = "https://kitsu.io/api/oauth/token"
private const val baseMangaUrl = "https://kitsu.io/manga/"
private const val algoliaKeyUrl = "https://kitsu.io/api/edge/algolia-keys/"
private const val algoliaKeyUrl = "https://kitsu.io/api/edge/algolia-keys/media/"
private const val algoliaUrl =
"https://AWQO5J657S-dsn.algolia.net/1/indexes/production_media/"
"https://AWQO5J657S-dsn.algolia.net/1/indexes/production_media/query/"
private const val algoliaAppId = "AWQO5J657S"
private const val algoliaFilter =
"&facetFilters=%5B%22kind%3Amanga%22%5D&attributesToRetrieve=%5B%22synopsis%22%2C%22canonicalTitle%22%2C%22chapterCount%22%2C%22posterImage%22%2C%22startDate%22%2C%22subtype%22%2C%22endDate%22%2C%20%22id%22%5D"
@ -247,12 +288,12 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
}
fun refreshTokenRequest(token: String) = POST(
"${loginUrl}oauth/token",
loginUrl,
body = FormBody.Builder()
.add("grant_type", "refresh_token")
.add("refresh_token", token)
.add("client_id", clientId)
.add("client_secret", clientSecret)
.add("refresh_token", token)
.build()
)
}

View File

@ -0,0 +1,25 @@
package eu.kanade.tachiyomi.data.track.kitsu
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
object KitsuDateHelper {
private const val pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"
private val formatter = SimpleDateFormat(pattern, Locale.ENGLISH)
fun convert(dateValue: Long): String? {
if (dateValue <= 0L) return null
return formatter.format(Date(dateValue))
}
fun parse(dateString: String?): Long {
if (dateString == null) return 0L
val dateValue = formatter.parse(dateString)
return dateValue?.time ?: return 0
}
}

View File

@ -1,10 +1,14 @@
package eu.kanade.tachiyomi.data.track.kitsu
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 KitsuInterceptor(val kitsu: Kitsu, val gson: Gson) : Interceptor {
class KitsuInterceptor(val kitsu: Kitsu) : Interceptor {
private val json: Json by injectLazy()
/**
* OAuth object used for authenticated requests.
@ -22,7 +26,7 @@ class KitsuInterceptor(val kitsu: Kitsu, val gson: Gson) : Interceptor {
if (currAuth.isExpired()) {
val response = chain.proceed(KitsuApi.refreshTokenRequest(refreshToken))
if (response.isSuccessful) {
newAuth(gson.fromJson(response.body!!.string(), OAuth::class.java))
newAuth(json.decodeFromString(response.body!!.string()))
} else {
response.close()
}

View File

@ -1,32 +1,36 @@
package eu.kanade.tachiyomi.data.track.kitsu
import androidx.annotation.CallSuper
import com.github.salomonbrys.kotson.byInt
import com.github.salomonbrys.kotson.byString
import com.github.salomonbrys.kotson.nullInt
import com.github.salomonbrys.kotson.nullObj
import com.github.salomonbrys.kotson.nullString
import com.github.salomonbrys.kotson.obj
import com.google.gson.JsonObject
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.int
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
class KitsuSearchManga(obj: JsonObject) {
val id by obj.byInt
private val canonicalTitle by obj.byString
private val chapterCount = obj.get("chapterCount").nullInt
val subType = obj.get("subtype").nullString
val original = obj.get("posterImage").nullObj?.get("original")?.asString
private val synopsis by obj.byString
private var startDate = obj.get("startDate").nullString?.let {
val id = obj["id"]!!.jsonPrimitive.int
private val canonicalTitle = obj["canonicalTitle"]!!.jsonPrimitive.content
private val chapterCount = obj["chapterCount"]?.jsonPrimitive?.intOrNull
val subType = obj["subtype"]?.jsonPrimitive?.contentOrNull
val original = try {
obj["posterImage"]?.jsonObject?.get("original")?.jsonPrimitive?.content
} catch (e: IllegalArgumentException) {
// posterImage is sometimes a jsonNull object instead
null
}
private val synopsis = obj["synopsis"]!!.jsonPrimitive.content
private var startDate = obj["startDate"]?.jsonPrimitive?.contentOrNull?.let {
val outputDf = SimpleDateFormat("yyyy-MM-dd", Locale.US)
outputDf.format(Date(it.toLong() * 1000))
}
private val endDate = obj.get("endDate").nullString
private val endDate = obj["endDate"]?.jsonPrimitive?.contentOrNull
@CallSuper
fun toTrack() = TrackSearch.create(TrackManager.KITSU).apply {
@ -36,10 +40,10 @@ class KitsuSearchManga(obj: JsonObject) {
cover_url = original ?: ""
summary = synopsis
tracking_url = KitsuApi.mangaUrl(media_id)
if (endDate == null) {
publishing_status = "Publishing"
publishing_status = if (endDate == null) {
"Publishing"
} else {
publishing_status = "Finished"
"Finished"
}
publishing_type = subType ?: ""
start_date = startDate ?: ""
@ -47,17 +51,19 @@ class KitsuSearchManga(obj: JsonObject) {
}
class KitsuLibManga(obj: JsonObject, manga: JsonObject) {
val id by manga.byInt
private val canonicalTitle by manga["attributes"].byString
private val chapterCount = manga["attributes"].obj.get("chapterCount").nullInt
val type = manga["attributes"].obj.get("mangaType").nullString.orEmpty()
val original by manga["attributes"].obj["posterImage"].byString
private val synopsis by manga["attributes"].byString
private val startDate = manga["attributes"].obj.get("startDate").nullString.orEmpty()
private val libraryId by obj.byInt("id")
val status by obj["attributes"].byString
private val ratingTwenty = obj["attributes"].obj.get("ratingTwenty").nullString
val progress by obj["attributes"].byInt
val id = manga["id"]!!.jsonPrimitive.int
private val canonicalTitle = manga["attributes"]!!.jsonObject["canonicalTitle"]!!.jsonPrimitive.content
private val chapterCount = manga["attributes"]!!.jsonObject["chapterCount"]?.jsonPrimitive?.intOrNull
val type = manga["attributes"]!!.jsonObject["mangaType"]?.jsonPrimitive?.contentOrNull.orEmpty()
val original = manga["attributes"]!!.jsonObject["posterImage"]!!.jsonObject["original"]!!.jsonPrimitive.content
private val synopsis = manga["attributes"]!!.jsonObject["synopsis"]!!.jsonPrimitive.content
private val startDate = manga["attributes"]!!.jsonObject["startDate"]?.jsonPrimitive?.contentOrNull.orEmpty()
private val startedAt = obj["attributes"]!!.jsonObject["startedAt"]?.jsonPrimitive?.contentOrNull
private val finishedAt = obj["attributes"]!!.jsonObject["finishedAt"]?.jsonPrimitive?.contentOrNull
private val libraryId = obj["id"]!!.jsonPrimitive.int
val status = obj["attributes"]!!.jsonObject["status"]!!.jsonPrimitive.content
private val ratingTwenty = obj["attributes"]!!.jsonObject["ratingTwenty"]?.jsonPrimitive?.contentOrNull
val progress = obj["attributes"]!!.jsonObject["progress"]!!.jsonPrimitive.int
fun toTrack() = TrackSearch.create(TrackManager.KITSU).apply {
media_id = libraryId
@ -69,6 +75,8 @@ class KitsuLibManga(obj: JsonObject, manga: JsonObject) {
publishing_status = this@KitsuLibManga.status
publishing_type = type
start_date = startDate
started_reading_date = KitsuDateHelper.parse(startedAt)
finished_reading_date = KitsuDateHelper.parse(finishedAt)
status = toTrackStatus()
score = ratingTwenty?.let { it.toInt() / 2f } ?: 0f
last_chapter_read = progress

View File

@ -1,5 +1,8 @@
package eu.kanade.tachiyomi.data.track.kitsu
import kotlinx.serialization.Serializable
@Serializable
data class OAuth(
val access_token: String,
val token_type: String,