mirror of
https://github.com/Suwayomi/Suwayomi-Server.git
synced 2026-07-03 02:44:34 -05:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4a3dad4e8 | ||
|
|
6934d344f0 | ||
|
|
62ee91ff0e | ||
|
|
f37d7c841b | ||
|
|
86aaf28046 | ||
|
|
34f658e5f2 | ||
|
|
597022f24a | ||
|
|
0458a80c17 | ||
|
|
cbefe1125d | ||
|
|
cde5dc5bfa | ||
|
|
f2a650ba02 | ||
|
|
871c28b1ea | ||
|
|
d3aa32147a | ||
|
|
9a50f2e408 | ||
|
|
dcde4947e8 | ||
|
|
5b61bdc3a8 | ||
|
|
ec1d65f4c3 | ||
|
|
a0081dec07 | ||
|
|
783787e514 | ||
|
|
ac99dd55a2 | ||
|
|
c56f984952 | ||
|
|
9269ca726e | ||
|
|
eca3205dcf | ||
|
|
13f5486d0b | ||
|
|
d4e71274f9 |
8
.github/workflows/winget.yml
vendored
8
.github/workflows/winget.yml
vendored
@@ -1,12 +1,14 @@
|
|||||||
name: Publish to WinGet
|
name: Publish to WinGet
|
||||||
on:
|
on:
|
||||||
release:
|
workflow_run:
|
||||||
types: [released]
|
workflows: ["CI Publish"]
|
||||||
|
types:
|
||||||
|
- completed
|
||||||
jobs:
|
jobs:
|
||||||
publish:
|
publish:
|
||||||
runs-on: windows-latest # action can only be run on windows
|
runs-on: windows-latest # action can only be run on windows
|
||||||
steps:
|
steps:
|
||||||
- uses: vedantmgoyal2009/winget-releaser@v1
|
- uses: vedantmgoyal2009/winget-releaser@v2
|
||||||
with:
|
with:
|
||||||
identifier: Suwayomi.Tachidesk-Server
|
identifier: Suwayomi.Tachidesk-Server
|
||||||
installers-regex: '.*x64.msi$'
|
installers-regex: '.*x64.msi$'
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ package xyz.nulldev.androidcompat.io.sharedprefs
|
|||||||
|
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
import com.russhwolf.settings.ExperimentalSettingsApi
|
import com.russhwolf.settings.ExperimentalSettingsApi
|
||||||
import com.russhwolf.settings.ExperimentalSettingsImplementation
|
|
||||||
import com.russhwolf.settings.PreferencesSettings
|
import com.russhwolf.settings.PreferencesSettings
|
||||||
import com.russhwolf.settings.serialization.decodeValue
|
import com.russhwolf.settings.serialization.decodeValue
|
||||||
import com.russhwolf.settings.serialization.decodeValueOrNull
|
import com.russhwolf.settings.serialization.decodeValueOrNull
|
||||||
@@ -21,7 +20,7 @@ import kotlinx.serialization.builtins.serializer
|
|||||||
import java.util.prefs.PreferenceChangeListener
|
import java.util.prefs.PreferenceChangeListener
|
||||||
import java.util.prefs.Preferences
|
import java.util.prefs.Preferences
|
||||||
|
|
||||||
@OptIn(ExperimentalSettingsImplementation::class, ExperimentalSerializationApi::class, ExperimentalSettingsApi::class)
|
@OptIn(ExperimentalSerializationApi::class, ExperimentalSettingsApi::class)
|
||||||
class JavaSharedPreferences(key: String) : SharedPreferences {
|
class JavaSharedPreferences(key: String) : SharedPreferences {
|
||||||
private val javaPreferences = Preferences.userRoot().node("suwayomi/tachidesk/$key")
|
private val javaPreferences = Preferences.userRoot().node("suwayomi/tachidesk/$key")
|
||||||
private val preferences = PreferencesSettings(javaPreferences)
|
private val preferences = PreferencesSettings(javaPreferences)
|
||||||
@@ -77,13 +76,19 @@ class JavaSharedPreferences(key: String) : SharedPreferences {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class Editor(private val preferences: PreferencesSettings) : SharedPreferences.Editor {
|
class Editor(private val preferences: PreferencesSettings) : SharedPreferences.Editor {
|
||||||
val itemsToAdd = mutableMapOf<String, Any>()
|
private val actions = mutableListOf<Action>()
|
||||||
|
|
||||||
|
private sealed class Action {
|
||||||
|
data class Add(val key: String, val value: Any) : Action()
|
||||||
|
data class Remove(val key: String) : Action()
|
||||||
|
object Clear : Action()
|
||||||
|
}
|
||||||
|
|
||||||
override fun putString(key: String, value: String?): SharedPreferences.Editor {
|
override fun putString(key: String, value: String?): SharedPreferences.Editor {
|
||||||
if (value != null) {
|
if (value != null) {
|
||||||
itemsToAdd[key] = value
|
actions += Action.Add(key, value)
|
||||||
} else {
|
} else {
|
||||||
remove(key)
|
actions += Action.Remove(key)
|
||||||
}
|
}
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
@@ -93,40 +98,40 @@ class JavaSharedPreferences(key: String) : SharedPreferences {
|
|||||||
values: MutableSet<String>?
|
values: MutableSet<String>?
|
||||||
): SharedPreferences.Editor {
|
): SharedPreferences.Editor {
|
||||||
if (values != null) {
|
if (values != null) {
|
||||||
itemsToAdd[key] = values
|
actions += Action.Add(key, values)
|
||||||
} else {
|
} else {
|
||||||
remove(key)
|
actions += Action.Remove(key)
|
||||||
}
|
}
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun putInt(key: String, value: Int): SharedPreferences.Editor {
|
override fun putInt(key: String, value: Int): SharedPreferences.Editor {
|
||||||
itemsToAdd[key] = value
|
actions += Action.Add(key, value)
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun putLong(key: String, value: Long): SharedPreferences.Editor {
|
override fun putLong(key: String, value: Long): SharedPreferences.Editor {
|
||||||
itemsToAdd[key] = value
|
actions += Action.Add(key, value)
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun putFloat(key: String, value: Float): SharedPreferences.Editor {
|
override fun putFloat(key: String, value: Float): SharedPreferences.Editor {
|
||||||
itemsToAdd[key] = value
|
actions += Action.Add(key, value)
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun putBoolean(key: String, value: Boolean): SharedPreferences.Editor {
|
override fun putBoolean(key: String, value: Boolean): SharedPreferences.Editor {
|
||||||
itemsToAdd[key] = value
|
actions += Action.Add(key, value)
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun remove(key: String): SharedPreferences.Editor {
|
override fun remove(key: String): SharedPreferences.Editor {
|
||||||
itemsToAdd.remove(key)
|
actions += Action.Remove(key)
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun clear(): SharedPreferences.Editor {
|
override fun clear(): SharedPreferences.Editor {
|
||||||
itemsToAdd.clear()
|
actions.add(Action.Clear)
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,16 +145,33 @@ class JavaSharedPreferences(key: String) : SharedPreferences {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun addToPreferences() {
|
private fun addToPreferences() {
|
||||||
itemsToAdd.forEach { (key, value) ->
|
actions.forEach {
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
when (value) {
|
when (it) {
|
||||||
is Set<*> -> preferences.encodeValue(SetSerializer(String.serializer()), key, value as Set<String>)
|
is Action.Add -> when (val value = it.value) {
|
||||||
is String -> preferences.putString(key, value)
|
is Set<*> -> preferences.encodeValue(SetSerializer(String.serializer()), it.key, value as Set<String>)
|
||||||
is Int -> preferences.putInt(key, value)
|
is String -> preferences.putString(it.key, value)
|
||||||
is Long -> preferences.putLong(key, value)
|
is Int -> preferences.putInt(it.key, value)
|
||||||
is Float -> preferences.putFloat(key, value)
|
is Long -> preferences.putLong(it.key, value)
|
||||||
is Double -> preferences.putDouble(key, value)
|
is Float -> preferences.putFloat(it.key, value)
|
||||||
is Boolean -> preferences.putBoolean(key, value)
|
is Double -> preferences.putDouble(it.key, value)
|
||||||
|
is Boolean -> preferences.putBoolean(it.key, value)
|
||||||
|
}
|
||||||
|
is Action.Remove -> {
|
||||||
|
preferences.remove(it.key)
|
||||||
|
/**
|
||||||
|
* Set<String> are stored like
|
||||||
|
* key.0 = value1
|
||||||
|
* key.1 = value2
|
||||||
|
* key.size = 2
|
||||||
|
*/
|
||||||
|
preferences.keys.forEach { key ->
|
||||||
|
if (key.startsWith(it.key + ".")) {
|
||||||
|
preferences.remove(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Action.Clear -> preferences.clear()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
73
CHANGELOG.md
73
CHANGELOG.md
@@ -1,3 +1,76 @@
|
|||||||
|
# Server: v0.7.0 + WebUI: r983
|
||||||
|
## TL;DR
|
||||||
|
- CBZ downloads support
|
||||||
|
- Webview implementation based on Microsoft playwright, disabled for this release
|
||||||
|
- Fixed compatibility with some chinese extensions
|
||||||
|
- Support for Tachiyomi extensions lib 1.4
|
||||||
|
- WebUI changes:
|
||||||
|
- Uhh, idk, find out yourself...
|
||||||
|
|
||||||
|
## Tachidesk-Server Changelog
|
||||||
|
- (r1159) v0.6.6 (by @AriaMoradi)
|
||||||
|
- (r1160) add Chagelog TL;DR (by @AriaMoradi)
|
||||||
|
- (r1161) fix Changelog typos (by @AriaMoradi)
|
||||||
|
- (r1162) WebView based cloudflare interceptor ([#456](https://github.com/Suwayomi/Tachidesk-Server/pull/456) by @AriaMoradi)
|
||||||
|
- (r1163) update issue mod (by @AriaMoradi)
|
||||||
|
- (r1164) better description (by @AriaMoradi)
|
||||||
|
- (r1165) fix regex (by @AriaMoradi)
|
||||||
|
- (r1166) get default User Agent from WebView ([#457](https://github.com/Suwayomi/Tachidesk-Server/pull/457) by @AriaMoradi)
|
||||||
|
- (r1167) implementation of android.graphics.BitmapFactory ([#460](https://github.com/Suwayomi/Tachidesk-Server/pull/460) by @animeavi)
|
||||||
|
- (r1168) Basic android.graphics Rect and Canvas implementation ([#461](https://github.com/Suwayomi/Tachidesk-Server/pull/461) by @animeavi)
|
||||||
|
- (r1169) Get Playwright working ([#462](https://github.com/Suwayomi/Tachidesk-Server/pull/462) by @Syer10)
|
||||||
|
- (r1170) disable deb release (by @AriaMoradi)
|
||||||
|
- (r1171) Fix debian release ([#463](https://github.com/Suwayomi/Tachidesk-Server/pull/463) by @mahor1221)
|
||||||
|
- (r1172) Add better manga thumbnail handling ([#465](https://github.com/Suwayomi/Tachidesk-Server/pull/465) by @Syer10)
|
||||||
|
- (r1173) Use extension list fallback if extensions fail to fetch ([#469](https://github.com/Suwayomi/Tachidesk-Server/pull/469) by @Syer10)
|
||||||
|
- (r1174) fix when playwright fails on providing a UA (by @AriaMoradi)
|
||||||
|
- (r1175) Update CategoryMetaTable.kt (by @AriaMoradi)
|
||||||
|
- (r1176) fix CategoryMetaTable reference to CategoryTable ([#473](https://github.com/Suwayomi/Tachidesk-Server/pull/473) by @AriaMoradi)
|
||||||
|
- (r1177) remove possibly misleading sentence (by @AriaMoradi)
|
||||||
|
- (r1178) Clarify and Update (by @AriaMoradi)
|
||||||
|
- (r1179) Clarify and Update (by @AriaMoradi)
|
||||||
|
- (r1180) link to Tachiyomi section (by @AriaMoradi)
|
||||||
|
- (r1181) fix typo (by @AriaMoradi)
|
||||||
|
- (r1182) Improve Gradle Configuration ([#478](https://github.com/Suwayomi/Tachidesk-Server/pull/478) by @Syer10)
|
||||||
|
- (r1183) Improve Playwright handling ([#479](https://github.com/Suwayomi/Tachidesk-Server/pull/479) by @Syer10)
|
||||||
|
- (r1184) fix ambiguous reference issue on JDK 13+ (by @AriaMoradi)
|
||||||
|
- (r1185) update gradle version (by @AriaMoradi)
|
||||||
|
- (r1186) upgrade dorkbox stuff (by @AriaMoradi)
|
||||||
|
- (r1187) Fixe Dex2Jar and dorkbox dependency issues ([#487](https://github.com/Suwayomi/Tachidesk-Server/pull/487) by @akabhirav)
|
||||||
|
- (r1188) Fix logging and update system try ([#488](https://github.com/Suwayomi/Tachidesk-Server/pull/488) by @Syer10)
|
||||||
|
- (r1189) add support for Extensions Lib 1.4 ([#496](https://github.com/Suwayomi/Tachidesk-Server/pull/496) by @Syer10)
|
||||||
|
- (r1190) disable playwright for v0.6.7 (by @AriaMoradi)
|
||||||
|
- (r1191) Decouple Cache and Download behaviour ([#493](https://github.com/Suwayomi/Tachidesk-Server/pull/493) by @akabhirav)
|
||||||
|
- (r1192) rethink image cache ([#498](https://github.com/Suwayomi/Tachidesk-Server/pull/498) by @AriaMoradi)
|
||||||
|
- (r1193) fix Page index issues for some providers ([#491](https://github.com/Suwayomi/Tachidesk-Server/pull/491) by @akabhirav)
|
||||||
|
- (r1194) Download as CBZ ([#490](https://github.com/Suwayomi/Tachidesk-Server/pull/490) by @akabhirav)
|
||||||
|
- (r1195) re-order config options (by @AriaMoradi)
|
||||||
|
- (r1196) stop using depricated API (by @AriaMoradi)
|
||||||
|
|
||||||
|
## Tachidesk-WebUI Changelog
|
||||||
|
- (r964) Created a GridLayout enum and updated all locations to use it. ([#208](https://github.com/Suwayomi/Tachidesk-WebUI/pull/208) by @infix)
|
||||||
|
- (r965) fix library update progress rendering ([#210](https://github.com/Suwayomi/Tachidesk-WebUI/pull/210) by @schroda)
|
||||||
|
- (r966) Save reader settings per manga in Meta ([#216](https://github.com/Suwayomi/Tachidesk-WebUI/pull/216) by @schroda)
|
||||||
|
- (r967) make default reader settings changeable ([#217](https://github.com/Suwayomi/Tachidesk-WebUI/pull/217) by @schroda)
|
||||||
|
- (r968) [#211] Refresh Library after a update ([#212](https://github.com/Suwayomi/Tachidesk-WebUI/pull/212) by @schroda)
|
||||||
|
- (r969) add logic for metadata migration ([#218](https://github.com/Suwayomi/Tachidesk-WebUI/pull/218) by @schroda)
|
||||||
|
- (r970) set browser tab title ([#220](https://github.com/Suwayomi/Tachidesk-WebUI/pull/220) by @schroda)
|
||||||
|
- (r971) Add tooltip containing full manga title to title of manga ([#221](https://github.com/Suwayomi/Tachidesk-WebUI/pull/221) by @schroda)
|
||||||
|
- (r972) show more detailed upload dates for today and yesterday ([#222](https://github.com/Suwayomi/Tachidesk-WebUI/pull/222) by @schroda)
|
||||||
|
- (r973) add GitHub action on pushing to run lint ([#224](https://github.com/Suwayomi/Tachidesk-WebUI/pull/224) by @schroda)
|
||||||
|
- (r974) Ignore filters while searching ([#226](https://github.com/Suwayomi/Tachidesk-WebUI/pull/226) by @schroda)
|
||||||
|
- (r975) force absolute import path ([#223](https://github.com/Suwayomi/Tachidesk-WebUI/pull/223) by @schroda)
|
||||||
|
- (r976) add prettier for auto formatting ([#231](https://github.com/Suwayomi/Tachidesk-WebUI/pull/231) by @schroda)
|
||||||
|
- (r977) Fix import path ([#228](https://github.com/Suwayomi/Tachidesk-WebUI/pull/228) by @schroda)
|
||||||
|
- (r978) increase prettier line length to 120 ([#233](https://github.com/Suwayomi/Tachidesk-WebUI/pull/233) by @schroda)
|
||||||
|
- (r979) Add chapter page dropdown ([#230](https://github.com/Suwayomi/Tachidesk-WebUI/pull/230) by @schroda)
|
||||||
|
- (r980) add chapter dropdown to reader nav bar ([#229](https://github.com/Suwayomi/Tachidesk-WebUI/pull/229) by @schroda)
|
||||||
|
- (r981) Fix lint error ([#235](https://github.com/Suwayomi/Tachidesk-WebUI/pull/235) by @schroda)
|
||||||
|
- (r982) Fix reader nav bar scroll to page ([#236](https://github.com/Suwayomi/Tachidesk-WebUI/pull/236) by @schroda)
|
||||||
|
- (r964) Created a GridLayout enum and updated all locations to use it. ([#208](https://github.com/Suwayomi/Tachidesk-WebUI/pull/208) by @infix)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Server: v0.6.6 + WebUI: r963
|
# Server: v0.6.6 + WebUI: r963
|
||||||
## TL;DR
|
## TL;DR
|
||||||
- Batch actions for chapters
|
- Batch actions for chapters
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
## Where should I start?
|
## Where should I start?
|
||||||
Checkout [This Kanban Board](https://github.com/Suwayomi/Tachidesk/projects/1) to see the rough development roadmap.
|
Checkout [This Kanban Board](https://github.com/Suwayomi/Tachidesk/projects/1) to see the rough development roadmap.
|
||||||
|
|
||||||
**Note 1:** Notify the developers on [Suwayomi discord](https://discord.gg/DDZdqZWaHA) (#tachidesk-server and #tachidesk-webui channels) or open a WIP pull request before starting if you decide to take on working on anything from/not from the roadmap in order to avoid parallel efforts on the same issue/feature.
|
### Important notes
|
||||||
|
- Notify the developers on [Suwayomi discord](https://discord.gg/DDZdqZWaHA) (#tachidesk-server and #tachidesk-webui channels) or open a WIP pull request before starting if you decide to take on working on anything from/not from the roadmap in order to avoid parallel efforts on the same issue/feature.
|
||||||
**Note 2:** Your pull request will be squashed into a single commit.
|
- Your pull request will be squashed into a single commit.
|
||||||
|
- We hate big pull requests, make them as small as possible, change one meaningful thing. Spam pull requests, we don't mind.
|
||||||
|
|
||||||
### Project goals and vision
|
### Project goals and vision
|
||||||
- Porting Tachiyomi and covering it's features
|
- Porting Tachiyomi and covering it's features
|
||||||
|
|||||||
@@ -177,3 +177,7 @@ Changes to both codebases is licensed under `MPL v. 2.0` as the rest of this pro
|
|||||||
This Source Code Form is subject to the terms of the Mozilla Public
|
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
|
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
|
||||||
|
## Disclaimer
|
||||||
|
|
||||||
|
The developer of this application does not have any affiliation with the content providers available.
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const val MainClass = "suwayomi.tachidesk.MainKt"
|
|||||||
// should be bumped with each stable release
|
// should be bumped with each stable release
|
||||||
val tachideskVersion = System.getenv("ProductVersion") ?: "v0.7.0"
|
val tachideskVersion = System.getenv("ProductVersion") ?: "v0.7.0"
|
||||||
|
|
||||||
val webUIRevisionTag = System.getenv("WebUIRevision") ?: "r983"
|
val webUIRevisionTag = System.getenv("WebUIRevision") ?: "r1045"
|
||||||
|
|
||||||
// counts commits on the master branch
|
// counts commits on the master branch
|
||||||
val tachideskRevision = runCatching {
|
val tachideskRevision = runCatching {
|
||||||
|
|||||||
@@ -6,11 +6,10 @@ okhttp = "5.0.0-alpha.11" # Major version is locked by Tachiyomi extensions
|
|||||||
javalin = "4.6.6" # Javalin 5.0.0+ requires Java 11
|
javalin = "4.6.6" # Javalin 5.0.0+ requires Java 11
|
||||||
jackson = "2.13.3" # jackson version locked by javalin, ref: `io.javalin.core.util.OptionalDependency`
|
jackson = "2.13.3" # jackson version locked by javalin, ref: `io.javalin.core.util.OptionalDependency`
|
||||||
exposed = "0.40.1"
|
exposed = "0.40.1"
|
||||||
dex2jar = "v59"
|
dex2jar = "v60"
|
||||||
rhino = "1.7.14"
|
rhino = "1.7.14"
|
||||||
settings = "1.0.0-RC"
|
settings = "1.0.0-RC"
|
||||||
twelvemonkeys = "3.9.4"
|
twelvemonkeys = "3.9.4"
|
||||||
playwright = "1.28.0"
|
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
# Kotlin
|
# Kotlin
|
||||||
@@ -95,9 +94,6 @@ appdirs = "net.harawata:appdirs:1.2.1"
|
|||||||
zip4j = "net.lingala.zip4j:zip4j:2.11.2"
|
zip4j = "net.lingala.zip4j:zip4j:2.11.2"
|
||||||
junrar = "com.github.junrar:junrar:7.5.3"
|
junrar = "com.github.junrar:junrar:7.5.3"
|
||||||
|
|
||||||
# CloudflareInterceptor
|
|
||||||
playwright = { module = "com.microsoft.playwright:playwright", version.ref = "playwright" }
|
|
||||||
|
|
||||||
# AES/CBC/PKCS7Padding Cypher provider
|
# AES/CBC/PKCS7Padding Cypher provider
|
||||||
bouncycastle = "org.bouncycastle:bcprov-jdk18on:1.72"
|
bouncycastle = "org.bouncycastle:bcprov-jdk18on:1.72"
|
||||||
|
|
||||||
|
|||||||
@@ -26,8 +26,6 @@ main() {
|
|||||||
set -- "${POSITIONAL_ARGS[@]}"
|
set -- "${POSITIONAL_ARGS[@]}"
|
||||||
|
|
||||||
OS="$1"
|
OS="$1"
|
||||||
PLAYWRIGHT_VERSION="$(cat gradle/libs.versions.toml | grep -oP "playwright = \"\K([0-9\.]*)(?=\")")"
|
|
||||||
PLAYWRIGHT_REVISION="$(curl --silent "https://raw.githubusercontent.com/microsoft/playwright/v$PLAYWRIGHT_VERSION/packages/playwright-core/browsers.json" 2>&1 | grep -ozP "\"name\": \"chromium\",\n *\"revision\": \"\K[0-9]*")"
|
|
||||||
JAR="$(ls server/build/*.jar | tail -n1)"
|
JAR="$(ls server/build/*.jar | tail -n1)"
|
||||||
RELEASE_NAME="$(echo "${JAR%.*}" | xargs basename)-$OS"
|
RELEASE_NAME="$(echo "${JAR%.*}" | xargs basename)-$OS"
|
||||||
RELEASE_VERSION="$(tmp="${JAR%-*}"; echo "${tmp##*-}" | tr -d v)"
|
RELEASE_VERSION="$(tmp="${JAR%-*}"; echo "${tmp##*-}" | tr -d v)"
|
||||||
@@ -127,8 +125,9 @@ main() {
|
|||||||
ELECTRON_URL="https://github.com/electron/electron/releases/download/$electron_version/$ELECTRON"
|
ELECTRON_URL="https://github.com/electron/electron/releases/download/$electron_version/$ELECTRON"
|
||||||
download_jre_and_electron
|
download_jre_and_electron
|
||||||
|
|
||||||
PLAYWRIGHT_PLATFORM="win64"
|
PYTHON=Winpython64-3.10.9.0dot.exe
|
||||||
setup_playwright
|
PYTHON_URL=https://github.com/winpython/winpython/releases/download/5.3.20221233/Winpython64-3.10.9.0dot.exe
|
||||||
|
setup_undetected_chromedriver_and_python
|
||||||
|
|
||||||
RELEASE="$RELEASE_NAME.zip"
|
RELEASE="$RELEASE_NAME.zip"
|
||||||
make_windows_bundle
|
make_windows_bundle
|
||||||
@@ -285,9 +284,23 @@ make_windows_package() {
|
|||||||
"$RELEASE_NAME/jre.wxs" "$RELEASE_NAME/electron.wxs" -o "$RELEASE"
|
"$RELEASE_NAME/jre.wxs" "$RELEASE_NAME/electron.wxs" -o "$RELEASE"
|
||||||
}
|
}
|
||||||
|
|
||||||
setup_playwright() {
|
setup_python() {
|
||||||
mkdir "$RELEASE_NAME/bin"
|
mkdir "$RELEASE_NAME/"
|
||||||
curl -L "https://playwright.azureedge.net/builds/chromium/$PLAYWRIGHT_REVISION/chromium-$PLAYWRIGHT_PLATFORM.zip" -o "$RELEASE_NAME/bin/chromium.zip"
|
curl -L "$PYTHON_URL" -o "$PYTHON"
|
||||||
|
7z x $PYTHON
|
||||||
|
mv WPy64-31090/python-3.10.9.amd64 "$RELEASE_NAME/python"
|
||||||
|
}
|
||||||
|
|
||||||
|
setup_undetected_chromedriver() {
|
||||||
|
curl -L "https://github.com/Suwayomi/undetected-chromedriver/archive/refs/heads/master.zip" -o undetected-chromedriver-master.zip
|
||||||
|
unzip undetected-chromedriver-master.zip
|
||||||
|
mv undetected-chromedriver-master "$RELEASE_NAME/undetected-chromedriver"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
setup_undetected_chromedriver_and_python() {
|
||||||
|
setup_python
|
||||||
|
setup_undetected_chromedriver
|
||||||
}
|
}
|
||||||
|
|
||||||
# Error handler
|
# Error handler
|
||||||
|
|||||||
@@ -50,9 +50,6 @@ dependencies {
|
|||||||
implementation(libs.zip4j)
|
implementation(libs.zip4j)
|
||||||
implementation(libs.junrar)
|
implementation(libs.junrar)
|
||||||
|
|
||||||
// CloudflareInterceptor
|
|
||||||
implementation(libs.playwright)
|
|
||||||
|
|
||||||
// AES/CBC/PKCS7Padding Cypher provider for zh.copymanga
|
// AES/CBC/PKCS7Padding Cypher provider for zh.copymanga
|
||||||
implementation(libs.bouncycastle)
|
implementation(libs.bouncycastle)
|
||||||
|
|
||||||
|
|||||||
@@ -1,212 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (c) Microsoft Corporation.
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package suwayomi.tachidesk.server.util;
|
|
||||||
|
|
||||||
import com.microsoft.playwright.impl.driver.Driver;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.net.URI;
|
|
||||||
import java.net.URISyntaxException;
|
|
||||||
import java.nio.file.*;
|
|
||||||
import java.util.Collections;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.concurrent.TimeUnit;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Copy of <a href="https://github.com/microsoft/playwright-java/blob/8c0231b0f739656e8a86bc58fca9ee778ddc571b/driver-bundle/src/main/java/com/microsoft/playwright/impl/driver/jar/DriverJar.java">DriverJar</a>
|
|
||||||
* with support for pre-installing chromium and only supports chromium playwright
|
|
||||||
*/
|
|
||||||
public class DriverJar extends Driver {
|
|
||||||
private static final String PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = "PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD";
|
|
||||||
private static final String SELENIUM_REMOTE_URL = "SELENIUM_REMOTE_URL";
|
|
||||||
static final String PLAYWRIGHT_NODEJS_PATH = "PLAYWRIGHT_NODEJS_PATH";
|
|
||||||
private final Path driverTempDir;
|
|
||||||
private Path preinstalledNodePath;
|
|
||||||
|
|
||||||
public DriverJar() throws IOException {
|
|
||||||
// Allow specifying custom path for the driver installation
|
|
||||||
// See https://github.com/microsoft/playwright-java/issues/728
|
|
||||||
String alternativeTmpdir = System.getProperty("playwright.driver.tmpdir");
|
|
||||||
String prefix = "playwright-java-";
|
|
||||||
driverTempDir = alternativeTmpdir == null
|
|
||||||
? Files.createTempDirectory(prefix)
|
|
||||||
: Files.createTempDirectory(Paths.get(alternativeTmpdir), prefix);
|
|
||||||
driverTempDir.toFile().deleteOnExit();
|
|
||||||
String nodePath = System.getProperty("playwright.nodejs.path");
|
|
||||||
if (nodePath != null) {
|
|
||||||
preinstalledNodePath = Paths.get(nodePath);
|
|
||||||
if (!Files.exists(preinstalledNodePath)) {
|
|
||||||
throw new RuntimeException("Invalid Node.js path specified: " + nodePath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
logMessage("created DriverJar: " + driverTempDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void initialize(Boolean installBrowsers) throws Exception {
|
|
||||||
if (preinstalledNodePath == null && env.containsKey(PLAYWRIGHT_NODEJS_PATH)) {
|
|
||||||
preinstalledNodePath = Paths.get(env.get(PLAYWRIGHT_NODEJS_PATH));
|
|
||||||
if (!Files.exists(preinstalledNodePath)) {
|
|
||||||
throw new RuntimeException("Invalid Node.js path specified: " + preinstalledNodePath);
|
|
||||||
}
|
|
||||||
} else if (preinstalledNodePath != null) {
|
|
||||||
// Pass the env variable to the driver process.
|
|
||||||
env.put(PLAYWRIGHT_NODEJS_PATH, preinstalledNodePath.toString());
|
|
||||||
}
|
|
||||||
extractDriverToTempDir();
|
|
||||||
logMessage("extracted driver from jar to " + driverPath());
|
|
||||||
if (installBrowsers)
|
|
||||||
installBrowsers(env);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void installBrowsers(Map<String, String> env) throws IOException, InterruptedException {
|
|
||||||
String skip = env.get(PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD);
|
|
||||||
if (skip == null) {
|
|
||||||
skip = System.getenv(PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD);
|
|
||||||
}
|
|
||||||
if (skip != null && !"0".equals(skip) && !"false".equals(skip)) {
|
|
||||||
System.out.println("Skipping browsers download because `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD` env variable is set");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (env.get(SELENIUM_REMOTE_URL) != null || System.getenv(SELENIUM_REMOTE_URL) != null) {
|
|
||||||
logMessage("Skipping browsers download because `SELENIUM_REMOTE_URL` env variable is set");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Chromium.preinstall(platformDir());
|
|
||||||
Path driver = driverPath();
|
|
||||||
if (!Files.exists(driver)) {
|
|
||||||
throw new RuntimeException("Failed to find driver: " + driver);
|
|
||||||
}
|
|
||||||
ProcessBuilder pb = createProcessBuilder();
|
|
||||||
pb.command().add("install");
|
|
||||||
pb.command().add("chromium");
|
|
||||||
pb.redirectError(ProcessBuilder.Redirect.INHERIT);
|
|
||||||
pb.redirectOutput(ProcessBuilder.Redirect.INHERIT);
|
|
||||||
Process p = pb.start();
|
|
||||||
boolean result = p.waitFor(10, TimeUnit.MINUTES);
|
|
||||||
if (!result) {
|
|
||||||
p.destroy();
|
|
||||||
throw new RuntimeException("Timed out waiting for browsers to install");
|
|
||||||
}
|
|
||||||
if (p.exitValue() != 0) {
|
|
||||||
throw new RuntimeException("Failed to install browsers, exit code: " + p.exitValue());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean isExecutable(Path filePath) {
|
|
||||||
String name = filePath.getFileName().toString();
|
|
||||||
return name.endsWith(".sh") || name.endsWith(".exe") || !name.contains(".");
|
|
||||||
}
|
|
||||||
|
|
||||||
private FileSystem initFileSystem(URI uri) throws IOException {
|
|
||||||
try {
|
|
||||||
return FileSystems.newFileSystem(uri, Collections.emptyMap());
|
|
||||||
} catch (FileSystemAlreadyExistsException e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static URI getDriverResourceURI() throws URISyntaxException {
|
|
||||||
ClassLoader classloader = Thread.currentThread().getContextClassLoader();
|
|
||||||
return classloader.getResource("driver/" + platformDir()).toURI();
|
|
||||||
}
|
|
||||||
|
|
||||||
void extractDriverToTempDir() throws URISyntaxException, IOException {
|
|
||||||
URI originalUri = getDriverResourceURI();
|
|
||||||
URI uri = maybeExtractNestedJar(originalUri);
|
|
||||||
|
|
||||||
// Create zip filesystem if loading from jar.
|
|
||||||
try (FileSystem fileSystem = "jar".equals(uri.getScheme()) ? initFileSystem(uri) : null) {
|
|
||||||
Path srcRoot = Paths.get(uri);
|
|
||||||
// jar file system's .relativize gives wrong results when used with
|
|
||||||
// spring-boot-maven-plugin, convert to the default filesystem to
|
|
||||||
// have predictable results.
|
|
||||||
// See https://github.com/microsoft/playwright-java/issues/306
|
|
||||||
Path srcRootDefaultFs = Paths.get(srcRoot.toString());
|
|
||||||
Files.walk(srcRoot).forEach(fromPath -> {
|
|
||||||
if (preinstalledNodePath != null) {
|
|
||||||
String fileName = fromPath.getFileName().toString();
|
|
||||||
if ("node.exe".equals(fileName) || "node".equals(fileName)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Path relative = srcRootDefaultFs.relativize(Paths.get(fromPath.toString()));
|
|
||||||
Path toPath = driverTempDir.resolve(relative.toString());
|
|
||||||
try {
|
|
||||||
if (Files.isDirectory(fromPath)) {
|
|
||||||
Files.createDirectories(toPath);
|
|
||||||
} else {
|
|
||||||
Files.copy(fromPath, toPath);
|
|
||||||
if (isExecutable(toPath)) {
|
|
||||||
toPath.toFile().setExecutable(true, true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
toPath.toFile().deleteOnExit();
|
|
||||||
} catch (IOException e) {
|
|
||||||
throw new RuntimeException("Failed to extract driver from " + uri + ", full uri: " + originalUri, e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private URI maybeExtractNestedJar(final URI uri) throws URISyntaxException {
|
|
||||||
if (!"jar".equals(uri.getScheme())) {
|
|
||||||
return uri;
|
|
||||||
}
|
|
||||||
final String JAR_URL_SEPARATOR = "!/";
|
|
||||||
String[] parts = uri.toString().split("!/");
|
|
||||||
if (parts.length != 3) {
|
|
||||||
return uri;
|
|
||||||
}
|
|
||||||
String innerJar = String.join(JAR_URL_SEPARATOR, parts[0], parts[1]);
|
|
||||||
URI jarUri = new URI(innerJar);
|
|
||||||
try (FileSystem fs = FileSystems.newFileSystem(jarUri, Collections.emptyMap())) {
|
|
||||||
Path fromPath = Paths.get(jarUri);
|
|
||||||
Path toPath = driverTempDir.resolve(fromPath.getFileName().toString());
|
|
||||||
Files.copy(fromPath, toPath);
|
|
||||||
toPath.toFile().deleteOnExit();
|
|
||||||
return new URI("jar:" + toPath.toUri() + JAR_URL_SEPARATOR + parts[2]);
|
|
||||||
} catch (IOException e) {
|
|
||||||
throw new RuntimeException("Failed to extract driver's nested .jar from " + jarUri + "; full uri: " + uri, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String platformDir() {
|
|
||||||
String name = System.getProperty("os.name").toLowerCase();
|
|
||||||
String arch = System.getProperty("os.arch").toLowerCase();
|
|
||||||
|
|
||||||
if (name.contains("windows")) {
|
|
||||||
return "win32_x64";
|
|
||||||
}
|
|
||||||
if (name.contains("linux")) {
|
|
||||||
if (arch.equals("aarch64")) {
|
|
||||||
return "linux-arm64";
|
|
||||||
} else {
|
|
||||||
return "linux";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (name.contains("mac os x")) {
|
|
||||||
return "mac";
|
|
||||||
}
|
|
||||||
throw new RuntimeException("Unexpected os.name value: " + name);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected Path driverDir() {
|
|
||||||
return driverTempDir;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +1,29 @@
|
|||||||
package eu.kanade.tachiyomi.network.interceptor
|
package eu.kanade.tachiyomi.network.interceptor
|
||||||
|
|
||||||
import com.microsoft.playwright.Browser
|
|
||||||
import com.microsoft.playwright.BrowserType.LaunchOptions
|
|
||||||
import com.microsoft.playwright.Page
|
|
||||||
import com.microsoft.playwright.Playwright
|
|
||||||
import com.microsoft.playwright.PlaywrightException
|
|
||||||
import eu.kanade.tachiyomi.network.NetworkHelper
|
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||||
import eu.kanade.tachiyomi.network.interceptor.CFClearance.resolveWithWebView
|
import eu.kanade.tachiyomi.network.interceptor.CloudflareBypasser.resolveWithWebView
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
import mu.KotlinLogging
|
import mu.KotlinLogging
|
||||||
import okhttp3.Cookie
|
import okhttp3.Cookie
|
||||||
import okhttp3.HttpUrl
|
import okhttp3.HttpUrl
|
||||||
import okhttp3.Interceptor
|
import okhttp3.Interceptor
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import suwayomi.tachidesk.server.ServerConfig
|
import org.kodein.di.DI
|
||||||
|
import org.kodein.di.conf.global
|
||||||
|
import org.kodein.di.instance
|
||||||
import suwayomi.tachidesk.server.serverConfig
|
import suwayomi.tachidesk.server.serverConfig
|
||||||
import uy.kohesive.injekt.injectLazy
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
import java.io.BufferedReader
|
||||||
|
import java.io.Closeable
|
||||||
|
import java.io.File
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import java.io.InputStreamReader
|
||||||
import kotlin.time.DurationUnit
|
import java.io.PrintWriter
|
||||||
|
import java.nio.file.Paths
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
class CloudflareInterceptor : Interceptor {
|
class CloudflareInterceptor : Interceptor {
|
||||||
private val logger = KotlinLogging.logger {}
|
private val logger = KotlinLogging.logger {}
|
||||||
@@ -38,10 +43,12 @@ class CloudflareInterceptor : Interceptor {
|
|||||||
return originalResponse
|
return originalResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
throw IOException("playwrite is diabled for v0.6.7")
|
|
||||||
|
|
||||||
logger.debug { "Cloudflare anti-bot is on, CloudflareInterceptor is kicking in..." }
|
logger.debug { "Cloudflare anti-bot is on, CloudflareInterceptor is kicking in..." }
|
||||||
|
|
||||||
|
if (!serverConfig.webviewEnabled) {
|
||||||
|
throw CloudflareBypassException("Webview is disabled, enable it in server config")
|
||||||
|
}
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
originalResponse.close()
|
originalResponse.close()
|
||||||
network.cookies.remove(originalRequest.url.toUri())
|
network.cookies.remove(originalRequest.url.toUri())
|
||||||
@@ -63,43 +70,43 @@ class CloudflareInterceptor : Interceptor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
object CloudflareBypasser {
|
||||||
* This class is ported from https://github.com/vvanglro/cf-clearance
|
|
||||||
* The original code is licensed under Apache 2.0
|
|
||||||
*/
|
|
||||||
object CFClearance {
|
|
||||||
private val logger = KotlinLogging.logger {}
|
private val logger = KotlinLogging.logger {}
|
||||||
private val network: NetworkHelper by injectLazy()
|
private val network: NetworkHelper by injectLazy()
|
||||||
|
|
||||||
init {
|
|
||||||
// Fix the default DriverJar issue by providing our own implementation
|
|
||||||
// ref: https://github.com/microsoft/playwright-java/issues/1138
|
|
||||||
System.setProperty("playwright.driver.impl", "suwayomi.tachidesk.server.util.DriverJar")
|
|
||||||
}
|
|
||||||
|
|
||||||
fun resolveWithWebView(originalRequest: Request): Request {
|
fun resolveWithWebView(originalRequest: Request): Request {
|
||||||
val url = originalRequest.url.toString()
|
val url = originalRequest.url.toString()
|
||||||
|
|
||||||
logger.debug { "resolveWithWebView($url)" }
|
logger.debug { "resolveWithWebView($url)" }
|
||||||
|
|
||||||
val cookies = Playwright.create().use { playwright ->
|
val cookies = PythonInterpreter.create().use { py ->
|
||||||
playwright.chromium().launch(
|
try {
|
||||||
LaunchOptions()
|
py.exec("import undetected_chromedriver as uc")
|
||||||
.setHeadless(false)
|
|
||||||
.apply {
|
py.exec("options = uc.ChromeOptions()")
|
||||||
if (serverConfig.socksProxyEnabled) {
|
|
||||||
setProxy("socks5://${serverConfig.socksProxyHost}:${serverConfig.socksProxyPort}")
|
if (serverConfig.socksProxyEnabled) {
|
||||||
}
|
val proxy = "socks5://${serverConfig.socksProxyHost}:${serverConfig.socksProxyPort}"
|
||||||
}
|
py.exec("options.add_argument('--proxy-server=$proxy')")
|
||||||
).use { browser ->
|
|
||||||
val userAgent = originalRequest.header("User-Agent")
|
|
||||||
if (userAgent != null) {
|
|
||||||
browser.newContext(Browser.NewContextOptions().setUserAgent(userAgent)).use { browserContext ->
|
|
||||||
browserContext.newPage().use { getCookies(it, url) }
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
browser.newPage().use { getCookies(it, url) }
|
|
||||||
}
|
}
|
||||||
|
// py.exec("driver = uc.Chrome(options=options)")
|
||||||
|
py.exec("driver = uc.Chrome(options=options, driver_executable_path='${py.chromedriverPath.replace("\\","\\\\")}', version_main=111)")
|
||||||
|
|
||||||
|
// TODO: handle custom userAgent
|
||||||
|
// val userAgent = originalRequest.header("User-Agent")
|
||||||
|
|
||||||
|
// if (userAgent != null) {
|
||||||
|
// browser.newContext(Browser.NewContextOptions().setUserAgent(userAgent)).use { browserContext ->
|
||||||
|
// browserContext.newPage().use { getCookies(it, url) }
|
||||||
|
// }
|
||||||
|
// } else {
|
||||||
|
// browser.newPage().use { getCookies(it, url) }
|
||||||
|
// }
|
||||||
|
py.exec("driver.get('$url')")
|
||||||
|
|
||||||
|
getCookies(py)
|
||||||
|
} finally {
|
||||||
|
py.exec("driver.quit()")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,44 +146,64 @@ object CFClearance {
|
|||||||
|
|
||||||
fun getWebViewUserAgent(): String {
|
fun getWebViewUserAgent(): String {
|
||||||
return try {
|
return try {
|
||||||
throw PlaywrightException("playwrite is diabled for v0.6.7")
|
if (!serverConfig.webviewEnabled) {
|
||||||
|
throw CloudflareBypassException("Webview is disabled, enable it in server config")
|
||||||
Playwright.create().use { playwright ->
|
|
||||||
playwright.chromium().launch(
|
|
||||||
LaunchOptions()
|
|
||||||
.setHeadless(true)
|
|
||||||
).use { browser ->
|
|
||||||
browser.newPage().use { page ->
|
|
||||||
val userAgent = page.evaluate("() => {return navigator.userAgent}") as String
|
|
||||||
logger.debug { "WebView User-Agent is $userAgent" }
|
|
||||||
return userAgent
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (e: PlaywrightException) {
|
|
||||||
// Playwright might fail on headless environments like docker
|
PythonInterpreter.create().use { py ->
|
||||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36"
|
py.exec("import undetected_chromedriver as uc")
|
||||||
|
|
||||||
|
py.exec("options = uc.ChromeOptions()")
|
||||||
|
py.exec("options.add_argument('--headless')")
|
||||||
|
py.exec("options.add_argument('--disable-gpu')")
|
||||||
|
// py.exec("driver = uc.Chrome(options=options)")
|
||||||
|
py.exec("driver = uc.Chrome(options=options, driver_executable_path='${py.chromedriverPath.replace("\\","\\\\")}', version_main=111)")
|
||||||
|
|
||||||
|
py.exec("userAgent = driver.execute_script('return navigator.userAgent')")
|
||||||
|
val userAgent = py.getValue("userAgent")
|
||||||
|
py.exec("driver.quit()")
|
||||||
|
|
||||||
|
userAgent
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Webview might fail on headless environments like docker
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Safari/537.36"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getCookies(page: Page, url: String): List<Cookie> {
|
@Serializable
|
||||||
applyStealthInitScripts(page)
|
data class PythonSeleniumCookie(
|
||||||
page.navigate(url)
|
val domain: String,
|
||||||
val challengeResolved = waitForChallengeResolve(page)
|
val expiry: Long?,
|
||||||
|
val httpOnly: Boolean,
|
||||||
|
val name: String,
|
||||||
|
val path: String,
|
||||||
|
val sameSite: String,
|
||||||
|
val secure: Boolean,
|
||||||
|
val value: String
|
||||||
|
)
|
||||||
|
|
||||||
|
private val json by DI.global.instance<Json>()
|
||||||
|
private fun getCookies(py: PythonInterpreter): List<Cookie> {
|
||||||
|
val challengeResolved = waitForChallengeResolve(py)
|
||||||
|
|
||||||
return if (challengeResolved) {
|
return if (challengeResolved) {
|
||||||
val cookies = page.context().cookies()
|
py.exec("import json")
|
||||||
|
py.exec("cookies = json.dumps(driver.get_cookies())")
|
||||||
|
val cookiesJson = py.getValue("cookies")
|
||||||
|
val cookies = json.decodeFromString<List<PythonSeleniumCookie>>(cookiesJson)
|
||||||
|
|
||||||
logger.debug {
|
logger.debug {
|
||||||
val userAgent = page.evaluate("() => {return navigator.userAgent}")
|
py.exec("userAgent = driver.execute_script('return navigator.userAgent')")
|
||||||
"Playwright User-Agent is $userAgent"
|
val userAgent = py.getValue("userAgent")
|
||||||
|
"Webview User-Agent is $userAgent"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert PlayWright cookies to OkHttp cookies
|
// Convert Webview cookies to OkHttp cookies
|
||||||
cookies.map {
|
cookies.map {
|
||||||
Cookie.Builder()
|
Cookie.Builder()
|
||||||
.domain(it.domain.removePrefix("."))
|
.domain(it.domain.removePrefix("."))
|
||||||
.expiresAt(it.expires?.times(1000)?.toLong() ?: Long.MAX_VALUE)
|
.expiresAt(it.expiry?.times(1000) ?: Long.MAX_VALUE)
|
||||||
.name(it.name)
|
.name(it.name)
|
||||||
.path(it.path)
|
.path(it.path)
|
||||||
.value(it.value).apply {
|
.value(it.value).apply {
|
||||||
@@ -185,39 +212,18 @@ object CFClearance {
|
|||||||
}.build()
|
}.build()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.debug { "Cloudflare challenge failed to resolve" }
|
throw CloudflareBypassException("Cloudflare challenge failed to resolve")
|
||||||
throw CloudflareBypassException()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ref: https://github.com/vvanglro/cf-clearance/blob/44124a8f06d8d0ecf2bf558a027082ff88dab435/cf_clearance/stealth.py#L18
|
private fun waitForChallengeResolve(py: PythonInterpreter): Boolean {
|
||||||
private val stealthInitScripts by lazy {
|
// sometimes the user has to solve the captcha challenge manually and multiple times, potentially wait a long time
|
||||||
arrayOf(
|
|
||||||
ServerConfig::class.java.getResource("/cloudflare-js/canvas.fingerprinting.js")!!.readText(),
|
|
||||||
ServerConfig::class.java.getResource("/cloudflare-js/chrome.global.js")!!.readText(),
|
|
||||||
ServerConfig::class.java.getResource("/cloudflare-js/emulate.touch.js")!!.readText(),
|
|
||||||
ServerConfig::class.java.getResource("/cloudflare-js/navigator.permissions.js")!!.readText(),
|
|
||||||
ServerConfig::class.java.getResource("/cloudflare-js/navigator.webdriver.js")!!.readText(),
|
|
||||||
ServerConfig::class.java.getResource("/cloudflare-js/chrome.runtime.js")!!.readText(),
|
|
||||||
ServerConfig::class.java.getResource("/cloudflare-js/chrome.plugin.js")!!.readText()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ref: https://github.com/vvanglro/cf-clearance/blob/44124a8f06d8d0ecf2bf558a027082ff88dab435/cf_clearance/stealth.py#L76
|
|
||||||
private fun applyStealthInitScripts(page: Page) {
|
|
||||||
for (script in stealthInitScripts) {
|
|
||||||
page.addInitScript(script)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ref: https://github.com/vvanglro/cf-clearance/blob/44124a8f06d8d0ecf2bf558a027082ff88dab435/cf_clearance/retry.py#L21
|
|
||||||
private fun waitForChallengeResolve(page: Page): Boolean {
|
|
||||||
// sometimes the user has to solve the captcha challenge manually, potentially wait a long time
|
|
||||||
val timeoutSeconds = 120
|
val timeoutSeconds = 120
|
||||||
repeat(timeoutSeconds) {
|
repeat(timeoutSeconds) {
|
||||||
page.waitForTimeout(1.seconds.toDouble(DurationUnit.MILLISECONDS))
|
TimeUnit.SECONDS.sleep(1)
|
||||||
val success = try {
|
val success = try {
|
||||||
page.querySelector("#challenge-form") == null
|
py.exec("r = driver.execute_script('return document.querySelector(\"#challenge-form\") == null')")
|
||||||
|
py.getValue("r").lowercase().toBoolean()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.debug(e) { "query Error" }
|
logger.debug(e) { "query Error" }
|
||||||
false
|
false
|
||||||
@@ -226,6 +232,113 @@ object CFClearance {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
}
|
||||||
private class CloudflareBypassException : Exception()
|
private class CloudflareBypassException(message: String?) : Exception(message)
|
||||||
|
|
||||||
|
class PythonInterpreter
|
||||||
|
private constructor(private val process: Process, val chromedriverPath: String) : Closeable {
|
||||||
|
private val stdin = process.outputStream
|
||||||
|
private val stdout = process.inputStream
|
||||||
|
private val stderr = process.errorStream
|
||||||
|
|
||||||
|
private val stdinWriter = PrintWriter(stdin)
|
||||||
|
private val stdoutReader = BufferedReader(InputStreamReader(stdout))
|
||||||
|
private val stderrReader = BufferedReader(InputStreamReader(stderr))
|
||||||
|
|
||||||
|
private fun rawExec(command: String) {
|
||||||
|
stdinWriter.println(command)
|
||||||
|
stdinWriter.flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
val BUFF_SIZE = 102400
|
||||||
|
fun exec(command: String) {
|
||||||
|
logger.debug { "Python Command: $command" }
|
||||||
|
rawExec(command)
|
||||||
|
makeSureExecDone()
|
||||||
|
}
|
||||||
|
|
||||||
|
private val commandOutputs = mutableListOf<String>()
|
||||||
|
|
||||||
|
fun makeSureExecDone() {
|
||||||
|
val makeSureString = "PYTHON_IS_READY"
|
||||||
|
|
||||||
|
rawExec("print('$makeSureString')")
|
||||||
|
var line: String?
|
||||||
|
do {
|
||||||
|
line = stdoutReader.readLine()
|
||||||
|
if (line != makeSureString) {
|
||||||
|
commandOutputs.add(line)
|
||||||
|
}
|
||||||
|
} while (line != makeSureString)
|
||||||
|
|
||||||
|
val pyError = buildString {
|
||||||
|
while (stderrReader.ready())
|
||||||
|
append(stderr.read().toChar())
|
||||||
|
}
|
||||||
|
if (pyError.isNotEmpty()) {
|
||||||
|
println("Python STDERR: $pyError")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getValue(variableName: String): String {
|
||||||
|
exec("print($variableName)")
|
||||||
|
return commandOutputs.last()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun flushStdoutReader() {
|
||||||
|
var line: String?
|
||||||
|
while (stdoutReader.ready()) {
|
||||||
|
val line = stdoutReader.readLine()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun destroy() {
|
||||||
|
stdinWriter.close()
|
||||||
|
stdoutReader.close()
|
||||||
|
stderr.close()
|
||||||
|
process.destroy()
|
||||||
|
}
|
||||||
|
override fun close() {
|
||||||
|
destroy()
|
||||||
|
}
|
||||||
|
companion object {
|
||||||
|
private val logger = KotlinLogging.logger {}
|
||||||
|
|
||||||
|
fun create(pythonPath: String, workingDir: String, pythonStartupFile: String, chromedriverPath: String): PythonInterpreter {
|
||||||
|
val processBuilder = ProcessBuilder()
|
||||||
|
.command(pythonPath, "-i", "-q")
|
||||||
|
processBuilder.directory(File(workingDir))
|
||||||
|
|
||||||
|
val environment = processBuilder.environment()
|
||||||
|
environment["PYTHONSTARTUP"] = pythonStartupFile
|
||||||
|
|
||||||
|
val process = processBuilder.start()
|
||||||
|
|
||||||
|
return PythonInterpreter(process, chromedriverPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun create(): PythonInterpreter {
|
||||||
|
val uc = Paths.get(serverConfig.undetectedChromePath).toAbsolutePath().toString()
|
||||||
|
logger.debug { "absolute path to undetected-chromedriver: $uc" }
|
||||||
|
|
||||||
|
val (pythonPath, chromedriverPath) = if (System.getProperty("os.name").startsWith("Windows")) {
|
||||||
|
arrayOf(
|
||||||
|
"$uc\\venv\\Scripts\\python.exe",
|
||||||
|
"$uc\\chromedriver.exe"
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
arrayOf(
|
||||||
|
"$uc/venv/bin/python",
|
||||||
|
"$uc/chromedriver"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return create(
|
||||||
|
pythonPath,
|
||||||
|
uc,
|
||||||
|
"$uc/console.py",
|
||||||
|
chromedriverPath
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package eu.kanade.tachiyomi.source.online
|
|||||||
import eu.kanade.tachiyomi.network.GET
|
import eu.kanade.tachiyomi.network.GET
|
||||||
import eu.kanade.tachiyomi.network.NetworkHelper
|
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||||
import eu.kanade.tachiyomi.network.asObservableSuccess
|
import eu.kanade.tachiyomi.network.asObservableSuccess
|
||||||
import eu.kanade.tachiyomi.network.interceptor.CFClearance.getWebViewUserAgent
|
import eu.kanade.tachiyomi.network.interceptor.CloudflareBypasser.getWebViewUserAgent
|
||||||
import eu.kanade.tachiyomi.network.newCallWithProgress
|
import eu.kanade.tachiyomi.network.newCallWithProgress
|
||||||
import eu.kanade.tachiyomi.source.CatalogueSource
|
import eu.kanade.tachiyomi.source.CatalogueSource
|
||||||
import eu.kanade.tachiyomi.source.model.FilterList
|
import eu.kanade.tachiyomi.source.model.FilterList
|
||||||
|
|||||||
@@ -61,14 +61,15 @@ object CategoryController {
|
|||||||
pathParam<Int>("categoryId"),
|
pathParam<Int>("categoryId"),
|
||||||
formParam<String?>("name"),
|
formParam<String?>("name"),
|
||||||
formParam<Boolean?>("default"),
|
formParam<Boolean?>("default"),
|
||||||
|
formParam<Int?>("includeInUpdate"),
|
||||||
documentWith = {
|
documentWith = {
|
||||||
withOperation {
|
withOperation {
|
||||||
summary("Category modify")
|
summary("Category modify")
|
||||||
description("Modify a category")
|
description("Modify a category")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
behaviorOf = { ctx, categoryId, name, isDefault ->
|
behaviorOf = { ctx, categoryId, name, isDefault, includeInUpdate ->
|
||||||
Category.updateCategory(categoryId, name, isDefault)
|
Category.updateCategory(categoryId, name, isDefault, includeInUpdate)
|
||||||
ctx.status(200)
|
ctx.status(200)
|
||||||
},
|
},
|
||||||
withResults = {
|
withResults = {
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
package suwayomi.tachidesk.manga.controller
|
package suwayomi.tachidesk.manga.controller
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 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 eu.kanade.tachiyomi.source.model.UpdateStrategy
|
import eu.kanade.tachiyomi.source.model.UpdateStrategy
|
||||||
import io.javalin.http.HttpCode
|
import io.javalin.http.HttpCode
|
||||||
import io.javalin.websocket.WsConfig
|
import io.javalin.websocket.WsConfig
|
||||||
@@ -13,23 +20,13 @@ import suwayomi.tachidesk.manga.impl.Chapter
|
|||||||
import suwayomi.tachidesk.manga.impl.update.IUpdater
|
import suwayomi.tachidesk.manga.impl.update.IUpdater
|
||||||
import suwayomi.tachidesk.manga.impl.update.UpdateStatus
|
import suwayomi.tachidesk.manga.impl.update.UpdateStatus
|
||||||
import suwayomi.tachidesk.manga.impl.update.UpdaterSocket
|
import suwayomi.tachidesk.manga.impl.update.UpdaterSocket
|
||||||
import suwayomi.tachidesk.manga.model.dataclass.CategoryDataClass
|
import suwayomi.tachidesk.manga.model.dataclass.*
|
||||||
import suwayomi.tachidesk.manga.model.dataclass.MangaChapterDataClass
|
|
||||||
import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass
|
|
||||||
import suwayomi.tachidesk.manga.model.dataclass.PaginatedList
|
|
||||||
import suwayomi.tachidesk.server.JavalinSetup.future
|
import suwayomi.tachidesk.server.JavalinSetup.future
|
||||||
import suwayomi.tachidesk.server.util.formParam
|
import suwayomi.tachidesk.server.util.formParam
|
||||||
import suwayomi.tachidesk.server.util.handler
|
import suwayomi.tachidesk.server.util.handler
|
||||||
import suwayomi.tachidesk.server.util.pathParam
|
import suwayomi.tachidesk.server.util.pathParam
|
||||||
import suwayomi.tachidesk.server.util.withOperation
|
import suwayomi.tachidesk.server.util.withOperation
|
||||||
|
|
||||||
/*
|
|
||||||
* 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/. */
|
|
||||||
|
|
||||||
object UpdateController {
|
object UpdateController {
|
||||||
private val logger = KotlinLogging.logger { }
|
private val logger = KotlinLogging.logger { }
|
||||||
|
|
||||||
@@ -93,14 +90,33 @@ object UpdateController {
|
|||||||
if (clear) {
|
if (clear) {
|
||||||
updater.reset()
|
updater.reset()
|
||||||
}
|
}
|
||||||
categories
|
|
||||||
|
val includeInUpdateStatusToCategoryMap = categories.groupBy { it.includeInUpdate }
|
||||||
|
val excludedCategories = includeInUpdateStatusToCategoryMap[IncludeInUpdate.EXCLUDE].orEmpty()
|
||||||
|
val includedCategories = includeInUpdateStatusToCategoryMap[IncludeInUpdate.INCLUDE].orEmpty()
|
||||||
|
val unsetCategories = includeInUpdateStatusToCategoryMap[IncludeInUpdate.UNSET].orEmpty()
|
||||||
|
val categoriesToUpdate = includedCategories.ifEmpty { unsetCategories }
|
||||||
|
|
||||||
|
logger.debug { "Updating categories: '${categoriesToUpdate.joinToString("', '") { it.name }}'" }
|
||||||
|
|
||||||
|
val categoriesToUpdateMangas = categoriesToUpdate
|
||||||
.flatMap { CategoryManga.getCategoryMangaList(it.id) }
|
.flatMap { CategoryManga.getCategoryMangaList(it.id) }
|
||||||
.distinctBy { it.id }
|
.distinctBy { it.id }
|
||||||
.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER, MangaDataClass::title))
|
val mangasToCategoriesMap = CategoryManga.getMangasCategories(categoriesToUpdateMangas.map { it.id })
|
||||||
|
val mangasToUpdate = categoriesToUpdateMangas
|
||||||
.filter { it.updateStrategy == UpdateStrategy.ALWAYS_UPDATE }
|
.filter { it.updateStrategy == UpdateStrategy.ALWAYS_UPDATE }
|
||||||
.forEach { manga ->
|
.filter { !excludedCategories.any { category -> mangasToCategoriesMap[it.id]?.contains(category) == true } }
|
||||||
updater.addMangaToQueue(manga)
|
|
||||||
}
|
// In case no manga gets updated and no update job was running before, the client would never receive an info about its update request
|
||||||
|
if (mangasToUpdate.isEmpty()) {
|
||||||
|
UpdaterSocket.notifyAllClients(UpdateStatus())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updater.addMangasToQueue(
|
||||||
|
mangasToUpdate
|
||||||
|
.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER, MangaDataClass::title))
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun categoryUpdateWS(ws: WsConfig) {
|
fun categoryUpdateWS(ws: WsConfig) {
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ import org.jetbrains.exposed.sql.selectAll
|
|||||||
import org.jetbrains.exposed.sql.transactions.transaction
|
import org.jetbrains.exposed.sql.transactions.transaction
|
||||||
import org.jetbrains.exposed.sql.update
|
import org.jetbrains.exposed.sql.update
|
||||||
import suwayomi.tachidesk.manga.impl.CategoryManga.removeMangaFromCategory
|
import suwayomi.tachidesk.manga.impl.CategoryManga.removeMangaFromCategory
|
||||||
import suwayomi.tachidesk.manga.impl.util.lang.isNotEmpty
|
|
||||||
import suwayomi.tachidesk.manga.model.dataclass.CategoryDataClass
|
import suwayomi.tachidesk.manga.model.dataclass.CategoryDataClass
|
||||||
|
import suwayomi.tachidesk.manga.model.dataclass.IncludeInUpdate
|
||||||
import suwayomi.tachidesk.manga.model.table.CategoryMangaTable
|
import suwayomi.tachidesk.manga.model.table.CategoryMangaTable
|
||||||
import suwayomi.tachidesk.manga.model.table.CategoryMetaTable
|
import suwayomi.tachidesk.manga.model.table.CategoryMetaTable
|
||||||
import suwayomi.tachidesk.manga.model.table.CategoryTable
|
import suwayomi.tachidesk.manga.model.table.CategoryTable
|
||||||
@@ -50,11 +50,12 @@ object Category {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateCategory(categoryId: Int, name: String?, isDefault: Boolean?) {
|
fun updateCategory(categoryId: Int, name: String?, isDefault: Boolean?, includeInUpdate: Int?) {
|
||||||
transaction {
|
transaction {
|
||||||
CategoryTable.update({ CategoryTable.id eq categoryId }) {
|
CategoryTable.update({ CategoryTable.id eq categoryId }) {
|
||||||
if (name != null && !name.equals(DEFAULT_CATEGORY_NAME, ignoreCase = true)) it[CategoryTable.name] = name
|
if (name != null && !name.equals(DEFAULT_CATEGORY_NAME, ignoreCase = true)) it[CategoryTable.name] = name
|
||||||
if (isDefault != null) it[CategoryTable.isDefault] = isDefault
|
if (isDefault != null) it[CategoryTable.isDefault] = isDefault
|
||||||
|
if (includeInUpdate != null) it[CategoryTable.includeInUpdate] = includeInUpdate
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -98,12 +99,14 @@ object Category {
|
|||||||
|
|
||||||
const val DEFAULT_CATEGORY_ID = 0
|
const val DEFAULT_CATEGORY_ID = 0
|
||||||
const val DEFAULT_CATEGORY_NAME = "Default"
|
const val DEFAULT_CATEGORY_NAME = "Default"
|
||||||
private fun addDefaultIfNecessary(categories: List<CategoryDataClass>): List<CategoryDataClass> =
|
private fun addDefaultIfNecessary(categories: List<CategoryDataClass>): List<CategoryDataClass> {
|
||||||
if (MangaTable.select { (MangaTable.inLibrary eq true) and (MangaTable.defaultCategory eq true) }.isNotEmpty()) {
|
val defaultCategorySize = MangaTable.select { (MangaTable.inLibrary eq true) and (MangaTable.defaultCategory eq true) }.count().toInt()
|
||||||
listOf(CategoryDataClass(DEFAULT_CATEGORY_ID, 0, DEFAULT_CATEGORY_NAME, true)) + categories
|
return if (defaultCategorySize > 0) {
|
||||||
|
listOf(CategoryDataClass(DEFAULT_CATEGORY_ID, 0, DEFAULT_CATEGORY_NAME, true, defaultCategorySize, IncludeInUpdate.UNSET)) + categories
|
||||||
} else {
|
} else {
|
||||||
categories
|
categories
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun getCategoryList(): List<CategoryDataClass> {
|
fun getCategoryList(): List<CategoryDataClass> {
|
||||||
return transaction {
|
return transaction {
|
||||||
@@ -123,6 +126,14 @@ object Category {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getCategorySize(categoryId: Int): Int {
|
||||||
|
return transaction {
|
||||||
|
CategoryMangaTable.select {
|
||||||
|
CategoryMangaTable.category eq categoryId
|
||||||
|
}.count().toInt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun getCategoryMetaMap(categoryId: Int): Map<String, String> {
|
fun getCategoryMetaMap(categoryId: Int): Map<String, String> {
|
||||||
return transaction {
|
return transaction {
|
||||||
CategoryMetaTable.select { CategoryMetaTable.ref eq categoryId }
|
CategoryMetaTable.select { CategoryMetaTable.ref eq categoryId }
|
||||||
|
|||||||
@@ -10,10 +10,13 @@ package suwayomi.tachidesk.manga.impl
|
|||||||
import org.jetbrains.exposed.sql.ResultRow
|
import org.jetbrains.exposed.sql.ResultRow
|
||||||
import org.jetbrains.exposed.sql.SortOrder
|
import org.jetbrains.exposed.sql.SortOrder
|
||||||
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
|
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
|
||||||
|
import org.jetbrains.exposed.sql.alias
|
||||||
import org.jetbrains.exposed.sql.and
|
import org.jetbrains.exposed.sql.and
|
||||||
import org.jetbrains.exposed.sql.count
|
import org.jetbrains.exposed.sql.count
|
||||||
import org.jetbrains.exposed.sql.deleteWhere
|
import org.jetbrains.exposed.sql.deleteWhere
|
||||||
import org.jetbrains.exposed.sql.insert
|
import org.jetbrains.exposed.sql.insert
|
||||||
|
import org.jetbrains.exposed.sql.leftJoin
|
||||||
|
import org.jetbrains.exposed.sql.max
|
||||||
import org.jetbrains.exposed.sql.select
|
import org.jetbrains.exposed.sql.select
|
||||||
import org.jetbrains.exposed.sql.transactions.transaction
|
import org.jetbrains.exposed.sql.transactions.transaction
|
||||||
import org.jetbrains.exposed.sql.update
|
import org.jetbrains.exposed.sql.update
|
||||||
@@ -61,46 +64,45 @@ object CategoryManga {
|
|||||||
* list of mangas that belong to a category
|
* list of mangas that belong to a category
|
||||||
*/
|
*/
|
||||||
fun getCategoryMangaList(categoryId: Int): List<MangaDataClass> {
|
fun getCategoryMangaList(categoryId: Int): List<MangaDataClass> {
|
||||||
val unreadExpression = wrapAsExpression<Long>(
|
// Select the required columns from the MangaTable and add the aggregate functions to compute unread, download, and chapter counts
|
||||||
ChapterTable
|
val unreadCount = wrapAsExpression<Long>(
|
||||||
.slice(ChapterTable.id.count())
|
ChapterTable.slice(ChapterTable.id.count()).select((ChapterTable.isRead eq false) and (ChapterTable.manga eq MangaTable.id))
|
||||||
.select { (MangaTable.id eq ChapterTable.manga) and (ChapterTable.isRead eq false) }
|
|
||||||
)
|
)
|
||||||
val downloadExpression = wrapAsExpression<Long>(
|
val downloadedCount = wrapAsExpression<Long>(
|
||||||
ChapterTable
|
ChapterTable.slice(ChapterTable.id.count()).select((ChapterTable.isDownloaded eq true) and (ChapterTable.manga eq MangaTable.id))
|
||||||
.slice(ChapterTable.id.count())
|
|
||||||
.select { (MangaTable.id eq ChapterTable.manga) and (ChapterTable.isDownloaded eq true) }
|
|
||||||
)
|
|
||||||
val chapterCountExpression = wrapAsExpression<Long>(
|
|
||||||
ChapterTable
|
|
||||||
.slice(ChapterTable.id.count())
|
|
||||||
.select { (MangaTable.id eq ChapterTable.manga) }
|
|
||||||
)
|
)
|
||||||
|
|
||||||
val selectedColumns = MangaTable.columns + unreadExpression + downloadExpression + chapterCountExpression
|
val chapterCount = ChapterTable.id.count().alias("chapter_count")
|
||||||
|
val lastReadAt = ChapterTable.lastReadAt.max().alias("last_read_at")
|
||||||
|
val selectedColumns = MangaTable.columns + unreadCount + downloadedCount + chapterCount + lastReadAt
|
||||||
|
|
||||||
val transform: (ResultRow) -> MangaDataClass = {
|
val transform: (ResultRow) -> MangaDataClass = {
|
||||||
|
// Map the data from the result row to the MangaDataClass
|
||||||
val dataClass = MangaTable.toDataClass(it)
|
val dataClass = MangaTable.toDataClass(it)
|
||||||
dataClass.unreadCount = it[unreadExpression]
|
dataClass.lastReadAt = it[lastReadAt]
|
||||||
dataClass.downloadCount = it[downloadExpression]
|
dataClass.unreadCount = it[unreadCount]
|
||||||
dataClass.chapterCount = it[chapterCountExpression]
|
dataClass.downloadCount = it[downloadedCount]
|
||||||
|
dataClass.chapterCount = it[chapterCount]
|
||||||
dataClass
|
dataClass
|
||||||
}
|
}
|
||||||
|
|
||||||
if (categoryId == DEFAULT_CATEGORY_ID) {
|
|
||||||
return transaction {
|
|
||||||
MangaTable
|
|
||||||
.slice(selectedColumns)
|
|
||||||
.select { (MangaTable.inLibrary eq true) and (MangaTable.defaultCategory eq true) }
|
|
||||||
.map(transform)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return transaction {
|
return transaction {
|
||||||
CategoryMangaTable.innerJoin(MangaTable)
|
// Fetch data from the MangaTable and join with the CategoryMangaTable, if a category is specified
|
||||||
.slice(selectedColumns)
|
val query = if (categoryId == DEFAULT_CATEGORY_ID) {
|
||||||
.select { (MangaTable.inLibrary eq true) and (CategoryMangaTable.category eq categoryId) }
|
MangaTable
|
||||||
.map(transform)
|
.leftJoin(ChapterTable, { MangaTable.id }, { ChapterTable.manga })
|
||||||
|
.slice(columns = selectedColumns)
|
||||||
|
.select { (MangaTable.inLibrary eq true) and (MangaTable.defaultCategory eq true) }
|
||||||
|
} else {
|
||||||
|
MangaTable
|
||||||
|
.innerJoin(CategoryMangaTable)
|
||||||
|
.leftJoin(ChapterTable, { MangaTable.id }, { ChapterTable.manga })
|
||||||
|
.slice(columns = selectedColumns)
|
||||||
|
.select { (MangaTable.inLibrary eq true) and (CategoryMangaTable.category eq categoryId) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Join with the ChapterTable to fetch the last read chapter for each manga
|
||||||
|
query.groupBy(*MangaTable.columns.toTypedArray()).map(transform)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,4 +116,20 @@ object CategoryManga {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getMangasCategories(mangaIDs: List<Int>): Map<Int, List<CategoryDataClass>> {
|
||||||
|
return buildMap {
|
||||||
|
transaction {
|
||||||
|
CategoryMangaTable.innerJoin(CategoryTable)
|
||||||
|
.select { CategoryMangaTable.manga inList mangaIDs }
|
||||||
|
.groupBy { it[CategoryMangaTable.manga] }
|
||||||
|
.forEach {
|
||||||
|
val mangaId = it.key.value
|
||||||
|
val categories = it.value
|
||||||
|
|
||||||
|
set(mangaId, categories.map { category -> CategoryTable.toDataClass(category) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,12 +33,12 @@ class ArchiveProvider(mangaId: Int, chapterId: Int) : DownloadedFilesProvider(ma
|
|||||||
val chapterFolder = File(chapterDir)
|
val chapterFolder = File(chapterDir)
|
||||||
if (outputFile.exists()) handleExistingCbzFile(outputFile, chapterFolder)
|
if (outputFile.exists()) handleExistingCbzFile(outputFile, chapterFolder)
|
||||||
|
|
||||||
|
FolderProvider(mangaId, chapterId).download(download, scope, step)
|
||||||
|
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
outputFile.createNewFile()
|
outputFile.createNewFile()
|
||||||
}
|
}
|
||||||
|
|
||||||
FolderProvider(mangaId, chapterId).download(download, scope, step)
|
|
||||||
|
|
||||||
ZipOutputStream(outputFile.outputStream()).use { zipOut ->
|
ZipOutputStream(outputFile.outputStream()).use { zipOut ->
|
||||||
if (chapterFolder.isDirectory) {
|
if (chapterFolder.isDirectory) {
|
||||||
chapterFolder.listFiles()?.sortedBy { it.name }?.forEach {
|
chapterFolder.listFiles()?.sortedBy { it.name }?.forEach {
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ class FolderProvider(mangaId: Int, chapterId: Int) : DownloadedFilesProvider(man
|
|||||||
val chapterDir = getChapterDownloadPath(mangaId, chapterId)
|
val chapterDir = getChapterDownloadPath(mangaId, chapterId)
|
||||||
val folder = File(chapterDir)
|
val folder = File(chapterDir)
|
||||||
folder.mkdirs()
|
folder.mkdirs()
|
||||||
val file = folder.listFiles()?.get(index)
|
val file = folder.listFiles()?.sortedBy { it.name }?.get(index)
|
||||||
val fileType = file!!.name.substringAfterLast(".")
|
val fileType = file!!.name.substringAfterLast(".")
|
||||||
return Pair(FileInputStream(file).buffered(), "image/$fileType")
|
return Pair(FileInputStream(file).buffered(), "image/$fileType")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass
|
import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass
|
||||||
|
|
||||||
interface IUpdater {
|
interface IUpdater {
|
||||||
fun addMangaToQueue(manga: MangaDataClass)
|
fun addMangasToQueue(mangas: List<MangaDataClass>)
|
||||||
val status: StateFlow<UpdateStatus>
|
val status: StateFlow<UpdateStatus>
|
||||||
fun reset()
|
fun reset()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,9 +14,12 @@ import kotlinx.coroutines.flow.launchIn
|
|||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Semaphore
|
||||||
|
import kotlinx.coroutines.sync.withPermit
|
||||||
import mu.KotlinLogging
|
import mu.KotlinLogging
|
||||||
import suwayomi.tachidesk.manga.impl.Chapter
|
import suwayomi.tachidesk.manga.impl.Chapter
|
||||||
import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass
|
import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass
|
||||||
|
import suwayomi.tachidesk.server.serverConfig
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
class Updater : IUpdater {
|
class Updater : IUpdater {
|
||||||
@@ -27,18 +30,29 @@ class Updater : IUpdater {
|
|||||||
override val status = _status.asStateFlow()
|
override val status = _status.asStateFlow()
|
||||||
|
|
||||||
private val tracker = ConcurrentHashMap<Int, UpdateJob>()
|
private val tracker = ConcurrentHashMap<Int, UpdateJob>()
|
||||||
private var updateChannel = createUpdateChannel()
|
private val updateChannels = ConcurrentHashMap<String, Channel<UpdateJob>>()
|
||||||
|
|
||||||
|
private val semaphore = Semaphore(serverConfig.maxParallelUpdateRequests)
|
||||||
|
|
||||||
|
private fun getOrCreateUpdateChannelFor(source: String): Channel<UpdateJob> {
|
||||||
|
return updateChannels.getOrPut(source) {
|
||||||
|
logger.debug { "getOrCreateUpdateChannelFor: created channel for $source - channels: ${updateChannels.size + 1}" }
|
||||||
|
createUpdateChannel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun createUpdateChannel(): Channel<UpdateJob> {
|
private fun createUpdateChannel(): Channel<UpdateJob> {
|
||||||
val channel = Channel<UpdateJob>(Channel.UNLIMITED)
|
val channel = Channel<UpdateJob>(Channel.UNLIMITED)
|
||||||
channel.consumeAsFlow()
|
channel.consumeAsFlow()
|
||||||
.onEach { job ->
|
.onEach { job ->
|
||||||
_status.value = UpdateStatus(
|
semaphore.withPermit {
|
||||||
process(job),
|
_status.value = UpdateStatus(
|
||||||
tracker.any { (_, job) ->
|
process(job),
|
||||||
job.status == JobStatus.PENDING || job.status == JobStatus.RUNNING
|
tracker.any { (_, job) ->
|
||||||
}
|
job.status == JobStatus.PENDING || job.status == JobStatus.RUNNING
|
||||||
)
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.catch { logger.error(it) { "Error during updates" } }
|
.catch { logger.error(it) { "Error during updates" } }
|
||||||
.launchIn(scope)
|
.launchIn(scope)
|
||||||
@@ -49,7 +63,7 @@ class Updater : IUpdater {
|
|||||||
tracker[job.manga.id] = job.copy(status = JobStatus.RUNNING)
|
tracker[job.manga.id] = job.copy(status = JobStatus.RUNNING)
|
||||||
_status.update { UpdateStatus(tracker.values.toList(), true) }
|
_status.update { UpdateStatus(tracker.values.toList(), true) }
|
||||||
tracker[job.manga.id] = try {
|
tracker[job.manga.id] = try {
|
||||||
logger.info { "Updating ${job.manga.title}" }
|
logger.info { "Updating \"${job.manga.title}\" (source: ${job.manga.sourceId})" }
|
||||||
Chapter.getChapterList(job.manga.id, true)
|
Chapter.getChapterList(job.manga.id, true)
|
||||||
job.copy(status = JobStatus.COMPLETE)
|
job.copy(status = JobStatus.COMPLETE)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -60,19 +74,24 @@ class Updater : IUpdater {
|
|||||||
return tracker.values.toList()
|
return tracker.values.toList()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun addMangaToQueue(manga: MangaDataClass) {
|
override fun addMangasToQueue(mangas: List<MangaDataClass>) {
|
||||||
|
mangas.forEach { tracker[it.id] = UpdateJob(it) }
|
||||||
|
_status.update { UpdateStatus(tracker.values.toList(), mangas.isNotEmpty()) }
|
||||||
|
mangas.forEach { addMangaToQueue(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addMangaToQueue(manga: MangaDataClass) {
|
||||||
|
val updateChannel = getOrCreateUpdateChannelFor(manga.sourceId)
|
||||||
scope.launch {
|
scope.launch {
|
||||||
updateChannel.send(UpdateJob(manga))
|
updateChannel.send(UpdateJob(manga))
|
||||||
}
|
}
|
||||||
tracker[manga.id] = UpdateJob(manga)
|
|
||||||
_status.update { UpdateStatus(tracker.values.toList(), true) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun reset() {
|
override fun reset() {
|
||||||
scope.coroutineContext.cancelChildren()
|
scope.coroutineContext.cancelChildren()
|
||||||
tracker.clear()
|
tracker.clear()
|
||||||
_status.update { UpdateStatus() }
|
_status.update { UpdateStatus() }
|
||||||
updateChannel.cancel()
|
updateChannels.forEach { (_, channel) -> channel.cancel() }
|
||||||
updateChannel = createUpdateChannel()
|
updateChannels.clear()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package suwayomi.tachidesk.manga.model.dataclass
|
package suwayomi.tachidesk.manga.model.dataclass
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonValue
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Copyright (C) Contributors to the Suwayomi project
|
* Copyright (C) Contributors to the Suwayomi project
|
||||||
*
|
*
|
||||||
@@ -7,10 +9,20 @@ package suwayomi.tachidesk.manga.model.dataclass
|
|||||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
||||||
|
|
||||||
|
enum class IncludeInUpdate(@JsonValue val value: Int) {
|
||||||
|
EXCLUDE(0), INCLUDE(1), UNSET(-1);
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromValue(value: Int) = IncludeInUpdate.values().find { it.value == value } ?: UNSET
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
data class CategoryDataClass(
|
data class CategoryDataClass(
|
||||||
val id: Int,
|
val id: Int,
|
||||||
val order: Int,
|
val order: Int,
|
||||||
val name: String,
|
val name: String,
|
||||||
val default: Boolean,
|
val default: Boolean,
|
||||||
|
val size: Int,
|
||||||
|
val includeInUpdate: IncludeInUpdate,
|
||||||
val meta: Map<String, String> = emptyMap()
|
val meta: Map<String, String> = emptyMap()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ data class MangaDataClass(
|
|||||||
var unreadCount: Long? = null,
|
var unreadCount: Long? = null,
|
||||||
var downloadCount: Long? = null,
|
var downloadCount: Long? = null,
|
||||||
var chapterCount: Long? = null,
|
var chapterCount: Long? = null,
|
||||||
|
var lastReadAt: Long? = null,
|
||||||
var lastChapterRead: ChapterDataClass? = null,
|
var lastChapterRead: ChapterDataClass? = null,
|
||||||
|
|
||||||
val age: Long? = if (lastFetchedAt == null) 0 else Instant.now().epochSecond.minus(lastFetchedAt),
|
val age: Long? = if (lastFetchedAt == null) 0 else Instant.now().epochSecond.minus(lastFetchedAt),
|
||||||
|
|||||||
@@ -11,11 +11,13 @@ import org.jetbrains.exposed.dao.id.IntIdTable
|
|||||||
import org.jetbrains.exposed.sql.ResultRow
|
import org.jetbrains.exposed.sql.ResultRow
|
||||||
import suwayomi.tachidesk.manga.impl.Category
|
import suwayomi.tachidesk.manga.impl.Category
|
||||||
import suwayomi.tachidesk.manga.model.dataclass.CategoryDataClass
|
import suwayomi.tachidesk.manga.model.dataclass.CategoryDataClass
|
||||||
|
import suwayomi.tachidesk.manga.model.dataclass.IncludeInUpdate
|
||||||
|
|
||||||
object CategoryTable : IntIdTable() {
|
object CategoryTable : IntIdTable() {
|
||||||
val name = varchar("name", 64)
|
val name = varchar("name", 64)
|
||||||
val order = integer("order").default(0)
|
val order = integer("order").default(0)
|
||||||
val isDefault = bool("is_default").default(false)
|
val isDefault = bool("is_default").default(false)
|
||||||
|
val includeInUpdate = integer("include_in_update").default(IncludeInUpdate.UNSET.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun CategoryTable.toDataClass(categoryEntry: ResultRow) = CategoryDataClass(
|
fun CategoryTable.toDataClass(categoryEntry: ResultRow) = CategoryDataClass(
|
||||||
@@ -23,5 +25,7 @@ fun CategoryTable.toDataClass(categoryEntry: ResultRow) = CategoryDataClass(
|
|||||||
categoryEntry[order],
|
categoryEntry[order],
|
||||||
categoryEntry[name],
|
categoryEntry[name],
|
||||||
categoryEntry[isDefault],
|
categoryEntry[isDefault],
|
||||||
|
Category.getCategorySize(categoryEntry[id].value),
|
||||||
|
IncludeInUpdate.fromValue(categoryEntry[includeInUpdate]),
|
||||||
Category.getCategoryMetaMap(categoryEntry[id].value)
|
Category.getCategoryMetaMap(categoryEntry[id].value)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ class ServerConfig(config: Config, moduleName: String = MODULE_NAME) : SystemPro
|
|||||||
val downloadAsCbz: Boolean by overridableConfig
|
val downloadAsCbz: Boolean by overridableConfig
|
||||||
val downloadsPath: String by overridableConfig
|
val downloadsPath: String by overridableConfig
|
||||||
|
|
||||||
|
// updater
|
||||||
|
val maxParallelUpdateRequests: Int by overridableConfig
|
||||||
|
|
||||||
// Authentication
|
// Authentication
|
||||||
val basicAuthEnabled: Boolean by overridableConfig
|
val basicAuthEnabled: Boolean by overridableConfig
|
||||||
val basicAuthUsername: String by overridableConfig
|
val basicAuthUsername: String by overridableConfig
|
||||||
@@ -42,6 +45,10 @@ class ServerConfig(config: Config, moduleName: String = MODULE_NAME) : SystemPro
|
|||||||
val debugLogsEnabled: Boolean = debugLogsEnabled(GlobalConfigManager.config)
|
val debugLogsEnabled: Boolean = debugLogsEnabled(GlobalConfigManager.config)
|
||||||
val systemTrayEnabled: Boolean by overridableConfig
|
val systemTrayEnabled: Boolean by overridableConfig
|
||||||
|
|
||||||
|
// Webview
|
||||||
|
val webviewEnabled: Boolean by overridableConfig
|
||||||
|
val undetectedChromePath: String by overridableConfig
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun register(config: Config) = ServerConfig(config.getConfig(MODULE_NAME))
|
fun register(config: Config) = ServerConfig(config.getConfig(MODULE_NAME))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
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.AddColumnMigration
|
||||||
|
import suwayomi.tachidesk.manga.model.dataclass.IncludeInUpdate
|
||||||
|
|
||||||
|
@Suppress("ClassName", "unused")
|
||||||
|
class M0026_CategoryIncludeInUpdate : AddColumnMigration(
|
||||||
|
"Category",
|
||||||
|
"include_in_update",
|
||||||
|
"INT",
|
||||||
|
IncludeInUpdate.UNSET.value.toString()
|
||||||
|
)
|
||||||
@@ -7,7 +7,6 @@ package suwayomi.tachidesk.server.util
|
|||||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
||||||
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import mu.KotlinLogging
|
import mu.KotlinLogging
|
||||||
import net.lingala.zip4j.ZipFile
|
import net.lingala.zip4j.ZipFile
|
||||||
import org.kodein.di.DI
|
import org.kodein.di.DI
|
||||||
@@ -16,7 +15,6 @@ import org.kodein.di.instance
|
|||||||
import suwayomi.tachidesk.server.ApplicationDirs
|
import suwayomi.tachidesk.server.ApplicationDirs
|
||||||
import suwayomi.tachidesk.server.BuildConfig
|
import suwayomi.tachidesk.server.BuildConfig
|
||||||
import suwayomi.tachidesk.server.serverConfig
|
import suwayomi.tachidesk.server.serverConfig
|
||||||
import uy.kohesive.injekt.injectLazy
|
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.net.HttpURLConnection
|
import java.net.HttpURLConnection
|
||||||
@@ -26,7 +24,6 @@ import java.security.MessageDigest
|
|||||||
|
|
||||||
private val logger = KotlinLogging.logger {}
|
private val logger = KotlinLogging.logger {}
|
||||||
private val applicationDirs by DI.global.instance<ApplicationDirs>()
|
private val applicationDirs by DI.global.instance<ApplicationDirs>()
|
||||||
private val json: Json by injectLazy()
|
|
||||||
private val tmpDir = System.getProperty("java.io.tmpdir")
|
private val tmpDir = System.getProperty("java.io.tmpdir")
|
||||||
|
|
||||||
private fun ByteArray.toHex(): String = joinToString(separator = "") { eachByte -> "%02x".format(eachByte) }
|
private fun ByteArray.toHex(): String = joinToString(separator = "") { eachByte -> "%02x".format(eachByte) }
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
(function () {
|
|
||||||
const ORIGINAL_CANVAS = HTMLCanvasElement.prototype[name];
|
|
||||||
Object.defineProperty(HTMLCanvasElement.prototype, name, {
|
|
||||||
"value": function () {
|
|
||||||
var shift = {
|
|
||||||
'r': Math.floor(Math.random() * 10) - 5,
|
|
||||||
'g': Math.floor(Math.random() * 10) - 5,
|
|
||||||
'b': Math.floor(Math.random() * 10) - 5,
|
|
||||||
'a': Math.floor(Math.random() * 10) - 5
|
|
||||||
};
|
|
||||||
var width = this.width,
|
|
||||||
height = this.height,
|
|
||||||
context = this.getContext("2d");
|
|
||||||
var imageData = context.getImageData(0, 0, width, height);
|
|
||||||
for (var i = 0; i < height; i++) {
|
|
||||||
for (var j = 0; j < width; j++) {
|
|
||||||
var n = ((i * (width * 4)) + (j * 4));
|
|
||||||
imageData.data[n + 0] = imageData.data[n + 0] + shift.r;
|
|
||||||
imageData.data[n + 1] = imageData.data[n + 1] + shift.g;
|
|
||||||
imageData.data[n + 2] = imageData.data[n + 2] + shift.b;
|
|
||||||
imageData.data[n + 3] = imageData.data[n + 3] + shift.a;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
context.putImageData(imageData, 0, 0);
|
|
||||||
return ORIGINAL_CANVAS.apply(this, arguments);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
})(this);
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
Object.defineProperty(window, 'chrome', {
|
|
||||||
value: new Proxy(window.chrome, {
|
|
||||||
has: (target, key) => true,
|
|
||||||
get: (target, key) => {
|
|
||||||
return {
|
|
||||||
app: {
|
|
||||||
isInstalled: false,
|
|
||||||
},
|
|
||||||
webstore: {
|
|
||||||
onInstallStageChanged: {},
|
|
||||||
onDownloadProgress: {},
|
|
||||||
},
|
|
||||||
runtime: {
|
|
||||||
PlatformOs: {
|
|
||||||
MAC: 'mac',
|
|
||||||
WIN: 'win',
|
|
||||||
ANDROID: 'android',
|
|
||||||
CROS: 'cros',
|
|
||||||
LINUX: 'linux',
|
|
||||||
OPENBSD: 'openbsd',
|
|
||||||
},
|
|
||||||
PlatformArch: {
|
|
||||||
ARM: 'arm',
|
|
||||||
X86_32: 'x86-32',
|
|
||||||
X86_64: 'x86-64',
|
|
||||||
},
|
|
||||||
PlatformNaclArch: {
|
|
||||||
ARM: 'arm',
|
|
||||||
X86_32: 'x86-32',
|
|
||||||
X86_64: 'x86-64',
|
|
||||||
},
|
|
||||||
RequestUpdateCheckStatus: {
|
|
||||||
THROTTLED: 'throttled',
|
|
||||||
NO_UPDATE: 'no_update',
|
|
||||||
UPDATE_AVAILABLE: 'update_available',
|
|
||||||
},
|
|
||||||
OnInstalledReason: {
|
|
||||||
INSTALL: 'install',
|
|
||||||
UPDATE: 'update',
|
|
||||||
CHROME_UPDATE: 'chrome_update',
|
|
||||||
SHARED_MODULE_UPDATE: 'shared_module_update',
|
|
||||||
},
|
|
||||||
OnRestartRequiredReason: {
|
|
||||||
APP_UPDATE: 'app_update',
|
|
||||||
OS_UPDATE: 'os_update',
|
|
||||||
PERIODIC: 'periodic',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
});
|
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
(function () {
|
|
||||||
const plugin0 = Object.create(Plugin.prototype);
|
|
||||||
|
|
||||||
const mimeType0 = Object.create(MimeType.prototype);
|
|
||||||
const mimeType1 = Object.create(MimeType.prototype);
|
|
||||||
Object.defineProperties(mimeType0, {
|
|
||||||
type: {
|
|
||||||
get: () => 'application/pdf',
|
|
||||||
},
|
|
||||||
suffixes: {
|
|
||||||
get: () => 'pdf',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
Object.defineProperties(mimeType1, {
|
|
||||||
type: {
|
|
||||||
get: () => 'text/pdf',
|
|
||||||
},
|
|
||||||
suffixes: {
|
|
||||||
get: () => 'pdf',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
Object.defineProperties(plugin0, {
|
|
||||||
name: {
|
|
||||||
get: () => 'Chrome PDF Viewer',
|
|
||||||
},
|
|
||||||
description: {
|
|
||||||
get: () => 'Portable Document Format',
|
|
||||||
},
|
|
||||||
0: {
|
|
||||||
get: () => {
|
|
||||||
return mimeType0;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
1: {
|
|
||||||
get: () => {
|
|
||||||
return mimeType1;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
length: {
|
|
||||||
get: () => 2,
|
|
||||||
},
|
|
||||||
filename: {
|
|
||||||
get: () => 'internal-pdf-viewer',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const plugin1 = Object.create(Plugin.prototype);
|
|
||||||
Object.defineProperties(plugin1, {
|
|
||||||
name: {
|
|
||||||
get: () => 'Chromium PDF Viewer',
|
|
||||||
},
|
|
||||||
description: {
|
|
||||||
get: () => 'Portable Document Format',
|
|
||||||
},
|
|
||||||
0: {
|
|
||||||
get: () => {
|
|
||||||
return mimeType0;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
1: {
|
|
||||||
get: () => {
|
|
||||||
return mimeType1;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
length: {
|
|
||||||
get: () => 2,
|
|
||||||
},
|
|
||||||
filename: {
|
|
||||||
get: () => 'internal-pdf-viewer',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const plugin2 = Object.create(Plugin.prototype);
|
|
||||||
Object.defineProperties(plugin2, {
|
|
||||||
name: {
|
|
||||||
get: () => 'Microsoft Edge PDF Viewer',
|
|
||||||
},
|
|
||||||
description: {
|
|
||||||
get: () => 'Portable Document Format',
|
|
||||||
},
|
|
||||||
0: {
|
|
||||||
get: () => {
|
|
||||||
return mimeType0;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
1: {
|
|
||||||
get: () => {
|
|
||||||
return mimeType1;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
length: {
|
|
||||||
get: () => 2,
|
|
||||||
},
|
|
||||||
filename: {
|
|
||||||
get: () => 'internal-pdf-viewer',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const plugin3 = Object.create(Plugin.prototype);
|
|
||||||
Object.defineProperties(plugin3, {
|
|
||||||
name: {
|
|
||||||
get: () => 'PDF Viewer',
|
|
||||||
},
|
|
||||||
description: {
|
|
||||||
get: () => 'Portable Document Format',
|
|
||||||
},
|
|
||||||
0: {
|
|
||||||
get: () => {
|
|
||||||
return mimeType0;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
1: {
|
|
||||||
get: () => {
|
|
||||||
return mimeType1;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
length: {
|
|
||||||
get: () => 2,
|
|
||||||
},
|
|
||||||
filename: {
|
|
||||||
get: () => 'internal-pdf-viewer',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const plugin4 = Object.create(Plugin.prototype);
|
|
||||||
Object.defineProperties(plugin4, {
|
|
||||||
name: {
|
|
||||||
get: () => 'WebKit built-in PDF',
|
|
||||||
},
|
|
||||||
description: {
|
|
||||||
get: () => 'Portable Document Format',
|
|
||||||
},
|
|
||||||
0: {
|
|
||||||
get: () => {
|
|
||||||
return mimeType0;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
1: {
|
|
||||||
get: () => {
|
|
||||||
return mimeType1;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
length: {
|
|
||||||
get: () => 2,
|
|
||||||
},
|
|
||||||
filename: {
|
|
||||||
get: () => 'internal-pdf-viewer',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const pluginArray = Object.create(PluginArray.prototype);
|
|
||||||
|
|
||||||
pluginArray['0'] = plugin0;
|
|
||||||
pluginArray['1'] = plugin1;
|
|
||||||
pluginArray['2'] = plugin2;
|
|
||||||
pluginArray['3'] = plugin3;
|
|
||||||
pluginArray['4'] = plugin4;
|
|
||||||
|
|
||||||
let refreshValue;
|
|
||||||
|
|
||||||
Object.defineProperties(pluginArray, {
|
|
||||||
length: {
|
|
||||||
get: () => 5,
|
|
||||||
},
|
|
||||||
item: {
|
|
||||||
value: (index) => {
|
|
||||||
if (index > 4294967295) {
|
|
||||||
index = index % 4294967296;
|
|
||||||
}
|
|
||||||
switch (index) {
|
|
||||||
case 0:
|
|
||||||
return plugin3;
|
|
||||||
case 1:
|
|
||||||
return plugin0;
|
|
||||||
case 2:
|
|
||||||
return plugin1;
|
|
||||||
case 3:
|
|
||||||
return plugin2;
|
|
||||||
case 4:
|
|
||||||
return plugin4;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
refresh: {
|
|
||||||
get: () => {
|
|
||||||
return refreshValue;
|
|
||||||
},
|
|
||||||
set: (value) => {
|
|
||||||
refreshValue = value;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
Object.defineProperty(Object.getPrototypeOf(navigator), 'plugins', {
|
|
||||||
get: () => {
|
|
||||||
return pluginArray;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
(function () {
|
|
||||||
window.chrome = {};
|
|
||||||
window.chrome.app = {
|
|
||||||
InstallState: {
|
|
||||||
DISABLED: 'disabled',
|
|
||||||
INSTALLED: 'installed',
|
|
||||||
NOT_INSTALLED: 'not_installed',
|
|
||||||
},
|
|
||||||
RunningState: {
|
|
||||||
CANNOT_RUN: 'cannot_run',
|
|
||||||
READY_TO_RUN: 'ready_to_run',
|
|
||||||
RUNNING: 'running',
|
|
||||||
},
|
|
||||||
getDetails: () => {
|
|
||||||
'[native code]';
|
|
||||||
},
|
|
||||||
getIsInstalled: () => {
|
|
||||||
'[native code]';
|
|
||||||
},
|
|
||||||
installState: () => {
|
|
||||||
'[native code]';
|
|
||||||
},
|
|
||||||
get isInstalled() {
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
runningState: () => {
|
|
||||||
'[native code]';
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
window.chrome.runtime = {
|
|
||||||
OnInstalledReason: {
|
|
||||||
CHROME_UPDATE: 'chrome_update',
|
|
||||||
INSTALL: 'install',
|
|
||||||
SHARED_MODULE_UPDATE: 'shared_module_update',
|
|
||||||
UPDATE: 'update',
|
|
||||||
},
|
|
||||||
OnRestartRequiredReason: {
|
|
||||||
APP_UPDATE: 'app_update',
|
|
||||||
OS_UPDATE: 'os_update',
|
|
||||||
PERIODIC: 'periodic',
|
|
||||||
},
|
|
||||||
PlatformArch: {
|
|
||||||
ARM: 'arm',
|
|
||||||
ARM64: 'arm64',
|
|
||||||
MIPS: 'mips',
|
|
||||||
MIPS64: 'mips64',
|
|
||||||
X86_32: 'x86-32',
|
|
||||||
X86_64: 'x86-64',
|
|
||||||
},
|
|
||||||
PlatformNaclArch: {
|
|
||||||
ARM: 'arm',
|
|
||||||
MIPS: 'mips',
|
|
||||||
MIPS64: 'mips64',
|
|
||||||
X86_32: 'x86-32',
|
|
||||||
X86_64: 'x86-64',
|
|
||||||
},
|
|
||||||
PlatformOs: {
|
|
||||||
ANDROID: 'android',
|
|
||||||
CROS: 'cros',
|
|
||||||
FUCHSIA: 'fuchsia',
|
|
||||||
LINUX: 'linux',
|
|
||||||
MAC: 'mac',
|
|
||||||
OPENBSD: 'openbsd',
|
|
||||||
WIN: 'win',
|
|
||||||
},
|
|
||||||
RequestUpdateCheckStatus: {
|
|
||||||
NO_UPDATE: 'no_update',
|
|
||||||
THROTTLED: 'throttled',
|
|
||||||
UPDATE_AVAILABLE: 'update_available',
|
|
||||||
},
|
|
||||||
connect() {
|
|
||||||
'[native code]';
|
|
||||||
},
|
|
||||||
sendMessage() {
|
|
||||||
'[native code]';
|
|
||||||
},
|
|
||||||
id: undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
let startE = Date.now();
|
|
||||||
window.chrome.csi = function () {
|
|
||||||
'[native code]';
|
|
||||||
return {
|
|
||||||
startE: startE,
|
|
||||||
onloadT: startE + 281,
|
|
||||||
pageT: 3947.235,
|
|
||||||
tran: 15,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
window.chrome.loadTimes = function () {
|
|
||||||
'[native code]';
|
|
||||||
return {
|
|
||||||
get requestTime() {
|
|
||||||
return startE / 1000;
|
|
||||||
},
|
|
||||||
get startLoadTime() {
|
|
||||||
return startE / 1000;
|
|
||||||
},
|
|
||||||
get commitLoadTime() {
|
|
||||||
return startE / 1000 + 0.324;
|
|
||||||
},
|
|
||||||
get finishDocumentLoadTime() {
|
|
||||||
return startE / 1000 + 0.498;
|
|
||||||
},
|
|
||||||
get finishLoadTime() {
|
|
||||||
return startE / 1000 + 0.534;
|
|
||||||
},
|
|
||||||
get firstPaintTime() {
|
|
||||||
return startE / 1000 + 0.437;
|
|
||||||
},
|
|
||||||
get firstPaintAfterLoadTime() {
|
|
||||||
return 0;
|
|
||||||
},
|
|
||||||
get navigationType() {
|
|
||||||
return 'Other';
|
|
||||||
},
|
|
||||||
get wasFetchedViaSpdy() {
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
get wasNpnNegotiated() {
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
get npnNegotiatedProtocol() {
|
|
||||||
return 'h3';
|
|
||||||
},
|
|
||||||
get wasAlternateProtocolAvailable() {
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
get connectionInfo() {
|
|
||||||
return 'h3';
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
|
|
||||||
// Bypass OOPIF test
|
|
||||||
(function performance_memory() {
|
|
||||||
const jsHeapSizeLimitInt = 4294705152;
|
|
||||||
|
|
||||||
const total_js_heap_size = 35244183;
|
|
||||||
const used_js_heap_size = [
|
|
||||||
17632315, 17632315, 17632315, 17634847, 17636091, 17636751,
|
|
||||||
];
|
|
||||||
|
|
||||||
let counter = 0;
|
|
||||||
|
|
||||||
let MemoryInfoProto = Object.getPrototypeOf(performance.memory);
|
|
||||||
Object.defineProperties(MemoryInfoProto, {
|
|
||||||
jsHeapSizeLimit: {
|
|
||||||
get: () => {
|
|
||||||
return jsHeapSizeLimitInt;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
totalJSHeapSize: {
|
|
||||||
get: () => {
|
|
||||||
return total_js_heap_size;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
usedJSHeapSize: {
|
|
||||||
get: () => {
|
|
||||||
if (counter > 5) {
|
|
||||||
counter = 0;
|
|
||||||
}
|
|
||||||
return used_js_heap_size[counter++];
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
Object.defineProperty(navigator, 'maxTouchPoints', {
|
|
||||||
get: () => 1
|
|
||||||
});
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
// https://github.com/microlinkhq/browserless/blob/master/packages/goto/src/evasions/navigator-permissions.js
|
|
||||||
if (!window.Notification) {
|
|
||||||
window.Notification = {
|
|
||||||
permission: 'denied'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const originalQuery = window.navigator.permissions.query
|
|
||||||
window.navigator.permissions.__proto__.query = parameters =>
|
|
||||||
parameters.name === 'notifications'
|
|
||||||
? Promise.resolve({state: window.Notification.permission})
|
|
||||||
: originalQuery(parameters)
|
|
||||||
const oldCall = Function.prototype.call
|
|
||||||
|
|
||||||
function call() {
|
|
||||||
return oldCall.apply(this, arguments)
|
|
||||||
}
|
|
||||||
|
|
||||||
Function.prototype.call = call
|
|
||||||
const nativeToStringFunctionString = Error.toString().replace(/Error/g, 'toString')
|
|
||||||
const oldToString = Function.prototype.toString
|
|
||||||
|
|
||||||
function functionToString() {
|
|
||||||
if (this === window.navigator.permissions.query) {
|
|
||||||
return 'function query() { [native code] }'
|
|
||||||
}
|
|
||||||
if (this === functionToString) {
|
|
||||||
return nativeToStringFunctionString
|
|
||||||
}
|
|
||||||
return oldCall.call(oldToString, this)
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line
|
|
||||||
Function.prototype.toString = functionToString
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
Object.defineProperty(Navigator.prototype, 'webdriver', {
|
|
||||||
get() {
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -18,6 +18,9 @@ server.electronPath = ""
|
|||||||
server.downloadAsCbz = false
|
server.downloadAsCbz = false
|
||||||
server.downloadsPath = ""
|
server.downloadsPath = ""
|
||||||
|
|
||||||
|
# updater
|
||||||
|
server.maxParallelUpdateRequests = 10 # sets how many sources can be updated in parallel. updates are grouped by source and all mangas of a source are updated synchronously
|
||||||
|
|
||||||
# Authentication
|
# Authentication
|
||||||
server.basicAuthEnabled = false
|
server.basicAuthEnabled = false
|
||||||
server.basicAuthUsername = ""
|
server.basicAuthUsername = ""
|
||||||
@@ -26,3 +29,7 @@ server.basicAuthPassword = ""
|
|||||||
# misc
|
# misc
|
||||||
server.debugLogsEnabled = false
|
server.debugLogsEnabled = false
|
||||||
server.systemTrayEnabled = true
|
server.systemTrayEnabled = true
|
||||||
|
|
||||||
|
# Webview
|
||||||
|
server.webviewEnabled = false
|
||||||
|
server.undetectedChromePath = ""
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ class TestUpdater : IUpdater {
|
|||||||
private val _status = MutableStateFlow(UpdateStatus())
|
private val _status = MutableStateFlow(UpdateStatus())
|
||||||
override val status: StateFlow<UpdateStatus> = _status.asStateFlow()
|
override val status: StateFlow<UpdateStatus> = _status.asStateFlow()
|
||||||
|
|
||||||
override fun addMangaToQueue(manga: MangaDataClass) {
|
override fun addMangasToQueue(mangas: List<MangaDataClass>) {
|
||||||
updateQueue.add(UpdateJob(manga))
|
mangas.forEach { updateQueue.add(UpdateJob(it)) }
|
||||||
isRunning = true
|
isRunning = true
|
||||||
updateStatus()
|
updateStatus()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ server.socksProxyPort = ""
|
|||||||
# downloader
|
# downloader
|
||||||
server.downloadAsCbz = false
|
server.downloadAsCbz = false
|
||||||
|
|
||||||
|
# updater
|
||||||
|
server.maxParallelUpdateRequests = 10
|
||||||
|
|
||||||
# misc
|
# misc
|
||||||
server.debugLogsEnabled = true
|
server.debugLogsEnabled = true
|
||||||
server.systemTrayEnabled = false
|
server.systemTrayEnabled = false
|
||||||
|
|||||||
Reference in New Issue
Block a user