Migrate to Kitsu's GraphQL API (#3792)
While there are still some nasty corners (multiple shapes of error response, unhandled error types on their end, etc.), this is a functional, feature-parity replacement for the JSON:API API we were using before. According to the GraphQL docs, searching by title still goes via Algolia.
This commit is contained in:
@@ -13,6 +13,7 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
### Added
|
### Added
|
||||||
- Add `id:` prefix search to remaining trackers (AniList, Bangumi, Kitsu, MangaUpdates, Shikimori, and Hikka) ([@MajorTanya](https://github.com/MajorTanya)) ([#3776](https://github.com/mihonapp/mihon/pull/3776))
|
- Add `id:` prefix search to remaining trackers (AniList, Bangumi, Kitsu, MangaUpdates, Shikimori, and Hikka) ([@MajorTanya](https://github.com/MajorTanya)) ([#3776](https://github.com/mihonapp/mihon/pull/3776))
|
||||||
|
- Allow `id:` to search for slugs on Kitsu ([@MajorTanya](https://github.com/MajorTanya)) ([#3792](https://github.com/mihonapp/mihon/pull/3792))
|
||||||
- Add support for using the user's chosen rating system for Kitsu ([@MajorTanya](https://github.com/MajorTanya)) ([#3818](https://github.com/mihonapp/mihon/pull/3818))
|
- Add support for using the user's chosen rating system for Kitsu ([@MajorTanya](https://github.com/MajorTanya)) ([#3818](https://github.com/mihonapp/mihon/pull/3818))
|
||||||
|
|
||||||
### Improved
|
### Improved
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ class Kitsu(id: Long) : BaseTracker(id, "Kitsu"), DeletableTracker {
|
|||||||
|
|
||||||
private val interceptor by lazy { KitsuInterceptor(this) }
|
private val interceptor by lazy { KitsuInterceptor(this) }
|
||||||
|
|
||||||
private val api by lazy { KitsuApi(client, interceptor) }
|
private val api by lazy { KitsuApi(id, client, interceptor) }
|
||||||
|
|
||||||
private val scorePreference by lazy { trackPreferences.kitsuScoreType }
|
private val scorePreference by lazy { trackPreferences.kitsuScoreType }
|
||||||
|
|
||||||
@@ -119,7 +119,7 @@ class Kitsu(id: Long) : BaseTracker(id, "Kitsu"), DeletableTracker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun add(track: Track): Track {
|
private suspend fun add(track: Track): Track {
|
||||||
return api.addLibManga(track, getUserId())
|
return api.addLibManga(track)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun update(track: Track, didReadChapter: Boolean): Track {
|
override suspend fun update(track: Track, didReadChapter: Boolean): Track {
|
||||||
@@ -145,7 +145,7 @@ class Kitsu(id: Long) : BaseTracker(id, "Kitsu"), DeletableTracker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun bind(track: Track, hasReadChapters: Boolean): Track {
|
override suspend fun bind(track: Track, hasReadChapters: Boolean): Track {
|
||||||
val remoteTrack = api.findLibManga(track, getUserId())
|
val remoteTrack = api.findLibManga(track)
|
||||||
return if (remoteTrack != null) {
|
return if (remoteTrack != null) {
|
||||||
track.copyPersonalFrom(remoteTrack, copyRemotePrivate = false)
|
track.copyPersonalFrom(remoteTrack, copyRemotePrivate = false)
|
||||||
track.remote_id = remoteTrack.remote_id
|
track.remote_id = remoteTrack.remote_id
|
||||||
@@ -165,7 +165,7 @@ class Kitsu(id: Long) : BaseTracker(id, "Kitsu"), DeletableTracker {
|
|||||||
|
|
||||||
override suspend fun search(query: String): List<TrackSearch> {
|
override suspend fun search(query: String): List<TrackSearch> {
|
||||||
if (query.startsWith(SEARCH_ID_PREFIX)) {
|
if (query.startsWith(SEARCH_ID_PREFIX)) {
|
||||||
query.substringAfter(SEARCH_ID_PREFIX).trim().toIntOrNull()?.let { id ->
|
query.substringAfter(SEARCH_ID_PREFIX).trim().let { id ->
|
||||||
return api.getMangaDetails(id)?.let { listOf(it) } ?: emptyList()
|
return api.getMangaDetails(id)?.let { listOf(it) } ?: emptyList()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -174,7 +174,7 @@ class Kitsu(id: Long) : BaseTracker(id, "Kitsu"), DeletableTracker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun refresh(track: Track): Track {
|
override suspend fun refresh(track: Track): Track {
|
||||||
val remoteTrack = api.getLibManga(track)
|
val remoteTrack = api.findLibManga(track) ?: throw Exception("Could not find manga")
|
||||||
track.copyPersonalFrom(remoteTrack)
|
track.copyPersonalFrom(remoteTrack)
|
||||||
track.total_chapters = remoteTrack.total_chapters
|
track.total_chapters = remoteTrack.total_chapters
|
||||||
return track
|
return track
|
||||||
@@ -185,14 +185,14 @@ class Kitsu(id: Long) : BaseTracker(id, "Kitsu"), DeletableTracker {
|
|||||||
interceptor.newAuth(token)
|
interceptor.newAuth(token)
|
||||||
val currentUser = api.getCurrentUser()
|
val currentUser = api.getCurrentUser()
|
||||||
|
|
||||||
val ratingSystem = currentUser.attributes.ratingSystem
|
val ratingSystem = currentUser.ratingSystem
|
||||||
if (ratingSystem in listOf(RATING_SIMPLE, RATING_REGULAR, RATING_ADVANCED)) {
|
if (ratingSystem.lowercase() in listOf(RATING_SIMPLE, RATING_REGULAR, RATING_ADVANCED)) {
|
||||||
scorePreference.set(ratingSystem)
|
scorePreference.set(ratingSystem)
|
||||||
} else {
|
} else {
|
||||||
logcat(LogPriority.ERROR) { "Unsupported Kitsu score type: $ratingSystem" }
|
logcat(LogPriority.ERROR) { "Unsupported Kitsu score type: $ratingSystem" }
|
||||||
scorePreference.set(RATING_ADVANCED)
|
scorePreference.set(RATING_ADVANCED)
|
||||||
}
|
}
|
||||||
saveDisplayUsername(currentUser.attributes.name)
|
saveDisplayUsername(currentUser.profile.name)
|
||||||
saveCredentials(username, currentUser.id)
|
saveCredentials(username, currentUser.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,10 +201,6 @@ class Kitsu(id: Long) : BaseTracker(id, "Kitsu"), DeletableTracker {
|
|||||||
interceptor.newAuth(null)
|
interceptor.newAuth(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getUserId(): String {
|
|
||||||
return getPassword()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun saveToken(oauth: KitsuOAuth?) {
|
fun saveToken(oauth: KitsuOAuth?) {
|
||||||
trackPreferences.trackToken(this).set(json.encodeToString(oauth))
|
trackPreferences.trackToken(this).set(json.encodeToString(oauth))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,17 @@
|
|||||||
package eu.kanade.tachiyomi.data.track.kitsu
|
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.database.models.Track
|
||||||
|
import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuAccount
|
||||||
import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuAddMangaResult
|
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.KitsuCurrentAccountResult
|
||||||
import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuCurrentUserResult
|
import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuDeleteMangaResult
|
||||||
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.KitsuOAuth
|
||||||
import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuSearchResult
|
import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuSearchByIdResult
|
||||||
import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuSingleManga
|
import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuSearchByIdWithLibraryResult
|
||||||
import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuUser
|
import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuSearchBySlugResult
|
||||||
|
import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuSearchByTitleResult
|
||||||
|
import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuUpdateMangaResult
|
||||||
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||||
import eu.kanade.tachiyomi.network.DELETE
|
|
||||||
import eu.kanade.tachiyomi.network.GET
|
|
||||||
import eu.kanade.tachiyomi.network.HttpException
|
|
||||||
import eu.kanade.tachiyomi.network.POST
|
import eu.kanade.tachiyomi.network.POST
|
||||||
import eu.kanade.tachiyomi.network.await
|
import eu.kanade.tachiyomi.network.await
|
||||||
import eu.kanade.tachiyomi.network.awaitSuccess
|
import eu.kanade.tachiyomi.network.awaitSuccess
|
||||||
@@ -23,193 +21,320 @@ import kotlinx.serialization.json.Json
|
|||||||
import kotlinx.serialization.json.buildJsonObject
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
import kotlinx.serialization.json.put
|
import kotlinx.serialization.json.put
|
||||||
import kotlinx.serialization.json.putJsonObject
|
import kotlinx.serialization.json.putJsonObject
|
||||||
|
import logcat.LogPriority
|
||||||
import okhttp3.FormBody
|
import okhttp3.FormBody
|
||||||
import okhttp3.Headers.Companion.headersOf
|
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
|
||||||
import okhttp3.RequestBody
|
import okhttp3.RequestBody
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
import tachiyomi.core.common.util.lang.withIOContext
|
import tachiyomi.core.common.util.lang.withIOContext
|
||||||
|
import tachiyomi.core.common.util.system.logcat
|
||||||
import uy.kohesive.injekt.injectLazy
|
import uy.kohesive.injekt.injectLazy
|
||||||
import java.net.URLEncoder
|
import kotlin.time.Instant
|
||||||
import java.nio.charset.StandardCharsets
|
|
||||||
import tachiyomi.domain.track.model.Track as DomainTrack
|
import tachiyomi.domain.track.model.Track as DomainTrack
|
||||||
|
|
||||||
class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor) {
|
class KitsuApi(
|
||||||
|
private val trackId: Long,
|
||||||
|
private val client: OkHttpClient,
|
||||||
|
interceptor: KitsuInterceptor,
|
||||||
|
) {
|
||||||
|
|
||||||
private val json: Json by injectLazy()
|
private val json: Json by injectLazy()
|
||||||
|
|
||||||
private val authClient = client.newBuilder().addInterceptor(interceptor).build()
|
private val authClient = client.newBuilder().addInterceptor(interceptor).build()
|
||||||
|
|
||||||
suspend fun addLibManga(track: Track, userId: String): Track {
|
suspend fun addLibManga(track: Track): Track {
|
||||||
return withIOContext {
|
return withIOContext {
|
||||||
val data = buildJsonObject {
|
val query = $$"""
|
||||||
putJsonObject("data") {
|
|mutation AddManga(
|
||||||
put("type", "libraryEntries")
|
|$media_id: ID!
|
||||||
putJsonObject("attributes") {
|
|$status: LibraryEntryStatusEnum!
|
||||||
put("status", track.toApiStatus())
|
|$progress: Int!
|
||||||
put("progress", track.last_chapter_read.toInt())
|
|$private: Boolean!
|
||||||
put("private", track.private)
|
|$rating: Int
|
||||||
}
|
|) {
|
||||||
putJsonObject("relationships") {
|
|libraryEntry {
|
||||||
putJsonObject("user") {
|
|create(
|
||||||
putJsonObject("data") {
|
|input: {
|
||||||
put("id", userId)
|
|mediaId: $media_id
|
||||||
put("type", "users")
|
|mediaType: MANGA
|
||||||
}
|
|status: $status
|
||||||
}
|
|progress: $progress
|
||||||
putJsonObject("media") {
|
|private: $private
|
||||||
putJsonObject("data") {
|
|rating: $rating
|
||||||
put("id", track.remote_id)
|
|}
|
||||||
put("type", "manga")
|
|) {
|
||||||
}
|
|errors {
|
||||||
}
|
|message
|
||||||
}
|
|}
|
||||||
|
|libraryEntry {
|
||||||
|
|id
|
||||||
|
|}
|
||||||
|
|}
|
||||||
|
|}
|
||||||
|
|}
|
||||||
|
""".trimMargin()
|
||||||
|
|
||||||
|
val payload = buildJsonObject {
|
||||||
|
put("query", query)
|
||||||
|
putJsonObject("variables") {
|
||||||
|
put("media_id", track.remote_id)
|
||||||
|
put("status", track.toKitsuApiStatus())
|
||||||
|
put("progress", track.last_chapter_read.toInt())
|
||||||
|
put("private", track.private)
|
||||||
|
put("rating", track.score.toInt().takeIf { it > 0 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
with(json) {
|
with(json) {
|
||||||
authClient.newCall(
|
val parsed = authClient.newCall(
|
||||||
POST(
|
POST(
|
||||||
"${BASE_URL}library-entries",
|
GRAPHQL_API_URL,
|
||||||
headers = headersOf("Content-Type", VND_API_JSON),
|
body = payload.toString().toRequestBody(jsonMime),
|
||||||
body = data.toString().toRequestBody(VND_JSON_MEDIA_TYPE),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.awaitSuccess()
|
.awaitSuccess()
|
||||||
.parseAs<KitsuAddMangaResult>()
|
.parseAs<KitsuAddMangaResult>()
|
||||||
.let {
|
|
||||||
track.library_id = it.data.id
|
if (parsed.error != null) {
|
||||||
track
|
logcat(LogPriority.ERROR) { "Failed to add: ${parsed.error.message ?: "(none)"}" }
|
||||||
|
throw Exception("Failed to add manga")
|
||||||
|
} else if (parsed.errors != null) {
|
||||||
|
parsed.errors.forEach {
|
||||||
|
logcat(LogPriority.ERROR) { "Failed to add: ${it.message ?: "(none)"}" }
|
||||||
}
|
}
|
||||||
|
throw Exception("Failed to add manga")
|
||||||
|
} else if (parsed.data == null) {
|
||||||
|
logcat(LogPriority.ERROR) { "Kitsu error, errors, and data null?" }
|
||||||
|
throw Exception("Encountered unexpected error while adding manga")
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed.data.libraryEntry.create.libraryEntry.id.let {
|
||||||
|
track.library_id = it.toLong()
|
||||||
|
track
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun updateLibManga(track: Track): Track {
|
suspend fun updateLibManga(track: Track): Track {
|
||||||
return withIOContext {
|
return withIOContext {
|
||||||
val data = buildJsonObject {
|
val query = $$"""
|
||||||
putJsonObject("data") {
|
|mutation UpdateManga(
|
||||||
put("type", "libraryEntries")
|
|$library_id: ID!
|
||||||
put("id", track.library_id)
|
|$status: LibraryEntryStatusEnum!
|
||||||
putJsonObject("attributes") {
|
|$progress: Int!
|
||||||
put("status", track.toApiStatus())
|
|$private: Boolean!
|
||||||
put("progress", track.last_chapter_read.toInt())
|
|$rating: Int
|
||||||
put("ratingTwenty", track.score.takeIf { it > 0 }?.toInt())
|
|$startedAt: ISO8601DateTime
|
||||||
put("startedAt", KitsuDateHelper.convert(track.started_reading_date))
|
|$finishedAt: ISO8601DateTime
|
||||||
put("finishedAt", KitsuDateHelper.convert(track.finished_reading_date))
|
|) {
|
||||||
put("private", track.private)
|
|libraryEntry {
|
||||||
}
|
|update(
|
||||||
|
|input: {
|
||||||
|
|id: $library_id
|
||||||
|
|status: $status
|
||||||
|
|progress: $progress
|
||||||
|
|private: $private
|
||||||
|
|rating: $rating
|
||||||
|
|startedAt: $startedAt
|
||||||
|
|finishedAt: $finishedAt
|
||||||
|
|}
|
||||||
|
|) {
|
||||||
|
|errors {
|
||||||
|
|message
|
||||||
|
|}
|
||||||
|
|libraryEntry {
|
||||||
|
|id
|
||||||
|
|}
|
||||||
|
|}
|
||||||
|
|}
|
||||||
|
|}
|
||||||
|
""".trimMargin()
|
||||||
|
|
||||||
|
val payload = buildJsonObject {
|
||||||
|
put("query", query)
|
||||||
|
putJsonObject("variables") {
|
||||||
|
put("library_id", track.library_id)
|
||||||
|
put("status", track.toKitsuApiStatus())
|
||||||
|
put("progress", track.last_chapter_read.toInt())
|
||||||
|
put("private", track.private)
|
||||||
|
put("rating", track.score.toInt().takeIf { it > 0 })
|
||||||
|
put(
|
||||||
|
"startedAt",
|
||||||
|
track.started_reading_date
|
||||||
|
.takeIf { it > 0 }
|
||||||
|
?.let { Instant.fromEpochMilliseconds(it).toString() },
|
||||||
|
)
|
||||||
|
put(
|
||||||
|
"finishedAt",
|
||||||
|
track.finished_reading_date
|
||||||
|
.takeIf { it > 0 }
|
||||||
|
?.let { Instant.fromEpochMilliseconds(it).toString() },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
authClient.newCall(
|
with(json) {
|
||||||
Request.Builder()
|
val parsed = authClient.newCall(
|
||||||
.url("${BASE_URL}library-entries/${track.library_id}")
|
POST(
|
||||||
.headers(
|
GRAPHQL_API_URL,
|
||||||
headersOf("Content-Type", VND_API_JSON),
|
body = payload.toString().toRequestBody(jsonMime),
|
||||||
)
|
),
|
||||||
.patch(data.toString().toRequestBody(VND_JSON_MEDIA_TYPE))
|
)
|
||||||
.build(),
|
.awaitSuccess()
|
||||||
)
|
.parseAs<KitsuUpdateMangaResult>()
|
||||||
.awaitSuccess()
|
|
||||||
|
|
||||||
track
|
if (parsed.error != null) {
|
||||||
|
logcat(LogPriority.ERROR) { "Failed to update: ${parsed.error.message ?: "(none)"}" }
|
||||||
|
throw Exception("Failed to update manga")
|
||||||
|
} else if (parsed.errors != null) {
|
||||||
|
parsed.errors.forEach {
|
||||||
|
logcat(LogPriority.ERROR) { "Failed to update: ${it.message ?: "(none)"}" }
|
||||||
|
}
|
||||||
|
throw Exception("Failed to update manga")
|
||||||
|
} else if (parsed.data == null) {
|
||||||
|
logcat(LogPriority.ERROR) { "Kitsu error, errors, and data null?" }
|
||||||
|
throw Exception("Encountered unexpected error while updating manga")
|
||||||
|
}
|
||||||
|
|
||||||
|
track
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun removeLibManga(track: DomainTrack) {
|
suspend fun removeLibManga(track: DomainTrack) {
|
||||||
withIOContext {
|
withIOContext {
|
||||||
authClient.newCall(
|
val query = $$"""|
|
||||||
DELETE(
|
|mutation DeleteLibEntry(
|
||||||
"${BASE_URL}library-entries/${track.libraryId}",
|
|$library_id: ID!
|
||||||
headers = headersOf("Content-Type", VND_API_JSON),
|
|) {
|
||||||
),
|
|libraryEntry {
|
||||||
)
|
|delete(
|
||||||
.awaitSuccess()
|
|input: {
|
||||||
}
|
|id: $library_id
|
||||||
}
|
|}
|
||||||
|
|) {
|
||||||
|
|errors {
|
||||||
|
|message
|
||||||
|
|}
|
||||||
|
|libraryEntry {
|
||||||
|
|id
|
||||||
|
|}
|
||||||
|
|}
|
||||||
|
|}
|
||||||
|
|}
|
||||||
|
""".trimMargin()
|
||||||
|
|
||||||
suspend fun search(query: String): List<TrackSearch> {
|
val payload = buildJsonObject {
|
||||||
return withIOContext {
|
put("query", query)
|
||||||
with(json) {
|
putJsonObject("variables") {
|
||||||
authClient.newCall(GET(ALGOLIA_KEY_URL))
|
put("library_id", track.libraryId)
|
||||||
.awaitSuccess()
|
}
|
||||||
.parseAs<KitsuSearchResult>()
|
|
||||||
.let {
|
|
||||||
algoliaSearch(it.media.key, query)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun algoliaSearch(key: String, query: String): List<TrackSearch> {
|
|
||||||
return withIOContext {
|
|
||||||
val jsonObject = buildJsonObject {
|
|
||||||
put("params", "query=${URLEncoder.encode(query, StandardCharsets.UTF_8.name())}$ALGOLIA_FILTER")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
with(json) {
|
with(json) {
|
||||||
client.newCall(
|
val parsed = authClient.newCall(
|
||||||
POST(
|
POST(
|
||||||
ALGOLIA_URL,
|
GRAPHQL_API_URL,
|
||||||
headers = headersOf(
|
body = payload.toString().toRequestBody(jsonMime),
|
||||||
"X-Algolia-Application-Id",
|
),
|
||||||
ALGOLIA_APP_ID,
|
)
|
||||||
"X-Algolia-API-Key",
|
// Deleting something not in the library returns a 500 with "Couldn't find LibraryEntry" msg
|
||||||
key,
|
// awaitSuccess would throw with that but user gets their wish of "title not in library" so ignore it
|
||||||
),
|
.await()
|
||||||
body = jsonObject.toString().toRequestBody(jsonMime),
|
.parseAs<KitsuDeleteMangaResult>()
|
||||||
|
|
||||||
|
if (parsed.error != null) {
|
||||||
|
logcat(LogPriority.ERROR) { "Failed to delete: ${parsed.error.message ?: "(none)"}" }
|
||||||
|
if (parsed.error.message != null && parsed.error.message.startsWith("Couldn't find")) {
|
||||||
|
return@with
|
||||||
|
}
|
||||||
|
throw Exception("Failed to delete manga")
|
||||||
|
} else if (parsed.errors != null) {
|
||||||
|
parsed.errors.forEach {
|
||||||
|
logcat(LogPriority.ERROR) { "Failed to delete: ${it.message ?: "(none)"}" }
|
||||||
|
}
|
||||||
|
throw Exception("Failed to delete manga")
|
||||||
|
} else if (parsed.data == null) {
|
||||||
|
logcat(LogPriority.ERROR) { "Kitsu error, errors, and data null?" }
|
||||||
|
throw Exception("Encountered unexpected error while deleting manga")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun search(search: String): List<TrackSearch> {
|
||||||
|
return withIOContext {
|
||||||
|
val query = $$"""
|
||||||
|
|query Query($query: String!) {
|
||||||
|
|searchMangaByTitle(title: $query, first: 20) {
|
||||||
|
|nodes {
|
||||||
|
$$COMMON_MANGA_DATA
|
||||||
|
|}
|
||||||
|
|}
|
||||||
|
|}
|
||||||
|
""".trimMargin()
|
||||||
|
|
||||||
|
val payload = buildJsonObject {
|
||||||
|
put("query", query)
|
||||||
|
putJsonObject("variables") {
|
||||||
|
put("query", search)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
with(json) {
|
||||||
|
authClient.newCall(
|
||||||
|
POST(
|
||||||
|
GRAPHQL_API_URL,
|
||||||
|
body = payload.toString().toRequestBody(jsonMime),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.awaitSuccess()
|
.awaitSuccess()
|
||||||
.parseAs<KitsuAlgoliaSearchResult>()
|
.parseAs<KitsuSearchByTitleResult>()
|
||||||
.hits
|
.data.searchMangaByTitle.nodes
|
||||||
.filter { it.subtype != "novel" }
|
.map { it.toTrackSearch(trackId) }
|
||||||
.map { it.toTrack() }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun findLibManga(track: Track, userId: String): Track? {
|
suspend fun findLibManga(track: Track): Track? {
|
||||||
return withIOContext {
|
return withIOContext {
|
||||||
val url = "${BASE_URL}library-entries".toUri().buildUpon()
|
val query = $$"""
|
||||||
.encodedQuery("filter[manga_id]=${track.remote_id}&filter[user_id]=$userId")
|
|query Query($remote_id: ID!) {
|
||||||
.appendQueryParameter("include", "manga")
|
|findMangaById(id: $remote_id) {
|
||||||
.build()
|
|$$COMMON_MANGA_DATA
|
||||||
with(json) {
|
|myLibraryEntry {
|
||||||
authClient.newCall(GET(url.toString()))
|
|id
|
||||||
.awaitSuccess()
|
|private
|
||||||
.parseAs<KitsuListSearchResult>()
|
|progress
|
||||||
.let {
|
|rating
|
||||||
if (it.data.isNotEmpty() && it.included.isNotEmpty()) {
|
|reconsuming
|
||||||
it.firstToTrack()
|
|status
|
||||||
} else {
|
|startedAt
|
||||||
null
|
|finishedAt
|
||||||
}
|
|}
|
||||||
}
|
|}
|
||||||
}
|
|}
|
||||||
}
|
""".trimMargin()
|
||||||
}
|
|
||||||
|
val payload = buildJsonObject {
|
||||||
|
put("query", query)
|
||||||
|
putJsonObject("variables") {
|
||||||
|
put("remote_id", track.remote_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun getLibManga(track: Track): Track {
|
|
||||||
return withIOContext {
|
|
||||||
val url = "${BASE_URL}library-entries".toUri().buildUpon()
|
|
||||||
.encodedQuery("filter[id]=${track.library_id}")
|
|
||||||
.appendQueryParameter("include", "manga")
|
|
||||||
.build()
|
|
||||||
with(json) {
|
with(json) {
|
||||||
authClient.newCall(GET(url.toString()))
|
authClient.newCall(
|
||||||
|
POST(
|
||||||
|
GRAPHQL_API_URL,
|
||||||
|
body = payload.toString().toRequestBody(jsonMime),
|
||||||
|
),
|
||||||
|
)
|
||||||
.awaitSuccess()
|
.awaitSuccess()
|
||||||
.parseAs<KitsuListSearchResult>()
|
.parseAs<KitsuSearchByIdWithLibraryResult>()
|
||||||
.let {
|
.data.findMangaById
|
||||||
if (it.data.isNotEmpty() && it.included.isNotEmpty()) {
|
?.toTrackSearch(trackId)
|
||||||
it.firstToTrack()
|
|
||||||
} else {
|
|
||||||
throw Exception("Could not find manga")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -231,36 +356,87 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getCurrentUser(): KitsuUser {
|
suspend fun getCurrentUser(): KitsuAccount {
|
||||||
return withIOContext {
|
return withIOContext {
|
||||||
val url = "${BASE_URL}users".toUri().buildUpon()
|
val query = """
|
||||||
.encodedQuery("filter[self]=true")
|
|query Query {
|
||||||
.build()
|
|currentAccount {
|
||||||
|
|id
|
||||||
|
|ratingSystem
|
||||||
|
|profile {
|
||||||
|
|name
|
||||||
|
|}
|
||||||
|
|}
|
||||||
|
|}
|
||||||
|
""".trimMargin()
|
||||||
|
|
||||||
|
val payload = buildJsonObject {
|
||||||
|
put("query", query)
|
||||||
|
}
|
||||||
|
|
||||||
with(json) {
|
with(json) {
|
||||||
authClient.newCall(GET(url.toString()))
|
authClient.newCall(
|
||||||
|
POST(
|
||||||
|
GRAPHQL_API_URL,
|
||||||
|
body = payload.toString().toRequestBody(jsonMime),
|
||||||
|
),
|
||||||
|
)
|
||||||
.awaitSuccess()
|
.awaitSuccess()
|
||||||
.parseAs<KitsuCurrentUserResult>()
|
.parseAs<KitsuCurrentAccountResult>()
|
||||||
.data[0]
|
.data.currentAccount
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getMangaDetails(id: Int): TrackSearch? {
|
suspend fun getMangaDetails(search: String): TrackSearch? {
|
||||||
|
val isSearchById = search.matches(Regex("\\d+"))
|
||||||
|
|
||||||
|
val query = if (isSearchById) {
|
||||||
|
$$"""
|
||||||
|
|query Query($query: ID!) {
|
||||||
|
|findMangaById(id: $query) {
|
||||||
|
|$$COMMON_MANGA_DATA
|
||||||
|
|}
|
||||||
|
|}
|
||||||
|
"""
|
||||||
|
} else {
|
||||||
|
$$"""
|
||||||
|
|query Query($query: String!) {
|
||||||
|
|findMangaBySlug(slug: $query) {
|
||||||
|
|$$COMMON_MANGA_DATA
|
||||||
|
|}
|
||||||
|
|}
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
val payload = buildJsonObject {
|
||||||
|
put("query", query.trimMargin())
|
||||||
|
putJsonObject("variables") {
|
||||||
|
put("query", search)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return withIOContext {
|
return withIOContext {
|
||||||
val url = "$BASE_URL/manga/$id"
|
with(json) {
|
||||||
try {
|
val response = authClient.newCall(
|
||||||
with(json) {
|
POST(
|
||||||
authClient.newCall(GET(url))
|
GRAPHQL_API_URL,
|
||||||
.await()
|
body = payload.toString().toRequestBody(jsonMime),
|
||||||
.parseAs<KitsuSingleManga>()
|
),
|
||||||
.toTrackSearch()
|
)
|
||||||
}
|
.awaitSuccess()
|
||||||
} catch (e: HttpException) {
|
|
||||||
if (e.code == 404) {
|
val kitsuManga = if (isSearchById) {
|
||||||
null
|
response
|
||||||
|
.parseAs<KitsuSearchByIdResult>()
|
||||||
|
.data.findMangaById
|
||||||
} else {
|
} else {
|
||||||
throw e
|
response
|
||||||
|
.parseAs<KitsuSearchBySlugResult>()
|
||||||
|
.data.findMangaBySlug
|
||||||
}
|
}
|
||||||
|
|
||||||
|
kitsuManga?.toTrackSearch(trackId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -269,23 +445,8 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
|
|||||||
private const val CLIENT_ID = "dd031b32d2f56c990b1425efe6c42ad847e7fe3ab46bf1299f05ecd856bdb7dd"
|
private const val CLIENT_ID = "dd031b32d2f56c990b1425efe6c42ad847e7fe3ab46bf1299f05ecd856bdb7dd"
|
||||||
private const val CLIENT_SECRET = "54d7307928f63414defd96399fc31ba847961ceaecef3a5fd93144e960c0e151"
|
private const val CLIENT_SECRET = "54d7307928f63414defd96399fc31ba847961ceaecef3a5fd93144e960c0e151"
|
||||||
|
|
||||||
private const val BASE_URL = "https://kitsu.app/api/edge/"
|
private const val GRAPHQL_API_URL = "https://kitsu.app/api/graphql"
|
||||||
private const val LOGIN_URL = "https://kitsu.app/api/oauth/token"
|
private const val LOGIN_URL = "https://kitsu.app/api/oauth/token"
|
||||||
private const val BASE_MANGA_URL = "https://kitsu.app/manga/"
|
|
||||||
private const val ALGOLIA_KEY_URL = "https://kitsu.app/api/edge/algolia-keys/media/"
|
|
||||||
|
|
||||||
private const val ALGOLIA_APP_ID = "AWQO5J657S"
|
|
||||||
private const val ALGOLIA_URL = "https://$ALGOLIA_APP_ID-dsn.algolia.net/1/indexes/production_media/query/"
|
|
||||||
private const val ALGOLIA_FILTER = "&facetFilters=%5B%22kind%3Amanga%22%5D&attributesToRetrieve=" +
|
|
||||||
"%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
|
|
||||||
}
|
|
||||||
|
|
||||||
fun refreshTokenRequest(token: String) = POST(
|
fun refreshTokenRequest(token: String) = POST(
|
||||||
LOGIN_URL,
|
LOGIN_URL,
|
||||||
@@ -296,5 +457,38 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
|
|||||||
.add("client_secret", CLIENT_SECRET)
|
.add("client_secret", CLIENT_SECRET)
|
||||||
.build(),
|
.build(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private val COMMON_MANGA_DATA = """
|
||||||
|
|id
|
||||||
|
|titles {
|
||||||
|
|preferred
|
||||||
|
|}
|
||||||
|
|chapterCount
|
||||||
|
|staff(first: 5) {
|
||||||
|
|nodes {
|
||||||
|
|role
|
||||||
|
|person {
|
||||||
|
|name
|
||||||
|
|}
|
||||||
|
|}
|
||||||
|
|}
|
||||||
|
|posterImage {
|
||||||
|
|views(names: "small") {
|
||||||
|
|name
|
||||||
|
|url
|
||||||
|
|}
|
||||||
|
|original {
|
||||||
|
|name
|
||||||
|
|url
|
||||||
|
|}
|
||||||
|
|}
|
||||||
|
|description(locales: "en")
|
||||||
|
|status
|
||||||
|
|subtype
|
||||||
|
|startDate
|
||||||
|
|endDate
|
||||||
|
|slug
|
||||||
|
|averageRating
|
||||||
|
""".trimMargin()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
package eu.kanade.tachiyomi.data.track.kitsu
|
|
||||||
|
|
||||||
import java.text.SimpleDateFormat
|
|
||||||
import java.util.Date
|
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
object KitsuDateHelper {
|
|
||||||
|
|
||||||
private const val PATTERN = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"
|
|
||||||
private val formatter = SimpleDateFormat(PATTERN, Locale.ENGLISH)
|
|
||||||
|
|
||||||
fun convert(dateValue: Long): String? {
|
|
||||||
if (dateValue == 0L) return null
|
|
||||||
|
|
||||||
return formatter.format(Date(dateValue))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun parse(dateString: String?): Long {
|
|
||||||
if (dateString == null) return 0L
|
|
||||||
|
|
||||||
val dateValue = formatter.parse(dateString)
|
|
||||||
|
|
||||||
return dateValue?.time ?: 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,6 @@ package eu.kanade.tachiyomi.data.track.kitsu
|
|||||||
|
|
||||||
import eu.kanade.tachiyomi.BuildConfig
|
import eu.kanade.tachiyomi.BuildConfig
|
||||||
import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuOAuth
|
import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuOAuth
|
||||||
import eu.kanade.tachiyomi.data.track.kitsu.dto.isExpired
|
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import okhttp3.Interceptor
|
import okhttp3.Interceptor
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
|
|||||||
@@ -2,11 +2,20 @@ package eu.kanade.tachiyomi.data.track.kitsu
|
|||||||
|
|
||||||
import eu.kanade.tachiyomi.data.database.models.Track
|
import eu.kanade.tachiyomi.data.database.models.Track
|
||||||
|
|
||||||
fun Track.toApiStatus() = when (status) {
|
fun Track.toKitsuApiStatus() = when (status) {
|
||||||
Kitsu.READING -> "current"
|
Kitsu.READING -> "CURRENT"
|
||||||
Kitsu.COMPLETED -> "completed"
|
Kitsu.COMPLETED -> "COMPLETED"
|
||||||
Kitsu.ON_HOLD -> "on_hold"
|
Kitsu.ON_HOLD -> "ON_HOLD"
|
||||||
Kitsu.DROPPED -> "dropped"
|
Kitsu.DROPPED -> "DROPPED"
|
||||||
Kitsu.PLAN_TO_READ -> "planned"
|
Kitsu.PLAN_TO_READ -> "PLANNED"
|
||||||
else -> throw Exception("Unknown status")
|
else -> throw Exception("Unknown status: $status")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun String.toKitsuLocalStatus() = when (this) {
|
||||||
|
"CURRENT" -> Kitsu.READING
|
||||||
|
"COMPLETED" -> Kitsu.COMPLETED
|
||||||
|
"ON_HOLD" -> Kitsu.ON_HOLD
|
||||||
|
"DROPPED" -> Kitsu.DROPPED
|
||||||
|
"PLANNED" -> Kitsu.PLAN_TO_READ
|
||||||
|
else -> throw Exception("Unknown status: $this")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,20 @@ import kotlinx.serialization.Serializable
|
|||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class KitsuAddMangaResult(
|
data class KitsuAddMangaResult(
|
||||||
val data: KitsuAddMangaItem,
|
// yes there are two different error attributes and yes they have different structures
|
||||||
|
// it seems both are valid in different cases
|
||||||
|
// (500s send "error" for example, 200s with validation errors send "errors")
|
||||||
|
val data: KitsuAddMangaData?,
|
||||||
|
val errors: List<KitsuErrorMessage>?,
|
||||||
|
val error: KitsuErrorMessage?,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class KitsuAddMangaItem(
|
data class KitsuAddMangaData(
|
||||||
val id: Long,
|
val libraryEntry: KitsuAddMangaLibraryEntryWrapper,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class KitsuAddMangaLibraryEntryWrapper(
|
||||||
|
val create: KitsuLibraryEntryResult,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.track.kitsu.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class KitsuErrorMessage(
|
||||||
|
val message: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class KitsuLibraryEntryResult(
|
||||||
|
val libraryEntry: KitsuSparseLibraryEntry,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class KitsuSparseLibraryEntry(
|
||||||
|
val id: String,
|
||||||
|
)
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.track.kitsu.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class KitsuDeleteMangaResult(
|
||||||
|
// yes there are two different error attributes and yes they have different structures
|
||||||
|
// it seems both are valid in different cases
|
||||||
|
// (500s send "error" for example, 200s with validation errors send "errors")
|
||||||
|
val data: KitsuDeleteMangaData?,
|
||||||
|
val errors: List<KitsuErrorMessage>?,
|
||||||
|
val error: KitsuErrorMessage?,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class KitsuDeleteMangaData(
|
||||||
|
val libraryEntry: KitsuDeleteMangaLibraryEntryWrapper,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class KitsuDeleteMangaLibraryEntryWrapper(
|
||||||
|
val delete: KitsuLibraryEntryResult,
|
||||||
|
)
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
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 = included[0].id
|
|
||||||
library_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?.toDouble() ?: 0.0
|
|
||||||
last_chapter_read = userDataAttrs.progress.toDouble()
|
|
||||||
private = userDataAttrs.private
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@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: Int?,
|
|
||||||
val progress: Int,
|
|
||||||
val private: Boolean,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class KitsuSingleManga(
|
|
||||||
val data: KitsuListSearchItemIncluded,
|
|
||||||
) {
|
|
||||||
fun toTrackSearch(): TrackSearch {
|
|
||||||
return TrackSearch.create(TrackerManager.KITSU).apply {
|
|
||||||
remote_id = data.id
|
|
||||||
title = data.attributes.canonicalTitle
|
|
||||||
total_chapters = data.attributes.chapterCount ?: 0
|
|
||||||
cover_url = data.attributes.posterImage?.original ?: ""
|
|
||||||
summary = data.attributes.synopsis ?: ""
|
|
||||||
tracking_url = KitsuApi.mangaUrl(remote_id)
|
|
||||||
score = data.attributes.averageRating?.toDoubleOrNull() ?: -1.0
|
|
||||||
publishing_status = data.attributes.status
|
|
||||||
publishing_type = data.attributes.mangaType ?: ""
|
|
||||||
start_date = data.attributes.startDate ?: ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@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,
|
|
||||||
val averageRating: String?,
|
|
||||||
)
|
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.track.kitsu.dto
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class KitsuManga(
|
||||||
|
val id: String,
|
||||||
|
val titles: KitsuMangaTitles,
|
||||||
|
val chapterCount: Long?,
|
||||||
|
val staff: KitsuMangaStaffData,
|
||||||
|
val posterImage: KitsuMangaPosters,
|
||||||
|
val description: Map<String, String>,
|
||||||
|
val status: String,
|
||||||
|
val subtype: String,
|
||||||
|
val startDate: String?,
|
||||||
|
val endDate: String?,
|
||||||
|
val slug: String,
|
||||||
|
val averageRating: Double?,
|
||||||
|
) {
|
||||||
|
fun toTrackSearch(trackId: Long): TrackSearch {
|
||||||
|
return TrackSearch.create(trackId).apply {
|
||||||
|
remote_id = this@KitsuManga.id.toLong()
|
||||||
|
title = titles.preferred
|
||||||
|
total_chapters = chapterCount ?: 0
|
||||||
|
cover_url = posterImage.getPosterUrl()
|
||||||
|
summary = description["en"] ?: ""
|
||||||
|
tracking_url = "https://kitsu.app/manga/$slug"
|
||||||
|
score = averageRating ?: -1.0
|
||||||
|
publishing_status = when (this@KitsuManga.status) {
|
||||||
|
"TBA" -> "TBA"
|
||||||
|
"CURRENT" -> "Publishing"
|
||||||
|
else -> this@KitsuManga.status.lowercase().replaceFirstChar { it.uppercase() }
|
||||||
|
}
|
||||||
|
publishing_type = if (subtype != "OEL") {
|
||||||
|
subtype.lowercase().replaceFirstChar { it.uppercase() }
|
||||||
|
} else {
|
||||||
|
subtype
|
||||||
|
}
|
||||||
|
start_date = startDate ?: ""
|
||||||
|
authors = staff.nodes
|
||||||
|
.filter { it.role.contains("Story") }
|
||||||
|
.map { it.person.name }
|
||||||
|
artists = staff.nodes
|
||||||
|
.filter { it.role.contains("Art") }
|
||||||
|
.map { it.person.name }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class KitsuMangaTitles(
|
||||||
|
val preferred: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class KitsuMangaStaffData(
|
||||||
|
val nodes: List<KitsuMangaStaff>,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class KitsuMangaStaff(
|
||||||
|
val role: String,
|
||||||
|
val person: KitsuMangaStaffPerson,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class KitsuMangaStaffPerson(
|
||||||
|
val name: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class KitsuMangaPosters(
|
||||||
|
val views: List<KitsuMangaPoster>,
|
||||||
|
val original: KitsuMangaPoster,
|
||||||
|
) {
|
||||||
|
// we only ask for the "small" poster in the query
|
||||||
|
fun getPosterUrl(): String = views.firstOrNull()?.url ?: original.url
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class KitsuMangaPoster(
|
||||||
|
val name: String,
|
||||||
|
val url: String,
|
||||||
|
)
|
||||||
+74
@@ -0,0 +1,74 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.track.kitsu.dto
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.data.track.kitsu.toKitsuLocalStatus
|
||||||
|
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
// KitsuManga extended with KitsuLibraryEntryData
|
||||||
|
@Serializable
|
||||||
|
data class KitsuMangaWithLibraryEntry(
|
||||||
|
val id: String,
|
||||||
|
val titles: KitsuMangaTitles,
|
||||||
|
val chapterCount: Long?,
|
||||||
|
val staff: KitsuMangaStaffData,
|
||||||
|
val posterImage: KitsuMangaPosters,
|
||||||
|
val description: Map<String, String>,
|
||||||
|
val status: String,
|
||||||
|
val subtype: String,
|
||||||
|
val startDate: String?,
|
||||||
|
val endDate: String?,
|
||||||
|
val slug: String,
|
||||||
|
val averageRating: Double?,
|
||||||
|
val myLibraryEntry: KitsuLibraryEntryData?,
|
||||||
|
) {
|
||||||
|
fun toTrackSearch(trackId: Long): TrackSearch? {
|
||||||
|
if (myLibraryEntry == null) return null
|
||||||
|
|
||||||
|
return TrackSearch.create(trackId).apply {
|
||||||
|
remote_id = this@KitsuMangaWithLibraryEntry.id.toLong()
|
||||||
|
library_id = myLibraryEntry.id.toLong()
|
||||||
|
title = titles.preferred
|
||||||
|
total_chapters = chapterCount ?: 0
|
||||||
|
cover_url = posterImage.getPosterUrl()
|
||||||
|
summary = description["en"] ?: ""
|
||||||
|
tracking_url = "https://kitsu.app/manga/$slug"
|
||||||
|
publishing_status = when (this@KitsuMangaWithLibraryEntry.status) {
|
||||||
|
"TBA" -> "TBA"
|
||||||
|
"CURRENT" -> "Publishing"
|
||||||
|
else -> this@KitsuMangaWithLibraryEntry.status.lowercase().replaceFirstChar { it.uppercase() }
|
||||||
|
}
|
||||||
|
publishing_type = if (subtype != "OEL") {
|
||||||
|
subtype.lowercase().replaceFirstChar { it.uppercase() }
|
||||||
|
} else {
|
||||||
|
subtype
|
||||||
|
}
|
||||||
|
start_date = startDate ?: ""
|
||||||
|
authors = staff.nodes
|
||||||
|
.filter { it.role.contains("Story") }
|
||||||
|
.map { it.person.name }
|
||||||
|
artists = staff.nodes
|
||||||
|
.filter { it.role.contains("Art") }
|
||||||
|
.map { it.person.name }
|
||||||
|
|
||||||
|
started_reading_date = myLibraryEntry.startedAt?.let { Instant.parse(it).toEpochMilliseconds() } ?: 0
|
||||||
|
finished_reading_date = myLibraryEntry.finishedAt?.let { Instant.parse(it).toEpochMilliseconds() } ?: 0
|
||||||
|
status = myLibraryEntry.status.toKitsuLocalStatus()
|
||||||
|
score = myLibraryEntry.rating?.toDouble() ?: 0.0
|
||||||
|
last_chapter_read = myLibraryEntry.progress.toDouble()
|
||||||
|
private = myLibraryEntry.private
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class KitsuLibraryEntryData(
|
||||||
|
val id: String,
|
||||||
|
val private: Boolean,
|
||||||
|
val progress: Long,
|
||||||
|
val rating: Long?,
|
||||||
|
val reconsuming: Boolean,
|
||||||
|
val status: String,
|
||||||
|
val startedAt: String?,
|
||||||
|
val finishedAt: String?,
|
||||||
|
)
|
||||||
@@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.data.track.kitsu.dto
|
|||||||
|
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlin.time.Clock
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class KitsuOAuth(
|
data class KitsuOAuth(
|
||||||
@@ -15,6 +16,6 @@ data class KitsuOAuth(
|
|||||||
val expiresIn: Long,
|
val expiresIn: Long,
|
||||||
@SerialName("refresh_token")
|
@SerialName("refresh_token")
|
||||||
val refreshToken: String?,
|
val refreshToken: String?,
|
||||||
)
|
) {
|
||||||
|
fun isExpired(): Boolean = (Clock.System.now().toEpochMilliseconds() / 1000) > (createdAt + expiresIn - 3600)
|
||||||
fun KitsuOAuth.isExpired() = (System.currentTimeMillis() / 1000) > (createdAt + expiresIn - 3600)
|
}
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
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,25 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.track.kitsu.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
// normal search
|
||||||
|
@Serializable
|
||||||
|
data class KitsuSearchByIdResult(
|
||||||
|
val data: KitsuSearchByIdData,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class KitsuSearchByIdData(
|
||||||
|
val findMangaById: KitsuManga?,
|
||||||
|
)
|
||||||
|
|
||||||
|
// findLibManga (on tracker sheet refresh & when checking for remote track on binding)
|
||||||
|
@Serializable
|
||||||
|
data class KitsuSearchByIdWithLibraryResult(
|
||||||
|
val data: KitsuSearchByIdWithLibraryData,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class KitsuSearchByIdWithLibraryData(
|
||||||
|
val findMangaById: KitsuMangaWithLibraryEntry?,
|
||||||
|
)
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.track.kitsu.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class KitsuSearchBySlugResult(
|
||||||
|
val data: KitsuSearchBySlugData,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class KitsuSearchBySlugData(
|
||||||
|
val findMangaBySlug: KitsuManga?,
|
||||||
|
)
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.track.kitsu.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class KitsuSearchByTitleResult(
|
||||||
|
val data: KitsuSearchByTitleData,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class KitsuSearchByTitleData(
|
||||||
|
val searchMangaByTitle: KitsuSearchNodes,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class KitsuSearchNodes(
|
||||||
|
val nodes: List<KitsuManga>,
|
||||||
|
)
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
package eu.kanade.tachiyomi.data.track.kitsu.dto
|
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class KitsuSearchItemCover(
|
|
||||||
val original: String?,
|
|
||||||
)
|
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.track.kitsu.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class KitsuUpdateMangaResult(
|
||||||
|
// yes there are two different error attributes and yes they have different structures
|
||||||
|
// it seems both are valid in different cases
|
||||||
|
// (500s send "error" for example, 200s with validation errors send "errors")
|
||||||
|
val data: KitsuUpdateMangaData?,
|
||||||
|
val errors: List<KitsuErrorMessage>?,
|
||||||
|
val error: KitsuErrorMessage?,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class KitsuUpdateMangaData(
|
||||||
|
val libraryEntry: KitsuUpdateMangaLibraryEntryWrapper,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class KitsuUpdateMangaLibraryEntryWrapper(
|
||||||
|
val update: KitsuLibraryEntryResult,
|
||||||
|
)
|
||||||
@@ -3,18 +3,23 @@ package eu.kanade.tachiyomi.data.track.kitsu.dto
|
|||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class KitsuCurrentUserResult(
|
data class KitsuCurrentAccountResult(
|
||||||
val data: List<KitsuUser>,
|
val data: KitsuCurrentAccountData,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class KitsuUser(
|
data class KitsuCurrentAccountData(
|
||||||
|
val currentAccount: KitsuAccount,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class KitsuAccount(
|
||||||
val id: String,
|
val id: String,
|
||||||
val attributes: KitsuUserAttributes,
|
val ratingSystem: String,
|
||||||
|
val profile: KitsuProfile,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class KitsuUserAttributes(
|
data class KitsuProfile(
|
||||||
val name: String,
|
val name: String,
|
||||||
val ratingSystem: String,
|
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user