mirror of
https://github.com/Suwayomi/Suwayomi-Server.git
synced 2026-07-15 08:44:08 -05:00
Compare commits
57 Commits
762d5bdbe6
...
renovate/j
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5fa74cd877 | ||
|
|
c8f5d83e9c | ||
|
|
be5e3f022e | ||
|
|
40a21fabca | ||
|
|
b33069f107 | ||
|
|
14ab3aa9f4 | ||
|
|
bfa0038f53 | ||
|
|
934459f15f | ||
|
|
bab58daecc | ||
|
|
61eb0630cb | ||
|
|
0d3afadfa3 | ||
|
|
f9e81c75b6 | ||
|
|
e123857399 | ||
|
|
31817639bd | ||
|
|
2b91ab755d | ||
|
|
98576c6e62 | ||
|
|
8fbc8fd3d4 | ||
|
|
c81020dbb1 | ||
|
|
348d525b00 | ||
|
|
a9e3d4dc81 | ||
|
|
15af84e626 | ||
|
|
745b11d91a | ||
|
|
b9efbc1aa4 | ||
|
|
ba347e24a2 | ||
|
|
811e15162b | ||
|
|
a403b1c564 | ||
|
|
98833fa738 | ||
|
|
f571b52740 | ||
|
|
744c5189c4 | ||
|
|
96859f90f0 | ||
|
|
76ee471933 | ||
|
|
6fdc247ace | ||
|
|
d0d28a692a | ||
|
|
7051a5d525 | ||
|
|
0e4de02b52 | ||
|
|
1b9db6cb01 | ||
|
|
af45cce641 | ||
|
|
903a3d53b3 | ||
|
|
13a5b3a831 | ||
|
|
da3a1dce9e | ||
|
|
c68a108298 | ||
|
|
911c0ce2e3 | ||
|
|
2a1f1faae5 | ||
|
|
07dceeb07c | ||
|
|
ba2257a164 | ||
|
|
669cb44864 | ||
|
|
6493eaaa02 | ||
|
|
701e4674ea | ||
|
|
75fa4b4b23 | ||
|
|
00861d7750 | ||
|
|
fff291cdb5 | ||
|
|
70f3036f58 | ||
|
|
cc75ad328d | ||
|
|
c0618fcc5c | ||
|
|
9686f75a2d | ||
|
|
4d5307f15b | ||
|
|
779229a48a |
@@ -11,4 +11,8 @@ ktlint_standard_if-else-wrapping=disabled
|
|||||||
ktlint_standard_no-consecutive-comments=disabled
|
ktlint_standard_no-consecutive-comments=disabled
|
||||||
|
|
||||||
[**/generated/**]
|
[**/generated/**]
|
||||||
ktlint=disabled
|
ktlint=disabled
|
||||||
|
|
||||||
|
[*.json]
|
||||||
|
indent_size=2
|
||||||
|
indent_style = space
|
||||||
4
.github/workflows/build_pull_request.yml
vendored
4
.github/workflows/build_pull_request.yml
vendored
@@ -96,6 +96,10 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
exit 0
|
exit 0
|
||||||
|
|
||||||
|
- name: "Run tests"
|
||||||
|
working-directory: master
|
||||||
|
run: ./gradlew test --stacktrace
|
||||||
|
|
||||||
check_docs:
|
check_docs:
|
||||||
name: Validate that all options are documented
|
name: Validate that all options are documented
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
9
.github/workflows/wiki.yml
vendored
9
.github/workflows/wiki.yml
vendored
@@ -24,6 +24,8 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
repository: ${{github.repository}}
|
repository: ${{github.repository}}
|
||||||
path: ${{github.repository}}
|
path: ${{github.repository}}
|
||||||
|
fetch-depth: 0 # fetch history & tags to determine stable version
|
||||||
|
fetch-tags: true
|
||||||
|
|
||||||
- name: Checkout Wiki
|
- name: Checkout Wiki
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
@@ -36,6 +38,13 @@ jobs:
|
|||||||
set -e
|
set -e
|
||||||
cd $GITHUB_WORKSPACE/${{github.repository}}.wiki
|
cd $GITHUB_WORKSPACE/${{github.repository}}.wiki
|
||||||
cp -r $GITHUB_WORKSPACE/${{github.repository}}/docs/* .
|
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-Suwayomi‐Server.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-Suwayomi‐Server.md
|
||||||
|
fi
|
||||||
|
|
||||||
git config --local user.email "action@github.com"
|
git config --local user.email "action@github.com"
|
||||||
git config --local user.name "GitHub Action"
|
git config --local user.name "GitHub Action"
|
||||||
git add .
|
git add .
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,47 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,11 +51,10 @@ import android.webkit.WebViewProvider.ScrollDelegate
|
|||||||
import android.webkit.WebViewProvider.ViewDelegate
|
import android.webkit.WebViewProvider.ViewDelegate
|
||||||
import android.webkit.WebViewRenderProcess
|
import android.webkit.WebViewRenderProcess
|
||||||
import android.webkit.WebViewRenderProcessClient
|
import android.webkit.WebViewRenderProcessClient
|
||||||
import dev.datlag.kcef.KCEF
|
import kotlinx.coroutines.runBlocking
|
||||||
import dev.datlag.kcef.KCEFBrowser
|
|
||||||
import dev.datlag.kcef.KCEFClient
|
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
import org.cef.CefClient
|
||||||
import org.cef.CefSettings
|
import org.cef.CefSettings
|
||||||
import org.cef.browser.CefBrowser
|
import org.cef.browser.CefBrowser
|
||||||
import org.cef.browser.CefFrame
|
import org.cef.browser.CefFrame
|
||||||
@@ -69,6 +68,7 @@ import org.cef.handler.CefLoadHandler
|
|||||||
import org.cef.handler.CefLoadHandlerAdapter
|
import org.cef.handler.CefLoadHandlerAdapter
|
||||||
import org.cef.handler.CefMessageRouterHandlerAdapter
|
import org.cef.handler.CefMessageRouterHandlerAdapter
|
||||||
import org.cef.handler.CefPermissionHandler
|
import org.cef.handler.CefPermissionHandler
|
||||||
|
import org.cef.handler.CefRenderHandlerAdapter
|
||||||
import org.cef.handler.CefRequestHandler
|
import org.cef.handler.CefRequestHandler
|
||||||
import org.cef.handler.CefRequestHandlerAdapter
|
import org.cef.handler.CefRequestHandlerAdapter
|
||||||
import org.cef.handler.CefResourceHandler
|
import org.cef.handler.CefResourceHandler
|
||||||
@@ -83,11 +83,14 @@ import org.cef.network.CefPostDataElement
|
|||||||
import org.cef.network.CefRequest
|
import org.cef.network.CefRequest
|
||||||
import org.cef.network.CefResponse
|
import org.cef.network.CefResponse
|
||||||
import org.koin.mp.KoinPlatformTools
|
import org.koin.mp.KoinPlatformTools
|
||||||
|
import java.awt.Rectangle
|
||||||
import java.io.BufferedWriter
|
import java.io.BufferedWriter
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
import java.nio.ByteBuffer
|
||||||
import java.util.concurrent.Executor
|
import java.util.concurrent.Executor
|
||||||
import kotlin.reflect.KClass
|
import javax.swing.JPanel
|
||||||
|
import kotlin.math.min
|
||||||
import kotlin.reflect.KFunction
|
import kotlin.reflect.KFunction
|
||||||
import kotlin.reflect.full.declaredMemberFunctions
|
import kotlin.reflect.full.declaredMemberFunctions
|
||||||
import kotlin.reflect.jvm.javaMethod
|
import kotlin.reflect.jvm.javaMethod
|
||||||
@@ -98,12 +101,13 @@ class KcefWebViewProvider(
|
|||||||
private val settings = KcefWebSettings()
|
private val settings = KcefWebSettings()
|
||||||
private var viewClient = WebViewClient()
|
private var viewClient = WebViewClient()
|
||||||
private var chromeClient = WebChromeClient()
|
private var chromeClient = WebChromeClient()
|
||||||
|
private val renderHandler = RenderHandler()
|
||||||
private val mappings: MutableList<FunctionMapping> = mutableListOf()
|
private val mappings: MutableList<FunctionMapping> = mutableListOf()
|
||||||
private val urlHttpMapping: MutableMap<String, String> = mutableMapOf()
|
private val urlHttpMapping: MutableMap<String, String> = mutableMapOf()
|
||||||
private var initialRequestData: InitialRequestData? = null
|
private var initialRequestData: InitialRequestData? = null
|
||||||
|
|
||||||
private var kcefClient: KCEFClient? = null
|
private var kcefClient: CefClient? = null
|
||||||
private var browser: KCEFBrowser? = null
|
private var browser: CefBrowser? = null
|
||||||
|
|
||||||
private val handler = Handler(view.webViewLooper)
|
private val handler = Handler(view.webViewLooper)
|
||||||
|
|
||||||
@@ -115,8 +119,8 @@ class KcefWebViewProvider(
|
|||||||
private val initHandler: InitBrowserHandler by KoinPlatformTools.defaultContext().get().inject()
|
private val initHandler: InitBrowserHandler by KoinPlatformTools.defaultContext().get().inject()
|
||||||
}
|
}
|
||||||
|
|
||||||
public interface InitBrowserHandler {
|
interface InitBrowserHandler {
|
||||||
public fun init(provider: KcefWebViewProvider): Unit
|
fun init(provider: KcefWebViewProvider): Unit
|
||||||
}
|
}
|
||||||
|
|
||||||
private data class InitialRequestData(
|
private data class InitialRequestData(
|
||||||
@@ -192,7 +196,7 @@ class KcefWebViewProvider(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private inner class DisplayHandler : CefDisplayHandlerAdapter() {
|
private class DisplayHandler : CefDisplayHandlerAdapter() {
|
||||||
override fun onConsoleMessage(
|
override fun onConsoleMessage(
|
||||||
browser: CefBrowser,
|
browser: CefBrowser,
|
||||||
level: CefSettings.LogSeverity,
|
level: CefSettings.LogSeverity,
|
||||||
@@ -220,6 +224,7 @@ class KcefWebViewProvider(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
private inner class LoadHandler : CefLoadHandlerAdapter() {
|
private inner class LoadHandler : CefLoadHandlerAdapter() {
|
||||||
override fun onLoadEnd(
|
override fun onLoadEnd(
|
||||||
browser: CefBrowser,
|
browser: CefBrowser,
|
||||||
@@ -366,7 +371,7 @@ class KcefWebViewProvider(
|
|||||||
callback: CefCallback,
|
callback: CefCallback,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
val data = resolvedData ?: return false
|
val data = resolvedData ?: return false
|
||||||
val bytesToTransfer = Math.min(bytesToRead, data.size - readOffset)
|
val bytesToTransfer = min(bytesToRead, data.size - readOffset)
|
||||||
Log.v(
|
Log.v(
|
||||||
TAG,
|
TAG,
|
||||||
"readResponse: $readOffset/${data.size}, reading $bytesToRead->$bytesToTransfer",
|
"readResponse: $readOffset/${data.size}, reading $bytesToRead->$bytesToTransfer",
|
||||||
@@ -378,7 +383,7 @@ class KcefWebViewProvider(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private inner class WebResponseResourceHandler(
|
private class WebResponseResourceHandler(
|
||||||
val webResponse: WebResourceResponse,
|
val webResponse: WebResourceResponse,
|
||||||
) : ArrayResponseResourceHandler() {
|
) : ArrayResponseResourceHandler() {
|
||||||
override fun processRequest(
|
override fun processRequest(
|
||||||
@@ -408,7 +413,7 @@ class KcefWebViewProvider(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private inner class HtmlResponseResourceHandler(
|
private class HtmlResponseResourceHandler(
|
||||||
val html: String,
|
val html: String,
|
||||||
) : ArrayResponseResourceHandler() {
|
) : ArrayResponseResourceHandler() {
|
||||||
override fun processRequest(
|
override fun processRequest(
|
||||||
@@ -439,7 +444,7 @@ class KcefWebViewProvider(
|
|||||||
view,
|
view,
|
||||||
CefWebResourceRequest(request, frame, false),
|
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) }
|
handler.post { viewClient.onLoadResource(view, frame?.url) }
|
||||||
|
|
||||||
@@ -466,7 +471,7 @@ class KcefWebViewProvider(
|
|||||||
}
|
}
|
||||||
if (response == null) {
|
if (response == null) {
|
||||||
// prefer user's response override
|
// prefer user's response override
|
||||||
urlHttpMapping.get(request.url.trimEnd('/'))?.let {
|
urlHttpMapping[request.url.trimEnd('/')]?.let {
|
||||||
return HtmlResponseResourceHandler(it)
|
return HtmlResponseResourceHandler(it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -475,6 +480,7 @@ class KcefWebViewProvider(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
private inner class RequestHandler : CefRequestHandlerAdapter() {
|
private inner class RequestHandler : CefRequestHandlerAdapter() {
|
||||||
override fun getResourceRequestHandler(
|
override fun getResourceRequestHandler(
|
||||||
browser: CefBrowser,
|
browser: CefBrowser,
|
||||||
@@ -484,11 +490,13 @@ class KcefWebViewProvider(
|
|||||||
isDownload: Boolean,
|
isDownload: Boolean,
|
||||||
requestInitiator: String,
|
requestInitiator: String,
|
||||||
disableDefaultHandling: BoolRef,
|
disableDefaultHandling: BoolRef,
|
||||||
): CefResourceRequestHandler? = ResourceRequestHandler()
|
): CefResourceRequestHandler = ResourceRequestHandler()
|
||||||
|
|
||||||
override fun onRenderProcessTerminated(
|
override fun onRenderProcessTerminated(
|
||||||
browser: CefBrowser,
|
browser: CefBrowser,
|
||||||
status: CefRequestHandler.TerminationStatus,
|
status: CefRequestHandler.TerminationStatus,
|
||||||
|
errorCode: Int,
|
||||||
|
errorString: String,
|
||||||
) {
|
) {
|
||||||
handler.post {
|
handler.post {
|
||||||
viewClient.onRenderProcessGone(
|
viewClient.onRenderProcessGone(
|
||||||
@@ -507,18 +515,33 @@ class KcefWebViewProvider(
|
|||||||
override fun onRequestMediaAccessPermission(
|
override fun onRequestMediaAccessPermission(
|
||||||
browser: CefBrowser,
|
browser: CefBrowser,
|
||||||
frame: CefFrame,
|
frame: CefFrame,
|
||||||
requesting_url: String,
|
requestingUrl: String,
|
||||||
requested_permissions: Int,
|
requestedPermissions: Int,
|
||||||
callback: CefMediaAccessCallback,
|
callback: CefMediaAccessCallback,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
handler.post {
|
handler.post {
|
||||||
Log.v(TAG, "Checking permission for $requesting_url: $requested_permissions")
|
Log.v(TAG, "Checking permission for $requestingUrl: $requestedPermissions")
|
||||||
chromeClient.onPermissionRequest(CefPermissionRequest(requesting_url, requested_permissions, callback))
|
chromeClient.onPermissionRequest(CefPermissionRequest(requestingUrl, requestedPermissions, callback))
|
||||||
}
|
}
|
||||||
return true
|
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(
|
override fun init(
|
||||||
javaScriptInterfaces: Map<String, Any>?,
|
javaScriptInterfaces: Map<String, Any>?,
|
||||||
privateBrowsing: Boolean,
|
privateBrowsing: Boolean,
|
||||||
@@ -526,16 +549,18 @@ class KcefWebViewProvider(
|
|||||||
Log.v(TAG, "KcefWebViewProvider: initialize")
|
Log.v(TAG, "KcefWebViewProvider: initialize")
|
||||||
destroy()
|
destroy()
|
||||||
kcefClient =
|
kcefClient =
|
||||||
KCEF.newClientBlocking().apply {
|
runBlocking {
|
||||||
addDisplayHandler(DisplayHandler())
|
CefHelper.createClient().apply {
|
||||||
addLoadHandler(LoadHandler())
|
addDisplayHandler(DisplayHandler())
|
||||||
addRequestHandler(RequestHandler())
|
addLoadHandler(LoadHandler())
|
||||||
addPermissionHandler(PermissionHandler())
|
addRequestHandler(RequestHandler())
|
||||||
|
addPermissionHandler(PermissionHandler())
|
||||||
|
|
||||||
val config = CefMessageRouter.CefMessageRouterConfig()
|
val config = CefMessageRouter.CefMessageRouterConfig()
|
||||||
config.jsQueryFunction = QUERY_FN
|
config.jsQueryFunction = QUERY_FN
|
||||||
config.jsCancelFunction = QUERY_CANCEL_FN
|
config.jsCancelFunction = QUERY_CANCEL_FN
|
||||||
addMessageRouter(CefMessageRouter.create(config, MessageRouterHandler()))
|
addMessageRouter(CefMessageRouter.create(config, MessageRouterHandler()))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
initHandler.init(this)
|
initHandler.init(this)
|
||||||
}
|
}
|
||||||
@@ -612,7 +637,8 @@ class KcefWebViewProvider(
|
|||||||
kcefClient!!
|
kcefClient!!
|
||||||
.createBrowser(
|
.createBrowser(
|
||||||
loadUrl,
|
loadUrl,
|
||||||
CefRendering.OFFSCREEN,
|
CefRendering.CefRenderingWithHandler(renderHandler, JPanel()),
|
||||||
|
false,
|
||||||
).apply {
|
).apply {
|
||||||
// NOTE: Without this, we don't seem to be receiving any events
|
// NOTE: Without this, we don't seem to be receiving any events
|
||||||
createImmediately()
|
createImmediately()
|
||||||
@@ -636,7 +662,8 @@ class KcefWebViewProvider(
|
|||||||
kcefClient!!
|
kcefClient!!
|
||||||
.createBrowser(
|
.createBrowser(
|
||||||
url,
|
url,
|
||||||
CefRendering.OFFSCREEN,
|
CefRendering.CefRenderingWithHandler(renderHandler, JPanel()),
|
||||||
|
false,
|
||||||
).apply {
|
).apply {
|
||||||
// NOTE: Without this, we don't seem to be receiving any events
|
// NOTE: Without this, we don't seem to be receiving any events
|
||||||
createImmediately()
|
createImmediately()
|
||||||
@@ -662,27 +689,19 @@ class KcefWebViewProvider(
|
|||||||
browser?.close(true)
|
browser?.close(true)
|
||||||
browser?.dispose()
|
browser?.dispose()
|
||||||
chromeClient.onProgressChanged(view, 0)
|
chromeClient.onProgressChanged(view, 0)
|
||||||
|
val url = baseUrl ?: "about:blank"
|
||||||
|
urlHttpMapping[url.trimEnd('/')] = data
|
||||||
|
|
||||||
browser =
|
browser =
|
||||||
(
|
kcefClient!!
|
||||||
baseUrl?.let { url ->
|
.createBrowser(
|
||||||
urlHttpMapping.put(url.trimEnd('/'), data)
|
url,
|
||||||
kcefClient!!.createBrowser(
|
CefRendering.CefRenderingWithHandler(renderHandler, JPanel()),
|
||||||
url,
|
false,
|
||||||
CefRendering.OFFSCREEN,
|
).apply {
|
||||||
)
|
// NOTE: Without this, we don't seem to be receiving any events
|
||||||
|
createImmediately()
|
||||||
}
|
}
|
||||||
?: 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")
|
Log.d(TAG, "Page loaded from data at base URL $baseUrl")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -692,11 +711,11 @@ class KcefWebViewProvider(
|
|||||||
) {
|
) {
|
||||||
browser!!.evaluateJavaScript(
|
browser!!.evaluateJavaScript(
|
||||||
script.removePrefix("javascript:"),
|
script.removePrefix("javascript:"),
|
||||||
|
)
|
||||||
{
|
{
|
||||||
Log.v(TAG, "JS returned: $it")
|
Log.v(TAG, "JS returned: $it")
|
||||||
it?.let { handler.post { resultCallback?.onReceiveValue(it) } }
|
it?.let { handler.post { resultCallback?.onReceiveValue(it) } }
|
||||||
},
|
}
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun saveWebArchive(filename: String): Unit = throw RuntimeException("Stub!")
|
override fun saveWebArchive(filename: String): Unit = throw RuntimeException("Stub!")
|
||||||
@@ -776,15 +795,23 @@ class KcefWebViewProvider(
|
|||||||
|
|
||||||
override fun getContentWidth(): Int = throw RuntimeException("Stub!")
|
override fun getContentWidth(): Int = throw RuntimeException("Stub!")
|
||||||
|
|
||||||
override fun pauseTimers(): Unit = throw RuntimeException("Stub!")
|
override fun pauseTimers() {
|
||||||
|
Log.v(TAG, "pauseTimers: doing nothing")
|
||||||
|
}
|
||||||
|
|
||||||
override fun resumeTimers(): Unit = throw RuntimeException("Stub!")
|
override fun resumeTimers() {
|
||||||
|
Log.v(TAG, "resumeTimers: doing nothing")
|
||||||
|
}
|
||||||
|
|
||||||
override fun onPause(): Unit = throw RuntimeException("Stub!")
|
override fun onPause() {
|
||||||
|
Log.v(TAG, "onPause: doing nothing")
|
||||||
|
}
|
||||||
|
|
||||||
override fun onResume(): Unit = throw RuntimeException("Stub!")
|
override fun onResume() {
|
||||||
|
Log.v(TAG, "onResume: doing nothing")
|
||||||
|
}
|
||||||
|
|
||||||
override fun isPaused(): Boolean = throw RuntimeException("Stub!")
|
override fun isPaused(): Boolean = false
|
||||||
|
|
||||||
override fun freeMemory(): Unit = throw RuntimeException("Stub!")
|
override fun freeMemory(): Unit = throw RuntimeException("Stub!")
|
||||||
|
|
||||||
@@ -838,6 +865,7 @@ class KcefWebViewProvider(
|
|||||||
|
|
||||||
override fun getWebChromeClient(): WebChromeClient = chromeClient
|
override fun getWebChromeClient(): WebChromeClient = chromeClient
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
override fun setPictureListener(listener: PictureListener): Unit = throw RuntimeException("Stub!")
|
override fun setPictureListener(listener: PictureListener): Unit = throw RuntimeException("Stub!")
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -860,7 +888,7 @@ class KcefWebViewProvider(
|
|||||||
obj: Any,
|
obj: Any,
|
||||||
interfaceName: String,
|
interfaceName: String,
|
||||||
) {
|
) {
|
||||||
val cls = obj::class as KClass<Any>
|
val cls = obj::class
|
||||||
mappings.addAll(
|
mappings.addAll(
|
||||||
cls.declaredMemberFunctions.map {
|
cls.declaredMemberFunctions.map {
|
||||||
// This is ridiculous, but necessary, otherwise "public final" throws
|
// This is ridiculous, but necessary, otherwise "public final" throws
|
||||||
@@ -922,7 +950,8 @@ class KcefWebViewProvider(
|
|||||||
override fun getRendererPriorityWaivedWhenNotVisible(): Boolean = throw RuntimeException("Stub!")
|
override fun getRendererPriorityWaivedWhenNotVisible(): Boolean = throw RuntimeException("Stub!")
|
||||||
|
|
||||||
@SuppressWarnings("unused")
|
@SuppressWarnings("unused")
|
||||||
override fun setTextClassifier(textClassifier: TextClassifier?) {}
|
override fun setTextClassifier(textClassifier: TextClassifier?) {
|
||||||
|
}
|
||||||
|
|
||||||
override fun getTextClassifier(): TextClassifier = TextClassifier.NO_OP
|
override fun getTextClassifier(): TextClassifier = TextClassifier.NO_OP
|
||||||
|
|
||||||
@@ -948,11 +977,13 @@ class KcefWebViewProvider(
|
|||||||
override fun onProvideAutofillVirtualStructure(
|
override fun onProvideAutofillVirtualStructure(
|
||||||
@SuppressWarnings("unused") structure: android.view.ViewStructure,
|
@SuppressWarnings("unused") structure: android.view.ViewStructure,
|
||||||
@SuppressWarnings("unused") flags: Int,
|
@SuppressWarnings("unused") flags: Int,
|
||||||
) {}
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
override fun autofill(
|
override fun autofill(
|
||||||
@SuppressWarnings("unused") values: SparseArray<AutofillValue>,
|
@SuppressWarnings("unused") values: SparseArray<AutofillValue>,
|
||||||
) {}
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
override fun isVisibleToUserForAutofill(
|
override fun isVisibleToUserForAutofill(
|
||||||
@SuppressWarnings("unused") virtualId: Int,
|
@SuppressWarnings("unused") virtualId: Int,
|
||||||
@@ -963,7 +994,8 @@ class KcefWebViewProvider(
|
|||||||
override fun onProvideContentCaptureStructure(
|
override fun onProvideContentCaptureStructure(
|
||||||
@SuppressWarnings("unused") structure: android.view.ViewStructure,
|
@SuppressWarnings("unused") structure: android.view.ViewStructure,
|
||||||
@SuppressWarnings("unused") flags: Int,
|
@SuppressWarnings("unused") flags: Int,
|
||||||
) {}
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
override fun getAccessibilityNodeProvider(): AccessibilityNodeProvider = throw RuntimeException("Stub!")
|
override fun getAccessibilityNodeProvider(): AccessibilityNodeProvider = throw RuntimeException("Stub!")
|
||||||
|
|
||||||
@@ -1033,7 +1065,8 @@ class KcefWebViewProvider(
|
|||||||
override fun onMovedToDisplay(
|
override fun onMovedToDisplay(
|
||||||
displayId: Int,
|
displayId: Int,
|
||||||
config: Configuration,
|
config: Configuration,
|
||||||
) {}
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
override fun onVisibilityChanged(
|
override fun onVisibilityChanged(
|
||||||
changedView: View,
|
changedView: View,
|
||||||
|
|||||||
17
CHANGELOG.md
17
CHANGELOG.md
@@ -7,21 +7,30 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|||||||
## [Unreleased] (Preview)
|
## [Unreleased] (Preview)
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- .
|
- (**Sync**) Added [SyncYomi](https://github.com/syncyomi/syncyomi) support
|
||||||
|
- (**OPDS**) Add option to skip chapter metadata feed providing direct stream/download links
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- (Database/H2) Use the latest H2 database engine
|
- (**Database/H2**) Use the latest H2 database engine
|
||||||
- (Startup) Crash on startup if an unrecoverable error happens
|
- (**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
|
### Fixed
|
||||||
- (**CloudFlareInterceptor**) Don't send the `cf_clearance` cookie back to Flaresolverr
|
- (**CloudFlareInterceptor**) Don't send the `cf_clearance` cookie back to Flaresolverr
|
||||||
- (**WebUI**) Handle serving non-default webui with "bundled"
|
- (**WebUI**) Handle serving non-default webui with "bundled"
|
||||||
- (**WebUI**) Wait until WebUI is ready to open in browser
|
- (**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**) 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
|
- (**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 losing chapter data on failed chapter list update
|
||||||
- (**Chapter**) Fix database error when fetching chapter updates
|
- (**Chapter**) Fix database error when fetching chapter updates
|
||||||
- (**Manga/API**) Fix "mangas" graphql query with active sorting and using a postgresql database (QUERY "mangas")
|
- (**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
|
||||||
|
|
||||||
## [v2.2.2100] + [WebUI: v20260508.01] - 2026-05-08
|
## [v2.2.2100] + [WebUI: v20260508.01] - 2026-05-08
|
||||||
|
|
||||||
|
|||||||
20
README.md
20
README.md
@@ -8,7 +8,7 @@
|
|||||||
- [Features](#features)
|
- [Features](#features)
|
||||||
- [Suwayomi client projects](#suwayomi-client-projects)
|
- [Suwayomi client projects](#suwayomi-client-projects)
|
||||||
- [Integrated clients](#integrated-clients)
|
- [Integrated clients](#integrated-clients)
|
||||||
- [Other clients](#other-clients-potentially-inactive-or-abondend)
|
- [Other clients](#other-clients-potentially-inactive-or-abandoned)
|
||||||
- [Downloading and Running the app](#downloading-and-running-the-app)
|
- [Downloading and Running the app](#downloading-and-running-the-app)
|
||||||
- [Using Operating System Specific Bundles](#using-operating-system-specific-bundles)
|
- [Using Operating System Specific Bundles](#using-operating-system-specific-bundles)
|
||||||
- [Windows](#windows)
|
- [Windows](#windows)
|
||||||
@@ -82,6 +82,7 @@ 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-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-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
|
- [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
|
# Downloading and Running the app
|
||||||
## Using Operating System Specific Bundles
|
## Using Operating System Specific Bundles
|
||||||
@@ -106,23 +107,22 @@ Download the latest `linux-x64`(x86_64) release from [the releases section](http
|
|||||||
|
|
||||||
#### WebView support (GNU/Linux)
|
#### WebView support (GNU/Linux)
|
||||||
|
|
||||||
WebView support is implemented via [KCEF](https://github.com/DATL4G/KCEF).
|
WebView support is implemented via [JCEF](https://github.com/JetBrains/jcef).
|
||||||
This is optional, and is only necessary to support some extensions.
|
This is optional, and is only necessary to support some extensions.
|
||||||
|
|
||||||
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`).
|
To have a functional WebView, some X11 dependencies are required for rendering Chromium.
|
||||||
Note that on some systems (e.g. Ubuntu), the JNI libraries are not automatically found, see below.
|
These include `libxrender`, `libxcomposite` `libxdamage`, `libxkbcommon` and `libxtst`.
|
||||||
|
|
||||||
A KCEF server is launched on startup, which loads the X11 libraries.
|
A CEF server is launched on startup, which loads the X11 libraries.
|
||||||
If those are missing, you should see "Could not load 'jcef' library".
|
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.
|
If so, use `ldd ~/.local/share/Tachidesk/bin/kcef/libjcef.so | grep not` to figure out which libraries are not found on your system.
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
Refer to the [Dockerfile](https://github.com/Suwayomi/Suwayomi-Server-docker/blob/main/Dockerfile) for more details.
|
Refer to the [Dockerfile](https://github.com/Suwayomi/Suwayomi-Server-docker/blob/main/Dockerfile) for more details.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
## Other methods of getting Suwayomi
|
## Other methods of getting Suwayomi
|
||||||
### Docker
|
### Docker
|
||||||
Check our Official Docker release [Suwayomi Container](https://github.com/orgs/Suwayomi/packages/container/package/tachidesk) for running Suwayomi Server in a docker container. Source code for our container is available at [docker-tachidesk](https://github.com/Suwayomi/docker-tachidesk), an example compose file can also be found there. By default, the server will be running on http://localhost:4567 open this url in your browser.
|
Check our Official Docker release [Suwayomi Container](https://github.com/orgs/Suwayomi/packages/container/package/tachidesk) for running Suwayomi Server in a docker container. Source code for our container is available at [docker-tachidesk](https://github.com/Suwayomi/docker-tachidesk), an example compose file can also be found there. By default, the server will be running on http://localhost:4567 open this url in your browser.
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ allprojects {
|
|||||||
maven("https://github.com/Suwayomi/Suwayomi-Server/raw/android-jar/")
|
maven("https://github.com/Suwayomi/Suwayomi-Server/raw/android-jar/")
|
||||||
maven("https://jitpack.io")
|
maven("https://jitpack.io")
|
||||||
maven("https://jogamp.org/deployment/maven")
|
maven("https://jogamp.org/deployment/maven")
|
||||||
|
maven("https://packages.jetbrains.team/maven/p/ij/intellij-dependencies")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ val getTachideskVersion = { "v2.2.${getCommitCount()}" }
|
|||||||
|
|
||||||
val webUIRevisionTag = "r3136"
|
val webUIRevisionTag = "r3136"
|
||||||
|
|
||||||
|
val webviewJbrRelease = "jbr-release-25.0.3b508.16"
|
||||||
|
|
||||||
private val getCommitCount = {
|
private val getCommitCount = {
|
||||||
runCatching {
|
runCatching {
|
||||||
ProcessBuilder()
|
ProcessBuilder()
|
||||||
|
|||||||
@@ -63,6 +63,14 @@ server.webUISubpath = ""
|
|||||||
- `server.webUIUpdateCheckInterval` the interval time in hours at which to check for updates. Use `0` to disable update checking.
|
- `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`
|
- `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
|
### Downloader
|
||||||
```
|
```
|
||||||
server.downloadAsCbz = true
|
server.downloadAsCbz = true
|
||||||
@@ -224,6 +232,7 @@ server.opdsShowOnlyUnreadChapters = false
|
|||||||
server.opdsShowOnlyDownloadedChapters = false
|
server.opdsShowOnlyDownloadedChapters = false
|
||||||
server.opdsChapterSortOrder = "DESC"
|
server.opdsChapterSortOrder = "DESC"
|
||||||
server.opdsCbzMimetype = "MODERN"
|
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.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.
|
- `server.opdsItemsPerPage = 50` sets the number of items per page in OPDS listings. Range: 10 <= n <= 5000.
|
||||||
@@ -233,6 +242,7 @@ server.opdsCbzMimetype = "MODERN"
|
|||||||
- `server.opdsShowOnlyDownloadedChapters = false` controls if OPDS listings should only include downloaded chapters.
|
- `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.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.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
|
### KOReader Sync
|
||||||
```
|
```
|
||||||
@@ -268,6 +278,28 @@ server.useHikariConnectionPool = true
|
|||||||
- `server.databasePassword` the username with which to authenticate at the PostgreSQL instance.
|
- `server.databasePassword` the username with which to authenticate at the PostgreSQL instance.
|
||||||
- `server.useHikariConnectionPool` use Hikari Connection Pool to connect to the database.
|
- `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:** 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.
|
**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.
|
||||||
|
|||||||
@@ -52,6 +52,29 @@ Solutions:
|
|||||||
- 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).
|
- 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
|
## General Troubleshooting
|
||||||
This guide will try to fix Suwayomi by reseting it to a clean installation state.
|
This guide will try to fix Suwayomi by reseting it to a clean installation state.
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,23 @@
|
|||||||
[versions]
|
[versions]
|
||||||
kotlin = "2.3.21"
|
kotlin = "2.4.0"
|
||||||
coroutines = "1.11.0"
|
coroutines = "1.11.0"
|
||||||
serialization = "1.11.0"
|
serialization = "1.11.0"
|
||||||
jvmTarget = "21"
|
jvmTarget = "21"
|
||||||
okhttp = "5.3.2" # Major version is locked by Tachiyomi extensions
|
okhttp = "5.4.0" # Major version is locked by Tachiyomi extensions
|
||||||
javalin = "7.2.2"
|
javalin = "7.2.2"
|
||||||
jte = "3.2.4"
|
jte = "3.2.4"
|
||||||
jackson = "3.1.3" # jackson version locked by javalin, ref: `io.javalin.core.util.OptionalDependency`
|
jackson = "3.2.0" # jackson version locked by javalin, ref: `io.javalin.core.util.OptionalDependency`
|
||||||
exposed = "1.2.0"
|
exposed = "1.2.0"
|
||||||
dex2jar = "2.4.36"
|
dex2jar = "2.4.37"
|
||||||
polyglot = "25.0.3"
|
polyglot = "25.0.3"
|
||||||
settings = "1.3.0"
|
settings = "1.3.0"
|
||||||
twelvemonkeys = "3.13.1"
|
twelvemonkeys = "3.13.1"
|
||||||
graphqlkotlin = "10.0.0-alpha.3"
|
graphqlkotlin = "10.0.0"
|
||||||
xmlserialization = "0.91.3"
|
xmlserialization = "0.91.3"
|
||||||
ktlint = "1.8.0"
|
ktlint = "1.8.0"
|
||||||
koin = "4.2.1"
|
koin = "4.2.2"
|
||||||
moko = "0.26.4"
|
moko = "0.26.4"
|
||||||
|
jcef = "144.0.15-g72717cf-chromium-144.0.7559.172-api-1.21-262-b37"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
# Kotlin
|
# Kotlin
|
||||||
@@ -38,8 +39,8 @@ serialization-xml = { module = "io.github.pdvrieze.xmlutil:serialization-jvm", v
|
|||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
slf4japi = "org.slf4j:slf4j-api:2.0.18"
|
slf4japi = "org.slf4j:slf4j-api:2.0.18"
|
||||||
logback = "ch.qos.logback:logback-classic:1.5.32"
|
logback = "ch.qos.logback:logback-classic:1.5.34"
|
||||||
kotlinlogging = "io.github.oshai:kotlin-logging-jvm:8.0.02"
|
kotlinlogging = "io.github.oshai:kotlin-logging-jvm:8.0.4"
|
||||||
|
|
||||||
# OkHttp
|
# OkHttp
|
||||||
okhttp-core = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
|
okhttp-core = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
|
||||||
@@ -54,7 +55,7 @@ javalin-openapi = { module = "io.javalin:javalin-openapi", version.ref = "javali
|
|||||||
javalin-rendering = { module = "io.javalin:javalin-rendering-jte", version.ref = "javalin" }
|
javalin-rendering = { module = "io.javalin:javalin-rendering-jte", version.ref = "javalin" }
|
||||||
jackson-databind = { module = "tools.jackson.core:jackson-databind", version.ref = "jackson" }
|
jackson-databind = { module = "tools.jackson.core:jackson-databind", version.ref = "jackson" }
|
||||||
jackson-kotlin = { module = "tools.jackson.module:jackson-module-kotlin", version.ref = "jackson" }
|
jackson-kotlin = { module = "tools.jackson.module:jackson-module-kotlin", version.ref = "jackson" }
|
||||||
jackson-annotations = "com.fasterxml.jackson.core:jackson-annotations:2.21"
|
jackson-annotations = "com.fasterxml.jackson.core:jackson-annotations:2.22"
|
||||||
jte = { module = "gg.jte:jte", version.ref = "jte" }
|
jte = { module = "gg.jte:jte", version.ref = "jte" }
|
||||||
kte = { module = "gg.jte:jte-kotlin", version.ref = "jte" }
|
kte = { module = "gg.jte:jte-kotlin", version.ref = "jte" }
|
||||||
|
|
||||||
@@ -71,7 +72,7 @@ exposed-javatime = { module = "org.jetbrains.exposed:exposed-java-time", version
|
|||||||
exposed-kotlintime = { module = "org.jetbrains.exposed:exposed-kotlin-datetime", version.ref = "exposed" }
|
exposed-kotlintime = { module = "org.jetbrains.exposed:exposed-kotlin-datetime", version.ref = "exposed" }
|
||||||
postgres = "org.postgresql:postgresql:42.7.11"
|
postgres = "org.postgresql:postgresql:42.7.11"
|
||||||
h2 = "com.h2database:h2:2.4.240"
|
h2 = "com.h2database:h2:2.4.240"
|
||||||
hikaricp = "com.zaxxer:HikariCP:7.0.2"
|
hikaricp = "com.zaxxer:HikariCP:7.1.0"
|
||||||
|
|
||||||
# Exposed Migrations
|
# Exposed Migrations
|
||||||
exposed-migrations = "com.github.Suwayomi:exposed-migrations:3.10.1"
|
exposed-migrations = "com.github.Suwayomi:exposed-migrations:3.10.1"
|
||||||
@@ -90,7 +91,7 @@ rxjava = "io.reactivex:rxjava:1.3.8"
|
|||||||
jsoup = "org.jsoup:jsoup:1.22.2"
|
jsoup = "org.jsoup:jsoup:1.22.2"
|
||||||
|
|
||||||
# Config
|
# Config
|
||||||
config = "com.typesafe:config:1.4.8"
|
config = "com.typesafe:config:1.4.9"
|
||||||
config4k = "io.github.config4k:config4k:0.7.0"
|
config4k = "io.github.config4k:config4k:0.7.0"
|
||||||
|
|
||||||
# Sort
|
# Sort
|
||||||
@@ -144,10 +145,10 @@ twelvemonkeys-imageio-metadata = { module = "com.twelvemonkeys.imageio:imageio-m
|
|||||||
twelvemonkeys-imageio-jpeg = { module = "com.twelvemonkeys.imageio:imageio-jpeg", version.ref = "twelvemonkeys" }
|
twelvemonkeys-imageio-jpeg = { module = "com.twelvemonkeys.imageio:imageio-jpeg", version.ref = "twelvemonkeys" }
|
||||||
twelvemonkeys-imageio-webp = { module = "com.twelvemonkeys.imageio:imageio-webp", version.ref = "twelvemonkeys" }
|
twelvemonkeys-imageio-webp = { module = "com.twelvemonkeys.imageio:imageio-webp", version.ref = "twelvemonkeys" }
|
||||||
|
|
||||||
imageio-webp = "com.github.usefulness:webp-imageio:0.10.2"
|
imageio-webp = "com.github.usefulness:webp-imageio:0.11.0"
|
||||||
|
|
||||||
# Testing
|
# Testing
|
||||||
mockk = "io.mockk:mockk:1.14.9"
|
mockk = "io.mockk:mockk:1.14.11"
|
||||||
|
|
||||||
# cron scheduler
|
# cron scheduler
|
||||||
cron4j = "it.sauronsoftware.cron4j:cron4j:2.2.5"
|
cron4j = "it.sauronsoftware.cron4j:cron4j:2.2.5"
|
||||||
@@ -156,7 +157,7 @@ cron4j = "it.sauronsoftware.cron4j:cron4j:2.2.5"
|
|||||||
cronUtils = "com.cronutils:cron-utils:9.2.1"
|
cronUtils = "com.cronutils:cron-utils:9.2.1"
|
||||||
|
|
||||||
# Webview
|
# Webview
|
||||||
kcef = "dev.datlag:kcef:2024.04.20.4"
|
jcef = { module = "org.jetbrains.intellij.deps.jcef:jcef", version.ref = "jcef" }
|
||||||
|
|
||||||
# User
|
# User
|
||||||
jwt = "com.auth0:java-jwt:4.5.2"
|
jwt = "com.auth0:java-jwt:4.5.2"
|
||||||
@@ -177,13 +178,13 @@ kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref
|
|||||||
ktlint = { id = "org.jlleitschuh.gradle.ktlint", version = "14.2.0"}
|
ktlint = { id = "org.jlleitschuh.gradle.ktlint", version = "14.2.0"}
|
||||||
|
|
||||||
# Build config
|
# Build config
|
||||||
buildconfig = { id = "com.github.gmazzo.buildconfig", version = "6.0.9"}
|
buildconfig = { id = "com.github.gmazzo.buildconfig", version = "6.0.10"}
|
||||||
|
|
||||||
# Download
|
# Download
|
||||||
download = { id = "de.undercouch.download", version = "5.7.0"}
|
download = { id = "de.undercouch.download", version = "5.7.0"}
|
||||||
|
|
||||||
# ShadowJar
|
# ShadowJar
|
||||||
shadowjar = { id = "com.gradleup.shadow", version = "8.3.10"}
|
shadowjar = { id = "com.gradleup.shadow", version = "8.3.11"}
|
||||||
|
|
||||||
# Moko
|
# Moko
|
||||||
moko = { id = "dev.icerock.mobile.multiplatform-resources", version.ref = "moko" }
|
moko = { id = "dev.icerock.mobile.multiplatform-resources", version.ref = "moko" }
|
||||||
@@ -213,7 +214,7 @@ shared = [
|
|||||||
"dex2jar-tools",
|
"dex2jar-tools",
|
||||||
"apk-parser",
|
"apk-parser",
|
||||||
"jackson-annotations",
|
"jackson-annotations",
|
||||||
"kcef"
|
"jcef",
|
||||||
]
|
]
|
||||||
|
|
||||||
sharedTest = [
|
sharedTest = [
|
||||||
|
|||||||
@@ -16,14 +16,43 @@
|
|||||||
"depNameTemplate": "zulu",
|
"depNameTemplate": "zulu",
|
||||||
"datasourceTemplate": "custom.zulu",
|
"datasourceTemplate": "custom.zulu",
|
||||||
"versioningTemplate": "regex:^(?<major>\\d+)\\.(?<minor>\\d+)\\.(?<patch>\\d+).*$"
|
"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": {
|
"customDatasources": {
|
||||||
"zulu": {
|
"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",
|
"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": [
|
"transformTemplates": [
|
||||||
"{\"releases\": $$.$join([$join($map(distro_version[[0..2]], $string), \".\"), \"_\", $join($map(java_version, $string), \".\")])}"
|
"{ \"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])]) } }) }"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ main() {
|
|||||||
gcc -fPIC -shared scripts/resources/catch_abort.c -lpthread -o scripts/resources/catch_abort.so
|
gcc -fPIC -shared scripts/resources/catch_abort.c -lpthread -o scripts/resources/catch_abort.so
|
||||||
fi
|
fi
|
||||||
|
|
||||||
JRE_ZULU="25.30.17_25.0.1"
|
JRE_ZULU="25.34.17_25.0.3"
|
||||||
JRE_RELEASE="jre${JRE_ZULU#*_}" # e.g. jre25.0.1
|
JRE_RELEASE="jre${JRE_ZULU#*_}" # e.g. jre25.0.1
|
||||||
ZULU_RELEASE="zulu${JRE_ZULU%_*}" # e.g. zulu25.30.17
|
ZULU_RELEASE="zulu${JRE_ZULU%_*}" # e.g. zulu25.30.17
|
||||||
|
|
||||||
@@ -149,15 +149,15 @@ move_release_to_output_dir() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
download_launcher() {
|
download_launcher() {
|
||||||
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)
|
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 -L "$LAUNCHER_URL" -o "Suwayomi-Launcher.jar"
|
curl -fL "$LAUNCHER_URL" -o "Suwayomi-Launcher.jar"
|
||||||
mv "Suwayomi-Launcher.jar" "$RELEASE_NAME/Suwayomi-Launcher.jar"
|
mv "Suwayomi-Launcher.jar" "$RELEASE_NAME/Suwayomi-Launcher.jar"
|
||||||
}
|
}
|
||||||
|
|
||||||
download_jogamp() {
|
download_jogamp() {
|
||||||
local platform="$1"
|
local platform="$1"
|
||||||
if [ ! -f jogamp-all-platforms.7z ]; then
|
if [ ! -f jogamp-all-platforms.7z ]; then
|
||||||
curl "https://jogamp.org/deployment/jogamp-current/archive/jogamp-all-platforms.7z" -o jogamp-all-platforms.7z
|
curl -f "https://jogamp.org/deployment/jogamp-current/archive/jogamp-all-platforms.7z" -o jogamp-all-platforms.7z
|
||||||
fi
|
fi
|
||||||
|
|
||||||
7z x jogamp-all-platforms.7z "jogamp-all-platforms/lib/$platform/"
|
7z x jogamp-all-platforms.7z "jogamp-all-platforms/lib/$platform/"
|
||||||
@@ -168,7 +168,7 @@ download_jogamp() {
|
|||||||
|
|
||||||
download_electron() {
|
download_electron() {
|
||||||
if [ ! -f "$ELECTRON" ]; then
|
if [ ! -f "$ELECTRON" ]; then
|
||||||
curl -L "$ELECTRON_URL" -o "$ELECTRON"
|
curl -fL "$ELECTRON_URL" -o "$ELECTRON"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
unzip "$ELECTRON" -d "$RELEASE_NAME/electron/"
|
unzip "$ELECTRON" -d "$RELEASE_NAME/electron/"
|
||||||
@@ -181,7 +181,7 @@ setup_jre() {
|
|||||||
mv "jre" "$RELEASE_NAME/jre"
|
mv "jre" "$RELEASE_NAME/jre"
|
||||||
else
|
else
|
||||||
if [ ! -f "$JRE" ]; then
|
if [ ! -f "$JRE" ]; then
|
||||||
curl -L "$JRE_URL" -o "$JRE"
|
curl -fL "$JRE_URL" -o "$JRE"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
local ext="${JRE##*.}"
|
local ext="${JRE##*.}"
|
||||||
@@ -273,7 +273,7 @@ make_appimage() {
|
|||||||
sudo apt update
|
sudo apt update
|
||||||
sudo apt install libfuse2
|
sudo apt install libfuse2
|
||||||
fi
|
fi
|
||||||
curl -L $APPIMAGE_URL -o $APPIMAGE_TOOLNAME
|
curl -fL $APPIMAGE_URL -o $APPIMAGE_TOOLNAME
|
||||||
chmod +x $APPIMAGE_TOOLNAME
|
chmod +x $APPIMAGE_TOOLNAME
|
||||||
ARCH=x86_64 ./$APPIMAGE_TOOLNAME "$RELEASE_NAME" "$RELEASE"
|
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"
|
#local rcedit_url="https://github.com/electron/rcedit/releases/download/v0.1.1/$rcedit"
|
||||||
## change electron's icon
|
## change electron's icon
|
||||||
#if [ ! -f "$rcedit" ]; then
|
#if [ ! -f "$rcedit" ]; then
|
||||||
#curl -L "$rcedit_url" -o "$rcedit"
|
#curl -fL "$rcedit_url" -o "$rcedit"
|
||||||
#fi
|
#fi
|
||||||
|
|
||||||
#local icon="server/src/main/resources/icon/faviconlogo.ico"
|
#local icon="server/src/main/resources/icon/faviconlogo.ico"
|
||||||
|
|||||||
@@ -159,6 +159,8 @@ buildConfig {
|
|||||||
|
|
||||||
buildConfigField("String", "GITHUB", quoteWrap("https://github.com/Suwayomi/Suwayomi-Server"))
|
buildConfigField("String", "GITHUB", quoteWrap("https://github.com/Suwayomi/Suwayomi-Server"))
|
||||||
buildConfigField("String", "DISCORD", quoteWrap("https://discord.gg/DDZdqZWaHA"))
|
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 {
|
tasks {
|
||||||
@@ -172,6 +174,7 @@ tasks {
|
|||||||
"Specification-Version" to getTachideskVersion(),
|
"Specification-Version" to getTachideskVersion(),
|
||||||
"Implementation-Version" to getTachideskRevision(),
|
"Implementation-Version" to getTachideskRevision(),
|
||||||
"Multi-Release" to true, // needed for polyglot
|
"Multi-Release" to true, // needed for polyglot
|
||||||
|
"X-JBR-Release" to webviewJbrRelease,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
archiveBaseName.set(rootProject.name)
|
archiveBaseName.set(rootProject.name)
|
||||||
@@ -182,7 +185,11 @@ tasks {
|
|||||||
}
|
}
|
||||||
|
|
||||||
test {
|
test {
|
||||||
useJUnitPlatform()
|
useJUnitPlatform {
|
||||||
|
if (!project.hasProperty("masstest")) {
|
||||||
|
exclude("**/masstest/*")
|
||||||
|
}
|
||||||
|
}
|
||||||
testLogging {
|
testLogging {
|
||||||
showStandardStreams = true
|
showStandardStreams = true
|
||||||
events("passed", "skipped", "failed")
|
events("passed", "skipped", "failed")
|
||||||
|
|||||||
@@ -50,6 +50,7 @@
|
|||||||
<!-- OPDS errors -->
|
<!-- OPDS errors -->
|
||||||
<string name="opds_error_manga_not_found">Series with ID %1$d not found.</string>
|
<string name="opds_error_manga_not_found">Series with ID %1$d not found.</string>
|
||||||
<string name="opds_error_chapter_not_found">Chapter with index %1$d not found.</string>
|
<string name="opds_error_chapter_not_found">Chapter with index %1$d not found.</string>
|
||||||
|
<string name="opds_error_chapters_not_found">No chapters found or the source is unreachable on page %1$d.</string>
|
||||||
|
|
||||||
<!-- OPDS facets (Filters and Sorting) -->
|
<!-- OPDS facets (Filters and Sorting) -->
|
||||||
<string name="opds_facetgroup_sort_order">Sort Order</string>
|
<string name="opds_facetgroup_sort_order">Sort Order</string>
|
||||||
@@ -153,4 +154,7 @@
|
|||||||
<string name="login_label_login">Log In</string>
|
<string name="login_label_login">Log In</string>
|
||||||
<string name="login_placeholder_username">Type username...</string>
|
<string name="login_placeholder_username">Type username...</string>
|
||||||
<string name="login_placeholder_password">Secret...</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>
|
</resources>
|
||||||
|
|||||||
125
server/i18n/src/commonMain/moko-resources/values/el/strings.xml
Normal file
125
server/i18n/src/commonMain/moko-resources/values/el/strings.xml
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||||
|
<string name="opds_feeds_root">Suwayomi OPDS Κατάλογος</string>
|
||||||
|
<string name="opds_feeds_manga_chapters">Κεφάλαια %1$s</string>
|
||||||
|
<string name="opds_feeds_chapter_details">%1$s | %2$s | Λεπτομέρειες</string>
|
||||||
|
<string name="opds_feeds_explore_title">Εξερεύνηση</string>
|
||||||
|
<string name="opds_feeds_explore_entry_content">Εξερεύνησε νέες σειρές από τις πηγές σου</string>
|
||||||
|
<string name="opds_feeds_history_title">Ιστορικό</string>
|
||||||
|
<string name="opds_feeds_history_entry_content">Πρόσφατα διαβασμένα κεφάλαια</string>
|
||||||
|
<string name="opds_feeds_all_series_in_library_title">Όλες οι σειρές</string>
|
||||||
|
<string name="opds_feeds_all_series_in_library_entry_content">Περιήγηση σε όλες τις σειρές της βιβλιοθήκης σου</string>
|
||||||
|
<string name="opds_feeds_library_sources_title">Πηγές</string>
|
||||||
|
<string name="opds_feeds_library_sources_entry_content">Περιήγηση σε σειρές της βιβλιοθήκης σου φιλτραρισμένες κατά πηγή</string>
|
||||||
|
<string name="opds_feeds_categories_title">Κατηγορίες</string>
|
||||||
|
<string name="opds_feeds_categories_entry_content">Περιήγηση σε σειρές οργανωμένες κατά κατηγορία</string>
|
||||||
|
<string name="opds_feeds_genres_title">Είδη</string>
|
||||||
|
<string name="opds_feeds_genres_entry_content">Περιήγηση σε σειρές κατά ετικέτες είδους</string>
|
||||||
|
<string name="opds_feeds_status_title">Κατάσταση</string>
|
||||||
|
<string name="opds_feeds_status_entry_content">Περιήγηση σε σειρές κατά κατάσταση δημοσίευσης</string>
|
||||||
|
<string name="opds_feeds_languages_title">Γλώσσες</string>
|
||||||
|
<string name="opds_feeds_languages_entry_content">Περιήγηση σε σειρές κατά γλώσσα περιεχομένου</string>
|
||||||
|
<string name="opds_feeds_library_updates_title">Ιστορικό ενημερώσεων βιβλιοθήκης</string>
|
||||||
|
<string name="opds_feeds_library_updates_entry_content">Πρόσφατα ενημερωμένα κεφάλαια από τη βιβλιοθήκη σου</string>
|
||||||
|
<string name="opds_feeds_search_results_title">Αποτελέσματα αναζήτησης</string>
|
||||||
|
<string name="opds_feeds_sources_title">Όλες οι πηγές</string>
|
||||||
|
<string name="opds_feeds_category_specific_title">Κατηγορία: %1$s</string>
|
||||||
|
<string name="opds_feeds_genre_specific_title">Είδος: %1$s</string>
|
||||||
|
<string name="opds_feeds_status_specific_title">Κατάσταση: %1$s</string>
|
||||||
|
<string name="opds_feeds_language_specific_title">Γλώσσα: %1$s</string>
|
||||||
|
<string name="opds_feeds_source_specific_title">Πηγή: %1$s</string>
|
||||||
|
<string name="opds_feeds_library_source_specific_title">Βιβλιοθήκη - Πηγή: %1$s</string>
|
||||||
|
<string name="opds_feeds_source_specific_popular_title">Πηγή: %1$s - Δημοφιλές</string>
|
||||||
|
<string name="opds_feeds_source_specific_latest_title">Πηγή: %1$s - Τελευταίο</string>
|
||||||
|
<string name="opds_search_shortname">Suwayomi OPDS Αναζήτηση</string>
|
||||||
|
<string name="opds_search_description">Αναζήτηση σειρών στον κατάλογο.</string>
|
||||||
|
<string name="opds_error_manga_not_found">Η σειρά με ID %1$d δεν βρέθηκε.</string>
|
||||||
|
<string name="opds_error_chapter_not_found">Το κεφάλαιο με δείκτη %1$d δεν βρέθηκε.</string>
|
||||||
|
<string name="opds_facetgroup_sort_order">Σειρά ταξινόμησης</string>
|
||||||
|
<string name="opds_facetgroup_filter_read_status">Φίλτρο κατά κατάσταση ανάγνωσης</string>
|
||||||
|
<string name="opds_facetgroup_filter_content">Φίλτρο περιεχομένου</string>
|
||||||
|
<string name="opds_facetgroup_filter_source">Φίλτρο κατά πηγή</string>
|
||||||
|
<string name="opds_facetgroup_filter_category">Φίλτρο κατά κατηγορία</string>
|
||||||
|
<string name="opds_facetgroup_filter_status">Φίλτρο κατά κατάσταση</string>
|
||||||
|
<string name="opds_facetgroup_filter_language">Φίλτρο κατά γλώσσα</string>
|
||||||
|
<string name="opds_facetgroup_filter_genre">Φίλτρο κατά είδος</string>
|
||||||
|
<string name="opds_facet_sort_oldest_first">Παλαιότερα πρώτα</string>
|
||||||
|
<string name="opds_facet_sort_newest_first">Νεότερα πρώτα</string>
|
||||||
|
<string name="opds_facet_sort_date_asc">Ημερομηνία αύξουσα</string>
|
||||||
|
<string name="opds_facet_sort_date_desc">Ημερομηνία φθίνουσα</string>
|
||||||
|
<string name="opds_facet_sort_popular">Δημοφιλές</string>
|
||||||
|
<string name="opds_facet_sort_latest">Τελευταίο</string>
|
||||||
|
<string name="opds_facet_sort_alpha_asc">Αλφαβητικά Α-Ω</string>
|
||||||
|
<string name="opds_facet_sort_alpha_desc">Αλφαβητικά Ω-Α</string>
|
||||||
|
<string name="opds_facet_sort_last_read_desc">Τελευταία ανάγνωση</string>
|
||||||
|
<string name="opds_facet_sort_latest_chapter_desc">Τελευταίο κεφάλαιο</string>
|
||||||
|
<string name="opds_facet_sort_date_added_desc">Ημερομηνία προσθήκης</string>
|
||||||
|
<string name="opds_facet_sort_unread_desc">Αδιάβαστα κεφάλαια</string>
|
||||||
|
<string name="opds_facet_filter_all">Όλα</string>
|
||||||
|
<string name="opds_facet_filter_all_chapters">Όλα τα κεφάλαια</string>
|
||||||
|
<string name="opds_facet_filter_unread_only">Αδιάβαστα</string>
|
||||||
|
<string name="opds_facet_filter_read_only">Διαβασμένα</string>
|
||||||
|
<string name="opds_facet_filter_downloaded">Ληφθέντα</string>
|
||||||
|
<string name="opds_facet_filter_ongoing">Σε εξέλιξη</string>
|
||||||
|
<string name="opds_facet_filter_completed">Ολοκληρωμένα</string>
|
||||||
|
<string name="opds_facet_all_sources">Όλες οι πηγές</string>
|
||||||
|
<string name="opds_facet_all_categories">Όλες οι κατηγορίες</string>
|
||||||
|
<string name="opds_facet_all_statuses">Όλες οι καταστάσεις</string>
|
||||||
|
<string name="opds_facet_all_languages">Όλες οι γλώσσες</string>
|
||||||
|
<string name="opds_facet_all_genres">Όλα τα είδη</string>
|
||||||
|
<string name="opds_linktitle_catalog_root">Ρίζα καταλόγου</string>
|
||||||
|
<string name="opds_linktitle_search_catalog">Αναζήτηση καταλόγου</string>
|
||||||
|
<string name="opds_linktitle_first_page">Πρώτη σελίδα</string>
|
||||||
|
<string name="opds_linktitle_previous_page">Προηγούμενη σελίδα</string>
|
||||||
|
<string name="opds_linktitle_next_page">Επόμενη σελίδα</string>
|
||||||
|
<string name="opds_linktitle_last_page">Τελευταία σελίδα</string>
|
||||||
|
<string name="opds_linktitle_self_feed">Τρέχουσα ροή</string>
|
||||||
|
<string name="opds_linktitle_view_on_web">Προβολή στο Web</string>
|
||||||
|
<string name="opds_linktitle_stream_pages_start">Διάβασε Online</string>
|
||||||
|
<string name="opds_linktitle_stream_pages_continue">Συνέχισε να διαβάζεις Online</string>
|
||||||
|
<string name="opds_linktitle_stream_pages_start_local">Διάβασε Online (Τοπική πρόοδος)</string>
|
||||||
|
<string name="opds_linktitle_stream_pages_continue_local">Συνέχισε να διαβάζεις Online (Τοπική πρόοδος)</string>
|
||||||
|
<string name="opds_linktitle_stream_pages_start_remote">Διάβασε Online (Συγχρονισμένο από %1$s)</string>
|
||||||
|
<string name="opds_linktitle_stream_pages_continue_remote">Συνέχισε να διαβάζεις Online (Συγχρονισμένο από %1$s)</string>
|
||||||
|
<string name="opds_linktitle_download_cbz">Λήψη CBZ</string>
|
||||||
|
<string name="opds_linktitle_chapter_cover">Εξώφυλλο κεφαλαίου</string>
|
||||||
|
<string name="opds_linktitle_view_chapter_details">Λεπτομέρειες κεφαλαίου & Σελίδες</string>
|
||||||
|
<string name="opds_chapter_status_read">✅</string>
|
||||||
|
<string name="opds_chapter_status_in_progress">⌛</string>
|
||||||
|
<string name="opds_chapter_status_unread">⭕</string>
|
||||||
|
<string name="opds_chapter_status_downloaded">⬇️</string>
|
||||||
|
<string name="opds_chapter_status_synced">🌐</string>
|
||||||
|
<string name="opds_chapter_details_base">Σειρά: %1$s | %2$s</string>
|
||||||
|
<string name="opds_chapter_details_scanlator">| Από %1$s</string>
|
||||||
|
<string name="opds_chapter_details_progress">| Πρόοδος: %1$d από %2$d</string>
|
||||||
|
<string name="opds_manga_summary_status">Κατάσταση: %1$s</string>
|
||||||
|
<string name="opds_manga_summary_source">Πηγή: %1$s</string>
|
||||||
|
<string name="opds_manga_summary_language">Γλώσσα: %1$s</string>
|
||||||
|
<string name="manga_status_ongoing">Σε εξέλιξη</string>
|
||||||
|
<string name="manga_status_completed">Ολοκληρωμένο</string>
|
||||||
|
<string name="manga_status_licensed">Υπό άδεια</string>
|
||||||
|
<string name="manga_status_publishing_finished">Δημοσίευση ολοκληρωμένη</string>
|
||||||
|
<string name="manga_status_cancelled">Ακυρωμένο</string>
|
||||||
|
<string name="manga_status_on_hiatus">Σε παύση</string>
|
||||||
|
<string name="manga_status_unknown">Άγνωστο</string>
|
||||||
|
<string name="label_error">Σφάλμα</string>
|
||||||
|
<string name="label_version">Έκδοση <xliff:g id="version" example="v2.0.1833">%1$s</xliff:g></string>
|
||||||
|
<string name="label_close">Κλείσε</string>
|
||||||
|
<string name="webview_label_title">Suwayomi WebView</string>
|
||||||
|
<string name="webview_label_disconnected">Αποσυνδεδεμένο, κάνε ανανέωση</string>
|
||||||
|
<string name="webview_label_reversescroll">Αντίστροφη κύλιση</string>
|
||||||
|
<string name="webview_label_bindingshint">Σημείωση: Όταν η εστίαση είναι στο τμήμα WebView, καμία συντόμευση πληκτρολογίου, συμπεριλαμβανομένης της ανανέωσης, δεν θα αντιμετωπίζεται από το πρόγραμμα περιήγησης.</string>
|
||||||
|
<string name="webview_label_init">Αρχικοποίηση... Παρακαλώ περίμενε</string>
|
||||||
|
<string name="webview_label_getstarted">Εισήγαγε μια διεύθυνση URL για να ξεκινήσεις</string>
|
||||||
|
<string name="webview_label_loading">Φόρτωση σελίδας...</string>
|
||||||
|
<string name="webview_label_copy">Αντιγραφή στο πρόχειρο</string>
|
||||||
|
<string name="webview_label_copy_description">Η αυτόματη αντιγραφή στο πρόχειρο απέτυχε, χρησιμοποίησε το παρακάτω πεδίο για να αντιγράψεις χειροκίνητα την τιμή.</string>
|
||||||
|
<string name="webview_label_login_required">Η διαμόρφωσή σου απαιτεί σύνδεση. Εισήγαγε όνομα χρήστη και κωδικό.</string>
|
||||||
|
<string name="webview_placeholder_url">Εισήγαγε URL...</string>
|
||||||
|
<string name="login_label_title">Suwayomi Σύνδεση</string>
|
||||||
|
<string name="login_label_username">Όνομα χρήστη</string>
|
||||||
|
<string name="login_label_password">Κωδικός</string>
|
||||||
|
<string name="login_label_login">Σύνδεση</string>
|
||||||
|
<string name="login_placeholder_username">Πληκτρολόγησε όνομα χρήστη...</string>
|
||||||
|
<string name="login_placeholder_password">Μυστικό...</string>
|
||||||
|
</resources>
|
||||||
@@ -15,15 +15,13 @@ import kotlinx.coroutines.CoroutineScope
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.channels.BufferOverflow
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.conflate
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.drop
|
import kotlinx.coroutines.flow.drop
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.mapLatest
|
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import org.jetbrains.exposed.v1.core.SortOrder
|
import org.jetbrains.exposed.v1.core.SortOrder
|
||||||
import suwayomi.tachidesk.graphql.types.AuthMode
|
import suwayomi.tachidesk.graphql.types.AuthMode
|
||||||
@@ -73,6 +71,21 @@ val serverConfig: ServerConfig by lazy { GlobalConfigManager.module() }
|
|||||||
|
|
||||||
private val application: Application by injectLazy()
|
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".
|
// Settings are ordered by "protoNumber".
|
||||||
class ServerConfig(
|
class ServerConfig(
|
||||||
getConfig: () -> Config,
|
getConfig: () -> Config,
|
||||||
@@ -1014,7 +1027,85 @@ class ServerConfig(
|
|||||||
description = "Use Hikari Connection Pool to connect to the database.",
|
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)."
|
||||||
|
|
||||||
|
)
|
||||||
|
|
||||||
/** ****************************************************************** **/
|
/** ****************************************************************** **/
|
||||||
/** **/
|
/** **/
|
||||||
@@ -1059,18 +1150,7 @@ class ServerConfig(
|
|||||||
flow: Flow<T>,
|
flow: Flow<T>,
|
||||||
onChange: suspend (value: T) -> Unit,
|
onChange: suspend (value: T) -> Unit,
|
||||||
ignoreInitialValue: Boolean = true,
|
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(
|
fun <T> subscribeTo(
|
||||||
flow: Flow<T>,
|
flow: Flow<T>,
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ enum class SettingGroup(
|
|||||||
CLOUDFLARE("Cloudflare"),
|
CLOUDFLARE("Cloudflare"),
|
||||||
OPDS("OPDS"),
|
OPDS("OPDS"),
|
||||||
KOREADER_SYNC("KOReader sync"),
|
KOREADER_SYNC("KOReader sync"),
|
||||||
|
WEB_VIEW("WebView"),
|
||||||
|
SYNCYOMI("SyncYomi")
|
||||||
;
|
;
|
||||||
|
|
||||||
override fun toString(): String = value
|
override fun toString(): String = value
|
||||||
|
|||||||
@@ -88,4 +88,6 @@ object SettingsRegistry {
|
|||||||
fun get(name: String): SettingMetadata? = settings[name]
|
fun get(name: String): SettingMetadata? = settings[name]
|
||||||
|
|
||||||
fun getAll(): Map<String, SettingMetadata> = settings.toMap()
|
fun getAll(): Map<String, SettingMetadata> = settings.toMap()
|
||||||
|
|
||||||
|
fun clear() = settings.clear()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
package suwayomi.tachidesk.global.impl
|
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 eu.kanade.tachiyomi.network.NetworkHelper
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import okhttp3.Cookie
|
import okhttp3.Cookie
|
||||||
import okhttp3.HttpUrl
|
import okhttp3.HttpUrl
|
||||||
|
import org.cef.CefClient
|
||||||
import org.cef.CefSettings
|
import org.cef.CefSettings
|
||||||
import org.cef.browser.CefBrowser
|
import org.cef.browser.CefBrowser
|
||||||
import org.cef.browser.CefFrame
|
import org.cef.browser.CefFrame
|
||||||
@@ -26,6 +25,9 @@ import org.cef.network.CefCookie
|
|||||||
import org.cef.network.CefCookieManager
|
import org.cef.network.CefCookieManager
|
||||||
import org.cef.network.CefRequest
|
import org.cef.network.CefRequest
|
||||||
import uy.kohesive.injekt.injectLazy
|
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.Component
|
||||||
import java.awt.HeadlessException
|
import java.awt.HeadlessException
|
||||||
import java.awt.Rectangle
|
import java.awt.Rectangle
|
||||||
@@ -47,8 +49,8 @@ import javax.swing.JPanel
|
|||||||
class KcefWebView {
|
class KcefWebView {
|
||||||
private val logger = KotlinLogging.logger {}
|
private val logger = KotlinLogging.logger {}
|
||||||
private val renderHandler = RenderHandler()
|
private val renderHandler = RenderHandler()
|
||||||
private var kcefClient: KCEFClient? = null
|
private var kcefClient: CefClient? = null
|
||||||
private var browser: KCEFBrowser? = null
|
private var browser: CefBrowser? = null
|
||||||
private var width = 1000
|
private var width = 1000
|
||||||
private var height = 1000
|
private var height = 1000
|
||||||
|
|
||||||
@@ -76,7 +78,8 @@ class KcefWebView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Serializable sealed class Event
|
@Serializable
|
||||||
|
sealed class Event
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("consoleMessage")
|
@SerialName("consoleMessage")
|
||||||
@@ -247,10 +250,12 @@ class KcefWebView {
|
|||||||
init {
|
init {
|
||||||
destroy()
|
destroy()
|
||||||
kcefClient =
|
kcefClient =
|
||||||
KCEF.newClientBlocking().apply {
|
runBlocking {
|
||||||
addDisplayHandler(DisplayHandler())
|
CefHelper.createClient().apply {
|
||||||
addLoadHandler(LoadHandler())
|
addDisplayHandler(DisplayHandler())
|
||||||
addRequestHandler(RequestHandler())
|
addLoadHandler(LoadHandler())
|
||||||
|
addRequestHandler(RequestHandler())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug { "Start loading cookies" }
|
logger.debug { "Start loading cookies" }
|
||||||
@@ -289,6 +294,7 @@ class KcefWebView {
|
|||||||
.createBrowser(
|
.createBrowser(
|
||||||
url,
|
url,
|
||||||
CefRendering.CefRenderingWithHandler(renderHandler, JPanel()),
|
CefRendering.CefRenderingWithHandler(renderHandler, JPanel()),
|
||||||
|
false,
|
||||||
// NOTE: with a context, we don't seem to be getting any cookies
|
// NOTE: with a context, we don't seem to be getting any cookies
|
||||||
).apply {
|
).apply {
|
||||||
// NOTE: Without this, we don't seem to be receiving any events
|
// NOTE: Without this, we don't seem to be receiving any events
|
||||||
|
|||||||
@@ -0,0 +1,519 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,597 @@
|
|||||||
|
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.POST
|
||||||
|
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.coroutines.CancellationException
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.SerializationException
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
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)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class SyncEvent(
|
||||||
|
val event: SyncEventStatus,
|
||||||
|
val device_Name: String? = null,
|
||||||
|
val message: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private enum class SyncEventStatus {
|
||||||
|
SYNC_STARTED,
|
||||||
|
SYNC_SUCCESS,
|
||||||
|
SYNC_FAILED,
|
||||||
|
SYNC_ERROR,
|
||||||
|
SYNC_CANCELLED,
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun doSync(
|
||||||
|
syncData: SyncData,
|
||||||
|
startDate: Instant,
|
||||||
|
setSyncState: (SyncManager.SyncState) -> Unit,
|
||||||
|
): Backup? {
|
||||||
|
reportSyncEvent(SyncEventStatus.SYNC_STARTED)
|
||||||
|
setSyncState(SyncManager.SyncState.Downloading(startDate))
|
||||||
|
|
||||||
|
return try {
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
val success = pushSyncData(finalSyncData, etag)
|
||||||
|
if (success) {
|
||||||
|
reportSyncEvent(SyncEventStatus.SYNC_SUCCESS)
|
||||||
|
} else {
|
||||||
|
reportSyncEvent(SyncEventStatus.SYNC_FAILED, "Failed to push sync data")
|
||||||
|
}
|
||||||
|
|
||||||
|
finalSyncData.backup
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (e is CancellationException) {
|
||||||
|
reportSyncEvent(SyncEventStatus.SYNC_CANCELLED, e.message)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
logger.error { "Error syncing: ${e.message}" }
|
||||||
|
reportSyncEvent(SyncEventStatus.SYNC_ERROR, e.message)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
): Boolean {
|
||||||
|
val backup = syncData.backup ?: return true
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
return 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" }
|
||||||
|
true
|
||||||
|
} else if (response.code == HttpStatus.PRECONDITION_FAILED.code) {
|
||||||
|
// other clients updated remote data, will try next time
|
||||||
|
logger.debug { "SyncYomi sync failed with 412" }
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
val responseBody = response.body.string()
|
||||||
|
logger.error { "SyncError: $responseBody" }
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun reportSyncEvent(
|
||||||
|
event: SyncEventStatus,
|
||||||
|
message: String? = null,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
val host = serverConfig.syncYomiHost.value
|
||||||
|
val apiKey = serverConfig.syncYomiApiKey.value
|
||||||
|
val url = "$host/api/sync/event"
|
||||||
|
|
||||||
|
val headers = Headers.Builder().add("X-API-Token", apiKey).build()
|
||||||
|
|
||||||
|
// Use a fixed server name.
|
||||||
|
val bodyObj =
|
||||||
|
SyncEvent(
|
||||||
|
event = event,
|
||||||
|
device_Name = "Suwayomi Server",
|
||||||
|
message = message,
|
||||||
|
)
|
||||||
|
|
||||||
|
val jsonBody = Json.encodeToString(SyncEvent.serializer(), bodyObj)
|
||||||
|
val requestBody = jsonBody.toRequestBody("application/json; charset=utf-8".toMediaType())
|
||||||
|
|
||||||
|
val request =
|
||||||
|
POST(
|
||||||
|
url = url,
|
||||||
|
headers = headers,
|
||||||
|
body = requestBody,
|
||||||
|
)
|
||||||
|
|
||||||
|
network.client
|
||||||
|
.newCall(request)
|
||||||
|
.await()
|
||||||
|
.close()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error { "Failed to report sync event: ${e.message}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
@@ -435,7 +435,7 @@ fun <T : String, S : T?> andFilterWithCompareString(
|
|||||||
|
|
||||||
opAnd.andWhere(filter.isNull) { if (it) column.isNull() else column.isNotNull() }
|
opAnd.andWhere(filter.isNull) { if (it) column.isNull() else column.isNotNull() }
|
||||||
opAnd.andWhere(filter.equalTo) { column eq it as S }
|
opAnd.andWhere(filter.equalTo) { column eq it as S }
|
||||||
opAnd.andWhere(filter.notEqualTo, filter.notEqualToAll, filter.notEqualToAny) { column neq it as S }
|
opAnd.andNotWhere(filter.notEqualTo, filter.notEqualToAll, filter.notEqualToAny) { column neq it as S }
|
||||||
opAnd.andWhere(
|
opAnd.andWhere(
|
||||||
filter.distinctFrom,
|
filter.distinctFrom,
|
||||||
filter.distinctFromAll,
|
filter.distinctFromAll,
|
||||||
@@ -455,36 +455,36 @@ fun <T : String, S : T?> andFilterWithCompareString(
|
|||||||
opAnd.andWhere(filter.greaterThanOrEqualTo) { column greaterEq it }
|
opAnd.andWhere(filter.greaterThanOrEqualTo) { column greaterEq it }
|
||||||
|
|
||||||
opAnd.andWhere(filter.includes, filter.includesAll, filter.includesAny) { column like "%$it%" }
|
opAnd.andWhere(filter.includes, filter.includesAll, filter.includesAny) { column like "%$it%" }
|
||||||
opAnd.andWhere(filter.notIncludes, filter.notIncludesAll, filter.notIncludesAny) { column notLike "%$it%" }
|
opAnd.andNotWhere(filter.notIncludes, filter.notIncludesAll, filter.notIncludesAny) { column notLike "%$it%" }
|
||||||
opAnd.andWhere(filter.includesInsensitive, filter.includesInsensitiveAll, filter.includesInsensitiveAny) {
|
opAnd.andWhere(filter.includesInsensitive, filter.includesInsensitiveAll, filter.includesInsensitiveAny) {
|
||||||
ILikeEscapeOp.iLike(column, "%$it%")
|
ILikeEscapeOp.iLike(column, "%$it%")
|
||||||
}
|
}
|
||||||
opAnd.andWhere(filter.notIncludesInsensitive, filter.notIncludesInsensitiveAll, filter.notIncludesInsensitiveAny) {
|
opAnd.andNotWhere(filter.notIncludesInsensitive, filter.notIncludesInsensitiveAll, filter.notIncludesInsensitiveAny) {
|
||||||
ILikeEscapeOp.iNotLike(column, "%$it%")
|
ILikeEscapeOp.iNotLike(column, "%$it%")
|
||||||
}
|
}
|
||||||
|
|
||||||
opAnd.andWhere(filter.startsWith, filter.startsWithAll, filter.startsWithAny) { column like "$it%" }
|
opAnd.andWhere(filter.startsWith, filter.startsWithAll, filter.startsWithAny) { column like "$it%" }
|
||||||
opAnd.andWhere(filter.notStartsWith, filter.notStartsWithAll, filter.notStartsWithAny) { column notLike "$it%" }
|
opAnd.andNotWhere(filter.notStartsWith, filter.notStartsWithAll, filter.notStartsWithAny) { column notLike "$it%" }
|
||||||
opAnd.andWhere(filter.startsWithInsensitive, filter.startsWithInsensitiveAll, filter.startsWithInsensitiveAny) {
|
opAnd.andWhere(filter.startsWithInsensitive, filter.startsWithInsensitiveAll, filter.startsWithInsensitiveAny) {
|
||||||
ILikeEscapeOp.iLike(column, "$it%")
|
ILikeEscapeOp.iLike(column, "$it%")
|
||||||
}
|
}
|
||||||
opAnd.andWhere(filter.notStartsWithInsensitive, filter.notStartsWithInsensitiveAll, filter.notStartsWithInsensitiveAny) {
|
opAnd.andNotWhere(filter.notStartsWithInsensitive, filter.notStartsWithInsensitiveAll, filter.notStartsWithInsensitiveAny) {
|
||||||
ILikeEscapeOp.iNotLike(column, "$it%")
|
ILikeEscapeOp.iNotLike(column, "$it%")
|
||||||
}
|
}
|
||||||
|
|
||||||
opAnd.andWhere(filter.endsWith, filter.endsWithAll, filter.endsWithAny) { column like "%$it" }
|
opAnd.andWhere(filter.endsWith, filter.endsWithAll, filter.endsWithAny) { column like "%$it" }
|
||||||
opAnd.andWhere(filter.notEndsWith, filter.notEndsWithAll, filter.notEndsWithAny) { column notLike "%$it" }
|
opAnd.andNotWhere(filter.notEndsWith, filter.notEndsWithAll, filter.notEndsWithAny) { column notLike "%$it" }
|
||||||
opAnd.andWhere(filter.endsWithInsensitive, filter.endsWithInsensitiveAll, filter.endsWithInsensitiveAny) {
|
opAnd.andWhere(filter.endsWithInsensitive, filter.endsWithInsensitiveAll, filter.endsWithInsensitiveAny) {
|
||||||
ILikeEscapeOp.iLike(column, "%$it")
|
ILikeEscapeOp.iLike(column, "%$it")
|
||||||
}
|
}
|
||||||
opAnd.andWhere(filter.notEndsWithInsensitive, filter.notEndsWithInsensitiveAll, filter.notEndsWithInsensitiveAny) {
|
opAnd.andNotWhere(filter.notEndsWithInsensitive, filter.notEndsWithInsensitiveAll, filter.notEndsWithInsensitiveAny) {
|
||||||
ILikeEscapeOp.iNotLike(column, "%$it")
|
ILikeEscapeOp.iNotLike(column, "%$it")
|
||||||
}
|
}
|
||||||
|
|
||||||
opAnd.andWhere(filter.like, filter.likeAll, filter.likeAny) { column like it }
|
opAnd.andWhere(filter.like, filter.likeAll, filter.likeAny) { column like it }
|
||||||
opAnd.andWhere(filter.notLike, filter.notLikeAll, filter.notLikeAny) { column notLike it }
|
opAnd.andNotWhere(filter.notLike, filter.notLikeAll, filter.notLikeAny) { column notLike it }
|
||||||
opAnd.andWhere(filter.likeInsensitive, filter.likeInsensitiveAll, filter.likeInsensitiveAny) { ILikeEscapeOp.iLike(column, it) }
|
opAnd.andWhere(filter.likeInsensitive, filter.likeInsensitiveAll, filter.likeInsensitiveAny) { ILikeEscapeOp.iLike(column, it) }
|
||||||
opAnd.andWhere(filter.notLikeInsensitive, filter.notLikeInsensitiveAll, filter.notLikeInsensitiveAny) {
|
opAnd.andNotWhere(filter.notLikeInsensitive, filter.notLikeInsensitiveAll, filter.notLikeInsensitiveAny) {
|
||||||
ILikeEscapeOp.iNotLike(column, it)
|
ILikeEscapeOp.iNotLike(column, it)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -535,6 +535,17 @@ class OpAnd(
|
|||||||
andWhereAny(valueAny, expr)
|
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(
|
fun <T : Any> andWhereAll(
|
||||||
values: List<T>?,
|
values: List<T>?,
|
||||||
andPart: (T) -> Op<Boolean>,
|
andPart: (T) -> Op<Boolean>,
|
||||||
@@ -542,15 +553,31 @@ class OpAnd(
|
|||||||
values?.map { andWhere(it, andPart) }
|
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(
|
fun <T : Any> andWhereAny(
|
||||||
values: List<T>?,
|
values: List<T>?,
|
||||||
andPart: (T) -> Op<Boolean>,
|
andPart: (T) -> Op<Boolean>,
|
||||||
) {
|
) {
|
||||||
values ?: return
|
values ?: return
|
||||||
val expr = values.map { andPart(it) }.reduce { acc, op -> acc or op }
|
val expr = values.map { andPart(it) }.reduceOrNull { acc, op -> acc or op } ?: return
|
||||||
op = if (op == null) expr else (op!! and expr)
|
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(
|
fun <T> eq(
|
||||||
value: T?,
|
value: T?,
|
||||||
column: Column<T>,
|
column: Column<T>,
|
||||||
@@ -578,7 +605,7 @@ fun <T : Comparable<T>, S : T?> andFilterWithCompare(
|
|||||||
opAnd.andWhere(filter.isNull) { if (it) column.isNull() else column.isNotNull() }
|
opAnd.andWhere(filter.isNull) { if (it) column.isNull() else column.isNotNull() }
|
||||||
|
|
||||||
opAnd.andWhere(filter.equalTo) { column eq it as S }
|
opAnd.andWhere(filter.equalTo) { column eq it as S }
|
||||||
opAnd.andWhere(filter.notEqualTo, filter.notEqualToAll, filter.notEqualToAny) { column neq it as S }
|
opAnd.andNotWhere(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.distinctFrom, filter.distinctFromAll, filter.distinctFromAny) { DistinctFromOp.distinctFrom(column, it as S) }
|
||||||
opAnd.andWhere(filter.notDistinctFrom) { DistinctFromOp.notDistinctFrom(column, it as S) }
|
opAnd.andWhere(filter.notDistinctFrom) { DistinctFromOp.notDistinctFrom(column, it as S) }
|
||||||
if (!filter.`in`.isNullOrEmpty()) {
|
if (!filter.`in`.isNullOrEmpty()) {
|
||||||
@@ -606,7 +633,7 @@ fun <T : Comparable<T>> andFilterWithCompareEntity(
|
|||||||
opAnd.andWhere(filter.isNull) { if (it) column.isNull() else column.isNotNull() }
|
opAnd.andWhere(filter.isNull) { if (it) column.isNull() else column.isNotNull() }
|
||||||
|
|
||||||
opAnd.andWhere(filter.equalTo) { column eq it }
|
opAnd.andWhere(filter.equalTo) { column eq it }
|
||||||
opAnd.andWhere(filter.notEqualTo, filter.notEqualToAll, filter.notEqualToAny) { column neq it }
|
opAnd.andNotWhere(filter.notEqualTo, filter.notEqualToAll, filter.notEqualToAny) { column neq it }
|
||||||
opAnd.andWhere(filter.distinctFrom, filter.distinctFromAll, filter.distinctFromAny) { DistinctFromOp.distinctFrom(column, it) }
|
opAnd.andWhere(filter.distinctFrom, filter.distinctFromAll, filter.distinctFromAny) { DistinctFromOp.distinctFrom(column, it) }
|
||||||
opAnd.andWhere(filter.notDistinctFrom) { DistinctFromOp.notDistinctFrom(column, it) }
|
opAnd.andWhere(filter.notDistinctFrom) { DistinctFromOp.notDistinctFrom(column, it) }
|
||||||
if (!filter.`in`.isNullOrEmpty()) {
|
if (!filter.`in`.isNullOrEmpty()) {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import suwayomi.tachidesk.graphql.mutations.MangaMutation
|
|||||||
import suwayomi.tachidesk.graphql.mutations.MetaMutation
|
import suwayomi.tachidesk.graphql.mutations.MetaMutation
|
||||||
import suwayomi.tachidesk.graphql.mutations.SettingsMutation
|
import suwayomi.tachidesk.graphql.mutations.SettingsMutation
|
||||||
import suwayomi.tachidesk.graphql.mutations.SourceMutation
|
import suwayomi.tachidesk.graphql.mutations.SourceMutation
|
||||||
|
import suwayomi.tachidesk.graphql.mutations.SyncMutation
|
||||||
import suwayomi.tachidesk.graphql.mutations.TrackMutation
|
import suwayomi.tachidesk.graphql.mutations.TrackMutation
|
||||||
import suwayomi.tachidesk.graphql.mutations.UpdateMutation
|
import suwayomi.tachidesk.graphql.mutations.UpdateMutation
|
||||||
import suwayomi.tachidesk.graphql.mutations.UserMutation
|
import suwayomi.tachidesk.graphql.mutations.UserMutation
|
||||||
@@ -41,6 +42,7 @@ import suwayomi.tachidesk.graphql.queries.MangaQuery
|
|||||||
import suwayomi.tachidesk.graphql.queries.MetaQuery
|
import suwayomi.tachidesk.graphql.queries.MetaQuery
|
||||||
import suwayomi.tachidesk.graphql.queries.SettingsQuery
|
import suwayomi.tachidesk.graphql.queries.SettingsQuery
|
||||||
import suwayomi.tachidesk.graphql.queries.SourceQuery
|
import suwayomi.tachidesk.graphql.queries.SourceQuery
|
||||||
|
import suwayomi.tachidesk.graphql.queries.SyncQuery
|
||||||
import suwayomi.tachidesk.graphql.queries.TrackQuery
|
import suwayomi.tachidesk.graphql.queries.TrackQuery
|
||||||
import suwayomi.tachidesk.graphql.queries.UpdateQuery
|
import suwayomi.tachidesk.graphql.queries.UpdateQuery
|
||||||
import suwayomi.tachidesk.graphql.server.primitives.Cursor
|
import suwayomi.tachidesk.graphql.server.primitives.Cursor
|
||||||
@@ -50,6 +52,7 @@ import suwayomi.tachidesk.graphql.server.primitives.GraphQLLongAsString
|
|||||||
import suwayomi.tachidesk.graphql.server.primitives.GraphQLUpload
|
import suwayomi.tachidesk.graphql.server.primitives.GraphQLUpload
|
||||||
import suwayomi.tachidesk.graphql.subscriptions.DownloadSubscription
|
import suwayomi.tachidesk.graphql.subscriptions.DownloadSubscription
|
||||||
import suwayomi.tachidesk.graphql.subscriptions.InfoSubscription
|
import suwayomi.tachidesk.graphql.subscriptions.InfoSubscription
|
||||||
|
import suwayomi.tachidesk.graphql.subscriptions.SyncSubscription
|
||||||
import suwayomi.tachidesk.graphql.subscriptions.UpdateSubscription
|
import suwayomi.tachidesk.graphql.subscriptions.UpdateSubscription
|
||||||
import kotlin.reflect.KClass
|
import kotlin.reflect.KClass
|
||||||
import kotlin.reflect.KType
|
import kotlin.reflect.KType
|
||||||
@@ -98,6 +101,7 @@ val schema =
|
|||||||
TopLevelObject(MetaQuery()),
|
TopLevelObject(MetaQuery()),
|
||||||
TopLevelObject(SettingsQuery()),
|
TopLevelObject(SettingsQuery()),
|
||||||
TopLevelObject(SourceQuery()),
|
TopLevelObject(SourceQuery()),
|
||||||
|
TopLevelObject(SyncQuery()),
|
||||||
TopLevelObject(TrackQuery()),
|
TopLevelObject(TrackQuery()),
|
||||||
TopLevelObject(UpdateQuery()),
|
TopLevelObject(UpdateQuery()),
|
||||||
),
|
),
|
||||||
@@ -114,6 +118,7 @@ val schema =
|
|||||||
TopLevelObject(MangaMutation()),
|
TopLevelObject(MangaMutation()),
|
||||||
TopLevelObject(MetaMutation()),
|
TopLevelObject(MetaMutation()),
|
||||||
TopLevelObject(SettingsMutation()),
|
TopLevelObject(SettingsMutation()),
|
||||||
|
TopLevelObject(SyncMutation()),
|
||||||
TopLevelObject(SourceMutation()),
|
TopLevelObject(SourceMutation()),
|
||||||
TopLevelObject(TrackMutation()),
|
TopLevelObject(TrackMutation()),
|
||||||
TopLevelObject(UpdateMutation()),
|
TopLevelObject(UpdateMutation()),
|
||||||
@@ -123,6 +128,7 @@ val schema =
|
|||||||
listOf(
|
listOf(
|
||||||
TopLevelObject(DownloadSubscription()),
|
TopLevelObject(DownloadSubscription()),
|
||||||
TopLevelObject(InfoSubscription()),
|
TopLevelObject(InfoSubscription()),
|
||||||
|
TopLevelObject(SyncSubscription()),
|
||||||
TopLevelObject(UpdateSubscription()),
|
TopLevelObject(UpdateSubscription()),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
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() }
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -85,6 +85,12 @@ object CategoryManga {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun removeMangaFromAllCategories(mangaId: Int) {
|
||||||
|
transaction {
|
||||||
|
CategoryMangaTable.deleteWhere { CategoryMangaTable.manga eq mangaId }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* list of mangas that belong to a category
|
* list of mangas that belong to a category
|
||||||
*/
|
*/
|
||||||
@@ -111,12 +117,14 @@ object CategoryManga {
|
|||||||
|
|
||||||
val transform: (ResultRow) -> MangaDataClass = {
|
val transform: (ResultRow) -> MangaDataClass = {
|
||||||
// Map the data from the result row to the MangaDataClass
|
// Map the data from the result row to the MangaDataClass
|
||||||
val dataClass = MangaTable.toDataClass(it)
|
MangaTable
|
||||||
dataClass.lastReadAt = it[lastReadAt]
|
.toDataClass(it)
|
||||||
dataClass.unreadCount = it[unreadCount]
|
.copy(
|
||||||
dataClass.downloadCount = it[downloadedCount]
|
lastReadAt = it[lastReadAt],
|
||||||
dataClass.chapterCount = it[chapterCount]
|
unreadCount = it[unreadCount],
|
||||||
dataClass
|
downloadCount = it[downloadedCount],
|
||||||
|
chapterCount = it[chapterCount],
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return transaction {
|
return transaction {
|
||||||
|
|||||||
@@ -104,9 +104,6 @@ object Chapter {
|
|||||||
.associateBy({ it[ChapterTable.url] }, { it })
|
.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 ->
|
return chapterList.mapIndexed { index, it ->
|
||||||
|
|
||||||
val dbChapter = dbChapterMap.getValue(it.url)
|
val dbChapter = dbChapterMap.getValue(it.url)
|
||||||
@@ -128,8 +125,8 @@ object Chapter {
|
|||||||
realUrl = dbChapter[ChapterTable.realUrl],
|
realUrl = dbChapter[ChapterTable.realUrl],
|
||||||
downloaded = dbChapter[ChapterTable.isDownloaded],
|
downloaded = dbChapter[ChapterTable.isDownloaded],
|
||||||
pageCount = dbChapter[ChapterTable.pageCount],
|
pageCount = dbChapter[ChapterTable.pageCount],
|
||||||
chapterCount = chapterList.size,
|
lastModifiedAt = dbChapter[ChapterTable.lastModifiedAt],
|
||||||
meta = chapterMetas.getValue(dbChapter[ChapterTable.id].value),
|
version = dbChapter[ChapterTable.version],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -192,7 +189,7 @@ object Chapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// new chapters after they have been added to the database for auto downloads
|
// new chapters after they have been added to the database for auto downloads
|
||||||
val insertedChapters = mutableListOf<ChapterDataClass>()
|
val insertedChapterIds = mutableListOf<Int>()
|
||||||
|
|
||||||
val chaptersToInsert = mutableListOf<ChapterDataClass>() // do not yet have an ID from the database
|
val chaptersToInsert = mutableListOf<ChapterDataClass>() // do not yet have an ID from the database
|
||||||
val chaptersToUpdate = mutableListOf<ChapterDataClass>()
|
val chaptersToUpdate = mutableListOf<ChapterDataClass>()
|
||||||
@@ -236,7 +233,7 @@ object Chapter {
|
|||||||
val deletedChapterNumbers = TreeSet<Float>()
|
val deletedChapterNumbers = TreeSet<Float>()
|
||||||
val deletedReadChapterNumbers = TreeSet<Float>()
|
val deletedReadChapterNumbers = TreeSet<Float>()
|
||||||
val deletedBookmarkedChapterNumbers = TreeSet<Float>()
|
val deletedBookmarkedChapterNumbers = TreeSet<Float>()
|
||||||
val deletedDownloadedChapterNumberInfoMap = mutableMapOf<Float, MutableMap<String?, Int>>()
|
val deletedDownloadedChapterNumberToChapter = mutableMapOf<Float, ChapterDataClass>()
|
||||||
val deletedChapterNumberDateFetchMap = mutableMapOf<Float, Long>()
|
val deletedChapterNumberDateFetchMap = mutableMapOf<Float, Long>()
|
||||||
|
|
||||||
// clear any orphaned/duplicate chapters that are in the db but not in `chapterList`
|
// clear any orphaned/duplicate chapters that are in the db but not in `chapterList`
|
||||||
@@ -247,13 +244,7 @@ object Chapter {
|
|||||||
if (!chapterUrls.contains(dbChapter.url)) {
|
if (!chapterUrls.contains(dbChapter.url)) {
|
||||||
if (dbChapter.read) deletedReadChapterNumbers.add(dbChapter.chapterNumber)
|
if (dbChapter.read) deletedReadChapterNumbers.add(dbChapter.chapterNumber)
|
||||||
if (dbChapter.bookmarked) deletedBookmarkedChapterNumbers.add(dbChapter.chapterNumber)
|
if (dbChapter.bookmarked) deletedBookmarkedChapterNumbers.add(dbChapter.chapterNumber)
|
||||||
if (dbChapter.downloaded) {
|
if (dbChapter.downloaded) deletedDownloadedChapterNumberToChapter[dbChapter.chapterNumber] = dbChapter
|
||||||
val pageCountByScanlator =
|
|
||||||
deletedDownloadedChapterNumberInfoMap.getOrPut(
|
|
||||||
dbChapter.chapterNumber,
|
|
||||||
) { mutableMapOf() }
|
|
||||||
pageCountByScanlator[dbChapter.scanlator] = dbChapter.pageCount
|
|
||||||
}
|
|
||||||
deletedChapterNumbers.add(dbChapter.chapterNumber)
|
deletedChapterNumbers.add(dbChapter.chapterNumber)
|
||||||
deletedChapterNumberDateFetchMap[dbChapter.chapterNumber] = dbChapter.fetchedAt
|
deletedChapterNumberDateFetchMap[dbChapter.chapterNumber] = dbChapter.fetchedAt
|
||||||
dbChapter.id
|
dbChapter.id
|
||||||
@@ -285,6 +276,8 @@ object Chapter {
|
|||||||
this[ChapterTable.isRead] = false
|
this[ChapterTable.isRead] = false
|
||||||
this[ChapterTable.isBookmarked] = false
|
this[ChapterTable.isBookmarked] = false
|
||||||
this[ChapterTable.isDownloaded] = false
|
this[ChapterTable.isDownloaded] = false
|
||||||
|
this[ChapterTable.lastModifiedAt] = chapter.lastModifiedAt
|
||||||
|
this[ChapterTable.version] = chapter.version
|
||||||
this[ChapterTable.pageCount] = -1
|
this[ChapterTable.pageCount] = -1
|
||||||
|
|
||||||
// is recognized chapter number
|
// is recognized chapter number
|
||||||
@@ -292,20 +285,26 @@ object Chapter {
|
|||||||
this[ChapterTable.isRead] = chapter.chapterNumber in deletedReadChapterNumbers
|
this[ChapterTable.isRead] = chapter.chapterNumber in deletedReadChapterNumbers
|
||||||
this[ChapterTable.isBookmarked] = chapter.chapterNumber in deletedBookmarkedChapterNumbers
|
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
|
// Try to use the fetch date of the original entry to not pollute 'Updates' tab
|
||||||
deletedChapterNumberDateFetchMap[chapter.chapterNumber]?.let {
|
deletedChapterNumberDateFetchMap[chapter.chapterNumber]?.let {
|
||||||
this[ChapterTable.fetchedAt] = it
|
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 { insertedChapters.add(ChapterTable.toDataClass(it)) }
|
}.forEach { insertedChapterIds.add(it[ChapterTable.id].value) }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (chaptersToUpdate.isNotEmpty()) {
|
if (chaptersToUpdate.isNotEmpty()) {
|
||||||
@@ -313,12 +312,32 @@ object Chapter {
|
|||||||
.apply {
|
.apply {
|
||||||
chaptersToUpdate.forEach {
|
chaptersToUpdate.forEach {
|
||||||
addBatch(EntityID(it.id, ChapterTable))
|
addBatch(EntityID(it.id, ChapterTable))
|
||||||
|
|
||||||
|
val currentChapter = chaptersInDb.find { dbChapter -> dbChapter.id == it.id }!!
|
||||||
|
|
||||||
this[ChapterTable.name] = it.name
|
this[ChapterTable.name] = it.name
|
||||||
this[ChapterTable.date_upload] = it.uploadDate
|
this[ChapterTable.date_upload] = it.uploadDate
|
||||||
this[ChapterTable.chapter_number] = it.chapterNumber
|
this[ChapterTable.chapter_number] = it.chapterNumber
|
||||||
this[ChapterTable.scanlator] = it.scanlator
|
this[ChapterTable.scanlator] = it.scanlator
|
||||||
this[ChapterTable.sourceOrder] = it.index
|
this[ChapterTable.sourceOrder] = it.index
|
||||||
this[ChapterTable.realUrl] = it.realUrl
|
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()
|
}.toExecutable()
|
||||||
.execute(this@transaction)
|
.execute(this@transaction)
|
||||||
@@ -330,6 +349,13 @@ object Chapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (manga.inLibrary) {
|
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)
|
downloadNewChapters(mangaId, currentLatestChapterNumber, numberOfCurrentChapters, insertedChapters)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -587,7 +613,7 @@ object Chapter {
|
|||||||
.withDefault { emptyMap() }
|
.withDefault { emptyMap() }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getChapterMetaMap(chapter: EntityID<Int>): Map<String, String> =
|
fun getChapterMetaMap(chapter: Int): Map<String, String> =
|
||||||
transaction {
|
transaction {
|
||||||
ChapterMetaTable
|
ChapterMetaTable
|
||||||
.selectAll()
|
.selectAll()
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import suwayomi.tachidesk.manga.model.table.ChapterTable
|
|||||||
import suwayomi.tachidesk.manga.model.table.MangaTable
|
import suwayomi.tachidesk.manga.model.table.MangaTable
|
||||||
import suwayomi.tachidesk.manga.model.table.toDataClass
|
import suwayomi.tachidesk.manga.model.table.toDataClass
|
||||||
import suwayomi.tachidesk.server.serverConfig
|
import suwayomi.tachidesk.server.serverConfig
|
||||||
|
import xyz.nulldev.androidcompat.util.SafePath
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
|
|
||||||
@@ -64,6 +65,11 @@ object ChapterDownloadHelper {
|
|||||||
chapterId: Int,
|
chapterId: Int,
|
||||||
): Pair<InputStream, Long> = provider(mangaId, chapterId).getAsArchiveStream()
|
): 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> =
|
private fun getChapterWithCbzFileName(chapterId: Int): Pair<ChapterDataClass, String> =
|
||||||
transaction {
|
transaction {
|
||||||
val row =
|
val row =
|
||||||
@@ -71,13 +77,46 @@ object ChapterDownloadHelper {
|
|||||||
.select(ChapterTable.columns + MangaTable.columns)
|
.select(ChapterTable.columns + MangaTable.columns)
|
||||||
.where { ChapterTable.id eq chapterId }
|
.where { ChapterTable.id eq chapterId }
|
||||||
.firstOrNull() ?: throw IllegalArgumentException("ChapterId $chapterId not found")
|
.firstOrNull() ?: throw IllegalArgumentException("ChapterId $chapterId not found")
|
||||||
|
|
||||||
val chapter = ChapterTable.toDataClass(row)
|
val chapter = ChapterTable.toDataClass(row)
|
||||||
val mangaTitle = row[MangaTable.title]
|
val mangaTitle = row[MangaTable.title].trim()
|
||||||
|
|
||||||
val scanlatorPart = chapter.scanlator?.let { "[$it] " } ?: ""
|
val scanlatorName = chapter.scanlator?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
val fileName = "$mangaTitle - $scanlatorPart${chapter.name}.cbz"
|
val chapterName = chapter.name.trim().takeIf { it.isNotEmpty() }
|
||||||
|
|
||||||
Pair(chapter, fileName)
|
val fileName =
|
||||||
|
buildString {
|
||||||
|
append(mangaTitle)
|
||||||
|
append(" - ")
|
||||||
|
|
||||||
|
if (chapterName != null) {
|
||||||
|
append(chapterName)
|
||||||
|
} else if (chapter.chapterNumber >= 0f) {
|
||||||
|
// chapterNumber is stored as Float, drop .0 for whole numbers.
|
||||||
|
val formatNumber =
|
||||||
|
if (chapter.chapterNumber % 1 == 0f) {
|
||||||
|
chapter.chapterNumber.toInt().toString()
|
||||||
|
} else {
|
||||||
|
chapter.chapterNumber.toString()
|
||||||
|
}
|
||||||
|
append("#$formatNumber")
|
||||||
|
} else {
|
||||||
|
// Fallback when neither name nor valid chapter number exists
|
||||||
|
append("#${chapter.index}")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scanlatorName != null) {
|
||||||
|
append(" [")
|
||||||
|
append(scanlatorName)
|
||||||
|
append("]")
|
||||||
|
}
|
||||||
|
append(".cbz")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize filename for OS compatibility
|
||||||
|
val safeFileName = SafePath.buildValidFilename(fileName)
|
||||||
|
|
||||||
|
Pair(chapter, safeFileName)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getCbzForDownload(
|
fun getCbzForDownload(
|
||||||
|
|||||||
@@ -92,13 +92,14 @@ object Manga {
|
|||||||
inLibrary = mangaEntry[MangaTable.inLibrary],
|
inLibrary = mangaEntry[MangaTable.inLibrary],
|
||||||
inLibraryAt = mangaEntry[MangaTable.inLibraryAt],
|
inLibraryAt = mangaEntry[MangaTable.inLibraryAt],
|
||||||
source = getSource(mangaEntry[MangaTable.sourceReference]),
|
source = getSource(mangaEntry[MangaTable.sourceReference]),
|
||||||
meta = getMangaMetaMap(mangaId),
|
|
||||||
realUrl = mangaEntry[MangaTable.realUrl],
|
realUrl = mangaEntry[MangaTable.realUrl],
|
||||||
lastFetchedAt = mangaEntry[MangaTable.lastFetchedAt],
|
lastFetchedAt = mangaEntry[MangaTable.lastFetchedAt],
|
||||||
chaptersLastFetchedAt = mangaEntry[MangaTable.chaptersLastFetchedAt],
|
chaptersLastFetchedAt = mangaEntry[MangaTable.chaptersLastFetchedAt],
|
||||||
updateStrategy = UpdateStrategy.valueOf(mangaEntry[MangaTable.updateStrategy]),
|
updateStrategy = UpdateStrategy.valueOf(mangaEntry[MangaTable.updateStrategy]),
|
||||||
freshData = true,
|
freshData = true,
|
||||||
trackers = Track.getTrackRecordsByMangaId(mangaId),
|
trackers = Track.getTrackRecordsByMangaId(mangaId),
|
||||||
|
lastModifiedAt = mangaEntry[MangaTable.lastModifiedAt],
|
||||||
|
version = mangaEntry[MangaTable.version],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -133,7 +134,7 @@ object Manga {
|
|||||||
""
|
""
|
||||||
}
|
}
|
||||||
if (remoteTitle.isNotEmpty() && remoteTitle != mangaEntry[MangaTable.title]) {
|
if (remoteTitle.isNotEmpty() && remoteTitle != mangaEntry[MangaTable.title]) {
|
||||||
val canUpdateTitle = updateMangaDownloadDir(mangaId, remoteTitle)
|
val canUpdateTitle = updateMangaDownloadDir(mangaEntry[MangaTable.title], source.toString(), remoteTitle)
|
||||||
|
|
||||||
if (canUpdateTitle) {
|
if (canUpdateTitle) {
|
||||||
it[MangaTable.title] = remoteTitle
|
it[MangaTable.title] = remoteTitle
|
||||||
@@ -211,12 +212,12 @@ object Manga {
|
|||||||
.orderBy(ChapterTable.sourceOrder to SortOrder.DESC)
|
.orderBy(ChapterTable.sourceOrder to SortOrder.DESC)
|
||||||
.firstOrNull { it[ChapterTable.isRead] }
|
.firstOrNull { it[ChapterTable.isRead] }
|
||||||
|
|
||||||
mangaDaaClass.unreadCount = unreadCount
|
mangaDaaClass.copy(
|
||||||
mangaDaaClass.downloadCount = downloadCount
|
unreadCount = unreadCount,
|
||||||
mangaDaaClass.chapterCount = chapterCount
|
downloadCount = downloadCount,
|
||||||
mangaDaaClass.lastChapterRead = lastChapterRead?.let { ChapterTable.toDataClass(it) }
|
chapterCount = chapterCount,
|
||||||
|
lastChapterRead = lastChapterRead?.let { ChapterTable.toDataClass(it) },
|
||||||
mangaDaaClass
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,13 +240,14 @@ object Manga {
|
|||||||
inLibrary = mangaEntry[MangaTable.inLibrary],
|
inLibrary = mangaEntry[MangaTable.inLibrary],
|
||||||
inLibraryAt = mangaEntry[MangaTable.inLibraryAt],
|
inLibraryAt = mangaEntry[MangaTable.inLibraryAt],
|
||||||
source = getSource(mangaEntry[MangaTable.sourceReference]),
|
source = getSource(mangaEntry[MangaTable.sourceReference]),
|
||||||
meta = getMangaMetaMap(mangaId),
|
|
||||||
realUrl = mangaEntry[MangaTable.realUrl],
|
realUrl = mangaEntry[MangaTable.realUrl],
|
||||||
lastFetchedAt = mangaEntry[MangaTable.lastFetchedAt],
|
lastFetchedAt = mangaEntry[MangaTable.lastFetchedAt],
|
||||||
chaptersLastFetchedAt = mangaEntry[MangaTable.chaptersLastFetchedAt],
|
chaptersLastFetchedAt = mangaEntry[MangaTable.chaptersLastFetchedAt],
|
||||||
updateStrategy = UpdateStrategy.valueOf(mangaEntry[MangaTable.updateStrategy]),
|
updateStrategy = UpdateStrategy.valueOf(mangaEntry[MangaTable.updateStrategy]),
|
||||||
freshData = false,
|
freshData = false,
|
||||||
trackers = Track.getTrackRecordsByMangaId(mangaId),
|
trackers = Track.getTrackRecordsByMangaId(mangaId),
|
||||||
|
lastModifiedAt = mangaEntry[MangaTable.lastModifiedAt],
|
||||||
|
version = mangaEntry[MangaTable.version],
|
||||||
)
|
)
|
||||||
|
|
||||||
fun getMangaMetaMap(mangaId: Int): Map<String, String> =
|
fun getMangaMetaMap(mangaId: Int): Map<String, String> =
|
||||||
|
|||||||
@@ -148,13 +148,11 @@ object ProtoBackupExport : ProtoBackupBase() {
|
|||||||
|
|
||||||
fun createBackup(flags: BackupFlags): InputStream {
|
fun createBackup(flags: BackupFlags): InputStream {
|
||||||
// Create root object
|
// Create root object
|
||||||
|
|
||||||
val backupMangas = BackupMangaHandler.backup(flags)
|
|
||||||
|
|
||||||
val backup: Backup =
|
val backup: Backup =
|
||||||
transaction {
|
transaction {
|
||||||
|
val backupMangas = BackupMangaHandler.backup(flags)
|
||||||
Backup(
|
Backup(
|
||||||
BackupMangaHandler.backup(flags),
|
backupMangas,
|
||||||
BackupCategoryHandler.backup(flags),
|
BackupCategoryHandler.backup(flags),
|
||||||
BackupSourceHandler.backup(backupMangas, flags),
|
BackupSourceHandler.backup(backupMangas, flags),
|
||||||
BackupGlobalMetaHandler.backup(flags),
|
BackupGlobalMetaHandler.backup(flags),
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ import kotlinx.coroutines.sync.withLock
|
|||||||
import okio.buffer
|
import okio.buffer
|
||||||
import okio.gzip
|
import okio.gzip
|
||||||
import okio.source
|
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.graphql.types.toStatus
|
||||||
import suwayomi.tachidesk.manga.impl.backup.BackupFlags
|
import suwayomi.tachidesk.manga.impl.backup.BackupFlags
|
||||||
import suwayomi.tachidesk.manga.impl.backup.proto.ProtoBackupValidator.ValidationResult
|
import suwayomi.tachidesk.manga.impl.backup.proto.ProtoBackupValidator.ValidationResult
|
||||||
@@ -31,6 +34,8 @@ 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.BackupSettingsHandler
|
||||||
import suwayomi.tachidesk.manga.impl.backup.proto.handlers.BackupSourceHandler
|
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.Backup
|
||||||
|
import suwayomi.tachidesk.manga.model.table.ChapterTable
|
||||||
|
import suwayomi.tachidesk.manga.model.table.MangaTable
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.Timer
|
import java.util.Timer
|
||||||
@@ -109,6 +114,7 @@ object ProtoBackupImport : ProtoBackupBase() {
|
|||||||
fun restore(
|
fun restore(
|
||||||
sourceStream: InputStream,
|
sourceStream: InputStream,
|
||||||
flags: BackupFlags,
|
flags: BackupFlags,
|
||||||
|
isSync: Boolean = false,
|
||||||
): String {
|
): String {
|
||||||
val restoreId = System.currentTimeMillis().toString()
|
val restoreId = System.currentTimeMillis().toString()
|
||||||
|
|
||||||
@@ -117,7 +123,7 @@ object ProtoBackupImport : ProtoBackupBase() {
|
|||||||
updateRestoreState(restoreId, BackupRestoreState.Idle)
|
updateRestoreState(restoreId, BackupRestoreState.Idle)
|
||||||
|
|
||||||
GlobalScope.launch {
|
GlobalScope.launch {
|
||||||
restoreLegacy(sourceStream, restoreId, flags)
|
restoreLegacy(sourceStream, restoreId, flags, isSync)
|
||||||
}
|
}
|
||||||
|
|
||||||
return restoreId
|
return restoreId
|
||||||
@@ -127,11 +133,12 @@ object ProtoBackupImport : ProtoBackupBase() {
|
|||||||
sourceStream: InputStream,
|
sourceStream: InputStream,
|
||||||
restoreId: String = "legacy",
|
restoreId: String = "legacy",
|
||||||
flags: BackupFlags = BackupFlags.DEFAULT,
|
flags: BackupFlags = BackupFlags.DEFAULT,
|
||||||
|
isSync: Boolean = false,
|
||||||
): ValidationResult =
|
): ValidationResult =
|
||||||
backupMutex.withLock {
|
backupMutex.withLock {
|
||||||
try {
|
try {
|
||||||
logger.info { "restore($restoreId): restoring..." }
|
logger.info { "restore($restoreId): restoring..." }
|
||||||
performRestore(restoreId, sourceStream, flags)
|
performRestore(restoreId, sourceStream, flags, isSync)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error(e) { "restore($restoreId): failed due to" }
|
logger.error(e) { "restore($restoreId): failed due to" }
|
||||||
|
|
||||||
@@ -152,12 +159,14 @@ object ProtoBackupImport : ProtoBackupBase() {
|
|||||||
id: String,
|
id: String,
|
||||||
sourceStream: InputStream,
|
sourceStream: InputStream,
|
||||||
flags: BackupFlags,
|
flags: BackupFlags,
|
||||||
|
isSync: Boolean,
|
||||||
): ValidationResult {
|
): ValidationResult {
|
||||||
val backupString =
|
val backupString =
|
||||||
sourceStream
|
sourceStream
|
||||||
.source()
|
.source()
|
||||||
.gzip()
|
.run {
|
||||||
.buffer()
|
if (!isSync) gzip() else this
|
||||||
|
}.buffer()
|
||||||
.use { it.readByteArray() }
|
.use { it.readByteArray() }
|
||||||
val backup = parser.decodeFromByteArray(Backup.serializer(), backupString)
|
val backup = parser.decodeFromByteArray(Backup.serializer(), backupString)
|
||||||
|
|
||||||
@@ -235,6 +244,17 @@ object ProtoBackupImport : ProtoBackupBase() {
|
|||||||
""".trimIndent()
|
""".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)
|
updateRestoreState(id, BackupRestoreState.Success)
|
||||||
|
|
||||||
return validationResult
|
return validationResult
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ package suwayomi.tachidesk.manga.impl.backup.proto.handlers
|
|||||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
* 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.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.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
|
||||||
import suwayomi.tachidesk.manga.impl.Category.modifyCategoriesMetas
|
import suwayomi.tachidesk.manga.impl.Category.modifyCategoriesMetas
|
||||||
import suwayomi.tachidesk.manga.impl.backup.BackupFlags
|
import suwayomi.tachidesk.manga.impl.backup.BackupFlags
|
||||||
@@ -38,6 +42,9 @@ object BackupCategoryHandler {
|
|||||||
it.name,
|
it.name,
|
||||||
it.order,
|
it.order,
|
||||||
0, // not supported in Tachidesk
|
0, // not supported in Tachidesk
|
||||||
|
it.version,
|
||||||
|
it.uid,
|
||||||
|
it.lastModifiedAt,
|
||||||
).apply {
|
).apply {
|
||||||
this.meta = categoryToMeta[it.id] ?: emptyMap()
|
this.meta = categoryToMeta[it.id] ?: emptyMap()
|
||||||
}
|
}
|
||||||
@@ -45,7 +52,56 @@ object BackupCategoryHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun restore(backupCategories: List<BackupCategory>): Map<Int, Int> {
|
fun restore(backupCategories: List<BackupCategory>): Map<Int, Int> {
|
||||||
val categoryIds = Category.createCategories(backupCategories.map { it.name })
|
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 metaEntryByCategoryId =
|
val metaEntryByCategoryId =
|
||||||
categoryIds
|
categoryIds
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ import suwayomi.tachidesk.manga.model.dataclass.TrackRecordDataClass
|
|||||||
import suwayomi.tachidesk.manga.model.table.ChapterTable
|
import suwayomi.tachidesk.manga.model.table.ChapterTable
|
||||||
import suwayomi.tachidesk.manga.model.table.MangaStatus
|
import suwayomi.tachidesk.manga.model.table.MangaStatus
|
||||||
import suwayomi.tachidesk.manga.model.table.MangaTable
|
import suwayomi.tachidesk.manga.model.table.MangaTable
|
||||||
import suwayomi.tachidesk.manga.model.table.toDataClass
|
|
||||||
import suwayomi.tachidesk.server.database.dbTransaction
|
import suwayomi.tachidesk.server.database.dbTransaction
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
@@ -75,6 +74,8 @@ object BackupMangaHandler {
|
|||||||
dateAdded = mangaRow[MangaTable.inLibraryAt].seconds.inWholeMilliseconds,
|
dateAdded = mangaRow[MangaTable.inLibraryAt].seconds.inWholeMilliseconds,
|
||||||
viewer = 0, // not supported in Tachidesk
|
viewer = 0, // not supported in Tachidesk
|
||||||
updateStrategy = UpdateStrategy.valueOf(mangaRow[MangaTable.updateStrategy]),
|
updateStrategy = UpdateStrategy.valueOf(mangaRow[MangaTable.updateStrategy]),
|
||||||
|
lastModifiedAt = mangaRow[MangaTable.lastModifiedAt],
|
||||||
|
version = mangaRow[MangaTable.version],
|
||||||
)
|
)
|
||||||
|
|
||||||
val mangaId = mangaRow[MangaTable.id].value
|
val mangaId = mangaRow[MangaTable.id].value
|
||||||
@@ -90,29 +91,31 @@ object BackupMangaHandler {
|
|||||||
.selectAll()
|
.selectAll()
|
||||||
.where { ChapterTable.manga eq mangaId }
|
.where { ChapterTable.manga eq mangaId }
|
||||||
.orderBy(ChapterTable.sourceOrder to SortOrder.DESC)
|
.orderBy(ChapterTable.sourceOrder to SortOrder.DESC)
|
||||||
.map {
|
.toList()
|
||||||
ChapterTable.toDataClass(it)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (flags.includeChapters) {
|
if (flags.includeChapters) {
|
||||||
val chapterToMeta = Chapter.getChaptersMetaMaps(chapters.map { it.id })
|
val chapterToMeta =
|
||||||
|
Chapter.getChaptersMetaMaps(chapters.map { it[ChapterTable.id].value })
|
||||||
|
|
||||||
backupManga.chapters =
|
backupManga.chapters =
|
||||||
chapters.map {
|
chapters.map {
|
||||||
BackupChapter(
|
BackupChapter(
|
||||||
it.url,
|
url = it[ChapterTable.url],
|
||||||
it.name,
|
name = it[ChapterTable.name],
|
||||||
it.scanlator,
|
scanlator = it[ChapterTable.scanlator],
|
||||||
it.read,
|
read = it[ChapterTable.isRead],
|
||||||
it.bookmarked,
|
bookmark = it[ChapterTable.isBookmarked],
|
||||||
it.lastPageRead,
|
lastPageRead = it[ChapterTable.lastPageRead],
|
||||||
it.fetchedAt.seconds.inWholeMilliseconds,
|
dateFetch = it[ChapterTable.fetchedAt].seconds.inWholeMilliseconds,
|
||||||
it.uploadDate,
|
dateUpload = it[ChapterTable.date_upload],
|
||||||
it.chapterNumber,
|
chapterNumber = it[ChapterTable.chapter_number],
|
||||||
chapters.size - it.index,
|
sourceOrder = chapters.size - it[ChapterTable.sourceOrder],
|
||||||
|
lastModifiedAt = it[ChapterTable.lastModifiedAt],
|
||||||
|
version = it[ChapterTable.version],
|
||||||
).apply {
|
).apply {
|
||||||
if (flags.includeClientData) {
|
if (flags.includeClientData) {
|
||||||
this.meta = chapterToMeta[it.id] ?: emptyMap()
|
this.meta = chapterToMeta[it[ChapterTable.id].value] ?: emptyMap()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -120,10 +123,10 @@ object BackupMangaHandler {
|
|||||||
if (flags.includeHistory) {
|
if (flags.includeHistory) {
|
||||||
backupManga.history =
|
backupManga.history =
|
||||||
chapters.mapNotNull {
|
chapters.mapNotNull {
|
||||||
if (it.lastReadAt > 0) {
|
if (it[ChapterTable.lastReadAt] > 0) {
|
||||||
BackupHistory(
|
BackupHistory(
|
||||||
url = it.url,
|
url = it[ChapterTable.url],
|
||||||
lastRead = it.lastReadAt.seconds.inWholeMilliseconds,
|
lastRead = it[ChapterTable.lastReadAt].seconds.inWholeMilliseconds,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
@@ -232,6 +235,9 @@ object BackupMangaHandler {
|
|||||||
it[inLibrary] = manga.favorite
|
it[inLibrary] = manga.favorite
|
||||||
|
|
||||||
it[inLibraryAt] = manga.dateAdded.milliseconds.inWholeSeconds
|
it[inLibraryAt] = manga.dateAdded.milliseconds.inWholeSeconds
|
||||||
|
|
||||||
|
it[lastModifiedAt] = manga.lastModifiedAt
|
||||||
|
it[version] = manga.version
|
||||||
}.value
|
}.value
|
||||||
} else {
|
} else {
|
||||||
val dbMangaId = dbManga[MangaTable.id].value
|
val dbMangaId = dbManga[MangaTable.id].value
|
||||||
@@ -251,6 +257,9 @@ object BackupMangaHandler {
|
|||||||
it[inLibrary] = manga.favorite || dbManga[inLibrary]
|
it[inLibrary] = manga.favorite || dbManga[inLibrary]
|
||||||
|
|
||||||
it[inLibraryAt] = manga.dateAdded.milliseconds.inWholeSeconds
|
it[inLibraryAt] = manga.dateAdded.milliseconds.inWholeSeconds
|
||||||
|
|
||||||
|
it[lastModifiedAt] = manga.lastModifiedAt
|
||||||
|
it[version] = manga.version
|
||||||
}
|
}
|
||||||
|
|
||||||
dbMangaId
|
dbMangaId
|
||||||
@@ -268,7 +277,7 @@ object BackupMangaHandler {
|
|||||||
restoreMangaChapterData(mangaId, restoreMode, chapters, history, flags)
|
restoreMangaChapterData(mangaId, restoreMode, chapters, history, flags)
|
||||||
}
|
}
|
||||||
|
|
||||||
// merge categories
|
// update categories
|
||||||
if (flags.includeCategories) {
|
if (flags.includeCategories) {
|
||||||
restoreMangaCategoryData(mangaId, categoryIds)
|
restoreMangaCategoryData(mangaId, categoryIds)
|
||||||
}
|
}
|
||||||
@@ -339,6 +348,9 @@ object BackupMangaHandler {
|
|||||||
this[ChapterTable.lastReadAt] =
|
this[ChapterTable.lastReadAt] =
|
||||||
historyByChapter[chapter.url]?.maxOrNull()?.milliseconds?.inWholeSeconds ?: 0
|
historyByChapter[chapter.url]?.maxOrNull()?.milliseconds?.inWholeSeconds ?: 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this[ChapterTable.lastModifiedAt] = chapter.lastModifiedAt
|
||||||
|
this[ChapterTable.version] = chapter.version
|
||||||
}.map { it[ChapterTable.id].value }
|
}.map { it[ChapterTable.id].value }
|
||||||
} else {
|
} else {
|
||||||
emptyList()
|
emptyList()
|
||||||
@@ -387,6 +399,7 @@ object BackupMangaHandler {
|
|||||||
mangaId: Int,
|
mangaId: Int,
|
||||||
categoryIds: List<Int>,
|
categoryIds: List<Int>,
|
||||||
) {
|
) {
|
||||||
|
CategoryManga.removeMangaFromAllCategories(mangaId)
|
||||||
CategoryManga.addMangaToCategories(mangaId, categoryIds)
|
CategoryManga.addMangaToCategories(mangaId, categoryIds)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ class BackupCategory(
|
|||||||
// @ProtoNumber(3) val updateInterval: Int = 0, 1.x value not used in 0.x
|
// @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
|
// Bump by 100 to specify this is a 0.x value
|
||||||
@ProtoNumber(100) var flags: Int = 0,
|
@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
|
// suwayomi
|
||||||
@ProtoNumber(9000) var meta: Map<String, String> = emptyMap(),
|
@ProtoNumber(9000) var meta: Map<String, String> = emptyMap(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ data class BackupChapter(
|
|||||||
// chapterNumber is called number is 1.x
|
// chapterNumber is called number is 1.x
|
||||||
@ProtoNumber(9) var chapterNumber: Float = 0F,
|
@ProtoNumber(9) var chapterNumber: Float = 0F,
|
||||||
@ProtoNumber(10) var sourceOrder: Int = 0,
|
@ProtoNumber(10) var sourceOrder: Int = 0,
|
||||||
|
// syncyomi
|
||||||
|
@ProtoNumber(11) var lastModifiedAt: Long = 0,
|
||||||
|
@ProtoNumber(12) var version: Long = 0,
|
||||||
// suwayomi
|
// suwayomi
|
||||||
@ProtoNumber(9000) var meta: Map<String, String> = emptyMap(),
|
@ProtoNumber(9000) var meta: Map<String, String> = emptyMap(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ data class BackupManga(
|
|||||||
@ProtoNumber(103) var viewer_flags: Int? = null,
|
@ProtoNumber(103) var viewer_flags: Int? = null,
|
||||||
@ProtoNumber(104) var history: List<BackupHistory> = emptyList(),
|
@ProtoNumber(104) var history: List<BackupHistory> = emptyList(),
|
||||||
@ProtoNumber(105) var updateStrategy: UpdateStrategy = UpdateStrategy.ALWAYS_UPDATE,
|
@ProtoNumber(105) var updateStrategy: UpdateStrategy = UpdateStrategy.ALWAYS_UPDATE,
|
||||||
|
// syncyomi
|
||||||
|
@ProtoNumber(106) var lastModifiedAt: Long = 0,
|
||||||
|
@ProtoNumber(109) var version: Long = 0,
|
||||||
// suwayomi
|
// suwayomi
|
||||||
@ProtoNumber(9000) var meta: Map<String, String> = emptyMap(),
|
@ProtoNumber(9000) var meta: Map<String, String> = emptyMap(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -31,6 +31,88 @@ import suwayomi.tachidesk.manga.model.table.PageTable
|
|||||||
import suwayomi.tachidesk.manga.model.table.toDataClass
|
import suwayomi.tachidesk.manga.model.table.toDataClass
|
||||||
import kotlin.time.Duration.Companion.minutes
|
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(
|
suspend fun getChapterDownloadReady(
|
||||||
chapterId: Int? = null,
|
chapterId: Int? = null,
|
||||||
chapterIndex: Int? = null,
|
chapterIndex: Int? = null,
|
||||||
@@ -68,56 +150,31 @@ private class ChapterForDownload(
|
|||||||
suspend fun asDownloadReady(): ChapterDataClass {
|
suspend fun asDownloadReady(): ChapterDataClass {
|
||||||
val log = KotlinLogging.logger("${logger.name}::asDownloadReady")
|
val log = KotlinLogging.logger("${logger.name}::asDownloadReady")
|
||||||
|
|
||||||
val downloadPageCount =
|
val downloadPageCount = runCatching { ChapterDownloadHelper.getImageCount(mangaId, chapterId) }.getOrDefault(0)
|
||||||
try {
|
|
||||||
ChapterDownloadHelper.getImageCount(mangaId, chapterId)
|
|
||||||
} catch (_: Exception) {
|
|
||||||
0
|
|
||||||
}
|
|
||||||
val isMarkedAsDownloaded = chapterEntry[ChapterTable.isDownloaded]
|
val isMarkedAsDownloaded = chapterEntry[ChapterTable.isDownloaded]
|
||||||
val dbPageCount = chapterEntry[ChapterTable.pageCount]
|
val dbPageCount = chapterEntry[ChapterTable.pageCount]
|
||||||
val doesDownloadExist = downloadPageCount != 0
|
val doesDownloadExist = downloadPageCount != 0
|
||||||
val doPageCountsMatch = dbPageCount == downloadPageCount
|
|
||||||
|
|
||||||
log.debug { "isMarkedAsDownloaded= $isMarkedAsDownloaded, dbPageCount= $dbPageCount, downloadPageCount= $downloadPageCount" }
|
log.debug { "isMarkedAsDownloaded= $isMarkedAsDownloaded, dbPageCount= $dbPageCount, downloadPageCount= $downloadPageCount" }
|
||||||
|
|
||||||
return if (!doesDownloadExist) {
|
return if (!doesDownloadExist) {
|
||||||
log.debug { "reset download status and fetch page list" }
|
log.debug { "reset download status and fetch page list" }
|
||||||
updateDownloadStatusAndPageList(false)
|
refreshChapterPageList(mangaId, chapterId, chapterEntry)
|
||||||
|
chapterEntry = freshChapterEntry(optChapterId = chapterId)
|
||||||
|
ChapterTable.toDataClass(chapterEntry)
|
||||||
} else {
|
} else {
|
||||||
transaction {
|
if (updateChapterPersistence(
|
||||||
var needsUpdate = false
|
chapterId,
|
||||||
|
isMarkedAsDownloaded,
|
||||||
if (!isMarkedAsDownloaded) {
|
dbPageCount,
|
||||||
log.debug { "mark as downloaded" }
|
downloadPageCount,
|
||||||
ChapterTable.update({ ChapterTable.id eq chapterId }) {
|
chapterEntry[ChapterTable.lastPageRead],
|
||||||
it[isDownloaded] = true
|
log,
|
||||||
}
|
)
|
||||||
needsUpdate = true
|
) {
|
||||||
}
|
chapterEntry = freshChapterEntry(optChapterId = chapterId)
|
||||||
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,59 +207,4 @@ private class ChapterForDownload(
|
|||||||
}
|
}
|
||||||
}.first()
|
}.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) }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,29 +127,36 @@ object KoreaderSyncService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val mangaId = chapterRow[ChapterTable.manga].value
|
val mangaId = chapterRow[ChapterTable.manga].value
|
||||||
|
val isDownloaded = chapterRow[ChapterTable.isDownloaded]
|
||||||
val checksumMethod = serverConfig.koreaderSyncChecksumMethod.value
|
val checksumMethod = serverConfig.koreaderSyncChecksumMethod.value
|
||||||
|
|
||||||
val newHash =
|
val newHash =
|
||||||
when (checksumMethod) {
|
when (checksumMethod) {
|
||||||
KoreaderSyncChecksumMethod.BINARY -> {
|
KoreaderSyncChecksumMethod.BINARY -> {
|
||||||
logger.debug { "[KOSYNC HASH] No hash for chapterId=$chapterId. Generating from downloaded content." }
|
// Only generate binary hash if the chapter is downloaded to avoid fetching missing files
|
||||||
try {
|
if (isDownloaded) {
|
||||||
// Always create a CBZ in memory if it doesn't exist
|
logger.debug { "[KOSYNC HASH] No hash for chapterId=$chapterId. Generating from downloaded content." }
|
||||||
val (stream, _) = ChapterDownloadHelper.getArchiveStreamWithSize(mangaId, chapterId)
|
|
||||||
// Write the stream to a temp file for partial hashing
|
|
||||||
val tempFile = File.createTempFile("kosync-hash-", ".cbz")
|
|
||||||
try {
|
try {
|
||||||
tempFile.outputStream().use { fos ->
|
// Always create a CBZ in memory if it doesn't exist
|
||||||
stream.use { it.copyTo(fos) }
|
val (stream, _) = ChapterDownloadHelper.getArchiveStreamWithSize(mangaId, chapterId)
|
||||||
|
// Write the stream to a temp file for partial hashing
|
||||||
|
val tempFile = File.createTempFile("kosync-hash-", ".cbz")
|
||||||
|
try {
|
||||||
|
tempFile.outputStream().use { fos ->
|
||||||
|
stream.use { it.copyTo(fos) }
|
||||||
|
}
|
||||||
|
// Use the same hashing method as for downloads
|
||||||
|
KoreaderHelper.hashContents(tempFile)
|
||||||
|
} finally {
|
||||||
|
// Always delete the temp file
|
||||||
|
tempFile.delete()
|
||||||
}
|
}
|
||||||
// Use the same hashing method as for downloads
|
} catch (e: Exception) {
|
||||||
KoreaderHelper.hashContents(tempFile)
|
logger.warn(e) { "[KOSYNC HASH] Failed to generate archive stream for chapterId=$chapterId." }
|
||||||
} finally {
|
null
|
||||||
// Always delete the temp file
|
|
||||||
tempFile.delete()
|
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} else {
|
||||||
logger.warn(e) { "[KOSYNC HASH] Failed to generate archive stream for chapterId=$chapterId." }
|
logger.debug { "[KOSYNC HASH] Skipping binary hash for chapterId=$chapterId because it is not downloaded." }
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -175,7 +182,7 @@ object KoreaderSyncService {
|
|||||||
}
|
}
|
||||||
logger.info { "[KOSYNC HASH] Generated and saved new hash for chapterId=$chapterId" }
|
logger.info { "[KOSYNC HASH] Generated and saved new hash for chapterId=$chapterId" }
|
||||||
} else {
|
} else {
|
||||||
logger.warn { "[KOSYNC HASH] Hashing failed for chapterId=$chapterId." }
|
logger.warn { "[KOSYNC HASH] Hashing failed or skipped for chapterId=$chapterId." }
|
||||||
}
|
}
|
||||||
newHash
|
newHash
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import kotlinx.coroutines.flow.update
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Semaphore
|
import kotlinx.coroutines.sync.Semaphore
|
||||||
import kotlinx.coroutines.sync.withPermit
|
import kotlinx.coroutines.sync.withPermit
|
||||||
|
import suwayomi.tachidesk.global.impl.sync.SyncManager
|
||||||
import suwayomi.tachidesk.manga.impl.Category
|
import suwayomi.tachidesk.manga.impl.Category
|
||||||
import suwayomi.tachidesk.manga.impl.CategoryManga
|
import suwayomi.tachidesk.manga.impl.CategoryManga
|
||||||
import suwayomi.tachidesk.manga.impl.Chapter
|
import suwayomi.tachidesk.manga.impl.Chapter
|
||||||
@@ -337,90 +338,98 @@ class Updater : IUpdater {
|
|||||||
clear: Boolean?,
|
clear: Boolean?,
|
||||||
forceAll: Boolean,
|
forceAll: Boolean,
|
||||||
) {
|
) {
|
||||||
saveLastUpdateTimestamp()
|
|
||||||
|
|
||||||
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),
|
|
||||||
)
|
|
||||||
|
|
||||||
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()
|
|
||||||
|
|
||||||
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(immediate = true)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
scope.launch {
|
scope.launch {
|
||||||
updateStatus(
|
SyncManager.ensureSync()
|
||||||
categoryUpdates =
|
|
||||||
updateStatusCategories[CategoryUpdateStatus.UPDATING]
|
saveLastUpdateTimestamp()
|
||||||
?.map {
|
|
||||||
CategoryUpdateJob(it, CategoryUpdateStatus.UPDATING)
|
if (clear == true) {
|
||||||
}.orEmpty(),
|
reset()
|
||||||
mangaUpdates = mangasToUpdate.map { UpdateJob(it) },
|
}
|
||||||
isRunning = true,
|
|
||||||
|
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 }}'" }
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
addMangasToQueue(
|
|
||||||
mangasToUpdate
|
|
||||||
.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER, MangaDataClass::title)),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun addMangasToQueue(mangas: List<MangaDataClass>) {
|
override fun addMangasToQueue(mangas: List<MangaDataClass>) {
|
||||||
|
|||||||
@@ -24,14 +24,22 @@ private val applicationDirs: ApplicationDirs by injectLazy()
|
|||||||
|
|
||||||
private val logger = KotlinLogging.logger { }
|
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 =
|
private fun getMangaDir(mangaId: Int): String =
|
||||||
transaction {
|
transaction {
|
||||||
val mangaEntry = MangaTable.selectAll().where { MangaTable.id eq mangaId }.first()
|
val mangaEntry = MangaTable.selectAll().where { MangaTable.id eq mangaId }.first()
|
||||||
val source = GetCatalogueSource.getCatalogueSourceOrStub(mangaEntry[MangaTable.sourceReference])
|
val source = GetCatalogueSource.getCatalogueSourceOrStub(mangaEntry[MangaTable.sourceReference])
|
||||||
|
|
||||||
val sourceDir = SafePath.buildValidFilename(source.toString())
|
getMangaDir(mangaEntry[MangaTable.title], source.toString())
|
||||||
val mangaDir = SafePath.buildValidFilename(mangaEntry[MangaTable.title])
|
|
||||||
"$sourceDir/$mangaDir"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getChapterDir(
|
private fun getChapterDir(
|
||||||
@@ -62,8 +70,18 @@ private fun getChapterDir(
|
|||||||
|
|
||||||
fun getThumbnailDownloadPath(mangaId: Int): String = applicationDirs.thumbnailDownloadsRoot + "/$mangaId"
|
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 getMangaDownloadDir(mangaId: Int): String = applicationDirs.mangaDownloadsRoot + "/" + getMangaDir(mangaId)
|
||||||
|
|
||||||
|
fun getMangaCacheDir(
|
||||||
|
title: String,
|
||||||
|
sourceName: String,
|
||||||
|
): String = applicationDirs.tempMangaCacheRoot + "/" + getMangaDir(title, sourceName)
|
||||||
|
|
||||||
fun getChapterDownloadPath(
|
fun getChapterDownloadPath(
|
||||||
mangaId: Int,
|
mangaId: Int,
|
||||||
chapterId: Int,
|
chapterId: Int,
|
||||||
@@ -79,38 +97,21 @@ fun getChapterCachePath(
|
|||||||
chapterId: Int,
|
chapterId: Int,
|
||||||
): String = applicationDirs.tempMangaCacheRoot + "/" + getChapterDir(mangaId, chapterId)
|
): String = applicationDirs.tempMangaCacheRoot + "/" + getChapterDir(mangaId, chapterId)
|
||||||
|
|
||||||
/** return value says if rename/move was successful */
|
private fun updateDownloadDir(
|
||||||
fun updateMangaDownloadDir(
|
currentDir: String,
|
||||||
mangaId: Int,
|
newDir: String,
|
||||||
newTitle: String,
|
|
||||||
): Boolean {
|
): Boolean {
|
||||||
// Get current manga directory (uses its own transaction)
|
val currentDirFile = File(currentDir)
|
||||||
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)
|
val newDirFile = File(newDir)
|
||||||
|
|
||||||
if (!oldDirFile.exists()) {
|
if (!currentDirFile.exists()) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
Files.move(oldDirFile.toPath(), newDirFile.toPath())
|
Files.move(currentDirFile.toPath(), newDirFile.toPath())
|
||||||
|
|
||||||
if (oldDirFile.exists()) {
|
if (currentDirFile.exists()) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,9 +119,31 @@ fun updateMangaDownloadDir(
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
true
|
return true
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error(e) { "updateMangaDownloadDir: failed to rename manga download folder from \"$oldDir\" to \"$newDir\"" }
|
logger.error(e) { "updateDownloadDir: failed to rename download folder from \"$currentDir\" to \"$newDir\"" }
|
||||||
false
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package suwayomi.tachidesk.manga.model.dataclass
|
package suwayomi.tachidesk.manga.model.dataclass
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonValue
|
import com.fasterxml.jackson.annotation.JsonValue
|
||||||
|
import suwayomi.tachidesk.manga.impl.Category
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Copyright (C) Contributors to the Suwayomi project
|
* Copyright (C) Contributors to the Suwayomi project
|
||||||
@@ -18,7 +19,7 @@ enum class IncludeOrExclude(
|
|||||||
;
|
;
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun fromValue(value: Int) = IncludeOrExclude.values().find { it.value == value } ?: UNSET
|
fun fromValue(value: Int) = entries.find { it.value == value } ?: UNSET
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,8 +28,19 @@ data class CategoryDataClass(
|
|||||||
val order: Int,
|
val order: Int,
|
||||||
val name: String,
|
val name: String,
|
||||||
val default: Boolean,
|
val default: Boolean,
|
||||||
val size: Int,
|
|
||||||
val includeInUpdate: IncludeOrExclude,
|
val includeInUpdate: IncludeOrExclude,
|
||||||
val includeInDownload: IncludeOrExclude,
|
val includeInDownload: IncludeOrExclude,
|
||||||
val meta: Map<String, String> = emptyMap(),
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
package suwayomi.tachidesk.manga.model.dataclass
|
package suwayomi.tachidesk.manga.model.dataclass
|
||||||
|
|
||||||
import eu.kanade.tachiyomi.source.model.SChapter
|
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
|
* Copyright (C) Contributors to the Suwayomi project
|
||||||
@@ -36,10 +41,8 @@ data class ChapterDataClass(
|
|||||||
val downloaded: Boolean,
|
val downloaded: Boolean,
|
||||||
/** used to construct pages in the front-end */
|
/** used to construct pages in the front-end */
|
||||||
val pageCount: Int = -1,
|
val pageCount: Int = -1,
|
||||||
/** total chapter count, used to calculate if there's a next and prev chapter */
|
val lastModifiedAt: Long = 0,
|
||||||
val chapterCount: Int? = null,
|
val version: Long = 0,
|
||||||
/** used to store client specific values */
|
|
||||||
val meta: Map<String, String> = emptyMap(),
|
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
fun fromSChapter(
|
fun fromSChapter(
|
||||||
@@ -68,4 +71,20 @@ data class ChapterDataClass(
|
|||||||
downloaded = false,
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ package suwayomi.tachidesk.manga.model.dataclass
|
|||||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
||||||
|
|
||||||
import eu.kanade.tachiyomi.source.model.UpdateStrategy
|
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.impl.util.lang.trimAll
|
||||||
import suwayomi.tachidesk.manga.model.table.MangaStatus
|
import suwayomi.tachidesk.manga.model.table.MangaStatus
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
@@ -28,23 +29,28 @@ data class MangaDataClass(
|
|||||||
val inLibrary: Boolean = false,
|
val inLibrary: Boolean = false,
|
||||||
val inLibraryAt: Long = 0,
|
val inLibraryAt: Long = 0,
|
||||||
val source: SourceDataClass? = null,
|
val source: SourceDataClass? = null,
|
||||||
/** meta data for clients */
|
|
||||||
val meta: Map<String, String> = emptyMap(),
|
|
||||||
val realUrl: String? = null,
|
val realUrl: String? = null,
|
||||||
var lastFetchedAt: Long? = 0,
|
val lastFetchedAt: Long? = 0,
|
||||||
var chaptersLastFetchedAt: Long? = 0,
|
val chaptersLastFetchedAt: Long? = 0,
|
||||||
var updateStrategy: UpdateStrategy = UpdateStrategy.ALWAYS_UPDATE,
|
val updateStrategy: UpdateStrategy = UpdateStrategy.ALWAYS_UPDATE,
|
||||||
val freshData: Boolean = false,
|
val freshData: Boolean = false,
|
||||||
var unreadCount: Long? = null,
|
val unreadCount: Long? = null,
|
||||||
var downloadCount: Long? = null,
|
val downloadCount: Long? = null,
|
||||||
var chapterCount: Long? = null,
|
val chapterCount: Long? = null,
|
||||||
var lastReadAt: Long? = null,
|
val lastReadAt: Long? = null,
|
||||||
var lastChapterRead: ChapterDataClass? = null,
|
val lastChapterRead: ChapterDataClass? = null,
|
||||||
val age: Long? = if (lastFetchedAt == null) 0 else Instant.now().epochSecond.minus(lastFetchedAt),
|
val age: Long? = if (lastFetchedAt == null) 0 else Instant.now().epochSecond.minus(lastFetchedAt),
|
||||||
val chaptersAge: Long? = if (chaptersLastFetchedAt == null) null else Instant.now().epochSecond.minus(chaptersLastFetchedAt),
|
val chaptersAge: Long? = if (chaptersLastFetchedAt == null) null else Instant.now().epochSecond.minus(chaptersLastFetchedAt),
|
||||||
val trackers: List<MangaTrackerDataClass>? = null,
|
val trackers: List<MangaTrackerDataClass>? = null,
|
||||||
|
val lastModifiedAt: Long = 0,
|
||||||
|
val version: Long = 0,
|
||||||
) {
|
) {
|
||||||
override fun toString(): String = "\"$title\" (id= $id) (sourceId= $sourceId)"
|
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(
|
data class PagedMangaListDataClass(
|
||||||
|
|||||||
@@ -7,12 +7,21 @@ package suwayomi.tachidesk.manga.model.dataclass
|
|||||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
* 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/. */
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
||||||
|
|
||||||
|
import java.util.Objects
|
||||||
import kotlin.math.min
|
import kotlin.math.min
|
||||||
|
|
||||||
open class PaginatedList<T>(
|
open class PaginatedList<T>(
|
||||||
val page: List<T>,
|
val page: List<T>,
|
||||||
val hasNextPage: Boolean,
|
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
|
const val PAGINATION_FACTOR = 50
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ package suwayomi.tachidesk.manga.model.table
|
|||||||
|
|
||||||
import org.jetbrains.exposed.v1.core.ResultRow
|
import org.jetbrains.exposed.v1.core.ResultRow
|
||||||
import org.jetbrains.exposed.v1.core.dao.id.IntIdTable
|
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.CategoryDataClass
|
||||||
import suwayomi.tachidesk.manga.model.dataclass.IncludeOrExclude
|
import suwayomi.tachidesk.manga.model.dataclass.IncludeOrExclude
|
||||||
|
|
||||||
@@ -19,16 +18,22 @@ object CategoryTable : IntIdTable() {
|
|||||||
val isDefault = bool("is_default").default(false)
|
val isDefault = bool("is_default").default(false)
|
||||||
val includeInUpdate = integer("include_in_update").default(IncludeOrExclude.UNSET.value)
|
val includeInUpdate = integer("include_in_update").default(IncludeOrExclude.UNSET.value)
|
||||||
val includeInDownload = integer("include_in_download").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) =
|
fun CategoryTable.toDataClass(categoryEntry: ResultRow) =
|
||||||
CategoryDataClass(
|
CategoryDataClass(
|
||||||
categoryEntry[id].value,
|
id = categoryEntry[id].value,
|
||||||
categoryEntry[order],
|
order = categoryEntry[order],
|
||||||
categoryEntry[name],
|
name = categoryEntry[name],
|
||||||
categoryEntry[isDefault],
|
default = categoryEntry[isDefault],
|
||||||
Category.getCategorySize(categoryEntry[id].value),
|
includeInUpdate = IncludeOrExclude.fromValue(categoryEntry[includeInUpdate]),
|
||||||
IncludeOrExclude.fromValue(categoryEntry[includeInUpdate]),
|
includeInDownload = IncludeOrExclude.fromValue(categoryEntry[includeInDownload]),
|
||||||
IncludeOrExclude.fromValue(categoryEntry[includeInDownload]),
|
version = categoryEntry[version],
|
||||||
Category.getCategoryMetaMap(categoryEntry[id].value),
|
uid = categoryEntry[uid],
|
||||||
|
lastModifiedAt = categoryEntry[lastModifiedAt],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,10 +10,6 @@ package suwayomi.tachidesk.manga.model.table
|
|||||||
import org.jetbrains.exposed.v1.core.ReferenceOption
|
import org.jetbrains.exposed.v1.core.ReferenceOption
|
||||||
import org.jetbrains.exposed.v1.core.ResultRow
|
import org.jetbrains.exposed.v1.core.ResultRow
|
||||||
import org.jetbrains.exposed.v1.core.dao.id.IntIdTable
|
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.dataclass.ChapterDataClass
|
||||||
import suwayomi.tachidesk.manga.model.table.columns.truncatingVarchar
|
import suwayomi.tachidesk.manga.model.table.columns.truncatingVarchar
|
||||||
|
|
||||||
@@ -42,45 +38,30 @@ object ChapterTable : IntIdTable() {
|
|||||||
val manga = reference("manga", MangaTable, ReferenceOption.CASCADE)
|
val manga = reference("manga", MangaTable, ReferenceOption.CASCADE)
|
||||||
|
|
||||||
val koreaderHash = varchar("koreader_hash", 32).nullable()
|
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(
|
fun ChapterTable.toDataClass(chapterEntry: ResultRow) =
|
||||||
chapterEntry: ResultRow,
|
ChapterDataClass(
|
||||||
includeChapterCount: Boolean = true,
|
id = chapterEntry[id].value,
|
||||||
includeChapterMeta: Boolean = true,
|
url = chapterEntry[url],
|
||||||
) = ChapterDataClass(
|
name = chapterEntry[name],
|
||||||
id = chapterEntry[id].value,
|
uploadDate = chapterEntry[date_upload],
|
||||||
url = chapterEntry[url],
|
chapterNumber = chapterEntry[chapter_number],
|
||||||
name = chapterEntry[name],
|
scanlator = chapterEntry[scanlator],
|
||||||
uploadDate = chapterEntry[date_upload],
|
mangaId = chapterEntry[manga].value,
|
||||||
chapterNumber = chapterEntry[chapter_number],
|
read = chapterEntry[isRead],
|
||||||
scanlator = chapterEntry[scanlator],
|
bookmarked = chapterEntry[isBookmarked],
|
||||||
mangaId = chapterEntry[manga].value,
|
lastPageRead = chapterEntry[lastPageRead],
|
||||||
read = chapterEntry[isRead],
|
lastReadAt = chapterEntry[lastReadAt],
|
||||||
bookmarked = chapterEntry[isBookmarked],
|
index = chapterEntry[sourceOrder],
|
||||||
lastPageRead = chapterEntry[lastPageRead],
|
fetchedAt = chapterEntry[fetchedAt],
|
||||||
lastReadAt = chapterEntry[lastReadAt],
|
realUrl = chapterEntry[realUrl],
|
||||||
index = chapterEntry[sourceOrder],
|
downloaded = chapterEntry[isDownloaded],
|
||||||
fetchedAt = chapterEntry[fetchedAt],
|
pageCount = chapterEntry[pageCount],
|
||||||
realUrl = chapterEntry[realUrl],
|
lastModifiedAt = chapterEntry[lastModifiedAt],
|
||||||
downloaded = chapterEntry[isDownloaded],
|
version = chapterEntry[version],
|
||||||
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()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -11,11 +11,9 @@ import eu.kanade.tachiyomi.source.model.SManga
|
|||||||
import eu.kanade.tachiyomi.source.model.UpdateStrategy
|
import eu.kanade.tachiyomi.source.model.UpdateStrategy
|
||||||
import org.jetbrains.exposed.v1.core.ResultRow
|
import org.jetbrains.exposed.v1.core.ResultRow
|
||||||
import org.jetbrains.exposed.v1.core.dao.id.IntIdTable
|
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.impl.MangaList.proxyThumbnailUrl
|
||||||
import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass
|
import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass
|
||||||
import suwayomi.tachidesk.manga.model.dataclass.toGenreList
|
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.truncatingVarchar
|
||||||
import suwayomi.tachidesk.manga.model.table.columns.unlimitedVarchar
|
import suwayomi.tachidesk.manga.model.table.columns.unlimitedVarchar
|
||||||
|
|
||||||
@@ -46,37 +44,35 @@ object MangaTable : IntIdTable() {
|
|||||||
val chaptersLastFetchedAt = long("chapters_last_fetched_at").default(0)
|
val chaptersLastFetchedAt = long("chapters_last_fetched_at").default(0)
|
||||||
|
|
||||||
val updateStrategy = varchar("update_strategy", 256).default(UpdateStrategy.ALWAYS_UPDATE.name)
|
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(
|
fun MangaTable.toDataClass(mangaEntry: ResultRow) =
|
||||||
mangaEntry: ResultRow,
|
MangaDataClass(
|
||||||
includeMangaMeta: Boolean = true,
|
id = mangaEntry[this.id].value,
|
||||||
) = MangaDataClass(
|
sourceId = mangaEntry[sourceReference].toString(),
|
||||||
id = mangaEntry[this.id].value,
|
url = mangaEntry[url],
|
||||||
sourceId = mangaEntry[sourceReference].toString(),
|
title = mangaEntry[title],
|
||||||
url = mangaEntry[url],
|
thumbnailUrl = proxyThumbnailUrl(mangaEntry[this.id].value),
|
||||||
title = mangaEntry[title],
|
thumbnailUrlLastFetched = mangaEntry[thumbnailUrlLastFetched],
|
||||||
thumbnailUrl = proxyThumbnailUrl(mangaEntry[this.id].value),
|
initialized = mangaEntry[initialized],
|
||||||
thumbnailUrlLastFetched = mangaEntry[thumbnailUrlLastFetched],
|
artist = mangaEntry[artist],
|
||||||
initialized = mangaEntry[initialized],
|
author = mangaEntry[author],
|
||||||
artist = mangaEntry[artist],
|
description = mangaEntry[description],
|
||||||
author = mangaEntry[author],
|
genre = mangaEntry[genre].toGenreList(),
|
||||||
description = mangaEntry[description],
|
status = MangaStatus.valueOf(mangaEntry[status]).name,
|
||||||
genre = mangaEntry[genre].toGenreList(),
|
inLibrary = mangaEntry[inLibrary],
|
||||||
status = Companion.valueOf(mangaEntry[status]).name,
|
inLibraryAt = mangaEntry[inLibraryAt],
|
||||||
inLibrary = mangaEntry[inLibrary],
|
realUrl = mangaEntry[realUrl],
|
||||||
inLibraryAt = mangaEntry[inLibraryAt],
|
lastFetchedAt = mangaEntry[lastFetchedAt],
|
||||||
meta =
|
chaptersLastFetchedAt = mangaEntry[chaptersLastFetchedAt],
|
||||||
if (includeMangaMeta) {
|
updateStrategy = UpdateStrategy.valueOf(mangaEntry[updateStrategy]),
|
||||||
getMangaMetaMap(mangaEntry[id].value)
|
lastModifiedAt = mangaEntry[lastModifiedAt],
|
||||||
} else {
|
version = mangaEntry[version],
|
||||||
emptyMap()
|
)
|
||||||
},
|
|
||||||
realUrl = mangaEntry[realUrl],
|
|
||||||
lastFetchedAt = mangaEntry[lastFetchedAt],
|
|
||||||
chaptersLastFetchedAt = mangaEntry[chaptersLastFetchedAt],
|
|
||||||
updateStrategy = UpdateStrategy.valueOf(mangaEntry[updateStrategy]),
|
|
||||||
)
|
|
||||||
|
|
||||||
enum class MangaStatus(
|
enum class MangaStatus(
|
||||||
val value: Int,
|
val value: Int,
|
||||||
|
|||||||
@@ -13,18 +13,22 @@ object OpdsAPI {
|
|||||||
// OPDS Search Description Feed
|
// OPDS Search Description Feed
|
||||||
get("search", OpdsV1Controller.searchFeed)
|
get("search", OpdsV1Controller.searchFeed)
|
||||||
|
|
||||||
// --- Main Navigation Feeds ---
|
|
||||||
|
|
||||||
// Explore Navigation Feed
|
|
||||||
get("explore", OpdsV1Controller.exploreSourcesFeed)
|
|
||||||
|
|
||||||
// Reading History Acquisition Feed
|
// Reading History Acquisition Feed
|
||||||
get("history", OpdsV1Controller.historyFeed)
|
get("history", OpdsV1Controller.historyFeed)
|
||||||
|
|
||||||
// Library Updates Acquisition Feed
|
// Library Updates Acquisition Feed
|
||||||
get("library-updates", OpdsV1Controller.libraryUpdatesFeed)
|
get("library-updates", OpdsV1Controller.libraryUpdatesFeed)
|
||||||
|
|
||||||
// --- Library-Specific Feeds ---
|
// --- 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 ---
|
||||||
path("library") {
|
path("library") {
|
||||||
// All Series in Library / Search Results Feed (Acquisition)
|
// All Series in Library / Search Results Feed (Acquisition)
|
||||||
get("series", OpdsV1Controller.seriesFeed)
|
get("series", OpdsV1Controller.seriesFeed)
|
||||||
@@ -32,11 +36,6 @@ object OpdsAPI {
|
|||||||
// Library Sources Navigation Feed
|
// Library Sources Navigation Feed
|
||||||
get("sources", OpdsV1Controller.librarySourcesFeed)
|
get("sources", OpdsV1Controller.librarySourcesFeed)
|
||||||
|
|
||||||
// Library Source-Specific Series Acquisition Feed
|
|
||||||
path("source/{sourceId}") {
|
|
||||||
get(OpdsV1Controller.librarySourceFeed)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Library Categories Navigation Feed
|
// Library Categories Navigation Feed
|
||||||
get("categories", OpdsV1Controller.categoriesFeed)
|
get("categories", OpdsV1Controller.categoriesFeed)
|
||||||
|
|
||||||
@@ -50,26 +49,10 @@ object OpdsAPI {
|
|||||||
get("languages", OpdsV1Controller.languagesFeed)
|
get("languages", OpdsV1Controller.languagesFeed)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Explore-Specific Feeds ---
|
// --- Library Series Filters ---
|
||||||
|
// Source-Specific Series Acquisition Feed (Library)
|
||||||
// All Sources Navigation Feed (Explore)
|
|
||||||
get("sources", OpdsV1Controller.exploreSourcesFeed)
|
|
||||||
|
|
||||||
// Source-Specific Series Acquisition Feed (Explore)
|
|
||||||
path("source/{sourceId}") {
|
path("source/{sourceId}") {
|
||||||
get(OpdsV1Controller.exploreSourceFeed)
|
get(OpdsV1Controller.librarySourceFeed)
|
||||||
}
|
|
||||||
|
|
||||||
// --- 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)
|
// Category-Specific Series Acquisition Feed (Library)
|
||||||
@@ -91,6 +74,16 @@ object OpdsAPI {
|
|||||||
path("language/{langCode}") {
|
path("language/{langCode}") {
|
||||||
get(OpdsV1Controller.languageFeed)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,21 +32,21 @@ object OpdsV1Controller {
|
|||||||
*/
|
*/
|
||||||
private fun getLibraryFeed(
|
private fun getLibraryFeed(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
pageNum: Int?,
|
locale: Locale,
|
||||||
criteria: OpdsMangaFilter,
|
criteria: OpdsMangaFilter,
|
||||||
isSearch: Boolean,
|
isSearch: Boolean,
|
||||||
|
pageNum: Int,
|
||||||
) {
|
) {
|
||||||
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, ctx.queryParam("lang"))
|
|
||||||
ctx.future {
|
ctx.future {
|
||||||
future {
|
future {
|
||||||
OpdsFeedBuilder.getLibraryFeed(
|
OpdsFeedBuilder.getLibraryFeed(
|
||||||
criteria = criteria,
|
BASE_URL,
|
||||||
baseUrl = BASE_URL,
|
locale,
|
||||||
pageNum = pageNum ?: 1,
|
criteria,
|
||||||
sort = criteria.sort,
|
isSearch,
|
||||||
filter = criteria.filter,
|
pageNum,
|
||||||
locale = locale,
|
criteria.sort,
|
||||||
isSearch = isSearch,
|
criteria.filter,
|
||||||
)
|
)
|
||||||
}.thenApply { xml ->
|
}.thenApply { xml ->
|
||||||
ctx.contentType(OPDS_MIME).result(xml)
|
ctx.contentType(OPDS_MIME).result(xml)
|
||||||
@@ -94,7 +94,7 @@ object OpdsV1Controller {
|
|||||||
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
|
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
|
||||||
ctx.future {
|
ctx.future {
|
||||||
future {
|
future {
|
||||||
OpdsFeedBuilder.getHistoryFeed(BASE_URL, pageNumber ?: 1, locale)
|
OpdsFeedBuilder.getHistoryFeed(BASE_URL, locale, pageNumber ?: 1)
|
||||||
}.thenApply { xml ->
|
}.thenApply { xml ->
|
||||||
ctx.contentType(OPDS_MIME).result(xml)
|
ctx.contentType(OPDS_MIME).result(xml)
|
||||||
}
|
}
|
||||||
@@ -139,6 +139,7 @@ object OpdsV1Controller {
|
|||||||
/**
|
/**
|
||||||
* Serves an acquisition feed for all series in the library or search results.
|
* 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 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 =
|
val seriesFeed =
|
||||||
handler(
|
handler(
|
||||||
@@ -157,29 +158,14 @@ object OpdsV1Controller {
|
|||||||
val opdsSearchCriteria = OpdsSearchCriteria(query, author, title)
|
val opdsSearchCriteria = OpdsSearchCriteria(query, author, title)
|
||||||
ctx.future {
|
ctx.future {
|
||||||
future {
|
future {
|
||||||
OpdsFeedBuilder.getSearchFeed(opdsSearchCriteria, BASE_URL, pageNumber ?: 1, locale)
|
OpdsFeedBuilder.getSearchFeed(BASE_URL, locale, opdsSearchCriteria, pageNumber ?: 1)
|
||||||
}.thenApply { xml ->
|
}.thenApply { xml ->
|
||||||
ctx.contentType(OPDS_MIME).result(xml)
|
ctx.contentType(OPDS_MIME).result(xml)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
val criteria =
|
val criteria = OpdsMangaFilter.fromContext(ctx, PrimaryFilterType.NONE)
|
||||||
OpdsMangaFilter(
|
getLibraryFeed(ctx, locale, criteria, false, pageNumber ?: 1)
|
||||||
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) },
|
withResults = { httpCode(HttpStatus.OK) },
|
||||||
@@ -203,7 +189,7 @@ object OpdsV1Controller {
|
|||||||
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
|
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
|
||||||
ctx.future {
|
ctx.future {
|
||||||
future {
|
future {
|
||||||
OpdsFeedBuilder.getExploreSourcesFeed(BASE_URL, pageNumber ?: 1, locale)
|
OpdsFeedBuilder.getExploreSourcesFeed(BASE_URL, locale, pageNumber ?: 1)
|
||||||
}.thenApply { xml ->
|
}.thenApply { xml ->
|
||||||
ctx.contentType(OPDS_MIME).result(xml)
|
ctx.contentType(OPDS_MIME).result(xml)
|
||||||
}
|
}
|
||||||
@@ -230,7 +216,7 @@ object OpdsV1Controller {
|
|||||||
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
|
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
|
||||||
ctx.future {
|
ctx.future {
|
||||||
future {
|
future {
|
||||||
OpdsFeedBuilder.getLibrarySourcesFeed(BASE_URL, pageNumber ?: 1, locale)
|
OpdsFeedBuilder.getLibrarySourcesFeed(BASE_URL, locale, pageNumber ?: 1)
|
||||||
}.thenApply { xml ->
|
}.thenApply { xml ->
|
||||||
ctx.contentType(OPDS_MIME).result(xml)
|
ctx.contentType(OPDS_MIME).result(xml)
|
||||||
}
|
}
|
||||||
@@ -257,7 +243,7 @@ object OpdsV1Controller {
|
|||||||
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
|
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
|
||||||
ctx.future {
|
ctx.future {
|
||||||
future {
|
future {
|
||||||
OpdsFeedBuilder.getCategoriesFeed(BASE_URL, pageNumber ?: 1, locale)
|
OpdsFeedBuilder.getCategoriesFeed(BASE_URL, locale, pageNumber ?: 1)
|
||||||
}.thenApply { xml ->
|
}.thenApply { xml ->
|
||||||
ctx.contentType(OPDS_MIME).result(xml)
|
ctx.contentType(OPDS_MIME).result(xml)
|
||||||
}
|
}
|
||||||
@@ -284,7 +270,7 @@ object OpdsV1Controller {
|
|||||||
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
|
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
|
||||||
ctx.future {
|
ctx.future {
|
||||||
future {
|
future {
|
||||||
OpdsFeedBuilder.getGenresFeed(BASE_URL, pageNumber ?: 1, locale)
|
OpdsFeedBuilder.getGenresFeed(BASE_URL, locale, pageNumber ?: 1)
|
||||||
}.thenApply { xml ->
|
}.thenApply { xml ->
|
||||||
ctx.contentType(OPDS_MIME).result(xml)
|
ctx.contentType(OPDS_MIME).result(xml)
|
||||||
}
|
}
|
||||||
@@ -298,6 +284,7 @@ object OpdsV1Controller {
|
|||||||
*/
|
*/
|
||||||
val statusesFeed =
|
val statusesFeed =
|
||||||
handler(
|
handler(
|
||||||
|
queryParam<Int?>("pageNumber"),
|
||||||
queryParam<String?>("lang"),
|
queryParam<String?>("lang"),
|
||||||
documentWith = {
|
documentWith = {
|
||||||
withOperation {
|
withOperation {
|
||||||
@@ -305,12 +292,12 @@ object OpdsV1Controller {
|
|||||||
description("Navigation feed listing series publication statuses for the library.")
|
description("Navigation feed listing series publication statuses for the library.")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
behaviorOf = { ctx, lang ->
|
behaviorOf = { ctx, pageNumber, lang ->
|
||||||
ctx.getAttribute(Attribute.TachideskUser).requireUserWithBasicFallback(ctx)
|
ctx.getAttribute(Attribute.TachideskUser).requireUserWithBasicFallback(ctx)
|
||||||
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
|
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
|
||||||
ctx.future {
|
ctx.future {
|
||||||
future {
|
future {
|
||||||
OpdsFeedBuilder.getStatusFeed(BASE_URL, 1, locale)
|
OpdsFeedBuilder.getStatusFeed(BASE_URL, locale, pageNumber ?: 1)
|
||||||
}.thenApply { xml ->
|
}.thenApply { xml ->
|
||||||
ctx.contentType(OPDS_MIME).result(xml)
|
ctx.contentType(OPDS_MIME).result(xml)
|
||||||
}
|
}
|
||||||
@@ -324,6 +311,7 @@ object OpdsV1Controller {
|
|||||||
*/
|
*/
|
||||||
val languagesFeed =
|
val languagesFeed =
|
||||||
handler(
|
handler(
|
||||||
|
queryParam<Int?>("pageNumber"),
|
||||||
queryParam<String?>("lang"),
|
queryParam<String?>("lang"),
|
||||||
documentWith = {
|
documentWith = {
|
||||||
withOperation {
|
withOperation {
|
||||||
@@ -331,12 +319,12 @@ object OpdsV1Controller {
|
|||||||
description("Navigation feed listing available content languages for series in the library.")
|
description("Navigation feed listing available content languages for series in the library.")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
behaviorOf = { ctx, lang ->
|
behaviorOf = { ctx, pageNumber, lang ->
|
||||||
ctx.getAttribute(Attribute.TachideskUser).requireUserWithBasicFallback(ctx)
|
ctx.getAttribute(Attribute.TachideskUser).requireUserWithBasicFallback(ctx)
|
||||||
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
|
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
|
||||||
ctx.future {
|
ctx.future {
|
||||||
future {
|
future {
|
||||||
OpdsFeedBuilder.getLanguagesFeed(BASE_URL, locale)
|
OpdsFeedBuilder.getLanguagesFeed(BASE_URL, locale, pageNumber ?: 1)
|
||||||
}.thenApply { xml ->
|
}.thenApply { xml ->
|
||||||
ctx.contentType(OPDS_MIME).result(xml)
|
ctx.contentType(OPDS_MIME).result(xml)
|
||||||
}
|
}
|
||||||
@@ -363,7 +351,7 @@ object OpdsV1Controller {
|
|||||||
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
|
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
|
||||||
ctx.future {
|
ctx.future {
|
||||||
future {
|
future {
|
||||||
OpdsFeedBuilder.getLibraryUpdatesFeed(BASE_URL, pageNumber ?: 1, locale)
|
OpdsFeedBuilder.getLibraryUpdatesFeed(BASE_URL, locale, pageNumber ?: 1)
|
||||||
}.thenApply { xml ->
|
}.thenApply { xml ->
|
||||||
ctx.contentType(OPDS_MIME).result(xml)
|
ctx.contentType(OPDS_MIME).result(xml)
|
||||||
}
|
}
|
||||||
@@ -392,7 +380,7 @@ object OpdsV1Controller {
|
|||||||
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
|
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
|
||||||
ctx.future {
|
ctx.future {
|
||||||
future {
|
future {
|
||||||
OpdsFeedBuilder.getExploreSourceFeed(sourceId, BASE_URL, pageNumber ?: 1, sort ?: "popular", locale)
|
OpdsFeedBuilder.getExploreSourceFeed(BASE_URL, locale, sourceId, pageNumber ?: 1, sort ?: "popular")
|
||||||
}.thenApply { xml ->
|
}.thenApply { xml ->
|
||||||
ctx.contentType(OPDS_MIME).result(xml)
|
ctx.contentType(OPDS_MIME).result(xml)
|
||||||
}
|
}
|
||||||
@@ -425,8 +413,9 @@ object OpdsV1Controller {
|
|||||||
documentWith = { withOperation { summary("OPDS Library Source Specific Series Feed") } },
|
documentWith = { withOperation { summary("OPDS Library Source Specific Series Feed") } },
|
||||||
behaviorOf = { ctx, sourceId ->
|
behaviorOf = { ctx, sourceId ->
|
||||||
ctx.getAttribute(Attribute.TachideskUser).requireUserWithBasicFallback(ctx)
|
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))
|
val criteria = buildCriteriaFromContext(ctx, OpdsMangaFilter(sourceId = sourceId, primaryFilter = PrimaryFilterType.SOURCE))
|
||||||
getLibraryFeed(ctx, ctx.queryParam("pageNumber")?.toIntOrNull(), criteria, isSearch = false)
|
getLibraryFeed(ctx, locale, criteria, false, ctx.queryParam("pageNumber")?.toIntOrNull() ?: 1)
|
||||||
},
|
},
|
||||||
withResults = {
|
withResults = {
|
||||||
httpCode(HttpStatus.OK)
|
httpCode(HttpStatus.OK)
|
||||||
@@ -443,9 +432,10 @@ object OpdsV1Controller {
|
|||||||
documentWith = { withOperation { summary("OPDS Category Specific Series Feed") } },
|
documentWith = { withOperation { summary("OPDS Category Specific Series Feed") } },
|
||||||
behaviorOf = { ctx, categoryId ->
|
behaviorOf = { ctx, categoryId ->
|
||||||
ctx.getAttribute(Attribute.TachideskUser).requireUserWithBasicFallback(ctx)
|
ctx.getAttribute(Attribute.TachideskUser).requireUserWithBasicFallback(ctx)
|
||||||
|
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, ctx.queryParam("lang"))
|
||||||
val criteria =
|
val criteria =
|
||||||
buildCriteriaFromContext(ctx, OpdsMangaFilter(categoryId = categoryId, primaryFilter = PrimaryFilterType.CATEGORY))
|
buildCriteriaFromContext(ctx, OpdsMangaFilter(categoryId = categoryId, primaryFilter = PrimaryFilterType.CATEGORY))
|
||||||
getLibraryFeed(ctx, ctx.queryParam("pageNumber")?.toIntOrNull(), criteria, isSearch = false)
|
getLibraryFeed(ctx, locale, criteria, false, ctx.queryParam("pageNumber")?.toIntOrNull() ?: 1)
|
||||||
},
|
},
|
||||||
withResults = {
|
withResults = {
|
||||||
httpCode(HttpStatus.OK)
|
httpCode(HttpStatus.OK)
|
||||||
@@ -462,8 +452,9 @@ object OpdsV1Controller {
|
|||||||
documentWith = { withOperation { summary("OPDS Genre Specific Series Feed") } },
|
documentWith = { withOperation { summary("OPDS Genre Specific Series Feed") } },
|
||||||
behaviorOf = { ctx, genre ->
|
behaviorOf = { ctx, genre ->
|
||||||
ctx.getAttribute(Attribute.TachideskUser).requireUserWithBasicFallback(ctx)
|
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))
|
val criteria = buildCriteriaFromContext(ctx, OpdsMangaFilter(genre = genre, primaryFilter = PrimaryFilterType.GENRE))
|
||||||
getLibraryFeed(ctx, ctx.queryParam("pageNumber")?.toIntOrNull(), criteria, isSearch = false)
|
getLibraryFeed(ctx, locale, criteria, false, ctx.queryParam("pageNumber")?.toIntOrNull() ?: 1)
|
||||||
},
|
},
|
||||||
withResults = {
|
withResults = {
|
||||||
httpCode(HttpStatus.OK)
|
httpCode(HttpStatus.OK)
|
||||||
@@ -480,8 +471,9 @@ object OpdsV1Controller {
|
|||||||
documentWith = { withOperation { summary("OPDS Status Specific Series Feed") } },
|
documentWith = { withOperation { summary("OPDS Status Specific Series Feed") } },
|
||||||
behaviorOf = { ctx, statusId ->
|
behaviorOf = { ctx, statusId ->
|
||||||
ctx.getAttribute(Attribute.TachideskUser).requireUserWithBasicFallback(ctx)
|
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))
|
val criteria = buildCriteriaFromContext(ctx, OpdsMangaFilter(statusId = statusId, primaryFilter = PrimaryFilterType.STATUS))
|
||||||
getLibraryFeed(ctx, ctx.queryParam("pageNumber")?.toIntOrNull(), criteria, isSearch = false)
|
getLibraryFeed(ctx, locale, criteria, false, ctx.queryParam("pageNumber")?.toIntOrNull() ?: 1)
|
||||||
},
|
},
|
||||||
withResults = {
|
withResults = {
|
||||||
httpCode(HttpStatus.OK)
|
httpCode(HttpStatus.OK)
|
||||||
@@ -503,9 +495,10 @@ object OpdsV1Controller {
|
|||||||
},
|
},
|
||||||
behaviorOf = { ctx, langCode ->
|
behaviorOf = { ctx, langCode ->
|
||||||
ctx.getAttribute(Attribute.TachideskUser).requireUserWithBasicFallback(ctx)
|
ctx.getAttribute(Attribute.TachideskUser).requireUserWithBasicFallback(ctx)
|
||||||
|
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, ctx.queryParam("lang"))
|
||||||
val criteria =
|
val criteria =
|
||||||
buildCriteriaFromContext(ctx, OpdsMangaFilter(langCode = langCode, primaryFilter = PrimaryFilterType.LANGUAGE))
|
buildCriteriaFromContext(ctx, OpdsMangaFilter(langCode = langCode, primaryFilter = PrimaryFilterType.LANGUAGE))
|
||||||
getLibraryFeed(ctx, ctx.queryParam("pageNumber")?.toIntOrNull(), criteria, isSearch = false)
|
getLibraryFeed(ctx, locale, criteria, false, ctx.queryParam("pageNumber")?.toIntOrNull() ?: 1)
|
||||||
},
|
},
|
||||||
withResults = {
|
withResults = {
|
||||||
httpCode(HttpStatus.OK)
|
httpCode(HttpStatus.OK)
|
||||||
@@ -534,7 +527,7 @@ object OpdsV1Controller {
|
|||||||
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
|
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
|
||||||
ctx.future {
|
ctx.future {
|
||||||
future {
|
future {
|
||||||
OpdsFeedBuilder.getSeriesChaptersFeed(seriesId, BASE_URL, pageNumber ?: 1, sort, filter, locale)
|
OpdsFeedBuilder.getSeriesChaptersFeed(BASE_URL, locale, seriesId, pageNumber ?: 1, sort, filter)
|
||||||
}.thenApply { xml ->
|
}.thenApply { xml ->
|
||||||
ctx.contentType(OPDS_MIME).result(xml)
|
ctx.contentType(OPDS_MIME).result(xml)
|
||||||
}
|
}
|
||||||
@@ -565,7 +558,7 @@ object OpdsV1Controller {
|
|||||||
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
|
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
|
||||||
ctx.future {
|
ctx.future {
|
||||||
future {
|
future {
|
||||||
OpdsFeedBuilder.getChapterMetadataFeed(seriesId, chapterIndex, BASE_URL, locale)
|
OpdsFeedBuilder.getChapterMetadataFeed(BASE_URL, locale, seriesId, chapterIndex)
|
||||||
}.thenApply { xml ->
|
}.thenApply { xml ->
|
||||||
ctx.contentType(OPDS_MIME).result(xml)
|
ctx.contentType(OPDS_MIME).result(xml)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,4 +12,6 @@ data class OpdsChapterListAcqEntry(
|
|||||||
val lastReadAt: Long,
|
val lastReadAt: Long,
|
||||||
val sourceOrder: Int,
|
val sourceOrder: Int,
|
||||||
val pageCount: Int, // Can be -1 if not known
|
val pageCount: Int, // Can be -1 if not known
|
||||||
|
val downloaded: Boolean,
|
||||||
|
val cbzFileSize: Long? = null,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ data class OpdsChapterMetadataAcqEntry(
|
|||||||
val mangaId: Int,
|
val mangaId: Int,
|
||||||
val name: String,
|
val name: String,
|
||||||
val uploadDate: Long,
|
val uploadDate: Long,
|
||||||
|
val chapterNumber: Float, // Added to fallback chapter titles
|
||||||
val scanlator: String?,
|
val scanlator: String?,
|
||||||
val read: Boolean,
|
val read: Boolean,
|
||||||
val lastPageRead: Int,
|
val lastPageRead: Int,
|
||||||
@@ -13,4 +14,5 @@ data class OpdsChapterMetadataAcqEntry(
|
|||||||
val downloaded: Boolean,
|
val downloaded: Boolean,
|
||||||
val pageCount: Int,
|
val pageCount: Int,
|
||||||
val url: String?,
|
val url: String?,
|
||||||
|
val cbzFileSize: Long? = null,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ data class OpdsHistoryAcqEntry(
|
|||||||
val mangaTitle: String,
|
val mangaTitle: String,
|
||||||
val mangaAuthor: String?,
|
val mangaAuthor: String?,
|
||||||
val mangaId: Int,
|
val mangaId: Int,
|
||||||
|
val mangaTotalChapters: Long,
|
||||||
val mangaSourceLang: String?,
|
val mangaSourceLang: String?,
|
||||||
val mangaThumbnailUrl: String?,
|
val mangaThumbnailUrl: String?,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ data class OpdsLibraryUpdateAcqEntry(
|
|||||||
val mangaTitle: String,
|
val mangaTitle: String,
|
||||||
val mangaAuthor: String?,
|
val mangaAuthor: String?,
|
||||||
val mangaId: Int,
|
val mangaId: Int,
|
||||||
|
val mangaTotalChapters: Long,
|
||||||
val mangaSourceLang: String?,
|
val mangaSourceLang: String?,
|
||||||
val mangaThumbnailUrl: String?,
|
val mangaThumbnailUrl: String?,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,4 +5,5 @@ data class OpdsMangaDetails( // Kept name, it's specific enough
|
|||||||
val title: String,
|
val title: String,
|
||||||
val thumbnailUrl: String?,
|
val thumbnailUrl: String?,
|
||||||
val author: String?, // Added for chapter entry authors
|
val author: String?, // Added for chapter entry authors
|
||||||
|
val totalChapters: Long, // Added to determine if it's a oneshot
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package suwayomi.tachidesk.opds.dto
|
package suwayomi.tachidesk.opds.dto
|
||||||
|
|
||||||
|
import io.javalin.http.Context
|
||||||
import suwayomi.tachidesk.opds.util.OpdsStringUtil.encodeForOpdsURL
|
import suwayomi.tachidesk.opds.util.OpdsStringUtil.encodeForOpdsURL
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -69,4 +70,24 @@ data class OpdsMangaFilter(
|
|||||||
"genre" -> this.copy(genre = value)
|
"genre" -> this.copy(genre = value)
|
||||||
else -> this
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,145 +16,131 @@ import kotlin.math.ceil
|
|||||||
*/
|
*/
|
||||||
class FeedBuilderInternal(
|
class FeedBuilderInternal(
|
||||||
private val baseUrl: String,
|
private val baseUrl: String,
|
||||||
|
private val locale: Locale,
|
||||||
private val idPath: String,
|
private val idPath: String,
|
||||||
private val title: String,
|
private val title: String,
|
||||||
private val locale: Locale,
|
|
||||||
private val feedType: String,
|
private val feedType: String,
|
||||||
private val pageNum: Int? = 1,
|
private val pageNum: Int? = null,
|
||||||
private val explicitQueryParams: String? = null,
|
private val explicitQueryParams: String? = null,
|
||||||
private val currentSort: String? = null,
|
private val currentSort: String? = null,
|
||||||
private val currentFilter: String? = null,
|
private val currentFilter: String? = null,
|
||||||
private val isSearchFeed: Boolean = false,
|
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 totalResults: Long = 0
|
||||||
var icon: String? = null
|
var icon: String? = null
|
||||||
val links = mutableListOf<OpdsLinkXml>()
|
val links = mutableListOf<OpdsLinkXml>()
|
||||||
val entries = mutableListOf<OpdsEntryXml>()
|
val entries = mutableListOf<OpdsEntryXml>()
|
||||||
|
|
||||||
private fun buildUrlWithParams(
|
private fun buildUrlWithParams(page: Int? = pageNum): String {
|
||||||
baseHrefPath: String,
|
val queryParams =
|
||||||
page: Int?,
|
listOfNotNull(
|
||||||
): String {
|
explicitQueryParams?.takeIf(String::isNotBlank),
|
||||||
val sb = StringBuilder("$baseUrl/$baseHrefPath")
|
page?.let { "pageNumber=$it" },
|
||||||
val queryParamsList = mutableListOf<String>()
|
currentSort?.let { "sort=$it" },
|
||||||
|
currentFilter?.let { "filter=$it" },
|
||||||
|
"lang=${locale.toLanguageTag()}",
|
||||||
|
).joinToString("&")
|
||||||
|
|
||||||
explicitQueryParams?.takeIf { it.isNotBlank() }?.let { queryParamsList.add(it) }
|
return "$baseUrl/$idPath" + if (queryParams.isNotEmpty()) "?$queryParams" else ""
|
||||||
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 {
|
fun build(): OpdsFeedXml {
|
||||||
val selfLinkHref = buildUrlWithParams(idPath, if (pageNum != null) pageNum else null)
|
val itemsPerPage = serverConfig.opdsItemsPerPage.value
|
||||||
val feedLinks = mutableListOf<OpdsLinkXml>()
|
val showOpenSearch = isSearchFeed && pageNum != null && totalResults > 0
|
||||||
feedLinks.addAll(this.links)
|
val urnSuffix =
|
||||||
|
listOfNotNull(
|
||||||
feedLinks.add(
|
locale.toLanguageTag(),
|
||||||
OpdsLinkXml(OpdsConstants.LINK_REL_SELF, selfLinkHref, feedType, MR.strings.opds_linktitle_self_feed.localized(locale)),
|
pageNum?.let { "page$it" },
|
||||||
)
|
explicitQueryParams?.replace("&", ":")?.replace("=", "_"),
|
||||||
feedLinks.add(
|
currentSort?.let { "sort_$it" },
|
||||||
OpdsLinkXml(
|
currentFilter?.let { "filter_$it" },
|
||||||
OpdsConstants.LINK_REL_START,
|
).joinToString(":")
|
||||||
"$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(
|
return OpdsFeedXml(
|
||||||
id = "urn:suwayomi:feed:${idPath.replace('/',':')}$urnSuffix",
|
id = "urn:suwayomi:feed:${idPath.replace('/', ':')}${if (urnSuffix.isNotEmpty()) ":$urnSuffix" else ""}",
|
||||||
title = title,
|
title = title,
|
||||||
updated = feedGeneratedAt,
|
updated = OpdsDateUtil.formatCurrentInstantForOpds(),
|
||||||
icon = icon,
|
icon = icon,
|
||||||
author = feedAuthor,
|
author = OpdsAuthorXml("Suwayomi", "https://suwayomi.org/"),
|
||||||
links = feedLinks,
|
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),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
entries = entries,
|
entries = entries,
|
||||||
totalResults = totalResults.takeIf { showOpenSearchFields },
|
totalResults = totalResults.takeIf { showOpenSearch },
|
||||||
itemsPerPage = if (showOpenSearchFields) opdsItemsPerPageBounded else null,
|
itemsPerPage = itemsPerPage.takeIf { showOpenSearch },
|
||||||
startIndex = if (showOpenSearchFields) ((pageNum - 1) * opdsItemsPerPageBounded) + 1 else null,
|
startIndex = if (showOpenSearch && pageNum != null) ((pageNum - 1) * itemsPerPage) + 1 else null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
package suwayomi.tachidesk.opds.impl
|
package suwayomi.tachidesk.opds.impl
|
||||||
|
|
||||||
import dev.icerock.moko.resources.StringResource
|
import dev.icerock.moko.resources.StringResource
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import suwayomi.tachidesk.i18n.MR
|
import suwayomi.tachidesk.i18n.MR
|
||||||
import suwayomi.tachidesk.manga.impl.ChapterDownloadHelper
|
|
||||||
import suwayomi.tachidesk.manga.impl.MangaList.proxyThumbnailUrl
|
import suwayomi.tachidesk.manga.impl.MangaList.proxyThumbnailUrl
|
||||||
import suwayomi.tachidesk.manga.impl.sync.KoreaderSyncService
|
import suwayomi.tachidesk.manga.impl.sync.KoreaderSyncService
|
||||||
import suwayomi.tachidesk.manga.model.table.MangaStatus
|
import suwayomi.tachidesk.manga.model.table.MangaStatus
|
||||||
@@ -93,15 +90,15 @@ object OpdsEntryBuilder {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts a manga data object into a full OPDS acquisition entry.
|
* 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 baseUrl The base URL for constructing links.
|
||||||
* @param locale The locale for localization.
|
* @param locale The locale for localization.
|
||||||
|
* @param entry The manga data object.
|
||||||
* @return An [OpdsEntryXml] object representing the manga.
|
* @return An [OpdsEntryXml] object representing the manga.
|
||||||
*/
|
*/
|
||||||
fun mangaAcqEntryToEntry(
|
fun mangaAcqEntryToEntry(
|
||||||
entry: OpdsMangaAcqEntry,
|
|
||||||
baseUrl: String,
|
baseUrl: String,
|
||||||
locale: Locale,
|
locale: Locale,
|
||||||
|
entry: OpdsMangaAcqEntry,
|
||||||
): OpdsEntryXml {
|
): OpdsEntryXml {
|
||||||
val displayThumbnailUrl = entry.thumbnailUrl?.let { proxyThumbnailUrl(entry.id) }
|
val displayThumbnailUrl = entry.thumbnailUrl?.let { proxyThumbnailUrl(entry.id) }
|
||||||
val categoryScheme = if (entry.inLibrary) "$baseUrl/library/genres" else "$baseUrl/genres"
|
val categoryScheme = if (entry.inLibrary) "$baseUrl/library/genres" else "$baseUrl/genres"
|
||||||
@@ -152,40 +149,151 @@ 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.
|
* 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 chapter The chapter data object.
|
||||||
* @param manga The parent manga's details.
|
* @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 addMangaTitle Whether to prepend the manga title to the entry title.
|
||||||
* @param locale The locale for localization.
|
* @param skipMetadataFeed Whether to skip the metadata feed.
|
||||||
* @return An [OpdsEntryXml] object for the chapter.
|
* @return An [OpdsEntryXml] object for the chapter.
|
||||||
*/
|
*/
|
||||||
suspend fun createChapterListEntry(
|
suspend fun createChapterListEntry(
|
||||||
|
baseUrl: String,
|
||||||
|
locale: Locale,
|
||||||
chapter: OpdsChapterListAcqEntry,
|
chapter: OpdsChapterListAcqEntry,
|
||||||
manga: OpdsMangaDetails,
|
manga: OpdsMangaDetails,
|
||||||
baseUrl: String,
|
|
||||||
addMangaTitle: Boolean,
|
addMangaTitle: Boolean,
|
||||||
locale: Locale,
|
skipMetadataFeed: Boolean,
|
||||||
): OpdsEntryXml {
|
): 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 =
|
val statusKey =
|
||||||
when {
|
when {
|
||||||
|
chapter.downloaded -> MR.strings.opds_chapter_status_downloaded
|
||||||
chapter.read -> MR.strings.opds_chapter_status_read
|
chapter.read -> MR.strings.opds_chapter_status_read
|
||||||
chapter.lastPageRead > 0 -> MR.strings.opds_chapter_status_in_progress
|
effectiveLastPageRead > 0 -> MR.strings.opds_chapter_status_in_progress
|
||||||
else -> MR.strings.opds_chapter_status_unread
|
else -> MR.strings.opds_chapter_status_unread
|
||||||
}
|
}
|
||||||
val titlePrefix = statusKey.localized(locale)
|
val titlePrefix = statusKey.localized(locale)
|
||||||
val entryTitle = titlePrefix + (if (addMangaTitle) " ${manga.title}:" else "") + " ${chapter.name}"
|
val chapterName = resolveChapterTitle(chapter.name, chapter.chapterNumber, chapter.sourceOrder, manga.totalChapters, locale)
|
||||||
|
val entryTitle = titlePrefix + (if (addMangaTitle) " ${manga.title}:" else "") + " $chapterName"
|
||||||
val details =
|
val details =
|
||||||
buildString {
|
buildString {
|
||||||
append(MR.strings.opds_chapter_details_base.localized(locale, manga.title, chapter.name))
|
append(MR.strings.opds_chapter_details_base.localized(locale, manga.title, chapterName))
|
||||||
chapter.scanlator?.takeIf { it.isNotBlank() }?.let {
|
chapter.scanlator?.takeIf { it.isNotBlank() }?.let {
|
||||||
append(MR.strings.opds_chapter_details_scanlator.localized(locale, it))
|
append(MR.strings.opds_chapter_details_scanlator.localized(locale, it))
|
||||||
}
|
}
|
||||||
if (chapter.pageCount > 0) {
|
if (chapter.pageCount > 0) {
|
||||||
append(MR.strings.opds_chapter_details_progress.localized(locale, chapter.lastPageRead, chapter.pageCount))
|
append(MR.strings.opds_chapter_details_progress.localized(locale, effectiveLastPageRead, 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(
|
return OpdsEntryXml(
|
||||||
id = "urn:suwayomi:chapter:${chapter.id}",
|
id = "urn:suwayomi:chapter:${chapter.id}",
|
||||||
title = entryTitle,
|
title = entryTitle,
|
||||||
@@ -196,33 +304,25 @@ object OpdsEntryBuilder {
|
|||||||
chapter.scanlator?.takeIf { it.isNotBlank() }?.let { OpdsAuthorXml(name = it) },
|
chapter.scanlator?.takeIf { it.isNotBlank() }?.let { OpdsAuthorXml(name = it) },
|
||||||
),
|
),
|
||||||
summary = OpdsSummaryXml(value = details),
|
summary = OpdsSummaryXml(value = details),
|
||||||
link =
|
link = links,
|
||||||
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.
|
* Creates one or two OPDS entries for a chapter, handling synchronization conflicts internally.
|
||||||
*
|
*
|
||||||
* @param chapter The chapter metadata object.
|
|
||||||
* @param manga The parent manga's details.
|
|
||||||
* @param baseUrl The base URL for constructing links.
|
* @param baseUrl The base URL for constructing links.
|
||||||
* @param locale The locale for localization.
|
* @param locale The locale for localization.
|
||||||
|
* @param chapter The chapter metadata object.
|
||||||
|
* @param manga The parent manga's details.
|
||||||
* @return A `Pair` where the first element is the primary entry (always present) and the
|
* @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.
|
* second is an optional entry representing the remote progress in case of a conflict.
|
||||||
*/
|
*/
|
||||||
suspend fun createChapterMetadataEntries(
|
suspend fun createChapterMetadataEntries(
|
||||||
chapter: OpdsChapterMetadataAcqEntry,
|
|
||||||
manga: OpdsMangaDetails,
|
|
||||||
baseUrl: String,
|
baseUrl: String,
|
||||||
locale: Locale,
|
locale: Locale,
|
||||||
|
chapter: OpdsChapterMetadataAcqEntry,
|
||||||
|
manga: OpdsMangaDetails,
|
||||||
): Pair<OpdsEntryXml, OpdsEntryXml?> {
|
): Pair<OpdsEntryXml, OpdsEntryXml?> {
|
||||||
// Check remote progress before building the entry
|
// Check remote progress before building the entry
|
||||||
val syncResult = KoreaderSyncService.checkAndPullProgress(chapter.id)
|
val syncResult = KoreaderSyncService.checkAndPullProgress(chapter.id)
|
||||||
@@ -234,20 +334,20 @@ object OpdsEntryBuilder {
|
|||||||
// Generate two entries: one for local progress and another for remote.
|
// Generate two entries: one for local progress and another for remote.
|
||||||
val localEntry =
|
val localEntry =
|
||||||
buildSingleChapterMetadataEntry(
|
buildSingleChapterMetadataEntry(
|
||||||
chapter,
|
|
||||||
manga,
|
|
||||||
baseUrl,
|
baseUrl,
|
||||||
locale,
|
locale,
|
||||||
|
chapter,
|
||||||
|
manga,
|
||||||
progressSource = ProgressSource.Local(chapter.lastPageRead, chapter.lastReadAt),
|
progressSource = ProgressSource.Local(chapter.lastPageRead, chapter.lastReadAt),
|
||||||
isConflict = true,
|
isConflict = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
val remoteEntry =
|
val remoteEntry =
|
||||||
buildSingleChapterMetadataEntry(
|
buildSingleChapterMetadataEntry(
|
||||||
chapter,
|
|
||||||
manga,
|
|
||||||
baseUrl,
|
baseUrl,
|
||||||
locale,
|
locale,
|
||||||
|
chapter,
|
||||||
|
manga,
|
||||||
progressSource = ProgressSource.Remote(syncResult!!.pageRead, syncResult.timestamp, syncResult.device),
|
progressSource = ProgressSource.Remote(syncResult!!.pageRead, syncResult.timestamp, syncResult.device),
|
||||||
isConflict = true,
|
isConflict = true,
|
||||||
)
|
)
|
||||||
@@ -263,10 +363,10 @@ object OpdsEntryBuilder {
|
|||||||
|
|
||||||
val mainEntry =
|
val mainEntry =
|
||||||
buildSingleChapterMetadataEntry(
|
buildSingleChapterMetadataEntry(
|
||||||
chapter,
|
|
||||||
manga,
|
|
||||||
baseUrl,
|
baseUrl,
|
||||||
locale,
|
locale,
|
||||||
|
chapter,
|
||||||
|
manga,
|
||||||
progressSource = progressSource,
|
progressSource = progressSource,
|
||||||
isConflict = false,
|
isConflict = false,
|
||||||
)
|
)
|
||||||
@@ -297,10 +397,10 @@ object OpdsEntryBuilder {
|
|||||||
* Helper function to build a single OpdsEntryXml for a chapter.
|
* Helper function to build a single OpdsEntryXml for a chapter.
|
||||||
*/
|
*/
|
||||||
private suspend fun buildSingleChapterMetadataEntry(
|
private suspend fun buildSingleChapterMetadataEntry(
|
||||||
chapter: OpdsChapterMetadataAcqEntry,
|
|
||||||
manga: OpdsMangaDetails,
|
|
||||||
baseUrl: String,
|
baseUrl: String,
|
||||||
locale: Locale,
|
locale: Locale,
|
||||||
|
chapter: OpdsChapterMetadataAcqEntry,
|
||||||
|
manga: OpdsMangaDetails,
|
||||||
progressSource: ProgressSource,
|
progressSource: ProgressSource,
|
||||||
isConflict: Boolean,
|
isConflict: Boolean,
|
||||||
): OpdsEntryXml {
|
): OpdsEntryXml {
|
||||||
@@ -337,14 +437,7 @@ object OpdsEntryBuilder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val entryTitle = "$titlePrefix ${chapter.name}"
|
val entryTitle = "$titlePrefix ${chapter.name}"
|
||||||
val cbzFileSize =
|
val cbzFileSize = chapter.cbzFileSize
|
||||||
if (chapter.downloaded) {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
runCatching { ChapterDownloadHelper.getArchiveStreamWithSize(manga.id, chapter.id).second }.getOrNull()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
val links = mutableListOf<OpdsLinkXml>()
|
val links = mutableListOf<OpdsLinkXml>()
|
||||||
chapter.url?.let {
|
chapter.url?.let {
|
||||||
@@ -449,8 +542,8 @@ object OpdsEntryBuilder {
|
|||||||
fun addSourceSortFacets(
|
fun addSourceSortFacets(
|
||||||
feedBuilder: FeedBuilderInternal,
|
feedBuilder: FeedBuilderInternal,
|
||||||
baseUrl: String,
|
baseUrl: String,
|
||||||
currentSort: String,
|
|
||||||
locale: Locale,
|
locale: Locale,
|
||||||
|
currentSort: String,
|
||||||
) {
|
) {
|
||||||
val sortGroup = MR.strings.opds_facetgroup_sort_order.localized(locale)
|
val sortGroup = MR.strings.opds_facetgroup_sort_order.localized(locale)
|
||||||
val addFacet = { href: String, titleKey: StringResource, isActive: Boolean ->
|
val addFacet = { href: String, titleKey: StringResource, isActive: Boolean ->
|
||||||
@@ -475,9 +568,9 @@ object OpdsEntryBuilder {
|
|||||||
fun addChapterSortAndFilterFacets(
|
fun addChapterSortAndFilterFacets(
|
||||||
feedBuilder: FeedBuilderInternal,
|
feedBuilder: FeedBuilderInternal,
|
||||||
baseUrl: String,
|
baseUrl: String,
|
||||||
|
locale: Locale,
|
||||||
currentSort: String,
|
currentSort: String,
|
||||||
currentFilter: String,
|
currentFilter: String,
|
||||||
locale: Locale,
|
|
||||||
filterCounts: Map<String, Long>? = null,
|
filterCounts: Map<String, Long>? = null,
|
||||||
) {
|
) {
|
||||||
val sortGroup = MR.strings.opds_facetgroup_sort_order.localized(locale)
|
val sortGroup = MR.strings.opds_facetgroup_sort_order.localized(locale)
|
||||||
@@ -554,15 +647,15 @@ object OpdsEntryBuilder {
|
|||||||
fun addLibraryFacets(
|
fun addLibraryFacets(
|
||||||
feedBuilder: FeedBuilderInternal,
|
feedBuilder: FeedBuilderInternal,
|
||||||
baseUrl: String,
|
baseUrl: String,
|
||||||
activeFilters: OpdsMangaFilter,
|
|
||||||
locale: Locale,
|
locale: Locale,
|
||||||
|
activeFilters: OpdsMangaFilter,
|
||||||
) {
|
) {
|
||||||
val currentSort = activeFilters.sort ?: "alpha_asc"
|
val currentSort = activeFilters.sort ?: "alpha_asc"
|
||||||
val currentFilter = activeFilters.filter ?: "all"
|
val currentFilter = activeFilters.filter ?: "all"
|
||||||
|
|
||||||
val sortGroup = MR.strings.opds_facetgroup_sort_order.localized(locale)
|
val sortGroup = MR.strings.opds_facetgroup_sort_order.localized(locale)
|
||||||
val filterGroup = MR.strings.opds_facetgroup_filter_content.localized(locale)
|
val filterGroup = MR.strings.opds_facetgroup_filter_content.localized(locale)
|
||||||
val filterCounts = MangaRepository.getLibraryFilterCounts()
|
val filterCounts = MangaRepository.getLibraryFilterCounts(activeFilters)
|
||||||
|
|
||||||
val buildUrl = { newFilters: OpdsMangaFilter, newSort: String, newFilter: String ->
|
val buildUrl = { newFilters: OpdsMangaFilter, newSort: String, newFilter: String ->
|
||||||
val crossFilterParams = newFilters.toCrossFilterQueryParameters()
|
val crossFilterParams = newFilters.toCrossFilterQueryParameters()
|
||||||
@@ -667,7 +760,7 @@ object OpdsEntryBuilder {
|
|||||||
|
|
||||||
// --- Cross-Filter Facets ---
|
// --- Cross-Filter Facets ---
|
||||||
if (activeFilters.primaryFilter != PrimaryFilterType.SOURCE) {
|
if (activeFilters.primaryFilter != PrimaryFilterType.SOURCE) {
|
||||||
val sources = NavigationRepository.getLibrarySources(1).first
|
val sources = NavigationRepository.getLibrarySources(pageNum = null, activeFilters = activeFilters).first
|
||||||
addFacet(
|
addFacet(
|
||||||
feedBuilder,
|
feedBuilder,
|
||||||
buildUrl(activeFilters.without("source_id"), currentSort, currentFilter),
|
buildUrl(activeFilters.without("source_id"), currentSort, currentFilter),
|
||||||
@@ -688,7 +781,7 @@ object OpdsEntryBuilder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (activeFilters.primaryFilter != PrimaryFilterType.CATEGORY) {
|
if (activeFilters.primaryFilter != PrimaryFilterType.CATEGORY) {
|
||||||
val categories = NavigationRepository.getCategories(1).first
|
val categories = NavigationRepository.getCategories(pageNum = null, activeFilters = activeFilters).first
|
||||||
addFacet(
|
addFacet(
|
||||||
feedBuilder,
|
feedBuilder,
|
||||||
buildUrl(activeFilters.without("category_id"), currentSort, currentFilter),
|
buildUrl(activeFilters.without("category_id"), currentSort, currentFilter),
|
||||||
@@ -709,7 +802,7 @@ object OpdsEntryBuilder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (activeFilters.primaryFilter != PrimaryFilterType.STATUS) {
|
if (activeFilters.primaryFilter != PrimaryFilterType.STATUS) {
|
||||||
val statuses = NavigationRepository.getStatuses(locale)
|
val statuses = NavigationRepository.getStatuses(locale, pageNum = null, activeFilters = activeFilters).first
|
||||||
addFacet(
|
addFacet(
|
||||||
feedBuilder,
|
feedBuilder,
|
||||||
buildUrl(activeFilters.without("status_id"), currentSort, currentFilter),
|
buildUrl(activeFilters.without("status_id"), currentSort, currentFilter),
|
||||||
@@ -730,7 +823,7 @@ object OpdsEntryBuilder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (activeFilters.primaryFilter != PrimaryFilterType.LANGUAGE) {
|
if (activeFilters.primaryFilter != PrimaryFilterType.LANGUAGE) {
|
||||||
val languages = NavigationRepository.getContentLanguages(locale)
|
val languages = NavigationRepository.getContentLanguages(locale, pageNum = null, activeFilters = activeFilters).first
|
||||||
addFacet(
|
addFacet(
|
||||||
feedBuilder,
|
feedBuilder,
|
||||||
buildUrl(activeFilters.without("lang_code"), currentSort, currentFilter),
|
buildUrl(activeFilters.without("lang_code"), currentSort, currentFilter),
|
||||||
@@ -751,7 +844,7 @@ object OpdsEntryBuilder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (activeFilters.primaryFilter != PrimaryFilterType.GENRE) {
|
if (activeFilters.primaryFilter != PrimaryFilterType.GENRE) {
|
||||||
val genres = NavigationRepository.getGenres(1, locale).first
|
val genres = NavigationRepository.getGenres(locale, pageNum = null, activeFilters = activeFilters).first
|
||||||
addFacet(
|
addFacet(
|
||||||
feedBuilder,
|
feedBuilder,
|
||||||
buildUrl(activeFilters.without("genre"), currentSort, currentFilter),
|
buildUrl(activeFilters.without("genre"), currentSort, currentFilter),
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import suwayomi.tachidesk.opds.repository.ChapterRepository
|
|||||||
import suwayomi.tachidesk.opds.repository.MangaRepository
|
import suwayomi.tachidesk.opds.repository.MangaRepository
|
||||||
import suwayomi.tachidesk.opds.repository.NavigationRepository
|
import suwayomi.tachidesk.opds.repository.NavigationRepository
|
||||||
import suwayomi.tachidesk.opds.util.OpdsDateUtil
|
import suwayomi.tachidesk.opds.util.OpdsDateUtil
|
||||||
|
import suwayomi.tachidesk.opds.util.OpdsStringUtil
|
||||||
import suwayomi.tachidesk.opds.util.OpdsXmlUtil
|
import suwayomi.tachidesk.opds.util.OpdsXmlUtil
|
||||||
import suwayomi.tachidesk.server.serverConfig
|
import suwayomi.tachidesk.server.serverConfig
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
@@ -40,12 +41,11 @@ object OpdsFeedBuilder {
|
|||||||
val navItems = NavigationRepository.getRootNavigationItems(locale)
|
val navItems = NavigationRepository.getRootNavigationItems(locale)
|
||||||
val builder =
|
val builder =
|
||||||
FeedBuilderInternal(
|
FeedBuilderInternal(
|
||||||
baseUrl,
|
baseUrl = baseUrl,
|
||||||
"", // Root path is empty
|
locale = locale,
|
||||||
MR.strings.opds_feeds_root.localized(locale),
|
idPath = "", // Root path is empty
|
||||||
locale,
|
title = MR.strings.opds_feeds_root.localized(locale),
|
||||||
OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION,
|
feedType = OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION,
|
||||||
null,
|
|
||||||
)
|
)
|
||||||
builder.totalResults = navItems.size.toLong()
|
builder.totalResults = navItems.size.toLong()
|
||||||
builder.entries.addAll(
|
builder.entries.addAll(
|
||||||
@@ -73,30 +73,39 @@ object OpdsFeedBuilder {
|
|||||||
/**
|
/**
|
||||||
* Generates the history feed showing recently read chapters.
|
* Generates the history feed showing recently read chapters.
|
||||||
* @param baseUrl The base URL for constructing links.
|
* @param baseUrl The base URL for constructing links.
|
||||||
* @param pageNum The page number for pagination.
|
|
||||||
* @param locale The locale for localization.
|
* @param locale The locale for localization.
|
||||||
|
* @param pageNum The page number for pagination.
|
||||||
* @return An XML string representing the history feed.
|
* @return An XML string representing the history feed.
|
||||||
*/
|
*/
|
||||||
suspend fun getHistoryFeed(
|
suspend fun getHistoryFeed(
|
||||||
baseUrl: String,
|
baseUrl: String,
|
||||||
pageNum: Int,
|
|
||||||
locale: Locale,
|
locale: Locale,
|
||||||
|
pageNum: Int,
|
||||||
): String {
|
): String {
|
||||||
val (historyItems, total) = ChapterRepository.getHistory(pageNum)
|
val (historyItems, total) = ChapterRepository.getHistory(pageNum)
|
||||||
val builder =
|
val builder =
|
||||||
FeedBuilderInternal(
|
FeedBuilderInternal(
|
||||||
baseUrl,
|
baseUrl = baseUrl,
|
||||||
"history",
|
locale = locale,
|
||||||
MR.strings.opds_feeds_history_title.localized(locale),
|
idPath = "history",
|
||||||
locale,
|
title = MR.strings.opds_feeds_history_title.localized(locale),
|
||||||
OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
|
feedType = OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
|
||||||
pageNum,
|
pageNum = pageNum,
|
||||||
)
|
)
|
||||||
builder.totalResults = total
|
builder.totalResults = total
|
||||||
|
val skipMetadata = serverConfig.opdsSkipChapterMetadataFeed.value
|
||||||
builder.entries.addAll(
|
builder.entries.addAll(
|
||||||
historyItems.map { item ->
|
historyItems.map { item ->
|
||||||
val mangaDetails = OpdsMangaDetails(item.mangaId, item.mangaTitle, item.mangaThumbnailUrl, item.mangaAuthor)
|
val mangaDetails =
|
||||||
OpdsEntryBuilder.createChapterListEntry(item.chapter, mangaDetails, baseUrl, true, locale)
|
OpdsMangaDetails(item.mangaId, item.mangaTitle, item.mangaThumbnailUrl, item.mangaAuthor, item.mangaTotalChapters)
|
||||||
|
OpdsEntryBuilder.createChapterListEntry(
|
||||||
|
baseUrl,
|
||||||
|
locale,
|
||||||
|
item.chapter,
|
||||||
|
mangaDetails,
|
||||||
|
true,
|
||||||
|
skipMetadata,
|
||||||
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return OpdsXmlUtil.serializeFeedToString(builder.build())
|
return OpdsXmlUtil.serializeFeedToString(builder.build())
|
||||||
@@ -104,54 +113,55 @@ object OpdsFeedBuilder {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates a feed for search results based on the provided criteria.
|
* Generates a feed for search results based on the provided criteria.
|
||||||
* @param criteria The search criteria.
|
|
||||||
* @param baseUrl The base URL for constructing links.
|
* @param baseUrl The base URL for constructing links.
|
||||||
* @param pageNum The page number for pagination.
|
|
||||||
* @param locale The locale for localization.
|
* @param locale The locale for localization.
|
||||||
|
* @param criteria The search criteria.
|
||||||
|
* @param pageNum The page number for pagination.
|
||||||
* @return An XML string representing the search results feed.
|
* @return An XML string representing the search results feed.
|
||||||
*/
|
*/
|
||||||
fun getSearchFeed(
|
fun getSearchFeed(
|
||||||
criteria: OpdsSearchCriteria,
|
|
||||||
baseUrl: String,
|
baseUrl: String,
|
||||||
pageNum: Int,
|
|
||||||
locale: Locale,
|
locale: Locale,
|
||||||
|
criteria: OpdsSearchCriteria,
|
||||||
|
pageNum: Int,
|
||||||
): String {
|
): String {
|
||||||
val (mangaEntries, total) = MangaRepository.findMangaByCriteria(criteria)
|
val (mangaEntries, total) = MangaRepository.findMangaByCriteria(criteria)
|
||||||
val builder =
|
val builder =
|
||||||
FeedBuilderInternal(
|
FeedBuilderInternal(
|
||||||
baseUrl,
|
baseUrl = baseUrl,
|
||||||
"library/series",
|
locale = locale,
|
||||||
MR.strings.opds_feeds_search_results_title.localized(locale),
|
idPath = "library/series",
|
||||||
locale,
|
title = MR.strings.opds_feeds_search_results_title.localized(locale),
|
||||||
OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
|
feedType = OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
|
||||||
pageNum,
|
pageNum = pageNum,
|
||||||
isSearchFeed = true,
|
isSearchFeed = true,
|
||||||
)
|
)
|
||||||
builder.totalResults = total
|
builder.totalResults = total
|
||||||
builder.entries.addAll(mangaEntries.map { OpdsEntryBuilder.mangaAcqEntryToEntry(it, baseUrl, locale) })
|
builder.entries.addAll(mangaEntries.map { OpdsEntryBuilder.mangaAcqEntryToEntry(baseUrl, locale, it) })
|
||||||
return OpdsXmlUtil.serializeFeedToString(builder.build())
|
return OpdsXmlUtil.serializeFeedToString(builder.build())
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates a generic library feed based on various filtering and sorting criteria.
|
* Generates a generic library feed based on various filtering and sorting criteria.
|
||||||
* @param criteria The filtering criteria.
|
|
||||||
* @param baseUrl The base URL for constructing links.
|
* @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 pageNum The page number for pagination.
|
* @param pageNum The page number for pagination.
|
||||||
* @param sort The sorting parameter.
|
* @param sort The sorting parameter.
|
||||||
* @param filter The filtering parameter.
|
* @param filter The filtering parameter.
|
||||||
* @param locale The locale for localization.
|
|
||||||
* @return An XML string representing the library feed.
|
* @return An XML string representing the library feed.
|
||||||
*/
|
*/
|
||||||
fun getLibraryFeed(
|
fun getLibraryFeed(
|
||||||
criteria: OpdsMangaFilter,
|
|
||||||
baseUrl: String,
|
baseUrl: String,
|
||||||
|
locale: Locale,
|
||||||
|
criteria: OpdsMangaFilter,
|
||||||
|
isSearch: Boolean,
|
||||||
pageNum: Int,
|
pageNum: Int,
|
||||||
sort: String?,
|
sort: String?,
|
||||||
filter: String?,
|
filter: String?,
|
||||||
locale: Locale,
|
|
||||||
isSearch: Boolean,
|
|
||||||
): String {
|
): String {
|
||||||
val result = MangaRepository.getLibraryManga(pageNum, sort, filter, criteria)
|
val result = MangaRepository.getLibraryManga(criteria, pageNum, sort, filter)
|
||||||
|
|
||||||
val feedTitle =
|
val feedTitle =
|
||||||
when (criteria.primaryFilter) {
|
when (criteria.primaryFilter) {
|
||||||
@@ -177,7 +187,12 @@ object OpdsFeedBuilder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
PrimaryFilterType.STATUS -> {
|
PrimaryFilterType.STATUS -> {
|
||||||
val statusName = NavigationRepository.getStatuses(locale).find { it.id == criteria.statusId }?.title
|
val statusName =
|
||||||
|
NavigationRepository
|
||||||
|
.getStatuses(locale, pageNum = null, activeFilters = criteria)
|
||||||
|
.first
|
||||||
|
.find { it.id == criteria.statusId }
|
||||||
|
?.title
|
||||||
MR.strings.opds_feeds_status_specific_title.localized(locale, statusName ?: criteria.statusId.toString())
|
MR.strings.opds_feeds_status_specific_title.localized(locale, statusName ?: criteria.statusId.toString())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,7 +208,7 @@ object OpdsFeedBuilder {
|
|||||||
|
|
||||||
val feedUrl =
|
val feedUrl =
|
||||||
when (criteria.primaryFilter) {
|
when (criteria.primaryFilter) {
|
||||||
PrimaryFilterType.SOURCE -> "library/source/${criteria.sourceId}"
|
PrimaryFilterType.SOURCE -> "source/${criteria.sourceId}"
|
||||||
PrimaryFilterType.CATEGORY -> "category/${criteria.categoryId}"
|
PrimaryFilterType.CATEGORY -> "category/${criteria.categoryId}"
|
||||||
PrimaryFilterType.GENRE -> "genre/${criteria.genre}"
|
PrimaryFilterType.GENRE -> "genre/${criteria.genre}"
|
||||||
PrimaryFilterType.STATUS -> "status/${criteria.statusId}"
|
PrimaryFilterType.STATUS -> "status/${criteria.statusId}"
|
||||||
@@ -203,23 +218,23 @@ object OpdsFeedBuilder {
|
|||||||
|
|
||||||
val builder =
|
val builder =
|
||||||
FeedBuilderInternal(
|
FeedBuilderInternal(
|
||||||
baseUrl,
|
baseUrl = baseUrl,
|
||||||
feedUrl,
|
locale = locale,
|
||||||
feedTitle,
|
idPath = feedUrl,
|
||||||
locale,
|
title = feedTitle,
|
||||||
OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
|
feedType = OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
|
||||||
pageNum,
|
pageNum = pageNum,
|
||||||
|
explicitQueryParams = criteria.toCrossFilterQueryParameters(),
|
||||||
currentSort = criteria.sort,
|
currentSort = criteria.sort,
|
||||||
currentFilter = criteria.filter,
|
currentFilter = criteria.filter,
|
||||||
explicitQueryParams = criteria.toCrossFilterQueryParameters(),
|
|
||||||
isSearchFeed = isSearch,
|
isSearchFeed = isSearch,
|
||||||
)
|
)
|
||||||
builder.totalResults = result.totalCount
|
builder.totalResults = result.totalCount
|
||||||
|
|
||||||
// Add all library facets (sort, filter, and cross-filtering)
|
// Add all library facets (sort, filter, and cross-filtering)
|
||||||
OpdsEntryBuilder.addLibraryFacets(builder, baseUrl, criteria, locale)
|
OpdsEntryBuilder.addLibraryFacets(builder, baseUrl, locale, criteria)
|
||||||
|
|
||||||
builder.entries.addAll(result.mangaEntries.map { OpdsEntryBuilder.mangaAcqEntryToEntry(it, baseUrl, locale) })
|
builder.entries.addAll(result.mangaEntries.map { OpdsEntryBuilder.mangaAcqEntryToEntry(baseUrl, locale, it) })
|
||||||
|
|
||||||
return OpdsXmlUtil.serializeFeedToString(builder.build())
|
return OpdsXmlUtil.serializeFeedToString(builder.build())
|
||||||
}
|
}
|
||||||
@@ -227,24 +242,24 @@ object OpdsFeedBuilder {
|
|||||||
/**
|
/**
|
||||||
* Generates a navigation feed listing all available sources for exploration.
|
* Generates a navigation feed listing all available sources for exploration.
|
||||||
* @param baseUrl The base URL for constructing links.
|
* @param baseUrl The base URL for constructing links.
|
||||||
* @param pageNum The page number for pagination.
|
|
||||||
* @param locale The locale for localization.
|
* @param locale The locale for localization.
|
||||||
|
* @param pageNum The page number for pagination.
|
||||||
* @return An XML string representing the explore sources feed.
|
* @return An XML string representing the explore sources feed.
|
||||||
*/
|
*/
|
||||||
fun getExploreSourcesFeed(
|
fun getExploreSourcesFeed(
|
||||||
baseUrl: String,
|
baseUrl: String,
|
||||||
pageNum: Int,
|
|
||||||
locale: Locale,
|
locale: Locale,
|
||||||
|
pageNum: Int,
|
||||||
): String {
|
): String {
|
||||||
val (sourceNavEntries, total) = NavigationRepository.getExploreSources(pageNum)
|
val (sourceNavEntries, total) = NavigationRepository.getExploreSources(pageNum)
|
||||||
val builder =
|
val builder =
|
||||||
FeedBuilderInternal(
|
FeedBuilderInternal(
|
||||||
baseUrl,
|
baseUrl = baseUrl,
|
||||||
"sources",
|
locale = locale,
|
||||||
MR.strings.opds_feeds_sources_title.localized(locale),
|
idPath = "sources",
|
||||||
locale,
|
title = MR.strings.opds_feeds_sources_title.localized(locale),
|
||||||
OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION,
|
feedType = OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION,
|
||||||
pageNum,
|
pageNum = pageNum,
|
||||||
)
|
)
|
||||||
builder.totalResults = total
|
builder.totalResults = total
|
||||||
builder.entries.addAll(
|
builder.entries.addAll(
|
||||||
@@ -257,7 +272,7 @@ object OpdsFeedBuilder {
|
|||||||
listOf(
|
listOf(
|
||||||
OpdsLinkXml(
|
OpdsLinkXml(
|
||||||
OpdsConstants.LINK_REL_SUBSECTION,
|
OpdsConstants.LINK_REL_SUBSECTION,
|
||||||
"$baseUrl/source/${entry.id}?sort=popular&lang=${locale.toLanguageTag()}",
|
"$baseUrl/explore/source/${entry.id}?sort=popular&lang=${locale.toLanguageTag()}",
|
||||||
OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
|
OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -270,24 +285,24 @@ object OpdsFeedBuilder {
|
|||||||
/**
|
/**
|
||||||
* Generates a navigation feed listing sources for series present in the library.
|
* Generates a navigation feed listing sources for series present in the library.
|
||||||
* @param baseUrl The base URL for constructing links.
|
* @param baseUrl The base URL for constructing links.
|
||||||
* @param pageNum The page number for pagination.
|
|
||||||
* @param locale The locale for localization.
|
* @param locale The locale for localization.
|
||||||
|
* @param pageNum The page number for pagination.
|
||||||
* @return An XML string representing the library sources feed.
|
* @return An XML string representing the library sources feed.
|
||||||
*/
|
*/
|
||||||
fun getLibrarySourcesFeed(
|
fun getLibrarySourcesFeed(
|
||||||
baseUrl: String,
|
baseUrl: String,
|
||||||
pageNum: Int,
|
|
||||||
locale: Locale,
|
locale: Locale,
|
||||||
|
pageNum: Int,
|
||||||
): String {
|
): String {
|
||||||
val (sourceNavEntries, total) = NavigationRepository.getLibrarySources(pageNum)
|
val (sourceNavEntries, total) = NavigationRepository.getLibrarySources(pageNum)
|
||||||
val builder =
|
val builder =
|
||||||
FeedBuilderInternal(
|
FeedBuilderInternal(
|
||||||
baseUrl,
|
baseUrl = baseUrl,
|
||||||
"library/sources",
|
locale = locale,
|
||||||
MR.strings.opds_feeds_library_sources_title.localized(locale),
|
idPath = "library/sources",
|
||||||
locale,
|
title = MR.strings.opds_feeds_library_sources_title.localized(locale),
|
||||||
OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION,
|
feedType = OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION,
|
||||||
pageNum,
|
pageNum = pageNum,
|
||||||
)
|
)
|
||||||
builder.totalResults = total
|
builder.totalResults = total
|
||||||
builder.entries.addAll(
|
builder.entries.addAll(
|
||||||
@@ -300,7 +315,7 @@ object OpdsFeedBuilder {
|
|||||||
listOf(
|
listOf(
|
||||||
OpdsLinkXml(
|
OpdsLinkXml(
|
||||||
OpdsConstants.LINK_REL_SUBSECTION,
|
OpdsConstants.LINK_REL_SUBSECTION,
|
||||||
"$baseUrl/library/source/${entry.id}?lang=${locale.toLanguageTag()}",
|
"$baseUrl/source/${entry.id}?lang=${locale.toLanguageTag()}",
|
||||||
OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
|
OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
|
||||||
entry.name,
|
entry.name,
|
||||||
thrCount = entry.mangaCount?.toInt(),
|
thrCount = entry.mangaCount?.toInt(),
|
||||||
@@ -314,78 +329,75 @@ object OpdsFeedBuilder {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates an acquisition feed for manga from a specific source (explore context).
|
* Generates an acquisition feed for manga from a specific source (explore context).
|
||||||
* @param sourceId The ID of the source.
|
|
||||||
* @param baseUrl The base URL for constructing links.
|
* @param baseUrl The base URL for constructing links.
|
||||||
|
* @param locale The locale for localization.
|
||||||
|
* @param sourceId The ID of the source.
|
||||||
* @param pageNum The page number for pagination.
|
* @param pageNum The page number for pagination.
|
||||||
* @param sort The sorting parameter ('popular' or 'latest').
|
* @param sort The sorting parameter ('popular' or 'latest').
|
||||||
* @param locale The locale for localization.
|
|
||||||
* @return An XML string representing the source-specific feed.
|
* @return An XML string representing the source-specific feed.
|
||||||
*/
|
*/
|
||||||
suspend fun getExploreSourceFeed(
|
suspend fun getExploreSourceFeed(
|
||||||
sourceId: Long,
|
|
||||||
baseUrl: String,
|
baseUrl: String,
|
||||||
|
locale: Locale,
|
||||||
|
sourceId: Long,
|
||||||
pageNum: Int,
|
pageNum: Int,
|
||||||
sort: String,
|
sort: String,
|
||||||
locale: Locale,
|
|
||||||
): String {
|
): String {
|
||||||
val (mangaEntries, hasNextPage) = MangaRepository.getMangaBySource(sourceId, pageNum, sort)
|
val (mangaEntries, hasNextPage) = MangaRepository.getMangaBySource(sourceId, pageNum, sort)
|
||||||
val sourceNavEntry = NavigationRepository.getExploreSources(1).first.find { it.id == sourceId }
|
val sourceInfo = NavigationRepository.getSourceDetails(sourceId)
|
||||||
val sourceNameOrId = sourceNavEntry?.name ?: sourceId.toString()
|
val sourceName = sourceInfo?.first ?: sourceId.toString()
|
||||||
val titleRes =
|
val titleRes =
|
||||||
if (sort == "latest") {
|
if (sort ==
|
||||||
|
"latest"
|
||||||
|
) {
|
||||||
MR.strings.opds_feeds_source_specific_latest_title
|
MR.strings.opds_feeds_source_specific_latest_title
|
||||||
} else {
|
} else {
|
||||||
MR.strings.opds_feeds_source_specific_popular_title
|
MR.strings.opds_feeds_source_specific_popular_title
|
||||||
}
|
}
|
||||||
val feedTitle = titleRes.localized(locale, sourceNameOrId)
|
val feedTitle = titleRes.localized(locale, sourceName)
|
||||||
val feedUrl = "source/$sourceId"
|
|
||||||
val builder =
|
val builder =
|
||||||
FeedBuilderInternal(
|
FeedBuilderInternal(
|
||||||
baseUrl,
|
baseUrl = baseUrl,
|
||||||
feedUrl,
|
locale = locale,
|
||||||
feedTitle,
|
idPath = "explore/source/$sourceId",
|
||||||
locale,
|
title = feedTitle,
|
||||||
OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
|
feedType = OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
|
||||||
pageNum,
|
pageNum = pageNum,
|
||||||
currentSort = sort,
|
currentSort = sort,
|
||||||
)
|
)
|
||||||
builder.totalResults =
|
builder.totalResults =
|
||||||
if (hasNextPage) {
|
if (hasNextPage) {
|
||||||
(pageNum * serverConfig.opdsItemsPerPage.value + 1).toLong()
|
(pageNum * serverConfig.opdsItemsPerPage.value + 1).toLong()
|
||||||
} else {
|
} else {
|
||||||
(
|
((pageNum - 1) * serverConfig.opdsItemsPerPage.value + mangaEntries.size).toLong()
|
||||||
(pageNum - 1) *
|
|
||||||
serverConfig.opdsItemsPerPage.value +
|
|
||||||
mangaEntries.size
|
|
||||||
).toLong()
|
|
||||||
}
|
}
|
||||||
builder.icon = sourceNavEntry?.iconUrl
|
builder.icon = sourceInfo?.second
|
||||||
OpdsEntryBuilder.addSourceSortFacets(builder, "$baseUrl/$feedUrl", sort, locale)
|
OpdsEntryBuilder.addSourceSortFacets(builder, "$baseUrl/explore/source/$sourceId", locale, sort)
|
||||||
builder.entries.addAll(mangaEntries.map { OpdsEntryBuilder.mangaAcqEntryToEntry(it, baseUrl, locale) })
|
builder.entries.addAll(mangaEntries.map { OpdsEntryBuilder.mangaAcqEntryToEntry(baseUrl, locale, it) })
|
||||||
return OpdsXmlUtil.serializeFeedToString(builder.build())
|
return OpdsXmlUtil.serializeFeedToString(builder.build())
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates a navigation feed for library categories.
|
* Generates a navigation feed for library categories.
|
||||||
* @param baseUrl The base URL for constructing links.
|
* @param baseUrl The base URL for constructing links.
|
||||||
* @param pageNum The page number for pagination.
|
|
||||||
* @param locale The locale for localization.
|
* @param locale The locale for localization.
|
||||||
|
* @param pageNum The page number for pagination.
|
||||||
* @return An XML string representing the categories navigation feed.
|
* @return An XML string representing the categories navigation feed.
|
||||||
*/
|
*/
|
||||||
fun getCategoriesFeed(
|
fun getCategoriesFeed(
|
||||||
baseUrl: String,
|
baseUrl: String,
|
||||||
pageNum: Int,
|
|
||||||
locale: Locale,
|
locale: Locale,
|
||||||
|
pageNum: Int,
|
||||||
): String {
|
): String {
|
||||||
val (categoryNavEntries, total) = NavigationRepository.getCategories(pageNum)
|
val (categoryNavEntries, total) = NavigationRepository.getCategories(pageNum)
|
||||||
val builder =
|
val builder =
|
||||||
FeedBuilderInternal(
|
FeedBuilderInternal(
|
||||||
baseUrl,
|
baseUrl = baseUrl,
|
||||||
"library/categories",
|
locale = locale,
|
||||||
MR.strings.opds_feeds_categories_title.localized(locale),
|
idPath = "library/categories",
|
||||||
locale,
|
title = MR.strings.opds_feeds_categories_title.localized(locale),
|
||||||
OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION,
|
feedType = OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION,
|
||||||
pageNum,
|
pageNum = pageNum,
|
||||||
)
|
)
|
||||||
builder.totalResults = total
|
builder.totalResults = total
|
||||||
builder.entries.addAll(
|
builder.entries.addAll(
|
||||||
@@ -413,24 +425,24 @@ object OpdsFeedBuilder {
|
|||||||
/**
|
/**
|
||||||
* Generates a navigation feed for library genres.
|
* Generates a navigation feed for library genres.
|
||||||
* @param baseUrl The base URL for constructing links.
|
* @param baseUrl The base URL for constructing links.
|
||||||
* @param pageNum The page number for pagination.
|
|
||||||
* @param locale The locale for localization.
|
* @param locale The locale for localization.
|
||||||
|
* @param pageNum The page number for pagination.
|
||||||
* @return An XML string representing the genres navigation feed.
|
* @return An XML string representing the genres navigation feed.
|
||||||
*/
|
*/
|
||||||
fun getGenresFeed(
|
fun getGenresFeed(
|
||||||
baseUrl: String,
|
baseUrl: String,
|
||||||
pageNum: Int,
|
|
||||||
locale: Locale,
|
locale: Locale,
|
||||||
|
pageNum: Int,
|
||||||
): String {
|
): String {
|
||||||
val (genreNavEntries, total) = NavigationRepository.getGenres(pageNum, locale)
|
val (genreNavEntries, total) = NavigationRepository.getGenres(locale, pageNum)
|
||||||
val builder =
|
val builder =
|
||||||
FeedBuilderInternal(
|
FeedBuilderInternal(
|
||||||
baseUrl,
|
baseUrl = baseUrl,
|
||||||
"library/genres",
|
locale = locale,
|
||||||
MR.strings.opds_feeds_genres_title.localized(locale),
|
idPath = "library/genres",
|
||||||
locale,
|
title = MR.strings.opds_feeds_genres_title.localized(locale),
|
||||||
OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION,
|
feedType = OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION,
|
||||||
pageNum,
|
pageNum = pageNum,
|
||||||
)
|
)
|
||||||
builder.totalResults = total
|
builder.totalResults = total
|
||||||
builder.entries.addAll(
|
builder.entries.addAll(
|
||||||
@@ -458,26 +470,26 @@ object OpdsFeedBuilder {
|
|||||||
/**
|
/**
|
||||||
* Generates a navigation feed for manga publication statuses.
|
* Generates a navigation feed for manga publication statuses.
|
||||||
* @param baseUrl The base URL for constructing links.
|
* @param baseUrl The base URL for constructing links.
|
||||||
* @param pageNum The page number (currently unused).
|
|
||||||
* @param locale The locale for localization.
|
* @param locale The locale for localization.
|
||||||
|
* @param pageNum The page number (currently unused).
|
||||||
* @return An XML string representing the status navigation feed.
|
* @return An XML string representing the status navigation feed.
|
||||||
*/
|
*/
|
||||||
fun getStatusFeed(
|
fun getStatusFeed(
|
||||||
baseUrl: String,
|
baseUrl: String,
|
||||||
@Suppress("UNUSED_PARAMETER") pageNum: Int,
|
|
||||||
locale: Locale,
|
locale: Locale,
|
||||||
|
pageNum: Int,
|
||||||
): String {
|
): String {
|
||||||
val statuses = NavigationRepository.getStatuses(locale)
|
val (statuses, total) = NavigationRepository.getStatuses(locale, pageNum)
|
||||||
val builder =
|
val builder =
|
||||||
FeedBuilderInternal(
|
FeedBuilderInternal(
|
||||||
baseUrl,
|
baseUrl = baseUrl,
|
||||||
"library/statuses",
|
locale = locale,
|
||||||
MR.strings.opds_feeds_status_title.localized(locale),
|
idPath = "library/statuses",
|
||||||
locale,
|
title = MR.strings.opds_feeds_status_title.localized(locale),
|
||||||
OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION,
|
feedType = OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION,
|
||||||
null,
|
pageNum = pageNum,
|
||||||
)
|
)
|
||||||
builder.totalResults = statuses.size.toLong()
|
builder.totalResults = total
|
||||||
builder.entries.addAll(
|
builder.entries.addAll(
|
||||||
statuses.map { entry ->
|
statuses.map { entry ->
|
||||||
OpdsEntryXml(
|
OpdsEntryXml(
|
||||||
@@ -503,24 +515,26 @@ object OpdsFeedBuilder {
|
|||||||
/**
|
/**
|
||||||
* Generates a navigation feed for content languages available in the library.
|
* Generates a navigation feed for content languages available in the library.
|
||||||
* @param baseUrl The base URL for constructing links.
|
* @param baseUrl The base URL for constructing links.
|
||||||
* @param uiLocale The locale for the user interface.
|
* @param locale The locale for the user interface.
|
||||||
|
* @param pageNum The page number for pagination.
|
||||||
* @return An XML string representing the languages navigation feed.
|
* @return An XML string representing the languages navigation feed.
|
||||||
*/
|
*/
|
||||||
fun getLanguagesFeed(
|
fun getLanguagesFeed(
|
||||||
baseUrl: String,
|
baseUrl: String,
|
||||||
uiLocale: Locale,
|
locale: Locale,
|
||||||
|
pageNum: Int,
|
||||||
): String {
|
): String {
|
||||||
val languages = NavigationRepository.getContentLanguages(uiLocale)
|
val (languages, total) = NavigationRepository.getContentLanguages(locale, pageNum)
|
||||||
val builder =
|
val builder =
|
||||||
FeedBuilderInternal(
|
FeedBuilderInternal(
|
||||||
baseUrl,
|
baseUrl = baseUrl,
|
||||||
"library/languages",
|
locale = locale,
|
||||||
MR.strings.opds_feeds_languages_title.localized(uiLocale),
|
idPath = "library/languages",
|
||||||
uiLocale,
|
title = MR.strings.opds_feeds_languages_title.localized(locale),
|
||||||
OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION,
|
feedType = OpdsConstants.TYPE_ATOM_XML_FEED_NAVIGATION,
|
||||||
null,
|
pageNum = pageNum,
|
||||||
)
|
)
|
||||||
builder.totalResults = languages.size.toLong()
|
builder.totalResults = total
|
||||||
builder.entries.addAll(
|
builder.entries.addAll(
|
||||||
languages.map { entry ->
|
languages.map { entry ->
|
||||||
OpdsEntryXml(
|
OpdsEntryXml(
|
||||||
@@ -531,7 +545,7 @@ object OpdsFeedBuilder {
|
|||||||
listOf(
|
listOf(
|
||||||
OpdsLinkXml(
|
OpdsLinkXml(
|
||||||
OpdsConstants.LINK_REL_SUBSECTION,
|
OpdsConstants.LINK_REL_SUBSECTION,
|
||||||
"$baseUrl/language/${entry.id}?lang=${uiLocale.toLanguageTag()}",
|
"$baseUrl/language/${entry.id}?lang=${locale.toLanguageTag()}",
|
||||||
OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
|
OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
|
||||||
entry.title,
|
entry.title,
|
||||||
thrCount = entry.mangaCount.toInt(),
|
thrCount = entry.mangaCount.toInt(),
|
||||||
@@ -544,32 +558,41 @@ object OpdsFeedBuilder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates an acquisition feed for recent chapter updates in the library.
|
* Generates an acquisition feed of recent chapter updates for series in the library.
|
||||||
* @param baseUrl The base URL for constructing links.
|
* @param baseUrl The base URL for constructing links.
|
||||||
* @param pageNum The page number for pagination.
|
|
||||||
* @param locale The locale for localization.
|
* @param locale The locale for localization.
|
||||||
|
* @param pageNum The page number for pagination.
|
||||||
* @return An XML string representing the library updates feed.
|
* @return An XML string representing the library updates feed.
|
||||||
*/
|
*/
|
||||||
suspend fun getLibraryUpdatesFeed(
|
suspend fun getLibraryUpdatesFeed(
|
||||||
baseUrl: String,
|
baseUrl: String,
|
||||||
pageNum: Int,
|
|
||||||
locale: Locale,
|
locale: Locale,
|
||||||
|
pageNum: Int,
|
||||||
): String {
|
): String {
|
||||||
val (updateItems, total) = ChapterRepository.getLibraryUpdates(pageNum)
|
val (updateItems, total) = ChapterRepository.getLibraryUpdates(pageNum)
|
||||||
val builder =
|
val builder =
|
||||||
FeedBuilderInternal(
|
FeedBuilderInternal(
|
||||||
baseUrl,
|
baseUrl = baseUrl,
|
||||||
"library-updates",
|
locale = locale,
|
||||||
MR.strings.opds_feeds_library_updates_title.localized(locale),
|
idPath = "library-updates",
|
||||||
locale,
|
title = MR.strings.opds_feeds_library_updates_title.localized(locale),
|
||||||
OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
|
feedType = OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
|
||||||
pageNum,
|
pageNum = pageNum,
|
||||||
)
|
)
|
||||||
builder.totalResults = total
|
builder.totalResults = total
|
||||||
|
val skipMetadata = serverConfig.opdsSkipChapterMetadataFeed.value
|
||||||
builder.entries.addAll(
|
builder.entries.addAll(
|
||||||
updateItems.map { item ->
|
updateItems.map { item ->
|
||||||
val mangaDetails = OpdsMangaDetails(item.mangaId, item.mangaTitle, item.mangaThumbnailUrl, item.mangaAuthor)
|
val mangaDetails =
|
||||||
OpdsEntryBuilder.createChapterListEntry(item.chapter, mangaDetails, baseUrl, true, locale)
|
OpdsMangaDetails(item.mangaId, item.mangaTitle, item.mangaThumbnailUrl, item.mangaAuthor, item.mangaTotalChapters)
|
||||||
|
OpdsEntryBuilder.createChapterListEntry(
|
||||||
|
baseUrl,
|
||||||
|
locale,
|
||||||
|
item.chapter,
|
||||||
|
mangaDetails,
|
||||||
|
true,
|
||||||
|
skipMetadata,
|
||||||
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return OpdsXmlUtil.serializeFeedToString(builder.build())
|
return OpdsXmlUtil.serializeFeedToString(builder.build())
|
||||||
@@ -577,29 +600,29 @@ object OpdsFeedBuilder {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates an acquisition feed for all chapters of a specific manga.
|
* Generates an acquisition feed for all chapters of a specific manga.
|
||||||
* @param mangaId The ID of the manga.
|
|
||||||
* @param baseUrl The base URL for constructing links.
|
* @param baseUrl The base URL for constructing links.
|
||||||
|
* @param locale The locale for localization.
|
||||||
|
* @param mangaId The ID of the manga.
|
||||||
* @param pageNum The page number for pagination.
|
* @param pageNum The page number for pagination.
|
||||||
* @param sortParam The sorting parameter for chapters.
|
* @param sortParam The sorting parameter for chapters.
|
||||||
* @param filterParam The filtering 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.
|
* @return An XML string representing the series' chapters feed.
|
||||||
*/
|
*/
|
||||||
suspend fun getSeriesChaptersFeed(
|
suspend fun getSeriesChaptersFeed(
|
||||||
mangaId: Int,
|
|
||||||
baseUrl: String,
|
baseUrl: String,
|
||||||
|
locale: Locale,
|
||||||
|
mangaId: Int,
|
||||||
pageNum: Int,
|
pageNum: Int,
|
||||||
sortParam: String?,
|
sortParam: String?,
|
||||||
filterParam: String?,
|
filterParam: String?,
|
||||||
locale: Locale,
|
|
||||||
): String {
|
): String {
|
||||||
val mangaDetails =
|
val mangaDetails =
|
||||||
MangaRepository.getMangaDetails(mangaId)
|
MangaRepository.getMangaDetails(mangaId)
|
||||||
?: return buildNotFoundFeed(
|
?: return buildNotFoundFeed(
|
||||||
baseUrl,
|
baseUrl,
|
||||||
|
locale,
|
||||||
"series/$mangaId/chapters",
|
"series/$mangaId/chapters",
|
||||||
MR.strings.opds_error_manga_not_found.localized(locale, mangaId),
|
MR.strings.opds_error_manga_not_found.localized(locale, mangaId),
|
||||||
locale,
|
|
||||||
)
|
)
|
||||||
val (sortColumn, currentSortOrder) =
|
val (sortColumn, currentSortOrder) =
|
||||||
when (sortParam?.lowercase()) {
|
when (sortParam?.lowercase()) {
|
||||||
@@ -610,15 +633,27 @@ object OpdsFeedBuilder {
|
|||||||
else -> ChapterTable.sourceOrder to (serverConfig.opdsChapterSortOrder.value)
|
else -> ChapterTable.sourceOrder to (serverConfig.opdsChapterSortOrder.value)
|
||||||
}
|
}
|
||||||
val currentFilter = filterParam?.lowercase() ?: if (serverConfig.opdsShowOnlyUnreadChapters.value) "unread" else "all"
|
val currentFilter = filterParam?.lowercase() ?: if (serverConfig.opdsShowOnlyUnreadChapters.value) "unread" else "all"
|
||||||
|
val skipMetadata = serverConfig.opdsSkipChapterMetadataFeed.value
|
||||||
var (chapterEntries, totalChapters) =
|
var (chapterEntries, totalChapters) =
|
||||||
ChapterRepository.getChaptersForManga(
|
ChapterRepository.getChaptersForManga(
|
||||||
mangaId,
|
mangaId,
|
||||||
pageNum,
|
|
||||||
sortColumn,
|
sortColumn,
|
||||||
currentSortOrder,
|
currentSortOrder,
|
||||||
currentFilter,
|
currentFilter,
|
||||||
|
pageNum,
|
||||||
|
skipMetadata,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Return a not-found feed if all available chapters are filtered out as unreachable
|
||||||
|
if (skipMetadata && chapterEntries.isEmpty() && totalChapters > 0L) {
|
||||||
|
return buildNotFoundFeed(
|
||||||
|
baseUrl = baseUrl,
|
||||||
|
locale = locale,
|
||||||
|
idPath = "series/$mangaId/chapters",
|
||||||
|
title = MR.strings.opds_error_chapters_not_found.localized(locale, pageNum),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// If no chapters are found in the database, attempt to fetch them from the source.
|
// If no chapters are found in the database, attempt to fetch them from the source.
|
||||||
if (chapterEntries.isEmpty() && totalChapters == 0L) {
|
if (chapterEntries.isEmpty() && totalChapters == 0L) {
|
||||||
try {
|
try {
|
||||||
@@ -629,10 +664,11 @@ object OpdsFeedBuilder {
|
|||||||
val (refetchedChapters, refetchedTotal) =
|
val (refetchedChapters, refetchedTotal) =
|
||||||
ChapterRepository.getChaptersForManga(
|
ChapterRepository.getChaptersForManga(
|
||||||
mangaId,
|
mangaId,
|
||||||
pageNum,
|
|
||||||
sortColumn,
|
sortColumn,
|
||||||
currentSortOrder,
|
currentSortOrder,
|
||||||
currentFilter,
|
currentFilter,
|
||||||
|
pageNum,
|
||||||
|
skipMetadata,
|
||||||
)
|
)
|
||||||
chapterEntries = refetchedChapters
|
chapterEntries = refetchedChapters
|
||||||
totalChapters = refetchedTotal
|
totalChapters = refetchedTotal
|
||||||
@@ -651,12 +687,12 @@ object OpdsFeedBuilder {
|
|||||||
val feedUrl = "series/$mangaId/chapters"
|
val feedUrl = "series/$mangaId/chapters"
|
||||||
val builder =
|
val builder =
|
||||||
FeedBuilderInternal(
|
FeedBuilderInternal(
|
||||||
baseUrl,
|
baseUrl = baseUrl,
|
||||||
feedUrl,
|
locale = locale,
|
||||||
MR.strings.opds_feeds_manga_chapters.localized(locale, mangaDetails.title),
|
idPath = feedUrl,
|
||||||
locale,
|
title = MR.strings.opds_feeds_manga_chapters.localized(locale, mangaDetails.title),
|
||||||
OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
|
feedType = OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
|
||||||
pageNum,
|
pageNum = pageNum,
|
||||||
currentSort = actualSortParamForLinks,
|
currentSort = actualSortParamForLinks,
|
||||||
currentFilter = currentFilter,
|
currentFilter = currentFilter,
|
||||||
)
|
)
|
||||||
@@ -669,14 +705,21 @@ object OpdsFeedBuilder {
|
|||||||
OpdsEntryBuilder.addChapterSortAndFilterFacets(
|
OpdsEntryBuilder.addChapterSortAndFilterFacets(
|
||||||
builder,
|
builder,
|
||||||
"$baseUrl/$feedUrl",
|
"$baseUrl/$feedUrl",
|
||||||
|
locale,
|
||||||
actualSortParamForLinks,
|
actualSortParamForLinks,
|
||||||
currentFilter,
|
currentFilter,
|
||||||
locale,
|
|
||||||
filterCounts,
|
filterCounts,
|
||||||
)
|
)
|
||||||
builder.entries.addAll(
|
builder.entries.addAll(
|
||||||
chapterEntries.map { chapter ->
|
chapterEntries.map { chapter ->
|
||||||
OpdsEntryBuilder.createChapterListEntry(chapter, mangaDetails, baseUrl, false, locale)
|
OpdsEntryBuilder.createChapterListEntry(
|
||||||
|
baseUrl,
|
||||||
|
locale,
|
||||||
|
chapter,
|
||||||
|
mangaDetails,
|
||||||
|
false,
|
||||||
|
skipMetadata,
|
||||||
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return OpdsXmlUtil.serializeFeedToString(builder.build())
|
return OpdsXmlUtil.serializeFeedToString(builder.build())
|
||||||
@@ -684,43 +727,43 @@ object OpdsFeedBuilder {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates an acquisition feed with detailed metadata for a single chapter.
|
* Generates an acquisition feed with detailed metadata for a single chapter.
|
||||||
* @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 baseUrl The base URL for constructing links.
|
||||||
* @param locale The locale for localization.
|
* @param locale The locale for localization.
|
||||||
|
* @param mangaId The ID of the manga.
|
||||||
|
* @param chapterSourceOrder The source order index of the chapter.
|
||||||
* @return An XML string representing the chapter's metadata feed.
|
* @return An XML string representing the chapter's metadata feed.
|
||||||
*/
|
*/
|
||||||
suspend fun getChapterMetadataFeed(
|
suspend fun getChapterMetadataFeed(
|
||||||
mangaId: Int,
|
|
||||||
chapterSourceOrder: Int,
|
|
||||||
baseUrl: String,
|
baseUrl: String,
|
||||||
locale: Locale,
|
locale: Locale,
|
||||||
|
mangaId: Int,
|
||||||
|
chapterSourceOrder: Int,
|
||||||
): String {
|
): String {
|
||||||
val mangaDetails =
|
val mangaDetails =
|
||||||
MangaRepository.getMangaDetails(mangaId)
|
MangaRepository.getMangaDetails(mangaId)
|
||||||
?: return buildNotFoundFeed(
|
?: return buildNotFoundFeed(
|
||||||
baseUrl,
|
baseUrl,
|
||||||
|
locale,
|
||||||
"series/$mangaId/chapter/$chapterSourceOrder/metadata",
|
"series/$mangaId/chapter/$chapterSourceOrder/metadata",
|
||||||
MR.strings.opds_error_manga_not_found.localized(locale, mangaId),
|
MR.strings.opds_error_manga_not_found.localized(locale, mangaId),
|
||||||
locale,
|
|
||||||
)
|
)
|
||||||
val chapterMetadata =
|
val chapterMetadata =
|
||||||
ChapterRepository.getChapterDetailsForMetadataFeed(mangaId, chapterSourceOrder)
|
ChapterRepository.getChapterDetailsForMetadataFeed(mangaId, chapterSourceOrder)
|
||||||
?: return buildNotFoundFeed(
|
?: return buildNotFoundFeed(
|
||||||
baseUrl,
|
baseUrl,
|
||||||
|
locale,
|
||||||
"series/$mangaId/chapter/$chapterSourceOrder/metadata",
|
"series/$mangaId/chapter/$chapterSourceOrder/metadata",
|
||||||
MR.strings.opds_error_chapter_not_found.localized(locale, chapterSourceOrder),
|
MR.strings.opds_error_chapter_not_found.localized(locale, chapterSourceOrder),
|
||||||
locale,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
val builder =
|
val builder =
|
||||||
FeedBuilderInternal(
|
FeedBuilderInternal(
|
||||||
baseUrl,
|
baseUrl = baseUrl,
|
||||||
"series/$mangaId/chapter/${chapterMetadata.sourceOrder}/metadata",
|
locale = locale,
|
||||||
MR.strings.opds_feeds_chapter_details.localized(locale, mangaDetails.title, chapterMetadata.name),
|
idPath = "series/$mangaId/chapter/${chapterMetadata.sourceOrder}/metadata",
|
||||||
locale,
|
title = MR.strings.opds_feeds_chapter_details.localized(locale, mangaDetails.title, chapterMetadata.name),
|
||||||
OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
|
feedType = OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
|
||||||
null,
|
pageNum = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
mangaDetails.thumbnailUrl?.let { proxyThumbnailUrl(mangaDetails.id) }?.also {
|
mangaDetails.thumbnailUrl?.let { proxyThumbnailUrl(mangaDetails.id) }?.also {
|
||||||
@@ -731,10 +774,10 @@ object OpdsFeedBuilder {
|
|||||||
|
|
||||||
val (primaryEntry, conflictEntry) =
|
val (primaryEntry, conflictEntry) =
|
||||||
OpdsEntryBuilder.createChapterMetadataEntries(
|
OpdsEntryBuilder.createChapterMetadataEntries(
|
||||||
chapter = chapterMetadata,
|
|
||||||
manga = mangaDetails,
|
|
||||||
baseUrl = baseUrl,
|
baseUrl = baseUrl,
|
||||||
locale = locale,
|
locale = locale,
|
||||||
|
chapter = chapterMetadata,
|
||||||
|
manga = mangaDetails,
|
||||||
)
|
)
|
||||||
|
|
||||||
builder.entries.add(primaryEntry)
|
builder.entries.add(primaryEntry)
|
||||||
@@ -751,19 +794,25 @@ object OpdsFeedBuilder {
|
|||||||
/**
|
/**
|
||||||
* Builds a simple OPDS feed to indicate that a resource was not found.
|
* Builds a simple OPDS feed to indicate that a resource was not found.
|
||||||
* @param baseUrl The base URL.
|
* @param baseUrl The base URL.
|
||||||
|
* @param locale The locale for localization.
|
||||||
* @param idPath The path that was not found.
|
* @param idPath The path that was not found.
|
||||||
* @param title The title for the feed (e.g., an error message).
|
* @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.
|
* @return An XML string representing the 'not found' feed.
|
||||||
*/
|
*/
|
||||||
fun buildNotFoundFeed(
|
fun buildNotFoundFeed(
|
||||||
baseUrl: String,
|
baseUrl: String,
|
||||||
|
locale: Locale,
|
||||||
idPath: String,
|
idPath: String,
|
||||||
title: String,
|
title: String,
|
||||||
locale: Locale,
|
|
||||||
): String =
|
): String =
|
||||||
FeedBuilderInternal(baseUrl, idPath, title, locale, feedType = OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION, pageNum = null)
|
FeedBuilderInternal(
|
||||||
.apply { totalResults = 0L }
|
baseUrl = baseUrl,
|
||||||
|
locale = locale,
|
||||||
|
idPath = idPath,
|
||||||
|
title = title,
|
||||||
|
feedType = OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION,
|
||||||
|
pageNum = null,
|
||||||
|
).apply { totalResults = 0L }
|
||||||
.build()
|
.build()
|
||||||
.let(OpdsXmlUtil::serializeFeedToString)
|
.let(OpdsXmlUtil::serializeFeedToString)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,29 @@
|
|||||||
package suwayomi.tachidesk.opds.repository
|
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.Column
|
||||||
import org.jetbrains.exposed.v1.core.JoinType
|
import org.jetbrains.exposed.v1.core.JoinType
|
||||||
import org.jetbrains.exposed.v1.core.Op
|
import org.jetbrains.exposed.v1.core.Op
|
||||||
import org.jetbrains.exposed.v1.core.ResultRow
|
import org.jetbrains.exposed.v1.core.ResultRow
|
||||||
import org.jetbrains.exposed.v1.core.SortOrder
|
import org.jetbrains.exposed.v1.core.SortOrder
|
||||||
import org.jetbrains.exposed.v1.core.and
|
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.eq
|
||||||
import org.jetbrains.exposed.v1.core.greater
|
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.andWhere
|
||||||
import org.jetbrains.exposed.v1.jdbc.select
|
import org.jetbrains.exposed.v1.jdbc.select
|
||||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
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.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.ChapterTable
|
||||||
import suwayomi.tachidesk.manga.model.table.MangaTable
|
import suwayomi.tachidesk.manga.model.table.MangaTable
|
||||||
import suwayomi.tachidesk.manga.model.table.SourceTable
|
import suwayomi.tachidesk.manga.model.table.SourceTable
|
||||||
@@ -24,6 +36,7 @@ import suwayomi.tachidesk.server.serverConfig
|
|||||||
object ChapterRepository {
|
object ChapterRepository {
|
||||||
private val opdsItemsPerPageBounded: Int
|
private val opdsItemsPerPageBounded: Int
|
||||||
get() = serverConfig.opdsItemsPerPage.value
|
get() = serverConfig.opdsItemsPerPage.value
|
||||||
|
private val logger = KotlinLogging.logger {}
|
||||||
|
|
||||||
private fun ResultRow.toOpdsChapterListAcqEntry(): OpdsChapterListAcqEntry =
|
private fun ResultRow.toOpdsChapterListAcqEntry(): OpdsChapterListAcqEntry =
|
||||||
OpdsChapterListAcqEntry(
|
OpdsChapterListAcqEntry(
|
||||||
@@ -38,69 +51,165 @@ object ChapterRepository {
|
|||||||
lastReadAt = this[ChapterTable.lastReadAt],
|
lastReadAt = this[ChapterTable.lastReadAt],
|
||||||
sourceOrder = this[ChapterTable.sourceOrder],
|
sourceOrder = this[ChapterTable.sourceOrder],
|
||||||
pageCount = this[ChapterTable.pageCount],
|
pageCount = this[ChapterTable.pageCount],
|
||||||
|
downloaded = this[ChapterTable.isDownloaded],
|
||||||
)
|
)
|
||||||
|
|
||||||
fun getChaptersForManga(
|
suspend fun getChaptersForManga(
|
||||||
mangaId: Int,
|
mangaId: Int,
|
||||||
pageNum: Int,
|
|
||||||
sortColumn: Column<*>,
|
sortColumn: Column<*>,
|
||||||
sortOrder: SortOrder,
|
sortOrder: SortOrder,
|
||||||
filter: String,
|
filter: String,
|
||||||
): Pair<List<OpdsChapterListAcqEntry>, Long> =
|
pageNum: Int,
|
||||||
transaction {
|
skipMetadata: Boolean,
|
||||||
val conditions = mutableListOf<Op<Boolean>>()
|
): Pair<List<OpdsChapterListAcqEntry>, Long> {
|
||||||
conditions.add(ChapterTable.manga eq mangaId)
|
val (rawChapters, totalCount) =
|
||||||
|
transaction {
|
||||||
|
val conditions = mutableListOf<Op<Boolean>>()
|
||||||
|
conditions.add(ChapterTable.manga eq mangaId)
|
||||||
|
|
||||||
when (filter) {
|
when (filter) {
|
||||||
"unread" -> conditions.add(ChapterTable.isRead eq false)
|
"unread" -> conditions.add(ChapterTable.isRead eq false)
|
||||||
"read" -> conditions.add(ChapterTable.isRead eq true)
|
"read" -> conditions.add(ChapterTable.isRead eq true)
|
||||||
}
|
}
|
||||||
if (serverConfig.opdsShowOnlyDownloadedChapters.value) {
|
if (serverConfig.opdsShowOnlyDownloadedChapters.value) {
|
||||||
conditions.add(ChapterTable.isDownloaded eq true)
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
val finalCondition = conditions.reduceOrNull { acc, op -> acc and op } ?: Op.TRUE
|
// If not skipping metadata, return basic DTOs
|
||||||
|
if (!skipMetadata) {
|
||||||
val baseQuery =
|
return Pair(rawChapters, totalCount)
|
||||||
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()
|
||||||
|
// Exclude unreachable chapters that are not downloaded and have no page count
|
||||||
|
.filter { it.downloaded || it.pageCount > 0 }
|
||||||
|
|
||||||
|
return Pair(enrichedChapters, totalCount)
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun getChapterDetailsForMetadataFeed(
|
suspend fun getChapterDetailsForMetadataFeed(
|
||||||
mangaId: Int,
|
mangaId: Int,
|
||||||
chapterSourceOrder: Int,
|
chapterSourceOrder: Int,
|
||||||
): OpdsChapterMetadataAcqEntry? =
|
): OpdsChapterMetadataAcqEntry? {
|
||||||
try {
|
val chapterDataClass =
|
||||||
val chapterDataClass = getChapterDownloadReady(chapterIndex = chapterSourceOrder, mangaId = mangaId)
|
try {
|
||||||
OpdsChapterMetadataAcqEntry(
|
getChapterDownloadReady(chapterIndex = chapterSourceOrder, mangaId = mangaId)
|
||||||
id = chapterDataClass.id,
|
} catch (e: Exception) {
|
||||||
mangaId = chapterDataClass.mangaId,
|
return null
|
||||||
name = chapterDataClass.name,
|
}
|
||||||
uploadDate = chapterDataClass.uploadDate,
|
|
||||||
scanlator = chapterDataClass.scanlator,
|
return OpdsChapterMetadataAcqEntry(
|
||||||
read = chapterDataClass.read,
|
id = chapterDataClass.id,
|
||||||
lastPageRead = chapterDataClass.lastPageRead,
|
mangaId = chapterDataClass.mangaId,
|
||||||
lastReadAt = chapterDataClass.lastReadAt,
|
name = chapterDataClass.name,
|
||||||
sourceOrder = chapterDataClass.index,
|
uploadDate = chapterDataClass.uploadDate,
|
||||||
downloaded = chapterDataClass.downloaded,
|
chapterNumber = chapterDataClass.chapterNumber,
|
||||||
pageCount = chapterDataClass.pageCount,
|
scanlator = chapterDataClass.scanlator,
|
||||||
url = chapterDataClass.realUrl,
|
read = chapterDataClass.read,
|
||||||
)
|
lastPageRead = chapterDataClass.lastPageRead,
|
||||||
} catch (e: Exception) {
|
lastReadAt = chapterDataClass.lastReadAt,
|
||||||
null
|
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
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fun getLibraryUpdates(pageNum: Int): Pair<List<OpdsLibraryUpdateAcqEntry>, Long> =
|
fun getLibraryUpdates(pageNum: Int): Pair<List<OpdsLibraryUpdateAcqEntry>, Long> =
|
||||||
transaction {
|
transaction {
|
||||||
@@ -115,21 +224,38 @@ object ChapterRepository {
|
|||||||
|
|
||||||
val totalCount = query.count()
|
val totalCount = query.count()
|
||||||
|
|
||||||
val items =
|
val rawItems =
|
||||||
query
|
query
|
||||||
.orderBy(ChapterTable.fetchedAt to SortOrder.DESC, ChapterTable.sourceOrder to SortOrder.DESC)
|
.orderBy(ChapterTable.fetchedAt to SortOrder.DESC, ChapterTable.sourceOrder to SortOrder.DESC)
|
||||||
.limit(opdsItemsPerPageBounded)
|
.limit(opdsItemsPerPageBounded)
|
||||||
.offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong())
|
.offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong())
|
||||||
.map {
|
.toList()
|
||||||
OpdsLibraryUpdateAcqEntry(
|
|
||||||
chapter = it.toOpdsChapterListAcqEntry(),
|
val mangaIds = rawItems.map { it[MangaTable.id].value }.distinct()
|
||||||
mangaTitle = it[MangaTable.title],
|
val chapterCounts =
|
||||||
mangaAuthor = it[MangaTable.author],
|
if (mangaIds.isNotEmpty()) {
|
||||||
mangaId = it[MangaTable.id].value,
|
ChapterTable
|
||||||
mangaSourceLang = it[SourceTable.lang],
|
.select(ChapterTable.manga, ChapterTable.id.count())
|
||||||
mangaThumbnailUrl = it[MangaTable.thumbnail_url],
|
.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,
|
||||||
|
)
|
||||||
|
}
|
||||||
Pair(items, totalCount)
|
Pair(items, totalCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,21 +272,38 @@ object ChapterRepository {
|
|||||||
|
|
||||||
val totalCount = query.count()
|
val totalCount = query.count()
|
||||||
|
|
||||||
val items =
|
val rawItems =
|
||||||
query
|
query
|
||||||
.orderBy(ChapterTable.lastReadAt to SortOrder.DESC)
|
.orderBy(ChapterTable.lastReadAt to SortOrder.DESC)
|
||||||
.limit(opdsItemsPerPageBounded)
|
.limit(opdsItemsPerPageBounded)
|
||||||
.offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong())
|
.offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong())
|
||||||
.map {
|
.toList()
|
||||||
OpdsHistoryAcqEntry(
|
|
||||||
chapter = it.toOpdsChapterListAcqEntry(),
|
val mangaIds = rawItems.map { it[MangaTable.id].value }.distinct()
|
||||||
mangaTitle = it[MangaTable.title],
|
val chapterCounts =
|
||||||
mangaAuthor = it[MangaTable.author],
|
if (mangaIds.isNotEmpty()) {
|
||||||
mangaId = it[MangaTable.id].value,
|
ChapterTable
|
||||||
mangaSourceLang = it[SourceTable.lang],
|
.select(ChapterTable.manga, ChapterTable.id.count())
|
||||||
mangaThumbnailUrl = it[MangaTable.thumbnail_url],
|
.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,
|
||||||
|
)
|
||||||
|
}
|
||||||
Pair(items, totalCount)
|
Pair(items, totalCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import org.jetbrains.exposed.v1.core.and
|
|||||||
import org.jetbrains.exposed.v1.core.eq
|
import org.jetbrains.exposed.v1.core.eq
|
||||||
import org.jetbrains.exposed.v1.core.greater
|
import org.jetbrains.exposed.v1.core.greater
|
||||||
import org.jetbrains.exposed.v1.core.inList
|
import org.jetbrains.exposed.v1.core.inList
|
||||||
|
import org.jetbrains.exposed.v1.core.inSubQuery
|
||||||
import org.jetbrains.exposed.v1.core.intLiteral
|
import org.jetbrains.exposed.v1.core.intLiteral
|
||||||
import org.jetbrains.exposed.v1.core.like
|
import org.jetbrains.exposed.v1.core.like
|
||||||
import org.jetbrains.exposed.v1.core.lowerCase
|
import org.jetbrains.exposed.v1.core.lowerCase
|
||||||
@@ -36,8 +37,72 @@ import suwayomi.tachidesk.opds.dto.OpdsMangaDetails
|
|||||||
import suwayomi.tachidesk.opds.dto.OpdsMangaFilter
|
import suwayomi.tachidesk.opds.dto.OpdsMangaFilter
|
||||||
import suwayomi.tachidesk.opds.dto.OpdsSearchCriteria
|
import suwayomi.tachidesk.opds.dto.OpdsSearchCriteria
|
||||||
import suwayomi.tachidesk.opds.dto.PrimaryFilterType
|
import suwayomi.tachidesk.opds.dto.PrimaryFilterType
|
||||||
|
import suwayomi.tachidesk.opds.util.OpdsStringUtil.formatSourceName
|
||||||
import suwayomi.tachidesk.server.serverConfig
|
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 ->
|
||||||
|
when (filterVal) {
|
||||||
|
"unread" -> {
|
||||||
|
andWhere {
|
||||||
|
MangaTable.id inSubQuery
|
||||||
|
ChapterTable.select(ChapterTable.manga).where { ChapterTable.isRead eq false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
"downloaded" -> {
|
||||||
|
andWhere {
|
||||||
|
MangaTable.id inSubQuery
|
||||||
|
ChapterTable.select(ChapterTable.manga).where { ChapterTable.isDownloaded eq true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
"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.
|
* Repository for fetching manga data tailored for OPDS feeds.
|
||||||
*/
|
*/
|
||||||
@@ -60,53 +125,48 @@ object MangaRepository {
|
|||||||
sourceLang = this[SourceTable.lang],
|
sourceLang = this[SourceTable.lang],
|
||||||
inLibrary = this[MangaTable.inLibrary],
|
inLibrary = this[MangaTable.inLibrary],
|
||||||
status = this[MangaTable.status],
|
status = this[MangaTable.status],
|
||||||
sourceName = this[SourceTable.name],
|
sourceName = formatSourceName(this[SourceTable.name], this[SourceTable.lang]),
|
||||||
lastFetchedAt = this[MangaTable.lastFetchedAt],
|
lastFetchedAt = this[MangaTable.lastFetchedAt],
|
||||||
url = this[MangaTable.realUrl],
|
url = this[MangaTable.realUrl],
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Centralized function to retrieve paginated, sorted, and filtered manga from the library.
|
* 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 pageNum The page number for pagination.
|
||||||
* @param sort The sorting parameter.
|
* @param sort The sorting parameter.
|
||||||
* @param filter The filtering 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.
|
* @return An [OpdsLibraryFeedResult] containing the list of manga, total count, and the specific filter name.
|
||||||
*/
|
*/
|
||||||
fun getLibraryManga(
|
fun getLibraryManga(
|
||||||
|
criteria: OpdsMangaFilter,
|
||||||
pageNum: Int,
|
pageNum: Int,
|
||||||
sort: String?,
|
sort: String?,
|
||||||
filter: String?,
|
filter: String?,
|
||||||
criteria: OpdsMangaFilter,
|
|
||||||
): OpdsLibraryFeedResult =
|
): OpdsLibraryFeedResult =
|
||||||
transaction {
|
transaction {
|
||||||
val unreadCountExpr = Case().When(ChapterTable.isRead eq false, intLiteral(1)).Else(intLiteral(0)).sum()
|
val unreadCountExpr = Case().When(ChapterTable.isRead eq false, intLiteral(1)).Else(intLiteral(0)).sum()
|
||||||
val unreadCount = unreadCountExpr.alias("unread_count")
|
val unreadCount = unreadCountExpr.alias("unread_count")
|
||||||
|
|
||||||
// Base query with necessary joins for filtering and sorting
|
// Base query with necessary joins for filtering and sorting
|
||||||
val query =
|
var baseJoin =
|
||||||
MangaTable
|
MangaTable
|
||||||
.join(SourceTable, JoinType.INNER, MangaTable.sourceReference, SourceTable.id)
|
.join(SourceTable, JoinType.INNER, MangaTable.sourceReference, SourceTable.id)
|
||||||
.join(ChapterTable, JoinType.LEFT, MangaTable.id, ChapterTable.manga)
|
.join(ChapterTable, JoinType.LEFT, MangaTable.id, ChapterTable.manga)
|
||||||
.join(CategoryMangaTable, JoinType.LEFT, MangaTable.id, CategoryMangaTable.manga)
|
|
||||||
|
if (criteria.categoryId != null) {
|
||||||
|
baseJoin = baseJoin.join(CategoryMangaTable, JoinType.LEFT, MangaTable.id, CategoryMangaTable.manga)
|
||||||
|
}
|
||||||
|
|
||||||
|
val query =
|
||||||
|
baseJoin
|
||||||
.select(MangaTable.columns + SourceTable.lang + SourceTable.name + unreadCount)
|
.select(MangaTable.columns + SourceTable.lang + SourceTable.name + unreadCount)
|
||||||
.where { MangaTable.inLibrary eq true }
|
.where { MangaTable.inLibrary eq true }
|
||||||
.groupBy(MangaTable.id, SourceTable.lang, SourceTable.name)
|
|
||||||
|
|
||||||
// Apply specific filters from criteria
|
query.applyOpdsMangaFilter(criteria)
|
||||||
criteria.sourceId?.let { query.andWhere { MangaTable.sourceReference eq it } }
|
applyMangaLibrarySort(query, sort)
|
||||||
criteria.categoryId?.let { query.andWhere { CategoryMangaTable.category eq it } }
|
|
||||||
criteria.statusId?.let { query.andWhere { MangaTable.status eq it } }
|
query.groupBy(MangaTable.id, SourceTable.lang, SourceTable.name)
|
||||||
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
|
// Efficiently get the name of the primary filter item
|
||||||
val specificFilterName =
|
val specificFilterName =
|
||||||
@@ -114,10 +174,10 @@ object MangaRepository {
|
|||||||
PrimaryFilterType.SOURCE -> {
|
PrimaryFilterType.SOURCE -> {
|
||||||
criteria.sourceId?.let {
|
criteria.sourceId?.let {
|
||||||
SourceTable
|
SourceTable
|
||||||
.select(SourceTable.name)
|
.select(SourceTable.name, SourceTable.lang)
|
||||||
.where { SourceTable.id eq it }
|
.where { SourceTable.id eq it }
|
||||||
.firstOrNull()
|
.firstOrNull()
|
||||||
?.get(SourceTable.name)
|
?.let { formatSourceName(it[SourceTable.name], it[SourceTable.lang]) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,8 +210,6 @@ object MangaRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
applyMangaLibrarySortAndFilter(query, sort, filter)
|
|
||||||
|
|
||||||
val totalCount = query.count()
|
val totalCount = query.count()
|
||||||
val mangas =
|
val mangas =
|
||||||
query
|
query
|
||||||
@@ -245,6 +303,7 @@ object MangaRepository {
|
|||||||
*/
|
*/
|
||||||
fun getMangaDetails(mangaId: Int): OpdsMangaDetails? =
|
fun getMangaDetails(mangaId: Int): OpdsMangaDetails? =
|
||||||
transaction {
|
transaction {
|
||||||
|
val chapterCount = ChapterTable.select(ChapterTable.id).where { ChapterTable.manga eq mangaId }.count()
|
||||||
MangaTable
|
MangaTable
|
||||||
.select(MangaTable.id, MangaTable.title, MangaTable.thumbnail_url, MangaTable.author)
|
.select(MangaTable.id, MangaTable.title, MangaTable.thumbnail_url, MangaTable.author)
|
||||||
.where { MangaTable.id eq mangaId }
|
.where { MangaTable.id eq mangaId }
|
||||||
@@ -255,6 +314,7 @@ object MangaRepository {
|
|||||||
title = it[MangaTable.title],
|
title = it[MangaTable.title],
|
||||||
thumbnailUrl = it[MangaTable.thumbnail_url],
|
thumbnailUrl = it[MangaTable.thumbnail_url],
|
||||||
author = it[MangaTable.author],
|
author = it[MangaTable.author],
|
||||||
|
totalChapters = chapterCount,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -263,26 +323,15 @@ object MangaRepository {
|
|||||||
* Applies sorting and filtering logic to a manga library query.
|
* Applies sorting and filtering logic to a manga library query.
|
||||||
* @param query The Exposed SQL query to modify.
|
* @param query The Exposed SQL query to modify.
|
||||||
* @param sort The sorting parameter.
|
* @param sort The sorting parameter.
|
||||||
* @param filter The filtering parameter.
|
|
||||||
*/
|
*/
|
||||||
private fun applyMangaLibrarySortAndFilter(
|
private fun applyMangaLibrarySort(
|
||||||
query: Query,
|
query: Query,
|
||||||
sort: String?,
|
sort: String?,
|
||||||
filter: String?,
|
|
||||||
) {
|
) {
|
||||||
val unreadCountExpr = Case().When(ChapterTable.isRead eq false, intLiteral(1)).Else(intLiteral(0)).sum()
|
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 lastReadAtExpr = ChapterTable.lastReadAt.max()
|
||||||
val latestChapterDateExpr = ChapterTable.date_upload.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
|
// Apply sorting
|
||||||
when (sort) {
|
when (sort) {
|
||||||
"alpha_asc" -> query.orderBy(MangaTable.title to SortOrder.ASC)
|
"alpha_asc" -> query.orderBy(MangaTable.title to SortOrder.ASC)
|
||||||
@@ -296,27 +345,44 @@ object MangaRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculates the count of manga for various library filter facets.
|
* 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.
|
||||||
* @return A map where keys are filter names and values are the counts.
|
* @return A map where keys are filter names and values are the counts.
|
||||||
*/
|
*/
|
||||||
fun getLibraryFilterCounts(): Map<String, Long> =
|
fun getLibraryFilterCounts(activeFilters: OpdsMangaFilter): Map<String, Long> =
|
||||||
transaction {
|
transaction {
|
||||||
val unreadCountExpr = Case().When(ChapterTable.isRead eq false, intLiteral(1)).Else(intLiteral(0)).sum()
|
var baseJoin =
|
||||||
val downloadedCountExpr = Case().When(ChapterTable.isDownloaded eq true, intLiteral(1)).Else(intLiteral(0)).sum()
|
MangaTable
|
||||||
|
.join(SourceTable, JoinType.INNER, MangaTable.sourceReference, SourceTable.id)
|
||||||
|
|
||||||
|
if (activeFilters.categoryId != null) {
|
||||||
|
baseJoin = baseJoin.join(CategoryMangaTable, JoinType.LEFT, MangaTable.id, CategoryMangaTable.manga)
|
||||||
|
}
|
||||||
|
|
||||||
val baseQuery =
|
val baseQuery =
|
||||||
MangaTable
|
baseJoin
|
||||||
.join(ChapterTable, JoinType.LEFT, MangaTable.id, ChapterTable.manga)
|
|
||||||
.select(MangaTable.id)
|
.select(MangaTable.id)
|
||||||
.where { MangaTable.inLibrary eq true }
|
.where { MangaTable.inLibrary eq true }
|
||||||
.groupBy(MangaTable.id)
|
.withDistinct()
|
||||||
|
|
||||||
val unreadCount = baseQuery.copy().having { unreadCountExpr greater 0 }.count()
|
baseQuery.applyOpdsMangaFilter(activeFilters, excludeField = "filter")
|
||||||
val downloadedCount = baseQuery.copy().having { downloadedCountExpr greater 0 }.count()
|
|
||||||
|
|
||||||
val statusBaseQuery = MangaTable.select(MangaTable.id).where { MangaTable.inLibrary eq true }
|
val unreadCount =
|
||||||
val ongoingCount = statusBaseQuery.copy().andWhere { MangaTable.status eq MangaStatus.ONGOING.value }.count()
|
baseQuery
|
||||||
val completedCount = statusBaseQuery.copy().andWhere { MangaTable.status eq MangaStatus.COMPLETED.value }.count()
|
.copy()
|
||||||
|
.andWhere {
|
||||||
|
MangaTable.id inSubQuery
|
||||||
|
ChapterTable.select(ChapterTable.manga).where { ChapterTable.isRead eq false }
|
||||||
|
}.count()
|
||||||
|
val downloadedCount =
|
||||||
|
baseQuery
|
||||||
|
.copy()
|
||||||
|
.andWhere {
|
||||||
|
MangaTable.id inSubQuery
|
||||||
|
ChapterTable.select(ChapterTable.manga).where { ChapterTable.isDownloaded eq true }
|
||||||
|
}.count()
|
||||||
|
val ongoingCount = baseQuery.copy().andWhere { MangaTable.status eq MangaStatus.ONGOING.value }.count()
|
||||||
|
val completedCount = baseQuery.copy().andWhere { MangaTable.status eq MangaStatus.COMPLETED.value }.count()
|
||||||
|
|
||||||
mapOf(
|
mapOf(
|
||||||
"unread" to unreadCount,
|
"unread" to unreadCount,
|
||||||
|
|||||||
@@ -21,10 +21,12 @@ import suwayomi.tachidesk.opds.constants.OpdsConstants
|
|||||||
import suwayomi.tachidesk.opds.dto.OpdsCategoryNavEntry
|
import suwayomi.tachidesk.opds.dto.OpdsCategoryNavEntry
|
||||||
import suwayomi.tachidesk.opds.dto.OpdsGenreNavEntry
|
import suwayomi.tachidesk.opds.dto.OpdsGenreNavEntry
|
||||||
import suwayomi.tachidesk.opds.dto.OpdsLanguageNavEntry
|
import suwayomi.tachidesk.opds.dto.OpdsLanguageNavEntry
|
||||||
|
import suwayomi.tachidesk.opds.dto.OpdsMangaFilter
|
||||||
import suwayomi.tachidesk.opds.dto.OpdsRootNavEntry
|
import suwayomi.tachidesk.opds.dto.OpdsRootNavEntry
|
||||||
import suwayomi.tachidesk.opds.dto.OpdsSourceNavEntry
|
import suwayomi.tachidesk.opds.dto.OpdsSourceNavEntry
|
||||||
import suwayomi.tachidesk.opds.dto.OpdsStatusNavEntry
|
import suwayomi.tachidesk.opds.dto.OpdsStatusNavEntry
|
||||||
import suwayomi.tachidesk.opds.util.OpdsStringUtil.encodeForOpdsURL
|
import suwayomi.tachidesk.opds.util.OpdsStringUtil.encodeForOpdsURL
|
||||||
|
import suwayomi.tachidesk.opds.util.OpdsStringUtil.formatSourceName
|
||||||
import suwayomi.tachidesk.server.serverConfig
|
import suwayomi.tachidesk.server.serverConfig
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
@@ -131,15 +133,14 @@ object NavigationRepository {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ... (El resto del archivo permanece sin cambios)
|
|
||||||
fun getExploreSources(pageNum: Int): Pair<List<OpdsSourceNavEntry>, Long> =
|
fun getExploreSources(pageNum: Int): Pair<List<OpdsSourceNavEntry>, Long> =
|
||||||
transaction {
|
transaction {
|
||||||
val query =
|
val query =
|
||||||
SourceTable
|
SourceTable
|
||||||
.join(ExtensionTable, JoinType.LEFT, onColumn = SourceTable.extension, otherColumn = ExtensionTable.id)
|
.join(ExtensionTable, JoinType.LEFT, onColumn = SourceTable.extension, otherColumn = ExtensionTable.id)
|
||||||
.select(SourceTable.id, SourceTable.name, ExtensionTable.apkName)
|
.select(SourceTable.id, SourceTable.name, SourceTable.lang, ExtensionTable.apkName)
|
||||||
.where { ExtensionTable.isInstalled eq true }
|
.where { ExtensionTable.isInstalled eq true }
|
||||||
.groupBy(SourceTable.id, SourceTable.name, ExtensionTable.apkName)
|
.groupBy(SourceTable.id, SourceTable.name, SourceTable.lang, ExtensionTable.apkName)
|
||||||
.orderBy(SourceTable.name to SortOrder.ASC)
|
.orderBy(SourceTable.name to SortOrder.ASC)
|
||||||
|
|
||||||
val totalCount = query.count()
|
val totalCount = query.count()
|
||||||
@@ -150,7 +151,7 @@ object NavigationRepository {
|
|||||||
.map {
|
.map {
|
||||||
OpdsSourceNavEntry(
|
OpdsSourceNavEntry(
|
||||||
id = it[SourceTable.id].value,
|
id = it[SourceTable.id].value,
|
||||||
name = it[SourceTable.name],
|
name = formatSourceName(it[SourceTable.name], it[SourceTable.lang]),
|
||||||
iconUrl = it[ExtensionTable.apkName].let { apkName -> Extension.getExtensionIconUrl(apkName) },
|
iconUrl = it[ExtensionTable.apkName].let { apkName -> Extension.getExtensionIconUrl(apkName) },
|
||||||
mangaCount = null,
|
mangaCount = null,
|
||||||
)
|
)
|
||||||
@@ -158,36 +159,71 @@ object NavigationRepository {
|
|||||||
Pair(sources, totalCount)
|
Pair(sources, totalCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getLibrarySources(pageNum: Int): Pair<List<OpdsSourceNavEntry>, Long> =
|
fun getLibrarySources(
|
||||||
|
pageNum: Int? = null,
|
||||||
|
activeFilters: OpdsMangaFilter = OpdsMangaFilter(),
|
||||||
|
): Pair<List<OpdsSourceNavEntry>, Long> =
|
||||||
transaction {
|
transaction {
|
||||||
val mangaCount = MangaTable.id.countDistinct().alias("manga_count")
|
val mangaCount = MangaTable.id.countDistinct().alias("manga_count")
|
||||||
|
|
||||||
val query =
|
var baseJoin =
|
||||||
SourceTable
|
SourceTable
|
||||||
.join(MangaTable, JoinType.INNER, SourceTable.id, MangaTable.sourceReference)
|
.join(MangaTable, JoinType.INNER, SourceTable.id, MangaTable.sourceReference)
|
||||||
.join(ExtensionTable, JoinType.LEFT, onColumn = SourceTable.extension, otherColumn = ExtensionTable.id)
|
.join(ExtensionTable, JoinType.LEFT, onColumn = SourceTable.extension, otherColumn = ExtensionTable.id)
|
||||||
.select(SourceTable.id, SourceTable.name, ExtensionTable.apkName, mangaCount)
|
|
||||||
|
if (activeFilters.categoryId != null) {
|
||||||
|
baseJoin = baseJoin.join(CategoryMangaTable, JoinType.LEFT, MangaTable.id, CategoryMangaTable.manga)
|
||||||
|
}
|
||||||
|
|
||||||
|
val query =
|
||||||
|
baseJoin
|
||||||
|
.select(SourceTable.id, SourceTable.name, SourceTable.lang, ExtensionTable.apkName, mangaCount)
|
||||||
.where { MangaTable.inLibrary eq true }
|
.where { MangaTable.inLibrary eq true }
|
||||||
.groupBy(SourceTable.id, SourceTable.name, ExtensionTable.apkName)
|
|
||||||
.orderBy(SourceTable.name to SortOrder.ASC)
|
query.applyOpdsMangaFilter(activeFilters, excludeField = "source_id")
|
||||||
|
|
||||||
|
query
|
||||||
|
.groupBy(SourceTable.id, SourceTable.name, SourceTable.lang, ExtensionTable.apkName)
|
||||||
|
.orderBy(SourceTable.name to SortOrder.ASC)
|
||||||
|
|
||||||
val totalCount = query.count()
|
val totalCount = query.count()
|
||||||
val sources =
|
|
||||||
|
if (pageNum != null) {
|
||||||
query
|
query
|
||||||
.limit(opdsItemsPerPageBounded)
|
.limit(opdsItemsPerPageBounded)
|
||||||
.offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong())
|
.offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong())
|
||||||
.map {
|
}
|
||||||
OpdsSourceNavEntry(
|
|
||||||
id = it[SourceTable.id].value,
|
val sources =
|
||||||
name = it[SourceTable.name],
|
query.map {
|
||||||
iconUrl = it[ExtensionTable.apkName].let { apkName -> Extension.getExtensionIconUrl(apkName) },
|
OpdsSourceNavEntry(
|
||||||
mangaCount = it[mangaCount],
|
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],
|
||||||
|
)
|
||||||
|
}
|
||||||
Pair(sources, totalCount)
|
Pair(sources, totalCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getCategories(pageNum: Int): Pair<List<OpdsCategoryNavEntry>, Long> =
|
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> =
|
||||||
transaction {
|
transaction {
|
||||||
val mangaCount = MangaTable.id.countDistinct().alias("manga_count")
|
val mangaCount = MangaTable.id.countDistinct().alias("manga_count")
|
||||||
|
|
||||||
@@ -195,35 +231,57 @@ object NavigationRepository {
|
|||||||
CategoryTable
|
CategoryTable
|
||||||
.join(CategoryMangaTable, JoinType.INNER, CategoryTable.id, CategoryMangaTable.category)
|
.join(CategoryMangaTable, JoinType.INNER, CategoryTable.id, CategoryMangaTable.category)
|
||||||
.join(MangaTable, JoinType.INNER, CategoryMangaTable.manga, MangaTable.id)
|
.join(MangaTable, JoinType.INNER, CategoryMangaTable.manga, MangaTable.id)
|
||||||
|
.join(SourceTable, JoinType.INNER, MangaTable.sourceReference, SourceTable.id)
|
||||||
.select(CategoryTable.id, CategoryTable.name, mangaCount)
|
.select(CategoryTable.id, CategoryTable.name, mangaCount)
|
||||||
.where { MangaTable.inLibrary eq true }
|
.where { MangaTable.inLibrary eq true }
|
||||||
.groupBy(CategoryTable.id, CategoryTable.name)
|
|
||||||
.orderBy(CategoryTable.order to SortOrder.ASC)
|
query.applyOpdsMangaFilter(activeFilters, excludeField = "category_id")
|
||||||
|
|
||||||
|
query
|
||||||
|
.groupBy(CategoryTable.id, CategoryTable.name)
|
||||||
|
.orderBy(CategoryTable.order to SortOrder.ASC)
|
||||||
|
|
||||||
val totalCount = query.count()
|
val totalCount = query.count()
|
||||||
val categories =
|
|
||||||
|
if (pageNum != null) {
|
||||||
query
|
query
|
||||||
.limit(opdsItemsPerPageBounded)
|
.limit(opdsItemsPerPageBounded)
|
||||||
.offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong())
|
.offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong())
|
||||||
.map {
|
}
|
||||||
OpdsCategoryNavEntry(
|
|
||||||
id = it[CategoryTable.id].value,
|
val categories =
|
||||||
name = it[CategoryTable.name],
|
query.map {
|
||||||
mangaCount = it[mangaCount],
|
OpdsCategoryNavEntry(
|
||||||
)
|
id = it[CategoryTable.id].value,
|
||||||
}
|
name = it[CategoryTable.name],
|
||||||
|
mangaCount = it[mangaCount],
|
||||||
|
)
|
||||||
|
}
|
||||||
Pair(categories, totalCount)
|
Pair(categories, totalCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getGenres(
|
fun getGenres(
|
||||||
pageNum: Int,
|
|
||||||
locale: Locale,
|
locale: Locale,
|
||||||
|
pageNum: Int? = null,
|
||||||
|
activeFilters: OpdsMangaFilter = OpdsMangaFilter(),
|
||||||
): Pair<List<OpdsGenreNavEntry>, Long> =
|
): Pair<List<OpdsGenreNavEntry>, Long> =
|
||||||
transaction {
|
transaction {
|
||||||
val allGenres =
|
var baseJoin =
|
||||||
MangaTable
|
MangaTable
|
||||||
|
.join(SourceTable, JoinType.INNER, MangaTable.sourceReference, SourceTable.id)
|
||||||
|
if (activeFilters.categoryId != null) {
|
||||||
|
baseJoin = baseJoin.join(CategoryMangaTable, JoinType.LEFT, MangaTable.id, CategoryMangaTable.manga)
|
||||||
|
}
|
||||||
|
|
||||||
|
val query =
|
||||||
|
baseJoin
|
||||||
.select(MangaTable.genre)
|
.select(MangaTable.genre)
|
||||||
.where { MangaTable.inLibrary eq true }
|
.where { MangaTable.inLibrary eq true }
|
||||||
|
|
||||||
|
query.applyOpdsMangaFilter(activeFilters, excludeField = "genre")
|
||||||
|
|
||||||
|
val allGenres =
|
||||||
|
query
|
||||||
.mapNotNull { it[MangaTable.genre] }
|
.mapNotNull { it[MangaTable.genre] }
|
||||||
.flatMap { it.split(",").map(String::trim).filterNot(String::isBlank) }
|
.flatMap { it.split(",").map(String::trim).filterNot(String::isBlank) }
|
||||||
|
|
||||||
@@ -231,21 +289,32 @@ object NavigationRepository {
|
|||||||
val distinctGenres = genreCounts.keys.sorted()
|
val distinctGenres = genreCounts.keys.sorted()
|
||||||
|
|
||||||
val totalCount = distinctGenres.size.toLong()
|
val totalCount = distinctGenres.size.toLong()
|
||||||
val fromIndex = ((pageNum - 1) * opdsItemsPerPageBounded)
|
|
||||||
val toIndex = minOf(fromIndex + opdsItemsPerPageBounded, distinctGenres.size)
|
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 paginatedGenres =
|
val paginatedGenres =
|
||||||
(if (fromIndex < distinctGenres.size) distinctGenres.subList(fromIndex, toIndex) else emptyList())
|
finalGenres.map { genreName ->
|
||||||
.map { genreName ->
|
OpdsGenreNavEntry(
|
||||||
OpdsGenreNavEntry(
|
id = genreName.encodeForOpdsURL(),
|
||||||
id = genreName.encodeForOpdsURL(),
|
title = genreName,
|
||||||
title = genreName,
|
mangaCount = genreCounts[genreName]?.toLong() ?: 0L,
|
||||||
mangaCount = genreCounts[genreName]?.toLong() ?: 0L,
|
)
|
||||||
)
|
}
|
||||||
}
|
|
||||||
Pair(paginatedGenres, totalCount)
|
Pair(paginatedGenres, totalCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getStatuses(locale: Locale): List<OpdsStatusNavEntry> {
|
fun getStatuses(
|
||||||
|
locale: Locale,
|
||||||
|
pageNum: Int? = null,
|
||||||
|
activeFilters: OpdsMangaFilter = OpdsMangaFilter(),
|
||||||
|
): Pair<List<OpdsStatusNavEntry>, Long> {
|
||||||
val statusStringResources: Map<MangaStatus, StringResource> =
|
val statusStringResources: Map<MangaStatus, StringResource> =
|
||||||
mapOf(
|
mapOf(
|
||||||
MangaStatus.UNKNOWN to MR.strings.manga_status_unknown,
|
MangaStatus.UNKNOWN to MR.strings.manga_status_unknown,
|
||||||
@@ -259,43 +328,98 @@ object NavigationRepository {
|
|||||||
|
|
||||||
val statusCounts =
|
val statusCounts =
|
||||||
transaction {
|
transaction {
|
||||||
MangaTable
|
val countExpr = MangaTable.id.countDistinct().alias("manga_count")
|
||||||
.select(MangaTable.status, MangaTable.id.count())
|
|
||||||
.where { MangaTable.inLibrary eq true }
|
var baseJoin =
|
||||||
|
MangaTable
|
||||||
|
.join(SourceTable, JoinType.INNER, MangaTable.sourceReference, SourceTable.id)
|
||||||
|
if (activeFilters.categoryId != null) {
|
||||||
|
baseJoin = baseJoin.join(CategoryMangaTable, JoinType.LEFT, MangaTable.id, CategoryMangaTable.manga)
|
||||||
|
}
|
||||||
|
|
||||||
|
val query =
|
||||||
|
baseJoin
|
||||||
|
.select(MangaTable.status, countExpr)
|
||||||
|
.where { MangaTable.inLibrary eq true }
|
||||||
|
|
||||||
|
query.applyOpdsMangaFilter(activeFilters, excludeField = "status_id")
|
||||||
|
|
||||||
|
query
|
||||||
.groupBy(MangaTable.status)
|
.groupBy(MangaTable.status)
|
||||||
.associate { it[MangaTable.status] to it[MangaTable.id.count()] }
|
.associate { it[MangaTable.status] to it[countExpr] }
|
||||||
}
|
}
|
||||||
|
|
||||||
return MangaStatus.entries
|
val allStatuses =
|
||||||
.map { mangaStatus ->
|
MangaStatus.entries
|
||||||
val titleRes = statusStringResources[mangaStatus] ?: MR.strings.manga_status_unknown
|
.map { mangaStatus ->
|
||||||
OpdsStatusNavEntry(
|
val titleRes = statusStringResources[mangaStatus] ?: MR.strings.manga_status_unknown
|
||||||
id = mangaStatus.value,
|
OpdsStatusNavEntry(
|
||||||
title = titleRes.localized(locale),
|
id = mangaStatus.value,
|
||||||
mangaCount = statusCounts[mangaStatus.value] ?: 0L,
|
title = titleRes.localized(locale),
|
||||||
)
|
mangaCount = statusCounts[mangaStatus.value] ?: 0L,
|
||||||
}.sortedBy { it.id }
|
)
|
||||||
|
}.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)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getContentLanguages(uiLocale: Locale): List<OpdsLanguageNavEntry> =
|
fun getContentLanguages(
|
||||||
|
locale: Locale,
|
||||||
|
pageNum: Int? = null,
|
||||||
|
activeFilters: OpdsMangaFilter = OpdsMangaFilter(),
|
||||||
|
): Pair<List<OpdsLanguageNavEntry>, Long> =
|
||||||
transaction {
|
transaction {
|
||||||
val mangaCount = MangaTable.id.countDistinct().alias("manga_count")
|
val mangaCount = MangaTable.id.countDistinct().alias("manga_count")
|
||||||
SourceTable
|
|
||||||
.join(MangaTable, JoinType.INNER, SourceTable.id, MangaTable.sourceReference)
|
var baseJoin =
|
||||||
.select(SourceTable.lang, mangaCount)
|
SourceTable
|
||||||
.where { MangaTable.inLibrary eq true }
|
.join(MangaTable, JoinType.INNER, SourceTable.id, MangaTable.sourceReference)
|
||||||
|
if (activeFilters.categoryId != null) {
|
||||||
|
baseJoin = baseJoin.join(CategoryMangaTable, JoinType.LEFT, MangaTable.id, CategoryMangaTable.manga)
|
||||||
|
}
|
||||||
|
|
||||||
|
val query =
|
||||||
|
baseJoin
|
||||||
|
.select(SourceTable.lang, mangaCount)
|
||||||
|
.where { MangaTable.inLibrary eq true }
|
||||||
|
|
||||||
|
query.applyOpdsMangaFilter(activeFilters, excludeField = "lang_code")
|
||||||
|
|
||||||
|
query
|
||||||
.groupBy(SourceTable.lang)
|
.groupBy(SourceTable.lang)
|
||||||
.orderBy(SourceTable.lang to SortOrder.ASC)
|
.orderBy(SourceTable.lang to SortOrder.ASC)
|
||||||
.map {
|
|
||||||
|
val totalCount = query.count()
|
||||||
|
|
||||||
|
if (pageNum != null) {
|
||||||
|
query
|
||||||
|
.limit(opdsItemsPerPageBounded)
|
||||||
|
.offset(((pageNum - 1) * opdsItemsPerPageBounded).toLong())
|
||||||
|
}
|
||||||
|
|
||||||
|
val languages =
|
||||||
|
query.map {
|
||||||
val langCode = it[SourceTable.lang]
|
val langCode = it[SourceTable.lang]
|
||||||
OpdsLanguageNavEntry(
|
OpdsLanguageNavEntry(
|
||||||
id = langCode,
|
id = langCode,
|
||||||
title =
|
title =
|
||||||
Locale.forLanguageTag(langCode).getDisplayName(uiLocale).replaceFirstChar { char ->
|
Locale.forLanguageTag(langCode).getDisplayName(locale).replaceFirstChar { char ->
|
||||||
if (char.isLowerCase()) char.titlecase(uiLocale) else char.toString()
|
if (char.isLowerCase()) char.titlecase(locale) else char.toString()
|
||||||
},
|
},
|
||||||
mangaCount = it[mangaCount],
|
mangaCount = it[mangaCount],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
Pair(languages, totalCount)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,19 @@ object OpdsStringUtil {
|
|||||||
return slug
|
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.
|
* 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.
|
* Uses binary (KiB, MiB, GiB, TiB) or decimal (KB, MB, GB, TB) units based on server configuration.
|
||||||
|
|||||||
@@ -14,15 +14,13 @@ import com.typesafe.config.ConfigException
|
|||||||
import com.typesafe.config.ConfigRenderOptions
|
import com.typesafe.config.ConfigRenderOptions
|
||||||
import com.typesafe.config.ConfigValue
|
import com.typesafe.config.ConfigValue
|
||||||
import com.typesafe.config.parser.ConfigDocument
|
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.App
|
||||||
import eu.kanade.tachiyomi.createAppModule
|
import eu.kanade.tachiyomi.createAppModule
|
||||||
import eu.kanade.tachiyomi.network.NetworkHelper
|
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||||
import eu.kanade.tachiyomi.source.local.LocalSource
|
import eu.kanade.tachiyomi.source.local.LocalSource
|
||||||
import io.github.config4k.toConfig
|
import io.github.config4k.toConfig
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
import io.javalin.json.JavalinJackson
|
import io.javalin.json.JavalinJackson3
|
||||||
import io.javalin.json.JsonMapper
|
import io.javalin.json.JsonMapper
|
||||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||||
import kotlinx.coroutines.GlobalScope
|
import kotlinx.coroutines.GlobalScope
|
||||||
@@ -38,6 +36,7 @@ import org.koin.core.context.startKoin
|
|||||||
import org.koin.core.module.Module
|
import org.koin.core.module.Module
|
||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
import suwayomi.tachidesk.global.impl.KcefWebView.Companion.toCefCookie
|
import suwayomi.tachidesk.global.impl.KcefWebView.Companion.toCefCookie
|
||||||
|
import suwayomi.tachidesk.global.impl.sync.SyncManager
|
||||||
import suwayomi.tachidesk.graphql.types.DatabaseType
|
import suwayomi.tachidesk.graphql.types.DatabaseType
|
||||||
import suwayomi.tachidesk.i18n.LocalizationHelper
|
import suwayomi.tachidesk.i18n.LocalizationHelper
|
||||||
import suwayomi.tachidesk.manga.impl.backup.proto.ProtoBackupExport
|
import suwayomi.tachidesk.manga.impl.backup.proto.ProtoBackupExport
|
||||||
@@ -49,6 +48,7 @@ import suwayomi.tachidesk.server.database.databaseUp
|
|||||||
import suwayomi.tachidesk.server.generated.BuildConfig
|
import suwayomi.tachidesk.server.generated.BuildConfig
|
||||||
import suwayomi.tachidesk.server.settings.SettingsRegistry
|
import suwayomi.tachidesk.server.settings.SettingsRegistry
|
||||||
import suwayomi.tachidesk.server.util.AppMutex.handleAppMutex
|
import suwayomi.tachidesk.server.util.AppMutex.handleAppMutex
|
||||||
|
import suwayomi.tachidesk.server.util.CEFManager
|
||||||
import suwayomi.tachidesk.server.util.ConfigTypeRegistration
|
import suwayomi.tachidesk.server.util.ConfigTypeRegistration
|
||||||
import suwayomi.tachidesk.server.util.ExitCode
|
import suwayomi.tachidesk.server.util.ExitCode
|
||||||
import suwayomi.tachidesk.server.util.SystemTray
|
import suwayomi.tachidesk.server.util.SystemTray
|
||||||
@@ -71,11 +71,6 @@ import java.net.Authenticator
|
|||||||
import java.net.PasswordAuthentication
|
import java.net.PasswordAuthentication
|
||||||
import java.security.Security
|
import java.security.Security
|
||||||
import java.util.Locale
|
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 {}
|
private val logger = KotlinLogging.logger {}
|
||||||
|
|
||||||
@@ -222,7 +217,7 @@ fun serverModule(applicationDirs: ApplicationDirs): Module =
|
|||||||
module {
|
module {
|
||||||
single { applicationDirs }
|
single { applicationDirs }
|
||||||
single<IUpdater> { Updater() }
|
single<IUpdater> { Updater() }
|
||||||
single<JsonMapper> { JavalinJackson() }
|
single<JsonMapper> { JavalinJackson3() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(DelicateCoroutinesApi::class)
|
@OptIn(DelicateCoroutinesApi::class)
|
||||||
@@ -518,56 +513,10 @@ fun applicationSetup() {
|
|||||||
// start DownloadManager and restore + resume downloads
|
// start DownloadManager and restore + resume downloads
|
||||||
DownloadManager.restoreAndResumeDownloads()
|
DownloadManager.restoreAndResumeDownloads()
|
||||||
|
|
||||||
|
SyncManager.scheduleSyncTask()
|
||||||
|
|
||||||
|
// asynchronously initialize CEF
|
||||||
GlobalScope.launch {
|
GlobalScope.launch {
|
||||||
val logger = KotlinLogging.logger("KCEF")
|
CEFManager.init()
|
||||||
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 { release("jbr-release-21.0.10b1163.108") } }
|
|
||||||
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" }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -144,14 +144,15 @@ object DBManager {
|
|||||||
|
|
||||||
private val logger = KotlinLogging.logger {}
|
private val logger = KotlinLogging.logger {}
|
||||||
|
|
||||||
fun databaseUp() {
|
fun databaseUp(givenDb: Database? = null) {
|
||||||
val db =
|
val db =
|
||||||
try {
|
givenDb
|
||||||
DBManager.setupDatabase()
|
?: try {
|
||||||
} catch (e: Exception) {
|
DBManager.setupDatabase()
|
||||||
logger.error(e) { "Failed to setup Database" }
|
} catch (e: Exception) {
|
||||||
return
|
logger.error(e) { "Failed to setup Database" }
|
||||||
}
|
return
|
||||||
|
}
|
||||||
|
|
||||||
logger.info {
|
logger.info {
|
||||||
"Using ${db.vendor} database version ${db.version}"
|
"Using ${db.vendor} database version ${db.version}"
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,571 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
@@ -136,7 +136,7 @@ object WebInterfaceManager {
|
|||||||
fun getAboutInfo(): AboutWebUI {
|
fun getAboutInfo(): AboutWebUI {
|
||||||
val currentVersion = getLocalVersion()
|
val currentVersion = getLocalVersion()
|
||||||
|
|
||||||
val failedToGetVersion = currentVersion === "r-1"
|
val failedToGetVersion = currentVersion == "r-1"
|
||||||
if (failedToGetVersion) {
|
if (failedToGetVersion) {
|
||||||
throw Exception("Failed to get current version")
|
throw Exception("Failed to get current version")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
package masstest
|
package masstest
|
||||||
|
|
||||||
|
import android.os.Looper
|
||||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.jupiter.api.AfterAll
|
||||||
import org.junit.jupiter.api.BeforeAll
|
import org.junit.jupiter.api.BeforeAll
|
||||||
|
import org.junit.jupiter.api.Disabled
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import org.junit.jupiter.api.TestInstance
|
import org.junit.jupiter.api.TestInstance
|
||||||
|
import org.koin.core.context.stopKoin
|
||||||
import suwayomi.tachidesk.manga.impl.Source
|
import suwayomi.tachidesk.manga.impl.Source
|
||||||
import suwayomi.tachidesk.manga.impl.extension.Extension
|
import suwayomi.tachidesk.manga.impl.extension.Extension
|
||||||
import suwayomi.tachidesk.manga.impl.extension.ExtensionsList
|
import suwayomi.tachidesk.manga.impl.extension.ExtensionsList
|
||||||
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource
|
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource
|
||||||
import suwayomi.tachidesk.server.applicationSetup
|
import suwayomi.tachidesk.server.applicationSetup
|
||||||
|
import suwayomi.tachidesk.server.settings.SettingsRegistry
|
||||||
import suwayomi.tachidesk.test.BASE_PATH
|
import suwayomi.tachidesk.test.BASE_PATH
|
||||||
import suwayomi.tachidesk.test.setLoggingEnabled
|
import suwayomi.tachidesk.test.setLoggingEnabled
|
||||||
import xyz.nulldev.ts.config.CONFIG_PREFIX
|
import xyz.nulldev.ts.config.CONFIG_PREFIX
|
||||||
@@ -25,8 +30,11 @@ class CloudFlareTest {
|
|||||||
fun setup() {
|
fun setup() {
|
||||||
val dataRoot = File(BASE_PATH).absolutePath
|
val dataRoot = File(BASE_PATH).absolutePath
|
||||||
System.setProperty("$CONFIG_PREFIX.server.rootDir", dataRoot)
|
System.setProperty("$CONFIG_PREFIX.server.rootDir", dataRoot)
|
||||||
|
Looper.clearMainLooperForTest()
|
||||||
|
SettingsRegistry.clear()
|
||||||
applicationSetup()
|
applicationSetup()
|
||||||
setLoggingEnabled(false)
|
setLoggingEnabled(false)
|
||||||
|
return
|
||||||
|
|
||||||
runBlocking {
|
runBlocking {
|
||||||
val extensions = ExtensionsList.getExtensionList()
|
val extensions = ExtensionsList.getExtensionList()
|
||||||
@@ -48,9 +56,15 @@ class CloudFlareTest {
|
|||||||
setLoggingEnabled(true)
|
setLoggingEnabled(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@AfterAll
|
||||||
|
fun teardown() {
|
||||||
|
stopKoin()
|
||||||
|
}
|
||||||
|
|
||||||
private val logger = KotlinLogging.logger {}
|
private val logger = KotlinLogging.logger {}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
@Disabled
|
||||||
fun `test nhentai browse`() =
|
fun `test nhentai browse`() =
|
||||||
runTest {
|
runTest {
|
||||||
assert(nhentai.getPopularManga(1).mangas.isNotEmpty()) {
|
assert(nhentai.getPopularManga(1).mangas.isNotEmpty()) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ package masstest
|
|||||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
* 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/. */
|
* 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.SChapter
|
||||||
import eu.kanade.tachiyomi.source.model.SManga
|
import eu.kanade.tachiyomi.source.model.SManga
|
||||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||||
@@ -17,9 +18,11 @@ import kotlinx.coroutines.awaitAll
|
|||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.coroutines.sync.Semaphore
|
import kotlinx.coroutines.sync.Semaphore
|
||||||
import kotlinx.coroutines.sync.withPermit
|
import kotlinx.coroutines.sync.withPermit
|
||||||
|
import org.junit.jupiter.api.AfterAll
|
||||||
import org.junit.jupiter.api.BeforeAll
|
import org.junit.jupiter.api.BeforeAll
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import org.junit.jupiter.api.TestInstance
|
import org.junit.jupiter.api.TestInstance
|
||||||
|
import org.koin.core.context.stopKoin
|
||||||
import suwayomi.tachidesk.manga.impl.Source.getSourceList
|
import suwayomi.tachidesk.manga.impl.Source.getSourceList
|
||||||
import suwayomi.tachidesk.manga.impl.extension.Extension.installExtension
|
import suwayomi.tachidesk.manga.impl.extension.Extension.installExtension
|
||||||
import suwayomi.tachidesk.manga.impl.extension.Extension.uninstallExtension
|
import suwayomi.tachidesk.manga.impl.extension.Extension.uninstallExtension
|
||||||
@@ -28,6 +31,7 @@ import suwayomi.tachidesk.manga.impl.extension.ExtensionsList.getExtensionList
|
|||||||
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrNull
|
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrNull
|
||||||
import suwayomi.tachidesk.manga.model.dataclass.ExtensionDataClass
|
import suwayomi.tachidesk.manga.model.dataclass.ExtensionDataClass
|
||||||
import suwayomi.tachidesk.server.applicationSetup
|
import suwayomi.tachidesk.server.applicationSetup
|
||||||
|
import suwayomi.tachidesk.server.settings.SettingsRegistry
|
||||||
import suwayomi.tachidesk.test.BASE_PATH
|
import suwayomi.tachidesk.test.BASE_PATH
|
||||||
import suwayomi.tachidesk.test.setLoggingEnabled
|
import suwayomi.tachidesk.test.setLoggingEnabled
|
||||||
import xyz.nulldev.ts.config.CONFIG_PREFIX
|
import xyz.nulldev.ts.config.CONFIG_PREFIX
|
||||||
@@ -51,6 +55,8 @@ class TestExtensionCompatibility {
|
|||||||
fun setup() {
|
fun setup() {
|
||||||
val dataRoot = File(BASE_PATH).absolutePath
|
val dataRoot = File(BASE_PATH).absolutePath
|
||||||
System.setProperty("$CONFIG_PREFIX.server.rootDir", dataRoot)
|
System.setProperty("$CONFIG_PREFIX.server.rootDir", dataRoot)
|
||||||
|
Looper.clearMainLooperForTest()
|
||||||
|
SettingsRegistry.clear()
|
||||||
applicationSetup()
|
applicationSetup()
|
||||||
setLoggingEnabled(false)
|
setLoggingEnabled(false)
|
||||||
|
|
||||||
@@ -72,12 +78,22 @@ class TestExtensionCompatibility {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sources = getSourceList().map { getCatalogueSourceOrNull(it.id.toLong())!! as HttpSource }
|
sources =
|
||||||
|
getSourceList()
|
||||||
|
.filter {
|
||||||
|
// filter local source
|
||||||
|
it.id.toLong() != 0L
|
||||||
|
}.map { getCatalogueSourceOrNull(it.id.toLong())!! as HttpSource }
|
||||||
}
|
}
|
||||||
setLoggingEnabled(true)
|
setLoggingEnabled(true)
|
||||||
File("$BASE_PATH/sources.txt").writeText(sources.joinToString("\n") { "${it.name} - ${it.lang.uppercase()} - ${it.id}" })
|
File("$BASE_PATH/sources.txt").writeText(sources.joinToString("\n") { "${it.name} - ${it.lang.uppercase()} - ${it.id}" })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@AfterAll
|
||||||
|
fun teardown() {
|
||||||
|
stopKoin()
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun runTest() {
|
fun runTest() {
|
||||||
runBlocking(Dispatchers.Default) {
|
runBlocking(Dispatchers.Default) {
|
||||||
|
|||||||
42
server/src/test/kotlin/suwayomi/tachidesk/CefTest.kt
Normal file
42
server/src/test/kotlin/suwayomi/tachidesk/CefTest.kt
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
26
server/src/test/kotlin/suwayomi/tachidesk/FlowTest.kt
Normal file
26
server/src/test/kotlin/suwayomi/tachidesk/FlowTest.kt
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
84
server/src/test/kotlin/suwayomi/tachidesk/LooperTest.kt
Normal file
84
server/src/test/kotlin/suwayomi/tachidesk/LooperTest.kt
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,19 +18,22 @@ import suwayomi.tachidesk.test.clearTables
|
|||||||
class CategoryControllerTest : ApplicationTest() {
|
class CategoryControllerTest : ApplicationTest() {
|
||||||
@Test
|
@Test
|
||||||
fun categoryReorder() {
|
fun categoryReorder() {
|
||||||
|
clearTables(
|
||||||
|
CategoryTable,
|
||||||
|
)
|
||||||
Category.createCategory("foo")
|
Category.createCategory("foo")
|
||||||
Category.createCategory("bar")
|
Category.createCategory("bar")
|
||||||
val cats = Category.getCategoryList()
|
val cats = Category.getCategoryList()
|
||||||
val foo = cats.asSequence().filter { it.name == "foo" }.first()
|
val foo = cats.asSequence().filter { it.name == "foo" }.first()
|
||||||
val bar = cats.asSequence().filter { it.name == "bar" }.first()
|
val bar = cats.asSequence().filter { it.name == "bar" }.first()
|
||||||
assertEquals(1, foo.order)
|
assertEquals(0, foo.order)
|
||||||
assertEquals(2, bar.order)
|
assertEquals(1, bar.order)
|
||||||
Category.reorderCategory(1, 2)
|
Category.reorderCategory(1, 2)
|
||||||
val catsReordered = Category.getCategoryList()
|
val catsReordered = Category.getCategoryList()
|
||||||
val fooReordered = catsReordered.asSequence().filter { it.name == "foo" }.first()
|
val fooReordered = catsReordered.asSequence().filter { it.name == "foo" }.first()
|
||||||
val barReordered = catsReordered.asSequence().filter { it.name == "bar" }.first()
|
val barReordered = catsReordered.asSequence().filter { it.name == "bar" }.first()
|
||||||
assertEquals(2, fooReordered.order)
|
assertEquals(1, fooReordered.order)
|
||||||
assertEquals(1, barReordered.order)
|
assertEquals(0, barReordered.order)
|
||||||
}
|
}
|
||||||
|
|
||||||
@AfterEach
|
@AfterEach
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ class CategoryMangaTest : ApplicationTest() {
|
|||||||
CategoryManga.getCategoryMangaList(DEFAULT_CATEGORY_ID)[0].unreadCount,
|
CategoryManga.getCategoryMangaList(DEFAULT_CATEGORY_ID)[0].unreadCount,
|
||||||
"Manga should not have any unread chapters",
|
"Manga should not have any unread chapters",
|
||||||
)
|
)
|
||||||
createChapters(mangaId, 10, false)
|
createChapters(mangaId, 10, false, start = 11)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
10,
|
10,
|
||||||
CategoryManga.getCategoryMangaList(DEFAULT_CATEGORY_ID)[0].unreadCount,
|
CategoryManga.getCategoryMangaList(DEFAULT_CATEGORY_ID)[0].unreadCount,
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ class TestUpdater : IUpdater {
|
|||||||
TODO("Not yet implemented")
|
TODO("Not yet implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Deprecated("Replaced with updates", replaceWith = ReplaceWith("updates"))
|
||||||
override val status: Flow<UpdateStatus>
|
override val status: Flow<UpdateStatus>
|
||||||
get() = TODO("Not yet implemented")
|
get() = TODO("Not yet implemented")
|
||||||
override val updates: Flow<UpdateUpdates>
|
override val updates: Flow<UpdateUpdates>
|
||||||
|
|||||||
@@ -12,9 +12,12 @@ import eu.kanade.tachiyomi.createAppModule
|
|||||||
import eu.kanade.tachiyomi.network.NetworkHelper
|
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||||
import eu.kanade.tachiyomi.source.local.LocalSource
|
import eu.kanade.tachiyomi.source.local.LocalSource
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
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.jetbrains.exposed.v1.jdbc.Database
|
||||||
import org.junit.jupiter.api.BeforeAll
|
import org.junit.jupiter.api.BeforeAll
|
||||||
import org.koin.core.context.startKoin
|
import org.koin.core.context.startKoin
|
||||||
|
import org.koin.core.context.stopKoin
|
||||||
import suwayomi.tachidesk.server.ApplicationDirs
|
import suwayomi.tachidesk.server.ApplicationDirs
|
||||||
import suwayomi.tachidesk.server.JavalinSetup
|
import suwayomi.tachidesk.server.JavalinSetup
|
||||||
import suwayomi.tachidesk.server.ServerConfig
|
import suwayomi.tachidesk.server.ServerConfig
|
||||||
@@ -22,8 +25,9 @@ import suwayomi.tachidesk.server.androidCompat
|
|||||||
import suwayomi.tachidesk.server.database.databaseUp
|
import suwayomi.tachidesk.server.database.databaseUp
|
||||||
import suwayomi.tachidesk.server.serverConfig
|
import suwayomi.tachidesk.server.serverConfig
|
||||||
import suwayomi.tachidesk.server.serverModule
|
import suwayomi.tachidesk.server.serverModule
|
||||||
|
import suwayomi.tachidesk.server.settings.SettingsRegistry
|
||||||
import suwayomi.tachidesk.server.util.AppMutex.handleAppMutex
|
import suwayomi.tachidesk.server.util.AppMutex.handleAppMutex
|
||||||
import suwayomi.tachidesk.server.util.SystemTray
|
import suwayomi.tachidesk.server.util.ConfigTypeRegistration
|
||||||
import uy.kohesive.injekt.Injekt
|
import uy.kohesive.injekt.Injekt
|
||||||
import uy.kohesive.injekt.api.get
|
import uy.kohesive.injekt.api.get
|
||||||
import xyz.nulldev.androidcompat.AndroidCompatInitializer
|
import xyz.nulldev.androidcompat.AndroidCompatInitializer
|
||||||
@@ -55,6 +59,13 @@ open class ApplicationTest {
|
|||||||
private var initializedTheApp = false
|
private var initializedTheApp = false
|
||||||
|
|
||||||
fun testingSetup() {
|
fun testingSetup() {
|
||||||
|
// register Tachidesk's config which is dubbed "ServerConfig"
|
||||||
|
SettingsRegistry.clear()
|
||||||
|
ConfigTypeRegistration.registerCustomTypes()
|
||||||
|
GlobalConfigManager.registerModule(
|
||||||
|
ServerConfig.register { GlobalConfigManager.config },
|
||||||
|
)
|
||||||
|
|
||||||
// Application dirs
|
// Application dirs
|
||||||
val applicationDirs = ApplicationDirs()
|
val applicationDirs = ApplicationDirs()
|
||||||
|
|
||||||
@@ -72,13 +83,9 @@ open class ApplicationTest {
|
|||||||
File(it).mkdirs()
|
File(it).mkdirs()
|
||||||
}
|
}
|
||||||
|
|
||||||
// register Tachidesk's config which is dubbed "ServerConfig"
|
|
||||||
GlobalConfigManager.registerModule(
|
|
||||||
ServerConfig.register { GlobalConfigManager.config },
|
|
||||||
)
|
|
||||||
|
|
||||||
// initialize Koin modules
|
// initialize Koin modules
|
||||||
val app = App()
|
val app = App()
|
||||||
|
stopKoin()
|
||||||
startKoin {
|
startKoin {
|
||||||
modules(
|
modules(
|
||||||
createAppModule(app),
|
createAppModule(app),
|
||||||
@@ -128,14 +135,14 @@ open class ApplicationTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// create system tray
|
// create system tray
|
||||||
if (serverConfig.systemTrayEnabled.value) {
|
// if (serverConfig.systemTrayEnabled.value) {
|
||||||
try {
|
// try {
|
||||||
SystemTray.create()
|
// SystemTray.create()
|
||||||
} catch (e: Throwable) {
|
// } catch (e: Throwable) {
|
||||||
// cover both java.lang.Exception and java.lang.Error
|
// // cover both java.lang.Exception and java.lang.Error
|
||||||
e.printStackTrace()
|
// e.printStackTrace()
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
// Disable jetty's logging
|
// Disable jetty's logging
|
||||||
System.setProperty("org.eclipse.jetty.util.log.announce", "false")
|
System.setProperty("org.eclipse.jetty.util.log.announce", "false")
|
||||||
@@ -154,8 +161,16 @@ open class ApplicationTest {
|
|||||||
// fixes #119 , ref: https://github.com/Suwayomi/Suwayomi-Server/issues/119#issuecomment-894681292 , source Id calculation depends on String.lowercase()
|
// fixes #119 , ref: https://github.com/Suwayomi/Suwayomi-Server/issues/119#issuecomment-894681292 , source Id calculation depends on String.lowercase()
|
||||||
Locale.setDefault(Locale.ENGLISH)
|
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
|
// 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")
|
val db = Database.connect("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;", "org.h2.Driver", databaseConfig = dbConfig)
|
||||||
|
|
||||||
databaseUp(db)
|
databaseUp(db)
|
||||||
|
|
||||||
|
|||||||
@@ -55,8 +55,9 @@ fun createChapters(
|
|||||||
mangaId: Int,
|
mangaId: Int,
|
||||||
amount: Int,
|
amount: Int,
|
||||||
read: Boolean,
|
read: Boolean,
|
||||||
|
start: Int = 1,
|
||||||
) {
|
) {
|
||||||
val list = listOf((0 until amount)).flatten().map { 1 }
|
val list = listOf((0 until amount)).flatten().map { it + start }
|
||||||
transaction {
|
transaction {
|
||||||
ChapterTable
|
ChapterTable
|
||||||
.batchInsert(list) {
|
.batchInsert(list) {
|
||||||
|
|||||||
Reference in New Issue
Block a user