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:
Tachimanga
2024-01-08 04:07:41 +08:00
committed by GitHub
parent 230427e758
commit 5a178ada74
44 changed files with 3726 additions and 10 deletions

View 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" })
}

View File

@@ -9,6 +9,7 @@ import kotlinx.serialization.json.okio.decodeFromBufferedSource
import kotlinx.serialization.serializer
import okhttp3.Call
import okhttp3.Callback
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
@@ -19,6 +20,8 @@ import java.io.IOException
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.coroutines.resumeWithException
val jsonMime = "application/json; charset=utf-8".toMediaType()
fun Call.asObservable(): Observable<Response> {
return Observable.unsafeCreate { subscriber ->
// Since Call is a one-shot type, clone it for each new subscriber.

View 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)
}
}

View File

@@ -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)

View File

@@ -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] }
}
}
}
}

View File

@@ -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) },
)
}
}
}

View File

@@ -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)
},
)
}
}
}

View File

@@ -259,6 +259,20 @@ data class FloatFilter(
override val greaterThanOrEqualTo: Float? = null,
) : 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(
override val isNull: Boolean? = null,
override val equalTo: String? = null,
@@ -418,8 +432,8 @@ class OpAnd(var op: Op<Boolean>? = null) {
) = andWhere(value) { column like it }
}
fun <T : Comparable<T>> andFilterWithCompare(
column: Column<T>,
fun <T : Comparable<T>, S : T?> andFilterWithCompare(
column: Column<S>,
filter: ComparableScalarFilter<T>?,
): Op<Boolean>? {
filter ?: return null
@@ -448,23 +462,24 @@ fun <T : Comparable<T>> andFilterWithCompareEntity(
return opAnd.op
}
fun <T : Comparable<T>> andFilter(
column: Column<T>,
@Suppress("UNCHECKED_CAST")
fun <T : Comparable<T>, S : T?> andFilter(
column: Column<S>,
filter: ScalarFilter<T>?,
): Op<Boolean>? {
filter ?: return null
val opAnd = OpAnd()
opAnd.andWhere(filter.isNull) { if (it) column.isNull() else column.isNotNull() }
opAnd.andWhere(filter.equalTo) { column eq it }
opAnd.andWhere(filter.notEqualTo) { column neq it }
opAnd.andWhere(filter.distinctFrom) { DistinctFromOp.distinctFrom(column, it) }
opAnd.andWhere(filter.notDistinctFrom) { DistinctFromOp.notDistinctFrom(column, it) }
opAnd.andWhere(filter.equalTo) { column eq it as S }
opAnd.andWhere(filter.notEqualTo) { column neq it as S }
opAnd.andWhere(filter.distinctFrom) { DistinctFromOp.distinctFrom(column, it as S) }
opAnd.andWhere(filter.notDistinctFrom) { DistinctFromOp.notDistinctFrom(column, it as S) }
if (!filter.`in`.isNullOrEmpty()) {
opAnd.andWhere(filter.`in`) { column inList it }
opAnd.andWhere(filter.`in`) { column inList it as List<S> }
}
if (!filter.notIn.isNullOrEmpty()) {
opAnd.andWhere(filter.notIn) { column notInList it }
opAnd.andWhere(filter.notIn) { column notInList it as List<S> }
}
return opAnd.op
}

View File

@@ -15,6 +15,7 @@ import suwayomi.tachidesk.graphql.dataLoaders.CategoryMetaDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.ChapterDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.ChapterMetaDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.ChaptersForMangaDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.DisplayScoreForTrackRecordDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.DownloadedChapterCountForMangaDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.ExtensionDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.ExtensionForSourceDataLoader
@@ -27,6 +28,10 @@ import suwayomi.tachidesk.graphql.dataLoaders.MangaForSourceDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.MangaMetaDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.SourceDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.SourcesForExtensionDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.TrackRecordDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.TrackRecordsForMangaIdDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.TrackRecordsForTrackerIdDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.TrackerDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.UnreadChapterCountForMangaDataLoader
class TachideskDataLoaderRegistryFactory {
@@ -53,6 +58,11 @@ class TachideskDataLoaderRegistryFactory {
SourcesForExtensionDataLoader(),
ExtensionDataLoader(),
ExtensionForSourceDataLoader(),
// TrackerDataLoader(),
// TrackRecordsForMangaIdDataLoader(),
// DisplayScoreForTrackRecordDataLoader(),
// TrackRecordsForTrackerIdDataLoader(),
// TrackRecordDataLoader(),
)
}
}

View File

@@ -24,6 +24,7 @@ import suwayomi.tachidesk.graphql.mutations.MangaMutation
import suwayomi.tachidesk.graphql.mutations.MetaMutation
import suwayomi.tachidesk.graphql.mutations.SettingsMutation
import suwayomi.tachidesk.graphql.mutations.SourceMutation
import suwayomi.tachidesk.graphql.mutations.TrackMutation
import suwayomi.tachidesk.graphql.mutations.UpdateMutation
import suwayomi.tachidesk.graphql.queries.BackupQuery
import suwayomi.tachidesk.graphql.queries.CategoryQuery
@@ -35,6 +36,7 @@ import suwayomi.tachidesk.graphql.queries.MangaQuery
import suwayomi.tachidesk.graphql.queries.MetaQuery
import suwayomi.tachidesk.graphql.queries.SettingsQuery
import suwayomi.tachidesk.graphql.queries.SourceQuery
import suwayomi.tachidesk.graphql.queries.TrackQuery
import suwayomi.tachidesk.graphql.queries.UpdateQuery
import suwayomi.tachidesk.graphql.server.primitives.Cursor
import suwayomi.tachidesk.graphql.server.primitives.GraphQLCursor
@@ -76,6 +78,7 @@ val schema =
TopLevelObject(MetaQuery()),
TopLevelObject(SettingsQuery()),
TopLevelObject(SourceQuery()),
// TopLevelObject(TrackQuery()),
TopLevelObject(UpdateQuery()),
),
mutations =
@@ -91,6 +94,7 @@ val schema =
TopLevelObject(MetaMutation()),
TopLevelObject(SettingsMutation()),
TopLevelObject(SourceMutation()),
// TopLevelObject(TrackMutation()),
TopLevelObject(UpdateMutation()),
),
subscriptions =

