mirror of
https://github.com/mihonapp/mihon.git
synced 2024-12-26 19:08:25 +01:00
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
This commit is contained in:
parent
6c6ea84509
commit
9f99f038f3
@ -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<OAuth>(trackPreferences.trackToken(this).get())
|
||||
json.decodeFromString<ALOAuth>(trackPreferences.trackToken(this).get())
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
@ -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<JsonObject>()
|
||||
.parseAs<ALAddMangaResult>()
|
||||
.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<TrackSearch> {
|
||||
return withIOContext {
|
||||
val query = """
|
||||
@ -177,14 +173,9 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|
||||
),
|
||||
)
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.let { response ->
|
||||
val data = response["data"]!!.jsonObject
|
||||
val page = data["Page"]!!.jsonObject
|
||||
val media = page["media"]!!.jsonArray
|
||||
val entries = media.map { jsonToALManga(it.jsonObject) }
|
||||
entries.map { it.toTrack() }
|
||||
}
|
||||
.parseAs<ALSearchResult>()
|
||||
.data.page.media
|
||||
.map { it.toALManga().toTrack() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -247,14 +238,11 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|
||||
),
|
||||
)
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.let { response ->
|
||||
val data = response["data"]!!.jsonObject
|
||||
val page = data["Page"]!!.jsonObject
|
||||
val media = page["mediaList"]!!.jsonArray
|
||||
val entries = media.map { jsonToALUserManga(it.jsonObject) }
|
||||
entries.firstOrNull()?.toTrack()
|
||||
}
|
||||
.parseAs<ALUserListMangaQueryResult>()
|
||||
.data.page.mediaList
|
||||
.map { it.toALUserManga() }
|
||||
.firstOrNull()
|
||||
?.toTrack()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -263,8 +251,8 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|
||||
return findLibManga(track, userId) ?: throw Exception("Could not find manga")
|
||||
}
|
||||
|
||||
fun createOAuth(token: String): OAuth {
|
||||
return OAuth(token, "Bearer", System.currentTimeMillis() + 31536000000, 31536000000)
|
||||
fun createOAuth(token: String): ALOAuth {
|
||||
return ALOAuth(token, "Bearer", System.currentTimeMillis() + 31536000000, 31536000000)
|
||||
}
|
||||
|
||||
suspend fun getCurrentUser(): Pair<Int, String> {
|
||||
@ -291,61 +279,15 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|
||||
),
|
||||
)
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.parseAs<ALCurrentUserResult>()
|
||||
.let {
|
||||
val data = it["data"]!!.jsonObject
|
||||
val viewer = data["Viewer"]!!.jsonObject
|
||||
Pair(
|
||||
viewer["id"]!!.jsonPrimitive.int,
|
||||
viewer["mediaListOptions"]!!.jsonObject["scoreFormat"]!!.jsonPrimitive.content,
|
||||
)
|
||||
val viewer = it.data.viewer
|
||||
Pair(viewer.id, viewer.mediaListOptions.scoreFormat)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun jsonToALManga(struct: JsonObject): ALManga {
|
||||
return ALManga(
|
||||
struct["id"]!!.jsonPrimitive.long,
|
||||
struct["title"]!!.jsonObject["userPreferred"]!!.jsonPrimitive.content,
|
||||
struct["coverImage"]!!.jsonObject["large"]!!.jsonPrimitive.content,
|
||||
struct["description"]!!.jsonPrimitive.contentOrNull,
|
||||
struct["format"]!!.jsonPrimitive.content.replace("_", "-"),
|
||||
struct["status"]!!.jsonPrimitive.contentOrNull ?: "",
|
||||
parseDate(struct, "startDate"),
|
||||
struct["chapters"]!!.jsonPrimitive.longOrNull ?: 0,
|
||||
struct["averageScore"]?.jsonPrimitive?.intOrNull ?: -1,
|
||||
)
|
||||
}
|
||||
|
||||
private fun jsonToALUserManga(struct: JsonObject): ALUserManga {
|
||||
return ALUserManga(
|
||||
struct["id"]!!.jsonPrimitive.long,
|
||||
struct["status"]!!.jsonPrimitive.content,
|
||||
struct["scoreRaw"]!!.jsonPrimitive.int,
|
||||
struct["progress"]!!.jsonPrimitive.int,
|
||||
parseDate(struct, "startedAt"),
|
||||
parseDate(struct, "completedAt"),
|
||||
jsonToALManga(struct["media"]!!.jsonObject),
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseDate(struct: JsonObject, dateKey: String): Long {
|
||||
return try {
|
||||
return LocalDate
|
||||
.of(
|
||||
struct[dateKey]!!.jsonObject["year"]!!.jsonPrimitive.int,
|
||||
struct[dateKey]!!.jsonObject["month"]!!.jsonPrimitive.int,
|
||||
struct[dateKey]!!.jsonObject["day"]!!.jsonPrimitive.int,
|
||||
)
|
||||
.atStartOfDay(ZoneId.systemDefault())
|
||||
.toInstant()
|
||||
.toEpochMilli()
|
||||
} catch (_: Exception) {
|
||||
0L
|
||||
}
|
||||
}
|
||||
|
||||
private fun createDate(dateValue: Long): JsonObject {
|
||||
if (dateValue == 0L) {
|
||||
return buildJsonObject {
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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")
|
||||
}
|
@ -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")
|
||||
}
|
@ -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,
|
||||
)
|
@ -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
|
||||
}
|
||||
}
|
@ -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")
|
||||
}
|
||||
}
|
@ -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
|
@ -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<ALSearchItem>,
|
||||
)
|
@ -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,
|
||||
)
|
@ -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,
|
||||
)
|
@ -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<ALUserListItem>,
|
||||
)
|
||||
|
||||
@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(),
|
||||
)
|
||||
}
|
||||
}
|
@ -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<OAuth>(trackPreferences.trackToken(this).get())
|
||||
json.decodeFromString<BGMOAuth>(trackPreferences.trackToken(this).get())
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
@ -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<JsonObject>(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<BGMSearchResult>()
|
||||
.let { result ->
|
||||
if (result.code == 404) emptyList<TrackSearch>()
|
||||
|
||||
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<JsonObject>()
|
||||
.let { jsonToSearch(it) }
|
||||
.parseAs<BGMSearchItem>()
|
||||
.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<Collection>(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<BGMCollectionResponse>()
|
||||
.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))
|
||||
|
@ -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<OAuth>(response.body.string()))
|
||||
newAuth(json.decodeFromString<BGMOAuth>(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,
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -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<String?>? = 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")
|
||||
}
|
@ -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")
|
||||
}
|
@ -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<String?>? = 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? = "",
|
||||
)
|
@ -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)
|
@ -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<BGMSearchItem>?,
|
||||
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?,
|
||||
)
|
@ -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? = "",
|
||||
)
|
@ -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<OAuth>(trackPreferences.trackToken(this).get())
|
||||
json.decodeFromString<KitsuOAuth>(trackPreferences.trackToken(this).get())
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
@ -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<JsonObject>()
|
||||
.parseAs<KitsuAddMangaResult>()
|
||||
.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<JsonObject>()
|
||||
.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<TrackSearch> {
|
||||
return withIOContext {
|
||||
with(json) {
|
||||
authClient.newCall(GET(ALGOLIA_KEY_URL))
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.parseAs<KitsuSearchResult>()
|
||||
.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<JsonObject>()
|
||||
.let {
|
||||
it["hits"]!!.jsonArray
|
||||
.map { KitsuSearchManga(it.jsonObject) }
|
||||
.filter { it.subType != "novel" }
|
||||
.map { it.toTrack() }
|
||||
}
|
||||
.parseAs<KitsuAlgoliaSearchResult>()
|
||||
.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<JsonObject>()
|
||||
.parseAs<KitsuListSearchResult>()
|
||||
.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<JsonObject>()
|
||||
.parseAs<KitsuListSearchResult>()
|
||||
.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<JsonObject>()
|
||||
.let {
|
||||
it["data"]!!.jsonArray[0].jsonObject["id"]!!.jsonPrimitive.content
|
||||
}
|
||||
.parseAs<KitsuCurrentUserResult>()
|
||||
.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
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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
|
||||
}
|
@ -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
|
||||
}
|
@ -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,
|
||||
)
|
@ -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<KitsuListSearchItemData>,
|
||||
val included: List<KitsuListSearchItemIncluded> = 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,
|
||||
)
|
@ -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)
|
@ -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<KitsuAlgoliaSearchItem>,
|
||||
)
|
||||
|
||||
@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))
|
||||
} ?: ""
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
package eu.kanade.tachiyomi.data.track.kitsu.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class KitsuSearchItemCover(
|
||||
val original: String?,
|
||||
)
|
@ -0,0 +1,13 @@
|
||||
package eu.kanade.tachiyomi.data.track.kitsu.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class KitsuCurrentUserResult(
|
||||
val data: List<KitsuUser>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class KitsuUser(
|
||||
val id: String,
|
||||
)
|
@ -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
|
||||
}
|
||||
|
@ -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<ListItem, Rating?> {
|
||||
suspend fun getSeriesListItem(track: Track): Pair<MUListItem, MURating?> {
|
||||
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<ListItem>()
|
||||
.parseAs<MUListItem>()
|
||||
}
|
||||
|
||||
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<Rating>()
|
||||
.parseAs<MURating>()
|
||||
}
|
||||
} 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<Record> {
|
||||
suspend fun search(query: String): List<MURecord> {
|
||||
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<JsonObject>()
|
||||
.let { obj ->
|
||||
obj["results"]?.jsonArray?.map { element ->
|
||||
json.decodeFromJsonElement<Record>(element.jsonObject["record"]!!)
|
||||
}
|
||||
}
|
||||
.orEmpty()
|
||||
.parseAs<MUSearchResult>()
|
||||
.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<JsonObject>()
|
||||
.let { obj ->
|
||||
try {
|
||||
json.decodeFromJsonElement<Context>(obj["context"]!!)
|
||||
} catch (e: Exception) {
|
||||
logcat(LogPriority.ERROR, e)
|
||||
null
|
||||
}
|
||||
}
|
||||
.parseAs<MULoginResponse>()
|
||||
.context
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val BASE_URL = "https://api.mangaupdates.com"
|
||||
|
||||
private val CONTENT_TYPE = "application/vnd.api+json".toMediaType()
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
@ -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,
|
||||
)
|
@ -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
|
@ -0,0 +1,8 @@
|
||||
package eu.kanade.tachiyomi.data.track.mangaupdates.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class MULoginResponse(
|
||||
val context: MUContext,
|
||||
)
|
@ -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
|
||||
}
|
@ -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() ?: ""
|
@ -0,0 +1,13 @@
|
||||
package eu.kanade.tachiyomi.data.track.mangaupdates.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class MUSearchResult(
|
||||
val results: List<MUSearchResultItem>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MUSearchResultItem(
|
||||
val record: MURecord,
|
||||
)
|
@ -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,
|
||||
)
|
@ -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,
|
||||
)
|
@ -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,
|
||||
)
|
@ -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<OAuth>(trackPreferences.trackToken(this).get())
|
||||
json.decodeFromString<MALOAuth>(trackPreferences.trackToken(this).get())
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
@ -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<JsonObject>()
|
||||
.let { it["name"]!!.jsonPrimitive.content }
|
||||
.parseAs<MALUser>()
|
||||
.name
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -85,17 +82,11 @@ class MyAnimeListApi(
|
||||
with(json) {
|
||||
authClient.newCall(GET(url.toString()))
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.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<MALSearchResult>()
|
||||
.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<JsonObject>()
|
||||
.parseAs<MALManga>()
|
||||
.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<JsonObject>()
|
||||
.parseAs<MALListItemStatus>()
|
||||
.let { parseMangaItem(it, track) }
|
||||
}
|
||||
}
|
||||
@ -180,12 +166,10 @@ class MyAnimeListApi(
|
||||
with(json) {
|
||||
authClient.newCall(GET(uri.toString()))
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.let { obj ->
|
||||
track.total_chapters = obj["num_chapters"]!!.jsonPrimitive.long
|
||||
obj.jsonObject["my_list_status"]?.jsonObject?.let {
|
||||
parseMangaItem(it, track)
|
||||
}
|
||||
.parseAs<MALListItem>()
|
||||
.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<TrackSearch> {
|
||||
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)
|
||||
|
@ -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<OAuth>() }
|
||||
with(json) { response.parseAs<MALOAuth>() }
|
||||
} else {
|
||||
response.close()
|
||||
null
|
||||
|
@ -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"
|
@ -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?,
|
||||
)
|
@ -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 = "",
|
||||
)
|
@ -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()
|
||||
}
|
@ -0,0 +1,18 @@
|
||||
package eu.kanade.tachiyomi.data.track.myanimelist.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class MALSearchResult(
|
||||
val data: List<MALSearchResultNode>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MALSearchResultNode(
|
||||
val node: MALSearchResultItem,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MALSearchResultItem(
|
||||
val id: Int,
|
||||
)
|
@ -0,0 +1,8 @@
|
||||
package eu.kanade.tachiyomi.data.track.myanimelist.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class MALUser(
|
||||
val name: String,
|
||||
)
|
@ -0,0 +1,25 @@
|
||||
package eu.kanade.tachiyomi.data.track.myanimelist.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class MALUserSearchResult(
|
||||
val data: List<MALUserSearchItem>,
|
||||
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,
|
||||
)
|
@ -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<OAuth>(trackPreferences.trackToken(this).get())
|
||||
json.decodeFromString<SMOAuth>(trackPreferences.trackToken(this).get())
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
@ -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<JsonObject>()
|
||||
.parseAs<SMAddMangaResponse>()
|
||||
.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<JsonArray>()
|
||||
.let { response ->
|
||||
response.map {
|
||||
jsonToSearch(it.jsonObject)
|
||||
}
|
||||
}
|
||||
.parseAs<List<SMManga>>()
|
||||
.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<JsonObject>()
|
||||
.parseAs<SMManga>()
|
||||
}
|
||||
|
||||
val url = "$API_URL/v2/user_rates".toUri().buildUpon()
|
||||
@ -145,15 +110,14 @@ class ShikimoriApi(
|
||||
with(json) {
|
||||
authClient.newCall(GET(url.toString()))
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonArray>()
|
||||
.let { response ->
|
||||
if (response.size > 1) {
|
||||
throw Exception("Too much mangas in response")
|
||||
.parseAs<List<SMUserListEntry>>()
|
||||
.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<JsonObject>()
|
||||
.let {
|
||||
it["id"]!!.jsonPrimitive.int
|
||||
}
|
||||
.parseAs<SMUser>()
|
||||
.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)
|
||||
|
@ -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<OAuth>(response.body.string()))
|
||||
newAuth(json.decodeFromString<SMOAuth>(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)
|
||||
}
|
||||
|
@ -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"
|
@ -0,0 +1,8 @@
|
||||
package eu.kanade.tachiyomi.data.track.shikimori.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class SMAddMangaResponse(
|
||||
val id: Long,
|
||||
)
|
@ -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,
|
||||
)
|
@ -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)
|
@ -0,0 +1,8 @@
|
||||
package eu.kanade.tachiyomi.data.track.shikimori.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class SMUser(
|
||||
val id: Int,
|
||||
)
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user