mirror of
https://github.com/Suwayomi/Suwayomi-Server.git
synced 2026-07-13 15:54:35 -05:00
Compare commits
36 Commits
renovate/m
...
4743c776d0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4743c776d0 | ||
|
|
13a5b3a831 | ||
|
|
da3a1dce9e | ||
|
|
c68a108298 | ||
|
|
911c0ce2e3 | ||
|
|
2a1f1faae5 | ||
|
|
07dceeb07c | ||
|
|
ba2257a164 | ||
|
|
669cb44864 | ||
|
|
6493eaaa02 | ||
|
|
701e4674ea | ||
|
|
75fa4b4b23 | ||
|
|
00861d7750 | ||
|
|
fff291cdb5 | ||
|
|
70f3036f58 | ||
|
|
cc75ad328d | ||
|
|
c0618fcc5c | ||
|
|
9686f75a2d | ||
|
|
4d5307f15b | ||
|
|
779229a48a | ||
|
|
762d5bdbe6 | ||
|
|
41bb6d3dc1 | ||
|
|
fbb383b1f1 | ||
|
|
558407d92c | ||
|
|
6870922784 | ||
|
|
a4b647972e | ||
|
|
16a14e6ac2 | ||
|
|
a2f29ec9dc | ||
|
|
82df985201 | ||
|
|
740db4f1ab | ||
|
|
c4711dec00 | ||
|
|
75d8d172aa | ||
|
|
81fb8c395d | ||
|
|
e93efa9627 | ||
|
|
03a95e6652 | ||
|
|
c117d380a3 |
8
.github/workflows/build_pull_request.yml
vendored
8
.github/workflows/build_pull_request.yml
vendored
@@ -67,7 +67,7 @@ jobs:
|
|||||||
export LD_PRELOAD="$(pwd)/scripts/resources/catch_abort.so"
|
export LD_PRELOAD="$(pwd)/scripts/resources/catch_abort.so"
|
||||||
JAR=$(ls ./server/build/*.jar| head -1)
|
JAR=$(ls ./server/build/*.jar| head -1)
|
||||||
set +e
|
set +e
|
||||||
timeout 30s java -DcrashOnFailedMigration=true \
|
timeout 30s java \
|
||||||
-Dsuwayomi.tachidesk.config.server.systemTrayEnabled=false \
|
-Dsuwayomi.tachidesk.config.server.systemTrayEnabled=false \
|
||||||
-Dsuwayomi.tachidesk.config.server.initialOpenInBrowserEnabled=false \
|
-Dsuwayomi.tachidesk.config.server.initialOpenInBrowserEnabled=false \
|
||||||
-Dsuwayomi.tachidesk.config.server.databaseType=POSTGRESQL \
|
-Dsuwayomi.tachidesk.config.server.databaseType=POSTGRESQL \
|
||||||
@@ -83,7 +83,7 @@ jobs:
|
|||||||
exit "$ecode"
|
exit "$ecode"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
timeout 30s java -DcrashOnFailedMigration=true \
|
timeout 30s java \
|
||||||
-Dsuwayomi.tachidesk.config.server.systemTrayEnabled=false \
|
-Dsuwayomi.tachidesk.config.server.systemTrayEnabled=false \
|
||||||
-Dsuwayomi.tachidesk.config.server.initialOpenInBrowserEnabled=false \
|
-Dsuwayomi.tachidesk.config.server.initialOpenInBrowserEnabled=false \
|
||||||
-jar "$JAR"
|
-jar "$JAR"
|
||||||
@@ -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
|
||||||
|
|||||||
8
.github/workflows/wiki.yml
vendored
8
.github/workflows/wiki.yml
vendored
@@ -1,6 +1,7 @@
|
|||||||
name: GitHub Wiki upload
|
name: GitHub Wiki upload
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
@@ -35,6 +36,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 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<CefApp> {
|
||||||
|
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
|
||||||
@@ -87,7 +86,7 @@ import java.io.BufferedWriter
|
|||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.util.concurrent.Executor
|
import java.util.concurrent.Executor
|
||||||
import kotlin.reflect.KClass
|
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
|
||||||
@@ -102,8 +101,8 @@ class KcefWebViewProvider(
|
|||||||
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 +114,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 +191,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 +219,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 +366,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 +378,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 +408,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 +439,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 +466,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 +475,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 +485,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,13 +510,13 @@ 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
|
||||||
}
|
}
|
||||||
@@ -526,16 +529,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)
|
||||||
}
|
}
|
||||||
@@ -613,6 +618,7 @@ class KcefWebViewProvider(
|
|||||||
.createBrowser(
|
.createBrowser(
|
||||||
loadUrl,
|
loadUrl,
|
||||||
CefRendering.OFFSCREEN,
|
CefRendering.OFFSCREEN,
|
||||||
|
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()
|
||||||
@@ -637,6 +643,7 @@ class KcefWebViewProvider(
|
|||||||
.createBrowser(
|
.createBrowser(
|
||||||
url,
|
url,
|
||||||
CefRendering.OFFSCREEN,
|
CefRendering.OFFSCREEN,
|
||||||
|
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 +669,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.OFFSCREEN,
|
||||||
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 +691,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 +775,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 +845,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 +868,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 +930,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 +957,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 +974,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 +1045,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,
|
||||||
|
|||||||
22
CHANGELOG.md
22
CHANGELOG.md
@@ -10,13 +10,25 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|||||||
- .
|
- .
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- .
|
- (**Database/H2**) Use the latest H2 database engine
|
||||||
|
- (**Startup**) Crash on startup if an unrecoverable error happens
|
||||||
|
- (**WebView**) Use JCEF directly and update to newest Chromium
|
||||||
|
- (**Extension/Android**) Switch MessageQueue to LegacyMessageQueue from ConcurrentMessageQueue
|
||||||
|
|
||||||
### Fixed
|
### 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
|
||||||
|
- (**Chapter**) Fix losing chapter data on failed chapter list update
|
||||||
|
- (**Chapter**) Fix database error when fetching chapter updates
|
||||||
|
- (**Manga/API**) Fix "mangas" graphql query with active sorting and using a PostgreSQL database (QUERY "mangas")
|
||||||
|
- (**API**) Fix GraphQL `Filter` `notAll` and `notAny` being inversed
|
||||||
|
- (**API**) Fix GraphQL `Filter` causing an UnsupportedOperationException when passing an empty list as a `Any` filter value
|
||||||
|
- (**Build**) Fix CURL failing silently in builds
|
||||||
|
|
||||||
## [v2.2.2100] + [WebUI: v20260508.01] - 2026-05-08
|
## [v2.2.2100] + [WebUI: v20260508.01] - 2026-05-08
|
||||||
|
|
||||||
|
|||||||
11
README.md
11
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,13 +107,13 @@ 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, several dependencies are required; aside from X11 libraries necessary for rendering Chromium, some JNI bindings are necessary: gluegen and jogl (found in Ubuntu as `libgluegen2-jni` and `libjogl2-jni`).
|
||||||
Note that on some systems (e.g. Ubuntu), the JNI libraries are not automatically found, see below.
|
Note that on some systems (e.g. Ubuntu), the JNI libraries are not automatically found, see below.
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
@@ -123,6 +124,10 @@ This search path includes the current working directory, if you do not want to m
|
|||||||
|
|
||||||
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.3b475.60"
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -1,19 +1,92 @@
|
|||||||
|
# Troubleshooting
|
||||||
|
|
||||||
|
This page is laid out in several sections, where each section describes a specific problem, followed by one or more possible solutions.
|
||||||
|
|
||||||
|
At the end, you will find a General section, which is the nuclear option if nothing else works.
|
||||||
|
|
||||||
|
For further support, visit the [official Suwayomi Discord server](https://discord.gg/DDZdqZWaHA).
|
||||||
|
In such cases, it will be helpful to have logs ready. You can find them in [The Data Directory](./The-Data-Directory) in the logs directory.
|
||||||
|
|
||||||
|
**All steps below assume that you have stopped Suwayomi**.
|
||||||
|
|
||||||
|
|
||||||
|
## Broken database
|
||||||
|
|
||||||
|
- `failed due to
|
||||||
|
org.jetbrains.exposed.exceptions.ExposedSQLException: org.h2.jdbc.JdbcSQLSyntaxErrorException: Column "CATEGORY.SORT_ORDER" not found`
|
||||||
|
- `org.h2.jdbc.JdbcSQLSyntaxErrorException: Column "CHAPTER.KOREADER_HASH" not found`
|
||||||
|
- `java.lang.IllegalStateException: Unable to read the page at position 96170708817765466`
|
||||||
|
- Any other error text that includes "SQL Statement"
|
||||||
|
|
||||||
|
Your database is either corrupted or incompatible.
|
||||||
|
|
||||||
|
One of these is the cause:
|
||||||
|
- You were running a preview version and decided to downgrade to stable.
|
||||||
|
- You did not shut down Suwayomi properly.
|
||||||
|
- Suwayomi crashed in an unexpected way.
|
||||||
|
|
||||||
|
Solutions:
|
||||||
|
- If you downgraded, upgrade to preview again.
|
||||||
|
- Otherwise, you will need to reset and restore from a backup. See [General Troubleshooting](#general-troubleshooting) below.
|
||||||
|
|
||||||
|
|
||||||
|
## `HTTP error 429`
|
||||||
|
|
||||||
|
The source (or, if trackers are enabled, possibly the tracker) has blocked you for sending too many requests.
|
||||||
|
Note that Mass-Migration can result in an unexpectedly high number of requests to both the source and any configured trackers.
|
||||||
|
|
||||||
|
Solution: Use other/more sources, download less, and wait between request-heavy actions.
|
||||||
|
|
||||||
|
|
||||||
|
## Extension times out
|
||||||
|
|
||||||
|
- `Timed out waiting for 20000 ms…`
|
||||||
|
- `Timed out waiting for page list`
|
||||||
|
|
||||||
|
First, check if this is an extension issue or a Suwayomi issue.
|
||||||
|
On the manga page of the problematic entry, click "Open in WebView".
|
||||||
|
|
||||||
|
Solutions:
|
||||||
|
- If the WebView loads: The issue is with the extension. Search [the issues](https://github.com/Suwayomi/Suwayomi-Server/issues) and discord if there are known problems with that extension.
|
||||||
|
- If the WebView errors: Go to [The Data Directory](./The-Data-Directory) and remove the `bin` and `cache` folders.
|
||||||
|
- If the WebView still does not work after a restart, your installation is incomplete. On Linux, refer to [the README](https://github.com/Suwayomi/Suwayomi-Server#webview-support-gnulinux).
|
||||||
|
|
||||||
|
|
||||||
|
## Flaresolverr required
|
||||||
|
|
||||||
|
- `java.io.IOException: Cloudflare bypass currently disabled`
|
||||||
|
|
||||||
|
The source you are using has enabled CloudFlare's bot protection.
|
||||||
|
If you open the source's website in your browser, you should see the "Confirm I'm human" page.
|
||||||
|
|
||||||
|
Solution:
|
||||||
|
- Download and set up [Flaresolverr](https://github.com/FlareSolverr/FlareSolverr) or [Byparr](https://github.com/ThePhaseless/Byparr). Make sure to run Flaresolverr/Byparr every time you use this source.
|
||||||
|
|
||||||
|
|
||||||
|
## Flaresolverr not running
|
||||||
|
|
||||||
|
- `java.io.IOException: Failed to connect to localhost/[0:0:0:0:0:0:0:1]:8191`
|
||||||
|
|
||||||
|
You have configured Flaresolverr by enabling the `server.flareSolverrEnabled` setting, but Flaresolverr is not installed and/or running.
|
||||||
|
|
||||||
|
Solutions:
|
||||||
|
- Install Flaresolverr if you haven't already (see previous section). Then ensure it is running (Windows: do not close the console window!).
|
||||||
|
- If it is running, ensure the configured url in `server.flareSolverrUrl` is correct. There is usually no need to change this.
|
||||||
|
- If it is running and the url is correct, check your firewall settings, your system may be blocking access to Flaresolverr.
|
||||||
|
|
||||||
|
|
||||||
## General Troubleshooting
|
## 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.
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> This will remove all your data, including the library.
|
||||||
|
> Make sure you have copied your backups as described above!
|
||||||
|
|
||||||
- Make sure you have a recent backup of your library or create one in the app (if possible) because we **are going to wipe all Suwayomi data**.
|
- Make sure you have a recent backup of your library or create one in the app (if possible) because we **are going to wipe all Suwayomi data**.
|
||||||
- Make sure Suwayomi is not running (right click on tray icon and quit or kill it through the way your Operating System provides)
|
- Make sure Suwayomi is not running (right click on tray icon and quit or kill it through the way your Operating System provides)
|
||||||
- Clear all browsing data on your browser if you use Suwayomi from a browser.
|
- Clear all browsing data on your browser if you use Suwayomi from a browser.
|
||||||
- Delete the Suwayomi data directory located below and re-run the app.
|
- Delete the Suwayomi data directory located below and re-run the app. See the article [The Data Directory](./The-Data-Directory) for information on how to find it.
|
||||||
|
- If you wish to keep your downloads, you may also attempt to surgically remove only parts. You will need to remove `database.mv.db`, `database.trace.db`, `bin`, `cache`, `extensions`, `settings`, `webUI`. Removing only a subset of these files and folders may fail to resolve the problem.
|
||||||
Note: Replace `<Account>` with the currently logged in account/username on your pc.
|
- Open Suwayomi and go to Settings > Backup > Restore Backup, and select the latest backup you have.
|
||||||
|
- Restoring from backup does not restore your downloads. If you chose to keep them in the above step, you will now need to re-download all manga. Suwayomi will pick up on the existing files and not actually download anything that isn't new.
|
||||||
On Mac OS X : `/Users/<Account>/Library/Application Support/Tachidesk`
|
|
||||||
|
|
||||||
On Windows XP : `C:\Documents and Settings\<Account>\Application Data\Local Settings\Tachidesk`
|
|
||||||
|
|
||||||
On Windows 7 and later : `C:\Users\<Account>\AppData\Local\Tachidesk`
|
|
||||||
|
|
||||||
On Unix/Linux : `/home/<account>/.local/share/Tachidesk`
|
|
||||||
|
|
||||||
- In the case that you have to periodically perform this fix or the problem persists or the method failed to fix it, open an issue or Join the [Suwayomi discord server](https://discord.gg/DDZdqZWaHA) to hang out with the community and to receive support and help.
|
- In the case that you have to periodically perform this fix or the problem persists or the method failed to fix it, open an issue or Join the [Suwayomi discord server](https://discord.gg/DDZdqZWaHA) to hang out with the community and to receive support and help.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ 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.3.2" # Major version is locked by Tachiyomi extensions
|
||||||
javalin = "7.2.0"
|
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.1.3" # jackson version locked by javalin, ref: `io.javalin.core.util.OptionalDependency`
|
||||||
exposed = "1.2.0"
|
exposed = "1.2.0"
|
||||||
@@ -12,11 +12,12 @@ dex2jar = "2.4.36"
|
|||||||
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-alpha.4"
|
||||||
xmlserialization = "0.91.3"
|
xmlserialization = "0.91.3"
|
||||||
ktlint = "1.8.0"
|
ktlint = "1.8.0"
|
||||||
koin = "4.2.1"
|
koin = "4.2.1"
|
||||||
moko = "0.26.4"
|
moko = "0.26.4"
|
||||||
|
jcef = "144.0.15-g72717cf-chromium-144.0.7559.172-api-1.21-262-b34"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
# Kotlin
|
# Kotlin
|
||||||
@@ -37,9 +38,9 @@ serialization-xml-core = { module = "io.github.pdvrieze.xmlutil:core", version.r
|
|||||||
serialization-xml = { module = "io.github.pdvrieze.xmlutil:serialization-jvm", version.ref = "xmlserialization" }
|
serialization-xml = { module = "io.github.pdvrieze.xmlutil:serialization-jvm", version.ref = "xmlserialization" }
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
slf4japi = "org.slf4j:slf4j-api:2.0.17"
|
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.32"
|
||||||
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" }
|
||||||
@@ -116,7 +117,7 @@ appdirs = "ca.gosyer:kotlin-multiplatform-appdirs:2.0.0"
|
|||||||
cache4k = "io.github.reactivecircus.cache4k:cache4k:0.14.0"
|
cache4k = "io.github.reactivecircus.cache4k:cache4k:0.14.0"
|
||||||
zip4j = "net.lingala.zip4j:zip4j:2.11.6"
|
zip4j = "net.lingala.zip4j:zip4j:2.11.6"
|
||||||
commonscompress = "org.apache.commons:commons-compress:1.28.0"
|
commonscompress = "org.apache.commons:commons-compress:1.28.0"
|
||||||
junrar = "com.github.junrar:junrar:7.5.10"
|
junrar = "com.github.junrar:junrar:7.6.0"
|
||||||
|
|
||||||
# AES/CBC/PKCS7Padding Cypher provider
|
# AES/CBC/PKCS7Padding Cypher provider
|
||||||
bouncycastle = "org.bouncycastle:bcprov-jdk18on:1.84"
|
bouncycastle = "org.bouncycastle:bcprov-jdk18on:1.84"
|
||||||
@@ -147,7 +148,7 @@ twelvemonkeys-imageio-webp = { module = "com.twelvemonkeys.imageio:imageio-webp"
|
|||||||
imageio-webp = "com.github.usefulness:webp-imageio:0.10.2"
|
imageio-webp = "com.github.usefulness:webp-imageio:0.10.2"
|
||||||
|
|
||||||
# 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,9 @@ 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" }
|
||||||
|
gluegen = "org.jogamp.gluegen:gluegen-rt:2.5.0"
|
||||||
|
jogl = "org.jogamp.jogl:jogl-all:2.5.0"
|
||||||
|
|
||||||
# User
|
# User
|
||||||
jwt = "com.auth0:java-jwt:4.5.2"
|
jwt = "com.auth0:java-jwt:4.5.2"
|
||||||
@@ -183,7 +186,7 @@ buildconfig = { id = "com.github.gmazzo.buildconfig", version = "6.0.9"}
|
|||||||
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 = "9.4.2"}
|
||||||
|
|
||||||
# Moko
|
# Moko
|
||||||
moko = { id = "dev.icerock.mobile.multiplatform-resources", version.ref = "moko" }
|
moko = { id = "dev.icerock.mobile.multiplatform-resources", version.ref = "moko" }
|
||||||
@@ -213,7 +216,7 @@ shared = [
|
|||||||
"dex2jar-tools",
|
"dex2jar-tools",
|
||||||
"apk-parser",
|
"apk-parser",
|
||||||
"jackson-annotations",
|
"jackson-annotations",
|
||||||
"kcef"
|
"jcef",
|
||||||
]
|
]
|
||||||
|
|
||||||
sharedTest = [
|
sharedTest = [
|
||||||
|
|||||||
2
gradle/wrapper/gradle-wrapper.properties
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
@@ -1,6 +1,6 @@
|
|||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip
|
||||||
networkTimeout=10000
|
networkTimeout=10000
|
||||||
retries=0
|
retries=0
|
||||||
retryBackOffMs=500
|
retryBackOffMs=500
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ dependencies {
|
|||||||
implementation(libs.bundles.shared)
|
implementation(libs.bundles.shared)
|
||||||
testImplementation(libs.bundles.sharedTest)
|
testImplementation(libs.bundles.sharedTest)
|
||||||
|
|
||||||
|
// WebView
|
||||||
|
implementation(libs.gluegen)
|
||||||
|
implementation(libs.jogl)
|
||||||
|
|
||||||
// OkHttp
|
// OkHttp
|
||||||
implementation(libs.bundles.okhttp)
|
implementation(libs.bundles.okhttp)
|
||||||
implementation(libs.okio)
|
implementation(libs.okio)
|
||||||
@@ -159,6 +163,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 +178,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 +189,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")
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import kotlinx.coroutines.flow.Flow
|
|||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
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
|
||||||
@@ -73,6 +74,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,6 +1030,14 @@ 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)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/** ****************************************************************** **/
|
/** ****************************************************************** **/
|
||||||
@@ -1059,18 +1083,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,7 @@ enum class SettingGroup(
|
|||||||
CLOUDFLARE("Cloudflare"),
|
CLOUDFLARE("Cloudflare"),
|
||||||
OPDS("OPDS"),
|
OPDS("OPDS"),
|
||||||
KOREADER_SYNC("KOReader sync"),
|
KOREADER_SYNC("KOReader sync"),
|
||||||
|
WEB_VIEW("WebView"),
|
||||||
;
|
;
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -13,6 +13,6 @@ import org.jetbrains.exposed.v1.core.dao.id.IntIdTable
|
|||||||
* Metadata storage for clients, server/global level.
|
* Metadata storage for clients, server/global level.
|
||||||
*/
|
*/
|
||||||
object GlobalMetaTable : IntIdTable() {
|
object GlobalMetaTable : IntIdTable() {
|
||||||
val key = varchar("key", 256)
|
val key = varchar("meta_key", 256)
|
||||||
val value = varchar("value", 4096)
|
val value = varchar("value", 4096)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,9 +15,11 @@ import org.jetbrains.exposed.v1.core.Op
|
|||||||
import org.jetbrains.exposed.v1.core.SortOrder
|
import org.jetbrains.exposed.v1.core.SortOrder
|
||||||
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.less
|
import org.jetbrains.exposed.v1.core.less
|
||||||
import org.jetbrains.exposed.v1.core.like
|
import org.jetbrains.exposed.v1.core.like
|
||||||
import org.jetbrains.exposed.v1.jdbc.select
|
import org.jetbrains.exposed.v1.jdbc.select
|
||||||
|
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||||
import suwayomi.tachidesk.graphql.directives.RequireAuth
|
import suwayomi.tachidesk.graphql.directives.RequireAuth
|
||||||
import suwayomi.tachidesk.graphql.queries.filter.BooleanFilter
|
import suwayomi.tachidesk.graphql.queries.filter.BooleanFilter
|
||||||
@@ -243,13 +245,16 @@ class MangaQuery {
|
|||||||
): MangaNodeList {
|
): MangaNodeList {
|
||||||
val queryResults =
|
val queryResults =
|
||||||
transaction {
|
transaction {
|
||||||
val res =
|
val mangaIdsQuery =
|
||||||
MangaTable
|
MangaTable
|
||||||
.leftJoin(CategoryMangaTable)
|
.leftJoin(CategoryMangaTable)
|
||||||
.select(MangaTable.columns)
|
.select(MangaTable.id)
|
||||||
.withDistinctOn(MangaTable.id)
|
.withDistinct()
|
||||||
|
|
||||||
res.applyOps(condition, filter)
|
mangaIdsQuery.applyOps(condition, filter)
|
||||||
|
|
||||||
|
val res =
|
||||||
|
MangaTable.selectAll().where { MangaTable.id inSubQuery mangaIdsQuery }
|
||||||
|
|
||||||
if (order != null || orderBy != null || (last != null || before != null)) {
|
if (order != null || orderBy != null || (last != null || before != null)) {
|
||||||
val baseSort = listOf(MangaOrder(MangaOrderBy.ID, SortOrder.ASC))
|
val baseSort = listOf(MangaOrder(MangaOrderBy.ID, SortOrder.ASC))
|
||||||
|
|||||||
@@ -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()) {
|
||||||
|
|||||||
@@ -236,7 +236,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 +247,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
|
||||||
@@ -262,16 +256,14 @@ object Chapter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// we got some clean up due
|
transaction {
|
||||||
if (chaptersIdsToDelete.isNotEmpty()) {
|
// we got some clean up due
|
||||||
DownloadManager.dequeue(chaptersIdsToDelete)
|
if (chaptersIdsToDelete.isNotEmpty()) {
|
||||||
transaction {
|
DownloadManager.dequeue(chaptersIdsToDelete)
|
||||||
PageTable.deleteWhere { chapter inList chaptersIdsToDelete }
|
PageTable.deleteWhere { chapter inList chaptersIdsToDelete }
|
||||||
ChapterTable.deleteWhere { id inList chaptersIdsToDelete }
|
ChapterTable.deleteWhere { id inList chaptersIdsToDelete }
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
transaction {
|
|
||||||
if (chaptersToInsert.isNotEmpty()) {
|
if (chaptersToInsert.isNotEmpty()) {
|
||||||
ChapterTable
|
ChapterTable
|
||||||
.batchInsert(chaptersToInsert) { chapter ->
|
.batchInsert(chaptersToInsert) { chapter ->
|
||||||
@@ -287,24 +279,31 @@ 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.pageCount] = -1
|
||||||
|
|
||||||
// is recognized chapter number
|
// is recognized chapter number
|
||||||
if (chapter.chapterNumber >= 0f && chapter.chapterNumber in deletedChapterNumbers) {
|
if (chapter.chapterNumber >= 0f && chapter.chapterNumber in deletedChapterNumbers) {
|
||||||
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 { insertedChapters.add(ChapterTable.toDataClass(it)) }
|
||||||
}
|
}
|
||||||
@@ -314,12 +313,30 @@ 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.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)
|
||||||
|
|||||||
@@ -133,7 +133,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
|
||||||
|
|||||||
@@ -358,6 +358,7 @@ object Extension {
|
|||||||
} else {
|
} else {
|
||||||
ExtensionTable.update({ ExtensionTable.pkgName eq pkgName }) {
|
ExtensionTable.update({ ExtensionTable.pkgName eq pkgName }) {
|
||||||
it[isInstalled] = false
|
it[isInstalled] = false
|
||||||
|
it[hasUpdate] = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import suwayomi.tachidesk.manga.model.table.CategoryMetaTable.ref
|
|||||||
* Metadata storage for clients, about Category with id == [ref].
|
* Metadata storage for clients, about Category with id == [ref].
|
||||||
*/
|
*/
|
||||||
object CategoryMetaTable : IntIdTable() {
|
object CategoryMetaTable : IntIdTable() {
|
||||||
val key = varchar("key", 256)
|
val key = varchar("meta_key", 256)
|
||||||
val value = varchar("value", 4096)
|
val value = varchar("value", 4096)
|
||||||
val ref = reference("category_ref", CategoryTable, ReferenceOption.CASCADE)
|
val ref = reference("category_ref", CategoryTable, ReferenceOption.CASCADE)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import suwayomi.tachidesk.manga.model.table.ChapterMetaTable.ref
|
|||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
object ChapterMetaTable : IntIdTable() {
|
object ChapterMetaTable : IntIdTable() {
|
||||||
val key = varchar("key", 256)
|
val key = varchar("meta_key", 256)
|
||||||
val value = varchar("value", 4096)
|
val value = varchar("value", 4096)
|
||||||
val ref = reference("chapter_ref", ChapterTable, ReferenceOption.CASCADE)
|
val ref = reference("chapter_ref", ChapterTable, ReferenceOption.CASCADE)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import suwayomi.tachidesk.manga.model.table.MangaMetaTable.ref
|
|||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
object MangaMetaTable : IntIdTable() {
|
object MangaMetaTable : IntIdTable() {
|
||||||
val key = varchar("key", 256)
|
val key = varchar("meta_key", 256)
|
||||||
val value = varchar("value", 4096)
|
val value = varchar("value", 4096)
|
||||||
val ref = reference("manga_ref", MangaTable, ReferenceOption.CASCADE)
|
val ref = reference("manga_ref", MangaTable, ReferenceOption.CASCADE)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import suwayomi.tachidesk.manga.model.table.SourceMetaTable.ref
|
|||||||
* Metadata storage for clients, about Source with id == [ref].
|
* Metadata storage for clients, about Source with id == [ref].
|
||||||
*/
|
*/
|
||||||
object SourceMetaTable : IntIdTable() {
|
object SourceMetaTable : IntIdTable() {
|
||||||
val key = varchar("key", 256)
|
val key = varchar("meta_key", 256)
|
||||||
val value = varchar("value", 4096)
|
val value = varchar("value", 4096)
|
||||||
val ref = long("source_ref")
|
val ref = long("source_ref")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import android.content.Context
|
|||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
import suwayomi.tachidesk.manga.impl.update.IUpdater
|
import suwayomi.tachidesk.manga.impl.update.IUpdater
|
||||||
import suwayomi.tachidesk.server.database.H2Migration
|
import suwayomi.tachidesk.server.database.H2Migration
|
||||||
|
import suwayomi.tachidesk.server.util.ExitCode
|
||||||
|
import suwayomi.tachidesk.server.util.shutdownApp
|
||||||
import uy.kohesive.injekt.Injekt
|
import uy.kohesive.injekt.Injekt
|
||||||
import uy.kohesive.injekt.api.get
|
import uy.kohesive.injekt.api.get
|
||||||
import java.io.File
|
import java.io.File
|
||||||
@@ -99,31 +101,35 @@ private val MIGRATIONS =
|
|||||||
|
|
||||||
fun runMigrations(applicationDirs: ApplicationDirs) {
|
fun runMigrations(applicationDirs: ApplicationDirs) {
|
||||||
val logger = KotlinLogging.logger("Migration")
|
val logger = KotlinLogging.logger("Migration")
|
||||||
|
try {
|
||||||
|
val migrationPreferences =
|
||||||
|
Injekt
|
||||||
|
.get<Application>()
|
||||||
|
.getSharedPreferences(
|
||||||
|
"migrations",
|
||||||
|
Context.MODE_PRIVATE,
|
||||||
|
)
|
||||||
|
val version = migrationPreferences.getInt("version", 0)
|
||||||
|
|
||||||
val migrationPreferences =
|
logger.info { "Running migrations, previous version $version, target version ${MIGRATIONS.size}" }
|
||||||
Injekt
|
|
||||||
.get<Application>()
|
|
||||||
.getSharedPreferences(
|
|
||||||
"migrations",
|
|
||||||
Context.MODE_PRIVATE,
|
|
||||||
)
|
|
||||||
val version = migrationPreferences.getInt("version", 0)
|
|
||||||
|
|
||||||
logger.info { "Running migrations, previous version $version, target version ${MIGRATIONS.size}" }
|
MIGRATIONS.forEachIndexed { index, (migrationName, migrationFunction) ->
|
||||||
|
val migrationVersion = index + 1
|
||||||
|
|
||||||
MIGRATIONS.forEachIndexed { index, (migrationName, migrationFunction) ->
|
val isMigrationRequired = version < migrationVersion
|
||||||
val migrationVersion = index + 1
|
if (!isMigrationRequired) {
|
||||||
|
logger.info { "Skipping migration version $migrationVersion: $migrationName" }
|
||||||
|
return@forEachIndexed
|
||||||
|
}
|
||||||
|
|
||||||
val isMigrationRequired = version < migrationVersion
|
logger.info { "Running migration version $migrationVersion: $migrationName" }
|
||||||
if (!isMigrationRequired) {
|
|
||||||
logger.info { "Skipping migration version $migrationVersion: $migrationName" }
|
migrationFunction(applicationDirs)
|
||||||
return@forEachIndexed
|
|
||||||
|
migrationPreferences.edit().putInt("version", migrationVersion).apply()
|
||||||
}
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
logger.info { "Running migration version $migrationVersion: $migrationName" }
|
logger.error(e) { "Failed to run migrations" }
|
||||||
|
shutdownApp(ExitCode.MigrationsRunFailure)
|
||||||
migrationFunction(applicationDirs)
|
|
||||||
|
|
||||||
migrationPreferences.edit().putInt("version", migrationVersion).apply()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -49,6 +47,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
|
||||||
@@ -222,7 +221,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)
|
||||||
@@ -366,6 +365,7 @@ fun applicationSetup() {
|
|||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error(e) { "Exception while creating initial server.conf" }
|
logger.error(e) { "Exception while creating initial server.conf" }
|
||||||
|
shutdownApp(ExitCode.SetupConfFileFailed)
|
||||||
}
|
}
|
||||||
|
|
||||||
// copy local source icon
|
// copy local source icon
|
||||||
@@ -378,6 +378,7 @@ fun applicationSetup() {
|
|||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error(e) { "Exception while copying Local source's icon" }
|
logger.error(e) { "Exception while copying Local source's icon" }
|
||||||
|
shutdownApp(ExitCode.LocalSourceIconCopyFailure)
|
||||||
}
|
}
|
||||||
|
|
||||||
// fixes #119 , ref:
|
// fixes #119 , ref:
|
||||||
@@ -395,7 +396,12 @@ fun applicationSetup() {
|
|||||||
|
|
||||||
databaseUp()
|
databaseUp()
|
||||||
|
|
||||||
LocalSource.register()
|
try {
|
||||||
|
LocalSource.register()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error(e) { "Failed to setup LocalSource" }
|
||||||
|
shutdownApp(ExitCode.LocalSourceSetupFailure)
|
||||||
|
}
|
||||||
|
|
||||||
serverConfig.subscribeTo(
|
serverConfig.subscribeTo(
|
||||||
combine<Any, DatabaseSettings>(
|
combine<Any, DatabaseSettings>(
|
||||||
@@ -511,56 +517,8 @@ fun applicationSetup() {
|
|||||||
// start DownloadManager and restore + resume downloads
|
// start DownloadManager and restore + resume downloads
|
||||||
DownloadManager.restoreAndResumeDownloads()
|
DownloadManager.restoreAndResumeDownloads()
|
||||||
|
|
||||||
|
// 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() }
|
|
||||||
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" }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ import suwayomi.tachidesk.server.util.ExitCode
|
|||||||
import suwayomi.tachidesk.server.util.shutdownApp
|
import suwayomi.tachidesk.server.util.shutdownApp
|
||||||
import uy.kohesive.injekt.Injekt
|
import uy.kohesive.injekt.Injekt
|
||||||
import uy.kohesive.injekt.api.get
|
import uy.kohesive.injekt.api.get
|
||||||
import java.sql.SQLException
|
|
||||||
import kotlin.time.Duration.Companion.minutes
|
import kotlin.time.Duration.Companion.minutes
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
@@ -145,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}"
|
||||||
@@ -184,10 +184,8 @@ fun databaseUp() {
|
|||||||
}
|
}
|
||||||
val migrations = loadMigrationsFrom("suwayomi.tachidesk.server.database.migration", ServerConfig::class.java)
|
val migrations = loadMigrationsFrom("suwayomi.tachidesk.server.database.migration", ServerConfig::class.java)
|
||||||
runMigrations(migrations)
|
runMigrations(migrations)
|
||||||
} catch (e: SQLException) {
|
} catch (e: Exception) {
|
||||||
logger.error(e) { "Error up-to-database migration" }
|
logger.error(e) { "Error up-to-database migration" }
|
||||||
if (System.getProperty("crashOnFailedMigration").toBoolean()) {
|
shutdownApp(ExitCode.DbMigrationFailure)
|
||||||
shutdownApp(ExitCode.DbMigrationFailure)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,9 +9,12 @@ import java.net.URLClassLoader
|
|||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
import kotlin.io.path.Path
|
import kotlin.io.path.Path
|
||||||
import kotlin.io.path.absolutePathString
|
import kotlin.io.path.absolutePathString
|
||||||
|
import kotlin.io.path.bufferedReader
|
||||||
|
import kotlin.io.path.bufferedWriter
|
||||||
import kotlin.io.path.copyTo
|
import kotlin.io.path.copyTo
|
||||||
import kotlin.io.path.createDirectories
|
import kotlin.io.path.createDirectories
|
||||||
import kotlin.io.path.deleteExisting
|
import kotlin.io.path.deleteExisting
|
||||||
|
import kotlin.io.path.deleteIfExists
|
||||||
import kotlin.io.path.div
|
import kotlin.io.path.div
|
||||||
import kotlin.io.path.exists
|
import kotlin.io.path.exists
|
||||||
import kotlin.io.path.name
|
import kotlin.io.path.name
|
||||||
@@ -46,6 +49,10 @@ object H2Migration {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val script = Path("$dbBase.${h2Old.substringAfterLast('.')}.sql")
|
val script = Path("$dbBase.${h2Old.substringAfterLast('.')}.sql")
|
||||||
|
script.deleteIfExists()
|
||||||
|
|
||||||
|
val modifiedScript = Path("$dbBase.${h2Old.substringAfterLast('.')}.modified.sql")
|
||||||
|
modifiedScript.deleteIfExists()
|
||||||
|
|
||||||
// Backup original database.
|
// Backup original database.
|
||||||
val backup = Path("$dbBase.mv.db.${h2Old.substringAfterLast('.')}.backup")
|
val backup = Path("$dbBase.mv.db.${h2Old.substringAfterLast('.')}.backup")
|
||||||
@@ -72,19 +79,32 @@ object H2Migration {
|
|||||||
libsDir.resolve("h2-$h2New.bin"),
|
libsDir.resolve("h2-$h2New.bin"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Delete attempted migration if failed previously
|
||||||
|
val newDatabase = Path(rootDir, "database.${h2New.substringAfterLast('.')}.mv.db")
|
||||||
|
newDatabase.deleteIfExists()
|
||||||
|
|
||||||
|
val modifiedNewDatabase = Path(rootDir, "database.${h2Old.substringAfterLast('.')}.modified.${h2New.substringAfterLast('.')}.mv.db")
|
||||||
|
modifiedNewDatabase.deleteIfExists()
|
||||||
|
|
||||||
runMigrationTool(
|
runMigrationTool(
|
||||||
migrationJar = migrationJar,
|
migrationJar = migrationJar,
|
||||||
libsDir = libsDir,
|
libsDir = libsDir,
|
||||||
mvStore = mvStore,
|
mvStore = mvStore,
|
||||||
script = script,
|
script = script,
|
||||||
|
modifiedScript = modifiedScript,
|
||||||
h2Old = h2Old,
|
h2Old = h2Old,
|
||||||
h2New = h2New,
|
h2New = h2New,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Move database to proper path
|
// Move database to proper path
|
||||||
val newDatabase = Path(rootDir, "database.${h2New.substringAfterLast('.')}.mv.db")
|
if (modifiedNewDatabase.exists()) {
|
||||||
newDatabase.copyTo(mvStore, overwrite = true)
|
modifiedNewDatabase.copyTo(mvStore, overwrite = true)
|
||||||
newDatabase.deleteExisting()
|
modifiedNewDatabase.deleteExisting()
|
||||||
|
newDatabase.deleteIfExists()
|
||||||
|
} else {
|
||||||
|
newDatabase.copyTo(mvStore, overwrite = true)
|
||||||
|
newDatabase.deleteExisting()
|
||||||
|
}
|
||||||
|
|
||||||
logger.info { "H2 migration completed successfully." }
|
logger.info { "H2 migration completed successfully." }
|
||||||
}
|
}
|
||||||
@@ -123,6 +143,7 @@ object H2Migration {
|
|||||||
libsDir: Path,
|
libsDir: Path,
|
||||||
mvStore: Path,
|
mvStore: Path,
|
||||||
script: Path,
|
script: Path,
|
||||||
|
modifiedScript: Path,
|
||||||
h2Old: String,
|
h2Old: String,
|
||||||
h2New: String,
|
h2New: String,
|
||||||
) {
|
) {
|
||||||
@@ -136,32 +157,77 @@ object H2Migration {
|
|||||||
val main =
|
val main =
|
||||||
clazz.getMethod("main", Array<String>::class.java)
|
clazz.getMethod("main", Array<String>::class.java)
|
||||||
|
|
||||||
main.invoke(
|
try {
|
||||||
null,
|
main.invoke(
|
||||||
arrayOf(
|
null,
|
||||||
// h2 driver dir
|
arrayOf(
|
||||||
"-l",
|
// h2 driver dir
|
||||||
libsDir.absolutePathString(),
|
"-l",
|
||||||
// from version
|
libsDir.absolutePathString(),
|
||||||
"-f",
|
// from version
|
||||||
h2Old,
|
"-f",
|
||||||
// to version
|
h2Old,
|
||||||
"-t",
|
// to version
|
||||||
h2New,
|
"-t",
|
||||||
// user
|
h2New,
|
||||||
"-u",
|
// user
|
||||||
"",
|
"-u",
|
||||||
// password
|
"",
|
||||||
"-p",
|
// password
|
||||||
"",
|
"-p",
|
||||||
// database.mv.db
|
"",
|
||||||
"-d",
|
// database.mv.db
|
||||||
mvStore.absolutePathString(),
|
"-d",
|
||||||
// database backup in SQL
|
mvStore.absolutePathString(),
|
||||||
"-s",
|
// database backup in SQL
|
||||||
script.absolutePathString(),
|
"-s",
|
||||||
),
|
script.absolutePathString(),
|
||||||
)
|
),
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Modify raw .sql file as needed for compatibility
|
||||||
|
if (e.stackTraceToString().contains("Unknown data type: \"DATETIME\"; SQL statement:") && script.exists()) {
|
||||||
|
script.bufferedReader().use { reader ->
|
||||||
|
modifiedScript.bufferedWriter().use { writer ->
|
||||||
|
reader.forEachLine { line ->
|
||||||
|
writer.write(
|
||||||
|
line.replace(
|
||||||
|
" \"EXECUTED_AT\" DATETIME(9) NOT NULL",
|
||||||
|
" \"EXECUTED_AT\" TIMESTAMP(9) NOT NULL",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
writer.newLine()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main.invoke(
|
||||||
|
null,
|
||||||
|
arrayOf(
|
||||||
|
// h2 driver dir
|
||||||
|
"-l",
|
||||||
|
libsDir.absolutePathString(),
|
||||||
|
// from version
|
||||||
|
"-f",
|
||||||
|
h2Old,
|
||||||
|
// to version
|
||||||
|
"-t",
|
||||||
|
h2New,
|
||||||
|
// user
|
||||||
|
"-u",
|
||||||
|
"",
|
||||||
|
// password
|
||||||
|
"-p",
|
||||||
|
"",
|
||||||
|
// database.mv.db
|
||||||
|
"-d",
|
||||||
|
modifiedScript.absolutePathString(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,17 +10,10 @@ package suwayomi.tachidesk.server.database.migration
|
|||||||
* 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 de.neonew.exposed.migrations.helpers.SQLMigration
|
import de.neonew.exposed.migrations.helpers.SQLMigration
|
||||||
import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager
|
import suwayomi.tachidesk.server.database.migration.helpers.toSqlName
|
||||||
|
|
||||||
@Suppress("ClassName", "unused")
|
@Suppress("ClassName", "unused")
|
||||||
class M0023_CategoryMetaRefFix : SQLMigration() {
|
class M0023_CategoryMetaRefFix : SQLMigration() {
|
||||||
fun String.toSqlName(): String =
|
|
||||||
TransactionManager.defaultDatabase!!.identifierManager.let {
|
|
||||||
it.quoteIfNecessary(
|
|
||||||
it.inProperCase(this),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private val CategoryMetaTable by lazy { "CategoryMeta".toSqlName() }
|
private val CategoryMetaTable by lazy { "CategoryMeta".toSqlName() }
|
||||||
private val CategoryRefColumn by lazy { "category_ref".toSqlName() }
|
private val CategoryRefColumn by lazy { "category_ref".toSqlName() }
|
||||||
private val CategoryTable by lazy { "Category".toSqlName() }
|
private val CategoryTable by lazy { "Category".toSqlName() }
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ package suwayomi.tachidesk.server.database.migration
|
|||||||
* 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 de.neonew.exposed.migrations.helpers.SQLMigration
|
import de.neonew.exposed.migrations.helpers.SQLMigration
|
||||||
|
import suwayomi.tachidesk.server.database.migration.helpers.toSqlName
|
||||||
|
|
||||||
@Suppress("ClassName", "unused")
|
@Suppress("ClassName", "unused")
|
||||||
class M0049_FixDuplicatedMetas : SQLMigration() {
|
class M0049_FixDuplicatedMetas : SQLMigration() {
|
||||||
@@ -15,7 +16,7 @@ class M0049_FixDuplicatedMetas : SQLMigration() {
|
|||||||
table: String,
|
table: String,
|
||||||
refColumn: String? = null,
|
refColumn: String? = null,
|
||||||
): String {
|
): String {
|
||||||
val groupBy = listOfNotNull(refColumn, "KEY").joinToString(", ")
|
val groupBy = listOfNotNull(refColumn, "KEY".toSqlName()).joinToString(", ")
|
||||||
|
|
||||||
return """
|
return """
|
||||||
DELETE FROM $table
|
DELETE FROM $table
|
||||||
@@ -30,10 +31,11 @@ class M0049_FixDuplicatedMetas : SQLMigration() {
|
|||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
}
|
}
|
||||||
|
|
||||||
override val sql: String =
|
override val sql: String by lazy {
|
||||||
createMigrationForTable("CATEGORYMETA", "CATEGORY_REF") +
|
createMigrationForTable("CATEGORYMETA", "CATEGORY_REF") +
|
||||||
createMigrationForTable("CHAPTERMETA", "CHAPTER_REF") +
|
createMigrationForTable("CHAPTERMETA", "CHAPTER_REF") +
|
||||||
createMigrationForTable("GLOBALMETA") +
|
createMigrationForTable("GLOBALMETA") +
|
||||||
createMigrationForTable("MANGAMETA", "MANGA_REF") +
|
createMigrationForTable("MANGAMETA", "MANGA_REF") +
|
||||||
createMigrationForTable("SOURCEMETA", "SOURCE_REF")
|
createMigrationForTable("SOURCEMETA", "SOURCE_REF")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package suwayomi.tachidesk.server.database.migration
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Copyright (C) Contributors to the Suwayomi project
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
||||||
|
|
||||||
|
import de.neonew.exposed.migrations.helpers.SQLMigration
|
||||||
|
import suwayomi.tachidesk.graphql.types.DatabaseType
|
||||||
|
import suwayomi.tachidesk.server.database.migration.helpers.toSqlName
|
||||||
|
import suwayomi.tachidesk.server.serverConfig
|
||||||
|
|
||||||
|
@Suppress("ClassName", "unused")
|
||||||
|
class M0055_RenameMetaKeys : SQLMigration() {
|
||||||
|
fun postgresRename(table: String): String =
|
||||||
|
"ALTER TABLE $table " +
|
||||||
|
"RENAME COLUMN " + "KEY".toSqlName() + " TO META_KEY;"
|
||||||
|
|
||||||
|
fun h2Rename(table: String): String =
|
||||||
|
"ALTER TABLE $table " +
|
||||||
|
"ALTER COLUMN " + "KEY".toSqlName() + " RENAME TO META_KEY;"
|
||||||
|
|
||||||
|
fun createRenameMigration(table: String): String =
|
||||||
|
when (serverConfig.databaseType.value) {
|
||||||
|
DatabaseType.H2 -> h2Rename(table.toSqlName())
|
||||||
|
DatabaseType.POSTGRESQL -> postgresRename(table.toSqlName())
|
||||||
|
}
|
||||||
|
|
||||||
|
override val sql: String by lazy {
|
||||||
|
createRenameMigration("CATEGORYMETA") +
|
||||||
|
createRenameMigration("CHAPTERMETA") +
|
||||||
|
createRenameMigration("GLOBALMETA") +
|
||||||
|
createRenameMigration("MANGAMETA") +
|
||||||
|
createRenameMigration("SOURCEMETA")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +1,9 @@
|
|||||||
package suwayomi.tachidesk.server.database.migration.helpers
|
package suwayomi.tachidesk.server.database.migration.helpers
|
||||||
|
|
||||||
import de.neonew.exposed.migrations.helpers.SQLMigration
|
import de.neonew.exposed.migrations.helpers.SQLMigration
|
||||||
import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager
|
|
||||||
import suwayomi.tachidesk.graphql.types.DatabaseType
|
import suwayomi.tachidesk.graphql.types.DatabaseType
|
||||||
import suwayomi.tachidesk.server.serverConfig
|
import suwayomi.tachidesk.server.serverConfig
|
||||||
|
|
||||||
fun String.toSqlName(): String =
|
|
||||||
TransactionManager.current().db.identifierManager.let {
|
|
||||||
it.quoteIfNecessary(
|
|
||||||
it.inProperCase(this),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
abstract class RenameFieldMigration(
|
abstract class RenameFieldMigration(
|
||||||
tableName: String,
|
tableName: String,
|
||||||
originalName: String,
|
originalName: String,
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package suwayomi.tachidesk.server.database.migration.helpers
|
||||||
|
|
||||||
|
import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager
|
||||||
|
|
||||||
|
fun String.toSqlName(): String =
|
||||||
|
TransactionManager.current().db.identifierManager.let {
|
||||||
|
it.quoteIfNecessary(
|
||||||
|
it.inProperCase(this),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -21,6 +21,10 @@ enum class ExitCode(
|
|||||||
WebUISetupFailure(3),
|
WebUISetupFailure(3),
|
||||||
ConfigMigrationMisconfiguredFailure(4),
|
ConfigMigrationMisconfiguredFailure(4),
|
||||||
DbMigrationFailure(5),
|
DbMigrationFailure(5),
|
||||||
|
SetupConfFileFailed(6),
|
||||||
|
LocalSourceIconCopyFailure(7),
|
||||||
|
LocalSourceSetupFailure(8),
|
||||||
|
MigrationsRunFailure(9),
|
||||||
}
|
}
|
||||||
|
|
||||||
fun shutdownApp(exitCode: ExitCode) {
|
fun shutdownApp(exitCode: ExitCode) {
|
||||||
|
|||||||
@@ -0,0 +1,573 @@
|
|||||||
|
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.flow.subscribe
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
86
server/src/test/kotlin/suwayomi/tachidesk/LooperTest.kt
Normal file
86
server/src/test/kotlin/suwayomi/tachidesk/LooperTest.kt
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
package suwayomi.tachidesk
|
||||||
|
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.os.Message
|
||||||
|
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
|
||||||
|
import kotlin.text.StringBuilder
|
||||||
|
|
||||||
|
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,
|
||||||
|
|||||||
@@ -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,7 +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.ConfigTypeRegistration
|
||||||
import suwayomi.tachidesk.server.util.SystemTray
|
import suwayomi.tachidesk.server.util.SystemTray
|
||||||
import uy.kohesive.injekt.Injekt
|
import uy.kohesive.injekt.Injekt
|
||||||
import uy.kohesive.injekt.api.get
|
import uy.kohesive.injekt.api.get
|
||||||
@@ -55,6 +60,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 +84,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 +136,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 +162,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