mirror of
https://github.com/mihonapp/mihon.git
synced 2026-10-16 13:48:09 -07:00
Use Apollo GraphQL for Trackers with GraphQL APIs (#3827)
* Add Apollo for GraphQL interactions * Migrate AniList to use Apollo * Migrate Shikimori to use Apollo Not all Shikimori requests can be served by their GraphQL API (notably they do not offer any mutations), so those remain as-is. * Migrate Suwayomi to use Apollo Includes leveraging some slightly newer Suwayomi features, specifically the Double/Float filter fixes first pre-released in 2.1.1985 on 2025-10-26 and first fully released in 2.1.2100 on 2026-05-08. Considering the tachiyomix 1.6 extension lib changes were added in Suwayomi 2.3.2223 on 2026-06-30, I think it is fair to depend on such sorta-recent behaviour. * Fix temp migration names and leftovers * Create ApolloResponse.dataOrElse extension Because Apollo doesn't throw exceptions and instead just provides them as response attributes, the current ApolloResponse.awaitSuccess() is useless and will be removed shortly. This new extension function should wrap the basic handling of an ApolloResponse object neatly. Co-authored-by: Mitchell Syer <6090655+Syer10@users.noreply.github.com> * Use dataOrElse in AnilistApi.kt * Use dataOrElse in ShikimoriApi.kt * Use dataOrElse in SuwayomiApi.kt * Delete obsolete ApolloCall.awaitSuccess * Migrate Kitsu to use Apollo Kitsu has some unfortunate edges in its GraphQL API (such as returning nonstandard bodies which don't play well with Apollo) but this should be an equivalent implementation. * Extract a MangaFragment for AniList This simplifies the queries and the converters (which can now delegate to the MangaFragment.toTrackSearch() method internally and adjust the track as needed). Also move the toALUser converter to AnilistUtils.kt to keep all converters together. Added a scalar-mapper for AL's CountryCode type. * Minor consistency cleanup of the queries * Store list entry ID not title ID for Shikimori * Extract common MangaFragment for Shikimori Also map the ISO8601Date to a Kotlin String in the service config. * Map Kitsu's ISO8601DateTime to kotlin.time.Instant Utilises Apollo's own adapter-core library which provides the mapper. Requires opting in to ExperimentalTime until the library resolves the outdated annotation. Co-authored-by: Mitchell Syer <6090655+Syer10@users.noreply.github.com> * Replace Kitsu TODO comment So much for STOPSHIP preventing me shipping this... * Bump Apollo version to 5.1.0 * Bump apollo-adapters-core to 0.8.0 Freshly released just earlier. This resolves having to opt in to ExperimentalTime. * Make new utils internal to align with #3908 * Fix leftover issues from merged #3828 This PR restructured the AniList and Kitsu user objects. * Bump apollo to 5.2.0 * Remove unused attributes from queries * Allow custom exception handler in dataOrElse Enables callers to manually handle specific non-200 responses. Callers MUST close the `ApolloHttpException.body` attached to the passed exception to avoid resource and socket leaks. Defaults to current behaviour of (re)throwing the status code with our HttpException. Utilized in KitsuApi.kt to specially handle Kitsu sending a 500 error response when deleting library IDs not actually in a user's library and falling back to the "throw as HttpException" if it's any other error response. * Rethrow networking/IO exceptions This helps to avoid masking networking problems as "This isn't in your list" problems. Currently, the app shows "Failed to connect to ..." when the remote server is unreachable (e.g. downtime, DNS block, etc) because that's the message provided by the ConnectException thrown by Android's network stack. This change restores that behaviour, approximately. Not the most elegant solution but it _should_ avoid false-positive toasts. --------- Co-authored-by: Mitchell Syer <6090655+Syer10@users.noreply.github.com>
This commit is contained in:
co-authored by
Mitchell Syer
parent
59fdf7e7fa
commit
ec614bb4c3
@@ -19,6 +19,7 @@ plugins {
|
||||
alias(libs.plugins.aboutLibraries)
|
||||
alias(libs.plugins.androidx.baselineProfile)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.apollo)
|
||||
}
|
||||
|
||||
if (Config.includeTelemetry) {
|
||||
@@ -318,6 +319,10 @@ dependencies {
|
||||
// String similarity
|
||||
implementation(libs.stringSimilarity)
|
||||
|
||||
// GraphQL generation
|
||||
implementation(libs.apollo)
|
||||
implementation(libs.apollo.adapters)
|
||||
|
||||
// Tests
|
||||
testImplementation(libs.bundles.test)
|
||||
testRuntimeOnly(libs.junit.platform.launcher)
|
||||
@@ -329,6 +334,76 @@ dependencies {
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
}
|
||||
|
||||
apollo {
|
||||
val schemaBasePath = "src/main/graphql/mihon/graphql"
|
||||
service("anilist") {
|
||||
packageName.set("mihon.graphql.anilist")
|
||||
val srcDir = "$schemaBasePath/anilist"
|
||||
srcDir(file(srcDir))
|
||||
|
||||
introspection {
|
||||
endpointUrl.set("https://graphql.anilist.co")
|
||||
schemaFile.set(file("$srcDir/anilist.graphqls"))
|
||||
}
|
||||
|
||||
// ISO 3166-1 alpha-2 country code
|
||||
mapScalarToKotlinString("CountryCode")
|
||||
}
|
||||
|
||||
service("kitsu") {
|
||||
packageName.set("mihon.graphql.kitsu")
|
||||
val srcDir = "$schemaBasePath/kitsu"
|
||||
srcDir(file(srcDir))
|
||||
android.defaultConfig.versionCode
|
||||
|
||||
introspection {
|
||||
// Kitsu doesn't like requests without a UA
|
||||
headers.put(
|
||||
"User-Agent",
|
||||
"Mihon v${android.defaultConfig.versionName} (${android.defaultConfig.applicationId})",
|
||||
)
|
||||
endpointUrl.set("https://kitsu.app/api/graphql")
|
||||
schemaFile.set(file("$srcDir/kitsu.graphqls"))
|
||||
}
|
||||
|
||||
// A date, expressed as an ISO8601 string
|
||||
mapScalarToKotlinString("Date")
|
||||
// An ISO 8601-encoded datetime
|
||||
mapScalar("ISO8601DateTime", "kotlin.time.Instant", "com.apollographql.adapter.core.KotlinInstantAdapter")
|
||||
// A loose key-value map in GraphQL
|
||||
mapScalar(
|
||||
"Map",
|
||||
"kotlin.collections.Map<String, Any?>",
|
||||
"mihon.graphql.kitsu.adapter.KitsuMapAdapter",
|
||||
)
|
||||
}
|
||||
|
||||
service("shikimori") {
|
||||
packageName.set("mihon.graphql.shikimori")
|
||||
val srcDir = "$schemaBasePath/shikimori"
|
||||
srcDir(file(srcDir))
|
||||
|
||||
introspection {
|
||||
endpointUrl.set("https://shikimori.io/api/graphql")
|
||||
schemaFile.set(file("$srcDir/shikimori.graphqls"))
|
||||
}
|
||||
|
||||
// An ISO 8601-encoded date
|
||||
mapScalarToKotlinString("ISO8601Date")
|
||||
}
|
||||
|
||||
service("suwayomi") {
|
||||
packageName.set("mihon.graphql.suwayomi")
|
||||
val srcDir = "$schemaBasePath/suwayomi"
|
||||
srcDir(file(srcDir))
|
||||
|
||||
introspection {
|
||||
endpointUrl.set("http://localhost:4567/api/graphql")
|
||||
schemaFile.set(file("$srcDir/suwayomi.graphqls"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val latestCommitCount = getLatestCommitCount()
|
||||
val latestCommitSha = getLatestCommitSha()
|
||||
val latestCommitTime = getLatestCommitTime()
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
mutation AniListAddManga(
|
||||
$manga_id: Int!,
|
||||
$progress: Int!,
|
||||
$status: MediaListStatus!,
|
||||
$private: Boolean!,
|
||||
) {
|
||||
SaveMediaListEntry (
|
||||
mediaId: $manga_id,
|
||||
progress: $progress,
|
||||
status: $status,
|
||||
private: $private,
|
||||
) {
|
||||
id
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
mutation AniListDeleteManga(
|
||||
$library_id: Int!,
|
||||
) {
|
||||
DeleteMediaListEntry(
|
||||
id: $library_id,
|
||||
) {
|
||||
deleted
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
query AniListGetCurrentUser {
|
||||
Viewer {
|
||||
id
|
||||
name
|
||||
mediaListOptions {
|
||||
scoreFormat
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
query AniListGetLibManga(
|
||||
$user_id: Int!,
|
||||
$manga_id: Int!,
|
||||
) {
|
||||
Page {
|
||||
mediaList(
|
||||
userId: $user_id,
|
||||
type: MANGA,
|
||||
mediaId: $manga_id,
|
||||
) {
|
||||
id
|
||||
status
|
||||
scoreRaw: score(format: POINT_100)
|
||||
progress
|
||||
private
|
||||
startedAt {
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
completedAt {
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
media {
|
||||
...MangaFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
query AniListGetMangaDetails(
|
||||
$manga_id: Int!,
|
||||
) {
|
||||
Page (perPage: 1) {
|
||||
media(
|
||||
id: $manga_id,
|
||||
type: MANGA,
|
||||
format_not_in: [NOVEL],
|
||||
) {
|
||||
...MangaFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
query AniListSearchManga(
|
||||
$search: String!,
|
||||
) {
|
||||
Page (perPage: 50) {
|
||||
media(
|
||||
search: $search,
|
||||
type: MANGA,
|
||||
format_not_in: [NOVEL],
|
||||
) {
|
||||
...MangaFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
mutation AniListUpdateManga(
|
||||
$library_id: Int!,
|
||||
$progress: Int!,
|
||||
$status: MediaListStatus!,
|
||||
$private: Boolean!,
|
||||
$score: Int!,
|
||||
$startedAt: FuzzyDateInput!,
|
||||
$completedAt: FuzzyDateInput!,
|
||||
) {
|
||||
SaveMediaListEntry(
|
||||
id: $library_id,
|
||||
progress: $progress,
|
||||
status: $status,
|
||||
private: $private,
|
||||
scoreRaw: $score,
|
||||
startedAt: $startedAt,
|
||||
completedAt: $completedAt,
|
||||
) {
|
||||
id
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
fragment MangaFragment on Media {
|
||||
id
|
||||
staff {
|
||||
edges {
|
||||
role
|
||||
node {
|
||||
name {
|
||||
userPreferred
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
title {
|
||||
userPreferred
|
||||
}
|
||||
coverImage {
|
||||
large
|
||||
}
|
||||
format
|
||||
countryOfOrigin
|
||||
status
|
||||
chapters
|
||||
description
|
||||
startDate {
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
averageScore
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,27 @@
|
||||
mutation KitsuAddLibManga(
|
||||
$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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
mutation KitsuDeleteLibEntry(
|
||||
$library_id: ID!,
|
||||
) {
|
||||
libraryEntry {
|
||||
delete(
|
||||
input: {
|
||||
id: $library_id,
|
||||
}
|
||||
) {
|
||||
errors {
|
||||
message
|
||||
}
|
||||
libraryEntry {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
query KitsuFindLibManga(
|
||||
$remote_id: ID!,
|
||||
) {
|
||||
findMangaById(
|
||||
id: $remote_id,
|
||||
) {
|
||||
...MangaFragment
|
||||
myLibraryEntry {
|
||||
id
|
||||
private
|
||||
progress
|
||||
rating
|
||||
status
|
||||
startedAt
|
||||
finishedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
query KitsuGetCurrentAccount {
|
||||
currentAccount {
|
||||
id
|
||||
ratingSystem
|
||||
profile {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
query KitsuGetMangaDetailsById(
|
||||
$id: ID!,
|
||||
) {
|
||||
findMangaById(
|
||||
id: $id,
|
||||
) {
|
||||
...MangaFragment
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
query KitsuGetMangaDetailsBySlug(
|
||||
$slug: String!,
|
||||
) {
|
||||
findMangaBySlug(
|
||||
slug: $slug,
|
||||
) {
|
||||
...MangaFragment
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
query KitsuSearchMangaByTitle(
|
||||
$query: String!,
|
||||
) {
|
||||
searchMangaByTitle(
|
||||
title: $query,
|
||||
first: 20,
|
||||
) {
|
||||
nodes {
|
||||
...MangaFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
mutation KitsuUpdateLibManga(
|
||||
$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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
fragment MangaFragment on Manga {
|
||||
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
|
||||
slug
|
||||
averageRating
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
fragment MangaFragment on Manga {
|
||||
id
|
||||
name
|
||||
chapters
|
||||
kind
|
||||
poster {
|
||||
mainUrl
|
||||
}
|
||||
score
|
||||
url
|
||||
status
|
||||
airedOn {
|
||||
date
|
||||
}
|
||||
description
|
||||
personRoles {
|
||||
person {
|
||||
name
|
||||
}
|
||||
rolesEn
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
query ShikimoriGetCurrentUser {
|
||||
currentUser {
|
||||
id
|
||||
nickname
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
query ShikimoriGetLibManga(
|
||||
$remote_id: String!,
|
||||
) {
|
||||
mangas(
|
||||
ids: $remote_id,
|
||||
limit: 1,
|
||||
) {
|
||||
id
|
||||
url
|
||||
name
|
||||
chapters
|
||||
userRate {
|
||||
id
|
||||
chapters
|
||||
status
|
||||
score
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
query ShikimoriGetMangaDetails(
|
||||
$query: String!,
|
||||
) {
|
||||
mangas(
|
||||
ids: $query,
|
||||
limit: 1,
|
||||
kind: "!light_novel,!novel",
|
||||
) {
|
||||
...MangaFragment
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
query ShikimoriSearchManga(
|
||||
$search: String!
|
||||
) {
|
||||
mangas(
|
||||
search: $search,
|
||||
limit: 20,
|
||||
kind: "!light_novel,!novel",
|
||||
) {
|
||||
...MangaFragment
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,15 @@
|
||||
fragment MangaFragment on MangaType {
|
||||
description
|
||||
id
|
||||
status
|
||||
thumbnailUrl
|
||||
title
|
||||
url
|
||||
chapters {
|
||||
totalCount
|
||||
}
|
||||
latestReadChapter {
|
||||
chapterNumber
|
||||
}
|
||||
unreadCount
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
query SuwayomiGetManga(
|
||||
$mangaId: Int!,
|
||||
) {
|
||||
manga(
|
||||
id: $mangaId,
|
||||
) {
|
||||
...MangaFragment
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
query SuwayomiGetMangaUnreadChapters(
|
||||
$mangaId: Int!,
|
||||
$chapterNumber: Float!,
|
||||
) {
|
||||
chapters(
|
||||
condition: {
|
||||
mangaId: $mangaId,
|
||||
isRead: false,
|
||||
},
|
||||
filter: {
|
||||
and: [
|
||||
{
|
||||
chapterNumber: {
|
||||
lessThanOrEqualTo: $chapterNumber
|
||||
}
|
||||
},
|
||||
{
|
||||
chapterNumber: {
|
||||
greaterThan: 0
|
||||
}
|
||||
},
|
||||
]
|
||||
},
|
||||
){
|
||||
nodes {
|
||||
id
|
||||
chapterNumber
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
mutation SuwayomiMarkChaptersRead(
|
||||
$chapters: [Int!]!,
|
||||
) {
|
||||
updateChapters(
|
||||
input: {
|
||||
ids: $chapters,
|
||||
patch: {
|
||||
isRead: true,
|
||||
},
|
||||
},
|
||||
) {
|
||||
__typename
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
mutation SuwayomiMarkAndDeleteChapters(
|
||||
$chapters: [Int!]!,
|
||||
) {
|
||||
updateChapters(
|
||||
input: {
|
||||
ids: $chapters,
|
||||
patch: {
|
||||
isRead: true,
|
||||
},
|
||||
},
|
||||
) {
|
||||
__typename
|
||||
}
|
||||
|
||||
deleteDownloadedChapters(
|
||||
input: {
|
||||
ids: $chapters,
|
||||
},
|
||||
) {
|
||||
__typename
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
mutation SuwayomiUpdateMangaProgress(
|
||||
$mangaId: Int!,
|
||||
) {
|
||||
trackProgress(
|
||||
input: {
|
||||
mangaId: $mangaId,
|
||||
},
|
||||
) {
|
||||
__typename
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -207,7 +207,7 @@ class Anilist(id: Long) : BaseTracker(id, "AniList"), DeletableTracker {
|
||||
}
|
||||
|
||||
override suspend fun refresh(track: Track): Track {
|
||||
val remoteTrack = api.getLibManga(track, getUsername().toInt())
|
||||
val remoteTrack = api.findLibManga(track, getUsername().toInt()) ?: throw Exception("Could not find manga")
|
||||
track.copyPersonalFrom(remoteTrack)
|
||||
track.title = remoteTrack.title
|
||||
track.total_chapters = remoteTrack.total_chapters
|
||||
@@ -216,7 +216,7 @@ class Anilist(id: Long) : BaseTracker(id, "AniList"), DeletableTracker {
|
||||
|
||||
override suspend fun updateUserConfig() {
|
||||
val currentUser = api.getCurrentUser()
|
||||
scorePreference.set(currentUser.mediaListOptions.scoreFormat)
|
||||
scorePreference.set(currentUser.scoreFormat)
|
||||
saveDisplayUsername(currentUser.name)
|
||||
}
|
||||
|
||||
@@ -227,7 +227,7 @@ class Anilist(id: Long) : BaseTracker(id, "AniList"), DeletableTracker {
|
||||
val oauth = ALOAuth(token)
|
||||
interceptor.setAuth(oauth)
|
||||
val currentUser = api.getCurrentUser()
|
||||
scorePreference.set(currentUser.mediaListOptions.scoreFormat)
|
||||
scorePreference.set(currentUser.scoreFormat)
|
||||
saveDisplayUsername(currentUser.name)
|
||||
saveCredentials(currentUser.id.toString(), oauth.accessToken)
|
||||
} catch (_: Throwable) {
|
||||
|
||||
@@ -2,31 +2,28 @@ package eu.kanade.tachiyomi.data.track.anilist
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.core.net.toUri
|
||||
import com.apollographql.apollo.ApolloClient
|
||||
import com.apollographql.apollo.api.Optional
|
||||
import com.apollographql.apollo.network.okHttpClient
|
||||
import eu.kanade.tachiyomi.data.database.models.Track
|
||||
import eu.kanade.tachiyomi.data.track.anilist.dto.ALAddMangaResult
|
||||
import eu.kanade.tachiyomi.data.track.anilist.dto.ALCurrentUserResult
|
||||
import eu.kanade.tachiyomi.data.track.anilist.dto.ALSearchResult
|
||||
import eu.kanade.tachiyomi.data.track.anilist.dto.ALUserListMangaQueryResult
|
||||
import eu.kanade.tachiyomi.data.track.anilist.dto.ALUserViewerData
|
||||
import eu.kanade.tachiyomi.data.track.anilist.dto.ALUser
|
||||
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||
import eu.kanade.tachiyomi.network.POST
|
||||
import eu.kanade.tachiyomi.network.awaitSuccess
|
||||
import eu.kanade.tachiyomi.network.dataOrElse
|
||||
import eu.kanade.tachiyomi.network.interceptor.rateLimit
|
||||
import eu.kanade.tachiyomi.network.jsonMime
|
||||
import eu.kanade.tachiyomi.network.parseAs
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.number
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlinx.serialization.json.putJsonObject
|
||||
import mihon.graphql.anilist.AniListAddMangaMutation
|
||||
import mihon.graphql.anilist.AniListDeleteMangaMutation
|
||||
import mihon.graphql.anilist.AniListGetCurrentUserQuery
|
||||
import mihon.graphql.anilist.AniListGetLibMangaQuery
|
||||
import mihon.graphql.anilist.AniListGetMangaDetailsQuery
|
||||
import mihon.graphql.anilist.AniListSearchMangaQuery
|
||||
import mihon.graphql.anilist.AniListUpdateMangaMutation
|
||||
import mihon.graphql.anilist.type.FuzzyDateInput
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import tachiyomi.core.common.util.lang.withIOContext
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import tachiyomi.core.common.util.system.logcat
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Instant
|
||||
import tachiyomi.domain.track.model.Track as DomainTrack
|
||||
@@ -37,381 +34,175 @@ class AnilistApi(
|
||||
interceptor: AnilistInterceptor,
|
||||
) {
|
||||
|
||||
private val json: Json by injectLazy()
|
||||
|
||||
private val authClient = client.newBuilder()
|
||||
.addInterceptor(interceptor)
|
||||
.rateLimit(permits = 25, period = 1.minutes)
|
||||
.build()
|
||||
|
||||
private val graphQlClient by lazy {
|
||||
ApolloClient.Builder()
|
||||
.serverUrl("https://graphql.anilist.co")
|
||||
.okHttpClient(authClient)
|
||||
.dispatcher(Dispatchers.IO)
|
||||
// required to log the error body in dataOrElse, which also properly closes it
|
||||
.httpExposeErrorBody(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
suspend fun addLibManga(track: Track): Track {
|
||||
return withIOContext {
|
||||
val query = $$"""
|
||||
|mutation AddManga($mangaId: Int, $progress: Int, $status: MediaListStatus, $private: Boolean) {
|
||||
|SaveMediaListEntry (mediaId: $mangaId, progress: $progress, status: $status, private: $private) {
|
||||
| id
|
||||
| status
|
||||
|}
|
||||
|}
|
||||
|
|
||||
""".trimMargin()
|
||||
val payload = buildJsonObject {
|
||||
put("query", query)
|
||||
putJsonObject("variables") {
|
||||
put("mangaId", track.remote_id)
|
||||
put("progress", track.last_chapter_read.toInt())
|
||||
put("status", track.toApiStatus())
|
||||
put("private", track.private)
|
||||
}
|
||||
}
|
||||
with(json) {
|
||||
authClient.newCall(
|
||||
POST(
|
||||
API_URL,
|
||||
body = payload.toString().toRequestBody(jsonMime),
|
||||
return graphQlClient
|
||||
.mutation(
|
||||
AniListAddMangaMutation(
|
||||
manga_id = track.remote_id.toInt(),
|
||||
progress = track.last_chapter_read.toInt(),
|
||||
status = track.toApiStatus(),
|
||||
private = track.private,
|
||||
),
|
||||
)
|
||||
.awaitSuccess()
|
||||
.parseAs<ALAddMangaResult>()
|
||||
.let {
|
||||
track.library_id = it.data.entry.id
|
||||
.execute()
|
||||
.dataOrElse(
|
||||
errorLog = "AniList: Failed to add manga",
|
||||
default = { null },
|
||||
) {
|
||||
it.SaveMediaListEntry?.id?.let { libraryId ->
|
||||
track.library_id = libraryId.toLong()
|
||||
track
|
||||
}
|
||||
}
|
||||
}
|
||||
?: throw Exception("Failed to add manga")
|
||||
}
|
||||
|
||||
suspend fun updateLibManga(track: Track): Track {
|
||||
return withIOContext {
|
||||
val query = $$"""
|
||||
|mutation UpdateManga(
|
||||
|$listId: Int, $progress: Int, $status: MediaListStatus, $private: Boolean,
|
||||
|$score: Int, $startedAt: FuzzyDateInput, $completedAt: FuzzyDateInput
|
||||
|) {
|
||||
|SaveMediaListEntry(
|
||||
|id: $listId, progress: $progress, status: $status, private: $private,
|
||||
|scoreRaw: $score, startedAt: $startedAt, completedAt: $completedAt
|
||||
|) {
|
||||
|id
|
||||
|status
|
||||
|progress
|
||||
|}
|
||||
|}
|
||||
|
|
||||
""".trimMargin()
|
||||
val payload = buildJsonObject {
|
||||
put("query", query)
|
||||
putJsonObject("variables") {
|
||||
put("listId", track.library_id)
|
||||
put("progress", track.last_chapter_read.toInt())
|
||||
put("status", track.toApiStatus())
|
||||
put("score", track.score.toInt())
|
||||
put("startedAt", createDate(track.started_reading_date))
|
||||
put("completedAt", createDate(track.finished_reading_date))
|
||||
put("private", track.private)
|
||||
}
|
||||
}
|
||||
authClient.newCall(POST(API_URL, body = payload.toString().toRequestBody(jsonMime)))
|
||||
.awaitSuccess()
|
||||
val libraryId = track.library_id
|
||||
requireNotNull(libraryId) { "AniList cannot update track with null library_id" }
|
||||
|
||||
return graphQlClient
|
||||
.mutation(
|
||||
AniListUpdateMangaMutation(
|
||||
library_id = libraryId.toInt(),
|
||||
progress = track.last_chapter_read.toInt(),
|
||||
status = track.toApiStatus(),
|
||||
private = track.private,
|
||||
score = track.score.toInt(),
|
||||
startedAt = createFuzzyDate(track.started_reading_date),
|
||||
completedAt = createFuzzyDate(track.finished_reading_date),
|
||||
),
|
||||
)
|
||||
.execute()
|
||||
.dataOrElse(
|
||||
errorLog = "AniList: Failed to update manga",
|
||||
default = { null },
|
||||
) {
|
||||
it.SaveMediaListEntry?.id?.let { remoteLibraryId ->
|
||||
track.library_id = remoteLibraryId.toLong()
|
||||
track
|
||||
}
|
||||
}
|
||||
?: throw Exception("Failed to update manga")
|
||||
}
|
||||
|
||||
suspend fun deleteLibManga(track: DomainTrack) {
|
||||
withIOContext {
|
||||
val query = $$"""
|
||||
|mutation DeleteManga($listId: Int) {
|
||||
|DeleteMediaListEntry(id: $listId) {
|
||||
|deleted
|
||||
|}
|
||||
|}
|
||||
|
|
||||
""".trimMargin()
|
||||
val payload = buildJsonObject {
|
||||
put("query", query)
|
||||
putJsonObject("variables") {
|
||||
put("listId", track.libraryId)
|
||||
val libraryId = track.libraryId
|
||||
requireNotNull(libraryId) { "AniList cannot delete track with null library_id" }
|
||||
|
||||
graphQlClient
|
||||
.mutation(
|
||||
AniListDeleteMangaMutation(library_id = libraryId.toInt()),
|
||||
)
|
||||
.execute()
|
||||
.dataOrElse(
|
||||
errorLog = "AniList: Failed to delete manga",
|
||||
default = { null },
|
||||
) {
|
||||
it.DeleteMediaListEntry?.deleted?.let { deleted ->
|
||||
if (deleted) {
|
||||
logcat { "AniList: Deleted manga ${track.libraryId} successfully" }
|
||||
}
|
||||
}
|
||||
authClient.newCall(POST(API_URL, body = payload.toString().toRequestBody(jsonMime)))
|
||||
.awaitSuccess()
|
||||
}
|
||||
?: throw Exception("Failed to delete manga")
|
||||
}
|
||||
|
||||
suspend fun search(search: String): List<TrackSearch> {
|
||||
return withIOContext {
|
||||
val query = $$"""
|
||||
|query Search($query: String) {
|
||||
|Page (perPage: 50) {
|
||||
|media(search: $query, type: MANGA, format_not_in: [NOVEL]) {
|
||||
|id
|
||||
|staff {
|
||||
|edges {
|
||||
|role
|
||||
|id
|
||||
|node {
|
||||
|name {
|
||||
|full
|
||||
|userPreferred
|
||||
|native
|
||||
|}
|
||||
|}
|
||||
|}
|
||||
|}
|
||||
|title {
|
||||
|userPreferred
|
||||
|}
|
||||
|coverImage {
|
||||
|large
|
||||
|}
|
||||
|format
|
||||
|countryOfOrigin
|
||||
|status
|
||||
|chapters
|
||||
|description
|
||||
|startDate {
|
||||
|year
|
||||
|month
|
||||
|day
|
||||
|}
|
||||
|averageScore
|
||||
|}
|
||||
|}
|
||||
|}
|
||||
|
|
||||
""".trimMargin()
|
||||
val payload = buildJsonObject {
|
||||
put("query", query)
|
||||
putJsonObject("variables") {
|
||||
put("query", search)
|
||||
}
|
||||
}
|
||||
with(json) {
|
||||
authClient.newCall(
|
||||
POST(
|
||||
API_URL,
|
||||
body = payload.toString().toRequestBody(jsonMime),
|
||||
),
|
||||
return graphQlClient
|
||||
.query(
|
||||
AniListSearchMangaQuery(search = search),
|
||||
)
|
||||
.awaitSuccess()
|
||||
.parseAs<ALSearchResult>()
|
||||
.data.page.media
|
||||
.map { it.toALManga().toTrack(trackerId) }
|
||||
}
|
||||
.execute()
|
||||
.dataOrElse(
|
||||
errorLog = "AniList: Search failed",
|
||||
default = { emptyList() },
|
||||
) {
|
||||
it.Page?.media
|
||||
?.mapNotNull { alManga -> alManga?.toTrackSearch(trackerId) }
|
||||
?: emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun findLibManga(track: Track, userid: Int): Track? {
|
||||
return withIOContext {
|
||||
val query = $$"""
|
||||
|query ($id: Int!, $manga_id: Int!) {
|
||||
|Page {
|
||||
|mediaList(userId: $id, type: MANGA, mediaId: $manga_id) {
|
||||
|id
|
||||
|status
|
||||
|scoreRaw: score(format: POINT_100)
|
||||
|progress
|
||||
|private
|
||||
|startedAt {
|
||||
|year
|
||||
|month
|
||||
|day
|
||||
|}
|
||||
|completedAt {
|
||||
|year
|
||||
|month
|
||||
|day
|
||||
|}
|
||||
|media {
|
||||
|id
|
||||
|title {
|
||||
|userPreferred
|
||||
|}
|
||||
|coverImage {
|
||||
|large
|
||||
|}
|
||||
|format
|
||||
|status
|
||||
|chapters
|
||||
|description
|
||||
|startDate {
|
||||
|year
|
||||
|month
|
||||
|day
|
||||
|}
|
||||
|staff {
|
||||
|edges {
|
||||
|role
|
||||
|id
|
||||
|node {
|
||||
|name {
|
||||
|full
|
||||
|userPreferred
|
||||
|native
|
||||
|}
|
||||
|}
|
||||
|}
|
||||
|}
|
||||
|}
|
||||
|}
|
||||
|}
|
||||
|}
|
||||
|
|
||||
""".trimMargin()
|
||||
val payload = buildJsonObject {
|
||||
put("query", query)
|
||||
putJsonObject("variables") {
|
||||
put("id", userid)
|
||||
put("manga_id", track.remote_id)
|
||||
}
|
||||
}
|
||||
with(json) {
|
||||
authClient.newCall(
|
||||
POST(
|
||||
API_URL,
|
||||
body = payload.toString().toRequestBody(jsonMime),
|
||||
suspend fun findLibManga(track: Track, userId: Int): Track? {
|
||||
return graphQlClient
|
||||
.query(
|
||||
AniListGetLibMangaQuery(
|
||||
user_id = userId,
|
||||
manga_id = track.remote_id.toInt(),
|
||||
),
|
||||
)
|
||||
.awaitSuccess()
|
||||
.parseAs<ALUserListMangaQueryResult>()
|
||||
.data.page.mediaList
|
||||
.map { it.toALUserManga() }
|
||||
.firstOrNull()
|
||||
.execute()
|
||||
.dataOrElse(
|
||||
errorLog = "AniList: Failed to find manga in library",
|
||||
default = { null },
|
||||
) {
|
||||
it.Page?.mediaList
|
||||
?.firstOrNull()
|
||||
?.toTrack(trackerId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getLibManga(track: Track, userId: Int): Track {
|
||||
return findLibManga(track, userId) ?: throw Exception("Could not find manga")
|
||||
}
|
||||
|
||||
suspend fun getCurrentUser(): ALUserViewerData {
|
||||
return withIOContext {
|
||||
val query = """
|
||||
|query User {
|
||||
|Viewer {
|
||||
|id
|
||||
|name
|
||||
|mediaListOptions {
|
||||
|scoreFormat
|
||||
|}
|
||||
|}
|
||||
|}
|
||||
|
|
||||
""".trimMargin()
|
||||
val payload = buildJsonObject {
|
||||
put("query", query)
|
||||
}
|
||||
with(json) {
|
||||
authClient.newCall(
|
||||
POST(
|
||||
API_URL,
|
||||
body = payload.toString().toRequestBody(jsonMime),
|
||||
),
|
||||
)
|
||||
.awaitSuccess()
|
||||
.parseAs<ALCurrentUserResult>()
|
||||
.data.viewer
|
||||
}
|
||||
suspend fun getCurrentUser(): ALUser {
|
||||
return graphQlClient
|
||||
.query(AniListGetCurrentUserQuery())
|
||||
.execute()
|
||||
.dataOrElse(
|
||||
errorLog = "AniList: Failed to get current user",
|
||||
default = { null },
|
||||
) {
|
||||
it.Viewer?.toALUser()
|
||||
}
|
||||
?: throw Exception("Failed to get AniList user data")
|
||||
}
|
||||
|
||||
suspend fun getMangaDetails(id: Int): TrackSearch? {
|
||||
return withIOContext {
|
||||
val query = $$"""
|
||||
|query Search($manga_id: Int) {
|
||||
|Page (perPage: 1) {
|
||||
|media(id: $manga_id, type: MANGA, format_not_in: [NOVEL]) {
|
||||
|id
|
||||
|staff {
|
||||
|edges {
|
||||
|role
|
||||
|id
|
||||
|node {
|
||||
|name {
|
||||
|full
|
||||
|userPreferred
|
||||
|native
|
||||
|}
|
||||
|}
|
||||
|}
|
||||
|}
|
||||
|title {
|
||||
|userPreferred
|
||||
|}
|
||||
|coverImage {
|
||||
|large
|
||||
|}
|
||||
|format
|
||||
|countryOfOrigin
|
||||
|status
|
||||
|chapters
|
||||
|description
|
||||
|startDate {
|
||||
|year
|
||||
|month
|
||||
|day
|
||||
|}
|
||||
|averageScore
|
||||
|}
|
||||
|}
|
||||
|}
|
||||
|
|
||||
""".trimMargin()
|
||||
|
||||
val payload = buildJsonObject {
|
||||
put("query", query)
|
||||
putJsonObject("variables") {
|
||||
put("manga_id", id)
|
||||
}
|
||||
}
|
||||
|
||||
with(json) {
|
||||
authClient.newCall(
|
||||
POST(
|
||||
API_URL,
|
||||
body = payload.toString().toRequestBody(jsonMime),
|
||||
),
|
||||
return graphQlClient
|
||||
.query(
|
||||
AniListGetMangaDetailsQuery(manga_id = id),
|
||||
)
|
||||
.awaitSuccess()
|
||||
.parseAs<ALSearchResult>()
|
||||
.data.page.media
|
||||
.firstOrNull()
|
||||
?.toALManga()
|
||||
?.toTrack(trackerId)
|
||||
}
|
||||
.execute()
|
||||
.dataOrElse(
|
||||
errorLog = "AniList: Failed to get manga details",
|
||||
default = { null },
|
||||
) {
|
||||
it.Page?.media
|
||||
?.firstOrNull()
|
||||
?.toTrackSearch(trackerId)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createDate(dateValue: Long): JsonObject {
|
||||
if (dateValue == 0L) {
|
||||
return buildJsonObject {
|
||||
put("year", JsonNull)
|
||||
put("month", JsonNull)
|
||||
put("day", JsonNull)
|
||||
}
|
||||
}
|
||||
private fun createFuzzyDate(dateValue: Long): FuzzyDateInput {
|
||||
// all absent/null
|
||||
if (dateValue == 0L) return FuzzyDateInput()
|
||||
|
||||
val dateTime = Instant.fromEpochMilliseconds(dateValue).toLocalDateTime(TimeZone.currentSystemDefault())
|
||||
return buildJsonObject {
|
||||
put("year", dateTime.year)
|
||||
put("month", dateTime.month.number)
|
||||
put("day", dateTime.day)
|
||||
}
|
||||
return FuzzyDateInput(
|
||||
year = Optional.present(dateTime.year),
|
||||
month = Optional.present(dateTime.month.number),
|
||||
day = Optional.present(dateTime.day),
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CLIENT_ID = "16329"
|
||||
private const val API_URL = "https://graphql.anilist.co/"
|
||||
private const val BASE_URL = "https://anilist.co/api/v2/"
|
||||
private const val BASE_MANGA_URL = "https://anilist.co/manga/"
|
||||
|
||||
fun mangaUrl(mediaId: Long): String {
|
||||
return BASE_MANGA_URL + mediaId
|
||||
}
|
||||
|
||||
fun authUrl(): Uri = "${BASE_URL}oauth/authorize".toUri().buildUpon()
|
||||
fun authUrl(): Uri = "https://anilist.co/api/v2/oauth/authorize".toUri().buildUpon()
|
||||
.appendQueryParameter("client_id", CLIENT_ID)
|
||||
.appendQueryParameter("response_type", "token")
|
||||
.build()
|
||||
|
||||
@@ -2,15 +2,130 @@ package eu.kanade.tachiyomi.data.track.anilist
|
||||
|
||||
import eu.kanade.domain.track.service.TrackPreferences
|
||||
import eu.kanade.tachiyomi.data.database.models.Track
|
||||
import eu.kanade.tachiyomi.data.track.anilist.dto.ALUser
|
||||
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||
import eu.kanade.tachiyomi.util.lang.htmlDecode
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.atStartOfDayIn
|
||||
import mihon.graphql.anilist.AniListGetCurrentUserQuery
|
||||
import mihon.graphql.anilist.AniListGetLibMangaQuery
|
||||
import mihon.graphql.anilist.AniListGetMangaDetailsQuery
|
||||
import mihon.graphql.anilist.AniListSearchMangaQuery
|
||||
import mihon.graphql.anilist.fragment.MangaFragment
|
||||
import mihon.graphql.anilist.type.MediaListStatus
|
||||
import mihon.graphql.anilist.type.ScoreFormat
|
||||
import tachiyomi.domain.track.model.Track as DomainTrack
|
||||
|
||||
internal fun Track.toApiStatus() = when (status) {
|
||||
Anilist.READING -> "CURRENT"
|
||||
Anilist.COMPLETED -> "COMPLETED"
|
||||
Anilist.ON_HOLD -> "PAUSED"
|
||||
Anilist.DROPPED -> "DROPPED"
|
||||
Anilist.PLAN_TO_READ -> "PLANNING"
|
||||
Anilist.REREADING -> "REPEATING"
|
||||
internal fun AniListGetCurrentUserQuery.Viewer.toALUser(): ALUser {
|
||||
return ALUser(
|
||||
id = id,
|
||||
name = name,
|
||||
scoreFormat = mediaListOptions?.scoreFormat?.rawValue ?: ScoreFormat.POINT_10_DECIMAL.rawValue,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun AniListGetLibMangaQuery.MediaList.toTrack(trackId: Long): Track {
|
||||
val mediaList = this
|
||||
requireNotNull(mediaList.media) { "Missing Media data from AniList" }
|
||||
|
||||
return mediaList.media.mangaFragment
|
||||
.toTrackSearch(trackId)
|
||||
.apply {
|
||||
library_id = mediaList.id.toLong()
|
||||
status = when (mediaList.status) {
|
||||
MediaListStatus.CURRENT -> Anilist.READING
|
||||
MediaListStatus.COMPLETED -> Anilist.COMPLETED
|
||||
MediaListStatus.PAUSED -> Anilist.ON_HOLD
|
||||
MediaListStatus.DROPPED -> Anilist.DROPPED
|
||||
MediaListStatus.PLANNING -> Anilist.PLAN_TO_READ
|
||||
MediaListStatus.REPEATING -> Anilist.REREADING
|
||||
else -> throw NotImplementedError("Unknown status: ${mediaList.status?.rawValue}")
|
||||
}
|
||||
score = mediaList.scoreRaw ?: 0.0
|
||||
last_chapter_read = mediaList.progress?.toDouble() ?: 0.0
|
||||
|
||||
started_reading_date = mediaList.startedAt.let {
|
||||
if (it == null || it.year == null || it.month == null || it.day == null) {
|
||||
0
|
||||
} else {
|
||||
LocalDate(it.year, it.month, it.day)
|
||||
.atStartOfDayIn(TimeZone.currentSystemDefault())
|
||||
.toEpochMilliseconds()
|
||||
}
|
||||
}
|
||||
|
||||
finished_reading_date = mediaList.completedAt.let {
|
||||
if (it == null || it.year == null || it.month == null || it.day == null) {
|
||||
0
|
||||
} else {
|
||||
LocalDate(it.year, it.month, it.day)
|
||||
.atStartOfDayIn(TimeZone.currentSystemDefault())
|
||||
.toEpochMilliseconds()
|
||||
}
|
||||
}
|
||||
|
||||
private = mediaList.private ?: false
|
||||
}
|
||||
}
|
||||
|
||||
internal fun AniListSearchMangaQuery.Medium.toTrackSearch(trackId: Long): TrackSearch {
|
||||
return mangaFragment.toTrackSearch(trackId)
|
||||
}
|
||||
|
||||
internal fun AniListGetMangaDetailsQuery.Medium.toTrackSearch(trackId: Long): TrackSearch {
|
||||
return mangaFragment.toTrackSearch(trackId)
|
||||
}
|
||||
|
||||
private fun MangaFragment.toTrackSearch(trackId: Long): TrackSearch {
|
||||
val alManga = this
|
||||
return TrackSearch.create(trackId).apply {
|
||||
remote_id = alManga.id.toLong()
|
||||
title = alManga.title?.userPreferred ?: "(no title)"
|
||||
total_chapters = alManga.chapters?.toLong() ?: 0
|
||||
cover_url = alManga.coverImage?.large ?: ""
|
||||
summary = alManga.description?.htmlDecode() ?: ""
|
||||
score = alManga.averageScore?.toDouble() ?: -1.0
|
||||
tracking_url = "https://anilist.co/manga/$remote_id"
|
||||
publishing_status = alManga.status?.rawValue ?: ""
|
||||
publishing_type = if (alManga.format == null) {
|
||||
"Manga"
|
||||
} else if (alManga.format.rawValue != "MANGA") {
|
||||
alManga.format.rawValue.replace("_", "-")
|
||||
} else {
|
||||
when (alManga.countryOfOrigin) {
|
||||
"KR" -> "Manhwa"
|
||||
"CN", "TW" -> "Manhua"
|
||||
else -> "Manga"
|
||||
}
|
||||
}
|
||||
start_date = alManga.startDate?.let {
|
||||
buildString {
|
||||
it.year?.let { year -> append(year) }
|
||||
it.month?.let { month -> append("-$month") }
|
||||
it.day?.let { day -> append("-$day") }
|
||||
}.trim('-')
|
||||
} ?: ""
|
||||
|
||||
authors = alManga.staff?.edges
|
||||
?.filter { "Story" in (it?.role ?: "") }
|
||||
?.mapNotNull { it?.node?.name?.userPreferred }
|
||||
?: emptyList()
|
||||
|
||||
artists = alManga.staff?.edges
|
||||
?.filter { "Art" in (it?.role ?: "") }
|
||||
?.mapNotNull { it?.node?.name?.userPreferred }
|
||||
?: emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
internal fun Track.toApiStatus(): MediaListStatus = when (status) {
|
||||
Anilist.READING -> MediaListStatus.CURRENT
|
||||
Anilist.COMPLETED -> MediaListStatus.COMPLETED
|
||||
Anilist.ON_HOLD -> MediaListStatus.PAUSED
|
||||
Anilist.DROPPED -> MediaListStatus.DROPPED
|
||||
Anilist.PLAN_TO_READ -> MediaListStatus.PLANNING
|
||||
Anilist.REREADING -> MediaListStatus.REPEATING
|
||||
else -> throw NotImplementedError("Unknown status: $status")
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.track.anilist.dto
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class ALAddMangaResult(
|
||||
val data: ALAddMangaData,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ALAddMangaData(
|
||||
@SerialName("SaveMediaListEntry")
|
||||
val entry: ALAddMangaEntry,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ALAddMangaEntry(
|
||||
val id: Long,
|
||||
)
|
||||
@@ -1,21 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.track.anilist.dto
|
||||
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.atStartOfDayIn
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class ALFuzzyDate(
|
||||
val year: Int?,
|
||||
val month: Int?,
|
||||
val day: Int?,
|
||||
) {
|
||||
fun toEpochMilli(): Long = try {
|
||||
LocalDate(year!!, month!!, day!!)
|
||||
.atStartOfDayIn(TimeZone.currentSystemDefault())
|
||||
.toEpochMilliseconds()
|
||||
} catch (_: Exception) {
|
||||
0L
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.track.anilist.dto
|
||||
|
||||
import eu.kanade.tachiyomi.data.database.models.Track
|
||||
import eu.kanade.tachiyomi.data.track.anilist.Anilist
|
||||
import eu.kanade.tachiyomi.data.track.anilist.AnilistApi
|
||||
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||
import eu.kanade.tachiyomi.util.lang.htmlDecode
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
data class ALManga(
|
||||
val remoteId: Long,
|
||||
val title: String,
|
||||
val imageUrl: String,
|
||||
val description: String?,
|
||||
val format: String,
|
||||
val publishingStatus: String,
|
||||
val startDateFuzzy: Long,
|
||||
val totalChapters: Long,
|
||||
val averageScore: Int,
|
||||
val staff: ALStaff,
|
||||
) {
|
||||
fun toTrack(trackerId: Long) = TrackSearch.create(trackerId).apply {
|
||||
remote_id = remoteId
|
||||
title = this@ALManga.title
|
||||
total_chapters = totalChapters
|
||||
cover_url = imageUrl
|
||||
summary = description?.htmlDecode() ?: ""
|
||||
score = averageScore.toDouble()
|
||||
tracking_url = AnilistApi.mangaUrl(remote_id)
|
||||
publishing_status = publishingStatus
|
||||
publishing_type = format
|
||||
if (startDateFuzzy != 0L) {
|
||||
start_date = try {
|
||||
val outputDf = SimpleDateFormat("yyyy-MM-dd", Locale.US)
|
||||
outputDf.format(startDateFuzzy)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
""
|
||||
}
|
||||
}
|
||||
staff.edges.forEach {
|
||||
val name = it.node.name() ?: return@forEach
|
||||
if ("Story" in it.role) authors += name
|
||||
if ("Art" in it.role) artists += name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class ALUserManga(
|
||||
val libraryId: Long,
|
||||
val listStatus: String,
|
||||
val scoreRaw: Int,
|
||||
val chaptersRead: Int,
|
||||
val startDateFuzzy: Long,
|
||||
val completedDateFuzzy: Long,
|
||||
val manga: ALManga,
|
||||
val private: Boolean,
|
||||
) {
|
||||
fun toTrack(trackerId: Long) = Track.create(trackerId).apply {
|
||||
remote_id = manga.remoteId
|
||||
title = manga.title
|
||||
status = toTrackStatus()
|
||||
score = scoreRaw.toDouble()
|
||||
started_reading_date = startDateFuzzy
|
||||
finished_reading_date = completedDateFuzzy
|
||||
last_chapter_read = chaptersRead.toDouble()
|
||||
library_id = libraryId
|
||||
total_chapters = manga.totalChapters
|
||||
private = this@ALUserManga.private
|
||||
}
|
||||
|
||||
private fun toTrackStatus() = when (listStatus) {
|
||||
"CURRENT" -> Anilist.READING
|
||||
"COMPLETED" -> Anilist.COMPLETED
|
||||
"PAUSED" -> Anilist.ON_HOLD
|
||||
"DROPPED" -> Anilist.DROPPED
|
||||
"PLANNING" -> Anilist.PLAN_TO_READ
|
||||
"REPEATING" -> Anilist.REREADING
|
||||
else -> throw NotImplementedError("Unknown status: $listStatus")
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.track.anilist.dto
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class ALSearchResult(
|
||||
val data: ALSearchPage,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ALSearchPage(
|
||||
@SerialName("Page")
|
||||
val page: ALSearchMedia,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ALSearchMedia(
|
||||
val media: List<ALSearchItem>,
|
||||
)
|
||||
@@ -1,77 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.track.anilist.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class ALSearchItem(
|
||||
val id: Long,
|
||||
val title: ALItemTitle,
|
||||
val coverImage: ItemCover,
|
||||
val description: String?,
|
||||
val format: String,
|
||||
val status: String?,
|
||||
val startDate: ALFuzzyDate,
|
||||
val chapters: Long?,
|
||||
val averageScore: Int?,
|
||||
val staff: ALStaff,
|
||||
val countryOfOrigin: String = "",
|
||||
) {
|
||||
fun toALManga(): ALManga = ALManga(
|
||||
remoteId = id,
|
||||
title = title.userPreferred,
|
||||
imageUrl = coverImage.large,
|
||||
description = description,
|
||||
format = if (format != "MANGA") {
|
||||
format.replace("_", "-")
|
||||
} else {
|
||||
when (countryOfOrigin) {
|
||||
"KR" -> "Manhwa"
|
||||
"CN", "TW" -> "Manhua"
|
||||
else -> "Manga"
|
||||
}
|
||||
},
|
||||
publishingStatus = status ?: "",
|
||||
startDateFuzzy = startDate.toEpochMilli(),
|
||||
totalChapters = chapters ?: 0,
|
||||
averageScore = averageScore ?: -1,
|
||||
staff = staff,
|
||||
)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ALItemTitle(
|
||||
val userPreferred: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ItemCover(
|
||||
val large: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ALStaff(
|
||||
val edges: List<ALEdge>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ALEdge(
|
||||
val role: String,
|
||||
val id: Int,
|
||||
val node: ALStaffNode,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ALStaffNode(
|
||||
val name: ALStaffName,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ALStaffName(
|
||||
val userPreferred: String?,
|
||||
val native: String?,
|
||||
val full: String?,
|
||||
) {
|
||||
operator fun invoke(): String? {
|
||||
return userPreferred ?: full ?: native
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,10 @@
|
||||
package eu.kanade.tachiyomi.data.track.anilist.dto
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class ALCurrentUserResult(
|
||||
val data: ALUserViewer,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ALUserViewer(
|
||||
@SerialName("Viewer")
|
||||
val viewer: ALUserViewerData,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ALUserViewerData(
|
||||
data class ALUser(
|
||||
val id: Int,
|
||||
val name: String,
|
||||
val mediaListOptions: ALUserListOptions,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ALUserListOptions(
|
||||
val scoreFormat: String,
|
||||
)
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.track.anilist.dto
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class ALUserListMangaQueryResult(
|
||||
val data: ALUserListMangaPage,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ALUserListMangaPage(
|
||||
@SerialName("Page")
|
||||
val page: ALUserListMediaList,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ALUserListMediaList(
|
||||
val mediaList: List<ALUserListItem>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ALUserListItem(
|
||||
val id: Long,
|
||||
val status: String,
|
||||
val scoreRaw: Int,
|
||||
val progress: Int,
|
||||
val startedAt: ALFuzzyDate,
|
||||
val completedAt: ALFuzzyDate,
|
||||
val media: ALSearchItem,
|
||||
val private: Boolean,
|
||||
) {
|
||||
fun toALUserManga(): ALUserManga {
|
||||
return ALUserManga(
|
||||
libraryId = this@ALUserListItem.id,
|
||||
listStatus = status,
|
||||
scoreRaw = scoreRaw,
|
||||
chaptersRead = progress,
|
||||
startDateFuzzy = startedAt.toEpochMilli(),
|
||||
completedDateFuzzy = completedAt.toEpochMilli(),
|
||||
manga = media.toALManga(),
|
||||
private = private,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -192,7 +192,7 @@ class Kitsu(id: Long) : BaseTracker(id, "Kitsu"), DeletableTracker {
|
||||
logcat(LogPriority.ERROR) { "Unsupported Kitsu score type: $ratingSystem" }
|
||||
scorePreference.set(RATING_ADVANCED)
|
||||
}
|
||||
saveDisplayUsername(currentUser.profile.name)
|
||||
saveDisplayUsername(currentUser.name)
|
||||
saveCredentials(username, currentUser.id)
|
||||
}
|
||||
|
||||
@@ -206,7 +206,7 @@ class Kitsu(id: Long) : BaseTracker(id, "Kitsu"), DeletableTracker {
|
||||
logcat(LogPriority.ERROR) { "Unsupported Kitsu score type: $ratingSystem" }
|
||||
scorePreference.set(RATING_ADVANCED)
|
||||
}
|
||||
saveDisplayUsername(currentUser.profile.name)
|
||||
saveDisplayUsername(currentUser.name)
|
||||
}
|
||||
|
||||
override fun logout() {
|
||||
|
||||
@@ -1,31 +1,30 @@
|
||||
package eu.kanade.tachiyomi.data.track.kitsu
|
||||
|
||||
import com.apollographql.apollo.ApolloClient
|
||||
import com.apollographql.apollo.api.Optional
|
||||
import com.apollographql.apollo.network.okHttpClient
|
||||
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.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.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.kitsu.dto.KitsuUser
|
||||
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||
import eu.kanade.tachiyomi.network.HttpException
|
||||
import eu.kanade.tachiyomi.network.POST
|
||||
import eu.kanade.tachiyomi.network.await
|
||||
import eu.kanade.tachiyomi.network.awaitSuccess
|
||||
import eu.kanade.tachiyomi.network.jsonMime
|
||||
import eu.kanade.tachiyomi.network.dataOrElse
|
||||
import eu.kanade.tachiyomi.network.parseAs
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlinx.serialization.json.putJsonObject
|
||||
import logcat.LogPriority
|
||||
import mihon.graphql.kitsu.KitsuAddLibMangaMutation
|
||||
import mihon.graphql.kitsu.KitsuDeleteLibEntryMutation
|
||||
import mihon.graphql.kitsu.KitsuFindLibMangaQuery
|
||||
import mihon.graphql.kitsu.KitsuGetCurrentAccountQuery
|
||||
import mihon.graphql.kitsu.KitsuGetMangaDetailsByIdQuery
|
||||
import mihon.graphql.kitsu.KitsuGetMangaDetailsBySlugQuery
|
||||
import mihon.graphql.kitsu.KitsuSearchMangaByTitleQuery
|
||||
import mihon.graphql.kitsu.KitsuUpdateLibMangaMutation
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.OkHttpClient
|
||||
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
|
||||
@@ -42,300 +41,142 @@ class KitsuApi(
|
||||
|
||||
private val authClient = client.newBuilder().addInterceptor(interceptor).build()
|
||||
|
||||
private val graphQlClient by lazy {
|
||||
ApolloClient.Builder()
|
||||
.serverUrl("https://kitsu.app/api/graphql")
|
||||
.okHttpClient(authClient)
|
||||
.dispatcher(Dispatchers.IO)
|
||||
// required to log the error body in dataOrElse, which also properly closes it
|
||||
.httpExposeErrorBody(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
suspend fun addLibManga(track: Track): Track {
|
||||
return withIOContext {
|
||||
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)
|
||||
put("rating", track.score.toInt().takeIf { it > 0 })
|
||||
}
|
||||
}
|
||||
|
||||
with(json) {
|
||||
val parsed = authClient.newCall(
|
||||
POST(
|
||||
GRAPHQL_API_URL,
|
||||
body = payload.toString().toRequestBody(jsonMime),
|
||||
return graphQlClient
|
||||
.mutation(
|
||||
KitsuAddLibMangaMutation(
|
||||
media_id = track.remote_id.toString(),
|
||||
status = track.toKitsuStatus(),
|
||||
progress = track.last_chapter_read.toInt(),
|
||||
private = track.private,
|
||||
rating = Optional.present(track.score.toInt().takeIf { it > 0 }),
|
||||
),
|
||||
)
|
||||
.awaitSuccess()
|
||||
.parseAs<KitsuAddMangaResult>()
|
||||
|
||||
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()
|
||||
.execute()
|
||||
.dataOrElse(
|
||||
errorLog = "Kitsu: Failed to add manga",
|
||||
default = { null },
|
||||
) {
|
||||
it.libraryEntry.create?.libraryEntry?.id?.let { libraryId ->
|
||||
track.library_id = libraryId.toLong()
|
||||
track
|
||||
}
|
||||
}
|
||||
}
|
||||
?: throw Exception("Failed to add manga")
|
||||
}
|
||||
|
||||
suspend fun updateLibManga(track: Track): Track {
|
||||
return withIOContext {
|
||||
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 libraryId = track.library_id
|
||||
requireNotNull(libraryId) { "Kitsu cannot update track with null library_id" }
|
||||
|
||||
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",
|
||||
return graphQlClient
|
||||
.mutation(
|
||||
KitsuUpdateLibMangaMutation(
|
||||
library_id = libraryId.toString(),
|
||||
status = track.toKitsuStatus(),
|
||||
progress = track.last_chapter_read.toInt(),
|
||||
private = track.private,
|
||||
rating = Optional.present(track.score.toInt().takeIf { it > 0 }),
|
||||
startedAt = Optional.present(
|
||||
track.started_reading_date
|
||||
.takeIf { it > 0 }
|
||||
?.let { Instant.fromEpochMilliseconds(it).toString() },
|
||||
)
|
||||
put(
|
||||
"finishedAt",
|
||||
?.let { Instant.fromEpochMilliseconds(it) },
|
||||
),
|
||||
finishedAt = Optional.present(
|
||||
track.finished_reading_date
|
||||
.takeIf { it > 0 }
|
||||
?.let { Instant.fromEpochMilliseconds(it).toString() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
with(json) {
|
||||
val parsed = authClient.newCall(
|
||||
POST(
|
||||
GRAPHQL_API_URL,
|
||||
body = payload.toString().toRequestBody(jsonMime),
|
||||
?.let { Instant.fromEpochMilliseconds(it) },
|
||||
),
|
||||
),
|
||||
)
|
||||
.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")
|
||||
}
|
||||
|
||||
.execute()
|
||||
.dataOrElse(
|
||||
errorLog = "Kitsu: Failed to update manga",
|
||||
default = { null },
|
||||
) {
|
||||
it.libraryEntry.update?.libraryEntry?.id?.let { libraryId ->
|
||||
logcat { "Kitsu: Updated library entry $libraryId" }
|
||||
track.library_id = libraryId.toLong()
|
||||
track
|
||||
}
|
||||
}
|
||||
?: throw Exception("Failed to update manga")
|
||||
}
|
||||
|
||||
suspend fun removeLibManga(track: DomainTrack) {
|
||||
withIOContext {
|
||||
val query = $$"""|
|
||||
|mutation DeleteLibEntry(
|
||||
|$library_id: ID!
|
||||
|) {
|
||||
|libraryEntry {
|
||||
|delete(
|
||||
|input: {
|
||||
|id: $library_id
|
||||
|}
|
||||
|) {
|
||||
|errors {
|
||||
|message
|
||||
|}
|
||||
|libraryEntry {
|
||||
|id
|
||||
|}
|
||||
|}
|
||||
|}
|
||||
|}
|
||||
""".trimMargin()
|
||||
val libraryId = track.libraryId
|
||||
requireNotNull(libraryId) { "Kitsu cannot delete track with null library_id" }
|
||||
|
||||
val payload = buildJsonObject {
|
||||
put("query", query)
|
||||
putJsonObject("variables") {
|
||||
put("library_id", track.libraryId)
|
||||
}
|
||||
}
|
||||
|
||||
with(json) {
|
||||
val parsed = authClient.newCall(
|
||||
POST(
|
||||
GRAPHQL_API_URL,
|
||||
body = payload.toString().toRequestBody(jsonMime),
|
||||
graphQlClient
|
||||
.mutation(
|
||||
KitsuDeleteLibEntryMutation(
|
||||
library_id = libraryId.toString(),
|
||||
),
|
||||
)
|
||||
// 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")
|
||||
}
|
||||
.execute()
|
||||
.dataOrElse(
|
||||
errorLog = "Kitsu: Failed to delete manga",
|
||||
default = {},
|
||||
onException = { e ->
|
||||
val body = e.body?.use { it.readUtf8() }
|
||||
if (
|
||||
e.statusCode == 500 &&
|
||||
body?.contains("Couldn't find LibraryEntry with 'id'=") == true
|
||||
) {
|
||||
// Deleting something not in the library (currently as of 2026-09-20) returns a 500 with a
|
||||
// "Couldn't find LibraryEntry" error message at the error.message key --
|
||||
// but the user gets their wish of "title not in library" so ignore it
|
||||
return@dataOrElse
|
||||
} else {
|
||||
throw HttpException(e.statusCode).apply { stackTrace = e.stackTrace }
|
||||
}
|
||||
},
|
||||
) {
|
||||
logcat { "Kitsu: Deleted library entry ${it.libraryEntry.delete?.libraryEntry?.id}" }
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
return graphQlClient
|
||||
.query(
|
||||
KitsuSearchMangaByTitleQuery(
|
||||
query = search,
|
||||
),
|
||||
)
|
||||
.awaitSuccess()
|
||||
.parseAs<KitsuSearchByTitleResult>()
|
||||
.data.searchMangaByTitle.nodes
|
||||
.map { it.toTrackSearch(trackerId) }
|
||||
}
|
||||
.execute()
|
||||
.dataOrElse(
|
||||
errorLog = "Kitsu: Search failed",
|
||||
default = { emptyList() },
|
||||
) {
|
||||
it.searchMangaByTitle.nodes
|
||||
?.mapNotNull { node -> node?.toTrackSearch(trackerId) }
|
||||
}
|
||||
?: emptyList()
|
||||
}
|
||||
|
||||
suspend fun findLibManga(track: Track): Track? {
|
||||
return withIOContext {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
with(json) {
|
||||
authClient.newCall(
|
||||
POST(
|
||||
GRAPHQL_API_URL,
|
||||
body = payload.toString().toRequestBody(jsonMime),
|
||||
return graphQlClient
|
||||
.query(
|
||||
KitsuFindLibMangaQuery(
|
||||
remote_id = track.remote_id.toString(),
|
||||
),
|
||||
)
|
||||
.awaitSuccess()
|
||||
.parseAs<KitsuSearchByIdWithLibraryResult>()
|
||||
.data.findMangaById
|
||||
?.toTrackSearch(trackerId)
|
||||
}
|
||||
.execute()
|
||||
.dataOrElse(
|
||||
errorLog = "Kitsu: Failed to find manga in library",
|
||||
default = { null },
|
||||
) {
|
||||
it.findMangaById?.toTrackSearch(trackerId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -356,88 +197,60 @@ class KitsuApi(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getCurrentUser(): KitsuAccount {
|
||||
return withIOContext {
|
||||
val query = """
|
||||
|query Query {
|
||||
|currentAccount {
|
||||
|id
|
||||
|ratingSystem
|
||||
|profile {
|
||||
|name
|
||||
|}
|
||||
|}
|
||||
|}
|
||||
""".trimMargin()
|
||||
|
||||
val payload = buildJsonObject {
|
||||
put("query", query)
|
||||
}
|
||||
|
||||
with(json) {
|
||||
authClient.newCall(
|
||||
POST(
|
||||
GRAPHQL_API_URL,
|
||||
body = payload.toString().toRequestBody(jsonMime),
|
||||
),
|
||||
suspend fun getCurrentUser(): KitsuUser {
|
||||
return graphQlClient
|
||||
.query(
|
||||
KitsuGetCurrentAccountQuery(),
|
||||
)
|
||||
.awaitSuccess()
|
||||
.parseAs<KitsuCurrentAccountResult>()
|
||||
.data.currentAccount
|
||||
}
|
||||
.execute()
|
||||
.dataOrElse(
|
||||
errorLog = "Kitsu: Failed to get current user",
|
||||
default = { null },
|
||||
) {
|
||||
it.currentAccount?.toKitsuUser()
|
||||
}
|
||||
?: throw Exception("Failed to get Kitsu user data")
|
||||
}
|
||||
|
||||
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
|
||||
|}
|
||||
|}
|
||||
"""
|
||||
return if (isSearchById) {
|
||||
getMangaDetailsById(search)
|
||||
} else {
|
||||
$$"""
|
||||
|query Query($query: String!) {
|
||||
|findMangaBySlug(slug: $query) {
|
||||
|$$COMMON_MANGA_DATA
|
||||
|}
|
||||
|}
|
||||
"""
|
||||
}
|
||||
|
||||
val payload = buildJsonObject {
|
||||
put("query", query.trimMargin())
|
||||
putJsonObject("variables") {
|
||||
put("query", search)
|
||||
getMangaDetailsBySlug(search)
|
||||
}
|
||||
}
|
||||
|
||||
return withIOContext {
|
||||
with(json) {
|
||||
val response = authClient.newCall(
|
||||
POST(
|
||||
GRAPHQL_API_URL,
|
||||
body = payload.toString().toRequestBody(jsonMime),
|
||||
private suspend fun getMangaDetailsById(id: String): TrackSearch? {
|
||||
return graphQlClient
|
||||
.query(
|
||||
KitsuGetMangaDetailsByIdQuery(
|
||||
id = id,
|
||||
),
|
||||
)
|
||||
.awaitSuccess()
|
||||
|
||||
val kitsuManga = if (isSearchById) {
|
||||
response
|
||||
.parseAs<KitsuSearchByIdResult>()
|
||||
.data.findMangaById
|
||||
} else {
|
||||
response
|
||||
.parseAs<KitsuSearchBySlugResult>()
|
||||
.data.findMangaBySlug
|
||||
.execute()
|
||||
.dataOrElse(
|
||||
errorLog = "Kitsu: Search by ID failed",
|
||||
default = { null },
|
||||
) {
|
||||
it.findMangaById?.toTrackSearch(trackerId)
|
||||
}
|
||||
}
|
||||
|
||||
kitsuManga?.toTrackSearch(trackerId)
|
||||
}
|
||||
private suspend fun getMangaDetailsBySlug(slug: String): TrackSearch? {
|
||||
return graphQlClient
|
||||
.query(
|
||||
KitsuGetMangaDetailsBySlugQuery(
|
||||
slug = slug,
|
||||
),
|
||||
)
|
||||
.execute()
|
||||
.dataOrElse(
|
||||
errorLog = "Kitsu: Search by Slug failed",
|
||||
default = { null },
|
||||
) {
|
||||
it.findMangaBySlug?.toTrackSearch(trackerId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -445,7 +258,6 @@ class KitsuApi(
|
||||
private const val CLIENT_ID = "dd031b32d2f56c990b1425efe6c42ad847e7fe3ab46bf1299f05ecd856bdb7dd"
|
||||
private const val CLIENT_SECRET = "54d7307928f63414defd96399fc31ba847961ceaecef3a5fd93144e960c0e151"
|
||||
|
||||
private const val GRAPHQL_API_URL = "https://kitsu.app/api/graphql"
|
||||
private const val LOGIN_URL = "https://kitsu.app/api/oauth/token"
|
||||
|
||||
fun refreshTokenRequest(token: String) = POST(
|
||||
@@ -457,38 +269,5 @@ class KitsuApi(
|
||||
.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,21 +1,98 @@
|
||||
package eu.kanade.tachiyomi.data.track.kitsu
|
||||
|
||||
import eu.kanade.tachiyomi.data.database.models.Track
|
||||
import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuUser
|
||||
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||
import mihon.graphql.kitsu.KitsuFindLibMangaQuery
|
||||
import mihon.graphql.kitsu.KitsuGetCurrentAccountQuery
|
||||
import mihon.graphql.kitsu.KitsuGetMangaDetailsByIdQuery
|
||||
import mihon.graphql.kitsu.KitsuGetMangaDetailsBySlugQuery
|
||||
import mihon.graphql.kitsu.KitsuSearchMangaByTitleQuery
|
||||
import mihon.graphql.kitsu.fragment.MangaFragment
|
||||
import mihon.graphql.kitsu.type.LibraryEntryStatusEnum
|
||||
import mihon.graphql.kitsu.type.MangaSubtypeEnum
|
||||
import mihon.graphql.kitsu.type.ReleaseStatusEnum
|
||||
|
||||
internal 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")
|
||||
internal fun Track.toKitsuStatus() = when (this.status) {
|
||||
Kitsu.READING -> LibraryEntryStatusEnum.CURRENT
|
||||
Kitsu.PLAN_TO_READ -> LibraryEntryStatusEnum.PLANNED
|
||||
Kitsu.COMPLETED -> LibraryEntryStatusEnum.COMPLETED
|
||||
Kitsu.ON_HOLD -> LibraryEntryStatusEnum.ON_HOLD
|
||||
Kitsu.DROPPED -> LibraryEntryStatusEnum.DROPPED
|
||||
else -> throw Exception("Unknown status: ${this.status}")
|
||||
}
|
||||
|
||||
internal fun String.toKitsuLocalStatus() = when (this) {
|
||||
"CURRENT" -> Kitsu.READING
|
||||
"COMPLETED" -> Kitsu.COMPLETED
|
||||
"ON_HOLD" -> Kitsu.ON_HOLD
|
||||
"DROPPED" -> Kitsu.DROPPED
|
||||
"PLANNED" -> Kitsu.PLAN_TO_READ
|
||||
internal fun LibraryEntryStatusEnum.toLocalStatus(): Long = when (this) {
|
||||
LibraryEntryStatusEnum.CURRENT -> Kitsu.READING
|
||||
LibraryEntryStatusEnum.PLANNED -> Kitsu.PLAN_TO_READ
|
||||
LibraryEntryStatusEnum.COMPLETED -> Kitsu.COMPLETED
|
||||
LibraryEntryStatusEnum.ON_HOLD -> Kitsu.ON_HOLD
|
||||
LibraryEntryStatusEnum.DROPPED -> Kitsu.DROPPED
|
||||
else -> throw Exception("Unknown status: $this")
|
||||
}
|
||||
|
||||
internal fun KitsuGetCurrentAccountQuery.CurrentAccount.toKitsuUser(): KitsuUser {
|
||||
return KitsuUser(
|
||||
id = id,
|
||||
name = profile.name,
|
||||
ratingSystem = ratingSystem.rawValue,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun KitsuGetMangaDetailsByIdQuery.FindMangaById.toTrackSearch(trackId: Long): TrackSearch {
|
||||
return mangaFragment.toTrackSearch(trackId)
|
||||
}
|
||||
|
||||
internal fun KitsuGetMangaDetailsBySlugQuery.FindMangaBySlug.toTrackSearch(trackId: Long): TrackSearch {
|
||||
return mangaFragment.toTrackSearch(trackId)
|
||||
}
|
||||
|
||||
internal fun KitsuSearchMangaByTitleQuery.Node.toTrackSearch(trackId: Long): TrackSearch {
|
||||
return mangaFragment.toTrackSearch(trackId)
|
||||
}
|
||||
|
||||
internal fun KitsuFindLibMangaQuery.FindMangaById.toTrackSearch(trackId: Long): TrackSearch? {
|
||||
if (myLibraryEntry == null) return null
|
||||
|
||||
return mangaFragment.toTrackSearch(trackId).apply {
|
||||
library_id = myLibraryEntry.id.toLong()
|
||||
started_reading_date = myLibraryEntry.startedAt?.toEpochMilliseconds() ?: 0
|
||||
finished_reading_date = myLibraryEntry.finishedAt?.toEpochMilliseconds() ?: 0
|
||||
status = myLibraryEntry.status.toLocalStatus()
|
||||
score = myLibraryEntry.rating?.toDouble() ?: 0.0
|
||||
last_chapter_read = myLibraryEntry.progress.toDouble()
|
||||
private = myLibraryEntry.private
|
||||
}
|
||||
}
|
||||
|
||||
private fun MangaFragment.toTrackSearch(trackId: Long): TrackSearch {
|
||||
val kitsuManga = this
|
||||
return TrackSearch.create(trackId).apply {
|
||||
remote_id = kitsuManga.id.toLong()
|
||||
title = kitsuManga.titles.preferred
|
||||
total_chapters = kitsuManga.chapterCount?.toLong() ?: 0
|
||||
cover_url = kitsuManga.posterImage?.views?.firstOrNull()?.url ?: kitsuManga.posterImage?.original?.url.orEmpty()
|
||||
summary = (kitsuManga.description["en"] as? String).orEmpty()
|
||||
tracking_url = "https://kitsu.app/manga/${kitsuManga.slug}"
|
||||
score = kitsuManga.averageRating ?: -1.0
|
||||
publishing_status = when (kitsuManga.status) {
|
||||
ReleaseStatusEnum.TBA -> ReleaseStatusEnum.TBA.rawValue
|
||||
else -> kitsuManga.status.rawValue.lowercase().replaceFirstChar { it.uppercase() }
|
||||
}
|
||||
publishing_type = when (kitsuManga.subtype) {
|
||||
MangaSubtypeEnum.OEL -> MangaSubtypeEnum.OEL.rawValue
|
||||
else -> kitsuManga.subtype.rawValue.lowercase().replaceFirstChar { it.uppercase() }
|
||||
}
|
||||
start_date = kitsuManga.startDate.orEmpty()
|
||||
authors = kitsuManga.staff.nodes
|
||||
?.filterNotNull()
|
||||
?.filter { it.role.contains("Story") }
|
||||
?.map { it.person.name }
|
||||
?: emptyList()
|
||||
artists = kitsuManga.staff.nodes
|
||||
?.filterNotNull()
|
||||
?.filter { it.role.contains("Art") }
|
||||
?.map { it.person.name }
|
||||
?: emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.track.kitsu.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class KitsuAddMangaResult(
|
||||
// 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 KitsuAddMangaData(
|
||||
val libraryEntry: KitsuAddMangaLibraryEntryWrapper,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class KitsuAddMangaLibraryEntryWrapper(
|
||||
val create: KitsuLibraryEntryResult,
|
||||
)
|
||||
@@ -1,18 +0,0 @@
|
||||
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,
|
||||
)
|
||||
@@ -1,23 +0,0 @@
|
||||
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,85 +0,0 @@
|
||||
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
@@ -1,74 +0,0 @@
|
||||
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?,
|
||||
)
|
||||
@@ -1,25 +0,0 @@
|
||||
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?,
|
||||
)
|
||||
@@ -1,13 +0,0 @@
|
||||
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?,
|
||||
)
|
||||
@@ -1,18 +0,0 @@
|
||||
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,23 +0,0 @@
|
||||
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,
|
||||
)
|
||||
@@ -1,25 +1,7 @@
|
||||
package eu.kanade.tachiyomi.data.track.kitsu.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class KitsuCurrentAccountResult(
|
||||
val data: KitsuCurrentAccountData,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class KitsuCurrentAccountData(
|
||||
val currentAccount: KitsuAccount,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class KitsuAccount(
|
||||
data class KitsuUser(
|
||||
val id: String,
|
||||
val ratingSystem: String,
|
||||
val profile: KitsuProfile,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class KitsuProfile(
|
||||
val name: String,
|
||||
val ratingSystem: String,
|
||||
)
|
||||
|
||||
@@ -2,24 +2,29 @@ package eu.kanade.tachiyomi.data.track.shikimori
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.core.net.toUri
|
||||
import com.apollographql.apollo.ApolloClient
|
||||
import com.apollographql.apollo.network.okHttpClient
|
||||
import eu.kanade.tachiyomi.data.database.models.Track
|
||||
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||
import eu.kanade.tachiyomi.data.track.shikimori.dto.SMLibraryIdResponse
|
||||
import eu.kanade.tachiyomi.data.track.shikimori.dto.SMOAuth
|
||||
import eu.kanade.tachiyomi.data.track.shikimori.dto.SMSearchResult
|
||||
import eu.kanade.tachiyomi.data.track.shikimori.dto.SMUser
|
||||
import eu.kanade.tachiyomi.data.track.shikimori.dto.SMUserListResult
|
||||
import eu.kanade.tachiyomi.data.track.shikimori.dto.SMUserResult
|
||||
import eu.kanade.tachiyomi.network.DELETE
|
||||
import eu.kanade.tachiyomi.network.POST
|
||||
import eu.kanade.tachiyomi.network.PUT
|
||||
import eu.kanade.tachiyomi.network.awaitSuccess
|
||||
import eu.kanade.tachiyomi.network.dataOrElse
|
||||
import eu.kanade.tachiyomi.network.jsonMime
|
||||
import eu.kanade.tachiyomi.network.parseAs
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlinx.serialization.json.putJsonObject
|
||||
import mihon.graphql.shikimori.ShikimoriGetCurrentUserQuery
|
||||
import mihon.graphql.shikimori.ShikimoriGetLibMangaQuery
|
||||
import mihon.graphql.shikimori.ShikimoriGetMangaDetailsQuery
|
||||
import mihon.graphql.shikimori.ShikimoriSearchMangaQuery
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
@@ -37,6 +42,16 @@ class ShikimoriApi(
|
||||
|
||||
private val authClient = client.newBuilder().addInterceptor(interceptor).build()
|
||||
|
||||
private val graphQlClient by lazy {
|
||||
ApolloClient.Builder()
|
||||
.serverUrl("$API_URL/graphql")
|
||||
.okHttpClient(authClient)
|
||||
.dispatcher(Dispatchers.IO)
|
||||
// required to log the error body in dataOrElse, which also properly closes it
|
||||
.httpExposeErrorBody(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
suspend fun addLibManga(track: Track, userId: String): Track {
|
||||
return withIOContext {
|
||||
with(json) {
|
||||
@@ -102,179 +117,79 @@ class ShikimoriApi(
|
||||
}
|
||||
|
||||
suspend fun search(search: String): List<TrackSearch> {
|
||||
return withIOContext {
|
||||
val query = $$"""
|
||||
|query($query: String) {
|
||||
|mangas(search: $query, limit: 20, kind:"!light_novel,!novel") {
|
||||
|id
|
||||
|name
|
||||
|chapters
|
||||
|kind
|
||||
|poster {
|
||||
|mainUrl
|
||||
|}
|
||||
|score
|
||||
|url
|
||||
|status
|
||||
|airedOn {
|
||||
|date
|
||||
|}
|
||||
|description
|
||||
|personRoles {
|
||||
|person {
|
||||
|name
|
||||
|}
|
||||
|rolesEn
|
||||
|}
|
||||
|}
|
||||
|}
|
||||
""".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),
|
||||
),
|
||||
return graphQlClient
|
||||
.query(
|
||||
ShikimoriSearchMangaQuery(search = search),
|
||||
)
|
||||
.awaitSuccess()
|
||||
.parseAs<SMSearchResult>()
|
||||
.data.mangas
|
||||
.map { it.toTrack(trackerId) }
|
||||
.execute()
|
||||
.dataOrElse(
|
||||
errorLog = "Shikimori: Search failed",
|
||||
default = { emptyList() },
|
||||
) {
|
||||
it.mangas.map { manga ->
|
||||
manga.toTrackSearch(trackerId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getMangaDetails(id: Int): TrackSearch? {
|
||||
return withIOContext {
|
||||
val query = $$"""
|
||||
|query($query: String) {
|
||||
|mangas(ids: $query, limit: 1, kind:"!light_novel,!novel") {
|
||||
|id
|
||||
|name
|
||||
|chapters
|
||||
|kind
|
||||
|poster {
|
||||
|mainUrl
|
||||
|}
|
||||
|score
|
||||
|url
|
||||
|status
|
||||
|airedOn {
|
||||
|date
|
||||
|}
|
||||
|description
|
||||
|personRoles {
|
||||
|person {
|
||||
|name
|
||||
|}
|
||||
|rolesEn
|
||||
|}
|
||||
|}
|
||||
|}
|
||||
""".trimMargin()
|
||||
val payload = buildJsonObject {
|
||||
put("query", query)
|
||||
putJsonObject("variables") {
|
||||
put("query", "$id")
|
||||
}
|
||||
}
|
||||
|
||||
with(json) {
|
||||
authClient.newCall(
|
||||
POST(
|
||||
GRAPHQL_API_URL,
|
||||
body = payload.toString().toRequestBody(jsonMime),
|
||||
),
|
||||
return graphQlClient
|
||||
.query(
|
||||
ShikimoriGetMangaDetailsQuery(query = "$id"),
|
||||
)
|
||||
.awaitSuccess()
|
||||
.parseAs<SMSearchResult>()
|
||||
.data.mangas
|
||||
.execute()
|
||||
.dataOrElse(
|
||||
errorLog = "Shikimori: Failed to get manga details",
|
||||
default = { null },
|
||||
) {
|
||||
it.mangas
|
||||
.firstOrNull()
|
||||
?.toTrack(trackerId)
|
||||
}
|
||||
?.toTrackSearch(trackerId)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun findLibManga(track: Track): Track? {
|
||||
return withIOContext {
|
||||
val query = $$"""
|
||||
|query($id: String) {
|
||||
|mangas(ids: $id, limit: 1) {
|
||||
|id
|
||||
|url
|
||||
|name
|
||||
|chapters
|
||||
|userRate {
|
||||
|id
|
||||
|chapters
|
||||
|status
|
||||
|score
|
||||
|}
|
||||
|}
|
||||
|}
|
||||
""".trimMargin()
|
||||
|
||||
val payload = buildJsonObject {
|
||||
put("query", query)
|
||||
putJsonObject("variables") {
|
||||
put("id", track.remote_id.toString())
|
||||
}
|
||||
}
|
||||
with(json) {
|
||||
val listResult = authClient.newCall(
|
||||
POST(
|
||||
GRAPHQL_API_URL,
|
||||
body = payload.toString().toRequestBody(jsonMime),
|
||||
return graphQlClient
|
||||
.query(
|
||||
ShikimoriGetLibMangaQuery(
|
||||
remote_id = track.remote_id.toString(),
|
||||
),
|
||||
)
|
||||
.awaitSuccess()
|
||||
.parseAs<SMUserListResult>()
|
||||
.data.mangas
|
||||
.firstOrNull()
|
||||
.execute()
|
||||
.dataOrElse(
|
||||
errorLog = "Shikimori: Failed to find manga in library",
|
||||
default = { null },
|
||||
) {
|
||||
val mangaResult = it.mangas.firstOrNull()
|
||||
|
||||
// Shikimori has no user list query that allows query by ID, so we go via the "mangas" query & include
|
||||
// userRate data which will be null if the title is not in the user's list.
|
||||
// If it was removed on Shikimori and is still linked in the app, notify user via returning null here
|
||||
// which throws an exception at the Shikimori.refresh call
|
||||
if (listResult?.userRate == null) {
|
||||
if (mangaResult?.userRate == null) {
|
||||
null
|
||||
} else {
|
||||
listResult.toTrack(trackerId)
|
||||
}
|
||||
mangaResult.toTrack(trackerId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getCurrentUser(): SMUser {
|
||||
return with(json) {
|
||||
val query = """
|
||||
|{
|
||||
|currentUser {
|
||||
|id
|
||||
|nickname
|
||||
|}
|
||||
|}
|
||||
""".trimMargin()
|
||||
val payload = buildJsonObject {
|
||||
put("query", query)
|
||||
}
|
||||
authClient.newCall(
|
||||
POST(
|
||||
GRAPHQL_API_URL,
|
||||
body = payload.toString().toRequestBody(jsonMime),
|
||||
),
|
||||
return graphQlClient
|
||||
.query(
|
||||
ShikimoriGetCurrentUserQuery(),
|
||||
)
|
||||
.awaitSuccess()
|
||||
.parseAs<SMUserResult>()
|
||||
.data.currentUser
|
||||
.execute()
|
||||
.dataOrElse(
|
||||
errorLog = "Shikimori: Failed to get current user",
|
||||
default = { null },
|
||||
) {
|
||||
it.currentUser?.let { currentUser ->
|
||||
SMUser(id = currentUser.id, nickname = currentUser.nickname)
|
||||
}
|
||||
}
|
||||
?: throw Exception("Failed to get Shikimori user data")
|
||||
}
|
||||
|
||||
suspend fun accessToken(code: String): SMOAuth {
|
||||
return withIOContext {
|
||||
@@ -300,7 +215,6 @@ class ShikimoriApi(
|
||||
companion object {
|
||||
private const val BASE_URL = "https://shikimori.io"
|
||||
private const val API_URL = "$BASE_URL/api"
|
||||
private const val GRAPHQL_API_URL = "$BASE_URL/api/graphql"
|
||||
private const val OAUTH_URL = "$BASE_URL/oauth/token"
|
||||
private const val LOGIN_URL = "$BASE_URL/oauth/authorize"
|
||||
|
||||
|
||||
@@ -1,23 +1,97 @@
|
||||
package eu.kanade.tachiyomi.data.track.shikimori
|
||||
|
||||
import eu.kanade.tachiyomi.data.database.models.Track
|
||||
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||
import mihon.graphql.shikimori.ShikimoriGetLibMangaQuery
|
||||
import mihon.graphql.shikimori.ShikimoriGetMangaDetailsQuery
|
||||
import mihon.graphql.shikimori.ShikimoriSearchMangaQuery
|
||||
import mihon.graphql.shikimori.fragment.MangaFragment
|
||||
import mihon.graphql.shikimori.type.MangaKindEnum
|
||||
import mihon.graphql.shikimori.type.MangaStatusEnum
|
||||
import mihon.graphql.shikimori.type.UserRateStatusEnum
|
||||
|
||||
internal fun ShikimoriGetLibMangaQuery.Manga.toTrack(trackId: Long): Track {
|
||||
val sManga = this
|
||||
return Track.create(trackId).apply {
|
||||
remote_id = sManga.id.toLong()
|
||||
title = sManga.name
|
||||
total_chapters = sManga.chapters.toLong()
|
||||
tracking_url = sManga.url
|
||||
|
||||
if (sManga.userRate != null) {
|
||||
// null if not in user's list, must not throw here because it'd break adding titles
|
||||
// throws in the findLibManga method of ShikimoriApi if null and shouldn't be
|
||||
library_id = sManga.userRate.id.toLong()
|
||||
last_chapter_read = sManga.userRate.chapters.toDouble()
|
||||
score = sManga.userRate.score.toDouble()
|
||||
status = sManga.userRate.status.toLocalStatus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun ShikimoriGetMangaDetailsQuery.Manga.toTrackSearch(trackId: Long): TrackSearch {
|
||||
return mangaFragment.toTrackSearch(trackId)
|
||||
}
|
||||
|
||||
internal fun ShikimoriSearchMangaQuery.Manga.toTrackSearch(trackId: Long): TrackSearch {
|
||||
return mangaFragment.toTrackSearch(trackId)
|
||||
}
|
||||
|
||||
private fun MangaFragment.toTrackSearch(trackId: Long): TrackSearch {
|
||||
val sManga = this
|
||||
return TrackSearch.create(trackId).apply {
|
||||
remote_id = sManga.id.toLong()
|
||||
title = sManga.name
|
||||
total_chapters = sManga.chapters.toLong()
|
||||
cover_url = sManga.poster?.mainUrl.orEmpty()
|
||||
summary = sManga.description.orEmpty()
|
||||
score = sManga.score?.takeIf { it > 0.0 } ?: -1.0
|
||||
tracking_url = sManga.url
|
||||
publishing_status = sManga.status.toLocalString()
|
||||
publishing_type = sManga.kind.toLocalString()
|
||||
start_date = sManga.airedOn?.date.orEmpty()
|
||||
authors = sManga.personRoles?.filter { it.rolesEn.contains("Story") }?.map { it.person.name } ?: emptyList()
|
||||
artists = sManga.personRoles?.filter { it.rolesEn.contains("Art") }?.map { it.person.name } ?: emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun MangaKindEnum?.toLocalString() = when (this) {
|
||||
MangaKindEnum.manga -> "Manga"
|
||||
MangaKindEnum.manhwa -> "Manhwa"
|
||||
MangaKindEnum.manhua -> "Manhua"
|
||||
MangaKindEnum.one_shot -> "Oneshot"
|
||||
MangaKindEnum.doujin -> "Doujin"
|
||||
// light novel & novel omitted by query filter, fall back to empty if included
|
||||
else -> ""
|
||||
}
|
||||
|
||||
private fun MangaStatusEnum?.toLocalString() = when (this) {
|
||||
MangaStatusEnum.anons -> "Planned"
|
||||
MangaStatusEnum.ongoing -> "Ongoing"
|
||||
MangaStatusEnum.released -> "Released"
|
||||
MangaStatusEnum.paused -> "Paused"
|
||||
MangaStatusEnum.discontinued -> "Discontinued"
|
||||
else -> ""
|
||||
}
|
||||
|
||||
private fun UserRateStatusEnum.toLocalStatus() = when (this) {
|
||||
UserRateStatusEnum.planned -> Shikimori.PLAN_TO_READ
|
||||
UserRateStatusEnum.watching -> Shikimori.READING
|
||||
UserRateStatusEnum.rewatching -> Shikimori.REREADING
|
||||
UserRateStatusEnum.completed -> Shikimori.COMPLETED
|
||||
UserRateStatusEnum.on_hold -> Shikimori.ON_HOLD
|
||||
UserRateStatusEnum.dropped -> Shikimori.DROPPED
|
||||
else -> throw NotImplementedError("Unknown status: $this")
|
||||
}
|
||||
|
||||
internal fun Track.toShikimoriStatus() = when (status) {
|
||||
Shikimori.READING -> "watching"
|
||||
Shikimori.COMPLETED -> "completed"
|
||||
Shikimori.ON_HOLD -> "on_hold"
|
||||
Shikimori.DROPPED -> "dropped"
|
||||
Shikimori.PLAN_TO_READ -> "planned"
|
||||
Shikimori.REREADING -> "rewatching"
|
||||
else -> throw NotImplementedError("Unknown status: $status")
|
||||
}
|
||||
|
||||
internal fun toTrackStatus(status: String) = when (status) {
|
||||
"watching" -> Shikimori.READING
|
||||
"completed" -> Shikimori.COMPLETED
|
||||
"on_hold" -> Shikimori.ON_HOLD
|
||||
"dropped" -> Shikimori.DROPPED
|
||||
"planned" -> Shikimori.PLAN_TO_READ
|
||||
"rewatching" -> Shikimori.REREADING
|
||||
// rawValues because Shikimori doesn't have GraphQL mutations and this goes through the v2 API
|
||||
// if/when Shikimori adds GraphQL mutations, this should return UserRateStatusEnum members
|
||||
Shikimori.READING -> UserRateStatusEnum.watching.rawValue
|
||||
Shikimori.COMPLETED -> UserRateStatusEnum.completed.rawValue
|
||||
Shikimori.ON_HOLD -> UserRateStatusEnum.on_hold.rawValue
|
||||
Shikimori.DROPPED -> UserRateStatusEnum.dropped.rawValue
|
||||
Shikimori.PLAN_TO_READ -> UserRateStatusEnum.planned.rawValue
|
||||
Shikimori.REREADING -> UserRateStatusEnum.rewatching.rawValue
|
||||
else -> throw NotImplementedError("Unknown status: $status")
|
||||
}
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.track.shikimori.dto
|
||||
|
||||
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class SMSearchResult(
|
||||
val data: SMMangaResults,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SMMangaResults(
|
||||
val mangas: List<SMManga>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SMManga(
|
||||
val id: Long,
|
||||
val name: String,
|
||||
val chapters: Long,
|
||||
val score: Double?,
|
||||
val url: String,
|
||||
val status: String?,
|
||||
val poster: SMPoster?,
|
||||
val airedOn: SMAiredDate?,
|
||||
val description: String?,
|
||||
val kind: String?,
|
||||
val personRoles: List<SMPersonRole>?,
|
||||
) {
|
||||
fun toTrack(trackerId: Long): TrackSearch {
|
||||
return TrackSearch.create(trackerId).apply {
|
||||
remote_id = this@SMManga.id
|
||||
title = name
|
||||
total_chapters = chapters
|
||||
cover_url = poster?.mainUrl.orEmpty()
|
||||
summary = description.orEmpty()
|
||||
score = this@SMManga.score?.takeIf { it > 0.0 } ?: -1.0
|
||||
tracking_url = url
|
||||
publishing_status = this@SMManga.status.orEmpty()
|
||||
publishing_type = kind?.replace("one_shot", "oneshot").orEmpty()
|
||||
start_date = airedOn?.date.orEmpty()
|
||||
personRoles?.forEach { personRole ->
|
||||
personRole.roles.forEach { role ->
|
||||
if ("Story" in role) authors += personRole.person.name
|
||||
if ("Art" in role) artists += personRole.person.name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class SMPoster(
|
||||
val mainUrl: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SMAiredDate(
|
||||
val date: String?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SMPersonRole(
|
||||
val person: SMPerson,
|
||||
@SerialName("rolesEn")
|
||||
val roles: List<String>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SMPerson(
|
||||
val name: String,
|
||||
)
|
||||
@@ -1,18 +1,5 @@
|
||||
package eu.kanade.tachiyomi.data.track.shikimori.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class SMUserResult(
|
||||
val data: SMCurrentUser,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SMCurrentUser(
|
||||
val currentUser: SMUser,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SMUser(
|
||||
val id: String,
|
||||
val nickname: String,
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.track.shikimori.dto
|
||||
|
||||
import eu.kanade.tachiyomi.data.database.models.Track
|
||||
import eu.kanade.tachiyomi.data.track.shikimori.toTrackStatus
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class SMUserListResult(
|
||||
val data: SMUserListEntries,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SMUserListEntries(
|
||||
val mangas: List<SMUserListManga>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SMUserListManga(
|
||||
val id: String,
|
||||
val url: String,
|
||||
val name: String,
|
||||
@SerialName("chapters")
|
||||
val totalChapters: Long, // the title's total chapters
|
||||
val userRate: SMUserRate?,
|
||||
) {
|
||||
fun toTrack(trackerId: Long): Track {
|
||||
return Track.create(trackerId).apply {
|
||||
title = name
|
||||
total_chapters = totalChapters
|
||||
tracking_url = url
|
||||
if (userRate != null) {
|
||||
// null if not in user's list, must not throw here because it'd break adding titles
|
||||
// throws in the findLibManga method of ShikimoriApi if null and shouldn't be
|
||||
remote_id = userRate.rateId.toLong()
|
||||
library_id = userRate.rateId.toLong()
|
||||
last_chapter_read = userRate.chapters.toDouble()
|
||||
score = userRate.score
|
||||
status = toTrackStatus(userRate.status)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class SMUserRate(
|
||||
@SerialName("id")
|
||||
val rateId: String, // ID of the list entry (NOT the title)
|
||||
val chapters: Long, // the user's chapter progress
|
||||
val status: String,
|
||||
val score: Double,
|
||||
)
|
||||
@@ -56,7 +56,7 @@ class Suwayomi(id: Long) : BaseTracker(id, "Suwayomi"), EnhancedTracker {
|
||||
}
|
||||
}
|
||||
|
||||
return api.updateProgress(track, getPrefTrackerDelete())
|
||||
return api.updateProgress(track, getPrefTrackerDelete()) ?: throw Exception("Could not update manga")
|
||||
}
|
||||
|
||||
override suspend fun bind(track: Track, hasReadChapters: Boolean): Track {
|
||||
@@ -68,7 +68,7 @@ class Suwayomi(id: Long) : BaseTracker(id, "Suwayomi"), EnhancedTracker {
|
||||
}
|
||||
|
||||
override suspend fun refresh(track: Track): Track {
|
||||
val remoteTrack = api.getTrackSearch(track.remote_id)
|
||||
val remoteTrack = api.getTrackSearch(track.remote_id) ?: throw Exception("Could not find manga")
|
||||
track.copyPersonalFrom(remoteTrack)
|
||||
track.total_chapters = remoteTrack.total_chapters
|
||||
return track
|
||||
@@ -84,25 +84,27 @@ class Suwayomi(id: Long) : BaseTracker(id, "Suwayomi"), EnhancedTracker {
|
||||
|
||||
override fun getAcceptedSources(): List<String> = listOf("eu.kanade.tachiyomi.extension.all.tachidesk.Tachidesk")
|
||||
|
||||
override suspend fun match(manga: DomainManga): TrackSearch? =
|
||||
try {
|
||||
override suspend fun match(manga: DomainManga): TrackSearch? {
|
||||
return try {
|
||||
api.getTrackSearch(manga.url.getMangaId())
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override fun isTrackFrom(track: DomainTrack, manga: DomainManga, source: Source?): Boolean =
|
||||
track.remoteUrl == manga.url && source?.let { accept(it) } == true
|
||||
override fun isTrackFrom(track: DomainTrack, manga: DomainManga, source: Source?): Boolean {
|
||||
return track.remoteUrl == manga.url && source?.let { accept(it) } == true
|
||||
}
|
||||
|
||||
override fun migrateTrack(track: DomainTrack, manga: DomainManga, newSource: Source): DomainTrack? =
|
||||
if (accept(newSource)) {
|
||||
override fun migrateTrack(track: DomainTrack, manga: DomainManga, newSource: Source): DomainTrack? {
|
||||
return if (accept(newSource)) {
|
||||
track.copy(remoteUrl = manga.url)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.getMangaId(): Long =
|
||||
this.substringAfterLast('/').toLong()
|
||||
private fun String.getMangaId(): Long = this.substringAfterLast('/').toLong()
|
||||
|
||||
private fun getPrefTrackerDelete(): Boolean {
|
||||
val preferences = api.sourcePreferences()
|
||||
|
||||
@@ -1,187 +1,112 @@
|
||||
package eu.kanade.tachiyomi.data.track.suwayomi
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import com.apollographql.apollo.ApolloClient
|
||||
import com.apollographql.apollo.network.okHttpClient
|
||||
import eu.kanade.tachiyomi.data.database.models.Track
|
||||
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||
import eu.kanade.tachiyomi.network.POST
|
||||
import eu.kanade.tachiyomi.network.awaitSuccess
|
||||
import eu.kanade.tachiyomi.network.jsonMime
|
||||
import eu.kanade.tachiyomi.network.parseAs
|
||||
import eu.kanade.tachiyomi.network.dataOrElse
|
||||
import eu.kanade.tachiyomi.source.ConfigurableSource
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import eu.kanade.tachiyomi.source.sourcePreferences
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.addAll
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlinx.serialization.json.putJsonArray
|
||||
import kotlinx.serialization.json.putJsonObject
|
||||
import mihon.graphql.suwayomi.SuwayomiGetMangaQuery
|
||||
import mihon.graphql.suwayomi.SuwayomiGetMangaUnreadChaptersQuery
|
||||
import mihon.graphql.suwayomi.SuwayomiMarkAndDeleteChaptersMutation
|
||||
import mihon.graphql.suwayomi.SuwayomiMarkChaptersReadMutation
|
||||
import mihon.graphql.suwayomi.SuwayomiUpdateMangaProgressMutation
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import tachiyomi.core.common.util.lang.withIOContext
|
||||
import tachiyomi.domain.source.service.SourceManager
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.security.MessageDigest
|
||||
|
||||
class SuwayomiApi(
|
||||
private val trackerId: Long,
|
||||
private val sourceManager: SourceManager,
|
||||
) {
|
||||
|
||||
private val json: Json by injectLazy()
|
||||
|
||||
// Blocking is fine here: these are only touched from OkHttp and tracker threads.
|
||||
private val source: HttpSource by lazy { runBlocking { sourceManager.get(sourceId) as HttpSource } }
|
||||
private val configurableSource: ConfigurableSource by lazy {
|
||||
runBlocking { sourceManager.get(sourceId) as ConfigurableSource }
|
||||
}
|
||||
private val client: OkHttpClient by lazy { source.client }
|
||||
private val baseUrl: String by lazy { source.baseUrl.trimEnd('/') }
|
||||
private val source: Source by lazy { runBlocking { sourceManager.get(sourceId)!! } }
|
||||
private val httpSource: HttpSource by lazy { source as HttpSource }
|
||||
private val configurableSource: ConfigurableSource by lazy { source as ConfigurableSource }
|
||||
private val client: OkHttpClient by lazy { httpSource.client }
|
||||
private val baseUrl: String by lazy { httpSource.baseUrl.trimEnd('/') }
|
||||
private val apiUrl: String by lazy { "$baseUrl/api/graphql" }
|
||||
|
||||
private val graphQlClient by lazy {
|
||||
ApolloClient.Builder()
|
||||
.serverUrl(apiUrl)
|
||||
.okHttpClient(client)
|
||||
.dispatcher(Dispatchers.IO)
|
||||
// required to log the error body in dataOrElse, which also properly closes it
|
||||
.httpExposeErrorBody(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun sourcePreferences(): SharedPreferences = configurableSource.sourcePreferences()
|
||||
|
||||
suspend fun getTrackSearch(mangaId: Long): TrackSearch = withIOContext {
|
||||
val query = $$"""
|
||||
|query GetManga($mangaId: Int!) {
|
||||
| manga(id: $mangaId) {
|
||||
| ...MangaFragment
|
||||
| }
|
||||
|}
|
||||
|
|
||||
|$$MangaFragment
|
||||
""".trimMargin()
|
||||
val payload = buildJsonObject {
|
||||
put("query", query)
|
||||
putJsonObject("variables") {
|
||||
put("mangaId", mangaId)
|
||||
}
|
||||
}
|
||||
val manga = with(json) {
|
||||
client.newCall(
|
||||
POST(
|
||||
apiUrl,
|
||||
body = payload.toString().toRequestBody(jsonMime),
|
||||
),
|
||||
suspend fun getTrackSearch(mangaId: Long): TrackSearch? {
|
||||
return graphQlClient
|
||||
.query(
|
||||
SuwayomiGetMangaQuery(mangaId = mangaId.toInt()),
|
||||
)
|
||||
.awaitSuccess()
|
||||
.parseAs<GetMangaResult>()
|
||||
.data
|
||||
.entry
|
||||
}
|
||||
|
||||
TrackSearch.create(trackerId).apply {
|
||||
remote_id = mangaId
|
||||
title = manga.title
|
||||
cover_url = "$baseUrl/${manga.thumbnailUrl}"
|
||||
summary = manga.description.orEmpty()
|
||||
tracking_url = "$baseUrl/manga/$mangaId"
|
||||
total_chapters = manga.chapters.totalCount.toLong()
|
||||
publishing_status = manga.status.name
|
||||
last_chapter_read = manga.latestReadChapter?.chapterNumber ?: 0.0
|
||||
status = when (manga.unreadCount) {
|
||||
manga.chapters.totalCount -> Suwayomi.UNREAD
|
||||
0 -> Suwayomi.COMPLETED
|
||||
else -> Suwayomi.READING
|
||||
}
|
||||
.execute()
|
||||
.dataOrElse(
|
||||
errorLog = "Suwayomi: Failed to find manga in library",
|
||||
default = { null },
|
||||
) {
|
||||
it.manga.mangaFragment.toTrackSearch(trackerId, baseUrl)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateProgress(track: Track, deleteDownloadsOnServer: Boolean = false): Track {
|
||||
suspend fun updateProgress(track: Track, deleteDownloadsOnServer: Boolean = false): Track? {
|
||||
val mangaId = track.remote_id
|
||||
|
||||
// TODO: Include a filter on the chapter number here
|
||||
// Below, we only consider older chapters; since v2.1.1985 filtering works properly in the query
|
||||
val chaptersQuery = $$"""
|
||||
|query GetMangaUnreadChapters($mangaId: Int!) {
|
||||
| chapters(condition: {mangaId: $mangaId, isRead: false}) {
|
||||
| nodes {
|
||||
| id
|
||||
| chapterNumber
|
||||
| }
|
||||
| }
|
||||
|}
|
||||
""".trimMargin()
|
||||
val chaptersPayload = buildJsonObject {
|
||||
put("query", chaptersQuery)
|
||||
putJsonObject("variables") {
|
||||
put("mangaId", mangaId)
|
||||
}
|
||||
}
|
||||
val chaptersToMark = with(json) {
|
||||
client.newCall(
|
||||
POST(
|
||||
apiUrl,
|
||||
body = chaptersPayload.toString().toRequestBody(jsonMime),
|
||||
val chaptersToMark = graphQlClient
|
||||
.query(
|
||||
SuwayomiGetMangaUnreadChaptersQuery(
|
||||
mangaId = mangaId.toInt(),
|
||||
chapterNumber = track.last_chapter_read,
|
||||
),
|
||||
)
|
||||
.awaitSuccess()
|
||||
.parseAs<GetMangaUnreadChaptersResult>()
|
||||
.data
|
||||
.entry
|
||||
.nodes
|
||||
.mapNotNull { n -> n.id.takeIf { n.chapterNumber <= track.last_chapter_read + 0.001 } }
|
||||
.execute()
|
||||
.dataOrElse(
|
||||
errorLog = "Suwayomi: Failed to get chapters data",
|
||||
default = { null },
|
||||
) {
|
||||
it.chapters.nodes.map { chapter -> chapter.id }
|
||||
}
|
||||
?: throw Exception("Could not get chapters data")
|
||||
|
||||
if (chaptersToMark.isEmpty()) {
|
||||
return getTrackSearch(mangaId)
|
||||
}
|
||||
|
||||
val markQuery = if (deleteDownloadsOnServer) {
|
||||
$$"""
|
||||
|mutation MarkChaptersRead($chapters: [Int!]!) {
|
||||
| updateChapters(input: {ids: $chapters, patch: {isRead: true}}) {
|
||||
| __typename
|
||||
| }
|
||||
| deleteDownloadedChapters(input: {ids: $chapters}) {
|
||||
| __typename
|
||||
| }
|
||||
|}
|
||||
""".trimMargin()
|
||||
val markMutation = if (deleteDownloadsOnServer) {
|
||||
SuwayomiMarkAndDeleteChaptersMutation(chapters = chaptersToMark)
|
||||
} else {
|
||||
$$"""
|
||||
|mutation MarkChaptersRead($chapters: [Int!]!) {
|
||||
| updateChapters(input: {ids: $chapters, patch: {isRead: true}}) {
|
||||
| __typename
|
||||
| }
|
||||
|}
|
||||
""".trimMargin()
|
||||
SuwayomiMarkChaptersReadMutation(chapters = chaptersToMark)
|
||||
}
|
||||
val markPayload = buildJsonObject {
|
||||
put("query", markQuery)
|
||||
putJsonObject("variables") {
|
||||
putJsonArray("chapters") {
|
||||
addAll(chaptersToMark)
|
||||
}
|
||||
}
|
||||
}
|
||||
client.newCall(
|
||||
POST(
|
||||
apiUrl,
|
||||
body = markPayload.toString().toRequestBody(jsonMime),
|
||||
),
|
||||
)
|
||||
.awaitSuccess()
|
||||
|
||||
val trackQuery = $$"""
|
||||
|mutation TrackManga($mangaId: Int!) {
|
||||
| trackProgress(input: {mangaId: $mangaId}) {
|
||||
| __typename
|
||||
| }
|
||||
|}
|
||||
""".trimMargin()
|
||||
val trackPayload = buildJsonObject {
|
||||
put("query", trackQuery)
|
||||
putJsonObject("variables") {
|
||||
put("mangaId", mangaId)
|
||||
}
|
||||
}
|
||||
client.newCall(
|
||||
POST(
|
||||
apiUrl,
|
||||
body = trackPayload.toString().toRequestBody(jsonMime),
|
||||
),
|
||||
)
|
||||
.awaitSuccess()
|
||||
graphQlClient
|
||||
.mutation(markMutation)
|
||||
.execute()
|
||||
.dataOrElse(
|
||||
errorLog = "Suwayomi: Failed to mark chapters",
|
||||
default = {},
|
||||
) {}
|
||||
|
||||
return getTrackSearch(track.remote_id)
|
||||
graphQlClient
|
||||
.mutation(
|
||||
SuwayomiUpdateMangaProgressMutation(mangaId = mangaId.toInt()),
|
||||
)
|
||||
.execute()
|
||||
.dataOrElse(
|
||||
errorLog = "Suwayomi: Failed to update progress",
|
||||
default = {},
|
||||
) {}
|
||||
|
||||
return getTrackSearch(mangaId)
|
||||
}
|
||||
|
||||
private val sourceId by lazy {
|
||||
@@ -189,36 +114,4 @@ class SuwayomiApi(
|
||||
val bytes = MessageDigest.getInstance("MD5").digest(key.toByteArray())
|
||||
(0..7).map { bytes[it].toLong() and 0xff shl 8 * (7 - it) }.reduce(Long::or) and Long.MAX_VALUE
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val MangaFragment = """
|
||||
|fragment MangaFragment on MangaType {
|
||||
| artist
|
||||
| author
|
||||
| description
|
||||
| id
|
||||
| status
|
||||
| thumbnailUrl
|
||||
| title
|
||||
| url
|
||||
| genre
|
||||
| inLibraryAt
|
||||
| chapters {
|
||||
| totalCount
|
||||
| }
|
||||
| latestUploadedChapter {
|
||||
| uploadDate
|
||||
| }
|
||||
| latestFetchedChapter {
|
||||
| fetchedAt
|
||||
| }
|
||||
| latestReadChapter {
|
||||
| lastReadAt
|
||||
| chapterNumber
|
||||
| }
|
||||
| unreadCount
|
||||
| downloadCount
|
||||
|}
|
||||
""".trimMargin()
|
||||
}
|
||||
}
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package eu.kanade.tachiyomi.data.track.suwayomi
|
||||
|
||||
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||
import mihon.graphql.suwayomi.fragment.MangaFragment
|
||||
|
||||
internal fun MangaFragment.toTrackSearch(trackId: Long, baseUrl: String): TrackSearch {
|
||||
val manga = this
|
||||
return TrackSearch.create(trackId).apply {
|
||||
remote_id = manga.id.toLong()
|
||||
title = manga.title
|
||||
cover_url = "$baseUrl/${manga.thumbnailUrl}"
|
||||
summary = manga.description.orEmpty()
|
||||
tracking_url = "$baseUrl/manga/${manga.id}"
|
||||
total_chapters = manga.chapters.totalCount.toLong()
|
||||
publishing_status = manga.status.name
|
||||
last_chapter_read = manga.latestReadChapter?.chapterNumber ?: 0.0
|
||||
status = when (manga.unreadCount) {
|
||||
manga.chapters.totalCount -> Suwayomi.UNREAD
|
||||
0 -> Suwayomi.COMPLETED
|
||||
else -> Suwayomi.READING
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.track.suwayomi
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
enum class MangaStatus(
|
||||
val rawValue: String,
|
||||
) {
|
||||
UNKNOWN("UNKNOWN"),
|
||||
ONGOING("ONGOING"),
|
||||
COMPLETED("COMPLETED"),
|
||||
LICENSED("LICENSED"),
|
||||
PUBLISHING_FINISHED("PUBLISHING_FINISHED"),
|
||||
CANCELLED("CANCELLED"),
|
||||
ON_HIATUS("ON_HIATUS"),
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class MangaFragment(
|
||||
val artist: String?,
|
||||
val author: String?,
|
||||
val description: String?,
|
||||
val id: Int,
|
||||
val status: MangaStatus,
|
||||
val thumbnailUrl: String?,
|
||||
val title: String,
|
||||
val url: String,
|
||||
val genre: List<String>,
|
||||
val inLibraryAt: Long,
|
||||
val chapters: Chapters,
|
||||
val latestUploadedChapter: LatestUploadedChapter?,
|
||||
val latestFetchedChapter: LatestFetchedChapter?,
|
||||
val latestReadChapter: LatestReadChapter?,
|
||||
val unreadCount: Int,
|
||||
val downloadCount: Int,
|
||||
) {
|
||||
@Serializable
|
||||
data class Chapters(
|
||||
val totalCount: Int,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LatestUploadedChapter(
|
||||
val uploadDate: Long,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LatestFetchedChapter(
|
||||
val fetchedAt: Long,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LatestReadChapter(
|
||||
val lastReadAt: Long,
|
||||
val chapterNumber: Double,
|
||||
)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class GetMangaResult(
|
||||
val data: GetMangaData,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GetMangaData(
|
||||
@SerialName("manga") val entry: MangaFragment,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GetMangaUnreadChaptersEntry(
|
||||
val nodes: List<GetMangaUnreadChaptersNode>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GetMangaUnreadChaptersNode(
|
||||
val id: Int,
|
||||
val chapterNumber: Double,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GetMangaUnreadChaptersResult(
|
||||
val data: GetMangaUnreadChaptersData,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GetMangaUnreadChaptersData(
|
||||
@SerialName("chapters") val entry: GetMangaUnreadChaptersEntry,
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
package mihon.graphql.kitsu.adapter
|
||||
|
||||
import com.apollographql.apollo.api.Adapter
|
||||
import com.apollographql.apollo.api.AnyAdapter
|
||||
import com.apollographql.apollo.api.CustomScalarAdapters
|
||||
import com.apollographql.apollo.api.json.JsonReader
|
||||
import com.apollographql.apollo.api.json.JsonWriter
|
||||
|
||||
object KitsuMapAdapter : Adapter<Map<String, Any?>> {
|
||||
|
||||
override fun fromJson(
|
||||
reader: JsonReader,
|
||||
customScalarAdapters: CustomScalarAdapters,
|
||||
): Map<String, Any?> {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return AnyAdapter.fromJson(reader, customScalarAdapters) as Map<String, Any?>
|
||||
}
|
||||
|
||||
override fun toJson(
|
||||
writer: JsonWriter,
|
||||
customScalarAdapters: CustomScalarAdapters,
|
||||
value: Map<String, Any?>,
|
||||
) {
|
||||
AnyAdapter.toJson(writer, customScalarAdapters, value)
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,8 @@ dependencies {
|
||||
api(libs.okhttp.dnsOverHttps)
|
||||
api(libs.okio)
|
||||
|
||||
api(libs.apollo)
|
||||
|
||||
implementation(libs.image.decoder)
|
||||
|
||||
implementation(libs.unifile)
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package eu.kanade.tachiyomi.network
|
||||
|
||||
import com.apollographql.apollo.api.ApolloResponse
|
||||
import com.apollographql.apollo.api.Operation
|
||||
import com.apollographql.apollo.exception.ApolloHttpException
|
||||
import com.apollographql.apollo.exception.ApolloNetworkException
|
||||
import logcat.LogPriority
|
||||
import tachiyomi.core.common.util.system.logcat
|
||||
|
||||
/**
|
||||
* Returns the result of calling [transform] with [data] if the GraphQL response had data present without errors,
|
||||
* otherwise logs the failure under [errorLog] and falls back to [default].
|
||||
*
|
||||
* Any [ApolloHttpException] is re-thrown as [HttpException] after logging the response body.
|
||||
* This behavior can be replaced with a custom handler by providing a non-null value to [onException]. Such a lambda
|
||||
* MUST close the [ApolloHttpException.body] properly to avoid sockets and other resources leaking.
|
||||
*
|
||||
* Other failures are logged and the result of calling [default] is returned.
|
||||
*
|
||||
* @param errorLog A log message which will be included with any exceptions or errors being logged.
|
||||
* @param default A lambda which provides a default value to be returned in place of the expected value when an
|
||||
* exception or error is found.
|
||||
* @param onException A handler for [ApolloHttpException]s. It MUST handle closing the response body to avoid resource
|
||||
* leaks. Defaults to throwing a [HttpException] with the same status code.
|
||||
* @param transform A transforming lambda for the GraphQL data received.
|
||||
*/
|
||||
fun <D : Operation.Data, R> ApolloResponse<D>.dataOrElse(
|
||||
errorLog: String,
|
||||
default: () -> R,
|
||||
onException: ((ApolloHttpException) -> Unit)? = null,
|
||||
transform: (D) -> R,
|
||||
): R {
|
||||
if (exception != null) {
|
||||
val e = exception!!
|
||||
if (e is ApolloHttpException) {
|
||||
val body = e.body?.use { it.readUtf8() }
|
||||
logcat(LogPriority.ERROR, throwable = e) { "$errorLog: $body" }
|
||||
if (onException != null) {
|
||||
onException(e)
|
||||
} else {
|
||||
throw HttpException(e.statusCode).apply { stackTrace = e.stackTrace }
|
||||
}
|
||||
}
|
||||
logcat(LogPriority.ERROR, throwable = e) { errorLog }
|
||||
// Don't mask connection/IO-related issues
|
||||
if (e is ApolloNetworkException && e.cause != null) {
|
||||
throw e.cause!!
|
||||
}
|
||||
} else if (!errors.isNullOrEmpty()) {
|
||||
val errorMessages = errors!!.joinToString(separator = "\n ", prefix = "\n ") { it.message }
|
||||
logcat(LogPriority.ERROR) { "$errorLog: $errorMessages" }
|
||||
} else if (data != null) {
|
||||
return transform(data!!)
|
||||
}
|
||||
return default()
|
||||
}
|
||||
@@ -26,6 +26,8 @@ androidx-test-uiautomator = "2.4.0"
|
||||
androidx-viewPager = "1.1.0"
|
||||
androidx-webkit = "1.17.1"
|
||||
androidx-work = "2.12.0"
|
||||
apollo = "5.2.0"
|
||||
apollo-adapters = "0.8.0"
|
||||
archive = "1.1.7"
|
||||
coil = "3.6.3"
|
||||
composeGrid = "2.8.0"
|
||||
@@ -120,6 +122,8 @@ androidx-test-uiautomator = { module = "androidx.test.uiautomator:uiautomator",
|
||||
androidx-viewPager = { module = "androidx.viewpager:viewpager", version.ref = "androidx-viewPager" }
|
||||
androidx-webkit = { module = "androidx.webkit:webkit", version.ref = "androidx-webkit" }
|
||||
androidx-work = { module = "androidx.work:work-runtime", version.ref = "androidx-work" }
|
||||
apollo = { module = "com.apollographql.apollo:apollo-runtime", version.ref = "apollo" }
|
||||
apollo-adapters = { module = "com.apollographql.adapters:apollo-adapters-core", version.ref = "apollo-adapters" }
|
||||
archive = { module = "me.zhanghai.android.libarchive:library", version.ref = "archive" }
|
||||
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
|
||||
coil-core = { module = "io.coil-kt.coil3:coil", version.ref = "coil" }
|
||||
@@ -204,6 +208,7 @@ android-kmp-library = { id = "com.android.kotlin.multiplatform.library", version
|
||||
android-library = { id = "com.android.library", version.ref = "android-gradle" }
|
||||
android-test = { id = "com.android.test", version.ref = "android-gradle" }
|
||||
androidx-baselineProfile = { id = "androidx.baselineprofile", version.ref = "androidx-baselineProfile" }
|
||||
apollo = { id = "com.apollographql.apollo", version.ref = "apollo" }
|
||||
firebase-crashlytics = { id = "com.google.firebase.crashlytics", version.ref = "firebase-crashlytics" }
|
||||
google-services = { id = "com.google.gms.google-services", version.ref = "google-services" }
|
||||
kotlin-compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin-gradle" }
|
||||
|
||||
Reference in New Issue
Block a user