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:
MajorTanya 2024-09-02 21:46:08 +02:00 committed by GitHub
parent 6c6ea84509
commit 9f99f038f3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
64 changed files with 1195 additions and 822 deletions

View File

@ -7,6 +7,7 @@ import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.BaseTracker import eu.kanade.tachiyomi.data.track.BaseTracker
import eu.kanade.tachiyomi.data.track.DeletableTracker 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 eu.kanade.tachiyomi.data.track.model.TrackSearch
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
@ -129,13 +130,15 @@ class Anilist(id: Long) : BaseTracker(id, "AniList"), DeletableTracker {
0.0 -> "0 ★" 0.0 -> "0 ★"
else -> "${((score + 10) / 20).toInt()}" else -> "${((score + 10) / 20).toInt()}"
} }
POINT_3 -> when { POINT_3 -> when {
score == 0.0 -> "0" score == 0.0 -> "0"
score <= 35 -> "😦" score <= 35 -> "😦"
score <= 60 -> "😐" score <= 60 -> "😐"
else -> "😊" else -> "😊"
} }
else -> track.toAnilistScore()
else -> track.toApiScore()
} }
} }
@ -217,7 +220,7 @@ class Anilist(id: Long) : BaseTracker(id, "AniList"), DeletableTracker {
interceptor.setAuth(oauth) interceptor.setAuth(oauth)
val (username, scoreType) = api.getCurrentUser() val (username, scoreType) = api.getCurrentUser()
scorePreference.set(scoreType) scorePreference.set(scoreType)
saveCredentials(username.toString(), oauth.access_token) saveCredentials(username.toString(), oauth.accessToken)
} catch (e: Throwable) { } catch (e: Throwable) {
logout() logout()
} }
@ -229,13 +232,13 @@ class Anilist(id: Long) : BaseTracker(id, "AniList"), DeletableTracker {
interceptor.setAuth(null) interceptor.setAuth(null)
} }
fun saveOAuth(oAuth: OAuth?) { fun saveOAuth(alOAuth: ALOAuth?) {
trackPreferences.trackToken(this).set(json.encodeToString(oAuth)) trackPreferences.trackToken(this).set(json.encodeToString(alOAuth))
} }
fun loadOAuth(): OAuth? { fun loadOAuth(): ALOAuth? {
return try { return try {
json.decodeFromString<OAuth>(trackPreferences.trackToken(this).get()) json.decodeFromString<ALOAuth>(trackPreferences.trackToken(this).get())
} catch (e: Exception) { } catch (e: Exception) {
null null
} }

View File

@ -3,6 +3,11 @@ package eu.kanade.tachiyomi.data.track.anilist
import android.net.Uri import android.net.Uri
import androidx.core.net.toUri import androidx.core.net.toUri
import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.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.data.track.model.TrackSearch
import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.awaitSuccess import eu.kanade.tachiyomi.network.awaitSuccess
@ -13,14 +18,6 @@ import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.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.put
import kotlinx.serialization.json.putJsonObject import kotlinx.serialization.json.putJsonObject
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@ -28,7 +25,6 @@ import okhttp3.RequestBody.Companion.toRequestBody
import tachiyomi.core.common.util.lang.withIOContext import tachiyomi.core.common.util.lang.withIOContext
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.time.Instant import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId import java.time.ZoneId
import java.time.ZonedDateTime import java.time.ZonedDateTime
import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.minutes
@ -59,7 +55,7 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
putJsonObject("variables") { putJsonObject("variables") {
put("mangaId", track.remote_id) put("mangaId", track.remote_id)
put("progress", track.last_chapter_read.toInt()) put("progress", track.last_chapter_read.toInt())
put("status", track.toAnilistStatus()) put("status", track.toApiStatus())
} }
} }
with(json) { with(json) {
@ -70,10 +66,9 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
), ),
) )
.awaitSuccess() .awaitSuccess()
.parseAs<JsonObject>() .parseAs<ALAddMangaResult>()
.let { .let {
track.library_id = track.library_id = it.data.entry.id
it["data"]!!.jsonObject["SaveMediaListEntry"]!!.jsonObject["id"]!!.jsonPrimitive.long
track track
} }
} }
@ -103,7 +98,7 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
putJsonObject("variables") { putJsonObject("variables") {
put("listId", track.library_id) put("listId", track.library_id)
put("progress", track.last_chapter_read.toInt()) put("progress", track.last_chapter_read.toInt())
put("status", track.toAnilistStatus()) put("status", track.toApiStatus())
put("score", track.score.toInt()) put("score", track.score.toInt())
put("startedAt", createDate(track.started_reading_date)) put("startedAt", createDate(track.started_reading_date))
put("completedAt", createDate(track.finished_reading_date)) put("completedAt", createDate(track.finished_reading_date))
@ -135,6 +130,7 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
.awaitSuccess() .awaitSuccess()
} }
} }
suspend fun search(search: String): List<TrackSearch> { suspend fun search(search: String): List<TrackSearch> {
return withIOContext { return withIOContext {
val query = """ val query = """
@ -177,14 +173,9 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
), ),
) )
.awaitSuccess() .awaitSuccess()
.parseAs<JsonObject>() .parseAs<ALSearchResult>()
.let { response -> .data.page.media
val data = response["data"]!!.jsonObject .map { it.toALManga().toTrack() }
val page = data["Page"]!!.jsonObject
val media = page["media"]!!.jsonArray
val entries = media.map { jsonToALManga(it.jsonObject) }
entries.map { it.toTrack() }
}
} }
} }
} }
@ -247,14 +238,11 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
), ),
) )
.awaitSuccess() .awaitSuccess()
.parseAs<JsonObject>() .parseAs<ALUserListMangaQueryResult>()
.let { response -> .data.page.mediaList
val data = response["data"]!!.jsonObject .map { it.toALUserManga() }
val page = data["Page"]!!.jsonObject .firstOrNull()
val media = page["mediaList"]!!.jsonArray ?.toTrack()
val entries = media.map { jsonToALUserManga(it.jsonObject) }
entries.firstOrNull()?.toTrack()
}
} }
} }
} }
@ -263,8 +251,8 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
return findLibManga(track, userId) ?: throw Exception("Could not find manga") return findLibManga(track, userId) ?: throw Exception("Could not find manga")
} }
fun createOAuth(token: String): OAuth { fun createOAuth(token: String): ALOAuth {
return OAuth(token, "Bearer", System.currentTimeMillis() + 31536000000, 31536000000) return ALOAuth(token, "Bearer", System.currentTimeMillis() + 31536000000, 31536000000)
} }
suspend fun getCurrentUser(): Pair<Int, String> { suspend fun getCurrentUser(): Pair<Int, String> {
@ -291,61 +279,15 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
), ),
) )
.awaitSuccess() .awaitSuccess()
.parseAs<JsonObject>() .parseAs<ALCurrentUserResult>()
.let { .let {
val data = it["data"]!!.jsonObject val viewer = it.data.viewer
val viewer = data["Viewer"]!!.jsonObject Pair(viewer.id, viewer.mediaListOptions.scoreFormat)
Pair(
viewer["id"]!!.jsonPrimitive.int,
viewer["mediaListOptions"]!!.jsonObject["scoreFormat"]!!.jsonPrimitive.content,
)
} }
} }
} }
} }
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 { private fun createDate(dateValue: Long): JsonObject {
if (dateValue == 0L) { if (dateValue == 0L) {
return buildJsonObject { return buildJsonObject {

View File

@ -1,6 +1,8 @@
package eu.kanade.tachiyomi.data.track.anilist package eu.kanade.tachiyomi.data.track.anilist
import eu.kanade.tachiyomi.BuildConfig 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.Interceptor
import okhttp3.Response import okhttp3.Response
import java.io.IOException 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 * Anilist returns the date without milliseconds. We fix that and make the token expire 1 minute
* before its original expiration date. * before its original expiration date.
*/ */
private var oauth: OAuth? = null private var oauth: ALOAuth? = null
set(value) { set(value) {
field = value?.copy(expires = value.expires * 1000 - 60 * 1000) 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. // Add the authorization header to the original request.
val authRequest = originalRequest.newBuilder() 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("User-Agent", "Mihon v${BuildConfig.VERSION_NAME} (${BuildConfig.APPLICATION_ID})")
.build() .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 * Called when the user authenticates with Anilist for the first time. Sets the refresh token
* and the oauth object. * and the oauth object.
*/ */
fun setAuth(oauth: OAuth?) { fun setAuth(oauth: ALOAuth?) {
token = oauth?.access_token token = oauth?.accessToken
this.oauth = oauth this.oauth = oauth
anilist.saveOAuth(oauth) anilist.saveOAuth(oauth)
} }

View File

@ -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")
}

View File

@ -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")
}

View File

@ -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,
)

View File

@ -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
}
}

View File

@ -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")
}
}

View File

@ -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

View File

@ -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>,
)

