Compare commits

...

75 Commits

Author SHA1 Message Date
Aria Moradi 4cc96de806 v0.7.0
CI Publish / Validate Gradle Wrapper (push) Successful in 12s
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
2023-02-12 23:08:05 +03:30
Aria Moradi d27ef12039 stop using depricated API 2023-02-12 23:03:45 +03:30
Aria Moradi f3c2ee4c40 re-order config options 2023-02-12 22:50:06 +03:30
akabhirav 555f73b478 Download as CBZ (#490)
* Download as CBZ

* Better error handling for zips (code review changes)
2023-02-12 22:45:58 +03:30
akabhirav 544bf2ea21 fix Page index issues for some providers (#491) 2023-02-12 18:34:30 +03:30
Aria Moradi 54bbb5e384 rethink image cache (#498) 2023-02-12 18:33:36 +03:30
akabhirav b10062c73d Decouple Cache and Download behaviour (#493)
* Separate cache dir from download dir

* Move downloader logic outside of caching/image download logic

* remove unnecessary method duplication

* moved download logic inside download provider

* optimize and handle partial downloads

* made code review changes
2023-02-12 18:26:26 +03:30
Aria Moradi a027d6df1b disable playwright for v0.6.7 2023-02-12 14:35:11 +03:30
Mitchell Syer 926a53a4b0 add support for Extensions Lib 1.4 (#496)
* Support extensions lib 1.4

* Fix build

* Support UpdateStrategy

* Update extension lib min/max to match Tachiyomi

* Use HttpSource.getMangaUrl and add Chapter.realUrl
2023-02-12 05:49:32 +03:30
Mitchell Syer 406cb46170 Fix logging and update system try (#488)
- Dorkbox SystemTray now automatically adds its shutdown hook, and removed the manual function
2023-02-05 22:21:35 +03:30
akabhirav acc58dc892 Fixe Dex2Jar and dorkbox dependency issues (#487)
Co-authored-by: akxer <>
2023-02-05 18:43:00 +03:30
Aria Moradi 55894c22a4 upgrade dorkbox stuff 2023-01-15 12:46:50 +03:30
Aria Moradi 476b10b862 update gradle version 2023-01-13 13:05:50 +03:30
Aria Moradi 3cbbe446ab fix ambiguous reference issue on JDK 13+ 2023-01-11 15:46:02 +03:30
Mitchell Syer 4cf7512ee0 Improve Playwright handling (#479)
* Improve playwright

* Move DriverJar.java and Chromium.kt
2023-01-08 01:17:53 +03:30
Mitchell Syer ee8ec460a1 Improve Gradle Configuration (#478)
* Improve gradle configuration

* Formatting fix

Co-authored-by: Aria Moradi <aria.moradi007@gmail.com>

* Improve asm version lock description

Co-authored-by: Aria Moradi <aria.moradi007@gmail.com>

* Improve image decoder description

Co-authored-by: Aria Moradi <aria.moradi007@gmail.com>

Co-authored-by: Aria Moradi <aria.moradi007@gmail.com>
2023-01-07 20:07:53 +03:30
Aria Moradi deecab3cca fix typo 2023-01-03 13:39:15 +03:30
Aria Moradi d2f5c1a195 link to Tachiyomi section 2023-01-03 13:38:20 +03:30
Aria Moradi dba77e26a3 Clarify and Update 2023-01-03 13:30:58 +03:30
Aria Moradi fa48bafbc6 Clarify and Update 2023-01-03 13:28:54 +03:30
Aria Moradi 73c48694c7 remove possibly misleading sentence 2023-01-03 13:24:42 +03:30
Aria Moradi 0ff89d039b fix CategoryMetaTable reference to CategoryTable (#473) 2023-01-03 13:19:44 +03:30
Aria Moradi 7a7081ee13 Update CategoryMetaTable.kt 2023-01-02 18:23:01 +03:30
Aria Moradi 874aaf4e93 fix when playwright fails on providing a UA 2022-12-28 17:14:12 +03:30
Mitchell Syer ebf076d9f6 Use extension list fallback if extensions fail to fetch (#469) 2022-12-25 11:15:37 +03:30
Mitchell Syer 073a041d4c Add better manga thumbnail handling (#465) 2022-12-23 00:43:36 +03:30
Mahor 96a9b4dabd Fix debian release (#463)
* Update debhelper-compat to 13

* Re-enable debian release

* Re-enable debian release
2022-12-16 00:21:42 +03:30
Aria Moradi 8e4cdf2386 disable deb release 2022-12-11 20:05:42 +03:30
Mitchell Syer ab4d925a5a Get Playwright working (#462)
* Get Playwright working with ShadowJar

* Set system driver implementation

* Minor cleanup

* Fix run gradle task and re-add some removed code

* No need to get the FS if it already exists

* use java implementation

Co-authored-by: Aria Moradi <aria.moradi007@gmail.com>
2022-12-09 21:47:26 +03:30
Zero d9c6f52e21 Basic android.graphics Rect and Canvas implementation (#461)
Some extensions use more Canvas methods, but they don't
really seem to get that far yet, all the others I was
able to test seem to work now.
2022-12-06 07:48:40 +03:30
Zero 0a748cd53b implementation of android.graphics.BitmapFactory (#460)
Only what was needed is implemented, compression method is still untested.
2022-12-05 19:21:16 +03:30
Aria Moradi 07314ef018 get default User Agent from WebView (#457)
* get default User Agent from WebView

* make sure to close browser

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

Co-authored-by: Mitchell Syer <Mitchellptbo@gmail.com>
2022-12-04 21:21:19 +03:30
Aria Moradi 5eaebf678f fix regex 2022-12-04 13:03:23 +03:30
Aria Moradi 80fbfa60de better description 2022-12-04 12:59:33 +03:30
Aria Moradi fbbcc9e9b6 update issue mod 2022-12-04 12:50:03 +03:30
Aria Moradi f47dc6b9de WebView based cloudflare interceptor (#456)
* WebView based cloudflare interceptor

ported https://github.com/vvanglro/cf-clearance to kotlin

* code clean up

* Forgot to commit these

* Get ResolveWithWebView working
1. Make sure to .use all closeable resources
2. Use 10 seconds instead of 1 second for waiting for cloudflare(this was the most probable issue)
3. Use Extension UA when possible
4. Minor cleanup of logging

* rewrite and refactor

Co-authored-by: Syer10 <syer10@users.noreply.github.com>
2022-12-04 12:08:54 +03:30
Aria Moradi 5f8e74f017 fix Changelog typos 2022-11-26 20:45:51 +03:30
Aria Moradi 8c1ca0ac7e add Chagelog TL;DR 2022-11-26 20:37:09 +03:30
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
158 changed files with 3907 additions and 956 deletions
@@ -1,24 +1,35 @@
name: Issue closer name: Issue moderator
on: on:
issues: issues:
types: [opened, edited, reopened] types: [opened, edited, reopened]
issue_comment:
types: [created]
jobs: jobs:
autoclose: autoclose:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Autoclose issues - name: Moderate issues
uses: arkon/issue-closer-action@v3.0 uses: tachiyomiorg/issue-moderator-action@v1
with: with:
repo-token: ${{ github.token }} repo-token: ${{ github.token }}
rules: | duplicate-check-enabled: true
duplicate-check-label: Source request
existing-check-enabled: true
existing-check-label: Source request
auto-close-rules: |
[ [
{ {
"type": "title", "type": "title",
"regex": ".*<short description>*", "regex": ".*<short description>.*",
"message": "You did not fill out the description in the title" "message": "You did not fill out the description in the title"
}, },
{
"type": "title",
"regex": ".*(<|>)+.*",
"message": "You did not remove Angle brackets(< and >) from the title"
},
{ {
"type": "body", "type": "body",
"regex": ".*DELETE THIS SECTION IF YOU HAVE READ AND ACKNOWLEDGED IT.*", "regex": ".*DELETE THIS SECTION IF YOU HAVE READ AND ACKNOWLEDGED IT.*",
@@ -26,7 +37,7 @@ jobs:
}, },
{ {
"type": "body", "type": "body",
"regex": "(Tachidesk version|Server Operating System|Server Desktop Environment|Server JVM version|Client Operating System|Client Web Browser):.*(\\(Example:|<usually).*", "regex": ".*(Tachidesk version|Server Operating System|Server Desktop Environment|Server JVM version|Client Operating System|Client Web Browser):.*(\\(Example:|<usually).*",
"message": "The requested information was not filled out" "message": "The requested information was not filled out"
}, },
{ {
+1 -1
View File
@@ -6,7 +6,7 @@ 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$' installers-regex: '.*x64.msi$'
+1 -1
View File
@@ -2,7 +2,7 @@
.gradle .gradle
.idea .idea
gradle.properties gradle.properties
.fleet
# But we need these # But we need these
!.idea/runConfigurations !.idea/runConfigurations
+12
View File
@@ -0,0 +1,12 @@
@Suppress("DSL_SCOPE_VIOLATION")
plugins {
id(libs.plugins.kotlin.jvm.get().pluginId)
id(libs.plugins.kotlin.serialization.get().pluginId)
id(libs.plugins.kotlinter.get().pluginId)
}
dependencies {
// Shared
implementation(libs.bundles.shared)
testImplementation(libs.bundles.sharedTest)
}
+22 -11
View File
@@ -1,28 +1,39 @@
@Suppress("DSL_SCOPE_VIOLATION")
plugins {
id(libs.plugins.kotlin.jvm.get().pluginId)
id(libs.plugins.kotlin.serialization.get().pluginId)
id(libs.plugins.kotlinter.get().pluginId)
}
dependencies { dependencies {
// Shared
implementation(libs.bundles.shared)
testImplementation(libs.bundles.sharedTest)
// Android stub library // Android stub library
implementation("com.github.Suwayomi:android-jar:1.0.0") implementation(libs.android.stubs)
// XML // XML
compileOnly("xmlpull:xmlpull:1.1.3.4a") compileOnly(libs.xmlpull)
// Config API // Config API
implementation(project(":AndroidCompat:Config")) implementation(projects.androidCompat.config)
// APK sig verifier // APK sig verifier
compileOnly("com.android.tools.build:apksig:7.1.0-beta05") compileOnly(libs.apksig)
// AndroidX annotations // AndroidX annotations
compileOnly("androidx.annotation:annotation:1.3.0") compileOnly(libs.android.annotations)
// substitute for duktape-android // substitute for duktape-android
implementation("org.mozilla:rhino-runtime:1.7.14") // slimmer version of 'org.mozilla:rhino' implementation(libs.bundles.rhino)
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" implementation(libs.bundles.settings)
implementation("com.russhwolf:multiplatform-settings-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(libs.icu4j)
// OpenJDK lacks native JPEG encoder and native WEBP decoder
implementation(libs.bundles.twelvemonkeys)
} }
@@ -0,0 +1,129 @@
package android.graphics;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.util.Iterator;
import javax.imageio.IIOImage;
import javax.imageio.ImageIO;
import javax.imageio.ImageWriteParam;
import javax.imageio.ImageWriter;
import javax.imageio.stream.ImageOutputStream;
public final class Bitmap {
private int width;
private int height;
private BufferedImage image;
public Bitmap(BufferedImage image) {
this.image = image;
this.width = image.getWidth();
this.height = image.getHeight();
}
public BufferedImage getImage() {
return image;
}
public int getHeight() {
return height;
}
public int getWidth() {
return width;
}
public enum CompressFormat {
JPEG (0),
PNG (1),
WEBP (2),
WEBP_LOSSY (3),
WEBP_LOSSLESS (4);
CompressFormat(int nativeInt) {
this.nativeInt = nativeInt;
}
final int nativeInt;
}
public enum Config {
ALPHA_8(1),
RGB_565(3),
ARGB_4444(4),
ARGB_8888(5),
RGBA_F16(6),
HARDWARE(7),
RGBA_1010102(8);
final int nativeInt;
private static Config sConfigs[] = {
null, ALPHA_8, null, RGB_565, ARGB_4444, ARGB_8888, RGBA_F16, HARDWARE, RGBA_1010102
};
Config(int ni) {
this.nativeInt = ni;
}
static Config nativeToConfig(int ni) {
return sConfigs[ni];
}
}
public static Bitmap createBitmap(int width, int height, Config config) {
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
return new Bitmap(image);
}
public boolean compress(CompressFormat format, int quality, OutputStream stream) {
if (stream == null) {
throw new NullPointerException();
}
if (quality < 0 || quality > 100) {
throw new IllegalArgumentException("quality must be 0..100");
}
float qualityFloat = ((float) quality) / 100;
String formatString = "";
if (format == Bitmap.CompressFormat.PNG) {
formatString = "png";
} else if (format == Bitmap.CompressFormat.JPEG) {
formatString = "jpg";
} else {
throw new IllegalArgumentException("unsupported compression format!");
}
Iterator<ImageWriter> writers = ImageIO.getImageWritersByFormatName(formatString);
if (!writers.hasNext()) {
throw new IllegalStateException("no image writers found for this format!");
}
ImageWriter writer = (ImageWriter) writers.next();
ImageOutputStream ios;
try {
ios = ImageIO.createImageOutputStream(stream);
} catch (IOException ex) {
throw new RuntimeException(ex);
}
writer.setOutput(ios);
ImageWriteParam param = writer.getDefaultWriteParam();
if (formatString == "jpg") {
param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
param.setCompressionQuality(qualityFloat);
}
try {
writer.write(null, new IIOImage(image, null, null), param);
ios.close();
writer.dispose();
} catch (IOException ex) {
throw new RuntimeException(ex);
}
return true;
}
}
@@ -0,0 +1,51 @@
package android.graphics;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.io.IOException;
import java.util.Iterator;
import javax.imageio.ImageIO;
import javax.imageio.ImageReader;
import javax.imageio.stream.ImageInputStream;
public class BitmapFactory {
public static Bitmap decodeStream(InputStream inputStream) {
Bitmap bitmap = null;
try {
ImageInputStream imageInputStream = ImageIO.createImageInputStream(inputStream);
Iterator<ImageReader> imageReaders = ImageIO.getImageReaders(imageInputStream);
if (!imageReaders.hasNext()) {
throw new IllegalArgumentException("no reader for image");
}
ImageReader imageReader = imageReaders.next();
imageReader.setInput(imageInputStream);
BufferedImage image = imageReader.read(0, imageReader.getDefaultReadParam());
bitmap = new Bitmap(image);
imageReader.dispose();
} catch (IOException ex) {
throw new RuntimeException(ex);
}
return bitmap;
}
public static Bitmap decodeByteArray(byte[] data, int offset, int length) {
Bitmap bitmap = null;
ByteArrayInputStream byteArrayStream = new ByteArrayInputStream(data);
try {
BufferedImage image = ImageIO.read(byteArrayStream);
bitmap = new Bitmap(image);
} catch (IOException ex) {
throw new RuntimeException(ex);
}
return bitmap;
}
}
@@ -0,0 +1,21 @@
package android.graphics;
import java.awt.Graphics2D;
import java.awt.image.BufferedImage;
import javax.imageio.ImageIO;
public final class Canvas {
private BufferedImage canvasImage;
private Graphics2D canvas;
public Canvas(Bitmap bitmap) {
canvasImage = bitmap.getImage();
canvas = canvasImage.createGraphics();
}
public void drawBitmap(Bitmap sourceBitmap, Rect src, Rect dst, Paint paint) {
BufferedImage sourceImage = sourceBitmap.getImage();
BufferedImage sourceImageCropped = sourceImage.getSubimage(src.left, src.top, src.getWidth(), src.getHeight());
canvas.drawImage(sourceImageCropped, null, dst.left, dst.top);
}
}
@@ -0,0 +1,122 @@
package android.graphics;
import android.os.Parcel;
import android.os.Parcelable;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public final class Rect {
int left;
int top;
int right;
int bottom;
private static final class UnflattenHelper {
private static final Pattern FLATTENED_PATTERN = Pattern.compile(
"(-?\\d+) (-?\\d+) (-?\\d+) (-?\\d+)");
static Matcher getMatcher(String str) {
return FLATTENED_PATTERN.matcher(str);
}
}
public Rect() {
}
public Rect(int left, int top, int right, int bottom) {
this.left = left;
this.top = top;
this.right = right;
this.bottom = bottom;
}
public Rect(Rect r) {
if (r == null) {
this.left = 0;
this.top = 0;
this.right = 0;
this.bottom = 0;
} else {
this.left = left;
this.top = top;
this.right = right;
this.bottom = bottom;
}
}
public final int getWidth() {
return right - left;
}
public final int getHeight() {
return bottom - top;
}
public static Rect unflattenFromString(String str) {
if (str.isEmpty()) {
return null;
}
Matcher matcher = UnflattenHelper.getMatcher(str);
if (!matcher.matches()) {
return null;
}
return new Rect(Integer.parseInt(matcher.group(1)),
Integer.parseInt(matcher.group(2)),
Integer.parseInt(matcher.group(3)),
Integer.parseInt(matcher.group(4)));
}
public String toShortString() {
return toShortString(new StringBuilder(32));
}
public String toShortString(StringBuilder sb) {
sb.setLength(0);
sb.append('['); sb.append(left); sb.append(',');
sb.append(top); sb.append("]["); sb.append(right);
sb.append(','); sb.append(bottom); sb.append(']');
return sb.toString();
}
public String flattenToString() {
StringBuilder sb = new StringBuilder(32);
sb.append(left);
sb.append(' ');
sb.append(top);
sb.append(' ');
sb.append(right);
sb.append(' ');
sb.append(bottom);
return sb.toString();
}
public void writeToParcel(Parcel out, int flags) {
out.writeInt(left);
out.writeInt(top);
out.writeInt(right);
out.writeInt(bottom);
}
public static final Parcelable.Creator<Rect> CREATOR = new Parcelable.Creator<Rect>() {
@Override
public Rect createFromParcel(Parcel in) {
Rect r = new Rect();
r.readFromParcel(in);
return r;
}
@Override
public Rect[] newArray(int size) {
return new Rect[size];
}
};
public void readFromParcel(Parcel in) {
left = in.readInt();
top = in.readInt();
right = in.readInt();
bottom = in.readInt();
}
}
@@ -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? {
+78 -1
View File
@@ -1,3 +1,80 @@
# Server: v0.6.6 + WebUI: r963
## TL;DR
- Batch actions for chapters
- Improved the downloader
- WebUI changes:
- Support for chapter actions
- a lot of code cleanup
- some bugfixes
## 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) by @vedantmgoyal2009)
- (r1119) fix jre path([#396](https://github.com/Suwayomi/Tachidesk-Server/pull/396) by @vedantmgoyal2009)
- (r1120) Fix deb package ([#397](https://github.com/Suwayomi/Tachidesk-Server/pull/397) by @mahor1221)
- (r1121) bump version (by @AriaMoradi)
- (r1122) Update Changelog (by @AriaMoradi)
- (r1123) Add libc++-dev ([#405](https://github.com/Suwayomi/Tachidesk-Server/pull/405) by @mahor1221)
- (r1124) Revert back to correct way of handling jre_dir ([#408](https://github.com/Suwayomi/Tachidesk-Server/pull/408) by @mahor1221)
- (r1125) Update winget.yml ([#410](https://github.com/Suwayomi/Tachidesk-Server/pull/410) by @vedantmgoyal2009)
- (r1126) Remove support for Sorayomi web interface ([#414](https://github.com/Suwayomi/Tachidesk-Server/pull/414) by @marcoebbinghaus)
- (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) by @xhzhe)
- (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) by @meta-boy)
- (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 # Server: v0.6.5 + WebUI: r946
## TL;DR ## TL;DR
- Fixed Windows bundler - Fixed Windows bundler
@@ -10,7 +87,7 @@
- (r1117) 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) - (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) - (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) - (r1120) Fix deb package ([#397](https://github.com/Suwayomi/Tachidesk-Server/pull/397) by @mahor1221)
- (r1121) bump version (by @AriaMoradi) - (r1121) bump version (by @AriaMoradi)
## Tachidesk-WebUI Changelog ## Tachidesk-WebUI Changelog
+8 -11
View File
@@ -34,25 +34,22 @@ A free and open source manga reader server that runs extensions built for [Tachi
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.
`Tachidesk` is a general term used to describe the combination of Tachidesk-Server(this project) and one of our clients.
Think of it roughly like the concept of "distribution" in GNU/Linux distributions, in which Linux(Tachidesk-Server) is the kernel and the difference is which desktop environment(Tachidesk client) you get with it.
Tachidesk-Server is as multi-platform as you can get. Any platform that runs java and/or has a modern browser can run it. This includes Windows, Linux, macOS, chrome OS, etc. Follow [Downloading and Running the app](#downloading-and-running-the-app) for installation instructions. Tachidesk-Server is as multi-platform as you can get. Any platform that runs java and/or has a modern browser can run it. This includes Windows, Linux, macOS, chrome OS, etc. Follow [Downloading and Running the app](#downloading-and-running-the-app) for installation instructions.
Ability to sync with Tachiyomi is a planned feature. Ability to sync with Tachiyomi is a planned feature, for more info look [here](#syncing-with-tachiyomi).
# Tachidesk client projects # Tachidesk client projects
**You need a client/user interface app as a front-end for Tachidesk-Server, if you Directly Download Tachidesk-Server you'll get a bundled version of [Tachidesk-WebUI](https://github.com/Suwayomi/Tachidesk-WebUI) with it.** **You need a client/user interface app as a front-end for Tachidesk-Server, if you [Directly Download Tachidesk-Server](https://github.com/Suwayomi/Tachidesk-Server/releases/latest) you'll get a bundled version of [Tachidesk-WebUI](https://github.com/Suwayomi/Tachidesk-WebUI) with it.**
Here's a list of known clients/user interfaces for Tachidesk-Server: 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 ships with by default.
- [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), feature support is basic.
- [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 with a User Inerface inspired by 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.
- [Tachidesk-GTK](https://github.com/mahor1221/Tachidesk-GTK): A native Rust/GTK desktop client, in super early stage of development. - [Tachidesk-GTK](https://github.com/mahor1221/Tachidesk-GTK): A native Rust/GTK desktop client.
## Is this application usable? Should I test it? ## Is this application usable? Should I test it?
Here is a list of current features: Here is a list of current features:
@@ -85,7 +82,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.
+16 -80
View File
@@ -1,12 +1,14 @@
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile
import org.jmailen.gradle.kotlinter.tasks.FormatTask import org.jmailen.gradle.kotlinter.tasks.FormatTask
import org.jmailen.gradle.kotlinter.tasks.LintTask import org.jmailen.gradle.kotlinter.tasks.LintTask
@Suppress("DSL_SCOPE_VIOLATION")
plugins { plugins {
kotlin("jvm") version kotlinVersion alias(libs.plugins.kotlin.jvm)
kotlin("plugin.serialization") version kotlinVersion alias(libs.plugins.kotlin.serialization)
id("org.jmailen.kotlinter") version "3.8.0" alias(libs.plugins.kotlinter)
id("com.github.gmazzo.buildconfig") version "3.0.3" apply false alias(libs.plugins.buildconfig) apply false
alias(libs.plugins.download)
} }
allprojects { allprojects {
@@ -17,38 +19,24 @@ allprojects {
repositories { repositories {
mavenCentral() mavenCentral()
google() google()
maven("https://jitpack.io")
maven("https://github.com/Suwayomi/Tachidesk-Server/raw/android-jar/") maven("https://github.com/Suwayomi/Tachidesk-Server/raw/android-jar/")
maven("https://jitpack.io")
} }
} }
val projects = listOf( subprojects {
project(":AndroidCompat"), plugins.withType<JavaPlugin> {
project(":AndroidCompat:Config"), extensions.configure<JavaPluginExtension> {
project(":server") sourceCompatibility = JavaVersion.VERSION_1_8
) targetCompatibility = JavaVersion.VERSION_1_8
}
configure(projects) {
apply(plugin = "org.jetbrains.kotlin.jvm")
apply(plugin = "org.jetbrains.kotlin.plugin.serialization")
apply(plugin = "org.jmailen.kotlinter")
java {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
} }
tasks { tasks {
withType<KotlinCompile> { withType<KotlinJvmCompile> {
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",
)
} }
} }
@@ -60,56 +48,4 @@ configure(projects) {
source(files("src/kotlin")) source(files("src/kotlin"))
} }
} }
dependencies {
// Kotlin
implementation(kotlin("stdlib-jdk8"))
implementation(kotlin("reflect"))
testImplementation(kotlin("test-junit5"))
// coroutines
val coroutinesVersion = "1.6.0"
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-jdk8:$coroutinesVersion")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion")
val kotlinSerializationVersion = "1.3.2"
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinSerializationVersion")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-protobuf:$kotlinSerializationVersion")
// Dependency Injection
implementation("org.kodein.di:kodein-di-conf-jvm:7.10.0")
// Logging
implementation("org.slf4j:slf4j-api:1.7.32")
implementation("ch.qos.logback:logback-classic:1.2.6")
implementation("io.github.microutils:kotlin-logging:2.1.21")
// ReactiveX
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
implementation("org.jsoup:jsoup:1.14.3")
// dependency of :AndroidCompat:Config
implementation("com.typesafe:config:1.4.1")
implementation("io.github.config4k:config4k:0.4.2")
// to get application content root
implementation("net.harawata:appdirs:1.2.1")
// dex2jar
val dex2jarVersion = "v35"
implementation("com.github.ThexXTURBOXx.dex2jar:dex-translator:$dex2jarVersion")
implementation("com.github.ThexXTURBOXx.dex2jar:dex-tools:$dex2jarVersion")
// APK parser
implementation("net.dongliu:apk-parser:2.6.10")
// dependency both in AndroidCompat and server, version locked by javalin
implementation("com.fasterxml.jackson.core:jackson-annotations:2.12.4")
}
} }
+5 -8
View File
@@ -7,21 +7,18 @@ 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 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.5" val tachideskVersion = System.getenv("ProductVersion") ?: "v0.7.0"
val webUIRevisionTag = System.getenv("WebUIRevision") ?: "r946" val webUIRevisionTag = System.getenv("WebUIRevision") ?: "r983"
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 {
System.getenv("ProductRevision") ?: Runtime System.getenv("ProductRevision") ?: ProcessBuilder()
.getRuntime() .command("git", "rev-list", "HEAD", "--count")
.exec("git rev-list HEAD --count") .start()
.let { process -> .let { process ->
process.waitFor() process.waitFor()
val output = process.inputStream.use { val output = process.inputStream.use {
+216
View File
@@ -0,0 +1,216 @@
[versions]
kotlin = "1.8.0"
coroutines = "1.6.4"
serialization = "1.4.1"
okhttp = "5.0.0-alpha.11" # Major version is locked by Tachiyomi extensions
javalin = "4.6.6" # Javalin 5.0.0+ requires Java 11
jackson = "2.13.3" # jackson version locked by javalin, ref: `io.javalin.core.util.OptionalDependency`
exposed = "0.40.1"
dex2jar = "v59"
rhino = "1.7.14"
settings = "1.0.0-RC"
twelvemonkeys = "3.9.4"
playwright = "1.28.0"
[libraries]
# Kotlin
kotlin-stdlib-jdk8 = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk8", version.ref = "kotlin" }
kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" }
kotlin-test-junit5 = { module = "org.jetbrains.kotlin:kotlin-test-junit5", version.ref = "kotlin" }
# Coroutines
coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
coroutines-jdk8 = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-jdk8", version.ref = "coroutines" }
coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
# Serialization
serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" }
serialization-protobuf = { module = "org.jetbrains.kotlinx:kotlinx-serialization-protobuf", version.ref = "serialization" }
# Logging
slf4japi = "org.slf4j:slf4j-api:2.0.6"
logback = "ch.qos.logback:logback-classic:1.3.5"
kotlinlogging = "io.github.microutils:kotlin-logging:3.0.5"
# OkHttp
okhttp-core = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
okhttp-logging = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" }
okhttp-dnsoverhttps = { module = "com.squareup.okhttp3:okhttp-dnsoverhttps", version.ref = "okhttp" }
okio = "com.squareup.okio:okio:3.3.0"
# Javalin api
javalin-core = { module = "io.javalin:javalin", version.ref = "javalin" }
javalin-openapi = { module = "io.javalin:javalin-openapi", version.ref = "javalin" }
jackson-databind = { module = "com.fasterxml.jackson.core:jackson-databind", version.ref = "jackson" }
jackson-kotlin = { module = "com.fasterxml.jackson.module:jackson-module-kotlin", version.ref = "jackson" }
jackson-annotations = { module = "com.fasterxml.jackson.core:jackson-annotations", version.ref = "jackson" }
# Exposed ORM
exposed-core = { module = "org.jetbrains.exposed:exposed-core", version.ref = "exposed" }
exposed-dao = { module = "org.jetbrains.exposed:exposed-dao", version.ref = "exposed" }
exposed-jdbc = { module = "org.jetbrains.exposed:exposed-jdbc", version.ref = "exposed" }
exposed-javatime = { module = "org.jetbrains.exposed:exposed-java-time", version.ref = "exposed" }
h2 = "com.h2database:h2:1.4.200" # current database driver, can't update to h2 v2 without sql migration
# Exposed Migrations
exposed-migrations = "com.github.Suwayomi:exposed-migrations:3.2.0"
# Dependency Injection
kodein = "org.kodein.di:kodein-di-conf-jvm:7.15.0"
# tray icon
systemtray-core = "com.dorkbox:SystemTray:4.2.1"
systemtray-utils = "com.dorkbox:Utilities:1.39" # version locked by SystemTray
systemtray-desktop = "com.dorkbox:Desktop:1.0"
# dependencies of Tachiyomi extensions
injekt = "com.github.inorichi.injekt:injekt-core:65b0440"
rxjava = "io.reactivex:rxjava:1.3.8"
jsoup = "org.jsoup:jsoup:1.15.3"
# Config
config = "com.typesafe:config:1.4.2"
config4k = "io.github.config4k:config4k:0.5.0"
# Sort
sort = "com.github.gpanther:java-nat-sort:natural-comparator-1.1"
# Android stub library
android-stubs = "com.github.Suwayomi:android-jar:1.0.0"
# Asm modificiation
asm = "org.ow2.asm:asm:9.4" # version locked by Dex2Jar
dex2jar-translator = { module = "com.github.ThexXTURBOXx.dex2jar:dex-translator", version.ref = "dex2jar" }
dex2jar-tools = { module = "com.github.ThexXTURBOXx.dex2jar:dex-tools", version.ref = "dex2jar" }
# APK
apk-parser = "net.dongliu:apk-parser:2.6.10"
apksig = "com.android.tools.build:apksig:7.2.1"
# Xml
xmlpull = "xmlpull:xmlpull:1.1.3.4a"
# Disk & File
appdirs = "net.harawata:appdirs:1.2.1"
zip4j = "net.lingala.zip4j:zip4j:2.11.2"
junrar = "com.github.junrar:junrar:7.5.3"
# CloudflareInterceptor
playwright = { module = "com.microsoft.playwright:playwright", version.ref = "playwright" }
# AES/CBC/PKCS7Padding Cypher provider
bouncycastle = "org.bouncycastle:bcprov-jdk18on:1.72"
# AndroidX annotations
android-annotations = "androidx.annotation:annotation:1.5.0"
# Substitute for duktape-android
rhino-runtime = { module = "org.mozilla:rhino-runtime", version.ref = "rhino" } # slimmer version of 'org.mozilla:rhino'
rhino-engine = { module = "org.mozilla:rhino-engine", version.ref = "rhino" } # provides the same interface as 'javax.script' a.k.a Nashorn
# Settings
settings-core = { module = "com.russhwolf:multiplatform-settings-jvm", version.ref = "settings" }
settings-serialization = { module = "com.russhwolf:multiplatform-settings-serialization-jvm", version.ref = "settings" }
# ICU4J
icu4j = "com.ibm.icu:icu4j:72.1"
# Image Decoding implementation provider
twelvemonkeys-common-lang = { module = "com.twelvemonkeys.common:common-lang", version.ref = "twelvemonkeys" }
twelvemonkeys-common-io = { module = "com.twelvemonkeys.common:common-io", version.ref = "twelvemonkeys" }
twelvemonkeys-common-image = { module = "com.twelvemonkeys.common:common-image", version.ref = "twelvemonkeys" }
twelvemonkeys-imageio-core = { module = "com.twelvemonkeys.imageio:imageio-core", version.ref = "twelvemonkeys" }
twelvemonkeys-imageio-metadata = { module = "com.twelvemonkeys.imageio:imageio-metadata", version.ref = "twelvemonkeys" }
twelvemonkeys-imageio-jpeg = { module = "com.twelvemonkeys.imageio:imageio-jpeg", version.ref = "twelvemonkeys" }
twelvemonkeys-imageio-webp = { module = "com.twelvemonkeys.imageio:imageio-webp", version.ref = "twelvemonkeys" }
# Testing
mockk = "io.mockk:mockk:1.13.2"
[plugins]
# Kotlin
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin"}
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin"}
# Linter
kotlinter = { id = "org.jmailen.kotlinter", version = "3.12.0"}
# Build config
buildconfig = { id = "com.github.gmazzo.buildconfig", version = "3.1.0"}
# Download
download = { id = "de.undercouch.download", version = "5.3.0"}
# ShadowJar
shadowjar = { id = "com.github.johnrengelman.shadow", version = "7.1.2"}
[bundles]
shared = [
"kotlin-stdlib-jdk8",
"kotlin-reflect",
"coroutines-core",
"coroutines-jdk8",
"serialization-json",
"serialization-protobuf",
"kodein",
"slf4japi",
"logback",
"kotlinlogging",
"appdirs",
"rxjava",
"jsoup",
"config",
"config4k",
"dex2jar-translator",
"dex2jar-tools",
"apk-parser",
"jackson-annotations"
]
sharedTest = [
"kotlin-test-junit5",
"coroutines-test",
]
okhttp = [
"okhttp-core",
"okhttp-logging",
"okhttp-dnsoverhttps",
]
javalin = [
"javalin-core",
"javalin-openapi",
]
jackson = [
"jackson-databind",
"jackson-kotlin",
"jackson-annotations",
]
exposed = [
"exposed-core",
"exposed-dao",
"exposed-jdbc",
"exposed-javatime",
]
systemtray = [
"systemtray-core",
"systemtray-utils",
"systemtray-desktop"
]
rhino = [
"rhino-runtime",
"rhino-engine",
]
settings = [
"settings-core",
"settings-serialization",
]
twelvemonkeys = [
"twelvemonkeys-common-lang",
"twelvemonkeys-common-io",
"twelvemonkeys-common-image",
"twelvemonkeys-imageio-core",
"twelvemonkeys-imageio-metadata",
"twelvemonkeys-imageio-jpeg",
"twelvemonkeys-imageio-webp",
]
Binary file not shown.
+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.6-bin.zip
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
Vendored
+11 -5
View File
@@ -1,7 +1,7 @@
#!/bin/sh #!/bin/sh
# #
# Copyright © 2015-2021 the original authors. # Copyright © 2015-2021 the original authors.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -32,10 +32,10 @@
# Busybox and similar reduced shells will NOT work, because this script # Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features: # requires all of these POSIX shell features:
# * functions; # * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», # * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»; # «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»; # * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit». # * various built-in commands including «command», «set», and «ulimit».
# #
# Important for patching: # Important for patching:
# #
@@ -205,6 +205,12 @@ set -- \
org.gradle.wrapper.GradleWrapperMain \ org.gradle.wrapper.GradleWrapperMain \
"$@" "$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args. # Use "xargs" to parse quoted args.
# #
# With -n1 it outputs one arg per line, with the quotes and backslashes removed. # With -n1 it outputs one arg per line, with the quotes and backslashes removed.
Vendored
+8 -6
View File
@@ -14,7 +14,7 @@
@rem limitations under the License. @rem limitations under the License.
@rem @rem
@if "%DEBUG%" == "" @echo off @if "%DEBUG%"=="" @echo off
@rem ########################################################################## @rem ##########################################################################
@rem @rem
@rem Gradle startup script for Windows @rem Gradle startup script for Windows
@@ -25,7 +25,7 @@
if "%OS%"=="Windows_NT" setlocal if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0 set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=. if "%DIRNAME%"=="" set DIRNAME=.
set APP_BASE_NAME=%~n0 set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME% set APP_HOME=%DIRNAME%
@@ -40,7 +40,7 @@ if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1 %JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute if %ERRORLEVEL% equ 0 goto execute
echo. echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
@@ -75,13 +75,15 @@ set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
:end :end
@rem End local scope for the variables with windows NT shell @rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd if %ERRORLEVEL% equ 0 goto mainEnd
:fail :fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code! rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 set EXIT_CODE=%ERRORLEVEL%
exit /b 1 if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd :mainEnd
if "%OS%"=="Windows_NT" endlocal if "%OS%"=="Windows_NT" endlocal
+40 -10
View File
@@ -26,6 +26,8 @@ main() {
set -- "${POSITIONAL_ARGS[@]}" set -- "${POSITIONAL_ARGS[@]}"
OS="$1" OS="$1"
PLAYWRIGHT_VERSION="$(cat gradle/libs.versions.toml | grep -oP "playwright = \"\K([0-9\.]*)(?=\")")"
PLAYWRIGHT_REVISION="$(curl --silent "https://raw.githubusercontent.com/microsoft/playwright/v$PLAYWRIGHT_VERSION/packages/playwright-core/browsers.json" 2>&1 | grep -ozP "\"name\": \"chromium\",\n *\"revision\": \"\K[0-9]*")"
JAR="$(ls server/build/*.jar | tail -n1)" JAR="$(ls server/build/*.jar | tail -n1)"
RELEASE_NAME="$(echo "${JAR%.*}" | xargs basename)-$OS" RELEASE_NAME="$(echo "${JAR%.*}" | xargs basename)-$OS"
RELEASE_VERSION="$(tmp="${JAR%-*}"; echo "${tmp##*-}" | tr -d v)" RELEASE_VERSION="$(tmp="${JAR%-*}"; echo "${tmp##*-}" | tr -d v)"
@@ -50,44 +52,64 @@ 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
PLAYWRIGHT_PLATFORM="linux"
setup_playwright
RELEASE="$RELEASE_NAME.tar.gz" RELEASE="$RELEASE_NAME.tar.gz"
make_linux_bundle make_linux_bundle
move_release_to_output_dir move_release_to_output_dir
;; ;;
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
PLAYWRIGHT_PLATFORM="mac"
setup_playwright
RELEASE="$RELEASE_NAME.zip" RELEASE="$RELEASE_NAME.zip"
make_macos_bundle make_macos_bundle
move_release_to_output_dir move_release_to_output_dir
;; ;;
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"
download_jre_and_electron download_jre_and_electron
PLAYWRIGHT_PLATFORM="mac-arm64"
setup_playwright
RELEASE="$RELEASE_NAME.zip" RELEASE="$RELEASE_NAME.zip"
make_macos_bundle make_macos_bundle
move_release_to_output_dir move_release_to_output_dir
;; ;;
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
PLAYWRIGHT_PLATFORM="win64"
setup_playwright
RELEASE="$RELEASE_NAME.zip" RELEASE="$RELEASE_NAME.zip"
make_windows_bundle make_windows_bundle
move_release_to_output_dir move_release_to_output_dir
@@ -98,11 +120,16 @@ 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
PLAYWRIGHT_PLATFORM="win64"
setup_playwright
RELEASE="$RELEASE_NAME.zip" RELEASE="$RELEASE_NAME.zip"
make_windows_bundle make_windows_bundle
move_release_to_output_dir move_release_to_output_dir
@@ -133,17 +160,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 -T "$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() {
@@ -260,6 +285,11 @@ make_windows_package() {
"$RELEASE_NAME/jre.wxs" "$RELEASE_NAME/electron.wxs" -o "$RELEASE" "$RELEASE_NAME/jre.wxs" "$RELEASE_NAME/electron.wxs" -o "$RELEASE"
} }
setup_playwright() {
mkdir "$RELEASE_NAME/bin"
curl -L "https://playwright.azureedge.net/builds/chromium/$PLAYWRIGHT_REVISION/chromium-$PLAYWRIGHT_PLATFORM.zip" -o "$RELEASE_NAME/bin/chromium.zip"
}
# Error handler # Error handler
# set -u: Treat unset variables as an error when substituting. # set -u: Treat unset variables as an error when substituting.
# set -o pipefail: Prevents errors in pipeline from being masked. # set -o pipefail: Prevents errors in pipeline from being masked.
+2 -2
View File
@@ -2,13 +2,13 @@ Source: tachidesk-server
Section: web Section: web
Priority: optional Priority: optional
Maintainer: Mahor1221 <mahor1221@pm.me> Maintainer: Mahor1221 <mahor1221@pm.me>
Build-Depends: debhelper-compat (= 12), dh-exec Build-Depends: debhelper-compat (= 13), dh-exec
Standards-Version: 4.5.1 Standards-Version: 4.5.1
Homepage: https://github.com/Suwayomi/Tachidesk-Server 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.
+32 -45
View File
@@ -1,80 +1,70 @@
import de.undercouch.gradle.tasks.download.Download import de.undercouch.gradle.tasks.download.Download
import java.time.Instant import java.time.Instant
@Suppress("DSL_SCOPE_VIOLATION")
plugins { plugins {
id(libs.plugins.kotlin.jvm.get().pluginId)
id(libs.plugins.kotlin.serialization.get().pluginId)
id(libs.plugins.kotlinter.get().pluginId)
application application
id("com.github.johnrengelman.shadow") version "7.1.2" alias(libs.plugins.shadowjar)
id("com.github.gmazzo.buildconfig") id(libs.plugins.buildconfig.get().pluginId)
} }
dependencies { dependencies {
// okhttp // Shared
val okhttpVersion = "4.9.3" // Major version is locked by Tachiyomi extensions implementation(libs.bundles.shared)
implementation("com.squareup.okhttp3:okhttp:$okhttpVersion") testImplementation(libs.bundles.sharedTest)
implementation("com.squareup.okhttp3:logging-interceptor:$okhttpVersion")
implementation("com.squareup.okhttp3:okhttp-dnsoverhttps:$okhttpVersion") // OkHttp
implementation("com.squareup.okio:okio:3.0.0") implementation(libs.bundles.okhttp)
implementation(libs.okio)
// Javalin api // Javalin api
implementation("io.javalin:javalin:4.2.0") implementation(libs.bundles.javalin)
implementation("io.javalin:javalin-openapi:4.2.0") implementation(libs.bundles.jackson)
// jackson version locked by javalin, ref: `io.javalin.core.util.OptionalDependency`
val jacksonVersion = "2.12.4"
implementation("com.fasterxml.jackson.core:jackson-databind:$jacksonVersion")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin:$jacksonVersion")
// Exposed ORM // Exposed ORM
val exposedVersion = "0.34.1" implementation(libs.bundles.exposed)
implementation("org.jetbrains.exposed:exposed-core:$exposedVersion") implementation(libs.h2)
implementation("org.jetbrains.exposed:exposed-dao:$exposedVersion")
implementation("org.jetbrains.exposed:exposed-jdbc:$exposedVersion")
implementation("org.jetbrains.exposed:exposed-java-time:$exposedVersion")
// current database driver
implementation("com.h2database:h2:1.4.200")
// Exposed Migrations // Exposed Migrations
implementation("com.github.Suwayomi:exposed-migrations:3.1.4") implementation(libs.exposed.migrations)
// tray icon // tray icon
implementation("com.dorkbox:SystemTray:4.1") implementation(libs.bundles.systemtray)
implementation("com.dorkbox:Utilities:1.9") // version locked by SystemTray
// dependencies of Tachiyomi extensions, some are duplicate, keeping it here for reference // dependencies of Tachiyomi extensions, some are duplicate, keeping it here for reference
implementation("com.github.inorichi.injekt:injekt-core:65b0440") implementation(libs.injekt)
implementation("com.squareup.okhttp3:okhttp:$okhttpVersion") implementation(libs.okhttp.core)
implementation("io.reactivex:rxjava:1.3.8") implementation(libs.rxjava)
implementation("org.jsoup:jsoup:1.14.3") implementation(libs.jsoup)
implementation("app.cash.quickjs:quickjs-jvm:0.9.2")
// Sort // Sort
implementation("com.github.gpanther:java-nat-sort:natural-comparator-1.1") implementation(libs.sort)
// 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(libs.asm)
// Disk & File // Disk & File
implementation("net.lingala.zip4j:zip4j:2.9.1") implementation(libs.zip4j)
implementation("com.github.junrar:junrar:7.5.0") implementation(libs.junrar)
// CloudflareInterceptor // CloudflareInterceptor
implementation("net.sourceforge.htmlunit:htmlunit:2.56.0") implementation(libs.playwright)
// AES/CBC/PKCS7Padding Cypher provider for zh.copymanga // AES/CBC/PKCS7Padding Cypher provider for zh.copymanga
implementation("org.bouncycastle:bcprov-jdk18on:1.71") implementation(libs.bouncycastle)
// Source models and interfaces from Tachiyomi 1.x
// using source class from tachiyomi commit 9493577de27c40ce8b2b6122cc447d025e34c477 to not depend on tachiyomi.sourceapi
// implementation("tachiyomi.sourceapi:source-api:1.1")
// AndroidCompat // AndroidCompat
implementation(project(":AndroidCompat")) implementation(projects.androidCompat)
implementation(project(":AndroidCompat:Config")) implementation(projects.androidCompat.config)
// uncomment to test extensions directly // uncomment to test extensions directly
// implementation(fileTree("lib/")) // implementation(fileTree("lib/"))
implementation(kotlin("script-runtime")) implementation(kotlin("script-runtime"))
testImplementation("io.mockk:mockk:1.12.2") testImplementation(libs.mockk)
} }
application { application {
@@ -110,9 +100,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);
}
@@ -0,0 +1,212 @@
/*
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package suwayomi.tachidesk.server.util;
import com.microsoft.playwright.impl.driver.Driver;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.file.*;
import java.util.Collections;
import java.util.Map;
import java.util.concurrent.TimeUnit;
/**
* Copy of <a href="https://github.com/microsoft/playwright-java/blob/8c0231b0f739656e8a86bc58fca9ee778ddc571b/driver-bundle/src/main/java/com/microsoft/playwright/impl/driver/jar/DriverJar.java">DriverJar</a>
* with support for pre-installing chromium and only supports chromium playwright
*/
public class DriverJar extends Driver {
private static final String PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = "PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD";
private static final String SELENIUM_REMOTE_URL = "SELENIUM_REMOTE_URL";
static final String PLAYWRIGHT_NODEJS_PATH = "PLAYWRIGHT_NODEJS_PATH";
private final Path driverTempDir;
private Path preinstalledNodePath;
public DriverJar() throws IOException {
// Allow specifying custom path for the driver installation
// See https://github.com/microsoft/playwright-java/issues/728
String alternativeTmpdir = System.getProperty("playwright.driver.tmpdir");
String prefix = "playwright-java-";
driverTempDir = alternativeTmpdir == null
? Files.createTempDirectory(prefix)
: Files.createTempDirectory(Paths.get(alternativeTmpdir), prefix);
driverTempDir.toFile().deleteOnExit();
String nodePath = System.getProperty("playwright.nodejs.path");
if (nodePath != null) {
preinstalledNodePath = Paths.get(nodePath);
if (!Files.exists(preinstalledNodePath)) {
throw new RuntimeException("Invalid Node.js path specified: " + nodePath);
}
}
logMessage("created DriverJar: " + driverTempDir);
}
@Override
protected void initialize(Boolean installBrowsers) throws Exception {
if (preinstalledNodePath == null && env.containsKey(PLAYWRIGHT_NODEJS_PATH)) {
preinstalledNodePath = Paths.get(env.get(PLAYWRIGHT_NODEJS_PATH));
if (!Files.exists(preinstalledNodePath)) {
throw new RuntimeException("Invalid Node.js path specified: " + preinstalledNodePath);
}
} else if (preinstalledNodePath != null) {
// Pass the env variable to the driver process.
env.put(PLAYWRIGHT_NODEJS_PATH, preinstalledNodePath.toString());
}
extractDriverToTempDir();
logMessage("extracted driver from jar to " + driverPath());
if (installBrowsers)
installBrowsers(env);
}
private void installBrowsers(Map<String, String> env) throws IOException, InterruptedException {
String skip = env.get(PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD);
if (skip == null) {
skip = System.getenv(PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD);
}
if (skip != null && !"0".equals(skip) && !"false".equals(skip)) {
System.out.println("Skipping browsers download because `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD` env variable is set");
return;
}
if (env.get(SELENIUM_REMOTE_URL) != null || System.getenv(SELENIUM_REMOTE_URL) != null) {
logMessage("Skipping browsers download because `SELENIUM_REMOTE_URL` env variable is set");
return;
}
Chromium.preinstall(platformDir());
Path driver = driverPath();
if (!Files.exists(driver)) {
throw new RuntimeException("Failed to find driver: " + driver);
}
ProcessBuilder pb = createProcessBuilder();
pb.command().add("install");
pb.command().add("chromium");
pb.redirectError(ProcessBuilder.Redirect.INHERIT);
pb.redirectOutput(ProcessBuilder.Redirect.INHERIT);
Process p = pb.start();
boolean result = p.waitFor(10, TimeUnit.MINUTES);
if (!result) {
p.destroy();
throw new RuntimeException("Timed out waiting for browsers to install");
}
if (p.exitValue() != 0) {
throw new RuntimeException("Failed to install browsers, exit code: " + p.exitValue());
}
}
private static boolean isExecutable(Path filePath) {
String name = filePath.getFileName().toString();
return name.endsWith(".sh") || name.endsWith(".exe") || !name.contains(".");
}
private FileSystem initFileSystem(URI uri) throws IOException {
try {
return FileSystems.newFileSystem(uri, Collections.emptyMap());
} catch (FileSystemAlreadyExistsException e) {
return null;
}
}
public static URI getDriverResourceURI() throws URISyntaxException {
ClassLoader classloader = Thread.currentThread().getContextClassLoader();
return classloader.getResource("driver/" + platformDir()).toURI();
}
void extractDriverToTempDir() throws URISyntaxException, IOException {
URI originalUri = getDriverResourceURI();
URI uri = maybeExtractNestedJar(originalUri);
// Create zip filesystem if loading from jar.
try (FileSystem fileSystem = "jar".equals(uri.getScheme()) ? initFileSystem(uri) : null) {
Path srcRoot = Paths.get(uri);
// jar file system's .relativize gives wrong results when used with
// spring-boot-maven-plugin, convert to the default filesystem to
// have predictable results.
// See https://github.com/microsoft/playwright-java/issues/306
Path srcRootDefaultFs = Paths.get(srcRoot.toString());
Files.walk(srcRoot).forEach(fromPath -> {
if (preinstalledNodePath != null) {
String fileName = fromPath.getFileName().toString();
if ("node.exe".equals(fileName) || "node".equals(fileName)) {
return;
}
}
Path relative = srcRootDefaultFs.relativize(Paths.get(fromPath.toString()));
Path toPath = driverTempDir.resolve(relative.toString());
try {
if (Files.isDirectory(fromPath)) {
Files.createDirectories(toPath);
} else {
Files.copy(fromPath, toPath);
if (isExecutable(toPath)) {
toPath.toFile().setExecutable(true, true);
}
}
toPath.toFile().deleteOnExit();
} catch (IOException e) {
throw new RuntimeException("Failed to extract driver from " + uri + ", full uri: " + originalUri, e);
}
});
}
}
private URI maybeExtractNestedJar(final URI uri) throws URISyntaxException {
if (!"jar".equals(uri.getScheme())) {
return uri;
}
final String JAR_URL_SEPARATOR = "!/";
String[] parts = uri.toString().split("!/");
if (parts.length != 3) {
return uri;
}
String innerJar = String.join(JAR_URL_SEPARATOR, parts[0], parts[1]);
URI jarUri = new URI(innerJar);
try (FileSystem fs = FileSystems.newFileSystem(jarUri, Collections.emptyMap())) {
Path fromPath = Paths.get(jarUri);
Path toPath = driverTempDir.resolve(fromPath.getFileName().toString());
Files.copy(fromPath, toPath);
toPath.toFile().deleteOnExit();
return new URI("jar:" + toPath.toUri() + JAR_URL_SEPARATOR + parts[2]);
} catch (IOException e) {
throw new RuntimeException("Failed to extract driver's nested .jar from " + jarUri + "; full uri: " + uri, e);
}
}
private static String platformDir() {
String name = System.getProperty("os.name").toLowerCase();
String arch = System.getProperty("os.arch").toLowerCase();
if (name.contains("windows")) {
return "win32_x64";
}
if (name.contains("linux")) {
if (arch.equals("aarch64")) {
return "linux-arm64";
} else {
return "linux";
}
}
if (name.contains("mac os x")) {
return "mac";
}
throw new RuntimeException("Unexpected os.name value: " + name);
}
@Override
protected Path driverDir() {
return driverTempDir;
}
}
@@ -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)
} }
@@ -16,6 +16,7 @@ package eu.kanade.tachiyomi
// import eu.kanade.tachiyomi.data.track.TrackManager // import eu.kanade.tachiyomi.data.track.TrackManager
// import eu.kanade.tachiyomi.extension.ExtensionManager // import eu.kanade.tachiyomi.extension.ExtensionManager
import android.app.Application import android.app.Application
import eu.kanade.tachiyomi.network.JavaScriptEngine
import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.network.NetworkHelper
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import rx.Observable import rx.Observable
@@ -29,7 +30,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) }
@@ -42,6 +42,8 @@ class AppModule(val app: Application) : InjektModule {
addSingletonFactory { NetworkHelper(app) } addSingletonFactory { NetworkHelper(app) }
addSingletonFactory { JavaScriptEngine(app) }
// addSingletonFactory { SourceManager(app).also { get<ExtensionManager>().init(it) } } // addSingletonFactory { SourceManager(app).also { get<ExtensionManager>().init(it) } }
// //
// addSingletonFactory { ExtensionManager(app) } // addSingletonFactory { ExtensionManager(app) }
@@ -0,0 +1,27 @@
package eu.kanade.tachiyomi.network
import android.content.Context
import app.cash.quickjs.QuickJs
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* Util for evaluating JavaScript in sources.
*/
class JavaScriptEngine(context: Context) {
/**
* Evaluate arbitrary JavaScript code and get the result as a primitive type
* (e.g., String, Int).
*
* @since extensions-lib 1.4
* @param script JavaScript to execute.
* @return Result of JavaScript code as a primitive type.
*/
@Suppress("UNUSED", "UNCHECKED_CAST")
suspend fun <T> evaluate(script: String): T = withContext(Dispatchers.IO) {
QuickJs.create().use {
it.evaluate(script) as T
}
}
}
@@ -39,6 +39,7 @@ class NetworkHelper(context: Context) {
.cookieJar(cookieManager) .cookieJar(cookieManager)
.connectTimeout(30, TimeUnit.SECONDS) .connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS)
.callTimeout(2, TimeUnit.MINUTES)
.addInterceptor(UserAgentInterceptor()) .addInterceptor(UserAgentInterceptor())
if (serverConfig.debugLogsEnabled) { if (serverConfig.debugLogsEnabled) {
@@ -62,7 +62,7 @@ suspend fun Call.await(): Response {
object : Callback { object : Callback {
override fun onResponse(call: Call, response: Response) { override fun onResponse(call: Call, response: Response) {
if (!response.isSuccessful) { if (!response.isSuccessful) {
continuation.resumeWithException(Exception("HTTP error ${response.code}")) continuation.resumeWithException(HttpException(response.code))
return return
} }
@@ -94,7 +94,7 @@ fun Call.asObservableSuccess(): Observable<Response> {
.doOnNext { response -> .doOnNext { response ->
if (!response.isSuccessful) { if (!response.isSuccessful) {
response.close() response.close()
throw Exception("HTTP error ${response.code}") throw HttpException(response.code)
} }
} }
} }
@@ -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)
@@ -136,3 +136,5 @@ inline fun <reified T> Response.parseAs(): T {
return json.decodeFromString(responseBody) return json.decodeFromString(responseBody)
} }
} }
class HttpException(val code: Int) : IllegalStateException("HTTP error $code")
@@ -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
}
}
}
}
@@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.network
import okhttp3.CacheControl import okhttp3.CacheControl
import okhttp3.FormBody import okhttp3.FormBody
import okhttp3.Headers import okhttp3.Headers
import okhttp3.HttpUrl
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody import okhttp3.RequestBody
import java.util.concurrent.TimeUnit.MINUTES import java.util.concurrent.TimeUnit.MINUTES
@@ -23,6 +24,21 @@ fun GET(
.build() .build()
} }
/**
* @since extensions-lib 1.4
*/
fun GET(
url: HttpUrl,
headers: Headers = DEFAULT_HEADERS,
cache: CacheControl = DEFAULT_CACHE_CONTROL
): Request {
return Request.Builder()
.url(url)
.headers(headers)
.cacheControl(cache)
.build()
}
fun POST( fun POST(
url: String, url: String,
headers: Headers = DEFAULT_HEADERS, headers: Headers = DEFAULT_HEADERS,
@@ -1,19 +1,25 @@
package eu.kanade.tachiyomi.network.interceptor package eu.kanade.tachiyomi.network.interceptor
import com.gargoylesoftware.htmlunit.BrowserVersion import com.microsoft.playwright.Browser
import com.gargoylesoftware.htmlunit.WebClient import com.microsoft.playwright.BrowserType.LaunchOptions
import com.gargoylesoftware.htmlunit.html.HtmlPage import com.microsoft.playwright.Page
import com.microsoft.playwright.Playwright
import com.microsoft.playwright.PlaywrightException
import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.network.interceptor.CFClearance.resolveWithWebView
import mu.KotlinLogging import mu.KotlinLogging
import okhttp3.Cookie import okhttp3.Cookie
import okhttp3.HttpUrl import okhttp3.HttpUrl
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import suwayomi.tachidesk.server.ServerConfig
import suwayomi.tachidesk.server.serverConfig
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.io.IOException import java.io.IOException
import kotlin.time.Duration.Companion.seconds
import kotlin.time.DurationUnit
// from TachiWeb-Server
class CloudflareInterceptor : Interceptor { class CloudflareInterceptor : Interceptor {
private val logger = KotlinLogging.logger {} private val logger = KotlinLogging.logger {}
@@ -25,20 +31,24 @@ class CloudflareInterceptor : Interceptor {
logger.trace { "CloudflareInterceptor is being used." } logger.trace { "CloudflareInterceptor is being used." }
val response = chain.proceed(originalRequest) val originalResponse = chain.proceed(chain.request())
// Check if Cloudflare anti-bot is on // Check if Cloudflare anti-bot is on
if (response.code != 503 || response.header("Server") !in SERVER_CHECK) { if (!(originalResponse.code in ERROR_CODES && originalResponse.header("Server") in SERVER_CHECK)) {
return response return originalResponse
} }
throw IOException("playwrite is diabled for v0.6.7")
logger.debug { "Cloudflare anti-bot is on, CloudflareInterceptor is kicking in..." } logger.debug { "Cloudflare anti-bot is on, CloudflareInterceptor is kicking in..." }
return try { return try {
response.close() originalResponse.close()
network.cookies.remove(originalRequest.url.toUri()) network.cookies.remove(originalRequest.url.toUri())
chain.proceed(resolveChallenge(response)) val request = resolveWithWebView(originalRequest)
chain.proceed(request)
} catch (e: Exception) { } catch (e: Exception) {
// Because OkHttp's enqueue only handles IOExceptions, wrap the exception so that // Because OkHttp's enqueue only handles IOExceptions, wrap the exception so that
// we don't crash the entire app // we don't crash the entire app
@@ -46,65 +56,176 @@ class CloudflareInterceptor : Interceptor {
} }
} }
private fun resolveChallenge(response: Response): Request { companion object {
val browserVersion = BrowserVersion.BrowserVersionBuilder(BrowserVersion.BEST_SUPPORTED) private val ERROR_CODES = listOf(403, 503)
.setUserAgent(response.request.header("User-Agent") ?: BrowserVersion.BEST_SUPPORTED.userAgent) private val SERVER_CHECK = arrayOf("cloudflare-nginx", "cloudflare")
.build() private val COOKIE_NAMES = listOf("cf_clearance")
val convertedCookies = WebClient(browserVersion).use { webClient -> }
webClient.options.isThrowExceptionOnFailingStatusCode = false }
webClient.options.isThrowExceptionOnScriptError = false
webClient.getPage<HtmlPage>(response.request.url.toString()) /*
webClient.waitForBackgroundJavaScript(10000) * This class is ported from https://github.com/vvanglro/cf-clearance
// Challenge solved, process cookies * The original code is licensed under Apache 2.0
webClient.cookieManager.cookies.filter { */
// Only include Cloudflare cookies object CFClearance {
it.name.startsWith("__cf") || it.name.startsWith("cf_") private val logger = KotlinLogging.logger {}
}.map { private val network: NetworkHelper by injectLazy()
// Convert cookies -> OkHttp format
Cookie.Builder() init {
.domain(it.domain.removePrefix(".")) // Fix the default DriverJar issue by providing our own implementation
.expiresAt(it.expires?.time ?: Long.MAX_VALUE) // ref: https://github.com/microsoft/playwright-java/issues/1138
.name(it.name) System.setProperty("playwright.driver.impl", "suwayomi.tachidesk.server.util.DriverJar")
.path(it.path) }
.value(it.value).apply {
if (it.isHttpOnly) httpOnly() fun resolveWithWebView(originalRequest: Request): Request {
if (it.isSecure) secure() val url = originalRequest.url.toString()
}.build()
logger.debug { "resolveWithWebView($url)" }
val cookies = Playwright.create().use { playwright ->
playwright.chromium().launch(
LaunchOptions()
.setHeadless(false)
.apply {
if (serverConfig.socksProxyEnabled) {
setProxy("socks5://${serverConfig.socksProxyHost}:${serverConfig.socksProxyPort}")
}
}
).use { browser ->
val userAgent = originalRequest.header("User-Agent")
if (userAgent != null) {
browser.newContext(Browser.NewContextOptions().setUserAgent(userAgent)).use { browserContext ->
browserContext.newPage().use { getCookies(it, url) }
}
} else {
browser.newPage().use { getCookies(it, url) }
}
} }
} }
// Copy cookies to cookie store // Copy cookies to cookie store
convertedCookies.forEach { cookies.groupBy { it.domain }.forEach { (domain, cookies) ->
network.cookies.addAll( network.cookies.addAll(
HttpUrl.Builder() url = HttpUrl.Builder()
.scheme("http") .scheme("http")
.host(it.domain) .host(domain)
.build(), .build(),
listOf(it) cookies = cookies
) )
} }
// Merge new and existing cookies for this request // Merge new and existing cookies for this request
// Find the cookies that we need to merge into this request // Find the cookies that we need to merge into this request
val convertedForThisRequest = convertedCookies.filter { val convertedForThisRequest = cookies.filter {
it.matches(response.request.url) it.matches(originalRequest.url)
} }
// Extract cookies from current request // Extract cookies from current request
val existingCookies = Cookie.parseAll( val existingCookies = Cookie.parseAll(
response.request.url, originalRequest.url,
response.request.headers originalRequest.headers
) )
// Filter out existing values of cookies that we are about to merge in // Filter out existing values of cookies that we are about to merge in
val filteredExisting = existingCookies.filter { existing -> val filteredExisting = existingCookies.filter { existing ->
convertedForThisRequest.none { converted -> converted.name == existing.name } convertedForThisRequest.none { converted -> converted.name == existing.name }
} }
logger.trace { "Existing cookies" }
logger.trace { existingCookies.joinToString("; ") }
val newCookies = filteredExisting + convertedForThisRequest val newCookies = filteredExisting + convertedForThisRequest
return response.request.newBuilder() logger.trace { "New cookies" }
.header("Cookie", newCookies.map { it.toString() }.joinToString("; ")) logger.trace { newCookies.joinToString("; ") }
return originalRequest.newBuilder()
.header("Cookie", newCookies.joinToString("; ") { "${it.name}=${it.value}" })
.build() .build()
} }
companion object { fun getWebViewUserAgent(): String {
private val SERVER_CHECK = arrayOf("cloudflare-nginx", "cloudflare") return try {
private val COOKIE_NAMES = listOf("cf_clearance") throw PlaywrightException("playwrite is diabled for v0.6.7")
Playwright.create().use { playwright ->
playwright.chromium().launch(
LaunchOptions()
.setHeadless(true)
).use { browser ->
browser.newPage().use { page ->
val userAgent = page.evaluate("() => {return navigator.userAgent}") as String
logger.debug { "WebView User-Agent is $userAgent" }
return userAgent
}
}
}
} catch (e: PlaywrightException) {
// Playwright might fail on headless environments like docker
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36"
}
} }
private fun getCookies(page: Page, url: String): List<Cookie> {
applyStealthInitScripts(page)
page.navigate(url)
val challengeResolved = waitForChallengeResolve(page)
return if (challengeResolved) {
val cookies = page.context().cookies()
logger.debug {
val userAgent = page.evaluate("() => {return navigator.userAgent}")
"Playwright User-Agent is $userAgent"
}
// Convert PlayWright cookies to OkHttp cookies
cookies.map {
Cookie.Builder()
.domain(it.domain.removePrefix("."))
.expiresAt(it.expires?.times(1000)?.toLong() ?: Long.MAX_VALUE)
.name(it.name)
.path(it.path)
.value(it.value).apply {
if (it.httpOnly) httpOnly()
if (it.secure) secure()
}.build()
}
} else {
logger.debug { "Cloudflare challenge failed to resolve" }
throw CloudflareBypassException()
}
}
// ref: https://github.com/vvanglro/cf-clearance/blob/44124a8f06d8d0ecf2bf558a027082ff88dab435/cf_clearance/stealth.py#L18
private val stealthInitScripts by lazy {
arrayOf(
ServerConfig::class.java.getResource("/cloudflare-js/canvas.fingerprinting.js")!!.readText(),
ServerConfig::class.java.getResource("/cloudflare-js/chrome.global.js")!!.readText(),
ServerConfig::class.java.getResource("/cloudflare-js/emulate.touch.js")!!.readText(),
ServerConfig::class.java.getResource("/cloudflare-js/navigator.permissions.js")!!.readText(),
ServerConfig::class.java.getResource("/cloudflare-js/navigator.webdriver.js")!!.readText(),
ServerConfig::class.java.getResource("/cloudflare-js/chrome.runtime.js")!!.readText(),
ServerConfig::class.java.getResource("/cloudflare-js/chrome.plugin.js")!!.readText()
)
}
// ref: https://github.com/vvanglro/cf-clearance/blob/44124a8f06d8d0ecf2bf558a027082ff88dab435/cf_clearance/stealth.py#L76
private fun applyStealthInitScripts(page: Page) {
for (script in stealthInitScripts) {
page.addInitScript(script)
}
}
// ref: https://github.com/vvanglro/cf-clearance/blob/44124a8f06d8d0ecf2bf558a027082ff88dab435/cf_clearance/retry.py#L21
private fun waitForChallengeResolve(page: Page): Boolean {
// sometimes the user has to solve the captcha challenge manually, potentially wait a long time
val timeoutSeconds = 120
repeat(timeoutSeconds) {
page.waitForTimeout(1.seconds.toDouble(DurationUnit.MILLISECONDS))
val success = try {
page.querySelector("#challenge-form") == null
} catch (e: Exception) {
logger.debug(e) { "query Error" }
false
}
if (success) return true
}
return false
}
private class CloudflareBypassException : Exception()
} }
@@ -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
@@ -20,6 +20,8 @@ interface SManga : Serializable {
var thumbnail_url: String? var thumbnail_url: String?
var update_strategy: UpdateStrategy
var initialized: Boolean var initialized: Boolean
fun copyFrom(other: SManga) { fun copyFrom(other: SManga) {
@@ -18,5 +18,7 @@ class SMangaImpl : SManga {
override var thumbnail_url: String? = null override var thumbnail_url: String? = null
override var update_strategy: UpdateStrategy = UpdateStrategy.ALWAYS_UPDATE
override var initialized: Boolean = false override var initialized: Boolean = false
} }
@@ -0,0 +1,6 @@
package eu.kanade.tachiyomi.source.model
enum class UpdateStrategy {
ALWAYS_UPDATE,
ONLY_FETCH_ONCE
}
@@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.source.online
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.network.asObservableSuccess import eu.kanade.tachiyomi.network.asObservableSuccess
import eu.kanade.tachiyomi.network.interceptor.CFClearance.getWebViewUserAgent
import eu.kanade.tachiyomi.network.newCallWithProgress import eu.kanade.tachiyomi.network.newCallWithProgress
import eu.kanade.tachiyomi.source.CatalogueSource import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.model.FilterList import eu.kanade.tachiyomi.source.model.FilterList
@@ -356,6 +357,28 @@ abstract class HttpSource : CatalogueSource {
} }
} }
/**
* Returns the url of the provided manga
*
* @since extensions-lib 1.4
* @param manga the manga
* @return url of the manga
*/
open fun getMangaUrl(manga: SManga): String {
return mangaDetailsRequest(manga).url.toString()
}
/**
* Returns the url of the provided chapter
*
* @since extensions-lib 1.4
* @param chapter the chapter
* @return url of the chapter
*/
open fun getChapterUrl(chapter: SChapter): String {
return pageListRequest(chapter).url.toString()
}
/** /**
* Called before inserting a new chapter into database. Use it if you need to override chapter * Called before inserting a new chapter into database. Use it if you need to override chapter
* fields, like the title or the chapter number. Do not change anything to [manga]. * fields, like the title or the chapter number. Do not change anything to [manga].
@@ -363,8 +386,7 @@ abstract class HttpSource : CatalogueSource {
* @param chapter the chapter to be added. * @param chapter the chapter to be added.
* @param manga the manga of the chapter. * @param manga the manga of the chapter.
*/ */
open fun prepareNewChapter(chapter: SChapter, manga: SManga) { open fun prepareNewChapter(chapter: SChapter, manga: SManga) {}
}
/** /**
* Returns the list of filters for the source. * Returns the list of filters for the source.
@@ -372,6 +394,6 @@ abstract class HttpSource : CatalogueSource {
override fun getFilterList() = FilterList() override fun getFilterList() = FilterList()
companion object { companion object {
const val DEFAULT_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.150 Safari/537.36 Edg/88.0.705.63" val DEFAULT_USER_AGENT by lazy { getWebViewUserAgent() }
} }
} }
@@ -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] }
}
}
}
@@ -0,0 +1,18 @@
package suwayomi.tachidesk.global.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
/**
* 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)
}
)
} }
@@ -15,7 +15,6 @@ import suwayomi.tachidesk.manga.model.dataclass.ExtensionDataClass
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.queryParam
import suwayomi.tachidesk.server.util.withOperation import suwayomi.tachidesk.server.util.withOperation
object ExtensionController { object ExtensionController {
@@ -141,16 +140,15 @@ object ExtensionController {
/** icon for extension named `apkName` */ /** icon for extension named `apkName` */
val icon = handler( val icon = handler(
pathParam<String>("apkName"), pathParam<String>("apkName"),
queryParam("useCache", true),
documentWith = { documentWith = {
withOperation { withOperation {
summary("Extension icon") summary("Extension icon")
description("Icon for extension named `apkName`") description("Icon for extension named `apkName`")
} }
}, },
behaviorOf = { ctx, apkName, useCache -> behaviorOf = { ctx, apkName ->
ctx.future( ctx.future(
future { Extension.getExtensionIcon(apkName, useCache) } future { Extension.getExtensionIcon(apkName) }
.thenApply { .thenApply {
ctx.header("content-type", it.second) ctx.header("content-type", it.second)
it.first it.first
@@ -158,7 +156,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,29 +55,51 @@ 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"),
queryParam("useCache", true),
documentWith = { documentWith = {
withOperation { withOperation {
summary("Get a manga thumbnail") summary("Get a manga thumbnail")
description("Get a manga thumbnail from the source or the cache.") description("Get a manga thumbnail from the source or the cache.")
} }
}, },
behaviorOf = { ctx, mangaId, useCache -> behaviorOf = { ctx, mangaId ->
ctx.future( ctx.future(
future { Manga.getMangaThumbnail(mangaId, useCache) } future { Manga.getMangaThumbnail(mangaId) }
.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 +239,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"),
@@ -302,24 +374,25 @@ object MangaController {
pathParam<Int>("mangaId"), pathParam<Int>("mangaId"),
pathParam<Int>("chapterIndex"), pathParam<Int>("chapterIndex"),
pathParam<Int>("index"), pathParam<Int>("index"),
queryParam("useCache", true),
documentWith = { documentWith = {
withOperation { withOperation {
summary("Get a chapter page") summary("Get a chapter page")
description("Get a chapter page for a given index. Cache use can be disabled so it only retrieves it directly from the source.") description("Get a chapter page for a given index. Cache use can be disabled so it only retrieves it directly from the source.")
} }
}, },
behaviorOf = { ctx, mangaId, chapterIndex, index, useCache -> behaviorOf = { ctx, mangaId, chapterIndex, index ->
ctx.future( ctx.future(
future { Page.getPageImage(mangaId, chapterIndex, index, useCache) } future { Page.getPageImage(mangaId, chapterIndex, index) }
.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"),
@@ -1,8 +1,8 @@
package suwayomi.tachidesk.manga.controller package suwayomi.tachidesk.manga.controller
import eu.kanade.tachiyomi.source.model.UpdateStrategy
import io.javalin.http.HttpCode import io.javalin.http.HttpCode
import io.javalin.websocket.WsConfig import io.javalin.websocket.WsConfig
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 +15,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 +69,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 +91,16 @@ 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))
.filter { it.updateStrategy == UpdateStrategy.ALWAYS_UPDATE }
.forEach { manga ->
updater.addMangaToQueue(manga) updater.addMangaToQueue(manga)
} }
}
} }
fun categoryUpdateWS(ws: WsConfig) { fun categoryUpdateWS(ws: WsConfig) {
@@ -125,7 +124,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,9 +10,12 @@ 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.Op
import org.jetbrains.exposed.sql.SortOrder import org.jetbrains.exposed.sql.SortOrder
import org.jetbrains.exposed.sql.SortOrder.ASC import org.jetbrains.exposed.sql.SortOrder.ASC
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.insert
@@ -20,7 +23,6 @@ import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.update import org.jetbrains.exposed.sql.update
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.lang.awaitSingle import suwayomi.tachidesk.manga.impl.util.lang.awaitSingle
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrStub import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrStub
import suwayomi.tachidesk.manga.model.dataclass.ChapterDataClass import suwayomi.tachidesk.manga.model.dataclass.ChapterDataClass
@@ -29,10 +31,10 @@ import suwayomi.tachidesk.manga.model.dataclass.PaginatedList
import suwayomi.tachidesk.manga.model.dataclass.paginatedFrom import suwayomi.tachidesk.manga.model.dataclass.paginatedFrom
import suwayomi.tachidesk.manga.model.table.ChapterMetaTable import suwayomi.tachidesk.manga.model.table.ChapterMetaTable
import suwayomi.tachidesk.manga.model.table.ChapterTable import suwayomi.tachidesk.manga.model.table.ChapterTable
import suwayomi.tachidesk.manga.model.table.ChapterTable.scanlator
import suwayomi.tachidesk.manga.model.table.MangaTable import suwayomi.tachidesk.manga.model.table.MangaTable
import suwayomi.tachidesk.manga.model.table.PageTable import suwayomi.tachidesk.manga.model.table.PageTable
import suwayomi.tachidesk.manga.model.table.toDataClass import suwayomi.tachidesk.manga.model.table.toDataClass
import java.io.File
import java.time.Instant import java.time.Instant
object Chapter { object Chapter {
@@ -87,6 +89,10 @@ object Chapter {
it[sourceOrder] = index + 1 it[sourceOrder] = index + 1
it[fetchedAt] = now++ it[fetchedAt] = now++
it[ChapterTable.manga] = mangaId it[ChapterTable.manga] = mangaId
it[realUrl] = runCatching {
(source as? HttpSource)?.getChapterUrl(fetchedChapter)
}.getOrNull()
} }
} else { } else {
ChapterTable.update({ ChapterTable.url eq fetchedChapter.url }) { ChapterTable.update({ ChapterTable.url eq fetchedChapter.url }) {
@@ -97,9 +103,17 @@ object Chapter {
it[sourceOrder] = index + 1 it[sourceOrder] = index + 1
it[ChapterTable.manga] = mangaId it[ChapterTable.manga] = mangaId
it[realUrl] = runCatching {
(source as? HttpSource)?.getChapterUrl(fetchedChapter)
}.getOrNull()
} }
} }
} }
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,31 +142,36 @@ 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(
it.url, id = dbChapter[ChapterTable.id].value,
it.name, url = it.url,
it.date_upload, name = it.name,
it.chapter_number, uploadDate = it.date_upload,
it.scanlator, chapterNumber = it.chapter_number,
mangaId, scanlator = it.scanlator,
mangaId = mangaId,
dbChapter[ChapterTable.isRead], read = dbChapter[ChapterTable.isRead],
dbChapter[ChapterTable.isBookmarked], bookmarked = dbChapter[ChapterTable.isBookmarked],
dbChapter[ChapterTable.lastPageRead], lastPageRead = dbChapter[ChapterTable.lastPageRead],
dbChapter[ChapterTable.lastReadAt], lastReadAt = dbChapter[ChapterTable.lastReadAt],
chapterCount - index, index = chapterCount - index,
dbChapter[ChapterTable.fetchedAt], fetchedAt = dbChapter[ChapterTable.fetchedAt],
dbChapter[ChapterTable.isDownloaded], realUrl = dbChapter[ChapterTable.realUrl],
downloaded = dbChapter[ChapterTable.isDownloaded],
dbChapter[ChapterTable.pageCount], pageCount = dbChapter[ChapterTable.pageCount],
chapterList.size, chapterCount = chapterList.size,
meta = getChapterMetaMap(dbChapter[ChapterTable.id]) meta = chapterMetas.getValue(dbChapter[ChapterTable.id])
) )
} }
} }
@@ -189,6 +208,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 +301,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 {
@@ -225,9 +325,7 @@ object Chapter {
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 chapterDir = getChapterDir(mangaId, chapterId) ChapterDownloadHelper.delete(mangaId, chapterId)
File(chapterDir).deleteRecursively()
ChapterTable.update({ (ChapterTable.manga eq mangaId) and (ChapterTable.sourceOrder eq chapterIndex) }) { ChapterTable.update({ (ChapterTable.manga eq mangaId) and (ChapterTable.sourceOrder eq chapterIndex) }) {
it[isDownloaded] = false it[isDownloaded] = false
@@ -235,6 +333,41 @@ 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
ChapterDownloadHelper.delete(chapterMangaId, chapterId)
}
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
ChapterDownloadHelper.delete(mangaId, chapterId)
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 {
@@ -0,0 +1,41 @@
package suwayomi.tachidesk.manga.impl
import kotlinx.coroutines.CoroutineScope
import suwayomi.tachidesk.manga.impl.download.ArchiveProvider
import suwayomi.tachidesk.manga.impl.download.DownloadedFilesProvider
import suwayomi.tachidesk.manga.impl.download.FolderProvider
import suwayomi.tachidesk.manga.impl.download.model.DownloadChapter
import suwayomi.tachidesk.manga.impl.util.getChapterCbzPath
import suwayomi.tachidesk.manga.impl.util.getChapterDownloadPath
import suwayomi.tachidesk.server.serverConfig
import java.io.File
import java.io.InputStream
object ChapterDownloadHelper {
fun getImage(mangaId: Int, chapterId: Int, index: Int): Pair<InputStream, String> {
return provider(mangaId, chapterId).getImage(index)
}
fun delete(mangaId: Int, chapterId: Int): Boolean {
return provider(mangaId, chapterId).delete()
}
suspend fun download(
mangaId: Int,
chapterId: Int,
download: DownloadChapter,
scope: CoroutineScope,
step: suspend (DownloadChapter?, Boolean) -> Unit
): Boolean {
return provider(mangaId, chapterId).download(download, scope, step)
}
// return the appropriate provider based on how the download was saved. For the logic is simple but will evolve when new types of downloads are available
private fun provider(mangaId: Int, chapterId: Int): DownloadedFilesProvider {
val chapterFolder = File(getChapterDownloadPath(mangaId, chapterId))
val cbzFile = File(getChapterCbzPath(mangaId, chapterId))
if (cbzFile.exists()) return ArchiveProvider(mangaId, chapterId)
if (!chapterFolder.exists() && serverConfig.downloadAsCbz) return ArchiveProvider(mangaId, chapterId)
return FolderProvider(mangaId, chapterId)
}
}
@@ -11,8 +11,10 @@ import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.source.local.LocalSource import eu.kanade.tachiyomi.source.local.LocalSource
import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.model.UpdateStrategy
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
@@ -29,26 +31,30 @@ import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogue
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrStub import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrStub
import suwayomi.tachidesk.manga.impl.util.source.StubSource import suwayomi.tachidesk.manga.impl.util.source.StubSource
import suwayomi.tachidesk.manga.impl.util.storage.ImageResponse.clearCachedImage import suwayomi.tachidesk.manga.impl.util.storage.ImageResponse.clearCachedImage
import suwayomi.tachidesk.manga.impl.util.storage.ImageResponse.getImageResponse import suwayomi.tachidesk.manga.impl.util.storage.ImageResponse.getCachedImageResponse
import suwayomi.tachidesk.manga.impl.util.storage.ImageUtil 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 +74,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,90 +88,135 @@ 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.isNullOrEmpty() && sManga.thumbnail_url != mangaEntry[MangaTable.thumbnail_url]) {
it[MangaTable.thumbnail_url] = sManga.thumbnail_url it[MangaTable.thumbnail_url] = sManga.thumbnail_url
it[MangaTable.thumbnailUrlLastFetched] = Instant.now().epochSecond
clearMangaThumbnailCache(mangaId)
}
it[MangaTable.realUrl] = runCatching { it[MangaTable.realUrl] = runCatching {
(source as? HttpSource)?.mangaDetailsRequest(sManga)?.url?.toString() (source as? HttpSource)?.getMangaUrl(sManga)
}.getOrNull() }.getOrNull()
it[MangaTable.lastFetchedAt] = Instant.now().epochSecond
it[MangaTable.updateStrategy] = sManga.update_strategy.name
} }
} }
clearMangaThumbnail(mangaId)
mangaEntry = transaction { MangaTable.select { MangaTable.id eq mangaId }.first() } mangaEntry = transaction { MangaTable.select { MangaTable.id eq mangaId }.first() }
MangaDataClass( MangaDataClass(
mangaId, id = mangaId,
mangaEntry[MangaTable.sourceReference].toString(), sourceId = mangaEntry[MangaTable.sourceReference].toString(),
mangaEntry[MangaTable.url], url = mangaEntry[MangaTable.url],
mangaEntry[MangaTable.title], title = mangaEntry[MangaTable.title],
proxyThumbnailUrl(mangaId), thumbnailUrl = proxyThumbnailUrl(mangaId),
thumbnailUrlLastFetched = mangaEntry[MangaTable.thumbnailUrlLastFetched],
true, initialized = true,
sManga.artist, artist = sManga.artist,
sManga.author, author = sManga.author,
sManga.description, description = sManga.description,
sManga.genre.toGenreList(), genre = sManga.genre.toGenreList(),
MangaStatus.valueOf(sManga.status).name, status = MangaStatus.valueOf(sManga.status).name,
mangaEntry[MangaTable.inLibrary], inLibrary = mangaEntry[MangaTable.inLibrary],
mangaEntry[MangaTable.inLibraryAt], inLibraryAt = mangaEntry[MangaTable.inLibraryAt],
getSource(mangaEntry[MangaTable.sourceReference]), source = getSource(mangaEntry[MangaTable.sourceReference]),
getMangaMetaMap(mangaId), meta = getMangaMetaMap(mangaId),
mangaEntry[MangaTable.realUrl], realUrl = mangaEntry[MangaTable.realUrl],
true lastFetchedAt = mangaEntry[MangaTable.lastFetchedAt],
chaptersLastFetchedAt = mangaEntry[MangaTable.chaptersLastFetchedAt],
updateStrategy = UpdateStrategy.valueOf(mangaEntry[MangaTable.updateStrategy]),
freshData = 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, id = mangaId,
mangaEntry[MangaTable.sourceReference].toString(), sourceId = mangaEntry[MangaTable.sourceReference].toString(),
mangaEntry[MangaTable.url], url = mangaEntry[MangaTable.url],
mangaEntry[MangaTable.title], title = mangaEntry[MangaTable.title],
proxyThumbnailUrl(mangaId), thumbnailUrl = proxyThumbnailUrl(mangaId),
thumbnailUrlLastFetched = mangaEntry[MangaTable.thumbnailUrlLastFetched],
true, initialized = true,
mangaEntry[MangaTable.artist], artist = mangaEntry[MangaTable.artist],
mangaEntry[MangaTable.author], author = mangaEntry[MangaTable.author],
mangaEntry[MangaTable.description], description = mangaEntry[MangaTable.description],
mangaEntry[MangaTable.genre].toGenreList(), genre = mangaEntry[MangaTable.genre].toGenreList(),
MangaStatus.valueOf(mangaEntry[MangaTable.status]).name, status = MangaStatus.valueOf(mangaEntry[MangaTable.status]).name,
mangaEntry[MangaTable.inLibrary], inLibrary = mangaEntry[MangaTable.inLibrary],
mangaEntry[MangaTable.inLibraryAt], inLibraryAt = mangaEntry[MangaTable.inLibraryAt],
getSource(mangaEntry[MangaTable.sourceReference]), source = getSource(mangaEntry[MangaTable.sourceReference]),
getMangaMetaMap(mangaId), meta = getMangaMetaMap(mangaId),
mangaEntry[MangaTable.realUrl], realUrl = mangaEntry[MangaTable.realUrl],
false lastFetchedAt = mangaEntry[MangaTable.lastFetchedAt],
chaptersLastFetchedAt = mangaEntry[MangaTable.chaptersLastFetchedAt],
updateStrategy = UpdateStrategy.valueOf(mangaEntry[MangaTable.updateStrategy]),
freshData = 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
} }
} }
@@ -174,15 +225,15 @@ object Manga {
private val applicationDirs by DI.global.instance<ApplicationDirs>() private val applicationDirs by DI.global.instance<ApplicationDirs>()
private val network: NetworkHelper by injectLazy() private val network: NetworkHelper by injectLazy()
suspend fun getMangaThumbnail(mangaId: Int, useCache: Boolean): Pair<InputStream, String> { suspend fun getMangaThumbnail(mangaId: Int): Pair<InputStream, String> {
val saveDir = applicationDirs.thumbnailsRoot val cacheSaveDir = applicationDirs.thumbnailsRoot
val fileName = mangaId.toString() val fileName = mangaId.toString()
val mangaEntry = transaction { MangaTable.select { MangaTable.id eq mangaId }.first() } val mangaEntry = transaction { MangaTable.select { MangaTable.id eq mangaId }.first() }
val sourceId = mangaEntry[MangaTable.sourceReference] val sourceId = mangaEntry[MangaTable.sourceReference]
return when (val source = getCatalogueSourceOrStub(sourceId)) { return when (val source = getCatalogueSourceOrStub(sourceId)) {
is HttpSource -> getImageResponse(saveDir, fileName, useCache) { is HttpSource -> getCachedImageResponse(cacheSaveDir, fileName) {
val thumbnailUrl = mangaEntry[MangaTable.thumbnail_url] val thumbnailUrl = mangaEntry[MangaTable.thumbnail_url]
?: if (!mangaEntry[MangaTable.initialized]) { ?: if (!mangaEntry[MangaTable.initialized]) {
// initialize then try again // initialize then try again
@@ -199,6 +250,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,18 +264,20 @@ object Manga {
?: "image/jpeg" ?: "image/jpeg"
imageFile.inputStream() to contentType imageFile.inputStream() to contentType
} }
is StubSource -> getImageResponse(saveDir, fileName, useCache) {
is StubSource -> getCachedImageResponse(cacheSaveDir, fileName) {
val thumbnailUrl = mangaEntry[MangaTable.thumbnail_url] val thumbnailUrl = mangaEntry[MangaTable.thumbnail_url]
?: throw NullPointerException("No thumbnail found") ?: throw NullPointerException("No thumbnail found")
network.client.newCall( network.client.newCall(
GET(thumbnailUrl) GET(thumbnailUrl)
).await() ).await()
} }
else -> throw IllegalArgumentException("Unknown source") else -> throw IllegalArgumentException("Unknown source")
} }
} }
private fun clearMangaThumbnail(mangaId: Int) { private fun clearMangaThumbnailCache(mangaId: Int) {
val saveDir = applicationDirs.thumbnailsRoot val saveDir = applicationDirs.thumbnailsRoot
val fileName = mangaId.toString() val fileName = mangaId.toString()
@@ -8,6 +8,7 @@ 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 eu.kanade.tachiyomi.source.model.MangasPage import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.UpdateStrategy
import org.jetbrains.exposed.sql.and import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.insertAndGetId import org.jetbrains.exposed.sql.insertAndGetId
import org.jetbrains.exposed.sql.select import org.jetbrains.exposed.sql.select
@@ -27,6 +28,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()
@@ -58,6 +62,7 @@ object MangaList {
it[genre] = manga.genre it[genre] = manga.genre
it[status] = manga.status it[status] = manga.status
it[thumbnail_url] = manga.thumbnail_url it[thumbnail_url] = manga.thumbnail_url
it[updateStrategy] = manga.update_strategy.name
it[sourceReference] = sourceId it[sourceReference] = sourceId
}.value }.value
@@ -67,47 +72,55 @@ object MangaList {
}.first() }.first()
MangaDataClass( MangaDataClass(
mangaId, id = mangaId,
sourceId.toString(), sourceId = sourceId.toString(),
manga.url, url = manga.url,
manga.title, title = manga.title,
proxyThumbnailUrl(mangaId), thumbnailUrl = proxyThumbnailUrl(mangaId),
thumbnailUrlLastFetched = mangaEntry[MangaTable.thumbnailUrlLastFetched],
manga.initialized, initialized = manga.initialized,
manga.artist, artist = manga.artist,
manga.author, author = manga.author,
manga.description, description = manga.description,
manga.genre.toGenreList(), genre = manga.genre.toGenreList(),
MangaStatus.valueOf(manga.status).name, status = MangaStatus.valueOf(manga.status).name,
false, // It's a new manga entry inLibrary = false, // It's a new manga entry
0, inLibraryAt = 0,
meta = getMangaMetaMap(mangaId), meta = getMangaMetaMap(mangaId),
realUrl = mangaEntry[MangaTable.realUrl], realUrl = mangaEntry[MangaTable.realUrl],
lastFetchedAt = mangaEntry[MangaTable.lastFetchedAt],
chaptersLastFetchedAt = mangaEntry[MangaTable.chaptersLastFetchedAt],
updateStrategy = UpdateStrategy.valueOf(mangaEntry[MangaTable.updateStrategy]),
freshData = true freshData = true
) )
} else { } else {
val mangaId = mangaEntry[MangaTable.id].value val mangaId = mangaEntry[MangaTable.id].value
MangaDataClass( MangaDataClass(
mangaId, id = mangaId,
sourceId.toString(), sourceId = sourceId.toString(),
manga.url, url = manga.url,
manga.title, title = manga.title,
proxyThumbnailUrl(mangaId), thumbnailUrl = proxyThumbnailUrl(mangaId),
thumbnailUrlLastFetched = mangaEntry[MangaTable.thumbnailUrlLastFetched],
true, initialized = true,
mangaEntry[MangaTable.artist], artist = mangaEntry[MangaTable.artist],
mangaEntry[MangaTable.author], author = mangaEntry[MangaTable.author],
mangaEntry[MangaTable.description], description = mangaEntry[MangaTable.description],
mangaEntry[MangaTable.genre].toGenreList(), genre = mangaEntry[MangaTable.genre].toGenreList(),
MangaStatus.valueOf(mangaEntry[MangaTable.status]).name, status = MangaStatus.valueOf(mangaEntry[MangaTable.status]).name,
mangaEntry[MangaTable.inLibrary], inLibrary = mangaEntry[MangaTable.inLibrary],
mangaEntry[MangaTable.inLibraryAt], 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],
updateStrategy = UpdateStrategy.valueOf(mangaEntry[MangaTable.updateStrategy]),
freshData = false freshData = false
) )
} }
@@ -10,14 +10,16 @@ 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.SortOrder
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 org.jetbrains.exposed.sql.update import org.jetbrains.exposed.sql.update
import suwayomi.tachidesk.manga.impl.util.getChapterDir import suwayomi.tachidesk.manga.impl.util.getChapterCachePath
import suwayomi.tachidesk.manga.impl.util.lang.awaitSingle import suwayomi.tachidesk.manga.impl.util.lang.awaitSingle
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrStub import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrStub
import suwayomi.tachidesk.manga.impl.util.storage.ImageResponse.getImageResponse import suwayomi.tachidesk.manga.impl.util.storage.ImageResponse.getCachedImageResponse
import suwayomi.tachidesk.manga.impl.util.storage.ImageUtil import suwayomi.tachidesk.manga.impl.util.storage.ImageUtil
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.MangaTable
@@ -37,7 +39,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, 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 {
@@ -48,13 +50,17 @@ object Page {
val chapterId = chapterEntry[ChapterTable.id].value val chapterId = chapterEntry[ChapterTable.id].value
val pageEntry = val pageEntry =
transaction { PageTable.select { (PageTable.chapter eq chapterId) and (PageTable.index eq index) }.first() } transaction {
PageTable.select { (PageTable.chapter eq chapterId) }
.orderBy(PageTable.index to SortOrder.ASC)
.limit(1, index.toLong()).first()
}
val tachiyomiPage = Page( val tachiyomiPage = Page(
pageEntry[PageTable.index], pageEntry[PageTable.index],
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) {
@@ -80,11 +86,16 @@ object Page {
} }
} }
val chapterDir = getChapterDir(mangaId, chapterId)
File(chapterDir).mkdirs()
val fileName = getPageName(index) val fileName = getPageName(index)
return getImageResponse(chapterDir, fileName, useCache) { if (chapterEntry[ChapterTable.isDownloaded]) {
return ChapterDownloadHelper.getImage(mangaId, chapterId, index)
}
val cacheSaveDir = getChapterCachePath(mangaId, chapterId)
// Note: don't care about invalidating cache because OS cache is not permanent
return getCachedImageResponse(cacheSaveDir, fileName) {
source.fetchImage(tachiyomiPage).awaitSingle() source.fetchImage(tachiyomiPage).awaitSingle()
} }
} }
@@ -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
) )
@@ -1,8 +0,0 @@
package suwayomi.tachidesk.manga.impl.backup.models
class LibraryManga : MangaImpl() {
var unread: Int = 0
var category: Int = 0
}
@@ -1,5 +1,6 @@
package suwayomi.tachidesk.manga.impl.backup.models package suwayomi.tachidesk.manga.impl.backup.models
import eu.kanade.tachiyomi.source.model.UpdateStrategy
import org.jetbrains.exposed.sql.ResultRow import org.jetbrains.exposed.sql.ResultRow
import suwayomi.tachidesk.manga.model.table.MangaTable import suwayomi.tachidesk.manga.model.table.MangaTable
@@ -25,6 +26,8 @@ open class MangaImpl : Manga {
override var thumbnail_url: String? = null override var thumbnail_url: String? = null
override var update_strategy: UpdateStrategy = UpdateStrategy.ALWAYS_UPDATE
override var favorite: Boolean = false override var favorite: Boolean = false
override var last_update: Long = 0 override var last_update: Long = 0
@@ -7,6 +7,7 @@ package suwayomi.tachidesk.manga.impl.backup.proto
* 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 eu.kanade.tachiyomi.source.model.UpdateStrategy
import okio.buffer import okio.buffer
import okio.gzip import okio.gzip
import okio.sink import okio.sink
@@ -59,17 +60,18 @@ object ProtoBackupExport : ProtoBackupBase() {
private fun backupManga(databaseManga: Query, flags: BackupFlags): List<BackupManga> { private fun backupManga(databaseManga: Query, flags: BackupFlags): List<BackupManga> {
return databaseManga.map { mangaRow -> return databaseManga.map { mangaRow ->
val backupManga = BackupManga( val backupManga = BackupManga(
mangaRow[MangaTable.sourceReference], source = mangaRow[MangaTable.sourceReference],
mangaRow[MangaTable.url], url = mangaRow[MangaTable.url],
mangaRow[MangaTable.title], title = mangaRow[MangaTable.title],
mangaRow[MangaTable.artist], artist = mangaRow[MangaTable.artist],
mangaRow[MangaTable.author], author = mangaRow[MangaTable.author],
mangaRow[MangaTable.description], description = mangaRow[MangaTable.description],
mangaRow[MangaTable.genre]?.split(", ") ?: emptyList(), genre = mangaRow[MangaTable.genre]?.split(", ") ?: emptyList(),
MangaStatus.valueOf(mangaRow[MangaTable.status]).value, status = MangaStatus.valueOf(mangaRow[MangaTable.status]).value,
mangaRow[MangaTable.thumbnail_url], thumbnailUrl = mangaRow[MangaTable.thumbnail_url],
TimeUnit.SECONDS.toMillis(mangaRow[MangaTable.inLibraryAt]), dateAdded = TimeUnit.SECONDS.toMillis(mangaRow[MangaTable.inLibraryAt]),
0, // not supported in Tachidesk viewer = 0, // not supported in Tachidesk
updateStrategy = UpdateStrategy.valueOf(mangaRow[MangaTable.updateStrategy])
) )
val mangaId = mangaRow[MangaTable.id].value val mangaId = mangaRow[MangaTable.id].value
@@ -94,7 +96,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 +124,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()
@@ -143,6 +145,7 @@ object ProtoBackupImport : ProtoBackupBase() {
it[genre] = manga.genre it[genre] = manga.genre
it[status] = manga.status it[status] = manga.status
it[thumbnail_url] = manga.thumbnail_url it[thumbnail_url] = manga.thumbnail_url
it[updateStrategy] = manga.update_strategy.name
it[sourceReference] = manga.source it[sourceReference] = manga.source
@@ -191,6 +194,7 @@ object ProtoBackupImport : ProtoBackupBase() {
it[genre] = manga.genre ?: dbManga[genre] it[genre] = manga.genre ?: dbManga[genre]
it[status] = manga.status it[status] = manga.status
it[thumbnail_url] = manga.thumbnail_url ?: dbManga[thumbnail_url] it[thumbnail_url] = manga.thumbnail_url ?: dbManga[thumbnail_url]
it[updateStrategy] = manga.update_strategy.name
it[initialized] = dbManga[initialized] || manga.description != null it[initialized] = dbManga[initialized] || manga.description != null
@@ -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 {
@@ -1,5 +1,6 @@
package suwayomi.tachidesk.manga.impl.backup.proto.models package suwayomi.tachidesk.manga.impl.backup.proto.models
import eu.kanade.tachiyomi.source.model.UpdateStrategy
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.protobuf.ProtoNumber import kotlinx.serialization.protobuf.ProtoNumber
import suwayomi.tachidesk.manga.impl.backup.models.ChapterImpl import suwayomi.tachidesk.manga.impl.backup.models.ChapterImpl
@@ -36,6 +37,7 @@ data class BackupManga(
@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(),
@ProtoNumber(105) var updateStrategy: UpdateStrategy = UpdateStrategy.ALWAYS_UPDATE
) { ) {
fun getMangaImpl(): MangaImpl { fun getMangaImpl(): MangaImpl {
return MangaImpl().apply { return MangaImpl().apply {
@@ -52,6 +54,7 @@ data class BackupManga(
date_added = this@BackupManga.dateAdded date_added = this@BackupManga.dateAdded
viewer_flags = this@BackupManga.viewer_flags ?: this@BackupManga.viewer viewer_flags = this@BackupManga.viewer_flags ?: this@BackupManga.viewer
chapter_flags = this@BackupManga.chapterFlags chapter_flags = this@BackupManga.chapterFlags
update_strategy = this@BackupManga.updateStrategy
} }
} }
@@ -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 {
@@ -16,7 +16,8 @@ import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.update import org.jetbrains.exposed.sql.update
import suwayomi.tachidesk.manga.impl.Page.getPageName import suwayomi.tachidesk.manga.impl.Page.getPageName
import suwayomi.tachidesk.manga.impl.util.getChapterDir import suwayomi.tachidesk.manga.impl.util.getChapterCbzPath
import suwayomi.tachidesk.manga.impl.util.getChapterDownloadPath
import suwayomi.tachidesk.manga.impl.util.lang.awaitSingle import suwayomi.tachidesk.manga.impl.util.lang.awaitSingle
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrStub import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrStub
import suwayomi.tachidesk.manga.impl.util.storage.ImageResponse import suwayomi.tachidesk.manga.impl.util.storage.ImageResponse
@@ -25,6 +26,7 @@ import suwayomi.tachidesk.manga.model.table.ChapterTable
import suwayomi.tachidesk.manga.model.table.MangaTable import suwayomi.tachidesk.manga.model.table.MangaTable
import suwayomi.tachidesk.manga.model.table.PageTable import suwayomi.tachidesk.manga.model.table.PageTable
import suwayomi.tachidesk.manga.model.table.toDataClass import suwayomi.tachidesk.manga.model.table.toDataClass
import java.io.File
suspend fun getChapterDownloadReady(chapterIndex: Int, mangaId: Int): ChapterDataClass { suspend fun getChapterDownloadReady(chapterIndex: Int, mangaId: Int): ChapterDataClass {
val chapter = ChapterForDownload(chapterIndex, mangaId) val chapter = ChapterForDownload(chapterIndex, mangaId)
@@ -37,7 +39,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()
@@ -128,13 +129,16 @@ private class ChapterForDownload(
} }
private fun isNotCompletelyDownloaded(): Boolean { private fun isNotCompletelyDownloaded(): Boolean {
return !(chapterEntry[ChapterTable.isDownloaded] && firstPageExists()) return !(
chapterEntry[ChapterTable.isDownloaded] &&
(firstPageExists() || File(getChapterCbzPath(mangaId, chapterEntry[ChapterTable.id].value)).exists())
)
} }
private fun firstPageExists(): Boolean { private fun firstPageExists(): Boolean {
val chapterId = chapterEntry[ChapterTable.id].value val chapterId = chapterEntry[ChapterTable.id].value
val chapterDir = getChapterDir(mangaId, chapterId) val chapterDir = getChapterDownloadPath(mangaId, chapterId)
println(chapterDir) println(chapterDir)
println(getPageName(0)) println(getPageName(0))
@@ -0,0 +1,89 @@
package suwayomi.tachidesk.manga.impl.download
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import suwayomi.tachidesk.manga.impl.download.model.DownloadChapter
import suwayomi.tachidesk.manga.impl.util.getChapterCbzPath
import suwayomi.tachidesk.manga.impl.util.getChapterDownloadPath
import java.io.File
import java.io.InputStream
import java.util.zip.ZipEntry
import java.util.zip.ZipFile
import java.util.zip.ZipInputStream
import java.util.zip.ZipOutputStream
class ArchiveProvider(mangaId: Int, chapterId: Int) : DownloadedFilesProvider(mangaId, chapterId) {
override fun getImage(index: Int): Pair<InputStream, String> {
val cbzPath = getChapterCbzPath(mangaId, chapterId)
val zipFile = ZipFile(cbzPath)
val zipEntry = zipFile.entries().toList().sortedWith(compareBy({ it.name }, { it.name }))[index]
val inputStream = zipFile.getInputStream(zipEntry)
val fileType = zipEntry.name.substringAfterLast(".")
return Pair(inputStream.buffered(), "image/$fileType")
}
override suspend fun download(
download: DownloadChapter,
scope: CoroutineScope,
step: suspend (DownloadChapter?, Boolean) -> Unit
): Boolean {
val chapterDir = getChapterDownloadPath(mangaId, chapterId)
val outputFile = File(getChapterCbzPath(mangaId, chapterId))
val chapterFolder = File(chapterDir)
if (outputFile.exists()) handleExistingCbzFile(outputFile, chapterFolder)
withContext(Dispatchers.IO) {
outputFile.createNewFile()
}
FolderProvider(mangaId, chapterId).download(download, scope, step)
ZipOutputStream(outputFile.outputStream()).use { zipOut ->
if (chapterFolder.isDirectory) {
chapterFolder.listFiles()?.sortedBy { it.name }?.forEach {
val entry = ZipEntry(it.name)
try {
zipOut.putNextEntry(entry)
it.inputStream().use { inputStream ->
inputStream.copyTo(zipOut)
}
} finally {
zipOut.closeEntry()
}
}
}
}
if (chapterFolder.exists() && chapterFolder.isDirectory) {
chapterFolder.deleteRecursively()
}
return true
}
override fun delete(): Boolean {
val cbzFile = File(getChapterCbzPath(mangaId, chapterId))
if (cbzFile.exists()) return cbzFile.delete()
return false
}
private fun handleExistingCbzFile(cbzFile: File, chapterFolder: File) {
if (!chapterFolder.exists()) chapterFolder.mkdirs()
ZipInputStream(cbzFile.inputStream()).use { zipInputStream ->
var zipEntry = zipInputStream.nextEntry
while (zipEntry != null) {
val file = File(chapterFolder, zipEntry.name)
if (!file.exists()) {
file.parentFile.mkdirs()
file.createNewFile()
}
file.outputStream().use { outputStream ->
zipInputStream.copyTo(outputStream)
}
zipEntry = zipInputStream.nextEntry
}
}
cbzFile.delete()
}
}
@@ -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)
} }
@@ -0,0 +1,20 @@
package suwayomi.tachidesk.manga.impl.download
import kotlinx.coroutines.CoroutineScope
import suwayomi.tachidesk.manga.impl.download.model.DownloadChapter
import java.io.InputStream
/*
* Base class for downloaded chapter files provider, example: Folder, Archive
* */
abstract class DownloadedFilesProvider(val mangaId: Int, val chapterId: Int) {
abstract fun getImage(index: Int): Pair<InputStream, String>
abstract suspend fun download(
download: DownloadChapter,
scope: CoroutineScope,
step: suspend (DownloadChapter?, Boolean) -> Unit
): Boolean
abstract fun delete(): Boolean
}
@@ -7,12 +7,19 @@ 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.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
import org.jetbrains.exposed.sql.update import org.jetbrains.exposed.sql.update
import suwayomi.tachidesk.manga.impl.Page.getPageImage import suwayomi.tachidesk.manga.impl.ChapterDownloadHelper
import suwayomi.tachidesk.manga.impl.chapter.getChapterDownloadReady import suwayomi.tachidesk.manga.impl.chapter.getChapterDownloadReady
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
@@ -24,61 +31,89 @@ 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 ChapterDownloadHelper.download(download.mangaId, download.chapter.id, download, scope, this::step)
for (pageNum in 0 until pageCount) {
runBlocking { getPageImage(download.mangaId, download.chapterIndex, pageNum) }
// 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
// TODO: fine grained download percentage
download.progress = (pageNum + 1).toFloat() / pageCount
step()
}
download.state = Finished download.state = Finished
transaction { transaction {
ChapterTable.update({ (ChapterTable.manga eq download.mangaId) and (ChapterTable.sourceOrder eq download.chapterIndex) }) { ChapterTable.update({ (ChapterTable.manga eq download.mangaId) and (ChapterTable.sourceOrder eq download.chapterIndex) }) {
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) }
} }
} }
@@ -0,0 +1,87 @@
package suwayomi.tachidesk.manga.impl.download
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.sample
import suwayomi.tachidesk.manga.impl.Page
import suwayomi.tachidesk.manga.impl.Page.getPageName
import suwayomi.tachidesk.manga.impl.download.model.DownloadChapter
import suwayomi.tachidesk.manga.impl.util.getChapterDownloadPath
import suwayomi.tachidesk.manga.impl.util.storage.ImageResponse
import java.io.File
import java.io.FileInputStream
import java.io.InputStream
/*
* Provides downloaded files when pages were downloaded into folders
* */
class FolderProvider(mangaId: Int, chapterId: Int) : DownloadedFilesProvider(mangaId, chapterId) {
override fun getImage(index: Int): Pair<InputStream, String> {
val chapterDir = getChapterDownloadPath(mangaId, chapterId)
val folder = File(chapterDir)
folder.mkdirs()
val file = folder.listFiles()?.get(index)
val fileType = file!!.name.substringAfterLast(".")
return Pair(FileInputStream(file).buffered(), "image/$fileType")
}
@OptIn(FlowPreview::class)
override suspend fun download(
download: DownloadChapter,
scope: CoroutineScope,
step: suspend (DownloadChapter?, Boolean) -> Unit
): Boolean {
val pageCount = download.chapter.pageCount
val chapterDir = getChapterDownloadPath(mangaId, chapterId)
val folder = File(chapterDir)
folder.mkdirs()
for (pageNum in 0 until pageCount) {
var pageProgressJob: Job? = null
val fileName = getPageName(pageNum) // might have to change this to index stored in database
if (isExistingFile(folder, fileName)) continue
try {
Page.getPageImage(
mangaId = download.mangaId,
chapterIndex = download.chapterIndex,
index = pageNum
) { 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.use { image ->
val filePath = "$chapterDir/$fileName"
ImageResponse.saveImage(filePath, image)
}
} 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
download.progress = ((pageNum + 1).toFloat()) / pageCount
step(download, false)
}
return true
}
override fun delete(): Boolean {
val chapterDir = getChapterDownloadPath(mangaId, chapterId)
return File(chapterDir).deleteRecursively()
}
private fun isExistingFile(folder: File, fileName: String): Boolean {
val existingFile = folder.listFiles { file ->
file.isFile && file.name.startsWith(fileName)
}?.firstOrNull()
return existingFile?.exists() == true
}
}
@@ -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
@@ -39,7 +40,7 @@ import suwayomi.tachidesk.manga.impl.util.PackageTools.getPackageInfo
import suwayomi.tachidesk.manga.impl.util.PackageTools.loadExtensionSources import suwayomi.tachidesk.manga.impl.util.PackageTools.loadExtensionSources
import suwayomi.tachidesk.manga.impl.util.network.await import suwayomi.tachidesk.manga.impl.util.network.await
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource
import suwayomi.tachidesk.manga.impl.util.storage.ImageResponse.getImageResponse import suwayomi.tachidesk.manga.impl.util.storage.ImageResponse.getCachedImageResponse
import suwayomi.tachidesk.manga.model.table.ExtensionTable import suwayomi.tachidesk.manga.model.table.ExtensionTable
import suwayomi.tachidesk.manga.model.table.SourceTable import suwayomi.tachidesk.manga.model.table.SourceTable
import suwayomi.tachidesk.server.ApplicationDirs import suwayomi.tachidesk.server.ApplicationDirs
@@ -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
} }
@@ -264,13 +266,16 @@ object Extension {
return installExtension(pkgName) return installExtension(pkgName)
} }
suspend fun getExtensionIcon(apkName: String, useCache: Boolean): Pair<InputStream, String> { suspend fun getExtensionIcon(apkName: String): 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 cacheSaveDir = "${applicationDirs.extensionsRoot}/icon"
return getImageResponse(saveDir, apkName, useCache) { return getCachedImageResponse(cacheSaveDir, apkName) {
network.client.newCall( network.client.newCall(
GET(iconUrl) GET(iconUrl)
).await() ).await()
@@ -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
@@ -21,6 +22,7 @@ import suwayomi.tachidesk.manga.impl.extension.github.OnlineExtension
import suwayomi.tachidesk.manga.model.dataclass.ExtensionDataClass import suwayomi.tachidesk.manga.model.dataclass.ExtensionDataClass
import suwayomi.tachidesk.manga.model.table.ExtensionTable import suwayomi.tachidesk.manga.model.table.ExtensionTable
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import kotlin.time.Duration.Companion.seconds
object ExtensionsList { object ExtensionsList {
private val logger = KotlinLogging.logger {} private val logger = KotlinLogging.logger {}
@@ -28,12 +30,9 @@ object ExtensionsList {
var lastUpdateCheck: Long = 0 var lastUpdateCheck: Long = 0
var updateMap = ConcurrentHashMap<String, OnlineExtension>() var updateMap = ConcurrentHashMap<String, OnlineExtension>()
/** 60,000 milliseconds = 60 seconds */
private const val ExtensionUpdateDelayTime = 60 * 1000
suspend fun getExtensionList(): List<ExtensionDataClass> { suspend fun getExtensionList(): List<ExtensionDataClass> {
// update if {ExtensionUpdateDelayTime} seconds has passed or requested offline and database is empty // update if 60 seconds has passed or requested offline and database is empty
if (lastUpdateCheck + ExtensionUpdateDelayTime < System.currentTimeMillis()) { if (lastUpdateCheck + 60.seconds.inWholeMilliseconds < System.currentTimeMillis()) {
logger.debug("Getting extensions list from the internet") logger.debug("Getting extensions list from the internet")
lastUpdateCheck = System.currentTimeMillis() lastUpdateCheck = System.currentTimeMillis()
@@ -59,7 +58,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]
) )
} }
} }
@@ -79,14 +78,14 @@ object ExtensionsList {
updateMap.putIfAbsent(foundExtension.pkgName, foundExtension) updateMap.putIfAbsent(foundExtension.pkgName, foundExtension)
} }
foundExtension.versionCode < extensionRecord[ExtensionTable.versionCode] -> { foundExtension.versionCode < extensionRecord[ExtensionTable.versionCode] -> {
// some how the user installed an invalid version // somehow the user installed an invalid version
ExtensionTable.update({ ExtensionTable.pkgName eq foundExtension.pkgName }) { ExtensionTable.update({ ExtensionTable.pkgName eq foundExtension.pkgName }) {
it[isObsolete] = true it[isObsolete] = true
} }
} }
} }
} else { } else {
// extension is not installed so we can overwrite the data without a care // extension is not installed, so we can overwrite the data without a care
ExtensionTable.update({ ExtensionTable.pkgName eq foundExtension.pkgName }) { ExtensionTable.update({ ExtensionTable.pkgName eq foundExtension.pkgName }) {
it[name] = foundExtension.name it[name] = foundExtension.name
it[versionName] = foundExtension.versionName it[versionName] = foundExtension.versionName
@@ -116,14 +115,14 @@ object ExtensionsList {
ExtensionTable.selectAll().forEach { extensionRecord -> ExtensionTable.selectAll().forEach { extensionRecord ->
val foundExtension = foundExtensions.find { it.pkgName == extensionRecord[ExtensionTable.pkgName] } val foundExtension = foundExtensions.find { it.pkgName == extensionRecord[ExtensionTable.pkgName] }
if (foundExtension == null) { if (foundExtension == null) {
// not in the repo, so this extensions is obsolete // not in the repo, so these extensions are obsolete
if (extensionRecord[ExtensionTable.isInstalled]) { if (extensionRecord[ExtensionTable.isInstalled]) {
// is installed so we should mark it as obsolete // is installed so we should mark it as obsolete
ExtensionTable.update({ ExtensionTable.pkgName eq extensionRecord[ExtensionTable.pkgName] }) { ExtensionTable.update({ ExtensionTable.pkgName eq extensionRecord[ExtensionTable.pkgName] }) {
it[isObsolete] = true it[isObsolete] = true
} }
} else { } else {
// is not installed so we can remove the record without a care // is not installed, so we can remove the record without a care
ExtensionTable.deleteWhere { ExtensionTable.pkgName eq extensionRecord[ExtensionTable.pkgName] } ExtensionTable.deleteWhere { ExtensionTable.pkgName eq extensionRecord[ExtensionTable.pkgName] }
} }
} }
@@ -7,11 +7,12 @@ package suwayomi.tachidesk.manga.impl.extension.github
* 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 eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.network.await import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.network.parseAs import eu.kanade.tachiyomi.network.parseAs
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import okhttp3.Request import mu.KotlinLogging
import suwayomi.tachidesk.manga.impl.util.PackageTools.LIB_VERSION_MAX import suwayomi.tachidesk.manga.impl.util.PackageTools.LIB_VERSION_MAX
import suwayomi.tachidesk.manga.impl.util.PackageTools.LIB_VERSION_MIN import suwayomi.tachidesk.manga.impl.util.PackageTools.LIB_VERSION_MIN
import suwayomi.tachidesk.manga.model.dataclass.ExtensionDataClass import suwayomi.tachidesk.manga.model.dataclass.ExtensionDataClass
@@ -19,6 +20,8 @@ import uy.kohesive.injekt.injectLazy
object ExtensionGithubApi { object ExtensionGithubApi {
private const val REPO_URL_PREFIX = "https://raw.githubusercontent.com/tachiyomiorg/tachiyomi-extensions/repo/" private const val REPO_URL_PREFIX = "https://raw.githubusercontent.com/tachiyomiorg/tachiyomi-extensions/repo/"
private const val FALLBACK_REPO_URL_PREFIX = "https://gcore.jsdelivr.net/gh/tachiyomiorg/tachiyomi-extensions@repo/"
private val logger = KotlinLogging.logger {}
@Serializable @Serializable
private data class ExtensionJsonObject( private data class ExtensionJsonObject(
@@ -31,7 +34,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
@@ -42,13 +45,26 @@ object ExtensionGithubApi {
val baseUrl: String val baseUrl: String
) )
suspend fun findExtensions(): List<OnlineExtension> { private var requiresFallbackSource = false
val request = Request.Builder()
.url("$REPO_URL_PREFIX/index.min.json")
.build()
return client.newCall(request) suspend fun findExtensions(): List<OnlineExtension> {
.await() val githubResponse = if (requiresFallbackSource) {
null
} else {
try {
client.newCall(GET("${REPO_URL_PREFIX}index.min.json")).await()
} catch (e: Throwable) {
logger.error(e) { "Failed to get extensions from GitHub" }
requiresFallbackSource = true
null
}
}
val response = githubResponse ?: run {
client.newCall(GET("${FALLBACK_REPO_URL_PREFIX}index.min.json")).await()
}
return response
.parseAs<List<ExtensionJsonObject>>() .parseAs<List<ExtensionJsonObject>>()
.toExtensions() .toExtensions()
} }
@@ -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}"""
}
} }

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