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

View File

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

View File

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

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

View File

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

View File

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

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

View File

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

View File

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

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.track.BaseTracker
import eu.kanade.tachiyomi.data.track.DeletableTracker
import eu.kanade.tachiyomi.data.track.mangaupdates.dto.ListItem
import eu.kanade.tachiyomi.data.track.mangaupdates.dto.Rating
import eu.kanade.tachiyomi.data.track.mangaupdates.dto.MUListItem
import eu.kanade.tachiyomi.data.track.mangaupdates.dto.MURating
import eu.kanade.tachiyomi.data.track.mangaupdates.dto.copyTo
import eu.kanade.tachiyomi.data.track.mangaupdates.dto.toTrackSearch
import eu.kanade.tachiyomi.data.track.model.TrackSearch
@ -106,7 +106,7 @@ class MangaUpdates(id: Long) : BaseTracker(id, "MangaUpdates"), DeletableTracker
return track.copyFrom(series, rating)
}
private fun Track.copyFrom(item: ListItem, rating: Rating?): Track = apply {
private fun Track.copyFrom(item: MUListItem, rating: MURating?): Track = apply {
item.copyTo(this)
score = rating?.rating ?: 0.0
}

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

View File

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

View File

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

View File

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

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
@Serializable
data class Rating(
data class MURating(
val rating: Double? = null,
)
fun Rating.copyTo(track: Track): Track {
fun MURating.copyTo(track: Track): Track {
return track.apply {
this.score = rating ?: 0.0
}

View File

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

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
@Serializable
data class Series(
data class MUSeries(
val id: Long? = null,
val title: String? = null,
)

View File

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

View File

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

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

View File

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

View File

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

View File

@ -1,20 +1,6 @@
package eu.kanade.tachiyomi.data.track.myanimelist
import eu.kanade.tachiyomi.data.database.models.Track
import kotlinx.serialization.Serializable
@Serializable
data class OAuth(
val token_type: String,
val refresh_token: String,
val access_token: String,
val expires_in: Long,
val created_at: Long = System.currentTimeMillis(),
) {
// Assumes expired a minute earlier
private val adjustedExpiresIn: Long = (expires_in - 60) * 1000
fun isExpired() = created_at + adjustedExpiresIn < System.currentTimeMillis()
}
fun Track.toMyAnimeListStatus() = when (status) {
MyAnimeList.READING -> "reading"

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

View File

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

View File

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

View File

@ -1,19 +1,6 @@
package eu.kanade.tachiyomi.data.track.shikimori
import eu.kanade.tachiyomi.data.database.models.Track
import kotlinx.serialization.Serializable
@Serializable
data class OAuth(
val access_token: String,
val token_type: String,
val created_at: Long,
val expires_in: Long,
val refresh_token: String?,
)
// Access token lives 1 day
fun OAuth.isExpired() = (System.currentTimeMillis() / 1000) > (created_at + expires_in - 3600)
fun Track.toShikimoriStatus() = when (status) {
Shikimori.READING -> "watching"

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