View File

@@ -81,6 +81,15 @@ fun <T : Comparable<T>> greaterNotUnique(
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(
column: Column<V>,
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))
}
@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")
fun <T : Comparable<T>> greaterNotUnique(
column: Column<T>,
@@ -125,6 +147,15 @@ fun <T : Comparable<T>> lessNotUnique(
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(
column: Column<V>,
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))
}
@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")
fun <T : Comparable<T>> lessNotUnique(
column: Column<T>,

View File

@@ -139,6 +139,10 @@ class MangaType(
fun source(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<SourceType?> {
return dataFetchingEnvironment.getValueFromDataLoader<Long, SourceType?>("SourceDataLoader", sourceId)
}
// fun trackRecords(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<TrackRecordNodeList> {
// return dataFetchingEnvironment.getValueFromDataLoader<Int, TrackRecordNodeList>("TrackRecordsForMangaIdDataLoader", id)
// }
}
data class MangaNodeList(

View File

@@ -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(),
),
)
}
}
}

View File

@@ -20,6 +20,7 @@ import suwayomi.tachidesk.manga.controller.DownloadController
import suwayomi.tachidesk.manga.controller.ExtensionController
import suwayomi.tachidesk.manga.controller.MangaController
import suwayomi.tachidesk.manga.controller.SourceController
import suwayomi.tachidesk.manga.controller.TrackController
import suwayomi.tachidesk.manga.controller.UpdateController
object MangaAPI {
@@ -132,5 +133,14 @@ object MangaAPI {
get("summary", UpdateController.updateSummary)
ws("", UpdateController::categoryUpdateWS)
}
// path("track") {
// get("list", TrackController.list)
// post("login", TrackController.login)
// post("logout", TrackController.logout)
// post("search", TrackController.search)
// post("bind", TrackController.bind)
// post("update", TrackController.update)
// }
}
}

View File

@@ -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)
},
)
}

View File

