From 9f99f038f341e325c4f56372a5ce950cf9f7cd6d Mon Sep 17 00:00:00 2001 From: MajorTanya <39014446+MajorTanya@users.noreply.github.com> Date: Mon, 2 Sep 2024 21:46:08 +0200 Subject: [PATCH] Use DTOs to parse tracking API responses (#1103) * 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 --- .../tachiyomi/data/track/anilist/Anilist.kt | 15 ++- .../data/track/anilist/AnilistApi.kt | 104 ++++----------- .../data/track/anilist/AnilistInterceptor.kt | 10 +- .../data/track/anilist/AnilistModels.kt | 126 ------------------ .../data/track/anilist/AnilistUtils.kt | 44 ++++++ .../data/track/anilist/dto/ALAddManga.kt | 20 +++ .../data/track/anilist/dto/ALFuzzyDate.kt | 21 +++ .../data/track/anilist/dto/ALManga.kt | 74 ++++++++++ .../data/track/anilist/dto/ALOAuth.kt | 17 +++ .../data/track/anilist/dto/ALSearch.kt | 20 +++ .../data/track/anilist/dto/ALSearchItem.kt | 38 ++++++ .../data/track/anilist/dto/ALUser.kt | 26 ++++ .../data/track/anilist/dto/ALUserList.kt | 43 ++++++ .../tachiyomi/data/track/bangumi/Bangumi.kt | 13 +- .../data/track/bangumi/BangumiApi.kt | 102 +++++--------- .../data/track/bangumi/BangumiInterceptor.kt | 26 ++-- .../data/track/bangumi/BangumiModels.kt | 64 --------- .../data/track/bangumi/BangumiUtils.kt | 12 ++ .../bangumi/dto/BGMCollectionResponse.kt | 28 ++++ .../data/track/bangumi/dto/BGMOAuth.kt | 23 ++++ .../data/track/bangumi/dto/BGMSearch.kt | 45 +++++++ .../data/track/bangumi/dto/BGMUser.kt | 23 ++++ .../tachiyomi/data/track/kitsu/Kitsu.kt | 7 +- .../tachiyomi/data/track/kitsu/KitsuApi.kt | 123 +++++++---------- .../data/track/kitsu/KitsuInterceptor.kt | 10 +- .../tachiyomi/data/track/kitsu/KitsuModels.kt | 121 ----------------- .../tachiyomi/data/track/kitsu/KitsuUtils.kt | 16 +++ .../data/track/kitsu/dto/KitsuAddManga.kt | 13 ++ .../data/track/kitsu/dto/KitsuListSearch.kt | 79 +++++++++++ .../data/track/kitsu/dto/KitsuOAuth.kt | 20 +++ .../data/track/kitsu/dto/KitsuSearch.kt | 55 ++++++++ .../track/kitsu/dto/KitsuSearchItemCover.kt | 8 ++ .../data/track/kitsu/dto/KitsuUser.kt | 13 ++ .../data/track/mangaupdates/MangaUpdates.kt | 6 +- .../track/mangaupdates/MangaUpdatesApi.kt | 91 ++++++------- .../dto/{Context.kt => MUContext.kt} | 2 +- .../mangaupdates/dto/{Image.kt => MUImage.kt} | 4 +- .../dto/{ListItem.kt => MUListItem.kt} | 8 +- .../track/mangaupdates/dto/MULoginResponse.kt | 8 ++ .../dto/{Rating.kt => MURating.kt} | 4 +- .../dto/{Record.kt => MURecord.kt} | 6 +- .../data/track/mangaupdates/dto/MUSearch.kt | 13 ++ .../dto/{Series.kt => MUSeries.kt} | 2 +- .../dto/{Status.kt => MUStatus.kt} | 2 +- .../mangaupdates/dto/{Url.kt => MUUrl.kt} | 2 +- .../data/track/myanimelist/MyAnimeList.kt | 9 +- .../data/track/myanimelist/MyAnimeListApi.kt | 122 +++++++---------- .../myanimelist/MyAnimeListInterceptor.kt | 11 +- ...AnimeListModels.kt => MyAnimeListUtils.kt} | 14 -- .../data/track/myanimelist/dto/MALList.kt | 26 ++++ .../data/track/myanimelist/dto/MALManga.kt | 26 ++++ .../data/track/myanimelist/dto/MALOAuth.kt | 23 ++++ .../data/track/myanimelist/dto/MALSearch.kt | 18 +++ .../data/track/myanimelist/dto/MALUser.kt | 8 ++ .../myanimelist/dto/MALUserListSearch.kt | 25 ++++ .../data/track/shikimori/Shikimori.kt | 11 +- .../data/track/shikimori/ShikimoriApi.kt | 88 ++++-------- .../track/shikimori/ShikimoriInterceptor.kt | 12 +- .../{ShikimoriModels.kt => ShikimoriUtils.kt} | 13 -- .../track/shikimori/dto/SMAddMangaResponse.kt | 8 ++ .../data/track/shikimori/dto/SMManga.kt | 40 ++++++ .../data/track/shikimori/dto/SMOAuth.kt | 21 +++ .../data/track/shikimori/dto/SMUser.kt | 8 ++ .../track/shikimori/dto/SMUserListEntry.kt | 27 ++++ 64 files changed, 1195 insertions(+), 822 deletions(-) delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistModels.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistUtils.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/dto/ALAddManga.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/dto/ALFuzzyDate.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/dto/ALManga.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/dto/ALOAuth.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/dto/ALSearch.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/dto/ALSearchItem.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/dto/ALUser.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/dto/ALUserList.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiModels.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiUtils.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/dto/BGMCollectionResponse.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/dto/BGMOAuth.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/dto/BGMSearch.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/dto/BGMUser.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuModels.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuUtils.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuAddManga.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuListSearch.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuOAuth.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuSearch.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuSearchItemCover.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuUser.kt rename app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/{Context.kt => MUContext.kt} (91%) rename app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/{Image.kt => MUImage.kt} (78%) rename app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/{ListItem.kt => MUListItem.kt} (80%) create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/MULoginResponse.kt rename app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/{Rating.kt => MURating.kt} (80%) rename app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/{Record.kt => MURecord.kt} (92%) create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/MUSearch.kt rename app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/{Series.kt => MUSeries.kt} (89%) rename app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/{Status.kt => MUStatus.kt} (89%) rename app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/{Url.kt => MUUrl.kt} (90%) rename app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/{MyAnimeListModels.kt => MyAnimeListUtils.kt} (61%) create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/dto/MALList.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/dto/MALManga.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/dto/MALOAuth.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/dto/MALSearch.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/dto/MALUser.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/dto/MALUserListSearch.kt rename app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/{ShikimoriModels.kt => ShikimoriUtils.kt} (69%) create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/dto/SMAddMangaResponse.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/dto/SMManga.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/dto/SMOAuth.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/dto/SMUser.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/dto/SMUserListEntry.kt diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/Anilist.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/Anilist.kt index abf0d702a..db25cc763 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/Anilist.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/Anilist.kt @@ -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 @@ -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() } } @@ -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() } @@ -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(trackPreferences.trackToken(this).get()) + json.decodeFromString(trackPreferences.trackToken(this).get()) } catch (e: Exception) { null } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistApi.kt index e31454e74..3695b6f25 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistApi.kt @@ -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 @@ -13,14 +18,6 @@ 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 @@ -28,7 +25,6 @@ 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 @@ -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) { @@ -70,10 +66,9 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) { ), ) .awaitSuccess() - .parseAs() + .parseAs() .let { - track.library_id = - it["data"]!!.jsonObject["SaveMediaListEntry"]!!.jsonObject["id"]!!.jsonPrimitive.long + track.library_id = it.data.entry.id track } } @@ -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)) @@ -135,6 +130,7 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) { .awaitSuccess() } } + suspend fun search(search: String): List { return withIOContext { val query = """ @@ -177,14 +173,9 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) { ), ) .awaitSuccess() - .parseAs() - .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() + .data.page.media + .map { it.toALManga().toTrack() } } } } @@ -247,14 +238,11 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) { ), ) .awaitSuccess() - .parseAs() - .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() + .data.page.mediaList + .map { it.toALUserManga() } + .firstOrNull() + ?.toTrack() } } } @@ -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 { @@ -291,61 +279,15 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) { ), ) .awaitSuccess() - .parseAs() + .parseAs() .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 { diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistInterceptor.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistInterceptor.kt index 388b3e1b5..b23179f6e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistInterceptor.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistInterceptor.kt @@ -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 @@ -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) } @@ -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() @@ -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) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistModels.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistModels.kt deleted file mode 100644 index d7c037afe..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistModels.kt +++ /dev/null @@ -1,126 +0,0 @@ -package eu.kanade.tachiyomi.data.track.anilist - -import eu.kanade.domain.track.service.TrackPreferences -import eu.kanade.tachiyomi.data.database.models.Track -import eu.kanade.tachiyomi.data.track.TrackerManager -import eu.kanade.tachiyomi.data.track.model.TrackSearch -import eu.kanade.tachiyomi.util.lang.htmlDecode -import kotlinx.serialization.Serializable -import uy.kohesive.injekt.injectLazy -import java.text.SimpleDateFormat -import java.util.Locale -import tachiyomi.domain.track.model.Track as DomainTrack - -data class ALManga( - val remote_id: Long, - val title_user_pref: String, - val image_url_lge: String, - val description: String?, - val format: String, - val publishing_status: String, - val start_date_fuzzy: Long, - val total_chapters: Long, - val average_score: Int, -) { - - fun toTrack() = TrackSearch.create(TrackerManager.ANILIST).apply { - remote_id = this@ALManga.remote_id - title = title_user_pref - total_chapters = this@ALManga.total_chapters - cover_url = image_url_lge - summary = description?.htmlDecode() ?: "" - score = average_score.toDouble() - tracking_url = AnilistApi.mangaUrl(remote_id) - publishing_status = this@ALManga.publishing_status - publishing_type = format - if (start_date_fuzzy != 0L) { - start_date = try { - val outputDf = SimpleDateFormat("yyyy-MM-dd", Locale.US) - outputDf.format(start_date_fuzzy) - } catch (e: Exception) { - "" - } - } - } -} - -data class ALUserManga( - val library_id: Long, - val list_status: String, - val score_raw: Int, - val chapters_read: Int, - val start_date_fuzzy: Long, - val completed_date_fuzzy: Long, - val manga: ALManga, -) { - - fun toTrack() = Track.create(TrackerManager.ANILIST).apply { - remote_id = manga.remote_id - title = manga.title_user_pref - status = toTrackStatus() - score = score_raw.toDouble() - started_reading_date = start_date_fuzzy - finished_reading_date = completed_date_fuzzy - last_chapter_read = chapters_read.toDouble() - library_id = this@ALUserManga.library_id - total_chapters = manga.total_chapters - } - - private fun toTrackStatus() = when (list_status) { - "CURRENT" -> Anilist.READING - "COMPLETED" -> Anilist.COMPLETED - "PAUSED" -> Anilist.ON_HOLD - "DROPPED" -> Anilist.DROPPED - "PLANNING" -> Anilist.PLAN_TO_READ - "REPEATING" -> Anilist.REREADING - else -> throw NotImplementedError("Unknown status: $list_status") - } -} - -@Serializable -data class OAuth( - val access_token: String, - val token_type: String, - val expires: Long, - val expires_in: Long, -) - -fun OAuth.isExpired() = System.currentTimeMillis() > expires - -fun Track.toAnilistStatus() = when (status) { - Anilist.READING -> "CURRENT" - Anilist.COMPLETED -> "COMPLETED" - Anilist.ON_HOLD -> "PAUSED" - Anilist.DROPPED -> "DROPPED" - Anilist.PLAN_TO_READ -> "PLANNING" - Anilist.REREADING -> "REPEATING" - else -> throw NotImplementedError("Unknown status: $status") -} - -private val preferences: TrackPreferences by injectLazy() - -fun DomainTrack.toAnilistScore(): String = when (preferences.anilistScoreType().get()) { - // 10 point - "POINT_10" -> (score.toInt() / 10).toString() - // 100 point - "POINT_100" -> score.toInt().toString() - // 5 stars - "POINT_5" -> when { - score == 0.0 -> "0" - score < 30 -> "1" - score < 50 -> "2" - score < 70 -> "3" - score < 90 -> "4" - else -> "5" - } - // Smiley - "POINT_3" -> when { - score == 0.0 -> "0" - score <= 35 -> ":(" - score <= 60 -> ":|" - else -> ":)" - } - // 10 point decimal - "POINT_10_DECIMAL" -> (score / 10).toString() - else -> throw NotImplementedError("Unknown score type") -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistUtils.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistUtils.kt new file mode 100644 index 000000000..5e45f8da9 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistUtils.kt @@ -0,0 +1,44 @@ +package eu.kanade.tachiyomi.data.track.anilist + +import eu.kanade.domain.track.service.TrackPreferences +import eu.kanade.tachiyomi.data.database.models.Track +import uy.kohesive.injekt.injectLazy +import tachiyomi.domain.track.model.Track as DomainTrack + +fun Track.toApiStatus() = when (status) { + Anilist.READING -> "CURRENT" + Anilist.COMPLETED -> "COMPLETED" + Anilist.ON_HOLD -> "PAUSED" + Anilist.DROPPED -> "DROPPED" + Anilist.PLAN_TO_READ -> "PLANNING" + Anilist.REREADING -> "REPEATING" + else -> throw NotImplementedError("Unknown status: $status") +} + +private val preferences: TrackPreferences by injectLazy() + +fun DomainTrack.toApiScore(): String = when (preferences.anilistScoreType().get()) { + // 10 point + "POINT_10" -> (score.toInt() / 10).toString() + // 100 point + "POINT_100" -> score.toInt().toString() + // 5 stars + "POINT_5" -> when { + score == 0.0 -> "0" + score < 30 -> "1" + score < 50 -> "2" + score < 70 -> "3" + score < 90 -> "4" + else -> "5" + } + // Smiley + "POINT_3" -> when { + score == 0.0 -> "0" + score <= 35 -> ":(" + score <= 60 -> ":|" + else -> ":)" + } + // 10 point decimal + "POINT_10_DECIMAL" -> (score / 10).toString() + else -> throw NotImplementedError("Unknown score type") +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/dto/ALAddManga.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/dto/ALAddManga.kt new file mode 100644 index 000000000..a552e7c1d --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/dto/ALAddManga.kt @@ -0,0 +1,20 @@ +package eu.kanade.tachiyomi.data.track.anilist.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ALAddMangaResult( + val data: ALAddMangaData, +) + +@Serializable +data class ALAddMangaData( + @SerialName("SaveMediaListEntry") + val entry: ALAddMangaEntry, +) + +@Serializable +data class ALAddMangaEntry( + val id: Long, +) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/dto/ALFuzzyDate.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/dto/ALFuzzyDate.kt new file mode 100644 index 000000000..7dbd8c296 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/dto/ALFuzzyDate.kt @@ -0,0 +1,21 @@ +package eu.kanade.tachiyomi.data.track.anilist.dto + +import kotlinx.serialization.Serializable +import java.time.LocalDate +import java.time.ZoneId + +@Serializable +data class ALFuzzyDate( + val year: Int?, + val month: Int?, + val day: Int?, +) { + fun toEpochMilli(): Long = try { + LocalDate.of(year!!, month!!, day!!) + .atStartOfDay(ZoneId.systemDefault()) + .toInstant() + .toEpochMilli() + } catch (_: Exception) { + 0L + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/dto/ALManga.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/dto/ALManga.kt new file mode 100644 index 000000000..b8f98ce63 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/dto/ALManga.kt @@ -0,0 +1,74 @@ +package eu.kanade.tachiyomi.data.track.anilist.dto + +import eu.kanade.tachiyomi.data.database.models.Track +import eu.kanade.tachiyomi.data.track.TrackerManager +import eu.kanade.tachiyomi.data.track.anilist.Anilist +import eu.kanade.tachiyomi.data.track.anilist.AnilistApi +import eu.kanade.tachiyomi.data.track.model.TrackSearch +import eu.kanade.tachiyomi.util.lang.htmlDecode +import java.text.SimpleDateFormat +import java.util.Locale + +data class ALManga( + val remoteId: Long, + val title: String, + val imageUrl: String, + val description: String?, + val format: String, + val publishingStatus: String, + val startDateFuzzy: Long, + val totalChapters: Long, + val averageScore: Int, +) { + fun toTrack() = TrackSearch.create(TrackerManager.ANILIST).apply { + remote_id = remoteId + title = this@ALManga.title + total_chapters = totalChapters + cover_url = imageUrl + summary = description?.htmlDecode() ?: "" + score = averageScore.toDouble() + tracking_url = AnilistApi.mangaUrl(remote_id) + publishing_status = publishingStatus + publishing_type = format + if (startDateFuzzy != 0L) { + start_date = try { + val outputDf = SimpleDateFormat("yyyy-MM-dd", Locale.US) + outputDf.format(startDateFuzzy) + } catch (e: IllegalArgumentException) { + "" + } + } + } +} + +data class ALUserManga( + val libraryId: Long, + val listStatus: String, + val scoreRaw: Int, + val chaptersRead: Int, + val startDateFuzzy: Long, + val completedDateFuzzy: Long, + val manga: ALManga, +) { + fun toTrack() = Track.create(TrackerManager.ANILIST).apply { + remote_id = manga.remoteId + title = manga.title + status = toTrackStatus() + score = scoreRaw.toDouble() + started_reading_date = startDateFuzzy + finished_reading_date = completedDateFuzzy + last_chapter_read = chaptersRead.toDouble() + library_id = libraryId + total_chapters = manga.totalChapters + } + + private fun toTrackStatus() = when (listStatus) { + "CURRENT" -> Anilist.READING + "COMPLETED" -> Anilist.COMPLETED + "PAUSED" -> Anilist.ON_HOLD + "DROPPED" -> Anilist.DROPPED + "PLANNING" -> Anilist.PLAN_TO_READ + "REPEATING" -> Anilist.REREADING + else -> throw NotImplementedError("Unknown status: $listStatus") + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/dto/ALOAuth.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/dto/ALOAuth.kt new file mode 100644 index 000000000..94fbd6400 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/dto/ALOAuth.kt @@ -0,0 +1,17 @@ +package eu.kanade.tachiyomi.data.track.anilist.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ALOAuth( + @SerialName("access_token") + val accessToken: String, + @SerialName("token_type") + val tokenType: String, + val expires: Long, + @SerialName("expires_in") + val expiresIn: Long, +) + +fun ALOAuth.isExpired() = System.currentTimeMillis() > expires diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/dto/ALSearch.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/dto/ALSearch.kt new file mode 100644 index 000000000..f13ebb400 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/dto/ALSearch.kt @@ -0,0 +1,20 @@ +package eu.kanade.tachiyomi.data.track.anilist.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ALSearchResult( + val data: ALSearchPage, +) + +@Serializable +data class ALSearchPage( + @SerialName("Page") + val page: ALSearchMedia, +) + +@Serializable +data class ALSearchMedia( + val media: List, +) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/dto/ALSearchItem.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/dto/ALSearchItem.kt new file mode 100644 index 000000000..49c8df938 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/dto/ALSearchItem.kt @@ -0,0 +1,38 @@ +package eu.kanade.tachiyomi.data.track.anilist.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class ALSearchItem( + val id: Long, + val title: ALItemTitle, + val coverImage: ItemCover, + val description: String?, + val format: String, + val status: String = "", + val startDate: ALFuzzyDate, + val chapters: Long?, + val averageScore: Int?, +) { + fun toALManga(): ALManga = ALManga( + remoteId = id, + title = title.userPreferred, + imageUrl = coverImage.large, + description = description, + format = format.replace("_", "-"), + publishingStatus = status, + startDateFuzzy = startDate.toEpochMilli(), + totalChapters = chapters ?: 0, + averageScore = averageScore ?: -1, + ) +} + +@Serializable +data class ALItemTitle( + val userPreferred: String, +) + +@Serializable +data class ItemCover( + val large: String, +) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/dto/ALUser.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/dto/ALUser.kt new file mode 100644 index 000000000..39507a0d5 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/dto/ALUser.kt @@ -0,0 +1,26 @@ +package eu.kanade.tachiyomi.data.track.anilist.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ALCurrentUserResult( + val data: ALUserViewer, +) + +@Serializable +data class ALUserViewer( + @SerialName("Viewer") + val viewer: ALUserViewerData, +) + +@Serializable +data class ALUserViewerData( + val id: Int, + val mediaListOptions: ALUserListOptions, +) + +@Serializable +data class ALUserListOptions( + val scoreFormat: String, +) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/dto/ALUserList.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/dto/ALUserList.kt new file mode 100644 index 000000000..4ccec7aa4 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/dto/ALUserList.kt @@ -0,0 +1,43 @@ +package eu.kanade.tachiyomi.data.track.anilist.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ALUserListMangaQueryResult( + val data: ALUserListMangaPage, +) + +@Serializable +data class ALUserListMangaPage( + @SerialName("Page") + val page: ALUserListMediaList, +) + +@Serializable +data class ALUserListMediaList( + val mediaList: List, +) + +@Serializable +data class ALUserListItem( + val id: Long, + val status: String, + val scoreRaw: Int, + val progress: Int, + val startedAt: ALFuzzyDate, + val completedAt: ALFuzzyDate, + val media: ALSearchItem, +) { + fun toALUserManga(): ALUserManga { + return ALUserManga( + libraryId = this@ALUserListItem.id, + listStatus = status, + scoreRaw = scoreRaw, + chaptersRead = progress, + startDateFuzzy = startedAt.toEpochMilli(), + completedDateFuzzy = completedAt.toEpochMilli(), + manga = media.toALManga(), + ) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/Bangumi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/Bangumi.kt index b8e7d2acc..8eb3ec776 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/Bangumi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/Bangumi.kt @@ -5,6 +5,7 @@ import dev.icerock.moko.resources.StringResource 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.bangumi.dto.BGMOAuth import eu.kanade.tachiyomi.data.track.model.TrackSearch import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList @@ -75,8 +76,8 @@ class Bangumi(id: Long) : BaseTracker(id, "Bangumi") { } override suspend fun refresh(track: Track): Track { - val remoteStatusTrack = api.statusLibManga(track) - track.copyPersonalFrom(remoteStatusTrack!!) + val remoteStatusTrack = api.statusLibManga(track) ?: throw Exception("Could not find manga") + track.copyPersonalFrom(remoteStatusTrack) api.findLibManga(track)?.let { remoteTrack -> track.total_chapters = remoteTrack.total_chapters } @@ -112,19 +113,19 @@ class Bangumi(id: Long) : BaseTracker(id, "Bangumi") { try { val oauth = api.accessToken(code) interceptor.newAuth(oauth) - saveCredentials(oauth.user_id.toString(), oauth.access_token) + saveCredentials(oauth.userId.toString(), oauth.accessToken) } catch (e: Throwable) { logout() } } - fun saveToken(oauth: OAuth?) { + fun saveToken(oauth: BGMOAuth?) { trackPreferences.trackToken(this).set(json.encodeToString(oauth)) } - fun restoreToken(): OAuth? { + fun restoreToken(): BGMOAuth? { return try { - json.decodeFromString(trackPreferences.trackToken(this).get()) + json.decodeFromString(trackPreferences.trackToken(this).get()) } catch (e: Exception) { null } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiApi.kt index c48fa2490..859f10d3e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiApi.kt @@ -3,20 +3,16 @@ package eu.kanade.tachiyomi.data.track.bangumi import android.net.Uri import androidx.core.net.toUri import eu.kanade.tachiyomi.data.database.models.Track +import eu.kanade.tachiyomi.data.track.bangumi.dto.BGMCollectionResponse +import eu.kanade.tachiyomi.data.track.bangumi.dto.BGMOAuth +import eu.kanade.tachiyomi.data.track.bangumi.dto.BGMSearchItem +import eu.kanade.tachiyomi.data.track.bangumi.dto.BGMSearchResult 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.awaitSuccess import eu.kanade.tachiyomi.network.parseAs import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.contentOrNull -import kotlinx.serialization.json.doubleOrNull -import kotlinx.serialization.json.int -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive -import kotlinx.serialization.json.long import okhttp3.CacheControl import okhttp3.FormBody import okhttp3.OkHttpClient @@ -40,7 +36,7 @@ class BangumiApi( return withIOContext { val body = FormBody.Builder() .add("rating", track.score.toInt().toString()) - .add("status", track.toBangumiStatus()) + .add("status", track.toApiStatus()) .build() authClient.newCall(POST("$API_URL/collection/${track.remote_id}/update", body = body)) .awaitSuccess() @@ -53,7 +49,7 @@ class BangumiApi( // read status update val sbody = FormBody.Builder() .add("rating", track.score.toInt().toString()) - .add("status", track.toBangumiStatus()) + .add("status", track.toApiStatus()) .build() authClient.newCall(POST("$API_URL/collection/${track.remote_id}/update", body = sbody)) .awaitSuccess() @@ -63,10 +59,7 @@ class BangumiApi( .add("watched_eps", track.last_chapter_read.toInt().toString()) .build() authClient.newCall( - POST( - "$API_URL/subject/${track.remote_id}/update/watched_eps", - body = body, - ), + POST("$API_URL/subject/${track.remote_id}/update/watched_eps", body = body), ).awaitSuccess() track @@ -80,44 +73,19 @@ class BangumiApi( .buildUpon() .appendQueryParameter("max_results", "20") .build() - authClient.newCall(GET(url.toString())) - .awaitSuccess() - .use { - var responseBody = it.body.string() - if (responseBody.isEmpty()) { - throw Exception("Null Response") - } - if (responseBody.contains("\"code\":404")) { - responseBody = "{\"results\":0,\"list\":[]}" - } - val response = json.decodeFromString(responseBody)["list"]?.jsonArray - response?.filter { it.jsonObject["type"]?.jsonPrimitive?.int == 1 } - ?.map { jsonToSearch(it.jsonObject) }.orEmpty() - } - } - } + with(json) { + authClient.newCall(GET(url.toString())) + .awaitSuccess() + .parseAs() + .let { result -> + if (result.code == 404) emptyList() - private fun jsonToSearch(obj: JsonObject): TrackSearch { - val coverUrl = if (obj["images"] is JsonObject) { - obj["images"]?.jsonObject?.get("common")?.jsonPrimitive?.contentOrNull ?: "" - } else { - // Sometimes JsonNull - "" - } - val totalChapters = if (obj["eps_count"] != null) { - obj["eps_count"]!!.jsonPrimitive.long - } else { - 0 - } - val rating = obj["rating"]?.jsonObject?.get("score")?.jsonPrimitive?.doubleOrNull ?: -1.0 - return TrackSearch.create(trackId).apply { - remote_id = obj["id"]!!.jsonPrimitive.long - title = obj["name_cn"]!!.jsonPrimitive.content - cover_url = coverUrl - summary = obj["name"]!!.jsonPrimitive.content - score = rating - tracking_url = obj["url"]!!.jsonPrimitive.content - total_chapters = totalChapters + result.list + ?.filter { it.type == 1 } + ?.map { it.toTrackSearch(trackId) } + .orEmpty() + } + } } } @@ -126,8 +94,8 @@ class BangumiApi( with(json) { authClient.newCall(GET("$API_URL/subject/${track.remote_id}")) .awaitSuccess() - .parseAs() - .let { jsonToSearch(it) } + .parseAs() + .toTrackSearch(trackId) } } } @@ -142,25 +110,23 @@ class BangumiApi( .build() // TODO: get user readed chapter here - val response = authClient.newCall(requestUserRead).awaitSuccess() - val responseBody = response.body.string() - if (responseBody.isEmpty()) { - throw Exception("Null Response") - } - if (responseBody.contains("\"code\":400")) { - null - } else { - json.decodeFromString(responseBody).let { - track.status = it.status?.id!! - track.last_chapter_read = it.ep_status!!.toDouble() - track.score = it.rating!! - track - } + with(json) { + authClient.newCall(requestUserRead) + .awaitSuccess() + .parseAs() + .let { + if (it.code == 400) return@let null + + track.status = it.status?.id!! + track.last_chapter_read = it.epStatus!!.toDouble() + track.score = it.rating!! + track + } } } } - suspend fun accessToken(code: String): OAuth { + suspend fun accessToken(code: String): BGMOAuth { return withIOContext { with(json) { client.newCall(accessTokenRequest(code)) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiInterceptor.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiInterceptor.kt index a1822cca0..349ab4886 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiInterceptor.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiInterceptor.kt @@ -1,6 +1,8 @@ package eu.kanade.tachiyomi.data.track.bangumi import eu.kanade.tachiyomi.BuildConfig +import eu.kanade.tachiyomi.data.track.bangumi.dto.BGMOAuth +import eu.kanade.tachiyomi.data.track.bangumi.dto.isExpired import kotlinx.serialization.json.Json import okhttp3.FormBody import okhttp3.Interceptor @@ -14,7 +16,7 @@ class BangumiInterceptor(private val bangumi: Bangumi) : Interceptor { /** * OAuth object used for authenticated requests. */ - private var oauth: OAuth? = bangumi.restoreToken() + private var oauth: BGMOAuth? = bangumi.restoreToken() override fun intercept(chain: Interceptor.Chain): Response { val originalRequest = chain.request() @@ -22,9 +24,9 @@ class BangumiInterceptor(private val bangumi: Bangumi) : Interceptor { val currAuth = oauth ?: throw Exception("Not authenticated with Bangumi") if (currAuth.isExpired()) { - val response = chain.proceed(BangumiApi.refreshTokenRequest(currAuth.refresh_token!!)) + val response = chain.proceed(BangumiApi.refreshTokenRequest(currAuth.refreshToken!!)) if (response.isSuccessful) { - newAuth(json.decodeFromString(response.body.string())) + newAuth(json.decodeFromString(response.body.string())) } else { response.close() } @@ -38,28 +40,28 @@ class BangumiInterceptor(private val bangumi: Bangumi) : Interceptor { .apply { if (originalRequest.method == "GET") { val newUrl = originalRequest.url.newBuilder() - .addQueryParameter("access_token", currAuth.access_token) + .addQueryParameter("access_token", currAuth.accessToken) .build() url(newUrl) } else { - post(addToken(currAuth.access_token, originalRequest.body as FormBody)) + post(addToken(currAuth.accessToken, originalRequest.body as FormBody)) } } .build() .let(chain::proceed) } - fun newAuth(oauth: OAuth?) { + fun newAuth(oauth: BGMOAuth?) { this.oauth = if (oauth == null) { null } else { - OAuth( - oauth.access_token, - oauth.token_type, + BGMOAuth( + oauth.accessToken, + oauth.tokenType, System.currentTimeMillis() / 1000, - oauth.expires_in, - oauth.refresh_token, - this.oauth?.user_id, + oauth.expiresIn, + oauth.refreshToken, + this.oauth?.userId, ) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiModels.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiModels.kt deleted file mode 100644 index c4b1aeed7..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiModels.kt +++ /dev/null @@ -1,64 +0,0 @@ -package eu.kanade.tachiyomi.data.track.bangumi - -import eu.kanade.tachiyomi.data.database.models.Track -import kotlinx.serialization.Serializable - -@Serializable -data class Avatar( - val large: String? = "", - val medium: String? = "", - val small: String? = "", -) - -@Serializable -data class Collection( - val `private`: Int? = 0, - val comment: String? = "", - val ep_status: Int? = 0, - val lasttouch: Int? = 0, - val rating: Double? = 0.0, - val status: Status? = Status(), - val tag: List? = emptyList(), - val user: User? = User(), - val vol_status: Int? = 0, -) - -@Serializable -data class Status( - val id: Long? = 0, - val name: String? = "", - val type: String? = "", -) - -@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? = "", -) - -@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 OAuth.isExpired() = (System.currentTimeMillis() / 1000) > (created_at + expires_in - 3600) - -fun Track.toBangumiStatus() = when (status) { - Bangumi.READING -> "do" - Bangumi.COMPLETED -> "collect" - Bangumi.ON_HOLD -> "on_hold" - Bangumi.DROPPED -> "dropped" - Bangumi.PLAN_TO_READ -> "wish" - else -> throw NotImplementedError("Unknown status: $status") -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiUtils.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiUtils.kt new file mode 100644 index 000000000..5e38960bf --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiUtils.kt @@ -0,0 +1,12 @@ +package eu.kanade.tachiyomi.data.track.bangumi + +import eu.kanade.tachiyomi.data.database.models.Track + +fun Track.toApiStatus() = when (status) { + Bangumi.READING -> "do" + Bangumi.COMPLETED -> "collect" + Bangumi.ON_HOLD -> "on_hold" + Bangumi.DROPPED -> "dropped" + Bangumi.PLAN_TO_READ -> "wish" + else -> throw NotImplementedError("Unknown status: $status") +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/dto/BGMCollectionResponse.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/dto/BGMCollectionResponse.kt new file mode 100644 index 000000000..85501934f --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/dto/BGMCollectionResponse.kt @@ -0,0 +1,28 @@ +package eu.kanade.tachiyomi.data.track.bangumi.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class BGMCollectionResponse( + val code: Int?, + val `private`: Int? = 0, + val comment: String? = "", + @SerialName("ep_status") + val epStatus: Int? = 0, + @SerialName("lasttouch") + val lastTouch: Int? = 0, + val rating: Double? = 0.0, + val status: Status? = Status(), + val tag: List? = emptyList(), + val user: User? = User(), + @SerialName("vol_status") + val volStatus: Int? = 0, +) + +@Serializable +data class Status( + val id: Long? = 0, + val name: String? = "", + val type: String? = "", +) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/dto/BGMOAuth.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/dto/BGMOAuth.kt new file mode 100644 index 000000000..6a4fea3cb --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/dto/BGMOAuth.kt @@ -0,0 +1,23 @@ +package eu.kanade.tachiyomi.data.track.bangumi.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class BGMOAuth( + @SerialName("access_token") + val accessToken: String, + @SerialName("token_type") + val tokenType: String, + @SerialName("created_at") + val createdAt: Long = System.currentTimeMillis() / 1000, + @SerialName("expires_in") + val expiresIn: Long, + @SerialName("refresh_token") + val refreshToken: String?, + @SerialName("user_id") + val userId: Long?, +) + +// Access token refresh before expired +fun BGMOAuth.isExpired() = (System.currentTimeMillis() / 1000) > (createdAt + expiresIn - 3600) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/dto/BGMSearch.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/dto/BGMSearch.kt new file mode 100644 index 000000000..241b95430 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/dto/BGMSearch.kt @@ -0,0 +1,45 @@ +package eu.kanade.tachiyomi.data.track.bangumi.dto + +import eu.kanade.tachiyomi.data.track.model.TrackSearch +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class BGMSearchResult( + val list: List?, + val code: Int?, +) + +@Serializable +data class BGMSearchItem( + val id: Long, + @SerialName("name_cn") + val nameCn: String, + val name: String, + val type: Int, + val images: BGMSearchItemCovers?, + @SerialName("eps_count") + val epsCount: Long?, + val rating: BGMSearchItemRating?, + val url: String, +) { + fun toTrackSearch(trackId: Long): TrackSearch = TrackSearch.create(trackId).apply { + remote_id = this@BGMSearchItem.id + title = nameCn + cover_url = images?.common ?: "" + summary = this@BGMSearchItem.name + score = rating?.score ?: -1.0 + tracking_url = url + total_chapters = epsCount ?: 0 + } +} + +@Serializable +data class BGMSearchItemCovers( + val common: String?, +) + +@Serializable +data class BGMSearchItemRating( + val score: Double?, +) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/dto/BGMUser.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/dto/BGMUser.kt new file mode 100644 index 000000000..375c39eb6 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/dto/BGMUser.kt @@ -0,0 +1,23 @@ +package eu.kanade.tachiyomi.data.track.bangumi.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class Avatar( + val large: String? = "", + val medium: String? = "", + val small: String? = "", +) + +@Serializable +data class User( + val avatar: Avatar? = Avatar(), + val id: Int? = 0, + val nickname: String? = "", + val sign: String? = "", + val url: String? = "", + @SerialName("usergroup") + val userGroup: Int? = 0, + val username: String? = "", +) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/Kitsu.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/Kitsu.kt index 4b0db8bce..f0ca4e720 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/Kitsu.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/Kitsu.kt @@ -6,6 +6,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.kitsu.dto.KitsuOAuth import eu.kanade.tachiyomi.data.track.model.TrackSearch import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList @@ -142,13 +143,13 @@ class Kitsu(id: Long) : BaseTracker(id, "Kitsu"), DeletableTracker { return getPassword() } - fun saveToken(oauth: OAuth?) { + fun saveToken(oauth: KitsuOAuth?) { trackPreferences.trackToken(this).set(json.encodeToString(oauth)) } - fun restoreToken(): OAuth? { + fun restoreToken(): KitsuOAuth? { return try { - json.decodeFromString(trackPreferences.trackToken(this).get()) + json.decodeFromString(trackPreferences.trackToken(this).get()) } catch (e: Exception) { null } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuApi.kt index c124ddd13..5933c96dd 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuApi.kt @@ -2,6 +2,12 @@ package eu.kanade.tachiyomi.data.track.kitsu import androidx.core.net.toUri import eu.kanade.tachiyomi.data.database.models.Track +import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuAddMangaResult +import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuAlgoliaSearchResult +import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuCurrentUserResult +import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuListSearchResult +import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuOAuth +import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuSearchResult import eu.kanade.tachiyomi.data.track.model.TrackSearch import eu.kanade.tachiyomi.network.DELETE import eu.kanade.tachiyomi.network.GET @@ -10,12 +16,7 @@ import eu.kanade.tachiyomi.network.awaitSuccess import eu.kanade.tachiyomi.network.jsonMime import eu.kanade.tachiyomi.network.parseAs import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive -import kotlinx.serialization.json.long import kotlinx.serialization.json.put import kotlinx.serialization.json.putJsonObject import okhttp3.FormBody @@ -43,7 +44,7 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor) putJsonObject("data") { put("type", "libraryEntries") putJsonObject("attributes") { - put("status", track.toKitsuStatus()) + put("status", track.toApiStatus()) put("progress", track.last_chapter_read.toInt()) } putJsonObject("relationships") { @@ -67,18 +68,14 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor) authClient.newCall( POST( "${BASE_URL}library-entries", - headers = headersOf( - "Content-Type", - "application/vnd.api+json", - ), - body = data.toString() - .toRequestBody("application/vnd.api+json".toMediaType()), + headers = headersOf("Content-Type", VND_API_JSON), + body = data.toString().toRequestBody(VND_JSON_MEDIA_TYPE), ), ) .awaitSuccess() - .parseAs() + .parseAs() .let { - track.remote_id = it["data"]!!.jsonObject["id"]!!.jsonPrimitive.long + track.remote_id = it.data.id track } } @@ -92,63 +89,50 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor) put("type", "libraryEntries") put("id", track.remote_id) putJsonObject("attributes") { - put("status", track.toKitsuStatus()) + put("status", track.toApiStatus()) put("progress", track.last_chapter_read.toInt()) - put("ratingTwenty", track.toKitsuScore()) + put("ratingTwenty", track.toApiScore()) put("startedAt", KitsuDateHelper.convert(track.started_reading_date)) put("finishedAt", KitsuDateHelper.convert(track.finished_reading_date)) } } } - with(json) { - authClient.newCall( - Request.Builder() - .url("${BASE_URL}library-entries/${track.remote_id}") - .headers( - headersOf( - "Content-Type", - "application/vnd.api+json", - ), - ) - .patch( - data.toString().toRequestBody("application/vnd.api+json".toMediaType()), - ) - .build(), - ) - .awaitSuccess() - .parseAs() - .let { - track - } - } + authClient.newCall( + Request.Builder() + .url("${BASE_URL}library-entries/${track.remote_id}") + .headers( + headersOf("Content-Type", VND_API_JSON), + ) + .patch(data.toString().toRequestBody(VND_JSON_MEDIA_TYPE)) + .build(), + ) + .awaitSuccess() + + track } } suspend fun removeLibManga(track: DomainTrack) { withIOContext { - authClient - .newCall( - DELETE( - "${BASE_URL}library-entries/${track.remoteId}", - headers = headersOf( - "Content-Type", - "application/vnd.api+json", - ), - ), - ) + authClient.newCall( + DELETE( + "${BASE_URL}library-entries/${track.remoteId}", + headers = headersOf("Content-Type", VND_API_JSON), + ), + ) .awaitSuccess() } } + suspend fun search(query: String): List { return withIOContext { with(json) { authClient.newCall(GET(ALGOLIA_KEY_URL)) .awaitSuccess() - .parseAs() + .parseAs() .let { - val key = it["media"]!!.jsonObject["key"]!!.jsonPrimitive.content - algoliaSearch(key, query) + algoliaSearch(it.media.key, query) } } } @@ -174,13 +158,10 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor) ), ) .awaitSuccess() - .parseAs() - .let { - it["hits"]!!.jsonArray - .map { KitsuSearchManga(it.jsonObject) } - .filter { it.subType != "novel" } - .map { it.toTrack() } - } + .parseAs() + .hits + .filter { it.subtype != "novel" } + .map { it.toTrack() } } } } @@ -194,12 +175,10 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor) with(json) { authClient.newCall(GET(url.toString())) .awaitSuccess() - .parseAs() + .parseAs() .let { - val data = it["data"]!!.jsonArray - if (data.size > 0) { - val manga = it["included"]!!.jsonArray[0].jsonObject - KitsuLibManga(data[0].jsonObject, manga).toTrack() + if (it.data.isNotEmpty() && it.included.isNotEmpty()) { + it.firstToTrack() } else { null } @@ -217,12 +196,10 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor) with(json) { authClient.newCall(GET(url.toString())) .awaitSuccess() - .parseAs() + .parseAs() .let { - val data = it["data"]!!.jsonArray - if (data.size > 0) { - val manga = it["included"]!!.jsonArray[0].jsonObject - KitsuLibManga(data[0].jsonObject, manga).toTrack() + if (it.data.isNotEmpty() && it.included.isNotEmpty()) { + it.firstToTrack() } else { throw Exception("Could not find manga") } @@ -231,7 +208,7 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor) } } - suspend fun login(username: String, password: String): OAuth { + suspend fun login(username: String, password: String): KitsuOAuth { return withIOContext { val formBody: RequestBody = FormBody.Builder() .add("username", username) @@ -256,10 +233,9 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor) with(json) { authClient.newCall(GET(url.toString())) .awaitSuccess() - .parseAs() - .let { - it["data"]!!.jsonArray[0].jsonObject["id"]!!.jsonPrimitive.content - } + .parseAs() + .data[0] + .id } } } @@ -279,6 +255,9 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor) "%5B%22synopsis%22%2C%22averageRating%22%2C%22canonicalTitle%22%2C%22chapterCount%22%2C%22" + "posterImage%22%2C%22startDate%22%2C%22subtype%22%2C%22endDate%22%2C%20%22id%22%5D" + private const val VND_API_JSON = "application/vnd.api+json" + private val VND_JSON_MEDIA_TYPE = VND_API_JSON.toMediaType() + fun mangaUrl(remoteId: Long): String { return BASE_MANGA_URL + remoteId } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuInterceptor.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuInterceptor.kt index da9aff7fc..0f7c1b7db 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuInterceptor.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuInterceptor.kt @@ -1,6 +1,8 @@ package eu.kanade.tachiyomi.data.track.kitsu import eu.kanade.tachiyomi.BuildConfig +import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuOAuth +import eu.kanade.tachiyomi.data.track.kitsu.dto.isExpired import kotlinx.serialization.json.Json import okhttp3.Interceptor import okhttp3.Response @@ -13,14 +15,14 @@ class KitsuInterceptor(private val kitsu: Kitsu) : Interceptor { /** * OAuth object used for authenticated requests. */ - private var oauth: OAuth? = kitsu.restoreToken() + private var oauth: KitsuOAuth? = kitsu.restoreToken() override fun intercept(chain: Interceptor.Chain): Response { val originalRequest = chain.request() val currAuth = oauth ?: throw Exception("Not authenticated with Kitsu") - val refreshToken = currAuth.refresh_token!! + val refreshToken = currAuth.refreshToken!! // Refresh access token if expired. if (currAuth.isExpired()) { @@ -34,7 +36,7 @@ class KitsuInterceptor(private val kitsu: Kitsu) : Interceptor { // 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})") .header("Accept", "application/vnd.api+json") .header("Content-Type", "application/vnd.api+json") @@ -43,7 +45,7 @@ class KitsuInterceptor(private val kitsu: Kitsu) : Interceptor { return chain.proceed(authRequest) } - fun newAuth(oauth: OAuth?) { + fun newAuth(oauth: KitsuOAuth?) { this.oauth = oauth kitsu.saveToken(oauth) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuModels.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuModels.kt deleted file mode 100644 index 752ef4e12..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuModels.kt +++ /dev/null @@ -1,121 +0,0 @@ -package eu.kanade.tachiyomi.data.track.kitsu - -import androidx.annotation.CallSuper -import eu.kanade.tachiyomi.data.database.models.Track -import eu.kanade.tachiyomi.data.track.TrackerManager -import eu.kanade.tachiyomi.data.track.model.TrackSearch -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.contentOrNull -import kotlinx.serialization.json.int -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive -import kotlinx.serialization.json.long -import kotlinx.serialization.json.longOrNull -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale - -class KitsuSearchManga(obj: JsonObject) { - val id = obj["id"]!!.jsonPrimitive.long - private val canonicalTitle = obj["canonicalTitle"]!!.jsonPrimitive.content - private val chapterCount = obj["chapterCount"]?.jsonPrimitive?.longOrNull - 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?.contentOrNull - private val rating = obj["averageRating"]?.jsonPrimitive?.contentOrNull?.toDoubleOrNull() - 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["endDate"]?.jsonPrimitive?.contentOrNull - - @CallSuper - fun toTrack() = TrackSearch.create(TrackerManager.KITSU).apply { - remote_id = this@KitsuSearchManga.id - title = canonicalTitle - total_chapters = chapterCount ?: 0 - cover_url = original ?: "" - summary = synopsis ?: "" - tracking_url = KitsuApi.mangaUrl(remote_id) - score = rating ?: -1.0 - publishing_status = if (endDate == null) { - "Publishing" - } else { - "Finished" - } - publishing_type = subType ?: "" - start_date = startDate ?: "" - } -} - -class KitsuLibManga(obj: JsonObject, manga: JsonObject) { - val id = manga["id"]!!.jsonPrimitive.int - private val canonicalTitle = manga["attributes"]!!.jsonObject["canonicalTitle"]!!.jsonPrimitive.content - private val chapterCount = manga["attributes"]!!.jsonObject["chapterCount"]?.jsonPrimitive?.longOrNull - 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.long - 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(TrackerManager.KITSU).apply { - remote_id = libraryId - title = canonicalTitle - total_chapters = chapterCount ?: 0 - cover_url = original - summary = synopsis - tracking_url = KitsuApi.mangaUrl(remote_id) - 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() / 2.0 } ?: 0.0 - last_chapter_read = progress.toDouble() - } - - private fun toTrackStatus() = when (status) { - "current" -> Kitsu.READING - "completed" -> Kitsu.COMPLETED - "on_hold" -> Kitsu.ON_HOLD - "dropped" -> Kitsu.DROPPED - "planned" -> Kitsu.PLAN_TO_READ - else -> throw Exception("Unknown status") - } -} - -@Serializable -data class OAuth( - val access_token: String, - val token_type: String, - val created_at: Long, - val expires_in: Long, - val refresh_token: String?, -) - -fun OAuth.isExpired() = (System.currentTimeMillis() / 1000) > (created_at + expires_in - 3600) - -fun Track.toKitsuStatus() = when (status) { - Kitsu.READING -> "current" - Kitsu.COMPLETED -> "completed" - Kitsu.ON_HOLD -> "on_hold" - Kitsu.DROPPED -> "dropped" - Kitsu.PLAN_TO_READ -> "planned" - else -> throw Exception("Unknown status") -} - -fun Track.toKitsuScore(): String? { - return if (score > 0) (score * 2).toInt().toString() else null -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuUtils.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuUtils.kt new file mode 100644 index 000000000..02a88e09c --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuUtils.kt @@ -0,0 +1,16 @@ +package eu.kanade.tachiyomi.data.track.kitsu + +import eu.kanade.tachiyomi.data.database.models.Track + +fun Track.toApiStatus() = when (status) { + Kitsu.READING -> "current" + Kitsu.COMPLETED -> "completed" + Kitsu.ON_HOLD -> "on_hold" + Kitsu.DROPPED -> "dropped" + Kitsu.PLAN_TO_READ -> "planned" + else -> throw Exception("Unknown status") +} + +fun Track.toApiScore(): String? { + return if (score > 0) (score * 2).toInt().toString() else null +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuAddManga.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuAddManga.kt new file mode 100644 index 000000000..9ddec35e9 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuAddManga.kt @@ -0,0 +1,13 @@ +package eu.kanade.tachiyomi.data.track.kitsu.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class KitsuAddMangaResult( + val data: KitsuAddMangaItem, +) + +@Serializable +data class KitsuAddMangaItem( + val id: Long, +) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuListSearch.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuListSearch.kt new file mode 100644 index 000000000..0a505d627 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuListSearch.kt @@ -0,0 +1,79 @@ +package eu.kanade.tachiyomi.data.track.kitsu.dto + +import eu.kanade.tachiyomi.data.track.TrackerManager +import eu.kanade.tachiyomi.data.track.kitsu.Kitsu +import eu.kanade.tachiyomi.data.track.kitsu.KitsuApi +import eu.kanade.tachiyomi.data.track.kitsu.KitsuDateHelper +import eu.kanade.tachiyomi.data.track.model.TrackSearch +import kotlinx.serialization.Serializable + +@Serializable +data class KitsuListSearchResult( + val data: List, + val included: List = emptyList(), +) { + fun firstToTrack(): TrackSearch { + require(data.isNotEmpty()) { "Missing User data from Kitsu" } + require(included.isNotEmpty()) { "Missing Manga data from Kitsu" } + + val userData = data[0] + val userDataAttrs = userData.attributes + val manga = included[0].attributes + + return TrackSearch.create(TrackerManager.KITSU).apply { + remote_id = userData.id + title = manga.canonicalTitle + total_chapters = manga.chapterCount ?: 0 + cover_url = manga.posterImage?.original ?: "" + summary = manga.synopsis + tracking_url = KitsuApi.mangaUrl(remote_id) + publishing_status = manga.status + publishing_type = manga.mangaType ?: "" + start_date = userDataAttrs.startedAt ?: "" + started_reading_date = KitsuDateHelper.parse(userDataAttrs.startedAt) + finished_reading_date = KitsuDateHelper.parse(userDataAttrs.finishedAt) + status = when (userDataAttrs.status) { + "current" -> Kitsu.READING + "completed" -> Kitsu.COMPLETED + "on_hold" -> Kitsu.ON_HOLD + "dropped" -> Kitsu.DROPPED + "planned" -> Kitsu.PLAN_TO_READ + else -> throw Exception("Unknown status") + } + score = userDataAttrs.ratingTwenty?.let { it.toInt() / 2.0 } ?: 0.0 + last_chapter_read = userDataAttrs.progress.toDouble() + } + } +} + +@Serializable +data class KitsuListSearchItemData( + val id: Long, + val attributes: KitsuListSearchItemDataAttributes, +) + +@Serializable +data class KitsuListSearchItemDataAttributes( + val status: String, + val startedAt: String?, + val finishedAt: String?, + val ratingTwenty: String?, + val progress: Int, +) + +@Serializable +data class KitsuListSearchItemIncluded( + val id: Long, + val attributes: KitsuListSearchItemIncludedAttributes, +) + +@Serializable +data class KitsuListSearchItemIncludedAttributes( + val canonicalTitle: String, + val chapterCount: Long?, + val mangaType: String?, + val posterImage: KitsuSearchItemCover?, + val synopsis: String, + val startDate: String?, + val status: String, +) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuOAuth.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuOAuth.kt new file mode 100644 index 000000000..c5cab234a --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuOAuth.kt @@ -0,0 +1,20 @@ +package eu.kanade.tachiyomi.data.track.kitsu.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class KitsuOAuth( + @SerialName("access_token") + val accessToken: String, + @SerialName("token_type") + val tokenType: String, + @SerialName("created_at") + val createdAt: Long, + @SerialName("expires_in") + val expiresIn: Long, + @SerialName("refresh_token") + val refreshToken: String?, +) + +fun KitsuOAuth.isExpired() = (System.currentTimeMillis() / 1000) > (createdAt + expiresIn - 3600) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuSearch.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuSearch.kt new file mode 100644 index 000000000..c0f941673 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuSearch.kt @@ -0,0 +1,55 @@ +package eu.kanade.tachiyomi.data.track.kitsu.dto + +import eu.kanade.tachiyomi.data.track.TrackerManager +import eu.kanade.tachiyomi.data.track.kitsu.KitsuApi +import eu.kanade.tachiyomi.data.track.model.TrackSearch +import kotlinx.serialization.Serializable +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +@Serializable +data class KitsuSearchResult( + val media: KitsuSearchResultData, +) + +@Serializable +data class KitsuSearchResultData( + val key: String, +) + +@Serializable +data class KitsuAlgoliaSearchResult( + val hits: List, +) + +@Serializable +data class KitsuAlgoliaSearchItem( + val id: Long, + val canonicalTitle: String, + val chapterCount: Long?, + val subtype: String?, + val posterImage: KitsuSearchItemCover?, + val synopsis: String?, + val averageRating: Double?, + val startDate: Long?, + val endDate: Long?, +) { + fun toTrack(): TrackSearch { + return TrackSearch.create(TrackerManager.KITSU).apply { + remote_id = this@KitsuAlgoliaSearchItem.id + title = canonicalTitle + total_chapters = chapterCount ?: 0 + cover_url = posterImage?.original ?: "" + summary = synopsis ?: "" + tracking_url = KitsuApi.mangaUrl(remote_id) + score = averageRating ?: -1.0 + publishing_status = if (endDate == null) "Publishing" else "Finished" + publishing_type = subtype ?: "" + start_date = startDate?.let { + val outputDf = SimpleDateFormat("yyyy-MM-dd", Locale.US) + outputDf.format(Date(it * 1000)) + } ?: "" + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuSearchItemCover.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuSearchItemCover.kt new file mode 100644 index 000000000..6c062bf52 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuSearchItemCover.kt @@ -0,0 +1,8 @@ +package eu.kanade.tachiyomi.data.track.kitsu.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class KitsuSearchItemCover( + val original: String?, +) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuUser.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuUser.kt new file mode 100644 index 000000000..d02337651 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuUser.kt @@ -0,0 +1,13 @@ +package eu.kanade.tachiyomi.data.track.kitsu.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class KitsuCurrentUserResult( + val data: List, +) + +@Serializable +data class KitsuUser( + val id: String, +) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/MangaUpdates.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/MangaUpdates.kt index 6219e728b..c47a799a7 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/MangaUpdates.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/MangaUpdates.kt @@ -6,8 +6,8 @@ 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.mangaupdates.dto.ListItem -import eu.kanade.tachiyomi.data.track.mangaupdates.dto.Rating +import eu.kanade.tachiyomi.data.track.mangaupdates.dto.MUListItem +import eu.kanade.tachiyomi.data.track.mangaupdates.dto.MURating import eu.kanade.tachiyomi.data.track.mangaupdates.dto.copyTo import eu.kanade.tachiyomi.data.track.mangaupdates.dto.toTrackSearch import eu.kanade.tachiyomi.data.track.model.TrackSearch @@ -106,7 +106,7 @@ class MangaUpdates(id: Long) : BaseTracker(id, "MangaUpdates"), DeletableTracker return track.copyFrom(series, rating) } - private fun Track.copyFrom(item: ListItem, rating: Rating?): Track = apply { + private fun Track.copyFrom(item: MUListItem, rating: MURating?): Track = apply { item.copyTo(this) score = rating?.rating ?: 0.0 } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/MangaUpdatesApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/MangaUpdatesApi.kt index 5da3b7222..6f4471df5 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/MangaUpdatesApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/MangaUpdatesApi.kt @@ -3,10 +3,12 @@ package eu.kanade.tachiyomi.data.track.mangaupdates import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.track.mangaupdates.MangaUpdates.Companion.READING_LIST import eu.kanade.tachiyomi.data.track.mangaupdates.MangaUpdates.Companion.WISH_LIST -import eu.kanade.tachiyomi.data.track.mangaupdates.dto.Context -import eu.kanade.tachiyomi.data.track.mangaupdates.dto.ListItem -import eu.kanade.tachiyomi.data.track.mangaupdates.dto.Rating -import eu.kanade.tachiyomi.data.track.mangaupdates.dto.Record +import eu.kanade.tachiyomi.data.track.mangaupdates.dto.MUContext +import eu.kanade.tachiyomi.data.track.mangaupdates.dto.MUListItem +import eu.kanade.tachiyomi.data.track.mangaupdates.dto.MULoginResponse +import eu.kanade.tachiyomi.data.track.mangaupdates.dto.MURating +import eu.kanade.tachiyomi.data.track.mangaupdates.dto.MURecord +import eu.kanade.tachiyomi.data.track.mangaupdates.dto.MUSearchResult import eu.kanade.tachiyomi.network.DELETE import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.POST @@ -14,21 +16,15 @@ import eu.kanade.tachiyomi.network.PUT import eu.kanade.tachiyomi.network.awaitSuccess import eu.kanade.tachiyomi.network.parseAs import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.add import kotlinx.serialization.json.addJsonObject import kotlinx.serialization.json.buildJsonArray import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.decodeFromJsonElement -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.put import kotlinx.serialization.json.putJsonObject -import logcat.LogPriority import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.RequestBody.Companion.toRequestBody -import tachiyomi.core.common.util.system.logcat import uy.kohesive.injekt.injectLazy import tachiyomi.domain.track.model.Track as DomainTrack @@ -38,20 +34,17 @@ class MangaUpdatesApi( ) { private val json: Json by injectLazy() - private val baseUrl = "https://api.mangaupdates.com" - private val contentType = "application/vnd.api+json".toMediaType() - private val authClient by lazy { client.newBuilder() .addInterceptor(interceptor) .build() } - suspend fun getSeriesListItem(track: Track): Pair { + suspend fun getSeriesListItem(track: Track): Pair { val listItem = with(json) { - authClient.newCall(GET("$baseUrl/v1/lists/series/${track.remote_id}")) + authClient.newCall(GET("$BASE_URL/v1/lists/series/${track.remote_id}")) .awaitSuccess() - .parseAs() + .parseAs() } val rating = getSeriesRating(track) @@ -71,8 +64,8 @@ class MangaUpdatesApi( } authClient.newCall( POST( - url = "$baseUrl/v1/lists/series", - body = body.toString().toRequestBody(contentType), + url = "$BASE_URL/v1/lists/series", + body = body.toString().toRequestBody(CONTENT_TYPE), ), ) .awaitSuccess() @@ -98,8 +91,8 @@ class MangaUpdatesApi( } authClient.newCall( POST( - url = "$baseUrl/v1/lists/series/update", - body = body.toString().toRequestBody(contentType), + url = "$BASE_URL/v1/lists/series/update", + body = body.toString().toRequestBody(CONTENT_TYPE), ), ) .awaitSuccess() @@ -113,19 +106,19 @@ class MangaUpdatesApi( } authClient.newCall( POST( - url = "$baseUrl/v1/lists/series/delete", - body = body.toString().toRequestBody(contentType), + url = "$BASE_URL/v1/lists/series/delete", + body = body.toString().toRequestBody(CONTENT_TYPE), ), ) .awaitSuccess() } - private suspend fun getSeriesRating(track: Track): Rating? { + private suspend fun getSeriesRating(track: Track): MURating? { return try { with(json) { - authClient.newCall(GET("$baseUrl/v1/series/${track.remote_id}/rating")) + authClient.newCall(GET("$BASE_URL/v1/series/${track.remote_id}/rating")) .awaitSuccess() - .parseAs() + .parseAs() } } catch (e: Exception) { null @@ -140,22 +133,20 @@ class MangaUpdatesApi( } authClient.newCall( PUT( - url = "$baseUrl/v1/series/${track.remote_id}/rating", - body = body.toString().toRequestBody(contentType), + url = "$BASE_URL/v1/series/${track.remote_id}/rating", + body = body.toString().toRequestBody(CONTENT_TYPE), ), ) .awaitSuccess() } else { authClient.newCall( - DELETE( - url = "$baseUrl/v1/series/${track.remote_id}/rating", - ), + DELETE(url = "$BASE_URL/v1/series/${track.remote_id}/rating"), ) .awaitSuccess() } } - suspend fun search(query: String): List { + suspend fun search(query: String): List { val body = buildJsonObject { put("search", query) put( @@ -166,25 +157,22 @@ class MangaUpdatesApi( }, ) } + return with(json) { client.newCall( POST( - url = "$baseUrl/v1/series/search", - body = body.toString().toRequestBody(contentType), + url = "$BASE_URL/v1/series/search", + body = body.toString().toRequestBody(CONTENT_TYPE), ), ) .awaitSuccess() - .parseAs() - .let { obj -> - obj["results"]?.jsonArray?.map { element -> - json.decodeFromJsonElement(element.jsonObject["record"]!!) - } - } - .orEmpty() + .parseAs() + .results + .map { it.record } } } - suspend fun authenticate(username: String, password: String): Context? { + suspend fun authenticate(username: String, password: String): MUContext? { val body = buildJsonObject { put("username", username) put("password", password) @@ -192,20 +180,19 @@ class MangaUpdatesApi( return with(json) { client.newCall( PUT( - url = "$baseUrl/v1/account/login", - body = body.toString().toRequestBody(contentType), + url = "$BASE_URL/v1/account/login", + body = body.toString().toRequestBody(CONTENT_TYPE), ), ) .awaitSuccess() - .parseAs() - .let { obj -> - try { - json.decodeFromJsonElement(obj["context"]!!) - } catch (e: Exception) { - logcat(LogPriority.ERROR, e) - null - } - } + .parseAs() + .context } } + + companion object { + private const val BASE_URL = "https://api.mangaupdates.com" + + private val CONTENT_TYPE = "application/vnd.api+json".toMediaType() + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/Context.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/MUContext.kt similarity index 91% rename from app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/Context.kt rename to app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/MUContext.kt index 77019cacd..688de0700 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/Context.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/MUContext.kt @@ -4,7 +4,7 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable -data class Context( +data class MUContext( @SerialName("session_token") val sessionToken: String, val uid: Long, diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/Image.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/MUImage.kt similarity index 78% rename from app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/Image.kt rename to app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/MUImage.kt index bed1f2657..0ef38ca24 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/Image.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/MUImage.kt @@ -3,8 +3,8 @@ package eu.kanade.tachiyomi.data.track.mangaupdates.dto import kotlinx.serialization.Serializable @Serializable -data class Image( - val url: Url? = null, +data class MUImage( + val url: MUUrl? = null, val height: Int? = null, val width: Int? = null, ) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/ListItem.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/MUListItem.kt similarity index 80% rename from app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/ListItem.kt rename to app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/MUListItem.kt index 15a551078..b406fd56e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/ListItem.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/MUListItem.kt @@ -6,15 +6,15 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable -data class ListItem( - val series: Series? = null, +data class MUListItem( + val series: MUSeries? = null, @SerialName("list_id") val listId: Long? = null, - val status: Status? = null, + val status: MUStatus? = null, val priority: Int? = null, ) -fun ListItem.copyTo(track: Track): Track { +fun MUListItem.copyTo(track: Track): Track { return track.apply { this.status = listId ?: READING_LIST this.last_chapter_read = this@copyTo.status?.chapter?.toDouble() ?: 0.0 diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/MULoginResponse.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/MULoginResponse.kt new file mode 100644 index 000000000..6b2a60cc7 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/MULoginResponse.kt @@ -0,0 +1,8 @@ +package eu.kanade.tachiyomi.data.track.mangaupdates.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class MULoginResponse( + val context: MUContext, +) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/Rating.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/MURating.kt similarity index 80% rename from app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/Rating.kt rename to app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/MURating.kt index 89a55b413..eeca1bbb7 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/Rating.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/MURating.kt @@ -4,11 +4,11 @@ import eu.kanade.tachiyomi.data.database.models.Track import kotlinx.serialization.Serializable @Serializable -data class Rating( +data class MURating( val rating: Double? = null, ) -fun Rating.copyTo(track: Track): Track { +fun MURating.copyTo(track: Track): Track { return track.apply { this.score = rating ?: 0.0 } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/Record.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/MURecord.kt similarity index 92% rename from app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/Record.kt rename to app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/MURecord.kt index 4b66273e8..88a560b9d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/Record.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/MURecord.kt @@ -6,13 +6,13 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable -data class Record( +data class MURecord( @SerialName("series_id") val seriesId: Long? = null, val title: String? = null, val url: String? = null, val description: String? = null, - val image: Image? = null, + val image: MUImage? = null, val type: String? = null, val year: String? = null, @SerialName("bayesian_rating") @@ -23,7 +23,7 @@ data class Record( val latestChapter: Int? = null, ) -fun Record.toTrackSearch(id: Long): TrackSearch { +fun MURecord.toTrackSearch(id: Long): TrackSearch { return TrackSearch.create(id).apply { remote_id = this@toTrackSearch.seriesId ?: 0L title = this@toTrackSearch.title?.htmlDecode() ?: "" diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/MUSearch.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/MUSearch.kt new file mode 100644 index 000000000..3e1771b0b --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/MUSearch.kt @@ -0,0 +1,13 @@ +package eu.kanade.tachiyomi.data.track.mangaupdates.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class MUSearchResult( + val results: List, +) + +@Serializable +data class MUSearchResultItem( + val record: MURecord, +) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/Series.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/MUSeries.kt similarity index 89% rename from app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/Series.kt rename to app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/MUSeries.kt index 261c85737..fa8e3feac 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/Series.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/MUSeries.kt @@ -3,7 +3,7 @@ package eu.kanade.tachiyomi.data.track.mangaupdates.dto import kotlinx.serialization.Serializable @Serializable -data class Series( +data class MUSeries( val id: Long? = null, val title: String? = null, ) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/Status.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/MUStatus.kt similarity index 89% rename from app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/Status.kt rename to app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/MUStatus.kt index 7320ac2e3..99bf5a7af 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/Status.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/MUStatus.kt @@ -3,7 +3,7 @@ package eu.kanade.tachiyomi.data.track.mangaupdates.dto import kotlinx.serialization.Serializable @Serializable -data class Status( +data class MUStatus( val volume: Int? = null, val chapter: Int? = null, ) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/Url.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/MUUrl.kt similarity index 90% rename from app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/Url.kt rename to app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/MUUrl.kt index f295d3bdc..3e969e3b3 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/Url.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/MUUrl.kt @@ -3,7 +3,7 @@ package eu.kanade.tachiyomi.data.track.mangaupdates.dto import kotlinx.serialization.Serializable @Serializable -data class Url( +data class MUUrl( val original: String? = null, val thumb: String? = null, ) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeList.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeList.kt index 33daee1a9..ed744a880 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeList.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeList.kt @@ -7,6 +7,7 @@ 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.model.TrackSearch +import eu.kanade.tachiyomi.data.track.myanimelist.dto.MALOAuth import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.serialization.encodeToString @@ -143,7 +144,7 @@ class MyAnimeList(id: Long) : BaseTracker(id, "MyAnimeList"), DeletableTracker { val oauth = api.getAccessToken(authCode) interceptor.setAuth(oauth) val username = api.getCurrentUser() - saveCredentials(username, oauth.access_token) + saveCredentials(username, oauth.accessToken) } catch (e: Throwable) { logout() } @@ -163,13 +164,13 @@ class MyAnimeList(id: Long) : BaseTracker(id, "MyAnimeList"), DeletableTracker { trackPreferences.trackAuthExpired(this).set(true) } - fun saveOAuth(oAuth: OAuth?) { + fun saveOAuth(oAuth: MALOAuth?) { trackPreferences.trackToken(this).set(json.encodeToString(oAuth)) } - fun loadOAuth(): OAuth? { + fun loadOAuth(): MALOAuth? { return try { - json.decodeFromString(trackPreferences.trackToken(this).get()) + json.decodeFromString(trackPreferences.trackToken(this).get()) } catch (e: Exception) { null } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListApi.kt index 097e36a5d..33435d4c3 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListApi.kt @@ -4,6 +4,13 @@ import android.net.Uri import androidx.core.net.toUri import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.track.model.TrackSearch +import eu.kanade.tachiyomi.data.track.myanimelist.dto.MALListItem +import eu.kanade.tachiyomi.data.track.myanimelist.dto.MALListItemStatus +import eu.kanade.tachiyomi.data.track.myanimelist.dto.MALManga +import eu.kanade.tachiyomi.data.track.myanimelist.dto.MALOAuth +import eu.kanade.tachiyomi.data.track.myanimelist.dto.MALSearchResult +import eu.kanade.tachiyomi.data.track.myanimelist.dto.MALUser +import eu.kanade.tachiyomi.data.track.myanimelist.dto.MALUserSearchResult import eu.kanade.tachiyomi.network.DELETE import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.POST @@ -13,16 +20,6 @@ import eu.kanade.tachiyomi.util.PkceUtil import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.boolean -import kotlinx.serialization.json.contentOrNull -import kotlinx.serialization.json.double -import kotlinx.serialization.json.doubleOrNull -import kotlinx.serialization.json.int -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive -import kotlinx.serialization.json.long import okhttp3.FormBody import okhttp3.Headers import okhttp3.OkHttpClient @@ -44,7 +41,7 @@ class MyAnimeListApi( private val authClient = client.newBuilder().addInterceptor(interceptor).build() - suspend fun getAccessToken(authCode: String): OAuth { + suspend fun getAccessToken(authCode: String): MALOAuth { return withIOContext { val formBody: RequestBody = FormBody.Builder() .add("client_id", CLIENT_ID) @@ -69,8 +66,8 @@ class MyAnimeListApi( with(json) { authClient.newCall(request) .awaitSuccess() - .parseAs() - .let { it["name"]!!.jsonPrimitive.content } + .parseAs() + .name } } } @@ -85,17 +82,11 @@ class MyAnimeListApi( with(json) { authClient.newCall(GET(url.toString())) .awaitSuccess() - .parseAs() - .let { - it["data"]!!.jsonArray - .map { data -> data.jsonObject["node"]!!.jsonObject } - .map { node -> - val id = node["id"]!!.jsonPrimitive.int - async { getMangaDetails(id) } - } - .awaitAll() - .filter { trackSearch -> !trackSearch.publishing_type.contains("novel") } - } + .parseAs() + .data + .map { async { getMangaDetails(it.node.id) } } + .awaitAll() + .filter { !it.publishing_type.contains("novel") } } } } @@ -112,24 +103,19 @@ class MyAnimeListApi( with(json) { authClient.newCall(GET(url.toString())) .awaitSuccess() - .parseAs() + .parseAs() .let { - val obj = it.jsonObject TrackSearch.create(trackId).apply { - remote_id = obj["id"]!!.jsonPrimitive.long - title = obj["title"]!!.jsonPrimitive.content - summary = obj["synopsis"]?.jsonPrimitive?.content ?: "" - total_chapters = obj["num_chapters"]!!.jsonPrimitive.long - score = obj["mean"]?.jsonPrimitive?.doubleOrNull ?: -1.0 - cover_url = - obj["main_picture"]?.jsonObject?.get("large")?.jsonPrimitive?.content - ?: "" + remote_id = it.id + title = it.title + summary = it.synopsis + total_chapters = it.numChapters + score = it.mean + cover_url = it.covers.large tracking_url = "https://myanimelist.net/manga/$remote_id" - publishing_status = - obj["status"]!!.jsonPrimitive.content.replace("_", " ") - publishing_type = - obj["media_type"]!!.jsonPrimitive.content.replace("_", " ") - start_date = obj["start_date"]?.jsonPrimitive?.content ?: "" + publishing_status = it.status.replace("_", " ") + publishing_type = it.mediaType.replace("_", " ") + start_date = it.startDate ?: "" } } } @@ -157,7 +143,7 @@ class MyAnimeListApi( with(json) { authClient.newCall(request) .awaitSuccess() - .parseAs() + .parseAs() .let { parseMangaItem(it, track) } } } @@ -180,12 +166,10 @@ class MyAnimeListApi( with(json) { authClient.newCall(GET(uri.toString())) .awaitSuccess() - .parseAs() - .let { obj -> - track.total_chapters = obj["num_chapters"]!!.jsonPrimitive.long - obj.jsonObject["my_list_status"]?.jsonObject?.let { - parseMangaItem(it, track) - } + .parseAs() + .let { item -> + track.total_chapters = item.numChapters + item.myListStatus?.let { parseMangaItem(it, track) } } } } @@ -193,24 +177,15 @@ class MyAnimeListApi( suspend fun findListItems(query: String, offset: Int = 0): List { return withIOContext { - val json = getListPage(offset) - val obj = json.jsonObject + val myListSearchResult = getListPage(offset) - val matches = obj["data"]!!.jsonArray - .filter { - it.jsonObject["node"]!!.jsonObject["title"]!!.jsonPrimitive.content.contains( - query, - ignoreCase = true, - ) - } - .map { - val id = it.jsonObject["node"]!!.jsonObject["id"]!!.jsonPrimitive.int - async { getMangaDetails(id) } - } + val matches = myListSearchResult.data + .filter { it.node.title.contains(query, ignoreCase = true) } + .map { async { getMangaDetails(it.node.id) } } .awaitAll() // Check next page if there's more - if (!obj["paging"]!!.jsonObject["next"]?.jsonPrimitive?.contentOrNull.isNullOrBlank()) { + if (!myListSearchResult.paging.next.isNullOrBlank()) { matches + findListItems(query, offset + LIST_PAGINATION_AMOUNT) } else { matches @@ -218,7 +193,7 @@ class MyAnimeListApi( } } - private suspend fun getListPage(offset: Int): JsonObject { + private suspend fun getListPage(offset: Int): MALUserSearchResult { return withIOContext { val urlBuilder = "$BASE_API_URL/users/@me/mangalist".toUri().buildUpon() .appendQueryParameter("fields", "list_status{start_date,finish_date}") @@ -239,19 +214,14 @@ class MyAnimeListApi( } } - private fun parseMangaItem(response: JsonObject, track: Track): Track { - val obj = response.jsonObject + private fun parseMangaItem(listStatus: MALListItemStatus, track: Track): Track { return track.apply { - val isRereading = obj["is_rereading"]!!.jsonPrimitive.boolean - status = if (isRereading) MyAnimeList.REREADING else getStatus(obj["status"]?.jsonPrimitive?.content) - last_chapter_read = obj["num_chapters_read"]!!.jsonPrimitive.double - score = obj["score"]!!.jsonPrimitive.int.toDouble() - obj["start_date"]?.let { - started_reading_date = parseDate(it.jsonPrimitive.content) - } - obj["finish_date"]?.let { - finished_reading_date = parseDate(it.jsonPrimitive.content) - } + val isRereading = listStatus.isRereading + status = if (isRereading) MyAnimeList.REREADING else getStatus(listStatus.status) + last_chapter_read = listStatus.numChaptersRead + score = listStatus.score.toDouble() + listStatus.startDate?.let { started_reading_date = parseDate(it) } + listStatus.finishDate?.let { finished_reading_date = parseDate(it) } } } @@ -292,10 +262,10 @@ class MyAnimeListApi( .appendPath("my_list_status") .build() - fun refreshTokenRequest(oauth: OAuth): Request { + fun refreshTokenRequest(oauth: MALOAuth): Request { val formBody: RequestBody = FormBody.Builder() .add("client_id", CLIENT_ID) - .add("refresh_token", oauth.refresh_token) + .add("refresh_token", oauth.refreshToken) .add("grant_type", "refresh_token") .build() @@ -303,7 +273,7 @@ class MyAnimeListApi( // request is called by the interceptor itself so it doesn't reach // the part where the token is added automatically. val headers = Headers.Builder() - .add("Authorization", "Bearer ${oauth.access_token}") + .add("Authorization", "Bearer ${oauth.accessToken}") .build() return POST("$BASE_OAUTH_URL/token", body = formBody, headers = headers) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListInterceptor.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListInterceptor.kt index d67c2cabe..92eca9ca2 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListInterceptor.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListInterceptor.kt @@ -1,5 +1,6 @@ package eu.kanade.tachiyomi.data.track.myanimelist +import eu.kanade.tachiyomi.data.track.myanimelist.dto.MALOAuth import eu.kanade.tachiyomi.network.parseAs import kotlinx.serialization.json.Json import okhttp3.Interceptor @@ -11,7 +12,7 @@ class MyAnimeListInterceptor(private val myanimelist: MyAnimeList) : Interceptor private val json: Json by injectLazy() - private var oauth: OAuth? = myanimelist.loadOAuth() + private var oauth: MALOAuth? = myanimelist.loadOAuth() private val tokenExpired get() = myanimelist.getIfAuthExpired() override fun intercept(chain: Interceptor.Chain): Response { @@ -30,7 +31,7 @@ class MyAnimeListInterceptor(private val myanimelist: MyAnimeList) : Interceptor // Add the authorization header to the original request val authRequest = originalRequest.newBuilder() - .addHeader("Authorization", "Bearer ${oauth!!.access_token}") + .addHeader("Authorization", "Bearer ${oauth!!.accessToken}") // TODO(antsy): Add back custom user agent when they stop blocking us for no apparent reason // .header("User-Agent", "Mihon v${BuildConfig.VERSION_NAME} (${BuildConfig.APPLICATION_ID})") .build() @@ -42,12 +43,12 @@ class MyAnimeListInterceptor(private val myanimelist: MyAnimeList) : Interceptor * Called when the user authenticates with MyAnimeList for the first time. Sets the refresh token * and the oauth object. */ - fun setAuth(oauth: OAuth?) { + fun setAuth(oauth: MALOAuth?) { this.oauth = oauth myanimelist.saveOAuth(oauth) } - private fun refreshToken(chain: Interceptor.Chain): OAuth = synchronized(this) { + private fun refreshToken(chain: Interceptor.Chain): MALOAuth = synchronized(this) { if (tokenExpired) throw MALTokenExpired() oauth?.takeUnless { it.isExpired() }?.let { return@synchronized it } @@ -64,7 +65,7 @@ class MyAnimeListInterceptor(private val myanimelist: MyAnimeList) : Interceptor return runCatching { if (response.isSuccessful) { - with(json) { response.parseAs() } + with(json) { response.parseAs() } } else { response.close() null diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListModels.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListUtils.kt similarity index 61% rename from app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListModels.kt rename to app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListUtils.kt index 1ae02142f..593111a7d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListModels.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListUtils.kt @@ -1,20 +1,6 @@ package eu.kanade.tachiyomi.data.track.myanimelist import eu.kanade.tachiyomi.data.database.models.Track -import kotlinx.serialization.Serializable - -@Serializable -data class OAuth( - val token_type: String, - val refresh_token: String, - val access_token: String, - val expires_in: Long, - val created_at: Long = System.currentTimeMillis(), -) { - // Assumes expired a minute earlier - private val adjustedExpiresIn: Long = (expires_in - 60) * 1000 - fun isExpired() = created_at + adjustedExpiresIn < System.currentTimeMillis() -} fun Track.toMyAnimeListStatus() = when (status) { MyAnimeList.READING -> "reading" diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/dto/MALList.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/dto/MALList.kt new file mode 100644 index 000000000..69cd9bcf1 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/dto/MALList.kt @@ -0,0 +1,26 @@ +package eu.kanade.tachiyomi.data.track.myanimelist.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class MALListItem( + @SerialName("num_chapters") + val numChapters: Long, + @SerialName("my_list_status") + val myListStatus: MALListItemStatus?, +) + +@Serializable +data class MALListItemStatus( + @SerialName("is_rereading") + val isRereading: Boolean, + val status: String, + @SerialName("num_chapters_read") + val numChaptersRead: Double, + val score: Int, + @SerialName("start_date") + val startDate: String?, + @SerialName("finish_date") + val finishDate: String?, +) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/dto/MALManga.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/dto/MALManga.kt new file mode 100644 index 000000000..c4ab92ee9 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/dto/MALManga.kt @@ -0,0 +1,26 @@ +package eu.kanade.tachiyomi.data.track.myanimelist.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class MALManga( + val id: Long, + val title: String, + val synopsis: String = "", + @SerialName("num_chapters") + val numChapters: Long, + val mean: Double = -1.0, + @SerialName("main_picture") + val covers: MALMangaCovers, + val status: String, + @SerialName("media_type") + val mediaType: String, + @SerialName("start_date") + val startDate: String?, +) + +@Serializable +data class MALMangaCovers( + val large: String = "", +) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/dto/MALOAuth.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/dto/MALOAuth.kt new file mode 100644 index 000000000..2f3a5f8e8 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/dto/MALOAuth.kt @@ -0,0 +1,23 @@ +package eu.kanade.tachiyomi.data.track.myanimelist.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class MALOAuth( + @SerialName("token_type") + val tokenType: String, + @SerialName("refresh_token") + val refreshToken: String, + @SerialName("access_token") + val accessToken: String, + @SerialName("expires_in") + val expiresIn: Long, + @SerialName("created_at") + val createdAt: Long = System.currentTimeMillis(), +) { + // Assumes expired a minute earlier + private val adjustedExpiresIn: Long = (expiresIn - 60) * 1000 + + fun isExpired() = createdAt + adjustedExpiresIn < System.currentTimeMillis() +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/dto/MALSearch.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/dto/MALSearch.kt new file mode 100644 index 000000000..51ef2a6a4 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/dto/MALSearch.kt @@ -0,0 +1,18 @@ +package eu.kanade.tachiyomi.data.track.myanimelist.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class MALSearchResult( + val data: List, +) + +@Serializable +data class MALSearchResultNode( + val node: MALSearchResultItem, +) + +@Serializable +data class MALSearchResultItem( + val id: Int, +) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/dto/MALUser.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/dto/MALUser.kt new file mode 100644 index 000000000..a59974abd --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/dto/MALUser.kt @@ -0,0 +1,8 @@ +package eu.kanade.tachiyomi.data.track.myanimelist.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class MALUser( + val name: String, +) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/dto/MALUserListSearch.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/dto/MALUserListSearch.kt new file mode 100644 index 000000000..fad099a24 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/dto/MALUserListSearch.kt @@ -0,0 +1,25 @@ +package eu.kanade.tachiyomi.data.track.myanimelist.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class MALUserSearchResult( + val data: List, + val paging: MALUserSearchPaging, +) + +@Serializable +data class MALUserSearchItem( + val node: MALUserSearchItemNode, +) + +@Serializable +data class MALUserSearchPaging( + val next: String?, +) + +@Serializable +data class MALUserSearchItemNode( + val id: Int, + val title: String, +) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/Shikimori.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/Shikimori.kt index 118d005c1..f04167151 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/Shikimori.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/Shikimori.kt @@ -7,6 +7,7 @@ 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.model.TrackSearch +import eu.kanade.tachiyomi.data.track.shikimori.dto.SMOAuth import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.serialization.encodeToString @@ -93,7 +94,7 @@ class Shikimori(id: Long) : BaseTracker(id, "Shikimori"), DeletableTracker { track.library_id = remoteTrack.library_id track.copyPersonalFrom(remoteTrack) track.total_chapters = remoteTrack.total_chapters - } + } ?: throw Exception("Could not find manga") return track } @@ -128,19 +129,19 @@ class Shikimori(id: Long) : BaseTracker(id, "Shikimori"), DeletableTracker { val oauth = api.accessToken(code) interceptor.newAuth(oauth) val user = api.getCurrentUser() - saveCredentials(user.toString(), oauth.access_token) + saveCredentials(user.toString(), oauth.accessToken) } catch (e: Throwable) { logout() } } - fun saveToken(oauth: OAuth?) { + fun saveToken(oauth: SMOAuth?) { trackPreferences.trackToken(this).set(json.encodeToString(oauth)) } - fun restoreToken(): OAuth? { + fun restoreToken(): SMOAuth? { return try { - json.decodeFromString(trackPreferences.trackToken(this).get()) + json.decodeFromString(trackPreferences.trackToken(this).get()) } catch (e: Exception) { null } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/ShikimoriApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/ShikimoriApi.kt index 08a695930..b11fc9877 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/ShikimoriApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/ShikimoriApi.kt @@ -4,6 +4,11 @@ import android.net.Uri import androidx.core.net.toUri import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.track.model.TrackSearch +import eu.kanade.tachiyomi.data.track.shikimori.dto.SMAddMangaResponse +import eu.kanade.tachiyomi.data.track.shikimori.dto.SMManga +import eu.kanade.tachiyomi.data.track.shikimori.dto.SMOAuth +import eu.kanade.tachiyomi.data.track.shikimori.dto.SMUser +import eu.kanade.tachiyomi.data.track.shikimori.dto.SMUserListEntry import eu.kanade.tachiyomi.network.DELETE import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.POST @@ -11,15 +16,7 @@ import eu.kanade.tachiyomi.network.awaitSuccess import eu.kanade.tachiyomi.network.jsonMime import eu.kanade.tachiyomi.network.parseAs import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.contentOrNull -import kotlinx.serialization.json.double -import kotlinx.serialization.json.int -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive -import kotlinx.serialization.json.long import kotlinx.serialization.json.put import kotlinx.serialization.json.putJsonObject import okhttp3.FormBody @@ -58,10 +55,10 @@ class ShikimoriApi( body = payload.toString().toRequestBody(jsonMime), ), ).awaitSuccess() - .parseAs() + .parseAs() .let { // save id of the entry for possible future delete request - track.library_id = it["id"]!!.jsonPrimitive.long + track.library_id = it.id } track } @@ -88,53 +85,21 @@ class ShikimoriApi( with(json) { authClient.newCall(GET(url.toString())) .awaitSuccess() - .parseAs() - .let { response -> - response.map { - jsonToSearch(it.jsonObject) - } - } + .parseAs>() + .map { it.toTrack(trackId) } } } } - private fun jsonToSearch(obj: JsonObject): TrackSearch { - return TrackSearch.create(trackId).apply { - remote_id = obj["id"]!!.jsonPrimitive.long - title = obj["name"]!!.jsonPrimitive.content - total_chapters = obj["chapters"]!!.jsonPrimitive.long - cover_url = BASE_URL + obj["image"]!!.jsonObject["preview"]!!.jsonPrimitive.content - summary = "" - score = obj["score"]!!.jsonPrimitive.double - tracking_url = BASE_URL + 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(trackId).apply { - title = mangas["name"]!!.jsonPrimitive.content - remote_id = obj["id"]!!.jsonPrimitive.long - total_chapters = mangas["chapters"]!!.jsonPrimitive.long - library_id = obj["id"]!!.jsonPrimitive.long - last_chapter_read = obj["chapters"]!!.jsonPrimitive.double - score = obj["score"]!!.jsonPrimitive.int.toDouble() - status = toTrackStatus(obj["status"]!!.jsonPrimitive.content) - tracking_url = BASE_URL + mangas["url"]!!.jsonPrimitive.content - } - } - suspend fun findLibManga(track: Track, userId: String): Track? { return withIOContext { val urlMangas = "$API_URL/mangas".toUri().buildUpon() .appendPath(track.remote_id.toString()) .build() - val mangas = with(json) { + val manga = with(json) { authClient.newCall(GET(urlMangas.toString())) .awaitSuccess() - .parseAs() + .parseAs() } val url = "$API_URL/v2/user_rates".toUri().buildUpon() @@ -145,15 +110,14 @@ class ShikimoriApi( with(json) { authClient.newCall(GET(url.toString())) .awaitSuccess() - .parseAs() - .let { response -> - if (response.size > 1) { - throw Exception("Too much mangas in response") + .parseAs>() + .let { entries -> + if (entries.size > 1) { + throw Exception("Too many manga in response") } - val entry = response.map { - jsonToTrack(it.jsonObject, mangas) - } - entry.firstOrNull() + entries + .map { it.toTrack(trackId, manga) } + .firstOrNull() } } } @@ -163,14 +127,12 @@ class ShikimoriApi( return with(json) { authClient.newCall(GET("$API_URL/users/whoami")) .awaitSuccess() - .parseAs() - .let { - it["id"]!!.jsonPrimitive.int - } + .parseAs() + .id } } - suspend fun accessToken(code: String): OAuth { + suspend fun accessToken(code: String): SMOAuth { return withIOContext { with(json) { client.newCall(accessTokenRequest(code)) @@ -192,16 +154,16 @@ class ShikimoriApi( ) companion object { - private const val CLIENT_ID = "PB9dq8DzI405s7wdtwTdirYqHiyVMh--djnP7lBUqSA" - private const val CLIENT_SECRET = "NajpZcOBKB9sJtgNcejf8OB9jBN1OYYoo-k4h2WWZus" - - private const val BASE_URL = "https://shikimori.one" + const val BASE_URL = "https://shikimori.one" private const val API_URL = "$BASE_URL/api" private const val OAUTH_URL = "$BASE_URL/oauth/token" private const val LOGIN_URL = "$BASE_URL/oauth/authorize" private const val REDIRECT_URL = "mihon://shikimori-auth" + private const val CLIENT_ID = "PB9dq8DzI405s7wdtwTdirYqHiyVMh--djnP7lBUqSA" + private const val CLIENT_SECRET = "NajpZcOBKB9sJtgNcejf8OB9jBN1OYYoo-k4h2WWZus" + fun authUrl(): Uri = LOGIN_URL.toUri().buildUpon() .appendQueryParameter("client_id", CLIENT_ID) .appendQueryParameter("redirect_uri", REDIRECT_URL) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/ShikimoriInterceptor.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/ShikimoriInterceptor.kt index aa2d4247a..baa65025d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/ShikimoriInterceptor.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/ShikimoriInterceptor.kt @@ -1,6 +1,8 @@ package eu.kanade.tachiyomi.data.track.shikimori import eu.kanade.tachiyomi.BuildConfig +import eu.kanade.tachiyomi.data.track.shikimori.dto.SMOAuth +import eu.kanade.tachiyomi.data.track.shikimori.dto.isExpired import kotlinx.serialization.json.Json import okhttp3.Interceptor import okhttp3.Response @@ -13,34 +15,34 @@ class ShikimoriInterceptor(private val shikimori: Shikimori) : Interceptor { /** * OAuth object used for authenticated requests. */ - private var oauth: OAuth? = shikimori.restoreToken() + private var oauth: SMOAuth? = shikimori.restoreToken() override fun intercept(chain: Interceptor.Chain): Response { val originalRequest = chain.request() val currAuth = oauth ?: throw Exception("Not authenticated with Shikimori") - val refreshToken = currAuth.refresh_token!! + val refreshToken = currAuth.refreshToken!! // Refresh access token if expired. if (currAuth.isExpired()) { val response = chain.proceed(ShikimoriApi.refreshTokenRequest(refreshToken)) if (response.isSuccessful) { - newAuth(json.decodeFromString(response.body.string())) + newAuth(json.decodeFromString(response.body.string())) } else { response.close() } } // 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() return chain.proceed(authRequest) } - fun newAuth(oauth: OAuth?) { + fun newAuth(oauth: SMOAuth?) { this.oauth = oauth shikimori.saveToken(oauth) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/ShikimoriModels.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/ShikimoriUtils.kt similarity index 69% rename from app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/ShikimoriModels.kt rename to app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/ShikimoriUtils.kt index 5a28eceb4..fe59f6e80 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/ShikimoriModels.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/ShikimoriUtils.kt @@ -1,19 +1,6 @@ package eu.kanade.tachiyomi.data.track.shikimori import eu.kanade.tachiyomi.data.database.models.Track -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 OAuth.isExpired() = (System.currentTimeMillis() / 1000) > (created_at + expires_in - 3600) fun Track.toShikimoriStatus() = when (status) { Shikimori.READING -> "watching" diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/dto/SMAddMangaResponse.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/dto/SMAddMangaResponse.kt new file mode 100644 index 000000000..be5ee9f58 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/dto/SMAddMangaResponse.kt @@ -0,0 +1,8 @@ +package eu.kanade.tachiyomi.data.track.shikimori.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class SMAddMangaResponse( + val id: Long, +) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/dto/SMManga.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/dto/SMManga.kt new file mode 100644 index 000000000..00b7754a2 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/dto/SMManga.kt @@ -0,0 +1,40 @@ +package eu.kanade.tachiyomi.data.track.shikimori.dto + +import eu.kanade.tachiyomi.data.track.model.TrackSearch +import eu.kanade.tachiyomi.data.track.shikimori.ShikimoriApi +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class SMManga( + val id: Long, + val name: String, + val chapters: Long, + val image: SUMangaCover, + val score: Double, + val url: String, + val status: String, + val kind: String, + @SerialName("aired_on") + val airedOn: String?, +) { + fun toTrack(trackId: Long): TrackSearch { + return TrackSearch.create(trackId).apply { + remote_id = this@SMManga.id + title = name + total_chapters = chapters + cover_url = ShikimoriApi.BASE_URL + image.preview + summary = "" + score = this@SMManga.score + tracking_url = ShikimoriApi.BASE_URL + url + publishing_status = this@SMManga.status + publishing_type = kind + start_date = airedOn ?: "" + } + } +} + +@Serializable +data class SUMangaCover( + val preview: String, +) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/dto/SMOAuth.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/dto/SMOAuth.kt new file mode 100644 index 000000000..e04104801 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/dto/SMOAuth.kt @@ -0,0 +1,21 @@ +package eu.kanade.tachiyomi.data.track.shikimori.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class SMOAuth( + @SerialName("access_token") + val accessToken: String, + @SerialName("token_type") + val tokenType: String, + @SerialName("created_at") + val createdAt: Long, + @SerialName("expires_in") + val expiresIn: Long, + @SerialName("refresh_token") + val refreshToken: String?, +) + +// Access token lives 1 day +fun SMOAuth.isExpired() = (System.currentTimeMillis() / 1000) > (createdAt + expiresIn - 3600) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/dto/SMUser.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/dto/SMUser.kt new file mode 100644 index 000000000..1b9ed6cdb --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/dto/SMUser.kt @@ -0,0 +1,8 @@ +package eu.kanade.tachiyomi.data.track.shikimori.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class SMUser( + val id: Int, +) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/dto/SMUserListEntry.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/dto/SMUserListEntry.kt new file mode 100644 index 000000000..e5e160560 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/dto/SMUserListEntry.kt @@ -0,0 +1,27 @@ +package eu.kanade.tachiyomi.data.track.shikimori.dto + +import eu.kanade.tachiyomi.data.database.models.Track +import eu.kanade.tachiyomi.data.track.shikimori.ShikimoriApi +import eu.kanade.tachiyomi.data.track.shikimori.toTrackStatus +import kotlinx.serialization.Serializable + +@Serializable +data class SMUserListEntry( + val id: Long, + val chapters: Double, + val score: Int, + val status: String, +) { + fun toTrack(trackId: Long, manga: SMManga): Track { + return Track.create(trackId).apply { + title = manga.name + remote_id = this@SMUserListEntry.id + total_chapters = manga.chapters + library_id = this@SMUserListEntry.id + last_chapter_read = this@SMUserListEntry.chapters + score = this@SMUserListEntry.score.toDouble() + status = toTrackStatus(this@SMUserListEntry.status) + tracking_url = ShikimoriApi.BASE_URL + manga.url + } + } +}