Compare commits

..

28 Commits

Author SHA1 Message Date
renovate[bot]
730c76e7b1 Update dependency io.github.oshai:kotlin-logging-jvm to v8.0.03 2026-05-23 15:43:35 +00:00
schroda
6493eaaa02 Fix not All/Any filters (#2064)
Both filters were inversed. `notAll` did what `notAny` was supposed to do and vise versa

Co-authored-by: Mitchell Syer <Syer10@users.noreply.github.com>
2026-05-20 20:15:45 -04:00
schroda
701e4674ea Fix gql Filter UnsupportedOperationException (#2063) 2026-05-20 20:15:18 -04:00
Syer10
75fa4b4b23 [skip ci] MessesgeQueue changelog 2026-05-19 17:09:59 -04:00
Constantin Piber
00861d7750 Switch to JCEF (#2038)
* Switch to JCEF

This is a full implementation, but it does not yet include downloading
CEF as KCEF did

* Download CEF automatically

* Handle and propagate CEF init errors

* Lint

* Simplify jcef version extract

* CEF: Download async

* Copy StartupAsync to support handling errors

Startup failures are simply swallowed, since they are recorded in the
future, but there is no way to get that exception

* CEF: Search for release file recursively

On Mac, the file is buried a bit deeper than first level, like on Win
and Linux

* KcefWebViewProvider: Suppress deprecation

We need to send those events, even if they are deprecated

* Update readme

* Optimize imports

* Suggestion

Co-authored-by: Mitchell Syer <syer10@users.noreply.github.com>

* Refactor: stick to `Path` instead of `File`

Also extracts the downloading of CEF to a separate method

* Lint

* Support disabling CEF

Co-authored-by: Kolby Moroz Liebl <31669092+kolbyml@users.noreply.github.com>

* Move JBR version to build constants

Allows embedding into Manifest so docker can later extract the proper version

* Create test to verify JCEF dependency matches downloaded JBR

* Update server/src/main/kotlin/suwayomi/tachidesk/server/util/CEFManager.kt

Co-authored-by: Mitchell Syer <Syer10@users.noreply.github.com>

* Apply suggestions from code review

Co-authored-by: Mitchell Syer <Syer10@users.noreply.github.com>

* Fix compile, apply Path suggestions

* Download progress

* Lint

* Fix exception on non-posix

* Delete recursively

Others can be non-empty

* Support disabling CEF at will

Not really functional, but nice

* Fix test

* Exclude masstest unless explicitly requested

* PR-CI: Run tests

* Add Changelog entry

---------

Co-authored-by: Mitchell Syer <syer10@users.noreply.github.com>
Co-authored-by: Kolby Moroz Liebl <31669092+kolbyml@users.noreply.github.com>
2026-05-19 17:05:59 -04:00
renovate[bot]
fff291cdb5 Update graphqlkotlin to v10.0.0-alpha.4 (#2055)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-19 17:05:36 -04:00
schroda
70f3036f58 Fix NullPointerException (#2056) 2026-05-19 17:05:09 -04:00
Constantin Piber
cc75ad328d Switch to LegacyMessageQueue (#2054) 2026-05-19 17:05:02 -04:00
schroda
c0618fcc5c Try to keep cached images usable on manga rename (#2052) 2026-05-18 14:17:52 -04:00
schroda
9686f75a2d Fix/losing downloads on manga rename during update (#2051)
* Fix renaming manga download dir

* Simplify manga download dir rename function

---------

Co-authored-by: Mitchell Syer <Syer10@users.noreply.github.com>
2026-05-18 14:05:21 -04:00
schroda
4d5307f15b Fix/chapter list update preserving download state (#2050)
* Fix preserving chapter download state of deleted chapters

* Fix preserving chapter download state of updated chapters
2026-05-18 14:04:49 -04:00
Constantin Piber
779229a48a Fix tests (#2049)
* Fix test setup

* Fix tests

* Disable broken CloudflareTest

* Add a basic test for Android's Looper
2026-05-18 14:04:39 -04:00
Constantin Piber
762d5bdbe6 [skip ci] Add workflow_dispatch trigger to wiki upload (#2046) 2026-05-17 12:32:25 -04:00
schroda
41bb6d3dc1 Fix sorting of gql mangas query (#2043)
Regression fbb383b1f1

Broke sorting due to ordering the manga by their id first, thus, the other orderings were never applied
2026-05-16 20:16:27 -04:00
schroda
fbb383b1f1 Fix mangas query with active sorting and postgresql db (#2042)
fixes #2036
2026-05-16 19:41:37 -04:00
Constantin Piber
558407d92c Update Troubleshooting (#2029)
* Simplify general section

* Troubleshooting: Document some common problems and their solutions

* Remove icon since it's not rendered anyways

[skip ci]

* Update corrupt DB examples

[skip ci]
2026-05-16 19:08:47 -04:00
schroda
6870922784 Fix chapter update failure db rollback (#2040)
The deletion of chapter data was done in its own transaction. Thus, when the update or insertion failed later on, the deletion was not rolled back

fixes #2031

Co-authored-by: Mitchell Syer <Syer10@users.noreply.github.com>
2026-05-16 19:08:38 -04:00
AeonLucid
a4b647972e Add default pageCount value to fix PSQLException (#2039)
* Add default pageCount value to fix PSQLException

* Update CHANGELOG.md
2026-05-16 19:07:56 -04:00
Constantin Piber
16a14e6ac2 Pin CEF version to one known to work with KCEF (#2027)
Fixes problems like
```
java.lang.ClassNotFoundException: org.cef.callback.CefResourceReadCallback_N
```
and
```
Exception in thread "Thread-584" java.lang.NoSuchMethodError: open
```
2026-05-14 11:45:30 -04:00
Constantin Piber
a2f29ec9dc Reset update-flag on uninstall (#2025)
* Reset update-flag on uninstall

If there is an update available when the extension is uninstalled, the
table will still have the update flag, which makes no sense if it is not
installed.

Example:
```
{
  "pkgName": "eu.kanade.tachiyomi.extension.en.comix",
  "name": "Comix",
  "lang": "en",
  "versionCode": 20,
  "versionName": "1.4.20",
  "iconUrl": "/api/v1/extension/icon/tachiyomi-en.comix-v1.4.20.apk",
  "repo": "<hidden>",
  "isNsfw": true,
  "isInstalled": false,
  "isObsolete": false,
  "hasUpdate": true,
  "__typename": "ExtensionType"
},
```

* Update changelog
2026-05-14 11:44:59 -04:00
Mitchell Syer
82df985201 Crash on startup if an unrecoverable error happens (#2019)
* Crash on startup if an unrecoverable error happens

* Changelog
2026-05-14 11:44:52 -04:00
renovate[bot]
740db4f1ab Update javalin to v7.2.2 (#2026)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-14 11:44:34 -04:00
renovate[bot]
c4711dec00 Update dependency com.github.junrar:junrar to v7.6.0 (#2022)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-14 11:44:18 -04:00
renovate[bot]
75d8d172aa Update dependency org.slf4j:slf4j-api to v2.0.18 (#2017)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-14 11:44:00 -04:00
renovate[bot]
81fb8c395d Update Gradle to v9.5.1 (#2015)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-14 11:43:46 -04:00
Mitchell Syer
e93efa9627 Fix Database Types as Needed (#2020) 2026-05-12 19:59:06 -04:00
Mitchell Syer
03a95e6652 Fix New Databases (#2016)
* Standardize toSqlName

* Rename Meta Key db field since KEY is now a reserved name in H2

* Changelog entry

* Use toSqlName

* Forgot this key

* Catch any exception
2026-05-12 17:22:35 -04:00
renovate[bot]
c117d380a3 Update exposed to v1 (major) (#1868)
* Update exposed to v1

* Update Exposed

* Add Kotlinx.DateTime extensions

* Update H2

* Review comments

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Syer10 <syer10@users.noreply.github.com>
2026-05-12 12:53:41 -04:00
51 changed files with 2587 additions and 1712 deletions

View File

@@ -67,7 +67,7 @@ jobs:
export LD_PRELOAD="$(pwd)/scripts/resources/catch_abort.so"
JAR=$(ls ./server/build/*.jar| head -1)
set +e
timeout 30s java -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

View File

@@ -1,6 +1,7 @@
name: GitHub Wiki upload
on:
workflow_dispatch:
push:
branches:
- master

File diff suppressed because it is too large Load Diff

View File

@@ -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)
}

View File

@@ -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()
}
}

View File

@@ -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,

View File

@@ -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

View File

@@ -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.

View File

@@ -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")
}
}

View File

@@ -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()

View File

@@ -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

View File

@@ -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.

View File

@@ -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 = [

View File

@@ -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

View File

@@ -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")

View File

@@ -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)"
)
/** ****************************************************************** **/

View File

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

View File

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

View File

@@ -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

View File

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

View File

@@ -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))

View File

@@ -435,7 +435,7 @@ fun <T : String, S : T?> andFilterWithCompareString(
opAnd.andWhere(filter.isNull) { if (it) column.isNull() else column.isNotNull() }
opAnd.andWhere(filter.equalTo) { column eq it as S }
opAnd.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()) {

View File

@@ -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)

View File

@@ -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

View File

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

View File

@@ -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
}

View File

@@ -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

View File

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

View File

@@ -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)
}

View File

@@ -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)
}

View File

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

View File

@@ -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)
}
}

View File

@@ -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" }
},
)
}

View File

@@ -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)
}
}

View File

@@ -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
}
}
}
}
}

View File

@@ -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() }

View File

@@ -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")
}
}

View File

@@ -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")
}
}

View File

@@ -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,

View File

@@ -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),
)
}

View File

@@ -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) {

View File

@@ -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()
}
}
}
}

View File

@@ -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)
}

View File

@@ -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()) {

View File

@@ -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) {

View 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()
}
}
}

View 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()
}
}

View File

@@ -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

View File

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

View File

@@ -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)

View File

@@ -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) {