@@ -34,6 +34,7 @@ import org.jetbrains.exposed.sql.update
import suwayomi.tachidesk.manga.impl.Manga.getManga
import suwayomi.tachidesk.manga.impl.download.DownloadManager
import suwayomi.tachidesk.manga.impl.download.DownloadManager.EnqueueInput
import suwayomi.tachidesk.manga.impl.track.Track
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrStub
import suwayomi.tachidesk.manga.model.dataclass.ChapterDataClass
import suwayomi.tachidesk.manga.model.dataclass.MangaChapterDataClass
@@ -389,6 +390,10 @@ object Chapter {
}
}
}
if (isRead == true || markPrevRead == true) {
Track.asyncTrackChapter(mangaId)
}
}
@Serializable
@@ -469,6 +474,16 @@ object Chapter {
}
}
}
if (isRead == true) {
val mangaIds =
transaction {
ChapterTable.select { condition }
.map { it[ChapterTable.manga].value }
.distinct()
}
mangaIds.forEach { Track.asyncTrackChapter(it) }
}
}
fun getChaptersMetaMaps(chapterIds: List<EntityID<Int>>): Map<EntityID<Int>, Map<String, String>> {

View File

@@ -30,6 +30,7 @@ import suwayomi.tachidesk.manga.impl.Source.getSource
import suwayomi.tachidesk.manga.impl.download.DownloadManager
import suwayomi.tachidesk.manga.impl.download.DownloadManager.EnqueueInput
import suwayomi.tachidesk.manga.impl.download.fileProvider.impl.MissingThumbnailException
import suwayomi.tachidesk.manga.impl.track.Track
import suwayomi.tachidesk.manga.impl.util.network.await
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrNull
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrStub
@@ -105,6 +106,7 @@ object Manga {
chaptersLastFetchedAt = mangaEntry[MangaTable.chaptersLastFetchedAt],
updateStrategy = UpdateStrategy.valueOf(mangaEntry[MangaTable.updateStrategy]),
freshData = true,
trackers = Track.getTrackRecordsByMangaId(mangaId),
)
}
}
@@ -221,6 +223,7 @@ object Manga {
chaptersLastFetchedAt = mangaEntry[MangaTable.chaptersLastFetchedAt],
updateStrategy = UpdateStrategy.valueOf(mangaEntry[MangaTable.updateStrategy]),
freshData = false,
trackers = Track.getTrackRecordsByMangaId(mangaId),
)
fun getMangaMetaMap(mangaId: Int): Map<String, String> {

View File

@@ -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,
)
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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 }
}

View File

@@ -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"
}
}

View File

@@ -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
}
}
}

View File

@@ -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()
}
}

View File

@@ -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)
}
}

View File

@@ -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")
}

View File

@@ -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
}
}
}

View File

@@ -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]
}

View File

@@ -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 = ""
}

View File

@@ -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
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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()!!
}
}

View File

@@ -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
}

View File

@@ -42,6 +42,7 @@ data class MangaDataClass(
var lastChapterRead: ChapterDataClass? = null,
val age: Long? = if (lastFetchedAt == null) 0 else Instant.now().epochSecond.minus(lastFetchedAt),
val chaptersAge: Long? = if (chaptersLastFetchedAt == null) null else Instant.now().epochSecond.minus(chaptersLastFetchedAt),
val trackers: List<MangaTrackerDataClass>? = null,
)
data class PagedMangaListDataClass(

View File

@@ -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?,
)

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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?,
)

View File

@@ -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")
}

View File

@@ -35,6 +35,7 @@ import suwayomi.tachidesk.server.util.WebInterfaceManager
import java.io.IOException
import java.lang.IllegalArgumentException
import java.util.concurrent.CompletableFuture
import kotlin.NoSuchElementException
import kotlin.concurrent.thread
object JavalinSetup {
@@ -90,6 +91,13 @@ object JavalinSetup {
config.server { server }
config.addStaticFiles { staticFiles ->
staticFiles.hostedPath = "/static"
staticFiles.directory = "/static"
staticFiles.location = Location.CLASSPATH
staticFiles.headers = mapOf("cache-control" to "max-age=86400")
}
config.enableCorsForAllOrigins()
config.accessManager { handler, ctx, _ ->

View File

@@ -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(),
)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB