Skip to content

Commit

Permalink
Use DTOs to parse tracking API responses (#1103)
Browse files Browse the repository at this point in the history
* Migrate tracking APIs to DTOs

Changes the handling of tracker API responses to be parsed to DTOs
instead of doing so "manually" by use of `jsonPrimitive`s and/or
`Json.decodeFromString` invocations.

This greatly simplifies the API response handling.

Renamed constants to SCREAMING_SNAKE_CASE.

Largely tried to name the DTOs in a uniform pattern, with the
tracker's (short) name at the beginning of file and data class names
(ALOAuth instead of OAuth, etc).

With these changes, no area of the code base should be using
`jsonPrimitive` and/or `Json.decodeFromString` anymore.

* Fix wrong types in KitsuAlgoliaSearchItem

This API returns start and end dates as Long and the score as Double.

Kitsu's docs claim they're strings (and they are, when requesting
manga details from Kitsu directly) but the Algolia search results
return Longs and Double, respectively.

* Apply review changes

- Renamed `BangumiX` classes to `BGMX` classes.
- Renamed `toXStatus` and `toXScore` to `toApiStatus` and `toApiScore`

* Handle migration from detekt to spotless

Removed Suppressions added for detekt.

Specifically removed:
- `SwallowedException` where an exception ends as a default value
- `MagicNumber`
- `CyclomaticComplexMethod`
- `TooGenericExceptionThrown`

Also ran spotlessApply which changed SMAddMangaResponse

* Fix Kitsu failing to add series

The `included` attribute seems to only appear when the user already
has the entry in their Kitsu list.

Since both `data` and `included` are required for `firstToTrack`, a
guard clause has been added before all its calls.

* Fix empty Bangumi error when entry doesn't exist

Previously, the non-null assertion (!!) would cause a
NullPointerException and a Toast with
"Bangumi error: " (no message) when the user had removed their list
entry from Bangumi through other means like the website.

Now it will show "Bangumi error: Could not find manga".

This is analogous to the error shown by Kitsu under these
circumstances.

* Fix Shikimori ignoring missing remote entry

The user would see no indication that Shikimori could not properly
refresh the track from the remote. This change causes the error Toast
notification to pop up with the following message
"Shikimori error: Could not find manga".

This is analogous to Kitsu and Bangumi.

* Remove usage of let where not needed

These particular occurrences weren't needed because properties are
directly accessible to further act upon. This neatly simplifies these
clauses.

* Remove missed let
  • Loading branch information
MajorTanya authored Sep 2, 2024
1 parent 6c6ea84 commit 9f99f03
Show file tree
Hide file tree
Showing 64 changed files with 1,195 additions and 822 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.BaseTracker
import eu.kanade.tachiyomi.data.track.DeletableTracker
import eu.kanade.tachiyomi.data.track.anilist.dto.ALOAuth
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
Expand Down Expand Up @@ -129,13 +130,15 @@ class Anilist(id: Long) : BaseTracker(id, "AniList"), DeletableTracker {
0.0 -> "0 ★"
else -> "${((score + 10) / 20).toInt()}"
}

POINT_3 -> when {
score == 0.0 -> "0"
score <= 35 -> "😦"
score <= 60 -> "😐"
else -> "😊"
}
else -> track.toAnilistScore()

else -> track.toApiScore()
}
}

Expand Down Expand Up @@ -217,7 +220,7 @@ class Anilist(id: Long) : BaseTracker(id, "AniList"), DeletableTracker {
interceptor.setAuth(oauth)
val (username, scoreType) = api.getCurrentUser()
scorePreference.set(scoreType)
saveCredentials(username.toString(), oauth.access_token)
saveCredentials(username.toString(), oauth.accessToken)
} catch (e: Throwable) {
logout()
}
Expand All @@ -229,13 +232,13 @@ class Anilist(id: Long) : BaseTracker(id, "AniList"), DeletableTracker {
interceptor.setAuth(null)
}

