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]
|
||||
### 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))
|
||||
- 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))
|
||||
|
||||
### Improved
|
||||
|
||||
@@ -68,7 +68,7 @@ class Kitsu(id: Long) : BaseTracker(id, "Kitsu"), DeletableTracker {
|
||||
|
||||
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 }
|
||||
|
||||
@@ -119,7 +119,7 @@ class Kitsu(id: Long) : BaseTracker(id, "Kitsu"), DeletableTracker {
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -145,7 +145,7 @@ class Kitsu(id: Long) : BaseTracker(id, "Kitsu"), DeletableTracker {
|
||||
}
|
||||
|
||||
override suspend fun bind(track: Track, hasReadChapters: Boolean): Track {
|
||||
val remoteTrack = api.findLibManga(track, getUserId())
|
||||
val remoteTrack = api.findLibManga(track)
|
||||
return if (remoteTrack != null) {
|
||||
track.copyPersonalFrom(remoteTrack, copyRemotePrivate = false)
|
||||
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> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -174,7 +174,7 @@ class Kitsu(id: Long) : BaseTracker(id, "Kitsu"), DeletableTracker {
|
||||
}
|
||||
|
||||
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.total_chapters = remoteTrack.total_chapters
|
||||
return track
|
||||
@@ -185,14 +185,14 @@ class Kitsu(id: Long) : BaseTracker(id, "Kitsu"), DeletableTracker {
|
||||
interceptor.newAuth(token)
|
||||
val currentUser = api.getCurrentUser()
|
||||
|
||||
val ratingSystem = currentUser.attributes.ratingSystem
|
||||
if (ratingSystem in listOf(RATING_SIMPLE, RATING_REGULAR, RATING_ADVANCED)) {
|
||||
val ratingSystem = currentUser.ratingSystem
|
||||
if (ratingSystem.lowercase() in listOf(RATING_SIMPLE, RATING_REGULAR, RATING_ADVANCED)) {
|
||||
scorePreference.set(ratingSystem)
|
||||
} else {
|
||||
logcat(LogPriority.ERROR) { "Unsupported Kitsu score type: $ratingSystem" }
|
||||
scorePreference.set(RATING_ADVANCED)
|
||||
}
|
||||
saveDisplayUsername(currentUser.attributes.name)
|
||||
saveDisplayUsername(currentUser.profile.name)
|
||||
saveCredentials(username, currentUser.id)
|
||||
}
|
||||
|
||||
@@ -201,10 +201,6 @@ class Kitsu(id: Long) : BaseTracker(id, "Kitsu"), DeletableTracker {
|
||||
interceptor.newAuth(null)
|
||||
}
|
||||
|
||||
private fun getUserId(): String {
|
||||
return getPassword()
|
||||
}
|
||||
|
||||
fun saveToken(oauth: KitsuOAuth?) {
|
||||
trackPreferences.trackToken(this).set(json.encodeToString(oauth))
|
||||
}
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
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.KitsuAccount
|
||||
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.KitsuCurrentAccountResult
|
||||
import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuDeleteMangaResult
|
||||
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.KitsuSingleManga
|
||||
import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuUser
|
||||
import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuSearchByIdResult
|
||||
import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuSearchByIdWithLibraryResult
|
||||
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.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.await
|
||||
import eu.kanade.tachiyomi.network.awaitSuccess
|
||||
@@ -23,64 +21,95 @@ import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlinx.serialization.json.putJsonObject
|
||||
import logcat.LogPriority
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.Headers.Companion.headersOf
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import tachiyomi.core.common.util.lang.withIOContext
|
||||
import tachiyomi.core.common.util.system.logcat
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.net.URLEncoder
|
||||
import java.nio.charset.StandardCharsets
|
||||
import kotlin.time.Instant
|
||||
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 authClient = client.newBuilder().addInterceptor(interceptor).build()
|
||||
|
||||
suspend fun addLibManga(track: Track, userId: String): Track {
|
||||
suspend fun addLibManga(track: Track): Track {
|
||||
return withIOContext {
|
||||
val data = buildJsonObject {
|
||||
putJsonObject("data") {
|
||||
put("type", "libraryEntries")
|
||||
putJsonObject("attributes") {
|
||||
put("status", track.toApiStatus())
|
||||
val query = $$"""
|
||||
|mutation AddManga(
|
||||
|$media_id: ID!
|
||||
|$status: LibraryEntryStatusEnum!
|
||||
|$progress: Int!
|
||||
|$private: Boolean!
|
||||
|$rating: Int
|
||||
|) {
|
||||
|libraryEntry {
|
||||
|create(
|
||||
|input: {
|
||||
|mediaId: $media_id
|
||||
|mediaType: MANGA
|
||||
|status: $status
|
||||
|progress: $progress
|
||||
|private: $private
|
||||
|rating: $rating
|
||||
|}
|
||||
|) {
|
||||
|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)
|
||||
}
|
||||
putJsonObject("relationships") {
|
||||
putJsonObject("user") {
|
||||
putJsonObject("data") {
|
||||
put("id", userId)
|
||||
put("type", "users")
|
||||
}
|
||||
}
|
||||
putJsonObject("media") {
|
||||
putJsonObject("data") {
|
||||
put("id", track.remote_id)
|
||||
put("type", "manga")
|
||||
}
|
||||
}
|
||||
}
|
||||
put("rating", track.score.toInt().takeIf { it > 0 })
|
||||
}
|
||||
}
|
||||
|
||||
with(json) {
|
||||
authClient.newCall(
|
||||
val parsed = authClient.newCall(
|
||||
POST(
|
||||
"${BASE_URL}library-entries",
|
||||
headers = headersOf("Content-Type", VND_API_JSON),
|
||||
body = data.toString().toRequestBody(VND_JSON_MEDIA_TYPE),
|
||||
GRAPHQL_API_URL,
|
||||
body = payload.toString().toRequestBody(jsonMime),
|
||||
),
|
||||
)
|
||||
.awaitSuccess()
|
||||
.parseAs<KitsuAddMangaResult>()
|
||||
.let {
|
||||
track.library_id = it.data.id
|
||||
|
||||
if (parsed.error != null) {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -89,127 +118,223 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
|
||||
|
||||
suspend fun updateLibManga(track: Track): Track {
|
||||
return withIOContext {
|
||||
val data = buildJsonObject {
|
||||
putJsonObject("data") {
|
||||
put("type", "libraryEntries")
|
||||
put("id", track.library_id)
|
||||
putJsonObject("attributes") {
|
||||
put("status", track.toApiStatus())
|
||||
val query = $$"""
|
||||
|mutation UpdateManga(
|
||||
|$library_id: ID!
|
||||
|$status: LibraryEntryStatusEnum!
|
||||
|$progress: Int!
|
||||
|$private: Boolean!
|
||||
|$rating: Int
|
||||
|$startedAt: ISO8601DateTime
|
||||
|$finishedAt: ISO8601DateTime
|
||||
|) {
|
||||
|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("ratingTwenty", track.score.takeIf { it > 0 }?.toInt())
|
||||
put("startedAt", KitsuDateHelper.convert(track.started_reading_date))
|
||||
put("finishedAt", KitsuDateHelper.convert(track.finished_reading_date))
|
||||
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(
|
||||
Request.Builder()
|
||||
.url("${BASE_URL}library-entries/${track.library_id}")
|
||||
.headers(
|
||||
headersOf("Content-Type", VND_API_JSON),
|
||||
)
|
||||
.patch(data.toString().toRequestBody(VND_JSON_MEDIA_TYPE))
|
||||
.build(),
|
||||
with(json) {
|
||||
val parsed = authClient.newCall(
|
||||
POST(
|
||||
GRAPHQL_API_URL,
|
||||
body = payload.toString().toRequestBody(jsonMime),
|
||||
),
|
||||
)
|
||||
.awaitSuccess()
|
||||
.parseAs<KitsuUpdateMangaResult>()
|
||||
|
||||
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) {
|
||||
withIOContext {
|
||||
authClient.newCall(
|
||||
DELETE(
|
||||
"${BASE_URL}library-entries/${track.libraryId}",
|
||||
headers = headersOf("Content-Type", VND_API_JSON),
|
||||
),
|
||||
)
|
||||
.awaitSuccess()
|
||||
}
|
||||
}
|
||||
val query = $$"""|
|
||||
|mutation DeleteLibEntry(
|
||||
|$library_id: ID!
|
||||
|) {
|
||||
|libraryEntry {
|
||||
|delete(
|
||||
|input: {
|
||||
|id: $library_id
|
||||
|}
|
||||
|) {
|
||||
|errors {
|
||||
|message
|
||||
|}
|
||||
|libraryEntry {
|
||||
|id
|
||||
|}
|
||||
|}
|
||||
|}
|
||||
|}
|
||||
""".trimMargin()
|
||||
|
||||
suspend fun search(query: String): List<TrackSearch> {
|
||||
return withIOContext {
|
||||
with(json) {
|
||||
authClient.newCall(GET(ALGOLIA_KEY_URL))
|
||||
.awaitSuccess()
|
||||
.parseAs<KitsuSearchResult>()
|
||||
.let {
|
||||
algoliaSearch(it.media.key, query)
|
||||
val payload = buildJsonObject {
|
||||
put("query", query)
|
||||
putJsonObject("variables") {
|
||||
put("library_id", track.libraryId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
client.newCall(
|
||||
val parsed = authClient.newCall(
|
||||
POST(
|
||||
ALGOLIA_URL,
|
||||
headers = headersOf(
|
||||
"X-Algolia-Application-Id",
|
||||
ALGOLIA_APP_ID,
|
||||
"X-Algolia-API-Key",
|
||||
key,
|
||||
GRAPHQL_API_URL,
|
||||
body = payload.toString().toRequestBody(jsonMime),
|
||||
),
|
||||
body = jsonObject.toString().toRequestBody(jsonMime),
|
||||
)
|
||||
// Deleting something not in the library returns a 500 with "Couldn't find LibraryEntry" msg
|
||||
// awaitSuccess would throw with that but user gets their wish of "title not in library" so ignore it
|
||||
.await()
|
||||
.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()
|
||||
.parseAs<KitsuAlgoliaSearchResult>()
|
||||
.hits
|
||||
.filter { it.subtype != "novel" }
|
||||
.map { it.toTrack() }
|
||||
.parseAs<KitsuSearchByTitleResult>()
|
||||
.data.searchMangaByTitle.nodes
|
||||
.map { it.toTrackSearch(trackId) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun findLibManga(track: Track, userId: String): Track? {
|
||||
suspend fun findLibManga(track: Track): Track? {
|
||||
return withIOContext {
|
||||
val url = "${BASE_URL}library-entries".toUri().buildUpon()
|
||||
.encodedQuery("filter[manga_id]=${track.remote_id}&filter[user_id]=$userId")
|
||||
.appendQueryParameter("include", "manga")
|
||||
.build()
|
||||
with(json) {
|
||||
authClient.newCall(GET(url.toString()))
|
||||
.awaitSuccess()
|
||||
.parseAs<KitsuListSearchResult>()
|
||||
.let {
|
||||
if (it.data.isNotEmpty() && it.included.isNotEmpty()) {
|
||||
it.firstToTrack()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
val query = $$"""
|
||||
|query Query($remote_id: ID!) {
|
||||
|findMangaById(id: $remote_id) {
|
||||
|$$COMMON_MANGA_DATA
|
||||
|myLibraryEntry {
|
||||
|id
|
||||
|private
|
||||
|progress
|
||||
|rating
|
||||
|reconsuming
|
||||
|status
|
||||
|startedAt
|
||||
|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) {
|
||||
authClient.newCall(GET(url.toString()))
|
||||
authClient.newCall(
|
||||
POST(
|
||||
GRAPHQL_API_URL,
|
||||
body = payload.toString().toRequestBody(jsonMime),
|
||||
),
|
||||
)
|
||||
.awaitSuccess()
|
||||
.parseAs<KitsuListSearchResult>()
|
||||
.let {
|
||||
if (it.data.isNotEmpty() && it.included.isNotEmpty()) {
|
||||
it.firstToTrack()
|
||||
} else {
|
||||
throw Exception("Could not find manga")
|
||||
}
|
||||
}
|
||||
.parseAs<KitsuSearchByIdWithLibraryResult>()
|
||||
.data.findMangaById
|
||||
?.toTrackSearch(trackId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -231,36 +356,87 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getCurrentUser(): KitsuUser {
|
||||
suspend fun getCurrentUser(): KitsuAccount {
|
||||
return withIOContext {
|
||||
val url = "${BASE_URL}users".toUri().buildUpon()
|
||||
.encodedQuery("filter[self]=true")
|
||||
.build()
|
||||
val query = """
|
||||
|query Query {
|
||||
|currentAccount {
|
||||
|id
|
||||
|ratingSystem
|
||||
|profile {
|
||||
|name
|
||||
|}
|
||||
|}
|
||||
|}
|
||||
""".trimMargin()
|
||||
|
||||
val payload = buildJsonObject {
|
||||
put("query", query)
|
||||
}
|
||||
|
||||
with(json) {
|
||||
authClient.newCall(GET(url.toString()))
|
||||
authClient.newCall(
|
||||
POST(
|
||||
GRAPHQL_API_URL,
|
||||
body = payload.toString().toRequestBody(jsonMime),
|
||||
),
|
||||
)
|
||||
.awaitSuccess()
|
||||
.parseAs<KitsuCurrentUserResult>()
|
||||
.data[0]
|
||||
.parseAs<KitsuCurrentAccountResult>()
|
||||
.data.currentAccount
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getMangaDetails(id: Int): TrackSearch? {
|
||||
return withIOContext {
|
||||
val url = "$BASE_URL/manga/$id"
|
||||
try {
|
||||
with(json) {
|
||||
authClient.newCall(GET(url))
|
||||
.await()
|
||||
.parseAs<KitsuSingleManga>()
|
||||
.toTrackSearch()
|
||||
}
|
||||
} catch (e: HttpException) {
|
||||
if (e.code == 404) {
|
||||
null
|
||||
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 {
|
||||
throw e
|
||||
$$"""
|
||||
|query Query($query: String!) {
|
||||
|findMangaBySlug(slug: $query) {
|
||||
|$$COMMON_MANGA_DATA
|
||||
|}
|
||||
|}
|
||||
"""
|
||||
}
|
||||
|
||||
val payload = buildJsonObject {
|
||||
put("query", query.trimMargin())
|
||||
putJsonObject("variables") {
|
||||
put("query", search)
|
||||
}
|
||||
}
|
||||
|
||||
return withIOContext {
|
||||
with(json) {
|
||||
val response = authClient.newCall(
|
||||
POST(
|
||||
GRAPHQL_API_URL,
|
||||
body = payload.toString().toRequestBody(jsonMime),
|
||||
),
|
||||
)
|
||||
.awaitSuccess()
|
||||
|
||||
val kitsuManga = if (isSearchById) {
|
||||
response
|
||||
.parseAs<KitsuSearchByIdResult>()
|
||||
.data.findMangaById
|
||||
} else {
|
||||
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_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 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(
|
||||
LOGIN_URL,
|
||||
@@ -296,5 +457,38 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
|
||||
.add("client_secret", CLIENT_SECRET)
|
||||
.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.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
|
||||
|
||||
@@ -2,11 +2,20 @@ 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.toKitsuApiStatus() = 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: $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
|
||||
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
|
||||
data class KitsuAddMangaItem(
|
||||
val id: Long,
|
||||
data class KitsuAddMangaData(
|
||||
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.Serializable
|
||||
import kotlin.time.Clock
|
||||
|
||||
@Serializable
|
||||
data class KitsuOAuth(
|
||||
@@ -15,6 +16,6 @@ data class KitsuOAuth(
|
||||
val expiresIn: Long,
|
||||
@SerialName("refresh_token")
|
||||
val refreshToken: String?,
|
||||
)
|
||||
|
||||
fun KitsuOAuth.isExpired() = (System.currentTimeMillis() / 1000) > (createdAt + expiresIn - 3600)
|
||||
) {
|
||||
fun isExpired(): Boolean = (Clock.System.now().toEpochMilliseconds() / 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
|
||||
|
||||
@Serializable
|
||||
data class KitsuCurrentUserResult(
|
||||
val data: List<KitsuUser>,
|
||||
data class KitsuCurrentAccountResult(
|
||||
val data: KitsuCurrentAccountData,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class KitsuUser(
|
||||
data class KitsuCurrentAccountData(
|
||||
val currentAccount: KitsuAccount,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class KitsuAccount(
|
||||
val id: String,
|
||||
val attributes: KitsuUserAttributes,
|
||||
val ratingSystem: String,
|
||||
val profile: KitsuProfile,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class KitsuUserAttributes(
|
||||
data class KitsuProfile(
|
||||
val name: String,
|
||||
val ratingSystem: String,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user