From ceac5f74c46f2ea4b82a6d7bbc156f84a0834118 Mon Sep 17 00:00:00 2001 From: Syer10 Date: Mon, 15 Jun 2026 20:11:55 -0400 Subject: [PATCH] Non-Extension Index changes for 1.6 --- gradle/libs.versions.toml | 2 + .../kanade/tachiyomi/network/HttpException.kt | 15 + .../tachiyomi/network/JavaScriptEngine.kt | 11 +- .../kanade/tachiyomi/network/NetworkHelper.kt | 2 + .../tachiyomi/network/OkHttpExtensions.kt | 85 +-- .../tachiyomi/network/ProgressResponseBody.kt | 6 +- .../eu/kanade/tachiyomi/network/Requests.kt | 9 +- .../tachiyomi/source/CatalogueSource.kt | 88 +-- .../tachiyomi/source/PreferenceScreen.kt | 4 + .../eu/kanade/tachiyomi/source/Source.kt | 101 ++-- .../tachiyomi/source/local/LocalSource.kt | 18 +- .../tachiyomi/source/model/MangasPage.kt | 20 +- .../eu/kanade/tachiyomi/source/model/Page.kt | 8 - .../kanade/tachiyomi/source/model/SChapter.kt | 18 +- .../tachiyomi/source/model/SChapterImpl.kt | 8 +- .../kanade/tachiyomi/source/model/SManga.kt | 72 +-- .../tachiyomi/source/model/SMangaImpl.kt | 12 +- .../tachiyomi/source/model/SMangaUpdate.kt | 7 + .../tachiyomi/source/model/UpdateStrategy.kt | 16 + .../tachiyomi/source/online/HttpSource.kt | 238 +++++---- .../source/online/ParsedHttpSource.kt | 26 + .../source/online/ResolvableSource.kt | 28 +- .../graphql/mutations/ChapterMutation.kt | 2 + .../graphql/mutations/MangaMutation.kt | 58 ++ .../suwayomi/tachidesk/manga/impl/Chapter.kt | 499 +++++++++--------- .../suwayomi/tachidesk/manga/impl/Manga.kt | 163 +++--- .../manga/impl/util/source/StubSource.kt | 11 + .../manga/model/dataclass/ChapterDataClass.kt | 5 + .../manga/model/dataclass/MangaDataClass.kt | 4 + .../manga/model/table/ChapterTable.kt | 6 + .../tachidesk/manga/model/table/MangaTable.kt | 5 + .../tachidesk/server/database/DBManager.kt | 3 + .../M0057_AddMangaChapterMemoFields.kt | 19 + 33 files changed, 954 insertions(+), 615 deletions(-) create mode 100644 server/src/main/kotlin/eu/kanade/tachiyomi/network/HttpException.kt create mode 100644 server/src/main/kotlin/eu/kanade/tachiyomi/source/PreferenceScreen.kt create mode 100644 server/src/main/kotlin/eu/kanade/tachiyomi/source/model/SMangaUpdate.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/server/database/migration/M0057_AddMangaChapterMemoFields.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e62dd34da..916ed647c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -70,6 +70,7 @@ exposed-dao = { module = "org.jetbrains.exposed:exposed-dao", version.ref = "exp exposed-jdbc = { module = "org.jetbrains.exposed:exposed-jdbc", version.ref = "exposed" } exposed-javatime = { module = "org.jetbrains.exposed:exposed-java-time", version.ref = "exposed" } exposed-kotlintime = { module = "org.jetbrains.exposed:exposed-kotlin-datetime", version.ref = "exposed" } +exposed-json = { module = "org.jetbrains.exposed:exposed-json ", version.ref = "exposed" } postgres = "org.postgresql:postgresql:42.7.11" h2 = "com.h2database:h2:2.4.240" hikaricp = "com.zaxxer:HikariCP:7.1.0" @@ -245,6 +246,7 @@ exposed = [ "exposed-jdbc", "exposed-javatime", "exposed-kotlintime", + "exposed-json", ] systemtray = [ "systemtray-core", diff --git a/server/src/main/kotlin/eu/kanade/tachiyomi/network/HttpException.kt b/server/src/main/kotlin/eu/kanade/tachiyomi/network/HttpException.kt new file mode 100644 index 000000000..0b551a656 --- /dev/null +++ b/server/src/main/kotlin/eu/kanade/tachiyomi/network/HttpException.kt @@ -0,0 +1,15 @@ +package eu.kanade.tachiyomi.network + +import okhttp3.Response + +/** + * Exception that handles HTTP codes considered not successful by OkHttp. + * Use it to have a standardized error message in the app across the extensions. + * + * @see Response.isSuccessful + * @since tachiyomix 1.6 + * @param code [Int] the HTTP status code + */ +class HttpException( + val code: Int, +) : IllegalStateException("HTTP error $code") diff --git a/server/src/main/kotlin/eu/kanade/tachiyomi/network/JavaScriptEngine.kt b/server/src/main/kotlin/eu/kanade/tachiyomi/network/JavaScriptEngine.kt index ac2e7b0c5..6356e78b3 100644 --- a/server/src/main/kotlin/eu/kanade/tachiyomi/network/JavaScriptEngine.kt +++ b/server/src/main/kotlin/eu/kanade/tachiyomi/network/JavaScriptEngine.kt @@ -2,26 +2,25 @@ package eu.kanade.tachiyomi.network import android.content.Context import app.cash.quickjs.QuickJs -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext +import eu.kanade.tachiyomi.util.lang.withIOContext /** * Util for evaluating JavaScript in sources. */ +@Suppress("UNUSED", "UNCHECKED_CAST") class JavaScriptEngine( - @Suppress("UNUSED_PARAMETER") context: Context, + context: Context, ) { /** * Evaluate arbitrary JavaScript code and get the result as a primitive type * (e.g., String, Int). * - * @since extensions-lib 1.4 + * @since tachiyomix 1.4 * @param script JavaScript to execute. * @return Result of JavaScript code as a primitive type. */ - @Suppress("UNUSED", "UNCHECKED_CAST") suspend fun evaluate(script: String): T = - withContext(Dispatchers.IO) { + withIOContext { QuickJs.create().use { it.evaluate(script) as T } diff --git a/server/src/main/kotlin/eu/kanade/tachiyomi/network/NetworkHelper.kt b/server/src/main/kotlin/eu/kanade/tachiyomi/network/NetworkHelper.kt index 503bb1b56..6e339ee2b 100644 --- a/server/src/main/kotlin/eu/kanade/tachiyomi/network/NetworkHelper.kt +++ b/server/src/main/kotlin/eu/kanade/tachiyomi/network/NetworkHelper.kt @@ -128,5 +128,7 @@ class NetworkHelper( // val client by lazy { baseClientBuilder.cache(Cache(cacheDir, cacheSize)).build() } val client by lazy { baseClientBuilder.build() } + @Deprecated("The regular client handles Cloudflare by default") + @Suppress("UNUSED") val cloudflareClient by lazy { client } } diff --git a/server/src/main/kotlin/eu/kanade/tachiyomi/network/OkHttpExtensions.kt b/server/src/main/kotlin/eu/kanade/tachiyomi/network/OkHttpExtensions.kt index 8e6e3f700..4622c67f2 100644 --- a/server/src/main/kotlin/eu/kanade/tachiyomi/network/OkHttpExtensions.kt +++ b/server/src/main/kotlin/eu/kanade/tachiyomi/network/OkHttpExtensions.kt @@ -15,11 +15,14 @@ import rx.Observable import rx.Producer import rx.Subscription import java.io.IOException -import java.util.concurrent.atomic.AtomicBoolean +import kotlin.concurrent.atomics.AtomicBoolean +import kotlin.concurrent.atomics.ExperimentalAtomicApi import kotlin.coroutines.resumeWithException val jsonMime = "application/json; charset=utf-8".toMediaType() +@OptIn(ExperimentalAtomicApi::class) +@Deprecated("Use suspend APIs instead") fun Call.asObservable(): Observable { return Observable.unsafeCreate { subscriber -> // Since Call is a one-shot type, clone it for each new subscriber. @@ -27,9 +30,11 @@ fun Call.asObservable(): Observable { // Wrap the call in a helper which handles both unsubscription and backpressure. val requestArbiter = - object : AtomicBoolean(), Producer, Subscription { + object : Producer, Subscription { + val boolean = AtomicBoolean(false) + override fun request(n: Long) { - if (n == 0L || !compareAndSet(false, true)) return + if (n == 0L || !boolean.compareAndSet(expectedValue = false, newValue = true)) return try { val response = call.execute() @@ -37,15 +42,15 @@ fun Call.asObservable(): Observable { subscriber.onNext(response) subscriber.onCompleted() } - } catch (error: Exception) { + } catch (e: Exception) { if (!subscriber.isUnsubscribed) { - subscriber.onError(error) + subscriber.onError(e) } } } override fun unsubscribe() { - // call.cancel() + call.cancel() } override fun isUnsubscribed(): Boolean = call.isCanceled() @@ -56,50 +61,50 @@ fun Call.asObservable(): Observable { } } -fun Call.asObservableSuccess(): Observable = - asObservable() - .doOnNext { response -> - if (!response.isSuccessful) { - response.close() - throw HttpException(response.code) +@Deprecated("Use suspend APIs instead") +fun Call.asObservableSuccess(): Observable { + @Suppress("DEPRECATION") + return asObservable().doOnNext { response -> + if (!response.isSuccessful) { + response.close() + throw HttpException(response.code) + } + } +} + +// Based on https://github.com/square/okhttp/blob/master/okhttp-coroutines/src/main/kotlin/okhttp3/coroutines/ExecuteAsync.kt +// and https://github.com/gildor/kotlin-coroutines-okhttp +private suspend fun Call.await(callStack: Array): Response { + return suspendCancellableCoroutine { continuation -> + continuation.invokeOnCancellation { + try { + this.cancel() + } catch (_: Throwable) { + // ignore } } -// Based on https://github.com/gildor/kotlin-coroutines-okhttp -private suspend fun Call.await(callStack: Array): Response { - return suspendCancellableCoroutine { continuation -> - val callback = + this.enqueue( object : Callback { - override fun onResponse( - call: Call, - response: Response, - ) { - continuation.resume(response) { _, resourceToClose, _ -> - response.body.close() - resourceToClose.close() - } - } - override fun onFailure( call: Call, e: IOException, ) { - // Don't bother with resuming the continuation if it is already cancelled. if (continuation.isCancelled) return val exception = IOException(e.message, e).apply { stackTrace = callStack } continuation.resumeWithException(exception) } - } - enqueue(callback) - - continuation.invokeOnCancellation { - try { - cancel() - } catch (ex: Throwable) { - // Ignore cancel exception - } - } + override fun onResponse( + call: Call, + response: Response, + ) { + continuation.resume(response) { _, value, _ -> + value.close() + } + } + }, + ) } } @@ -109,7 +114,7 @@ suspend fun Call.await(): Response { } /** - * @since extensions-lib 1.5 + * Similar to [await] but throws [HttpException] if [Response.isSuccessful] returns false */ suspend fun Call.awaitSuccess(): Response { val callStack = Exception().stackTrace.run { copyOfRange(1, size) } @@ -150,7 +155,3 @@ fun decodeFromJsonResponse( response.body.source().use { json.decodeFromBufferedSource(deserializer, it) } - -class HttpException( - val code: Int, -) : IllegalStateException("HTTP error $code") diff --git a/server/src/main/kotlin/eu/kanade/tachiyomi/network/ProgressResponseBody.kt b/server/src/main/kotlin/eu/kanade/tachiyomi/network/ProgressResponseBody.kt index 360967733..abc75ced1 100644 --- a/server/src/main/kotlin/eu/kanade/tachiyomi/network/ProgressResponseBody.kt +++ b/server/src/main/kotlin/eu/kanade/tachiyomi/network/ProgressResponseBody.kt @@ -35,7 +35,11 @@ class ProgressResponseBody( val bytesRead = super.read(sink, byteCount) // read() returns the number of bytes read, or -1 if this source is exhausted. totalBytesRead += if (bytesRead != -1L) bytesRead else 0 - progressListener.update(totalBytesRead, responseBody.contentLength(), bytesRead == -1L) + progressListener.update( + totalBytesRead, + responseBody.contentLength(), + bytesRead == -1L, + ) return bytesRead } } diff --git a/server/src/main/kotlin/eu/kanade/tachiyomi/network/Requests.kt b/server/src/main/kotlin/eu/kanade/tachiyomi/network/Requests.kt index d384dc571..3b3c5ac8b 100644 --- a/server/src/main/kotlin/eu/kanade/tachiyomi/network/Requests.kt +++ b/server/src/main/kotlin/eu/kanade/tachiyomi/network/Requests.kt @@ -6,6 +6,7 @@ import okhttp3.CacheControl import okhttp3.FormBody import okhttp3.Headers import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.Request import okhttp3.RequestBody import java.util.concurrent.TimeUnit.MINUTES @@ -18,13 +19,7 @@ fun GET( url: String, headers: Headers = DEFAULT_HEADERS, cache: CacheControl = DEFAULT_CACHE_CONTROL, -): Request = - Request - .Builder() - .url(url) - .headers(headers) - .cacheControl(cache) - .build() +): Request = GET(url.toHttpUrl(), headers, cache) /** * @since extensions-lib 1.4 diff --git a/server/src/main/kotlin/eu/kanade/tachiyomi/source/CatalogueSource.kt b/server/src/main/kotlin/eu/kanade/tachiyomi/source/CatalogueSource.kt index fa6f75dc1..b24123239 100644 --- a/server/src/main/kotlin/eu/kanade/tachiyomi/source/CatalogueSource.kt +++ b/server/src/main/kotlin/eu/kanade/tachiyomi/source/CatalogueSource.kt @@ -2,6 +2,12 @@ package eu.kanade.tachiyomi.source import eu.kanade.tachiyomi.source.model.FilterList import eu.kanade.tachiyomi.source.model.MangasPage +import eu.kanade.tachiyomi.source.model.Page +import eu.kanade.tachiyomi.source.model.SChapter +import eu.kanade.tachiyomi.source.model.SManga +import eu.kanade.tachiyomi.source.model.SMangaUpdate +import kotlinx.coroutines.async +import kotlinx.coroutines.supervisorScope import rx.Observable import suwayomi.tachidesk.manga.impl.util.lang.awaitSingle @@ -11,68 +17,62 @@ interface CatalogueSource : Source { */ override val lang: String - /** - * Whether the source has support for latest updates. - */ - val supportsLatest: Boolean - - /** - * Get a page with a list of manga. - * - * @since extensions-lib 1.5 - * @param page the page number to retrieve. - */ @Suppress("DEPRECATION") - suspend fun getPopularManga(page: Int): MangasPage = fetchPopularManga(page).awaitSingle() + override suspend fun getPopularManga(page: Int): MangasPage = fetchPopularManga(page).awaitSingle() - /** - * Get a page with a list of manga. - * - * @since extensions-lib 1.5 - * @param page the page number to retrieve. - * @param query the search query. - * @param filters the list of filters to apply. - */ @Suppress("DEPRECATION") - suspend fun getSearchManga( + override suspend fun getLatestUpdates(page: Int): MangasPage = fetchLatestUpdates(page).awaitSingle() + + @Suppress("DEPRECATION") + override suspend fun getSearchManga( page: Int, query: String, filters: FilterList, ): MangasPage = fetchSearchManga(page, query, filters).awaitSingle() + @Suppress("DEPRECATION") + override suspend fun getMangaUpdate( + manga: SManga, + chapters: List, + fetchDetails: Boolean, + fetchChapters: Boolean, + ): SMangaUpdate = + supervisorScope { + val asyncManga = if (fetchDetails) async { fetchMangaDetails(manga).awaitSingle() } else null + val asyncChapters = if (fetchChapters) async { fetchChapterList(manga).awaitSingle() } else null + SMangaUpdate(asyncManga?.await() ?: manga, asyncChapters?.await() ?: chapters) + } + + @Suppress("DEPRECATION") + override suspend fun getPageList(chapter: SChapter): List = fetchPageList(chapter).awaitSingle() + /** - * Get a page with a list of latest manga updates. + * Returns an observable containing a page with a list of manga. * - * @since extensions-lib 1.5 * @param page the page number to retrieve. */ - @Suppress("DEPRECATION") - suspend fun getLatestUpdates(page: Int): MangasPage = fetchLatestUpdates(page).awaitSingle() + @Deprecated("Use the suspend API instead", ReplaceWith("getPopularManga")) + fun fetchPopularManga(page: Int): Observable = throw UnsupportedOperationException() /** - * Returns the list of filters for the source. + * Returns an observable containing a page with a list of manga. + * + * @param page the page number to retrieve. + * @param query the search query. + * @param filters the list of filters to apply. */ - fun getFilterList(): FilterList - - @Deprecated( - "Use the non-RxJava API instead", - ReplaceWith("getPopularManga"), - ) - fun fetchPopularManga(page: Int): Observable = throw IllegalStateException("Not used") - - @Deprecated( - "Use the non-RxJava API instead", - ReplaceWith("getSearchManga"), - ) + @Deprecated("Use the suspend API instead", ReplaceWith("getSearchManga")) fun fetchSearchManga( page: Int, query: String, filters: FilterList, - ): Observable = throw IllegalStateException("Not used") + ): Observable = throw UnsupportedOperationException() - @Deprecated( - "Use the non-RxJava API instead", - ReplaceWith("getLatestUpdates"), - ) - fun fetchLatestUpdates(page: Int): Observable = throw IllegalStateException("Not used") + /** + * Returns an observable containing a page with a list of latest manga updates. + * + * @param page the page number to retrieve. + */ + @Deprecated("Use the suspend API instead", ReplaceWith("getLatestUpdates")) + fun fetchLatestUpdates(page: Int): Observable = throw UnsupportedOperationException() } diff --git a/server/src/main/kotlin/eu/kanade/tachiyomi/source/PreferenceScreen.kt b/server/src/main/kotlin/eu/kanade/tachiyomi/source/PreferenceScreen.kt new file mode 100644 index 000000000..0e6d5cdc2 --- /dev/null +++ b/server/src/main/kotlin/eu/kanade/tachiyomi/source/PreferenceScreen.kt @@ -0,0 +1,4 @@ +package eu.kanade.tachiyomi.source + +@Suppress("unused") +typealias PreferenceScreen = androidx.preference.PreferenceScreen diff --git a/server/src/main/kotlin/eu/kanade/tachiyomi/source/Source.kt b/server/src/main/kotlin/eu/kanade/tachiyomi/source/Source.kt index 76f2dfe80..e7e6ddefc 100644 --- a/server/src/main/kotlin/eu/kanade/tachiyomi/source/Source.kt +++ b/server/src/main/kotlin/eu/kanade/tachiyomi/source/Source.kt @@ -1,10 +1,12 @@ package eu.kanade.tachiyomi.source +import eu.kanade.tachiyomi.source.model.FilterList +import eu.kanade.tachiyomi.source.model.MangasPage import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SManga +import eu.kanade.tachiyomi.source.model.SMangaUpdate import rx.Observable -import suwayomi.tachidesk.manga.impl.util.lang.awaitSingle /** * A basic interface for creating a source. It could be an online source, a local source, etc. @@ -24,53 +26,86 @@ interface Source { get() = "" /** - * Get the updated details for a manga. - * - * @since extensions-lib 1.5 - * @param manga the manga to update. - * @return the updated manga. + * Whether the source has support for latest updates. */ - @Suppress("DEPRECATION") - suspend fun getMangaDetails(manga: SManga): SManga = fetchMangaDetails(manga).awaitSingle() + val supportsLatest: Boolean /** - * Get all the available chapters for a manga. - * - * @since extensions-lib 1.5 - * @param manga the manga to update. - * @return the chapters for the manga. + * Returns the list of filters for the source. */ - @Suppress("DEPRECATION") - suspend fun getChapterList(manga: SManga): List = fetchChapterList(manga).awaitSingle() + fun getFilterList(): FilterList = FilterList() + + /** + * Get a page with a list of manga. + * + * @since tachiyomix 1.6 + * @param page the page number to retrieve. + */ + suspend fun getPopularManga(page: Int): MangasPage + + /** + * Get a page with a list of latest manga updates. + * + * @since tachiyomix 1.6 + * @param page the page number to retrieve. + */ + suspend fun getLatestUpdates(page: Int): MangasPage + + /** + * Get a page with a list of manga. + * + * @since tachiyomix 1.6 + * @param page the page number to retrieve. + * @param query the search query. + * @param filters the list of filters to apply. + */ + suspend fun getSearchManga( + page: Int, + query: String, + filters: FilterList, + ): MangasPage + + /** + * Fetches updated information for a manga. + * + * Depending on the provided flags or source availability, this may include + * updated manga metadata, available chapters, or both. + * + * If a value is not requested, the existing provided value can be returned as-is. + * The host app may apply any returned updates regardless of the flags, + * so care should be taken to only return accurate and intentional changes. + * + * @since tachiyomix 1.6 + * @param manga The manga to fetch updates for. + * @param chapters Existing chapters of the manga + * @param fetchDetails Whether to fetch updated manga details. + * @param fetchChapters Whether to fetch available chapters. + */ + suspend fun getMangaUpdate( + manga: SManga, + chapters: List, + fetchDetails: Boolean, + fetchChapters: Boolean, + ): SMangaUpdate /** * Get the list of pages a chapter has. Pages should be returned * in the expected order; the index is ignored. * - * @since extensions-lib 1.5 + * @since tachiyomix 1.6 * @param chapter the chapter. * @return the pages for the chapter. */ - @Suppress("DEPRECATION") - suspend fun getPageList(chapter: SChapter): List = fetchPageList(chapter).awaitSingle() + suspend fun getPageList(chapter: SChapter): List - @Deprecated( - "Use the non-RxJava API instead", - ReplaceWith("getMangaDetails"), - ) - fun fetchMangaDetails(manga: SManga): Observable = throw IllegalStateException("Not used") + @Deprecated("Use the combined suspend API instead", ReplaceWith("getMangaUpdate")) + fun fetchMangaDetails(manga: SManga): Observable = throw UnsupportedOperationException() - @Deprecated( - "Use the non-RxJava API instead", - ReplaceWith("getChapterList"), - ) - fun fetchChapterList(manga: SManga): Observable> = throw IllegalStateException("Not used") + @Deprecated("Use the combined suspend API instead", ReplaceWith("getMangaUpdate")) + fun fetchChapterList(manga: SManga): Observable> = throw UnsupportedOperationException() - @Deprecated( - "Use the non-RxJava API instead", - ReplaceWith("getPageList"), - ) - fun fetchPageList(chapter: SChapter): Observable> = throw IllegalStateException("Not used") + @Deprecated("Use the suspend API instead", ReplaceWith("getPageList")) + fun fetchPageList(chapter: SChapter): Observable> = throw UnsupportedOperationException() } // fun Source.icon(): Drawable? = Injekt.get().getAppIconForSource(this) diff --git a/server/src/main/kotlin/eu/kanade/tachiyomi/source/local/LocalSource.kt b/server/src/main/kotlin/eu/kanade/tachiyomi/source/local/LocalSource.kt index 41035e2c1..bf102bd75 100644 --- a/server/src/main/kotlin/eu/kanade/tachiyomi/source/local/LocalSource.kt +++ b/server/src/main/kotlin/eu/kanade/tachiyomi/source/local/LocalSource.kt @@ -23,12 +23,15 @@ import eu.kanade.tachiyomi.source.model.MangasPage import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SManga +import eu.kanade.tachiyomi.source.model.SMangaUpdate import eu.kanade.tachiyomi.util.chapter.ChapterRecognition import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder import eu.kanade.tachiyomi.util.storage.EpubFile import io.github.oshai.kotlinlogging.KotlinLogging import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import kotlinx.serialization.json.decodeFromStream @@ -167,8 +170,19 @@ class LocalSource( return MangasPage(mangas.toList(), false) } + override suspend fun getMangaUpdate( + manga: SManga, + chapters: List, + fetchDetails: Boolean, + fetchChapters: Boolean, + ): SMangaUpdate = supervisorScope { + val asyncManga = if (fetchDetails) async { getMangaDetails(manga) } else null + val asyncChapters = if (fetchChapters) async { getChapterList(manga) } else null + SMangaUpdate(asyncManga?.await() ?: manga, asyncChapters?.await() ?: chapters) + } + // Manga details related - override suspend fun getMangaDetails(manga: SManga): SManga = + private suspend fun getMangaDetails(manga: SManga): SManga = withContext(Dispatchers.IO) { coverManager.find(manga.url)?.let { manga.thumbnail_url = it.absolutePath @@ -289,7 +303,7 @@ class LocalSource( } // Chapters - override suspend fun getChapterList(manga: SManga): List = + private suspend fun getChapterList(manga: SManga): List = fileSystem .getFilesInMangaDirectory(manga.url) // Only keep supported formats diff --git a/server/src/main/kotlin/eu/kanade/tachiyomi/source/model/MangasPage.kt b/server/src/main/kotlin/eu/kanade/tachiyomi/source/model/MangasPage.kt index cda439bf0..0f72131e1 100644 --- a/server/src/main/kotlin/eu/kanade/tachiyomi/source/model/MangasPage.kt +++ b/server/src/main/kotlin/eu/kanade/tachiyomi/source/model/MangasPage.kt @@ -1,6 +1,22 @@ package eu.kanade.tachiyomi.source.model -data class MangasPage( +class MangasPage( val mangas: List, val hasNextPage: Boolean, -) +) { + @Deprecated("MangasPage is now a regular class") + operator fun component1(): List = mangas + + @Deprecated("MangasPage is now a regular class") + operator fun component2(): Boolean = hasNextPage + + @Deprecated("MangasPage is now a regular class") + fun copy( + mangas: List = this.mangas, + hasNextPage: Boolean = this.hasNextPage, + ): MangasPage = + MangasPage( + mangas = mangas, + hasNextPage = hasNextPage, + ) +} diff --git a/server/src/main/kotlin/eu/kanade/tachiyomi/source/model/Page.kt b/server/src/main/kotlin/eu/kanade/tachiyomi/source/model/Page.kt index 5bebace06..584fbdf61 100644 --- a/server/src/main/kotlin/eu/kanade/tachiyomi/source/model/Page.kt +++ b/server/src/main/kotlin/eu/kanade/tachiyomi/source/model/Page.kt @@ -27,12 +27,4 @@ open class Page( -1 } } - - companion object { - const val QUEUE = 0 - const val LOAD_PAGE = 1 - const val DOWNLOAD_IMAGE = 2 - const val READY = 3 - const val ERROR = 4 - } } diff --git a/server/src/main/kotlin/eu/kanade/tachiyomi/source/model/SChapter.kt b/server/src/main/kotlin/eu/kanade/tachiyomi/source/model/SChapter.kt index 5ae70aa20..c6e5251ce 100644 --- a/server/src/main/kotlin/eu/kanade/tachiyomi/source/model/SChapter.kt +++ b/server/src/main/kotlin/eu/kanade/tachiyomi/source/model/SChapter.kt @@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.source.model +import kotlinx.serialization.json.JsonObject import java.io.Serializable interface SChapter : Serializable { @@ -9,12 +10,25 @@ interface SChapter : Serializable { var name: String - var date_upload: Long - var chapter_number: Float var scanlator: String? + var date_upload: Long + + /** + * Extra metadata associated with the chapter. + * + * The JSON object is not visible to users and intended for internal or source-specific + * purposes. Apps may define their own namespaced keys (e.g., `"mihon.*"`) for sources to populate. + * + * This allows apps to attach and ask for custom information without affecting the visible + * chapter data. + * + * @since tachiyomix 1.6 + */ + var memo: JsonObject + fun copyFrom(other: SChapter) { name = other.name url = other.url diff --git a/server/src/main/kotlin/eu/kanade/tachiyomi/source/model/SChapterImpl.kt b/server/src/main/kotlin/eu/kanade/tachiyomi/source/model/SChapterImpl.kt index 7c96902d2..b8abe7e8b 100644 --- a/server/src/main/kotlin/eu/kanade/tachiyomi/source/model/SChapterImpl.kt +++ b/server/src/main/kotlin/eu/kanade/tachiyomi/source/model/SChapterImpl.kt @@ -2,14 +2,18 @@ package eu.kanade.tachiyomi.source.model +import kotlinx.serialization.json.JsonObject + class SChapterImpl : SChapter { override lateinit var url: String override lateinit var name: String - override var date_upload: Long = 0 - override var chapter_number: Float = -1f override var scanlator: String? = null + + override var date_upload: Long = 0 + + override var memo: JsonObject = JsonObject(emptyMap()) } diff --git a/server/src/main/kotlin/eu/kanade/tachiyomi/source/model/SManga.kt b/server/src/main/kotlin/eu/kanade/tachiyomi/source/model/SManga.kt index f50e430ba..6ba982fab 100644 --- a/server/src/main/kotlin/eu/kanade/tachiyomi/source/model/SManga.kt +++ b/server/src/main/kotlin/eu/kanade/tachiyomi/source/model/SManga.kt @@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.source.model +import kotlinx.serialization.json.JsonObject import java.io.Serializable interface SManga : Serializable { @@ -9,22 +10,58 @@ interface SManga : Serializable { var title: String + var thumbnail_url: String? + var artist: String? var author: String? + var status: Int + var description: String? var genre: String? - var status: Int - - var thumbnail_url: String? - var update_strategy: UpdateStrategy var initialized: Boolean + /** + * Extra metadata associated with the manga. + * + * The JSON object is not visible to users and intended for internal or source-specific + * purposes. Apps may define their own namespaced keys (e.g., `"mihon.*"`) for sources to populate. + * + * This allows apps to attach and ask for custom information without affecting the visible + * manga data. + * + * @since tachiyomix 1.6 + */ + var memo: JsonObject + + fun getGenres(): List? { + if (genre.isNullOrBlank()) return null + return genre + ?.split(", ") + ?.map { it.trim() } + ?.filterNot { it.isBlank() } + ?.distinct() + } + + fun copy() = + create().also { + it.url = url + it.title = title + it.artist = artist + it.author = author + it.description = description + it.genre = genre + it.status = status + it.thumbnail_url = thumbnail_url + it.update_strategy = update_strategy + it.initialized = initialized + } + companion object { const val UNKNOWN = 0 const val ONGOING = 1 @@ -37,30 +74,3 @@ interface SManga : Serializable { fun create(): SManga = SMangaImpl() } } - -// fun SManga.toMangaInfo(): MangaInfo { -// return MangaInfo( -// key = this.url, -// title = this.title, -// artist = this.artist ?: "", -// author = this.author ?: "", -// description = this.description ?: "", -// genres = this.genre?.split(", ") ?: emptyList(), -// status = this.status, -// cover = this.thumbnail_url ?: "" -// ) -// } -// -// fun MangaInfo.toSManga(): SManga { -// val mangaInfo = this -// return SManga.create().apply { -// url = mangaInfo.key -// title = mangaInfo.title -// artist = mangaInfo.artist -// author = mangaInfo.author -// description = mangaInfo.description -// genre = mangaInfo.genres.joinToString(", ") -// status = mangaInfo.status -// thumbnail_url = mangaInfo.cover -// } -// } diff --git a/server/src/main/kotlin/eu/kanade/tachiyomi/source/model/SMangaImpl.kt b/server/src/main/kotlin/eu/kanade/tachiyomi/source/model/SMangaImpl.kt index fa696dd19..561b64f7c 100644 --- a/server/src/main/kotlin/eu/kanade/tachiyomi/source/model/SMangaImpl.kt +++ b/server/src/main/kotlin/eu/kanade/tachiyomi/source/model/SMangaImpl.kt @@ -2,24 +2,28 @@ package eu.kanade.tachiyomi.source.model +import kotlinx.serialization.json.JsonObject + class SMangaImpl : SManga { override lateinit var url: String override lateinit var title: String + override var thumbnail_url: String? = null + override var artist: String? = null override var author: String? = null + override var status: Int = 0 + override var description: String? = null override var genre: String? = null - override var status: Int = 0 - - override var thumbnail_url: String? = null - override var update_strategy: UpdateStrategy = UpdateStrategy.ALWAYS_UPDATE override var initialized: Boolean = false + + override var memo: JsonObject = JsonObject(emptyMap()) } diff --git a/server/src/main/kotlin/eu/kanade/tachiyomi/source/model/SMangaUpdate.kt b/server/src/main/kotlin/eu/kanade/tachiyomi/source/model/SMangaUpdate.kt new file mode 100644 index 000000000..19a692f87 --- /dev/null +++ b/server/src/main/kotlin/eu/kanade/tachiyomi/source/model/SMangaUpdate.kt @@ -0,0 +1,7 @@ +package eu.kanade.tachiyomi.source.model + +@Suppress("UNUSED") +class SMangaUpdate( + val manga: SManga, + val chapters: List, +) diff --git a/server/src/main/kotlin/eu/kanade/tachiyomi/source/model/UpdateStrategy.kt b/server/src/main/kotlin/eu/kanade/tachiyomi/source/model/UpdateStrategy.kt index 2ebd485c8..91b5f5e29 100644 --- a/server/src/main/kotlin/eu/kanade/tachiyomi/source/model/UpdateStrategy.kt +++ b/server/src/main/kotlin/eu/kanade/tachiyomi/source/model/UpdateStrategy.kt @@ -1,6 +1,22 @@ package eu.kanade.tachiyomi.source.model +/** + * Define the update strategy for a single [SManga]. + * The strategy used will only take effect on the library update. + * + * @since extensions-lib 1.4 + */ enum class UpdateStrategy { + /** + * Series marked as always update will be included in the library + * update if they aren't excluded by additional restrictions. + */ ALWAYS_UPDATE, + + /** + * Series marked as only fetch once will be automatically skipped + * during library updates. Useful for cases where the series is previously + * known to be finished and have only a single chapter, for example. + */ ONLY_FETCH_ONCE, } diff --git a/server/src/main/kotlin/eu/kanade/tachiyomi/source/online/HttpSource.kt b/server/src/main/kotlin/eu/kanade/tachiyomi/source/online/HttpSource.kt index 966b67c4f..1a5719718 100644 --- a/server/src/main/kotlin/eu/kanade/tachiyomi/source/online/HttpSource.kt +++ b/server/src/main/kotlin/eu/kanade/tachiyomi/source/online/HttpSource.kt @@ -25,7 +25,6 @@ import java.security.MessageDigest /** * A simple implementation for sources from a website. */ -@Suppress("unused") abstract class HttpSource : CatalogueSource { /** * Network service. @@ -37,11 +36,24 @@ abstract class HttpSource : CatalogueSource { */ abstract val baseUrl: String + /** + * Returns the base (home) URL of the website as a string. + * + * This is typically the root address that serves as the main entry point + * to the site's content, such as "https://mihon.tech". + * + * This method is used in the browse screen to determine the URL + * opened when tapping "Open in WebView". + * + * @return The website’s home page URL. Defaults to [baseUrl]. + */ + open fun getHomeUrl(): String = baseUrl + /** * Version id used to generate the source id. If the site completely changes and urls are * incompatible, you may increase this value and it'll be considered as a new source. */ - open val versionId = 1 + open val versionId: Int = 1 /** * ID of the source. By default it uses a generated id using the first 16 characters (64 bits) @@ -53,7 +65,7 @@ abstract class HttpSource : CatalogueSource { * * Note: the generated ID sets the sign bit to `0`. */ - override val id by lazy { generateId() } + override val id: Long by lazy { generateId(name, lang, versionId) } /** * Headers used for requests. @@ -63,10 +75,7 @@ abstract class HttpSource : CatalogueSource { /** * Default network client for doing requests. */ - open val client: OkHttpClient - get() = network.client - - private fun generateId(): Long = generateId("${name.lowercase()}/$lang/$versionId") + open val client: OkHttpClient get() = network.client /** * Generates a unique ID for the source based on the provided [name], [lang] and @@ -91,10 +100,6 @@ abstract class HttpSource : CatalogueSource { versionId: Int, ): Long { val key = "${name.lowercase()}/$lang/$versionId" - return generateId(key) - } - - private fun generateId(key: String): Long { val bytes = MessageDigest.getInstance("MD5").digest(key.toByteArray()) return (0..7).map { bytes[it].toLong() and 0xff shl 8 * (7 - it) }.reduce(Long::or) and Long.MAX_VALUE } @@ -102,7 +107,7 @@ abstract class HttpSource : CatalogueSource { /** * Headers builder for requests. Implementations can override this method for custom headers. */ - protected open fun headersBuilder() = + protected open fun headersBuilder(): Headers.Builder = Headers.Builder().apply { add("User-Agent", network.defaultUserAgentProvider()) } @@ -110,7 +115,7 @@ abstract class HttpSource : CatalogueSource { /** * Visible name of the source. */ - override fun toString() = "$name (${lang.uppercase()})" + override fun toString(): String = "$name (${lang.uppercase()})" /** * Returns an observable containing a page with a list of manga. Normally it's not needed to @@ -118,7 +123,8 @@ abstract class HttpSource : CatalogueSource { * * @param page the page number to retrieve. */ - @Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getPopularManga")) + @Suppress("DEPRECATION") + @Deprecated("Use the suspend API instead", ReplaceWith("getPopularManga")) override fun fetchPopularManga(page: Int): Observable = client .newCall(popularMangaRequest(page)) @@ -132,14 +138,24 @@ abstract class HttpSource : CatalogueSource { * * @param page the page number to retrieve. */ - protected abstract fun popularMangaRequest(page: Int): Request + @Deprecated( + message = + "The helper functions are inherently limiting and hides the underlying implementation. " + + "Source developers should make their own implementation according to their needs.", + ) + protected open fun popularMangaRequest(page: Int): Request = throw UnsupportedOperationException() /** * Parses the response from the site and returns a [MangasPage] object. * * @param response the response from the site. */ - protected abstract fun popularMangaParse(response: Response): MangasPage + @Deprecated( + message = + "The helper functions are inherently limiting and hides the underlying implementation. " + + "Source developers should make their own implementation according to their needs.", + ) + protected open fun popularMangaParse(response: Response): MangasPage = throw UnsupportedOperationException() /** * Returns an observable containing a page with a list of manga. Normally it's not needed to @@ -149,22 +165,17 @@ abstract class HttpSource : CatalogueSource { * @param query the search query. * @param filters the list of filters to apply. */ - @Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getSearchManga")) + @Suppress("DEPRECATION") + @Deprecated("Use the suspend API instead", ReplaceWith("getSearchManga")) override fun fetchSearchManga( page: Int, query: String, filters: FilterList, ): Observable = - Observable - .defer { - try { - client.newCall(searchMangaRequest(page, query, filters)).asObservableSuccess() - } catch (e: NoClassDefFoundError) { - // RxJava doesn't handle Errors, which tends to happen during global searches - // if an old extension using non-existent classes is still around - throw RuntimeException(e) - } - }.map { response -> + client + .newCall(searchMangaRequest(page, query, filters)) + .asObservableSuccess() + .map { response -> searchMangaParse(response) } @@ -175,25 +186,36 @@ abstract class HttpSource : CatalogueSource { * @param query the search query. * @param filters the list of filters to apply. */ - protected abstract fun searchMangaRequest( + @Deprecated( + message = + "The helper functions are inherently limiting and hides the underlying implementation. " + + "Source developers should make their own implementation according to their needs.", + ) + protected open fun searchMangaRequest( page: Int, query: String, filters: FilterList, - ): Request + ): Request = throw UnsupportedOperationException() /** * Parses the response from the site and returns a [MangasPage] object. * * @param response the response from the site. */ - protected abstract fun searchMangaParse(response: Response): MangasPage + @Deprecated( + message = + "The helper functions are inherently limiting and hides the underlying implementation. " + + "Source developers should make their own implementation according to their needs.", + ) + protected open fun searchMangaParse(response: Response): MangasPage = throw UnsupportedOperationException() /** * Returns an observable containing a page with a list of latest manga updates. * * @param page the page number to retrieve. */ - @Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getLatestUpdates")) + @Suppress("DEPRECATION") + @Deprecated("Use the suspend API instead", ReplaceWith("getLatestUpdates")) override fun fetchLatestUpdates(page: Int): Observable = client .newCall(latestUpdatesRequest(page)) @@ -207,26 +229,33 @@ abstract class HttpSource : CatalogueSource { * * @param page the page number to retrieve. */ - protected abstract fun latestUpdatesRequest(page: Int): Request + @Deprecated( + message = + "The helper functions are inherently limiting and hides the underlying implementation. " + + "Source developers should make their own implementation according to their needs.", + ) + protected open fun latestUpdatesRequest(page: Int): Request = throw UnsupportedOperationException() /** * Parses the response from the site and returns a [MangasPage] object. * * @param response the response from the site. */ - protected abstract fun latestUpdatesParse(response: Response): MangasPage + @Deprecated( + message = + "The helper functions are inherently limiting and hides the underlying implementation. " + + "Source developers should make their own implementation according to their needs.", + ) + protected open fun latestUpdatesParse(response: Response): MangasPage = throw UnsupportedOperationException() /** - * Get the updated details for a manga. - * Normally it's not needed to override this method. + * Returns an observable with the updated details for a manga. Normally it's not needed to + * override this method. * - * @param manga the manga to update. - * @return the updated manga. + * @param manga the manga to be updated. */ @Suppress("DEPRECATION") - override suspend fun getMangaDetails(manga: SManga): SManga = fetchMangaDetails(manga).awaitSingle() - - @Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getMangaDetails")) + @Deprecated("Use the combined suspend API instead", replaceWith = ReplaceWith("getMangaUpdate")) override fun fetchMangaDetails(manga: SManga): Observable = client .newCall(mangaDetailsRequest(manga)) @@ -241,6 +270,11 @@ abstract class HttpSource : CatalogueSource { * * @param manga the manga to be updated. */ + @Deprecated( + message = + "The helper functions are inherently limiting and hides the underlying implementation. " + + "Source developers should make their own implementation according to their needs.", + ) open fun mangaDetailsRequest(manga: SManga): Request = GET(baseUrl + manga.url, headers) /** @@ -248,37 +282,28 @@ abstract class HttpSource : CatalogueSource { * * @param response the response from the site. */ - protected abstract fun mangaDetailsParse(response: Response): SManga + @Deprecated( + message = + "The helper functions are inherently limiting and hides the underlying implementation. " + + "Source developers should make their own implementation according to their needs.", + ) + protected open fun mangaDetailsParse(response: Response): SManga = throw UnsupportedOperationException() /** - * Get all the available chapters for a manga. - * Normally it's not needed to override this method. + * Returns an observable with the updated chapter list for a manga. Normally it's not needed to + * override this method. * - * @param manga the manga to update. - * @return the chapters for the manga. - * @throws LicensedMangaChaptersException if a manga is licensed and therefore no chapters are available. + * @param manga the manga to look for chapters. */ @Suppress("DEPRECATION") - override suspend fun getChapterList(manga: SManga): List { - if (manga.status == SManga.LICENSED) { - throw LicensedMangaChaptersException() - } - - return fetchChapterList(manga).awaitSingle() - } - - @Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getChapterList")) + @Deprecated("Use the combined suspend API instead", replaceWith = ReplaceWith("getMangaUpdate")) override fun fetchChapterList(manga: SManga): Observable> = - if (manga.status != SManga.LICENSED) { - client - .newCall(chapterListRequest(manga)) - .asObservableSuccess() - .map { response -> - chapterListParse(response) - } - } else { - Observable.error(LicensedMangaChaptersException()) - } + client + .newCall(chapterListRequest(manga)) + .asObservableSuccess() + .map { response -> + chapterListParse(response) + } /** * Returns the request for updating the chapter list. Override only if it's needed to override @@ -286,6 +311,11 @@ abstract class HttpSource : CatalogueSource { * * @param manga the manga to look for chapters. */ + @Deprecated( + message = + "The helper functions are inherently limiting and hides the underlying implementation. " + + "Source developers should make their own implementation according to their needs.", + ) protected open fun chapterListRequest(manga: SManga): Request = GET(baseUrl + manga.url, headers) /** @@ -293,19 +323,20 @@ abstract class HttpSource : CatalogueSource { * * @param response the response from the site. */ - protected abstract fun chapterListParse(response: Response): List + @Deprecated( + message = + "The helper functions are inherently limiting and hides the underlying implementation. " + + "Source developers should make their own implementation according to their needs.", + ) + protected open fun chapterListParse(response: Response): List = throw UnsupportedOperationException() /** - * Get the list of pages a chapter has. Pages should be returned - * in the expected order; the index is ignored. + * Returns an observable with the page list for a chapter. * - * @param chapter the chapter. - * @return the pages for the chapter. + * @param chapter the chapter whose page list has to be fetched. */ @Suppress("DEPRECATION") - override suspend fun getPageList(chapter: SChapter): List = fetchPageList(chapter).awaitSingle() - - @Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getPageList")) + @Deprecated("Use the suspend API instead", ReplaceWith("getPageList")) override fun fetchPageList(chapter: SChapter): Observable> = client .newCall(pageListRequest(chapter)) @@ -320,6 +351,11 @@ abstract class HttpSource : CatalogueSource { * * @param chapter the chapter whose page list has to be fetched. */ + @Deprecated( + message = + "The helper functions are inherently limiting and hides the underlying implementation. " + + "Source developers should make their own implementation according to their needs.", + ) protected open fun pageListRequest(chapter: SChapter): Request = GET(baseUrl + chapter.url, headers) /** @@ -327,31 +363,47 @@ abstract class HttpSource : CatalogueSource { * * @param response the response from the site. */ - protected abstract fun pageListParse(response: Response): List + @Deprecated( + message = + "The helper functions are inherently limiting and hides the underlying implementation. " + + "Source developers should make their own implementation according to their needs.", + ) + protected open fun pageListParse(response: Response): List = throw UnsupportedOperationException() /** * Returns an observable with the page containing the source url of the image. If there's any * error, it will return null instead of throwing an exception. * - * @since extensions-lib 1.5 * @param page the page whose source image has to be fetched. */ @Suppress("DEPRECATION") - open suspend fun getImageUrl(page: Page): String = fetchImageUrl(page).awaitSingle() - - @Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getImageUrl")) + @Deprecated("Use the suspend API instead", ReplaceWith("getImageUrl")) open fun fetchImageUrl(page: Page): Observable = client .newCall(imageUrlRequest(page)) .asObservableSuccess() .map { imageUrlParse(it) } + /** + * Returns the image url for the provided [page]. The function is only called if [Page.imageUrl] is null. + * + * @since tachiyomix 1.6 + * @param page the page whose source image has to be fetched. + */ + @Suppress("DEPRECATION") + open suspend fun getImageUrl(page: Page): String = fetchImageUrl(page).awaitSingle() + /** * Returns the request for getting the url to the source image. Override only if it's needed to * override the url, send different headers or request method like POST. * * @param page the chapter whose page list has to be fetched */ + @Deprecated( + message = + "The helper functions are inherently limiting and hides the underlying implementation. " + + "Source developers should make their own implementation according to their needs.", + ) protected open fun imageUrlRequest(page: Page): Request = GET(page.url, headers) /** @@ -359,16 +411,14 @@ abstract class HttpSource : CatalogueSource { * * @param response the response from the site. */ - protected abstract fun imageUrlParse(response: Response): String + @Deprecated( + message = + "The helper functions are inherently limiting and hides the underlying implementation. " + + "Source developers should make their own implementation according to their needs.", + ) + protected open fun imageUrlParse(response: Response): String = throw UnsupportedOperationException() - /** - * Returns the response of the source image. - * Typically does not need to be overridden. - * - * @since extensions-lib 1.5 - * @param page the page whose source image has to be downloaded. - */ - open suspend fun getImage(page: Page): Response = + suspend fun getImage(page: Page): Response = client .newCachelessCallWithProgress(imageRequest(page), page) .awaitSuccess() @@ -387,6 +437,7 @@ abstract class HttpSource : CatalogueSource { * * @param url the full url to the chapter. */ + @Suppress("Unused") fun SChapter.setUrlWithoutDomain(url: String) { this.url = getUrlWithoutDomain(url) } @@ -397,6 +448,7 @@ abstract class HttpSource : CatalogueSource { * * @param url the full url to the manga. */ + @Suppress("Unused") fun SManga.setUrlWithoutDomain(url: String) { this.url = getUrlWithoutDomain(url) } @@ -417,7 +469,7 @@ abstract class HttpSource : CatalogueSource { out += "#" + uri.fragment } out - } catch (e: URISyntaxException) { + } catch (_: URISyntaxException) { orig } @@ -428,6 +480,7 @@ abstract class HttpSource : CatalogueSource { * @param manga the manga * @return url of the manga */ + @Suppress("DEPRECATION") open fun getMangaUrl(manga: SManga): String = mangaDetailsRequest(manga).url.toString() /** @@ -437,6 +490,7 @@ abstract class HttpSource : CatalogueSource { * @param chapter the chapter * @return url of the chapter */ + @Suppress("DEPRECATION") open fun getChapterUrl(chapter: SChapter): String = pageListRequest(chapter).url.toString() /** @@ -446,15 +500,9 @@ abstract class HttpSource : CatalogueSource { * @param chapter the chapter to be added. * @param manga the manga of the chapter. */ + @Deprecated("All modifications should be done when constructing the chapter") open fun prepareNewChapter( chapter: SChapter, manga: SManga, ) {} - - /** - * Returns the list of filters for the source. - */ - override fun getFilterList() = FilterList() } - -class LicensedMangaChaptersException : Exception("Licensed - No chapters to show") diff --git a/server/src/main/kotlin/eu/kanade/tachiyomi/source/online/ParsedHttpSource.kt b/server/src/main/kotlin/eu/kanade/tachiyomi/source/online/ParsedHttpSource.kt index 66db603b9..ec5648463 100644 --- a/server/src/main/kotlin/eu/kanade/tachiyomi/source/online/ParsedHttpSource.kt +++ b/server/src/main/kotlin/eu/kanade/tachiyomi/source/online/ParsedHttpSource.kt @@ -12,12 +12,20 @@ import org.jsoup.nodes.Element /** * A simple implementation for sources from a website using Jsoup, an HTML parser. */ +@Deprecated( + message = + "In most cases sources only require a subset of the methods from this class. " + + "Source developers should make their own implementation according to their needs.", +) abstract class ParsedHttpSource : HttpSource() { /** * Parses the response from the site and returns a [MangasPage] object. * * @param response the response from the site. */ + @Deprecated( + "The helper functions are inherently limiting and hides the underlying implementation. Source developers should make their own implementation according to their needs.", + ) override fun popularMangaParse(response: Response): MangasPage { val document = response.asJsoup() @@ -58,6 +66,9 @@ abstract class ParsedHttpSource : HttpSource() { * * @param response the response from the site. */ + @Deprecated( + "The helper functions are inherently limiting and hides the underlying implementation. Source developers should make their own implementation according to their needs.", + ) override fun searchMangaParse(response: Response): MangasPage { val document = response.asJsoup() @@ -98,6 +109,9 @@ abstract class ParsedHttpSource : HttpSource() { * * @param response the response from the site. */ + @Deprecated( + "The helper functions are inherently limiting and hides the underlying implementation. Source developers should make their own implementation according to their needs.", + ) override fun latestUpdatesParse(response: Response): MangasPage { val document = response.asJsoup() @@ -138,6 +152,9 @@ abstract class ParsedHttpSource : HttpSource() { * * @param response the response from the site. */ + @Deprecated( + "The helper functions are inherently limiting and hides the underlying implementation. Source developers should make their own implementation according to their needs.", + ) override fun mangaDetailsParse(response: Response): SManga = mangaDetailsParse(response.asJsoup()) /** @@ -152,6 +169,9 @@ abstract class ParsedHttpSource : HttpSource() { * * @param response the response from the site. */ + @Deprecated( + "The helper functions are inherently limiting and hides the underlying implementation. Source developers should make their own implementation according to their needs.", + ) override fun chapterListParse(response: Response): List { val document = response.asJsoup() return document.select(chapterListSelector()).map { chapterFromElement(it) } @@ -174,6 +194,9 @@ abstract class ParsedHttpSource : HttpSource() { * * @param response the response from the site. */ + @Deprecated( + "The helper functions are inherently limiting and hides the underlying implementation. Source developers should make their own implementation according to their needs.", + ) override fun pageListParse(response: Response): List = pageListParse(response.asJsoup()) /** @@ -188,6 +211,9 @@ abstract class ParsedHttpSource : HttpSource() { * * @param response the response from the site. */ + @Deprecated( + "The helper functions are inherently limiting and hides the underlying implementation. Source developers should make their own implementation according to their needs.", + ) override fun imageUrlParse(response: Response): String = imageUrlParse(response.asJsoup()) /** diff --git a/server/src/main/kotlin/eu/kanade/tachiyomi/source/online/ResolvableSource.kt b/server/src/main/kotlin/eu/kanade/tachiyomi/source/online/ResolvableSource.kt index 0e9431692..59f732cf8 100644 --- a/server/src/main/kotlin/eu/kanade/tachiyomi/source/online/ResolvableSource.kt +++ b/server/src/main/kotlin/eu/kanade/tachiyomi/source/online/ResolvableSource.kt @@ -1,26 +1,44 @@ package eu.kanade.tachiyomi.source.online import eu.kanade.tachiyomi.source.Source +import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SManga /** - * A source that may handle opening an SManga for a given URI. + * A source that may handle opening an SManga or SChapter for a given URI. * * @since extensions-lib 1.5 */ -@Suppress("unused") interface ResolvableSource : Source { /** - * Whether this source may potentially handle the given URI. + * Returns what the given URI may open. + * Returns [UriType.Unknown] if the source is not able to resolve the URI. * * @since extensions-lib 1.5 */ - fun canResolveUri(uri: String): Boolean + fun getUriType(uri: String): UriType /** - * Called if canHandleUri is true. Returns the corresponding SManga, if possible. + * Called if [getUriType] is [UriType.Manga]. + * Returns the corresponding SManga, if possible. * * @since extensions-lib 1.5 */ suspend fun getManga(uri: String): SManga? + + /** + * Called if [getUriType] is [UriType.Chapter]. + * Returns the corresponding SChapter, if possible. + * + * @since extensions-lib 1.5 + */ + suspend fun getChapter(uri: String): SChapter? +} + +sealed interface UriType { + data object Manga : UriType + + data object Chapter : UriType + + data object Unknown : UriType } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/ChapterMutation.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/ChapterMutation.kt index 0c91186ec..8e3375fd3 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/ChapterMutation.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/ChapterMutation.kt @@ -2,6 +2,7 @@ package suwayomi.tachidesk.graphql.mutations +import com.expediagroup.graphql.generator.annotations.GraphQLDeprecated import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import org.jetbrains.exposed.v1.core.LikePattern @@ -167,6 +168,7 @@ class ChapterMutation { ) @RequireAuth + @GraphQLDeprecated("Deprecated in Tachiyomix 1.6", ReplaceWith("fetchMangaAndChapters")) fun fetchChapters(input: FetchChaptersInput): CompletableFuture { val (clientMutationId, mangaId) = input diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/MangaMutation.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/MangaMutation.kt index 975f55678..d3bf9b196 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/MangaMutation.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/MangaMutation.kt @@ -2,6 +2,7 @@ package suwayomi.tachidesk.graphql.mutations +import com.expediagroup.graphql.generator.annotations.GraphQLDeprecated import org.jetbrains.exposed.v1.core.LikePattern import org.jetbrains.exposed.v1.core.Op import org.jetbrains.exposed.v1.core.and @@ -14,12 +15,16 @@ import org.jetbrains.exposed.v1.jdbc.selectAll import org.jetbrains.exposed.v1.jdbc.transactions.transaction import org.jetbrains.exposed.v1.jdbc.update import suwayomi.tachidesk.graphql.directives.RequireAuth +import suwayomi.tachidesk.graphql.types.ChapterType import suwayomi.tachidesk.graphql.types.MangaMetaType import suwayomi.tachidesk.graphql.types.MangaType import suwayomi.tachidesk.graphql.types.MetaInput +import suwayomi.tachidesk.manga.impl.Chapter import suwayomi.tachidesk.manga.impl.Library import suwayomi.tachidesk.manga.impl.Manga import suwayomi.tachidesk.manga.impl.update.IUpdater +import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrStub +import suwayomi.tachidesk.manga.model.table.ChapterTable import suwayomi.tachidesk.manga.model.table.MangaMetaTable import suwayomi.tachidesk.manga.model.table.MangaTable import suwayomi.tachidesk.manga.model.table.toDataClass @@ -146,6 +151,7 @@ class MangaMutation { ) @RequireAuth + @GraphQLDeprecated("Deprecated in Tachiyomix 1.6", ReplaceWith("fetchMangaAndChapters")) fun fetchManga(input: FetchMangaInput): CompletableFuture { val (clientMutationId, id) = input @@ -163,6 +169,58 @@ class MangaMutation { } } + data class FetchMangaAndChaptersInput( + val clientMutationId: String? = null, + val id: Int, + val fetchManga: Boolean, + val fetchChapters: Boolean, + ) + + data class FetchMangaAndChaptersPayload( + val clientMutationId: String?, + val manga: MangaType, + val chapters: List, + ) + + @RequireAuth + fun fetchMangaAndChapters(input: FetchMangaAndChaptersInput): CompletableFuture { + val (clientMutationId, id, fetchManga, fetchChapters) = input + + return future { + var mangaEntry = + transaction { MangaTable.selectAll().where { MangaTable.id eq id }.first() } + val source = getCatalogueSourceOrStub(mangaEntry[MangaTable.sourceReference]) + val sMangaUpdate = Manga.fetchMangaAndChapters( + mangaEntry = mangaEntry, + source = source, + fetchDetails = fetchManga, + fetchChapters = fetchChapters + ) + + Manga.updateMangaDatabase(mangaEntry, source, sMangaUpdate.manga) + mangaEntry = transaction { MangaTable.selectAll().where { MangaTable.id eq id }.first() } + Chapter.updateChapterListDatabase(mangaEntry, sMangaUpdate.chapters, source) + + val (manga, chapters) = + transaction { + Pair( + MangaTable.selectAll().where { MangaTable.id eq id }.first(), + ChapterTable + .selectAll() + .where { ChapterTable.manga eq id } + .orderBy(ChapterTable.sourceOrder) + .map { ChapterType(it) } + ) + + } + FetchMangaAndChaptersPayload( + clientMutationId = clientMutationId, + manga = MangaType(manga), + chapters = chapters, + ) + } + } + data class SetMangaMetaInput( val clientMutationId: String? = null, val meta: MangaMetaType, diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Chapter.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Chapter.kt index 763a39989..5c075baba 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Chapter.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Chapter.kt @@ -7,16 +7,18 @@ package suwayomi.tachidesk.manga.impl * 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 eu.kanade.tachiyomi.source.CatalogueSource import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SManga +import eu.kanade.tachiyomi.source.model.UpdateStrategy import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.util.chapter.ChapterRecognition import eu.kanade.tachiyomi.util.chapter.ChapterSanitizer.sanitize import io.github.oshai.kotlinlogging.KotlinLogging -import io.github.reactivecircus.cache4k.Cache import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.serialization.Serializable +import org.jetbrains.exposed.v1.core.ResultRow import org.jetbrains.exposed.v1.core.SortOrder import org.jetbrains.exposed.v1.core.and import org.jetbrains.exposed.v1.core.dao.id.EntityID @@ -32,7 +34,6 @@ import org.jetbrains.exposed.v1.jdbc.selectAll import org.jetbrains.exposed.v1.jdbc.statements.toExecutable import org.jetbrains.exposed.v1.jdbc.transactions.transaction import org.jetbrains.exposed.v1.jdbc.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 @@ -50,7 +51,6 @@ import suwayomi.tachidesk.server.serverConfig import java.time.Instant import java.util.TreeSet import kotlin.math.max -import kotlin.time.Duration.Companion.minutes private fun List.removeDuplicates(currentChapter: ChapterDataClass): List = groupBy { it.chapterNumber } @@ -104,267 +104,268 @@ object Chapter { .associateBy({ it[ChapterTable.url] }, { it }) } - return chapterList.mapIndexed { index, it -> - + return chapterList.map { val dbChapter = dbChapterMap.getValue(it.url) - - ChapterDataClass( - id = dbChapter[ChapterTable.id].value, - url = it.url, - name = it.name, - uploadDate = it.date_upload, - chapterNumber = it.chapter_number, - scanlator = it.scanlator, - mangaId = mangaId, - read = dbChapter[ChapterTable.isRead], - bookmarked = dbChapter[ChapterTable.isBookmarked], - lastPageRead = dbChapter[ChapterTable.lastPageRead], - lastReadAt = dbChapter[ChapterTable.lastReadAt], - index = chapterList.size - index, - fetchedAt = dbChapter[ChapterTable.fetchedAt], - realUrl = dbChapter[ChapterTable.realUrl], - downloaded = dbChapter[ChapterTable.isDownloaded], - pageCount = dbChapter[ChapterTable.pageCount], - lastModifiedAt = dbChapter[ChapterTable.lastModifiedAt], - version = dbChapter[ChapterTable.version], - ) + ChapterTable.toDataClass(dbChapter) } } - val map: Cache = - Cache - .Builder() - .expireAfterAccess(10.minutes) - .build() - suspend fun fetchChapterList(mangaId: Int): List { - val mutex = map.get(mangaId) { Mutex() } + val mutex = Manga.mangaInfoMutex.get(mangaId) { Mutex() } val chapterList = mutex.withLock { - val manga = getManga(mangaId) - val source = getCatalogueSourceOrStub(manga.sourceId.toLong()) - - val sManga = - SManga.create().apply { - title = manga.title - url = manga.url - description = manga.description - } - - val currentLatestChapterNumber = Manga.getLatestChapter(mangaId)?.chapterNumber ?: 0f - val numberOfCurrentChapters = getCountOfMangaChapters(mangaId) - - val chapters = source.getChapterList(sManga) - // it's possible that the source returns a list containing chapters with the same url - // once such duplicated chapters have been added, they aren't being removed anymore as long as there is - // a chapter with the same url in the fetched chapter list, even if the duplicated chapter itself - // does not exist anymore on the source - val uniqueChapters = chapters.distinctBy { it.url } - - if (uniqueChapters.isEmpty()) { - throw Exception("No chapters found") + val mangaEntry = transaction { + MangaTable.selectAll().where { MangaTable.id eq mangaId }.first() } + val source = getCatalogueSourceOrStub(mangaEntry[MangaTable.sourceReference]) - // Recognize number for new chapters. - uniqueChapters.forEach { chapter -> - (source as? HttpSource)?.prepareNewChapter(chapter, sManga) - val chapterNumber = ChapterRecognition.parseChapterNumber(manga.title, chapter.name, chapter.chapter_number.toDouble()) - chapter.chapter_number = chapterNumber.toFloat() - chapter.name = chapter.name.sanitize(manga.title) - chapter.scanlator = chapter.scanlator?.ifBlank { null }?.trim() - } + val chapters = Manga.fetchMangaAndChapters( + mangaEntry = mangaEntry, + source = source, + fetchDetails = false, + fetchChapters = true, + ).chapters - val now = Instant.now().epochSecond - // Used to not set upload date of older chapters - // to a higher value than newer chapters - var maxSeenUploadDate = 0L - - val chaptersInDb = - transaction { - ChapterTable - .selectAll() - .where { ChapterTable.manga eq mangaId } - .map { ChapterTable.toDataClass(it) } - .toList() - } - - // new chapters after they have been added to the database for auto downloads - val insertedChapterIds = mutableListOf() - - val chaptersToInsert = mutableListOf() // do not yet have an ID from the database - val chaptersToUpdate = mutableListOf() - - uniqueChapters.reversed().forEachIndexed { index, fetchedChapter -> - val chapterEntry = chaptersInDb.find { it.url == fetchedChapter.url } - - val chapterData = - ChapterDataClass.fromSChapter( - fetchedChapter, - chapterEntry?.id ?: 0, - index + 1, - now, - mangaId, - runCatching { - (source as? HttpSource)?.getChapterUrl(fetchedChapter) - }.getOrNull(), - ) - - if (chapterEntry == null) { - val newChapterData = - if (chapterData.uploadDate == 0L) { - val altDateUpload = if (maxSeenUploadDate == 0L) now else maxSeenUploadDate - chapterData.copy(uploadDate = altDateUpload) - } else { - maxSeenUploadDate = max(maxSeenUploadDate, chapterData.uploadDate) - chapterData - } - chaptersToInsert.add(newChapterData) - } else { - val newChapterData = - if (chapterData.uploadDate == 0L) { - chapterData.copy(uploadDate = chapterEntry.uploadDate) - } else { - chapterData - } - chaptersToUpdate.add(newChapterData) - } - } - - val deletedChapterNumbers = TreeSet() - val deletedReadChapterNumbers = TreeSet() - val deletedBookmarkedChapterNumbers = TreeSet() - val deletedDownloadedChapterNumberToChapter = mutableMapOf() - val deletedChapterNumberDateFetchMap = mutableMapOf() - - // clear any orphaned/duplicate chapters that are in the db but not in `chapterList` - val chapterUrls = uniqueChapters.map { it.url }.toSet() - - val chaptersIdsToDelete = - chaptersInDb.mapNotNull { dbChapter -> - if (!chapterUrls.contains(dbChapter.url)) { - if (dbChapter.read) deletedReadChapterNumbers.add(dbChapter.chapterNumber) - if (dbChapter.bookmarked) deletedBookmarkedChapterNumbers.add(dbChapter.chapterNumber) - if (dbChapter.downloaded) deletedDownloadedChapterNumberToChapter[dbChapter.chapterNumber] = dbChapter - deletedChapterNumbers.add(dbChapter.chapterNumber) - deletedChapterNumberDateFetchMap[dbChapter.chapterNumber] = dbChapter.fetchedAt - dbChapter.id - } else { - null - } - } - - transaction { - // we got some clean up due - if (chaptersIdsToDelete.isNotEmpty()) { - DownloadManager.dequeue(chaptersIdsToDelete) - PageTable.deleteWhere { chapter inList chaptersIdsToDelete } - ChapterTable.deleteWhere { id inList chaptersIdsToDelete } - } - - if (chaptersToInsert.isNotEmpty()) { - ChapterTable - .batchInsert(chaptersToInsert) { chapter -> - this[ChapterTable.url] = chapter.url - this[ChapterTable.name] = chapter.name - this[ChapterTable.date_upload] = chapter.uploadDate - this[ChapterTable.chapter_number] = chapter.chapterNumber - this[ChapterTable.scanlator] = chapter.scanlator - this[ChapterTable.sourceOrder] = chapter.index - this[ChapterTable.fetchedAt] = chapter.fetchedAt - this[ChapterTable.manga] = chapter.mangaId - this[ChapterTable.realUrl] = chapter.realUrl - this[ChapterTable.isRead] = false - this[ChapterTable.isBookmarked] = false - this[ChapterTable.isDownloaded] = false - this[ChapterTable.lastModifiedAt] = chapter.lastModifiedAt - this[ChapterTable.version] = chapter.version - this[ChapterTable.pageCount] = -1 - - // is recognized chapter number - if (chapter.chapterNumber >= 0f && chapter.chapterNumber in deletedChapterNumbers) { - this[ChapterTable.isRead] = chapter.chapterNumber in deletedReadChapterNumbers - this[ChapterTable.isBookmarked] = chapter.chapterNumber in deletedBookmarkedChapterNumbers - - // Try to use the fetch date of the original entry to not pollute 'Updates' tab - deletedChapterNumberDateFetchMap[chapter.chapterNumber]?.let { - this[ChapterTable.fetchedAt] = it - } - - deletedDownloadedChapterNumberToChapter[chapter.chapterNumber]?.let { - val hasDownloadedPages = it.pageCount > 0 - val isSameName = it.name == chapter.name - val isSameScanlator = it.scanlator == chapter.scanlator - - // Only preserve download status for chapters with the same name and of the same scanlator; otherwise, - // the downloaded files won't be found anyway - val isDownloadPreservable = hasDownloadedPages && isSameName && isSameScanlator - if (isDownloadPreservable) { - this[ChapterTable.isDownloaded] = true - this[ChapterTable.pageCount] = it.pageCount - } - } - } - }.forEach { insertedChapterIds.add(it[ChapterTable.id].value) } - } - - if (chaptersToUpdate.isNotEmpty()) { - BatchUpdateStatement(ChapterTable) - .apply { - chaptersToUpdate.forEach { - addBatch(EntityID(it.id, ChapterTable)) - - val currentChapter = chaptersInDb.find { dbChapter -> dbChapter.id == it.id }!! - - this[ChapterTable.name] = it.name - this[ChapterTable.date_upload] = it.uploadDate - this[ChapterTable.chapter_number] = it.chapterNumber - this[ChapterTable.scanlator] = it.scanlator - this[ChapterTable.sourceOrder] = it.index - this[ChapterTable.realUrl] = it.realUrl - this[ChapterTable.lastModifiedAt] = it.lastModifiedAt - this[ChapterTable.version] = it.version - this[ChapterTable.isDownloaded] = currentChapter.downloaded - this[ChapterTable.pageCount] = currentChapter.pageCount - - if (!currentChapter.downloaded) { - return@forEach - } - - val isSameScanlator = currentChapter.scanlator == it.scanlator - val isSameName = currentChapter.name == it.name - - val isDownloadPreservable = isSameName && isSameScanlator - if (!isDownloadPreservable) { - this[ChapterTable.isDownloaded] = false - this[ChapterTable.pageCount] = -1 - } - } - }.toExecutable() - .execute(this@transaction) - } - - MangaTable.update({ MangaTable.id eq mangaId }) { - it[chaptersLastFetchedAt] = Instant.now().epochSecond - } - } - - if (manga.inLibrary) { - // We have to query the inserted chapters to get the up-to-date data. I.e. "last_modified_at" is not returned by the insert statement, due to being set by a DB trigger - val insertedChapters = - transaction { - ChapterTable.selectAll().where { ChapterTable.id inList insertedChapterIds }.map( - ChapterTable::toDataClass, - ) - } - downloadNewChapters(mangaId, currentLatestChapterNumber, numberOfCurrentChapters, insertedChapters) - } - - uniqueChapters + updateChapterListDatabase(mangaEntry, chapters, source) } return chapterList } + fun updateChapterListDatabase( + mangaEntry: ResultRow, + chapters: List, + source: CatalogueSource, + ): List { + val currentLatestChapterNumber = Manga.getLatestChapter(mangaEntry[MangaTable.id].value)?.chapterNumber ?: 0f + val numberOfCurrentChapters = getCountOfMangaChapters(mangaEntry[MangaTable.id].value) + // it's possible that the source returns a list containing chapters with the same url + // once such duplicated chapters have been added, they aren't being removed anymore as long as there is + // a chapter with the same url in the fetched chapter list, even if the duplicated chapter itself + // does not exist anymore on the source + val uniqueChapters = chapters.distinctBy { it.url } + + if (uniqueChapters.isEmpty()) { + throw Exception("No chapters found") + } + + // Recognize number for new chapters. + val sManga = SManga.create().apply { + url = mangaEntry[MangaTable.url] + title = mangaEntry[MangaTable.title] + thumbnail_url = mangaEntry[MangaTable.thumbnail_url] + artist = mangaEntry[MangaTable.artist] + author = mangaEntry[MangaTable.author] + description = mangaEntry[MangaTable.description] + genre = mangaEntry[MangaTable.genre] + status = mangaEntry[MangaTable.status] + update_strategy = UpdateStrategy.valueOf(mangaEntry[MangaTable.updateStrategy]) + memo = mangaEntry[MangaTable.memo] + initialized = mangaEntry[MangaTable.initialized] + } + uniqueChapters.forEach { chapter -> + (source as? HttpSource)?.prepareNewChapter(chapter, sManga) + val chapterNumber = ChapterRecognition.parseChapterNumber(mangaEntry[MangaTable.title], chapter.name, chapter.chapter_number.toDouble()) + chapter.chapter_number = chapterNumber.toFloat() + chapter.name = chapter.name.sanitize(mangaEntry[MangaTable.title]) + chapter.scanlator = chapter.scanlator?.ifBlank { null }?.trim() + } + + val now = Instant.now().epochSecond + // Used to not set upload date of older chapters + // to a higher value than newer chapters + var maxSeenUploadDate = 0L + + val chaptersInDb = + transaction { + ChapterTable + .selectAll() + .where { ChapterTable.manga eq mangaEntry[MangaTable.id].value } + .map { ChapterTable.toDataClass(it) } + .toList() + } + + // new chapters after they have been added to the database for auto downloads + val insertedChapterIds = mutableListOf() + + val chaptersToInsert = mutableListOf() // do not yet have an ID from the database + val chaptersToUpdate = mutableListOf() + + uniqueChapters.reversed().forEachIndexed { index, fetchedChapter -> + val chapterEntry = chaptersInDb.find { it.url == fetchedChapter.url } + + val chapterData = + ChapterDataClass.fromSChapter( + fetchedChapter, + chapterEntry?.id ?: 0, + index + 1, + now, + mangaEntry[MangaTable.id].value, + runCatching { + (source as? HttpSource)?.getChapterUrl(fetchedChapter) + }.getOrNull(), + ) + + if (chapterEntry == null) { + val newChapterData = + if (chapterData.uploadDate == 0L) { + val altDateUpload = if (maxSeenUploadDate == 0L) now else maxSeenUploadDate + chapterData.copy(uploadDate = altDateUpload) + } else { + maxSeenUploadDate = max(maxSeenUploadDate, chapterData.uploadDate) + chapterData + } + chaptersToInsert.add(newChapterData) + } else { + val newChapterData = + if (chapterData.uploadDate == 0L) { + chapterData.copy(uploadDate = chapterEntry.uploadDate) + } else { + chapterData + } + chaptersToUpdate.add(newChapterData) + } + } + + val deletedChapterNumbers = TreeSet() + val deletedReadChapterNumbers = TreeSet() + val deletedBookmarkedChapterNumbers = TreeSet() + val deletedDownloadedChapterNumberToChapter = mutableMapOf() + val deletedChapterNumberDateFetchMap = mutableMapOf() + + // clear any orphaned/duplicate chapters that are in the db but not in `chapterList` + val chapterUrls = uniqueChapters.map { it.url }.toSet() + + val chaptersIdsToDelete = + chaptersInDb.mapNotNull { dbChapter -> + if (!chapterUrls.contains(dbChapter.url)) { + if (dbChapter.read) deletedReadChapterNumbers.add(dbChapter.chapterNumber) + if (dbChapter.bookmarked) deletedBookmarkedChapterNumbers.add(dbChapter.chapterNumber) + if (dbChapter.downloaded) deletedDownloadedChapterNumberToChapter[dbChapter.chapterNumber] = dbChapter + deletedChapterNumbers.add(dbChapter.chapterNumber) + deletedChapterNumberDateFetchMap[dbChapter.chapterNumber] = dbChapter.fetchedAt + dbChapter.id + } else { + null + } + } + + transaction { + // we got some clean up due + if (chaptersIdsToDelete.isNotEmpty()) { + DownloadManager.dequeue(chaptersIdsToDelete) + PageTable.deleteWhere { chapter inList chaptersIdsToDelete } + ChapterTable.deleteWhere { id inList chaptersIdsToDelete } + } + + if (chaptersToInsert.isNotEmpty()) { + ChapterTable + .batchInsert(chaptersToInsert) { chapter -> + this[ChapterTable.url] = chapter.url + this[ChapterTable.name] = chapter.name + this[ChapterTable.date_upload] = chapter.uploadDate + this[ChapterTable.chapter_number] = chapter.chapterNumber + this[ChapterTable.scanlator] = chapter.scanlator + this[ChapterTable.sourceOrder] = chapter.index + this[ChapterTable.fetchedAt] = chapter.fetchedAt + this[ChapterTable.manga] = chapter.mangaId + this[ChapterTable.realUrl] = chapter.realUrl + this[ChapterTable.memo] = chapter.memo + this[ChapterTable.isRead] = false + this[ChapterTable.isBookmarked] = false + this[ChapterTable.isDownloaded] = false + this[ChapterTable.lastModifiedAt] = chapter.lastModifiedAt + this[ChapterTable.version] = chapter.version + this[ChapterTable.pageCount] = -1 + + // is recognized chapter number + if (chapter.chapterNumber >= 0f && chapter.chapterNumber in deletedChapterNumbers) { + this[ChapterTable.isRead] = chapter.chapterNumber in deletedReadChapterNumbers + this[ChapterTable.isBookmarked] = chapter.chapterNumber in deletedBookmarkedChapterNumbers + + // Try to use the fetch date of the original entry to not pollute 'Updates' tab + deletedChapterNumberDateFetchMap[chapter.chapterNumber]?.let { + this[ChapterTable.fetchedAt] = it + } + + deletedDownloadedChapterNumberToChapter[chapter.chapterNumber]?.let { + val hasDownloadedPages = it.pageCount > 0 + val isSameName = it.name == chapter.name + val isSameScanlator = it.scanlator == chapter.scanlator + + // Only preserve download status for chapters with the same name and of the same scanlator; otherwise, + // the downloaded files won't be found anyway + val isDownloadPreservable = hasDownloadedPages && isSameName && isSameScanlator + if (isDownloadPreservable) { + this[ChapterTable.isDownloaded] = true + this[ChapterTable.pageCount] = it.pageCount + } + } + } + }.forEach { insertedChapterIds.add(it[ChapterTable.id].value) } + } + + if (chaptersToUpdate.isNotEmpty()) { + BatchUpdateStatement(ChapterTable) + .apply { + chaptersToUpdate.forEach { + addBatch(EntityID(it.id, ChapterTable)) + + val currentChapter = chaptersInDb.find { dbChapter -> dbChapter.id == it.id }!! + + this[ChapterTable.name] = it.name + this[ChapterTable.date_upload] = it.uploadDate + this[ChapterTable.chapter_number] = it.chapterNumber + this[ChapterTable.scanlator] = it.scanlator + this[ChapterTable.sourceOrder] = it.index + this[ChapterTable.realUrl] = it.realUrl + this[ChapterTable.lastModifiedAt] = it.lastModifiedAt + this[ChapterTable.version] = it.version + this[ChapterTable.memo] = it.memo + this[ChapterTable.isDownloaded] = currentChapter.downloaded + this[ChapterTable.pageCount] = currentChapter.pageCount + + if (!currentChapter.downloaded) { + return@forEach + } + + val isSameScanlator = currentChapter.scanlator == it.scanlator + val isSameName = currentChapter.name == it.name + + val isDownloadPreservable = isSameName && isSameScanlator + if (!isDownloadPreservable) { + this[ChapterTable.isDownloaded] = false + this[ChapterTable.pageCount] = -1 + } + } + }.toExecutable() + .execute(this@transaction) + } + + MangaTable.update({ MangaTable.id eq mangaEntry[MangaTable.id].value }) { + it[chaptersLastFetchedAt] = Instant.now().epochSecond + } + } + + if (mangaEntry[MangaTable.inLibrary]) { + // We have to query the inserted chapters to get the up-to-date data. I.e. "last_modified_at" is not returned by the insert statement, due to being set by a DB trigger + val insertedChapters = + transaction { + ChapterTable.selectAll().where { ChapterTable.id inList insertedChapterIds }.map( + ChapterTable::toDataClass, + ) + } + downloadNewChapters( + mangaEntry[MangaTable.id].value, + currentLatestChapterNumber, + numberOfCurrentChapters, + insertedChapters + ) + } + + return uniqueChapters + } + private fun downloadNewChapters( mangaId: Int, prevLatestChapterNumber: Float, diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Manga.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Manga.kt index 90b14996e..de1c7070e 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Manga.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/Manga.kt @@ -11,13 +11,19 @@ import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.HttpException import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.network.awaitSuccess +import eu.kanade.tachiyomi.source.CatalogueSource import eu.kanade.tachiyomi.source.local.LocalSource +import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SManga +import eu.kanade.tachiyomi.source.model.SMangaUpdate import eu.kanade.tachiyomi.source.model.UpdateStrategy import eu.kanade.tachiyomi.source.online.HttpSource import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KotlinLogging +import io.github.reactivecircus.cache4k.Cache import io.javalin.http.HttpStatus +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import okhttp3.CacheControl import okhttp3.Response import org.jetbrains.exposed.v1.core.ResultRow @@ -32,10 +38,7 @@ import org.jetbrains.exposed.v1.jdbc.selectAll import org.jetbrains.exposed.v1.jdbc.statements.toExecutable import org.jetbrains.exposed.v1.jdbc.transactions.transaction import org.jetbrains.exposed.v1.jdbc.update -import suwayomi.tachidesk.manga.impl.MangaList.proxyThumbnailUrl -import suwayomi.tachidesk.manga.impl.Source.getSource 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 @@ -47,10 +50,8 @@ import suwayomi.tachidesk.manga.impl.util.updateMangaDownloadDir import suwayomi.tachidesk.manga.model.dataclass.ChapterDataClass import suwayomi.tachidesk.manga.model.dataclass.IncludeOrExclude import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass -import suwayomi.tachidesk.manga.model.dataclass.toGenreList import suwayomi.tachidesk.manga.model.table.ChapterTable import suwayomi.tachidesk.manga.model.table.MangaMetaTable -import suwayomi.tachidesk.manga.model.table.MangaStatus import suwayomi.tachidesk.manga.model.table.MangaTable import suwayomi.tachidesk.manga.model.table.toDataClass import suwayomi.tachidesk.server.ApplicationDirs @@ -59,10 +60,17 @@ import java.io.File import java.io.IOException import java.io.InputStream import java.time.Instant +import kotlin.time.Duration.Companion.minutes private val logger = KotlinLogging.logger { } object Manga { + val mangaInfoMutex: Cache = + Cache + .Builder() + .expireAfterAccess(10.minutes) + .build() + suspend fun getManga( mangaId: Int, onlineFetch: Boolean = false, @@ -70,63 +78,83 @@ object Manga { var mangaEntry = transaction { MangaTable.selectAll().where { MangaTable.id eq mangaId }.first() } return if (!onlineFetch && mangaEntry[MangaTable.initialized]) { - getMangaDataClass(mangaId, mangaEntry) + MangaTable.toDataClass(mangaEntry) } else { // initialize manga - val sManga = fetchManga(mangaId) ?: return getMangaDataClass(mangaId, mangaEntry) + fetchManga(mangaId) ?: return MangaTable.toDataClass(mangaEntry) mangaEntry = transaction { MangaTable.selectAll().where { MangaTable.id eq mangaId }.first() } - MangaDataClass( - id = mangaId, - sourceId = mangaEntry[MangaTable.sourceReference].toString(), - url = mangaEntry[MangaTable.url], - title = mangaEntry[MangaTable.title], - thumbnailUrl = proxyThumbnailUrl(mangaId), - thumbnailUrlLastFetched = mangaEntry[MangaTable.thumbnailUrlLastFetched], - initialized = true, - artist = sManga.artist, - author = sManga.author, - description = sManga.description, - genre = sManga.genre.toGenreList(), - status = MangaStatus.valueOf(sManga.status).name, - inLibrary = mangaEntry[MangaTable.inLibrary], - inLibraryAt = mangaEntry[MangaTable.inLibraryAt], - source = getSource(mangaEntry[MangaTable.sourceReference]), - realUrl = mangaEntry[MangaTable.realUrl], - lastFetchedAt = mangaEntry[MangaTable.lastFetchedAt], - chaptersLastFetchedAt = mangaEntry[MangaTable.chaptersLastFetchedAt], - updateStrategy = UpdateStrategy.valueOf(mangaEntry[MangaTable.updateStrategy]), - freshData = true, - trackers = Track.getTrackRecordsByMangaId(mangaId), - lastModifiedAt = mangaEntry[MangaTable.lastModifiedAt], - version = mangaEntry[MangaTable.version], - ) + MangaTable.toDataClass(mangaEntry).copy(freshData = true) } } - suspend fun fetchManga(mangaId: Int): SManga? { - val mangaEntry = transaction { MangaTable.selectAll().where { MangaTable.id eq mangaId }.first() } - - val source = - getCatalogueSourceOrNull(mangaEntry[MangaTable.sourceReference]) - ?: return null + suspend fun fetchMangaAndChapters( + mangaEntry: ResultRow, + source: CatalogueSource, + fetchDetails: Boolean, + fetchChapters: Boolean, + ): SMangaUpdate { val sManga = - source.getMangaDetails( - SManga.create().apply { - url = mangaEntry[MangaTable.url] - title = mangaEntry[MangaTable.title] - thumbnail_url = mangaEntry[MangaTable.thumbnail_url] - artist = mangaEntry[MangaTable.artist] - author = mangaEntry[MangaTable.author] - description = mangaEntry[MangaTable.description] - genre = mangaEntry[MangaTable.genre] - status = mangaEntry[MangaTable.status] - update_strategy = UpdateStrategy.valueOf(mangaEntry[MangaTable.updateStrategy]) - }, - ) + SManga.create().apply { + url = mangaEntry[MangaTable.url] + title = mangaEntry[MangaTable.title] + thumbnail_url = mangaEntry[MangaTable.thumbnail_url] + artist = mangaEntry[MangaTable.artist] + author = mangaEntry[MangaTable.author] + description = mangaEntry[MangaTable.description] + genre = mangaEntry[MangaTable.genre] + status = mangaEntry[MangaTable.status] + update_strategy = UpdateStrategy.valueOf(mangaEntry[MangaTable.updateStrategy]) + memo = mangaEntry[MangaTable.memo] + initialized = mangaEntry[MangaTable.initialized] + } + val sChapters = transaction { + ChapterTable.selectAll() + .where { ChapterTable.manga eq mangaEntry[MangaTable.id] } + .orderBy(ChapterTable.sourceOrder to SortOrder.DESC) + .map { + SChapter.create().apply { + url = it[ChapterTable.url] + name = it[ChapterTable.name] + chapter_number = it[ChapterTable.chapter_number] + scanlator = it[ChapterTable.scanlator] + date_upload = it[ChapterTable.date_upload] + memo = it[ChapterTable.memo] + } + } + } + return source.getMangaUpdate( + sManga, + sChapters, + fetchDetails = fetchDetails, + fetchChapters = fetchChapters, + ) + } + + suspend fun fetchManga(mangaId: Int): SManga? { + return mangaInfoMutex.get(mangaId) { Mutex() }.withLock { + val mangaEntry = + transaction { MangaTable.selectAll().where { MangaTable.id eq mangaId }.first() } + val source = getCatalogueSourceOrNull(mangaEntry[MangaTable.sourceReference]) ?: return null + val sManga = fetchMangaAndChapters( + mangaEntry, + source, + fetchDetails = true, + fetchChapters = false + ).manga + + updateMangaDatabase(mangaEntry, source, sManga) + } + } + + fun updateMangaDatabase( + mangaEntry: ResultRow, + source: CatalogueSource, + sManga: SManga, + ): SManga { transaction { - MangaTable.update({ MangaTable.id eq mangaId }) { + MangaTable.update({ MangaTable.id eq mangaEntry[MangaTable.id] }) { val remoteTitle = try { sManga.title @@ -151,7 +179,7 @@ object Manga { if (!sManga.thumbnail_url.isNullOrEmpty()) { it[MangaTable.thumbnail_url] = sManga.thumbnail_url it[MangaTable.thumbnailUrlLastFetched] = Instant.now().epochSecond - clearThumbnail(mangaId) + clearThumbnail(mangaEntry[MangaTable.id].value) } it[MangaTable.realUrl] = @@ -221,35 +249,6 @@ object Manga { } } - private fun getMangaDataClass( - mangaId: Int, - mangaEntry: ResultRow, - ) = MangaDataClass( - id = mangaId, - sourceId = mangaEntry[MangaTable.sourceReference].toString(), - url = mangaEntry[MangaTable.url], - title = mangaEntry[MangaTable.title], - thumbnailUrl = proxyThumbnailUrl(mangaId), - thumbnailUrlLastFetched = mangaEntry[MangaTable.thumbnailUrlLastFetched], - initialized = true, - artist = mangaEntry[MangaTable.artist], - author = mangaEntry[MangaTable.author], - description = mangaEntry[MangaTable.description], - genre = mangaEntry[MangaTable.genre].toGenreList(), - status = MangaStatus.valueOf(mangaEntry[MangaTable.status]).name, - inLibrary = mangaEntry[MangaTable.inLibrary], - inLibraryAt = mangaEntry[MangaTable.inLibraryAt], - source = getSource(mangaEntry[MangaTable.sourceReference]), - realUrl = mangaEntry[MangaTable.realUrl], - lastFetchedAt = mangaEntry[MangaTable.lastFetchedAt], - chaptersLastFetchedAt = mangaEntry[MangaTable.chaptersLastFetchedAt], - updateStrategy = UpdateStrategy.valueOf(mangaEntry[MangaTable.updateStrategy]), - freshData = false, - trackers = Track.getTrackRecordsByMangaId(mangaId), - lastModifiedAt = mangaEntry[MangaTable.lastModifiedAt], - version = mangaEntry[MangaTable.version], - ) - fun getMangaMetaMap(mangaId: Int): Map = transaction { MangaMetaTable diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/util/source/StubSource.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/util/source/StubSource.kt index a7a5a3641..c651a5d5d 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/util/source/StubSource.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/util/source/StubSource.kt @@ -13,6 +13,7 @@ import eu.kanade.tachiyomi.source.model.MangasPage import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SManga +import eu.kanade.tachiyomi.source.model.SMangaUpdate import rx.Observable open class StubSource( @@ -23,9 +24,13 @@ open class StubSource( override val name: String get() = id.toString() + override suspend fun getPopularManga(page: Int): MangasPage = throw getSourceNotInstalledException() + @Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getPopularManga")) override fun fetchPopularManga(page: Int): Observable = Observable.error(getSourceNotInstalledException()) + override suspend fun getSearchManga(page: Int, query: String, filters: FilterList): MangasPage = throw getSourceNotInstalledException() + @Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getSearchManga")) override fun fetchSearchManga( page: Int, @@ -33,17 +38,23 @@ open class StubSource( filters: FilterList, ): Observable = Observable.error(getSourceNotInstalledException()) + override suspend fun getLatestUpdates(page: Int): MangasPage = throw getSourceNotInstalledException() + @Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getLatestUpdates")) override fun fetchLatestUpdates(page: Int): Observable = Observable.error(getSourceNotInstalledException()) override fun getFilterList(): FilterList = FilterList() + override suspend fun getMangaUpdate(manga: SManga, chapters: List, fetchDetails: Boolean, fetchChapters: Boolean): SMangaUpdate = throw getSourceNotInstalledException() + @Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getMangaDetails")) override fun fetchMangaDetails(manga: SManga): Observable = Observable.error(getSourceNotInstalledException()) @Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getChapterList")) override fun fetchChapterList(manga: SManga): Observable> = Observable.error(getSourceNotInstalledException()) + override suspend fun getPageList(chapter: SChapter): List = throw getSourceNotInstalledException() + @Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getPageList")) override fun fetchPageList(chapter: SChapter): Observable> = Observable.error(getSourceNotInstalledException()) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/model/dataclass/ChapterDataClass.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/model/dataclass/ChapterDataClass.kt index 918114774..f97dad95f 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/model/dataclass/ChapterDataClass.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/model/dataclass/ChapterDataClass.kt @@ -1,6 +1,8 @@ package suwayomi.tachidesk.manga.model.dataclass +import com.fasterxml.jackson.annotation.JsonIgnore import eu.kanade.tachiyomi.source.model.SChapter +import kotlinx.serialization.json.JsonObject import org.jetbrains.exposed.v1.core.eq import org.jetbrains.exposed.v1.jdbc.selectAll import org.jetbrains.exposed.v1.jdbc.transactions.transaction @@ -43,6 +45,8 @@ data class ChapterDataClass( val pageCount: Int = -1, val lastModifiedAt: Long = 0, val version: Long = 0, + @JsonIgnore + val memo: JsonObject = JsonObject(emptyMap()), ) { companion object { fun fromSChapter( @@ -60,6 +64,7 @@ data class ChapterDataClass( uploadDate = sChapter.date_upload, chapterNumber = sChapter.chapter_number, scanlator = sChapter.scanlator, + memo = sChapter.memo, index = index, fetchedAt = fetchedAt, realUrl = realUrl, diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/model/dataclass/MangaDataClass.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/model/dataclass/MangaDataClass.kt index ecf882554..5310611c8 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/model/dataclass/MangaDataClass.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/model/dataclass/MangaDataClass.kt @@ -7,7 +7,9 @@ package suwayomi.tachidesk.manga.model.dataclass * 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 com.fasterxml.jackson.annotation.JsonIgnore import eu.kanade.tachiyomi.source.model.UpdateStrategy +import kotlinx.serialization.json.JsonObject import suwayomi.tachidesk.manga.impl.Manga.getMangaMetaMap import suwayomi.tachidesk.manga.impl.util.lang.trimAll import suwayomi.tachidesk.manga.model.table.MangaStatus @@ -44,6 +46,8 @@ data class MangaDataClass( val trackers: List? = null, val lastModifiedAt: Long = 0, val version: Long = 0, + @JsonIgnore + val memo: JsonObject = JsonObject(emptyMap()), ) { override fun toString(): String = "\"$title\" (id= $id) (sourceId= $sourceId)" diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/ChapterTable.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/ChapterTable.kt index a672d906a..dc1b87d60 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/ChapterTable.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/ChapterTable.kt @@ -7,11 +7,14 @@ package suwayomi.tachidesk.manga.model.table * 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 kotlinx.serialization.json.JsonObject import org.jetbrains.exposed.v1.core.ReferenceOption import org.jetbrains.exposed.v1.core.ResultRow import org.jetbrains.exposed.v1.core.dao.id.IntIdTable +import org.jetbrains.exposed.v1.json.json import suwayomi.tachidesk.manga.model.dataclass.ChapterDataClass import suwayomi.tachidesk.manga.model.table.columns.truncatingVarchar +import suwayomi.tachidesk.server.database.DBManager object ChapterTable : IntIdTable() { val url = varchar("url", 2048) @@ -42,6 +45,8 @@ object ChapterTable : IntIdTable() { val lastModifiedAt = long("last_modified_at").default(0) val version = long("version").default(0) val isSyncing = bool("is_syncing").default(false) + + val memo = json("memo", DBManager.format) } fun ChapterTable.toDataClass(chapterEntry: ResultRow) = @@ -64,4 +69,5 @@ fun ChapterTable.toDataClass(chapterEntry: ResultRow) = pageCount = chapterEntry[pageCount], lastModifiedAt = chapterEntry[lastModifiedAt], version = chapterEntry[version], + memo = chapterEntry[memo], ) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/MangaTable.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/MangaTable.kt index 323d8142e..ac49d509a 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/MangaTable.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/MangaTable.kt @@ -9,13 +9,16 @@ package suwayomi.tachidesk.manga.model.table import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.model.UpdateStrategy +import kotlinx.serialization.json.JsonObject import org.jetbrains.exposed.v1.core.ResultRow import org.jetbrains.exposed.v1.core.dao.id.IntIdTable +import org.jetbrains.exposed.v1.json.json import suwayomi.tachidesk.manga.impl.MangaList.proxyThumbnailUrl import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass import suwayomi.tachidesk.manga.model.dataclass.toGenreList import suwayomi.tachidesk.manga.model.table.columns.truncatingVarchar import suwayomi.tachidesk.manga.model.table.columns.unlimitedVarchar +import suwayomi.tachidesk.server.database.DBManager object MangaTable : IntIdTable() { val url = varchar("url", 2048) @@ -48,6 +51,7 @@ object MangaTable : IntIdTable() { val lastModifiedAt = long("last_modified_at").default(0) val version = long("version").default(0) val isSyncing = bool("is_syncing").default(false) + val memo = json("memo", DBManager.format) } fun MangaTable.toDataClass(mangaEntry: ResultRow) = @@ -72,6 +76,7 @@ fun MangaTable.toDataClass(mangaEntry: ResultRow) = updateStrategy = UpdateStrategy.valueOf(mangaEntry[updateStrategy]), lastModifiedAt = mangaEntry[lastModifiedAt], version = mangaEntry[version], + memo = mangaEntry[memo], ) enum class MangaStatus( diff --git a/server/src/main/kotlin/suwayomi/tachidesk/server/database/DBManager.kt b/server/src/main/kotlin/suwayomi/tachidesk/server/database/DBManager.kt index 2891274cd..07c70cfb6 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/server/database/DBManager.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/server/database/DBManager.kt @@ -12,6 +12,7 @@ import com.zaxxer.hikari.HikariDataSource import de.neonew.exposed.migrations.loadMigrationsFrom import de.neonew.exposed.migrations.runMigrations import io.github.oshai.kotlinlogging.KotlinLogging +import kotlinx.serialization.json.Json import org.jetbrains.exposed.v1.core.DatabaseConfig import org.jetbrains.exposed.v1.core.ExperimentalKeywordApi import org.jetbrains.exposed.v1.core.Schema @@ -140,6 +141,8 @@ object DBManager { "Idle: ${ds.hikariPoolMXBean.idleConnections}, " + "Waiting: ${ds.hikariPoolMXBean.threadsAwaitingConnection}" } + + val format = Json { prettyPrint = false } } private val logger = KotlinLogging.logger {} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/server/database/migration/M0057_AddMangaChapterMemoFields.kt b/server/src/main/kotlin/suwayomi/tachidesk/server/database/migration/M0057_AddMangaChapterMemoFields.kt new file mode 100644 index 000000000..e71c6f6ae --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/server/database/migration/M0057_AddMangaChapterMemoFields.kt @@ -0,0 +1,19 @@ +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.SQLMigration + +@Suppress("ClassName", "unused") +class M0057_AddMangaChapterMemoFields : SQLMigration() { + override val sql = + """ + ALTER TABLE MANGA ADD COLUMN memo JSON DEFAULT '{}'; + ALTER TABLE CHAPTER ADD COLUMN memo JSON DEFAULT '{}'; + """.trimIndent() +}