Compare commits

..

5 Commits

Author SHA1 Message Date
Syer10
3bc75d5b00 Review comments 2026-05-12 09:32:09 -04:00
Syer10
fe6dd05411 Update H2 2026-05-11 19:20:06 -04:00
Syer10
57c0a85a35 Add Kotlinx.DateTime extensions 2026-05-11 00:31:23 -04:00
Syer10
c2c927ae97 Update Exposed 2026-05-11 00:25:44 -04:00
renovate[bot]
2c70700bb7 Update exposed to v1 2026-05-11 04:16:03 +00:00
99 changed files with 2671 additions and 5892 deletions

View File

@@ -11,8 +11,4 @@ ktlint_standard_if-else-wrapping=disabled
ktlint_standard_no-consecutive-comments=disabled
[**/generated/**]
ktlint=disabled
[*.json]
indent_size=2
indent_style = space
ktlint=disabled

View File

@@ -67,7 +67,7 @@ jobs:
export LD_PRELOAD="$(pwd)/scripts/resources/catch_abort.so"
JAR=$(ls ./server/build/*.jar| head -1)
set +e
timeout 30s java \
timeout 30s java -DcrashOnFailedMigration=true \
-Dsuwayomi.tachidesk.config.server.systemTrayEnabled=false \
-Dsuwayomi.tachidesk.config.server.initialOpenInBrowserEnabled=false \
-Dsuwayomi.tachidesk.config.server.databaseType=POSTGRESQL \
@@ -83,7 +83,7 @@ jobs:
exit "$ecode"
fi
timeout 30s java \
timeout 30s java -DcrashOnFailedMigration=true \
-Dsuwayomi.tachidesk.config.server.systemTrayEnabled=false \
-Dsuwayomi.tachidesk.config.server.initialOpenInBrowserEnabled=false \
-jar "$JAR"
@@ -96,10 +96,6 @@ jobs:
fi
exit 0
- name: "Run tests"
working-directory: master
run: ./gradlew test --stacktrace
check_docs:
name: Validate that all options are documented
runs-on: ubuntu-latest

View File

@@ -1,7 +1,6 @@
name: GitHub Wiki upload
on:
workflow_dispatch:
push:
branches:
- master
@@ -24,8 +23,6 @@ jobs:
with:
repository: ${{github.repository}}
path: ${{github.repository}}
fetch-depth: 0 # fetch history & tags to determine stable version
fetch-tags: true
- name: Checkout Wiki
uses: actions/checkout@v6
@@ -38,13 +35,6 @@ jobs:
set -e
cd $GITHUB_WORKSPACE/${{github.repository}}.wiki
cp -r $GITHUB_WORKSPACE/${{github.repository}}/docs/* .
stable="$(git -C $GITHUB_WORKSPACE/${{github.repository}} describe --abbrev=0 --tags)"
if ! git -C $GITHUB_WORKSPACE/${{github.repository}} log --exit-code --pretty= "$stable.." -- docs/Configuring-SuwayomiServer.md; then
echo "Changes to config detected, embedding link to stable"
sed -i '1s/^/> [!WARNING]\n> This document describes the settings available in the preview version. Please head to ['$stable'](https:\/\/github.com\/Suwayomi\/Suwayomi-Server\/blob\/'$stable'\/docs\/Configuring-Suwayomi%E2%80%90Server.md) for the current stable version\n\n/' Configuring-SuwayomiServer.md
fi
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
git add .

File diff suppressed because it is too large Load Diff

View File

@@ -1,47 +0,0 @@
package xyz.nulldev.androidcompat.webkit
import io.github.oshai.kotlinlogging.KotlinLogging
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.first
import org.cef.CefApp
import org.cef.CefClient
private val logger = KotlinLogging.logger {}
object CefHelper {
val cefApp = MutableStateFlow<Result<CefApp?>>(Result.success(null))
suspend fun createClient(): CefClient {
val app = waitForInit().first()
val client = app.createClient()
JsHandler(client) // This adds itself to a global map
return client
}
fun waitForInit() =
callbackFlow {
val app = cefApp.first { it.isFailure || it.getOrThrow() != null }.getOrThrow()!!
app.onInitialization {
logger.debug { "CEF: Initialization state $it" }
when (it) {
CefApp.CefAppState.INITIALIZED -> {
trySend(app)
close()
}
CefApp.CefAppState.SHUTTING_DOWN, CefApp.CefAppState.TERMINATED -> {
close(CefException("Shutting down"))
}
else -> {}
}
}
awaitClose {}
}
class CefException(
msg: String,
) : Exception(msg)
}

View File

@@ -1,136 +0,0 @@
package xyz.nulldev.androidcompat.webkit
import io.github.oshai.kotlinlogging.KotlinLogging
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import org.cef.CefClient
import org.cef.browser.CefBrowser
import org.cef.browser.CefFrame
import org.cef.browser.CefMessageRouter
import org.cef.callback.CefQueryCallback
import org.cef.handler.CefMessageRouterHandlerAdapter
import kotlin.random.Random
private val logger = KotlinLogging.logger {}
private val jsHandler: MutableMap<CefClient, JsHandler> = mutableMapOf()
fun CefBrowser.evaluateJavaScript(
expression: String,
cb: (String?) -> Unit,
) = jsHandler[this.client]!!.eval(this, expression, cb)
fun CefBrowser.dispose() {
stopLoad()
setCloseAllowed()
close(true)
}
class JsHandler : CefMessageRouterHandlerAdapter {
private val handler: MutableMap<String, (String?) -> Unit> = mutableMapOf()
constructor(client: CefClient) {
val config = CefMessageRouter.CefMessageRouterConfig()
config.jsQueryFunction = QUERY_FN
config.jsCancelFunction = QUERY_CANCEL_FN
client.addMessageRouter(CefMessageRouter.create(config, this))
jsHandler[client] = this
}
fun eval(
frame: CefFrame,
expression: String,
cb: (String?) -> Unit,
) {
val id = Random.nextBytes(48).toHexString()
handler[id] = cb
frame.executeJavaScript(expression.toCode(id), "about:cef", 0)
}
fun eval(
browser: CefBrowser,
expression: String,
cb: (String?) -> Unit,
) {
val id = Random.nextBytes(48).toHexString()
handler[id] = cb
browser.executeJavaScript(expression.toCode(id), "about:cef", 0)
}
override fun onQuery(
browser: CefBrowser?,
frame: CefFrame?,
queryId: Long,
request: String?,
persistent: Boolean,
callback: CefQueryCallback?,
): Boolean {
super.onQuery(browser, frame, queryId, request, persistent, callback)
if (request != null) {
val invoke =
try {
Json.decodeFromString<FunctionCall>(request)
} catch (e: Exception) {
logger.warn(e) { "Invalid request received" }
return false
}
val handler = handler.remove(invoke.id) ?: return false
handler(invoke.result)
callback?.success("")
return true
}
return false
}
@Serializable
private data class FunctionCall(
val id: String,
val result: String? = null,
)
companion object {
const val QUERY_FN = "__\$_evalQuery"
const val QUERY_CANCEL_FN = "__\$_evalQueryCancel"
private fun Char.isLineBreak(): Boolean = this == '\n' || this == '\r'
private fun String.containsLineBreak(): Boolean =
this.any {
it.isLineBreak()
}
private fun String.asFunctionBody(): String =
let { expression ->
when {
expression.containsLineBreak() -> expression
expression.trim().startsWith("return", false) -> expression
else -> "return $expression"
}
}
private fun String.toCode(id: String): String =
"""
function payload() {
${this.asFunctionBody()}
}
try {
var result = payload();
window.${QUERY_FN}({
request: JSON.stringify({ id: "$id", result }),
onSuccess: function (response) {},
onFailure: function (error_code, error_message) {}
});
} catch (e) {
console.error("Failed to eval $id", e)
window.${QUERY_CANCEL_FN}({
request: JSON.stringify({ id: "$id", error: ""+e }),
onSuccess: function (response) {},
onFailure: function (error_code, error_message) {}
});
}
""".trimIndent()
}
}

View File

@@ -51,10 +51,11 @@ import android.webkit.WebViewProvider.ScrollDelegate
import android.webkit.WebViewProvider.ViewDelegate
import android.webkit.WebViewRenderProcess
import android.webkit.WebViewRenderProcessClient
import kotlinx.coroutines.runBlocking
import dev.datlag.kcef.KCEF
import dev.datlag.kcef.KCEFBrowser
import dev.datlag.kcef.KCEFClient
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import org.cef.CefClient
import org.cef.CefSettings
import org.cef.browser.CefBrowser
import org.cef.browser.CefFrame
@@ -68,7 +69,6 @@ import org.cef.handler.CefLoadHandler
import org.cef.handler.CefLoadHandlerAdapter
import org.cef.handler.CefMessageRouterHandlerAdapter
import org.cef.handler.CefPermissionHandler
import org.cef.handler.CefRenderHandlerAdapter
import org.cef.handler.CefRequestHandler
import org.cef.handler.CefRequestHandlerAdapter
import org.cef.handler.CefResourceHandler
@@ -83,14 +83,11 @@ import org.cef.network.CefPostDataElement
import org.cef.network.CefRequest
import org.cef.network.CefResponse
import org.koin.mp.KoinPlatformTools
import java.awt.Rectangle
import java.io.BufferedWriter
import java.io.File
import java.io.IOException
import java.nio.ByteBuffer
import java.util.concurrent.Executor
import javax.swing.JPanel
import kotlin.math.min
import kotlin.reflect.KClass
import kotlin.reflect.KFunction
import kotlin.reflect.full.declaredMemberFunctions
import kotlin.reflect.jvm.javaMethod
@@ -101,13 +98,12 @@ class KcefWebViewProvider(
private val settings = KcefWebSettings()
private var viewClient = WebViewClient()
private var chromeClient = WebChromeClient()
private val renderHandler = RenderHandler()
private val mappings: MutableList<FunctionMapping> = mutableListOf()
private val urlHttpMapping: MutableMap<String, String> = mutableMapOf()
private var initialRequestData: InitialRequestData? = null
private var kcefClient: CefClient? = null
private var browser: CefBrowser? = null
private var kcefClient: KCEFClient? = null
private var browser: KCEFBrowser? = null
private val handler = Handler(view.webViewLooper)
@@ -119,8 +115,8 @@ class KcefWebViewProvider(
private val initHandler: InitBrowserHandler by KoinPlatformTools.defaultContext().get().inject()
}
interface InitBrowserHandler {
fun init(provider: KcefWebViewProvider): Unit
public interface InitBrowserHandler {
public fun init(provider: KcefWebViewProvider): Unit
}
private data class InitialRequestData(
@@ -196,7 +192,7 @@ class KcefWebViewProvider(
}
}
private class DisplayHandler : CefDisplayHandlerAdapter() {
private inner class DisplayHandler : CefDisplayHandlerAdapter() {
override fun onConsoleMessage(
browser: CefBrowser,
level: CefSettings.LogSeverity,
@@ -224,7 +220,6 @@ class KcefWebViewProvider(
}
}
@Suppress("DEPRECATION")
private inner class LoadHandler : CefLoadHandlerAdapter() {
override fun onLoadEnd(
browser: CefBrowser,
@@ -371,7 +366,7 @@ class KcefWebViewProvider(
callback: CefCallback,
): Boolean {
val data = resolvedData ?: return false
val bytesToTransfer = min(bytesToRead, data.size - readOffset)
val bytesToTransfer = Math.min(bytesToRead, data.size - readOffset)
Log.v(
TAG,
"readResponse: $readOffset/${data.size}, reading $bytesToRead->$bytesToTransfer",
@@ -383,7 +378,7 @@ class KcefWebViewProvider(
}
}
private class WebResponseResourceHandler(
private inner class WebResponseResourceHandler(
val webResponse: WebResourceResponse,
) : ArrayResponseResourceHandler() {
override fun processRequest(
@@ -413,7 +408,7 @@ class KcefWebViewProvider(
}
}
private class HtmlResponseResourceHandler(
private inner class HtmlResponseResourceHandler(
val html: String,
) : ArrayResponseResourceHandler() {
override fun processRequest(
@@ -444,7 +439,7 @@ class KcefWebViewProvider(
view,
CefWebResourceRequest(request, frame, false),
)
Log.v(TAG, "Resource ${request.url}, result is cancel? $cancel")
Log.v(TAG, "Resource ${request?.url}, result is cancel? $cancel")
handler.post { viewClient.onLoadResource(view, frame?.url) }
@@ -471,7 +466,7 @@ class KcefWebViewProvider(
}
if (response == null) {
// prefer user's response override
urlHttpMapping[request.url.trimEnd('/')]?.let {
urlHttpMapping.get(request.url.trimEnd('/'))?.let {
return HtmlResponseResourceHandler(it)
}
}
@@ -480,7 +475,6 @@ class KcefWebViewProvider(
}
}
@Suppress("DEPRECATION")
private inner class RequestHandler : CefRequestHandlerAdapter() {
override fun getResourceRequestHandler(
browser: CefBrowser,
@@ -490,13 +484,11 @@ class KcefWebViewProvider(
isDownload: Boolean,
requestInitiator: String,
disableDefaultHandling: BoolRef,
): CefResourceRequestHandler = ResourceRequestHandler()
): CefResourceRequestHandler? = ResourceRequestHandler()
override fun onRenderProcessTerminated(
browser: CefBrowser,
status: CefRequestHandler.TerminationStatus,
errorCode: Int,
errorString: String,
) {
handler.post {
viewClient.onRenderProcessGone(
@@ -515,33 +507,18 @@ class KcefWebViewProvider(
override fun onRequestMediaAccessPermission(
browser: CefBrowser,
frame: CefFrame,
requestingUrl: String,
requestedPermissions: Int,
requesting_url: String,
requested_permissions: Int,
callback: CefMediaAccessCallback,
): Boolean {
handler.post {
Log.v(TAG, "Checking permission for $requestingUrl: $requestedPermissions")
chromeClient.onPermissionRequest(CefPermissionRequest(requestingUrl, requestedPermissions, callback))
Log.v(TAG, "Checking permission for $requesting_url: $requested_permissions")
chromeClient.onPermissionRequest(CefPermissionRequest(requesting_url, requested_permissions, callback))
}
return true
}
}
private class RenderHandler : CefRenderHandlerAdapter() {
override fun getViewRect(browser: CefBrowser): Rectangle = Rectangle(0, 0, 1280, 2856)
override fun onPaint(
browser: CefBrowser,
popup: Boolean,
dirtyRects: Array<Rectangle>,
buffer: ByteBuffer,
width: Int,
height: Int,
) {
// do nothing
}
}
override fun init(
javaScriptInterfaces: Map<String, Any>?,
privateBrowsing: Boolean,
@@ -549,18 +526,16 @@ class KcefWebViewProvider(
Log.v(TAG, "KcefWebViewProvider: initialize")
destroy()
kcefClient =
runBlocking {
CefHelper.createClient().apply {
addDisplayHandler(DisplayHandler())
addLoadHandler(LoadHandler())
addRequestHandler(RequestHandler())
addPermissionHandler(PermissionHandler())
KCEF.newClientBlocking().apply {
addDisplayHandler(DisplayHandler())
addLoadHandler(LoadHandler())
addRequestHandler(RequestHandler())
addPermissionHandler(PermissionHandler())
val config = CefMessageRouter.CefMessageRouterConfig()
config.jsQueryFunction = QUERY_FN
config.jsCancelFunction = QUERY_CANCEL_FN
addMessageRouter(CefMessageRouter.create(config, MessageRouterHandler()))
}
val config = CefMessageRouter.CefMessageRouterConfig()
config.jsQueryFunction = QUERY_FN
config.jsCancelFunction = QUERY_CANCEL_FN
addMessageRouter(CefMessageRouter.create(config, MessageRouterHandler()))
}
initHandler.init(this)
}
@@ -637,8 +612,7 @@ class KcefWebViewProvider(
kcefClient!!
.createBrowser(
loadUrl,
CefRendering.CefRenderingWithHandler(renderHandler, JPanel()),
false,
CefRendering.OFFSCREEN,
).apply {
// NOTE: Without this, we don't seem to be receiving any events
createImmediately()
@@ -662,8 +636,7 @@ class KcefWebViewProvider(
kcefClient!!
.createBrowser(
url,
CefRendering.CefRenderingWithHandler(renderHandler, JPanel()),
false,
CefRendering.OFFSCREEN,
).apply {
// NOTE: Without this, we don't seem to be receiving any events
createImmediately()
@@ -689,19 +662,27 @@ class KcefWebViewProvider(
browser?.close(true)
browser?.dispose()
chromeClient.onProgressChanged(view, 0)
val url = baseUrl ?: "about:blank"
urlHttpMapping[url.trimEnd('/')] = data
browser =
kcefClient!!
.createBrowser(
url,
CefRendering.CefRenderingWithHandler(renderHandler, JPanel()),
false,
).apply {
// NOTE: Without this, we don't seem to be receiving any events
createImmediately()
(
baseUrl?.let { url ->
urlHttpMapping.put(url.trimEnd('/'), data)
kcefClient!!.createBrowser(
url,
CefRendering.OFFSCREEN,
)
}
?: run {
kcefClient!!.createBrowserWithHtml(
data,
KCEFBrowser.BLANK_URI,
CefRendering.OFFSCREEN,
)
}
).apply {
// NOTE: Without this, we don't seem to be receiving any events
createImmediately()
}
Log.d(TAG, "Page loaded from data at base URL $baseUrl")
}
@@ -711,11 +692,11 @@ class KcefWebViewProvider(
) {
browser!!.evaluateJavaScript(
script.removePrefix("javascript:"),
)
{
Log.v(TAG, "JS returned: $it")
it?.let { handler.post { resultCallback?.onReceiveValue(it) } }
}
},
)
}
override fun saveWebArchive(filename: String): Unit = throw RuntimeException("Stub!")
@@ -795,23 +776,15 @@ class KcefWebViewProvider(
override fun getContentWidth(): Int = throw RuntimeException("Stub!")
override fun pauseTimers() {
Log.v(TAG, "pauseTimers: doing nothing")
}
override fun pauseTimers(): Unit = throw RuntimeException("Stub!")
override fun resumeTimers() {
Log.v(TAG, "resumeTimers: doing nothing")
}
override fun resumeTimers(): Unit = throw RuntimeException("Stub!")
override fun onPause() {
Log.v(TAG, "onPause: doing nothing")
}
override fun onPause(): Unit = throw RuntimeException("Stub!")
override fun onResume() {
Log.v(TAG, "onResume: doing nothing")
}
override fun onResume(): Unit = throw RuntimeException("Stub!")
override fun isPaused(): Boolean = false
override fun isPaused(): Boolean = throw RuntimeException("Stub!")
override fun freeMemory(): Unit = throw RuntimeException("Stub!")
@@ -865,7 +838,6 @@ class KcefWebViewProvider(
override fun getWebChromeClient(): WebChromeClient = chromeClient
@Suppress("DEPRECATION")
override fun setPictureListener(listener: PictureListener): Unit = throw RuntimeException("Stub!")
@Serializable
@@ -888,7 +860,7 @@ class KcefWebViewProvider(
obj: Any,
interfaceName: String,
) {
val cls = obj::class
val cls = obj::class as KClass<Any>
mappings.addAll(
cls.declaredMemberFunctions.map {
// This is ridiculous, but necessary, otherwise "public final" throws
@@ -950,8 +922,7 @@ class KcefWebViewProvider(
override fun getRendererPriorityWaivedWhenNotVisible(): Boolean = throw RuntimeException("Stub!")
@SuppressWarnings("unused")
override fun setTextClassifier(textClassifier: TextClassifier?) {
}
override fun setTextClassifier(textClassifier: TextClassifier?) {}
override fun getTextClassifier(): TextClassifier = TextClassifier.NO_OP
@@ -977,13 +948,11 @@ class KcefWebViewProvider(
override fun onProvideAutofillVirtualStructure(
@SuppressWarnings("unused") structure: android.view.ViewStructure,
@SuppressWarnings("unused") flags: Int,
) {
}
) {}
override fun autofill(
@SuppressWarnings("unused") values: SparseArray<AutofillValue>,
) {
}
) {}
override fun isVisibleToUserForAutofill(
@SuppressWarnings("unused") virtualId: Int,
@@ -994,8 +963,7 @@ class KcefWebViewProvider(
override fun onProvideContentCaptureStructure(
@SuppressWarnings("unused") structure: android.view.ViewStructure,
@SuppressWarnings("unused") flags: Int,
) {
}
) {}
override fun getAccessibilityNodeProvider(): AccessibilityNodeProvider = throw RuntimeException("Stub!")
@@ -1065,8 +1033,7 @@ class KcefWebViewProvider(
override fun onMovedToDisplay(
displayId: Int,
config: Configuration,
) {
}
) {}
override fun onVisibilityChanged(
changedView: View,

View File

@@ -7,30 +7,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased] (Preview)
### Added
- (**Sync**) Added [SyncYomi](https://github.com/syncyomi/syncyomi) support
- (**OPDS**) Add option to skip chapter metadata feed providing direct stream/download links
- .
### Changed
- (**Database/H2**) Use the latest H2 database engine
- (**Startup**) Crash on startup if an unrecoverable error happens
- (**WebView**) Use JCEF directly and update to newest Chromium
- (**Extension/Android**) Switch MessageQueue to LegacyMessageQueue from ConcurrentMessageQueue
- .
### Fixed
- (**CloudFlareInterceptor**) Don't send the `cf_clearance` cookie back to Flaresolverr
- (**WebUI**) Handle serving non-default webui with "bundled"
- (**WebUI**) Wait until WebUI is ready to open in browser
- (**Downloads**) Truncate filenames by byte length to prevent "File name too long" IO errors
- (**Downloads**) Fix being unable to find downloads after manga was renamed during an update
- (**Downloads**) Fix preserving chapter download states during an update
- (**Extension**) Do not indicate an update is available when the extension is not installed
- (**Chapter**) Fix losing chapter data on failed chapter list update
- (**Chapter**) Fix database error when fetching chapter updates
- (**Manga/API**) Fix "mangas" graphql query with active sorting and using a PostgreSQL database (QUERY "mangas")
- (**API**) Fix GraphQL `Filter` `notAll` and `notAny` being inversed
- (**API**) Fix GraphQL `Filter` causing an UnsupportedOperationException when passing an empty list as a `Any` filter value
- (**Build**) Fix CURL failing silently in builds
- (**Backup/Database**) Fix backup creation slowdown when mapping chapters
- (CloudFlareInterceptor) Don't send the `cf_clearance` cookie back to Flaresolverr
- (WebUI) Handle serving non-default webui with "bundled"
- (WebUI) Wait until WebUI is ready to open in browser
- (Downloads) Truncate filenames by byte length to prevent "File name too long" IO errors
## [v2.2.2100] + [WebUI: v20260508.01] - 2026-05-08

View File

@@ -8,7 +8,7 @@
- [Features](#features)
- [Suwayomi client projects](#suwayomi-client-projects)
- [Integrated clients](#integrated-clients)
- [Other clients](#other-clients-potentially-inactive-or-abandoned)
- [Other clients](#other-clients-potentially-inactive-or-abondend)
- [Downloading and Running the app](#downloading-and-running-the-app)
- [Using Operating System Specific Bundles](#using-operating-system-specific-bundles)
- [Windows](#windows)
@@ -82,7 +82,6 @@ These clients are built-in options, and the server can keep them automatically u
- [Tachidesk-JUI](https://github.com/Suwayomi/Tachidesk-JUI): Desktop app (windows, linux, mac); can manage its own suwayomi server instance
- [Tachidesk-Sorayomi](https://github.com/Suwayomi/Tachidesk-Sorayomi): Web app; Desktop app (windows, linux, mac); Android app; requires access to a running server
- [Tachidesk-qtui](https://github.com/Suwayomi/Tachidesk-qtui): Android app; iOS app Desktop app (linux); requires access to a running server
- [Suwayomi Client for KOReader](https://github.com/LK4D4/suwayomi.koplugin): KOReader plugin; works anywhere KOReader can run (Android, Kindle, Kobo, etc.); requires access to a running server
# Downloading and Running the app
## Using Operating System Specific Bundles
@@ -107,21 +106,22 @@ Download the latest `linux-x64`(x86_64) release from [the releases section](http
#### WebView support (GNU/Linux)
WebView support is implemented via [JCEF](https://github.com/JetBrains/jcef).
WebView support is implemented via [KCEF](https://github.com/DATL4G/KCEF).
This is optional, and is only necessary to support some extensions.
To have a functional WebView, some X11 dependencies are required for rendering Chromium.
These include `libxrender`, `libxcomposite` `libxdamage`, `libxkbcommon` and `libxtst`.
To have a functional WebView, several dependencies are required; aside from X11 libraries necessary for rendering Chromium, some JNI bindings are necessary: gluegen and jogl (found in Ubuntu as `libgluegen2-jni` and `libjogl2-jni`).
Note that on some systems (e.g. Ubuntu), the JNI libraries are not automatically found, see below.
A CEF server is launched on startup, which loads the X11 libraries.
A KCEF server is launched on startup, which loads the X11 libraries.
If those are missing, you should see "Could not load 'jcef' library".
If so, use `ldd ~/.local/share/Tachidesk/bin/kcef/libjcef.so | grep not` to figure out which libraries are not found on your system.
Refer to the [Dockerfile](https://github.com/Suwayomi/Suwayomi-Server-docker/blob/main/Dockerfile) for more details.
The JNI bindings are only loaded when a browser is actually launched.
This is done by extensions that rely on WebView, not by Suwayomi itself.
If there is a problem loading the JNI libraries, you should see a message indicating the library and the search path.
This search path includes the current working directory, if you do not want to modify system directories.
Note that it is required to have an X session active and available to Suwayomi (i.e. `DISPLAY` is set).
It is not enough to have `WAYLAND_DISPLAY`, if your environment does not provide xwayland (or if you run Suwayomi as a service), you need to use a tool like [`Xvfb`](https://en.wikipedia.org/wiki/Xvfb).
The Dockerfile linked above also does this.
Refer to the [Dockerfile](https://github.com/Suwayomi/Suwayomi-Server-docker/blob/main/Dockerfile) for more details.
## Other methods of getting Suwayomi
### Docker

View File

@@ -25,7 +25,6 @@ allprojects {
maven("https://github.com/Suwayomi/Suwayomi-Server/raw/android-jar/")
maven("https://jitpack.io")
maven("https://jogamp.org/deployment/maven")
maven("https://packages.jetbrains.team/maven/p/ij/intellij-dependencies")
}
}

View File

@@ -14,8 +14,6 @@ val getTachideskVersion = { "v2.2.${getCommitCount()}" }
val webUIRevisionTag = "r3136"
val webviewJbrRelease = "jbr-release-25.0.3b508.4"
private val getCommitCount = {
runCatching {
ProcessBuilder()

View File

@@ -63,14 +63,6 @@ server.webUISubpath = ""
- `server.webUIUpdateCheckInterval` the interval time in hours at which to check for updates. Use `0` to disable update checking.
- `server.webUISubpath` controls on which sub-path the UI is served; by default, it will be accessible on `/` (i.e. directly), with this setting it can also be set to appear at e.g. `/suwayomi`
### webView
```
server.kcefEnabled = true
```
- `server.kcefEnabled` controls if KCEF WebView provider is enabled.
### Downloader
```
server.downloadAsCbz = true
@@ -232,7 +224,6 @@ server.opdsShowOnlyUnreadChapters = false
server.opdsShowOnlyDownloadedChapters = false
server.opdsChapterSortOrder = "DESC"
server.opdsCbzMimetype = "MODERN"
server.opdsSkipChapterMetadataFeed = false
```
- `server.opdsUseBinaryFileSizes = false` controls if Suwayomi should display file sizes in binary units (KiB, MiB, GiB) or decimal (KB, MB, GB) in OPDS listings.
- `server.opdsItemsPerPage = 50` sets the number of items per page in OPDS listings. Range: 10 <= n <= 5000.
@@ -242,7 +233,6 @@ server.opdsSkipChapterMetadataFeed = false
- `server.opdsShowOnlyDownloadedChapters = false` controls if OPDS listings should only include downloaded chapters.
- `server.opdsChapterSortOrder = "DESC"` sets the default chapter sort order in OPDS listings, either `"ASC"` or `"DESC"`
- `server.opdsCbzMimetype = "MODERN"` controls which mimetype to use for CBZ downloads. This affects the offered link in OPDS, as well as the content type of the CBZ download. Allowed is MODERN (current IANA standard), LEGACY (deprecated mimetype for .cbz) and COMPATIBLE (deprecated mimetype for all comic archives). Use LEGACY or COMPATIBLE if older clients don't offer the chapter download (note that the chapter needs to first be downloaded in Suwayomi, before it is available in OPDS).
- `server.opdsSkipChapterMetadataFeed = false` controls if the metadata feed should be skipped. When enabled, download and streaming links are provided directly in the chapter list. This improves compatibility with automated downloaders (like KOReader). KoSync strategies are applied, but `PROMPT` conflicts are ignored (treating local progress as priority).
### KOReader Sync
```
@@ -278,28 +268,6 @@ server.useHikariConnectionPool = true
- `server.databasePassword` the username with which to authenticate at the PostgreSQL instance.
- `server.useHikariConnectionPool` use Hikari Connection Pool to connect to the database.
### SyncYomi
```
server.syncYomiEnabled = false
server.syncYomiHost = ""
server.syncYomiApiKey = ""
server.syncDataManga = true
server.syncDataChapters = true
server.syncDataTracking = true
server.syncDataHistory = true
server.syncDataCategories = true
server.syncInterval = "0s"
```
- `server.syncYomiEnabled` controls whether SyncYomi is enabled.
- `server.syncYomiHost` base URL of the SyncYomi server instance. e.g. `http://localhost:8282`
- `server.syncYomiApiKey` API key to authenticate with SyncYomi. You must use the same API key in both Suwayomi and SyncYomi.
- `server.syncDataManga` enables syncing manga.
- `server.syncDataChapters` enables syncing chapters.
- `server.syncDataTracking` enables syncing tracking data.
- `server.syncDataHistory` enables syncing reading history.
- `server.syncDataCategories` enables syncing categories.
- `server.syncInterval` interval between automatic sync operations. Use `0s` to disable.
**Note:** The example [docker-compose.yml file](https://github.com/Suwayomi/Suwayomi-Server-docker/blob/main/docker-compose.yml) contains everything you need to get started with Suwayomi+PostgreSQL. Please be aware that PostgreSQL support is currently still in beta.
**Note:** These settings are excluded from backups, so a backup can be used to easily switch database installations by setting up the connection first, then restoring the backup.

View File

@@ -1,92 +1,19 @@
# Troubleshooting
This page is laid out in several sections, where each section describes a specific problem, followed by one or more possible solutions.
At the end, you will find a General section, which is the nuclear option if nothing else works.
For further support, visit the [official Suwayomi Discord server](https://discord.gg/DDZdqZWaHA).
In such cases, it will be helpful to have logs ready. You can find them in [The Data Directory](./The-Data-Directory) in the logs directory.
**All steps below assume that you have stopped Suwayomi**.
## Broken database
- `failed due to
org.jetbrains.exposed.exceptions.ExposedSQLException: org.h2.jdbc.JdbcSQLSyntaxErrorException: Column "CATEGORY.SORT_ORDER" not found`
- `org.h2.jdbc.JdbcSQLSyntaxErrorException: Column "CHAPTER.KOREADER_HASH" not found`
- `java.lang.IllegalStateException: Unable to read the page at position 96170708817765466`
- Any other error text that includes "SQL Statement"
Your database is either corrupted or incompatible.
One of these is the cause:
- You were running a preview version and decided to downgrade to stable.
- You did not shut down Suwayomi properly.
- Suwayomi crashed in an unexpected way.
Solutions:
- If you downgraded, upgrade to preview again.
- Otherwise, you will need to reset and restore from a backup. See [General Troubleshooting](#general-troubleshooting) below.
## `HTTP error 429`
The source (or, if trackers are enabled, possibly the tracker) has blocked you for sending too many requests.
Note that Mass-Migration can result in an unexpectedly high number of requests to both the source and any configured trackers.
Solution: Use other/more sources, download less, and wait between request-heavy actions.
## Extension times out
- `Timed out waiting for 20000 ms…`
- `Timed out waiting for page list`
First, check if this is an extension issue or a Suwayomi issue.
On the manga page of the problematic entry, click "Open in WebView".
Solutions:
- If the WebView loads: The issue is with the extension. Search [the issues](https://github.com/Suwayomi/Suwayomi-Server/issues) and discord if there are known problems with that extension.
- If the WebView errors: Go to [The Data Directory](./The-Data-Directory) and remove the `bin` and `cache` folders.
- If the WebView still does not work after a restart, your installation is incomplete. On Linux, refer to [the README](https://github.com/Suwayomi/Suwayomi-Server#webview-support-gnulinux).
## Flaresolverr required
- `java.io.IOException: Cloudflare bypass currently disabled`
The source you are using has enabled CloudFlare's bot protection.
If you open the source's website in your browser, you should see the "Confirm I'm human" page.
Solution:
- Download and set up [Flaresolverr](https://github.com/FlareSolverr/FlareSolverr) or [Byparr](https://github.com/ThePhaseless/Byparr). Make sure to run Flaresolverr/Byparr every time you use this source.
## Flaresolverr not running
- `java.io.IOException: Failed to connect to localhost/[0:0:0:0:0:0:0:1]:8191`
You have configured Flaresolverr by enabling the `server.flareSolverrEnabled` setting, but Flaresolverr is not installed and/or running.
Solutions:
- Install Flaresolverr if you haven't already (see previous section). Then ensure it is running (Windows: do not close the console window!).
- If it is running, ensure the configured url in `server.flareSolverrUrl` is correct. There is usually no need to change this.
- If it is running and the url is correct, check your firewall settings, your system may be blocking access to Flaresolverr.
## General Troubleshooting
This guide will try to fix Suwayomi by reseting it to a clean installation state.
> [!WARNING]
> This will remove all your data, including the library.
> Make sure you have copied your backups as described above!
- Make sure you have a recent backup of your library or create one in the app (if possible) because we **are going to wipe all Suwayomi data**.
- Make sure Suwayomi is not running (right click on tray icon and quit or kill it through the way your Operating System provides)
- Clear all browsing data on your browser if you use Suwayomi from a browser.
- Delete the Suwayomi data directory located below and re-run the app. See the article [The Data Directory](./The-Data-Directory) for information on how to find it.
- If you wish to keep your downloads, you may also attempt to surgically remove only parts. You will need to remove `database.mv.db`, `database.trace.db`, `bin`, `cache`, `extensions`, `settings`, `webUI`. Removing only a subset of these files and folders may fail to resolve the problem.
- Open Suwayomi and go to Settings > Backup > Restore Backup, and select the latest backup you have.
- Restoring from backup does not restore your downloads. If you chose to keep them in the above step, you will now need to re-download all manga. Suwayomi will pick up on the existing files and not actually download anything that isn't new.
- Delete the Suwayomi data directory located below and re-run the app.
Note: Replace `<Account>` with the currently logged in account/username on your pc.
On Mac OS X : `/Users/<Account>/Library/Application Support/Tachidesk`
On Windows XP : `C:\Documents and Settings\<Account>\Application Data\Local Settings\Tachidesk`
On Windows 7 and later : `C:\Users\<Account>\AppData\Local\Tachidesk`
On Unix/Linux : `/home/<account>/.local/share/Tachidesk`
- In the case that you have to periodically perform this fix or the problem persists or the method failed to fix it, open an issue or Join the [Suwayomi discord server](https://discord.gg/DDZdqZWaHA) to hang out with the community and to receive support and help.

View File

@@ -1,23 +1,22 @@
[versions]
kotlin = "2.4.0"
kotlin = "2.3.21"
coroutines = "1.11.0"
serialization = "1.11.0"
jvmTarget = "21"
okhttp = "5.4.0" # Major version is locked by Tachiyomi extensions
javalin = "7.2.2"
okhttp = "5.3.2" # Major version is locked by Tachiyomi extensions
javalin = "7.2.0"
jte = "3.2.4"
jackson = "3.2.0" # jackson version locked by javalin, ref: `io.javalin.core.util.OptionalDependency`
exposed = "1.3.0"
jackson = "3.1.3" # jackson version locked by javalin, ref: `io.javalin.core.util.OptionalDependency`
exposed = "1.2.0"
dex2jar = "2.4.36"
polyglot = "25.0.3"
settings = "1.3.0"
twelvemonkeys = "3.13.1"
graphqlkotlin = "10.0.0"
graphqlkotlin = "10.0.0-alpha.3"
xmlserialization = "0.91.3"
ktlint = "1.8.0"
koin = "4.2.2"
koin = "4.2.1"
moko = "0.26.4"
jcef = "144.0.15-g72717cf-chromium-144.0.7559.172-api-1.21-262-b37"
[libraries]
# Kotlin
@@ -38,9 +37,9 @@ serialization-xml-core = { module = "io.github.pdvrieze.xmlutil:core", version.r
serialization-xml = { module = "io.github.pdvrieze.xmlutil:serialization-jvm", version.ref = "xmlserialization" }
# Logging
slf4japi = "org.slf4j:slf4j-api:2.0.18"
logback = "ch.qos.logback:logback-classic:1.5.34"
kotlinlogging = "io.github.oshai:kotlin-logging-jvm:8.0.4"
slf4japi = "org.slf4j:slf4j-api:2.0.17"
logback = "ch.qos.logback:logback-classic:1.5.32"
kotlinlogging = "io.github.oshai:kotlin-logging-jvm:8.0.02"
# OkHttp
okhttp-core = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
@@ -55,7 +54,7 @@ javalin-openapi = { module = "io.javalin:javalin-openapi", version.ref = "javali
javalin-rendering = { module = "io.javalin:javalin-rendering-jte", version.ref = "javalin" }
jackson-databind = { module = "tools.jackson.core:jackson-databind", version.ref = "jackson" }
jackson-kotlin = { module = "tools.jackson.module:jackson-module-kotlin", version.ref = "jackson" }
jackson-annotations = "com.fasterxml.jackson.core:jackson-annotations:2.22"
jackson-annotations = "com.fasterxml.jackson.core:jackson-annotations:2.21"
jte = { module = "gg.jte:jte", version.ref = "jte" }
kte = { module = "gg.jte:jte-kotlin", version.ref = "jte" }
@@ -72,7 +71,7 @@ exposed-javatime = { module = "org.jetbrains.exposed:exposed-java-time", version
exposed-kotlintime = { module = "org.jetbrains.exposed:exposed-kotlin-datetime", version.ref = "exposed" }
postgres = "org.postgresql:postgresql:42.7.11"
h2 = "com.h2database:h2:2.4.240"
hikaricp = "com.zaxxer:HikariCP:7.1.0"
hikaricp = "com.zaxxer:HikariCP:7.0.2"
# Exposed Migrations
exposed-migrations = "com.github.Suwayomi:exposed-migrations:3.10.1"
@@ -91,7 +90,7 @@ rxjava = "io.reactivex:rxjava:1.3.8"
jsoup = "org.jsoup:jsoup:1.22.2"
# Config
config = "com.typesafe:config:1.4.9"
config = "com.typesafe:config:1.4.8"
config4k = "io.github.config4k:config4k:0.7.0"
# Sort
@@ -117,7 +116,7 @@ appdirs = "ca.gosyer:kotlin-multiplatform-appdirs:2.0.0"
cache4k = "io.github.reactivecircus.cache4k:cache4k:0.14.0"
zip4j = "net.lingala.zip4j:zip4j:2.11.6"
commonscompress = "org.apache.commons:commons-compress:1.28.0"
junrar = "com.github.junrar:junrar:7.6.0"
junrar = "com.github.junrar:junrar:7.5.10"
# AES/CBC/PKCS7Padding Cypher provider
bouncycastle = "org.bouncycastle:bcprov-jdk18on:1.84"
@@ -145,10 +144,10 @@ twelvemonkeys-imageio-metadata = { module = "com.twelvemonkeys.imageio:imageio-m
twelvemonkeys-imageio-jpeg = { module = "com.twelvemonkeys.imageio:imageio-jpeg", version.ref = "twelvemonkeys" }
twelvemonkeys-imageio-webp = { module = "com.twelvemonkeys.imageio:imageio-webp", version.ref = "twelvemonkeys" }
imageio-webp = "com.github.usefulness:webp-imageio:0.11.0"
imageio-webp = "com.github.usefulness:webp-imageio:0.10.2"
# Testing
mockk = "io.mockk:mockk:1.14.11"
mockk = "io.mockk:mockk:1.14.9"
# cron scheduler
cron4j = "it.sauronsoftware.cron4j:cron4j:2.2.5"
@@ -157,7 +156,7 @@ cron4j = "it.sauronsoftware.cron4j:cron4j:2.2.5"
cronUtils = "com.cronutils:cron-utils:9.2.1"
# Webview
jcef = { module = "org.jetbrains.intellij.deps.jcef:jcef", version.ref = "jcef" }
kcef = "dev.datlag:kcef:2024.04.20.4"
# User
jwt = "com.auth0:java-jwt:4.5.2"
@@ -178,13 +177,13 @@ kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref
ktlint = { id = "org.jlleitschuh.gradle.ktlint", version = "14.2.0"}
# Build config
buildconfig = { id = "com.github.gmazzo.buildconfig", version = "6.0.10"}
buildconfig = { id = "com.github.gmazzo.buildconfig", version = "6.0.9"}
# Download
download = { id = "de.undercouch.download", version = "5.7.0"}
# ShadowJar
shadowjar = { id = "com.gradleup.shadow", version = "8.3.11"}
shadowjar = { id = "com.gradleup.shadow", version = "8.3.10"}
# Moko
moko = { id = "dev.icerock.mobile.multiplatform-resources", version.ref = "moko" }
@@ -214,7 +213,7 @@ shared = [
"dex2jar-tools",
"apk-parser",
"jackson-annotations",
"jcef",
"kcef"
]
sharedTest = [

View File

@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500

View File

@@ -16,43 +16,14 @@
"depNameTemplate": "zulu",
"datasourceTemplate": "custom.zulu",
"versioningTemplate": "regex:^(?<major>\\d+)\\.(?<minor>\\d+)\\.(?<patch>\\d+).*$"
},
{
"customType": "regex",
"managerFilePatterns": [
"/buildSrc/src/main/kotlin/Constants.kt/"
],
"matchStrings": [
"val\\s+webviewJbrRelease\\s*=\\s*\"(?<currentValue>[^\"]+)\""
],
"depNameTemplate": "JetBrainsRuntime",
"packageNameTemplate": "JetBrains/JetBrainsRuntime",
"datasourceTemplate": "github-releases",
"versioningTemplate": "regex:^jbr-release-(?<major>\\d+)\\.(?<minor>\\d+)\\.(?<patch>\\d+)b(?<build>\\d+)\\.(?<revision>\\d+)$"
}
],
"customDatasources": {
"zulu": {
"defaultRegistryUrlTemplate": "https://api.azul.com/metadata/v1/zulu/packages?availability_types=ca&release_status=both&java_package_type=jre&crac_supported=false&javafx_bundled=false&support_term=lts&arch=x86&os=linux&archive_type=zip&page_size=1000&include_fields=java_package_features,release_status,support_term,os,arch,hw_bitness,abi,java_package_type,javafx_bundled,sha256_hash,cpu_gen,size,archive_type,certifications,lib_c_type,crac_supported&page=1&azul_com=true",
"transformTemplates": [
"{ \"releases\": $map($, function($v) { { \"version\": $join([$string($v.distro_version[0]), \".\", $string($v.distro_version[1]), \".\", $string($v.distro_version[2]), \"_\", $string($v.java_version[0]), \".\", $string($v.java_version[1]), \".\", $string($v.java_version[2])]) } }) }"
"{\"releases\": $$.$join([$join($map(distro_version[[0..2]], $string), \".\"), \"_\", $join($map(java_version, $string), \".\")])}"
]
}
},
"packageRules": [
{
"matchPackageNames": [
"org.jetbrains.intellij.deps.jcef:jcef",
"JetBrains/JetBrainsRuntime"
],
"groupName": "JCEF + JetBrains Runtime",
"groupSlug": "jcef-jbr"
},
{
"matchPackageNames": [
"com.github.Suwayomi:android-jar"
],
"enabled": false
}
]
}
}

View File

@@ -42,7 +42,7 @@ main() {
gcc -fPIC -shared scripts/resources/catch_abort.c -lpthread -o scripts/resources/catch_abort.so
fi
JRE_ZULU="25.34.17_25.0.3"
JRE_ZULU="25.30.17_25.0.1"
JRE_RELEASE="jre${JRE_ZULU#*_}" # e.g. jre25.0.1
ZULU_RELEASE="zulu${JRE_ZULU%_*}" # e.g. zulu25.30.17
@@ -149,15 +149,15 @@ move_release_to_output_dir() {
}
download_launcher() {
LAUNCHER_URL=$(curl -sf "https://api.github.com/repos/Suwayomi/Suwayomi-Launcher/releases/latest" | grep "browser_download_url" | grep ".jar" | head -n 1 | cut -d '"' -f 4)
curl -fL "$LAUNCHER_URL" -o "Suwayomi-Launcher.jar"
LAUNCHER_URL=$(curl -s "https://api.github.com/repos/Suwayomi/Suwayomi-Launcher/releases/latest" | grep "browser_download_url" | grep ".jar" | head -n 1 | cut -d '"' -f 4)
curl -L "$LAUNCHER_URL" -o "Suwayomi-Launcher.jar"
mv "Suwayomi-Launcher.jar" "$RELEASE_NAME/Suwayomi-Launcher.jar"
}
download_jogamp() {
local platform="$1"
if [ ! -f jogamp-all-platforms.7z ]; then
curl -f "https://jogamp.org/deployment/jogamp-current/archive/jogamp-all-platforms.7z" -o jogamp-all-platforms.7z
curl "https://jogamp.org/deployment/jogamp-current/archive/jogamp-all-platforms.7z" -o jogamp-all-platforms.7z
fi
7z x jogamp-all-platforms.7z "jogamp-all-platforms/lib/$platform/"
@@ -168,7 +168,7 @@ download_jogamp() {
download_electron() {
if [ ! -f "$ELECTRON" ]; then
curl -fL "$ELECTRON_URL" -o "$ELECTRON"
curl -L "$ELECTRON_URL" -o "$ELECTRON"
fi
unzip "$ELECTRON" -d "$RELEASE_NAME/electron/"
@@ -181,7 +181,7 @@ setup_jre() {
mv "jre" "$RELEASE_NAME/jre"
else
if [ ! -f "$JRE" ]; then
curl -fL "$JRE_URL" -o "$JRE"
curl -L "$JRE_URL" -o "$JRE"
fi
local ext="${JRE##*.}"
@@ -273,7 +273,7 @@ make_appimage() {
sudo apt update
sudo apt install libfuse2
fi
curl -fL $APPIMAGE_URL -o $APPIMAGE_TOOLNAME
curl -L $APPIMAGE_URL -o $APPIMAGE_TOOLNAME
chmod +x $APPIMAGE_TOOLNAME
ARCH=x86_64 ./$APPIMAGE_TOOLNAME "$RELEASE_NAME" "$RELEASE"
}
@@ -300,7 +300,7 @@ make_windows_bundle() {
#local rcedit_url="https://github.com/electron/rcedit/releases/download/v0.1.1/$rcedit"
## change electron's icon
#if [ ! -f "$rcedit" ]; then
#curl -fL "$rcedit_url" -o "$rcedit"
#curl -L "$rcedit_url" -o "$rcedit"
#fi
#local icon="server/src/main/resources/icon/faviconlogo.ico"

View File

@@ -159,8 +159,6 @@ buildConfig {
buildConfigField("String", "GITHUB", quoteWrap("https://github.com/Suwayomi/Suwayomi-Server"))
buildConfigField("String", "DISCORD", quoteWrap("https://discord.gg/DDZdqZWaHA"))
buildConfigField("String", "JCEF_VERSION", quoteWrap(libs.versions.jcef.get()))
buildConfigField("String", "JCEF_JBR_RELEASE", quoteWrap(webviewJbrRelease))
}
tasks {
@@ -174,7 +172,6 @@ tasks {
"Specification-Version" to getTachideskVersion(),
"Implementation-Version" to getTachideskRevision(),
"Multi-Release" to true, // needed for polyglot
"X-JBR-Release" to webviewJbrRelease,
)
}
archiveBaseName.set(rootProject.name)
@@ -185,11 +182,7 @@ tasks {
}
test {
useJUnitPlatform {
if (!project.hasProperty("masstest")) {
exclude("**/masstest/*")
}
}
useJUnitPlatform()
testLogging {
showStandardStreams = true
events("passed", "skipped", "failed")

View File

@@ -153,7 +153,4 @@
<string name="login_label_login">Log In</string>
<string name="login_placeholder_username">Type username...</string>
<string name="login_placeholder_password">Secret...</string>
<string name="opds_chapter_title_oneshot">Oneshot</string>
<string name="opds_chapter_title_fallback">Chapter %1$s</string>
</resources>

View File

@@ -15,13 +15,15 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.onEach
import org.jetbrains.exposed.v1.core.SortOrder
import suwayomi.tachidesk.graphql.types.AuthMode
@@ -71,21 +73,6 @@ val serverConfig: ServerConfig by lazy { GlobalConfigManager.module() }
private val application: Application by injectLazy()
@OptIn(ExperimentalCoroutinesApi::class)
fun <T> subscribeTo(
flow: Flow<T>,
ignoreInitialValue: Boolean = true,
onChange: suspend (value: T) -> Unit,
) {
val actualFlow =
if (ignoreInitialValue) {
flow.drop(1)
} else {
flow
}
actualFlow.distinctUntilChanged().conflate().onEach { onChange(it) }.launchIn(mutableConfigValueScope)
}
// Settings are ordered by "protoNumber".
class ServerConfig(
getConfig: () -> Config,
@@ -1027,85 +1014,7 @@ class ServerConfig(
description = "Use Hikari Connection Pool to connect to the database.",
)
val kcefEnabled: MutableStateFlow<Boolean> by BooleanSetting(
protoNumber = 86,
group = SettingGroup.WEB_VIEW,
privacySafe = true,
defaultValue = true,
description = "Enable the WebView via CEF (Chromium)"
)
val syncYomiEnabled: MutableStateFlow<Boolean> by BooleanSetting(
protoNumber = 87,
defaultValue = false,
group = SettingGroup.SYNCYOMI,
privacySafe = true
)
val syncYomiHost: MutableStateFlow<String> by StringSetting(
protoNumber = 88,
defaultValue = "",
group = SettingGroup.SYNCYOMI,
privacySafe = true,
)
val syncYomiApiKey: MutableStateFlow<String> by StringSetting(
protoNumber = 89,
defaultValue = "",
group = SettingGroup.SYNCYOMI,
privacySafe = false,
)
val syncDataManga: MutableStateFlow<Boolean> by BooleanSetting(
protoNumber = 90,
defaultValue = true,
group = SettingGroup.SYNCYOMI,
privacySafe = true,
)
val syncDataChapters: MutableStateFlow<Boolean> by BooleanSetting(
protoNumber = 91,
defaultValue = true,
group = SettingGroup.SYNCYOMI,
privacySafe = true,
)
val syncDataTracking: MutableStateFlow<Boolean> by BooleanSetting(
protoNumber = 92,
defaultValue = true,
group = SettingGroup.SYNCYOMI,
privacySafe = true,
)
val syncDataHistory: MutableStateFlow<Boolean> by BooleanSetting(
protoNumber = 93,
defaultValue = true,
group = SettingGroup.SYNCYOMI,
privacySafe = true,
)
val syncDataCategories: MutableStateFlow<Boolean> by BooleanSetting(
protoNumber = 94,
defaultValue = true,
group = SettingGroup.SYNCYOMI,
privacySafe = true,
)
val syncInterval: MutableStateFlow<Duration> by DurationSetting(
protoNumber = 95,
defaultValue = 0.seconds,
group = SettingGroup.SYNCYOMI,
privacySafe = true,
)
val opdsSkipChapterMetadataFeed: MutableStateFlow<Boolean> by BooleanSetting(
protoNumber = 96,
group = SettingGroup.OPDS,
privacySafe = true,
defaultValue = false,
description = "Skips the metadata feed and provides download/stream links directly in the chapter list. Improves compatibility with KOReader auto-downloader. KoSync strategies are applied, but PROMPT conflicts are ignored (treating local progress as priority)."
)
/** ****************************************************************** **/
/** **/
@@ -1150,7 +1059,18 @@ class ServerConfig(
flow: Flow<T>,
onChange: suspend (value: T) -> Unit,
ignoreInitialValue: Boolean = true,
) = subscribeTo(flow, ignoreInitialValue, onChange)
) {
val actualFlow =
if (ignoreInitialValue) {
flow.drop(1)
} else {
flow
}
val sharedFlow = MutableSharedFlow<T>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
actualFlow.distinctUntilChanged().mapLatest { sharedFlow.emit(it) }.launchIn(mutableConfigValueScope)
sharedFlow.onEach { onChange(it) }.launchIn(mutableConfigValueScope)
}
fun <T> subscribeTo(
flow: Flow<T>,

View File

@@ -17,8 +17,6 @@ enum class SettingGroup(
CLOUDFLARE("Cloudflare"),
OPDS("OPDS"),
KOREADER_SYNC("KOReader sync"),
WEB_VIEW("WebView"),
SYNCYOMI("SyncYomi")
;
override fun toString(): String = value

View File

@@ -88,6 +88,4 @@ object SettingsRegistry {
fun get(name: String): SettingMetadata? = settings[name]
fun getAll(): Map<String, SettingMetadata> = settings.toMap()
fun clear() = settings.clear()
}

View File

@@ -1,14 +1,15 @@
package suwayomi.tachidesk.global.impl
import dev.datlag.kcef.KCEF
import dev.datlag.kcef.KCEFBrowser
import dev.datlag.kcef.KCEFClient
import eu.kanade.tachiyomi.network.NetworkHelper
import io.github.oshai.kotlinlogging.KotlinLogging
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import okhttp3.Cookie
import okhttp3.HttpUrl
import org.cef.CefClient
import org.cef.CefSettings
import org.cef.browser.CefBrowser
import org.cef.browser.CefFrame
@@ -25,9 +26,6 @@ import org.cef.network.CefCookie
import org.cef.network.CefCookieManager
import org.cef.network.CefRequest
import uy.kohesive.injekt.injectLazy
import xyz.nulldev.androidcompat.webkit.CefHelper
import xyz.nulldev.androidcompat.webkit.dispose
import xyz.nulldev.androidcompat.webkit.evaluateJavaScript
import java.awt.Component
import java.awt.HeadlessException
import java.awt.Rectangle
@@ -49,8 +47,8 @@ import javax.swing.JPanel
class KcefWebView {
private val logger = KotlinLogging.logger {}
private val renderHandler = RenderHandler()
private var kcefClient: CefClient? = null
private var browser: CefBrowser? = null
private var kcefClient: KCEFClient? = null
private var browser: KCEFBrowser? = null
private var width = 1000
private var height = 1000
@@ -78,8 +76,7 @@ class KcefWebView {
}
}
@Serializable
sealed class Event
@Serializable sealed class Event
@Serializable
@SerialName("consoleMessage")
@@ -250,12 +247,10 @@ class KcefWebView {
init {
destroy()
kcefClient =
runBlocking {
CefHelper.createClient().apply {
addDisplayHandler(DisplayHandler())
addLoadHandler(LoadHandler())
addRequestHandler(RequestHandler())
}
KCEF.newClientBlocking().apply {
addDisplayHandler(DisplayHandler())
addLoadHandler(LoadHandler())
addRequestHandler(RequestHandler())
}
logger.debug { "Start loading cookies" }
@@ -294,7 +289,6 @@ class KcefWebView {
.createBrowser(
url,
CefRendering.CefRenderingWithHandler(renderHandler, JPanel()),
false,
// NOTE: with a context, we don't seem to be getting any cookies
).apply {
// NOTE: Without this, we don't seem to be receiving any events

View File

@@ -1,519 +0,0 @@
package suwayomi.tachidesk.global.impl.sync
import android.app.Application
import android.content.Context
import io.github.oshai.kotlinlogging.KotlinLogging
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.Serializable
import kotlinx.serialization.protobuf.ProtoBuf
import org.jetbrains.exposed.v1.core.and
import org.jetbrains.exposed.v1.core.eq
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.types.StartSyncResult
import suwayomi.tachidesk.manga.impl.Category
import suwayomi.tachidesk.manga.impl.Library.handleMangaThumbnail
import suwayomi.tachidesk.manga.impl.backup.BackupFlags
import suwayomi.tachidesk.manga.impl.backup.proto.ProtoBackupImport
import suwayomi.tachidesk.manga.impl.backup.proto.handlers.BackupCategoryHandler
import suwayomi.tachidesk.manga.impl.backup.proto.handlers.BackupMangaHandler
import suwayomi.tachidesk.manga.impl.backup.proto.handlers.BackupSourceHandler
import suwayomi.tachidesk.manga.impl.backup.proto.models.Backup
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupChapter
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupManga
import suwayomi.tachidesk.manga.model.dataclass.ChapterDataClass
import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass
import suwayomi.tachidesk.manga.model.table.CategoryMangaTable
import suwayomi.tachidesk.manga.model.table.CategoryTable
import suwayomi.tachidesk.manga.model.table.ChapterTable
import suwayomi.tachidesk.manga.model.table.MangaTable
import suwayomi.tachidesk.manga.model.table.toDataClass
import suwayomi.tachidesk.server.serverConfig
import suwayomi.tachidesk.util.HAScheduler
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import kotlin.time.Clock
import kotlin.time.Duration.Companion.seconds
import kotlin.time.Instant
import kotlin.time.measureTime
@Serializable
data class SyncData(
val backup: Backup? = null,
)
object SyncManager {
private val syncPreferences = Injekt.get<Application>().getSharedPreferences("sync", Context.MODE_PRIVATE)
private val logger = KotlinLogging.logger {}
private var currentTaskId: String? = null
private val syncMutex = Mutex()
private val _lastSyncState: MutableStateFlow<SyncState?> = MutableStateFlow(null)
val lastSyncState: StateFlow<SyncState?> = _lastSyncState.asStateFlow()
@OptIn(DelicateCoroutinesApi::class)
fun scheduleSyncTask() {
serverConfig.subscribeTo(
combine(
serverConfig.syncYomiEnabled,
serverConfig.syncInterval,
) { enabled, interval -> Pair(enabled, interval) },
{ (enabled, interval) ->
currentTaskId?.let { HAScheduler.deschedule(it) }
currentTaskId =
if (enabled && interval > 0.seconds) {
val lastSyncDate =
syncPreferences
.getLong("last_scheduled_sync", 0L)
.takeIf { it != 0L }
?.let { Instant.fromEpochMilliseconds(it) }
if (lastSyncDate == null) {
syncPreferences
.edit()
.putLong("last_scheduled_sync", Clock.System.now().toEpochMilliseconds())
.apply()
}
val delay =
if (lastSyncDate != null) {
((interval) - (Clock.System.now() - lastSyncDate)).coerceAtLeast(0.seconds)
} else {
interval
}
HAScheduler.schedule(
{
startSync(periodic = true)
syncPreferences
.edit()
.putLong("last_scheduled_sync", Clock.System.now().toEpochMilliseconds())
.apply()
},
interval = interval.inWholeMilliseconds,
delay = delay.inWholeMilliseconds,
name = "sync",
)
} else {
syncPreferences
.edit()
.remove("last_scheduled_sync")
.apply()
null
}
},
ignoreInitialValue = false,
)
}
@OptIn(DelicateCoroutinesApi::class)
fun startSync(periodic: Boolean = false): StartSyncResult {
if (!serverConfig.syncYomiEnabled.value) {
return StartSyncResult.SYNC_DISABLED
}
if (!syncMutex.tryLock()) {
return StartSyncResult.SYNC_IN_PROGRESS
}
GlobalScope.launch {
try {
syncData(periodic)
} finally {
syncMutex.unlock()
}
}
return StartSyncResult.SUCCESS
}
suspend fun ensureSync() {
if (!serverConfig.syncYomiEnabled.value) {
return
}
if (syncMutex.tryLock()) {
// there is no ongoing sync, so start one
try {
syncData()
} finally {
syncMutex.unlock()
}
} else {
// wait for the ongoing sync to finish
syncMutex.withLock {}
}
}
private suspend fun syncData(periodic: Boolean = false) {
val startInstant = Clock.System.now()
_lastSyncState.value = SyncState.Started(startInstant)
try {
logger.info {
if (periodic) {
"Starting periodic sync"
} else {
"Starting manual sync"
}
}
transaction {
MangaTable.update({ MangaTable.isSyncing eq true }) {
it[isSyncing] = false
}
ChapterTable.update({ ChapterTable.isSyncing eq true }) {
it[isSyncing] = false
}
CategoryTable.update({ CategoryTable.isSyncing eq true }) {
it[isSyncing] = false
}
}
val backupFlags =
BackupFlags(
includeManga = serverConfig.syncDataManga.value,
includeCategories = serverConfig.syncDataCategories.value,
includeChapters = serverConfig.syncDataChapters.value,
includeTracking = serverConfig.syncDataTracking.value,
includeHistory = serverConfig.syncDataHistory.value,
includeClientData = false,
includeServerSettings = false,
)
_lastSyncState.value = SyncState.CreatingBackup(startInstant)
val backupMangas = BackupMangaHandler.backup(backupFlags)
val backup =
Backup(
backupMangas,
BackupCategoryHandler.backup(backupFlags).filter { it.name != Category.DEFAULT_CATEGORY_NAME },
BackupSourceHandler.backup(backupMangas, backupFlags),
emptyMap(),
null,
)
val syncData =
SyncData(
backup = backup,
)
val remoteBackup =
SyncYomiSyncService.doSync(syncData, startInstant) {
_lastSyncState.value = it
}
if (remoteBackup == null) {
logger.debug { "Skip restore due to network issues" }
finishWithError(startInstant, "Network error", periodic)
return
}
if (remoteBackup === syncData.backup) {
// nothing changed
logger.debug { "Skip restore due to remote was overwrite from local" }
finishWithSuccess(startInstant, periodic)
return
}
// Stop the sync early if the remote backup is null or empty
if (remoteBackup.backupManga.isEmpty() && remoteBackup.backupCategories.isEmpty() && remoteBackup.backupSources.isEmpty()) {
logger.error { "No data found on remote server." }
finishWithError(startInstant, "No data found on remote server.", periodic)
return
}
val isLibraryEmpty =
transaction {
MangaTable
.selectAll()
.where { MangaTable.inLibrary eq true }
.empty()
}
// Check if it's first sync based on lastSyncTimestamp
if (syncPreferences.getLong("last_sync_timestamp", 0) == 0L && !isLibraryEmpty) {
// It's first sync no need to restore data. (just update remote data)
finishWithSuccess(startInstant, periodic)
return
}
val (filteredFavorites, nonFavorites) = filterFavoritesAndNonFavorites(remoteBackup)
updateNonFavorites(nonFavorites)
val newSyncData =
backup.copy(
backupManga = filteredFavorites,
backupCategories = remoteBackup.backupCategories,
backupSources = remoteBackup.backupSources,
)
val hasMangaChanges = filteredFavorites.isNotEmpty()
val hasCategoryChanges = remoteBackup.backupCategories != backup.backupCategories
val hasSourceChanges = remoteBackup.backupSources != backup.backupSources
if (!hasMangaChanges && !hasCategoryChanges && !hasSourceChanges) {
// update the sync timestamp
finishWithSuccess(startInstant, periodic)
return
}
if (serverConfig.syncDataCategories.value) {
val mergedUids = newSyncData.backupCategories.map { it.uid }.toSet()
val mergedNames = newSyncData.backupCategories.map { it.name }.toSet()
val localCategories = Category.getCategoryList().filterNot { it.default } // Exclude system category
val categoriesToDelete =
localCategories.filter {
it.uid !in mergedUids && it.name !in mergedNames
}
if (categoriesToDelete.isNotEmpty()) {
transaction {
categoriesToDelete.forEach {
Category.removeCategory(it.id)
}
}
}
}
val backupStream = ProtoBuf.encodeToByteArray(Backup.serializer(), newSyncData).inputStream()
val restoreId =
ProtoBackupImport.restore(
sourceStream = backupStream,
flags = backupFlags,
isSync = true,
)
_lastSyncState.value = SyncState.Restoring(startInstant, restoreId)
ProtoBackupImport.notifyFlow.first {
val restoreState = ProtoBackupImport.getRestoreState(restoreId)
restoreState == ProtoBackupImport.BackupRestoreState.Success ||
restoreState == ProtoBackupImport.BackupRestoreState.Failure
}
// update the sync timestamp
finishWithSuccess(startInstant, periodic)
} catch (e: Throwable) {
logger.error { "Error syncing: ${e.message}" }
finishWithError(startInstant, "${e::class.qualifiedName}: ${e.message}", periodic)
}
}
private fun finishWithSuccess(
startInstant: Instant,
periodic: Boolean,
) {
syncPreferences
.edit()
.putLong("last_sync_timestamp", Clock.System.now().toEpochMilliseconds())
.apply()
_lastSyncState.value = SyncState.Success(startInstant)
logger.info {
if (periodic) {
"Periodic sync completed successfully"
} else {
"Manual sync completed successfully"
}
}
}
private fun finishWithError(
startInstant: Instant,
message: String,
periodic: Boolean,
) {
_lastSyncState.value = SyncState.Error(startInstant, message)
logger.info {
if (periodic) {
"Periodic sync failed: $message"
} else {
"Manual sync failed: $message"
}
}
}
private fun isMangaDifferent(
localManga: MangaDataClass,
remoteManga: BackupManga,
): Boolean {
if (localManga.version != remoteManga.version) {
return true
}
val localChapters =
transaction {
ChapterTable
.selectAll()
.where { ChapterTable.manga eq localManga.id }
.map { ChapterTable.toDataClass(it) }
}
if (areChaptersDifferent(localChapters, remoteManga.chapters)) {
return true
}
val localCategories =
transaction {
CategoryMangaTable
.innerJoin(CategoryTable)
.selectAll()
.where { CategoryMangaTable.manga eq localManga.id }
.map { it[CategoryTable.order] }
}
return localCategories.toSet() != remoteManga.categories.toSet()
}
private fun areChaptersDifferent(
localChapters: List<ChapterDataClass>,
remoteChapters: List<BackupChapter>,
): Boolean {
val localChapterMap = localChapters.associateBy { it.url }
val remoteChapterMap = remoteChapters.associateBy { it.url }
if (localChapterMap.size != remoteChapterMap.size) {
return true
}
for ((url, localChapter) in localChapterMap) {
val remoteChapter = remoteChapterMap[url]
// If a matching remote chapter doesn't exist, or the version numbers are different, consider them different
if (remoteChapter == null || localChapter.version != remoteChapter.version) {
return true
}
}
return false
}
private fun filterFavoritesAndNonFavorites(backup: Backup): Pair<List<BackupManga>, List<BackupManga>> {
val favorites = mutableListOf<BackupManga>()
val nonFavorites = mutableListOf<BackupManga>()
val elapsedTime =
measureTime {
logger.debug { "Starting to filter favorites and non-favorites from backup data." }
backup.backupManga.forEach { remoteManga ->
val localManga =
transaction {
MangaTable
.selectAll()
.where {
(MangaTable.sourceReference eq remoteManga.source) and
(MangaTable.url eq remoteManga.url)
}.limit(1)
.map { MangaTable.toDataClass(it) }
.firstOrNull()
}
when {
// Checks if the manga is in favorites and needs updating or adding
remoteManga.favorite -> {
if (localManga == null || isMangaDifferent(localManga, remoteManga)) {
logger.debug { "Adding to favorites: ${remoteManga.title}" }
favorites.add(remoteManga)
} else {
logger.debug { "Already up-to-date favorite: ${remoteManga.title}" }
}
}
// Handle non-favorites
!remoteManga.favorite -> {
logger.debug { "Adding to non-favorites: ${remoteManga.title}" }
nonFavorites.add(remoteManga)
}
}
}
}
logger.debug {
"Filtering completed in $elapsedTime. Favorites found: ${favorites.size}, Non-favorites found: ${nonFavorites.size}"
}
return Pair(favorites, nonFavorites)
}
private fun updateNonFavorites(nonFavorites: List<BackupManga>) {
nonFavorites.forEach { nonFavorite ->
val localManga =
transaction {
MangaTable
.selectAll()
.where {
(MangaTable.sourceReference eq nonFavorite.source) and
(MangaTable.url eq nonFavorite.url)
}.limit(1)
.map { MangaTable.toDataClass(it) }
.firstOrNull()
}
if (localManga != null) {
if (localManga.inLibrary != nonFavorite.favorite) {
transaction {
MangaTable.update({ MangaTable.id eq localManga.id }) {
it[inLibrary] = nonFavorite.favorite
}
}.apply {
handleMangaThumbnail(localManga.id, nonFavorite.favorite)
}
}
}
}
}
sealed class SyncState(
open val startDate: Instant,
) {
data class Started(
override val startDate: Instant,
) : SyncState(startDate)
data class CreatingBackup(
override val startDate: Instant,
) : SyncState(startDate)
data class Downloading(
override val startDate: Instant,
) : SyncState(startDate)
data class Merging(
override val startDate: Instant,
) : SyncState(startDate)
data class Uploading(
override val startDate: Instant,
) : SyncState(startDate)
data class Restoring(
override val startDate: Instant,
val restoreId: String,
) : SyncState(startDate)
data class Success(
override val startDate: Instant,
val endDate: Instant = Clock.System.now(),
) : SyncState(startDate)
data class Error(
override val startDate: Instant,
val message: String,
val endDate: Instant = Clock.System.now(),
) : SyncState(startDate)
}
}

View File

@@ -1,517 +0,0 @@
package suwayomi.tachidesk.global.impl.sync
import android.app.Application
import android.content.Context
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.network.PUT
import eu.kanade.tachiyomi.network.await
import io.github.oshai.kotlinlogging.KotlinLogging
import io.javalin.http.HttpStatus
import kotlinx.serialization.SerializationException
import kotlinx.serialization.protobuf.ProtoBuf
import okhttp3.Headers
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import suwayomi.tachidesk.manga.impl.backup.proto.models.Backup
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupCategory
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupChapter
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupManga
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupSource
import suwayomi.tachidesk.server.serverConfig
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
import kotlin.time.Instant
object SyncYomiSyncService {
private val syncPreferences = Injekt.get<Application>().getSharedPreferences("sync", Context.MODE_PRIVATE)
private val network: NetworkHelper by injectLazy()
private val logger = KotlinLogging.logger {}
private class SyncYomiException(
message: String?,
) : Exception(message)
suspend fun doSync(
syncData: SyncData,
startDate: Instant,
setSyncState: (SyncManager.SyncState) -> Unit,
): Backup? {
setSyncState(SyncManager.SyncState.Downloading(startDate))
val (remoteData, etag) = pullSyncData()
val finalSyncData =
if (remoteData != null) {
require(etag.isNotEmpty()) { "ETag should never be empty if remote data is not null" }
logger.debug { "Try update remote data with ETag($etag)" }
setSyncState(SyncManager.SyncState.Merging(startDate))
mergeSyncData(syncData, remoteData)
} else {
// init or overwrite remote data
logger.debug { "Try overwrite remote data with ETag($etag)" }
syncData
}
if (finalSyncData.backup != null) {
setSyncState(SyncManager.SyncState.Uploading(startDate))
}
pushSyncData(finalSyncData, etag)
return finalSyncData.backup
}
private suspend fun pullSyncData(): Pair<SyncData?, String> {
val host = serverConfig.syncYomiHost.value
val apiKey = serverConfig.syncYomiApiKey.value
val downloadUrl = "$host/api/sync/content"
val headersBuilder = Headers.Builder().add("X-API-Token", apiKey)
val lastETag = syncPreferences.getString("last_sync_etag", "") ?: ""
if (lastETag != "") {
headersBuilder.add("If-None-Match", lastETag)
}
val headers = headersBuilder.build()
val downloadRequest =
GET(
url = downloadUrl,
headers = headers,
)
val response = network.client.newCall(downloadRequest).await()
if (response.code == HttpStatus.NOT_MODIFIED.code) {
// not modified
require(lastETag.isNotEmpty())
logger.info { "Remote server not modified" }
return Pair(null, lastETag)
} else if (response.code == HttpStatus.NOT_FOUND.code) {
// maybe got deleted from remote
return Pair(null, "")
}
if (response.isSuccessful) {
val newETag =
response.headers["ETag"]
?.takeIf { it.isNotEmpty() } ?: throw SyncYomiException("Missing ETag")
val byteArray =
response.body.byteStream().use {
return@use it.readBytes()
}
return try {
val backup = ProtoBuf.decodeFromByteArray(Backup.serializer(), byteArray)
return Pair(SyncData(backup = backup), newETag)
} catch (_: SerializationException) {
logger.info { "Bad content responsed from server" }
// the body is invalid
// return default value so we can overwrite it
Pair(null, "")
}
} else {
val responseBody = response.body.string()
logger.error { "SyncError: $responseBody" }
throw SyncYomiException("Failed to download sync data: $responseBody")
}
}
private suspend fun pushSyncData(
syncData: SyncData,
eTag: String,
) {
val backup = syncData.backup ?: return
val host = serverConfig.syncYomiHost.value
val apiKey = serverConfig.syncYomiApiKey.value
val uploadUrl = "$host/api/sync/content"
val headersBuilder = Headers.Builder().add("X-API-Token", apiKey)
if (eTag.isNotEmpty()) {
headersBuilder.add("If-Match", eTag)
}
val headers = headersBuilder.build()
// Set timeout to 30 seconds
val timeout = 30.seconds
val client =
network.client
.newBuilder()
.connectTimeout(timeout)
.readTimeout(timeout)
.writeTimeout(timeout)
.build()
val byteArray = ProtoBuf.encodeToByteArray(Backup.serializer(), backup)
if (byteArray.isEmpty()) {
throw IllegalStateException("Empty backup error")
}
val body = byteArray.toRequestBody("application/octet-stream".toMediaType())
val uploadRequest =
PUT(
url = uploadUrl,
headers = headers,
body = body,
)
val response = client.newCall(uploadRequest).await()
if (response.isSuccessful) {
val newETag =
response.headers["ETag"]
?.takeIf { it.isNotEmpty() } ?: throw SyncYomiException("Missing ETag")
syncPreferences
.edit()
.putString("last_sync_etag", newETag)
.apply()
logger.debug { "SyncYomi sync completed" }
} else if (response.code == HttpStatus.PRECONDITION_FAILED.code) {
// other clients updated remote data, will try next time
logger.debug { "SyncYomi sync failed with 412" }
} else {
val responseBody = response.body.string()
logger.error { "SyncError: $responseBody" }
}
}
fun mergeSyncData(
localSyncData: SyncData,
remoteSyncData: SyncData,
): SyncData {
val mergedCategoriesList =
mergeCategoriesLists(localSyncData.backup?.backupCategories, remoteSyncData.backup?.backupCategories)
val mergedMangaList =
mergeMangaLists(
localSyncData.backup?.backupManga,
remoteSyncData.backup?.backupManga,
localSyncData.backup?.backupCategories ?: emptyList(),
remoteSyncData.backup?.backupCategories ?: emptyList(),
mergedCategoriesList,
)
val mergedSourcesList =
mergeSourcesLists(localSyncData.backup?.backupSources, remoteSyncData.backup?.backupSources)
// Create the merged Backup object
val mergedBackup =
Backup(
backupManga = mergedMangaList,
backupCategories = mergedCategoriesList,
backupSources = mergedSourcesList,
meta = emptyMap(),
serverSettings = null,
)
// Create the merged SData object
return SyncData(
backup = mergedBackup,
)
}
private fun mergeMangaLists(
localMangaList: List<BackupManga>?,
remoteMangaList: List<BackupManga>?,
localCategories: List<BackupCategory>,
remoteCategories: List<BackupCategory>,
mergedCategories: List<BackupCategory>,
): List<BackupManga> {
val localMangaListSafe = localMangaList.orEmpty()
val remoteMangaListSafe = remoteMangaList.orEmpty()
logger.debug { "Starting merge. Local list size: ${localMangaListSafe.size}, Remote list size: ${remoteMangaListSafe.size}" }
fun mangaCompositeKey(manga: BackupManga): String =
"${manga.source}|${manga.url}|${manga.title.lowercase().trim()}|${manga.author?.lowercase()?.trim()}"
// Create maps using composite keys
val localMangaMap = localMangaListSafe.associateBy { mangaCompositeKey(it) }
val remoteMangaMap = remoteMangaListSafe.associateBy { mangaCompositeKey(it) }
val localCategoriesMapByOrder = localCategories.associateBy { it.order }
val remoteCategoriesMapByOrder = remoteCategories.associateBy { it.order }
val mergedCategoriesMapByName = mergedCategories.associateBy { it.name }
fun updateCategories(
theManga: BackupManga,
theMap: Map<Int, BackupCategory>,
): BackupManga =
theManga.copy(
categories =
theManga.categories.mapNotNull {
theMap[it]?.let { category ->
mergedCategoriesMapByName[category.name]?.order
}
},
)
val lastSyncTime = syncPreferences.getLong("last_sync_timestamp", 0).milliseconds.inWholeSeconds
val mergedList =
(localMangaMap.keys + remoteMangaMap.keys).distinct().mapNotNull { compositeKey ->
val local = localMangaMap[compositeKey]
val remote = remoteMangaMap[compositeKey]
// New version comparison logic
when {
local != null && remote == null -> {
if (lastSyncTime == 0L || local.lastModifiedAt > lastSyncTime) {
updateCategories(local, localCategoriesMapByOrder)
} else {
logger.debug { "Dropping local manga deleted on remote: ${local.title}." }
null
}
}
local == null && remote != null -> {
if (lastSyncTime == 0L || remote.lastModifiedAt > lastSyncTime) {
updateCategories(remote, remoteCategoriesMapByOrder)
} else {
logger.debug { "Dropping deleted remote manga: ${remote.title}." }
null
}
}
local != null && remote != null -> {
// Compare versions to decide which manga to keep
if (local.version >= remote.version) {
logger.debug { "Keeping local version of ${local.title} with merged chapters." }
updateCategories(
local.copy(
chapters =
mergeChapters(
local.chapters,
remote.chapters,
lastSyncTime,
serverConfig.syncDataChapters.value,
),
),
localCategoriesMapByOrder,
)
} else {
logger.debug { "Keeping remote version of ${remote.title} with merged chapters." }
updateCategories(
remote.copy(
chapters =
mergeChapters(
local.chapters,
remote.chapters,
lastSyncTime,
serverConfig.syncDataChapters.value,
),
),
remoteCategoriesMapByOrder,
)
}
}
else -> {
null
} // No manga found for key
}
}
// Counting favorites and non-favorites
val (favorites, nonFavorites) = mergedList.partition { it.favorite }
logger.debug {
"Merge completed. Total merged manga: ${mergedList.size}, Favorites: ${favorites.size}, Non-Favorites: ${nonFavorites.size}"
}
return mergedList
}
private fun mergeChapters(
localChapters: List<BackupChapter>,
remoteChapters: List<BackupChapter>,
lastSyncTime: Long,
syncingChapters: Boolean,
): List<BackupChapter> {
if (!syncingChapters) {
return remoteChapters // If not syncing chapters, keep remote untouched
}
fun chapterCompositeKey(chapter: BackupChapter): String = "${chapter.url}|${chapter.name}|${chapter.chapterNumber}"
val localChapterMap = localChapters.associateBy { chapterCompositeKey(it) }
val remoteChapterMap = remoteChapters.associateBy { chapterCompositeKey(it) }
logger.debug { "Starting chapter merge. Local chapters: ${localChapters.size}, Remote chapters: ${remoteChapters.size}" }
// Merge both chapter maps based on version numbers
val mergedChapters =
(localChapterMap.keys + remoteChapterMap.keys).distinct().mapNotNull { compositeKey ->
val localChapter = localChapterMap[compositeKey]
val remoteChapter = remoteChapterMap[compositeKey]
logger.debug {
"Processing chapter key: $compositeKey. Local chapter: ${localChapter != null}, Remote chapter: ${remoteChapter != null}"
}
when {
localChapter != null && remoteChapter == null -> {
if (lastSyncTime == 0L || localChapter.lastModifiedAt > lastSyncTime) {
logger.debug { "Keeping local chapter: ${localChapter.name}." }
localChapter
} else {
logger.debug { "Dropping local chapter deleted on remote: ${localChapter.name}." }
null
}
}
localChapter == null && remoteChapter != null -> {
if (lastSyncTime == 0L || remoteChapter.lastModifiedAt > lastSyncTime) {
logger.debug { "Taking remote chapter: ${remoteChapter.name}." }
remoteChapter
} else {
logger.debug { "Dropping deleted remote chapter: ${remoteChapter.name}." }
null
}
}
localChapter != null && remoteChapter != null -> {
// Use version number to decide which chapter to keep
val chosenChapter =
if (localChapter.version >= remoteChapter.version) {
// If there are more chapter on remote, local sourceOrder will need to be updated to maintain correct source order.
if (localChapters.size < remoteChapters.size) {
localChapter.copy(sourceOrder = remoteChapter.sourceOrder)
} else {
localChapter
}
} else {
remoteChapter
}
logger.debug {
"Merging chapter: ${chosenChapter.name}. Chosen version from: ${if (localChapter.version >= remoteChapter.version) "Local" else "Remote"}, Local version: ${localChapter.version}, Remote version: ${remoteChapter.version}."
}
chosenChapter
}
else -> {
logger.debug { "No chapter found for composite key: $compositeKey. Skipping." }
null
}
}
}
logger.debug { "Chapter merge completed. Total merged chapters: ${mergedChapters.size}" }
return mergedChapters
}
private fun mergeCategoriesLists(
localCategoriesList: List<BackupCategory>?,
remoteCategoriesList: List<BackupCategory>?,
): List<BackupCategory> {
if (localCategoriesList == null) return remoteCategoriesList ?: emptyList()
if (remoteCategoriesList == null) return localCategoriesList
val result = mutableListOf<BackupCategory>()
val processedLocals = mutableSetOf<BackupCategory>()
val localMapByUid = localCategoriesList.filter { it.uid != 0L }.associateBy { it.uid }
val localMapByName = localCategoriesList.associateBy { it.name }
val lastSyncTime = syncPreferences.getLong("last_sync_timestamp", 0)
remoteCategoriesList.forEach { remote ->
var localMatch: BackupCategory? = null
// 1. Try match by UID
if (remote.uid != 0L) {
localMatch = localMapByUid[remote.uid]
}
// 2. Try match by Name (fallback)
if (localMatch == null) {
localMatch = localMapByName[remote.name]
}
if (localMatch != null) {
processedLocals.add(localMatch)
// Conflict resolution
if (localMatch.version >= remote.version) {
logger.debug { "Keeping local category: ${localMatch.name} (UID: ${localMatch.uid})" }
result.add(localMatch)
} else {
logger.debug { "Keeping remote category: ${remote.name} (UID: ${remote.uid})" }
// Preserve Local UID if Remote was 0
if (remote.uid == 0L) {
remote.uid = localMatch.uid
}
result.add(remote)
}
} else {
val remoteModifiedTimeMillis = remote.lastModifiedAt.seconds.inWholeMilliseconds
if (lastSyncTime == 0L || remoteModifiedTimeMillis > lastSyncTime) {
logger.debug { "Adding new remote category: ${remote.name} (UID: ${remote.uid})" }
result.add(remote)
} else {
logger.debug { "Dropping deleted remote category: ${remote.name} (UID: ${remote.uid})" }
}
}
}
// Add remaining Local Categories
localCategoriesList.forEach { local ->
if (local !in processedLocals) {
val localModifiedTimeMillis = local.lastModifiedAt.seconds.inWholeMilliseconds
if (lastSyncTime == 0L || localModifiedTimeMillis > lastSyncTime) {
logger.debug { "Keeping local only category: ${local.name} (UID: ${local.uid})" }
result.add(local)
} else {
logger.debug { "Dropping local category deleted on remote: ${local.name} (UID: ${local.uid})" }
}
}
}
return result.sortedBy { it.order }
}
private fun mergeSourcesLists(
localSources: List<BackupSource>?,
remoteSources: List<BackupSource>?,
): List<BackupSource> {
// Create maps using sourceId as key
val localSourceMap = localSources?.associateBy { it.sourceId } ?: emptyMap()
val remoteSourceMap = remoteSources?.associateBy { it.sourceId } ?: emptyMap()
logger.debug { "Starting source merge. Local sources: ${localSources?.size}, Remote sources: ${remoteSources?.size}" }
// Merge both source maps
val mergedSources =
(localSourceMap.keys + remoteSourceMap.keys).distinct().mapNotNull { sourceId ->
val localSource = localSourceMap[sourceId]
val remoteSource = remoteSourceMap[sourceId]
logger.debug {
"Processing source ID: $sourceId. Local source: ${localSource != null}, Remote source: ${remoteSource != null}"
}
when {
localSource != null && remoteSource == null -> {
logger.debug { "Using local source: ${localSource.name}." }
localSource
}
remoteSource != null && localSource == null -> {
logger.debug { "Using remote source: ${remoteSource.name}." }
remoteSource
}
else -> {
logger.debug { "Remote and local have the same source ID: $sourceId. Keeping local." }
localSource
}
}
}
logger.debug { "Source merge completed. Total merged sources: ${mergedSources.size}" }
return mergedSources
}
}

View File

@@ -13,6 +13,6 @@ import org.jetbrains.exposed.v1.core.dao.id.IntIdTable
* Metadata storage for clients, server/global level.
*/
object GlobalMetaTable : IntIdTable() {
val key = varchar("meta_key", 256)
val key = varchar("key", 256)
val value = varchar("value", 4096)
}

View File

@@ -1,28 +0,0 @@
package suwayomi.tachidesk.graphql.mutations
import suwayomi.tachidesk.global.impl.sync.SyncManager
import suwayomi.tachidesk.graphql.directives.RequireAuth
import suwayomi.tachidesk.graphql.types.StartSyncResult
class SyncMutation {
data class StartSyncInput(
val clientMutationId: String? = null,
)
data class StartSyncPayload(
val clientMutationId: String? = null,
val result: StartSyncResult,
)
@RequireAuth
fun startSync(input: StartSyncInput): StartSyncPayload {
val (clientMutationId) = input
val result = SyncManager.startSync()
return StartSyncPayload(
clientMutationId = clientMutationId,
result = result,
)
}
}

View File

@@ -15,11 +15,9 @@ import org.jetbrains.exposed.v1.core.Op
import org.jetbrains.exposed.v1.core.SortOrder
import org.jetbrains.exposed.v1.core.greater
import org.jetbrains.exposed.v1.core.inList
import org.jetbrains.exposed.v1.core.inSubQuery
import org.jetbrains.exposed.v1.core.less
import org.jetbrains.exposed.v1.core.like
import org.jetbrains.exposed.v1.jdbc.select
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import suwayomi.tachidesk.graphql.directives.RequireAuth
import suwayomi.tachidesk.graphql.queries.filter.BooleanFilter
@@ -245,16 +243,13 @@ class MangaQuery {
): MangaNodeList {
val queryResults =
transaction {
val mangaIdsQuery =
val res =
MangaTable
.leftJoin(CategoryMangaTable)
.select(MangaTable.id)
.withDistinct()
.select(MangaTable.columns)
.withDistinctOn(MangaTable.id)
mangaIdsQuery.applyOps(condition, filter)
val res =
MangaTable.selectAll().where { MangaTable.id inSubQuery mangaIdsQuery }
res.applyOps(condition, filter)
if (order != null || orderBy != null || (last != null || before != null)) {
val baseSort = listOf(MangaOrder(MangaOrderBy.ID, SortOrder.ASC))

View File

@@ -1,11 +0,0 @@
package suwayomi.tachidesk.graphql.queries
import suwayomi.tachidesk.global.impl.sync.SyncManager
import suwayomi.tachidesk.graphql.directives.RequireAuth
import suwayomi.tachidesk.graphql.types.SyncStatus
import suwayomi.tachidesk.graphql.types.toStatus
class SyncQuery {
@RequireAuth
fun lastSyncStatus(): SyncStatus? = SyncManager.lastSyncState.value?.toStatus()
}

View File

@@ -435,7 +435,7 @@ fun <T : String, S : T?> andFilterWithCompareString(
opAnd.andWhere(filter.isNull) { if (it) column.isNull() else column.isNotNull() }
opAnd.andWhere(filter.equalTo) { column eq it as S }
opAnd.andNotWhere(filter.notEqualTo, filter.notEqualToAll, filter.notEqualToAny) { column neq it as S }
opAnd.andWhere(filter.notEqualTo, filter.notEqualToAll, filter.notEqualToAny) { column neq it as S }
opAnd.andWhere(
filter.distinctFrom,
filter.distinctFromAll,
@@ -455,36 +455,36 @@ fun <T : String, S : T?> andFilterWithCompareString(
opAnd.andWhere(filter.greaterThanOrEqualTo) { column greaterEq it }
opAnd.andWhere(filter.includes, filter.includesAll, filter.includesAny) { column like "%$it%" }
opAnd.andNotWhere(filter.notIncludes, filter.notIncludesAll, filter.notIncludesAny) { column notLike "%$it%" }
opAnd.andWhere(filter.notIncludes, filter.notIncludesAll, filter.notIncludesAny) { column notLike "%$it%" }
opAnd.andWhere(filter.includesInsensitive, filter.includesInsensitiveAll, filter.includesInsensitiveAny) {
ILikeEscapeOp.iLike(column, "%$it%")
}
opAnd.andNotWhere(filter.notIncludesInsensitive, filter.notIncludesInsensitiveAll, filter.notIncludesInsensitiveAny) {
opAnd.andWhere(filter.notIncludesInsensitive, filter.notIncludesInsensitiveAll, filter.notIncludesInsensitiveAny) {
ILikeEscapeOp.iNotLike(column, "%$it%")
}
opAnd.andWhere(filter.startsWith, filter.startsWithAll, filter.startsWithAny) { column like "$it%" }
opAnd.andNotWhere(filter.notStartsWith, filter.notStartsWithAll, filter.notStartsWithAny) { column notLike "$it%" }
opAnd.andWhere(filter.notStartsWith, filter.notStartsWithAll, filter.notStartsWithAny) { column notLike "$it%" }
opAnd.andWhere(filter.startsWithInsensitive, filter.startsWithInsensitiveAll, filter.startsWithInsensitiveAny) {
ILikeEscapeOp.iLike(column, "$it%")
}
opAnd.andNotWhere(filter.notStartsWithInsensitive, filter.notStartsWithInsensitiveAll, filter.notStartsWithInsensitiveAny) {
opAnd.andWhere(filter.notStartsWithInsensitive, filter.notStartsWithInsensitiveAll, filter.notStartsWithInsensitiveAny) {
ILikeEscapeOp.iNotLike(column, "$it%")
}
opAnd.andWhere(filter.endsWith, filter.endsWithAll, filter.endsWithAny) { column like "%$it" }
opAnd.andNotWhere(filter.notEndsWith, filter.notEndsWithAll, filter.notEndsWithAny) { column notLike "%$it" }
opAnd.andWhere(filter.notEndsWith, filter.notEndsWithAll, filter.notEndsWithAny) { column notLike "%$it" }
opAnd.andWhere(filter.endsWithInsensitive, filter.endsWithInsensitiveAll, filter.endsWithInsensitiveAny) {
ILikeEscapeOp.iLike(column, "%$it")
}
opAnd.andNotWhere(filter.notEndsWithInsensitive, filter.notEndsWithInsensitiveAll, filter.notEndsWithInsensitiveAny) {
opAnd.andWhere(filter.notEndsWithInsensitive, filter.notEndsWithInsensitiveAll, filter.notEndsWithInsensitiveAny) {
ILikeEscapeOp.iNotLike(column, "%$it")
}
opAnd.andWhere(filter.like, filter.likeAll, filter.likeAny) { column like it }
opAnd.andNotWhere(filter.notLike, filter.notLikeAll, filter.notLikeAny) { column notLike it }
opAnd.andWhere(filter.notLike, filter.notLikeAll, filter.notLikeAny) { column notLike it }
opAnd.andWhere(filter.likeInsensitive, filter.likeInsensitiveAll, filter.likeInsensitiveAny) { ILikeEscapeOp.iLike(column, it) }
opAnd.andNotWhere(filter.notLikeInsensitive, filter.notLikeInsensitiveAll, filter.notLikeInsensitiveAny) {
opAnd.andWhere(filter.notLikeInsensitive, filter.notLikeInsensitiveAll, filter.notLikeInsensitiveAny) {
ILikeEscapeOp.iNotLike(column, it)
}
@@ -535,17 +535,6 @@ class OpAnd(
andWhereAny(valueAny, expr)
}
fun <T : Any> andNotWhere(
valueDefault: T?,
valueAll: List<T>?,
valueAny: List<T>?,
expr: (T) -> Op<Boolean>,
) {
andWhere(valueDefault, expr)
andNotWhereAll(valueAll, expr)
andNotWhereAny(valueAny, expr)
}
fun <T : Any> andWhereAll(
values: List<T>?,
andPart: (T) -> Op<Boolean>,
@@ -553,31 +542,15 @@ class OpAnd(
values?.map { andWhere(it, andPart) }
}
fun <T : Any> andNotWhereAll(
values: List<T>?,
andPart: (T) -> Op<Boolean>,
) {
// Inversed all equals any
andWhereAny(values, andPart)
}
fun <T : Any> andWhereAny(
values: List<T>?,
andPart: (T) -> Op<Boolean>,
) {
values ?: return
val expr = values.map { andPart(it) }.reduceOrNull { acc, op -> acc or op } ?: return
val expr = values.map { andPart(it) }.reduce { acc, op -> acc or op }
op = if (op == null) expr else (op!! and expr)
}
fun <T : Any> andNotWhereAny(
values: List<T>?,
andPart: (T) -> Op<Boolean>,
) {
// Inversed any equals all
andWhereAll(values, andPart)
}
fun <T> eq(
value: T?,
column: Column<T>,
@@ -605,7 +578,7 @@ fun <T : Comparable<T>, S : T?> andFilterWithCompare(
opAnd.andWhere(filter.isNull) { if (it) column.isNull() else column.isNotNull() }
opAnd.andWhere(filter.equalTo) { column eq it as S }
opAnd.andNotWhere(filter.notEqualTo, filter.notEqualToAll, filter.notEqualToAny) { column neq it as S }
opAnd.andWhere(filter.notEqualTo, filter.notEqualToAll, filter.notEqualToAny) { column neq it as S }
opAnd.andWhere(filter.distinctFrom, filter.distinctFromAll, filter.distinctFromAny) { DistinctFromOp.distinctFrom(column, it as S) }
opAnd.andWhere(filter.notDistinctFrom) { DistinctFromOp.notDistinctFrom(column, it as S) }
if (!filter.`in`.isNullOrEmpty()) {
@@ -633,7 +606,7 @@ fun <T : Comparable<T>> andFilterWithCompareEntity(
opAnd.andWhere(filter.isNull) { if (it) column.isNull() else column.isNotNull() }
opAnd.andWhere(filter.equalTo) { column eq it }
opAnd.andNotWhere(filter.notEqualTo, filter.notEqualToAll, filter.notEqualToAny) { column neq it }
opAnd.andWhere(filter.notEqualTo, filter.notEqualToAll, filter.notEqualToAny) { column neq it }
opAnd.andWhere(filter.distinctFrom, filter.distinctFromAll, filter.distinctFromAny) { DistinctFromOp.distinctFrom(column, it) }
opAnd.andWhere(filter.notDistinctFrom) { DistinctFromOp.notDistinctFrom(column, it) }
if (!filter.`in`.isNullOrEmpty()) {

View File

@@ -27,7 +27,6 @@ import suwayomi.tachidesk.graphql.mutations.MangaMutation
import suwayomi.tachidesk.graphql.mutations.MetaMutation
import suwayomi.tachidesk.graphql.mutations.SettingsMutation
import suwayomi.tachidesk.graphql.mutations.SourceMutation
import suwayomi.tachidesk.graphql.mutations.SyncMutation
import suwayomi.tachidesk.graphql.mutations.TrackMutation
import suwayomi.tachidesk.graphql.mutations.UpdateMutation
import suwayomi.tachidesk.graphql.mutations.UserMutation
@@ -42,7 +41,6 @@ import suwayomi.tachidesk.graphql.queries.MangaQuery
import suwayomi.tachidesk.graphql.queries.MetaQuery
import suwayomi.tachidesk.graphql.queries.SettingsQuery
import suwayomi.tachidesk.graphql.queries.SourceQuery
import suwayomi.tachidesk.graphql.queries.SyncQuery
import suwayomi.tachidesk.graphql.queries.TrackQuery
import suwayomi.tachidesk.graphql.queries.UpdateQuery
import suwayomi.tachidesk.graphql.server.primitives.Cursor
@@ -52,7 +50,6 @@ import suwayomi.tachidesk.graphql.server.primitives.GraphQLLongAsString
import suwayomi.tachidesk.graphql.server.primitives.GraphQLUpload
import suwayomi.tachidesk.graphql.subscriptions.DownloadSubscription
import suwayomi.tachidesk.graphql.subscriptions.InfoSubscription
import suwayomi.tachidesk.graphql.subscriptions.SyncSubscription
import suwayomi.tachidesk.graphql.subscriptions.UpdateSubscription
import kotlin.reflect.KClass
import kotlin.reflect.KType
@@ -101,7 +98,6 @@ val schema =
TopLevelObject(MetaQuery()),
TopLevelObject(SettingsQuery()),
TopLevelObject(SourceQuery()),
TopLevelObject(SyncQuery()),
TopLevelObject(TrackQuery()),
TopLevelObject(UpdateQuery()),
),
@@ -118,7 +114,6 @@ val schema =
TopLevelObject(MangaMutation()),
TopLevelObject(MetaMutation()),
TopLevelObject(SettingsMutation()),
TopLevelObject(SyncMutation()),
TopLevelObject(SourceMutation()),
TopLevelObject(TrackMutation()),
TopLevelObject(UpdateMutation()),
@@ -128,7 +123,6 @@ val schema =
listOf(
TopLevelObject(DownloadSubscription()),
TopLevelObject(InfoSubscription()),
TopLevelObject(SyncSubscription()),
TopLevelObject(UpdateSubscription()),
),
)

View File

@@ -1,17 +0,0 @@
package suwayomi.tachidesk.graphql.subscriptions
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map
import suwayomi.tachidesk.global.impl.sync.SyncManager
import suwayomi.tachidesk.graphql.directives.RequireAuth
import suwayomi.tachidesk.graphql.types.SyncStatus
import suwayomi.tachidesk.graphql.types.toStatus
class SyncSubscription {
@RequireAuth
fun syncStatusChanged(): Flow<SyncStatus> =
SyncManager.lastSyncState
.filterNotNull()
.map { it.toStatus() }
}

View File

@@ -1,91 +0,0 @@
package suwayomi.tachidesk.graphql.types
import suwayomi.tachidesk.global.impl.sync.SyncManager
enum class StartSyncResult {
SUCCESS,
SYNC_IN_PROGRESS,
SYNC_DISABLED,
}
enum class SyncState {
STARTED,
CREATING_BACKUP,
DOWNLOADING,
MERGING,
UPLOADING,
RESTORING,
SUCCESS,
ERROR,
}
data class SyncStatus(
val state: SyncState,
val startDate: Long,
val endDate: Long? = null,
val backupRestoreId: String? = null,
val errorMessage: String? = null,
)
fun SyncManager.SyncState.toStatus(): SyncStatus =
when (this) {
is SyncManager.SyncState.Started -> {
SyncStatus(
state = SyncState.STARTED,
startDate = startDate.toEpochMilliseconds(),
)
}
is SyncManager.SyncState.CreatingBackup -> {
SyncStatus(
state = SyncState.CREATING_BACKUP,
startDate = startDate.toEpochMilliseconds(),
)
}
is SyncManager.SyncState.Downloading -> {
SyncStatus(
state = SyncState.DOWNLOADING,
startDate = startDate.toEpochMilliseconds(),
)
}
is SyncManager.SyncState.Merging -> {
SyncStatus(
state = SyncState.MERGING,
startDate = startDate.toEpochMilliseconds(),
)
}
is SyncManager.SyncState.Uploading -> {
SyncStatus(
state = SyncState.UPLOADING,
startDate = startDate.toEpochMilliseconds(),
)
}
is SyncManager.SyncState.Restoring -> {
SyncStatus(
state = SyncState.RESTORING,
startDate = startDate.toEpochMilliseconds(),
backupRestoreId = restoreId,
)
}
is SyncManager.SyncState.Success -> {
SyncStatus(
state = SyncState.SUCCESS,
startDate = startDate.toEpochMilliseconds(),
endDate = endDate.toEpochMilliseconds(),
)
}
is SyncManager.SyncState.Error -> {
SyncStatus(
state = SyncState.ERROR,
startDate = startDate.toEpochMilliseconds(),
endDate = endDate.toEpochMilliseconds(),
errorMessage = message,
)
}
}

View File

@@ -85,12 +85,6 @@ object CategoryManga {
}
}
fun removeMangaFromAllCategories(mangaId: Int) {
transaction {
CategoryMangaTable.deleteWhere { CategoryMangaTable.manga eq mangaId }
}
}
/**
* list of mangas that belong to a category
*/
@@ -117,14 +111,12 @@ object CategoryManga {
val transform: (ResultRow) -> MangaDataClass = {
// Map the data from the result row to the MangaDataClass
MangaTable
.toDataClass(it)
.copy(
lastReadAt = it[lastReadAt],
unreadCount = it[unreadCount],
downloadCount = it[downloadedCount],
chapterCount = it[chapterCount],
)
val dataClass = MangaTable.toDataClass(it)
dataClass.lastReadAt = it[lastReadAt]
dataClass.unreadCount = it[unreadCount]
dataClass.downloadCount = it[downloadedCount]
dataClass.chapterCount = it[chapterCount]
dataClass
}
return transaction {

View File

@@ -104,6 +104,9 @@ object Chapter {
.associateBy({ it[ChapterTable.url] }, { it })
}
val chapterIds = chapterList.map { dbChapterMap.getValue(it.url)[ChapterTable.id] }
val chapterMetas = getChaptersMetaMaps(chapterIds.map { it.value })
return chapterList.mapIndexed { index, it ->
val dbChapter = dbChapterMap.getValue(it.url)
@@ -125,8 +128,8 @@ object Chapter {
realUrl = dbChapter[ChapterTable.realUrl],
downloaded = dbChapter[ChapterTable.isDownloaded],
pageCount = dbChapter[ChapterTable.pageCount],
lastModifiedAt = dbChapter[ChapterTable.lastModifiedAt],
version = dbChapter[ChapterTable.version],
chapterCount = chapterList.size,
meta = chapterMetas.getValue(dbChapter[ChapterTable.id].value),
)
}
}
@@ -189,7 +192,7 @@ object Chapter {
}
// new chapters after they have been added to the database for auto downloads
val insertedChapterIds = mutableListOf<Int>()
val insertedChapters = mutableListOf<ChapterDataClass>()
val chaptersToInsert = mutableListOf<ChapterDataClass>() // do not yet have an ID from the database
val chaptersToUpdate = mutableListOf<ChapterDataClass>()
@@ -233,7 +236,7 @@ object Chapter {
val deletedChapterNumbers = TreeSet<Float>()
val deletedReadChapterNumbers = TreeSet<Float>()
val deletedBookmarkedChapterNumbers = TreeSet<Float>()
val deletedDownloadedChapterNumberToChapter = mutableMapOf<Float, ChapterDataClass>()
val deletedDownloadedChapterNumberInfoMap = mutableMapOf<Float, MutableMap<String?, Int>>()
val deletedChapterNumberDateFetchMap = mutableMapOf<Float, Long>()
// clear any orphaned/duplicate chapters that are in the db but not in `chapterList`
@@ -244,7 +247,13 @@ object Chapter {
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
if (dbChapter.downloaded) {
val pageCountByScanlator =
deletedDownloadedChapterNumberInfoMap.getOrPut(
dbChapter.chapterNumber,
) { mutableMapOf() }
pageCountByScanlator[dbChapter.scanlator] = dbChapter.pageCount
}
deletedChapterNumbers.add(dbChapter.chapterNumber)
deletedChapterNumberDateFetchMap[dbChapter.chapterNumber] = dbChapter.fetchedAt
dbChapter.id
@@ -253,14 +262,16 @@ object Chapter {
}
}
transaction {
// we got some clean up due
if (chaptersIdsToDelete.isNotEmpty()) {
DownloadManager.dequeue(chaptersIdsToDelete)
// we got some clean up due
if (chaptersIdsToDelete.isNotEmpty()) {
DownloadManager.dequeue(chaptersIdsToDelete)
transaction {
PageTable.deleteWhere { chapter inList chaptersIdsToDelete }
ChapterTable.deleteWhere { id inList chaptersIdsToDelete }
}
}
transaction {
if (chaptersToInsert.isNotEmpty()) {
ChapterTable
.batchInsert(chaptersToInsert) { chapter ->
@@ -276,35 +287,26 @@ object Chapter {
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
// only preserve download status for chapters of the same scanlator, otherwise,
// the downloaded files won't be found anyway
val downloadedChapterInfo = deletedDownloadedChapterNumberInfoMap[chapter.chapterNumber]
val pageCount = downloadedChapterInfo?.get(chapter.scanlator)
if (pageCount != null) {
this[ChapterTable.isDownloaded] = true
this[ChapterTable.pageCount] = pageCount
}
// 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) }
}.forEach { insertedChapters.add(ChapterTable.toDataClass(it)) }
}
if (chaptersToUpdate.isNotEmpty()) {
@@ -312,32 +314,12 @@ object Chapter {
.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)
@@ -349,13 +331,6 @@ object Chapter {
}
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)
}
@@ -613,7 +588,7 @@ object Chapter {
.withDefault { emptyMap() }
}
fun getChapterMetaMap(chapter: Int): Map<String, String> =
fun getChapterMetaMap(chapter: EntityID<Int>): Map<String, String> =
transaction {
ChapterMetaTable
.selectAll()

View File

@@ -64,11 +64,6 @@ object ChapterDownloadHelper {
chapterId: Int,
): Pair<InputStream, Long> = provider(mangaId, chapterId).getAsArchiveStream()
fun getChapterArchiveSize(
mangaId: Int,
chapterId: Int,
): Long = provider(mangaId, chapterId).getArchiveSize()
private fun getChapterWithCbzFileName(chapterId: Int): Pair<ChapterDataClass, String> =
transaction {
val row =

View File

@@ -92,14 +92,13 @@ object Manga {
inLibrary = mangaEntry[MangaTable.inLibrary],
inLibraryAt = mangaEntry[MangaTable.inLibraryAt],
source = getSource(mangaEntry[MangaTable.sourceReference]),
meta = getMangaMetaMap(mangaId),
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],
)
}
}
@@ -134,7 +133,7 @@ object Manga {
""
}
if (remoteTitle.isNotEmpty() && remoteTitle != mangaEntry[MangaTable.title]) {
val canUpdateTitle = updateMangaDownloadDir(mangaEntry[MangaTable.title], source.toString(), remoteTitle)
val canUpdateTitle = updateMangaDownloadDir(mangaId, remoteTitle)
if (canUpdateTitle) {
it[MangaTable.title] = remoteTitle
@@ -212,12 +211,12 @@ object Manga {
.orderBy(ChapterTable.sourceOrder to SortOrder.DESC)
.firstOrNull { it[ChapterTable.isRead] }
mangaDaaClass.copy(
unreadCount = unreadCount,
downloadCount = downloadCount,
chapterCount = chapterCount,
lastChapterRead = lastChapterRead?.let { ChapterTable.toDataClass(it) },
)
mangaDaaClass.unreadCount = unreadCount
mangaDaaClass.downloadCount = downloadCount
mangaDaaClass.chapterCount = chapterCount
mangaDaaClass.lastChapterRead = lastChapterRead?.let { ChapterTable.toDataClass(it) }
mangaDaaClass
}
}
@@ -240,14 +239,13 @@ object Manga {
inLibrary = mangaEntry[MangaTable.inLibrary],
inLibraryAt = mangaEntry[MangaTable.inLibraryAt],
source = getSource(mangaEntry[MangaTable.sourceReference]),
meta = getMangaMetaMap(mangaId),
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<String, String> =

View File

@@ -148,11 +148,13 @@ object ProtoBackupExport : ProtoBackupBase() {
fun createBackup(flags: BackupFlags): InputStream {
// Create root object
val backupMangas = BackupMangaHandler.backup(flags)
val backup: Backup =
transaction {
val backupMangas = BackupMangaHandler.backup(flags)
Backup(
backupMangas,
BackupMangaHandler.backup(flags),
BackupCategoryHandler.backup(flags),
BackupSourceHandler.backup(backupMangas, flags),
BackupGlobalMetaHandler.backup(flags),

View File

@@ -21,9 +21,6 @@ import kotlinx.coroutines.sync.withLock
import okio.buffer
import okio.gzip
import okio.source
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import org.jetbrains.exposed.v1.jdbc.update
import suwayomi.tachidesk.graphql.types.toStatus
import suwayomi.tachidesk.manga.impl.backup.BackupFlags
import suwayomi.tachidesk.manga.impl.backup.proto.ProtoBackupValidator.ValidationResult
@@ -34,8 +31,6 @@ import suwayomi.tachidesk.manga.impl.backup.proto.handlers.BackupMangaHandler
import suwayomi.tachidesk.manga.impl.backup.proto.handlers.BackupSettingsHandler
import suwayomi.tachidesk.manga.impl.backup.proto.handlers.BackupSourceHandler
import suwayomi.tachidesk.manga.impl.backup.proto.models.Backup
import suwayomi.tachidesk.manga.model.table.ChapterTable
import suwayomi.tachidesk.manga.model.table.MangaTable
import java.io.InputStream
import java.util.Date
import java.util.Timer
@@ -114,7 +109,6 @@ object ProtoBackupImport : ProtoBackupBase() {
fun restore(
sourceStream: InputStream,
flags: BackupFlags,
isSync: Boolean = false,
): String {
val restoreId = System.currentTimeMillis().toString()
@@ -123,7 +117,7 @@ object ProtoBackupImport : ProtoBackupBase() {
updateRestoreState(restoreId, BackupRestoreState.Idle)
GlobalScope.launch {
restoreLegacy(sourceStream, restoreId, flags, isSync)
restoreLegacy(sourceStream, restoreId, flags)
}
return restoreId
@@ -133,12 +127,11 @@ object ProtoBackupImport : ProtoBackupBase() {
sourceStream: InputStream,
restoreId: String = "legacy",
flags: BackupFlags = BackupFlags.DEFAULT,
isSync: Boolean = false,
): ValidationResult =
backupMutex.withLock {
try {
logger.info { "restore($restoreId): restoring..." }
performRestore(restoreId, sourceStream, flags, isSync)
performRestore(restoreId, sourceStream, flags)
} catch (e: Exception) {
logger.error(e) { "restore($restoreId): failed due to" }
@@ -159,14 +152,12 @@ object ProtoBackupImport : ProtoBackupBase() {
id: String,
sourceStream: InputStream,
flags: BackupFlags,
isSync: Boolean,
): ValidationResult {
val backupString =
sourceStream
.source()
.run {
if (!isSync) gzip() else this
}.buffer()
.gzip()
.buffer()
.use { it.readByteArray() }
val backup = parser.decodeFromByteArray(Backup.serializer(), backupString)
@@ -244,17 +235,6 @@ object ProtoBackupImport : ProtoBackupBase() {
""".trimIndent()
}
if (isSync) {
transaction {
MangaTable.update({ MangaTable.isSyncing eq true }) {
it[isSyncing] = false
}
ChapterTable.update({ ChapterTable.isSyncing eq true }) {
it[isSyncing] = false
}
}
}
updateRestoreState(id, BackupRestoreState.Success)
return validationResult

View File

@@ -8,11 +8,7 @@ package suwayomi.tachidesk.manga.impl.backup.proto.handlers
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import org.jetbrains.exposed.v1.core.SortOrder
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.jdbc.insertAndGetId
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.manga.impl.Category
import suwayomi.tachidesk.manga.impl.Category.modifyCategoriesMetas
import suwayomi.tachidesk.manga.impl.backup.BackupFlags
@@ -42,9 +38,6 @@ object BackupCategoryHandler {
it.name,
it.order,
0, // not supported in Tachidesk
it.version,
it.uid,
it.lastModifiedAt,
).apply {
this.meta = categoryToMeta[it.id] ?: emptyMap()
}
@@ -52,56 +45,7 @@ object BackupCategoryHandler {
}
fun restore(backupCategories: List<BackupCategory>): Map<Int, Int> {
val dbCategories = Category.getCategoryList()
val dbCategoriesByName = dbCategories.associateBy { it.name }
val dbCategoriesByUid = dbCategories.associateBy { it.uid }
var nextOrder = dbCategories.maxOfOrNull { it.order }?.plus(1) ?: 0
val categoryIds =
transaction {
backupCategories
.map { backupCategory ->
var dbCategory =
if (backupCategory.uid != 0L) {
dbCategoriesByUid[backupCategory.uid]
} else {
null
}
if (dbCategory == null) {
dbCategory = dbCategoriesByName[backupCategory.name]
}
if (dbCategory != null) {
CategoryTable.update({ CategoryTable.id eq dbCategory.id }) {
it[name] = backupCategory.name
it[order] = backupCategory.order
it[version] = backupCategory.version
it[uid] = if (backupCategory.uid != 0L) backupCategory.uid else dbCategory.uid
it[lastModifiedAt] = backupCategory.lastModifiedAt
it[isSyncing] = true
}
return@map dbCategory.id
}
val currentOrder = nextOrder++
CategoryTable
.insertAndGetId {
it[name] = backupCategory.name
it[order] = currentOrder
it[version] = backupCategory.version
it[uid] = backupCategory.uid
it[lastModifiedAt] = backupCategory.lastModifiedAt
}.value
}
}
transaction {
CategoryTable.update({ CategoryTable.isSyncing eq true }) {
it[isSyncing] = false
}
}
val categoryIds = Category.createCategories(backupCategories.map { it.name })
val metaEntryByCategoryId =
categoryIds

View File

@@ -38,6 +38,7 @@ import suwayomi.tachidesk.manga.model.dataclass.TrackRecordDataClass
import suwayomi.tachidesk.manga.model.table.ChapterTable
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.database.dbTransaction
import java.util.Date
import kotlin.math.max
@@ -74,8 +75,6 @@ object BackupMangaHandler {
dateAdded = mangaRow[MangaTable.inLibraryAt].seconds.inWholeMilliseconds,
viewer = 0, // not supported in Tachidesk
updateStrategy = UpdateStrategy.valueOf(mangaRow[MangaTable.updateStrategy]),
lastModifiedAt = mangaRow[MangaTable.lastModifiedAt],
version = mangaRow[MangaTable.version],
)
val mangaId = mangaRow[MangaTable.id].value
@@ -91,31 +90,29 @@ object BackupMangaHandler {
.selectAll()
.where { ChapterTable.manga eq mangaId }
.orderBy(ChapterTable.sourceOrder to SortOrder.DESC)
.toList()
.map {
ChapterTable.toDataClass(it)
}
}
if (flags.includeChapters) {
val chapterToMeta =
Chapter.getChaptersMetaMaps(chapters.map { it[ChapterTable.id].value })
val chapterToMeta = Chapter.getChaptersMetaMaps(chapters.map { it.id })
backupManga.chapters =
chapters.map {
BackupChapter(
url = it[ChapterTable.url],
name = it[ChapterTable.name],
scanlator = it[ChapterTable.scanlator],
read = it[ChapterTable.isRead],
bookmark = it[ChapterTable.isBookmarked],
lastPageRead = it[ChapterTable.lastPageRead],
dateFetch = it[ChapterTable.fetchedAt].seconds.inWholeMilliseconds,
dateUpload = it[ChapterTable.date_upload],
chapterNumber = it[ChapterTable.chapter_number],
sourceOrder = chapters.size - it[ChapterTable.sourceOrder],
lastModifiedAt = it[ChapterTable.lastModifiedAt],
version = it[ChapterTable.version],
it.url,
it.name,
it.scanlator,
it.read,
it.bookmarked,
it.lastPageRead,
it.fetchedAt.seconds.inWholeMilliseconds,
it.uploadDate,
it.chapterNumber,
chapters.size - it.index,
).apply {
if (flags.includeClientData) {
this.meta = chapterToMeta[it[ChapterTable.id].value] ?: emptyMap()
this.meta = chapterToMeta[it.id] ?: emptyMap()
}
}
}
@@ -123,10 +120,10 @@ object BackupMangaHandler {
if (flags.includeHistory) {
backupManga.history =
chapters.mapNotNull {
if (it[ChapterTable.lastReadAt] > 0) {
if (it.lastReadAt > 0) {
BackupHistory(
url = it[ChapterTable.url],
lastRead = it[ChapterTable.lastReadAt].seconds.inWholeMilliseconds,
url = it.url,
lastRead = it.lastReadAt.seconds.inWholeMilliseconds,
)
} else {
null
@@ -235,9 +232,6 @@ object BackupMangaHandler {
it[inLibrary] = manga.favorite
it[inLibraryAt] = manga.dateAdded.milliseconds.inWholeSeconds
it[lastModifiedAt] = manga.lastModifiedAt
it[version] = manga.version
}.value
} else {
val dbMangaId = dbManga[MangaTable.id].value
@@ -257,9 +251,6 @@ object BackupMangaHandler {
it[inLibrary] = manga.favorite || dbManga[inLibrary]
it[inLibraryAt] = manga.dateAdded.milliseconds.inWholeSeconds
it[lastModifiedAt] = manga.lastModifiedAt
it[version] = manga.version
}
dbMangaId
@@ -277,7 +268,7 @@ object BackupMangaHandler {
restoreMangaChapterData(mangaId, restoreMode, chapters, history, flags)
}
// update categories
// merge categories
if (flags.includeCategories) {
restoreMangaCategoryData(mangaId, categoryIds)
}
@@ -348,9 +339,6 @@ object BackupMangaHandler {
this[ChapterTable.lastReadAt] =
historyByChapter[chapter.url]?.maxOrNull()?.milliseconds?.inWholeSeconds ?: 0
}
this[ChapterTable.lastModifiedAt] = chapter.lastModifiedAt
this[ChapterTable.version] = chapter.version
}.map { it[ChapterTable.id].value }
} else {
emptyList()
@@ -399,7 +387,6 @@ object BackupMangaHandler {
mangaId: Int,
categoryIds: List<Int>,
) {
CategoryManga.removeMangaFromAllCategories(mangaId)
CategoryManga.addMangaToCategories(mangaId, categoryIds)
}

View File

@@ -10,10 +10,6 @@ class BackupCategory(
// @ProtoNumber(3) val updateInterval: Int = 0, 1.x value not used in 0.x
// Bump by 100 to specify this is a 0.x value
@ProtoNumber(100) var flags: Int = 0,
// syncyomi
@ProtoNumber(601) var version: Long = 0,
@ProtoNumber(602) var uid: Long = 0,
@ProtoNumber(603) var lastModifiedAt: Long = 0,
// suwayomi
@ProtoNumber(9000) var meta: Map<String, String> = emptyMap(),
)

View File

@@ -19,9 +19,6 @@ data class BackupChapter(
// chapterNumber is called number is 1.x
@ProtoNumber(9) var chapterNumber: Float = 0F,
@ProtoNumber(10) var sourceOrder: Int = 0,
// syncyomi
@ProtoNumber(11) var lastModifiedAt: Long = 0,
@ProtoNumber(12) var version: Long = 0,
// suwayomi
@ProtoNumber(9000) var meta: Map<String, String> = emptyMap(),
)

View File

@@ -34,9 +34,6 @@ data class BackupManga(
@ProtoNumber(103) var viewer_flags: Int? = null,
@ProtoNumber(104) var history: List<BackupHistory> = emptyList(),
@ProtoNumber(105) var updateStrategy: UpdateStrategy = UpdateStrategy.ALWAYS_UPDATE,
// syncyomi
@ProtoNumber(106) var lastModifiedAt: Long = 0,
@ProtoNumber(109) var version: Long = 0,
// suwayomi
@ProtoNumber(9000) var meta: Map<String, String> = emptyMap(),
)

View File

@@ -31,88 +31,6 @@ import suwayomi.tachidesk.manga.model.table.PageTable
import suwayomi.tachidesk.manga.model.table.toDataClass
import kotlin.time.Duration.Companion.minutes
/**
* Updates chapter download status and page count in the database if they differ from the file system.
*/
fun updateChapterPersistence(
chapterId: Int,
isMarkedAsDownloaded: Boolean,
dbPageCount: Int,
downloadPageCount: Int,
lastPageRead: Int,
logger: KLogger,
): Boolean {
if (isMarkedAsDownloaded && dbPageCount == downloadPageCount) {
return false
}
return transaction {
var needsUpdate = false
if (!isMarkedAsDownloaded) {
logger.debug { "mark as downloaded" }
ChapterTable.update({ ChapterTable.id eq chapterId }) {
it[isDownloaded] = true
}
needsUpdate = true
}
if (dbPageCount != downloadPageCount) {
logger.debug { "use page count of downloaded chapter" }
ChapterTable.update({ ChapterTable.id eq chapterId }) {
it[pageCount] = downloadPageCount
it[ChapterTable.lastPageRead] = lastPageRead.coerceAtMost(downloadPageCount - 1).coerceAtLeast(0)
}
needsUpdate = true
}
needsUpdate
}
}
suspend fun refreshChapterPageList(
mangaId: Int,
chapterId: Int,
existingChapterEntry: ResultRow? = null,
): Int {
val mutex = mutexByChapterId.get(chapterId) { Mutex() }
return mutex.withLock {
val chapterEntry = existingChapterEntry ?: transaction { ChapterTable.selectAll().where { ChapterTable.id eq chapterId }.first() }
val mangaEntry = transaction { MangaTable.selectAll().where { MangaTable.id eq mangaId }.first() }
val source = getCatalogueSourceOrStub(mangaEntry[MangaTable.sourceReference])
val pageList =
source
.getPageList(
SChapter.create().apply {
url = chapterEntry[ChapterTable.url]
name = chapterEntry[ChapterTable.name]
scanlator = chapterEntry[ChapterTable.scanlator]
chapter_number = chapterEntry[ChapterTable.chapter_number]
date_upload = chapterEntry[ChapterTable.date_upload]
},
).mapIndexed { index, page -> Page(index, page.url, page.imageUrl, page.uri) }
transaction {
ChapterTable.update({ ChapterTable.id eq chapterId }) {
it[isDownloaded] = false
}
PageTable.deleteWhere { PageTable.chapter eq chapterId }
PageTable.batchInsert(pageList) { page ->
this[PageTable.index] = page.index
this[PageTable.url] = page.url
this[PageTable.imageUrl] = page.imageUrl
this[PageTable.chapter] = chapterId
}
ChapterTable.update({ ChapterTable.id eq chapterId }) {
it[pageCount] = pageList.size
it[lastPageRead] = chapterEntry[ChapterTable.lastPageRead].coerceAtMost(pageList.size - 1).coerceAtLeast(0)
}
}
pageList.size
}
}
suspend fun getChapterDownloadReady(
chapterId: Int? = null,
chapterIndex: Int? = null,
@@ -150,31 +68,56 @@ private class ChapterForDownload(
suspend fun asDownloadReady(): ChapterDataClass {
val log = KotlinLogging.logger("${logger.name}::asDownloadReady")
val downloadPageCount = runCatching { ChapterDownloadHelper.getImageCount(mangaId, chapterId) }.getOrDefault(0)
val downloadPageCount =
try {
ChapterDownloadHelper.getImageCount(mangaId, chapterId)
} catch (_: Exception) {
0
}
val isMarkedAsDownloaded = chapterEntry[ChapterTable.isDownloaded]
val dbPageCount = chapterEntry[ChapterTable.pageCount]
val doesDownloadExist = downloadPageCount != 0
val doPageCountsMatch = dbPageCount == downloadPageCount
log.debug { "isMarkedAsDownloaded= $isMarkedAsDownloaded, dbPageCount= $dbPageCount, downloadPageCount= $downloadPageCount" }
return if (!doesDownloadExist) {
log.debug { "reset download status and fetch page list" }
refreshChapterPageList(mangaId, chapterId, chapterEntry)
chapterEntry = freshChapterEntry(optChapterId = chapterId)
ChapterTable.toDataClass(chapterEntry)
updateDownloadStatusAndPageList(false)
} else {
if (updateChapterPersistence(
chapterId,
isMarkedAsDownloaded,
dbPageCount,
downloadPageCount,
chapterEntry[ChapterTable.lastPageRead],
log,
)
) {
chapterEntry = freshChapterEntry(optChapterId = chapterId)
transaction {
var needsUpdate = false
if (!isMarkedAsDownloaded) {
log.debug { "mark as downloaded" }
ChapterTable.update({ ChapterTable.id eq chapterId }) {
it[isDownloaded] = true
}
needsUpdate = true
}
if (!doPageCountsMatch) {
log.debug { "use page count of downloaded chapter" }
ChapterTable.update({ ChapterTable.id eq chapterId }) {
it[pageCount] = downloadPageCount
it[lastPageRead] = chapterEntry[ChapterTable.lastPageRead].coerceAtMost(downloadPageCount - 1).coerceAtLeast(0)
}
needsUpdate = true
}
// Return updated chapter data
val updatedRow =
ChapterTable
.selectAll()
.where { ChapterTable.id eq chapterId }
.first()
if (needsUpdate) {
chapterEntry = updatedRow
}
ChapterTable.toDataClass(updatedRow)
}
ChapterTable.toDataClass(chapterEntry)
}
}
@@ -207,4 +150,59 @@ private class ChapterForDownload(
}
}.first()
}
private suspend fun updateDownloadStatusAndPageList(downloaded: Boolean): ChapterDataClass {
val mutex = mutexByChapterId.get(chapterId) { Mutex() }
return mutex.withLock {
val pageList = fetchPageList()
transaction {
// Update download status
ChapterTable.update({ ChapterTable.id eq chapterId }) {
it[isDownloaded] = downloaded
}
// Clear existing pages and insert new ones
PageTable.deleteWhere { PageTable.chapter eq chapterId }
PageTable.batchInsert(pageList) { page ->
this[PageTable.index] = page.index
this[PageTable.url] = page.url
this[PageTable.imageUrl] = page.imageUrl
this[PageTable.chapter] = chapterId
}
// Update page count
ChapterTable.update({ ChapterTable.id eq chapterId }) {
it[pageCount] = pageList.size
it[lastPageRead] = chapterEntry[ChapterTable.lastPageRead].coerceAtMost(pageList.size - 1).coerceAtLeast(0)
}
// Get updated chapter data
val updatedRow =
ChapterTable
.selectAll()
.where { ChapterTable.id eq chapterId }
.first()
chapterEntry = updatedRow
ChapterTable.toDataClass(updatedRow)
}
}
}
private suspend fun fetchPageList(): List<Page> {
val mangaEntry = transaction { MangaTable.selectAll().where { MangaTable.id eq mangaId }.first() }
val source = getCatalogueSourceOrStub(mangaEntry[MangaTable.sourceReference])
return source
.getPageList(
SChapter.create().apply {
url = chapterEntry[ChapterTable.url]
name = chapterEntry[ChapterTable.name]
scanlator = chapterEntry[ChapterTable.scanlator]
chapter_number = chapterEntry[ChapterTable.chapter_number]
date_upload = chapterEntry[ChapterTable.date_upload]
},
).mapIndexed { index, page -> Page(index, page.url, page.imageUrl, page.uri) }
}
}

View File

@@ -358,7 +358,6 @@ object Extension {
} else {
ExtensionTable.update({ ExtensionTable.pkgName eq pkgName }) {
it[isInstalled] = false
it[hasUpdate] = false
}
}

View File

@@ -28,7 +28,6 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import suwayomi.tachidesk.global.impl.sync.SyncManager
import suwayomi.tachidesk.manga.impl.Category
import suwayomi.tachidesk.manga.impl.CategoryManga
import suwayomi.tachidesk.manga.impl.Chapter
@@ -338,98 +337,90 @@ class Updater : IUpdater {
clear: Boolean?,
forceAll: Boolean,
) {
scope.launch {
SyncManager.ensureSync()
saveLastUpdateTimestamp()
saveLastUpdateTimestamp()
if (clear == true) {
reset()
}
if (clear == true) {
reset()
val includeInUpdateStatusToCategoryMap = categories.groupBy { it.includeInUpdate }
val excludedCategories = includeInUpdateStatusToCategoryMap[IncludeOrExclude.EXCLUDE].orEmpty()
val includedCategories = includeInUpdateStatusToCategoryMap[IncludeOrExclude.INCLUDE].orEmpty()
val unsetCategories = includeInUpdateStatusToCategoryMap[IncludeOrExclude.UNSET].orEmpty()
val categoriesToUpdate =
if (forceAll) {
categories
} else {
includedCategories.ifEmpty { unsetCategories }
}
val skippedCategories = categories.subtract(categoriesToUpdate.toSet()).toList()
val updateStatusCategories =
mapOf(
Pair(CategoryUpdateStatus.UPDATING, categoriesToUpdate),
Pair(CategoryUpdateStatus.SKIPPED, skippedCategories),
)
val includeInUpdateStatusToCategoryMap = categories.groupBy { it.includeInUpdate }
val excludedCategories = includeInUpdateStatusToCategoryMap[IncludeOrExclude.EXCLUDE].orEmpty()
val includedCategories = includeInUpdateStatusToCategoryMap[IncludeOrExclude.INCLUDE].orEmpty()
val unsetCategories = includeInUpdateStatusToCategoryMap[IncludeOrExclude.UNSET].orEmpty()
val categoriesToUpdate =
if (forceAll) {
categories
} else {
includedCategories.ifEmpty { unsetCategories }
}
val skippedCategories = categories.subtract(categoriesToUpdate.toSet()).toList()
val updateStatusCategories =
mapOf(
Pair(CategoryUpdateStatus.UPDATING, categoriesToUpdate),
Pair(CategoryUpdateStatus.SKIPPED, skippedCategories),
)
logger.debug { "Updating categories: '${categoriesToUpdate.joinToString("', '") { it.name }}'" }
logger.debug { "Updating categories: '${categoriesToUpdate.joinToString("', '") { it.name }}'" }
val categoriesToUpdateMangas =
categoriesToUpdate
.flatMap { CategoryManga.getCategoryMangaList(it.id) }
.distinctBy { it.id }
val mangasToCategoriesMap = CategoryManga.getMangasCategories(categoriesToUpdateMangas.map { it.id })
val mangasToUpdate =
categoriesToUpdateMangas
.asSequence()
.filter { it.updateStrategy == UpdateStrategy.ALWAYS_UPDATE }
.filter {
if (serverConfig.excludeUnreadChapters.value) {
(it.unreadCount ?: 0L) == 0L
} else {
true
}
}.filter {
if (it.initialized && serverConfig.excludeNotStarted.value) {
it.lastReadAt != null
} else {
true
}
}.filter {
if (serverConfig.excludeCompleted.value) {
it.status != MangaStatus.COMPLETED.name
} else {
true
}
}.filter { forceAll || !excludedCategories.any { category -> mangasToCategoriesMap[it.id]?.contains(category) == true } }
.toList()
val skippedMangas = categoriesToUpdateMangas.subtract(mangasToUpdate.toSet()).toList()
val categoriesToUpdateMangas =
categoriesToUpdate
.flatMap { CategoryManga.getCategoryMangaList(it.id) }
.distinctBy { it.id }
val mangasToCategoriesMap = CategoryManga.getMangasCategories(categoriesToUpdateMangas.map { it.id })
val mangasToUpdate =
categoriesToUpdateMangas
.asSequence()
.filter { it.updateStrategy == UpdateStrategy.ALWAYS_UPDATE }
.filter {
if (serverConfig.excludeUnreadChapters.value) {
(it.unreadCount ?: 0L) == 0L
} else {
true
}
}.filter {
if (it.initialized && serverConfig.excludeNotStarted.value) {
it.lastReadAt != null
} else {
true
}
}.filter {
if (serverConfig.excludeCompleted.value) {
it.status != MangaStatus.COMPLETED.name
} else {
true
}
}.filter {
forceAll ||
!excludedCategories.any { category ->
mangasToCategoriesMap[it.id]?.contains(category) == true
}
}.toList()
val skippedMangas = categoriesToUpdateMangas.subtract(mangasToUpdate.toSet()).toList()
this@Updater.updateStatusCategories = updateStatusCategories
this@Updater.updateStatusSkippedMangas = skippedMangas
if (mangasToUpdate.isEmpty()) {
// In case no manga gets updated and no update job was running before, the client would never receive an info
// about its update request
scope.launch {
updateStatus(immediate = true)
}
return@launch
}
this.updateStatusCategories = updateStatusCategories
this.updateStatusSkippedMangas = skippedMangas
if (mangasToUpdate.isEmpty()) {
// In case no manga gets updated and no update job was running before, the client would never receive an info
// about its update request
scope.launch {
updateStatus(
categoryUpdates =
updateStatusCategories[CategoryUpdateStatus.UPDATING]
?.map {
CategoryUpdateJob(it, CategoryUpdateStatus.UPDATING)
}.orEmpty(),
mangaUpdates = mangasToUpdate.map { UpdateJob(it) },
isRunning = true,
)
updateStatus(immediate = true)
}
return
}
addMangasToQueue(
mangasToUpdate
.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER, MangaDataClass::title)),
scope.launch {
updateStatus(
categoryUpdates =
updateStatusCategories[CategoryUpdateStatus.UPDATING]
?.map {
CategoryUpdateJob(it, CategoryUpdateStatus.UPDATING)
}.orEmpty(),
mangaUpdates = mangasToUpdate.map { UpdateJob(it) },
isRunning = true,
)
}
addMangasToQueue(
mangasToUpdate
.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER, MangaDataClass::title)),
)
}
override fun addMangasToQueue(mangas: List<MangaDataClass>) {

View File

@@ -24,22 +24,14 @@ private val applicationDirs: ApplicationDirs by injectLazy()
private val logger = KotlinLogging.logger { }
private fun getMangaDir(
title: String,
sourceName: String,
): String {
val sourceDir = SafePath.buildValidFilename(sourceName)
val mangaDir = SafePath.buildValidFilename(title)
return "$sourceDir/$mangaDir"
}
private fun getMangaDir(mangaId: Int): String =
transaction {
val mangaEntry = MangaTable.selectAll().where { MangaTable.id eq mangaId }.first()
val source = GetCatalogueSource.getCatalogueSourceOrStub(mangaEntry[MangaTable.sourceReference])
getMangaDir(mangaEntry[MangaTable.title], source.toString())
val sourceDir = SafePath.buildValidFilename(source.toString())
val mangaDir = SafePath.buildValidFilename(mangaEntry[MangaTable.title])
"$sourceDir/$mangaDir"
}
private fun getChapterDir(
@@ -70,18 +62,8 @@ private fun getChapterDir(
fun getThumbnailDownloadPath(mangaId: Int): String = applicationDirs.thumbnailDownloadsRoot + "/$mangaId"
fun getMangaDownloadDir(
title: String,
sourceName: String,
): String = applicationDirs.mangaDownloadsRoot + "/" + getMangaDir(title, sourceName)
fun getMangaDownloadDir(mangaId: Int): String = applicationDirs.mangaDownloadsRoot + "/" + getMangaDir(mangaId)
fun getMangaCacheDir(
title: String,
sourceName: String,
): String = applicationDirs.tempMangaCacheRoot + "/" + getMangaDir(title, sourceName)
fun getChapterDownloadPath(
mangaId: Int,
chapterId: Int,
@@ -97,21 +79,38 @@ fun getChapterCachePath(
chapterId: Int,
): String = applicationDirs.tempMangaCacheRoot + "/" + getChapterDir(mangaId, chapterId)
private fun updateDownloadDir(
currentDir: String,
newDir: String,
/** return value says if rename/move was successful */
fun updateMangaDownloadDir(
mangaId: Int,
newTitle: String,
): Boolean {
val currentDirFile = File(currentDir)
// Get current manga directory (uses its own transaction)
val currentMangaDir = getMangaDir(mangaId)
// Build new directory path
val newMangaDir =
transaction {
val mangaEntry = MangaTable.selectAll().where { MangaTable.id eq mangaId }.first()
val source = GetCatalogueSource.getCatalogueSourceOrStub(mangaEntry[MangaTable.sourceReference])
val sourceDir = SafePath.buildValidFilename(source.toString())
val newMangaDirName = SafePath.buildValidFilename(newTitle)
"$sourceDir/$newMangaDirName"
}
val oldDir = "${applicationDirs.downloadsRoot}/$currentMangaDir"
val newDir = "${applicationDirs.downloadsRoot}/$newMangaDir"
val oldDirFile = File(oldDir)
val newDirFile = File(newDir)
if (!currentDirFile.exists()) {
if (!oldDirFile.exists()) {
return true
}
return try {
Files.move(currentDirFile.toPath(), newDirFile.toPath())
Files.move(oldDirFile.toPath(), newDirFile.toPath())
if (currentDirFile.exists()) {
if (oldDirFile.exists()) {
return false
}
@@ -119,31 +118,9 @@ private fun updateDownloadDir(
return false
}
return true
true
} catch (e: Exception) {
logger.error(e) { "updateDownloadDir: failed to rename download folder from \"$currentDir\" to \"$newDir\"" }
logger.error(e) { "updateMangaDownloadDir: failed to rename manga download folder from \"$oldDir\" to \"$newDir\"" }
false
}
}
/** return value says if rename/move was successful */
fun updateMangaDownloadDir(
title: String,
sourceName: String,
newTitle: String,
): Boolean {
val currentDownloadDir = getMangaDownloadDir(title, sourceName)
val newDownloadDir = getMangaDownloadDir(newTitle, sourceName)
val renamed = updateDownloadDir(currentDownloadDir, newDownloadDir)
val tryToKeepCachedFilesUsable = renamed
if (tryToKeepCachedFilesUsable) {
val currentCacheDir = getMangaCacheDir(title, sourceName)
val newCacheDir = getMangaCacheDir(newTitle, sourceName)
updateDownloadDir(currentCacheDir, newCacheDir)
}
return renamed
}

View File

@@ -1,7 +1,6 @@
package suwayomi.tachidesk.manga.model.dataclass
import com.fasterxml.jackson.annotation.JsonValue
import suwayomi.tachidesk.manga.impl.Category
/*
* Copyright (C) Contributors to the Suwayomi project
@@ -19,7 +18,7 @@ enum class IncludeOrExclude(
;
companion object {
fun fromValue(value: Int) = entries.find { it.value == value } ?: UNSET
fun fromValue(value: Int) = IncludeOrExclude.values().find { it.value == value } ?: UNSET
}
}
@@ -28,19 +27,8 @@ data class CategoryDataClass(
val order: Int,
val name: String,
val default: Boolean,
val size: Int,
val includeInUpdate: IncludeOrExclude,
val includeInDownload: IncludeOrExclude,
val version: Long,
val uid: Long,
val lastModifiedAt: Long,
) {
@Deprecated("Remove with V1 Api")
val size: Int by lazy {
Category.getCategorySize(id)
}
@Deprecated("Remove with V1 Api")
val meta: Map<String, String> by lazy {
Category.getCategoryMetaMap(id)
}
}
val meta: Map<String, String> = emptyMap(),
)

View File

@@ -1,11 +1,6 @@
package suwayomi.tachidesk.manga.model.dataclass
import eu.kanade.tachiyomi.source.model.SChapter
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import suwayomi.tachidesk.manga.impl.Chapter.getChapterMetaMap
import suwayomi.tachidesk.manga.model.table.ChapterTable
/*
* Copyright (C) Contributors to the Suwayomi project
@@ -41,8 +36,10 @@ data class ChapterDataClass(
val downloaded: Boolean,
/** used to construct pages in the front-end */
val pageCount: Int = -1,
val lastModifiedAt: Long = 0,
val version: Long = 0,
/** total chapter count, used to calculate if there's a next and prev chapter */
val chapterCount: Int? = null,
/** used to store client specific values */
val meta: Map<String, String> = emptyMap(),
) {
companion object {
fun fromSChapter(
@@ -71,20 +68,4 @@ data class ChapterDataClass(
downloaded = false,
)
}
@Deprecated("Remove with V1 Api")
val chapterCount: Int by lazy {
transaction {
ChapterTable
.selectAll()
.where { ChapterTable.manga eq mangaId }
.count()
.toInt()
}
}
@Deprecated("Remove with V1 Api")
val meta: Map<String, String> by lazy {
getChapterMetaMap(id)
}
}

View File

@@ -8,7 +8,6 @@ package suwayomi.tachidesk.manga.model.dataclass
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import eu.kanade.tachiyomi.source.model.UpdateStrategy
import suwayomi.tachidesk.manga.impl.Manga.getMangaMetaMap
import suwayomi.tachidesk.manga.impl.util.lang.trimAll
import suwayomi.tachidesk.manga.model.table.MangaStatus
import java.time.Instant
@@ -29,28 +28,23 @@ data class MangaDataClass(
val inLibrary: Boolean = false,
val inLibraryAt: Long = 0,
val source: SourceDataClass? = null,
/** meta data for clients */
val meta: Map<String, String> = emptyMap(),
val realUrl: String? = null,
val lastFetchedAt: Long? = 0,
val chaptersLastFetchedAt: Long? = 0,
val updateStrategy: UpdateStrategy = UpdateStrategy.ALWAYS_UPDATE,
var lastFetchedAt: Long? = 0,
var chaptersLastFetchedAt: Long? = 0,
var updateStrategy: UpdateStrategy = UpdateStrategy.ALWAYS_UPDATE,
val freshData: Boolean = false,
val unreadCount: Long? = null,
val downloadCount: Long? = null,
val chapterCount: Long? = null,
val lastReadAt: Long? = null,
val lastChapterRead: ChapterDataClass? = null,
var unreadCount: Long? = null,
var downloadCount: Long? = null,
var chapterCount: Long? = null,
var lastReadAt: Long? = null,
var lastChapterRead: ChapterDataClass? = null,
val age: Long? = if (lastFetchedAt == null) 0 else Instant.now().epochSecond.minus(lastFetchedAt),
val chaptersAge: Long? = if (chaptersLastFetchedAt == null) null else Instant.now().epochSecond.minus(chaptersLastFetchedAt),
val trackers: List<MangaTrackerDataClass>? = null,
val lastModifiedAt: Long = 0,
val version: Long = 0,
) {
override fun toString(): String = "\"$title\" (id= $id) (sourceId= $sourceId)"
@Deprecated("Remove with V1 Api")
val meta: Map<String, String> by lazy {
getMangaMetaMap(id)
}
}
data class PagedMangaListDataClass(

View File

@@ -7,21 +7,12 @@ 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 java.util.Objects
import kotlin.math.min
open class PaginatedList<T>(
val page: List<T>,
val hasNextPage: Boolean,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is PaginatedList<T>) return false
return page == other.page && hasNextPage == other.hasNextPage
}
override fun hashCode(): Int = Objects.hash(page, hasNextPage)
}
)
const val PAGINATION_FACTOR = 50

View File

@@ -15,7 +15,7 @@ import suwayomi.tachidesk.manga.model.table.CategoryMetaTable.ref
* Metadata storage for clients, about Category with id == [ref].
*/
object CategoryMetaTable : IntIdTable() {
val key = varchar("meta_key", 256)
val key = varchar("key", 256)
val value = varchar("value", 4096)
val ref = reference("category_ref", CategoryTable, ReferenceOption.CASCADE)
}

View File

@@ -9,6 +9,7 @@ package suwayomi.tachidesk.manga.model.table
import org.jetbrains.exposed.v1.core.ResultRow
import org.jetbrains.exposed.v1.core.dao.id.IntIdTable
import suwayomi.tachidesk.manga.impl.Category
import suwayomi.tachidesk.manga.model.dataclass.CategoryDataClass
import suwayomi.tachidesk.manga.model.dataclass.IncludeOrExclude
@@ -18,22 +19,16 @@ object CategoryTable : IntIdTable() {
val isDefault = bool("is_default").default(false)
val includeInUpdate = integer("include_in_update").default(IncludeOrExclude.UNSET.value)
val includeInDownload = integer("include_in_download").default(IncludeOrExclude.UNSET.value)
val version = long("version").default(0)
val uid = long("uid").default(0)
val lastModifiedAt = long("last_modified_at").default(0)
val isSyncing = bool("is_syncing").default(false)
}
fun CategoryTable.toDataClass(categoryEntry: ResultRow) =
CategoryDataClass(
id = categoryEntry[id].value,
order = categoryEntry[order],
name = categoryEntry[name],
default = categoryEntry[isDefault],
includeInUpdate = IncludeOrExclude.fromValue(categoryEntry[includeInUpdate]),
includeInDownload = IncludeOrExclude.fromValue(categoryEntry[includeInDownload]),
version = categoryEntry[version],
uid = categoryEntry[uid],
lastModifiedAt = categoryEntry[lastModifiedAt],
categoryEntry[id].value,
categoryEntry[order],
categoryEntry[name],
categoryEntry[isDefault],
Category.getCategorySize(categoryEntry[id].value),
IncludeOrExclude.fromValue(categoryEntry[includeInUpdate]),
IncludeOrExclude.fromValue(categoryEntry[includeInDownload]),
Category.getCategoryMetaMap(categoryEntry[id].value),
)

View File

@@ -26,7 +26,7 @@ import suwayomi.tachidesk.manga.model.table.ChapterMetaTable.ref
* }
*/
object ChapterMetaTable : IntIdTable() {
val key = varchar("meta_key", 256)
val key = varchar("key", 256)
val value = varchar("value", 4096)
val ref = reference("chapter_ref", ChapterTable, ReferenceOption.CASCADE)
}

View File

@@ -10,6 +10,10 @@ package suwayomi.tachidesk.manga.model.table
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.core.eq
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import suwayomi.tachidesk.manga.impl.Chapter.getChapterMetaMap
import suwayomi.tachidesk.manga.model.dataclass.ChapterDataClass
import suwayomi.tachidesk.manga.model.table.columns.truncatingVarchar
@@ -38,30 +42,45 @@ object ChapterTable : IntIdTable() {
val manga = reference("manga", MangaTable, ReferenceOption.CASCADE)
val koreaderHash = varchar("koreader_hash", 32).nullable()
val lastModifiedAt = long("last_modified_at").default(0)
val version = long("version").default(0)
val isSyncing = bool("is_syncing").default(false)
}
fun ChapterTable.toDataClass(chapterEntry: ResultRow) =
ChapterDataClass(
id = chapterEntry[id].value,
url = chapterEntry[url],
name = chapterEntry[name],
uploadDate = chapterEntry[date_upload],
chapterNumber = chapterEntry[chapter_number],
scanlator = chapterEntry[scanlator],
mangaId = chapterEntry[manga].value,
read = chapterEntry[isRead],
bookmarked = chapterEntry[isBookmarked],
lastPageRead = chapterEntry[lastPageRead],
lastReadAt = chapterEntry[lastReadAt],
index = chapterEntry[sourceOrder],
fetchedAt = chapterEntry[fetchedAt],
realUrl = chapterEntry[realUrl],
downloaded = chapterEntry[isDownloaded],
pageCount = chapterEntry[pageCount],
lastModifiedAt = chapterEntry[lastModifiedAt],
version = chapterEntry[version],
)
fun ChapterTable.toDataClass(
chapterEntry: ResultRow,
includeChapterCount: Boolean = true,
includeChapterMeta: Boolean = true,
) = ChapterDataClass(
id = chapterEntry[id].value,
url = chapterEntry[url],
name = chapterEntry[name],
uploadDate = chapterEntry[date_upload],
chapterNumber = chapterEntry[chapter_number],
scanlator = chapterEntry[scanlator],
mangaId = chapterEntry[manga].value,
read = chapterEntry[isRead],
bookmarked = chapterEntry[isBookmarked],
lastPageRead = chapterEntry[lastPageRead],
lastReadAt = chapterEntry[lastReadAt],
index = chapterEntry[sourceOrder],
fetchedAt = chapterEntry[fetchedAt],
realUrl = chapterEntry[realUrl],
downloaded = chapterEntry[isDownloaded],
pageCount = chapterEntry[pageCount],
chapterCount =
if (includeChapterCount) {
transaction {
ChapterTable
.selectAll()
.where { manga eq chapterEntry[manga].value }
.count()
.toInt()
}
} else {
null
},
meta =
if (includeChapterMeta) {
getChapterMetaMap(chapterEntry[id])
} else {
emptyMap()
},
)

View File

@@ -26,7 +26,7 @@ import suwayomi.tachidesk.manga.model.table.MangaMetaTable.ref
* }
*/
object MangaMetaTable : IntIdTable() {
val key = varchar("meta_key", 256)
val key = varchar("key", 256)
val value = varchar("value", 4096)
val ref = reference("manga_ref", MangaTable, ReferenceOption.CASCADE)
}

View File

@@ -11,9 +11,11 @@ import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.model.UpdateStrategy
import org.jetbrains.exposed.v1.core.ResultRow
import org.jetbrains.exposed.v1.core.dao.id.IntIdTable
import suwayomi.tachidesk.manga.impl.Manga.getMangaMetaMap
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.MangaStatus.Companion
import suwayomi.tachidesk.manga.model.table.columns.truncatingVarchar
import suwayomi.tachidesk.manga.model.table.columns.unlimitedVarchar
@@ -44,35 +46,37 @@ object MangaTable : IntIdTable() {
val chaptersLastFetchedAt = long("chapters_last_fetched_at").default(0)
val updateStrategy = varchar("update_strategy", 256).default(UpdateStrategy.ALWAYS_UPDATE.name)
val lastModifiedAt = long("last_modified_at").default(0)
val version = long("version").default(0)
val isSyncing = bool("is_syncing").default(false)
}
fun MangaTable.toDataClass(mangaEntry: ResultRow) =
MangaDataClass(
id = mangaEntry[this.id].value,
sourceId = mangaEntry[sourceReference].toString(),
url = mangaEntry[url],
title = mangaEntry[title],
thumbnailUrl = proxyThumbnailUrl(mangaEntry[this.id].value),
thumbnailUrlLastFetched = mangaEntry[thumbnailUrlLastFetched],
initialized = mangaEntry[initialized],
artist = mangaEntry[artist],
author = mangaEntry[author],
description = mangaEntry[description],
genre = mangaEntry[genre].toGenreList(),
status = MangaStatus.valueOf(mangaEntry[status]).name,
inLibrary = mangaEntry[inLibrary],
inLibraryAt = mangaEntry[inLibraryAt],
realUrl = mangaEntry[realUrl],
lastFetchedAt = mangaEntry[lastFetchedAt],
chaptersLastFetchedAt = mangaEntry[chaptersLastFetchedAt],
updateStrategy = UpdateStrategy.valueOf(mangaEntry[updateStrategy]),
lastModifiedAt = mangaEntry[lastModifiedAt],
version = mangaEntry[version],
)
fun MangaTable.toDataClass(
mangaEntry: ResultRow,
includeMangaMeta: Boolean = true,
) = MangaDataClass(
id = mangaEntry[this.id].value,
sourceId = mangaEntry[sourceReference].toString(),
url = mangaEntry[url],
title = mangaEntry[title],
thumbnailUrl = proxyThumbnailUrl(mangaEntry[this.id].value),
thumbnailUrlLastFetched = mangaEntry[thumbnailUrlLastFetched],
initialized = mangaEntry[initialized],
artist = mangaEntry[artist],
author = mangaEntry[author],
description = mangaEntry[description],
genre = mangaEntry[genre].toGenreList(),
status = Companion.valueOf(mangaEntry[status]).name,
inLibrary = mangaEntry[inLibrary],
inLibraryAt = mangaEntry[inLibraryAt],
meta =
if (includeMangaMeta) {
getMangaMetaMap(mangaEntry[id].value)
} else {
emptyMap()
},
realUrl = mangaEntry[realUrl],
lastFetchedAt = mangaEntry[lastFetchedAt],
chaptersLastFetchedAt = mangaEntry[chaptersLastFetchedAt],
updateStrategy = UpdateStrategy.valueOf(mangaEntry[updateStrategy]),
)
enum class MangaStatus(
val value: Int,

View File

@@ -14,7 +14,7 @@ import suwayomi.tachidesk.manga.model.table.SourceMetaTable.ref
* Metadata storage for clients, about Source with id == [ref].
*/
object SourceMetaTable : IntIdTable() {
val key = varchar("meta_key", 256)
val key = varchar("key", 256)
val value = varchar("value", 4096)
val ref = long("source_ref")
}

View File

@@ -13,22 +13,18 @@ object OpdsAPI {
// OPDS Search Description Feed
get("search", OpdsV1Controller.searchFeed)
// --- Main Navigation Feeds ---
// Explore Navigation Feed
get("explore", OpdsV1Controller.exploreSourcesFeed)
// Reading History Acquisition Feed
get("history", OpdsV1Controller.historyFeed)
// Library Updates Acquisition Feed
get("library-updates", OpdsV1Controller.libraryUpdatesFeed)
// --- Remote Catalog Exploration ---
// List of available online sources
get("explore", OpdsV1Controller.exploreSourcesFeed)
// Browse series from a specific online source
path("explore/source/{sourceId}") {
get(OpdsV1Controller.exploreSourceFeed)
}
// --- Library Navigation Feeds ---
// --- Library-Specific Feeds ---
path("library") {
// All Series in Library / Search Results Feed (Acquisition)
get("series", OpdsV1Controller.seriesFeed)
@@ -36,6 +32,11 @@ object OpdsAPI {
// Library Sources Navigation Feed
get("sources", OpdsV1Controller.librarySourcesFeed)
// Library Source-Specific Series Acquisition Feed
path("source/{sourceId}") {
get(OpdsV1Controller.librarySourceFeed)
}
// Library Categories Navigation Feed
get("categories", OpdsV1Controller.categoriesFeed)
@@ -49,10 +50,26 @@ object OpdsAPI {
get("languages", OpdsV1Controller.languagesFeed)
}
// --- Library Series Filters ---
// Source-Specific Series Acquisition Feed (Library)
// --- Explore-Specific Feeds ---
// All Sources Navigation Feed (Explore)
get("sources", OpdsV1Controller.exploreSourcesFeed)
// Source-Specific Series Acquisition Feed (Explore)
path("source/{sourceId}") {
get(OpdsV1Controller.librarySourceFeed)
get(OpdsV1Controller.exploreSourceFeed)
}
// --- Item-Specific Feeds (Apply to both Library and Explore contexts) ---
// Series Chapters Acquisition Feed
path("series/{seriesId}/chapters") {
get(OpdsV1Controller.seriesChaptersFeed)
}
// Chapter Metadata Acquisition Feed
path("series/{seriesId}/chapter/{chapterIndex}/metadata") {
get(OpdsV1Controller.chapterMetadataFeed)
}
// Category-Specific Series Acquisition Feed (Library)
@@ -74,16 +91,6 @@ object OpdsAPI {
path("language/{langCode}") {
get(OpdsV1Controller.languageFeed)
}
// --- Item Specific Feeds ---
// Series Chapters Acquisition Feed
path("series/{seriesId}/chapters") {
get(OpdsV1Controller.seriesChaptersFeed)
}
// Chapter Metadata Acquisition Feed
path("series/{seriesId}/chapter/{chapterIndex}/metadata") {
get(OpdsV1Controller.chapterMetadataFeed)
}
}
}
}

View File

@@ -32,21 +32,21 @@ object OpdsV1Controller {
*/
private fun getLibraryFeed(
ctx: Context,
locale: Locale,
pageNum: Int?,
criteria: OpdsMangaFilter,
isSearch: Boolean,
pageNum: Int,
) {
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, ctx.queryParam("lang"))
ctx.future {
future {
OpdsFeedBuilder.getLibraryFeed(
BASE_URL,
locale,
criteria,
isSearch,
pageNum,
criteria.sort,
criteria.filter,
criteria = criteria,
baseUrl = BASE_URL,
pageNum = pageNum ?: 1,
sort = criteria.sort,
filter = criteria.filter,
locale = locale,
isSearch = isSearch,
)
}.thenApply { xml ->
ctx.contentType(OPDS_MIME).result(xml)
@@ -94,7 +94,7 @@ object OpdsV1Controller {
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
ctx.future {
future {
OpdsFeedBuilder.getHistoryFeed(BASE_URL, locale, pageNumber ?: 1)
OpdsFeedBuilder.getHistoryFeed(BASE_URL, pageNumber ?: 1, locale)
}.thenApply { xml ->
ctx.contentType(OPDS_MIME).result(xml)
}
@@ -139,7 +139,6 @@ object OpdsV1Controller {
/**
* Serves an acquisition feed for all series in the library or search results.
* This endpoint handles both general library browsing and specific search queries.
* This is the ONLY feed that extracts all the cross-filters from the context.
*/
val seriesFeed =
handler(
@@ -158,14 +157,29 @@ object OpdsV1Controller {
val opdsSearchCriteria = OpdsSearchCriteria(query, author, title)
ctx.future {
future {
OpdsFeedBuilder.getSearchFeed(BASE_URL, locale, opdsSearchCriteria, pageNumber ?: 1)
OpdsFeedBuilder.getSearchFeed(opdsSearchCriteria, BASE_URL, pageNumber ?: 1, locale)
}.thenApply { xml ->
ctx.contentType(OPDS_MIME).result(xml)
}
}
} else {
val criteria = OpdsMangaFilter.fromContext(ctx, PrimaryFilterType.NONE)
getLibraryFeed(ctx, locale, criteria, false, pageNumber ?: 1)
val criteria =
OpdsMangaFilter(
sourceId = ctx.queryParam("source_id")?.toLongOrNull(),
categoryId = ctx.queryParam("category_id")?.toIntOrNull(),
statusId = ctx.queryParam("status_id")?.toIntOrNull(),
genre = ctx.queryParam("genre"),
langCode = ctx.queryParam("lang_code"),
sort = ctx.queryParam("sort"),
filter = ctx.queryParam("filter"),
primaryFilter = PrimaryFilterType.NONE,
)
getLibraryFeed(
ctx,
pageNumber,
criteria,
isSearch = false,
)
}
},
withResults = { httpCode(HttpStatus.OK) },
@@ -189,7 +203,7 @@ object OpdsV1Controller {
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
ctx.future {
future {
OpdsFeedBuilder.getExploreSourcesFeed(BASE_URL, locale, pageNumber ?: 1)
OpdsFeedBuilder.getExploreSourcesFeed(BASE_URL, pageNumber ?: 1, locale)
}.thenApply { xml ->
ctx.contentType(OPDS_MIME).result(xml)
}
@@ -216,7 +230,7 @@ object OpdsV1Controller {
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
ctx.future {
future {
OpdsFeedBuilder.getLibrarySourcesFeed(BASE_URL, locale, pageNumber ?: 1)
OpdsFeedBuilder.getLibrarySourcesFeed(BASE_URL, pageNumber ?: 1, locale)
}.thenApply { xml ->
ctx.contentType(OPDS_MIME).result(xml)
}
@@ -243,7 +257,7 @@ object OpdsV1Controller {
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
ctx.future {
future {
OpdsFeedBuilder.getCategoriesFeed(BASE_URL, locale, pageNumber ?: 1)
OpdsFeedBuilder.getCategoriesFeed(BASE_URL, pageNumber ?: 1, locale)
}.thenApply { xml ->
ctx.contentType(OPDS_MIME).result(xml)
}
@@ -270,7 +284,7 @@ object OpdsV1Controller {
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
ctx.future {
future {
OpdsFeedBuilder.getGenresFeed(BASE_URL, locale, pageNumber ?: 1)
OpdsFeedBuilder.getGenresFeed(BASE_URL, pageNumber ?: 1, locale)
}.thenApply { xml ->
ctx.contentType(OPDS_MIME).result(xml)
}
@@ -284,7 +298,6 @@ object OpdsV1Controller {
*/
val statusesFeed =
handler(
queryParam<Int?>("pageNumber"),
queryParam<String?>("lang"),
documentWith = {
withOperation {
@@ -292,12 +305,12 @@ object OpdsV1Controller {
description("Navigation feed listing series publication statuses for the library.")
}
},
behaviorOf = { ctx, pageNumber, lang ->
behaviorOf = { ctx, lang ->
ctx.getAttribute(Attribute.TachideskUser).requireUserWithBasicFallback(ctx)
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
ctx.future {
future {
OpdsFeedBuilder.getStatusFeed(BASE_URL, locale, pageNumber ?: 1)
OpdsFeedBuilder.getStatusFeed(BASE_URL, 1, locale)
}.thenApply { xml ->
ctx.contentType(OPDS_MIME).result(xml)
}
@@ -311,7 +324,6 @@ object OpdsV1Controller {
*/
val languagesFeed =
handler(
queryParam<Int?>("pageNumber"),
queryParam<String?>("lang"),
documentWith = {
withOperation {
@@ -319,12 +331,12 @@ object OpdsV1Controller {
description("Navigation feed listing available content languages for series in the library.")
}
},
behaviorOf = { ctx, pageNumber, lang ->
behaviorOf = { ctx, lang ->
ctx.getAttribute(Attribute.TachideskUser).requireUserWithBasicFallback(ctx)
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
ctx.future {
future {
OpdsFeedBuilder.getLanguagesFeed(BASE_URL, locale, pageNumber ?: 1)
OpdsFeedBuilder.getLanguagesFeed(BASE_URL, locale)
}.thenApply { xml ->
ctx.contentType(OPDS_MIME).result(xml)
}
@@ -351,7 +363,7 @@ object OpdsV1Controller {
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
ctx.future {
future {
OpdsFeedBuilder.getLibraryUpdatesFeed(BASE_URL, locale, pageNumber ?: 1)
OpdsFeedBuilder.getLibraryUpdatesFeed(BASE_URL, pageNumber ?: 1, locale)
}.thenApply { xml ->
ctx.contentType(OPDS_MIME).result(xml)
}
@@ -380,7 +392,7 @@ object OpdsV1Controller {
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
ctx.future {
future {
OpdsFeedBuilder.getExploreSourceFeed(BASE_URL, locale, sourceId, pageNumber ?: 1, sort ?: "popular")
OpdsFeedBuilder.getExploreSourceFeed(sourceId, BASE_URL, pageNumber ?: 1, sort ?: "popular", locale)
}.thenApply { xml ->
ctx.contentType(OPDS_MIME).result(xml)
}
@@ -413,9 +425,8 @@ object OpdsV1Controller {
documentWith = { withOperation { summary("OPDS Library Source Specific Series Feed") } },
behaviorOf = { ctx, sourceId ->
ctx.getAttribute(Attribute.TachideskUser).requireUserWithBasicFallback(ctx)
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, ctx.queryParam("lang"))
val criteria = buildCriteriaFromContext(ctx, OpdsMangaFilter(sourceId = sourceId, primaryFilter = PrimaryFilterType.SOURCE))
getLibraryFeed(ctx, locale, criteria, false, ctx.queryParam("pageNumber")?.toIntOrNull() ?: 1)
getLibraryFeed(ctx, ctx.queryParam("pageNumber")?.toIntOrNull(), criteria, isSearch = false)
},
withResults = {
httpCode(HttpStatus.OK)
@@ -432,10 +443,9 @@ object OpdsV1Controller {
documentWith = { withOperation { summary("OPDS Category Specific Series Feed") } },
behaviorOf = { ctx, categoryId ->
ctx.getAttribute(Attribute.TachideskUser).requireUserWithBasicFallback(ctx)
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, ctx.queryParam("lang"))
val criteria =
buildCriteriaFromContext(ctx, OpdsMangaFilter(categoryId = categoryId, primaryFilter = PrimaryFilterType.CATEGORY))
getLibraryFeed(ctx, locale, criteria, false, ctx.queryParam("pageNumber")?.toIntOrNull() ?: 1)
getLibraryFeed(ctx, ctx.queryParam("pageNumber")?.toIntOrNull(), criteria, isSearch = false)
},
withResults = {
httpCode(HttpStatus.OK)
@@ -452,9 +462,8 @@ object OpdsV1Controller {
documentWith = { withOperation { summary("OPDS Genre Specific Series Feed") } },
behaviorOf = { ctx, genre ->
ctx.getAttribute(Attribute.TachideskUser).requireUserWithBasicFallback(ctx)
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, ctx.queryParam("lang"))
val criteria = buildCriteriaFromContext(ctx, OpdsMangaFilter(genre = genre, primaryFilter = PrimaryFilterType.GENRE))
getLibraryFeed(ctx, locale, criteria, false, ctx.queryParam("pageNumber")?.toIntOrNull() ?: 1)
getLibraryFeed(ctx, ctx.queryParam("pageNumber")?.toIntOrNull(), criteria, isSearch = false)
},
withResults = {
httpCode(HttpStatus.OK)
@@ -471,9 +480,8 @@ object OpdsV1Controller {
documentWith = { withOperation { summary("OPDS Status Specific Series Feed") } },
behaviorOf = { ctx, statusId ->
ctx.getAttribute(Attribute.TachideskUser).requireUserWithBasicFallback(ctx)
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, ctx.queryParam("lang"))
val criteria = buildCriteriaFromContext(ctx, OpdsMangaFilter(statusId = statusId, primaryFilter = PrimaryFilterType.STATUS))
getLibraryFeed(ctx, locale, criteria, false, ctx.queryParam("pageNumber")?.toIntOrNull() ?: 1)
getLibraryFeed(ctx, ctx.queryParam("pageNumber")?.toIntOrNull(), criteria, isSearch = false)
},
withResults = {
httpCode(HttpStatus.OK)
@@ -495,10 +503,9 @@ object OpdsV1Controller {
},
behaviorOf = { ctx, langCode ->
ctx.getAttribute(Attribute.TachideskUser).requireUserWithBasicFallback(ctx)
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, ctx.queryParam("lang"))
val criteria =
buildCriteriaFromContext(ctx, OpdsMangaFilter(langCode = langCode, primaryFilter = PrimaryFilterType.LANGUAGE))
getLibraryFeed(ctx, locale, criteria, false, ctx.queryParam("pageNumber")?.toIntOrNull() ?: 1)
getLibraryFeed(ctx, ctx.queryParam("pageNumber")?.toIntOrNull(), criteria, isSearch = false)
},
withResults = {
httpCode(HttpStatus.OK)
@@ -527,7 +534,7 @@ object OpdsV1Controller {
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
ctx.future {
future {
OpdsFeedBuilder.getSeriesChaptersFeed(BASE_URL, locale, seriesId, pageNumber ?: 1, sort, filter)
OpdsFeedBuilder.getSeriesChaptersFeed(seriesId, BASE_URL, pageNumber ?: 1, sort, filter, locale)
}.thenApply { xml ->
ctx.contentType(OPDS_MIME).result(xml)
}
@@ -558,7 +565,7 @@ object OpdsV1Controller {
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
ctx.future {
future {
OpdsFeedBuilder.getChapterMetadataFeed(BASE_URL, locale, seriesId, chapterIndex)
OpdsFeedBuilder.getChapterMetadataFeed(seriesId, chapterIndex, BASE_URL, locale)
}.thenApply { xml ->
ctx.contentType(OPDS_MIME).result(xml)
}

View File

@@ -12,6 +12,4 @@ data class OpdsChapterListAcqEntry(
val lastReadAt: Long,
val sourceOrder: Int,
val pageCount: Int, // Can be -1 if not known
val downloaded: Boolean,
val cbzFileSize: Long? = null,
)

View File

@@ -5,7 +5,6 @@ data class OpdsChapterMetadataAcqEntry(
val mangaId: Int,
val name: String,
val uploadDate: Long,
val chapterNumber: Float, // Added to fallback chapter titles
val scanlator: String?,
val read: Boolean,
val lastPageRead: Int,
@@ -14,5 +13,4 @@ data class OpdsChapterMetadataAcqEntry(
val downloaded: Boolean,
val pageCount: Int,
val url: String?,
val cbzFileSize: Long? = null,
)

View File

@@ -5,7 +5,6 @@ data class OpdsHistoryAcqEntry(
val mangaTitle: String,
val mangaAuthor: String?,
val mangaId: Int,
val mangaTotalChapters: Long,
val mangaSourceLang: String?,
val mangaThumbnailUrl: String?,
)

View File

@@ -5,7 +5,6 @@ data class OpdsLibraryUpdateAcqEntry(
val mangaTitle: String,
val mangaAuthor: String?,
val mangaId: Int,
val mangaTotalChapters: Long,
val mangaSourceLang: String?,
val mangaThumbnailUrl: String?,
)

View File

@@ -5,5 +5,4 @@ data class OpdsMangaDetails( // Kept name, it's specific enough
val title: String,
val thumbnailUrl: String?,
val author: String?, // Added for chapter entry authors
val totalChapters: Long, // Added to determine if it's a oneshot
)

View File

@@ -1,6 +1,5 @@
package suwayomi.tachidesk.opds.dto
import io.javalin.http.Context
import suwayomi.tachidesk.opds.util.OpdsStringUtil.encodeForOpdsURL
/**
@@ -70,24 +69,4 @@ data class OpdsMangaFilter(
"genre" -> this.copy(genre = value)
else -> this
}
companion object {
/**
* Creates an OpdsMangaFilter directly from the Javalin Context, capturing all cross-filters.
*/
fun fromContext(
ctx: Context,
primaryFilter: PrimaryFilterType = PrimaryFilterType.NONE,
): OpdsMangaFilter =
OpdsMangaFilter(
sourceId = ctx.queryParam("source_id")?.toLongOrNull(),
categoryId = ctx.queryParam("category_id")?.toIntOrNull(),
statusId = ctx.queryParam("status_id")?.toIntOrNull(),
genre = ctx.queryParam("genre"),
langCode = ctx.queryParam("lang_code"),
sort = ctx.queryParam("sort"),
filter = ctx.queryParam("filter"),
primaryFilter = primaryFilter,
)
}
}

View File

@@ -16,131 +16,145 @@ import kotlin.math.ceil
*/
class FeedBuilderInternal(
private val baseUrl: String,
private val locale: Locale,
private val idPath: String,
private val title: String,
private val locale: Locale,
private val feedType: String,
private val pageNum: Int? = null,
private val pageNum: Int? = 1,
private val explicitQueryParams: String? = null,
private val currentSort: String? = null,
private val currentFilter: String? = null,
private val isSearchFeed: Boolean = false,
) {
private val opdsItemsPerPageBounded: Int
get() = serverConfig.opdsItemsPerPage.value
private val feedAuthor = OpdsAuthorXml("Suwayomi", "https://suwayomi.org/")
private val feedGeneratedAt: String = OpdsDateUtil.formatCurrentInstantForOpds()
var totalResults: Long = 0
var icon: String? = null
val links = mutableListOf<OpdsLinkXml>()
val entries = mutableListOf<OpdsEntryXml>()
private fun buildUrlWithParams(page: Int? = pageNum): String {
val queryParams =
listOfNotNull(
explicitQueryParams?.takeIf(String::isNotBlank),
page?.let { "pageNumber=$it" },
currentSort?.let { "sort=$it" },
currentFilter?.let { "filter=$it" },
"lang=${locale.toLanguageTag()}",
).joinToString("&")
private fun buildUrlWithParams(
baseHrefPath: String,
page: Int?,
): String {
val sb = StringBuilder("$baseUrl/$baseHrefPath")
val queryParamsList = mutableListOf<String>()
return "$baseUrl/$idPath" + if (queryParams.isNotEmpty()) "?$queryParams" else ""
explicitQueryParams?.takeIf { it.isNotBlank() }?.let { queryParamsList.add(it) }
page?.let { queryParamsList.add("pageNumber=$it") }
currentSort?.let { queryParamsList.add("sort=$it") }
currentFilter?.let { queryParamsList.add("filter=$it") }
queryParamsList.add("lang=${locale.toLanguageTag()}")
if (queryParamsList.isNotEmpty()) {
sb.append("?").append(queryParamsList.joinToString("&"))
}
return sb.toString()
}
fun build(): OpdsFeedXml {
val itemsPerPage = serverConfig.opdsItemsPerPage.value
val showOpenSearch = isSearchFeed && pageNum != null && totalResults > 0
val urnSuffix =
listOfNotNull(
locale.toLanguageTag(),
pageNum?.let { "page$it" },
explicitQueryParams?.replace("&", ":")?.replace("=", "_"),
currentSort?.let { "sort_$it" },
currentFilter?.let { "filter_$it" },
).joinToString(":")
val selfLinkHref = buildUrlWithParams(idPath, if (pageNum != null) pageNum else null)
val feedLinks = mutableListOf<OpdsLinkXml>()
feedLinks.addAll(this.links)
feedLinks.add(
OpdsLinkXml(OpdsConstants.LINK_REL_SELF, selfLinkHref, feedType, MR.strings.opds_linktitle_self_feed.localized(locale)),
)
feedLinks.add(
OpdsLinkXml(
OpdsConstants.LINK_REL_START,
"$baseUrl?lang=${locale.toLanguageTag()}",
OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION,
MR.strings.opds_linktitle_catalog_root.localized(locale),
),
)
feedLinks.add(
OpdsLinkXml(
OpdsConstants.LINK_REL_SEARCH,
"$baseUrl/search?lang=${locale.toLanguageTag()}",
OpdsConstants.TYPE_OPENSEARCH_DESCRIPTION,
MR.strings.opds_linktitle_search_catalog.localized(locale),
),
)
// Add pagination links if needed
if (pageNum != null) {
val totalPages = ceil(totalResults.toDouble() / opdsItemsPerPageBounded).toInt()
if (totalPages > 1) {
val currentPage = pageNum.coerceAtLeast(1)
// Always add 'first' link when there are multiple pages
feedLinks.add(
OpdsLinkXml(
OpdsConstants.LINK_REL_FIRST,
buildUrlWithParams(idPath, 1),
feedType,
MR.strings.opds_linktitle_first_page.localized(locale),
),
)
// Add 'prev' link if not on first page
if (currentPage > 1) {
feedLinks.add(
OpdsLinkXml(
OpdsConstants.LINK_REL_PREV,
buildUrlWithParams(idPath, currentPage - 1),
feedType,
MR.strings.opds_linktitle_previous_page.localized(locale),
),
)
}
// Add 'next' link if not on last page
if (currentPage < totalPages) {
feedLinks.add(
OpdsLinkXml(
OpdsConstants.LINK_REL_NEXT,
buildUrlWithParams(idPath, currentPage + 1),
feedType,
MR.strings.opds_linktitle_next_page.localized(locale),
),
)
}
// Always add 'last' link when there are multiple pages
feedLinks.add(
OpdsLinkXml(
OpdsConstants.LINK_REL_LAST,
buildUrlWithParams(idPath, totalPages),
feedType,
MR.strings.opds_linktitle_last_page.localized(locale),
),
)
}
}
val urnParams = mutableListOf<String>()
urnParams.add(locale.toLanguageTag())
pageNum?.let { urnParams.add("page$it") }
explicitQueryParams?.let { urnParams.add(it.replace("&", ":").replace("=", "_")) }
currentSort?.let { urnParams.add("sort_$it") }
currentFilter?.let { urnParams.add("filter_$it") }
val urnSuffix = if (urnParams.isNotEmpty()) ":${urnParams.joinToString(":")}" else ""
val showOpenSearchFields = isSearchFeed && pageNum != null && totalResults > 0
return OpdsFeedXml(
id = "urn:suwayomi:feed:${idPath.replace('/', ':')}${if (urnSuffix.isNotEmpty()) ":$urnSuffix" else ""}",
id = "urn:suwayomi:feed:${idPath.replace('/',':')}$urnSuffix",
title = title,
updated = OpdsDateUtil.formatCurrentInstantForOpds(),
updated = feedGeneratedAt,
icon = icon,
author = OpdsAuthorXml("Suwayomi", "https://suwayomi.org/"),
links =
buildList {
addAll(this@FeedBuilderInternal.links)
add(
OpdsLinkXml(
OpdsConstants.LINK_REL_SELF,
buildUrlWithParams(),
feedType,
MR.strings.opds_linktitle_self_feed.localized(locale),
),
)
add(
OpdsLinkXml(
OpdsConstants.LINK_REL_START,
"$baseUrl?lang=${locale.toLanguageTag()}",
OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION,
MR.strings.opds_linktitle_catalog_root.localized(locale),
),
)
add(
OpdsLinkXml(
OpdsConstants.LINK_REL_SEARCH,
"$baseUrl/search?lang=${locale.toLanguageTag()}",
OpdsConstants.TYPE_OPENSEARCH_DESCRIPTION,
MR.strings.opds_linktitle_search_catalog.localized(locale),
),
)
if (pageNum != null) {
val totalPages = ceil(totalResults.toDouble() / itemsPerPage).toInt()
if (totalPages > 1) {
val currentPage = pageNum.coerceAtLeast(1)
add(
OpdsLinkXml(
OpdsConstants.LINK_REL_FIRST,
buildUrlWithParams(1),
feedType,
MR.strings.opds_linktitle_first_page.localized(locale),
),
)
if (currentPage >
1
) {
add(
OpdsLinkXml(
OpdsConstants.LINK_REL_PREV,
buildUrlWithParams(currentPage - 1),
feedType,
MR.strings.opds_linktitle_previous_page.localized(locale),
),
)
}
if (currentPage <
totalPages
) {
add(
OpdsLinkXml(
OpdsConstants.LINK_REL_NEXT,
buildUrlWithParams(currentPage + 1),
feedType,
MR.strings.opds_linktitle_next_page.localized(locale),
),
)
}
add(
OpdsLinkXml(
OpdsConstants.LINK_REL_LAST,
buildUrlWithParams(totalPages),
feedType,
MR.strings.opds_linktitle_last_page.localized(locale),
),
)
}
}
},
author = feedAuthor,
links = feedLinks,
entries = entries,
totalResults = totalResults.takeIf { showOpenSearch },
itemsPerPage = itemsPerPage.takeIf { showOpenSearch },
startIndex = if (showOpenSearch && pageNum != null) ((pageNum - 1) * itemsPerPage) + 1 else null,
totalResults = totalResults.takeIf { showOpenSearchFields },
itemsPerPage = if (showOpenSearchFields) opdsItemsPerPageBounded else null,
startIndex = if (showOpenSearchFields) ((pageNum - 1) * opdsItemsPerPageBounded) + 1 else null,
)
}
}

View File

@@ -1,7 +1,10 @@
package suwayomi.tachidesk.opds.impl
import dev.icerock.moko.resources.StringResource
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import suwayomi.tachidesk.i18n.MR
import suwayomi.tachidesk.manga.impl.ChapterDownloadHelper
import suwayomi.tachidesk.manga.impl.MangaList.proxyThumbnailUrl
import suwayomi.tachidesk.manga.impl.sync.KoreaderSyncService
import suwayomi.tachidesk.manga.model.table.MangaStatus
@@ -90,15 +93,15 @@ object OpdsEntryBuilder {
/**
* Converts a manga data object into a full OPDS acquisition entry.
* @param entry The manga data object.
* @param baseUrl The base URL for constructing links.
* @param locale The locale for localization.
* @param entry The manga data object.
* @return An [OpdsEntryXml] object representing the manga.
*/
fun mangaAcqEntryToEntry(
entry: OpdsMangaAcqEntry,
baseUrl: String,
locale: Locale,
entry: OpdsMangaAcqEntry,
): OpdsEntryXml {
val displayThumbnailUrl = entry.thumbnailUrl?.let { proxyThumbnailUrl(entry.id) }
val categoryScheme = if (entry.inLibrary) "$baseUrl/library/genres" else "$baseUrl/genres"
@@ -149,151 +152,40 @@ object OpdsEntryBuilder {
)
}
/**
* Resolves the display title for a chapter, ensuring an empty name falls back to
* a generic "Chapter X" or "Oneshot" to keep the OPDS feed clean and usable.
*/
private fun resolveChapterTitle(
chapterName: String,
chapterNumber: Float,
sourceOrder: Int,
totalChapters: Long,
locale: Locale,
): String {
val trimmedName = chapterName.trim()
if (trimmedName.isNotEmpty()) {
return trimmedName
}
return if (totalChapters <= 1L) {
MR.strings.opds_chapter_title_oneshot.localized(locale)
} else {
val formatNumber =
if (chapterNumber >= 0f) {
if (chapterNumber % 1 == 0f) chapterNumber.toInt().toString() else chapterNumber.toString()
} else {
sourceOrder.toString()
}
MR.strings.opds_chapter_title_fallback.localized(locale, formatNumber)
}
}
/**
* Creates an OPDS entry for a chapter, including acquisition and streaming links.
* @param baseUrl The base URL for constructing links.
* @param locale The locale for localization.
* @param chapter The chapter data object.
* @param manga The parent manga's details.
* @param baseUrl The base URL for constructing links.
* @param addMangaTitle Whether to prepend the manga title to the entry title.
* @param skipMetadataFeed Whether to skip the metadata feed.
* @param locale The locale for localization.
* @return An [OpdsEntryXml] object for the chapter.
*/
suspend fun createChapterListEntry(
baseUrl: String,
locale: Locale,
chapter: OpdsChapterListAcqEntry,
manga: OpdsMangaDetails,
baseUrl: String,
addMangaTitle: Boolean,
skipMetadataFeed: Boolean,
locale: Locale,
): OpdsEntryXml {
var effectiveLastPageRead = chapter.lastPageRead
var effectiveLastReadAt = chapter.lastReadAt
if (skipMetadataFeed) {
val syncResult = KoreaderSyncService.checkAndPullProgress(chapter.id)
// If sync strategy dictates an update (e.g. KEEP_REMOTE), use remote data.
// If sync strategy is PROMPT (isConflict=true), we ignore it here (effectively KEEP_LOCAL/DISABLED)
// because we cannot show a prompt in the chapter list feed.
if (syncResult != null && syncResult.shouldUpdate) {
effectiveLastPageRead = syncResult.pageRead
effectiveLastReadAt = syncResult.timestamp
}
}
val statusKey =
when {
chapter.downloaded -> MR.strings.opds_chapter_status_downloaded
chapter.read -> MR.strings.opds_chapter_status_read
effectiveLastPageRead > 0 -> MR.strings.opds_chapter_status_in_progress
chapter.lastPageRead > 0 -> MR.strings.opds_chapter_status_in_progress
else -> MR.strings.opds_chapter_status_unread
}
val titlePrefix = statusKey.localized(locale)
val chapterName = resolveChapterTitle(chapter.name, chapter.chapterNumber, chapter.sourceOrder, manga.totalChapters, locale)
val entryTitle = titlePrefix + (if (addMangaTitle) " ${manga.title}:" else "") + " $chapterName"
val entryTitle = titlePrefix + (if (addMangaTitle) " ${manga.title}:" else "") + " ${chapter.name}"
val details =
buildString {
append(MR.strings.opds_chapter_details_base.localized(locale, manga.title, chapterName))
append(MR.strings.opds_chapter_details_base.localized(locale, manga.title, chapter.name))
chapter.scanlator?.takeIf { it.isNotBlank() }?.let {
append(MR.strings.opds_chapter_details_scanlator.localized(locale, it))
}
if (chapter.pageCount > 0) {
append(MR.strings.opds_chapter_details_progress.localized(locale, effectiveLastPageRead, chapter.pageCount))
append(MR.strings.opds_chapter_details_progress.localized(locale, chapter.lastPageRead, chapter.pageCount))
}
}
val links = mutableListOf<OpdsLinkXml>()
if (skipMetadataFeed) {
// Provide Acquisition Link (Download CBZ) if downloaded
if (chapter.downloaded) {
links.add(
OpdsLinkXml(
OpdsConstants.LINK_REL_ACQUISITION_OPEN_ACCESS,
"/api/v1/chapter/${chapter.id}/download?markAsRead=${serverConfig.opdsMarkAsReadOnDownload.value}",
serverConfig.opdsCbzMimetype.value.mediaType,
MR.strings.opds_linktitle_download_cbz.localized(locale),
length = chapter.cbzFileSize,
),
)
}
// Provide Stream Link (OPDS-PSE) if page count is known
if (chapter.pageCount > 0) {
val basePageHref =
"/api/v1/manga/${manga.id}/chapter/${chapter.sourceOrder}/page/{pageNumber}" +
"?updateProgress=${serverConfig.opdsEnablePageReadProgress.value}&opds=true"
val titleRes =
if (effectiveLastPageRead > 0) {
MR.strings.opds_linktitle_stream_pages_continue
} else {
MR.strings.opds_linktitle_stream_pages_start
}
links.add(
OpdsLinkXml(
rel = OpdsConstants.LINK_REL_PSE_STREAM,
href = basePageHref,
type = OpdsConstants.TYPE_IMAGE_JPEG,
title = titleRes.localized(locale),
pseCount = chapter.pageCount,
pseLastRead = effectiveLastPageRead.takeIf { it > 0 },
pseLastReadDate = effectiveLastReadAt.takeIf { it > 0 }?.let { OpdsDateUtil.formatEpochMillisForOpds(it * 1000) },
),
)
// Page 0 Cover
links.add(
OpdsLinkXml(
rel = OpdsConstants.LINK_REL_IMAGE,
href = "/api/v1/manga/${manga.id}/chapter/${chapter.sourceOrder}/page/0",
type = OpdsConstants.TYPE_IMAGE_JPEG,
title = MR.strings.opds_linktitle_chapter_cover.localized(locale),
),
)
}
} else {
// Link to Metadata Feed
links.add(
OpdsLinkXml(
rel = OpdsConstants.LINK_REL_SUBSECTION,
href = "$baseUrl/series/${manga.id}/chapter/${chapter.sourceOrder}/metadata?lang=${locale.toLanguageTag()}",
type = OpdsConstants.TYPE_ATOM_XML_ENTRY_PROFILE_OPDS,
title = MR.strings.opds_linktitle_view_chapter_details.localized(locale),
),
)
}
return OpdsEntryXml(
id = "urn:suwayomi:chapter:${chapter.id}",
title = entryTitle,
@@ -304,25 +196,33 @@ object OpdsEntryBuilder {
chapter.scanlator?.takeIf { it.isNotBlank() }?.let { OpdsAuthorXml(name = it) },
),
summary = OpdsSummaryXml(value = details),
link = links,
link =
listOf(
OpdsLinkXml(
rel = OpdsConstants.LINK_REL_SUBSECTION,
href = "$baseUrl/series/${manga.id}/chapter/${chapter.sourceOrder}/metadata?lang=${locale.toLanguageTag()}",
type = OpdsConstants.TYPE_ATOM_XML_ENTRY_PROFILE_OPDS,
title = MR.strings.opds_linktitle_view_chapter_details.localized(locale),
),
),
)
}
/**
* Creates one or two OPDS entries for a chapter, handling synchronization conflicts internally.
*
* @param baseUrl The base URL for constructing links.
* @param locale The locale for localization.
* @param chapter The chapter metadata object.
* @param manga The parent manga's details.
* @param baseUrl The base URL for constructing links.
* @param locale The locale for localization.
* @return A `Pair` where the first element is the primary entry (always present) and the
* second is an optional entry representing the remote progress in case of a conflict.
*/
suspend fun createChapterMetadataEntries(
baseUrl: String,
locale: Locale,
chapter: OpdsChapterMetadataAcqEntry,
manga: OpdsMangaDetails,
baseUrl: String,
locale: Locale,
): Pair<OpdsEntryXml, OpdsEntryXml?> {
// Check remote progress before building the entry
val syncResult = KoreaderSyncService.checkAndPullProgress(chapter.id)
@@ -334,20 +234,20 @@ object OpdsEntryBuilder {
// Generate two entries: one for local progress and another for remote.
val localEntry =
buildSingleChapterMetadataEntry(
baseUrl,
locale,
chapter,
manga,
baseUrl,
locale,
progressSource = ProgressSource.Local(chapter.lastPageRead, chapter.lastReadAt),
isConflict = true,
)
val remoteEntry =
buildSingleChapterMetadataEntry(
baseUrl,
locale,
chapter,
manga,
baseUrl,
locale,
progressSource = ProgressSource.Remote(syncResult!!.pageRead, syncResult.timestamp, syncResult.device),
isConflict = true,
)
@@ -363,10 +263,10 @@ object OpdsEntryBuilder {
val mainEntry =
buildSingleChapterMetadataEntry(
baseUrl,
locale,
chapter,
manga,
baseUrl,
locale,
progressSource = progressSource,
isConflict = false,
)
@@ -397,10 +297,10 @@ object OpdsEntryBuilder {
* Helper function to build a single OpdsEntryXml for a chapter.
*/
private suspend fun buildSingleChapterMetadataEntry(
baseUrl: String,
locale: Locale,
chapter: OpdsChapterMetadataAcqEntry,
manga: OpdsMangaDetails,
baseUrl: String,
locale: Locale,
progressSource: ProgressSource,
isConflict: Boolean,
): OpdsEntryXml {
@@ -437,7 +337,14 @@ object OpdsEntryBuilder {
}
val entryTitle = "$titlePrefix ${chapter.name}"
val cbzFileSize = chapter.cbzFileSize
val cbzFileSize =
if (chapter.downloaded) {
withContext(Dispatchers.IO) {
runCatching { ChapterDownloadHelper.getArchiveStreamWithSize(manga.id, chapter.id).second }.getOrNull()
}
} else {
null
}
val links = mutableListOf<OpdsLinkXml>()
chapter.url?.let {
@@ -542,8 +449,8 @@ object OpdsEntryBuilder {
fun addSourceSortFacets(
feedBuilder: FeedBuilderInternal,
baseUrl: String,
locale: Locale,
currentSort: String,
locale: Locale,
) {
val sortGroup = MR.strings.opds_facetgroup_sort_order.localized(locale)
val addFacet = { href: String, titleKey: StringResource, isActive: Boolean ->
@@ -568,9 +475,9 @@ object OpdsEntryBuilder {
fun addChapterSortAndFilterFacets(
feedBuilder: FeedBuilderInternal,
baseUrl: String,
locale: Locale,
currentSort: String,
currentFilter: String,
locale: Locale,
filterCounts: Map<String, Long>? = null,
) {
val sortGroup = MR.strings.opds_facetgroup_sort_order.localized(locale)
@@ -647,15 +554,15 @@ object OpdsEntryBuilder {
fun addLibraryFacets(
feedBuilder: FeedBuilderInternal,
baseUrl: String,
locale: Locale,
activeFilters: OpdsMangaFilter,
locale: Locale,
) {
val currentSort = activeFilters.sort ?: "alpha_asc"
val currentFilter = activeFilters.filter ?: "all"
val sortGroup = MR.strings.opds_facetgroup_sort_order.localized(locale)
val filterGroup = MR.strings.opds_facetgroup_filter_content.localized(locale)
val filterCounts = MangaRepository.getLibraryFilterCounts(activeFilters)
val filterCounts = MangaRepository.getLibraryFilterCounts()
val buildUrl = { newFilters: OpdsMangaFilter, newSort: String, newFilter: String ->
val crossFilterParams = newFilters.toCrossFilterQueryParameters()
@@ -760,7 +667,7 @@ object OpdsEntryBuilder {
// --- Cross-Filter Facets ---
if (activeFilters.primaryFilter != PrimaryFilterType.SOURCE) {
val sources = NavigationRepository.getLibrarySources(pageNum = null, activeFilters = activeFilters).first
val sources = NavigationRepository.getLibrarySources(1).first
addFacet(
feedBuilder,
buildUrl(activeFilters.without("source_id"), currentSort, currentFilter),
@@ -781,7 +688,7 @@ object OpdsEntryBuilder {
}
}
if (activeFilters.primaryFilter != PrimaryFilterType.CATEGORY) {
val categories = NavigationRepository.getCategories(pageNum = null, activeFilters = activeFilters).first
val categories = NavigationRepository.getCategories(1).first
addFacet(
feedBuilder,
buildUrl(activeFilters.without("category_id"), currentSort, currentFilter),
@@ -802,7 +709,7 @@ object OpdsEntryBuilder {
}
}
if (activeFilters.primaryFilter != PrimaryFilterType.STATUS) {
val statuses = NavigationRepository.getStatuses(locale, pageNum = null, activeFilters = activeFilters).first
val statuses = NavigationRepository.getStatuses(locale)
addFacet(
feedBuilder,
buildUrl(activeFilters.without("status_id"), currentSort, currentFilter),
@@ -823,7 +730,7 @@ object OpdsEntryBuilder {
}
}
if (activeFilters.primaryFilter != PrimaryFilterType.LANGUAGE) {
val languages = NavigationRepository.getContentLanguages(locale, pageNum = null, activeFilters = activeFilters).first
val languages = NavigationRepository.getContentLanguages(locale)
addFacet(
feedBuilder,
buildUrl(activeFilters.without("lang_code"), currentSort, currentFilter),
@@ -844,7 +751,7 @@ object OpdsEntryBuilder {
}
}
if (activeFilters.primaryFilter != PrimaryFilterType.GENRE) {
val genres = NavigationRepository.getGenres(locale, pageNum = null, activeFilters = activeFilters).first
val genres = NavigationRepository.getGenres(1, locale).first
addFacet(
feedBuilder,
buildUrl(activeFilters.without("genre"), currentSort, currentFilter),

View File

@@ -17,7 +17,6 @@ import suwayomi.tachidesk.opds.repository.ChapterRepository
import suwayomi.tachidesk.opds.repository.MangaRepository
import suwayomi.tachidesk.opds.repository.NavigationRepository
import suwayomi.tachidesk.opds.util.OpdsDateUtil
import suwayomi.tachidesk.opds.util.OpdsStringUtil
import suwayomi.tachidesk.opds.util.OpdsXmlUtil
import suwayomi.tachidesk.server.serverConfig
import java.util.Locale
@@ -41,11 +40,12 @@ object OpdsFeedBuilder {
val navItems = NavigationRepository.getRootNavigationItems(locale)
val builder =
FeedBuilderInternal(
baseUrl = baseUrl,
locale = locale,
idPath = "", // Root path is empty
title = MR.strings.opds_feeds_root.localized(locale),
feedType = OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION,
baseUrl,
"", // Root path is empty
MR.strings.opds_feeds_root.localized(locale),
locale,
OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION,
null,
)
builder.totalResults = navItems.size.toLong()
builder.entries.addAll(
@@ -73,39 +73,30 @@ object OpdsFeedBuilder {
/**
* Generates the history feed showing recently read chapters.
* @param baseUrl The base URL for constructing links.
* @param locale The locale for localization.
* @param pageNum The page number for pagination.
* @param locale The locale for localization.
* @return An XML string representing the history feed.
*/
suspend fun getHistoryFeed(
baseUrl: String,
locale: Locale,
pageNum: Int,
locale: Locale,
): String {
val (historyItems, total) = ChapterRepository.getHistory(pageNum)
val builder =
FeedBuilderInternal(
baseUrl = baseUrl,
locale = locale,
idPath = "history",
title = MR.strings.opds_feeds_history_title.localized(locale),
feedType = OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
pageNum = pageNum,
baseUrl,
"history",
MR.strings.opds_feeds_history_title.localized(locale),
locale,
OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
pageNum,
)
builder.totalResults = total
val skipMetadata = serverConfig.opdsSkipChapterMetadataFeed.value
builder.entries.addAll(
historyItems.map { item ->
val mangaDetails =
OpdsMangaDetails(item.mangaId, item.mangaTitle, item.mangaThumbnailUrl, item.mangaAuthor, item.mangaTotalChapters)
OpdsEntryBuilder.createChapterListEntry(
baseUrl,
locale,
item.chapter,
mangaDetails,
true,
skipMetadata,
)
val mangaDetails = OpdsMangaDetails(item.mangaId, item.mangaTitle, item.mangaThumbnailUrl, item.mangaAuthor)
OpdsEntryBuilder.createChapterListEntry(item.chapter, mangaDetails, baseUrl, true, locale)
},
)
return OpdsXmlUtil.serializeFeedToString(builder.build())
@@ -113,55 +104,54 @@ object OpdsFeedBuilder {
/**
* Generates a feed for search results based on the provided criteria.
* @param baseUrl The base URL for constructing links.
* @param locale The locale for localization.
* @param criteria The search criteria.
* @param baseUrl The base URL for constructing links.
* @param pageNum The page number for pagination.
* @param locale The locale for localization.
* @return An XML string representing the search results feed.
*/
fun getSearchFeed(
baseUrl: String,
locale: Locale,
criteria: OpdsSearchCriteria,
baseUrl: String,
pageNum: Int,
locale: Locale,
): String {
val (mangaEntries, total) = MangaRepository.findMangaByCriteria(criteria)
val builder =
FeedBuilderInternal(
baseUrl = baseUrl,
locale = locale,
idPath = "library/series",
title = MR.strings.opds_feeds_search_results_title.localized(locale),
feedType = OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
pageNum = pageNum,
baseUrl,
"library/series",
MR.strings.opds_feeds_search_results_title.localized(locale),
locale,
OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
pageNum,
isSearchFeed = true,
)
builder.totalResults = total
builder.entries.addAll(mangaEntries.map { OpdsEntryBuilder.mangaAcqEntryToEntry(baseUrl, locale, it) })
builder.entries.addAll(mangaEntries.map { OpdsEntryBuilder.mangaAcqEntryToEntry(it, baseUrl, locale) })
return OpdsXmlUtil.serializeFeedToString(builder.build())
}
/**
* Generates a generic library feed based on various filtering and sorting criteria.
* @param baseUrl The base URL for constructing links.
* @param locale The locale for localization.
* @param criteria The filtering criteria.
* @param isSearch Indicates if it's a search feed.
* @param baseUrl The base URL for constructing links.
* @param pageNum The page number for pagination.
* @param sort The sorting parameter.
* @param filter The filtering parameter.
* @param locale The locale for localization.
* @return An XML string representing the library feed.
*/
fun getLibraryFeed(
baseUrl: String,
locale: Locale,
criteria: OpdsMangaFilter,
isSearch: Boolean,
baseUrl: String,
pageNum: Int,
sort: String?,
filter: String?,
locale: Locale,
isSearch: Boolean,
): String {
val result = MangaRepository.getLibraryManga(criteria, pageNum, sort, filter)
val result = MangaRepository.getLibraryManga(pageNum, sort, filter, criteria)
val feedTitle =
when (criteria.primaryFilter) {
@@ -187,12 +177,7 @@ object OpdsFeedBuilder {
}
PrimaryFilterType.STATUS -> {
val statusName =
NavigationRepository
.getStatuses(locale, pageNum = null, activeFilters = criteria)
.first
.find { it.id == criteria.statusId }
?.title
val statusName = NavigationRepository.getStatuses(locale).find { it.id == criteria.statusId }?.title
MR.strings.opds_feeds_status_specific_title.localized(locale, statusName ?: criteria.statusId.toString())
}
@@ -208,7 +193,7 @@ object OpdsFeedBuilder {
val feedUrl =
when (criteria.primaryFilter) {
PrimaryFilterType.SOURCE -> "source/${criteria.sourceId}"
PrimaryFilterType.SOURCE -> "library/source/${criteria.sourceId}"
PrimaryFilterType.CATEGORY -> "category/${criteria.categoryId}"
PrimaryFilterType.GENRE -> "genre/${criteria.genre}"
PrimaryFilterType.STATUS -> "status/${criteria.statusId}"
@@ -218,23 +203,23 @@ object OpdsFeedBuilder {
val builder =
FeedBuilderInternal(
baseUrl = baseUrl,
locale = locale,
idPath = feedUrl,
title = feedTitle,
feedType = OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
pageNum = pageNum,
explicitQueryParams = criteria.toCrossFilterQueryParameters(),
baseUrl,
feedUrl,
feedTitle,
locale,
OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
pageNum,
currentSort = criteria.sort,
currentFilter = criteria.filter,
explicitQueryParams = criteria.toCrossFilterQueryParameters(),
isSearchFeed = isSearch,
)
builder.totalResults = result.totalCount
// Add all library facets (sort, filter, and cross-filtering)
OpdsEntryBuilder.addLibraryFacets(builder, baseUrl, locale, criteria)
OpdsEntryBuilder.addLibraryFacets(builder, baseUrl, criteria, locale)
builder.entries.addAll(result.mangaEntries.map { OpdsEntryBuilder.mangaAcqEntryToEntry(baseUrl, locale, it) })
builder.entries.addAll(result.mangaEntries.map { OpdsEntryBuilder.mangaAcqEntryToEntry(it, baseUrl, locale) })
return OpdsXmlUtil.serializeFeedToString(builder.build())
}
@@ -242,24 +227,24 @@ object OpdsFeedBuilder {
/**
* Generates a navigation feed listing all available sources for exploration.
* @param baseUrl The base URL for constructing links.
* @param locale The locale for localization.
* @param pageNum The page number for pagination.
* @param locale The locale for localization.
* @return An XML string representing the explore sources feed.
*/
fun getExploreSourcesFeed(
baseUrl: String,
locale: Locale,
pageNum: Int,
locale: Locale,
): String {
val (sourceNavEntries, total) = NavigationRepository.getExploreSources(pageNum)
val builder =
FeedBuilderInternal(
baseUrl = baseUrl,
locale = locale,
idPath = "sources",
title = MR.strings.opds_feeds_sources_title.localized(locale),
feedType = OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION,
pageNum = pageNum,
baseUrl,
"sources",
MR.strings.opds_feeds_sources_title.localized(locale),
locale,
OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION,
pageNum,
)
builder.totalResults = total
builder.entries.addAll(
@@ -272,7 +257,7 @@ object OpdsFeedBuilder {
listOf(
OpdsLinkXml(
OpdsConstants.LINK_REL_SUBSECTION,
"$baseUrl/explore/source/${entry.id}?sort=popular&lang=${locale.toLanguageTag()}",
"$baseUrl/source/${entry.id}?sort=popular&lang=${locale.toLanguageTag()}",
OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
),
),
@@ -285,24 +270,24 @@ object OpdsFeedBuilder {
/**
* Generates a navigation feed listing sources for series present in the library.
* @param baseUrl The base URL for constructing links.
* @param locale The locale for localization.
* @param pageNum The page number for pagination.
* @param locale The locale for localization.
* @return An XML string representing the library sources feed.
*/
fun getLibrarySourcesFeed(
baseUrl: String,
locale: Locale,
pageNum: Int,
locale: Locale,
): String {
val (sourceNavEntries, total) = NavigationRepository.getLibrarySources(pageNum)
val builder =
FeedBuilderInternal(
baseUrl = baseUrl,
locale = locale,
idPath = "library/sources",
title = MR.strings.opds_feeds_library_sources_title.localized(locale),
feedType = OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION,
pageNum = pageNum,
baseUrl,
"library/sources",
MR.strings.opds_feeds_library_sources_title.localized(locale),
locale,
OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION,
pageNum,
)
builder.totalResults = total
builder.entries.addAll(
@@ -315,7 +300,7 @@ object OpdsFeedBuilder {
listOf(
OpdsLinkXml(
OpdsConstants.LINK_REL_SUBSECTION,
"$baseUrl/source/${entry.id}?lang=${locale.toLanguageTag()}",
"$baseUrl/library/source/${entry.id}?lang=${locale.toLanguageTag()}",
OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
entry.name,
thrCount = entry.mangaCount?.toInt(),
@@ -329,75 +314,78 @@ object OpdsFeedBuilder {
/**
* Generates an acquisition feed for manga from a specific source (explore context).
* @param baseUrl The base URL for constructing links.
* @param locale The locale for localization.
* @param sourceId The ID of the source.
* @param baseUrl The base URL for constructing links.
* @param pageNum The page number for pagination.
* @param sort The sorting parameter ('popular' or 'latest').
* @param locale The locale for localization.
* @return An XML string representing the source-specific feed.
*/
suspend fun getExploreSourceFeed(
baseUrl: String,
locale: Locale,
sourceId: Long,
baseUrl: String,
pageNum: Int,
sort: String,
locale: Locale,
): String {
val (mangaEntries, hasNextPage) = MangaRepository.getMangaBySource(sourceId, pageNum, sort)
val sourceInfo = NavigationRepository.getSourceDetails(sourceId)
val sourceName = sourceInfo?.first ?: sourceId.toString()
val sourceNavEntry = NavigationRepository.getExploreSources(1).first.find { it.id == sourceId }
val sourceNameOrId = sourceNavEntry?.name ?: sourceId.toString()
val titleRes =
if (sort ==
"latest"
) {
if (sort == "latest") {
MR.strings.opds_feeds_source_specific_latest_title
} else {
MR.strings.opds_feeds_source_specific_popular_title
}
val feedTitle = titleRes.localized(locale, sourceName)
val feedTitle = titleRes.localized(locale, sourceNameOrId)
val feedUrl = "source/$sourceId"
val builder =
FeedBuilderInternal(
baseUrl = baseUrl,
locale = locale,
idPath = "explore/source/$sourceId",
title = feedTitle,
feedType = OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
pageNum = pageNum,
baseUrl,
feedUrl,
feedTitle,
locale,
OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
pageNum,
currentSort = sort,
)
builder.totalResults =
if (hasNextPage) {
(pageNum * serverConfig.opdsItemsPerPage.value + 1).toLong()
} else {
((pageNum - 1) * serverConfig.opdsItemsPerPage.value + mangaEntries.size).toLong()
(
(pageNum - 1) *
serverConfig.opdsItemsPerPage.value +
mangaEntries.size
).toLong()
}
builder.icon = sourceInfo?.second
OpdsEntryBuilder.addSourceSortFacets(builder, "$baseUrl/explore/source/$sourceId", locale, sort)
builder.entries.addAll(mangaEntries.map { OpdsEntryBuilder.mangaAcqEntryToEntry(baseUrl, locale, it) })
builder.icon = sourceNavEntry?.iconUrl
OpdsEntryBuilder.addSourceSortFacets(builder, "$baseUrl/$feedUrl", sort, locale)
builder.entries.addAll(mangaEntries.map { OpdsEntryBuilder.mangaAcqEntryToEntry(it, baseUrl, locale) })
return OpdsXmlUtil.serializeFeedToString(builder.build())
}
/**
* Generates a navigation feed for library categories.
* @param baseUrl The base URL for constructing links.
* @param locale The locale for localization.
* @param pageNum The page number for pagination.
* @param locale The locale for localization.
* @return An XML string representing the categories navigation feed.
*/
fun getCategoriesFeed(
baseUrl: String,
locale: Locale,
pageNum: Int,
locale: Locale,
): String {
val (categoryNavEntries, total) = NavigationRepository.getCategories(pageNum)
val builder =
FeedBuilderInternal(
baseUrl = baseUrl,
locale = locale,
idPath = "library/categories",
title = MR.strings.opds_feeds_categories_title.localized(locale),
feedType = OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION,
pageNum = pageNum,
baseUrl,
"library/categories",
MR.strings.opds_feeds_categories_title.localized(locale),
locale,
OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION,
pageNum,
)
builder.totalResults = total
builder.entries.addAll(
@@ -425,24 +413,24 @@ object OpdsFeedBuilder {
/**
* Generates a navigation feed for library genres.
* @param baseUrl The base URL for constructing links.
* @param locale The locale for localization.
* @param pageNum The page number for pagination.
* @param locale The locale for localization.
* @return An XML string representing the genres navigation feed.
*/
fun getGenresFeed(
baseUrl: String,
locale: Locale,
pageNum: Int,
locale: Locale,
): String {
val (genreNavEntries, total) = NavigationRepository.getGenres(locale, pageNum)
val (genreNavEntries, total) = NavigationRepository.getGenres(pageNum, locale)
val builder =
FeedBuilderInternal(
baseUrl = baseUrl,
locale = locale,
idPath = "library/genres",
title = MR.strings.opds_feeds_genres_title.localized(locale),
feedType = OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION,
pageNum = pageNum,
baseUrl,
"library/genres",
MR.strings.opds_feeds_genres_title.localized(locale),
locale,
OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION,
pageNum,
)
builder.totalResults = total
builder.entries.addAll(
@@ -470,26 +458,26 @@ object OpdsFeedBuilder {
/**
* Generates a navigation feed for manga publication statuses.
* @param baseUrl The base URL for constructing links.
* @param locale The locale for localization.
* @param pageNum The page number (currently unused).
* @param locale The locale for localization.
* @return An XML string representing the status navigation feed.
*/
fun getStatusFeed(
baseUrl: String,
@Suppress("UNUSED_PARAMETER") pageNum: Int,
locale: Locale,
pageNum: Int,
): String {
val (statuses, total) = NavigationRepository.getStatuses(locale, pageNum)
val statuses = NavigationRepository.getStatuses(locale)
val builder =
FeedBuilderInternal(
baseUrl = baseUrl,
locale = locale,
idPath = "library/statuses",
title = MR.strings.opds_feeds_status_title.localized(locale),
feedType = OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION,
pageNum = pageNum,
baseUrl,
"library/statuses",
MR.strings.opds_feeds_status_title.localized(locale),
locale,
OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION,
null,
)
builder.totalResults = total
builder.totalResults = statuses.size.toLong()
builder.entries.addAll(
statuses.map { entry ->
OpdsEntryXml(
@@ -515,26 +503,24 @@ object OpdsFeedBuilder {
/**
* Generates a navigation feed for content languages available in the library.
* @param baseUrl The base URL for constructing links.
* @param locale The locale for the user interface.
* @param pageNum The page number for pagination.
* @param uiLocale The locale for the user interface.
* @return An XML string representing the languages navigation feed.
*/
fun getLanguagesFeed(
baseUrl: String,
locale: Locale,
pageNum: Int,
uiLocale: Locale,
): String {
val (languages, total) = NavigationRepository.getContentLanguages(locale, pageNum)
val languages = NavigationRepository.getContentLanguages(uiLocale)
val builder =
FeedBuilderInternal(
baseUrl = baseUrl,
locale = locale,
idPath = "library/languages",
title = MR.strings.opds_feeds_languages_title.localized(locale),
feedType = OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION,
pageNum = pageNum,
baseUrl,
"library/languages",
MR.strings.opds_feeds_languages_title.localized(uiLocale),
uiLocale,
OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION,
null,
)
builder.totalResults = total
builder.totalResults = languages.size.toLong()
builder.entries.addAll(
languages.map { entry ->
OpdsEntryXml(
@@ -545,7 +531,7 @@ object OpdsFeedBuilder {
listOf(
OpdsLinkXml(
OpdsConstants.LINK_REL_SUBSECTION,
"$baseUrl/language/${entry.id}?lang=${locale.toLanguageTag()}",
"$baseUrl/language/${entry.id}?lang=${uiLocale.toLanguageTag()}",
OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
entry.title,
thrCount = entry.mangaCount.toInt(),
@@ -558,41 +544,32 @@ object OpdsFeedBuilder {
}
/**
* Generates an acquisition feed of recent chapter updates for series in the library.
* Generates an acquisition feed for recent chapter updates in the library.
* @param baseUrl The base URL for constructing links.
* @param locale The locale for localization.
* @param pageNum The page number for pagination.
* @param locale The locale for localization.
* @return An XML string representing the library updates feed.
*/
suspend fun getLibraryUpdatesFeed(
baseUrl: String,
locale: Locale,
pageNum: Int,
locale: Locale,
): String {
val (updateItems, total) = ChapterRepository.getLibraryUpdates(pageNum)
val builder =
FeedBuilderInternal(
baseUrl = baseUrl,
locale = locale,
idPath = "library-updates",
title = MR.strings.opds_feeds_library_updates_title.localized(locale),
feedType = OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
pageNum = pageNum,
baseUrl,
"library-updates",
MR.strings.opds_feeds_library_updates_title.localized(locale),
locale,
OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
pageNum,
)
builder.totalResults = total
val skipMetadata = serverConfig.opdsSkipChapterMetadataFeed.value
builder.entries.addAll(
updateItems.map { item ->
val mangaDetails =
OpdsMangaDetails(item.mangaId, item.mangaTitle, item.mangaThumbnailUrl, item.mangaAuthor, item.mangaTotalChapters)
OpdsEntryBuilder.createChapterListEntry(
baseUrl,
locale,
item.chapter,
mangaDetails,
true,
skipMetadata,
)
val mangaDetails = OpdsMangaDetails(item.mangaId, item.mangaTitle, item.mangaThumbnailUrl, item.mangaAuthor)
OpdsEntryBuilder.createChapterListEntry(item.chapter, mangaDetails, baseUrl, true, locale)
},
)
return OpdsXmlUtil.serializeFeedToString(builder.build())
@@ -600,29 +577,29 @@ object OpdsFeedBuilder {
/**
* Generates an acquisition feed for all chapters of a specific manga.
* @param baseUrl The base URL for constructing links.
* @param locale The locale for localization.
* @param mangaId The ID of the manga.
* @param baseUrl The base URL for constructing links.
* @param pageNum The page number for pagination.
* @param sortParam The sorting parameter for chapters.
* @param filterParam The filtering parameter for chapters.
* @param locale The locale for localization.
* @return An XML string representing the series' chapters feed.
*/
suspend fun getSeriesChaptersFeed(
baseUrl: String,
locale: Locale,
mangaId: Int,
baseUrl: String,
pageNum: Int,
sortParam: String?,
filterParam: String?,
locale: Locale,
): String {
val mangaDetails =
MangaRepository.getMangaDetails(mangaId)
?: return buildNotFoundFeed(
baseUrl,
locale,
"series/$mangaId/chapters",
MR.strings.opds_error_manga_not_found.localized(locale, mangaId),
locale,
)
val (sortColumn, currentSortOrder) =
when (sortParam?.lowercase()) {
@@ -633,15 +610,13 @@ object OpdsFeedBuilder {
else -> ChapterTable.sourceOrder to (serverConfig.opdsChapterSortOrder.value)
}
val currentFilter = filterParam?.lowercase() ?: if (serverConfig.opdsShowOnlyUnreadChapters.value) "unread" else "all"
val skipMetadata = serverConfig.opdsSkipChapterMetadataFeed.value
var (chapterEntries, totalChapters) =
ChapterRepository.getChaptersForManga(
mangaId,
pageNum,
sortColumn,
currentSortOrder,
currentFilter,
pageNum,
skipMetadata,
)
// If no chapters are found in the database, attempt to fetch them from the source.
@@ -654,11 +629,10 @@ object OpdsFeedBuilder {
val (refetchedChapters, refetchedTotal) =
ChapterRepository.getChaptersForManga(
mangaId,
pageNum,
sortColumn,
currentSortOrder,
currentFilter,
pageNum,
skipMetadata,
)
chapterEntries = refetchedChapters
totalChapters = refetchedTotal
@@ -677,12 +651,12 @@ object OpdsFeedBuilder {
val feedUrl = "series/$mangaId/chapters"
val builder =
FeedBuilderInternal(
baseUrl = baseUrl,
locale = locale,
idPath = feedUrl,
title = MR.strings.opds_feeds_manga_chapters.localized(locale, mangaDetails.title),
feedType = OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
pageNum = pageNum,
baseUrl,
feedUrl,
MR.strings.opds_feeds_manga_chapters.localized(locale, mangaDetails.title),
locale,
OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
pageNum,
currentSort = actualSortParamForLinks,
currentFilter = currentFilter,
)
@@ -695,21 +669,14 @@ object OpdsFeedBuilder {
OpdsEntryBuilder.addChapterSortAndFilterFacets(
builder,
"$baseUrl/$feedUrl",
locale,
actualSortParamForLinks,
currentFilter,
locale,
filterCounts,
)
builder.entries.addAll(
chapterEntries.map { chapter ->
OpdsEntryBuilder.createChapterListEntry(
baseUrl,
locale,
chapter,
mangaDetails,
false,
skipMetadata,
)
OpdsEntryBuilder.createChapterListEntry(chapter, mangaDetails, baseUrl, false, locale)
},
)
return OpdsXmlUtil.serializeFeedToString(builder.build())
@@ -717,43 +684,43 @@ object OpdsFeedBuilder {
/**
* Generates an acquisition feed with detailed metadata for a single chapter.
* @param baseUrl The base URL for constructing links.
* @param locale The locale for localization.
* @param mangaId The ID of the manga.
* @param chapterSourceOrder The source order index of the chapter.
* @param baseUrl The base URL for constructing links.
* @param locale The locale for localization.
* @return An XML string representing the chapter's metadata feed.
*/
suspend fun getChapterMetadataFeed(
baseUrl: String,
locale: Locale,
mangaId: Int,
chapterSourceOrder: Int,
baseUrl: String,
locale: Locale,
): String {
val mangaDetails =
MangaRepository.getMangaDetails(mangaId)
?: return buildNotFoundFeed(
baseUrl,
locale,
"series/$mangaId/chapter/$chapterSourceOrder/metadata",
MR.strings.opds_error_manga_not_found.localized(locale, mangaId),
locale,
)
val chapterMetadata =
ChapterRepository.getChapterDetailsForMetadataFeed(mangaId, chapterSourceOrder)
?: return buildNotFoundFeed(
baseUrl,
locale,
"series/$mangaId/chapter/$chapterSourceOrder/metadata",
MR.strings.opds_error_chapter_not_found.localized(locale, chapterSourceOrder),
locale,
)
val builder =
FeedBuilderInternal(
baseUrl = baseUrl,
locale = locale,
idPath = "series/$mangaId/chapter/${chapterMetadata.sourceOrder}/metadata",
title = MR.strings.opds_feeds_chapter_details.localized(locale, mangaDetails.title, chapterMetadata.name),
feedType = OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
pageNum = null,
baseUrl,
"series/$mangaId/chapter/${chapterMetadata.sourceOrder}/metadata",
MR.strings.opds_feeds_chapter_details.localized(locale, mangaDetails.title, chapterMetadata.name),
locale,
OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
null,
)
mangaDetails.thumbnailUrl?.let { proxyThumbnailUrl(mangaDetails.id) }?.also {
@@ -764,10 +731,10 @@ object OpdsFeedBuilder {
val (primaryEntry, conflictEntry) =
OpdsEntryBuilder.createChapterMetadataEntries(
baseUrl = baseUrl,
locale = locale,
chapter = chapterMetadata,
manga = mangaDetails,
baseUrl = baseUrl,
locale = locale,
)
builder.entries.add(primaryEntry)
@@ -784,25 +751,19 @@ object OpdsFeedBuilder {
/**
* Builds a simple OPDS feed to indicate that a resource was not found.
* @param baseUrl The base URL.
* @param locale The locale for localization.
* @param idPath The path that was not found.
* @param title The title for the feed (e.g., an error message).
* @param locale The locale for localization.
* @return An XML string representing the 'not found' feed.
*/
fun buildNotFoundFeed(
baseUrl: String,
locale: Locale,
idPath: String,
title: String,
locale: Locale,
): String =
FeedBuilderInternal(
baseUrl = baseUrl,
locale = locale,
idPath = idPath,
title = title,
feedType = OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
pageNum = null,
).apply { totalResults = 0L }
FeedBuilderInternal(baseUrl, idPath, title, locale, feedType = OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION, pageNum = null)
.apply { totalResults = 0L }
.build()
.let(OpdsXmlUtil::serializeFeedToString)
}

View File

@@ -1,29 +1,17 @@
package suwayomi.tachidesk.opds.repository
import io.github.oshai.kotlinlogging.KotlinLogging
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withContext
import org.jetbrains.exposed.v1.core.Column
import org.jetbrains.exposed.v1.core.JoinType
import org.jetbrains.exposed.v1.core.Op
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.count
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.core.greater
import org.jetbrains.exposed.v1.core.inList
import org.jetbrains.exposed.v1.jdbc.andWhere
import org.jetbrains.exposed.v1.jdbc.select
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import org.jetbrains.exposed.v1.jdbc.update
import suwayomi.tachidesk.manga.impl.ChapterDownloadHelper
import suwayomi.tachidesk.manga.impl.chapter.getChapterDownloadReady
import suwayomi.tachidesk.manga.impl.chapter.refreshChapterPageList
import suwayomi.tachidesk.manga.impl.chapter.updateChapterPersistence
import suwayomi.tachidesk.manga.model.table.ChapterTable
import suwayomi.tachidesk.manga.model.table.MangaTable
import suwayomi.tachidesk.manga.model.table.SourceTable
@@ -36,7 +24,6 @@ import suwayomi.tachidesk.server.serverConfig
object ChapterRepository {
private val opdsItemsPerPageBounded: Int
get() = serverConfig.opdsItemsPerPage.value
private val logger = KotlinLogging.logger {}
private fun ResultRow.toOpdsChapterListAcqEntry(): OpdsChapterListAcqEntry =
OpdsChapterListAcqEntry(
@@ -51,163 +38,69 @@ object ChapterRepository {
lastReadAt = this[ChapterTable.lastReadAt],
sourceOrder = this[ChapterTable.sourceOrder],
pageCount = this[ChapterTable.pageCount],
downloaded = this[ChapterTable.isDownloaded],
)
suspend fun getChaptersForManga(
fun getChaptersForManga(
mangaId: Int,
pageNum: Int,
sortColumn: Column<*>,
sortOrder: SortOrder,
filter: String,
pageNum: Int,
skipMetadata: Boolean,
): Pair<List<OpdsChapterListAcqEntry>, Long> {
val (rawChapters, totalCount) =
transaction {
val conditions = mutableListOf<Op<Boolean>>()
conditions.add(ChapterTable.manga eq mangaId)
): Pair<List<OpdsChapterListAcqEntry>, Long> =
transaction {
val conditions = mutableListOf<Op<Boolean>>()
conditions.add(ChapterTable.manga eq mangaId)
when (filter) {
"unread" -> conditions.add(ChapterTable.isRead eq false)
"read" -> conditions.add(ChapterTable.isRead eq true)
}
if (serverConfig.opdsShowOnlyDownloadedChapters.value) {
conditions.add(ChapterTable.isDownloaded eq true)
}
val finalCondition = conditions.reduceOrNull { acc, op -> acc and op } ?: Op.TRUE
val baseQuery =
ChapterTable
.select(ChapterTable.columns)
.where(finalCondition)
val totalCount = baseQuery.count()
val chapters =
baseQuery
.orderBy(sortColumn to sortOrder)
.limit(opdsItemsPerPageBounded)
.offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong())
.map { it.toOpdsChapterListAcqEntry() }
Pair(chapters, totalCount)
when (filter) {
"unread" -> conditions.add(ChapterTable.isRead eq false)
"read" -> conditions.add(ChapterTable.isRead eq true)
}
if (serverConfig.opdsShowOnlyDownloadedChapters.value) {
conditions.add(ChapterTable.isDownloaded eq true)
}
// If not skipping metadata, return basic DTOs
if (!skipMetadata) {
return Pair(rawChapters, totalCount)
val finalCondition = conditions.reduceOrNull { acc, op -> acc and op } ?: Op.TRUE
val baseQuery =
ChapterTable
.select(ChapterTable.columns)
.where(finalCondition)
val totalCount = baseQuery.count()
val chapters =
baseQuery
.orderBy(sortColumn to sortOrder)
.limit(opdsItemsPerPageBounded)
.offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong())
.map { it.toOpdsChapterListAcqEntry() }
Pair(chapters, totalCount)
}
// If skipping metadata, enrich DTOs with page count and file size
val enrichedChapters =
coroutineScope {
rawChapters.map { entry ->
async(Dispatchers.IO) {
var pageCount = entry.pageCount
var isDownloaded = entry.downloaded
// Verify physical files if page count is unknown or the DB marks it as downloaded
if (pageCount <= 0 || isDownloaded) {
val physicalPageCount =
runCatching {
ChapterDownloadHelper.getImageCount(entry.mangaId, entry.id)
}.getOrDefault(0)
if (physicalPageCount > 0) {
// Files exist! Sync DB if needed
if (updateChapterPersistence(
chapterId = entry.id,
isMarkedAsDownloaded = isDownloaded,
dbPageCount = pageCount,
downloadPageCount = physicalPageCount,
lastPageRead = entry.lastPageRead,
logger = logger,
)
) {
pageCount = physicalPageCount
isDownloaded = true
}
} else {
if (isDownloaded) {
// Fix DB state if marked as downloaded but physical files are missing
transaction {
ChapterTable.update({ ChapterTable.id eq entry.id }) {
it[ChapterTable.isDownloaded] = false
}
}
isDownloaded = false
}
if (pageCount <= 0) {
// No files, and DB has no page count. Fetch from network
pageCount =
runCatching {
refreshChapterPageList(entry.mangaId, entry.id)
}.onFailure {
logger.warn(it) { "Failed to fetch page count for chapter ${entry.id}" }
}.getOrDefault(0)
}
}
}
// Calculate CBZ size if downloaded
val cbzFileSize =
if (isDownloaded) {
runCatching {
ChapterDownloadHelper.getChapterArchiveSize(entry.mangaId, entry.id)
}.getOrNull()
} else {
null
}
entry.copy(
pageCount = pageCount,
downloaded = isDownloaded,
cbzFileSize = cbzFileSize,
)
}
}
}.awaitAll()
return Pair(enrichedChapters, totalCount)
}
suspend fun getChapterDetailsForMetadataFeed(
mangaId: Int,
chapterSourceOrder: Int,
): OpdsChapterMetadataAcqEntry? {
val chapterDataClass =
try {
getChapterDownloadReady(chapterIndex = chapterSourceOrder, mangaId = mangaId)
} catch (e: Exception) {
return null
}
return OpdsChapterMetadataAcqEntry(
id = chapterDataClass.id,
mangaId = chapterDataClass.mangaId,
name = chapterDataClass.name,
uploadDate = chapterDataClass.uploadDate,
chapterNumber = chapterDataClass.chapterNumber,
scanlator = chapterDataClass.scanlator,
read = chapterDataClass.read,
lastPageRead = chapterDataClass.lastPageRead,
lastReadAt = chapterDataClass.lastReadAt,
sourceOrder = chapterDataClass.index,
downloaded = chapterDataClass.downloaded,
pageCount = chapterDataClass.pageCount,
url = chapterDataClass.realUrl,
cbzFileSize =
if (chapterDataClass.downloaded) {
withContext(Dispatchers.IO) {
runCatching { ChapterDownloadHelper.getChapterArchiveSize(mangaId, chapterDataClass.id) }.getOrNull()
}
} else {
null
},
)
}
): OpdsChapterMetadataAcqEntry? =
try {
val chapterDataClass = getChapterDownloadReady(chapterIndex = chapterSourceOrder, mangaId = mangaId)
OpdsChapterMetadataAcqEntry(
id = chapterDataClass.id,
mangaId = chapterDataClass.mangaId,
name = chapterDataClass.name,
uploadDate = chapterDataClass.uploadDate,
scanlator = chapterDataClass.scanlator,
read = chapterDataClass.read,
lastPageRead = chapterDataClass.lastPageRead,
lastReadAt = chapterDataClass.lastReadAt,
sourceOrder = chapterDataClass.index,
downloaded = chapterDataClass.downloaded,
pageCount = chapterDataClass.pageCount,
url = chapterDataClass.realUrl,
)
} catch (e: Exception) {
null
}
fun getLibraryUpdates(pageNum: Int): Pair<List<OpdsLibraryUpdateAcqEntry>, Long> =
transaction {
@@ -222,38 +115,21 @@ object ChapterRepository {
val totalCount = query.count()
val rawItems =
val items =
query
.orderBy(ChapterTable.fetchedAt to SortOrder.DESC, ChapterTable.sourceOrder to SortOrder.DESC)
.limit(opdsItemsPerPageBounded)
.offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong())
.toList()
val mangaIds = rawItems.map { it[MangaTable.id].value }.distinct()
val chapterCounts =
if (mangaIds.isNotEmpty()) {
ChapterTable
.select(ChapterTable.manga, ChapterTable.id.count())
.where { ChapterTable.manga inList mangaIds }
.groupBy(ChapterTable.manga)
.associate { it[ChapterTable.manga].value to it[ChapterTable.id.count()] }
} else {
emptyMap()
}
val items =
rawItems.map {
val mId = it[MangaTable.id].value
OpdsLibraryUpdateAcqEntry(
chapter = it.toOpdsChapterListAcqEntry(),
mangaTitle = it[MangaTable.title],
mangaAuthor = it[MangaTable.author],
mangaId = mId,
mangaSourceLang = it[SourceTable.lang],
mangaThumbnailUrl = it[MangaTable.thumbnail_url],
mangaTotalChapters = chapterCounts[mId] ?: 0L,
)
}
.map {
OpdsLibraryUpdateAcqEntry(
chapter = it.toOpdsChapterListAcqEntry(),
mangaTitle = it[MangaTable.title],
mangaAuthor = it[MangaTable.author],
mangaId = it[MangaTable.id].value,
mangaSourceLang = it[SourceTable.lang],
mangaThumbnailUrl = it[MangaTable.thumbnail_url],
)
}
Pair(items, totalCount)
}
@@ -270,38 +146,21 @@ object ChapterRepository {
val totalCount = query.count()
val rawItems =
val items =
query
.orderBy(ChapterTable.lastReadAt to SortOrder.DESC)
.limit(opdsItemsPerPageBounded)
.offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong())
.toList()
val mangaIds = rawItems.map { it[MangaTable.id].value }.distinct()
val chapterCounts =
if (mangaIds.isNotEmpty()) {
ChapterTable
.select(ChapterTable.manga, ChapterTable.id.count())
.where { ChapterTable.manga inList mangaIds }
.groupBy(ChapterTable.manga)
.associate { it[ChapterTable.manga].value to it[ChapterTable.id.count()] }
} else {
emptyMap()
}
val items =
rawItems.map {
val mId = it[MangaTable.id].value
OpdsHistoryAcqEntry(
chapter = it.toOpdsChapterListAcqEntry(),
mangaTitle = it[MangaTable.title],
mangaAuthor = it[MangaTable.author],
mangaId = mId,
mangaSourceLang = it[SourceTable.lang],
mangaThumbnailUrl = it[MangaTable.thumbnail_url],
mangaTotalChapters = chapterCounts[mId] ?: 0L,
)
}
.map {
OpdsHistoryAcqEntry(
chapter = it.toOpdsChapterListAcqEntry(),
mangaTitle = it[MangaTable.title],
mangaAuthor = it[MangaTable.author],
mangaId = it[MangaTable.id].value,
mangaSourceLang = it[SourceTable.lang],
mangaThumbnailUrl = it[MangaTable.thumbnail_url],
)
}
Pair(items, totalCount)
}

View File

@@ -36,57 +36,8 @@ import suwayomi.tachidesk.opds.dto.OpdsMangaDetails
import suwayomi.tachidesk.opds.dto.OpdsMangaFilter
import suwayomi.tachidesk.opds.dto.OpdsSearchCriteria
import suwayomi.tachidesk.opds.dto.PrimaryFilterType
import suwayomi.tachidesk.opds.util.OpdsStringUtil.formatSourceName
import suwayomi.tachidesk.server.serverConfig
/**
* Applies dynamic filters based on the current user configuration and cross-filters.
* Allows excluding a specific field to calculate mutual exclusion facet counts efficiently.
*
* @param criteria The filtering criteria.
* @param excludeField The field to exclude from filtering.
*/
fun Query.applyOpdsMangaFilter(
criteria: OpdsMangaFilter,
excludeField: String? = null,
) {
if (excludeField != "source_id") {
criteria.sourceId?.let { andWhere { MangaTable.sourceReference eq it } }
}
if (excludeField != "category_id") {
criteria.categoryId?.let { andWhere { CategoryMangaTable.category eq it } }
}
if (excludeField != "status_id") {
criteria.statusId?.let { andWhere { MangaTable.status eq it } }
}
if (excludeField != "lang_code") {
criteria.langCode?.let { andWhere { SourceTable.lang eq it } }
}
if (excludeField != "genre") {
criteria.genre?.let { genre ->
val genreTrimmed = genre.trim()
andWhere {
(MangaTable.genre like "%, $genreTrimmed, %") or
(MangaTable.genre like "$genreTrimmed, %") or
(MangaTable.genre like "%, $genreTrimmed") or
(MangaTable.genre eq genreTrimmed)
}
}
}
if (excludeField != "filter") {
criteria.filter?.let { filterVal ->
val unreadCountExpr = Case().When(ChapterTable.isRead eq false, intLiteral(1)).Else(intLiteral(0)).sum()
val downloadedCountExpr = Case().When(ChapterTable.isDownloaded eq true, intLiteral(1)).Else(intLiteral(0)).sum()
when (filterVal) {
"unread" -> having { unreadCountExpr greater 0 }
"downloaded" -> having { downloadedCountExpr greater 0 }
"ongoing" -> andWhere { MangaTable.status eq MangaStatus.ONGOING.value }
"completed" -> andWhere { MangaTable.status eq MangaStatus.COMPLETED.value }
}
}
}
}
/**
* Repository for fetching manga data tailored for OPDS feeds.
*/
@@ -109,24 +60,24 @@ object MangaRepository {
sourceLang = this[SourceTable.lang],
inLibrary = this[MangaTable.inLibrary],
status = this[MangaTable.status],
sourceName = formatSourceName(this[SourceTable.name], this[SourceTable.lang]),
sourceName = this[SourceTable.name],
lastFetchedAt = this[MangaTable.lastFetchedAt],
url = this[MangaTable.realUrl],
)
/**
* Centralized function to retrieve paginated, sorted, and filtered manga from the library.
* @param criteria Additional filtering criteria for categories, sources, etc.
* @param pageNum The page number for pagination.
* @param sort The sorting parameter.
* @param filter The filtering parameter.
* @param criteria Additional filtering criteria for categories, sources, etc.
* @return An [OpdsLibraryFeedResult] containing the list of manga, total count, and the specific filter name.
*/
fun getLibraryManga(
criteria: OpdsMangaFilter,
pageNum: Int,
sort: String?,
filter: String?,
criteria: OpdsMangaFilter,
): OpdsLibraryFeedResult =
transaction {
val unreadCountExpr = Case().When(ChapterTable.isRead eq false, intLiteral(1)).Else(intLiteral(0)).sum()
@@ -140,11 +91,22 @@ object MangaRepository {
.join(CategoryMangaTable, JoinType.LEFT, MangaTable.id, CategoryMangaTable.manga)
.select(MangaTable.columns + SourceTable.lang + SourceTable.name + unreadCount)
.where { MangaTable.inLibrary eq true }
.groupBy(MangaTable.id, SourceTable.lang, SourceTable.name)
query.applyOpdsMangaFilter(criteria)
applyMangaLibrarySort(query, sort)
query.groupBy(MangaTable.id, SourceTable.lang, SourceTable.name)
// Apply specific filters from criteria
criteria.sourceId?.let { query.andWhere { MangaTable.sourceReference eq it } }
criteria.categoryId?.let { query.andWhere { CategoryMangaTable.category eq it } }
criteria.statusId?.let { query.andWhere { MangaTable.status eq it } }
criteria.langCode?.let { query.andWhere { SourceTable.lang eq it } }
criteria.genre?.let { genre ->
val genreTrimmed = genre.trim()
val genreCondition =
(MangaTable.genre like "%, $genreTrimmed, %") or
(MangaTable.genre like "$genreTrimmed, %") or
(MangaTable.genre like "%, $genreTrimmed") or
(MangaTable.genre eq genreTrimmed)
query.andWhere { genreCondition }
}
// Efficiently get the name of the primary filter item
val specificFilterName =
@@ -152,10 +114,10 @@ object MangaRepository {
PrimaryFilterType.SOURCE -> {
criteria.sourceId?.let {
SourceTable
.select(SourceTable.name, SourceTable.lang)
.select(SourceTable.name)
.where { SourceTable.id eq it }
.firstOrNull()
?.let { formatSourceName(it[SourceTable.name], it[SourceTable.lang]) }
?.get(SourceTable.name)
}
}
@@ -188,6 +150,8 @@ object MangaRepository {
}
}
applyMangaLibrarySortAndFilter(query, sort, filter)
val totalCount = query.count()
val mangas =
query
@@ -281,7 +245,6 @@ object MangaRepository {
*/
fun getMangaDetails(mangaId: Int): OpdsMangaDetails? =
transaction {
val chapterCount = ChapterTable.select(ChapterTable.id).where { ChapterTable.manga eq mangaId }.count()
MangaTable
.select(MangaTable.id, MangaTable.title, MangaTable.thumbnail_url, MangaTable.author)
.where { MangaTable.id eq mangaId }
@@ -292,7 +255,6 @@ object MangaRepository {
title = it[MangaTable.title],
thumbnailUrl = it[MangaTable.thumbnail_url],
author = it[MangaTable.author],
totalChapters = chapterCount,
)
}
}
@@ -303,14 +265,24 @@ object MangaRepository {
* @param sort The sorting parameter.
* @param filter The filtering parameter.
*/
private fun applyMangaLibrarySort(
private fun applyMangaLibrarySortAndFilter(
query: Query,
sort: String?,
filter: String?,
) {
val unreadCountExpr = Case().When(ChapterTable.isRead eq false, intLiteral(1)).Else(intLiteral(0)).sum()
val downloadedCountExpr = Case().When(ChapterTable.isDownloaded eq true, intLiteral(1)).Else(intLiteral(0)).sum()
val lastReadAtExpr = ChapterTable.lastReadAt.max()
val latestChapterDateExpr = ChapterTable.date_upload.max()
// Apply filtering using HAVING for aggregate functions or WHERE for direct columns
when (filter) {
"unread" -> query.having { unreadCountExpr greater 0 }
"downloaded" -> query.having { downloadedCountExpr greater 0 }
"ongoing" -> query.andWhere { MangaTable.status eq MangaStatus.ONGOING.value }
"completed" -> query.andWhere { MangaTable.status eq MangaStatus.COMPLETED.value }
}
// Apply sorting
when (sort) {
"alpha_asc" -> query.orderBy(MangaTable.title to SortOrder.ASC)
@@ -324,11 +296,10 @@ object MangaRepository {
}
/**
* Calculates the count of manga for various library filter facets, respecting other active cross-filters.
* @param activeFilters The currently active filters to respect during count calculation.
* Calculates the count of manga for various library filter facets.
* @return A map where keys are filter names and values are the counts.
*/
fun getLibraryFilterCounts(activeFilters: OpdsMangaFilter): Map<String, Long> =
fun getLibraryFilterCounts(): Map<String, Long> =
transaction {
val unreadCountExpr = Case().When(ChapterTable.isRead eq false, intLiteral(1)).Else(intLiteral(0)).sum()
val downloadedCountExpr = Case().When(ChapterTable.isDownloaded eq true, intLiteral(1)).Else(intLiteral(0)).sum()
@@ -336,28 +307,14 @@ object MangaRepository {
val baseQuery =
MangaTable
.join(ChapterTable, JoinType.LEFT, MangaTable.id, ChapterTable.manga)
.join(SourceTable, JoinType.INNER, MangaTable.sourceReference, SourceTable.id)
.join(CategoryMangaTable, JoinType.LEFT, MangaTable.id, CategoryMangaTable.manga)
.select(MangaTable.id)
.where { MangaTable.inLibrary eq true }
baseQuery.applyOpdsMangaFilter(activeFilters, excludeField = "filter")
baseQuery.groupBy(MangaTable.id)
.groupBy(MangaTable.id)
val unreadCount = baseQuery.copy().having { unreadCountExpr greater 0 }.count()
val downloadedCount = baseQuery.copy().having { downloadedCountExpr greater 0 }.count()
val statusBaseQuery =
MangaTable
.join(SourceTable, JoinType.INNER, MangaTable.sourceReference, SourceTable.id)
.join(CategoryMangaTable, JoinType.LEFT, MangaTable.id, CategoryMangaTable.manga)
.join(ChapterTable, JoinType.LEFT, MangaTable.id, ChapterTable.manga)
.select(MangaTable.id)
.where { MangaTable.inLibrary eq true }
statusBaseQuery.applyOpdsMangaFilter(activeFilters, excludeField = "filter")
statusBaseQuery.groupBy(MangaTable.id)
val statusBaseQuery = MangaTable.select(MangaTable.id).where { MangaTable.inLibrary eq true }
val ongoingCount = statusBaseQuery.copy().andWhere { MangaTable.status eq MangaStatus.ONGOING.value }.count()
val completedCount = statusBaseQuery.copy().andWhere { MangaTable.status eq MangaStatus.COMPLETED.value }.count()

View File

@@ -13,7 +13,6 @@ import suwayomi.tachidesk.i18n.MR
import suwayomi.tachidesk.manga.impl.extension.Extension
import suwayomi.tachidesk.manga.model.table.CategoryMangaTable
import suwayomi.tachidesk.manga.model.table.CategoryTable
import suwayomi.tachidesk.manga.model.table.ChapterTable
import suwayomi.tachidesk.manga.model.table.ExtensionTable
import suwayomi.tachidesk.manga.model.table.MangaStatus
import suwayomi.tachidesk.manga.model.table.MangaTable
@@ -22,12 +21,10 @@ import suwayomi.tachidesk.opds.constants.OpdsConstants
import suwayomi.tachidesk.opds.dto.OpdsCategoryNavEntry
import suwayomi.tachidesk.opds.dto.OpdsGenreNavEntry
import suwayomi.tachidesk.opds.dto.OpdsLanguageNavEntry
import suwayomi.tachidesk.opds.dto.OpdsMangaFilter
import suwayomi.tachidesk.opds.dto.OpdsRootNavEntry
import suwayomi.tachidesk.opds.dto.OpdsSourceNavEntry
import suwayomi.tachidesk.opds.dto.OpdsStatusNavEntry
import suwayomi.tachidesk.opds.util.OpdsStringUtil.encodeForOpdsURL
import suwayomi.tachidesk.opds.util.OpdsStringUtil.formatSourceName
import suwayomi.tachidesk.server.serverConfig
import java.util.Locale
@@ -134,14 +131,15 @@ object NavigationRepository {
)
}
// ... (El resto del archivo permanece sin cambios)
fun getExploreSources(pageNum: Int): Pair<List<OpdsSourceNavEntry>, Long> =
transaction {
val query =
SourceTable
.join(ExtensionTable, JoinType.LEFT, onColumn = SourceTable.extension, otherColumn = ExtensionTable.id)
.select(SourceTable.id, SourceTable.name, SourceTable.lang, ExtensionTable.apkName)
.select(SourceTable.id, SourceTable.name, ExtensionTable.apkName)
.where { ExtensionTable.isInstalled eq true }
.groupBy(SourceTable.id, SourceTable.name, SourceTable.lang, ExtensionTable.apkName)
.groupBy(SourceTable.id, SourceTable.name, ExtensionTable.apkName)
.orderBy(SourceTable.name to SortOrder.ASC)
val totalCount = query.count()
@@ -152,7 +150,7 @@ object NavigationRepository {
.map {
OpdsSourceNavEntry(
id = it[SourceTable.id].value,
name = formatSourceName(it[SourceTable.name], it[SourceTable.lang]),
name = it[SourceTable.name],
iconUrl = it[ExtensionTable.apkName].let { apkName -> Extension.getExtensionIconUrl(apkName) },
mangaCount = null,
)
@@ -160,10 +158,7 @@ object NavigationRepository {
Pair(sources, totalCount)
}
fun getLibrarySources(
pageNum: Int? = null,
activeFilters: OpdsMangaFilter = OpdsMangaFilter(),
): Pair<List<OpdsSourceNavEntry>, Long> =
fun getLibrarySources(pageNum: Int): Pair<List<OpdsSourceNavEntry>, Long> =
transaction {
val mangaCount = MangaTable.id.countDistinct().alias("manga_count")
@@ -171,55 +166,28 @@ object NavigationRepository {
SourceTable
.join(MangaTable, JoinType.INNER, SourceTable.id, MangaTable.sourceReference)
.join(ExtensionTable, JoinType.LEFT, onColumn = SourceTable.extension, otherColumn = ExtensionTable.id)
.join(CategoryMangaTable, JoinType.LEFT, MangaTable.id, CategoryMangaTable.manga)
.join(ChapterTable, JoinType.LEFT, MangaTable.id, ChapterTable.manga)
.select(SourceTable.id, SourceTable.name, SourceTable.lang, ExtensionTable.apkName, mangaCount)
.select(SourceTable.id, SourceTable.name, ExtensionTable.apkName, mangaCount)
.where { MangaTable.inLibrary eq true }
query.applyOpdsMangaFilter(activeFilters, excludeField = "source_id")
query
.groupBy(SourceTable.id, SourceTable.name, SourceTable.lang, ExtensionTable.apkName)
.orderBy(SourceTable.name to SortOrder.ASC)
.groupBy(SourceTable.id, SourceTable.name, ExtensionTable.apkName)
.orderBy(SourceTable.name to SortOrder.ASC)
val totalCount = query.count()
if (pageNum != null) {
val sources =
query
.limit(opdsItemsPerPageBounded)
.offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong())
}
val sources =
query.map {
OpdsSourceNavEntry(
id = it[SourceTable.id].value,
name = formatSourceName(it[SourceTable.name], it[SourceTable.lang]),
iconUrl = it[ExtensionTable.apkName].let { apkName -> Extension.getExtensionIconUrl(apkName) },
mangaCount = it[mangaCount],
)
}
.map {
OpdsSourceNavEntry(
id = it[SourceTable.id].value,
name = it[SourceTable.name],
iconUrl = it[ExtensionTable.apkName].let { apkName -> Extension.getExtensionIconUrl(apkName) },
mangaCount = it[mangaCount],
)
}
Pair(sources, totalCount)
}
fun getSourceDetails(sourceId: Long): Pair<String, String?>? =
transaction {
SourceTable
.join(ExtensionTable, JoinType.LEFT, onColumn = SourceTable.extension, otherColumn = ExtensionTable.id)
.select(SourceTable.name, SourceTable.lang, ExtensionTable.apkName)
.where { SourceTable.id eq sourceId }
.firstOrNull()
?.let {
val name = formatSourceName(it[SourceTable.name], it[SourceTable.lang])
val icon = Extension.getExtensionIconUrl(it[ExtensionTable.apkName])
Pair(name, icon)
}
}
fun getCategories(
pageNum: Int? = null,
activeFilters: OpdsMangaFilter = OpdsMangaFilter(),
): Pair<List<OpdsCategoryNavEntry>, Long> =
fun getCategories(pageNum: Int): Pair<List<OpdsCategoryNavEntry>, Long> =
transaction {
val mangaCount = MangaTable.id.countDistinct().alias("manga_count")
@@ -227,54 +195,35 @@ object NavigationRepository {
CategoryTable
.join(CategoryMangaTable, JoinType.INNER, CategoryTable.id, CategoryMangaTable.category)
.join(MangaTable, JoinType.INNER, CategoryMangaTable.manga, MangaTable.id)
.join(SourceTable, JoinType.INNER, MangaTable.sourceReference, SourceTable.id)
.join(ChapterTable, JoinType.LEFT, MangaTable.id, ChapterTable.manga)
.select(CategoryTable.id, CategoryTable.name, mangaCount)
.where { MangaTable.inLibrary eq true }
query.applyOpdsMangaFilter(activeFilters, excludeField = "category_id")
query
.groupBy(CategoryTable.id, CategoryTable.name)
.orderBy(CategoryTable.order to SortOrder.ASC)
.groupBy(CategoryTable.id, CategoryTable.name)
.orderBy(CategoryTable.order to SortOrder.ASC)
val totalCount = query.count()
if (pageNum != null) {
val categories =
query
.limit(opdsItemsPerPageBounded)
.offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong())
}
val categories =
query.map {
OpdsCategoryNavEntry(
id = it[CategoryTable.id].value,
name = it[CategoryTable.name],
mangaCount = it[mangaCount],
)
}
.map {
OpdsCategoryNavEntry(
id = it[CategoryTable.id].value,
name = it[CategoryTable.name],
mangaCount = it[mangaCount],
)
}
Pair(categories, totalCount)
}
fun getGenres(
pageNum: Int,
locale: Locale,
pageNum: Int? = null,
activeFilters: OpdsMangaFilter = OpdsMangaFilter(),
): Pair<List<OpdsGenreNavEntry>, Long> =
transaction {
val query =
val allGenres =
MangaTable
.join(SourceTable, JoinType.INNER, MangaTable.sourceReference, SourceTable.id)
.join(CategoryMangaTable, JoinType.LEFT, MangaTable.id, CategoryMangaTable.manga)
.join(ChapterTable, JoinType.LEFT, MangaTable.id, ChapterTable.manga)
.select(MangaTable.genre)
.where { MangaTable.inLibrary eq true }
query.applyOpdsMangaFilter(activeFilters, excludeField = "genre")
val allGenres =
query
.mapNotNull { it[MangaTable.genre] }
.flatMap { it.split(",").map(String::trim).filterNot(String::isBlank) }
@@ -282,32 +231,21 @@ object NavigationRepository {
val distinctGenres = genreCounts.keys.sorted()
val totalCount = distinctGenres.size.toLong()
val finalGenres =
if (pageNum != null) {
val fromIndex = ((pageNum - 1) * opdsItemsPerPageBounded)
val toIndex = minOf(fromIndex + opdsItemsPerPageBounded, distinctGenres.size)
if (fromIndex < distinctGenres.size) distinctGenres.subList(fromIndex, toIndex) else emptyList()
} else {
distinctGenres
}
val fromIndex = ((pageNum - 1) * opdsItemsPerPageBounded)
val toIndex = minOf(fromIndex + opdsItemsPerPageBounded, distinctGenres.size)
val paginatedGenres =
finalGenres.map { genreName ->
OpdsGenreNavEntry(
id = genreName.encodeForOpdsURL(),
title = genreName,
mangaCount = genreCounts[genreName]?.toLong() ?: 0L,
)
}
(if (fromIndex < distinctGenres.size) distinctGenres.subList(fromIndex, toIndex) else emptyList())
.map { genreName ->
OpdsGenreNavEntry(
id = genreName.encodeForOpdsURL(),
title = genreName,
mangaCount = genreCounts[genreName]?.toLong() ?: 0L,
)
}
Pair(paginatedGenres, totalCount)
}
fun getStatuses(
locale: Locale,
pageNum: Int? = null,
activeFilters: OpdsMangaFilter = OpdsMangaFilter(),
): Pair<List<OpdsStatusNavEntry>, Long> {
fun getStatuses(locale: Locale): List<OpdsStatusNavEntry> {
val statusStringResources: Map<MangaStatus, StringResource> =
mapOf(
MangaStatus.UNKNOWN to MR.strings.manga_status_unknown,
@@ -321,88 +259,43 @@ object NavigationRepository {
val statusCounts =
transaction {
val countExpr = MangaTable.id.countDistinct().alias("manga_count")
val query =
MangaTable
.join(SourceTable, JoinType.INNER, MangaTable.sourceReference, SourceTable.id)
.join(CategoryMangaTable, JoinType.LEFT, MangaTable.id, CategoryMangaTable.manga)
.join(ChapterTable, JoinType.LEFT, MangaTable.id, ChapterTable.manga)
.select(MangaTable.status, countExpr)
.where { MangaTable.inLibrary eq true }
query.applyOpdsMangaFilter(activeFilters, excludeField = "status_id")
query
MangaTable
.select(MangaTable.status, MangaTable.id.count())
.where { MangaTable.inLibrary eq true }
.groupBy(MangaTable.status)
.associate { it[MangaTable.status] to it[countExpr] }
.associate { it[MangaTable.status] to it[MangaTable.id.count()] }
}
val allStatuses =
MangaStatus.entries
.map { mangaStatus ->
val titleRes = statusStringResources[mangaStatus] ?: MR.strings.manga_status_unknown
OpdsStatusNavEntry(
id = mangaStatus.value,
title = titleRes.localized(locale),
mangaCount = statusCounts[mangaStatus.value] ?: 0L,
)
}.sortedBy { it.id }
val totalCount = allStatuses.size.toLong()
val paginatedStatuses =
if (pageNum != null) {
val fromIndex = ((pageNum - 1) * opdsItemsPerPageBounded)
val toIndex = minOf(fromIndex + opdsItemsPerPageBounded, allStatuses.size)
if (fromIndex < allStatuses.size) allStatuses.subList(fromIndex, toIndex) else emptyList()
} else {
allStatuses
}
return Pair(paginatedStatuses, totalCount)
return MangaStatus.entries
.map { mangaStatus ->
val titleRes = statusStringResources[mangaStatus] ?: MR.strings.manga_status_unknown
OpdsStatusNavEntry(
id = mangaStatus.value,
title = titleRes.localized(locale),
mangaCount = statusCounts[mangaStatus.value] ?: 0L,
)
}.sortedBy { it.id }
}
fun getContentLanguages(
locale: Locale,
pageNum: Int? = null,
activeFilters: OpdsMangaFilter = OpdsMangaFilter(),
): Pair<List<OpdsLanguageNavEntry>, Long> =
fun getContentLanguages(uiLocale: Locale): List<OpdsLanguageNavEntry> =
transaction {
val mangaCount = MangaTable.id.countDistinct().alias("manga_count")
val query =
SourceTable
.join(MangaTable, JoinType.INNER, SourceTable.id, MangaTable.sourceReference)
.join(CategoryMangaTable, JoinType.LEFT, MangaTable.id, CategoryMangaTable.manga)
.join(ChapterTable, JoinType.LEFT, MangaTable.id, ChapterTable.manga)
.select(SourceTable.lang, mangaCount)
.where { MangaTable.inLibrary eq true }
query.applyOpdsMangaFilter(activeFilters, excludeField = "lang_code")
query
SourceTable
.join(MangaTable, JoinType.INNER, SourceTable.id, MangaTable.sourceReference)
.select(SourceTable.lang, mangaCount)
.where { MangaTable.inLibrary eq true }
.groupBy(SourceTable.lang)
.orderBy(SourceTable.lang to SortOrder.ASC)
val totalCount = query.count()
if (pageNum != null) {
query
.limit(opdsItemsPerPageBounded)
.offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong())
}
val languages =
query.map {
.map {
val langCode = it[SourceTable.lang]
OpdsLanguageNavEntry(
id = langCode,
title =
Locale.forLanguageTag(langCode).getDisplayName(locale).replaceFirstChar { char ->
if (char.isLowerCase()) char.titlecase(locale) else char.toString()
Locale.forLanguageTag(langCode).getDisplayName(uiLocale).replaceFirstChar { char ->
if (char.isLowerCase()) char.titlecase(uiLocale) else char.toString()
},
mangaCount = it[mangaCount],
)
}
Pair(languages, totalCount)
}
}

View File

@@ -33,19 +33,6 @@ object OpdsStringUtil {
return slug
}
/**
* Formats the source name appending the language code if applicable.
*/
fun formatSourceName(
name: String,
lang: String?,
): String =
if (lang.isNullOrBlank() || lang == "all") {
name
} else {
"$name (${lang.uppercase()})"
}
/**
* Formats a size in bytes to a human-readable representation.
* Uses binary (KiB, MiB, GiB, TiB) or decimal (KB, MB, GB, TB) units based on server configuration.

View File

@@ -5,8 +5,6 @@ import android.content.Context
import io.github.oshai.kotlinlogging.KotlinLogging
import suwayomi.tachidesk.manga.impl.update.IUpdater
import suwayomi.tachidesk.server.database.H2Migration
import suwayomi.tachidesk.server.util.ExitCode
import suwayomi.tachidesk.server.util.shutdownApp
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.io.File
@@ -101,35 +99,31 @@ private val MIGRATIONS =
fun runMigrations(applicationDirs: ApplicationDirs) {
val logger = KotlinLogging.logger("Migration")
try {
val migrationPreferences =
Injekt
.get<Application>()
.getSharedPreferences(
"migrations",
Context.MODE_PRIVATE,
)
val version = migrationPreferences.getInt("version", 0)
logger.info { "Running migrations, previous version $version, target version ${MIGRATIONS.size}" }
val migrationPreferences =
Injekt
.get<Application>()
.getSharedPreferences(
"migrations",
Context.MODE_PRIVATE,
)
val version = migrationPreferences.getInt("version", 0)
MIGRATIONS.forEachIndexed { index, (migrationName, migrationFunction) ->
val migrationVersion = index + 1
logger.info { "Running migrations, previous version $version, target version ${MIGRATIONS.size}" }
val isMigrationRequired = version < migrationVersion
if (!isMigrationRequired) {
logger.info { "Skipping migration version $migrationVersion: $migrationName" }
return@forEachIndexed
}
MIGRATIONS.forEachIndexed { index, (migrationName, migrationFunction) ->
val migrationVersion = index + 1
logger.info { "Running migration version $migrationVersion: $migrationName" }
migrationFunction(applicationDirs)
migrationPreferences.edit().putInt("version", migrationVersion).apply()
val isMigrationRequired = version < migrationVersion
if (!isMigrationRequired) {
logger.info { "Skipping migration version $migrationVersion: $migrationName" }
return@forEachIndexed
}
} catch (e: Exception) {
logger.error(e) { "Failed to run migrations" }
shutdownApp(ExitCode.MigrationsRunFailure)
logger.info { "Running migration version $migrationVersion: $migrationName" }
migrationFunction(applicationDirs)
migrationPreferences.edit().putInt("version", migrationVersion).apply()
}
}

View File

@@ -14,13 +14,15 @@ import com.typesafe.config.ConfigException
import com.typesafe.config.ConfigRenderOptions
import com.typesafe.config.ConfigValue
import com.typesafe.config.parser.ConfigDocument
import dev.datlag.kcef.KCEF
import dev.datlag.kcef.KCEFBuilder.Settings.LogSeverity
import eu.kanade.tachiyomi.App
import eu.kanade.tachiyomi.createAppModule
import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.source.local.LocalSource
import io.github.config4k.toConfig
import io.github.oshai.kotlinlogging.KotlinLogging
import io.javalin.json.JavalinJackson3
import io.javalin.json.JavalinJackson
import io.javalin.json.JsonMapper
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
@@ -36,7 +38,6 @@ import org.koin.core.context.startKoin
import org.koin.core.module.Module
import org.koin.dsl.module
import suwayomi.tachidesk.global.impl.KcefWebView.Companion.toCefCookie
import suwayomi.tachidesk.global.impl.sync.SyncManager
import suwayomi.tachidesk.graphql.types.DatabaseType
import suwayomi.tachidesk.i18n.LocalizationHelper
import suwayomi.tachidesk.manga.impl.backup.proto.ProtoBackupExport
@@ -48,7 +49,6 @@ import suwayomi.tachidesk.server.database.databaseUp
import suwayomi.tachidesk.server.generated.BuildConfig
import suwayomi.tachidesk.server.settings.SettingsRegistry
import suwayomi.tachidesk.server.util.AppMutex.handleAppMutex
import suwayomi.tachidesk.server.util.CEFManager
import suwayomi.tachidesk.server.util.ConfigTypeRegistration
import suwayomi.tachidesk.server.util.ExitCode
import suwayomi.tachidesk.server.util.SystemTray
@@ -71,6 +71,11 @@ import java.net.Authenticator
import java.net.PasswordAuthentication
import java.security.Security
import java.util.Locale
import kotlin.concurrent.thread
import kotlin.io.path.Path
import kotlin.io.path.createDirectories
import kotlin.io.path.div
import kotlin.math.roundToInt
private val logger = KotlinLogging.logger {}
@@ -217,7 +222,7 @@ fun serverModule(applicationDirs: ApplicationDirs): Module =
module {
single { applicationDirs }
single<IUpdater> { Updater() }
single<JsonMapper> { JavalinJackson3() }
single<JsonMapper> { JavalinJackson() }
}
@OptIn(DelicateCoroutinesApi::class)
@@ -361,7 +366,6 @@ fun applicationSetup() {
}
} catch (e: Exception) {
logger.error(e) { "Exception while creating initial server.conf" }
shutdownApp(ExitCode.SetupConfFileFailed)
}
// copy local source icon
@@ -374,7 +378,6 @@ fun applicationSetup() {
}
} catch (e: Exception) {
logger.error(e) { "Exception while copying Local source's icon" }
shutdownApp(ExitCode.LocalSourceIconCopyFailure)
}
// fixes #119 , ref:
@@ -392,12 +395,7 @@ fun applicationSetup() {
databaseUp()
try {
LocalSource.register()
} catch (e: Exception) {
logger.error(e) { "Failed to setup LocalSource" }
shutdownApp(ExitCode.LocalSourceSetupFailure)
}
LocalSource.register()
serverConfig.subscribeTo(
combine<Any, DatabaseSettings>(
@@ -513,10 +511,56 @@ fun applicationSetup() {
// start DownloadManager and restore + resume downloads
DownloadManager.restoreAndResumeDownloads()
SyncManager.scheduleSyncTask()
// asynchronously initialize CEF
GlobalScope.launch {
CEFManager.init()
val logger = KotlinLogging.logger("KCEF")
KCEF.init(
builder = {
progress {
var lastNum = -1
onDownloading {
val num = it.roundToInt()
if (num > lastNum) {
lastNum = num
logger.info { "KCEF download progress: $num%" }
}
}
}
download { github() }
settings {
windowlessRenderingEnabled = true
cachePath = (Path(applicationDirs.dataRoot) / "cache/kcef").toString()
logSeverity = if (serverConfig.debugLogsEnabled.value) LogSeverity.Verbose else LogSeverity.Default
}
appHandler(
KCEF.AppHandler(
arrayOf(
"--disable-gpu",
// #1486 needed to be able to render without a window
"--off-screen-rendering-enabled",
// #1489 since /dev/shm is restricted in docker (OOM)
"--disable-dev-shm-usage",
// #1723 support Widevine (incomplete)
"--enable-widevine-cdm",
// #1736 JCEF does implement stack guards properly
"--change-stack-guard-on-fork=disable",
),
),
)
val kcefDir = Path(applicationDirs.dataRoot) / "bin/kcef"
kcefDir.createDirectories()
installDir(kcefDir.toFile())
},
onError = { it?.printStackTrace() },
)
}
Runtime.getRuntime().addShutdownHook(
thread(start = false) {
val logger = KotlinLogging.logger("KCEF")
logger.debug { "Shutting down KCEF" }
KCEF.disposeBlocking()
logger.debug { "KCEF shutdown complete" }
},
)
}

View File

@@ -27,6 +27,7 @@ import suwayomi.tachidesk.server.util.ExitCode
import suwayomi.tachidesk.server.util.shutdownApp
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.sql.SQLException
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
@@ -144,15 +145,14 @@ object DBManager {
private val logger = KotlinLogging.logger {}
fun databaseUp(givenDb: Database? = null) {
fun databaseUp() {
val db =
givenDb
?: try {
DBManager.setupDatabase()
} catch (e: Exception) {
logger.error(e) { "Failed to setup Database" }
return
}
try {
DBManager.setupDatabase()
} catch (e: Exception) {
logger.error(e) { "Failed to setup Database" }
return
}
logger.info {
"Using ${db.vendor} database version ${db.version}"
@@ -184,8 +184,10 @@ fun databaseUp(givenDb: Database? = null) {
}
val migrations = loadMigrationsFrom("suwayomi.tachidesk.server.database.migration", ServerConfig::class.java)
runMigrations(migrations)
} catch (e: Exception) {
} catch (e: SQLException) {
logger.error(e) { "Error up-to-database migration" }
shutdownApp(ExitCode.DbMigrationFailure)
if (System.getProperty("crashOnFailedMigration").toBoolean()) {
shutdownApp(ExitCode.DbMigrationFailure)
}
}
}

View File

@@ -9,12 +9,9 @@ import java.net.URLClassLoader
import java.nio.file.Path
import kotlin.io.path.Path
import kotlin.io.path.absolutePathString
import kotlin.io.path.bufferedReader
import kotlin.io.path.bufferedWriter
import kotlin.io.path.copyTo
import kotlin.io.path.createDirectories
import kotlin.io.path.deleteExisting
import kotlin.io.path.deleteIfExists
import kotlin.io.path.div
import kotlin.io.path.exists
import kotlin.io.path.name
@@ -49,10 +46,6 @@ object H2Migration {
}
val script = Path("$dbBase.${h2Old.substringAfterLast('.')}.sql")
script.deleteIfExists()
val modifiedScript = Path("$dbBase.${h2Old.substringAfterLast('.')}.modified.sql")
modifiedScript.deleteIfExists()
// Backup original database.
val backup = Path("$dbBase.mv.db.${h2Old.substringAfterLast('.')}.backup")
@@ -79,32 +72,19 @@ object H2Migration {
libsDir.resolve("h2-$h2New.bin"),
)
// Delete attempted migration if failed previously
val newDatabase = Path(rootDir, "database.${h2New.substringAfterLast('.')}.mv.db")
newDatabase.deleteIfExists()
val modifiedNewDatabase = Path(rootDir, "database.${h2Old.substringAfterLast('.')}.modified.${h2New.substringAfterLast('.')}.mv.db")
modifiedNewDatabase.deleteIfExists()
runMigrationTool(
migrationJar = migrationJar,
libsDir = libsDir,
mvStore = mvStore,
script = script,
modifiedScript = modifiedScript,
h2Old = h2Old,
h2New = h2New,
)
// Move database to proper path
if (modifiedNewDatabase.exists()) {
modifiedNewDatabase.copyTo(mvStore, overwrite = true)
modifiedNewDatabase.deleteExisting()
newDatabase.deleteIfExists()
} else {
newDatabase.copyTo(mvStore, overwrite = true)
newDatabase.deleteExisting()
}
val newDatabase = Path(rootDir, "database.${h2New.substringAfterLast('.')}.mv.db")
newDatabase.copyTo(mvStore, overwrite = true)
newDatabase.deleteExisting()
logger.info { "H2 migration completed successfully." }
}
@@ -143,7 +123,6 @@ object H2Migration {
libsDir: Path,
mvStore: Path,
script: Path,
modifiedScript: Path,
h2Old: String,
h2New: String,
) {
@@ -157,77 +136,32 @@ object H2Migration {
val main =
clazz.getMethod("main", Array<String>::class.java)
try {
main.invoke(
null,
arrayOf(
// h2 driver dir
"-l",
libsDir.absolutePathString(),
// from version
"-f",
h2Old,
// to version
"-t",
h2New,
// user
"-u",
"",
// password
"-p",
"",
// database.mv.db
"-d",
mvStore.absolutePathString(),
// database backup in SQL
"-s",
script.absolutePathString(),
),
)
} catch (e: Exception) {
// Modify raw .sql file as needed for compatibility
if (e.stackTraceToString().contains("Unknown data type: \"DATETIME\"; SQL statement:") && script.exists()) {
script.bufferedReader().use { reader ->
modifiedScript.bufferedWriter().use { writer ->
reader.forEachLine { line ->
writer.write(
line.replace(
" \"EXECUTED_AT\" DATETIME(9) NOT NULL",
" \"EXECUTED_AT\" TIMESTAMP(9) NOT NULL",
),
)
writer.newLine()
}
}
}
main.invoke(
null,
arrayOf(
// h2 driver dir
"-l",
libsDir.absolutePathString(),
// from version
"-f",
h2Old,
// to version
"-t",
h2New,
// user
"-u",
"",
// password
"-p",
"",
// database.mv.db
"-d",
modifiedScript.absolutePathString(),
),
)
} else {
throw e
}
}
main.invoke(
null,
arrayOf(
// h2 driver dir
"-l",
libsDir.absolutePathString(),
// from version
"-f",
h2Old,
// to version
"-t",
h2New,
// user
"-u",
"",
// password
"-p",
"",
// database.mv.db
"-d",
mvStore.absolutePathString(),
// database backup in SQL
"-s",
script.absolutePathString(),
),
)
}
}
}

View File

@@ -10,10 +10,17 @@ package suwayomi.tachidesk.server.database.migration
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import de.neonew.exposed.migrations.helpers.SQLMigration
import suwayomi.tachidesk.server.database.migration.helpers.toSqlName
import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager
@Suppress("ClassName", "unused")
class M0023_CategoryMetaRefFix : SQLMigration() {
fun String.toSqlName(): String =
TransactionManager.defaultDatabase!!.identifierManager.let {
it.quoteIfNecessary(
it.inProperCase(this),
)
}
private val CategoryMetaTable by lazy { "CategoryMeta".toSqlName() }
private val CategoryRefColumn by lazy { "category_ref".toSqlName() }
private val CategoryTable by lazy { "Category".toSqlName() }

View File

@@ -8,7 +8,6 @@ package suwayomi.tachidesk.server.database.migration
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import de.neonew.exposed.migrations.helpers.SQLMigration
import suwayomi.tachidesk.server.database.migration.helpers.toSqlName
@Suppress("ClassName", "unused")
class M0049_FixDuplicatedMetas : SQLMigration() {
@@ -16,7 +15,7 @@ class M0049_FixDuplicatedMetas : SQLMigration() {
table: String,
refColumn: String? = null,
): String {
val groupBy = listOfNotNull(refColumn, "KEY".toSqlName()).joinToString(", ")
val groupBy = listOfNotNull(refColumn, "KEY").joinToString(", ")
return """
DELETE FROM $table
@@ -31,11 +30,10 @@ class M0049_FixDuplicatedMetas : SQLMigration() {
""".trimIndent()
}
override val sql: String by lazy {
override val sql: String =
createMigrationForTable("CATEGORYMETA", "CATEGORY_REF") +
createMigrationForTable("CHAPTERMETA", "CHAPTER_REF") +
createMigrationForTable("GLOBALMETA") +
createMigrationForTable("MANGAMETA", "MANGA_REF") +
createMigrationForTable("SOURCEMETA", "SOURCE_REF")
}
}

View File

@@ -1,38 +0,0 @@
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
import suwayomi.tachidesk.graphql.types.DatabaseType
import suwayomi.tachidesk.server.database.migration.helpers.toSqlName
import suwayomi.tachidesk.server.serverConfig
@Suppress("ClassName", "unused")
class M0055_RenameMetaKeys : SQLMigration() {
fun postgresRename(table: String): String =
"ALTER TABLE $table " +
"RENAME COLUMN " + "KEY".toSqlName() + " TO META_KEY;"
fun h2Rename(table: String): String =
"ALTER TABLE $table " +
"ALTER COLUMN " + "KEY".toSqlName() + " RENAME TO META_KEY;"
fun createRenameMigration(table: String): String =
when (serverConfig.databaseType.value) {
DatabaseType.H2 -> h2Rename(table.toSqlName())
DatabaseType.POSTGRESQL -> postgresRename(table.toSqlName())
}
override val sql: String by lazy {
createRenameMigration("CATEGORYMETA") +
createRenameMigration("CHAPTERMETA") +
createRenameMigration("GLOBALMETA") +
createRenameMigration("MANGAMETA") +
createRenameMigration("SOURCEMETA")
}
}

View File

@@ -1,224 +0,0 @@
package suwayomi.tachidesk.server.database.migration
import de.neonew.exposed.migrations.helpers.SQLMigration
import suwayomi.tachidesk.graphql.types.DatabaseType
import suwayomi.tachidesk.server.serverConfig
@Suppress("ClassName", "unused")
class M0056_SyncYomi : SQLMigration() {
override val sql =
when (serverConfig.databaseType.value) {
DatabaseType.POSTGRESQL -> postgresQuery()
DatabaseType.H2 -> h2Query()
}
// language=postgresql
fun postgresQuery(): String =
"""
ALTER TABLE manga ADD COLUMN version BIGINT NOT NULL DEFAULT 0;
ALTER TABLE manga ADD COLUMN is_syncing BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE manga ADD COLUMN last_modified_at BIGINT NOT NULL DEFAULT 0;
ALTER TABLE chapter ADD COLUMN version BIGINT NOT NULL DEFAULT 0;
ALTER TABLE chapter ADD COLUMN is_syncing BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE chapter ADD COLUMN last_modified_at BIGINT NOT NULL DEFAULT 0;
ALTER TABLE category ADD COLUMN version BIGINT NOT NULL DEFAULT 0;
ALTER TABLE category ADD COLUMN uid BIGINT NOT NULL DEFAULT 0;
ALTER TABLE category ADD COLUMN is_syncing BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE category ADD COLUMN last_modified_at BIGINT NOT NULL DEFAULT 0;
CREATE OR REPLACE FUNCTION update_manga_version()
RETURNS trigger AS $$
BEGIN
IF NOT NEW.is_syncing
AND ROW(NEW.url, NEW.description, NEW.in_library)
IS DISTINCT FROM
ROW(OLD.url, OLD.description, OLD.in_library)
THEN
NEW.version := OLD.version + 1;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER update_manga_version
AFTER UPDATE ON manga
FOR EACH ROW
EXECUTE FUNCTION update_manga_version();
CREATE OR REPLACE FUNCTION update_chapter_and_manga_version()
RETURNS trigger AS $$
BEGIN
IF NOT NEW.is_syncing
AND ROW(NEW.read, NEW.bookmark, NEW.last_page_read)
IS DISTINCT FROM
ROW(OLD.read, OLD.bookmark, OLD.last_page_read)
THEN
NEW.version := OLD.version + 1;
UPDATE manga SET version = version + 1 WHERE id = NEW.manga AND is_syncing = FALSE;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER update_chapter_and_manga_version
AFTER UPDATE ON chapter
FOR EACH ROW
EXECUTE FUNCTION update_chapter_and_manga_version();
CREATE OR REPLACE FUNCTION update_manga_last_modified_at()
RETURNS trigger AS $$
BEGIN
NEW.last_modified_at := EXTRACT(EPOCH FROM NOW());
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER update_manga_last_modified_at
BEFORE UPDATE OR INSERT ON manga
FOR EACH ROW
EXECUTE FUNCTION update_manga_last_modified_at();
CREATE OR REPLACE FUNCTION update_chapter_last_modified_at()
RETURNS trigger AS $$
BEGIN
NEW.last_modified_at := EXTRACT(EPOCH FROM NOW());
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER update_chapter_last_modified_at
BEFORE UPDATE OR INSERT ON chapter
FOR EACH ROW
EXECUTE FUNCTION update_chapter_last_modified_at();
CREATE OR REPLACE FUNCTION insert_manga_category_update_version()
RETURNS trigger AS $$
BEGIN
UPDATE manga SET version = version + 1 WHERE id = NEW.manga AND is_syncing = FALSE;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER insert_manga_category_update_version
AFTER INSERT ON categorymanga
FOR EACH ROW
EXECUTE FUNCTION insert_manga_category_update_version();
CREATE OR REPLACE FUNCTION insert_category_uid()
RETURNS trigger AS $$
BEGIN
IF NEW.uid = 0 THEN
NEW.uid := RANDOM(1, 9223372036854775807);
END IF;
IF NEW.last_modified_at = 0 THEN
NEW.last_modified_at := EXTRACT(EPOCH FROM NOW());
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER insert_category_uid
BEFORE INSERT ON category
FOR EACH ROW
EXECUTE FUNCTION insert_category_uid();
CREATE OR REPLACE FUNCTION update_category_version()
RETURNS trigger AS $$
BEGIN
IF NOT NEW.is_syncing
AND ROW(NEW.name, NEW.sort_order)
IS DISTINCT FROM
ROW(OLD.name, OLD.sort_order)
THEN
NEW.version := NEW.version + 1;
NEW.last_modified_at := EXTRACT(EPOCH FROM NOW());
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER update_category_version
BEFORE UPDATE ON category
FOR EACH ROW
EXECUTE FUNCTION update_category_version();
""".trimIndent()
// language=h2
fun h2Query() =
"""
ALTER TABLE manga ADD COLUMN version BIGINT NOT NULL DEFAULT 0;
ALTER TABLE manga ADD COLUMN is_syncing BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE manga ADD COLUMN last_modified_at BIGINT NOT NULL DEFAULT 0;
ALTER TABLE chapter ADD COLUMN version BIGINT NOT NULL DEFAULT 0;
ALTER TABLE chapter ADD COLUMN is_syncing BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE chapter ADD COLUMN last_modified_at BIGINT NOT NULL DEFAULT 0;
ALTER TABLE category ADD COLUMN version BIGINT NOT NULL DEFAULT 0;
ALTER TABLE category ADD COLUMN uid BIGINT NOT NULL DEFAULT 0;
ALTER TABLE category ADD COLUMN is_syncing BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE category ADD COLUMN last_modified_at BIGINT NOT NULL DEFAULT 0;
CREATE TRIGGER update_manga_version
BEFORE UPDATE ON manga
FOR EACH ROW
CALL "suwayomi.tachidesk.server.database.trigger.UpdateMangaVersionTrigger";
CREATE TRIGGER update_chapter_and_manga_version
BEFORE UPDATE ON chapter
FOR EACH ROW
CALL "suwayomi.tachidesk.server.database.trigger.UpdateChapterAndMangaVersionTrigger";
CREATE TRIGGER update_manga_last_modified_at
BEFORE UPDATE ON manga
FOR EACH ROW
CALL "suwayomi.tachidesk.server.database.trigger.UpdateMangaLastModifiedAtTrigger";
CREATE TRIGGER insert_manga_last_modified_at
BEFORE INSERT ON manga
FOR EACH ROW
CALL "suwayomi.tachidesk.server.database.trigger.UpdateMangaLastModifiedAtTrigger";
CREATE TRIGGER update_chapter_last_modified_at
BEFORE UPDATE ON chapter
FOR EACH ROW
CALL "suwayomi.tachidesk.server.database.trigger.UpdateChapterLastModifiedAtTrigger";
CREATE TRIGGER insert_chapter_last_modified_at
BEFORE INSERT ON chapter
FOR EACH ROW
CALL "suwayomi.tachidesk.server.database.trigger.UpdateChapterLastModifiedAtTrigger";
CREATE TRIGGER insert_manga_category_update_version
AFTER INSERT ON categorymanga
FOR EACH ROW
CALL "suwayomi.tachidesk.server.database.trigger.InsertMangaCategoryUpdateVersionTrigger";
CREATE TRIGGER insert_category_uid
BEFORE INSERT ON category
FOR EACH ROW
CALL "suwayomi.tachidesk.server.database.trigger.InsertCategoryUidTrigger";
CREATE TRIGGER update_category_version
BEFORE UPDATE ON category
FOR EACH ROW
CALL "suwayomi.tachidesk.server.database.trigger.UpdateCategoryVersionTrigger";
""".trimIndent()
}

View File

@@ -1,9 +1,17 @@
package suwayomi.tachidesk.server.database.migration.helpers
import de.neonew.exposed.migrations.helpers.SQLMigration
import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager
import suwayomi.tachidesk.graphql.types.DatabaseType
import suwayomi.tachidesk.server.serverConfig
fun String.toSqlName(): String =
TransactionManager.current().db.identifierManager.let {
it.quoteIfNecessary(
it.inProperCase(this),
)
}
abstract class RenameFieldMigration(
tableName: String,
originalName: String,

View File

@@ -1,10 +0,0 @@
package suwayomi.tachidesk.server.database.migration.helpers
import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager
fun String.toSqlName(): String =
TransactionManager.current().db.identifierManager.let {
it.quoteIfNecessary(
it.inProperCase(this),
)
}

View File

@@ -1,141 +0,0 @@
package suwayomi.tachidesk.server.database.trigger
import org.h2.tools.TriggerAdapter
import java.sql.Connection
import java.sql.ResultSet
import kotlin.random.Random
import kotlin.time.Clock
@Suppress("unused")
class UpdateMangaVersionTrigger : TriggerAdapter() {
override fun fire(
conn: Connection,
oldRow: ResultSet,
newRow: ResultSet,
) {
val isSyncing = newRow.getBoolean("is_syncing")
val hasChanged =
oldRow.getString("url") != newRow.getString("url") ||
oldRow.getString("description") != newRow.getString("description") ||
oldRow.getBoolean("in_library") != newRow.getBoolean("in_library")
if (!isSyncing && hasChanged) {
val currentVersion = newRow.getLong("version")
newRow.updateLong("version", currentVersion + 1)
}
}
}
@Suppress("unused")
class UpdateChapterAndMangaVersionTrigger : TriggerAdapter() {
override fun fire(
conn: Connection,
oldRow: ResultSet,
newRow: ResultSet,
) {
val isSyncing = newRow.getBoolean("is_syncing")
val hasChanged =
oldRow.getBoolean("read") != newRow.getBoolean("read") ||
oldRow.getBoolean("bookmark") != newRow.getBoolean("bookmark") ||
oldRow.getInt("last_page_read") != newRow.getInt("last_page_read")
if (!isSyncing && hasChanged) {
val currentVersion = newRow.getLong("version")
newRow.updateLong("version", currentVersion + 1)
val mangaId = newRow.getInt("manga")
conn
.prepareStatement(
"UPDATE MANGA SET version = version + 1 WHERE id = ? AND NOT is_syncing",
).use {
it.setInt(1, mangaId)
it.executeUpdate()
}
}
}
}
@Suppress("unused")
class UpdateMangaLastModifiedAtTrigger : TriggerAdapter() {
override fun fire(
conn: Connection,
oldRow: ResultSet?,
newRow: ResultSet,
) {
newRow.updateLong("last_modified_at", Clock.System.now().epochSeconds)
}
}
@Suppress("unused")
class UpdateChapterLastModifiedAtTrigger : TriggerAdapter() {
override fun fire(
conn: Connection,
oldRow: ResultSet?,
newRow: ResultSet,
) {
newRow.updateLong("last_modified_at", Clock.System.now().epochSeconds)
}
}
@Suppress("unused")
class InsertMangaCategoryUpdateVersionTrigger : TriggerAdapter() {
override fun fire(
conn: Connection,
oldRow: ResultSet?,
newRow: ResultSet,
) {
val mangaId = newRow.getInt("manga")
conn
.prepareStatement(
"UPDATE MANGA SET version = version + 1 WHERE id = ? AND NOT is_syncing",
).use {
it.setInt(1, mangaId)
it.executeUpdate()
}
}
}
@Suppress("unused")
class InsertCategoryUidTrigger : TriggerAdapter() {
override fun fire(
conn: Connection,
oldRow: ResultSet?,
newRow: ResultSet,
) {
if (newRow.getLong("uid") == 0L) {
newRow.updateLong("uid", Random.nextLong(1, Long.MAX_VALUE))
}
if (newRow.getLong("last_modified_at") == 0L) {
newRow.updateLong(
"last_modified_at",
Clock.System.now().epochSeconds,
)
}
}
}
@Suppress("unused")
class UpdateCategoryVersionTrigger : TriggerAdapter() {
override fun fire(
conn: Connection,
oldRow: ResultSet,
newRow: ResultSet,
) {
val isSyncing = newRow.getBoolean("is_syncing")
val hasChanged =
oldRow.getString("name") != newRow.getString("name") ||
oldRow.getInt("sort_order") != newRow.getInt("sort_order")
if (!isSyncing && hasChanged) {
val currentVersion = newRow.getLong("version")
newRow.updateLong("version", currentVersion + 1)
newRow.updateLong(
"last_modified_at",
Clock.System.now().epochSeconds,
)
}
}
}

View File

@@ -21,10 +21,6 @@ enum class ExitCode(
WebUISetupFailure(3),
ConfigMigrationMisconfiguredFailure(4),
DbMigrationFailure(5),
SetupConfFileFailed(6),
LocalSourceIconCopyFailure(7),
LocalSourceSetupFailure(8),
MigrationsRunFailure(9),
}
fun shutdownApp(exitCode: ExitCode) {

View File

@@ -1,571 +0,0 @@
package suwayomi.tachidesk.server.util
import android.text.format.Formatter
import com.jetbrains.cef.JCefAppConfig
import eu.kanade.tachiyomi.network.ProgressListener
import eu.kanade.tachiyomi.network.awaitSuccess
import eu.kanade.tachiyomi.network.newCachelessCallWithProgress
import eu.kanade.tachiyomi.network.parseAs
import io.github.oshai.kotlinlogging.KotlinLogging
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream
import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream
import org.cef.CefApp
import org.cef.CefSettings.LogSeverity
import org.cef.SystemBootstrap
import suwayomi.tachidesk.server.ApplicationDirs
import suwayomi.tachidesk.server.generated.BuildConfig
import suwayomi.tachidesk.server.serverConfig
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
import xyz.nulldev.androidcompat.webkit.CefHelper
import java.io.BufferedOutputStream
import java.io.IOException
import java.nio.file.Files
import java.nio.file.LinkOption
import java.nio.file.Path
import java.nio.file.StandardOpenOption
import java.nio.file.attribute.PosixFilePermission
import kotlin.concurrent.thread
import kotlin.io.path.ExperimentalPathApi
import kotlin.io.path.Path
import kotlin.io.path.absolute
import kotlin.io.path.absolutePathString
import kotlin.io.path.createDirectories
import kotlin.io.path.createTempDirectory
import kotlin.io.path.deleteExisting
import kotlin.io.path.deleteIfExists
import kotlin.io.path.deleteRecursively
import kotlin.io.path.div
import kotlin.io.path.exists
import kotlin.io.path.getPosixFilePermissions
import kotlin.io.path.inputStream
import kotlin.io.path.isRegularFile
import kotlin.io.path.isSameFileAs
import kotlin.io.path.isSymbolicLink
import kotlin.io.path.listDirectoryEntries
import kotlin.io.path.moveTo
import kotlin.io.path.outputStream
import kotlin.io.path.readLines
import kotlin.io.path.readSymbolicLink
import kotlin.io.path.readText
import kotlin.io.path.setPosixFilePermissions
import kotlin.io.path.writeText
import kotlin.streams.asSequence
private val logger = KotlinLogging.logger {}
@OptIn(ExperimentalPathApi::class)
object CEFManager {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default + Dispatchers.IO)
private val applicationDirs by lazy { Injekt.get<ApplicationDirs>() }
private val cefDir by lazy { Path(applicationDirs.dataRoot) / "bin/kcef" }
private val releaseFile by lazy { cefDir / "release" }
fun init() =
scope.launch {
serverConfig.subscribeTo(serverConfig.kcefEnabled, CEFManager::initAsync, ignoreInitialValue = false)
Runtime.getRuntime().addShutdownHook(
thread(start = false) {
CefHelper.cefApp.value.getOrNull()?.let {
logger.debug { "Shutting down CEF" }
it.dispose()
logger.debug { "CEF shutdown complete" }
}
},
)
}
private suspend fun initAsync(): Unit =
try {
CefHelper.cefApp.value = Result.success(null)
if (!serverConfig.kcefEnabled.value) {
throw CefException("CEF is disabled")
}
System.loadLibrary("jawt")
if (serverConfig.debugLogsEnabled.value) System.setProperty("jcef.log.verbose", "true")
if (!isInstallationValid(releaseFile)) {
downloadRelease(cefDir)
if (!isInstallationValid(releaseFile)) {
throw CefException("Failed to provide a valid installation, this is a bug!")
}
logger.info { "Downloaded CEF successfully!" }
}
val app =
if (CefApp.getInstanceIfAny() == null) {
val config =
JCefAppConfig.getInstance(cefDir.toString(), false).apply {
appArgsAsList.addAll(
arrayOf(
"--disable-gpu",
// #1486 needed to be able to render without a window
"--off-screen-rendering-enabled",
// #1489 since /dev/shm is restricted in docker (OOM)
"--disable-dev-shm-usage",
// #1723 support Widevine (incomplete)
"--enable-widevine-cdm",
// #1736 JCEF does implement stack guards properly
"--change-stack-guard-on-fork=disable",
),
)
cefSettings.apply {
windowless_rendering_enabled = true
cache_path = (Path(applicationDirs.dataRoot) / "cache/kcef").absolutePathString()
log_severity =
if (serverConfig.debugLogsEnabled.value) {
LogSeverity.LOGSEVERITY_VERBOSE
} else {
LogSeverity.LOGSEVERITY_DEFAULT
}
}
}
logger.debug {
"Attempting to initialize CEF: exe=${config.getServerExe()}, settings={${
config.cefSettings.getDescription()
}}, args=${config.getAppArgs().contentToString()}"
}
// this is essentially https://github.com/JetBrains/jcef/blob/5b93e5b916068316f1c8e7f8a59bf958d5ffd6e1/java/org/cef/CefApp.java#L777
// we do this here because JCEF has no mechanism to tell us that initalization failed, they just record in an inaccessible future
val os = Platform.current.os
when {
os.isLinux -> {
config.getLoader().loadLibrary("cef")
}
os.isWindows -> {
config.getLoader().loadLibrary("chrome_elf")
config.getLoader().loadLibrary("libcef")
}
else -> {}
}
config.getLoader().loadLibrary("jcef")
CefApp.setIsRemoteEnabled(config.isRemoteEnabled)
SystemBootstrap.setLoader(config.getLoader())
CefApp.startup(config.getAppArgs())
CefApp.getInstance(config.getAppArgs(), config.cefSettings, config.getServerExe())
} else {
logger.debug { "Getting existing app instance" }
CefApp.getInstance()
}
CefHelper.cefApp.value = Result.success(app)
logger.debug { "CEF app created" }
CefHelper.waitForInit().first()
return
} catch (e: Throwable) {
logger.error(e) { "Failed to set up CEF" }
CefHelper.cefApp.value = Result.failure(e)
}
internal fun isInstallationValid(releaseFile: Path): Boolean {
if (!releaseFile.exists() || !releaseFile.isRegularFile()) return false
return try {
releaseFile
.readLines()
.firstNotNullOfOrNull {
if (it.contains("JCEF_VERSION_DETAILED")) it.split("=").getOrNull(1) else null
}?.let {
logger.debug { "Comparing internal ${BuildConfig.JCEF_VERSION} against downloaded $it" }
BuildConfig.JCEF_VERSION.split("-chromium")[0] == it.split("-chromium")[0]
} ?: false
} catch (_: Exception) {
false
}
}
internal suspend fun downloadRelease(installDir: Path) {
logger.info { "Downloading CEF from Github (${BuildConfig.JCEF_JBR_RELEASE})" }
installDir.deleteRecursively()
if (!runCatching { installDir.createDirectories() }.isSuccess) {
throw CefException("Failed to create installation directory")
}
val client = OkHttpClient.Builder().followRedirects(true).build()
val request =
Request
.Builder()
.url("https://api.github.com/repos/JetBrains/JetBrainsRuntime/releases/tags/${BuildConfig.JCEF_JBR_RELEASE}")
.addHeader("Content-Type", GithubReleaseTransform.GITHUB_JSON)
.build()
val downloadUrl =
client.newCall(request).awaitSuccess().use { response ->
if (!response.isSuccessful) throw IOException("Unexpected code $response")
GithubReleaseTransform.transform(response)
}
val tempDownload = createTempDirectory("cef")
try {
val downFile = tempDownload / "download.tar.gz"
val downloadRequest =
Request
.Builder()
.url(downloadUrl)
.build()
downFile.outputStream().use { output ->
client
.newCachelessCallWithProgress(
downloadRequest,
object : ProgressListener {
private var lastPercent = 0L
override fun update(
bytesRead: Long,
contentLength: Long,
done: Boolean,
) {
val newPercent = (bytesRead * 100).floorDiv(contentLength)
if (newPercent != lastPercent) {
logger.info { "Downloading $newPercent% of ${Formatter.formatFileSize(null, contentLength)}" }
lastPercent = newPercent
}
}
},
).awaitSuccess()
.use { response ->
response.body.byteStream().use { input -> input.copyTo(output) }
}
}
logger.debug { "Extracting CEF..." }
TarGzExtractor.extract(
installDir,
downFile,
4096,
)
TarGzExtractor.move(
installDir,
)
} finally {
tempDownload.deleteRecursively()
}
}
class CefException(
msg: String,
) : Exception(msg)
// based on https://github.com/DatL4g/KCEF/blob/master/kcef/src/main/kotlin/dev/datlag/kcef/KCEFBuilder.kt
private object GithubReleaseTransform {
private val json: Json by injectLazy()
private val urlRegex = "(https?://|www.)[-a-zA-Z0-9+&@#/%?=~_|!:.;]*[-a-zA-Z0-9+&@#/%=~_|]".toRegex()
const val GITHUB_JSON = "application/vnd.github+json"
fun transform(initialResponse: Response): String {
val release = with(json) { initialResponse.parseAs<GitHubRelease>() }
val packageUrlList =
urlRegex
.findAll(release.body)
.toList()
.map { it.value }
.filterNot {
it.isBlank() || it.endsWith(".checksum", true)
}.filter {
it.contains("jcef", true)
}
val platform = Platform.current
val osPackageList =
packageUrlList
.filter { url ->
platform.os.values.any { os ->
url.contains(os, true)
}
}.ifEmpty {
release.assets
.filter { asset ->
platform.os.values.any { os ->
asset.name.contains(os, true) || asset.downloadUrl.contains(os, true)
} && asset.downloadUrl.isNotBlank()
}.filter { asset ->
platform.arch.values.any { arch ->
asset.name.contains(arch, ignoreCase = true) ||
asset.downloadUrl.contains(
arch,
true,
)
} && asset.downloadUrl.isNotBlank()
}.map { it.downloadUrl }
}
val platformPackageList =
osPackageList.filter { url ->
platform.arch.values.any { arch ->
url.contains(arch, true)
}
}
if (platformPackageList.isEmpty()) {
throw CefException("Platform not supported by CEF (${platform.os},${platform.arch})")
}
val sortedPackageList =
platformPackageList.sortedWith(
compareBy<String> {
if (it.contains("sdk", true)) {
1
} else {
0
}
}.thenBy {
if (it.endsWith(".tar.gz", true)) {
0
} else {
1
}
},
)
return sortedPackageList.first()
}
@Serializable
private data class GitHubRelease(
val body: String,
val assets: List<Asset> = emptyList(),
) {
@Serializable
data class Asset(
val name: String = "",
@SerialName("browser_download_url") val downloadUrl: String = "",
)
}
}
// based on https://github.com/DatL4g/KCEF/blob/master/kcef/src/main/kotlin/dev/datlag/kcef/step/extract/TarGzExtractor.kt
internal data object TarGzExtractor {
internal fun Path.validate(parent: Path): Boolean =
runCatching {
this.normalize().startsWith(parent)
}.getOrNull() ?: false
internal fun Path.isSymlink(): Boolean =
runCatching {
this.isSymbolicLink()
}.getOrNull() ?: runCatching {
!this.isRegularFile(LinkOption.NOFOLLOW_LINKS)
}.getOrNull() ?: false
internal fun Path.getRealFile(): Path =
if (isSymlink()) {
runCatching {
this.readSymbolicLink()
}.getOrNull() ?: this
} else {
this
}
internal fun Path.isSame(file: Path?): Boolean {
var sourceFile = this.getRealFile()
if (!sourceFile.exists()) {
sourceFile = this
}
var targetFile = file?.getRealFile() ?: file
if (targetFile?.exists() == false) {
targetFile = file
}
return if (targetFile == null) {
false
} else {
this == targetFile || runCatching {
sourceFile.absolute() == targetFile.absolute() || sourceFile.isSameFileAs(targetFile)
}.getOrNull() ?: false
}
}
fun extract(
installDir: Path,
downloadedFile: Path,
bufferSize: Long,
) {
downloadedFile.inputStream().use { `in` ->
GzipCompressorInputStream(`in`).use { gzipIn ->
TarArchiveInputStream(gzipIn).use { tarIn ->
while (tarIn.nextEntry != null) {
val currentEntry = tarIn.currentEntry
if (currentEntry != null) {
val file = installDir / currentEntry.name
if (!file.validate(installDir)) {
throw CefException("bad archive")
}
if (currentEntry.isDirectory) {
file.createDirectories()
} else {
BufferedOutputStream(
file.outputStream(StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE),
bufferSize.toInt(),
).use { dest ->
tarIn.copyTo(dest)
}
}
try {
file.setPosixFilePermissions(
file.getPosixFilePermissions() +
setOf(
PosixFilePermission.OWNER_EXECUTE,
PosixFilePermission.GROUP_EXECUTE,
PosixFilePermission.OTHERS_EXECUTE,
),
)
} catch (_: UnsupportedOperationException) {
// ignore
}
}
}
}
}
}
downloadedFile.deleteExisting()
}
fun move(installDir: Path) {
val releaseFile =
Files.walk(installDir).use { s ->
s
.filter(Files::isRegularFile)
.asSequence()
.firstOrNull { it.fileName?.toString() == "release" }
} ?: (installDir / "release")
val releaseFileContents = if (releaseFile.exists()) releaseFile.readText(Charsets.UTF_8) else ""
val os = Platform.current.os
when {
os.isLinux -> linuxMove(installDir)
os.isMacOSX -> macMove(installDir)
os.isWindows -> winMove(installDir)
else -> linuxMove(installDir)
}
(installDir / "release").writeText(releaseFileContents)
}
private fun linuxMove(installDir: Path) {
var foundDir: Path? = null
var foundParent: Path? = null
installDir.listDirectoryEntries().forEach { parent ->
if ((parent / "lib").exists()) {
foundDir = parent / "lib"
foundParent = parent
}
}
foundDir?.let {
val target = it.moveTo(installDir / "lib")
foundParent?.let { p ->
p.deleteRecursively()
p.deleteIfExists()
}
installDir.listDirectoryEntries().forEach { deleteCandidate ->
if (!deleteCandidate.isSame(target)) {
deleteCandidate.deleteRecursively()
}
}
target.listDirectoryEntries().forEach { moveCandidate ->
moveCandidate.moveTo(installDir / moveCandidate.fileName)
}
target.deleteExisting()
}
}
private fun macMove(installDir: Path) {
var foundDir: Path? = null
var foundParent: Path? = null
installDir.listDirectoryEntries().forEach { parent ->
if ((parent / "Contents").exists()) {
foundDir = parent / "Contents"
foundParent = parent
}
}
val target = (installDir / "lib").also { it.createDirectories() }
foundDir?.let { contents ->
(contents / "Home" / "lib").listDirectoryEntries().forEach { moveCandidate ->
moveCandidate.moveTo(target / moveCandidate.fileName)
}
(contents / "Frameworks").moveTo(
target / "Frameworks",
)
foundParent?.let { p ->
p.deleteRecursively()
p.deleteIfExists()
}
installDir.listDirectoryEntries().forEach { deleteCandidate ->
if (!deleteCandidate.isSame(target)) {
deleteCandidate.deleteRecursively()
}
}
target.listDirectoryEntries().forEach { moveCandidate ->
moveCandidate.moveTo(installDir / moveCandidate.fileName)
}
target.deleteExisting()
}
}
private fun winMove(installDir: Path) {
var foundDir: Path? = null
installDir.listDirectoryEntries().forEach { parent ->
if ((parent / "lib").exists()) {
foundDir = parent
}
}
foundDir?.let {
val target = (it / "lib").moveTo(installDir / "lib")
(it / "bin").listDirectoryEntries().forEach { moveCandidate ->
moveCandidate.moveTo(target / moveCandidate.fileName)
}
installDir.listDirectoryEntries().forEach { deleteCandidate ->
if (!deleteCandidate.isSame(target)) {
deleteCandidate.deleteRecursively()
}
}
target.listDirectoryEntries().forEach { moveCandidate ->
moveCandidate.moveTo(installDir / moveCandidate.fileName)
}
target.deleteExisting()
}
}
}
}

View File

@@ -1,132 +0,0 @@
package suwayomi.tachidesk.server.util
import java.util.Locale
data class OSInfo(
val os: OS,
val arch: ARCH,
)
object Platform {
private val oses: List<OS.OSCreator> = listOf(OS.OSCreator.MACOSX(), OS.OSCreator.LINUX(), OS.OSCreator.WINDOWS())
private val archs: List<ARCH.ARCHCreator> =
listOf(ARCH.ARCHCreator.AMD64(), ARCH.ARCHCreator.I386(), ARCH.ARCHCreator.ARM64(), ARCH.ARCHCreator.ARM())
val current: OSInfo by lazy { getCurrentPlatform() }
private fun getCurrentPlatform(): OSInfo {
val osName = System.getProperty("os.name")
val archName = System.getProperty("os.arch")
val os = oses.firstNotNullOfOrNull { if (it.matches(osName)) it.create(osName) else null }
val arch = archs.firstNotNullOfOrNull { if (it.matches(archName)) it.create(archName) else null }
if (os == null || arch == null) {
throw UnsupportedOperationException("Unsupported platform tuple $osName,$archName")
}
return OSInfo(os, arch)
}
}
sealed class OS(
val name: String,
vararg val values: String,
) {
internal abstract class OSCreator(
vararg val values: String,
) {
abstract fun create(name: String): OS
fun matches(name: String): Boolean =
values.any { name.startsWith(it, true) } ||
values.contains(
name.lowercase(
Locale.ENGLISH,
),
)
class MACOSX : OSCreator("mac", "darwin", "osx") {
override fun create(name: String) = OS.MACOSX(name, *values)
}
class LINUX : OSCreator("linux") {
override fun create(name: String) = OS.LINUX(name, *values)
}
class WINDOWS : OSCreator("win", "windows") {
override fun create(name: String) = OS.WINDOWS(name, *values)
}
}
class MACOSX(
name: String,
vararg values: String,
) : OS(name, *values)
class LINUX(
name: String,
vararg values: String,
) : OS(name, *values)
class WINDOWS(
name: String,
vararg values: String,
) : OS(name, *values)
val isLinux: Boolean get() = this is LINUX
val isMacOSX: Boolean get() = this is MACOSX
val isWindows: Boolean get() = this is WINDOWS
}
sealed class ARCH(
val name: String,
vararg val values: String,
) {
internal abstract class ARCHCreator(
vararg val values: String,
) {
abstract fun create(name: String): ARCH
fun matches(name: String): Boolean =
values.any { name.startsWith(it, true) } ||
values.contains(
name.lowercase(
Locale.ENGLISH,
),
)
class AMD64 : ARCHCreator("amd64", "x86_64", "x64") {
override fun create(name: String) = ARCH.AMD64(name, *values)
}
class I386 : ARCHCreator("x86", "i386", "i486", "i586", "i686", "i786") {
override fun create(name: String) = ARCH.I386(name, *values)
}
class ARM64 : ARCHCreator("arm64", "aarch64") {
override fun create(name: String) = ARCH.ARM64(name, *values)
}
class ARM : ARCHCreator("arm") {
override fun create(name: String) = ARCH.ARM(name, *values)
}
}
class AMD64(
arch: String,
vararg values: String,
) : ARCH(arch, *values)
class I386(
arch: String,
vararg values: String,
) : ARCH(arch, *values)
class ARM64(
arch: String,
vararg values: String,
) : ARCH(arch, *values)
class ARM(
arch: String,
vararg values: String,
) : ARCH(arch, *values)
}

View File

@@ -136,7 +136,7 @@ object WebInterfaceManager {
fun getAboutInfo(): AboutWebUI {
val currentVersion = getLocalVersion()
val failedToGetVersion = currentVersion == "r-1"
val failedToGetVersion = currentVersion === "r-1"
if (failedToGetVersion) {
throw Exception("Failed to get current version")
}

View File

@@ -1,22 +1,17 @@
package masstest
import android.os.Looper
import eu.kanade.tachiyomi.source.online.HttpSource
import io.github.oshai.kotlinlogging.KotlinLogging
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.Disabled
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.koin.core.context.stopKoin
import suwayomi.tachidesk.manga.impl.Source
import suwayomi.tachidesk.manga.impl.extension.Extension
import suwayomi.tachidesk.manga.impl.extension.ExtensionsList
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource
import suwayomi.tachidesk.server.applicationSetup
import suwayomi.tachidesk.server.settings.SettingsRegistry
import suwayomi.tachidesk.test.BASE_PATH
import suwayomi.tachidesk.test.setLoggingEnabled
import xyz.nulldev.ts.config.CONFIG_PREFIX
@@ -30,11 +25,8 @@ class CloudFlareTest {
fun setup() {
val dataRoot = File(BASE_PATH).absolutePath
System.setProperty("$CONFIG_PREFIX.server.rootDir", dataRoot)
Looper.clearMainLooperForTest()
SettingsRegistry.clear()
applicationSetup()
setLoggingEnabled(false)
return
runBlocking {
val extensions = ExtensionsList.getExtensionList()
@@ -56,15 +48,9 @@ class CloudFlareTest {
setLoggingEnabled(true)
}
@AfterAll
fun teardown() {
stopKoin()
}
private val logger = KotlinLogging.logger {}
@Test
@Disabled
fun `test nhentai browse`() =
runTest {
assert(nhentai.getPopularManga(1).mangas.isNotEmpty()) {

View File

@@ -7,7 +7,6 @@ package masstest
* 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 android.os.Looper
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource
@@ -18,11 +17,9 @@ import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.koin.core.context.stopKoin
import suwayomi.tachidesk.manga.impl.Source.getSourceList
import suwayomi.tachidesk.manga.impl.extension.Extension.installExtension
import suwayomi.tachidesk.manga.impl.extension.Extension.uninstallExtension
@@ -31,7 +28,6 @@ import suwayomi.tachidesk.manga.impl.extension.ExtensionsList.getExtensionList
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrNull
import suwayomi.tachidesk.manga.model.dataclass.ExtensionDataClass
import suwayomi.tachidesk.server.applicationSetup
import suwayomi.tachidesk.server.settings.SettingsRegistry
import suwayomi.tachidesk.test.BASE_PATH
import suwayomi.tachidesk.test.setLoggingEnabled
import xyz.nulldev.ts.config.CONFIG_PREFIX
@@ -55,8 +51,6 @@ class TestExtensionCompatibility {
fun setup() {
val dataRoot = File(BASE_PATH).absolutePath
System.setProperty("$CONFIG_PREFIX.server.rootDir", dataRoot)
Looper.clearMainLooperForTest()
SettingsRegistry.clear()
applicationSetup()
setLoggingEnabled(false)
@@ -78,22 +72,12 @@ class TestExtensionCompatibility {
}
}
}
sources =
getSourceList()
.filter {
// filter local source
it.id.toLong() != 0L
}.map { getCatalogueSourceOrNull(it.id.toLong())!! as HttpSource }
sources = getSourceList().map { getCatalogueSourceOrNull(it.id.toLong())!! as HttpSource }
}
setLoggingEnabled(true)
File("$BASE_PATH/sources.txt").writeText(sources.joinToString("\n") { "${it.name} - ${it.lang.uppercase()} - ${it.id}" })
}
@AfterAll
fun teardown() {
stopKoin()
}
@Test
fun runTest() {
runBlocking(Dispatchers.Default) {

View File

@@ -1,42 +0,0 @@
package suwayomi.tachidesk
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import org.koin.core.context.startKoin
import org.koin.core.context.stopKoin
import org.koin.dsl.module
import suwayomi.tachidesk.server.util.CEFManager
import java.nio.file.Files
import kotlin.io.path.ExperimentalPathApi
import kotlin.io.path.deleteRecursively
import kotlin.io.path.div
import kotlin.test.Test
import kotlin.test.assertTrue
@OptIn(ExperimentalPathApi::class)
class CefTest {
@Test
fun downloadedJbrIsValidForJcef() =
runTest {
val tempDownload = Files.createTempDirectory("kcef")
val module =
module {
single {
Json {
ignoreUnknownKeys = true
explicitNulls = false
}
}
}
startKoin {
modules(module)
}
try {
CEFManager.downloadRelease(tempDownload)
assertTrue { CEFManager.isInstallationValid(tempDownload / "release") }
} finally {
tempDownload.deleteRecursively()
stopKoin()
}
}
}

View File

@@ -1,26 +0,0 @@
package suwayomi.tachidesk
import graphql.Assert.assertTrue
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import suwayomi.tachidesk.server.subscribeTo
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import kotlin.test.Test
class FlowTest {
@Test
fun subscribe() =
runTest {
(1..1000).forEach { _ ->
val testFlow = MutableStateFlow(value = 3)
testFlow.first()
val latch = CountDownLatch(1)
subscribeTo(testFlow, ignoreInitialValue = false) { _ ->
latch.countDown()
}
assertTrue(latch.await(5, TimeUnit.SECONDS))
}
}
}

View File

@@ -1,84 +0,0 @@
package suwayomi.tachidesk
import android.os.Handler
import android.os.Looper
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
import kotlin.test.assertTrue
class LooperThread : Thread() {
var mHandler: Handler? = null
val latch = CountDownLatch(1)
override fun run() {
Looper.prepare()
mHandler = Handler(Looper.myLooper()!!)
latch.countDown()
Looper.loop()
}
}
class LooperTest {
@Test
fun multiplePostWork() {
val thread = LooperThread()
thread.start()
val sb = StringBuilder()
val latch = CountDownLatch(1)
assertTrue(thread.latch.await(5, TimeUnit.SECONDS))
thread.mHandler!!.post {
Thread.sleep(100)
sb.append("a_b_c")
}
thread.mHandler!!.post {
Thread.sleep(100)
sb.append("_d_e_f")
}
thread.mHandler!!.post {
Thread.sleep(100)
sb.append("_g_h_i")
latch.countDown()
}
assertNotEquals("a_b_c_d_e_f_g_h_i", sb.toString())
assertTrue(latch.await(5, TimeUnit.SECONDS))
assertEquals("a_b_c_d_e_f_g_h_i", sb.toString())
thread.mHandler!!.looper.quit()
// thread.join()
}
@Test
fun loopTest() {
val thread = LooperThread()
thread.start()
val sb = StringBuilder()
val expected = StringBuilder()
val latch = CountDownLatch(1)
assertTrue(thread.latch.await(5, TimeUnit.SECONDS))
val n = 100
for (i in 0 until n) {
thread.mHandler!!.post {
Thread.sleep(10)
sb.append("$i")
}
expected.append("$i")
}
thread.mHandler!!.post {
latch.countDown()
}
assertNotEquals(expected.toString(), sb.toString())
assertTrue(latch.await(5, TimeUnit.SECONDS), "only got to $sb")
assertEquals(expected.toString(), sb.toString())
thread.mHandler!!.looper.quit()
// thread.join()
}
}

View File

@@ -18,22 +18,19 @@ import suwayomi.tachidesk.test.clearTables
class CategoryControllerTest : ApplicationTest() {
@Test
fun categoryReorder() {
clearTables(
CategoryTable,
)
Category.createCategory("foo")
Category.createCategory("bar")
val cats = Category.getCategoryList()
val foo = cats.asSequence().filter { it.name == "foo" }.first()
val bar = cats.asSequence().filter { it.name == "bar" }.first()
assertEquals(0, foo.order)
assertEquals(1, bar.order)
assertEquals(1, foo.order)
assertEquals(2, bar.order)
Category.reorderCategory(1, 2)
val catsReordered = Category.getCategoryList()
val fooReordered = catsReordered.asSequence().filter { it.name == "foo" }.first()
val barReordered = catsReordered.asSequence().filter { it.name == "bar" }.first()
assertEquals(1, fooReordered.order)
assertEquals(0, barReordered.order)
assertEquals(2, fooReordered.order)
assertEquals(1, barReordered.order)
}
@AfterEach

View File

@@ -35,7 +35,7 @@ class CategoryMangaTest : ApplicationTest() {
CategoryManga.getCategoryMangaList(DEFAULT_CATEGORY_ID)[0].unreadCount,
"Manga should not have any unread chapters",
)
createChapters(mangaId, 10, false, start = 11)
createChapters(mangaId, 10, false)
assertEquals(
10,
CategoryManga.getCategoryMangaList(DEFAULT_CATEGORY_ID)[0].unreadCount,

View File

@@ -46,7 +46,6 @@ class TestUpdater : IUpdater {
TODO("Not yet implemented")
}
@Deprecated("Replaced with updates", replaceWith = ReplaceWith("updates"))
override val status: Flow<UpdateStatus>
get() = TODO("Not yet implemented")
override val updates: Flow<UpdateUpdates>

View File

@@ -12,12 +12,9 @@ import eu.kanade.tachiyomi.createAppModule
import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.source.local.LocalSource
import io.github.oshai.kotlinlogging.KotlinLogging
import org.jetbrains.exposed.v1.core.DatabaseConfig
import org.jetbrains.exposed.v1.core.ExperimentalKeywordApi
import org.jetbrains.exposed.v1.jdbc.Database
import org.junit.jupiter.api.BeforeAll
import org.koin.core.context.startKoin
import org.koin.core.context.stopKoin
import suwayomi.tachidesk.server.ApplicationDirs
import suwayomi.tachidesk.server.JavalinSetup
import suwayomi.tachidesk.server.ServerConfig
@@ -25,9 +22,8 @@ import suwayomi.tachidesk.server.androidCompat
import suwayomi.tachidesk.server.database.databaseUp
import suwayomi.tachidesk.server.serverConfig
import suwayomi.tachidesk.server.serverModule
import suwayomi.tachidesk.server.settings.SettingsRegistry
import suwayomi.tachidesk.server.util.AppMutex.handleAppMutex
import suwayomi.tachidesk.server.util.ConfigTypeRegistration
import suwayomi.tachidesk.server.util.SystemTray
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import xyz.nulldev.androidcompat.AndroidCompatInitializer
@@ -59,13 +55,6 @@ open class ApplicationTest {
private var initializedTheApp = false
fun testingSetup() {
// register Tachidesk's config which is dubbed "ServerConfig"
SettingsRegistry.clear()
ConfigTypeRegistration.registerCustomTypes()
GlobalConfigManager.registerModule(
ServerConfig.register { GlobalConfigManager.config },
)
// Application dirs
val applicationDirs = ApplicationDirs()
@@ -83,9 +72,13 @@ open class ApplicationTest {
File(it).mkdirs()
}
// register Tachidesk's config which is dubbed "ServerConfig"
GlobalConfigManager.registerModule(
ServerConfig.register { GlobalConfigManager.config },
)
// initialize Koin modules
val app = App()
stopKoin()
startKoin {
modules(
createAppModule(app),
@@ -135,14 +128,14 @@ open class ApplicationTest {
}
// create system tray
// if (serverConfig.systemTrayEnabled.value) {
// try {
// SystemTray.create()
// } catch (e: Throwable) {
// // cover both java.lang.Exception and java.lang.Error
// e.printStackTrace()
// }
// }
if (serverConfig.systemTrayEnabled.value) {
try {
SystemTray.create()
} catch (e: Throwable) {
// cover both java.lang.Exception and java.lang.Error
e.printStackTrace()
}
}
// Disable jetty's logging
System.setProperty("org.eclipse.jetty.util.log.announce", "false")
@@ -161,16 +154,8 @@ open class ApplicationTest {
// fixes #119 , ref: https://github.com/Suwayomi/Suwayomi-Server/issues/119#issuecomment-894681292 , source Id calculation depends on String.lowercase()
Locale.setDefault(Locale.ENGLISH)
val dbConfig =
DatabaseConfig {
useNestedTransactions = true
@OptIn(ExperimentalKeywordApi::class)
preserveKeywordCasing = false
defaultSchema = null
}
// in-memory database, don't discard database between connections/transactions
val db = Database.connect("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;", "org.h2.Driver", databaseConfig = dbConfig)
val db = Database.connect("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;", "org.h2.Driver")
databaseUp(db)

View File

@@ -55,9 +55,8 @@ fun createChapters(
mangaId: Int,
amount: Int,
read: Boolean,
start: Int = 1,
) {
val list = listOf((0 until amount)).flatten().map { it + start }
val list = listOf((0 until amount)).flatten().map { 1 }
transaction {
ChapterTable
.batchInsert(list) {