mirror of
https://github.com/Suwayomi/Tachidesk.git
synced 2025-12-10 06:42:07 +01:00
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 <syer10@users.noreply.github.com>
This commit is contained in:
45
AndroidCompat/src/main/java/androidx/core/net/Uri.kt
Normal file
45
AndroidCompat/src/main/java/androidx/core/net/Uri.kt
Normal file
@@ -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" })
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import kotlinx.serialization.json.okio.decodeFromBufferedSource
|
|||||||
import kotlinx.serialization.serializer
|
import kotlinx.serialization.serializer
|
||||||
import okhttp3.Call
|
import okhttp3.Call
|
||||||
import okhttp3.Callback
|
import okhttp3.Callback
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
@@ -19,6 +20,8 @@ import java.io.IOException
|
|||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
import kotlin.coroutines.resumeWithException
|
import kotlin.coroutines.resumeWithException
|
||||||
|
|
||||||
|
val jsonMime = "application/json; charset=utf-8".toMediaType()
|
||||||
|
|
||||||
fun Call.asObservable(): Observable<Response> {
|
fun Call.asObservable(): Observable<Response> {
|
||||||
return Observable.unsafeCreate { subscriber ->
|
return Observable.unsafeCreate { subscriber ->
|
||||||
// Since Call is a one-shot type, clone it for each new subscriber.
|
// Since Call is a one-shot type, clone it for each new subscriber.
|
||||||
|
|||||||
14
server/src/main/kotlin/eu/kanade/tachiyomi/util/PkceUtil.kt
Normal file
14
server/src/main/kotlin/eu/kanade/tachiyomi/util/PkceUtil.kt
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 <T> withUIContext(block: suspend CoroutineScope.() -> T) =
|
||||||
|
withContext(
|
||||||
|
Dispatchers.Main,
|
||||||
|
block,
|
||||||
|
)
|
||||||
|
|
||||||
|
suspend fun <T> withIOContext(block: suspend CoroutineScope.() -> T) =
|
||||||
|
withContext(
|
||||||
|
Dispatchers.IO,
|
||||||
|
block,
|
||||||
|
)
|
||||||
|
|
||||||
|
suspend fun <T> withNonCancellableContext(block: suspend CoroutineScope.() -> T) = withContext(NonCancellable, block)
|
||||||
@@ -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<Int, TrackerType> {
|
||||||
|
override val dataLoaderName = "TrackerDataLoader"
|
||||||
|
|
||||||
|
override fun getDataLoader(): DataLoader<Int, TrackerType> =
|
||||||
|
DataLoaderFactory.newDataLoader { ids ->
|
||||||
|
future {
|
||||||
|
ids.map { id ->
|
||||||
|
TrackerManager.getTracker(id)?.let { TrackerType(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TrackRecordsForMangaIdDataLoader : KotlinDataLoader<Int, TrackRecordNodeList> {
|
||||||
|
override val dataLoaderName = "TrackRecordsForMangaIdDataLoader"
|
||||||
|
|
||||||
|
override fun getDataLoader(): DataLoader<Int, TrackRecordNodeList> =
|
||||||
|
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<Int, String> {
|
||||||
|
override val dataLoaderName = "DisplayScoreForTrackRecordDataLoader"
|
||||||
|
|
||||||
|
override fun getDataLoader(): DataLoader<Int, String> =
|
||||||
|
DataLoaderFactory.newDataLoader<Int, String> { 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<Int, TrackRecordNodeList> {
|
||||||
|
override val dataLoaderName = "TrackRecordsForTrackerIdDataLoader"
|
||||||
|
|
||||||
|
override fun getDataLoader(): DataLoader<Int, TrackRecordNodeList> =
|
||||||
|
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<Int, TrackRecordType> {
|
||||||
|
override val dataLoaderName = "TrackRecordDataLoader"
|
||||||
|
|
||||||
|
override fun getDataLoader(): DataLoader<Int, TrackRecordType> =
|
||||||
|
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] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<LoginTrackerOAuthPayload> {
|
||||||
|
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<LoginTrackerCredentialsPayload> {
|
||||||
|
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<LogoutTrackerPayload> {
|
||||||
|
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<BindTrackPayload> {
|
||||||
|
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<UpdateTrackPayload> {
|
||||||
|
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) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<TrackerType> {
|
||||||
|
return dataFetchingEnvironment.getValueFromDataLoader<Int, TrackerType>("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<TrackerFilter>? = null,
|
||||||
|
val or: List<TrackerFilter>? = 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<TrackRecordType> {
|
||||||
|
return dataFetchingEnvironment.getValueFromDataLoader<Int, TrackRecordType>("TrackRecordDataLoader", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class TrackRecordOrderBy(override val column: Column<out Comparable<*>>) : OrderBy<TrackRecordType> {
|
||||||
|
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<Boolean> {
|
||||||
|
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<Boolean> {
|
||||||
|
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<Boolean>? {
|
||||||
|
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<TrackRecordFilter>? = null,
|
||||||
|
override val or: List<TrackRecordFilter>? = null,
|
||||||
|
override val not: TrackRecordFilter? = null,
|
||||||
|
) : Filter<TrackRecordFilter> {
|
||||||
|
override fun getOpList(): List<Op<Boolean>> {
|
||||||
|
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<TrackSearchType>)
|
||||||
|
|
||||||
|
fun searchTracker(input: SearchTrackerInput): CompletableFuture<SearchTrackerPayload> {
|
||||||
|
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)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -259,6 +259,20 @@ data class FloatFilter(
|
|||||||
override val greaterThanOrEqualTo: Float? = null,
|
override val greaterThanOrEqualTo: Float? = null,
|
||||||
) : ComparableScalarFilter<Float>
|
) : ComparableScalarFilter<Float>
|
||||||
|
|
||||||
|
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<Double>? = null,
|
||||||
|
override val notIn: List<Double>? = null,
|
||||||
|
override val lessThan: Double? = null,
|
||||||
|
override val lessThanOrEqualTo: Double? = null,
|
||||||
|
override val greaterThan: Double? = null,
|
||||||
|
override val greaterThanOrEqualTo: Double? = null,
|
||||||
|
) : ComparableScalarFilter<Double>
|
||||||
|
|
||||||
data class StringFilter(
|
data class StringFilter(
|
||||||
override val isNull: Boolean? = null,
|
override val isNull: Boolean? = null,
|
||||||
override val equalTo: String? = null,
|
override val equalTo: String? = null,
|
||||||
@@ -418,8 +432,8 @@ class OpAnd(var op: Op<Boolean>? = null) {
|
|||||||
) = andWhere(value) { column like it }
|
) = andWhere(value) { column like it }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun <T : Comparable<T>> andFilterWithCompare(
|
fun <T : Comparable<T>, S : T?> andFilterWithCompare(
|
||||||
column: Column<T>,
|
column: Column<S>,
|
||||||
filter: ComparableScalarFilter<T>?,
|
filter: ComparableScalarFilter<T>?,
|
||||||
): Op<Boolean>? {
|
): Op<Boolean>? {
|
||||||
filter ?: return null
|
filter ?: return null
|
||||||
@@ -448,23 +462,24 @@ fun <T : Comparable<T>> andFilterWithCompareEntity(
|
|||||||
return opAnd.op
|
return opAnd.op
|
||||||
}
|
}
|
||||||
|
|
||||||
fun <T : Comparable<T>> andFilter(
|
@Suppress("UNCHECKED_CAST")
|
||||||
column: Column<T>,
|
fun <T : Comparable<T>, S : T?> andFilter(
|
||||||
|
column: Column<S>,
|
||||||
filter: ScalarFilter<T>?,
|
filter: ScalarFilter<T>?,
|
||||||
): Op<Boolean>? {
|
): Op<Boolean>? {
|
||||||
filter ?: return null
|
filter ?: return null
|
||||||
val opAnd = OpAnd()
|
val opAnd = OpAnd()
|
||||||
|
|
||||||
opAnd.andWhere(filter.isNull) { if (it) column.isNull() else column.isNotNull() }
|
opAnd.andWhere(filter.isNull) { if (it) column.isNull() else column.isNotNull() }
|
||||||
opAnd.andWhere(filter.equalTo) { column eq it }
|
opAnd.andWhere(filter.equalTo) { column eq it as S }
|
||||||
opAnd.andWhere(filter.notEqualTo) { column neq it }
|
opAnd.andWhere(filter.notEqualTo) { column neq it as S }
|
||||||
opAnd.andWhere(filter.distinctFrom) { DistinctFromOp.distinctFrom(column, it) }
|
opAnd.andWhere(filter.distinctFrom) { DistinctFromOp.distinctFrom(column, it as S) }
|
||||||
opAnd.andWhere(filter.notDistinctFrom) { DistinctFromOp.notDistinctFrom(column, it) }
|
opAnd.andWhere(filter.notDistinctFrom) { DistinctFromOp.notDistinctFrom(column, it as S) }
|
||||||
if (!filter.`in`.isNullOrEmpty()) {
|
if (!filter.`in`.isNullOrEmpty()) {
|
||||||
opAnd.andWhere(filter.`in`) { column inList it }
|
opAnd.andWhere(filter.`in`) { column inList it as List<S> }
|
||||||
}
|
}
|
||||||
if (!filter.notIn.isNullOrEmpty()) {
|
if (!filter.notIn.isNullOrEmpty()) {
|
||||||
opAnd.andWhere(filter.notIn) { column notInList it }
|
opAnd.andWhere(filter.notIn) { column notInList it as List<S> }
|
||||||
}
|
}
|
||||||
return opAnd.op
|
return opAnd.op
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import suwayomi.tachidesk.graphql.dataLoaders.CategoryMetaDataLoader
|
|||||||
import suwayomi.tachidesk.graphql.dataLoaders.ChapterDataLoader
|
import suwayomi.tachidesk.graphql.dataLoaders.ChapterDataLoader
|
||||||
import suwayomi.tachidesk.graphql.dataLoaders.ChapterMetaDataLoader
|
import suwayomi.tachidesk.graphql.dataLoaders.ChapterMetaDataLoader
|
||||||
import suwayomi.tachidesk.graphql.dataLoaders.ChaptersForMangaDataLoader
|
import suwayomi.tachidesk.graphql.dataLoaders.ChaptersForMangaDataLoader
|
||||||
|
import suwayomi.tachidesk.graphql.dataLoaders.DisplayScoreForTrackRecordDataLoader
|
||||||
import suwayomi.tachidesk.graphql.dataLoaders.DownloadedChapterCountForMangaDataLoader
|
import suwayomi.tachidesk.graphql.dataLoaders.DownloadedChapterCountForMangaDataLoader
|
||||||
import suwayomi.tachidesk.graphql.dataLoaders.ExtensionDataLoader
|
import suwayomi.tachidesk.graphql.dataLoaders.ExtensionDataLoader
|
||||||
import suwayomi.tachidesk.graphql.dataLoaders.ExtensionForSourceDataLoader
|
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.MangaMetaDataLoader
|
||||||
import suwayomi.tachidesk.graphql.dataLoaders.SourceDataLoader
|
import suwayomi.tachidesk.graphql.dataLoaders.SourceDataLoader
|
||||||
import suwayomi.tachidesk.graphql.dataLoaders.SourcesForExtensionDataLoader
|
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
|
import suwayomi.tachidesk.graphql.dataLoaders.UnreadChapterCountForMangaDataLoader
|
||||||
|
|
||||||
class TachideskDataLoaderRegistryFactory {
|
class TachideskDataLoaderRegistryFactory {
|
||||||
@@ -53,6 +58,11 @@ class TachideskDataLoaderRegistryFactory {
|
|||||||
SourcesForExtensionDataLoader(),
|
SourcesForExtensionDataLoader(),
|
||||||
ExtensionDataLoader(),
|
ExtensionDataLoader(),
|
||||||
ExtensionForSourceDataLoader(),
|
ExtensionForSourceDataLoader(),
|
||||||
|
// TrackerDataLoader(),
|
||||||
|
// TrackRecordsForMangaIdDataLoader(),
|
||||||
|
// DisplayScoreForTrackRecordDataLoader(),
|
||||||
|
// TrackRecordsForTrackerIdDataLoader(),
|
||||||
|
// TrackRecordDataLoader(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import suwayomi.tachidesk.graphql.mutations.MangaMutation
|
|||||||
import suwayomi.tachidesk.graphql.mutations.MetaMutation
|
import suwayomi.tachidesk.graphql.mutations.MetaMutation
|
||||||
import suwayomi.tachidesk.graphql.mutations.SettingsMutation
|
import suwayomi.tachidesk.graphql.mutations.SettingsMutation
|
||||||
import suwayomi.tachidesk.graphql.mutations.SourceMutation
|
import suwayomi.tachidesk.graphql.mutations.SourceMutation
|
||||||
|
import suwayomi.tachidesk.graphql.mutations.TrackMutation
|
||||||
import suwayomi.tachidesk.graphql.mutations.UpdateMutation
|
import suwayomi.tachidesk.graphql.mutations.UpdateMutation
|
||||||
import suwayomi.tachidesk.graphql.queries.BackupQuery
|
import suwayomi.tachidesk.graphql.queries.BackupQuery
|
||||||
import suwayomi.tachidesk.graphql.queries.CategoryQuery
|
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.MetaQuery
|
||||||
import suwayomi.tachidesk.graphql.queries.SettingsQuery
|
import suwayomi.tachidesk.graphql.queries.SettingsQuery
|
||||||
import suwayomi.tachidesk.graphql.queries.SourceQuery
|
import suwayomi.tachidesk.graphql.queries.SourceQuery
|
||||||
|
import suwayomi.tachidesk.graphql.queries.TrackQuery
|
||||||
import suwayomi.tachidesk.graphql.queries.UpdateQuery
|
import suwayomi.tachidesk.graphql.queries.UpdateQuery
|
||||||
import suwayomi.tachidesk.graphql.server.primitives.Cursor
|
import suwayomi.tachidesk.graphql.server.primitives.Cursor
|
||||||
import suwayomi.tachidesk.graphql.server.primitives.GraphQLCursor
|
import suwayomi.tachidesk.graphql.server.primitives.GraphQLCursor
|
||||||
@@ -76,6 +78,7 @@ val schema =
|
|||||||
TopLevelObject(MetaQuery()),
|
TopLevelObject(MetaQuery()),
|
||||||
TopLevelObject(SettingsQuery()),
|
TopLevelObject(SettingsQuery()),
|
||||||
TopLevelObject(SourceQuery()),
|
TopLevelObject(SourceQuery()),
|
||||||
|
// TopLevelObject(TrackQuery()),
|
||||||
TopLevelObject(UpdateQuery()),
|
TopLevelObject(UpdateQuery()),
|
||||||
),
|
),
|
||||||
mutations =
|
mutations =
|
||||||
@@ -91,6 +94,7 @@ val schema =
|
|||||||
TopLevelObject(MetaMutation()),
|
TopLevelObject(MetaMutation()),
|
||||||
TopLevelObject(SettingsMutation()),
|
TopLevelObject(SettingsMutation()),
|
||||||
TopLevelObject(SourceMutation()),
|
TopLevelObject(SourceMutation()),
|
||||||
|
// TopLevelObject(TrackMutation()),
|
||||||
TopLevelObject(UpdateMutation()),
|
TopLevelObject(UpdateMutation()),
|
||||||
),
|
),
|
||||||
subscriptions =
|
subscriptions =
|
||||||
|
|||||||
@@ -81,6 +81,15 @@ fun <T : Comparable<T>> greaterNotUnique(
|
|||||||
return greaterNotUniqueImpl(column, idColumn, cursor, String::toLong, toValue)
|
return greaterNotUniqueImpl(column, idColumn, cursor, String::toLong, toValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@JvmName("greaterNotUniqueIntKeyIntValue")
|
||||||
|
fun greaterNotUnique(
|
||||||
|
column: Column<EntityID<Int>>,
|
||||||
|
idColumn: Column<EntityID<Int>>,
|
||||||
|
cursor: Cursor,
|
||||||
|
): Op<Boolean> {
|
||||||
|
return greaterNotUniqueImpl(column, idColumn, cursor, String::toInt, String::toInt)
|
||||||
|
}
|
||||||
|
|
||||||
private fun <K : Comparable<K>, V : Comparable<V>> greaterNotUniqueImpl(
|
private fun <K : Comparable<K>, V : Comparable<V>> greaterNotUniqueImpl(
|
||||||
column: Column<V>,
|
column: Column<V>,
|
||||||
idColumn: Column<EntityID<K>>,
|
idColumn: Column<EntityID<K>>,
|
||||||
@@ -93,6 +102,19 @@ private fun <K : Comparable<K>, V : Comparable<V>> greaterNotUniqueImpl(
|
|||||||
return (column greater value) or ((column eq value) and (idColumn greater id))
|
return (column greater value) or ((column eq value) and (idColumn greater id))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@JvmName("greaterNotUniqueEntityValue")
|
||||||
|
private fun <K : Comparable<K>, V : Comparable<V>> greaterNotUniqueImpl(
|
||||||
|
column: Column<EntityID<V>>,
|
||||||
|
idColumn: Column<EntityID<K>>,
|
||||||
|
cursor: Cursor,
|
||||||
|
toKey: (String) -> K,
|
||||||
|
toValue: (String) -> V,
|
||||||
|
): Op<Boolean> {
|
||||||
|
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")
|
@JvmName("greaterNotUniqueStringKey")
|
||||||
fun <T : Comparable<T>> greaterNotUnique(
|
fun <T : Comparable<T>> greaterNotUnique(
|
||||||
column: Column<T>,
|
column: Column<T>,
|
||||||
@@ -125,6 +147,15 @@ fun <T : Comparable<T>> lessNotUnique(
|
|||||||
return lessNotUniqueImpl(column, idColumn, cursor, String::toLong, toValue)
|
return lessNotUniqueImpl(column, idColumn, cursor, String::toLong, toValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@JvmName("lessNotUniqueIntKeyIntValue")
|
||||||
|
fun lessNotUnique(
|
||||||
|
column: Column<EntityID<Int>>,
|
||||||
|
idColumn: Column<EntityID<Int>>,
|
||||||
|
cursor: Cursor,
|
||||||
|
): Op<Boolean> {
|
||||||
|
return lessNotUniqueImpl(column, idColumn, cursor, String::toInt, String::toInt)
|
||||||
|
}
|
||||||
|
|
||||||
private fun <K : Comparable<K>, V : Comparable<V>> lessNotUniqueImpl(
|
private fun <K : Comparable<K>, V : Comparable<V>> lessNotUniqueImpl(
|
||||||
column: Column<V>,
|
column: Column<V>,
|
||||||
idColumn: Column<EntityID<K>>,
|
idColumn: Column<EntityID<K>>,
|
||||||
@@ -137,6 +168,19 @@ private fun <K : Comparable<K>, V : Comparable<V>> lessNotUniqueImpl(
|
|||||||
return (column less value) or ((column eq value) and (idColumn less id))
|
return (column less value) or ((column eq value) and (idColumn less id))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@JvmName("lessNotUniqueEntityValue")
|
||||||
|
private fun <K : Comparable<K>, V : Comparable<V>> lessNotUniqueImpl(
|
||||||
|
column: Column<EntityID<V>>,
|
||||||
|
idColumn: Column<EntityID<K>>,
|
||||||
|
cursor: Cursor,
|
||||||
|
toKey: (String) -> K,
|
||||||
|
toValue: (String) -> V,
|
||||||
|
): Op<Boolean> {
|
||||||
|
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")
|
@JvmName("lessNotUniqueStringKey")
|
||||||
fun <T : Comparable<T>> lessNotUnique(
|
fun <T : Comparable<T>> lessNotUnique(
|
||||||
column: Column<T>,
|
column: Column<T>,
|
||||||
|
|||||||
@@ -139,6 +139,10 @@ class MangaType(
|
|||||||
fun source(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<SourceType?> {
|
fun source(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<SourceType?> {
|
||||||
return dataFetchingEnvironment.getValueFromDataLoader<Long, SourceType?>("SourceDataLoader", sourceId)
|
return dataFetchingEnvironment.getValueFromDataLoader<Long, SourceType?>("SourceDataLoader", sourceId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fun trackRecords(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<TrackRecordNodeList> {
|
||||||
|
// return dataFetchingEnvironment.getValueFromDataLoader<Int, TrackRecordNodeList>("TrackRecordsForMangaIdDataLoader", id)
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
data class MangaNodeList(
|
data class MangaNodeList(
|
||||||
|
|||||||
@@ -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<TrackRecordNodeList> {
|
||||||
|
return dataFetchingEnvironment.getValueFromDataLoader<Int, TrackRecordNodeList>("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<String> {
|
||||||
|
return dataFetchingEnvironment.getValueFromDataLoader<Int, String>("DisplayScoreForTrackRecordDataLoader", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun manga(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<MangaType> {
|
||||||
|
return dataFetchingEnvironment.getValueFromDataLoader<Int, MangaType>("MangaDataLoader", mangaId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun tracker(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<TrackerType> {
|
||||||
|
return dataFetchingEnvironment.getValueFromDataLoader<Int, TrackerType>("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<TrackerType> {
|
||||||
|
return dataFetchingEnvironment.getValueFromDataLoader<Int, TrackerType>("TrackerDataLoader", syncId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class TrackRecordNodeList(
|
||||||
|
override val nodes: List<TrackRecordType>,
|
||||||
|
override val edges: List<TrackRecordEdge>,
|
||||||
|
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<TrackRecordType>.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<TrackRecordType>.getEdges(): List<TrackRecordEdge> {
|
||||||
|
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<TrackerType>,
|
||||||
|
override val edges: List<TrackerEdge>,
|
||||||
|
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<TrackerType>.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<TrackerType>.getEdges(): List<TrackerEdge> {
|
||||||
|
if (isEmpty()) return emptyList()
|
||||||
|
return listOf(
|
||||||
|
TrackerEdge(
|
||||||
|
cursor = Cursor("0"),
|
||||||
|
node = first(),
|
||||||
|
),
|
||||||
|
TrackerEdge(
|
||||||
|
cursor = Cursor(lastIndex.toString()),
|
||||||
|
node = last(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import suwayomi.tachidesk.manga.controller.DownloadController
|
|||||||
import suwayomi.tachidesk.manga.controller.ExtensionController
|
import suwayomi.tachidesk.manga.controller.ExtensionController
|
||||||
import suwayomi.tachidesk.manga.controller.MangaController
|
import suwayomi.tachidesk.manga.controller.MangaController
|
||||||
import suwayomi.tachidesk.manga.controller.SourceController
|
import suwayomi.tachidesk.manga.controller.SourceController
|
||||||
|
import suwayomi.tachidesk.manga.controller.TrackController
|
||||||
import suwayomi.tachidesk.manga.controller.UpdateController
|
import suwayomi.tachidesk.manga.controller.UpdateController
|
||||||
|
|
||||||
object MangaAPI {
|
object MangaAPI {
|
||||||
@@ -132,5 +133,14 @@ object MangaAPI {
|
|||||||
get("summary", UpdateController.updateSummary)
|
get("summary", UpdateController.updateSummary)
|
||||||
ws("", UpdateController::categoryUpdateWS)
|
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)
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<Json>()
|
||||||
|
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<Array<TrackerDataClass>>(HttpCode.OK)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
val login =
|
||||||
|
handler(
|
||||||
|
documentWith = {
|
||||||
|
withOperation {
|
||||||
|
summary("Tracker Login")
|
||||||
|
description("Login to a tracker")
|
||||||
|
}
|
||||||
|
body<Track.LoginInput>()
|
||||||
|
},
|
||||||
|
behaviorOf = { ctx ->
|
||||||
|
val input = json.decodeFromString<Track.LoginInput>(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<Track.LogoutInput>()
|
||||||
|
},
|
||||||
|
behaviorOf = { ctx ->
|
||||||
|
val input = json.decodeFromString<Track.LogoutInput>(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<Track.SearchInput>()
|
||||||
|
},
|
||||||
|
behaviorOf = { ctx ->
|
||||||
|
val input = json.decodeFromString<Track.SearchInput>(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<Int>("mangaId"),
|
||||||
|
documentWith = {
|
||||||
|
withOperation {
|
||||||
|
summary("Track Record Bind")
|
||||||
|
description("Bind a Track Record to a Manga")
|
||||||
|
}
|
||||||
|
body<TrackSearchDataClass>()
|
||||||
|
},
|
||||||
|
behaviorOf = { ctx, mangaId ->
|
||||||
|
val input = json.decodeFromString<TrackSearchDataClass>(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<Track.UpdateInput>()
|
||||||
|
},
|
||||||
|
behaviorOf = { ctx ->
|
||||||
|
val input = json.decodeFromString<Track.UpdateInput>(ctx.body())
|
||||||
|
logger.debug { "tracker update $input" }
|
||||||
|
ctx.future(future { Track.update(input) })
|
||||||
|
},
|
||||||
|
withResults = {
|
||||||
|
httpCode(HttpCode.OK)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -34,6 +34,7 @@ import org.jetbrains.exposed.sql.update
|
|||||||
import suwayomi.tachidesk.manga.impl.Manga.getManga
|
import suwayomi.tachidesk.manga.impl.Manga.getManga
|
||||||
import suwayomi.tachidesk.manga.impl.download.DownloadManager
|
import suwayomi.tachidesk.manga.impl.download.DownloadManager
|
||||||
import suwayomi.tachidesk.manga.impl.download.DownloadManager.EnqueueInput
|
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.impl.util.source.GetCatalogueSource.getCatalogueSourceOrStub
|
||||||
import suwayomi.tachidesk.manga.model.dataclass.ChapterDataClass
|
import suwayomi.tachidesk.manga.model.dataclass.ChapterDataClass
|
||||||
import suwayomi.tachidesk.manga.model.dataclass.MangaChapterDataClass
|
import suwayomi.tachidesk.manga.model.dataclass.MangaChapterDataClass
|
||||||
@@ -389,6 +390,10 @@ object Chapter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isRead == true || markPrevRead == true) {
|
||||||
|
Track.asyncTrackChapter(mangaId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Serializable
|
@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<EntityID<Int>>): Map<EntityID<Int>, Map<String, String>> {
|
fun getChaptersMetaMaps(chapterIds: List<EntityID<Int>>): Map<EntityID<Int>, Map<String, String>> {
|
||||||
|
|||||||
@@ -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
|
||||||
import suwayomi.tachidesk.manga.impl.download.DownloadManager.EnqueueInput
|
import suwayomi.tachidesk.manga.impl.download.DownloadManager.EnqueueInput
|
||||||
import suwayomi.tachidesk.manga.impl.download.fileProvider.impl.MissingThumbnailException
|
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.network.await
|
||||||
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrNull
|
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrNull
|
||||||
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrStub
|
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrStub
|
||||||
@@ -105,6 +106,7 @@ object Manga {
|
|||||||
chaptersLastFetchedAt = mangaEntry[MangaTable.chaptersLastFetchedAt],
|
chaptersLastFetchedAt = mangaEntry[MangaTable.chaptersLastFetchedAt],
|
||||||
updateStrategy = UpdateStrategy.valueOf(mangaEntry[MangaTable.updateStrategy]),
|
updateStrategy = UpdateStrategy.valueOf(mangaEntry[MangaTable.updateStrategy]),
|
||||||
freshData = true,
|
freshData = true,
|
||||||
|
trackers = Track.getTrackRecordsByMangaId(mangaId),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -221,6 +223,7 @@ object Manga {
|
|||||||
chaptersLastFetchedAt = mangaEntry[MangaTable.chaptersLastFetchedAt],
|
chaptersLastFetchedAt = mangaEntry[MangaTable.chaptersLastFetchedAt],
|
||||||
updateStrategy = UpdateStrategy.valueOf(mangaEntry[MangaTable.updateStrategy]),
|
updateStrategy = UpdateStrategy.valueOf(mangaEntry[MangaTable.updateStrategy]),
|
||||||
freshData = false,
|
freshData = false,
|
||||||
|
trackers = Track.getTrackRecordsByMangaId(mangaId),
|
||||||
)
|
)
|
||||||
|
|
||||||
fun getMangaMetaMap(mangaId: Int): Map<String, String> {
|
fun getMangaMetaMap(mangaId: Int): Map<String, String> {
|
||||||
|
|||||||
@@ -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<TrackerDataClass> {
|
||||||
|
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<MangaTrackerDataClass> {
|
||||||
|
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<TrackSearchDataClass> {
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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<Int>
|
||||||
|
|
||||||
|
abstract fun getStatus(status: Int): String?
|
||||||
|
|
||||||
|
abstract fun getReadingStatus(): Int
|
||||||
|
|
||||||
|
abstract fun getRereadingStatus(): Int
|
||||||
|
|
||||||
|
abstract fun getCompletionStatus(): Int
|
||||||
|
|
||||||
|
abstract fun getScoreList(): List<String>
|
||||||
|
|
||||||
|
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<TrackSearch>
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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<Tracker> = listOf(myAnimeList, aniList)
|
||||||
|
|
||||||
|
fun getTracker(id: Int) = services.find { it.id == id }
|
||||||
|
|
||||||
|
fun hasLoggedTracker() = services.any { it.isLoggedIn }
|
||||||
|
}
|
||||||
@@ -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<Application>().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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Int> {
|
||||||
|
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<String> {
|
||||||
|
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<TrackSearch> {
|
||||||
|
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<OAuth>(trackPreferences.getTrackToken(this)!!)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error(e) { "loadOAuth err" }
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<JsonObject>()
|
||||||
|
.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<TrackSearch> {
|
||||||
|
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<JsonObject>()
|
||||||
|
.let { response ->
|
||||||
|
val data = response["data"]!!.jsonObject
|
||||||
|
val page = data["Page"]!!.jsonObject
|
||||||
|
val media = page["media"]!!.jsonArray
|
||||||
|
val entries = media.map { jsonToALManga(it.jsonObject) }
|
||||||
|
entries.map { it.toTrack() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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<JsonObject>()
|
||||||
|
.let { response ->
|
||||||
|
val data = response["data"]!!.jsonObject
|
||||||
|
val page = data["Page"]!!.jsonObject
|
||||||
|
val media = page["mediaList"]!!.jsonArray
|
||||||
|
val entries = media.map { jsonToALUserManga(it.jsonObject) }
|
||||||
|
entries.firstOrNull()?.toTrack()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Int, String> {
|
||||||
|
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<JsonObject>()
|
||||||
|
.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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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]
|
||||||
|
}
|
||||||
@@ -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 = ""
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Int> {
|
||||||
|
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<String> {
|
||||||
|
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<TrackSearch> {
|
||||||
|
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<OAuth>(trackPreferences.getTrackToken(this)!!)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error(e) { "loadOAuth err" }
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<JsonObject>()
|
||||||
|
.let { it["name"]!!.jsonPrimitive.content }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun search(query: String): List<TrackSearch> {
|
||||||
|
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<JsonObject>()
|
||||||
|
.let {
|
||||||
|
it["data"]!!.jsonArray
|
||||||
|
.map { data -> data.jsonObject["node"]!!.jsonObject }
|
||||||
|
.map { node ->
|
||||||
|
val id = node["id"]!!.jsonPrimitive.int
|
||||||
|
async { getMangaDetails(id) }
|
||||||
|
}
|
||||||
|
.awaitAll()
|
||||||
|
.filter { trackSearch -> !trackSearch.publishing_type.contains("novel") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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<JsonObject>()
|
||||||
|
.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<JsonObject>()
|
||||||
|
.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<JsonObject>()
|
||||||
|
.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<TrackSearch> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<OAuth>() }
|
||||||
|
} else {
|
||||||
|
oauthResponse.close()
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newOauth.getOrNull() == null) {
|
||||||
|
throw IOException("Failed to refresh the access token")
|
||||||
|
}
|
||||||
|
|
||||||
|
return newOauth.getOrNull()!!
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -42,6 +42,7 @@ data class MangaDataClass(
|
|||||||
var lastChapterRead: ChapterDataClass? = null,
|
var lastChapterRead: ChapterDataClass? = null,
|
||||||
val age: Long? = if (lastFetchedAt == null) 0 else Instant.now().epochSecond.minus(lastFetchedAt),
|
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 chaptersAge: Long? = if (chaptersLastFetchedAt == null) null else Instant.now().epochSecond.minus(chaptersLastFetchedAt),
|
||||||
|
val trackers: List<MangaTrackerDataClass>? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class PagedMangaListDataClass(
|
data class PagedMangaListDataClass(
|
||||||
|
|||||||
@@ -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<Int>,
|
||||||
|
val statusTextMap: Map<Int, String>,
|
||||||
|
val scoreList: List<String>,
|
||||||
|
val record: TrackRecordDataClass?,
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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?,
|
||||||
|
)
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -35,6 +35,7 @@ import suwayomi.tachidesk.server.util.WebInterfaceManager
|
|||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.lang.IllegalArgumentException
|
import java.lang.IllegalArgumentException
|
||||||
import java.util.concurrent.CompletableFuture
|
import java.util.concurrent.CompletableFuture
|
||||||
|
import kotlin.NoSuchElementException
|
||||||
import kotlin.concurrent.thread
|
import kotlin.concurrent.thread
|
||||||
|
|
||||||
object JavalinSetup {
|
object JavalinSetup {
|
||||||
@@ -90,6 +91,13 @@ object JavalinSetup {
|
|||||||
|
|
||||||
config.server { server }
|
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.enableCorsForAllOrigins()
|
||||||
|
|
||||||
config.accessManager { handler, ctx, _ ->
|
config.accessManager { handler, ctx, _ ->
|
||||||
|
|||||||
@@ -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<Table>
|
||||||
|
get() =
|
||||||
|
arrayOf(
|
||||||
|
TrackRecordTable(),
|
||||||
|
)
|
||||||
|
}
|
||||||
BIN
server/src/main/resources/static/tracker/anilist.png
Normal file
BIN
server/src/main/resources/static/tracker/anilist.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.5 KiB |
BIN
server/src/main/resources/static/tracker/mal.png
Normal file
BIN
server/src/main/resources/static/tracker/mal.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.0 KiB |
Reference in New Issue
Block a user