Compare commits

...

46 Commits

Author SHA1 Message Date
Aria Moradi 9018de3c4c v0.6.6
CI Publish / Validate Gradle Wrapper (push) Successful in 11s
CI Publish / Build Jar (push) Failing after 5s
CI Publish / Make debian-all release (push) Has been skipped
CI Publish / Make linux-assets release (push) Has been skipped
CI Publish / Make linux-x64 release (push) Has been skipped
CI Publish / Make macOS-arm64 release (push) Has been skipped
CI Publish / Make macOS-x64 release (push) Has been skipped
CI Publish / Make windows-x64 release (push) Has been skipped
CI Publish / Make windows-x86 release (push) Has been skipped
CI Publish / release (push) Has been skipped
2022-11-26 20:29:51 +03:30
Valter Martinek e7cb88c757 Download queue missing update fix (#450)
* Add immediate updates to download queue manager for updates that always needs to be delivered

* Update server/src/main/kotlin/suwayomi/tachidesk/manga/impl/download/DownloadManager.kt

Co-authored-by: Mitchell Syer <Mitchellptbo@gmail.com>

* Revert change to make sure that data in status sent to client are actual

* Reduce number of immediate updates to clients

Co-authored-by: Mitchell Syer <Mitchellptbo@gmail.com>
2022-11-16 20:03:51 +03:30
Valter Martinek d6127d6811 Add batch endpoint for removing downloads from download queue (#452) 2022-11-16 20:01:48 +03:30
Aria Moradi 67e09e2e1d make chapters endpoint more unifrom 2022-11-15 15:46:02 +03:30
Valter Martinek 8fbc24c751 Batch editing and deleting any chapter (#449)
* Add new endpoint for batch editing any chapter

* Add option to batch editing chapters to delete chapter (remove downloaded content)

* Rename the endpoint to match single manga batch endpoint

* Do not return early, in case there are other changes

* PR changes
2022-11-15 14:19:20 +03:30
Valter Martinek c0948209be Fix docs for /server/check-updates (#447) 2022-11-11 16:21:29 +03:30
Valter Martinek 7237161d52 Fix settings/check-update endpoint (#445) 2022-11-10 21:32:27 +03:30
Aria Moradi 94c2e21e2b Future proofing 2022-11-10 04:36:56 +03:30
Aria Moradi 65067e6e01 changes needed for tachiyomi tracker 2022-11-10 02:13:20 +03:30
Valter Martinek 39490ce7ba Add batch chapter update endpoint (#442) 2022-11-09 20:43:29 +03:30
Mitchell Syer 2f3f47c745 Set source preference doc fix (#441) 2022-11-08 10:32:45 +03:30
Mitchell Syer 2195c3df76 Downloader Rewrite (#437)
* Downloader rewrite
- Rewrite downloader to use coroutines instead of a thread
- Remove unused Page functions
- Add page progress
- Add ProgressResponseBody
- Add support for canceling a download in the middle of downloading
- Fix clear download queue

* Minor fix

* Minor improvements
- notifyAllClients now launches in another thread and only sends new data every second
- Better handling of download queue checker in step()
- Minor improvements and fixes

* Reorder downloads

* Download in parallel by source

* Remove TODO
2022-11-08 04:39:26 +03:30
Aria Moradi 119b9db6b4 refactor deprecated api 2022-11-07 22:50:20 +03:30
Aria Moradi fcbc598732 Revert H2 database to v1 2022-11-07 22:50:20 +03:30
Aria Moradi e850049e8e add category and global meta (#438) 2022-11-07 21:04:34 +03:30
Aria Moradi 907adea73f Migrate to H2 v2 2022-11-07 14:10:33 +03:30
Valter Martinek 2ac5c1362c add batch download api (#436)
* Add POST /downloads endpoint for creating multiple

* Fix review notes

* Add chapter id to API endpoints

* Rewrite batch chapter download to use chapter id instead of mangaId+chapterIndex combination

* Change EnqueueInput format to be more futureproof

* Change endpoint path

* Change endpoint path
2022-11-07 01:02:18 +03:30
Mitchell Syer 8b20e2b48f Add request body to documentation (#435) 2022-11-06 23:19:11 +03:30
Valter Martinek c2a9820fc1 POST variant for /{sourceId}/search endpoint (#434)
* Add POST variant for `/{sourceId}/search` endpoint which handles body data as list of FilterChanges

* Revert changes to existing endpoint and create new route and change the interface

* Update doc

* Rename api endpoint
2022-11-05 20:48:20 +03:30
Valter Martinek a9e5bc0c95 Pre-load meta entries for all chapters for optimization (#432)
Load meta entries for all chapters in one query to prevent N+1 queries
2022-10-30 20:18:27 +03:30
Valter Martinek 0fa2834d25 add MangaTable.lastFetchedAt and ChapterTable.chaptersLastFetchedAt (#431)
* Add lastFetchedAt and chaptersLastFetchedAt columns to manga

* Update lastFetchedAt columns when data are fetched from source

* Add age and chaptersAge fields to MangaDataClass

* Replace two migrations with single migration
2022-10-30 20:16:23 +03:30
Valter Martinek 23f0876c00 Add cache control header to manga page response (#430) 2022-10-29 22:19:19 +03:30
Anurag 6d88d90659 Fix: Error handling for popular/latest api if pageNum was supplied as zero (#424)
* fix: handle and throw proper error if pageNum is zero for popular/latest api, fixes #75

* chore: replace if-else with kotlin require which throws IllegalArgumentException and add comment

* fix: remove comment as exception message is enough
2022-10-28 14:34:22 +03:30
Mitchell Syer a3c366c360 Lint (#423) 2022-10-22 15:38:14 +03:30
Mitchell Syer 3bef07eeab Update dependencies (#422)
* Update dependencies and lint files

* Revert lint
2022-10-22 03:33:07 +03:30
Aria Moradi d029e65b8e include list of mangas missing source in restore report (#421) 2022-10-20 00:20:39 +03:30
Aria Moradi f305ac6905 remove BuildConfig as extensions now use AppInfo 2022-10-19 23:08:08 +03:30
Aria Moradi 4d4a46d2a5 move Tachiyomi's BuildConfig to kotlin dir 2022-10-19 22:44:00 +03:30
Aria Moradi 8218f2f830 ktlint 2022-10-19 16:22:07 +03:30
like b1bf901eac replace quickjs with Mozilla Rhino (#415)
* replace quickjs with jdk 8 default js engine

* replace quickjs with rhino engine and translate type for read comic online extension

* move quick js to AndroidCompat

* fix commicabc long type cast exception
2022-10-12 14:03:49 +03:30
Mitchell Syer 06eff55210 Updater cleanup and improvements (#416) 2022-10-11 19:57:15 +03:30
Mitchell Syer 71730fddad Documentation cleanup (#417) 2022-10-11 12:54:45 +03:30
Mitchell Syer f2d1c6e3cb Fix downloader memory leak (#418) 2022-10-11 12:52:10 +03:30
Marco Ebbinghaus 7ae837ca3c Remove support for Sorayomi web interface (#414)
fixes #392
2022-10-07 22:26:26 +03:30
Vedant b10908df5e Update winget.yml (#410) 2022-10-02 15:07:16 +03:30
Mahor 4dd4d38d5b Revert back to correct way of handling jre_dir (#408) 2022-09-28 22:44:14 +03:30
Mahor 447c286b56 Add libc++-dev (#405)
Use java8-runtime-headless virtual package which is a superset of default-jre-headless
2022-09-25 18:19:37 +03:30
Aria Moradi c71898ece9 Update Changelog
CI Publish / Validate Gradle Wrapper (push) Successful in 17s
CI Publish / Build Jar (push) Failing after 5s
CI Publish / Make debian-all release (push) Has been skipped
CI Publish / Make linux-assets release (push) Has been skipped
CI Publish / Make linux-x64 release (push) Has been skipped
CI Publish / Make macOS-arm64 release (push) Has been skipped
CI Publish / Make macOS-x64 release (push) Has been skipped
CI Publish / Make windows-x64 release (push) Has been skipped
CI Publish / Make windows-x86 release (push) Has been skipped
CI Publish / release (push) Has been skipped
2022-09-18 09:20:21 +04:30
Aria Moradi 9473e88ea9 bump version 2022-09-18 09:14:44 +04:30
Mahor d7663ed56e Fix deb package (#397) 2022-08-29 21:59:23 +04:30
voltrare da7569e2f5 fix jre path(#396)
use `mv -T` @mahor1221
2022-08-27 16:01:05 +04:30
Vedant d989940a4d Update winget.yml (#393) 2022-08-21 15:29:18 +04:30
Aria Moradi bd6a86b135 fix more broken stuff 2022-08-19 00:26:03 +04:30
Aria Moradi b38eb11503 fix more broken stuff 2022-08-19 00:25:37 +04:30
Aria Moradi 7aef32c13d fix more broken stuff 2022-08-19 00:24:40 +04:30
Aria Moradi fab64b147c fix broken links 2022-08-19 00:21:23 +04:30
114 changed files with 1611 additions and 581 deletions
+2 -1
View File
@@ -6,7 +6,8 @@ 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@latest - uses: vedantmgoyal2009/winget-releaser@v1
with: with:
identifier: Suwayomi.Tachidesk-Server identifier: Suwayomi.Tachidesk-Server
installers-regex: '.*x64.msi$'
token: ${{ secrets.WINGET_PUBLISH_PAT }} token: ${{ secrets.WINGET_PUBLISH_PAT }}
+4 -4
View File
@@ -9,20 +9,20 @@ dependencies {
implementation(project(":AndroidCompat:Config")) implementation(project(":AndroidCompat:Config"))
// APK sig verifier // APK sig verifier
compileOnly("com.android.tools.build:apksig:7.1.0-beta05") compileOnly("com.android.tools.build:apksig:7.2.1")
// AndroidX annotations // AndroidX annotations
compileOnly("androidx.annotation:annotation:1.3.0") compileOnly("androidx.annotation:annotation:1.5.0")
// substitute for duktape-android // substitute for duktape-android
implementation("org.mozilla:rhino-runtime:1.7.14") // slimmer version of 'org.mozilla:rhino' implementation("org.mozilla:rhino-runtime:1.7.14") // slimmer version of 'org.mozilla:rhino'
implementation("org.mozilla:rhino-engine:1.7.14") // provides the same interface as 'javax.script' a.k.a Nashorn implementation("org.mozilla:rhino-engine:1.7.14") // provides the same interface as 'javax.script' a.k.a Nashorn
// Kotlin wrapper around Java Preferences, makes certain things easier // Kotlin wrapper around Java Preferences, makes certain things easier
val multiplatformSettingsVersion = "0.8.1" val multiplatformSettingsVersion = "1.0.0-RC"
implementation("com.russhwolf:multiplatform-settings-jvm:$multiplatformSettingsVersion") implementation("com.russhwolf:multiplatform-settings-jvm:$multiplatformSettingsVersion")
implementation("com.russhwolf:multiplatform-settings-serialization-jvm:$multiplatformSettingsVersion") implementation("com.russhwolf:multiplatform-settings-serialization-jvm:$multiplatformSettingsVersion")
// Android version of SimpleDateFormat // Android version of SimpleDateFormat
implementation("com.ibm.icu:icu4j:70.1") implementation("com.ibm.icu:icu4j:72.1")
} }
@@ -4,7 +4,7 @@ package android.text;
import android.graphics.drawable.Drawable; import android.graphics.drawable.Drawable;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
import org.jsoup.Jsoup; import org.jsoup.Jsoup;
import org.jsoup.safety.Whitelist; import org.jsoup.safety.Safelist;
import org.xml.sax.XMLReader; import org.xml.sax.XMLReader;
/** /**
@@ -18,7 +18,7 @@ import org.xml.sax.XMLReader;
public class Html { public class Html {
public static Spanned fromHtml(String source) { public static Spanned fromHtml(String source) {
return new FakeSpanned(Jsoup.clean(source, Whitelist.none())); return new FakeSpanned(Jsoup.clean(source, Safelist.none()));
} }
public static Spanned fromHtml(String source, Html.ImageGetter imageGetter, Html.TagHandler tagHandler) { public static Spanned fromHtml(String source, Html.ImageGetter imageGetter, Html.TagHandler tagHandler) {
@@ -0,0 +1,69 @@
package app.cash.quickjs;
import org.mozilla.javascript.ConsString;
import org.mozilla.javascript.NativeArray;
import javax.script.ScriptEngine;
import javax.script.ScriptEngineManager;
import java.io.Closeable;
public final class QuickJs implements Closeable {
private ScriptEngine engine;
public static QuickJs create() {
return new QuickJs(new ScriptEngineManager());
}
public QuickJs(ScriptEngineManager manager) {
this.engine = manager.getEngineByName("rhino");
}
public Object evaluate(String script, String fileName) {
return this.evaluate(script);
}
public Object evaluate(String script) {
try {
Object value = engine.eval(script);
return translateType(value);
} catch (Exception exception) {
throw new QuickJsException(exception.getMessage(), exception);
}
}
private Object translateType(Object obj) {
if (obj instanceof NativeArray) {
NativeArray array = (NativeArray) obj;
long length = array.getLength();
Object[] objects = new Object[(int) length];
for (int i = 0; i < (int) length; i++) {
objects[i] = translateType(array.get(i));
}
return objects;
}
if (obj instanceof ConsString) {
ConsString consString = (ConsString) obj;
return consString.toString();
}
if (obj instanceof Long) {
Long value = (Long) obj;
return value.intValue();
}
return obj;
}
public byte[] compile(String sourceCode, String fileName) {
return sourceCode.getBytes();
}
public Object execute(byte[] bytecode) {
return this.evaluate(new String(bytecode));
}
@Override
public void close() {
this.engine = null;
}
}
@@ -0,0 +1,7 @@
package app.cash.quickjs;
public final class QuickJsException extends RuntimeException {
public QuickJsException(String message, Throwable cause) {
super(message, cause);
}
}
@@ -60,9 +60,13 @@ class ScrollableResultSet(val parent: ResultSet) : ResultSet by parent {
} }
private fun internalMove(row: Int) { private fun internalMove(row: Int) {
if (cursor < 0) cursor = 0 if (cursor < 0) {
else if (cursor > resultSetLength + 1) cursor = resultSetLength + 1 cursor = 0
else cursor = row } else if (cursor > resultSetLength + 1) {
cursor = resultSetLength + 1
} else {
cursor = row
}
} }
private fun obj(column: Int): Any? { private fun obj(column: Int): Any? {
@@ -293,10 +297,11 @@ class ScrollableResultSet(val parent: ResultSet) : ResultSet by parent {
} }
override fun <T : Any?> unwrap(iface: Class<T>?): T { override fun <T : Any?> unwrap(iface: Class<T>?): T {
if (thisIsWrapperFor(iface)) if (thisIsWrapperFor(iface)) {
return this as T return this as T
else } else {
return parent.unwrap(iface) return parent.unwrap(iface)
}
} }
override fun next(): Boolean { override fun next(): Boolean {
@@ -531,10 +536,15 @@ class ScrollableResultSet(val parent: ResultSet) : ResultSet by parent {
} }
private fun castToLong(obj: Any?): Long { private fun castToLong(obj: Any?): Long {
if (obj == null) return 0 if (obj == null) {
else if (obj is Long) return obj return 0
else if (obj is Number) return obj.toLong() } else if (obj is Long) {
else throw IllegalStateException("Object is not a long!") return obj
} else if (obj is Number) {
return obj.toLong()
} else {
throw IllegalStateException("Object is not a long!")
}
} }
override fun getLong(columnIndex: Int): Long { override fun getLong(columnIndex: Int): Long {
@@ -10,7 +10,7 @@ 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.ExperimentalSettingsImplementation
import com.russhwolf.settings.JvmPreferencesSettings 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
import com.russhwolf.settings.serialization.encodeValue import com.russhwolf.settings.serialization.encodeValue
@@ -24,7 +24,7 @@ import java.util.prefs.Preferences
@OptIn(ExperimentalSettingsImplementation::class, ExperimentalSerializationApi::class, ExperimentalSettingsApi::class) @OptIn(ExperimentalSettingsImplementation::class, 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 = JvmPreferencesSettings(javaPreferences) private val preferences = PreferencesSettings(javaPreferences)
private val listeners = mutableMapOf<SharedPreferences.OnSharedPreferenceChangeListener, PreferenceChangeListener>() private val listeners = mutableMapOf<SharedPreferences.OnSharedPreferenceChangeListener, PreferenceChangeListener>()
// TODO: 2021-05-29 Need to find a way to get this working with all pref types // TODO: 2021-05-29 Need to find a way to get this working with all pref types
@@ -76,7 +76,7 @@ class JavaSharedPreferences(key: String) : SharedPreferences {
return Editor(preferences) return Editor(preferences)
} }
class Editor(private val preferences: JvmPreferencesSettings) : SharedPreferences.Editor { class Editor(private val preferences: PreferencesSettings) : SharedPreferences.Editor {
val itemsToAdd = mutableMapOf<String, Any>() val itemsToAdd = mutableMapOf<String, Any>()
override fun putString(key: String, value: String?): SharedPreferences.Editor { override fun putString(key: String, value: String?): SharedPreferences.Editor {
@@ -74,10 +74,11 @@ class PackageController {
fun findPackage(packageName: String): InstalledPackage? { fun findPackage(packageName: String): InstalledPackage? {
val file = File(androidFiles.packagesDir, packageName) val file = File(androidFiles.packagesDir, packageName)
return if (file.exists()) return if (file.exists()) {
InstalledPackage(file) InstalledPackage(file)
else } else {
null null
}
} }
fun findJarFromApk(apkFile: File): File? { fun findJarFromApk(apkFile: File): File? {
+104 -12
View File
@@ -1,3 +1,95 @@
# Server: v0.6.6 + WebUI: r963
## TL;DR
- N/A
## Tachidesk-Server Changelog
- (r1114) fix broken links (by @AriaMoradi)
- (r1115) fix more broken stuff (by @AriaMoradi)
- (r1116) fix more broken stuff (by @AriaMoradi)
- (r1117) fix more broken stuff (by @AriaMoradi)
- (r1118) Update winget.yml ([#393](https://github.com/Suwayomi/Tachidesk-Server/pull/393)) 83997633+vedantmgoyal2009@users.noreply.github.com
- (r1119) fix jre path([#396](https://github.com/Suwayomi/Tachidesk-Server/pull/396)) 30526233+voltrare@users.noreply.github.com
- (r1120) Fix deb package ([#397](https://github.com/Suwayomi/Tachidesk-Server/pull/397)) mahor1221@pm.me
- (r1121) bump version (by @AriaMoradi)
- (r1122) Update Changelog (by @AriaMoradi)
- (r1123) Add libc++-dev ([#405](https://github.com/Suwayomi/Tachidesk-Server/pull/405)) mahor1221@pm.me
- (r1124) Revert back to correct way of handling jre_dir ([#408](https://github.com/Suwayomi/Tachidesk-Server/pull/408)) mahor1221@pm.me
- (r1125) Update winget.yml ([#410](https://github.com/Suwayomi/Tachidesk-Server/pull/410)) 83997633+vedantmgoyal2009@users.noreply.github.com
- (r1126) Remove support for Sorayomi web interface ([#414](https://github.com/Suwayomi/Tachidesk-Server/pull/414)) ebbinghaus.marco@gmail.com
- (r1127) Fix downloader memory leak ([#418](https://github.com/Suwayomi/Tachidesk-Server/pull/418) by @Syer10)
- (r1128) Documentation cleanup ([#417](https://github.com/Suwayomi/Tachidesk-Server/pull/417) by @Syer10)
- (r1129) Updater cleanup and improvements ([#416](https://github.com/Suwayomi/Tachidesk-Server/pull/416) by @Syer10)
- (r1130) replace quickjs with Mozilla Rhino ([#415](https://github.com/Suwayomi/Tachidesk-Server/pull/415)) 747367352@qq.com
- (r1131) ktlint (by @AriaMoradi)
- (r1132) move Tachiyomi's BuildConfig to kotlin dir (by @AriaMoradi)
- (r1133) remove BuildConfig as extensions now use AppInfo (by @AriaMoradi)
- (r1134) include list of mangas missing source in restore report ([#421](https://github.com/Suwayomi/Tachidesk-Server/pull/421) by @AriaMoradi)
- (r1135) Update dependencies ([#422](https://github.com/Suwayomi/Tachidesk-Server/pull/422) by @Syer10)
- (r1136) Lint ([#423](https://github.com/Suwayomi/Tachidesk-Server/pull/423) by @Syer10)
- (r1137) Fix: Error handling for popular/latest api if pageNum was supplied as zero ([#424](https://github.com/Suwayomi/Tachidesk-Server/pull/424)) anurag4884@gmail.com
- (r1138) Add cache control header to manga page response ([#430](https://github.com/Suwayomi/Tachidesk-Server/pull/430) by @martinek)
- (r1139) add MangaTable.lastFetchedAt and ChapterTable.chaptersLastFetchedAt ([#431](https://github.com/Suwayomi/Tachidesk-Server/pull/431) by @martinek)
- (r1140) Pre-load meta entries for all chapters for optimization ([#432](https://github.com/Suwayomi/Tachidesk-Server/pull/432) by @martinek)
- (r1141) POST variant for `/{sourceId}/search` endpoint ([#434](https://github.com/Suwayomi/Tachidesk-Server/pull/434) by @martinek)
- (r1142) Add request body to documentation ([#435](https://github.com/Suwayomi/Tachidesk-Server/pull/435) by @Syer10)
- (r1143) add batch download api ([#436](https://github.com/Suwayomi/Tachidesk-Server/pull/436) by @martinek)
- (r1144) Migrate to H2 v2 (by @AriaMoradi)
- (r1145) add category and global meta ([#438](https://github.com/Suwayomi/Tachidesk-Server/pull/438) by @AriaMoradi)
- (r1146) Revert H2 database to v1 (by @AriaMoradi)
- (r1147) refactor deprecated api (by @AriaMoradi)
- (r1148) Downloader Rewrite ([#437](https://github.com/Suwayomi/Tachidesk-Server/pull/437) by @Syer10)
- (r1149) Set source preference doc fix ([#441](https://github.com/Suwayomi/Tachidesk-Server/pull/441) by @Syer10)
- (r1150) Add batch chapter update endpoint ([#442](https://github.com/Suwayomi/Tachidesk-Server/pull/442) by @martinek)
- (r1151) changes needed for tachiyomi tracker (by @AriaMoradi)
- (r1152) Future proofing (by @AriaMoradi)
- (r1153) Fix settings/check-update endpoint ([#445](https://github.com/Suwayomi/Tachidesk-Server/pull/445) by @martinek)
- (r1154) Fix docs for /server/check-updates ([#447](https://github.com/Suwayomi/Tachidesk-Server/pull/447) by @martinek)
- (r1155) Batch editing and deleting any chapter ([#449](https://github.com/Suwayomi/Tachidesk-Server/pull/449) by @martinek)
- (r1156) make chapters endpoint more unifrom (by @AriaMoradi)
- (r1157) Add batch endpoint for removing downloads from download queue ([#452](https://github.com/Suwayomi/Tachidesk-Server/pull/452) by @martinek)
- (r1158) Download queue missing update fix ([#450](https://github.com/Suwayomi/Tachidesk-Server/pull/450) by @martinek)
## Tachidesk-WebUI Changelog
- (r947) Feature/swr for library screens ([#186](https://github.com/Suwayomi/Tachidesk-WebUI/pull/186) by @martinek)
- (r948) Feature/swr for simple queries ([#187](https://github.com/Suwayomi/Tachidesk-WebUI/pull/187) by @martinek)
- (r949) Check download queue for changes and reload chapters if any chapter download changes state. ([#189](https://github.com/Suwayomi/Tachidesk-WebUI/pull/189) by @martinek)
- (r950) Update typescript dependency ([#190](https://github.com/Suwayomi/Tachidesk-WebUI/pull/190) by @martinek)
- (r951) update browserlist (by @AriaMoradi)
- (r952) Feature/batch chapter download ([#191](https://github.com/Suwayomi/Tachidesk-WebUI/pull/191) by @martinek)
- (r953) Memoize empty view face so it does not change on rerender ([#193](https://github.com/Suwayomi/Tachidesk-WebUI/pull/193) by @martinek)
- (r954) Feature/batch chapter actions ([#194](https://github.com/Suwayomi/Tachidesk-WebUI/pull/194) by @martinek)
- (r955) Fix navbar back button behavior ([#195](https://github.com/Suwayomi/Tachidesk-WebUI/pull/195) by @martinek)
- (r956) Options panels refactoring ([#196](https://github.com/Suwayomi/Tachidesk-WebUI/pull/196) by @martinek)
- (r957) Refactor and fix sorting in library ([#197](https://github.com/Suwayomi/Tachidesk-WebUI/pull/197) by @martinek)
- (r958) Scroll window to top when PagedPager changes page ([#198](https://github.com/Suwayomi/Tachidesk-WebUI/pull/198) by @martinek)
- (r959) Verticall scroll navigation and fix ([#200](https://github.com/Suwayomi/Tachidesk-WebUI/pull/200) by @martinek)
- (r960) Hide overflowing text in reader title if text can't be wrapped ([#199](https://github.com/Suwayomi/Tachidesk-WebUI/pull/199) by @martinek)
- (r961) Add safezone to scroll end detection to prevent edge cases when scrolling to the end would not detect end ([#201](https://github.com/Suwayomi/Tachidesk-WebUI/pull/201) by @martinek)
- (r962) Refactor/download queue and cleanup visuals overall ([#202](https://github.com/Suwayomi/Tachidesk-WebUI/pull/202) by @martinek)
- (r963) Fix "back" pagination on double page layout in reader for spread pages ([#203](https://github.com/Suwayomi/Tachidesk-WebUI/pull/203) by @martinek)
# Server: v0.6.5 + WebUI: r946
## TL;DR
- Fixed Windows bundler
## Tachidesk-Server Changelog
- (r1113) v0.6.4 (by @AriaMoradi)
- (r1114) fix broken links (by @AriaMoradi)
- (r1115) fix more broken stuff (by @AriaMoradi)
- (r1116) fix more broken stuff (by @AriaMoradi)
- (r1117) fix more broken stuff (by @AriaMoradi)
- (r1118) Update winget.yml ([#393](https://github.com/Suwayomi/Tachidesk-Server/pull/393) by @vedantmgoyal2009)
- (r1119) fix jre path([#396](https://github.com/Suwayomi/Tachidesk-Server/pull/396) by @voltrare)
- (r1120) Fix deb package ([#397](https://github.com/Suwayomi/Tachidesk-Server/pull/397)) by @mahor1221)
- (r1121) bump version (by @AriaMoradi)
## Tachidesk-WebUI Changelog
- None
# Server: v0.6.4 + WebUI: r946 # Server: v0.6.4 + WebUI: r946
## TL;DR ## TL;DR
- No new major features - No new major features
@@ -19,18 +111,18 @@
- (r1098) fix formatting by kotlinter (by @AriaMoradi) - (r1098) fix formatting by kotlinter (by @AriaMoradi)
- (r1099) bump WebUI (by @AriaMoradi) - (r1099) bump WebUI (by @AriaMoradi)
- (r1100) fix WebUI release name (by @AriaMoradi) - (r1100) fix WebUI release name (by @AriaMoradi)
- (r1101) Fix documentation errors ([#358](https://github.com/Suwayomi/scripts/pull/358) by @Syer10) - (r1101) Fix documentation errors ([#358](https://github.com/Suwayomi/Tachidesk-Server/pull/358) by @Syer10)
- (r1102) Docs improvements ([#359](https://github.com/Suwayomi/scripts/pull/359) by @Syer10) - (r1102) Docs improvements ([#359](https://github.com/Suwayomi/Tachidesk-Server/pull/359) by @Syer10)
- (r1103) Add linux-all.tar.gz & systemd service ([#366](https://github.com/Suwayomi/scripts/pull/366)) mahor1221@pm.me - (r1103) Add linux-all.tar.gz & systemd service ([#366](https://github.com/Suwayomi/Tachidesk-Server/pull/366) by @mahor1221)
- (r1104) Publish to Windows Package Managar (WinGet [#369](https://github.com/Suwayomi/scripts/pull/369)) 83997633+vedantmgoyal2009@users.noreply.github.com - (r1104) Publish to Windows Package Managar (WinGet) ([#369](https://github.com/Suwayomi/Tachidesk-Server/pull/369) by @vedantmgoyal2009)
- (r1105) Refactor scripts ([#370](https://github.com/Suwayomi/scripts/pull/370)) mahor1221@pm.me - (r1105) Refactor scripts ([#370](https://github.com/Suwayomi/Tachidesk-Server/pull/370) by @mahor1221)
- (r1106) Run workflow jobs toghether ([#371](https://github.com/Suwayomi/scripts/pull/371)) mahor1221@pm.me - (r1106) Run workflow jobs toghether ([#371](https://github.com/Suwayomi/Tachidesk-Server/pull/371) by @mahor1221)
- (r1107) Update gradle action ([#372](https://github.com/Suwayomi/scripts/pull/372)) mahor1221@pm.me - (r1107) Update gradle action ([#372](https://github.com/Suwayomi/Tachidesk-Server/pull/372) by @mahor1221)
- (r1108) Improve DocumentationDsl, bugfix default values and add queryParams ([#378](https://github.com/Suwayomi/scripts/pull/378) by @Syer10) - (r1108) Improve DocumentationDsl, bugfix default values and add queryParams ([#378](https://github.com/Suwayomi/Tachidesk-Server/pull/378) by @Syer10)
- (r1109) Tidy up bundler script ([#380](https://github.com/Suwayomi/scripts/pull/380)) mahor1221@pm.me - (r1109) Tidy up bundler script ([#380](https://github.com/Suwayomi/Tachidesk-Server/pull/380) by @mahor1221)
- (r1110) Replace linux-all with linux-assets ([#381](https://github.com/Suwayomi/scripts/pull/381)) mahor1221@pm.me - (r1110) Replace linux-all with linux-assets ([#381](https://github.com/Suwayomi/Tachidesk-Server/pull/381) by @mahor1221)
- (r1111) Rename every instance of Tachidesk jar to Tachdidesk-Server.jar ([#384](https://github.com/Suwayomi/scripts/pull/384) by @AriaMoradi) - (r1111) Rename every instance of Tachidesk jar to Tachdidesk-Server.jar ([#384](https://github.com/Suwayomi/Tachidesk-Server/pull/384) by @AriaMoradi)
- (r1112) Fix mistakes from #384 ([#385](https://github.com/Suwayomi/scripts/pull/385) by @AriaMoradi) - (r1112) Fix mistakes from #384 ([#385](https://github.com/Suwayomi/Tachidesk-Server/pull/385) by @AriaMoradi)
## Tachidesk-WebUI Changelog ## Tachidesk-WebUI Changelog
- (r943) fix default width ([#171](https://github.com/Suwayomi/Tachidesk-WebUI/pull/171) by @Robonau) - (r943) fix default width ([#171](https://github.com/Suwayomi/Tachidesk-WebUI/pull/171) by @Robonau)
+2 -2
View File
@@ -48,7 +48,7 @@ Here's a list of known clients/user interfaces for Tachidesk-Server:
##### Actively Developed Cients ##### Actively Developed Cients
- [Tachidesk-WebUI](https://github.com/Suwayomi/Tachidesk-WebUI): The web/ElectronJS front-end that Tachidesk-Server is traditionally shipped with. Usually gets new features faster. - [Tachidesk-WebUI](https://github.com/Suwayomi/Tachidesk-WebUI): The web/ElectronJS front-end that Tachidesk-Server is traditionally shipped with. Usually gets new features faster.
- [Tachidesk-JUI](https://github.com/Suwayomi/Tachidesk-JUI): The native desktop front-end for Tachidesk-Server. Currently the most advanced. - [Tachidesk-JUI](https://github.com/Suwayomi/Tachidesk-JUI): The native desktop front-end for Tachidesk-Server. Currently the most advanced.
- [Tachidesk-qtui](https://github.com/Suwayomi/Tachidesk-qtui): A C++/Qt front-end for mobile devices(Android/linux), in super early stage of development. - [Tachidesk-qtui](https://github.com/Suwayomi/Tachidesk-qtui): A C++/Qt front-end for mobile devices(Android/linux), in super early stage of development.
- [Tachidesk-Sorayomi](https://github.com/Suwayomi/Tachidesk-Sorayomi): A Flutter front-end for Desktop(Linux, windows, etc.), Web and Android. UI and UX similar to Tachiyomi. - [Tachidesk-Sorayomi](https://github.com/Suwayomi/Tachidesk-Sorayomi): A Flutter front-end for Desktop(Linux, windows, etc.), Web and Android. UI and UX similar to Tachiyomi.
##### Inctive/Abandoned Cients ##### Inctive/Abandoned Cients
- [Equinox](https://github.com/Suwayomi/Equinox): A web user interface made with Vue.js, in super early stage of development. - [Equinox](https://github.com/Suwayomi/Equinox): A web user interface made with Vue.js, in super early stage of development.
@@ -85,7 +85,7 @@ Download the latest `win32`(Windows 32-bit) or `win64`(Windows 64-bit) release f
Unzip the downloaded file and double click on one of the launcher scripts. Unzip the downloaded file and double click on one of the launcher scripts.
### macOS ### macOS
Download the latest `macOS-x64`(older macOS systems) or `macOS-arm64`(Apple M1) release from [the releases section](https://github.com/Suwayomi/Tachidesk-Server/releases) or a preview one from [the preview repository](https://github.com/Suwayomi/Tachidesk-Server-preview/releases). Download the latest `macOS-x64`(older macOS systems) or `macOS-arm64`(Apple M1 and newer) release from [the releases section](https://github.com/Suwayomi/Tachidesk-Server/releases) or a preview one from [the preview repository](https://github.com/Suwayomi/Tachidesk-Server-preview/releases).
Unzip the downloaded file and double click on one of the launcher scripts. Unzip the downloaded file and double click on one of the launcher scripts.
+15 -18
View File
@@ -5,8 +5,9 @@ import org.jmailen.gradle.kotlinter.tasks.LintTask
plugins { plugins {
kotlin("jvm") version kotlinVersion kotlin("jvm") version kotlinVersion
kotlin("plugin.serialization") version kotlinVersion kotlin("plugin.serialization") version kotlinVersion
id("org.jmailen.kotlinter") version "3.8.0" id("org.jmailen.kotlinter") version "3.12.0"
id("com.github.gmazzo.buildconfig") version "3.0.3" apply false id("com.github.gmazzo.buildconfig") version "3.1.0" apply false
id("de.undercouch.download") version "5.3.0"
} }
allprojects { allprojects {
@@ -43,12 +44,6 @@ configure(projects) {
dependsOn(formatKotlin) dependsOn(formatKotlin)
kotlinOptions { kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8.toString() jvmTarget = JavaVersion.VERSION_1_8.toString()
freeCompilerArgs = listOf(
"-Xopt-in=kotlin.RequiresOptIn",
"-Xopt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
"-Xopt-in=kotlinx.coroutines.InternalCoroutinesApi",
"-Xopt-in=kotlinx.serialization.ExperimentalSerializationApi",
)
} }
} }
@@ -69,40 +64,42 @@ configure(projects) {
testImplementation(kotlin("test-junit5")) testImplementation(kotlin("test-junit5"))
// coroutines // coroutines
val coroutinesVersion = "1.6.0" val coroutinesVersion = "1.6.4"
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-jdk8:$coroutinesVersion") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-jdk8:$coroutinesVersion")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion") testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion")
val kotlinSerializationVersion = "1.3.2" val kotlinSerializationVersion = "1.4.1"
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinSerializationVersion") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinSerializationVersion")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-protobuf:$kotlinSerializationVersion") implementation("org.jetbrains.kotlinx:kotlinx-serialization-protobuf:$kotlinSerializationVersion")
// Dependency Injection // Dependency Injection
implementation("org.kodein.di:kodein-di-conf-jvm:7.10.0") implementation("org.kodein.di:kodein-di-conf-jvm:7.15.0")
// Logging // Logging
// Stuck on old versions since
// 1. Logback 1.3.0+ requires Java 9
// 2. Slf4j 2.0.0+ doesn't register older versions of Logback
// 3. Kotlin-logging 3.0.2+ requires Java 11, but this is probably a bug
implementation("org.slf4j:slf4j-api:1.7.32") implementation("org.slf4j:slf4j-api:1.7.32")
implementation("ch.qos.logback:logback-classic:1.2.6") implementation("ch.qos.logback:logback-classic:1.2.6")
implementation("io.github.microutils:kotlin-logging:2.1.21") implementation("io.github.microutils:kotlin-logging:2.1.21")
// ReactiveX // ReactiveX
implementation("io.reactivex:rxjava:1.3.8") implementation("io.reactivex:rxjava:1.3.8")
implementation("io.reactivex:rxkotlin:1.0.0")
implementation("com.jakewharton.rxrelay:rxrelay:1.2.0")
// dependency both in AndroidCompat and extensions, version locked by Tachiyomi app/extensions // dependency both in AndroidCompat and extensions, version locked by Tachiyomi app/extensions
implementation("org.jsoup:jsoup:1.14.3") implementation("org.jsoup:jsoup:1.15.3")
// dependency of :AndroidCompat:Config // dependency of :AndroidCompat:Config
implementation("com.typesafe:config:1.4.1") implementation("com.typesafe:config:1.4.2")
implementation("io.github.config4k:config4k:0.4.2") implementation("io.github.config4k:config4k:0.5.0")
// to get application content root // to get application content root
implementation("net.harawata:appdirs:1.2.1") implementation("net.harawata:appdirs:1.2.1")
// dex2jar // dex2jar
val dex2jarVersion = "v35" val dex2jarVersion = "v56"
implementation("com.github.ThexXTURBOXx.dex2jar:dex-translator:$dex2jarVersion") implementation("com.github.ThexXTURBOXx.dex2jar:dex-translator:$dex2jarVersion")
implementation("com.github.ThexXTURBOXx.dex2jar:dex-tools:$dex2jarVersion") implementation("com.github.ThexXTURBOXx.dex2jar:dex-tools:$dex2jarVersion")
+3 -4
View File
@@ -7,15 +7,14 @@ import java.io.BufferedReader
* 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/. */
const val kotlinVersion = "1.6.10" const val kotlinVersion = "1.7.20"
const val MainClass = "suwayomi.tachidesk.MainKt" 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.6.4" val tachideskVersion = System.getenv("ProductVersion") ?: "v0.6.6"
val webUIRevisionTag = System.getenv("WebUIRevision") ?: "r946" val webUIRevisionTag = System.getenv("WebUIRevision") ?: "r963"
val sorayomiRevisionTag = System.getenv("SorayomiRevision") ?: "0.1.5"
// counts commits on the master branch // counts commits on the master branch
val tachideskRevision = runCatching { val tachideskRevision = runCatching {
+1 -1
View File
@@ -1,5 +1,5 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-bin.zip
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
+31 -27
View File
@@ -50,7 +50,9 @@ main() {
;; ;;
linux-x64) linux-x64)
JRE="OpenJDK8U-jre_x64_linux_hotspot_8u302b08.tar.gz" JRE="OpenJDK8U-jre_x64_linux_hotspot_8u302b08.tar.gz"
JRE_URL="https://github.com/adoptium/temurin8-binaries/releases/download/jdk8u302-b08/$JRE" JRE_RELEASE="jdk8u302-b08"
JRE_DIR="$JRE_RELEASE-jre"
JRE_URL="https://github.com/adoptium/temurin8-binaries/releases/download/$JRE_RELEASE/$JRE"
ELECTRON="electron-$electron_version-linux-x64.zip" ELECTRON="electron-$electron_version-linux-x64.zip"
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
@@ -61,7 +63,9 @@ main() {
;; ;;
macOS-x64) macOS-x64)
JRE="OpenJDK8U-jre_x64_mac_hotspot_8u302b08.tar.gz" JRE="OpenJDK8U-jre_x64_mac_hotspot_8u302b08.tar.gz"
JRE_URL="https://github.com/adoptium/temurin8-binaries/releases/download/jdk8u302-b08/$JRE" JRE_RELEASE="jdk8u302-b08"
JRE_DIR="$JRE_RELEASE-jre"
JRE_URL="https://github.com/adoptium/temurin8-binaries/releases/download/$JRE_RELEASE/$JRE"
ELECTRON="electron-$electron_version-darwin-x64.zip" ELECTRON="electron-$electron_version-darwin-x64.zip"
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
@@ -72,6 +76,8 @@ main() {
;; ;;
macOS-arm64) macOS-arm64)
JRE="zulu8.56.0.23-ca-jre8.0.302-macosx_aarch64.tar.gz" JRE="zulu8.56.0.23-ca-jre8.0.302-macosx_aarch64.tar.gz"
JRE_RELEASE="zulu8.56.0.23-ca-jre8.0.302-macosx_aarch64"
JRE_DIR="$JRE_RELEASE/zulu-8.jre"
JRE_URL="https://cdn.azul.com/zulu/bin/$JRE" JRE_URL="https://cdn.azul.com/zulu/bin/$JRE"
ELECTRON="electron-$electron_version-darwin-arm64.zip" ELECTRON="electron-$electron_version-darwin-arm64.zip"
ELECTRON_URL="https://github.com/electron/electron/releases/download/$electron_version/$ELECTRON" ELECTRON_URL="https://github.com/electron/electron/releases/download/$electron_version/$ELECTRON"
@@ -83,7 +89,9 @@ main() {
;; ;;
windows-x86) windows-x86)
JRE="OpenJDK8U-jre_x86-32_windows_hotspot_8u292b10.zip" JRE="OpenJDK8U-jre_x86-32_windows_hotspot_8u292b10.zip"
JRE_URL="https://github.com/AdoptOpenJDK/openjdk8-binaries/releases/download/jdk8u292-b10/$JRE" JRE_RELEASE="jdk8u292-b10"
JRE_DIR="$JRE_RELEASE-jre"
JRE_URL="https://github.com/AdoptOpenJDK/openjdk8-binaries/releases/download/$JRE_RELEASE/$JRE"
ELECTRON="electron-$electron_version-win32-ia32.zip" ELECTRON="electron-$electron_version-win32-ia32.zip"
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
@@ -98,7 +106,9 @@ main() {
;; ;;
windows-x64) windows-x64)
JRE="OpenJDK8U-jre_x64_windows_hotspot_8u302b08.zip" JRE="OpenJDK8U-jre_x64_windows_hotspot_8u302b08.zip"
JRE_URL="https://github.com/adoptium/temurin8-binaries/releases/download/jdk8u302-b08/$JRE" JRE_RELEASE="jdk8u302-b08"
JRE_DIR="$JRE_RELEASE-jre"
JRE_URL="https://github.com/adoptium/temurin8-binaries/releases/download/$JRE_RELEASE/$JRE"
ELECTRON="electron-$electron_version-win32-x64.zip" ELECTRON="electron-$electron_version-win32-x64.zip"
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
@@ -133,17 +143,15 @@ download_jre_and_electron() {
curl -L "$ELECTRON_URL" -o "$ELECTRON" curl -L "$ELECTRON_URL" -o "$ELECTRON"
fi fi
mkdir -p "$RELEASE_NAME/jre/"
local ext="${JRE##*.}" local ext="${JRE##*.}"
local jre_dir
if [ "$ext" = "zip" ]; then if [ "$ext" = "zip" ]; then
jre_dir="$(unzip "$JRE" | sed -n '2p' | cut -d: -f2 | xargs basename)" unzip "$JRE"
mv "$jre_dir" "$RELEASE_NAME/jre"
else else
# --strip-components=1: untar an archive without the root folder tar xvf "$JRE"
tar xvf "$JRE" --strip-components=1 -C "$RELEASE_NAME/jre/"
fi fi
mv "$JRE_DIR" "$RELEASE_NAME/jre"
unzip "$ELECTRON" -d "$RELEASE_NAME/electron/" unzip "$ELECTRON" -d "$RELEASE_NAME/electron/"
tree
} }
copy_linux_package_assets_to() { copy_linux_package_assets_to() {
@@ -180,31 +188,27 @@ make_macos_bundle() {
# https://wiki.debian.org/SimplePackagingTutorial # https://wiki.debian.org/SimplePackagingTutorial
# https://www.debian.org/doc/manuals/packaging-tutorial/packaging-tutorial.pdf # https://www.debian.org/doc/manuals/packaging-tutorial/packaging-tutorial.pdf
make_deb_package() { make_deb_package() {
local temp_dir
temp_dir="$(mktemp -d)"
trap "rm -rf $temp_dir" RETURN
cp "$JAR" "$RELEASE_NAME/Tachidesk-Server.jar"
tar -I "gzip" -cvf "$RELEASE_NAME.tar.gz" "$RELEASE_NAME/"
#behind $RELEASE_VERSION is underscore "_"
local upstream_source="tachidesk-server_$RELEASE_VERSION.orig.tar.gz"
mv "$RELEASE_NAME.tar.gz" "$temp_dir/$upstream_source"
cp -r "scripts/resources/deb/" "$RELEASE_NAME/debian/"
copy_linux_package_assets_to "$RELEASE_NAME/debian/"
sed -i "s/\$pkgver/$RELEASE_VERSION/" "$RELEASE_NAME/debian/changelog"
sed -i "s/\$pkgrel/1/" "$RELEASE_NAME/debian/changelog"
#behind $RELEASE_VERSION is hyphen "-" #behind $RELEASE_VERSION is hyphen "-"
local source_dir="tachidesk-server-$RELEASE_VERSION" local source_dir="tachidesk-server-$RELEASE_VERSION"
mv "$RELEASE_NAME/" "$temp_dir/$source_dir/" #behind $RELEASE_VERSION is underscore "_"
local upstream_source="tachidesk-server_$RELEASE_VERSION.orig.tar.gz"
mkdir "$RELEASE_NAME/$source_dir/"
cp "$JAR" "$RELEASE_NAME/$source_dir/Tachidesk-Server.jar"
copy_linux_package_assets_to "$RELEASE_NAME/$source_dir/"
tar -I "gzip" -C "$RELEASE_NAME/" -cvf "$upstream_source" "$source_dir"
cp -r "scripts/resources/deb/" "$RELEASE_NAME/$source_dir/debian/"
sed -i "s/\$pkgver/$RELEASE_VERSION/" "$RELEASE_NAME/$source_dir/debian/changelog"
sed -i "s/\$pkgrel/1/" "$RELEASE_NAME/$source_dir/debian/changelog"
sudo apt install devscripts build-essential dh-exec sudo apt install devscripts build-essential dh-exec
cd "$temp_dir/$source_dir/" cd "$RELEASE_NAME/$source_dir/"
dpkg-buildpackage --no-sign --build=all dpkg-buildpackage --no-sign --build=all
cd - cd -
local deb="tachidesk-server_$RELEASE_VERSION-1_all.deb" local deb="tachidesk-server_$RELEASE_VERSION-1_all.deb"
mv "$temp_dir/$deb" "$RELEASE" mv "$RELEASE_NAME/$deb" "$RELEASE"
} }
make_windows_bundle() { make_windows_bundle() {
+1 -1
View File
@@ -8,7 +8,7 @@ Homepage: https://github.com/Suwayomi/Tachidesk-Server
Package: tachidesk-server Package: tachidesk-server
Architecture: all Architecture: all
Depends: ${misc:Depends}, default-jre-headless (>= 8) Depends: ${misc:Depends}, java8-runtime-headless, libc++-dev
Description: Manga Reader Description: Manga Reader
A free and open source manga reader server that runs extensions built for Tachiyomi. A free and open source manga reader server that runs extensions built for Tachiyomi.
Tachidesk is an independent Tachiyomi compatible software and is not a Fork of Tachiyomi. Tachidesk is an independent Tachiyomi compatible software and is not a Fork of Tachiyomi.
+10 -10
View File
@@ -1,12 +1,12 @@
#!/usr/bin/dh-exec #!/usr/bin/dh-exec
Tachidesk-Server.jar usr/share/java/tachidesk-server/ Tachidesk-Server.jar usr/share/java/tachidesk-server/
debian/tachidesk-server.png usr/share/pixmaps/ tachidesk-server.png usr/share/pixmaps/
debian/tachidesk-server.desktop usr/share/applications/ tachidesk-server.desktop usr/share/applications/
debian/tachidesk-server.service usr/lib/systemd/system/ tachidesk-server.service usr/lib/systemd/system/
debian/tachidesk-server.sysusers => usr/lib/sysusers.d/tachidesk-server.conf tachidesk-server.sysusers => usr/lib/sysusers.d/tachidesk-server.conf
debian/tachidesk-server.tmpfiles => usr/lib/tmpfiles.d/tachidesk-server.conf tachidesk-server.tmpfiles => usr/lib/tmpfiles.d/tachidesk-server.conf
debian/tachidesk-server.conf => etc/tachidesk/server.conf tachidesk-server.conf => etc/tachidesk/server.conf
debian/tachidesk-server-browser-launcher.sh => usr/bin/tachidesk-server-browser tachidesk-server-browser-launcher.sh => usr/bin/tachidesk-server-browser
debian/tachidesk-server-debug-launcher.sh => usr/bin/tachidesk-server-debug tachidesk-server-debug-launcher.sh => usr/bin/tachidesk-server-debug
debian/tachidesk-server-electron-launcher.sh => usr/bin/tachidesk-server-electron tachidesk-server-electron-launcher.sh => usr/bin/tachidesk-server-electron
+16 -19
View File
@@ -9,31 +9,32 @@ plugins {
dependencies { dependencies {
// okhttp // okhttp
val okhttpVersion = "4.9.3" // Major version is locked by Tachiyomi extensions val okhttpVersion = "4.10.0" // Major version is locked by Tachiyomi extensions
implementation("com.squareup.okhttp3:okhttp:$okhttpVersion") implementation("com.squareup.okhttp3:okhttp:$okhttpVersion")
implementation("com.squareup.okhttp3:logging-interceptor:$okhttpVersion") implementation("com.squareup.okhttp3:logging-interceptor:$okhttpVersion")
implementation("com.squareup.okhttp3:okhttp-dnsoverhttps:$okhttpVersion") implementation("com.squareup.okhttp3:okhttp-dnsoverhttps:$okhttpVersion")
implementation("com.squareup.okio:okio:3.0.0") implementation("com.squareup.okio:okio:3.2.0")
// Javalin api // Javalin api
implementation("io.javalin:javalin:4.2.0") // Javalin 5.0.0+ requires Java 11
implementation("io.javalin:javalin-openapi:4.2.0") implementation("io.javalin:javalin:4.6.6")
implementation("io.javalin:javalin-openapi:4.6.6")
// jackson version locked by javalin, ref: `io.javalin.core.util.OptionalDependency` // jackson version locked by javalin, ref: `io.javalin.core.util.OptionalDependency`
val jacksonVersion = "2.12.4" val jacksonVersion = "2.13.3"
implementation("com.fasterxml.jackson.core:jackson-databind:$jacksonVersion") implementation("com.fasterxml.jackson.core:jackson-databind:$jacksonVersion")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin:$jacksonVersion") implementation("com.fasterxml.jackson.module:jackson-module-kotlin:$jacksonVersion")
// Exposed ORM // Exposed ORM
val exposedVersion = "0.34.1" val exposedVersion = "0.40.1"
implementation("org.jetbrains.exposed:exposed-core:$exposedVersion") implementation("org.jetbrains.exposed:exposed-core:$exposedVersion")
implementation("org.jetbrains.exposed:exposed-dao:$exposedVersion") implementation("org.jetbrains.exposed:exposed-dao:$exposedVersion")
implementation("org.jetbrains.exposed:exposed-jdbc:$exposedVersion") implementation("org.jetbrains.exposed:exposed-jdbc:$exposedVersion")
implementation("org.jetbrains.exposed:exposed-java-time:$exposedVersion") implementation("org.jetbrains.exposed:exposed-java-time:$exposedVersion")
// current database driver // current database driver, can't update to h2 v2 without sql migration
implementation("com.h2database:h2:1.4.200") implementation("com.h2database:h2:1.4.200")
// Exposed Migrations // Exposed Migrations
implementation("com.github.Suwayomi:exposed-migrations:3.1.4") implementation("com.github.Suwayomi:exposed-migrations:3.2.0")
// tray icon // tray icon
implementation("com.dorkbox:SystemTray:4.1") implementation("com.dorkbox:SystemTray:4.1")
@@ -43,24 +44,23 @@ dependencies {
implementation("com.github.inorichi.injekt:injekt-core:65b0440") implementation("com.github.inorichi.injekt:injekt-core:65b0440")
implementation("com.squareup.okhttp3:okhttp:$okhttpVersion") implementation("com.squareup.okhttp3:okhttp:$okhttpVersion")
implementation("io.reactivex:rxjava:1.3.8") implementation("io.reactivex:rxjava:1.3.8")
implementation("org.jsoup:jsoup:1.14.3") implementation("org.jsoup:jsoup:1.15.3")
implementation("app.cash.quickjs:quickjs-jvm:0.9.2")
// Sort // Sort
implementation("com.github.gpanther:java-nat-sort:natural-comparator-1.1") implementation("com.github.gpanther:java-nat-sort:natural-comparator-1.1")
// asm for ByteCodeEditor(fixing SimpleDateFormat) (must match Dex2Jar version) // asm for ByteCodeEditor(fixing SimpleDateFormat) (must match Dex2Jar version)
implementation("org.ow2.asm:asm:9.2") implementation("org.ow2.asm:asm:9.4")
// Disk & File // Disk & File
implementation("net.lingala.zip4j:zip4j:2.9.1") implementation("net.lingala.zip4j:zip4j:2.11.2")
implementation("com.github.junrar:junrar:7.5.0") implementation("com.github.junrar:junrar:7.5.3")
// CloudflareInterceptor // CloudflareInterceptor
implementation("net.sourceforge.htmlunit:htmlunit:2.56.0") implementation("net.sourceforge.htmlunit:htmlunit:2.65.1")
// AES/CBC/PKCS7Padding Cypher provider for zh.copymanga // AES/CBC/PKCS7Padding Cypher provider for zh.copymanga
implementation("org.bouncycastle:bcprov-jdk18on:1.71") implementation("org.bouncycastle:bcprov-jdk18on:1.72")
// Source models and interfaces from Tachiyomi 1.x // Source models and interfaces from Tachiyomi 1.x
// using source class from tachiyomi commit 9493577de27c40ce8b2b6122cc447d025e34c477 to not depend on tachiyomi.sourceapi // using source class from tachiyomi commit 9493577de27c40ce8b2b6122cc447d025e34c477 to not depend on tachiyomi.sourceapi
@@ -74,7 +74,7 @@ dependencies {
// implementation(fileTree("lib/")) // implementation(fileTree("lib/"))
implementation(kotlin("script-runtime")) implementation(kotlin("script-runtime"))
testImplementation("io.mockk:mockk:1.12.2") testImplementation("io.mockk:mockk:1.13.2")
} }
application { application {
@@ -110,9 +110,6 @@ buildConfig {
buildConfigField("String", "WEBUI_REPO", quoteWrap("https://github.com/Suwayomi/Tachidesk-WebUI-preview")) buildConfigField("String", "WEBUI_REPO", quoteWrap("https://github.com/Suwayomi/Tachidesk-WebUI-preview"))
buildConfigField("String", "WEBUI_TAG", quoteWrap(webUIRevisionTag)) buildConfigField("String", "WEBUI_TAG", quoteWrap(webUIRevisionTag))
buildConfigField("String", "SORAYOMI_REPO", quoteWrap("https://github.com/Suwayomi/Tachidesk-Sorayomi"))
buildConfigField("String", "SORAYOMI_TAG", quoteWrap(sorayomiRevisionTag))
buildConfigField("String", "GITHUB", quoteWrap("https://github.com/Suwayomi/Tachidesk-Server")) buildConfigField("String", "GITHUB", quoteWrap("https://github.com/Suwayomi/Tachidesk-Server"))
buildConfigField("String", "DISCORD", quoteWrap("https://discord.gg/DDZdqZWaHA")) buildConfigField("String", "DISCORD", quoteWrap("https://discord.gg/DDZdqZWaHA"))
@@ -1,9 +0,0 @@
package eu.kanade.tachiyomi;
public class BuildConfig {
/** should be something like 74 */
public static final int VERSION_CODE = Integer.parseInt(suwayomi.tachidesk.server.BuildConfig.REVISION.substring(1));
/** should be something like "0.13.1" */
public static final String VERSION_NAME = suwayomi.tachidesk.server.BuildConfig.VERSION.substring(1);
}
@@ -6,6 +6,9 @@ package eu.kanade.tachiyomi
* @since extension-lib 1.3 * @since extension-lib 1.3
*/ */
object AppInfo { object AppInfo {
fun getVersionCode() = BuildConfig.VERSION_CODE /** should be something like 74 */
fun getVersionName() = BuildConfig.VERSION_NAME fun getVersionCode() = suwayomi.tachidesk.server.BuildConfig.REVISION.substring(1).toInt()
/** should be something like "0.13.1" */
fun getVersionName() = suwayomi.tachidesk.server.BuildConfig.VERSION.substring(1)
} }
@@ -29,7 +29,6 @@ import uy.kohesive.injekt.api.get
class AppModule(val app: Application) : InjektModule { class AppModule(val app: Application) : InjektModule {
override fun InjektRegistrar.registerInjectables() { override fun InjektRegistrar.registerInjectables() {
addSingleton(app) addSingleton(app)
// addSingletonFactory { PreferencesHelper(app) } // addSingletonFactory { PreferencesHelper(app) }
@@ -116,13 +116,13 @@ fun Call.asObservableSuccess(): Observable<Response> {
@Suppress("UNUSED_PARAMETER") @Suppress("UNUSED_PARAMETER")
fun OkHttpClient.newCallWithProgress(request: Request, listener: ProgressListener): Call { fun OkHttpClient.newCallWithProgress(request: Request, listener: ProgressListener): Call {
val progressClient = newBuilder() val progressClient = newBuilder()
// .cache(null) .cache(null)
// .addNetworkInterceptor { chain -> .addNetworkInterceptor { chain ->
// val originalResponse = chain.proceed(chain.request()) val originalResponse = chain.proceed(chain.request())
// originalResponse.newBuilder() originalResponse.newBuilder()
// .body(ProgressResponseBody(originalResponse.body!!, listener)) .body(ProgressResponseBody(originalResponse.body!!, listener))
// .build() .build()
// } }
.build() .build()
return progressClient.newCall(request) return progressClient.newCall(request)
@@ -0,0 +1,44 @@
package eu.kanade.tachiyomi.network
import okhttp3.MediaType
import okhttp3.ResponseBody
import okio.Buffer
import okio.BufferedSource
import okio.ForwardingSource
import okio.Source
import okio.buffer
import java.io.IOException
class ProgressResponseBody(private val responseBody: ResponseBody, private val progressListener: ProgressListener) : ResponseBody() {
private val bufferedSource: BufferedSource by lazy {
source(responseBody.source()).buffer()
}
override fun contentType(): MediaType? {
return responseBody.contentType()
}
override fun contentLength(): Long {
return responseBody.contentLength()
}
override fun source(): BufferedSource {
return bufferedSource
}
private fun source(source: Source): Source {
return object : ForwardingSource(source) {
var totalBytesRead = 0L
@Throws(IOException::class)
override fun read(sink: Buffer, byteCount: Long): Long {
val bytesRead = super.read(sink, byteCount)
// read() returns the number of bytes read, or -1 if this source is exhausted.
totalBytesRead += if (bytesRead != -1L) bytesRead else 0
progressListener.update(totalBytesRead, responseBody.contentLength(), bytesRead == -1L)
return bytesRead
}
}
}
}
@@ -23,13 +23,13 @@ import java.util.concurrent.TimeUnit
fun OkHttpClient.Builder.rateLimit( fun OkHttpClient.Builder.rateLimit(
permits: Int, permits: Int,
period: Long = 1, period: Long = 1,
unit: TimeUnit = TimeUnit.SECONDS, unit: TimeUnit = TimeUnit.SECONDS
) = addInterceptor(RateLimitInterceptor(permits, period, unit)) ) = addInterceptor(RateLimitInterceptor(permits, period, unit))
private class RateLimitInterceptor( private class RateLimitInterceptor(
private val permits: Int, private val permits: Int,
period: Long, period: Long,
unit: TimeUnit, unit: TimeUnit
) : Interceptor { ) : Interceptor {
private val requestQueue = ArrayList<Long>(permits) private val requestQueue = ArrayList<Long>(permits)
@@ -26,14 +26,14 @@ fun OkHttpClient.Builder.rateLimitHost(
httpUrl: HttpUrl, httpUrl: HttpUrl,
permits: Int, permits: Int,
period: Long = 1, period: Long = 1,
unit: TimeUnit = TimeUnit.SECONDS, unit: TimeUnit = TimeUnit.SECONDS
) = addInterceptor(SpecificHostRateLimitInterceptor(httpUrl, permits, period, unit)) ) = addInterceptor(SpecificHostRateLimitInterceptor(httpUrl, permits, period, unit))
class SpecificHostRateLimitInterceptor( class SpecificHostRateLimitInterceptor(
httpUrl: HttpUrl, httpUrl: HttpUrl,
private val permits: Int, private val permits: Int,
period: Long, period: Long,
unit: TimeUnit, unit: TimeUnit
) : Interceptor { ) : Interceptor {
private val requestQueue = ArrayList<Long>(permits) private val requestQueue = ArrayList<Long>(permits)
@@ -327,8 +327,9 @@ class LocalSource : CatalogueSource {
fun getFormat(chapter: SChapter): Format { fun getFormat(chapter: SChapter): Format {
val chapFile = File(applicationDirs.localMangaRoot, chapter.url) val chapFile = File(applicationDirs.localMangaRoot, chapter.url)
if (chapFile.exists()) if (chapFile.exists()) {
return getFormat(chapFile) return getFormat(chapFile)
}
throw Exception("Chapter not found") throw Exception("Chapter not found")
} }
@@ -1,6 +1,5 @@
package eu.kanade.tachiyomi.source.local.loader package eu.kanade.tachiyomi.source.local.loader
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.util.storage.EpubFile import eu.kanade.tachiyomi.util.storage.EpubFile
import java.io.File import java.io.File
@@ -24,7 +23,6 @@ class EpubPageLoader(file: File) : PageLoader {
val streamFn = { epub.getInputStream(epub.getEntry(path)!!) } val streamFn = { epub.getInputStream(epub.getEntry(path)!!) }
ReaderPage(i).apply { ReaderPage(i).apply {
stream = streamFn stream = streamFn
status = Page.READY
} }
} }
} }
@@ -2,7 +2,6 @@ package eu.kanade.tachiyomi.source.local.loader
import com.github.junrar.Archive import com.github.junrar.Archive
import com.github.junrar.rarfile.FileHeader import com.github.junrar.rarfile.FileHeader
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder
import suwayomi.tachidesk.manga.impl.util.storage.ImageUtil import suwayomi.tachidesk.manga.impl.util.storage.ImageUtil
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
@@ -46,7 +45,6 @@ class RarPageLoader(file: File) : PageLoader {
ReaderPage(i).apply { ReaderPage(i).apply {
stream = streamFn stream = streamFn
status = Page.READY
} }
} }
} }
@@ -58,7 +56,6 @@ class RarPageLoader(file: File) : PageLoader {
ReaderPage(i).apply { ReaderPage(i).apply {
stream = streamFn stream = streamFn
status = Page.READY
} }
} }
} }
@@ -1,6 +1,5 @@
package eu.kanade.tachiyomi.source.local.loader package eu.kanade.tachiyomi.source.local.loader
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder
import suwayomi.tachidesk.manga.impl.util.storage.ImageUtil import suwayomi.tachidesk.manga.impl.util.storage.ImageUtil
import java.io.File import java.io.File
@@ -24,7 +23,6 @@ class ZipPageLoader(file: File) : PageLoader {
val streamFn = { zip.getInputStream(entry) } val streamFn = { zip.getInputStream(entry) }
ReaderPage(i).apply { ReaderPage(i).apply {
stream = streamFn stream = streamFn
status = Page.READY
} }
} }
} }
@@ -2,7 +2,8 @@ package eu.kanade.tachiyomi.source.model
import android.net.Uri import android.net.Uri
import eu.kanade.tachiyomi.network.ProgressListener import eu.kanade.tachiyomi.network.ProgressListener
import rx.subjects.Subject import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
open class Page( open class Page(
val index: Int, val index: Int,
@@ -11,48 +12,17 @@ open class Page(
@Transient var uri: Uri? = null // Deprecated but can't be deleted due to extensions @Transient var uri: Uri? = null // Deprecated but can't be deleted due to extensions
) : ProgressListener { ) : ProgressListener {
val number: Int private val _progress = MutableStateFlow(0)
get() = index + 1 val progress = _progress.asStateFlow()
@Transient
@Volatile
var status: Int = 0
set(value) {
field = value
statusSubject?.onNext(value)
statusCallback?.invoke(this)
}
@Transient
@Volatile
var progress: Int = 0
set(value) {
field = value
statusCallback?.invoke(this)
}
@Transient
private var statusSubject: Subject<Int, Int>? = null
@Transient
private var statusCallback: ((Page) -> Unit)? = null
override fun update(bytesRead: Long, contentLength: Long, done: Boolean) { override fun update(bytesRead: Long, contentLength: Long, done: Boolean) {
progress = if (contentLength > 0) { _progress.value = if (contentLength > 0) {
(100 * bytesRead / contentLength).toInt() (100 * bytesRead / contentLength).toInt()
} else { } else {
-1 -1
} }
} }
fun setStatusSubject(subject: Subject<Int, Int>?) {
this.statusSubject = subject
}
fun setStatusCallback(f: ((Page) -> Unit)?) {
statusCallback = f
}
companion object { companion object {
const val QUEUE = 0 const val QUEUE = 0
const val LOAD_PAGE = 1 const val LOAD_PAGE = 1
@@ -4,9 +4,7 @@ import eu.kanade.tachiyomi.source.model.Page
import rx.Observable import rx.Observable
fun HttpSource.getImageUrl(page: Page): Observable<Page> { fun HttpSource.getImageUrl(page: Page): Observable<Page> {
page.status = Page.LOAD_PAGE
return fetchImageUrl(page) return fetchImageUrl(page)
.doOnError { page.status = Page.ERROR }
.onErrorReturn { null } .onErrorReturn { null }
.doOnNext { page.imageUrl = it } .doOnNext { page.imageUrl = it }
.map { page } .map { page }
@@ -139,7 +139,7 @@ class EpubFile(file: File) : Closeable {
*/ */
private fun getPagesFromDocument(document: Document): List<String> { private fun getPagesFromDocument(document: Document): List<String> {
val pages = document.select("manifest > item") val pages = document.select("manifest > item")
.filter { "application/xhtml+xml" == it.attr("media-type") } .filter { element -> "application/xhtml+xml" == element.attr("media-type") }
.associateBy { it.attr("id") } .associateBy { it.attr("id") }
val spine = document.select("spine > itemref").map { it.attr("idref") } val spine = document.select("spine > itemref").map { it.attr("idref") }
@@ -8,11 +8,17 @@ package suwayomi.tachidesk.global
* 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 io.javalin.apibuilder.ApiBuilder.get import io.javalin.apibuilder.ApiBuilder.get
import io.javalin.apibuilder.ApiBuilder.patch
import io.javalin.apibuilder.ApiBuilder.path import io.javalin.apibuilder.ApiBuilder.path
import suwayomi.tachidesk.global.controller.GlobalMetaController
import suwayomi.tachidesk.global.controller.SettingsController import suwayomi.tachidesk.global.controller.SettingsController
object GlobalAPI { object GlobalAPI {
fun defineEndpoints() { fun defineEndpoints() {
path("meta") {
get("", GlobalMetaController.getMeta)
patch("", GlobalMetaController.modifyMeta)
}
path("settings") { path("settings") {
get("about", SettingsController.about) get("about", SettingsController.about)
get("check-update", SettingsController.checkUpdate) get("check-update", SettingsController.checkUpdate)
@@ -0,0 +1,53 @@
package suwayomi.tachidesk.global.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 io.javalin.http.HttpCode
import suwayomi.tachidesk.global.impl.GlobalMeta
import suwayomi.tachidesk.server.util.formParam
import suwayomi.tachidesk.server.util.handler
import suwayomi.tachidesk.server.util.withOperation
object GlobalMetaController {
/** used to modify a category's meta parameters */
val getMeta = handler(
documentWith = {
withOperation {
summary("Server level meta mapping")
description("Get a list of globally stored key-value mapping, you can set values for whatever you want inside it.")
}
},
behaviorOf = { ctx ->
ctx.json(GlobalMeta.getMetaMap())
ctx.status(200)
},
withResults = {
httpCode(HttpCode.OK)
}
)
/** used to modify global meta parameters */
val modifyMeta = handler(
formParam<String>("key"),
formParam<String>("value"),
documentWith = {
withOperation {
summary("Add meta data to the global meta mapping")
description("A simple Key-Value stored at server global level, you can set values for whatever you want inside it.")
}
},
behaviorOf = { ctx, key, value ->
GlobalMeta.modifyMeta(key, value)
ctx.status(200)
},
withResults = {
httpCode(HttpCode.OK)
httpCode(HttpCode.NOT_FOUND)
}
)
}
@@ -43,12 +43,12 @@ object SettingsController {
} }
}, },
behaviorOf = { ctx -> behaviorOf = { ctx ->
ctx.json( ctx.future(
future { AppUpdate.checkUpdate() } future { AppUpdate.checkUpdate() }
) )
}, },
withResults = { withResults = {
json<UpdateDataClass>(HttpCode.OK) json<Array<UpdateDataClass>>(HttpCode.OK)
} }
) )
} }
@@ -16,7 +16,7 @@ data class AboutDataClass(
val buildType: String, val buildType: String,
val buildTime: Long, val buildTime: Long,
val github: String, val github: String,
val discord: String, val discord: String
) )
object About { object About {
@@ -28,7 +28,7 @@ object About {
BuildConfig.BUILD_TYPE, BuildConfig.BUILD_TYPE,
BuildConfig.BUILD_TIME, BuildConfig.BUILD_TIME,
BuildConfig.GITHUB, BuildConfig.GITHUB,
BuildConfig.DISCORD, BuildConfig.DISCORD
) )
} }
} }
@@ -46,13 +46,13 @@ object AppUpdate {
UpdateDataClass( UpdateDataClass(
"Stable", "Stable",
stableJson["tag_name"]!!.jsonPrimitive.content, stableJson["tag_name"]!!.jsonPrimitive.content,
stableJson["html_url"]!!.jsonPrimitive.content, stableJson["html_url"]!!.jsonPrimitive.content
), ),
UpdateDataClass( UpdateDataClass(
"Preview", "Preview",
previewJson["tag_name"]!!.jsonPrimitive.content, previewJson["tag_name"]!!.jsonPrimitive.content,
previewJson["html_url"]!!.jsonPrimitive.content, previewJson["html_url"]!!.jsonPrimitive.content
), )
) )
} }
} }
@@ -0,0 +1,43 @@
package suwayomi.tachidesk.global.impl
import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.update
import suwayomi.tachidesk.global.model.table.GlobalMetaTable
/*
* 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 GlobalMeta {
fun modifyMeta(key: String, value: String) {
transaction {
val meta = transaction {
GlobalMetaTable.select { GlobalMetaTable.key eq key }
}.firstOrNull()
if (meta == null) {
GlobalMetaTable.insert {
it[GlobalMetaTable.key] = key
it[GlobalMetaTable.value] = value
}
} else {
GlobalMetaTable.update({ GlobalMetaTable.key eq key }) {
it[GlobalMetaTable.value] = value
}
}
}
}
fun getMetaMap(): Map<String, String> {
return transaction {
GlobalMetaTable.selectAll()
.associate { it[GlobalMetaTable.key] to it[GlobalMetaTable.value] }
}
}
}
@@ -1,4 +1,4 @@
package suwayomi.tachidesk.manga.impl.backup package suwayomi.tachidesk.global.model.table
/* /*
* Copyright (C) Contributors to the Suwayomi project * Copyright (C) Contributors to the Suwayomi project
@@ -7,6 +7,12 @@ package suwayomi.tachidesk.manga.impl.backup
* 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/. */
abstract class AbstractBackupValidator { import org.jetbrains.exposed.dao.id.IntIdTable
data class ValidationResult(val missingSources: List<String>, val missingTrackers: List<String>)
/**
* Metadata storage for clients, server/global level.
*/
object GlobalMetaTable : IntIdTable() {
val key = varchar("key", 256)
val value = varchar("value", 4096)
} }
@@ -12,6 +12,7 @@ import io.javalin.apibuilder.ApiBuilder.get
import io.javalin.apibuilder.ApiBuilder.patch import io.javalin.apibuilder.ApiBuilder.patch
import io.javalin.apibuilder.ApiBuilder.path import io.javalin.apibuilder.ApiBuilder.path
import io.javalin.apibuilder.ApiBuilder.post import io.javalin.apibuilder.ApiBuilder.post
import io.javalin.apibuilder.ApiBuilder.put
import io.javalin.apibuilder.ApiBuilder.ws import io.javalin.apibuilder.ApiBuilder.ws
import suwayomi.tachidesk.manga.controller.BackupController import suwayomi.tachidesk.manga.controller.BackupController
import suwayomi.tachidesk.manga.controller.CategoryController import suwayomi.tachidesk.manga.controller.CategoryController
@@ -48,11 +49,13 @@ object MangaAPI {
post("{sourceId}/filters", SourceController.setFilters) post("{sourceId}/filters", SourceController.setFilters)
get("{sourceId}/search", SourceController.searchSingle) get("{sourceId}/search", SourceController.searchSingle)
post("{sourceId}/quick-search", SourceController.quickSearchSingle)
// get("all/search", SourceController.searchGlobal) // TODO // get("all/search", SourceController.searchGlobal) // TODO
} }
path("manga") { path("manga") {
get("{mangaId}", MangaController.retrieve) get("{mangaId}", MangaController.retrieve)
get("{mangaId}/full", MangaController.retrieveFull)
get("{mangaId}/thumbnail", MangaController.thumbnail) get("{mangaId}/thumbnail", MangaController.thumbnail)
get("{mangaId}/category", MangaController.categoryList) get("{mangaId}/category", MangaController.categoryList)
@@ -65,8 +68,10 @@ object MangaAPI {
patch("{mangaId}/meta", MangaController.meta) patch("{mangaId}/meta", MangaController.meta)
get("{mangaId}/chapters", MangaController.chapterList) get("{mangaId}/chapters", MangaController.chapterList)
post("{mangaId}/chapter/batch", MangaController.chapterBatch)
get("{mangaId}/chapter/{chapterIndex}", MangaController.chapterRetrieve) get("{mangaId}/chapter/{chapterIndex}", MangaController.chapterRetrieve)
patch("{mangaId}/chapter/{chapterIndex}", MangaController.chapterModify) patch("{mangaId}/chapter/{chapterIndex}", MangaController.chapterModify)
put("{mangaId}/chapter/{chapterIndex}", MangaController.chapterModify)
delete("{mangaId}/chapter/{chapterIndex}", MangaController.chapterDelete) delete("{mangaId}/chapter/{chapterIndex}", MangaController.chapterDelete)
patch("{mangaId}/chapter/{chapterIndex}/meta", MangaController.chapterMeta) patch("{mangaId}/chapter/{chapterIndex}/meta", MangaController.chapterMeta)
@@ -74,6 +79,10 @@ object MangaAPI {
get("{mangaId}/chapter/{chapterIndex}/page/{index}", MangaController.pageRetrieve) get("{mangaId}/chapter/{chapterIndex}/page/{index}", MangaController.pageRetrieve)
} }
path("chapter") {
post("batch", MangaController.anyChapterBatch)
}
path("category") { path("category") {
get("", CategoryController.categoryList) get("", CategoryController.categoryList)
post("", CategoryController.categoryCreate) post("", CategoryController.categoryCreate)
@@ -85,6 +94,8 @@ object MangaAPI {
get("{categoryId}", CategoryController.categoryMangas) get("{categoryId}", CategoryController.categoryMangas)
patch("{categoryId}", CategoryController.categoryModify) patch("{categoryId}", CategoryController.categoryModify)
delete("{categoryId}", CategoryController.categoryDelete) delete("{categoryId}", CategoryController.categoryDelete)
patch("{categoryId}/meta", CategoryController.meta)
} }
path("backup") { path("backup") {
@@ -103,12 +114,15 @@ object MangaAPI {
get("start", DownloadController.start) get("start", DownloadController.start)
get("stop", DownloadController.stop) get("stop", DownloadController.stop)
get("clear", DownloadController.stop) get("clear", DownloadController.clear)
} }
path("download") { path("download") {
get("{mangaId}/chapter/{chapterIndex}", DownloadController.queueChapter) get("{mangaId}/chapter/{chapterIndex}", DownloadController.queueChapter)
delete("{mangaId}/chapter/{chapterIndex}", DownloadController.unqueueChapter) delete("{mangaId}/chapter/{chapterIndex}", DownloadController.unqueueChapter)
patch("{mangaId}/chapter/{chapterIndex}/reorder/{to}", DownloadController.reorderChapter)
post("batch", DownloadController.queueChapters)
delete("batch", DownloadController.unqueueChapters)
} }
path("update") { path("update") {
@@ -1,7 +1,6 @@
package suwayomi.tachidesk.manga.controller package suwayomi.tachidesk.manga.controller
import io.javalin.http.HttpCode import io.javalin.http.HttpCode
import suwayomi.tachidesk.manga.impl.backup.AbstractBackupValidator
import suwayomi.tachidesk.manga.impl.backup.BackupFlags import suwayomi.tachidesk.manga.impl.backup.BackupFlags
import suwayomi.tachidesk.manga.impl.backup.proto.ProtoBackupExport import suwayomi.tachidesk.manga.impl.backup.proto.ProtoBackupExport
import suwayomi.tachidesk.manga.impl.backup.proto.ProtoBackupImport import suwayomi.tachidesk.manga.impl.backup.proto.ProtoBackupImport
@@ -85,14 +84,14 @@ object BackupController {
includeCategories = true, includeCategories = true,
includeChapters = true, includeChapters = true,
includeTracking = true, includeTracking = true,
includeHistory = true, includeHistory = true
) )
) )
} }
) )
}, },
withResults = { withResults = {
mime(HttpCode.OK, "application/octet-stream") stream(HttpCode.OK)
} }
) )
@@ -117,14 +116,14 @@ object BackupController {
includeCategories = true, includeCategories = true,
includeChapters = true, includeChapters = true,
includeTracking = true, includeTracking = true,
includeHistory = true, includeHistory = true
) )
) )
} }
) )
}, },
withResults = { withResults = {
mime(HttpCode.OK, "application/octet-stream") stream(HttpCode.OK)
} }
) )
@@ -144,7 +143,7 @@ object BackupController {
) )
}, },
withResults = { withResults = {
json<AbstractBackupValidator.ValidationResult>(HttpCode.OK) json<ProtoBackupValidator.ValidationResult>(HttpCode.OK)
} }
) )
@@ -168,7 +167,7 @@ object BackupController {
) )
}, },
withResults = { withResults = {
json<AbstractBackupValidator.ValidationResult>(HttpCode.OK) json<ProtoBackupValidator.ValidationResult>(HttpCode.OK)
} }
) )
} }
@@ -129,4 +129,25 @@ object CategoryController {
httpCode(HttpCode.OK) httpCode(HttpCode.OK)
} }
) )
/** used to modify a category's meta parameters */
val meta = handler(
pathParam<Int>("categoryId"),
formParam<String>("key"),
formParam<String>("value"),
documentWith = {
withOperation {
summary("Add meta data to category")
description("A simple Key-Value storage in the manga object, you can set values for whatever you want inside it.")
}
},
behaviorOf = { ctx, categoryId, key, value ->
Category.modifyMeta(categoryId, key, value)
ctx.status(200)
},
withResults = {
httpCode(HttpCode.OK)
httpCode(HttpCode.NOT_FOUND)
}
)
} }
@@ -9,13 +9,21 @@ package suwayomi.tachidesk.manga.controller
import io.javalin.http.HttpCode import io.javalin.http.HttpCode
import io.javalin.websocket.WsConfig import io.javalin.websocket.WsConfig
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import org.kodein.di.DI
import org.kodein.di.conf.global
import org.kodein.di.instance
import suwayomi.tachidesk.manga.impl.download.DownloadManager import suwayomi.tachidesk.manga.impl.download.DownloadManager
import suwayomi.tachidesk.manga.impl.download.DownloadManager.EnqueueInput
import suwayomi.tachidesk.server.JavalinSetup.future import suwayomi.tachidesk.server.JavalinSetup.future
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
object DownloadController { object DownloadController {
private val json by DI.global.instance<Json>()
/** Download queue stats */ /** Download queue stats */
fun downloadsWS(ws: WsConfig) { fun downloadsWS(ws: WsConfig) {
ws.onConnect { ctx -> ws.onConnect { ctx ->
@@ -38,10 +46,8 @@ object DownloadController {
description("Start the downloader") description("Start the downloader")
} }
}, },
behaviorOf = { ctx -> behaviorOf = {
DownloadManager.start() DownloadManager.start()
ctx.status(200)
}, },
withResults = { withResults = {
httpCode(HttpCode.OK) httpCode(HttpCode.OK)
@@ -57,9 +63,9 @@ object DownloadController {
} }
}, },
behaviorOf = { ctx -> behaviorOf = { ctx ->
DownloadManager.stop() ctx.future(
future { DownloadManager.stop() }
ctx.status(200) )
}, },
withResults = { withResults = {
httpCode(HttpCode.OK) httpCode(HttpCode.OK)
@@ -75,29 +81,29 @@ object DownloadController {
} }
}, },
behaviorOf = { ctx -> behaviorOf = { ctx ->
DownloadManager.clear() ctx.future(
future { DownloadManager.clear() }
ctx.status(200) )
}, },
withResults = { withResults = {
httpCode(HttpCode.OK) httpCode(HttpCode.OK)
} }
) )
/** Queue chapter for download */ /** Queue single chapter for download */
val queueChapter = handler( val queueChapter = handler(
pathParam<Int>("chapterIndex"), pathParam<Int>("chapterIndex"),
pathParam<Int>("mangaId"), pathParam<Int>("mangaId"),
documentWith = { documentWith = {
withOperation { withOperation {
summary("Downloader add chapter") summary("Downloader add single chapter")
description("Queue chapter for download") description("Queue single chapter for download")
} }
}, },
behaviorOf = { ctx, chapterIndex, mangaId -> behaviorOf = { ctx, chapterIndex, mangaId ->
ctx.future( ctx.future(
future { future {
DownloadManager.enqueue(chapterIndex, mangaId) DownloadManager.enqueueWithChapterIndex(mangaId, chapterIndex)
} }
) )
}, },
@@ -107,6 +113,49 @@ object DownloadController {
} }
) )
val queueChapters = handler(
documentWith = {
withOperation {
summary("Downloader add multiple chapters")
description("Queue multiple chapters for download")
}
body<EnqueueInput>()
},
behaviorOf = { ctx ->
val inputs = json.decodeFromString<EnqueueInput>(ctx.body())
ctx.future(
future {
DownloadManager.enqueue(inputs)
}
)
},
withResults = {
httpCode(HttpCode.OK)
}
)
/** delete multiple chapters from download queue */
val unqueueChapters = handler(
documentWith = {
withOperation {
summary("Downloader remove multiple downloads")
description("Remove multiple chapters downloads from queue")
}
body<EnqueueInput>()
},
behaviorOf = { ctx ->
val input = json.decodeFromString<EnqueueInput>(ctx.body())
ctx.future(
future {
DownloadManager.unqueue(input)
}
)
},
withResults = {
httpCode(HttpCode.OK)
}
)
/** delete chapter from download queue */ /** delete chapter from download queue */
val unqueueChapter = handler( val unqueueChapter = handler(
pathParam<Int>("chapterIndex"), pathParam<Int>("chapterIndex"),
@@ -126,4 +175,23 @@ object DownloadController {
httpCode(HttpCode.OK) httpCode(HttpCode.OK)
} }
) )
/** clear download queue */
val reorderChapter = handler(
pathParam<Int>("chapterIndex"),
pathParam<Int>("mangaId"),
pathParam<Int>("to"),
documentWith = {
withOperation {
summary("Downloader reorder chapter")
description("Reorder chapter in download queue")
}
},
behaviorOf = { _, chapterIndex, mangaId, to ->
DownloadManager.reorder(chapterIndex, mangaId, to)
},
withResults = {
httpCode(HttpCode.OK)
}
)
} }
@@ -158,7 +158,7 @@ object ExtensionController {
) )
}, },
withResults = { withResults = {
httpCode(HttpCode.OK) image(HttpCode.OK)
httpCode(HttpCode.NOT_FOUND) httpCode(HttpCode.NOT_FOUND)
} }
) )
@@ -8,6 +8,11 @@ package suwayomi.tachidesk.manga.controller
* 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 io.javalin.http.HttpCode import io.javalin.http.HttpCode
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import org.kodein.di.DI
import org.kodein.di.conf.global
import org.kodein.di.instance
import suwayomi.tachidesk.manga.impl.CategoryManga import suwayomi.tachidesk.manga.impl.CategoryManga
import suwayomi.tachidesk.manga.impl.Chapter import suwayomi.tachidesk.manga.impl.Chapter
import suwayomi.tachidesk.manga.impl.Library import suwayomi.tachidesk.manga.impl.Library
@@ -23,15 +28,17 @@ import suwayomi.tachidesk.server.util.handler
import suwayomi.tachidesk.server.util.pathParam import suwayomi.tachidesk.server.util.pathParam
import suwayomi.tachidesk.server.util.queryParam import suwayomi.tachidesk.server.util.queryParam
import suwayomi.tachidesk.server.util.withOperation import suwayomi.tachidesk.server.util.withOperation
import kotlin.time.Duration.Companion.days
object MangaController { object MangaController {
/** get manga info */ private val json by DI.global.instance<Json>()
val retrieve = handler( val retrieve = handler(
pathParam<Int>("mangaId"), pathParam<Int>("mangaId"),
queryParam("onlineFetch", false), queryParam("onlineFetch", false),
documentWith = { documentWith = {
withOperation { withOperation {
summary("Get a manga") summary("Get manga info")
description("Get a manga from the database using a specific id.") description("Get a manga from the database using a specific id.")
} }
}, },
@@ -48,6 +55,29 @@ object MangaController {
} }
) )
/** get manga info with all data filled in */
val retrieveFull = handler(
pathParam<Int>("mangaId"),
queryParam("onlineFetch", false),
documentWith = {
withOperation {
summary("Get manga info with all data filled in")
description("Get a manga from the database using a specific id.")
}
},
behaviorOf = { ctx, mangaId, onlineFetch ->
ctx.future(
future {
Manga.getMangaFull(mangaId, onlineFetch)
}
)
},
withResults = {
json<MangaDataClass>(HttpCode.OK)
httpCode(HttpCode.NOT_FOUND)
}
)
/** manga thumbnail */ /** manga thumbnail */
val thumbnail = handler( val thumbnail = handler(
pathParam<Int>("mangaId"), pathParam<Int>("mangaId"),
@@ -63,14 +93,14 @@ object MangaController {
future { Manga.getMangaThumbnail(mangaId, useCache) } future { Manga.getMangaThumbnail(mangaId, useCache) }
.thenApply { .thenApply {
ctx.header("content-type", it.second) ctx.header("content-type", it.second)
val httpCacheSeconds = 60 * 60 * 24 val httpCacheSeconds = 1.days.inWholeSeconds
ctx.header("cache-control", "max-age=$httpCacheSeconds") ctx.header("cache-control", "max-age=$httpCacheSeconds")
it.first it.first
} }
) )
}, },
withResults = { withResults = {
mime(HttpCode.OK, "image/*") image(HttpCode.OK)
httpCode(HttpCode.NOT_FOUND) httpCode(HttpCode.NOT_FOUND)
} }
) )
@@ -210,6 +240,49 @@ object MangaController {
} }
) )
/** batch edit chapters of single manga */
val chapterBatch = handler(
pathParam<Int>("mangaId"),
documentWith = {
withOperation {
summary("Chapters update multiple")
description("Update multiple chapters of single manga. For batch marking as read, or bookmarking")
}
body<Chapter.MangaChapterBatchEditInput>()
},
behaviorOf = { ctx, mangaId ->
val input = json.decodeFromString<Chapter.MangaChapterBatchEditInput>(ctx.body())
Chapter.modifyChapters(input, mangaId)
},
withResults = {
httpCode(HttpCode.OK)
}
)
/** batch edit chapters from multiple manga */
val anyChapterBatch = handler(
documentWith = {
withOperation {
summary("Chapters update multiple")
description("Update multiple chapters on any manga. For batch marking as read, or bookmarking")
}
body<Chapter.ChapterBatchEditInput>()
},
behaviorOf = { ctx ->
val input = json.decodeFromString<Chapter.ChapterBatchEditInput>(ctx.body())
Chapter.modifyChapters(
Chapter.MangaChapterBatchEditInput(
input.chapterIds,
null,
input.change
)
)
},
withResults = {
httpCode(HttpCode.OK)
}
)
/** used to display a chapter, get a chapter in order to show its pages */ /** used to display a chapter, get a chapter in order to show its pages */
val chapterRetrieve = handler( val chapterRetrieve = handler(
pathParam<Int>("mangaId"), pathParam<Int>("mangaId"),
@@ -314,12 +387,14 @@ object MangaController {
future { Page.getPageImage(mangaId, chapterIndex, index, useCache) } future { Page.getPageImage(mangaId, chapterIndex, index, useCache) }
.thenApply { .thenApply {
ctx.header("content-type", it.second) ctx.header("content-type", it.second)
val httpCacheSeconds = 1.days.inWholeSeconds
ctx.header("cache-control", "max-age=$httpCacheSeconds")
it.first it.first
} }
) )
}, },
withResults = { withResults = {
mime(HttpCode.OK, "image/*") image(HttpCode.OK)
httpCode(HttpCode.NOT_FOUND) httpCode(HttpCode.NOT_FOUND)
} }
) )
@@ -16,6 +16,7 @@ import org.kodein.di.instance
import suwayomi.tachidesk.manga.impl.MangaList import suwayomi.tachidesk.manga.impl.MangaList
import suwayomi.tachidesk.manga.impl.Search import suwayomi.tachidesk.manga.impl.Search
import suwayomi.tachidesk.manga.impl.Search.FilterChange import suwayomi.tachidesk.manga.impl.Search.FilterChange
import suwayomi.tachidesk.manga.impl.Search.FilterData
import suwayomi.tachidesk.manga.impl.Source import suwayomi.tachidesk.manga.impl.Source
import suwayomi.tachidesk.manga.impl.Source.SourcePreferenceChange import suwayomi.tachidesk.manga.impl.Source.SourcePreferenceChange
import suwayomi.tachidesk.manga.model.dataclass.PagedMangaListDataClass import suwayomi.tachidesk.manga.model.dataclass.PagedMangaListDataClass
@@ -130,6 +131,7 @@ object SourceController {
summary("Source preference set") summary("Source preference set")
description("Set one preference of source with id `sourceId`") description("Set one preference of source with id `sourceId`")
} }
body<SourcePreferenceChange>()
}, },
behaviorOf = { ctx, sourceId -> behaviorOf = { ctx, sourceId ->
val preferenceChange = ctx.bodyAsClass(SourcePreferenceChange::class.java) val preferenceChange = ctx.bodyAsClass(SourcePreferenceChange::class.java)
@@ -168,6 +170,8 @@ object SourceController {
summary("Source filters set") summary("Source filters set")
description("Change filters of source with id `sourceId`") description("Change filters of source with id `sourceId`")
} }
body<FilterChange>()
body<Array<FilterChange>>()
}, },
behaviorOf = { ctx, sourceId -> behaviorOf = { ctx, sourceId ->
val filterChange = try { val filterChange = try {
@@ -202,6 +206,26 @@ object SourceController {
} }
) )
/** quick search single source filter */
val quickSearchSingle = handler(
pathParam<Long>("sourceId"),
queryParam("pageNum", 1),
documentWith = {
withOperation {
summary("Source manga quick search")
description("Returns list of manga from source matching posted searchTerm and filter")
}
body<FilterData>()
},
behaviorOf = { ctx, sourceId, pageNum ->
val filter = json.decodeFromString<FilterData>(ctx.body())
ctx.future(future { Search.sourceFilter(sourceId, pageNum, filter) })
},
withResults = {
json<PagedMangaListDataClass>(HttpCode.OK)
}
)
/** all source search */ /** all source search */
val searchAll = handler( val searchAll = handler(
pathParam<String>("searchTerm"), pathParam<String>("searchTerm"),
@@ -2,7 +2,6 @@ package suwayomi.tachidesk.manga.controller
import io.javalin.http.HttpCode import io.javalin.http.HttpCode
import io.javalin.websocket.WsConfig import io.javalin.websocket.WsConfig
import kotlinx.coroutines.runBlocking
import mu.KotlinLogging import mu.KotlinLogging
import org.kodein.di.DI import org.kodein.di.DI
import org.kodein.di.conf.global import org.kodein.di.conf.global
@@ -15,6 +14,7 @@ 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.CategoryDataClass
import suwayomi.tachidesk.manga.model.dataclass.MangaChapterDataClass import suwayomi.tachidesk.manga.model.dataclass.MangaChapterDataClass
import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass
import suwayomi.tachidesk.manga.model.dataclass.PaginatedList 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
@@ -68,22 +68,18 @@ object UpdateController {
} }
}, },
behaviorOf = { ctx, categoryId -> behaviorOf = { ctx, categoryId ->
val categoriesForUpdate = ArrayList<CategoryDataClass>()
if (categoryId == null) { if (categoryId == null) {
logger.info { "Adding Library to Update Queue" } logger.info { "Adding Library to Update Queue" }
categoriesForUpdate.addAll(Category.getCategoryList()) addCategoriesToUpdateQueue(Category.getCategoryList(), true)
} else { } else {
val category = Category.getCategoryById(categoryId) val category = Category.getCategoryById(categoryId)
if (category != null) { if (category != null) {
categoriesForUpdate.add(category) addCategoriesToUpdateQueue(listOf(category), true)
} else { } else {
logger.info { "No Category found" } logger.info { "No Category found" }
ctx.status(HttpCode.BAD_REQUEST) ctx.status(HttpCode.BAD_REQUEST)
return@handler
} }
} }
addCategoriesToUpdateQueue(categoriesForUpdate, true)
ctx.status(HttpCode.OK)
}, },
withResults = { withResults = {
httpCode(HttpCode.OK) httpCode(HttpCode.OK)
@@ -94,14 +90,15 @@ object UpdateController {
private fun addCategoriesToUpdateQueue(categories: List<CategoryDataClass>, clear: Boolean = false) { private fun addCategoriesToUpdateQueue(categories: List<CategoryDataClass>, clear: Boolean = false) {
val updater by DI.global.instance<IUpdater>() val updater by DI.global.instance<IUpdater>()
if (clear) { if (clear) {
runBlocking { updater.reset() } updater.reset()
} }
categories.forEach { category -> categories
val mangas = CategoryManga.getCategoryMangaList(category.id) .flatMap { CategoryManga.getCategoryMangaList(it.id) }
mangas.forEach { manga -> .distinctBy { it.id }
.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER, MangaDataClass::title))
.forEach { manga ->
updater.addMangaToQueue(manga) updater.addMangaToQueue(manga)
} }
}
} }
fun categoryUpdateWS(ws: WsConfig) { fun categoryUpdateWS(ws: WsConfig) {
@@ -125,7 +122,7 @@ object UpdateController {
}, },
behaviorOf = { ctx -> behaviorOf = { ctx ->
val updater by DI.global.instance<IUpdater>() val updater by DI.global.instance<IUpdater>()
ctx.json(updater.getStatus().value.getJsonSummary()) ctx.json(updater.status.value)
}, },
withResults = { withResults = {
json<UpdateStatus>(HttpCode.OK) json<UpdateStatus>(HttpCode.OK)
@@ -8,8 +8,10 @@ package suwayomi.tachidesk.manga.impl
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import org.jetbrains.exposed.sql.SortOrder import org.jetbrains.exposed.sql.SortOrder
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.and import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.deleteWhere import org.jetbrains.exposed.sql.deleteWhere
import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.insertAndGetId import org.jetbrains.exposed.sql.insertAndGetId
import org.jetbrains.exposed.sql.select import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.selectAll import org.jetbrains.exposed.sql.selectAll
@@ -19,6 +21,7 @@ import suwayomi.tachidesk.manga.impl.CategoryManga.removeMangaFromCategory
import suwayomi.tachidesk.manga.impl.util.lang.isNotEmpty 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.table.CategoryMangaTable import suwayomi.tachidesk.manga.model.table.CategoryMangaTable
import suwayomi.tachidesk.manga.model.table.CategoryMetaTable
import suwayomi.tachidesk.manga.model.table.CategoryTable import suwayomi.tachidesk.manga.model.table.CategoryTable
import suwayomi.tachidesk.manga.model.table.MangaTable import suwayomi.tachidesk.manga.model.table.MangaTable
import suwayomi.tachidesk.manga.model.table.toDataClass import suwayomi.tachidesk.manga.model.table.toDataClass
@@ -41,7 +44,9 @@ object Category {
normalizeCategories() normalizeCategories()
newCategoryId newCategoryId
} else -1 } else {
-1
}
} }
} }
@@ -117,4 +122,31 @@ object Category {
} }
} }
} }
fun getCategoryMetaMap(categoryId: Int): Map<String, String> {
return transaction {
CategoryMetaTable.select { CategoryMetaTable.ref eq categoryId }
.associate { it[CategoryMetaTable.key] to it[CategoryMetaTable.value] }
}
}
fun modifyMeta(categoryId: Int, key: String, value: String) {
transaction {
val meta = transaction {
CategoryMetaTable.select { (CategoryMetaTable.ref eq categoryId) and (CategoryMetaTable.key eq key) }
}.firstOrNull()
if (meta == null) {
CategoryMetaTable.insert {
it[CategoryMetaTable.key] = key
it[CategoryMetaTable.value] = value
it[CategoryMetaTable.ref] = categoryId
}
} else {
CategoryMetaTable.update({ (CategoryMetaTable.ref eq categoryId) and (CategoryMetaTable.key eq key) }) {
it[CategoryMetaTable.value] = value
}
}
}
}
} }
@@ -9,6 +9,7 @@ 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.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
@@ -80,19 +81,20 @@ object CategoryManga {
val transform: (ResultRow) -> MangaDataClass = { val transform: (ResultRow) -> MangaDataClass = {
val dataClass = MangaTable.toDataClass(it) val dataClass = MangaTable.toDataClass(it)
dataClass.unreadCount = it[unreadExpression]?.toInt() dataClass.unreadCount = it[unreadExpression]
dataClass.downloadCount = it[downloadExpression]?.toInt() dataClass.downloadCount = it[downloadExpression]
dataClass.chapterCount = it[chapterCountExpression]?.toInt() dataClass.chapterCount = it[chapterCountExpression]
dataClass dataClass
} }
if (categoryId == DEFAULT_CATEGORY_ID) if (categoryId == DEFAULT_CATEGORY_ID) {
return transaction { return transaction {
MangaTable MangaTable
.slice(selectedColumns) .slice(selectedColumns)
.select { (MangaTable.inLibrary eq true) and (MangaTable.defaultCategory eq true) } .select { (MangaTable.inLibrary eq true) and (MangaTable.defaultCategory eq true) }
.map(transform) .map(transform)
} }
}
return transaction { return transaction {
CategoryMangaTable.innerJoin(MangaTable) CategoryMangaTable.innerJoin(MangaTable)
@@ -10,15 +10,13 @@ package suwayomi.tachidesk.manga.impl
import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.util.chapter.ChapterRecognition import eu.kanade.tachiyomi.util.chapter.ChapterRecognition
import kotlinx.serialization.Serializable
import org.jetbrains.exposed.dao.id.EntityID import org.jetbrains.exposed.dao.id.EntityID
import org.jetbrains.exposed.sql.SortOrder import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.SortOrder.ASC import org.jetbrains.exposed.sql.SortOrder.ASC
import org.jetbrains.exposed.sql.and import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.deleteWhere import org.jetbrains.exposed.sql.SqlExpressionBuilder.inList
import org.jetbrains.exposed.sql.insert
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 suwayomi.tachidesk.manga.impl.Manga.getManga import suwayomi.tachidesk.manga.impl.Manga.getManga
import suwayomi.tachidesk.manga.impl.util.getChapterDir import suwayomi.tachidesk.manga.impl.util.getChapterDir
import suwayomi.tachidesk.manga.impl.util.lang.awaitSingle import suwayomi.tachidesk.manga.impl.util.lang.awaitSingle
@@ -100,6 +98,10 @@ object Chapter {
} }
} }
} }
MangaTable.update({ MangaTable.id eq mangaId }) {
it[MangaTable.chaptersLastFetchedAt] = Instant.now().epochSecond
}
} }
// clear any orphaned/duplicate chapters that are in the db but not in `chapterList` // clear any orphaned/duplicate chapters that are in the db but not in `chapterList`
@@ -128,11 +130,15 @@ object Chapter {
.associateBy({ it[ChapterTable.url] }, { it }) .associateBy({ it[ChapterTable.url] }, { it })
} }
val chapterIds = chapterList.map { dbChapterMap.getValue(it.url)[ChapterTable.id] }
val chapterMetas = getChaptersMetaMaps(chapterIds)
return chapterList.mapIndexed { index, it -> return chapterList.mapIndexed { index, it ->
val dbChapter = dbChapterMap.getValue(it.url) val dbChapter = dbChapterMap.getValue(it.url)
ChapterDataClass( ChapterDataClass(
dbChapter[ChapterTable.id].value,
it.url, it.url,
it.name, it.name,
it.date_upload, it.date_upload,
@@ -152,7 +158,7 @@ object Chapter {
dbChapter[ChapterTable.pageCount], dbChapter[ChapterTable.pageCount],
chapterList.size, chapterList.size,
meta = getChapterMetaMap(dbChapter[ChapterTable.id]) meta = chapterMetas.getValue(dbChapter[ChapterTable.id])
) )
} }
} }
@@ -189,6 +195,87 @@ object Chapter {
} }
} }
@Serializable
data class ChapterChange(
val isRead: Boolean? = null,
val isBookmarked: Boolean? = null,
val lastPageRead: Int? = null,
val delete: Boolean? = null
)
@Serializable
data class MangaChapterBatchEditInput(
val chapterIds: List<Int>? = null,
val chapterIndexes: List<Int>? = null,
val change: ChapterChange?
)
@Serializable
data class ChapterBatchEditInput(
val chapterIds: List<Int>? = null,
val change: ChapterChange?
)
fun modifyChapters(input: MangaChapterBatchEditInput, mangaId: Int? = null) {
// Make sure change is defined
if (input.change == null) return
val (isRead, isBookmarked, lastPageRead, delete) = input.change
// Handle deleting separately
if (delete == true) {
deleteChapters(input, mangaId)
}
// return early if there are no other changes
if (listOfNotNull(isRead, isBookmarked, lastPageRead).isEmpty()) return
// Make sure some filter is defined
val condition = when {
mangaId != null ->
// mangaId is not null, scope query under manga
when {
input.chapterIds != null ->
Op.build { (ChapterTable.manga eq mangaId) and (ChapterTable.id inList input.chapterIds) }
input.chapterIndexes != null ->
Op.build { (ChapterTable.manga eq mangaId) and (ChapterTable.sourceOrder inList input.chapterIndexes) }
else -> null
}
else -> {
// mangaId is null, only chapterIndexes is valid for this case
when {
input.chapterIds != null ->
Op.build { (ChapterTable.id inList input.chapterIds) }
else -> null
}
}
} ?: return
transaction {
val now = Instant.now().epochSecond
ChapterTable.update({ condition }) { update ->
isRead?.also {
update[ChapterTable.isRead] = it
}
isBookmarked?.also {
update[ChapterTable.isBookmarked] = it
}
lastPageRead?.also {
update[ChapterTable.lastPageRead] = it
update[ChapterTable.lastReadAt] = now
}
}
}
}
fun getChaptersMetaMaps(chapterIds: List<EntityID<Int>>): Map<EntityID<Int>, Map<String, String>> {
return transaction {
ChapterMetaTable.select { ChapterMetaTable.ref inList chapterIds }
.groupBy { it[ChapterMetaTable.ref] }
.mapValues { it.value.associate { it[ChapterMetaTable.key] to it[ChapterMetaTable.value] } }
.withDefault { emptyMap<String, String>() }
}
}
fun getChapterMetaMap(chapter: EntityID<Int>): Map<String, String> { fun getChapterMetaMap(chapter: EntityID<Int>): Map<String, String> {
return transaction { return transaction {
ChapterMetaTable.select { ChapterMetaTable.ref eq chapter } ChapterMetaTable.select { ChapterMetaTable.ref eq chapter }
@@ -201,9 +288,9 @@ object Chapter {
val chapterId = val chapterId =
ChapterTable.select { (ChapterTable.manga eq mangaId) and (ChapterTable.sourceOrder eq chapterIndex) } ChapterTable.select { (ChapterTable.manga eq mangaId) and (ChapterTable.sourceOrder eq chapterIndex) }
.first()[ChapterTable.id].value .first()[ChapterTable.id].value
val meta = transaction { val meta =
ChapterMetaTable.select { (ChapterMetaTable.ref eq chapterId) and (ChapterMetaTable.key eq key) } ChapterMetaTable.select { (ChapterMetaTable.ref eq chapterId) and (ChapterMetaTable.key eq key) }
}.firstOrNull() .firstOrNull()
if (meta == null) { if (meta == null) {
ChapterMetaTable.insert { ChapterMetaTable.insert {
@@ -235,6 +322,42 @@ object Chapter {
} }
} }
private fun deleteChapters(input: MangaChapterBatchEditInput, mangaId: Int? = null) {
if (input.chapterIds != null) {
val chapterIds = input.chapterIds
transaction {
ChapterTable.slice(ChapterTable.manga, ChapterTable.id)
.select { ChapterTable.id inList chapterIds }
.forEach { row ->
val chapterMangaId = row[ChapterTable.manga].value
val chapterId = row[ChapterTable.id].value
val chapterDir = getChapterDir(chapterMangaId, chapterId)
File(chapterDir).deleteRecursively()
}
ChapterTable.update({ ChapterTable.id inList chapterIds }) {
it[isDownloaded] = false
}
}
} else if (input.chapterIndexes != null && mangaId != null) {
transaction {
val chapterIds = ChapterTable.slice(ChapterTable.manga, ChapterTable.id)
.select { (ChapterTable.sourceOrder inList input.chapterIndexes) and (ChapterTable.manga eq mangaId) }
.map { row ->
val chapterId = row[ChapterTable.id].value
val chapterDir = getChapterDir(mangaId, chapterId)
File(chapterDir).deleteRecursively()
chapterId
}
ChapterTable.update({ ChapterTable.id inList chapterIds }) {
it[isDownloaded] = false
}
}
}
}
fun getRecentChapters(pageNum: Int): PaginatedList<MangaChapterDataClass> { fun getRecentChapters(pageNum: Int): PaginatedList<MangaChapterDataClass> {
return paginatedFrom(pageNum) { return paginatedFrom(pageNum) {
transaction { transaction {
@@ -13,6 +13,7 @@ import eu.kanade.tachiyomi.source.local.LocalSource
import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.HttpSource
import org.jetbrains.exposed.sql.ResultRow import org.jetbrains.exposed.sql.ResultRow
import org.jetbrains.exposed.sql.SortOrder
import org.jetbrains.exposed.sql.and import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.insert import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.select import org.jetbrains.exposed.sql.select
@@ -34,21 +35,25 @@ import suwayomi.tachidesk.manga.impl.util.storage.ImageUtil
import suwayomi.tachidesk.manga.impl.util.updateMangaDownloadDir import suwayomi.tachidesk.manga.impl.util.updateMangaDownloadDir
import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass
import suwayomi.tachidesk.manga.model.dataclass.toGenreList import suwayomi.tachidesk.manga.model.dataclass.toGenreList
import suwayomi.tachidesk.manga.model.table.ChapterTable
import suwayomi.tachidesk.manga.model.table.MangaMetaTable import suwayomi.tachidesk.manga.model.table.MangaMetaTable
import suwayomi.tachidesk.manga.model.table.MangaStatus import suwayomi.tachidesk.manga.model.table.MangaStatus
import suwayomi.tachidesk.manga.model.table.MangaTable import suwayomi.tachidesk.manga.model.table.MangaTable
import suwayomi.tachidesk.manga.model.table.toDataClass
import suwayomi.tachidesk.server.ApplicationDirs import suwayomi.tachidesk.server.ApplicationDirs
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
import java.io.InputStream import java.io.InputStream
import java.time.Instant
object Manga { object Manga {
private fun truncate(text: String?, maxLength: Int): String? { private fun truncate(text: String?, maxLength: Int): String? {
return if (text?.length ?: 0 > maxLength) return if (text?.length ?: 0 > maxLength) {
text?.take(maxLength - 3) + "..." text?.take(maxLength - 3) + "..."
else } else {
text text
}
} }
suspend fun getManga(mangaId: Int, onlineFetch: Boolean = false): MangaDataClass { suspend fun getManga(mangaId: Int, onlineFetch: Boolean = false): MangaDataClass {
@@ -68,12 +73,12 @@ object Manga {
transaction { transaction {
MangaTable.update({ MangaTable.id eq mangaId }) { MangaTable.update({ MangaTable.id eq mangaId }) {
if (sManga.title != mangaEntry[MangaTable.title]) { if (sManga.title != mangaEntry[MangaTable.title]) {
val canUpdateTitle = updateMangaDownloadDir(mangaId, sManga.title) val canUpdateTitle = updateMangaDownloadDir(mangaId, sManga.title)
if (canUpdateTitle) if (canUpdateTitle) {
it[MangaTable.title] = sManga.title it[MangaTable.title] = sManga.title
}
} }
it[MangaTable.initialized] = true it[MangaTable.initialized] = true
@@ -82,12 +87,15 @@ object Manga {
it[MangaTable.description] = truncate(sManga.description, 4096) it[MangaTable.description] = truncate(sManga.description, 4096)
it[MangaTable.genre] = sManga.genre it[MangaTable.genre] = sManga.genre
it[MangaTable.status] = sManga.status it[MangaTable.status] = sManga.status
if (sManga.thumbnail_url != null && sManga.thumbnail_url.orEmpty().isNotEmpty()) if (sManga.thumbnail_url != null && sManga.thumbnail_url.orEmpty().isNotEmpty()) {
it[MangaTable.thumbnail_url] = sManga.thumbnail_url it[MangaTable.thumbnail_url] = sManga.thumbnail_url
}
it[MangaTable.realUrl] = runCatching { it[MangaTable.realUrl] = runCatching {
(source as? HttpSource)?.mangaDetailsRequest(sManga)?.url?.toString() (source as? HttpSource)?.mangaDetailsRequest(sManga)?.url?.toString()
}.getOrNull() }.getOrNull()
it[MangaTable.lastFetchedAt] = Instant.now().epochSecond
} }
} }
@@ -115,11 +123,47 @@ object Manga {
getSource(mangaEntry[MangaTable.sourceReference]), getSource(mangaEntry[MangaTable.sourceReference]),
getMangaMetaMap(mangaId), getMangaMetaMap(mangaId),
mangaEntry[MangaTable.realUrl], mangaEntry[MangaTable.realUrl],
mangaEntry[MangaTable.lastFetchedAt],
mangaEntry[MangaTable.chaptersLastFetchedAt],
true true
) )
} }
} }
suspend fun getMangaFull(mangaId: Int, onlineFetch: Boolean = false): MangaDataClass {
val mangaDaaClass = getManga(mangaId, onlineFetch)
return transaction {
val unreadCount =
ChapterTable
.select { (ChapterTable.manga eq mangaId) and (ChapterTable.isRead eq false) }
.count()
val downloadCount =
ChapterTable
.select { (ChapterTable.manga eq mangaId) and (ChapterTable.isDownloaded eq true) }
.count()
val chapterCount =
ChapterTable
.select { (ChapterTable.manga eq mangaId) }
.count()
val lastChapterRead =
ChapterTable
.select { (ChapterTable.manga eq mangaId) }
.orderBy(ChapterTable.sourceOrder to SortOrder.DESC)
.firstOrNull { it[ChapterTable.isRead] }
mangaDaaClass.unreadCount = unreadCount
mangaDaaClass.downloadCount = downloadCount
mangaDaaClass.chapterCount = chapterCount
mangaDaaClass.lastChapterRead = lastChapterRead?.let { ChapterTable.toDataClass(it) }
mangaDaaClass
}
}
private fun getMangaDataClass(mangaId: Int, mangaEntry: ResultRow) = MangaDataClass( private fun getMangaDataClass(mangaId: Int, mangaEntry: ResultRow) = MangaDataClass(
mangaId, mangaId,
mangaEntry[MangaTable.sourceReference].toString(), mangaEntry[MangaTable.sourceReference].toString(),
@@ -140,32 +184,32 @@ object Manga {
getSource(mangaEntry[MangaTable.sourceReference]), getSource(mangaEntry[MangaTable.sourceReference]),
getMangaMetaMap(mangaId), getMangaMetaMap(mangaId),
mangaEntry[MangaTable.realUrl], mangaEntry[MangaTable.realUrl],
mangaEntry[MangaTable.lastFetchedAt],
mangaEntry[MangaTable.chaptersLastFetchedAt],
false false
) )
fun getMangaMetaMap(manga: Int): Map<String, String> { fun getMangaMetaMap(mangaId: Int): Map<String, String> {
return transaction { return transaction {
MangaMetaTable.select { MangaMetaTable.ref eq manga } MangaMetaTable.select { MangaMetaTable.ref eq mangaId }
.associate { it[MangaMetaTable.key] to it[MangaMetaTable.value] } .associate { it[MangaMetaTable.key] to it[MangaMetaTable.value] }
} }
} }
fun modifyMangaMeta(mangaId: Int, key: String, value: String) { fun modifyMangaMeta(mangaId: Int, key: String, value: String) {
transaction { transaction {
val manga = MangaTable.select { MangaTable.id eq mangaId } val meta =
.first()[MangaTable.id] MangaMetaTable.select { (MangaMetaTable.ref eq mangaId) and (MangaMetaTable.key eq key) }
val meta = transaction { .firstOrNull()
MangaMetaTable.select { (MangaMetaTable.ref eq manga) and (MangaMetaTable.key eq key) }
}.firstOrNull()
if (meta == null) { if (meta == null) {
MangaMetaTable.insert { MangaMetaTable.insert {
it[MangaMetaTable.key] = key it[MangaMetaTable.key] = key
it[MangaMetaTable.value] = value it[MangaMetaTable.value] = value
it[MangaMetaTable.ref] = manga it[MangaMetaTable.ref] = mangaId
} }
} else { } else {
MangaMetaTable.update({ (MangaMetaTable.ref eq manga) and (MangaMetaTable.key eq key) }) { MangaMetaTable.update({ (MangaMetaTable.ref eq mangaId) and (MangaMetaTable.key eq key) }) {
it[MangaMetaTable.value] = value it[MangaMetaTable.value] = value
} }
} }
@@ -199,6 +243,7 @@ object Manga {
GET(thumbnailUrl, source.headers) GET(thumbnailUrl, source.headers)
).await() ).await()
} }
is LocalSource -> { is LocalSource -> {
val imageFile = mangaEntry[MangaTable.thumbnail_url]?.let { val imageFile = mangaEntry[MangaTable.thumbnail_url]?.let {
val file = File(it) val file = File(it)
@@ -212,6 +257,7 @@ object Manga {
?: "image/jpeg" ?: "image/jpeg"
imageFile.inputStream() to contentType imageFile.inputStream() to contentType
} }
is StubSource -> getImageResponse(saveDir, fileName, useCache) { is StubSource -> getImageResponse(saveDir, fileName, useCache) {
val thumbnailUrl = mangaEntry[MangaTable.thumbnail_url] val thumbnailUrl = mangaEntry[MangaTable.thumbnail_url]
?: throw NullPointerException("No thumbnail found") ?: throw NullPointerException("No thumbnail found")
@@ -219,6 +265,7 @@ object Manga {
GET(thumbnailUrl) GET(thumbnailUrl)
).await() ).await()
} }
else -> throw IllegalArgumentException("Unknown source") else -> throw IllegalArgumentException("Unknown source")
} }
} }
@@ -27,6 +27,9 @@ object MangaList {
} }
suspend fun getMangaList(sourceId: Long, pageNum: Int = 1, popular: Boolean): PagedMangaListDataClass { suspend fun getMangaList(sourceId: Long, pageNum: Int = 1, popular: Boolean): PagedMangaListDataClass {
require(pageNum > 0) {
"pageNum = $pageNum is not in valid range"
}
val source = getCatalogueSourceOrStub(sourceId) val source = getCatalogueSourceOrStub(sourceId)
val mangasPage = if (popular) { val mangasPage = if (popular) {
source.fetchPopularManga(pageNum).awaitSingle() source.fetchPopularManga(pageNum).awaitSingle()
@@ -85,6 +88,8 @@ object MangaList {
0, 0,
meta = getMangaMetaMap(mangaId), meta = getMangaMetaMap(mangaId),
realUrl = mangaEntry[MangaTable.realUrl], realUrl = mangaEntry[MangaTable.realUrl],
lastFetchedAt = mangaEntry[MangaTable.lastFetchedAt],
chaptersLastFetchedAt = mangaEntry[MangaTable.chaptersLastFetchedAt],
freshData = true freshData = true
) )
} else { } else {
@@ -108,6 +113,8 @@ object MangaList {
mangaEntry[MangaTable.inLibraryAt], mangaEntry[MangaTable.inLibraryAt],
meta = getMangaMetaMap(mangaId), meta = getMangaMetaMap(mangaId),
realUrl = mangaEntry[MangaTable.realUrl], realUrl = mangaEntry[MangaTable.realUrl],
lastFetchedAt = mangaEntry[MangaTable.lastFetchedAt],
chaptersLastFetchedAt = mangaEntry[MangaTable.chaptersLastFetchedAt],
freshData = false freshData = false
) )
} }
@@ -10,6 +10,7 @@ package suwayomi.tachidesk.manga.impl
import eu.kanade.tachiyomi.source.local.LocalSource import eu.kanade.tachiyomi.source.local.LocalSource
import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.HttpSource
import kotlinx.coroutines.flow.StateFlow
import org.jetbrains.exposed.sql.and import org.jetbrains.exposed.sql.and
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
@@ -37,7 +38,7 @@ object Page {
return page.imageUrl!! return page.imageUrl!!
} }
suspend fun getPageImage(mangaId: Int, chapterIndex: Int, index: Int, useCache: Boolean = true): Pair<InputStream, String> { suspend fun getPageImage(mangaId: Int, chapterIndex: Int, index: Int, useCache: Boolean = true, progressFlow: ((StateFlow<Int>) -> Unit)? = null): Pair<InputStream, String> {
val mangaEntry = transaction { MangaTable.select { MangaTable.id eq mangaId }.first() } val mangaEntry = transaction { MangaTable.select { MangaTable.id eq mangaId }.first() }
val source = getCatalogueSourceOrStub(mangaEntry[MangaTable.sourceReference]) val source = getCatalogueSourceOrStub(mangaEntry[MangaTable.sourceReference])
val chapterEntry = transaction { val chapterEntry = transaction {
@@ -55,6 +56,7 @@ object Page {
pageEntry[PageTable.url], pageEntry[PageTable.url],
pageEntry[PageTable.imageUrl] pageEntry[PageTable.imageUrl]
) )
progressFlow?.invoke(tachiyomiPage.progress)
// we treat Local source differently // we treat Local source differently
if (source.id == LocalSource.ID) { if (source.id == LocalSource.ID) {
@@ -27,6 +27,13 @@ object Search {
return searchManga.processEntries(sourceId) return searchManga.processEntries(sourceId)
} }
suspend fun sourceFilter(sourceId: Long, pageNum: Int, filter: FilterData): PagedMangaListDataClass {
val source = getCatalogueSourceOrStub(sourceId)
val filterList = if (filter.filter != null) buildFilterList(sourceId, filter.filter) else source.getFilterList()
val searchManga = source.fetchSearchManga(pageNum, filter.searchTerm ?: "", filterList).awaitSingle()
return searchManga.processEntries(sourceId)
}
private val filterListCache = mutableMapOf<Long, FilterList>() private val filterListCache = mutableMapOf<Long, FilterList>()
private fun getFilterListOf(source: CatalogueSource, reset: Boolean = false): FilterList { private fun getFilterListOf(source: CatalogueSource, reset: Boolean = false): FilterList {
@@ -78,13 +85,16 @@ object Search {
data class FilterObject( data class FilterObject(
val type: String, val type: String,
val filter: Filter<*>, val filter: Filter<*>
) )
fun setFilter(sourceId: Long, changes: List<FilterChange>) { fun setFilter(sourceId: Long, changes: List<FilterChange>) {
val source = getCatalogueSourceOrStub(sourceId) val source = getCatalogueSourceOrStub(sourceId)
val filterList = getFilterListOf(source, false) val filterList = getFilterListOf(source, false)
updateFilterList(filterList, changes)
}
private fun updateFilterList(filterList: FilterList, changes: List<FilterChange>): FilterList {
changes.forEach { change -> changes.forEach { change ->
when (val filter = filterList[change.position]) { when (val filter = filterList[change.position]) {
is Filter.Header -> { is Filter.Header -> {
@@ -112,6 +122,13 @@ object Search {
} }
} }
} }
return filterList
}
private fun buildFilterList(sourceId: Long, changes: List<FilterChange>): FilterList {
val source = getCatalogueSourceOrStub(sourceId)
val filterList = source.getFilterList()
return updateFilterList(filterList, changes)
} }
private val jsonMapper by DI.global.instance<JsonMapper>() private val jsonMapper by DI.global.instance<JsonMapper>()
@@ -122,6 +139,12 @@ object Search {
val state: String val state: String
) )
@Serializable
data class FilterData(
val searchTerm: String?,
val filter: List<FilterChange>?
)
@Suppress("UNUSED_PARAMETER") @Suppress("UNUSED_PARAMETER")
fun sourceGlobalSearch(searchTerm: String) { fun sourceGlobalSearch(searchTerm: String) {
// TODO // TODO
@@ -48,7 +48,7 @@ object Source {
catalogueSource.supportsLatest, catalogueSource.supportsLatest,
catalogueSource is ConfigurableSource, catalogueSource is ConfigurableSource,
it[SourceTable.isNsfw], it[SourceTable.isNsfw],
catalogueSource.toString(), catalogueSource.toString()
) )
} }
} }
@@ -12,5 +12,5 @@ data class BackupFlags(
val includeCategories: Boolean, val includeCategories: Boolean,
val includeChapters: Boolean, val includeChapters: Boolean,
val includeTracking: Boolean, val includeTracking: Boolean,
val includeHistory: Boolean, val includeHistory: Boolean
) )
@@ -69,7 +69,7 @@ object ProtoBackupExport : ProtoBackupBase() {
MangaStatus.valueOf(mangaRow[MangaTable.status]).value, MangaStatus.valueOf(mangaRow[MangaTable.status]).value,
mangaRow[MangaTable.thumbnail_url], mangaRow[MangaTable.thumbnail_url],
TimeUnit.SECONDS.toMillis(mangaRow[MangaTable.inLibraryAt]), TimeUnit.SECONDS.toMillis(mangaRow[MangaTable.inLibraryAt]),
0, // not supported in Tachidesk 0 // not supported in Tachidesk
) )
val mangaId = mangaRow[MangaTable.id].value val mangaId = mangaRow[MangaTable.id].value
@@ -94,7 +94,7 @@ object ProtoBackupExport : ProtoBackupBase() {
TimeUnit.SECONDS.toMillis(it.fetchedAt), TimeUnit.SECONDS.toMillis(it.fetchedAt),
it.uploadDate, it.uploadDate,
it.chapterNumber, it.chapterNumber,
chapters.size - it.index, chapters.size - it.index
) )
} }
} }
@@ -122,7 +122,7 @@ object ProtoBackupExport : ProtoBackupBase() {
BackupCategory( BackupCategory(
it.name, it.name,
it.order, it.order,
0, // not supported in Tachidesk 0 // not supported in Tachidesk
) )
} }
} }
@@ -19,10 +19,10 @@ import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.update import org.jetbrains.exposed.sql.update
import suwayomi.tachidesk.manga.impl.Category import suwayomi.tachidesk.manga.impl.Category
import suwayomi.tachidesk.manga.impl.CategoryManga import suwayomi.tachidesk.manga.impl.CategoryManga
import suwayomi.tachidesk.manga.impl.backup.AbstractBackupValidator.ValidationResult
import suwayomi.tachidesk.manga.impl.backup.models.Chapter import suwayomi.tachidesk.manga.impl.backup.models.Chapter
import suwayomi.tachidesk.manga.impl.backup.models.Manga import suwayomi.tachidesk.manga.impl.backup.models.Manga
import suwayomi.tachidesk.manga.impl.backup.models.Track import suwayomi.tachidesk.manga.impl.backup.models.Track
import suwayomi.tachidesk.manga.impl.backup.proto.ProtoBackupValidator.ValidationResult
import suwayomi.tachidesk.manga.impl.backup.proto.ProtoBackupValidator.validate import suwayomi.tachidesk.manga.impl.backup.proto.ProtoBackupValidator.validate
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupCategory import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupCategory
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupHistory import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupHistory
@@ -77,6 +77,8 @@ object ProtoBackupImport : ProtoBackupBase() {
Restore Summary: Restore Summary:
- Missing Sources: - Missing Sources:
${validationResult.missingSources.joinToString("\n ")} ${validationResult.missingSources.joinToString("\n ")}
- Titles missing Sources:
${validationResult.mangasMissingSources.joinToString("\n ")}
- Missing Trackers: - Missing Trackers:
${validationResult.missingTrackers.joinToString("\n ")} ${validationResult.missingTrackers.joinToString("\n ")}
""".trimIndent() """.trimIndent()
@@ -12,13 +12,18 @@ import okio.gzip
import okio.source import okio.source
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 suwayomi.tachidesk.manga.impl.backup.AbstractBackupValidator
import suwayomi.tachidesk.manga.impl.backup.proto.models.Backup import suwayomi.tachidesk.manga.impl.backup.proto.models.Backup
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupSerializer import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupSerializer
import suwayomi.tachidesk.manga.model.table.SourceTable import suwayomi.tachidesk.manga.model.table.SourceTable
import java.io.InputStream import java.io.InputStream
object ProtoBackupValidator : AbstractBackupValidator() { object ProtoBackupValidator {
data class ValidationResult(
val missingSources: List<String>,
val missingTrackers: List<String>,
val mangasMissingSources: List<String>
)
fun validate(backup: Backup): ValidationResult { fun validate(backup: Backup): ValidationResult {
if (backup.backupManga.isEmpty()) { if (backup.backupManga.isEmpty()) {
throw Exception("Backup does not contain any manga.") throw Exception("Backup does not contain any manga.")
@@ -33,6 +38,12 @@ object ProtoBackupValidator : AbstractBackupValidator() {
.sorted() .sorted()
} }
val brokenSourceIds = backup.brokenBackupSources.map { it.sourceId }
val mangasMissingSources = backup.backupManga
.filter { it.source in brokenSourceIds }
.map { manga -> "${manga.title} (from ${backup.brokenBackupSources.first { it.sourceId == manga.source }.name})" }
// val trackers = backup.backupManga // val trackers = backup.backupManga
// .flatMap { it.tracking } // .flatMap { it.tracking }
// .map { it.syncId } // .map { it.syncId }
@@ -45,7 +56,7 @@ object ProtoBackupValidator : AbstractBackupValidator() {
// .map { context.getString(it.nameRes()) } // .map { context.getString(it.nameRes()) }
// .sorted() // .sorted()
return ValidationResult(missingSources, missingTrackers) return ValidationResult(missingSources, missingTrackers, mangasMissingSources)
} }
suspend fun validate(sourceStream: InputStream): ValidationResult { suspend fun validate(sourceStream: InputStream): ValidationResult {
@@ -9,7 +9,7 @@ data class Backup(
@ProtoNumber(2) var backupCategories: List<BackupCategory> = emptyList(), @ProtoNumber(2) var backupCategories: List<BackupCategory> = emptyList(),
// Bump by 100 to specify this is a 0.x value // Bump by 100 to specify this is a 0.x value
@ProtoNumber(100) var brokenBackupSources: List<BrokenBackupSource> = emptyList(), @ProtoNumber(100) var brokenBackupSources: List<BrokenBackupSource> = emptyList(),
@ProtoNumber(101) var backupSources: List<BackupSource> = emptyList(), @ProtoNumber(101) var backupSources: List<BackupSource> = emptyList()
) { ) {
fun getSourceMap(): Map<Long, String> { fun getSourceMap(): Map<Long, String> {
return (brokenBackupSources.map { BackupSource(it.name, it.sourceId) } + backupSources) return (brokenBackupSources.map { BackupSource(it.name, it.sourceId) } + backupSources)
@@ -11,7 +11,7 @@ class BackupCategory(
@ProtoNumber(2) var order: Int = 0, @ProtoNumber(2) var order: Int = 0,
// @ProtoNumber(3) val updateInterval: Int = 0, 1.x value not used in 0.x // @ProtoNumber(3) val updateInterval: Int = 0, 1.x value not used in 0.x
// Bump by 100 to specify this is a 0.x value // Bump by 100 to specify this is a 0.x value
@ProtoNumber(100) var flags: Int = 0, @ProtoNumber(100) var flags: Int = 0
) { ) {
fun getCategoryImpl(): CategoryImpl { fun getCategoryImpl(): CategoryImpl {
return CategoryImpl().apply { return CategoryImpl().apply {
@@ -20,7 +20,7 @@ data class BackupChapter(
@ProtoNumber(8) var dateUpload: Long = 0, @ProtoNumber(8) var dateUpload: Long = 0,
// chapterNumber is called number is 1.x // chapterNumber is called number is 1.x
@ProtoNumber(9) var chapterNumber: Float = 0F, @ProtoNumber(9) var chapterNumber: Float = 0F,
@ProtoNumber(10) var sourceOrder: Int = 0, @ProtoNumber(10) var sourceOrder: Int = 0
) { ) {
fun toChapterImpl(): ChapterImpl { fun toChapterImpl(): ChapterImpl {
return ChapterImpl().apply { return ChapterImpl().apply {
@@ -35,7 +35,7 @@ data class BackupManga(
@ProtoNumber(101) var chapterFlags: Int = 0, @ProtoNumber(101) var chapterFlags: Int = 0,
@ProtoNumber(102) var brokenHistory: List<BrokenBackupHistory> = emptyList(), @ProtoNumber(102) var brokenHistory: List<BrokenBackupHistory> = emptyList(),
@ProtoNumber(103) var viewer_flags: Int? = null, @ProtoNumber(103) var viewer_flags: Int? = null,
@ProtoNumber(104) var history: List<BackupHistory> = emptyList(), @ProtoNumber(104) var history: List<BackupHistory> = emptyList()
) { ) {
fun getMangaImpl(): MangaImpl { fun getMangaImpl(): MangaImpl {
return MangaImpl().apply { return MangaImpl().apply {
@@ -24,7 +24,7 @@ data class BackupTracking(
// startedReadingDate is called startReadTime in 1.x // startedReadingDate is called startReadTime in 1.x
@ProtoNumber(10) var startedReadingDate: Long = 0, @ProtoNumber(10) var startedReadingDate: Long = 0,
// finishedReadingDate is called endReadTime in 1.x // finishedReadingDate is called endReadTime in 1.x
@ProtoNumber(11) var finishedReadingDate: Long = 0, @ProtoNumber(11) var finishedReadingDate: Long = 0
) { ) {
fun getTrackingImpl(): TrackImpl { fun getTrackingImpl(): TrackImpl {
return TrackImpl().apply { return TrackImpl().apply {
@@ -37,7 +37,6 @@ private class ChapterForDownload(
private val mangaId: Int private val mangaId: Int
) { ) {
suspend fun asDownloadReady(): ChapterDataClass { suspend fun asDownloadReady(): ChapterDataClass {
if (isNotCompletelyDownloaded()) { if (isNotCompletelyDownloaded()) {
markAsNotDownloaded() markAsNotDownloaded()
@@ -9,22 +9,42 @@ package suwayomi.tachidesk.manga.impl.download
import io.javalin.websocket.WsContext import io.javalin.websocket.WsContext
import io.javalin.websocket.WsMessageContext import io.javalin.websocket.WsMessageContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import mu.KotlinLogging
import org.jetbrains.exposed.sql.and import org.jetbrains.exposed.sql.and
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 suwayomi.tachidesk.manga.impl.Manga.getManga
import suwayomi.tachidesk.manga.impl.download.model.DownloadChapter import suwayomi.tachidesk.manga.impl.download.model.DownloadChapter
import suwayomi.tachidesk.manga.impl.download.model.DownloadState.Downloading import suwayomi.tachidesk.manga.impl.download.model.DownloadState.Downloading
import suwayomi.tachidesk.manga.impl.download.model.DownloadStatus import suwayomi.tachidesk.manga.impl.download.model.DownloadStatus
import suwayomi.tachidesk.manga.model.dataclass.ChapterDataClass
import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass
import suwayomi.tachidesk.manga.model.table.ChapterTable import suwayomi.tachidesk.manga.model.table.ChapterTable
import suwayomi.tachidesk.manga.model.table.MangaTable
import suwayomi.tachidesk.manga.model.table.toDataClass import suwayomi.tachidesk.manga.model.table.toDataClass
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.CopyOnWriteArrayList
import kotlin.time.Duration.Companion.seconds
private val logger = KotlinLogging.logger {}
private const val MAX_SOURCES_IN_PARAllEL = 5
object DownloadManager { object DownloadManager {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val clients = ConcurrentHashMap<String, WsContext>() private val clients = ConcurrentHashMap<String, WsContext>()
private val downloadQueue = CopyOnWriteArrayList<DownloadChapter>() private val downloadQueue = CopyOnWriteArrayList<DownloadChapter>()
private var downloader: Downloader? = null private val downloaders = ConcurrentHashMap<Long, Downloader>()
fun addClient(ctx: WsContext) { fun addClient(ctx: WsContext) {
clients[ctx.sessionId] = ctx clients[ctx.sessionId] = ctx
@@ -49,75 +69,209 @@ object DownloadManager {
|Supported commands are: |Supported commands are:
| - STATUS | - STATUS
| sends the current download status | sends the current download status
|""".trimMargin() |
""".trimMargin()
) )
} }
} }
private fun notifyAllClients() { private val notifyFlow = MutableSharedFlow<Unit>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
init {
scope.launch {
notifyFlow.sample(1.seconds).collect {
sendStatusToAllClients()
}
}
}
private fun sendStatusToAllClients() {
val status = getStatus() val status = getStatus()
clients.forEach { clients.forEach {
it.value.send(status) it.value.send(status)
} }
} }
private fun notifyAllClients(immediate: Boolean = false) {
if (immediate) {
sendStatusToAllClients()
} else {
scope.launch {
notifyFlow.emit(Unit)
}
}
}
private fun getStatus(): DownloadStatus { private fun getStatus(): DownloadStatus {
return DownloadStatus( return DownloadStatus(
if (downloader == null || if (downloadQueue.none { it.state == Downloading }) {
downloadQueue.none { it.state == Downloading } "Stopped"
) "Stopped" else "Started", } else {
downloadQueue "Started"
},
downloadQueue.toList()
) )
} }
suspend fun enqueue(chapterIndex: Int, mangaId: Int) { private val downloaderWatch = MutableSharedFlow<Unit>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
if (downloadQueue.none { it.mangaId == mangaId && it.chapterIndex == chapterIndex }) { init {
downloadQueue.add( scope.launch {
DownloadChapter( downloaderWatch.sample(1.seconds).collect {
chapterIndex, val runningDownloaders = downloaders.values.filter { it.isActive }
mangaId, logger.info { "Running: ${runningDownloaders.size}" }
chapter = ChapterTable.toDataClass( if (runningDownloaders.size < MAX_SOURCES_IN_PARAllEL) {
transaction { downloadQueue.asSequence()
ChapterTable.select { (ChapterTable.manga eq mangaId) and (ChapterTable.sourceOrder eq chapterIndex) } .map { it.manga.sourceId.toLong() }
.first() .distinct()
.minus(
runningDownloaders.map { it.sourceId }.toSet()
)
.take(MAX_SOURCES_IN_PARAllEL - runningDownloaders.size)
.map { getDownloader(it) }
.forEach {
it.start()
} }
), notifyAllClients()
manga = getManga(mangaId) }
) }
)
start()
} }
}
private fun refreshDownloaders() {
scope.launch {
downloaderWatch.emit(Unit)
}
}
private fun getDownloader(sourceId: Long) = downloaders.getOrPut(sourceId) {
Downloader(
scope = scope,
sourceId = sourceId,
downloadQueue = downloadQueue,
notifier = ::notifyAllClients,
onComplete = ::refreshDownloaders
)
}
fun enqueueWithChapterIndex(mangaId: Int, chapterIndex: Int) {
val chapter = transaction {
ChapterTable
.slice(ChapterTable.id)
.select { ChapterTable.manga.eq(mangaId) and ChapterTable.sourceOrder.eq(chapterIndex) }
.first()
}
enqueue(EnqueueInput(chapterIds = listOf(chapter[ChapterTable.id].value)))
}
@Serializable
// Input might have additional formats in the future, such as "All for mangaID" or "Unread for categoryID"
// Having this input format is just future-proofing
data class EnqueueInput(
val chapterIds: List<Int>?
)
fun enqueue(input: EnqueueInput) {
if (input.chapterIds.isNullOrEmpty()) return
val chapters = transaction {
(ChapterTable innerJoin MangaTable)
.select { ChapterTable.id inList input.chapterIds }
.toList()
}
val mangas = transaction {
chapters.distinctBy { chapter -> chapter[MangaTable.id] }
.map { MangaTable.toDataClass(it) }
.associateBy { it.id }
}
val inputPairs = transaction {
chapters.map {
Pair(
// this should be safe because mangas is created above from chapters
mangas[it[ChapterTable.manga].value]!!,
ChapterTable.toDataClass(it)
)
}
}
addMultipleToQueue(inputPairs)
}
fun unqueue(input: EnqueueInput) {
if (input.chapterIds.isNullOrEmpty()) return
downloadQueue.removeIf { it.chapter.id in input.chapterIds }
notifyAllClients() notifyAllClients()
} }
/**
* Tries to add multiple inputs to queue
* If any of inputs was actually added to queue, starts the queue
*/
private fun addMultipleToQueue(inputs: List<Pair<MangaDataClass, ChapterDataClass>>) {
val addedChapters = inputs.mapNotNull { addToQueue(it.first, it.second) }
if (addedChapters.isNotEmpty()) {
start()
notifyAllClients(true)
}
scope.launch {
downloaderWatch.emit(Unit)
}
}
/**
* Tries to add chapter to queue.
* If chapter is added, returns the created DownloadChapter, otherwise returns null
*/
private fun addToQueue(manga: MangaDataClass, chapter: ChapterDataClass): DownloadChapter? {
if (downloadQueue.none { it.mangaId == manga.id && it.chapterIndex == chapter.index }) {
val downloadChapter = DownloadChapter(
chapter.index,
manga.id,
chapter,
manga
)
downloadQueue.add(downloadChapter)
logger.debug { "Added chapter ${chapter.id} to download queue (${manga.title} | ${chapter.name})" }
return downloadChapter
}
logger.debug { "Chapter ${chapter.id} already present in queue (${manga.title} | ${chapter.name})" }
return null
}
fun unqueue(chapterIndex: Int, mangaId: Int) { fun unqueue(chapterIndex: Int, mangaId: Int) {
downloadQueue.removeIf { it.mangaId == mangaId && it.chapterIndex == chapterIndex } downloadQueue.removeIf { it.mangaId == mangaId && it.chapterIndex == chapterIndex }
notifyAllClients() notifyAllClients()
} }
fun reorder(chapterIndex: Int, mangaId: Int, to: Int) {
require(to >= 0) { "'to' must be over or equal to 0" }
val download = downloadQueue.find { it.mangaId == mangaId && it.chapterIndex == chapterIndex }
?: return
downloadQueue -= download
downloadQueue.add(to, download)
}
fun start() { fun start() {
if (downloader != null && !downloader?.isAlive!!) // doesn't exist or is dead scope.launch {
downloader = null downloaderWatch.emit(Unit)
if (downloader == null) {
downloader = Downloader(downloadQueue) { notifyAllClients() }
downloader!!.start()
} }
}
suspend fun stop() {
coroutineScope {
downloaders.map { (_, downloader) ->
async {
downloader.stop()
}
}.awaitAll()
}
notifyAllClients() notifyAllClients()
} }
fun stop() { suspend fun clear() {
downloader?.let {
synchronized(it.shouldStop) {
it.shouldStop = true
}
}
downloader = null
notifyAllClients()
}
fun clear() {
stop() stop()
downloadQueue.clear() downloadQueue.clear()
notifyAllClients() notifyAllClients()
@@ -127,5 +281,5 @@ object DownloadManager {
enum class DownloaderState(val state: Int) { enum class DownloaderState(val state: Int) {
Stopped(0), Stopped(0),
Running(1), Running(1),
Paused(2), Paused(2)
} }
@@ -7,7 +7,18 @@ package suwayomi.tachidesk.manga.impl.download
* 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.coroutines.runBlocking import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import mu.KotlinLogging import mu.KotlinLogging
import org.jetbrains.exposed.sql.and import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.sql.transactions.transaction
@@ -24,40 +35,92 @@ import java.util.concurrent.CopyOnWriteArrayList
private val logger = KotlinLogging.logger {} private val logger = KotlinLogging.logger {}
class Downloader(private val downloadQueue: CopyOnWriteArrayList<DownloadChapter>, val notifier: () -> Unit) : Thread() { class Downloader(
var shouldStop: Boolean = false private val scope: CoroutineScope,
val sourceId: Long,
private val downloadQueue: CopyOnWriteArrayList<DownloadChapter>,
private val notifier: (immediate: Boolean) -> Unit,
private val onComplete: () -> Unit
) {
private var job: Job? = null
class StopDownloadException : Exception("Cancelled download")
class PauseDownloadException : Exception("Pause download")
class DownloadShouldStopException : Exception() private suspend fun step(download: DownloadChapter?, immediate: Boolean) {
notifier(immediate)
fun step() { currentCoroutineContext().ensureActive()
notifier() if (download != null && download != downloadQueue.firstOrNull { it.manga.sourceId.toLong() == sourceId && it.state != Error }) {
synchronized(shouldStop) { if (download in downloadQueue) {
if (shouldStop) throw DownloadShouldStopException() throw PauseDownloadException()
} else {
throw StopDownloadException()
}
} }
} }
override fun run() { val isActive
do { get() = job?.isActive == true
fun start() {
if (!isActive) {
job = scope.launch {
run()
}.also { job ->
job.invokeOnCompletion {
if (it !is CancellationException) {
onComplete()
}
}
}
}
notifier(false)
}
suspend fun stop() {
job?.cancelAndJoin()
}
private suspend fun run() {
while (downloadQueue.isNotEmpty() && currentCoroutineContext().isActive) {
val download = downloadQueue.firstOrNull { val download = downloadQueue.firstOrNull {
it.state == Queued || it.manga.sourceId.toLong() == sourceId &&
(it.state == Error && it.tries < 3) // 3 re-tries per download (it.state == Queued || (it.state == Error && it.tries < 3)) // 3 re-tries per download
} ?: break } ?: break
try { try {
download.state = Downloading download.state = Downloading
step() step(download, true)
download.chapter = runBlocking { getChapterDownloadReady(download.chapterIndex, download.mangaId) } download.chapter = getChapterDownloadReady(download.chapterIndex, download.mangaId)
step() step(download, false)
val pageCount = download.chapter.pageCount val pageCount = download.chapter.pageCount
for (pageNum in 0 until pageCount) { for (pageNum in 0 until pageCount) {
runBlocking { getPageImage(download.mangaId, download.chapterIndex, pageNum) } var pageProgressJob: Job? = null
try {
getPageImage(
mangaId = download.mangaId,
chapterIndex = download.chapterIndex,
index = pageNum,
progressFlow = { flow ->
pageProgressJob = flow
.sample(100)
.distinctUntilChanged()
.onEach {
download.progress = (pageNum.toFloat() + (it.toFloat() * 0.01f)) / pageCount
step(null, false) // don't throw on canceled download here since we can't do anything
}
.launchIn(scope)
}
).first.close()
} finally {
// always cancel the page progress job even if it throws an exception to avoid memory leaks
pageProgressJob?.cancel()
}
// TODO: retry on error with 2,4,8 seconds of wait // TODO: retry on error with 2,4,8 seconds of wait
// TODO: download multiple pages at once, possible solution: rx observer's strategy is used in Tachiyomi download.progress = ((pageNum + 1).toFloat()) / pageCount
// TODO: fine grained download percentage step(download, false)
download.progress = (pageNum + 1).toFloat() / pageCount
step()
} }
download.state = Finished download.state = Finished
transaction { transaction {
@@ -65,20 +128,22 @@ class Downloader(private val downloadQueue: CopyOnWriteArrayList<DownloadChapter
it[isDownloaded] = true it[isDownloaded] = true
} }
} }
step() step(download, true)
downloadQueue.removeIf { it.mangaId == download.mangaId && it.chapterIndex == download.chapterIndex } downloadQueue.removeIf { it.mangaId == download.mangaId && it.chapterIndex == download.chapterIndex }
step() step(null, false)
} catch (e: DownloadShouldStopException) { } catch (e: CancellationException) {
logger.debug("Downloader was stopped") logger.debug("Downloader was stopped")
downloadQueue.filter { it.state == Downloading }.forEach { it.state = Queued } downloadQueue.filter { it.state == Downloading }.forEach { it.state = Queued }
} catch (e: PauseDownloadException) {
download.state = Queued
} catch (e: Exception) { } catch (e: Exception) {
logger.debug("Downloader faced an exception") logger.info("Downloader faced an exception", e)
downloadQueue.filter { it.state == Downloading }.forEach { it.state = Error; it.tries++ } download.tries++
e.printStackTrace() download.state = Error
} finally { } finally {
notifier() notifier(false)
} }
} while (!shouldStop) }
} }
} }
@@ -18,5 +18,5 @@ class DownloadChapter(
var manga: MangaDataClass, var manga: MangaDataClass,
var state: DownloadState = Queued, var state: DownloadState = Queued,
var progress: Float = 0f, var progress: Float = 0f,
var tries: Int = 0, var tries: Int = 0
) )
@@ -11,5 +11,5 @@ enum class DownloadState(val state: Int) {
Queued(0), Queued(0),
Downloading(1), Downloading(1),
Finished(2), Finished(2),
Error(3), Error(3)
} }
@@ -9,5 +9,5 @@ package suwayomi.tachidesk.manga.impl.download.model
data class DownloadStatus( data class DownloadStatus(
val status: String, val status: String,
val queue: List<DownloadChapter>, val queue: List<DownloadChapter>
) )
@@ -18,6 +18,7 @@ import okhttp3.Request
import okio.buffer import okio.buffer
import okio.sink import okio.sink
import okio.source import okio.source
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
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.select import org.jetbrains.exposed.sql.select
@@ -225,12 +226,13 @@ object Extension {
SourceTable.deleteWhere { SourceTable.extension eq extensionId } SourceTable.deleteWhere { SourceTable.extension eq extensionId }
if (extensionRecord[ExtensionTable.isObsolete]) if (extensionRecord[ExtensionTable.isObsolete]) {
ExtensionTable.deleteWhere { ExtensionTable.pkgName eq pkgName } ExtensionTable.deleteWhere { ExtensionTable.pkgName eq pkgName }
else } else {
ExtensionTable.update({ ExtensionTable.pkgName eq pkgName }) { ExtensionTable.update({ ExtensionTable.pkgName eq pkgName }) {
it[isInstalled] = false it[isInstalled] = false
} }
}
sources sources
} }
@@ -265,8 +267,11 @@ object Extension {
} }
suspend fun getExtensionIcon(apkName: String, useCache: Boolean): Pair<InputStream, String> { suspend fun getExtensionIcon(apkName: String, useCache: Boolean): Pair<InputStream, String> {
val iconUrl = if (apkName == "localSource") "" val iconUrl = if (apkName == "localSource") {
else transaction { ExtensionTable.select { ExtensionTable.apkName eq apkName }.first() }[ExtensionTable.iconUrl] ""
} else {
transaction { ExtensionTable.select { ExtensionTable.apkName eq apkName }.first() }[ExtensionTable.iconUrl]
}
val saveDir = "${applicationDirs.extensionsRoot}/icon" val saveDir = "${applicationDirs.extensionsRoot}/icon"
@@ -9,6 +9,7 @@ package suwayomi.tachidesk.manga.impl.extension
import eu.kanade.tachiyomi.source.local.LocalSource import eu.kanade.tachiyomi.source.local.LocalSource
import mu.KotlinLogging import mu.KotlinLogging
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
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.select import org.jetbrains.exposed.sql.select
@@ -59,7 +60,7 @@ object ExtensionsList {
it[ExtensionTable.isNsfw], it[ExtensionTable.isNsfw],
it[ExtensionTable.isInstalled], it[ExtensionTable.isInstalled],
it[ExtensionTable.hasUpdate], it[ExtensionTable.hasUpdate],
it[ExtensionTable.isObsolete], it[ExtensionTable.isObsolete]
) )
} }
} }
@@ -31,7 +31,7 @@ object ExtensionGithubApi {
val nsfw: Int, val nsfw: Int,
val hasReadme: Int = 0, val hasReadme: Int = 0,
val hasChangelog: Int = 0, val hasChangelog: Int = 0,
val sources: List<ExtensionSourceJsonObject>?, val sources: List<ExtensionSourceJsonObject>?
) )
@Serializable @Serializable
@@ -5,6 +5,6 @@ import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass
interface IUpdater { interface IUpdater {
fun addMangaToQueue(manga: MangaDataClass) fun addMangaToQueue(manga: MangaDataClass)
fun getStatus(): StateFlow<UpdateStatus> val status: StateFlow<UpdateStatus>
suspend fun reset(): Unit fun reset()
} }
@@ -9,9 +9,7 @@ enum class JobStatus {
FAILED FAILED
} }
class UpdateJob(val manga: MangaDataClass, var status: JobStatus = JobStatus.PENDING) { data class UpdateJob(
val manga: MangaDataClass,
override fun toString(): String { val status: JobStatus = JobStatus.PENDING
return "UpdateJob(status=$status, manga=${manga.title})" )
}
}
@@ -1,33 +1,23 @@
package suwayomi.tachidesk.manga.impl.update package suwayomi.tachidesk.manga.impl.update
import com.fasterxml.jackson.annotation.JsonIgnore
import mu.KotlinLogging import mu.KotlinLogging
import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass
var logger = KotlinLogging.logger {} val logger = KotlinLogging.logger {}
class UpdateStatus( data class UpdateStatus(
var statusMap: MutableMap<JobStatus, MutableList<MangaDataClass>> = mutableMapOf<JobStatus, MutableList<MangaDataClass>>(), val statusMap: Map<JobStatus, List<MangaDataClass>> = emptyMap(),
var running: Boolean = false, val running: Boolean = false,
@JsonIgnore
val numberOfJobs: Int = 0
) { ) {
var numberOfJobs: Int = 0
constructor(jobs: List<UpdateJob>, running: Boolean) : this( constructor(jobs: List<UpdateJob>, running: Boolean) : this(
mutableMapOf<JobStatus, MutableList<MangaDataClass>>(), statusMap = jobs.groupBy { it.status }
running .mapValues { entry ->
) { entry.value.map { it.manga }
this.numberOfJobs = jobs.size },
jobs.forEach { running = running,
val list = statusMap.getOrDefault(it.status, mutableListOf()) numberOfJobs = jobs.size
list.add(it.manga) )
statusMap[it.status] = list
}
}
override fun toString(): String {
return "UpdateStatus(statusMap=${statusMap.map { "${it.key} : ${it.value.size}" }.joinToString("; ")}, running=$running)"
}
// serialize to summary json
fun getJsonSummary(): String {
return """{"statusMap":{${statusMap.map { "\"${it.key}\" : ${it.value.size}" }.joinToString(",")}}, "running":$running}"""
}
} }
@@ -3,74 +3,76 @@ package suwayomi.tachidesk.manga.impl.update
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancelChildren
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.consumeAsFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
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 java.util.concurrent.ConcurrentHashMap
class Updater : IUpdater { class Updater : IUpdater {
private val logger = KotlinLogging.logger {} private val logger = KotlinLogging.logger {}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private var tracker = mutableMapOf<String, UpdateJob>() private val _status = MutableStateFlow(UpdateStatus())
private var updateChannel = Channel<UpdateJob>() override val status = _status.asStateFlow()
private val statusChannel = MutableStateFlow(UpdateStatus())
private var updateJob: Job? = null
init { private val tracker = ConcurrentHashMap<Int, UpdateJob>()
updateJob = createUpdateJob() private var updateChannel = createUpdateChannel()
}
private fun createUpdateJob(): Job { private fun createUpdateChannel(): Channel<UpdateJob> {
return scope.launch { val channel = Channel<UpdateJob>(Channel.UNLIMITED)
while (true) { channel.consumeAsFlow()
val job = updateChannel.receive() .onEach { job ->
process(job) _status.value = UpdateStatus(
statusChannel.value = UpdateStatus(tracker.values.toList(), !updateChannel.isEmpty) process(job),
tracker.any { (_, job) ->
job.status == JobStatus.PENDING || job.status == JobStatus.RUNNING
}
)
} }
} .catch { logger.error(it) { "Error during updates" } }
.launchIn(scope)
return channel
} }
private suspend fun process(job: UpdateJob) { private suspend fun process(job: UpdateJob): List<UpdateJob> {
job.status = JobStatus.RUNNING tracker[job.manga.id] = job.copy(status = JobStatus.RUNNING)
tracker["${job.manga.id}"] = job _status.update { UpdateStatus(tracker.values.toList(), true) }
statusChannel.value = UpdateStatus(tracker.values.toList(), true) tracker[job.manga.id] = try {
try {
logger.info { "Updating ${job.manga.title}" } logger.info { "Updating ${job.manga.title}" }
Chapter.getChapterList(job.manga.id, true) Chapter.getChapterList(job.manga.id, true)
job.status = JobStatus.COMPLETE job.copy(status = JobStatus.COMPLETE)
} catch (e: Exception) { } catch (e: Exception) {
if (e is CancellationException) throw e if (e is CancellationException) throw e
logger.error(e) { "Error while updating ${job.manga.title}" } logger.error(e) { "Error while updating ${job.manga.title}" }
job.status = JobStatus.FAILED job.copy(status = JobStatus.FAILED)
} }
tracker["${job.manga.id}"] = job return tracker.values.toList()
} }
override fun addMangaToQueue(manga: MangaDataClass) { override fun addMangaToQueue(manga: MangaDataClass) {
scope.launch { scope.launch {
updateChannel.send(UpdateJob(manga)) updateChannel.send(UpdateJob(manga))
} }
tracker["${manga.id}"] = UpdateJob(manga) tracker[manga.id] = UpdateJob(manga)
statusChannel.value = UpdateStatus(tracker.values.toList(), true) _status.update { UpdateStatus(tracker.values.toList(), true) }
} }
override fun getStatus(): StateFlow<UpdateStatus> { override fun reset() {
return statusChannel scope.coroutineContext.cancelChildren()
}
override suspend fun reset() {
tracker.clear() tracker.clear()
_status.update { UpdateStatus() }
updateChannel.cancel() updateChannel.cancel()
statusChannel.value = UpdateStatus() updateChannel = createUpdateChannel()
updateJob?.cancel("Reset")
updateChannel = Channel()
updateJob = createUpdateJob()
} }
} }
@@ -6,33 +6,34 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.launch import kotlinx.coroutines.flow.onEach
import mu.KotlinLogging import mu.KotlinLogging
import org.kodein.di.DI import org.kodein.di.DI
import org.kodein.di.conf.global import org.kodein.di.conf.global
import org.kodein.di.instance import org.kodein.di.instance
object UpdaterSocket : Websocket() { object UpdaterSocket : Websocket<UpdateStatus>() {
private val logger = KotlinLogging.logger {} private val logger = KotlinLogging.logger {}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val updater by DI.global.instance<IUpdater>() private val updater by DI.global.instance<IUpdater>()
private var job: Job? = null private var job: Job? = null
override fun notifyClient(ctx: WsContext) { override fun notifyClient(ctx: WsContext, value: UpdateStatus?) {
ctx.send(updater.getStatus().value.getJsonSummary()) ctx.send(value ?: updater.status.value)
} }
override fun handleRequest(ctx: WsMessageContext) { override fun handleRequest(ctx: WsMessageContext) {
when (ctx.message()) { when (ctx.message()) {
"STATUS" -> notifyClient(ctx) "STATUS" -> notifyClient(ctx, updater.status.value)
else -> ctx.send( else -> ctx.send(
""" """
|Invalid command. |Invalid command.
|Supported commands are: |Supported commands are:
| - STATUS | - STATUS
| sends the current update status | sends the current update status
|""".trimMargin() |
""".trimMargin()
) )
} }
} }
@@ -40,7 +41,7 @@ object UpdaterSocket : Websocket() {
override fun addClient(ctx: WsContext) { override fun addClient(ctx: WsContext) {
logger.info { ctx.sessionId } logger.info { ctx.sessionId }
super.addClient(ctx) super.addClient(ctx)
if (job == null) { if (job?.isActive != true) {
job = start() job = start()
} }
} }
@@ -54,12 +55,10 @@ object UpdaterSocket : Websocket() {
} }
fun start(): Job { fun start(): Job {
return scope.launch { return updater.status
while (true) { .onEach {
updater.getStatus().collectLatest { notifyAllClients(it)
notifyAllClients()
}
} }
} .launchIn(scope)
} }
} }
@@ -4,18 +4,18 @@ import io.javalin.websocket.WsContext
import io.javalin.websocket.WsMessageContext import io.javalin.websocket.WsMessageContext
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
abstract class Websocket { abstract class Websocket<T> {
protected val clients = ConcurrentHashMap<String, WsContext>() protected val clients = ConcurrentHashMap<String, WsContext>()
open fun addClient(ctx: WsContext) { open fun addClient(ctx: WsContext) {
clients[ctx.sessionId] = ctx clients[ctx.sessionId] = ctx
notifyClient(ctx) notifyClient(ctx, null)
} }
open fun removeClient(ctx: WsContext) { open fun removeClient(ctx: WsContext) {
clients.remove(ctx.sessionId) clients.remove(ctx.sessionId)
} }
open fun notifyAllClients() { open fun notifyAllClients(value: T) {
clients.values.forEach { notifyClient(it) } clients.values.forEach { notifyClient(it, value) }
} }
abstract fun notifyClient(ctx: WsContext) abstract fun notifyClient(ctx: WsContext, value: T?)
abstract fun handleRequest(ctx: WsMessageContext) abstract fun handleRequest(ctx: WsMessageContext)
} }
@@ -67,7 +67,9 @@ object BytecodeEditor {
} }
path to bytes path to bytes
} else null } else {
null
}
} catch (e: Exception) { } catch (e: Exception) {
logger.error(e) { "Error loading class from Path: $path" } logger.error(e) { "Error loading class from Path: $path" }
null null
@@ -172,7 +174,11 @@ object BytecodeEditor {
): MethodVisitor { ): MethodVisitor {
logger.trace { "Processing method $name: ${desc.replaceIndirectly()}: $signature" } logger.trace { "Processing method $name: ${desc.replaceIndirectly()}: $signature" }
val mv: MethodVisitor? = super.visitMethod( val mv: MethodVisitor? = super.visitMethod(
access, name, desc.replaceIndirectly(), signature, exceptions access,
name,
desc.replaceIndirectly(),
signature,
exceptions
) )
return object : MethodVisitor(Opcodes.ASM5, mv) { return object : MethodVisitor(Opcodes.ASM5, mv) {
override fun visitLdcInsn(cst: Any?) { override fun visitLdcInsn(cst: Any?) {
@@ -60,7 +60,9 @@ fun updateMangaDownloadDir(mangaId: Int, newTitle: String): Boolean {
val oldDirFile = File(oldDir) val oldDirFile = File(oldDir)
val newDirFile = File(newDir) val newDirFile = File(newDir)
return if (oldDirFile.exists()) return if (oldDirFile.exists()) {
oldDirFile.renameTo(newDirFile) oldDirFile.renameTo(newDirFile)
else true } else {
true
}
} }
@@ -8,6 +8,7 @@ package suwayomi.tachidesk.manga.impl.util.lang
* 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.coroutines.CancellableContinuation import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.InternalCoroutinesApi
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import rx.Observable import rx.Observable
import rx.Subscriber import rx.Subscriber
@@ -22,6 +23,7 @@ import kotlin.coroutines.resumeWithException
suspend fun <T> Observable<T>.awaitSingle(): T = single().awaitOne() suspend fun <T> Observable<T>.awaitSingle(): T = single().awaitOne()
@OptIn(InternalCoroutinesApi::class)
private suspend fun <T> Observable<T>.awaitOne(): T = suspendCancellableCoroutine { cont -> private suspend fun <T> Observable<T>.awaitOne(): T = suspendCancellableCoroutine { cont ->
cont.unsubscribeOnCancellation( cont.unsubscribeOnCancellation(
subscribe( subscribe(
@@ -35,11 +37,13 @@ private suspend fun <T> Observable<T>.awaitOne(): T = suspendCancellableCoroutin
} }
override fun onCompleted() { override fun onCompleted() {
if (cont.isActive) cont.resumeWithException( if (cont.isActive) {
IllegalStateException( cont.resumeWithException(
"Should have invoked onNext" IllegalStateException(
"Should have invoked onNext"
)
) )
) }
} }
override fun onError(e: Throwable) { override fun onError(e: Throwable) {
@@ -21,8 +21,9 @@ object ImageResponse {
fun findFileNameStartingWith(directoryPath: String, fileName: String): String? { fun findFileNameStartingWith(directoryPath: String, fileName: String): String? {
val target = "$fileName." val target = "$fileName."
File(directoryPath).listFiles().orEmpty().forEach { file -> File(directoryPath).listFiles().orEmpty().forEach { file ->
if (file.name.startsWith(target)) if (file.name.startsWith(target)) {
return "$directoryPath/${file.name}" return "$directoryPath/${file.name}"
}
} }
return null return null
} }
@@ -11,5 +11,6 @@ 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 meta: Map<String, String> = emptyMap()
) )
@@ -8,6 +8,7 @@ package suwayomi.tachidesk.manga.model.dataclass
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
data class ChapterDataClass( data class ChapterDataClass(
val id: Int,
val url: String, val url: String,
val name: String, val name: String,
val uploadDate: Long, val uploadDate: Long,
@@ -44,5 +45,5 @@ data class ChapterDataClass(
val chapterCount: Int? = null, val chapterCount: Int? = null,
/** used to store client specific values */ /** used to store client specific values */
val meta: Map<String, String> = emptyMap(), val meta: Map<String, String> = emptyMap()
) )
@@ -20,5 +20,5 @@ data class ExtensionDataClass(
val installed: Boolean, val installed: Boolean,
val hasUpdate: Boolean, val hasUpdate: Boolean,
val obsolete: Boolean, val obsolete: Boolean
) )
@@ -9,5 +9,5 @@ package suwayomi.tachidesk.manga.model.dataclass
data class MangaChapterDataClass( data class MangaChapterDataClass(
val manga: MangaDataClass, val manga: MangaDataClass,
val chapter: ChapterDataClass, val chapter: ChapterDataClass
) )
@@ -9,6 +9,7 @@ package suwayomi.tachidesk.manga.model.dataclass
import suwayomi.tachidesk.manga.impl.util.lang.trimAll import suwayomi.tachidesk.manga.impl.util.lang.trimAll
import suwayomi.tachidesk.manga.model.table.MangaStatus import suwayomi.tachidesk.manga.model.table.MangaStatus
import java.time.Instant
data class MangaDataClass( data class MangaDataClass(
val id: Int, val id: Int,
@@ -33,11 +34,17 @@ data class MangaDataClass(
val meta: Map<String, String> = emptyMap(), val meta: Map<String, String> = emptyMap(),
val realUrl: String? = null, val realUrl: String? = null,
var lastFetchedAt: Long? = 0,
var chaptersLastFetchedAt: Long? = 0,
val freshData: Boolean = false, val freshData: Boolean = false,
var unreadCount: Int? = null, var unreadCount: Long? = null,
var downloadCount: Int? = null, var downloadCount: Long? = null,
var chapterCount: Int? = null var chapterCount: Long? = null,
var lastChapterRead: ChapterDataClass? = null,
val age: Long? = if (lastFetchedAt == null) 0 else Instant.now().epochSecond.minus(lastFetchedAt),
val chaptersAge: Long? = if (chaptersLastFetchedAt == null) null else Instant.now().epochSecond.minus(chaptersLastFetchedAt)
) )
data class PagedMangaListDataClass( data class PagedMangaListDataClass(
@@ -9,5 +9,5 @@ package suwayomi.tachidesk.manga.model.dataclass
data class PageDataClass( data class PageDataClass(
val index: Int, val index: Int,
var imageUrl: String, var imageUrl: String
) )
@@ -11,7 +11,7 @@ import kotlin.math.min
open class PaginatedList<T>( open class PaginatedList<T>(
val page: List<T>, val page: List<T>,
val hasNextPage: Boolean, val hasNextPage: Boolean
) )
const val PaginationFactor = 50 const val PaginationFactor = 50
@@ -25,5 +25,5 @@ data class SourceDataClass(
val isNsfw: Boolean, val isNsfw: Boolean,
/** A nicer version of [name] */ /** A nicer version of [name] */
val displayName: String, val displayName: String
) )
@@ -0,0 +1,21 @@
package suwayomi.tachidesk.manga.model.table
/*
* 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 org.jetbrains.exposed.dao.id.IntIdTable
import org.jetbrains.exposed.sql.ReferenceOption
import suwayomi.tachidesk.manga.model.table.CategoryMetaTable.ref
/**
* Metadata storage for clients, about Chapter with id == [ref].
*/
object CategoryMetaTable : IntIdTable() {
val key = varchar("key", 256)
val value = varchar("value", 4096)
val ref = reference("category_ref", CategoryTable, ReferenceOption.CASCADE)
}
@@ -9,6 +9,7 @@ package suwayomi.tachidesk.manga.model.table
import org.jetbrains.exposed.dao.id.IntIdTable 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.model.dataclass.CategoryDataClass import suwayomi.tachidesk.manga.model.dataclass.CategoryDataClass
object CategoryTable : IntIdTable() { object CategoryTable : IntIdTable() {
@@ -18,8 +19,9 @@ object CategoryTable : IntIdTable() {
} }
fun CategoryTable.toDataClass(categoryEntry: ResultRow) = CategoryDataClass( fun CategoryTable.toDataClass(categoryEntry: ResultRow) = CategoryDataClass(
categoryEntry[this.id].value, categoryEntry[id].value,
categoryEntry[order], categoryEntry[order],
categoryEntry[name], categoryEntry[name],
categoryEntry[isDefault], categoryEntry[isDefault],
Category.getCategoryMetaMap(categoryEntry[id].value)
) )
@@ -12,7 +12,7 @@ import org.jetbrains.exposed.sql.ReferenceOption
import suwayomi.tachidesk.manga.model.table.ChapterMetaTable.ref import suwayomi.tachidesk.manga.model.table.ChapterMetaTable.ref
/** /**
* Meta data storage for clients, about Chapter with id == [ref]. * Metadata storage for clients, about Chapter with id == [ref].
* *
* For example, if you added reader mode(with the key juiReaderMode) such as webtoon to a manga object, * For example, if you added reader mode(with the key juiReaderMode) such as webtoon to a manga object,
* this is what will show up when you request that manga from the api again * this is what will show up when you request that manga from the api again
@@ -38,6 +38,7 @@ object ChapterTable : IntIdTable() {
fun ChapterTable.toDataClass(chapterEntry: ResultRow) = fun ChapterTable.toDataClass(chapterEntry: ResultRow) =
ChapterDataClass( ChapterDataClass(
chapterEntry[id].value,
chapterEntry[url], chapterEntry[url],
chapterEntry[name], chapterEntry[name],
chapterEntry[date_upload], chapterEntry[date_upload],
@@ -53,5 +54,5 @@ fun ChapterTable.toDataClass(chapterEntry: ResultRow) =
chapterEntry[isDownloaded], chapterEntry[isDownloaded],
chapterEntry[pageCount], chapterEntry[pageCount],
transaction { ChapterTable.select { manga eq chapterEntry[manga].value }.count().toInt() }, transaction { ChapterTable.select { manga eq chapterEntry[manga].value }.count().toInt() },
getChapterMetaMap(chapterEntry[id]), getChapterMetaMap(chapterEntry[id])
) )
@@ -12,7 +12,7 @@ import org.jetbrains.exposed.sql.ReferenceOption
import suwayomi.tachidesk.manga.model.table.MangaMetaTable.ref import suwayomi.tachidesk.manga.model.table.MangaMetaTable.ref
/** /**
* Meta data storage for clients, about Manga with id == [ref]. * Metadata storage for clients, about Manga with id == [ref].
* *
* For example, if you added reader mode(with the key juiReaderMode) such as webtoon to a manga object, * For example, if you added reader mode(with the key juiReaderMode) such as webtoon to a manga object,
* this is what will show up when you request that manga from the api again * this is what will show up when you request that manga from the api again
@@ -38,6 +38,9 @@ object MangaTable : IntIdTable() {
/** the real url of a manga used for the "open in WebView" feature */ /** the real url of a manga used for the "open in WebView" feature */
val realUrl = varchar("real_url", 2048).nullable() val realUrl = varchar("real_url", 2048).nullable()
val lastFetchedAt = long("last_fetched_at").default(0)
val chaptersLastFetchedAt = long("chapters_last_fetched_at").default(0)
} }
fun MangaTable.toDataClass(mangaEntry: ResultRow) = fun MangaTable.toDataClass(mangaEntry: ResultRow) =
@@ -60,6 +63,8 @@ fun MangaTable.toDataClass(mangaEntry: ResultRow) =
mangaEntry[inLibraryAt], mangaEntry[inLibraryAt],
meta = getMangaMetaMap(mangaEntry[id].value), meta = getMangaMetaMap(mangaEntry[id].value),
realUrl = mangaEntry[realUrl], realUrl = mangaEntry[realUrl],
lastFetchedAt = mangaEntry[lastFetchedAt],
chaptersLastFetchedAt = mangaEntry[chaptersLastFetchedAt]
) )
enum class MangaStatus(val value: Int) { enum class MangaStatus(val value: Int) {
@@ -28,6 +28,7 @@ import suwayomi.tachidesk.manga.MangaAPI
import suwayomi.tachidesk.server.util.Browser import suwayomi.tachidesk.server.util.Browser
import suwayomi.tachidesk.server.util.setupWebInterface import suwayomi.tachidesk.server.util.setupWebInterface
import java.io.IOException import java.io.IOException
import java.lang.IllegalArgumentException
import java.util.concurrent.CompletableFuture import java.util.concurrent.CompletableFuture
import kotlin.concurrent.thread import kotlin.concurrent.thread
@@ -97,6 +98,12 @@ object JavalinSetup {
ctx.result(e.message ?: "Internal Server Error") ctx.result(e.message ?: "Internal Server Error")
} }
app.exception(IllegalArgumentException::class.java) { e, ctx ->
logger.error("IllegalArgumentException while handling the request", e)
ctx.status(400)
ctx.result(e.message ?: "Bad Request")
}
app.routes { app.routes {
path("api/v1/") { path("api/v1/") {
GlobalAPI.defineEndpoints() GlobalAPI.defineEndpoints()
@@ -85,7 +85,7 @@ fun applicationSetup() {
applicationDirs.extensionsRoot + "/icon", applicationDirs.extensionsRoot + "/icon",
applicationDirs.thumbnailsRoot, applicationDirs.thumbnailsRoot,
applicationDirs.mangaDownloadsRoot, applicationDirs.mangaDownloadsRoot,
applicationDirs.localMangaRoot, applicationDirs.localMangaRoot
).forEach { ).forEach {
File(it).mkdirs() File(it).mkdirs()
} }
@@ -9,7 +9,9 @@ package suwayomi.tachidesk.server.database
import de.neonew.exposed.migrations.loadMigrationsFrom import de.neonew.exposed.migrations.loadMigrationsFrom
import de.neonew.exposed.migrations.runMigrations import de.neonew.exposed.migrations.runMigrations
import mu.KotlinLogging
import org.jetbrains.exposed.sql.Database import org.jetbrains.exposed.sql.Database
import org.jetbrains.exposed.sql.DatabaseConfig
import org.kodein.di.DI import org.kodein.di.DI
import org.kodein.di.conf.global import org.kodein.di.conf.global
import org.kodein.di.instance import org.kodein.di.instance
@@ -17,14 +19,26 @@ import suwayomi.tachidesk.server.ApplicationDirs
import suwayomi.tachidesk.server.ServerConfig import suwayomi.tachidesk.server.ServerConfig
object DBManager { object DBManager {
val db by lazy { val db by lazy {
val applicationDirs by DI.global.instance<ApplicationDirs>() val applicationDirs by DI.global.instance<ApplicationDirs>()
Database.connect("jdbc:h2:${applicationDirs.dataRoot}/database", "org.h2.Driver") Database.connect(
"jdbc:h2:${applicationDirs.dataRoot}/database",
"org.h2.Driver",
databaseConfig = DatabaseConfig {
useNestedTransactions = true
}
)
} }
} }
private val logger = KotlinLogging.logger {}
fun databaseUp(db: Database = DBManager.db) { fun databaseUp(db: Database = DBManager.db) {
db.useNestedTransactions = true // call db to initialize the lazy object
logger.info {
"Using ${db.vendor} database version ${db.version}"
}
val migrations = loadMigrationsFrom("suwayomi.tachidesk.server.database.migration", ServerConfig::class.java) val migrations = loadMigrationsFrom("suwayomi.tachidesk.server.database.migration", ServerConfig::class.java)
runMigrations(migrations) runMigrations(migrations)
@@ -13,8 +13,8 @@ import org.jetbrains.exposed.dao.id.IdTable
import org.jetbrains.exposed.dao.id.IntIdTable import org.jetbrains.exposed.dao.id.IntIdTable
import org.jetbrains.exposed.sql.Table import org.jetbrains.exposed.sql.Table
@Suppress("ClassName", "unused")
/** initial migration, create all tables */ /** initial migration, create all tables */
@Suppress("ClassName", "unused")
class M0001_Initial : AddTableMigration() { class M0001_Initial : AddTableMigration() {
private class ExtensionTable : IntIdTable() { private class ExtensionTable : IntIdTable() {
init { init {
@@ -128,7 +128,7 @@ class M0001_Initial : AddTableMigration() {
chapterTable, chapterTable,
pageTable, pageTable,
categoryTable, categoryTable,
categoryMangaTable, categoryMangaTable
) )
} }
} }

Some files were not shown because too many files have changed in this diff Show More