Refactor Kitsu API to remove Retrofit usage

This commit is contained in:
arkon 2021-01-04 11:29:24 -05:00
parent 07e76f35fa
commit 17b70ab38c
5 changed files with 187 additions and 180 deletions

View file

@ -170,11 +170,6 @@ dependencies {
// TLS 1.3 support for Android < 10 // TLS 1.3 support for Android < 10
implementation("org.conscrypt:conscrypt-android:2.5.1") implementation("org.conscrypt:conscrypt-android:2.5.1")
// REST
val retrofitVersion = "2.9.0"
implementation("com.squareup.retrofit2:retrofit:$retrofitVersion")
implementation("com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:0.8.0")
// JSON // JSON
val kotlinSerializationVersion = "1.0.1" val kotlinSerializationVersion = "1.0.1"
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinSerializationVersion") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinSerializationVersion")

View file

@ -6,10 +6,10 @@ import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.model.TrackSearch import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.await import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.network.jsonMime
import eu.kanade.tachiyomi.network.parseAs import eu.kanade.tachiyomi.network.parseAs
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.contentOrNull
@ -21,17 +21,12 @@ import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.long import kotlinx.serialization.json.long
import kotlinx.serialization.json.put import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonObject import kotlinx.serialization.json.putJsonObject
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import uy.kohesive.injekt.injectLazy
import java.util.Calendar import java.util.Calendar
class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) { class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
private val json: Json by injectLazy()
private val jsonMime = "application/json; charset=utf-8".toMediaType()
private val authClient = client.newBuilder().addInterceptor(interceptor).build() private val authClient = client.newBuilder().addInterceptor(interceptor).build()
suspend fun addLibManga(track: Track): Track { suspend fun addLibManga(track: Track): Track {

View file

@ -1,10 +1,15 @@
package eu.kanade.tachiyomi.data.track.kitsu package eu.kanade.tachiyomi.data.track.kitsu
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory import androidx.core.net.toUri
import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.model.TrackSearch 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.POST
import kotlinx.serialization.json.Json import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.network.jsonMime
import eu.kanade.tachiyomi.network.parseAs
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.int import kotlinx.serialization.json.int
@ -14,226 +19,236 @@ import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonObject import kotlinx.serialization.json.putJsonObject
import okhttp3.FormBody import okhttp3.FormBody
import okhttp3.Headers.Companion.headersOf
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import retrofit2.Retrofit import okhttp3.Request
import retrofit2.http.Body import okhttp3.RequestBody
import retrofit2.http.Field import okhttp3.RequestBody.Companion.toRequestBody
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
class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor) { class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor) {
private val authClient = client.newBuilder().addInterceptor(interceptor).build() private val authClient = client.newBuilder().addInterceptor(interceptor).build()
private val rest = Retrofit.Builder()
.baseUrl(baseUrl)
.client(authClient)
.addConverterFactory(jsonConverter)
.build()
.create(Rest::class.java)
private val searchRest = Retrofit.Builder()
.baseUrl(algoliaKeyUrl)
.client(authClient)
.addConverterFactory(jsonConverter)
.build()
.create(SearchKeyRest::class.java)
private val algoliaRest = Retrofit.Builder()
.baseUrl(algoliaUrl)
.client(client)
.addConverterFactory(jsonConverter)
.build()
.create(AgoliaSearchRest::class.java)
suspend fun addLibManga(track: Track, userId: String): Track { suspend fun addLibManga(track: Track, userId: String): Track {
val data = buildJsonObject { return withContext(Dispatchers.IO) {
putJsonObject("data") { val data = buildJsonObject {
put("type", "libraryEntries") putJsonObject("data") {
putJsonObject("attributes") { put("type", "libraryEntries")
put("status", track.toKitsuStatus()) putJsonObject("attributes") {
put("progress", track.last_chapter_read) 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("relationships") {
putJsonObject("data") { putJsonObject("user") {
put("id", track.media_id) putJsonObject("data") {
put("type", "manga") put("id", userId)
put("type", "users")
}
}
putJsonObject("media") {
putJsonObject("data") {
put("id", track.media_id)
put("type", "manga")
}
} }
} }
} }
} }
}
val json = rest.addLibManga(data) authClient.newCall(
track.media_id = json["data"]!!.jsonObject["id"]!!.jsonPrimitive.int POST(
return track "${baseUrl}library-entries",
headers = headersOf(
"Content-Type",
"application/vnd.api+json"
),
body = data.toString().toRequestBody("application/vnd.api+json".toMediaType())
)
)
.await()
.parseAs<JsonObject>()
.let {
track.media_id = it["data"]!!.jsonObject["id"]!!.jsonPrimitive.int
track
}
}
} }
suspend fun updateLibManga(track: Track): Track { suspend fun updateLibManga(track: Track): Track {
val data = buildJsonObject { return withContext(Dispatchers.IO) {
putJsonObject("data") { val data = buildJsonObject {
put("type", "libraryEntries") putJsonObject("data") {
put("id", track.media_id) put("type", "libraryEntries")
putJsonObject("attributes") { put("id", track.media_id)
put("status", track.toKitsuStatus()) putJsonObject("attributes") {
put("progress", track.last_chapter_read) put("status", track.toKitsuStatus())
put("ratingTwenty", track.toKitsuScore()) put("progress", track.last_chapter_read)
put("ratingTwenty", track.toKitsuScore())
}
} }
} }
}
rest.updateLibManga(track.media_id, data) authClient.newCall(
return track 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 {
track
}
}
} }
suspend fun search(query: String): List<TrackSearch> { suspend fun search(query: String): List<TrackSearch> {
val json = searchRest.getKey() return withContext(Dispatchers.IO) {
val key = json["media"]!!.jsonObject["key"]!!.jsonPrimitive.content authClient.newCall(GET(algoliaKeyUrl))
return algoliaSearch(key, query) .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> { private suspend fun algoliaSearch(key: String, query: String): List<TrackSearch> {
val jsonObject = buildJsonObject { return withContext(Dispatchers.IO) {
put("params", "query=$query$algoliaFilter") 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() }
}
} }
val json = algoliaRest.getSearchQuery(algoliaAppId, key, jsonObject)
val data = json["hits"]!!.jsonArray
return data.map { KitsuSearchManga(it.jsonObject) }
.filter { it.subType != "novel" }
.map { it.toTrack() }
} }
suspend fun findLibManga(track: Track, userId: String): Track? { suspend fun findLibManga(track: Track, userId: String): Track? {
val json = rest.findLibManga(track.media_id, userId) return withContext(Dispatchers.IO) {
val data = json["data"]!!.jsonArray val url = "${baseUrl}library-entries".toUri().buildUpon()
return if (data.size > 0) { .encodedQuery("filter[manga_id]=${track.media_id}&filter[user_id]=$userId")
val manga = json["included"]!!.jsonArray[0].jsonObject .appendQueryParameter("include", "manga")
KitsuLibManga(data[0].jsonObject, manga).toTrack() .build()
} else { authClient.newCall(GET(url.toString()))
null .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 { suspend fun getLibManga(track: Track): Track {
val json = rest.getLibManga(track.media_id) return withContext(Dispatchers.IO) {
val data = json["data"]!!.jsonArray val url = "${baseUrl}library-entries".toUri().buildUpon()
return if (data.size > 0) { .encodedQuery("filter[id]=${track.media_id}")
val manga = json["included"]!!.jsonArray[0].jsonObject .appendQueryParameter("include", "manga")
KitsuLibManga(data[0].jsonObject, manga).toTrack() .build()
} else { authClient.newCall(GET(url.toString()))
throw Exception("Could not find manga") .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 { suspend fun login(username: String, password: String): OAuth {
return Retrofit.Builder() return withContext(Dispatchers.IO) {
.baseUrl(loginUrl) val formBody: RequestBody = FormBody.Builder()
.client(client) .add("username", username)
.addConverterFactory(jsonConverter) .add("password", password)
.build() .add("grant_type", "password")
.create(LoginRest::class.java) .add("client_id", clientId)
.requestAccessToken(username, password) .add("client_secret", clientSecret)
.build()
client.newCall(POST(loginUrl, body = formBody))
.await()
.parseAs()
}
} }
suspend fun getCurrentUser(): String { suspend fun getCurrentUser(): String {
return rest.getCurrentUser()["data"]!!.jsonArray[0].jsonObject["id"]!!.jsonPrimitive.content return withContext(Dispatchers.IO) {
} val url = "${baseUrl}users".toUri().buildUpon()
.encodedQuery("filter[self]=true")
private interface Rest { .build()
authClient.newCall(GET(url.toString()))
@Headers("Content-Type: application/vnd.api+json") .await()
@POST("library-entries") .parseAs<JsonObject>()
suspend fun addLibManga( .let {
@Body data: JsonObject it["data"]!!.jsonArray[0].jsonObject["id"]!!.jsonPrimitive.content
): 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
} }
companion object { companion object {
private const val clientId = "dd031b32d2f56c990b1425efe6c42ad847e7fe3ab46bf1299f05ecd856bdb7dd" private const val clientId =
private const val clientSecret = "54d7307928f63414defd96399fc31ba847961ceaecef3a5fd93144e960c0e151" "dd031b32d2f56c990b1425efe6c42ad847e7fe3ab46bf1299f05ecd856bdb7dd"
private const val baseUrl = "https://kitsu.io/api/edge/" private const val clientSecret =
private const val loginUrl = "https://kitsu.io/api/" "54d7307928f63414defd96399fc31ba847961ceaecef3a5fd93144e960c0e151"
private const val baseMangaUrl = "https://kitsu.io/manga/"
private const val algoliaKeyUrl = "https://kitsu.io/api/edge/algolia-keys/"
private const val algoliaUrl = "https://AWQO5J657S-dsn.algolia.net/1/indexes/production_media/"
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"
private val jsonConverter = Json { ignoreUnknownKeys = true }.asConverterFactory("application/json".toMediaType()) private const val baseUrl = "https://kitsu.io/api/edge/"
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/media/"
private const val algoliaUrl =
"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"
fun mangaUrl(remoteId: Int): String { fun mangaUrl(remoteId: Int): String {
return baseMangaUrl + remoteId return baseMangaUrl + remoteId
} }
fun refreshTokenRequest(token: String) = POST( fun refreshTokenRequest(token: String) = POST(
"${loginUrl}oauth/token", loginUrl,
body = FormBody.Builder() body = FormBody.Builder()
.add("grant_type", "refresh_token") .add("grant_type", "refresh_token")
.add("refresh_token", token)
.add("client_id", clientId) .add("client_id", clientId)
.add("client_secret", clientSecret) .add("client_secret", clientSecret)
.add("refresh_token", token)
.build() .build()
) )
} }

View file

@ -7,6 +7,7 @@ import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.await import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.network.jsonMime
import eu.kanade.tachiyomi.network.parseAs import eu.kanade.tachiyomi.network.parseAs
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
@ -21,13 +22,11 @@ import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonObject import kotlinx.serialization.json.putJsonObject
import okhttp3.FormBody import okhttp3.FormBody
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
class ShikimoriApi(private val client: OkHttpClient, interceptor: ShikimoriInterceptor) { class ShikimoriApi(private val client: OkHttpClient, interceptor: ShikimoriInterceptor) {
private val jsonMime = "application/json; charset=utf-8".toMediaType()
private val authClient = client.newBuilder().addInterceptor(interceptor).build() private val authClient = client.newBuilder().addInterceptor(interceptor).build()
suspend fun addLibManga(track: Track, user_id: String): Track { suspend fun addLibManga(track: Track, user_id: String): Track {

View file

@ -5,6 +5,7 @@ import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import okhttp3.Call import okhttp3.Call
import okhttp3.Callback import okhttp3.Callback
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
@ -19,6 +20,8 @@ import java.util.concurrent.atomic.AtomicBoolean
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException import kotlin.coroutines.resumeWithException
val jsonMime = "application/json; charset=utf-8".toMediaType()
fun Call.asObservable(): Observable<Response> { fun Call.asObservable(): Observable<Response> {
return Observable.unsafeCreate { subscriber -> return Observable.unsafeCreate { subscriber ->
// Since Call is a one-shot type, clone it for each new subscriber. // Since Call is a one-shot type, clone it for each new subscriber.