mirror of
https://github.com/mihonapp/mihon.git
synced 2025-08-19 21:11:31 +02:00
Compare commits
3 Commits
6c6ea84509
...
abfb72c89c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
abfb72c89c | ||
|
|
9c1905ede7 | ||
|
|
9f99f038f3 |
@@ -7,6 +7,7 @@ import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.database.models.Track
|
||||
import eu.kanade.tachiyomi.data.track.BaseTracker
|
||||
import eu.kanade.tachiyomi.data.track.DeletableTracker
|
||||
import eu.kanade.tachiyomi.data.track.anilist.dto.ALOAuth
|
||||
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
@@ -129,13 +130,15 @@ class Anilist(id: Long) : BaseTracker(id, "AniList"), DeletableTracker {
|
||||
0.0 -> "0 ★"
|
||||
else -> "${((score + 10) / 20).toInt()} ★"
|
||||
}
|
||||
|
||||
POINT_3 -> when {
|
||||
score == 0.0 -> "0"
|
||||
score <= 35 -> "😦"
|
||||
score <= 60 -> "😐"
|
||||
else -> "😊"
|
||||
}
|
||||
else -> track.toAnilistScore()
|
||||
|
||||
else -> track.toApiScore()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,7 +220,7 @@ class Anilist(id: Long) : BaseTracker(id, "AniList"), DeletableTracker {
|
||||
interceptor.setAuth(oauth)
|
||||
val (username, scoreType) = api.getCurrentUser()
|
||||
scorePreference.set(scoreType)
|
||||
saveCredentials(username.toString(), oauth.access_token)
|
||||
saveCredentials(username.toString(), oauth.accessToken)
|
||||
} catch (e: Throwable) {
|
||||
logout()
|
||||
}
|
||||
@@ -229,13 +232,13 @@ class Anilist(id: Long) : BaseTracker(id, "AniList"), DeletableTracker {
|
||||
interceptor.setAuth(null)
|
||||
}
|
||||
|
||||
fun saveOAuth(oAuth: OAuth?) {
|
||||
trackPreferences.trackToken(this).set(json.encodeToString(oAuth))
|
||||
fun saveOAuth(alOAuth: ALOAuth?) {
|
||||
trackPreferences.trackToken(this).set(json.encodeToString(alOAuth))
|
||||
}
|
||||
|
||||
fun loadOAuth(): OAuth? {
|
||||
fun loadOAuth(): ALOAuth? {
|
||||
return try {
|
||||
json.decodeFromString<OAuth>(trackPreferences.trackToken(this).get())
|
||||
json.decodeFromString<ALOAuth>(trackPreferences.trackToken(this).get())
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
@@ -3,6 +3,11 @@ package eu.kanade.tachiyomi.data.track.anilist
|
||||
import android.net.Uri
|
||||
import androidx.core.net.toUri
|
||||
import eu.kanade.tachiyomi.data.database.models.Track
|
||||
import eu.kanade.tachiyomi.data.track.anilist.dto.ALAddMangaResult
|
||||
import eu.kanade.tachiyomi.data.track.anilist.dto.ALCurrentUserResult
|
||||
import eu.kanade.tachiyomi.data.track.anilist.dto.ALOAuth
|
||||
import eu.kanade.tachiyomi.data.track.anilist.dto.ALSearchResult
|
||||
import eu.kanade.tachiyomi.data.track.anilist.dto.ALUserListMangaQueryResult
|
||||
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||
import eu.kanade.tachiyomi.network.POST
|
||||
import eu.kanade.tachiyomi.network.awaitSuccess
|
||||
@@ -13,14 +18,6 @@ import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.int
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.long
|
||||
import kotlinx.serialization.json.longOrNull
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlinx.serialization.json.putJsonObject
|
||||
import okhttp3.OkHttpClient
|
||||
@@ -28,7 +25,6 @@ import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import tachiyomi.core.common.util.lang.withIOContext
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import java.time.ZonedDateTime
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
@@ -59,7 +55,7 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|
||||
putJsonObject("variables") {
|
||||
put("mangaId", track.remote_id)
|
||||
put("progress", track.last_chapter_read.toInt())
|
||||
put("status", track.toAnilistStatus())
|
||||
put("status", track.toApiStatus())
|
||||
}
|
||||
}
|
||||
with(json) {
|
||||
@@ -70,10 +66,9 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|
||||
),
|
||||
)
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.parseAs<ALAddMangaResult>()
|
||||
.let {
|
||||
track.library_id =
|
||||
it["data"]!!.jsonObject["SaveMediaListEntry"]!!.jsonObject["id"]!!.jsonPrimitive.long
|
||||
track.library_id = it.data.entry.id
|
||||
track
|
||||
}
|
||||
}
|
||||
@@ -103,7 +98,7 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|
||||
putJsonObject("variables") {
|
||||
put("listId", track.library_id)
|
||||
put("progress", track.last_chapter_read.toInt())
|
||||
put("status", track.toAnilistStatus())
|
||||
put("status", track.toApiStatus())
|
||||
put("score", track.score.toInt())
|
||||
put("startedAt", createDate(track.started_reading_date))
|
||||
put("completedAt", createDate(track.finished_reading_date))
|
||||
@@ -135,6 +130,7 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|
||||
.awaitSuccess()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun search(search: String): List<TrackSearch> {
|
||||
return withIOContext {
|
||||
val query = """
|
||||
@@ -177,14 +173,9 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|
||||
),
|
||||
)
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.let { response ->
|
||||
val data = response["data"]!!.jsonObject
|
||||
val page = data["Page"]!!.jsonObject
|
||||
val media = page["media"]!!.jsonArray
|
||||
val entries = media.map { jsonToALManga(it.jsonObject) }
|
||||
entries.map { it.toTrack() }
|
||||
}
|
||||
.parseAs<ALSearchResult>()
|
||||
.data.page.media
|
||||
.map { it.toALManga().toTrack() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -247,14 +238,11 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|
||||
),
|
||||
)
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.let { response ->
|
||||
val data = response["data"]!!.jsonObject
|
||||
val page = data["Page"]!!.jsonObject
|
||||
val media = page["mediaList"]!!.jsonArray
|
||||
val entries = media.map { jsonToALUserManga(it.jsonObject) }
|
||||
entries.firstOrNull()?.toTrack()
|
||||
}
|
||||
.parseAs<ALUserListMangaQueryResult>()
|
||||
.data.page.mediaList
|
||||
.map { it.toALUserManga() }
|
||||
.firstOrNull()
|
||||
?.toTrack()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -263,8 +251,8 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|
||||
return findLibManga(track, userId) ?: throw Exception("Could not find manga")
|
||||
}
|
||||
|
||||
fun createOAuth(token: String): OAuth {
|
||||
return OAuth(token, "Bearer", System.currentTimeMillis() + 31536000000, 31536000000)
|
||||
fun createOAuth(token: String): ALOAuth {
|
||||
return ALOAuth(token, "Bearer", System.currentTimeMillis() + 31536000000, 31536000000)
|
||||
}
|
||||
|
||||
suspend fun getCurrentUser(): Pair<Int, String> {
|
||||
@@ -291,61 +279,15 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|
||||
),
|
||||
)
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.parseAs<ALCurrentUserResult>()
|
||||
.let {
|
||||
val data = it["data"]!!.jsonObject
|
||||
val viewer = data["Viewer"]!!.jsonObject
|
||||
Pair(
|
||||
viewer["id"]!!.jsonPrimitive.int,
|
||||
viewer["mediaListOptions"]!!.jsonObject["scoreFormat"]!!.jsonPrimitive.content,
|
||||
)
|
||||
val viewer = it.data.viewer
|
||||
Pair(viewer.id, viewer.mediaListOptions.scoreFormat)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun jsonToALManga(struct: JsonObject): ALManga {
|
||||
return ALManga(
|
||||
struct["id"]!!.jsonPrimitive.long,
|
||||
struct["title"]!!.jsonObject["userPreferred"]!!.jsonPrimitive.content,
|
||||
struct["coverImage"]!!.jsonObject["large"]!!.jsonPrimitive.content,
|
||||
struct["description"]!!.jsonPrimitive.contentOrNull,
|
||||
struct["format"]!!.jsonPrimitive.content.replace("_", "-"),
|
||||
struct["status"]!!.jsonPrimitive.contentOrNull ?: "",
|
||||
parseDate(struct, "startDate"),
|
||||
struct["chapters"]!!.jsonPrimitive.longOrNull ?: 0,
|
||||
struct["averageScore"]?.jsonPrimitive?.intOrNull ?: -1,
|
||||
)
|
||||
}
|
||||
|
||||
private fun jsonToALUserManga(struct: JsonObject): ALUserManga {
|
||||
return ALUserManga(
|
||||
struct["id"]!!.jsonPrimitive.long,
|
||||
struct["status"]!!.jsonPrimitive.content,
|
||||
struct["scoreRaw"]!!.jsonPrimitive.int,
|
||||
struct["progress"]!!.jsonPrimitive.int,
|
||||
parseDate(struct, "startedAt"),
|
||||
parseDate(struct, "completedAt"),
|
||||
jsonToALManga(struct["media"]!!.jsonObject),
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseDate(struct: JsonObject, dateKey: String): Long {
|
||||
return try {
|
||||
return LocalDate
|
||||
.of(
|
||||
struct[dateKey]!!.jsonObject["year"]!!.jsonPrimitive.int,
|
||||
struct[dateKey]!!.jsonObject["month"]!!.jsonPrimitive.int,
|
||||
struct[dateKey]!!.jsonObject["day"]!!.jsonPrimitive.int,
|
||||
)
|
||||
.atStartOfDay(ZoneId.systemDefault())
|
||||
.toInstant()
|
||||
.toEpochMilli()
|
||||
} catch (_: Exception) {
|
||||
0L
|
||||
}
|
||||
}
|
||||
|
||||
private fun createDate(dateValue: Long): JsonObject {
|
||||
if (dateValue == 0L) {
|
||||
return buildJsonObject {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package eu.kanade.tachiyomi.data.track.anilist
|
||||
|
||||
import eu.kanade.tachiyomi.BuildConfig
|
||||
import eu.kanade.tachiyomi.data.track.anilist.dto.ALOAuth
|
||||
import eu.kanade.tachiyomi.data.track.anilist.dto.isExpired
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import java.io.IOException
|
||||
@@ -13,7 +15,7 @@ class AnilistInterceptor(val anilist: Anilist, private var token: String?) : Int
|
||||
* Anilist returns the date without milliseconds. We fix that and make the token expire 1 minute
|
||||
* before its original expiration date.
|
||||
*/
|
||||
private var oauth: OAuth? = null
|
||||
private var oauth: ALOAuth? = null
|
||||
set(value) {
|
||||
field = value?.copy(expires = value.expires * 1000 - 60 * 1000)
|
||||
}
|
||||
@@ -40,7 +42,7 @@ class AnilistInterceptor(val anilist: Anilist, private var token: String?) : Int
|
||||
|
||||
// Add the authorization header to the original request.
|
||||
val authRequest = originalRequest.newBuilder()
|
||||
.addHeader("Authorization", "Bearer ${oauth!!.access_token}")
|
||||
.addHeader("Authorization", "Bearer ${oauth!!.accessToken}")
|
||||
.header("User-Agent", "Mihon v${BuildConfig.VERSION_NAME} (${BuildConfig.APPLICATION_ID})")
|
||||
.build()
|
||||
|
||||
@@ -51,8 +53,8 @@ class AnilistInterceptor(val anilist: Anilist, private var token: String?) : Int
|
||||
* Called when the user authenticates with Anilist for the first time. Sets the refresh token
|
||||
* and the oauth object.
|
||||
*/
|
||||
fun setAuth(oauth: OAuth?) {
|
||||
token = oauth?.access_token
|
||||
fun setAuth(oauth: ALOAuth?) {
|
||||
token = oauth?.accessToken
|
||||
this.oauth = oauth
|
||||
anilist.saveOAuth(oauth)
|
||||
}
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.track.anilist
|
||||
|
||||
import eu.kanade.domain.track.service.TrackPreferences
|
||||
import eu.kanade.tachiyomi.data.database.models.Track
|
||||
import eu.kanade.tachiyomi.data.track.TrackerManager
|
||||
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||
import eu.kanade.tachiyomi.util.lang.htmlDecode
|
||||
import kotlinx.serialization.Serializable
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import tachiyomi.domain.track.model.Track as DomainTrack
|
||||
|
||||
data class ALManga(
|
||||
val remote_id: Long,
|
||||
val title_user_pref: String,
|
||||
val image_url_lge: String,
|
||||
val description: String?,
|
||||
val format: String,
|
||||
val publishing_status: String,
|
||||
val start_date_fuzzy: Long,
|
||||
val total_chapters: Long,
|
||||
val average_score: Int,
|
||||
) {
|
||||
|
||||
fun toTrack() = TrackSearch.create(TrackerManager.ANILIST).apply {
|
||||
remote_id = this@ALManga.remote_id
|
||||
title = title_user_pref
|
||||
total_chapters = this@ALManga.total_chapters
|
||||
cover_url = image_url_lge
|
||||
summary = description?.htmlDecode() ?: ""
|
||||
score = average_score.toDouble()
|
||||
tracking_url = AnilistApi.mangaUrl(remote_id)
|
||||
publishing_status = this@ALManga.publishing_status
|
||||
publishing_type = format
|
||||
if (start_date_fuzzy != 0L) {
|
||||
start_date = try {
|
||||
val outputDf = SimpleDateFormat("yyyy-MM-dd", Locale.US)
|
||||
outputDf.format(start_date_fuzzy)
|
||||
} catch (e: Exception) {
|
||||
""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class ALUserManga(
|
||||
val library_id: Long,
|
||||
val list_status: String,
|
||||
val score_raw: Int,
|
||||
val chapters_read: Int,
|
||||
val start_date_fuzzy: Long,
|
||||
val completed_date_fuzzy: Long,
|
||||
val manga: ALManga,
|
||||
) {
|
||||
|
||||
fun toTrack() = Track.create(TrackerManager.ANILIST).apply {
|
||||
remote_id = manga.remote_id
|
||||
title = manga.title_user_pref
|
||||
status = toTrackStatus()
|
||||
score = score_raw.toDouble()
|
||||
started_reading_date = start_date_fuzzy
|
||||
finished_reading_date = completed_date_fuzzy
|
||||
last_chapter_read = chapters_read.toDouble()
|
||||
library_id = this@ALUserManga.library_id
|
||||
total_chapters = manga.total_chapters
|
||||
}
|
||||
|
||||
private fun toTrackStatus() = when (list_status) {
|
||||
"CURRENT" -> Anilist.READING
|
||||
"COMPLETED" -> Anilist.COMPLETED
|
||||
"PAUSED" -> Anilist.ON_HOLD
|
||||
"DROPPED" -> Anilist.DROPPED
|
||||
"PLANNING" -> Anilist.PLAN_TO_READ
|
||||
"REPEATING" -> Anilist.REREADING
|
||||
else -> throw NotImplementedError("Unknown status: $list_status")
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class OAuth(
|
||||
val access_token: String,
|
||||
val token_type: String,
|
||||
val expires: Long,
|
||||
val expires_in: Long,
|
||||
)
|
||||
|
||||
fun OAuth.isExpired() = System.currentTimeMillis() > expires
|
||||
|
||||
fun Track.toAnilistStatus() = when (status) {
|
||||
Anilist.READING -> "CURRENT"
|
||||
Anilist.COMPLETED -> "COMPLETED"
|
||||
Anilist.ON_HOLD -> "PAUSED"
|
||||
Anilist.DROPPED -> "DROPPED"
|
||||
Anilist.PLAN_TO_READ -> "PLANNING"
|
||||
Anilist.REREADING -> "REPEATING"
|
||||
else -> throw NotImplementedError("Unknown status: $status")
|
||||
}
|
||||
|
||||
private val preferences: TrackPreferences by injectLazy()
|
||||
|
||||
fun DomainTrack.toAnilistScore(): String = when (preferences.anilistScoreType().get()) {
|
||||
// 10 point
|
||||
"POINT_10" -> (score.toInt() / 10).toString()
|
||||
// 100 point
|
||||
"POINT_100" -> score.toInt().toString()
|
||||
// 5 stars
|
||||
"POINT_5" -> when {
|
||||
score == 0.0 -> "0"
|
||||
score < 30 -> "1"
|
||||
score < 50 -> "2"
|
||||
score < 70 -> "3"
|
||||
score < 90 -> "4"
|
||||
else -> "5"
|
||||
}
|
||||
// Smiley
|
||||
"POINT_3" -> when {
|
||||
score == 0.0 -> "0"
|
||||
score <= 35 -> ":("
|
||||
score <= 60 -> ":|"
|
||||
else -> ":)"
|
||||
}
|
||||
// 10 point decimal
|
||||
"POINT_10_DECIMAL" -> (score / 10).toString()
|
||||
else -> throw NotImplementedError("Unknown score type")
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package eu.kanade.tachiyomi.data.track.anilist
|
||||
|
||||
import eu.kanade.domain.track.service.TrackPreferences
|
||||
import eu.kanade.tachiyomi.data.database.models.Track
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import tachiyomi.domain.track.model.Track as DomainTrack
|
||||
|
||||
fun Track.toApiStatus() = when (status) {
|
||||
Anilist.READING -> "CURRENT"
|
||||
Anilist.COMPLETED -> "COMPLETED"
|
||||
Anilist.ON_HOLD -> "PAUSED"
|
||||
Anilist.DROPPED -> "DROPPED"
|
||||
Anilist.PLAN_TO_READ -> "PLANNING"
|
||||
Anilist.REREADING -> "REPEATING"
|
||||
else -> throw NotImplementedError("Unknown status: $status")
|
||||
}
|
||||
|
||||
private val preferences: TrackPreferences by injectLazy()
|
||||
|
||||
fun DomainTrack.toApiScore(): String = when (preferences.anilistScoreType().get()) {
|
||||
// 10 point
|
||||
"POINT_10" -> (score.toInt() / 10).toString()
|
||||
// 100 point
|
||||
"POINT_100" -> score.toInt().toString()
|
||||
// 5 stars
|
||||
"POINT_5" -> when {
|
||||
score == 0.0 -> "0"
|
||||
score < 30 -> "1"
|
||||
score < 50 -> "2"
|
||||
score < 70 -> "3"
|
||||
score < 90 -> "4"
|
||||
else -> "5"
|
||||
}
|
||||
// Smiley
|
||||
"POINT_3" -> when {
|
||||
score == 0.0 -> "0"
|
||||
score <= 35 -> ":("
|
||||
score <= 60 -> ":|"
|
||||
else -> ":)"
|
||||
}
|
||||
// 10 point decimal
|
||||
"POINT_10_DECIMAL" -> (score / 10).toString()
|
||||
else -> throw NotImplementedError("Unknown score type")
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package eu.kanade.tachiyomi.data.track.anilist.dto
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class ALAddMangaResult(
|
||||
val data: ALAddMangaData,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ALAddMangaData(
|
||||
@SerialName("SaveMediaListEntry")
|
||||
val entry: ALAddMangaEntry,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ALAddMangaEntry(
|
||||
val id: Long,
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
package eu.kanade.tachiyomi.data.track.anilist.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
|
||||
@Serializable
|
||||
data class ALFuzzyDate(
|
||||
val year: Int?,
|
||||
val month: Int?,
|
||||
val day: Int?,
|
||||
) {
|
||||
fun toEpochMilli(): Long = try {
|
||||
LocalDate.of(year!!, month!!, day!!)
|
||||
.atStartOfDay(ZoneId.systemDefault())
|
||||
.toInstant()
|
||||
.toEpochMilli()
|
||||
} catch (_: Exception) {
|
||||
0L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package eu.kanade.tachiyomi.data.track.anilist.dto
|
||||
|
||||
import eu.kanade.tachiyomi.data.database.models.Track
|
||||
import eu.kanade.tachiyomi.data.track.TrackerManager
|
||||
import eu.kanade.tachiyomi.data.track.anilist.Anilist
|
||||
import eu.kanade.tachiyomi.data.track.anilist.AnilistApi
|
||||
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||
import eu.kanade.tachiyomi.util.lang.htmlDecode
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
data class ALManga(
|
||||
val remoteId: Long,
|
||||
val title: String,
|
||||
val imageUrl: String,
|
||||
val description: String?,
|
||||
val format: String,
|
||||
val publishingStatus: String,
|
||||
val startDateFuzzy: Long,
|
||||
val totalChapters: Long,
|
||||
val averageScore: Int,
|
||||
) {
|
||||
fun toTrack() = TrackSearch.create(TrackerManager.ANILIST).apply {
|
||||
remote_id = remoteId
|
||||
title = this@ALManga.title
|
||||
total_chapters = totalChapters
|
||||
cover_url = imageUrl
|
||||
summary = description?.htmlDecode() ?: ""
|
||||
score = averageScore.toDouble()
|
||||
tracking_url = AnilistApi.mangaUrl(remote_id)
|
||||
publishing_status = publishingStatus
|
||||
publishing_type = format
|
||||
if (startDateFuzzy != 0L) {
|
||||
start_date = try {
|
||||
val outputDf = SimpleDateFormat("yyyy-MM-dd", Locale.US)
|
||||
outputDf.format(startDateFuzzy)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class ALUserManga(
|
||||
val libraryId: Long,
|
||||
val listStatus: String,
|
||||
val scoreRaw: Int,
|
||||
val chaptersRead: Int,
|
||||
val startDateFuzzy: Long,
|
||||
val completedDateFuzzy: Long,
|
||||
val manga: ALManga,
|
||||
) {
|
||||
fun toTrack() = Track.create(TrackerManager.ANILIST).apply {
|
||||
remote_id = manga.remoteId
|
||||
title = manga.title
|
||||
status = toTrackStatus()
|
||||
score = scoreRaw.toDouble()
|
||||
started_reading_date = startDateFuzzy
|
||||
finished_reading_date = completedDateFuzzy
|
||||
last_chapter_read = chaptersRead.toDouble()
|
||||
library_id = libraryId
|
||||
total_chapters = manga.totalChapters
|
||||
}
|
||||
|
||||
private fun toTrackStatus() = when (listStatus) {
|
||||
"CURRENT" -> Anilist.READING
|
||||
"COMPLETED" -> Anilist.COMPLETED
|
||||
"PAUSED" -> Anilist.ON_HOLD
|
||||
"DROPPED" -> Anilist.DROPPED
|
||||
"PLANNING" -> Anilist.PLAN_TO_READ
|
||||
"REPEATING" -> Anilist.REREADING
|
||||
else -> throw NotImplementedError("Unknown status: $listStatus")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package eu.kanade.tachiyomi.data.track.anilist.dto
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class ALOAuth(
|
||||
@SerialName("access_token")
|
||||
val accessToken: String,
|
||||
@SerialName("token_type")
|
||||
val tokenType: String,
|
||||
val expires: Long,
|
||||
@SerialName("expires_in")
|
||||
val expiresIn: Long,
|
||||
)
|
||||
|
||||
fun ALOAuth.isExpired() = System.currentTimeMillis() > expires
|
||||
@@ -0,0 +1,20 @@
|
||||
package eu.kanade.tachiyomi.data.track.anilist.dto
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class ALSearchResult(
|
||||
val data: ALSearchPage,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ALSearchPage(
|
||||
@SerialName("Page")
|
||||
val page: ALSearchMedia,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ALSearchMedia(
|
||||
val media: List<ALSearchItem>,
|
||||
)
|
||||
@@ -0,0 +1,38 @@
|
||||
package eu.kanade.tachiyomi.data.track.anilist.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class ALSearchItem(
|
||||
val id: Long,
|
||||
val title: ALItemTitle,
|
||||
val coverImage: ItemCover,
|
||||
val description: String?,
|
||||
val format: String,
|
||||
val status: String = "",
|
||||
val startDate: ALFuzzyDate,
|
||||
val chapters: Long?,
|
||||
val averageScore: Int?,
|
||||
) {
|
||||
fun toALManga(): ALManga = ALManga(
|
||||
remoteId = id,
|
||||
title = title.userPreferred,
|
||||
imageUrl = coverImage.large,
|
||||
description = description,
|
||||
format = format.replace("_", "-"),
|
||||
publishingStatus = status,
|
||||
startDateFuzzy = startDate.toEpochMilli(),
|
||||
totalChapters = chapters ?: 0,
|
||||
averageScore = averageScore ?: -1,
|
||||
)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ALItemTitle(
|
||||
val userPreferred: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ItemCover(
|
||||
val large: String,
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
package eu.kanade.tachiyomi.data.track.anilist.dto
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class ALCurrentUserResult(
|
||||
val data: ALUserViewer,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ALUserViewer(
|
||||
@SerialName("Viewer")
|
||||
val viewer: ALUserViewerData,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ALUserViewerData(
|
||||
val id: Int,
|
||||
val mediaListOptions: ALUserListOptions,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ALUserListOptions(
|
||||
val scoreFormat: String,
|
||||
)
|
||||
@@ -0,0 +1,43 @@
|
||||
package eu.kanade.tachiyomi.data.track.anilist.dto
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class ALUserListMangaQueryResult(
|
||||
val data: ALUserListMangaPage,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ALUserListMangaPage(
|
||||
@SerialName("Page")
|
||||
val page: ALUserListMediaList,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ALUserListMediaList(
|
||||
val mediaList: List<ALUserListItem>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ALUserListItem(
|
||||
val id: Long,
|
||||
val status: String,
|
||||
val scoreRaw: Int,
|
||||
val progress: Int,
|
||||
val startedAt: ALFuzzyDate,
|
||||
val completedAt: ALFuzzyDate,
|
||||
val media: ALSearchItem,
|
||||
) {
|
||||
fun toALUserManga(): ALUserManga {
|
||||
return ALUserManga(
|
||||
libraryId = this@ALUserListItem.id,
|
||||
listStatus = status,
|
||||
scoreRaw = scoreRaw,
|
||||
chaptersRead = progress,
|
||||
startDateFuzzy = startedAt.toEpochMilli(),
|
||||
completedDateFuzzy = completedAt.toEpochMilli(),
|
||||
manga = media.toALManga(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import dev.icerock.moko.resources.StringResource
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.database.models.Track
|
||||
import eu.kanade.tachiyomi.data.track.BaseTracker
|
||||
import eu.kanade.tachiyomi.data.track.bangumi.dto.BGMOAuth
|
||||
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
@@ -75,8 +76,8 @@ class Bangumi(id: Long) : BaseTracker(id, "Bangumi") {
|
||||
}
|
||||
|
||||
override suspend fun refresh(track: Track): Track {
|
||||
val remoteStatusTrack = api.statusLibManga(track)
|
||||
track.copyPersonalFrom(remoteStatusTrack!!)
|
||||
val remoteStatusTrack = api.statusLibManga(track) ?: throw Exception("Could not find manga")
|
||||
track.copyPersonalFrom(remoteStatusTrack)
|
||||
api.findLibManga(track)?.let { remoteTrack ->
|
||||
track.total_chapters = remoteTrack.total_chapters
|
||||
}
|
||||
@@ -112,19 +113,19 @@ class Bangumi(id: Long) : BaseTracker(id, "Bangumi") {
|
||||
try {
|
||||
val oauth = api.accessToken(code)
|
||||
interceptor.newAuth(oauth)
|
||||
saveCredentials(oauth.user_id.toString(), oauth.access_token)
|
||||
saveCredentials(oauth.userId.toString(), oauth.accessToken)
|
||||
} catch (e: Throwable) {
|
||||
logout()
|
||||
}
|
||||
}
|
||||
|
||||
fun saveToken(oauth: OAuth?) {
|
||||
fun saveToken(oauth: BGMOAuth?) {
|
||||
trackPreferences.trackToken(this).set(json.encodeToString(oauth))
|
||||
}
|
||||
|
||||
fun restoreToken(): OAuth? {
|
||||
fun restoreToken(): BGMOAuth? {
|
||||
return try {
|
||||
json.decodeFromString<OAuth>(trackPreferences.trackToken(this).get())
|
||||
json.decodeFromString<BGMOAuth>(trackPreferences.trackToken(this).get())
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
@@ -3,20 +3,16 @@ package eu.kanade.tachiyomi.data.track.bangumi
|
||||
import android.net.Uri
|
||||
import androidx.core.net.toUri
|
||||
import eu.kanade.tachiyomi.data.database.models.Track
|
||||
import eu.kanade.tachiyomi.data.track.bangumi.dto.BGMCollectionResponse
|
||||
import eu.kanade.tachiyomi.data.track.bangumi.dto.BGMOAuth
|
||||
import eu.kanade.tachiyomi.data.track.bangumi.dto.BGMSearchItem
|
||||
import eu.kanade.tachiyomi.data.track.bangumi.dto.BGMSearchResult
|
||||
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.POST
|
||||
import eu.kanade.tachiyomi.network.awaitSuccess
|
||||
import eu.kanade.tachiyomi.network.parseAs
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.doubleOrNull
|
||||
import kotlinx.serialization.json.int
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.long
|
||||
import okhttp3.CacheControl
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.OkHttpClient
|
||||
@@ -40,7 +36,7 @@ class BangumiApi(
|
||||
return withIOContext {
|
||||
val body = FormBody.Builder()
|
||||
.add("rating", track.score.toInt().toString())
|
||||
.add("status", track.toBangumiStatus())
|
||||
.add("status", track.toApiStatus())
|
||||
.build()
|
||||
authClient.newCall(POST("$API_URL/collection/${track.remote_id}/update", body = body))
|
||||
.awaitSuccess()
|
||||
@@ -53,7 +49,7 @@ class BangumiApi(
|
||||
// read status update
|
||||
val sbody = FormBody.Builder()
|
||||
.add("rating", track.score.toInt().toString())
|
||||
.add("status", track.toBangumiStatus())
|
||||
.add("status", track.toApiStatus())
|
||||
.build()
|
||||
authClient.newCall(POST("$API_URL/collection/${track.remote_id}/update", body = sbody))
|
||||
.awaitSuccess()
|
||||
@@ -63,10 +59,7 @@ class BangumiApi(
|
||||
.add("watched_eps", track.last_chapter_read.toInt().toString())
|
||||
.build()
|
||||
authClient.newCall(
|
||||
POST(
|
||||
"$API_URL/subject/${track.remote_id}/update/watched_eps",
|
||||
body = body,
|
||||
),
|
||||
POST("$API_URL/subject/${track.remote_id}/update/watched_eps", body = body),
|
||||
).awaitSuccess()
|
||||
|
||||
track
|
||||
@@ -80,44 +73,19 @@ class BangumiApi(
|
||||
.buildUpon()
|
||||
.appendQueryParameter("max_results", "20")
|
||||
.build()
|
||||
authClient.newCall(GET(url.toString()))
|
||||
.awaitSuccess()
|
||||
.use {
|
||||
var responseBody = it.body.string()
|
||||
if (responseBody.isEmpty()) {
|
||||
throw Exception("Null Response")
|
||||
}
|
||||
if (responseBody.contains("\"code\":404")) {
|
||||
responseBody = "{\"results\":0,\"list\":[]}"
|
||||
}
|
||||
val response = json.decodeFromString<JsonObject>(responseBody)["list"]?.jsonArray
|
||||
response?.filter { it.jsonObject["type"]?.jsonPrimitive?.int == 1 }
|
||||
?.map { jsonToSearch(it.jsonObject) }.orEmpty()
|
||||
}
|
||||
}
|
||||
}
|
||||
with(json) {
|
||||
authClient.newCall(GET(url.toString()))
|
||||
.awaitSuccess()
|
||||
.parseAs<BGMSearchResult>()
|
||||
.let { result ->
|
||||
if (result.code == 404) emptyList<TrackSearch>()
|
||||
|
||||
private fun jsonToSearch(obj: JsonObject): TrackSearch {
|
||||
val coverUrl = if (obj["images"] is JsonObject) {
|
||||
obj["images"]?.jsonObject?.get("common")?.jsonPrimitive?.contentOrNull ?: ""
|
||||
} else {
|
||||
// Sometimes JsonNull
|
||||
""
|
||||
}
|
||||
val totalChapters = if (obj["eps_count"] != null) {
|
||||
obj["eps_count"]!!.jsonPrimitive.long
|
||||
} else {
|
||||
0
|
||||
}
|
||||
val rating = obj["rating"]?.jsonObject?.get("score")?.jsonPrimitive?.doubleOrNull ?: -1.0
|
||||
return TrackSearch.create(trackId).apply {
|
||||
remote_id = obj["id"]!!.jsonPrimitive.long
|
||||
title = obj["name_cn"]!!.jsonPrimitive.content
|
||||
cover_url = coverUrl
|
||||
summary = obj["name"]!!.jsonPrimitive.content
|
||||
score = rating
|
||||
tracking_url = obj["url"]!!.jsonPrimitive.content
|
||||
total_chapters = totalChapters
|
||||
result.list
|
||||
?.filter { it.type == 1 }
|
||||
?.map { it.toTrackSearch(trackId) }
|
||||
.orEmpty()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,8 +94,8 @@ class BangumiApi(
|
||||
with(json) {
|
||||
authClient.newCall(GET("$API_URL/subject/${track.remote_id}"))
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.let { jsonToSearch(it) }
|
||||
.parseAs<BGMSearchItem>()
|
||||
.toTrackSearch(trackId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -142,25 +110,23 @@ class BangumiApi(
|
||||
.build()
|
||||
|
||||
// TODO: get user readed chapter here
|
||||
val response = authClient.newCall(requestUserRead).awaitSuccess()
|
||||
val responseBody = response.body.string()
|
||||
if (responseBody.isEmpty()) {
|
||||
throw Exception("Null Response")
|
||||
}
|
||||
if (responseBody.contains("\"code\":400")) {
|
||||
null
|
||||
} else {
|
||||
json.decodeFromString<Collection>(responseBody).let {
|
||||
track.status = it.status?.id!!
|
||||
track.last_chapter_read = it.ep_status!!.toDouble()
|
||||
track.score = it.rating!!
|
||||
track
|
||||
}
|
||||
with(json) {
|
||||
authClient.newCall(requestUserRead)
|
||||
.awaitSuccess()
|
||||
.parseAs<BGMCollectionResponse>()
|
||||
.let {
|
||||
if (it.code == 400) return@let null
|
||||
|
||||
track.status = it.status?.id!!
|
||||
track.last_chapter_read = it.epStatus!!.toDouble()
|
||||
track.score = it.rating!!
|
||||
track
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun accessToken(code: String): OAuth {
|
||||
suspend fun accessToken(code: String): BGMOAuth {
|
||||
return withIOContext {
|
||||
with(json) {
|
||||
client.newCall(accessTokenRequest(code))
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package eu.kanade.tachiyomi.data.track.bangumi
|
||||
|
||||
import eu.kanade.tachiyomi.BuildConfig
|
||||
import eu.kanade.tachiyomi.data.track.bangumi.dto.BGMOAuth
|
||||
import eu.kanade.tachiyomi.data.track.bangumi.dto.isExpired
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.Interceptor
|
||||
@@ -14,7 +16,7 @@ class BangumiInterceptor(private val bangumi: Bangumi) : Interceptor {
|
||||
/**
|
||||
* OAuth object used for authenticated requests.
|
||||
*/
|
||||
private var oauth: OAuth? = bangumi.restoreToken()
|
||||
private var oauth: BGMOAuth? = bangumi.restoreToken()
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val originalRequest = chain.request()
|
||||
@@ -22,9 +24,9 @@ class BangumiInterceptor(private val bangumi: Bangumi) : Interceptor {
|
||||
val currAuth = oauth ?: throw Exception("Not authenticated with Bangumi")
|
||||
|
||||
if (currAuth.isExpired()) {
|
||||
val response = chain.proceed(BangumiApi.refreshTokenRequest(currAuth.refresh_token!!))
|
||||
val response = chain.proceed(BangumiApi.refreshTokenRequest(currAuth.refreshToken!!))
|
||||
if (response.isSuccessful) {
|
||||
newAuth(json.decodeFromString<OAuth>(response.body.string()))
|
||||
newAuth(json.decodeFromString<BGMOAuth>(response.body.string()))
|
||||
} else {
|
||||
response.close()
|
||||
}
|
||||
@@ -38,28 +40,28 @@ class BangumiInterceptor(private val bangumi: Bangumi) : Interceptor {
|
||||
.apply {
|
||||
if (originalRequest.method == "GET") {
|
||||
val newUrl = originalRequest.url.newBuilder()
|
||||
.addQueryParameter("access_token", currAuth.access_token)
|
||||
.addQueryParameter("access_token", currAuth.accessToken)
|
||||
.build()
|
||||
url(newUrl)
|
||||
} else {
|
||||
post(addToken(currAuth.access_token, originalRequest.body as FormBody))
|
||||
post(addToken(currAuth.accessToken, originalRequest.body as FormBody))
|
||||
}
|
||||
}
|
||||
.build()
|
||||
.let(chain::proceed)
|
||||
}
|
||||
|
||||
fun newAuth(oauth: OAuth?) {
|
||||
fun newAuth(oauth: BGMOAuth?) {
|
||||
this.oauth = if (oauth == null) {
|
||||
null
|
||||
} else {
|
||||
OAuth(
|
||||
oauth.access_token,
|
||||
oauth.token_type,
|
||||
BGMOAuth(
|
||||
oauth.accessToken,
|
||||
oauth.tokenType,
|
||||
System.currentTimeMillis() / 1000,
|
||||
oauth.expires_in,
|
||||
oauth.refresh_token,
|
||||
this.oauth?.user_id,
|
||||
oauth.expiresIn,
|
||||
oauth.refreshToken,
|
||||
this.oauth?.userId,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.track.bangumi
|
||||
|
||||
import eu.kanade.tachiyomi.data.database.models.Track
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class Avatar(
|
||||
val large: String? = "",
|
||||
val medium: String? = "",
|
||||
val small: String? = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Collection(
|
||||
val `private`: Int? = 0,
|
||||
val comment: String? = "",
|
||||
val ep_status: Int? = 0,
|
||||
val lasttouch: Int? = 0,
|
||||
val rating: Double? = 0.0,
|
||||
val status: Status? = Status(),
|
||||
val tag: List<String?>? = emptyList(),
|
||||
val user: User? = User(),
|
||||
val vol_status: Int? = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Status(
|
||||
val id: Long? = 0,
|
||||
val name: String? = "",
|
||||
val type: String? = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class User(
|
||||
val avatar: Avatar? = Avatar(),
|
||||
val id: Int? = 0,
|
||||
val nickname: String? = "",
|
||||
val sign: String? = "",
|
||||
val url: String? = "",
|
||||
val usergroup: Int? = 0,
|
||||
val username: String? = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class OAuth(
|
||||
val access_token: String,
|
||||
val token_type: String,
|
||||
val created_at: Long = System.currentTimeMillis() / 1000,
|
||||
val expires_in: Long,
|
||||
val refresh_token: String?,
|
||||
val user_id: Long?,
|
||||
)
|
||||
|
||||
// Access token refresh before expired
|
||||
fun OAuth.isExpired() = (System.currentTimeMillis() / 1000) > (created_at + expires_in - 3600)
|
||||
|
||||
fun Track.toBangumiStatus() = when (status) {
|
||||
Bangumi.READING -> "do"
|
||||
Bangumi.COMPLETED -> "collect"
|
||||
Bangumi.ON_HOLD -> "on_hold"
|
||||
Bangumi.DROPPED -> "dropped"
|
||||
Bangumi.PLAN_TO_READ -> "wish"
|
||||
else -> throw NotImplementedError("Unknown status: $status")
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package eu.kanade.tachiyomi.data.track.bangumi
|
||||
|
||||
import eu.kanade.tachiyomi.data.database.models.Track
|
||||
|
||||
fun Track.toApiStatus() = when (status) {
|
||||
Bangumi.READING -> "do"
|
||||
Bangumi.COMPLETED -> "collect"
|
||||
Bangumi.ON_HOLD -> "on_hold"
|
||||
Bangumi.DROPPED -> "dropped"
|
||||
Bangumi.PLAN_TO_READ -> "wish"
|
||||
else -> throw NotImplementedError("Unknown status: $status")
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package eu.kanade.tachiyomi.data.track.bangumi.dto
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class BGMCollectionResponse(
|
||||
val code: Int?,
|
||||
val `private`: Int? = 0,
|
||||
val comment: String? = "",
|
||||
@SerialName("ep_status")
|
||||
val epStatus: Int? = 0,
|
||||
@SerialName("lasttouch")
|
||||
val lastTouch: Int? = 0,
|
||||
val rating: Double? = 0.0,
|
||||
val status: Status? = Status(),
|
||||
val tag: List<String?>? = emptyList(),
|
||||
val user: User? = User(),
|
||||
@SerialName("vol_status")
|
||||
val volStatus: Int? = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Status(
|
||||
val id: Long? = 0,
|
||||
val name: String? = "",
|
||||
val type: String? = "",
|
||||
)
|
||||
@@ -0,0 +1,23 @@
|
||||
package eu.kanade.tachiyomi.data.track.bangumi.dto
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class BGMOAuth(
|
||||
@SerialName("access_token")
|
||||
val accessToken: String,
|
||||
@SerialName("token_type")
|
||||
val tokenType: String,
|
||||
@SerialName("created_at")
|
||||
val createdAt: Long = System.currentTimeMillis() / 1000,
|
||||
@SerialName("expires_in")
|
||||
val expiresIn: Long,
|
||||
@SerialName("refresh_token")
|
||||
val refreshToken: String?,
|
||||
@SerialName("user_id")
|
||||
val userId: Long?,
|
||||
)
|
||||
|
||||
// Access token refresh before expired
|
||||
fun BGMOAuth.isExpired() = (System.currentTimeMillis() / 1000) > (createdAt + expiresIn - 3600)
|
||||
@@ -0,0 +1,45 @@
|
||||
package eu.kanade.tachiyomi.data.track.bangumi.dto
|
||||
|
||||
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class BGMSearchResult(
|
||||
val list: List<BGMSearchItem>?,
|
||||
val code: Int?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class BGMSearchItem(
|
||||
val id: Long,
|
||||
@SerialName("name_cn")
|
||||
val nameCn: String,
|
||||
val name: String,
|
||||
val type: Int,
|
||||
val images: BGMSearchItemCovers?,
|
||||
@SerialName("eps_count")
|
||||
val epsCount: Long?,
|
||||
val rating: BGMSearchItemRating?,
|
||||
val url: String,
|
||||
) {
|
||||
fun toTrackSearch(trackId: Long): TrackSearch = TrackSearch.create(trackId).apply {
|
||||
remote_id = this@BGMSearchItem.id
|
||||
title = nameCn
|
||||
cover_url = images?.common ?: ""
|
||||
summary = this@BGMSearchItem.name
|
||||
score = rating?.score ?: -1.0
|
||||
tracking_url = url
|
||||
total_chapters = epsCount ?: 0
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class BGMSearchItemCovers(
|
||||
val common: String?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class BGMSearchItemRating(
|
||||
val score: Double?,
|
||||
)
|
||||
@@ -0,0 +1,23 @@
|
||||
package eu.kanade.tachiyomi.data.track.bangumi.dto
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class Avatar(
|
||||
val large: String? = "",
|
||||
val medium: String? = "",
|
||||
val small: String? = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class User(
|
||||
val avatar: Avatar? = Avatar(),
|
||||
val id: Int? = 0,
|
||||
val nickname: String? = "",
|
||||
val sign: String? = "",
|
||||
val url: String? = "",
|
||||
@SerialName("usergroup")
|
||||
val userGroup: Int? = 0,
|
||||
val username: String? = "",
|
||||
)
|
||||
@@ -6,6 +6,7 @@ import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.database.models.Track
|
||||
import eu.kanade.tachiyomi.data.track.BaseTracker
|
||||
import eu.kanade.tachiyomi.data.track.DeletableTracker
|
||||
import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuOAuth
|
||||
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
@@ -142,13 +143,13 @@ class Kitsu(id: Long) : BaseTracker(id, "Kitsu"), DeletableTracker {
|
||||
return getPassword()
|
||||
}
|
||||
|
||||
fun saveToken(oauth: OAuth?) {
|
||||
fun saveToken(oauth: KitsuOAuth?) {
|
||||
trackPreferences.trackToken(this).set(json.encodeToString(oauth))
|
||||
}
|
||||
|
||||
fun restoreToken(): OAuth? {
|
||||
fun restoreToken(): KitsuOAuth? {
|
||||
return try {
|
||||
json.decodeFromString<OAuth>(trackPreferences.trackToken(this).get())
|
||||
json.decodeFromString<KitsuOAuth>(trackPreferences.trackToken(this).get())
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
@@ -2,6 +2,12 @@ package eu.kanade.tachiyomi.data.track.kitsu
|
||||
|
||||
import androidx.core.net.toUri
|
||||
import eu.kanade.tachiyomi.data.database.models.Track
|
||||
import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuAddMangaResult
|
||||
import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuAlgoliaSearchResult
|
||||
import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuCurrentUserResult
|
||||
import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuListSearchResult
|
||||
import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuOAuth
|
||||
import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuSearchResult
|
||||
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||
import eu.kanade.tachiyomi.network.DELETE
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
@@ -10,12 +16,7 @@ import eu.kanade.tachiyomi.network.awaitSuccess
|
||||
import eu.kanade.tachiyomi.network.jsonMime
|
||||
import eu.kanade.tachiyomi.network.parseAs
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.long
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlinx.serialization.json.putJsonObject
|
||||
import okhttp3.FormBody
|
||||
@@ -43,7 +44,7 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
|
||||
putJsonObject("data") {
|
||||
put("type", "libraryEntries")
|
||||
putJsonObject("attributes") {
|
||||
put("status", track.toKitsuStatus())
|
||||
put("status", track.toApiStatus())
|
||||
put("progress", track.last_chapter_read.toInt())
|
||||
}
|
||||
putJsonObject("relationships") {
|
||||
@@ -67,18 +68,14 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
|
||||
authClient.newCall(
|
||||
POST(
|
||||
"${BASE_URL}library-entries",
|
||||
headers = headersOf(
|
||||
"Content-Type",
|
||||
"application/vnd.api+json",
|
||||
),
|
||||
body = data.toString()
|
||||
.toRequestBody("application/vnd.api+json".toMediaType()),
|
||||
headers = headersOf("Content-Type", VND_API_JSON),
|
||||
body = data.toString().toRequestBody(VND_JSON_MEDIA_TYPE),
|
||||
),
|
||||
)
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.parseAs<KitsuAddMangaResult>()
|
||||
.let {
|
||||
track.remote_id = it["data"]!!.jsonObject["id"]!!.jsonPrimitive.long
|
||||
track.remote_id = it.data.id
|
||||
track
|
||||
}
|
||||
}
|
||||
@@ -92,63 +89,50 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
|
||||
put("type", "libraryEntries")
|
||||
put("id", track.remote_id)
|
||||
putJsonObject("attributes") {
|
||||
put("status", track.toKitsuStatus())
|
||||
put("status", track.toApiStatus())
|
||||
put("progress", track.last_chapter_read.toInt())
|
||||
put("ratingTwenty", track.toKitsuScore())
|
||||
put("ratingTwenty", track.toApiScore())
|
||||
put("startedAt", KitsuDateHelper.convert(track.started_reading_date))
|
||||
put("finishedAt", KitsuDateHelper.convert(track.finished_reading_date))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
with(json) {
|
||||
authClient.newCall(
|
||||
Request.Builder()
|
||||
.url("${BASE_URL}library-entries/${track.remote_id}")
|
||||
.headers(
|
||||
headersOf(
|
||||
"Content-Type",
|
||||
"application/vnd.api+json",
|
||||
),
|
||||
)
|
||||
.patch(
|
||||
data.toString().toRequestBody("application/vnd.api+json".toMediaType()),
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.let {
|
||||
track
|
||||
}
|
||||
}
|
||||
authClient.newCall(
|
||||
Request.Builder()
|
||||
.url("${BASE_URL}library-entries/${track.remote_id}")
|
||||
.headers(
|
||||
headersOf("Content-Type", VND_API_JSON),
|
||||
)
|
||||
.patch(data.toString().toRequestBody(VND_JSON_MEDIA_TYPE))
|
||||
.build(),
|
||||
)
|
||||
.awaitSuccess()
|
||||
|
||||
track
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun removeLibManga(track: DomainTrack) {
|
||||
withIOContext {
|
||||
authClient
|
||||
.newCall(
|
||||
DELETE(
|
||||
"${BASE_URL}library-entries/${track.remoteId}",
|
||||
headers = headersOf(
|
||||
"Content-Type",
|
||||
"application/vnd.api+json",
|
||||
),
|
||||
),
|
||||
)
|
||||
authClient.newCall(
|
||||
DELETE(
|
||||
"${BASE_URL}library-entries/${track.remoteId}",
|
||||
headers = headersOf("Content-Type", VND_API_JSON),
|
||||
),
|
||||
)
|
||||
.awaitSuccess()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun search(query: String): List<TrackSearch> {
|
||||
return withIOContext {
|
||||
with(json) {
|
||||
authClient.newCall(GET(ALGOLIA_KEY_URL))
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.parseAs<KitsuSearchResult>()
|
||||
.let {
|
||||
val key = it["media"]!!.jsonObject["key"]!!.jsonPrimitive.content
|
||||
algoliaSearch(key, query)
|
||||
algoliaSearch(it.media.key, query)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -174,13 +158,10 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
|
||||
),
|
||||
)
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.let {
|
||||
it["hits"]!!.jsonArray
|
||||
.map { KitsuSearchManga(it.jsonObject) }
|
||||
.filter { it.subType != "novel" }
|
||||
.map { it.toTrack() }
|
||||
}
|
||||
.parseAs<KitsuAlgoliaSearchResult>()
|
||||
.hits
|
||||
.filter { it.subtype != "novel" }
|
||||
.map { it.toTrack() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -194,12 +175,10 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
|
||||
with(json) {
|
||||
authClient.newCall(GET(url.toString()))
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.parseAs<KitsuListSearchResult>()
|
||||
.let {
|
||||
val data = it["data"]!!.jsonArray
|
||||
if (data.size > 0) {
|
||||
val manga = it["included"]!!.jsonArray[0].jsonObject
|
||||
KitsuLibManga(data[0].jsonObject, manga).toTrack()
|
||||
if (it.data.isNotEmpty() && it.included.isNotEmpty()) {
|
||||
it.firstToTrack()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
@@ -217,12 +196,10 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
|
||||
with(json) {
|
||||
authClient.newCall(GET(url.toString()))
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.parseAs<KitsuListSearchResult>()
|
||||
.let {
|
||||
val data = it["data"]!!.jsonArray
|
||||
if (data.size > 0) {
|
||||
val manga = it["included"]!!.jsonArray[0].jsonObject
|
||||
KitsuLibManga(data[0].jsonObject, manga).toTrack()
|
||||
if (it.data.isNotEmpty() && it.included.isNotEmpty()) {
|
||||
it.firstToTrack()
|
||||
} else {
|
||||
throw Exception("Could not find manga")
|
||||
}
|
||||
@@ -231,7 +208,7 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun login(username: String, password: String): OAuth {
|
||||
suspend fun login(username: String, password: String): KitsuOAuth {
|
||||
return withIOContext {
|
||||
val formBody: RequestBody = FormBody.Builder()
|
||||
.add("username", username)
|
||||
@@ -256,10 +233,9 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
|
||||
with(json) {
|
||||
authClient.newCall(GET(url.toString()))
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.let {
|
||||
it["data"]!!.jsonArray[0].jsonObject["id"]!!.jsonPrimitive.content
|
||||
}
|
||||
.parseAs<KitsuCurrentUserResult>()
|
||||
.data[0]
|
||||
.id
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -279,6 +255,9 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
|
||||
"%5B%22synopsis%22%2C%22averageRating%22%2C%22canonicalTitle%22%2C%22chapterCount%22%2C%22" +
|
||||
"posterImage%22%2C%22startDate%22%2C%22subtype%22%2C%22endDate%22%2C%20%22id%22%5D"
|
||||
|
||||
private const val VND_API_JSON = "application/vnd.api+json"
|
||||
private val VND_JSON_MEDIA_TYPE = VND_API_JSON.toMediaType()
|
||||
|
||||
fun mangaUrl(remoteId: Long): String {
|
||||
return BASE_MANGA_URL + remoteId
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package eu.kanade.tachiyomi.data.track.kitsu
|
||||
|
||||
import eu.kanade.tachiyomi.BuildConfig
|
||||
import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuOAuth
|
||||
import eu.kanade.tachiyomi.data.track.kitsu.dto.isExpired
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
@@ -13,14 +15,14 @@ class KitsuInterceptor(private val kitsu: Kitsu) : Interceptor {
|
||||
/**
|
||||
* OAuth object used for authenticated requests.
|
||||
*/
|
||||
private var oauth: OAuth? = kitsu.restoreToken()
|
||||
private var oauth: KitsuOAuth? = kitsu.restoreToken()
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val originalRequest = chain.request()
|
||||
|
||||
val currAuth = oauth ?: throw Exception("Not authenticated with Kitsu")
|
||||
|
||||
val refreshToken = currAuth.refresh_token!!
|
||||
val refreshToken = currAuth.refreshToken!!
|
||||
|
||||
// Refresh access token if expired.
|
||||
if (currAuth.isExpired()) {
|
||||
@@ -34,7 +36,7 @@ class KitsuInterceptor(private val kitsu: Kitsu) : Interceptor {
|
||||
|
||||
// Add the authorization header to the original request.
|
||||
val authRequest = originalRequest.newBuilder()
|
||||
.addHeader("Authorization", "Bearer ${oauth!!.access_token}")
|
||||
.addHeader("Authorization", "Bearer ${oauth!!.accessToken}")
|
||||
.header("User-Agent", "Mihon v${BuildConfig.VERSION_NAME} (${BuildConfig.APPLICATION_ID})")
|
||||
.header("Accept", "application/vnd.api+json")
|
||||
.header("Content-Type", "application/vnd.api+json")
|
||||
@@ -43,7 +45,7 @@ class KitsuInterceptor(private val kitsu: Kitsu) : Interceptor {
|
||||
return chain.proceed(authRequest)
|
||||
}
|
||||
|
||||
fun newAuth(oauth: OAuth?) {
|
||||
fun newAuth(oauth: KitsuOAuth?) {
|
||||
this.oauth = oauth
|
||||
kitsu.saveToken(oauth)
|
||||
}
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.track.kitsu
|
||||
|
||||
import androidx.annotation.CallSuper
|
||||
import eu.kanade.tachiyomi.data.database.models.Track
|
||||
import eu.kanade.tachiyomi.data.track.TrackerManager
|
||||
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.int
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.long
|
||||
import kotlinx.serialization.json.longOrNull
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
class KitsuSearchManga(obj: JsonObject) {
|
||||
val id = obj["id"]!!.jsonPrimitive.long
|
||||
private val canonicalTitle = obj["canonicalTitle"]!!.jsonPrimitive.content
|
||||
private val chapterCount = obj["chapterCount"]?.jsonPrimitive?.longOrNull
|
||||
val subType = obj["subtype"]?.jsonPrimitive?.contentOrNull
|
||||
val original = try {
|
||||
obj["posterImage"]?.jsonObject?.get("original")?.jsonPrimitive?.content
|
||||
} catch (e: IllegalArgumentException) {
|
||||
// posterImage is sometimes a jsonNull object instead
|
||||
null
|
||||
}
|
||||
private val synopsis = obj["synopsis"]?.jsonPrimitive?.contentOrNull
|
||||
private val rating = obj["averageRating"]?.jsonPrimitive?.contentOrNull?.toDoubleOrNull()
|
||||
private var startDate = obj["startDate"]?.jsonPrimitive?.contentOrNull?.let {
|
||||
val outputDf = SimpleDateFormat("yyyy-MM-dd", Locale.US)
|
||||
outputDf.format(Date(it.toLong() * 1000))
|
||||
}
|
||||
private val endDate = obj["endDate"]?.jsonPrimitive?.contentOrNull
|
||||
|
||||
@CallSuper
|
||||
fun toTrack() = TrackSearch.create(TrackerManager.KITSU).apply {
|
||||
remote_id = this@KitsuSearchManga.id
|
||||
title = canonicalTitle
|
||||
total_chapters = chapterCount ?: 0
|
||||
cover_url = original ?: ""
|
||||
summary = synopsis ?: ""
|
||||
tracking_url = KitsuApi.mangaUrl(remote_id)
|
||||
score = rating ?: -1.0
|
||||
publishing_status = if (endDate == null) {
|
||||
"Publishing"
|
||||
} else {
|
||||
"Finished"
|
||||
}
|
||||
publishing_type = subType ?: ""
|
||||
start_date = startDate ?: ""
|
||||
}
|
||||
}
|
||||
|
||||
class KitsuLibManga(obj: JsonObject, manga: JsonObject) {
|
||||
val id = manga["id"]!!.jsonPrimitive.int
|
||||
private val canonicalTitle = manga["attributes"]!!.jsonObject["canonicalTitle"]!!.jsonPrimitive.content
|
||||
private val chapterCount = manga["attributes"]!!.jsonObject["chapterCount"]?.jsonPrimitive?.longOrNull
|
||||
val type = manga["attributes"]!!.jsonObject["mangaType"]?.jsonPrimitive?.contentOrNull.orEmpty()
|
||||
val original = manga["attributes"]!!.jsonObject["posterImage"]!!.jsonObject["original"]!!.jsonPrimitive.content
|
||||
private val synopsis = manga["attributes"]!!.jsonObject["synopsis"]!!.jsonPrimitive.content
|
||||
private val startDate = manga["attributes"]!!.jsonObject["startDate"]?.jsonPrimitive?.contentOrNull.orEmpty()
|
||||
private val startedAt = obj["attributes"]!!.jsonObject["startedAt"]?.jsonPrimitive?.contentOrNull
|
||||
private val finishedAt = obj["attributes"]!!.jsonObject["finishedAt"]?.jsonPrimitive?.contentOrNull
|
||||
private val libraryId = obj["id"]!!.jsonPrimitive.long
|
||||
val status = obj["attributes"]!!.jsonObject["status"]!!.jsonPrimitive.content
|
||||
private val ratingTwenty = obj["attributes"]!!.jsonObject["ratingTwenty"]?.jsonPrimitive?.contentOrNull
|
||||
val progress = obj["attributes"]!!.jsonObject["progress"]!!.jsonPrimitive.int
|
||||
|
||||
fun toTrack() = TrackSearch.create(TrackerManager.KITSU).apply {
|
||||
remote_id = libraryId
|
||||
title = canonicalTitle
|
||||
total_chapters = chapterCount ?: 0
|
||||
cover_url = original
|
||||
summary = synopsis
|
||||
tracking_url = KitsuApi.mangaUrl(remote_id)
|
||||
publishing_status = this@KitsuLibManga.status
|
||||
publishing_type = type
|
||||
start_date = startDate
|
||||
started_reading_date = KitsuDateHelper.parse(startedAt)
|
||||
finished_reading_date = KitsuDateHelper.parse(finishedAt)
|
||||
status = toTrackStatus()
|
||||
score = ratingTwenty?.let { it.toInt() / 2.0 } ?: 0.0
|
||||
last_chapter_read = progress.toDouble()
|
||||
}
|
||||
|
||||
private fun toTrackStatus() = when (status) {
|
||||
"current" -> Kitsu.READING
|
||||
"completed" -> Kitsu.COMPLETED
|
||||
"on_hold" -> Kitsu.ON_HOLD
|
||||
"dropped" -> Kitsu.DROPPED
|
||||
"planned" -> Kitsu.PLAN_TO_READ
|
||||
else -> throw Exception("Unknown status")
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class OAuth(
|
||||
val access_token: String,
|
||||
val token_type: String,
|
||||
val created_at: Long,
|
||||
val expires_in: Long,
|
||||
val refresh_token: String?,
|
||||
)
|
||||
|
||||
fun OAuth.isExpired() = (System.currentTimeMillis() / 1000) > (created_at + expires_in - 3600)
|
||||
|
||||
fun Track.toKitsuStatus() = when (status) {
|
||||
Kitsu.READING -> "current"
|
||||
Kitsu.COMPLETED -> "completed"
|
||||
Kitsu.ON_HOLD -> "on_hold"
|
||||
Kitsu.DROPPED -> "dropped"
|
||||
Kitsu.PLAN_TO_READ -> "planned"
|
||||
else -> throw Exception("Unknown status")
|
||||
}
|
||||
|
||||
fun Track.toKitsuScore(): String? {
|
||||
return if (score > 0) (score * 2).toInt().toString() else null
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package eu.kanade.tachiyomi.data.track.kitsu
|
||||
|
||||
import eu.kanade.tachiyomi.data.database.models.Track
|
||||
|
||||
fun Track.toApiStatus() = when (status) {
|
||||
Kitsu.READING -> "current"
|
||||
Kitsu.COMPLETED -> "completed"
|
||||
Kitsu.ON_HOLD -> "on_hold"
|
||||
Kitsu.DROPPED -> "dropped"
|
||||
Kitsu.PLAN_TO_READ -> "planned"
|
||||
else -> throw Exception("Unknown status")
|
||||
}
|
||||
|
||||
fun Track.toApiScore(): String? {
|
||||
return if (score > 0) (score * 2).toInt().toString() else null
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package eu.kanade.tachiyomi.data.track.kitsu.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class KitsuAddMangaResult(
|
||||
val data: KitsuAddMangaItem,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class KitsuAddMangaItem(
|
||||
val id: Long,
|
||||
)
|
||||
@@ -0,0 +1,79 @@
|
||||
package eu.kanade.tachiyomi.data.track.kitsu.dto
|
||||
|
||||
import eu.kanade.tachiyomi.data.track.TrackerManager
|
||||
import eu.kanade.tachiyomi.data.track.kitsu.Kitsu
|
||||
import eu.kanade.tachiyomi.data.track.kitsu.KitsuApi
|
||||
import eu.kanade.tachiyomi.data.track.kitsu.KitsuDateHelper
|
||||
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class KitsuListSearchResult(
|
||||
val data: List<KitsuListSearchItemData>,
|
||||
val included: List<KitsuListSearchItemIncluded> = emptyList(),
|
||||
) {
|
||||
fun firstToTrack(): TrackSearch {
|
||||
require(data.isNotEmpty()) { "Missing User data from Kitsu" }
|
||||
require(included.isNotEmpty()) { "Missing Manga data from Kitsu" }
|
||||
|
||||
val userData = data[0]
|
||||
val userDataAttrs = userData.attributes
|
||||
val manga = included[0].attributes
|
||||
|
||||
return TrackSearch.create(TrackerManager.KITSU).apply {
|
||||
remote_id = userData.id
|
||||
title = manga.canonicalTitle
|
||||
total_chapters = manga.chapterCount ?: 0
|
||||
cover_url = manga.posterImage?.original ?: ""
|
||||
summary = manga.synopsis
|
||||
tracking_url = KitsuApi.mangaUrl(remote_id)
|
||||
publishing_status = manga.status
|
||||
publishing_type = manga.mangaType ?: ""
|
||||
start_date = userDataAttrs.startedAt ?: ""
|
||||
started_reading_date = KitsuDateHelper.parse(userDataAttrs.startedAt)
|
||||
finished_reading_date = KitsuDateHelper.parse(userDataAttrs.finishedAt)
|
||||
status = when (userDataAttrs.status) {
|
||||
"current" -> Kitsu.READING
|
||||
"completed" -> Kitsu.COMPLETED
|
||||
"on_hold" -> Kitsu.ON_HOLD
|
||||
"dropped" -> Kitsu.DROPPED
|
||||
"planned" -> Kitsu.PLAN_TO_READ
|
||||
else -> throw Exception("Unknown status")
|
||||
}
|
||||
score = userDataAttrs.ratingTwenty?.let { it.toInt() / 2.0 } ?: 0.0
|
||||
last_chapter_read = userDataAttrs.progress.toDouble()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class KitsuListSearchItemData(
|
||||
val id: Long,
|
||||
val attributes: KitsuListSearchItemDataAttributes,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class KitsuListSearchItemDataAttributes(
|
||||
val status: String,
|
||||
val startedAt: String?,
|
||||
val finishedAt: String?,
|
||||
val ratingTwenty: String?,
|
||||
val progress: Int,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class KitsuListSearchItemIncluded(
|
||||
val id: Long,
|
||||
val attributes: KitsuListSearchItemIncludedAttributes,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class KitsuListSearchItemIncludedAttributes(
|
||||
val canonicalTitle: String,
|
||||
val chapterCount: Long?,
|
||||
val mangaType: String?,
|
||||
val posterImage: KitsuSearchItemCover?,
|
||||
val synopsis: String,
|
||||
val startDate: String?,
|
||||
val status: String,
|
||||
)
|
||||
@@ -0,0 +1,20 @@
|
||||
package eu.kanade.tachiyomi.data.track.kitsu.dto
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class KitsuOAuth(
|
||||
@SerialName("access_token")
|
||||
val accessToken: String,
|
||||
@SerialName("token_type")
|
||||
val tokenType: String,
|
||||
@SerialName("created_at")
|
||||
val createdAt: Long,
|
||||
@SerialName("expires_in")
|
||||
val expiresIn: Long,
|
||||
@SerialName("refresh_token")
|
||||
val refreshToken: String?,
|
||||
)
|
||||
|
||||
fun KitsuOAuth.isExpired() = (System.currentTimeMillis() / 1000) > (createdAt + expiresIn - 3600)
|
||||
@@ -0,0 +1,55 @@
|
||||
package eu.kanade.tachiyomi.data.track.kitsu.dto
|
||||
|
||||
import eu.kanade.tachiyomi.data.track.TrackerManager
|
||||
import eu.kanade.tachiyomi.data.track.kitsu.KitsuApi
|
||||
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
@Serializable
|
||||
data class KitsuSearchResult(
|
||||
val media: KitsuSearchResultData,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class KitsuSearchResultData(
|
||||
val key: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class KitsuAlgoliaSearchResult(
|
||||
val hits: List<KitsuAlgoliaSearchItem>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class KitsuAlgoliaSearchItem(
|
||||
val id: Long,
|
||||
val canonicalTitle: String,
|
||||
val chapterCount: Long?,
|
||||
val subtype: String?,
|
||||
val posterImage: KitsuSearchItemCover?,
|
||||
val synopsis: String?,
|
||||
val averageRating: Double?,
|
||||
val startDate: Long?,
|
||||
val endDate: Long?,
|
||||
) {
|
||||
fun toTrack(): TrackSearch {
|
||||
return TrackSearch.create(TrackerManager.KITSU).apply {
|
||||
remote_id = this@KitsuAlgoliaSearchItem.id
|
||||
title = canonicalTitle
|
||||
total_chapters = chapterCount ?: 0
|
||||
cover_url = posterImage?.original ?: ""
|
||||
summary = synopsis ?: ""
|
||||
tracking_url = KitsuApi.mangaUrl(remote_id)
|
||||
score = averageRating ?: -1.0
|
||||
publishing_status = if (endDate == null) "Publishing" else "Finished"
|
||||
publishing_type = subtype ?: ""
|
||||
start_date = startDate?.let {
|
||||
val outputDf = SimpleDateFormat("yyyy-MM-dd", Locale.US)
|
||||
outputDf.format(Date(it * 1000))
|
||||
} ?: ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package eu.kanade.tachiyomi.data.track.kitsu.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class KitsuSearchItemCover(
|
||||
val original: String?,
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
package eu.kanade.tachiyomi.data.track.kitsu.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class KitsuCurrentUserResult(
|
||||
val data: List<KitsuUser>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class KitsuUser(
|
||||
val id: String,
|
||||
)
|
||||
@@ -6,8 +6,8 @@ import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.database.models.Track
|
||||
import eu.kanade.tachiyomi.data.track.BaseTracker
|
||||
import eu.kanade.tachiyomi.data.track.DeletableTracker
|
||||
import eu.kanade.tachiyomi.data.track.mangaupdates.dto.ListItem
|
||||
import eu.kanade.tachiyomi.data.track.mangaupdates.dto.Rating
|
||||
import eu.kanade.tachiyomi.data.track.mangaupdates.dto.MUListItem
|
||||
import eu.kanade.tachiyomi.data.track.mangaupdates.dto.MURating
|
||||
import eu.kanade.tachiyomi.data.track.mangaupdates.dto.copyTo
|
||||
import eu.kanade.tachiyomi.data.track.mangaupdates.dto.toTrackSearch
|
||||
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||
@@ -106,7 +106,7 @@ class MangaUpdates(id: Long) : BaseTracker(id, "MangaUpdates"), DeletableTracker
|
||||
return track.copyFrom(series, rating)
|
||||
}
|
||||
|
||||
private fun Track.copyFrom(item: ListItem, rating: Rating?): Track = apply {
|
||||
private fun Track.copyFrom(item: MUListItem, rating: MURating?): Track = apply {
|
||||
item.copyTo(this)
|
||||
score = rating?.rating ?: 0.0
|
||||
}
|
||||
|
||||
@@ -3,10 +3,12 @@ package eu.kanade.tachiyomi.data.track.mangaupdates
|
||||
import eu.kanade.tachiyomi.data.database.models.Track
|
||||
import eu.kanade.tachiyomi.data.track.mangaupdates.MangaUpdates.Companion.READING_LIST
|
||||
import eu.kanade.tachiyomi.data.track.mangaupdates.MangaUpdates.Companion.WISH_LIST
|
||||
import eu.kanade.tachiyomi.data.track.mangaupdates.dto.Context
|
||||
import eu.kanade.tachiyomi.data.track.mangaupdates.dto.ListItem
|
||||
import eu.kanade.tachiyomi.data.track.mangaupdates.dto.Rating
|
||||
import eu.kanade.tachiyomi.data.track.mangaupdates.dto.Record
|
||||
import eu.kanade.tachiyomi.data.track.mangaupdates.dto.MUContext
|
||||
import eu.kanade.tachiyomi.data.track.mangaupdates.dto.MUListItem
|
||||
import eu.kanade.tachiyomi.data.track.mangaupdates.dto.MULoginResponse
|
||||
import eu.kanade.tachiyomi.data.track.mangaupdates.dto.MURating
|
||||
import eu.kanade.tachiyomi.data.track.mangaupdates.dto.MURecord
|
||||
import eu.kanade.tachiyomi.data.track.mangaupdates.dto.MUSearchResult
|
||||
import eu.kanade.tachiyomi.network.DELETE
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.POST
|
||||
@@ -14,21 +16,15 @@ import eu.kanade.tachiyomi.network.PUT
|
||||
import eu.kanade.tachiyomi.network.awaitSuccess
|
||||
import eu.kanade.tachiyomi.network.parseAs
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.add
|
||||
import kotlinx.serialization.json.addJsonObject
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlinx.serialization.json.putJsonObject
|
||||
import logcat.LogPriority
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import tachiyomi.core.common.util.system.logcat
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import tachiyomi.domain.track.model.Track as DomainTrack
|
||||
|
||||
@@ -38,20 +34,17 @@ class MangaUpdatesApi(
|
||||
) {
|
||||
private val json: Json by injectLazy()
|
||||
|
||||
private val baseUrl = "https://api.mangaupdates.com"
|
||||
private val contentType = "application/vnd.api+json".toMediaType()
|
||||
|
||||
private val authClient by lazy {
|
||||
client.newBuilder()
|
||||
.addInterceptor(interceptor)
|
||||
.build()
|
||||
}
|
||||
|
||||
suspend fun getSeriesListItem(track: Track): Pair<ListItem, Rating?> {
|
||||
suspend fun getSeriesListItem(track: Track): Pair<MUListItem, MURating?> {
|
||||
val listItem = with(json) {
|
||||
authClient.newCall(GET("$baseUrl/v1/lists/series/${track.remote_id}"))
|
||||
authClient.newCall(GET("$BASE_URL/v1/lists/series/${track.remote_id}"))
|
||||
.awaitSuccess()
|
||||
.parseAs<ListItem>()
|
||||
.parseAs<MUListItem>()
|
||||
}
|
||||
|
||||
val rating = getSeriesRating(track)
|
||||
@@ -71,8 +64,8 @@ class MangaUpdatesApi(
|
||||
}
|
||||
authClient.newCall(
|
||||
POST(
|
||||
url = "$baseUrl/v1/lists/series",
|
||||
body = body.toString().toRequestBody(contentType),
|
||||
url = "$BASE_URL/v1/lists/series",
|
||||
body = body.toString().toRequestBody(CONTENT_TYPE),
|
||||
),
|
||||
)
|
||||
.awaitSuccess()
|
||||
@@ -98,8 +91,8 @@ class MangaUpdatesApi(
|
||||
}
|
||||
authClient.newCall(
|
||||
POST(
|
||||
url = "$baseUrl/v1/lists/series/update",
|
||||
body = body.toString().toRequestBody(contentType),
|
||||
url = "$BASE_URL/v1/lists/series/update",
|
||||
body = body.toString().toRequestBody(CONTENT_TYPE),
|
||||
),
|
||||
)
|
||||
.awaitSuccess()
|
||||
@@ -113,19 +106,19 @@ class MangaUpdatesApi(
|
||||
}
|
||||
authClient.newCall(
|
||||
POST(
|
||||
url = "$baseUrl/v1/lists/series/delete",
|
||||
body = body.toString().toRequestBody(contentType),
|
||||
url = "$BASE_URL/v1/lists/series/delete",
|
||||
body = body.toString().toRequestBody(CONTENT_TYPE),
|
||||
),
|
||||
)
|
||||
.awaitSuccess()
|
||||
}
|
||||
|
||||
private suspend fun getSeriesRating(track: Track): Rating? {
|
||||
private suspend fun getSeriesRating(track: Track): MURating? {
|
||||
return try {
|
||||
with(json) {
|
||||
authClient.newCall(GET("$baseUrl/v1/series/${track.remote_id}/rating"))
|
||||
authClient.newCall(GET("$BASE_URL/v1/series/${track.remote_id}/rating"))
|
||||
.awaitSuccess()
|
||||
.parseAs<Rating>()
|
||||
.parseAs<MURating>()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
@@ -140,22 +133,20 @@ class MangaUpdatesApi(
|
||||
}
|
||||
authClient.newCall(
|
||||
PUT(
|
||||
url = "$baseUrl/v1/series/${track.remote_id}/rating",
|
||||
body = body.toString().toRequestBody(contentType),
|
||||
url = "$BASE_URL/v1/series/${track.remote_id}/rating",
|
||||
body = body.toString().toRequestBody(CONTENT_TYPE),
|
||||
),
|
||||
)
|
||||
.awaitSuccess()
|
||||
} else {
|
||||
authClient.newCall(
|
||||
DELETE(
|
||||
url = "$baseUrl/v1/series/${track.remote_id}/rating",
|
||||
),
|
||||
DELETE(url = "$BASE_URL/v1/series/${track.remote_id}/rating"),
|
||||
)
|
||||
.awaitSuccess()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun search(query: String): List<Record> {
|
||||
suspend fun search(query: String): List<MURecord> {
|
||||
val body = buildJsonObject {
|
||||
put("search", query)
|
||||
put(
|
||||
@@ -166,25 +157,22 @@ class MangaUpdatesApi(
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return with(json) {
|
||||
client.newCall(
|
||||
POST(
|
||||
url = "$baseUrl/v1/series/search",
|
||||
body = body.toString().toRequestBody(contentType),
|
||||
url = "$BASE_URL/v1/series/search",
|
||||
body = body.toString().toRequestBody(CONTENT_TYPE),
|
||||
),
|
||||
)
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.let { obj ->
|
||||
obj["results"]?.jsonArray?.map { element ->
|
||||
json.decodeFromJsonElement<Record>(element.jsonObject["record"]!!)
|
||||
}
|
||||
}
|
||||
.orEmpty()
|
||||
.parseAs<MUSearchResult>()
|
||||
.results
|
||||
.map { it.record }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun authenticate(username: String, password: String): Context? {
|
||||
suspend fun authenticate(username: String, password: String): MUContext? {
|
||||
val body = buildJsonObject {
|
||||
put("username", username)
|
||||
put("password", password)
|
||||
@@ -192,20 +180,19 @@ class MangaUpdatesApi(
|
||||
return with(json) {
|
||||
client.newCall(
|
||||
PUT(
|
||||
url = "$baseUrl/v1/account/login",
|
||||
body = body.toString().toRequestBody(contentType),
|
||||
url = "$BASE_URL/v1/account/login",
|
||||
body = body.toString().toRequestBody(CONTENT_TYPE),
|
||||
),
|
||||
)
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.let { obj ->
|
||||
try {
|
||||
json.decodeFromJsonElement<Context>(obj["context"]!!)
|
||||
} catch (e: Exception) {
|
||||
logcat(LogPriority.ERROR, e)
|
||||
null
|
||||
}
|
||||
}
|
||||
.parseAs<MULoginResponse>()
|
||||
.context
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val BASE_URL = "https://api.mangaupdates.com"
|
||||
|
||||
private val CONTENT_TYPE = "application/vnd.api+json".toMediaType()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class Context(
|
||||
data class MUContext(
|
||||
@SerialName("session_token")
|
||||
val sessionToken: String,
|
||||
val uid: Long,
|
||||
@@ -3,8 +3,8 @@ package eu.kanade.tachiyomi.data.track.mangaupdates.dto
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class Image(
|
||||
val url: Url? = null,
|
||||
data class MUImage(
|
||||
val url: MUUrl? = null,
|
||||
val height: Int? = null,
|
||||
val width: Int? = null,
|
||||
)
|
||||
@@ -6,15 +6,15 @@ import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class ListItem(
|
||||
val series: Series? = null,
|
||||
data class MUListItem(
|
||||
val series: MUSeries? = null,
|
||||
@SerialName("list_id")
|
||||
val listId: Long? = null,
|
||||
val status: Status? = null,
|
||||
val status: MUStatus? = null,
|
||||
val priority: Int? = null,
|
||||
)
|
||||
|
||||
fun ListItem.copyTo(track: Track): Track {
|
||||
fun MUListItem.copyTo(track: Track): Track {
|
||||
return track.apply {
|
||||
this.status = listId ?: READING_LIST
|
||||
this.last_chapter_read = this@copyTo.status?.chapter?.toDouble() ?: 0.0
|
||||
@@ -0,0 +1,8 @@
|
||||
package eu.kanade.tachiyomi.data.track.mangaupdates.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class MULoginResponse(
|
||||
val context: MUContext,
|
||||
)
|
||||
@@ -4,11 +4,11 @@ import eu.kanade.tachiyomi.data.database.models.Track
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class Rating(
|
||||
data class MURating(
|
||||
val rating: Double? = null,
|
||||
)
|
||||
|
||||
fun Rating.copyTo(track: Track): Track {
|
||||
fun MURating.copyTo(track: Track): Track {
|
||||
return track.apply {
|
||||
this.score = rating ?: 0.0
|
||||
}
|
||||
@@ -6,13 +6,13 @@ import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class Record(
|
||||
data class MURecord(
|
||||
@SerialName("series_id")
|
||||
val seriesId: Long? = null,
|
||||
val title: String? = null,
|
||||
val url: String? = null,
|
||||
val description: String? = null,
|
||||
val image: Image? = null,
|
||||
val image: MUImage? = null,
|
||||
val type: String? = null,
|
||||
val year: String? = null,
|
||||
@SerialName("bayesian_rating")
|
||||
@@ -23,7 +23,7 @@ data class Record(
|
||||
val latestChapter: Int? = null,
|
||||
)
|
||||
|
||||
fun Record.toTrackSearch(id: Long): TrackSearch {
|
||||
fun MURecord.toTrackSearch(id: Long): TrackSearch {
|
||||
return TrackSearch.create(id).apply {
|
||||
remote_id = this@toTrackSearch.seriesId ?: 0L
|
||||
title = this@toTrackSearch.title?.htmlDecode() ?: ""
|
||||
@@ -0,0 +1,13 @@
|
||||
package eu.kanade.tachiyomi.data.track.mangaupdates.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class MUSearchResult(
|
||||
val results: List<MUSearchResultItem>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MUSearchResultItem(
|
||||
val record: MURecord,
|
||||
)
|
||||
@@ -3,7 +3,7 @@ package eu.kanade.tachiyomi.data.track.mangaupdates.dto
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class Series(
|
||||
data class MUSeries(
|
||||
val id: Long? = null,
|
||||
val title: String? = null,
|
||||
)
|
||||
@@ -3,7 +3,7 @@ package eu.kanade.tachiyomi.data.track.mangaupdates.dto
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class Status(
|
||||
data class MUStatus(
|
||||
val volume: Int? = null,
|
||||
val chapter: Int? = null,
|
||||
)
|
||||
@@ -3,7 +3,7 @@ package eu.kanade.tachiyomi.data.track.mangaupdates.dto
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class Url(
|
||||
data class MUUrl(
|
||||
val original: String? = null,
|
||||
val thumb: String? = null,
|
||||
)
|
||||
@@ -7,6 +7,7 @@ import eu.kanade.tachiyomi.data.database.models.Track
|
||||
import eu.kanade.tachiyomi.data.track.BaseTracker
|
||||
import eu.kanade.tachiyomi.data.track.DeletableTracker
|
||||
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||
import eu.kanade.tachiyomi.data.track.myanimelist.dto.MALOAuth
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.serialization.encodeToString
|
||||
@@ -143,7 +144,7 @@ class MyAnimeList(id: Long) : BaseTracker(id, "MyAnimeList"), DeletableTracker {
|
||||
val oauth = api.getAccessToken(authCode)
|
||||
interceptor.setAuth(oauth)
|
||||
val username = api.getCurrentUser()
|
||||
saveCredentials(username, oauth.access_token)
|
||||
saveCredentials(username, oauth.accessToken)
|
||||
} catch (e: Throwable) {
|
||||
logout()
|
||||
}
|
||||
@@ -163,13 +164,13 @@ class MyAnimeList(id: Long) : BaseTracker(id, "MyAnimeList"), DeletableTracker {
|
||||
trackPreferences.trackAuthExpired(this).set(true)
|
||||
}
|
||||
|
||||
fun saveOAuth(oAuth: OAuth?) {
|
||||
fun saveOAuth(oAuth: MALOAuth?) {
|
||||
trackPreferences.trackToken(this).set(json.encodeToString(oAuth))
|
||||
}
|
||||
|
||||
fun loadOAuth(): OAuth? {
|
||||
fun loadOAuth(): MALOAuth? {
|
||||
return try {
|
||||
json.decodeFromString<OAuth>(trackPreferences.trackToken(this).get())
|
||||
json.decodeFromString<MALOAuth>(trackPreferences.trackToken(this).get())
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
@@ -4,6 +4,13 @@ import android.net.Uri
|
||||
import androidx.core.net.toUri
|
||||
import eu.kanade.tachiyomi.data.database.models.Track
|
||||
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||
import eu.kanade.tachiyomi.data.track.myanimelist.dto.MALListItem
|
||||
import eu.kanade.tachiyomi.data.track.myanimelist.dto.MALListItemStatus
|
||||
import eu.kanade.tachiyomi.data.track.myanimelist.dto.MALManga
|
||||
import eu.kanade.tachiyomi.data.track.myanimelist.dto.MALOAuth
|
||||
import eu.kanade.tachiyomi.data.track.myanimelist.dto.MALSearchResult
|
||||
import eu.kanade.tachiyomi.data.track.myanimelist.dto.MALUser
|
||||
import eu.kanade.tachiyomi.data.track.myanimelist.dto.MALUserSearchResult
|
||||
import eu.kanade.tachiyomi.network.DELETE
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.POST
|
||||
@@ -13,16 +20,6 @@ import eu.kanade.tachiyomi.util.PkceUtil
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.boolean
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.double
|
||||
import kotlinx.serialization.json.doubleOrNull
|
||||
import kotlinx.serialization.json.int
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.long
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.Headers
|
||||
import okhttp3.OkHttpClient
|
||||
@@ -44,7 +41,7 @@ class MyAnimeListApi(
|
||||
|
||||
private val authClient = client.newBuilder().addInterceptor(interceptor).build()
|
||||
|
||||
suspend fun getAccessToken(authCode: String): OAuth {
|
||||
suspend fun getAccessToken(authCode: String): MALOAuth {
|
||||
return withIOContext {
|
||||
val formBody: RequestBody = FormBody.Builder()
|
||||
.add("client_id", CLIENT_ID)
|
||||
@@ -69,8 +66,8 @@ class MyAnimeListApi(
|
||||
with(json) {
|
||||
authClient.newCall(request)
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.let { it["name"]!!.jsonPrimitive.content }
|
||||
.parseAs<MALUser>()
|
||||
.name
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -85,17 +82,11 @@ class MyAnimeListApi(
|
||||
with(json) {
|
||||
authClient.newCall(GET(url.toString()))
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.let {
|
||||
it["data"]!!.jsonArray
|
||||
.map { data -> data.jsonObject["node"]!!.jsonObject }
|
||||
.map { node ->
|
||||
val id = node["id"]!!.jsonPrimitive.int
|
||||
async { getMangaDetails(id) }
|
||||
}
|
||||
.awaitAll()
|
||||
.filter { trackSearch -> !trackSearch.publishing_type.contains("novel") }
|
||||
}
|
||||
.parseAs<MALSearchResult>()
|
||||
.data
|
||||
.map { async { getMangaDetails(it.node.id) } }
|
||||
.awaitAll()
|
||||
.filter { !it.publishing_type.contains("novel") }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -112,24 +103,19 @@ class MyAnimeListApi(
|
||||
with(json) {
|
||||
authClient.newCall(GET(url.toString()))
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.parseAs<MALManga>()
|
||||
.let {
|
||||
val obj = it.jsonObject
|
||||
TrackSearch.create(trackId).apply {
|
||||
remote_id = obj["id"]!!.jsonPrimitive.long
|
||||
title = obj["title"]!!.jsonPrimitive.content
|
||||
summary = obj["synopsis"]?.jsonPrimitive?.content ?: ""
|
||||
total_chapters = obj["num_chapters"]!!.jsonPrimitive.long
|
||||
score = obj["mean"]?.jsonPrimitive?.doubleOrNull ?: -1.0
|
||||
cover_url =
|
||||
obj["main_picture"]?.jsonObject?.get("large")?.jsonPrimitive?.content
|
||||
?: ""
|
||||
remote_id = it.id
|
||||
title = it.title
|
||||
summary = it.synopsis
|
||||
total_chapters = it.numChapters
|
||||
score = it.mean
|
||||
cover_url = it.covers.large
|
||||
tracking_url = "https://myanimelist.net/manga/$remote_id"
|
||||
publishing_status =
|
||||
obj["status"]!!.jsonPrimitive.content.replace("_", " ")
|
||||
publishing_type =
|
||||
obj["media_type"]!!.jsonPrimitive.content.replace("_", " ")
|
||||
start_date = obj["start_date"]?.jsonPrimitive?.content ?: ""
|
||||
publishing_status = it.status.replace("_", " ")
|
||||
publishing_type = it.mediaType.replace("_", " ")
|
||||
start_date = it.startDate ?: ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -157,7 +143,7 @@ class MyAnimeListApi(
|
||||
with(json) {
|
||||
authClient.newCall(request)
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.parseAs<MALListItemStatus>()
|
||||
.let { parseMangaItem(it, track) }
|
||||
}
|
||||
}
|
||||
@@ -180,12 +166,10 @@ class MyAnimeListApi(
|
||||
with(json) {
|
||||
authClient.newCall(GET(uri.toString()))
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.let { obj ->
|
||||
track.total_chapters = obj["num_chapters"]!!.jsonPrimitive.long
|
||||
obj.jsonObject["my_list_status"]?.jsonObject?.let {
|
||||
parseMangaItem(it, track)
|
||||
}
|
||||
.parseAs<MALListItem>()
|
||||
.let { item ->
|
||||
track.total_chapters = item.numChapters
|
||||
item.myListStatus?.let { parseMangaItem(it, track) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -193,24 +177,15 @@ class MyAnimeListApi(
|
||||
|
||||
suspend fun findListItems(query: String, offset: Int = 0): List<TrackSearch> {
|
||||
return withIOContext {
|
||||
val json = getListPage(offset)
|
||||
val obj = json.jsonObject
|
||||
val myListSearchResult = getListPage(offset)
|
||||
|
||||
val matches = obj["data"]!!.jsonArray
|
||||
.filter {
|
||||
it.jsonObject["node"]!!.jsonObject["title"]!!.jsonPrimitive.content.contains(
|
||||
query,
|
||||
ignoreCase = true,
|
||||
)
|
||||
}
|
||||
.map {
|
||||
val id = it.jsonObject["node"]!!.jsonObject["id"]!!.jsonPrimitive.int
|
||||
async { getMangaDetails(id) }
|
||||
}
|
||||
val matches = myListSearchResult.data
|
||||
.filter { it.node.title.contains(query, ignoreCase = true) }
|
||||
.map { async { getMangaDetails(it.node.id) } }
|
||||
.awaitAll()
|
||||
|
||||
// Check next page if there's more
|
||||
if (!obj["paging"]!!.jsonObject["next"]?.jsonPrimitive?.contentOrNull.isNullOrBlank()) {
|
||||
if (!myListSearchResult.paging.next.isNullOrBlank()) {
|
||||
matches + findListItems(query, offset + LIST_PAGINATION_AMOUNT)
|
||||
} else {
|
||||
matches
|
||||
@@ -218,7 +193,7 @@ class MyAnimeListApi(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getListPage(offset: Int): JsonObject {
|
||||
private suspend fun getListPage(offset: Int): MALUserSearchResult {
|
||||
return withIOContext {
|
||||
val urlBuilder = "$BASE_API_URL/users/@me/mangalist".toUri().buildUpon()
|
||||
.appendQueryParameter("fields", "list_status{start_date,finish_date}")
|
||||
@@ -239,19 +214,14 @@ class MyAnimeListApi(
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseMangaItem(response: JsonObject, track: Track): Track {
|
||||
val obj = response.jsonObject
|
||||
private fun parseMangaItem(listStatus: MALListItemStatus, track: Track): Track {
|
||||
return track.apply {
|
||||
val isRereading = obj["is_rereading"]!!.jsonPrimitive.boolean
|
||||
status = if (isRereading) MyAnimeList.REREADING else getStatus(obj["status"]?.jsonPrimitive?.content)
|
||||
last_chapter_read = obj["num_chapters_read"]!!.jsonPrimitive.double
|
||||
score = obj["score"]!!.jsonPrimitive.int.toDouble()
|
||||
obj["start_date"]?.let {
|
||||
started_reading_date = parseDate(it.jsonPrimitive.content)
|
||||
}
|
||||
obj["finish_date"]?.let {
|
||||
finished_reading_date = parseDate(it.jsonPrimitive.content)
|
||||
}
|
||||
val isRereading = listStatus.isRereading
|
||||
status = if (isRereading) MyAnimeList.REREADING else getStatus(listStatus.status)
|
||||
last_chapter_read = listStatus.numChaptersRead
|
||||
score = listStatus.score.toDouble()
|
||||
listStatus.startDate?.let { started_reading_date = parseDate(it) }
|
||||
listStatus.finishDate?.let { finished_reading_date = parseDate(it) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,10 +262,10 @@ class MyAnimeListApi(
|
||||
.appendPath("my_list_status")
|
||||
.build()
|
||||
|
||||
fun refreshTokenRequest(oauth: OAuth): Request {
|
||||
fun refreshTokenRequest(oauth: MALOAuth): Request {
|
||||
val formBody: RequestBody = FormBody.Builder()
|
||||
.add("client_id", CLIENT_ID)
|
||||
.add("refresh_token", oauth.refresh_token)
|
||||
.add("refresh_token", oauth.refreshToken)
|
||||
.add("grant_type", "refresh_token")
|
||||
.build()
|
||||
|
||||
@@ -303,7 +273,7 @@ class MyAnimeListApi(
|
||||
// request is called by the interceptor itself so it doesn't reach
|
||||
// the part where the token is added automatically.
|
||||
val headers = Headers.Builder()
|
||||
.add("Authorization", "Bearer ${oauth.access_token}")
|
||||
.add("Authorization", "Bearer ${oauth.accessToken}")
|
||||
.build()
|
||||
|
||||
return POST("$BASE_OAUTH_URL/token", body = formBody, headers = headers)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package eu.kanade.tachiyomi.data.track.myanimelist
|
||||
|
||||
import eu.kanade.tachiyomi.data.track.myanimelist.dto.MALOAuth
|
||||
import eu.kanade.tachiyomi.network.parseAs
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Interceptor
|
||||
@@ -11,7 +12,7 @@ class MyAnimeListInterceptor(private val myanimelist: MyAnimeList) : Interceptor
|
||||
|
||||
private val json: Json by injectLazy()
|
||||
|
||||
private var oauth: OAuth? = myanimelist.loadOAuth()
|
||||
private var oauth: MALOAuth? = myanimelist.loadOAuth()
|
||||
private val tokenExpired get() = myanimelist.getIfAuthExpired()
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
@@ -30,7 +31,7 @@ class MyAnimeListInterceptor(private val myanimelist: MyAnimeList) : Interceptor
|
||||
|
||||
// Add the authorization header to the original request
|
||||
val authRequest = originalRequest.newBuilder()
|
||||
.addHeader("Authorization", "Bearer ${oauth!!.access_token}")
|
||||
.addHeader("Authorization", "Bearer ${oauth!!.accessToken}")
|
||||
// TODO(antsy): Add back custom user agent when they stop blocking us for no apparent reason
|
||||
// .header("User-Agent", "Mihon v${BuildConfig.VERSION_NAME} (${BuildConfig.APPLICATION_ID})")
|
||||
.build()
|
||||
@@ -42,12 +43,12 @@ class MyAnimeListInterceptor(private val myanimelist: MyAnimeList) : Interceptor
|
||||
* Called when the user authenticates with MyAnimeList for the first time. Sets the refresh token
|
||||
* and the oauth object.
|
||||
*/
|
||||
fun setAuth(oauth: OAuth?) {
|
||||
fun setAuth(oauth: MALOAuth?) {
|
||||
this.oauth = oauth
|
||||
myanimelist.saveOAuth(oauth)
|
||||
}
|
||||
|
||||
private fun refreshToken(chain: Interceptor.Chain): OAuth = synchronized(this) {
|
||||
private fun refreshToken(chain: Interceptor.Chain): MALOAuth = synchronized(this) {
|
||||
if (tokenExpired) throw MALTokenExpired()
|
||||
oauth?.takeUnless { it.isExpired() }?.let { return@synchronized it }
|
||||
|
||||
@@ -64,7 +65,7 @@ class MyAnimeListInterceptor(private val myanimelist: MyAnimeList) : Interceptor
|
||||
|
||||
return runCatching {
|
||||
if (response.isSuccessful) {
|
||||
with(json) { response.parseAs<OAuth>() }
|
||||
with(json) { response.parseAs<MALOAuth>() }
|
||||
} else {
|
||||
response.close()
|
||||
null
|
||||
|
||||
@@ -1,20 +1,6 @@
|
||||
package eu.kanade.tachiyomi.data.track.myanimelist
|
||||
|
||||
import eu.kanade.tachiyomi.data.database.models.Track
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class OAuth(
|
||||
val token_type: String,
|
||||
val refresh_token: String,
|
||||
val access_token: String,
|
||||
val expires_in: Long,
|
||||
val created_at: Long = System.currentTimeMillis(),
|
||||
) {
|
||||
// Assumes expired a minute earlier
|
||||
private val adjustedExpiresIn: Long = (expires_in - 60) * 1000
|
||||
fun isExpired() = created_at + adjustedExpiresIn < System.currentTimeMillis()
|
||||
}
|
||||
|
||||
fun Track.toMyAnimeListStatus() = when (status) {
|
||||
MyAnimeList.READING -> "reading"
|
||||
@@ -0,0 +1,26 @@
|
||||
package eu.kanade.tachiyomi.data.track.myanimelist.dto
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class MALListItem(
|
||||
@SerialName("num_chapters")
|
||||
val numChapters: Long,
|
||||
@SerialName("my_list_status")
|
||||
val myListStatus: MALListItemStatus?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MALListItemStatus(
|
||||
@SerialName("is_rereading")
|
||||
val isRereading: Boolean,
|
||||
val status: String,
|
||||
@SerialName("num_chapters_read")
|
||||
val numChaptersRead: Double,
|
||||
val score: Int,
|
||||
@SerialName("start_date")
|
||||
val startDate: String?,
|
||||
@SerialName("finish_date")
|
||||
val finishDate: String?,
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
package eu.kanade.tachiyomi.data.track.myanimelist.dto
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class MALManga(
|
||||
val id: Long,
|
||||
val title: String,
|
||||
val synopsis: String = "",
|
||||
@SerialName("num_chapters")
|
||||
val numChapters: Long,
|
||||
val mean: Double = -1.0,
|
||||
@SerialName("main_picture")
|
||||
val covers: MALMangaCovers,
|
||||
val status: String,
|
||||
@SerialName("media_type")
|
||||
val mediaType: String,
|
||||
@SerialName("start_date")
|
||||
val startDate: String?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MALMangaCovers(
|
||||
val large: String = "",
|
||||
)
|
||||
@@ -0,0 +1,23 @@
|
||||
package eu.kanade.tachiyomi.data.track.myanimelist.dto
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class MALOAuth(
|
||||
@SerialName("token_type")
|
||||
val tokenType: String,
|
||||
@SerialName("refresh_token")
|
||||
val refreshToken: String,
|
||||
@SerialName("access_token")
|
||||
val accessToken: String,
|
||||
@SerialName("expires_in")
|
||||
val expiresIn: Long,
|
||||
@SerialName("created_at")
|
||||
val createdAt: Long = System.currentTimeMillis(),
|
||||
) {
|
||||
// Assumes expired a minute earlier
|
||||
private val adjustedExpiresIn: Long = (expiresIn - 60) * 1000
|
||||
|
||||
fun isExpired() = createdAt + adjustedExpiresIn < System.currentTimeMillis()
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package eu.kanade.tachiyomi.data.track.myanimelist.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class MALSearchResult(
|
||||
val data: List<MALSearchResultNode>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MALSearchResultNode(
|
||||
val node: MALSearchResultItem,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MALSearchResultItem(
|
||||
val id: Int,
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
package eu.kanade.tachiyomi.data.track.myanimelist.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class MALUser(
|
||||
val name: String,
|
||||
)
|
||||
@@ -0,0 +1,25 @@
|
||||
package eu.kanade.tachiyomi.data.track.myanimelist.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class MALUserSearchResult(
|
||||
val data: List<MALUserSearchItem>,
|
||||
val paging: MALUserSearchPaging,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MALUserSearchItem(
|
||||
val node: MALUserSearchItemNode,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MALUserSearchPaging(
|
||||
val next: String?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MALUserSearchItemNode(
|
||||
val id: Int,
|
||||
val title: String,
|
||||
)
|
||||
@@ -7,6 +7,7 @@ import eu.kanade.tachiyomi.data.database.models.Track
|
||||
import eu.kanade.tachiyomi.data.track.BaseTracker
|
||||
import eu.kanade.tachiyomi.data.track.DeletableTracker
|
||||
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||
import eu.kanade.tachiyomi.data.track.shikimori.dto.SMOAuth
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.serialization.encodeToString
|
||||
@@ -93,7 +94,7 @@ class Shikimori(id: Long) : BaseTracker(id, "Shikimori"), DeletableTracker {
|
||||
track.library_id = remoteTrack.library_id
|
||||
track.copyPersonalFrom(remoteTrack)
|
||||
track.total_chapters = remoteTrack.total_chapters
|
||||
}
|
||||
} ?: throw Exception("Could not find manga")
|
||||
return track
|
||||
}
|
||||
|
||||
@@ -128,19 +129,19 @@ class Shikimori(id: Long) : BaseTracker(id, "Shikimori"), DeletableTracker {
|
||||
val oauth = api.accessToken(code)
|
||||
interceptor.newAuth(oauth)
|
||||
val user = api.getCurrentUser()
|
||||
saveCredentials(user.toString(), oauth.access_token)
|
||||
saveCredentials(user.toString(), oauth.accessToken)
|
||||
} catch (e: Throwable) {
|
||||
logout()
|
||||
}
|
||||
}
|
||||
|
||||
fun saveToken(oauth: OAuth?) {
|
||||
fun saveToken(oauth: SMOAuth?) {
|
||||
trackPreferences.trackToken(this).set(json.encodeToString(oauth))
|
||||
}
|
||||
|
||||
fun restoreToken(): OAuth? {
|
||||
fun restoreToken(): SMOAuth? {
|
||||
return try {
|
||||
json.decodeFromString<OAuth>(trackPreferences.trackToken(this).get())
|
||||
json.decodeFromString<SMOAuth>(trackPreferences.trackToken(this).get())
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
@@ -4,6 +4,11 @@ import android.net.Uri
|
||||
import androidx.core.net.toUri
|
||||
import eu.kanade.tachiyomi.data.database.models.Track
|
||||
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||
import eu.kanade.tachiyomi.data.track.shikimori.dto.SMAddMangaResponse
|
||||
import eu.kanade.tachiyomi.data.track.shikimori.dto.SMManga
|
||||
import eu.kanade.tachiyomi.data.track.shikimori.dto.SMOAuth
|
||||
import eu.kanade.tachiyomi.data.track.shikimori.dto.SMUser
|
||||
import eu.kanade.tachiyomi.data.track.shikimori.dto.SMUserListEntry
|
||||
import eu.kanade.tachiyomi.network.DELETE
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.POST
|
||||
@@ -11,15 +16,7 @@ import eu.kanade.tachiyomi.network.awaitSuccess
|
||||
import eu.kanade.tachiyomi.network.jsonMime
|
||||
import eu.kanade.tachiyomi.network.parseAs
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.double
|
||||
import kotlinx.serialization.json.int
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.long
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlinx.serialization.json.putJsonObject
|
||||
import okhttp3.FormBody
|
||||
@@ -58,10 +55,10 @@ class ShikimoriApi(
|
||||
body = payload.toString().toRequestBody(jsonMime),
|
||||
),
|
||||
).awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.parseAs<SMAddMangaResponse>()
|
||||
.let {
|
||||
// save id of the entry for possible future delete request
|
||||
track.library_id = it["id"]!!.jsonPrimitive.long
|
||||
track.library_id = it.id
|
||||
}
|
||||
track
|
||||
}
|
||||
@@ -88,53 +85,21 @@ class ShikimoriApi(
|
||||
with(json) {
|
||||
authClient.newCall(GET(url.toString()))
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonArray>()
|
||||
.let { response ->
|
||||
response.map {
|
||||
jsonToSearch(it.jsonObject)
|
||||
}
|
||||
}
|
||||
.parseAs<List<SMManga>>()
|
||||
.map { it.toTrack(trackId) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun jsonToSearch(obj: JsonObject): TrackSearch {
|
||||
return TrackSearch.create(trackId).apply {
|
||||
remote_id = obj["id"]!!.jsonPrimitive.long
|
||||
title = obj["name"]!!.jsonPrimitive.content
|
||||
total_chapters = obj["chapters"]!!.jsonPrimitive.long
|
||||
cover_url = BASE_URL + obj["image"]!!.jsonObject["preview"]!!.jsonPrimitive.content
|
||||
summary = ""
|
||||
score = obj["score"]!!.jsonPrimitive.double
|
||||
tracking_url = BASE_URL + obj["url"]!!.jsonPrimitive.content
|
||||
publishing_status = obj["status"]!!.jsonPrimitive.content
|
||||
publishing_type = obj["kind"]!!.jsonPrimitive.content
|
||||
start_date = obj["aired_on"]!!.jsonPrimitive.contentOrNull ?: ""
|
||||
}
|
||||
}
|
||||
|
||||
private fun jsonToTrack(obj: JsonObject, mangas: JsonObject): Track {
|
||||
return Track.create(trackId).apply {
|
||||
title = mangas["name"]!!.jsonPrimitive.content
|
||||
remote_id = obj["id"]!!.jsonPrimitive.long
|
||||
total_chapters = mangas["chapters"]!!.jsonPrimitive.long
|
||||
library_id = obj["id"]!!.jsonPrimitive.long
|
||||
last_chapter_read = obj["chapters"]!!.jsonPrimitive.double
|
||||
score = obj["score"]!!.jsonPrimitive.int.toDouble()
|
||||
status = toTrackStatus(obj["status"]!!.jsonPrimitive.content)
|
||||
tracking_url = BASE_URL + mangas["url"]!!.jsonPrimitive.content
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun findLibManga(track: Track, userId: String): Track? {
|
||||
return withIOContext {
|
||||
val urlMangas = "$API_URL/mangas".toUri().buildUpon()
|
||||
.appendPath(track.remote_id.toString())
|
||||
.build()
|
||||
val mangas = with(json) {
|
||||
val manga = with(json) {
|
||||
authClient.newCall(GET(urlMangas.toString()))
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.parseAs<SMManga>()
|
||||
}
|
||||
|
||||
val url = "$API_URL/v2/user_rates".toUri().buildUpon()
|
||||
@@ -145,15 +110,14 @@ class ShikimoriApi(
|
||||
with(json) {
|
||||
authClient.newCall(GET(url.toString()))
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonArray>()
|
||||
.let { response ->
|
||||
if (response.size > 1) {
|
||||
throw Exception("Too much mangas in response")
|
||||
.parseAs<List<SMUserListEntry>>()
|
||||
.let { entries ->
|
||||
if (entries.size > 1) {
|
||||
throw Exception("Too many manga in response")
|
||||
}
|
||||
val entry = response.map {
|
||||
jsonToTrack(it.jsonObject, mangas)
|
||||
}
|
||||
entry.firstOrNull()
|
||||
entries
|
||||
.map { it.toTrack(trackId, manga) }
|
||||
.firstOrNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -163,14 +127,12 @@ class ShikimoriApi(
|
||||
return with(json) {
|
||||
authClient.newCall(GET("$API_URL/users/whoami"))
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.let {
|
||||
it["id"]!!.jsonPrimitive.int
|
||||
}
|
||||
.parseAs<SMUser>()
|
||||
.id
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun accessToken(code: String): OAuth {
|
||||
suspend fun accessToken(code: String): SMOAuth {
|
||||
return withIOContext {
|
||||
with(json) {
|
||||
client.newCall(accessTokenRequest(code))
|
||||
@@ -192,16 +154,16 @@ class ShikimoriApi(
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val CLIENT_ID = "PB9dq8DzI405s7wdtwTdirYqHiyVMh--djnP7lBUqSA"
|
||||
private const val CLIENT_SECRET = "NajpZcOBKB9sJtgNcejf8OB9jBN1OYYoo-k4h2WWZus"
|
||||
|
||||
private const val BASE_URL = "https://shikimori.one"
|
||||
const val BASE_URL = "https://shikimori.one"
|
||||
private const val API_URL = "$BASE_URL/api"
|
||||
private const val OAUTH_URL = "$BASE_URL/oauth/token"
|
||||
private const val LOGIN_URL = "$BASE_URL/oauth/authorize"
|
||||
|
||||
private const val REDIRECT_URL = "mihon://shikimori-auth"
|
||||
|
||||
private const val CLIENT_ID = "PB9dq8DzI405s7wdtwTdirYqHiyVMh--djnP7lBUqSA"
|
||||
private const val CLIENT_SECRET = "NajpZcOBKB9sJtgNcejf8OB9jBN1OYYoo-k4h2WWZus"
|
||||
|
||||
fun authUrl(): Uri = LOGIN_URL.toUri().buildUpon()
|
||||
.appendQueryParameter("client_id", CLIENT_ID)
|
||||
.appendQueryParameter("redirect_uri", REDIRECT_URL)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package eu.kanade.tachiyomi.data.track.shikimori
|
||||
|
||||
import eu.kanade.tachiyomi.BuildConfig
|
||||
import eu.kanade.tachiyomi.data.track.shikimori.dto.SMOAuth
|
||||
import eu.kanade.tachiyomi.data.track.shikimori.dto.isExpired
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
@@ -13,34 +15,34 @@ class ShikimoriInterceptor(private val shikimori: Shikimori) : Interceptor {
|
||||
/**
|
||||
* OAuth object used for authenticated requests.
|
||||
*/
|
||||
private var oauth: OAuth? = shikimori.restoreToken()
|
||||
private var oauth: SMOAuth? = shikimori.restoreToken()
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val originalRequest = chain.request()
|
||||
|
||||
val currAuth = oauth ?: throw Exception("Not authenticated with Shikimori")
|
||||
|
||||
val refreshToken = currAuth.refresh_token!!
|
||||
val refreshToken = currAuth.refreshToken!!
|
||||
|
||||
// Refresh access token if expired.
|
||||
if (currAuth.isExpired()) {
|
||||
val response = chain.proceed(ShikimoriApi.refreshTokenRequest(refreshToken))
|
||||
if (response.isSuccessful) {
|
||||
newAuth(json.decodeFromString<OAuth>(response.body.string()))
|
||||
newAuth(json.decodeFromString<SMOAuth>(response.body.string()))
|
||||
} else {
|
||||
response.close()
|
||||
}
|
||||
}
|
||||
// Add the authorization header to the original request.
|
||||
val authRequest = originalRequest.newBuilder()
|
||||
.addHeader("Authorization", "Bearer ${oauth!!.access_token}")
|
||||
.addHeader("Authorization", "Bearer ${oauth!!.accessToken}")
|
||||
.header("User-Agent", "Mihon v${BuildConfig.VERSION_NAME} (${BuildConfig.APPLICATION_ID})")
|
||||
.build()
|
||||
|
||||
return chain.proceed(authRequest)
|
||||
}
|
||||
|
||||
fun newAuth(oauth: OAuth?) {
|
||||
fun newAuth(oauth: SMOAuth?) {
|
||||
this.oauth = oauth
|
||||
shikimori.saveToken(oauth)
|
||||
}
|
||||
|
||||
@@ -1,19 +1,6 @@
|
||||
package eu.kanade.tachiyomi.data.track.shikimori
|
||||
|
||||
import eu.kanade.tachiyomi.data.database.models.Track
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class OAuth(
|
||||
val access_token: String,
|
||||
val token_type: String,
|
||||
val created_at: Long,
|
||||
val expires_in: Long,
|
||||
val refresh_token: String?,
|
||||
)
|
||||
|
||||
// Access token lives 1 day
|
||||
fun OAuth.isExpired() = (System.currentTimeMillis() / 1000) > (created_at + expires_in - 3600)
|
||||
|
||||
fun Track.toShikimoriStatus() = when (status) {
|
||||
Shikimori.READING -> "watching"
|
||||
@@ -0,0 +1,8 @@
|
||||
package eu.kanade.tachiyomi.data.track.shikimori.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class SMAddMangaResponse(
|
||||
val id: Long,
|
||||
)
|
||||
@@ -0,0 +1,40 @@
|
||||
package eu.kanade.tachiyomi.data.track.shikimori.dto
|
||||
|
||||
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||
import eu.kanade.tachiyomi.data.track.shikimori.ShikimoriApi
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class SMManga(
|
||||
val id: Long,
|
||||
val name: String,
|
||||
val chapters: Long,
|
||||
val image: SUMangaCover,
|
||||
val score: Double,
|
||||
val url: String,
|
||||
val status: String,
|
||||
val kind: String,
|
||||
@SerialName("aired_on")
|
||||
val airedOn: String?,
|
||||
) {
|
||||
fun toTrack(trackId: Long): TrackSearch {
|
||||
return TrackSearch.create(trackId).apply {
|
||||
remote_id = this@SMManga.id
|
||||
title = name
|
||||
total_chapters = chapters
|
||||
cover_url = ShikimoriApi.BASE_URL + image.preview
|
||||
summary = ""
|
||||
score = this@SMManga.score
|
||||
tracking_url = ShikimoriApi.BASE_URL + url
|
||||
publishing_status = this@SMManga.status
|
||||
publishing_type = kind
|
||||
start_date = airedOn ?: ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class SUMangaCover(
|
||||
val preview: String,
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
package eu.kanade.tachiyomi.data.track.shikimori.dto
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class SMOAuth(
|
||||
@SerialName("access_token")
|
||||
val accessToken: String,
|
||||
@SerialName("token_type")
|
||||
val tokenType: String,
|
||||
@SerialName("created_at")
|
||||
val createdAt: Long,
|
||||
@SerialName("expires_in")
|
||||
val expiresIn: Long,
|
||||
@SerialName("refresh_token")
|
||||
val refreshToken: String?,
|
||||
)
|
||||
|
||||
// Access token lives 1 day
|
||||
fun SMOAuth.isExpired() = (System.currentTimeMillis() / 1000) > (createdAt + expiresIn - 3600)
|
||||
@@ -0,0 +1,8 @@
|
||||
package eu.kanade.tachiyomi.data.track.shikimori.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class SMUser(
|
||||
val id: Int,
|
||||
)
|
||||
@@ -0,0 +1,27 @@
|
||||
package eu.kanade.tachiyomi.data.track.shikimori.dto
|
||||
|
||||
import eu.kanade.tachiyomi.data.database.models.Track
|
||||
import eu.kanade.tachiyomi.data.track.shikimori.ShikimoriApi
|
||||
import eu.kanade.tachiyomi.data.track.shikimori.toTrackStatus
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class SMUserListEntry(
|
||||
val id: Long,
|
||||
val chapters: Double,
|
||||
val score: Int,
|
||||
val status: String,
|
||||
) {
|
||||
fun toTrack(trackId: Long, manga: SMManga): Track {
|
||||
return Track.create(trackId).apply {
|
||||
title = manga.name
|
||||
remote_id = this@SMUserListEntry.id
|
||||
total_chapters = manga.chapters
|
||||
library_id = this@SMUserListEntry.id
|
||||
last_chapter_read = this@SMUserListEntry.chapters
|
||||
score = this@SMUserListEntry.score.toDouble()
|
||||
status = toTrackStatus(this@SMUserListEntry.status)
|
||||
tracking_url = ShikimoriApi.BASE_URL + manga.url
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package eu.kanade.tachiyomi.ui.manga
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.SnackbarResult
|
||||
import androidx.compose.runtime.Immutable
|
||||
@@ -23,6 +24,7 @@ import eu.kanade.domain.manga.model.chaptersFiltered
|
||||
import eu.kanade.domain.manga.model.downloadedFilter
|
||||
import eu.kanade.domain.manga.model.toSManga
|
||||
import eu.kanade.domain.track.interactor.AddTracks
|
||||
import eu.kanade.domain.track.interactor.TrackChapter
|
||||
import eu.kanade.presentation.manga.DownloadAction
|
||||
import eu.kanade.presentation.manga.components.ChapterDownloadAction
|
||||
import eu.kanade.presentation.util.formattedMessage
|
||||
@@ -92,6 +94,7 @@ class MangaScreenModel(
|
||||
private val libraryPreferences: LibraryPreferences = Injekt.get(),
|
||||
readerPreferences: ReaderPreferences = Injekt.get(),
|
||||
private val trackerManager: TrackerManager = Injekt.get(),
|
||||
private val trackChapter: TrackChapter = Injekt.get(),
|
||||
private val downloadManager: DownloadManager = Injekt.get(),
|
||||
private val downloadCache: DownloadCache = Injekt.get(),
|
||||
private val getMangaAndChapters: GetMangaWithChapters = Injekt.get(),
|
||||
@@ -721,13 +724,32 @@ class MangaScreenModel(
|
||||
* @param read whether to mark chapters as read or unread.
|
||||
*/
|
||||
fun markChaptersRead(chapters: List<Chapter>, read: Boolean) {
|
||||
toggleAllSelection(false)
|
||||
screenModelScope.launchIO {
|
||||
setReadStatus.await(
|
||||
read = read,
|
||||
chapters = chapters.toTypedArray(),
|
||||
)
|
||||
|
||||
if (!read) return@launchIO
|
||||
|
||||
val tracks = getTracks.await(mangaId)
|
||||
val maxChapterNumber = chapters.maxOf { it.chapterNumber }
|
||||
val shouldPromptTrackingUpdate = tracks.any { track -> maxChapterNumber > track.lastChapterRead }
|
||||
|
||||
if (!shouldPromptTrackingUpdate) return@launchIO
|
||||
|
||||
val result = snackbarHostState.showSnackbar(
|
||||
message = context.stringResource(MR.strings.confirm_tracker_update, maxChapterNumber.toInt()),
|
||||
actionLabel = context.stringResource(MR.strings.action_ok),
|
||||
duration = SnackbarDuration.Short,
|
||||
withDismissAction = true,
|
||||
)
|
||||
|
||||
if (result == SnackbarResult.ActionPerformed) {
|
||||
trackChapter.await(context, mangaId, maxChapterNumber)
|
||||
}
|
||||
}
|
||||
toggleAllSelection(false)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,7 +13,7 @@ class DownloadPreferences(
|
||||
|
||||
fun saveChaptersAsCBZ() = preferenceStore.getBoolean("save_chapter_as_cbz", true)
|
||||
|
||||
fun splitTallImages() = preferenceStore.getBoolean("split_tall_images", false)
|
||||
fun splitTallImages() = preferenceStore.getBoolean("split_tall_images", true)
|
||||
|
||||
fun autoDownloadWhileReading() = preferenceStore.getInt("auto_download_while_reading", 0)
|
||||
|
||||
|
||||
@@ -726,6 +726,7 @@
|
||||
<string name="are_you_sure">Are you sure?</string>
|
||||
<string name="exclude_scanlators">Exclude scanlators</string>
|
||||
<string name="no_scanlators_found">No scanlators found</string>
|
||||
<string name="confirm_tracker_update">Update trackers to chapter %d?</string>
|
||||
|
||||
<!-- Tracking Screen -->
|
||||
<string name="manga_tracking_tab">Tracking</string>
|
||||
|
||||
Reference in New Issue
Block a user