fun saveOAuth(oAuth: OAuth?) {
trackPreferences.trackToken(this).set(json.encodeToString(oAuth))
fun saveOAuth(alOAuth: ALOAuth?) {
trackPreferences.trackToken(this).set(json.encodeToString(alOAuth))
}

fun loadOAuth(): OAuth? {
fun loadOAuth(): ALOAuth? {
return try {
json.decodeFromString<OAuth>(trackPreferences.trackToken(this).get())
json.decodeFromString<ALOAuth>(trackPreferences.trackToken(this).get())
} catch (e: Exception) {
null
}
Expand Down
104 changes: 23 additions & 81 deletions app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistApi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ package eu.kanade.tachiyomi.data.track.anilist
import android.net.Uri
import androidx.core.net.toUri
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.anilist.dto.ALAddMangaResult
import eu.kanade.tachiyomi.data.track.anilist.dto.ALCurrentUserResult
import eu.kanade.tachiyomi.data.track.anilist.dto.ALOAuth
import eu.kanade.tachiyomi.data.track.anilist.dto.ALSearchResult
import eu.kanade.tachiyomi.data.track.anilist.dto.ALUserListMangaQueryResult
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.awaitSuccess
Expand All @@ -13,22 +18,13 @@ import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.int
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.long
import kotlinx.serialization.json.longOrNull
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonObject
import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.toRequestBody
import tachiyomi.core.common.util.lang.withIOContext
import uy.kohesive.injekt.injectLazy
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.ZonedDateTime
import kotlin.time.Duration.Companion.minutes
Expand Down Expand Up @@ -59,7 +55,7 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
putJsonObject("variables") {
put("mangaId", track.remote_id)
put("progress", track.last_chapter_read.toInt())
put("status", track.toAnilistStatus())
put("status", track.toApiStatus())
}
}
with(json) {
Expand All @@ -70,10 +66,9 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
),
)
.awaitSuccess()
.parseAs<JsonObject>()
.parseAs<ALAddMangaResult>()
.let {
track.library_id =
it["data"]!!.jsonObject["SaveMediaListEntry"]!!.jsonObject["id"]!!.jsonPrimitive.long
track.library_id = it.data.entry.id
track
}
}
Expand Down Expand Up @@ -103,7 +98,7 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
putJsonObject("variables") {
put("listId", track.library_id)
put("progress", track.last_chapter_read.toInt())
put("status", track.toAnilistStatus())
put("status", track.toApiStatus())
put("score", track.score.toInt())
put("startedAt", createDate(track.started_reading_date))
put("completedAt", createDate(track.finished_reading_date))
Expand Down Expand Up @@ -135,6 +130,7 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
.awaitSuccess()
}
}

