mirror of
https://github.com/Suwayomi/Suwayomi-Server.git
synced 2026-07-04 19:34:35 -05:00
Compare commits
28 Commits
renovate/m
...
730c76e7b1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
730c76e7b1 | ||
|
|
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"
|
||||
JAR=$(ls ./server/build/*.jar| head -1)
|
||||
set +e
|
||||
timeout 30s java -DcrashOnFailedMigration=true \
|
||||
timeout 30s java \
|
||||
-Dsuwayomi.tachidesk.config.server.systemTrayEnabled=false \
|
||||
-Dsuwayomi.tachidesk.config.server.initialOpenInBrowserEnabled=false \
|
||||
-Dsuwayomi.tachidesk.config.server.databaseType=POSTGRESQL \
|
||||
@@ -83,7 +83,7 @@ jobs:
|
||||
exit "$ecode"
|
||||
fi
|
||||
|
||||
timeout 30s java -DcrashOnFailedMigration=true \
|
||||
timeout 30s java \
|
||||
-Dsuwayomi.tachidesk.config.server.systemTrayEnabled=false \
|
||||
-Dsuwayomi.tachidesk.config.server.initialOpenInBrowserEnabled=false \
|
||||
-jar "$JAR"
|
||||
@@ -96,6 +96,10 @@ jobs:
|
||||
fi
|
||||
exit 0
|
||||
|
||||
- name: "Run tests"
|
||||
working-directory: master
|
||||
run: ./gradlew test --stacktrace
|
||||
|
||||
check_docs:
|
||||
name: Validate that all options are documented
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
1
.github/workflows/wiki.yml
vendored
1
.github/workflows/wiki.yml
vendored
@@ -1,6 +1,7 @@
|
||||
name: GitHub Wiki upload
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
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.WebViewRenderProcess
|
||||
import android.webkit.WebViewRenderProcessClient
|
||||
import dev.datlag.kcef.KCEF
|
||||
import dev.datlag.kcef.KCEFBrowser
|
||||
import dev.datlag.kcef.KCEFClient
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.cef.CefClient
|
||||
import org.cef.CefSettings
|
||||
import org.cef.browser.CefBrowser
|
||||
import org.cef.browser.CefFrame
|
||||
@@ -87,7 +86,7 @@ import java.io.BufferedWriter
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.Executor
|
||||
import kotlin.reflect.KClass
|
||||
import kotlin.math.min
|
||||
import kotlin.reflect.KFunction
|
||||
import kotlin.reflect.full.declaredMemberFunctions
|
||||
import kotlin.reflect.jvm.javaMethod
|
||||
@@ -102,8 +101,8 @@ class KcefWebViewProvider(
|
||||
private val urlHttpMapping: MutableMap<String, String> = mutableMapOf()
|
||||
private var initialRequestData: InitialRequestData? = null
|
||||
|
||||
private var kcefClient: KCEFClient? = null
|
||||
private var browser: KCEFBrowser? = null
|
||||
private var kcefClient: CefClient? = null
|
||||
private var browser: CefBrowser? = null
|
||||
|
||||
private val handler = Handler(view.webViewLooper)
|
||||
|
||||
@@ -115,8 +114,8 @@ class KcefWebViewProvider(
|
||||
private val initHandler: InitBrowserHandler by KoinPlatformTools.defaultContext().get().inject()
|
||||
}
|
||||
|
||||
public interface InitBrowserHandler {
|
||||
public fun init(provider: KcefWebViewProvider): Unit
|
||||
interface InitBrowserHandler {
|
||||
fun init(provider: KcefWebViewProvider): Unit
|
||||
}
|
||||
|
||||
private data class InitialRequestData(
|
||||
@@ -192,7 +191,7 @@ class KcefWebViewProvider(
|
||||
}
|
||||
}
|
||||
|
||||
private inner class DisplayHandler : CefDisplayHandlerAdapter() {
|
||||
private class DisplayHandler : CefDisplayHandlerAdapter() {
|
||||
override fun onConsoleMessage(
|
||||
browser: CefBrowser,
|
||||
level: CefSettings.LogSeverity,
|
||||
@@ -220,6 +219,7 @@ class KcefWebViewProvider(
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private inner class LoadHandler : CefLoadHandlerAdapter() {
|
||||
override fun onLoadEnd(
|
||||
browser: CefBrowser,
|
||||
@@ -366,7 +366,7 @@ class KcefWebViewProvider(
|
||||
callback: CefCallback,
|
||||
): Boolean {
|
||||
val data = resolvedData ?: return false
|
||||
val bytesToTransfer = Math.min(bytesToRead, data.size - readOffset)
|
||||
val bytesToTransfer = min(bytesToRead, data.size - readOffset)
|
||||
Log.v(
|
||||
TAG,
|
||||
"readResponse: $readOffset/${data.size}, reading $bytesToRead->$bytesToTransfer",
|
||||
@@ -378,7 +378,7 @@ class KcefWebViewProvider(
|
||||
}
|
||||
}
|
||||
|
||||
private inner class WebResponseResourceHandler(
|
||||
private class WebResponseResourceHandler(
|
||||
val webResponse: WebResourceResponse,
|
||||
) : ArrayResponseResourceHandler() {
|
||||
override fun processRequest(
|
||||
@@ -408,7 +408,7 @@ class KcefWebViewProvider(
|
||||
}
|
||||
}
|
||||
|
||||
private inner class HtmlResponseResourceHandler(
|
||||
private class HtmlResponseResourceHandler(
|
||||
val html: String,
|
||||
) : ArrayResponseResourceHandler() {
|
||||
override fun processRequest(
|
||||
@@ -439,7 +439,7 @@ class KcefWebViewProvider(
|
||||
view,
|
||||
CefWebResourceRequest(request, frame, false),
|
||||
)
|
||||
Log.v(TAG, "Resource ${request?.url}, result is cancel? $cancel")
|
||||
Log.v(TAG, "Resource ${request.url}, result is cancel? $cancel")
|
||||
|
||||
handler.post { viewClient.onLoadResource(view, frame?.url) }
|
||||
|
||||
@@ -466,7 +466,7 @@ class KcefWebViewProvider(
|
||||
}
|
||||
if (response == null) {
|
||||
// prefer user's response override
|
||||
urlHttpMapping.get(request.url.trimEnd('/'))?.let {
|
||||
urlHttpMapping[request.url.trimEnd('/')]?.let {
|
||||
return HtmlResponseResourceHandler(it)
|
||||
}
|
||||
}
|
||||
@@ -475,6 +475,7 @@ class KcefWebViewProvider(
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private inner class RequestHandler : CefRequestHandlerAdapter() {
|
||||
override fun getResourceRequestHandler(
|
||||
browser: CefBrowser,
|
||||
@@ -484,11 +485,13 @@ class KcefWebViewProvider(
|
||||
isDownload: Boolean,
|
||||
requestInitiator: String,
|
||||
disableDefaultHandling: BoolRef,
|
||||
): CefResourceRequestHandler? = ResourceRequestHandler()
|
||||
): CefResourceRequestHandler = ResourceRequestHandler()
|
||||
|
||||
override fun onRenderProcessTerminated(
|
||||
browser: CefBrowser,
|
||||
status: CefRequestHandler.TerminationStatus,
|
||||
errorCode: Int,
|
||||
errorString: String,
|
||||
) {
|
||||
handler.post {
|
||||
viewClient.onRenderProcessGone(
|
||||
@@ -507,13 +510,13 @@ class KcefWebViewProvider(
|
||||
override fun onRequestMediaAccessPermission(
|
||||
browser: CefBrowser,
|
||||
frame: CefFrame,
|
||||
requesting_url: String,
|
||||
requested_permissions: Int,
|
||||
requestingUrl: String,
|
||||
requestedPermissions: Int,
|
||||
callback: CefMediaAccessCallback,
|
||||
): Boolean {
|
||||
handler.post {
|
||||
Log.v(TAG, "Checking permission for $requesting_url: $requested_permissions")
|
||||
chromeClient.onPermissionRequest(CefPermissionRequest(requesting_url, requested_permissions, callback))
|
||||
Log.v(TAG, "Checking permission for $requestingUrl: $requestedPermissions")
|
||||
chromeClient.onPermissionRequest(CefPermissionRequest(requestingUrl, requestedPermissions, callback))
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -526,16 +529,18 @@ class KcefWebViewProvider(
|
||||
Log.v(TAG, "KcefWebViewProvider: initialize")
|
||||
destroy()
|
||||
kcefClient =
|
||||
KCEF.newClientBlocking().apply {
|
||||
addDisplayHandler(DisplayHandler())
|
||||
addLoadHandler(LoadHandler())
|
||||
addRequestHandler(RequestHandler())
|
||||
addPermissionHandler(PermissionHandler())
|
||||
runBlocking {
|
||||
CefHelper.createClient().apply {
|
||||
addDisplayHandler(DisplayHandler())
|
||||
addLoadHandler(LoadHandler())
|
||||
addRequestHandler(RequestHandler())
|
||||
addPermissionHandler(PermissionHandler())
|
||||
|
||||
val config = CefMessageRouter.CefMessageRouterConfig()
|
||||
config.jsQueryFunction = QUERY_FN
|
||||
config.jsCancelFunction = QUERY_CANCEL_FN
|
||||
addMessageRouter(CefMessageRouter.create(config, MessageRouterHandler()))
|
||||
val config = CefMessageRouter.CefMessageRouterConfig()
|
||||
config.jsQueryFunction = QUERY_FN
|
||||
config.jsCancelFunction = QUERY_CANCEL_FN
|
||||
addMessageRouter(CefMessageRouter.create(config, MessageRouterHandler()))
|
||||
}
|
||||
}
|
||||
initHandler.init(this)
|
||||
}
|
||||
@@ -613,6 +618,7 @@ class KcefWebViewProvider(
|
||||
.createBrowser(
|
||||
loadUrl,
|
||||
CefRendering.OFFSCREEN,
|
||||
false,
|
||||
).apply {
|
||||
// NOTE: Without this, we don't seem to be receiving any events
|
||||
createImmediately()
|
||||
@@ -637,6 +643,7 @@ class KcefWebViewProvider(
|
||||
.createBrowser(
|
||||
url,
|
||||
CefRendering.OFFSCREEN,
|
||||
false,
|
||||
).apply {
|
||||
// NOTE: Without this, we don't seem to be receiving any events
|
||||
createImmediately()
|
||||
@@ -662,27 +669,19 @@ class KcefWebViewProvider(
|
||||
browser?.close(true)
|
||||
browser?.dispose()
|
||||
chromeClient.onProgressChanged(view, 0)
|
||||
val url = baseUrl ?: "about:blank"
|
||||
urlHttpMapping[url.trimEnd('/')] = data
|
||||
|
||||
browser =
|
||||
(
|
||||
baseUrl?.let { url ->
|
||||
urlHttpMapping.put(url.trimEnd('/'), data)
|
||||
kcefClient!!.createBrowser(
|
||||
url,
|
||||
CefRendering.OFFSCREEN,
|
||||
)
|
||||
kcefClient!!
|
||||
.createBrowser(
|
||||
url,
|
||||
CefRendering.OFFSCREEN,
|
||||
false,
|
||||
).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")
|
||||
}
|
||||
|
||||
@@ -692,11 +691,11 @@ class KcefWebViewProvider(
|
||||
) {
|
||||
browser!!.evaluateJavaScript(
|
||||
script.removePrefix("javascript:"),
|
||||
)
|
||||
{
|
||||
Log.v(TAG, "JS returned: $it")
|
||||
it?.let { handler.post { resultCallback?.onReceiveValue(it) } }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun saveWebArchive(filename: String): Unit = throw RuntimeException("Stub!")
|
||||
@@ -838,6 +837,7 @@ class KcefWebViewProvider(
|
||||
|
||||
override fun getWebChromeClient(): WebChromeClient = chromeClient
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
override fun setPictureListener(listener: PictureListener): Unit = throw RuntimeException("Stub!")
|
||||
|
||||
@Serializable
|
||||
@@ -860,7 +860,7 @@ class KcefWebViewProvider(
|
||||
obj: Any,
|
||||
interfaceName: String,
|
||||
) {
|
||||
val cls = obj::class as KClass<Any>
|
||||
val cls = obj::class
|
||||
mappings.addAll(
|
||||
cls.declaredMemberFunctions.map {
|
||||
// This is ridiculous, but necessary, otherwise "public final" throws
|
||||
@@ -922,7 +922,8 @@ class KcefWebViewProvider(
|
||||
override fun getRendererPriorityWaivedWhenNotVisible(): Boolean = throw RuntimeException("Stub!")
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
override fun setTextClassifier(textClassifier: TextClassifier?) {}
|
||||
override fun setTextClassifier(textClassifier: TextClassifier?) {
|
||||
}
|
||||
|
||||
override fun getTextClassifier(): TextClassifier = TextClassifier.NO_OP
|
||||
|
||||
@@ -948,11 +949,13 @@ class KcefWebViewProvider(
|
||||
override fun onProvideAutofillVirtualStructure(
|
||||
@SuppressWarnings("unused") structure: android.view.ViewStructure,
|
||||
@SuppressWarnings("unused") flags: Int,
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
override fun autofill(
|
||||
@SuppressWarnings("unused") values: SparseArray<AutofillValue>,
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
override fun isVisibleToUserForAutofill(
|
||||
@SuppressWarnings("unused") virtualId: Int,
|
||||
@@ -963,7 +966,8 @@ class KcefWebViewProvider(
|
||||
override fun onProvideContentCaptureStructure(
|
||||
@SuppressWarnings("unused") structure: android.view.ViewStructure,
|
||||
@SuppressWarnings("unused") flags: Int,
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
override fun getAccessibilityNodeProvider(): AccessibilityNodeProvider = throw RuntimeException("Stub!")
|
||||
|
||||
@@ -1033,7 +1037,8 @@ class KcefWebViewProvider(
|
||||
override fun onMovedToDisplay(
|
||||
displayId: Int,
|
||||
config: Configuration,
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
override fun onVisibilityChanged(
|
||||
changedView: View,
|
||||
|
||||
21
CHANGELOG.md
21
CHANGELOG.md
@@ -10,13 +10,24 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
- .
|
||||
|
||||
### Changed
|
||||
- .
|
||||
- (**Database/H2**) Use the latest H2 database engine
|
||||
- (**Startup**) Crash on startup if an unrecoverable error happens
|
||||
- (**WebView**) Use JCEF directly and update to newest Chromium
|
||||
- (**Extension/Android**) Switch MessageQueue to LegacyMessageQueue from ConcurrentMessageQueue
|
||||
|
||||
### Fixed
|
||||
- (CloudFlareInterceptor) Don't send the `cf_clearance` cookie back to Flaresolverr
|
||||
- (WebUI) Handle serving non-default webui with "bundled"
|
||||
- (WebUI) Wait until WebUI is ready to open in browser
|
||||
- (Downloads) Truncate filenames by byte length to prevent "File name too long" IO errors
|
||||
- (**CloudFlareInterceptor**) Don't send the `cf_clearance` cookie back to Flaresolverr
|
||||
- (**WebUI**) Handle serving non-default webui with "bundled"
|
||||
- (**WebUI**) Wait until WebUI is ready to open in browser
|
||||
- (**Downloads**) Truncate filenames by byte length to prevent "File name too long" IO errors
|
||||
- (**Downloads**) Fix being unable to find downloads after manga was renamed during an update
|
||||
- (**Downloads**) Fix preserving chapter download states during an update
|
||||
- (**Extension**) Do not indicate an update is available when the extension is not installed
|
||||
- (**Chapter**) Fix losing chapter data on failed chapter list update
|
||||
- (**Chapter**) Fix database error when fetching chapter updates
|
||||
- (**Manga/API**) Fix "mangas" graphql query with active sorting and using a postgresql database (QUERY "mangas")
|
||||
- (**API**) Fix GraphQL `Filter` `notAll` and `notAny` being inversed
|
||||
- (**API**) Fix GraphQL `Filter` causing an UnsupportedOperationException when passing an empty list as a `Any` filter value
|
||||
|
||||
## [v2.2.2100] + [WebUI: v20260508.01] - 2026-05-08
|
||||
|
||||
|
||||
@@ -106,13 +106,13 @@ Download the latest `linux-x64`(x86_64) release from [the releases section](http
|
||||
|
||||
#### 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.
|
||||
|
||||
To have a functional WebView, several dependencies are required; aside from X11 libraries necessary for rendering Chromium, some JNI bindings are necessary: gluegen and jogl (found in Ubuntu as `libgluegen2-jni` and `libjogl2-jni`).
|
||||
Note that on some systems (e.g. Ubuntu), the JNI libraries are not automatically found, see below.
|
||||
|
||||
A 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 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 +123,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.
|
||||
|
||||
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
|
||||
### 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.
|
||||
|
||||
@@ -25,6 +25,7 @@ allprojects {
|
||||
maven("https://github.com/Suwayomi/Suwayomi-Server/raw/android-jar/")
|
||||
maven("https://jitpack.io")
|
||||
maven("https://jogamp.org/deployment/maven")
|
||||
maven("https://packages.jetbrains.team/maven/p/ij/intellij-dependencies")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ val getTachideskVersion = { "v2.2.${getCommitCount()}" }
|
||||
|
||||
val webUIRevisionTag = "r3136"
|
||||
|
||||
val webviewJbrRelease = "jbr-release-25.0.3b475.60"
|
||||
|
||||
private val getCommitCount = {
|
||||
runCatching {
|
||||
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.webUISubpath` controls on which sub-path the UI is served; by default, it will be accessible on `/` (i.e. directly), with this setting it can also be set to appear at e.g. `/suwayomi`
|
||||
|
||||
|
||||
### webView
|
||||
```
|
||||
server.kcefEnabled = true
|
||||
```
|
||||
- `server.kcefEnabled` controls if KCEF WebView provider is enabled.
|
||||
|
||||
|
||||
### Downloader
|
||||
```
|
||||
server.downloadAsCbz = true
|
||||
|
||||
@@ -1,19 +1,69 @@
|
||||
# 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).
|
||||
|
||||
|
||||
## General Troubleshooting
|
||||
This guide will try to fix Suwayomi by reseting it to a clean installation state.
|
||||
|
||||
> [!WARNING]
|
||||
> This will remove all your data, including the library.
|
||||
> Make sure you have copied your backups as described above!
|
||||
|
||||
- Make sure you have a recent backup of your library or create one in the app (if possible) because we **are going to wipe all Suwayomi data**.
|
||||
- Make sure Suwayomi is not running (right click on tray icon and quit or kill it through the way your Operating System provides)
|
||||
- Clear all browsing data on your browser if you use Suwayomi from a browser.
|
||||
- Delete the Suwayomi data directory located below and re-run the app.
|
||||
|
||||
Note: Replace `<Account>` with the currently logged in account/username on your pc.
|
||||
|
||||
On Mac OS X : `/Users/<Account>/Library/Application Support/Tachidesk`
|
||||
|
||||
On Windows XP : `C:\Documents and Settings\<Account>\Application Data\Local Settings\Tachidesk`
|
||||
|
||||
On Windows 7 and later : `C:\Users\<Account>\AppData\Local\Tachidesk`
|
||||
|
||||
On Unix/Linux : `/home/<account>/.local/share/Tachidesk`
|
||||
|
||||
- Delete the Suwayomi data directory located below and re-run the app. See the article [The Data Directory](./The-Data-Directory) for information on how to find it.
|
||||
- If you wish to keep your downloads, you may also attempt to surgically remove only parts. You will need to remove `database.mv.db`, `database.trace.db`, `bin`, `cache`, `extensions`, `settings`, `webUI`. Removing only a subset of these files and folders may fail to resolve the problem.
|
||||
- Open Suwayomi and go to Settings > Backup > Restore Backup, and select the latest backup you have.
|
||||
- Restoring from backup does not restore your downloads. If you chose to keep them in the above step, you will now need to re-download all manga. Suwayomi will pick up on the existing files and not actually download anything that isn't new.
|
||||
- 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"
|
||||
jvmTarget = "21"
|
||||
okhttp = "5.3.2" # Major version is locked by Tachiyomi extensions
|
||||
javalin = "7.2.0"
|
||||
javalin = "7.2.2"
|
||||
jte = "3.2.4"
|
||||
jackson = "3.1.3" # jackson version locked by javalin, ref: `io.javalin.core.util.OptionalDependency`
|
||||
exposed = "1.2.0"
|
||||
@@ -12,11 +12,12 @@ dex2jar = "2.4.36"
|
||||
polyglot = "25.0.3"
|
||||
settings = "1.3.0"
|
||||
twelvemonkeys = "3.13.1"
|
||||
graphqlkotlin = "10.0.0-alpha.3"
|
||||
graphqlkotlin = "10.0.0-alpha.4"
|
||||
xmlserialization = "0.91.3"
|
||||
ktlint = "1.8.0"
|
||||
koin = "4.2.1"
|
||||
moko = "0.26.4"
|
||||
jcef = "144.0.15-g72717cf-chromium-144.0.7559.172-api-1.21-262-b34"
|
||||
|
||||
[libraries]
|
||||
# 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" }
|
||||
|
||||
# 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"
|
||||
kotlinlogging = "io.github.oshai:kotlin-logging-jvm:8.0.02"
|
||||
kotlinlogging = "io.github.oshai:kotlin-logging-jvm:8.0.03"
|
||||
|
||||
# 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"
|
||||
zip4j = "net.lingala.zip4j:zip4j:2.11.6"
|
||||
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
|
||||
bouncycastle = "org.bouncycastle:bcprov-jdk18on:1.84"
|
||||
@@ -156,7 +157,9 @@ cron4j = "it.sauronsoftware.cron4j:cron4j:2.2.5"
|
||||
cronUtils = "com.cronutils:cron-utils:9.2.1"
|
||||
|
||||
# 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
|
||||
jwt = "com.auth0:java-jwt:4.5.2"
|
||||
@@ -213,7 +216,7 @@ shared = [
|
||||
"dex2jar-tools",
|
||||
"apk-parser",
|
||||
"jackson-annotations",
|
||||
"kcef"
|
||||
"jcef",
|
||||
]
|
||||
|
||||
sharedTest = [
|
||||
|
||||
2
gradle/wrapper/gradle-wrapper.properties
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
@@ -1,6 +1,6 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
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
|
||||
retries=0
|
||||
retryBackOffMs=500
|
||||
|
||||
@@ -37,6 +37,10 @@ dependencies {
|
||||
implementation(libs.bundles.shared)
|
||||
testImplementation(libs.bundles.sharedTest)
|
||||
|
||||
// WebView
|
||||
implementation(libs.gluegen)
|
||||
implementation(libs.jogl)
|
||||
|
||||
// OkHttp
|
||||
implementation(libs.bundles.okhttp)
|
||||
implementation(libs.okio)
|
||||
@@ -159,6 +163,8 @@ buildConfig {
|
||||
|
||||
buildConfigField("String", "GITHUB", quoteWrap("https://github.com/Suwayomi/Suwayomi-Server"))
|
||||
buildConfigField("String", "DISCORD", quoteWrap("https://discord.gg/DDZdqZWaHA"))
|
||||
buildConfigField("String", "JCEF_VERSION", quoteWrap(libs.versions.jcef.get()))
|
||||
buildConfigField("String", "JCEF_JBR_RELEASE", quoteWrap(webviewJbrRelease))
|
||||
}
|
||||
|
||||
tasks {
|
||||
@@ -172,6 +178,7 @@ tasks {
|
||||
"Specification-Version" to getTachideskVersion(),
|
||||
"Implementation-Version" to getTachideskRevision(),
|
||||
"Multi-Release" to true, // needed for polyglot
|
||||
"X-JBR-Release" to webviewJbrRelease,
|
||||
)
|
||||
}
|
||||
archiveBaseName.set(rootProject.name)
|
||||
@@ -182,7 +189,11 @@ tasks {
|
||||
}
|
||||
|
||||
test {
|
||||
useJUnitPlatform()
|
||||
useJUnitPlatform {
|
||||
if (!project.hasProperty("masstest")) {
|
||||
exclude("**/masstest/*")
|
||||
}
|
||||
}
|
||||
testLogging {
|
||||
showStandardStreams = true
|
||||
events("passed", "skipped", "failed")
|
||||
|
||||
@@ -1014,6 +1014,14 @@ class ServerConfig(
|
||||
description = "Use Hikari Connection Pool to connect to the database.",
|
||||
)
|
||||
|
||||
val kcefEnabled: MutableStateFlow<Boolean> by BooleanSetting(
|
||||
protoNumber = 86,
|
||||
group = SettingGroup.WEB_VIEW,
|
||||
privacySafe = true,
|
||||
defaultValue = true,
|
||||
description = "Enable the WebView via CEF (Chromium)"
|
||||
)
|
||||
|
||||
|
||||
|
||||
/** ****************************************************************** **/
|
||||
|
||||
@@ -17,6 +17,7 @@ enum class SettingGroup(
|
||||
CLOUDFLARE("Cloudflare"),
|
||||
OPDS("OPDS"),
|
||||
KOREADER_SYNC("KOReader sync"),
|
||||
WEB_VIEW("WebView"),
|
||||
;
|
||||
|
||||
override fun toString(): String = value
|
||||
|
||||
@@ -88,4 +88,6 @@ object SettingsRegistry {
|
||||
fun get(name: String): SettingMetadata? = settings[name]
|
||||
|
||||
fun getAll(): Map<String, SettingMetadata> = settings.toMap()
|
||||
|
||||
fun clear() = settings.clear()
|
||||
}
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
package suwayomi.tachidesk.global.impl
|
||||
|
||||
import dev.datlag.kcef.KCEF
|
||||
import dev.datlag.kcef.KCEFBrowser
|
||||
import dev.datlag.kcef.KCEFClient
|
||||
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Cookie
|
||||
import okhttp3.HttpUrl
|
||||
import org.cef.CefClient
|
||||
import org.cef.CefSettings
|
||||
import org.cef.browser.CefBrowser
|
||||
import org.cef.browser.CefFrame
|
||||
@@ -26,6 +25,9 @@ import org.cef.network.CefCookie
|
||||
import org.cef.network.CefCookieManager
|
||||
import org.cef.network.CefRequest
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import xyz.nulldev.androidcompat.webkit.CefHelper
|
||||
import xyz.nulldev.androidcompat.webkit.dispose
|
||||
import xyz.nulldev.androidcompat.webkit.evaluateJavaScript
|
||||
import java.awt.Component
|
||||
import java.awt.HeadlessException
|
||||
import java.awt.Rectangle
|
||||
@@ -47,8 +49,8 @@ import javax.swing.JPanel
|
||||
class KcefWebView {
|
||||
private val logger = KotlinLogging.logger {}
|
||||
private val renderHandler = RenderHandler()
|
||||
private var kcefClient: KCEFClient? = null
|
||||
private var browser: KCEFBrowser? = null
|
||||
private var kcefClient: CefClient? = null
|
||||
private var browser: CefBrowser? = null
|
||||
private var width = 1000
|
||||
private var height = 1000
|
||||
|
||||
@@ -76,7 +78,8 @@ class KcefWebView {
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable sealed class Event
|
||||
@Serializable
|
||||
sealed class Event
|
||||
|
||||
@Serializable
|
||||
@SerialName("consoleMessage")
|
||||
@@ -247,10 +250,12 @@ class KcefWebView {
|
||||
init {
|
||||
destroy()
|
||||
kcefClient =
|
||||
KCEF.newClientBlocking().apply {
|
||||
addDisplayHandler(DisplayHandler())
|
||||
addLoadHandler(LoadHandler())
|
||||
addRequestHandler(RequestHandler())
|
||||
runBlocking {
|
||||
CefHelper.createClient().apply {
|
||||
addDisplayHandler(DisplayHandler())
|
||||
addLoadHandler(LoadHandler())
|
||||
addRequestHandler(RequestHandler())
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug { "Start loading cookies" }
|
||||
@@ -289,6 +294,7 @@ class KcefWebView {
|
||||
.createBrowser(
|
||||
url,
|
||||
CefRendering.CefRenderingWithHandler(renderHandler, JPanel()),
|
||||
false,
|
||||
// NOTE: with a context, we don't seem to be getting any cookies
|
||||
).apply {
|
||||
// NOTE: Without this, we don't seem to be receiving any events
|
||||
|
||||
@@ -13,6 +13,6 @@ import org.jetbrains.exposed.v1.core.dao.id.IntIdTable
|
||||
* Metadata storage for clients, server/global level.
|
||||
*/
|
||||
object GlobalMetaTable : IntIdTable() {
|
||||
val key = varchar("key", 256)
|
||||
val key = varchar("meta_key", 256)
|
||||
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.greater
|
||||
import org.jetbrains.exposed.v1.core.inList
|
||||
import org.jetbrains.exposed.v1.core.inSubQuery
|
||||
import org.jetbrains.exposed.v1.core.less
|
||||
import org.jetbrains.exposed.v1.core.like
|
||||
import org.jetbrains.exposed.v1.jdbc.select
|
||||
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||
import suwayomi.tachidesk.graphql.directives.RequireAuth
|
||||
import suwayomi.tachidesk.graphql.queries.filter.BooleanFilter
|
||||
@@ -243,13 +245,16 @@ class MangaQuery {
|
||||
): MangaNodeList {
|
||||
val queryResults =
|
||||
transaction {
|
||||
val res =
|
||||
val mangaIdsQuery =
|
||||
MangaTable
|
||||
.leftJoin(CategoryMangaTable)
|
||||
.select(MangaTable.columns)
|
||||
.withDistinctOn(MangaTable.id)
|
||||
.select(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)) {
|
||||
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.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,
|
||||
@@ -455,36 +455,36 @@ fun <T : String, S : T?> andFilterWithCompareString(
|
||||
opAnd.andWhere(filter.greaterThanOrEqualTo) { column greaterEq it }
|
||||
|
||||
opAnd.andWhere(filter.includes, filter.includesAll, filter.includesAny) { column like "%$it%" }
|
||||
opAnd.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) {
|
||||
ILikeEscapeOp.iLike(column, "%$it%")
|
||||
}
|
||||
opAnd.andWhere(filter.notIncludesInsensitive, filter.notIncludesInsensitiveAll, filter.notIncludesInsensitiveAny) {
|
||||
opAnd.andNotWhere(filter.notIncludesInsensitive, filter.notIncludesInsensitiveAll, filter.notIncludesInsensitiveAny) {
|
||||
ILikeEscapeOp.iNotLike(column, "%$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) {
|
||||
ILikeEscapeOp.iLike(column, "$it%")
|
||||
}
|
||||
opAnd.andWhere(filter.notStartsWithInsensitive, filter.notStartsWithInsensitiveAll, filter.notStartsWithInsensitiveAny) {
|
||||
opAnd.andNotWhere(filter.notStartsWithInsensitive, filter.notStartsWithInsensitiveAll, filter.notStartsWithInsensitiveAny) {
|
||||
ILikeEscapeOp.iNotLike(column, "$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) {
|
||||
ILikeEscapeOp.iLike(column, "%$it")
|
||||
}
|
||||
opAnd.andWhere(filter.notEndsWithInsensitive, filter.notEndsWithInsensitiveAll, filter.notEndsWithInsensitiveAny) {
|
||||
opAnd.andNotWhere(filter.notEndsWithInsensitive, filter.notEndsWithInsensitiveAll, filter.notEndsWithInsensitiveAny) {
|
||||
ILikeEscapeOp.iNotLike(column, "%$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.notLikeInsensitive, filter.notLikeInsensitiveAll, filter.notLikeInsensitiveAny) {
|
||||
opAnd.andNotWhere(filter.notLikeInsensitive, filter.notLikeInsensitiveAll, filter.notLikeInsensitiveAny) {
|
||||
ILikeEscapeOp.iNotLike(column, it)
|
||||
}
|
||||
|
||||
@@ -535,6 +535,17 @@ class OpAnd(
|
||||
andWhereAny(valueAny, expr)
|
||||
}
|
||||
|
||||
fun <T : Any> andNotWhere(
|
||||
valueDefault: T?,
|
||||
valueAll: List<T>?,
|
||||
valueAny: List<T>?,
|
||||
expr: (T) -> Op<Boolean>,
|
||||
) {
|
||||
andWhere(valueDefault, expr)
|
||||
andNotWhereAll(valueAll, expr)
|
||||
andNotWhereAny(valueAny, expr)
|
||||
}
|
||||
|
||||
fun <T : Any> andWhereAll(
|
||||
values: List<T>?,
|
||||
andPart: (T) -> Op<Boolean>,
|
||||
@@ -542,15 +553,31 @@ class OpAnd(
|
||||
values?.map { andWhere(it, andPart) }
|
||||
}
|
||||
|
||||
fun <T : Any> andNotWhereAll(
|
||||
values: List<T>?,
|
||||
andPart: (T) -> Op<Boolean>,
|
||||
) {
|
||||
// Inversed all equals any
|
||||
andWhereAny(values, andPart)
|
||||
}
|
||||
|
||||
fun <T : Any> andWhereAny(
|
||||
values: List<T>?,
|
||||
andPart: (T) -> Op<Boolean>,
|
||||
) {
|
||||
values ?: return
|
||||
val expr = values.map { andPart(it) }.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)
|
||||
}
|
||||
|
||||
fun <T : Any> andNotWhereAny(
|
||||
values: List<T>?,
|
||||
andPart: (T) -> Op<Boolean>,
|
||||
) {
|
||||
// Inversed any equals all
|
||||
andWhereAll(values, andPart)
|
||||
}
|
||||
|
||||
fun <T> eq(
|
||||
value: T?,
|
||||
column: Column<T>,
|
||||
@@ -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.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.notDistinctFrom) { DistinctFromOp.notDistinctFrom(column, it as S) }
|
||||
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.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.notDistinctFrom) { DistinctFromOp.notDistinctFrom(column, it) }
|
||||
if (!filter.`in`.isNullOrEmpty()) {
|
||||
|
||||
@@ -236,7 +236,7 @@ object Chapter {
|
||||
val deletedChapterNumbers = TreeSet<Float>()
|
||||
val deletedReadChapterNumbers = TreeSet<Float>()
|
||||
val deletedBookmarkedChapterNumbers = TreeSet<Float>()
|
||||
val deletedDownloadedChapterNumberInfoMap = mutableMapOf<Float, MutableMap<String?, Int>>()
|
||||
val deletedDownloadedChapterNumberToChapter = mutableMapOf<Float, ChapterDataClass>()
|
||||
val deletedChapterNumberDateFetchMap = mutableMapOf<Float, Long>()
|
||||
|
||||
// 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 (dbChapter.read) deletedReadChapterNumbers.add(dbChapter.chapterNumber)
|
||||
if (dbChapter.bookmarked) deletedBookmarkedChapterNumbers.add(dbChapter.chapterNumber)
|
||||
if (dbChapter.downloaded) {
|
||||
val pageCountByScanlator =
|
||||
deletedDownloadedChapterNumberInfoMap.getOrPut(
|
||||
dbChapter.chapterNumber,
|
||||
) { mutableMapOf() }
|
||||
pageCountByScanlator[dbChapter.scanlator] = dbChapter.pageCount
|
||||
}
|
||||
if (dbChapter.downloaded) deletedDownloadedChapterNumberToChapter[dbChapter.chapterNumber] = dbChapter
|
||||
deletedChapterNumbers.add(dbChapter.chapterNumber)
|
||||
deletedChapterNumberDateFetchMap[dbChapter.chapterNumber] = dbChapter.fetchedAt
|
||||
dbChapter.id
|
||||
@@ -262,16 +256,14 @@ object Chapter {
|
||||
}
|
||||
}
|
||||
|
||||
// we got some clean up due
|
||||
if (chaptersIdsToDelete.isNotEmpty()) {
|
||||
DownloadManager.dequeue(chaptersIdsToDelete)
|
||||
transaction {
|
||||
transaction {
|
||||
// we got some clean up due
|
||||
if (chaptersIdsToDelete.isNotEmpty()) {
|
||||
DownloadManager.dequeue(chaptersIdsToDelete)
|
||||
PageTable.deleteWhere { chapter inList chaptersIdsToDelete }
|
||||
ChapterTable.deleteWhere { id inList chaptersIdsToDelete }
|
||||
}
|
||||
}
|
||||
|
||||
transaction {
|
||||
if (chaptersToInsert.isNotEmpty()) {
|
||||
ChapterTable
|
||||
.batchInsert(chaptersToInsert) { chapter ->
|
||||
@@ -287,24 +279,31 @@ object Chapter {
|
||||
this[ChapterTable.isRead] = false
|
||||
this[ChapterTable.isBookmarked] = false
|
||||
this[ChapterTable.isDownloaded] = false
|
||||
this[ChapterTable.pageCount] = -1
|
||||
|
||||
// is recognized chapter number
|
||||
if (chapter.chapterNumber >= 0f && chapter.chapterNumber in deletedChapterNumbers) {
|
||||
this[ChapterTable.isRead] = chapter.chapterNumber in deletedReadChapterNumbers
|
||||
this[ChapterTable.isBookmarked] = chapter.chapterNumber in deletedBookmarkedChapterNumbers
|
||||
|
||||
// only preserve download status for chapters of the same scanlator, otherwise,
|
||||
// the downloaded files won't be found anyway
|
||||
val downloadedChapterInfo = deletedDownloadedChapterNumberInfoMap[chapter.chapterNumber]
|
||||
val pageCount = downloadedChapterInfo?.get(chapter.scanlator)
|
||||
if (pageCount != null) {
|
||||
this[ChapterTable.isDownloaded] = true
|
||||
this[ChapterTable.pageCount] = pageCount
|
||||
}
|
||||
// Try to use the fetch date of the original entry to not pollute 'Updates' tab
|
||||
deletedChapterNumberDateFetchMap[chapter.chapterNumber]?.let {
|
||||
this[ChapterTable.fetchedAt] = it
|
||||
}
|
||||
|
||||
deletedDownloadedChapterNumberToChapter[chapter.chapterNumber]?.let {
|
||||
val hasDownloadedPages = it.pageCount > 0
|
||||
val isSameName = it.name == chapter.name
|
||||
val isSameScanlator = it.scanlator == chapter.scanlator
|
||||
|
||||
// Only preserve download status for chapters with the same name and of the same scanlator; otherwise,
|
||||
// the downloaded files won't be found anyway
|
||||
val isDownloadPreservable = hasDownloadedPages && isSameName && isSameScanlator
|
||||
if (isDownloadPreservable) {
|
||||
this[ChapterTable.isDownloaded] = true
|
||||
this[ChapterTable.pageCount] = it.pageCount
|
||||
}
|
||||
}
|
||||
}
|
||||
}.forEach { insertedChapters.add(ChapterTable.toDataClass(it)) }
|
||||
}
|
||||
@@ -314,12 +313,30 @@ object Chapter {
|
||||
.apply {
|
||||
chaptersToUpdate.forEach {
|
||||
addBatch(EntityID(it.id, ChapterTable))
|
||||
|
||||
val currentChapter = chaptersInDb.find { dbChapter -> dbChapter.id == it.id }!!
|
||||
|
||||
this[ChapterTable.name] = it.name
|
||||
this[ChapterTable.date_upload] = it.uploadDate
|
||||
this[ChapterTable.chapter_number] = it.chapterNumber
|
||||
this[ChapterTable.scanlator] = it.scanlator
|
||||
this[ChapterTable.sourceOrder] = it.index
|
||||
this[ChapterTable.realUrl] = it.realUrl
|
||||
this[ChapterTable.isDownloaded] = currentChapter.downloaded
|
||||
this[ChapterTable.pageCount] = currentChapter.pageCount
|
||||
|
||||
if (!currentChapter.downloaded) {
|
||||
return@forEach
|
||||
}
|
||||
|
||||
val isSameScanlator = currentChapter.scanlator == it.scanlator
|
||||
val isSameName = currentChapter.name == it.name
|
||||
|
||||
val isDownloadPreservable = isSameName && isSameScanlator
|
||||
if (!isDownloadPreservable) {
|
||||
this[ChapterTable.isDownloaded] = false
|
||||
this[ChapterTable.pageCount] = -1
|
||||
}
|
||||
}
|
||||
}.toExecutable()
|
||||
.execute(this@transaction)
|
||||
|
||||
@@ -133,7 +133,7 @@ object Manga {
|
||||
""
|
||||
}
|
||||
if (remoteTitle.isNotEmpty() && remoteTitle != mangaEntry[MangaTable.title]) {
|
||||
val canUpdateTitle = updateMangaDownloadDir(mangaId, remoteTitle)
|
||||
val canUpdateTitle = updateMangaDownloadDir(mangaEntry[MangaTable.title], source.toString(), remoteTitle)
|
||||
|
||||
if (canUpdateTitle) {
|
||||
it[MangaTable.title] = remoteTitle
|
||||
|
||||
@@ -358,6 +358,7 @@ object Extension {
|
||||
} else {
|
||||
ExtensionTable.update({ ExtensionTable.pkgName eq pkgName }) {
|
||||
it[isInstalled] = false
|
||||
it[hasUpdate] = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,14 +24,22 @@ private val applicationDirs: ApplicationDirs by injectLazy()
|
||||
|
||||
private val logger = KotlinLogging.logger { }
|
||||
|
||||
private fun getMangaDir(
|
||||
title: String,
|
||||
sourceName: String,
|
||||
): String {
|
||||
val sourceDir = SafePath.buildValidFilename(sourceName)
|
||||
val mangaDir = SafePath.buildValidFilename(title)
|
||||
|
||||
return "$sourceDir/$mangaDir"
|
||||
}
|
||||
|
||||
private fun getMangaDir(mangaId: Int): String =
|
||||
transaction {
|
||||
val mangaEntry = MangaTable.selectAll().where { MangaTable.id eq mangaId }.first()
|
||||
val source = GetCatalogueSource.getCatalogueSourceOrStub(mangaEntry[MangaTable.sourceReference])
|
||||
|
||||
val sourceDir = SafePath.buildValidFilename(source.toString())
|
||||
val mangaDir = SafePath.buildValidFilename(mangaEntry[MangaTable.title])
|
||||
"$sourceDir/$mangaDir"
|
||||
getMangaDir(mangaEntry[MangaTable.title], source.toString())
|
||||
}
|
||||
|
||||
private fun getChapterDir(
|
||||
@@ -62,8 +70,18 @@ private fun getChapterDir(
|
||||
|
||||
fun getThumbnailDownloadPath(mangaId: Int): String = applicationDirs.thumbnailDownloadsRoot + "/$mangaId"
|
||||
|
||||
fun getMangaDownloadDir(
|
||||
title: String,
|
||||
sourceName: String,
|
||||
): String = applicationDirs.mangaDownloadsRoot + "/" + getMangaDir(title, sourceName)
|
||||
|
||||
fun getMangaDownloadDir(mangaId: Int): String = applicationDirs.mangaDownloadsRoot + "/" + getMangaDir(mangaId)
|
||||
|
||||
fun getMangaCacheDir(
|
||||
title: String,
|
||||
sourceName: String,
|
||||
): String = applicationDirs.tempMangaCacheRoot + "/" + getMangaDir(title, sourceName)
|
||||
|
||||
fun getChapterDownloadPath(
|
||||
mangaId: Int,
|
||||
chapterId: Int,
|
||||
@@ -79,38 +97,21 @@ fun getChapterCachePath(
|
||||
chapterId: Int,
|
||||
): String = applicationDirs.tempMangaCacheRoot + "/" + getChapterDir(mangaId, chapterId)
|
||||
|
||||
/** return value says if rename/move was successful */
|
||||
fun updateMangaDownloadDir(
|
||||
mangaId: Int,
|
||||
newTitle: String,
|
||||
private fun updateDownloadDir(
|
||||
currentDir: String,
|
||||
newDir: String,
|
||||
): Boolean {
|
||||
// Get current manga directory (uses its own transaction)
|
||||
val currentMangaDir = getMangaDir(mangaId)
|
||||
|
||||
// Build new directory path
|
||||
val newMangaDir =
|
||||
transaction {
|
||||
val mangaEntry = MangaTable.selectAll().where { MangaTable.id eq mangaId }.first()
|
||||
val source = GetCatalogueSource.getCatalogueSourceOrStub(mangaEntry[MangaTable.sourceReference])
|
||||
val sourceDir = SafePath.buildValidFilename(source.toString())
|
||||
val newMangaDirName = SafePath.buildValidFilename(newTitle)
|
||||
"$sourceDir/$newMangaDirName"
|
||||
}
|
||||
|
||||
val oldDir = "${applicationDirs.downloadsRoot}/$currentMangaDir"
|
||||
val newDir = "${applicationDirs.downloadsRoot}/$newMangaDir"
|
||||
|
||||
val oldDirFile = File(oldDir)
|
||||
val currentDirFile = File(currentDir)
|
||||
val newDirFile = File(newDir)
|
||||
|
||||
if (!oldDirFile.exists()) {
|
||||
if (!currentDirFile.exists()) {
|
||||
return true
|
||||
}
|
||||
|
||||
return try {
|
||||
Files.move(oldDirFile.toPath(), newDirFile.toPath())
|
||||
Files.move(currentDirFile.toPath(), newDirFile.toPath())
|
||||
|
||||
if (oldDirFile.exists()) {
|
||||
if (currentDirFile.exists()) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -118,9 +119,31 @@ fun updateMangaDownloadDir(
|
||||
return false
|
||||
}
|
||||
|
||||
true
|
||||
return true
|
||||
} 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
|
||||
}
|
||||
}
|
||||
|
||||
/** 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
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import java.util.Objects
|
||||
import kotlin.math.min
|
||||
|
||||
open class PaginatedList<T>(
|
||||
val page: List<T>,
|
||||
val hasNextPage: Boolean,
|
||||
)
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is PaginatedList<T>) return false
|
||||
return page == other.page && hasNextPage == other.hasNextPage
|
||||
}
|
||||
|
||||
override fun hashCode(): Int = Objects.hash(page, hasNextPage)
|
||||
}
|
||||
|
||||
const val PAGINATION_FACTOR = 50
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import suwayomi.tachidesk.manga.model.table.CategoryMetaTable.ref
|
||||
* Metadata storage for clients, about Category with id == [ref].
|
||||
*/
|
||||
object CategoryMetaTable : IntIdTable() {
|
||||
val key = varchar("key", 256)
|
||||
val key = varchar("meta_key", 256)
|
||||
val value = varchar("value", 4096)
|
||||
val ref = reference("category_ref", CategoryTable, ReferenceOption.CASCADE)
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ import suwayomi.tachidesk.manga.model.table.ChapterMetaTable.ref
|
||||
* }
|
||||
*/
|
||||
object ChapterMetaTable : IntIdTable() {
|
||||
val key = varchar("key", 256)
|
||||
val key = varchar("meta_key", 256)
|
||||
val value = varchar("value", 4096)
|
||||
val ref = reference("chapter_ref", ChapterTable, ReferenceOption.CASCADE)
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ import suwayomi.tachidesk.manga.model.table.MangaMetaTable.ref
|
||||
* }
|
||||
*/
|
||||
object MangaMetaTable : IntIdTable() {
|
||||
val key = varchar("key", 256)
|
||||
val key = varchar("meta_key", 256)
|
||||
val value = varchar("value", 4096)
|
||||
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].
|
||||
*/
|
||||
object SourceMetaTable : IntIdTable() {
|
||||
val key = varchar("key", 256)
|
||||
val key = varchar("meta_key", 256)
|
||||
val value = varchar("value", 4096)
|
||||
val ref = long("source_ref")
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import android.content.Context
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import suwayomi.tachidesk.manga.impl.update.IUpdater
|
||||
import suwayomi.tachidesk.server.database.H2Migration
|
||||
import suwayomi.tachidesk.server.util.ExitCode
|
||||
import suwayomi.tachidesk.server.util.shutdownApp
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.io.File
|
||||
@@ -99,31 +101,35 @@ private val MIGRATIONS =
|
||||
|
||||
fun runMigrations(applicationDirs: ApplicationDirs) {
|
||||
val logger = KotlinLogging.logger("Migration")
|
||||
try {
|
||||
val migrationPreferences =
|
||||
Injekt
|
||||
.get<Application>()
|
||||
.getSharedPreferences(
|
||||
"migrations",
|
||||
Context.MODE_PRIVATE,
|
||||
)
|
||||
val version = migrationPreferences.getInt("version", 0)
|
||||
|
||||
val migrationPreferences =
|
||||
Injekt
|
||||
.get<Application>()
|
||||
.getSharedPreferences(
|
||||
"migrations",
|
||||
Context.MODE_PRIVATE,
|
||||
)
|
||||
val version = migrationPreferences.getInt("version", 0)
|
||||
logger.info { "Running migrations, previous version $version, target version ${MIGRATIONS.size}" }
|
||||
|
||||
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 migrationVersion = index + 1
|
||||
val isMigrationRequired = version < migrationVersion
|
||||
if (!isMigrationRequired) {
|
||||
logger.info { "Skipping migration version $migrationVersion: $migrationName" }
|
||||
return@forEachIndexed
|
||||
}
|
||||
|
||||
val isMigrationRequired = version < migrationVersion
|
||||
if (!isMigrationRequired) {
|
||||
logger.info { "Skipping migration version $migrationVersion: $migrationName" }
|
||||
return@forEachIndexed
|
||||
logger.info { "Running migration version $migrationVersion: $migrationName" }
|
||||
|
||||
migrationFunction(applicationDirs)
|
||||
|
||||
migrationPreferences.edit().putInt("version", migrationVersion).apply()
|
||||
}
|
||||
|
||||
logger.info { "Running migration version $migrationVersion: $migrationName" }
|
||||
|
||||
migrationFunction(applicationDirs)
|
||||
|
||||
migrationPreferences.edit().putInt("version", migrationVersion).apply()
|
||||
} catch (e: Exception) {
|
||||
logger.error(e) { "Failed to run migrations" }
|
||||
shutdownApp(ExitCode.MigrationsRunFailure)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,15 +14,13 @@ import com.typesafe.config.ConfigException
|
||||
import com.typesafe.config.ConfigRenderOptions
|
||||
import com.typesafe.config.ConfigValue
|
||||
import com.typesafe.config.parser.ConfigDocument
|
||||
import dev.datlag.kcef.KCEF
|
||||
import dev.datlag.kcef.KCEFBuilder.Settings.LogSeverity
|
||||
import eu.kanade.tachiyomi.App
|
||||
import eu.kanade.tachiyomi.createAppModule
|
||||
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||
import eu.kanade.tachiyomi.source.local.LocalSource
|
||||
import io.github.config4k.toConfig
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import io.javalin.json.JavalinJackson
|
||||
import io.javalin.json.JavalinJackson3
|
||||
import io.javalin.json.JsonMapper
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
@@ -49,6 +47,7 @@ import suwayomi.tachidesk.server.database.databaseUp
|
||||
import suwayomi.tachidesk.server.generated.BuildConfig
|
||||
import suwayomi.tachidesk.server.settings.SettingsRegistry
|
||||
import suwayomi.tachidesk.server.util.AppMutex.handleAppMutex
|
||||
import suwayomi.tachidesk.server.util.CEFManager
|
||||
import suwayomi.tachidesk.server.util.ConfigTypeRegistration
|
||||
import suwayomi.tachidesk.server.util.ExitCode
|
||||
import suwayomi.tachidesk.server.util.SystemTray
|
||||
@@ -222,7 +221,7 @@ fun serverModule(applicationDirs: ApplicationDirs): Module =
|
||||
module {
|
||||
single { applicationDirs }
|
||||
single<IUpdater> { Updater() }
|
||||
single<JsonMapper> { JavalinJackson() }
|
||||
single<JsonMapper> { JavalinJackson3() }
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
@@ -366,6 +365,7 @@ fun applicationSetup() {
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error(e) { "Exception while creating initial server.conf" }
|
||||
shutdownApp(ExitCode.SetupConfFileFailed)
|
||||
}
|
||||
|
||||
// copy local source icon
|
||||
@@ -378,6 +378,7 @@ fun applicationSetup() {
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error(e) { "Exception while copying Local source's icon" }
|
||||
shutdownApp(ExitCode.LocalSourceIconCopyFailure)
|
||||
}
|
||||
|
||||
// fixes #119 , ref:
|
||||
@@ -395,7 +396,12 @@ fun applicationSetup() {
|
||||
|
||||
databaseUp()
|
||||
|
||||
LocalSource.register()
|
||||
try {
|
||||
LocalSource.register()
|
||||
} catch (e: Exception) {
|
||||
logger.error(e) { "Failed to setup LocalSource" }
|
||||
shutdownApp(ExitCode.LocalSourceSetupFailure)
|
||||
}
|
||||
|
||||
serverConfig.subscribeTo(
|
||||
combine<Any, DatabaseSettings>(
|
||||
@@ -511,56 +517,8 @@ fun applicationSetup() {
|
||||
// start DownloadManager and restore + resume downloads
|
||||
DownloadManager.restoreAndResumeDownloads()
|
||||
|
||||
// asynchronously initialize CEF
|
||||
GlobalScope.launch {
|
||||
val logger = KotlinLogging.logger("KCEF")
|
||||
KCEF.init(
|
||||
builder = {
|
||||
progress {
|
||||
var lastNum = -1
|
||||
onDownloading {
|
||||
val num = it.roundToInt()
|
||||
if (num > lastNum) {
|
||||
lastNum = num
|
||||
logger.info { "KCEF download progress: $num%" }
|
||||
}
|
||||
}
|
||||
}
|
||||
download { github() }
|
||||
settings {
|
||||
windowlessRenderingEnabled = true
|
||||
cachePath = (Path(applicationDirs.dataRoot) / "cache/kcef").toString()
|
||||
logSeverity = if (serverConfig.debugLogsEnabled.value) LogSeverity.Verbose else LogSeverity.Default
|
||||
}
|
||||
appHandler(
|
||||
KCEF.AppHandler(
|
||||
arrayOf(
|
||||
"--disable-gpu",
|
||||
// #1486 needed to be able to render without a window
|
||||
"--off-screen-rendering-enabled",
|
||||
// #1489 since /dev/shm is restricted in docker (OOM)
|
||||
"--disable-dev-shm-usage",
|
||||
// #1723 support Widevine (incomplete)
|
||||
"--enable-widevine-cdm",
|
||||
// #1736 JCEF does implement stack guards properly
|
||||
"--change-stack-guard-on-fork=disable",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val kcefDir = Path(applicationDirs.dataRoot) / "bin/kcef"
|
||||
kcefDir.createDirectories()
|
||||
installDir(kcefDir.toFile())
|
||||
},
|
||||
onError = { it?.printStackTrace() },
|
||||
)
|
||||
CEFManager.init()
|
||||
}
|
||||
|
||||
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 uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.sql.SQLException
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@@ -145,14 +144,15 @@ object DBManager {
|
||||
|
||||
private val logger = KotlinLogging.logger {}
|
||||
|
||||
fun databaseUp() {
|
||||
fun databaseUp(givenDb: Database? = null) {
|
||||
val db =
|
||||
try {
|
||||
DBManager.setupDatabase()
|
||||
} catch (e: Exception) {
|
||||
logger.error(e) { "Failed to setup Database" }
|
||||
return
|
||||
}
|
||||
givenDb
|
||||
?: try {
|
||||
DBManager.setupDatabase()
|
||||
} catch (e: Exception) {
|
||||
logger.error(e) { "Failed to setup Database" }
|
||||
return
|
||||
}
|
||||
|
||||
logger.info {
|
||||
"Using ${db.vendor} database version ${db.version}"
|
||||
@@ -184,10 +184,8 @@ fun databaseUp() {
|
||||
}
|
||||
val migrations = loadMigrationsFrom("suwayomi.tachidesk.server.database.migration", ServerConfig::class.java)
|
||||
runMigrations(migrations)
|
||||
} catch (e: SQLException) {
|
||||
} catch (e: Exception) {
|
||||
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 kotlin.io.path.Path
|
||||
import kotlin.io.path.absolutePathString
|
||||
import kotlin.io.path.bufferedReader
|
||||
import kotlin.io.path.bufferedWriter
|
||||
import kotlin.io.path.copyTo
|
||||
import kotlin.io.path.createDirectories
|
||||
import kotlin.io.path.deleteExisting
|
||||
import kotlin.io.path.deleteIfExists
|
||||
import kotlin.io.path.div
|
||||
import kotlin.io.path.exists
|
||||
import kotlin.io.path.name
|
||||
@@ -46,6 +49,10 @@ object H2Migration {
|
||||
}
|
||||
|
||||
val script = Path("$dbBase.${h2Old.substringAfterLast('.')}.sql")
|
||||
script.deleteIfExists()
|
||||
|
||||
val modifiedScript = Path("$dbBase.${h2Old.substringAfterLast('.')}.modified.sql")
|
||||
modifiedScript.deleteIfExists()
|
||||
|
||||
// Backup original database.
|
||||
val backup = Path("$dbBase.mv.db.${h2Old.substringAfterLast('.')}.backup")
|
||||
@@ -72,19 +79,32 @@ object H2Migration {
|
||||
libsDir.resolve("h2-$h2New.bin"),
|
||||
)
|
||||
|
||||
// Delete attempted migration if failed previously
|
||||
val newDatabase = Path(rootDir, "database.${h2New.substringAfterLast('.')}.mv.db")
|
||||
newDatabase.deleteIfExists()
|
||||
|
||||
val modifiedNewDatabase = Path(rootDir, "database.${h2Old.substringAfterLast('.')}.modified.${h2New.substringAfterLast('.')}.mv.db")
|
||||
modifiedNewDatabase.deleteIfExists()
|
||||
|
||||
runMigrationTool(
|
||||
migrationJar = migrationJar,
|
||||
libsDir = libsDir,
|
||||
mvStore = mvStore,
|
||||
script = script,
|
||||
modifiedScript = modifiedScript,
|
||||
h2Old = h2Old,
|
||||
h2New = h2New,
|
||||
)
|
||||
|
||||
// Move database to proper path
|
||||
val newDatabase = Path(rootDir, "database.${h2New.substringAfterLast('.')}.mv.db")
|
||||
newDatabase.copyTo(mvStore, overwrite = true)
|
||||
newDatabase.deleteExisting()
|
||||
if (modifiedNewDatabase.exists()) {
|
||||
modifiedNewDatabase.copyTo(mvStore, overwrite = true)
|
||||
modifiedNewDatabase.deleteExisting()
|
||||
newDatabase.deleteIfExists()
|
||||
} else {
|
||||
newDatabase.copyTo(mvStore, overwrite = true)
|
||||
newDatabase.deleteExisting()
|
||||
}
|
||||
|
||||
logger.info { "H2 migration completed successfully." }
|
||||
}
|
||||
@@ -123,6 +143,7 @@ object H2Migration {
|
||||
libsDir: Path,
|
||||
mvStore: Path,
|
||||
script: Path,
|
||||
modifiedScript: Path,
|
||||
h2Old: String,
|
||||
h2New: String,
|
||||
) {
|
||||
@@ -136,32 +157,77 @@ object H2Migration {
|
||||
val main =
|
||||
clazz.getMethod("main", Array<String>::class.java)
|
||||
|
||||
main.invoke(
|
||||
null,
|
||||
arrayOf(
|
||||
// h2 driver dir
|
||||
"-l",
|
||||
libsDir.absolutePathString(),
|
||||
// from version
|
||||
"-f",
|
||||
h2Old,
|
||||
// to version
|
||||
"-t",
|
||||
h2New,
|
||||
// user
|
||||
"-u",
|
||||
"",
|
||||
// password
|
||||
"-p",
|
||||
"",
|
||||
// database.mv.db
|
||||
"-d",
|
||||
mvStore.absolutePathString(),
|
||||
// database backup in SQL
|
||||
"-s",
|
||||
script.absolutePathString(),
|
||||
),
|
||||
)
|
||||
try {
|
||||
main.invoke(
|
||||
null,
|
||||
arrayOf(
|
||||
// h2 driver dir
|
||||
"-l",
|
||||
libsDir.absolutePathString(),
|
||||
// from version
|
||||
"-f",
|
||||
h2Old,
|
||||
// to version
|
||||
"-t",
|
||||
h2New,
|
||||
// user
|
||||
"-u",
|
||||
"",
|
||||
// password
|
||||
"-p",
|
||||
"",
|
||||
// database.mv.db
|
||||
"-d",
|
||||
mvStore.absolutePathString(),
|
||||
// database backup in SQL
|
||||
"-s",
|
||||
script.absolutePathString(),
|
||||
),
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
// Modify raw .sql file as needed for compatibility
|
||||
if (e.stackTraceToString().contains("Unknown data type: \"DATETIME\"; SQL statement:") && script.exists()) {
|
||||
script.bufferedReader().use { reader ->
|
||||
modifiedScript.bufferedWriter().use { writer ->
|
||||
reader.forEachLine { line ->
|
||||
writer.write(
|
||||
line.replace(
|
||||
" \"EXECUTED_AT\" DATETIME(9) NOT NULL",
|
||||
" \"EXECUTED_AT\" TIMESTAMP(9) NOT NULL",
|
||||
),
|
||||
)
|
||||
writer.newLine()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main.invoke(
|
||||
null,
|
||||
arrayOf(
|
||||
// h2 driver dir
|
||||
"-l",
|
||||
libsDir.absolutePathString(),
|
||||
// from version
|
||||
"-f",
|
||||
h2Old,
|
||||
// to version
|
||||
"-t",
|
||||
h2New,
|
||||
// user
|
||||
"-u",
|
||||
"",
|
||||
// password
|
||||
"-p",
|
||||
"",
|
||||
// database.mv.db
|
||||
"-d",
|
||||
modifiedScript.absolutePathString(),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,17 +10,10 @@ package suwayomi.tachidesk.server.database.migration
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import de.neonew.exposed.migrations.helpers.SQLMigration
|
||||
import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager
|
||||
import suwayomi.tachidesk.server.database.migration.helpers.toSqlName
|
||||
|
||||
@Suppress("ClassName", "unused")
|
||||
class M0023_CategoryMetaRefFix : SQLMigration() {
|
||||
fun String.toSqlName(): String =
|
||||
TransactionManager.defaultDatabase!!.identifierManager.let {
|
||||
it.quoteIfNecessary(
|
||||
it.inProperCase(this),
|
||||
)
|
||||
}
|
||||
|
||||
private val CategoryMetaTable by lazy { "CategoryMeta".toSqlName() }
|
||||
private val CategoryRefColumn by lazy { "category_ref".toSqlName() }
|
||||
private val CategoryTable by lazy { "Category".toSqlName() }
|
||||
|
||||
@@ -8,6 +8,7 @@ package suwayomi.tachidesk.server.database.migration
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import de.neonew.exposed.migrations.helpers.SQLMigration
|
||||
import suwayomi.tachidesk.server.database.migration.helpers.toSqlName
|
||||
|
||||
@Suppress("ClassName", "unused")
|
||||
class M0049_FixDuplicatedMetas : SQLMigration() {
|
||||
@@ -15,7 +16,7 @@ class M0049_FixDuplicatedMetas : SQLMigration() {
|
||||
table: String,
|
||||
refColumn: String? = null,
|
||||
): String {
|
||||
val groupBy = listOfNotNull(refColumn, "KEY").joinToString(", ")
|
||||
val groupBy = listOfNotNull(refColumn, "KEY".toSqlName()).joinToString(", ")
|
||||
|
||||
return """
|
||||
DELETE FROM $table
|
||||
@@ -30,10 +31,11 @@ class M0049_FixDuplicatedMetas : SQLMigration() {
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
override val sql: String =
|
||||
override val sql: String by lazy {
|
||||
createMigrationForTable("CATEGORYMETA", "CATEGORY_REF") +
|
||||
createMigrationForTable("CHAPTERMETA", "CHAPTER_REF") +
|
||||
createMigrationForTable("GLOBALMETA") +
|
||||
createMigrationForTable("MANGAMETA", "MANGA_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
|
||||
|
||||
import de.neonew.exposed.migrations.helpers.SQLMigration
|
||||
import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager
|
||||
import suwayomi.tachidesk.graphql.types.DatabaseType
|
||||
import suwayomi.tachidesk.server.serverConfig
|
||||
|
||||
fun String.toSqlName(): String =
|
||||
TransactionManager.current().db.identifierManager.let {
|
||||
it.quoteIfNecessary(
|
||||
it.inProperCase(this),
|
||||
)
|
||||
}
|
||||
|
||||
abstract class RenameFieldMigration(
|
||||
tableName: String,
|
||||
originalName: String,
|
||||
|
||||
@@ -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),
|
||||
ConfigMigrationMisconfiguredFailure(4),
|
||||
DbMigrationFailure(5),
|
||||
SetupConfFileFailed(6),
|
||||
LocalSourceIconCopyFailure(7),
|
||||
LocalSourceSetupFailure(8),
|
||||
MigrationsRunFailure(9),
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
import android.os.Looper
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.AfterAll
|
||||
import org.junit.jupiter.api.BeforeAll
|
||||
import org.junit.jupiter.api.Disabled
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
import org.koin.core.context.stopKoin
|
||||
import suwayomi.tachidesk.manga.impl.Source
|
||||
import suwayomi.tachidesk.manga.impl.extension.Extension
|
||||
import suwayomi.tachidesk.manga.impl.extension.ExtensionsList
|
||||
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource
|
||||
import suwayomi.tachidesk.server.applicationSetup
|
||||
import suwayomi.tachidesk.server.settings.SettingsRegistry
|
||||
import suwayomi.tachidesk.test.BASE_PATH
|
||||
import suwayomi.tachidesk.test.setLoggingEnabled
|
||||
import xyz.nulldev.ts.config.CONFIG_PREFIX
|
||||
@@ -25,8 +30,11 @@ class CloudFlareTest {
|
||||
fun setup() {
|
||||
val dataRoot = File(BASE_PATH).absolutePath
|
||||
System.setProperty("$CONFIG_PREFIX.server.rootDir", dataRoot)
|
||||
Looper.clearMainLooperForTest()
|
||||
SettingsRegistry.clear()
|
||||
applicationSetup()
|
||||
setLoggingEnabled(false)
|
||||
return
|
||||
|
||||
runBlocking {
|
||||
val extensions = ExtensionsList.getExtensionList()
|
||||
@@ -48,9 +56,15 @@ class CloudFlareTest {
|
||||
setLoggingEnabled(true)
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
fun teardown() {
|
||||
stopKoin()
|
||||
}
|
||||
|
||||
private val logger = KotlinLogging.logger {}
|
||||
|
||||
@Test
|
||||
@Disabled
|
||||
fun `test nhentai browse`() =
|
||||
runTest {
|
||||
assert(nhentai.getPopularManga(1).mangas.isNotEmpty()) {
|
||||
|
||||
@@ -7,6 +7,7 @@ package masstest
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import android.os.Looper
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
@@ -17,9 +18,11 @@ import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import org.junit.jupiter.api.AfterAll
|
||||
import org.junit.jupiter.api.BeforeAll
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
import org.koin.core.context.stopKoin
|
||||
import suwayomi.tachidesk.manga.impl.Source.getSourceList
|
||||
import suwayomi.tachidesk.manga.impl.extension.Extension.installExtension
|
||||
import suwayomi.tachidesk.manga.impl.extension.Extension.uninstallExtension
|
||||
@@ -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.model.dataclass.ExtensionDataClass
|
||||
import suwayomi.tachidesk.server.applicationSetup
|
||||
import suwayomi.tachidesk.server.settings.SettingsRegistry
|
||||
import suwayomi.tachidesk.test.BASE_PATH
|
||||
import suwayomi.tachidesk.test.setLoggingEnabled
|
||||
import xyz.nulldev.ts.config.CONFIG_PREFIX
|
||||
@@ -51,6 +55,8 @@ class TestExtensionCompatibility {
|
||||
fun setup() {
|
||||
val dataRoot = File(BASE_PATH).absolutePath
|
||||
System.setProperty("$CONFIG_PREFIX.server.rootDir", dataRoot)
|
||||
Looper.clearMainLooperForTest()
|
||||
SettingsRegistry.clear()
|
||||
applicationSetup()
|
||||
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)
|
||||
File("$BASE_PATH/sources.txt").writeText(sources.joinToString("\n") { "${it.name} - ${it.lang.uppercase()} - ${it.id}" })
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
fun teardown() {
|
||||
stopKoin()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun runTest() {
|
||||
runBlocking(Dispatchers.Default) {
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
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() {
|
||||
@Test
|
||||
fun categoryReorder() {
|
||||
clearTables(
|
||||
CategoryTable,
|
||||
)
|
||||
Category.createCategory("foo")
|
||||
Category.createCategory("bar")
|
||||
val cats = Category.getCategoryList()
|
||||
val foo = cats.asSequence().filter { it.name == "foo" }.first()
|
||||
val bar = cats.asSequence().filter { it.name == "bar" }.first()
|
||||
assertEquals(1, foo.order)
|
||||
assertEquals(2, bar.order)
|
||||
assertEquals(0, foo.order)
|
||||
assertEquals(1, bar.order)
|
||||
Category.reorderCategory(1, 2)
|
||||
val catsReordered = Category.getCategoryList()
|
||||
val fooReordered = catsReordered.asSequence().filter { it.name == "foo" }.first()
|
||||
val barReordered = catsReordered.asSequence().filter { it.name == "bar" }.first()
|
||||
assertEquals(2, fooReordered.order)
|
||||
assertEquals(1, barReordered.order)
|
||||
assertEquals(1, fooReordered.order)
|
||||
assertEquals(0, barReordered.order)
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
|
||||
@@ -35,7 +35,7 @@ class CategoryMangaTest : ApplicationTest() {
|
||||
CategoryManga.getCategoryMangaList(DEFAULT_CATEGORY_ID)[0].unreadCount,
|
||||
"Manga should not have any unread chapters",
|
||||
)
|
||||
createChapters(mangaId, 10, false)
|
||||
createChapters(mangaId, 10, false, start = 11)
|
||||
assertEquals(
|
||||
10,
|
||||
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.source.local.LocalSource
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import org.jetbrains.exposed.v1.core.DatabaseConfig
|
||||
import org.jetbrains.exposed.v1.core.ExperimentalKeywordApi
|
||||
import org.jetbrains.exposed.v1.jdbc.Database
|
||||
import org.junit.jupiter.api.BeforeAll
|
||||
import org.koin.core.context.startKoin
|
||||
import org.koin.core.context.stopKoin
|
||||
import suwayomi.tachidesk.server.ApplicationDirs
|
||||
import suwayomi.tachidesk.server.JavalinSetup
|
||||
import suwayomi.tachidesk.server.ServerConfig
|
||||
@@ -22,7 +25,9 @@ import suwayomi.tachidesk.server.androidCompat
|
||||
import suwayomi.tachidesk.server.database.databaseUp
|
||||
import suwayomi.tachidesk.server.serverConfig
|
||||
import suwayomi.tachidesk.server.serverModule
|
||||
import suwayomi.tachidesk.server.settings.SettingsRegistry
|
||||
import suwayomi.tachidesk.server.util.AppMutex.handleAppMutex
|
||||
import suwayomi.tachidesk.server.util.ConfigTypeRegistration
|
||||
import suwayomi.tachidesk.server.util.SystemTray
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
@@ -55,6 +60,13 @@ open class ApplicationTest {
|
||||
private var initializedTheApp = false
|
||||
|
||||
fun testingSetup() {
|
||||
// register Tachidesk's config which is dubbed "ServerConfig"
|
||||
SettingsRegistry.clear()
|
||||
ConfigTypeRegistration.registerCustomTypes()
|
||||
GlobalConfigManager.registerModule(
|
||||
ServerConfig.register { GlobalConfigManager.config },
|
||||
)
|
||||
|
||||
// Application dirs
|
||||
val applicationDirs = ApplicationDirs()
|
||||
|
||||
@@ -72,13 +84,9 @@ open class ApplicationTest {
|
||||
File(it).mkdirs()
|
||||
}
|
||||
|
||||
// register Tachidesk's config which is dubbed "ServerConfig"
|
||||
GlobalConfigManager.registerModule(
|
||||
ServerConfig.register { GlobalConfigManager.config },
|
||||
)
|
||||
|
||||
// initialize Koin modules
|
||||
val app = App()
|
||||
stopKoin()
|
||||
startKoin {
|
||||
modules(
|
||||
createAppModule(app),
|
||||
@@ -128,14 +136,14 @@ open class ApplicationTest {
|
||||
}
|
||||
|
||||
// create system tray
|
||||
if (serverConfig.systemTrayEnabled.value) {
|
||||
try {
|
||||
SystemTray.create()
|
||||
} catch (e: Throwable) {
|
||||
// cover both java.lang.Exception and java.lang.Error
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
// if (serverConfig.systemTrayEnabled.value) {
|
||||
// try {
|
||||
// SystemTray.create()
|
||||
// } catch (e: Throwable) {
|
||||
// // cover both java.lang.Exception and java.lang.Error
|
||||
// e.printStackTrace()
|
||||
// }
|
||||
// }
|
||||
|
||||
// Disable jetty's logging
|
||||
System.setProperty("org.eclipse.jetty.util.log.announce", "false")
|
||||
@@ -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()
|
||||
Locale.setDefault(Locale.ENGLISH)
|
||||
|
||||
val dbConfig =
|
||||
DatabaseConfig {
|
||||
useNestedTransactions = true
|
||||
@OptIn(ExperimentalKeywordApi::class)
|
||||
preserveKeywordCasing = false
|
||||
defaultSchema = null
|
||||
}
|
||||
|
||||
// in-memory database, don't discard database between connections/transactions
|
||||
val db = Database.connect("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;", "org.h2.Driver")
|
||||
val db = Database.connect("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;", "org.h2.Driver", databaseConfig = dbConfig)
|
||||
|
||||
databaseUp(db)
|
||||
|
||||
|
||||
@@ -55,8 +55,9 @@ fun createChapters(
|
||||
mangaId: Int,
|
||||
amount: Int,
|
||||
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 {
|
||||
ChapterTable
|
||||
.batchInsert(list) {
|
||||
|
||||
Reference in New Issue
Block a user