From 5a178ada742c26e269736a2afc6744c444cde947 Mon Sep 17 00:00:00 2001 From: Tachimanga <144205246+tachimanga@users.noreply.github.com> Date: Mon, 8 Jan 2024 04:07:41 +0800 Subject: [PATCH] add trackers support (#720) * add trackers support * Cleanup Tracker Code * Add GraphQL support for Tracking * Fix lint and deprecation errors * remove password from logs * Fixes after merge * Disable tracking for now * More disabled --------- Co-authored-by: Syer10 --- .../src/main/java/androidx/core/net/Uri.kt | 45 ++ .../tachiyomi/network/OkHttpExtensions.kt | 3 + .../eu/kanade/tachiyomi/util/PkceUtil.kt | 14 + .../util/lang/CoroutinesExtensions.kt | 61 +++ .../graphql/dataLoaders/TrackDataLoader.kt | 112 +++++ .../graphql/mutations/TrackMutation.kt | 189 +++++++ .../tachidesk/graphql/queries/TrackQuery.kt | 471 ++++++++++++++++++ .../graphql/queries/filter/Filter.kt | 35 +- .../TachideskDataLoaderRegistryFactory.kt | 10 + .../graphql/server/TachideskGraphQLSchema.kt | 4 + .../graphql/server/primitives/OrderBy.kt | 44 ++ .../tachidesk/graphql/types/MangaType.kt | 4 + .../tachidesk/graphql/types/TrackType.kt | 203 ++++++++ .../suwayomi/tachidesk/manga/MangaAPI.kt | 10 + .../manga/controller/TrackController.kt | 142 ++++++ .../suwayomi/tachidesk/manga/impl/Chapter.kt | 15 + .../suwayomi/tachidesk/manga/impl/Manga.kt | 3 + .../tachidesk/manga/impl/track/Track.kt | 343 +++++++++++++ .../track/tracker/DeletableTrackService.kt | 10 + .../manga/impl/track/tracker/Tracker.kt | 95 ++++ .../impl/track/tracker/TrackerManager.kt | 32 ++ .../impl/track/tracker/TrackerPreferences.kt | 69 +++ .../impl/track/tracker/anilist/Anilist.kt | 251 ++++++++++ .../impl/track/tracker/anilist/AnilistApi.kt | 399 +++++++++++++++ .../tracker/anilist/AnilistInterceptor.kt | 57 +++ .../track/tracker/anilist/AnilistModels.kt | 124 +++++ .../manga/impl/track/tracker/model/Track.kt | 48 ++ .../track/tracker/model/TrackConvertor.kt | 48 ++ .../impl/track/tracker/model/TrackImpl.kt | 31 ++ .../impl/track/tracker/model/TrackSearch.kt | 50 ++ .../track/tracker/myanimelist/MyAnimeList.kt | 190 +++++++ .../tracker/myanimelist/MyAnimeListApi.kt | 340 +++++++++++++ .../myanimelist/MyAnimeListInterceptor.kt | 92 ++++ .../tracker/myanimelist/MyAnimeListModels.kt | 36 ++ .../manga/model/dataclass/MangaDataClass.kt | 1 + .../model/dataclass/MangaTrackerDataClass.kt | 18 + .../model/dataclass/TrackRecordDataClass.kt | 25 + .../model/dataclass/TrackSearchDataClass.kt | 24 + .../manga/model/dataclass/TrackerDataClass.kt | 16 + .../manga/model/table/TrackRecordTable.kt | 26 + .../suwayomi/tachidesk/server/JavalinSetup.kt | 8 + .../database/migration/M0033_TrackRecord.kt | 38 ++ .../main/resources/static/tracker/anilist.png | Bin 0 -> 3541 bytes .../src/main/resources/static/tracker/mal.png | Bin 0 -> 7141 bytes 44 files changed, 3726 insertions(+), 10 deletions(-) create mode 100644 AndroidCompat/src/main/java/androidx/core/net/Uri.kt create mode 100644 server/src/main/kotlin/eu/kanade/tachiyomi/util/PkceUtil.kt create mode 100644 server/src/main/kotlin/eu/kanade/tachiyomi/util/lang/CoroutinesExtensions.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/graphql/dataLoaders/TrackDataLoader.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/TrackMutation.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/TrackQuery.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/graphql/types/TrackType.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/manga/controller/TrackController.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/Track.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/DeletableTrackService.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/Tracker.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/TrackerManager.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/TrackerPreferences.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/anilist/Anilist.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/anilist/AnilistApi.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/anilist/AnilistInterceptor.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/anilist/AnilistModels.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/model/Track.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/model/TrackConvertor.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/model/TrackImpl.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/model/TrackSearch.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/myanimelist/MyAnimeList.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/myanimelist/MyAnimeListApi.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/myanimelist/MyAnimeListInterceptor.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/myanimelist/MyAnimeListModels.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/manga/model/dataclass/MangaTrackerDataClass.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/manga/model/dataclass/TrackRecordDataClass.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/manga/model/dataclass/TrackSearchDataClass.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/manga/model/dataclass/TrackerDataClass.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/TrackRecordTable.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/server/database/migration/M0033_TrackRecord.kt create mode 100644 server/src/main/resources/static/tracker/anilist.png create mode 100644 server/src/main/resources/static/tracker/mal.png diff --git a/AndroidCompat/src/main/java/androidx/core/net/Uri.kt b/AndroidCompat/src/main/java/androidx/core/net/Uri.kt new file mode 100644 index 000000000..a4c3f898b --- /dev/null +++ b/AndroidCompat/src/main/java/androidx/core/net/Uri.kt @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:Suppress("NOTHING_TO_INLINE") // Aliases to public API. + +package androidx.core.net + +import android.net.Uri +import java.io.File + +/** + * Creates a Uri from the given encoded URI string. + * + * @see Uri.parse + */ +public inline fun String.toUri(): Uri = Uri.parse(this) + +/** + * Creates a Uri from the given file. + * + * @see Uri.fromFile + */ +public inline fun File.toUri(): Uri = Uri.fromFile(this) + +/** + * Creates a [File] from the given [Uri]. Note that this will throw an + * [IllegalArgumentException] when invoked on a [Uri] that lacks `file` scheme. + */ +public fun Uri.toFile(): File { + require(scheme == "file") { "Uri lacks 'file' scheme: $this" } + return File(requireNotNull(path) { "Uri path is null: $this" }) +} diff --git a/server/src/main/kotlin/eu/kanade/tachiyomi/network/OkHttpExtensions.kt b/server/src/main/kotlin/eu/kanade/tachiyomi/network/OkHttpExtensions.kt index ef8915661..58564134c 100644 --- a/server/src/main/kotlin/eu/kanade/tachiyomi/network/OkHttpExtensions.kt +++ b/server/src/main/kotlin/eu/kanade/tachiyomi/network/OkHttpExtensions.kt @@ -9,6 +9,7 @@ import kotlinx.serialization.json.okio.decodeFromBufferedSource import kotlinx.serialization.serializer import okhttp3.Call import okhttp3.Callback +import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response @@ -19,6 +20,8 @@ import java.io.IOException import java.util.concurrent.atomic.AtomicBoolean import kotlin.coroutines.resumeWithException +val jsonMime = "application/json; charset=utf-8".toMediaType() + fun Call.asObservable(): Observable { return Observable.unsafeCreate { subscriber -> // Since Call is a one-shot type, clone it for each new subscriber. diff --git a/server/src/main/kotlin/eu/kanade/tachiyomi/util/PkceUtil.kt b/server/src/main/kotlin/eu/kanade/tachiyomi/util/PkceUtil.kt new file mode 100644 index 000000000..dc6fb5b97 --- /dev/null +++ b/server/src/main/kotlin/eu/kanade/tachiyomi/util/PkceUtil.kt @@ -0,0 +1,14 @@ +package eu.kanade.tachiyomi.util + +import android.util.Base64 +import java.security.SecureRandom + +object PkceUtil { + private const val PKCE_BASE64_ENCODE_SETTINGS = Base64.NO_WRAP or Base64.NO_PADDING or Base64.URL_SAFE + + fun generateCodeVerifier(): String { + val codeVerifier = ByteArray(50) + SecureRandom().nextBytes(codeVerifier) + return Base64.encodeToString(codeVerifier, PKCE_BASE64_ENCODE_SETTINGS) + } +} diff --git a/server/src/main/kotlin/eu/kanade/tachiyomi/util/lang/CoroutinesExtensions.kt b/server/src/main/kotlin/eu/kanade/tachiyomi/util/lang/CoroutinesExtensions.kt new file mode 100644 index 000000000..005cc3e3a --- /dev/null +++ b/server/src/main/kotlin/eu/kanade/tachiyomi/util/lang/CoroutinesExtensions.kt @@ -0,0 +1,61 @@ +package eu.kanade.tachiyomi.util.lang + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * Think twice before using this. This is a delicate API. It is easy to accidentally create resource or memory leaks when GlobalScope is used. + * + * **Possible replacements** + * - suspend function + * - custom scope like view or presenter scope + */ +@DelicateCoroutinesApi +fun launchUI(block: suspend CoroutineScope.() -> Unit): Job = GlobalScope.launch(Dispatchers.Main, CoroutineStart.DEFAULT, block) + +/** + * Think twice before using this. This is a delicate API. It is easy to accidentally create resource or memory leaks when GlobalScope is used. + * + * **Possible replacements** + * - suspend function + * - custom scope like view or presenter scope + */ +@DelicateCoroutinesApi +fun launchIO(block: suspend CoroutineScope.() -> Unit): Job = GlobalScope.launch(Dispatchers.IO, CoroutineStart.DEFAULT, block) + +/** + * Think twice before using this. This is a delicate API. It is easy to accidentally create resource or memory leaks when GlobalScope is used. + * + * **Possible replacements** + * - suspend function + * - custom scope like view or presenter scope + */ +@DelicateCoroutinesApi +fun launchNow(block: suspend CoroutineScope.() -> Unit): Job = GlobalScope.launch(Dispatchers.Main, CoroutineStart.UNDISPATCHED, block) + +fun CoroutineScope.launchUI(block: suspend CoroutineScope.() -> Unit): Job = launch(Dispatchers.Main, block = block) + +fun CoroutineScope.launchIO(block: suspend CoroutineScope.() -> Unit): Job = launch(Dispatchers.IO, block = block) + +fun CoroutineScope.launchNonCancellable(block: suspend CoroutineScope.() -> Unit): Job = launchIO { withContext(NonCancellable, block) } + +suspend fun withUIContext(block: suspend CoroutineScope.() -> T) = + withContext( + Dispatchers.Main, + block, + ) + +suspend fun withIOContext(block: suspend CoroutineScope.() -> T) = + withContext( + Dispatchers.IO, + block, + ) + +suspend fun withNonCancellableContext(block: suspend CoroutineScope.() -> T) = withContext(NonCancellable, block) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/dataLoaders/TrackDataLoader.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/dataLoaders/TrackDataLoader.kt new file mode 100644 index 000000000..dd8d3f155 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/dataLoaders/TrackDataLoader.kt @@ -0,0 +1,112 @@ +/* + * Copyright (C) Contributors to the Suwayomi project + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +package suwayomi.tachidesk.graphql.dataLoaders + +import com.expediagroup.graphql.dataloader.KotlinDataLoader +import org.dataloader.DataLoader +import org.dataloader.DataLoaderFactory +import org.jetbrains.exposed.sql.Slf4jSqlDebugLogger +import org.jetbrains.exposed.sql.addLogger +import org.jetbrains.exposed.sql.select +import org.jetbrains.exposed.sql.transactions.transaction +import suwayomi.tachidesk.graphql.types.TrackRecordNodeList +import suwayomi.tachidesk.graphql.types.TrackRecordNodeList.Companion.toNodeList +import suwayomi.tachidesk.graphql.types.TrackRecordType +import suwayomi.tachidesk.graphql.types.TrackerType +import suwayomi.tachidesk.manga.impl.track.tracker.TrackerManager +import suwayomi.tachidesk.manga.impl.track.tracker.model.toTrack +import suwayomi.tachidesk.manga.model.table.TrackRecordTable +import suwayomi.tachidesk.server.JavalinSetup.future + +class TrackerDataLoader : KotlinDataLoader { + override val dataLoaderName = "TrackerDataLoader" + + override fun getDataLoader(): DataLoader = + DataLoaderFactory.newDataLoader { ids -> + future { + ids.map { id -> + TrackerManager.getTracker(id)?.let { TrackerType(it) } + } + } + } +} + +class TrackRecordsForMangaIdDataLoader : KotlinDataLoader { + override val dataLoaderName = "TrackRecordsForMangaIdDataLoader" + + override fun getDataLoader(): DataLoader = + DataLoaderFactory.newDataLoader { ids -> + future { + transaction { + addLogger(Slf4jSqlDebugLogger) + val trackRecordsByMangaId = + TrackRecordTable.select { TrackRecordTable.mangaId inList ids } + .map { TrackRecordType(it) } + .groupBy { it.mangaId } + ids.map { (trackRecordsByMangaId[it] ?: emptyList()).toNodeList() } + } + } + } +} + +class DisplayScoreForTrackRecordDataLoader : KotlinDataLoader { + override val dataLoaderName = "DisplayScoreForTrackRecordDataLoader" + + override fun getDataLoader(): DataLoader = + DataLoaderFactory.newDataLoader { ids -> + future { + transaction { + addLogger(Slf4jSqlDebugLogger) + val trackRecords = + TrackRecordTable.select { TrackRecordTable.id inList ids } + .toList() + .map { it.toTrack() } + .associateBy { it.id!! } + .mapValues { TrackerManager.getTracker(it.value.sync_id)?.displayScore(it.value) } + + ids.map { trackRecords[it] } + } + } + } +} + +class TrackRecordsForTrackerIdDataLoader : KotlinDataLoader { + override val dataLoaderName = "TrackRecordsForTrackerIdDataLoader" + + override fun getDataLoader(): DataLoader = + DataLoaderFactory.newDataLoader { ids -> + future { + transaction { + addLogger(Slf4jSqlDebugLogger) + val trackRecordsBySyncId = + TrackRecordTable.select { TrackRecordTable.syncId inList ids } + .map { TrackRecordType(it) } + .groupBy { it.mangaId } + ids.map { (trackRecordsBySyncId[it] ?: emptyList()).toNodeList() } + } + } + } +} + +class TrackRecordDataLoader : KotlinDataLoader { + override val dataLoaderName = "TrackRecordDataLoader" + + override fun getDataLoader(): DataLoader = + DataLoaderFactory.newDataLoader { ids -> + future { + transaction { + addLogger(Slf4jSqlDebugLogger) + val trackRecordsId = + TrackRecordTable.select { TrackRecordTable.id inList ids } + .map { TrackRecordType(it) } + .associateBy { it.id } + ids.map { trackRecordsId[it] } + } + } + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/TrackMutation.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/TrackMutation.kt new file mode 100644 index 000000000..f11a32048 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/TrackMutation.kt @@ -0,0 +1,189 @@ +package suwayomi.tachidesk.graphql.mutations + +import org.jetbrains.exposed.sql.and +import org.jetbrains.exposed.sql.select +import org.jetbrains.exposed.sql.transactions.transaction +import suwayomi.tachidesk.graphql.types.TrackRecordType +import suwayomi.tachidesk.graphql.types.TrackSearchType +import suwayomi.tachidesk.graphql.types.TrackerType +import suwayomi.tachidesk.manga.impl.track.Track +import suwayomi.tachidesk.manga.impl.track.tracker.TrackerManager +import suwayomi.tachidesk.manga.model.dataclass.TrackSearchDataClass +import suwayomi.tachidesk.manga.model.table.TrackRecordTable +import suwayomi.tachidesk.server.JavalinSetup.future +import java.util.concurrent.CompletableFuture + +class TrackMutation { + data class LoginTrackerOAuthInput( + val clientMutationId: String? = null, + val trackerId: Int, + val callbackUrl: String, + ) + + data class LoginTrackerOAuthPayload( + val clientMutationId: String?, + val isLoggedIn: Boolean, + val tracker: TrackerType, + ) + + fun loginTrackerOAuth(input: LoginTrackerOAuthInput): CompletableFuture { + val tracker = + requireNotNull(TrackerManager.getTracker(input.trackerId)) { + "Could not find tracker" + } + return future { + tracker.authCallback(input.callbackUrl) + val trackerType = TrackerType(tracker) + LoginTrackerOAuthPayload( + input.clientMutationId, + trackerType.isLoggedIn, + trackerType, + ) + } + } + + data class LoginTrackerCredentialsInput( + val clientMutationId: String? = null, + val trackerId: Int, + val username: String, + val password: String, + ) + + data class LoginTrackerCredentialsPayload( + val clientMutationId: String?, + val isLoggedIn: Boolean, + val tracker: TrackerType, + ) + + fun loginTrackerCredentials(input: LoginTrackerCredentialsInput): CompletableFuture { + val tracker = + requireNotNull(TrackerManager.getTracker(input.trackerId)) { + "Could not find tracker" + } + return future { + tracker.login(input.username, input.password) + val trackerType = TrackerType(tracker) + LoginTrackerCredentialsPayload( + input.clientMutationId, + trackerType.isLoggedIn, + trackerType, + ) + } + } + + data class LogoutTrackerInput( + val clientMutationId: String? = null, + val trackerId: Int, + ) + + data class LogoutTrackerPayload( + val clientMutationId: String?, + val isLoggedIn: Boolean, + val tracker: TrackerType, + ) + + fun logoutTracker(input: LogoutTrackerInput): CompletableFuture { + val tracker = + requireNotNull(TrackerManager.getTracker(input.trackerId)) { + "Could not find tracker" + } + require(tracker.isLoggedIn) { + "Cannot logout of a tracker that is not logged-in" + } + return future { + tracker.logout() + val trackerType = TrackerType(tracker) + LogoutTrackerPayload( + input.clientMutationId, + trackerType.isLoggedIn, + trackerType, + ) + } + } + + data class BindTrackInput( + val clientMutationId: String? = null, + val mangaId: Int, + val track: TrackSearchType, + ) + + data class BindTrackPayload( + val clientMutationId: String?, + val trackRecord: TrackRecordType, + ) + + fun bindTrack(input: BindTrackInput): CompletableFuture { + val (clientMutationId, mangaId, track) = input + + return future { + Track.bind( + mangaId, + TrackSearchDataClass( + syncId = track.syncId, + mediaId = track.mediaId, + title = track.title, + totalChapters = track.totalChapters, + trackingUrl = track.trackingUrl, + coverUrl = track.coverUrl, + summary = track.summary, + publishingStatus = track.publishingStatus, + publishingType = track.publishingType, + startDate = track.startDate, + ), + ) + val trackRecord = + transaction { + TrackRecordTable.select { + TrackRecordTable.mangaId eq mangaId and (TrackRecordTable.syncId eq track.syncId) + }.first() + } + BindTrackPayload( + clientMutationId, + TrackRecordType(trackRecord), + ) + } + } + + data class UpdateTrackInput( + val clientMutationId: String? = null, + val recordId: Int, + val status: Int? = null, + val lastChapterRead: Double? = null, + val scoreString: String? = null, + val startDate: Long? = null, + val finishDate: Long? = null, + val unbind: Boolean? = null, + ) + + data class UpdateTrackPayload( + val clientMutationId: String?, + val trackRecord: TrackRecordType?, + ) + + fun updateTrack(input: UpdateTrackInput): CompletableFuture { + return future { + Track.update( + Track.UpdateInput( + input.recordId, + input.status, + input.lastChapterRead, + input.scoreString, + input.startDate, + input.finishDate, + input.unbind, + ), + ) + + val trackRecord = + transaction { + TrackRecordTable.select { + TrackRecordTable.id eq input.recordId + }.firstOrNull() + } + UpdateTrackPayload( + input.clientMutationId, + trackRecord?.let { TrackRecordType(it) }, + ) + } + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/TrackQuery.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/TrackQuery.kt new file mode 100644 index 000000000..d4547de8d --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/TrackQuery.kt @@ -0,0 +1,471 @@ +package suwayomi.tachidesk.graphql.queries + +import com.expediagroup.graphql.server.extensions.getValueFromDataLoader +import graphql.schema.DataFetchingEnvironment +import org.jetbrains.exposed.sql.Column +import org.jetbrains.exposed.sql.Op +import org.jetbrains.exposed.sql.SortOrder +import org.jetbrains.exposed.sql.SqlExpressionBuilder.greater +import org.jetbrains.exposed.sql.SqlExpressionBuilder.less +import org.jetbrains.exposed.sql.selectAll +import org.jetbrains.exposed.sql.transactions.transaction +import suwayomi.tachidesk.graphql.queries.filter.BooleanFilter +import suwayomi.tachidesk.graphql.queries.filter.DoubleFilter +import suwayomi.tachidesk.graphql.queries.filter.Filter +import suwayomi.tachidesk.graphql.queries.filter.HasGetOp +import suwayomi.tachidesk.graphql.queries.filter.IntFilter +import suwayomi.tachidesk.graphql.queries.filter.LongFilter +import suwayomi.tachidesk.graphql.queries.filter.OpAnd +import suwayomi.tachidesk.graphql.queries.filter.StringFilter +import suwayomi.tachidesk.graphql.queries.filter.andFilterWithCompare +import suwayomi.tachidesk.graphql.queries.filter.andFilterWithCompareEntity +import suwayomi.tachidesk.graphql.queries.filter.andFilterWithCompareString +import suwayomi.tachidesk.graphql.queries.filter.applyOps +import suwayomi.tachidesk.graphql.server.primitives.Cursor +import suwayomi.tachidesk.graphql.server.primitives.OrderBy +import suwayomi.tachidesk.graphql.server.primitives.PageInfo +import suwayomi.tachidesk.graphql.server.primitives.QueryResults +import suwayomi.tachidesk.graphql.server.primitives.applyBeforeAfter +import suwayomi.tachidesk.graphql.server.primitives.greaterNotUnique +import suwayomi.tachidesk.graphql.server.primitives.lessNotUnique +import suwayomi.tachidesk.graphql.server.primitives.maybeSwap +import suwayomi.tachidesk.graphql.types.TrackRecordNodeList +import suwayomi.tachidesk.graphql.types.TrackRecordType +import suwayomi.tachidesk.graphql.types.TrackSearchType +import suwayomi.tachidesk.graphql.types.TrackerNodeList +import suwayomi.tachidesk.graphql.types.TrackerType +import suwayomi.tachidesk.manga.impl.track.tracker.TrackerManager +import suwayomi.tachidesk.manga.model.table.TrackRecordTable +import suwayomi.tachidesk.server.JavalinSetup.future +import java.util.concurrent.CompletableFuture + +class TrackQuery { + fun tracker( + dataFetchingEnvironment: DataFetchingEnvironment, + id: Int, + ): CompletableFuture { + return dataFetchingEnvironment.getValueFromDataLoader("TrackerDataLoader", id) + } + + enum class TrackerOrderBy { + ID, + NAME, + IS_LOGGED_IN, + ; + + fun greater( + tracker: TrackerType, + cursor: Cursor, + ): Boolean { + return when (this) { + ID -> tracker.id > cursor.value.toInt() + NAME -> tracker.name > cursor.value + IS_LOGGED_IN -> { + val value = cursor.value.substringAfter('-').toBooleanStrict() + !value || tracker.isLoggedIn + } + } + } + + fun less( + tracker: TrackerType, + cursor: Cursor, + ): Boolean { + return when (this) { + ID -> tracker.id < cursor.value.toInt() + NAME -> tracker.name < cursor.value + IS_LOGGED_IN -> { + val value = cursor.value.substringAfter('-').toBooleanStrict() + value || !tracker.isLoggedIn + } + } + } + + fun asCursor(type: TrackerType): Cursor { + val value = + when (this) { + ID -> type.id.toString() + NAME -> type.name + IS_LOGGED_IN -> type.id.toString() + "-" + type.isLoggedIn + } + return Cursor(value) + } + } + + data class TrackerCondition( + val id: Int? = null, + val name: String? = null, + val icon: String? = null, + val isLoggedIn: Boolean? = null, + ) + + data class TrackerFilter( + val id: IntFilter? = null, + val name: StringFilter? = null, + val icon: StringFilter? = null, + val isLoggedIn: BooleanFilter? = null, + val authUrl: StringFilter? = null, + val and: List? = null, + val or: List? = null, + val not: TrackerFilter? = null, + ) + + fun trackers( + condition: TrackerCondition? = null, + orderBy: TrackerOrderBy? = null, + orderByType: SortOrder? = null, + before: Cursor? = null, + after: Cursor? = null, + first: Int? = null, + last: Int? = null, + offset: Int? = null, + ): TrackerNodeList { + val (queryResults, resultsAsType) = + run { + var res = TrackerManager.services.map { TrackerType(it) } + + if (condition != null) { + res = + res.filter { tracker -> + (condition.id == null || (condition.id == tracker.id)) && + (condition.name == null || (condition.name == tracker.name)) && + (condition.icon == null || (condition.icon == tracker.icon)) && + (condition.isLoggedIn == null || (condition.isLoggedIn == tracker.isLoggedIn)) + } + } + + if (orderBy != null || (last != null || before != null)) { + val orderType = orderByType.maybeSwap(last ?: before) + + res = + when (orderType) { + SortOrder.DESC, SortOrder.DESC_NULLS_FIRST, SortOrder.DESC_NULLS_LAST -> + when (orderBy) { + TrackerOrderBy.ID, null -> res.sortedByDescending { it.id } + TrackerOrderBy.NAME -> res.sortedByDescending { it.name } + TrackerOrderBy.IS_LOGGED_IN -> res.sortedByDescending { it.isLoggedIn } + } + SortOrder.ASC, SortOrder.ASC_NULLS_FIRST, SortOrder.ASC_NULLS_LAST -> + when (orderBy) { + TrackerOrderBy.ID, null -> res.sortedBy { it.id } + TrackerOrderBy.NAME -> res.sortedBy { it.name } + TrackerOrderBy.IS_LOGGED_IN -> res.sortedBy { it.isLoggedIn } + } + } + } + + val total = res.size + val firstResult = res.firstOrNull() + val lastResult = res.lastOrNull() + + val realOrderBy = orderBy ?: TrackerOrderBy.ID + if (after != null) { + res = + res.filter { + when (orderByType) { + SortOrder.DESC, SortOrder.DESC_NULLS_FIRST, SortOrder.DESC_NULLS_LAST -> realOrderBy.less(it, after) + null, SortOrder.ASC, SortOrder.ASC_NULLS_FIRST, SortOrder.ASC_NULLS_LAST -> realOrderBy.greater(it, after) + } + } + } else if (before != null) { + res = + res.filter { + when (orderByType) { + SortOrder.DESC, SortOrder.DESC_NULLS_FIRST, SortOrder.DESC_NULLS_LAST -> realOrderBy.greater(it, before) + null, SortOrder.ASC, SortOrder.ASC_NULLS_FIRST, SortOrder.ASC_NULLS_LAST -> realOrderBy.less(it, before) + } + } + } + + if (first != null) { + res = res.drop(offset ?: 0).take(first) + } else if (last != null) { + res = res.take(last) + } + + QueryResults(total.toLong(), firstResult, lastResult, emptyList()) to res + } + + val getAsCursor: (TrackerType) -> Cursor = (orderBy ?: TrackerOrderBy.ID)::asCursor + + return TrackerNodeList( + resultsAsType, + if (resultsAsType.isEmpty()) { + emptyList() + } else { + listOfNotNull( + resultsAsType.firstOrNull()?.let { + TrackerNodeList.TrackerEdge( + getAsCursor(it), + it, + ) + }, + resultsAsType.lastOrNull()?.let { + TrackerNodeList.TrackerEdge( + getAsCursor(it), + it, + ) + }, + ) + }, + pageInfo = + PageInfo( + hasNextPage = queryResults.lastKey?.id != resultsAsType.lastOrNull()?.id, + hasPreviousPage = queryResults.firstKey?.id != resultsAsType.firstOrNull()?.id, + startCursor = resultsAsType.firstOrNull()?.let { getAsCursor(it) }, + endCursor = resultsAsType.lastOrNull()?.let { getAsCursor(it) }, + ), + totalCount = queryResults.total.toInt(), + ) + } + + fun trackRecord( + dataFetchingEnvironment: DataFetchingEnvironment, + id: Int, + ): CompletableFuture { + return dataFetchingEnvironment.getValueFromDataLoader("TrackRecordDataLoader", id) + } + + enum class TrackRecordOrderBy(override val column: Column>) : OrderBy { + ID(TrackRecordTable.id), + MANGA_ID(TrackRecordTable.mangaId), + SYNC_ID(TrackRecordTable.syncId), + REMOTE_ID(TrackRecordTable.remoteId), + TITLE(TrackRecordTable.title), + LAST_CHAPTER_READ(TrackRecordTable.lastChapterRead), + TOTAL_CHAPTERS(TrackRecordTable.lastChapterRead), + SCORE(TrackRecordTable.score), + START_DATE(TrackRecordTable.startDate), + FINISH_DATE(TrackRecordTable.finishDate), + ; + + override fun greater(cursor: Cursor): Op { + return when (this) { + ID -> TrackRecordTable.id greater cursor.value.toInt() + MANGA_ID -> greaterNotUnique(TrackRecordTable.mangaId, TrackRecordTable.id, cursor) + SYNC_ID -> greaterNotUnique(TrackRecordTable.syncId, TrackRecordTable.id, cursor, String::toInt) + REMOTE_ID -> greaterNotUnique(TrackRecordTable.remoteId, TrackRecordTable.id, cursor, String::toLong) + TITLE -> greaterNotUnique(TrackRecordTable.title, TrackRecordTable.id, cursor, String::toString) + LAST_CHAPTER_READ -> greaterNotUnique(TrackRecordTable.lastChapterRead, TrackRecordTable.id, cursor, String::toDouble) + TOTAL_CHAPTERS -> greaterNotUnique(TrackRecordTable.totalChapters, TrackRecordTable.id, cursor, String::toInt) + SCORE -> greaterNotUnique(TrackRecordTable.score, TrackRecordTable.id, cursor, String::toDouble) + START_DATE -> greaterNotUnique(TrackRecordTable.startDate, TrackRecordTable.id, cursor, String::toLong) + FINISH_DATE -> greaterNotUnique(TrackRecordTable.finishDate, TrackRecordTable.id, cursor, String::toLong) + } + } + + override fun less(cursor: Cursor): Op { + return when (this) { + ID -> TrackRecordTable.id less cursor.value.toInt() + MANGA_ID -> lessNotUnique(TrackRecordTable.mangaId, TrackRecordTable.id, cursor) + SYNC_ID -> lessNotUnique(TrackRecordTable.syncId, TrackRecordTable.id, cursor, String::toInt) + REMOTE_ID -> lessNotUnique(TrackRecordTable.remoteId, TrackRecordTable.id, cursor, String::toLong) + TITLE -> lessNotUnique(TrackRecordTable.title, TrackRecordTable.id, cursor, String::toString) + LAST_CHAPTER_READ -> lessNotUnique(TrackRecordTable.lastChapterRead, TrackRecordTable.id, cursor, String::toDouble) + TOTAL_CHAPTERS -> lessNotUnique(TrackRecordTable.totalChapters, TrackRecordTable.id, cursor, String::toInt) + SCORE -> lessNotUnique(TrackRecordTable.score, TrackRecordTable.id, cursor, String::toDouble) + START_DATE -> lessNotUnique(TrackRecordTable.startDate, TrackRecordTable.id, cursor, String::toLong) + FINISH_DATE -> lessNotUnique(TrackRecordTable.finishDate, TrackRecordTable.id, cursor, String::toLong) + } + } + + override fun asCursor(type: TrackRecordType): Cursor { + val value = + when (this) { + ID -> type.id.toString() + MANGA_ID -> type.id.toString() + "-" + type.mangaId + SYNC_ID -> type.id.toString() + "-" + type.syncId + REMOTE_ID -> type.id.toString() + "-" + type.remoteId + TITLE -> type.id.toString() + "-" + type.title + LAST_CHAPTER_READ -> type.id.toString() + "-" + type.lastChapterRead + TOTAL_CHAPTERS -> type.id.toString() + "-" + type.totalChapters + SCORE -> type.id.toString() + "-" + type.score + START_DATE -> type.id.toString() + "-" + type.startDate + FINISH_DATE -> type.id.toString() + "-" + type.finishDate + } + return Cursor(value) + } + } + + data class TrackRecordCondition( + val id: Int? = null, + val mangaId: Int? = null, + val syncId: Int? = null, + val remoteId: Long? = null, + val libraryId: Long? = null, + val title: String? = null, + val lastChapterRead: Double? = null, + val totalChapters: Int? = null, + val status: Int? = null, + val score: Double? = null, + val remoteUrl: String? = null, + val startDate: Long? = null, + val finishDate: Long? = null, + ) : HasGetOp { + override fun getOp(): Op? { + val opAnd = OpAnd() + opAnd.eq(id, TrackRecordTable.id) + opAnd.eq(mangaId, TrackRecordTable.mangaId) + opAnd.eq(syncId, TrackRecordTable.syncId) + opAnd.eq(remoteId, TrackRecordTable.remoteId) + opAnd.eq(libraryId, TrackRecordTable.libraryId) + opAnd.eq(title, TrackRecordTable.title) + opAnd.eq(lastChapterRead, TrackRecordTable.lastChapterRead) + opAnd.eq(totalChapters, TrackRecordTable.totalChapters) + opAnd.eq(status, TrackRecordTable.status) + opAnd.eq(score, TrackRecordTable.score) + opAnd.eq(remoteUrl, TrackRecordTable.remoteUrl) + opAnd.eq(startDate, TrackRecordTable.startDate) + opAnd.eq(finishDate, TrackRecordTable.finishDate) + + return opAnd.op + } + } + + data class TrackRecordFilter( + val id: IntFilter? = null, + val mangaId: IntFilter? = null, + val syncId: IntFilter? = null, + val remoteId: LongFilter? = null, + val libraryId: LongFilter? = null, + val title: StringFilter? = null, + val lastChapterRead: DoubleFilter? = null, + val totalChapters: IntFilter? = null, + val status: IntFilter? = null, + val score: DoubleFilter? = null, + val remoteUrl: StringFilter? = null, + val startDate: LongFilter? = null, + val finishDate: LongFilter? = null, + override val and: List? = null, + override val or: List? = null, + override val not: TrackRecordFilter? = null, + ) : Filter { + override fun getOpList(): List> { + return listOfNotNull( + andFilterWithCompareEntity(TrackRecordTable.id, id), + andFilterWithCompareEntity(TrackRecordTable.mangaId, mangaId), + andFilterWithCompare(TrackRecordTable.syncId, syncId), + andFilterWithCompare(TrackRecordTable.remoteId, remoteId), + andFilterWithCompare(TrackRecordTable.libraryId, libraryId), + andFilterWithCompareString(TrackRecordTable.title, title), + andFilterWithCompare(TrackRecordTable.lastChapterRead, lastChapterRead), + andFilterWithCompare(TrackRecordTable.totalChapters, totalChapters), + andFilterWithCompare(TrackRecordTable.status, status), + andFilterWithCompare(TrackRecordTable.score, score), + andFilterWithCompareString(TrackRecordTable.remoteUrl, remoteUrl), + andFilterWithCompare(TrackRecordTable.startDate, startDate), + andFilterWithCompare(TrackRecordTable.finishDate, finishDate), + ) + } + } + + fun trackRecords( + condition: TrackRecordCondition? = null, + filter: TrackRecordFilter? = null, + orderBy: TrackRecordOrderBy? = null, + orderByType: SortOrder? = null, + before: Cursor? = null, + after: Cursor? = null, + first: Int? = null, + last: Int? = null, + offset: Int? = null, + ): TrackRecordNodeList { + val queryResults = + transaction { + val res = TrackRecordTable.selectAll() + + res.applyOps(condition, filter) + + if (orderBy != null || (last != null || before != null)) { + val orderByColumn = orderBy?.column ?: TrackRecordTable.id + val orderType = orderByType.maybeSwap(last ?: before) + + if (orderBy == TrackRecordOrderBy.ID || orderBy == null) { + res.orderBy(orderByColumn to orderType) + } else { + res.orderBy( + orderByColumn to orderType, + TrackRecordTable.id to SortOrder.ASC, + ) + } + } + + val total = res.count() + val firstResult = res.firstOrNull()?.get(TrackRecordTable.id)?.value + val lastResult = res.lastOrNull()?.get(TrackRecordTable.id)?.value + + res.applyBeforeAfter( + before = before, + after = after, + orderBy = orderBy ?: TrackRecordOrderBy.ID, + orderByType = orderByType, + ) + + if (first != null) { + res.limit(first, offset?.toLong() ?: 0) + } else if (last != null) { + res.limit(last) + } + + QueryResults(total, firstResult, lastResult, res.toList()) + } + + val getAsCursor: (TrackRecordType) -> Cursor = (orderBy ?: TrackRecordOrderBy.ID)::asCursor + + val resultsAsType = queryResults.results.map { TrackRecordType(it) } + + return TrackRecordNodeList( + resultsAsType, + if (resultsAsType.isEmpty()) { + emptyList() + } else { + listOfNotNull( + resultsAsType.firstOrNull()?.let { + TrackRecordNodeList.TrackRecordEdge( + getAsCursor(it), + it, + ) + }, + resultsAsType.lastOrNull()?.let { + TrackRecordNodeList.TrackRecordEdge( + getAsCursor(it), + it, + ) + }, + ) + }, + pageInfo = + PageInfo( + hasNextPage = queryResults.lastKey != resultsAsType.lastOrNull()?.id, + hasPreviousPage = queryResults.firstKey != resultsAsType.firstOrNull()?.id, + startCursor = resultsAsType.firstOrNull()?.let { getAsCursor(it) }, + endCursor = resultsAsType.lastOrNull()?.let { getAsCursor(it) }, + ), + totalCount = queryResults.total.toInt(), + ) + } + + data class SearchTrackerInput( + val trackerId: Int, + val query: String, + ) + + data class SearchTrackerPayload(val trackSearches: List) + + fun searchTracker(input: SearchTrackerInput): CompletableFuture { + return future { + val tracker = + requireNotNull(TrackerManager.getTracker(input.trackerId)) { + "Tracker not found" + } + require(tracker.isLoggedIn) { + "Tracker needs to be logged-in to search" + } + SearchTrackerPayload( + tracker.search(input.query).map { + TrackSearchType(it) + }, + ) + } + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/filter/Filter.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/filter/Filter.kt index 2deac6469..8de9fb9db 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/filter/Filter.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/filter/Filter.kt @@ -259,6 +259,20 @@ data class FloatFilter( override val greaterThanOrEqualTo: Float? = null, ) : ComparableScalarFilter +data class DoubleFilter( + override val isNull: Boolean? = null, + override val equalTo: Double? = null, + override val notEqualTo: Double? = null, + override val distinctFrom: Double? = null, + override val notDistinctFrom: Double? = null, + override val `in`: List? = null, + override val notIn: List? = null, + override val lessThan: Double? = null, + override val lessThanOrEqualTo: Double? = null, + override val greaterThan: Double? = null, + override val greaterThanOrEqualTo: Double? = null, +) : ComparableScalarFilter + data class StringFilter( override val isNull: Boolean? = null, override val equalTo: String? = null, @@ -418,8 +432,8 @@ class OpAnd(var op: Op? = null) { ) = andWhere(value) { column like it } } -fun > andFilterWithCompare( - column: Column, +fun , S : T?> andFilterWithCompare( + column: Column, filter: ComparableScalarFilter?, ): Op? { filter ?: return null @@ -448,23 +462,24 @@ fun > andFilterWithCompareEntity( return opAnd.op } -fun > andFilter( - column: Column, +@Suppress("UNCHECKED_CAST") +fun , S : T?> andFilter( + column: Column, filter: ScalarFilter?, ): Op? { filter ?: return null val opAnd = OpAnd() opAnd.andWhere(filter.isNull) { if (it) column.isNull() else column.isNotNull() } - opAnd.andWhere(filter.equalTo) { column eq it } - opAnd.andWhere(filter.notEqualTo) { column neq it } - opAnd.andWhere(filter.distinctFrom) { DistinctFromOp.distinctFrom(column, it) } - opAnd.andWhere(filter.notDistinctFrom) { DistinctFromOp.notDistinctFrom(column, it) } + opAnd.andWhere(filter.equalTo) { column eq it as S } + opAnd.andWhere(filter.notEqualTo) { column neq it as S } + opAnd.andWhere(filter.distinctFrom) { DistinctFromOp.distinctFrom(column, it as S) } + opAnd.andWhere(filter.notDistinctFrom) { DistinctFromOp.notDistinctFrom(column, it as S) } if (!filter.`in`.isNullOrEmpty()) { - opAnd.andWhere(filter.`in`) { column inList it } + opAnd.andWhere(filter.`in`) { column inList it as List } } if (!filter.notIn.isNullOrEmpty()) { - opAnd.andWhere(filter.notIn) { column notInList it } + opAnd.andWhere(filter.notIn) { column notInList it as List } } return opAnd.op } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/TachideskDataLoaderRegistryFactory.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/TachideskDataLoaderRegistryFactory.kt index c9fa7ab86..a5fd1aa55 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/TachideskDataLoaderRegistryFactory.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/TachideskDataLoaderRegistryFactory.kt @@ -15,6 +15,7 @@ import suwayomi.tachidesk.graphql.dataLoaders.CategoryMetaDataLoader import suwayomi.tachidesk.graphql.dataLoaders.ChapterDataLoader import suwayomi.tachidesk.graphql.dataLoaders.ChapterMetaDataLoader import suwayomi.tachidesk.graphql.dataLoaders.ChaptersForMangaDataLoader +import suwayomi.tachidesk.graphql.dataLoaders.DisplayScoreForTrackRecordDataLoader import suwayomi.tachidesk.graphql.dataLoaders.DownloadedChapterCountForMangaDataLoader import suwayomi.tachidesk.graphql.dataLoaders.ExtensionDataLoader import suwayomi.tachidesk.graphql.dataLoaders.ExtensionForSourceDataLoader @@ -27,6 +28,10 @@ import suwayomi.tachidesk.graphql.dataLoaders.MangaForSourceDataLoader import suwayomi.tachidesk.graphql.dataLoaders.MangaMetaDataLoader import suwayomi.tachidesk.graphql.dataLoaders.SourceDataLoader import suwayomi.tachidesk.graphql.dataLoaders.SourcesForExtensionDataLoader +import suwayomi.tachidesk.graphql.dataLoaders.TrackRecordDataLoader +import suwayomi.tachidesk.graphql.dataLoaders.TrackRecordsForMangaIdDataLoader +import suwayomi.tachidesk.graphql.dataLoaders.TrackRecordsForTrackerIdDataLoader +import suwayomi.tachidesk.graphql.dataLoaders.TrackerDataLoader import suwayomi.tachidesk.graphql.dataLoaders.UnreadChapterCountForMangaDataLoader class TachideskDataLoaderRegistryFactory { @@ -53,6 +58,11 @@ class TachideskDataLoaderRegistryFactory { SourcesForExtensionDataLoader(), ExtensionDataLoader(), ExtensionForSourceDataLoader(), + // TrackerDataLoader(), + // TrackRecordsForMangaIdDataLoader(), + // DisplayScoreForTrackRecordDataLoader(), + // TrackRecordsForTrackerIdDataLoader(), + // TrackRecordDataLoader(), ) } } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/TachideskGraphQLSchema.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/TachideskGraphQLSchema.kt index 63db0a46f..525f227db 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/TachideskGraphQLSchema.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/TachideskGraphQLSchema.kt @@ -24,6 +24,7 @@ import suwayomi.tachidesk.graphql.mutations.MangaMutation import suwayomi.tachidesk.graphql.mutations.MetaMutation import suwayomi.tachidesk.graphql.mutations.SettingsMutation import suwayomi.tachidesk.graphql.mutations.SourceMutation +import suwayomi.tachidesk.graphql.mutations.TrackMutation import suwayomi.tachidesk.graphql.mutations.UpdateMutation import suwayomi.tachidesk.graphql.queries.BackupQuery import suwayomi.tachidesk.graphql.queries.CategoryQuery @@ -35,6 +36,7 @@ import suwayomi.tachidesk.graphql.queries.MangaQuery import suwayomi.tachidesk.graphql.queries.MetaQuery import suwayomi.tachidesk.graphql.queries.SettingsQuery import suwayomi.tachidesk.graphql.queries.SourceQuery +import suwayomi.tachidesk.graphql.queries.TrackQuery import suwayomi.tachidesk.graphql.queries.UpdateQuery import suwayomi.tachidesk.graphql.server.primitives.Cursor import suwayomi.tachidesk.graphql.server.primitives.GraphQLCursor @@ -76,6 +78,7 @@ val schema = TopLevelObject(MetaQuery()), TopLevelObject(SettingsQuery()), TopLevelObject(SourceQuery()), + // TopLevelObject(TrackQuery()), TopLevelObject(UpdateQuery()), ), mutations = @@ -91,6 +94,7 @@ val schema = TopLevelObject(MetaMutation()), TopLevelObject(SettingsMutation()), TopLevelObject(SourceMutation()), + // TopLevelObject(TrackMutation()), TopLevelObject(UpdateMutation()), ), subscriptions = diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/primitives/OrderBy.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/primitives/OrderBy.kt index e15e792b2..37f131690 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/primitives/OrderBy.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/primitives/OrderBy.kt @@ -81,6 +81,15 @@ fun > greaterNotUnique( return greaterNotUniqueImpl(column, idColumn, cursor, String::toLong, toValue) } +@JvmName("greaterNotUniqueIntKeyIntValue") +fun greaterNotUnique( + column: Column>, + idColumn: Column>, + cursor: Cursor, +): Op { + return greaterNotUniqueImpl(column, idColumn, cursor, String::toInt, String::toInt) +} + private fun , V : Comparable> greaterNotUniqueImpl( column: Column, idColumn: Column>, @@ -93,6 +102,19 @@ private fun , V : Comparable> greaterNotUniqueImpl( return (column greater value) or ((column eq value) and (idColumn greater id)) } +@JvmName("greaterNotUniqueEntityValue") +private fun , V : Comparable> greaterNotUniqueImpl( + column: Column>, + idColumn: Column>, + cursor: Cursor, + toKey: (String) -> K, + toValue: (String) -> V, +): Op { + val id = toKey(cursor.value.substringBefore('-')) + val value = toValue(cursor.value.substringAfter('-')) + return (column greater value) or ((column eq value) and (idColumn greater id)) +} + @JvmName("greaterNotUniqueStringKey") fun > greaterNotUnique( column: Column, @@ -125,6 +147,15 @@ fun > lessNotUnique( return lessNotUniqueImpl(column, idColumn, cursor, String::toLong, toValue) } +@JvmName("lessNotUniqueIntKeyIntValue") +fun lessNotUnique( + column: Column>, + idColumn: Column>, + cursor: Cursor, +): Op { + return lessNotUniqueImpl(column, idColumn, cursor, String::toInt, String::toInt) +} + private fun , V : Comparable> lessNotUniqueImpl( column: Column, idColumn: Column>, @@ -137,6 +168,19 @@ private fun , V : Comparable> lessNotUniqueImpl( return (column less value) or ((column eq value) and (idColumn less id)) } +@JvmName("lessNotUniqueEntityValue") +private fun , V : Comparable> lessNotUniqueImpl( + column: Column>, + idColumn: Column>, + cursor: Cursor, + toKey: (String) -> K, + toValue: (String) -> V, +): Op { + val id = toKey(cursor.value.substringBefore('-')) + val value = toValue(cursor.value.substringAfter('-')) + return (column less value) or ((column eq value) and (idColumn less id)) +} + @JvmName("lessNotUniqueStringKey") fun > lessNotUnique( column: Column, diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/MangaType.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/MangaType.kt index dccd1ba76..66060740f 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/MangaType.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/MangaType.kt @@ -139,6 +139,10 @@ class MangaType( fun source(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture { return dataFetchingEnvironment.getValueFromDataLoader("SourceDataLoader", sourceId) } + + // fun trackRecords(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture { + // return dataFetchingEnvironment.getValueFromDataLoader("TrackRecordsForMangaIdDataLoader", id) + // } } data class MangaNodeList( diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/TrackType.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/TrackType.kt new file mode 100644 index 000000000..1fa9bfc00 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/TrackType.kt @@ -0,0 +1,203 @@ +package suwayomi.tachidesk.graphql.types + +import com.expediagroup.graphql.server.extensions.getValueFromDataLoader +import graphql.schema.DataFetchingEnvironment +import org.jetbrains.exposed.sql.ResultRow +import suwayomi.tachidesk.graphql.server.primitives.Cursor +import suwayomi.tachidesk.graphql.server.primitives.Edge +import suwayomi.tachidesk.graphql.server.primitives.Node +import suwayomi.tachidesk.graphql.server.primitives.NodeList +import suwayomi.tachidesk.graphql.server.primitives.PageInfo +import suwayomi.tachidesk.manga.impl.track.tracker.Tracker +import suwayomi.tachidesk.manga.impl.track.tracker.model.TrackSearch +import suwayomi.tachidesk.manga.model.table.TrackRecordTable +import java.util.concurrent.CompletableFuture + +class TrackerType( + val id: Int, + val name: String, + val icon: String, + val isLoggedIn: Boolean, + val authUrl: String?, +) : Node { + constructor(tracker: Tracker) : this( + tracker.isLoggedIn, + tracker, + ) + + constructor(isLoggedIn: Boolean, tracker: Tracker) : this( + tracker.id, + tracker.name, + tracker.getLogo(), + isLoggedIn, + if (isLoggedIn) { + null + } else { + tracker.authUrl() + }, + ) + + fun trackRecords(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture { + return dataFetchingEnvironment.getValueFromDataLoader("TrackRecordsForTrackerIdDataLoader", id) + } +} + +class TrackRecordType( + val id: Int, + val mangaId: Int, + val syncId: Int, + val remoteId: Long, + val libraryId: Long?, + val title: String, + val lastChapterRead: Double, + val totalChapters: Int, + val status: Int, + val score: Double, + val remoteUrl: String, + val startDate: Long, + val finishDate: Long, +) : Node { + constructor(row: ResultRow) : this( + row[TrackRecordTable.id].value, + row[TrackRecordTable.mangaId].value, + row[TrackRecordTable.syncId], + row[TrackRecordTable.remoteId], + row[TrackRecordTable.libraryId], + row[TrackRecordTable.title], + row[TrackRecordTable.lastChapterRead], + row[TrackRecordTable.totalChapters], + row[TrackRecordTable.status], + row[TrackRecordTable.score], + row[TrackRecordTable.remoteUrl], + row[TrackRecordTable.startDate], + row[TrackRecordTable.finishDate], + ) + + fun displayScore(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture { + return dataFetchingEnvironment.getValueFromDataLoader("DisplayScoreForTrackRecordDataLoader", id) + } + + fun manga(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture { + return dataFetchingEnvironment.getValueFromDataLoader("MangaDataLoader", mangaId) + } + + fun tracker(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture { + return dataFetchingEnvironment.getValueFromDataLoader("TrackerDataLoader", syncId) + } +} + +class TrackSearchType( + val syncId: Int, + val mediaId: Long, + val title: String, + val totalChapters: Int, + val trackingUrl: String, + val coverUrl: String, + val summary: String, + val publishingStatus: String, + val publishingType: String, + val startDate: String, +) { + constructor(trackSearch: TrackSearch) : this( + trackSearch.sync_id, + trackSearch.media_id, + trackSearch.title, + trackSearch.total_chapters, + trackSearch.tracking_url, + trackSearch.cover_url, + trackSearch.summary, + trackSearch.publishing_status, + trackSearch.publishing_type, + trackSearch.start_date, + ) + + fun tracker(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture { + return dataFetchingEnvironment.getValueFromDataLoader("TrackerDataLoader", syncId) + } +} + +data class TrackRecordNodeList( + override val nodes: List, + override val edges: List, + override val pageInfo: PageInfo, + override val totalCount: Int, +) : NodeList() { + data class TrackRecordEdge( + override val cursor: Cursor, + override val node: TrackRecordType, + ) : Edge() + + companion object { + fun List.toNodeList(): TrackRecordNodeList { + return TrackRecordNodeList( + nodes = this, + edges = getEdges(), + pageInfo = + PageInfo( + hasNextPage = false, + hasPreviousPage = false, + startCursor = Cursor(0.toString()), + endCursor = Cursor(lastIndex.toString()), + ), + totalCount = size, + ) + } + + private fun List.getEdges(): List { + if (isEmpty()) return emptyList() + return listOf( + TrackRecordEdge( + cursor = Cursor("0"), + node = first(), + ), + TrackRecordEdge( + cursor = Cursor(lastIndex.toString()), + node = last(), + ), + ) + } + } +} + +data class TrackerNodeList( + override val nodes: List, + override val edges: List, + override val pageInfo: PageInfo, + override val totalCount: Int, +) : NodeList() { + data class TrackerEdge( + override val cursor: Cursor, + override val node: TrackerType, + ) : Edge() + + companion object { + fun List.toNodeList(): TrackerNodeList { + return TrackerNodeList( + nodes = this, + edges = getEdges(), + pageInfo = + PageInfo( + hasNextPage = false, + hasPreviousPage = false, + startCursor = Cursor(0.toString()), + endCursor = Cursor(lastIndex.toString()), + ), + totalCount = size, + ) + } + + private fun List.getEdges(): List { + if (isEmpty()) return emptyList() + return listOf( + TrackerEdge( + cursor = Cursor("0"), + node = first(), + ), + TrackerEdge( + cursor = Cursor(lastIndex.toString()), + node = last(), + ), + ) + } + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/MangaAPI.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/MangaAPI.kt index 56bb0d9c1..fc091e415 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/MangaAPI.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/MangaAPI.kt @@ -20,6 +20,7 @@ import suwayomi.tachidesk.manga.controller.DownloadController import suwayomi.tachidesk.manga.controller.ExtensionController import suwayomi.tachidesk.manga.controller.MangaController import suwayomi.tachidesk.manga.controller.SourceController +import suwayomi.tachidesk.manga.controller.TrackController import suwayomi.tachidesk.manga.controller.UpdateController object MangaAPI { @@ -132,5 +133,14 @@ object MangaAPI { get("summary", UpdateController.updateSummary) ws("", UpdateController::categoryUpdateWS) } + + // path("track") { + // get("list", TrackController.list) + // post("login", TrackController.login) + // post("logout", TrackController.logout) + // post("search", TrackController.search) + // post("bind", TrackController.bind) + // post("update", TrackController.update) + // } } } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/TrackController.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/TrackController.kt new file mode 100644 index 000000000..b0d1fa4a9 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/TrackController.kt @@ -0,0 +1,142 @@ +package suwayomi.tachidesk.manga.controller + +/* + * Copyright (C) Contributors to the Suwayomi project + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +import io.javalin.http.HttpCode +import kotlinx.serialization.json.Json +import mu.KotlinLogging +import org.kodein.di.DI +import org.kodein.di.conf.global +import org.kodein.di.instance +import suwayomi.tachidesk.manga.impl.track.Track +import suwayomi.tachidesk.manga.model.dataclass.TrackSearchDataClass +import suwayomi.tachidesk.manga.model.dataclass.TrackerDataClass +import suwayomi.tachidesk.server.JavalinSetup.future +import suwayomi.tachidesk.server.util.handler +import suwayomi.tachidesk.server.util.queryParam +import suwayomi.tachidesk.server.util.withOperation + +object TrackController { + private val json by DI.global.instance() + private val logger = KotlinLogging.logger {} + + val list = + handler( + documentWith = { + withOperation { + summary("List Supported Trackers") + description("List all supported Trackers") + } + }, + behaviorOf = { ctx -> + ctx.json(Track.getTrackerList()) + }, + withResults = { + json>(HttpCode.OK) + }, + ) + + val login = + handler( + documentWith = { + withOperation { + summary("Tracker Login") + description("Login to a tracker") + } + body() + }, + behaviorOf = { ctx -> + val input = json.decodeFromString(ctx.body()) + logger.debug { "tracker login $input" } + ctx.future(future { Track.login(input) }) + }, + withResults = { + httpCode(HttpCode.OK) + httpCode(HttpCode.NOT_FOUND) + }, + ) + + val logout = + handler( + documentWith = { + withOperation { + summary("Tracker Logout") + description("Logout of a Tracker") + } + body() + }, + behaviorOf = { ctx -> + val input = json.decodeFromString(ctx.body()) + logger.debug { "tracker logout $input" } + ctx.future(future { Track.logout(input) }) + }, + withResults = { + httpCode(HttpCode.OK) + httpCode(HttpCode.NOT_FOUND) + }, + ) + + val search = + handler( + documentWith = { + withOperation { + summary("Tracker Search") + description("Search for a title on a tracker") + } + body() + }, + behaviorOf = { ctx -> + val input = json.decodeFromString(ctx.body()) + logger.debug { "tracker search $input" } + ctx.future(future { Track.search(input) }) + }, + withResults = { + httpCode(HttpCode.OK) + httpCode(HttpCode.NOT_FOUND) + }, + ) + + val bind = + handler( + queryParam("mangaId"), + documentWith = { + withOperation { + summary("Track Record Bind") + description("Bind a Track Record to a Manga") + } + body() + }, + behaviorOf = { ctx, mangaId -> + val input = json.decodeFromString(ctx.body()) + logger.debug { "tracker bind $input" } + ctx.future(future { Track.bind(mangaId, input) }) + }, + withResults = { + httpCode(HttpCode.OK) + }, + ) + + val update = + handler( + documentWith = { + withOperation { + summary("Track Update") + description("Update a Track Record with the Tracker") + } + body() + }, + behaviorOf = { ctx -> + val input = json.decodeFromString(ctx.body()) + logger.debug { "tracker update $input" } + ctx.future(future { Track.update(input) }) + }, + withResults = { + httpCode(HttpCode.OK) + }, + ) +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Chapter.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Chapter.kt index 8c5343446..bdc613e75 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Chapter.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Chapter.kt @@ -34,6 +34,7 @@ import org.jetbrains.exposed.sql.update import suwayomi.tachidesk.manga.impl.Manga.getManga import suwayomi.tachidesk.manga.impl.download.DownloadManager import suwayomi.tachidesk.manga.impl.download.DownloadManager.EnqueueInput +import suwayomi.tachidesk.manga.impl.track.Track import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrStub import suwayomi.tachidesk.manga.model.dataclass.ChapterDataClass import suwayomi.tachidesk.manga.model.dataclass.MangaChapterDataClass @@ -389,6 +390,10 @@ object Chapter { } } } + + if (isRead == true || markPrevRead == true) { + Track.asyncTrackChapter(mangaId) + } } @Serializable @@ -469,6 +474,16 @@ object Chapter { } } } + + if (isRead == true) { + val mangaIds = + transaction { + ChapterTable.select { condition } + .map { it[ChapterTable.manga].value } + .distinct() + } + mangaIds.forEach { Track.asyncTrackChapter(it) } + } } fun getChaptersMetaMaps(chapterIds: List>): Map, Map> { diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Manga.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Manga.kt index d83f56496..2ebc8ea8a 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Manga.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Manga.kt @@ -30,6 +30,7 @@ import suwayomi.tachidesk.manga.impl.Source.getSource import suwayomi.tachidesk.manga.impl.download.DownloadManager import suwayomi.tachidesk.manga.impl.download.DownloadManager.EnqueueInput import suwayomi.tachidesk.manga.impl.download.fileProvider.impl.MissingThumbnailException +import suwayomi.tachidesk.manga.impl.track.Track import suwayomi.tachidesk.manga.impl.util.network.await import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrNull import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrStub @@ -105,6 +106,7 @@ object Manga { chaptersLastFetchedAt = mangaEntry[MangaTable.chaptersLastFetchedAt], updateStrategy = UpdateStrategy.valueOf(mangaEntry[MangaTable.updateStrategy]), freshData = true, + trackers = Track.getTrackRecordsByMangaId(mangaId), ) } } @@ -221,6 +223,7 @@ object Manga { chaptersLastFetchedAt = mangaEntry[MangaTable.chaptersLastFetchedAt], updateStrategy = UpdateStrategy.valueOf(mangaEntry[MangaTable.updateStrategy]), freshData = false, + trackers = Track.getTrackRecordsByMangaId(mangaId), ) fun getMangaMetaMap(mangaId: Int): Map { diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/Track.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/Track.kt new file mode 100644 index 000000000..a3b023258 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/Track.kt @@ -0,0 +1,343 @@ +package suwayomi.tachidesk.manga.impl.track + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable +import mu.KotlinLogging +import org.jetbrains.exposed.sql.ResultRow +import org.jetbrains.exposed.sql.SortOrder +import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq +import org.jetbrains.exposed.sql.and +import org.jetbrains.exposed.sql.deleteWhere +import org.jetbrains.exposed.sql.insertAndGetId +import org.jetbrains.exposed.sql.select +import org.jetbrains.exposed.sql.transactions.transaction +import org.jetbrains.exposed.sql.update +import suwayomi.tachidesk.manga.impl.track.tracker.TrackerManager +import suwayomi.tachidesk.manga.impl.track.tracker.model.Track +import suwayomi.tachidesk.manga.impl.track.tracker.model.toTrack +import suwayomi.tachidesk.manga.impl.track.tracker.model.toTrackRecordDataClass +import suwayomi.tachidesk.manga.model.dataclass.MangaTrackerDataClass +import suwayomi.tachidesk.manga.model.dataclass.TrackSearchDataClass +import suwayomi.tachidesk.manga.model.dataclass.TrackerDataClass +import suwayomi.tachidesk.manga.model.table.ChapterTable +import suwayomi.tachidesk.manga.model.table.TrackRecordTable + +object Track { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val logger = KotlinLogging.logger {} + + fun getTrackerList(): List { + val trackers = TrackerManager.services + return trackers.map { + val isLogin = it.isLoggedIn + val authUrl = if (isLogin) null else it.authUrl() + TrackerDataClass( + id = it.id, + name = it.name, + icon = it.getLogo(), + isLogin = isLogin, + authUrl = authUrl, + ) + } + } + + suspend fun login(input: LoginInput) { + val tracker = TrackerManager.getTracker(input.trackerId)!! + if (input.callbackUrl != null) { + tracker.authCallback(input.callbackUrl) + } else { + tracker.login(input.username ?: "", input.password ?: "") + } + } + + fun logout(input: LogoutInput) { + val tracker = TrackerManager.getTracker(input.trackerId)!! + tracker.logout() + } + + fun getTrackRecordsByMangaId(mangaId: Int): List { + if (!TrackerManager.hasLoggedTracker()) { + return emptyList() + } + val recordMap = + transaction { + TrackRecordTable.select { TrackRecordTable.mangaId eq mangaId } + .map { it.toTrackRecordDataClass() } + }.associateBy { it.syncId } + + val trackers = TrackerManager.services + return trackers + .filter { it.isLoggedIn } + .map { + val record = recordMap[it.id] + if (record != null) { + val track = + Track.create(it.id).also { t -> + t.score = record.score.toFloat() + } + record.scoreString = it.displayScore(track) + } + MangaTrackerDataClass( + id = it.id, + name = it.name, + icon = it.getLogo(), + statusList = it.getStatusList(), + statusTextMap = it.getStatusList().associateWith { k -> it.getStatus(k) ?: "" }, + scoreList = it.getScoreList(), + record = record, + ) + } + } + + suspend fun search(input: SearchInput): List { + val tracker = TrackerManager.getTracker(input.trackerId)!! + val list = tracker.search(input.title) + return list.map { + TrackSearchDataClass( + syncId = it.sync_id, + mediaId = it.media_id, + title = it.title, + totalChapters = it.total_chapters, + trackingUrl = it.tracking_url, + coverUrl = it.cover_url, + summary = it.summary, + publishingStatus = it.publishing_status, + publishingType = it.publishing_type, + startDate = it.start_date, + ) + } + } + + suspend fun bind( + mangaId: Int, + input: TrackSearchDataClass, + ) { + val tracker = TrackerManager.getTracker(input.syncId)!! + + val track = input.toTrack(mangaId) + + val chapter = queryMaxReadChapter(mangaId) + val hasReadChapters = chapter != null + val chapterNumber = chapter?.get(ChapterTable.chapter_number) + + tracker.bind(track, hasReadChapters) + val recordId = upsertTrackRecord(track) + + var lastChapterRead: Double? = null + var startDate: Long? = null + if (chapterNumber != null && chapterNumber > 0) { + lastChapterRead = chapterNumber.toDouble() + } + if (track.started_reading_date <= 0) { + val oldestChapter = + transaction { + ChapterTable.select { + (ChapterTable.manga eq mangaId) and (ChapterTable.isRead eq true) + } + .orderBy(ChapterTable.lastReadAt to SortOrder.ASC) + .limit(1) + .firstOrNull() + } + if (oldestChapter != null) { + startDate = oldestChapter[ChapterTable.lastReadAt] * 1000 + } + } + if (lastChapterRead != null || startDate != null) { + val trackUpdate = + UpdateInput( + recordId = recordId, + lastChapterRead = lastChapterRead, + startDate = startDate, + ) + update(trackUpdate) + } + } + + suspend fun update(input: UpdateInput) { + if (input.unbind == true) { + transaction { + TrackRecordTable.deleteWhere { TrackRecordTable.id eq input.recordId } + } + return + } + val recordDb = + transaction { + TrackRecordTable.select { TrackRecordTable.id eq input.recordId }.first() + } + + val tracker = TrackerManager.getTracker(recordDb[TrackRecordTable.syncId])!! + + if (input.status != null) { + recordDb[TrackRecordTable.status] = input.status + if (input.status == tracker.getCompletionStatus() && recordDb[TrackRecordTable.totalChapters] != 0) { + recordDb[TrackRecordTable.lastChapterRead] = recordDb[TrackRecordTable.totalChapters] + } + } + if (input.lastChapterRead != null) { + if (recordDb[TrackRecordTable.lastChapterRead] == 0.0 && + recordDb[TrackRecordTable.lastChapterRead] < input.lastChapterRead && + recordDb[TrackRecordTable.status] != tracker.getRereadingStatus() + ) { + recordDb[TrackRecordTable.status] = tracker.getReadingStatus() + } + recordDb[TrackRecordTable.lastChapterRead] = input.lastChapterRead + if (recordDb[TrackRecordTable.totalChapters] != 0 && + input.lastChapterRead.toInt() == recordDb[TrackRecordTable.totalChapters] + ) { + recordDb[TrackRecordTable.status] = tracker.getCompletionStatus() + recordDb[TrackRecordTable.finishDate] = System.currentTimeMillis() + } + } + if (input.scoreString != null) { + val score = tracker.indexToScore(tracker.getScoreList().indexOf(input.scoreString)) + recordDb[TrackRecordTable.score] = score.toDouble() + } + if (input.startDate != null) { + recordDb[TrackRecordTable.startDate] = input.startDate + } + if (input.finishDate != null) { + recordDb[TrackRecordTable.finishDate] = input.finishDate + } + + val track = recordDb.toTrack() + tracker.update(track) + + upsertTrackRecord(track) + } + + fun asyncTrackChapter(mangaId: Int) { + scope.launch { + trackChapter(mangaId) + } + } + + private suspend fun trackChapter(mangaId: Int) { + val chapter = queryMaxReadChapter(mangaId) + val chapterNumber = chapter?.get(ChapterTable.chapter_number) + logger.debug { + "[Tracker]mangaId $mangaId chapter:${chapter?.get(ChapterTable.name)} " + + "chapterNumber:$chapterNumber" + } + if (chapterNumber != null && chapterNumber > 0) { + trackChapter(mangaId, chapterNumber.toDouble()) + } + } + + private fun queryMaxReadChapter(mangaId: Int): ResultRow? { + return transaction { + ChapterTable.select { (ChapterTable.manga eq mangaId) and (ChapterTable.isRead eq true) } + .orderBy(ChapterTable.chapter_number to SortOrder.DESC) + .limit(1) + .firstOrNull() + } + } + + private suspend fun trackChapter( + mangaId: Int, + chapterNumber: Double, + ) { + if (!TrackerManager.hasLoggedTracker()) { + return + } + + val records = + transaction { + TrackRecordTable.select { TrackRecordTable.mangaId eq mangaId } + .toList() + } + + records.forEach { + val tracker = TrackerManager.getTracker(it[TrackRecordTable.syncId]) + val lastChapterRead = it[TrackRecordTable.lastChapterRead] + val isLogin = tracker?.isLoggedIn == true + logger.debug { + "[Tracker]trackChapter id:${tracker?.id} login:$isLogin " + + "mangaId:$mangaId dbChapter:$lastChapterRead toChapter:$chapterNumber" + } + if (isLogin && chapterNumber > lastChapterRead) { + it[TrackRecordTable.lastChapterRead] = chapterNumber + val track = it.toTrack() + tracker?.update(track, true) + upsertTrackRecord(track) + } + } + } + + private fun upsertTrackRecord(track: Track): Int { + return transaction { + val existingRecord = + TrackRecordTable.select { + (TrackRecordTable.mangaId eq track.manga_id) and + (TrackRecordTable.syncId eq track.sync_id) + } + .singleOrNull() + + if (existingRecord != null) { + TrackRecordTable.update({ + (TrackRecordTable.mangaId eq track.manga_id) and + (TrackRecordTable.syncId eq track.sync_id) + }) { + it[remoteId] = track.media_id + it[libraryId] = track.library_id + it[title] = track.title + it[lastChapterRead] = track.last_chapter_read.toDouble() + it[totalChapters] = track.total_chapters + it[status] = track.status + it[score] = track.score.toDouble() + it[remoteUrl] = track.tracking_url + it[startDate] = track.started_reading_date + it[finishDate] = track.finished_reading_date + } + existingRecord[TrackRecordTable.id].value + } else { + TrackRecordTable.insertAndGetId { + it[mangaId] = track.manga_id + it[syncId] = track.sync_id + it[remoteId] = track.media_id + it[libraryId] = track.library_id + it[title] = track.title + it[lastChapterRead] = track.last_chapter_read.toDouble() + it[totalChapters] = track.total_chapters + it[status] = track.status + it[score] = track.score.toDouble() + it[remoteUrl] = track.tracking_url + it[startDate] = track.started_reading_date + it[finishDate] = track.finished_reading_date + }.value + } + } + } + + @Serializable + data class LoginInput( + val trackerId: Int, + val callbackUrl: String? = null, + val username: String? = null, + val password: String? = null, + ) + + @Serializable + data class LogoutInput( + val trackerId: Int, + ) + + @Serializable + data class SearchInput( + val trackerId: Int, + val title: String, + ) + + @Serializable + data class UpdateInput( + val recordId: Int, + val status: Int? = null, + val lastChapterRead: Double? = null, + val scoreString: String? = null, + val startDate: Long? = null, + val finishDate: Long? = null, + val unbind: Boolean? = null, + ) +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/DeletableTrackService.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/DeletableTrackService.kt new file mode 100644 index 000000000..bca514a88 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/DeletableTrackService.kt @@ -0,0 +1,10 @@ +package suwayomi.tachidesk.manga.impl.track.tracker + +import suwayomi.tachidesk.manga.impl.track.tracker.model.Track + +/** + * For track services api that support deleting a manga entry for a user's list + */ +interface DeletableTrackService { + suspend fun delete(track: Track): Track +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/Tracker.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/Tracker.kt new file mode 100644 index 000000000..da84cee25 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/Tracker.kt @@ -0,0 +1,95 @@ +package suwayomi.tachidesk.manga.impl.track.tracker + +import eu.kanade.tachiyomi.network.NetworkHelper +import okhttp3.OkHttpClient +import suwayomi.tachidesk.manga.impl.track.tracker.model.Track +import suwayomi.tachidesk.manga.impl.track.tracker.model.TrackSearch +import uy.kohesive.injekt.injectLazy + +abstract class Tracker(val id: Int, val name: String) { + val trackPreferences = TrackerPreferences() + private val networkService: NetworkHelper by injectLazy() + + open val client: OkHttpClient + get() = networkService.client + + // Application and remote support for reading dates + open val supportsReadingDates: Boolean = false + + abstract fun getLogo(): String + + abstract fun getStatusList(): List + + abstract fun getStatus(status: Int): String? + + abstract fun getReadingStatus(): Int + + abstract fun getRereadingStatus(): Int + + abstract fun getCompletionStatus(): Int + + abstract fun getScoreList(): List + + open fun indexToScore(index: Int): Float { + return index.toFloat() + } + + abstract fun displayScore(track: Track): String + + abstract suspend fun update( + track: Track, + didReadChapter: Boolean = false, + ): Track + + abstract suspend fun bind( + track: Track, + hasReadChapters: Boolean = false, + ): Track + + abstract suspend fun search(query: String): List + + abstract suspend fun refresh(track: Track): Track + + open fun authUrl(): String? { + return null + } + + open suspend fun authCallback(url: String) {} + + abstract suspend fun login( + username: String, + password: String, + ) + + open fun logout() { + trackPreferences.setTrackCredentials(this, "", "") + } + + open val isLoggedIn: Boolean + get() { + return getUsername().isNotEmpty() && + getPassword().isNotEmpty() + } + + fun getUsername() = trackPreferences.getTrackUsername(this) ?: "" + + fun getPassword() = trackPreferences.getTrackPassword(this) ?: "" + + fun saveCredentials( + username: String, + password: String, + ) { + trackPreferences.setTrackCredentials(this, username, password) + } +} + +fun String.extractToken(key: String): String? { + val regex = "$key=(.*?)$".toRegex() + for (s in this.split("&")) { + val matchResult = regex.find(s) + if (matchResult?.groups?.get(1) != null) { + return matchResult.groups[1]!!.value + } + } + return null +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/TrackerManager.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/TrackerManager.kt new file mode 100644 index 000000000..cea4a182e --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/TrackerManager.kt @@ -0,0 +1,32 @@ +package suwayomi.tachidesk.manga.impl.track.tracker + +import suwayomi.tachidesk.manga.impl.track.tracker.anilist.Anilist +import suwayomi.tachidesk.manga.impl.track.tracker.myanimelist.MyAnimeList + +object TrackerManager { + const val MYANIMELIST = 1 + const val ANILIST = 2 + const val KITSU = 3 + const val SHIKIMORI = 4 + const val BANGUMI = 5 + const val KOMGA = 6 + const val MANGA_UPDATES = 7 + const val KAVITA = 8 + const val SUWAYOMI = 9 + + val myAnimeList = MyAnimeList(MYANIMELIST) + val aniList = Anilist(ANILIST) +// val kitsu = Kitsu(KITSU) +// val shikimori = Shikimori(SHIKIMORI) +// val bangumi = Bangumi(BANGUMI) +// val komga = Komga(KOMGA) +// val mangaUpdates = MangaUpdates(MANGA_UPDATES) +// val kavita = Kavita(context, KAVITA) +// val suwayomi = Suwayomi(SUWAYOMI) + + val services: List = listOf(myAnimeList, aniList) + + fun getTracker(id: Int) = services.find { it.id == id } + + fun hasLoggedTracker() = services.any { it.isLoggedIn } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/TrackerPreferences.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/TrackerPreferences.kt new file mode 100644 index 000000000..c2a7b4de0 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/TrackerPreferences.kt @@ -0,0 +1,69 @@ +package suwayomi.tachidesk.manga.impl.track.tracker + +import android.app.Application +import android.content.Context +import mu.KotlinLogging +import suwayomi.tachidesk.manga.impl.track.tracker.anilist.Anilist +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +class TrackerPreferences { + private val preferenceStore = + Injekt.get().getSharedPreferences("tracker", Context.MODE_PRIVATE) + private val logger = KotlinLogging.logger {} + + fun getTrackUsername(sync: Tracker) = preferenceStore.getString(trackUsername(sync.id), "") + + fun getTrackPassword(sync: Tracker) = preferenceStore.getString(trackPassword(sync.id), "") + + fun setTrackCredentials( + sync: Tracker, + username: String, + password: String, + ) { + logger.debug { "setTrackCredentials: id=${sync.id} username=$username" } + preferenceStore.edit() + .putString(trackUsername(sync.id), username) + .putString(trackPassword(sync.id), password) + .apply() + } + + fun getTrackToken(sync: Tracker) = preferenceStore.getString(trackToken(sync.id), "") + + fun setTrackToken( + sync: Tracker, + token: String?, + ) { + logger.debug { "setTrackToken: id=${sync.id} token=$token" } + if (token == null) { + preferenceStore.edit() + .remove(trackToken(sync.id)) + .apply() + } else { + preferenceStore.edit() + .putString(trackToken(sync.id), token) + .apply() + } + } + + fun getScoreType(sync: Tracker) = preferenceStore.getString(scoreType(sync.id), Anilist.POINT_10) + + fun setScoreType( + sync: Tracker, + scoreType: String, + ) = preferenceStore.edit() + .putString(scoreType(sync.id), scoreType) + .apply() + + fun autoUpdateTrack() = preferenceStore.getBoolean("pref_auto_update_manga_sync_key", true) + + companion object { + fun trackUsername(syncId: Int) = "pref_mangasync_username_$syncId" + + private fun trackPassword(syncId: Int) = "pref_mangasync_password_$syncId" + + private fun trackToken(syncId: Int) = "track_token_$syncId" + + private fun scoreType(syncId: Int) = "score_type_$syncId" + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/anilist/Anilist.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/anilist/Anilist.kt new file mode 100644 index 000000000..04f6b627b --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/anilist/Anilist.kt @@ -0,0 +1,251 @@ +package suwayomi.tachidesk.manga.impl.track.tracker.anilist + +import android.annotation.StringRes +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import mu.KotlinLogging +import suwayomi.tachidesk.manga.impl.track.tracker.DeletableTrackService +import suwayomi.tachidesk.manga.impl.track.tracker.Tracker +import suwayomi.tachidesk.manga.impl.track.tracker.extractToken +import suwayomi.tachidesk.manga.impl.track.tracker.model.Track +import suwayomi.tachidesk.manga.impl.track.tracker.model.TrackSearch +import uy.kohesive.injekt.injectLazy +import java.io.IOException + +class Anilist(id: Int) : Tracker(id, "AniList"), DeletableTrackService { + companion object { + const val READING = 1 + const val COMPLETED = 2 + const val ON_HOLD = 3 + const val DROPPED = 4 + const val PLAN_TO_READ = 5 + const val REREADING = 6 + + const val POINT_100 = "POINT_100" + const val POINT_10 = "POINT_10" + const val POINT_10_DECIMAL = "POINT_10_DECIMAL" + const val POINT_5 = "POINT_5" + const val POINT_3 = "POINT_3" + } + + private val json: Json by injectLazy() + + private val interceptor by lazy { AnilistInterceptor(this, getPassword()) } + + private val api by lazy { AnilistApi(client, interceptor) } + + override val supportsReadingDates: Boolean = true + + private val logger = KotlinLogging.logger {} + + override fun getLogo(): String { + return "/static/tracker/anilist.png" + } + + override fun getStatusList(): List { + return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLAN_TO_READ, REREADING) + } + + @StringRes + override fun getStatus(status: Int): String? = + when (status) { + READING -> "reading" + PLAN_TO_READ -> "plan_to_read" + COMPLETED -> "completed" + ON_HOLD -> "on_hold" + DROPPED -> "dropped" + REREADING -> "repeating" + else -> null + } + + override fun getReadingStatus(): Int = READING + + override fun getRereadingStatus(): Int = REREADING + + override fun getCompletionStatus(): Int = COMPLETED + + override fun getScoreList(): List { + return when (trackPreferences.getScoreType(this)) { + // 10 point + POINT_10 -> IntRange(0, 10).map(Int::toString) + // 100 point + POINT_100 -> IntRange(0, 100).map(Int::toString) + // 5 stars + POINT_5 -> IntRange(0, 5).map { "$it ★" } + // Smiley + POINT_3 -> listOf("-", "😦", "😐", "😊") + // 10 point decimal + POINT_10_DECIMAL -> IntRange(0, 100).map { (it / 10f).toString() } + else -> throw Exception("Unknown score type") + } + } + + override fun indexToScore(index: Int): Float { + return when (trackPreferences.getScoreType(this)) { + // 10 point + POINT_10 -> index * 10f + // 100 point + POINT_100 -> index.toFloat() + // 5 stars + POINT_5 -> + when (index) { + 0 -> 0f + else -> index * 20f - 10f + } + // Smiley + POINT_3 -> + when (index) { + 0 -> 0f + else -> index * 25f + 10f + } + // 10 point decimal + POINT_10_DECIMAL -> index.toFloat() + else -> throw Exception("Unknown score type") + } + } + + override fun displayScore(track: Track): String { + val score = track.score + return when (val type = trackPreferences.getScoreType(this)) { + POINT_5 -> + when (score) { + 0f -> "0 ★" + else -> "${((score + 10) / 20).toInt()} ★" + } + POINT_3 -> + when { + score == 0f -> "0" + score <= 35 -> "😦" + score <= 60 -> "😐" + else -> "😊" + } + else -> track.toAnilistScore(type) + } + } + + private suspend fun add(track: Track): Track { + return api.addLibManga(track) + } + + override suspend fun update( + track: Track, + didReadChapter: Boolean, + ): Track { + // If user was using API v1 fetch library_id + if (track.library_id == null || track.library_id!! == 0L) { + val libManga = + api.findLibManga(track, getUsername().toInt()) + ?: throw Exception("$track not found on user library") + track.library_id = libManga.library_id + } + + if (track.status != COMPLETED) { + if (didReadChapter) { + if (track.last_chapter_read.toInt() == track.total_chapters && track.total_chapters > 0) { + track.status = COMPLETED + track.finished_reading_date = System.currentTimeMillis() + } else if (track.status != REREADING) { + track.status = READING + if (track.last_chapter_read == 1F) { + track.started_reading_date = System.currentTimeMillis() + } + } + } + } + + return api.updateLibManga(track) + } + + override suspend fun delete(track: Track): Track { + if (track.library_id == null || track.library_id!! == 0L) { + val libManga = api.findLibManga(track, getUsername().toInt()) ?: return track + track.library_id = libManga.library_id + } + + return api.deleteLibManga(track) + } + + override suspend fun bind( + track: Track, + hasReadChapters: Boolean, + ): Track { + val remoteTrack = api.findLibManga(track, getUsername().toInt()) + return if (remoteTrack != null) { + track.copyPersonalFrom(remoteTrack) + track.library_id = remoteTrack.library_id + + if (track.status != COMPLETED) { + val isRereading = track.status == REREADING + track.status = if (isRereading.not() && hasReadChapters) READING else track.status + } + + update(track) + } else { + // Set default fields if it's not found in the list + track.status = if (hasReadChapters) READING else PLAN_TO_READ + track.score = 0F + add(track) + } + } + + override suspend fun search(query: String): List { + return api.search(query) + } + + override suspend fun refresh(track: Track): Track { + val remoteTrack = api.getLibManga(track, getUsername().toInt()) + track.copyPersonalFrom(remoteTrack) + track.title = remoteTrack.title + track.total_chapters = remoteTrack.total_chapters + return track + } + + override fun authUrl(): String { + return AnilistApi.authUrl().toString() + } + + override suspend fun authCallback(url: String) { + val token = url.extractToken("access_token") ?: throw IOException("cannot find token") + login(token) + } + + override suspend fun login( + username: String, + password: String, + ) = login(password) + + private suspend fun login(token: String) { + try { + logger.debug { "login $token" } + val oauth = api.createOAuth(token) + interceptor.setAuth(oauth) + val (username, scoreType) = api.getCurrentUser() + trackPreferences.setScoreType(this, scoreType) + saveCredentials(username.toString(), oauth.access_token) + } catch (e: Throwable) { + logger.error(e) { "oauth err" } + logout() + throw e + } + } + + override fun logout() { + super.logout() + trackPreferences.setTrackToken(this, null) + interceptor.setAuth(null) + } + + fun saveOAuth(oAuth: OAuth?) { + trackPreferences.setTrackToken(this, json.encodeToString(oAuth)) + } + + fun loadOAuth(): OAuth? { + return try { + json.decodeFromString(trackPreferences.getTrackToken(this)!!) + } catch (e: Exception) { + logger.error(e) { "loadOAuth err" } + null + } + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/anilist/AnilistApi.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/anilist/AnilistApi.kt new file mode 100644 index 000000000..d08ba5cea --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/anilist/AnilistApi.kt @@ -0,0 +1,399 @@ +package suwayomi.tachidesk.manga.impl.track.tracker.anilist + +import android.net.Uri +import androidx.core.net.toUri +import eu.kanade.tachiyomi.network.POST +import eu.kanade.tachiyomi.network.awaitSuccess +import eu.kanade.tachiyomi.network.interceptor.rateLimit +import eu.kanade.tachiyomi.network.jsonMime +import eu.kanade.tachiyomi.network.parseAs +import eu.kanade.tachiyomi.util.lang.withIOContext +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.int +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.long +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonObject +import okhttp3.OkHttpClient +import okhttp3.RequestBody.Companion.toRequestBody +import suwayomi.tachidesk.manga.impl.track.tracker.model.Track +import suwayomi.tachidesk.manga.impl.track.tracker.model.TrackSearch +import uy.kohesive.injekt.injectLazy +import java.util.Calendar +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.minutes + +class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) { + private val json: Json by injectLazy() + + private val authClient = + client.newBuilder() + .addInterceptor(interceptor) + .rateLimit(permits = 85, period = 1.minutes) + .build() + + suspend fun addLibManga(track: Track): Track { + return withIOContext { + val query = + """ + |mutation AddManga(${'$'}mangaId: Int, ${'$'}progress: Int, ${'$'}status: MediaListStatus) { + |SaveMediaListEntry (mediaId: ${'$'}mangaId, progress: ${'$'}progress, status: ${'$'}status) { + | id + | status + |} + |} + | + """.trimMargin() + val payload = + buildJsonObject { + put("query", query) + putJsonObject("variables") { + put("mangaId", track.media_id) + put("progress", track.last_chapter_read.toInt()) + put("status", track.toAnilistStatus()) + } + } + with(json) { + authClient.newCall( + POST( + API_URL, + body = payload.toString().toRequestBody(jsonMime), + ), + ) + .awaitSuccess() + .parseAs() + .let { + track.library_id = + it["data"]!!.jsonObject["SaveMediaListEntry"]!!.jsonObject["id"]!!.jsonPrimitive.long + track + } + } + } + } + + suspend fun updateLibManga(track: Track): Track { + return withIOContext { + val query = + """ + |mutation UpdateManga( + |${'$'}listId: Int, ${'$'}progress: Int, ${'$'}status: MediaListStatus, + |${'$'}score: Int, ${'$'}startedAt: FuzzyDateInput, ${'$'}completedAt: FuzzyDateInput + |) { + |SaveMediaListEntry( + |id: ${'$'}listId, progress: ${'$'}progress, status: ${'$'}status, + |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.toAnilistStatus()) + put("score", track.score.toInt()) + put("startedAt", createDate(track.started_reading_date)) + put("completedAt", createDate(track.finished_reading_date)) + } + } + authClient.newCall(POST(API_URL, body = payload.toString().toRequestBody(jsonMime))) + .awaitSuccess() + track + } + } + + suspend fun deleteLibManga(track: Track): Track { + return withIOContext { + val query = + """ + |mutation DeleteManga(${'$'}listId: Int) { + |DeleteMediaListEntry(id: ${'$'}listId) { + |deleted + |} + |} + | + """.trimMargin() + val payload = + buildJsonObject { + put("query", query) + putJsonObject("variables") { + put("listId", track.library_id) + } + } + authClient.newCall(POST(API_URL, body = payload.toString().toRequestBody(jsonMime))) + .awaitSuccess() + track + } + } + + suspend fun search(search: String): List { + return withIOContext { + val query = + """ + |query Search(${'$'}query: String) { + |Page (perPage: 50) { + |media(search: ${'$'}query, type: MANGA, format_not_in: [NOVEL]) { + |id + |title { + |userPreferred + |} + |coverImage { + |large + |} + |format + |status + |chapters + |description + |startDate { + |year + |month + |day + |} + |} + |} + |} + | + """.trimMargin() + val payload = + buildJsonObject { + put("query", query) + putJsonObject("variables") { + put("query", search) + } + } + with(json) { + authClient.newCall( + POST( + API_URL, + body = payload.toString().toRequestBody(jsonMime), + ), + ) + .awaitSuccess() + .parseAs() + .let { response -> + val data = response["data"]!!.jsonObject + val page = data["Page"]!!.jsonObject + val media = page["media"]!!.jsonArray + val entries = media.map { jsonToALManga(it.jsonObject) } + entries.map { it.toTrack() } + } + } + } + } + + 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 + |startedAt { + |year + |month + |day + |} + |completedAt { + |year + |month + |day + |} + |media { + |id + |title { + |userPreferred + |} + |coverImage { + |large + |} + |format + |status + |chapters + |description + |startDate { + |year + |month + |day + |} + |} + |} + |} + |} + | + """.trimMargin() + val payload = + buildJsonObject { + put("query", query) + putJsonObject("variables") { + put("id", userid) + put("manga_id", track.media_id) + } + } + with(json) { + authClient.newCall( + POST( + API_URL, + body = payload.toString().toRequestBody(jsonMime), + ), + ) + .awaitSuccess() + .parseAs() + .let { response -> + val data = response["data"]!!.jsonObject + val page = data["Page"]!!.jsonObject + val media = page["mediaList"]!!.jsonArray + val entries = media.map { jsonToALUserManga(it.jsonObject) } + entries.firstOrNull()?.toTrack() + } + } + } + } + + suspend fun getLibManga( + track: Track, + userid: Int, + ): Track { + return findLibManga(track, userid) ?: throw Exception("Could not find manga") + } + + fun createOAuth(token: String): OAuth { + return OAuth(token, "Bearer", System.currentTimeMillis() + 365.days.inWholeMilliseconds, 365.days.inWholeMilliseconds) + } + + suspend fun getCurrentUser(): Pair { + return withIOContext { + val query = + """ + |query User { + |Viewer { + |id + |mediaListOptions { + |scoreFormat + |} + |} + |} + | + """.trimMargin() + val payload = + buildJsonObject { + put("query", query) + } + with(json) { + authClient.newCall( + POST( + API_URL, + body = payload.toString().toRequestBody(jsonMime), + ), + ) + .awaitSuccess() + .parseAs() + .let { + val data = it["data"]!!.jsonObject + val viewer = data["Viewer"]!!.jsonObject + Pair( + viewer["id"]!!.jsonPrimitive.int, + viewer["mediaListOptions"]!!.jsonObject["scoreFormat"]!!.jsonPrimitive.content, + ) + } + } + } + } + + private fun jsonToALManga(struct: JsonObject): ALManga { + return ALManga( + struct["id"]!!.jsonPrimitive.long, + struct["title"]!!.jsonObject["userPreferred"]!!.jsonPrimitive.content, + struct["coverImage"]!!.jsonObject["large"]!!.jsonPrimitive.content, + struct["description"]!!.jsonPrimitive.contentOrNull, + struct["format"]!!.jsonPrimitive.content.replace("_", "-"), + struct["status"]!!.jsonPrimitive.contentOrNull ?: "", + parseDate(struct, "startDate"), + struct["chapters"]!!.jsonPrimitive.intOrNull ?: 0, + ) + } + + private fun jsonToALUserManga(struct: JsonObject): ALUserManga { + return ALUserManga( + struct["id"]!!.jsonPrimitive.long, + struct["status"]!!.jsonPrimitive.content, + struct["scoreRaw"]!!.jsonPrimitive.int, + struct["progress"]!!.jsonPrimitive.int, + parseDate(struct, "startedAt"), + parseDate(struct, "completedAt"), + jsonToALManga(struct["media"]!!.jsonObject), + ) + } + + private fun parseDate( + struct: JsonObject, + dateKey: String, + ): Long { + return try { + val date = Calendar.getInstance() + date.set( + struct[dateKey]!!.jsonObject["year"]!!.jsonPrimitive.int, + struct[dateKey]!!.jsonObject["month"]!!.jsonPrimitive.int - 1, + struct[dateKey]!!.jsonObject["day"]!!.jsonPrimitive.int, + ) + date.timeInMillis + } catch (_: Exception) { + 0L + } + } + + private fun createDate(dateValue: Long): JsonObject { + if (dateValue == 0L) { + return buildJsonObject { + put("year", JsonNull) + put("month", JsonNull) + put("day", JsonNull) + } + } + + val calendar = Calendar.getInstance() + calendar.timeInMillis = dateValue + return buildJsonObject { + put("year", calendar.get(Calendar.YEAR)) + put("month", calendar.get(Calendar.MONTH) + 1) + put("day", calendar.get(Calendar.DAY_OF_MONTH)) + } + } + + companion object { + // TODO: need to replace it with official account, and set callback url to suwayomi://oauth/anilist + private const val CLIENT_ID = "14929" + 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() + .appendQueryParameter("client_id", CLIENT_ID) + .appendQueryParameter("response_type", "token") + .build() + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/anilist/AnilistInterceptor.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/anilist/AnilistInterceptor.kt new file mode 100644 index 000000000..aa4ff0025 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/anilist/AnilistInterceptor.kt @@ -0,0 +1,57 @@ +package suwayomi.tachidesk.manga.impl.track.tracker.anilist + +import okhttp3.Interceptor +import okhttp3.Response +import java.io.IOException + +class AnilistInterceptor(val anilist: Anilist, private var token: String?) : Interceptor { + /** + * OAuth object used for authenticated requests. + * + * Anilist returns the date without milliseconds. We fix that and make the token expire 1 minute + * before its original expiration date. + */ + private var oauth: OAuth? = null + set(value) { + field = value?.copy(expires = value.expires * 1000 - 60 * 1000) + } + + override fun intercept(chain: Interceptor.Chain): Response { + val originalRequest = chain.request() + + if (token.isNullOrEmpty()) { + throw Exception("Not authenticated with Anilist") + } + if (oauth == null) { + oauth = anilist.loadOAuth() + } + // Refresh access token if null or expired. + if (oauth!!.isExpired()) { + anilist.logout() + throw IOException("Token expired") + } + + // Throw on null auth. + if (oauth == null) { + throw IOException("No authentication token") + } + + // Add the authorization header to the original request. + val authRequest = + originalRequest.newBuilder() + .addHeader("Authorization", "Bearer ${oauth!!.access_token}") + .build() + + return chain.proceed(authRequest) + } + + /** + * Called when the user authenticates with Anilist for the first time. Sets the refresh token + * and the oauth object. + */ + fun setAuth(oauth: OAuth?) { + token = oauth?.access_token + this.oauth = oauth + anilist.saveOAuth(oauth) + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/anilist/AnilistModels.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/anilist/AnilistModels.kt new file mode 100644 index 000000000..5c5366af5 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/anilist/AnilistModels.kt @@ -0,0 +1,124 @@ +package suwayomi.tachidesk.manga.impl.track.tracker.anilist + +import kotlinx.serialization.Serializable +import suwayomi.tachidesk.manga.impl.track.tracker.TrackerManager +import suwayomi.tachidesk.manga.impl.track.tracker.model.Track +import suwayomi.tachidesk.manga.impl.track.tracker.model.TrackSearch +import java.text.SimpleDateFormat +import java.util.Locale + +data class ALManga( + val media_id: Long, + val title_user_pref: String, + val image_url_lge: String, + val description: String?, + val format: String, + val publishing_status: String, + val start_date_fuzzy: Long, + val total_chapters: Int, +) { + fun toTrack() = + TrackSearch.create(TrackerManager.ANILIST).apply { + media_id = this@ALManga.media_id + title = title_user_pref + total_chapters = this@ALManga.total_chapters + cover_url = image_url_lge + summary = description ?: "" + tracking_url = AnilistApi.mangaUrl(media_id) + publishing_status = this@ALManga.publishing_status + publishing_type = format + if (start_date_fuzzy != 0L) { + start_date = + try { + val outputDf = SimpleDateFormat("yyyy-MM-dd", Locale.US) + outputDf.format(start_date_fuzzy) + } catch (e: Exception) { + "" + } + } + } +} + +data class ALUserManga( + val library_id: Long, + val list_status: String, + val score_raw: Int, + val chapters_read: Int, + val start_date_fuzzy: Long, + val completed_date_fuzzy: Long, + val manga: ALManga, +) { + fun toTrack() = + Track.create(TrackerManager.ANILIST).apply { + media_id = manga.media_id + title = manga.title_user_pref + status = toTrackStatus() + score = score_raw.toFloat() + started_reading_date = start_date_fuzzy + finished_reading_date = completed_date_fuzzy + last_chapter_read = chapters_read.toFloat() + library_id = this@ALUserManga.library_id + total_chapters = manga.total_chapters + } + + fun toTrackStatus() = + when (list_status) { + "CURRENT" -> Anilist.READING + "COMPLETED" -> Anilist.COMPLETED + "PAUSED" -> Anilist.ON_HOLD + "DROPPED" -> Anilist.DROPPED + "PLANNING" -> Anilist.PLAN_TO_READ + "REPEATING" -> Anilist.REREADING + else -> throw NotImplementedError("Unknown status: $list_status") + } +} + +@Serializable +data class OAuth( + val access_token: String, + val token_type: String, + val expires: Long, + val expires_in: Long, +) + +fun OAuth.isExpired() = System.currentTimeMillis() > expires + +fun Track.toAnilistStatus() = + when (status) { + Anilist.READING -> "CURRENT" + Anilist.COMPLETED -> "COMPLETED" + Anilist.ON_HOLD -> "PAUSED" + Anilist.DROPPED -> "DROPPED" + Anilist.PLAN_TO_READ -> "PLANNING" + Anilist.REREADING -> "REPEATING" + else -> throw NotImplementedError("Unknown status: $status") + } + +fun Track.toAnilistScore(scoreType: String?): String = + when (scoreType) { +// 10 point + "POINT_10" -> (score.toInt() / 10).toString() +// 100 point + "POINT_100" -> score.toInt().toString() +// 5 stars + "POINT_5" -> + when { + score == 0f -> "0" + score < 30 -> "1" + score < 50 -> "2" + score < 70 -> "3" + score < 90 -> "4" + else -> "5" + } +// Smiley + "POINT_3" -> + when { + score == 0f -> "0" + score <= 35 -> ":(" + score <= 60 -> ":|" + else -> ":)" + } +// 10 point decimal + "POINT_10_DECIMAL" -> (score / 10).toString() + else -> throw NotImplementedError("Unknown score type") + } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/model/Track.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/model/Track.kt new file mode 100644 index 000000000..4dedb0fd6 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/model/Track.kt @@ -0,0 +1,48 @@ +@file:Suppress("ktlint:standard:property-naming") + +package suwayomi.tachidesk.manga.impl.track.tracker.model + +import java.io.Serializable + +interface Track : Serializable { + var id: Int? + + var manga_id: Int + + var sync_id: Int + + var media_id: Long + + var library_id: Long? + + var title: String + + var last_chapter_read: Float + + var total_chapters: Int + + var score: Float + + var status: Int + + var started_reading_date: Long + + var finished_reading_date: Long + + var tracking_url: String + + fun copyPersonalFrom(other: Track) { + last_chapter_read = other.last_chapter_read + score = other.score + status = other.status + started_reading_date = other.started_reading_date + finished_reading_date = other.finished_reading_date + } + + companion object { + fun create(serviceId: Int): Track = + TrackImpl().apply { + sync_id = serviceId + } + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/model/TrackConvertor.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/model/TrackConvertor.kt new file mode 100644 index 000000000..144337cc5 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/model/TrackConvertor.kt @@ -0,0 +1,48 @@ +package suwayomi.tachidesk.manga.impl.track.tracker.model + +import org.jetbrains.exposed.sql.ResultRow +import suwayomi.tachidesk.manga.model.dataclass.TrackRecordDataClass +import suwayomi.tachidesk.manga.model.dataclass.TrackSearchDataClass +import suwayomi.tachidesk.manga.model.table.TrackRecordTable + +fun TrackSearchDataClass.toTrack(mangaId: Int): Track = + Track.create(syncId).also { + it.manga_id = mangaId + it.media_id = mediaId + it.title = title + it.total_chapters = totalChapters + it.tracking_url = trackingUrl + } + +fun ResultRow.toTrackRecordDataClass(): TrackRecordDataClass = + TrackRecordDataClass( + id = this[TrackRecordTable.id].value, + mangaId = this[TrackRecordTable.mangaId].value, + syncId = this[TrackRecordTable.syncId], + remoteId = this[TrackRecordTable.remoteId], + libraryId = this[TrackRecordTable.libraryId], + title = this[TrackRecordTable.title], + lastChapterRead = this[TrackRecordTable.lastChapterRead], + totalChapters = this[TrackRecordTable.totalChapters], + status = this[TrackRecordTable.status], + score = this[TrackRecordTable.score], + remoteUrl = this[TrackRecordTable.remoteUrl], + startDate = this[TrackRecordTable.startDate], + finishDate = this[TrackRecordTable.finishDate], + ) + +fun ResultRow.toTrack(): Track = + Track.create(this[TrackRecordTable.syncId]).also { + it.id = this[TrackRecordTable.id].value + it.manga_id = this[TrackRecordTable.mangaId].value + it.media_id = this[TrackRecordTable.remoteId] + it.library_id = this[TrackRecordTable.libraryId] + it.title = this[TrackRecordTable.title] + it.last_chapter_read = this[TrackRecordTable.lastChapterRead].toFloat() + it.total_chapters = this[TrackRecordTable.totalChapters] + it.status = this[TrackRecordTable.status] + it.score = this[TrackRecordTable.score].toFloat() + it.tracking_url = this[TrackRecordTable.remoteUrl] + it.started_reading_date = this[TrackRecordTable.startDate] + it.finished_reading_date = this[TrackRecordTable.finishDate] + } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/model/TrackImpl.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/model/TrackImpl.kt new file mode 100644 index 000000000..96bff7f35 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/model/TrackImpl.kt @@ -0,0 +1,31 @@ +@file:Suppress("ktlint:standard:property-naming") + +package suwayomi.tachidesk.manga.impl.track.tracker.model + +class TrackImpl : Track { + override var id: Int? = null + + override var manga_id: Int = 0 + + override var sync_id: Int = 0 + + override var media_id: Long = 0 + + override var library_id: Long? = null + + override lateinit var title: String + + override var last_chapter_read: Float = 0F + + override var total_chapters: Int = 0 + + override var score: Float = 0f + + override var status: Int = 0 + + override var started_reading_date: Long = 0 + + override var finished_reading_date: Long = 0 + + override var tracking_url: String = "" +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/model/TrackSearch.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/model/TrackSearch.kt new file mode 100644 index 000000000..38a2d1cc8 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/model/TrackSearch.kt @@ -0,0 +1,50 @@ +@file:Suppress("ktlint:standard:property-naming") + +package suwayomi.tachidesk.manga.impl.track.tracker.model + +class TrackSearch { + var sync_id: Int = 0 + + var media_id: Long = 0 + + lateinit var title: String + + var total_chapters: Int = 0 + + lateinit var tracking_url: String + + var cover_url: String = "" + + var summary: String = "" + + var publishing_status: String = "" + + var publishing_type: String = "" + + var start_date: String = "" + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as TrackSearch + + if (sync_id != other.sync_id) return false + if (media_id != other.media_id) return false + + return true + } + + override fun hashCode(): Int { + var result = sync_id.hashCode() + result = 31 * result + media_id.hashCode() + return result + } + + companion object { + fun create(serviceId: Int): TrackSearch = + TrackSearch().apply { + sync_id = serviceId + } + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/myanimelist/MyAnimeList.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/myanimelist/MyAnimeList.kt new file mode 100644 index 000000000..0df53c652 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/myanimelist/MyAnimeList.kt @@ -0,0 +1,190 @@ +package suwayomi.tachidesk.manga.impl.track.tracker.myanimelist + +import android.annotation.StringRes +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import mu.KotlinLogging +import suwayomi.tachidesk.manga.impl.track.tracker.DeletableTrackService +import suwayomi.tachidesk.manga.impl.track.tracker.Tracker +import suwayomi.tachidesk.manga.impl.track.tracker.extractToken +import suwayomi.tachidesk.manga.impl.track.tracker.model.Track +import suwayomi.tachidesk.manga.impl.track.tracker.model.TrackSearch +import uy.kohesive.injekt.injectLazy +import java.io.IOException + +class MyAnimeList(id: Int) : Tracker(id, "MyAnimeList"), DeletableTrackService { + companion object { + const val READING = 1 + const val COMPLETED = 2 + const val ON_HOLD = 3 + const val DROPPED = 4 + const val PLAN_TO_READ = 6 + const val REREADING = 7 + + private const val SEARCH_ID_PREFIX = "id:" + private const val SEARCH_LIST_PREFIX = "my:" + } + + private val json: Json by injectLazy() + + private val interceptor by lazy { MyAnimeListInterceptor(this, getPassword()) } + private val api by lazy { MyAnimeListApi(client, interceptor) } + + override val supportsReadingDates: Boolean = true + + private val logger = KotlinLogging.logger {} + + override fun getLogo(): String { + return "/static/tracker/mal.png" + } + + override fun getStatusList(): List { + return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLAN_TO_READ, REREADING) + } + + @StringRes + override fun getStatus(status: Int): String? = + when (status) { + READING -> "reading" + PLAN_TO_READ -> "plan_to_read" + COMPLETED -> "completed" + ON_HOLD -> "on_hold" + DROPPED -> "dropped" + REREADING -> "repeating" + else -> null + } + + override fun getReadingStatus(): Int = READING + + override fun getRereadingStatus(): Int = REREADING + + override fun getCompletionStatus(): Int = COMPLETED + + override fun getScoreList(): List { + return IntRange(0, 10).map(Int::toString) + } + + override fun displayScore(track: Track): String { + return track.score.toInt().toString() + } + + private suspend fun add(track: Track): Track { + return api.updateItem(track) + } + + override suspend fun update( + track: Track, + didReadChapter: Boolean, + ): Track { + if (track.status != COMPLETED) { + if (didReadChapter) { + if (track.last_chapter_read.toInt() == track.total_chapters && track.total_chapters > 0) { + track.status = COMPLETED + track.finished_reading_date = System.currentTimeMillis() + } else if (track.status != REREADING) { + track.status = READING + if (track.last_chapter_read == 1F) { + track.started_reading_date = System.currentTimeMillis() + } + } + } + } + + return api.updateItem(track) + } + + override suspend fun delete(track: Track): Track { + return api.deleteItem(track) + } + + override suspend fun bind( + track: Track, + hasReadChapters: Boolean, + ): Track { + val remoteTrack = api.findListItem(track) + return if (remoteTrack != null) { + track.copyPersonalFrom(remoteTrack) + track.media_id = remoteTrack.media_id + + if (track.status != COMPLETED) { + val isRereading = track.status == REREADING + track.status = if (isRereading.not() && hasReadChapters) READING else track.status + } + + update(track) + } else { + // Set default fields if it's not found in the list + track.status = if (hasReadChapters) READING else PLAN_TO_READ + track.score = 0F + add(track) + } + } + + override suspend fun search(query: String): List { + if (query.startsWith(SEARCH_ID_PREFIX)) { + query.substringAfter(SEARCH_ID_PREFIX).toIntOrNull()?.let { id -> + return listOf(api.getMangaDetails(id)) + } + } + + if (query.startsWith(SEARCH_LIST_PREFIX)) { + query.substringAfter(SEARCH_LIST_PREFIX).let { title -> + return api.findListItems(title) + } + } + + return api.search(query) + } + + override suspend fun refresh(track: Track): Track { + return api.findListItem(track) ?: add(track) + } + + override fun authUrl(): String { + return MyAnimeListApi.authUrl().toString() + } + + override suspend fun authCallback(url: String) { + val code = url.extractToken("code") ?: throw IOException("cannot find token") + login(code) + } + + override suspend fun login( + username: String, + password: String, + ) = login(password) + + suspend fun login(authCode: String) { + try { + logger.debug { "login $authCode" } + val oauth = api.getAccessToken(authCode) + interceptor.setAuth(oauth) + val username = api.getCurrentUser() + saveCredentials(username, oauth.access_token) + } catch (e: Throwable) { + logger.error(e) { "oauth err" } + logout() + throw e + } + } + + override fun logout() { + super.logout() + trackPreferences.setTrackToken(this, null) + interceptor.setAuth(null) + } + + fun saveOAuth(oAuth: OAuth?) { + trackPreferences.setTrackToken(this, json.encodeToString(oAuth)) + } + + fun loadOAuth(): OAuth? { + return try { + json.decodeFromString(trackPreferences.getTrackToken(this)!!) + } catch (e: Exception) { + logger.error(e) { "loadOAuth err" } + null + } + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/myanimelist/MyAnimeListApi.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/myanimelist/MyAnimeListApi.kt new file mode 100644 index 000000000..2ad719ad6 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/myanimelist/MyAnimeListApi.kt @@ -0,0 +1,340 @@ +package suwayomi.tachidesk.manga.impl.track.tracker.myanimelist + +import android.net.Uri +import androidx.core.net.toUri +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.POST +import eu.kanade.tachiyomi.network.awaitSuccess +import eu.kanade.tachiyomi.network.parseAs +import eu.kanade.tachiyomi.util.PkceUtil +import eu.kanade.tachiyomi.util.lang.withIOContext +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.float +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.long +import okhttp3.FormBody +import okhttp3.Headers +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody +import suwayomi.tachidesk.manga.impl.track.tracker.TrackerManager +import suwayomi.tachidesk.manga.impl.track.tracker.model.Track +import suwayomi.tachidesk.manga.impl.track.tracker.model.TrackSearch +import uy.kohesive.injekt.injectLazy +import java.text.SimpleDateFormat +import java.util.Locale + +class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListInterceptor) { + private val json: Json by injectLazy() + + private val authClient = client.newBuilder().addInterceptor(interceptor).build() + + suspend fun getAccessToken(authCode: String): OAuth { + return withIOContext { + val formBody: RequestBody = + FormBody.Builder() + .add("client_id", CLIENT_ID) + .add("code", authCode) + .add("code_verifier", codeVerifier) + .add("grant_type", "authorization_code") + .build() + with(json) { + client.newCall(POST("$BASE_OAUTH_URL/token", body = formBody)) + .awaitSuccess() + .parseAs() + } + } + } + + suspend fun getCurrentUser(): String { + return withIOContext { + val request = + Request.Builder() + .url("$BASE_API_URL/users/@me") + .get() + .build() + with(json) { + authClient.newCall(request) + .awaitSuccess() + .parseAs() + .let { it["name"]!!.jsonPrimitive.content } + } + } + } + + suspend fun search(query: String): List { + return withIOContext { + val url = + "$BASE_API_URL/manga".toUri().buildUpon() + // MAL API throws a 400 when the query is over 64 characters... + .appendQueryParameter("q", query.take(64)) + .appendQueryParameter("nsfw", "true") + .build() + with(json) { + authClient.newCall(GET(url.toString())) + .awaitSuccess() + .parseAs() + .let { + it["data"]!!.jsonArray + .map { data -> data.jsonObject["node"]!!.jsonObject } + .map { node -> + val id = node["id"]!!.jsonPrimitive.int + async { getMangaDetails(id) } + } + .awaitAll() + .filter { trackSearch -> !trackSearch.publishing_type.contains("novel") } + } + } + } + } + + suspend fun getMangaDetails(id: Int): TrackSearch { + return withIOContext { + val url = + "$BASE_API_URL/manga".toUri().buildUpon() + .appendPath(id.toString()) + .appendQueryParameter("fields", "id,title,synopsis,num_chapters,main_picture,status,media_type,start_date") + .build() + with(json) { + authClient.newCall(GET(url.toString())) + .awaitSuccess() + .parseAs() + .let { + val obj = it.jsonObject + TrackSearch.create(TrackerManager.MYANIMELIST).apply { + media_id = obj["id"]!!.jsonPrimitive.long + title = obj["title"]!!.jsonPrimitive.content + summary = obj["synopsis"]?.jsonPrimitive?.content ?: "" + total_chapters = obj["num_chapters"]!!.jsonPrimitive.int + cover_url = + obj["main_picture"]?.jsonObject?.get("large")?.jsonPrimitive?.content + ?: "" + tracking_url = "https://myanimelist.net/manga/$media_id" + publishing_status = + obj["status"]!!.jsonPrimitive.content.replace("_", " ") + publishing_type = + obj["media_type"]!!.jsonPrimitive.content.replace("_", " ") + start_date = + try { + val outputDf = SimpleDateFormat("yyyy-MM-dd", Locale.US) + outputDf.format(obj["start_date"]!!) + } catch (e: Exception) { + "" + } + } + } + } + } + } + + suspend fun updateItem(track: Track): Track { + return withIOContext { + val formBodyBuilder = + FormBody.Builder() + .add("status", track.toMyAnimeListStatus() ?: "reading") + .add("is_rereading", (track.status == MyAnimeList.REREADING).toString()) + .add("score", track.score.toString()) + .add("num_chapters_read", track.last_chapter_read.toInt().toString()) + convertToIsoDate(track.started_reading_date)?.let { + formBodyBuilder.add("start_date", it) + } + convertToIsoDate(track.finished_reading_date)?.let { + formBodyBuilder.add("finish_date", it) + } + + val request = + Request.Builder() + .url(mangaUrl(track.media_id).toString()) + .put(formBodyBuilder.build()) + .build() + with(json) { + authClient.newCall(request) + .awaitSuccess() + .parseAs() + .let { parseMangaItem(it, track) } + } + } + } + + suspend fun deleteItem(track: Track): Track { + return withIOContext { + val request = + Request.Builder() + .url(mangaUrl(track.media_id).toString()) + .delete() + .build() + with(json) { + authClient.newCall(request) + .awaitSuccess() + track + } + } + } + + suspend fun findListItem(track: Track): Track? { + return withIOContext { + val uri = + "$BASE_API_URL/manga".toUri().buildUpon() + .appendPath(track.media_id.toString()) + .appendQueryParameter("fields", "num_chapters,my_list_status{start_date,finish_date}") + .build() + with(json) { + authClient.newCall(GET(uri.toString())) + .awaitSuccess() + .parseAs() + .let { obj -> + track.total_chapters = obj["num_chapters"]!!.jsonPrimitive.int + obj.jsonObject["my_list_status"]?.jsonObject?.let { + parseMangaItem(it, track) + } + } + } + } + } + + suspend fun findListItems( + query: String, + offset: Int = 0, + ): List { + return withIOContext { + val json = getListPage(offset) + val obj = json.jsonObject + + val matches = + obj["data"]!!.jsonArray + .filter { + it.jsonObject["node"]!!.jsonObject["title"]!!.jsonPrimitive.content.contains( + query, + ignoreCase = true, + ) + } + .map { + val id = it.jsonObject["node"]!!.jsonObject["id"]!!.jsonPrimitive.int + async { getMangaDetails(id) } + } + .awaitAll() + + // Check next page if there's more + if (!obj["paging"]!!.jsonObject["next"]?.jsonPrimitive?.contentOrNull.isNullOrBlank()) { + matches + findListItems(query, offset + LIST_PAGINATION_AMOUNT) + } else { + matches + } + } + } + + private suspend fun getListPage(offset: Int): JsonObject { + return withIOContext { + val urlBuilder = + "$BASE_API_URL/users/@me/mangalist".toUri().buildUpon() + .appendQueryParameter("fields", "list_status{start_date,finish_date}") + .appendQueryParameter("limit", LIST_PAGINATION_AMOUNT.toString()) + if (offset > 0) { + urlBuilder.appendQueryParameter("offset", offset.toString()) + } + + val request = + Request.Builder() + .url(urlBuilder.build().toString()) + .get() + .build() + with(json) { + authClient.newCall(request) + .awaitSuccess() + .parseAs() + } + } + } + + private fun parseMangaItem( + response: JsonObject, + track: Track, + ): Track { + val obj = response.jsonObject + return track.apply { + val isRereading = obj["is_rereading"]!!.jsonPrimitive.boolean + status = if (isRereading) MyAnimeList.REREADING else getStatus(obj["status"]?.jsonPrimitive?.content) + last_chapter_read = obj["num_chapters_read"]!!.jsonPrimitive.float + score = obj["score"]!!.jsonPrimitive.int.toFloat() + obj["start_date"]?.let { + started_reading_date = parseDate(it.jsonPrimitive.content) + } + obj["finish_date"]?.let { + finished_reading_date = parseDate(it.jsonPrimitive.content) + } + } + } + + private fun parseDate(isoDate: String): Long { + return SimpleDateFormat("yyyy-MM-dd", Locale.US).parse(isoDate)?.time ?: 0L + } + + private fun convertToIsoDate(epochTime: Long): String? { + if (epochTime == 0L) { + return "" + } + return try { + val outputDf = SimpleDateFormat("yyyy-MM-dd", Locale.US) + outputDf.format(epochTime) + } catch (e: Exception) { + null + } + } + + companion object { + // TODO: need to replace it with official account, and set callback url to suwayomi://oauth/myanimelist + private const val CLIENT_ID = "d9f6f745798cc5b0895e6a274e4b530c" + + private const val BASE_OAUTH_URL = "https://myanimelist.net/v1/oauth2" + private const val BASE_API_URL = "https://api.myanimelist.net/v2" + + private const val LIST_PAGINATION_AMOUNT = 250 + + private var codeVerifier: String = "" + + fun authUrl(): Uri = + "$BASE_OAUTH_URL/authorize".toUri().buildUpon() + .appendQueryParameter("client_id", CLIENT_ID) + .appendQueryParameter("code_challenge", getPkceChallengeCode()) + .appendQueryParameter("response_type", "code") + .build() + + fun mangaUrl(id: Long): Uri = + "$BASE_API_URL/manga".toUri().buildUpon() + .appendPath(id.toString()) + .appendPath("my_list_status") + .build() + + fun refreshTokenRequest(oauth: OAuth): Request { + val formBody: RequestBody = + FormBody.Builder() + .add("client_id", CLIENT_ID) + .add("refresh_token", oauth.refresh_token) + .add("grant_type", "refresh_token") + .build() + + // Add the Authorization header manually as this particular + // request is called by the interceptor itself so it doesn't reach + // the part where the token is added automatically. + val headers = + Headers.Builder() + .add("Authorization", "Bearer ${oauth.access_token}") + .build() + + return POST("$BASE_OAUTH_URL/token", body = formBody, headers = headers) + } + + private fun getPkceChallengeCode(): String { + codeVerifier = PkceUtil.generateCodeVerifier() + return codeVerifier + } + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/myanimelist/MyAnimeListInterceptor.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/myanimelist/MyAnimeListInterceptor.kt new file mode 100644 index 000000000..325626039 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/myanimelist/MyAnimeListInterceptor.kt @@ -0,0 +1,92 @@ +package suwayomi.tachidesk.manga.impl.track.tracker.myanimelist + +import eu.kanade.tachiyomi.network.parseAs +import kotlinx.serialization.json.Json +import okhttp3.Interceptor +import okhttp3.Response +import uy.kohesive.injekt.injectLazy +import java.io.IOException + +class MyAnimeListInterceptor(private val myanimelist: MyAnimeList, private var token: String?) : Interceptor { + private val json: Json by injectLazy() + + private var oauth: OAuth? = null + + override fun intercept(chain: Interceptor.Chain): Response { + val originalRequest = chain.request() + + if (token.isNullOrEmpty()) { + throw IOException("Not authenticated with MyAnimeList") + } + if (oauth == null) { + oauth = myanimelist.loadOAuth() + } + // Refresh access token if expired + if (oauth != null && oauth!!.isExpired()) { + setAuth(refreshToken(chain)) + } + + if (oauth == null) { + throw IOException("No authentication token") + } + + // Add the authorization header to the original request + val authRequest = + originalRequest.newBuilder() + .addHeader("Authorization", "Bearer ${oauth!!.access_token}") + .build() + + val response = chain.proceed(authRequest) + val tokenIsExpired = + response.headers["www-authenticate"] + ?.contains("The access token expired") ?: false + + // Retry the request once with a new token in case it was not already refreshed + // by the is expired check before. + if (response.code == 401 && tokenIsExpired) { + response.close() + + val newToken = refreshToken(chain) + setAuth(newToken) + + val newRequest = + originalRequest.newBuilder() + .addHeader("Authorization", "Bearer ${newToken.access_token}") + .build() + + return chain.proceed(newRequest) + } + + return response + } + + /** + * Called when the user authenticates with MyAnimeList for the first time. Sets the refresh token + * and the oauth object. + */ + fun setAuth(oauth: OAuth?) { + token = oauth?.access_token + this.oauth = oauth + myanimelist.saveOAuth(oauth) + } + + private fun refreshToken(chain: Interceptor.Chain): OAuth { + val newOauth = + runCatching { + val oauthResponse = chain.proceed(MyAnimeListApi.refreshTokenRequest(oauth!!)) + + if (oauthResponse.isSuccessful) { + with(json) { oauthResponse.parseAs() } + } else { + oauthResponse.close() + null + } + } + + if (newOauth.getOrNull() == null) { + throw IOException("Failed to refresh the access token") + } + + return newOauth.getOrNull()!! + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/myanimelist/MyAnimeListModels.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/myanimelist/MyAnimeListModels.kt new file mode 100644 index 000000000..caf084353 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/myanimelist/MyAnimeListModels.kt @@ -0,0 +1,36 @@ +package suwayomi.tachidesk.manga.impl.track.tracker.myanimelist + +import kotlinx.serialization.Serializable +import suwayomi.tachidesk.manga.impl.track.tracker.model.Track + +@Serializable +data class OAuth( + val refresh_token: String, + val access_token: String, + val token_type: String, + val created_at: Long = System.currentTimeMillis(), + val expires_in: Long, +) + +fun OAuth.isExpired() = System.currentTimeMillis() > created_at + (expires_in * 1000) + +fun Track.toMyAnimeListStatus() = + when (status) { + MyAnimeList.READING -> "reading" + MyAnimeList.COMPLETED -> "completed" + MyAnimeList.ON_HOLD -> "on_hold" + MyAnimeList.DROPPED -> "dropped" + MyAnimeList.PLAN_TO_READ -> "plan_to_read" + MyAnimeList.REREADING -> "reading" + else -> null + } + +fun getStatus(status: String?) = + when (status) { + "reading" -> MyAnimeList.READING + "completed" -> MyAnimeList.COMPLETED + "on_hold" -> MyAnimeList.ON_HOLD + "dropped" -> MyAnimeList.DROPPED + "plan_to_read" -> MyAnimeList.PLAN_TO_READ + else -> MyAnimeList.READING + } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/model/dataclass/MangaDataClass.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/model/dataclass/MangaDataClass.kt index 70baf21f1..e748f9831 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/model/dataclass/MangaDataClass.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/model/dataclass/MangaDataClass.kt @@ -42,6 +42,7 @@ data class MangaDataClass( var lastChapterRead: ChapterDataClass? = null, val age: Long? = if (lastFetchedAt == null) 0 else Instant.now().epochSecond.minus(lastFetchedAt), val chaptersAge: Long? = if (chaptersLastFetchedAt == null) null else Instant.now().epochSecond.minus(chaptersLastFetchedAt), + val trackers: List? = null, ) data class PagedMangaListDataClass( diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/model/dataclass/MangaTrackerDataClass.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/model/dataclass/MangaTrackerDataClass.kt new file mode 100644 index 000000000..1053b3b0e --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/model/dataclass/MangaTrackerDataClass.kt @@ -0,0 +1,18 @@ +package suwayomi.tachidesk.manga.model.dataclass + +/* + * Copyright (C) Contributors to the Suwayomi project + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +data class MangaTrackerDataClass( + val id: Int, + val name: String, + val icon: String, + val statusList: List, + val statusTextMap: Map, + val scoreList: List, + val record: TrackRecordDataClass?, +) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/model/dataclass/TrackRecordDataClass.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/model/dataclass/TrackRecordDataClass.kt new file mode 100644 index 000000000..4e4b287ea --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/model/dataclass/TrackRecordDataClass.kt @@ -0,0 +1,25 @@ +package suwayomi.tachidesk.manga.model.dataclass + +/* + * Copyright (C) Contributors to the Suwayomi project + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +data class TrackRecordDataClass( + val id: Int, + val mangaId: Int, + val syncId: Int, + val remoteId: Long, + val libraryId: Long?, + val title: String, + val lastChapterRead: Double, + val totalChapters: Int, + val status: Int, + val score: Double, + var scoreString: String? = null, + val remoteUrl: String, + val startDate: Long, + val finishDate: Long, +) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/model/dataclass/TrackSearchDataClass.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/model/dataclass/TrackSearchDataClass.kt new file mode 100644 index 000000000..f6230fea4 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/model/dataclass/TrackSearchDataClass.kt @@ -0,0 +1,24 @@ +package suwayomi.tachidesk.manga.model.dataclass + +import kotlinx.serialization.Serializable + +/* + * Copyright (C) Contributors to the Suwayomi project + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +@Serializable +data class TrackSearchDataClass( + val syncId: Int, + val mediaId: Long, + val title: String, + val totalChapters: Int, + val trackingUrl: String, + val coverUrl: String, + val summary: String, + val publishingStatus: String, + val publishingType: String, + val startDate: String, +) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/model/dataclass/TrackerDataClass.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/model/dataclass/TrackerDataClass.kt new file mode 100644 index 000000000..108586223 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/model/dataclass/TrackerDataClass.kt @@ -0,0 +1,16 @@ +package suwayomi.tachidesk.manga.model.dataclass + +/* + * Copyright (C) Contributors to the Suwayomi project + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +data class TrackerDataClass( + val id: Int, + val name: String, + val icon: String, + val isLogin: Boolean, + val authUrl: String?, +) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/TrackRecordTable.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/TrackRecordTable.kt new file mode 100644 index 000000000..81e3e5ff6 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/TrackRecordTable.kt @@ -0,0 +1,26 @@ +package suwayomi.tachidesk.manga.model.table + +/* + * Copyright (C) Contributors to the Suwayomi project + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +import org.jetbrains.exposed.dao.id.IntIdTable +import org.jetbrains.exposed.sql.ReferenceOption + +object TrackRecordTable : IntIdTable() { + val mangaId = reference("manga_id", MangaTable, ReferenceOption.CASCADE) + val syncId = integer("sync_id") + val remoteId = long("remote_id") + val libraryId = long("library_id").nullable() + val title = varchar("title", 512) + val lastChapterRead = double("last_chapter_read") + val totalChapters = integer("total_chapters") + val status = integer("status") + val score = double("score") + val remoteUrl = varchar("remote_url", 512) + val startDate = long("start_date") + val finishDate = long("finish_date") +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/server/JavalinSetup.kt b/server/src/main/kotlin/suwayomi/tachidesk/server/JavalinSetup.kt index 35467ef7c..3e5c737ce 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/server/JavalinSetup.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/server/JavalinSetup.kt @@ -35,6 +35,7 @@ import suwayomi.tachidesk.server.util.WebInterfaceManager import java.io.IOException import java.lang.IllegalArgumentException import java.util.concurrent.CompletableFuture +import kotlin.NoSuchElementException import kotlin.concurrent.thread object JavalinSetup { @@ -90,6 +91,13 @@ object JavalinSetup { config.server { server } + config.addStaticFiles { staticFiles -> + staticFiles.hostedPath = "/static" + staticFiles.directory = "/static" + staticFiles.location = Location.CLASSPATH + staticFiles.headers = mapOf("cache-control" to "max-age=86400") + } + config.enableCorsForAllOrigins() config.accessManager { handler, ctx, _ -> diff --git a/server/src/main/kotlin/suwayomi/tachidesk/server/database/migration/M0033_TrackRecord.kt b/server/src/main/kotlin/suwayomi/tachidesk/server/database/migration/M0033_TrackRecord.kt new file mode 100644 index 000000000..7485fde4e --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/server/database/migration/M0033_TrackRecord.kt @@ -0,0 +1,38 @@ +package suwayomi.tachidesk.server.database.migration + +/* + * Copyright (C) Contributors to the Suwayomi project + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +import de.neonew.exposed.migrations.helpers.AddTableMigration +import org.jetbrains.exposed.dao.id.IntIdTable +import org.jetbrains.exposed.sql.ReferenceOption +import org.jetbrains.exposed.sql.Table +import suwayomi.tachidesk.manga.model.table.MangaTable + +@Suppress("ClassName", "unused") +class M0033_TrackRecord : AddTableMigration() { + private class TrackRecordTable : IntIdTable() { + val mangaId = reference("manga_id", MangaTable, ReferenceOption.CASCADE) + val syncId = integer("sync_id") + val remoteId = long("remote_id") + val libraryId = long("library_id").nullable() + val title = varchar("title", 512) + val lastChapterRead = double("last_chapter_read") + val totalChapters = integer("total_chapters") + val status = integer("status") + val score = double("score") + val remoteUrl = varchar("remote_url", 512) + val startDate = long("start_date") + val finishDate = long("finish_date") + } + + override val tables: Array + get() = + arrayOf( + TrackRecordTable(), + ) +} diff --git a/server/src/main/resources/static/tracker/anilist.png b/server/src/main/resources/static/tracker/anilist.png new file mode 100644 index 0000000000000000000000000000000000000000..052ef713ddcc8bf6e7384568a1eb67afaa843210 GIT binary patch literal 3541 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 literal 0 HcmV?d00001 diff --git a/server/src/main/resources/static/tracker/mal.png b/server/src/main/resources/static/tracker/mal.png new file mode 100644 index 0000000000000000000000000000000000000000..aa25d2eb65b62512873c54e10d6a241edcb58a73 GIT binary patch literal 7141 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 IBW@b