suspend fun search(search: String): List<TrackSearch> {
return withIOContext {
val query = """
Expand Down Expand Up @@ -177,14 +173,9 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
),
)
.awaitSuccess()
.parseAs<JsonObject>()
.let { response ->
val data = response["data"]!!.jsonObject
val page = data["Page"]!!.jsonObject
val media = page["media"]!!.jsonArray
val entries = media.map { jsonToALManga(it.jsonObject) }
entries.map { it.toTrack() }
}
.parseAs<ALSearchResult>()
.data.page.media
.map { it.toALManga().toTrack() }
}
}
}
Expand Down Expand Up @@ -247,14 +238,11 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
),
)
.awaitSuccess()
.parseAs<JsonObject>()
.let { response ->
val data = response["data"]!!.jsonObject
val page = data["Page"]!!.jsonObject
val media = page["mediaList"]!!.jsonArray
val entries = media.map { jsonToALUserManga(it.jsonObject) }
entries.firstOrNull()?.toTrack()
}
.parseAs<ALUserListMangaQueryResult>()
.data.page.mediaList
.map { it.toALUserManga() }
.firstOrNull()
?.toTrack()
}
}
}
Expand All @@ -263,8 +251,8 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
return findLibManga(track, userId) ?: throw Exception("Could not find manga")
}

fun createOAuth(token: String): OAuth {
return OAuth(token, "Bearer", System.currentTimeMillis() + 31536000000, 31536000000)
fun createOAuth(token: String): ALOAuth {
return ALOAuth(token, "Bearer", System.currentTimeMillis() + 31536000000, 31536000000)
}

suspend fun getCurrentUser(): Pair<Int, String> {
Expand All @@ -291,61 +279,15 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
),
)
.awaitSuccess()
.parseAs<JsonObject>()
.parseAs<ALCurrentUserResult>()
.let {
val data = it["data"]!!.jsonObject
val viewer = data["Viewer"]!!.jsonObject
Pair(
viewer["id"]!!.jsonPrimitive.int,
viewer["mediaListOptions"]!!.jsonObject["scoreFormat"]!!.jsonPrimitive.content,
)
val viewer = it.data.viewer
Pair(viewer.id, viewer.mediaListOptions.scoreFormat)
}
}
}
}

private fun jsonToALManga(struct: JsonObject): ALManga {
return ALManga(
struct["id"]!!.jsonPrimitive.long,
struct["title"]!!.jsonObject["userPreferred"]!!.jsonPrimitive.content,
struct["coverImage"]!!.jsonObject["large"]!!.jsonPrimitive.content,
struct["description"]!!.jsonPrimitive.contentOrNull,
struct["format"]!!.jsonPrimitive.content.replace("_", "-"),
struct["status"]!!.jsonPrimitive.contentOrNull ?: "",
parseDate(struct, "startDate"),
struct["chapters"]!!.jsonPrimitive.longOrNull ?: 0,
struct["averageScore"]?.jsonPrimitive?.intOrNull ?: -1,
)
}

private fun jsonToALUserManga(struct: JsonObject): ALUserManga {
return ALUserManga(
struct["id"]!!.jsonPrimitive.long,
struct["status"]!!.jsonPrimitive.content,
struct["scoreRaw"]!!.jsonPrimitive.int,
struct["progress"]!!.jsonPrimitive.int,
parseDate(struct, "startedAt"),
parseDate(struct, "completedAt"),
jsonToALManga(struct["media"]!!.jsonObject),
)
}

private fun parseDate(struct: JsonObject, dateKey: String): Long {
return try {
return LocalDate
.of(
struct[dateKey]!!.jsonObject["year"]!!.jsonPrimitive.int,
struct[dateKey]!!.jsonObject["month"]!!.jsonPrimitive.int,
struct[dateKey]!!.jsonObject["day"]!!.jsonPrimitive.int,
)
.atStartOfDay(ZoneId.systemDefault())
.toInstant()
.toEpochMilli()
} catch (_: Exception) {
0L
}
}

private fun createDate(dateValue: Long): JsonObject {
if (dateValue == 0L) {
return buildJsonObject {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package eu.kanade.tachiyomi.data.track.anilist

import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.data.track.anilist.dto.ALOAuth
import eu.kanade.tachiyomi.data.track.anilist.dto.isExpired
import okhttp3.Interceptor
import okhttp3.Response
import java.io.IOException
Expand All @@ -13,7 +15,7 @@ class AnilistInterceptor(val anilist: Anilist, private var token: String?) : Int
* Anilist returns the date without milliseconds. We fix that and make the token expire 1 minute
* before its original expiration date.
*/
private var oauth: OAuth? = null
private var oauth: ALOAuth? = null
set(value) {
field = value?.copy(expires = value.expires * 1000 - 60 * 1000)
}
Expand All @@ -40,7 +42,7 @@ class AnilistInterceptor(val anilist: Anilist, private var token: String?) : Int

// Add the authorization header to the original request.
val authRequest = originalRequest.newBuilder()
.addHeader("Authorization", "Bearer ${oauth!!.access_token}")
.addHeader("Authorization", "Bearer ${oauth!!.accessToken}")
.header("User-Agent", "Mihon v${BuildConfig.VERSION_NAME} (${BuildConfig.APPLICATION_ID})")
.build()

Expand All @@ -51,8 +53,8 @@ class AnilistInterceptor(val anilist: Anilist, private var token: String?) : Int
* Called when the user authenticates with Anilist for the first time. Sets the refresh token
* and the oauth object.
*/
fun setAuth(oauth: OAuth?) {
token = oauth?.access_token
fun setAuth(oauth: ALOAuth?) {
token = oauth?.accessToken
this.oauth = oauth
anilist.saveOAuth(oauth)
}
Expand Down
Loading

0 comments on commit 9f99f03

Please sign in to comment.