View File

@ -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,
)

View File

@ -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,
)

View File

@ -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(),
)
}
}

View File

@ -5,6 +5,7 @@ import dev.icerock.moko.resources.StringResource
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.BaseTracker 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 eu.kanade.tachiyomi.data.track.model.TrackSearch
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
@ -75,8 +76,8 @@ class Bangumi(id: Long) : BaseTracker(id, "Bangumi") {
} }
override suspend fun refresh(track: Track): Track { override suspend fun refresh(track: Track): Track {
val remoteStatusTrack = api.statusLibManga(track) val remoteStatusTrack = api.statusLibManga(track) ?: throw Exception("Could not find manga")
track.copyPersonalFrom(remoteStatusTrack!!) track.copyPersonalFrom(remoteStatusTrack)
api.findLibManga(track)?.let { remoteTrack -> api.findLibManga(track)?.let { remoteTrack ->
track.total_chapters = remoteTrack.total_chapters track.total_chapters = remoteTrack.total_chapters
} }
@ -112,19 +113,19 @@ class Bangumi(id: Long) : BaseTracker(id, "Bangumi") {
try { try {
val oauth = api.accessToken(code) val oauth = api.accessToken(code)
interceptor.newAuth(oauth) interceptor.newAuth(oauth)
saveCredentials(oauth.user_id.toString(), oauth.access_token) saveCredentials(oauth.userId.toString(), oauth.accessToken)
} catch (e: Throwable) { } catch (e: Throwable) {
logout() logout()
} }
} }
fun saveToken(oauth: OAuth?) { fun saveToken(oauth: BGMOAuth?) {
trackPreferences.trackToken(this).set(json.encodeToString(oauth)) trackPreferences.trackToken(this).set(json.encodeToString(oauth))
} }
fun restoreToken(): OAuth? { fun restoreToken(): BGMOAuth? {
return try { return try {
json.decodeFromString<OAuth>(trackPreferences.trackToken(this).get()) json.decodeFromString<BGMOAuth>(trackPreferences.trackToken(this).get())
} catch (e: Exception) { } catch (e: Exception) {
null null
} }

View File

@ -3,20 +3,16 @@ package eu.kanade.tachiyomi.data.track.bangumi
import android.net.Uri import android.net.Uri
import androidx.core.net.toUri import androidx.core.net.toUri
import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.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.data.track.model.TrackSearch
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.awaitSuccess import eu.kanade.tachiyomi.network.awaitSuccess
import eu.kanade.tachiyomi.network.parseAs import eu.kanade.tachiyomi.network.parseAs
import kotlinx.serialization.json.Json 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.CacheControl
import okhttp3.FormBody import okhttp3.FormBody
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@ -40,7 +36,7 @@ class BangumiApi(
return withIOContext { return withIOContext {
val body = FormBody.Builder() val body = FormBody.Builder()
.add("rating", track.score.toInt().toString()) .add("rating", track.score.toInt().toString())
.add("status", track.toBangumiStatus()) .add("status", track.toApiStatus())
.build() .build()
authClient.newCall(POST("$API_URL/collection/${track.remote_id}/update", body = body)) authClient.newCall(POST("$API_URL/collection/${track.remote_id}/update", body = body))
.awaitSuccess() .awaitSuccess()
@ -53,7 +49,7 @@ class BangumiApi(
// read status update // read status update
val sbody = FormBody.Builder() val sbody = FormBody.Builder()
.add("rating", track.score.toInt().toString()) .add("rating", track.score.toInt().toString())
.add("status", track.toBangumiStatus()) .add("status", track.toApiStatus())
.build() .build()
authClient.newCall(POST("$API_URL/collection/${track.remote_id}/update", body = sbody)) authClient.newCall(POST("$API_URL/collection/${track.remote_id}/update", body = sbody))
.awaitSuccess() .awaitSuccess()
@ -63,10 +59,7 @@ class BangumiApi(
.add("watched_eps", track.last_chapter_read.toInt().toString()) .add("watched_eps", track.last_chapter_read.toInt().toString())
.build() .build()
authClient.newCall( authClient.newCall(
POST( POST("$API_URL/subject/${track.remote_id}/update/watched_eps", body = body),
"$API_URL/subject/${track.remote_id}/update/watched_eps",
body = body,
),
).awaitSuccess() ).awaitSuccess()
track track
@ -80,44 +73,19 @@ class BangumiApi(
.buildUpon() .buildUpon()
.appendQueryParameter("max_results", "20") .appendQueryParameter("max_results", "20")
.build() .build()
authClient.newCall(GET(url.toString())) with(json) {
.awaitSuccess() authClient.newCall(GET(url.toString()))
.use { .awaitSuccess()
var responseBody = it.body.string() .parseAs<BGMSearchResult>()
if (responseBody.isEmpty()) { .let { result ->
throw Exception("Null Response") if (result.code == 404) emptyList<TrackSearch>()
}
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()
}
}
}
private fun jsonToSearch(obj: JsonObject): TrackSearch { result.list
val coverUrl = if (obj["images"] is JsonObject) { ?.filter { it.type == 1 }
obj["images"]?.jsonObject?.get("common")?.jsonPrimitive?.contentOrNull ?: "" ?.map { it.toTrackSearch(trackId) }
} else { .orEmpty()
// 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
} }
} }
@ -126,8 +94,8 @@ class BangumiApi(
with(json) { with(json) {
authClient.newCall(GET("$API_URL/subject/${track.remote_id}")) authClient.newCall(GET("$API_URL/subject/${track.remote_id}"))
.awaitSuccess() .awaitSuccess()
.parseAs<JsonObject>() .parseAs<BGMSearchItem>()
.let { jsonToSearch(it) } .toTrackSearch(trackId)
} }
} }
} }
@ -142,25 +110,23 @@ class BangumiApi(
.build() .build()
// TODO: get user readed chapter here // TODO: get user readed chapter here
val response = authClient.newCall(requestUserRead).awaitSuccess() with(json) {
val responseBody = response.body.string() authClient.newCall(requestUserRead)
if (responseBody.isEmpty()) { .awaitSuccess()
throw Exception("Null Response") .parseAs<BGMCollectionResponse>()
} .let {
if (responseBody.contains("\"code\":400")) { if (it.code == 400) return@let null
null
} else { track.status = it.status?.id!!
json.decodeFromString<Collection>(responseBody).let { track.last_chapter_read = it.epStatus!!.toDouble()
track.status = it.status?.id!! track.score = it.rating!!
track.last_chapter_read = it.ep_status!!.toDouble() track
track.score = it.rating!! }
track
}
} }
} }
} }
suspend fun accessToken(code: String): OAuth { suspend fun accessToken(code: String): BGMOAuth {
return withIOContext { return withIOContext {
with(json) { with(json) {
client.newCall(accessTokenRequest(code)) client.newCall(accessTokenRequest(code))

View File

@ -1,6 +1,8 @@
package eu.kanade.tachiyomi.data.track.bangumi package eu.kanade.tachiyomi.data.track.bangumi
import eu.kanade.tachiyomi.BuildConfig 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 kotlinx.serialization.json.Json
import okhttp3.FormBody import okhttp3.FormBody
import okhttp3.Interceptor import okhttp3.Interceptor
@ -14,7 +16,7 @@ class BangumiInterceptor(private val bangumi: Bangumi) : Interceptor {
/** /**
* OAuth object used for authenticated requests. * 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 { override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request() val originalRequest = chain.request()
@ -22,9 +24,9 @@ class BangumiInterceptor(private val bangumi: Bangumi) : Interceptor {
val currAuth = oauth ?: throw Exception("Not authenticated with Bangumi") val currAuth = oauth ?: throw Exception("Not authenticated with Bangumi")
if (currAuth.isExpired()) { if (currAuth.isExpired()) {
val response = chain.proceed(BangumiApi.refreshTokenRequest(currAuth.refresh_token!!)) val response = chain.proceed(BangumiApi.refreshTokenRequest(currAuth.refreshToken!!))
if (response.isSuccessful) { if (response.isSuccessful) {
newAuth(json.decodeFromString<OAuth>(response.body.string())) newAuth(json.decodeFromString<BGMOAuth>(response.body.string()))
} else { } else {
response.close() response.close()
} }
@ -38,28 +40,28 @@ class BangumiInterceptor(private val bangumi: Bangumi) : Interceptor {
.apply { .apply {
if (originalRequest.method == "GET") { if (originalRequest.method == "GET") {
val newUrl = originalRequest.url.newBuilder() val newUrl = originalRequest.url.newBuilder()
.addQueryParameter("access_token", currAuth.access_token) .addQueryParameter("access_token", currAuth.accessToken)
.build() .build()
url(newUrl) url(newUrl)
} else { } else {
post(addToken(currAuth.access_token, originalRequest.body as FormBody)) post(addToken(currAuth.accessToken, originalRequest.body as FormBody))
} }
} }
.build() .build()
.let(chain::proceed) .let(chain::proceed)
} }
fun newAuth(oauth: OAuth?) { fun newAuth(oauth: BGMOAuth?) {
this.oauth = if (oauth == null) { this.oauth = if (oauth == null) {
null null
} else { } else {
OAuth( BGMOAuth(
oauth.access_token, oauth.accessToken,
oauth.token_type, oauth.tokenType,
System.currentTimeMillis() / 1000, System.currentTimeMillis() / 1000,
oauth.expires_in, oauth.expiresIn,
oauth.refresh_token, oauth.refreshToken,
this.oauth?.user_id, this.oauth?.userId,
) )
} }

View File

@ -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")
}

View File

@ -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")
}

View File

@ -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? = "",
)

View File

@ -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)

View File

@ -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?,
)

View File

@ -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? = "",
)

View File

@ -6,6 +6,7 @@ import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.BaseTracker import eu.kanade.tachiyomi.data.track.BaseTracker
import eu.kanade.tachiyomi.data.track.DeletableTracker 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 eu.kanade.tachiyomi.data.track.model.TrackSearch
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
@ -142,13 +143,13 @@ class Kitsu(id: Long) : BaseTracker(id, "Kitsu"), DeletableTracker {
return getPassword() return getPassword()
} }
fun saveToken(oauth: OAuth?) { fun saveToken(oauth: KitsuOAuth?) {
trackPreferences.trackToken(this).set(json.encodeToString(oauth)) trackPreferences.trackToken(this).set(json.encodeToString(oauth))
} }
fun restoreToken(): OAuth? { fun restoreToken(): KitsuOAuth? {
return try { return try {
json.decodeFromString<OAuth>(trackPreferences.trackToken(this).get()) json.decodeFromString<KitsuOAuth>(trackPreferences.trackToken(this).get())
} catch (e: Exception) { } catch (e: Exception) {
null null
} }

View File

@ -2,6 +2,12 @@ package eu.kanade.tachiyomi.data.track.kitsu
import androidx.core.net.toUri import androidx.core.net.toUri
import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.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.data.track.model.TrackSearch
import eu.kanade.tachiyomi.network.DELETE import eu.kanade.tachiyomi.network.DELETE
import eu.kanade.tachiyomi.network.GET 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.jsonMime
import eu.kanade.tachiyomi.network.parseAs import eu.kanade.tachiyomi.network.parseAs
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject 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.put
import kotlinx.serialization.json.putJsonObject import kotlinx.serialization.json.putJsonObject
import okhttp3.FormBody import okhttp3.FormBody
@ -43,7 +44,7 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
putJsonObject("data") { putJsonObject("data") {
put("type", "libraryEntries") put("type", "libraryEntries")
putJsonObject("attributes") { putJsonObject("attributes") {
put("status", track.toKitsuStatus()) put("status", track.toApiStatus())
put("progress", track.last_chapter_read.toInt()) put("progress", track.last_chapter_read.toInt())
} }
putJsonObject("relationships") { putJsonObject("relationships") {
@ -67,18 +68,14 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
authClient.newCall( authClient.newCall(
POST( POST(
"${BASE_URL}library-entries", "${BASE_URL}library-entries",
headers = headersOf( headers = headersOf("Content-Type", VND_API_JSON),
"Content-Type", body = data.toString().toRequestBody(VND_JSON_MEDIA_TYPE),
"application/vnd.api+json",
),
body = data.toString()
.toRequestBody("application/vnd.api+json".toMediaType()),
), ),
) )
.awaitSuccess() .awaitSuccess()
.parseAs<JsonObject>() .parseAs<KitsuAddMangaResult>()
.let { .let {
track.remote_id = it["data"]!!.jsonObject["id"]!!.jsonPrimitive.long track.remote_id = it.data.id
track track
} }
} }
@ -92,63 +89,50 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
put("type", "libraryEntries") put("type", "libraryEntries")
put("id", track.remote_id) put("id", track.remote_id)
putJsonObject("attributes") { putJsonObject("attributes") {
put("status", track.toKitsuStatus()) put("status", track.toApiStatus())
put("progress", track.last_chapter_read.toInt()) put("progress", track.last_chapter_read.toInt())
put("ratingTwenty", track.toKitsuScore()) put("ratingTwenty", track.toApiScore())
put("startedAt", KitsuDateHelper.convert(track.started_reading_date)) put("startedAt", KitsuDateHelper.convert(track.started_reading_date))
put("finishedAt", KitsuDateHelper.convert(track.finished_reading_date)) put("finishedAt", KitsuDateHelper.convert(track.finished_reading_date))
} }
} }
} }
with(json) { authClient.newCall(
authClient.newCall( Request.Builder()
Request.Builder() .url("${BASE_URL}library-entries/${track.remote_id}")
.url("${BASE_URL}library-entries/${track.remote_id}") .headers(
.headers( headersOf("Content-Type", VND_API_JSON),
headersOf( )
"Content-Type", .patch(data.toString().toRequestBody(VND_JSON_MEDIA_TYPE))
"application/vnd.api+json", .build(),
), )
) .awaitSuccess()
.patch(
data.toString().toRequestBody("application/vnd.api+json".toMediaType()), track
)
.build(),
)
.awaitSuccess()
.parseAs<JsonObject>()
.let {
track
}
}
} }
} }
suspend fun removeLibManga(track: DomainTrack) { suspend fun removeLibManga(track: DomainTrack) {
withIOContext { withIOContext {
authClient authClient.newCall(
.newCall( DELETE(
DELETE( "${BASE_URL}library-entries/${track.remoteId}",
"${BASE_URL}library-entries/${track.remoteId}", headers = headersOf("Content-Type", VND_API_JSON),
headers = headersOf( ),
"Content-Type", )
"application/vnd.api+json",
),
),
)
.awaitSuccess() .awaitSuccess()
} }
} }
suspend fun search(query: String): List<TrackSearch> { suspend fun search(query: String): List<TrackSearch> {
return withIOContext { return withIOContext {
with(json) { with(json) {
authClient.newCall(GET(ALGOLIA_KEY_URL)) authClient.newCall(GET(ALGOLIA_KEY_URL))
.awaitSuccess() .awaitSuccess()
.parseAs<JsonObject>() .parseAs<KitsuSearchResult>()
.let { .let {
val key = it["media"]!!.jsonObject["key"]!!.jsonPrimitive.content algoliaSearch(it.media.key, query)
algoliaSearch(key, query)
} }
} }
} }
@ -174,13 +158,10 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
), ),
) )
.awaitSuccess() .awaitSuccess()
.parseAs<JsonObject>() .parseAs<KitsuAlgoliaSearchResult>()
.let { .hits
it["hits"]!!.jsonArray .filter { it.subtype != "novel" }
.map { KitsuSearchManga(it.jsonObject) } .map { it.toTrack() }
.filter { it.subType != "novel" }
.map { it.toTrack() }
}
} }
} }
} }
@ -194,12 +175,10 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
with(json) { with(json) {
authClient.newCall(GET(url.toString())) authClient.newCall(GET(url.toString()))
.awaitSuccess() .awaitSuccess()
.parseAs<JsonObject>() .parseAs<KitsuListSearchResult>()
.let { .let {
val data = it["data"]!!.jsonArray if (it.data.isNotEmpty() && it.included.isNotEmpty()) {
if (data.size > 0) { it.firstToTrack()
val manga = it["included"]!!.jsonArray[0].jsonObject
KitsuLibManga(data[0].jsonObject, manga).toTrack()
} else { } else {
null null
} }
@ -217,12 +196,10 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
with(json) { with(json) {
authClient.newCall(GET(url.toString())) authClient.newCall(GET(url.toString()))
.awaitSuccess() .awaitSuccess()
.parseAs<JsonObject>() .parseAs<KitsuListSearchResult>()
.let { .let {
val data = it["data"]!!.jsonArray if (it.data.isNotEmpty() && it.included.isNotEmpty()) {
if (data.size > 0) { it.firstToTrack()
val manga = it["included"]!!.jsonArray[0].jsonObject
KitsuLibManga(data[0].jsonObject, manga).toTrack()
} else { } else {
throw Exception("Could not find manga") 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 { return withIOContext {
val formBody: RequestBody = FormBody.Builder() val formBody: RequestBody = FormBody.Builder()
.add("username", username) .add("username", username)
@ -256,10 +233,9 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
with(json) { with(json) {
authClient.newCall(GET(url.toString())) authClient.newCall(GET(url.toString()))
.awaitSuccess() .awaitSuccess()
.parseAs<JsonObject>() .parseAs<KitsuCurrentUserResult>()
.let { .data[0]
it["data"]!!.jsonArray[0].jsonObject["id"]!!.jsonPrimitive.content .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" + "%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" "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 { fun mangaUrl(remoteId: Long): String {
return BASE_MANGA_URL + remoteId return BASE_MANGA_URL + remoteId
} }

View File

@ -1,6 +1,8 @@
package eu.kanade.tachiyomi.data.track.kitsu package eu.kanade.tachiyomi.data.track.kitsu
import eu.kanade.tachiyomi.BuildConfig 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 kotlinx.serialization.json.Json
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.Response import okhttp3.Response
@ -13,14 +15,14 @@ class KitsuInterceptor(private val kitsu: Kitsu) : Interceptor {
/** /**
* OAuth object used for authenticated requests. * 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 { override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request() val originalRequest = chain.request()
val currAuth = oauth ?: throw Exception("Not authenticated with Kitsu") val currAuth = oauth ?: throw Exception("Not authenticated with Kitsu")
val refreshToken = currAuth.refresh_token!! val refreshToken = currAuth.refreshToken!!
// Refresh access token if expired. // Refresh access token if expired.
if (currAuth.isExpired()) { if (currAuth.isExpired()) {
@ -34,7 +36,7 @@ class KitsuInterceptor(private val kitsu: Kitsu) : Interceptor {
// Add the authorization header to the original request. // Add the authorization header to the original request.
val authRequest = originalRequest.newBuilder() 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("User-Agent", "Mihon v${BuildConfig.VERSION_NAME} (${BuildConfig.APPLICATION_ID})")
.header("Accept", "application/vnd.api+json") .header("Accept", "application/vnd.api+json")
.header("Content-Type", "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) return chain.proceed(authRequest)
} }
fun newAuth(oauth: OAuth?) { fun newAuth(oauth: KitsuOAuth?) {
this.oauth = oauth this.oauth = oauth
kitsu.saveToken(oauth) kitsu.saveToken(oauth)
} }

View File

@ -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
}

View File

@ -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
}

View File

@ -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,
)

View File

@ -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,
)

View File

@ -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)

View File

@ -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))
} ?: ""
}
}
}

View File

@ -0,0 +1,8 @@
package eu.kanade.tachiyomi.data.track.kitsu.dto
import kotlinx.serialization.Serializable
@Serializable
data class KitsuSearchItemCover(
val original: String?,
)

View File

@ -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,
)

View File

@ -6,8 +6,8 @@ import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.BaseTracker import eu.kanade.tachiyomi.data.track.BaseTracker
import eu.kanade.tachiyomi.data.track.DeletableTracker import eu.kanade.tachiyomi.data.track.DeletableTracker
import eu.kanade.tachiyomi.data.track.mangaupdates.dto.ListItem import eu.kanade.tachiyomi.data.track.mangaupdates.dto.MUListItem
import eu.kanade.tachiyomi.data.track.mangaupdates.dto.Rating 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.copyTo
import eu.kanade.tachiyomi.data.track.mangaupdates.dto.toTrackSearch import eu.kanade.tachiyomi.data.track.mangaupdates.dto.toTrackSearch
import eu.kanade.tachiyomi.data.track.model.TrackSearch 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) 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) item.copyTo(this)
score = rating?.rating ?: 0.0 score = rating?.rating ?: 0.0
} }

View File

@ -3,10 +3,12 @@ package eu.kanade.tachiyomi.data.track.mangaupdates
import eu.kanade.tachiyomi.data.database.models.Track 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.READING_LIST
import eu.kanade.tachiyomi.data.track.mangaupdates.MangaUpdates.Companion.WISH_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.MUContext
import eu.kanade.tachiyomi.data.track.mangaupdates.dto.ListItem import eu.kanade.tachiyomi.data.track.mangaupdates.dto.MUListItem
import eu.kanade.tachiyomi.data.track.mangaupdates.dto.Rating import eu.kanade.tachiyomi.data.track.mangaupdates.dto.MULoginResponse
import eu.kanade.tachiyomi.data.track.mangaupdates.dto.Record 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.DELETE
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST 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.awaitSuccess
import eu.kanade.tachiyomi.network.parseAs import eu.kanade.tachiyomi.network.parseAs
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.add import kotlinx.serialization.json.add
import kotlinx.serialization.json.addJsonObject import kotlinx.serialization.json.addJsonObject
import kotlinx.serialization.json.buildJsonArray import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject 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.put
import kotlinx.serialization.json.putJsonObject import kotlinx.serialization.json.putJsonObject
import logcat.LogPriority
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import tachiyomi.core.common.util.system.logcat
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import tachiyomi.domain.track.model.Track as DomainTrack import tachiyomi.domain.track.model.Track as DomainTrack
@ -38,20 +34,17 @@ class MangaUpdatesApi(
) { ) {
private val json: Json by injectLazy() 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 { private val authClient by lazy {
client.newBuilder() client.newBuilder()
.addInterceptor(interceptor) .addInterceptor(interceptor)
.build() .build()
} }
suspend fun getSeriesListItem(track: Track): Pair<ListItem, Rating?> { suspend fun getSeriesListItem(track: Track): Pair<MUListItem, MURating?> {
val listItem = with(json) { 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() .awaitSuccess()
.parseAs<ListItem>() .parseAs<MUListItem>()
} }
val rating = getSeriesRating(track) val rating = getSeriesRating(track)
@ -71,8 +64,8 @@ class MangaUpdatesApi(
} }
authClient.newCall( authClient.newCall(
POST( POST(
url = "$baseUrl/v1/lists/series", url = "$BASE_URL/v1/lists/series",
body = body.toString().toRequestBody(contentType), body = body.toString().toRequestBody(CONTENT_TYPE),
), ),
) )
.awaitSuccess() .awaitSuccess()
@ -98,8 +91,8 @@ class MangaUpdatesApi(
} }
authClient.newCall( authClient.newCall(
POST( POST(
url = "$baseUrl/v1/lists/series/update", url = "$BASE_URL/v1/lists/series/update",
body = body.toString().toRequestBody(contentType), body = body.toString().toRequestBody(CONTENT_TYPE),
), ),
) )
.awaitSuccess() .awaitSuccess()
@ -113,19 +106,19 @@ class MangaUpdatesApi(
} }
authClient.newCall( authClient.newCall(
POST( POST(
url = "$baseUrl/v1/lists/series/delete", url = "$BASE_URL/v1/lists/series/delete",
body = body.toString().toRequestBody(contentType), body = body.toString().toRequestBody(CONTENT_TYPE),
), ),
) )
.awaitSuccess() .awaitSuccess()
} }
private suspend fun getSeriesRating(track: Track): Rating? { private suspend fun getSeriesRating(track: Track): MURating? {
return try { return try {
with(json) { with(json) {
authClient.newCall(GET("$baseUrl/v1/series/${track.remote_id}/rating")) authClient.newCall(GET("$BASE_URL/v1/series/${track.remote_id}/rating"))
.awaitSuccess() .awaitSuccess()
.parseAs<Rating>() .parseAs<MURating>()
} }
} catch (e: Exception) { } catch (e: Exception) {
null null
@ -140,22 +133,20 @@ class MangaUpdatesApi(
} }
authClient.newCall( authClient.newCall(
PUT( PUT(
url = "$baseUrl/v1/series/${track.remote_id}/rating", url = "$BASE_URL/v1/series/${track.remote_id}/rating",
body = body.toString().toRequestBody(contentType), body = body.toString().toRequestBody(CONTENT_TYPE),
), ),
) )
.awaitSuccess() .awaitSuccess()
} else { } else {
authClient.newCall( authClient.newCall(
DELETE( DELETE(url = "$BASE_URL/v1/series/${track.remote_id}/rating"),
url = "$baseUrl/v1/series/${track.remote_id}/rating",
),
) )
.awaitSuccess() .awaitSuccess()
} }
} }
suspend fun search(query: String): List<Record> { suspend fun search(query: String): List<MURecord> {
val body = buildJsonObject { val body = buildJsonObject {
put("search", query) put("search", query)
put( put(
@ -166,25 +157,22 @@ class MangaUpdatesApi(
}, },
) )
} }
return with(json) { return with(json) {
client.newCall( client.newCall(
POST( POST(
url = "$baseUrl/v1/series/search", url = "$BASE_URL/v1/series/search",
body = body.toString().toRequestBody(contentType), body = body.toString().toRequestBody(CONTENT_TYPE),
), ),
) )
.awaitSuccess() .awaitSuccess()
.parseAs<JsonObject>() .parseAs<MUSearchResult>()
.let { obj -> .results
obj["results"]?.jsonArray?.map { element -> .map { it.record }
json.decodeFromJsonElement<Record>(element.jsonObject["record"]!!)
}
}
.orEmpty()
} }
} }
suspend fun authenticate(username: String, password: String): Context? { suspend fun authenticate(username: String, password: String): MUContext? {
val body = buildJsonObject { val body = buildJsonObject {
put("username", username) put("username", username)
put("password", password) put("password", password)
@ -192,20 +180,19 @@ class MangaUpdatesApi(
return with(json) { return with(json) {
client.newCall( client.newCall(
PUT( PUT(
url = "$baseUrl/v1/account/login", url = "$BASE_URL/v1/account/login",
body = body.toString().toRequestBody(contentType), body = body.toString().toRequestBody(CONTENT_TYPE),
), ),
) )
.awaitSuccess() .awaitSuccess()
.parseAs<JsonObject>() .parseAs<MULoginResponse>()
.let { obj -> .context
try {
json.decodeFromJsonElement<Context>(obj["context"]!!)
} catch (e: Exception) {
logcat(LogPriority.ERROR, e)
null
}
}
} }
} }
companion object {
private const val BASE_URL = "https://api.mangaupdates.com"
private val CONTENT_TYPE = "application/vnd.api+json".toMediaType()
}
} }

View File

@ -4,7 +4,7 @@ import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
data class Context( data class MUContext(
@SerialName("session_token") @SerialName("session_token")
val sessionToken: String, val sessionToken: String,
val uid: Long, val uid: Long,

View File

@ -3,8 +3,8 @@ package eu.kanade.tachiyomi.data.track.mangaupdates.dto
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
data class Image( data class MUImage(
val url: Url? = null, val url: MUUrl? = null,
val height: Int? = null, val height: Int? = null,
val width: Int? = null, val width: Int? = null,
) )

View File

@ -6,15 +6,15 @@ import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
data class ListItem( data class MUListItem(
val series: Series? = null, val series: MUSeries? = null,
@SerialName("list_id") @SerialName("list_id")
val listId: Long? = null, val listId: Long? = null,
val status: Status? = null, val status: MUStatus? = null,
val priority: Int? = null, val priority: Int? = null,
) )
fun ListItem.copyTo(track: Track): Track { fun MUListItem.copyTo(track: Track): Track {
return track.apply { return track.apply {
this.status = listId ?: READING_LIST this.status = listId ?: READING_LIST
this.last_chapter_read = this@copyTo.status?.chapter?.toDouble() ?: 0.0 this.last_chapter_read = this@copyTo.status?.chapter?.toDouble() ?: 0.0

View File

@ -0,0 +1,8 @@
package eu.kanade.tachiyomi.data.track.mangaupdates.dto
import kotlinx.serialization.Serializable
@Serializable
data class MULoginResponse(
val context: MUContext,
)

View File

@ -4,11 +4,11 @@ import eu.kanade.tachiyomi.data.database.models.Track
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
data class Rating( data class MURating(
val rating: Double? = null, val rating: Double? = null,
) )
fun Rating.copyTo(track: Track): Track { fun MURating.copyTo(track: Track): Track {
return track.apply { return track.apply {
this.score = rating ?: 0.0 this.score = rating ?: 0.0
} }

View File

@ -6,13 +6,13 @@ import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
data class Record( data class MURecord(
@SerialName("series_id") @SerialName("series_id")
val seriesId: Long? = null, val seriesId: Long? = null,
val title: String? = null, val title: String? = null,
val url: String? = null, val url: String? = null,
val description: String? = null, val description: String? = null,
val image: Image? = null, val image: MUImage? = null,
val type: String? = null, val type: String? = null,
val year: String? = null, val year: String? = null,
@SerialName("bayesian_rating") @SerialName("bayesian_rating")
@ -23,7 +23,7 @@ data class Record(
val latestChapter: Int? = null, val latestChapter: Int? = null,
) )
fun Record.toTrackSearch(id: Long): TrackSearch { fun MURecord.toTrackSearch(id: Long): TrackSearch {
return TrackSearch.create(id).apply { return TrackSearch.create(id).apply {
remote_id = this@toTrackSearch.seriesId ?: 0L remote_id = this@toTrackSearch.seriesId ?: 0L
title = this@toTrackSearch.title?.htmlDecode() ?: "" title = this@toTrackSearch.title?.htmlDecode() ?: ""

View File

@ -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,
)

View File

@ -3,7 +3,7 @@ package eu.kanade.tachiyomi.data.track.mangaupdates.dto
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
data class Series( data class MUSeries(
val id: Long? = null, val id: Long? = null,
val title: String? = null, val title: String? = null,
) )

View File

@ -3,7 +3,7 @@ package eu.kanade.tachiyomi.data.track.mangaupdates.dto
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
data class Status( data class MUStatus(
val volume: Int? = null, val volume: Int? = null,
val chapter: Int? = null, val chapter: Int? = null,
) )

View File

@ -3,7 +3,7 @@ package eu.kanade.tachiyomi.data.track.mangaupdates.dto
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
data class Url( data class MUUrl(
val original: String? = null, val original: String? = null,
val thumb: String? = null, val thumb: String? = null,
) )

View File

@ -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.BaseTracker
import eu.kanade.tachiyomi.data.track.DeletableTracker import eu.kanade.tachiyomi.data.track.DeletableTracker
import eu.kanade.tachiyomi.data.track.model.TrackSearch 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.ImmutableList
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
@ -143,7 +144,7 @@ class MyAnimeList(id: Long) : BaseTracker(id, "MyAnimeList"), DeletableTracker {
val oauth = api.getAccessToken(authCode) val oauth = api.getAccessToken(authCode)
interceptor.setAuth(oauth) interceptor.setAuth(oauth)
val username = api.getCurrentUser() val username = api.getCurrentUser()
saveCredentials(username, oauth.access_token) saveCredentials(username, oauth.accessToken)
} catch (e: Throwable) { } catch (e: Throwable) {
logout() logout()
} }
@ -163,13 +164,13 @@ class MyAnimeList(id: Long) : BaseTracker(id, "MyAnimeList"), DeletableTracker {
trackPreferences.trackAuthExpired(this).set(true) trackPreferences.trackAuthExpired(this).set(true)
} }
fun saveOAuth(oAuth: OAuth?) { fun saveOAuth(oAuth: MALOAuth?) {
trackPreferences.trackToken(this).set(json.encodeToString(oAuth)) trackPreferences.trackToken(this).set(json.encodeToString(oAuth))
} }
fun loadOAuth(): OAuth? { fun loadOAuth(): MALOAuth? {
return try { return try {
json.decodeFromString<OAuth>(trackPreferences.trackToken(this).get()) json.decodeFromString<MALOAuth>(trackPreferences.trackToken(this).get())
} catch (e: Exception) { } catch (e: Exception) {
null null
} }

View File

@ -4,6 +4,13 @@ import android.net.Uri
import androidx.core.net.toUri import androidx.core.net.toUri
import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.model.TrackSearch import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.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.DELETE
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.network.POST
@ -13,16 +20,6 @@ import eu.kanade.tachiyomi.util.PkceUtil
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
import kotlinx.serialization.json.Json 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.FormBody
import okhttp3.Headers import okhttp3.Headers
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@ -44,7 +41,7 @@ class MyAnimeListApi(
private val authClient = client.newBuilder().addInterceptor(interceptor).build() private val authClient = client.newBuilder().addInterceptor(interceptor).build()
suspend fun getAccessToken(authCode: String): OAuth { suspend fun getAccessToken(authCode: String): MALOAuth {
return withIOContext { return withIOContext {
val formBody: RequestBody = FormBody.Builder() val formBody: RequestBody = FormBody.Builder()
.add("client_id", CLIENT_ID) .add("client_id", CLIENT_ID)
@ -69,8 +66,8 @@ class MyAnimeListApi(
with(json) { with(json) {
authClient.newCall(request) authClient.newCall(request)
.awaitSuccess() .awaitSuccess()
.parseAs<JsonObject>() .parseAs<MALUser>()
.let { it["name"]!!.jsonPrimitive.content } .name
} }
} }
} }
@ -85,17 +82,11 @@ class MyAnimeListApi(
with(json) { with(json) {
authClient.newCall(GET(url.toString())) authClient.newCall(GET(url.toString()))
.awaitSuccess() .awaitSuccess()
.parseAs<JsonObject>() .parseAs<MALSearchResult>()
.let { .data
it["data"]!!.jsonArray .map { async { getMangaDetails(it.node.id) } }
.map { data -> data.jsonObject["node"]!!.jsonObject } .awaitAll()
.map { node -> .filter { !it.publishing_type.contains("novel") }
val id = node["id"]!!.jsonPrimitive.int
async { getMangaDetails(id) }
}
.awaitAll()
.filter { trackSearch -> !trackSearch.publishing_type.contains("novel") }
}
} }
} }
} }
@ -112,24 +103,19 @@ class MyAnimeListApi(
with(json) { with(json) {
authClient.newCall(GET(url.toString())) authClient.newCall(GET(url.toString()))
.awaitSuccess() .awaitSuccess()
.parseAs<JsonObject>() .parseAs<MALManga>()
.let { .let {
val obj = it.jsonObject
TrackSearch.create(trackId).apply { TrackSearch.create(trackId).apply {
remote_id = obj["id"]!!.jsonPrimitive.long remote_id = it.id
title = obj["title"]!!.jsonPrimitive.content title = it.title
summary = obj["synopsis"]?.jsonPrimitive?.content ?: "" summary = it.synopsis
total_chapters = obj["num_chapters"]!!.jsonPrimitive.long total_chapters = it.numChapters
score = obj["mean"]?.jsonPrimitive?.doubleOrNull ?: -1.0 score = it.mean
cover_url = cover_url = it.covers.large
obj["main_picture"]?.jsonObject?.get("large")?.jsonPrimitive?.content
?: ""
tracking_url = "https://myanimelist.net/manga/$remote_id" tracking_url = "https://myanimelist.net/manga/$remote_id"
publishing_status = publishing_status = it.status.replace("_", " ")
obj["status"]!!.jsonPrimitive.content.replace("_", " ") publishing_type = it.mediaType.replace("_", " ")
publishing_type = start_date = it.startDate ?: ""
obj["media_type"]!!.jsonPrimitive.content.replace("_", " ")
start_date = obj["start_date"]?.jsonPrimitive?.content ?: ""
} }
} }
} }
@ -157,7 +143,7 @@ class MyAnimeListApi(
with(json) { with(json) {
authClient.newCall(request) authClient.newCall(request)
.awaitSuccess() .awaitSuccess()
.parseAs<JsonObject>() .parseAs<MALListItemStatus>()
.let { parseMangaItem(it, track) } .let { parseMangaItem(it, track) }
} }
} }
@ -180,12 +166,10 @@ class MyAnimeListApi(
with(json) { with(json) {
authClient.newCall(GET(uri.toString())) authClient.newCall(GET(uri.toString()))
.awaitSuccess() .awaitSuccess()
.parseAs<JsonObject>() .parseAs<MALListItem>()
.let { obj -> .let { item ->
track.total_chapters = obj["num_chapters"]!!.jsonPrimitive.long track.total_chapters = item.numChapters
obj.jsonObject["my_list_status"]?.jsonObject?.let { item.myListStatus?.let { parseMangaItem(it, track) }
parseMangaItem(it, track)
}
} }
} }
} }
@ -193,24 +177,15 @@ class MyAnimeListApi(
suspend fun findListItems(query: String, offset: Int = 0): List<TrackSearch> { suspend fun findListItems(query: String, offset: Int = 0): List<TrackSearch> {
return withIOContext { return withIOContext {
val json = getListPage(offset) val myListSearchResult = getListPage(offset)
val obj = json.jsonObject
val matches = obj["data"]!!.jsonArray val matches = myListSearchResult.data
.filter { .filter { it.node.title.contains(query, ignoreCase = true) }
it.jsonObject["node"]!!.jsonObject["title"]!!.jsonPrimitive.content.contains( .map { async { getMangaDetails(it.node.id) } }
query,
ignoreCase = true,
)
}
.map {
val id = it.jsonObject["node"]!!.jsonObject["id"]!!.jsonPrimitive.int
async { getMangaDetails(id) }
}
.awaitAll() .awaitAll()
// Check next page if there's more // 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) matches + findListItems(query, offset + LIST_PAGINATION_AMOUNT)
} else { } else {
matches matches
@ -218,7 +193,7 @@ class MyAnimeListApi(
} }
} }
private suspend fun getListPage(offset: Int): JsonObject { private suspend fun getListPage(offset: Int): MALUserSearchResult {
return withIOContext { return withIOContext {
val urlBuilder = "$BASE_API_URL/users/@me/mangalist".toUri().buildUpon() val urlBuilder = "$BASE_API_URL/users/@me/mangalist".toUri().buildUpon()
.appendQueryParameter("fields", "list_status{start_date,finish_date}") .appendQueryParameter("fields", "list_status{start_date,finish_date}")
@ -239,19 +214,14 @@ class MyAnimeListApi(
} }
} }
private fun parseMangaItem(response: JsonObject, track: Track): Track { private fun parseMangaItem(listStatus: MALListItemStatus, track: Track): Track {
val obj = response.jsonObject
return track.apply { return track.apply {
val isRereading = obj["is_rereading"]!!.jsonPrimitive.boolean val isRereading = listStatus.isRereading
status = if (isRereading) MyAnimeList.REREADING else getStatus(obj["status"]?.jsonPrimitive?.content) status = if (isRereading) MyAnimeList.REREADING else getStatus(listStatus.status)
last_chapter_read = obj["num_chapters_read"]!!.jsonPrimitive.double last_chapter_read = listStatus.numChaptersRead
score = obj["score"]!!.jsonPrimitive.int.toDouble() score = listStatus.score.toDouble()
obj["start_date"]?.let { listStatus.startDate?.let { started_reading_date = parseDate(it) }
started_reading_date = parseDate(it.jsonPrimitive.content) listStatus.finishDate?.let { finished_reading_date = parseDate(it) }
}
obj["finish_date"]?.let {
finished_reading_date = parseDate(it.jsonPrimitive.content)
}
} }
} }
@ -292,10 +262,10 @@ class MyAnimeListApi(
.appendPath("my_list_status") .appendPath("my_list_status")
.build() .build()
fun refreshTokenRequest(oauth: OAuth): Request { fun refreshTokenRequest(oauth: MALOAuth): Request {
val formBody: RequestBody = FormBody.Builder() val formBody: RequestBody = FormBody.Builder()
.add("client_id", CLIENT_ID) .add("client_id", CLIENT_ID)
.add("refresh_token", oauth.refresh_token) .add("refresh_token", oauth.refreshToken)
.add("grant_type", "refresh_token") .add("grant_type", "refresh_token")
.build() .build()
@ -303,7 +273,7 @@ class MyAnimeListApi(
// request is called by the interceptor itself so it doesn't reach // request is called by the interceptor itself so it doesn't reach
// the part where the token is added automatically. // the part where the token is added automatically.
val headers = Headers.Builder() val headers = Headers.Builder()
.add("Authorization", "Bearer ${oauth.access_token}") .add("Authorization", "Bearer ${oauth.accessToken}")
.build() .build()
return POST("$BASE_OAUTH_URL/token", body = formBody, headers = headers) return POST("$BASE_OAUTH_URL/token", body = formBody, headers = headers)

View File

@ -1,5 +1,6 @@
package eu.kanade.tachiyomi.data.track.myanimelist package eu.kanade.tachiyomi.data.track.myanimelist
import eu.kanade.tachiyomi.data.track.myanimelist.dto.MALOAuth
import eu.kanade.tachiyomi.network.parseAs import eu.kanade.tachiyomi.network.parseAs
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import okhttp3.Interceptor import okhttp3.Interceptor
@ -11,7 +12,7 @@ class MyAnimeListInterceptor(private val myanimelist: MyAnimeList) : Interceptor
private val json: Json by injectLazy() private val json: Json by injectLazy()
private var oauth: OAuth? = myanimelist.loadOAuth() private var oauth: MALOAuth? = myanimelist.loadOAuth()
private val tokenExpired get() = myanimelist.getIfAuthExpired() private val tokenExpired get() = myanimelist.getIfAuthExpired()
override fun intercept(chain: Interceptor.Chain): Response { 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 // Add the authorization header to the original request
val authRequest = originalRequest.newBuilder() 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 // 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})") // .header("User-Agent", "Mihon v${BuildConfig.VERSION_NAME} (${BuildConfig.APPLICATION_ID})")
.build() .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 * Called when the user authenticates with MyAnimeList for the first time. Sets the refresh token
* and the oauth object. * and the oauth object.
*/ */
fun setAuth(oauth: OAuth?) { fun setAuth(oauth: MALOAuth?) {
this.oauth = oauth this.oauth = oauth
myanimelist.saveOAuth(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() if (tokenExpired) throw MALTokenExpired()
oauth?.takeUnless { it.isExpired() }?.let { return@synchronized it } oauth?.takeUnless { it.isExpired() }?.let { return@synchronized it }
@ -64,7 +65,7 @@ class MyAnimeListInterceptor(private val myanimelist: MyAnimeList) : Interceptor
return runCatching { return runCatching {
if (response.isSuccessful) { if (response.isSuccessful) {
with(json) { response.parseAs<OAuth>() } with(json) { response.parseAs<MALOAuth>() }
} else { } else {
response.close() response.close()
null null

View File

@ -1,20 +1,6 @@
package eu.kanade.tachiyomi.data.track.myanimelist package eu.kanade.tachiyomi.data.track.myanimelist
import eu.kanade.tachiyomi.data.database.models.Track 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) { fun Track.toMyAnimeListStatus() = when (status) {
MyAnimeList.READING -> "reading" MyAnimeList.READING -> "reading"

View File

@ -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?,
)

View File

@ -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 = "",
)

View File

@ -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()
}

View File

@ -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,
)

View File

@ -0,0 +1,8 @@
package eu.kanade.tachiyomi.data.track.myanimelist.dto
import kotlinx.serialization.Serializable
@Serializable
data class MALUser(
val name: String,
)

View File

@ -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,
)

View File

@ -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.BaseTracker
import eu.kanade.tachiyomi.data.track.DeletableTracker import eu.kanade.tachiyomi.data.track.DeletableTracker
import eu.kanade.tachiyomi.data.track.model.TrackSearch 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.ImmutableList
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
@ -93,7 +94,7 @@ class Shikimori(id: Long) : BaseTracker(id, "Shikimori"), DeletableTracker {
track.library_id = remoteTrack.library_id track.library_id = remoteTrack.library_id
track.copyPersonalFrom(remoteTrack) track.copyPersonalFrom(remoteTrack)
track.total_chapters = remoteTrack.total_chapters track.total_chapters = remoteTrack.total_chapters
} } ?: throw Exception("Could not find manga")
return track return track
} }
@ -128,19 +129,19 @@ class Shikimori(id: Long) : BaseTracker(id, "Shikimori"), DeletableTracker {
val oauth = api.accessToken(code) val oauth = api.accessToken(code)
interceptor.newAuth(oauth) interceptor.newAuth(oauth)
val user = api.getCurrentUser() val user = api.getCurrentUser()
saveCredentials(user.toString(), oauth.access_token) saveCredentials(user.toString(), oauth.accessToken)
} catch (e: Throwable) { } catch (e: Throwable) {
logout() logout()
} }
} }
fun saveToken(oauth: OAuth?) { fun saveToken(oauth: SMOAuth?) {
trackPreferences.trackToken(this).set(json.encodeToString(oauth)) trackPreferences.trackToken(this).set(json.encodeToString(oauth))
} }
fun restoreToken(): OAuth? { fun restoreToken(): SMOAuth? {
return try { return try {
json.decodeFromString<OAuth>(trackPreferences.trackToken(this).get()) json.decodeFromString<SMOAuth>(trackPreferences.trackToken(this).get())
} catch (e: Exception) { } catch (e: Exception) {
null null
} }

View File

@ -4,6 +4,11 @@ import android.net.Uri
import androidx.core.net.toUri import androidx.core.net.toUri
import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.model.TrackSearch import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.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.DELETE
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST 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.jsonMime
import eu.kanade.tachiyomi.network.parseAs import eu.kanade.tachiyomi.network.parseAs
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject 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.put
import kotlinx.serialization.json.putJsonObject import kotlinx.serialization.json.putJsonObject
import okhttp3.FormBody import okhttp3.FormBody
@ -58,10 +55,10 @@ class ShikimoriApi(
body = payload.toString().toRequestBody(jsonMime), body = payload.toString().toRequestBody(jsonMime),
), ),
).awaitSuccess() ).awaitSuccess()
.parseAs<JsonObject>() .parseAs<SMAddMangaResponse>()
.let { .let {
// save id of the entry for possible future delete request // save id of the entry for possible future delete request
track.library_id = it["id"]!!.jsonPrimitive.long track.library_id = it.id
} }
track track
} }
@ -88,53 +85,21 @@ class ShikimoriApi(
with(json) { with(json) {
authClient.newCall(GET(url.toString())) authClient.newCall(GET(url.toString()))
.awaitSuccess() .awaitSuccess()
.parseAs<JsonArray>() .parseAs<List<SMManga>>()
.let { response -> .map { it.toTrack(trackId) }
response.map {
jsonToSearch(it.jsonObject)
}
}
} }
} }
} }
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? { suspend fun findLibManga(track: Track, userId: String): Track? {
return withIOContext { return withIOContext {
val urlMangas = "$API_URL/mangas".toUri().buildUpon() val urlMangas = "$API_URL/mangas".toUri().buildUpon()
.appendPath(track.remote_id.toString()) .appendPath(track.remote_id.toString())
.build() .build()
val mangas = with(json) { val manga = with(json) {
authClient.newCall(GET(urlMangas.toString())) authClient.newCall(GET(urlMangas.toString()))
.awaitSuccess() .awaitSuccess()
.parseAs<JsonObject>() .parseAs<SMManga>()
} }
val url = "$API_URL/v2/user_rates".toUri().buildUpon() val url = "$API_URL/v2/user_rates".toUri().buildUpon()
@ -145,15 +110,14 @@ class ShikimoriApi(
with(json) { with(json) {
authClient.newCall(GET(url.toString())) authClient.newCall(GET(url.toString()))
.awaitSuccess() .awaitSuccess()
.parseAs<JsonArray>() .parseAs<List<SMUserListEntry>>()
.let { response -> .let { entries ->
if (response.size > 1) { if (entries.size > 1) {
throw Exception("Too much mangas in response") throw Exception("Too many manga in response")
} }
val entry = response.map { entries
jsonToTrack(it.jsonObject, mangas) .map { it.toTrack(trackId, manga) }
} .firstOrNull()
entry.firstOrNull()
} }
} }
} }
@ -163,14 +127,12 @@ class ShikimoriApi(
return with(json) { return with(json) {
authClient.newCall(GET("$API_URL/users/whoami")) authClient.newCall(GET("$API_URL/users/whoami"))
.awaitSuccess() .awaitSuccess()
.parseAs<JsonObject>() .parseAs<SMUser>()
.let { .id
it["id"]!!.jsonPrimitive.int
}
} }
} }
suspend fun accessToken(code: String): OAuth { suspend fun accessToken(code: String): SMOAuth {
return withIOContext { return withIOContext {
with(json) { with(json) {
client.newCall(accessTokenRequest(code)) client.newCall(accessTokenRequest(code))
@ -192,16 +154,16 @@ class ShikimoriApi(
) )
companion object { companion object {
private const val CLIENT_ID = "PB9dq8DzI405s7wdtwTdirYqHiyVMh--djnP7lBUqSA" const val BASE_URL = "https://shikimori.one"
private const val CLIENT_SECRET = "NajpZcOBKB9sJtgNcejf8OB9jBN1OYYoo-k4h2WWZus"
private const val BASE_URL = "https://shikimori.one"
private const val API_URL = "$BASE_URL/api" private const val API_URL = "$BASE_URL/api"
private const val OAUTH_URL = "$BASE_URL/oauth/token" private const val OAUTH_URL = "$BASE_URL/oauth/token"
private const val LOGIN_URL = "$BASE_URL/oauth/authorize" private const val LOGIN_URL = "$BASE_URL/oauth/authorize"
private const val REDIRECT_URL = "mihon://shikimori-auth" 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() fun authUrl(): Uri = LOGIN_URL.toUri().buildUpon()
.appendQueryParameter("client_id", CLIENT_ID) .appendQueryParameter("client_id", CLIENT_ID)
.appendQueryParameter("redirect_uri", REDIRECT_URL) .appendQueryParameter("redirect_uri", REDIRECT_URL)

View File

@ -1,6 +1,8 @@
package eu.kanade.tachiyomi.data.track.shikimori package eu.kanade.tachiyomi.data.track.shikimori
import eu.kanade.tachiyomi.BuildConfig 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 kotlinx.serialization.json.Json
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.Response import okhttp3.Response
@ -13,34 +15,34 @@ class ShikimoriInterceptor(private val shikimori: Shikimori) : Interceptor {
/** /**
* OAuth object used for authenticated requests. * 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 { override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request() val originalRequest = chain.request()
val currAuth = oauth ?: throw Exception("Not authenticated with Shikimori") val currAuth = oauth ?: throw Exception("Not authenticated with Shikimori")
val refreshToken = currAuth.refresh_token!! val refreshToken = currAuth.refreshToken!!
// Refresh access token if expired. // Refresh access token if expired.
if (currAuth.isExpired()) { if (currAuth.isExpired()) {
val response = chain.proceed(ShikimoriApi.refreshTokenRequest(refreshToken)) val response = chain.proceed(ShikimoriApi.refreshTokenRequest(refreshToken))
if (response.isSuccessful) { if (response.isSuccessful) {
newAuth(json.decodeFromString<OAuth>(response.body.string())) newAuth(json.decodeFromString<SMOAuth>(response.body.string()))
} else { } else {
response.close() response.close()
} }
} }
// Add the authorization header to the original request. // Add the authorization header to the original request.
val authRequest = originalRequest.newBuilder() 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("User-Agent", "Mihon v${BuildConfig.VERSION_NAME} (${BuildConfig.APPLICATION_ID})")
.build() .build()
return chain.proceed(authRequest) return chain.proceed(authRequest)
} }
fun newAuth(oauth: OAuth?) { fun newAuth(oauth: SMOAuth?) {
this.oauth = oauth this.oauth = oauth
shikimori.saveToken(oauth) shikimori.saveToken(oauth)
} }

View File

@ -1,19 +1,6 @@
package eu.kanade.tachiyomi.data.track.shikimori package eu.kanade.tachiyomi.data.track.shikimori
import eu.kanade.tachiyomi.data.database.models.Track 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) { fun Track.toShikimoriStatus() = when (status) {
Shikimori.READING -> "watching" Shikimori.READING -> "watching"

View File

@ -0,0 +1,8 @@
package eu.kanade.tachiyomi.data.track.shikimori.dto
import kotlinx.serialization.Serializable
@Serializable
data class SMAddMangaResponse(
val id: Long,
)

View File

@ -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,
)

View File

@ -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)

View File

@ -0,0 +1,8 @@
package eu.kanade.tachiyomi.data.track.shikimori.dto
import kotlinx.serialization.Serializable
@Serializable
data class SMUser(
val id: Int,
)

View File

@ -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
}
}
}