Compare commits

..

66 Commits

Author SHA1 Message Date
Aria Moradi 2c5114c770 bump version
CI Publish / Validate Gradle Wrapper (push) Successful in 13s
CI Publish / Build artifacts and release (push) Failing after 16s
2021-09-18 21:07:38 +04:30
Aria Moradi a30895a199 update WebUI 2021-09-18 21:03:01 +04:30
Aria Moradi 6d46d4b3da closes #131 2021-09-18 20:30:56 +04:30
Aria Moradi 006efbbb77 add ChapterRecognition from tachiyomi, closes #10 2021-09-18 19:40:44 +04:30
Aria Moradi 52334087ad add support for Archive chapters to Local source 2021-09-18 19:14:06 +04:30
Aria Moradi ea8fb2c70a add comment 2021-09-18 16:11:25 +04:30
Aria Moradi 531d148718 don't save Local source chapters into disk again! 2021-09-18 14:53:59 +04:30
Aria Moradi d83ddea323 better URLs 2021-09-18 14:49:38 +04:30
Aria Moradi b5dea34090 update WebUI 2021-09-18 02:39:15 +04:30
Aria Moradi 7d8e3202b5 lint 2021-09-18 02:28:52 +04:30
Aria Moradi d956e0af4b add Local Source (#196) 2021-09-18 02:20:30 +04:30
Aria Moradi 9ad70990b5 update WebUI 2021-09-18 02:19:43 +04:30
Aria Moradi 82d711f077 fix typo 2021-09-18 02:19:38 +04:30
Aria Moradi 5fc28ef711 handle when title is changing properly 2021-09-18 02:10:35 +04:30
Aria Moradi 1f3dc682e2 migrate to kotlinx.json 2021-09-18 01:43:10 +04:30
Aria Moradi cce7768246 clean up imports 2021-09-18 01:03:06 +04:30
Aria Moradi 01172b0664 better exception messages 2021-09-18 01:00:42 +04:30
Aria Moradi 1ca11fdd34 add Local Source 2021-09-18 00:47:50 +04:30
Aria Moradi 52a064ae45 lint 2021-09-17 16:32:14 +04:30
Aria Moradi 6c62ddf927 depricate zero based chapters 2021-09-17 16:31:47 +04:30
Aria Moradi 47e04c08d0 fix lint complain 2021-09-17 15:17:18 +04:30
Aria Moradi d1601874b4 [SKIP CI] update CHANGELOG.md 2021-09-17 01:31:13 +04:30
Aria Moradi 6bc19af041 add ability to delete downloaded chapters 2021-09-17 01:29:42 +04:30
Aria Moradi 4e72a3886f actaully make sure the chapter exists 2021-09-17 00:38:15 +04:30
Aria Moradi 8e6b219eea update CHANGELOG.md 2021-09-14 04:18:19 +04:30
Aria Moradi bd638251e4 add BasicAuth support 2021-09-14 04:17:01 +04:30
Aria Moradi 0173d5e4b3 migrate to Javalin 4 2021-09-14 03:23:00 +04:30
Aria Moradi 4f364e134b remove expand char limit on MangaTable columns 2021-09-13 20:57:41 +04:30
Aria Moradi 94d2519717 add CHANGELOG for the next release 2021-09-13 20:14:02 +04:30
Aria Moradi 6a11d2e357 bump version
CI Publish / Validate Gradle Wrapper (push) Successful in 11s
CI Publish / Build artifacts and release (push) Failing after 16s
2021-09-13 19:32:05 +04:30
Aria Moradi 0a9e0bc9e4 update WebUI 2021-09-13 19:31:07 +04:30
Aria Moradi 5914e367d1 better CHANGELOG 2021-09-13 19:29:54 +04:30
Mitchell Syer aeaed888d4 Update Proto models to match Tachi preview (#192) 2021-09-12 04:04:48 +04:30
Aria Moradi a0f054b005 CHANGELOG.md 2021-09-11 19:47:06 +04:30
Aria Moradi 4498e9d444 beter handling of uninstalling Extensions 2021-09-11 16:26:04 +04:30
Aria Moradi 43e0763fef fix a bug where if two sources return the same URL, a false duplicate might be detected 2021-09-11 14:47:03 +04:30
Aria Moradi a519c8a482 better PR links 2021-09-11 03:18:36 +04:30
Aria Moradi 19bc595a2a update CHANGELOG.md 2021-09-11 03:13:26 +04:30
Aria Moradi db07825c58 remove println instance 2021-09-11 03:09:26 +04:30
Aria Moradi b199e3bf0e fix installing apk with weird name 2021-09-11 01:21:22 +04:30
Aria Moradi 1f9ea0891e add some logging 2021-09-11 00:48:17 +04:30
Aria Moradi 0a7aa48f1e fix apk log and apk name 2021-09-11 00:14:52 +04:30
Aria Moradi 4b65b7da6c rm dummy 2021-09-10 21:42:52 +04:30
Mosei 183a7dac4b Create docker_build_stable (#184)
* Create docker_build_stable

Add docker build workflow

* Rename docker_build_stable to docker_build_stable.yml

* Update docker_build_stable.yml

* Update docker_build_stable.yml
2021-09-10 21:42:31 +04:30
Aria Moradi 6726f008c1 add dummy file to trigger workflow 2021-09-10 21:22:28 +04:30
Mosei 89cf0c140f Update publish.yml (#190) 2021-09-10 20:49:57 +04:30
Mosei 504025ce80 Update build_push.yml (#191) 2021-09-10 20:49:33 +04:30
Aria Moradi fee9e914f1 fix exposed crash 2021-09-10 00:45:00 +04:30
Aria Moradi 76efa71c68 update WebUI 2021-09-09 23:05:58 +04:30
Aria Moradi 26e61959ae update WebUI 2021-09-09 16:46:19 +04:30
Aria Moradi 9a8956ef9d update dependencies 2021-09-09 16:41:41 +04:30
Aria Moradi 10d3ffc2f6 better UX 2021-09-09 06:05:09 +04:30
Aria Moradi 090399f61d add support for installing external APK 2021-09-09 05:22:45 +04:30
Aria Moradi ae7d975a92 run won't get stuck now 2021-09-09 05:15:51 +04:30
Aria Moradi 55ec6bcafe add TODO for changing later 2021-09-09 05:15:08 +04:30
Aria Moradi f0566d15af fix compiler warning 2021-09-08 20:27:29 +04:30
Aria Moradi a730b692bc update WebUI 2021-09-08 20:24:22 +04:30
Aria Moradi 826d767423 [SKIP CI] update CHANGELOG.md 2021-09-08 20:23:45 +04:30
Aria Moradi 6d227c7fcd Fix typo 2021-09-06 22:23:26 +04:30
Aria Moradi 7d9d97840e Update README.md 2021-09-06 14:41:00 +04:30
Mosei 110ded45a0 Update README.md (#189)
Remove depreciated arbuilder’s docker link
2021-09-05 21:16:25 +04:30
Aria Moradi 7872b593c7 Credit to @Syer10 2021-09-05 17:50:45 +04:30
Aria Moradi 90be30bddb add support for the new gnere type in WebUI 2021-09-05 01:40:34 +04:30
Mitchell Syer a298c61dab Change type of Manga.genre to a List<String> (#188) 2021-09-05 01:36:52 +04:30
Aria Moradi eb416c45bd remove not-code changes from CHANGELOG 2021-09-05 00:54:01 +04:30
Aria Moradi b05b817aeb Update README.md 2021-09-03 18:52:06 +04:30
63 changed files with 1807 additions and 644 deletions
-4
View File
@@ -123,7 +123,3 @@ jobs:
owner: "Suwayomi"
repo: "Tachidesk-Server-preview"
tag: ${{ steps.GenTagName.outputs.value }}
- name: Run Docker build workflow
run: |
curl -X POST -H "Accept: application/vnd.github.v3+json" -H "Authorization: token ${{ secrets.DEPLOY_PREVIEW_TOKEN }}" -d '{"ref":"main", "inputs":{"tachidesk_release_type": "preview"}}' https://api.github.com/repos/suwayomi/docker-tachidesk/actions/workflows/build_container_images.yml/dispatches
+15
View File
@@ -0,0 +1,15 @@
name: Docker Build Stable
on:
release:
types: [published]
jobs:
build_publish_docker_container:
runs-on: ubuntu-latest
steps:
- name: run docker build and publish script
run: |
curl -X POST -H "Accept: application/vnd.github.v3+json" -H "Authorization: token ${{ secrets.DEPLOY_PREVIEW_TOKEN }}" -d '{"ref":"main", "inputs":{"tachidesk_release_type": "stable"}}' https://api.github.com/repos/suwayomi/docker-tachidesk/actions/workflows/build_container_images.yml/dispatches
-6
View File
@@ -81,9 +81,3 @@ jobs:
tags: true
draft: true
verbose: true
- name: Run Docker build workflow
run: |
sleep 10 # sleep a bit to make sure the release is actually inside github db
curl -X POST -H "Accept: application/vnd.github.v3+json" -H "Authorization: token ${{ secrets.DEPLOY_PREVIEW_TOKEN }}" -d '{"ref":"main", "inputs":{"tachidesk_release_type": "stable"}}' https://api.github.com/repos/suwayomi/docker-tachidesk/actions/workflows/build_container_images.yml/dispatches
+4 -7
View File
@@ -1,4 +1,7 @@
# Server: v0.X.Y-next + WebUI: rXXX
# Server: v0.X.Y-rXXX + WebUI: rXXX
## TL;DR
<!-- TODO: fill before release -->
## Tachidesk-Server
### Public API
#### Non-breaking changes
@@ -13,9 +16,6 @@
### Private API
- N/A
#### Non-code changes
- N/A
## Tachidesk-WebUI
#### Visible changes
@@ -26,6 +26,3 @@
#### Internal changes
- N/A
#### Non-code changes
- N/A
+75 -9
View File
@@ -1,25 +1,46 @@
# Server: v0.4.9-next + WebUI: r769
# Server: v0.5.1 + WebUI: r803
## TL;DR
- Loading sources' manga list is at least twice as fast
- Added support for Tachiyomi's Local source
- Added BasicAuth support, now you can protect your Tachidesk instance if you are running it on a public server
- Added ability to turn off cache for image requests
<!-- TODO: fill before release -->
## Tachidesk-Server
### Public API
#### Non-breaking changes
- N/A
- (r915) add BasicAuth support
- (r918) add ability to delete downloaded chapters
- (r923-r930) add Local Source
- (r938) add ability to turn off cache for image requests
#### Breaking changes
- N/A
#### Bug fixes
- N/A
- (r917) detect if a downloaded chapter is missing
### Private API
- N/A
- (r913) remove expand char limit on MangaTable columns
- (r914) migrate to Javalin 4
- (r921) depricate zero based chapters
- (r937) add ChapterRecognition from tachiyomi, closes #10
#### Non-code changes
- N/A
## Tachidesk-WebUI
#### Visible changes
- N/A
- (r790) nice looking progress percentage
- (r791) show a Delete button for downloaded chapters
- (r792) Update hover effect using more of Material-UI color pallete ([#29](https://github.com/Suwayomi/Tachidesk-WebUI/pull/21) by @voltrare)
- (r793) Optimize images ([#32](https://github.com/Suwayomi/Tachidesk-WebUI/pull/21) by @phanirithvij)
- (r794) try fix #30 ([#31](https://github.com/Suwayomi/Tachidesk-WebUI/pull/21) by @phanirithvij)
- (r795) fix viewing page number when the string is long
- (r796) show proper display name for source
- (r797) fail gracefully when a thumbnail has errors
- (r798) fix when a source fails to load mangas
- (r800) add Local source ([#31](https://github.com/Suwayomi/Tachidesk-WebUI/pull/21))
- (r803) add support for useCache
#### Bug fixes
- N/A
@@ -27,9 +48,54 @@
#### Internal changes
- N/A
#### Non-code changes
# Server: v0.5.0 + WebUI: r789
## TL;DR
- You can now install APK extensions from the extensions page
- WebUI now comes with an updated Material Design looks and is faster a little bit.
- WebUI now shows Nsfw content by default, disable it in settings if you prefer to not see Nsfw stuff
- Added support for configuration of sources, this enables MangaDex, Komga, Cubari and many other sources
- Chapters in the Manga page and Sources in the source page now look nicer and will glow with mouse hover
## Tachidesk-Server
### Public API
#### Non-breaking changes
- (r888) add installing APK from external sources endpoint
#### Breaking changes
- (r877 [#188](https://github.com/Suwayomi/Tachidesk-Server/pull/188) by @Syer10) `MangaDataClass.genre` changed type to `List<String>`
#### Bug fixes
- (r899-r901) fix when an external apk is installed and it doesn't have the default tachiyomi-extensions name
- (r905) fix a bug where if two sources return the same URL, a false duplicate might be detected
### Private API
- (r887) the `run` task won't call `downloadWebUI` now
- (r902) cleanup print/ln instances
- (r906) better handling of uninstalling Extensions
## Tachidesk-WebUI
#### Visible changes
- (r770) add support for the new genre type
- (r771) set the default value of `showNsfw` to `true` so we won't have visual artifacts with a clean install
- (r774 [#21](https://github.com/Suwayomi/Tachidesk-WebUI/pull/21) by @voltrare) `ReaderNavbar.jsx`: Swap close and retract Navbar buttons
- (r775 [#23](https://github.com/Suwayomi/Tachidesk-WebUI/pull/23) by @voltrare) `yarn.lock`: Fixes version inconsistency after commit 9b866811b
- (r776 [#23](https://github.com/Suwayomi/Tachidesk-WebUI/pull/23) by @voltrare) add margin between Source and Extension cards, make the Search button look nicer
- (r777) add support for installing external APK files
- (r778) fix the makeToaster?
- (r779) Action button for installing external extension
- (r780 Suwayomi/Tachidesk-WebUI#25) add on hover, active effect to Chapter/Episode card
- (r782-r785) updating material-ui to v5 changed the theme
- (r785-r788) better `SourceCard` looks on mobile, move `SourceDataClass.isConfigurable` gear button to `SourceMangas`
- (r789) implement source configuration
#### Bug fixes
- N/A
#### Internal changes
- (r782-r785) update dependencies, migrate material-ui from v4 to v5
# Server: v0.4.9 + WebUI: r769
@@ -109,4 +175,4 @@
- Re-ordering categories now works
#### Internal changes
- N/A
- N/A
+36 -14
View File
@@ -4,11 +4,11 @@
| ![CI](https://github.com/Suwayomi/Tachidesk/actions/workflows/build_push.yml/badge.svg) | [![stable release](https://img.shields.io/github/release/Suwayomi/Tachidesk.svg?maxAge=3600&label=download)](https://github.com/Suwayomi/Tachidesk/releases) | [![preview](https://img.shields.io/badge/dynamic/json?url=https://github.com/Suwayomi/Tachidesk-preview/raw/main/index.json&label=download&query=$.latest&color=blue)](https://github.com/Suwayomi/Tachidesk-preview/releases/latest) | [![Discord](https://img.shields.io/discord/801021177333940224.svg?label=discord&labelColor=7289da&color=2c2f33&style=flat)](https://discord.gg/DDZdqZWaHA) |
# Tachidesk-Server is a server app! You may not want to Download Tachidesk-Server directly.
Yes, 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 [Tachodesk-WebUI](https://github.com/Suwayomi/Tachidesk-WebUI) with it.
Yes, 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.
Here's a list of known clients/user interfaces for Tachidesk-Server:
- [Tachidesk-JUI](https://github.com/Suwayomi/Tachidesk-JUI): The "official" front-end for Tachidesk-Server, A native desktop Application.
- [Tachidesk-WebUI](https://github.com/Suwayomi/Tachidesk-WebUI): The web/electrion front-end that Tachidesk is traditionally shipped with.
- [Tachidesk-WebUI](https://github.com/Suwayomi/Tachidesk-WebUI): The web/electrion front-end that Tachidesk-Server is traditionally shipped with.
- [Tachidesk-qtui](https://github.com/Suwayomi/Tachidesk-qtui): A C++/Qt front-end for mobile devices(Android/linux), in super early stage of development.
- [Equinox](https://github.com/Suwayomi/Equinox): A web user interface made with Vue.js, in super early stage of development.
@@ -41,23 +41,45 @@ Here is a list of current features:
**Note:** Tachidesk-Server is alpha software and can break rarely and/or with each update. See [Troubleshooting](https://github.com/Suwayomi/Tachidesk-Server/wiki/Troubleshooting) if it happens.
## Downloading and Running the app
### All Operating Systems
You should have The Java Runtime Environment(JRE) 8 or newer and a modern browser installed(Google is your friend for seeking assitance). Also an internet connection is required as almost everything this app does is downloading stuff.
# Downloading and Running the app
## General Requirements
In order to use the app effectively you need the following:
- The jar release of Tachideesk-Server
- The Java Runtime Environment(JRE) 8 or newer (included in bundle releases)
- A Modern Browser like Google Chrome, Firefox, etc.
- ElectronJS (optional) (included in bundle releases)
- An internet connection (when you want to use online features)
## Using the jar release directly
Download the latest `.jar` release from [the releases section](https://github.com/Suwayomi/Tachidesk-Server/releases) or a preview jar build from [the preview repository](https://github.com/Suwayomi/Tachidesk-preview/releases).
Download the latest "Stable" jar release from [the releases section](https://github.com/Suwayomi/Tachidesk/releases) or a preview jar build from [the preview repository](https://github.com/Suwayomi/Tachidesk-preview/releases).
Make sure you have The Java Runtime Environment installed on your system, Double click on the jar file or run `java -jar Tachidesk-vX.Y.Z-rxxx.jar` (or `java -jar Tachidesk-latest.jar` if you have the latest preview) from a Terminal/Command Prompt window to run the app which will open a new browser window automatically. Also the System Tray Icon is your friend if you need to open the browser window again or close Tachidesk.
Double click on the jar file or run `java -jar Tachidesk-vX.Y.Z-rxxx.jar` (or `java -jar Tachidesk-latest.jar` if you have the latest preview) from a Terminal/Command Prompt window to run the app which will open a new browser window automatically. Also the System Tray Icon is your friend if you need to open the browser window again or close Tachidesk.
## Using Operating System Specific Bundles
To facilitate the use of Tachidesk we provide bundle releases that include The Java Runtime Environment, ElectronJS and 3 Tachidesk Launcher Scripts.
#### Launcher Scripts
- `Tachidesk Electron Launcher`: Launches Tachidesk inside Electron as a desktop applicaton
- `Tachidesk Browser Launcher`: Launches Tachidesk in a browser window
- `Tachidesk Debug Launcher`: Launches Tachidesk with debug logs attached. If Tachidesk doesn't work for you, running this can give you insight into why.
**Node:** Linux launcher scripts are named a bit differently but work the same.
### Windows
Download the latest "Stable" win32 or win64 (depending on your system, usually you want win64) release from [the releases section](https://github.com/Suwayomi/Tachidesk/releases) or a preview one from [the preview repository](https://github.com/Suwayomi/Tachidesk-preview/releases).
Download the latest `win32`(Windows 32-bit) or `win64`(Windows 64-bit) release from [the releases section](https://github.com/Suwayomi/Tachidesk/releases) or a preview one from [the preview repository](https://github.com/Suwayomi/Tachidesk-preview/releases).
The Windows specific build has java bundled inside, so you don't have to install java to use it. Unzip `Tachidesk-vX.Y.Z-rxxx-win64.zip` and run one of the Launcher files depending on what you want(see bellow). The rest works like the previous section.
#### Windows Launchers
- `Tachidesk Electron Launcher.bat`: Launches Tachidesk inside Electron as a desktop applicaton
- `Tachidesk Browser Launcher.bat`: Launches Tachidesk in a browser window
- `Tachidesk Debug Launcher.bat`: Launches Tachidesk with debug logs attached. If Tachidesk doesn't work for you, running this can give you insight into why.
Unzip the downloaded file and double click on one of the launcher scripts.
### macOS
Download the latest `macOS-x64`(older macOS systems) or `macOS-arm64`(Apple M1) release from [the releases section](https://github.com/Suwayomi/Tachidesk/releases) or a preview one from [the preview repository](https://github.com/Suwayomi/Tachidesk-preview/releases).
Unzip the downloaded file and double click on one of the launcher scripts.
### GNU/Linux
Download the latest `linux-x64`(x86_64) release from [the releases section](https://github.com/Suwayomi/Tachidesk/releases) or a preview one from [the preview repository](https://github.com/Suwayomi/Tachidesk-preview/releases).
`tar xvf` the downloaded file and double click on one of the launcher scripts or run them using the terminal.
## Other methods of getting Tachidesk
### Arch Linux
You can install Tachidesk from the AUR
```
@@ -65,7 +87,7 @@ yay -S tachidesk
```
### Docker
Check our Offical Docker release [Tachidesk Container](https://github.com/orgs/Suwayomi/packages/container/package/tachidesk) or use [arbuilder's](https://github.com/arbuilder/Tachidesk-docker) tachidesk docker repo for installation. Source code for our container is available at [docker-tachidesk](https://github.com/Suwayomi/docker-tachidesk). By default the server will be running on http://localhost:4567 open this url in your browser.
Check our Official Docker release [Tachidesk Container](https://github.com/orgs/Suwayomi/packages/container/package/tachidesk) for running Tachidesk Server in a docker container. Source code for our container is available at [docker-tachidesk](https://github.com/Suwayomi/docker-tachidesk). By default the server will be running on http://localhost:4567 open this url in your browser.
Install from the command line:
```
+9 -8
View File
@@ -46,18 +46,18 @@ configure(projects) {
testImplementation(kotlin("test-junit5"))
// coroutines
val coroutinesVersion = "1.5.0"
val coroutinesVersion = "1.5.1"
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.2.1"
val kotlinSerializationVersion = "1.3.0-RC"
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.5.0")
implementation("org.kodein.di:kodein-di-conf-jvm:7.7.0")
// Logging
implementation("org.slf4j:slf4j-api:1.7.30")
@@ -69,8 +69,8 @@ configure(projects) {
implementation("io.reactivex:rxkotlin:1.0.0")
implementation("com.jakewharton.rxrelay:rxrelay:1.2.0")
// JSoup
implementation("org.jsoup:jsoup:1.13.1")
// dependency both in AndroidCompat and extensions, version locked by Tachiyomi app/extensions
implementation("org.jsoup:jsoup:1.14.1")
// dependency of :AndroidCompat:Config
implementation("com.typesafe:config:1.4.1")
@@ -80,14 +80,15 @@ configure(projects) {
implementation("net.harawata:appdirs:1.2.1")
// dex2jar
val dex2jarVersion = "v21"
val dex2jarVersion = "v26"
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")
// Jackson
implementation("com.fasterxml.jackson.core:jackson-annotations:2.10.3")
// dependency both in AndroidCompat and server, version locked by javalin
implementation("com.fasterxml.jackson.core:jackson-annotations:2.12.4")
}
}
+4 -4
View File
@@ -7,16 +7,16 @@ import java.io.BufferedReader
* 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/. */
const val kotlinVersion = "1.5.21"
const val kotlinVersion = "1.5.30"
const val MainClass = "suwayomi.tachidesk.MainKt"
// should be bumped with each stable release
val tachideskVersion = System.getenv("ProductVersion") ?: "v0.4.9"
val tachideskVersion = System.getenv("ProductVersion") ?: "v0.5.1"
val webUIRevisionTag = System.getenv("WebUIRevision") ?: "r769"
val webUIRevisionTag = System.getenv("WebUIRevision") ?: "r803"
// counts commits on the the master branch
// counts commits on the master branch
val tachideskRevision = runCatching {
System.getenv("ProductRevision") ?: Runtime
.getRuntime()
+13 -11
View File
@@ -9,7 +9,7 @@ plugins {
application
kotlin("plugin.serialization")
id("com.github.johnrengelman.shadow") version "7.0.0"
id("org.jmailen.kotlinter") version "3.5.0"
id("org.jmailen.kotlinter") version "3.6.0"
id("com.github.gmazzo.buildconfig") version "3.0.2"
}
@@ -31,13 +31,13 @@ dependencies {
implementation("com.squareup.okio:okio:2.10.0")
// Javalin api
implementation("io.javalin:javalin:3.13.6")
// jackson version is tied to javalin, ref: `io.javalin.core.util.OptionalDependency`
implementation("com.fasterxml.jackson.core:jackson-databind:2.10.3")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.10.3")
implementation("io.javalin:javalin:4.0.0")
// jackson version locked by javalin, ref: `io.javalin.core.util.OptionalDependency`
implementation("com.fasterxml.jackson.core:jackson-databind:2.12.4")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.12.4")
// Exposed ORM
val exposedVersion = "0.31.1"
val exposedVersion = "0.34.1"
implementation("org.jetbrains.exposed:exposed-core:$exposedVersion")
implementation("org.jetbrains.exposed:exposed-dao:$exposedVersion")
implementation("org.jetbrains.exposed:exposed-jdbc:$exposedVersion")
@@ -46,13 +46,12 @@ dependencies {
implementation("com.h2database:h2:1.4.200")
// Exposed Migrations
val exposedMigrationsVersion = "3.1.1"
val exposedMigrationsVersion = "3.1.2"
implementation("com.github.Suwayomi:exposed-migrations:$exposedMigrationsVersion")
// tray icon
implementation("com.dorkbox:SystemTray:4.1")
implementation("com.dorkbox:Utilities:1.9")
implementation("com.dorkbox:Utilities:1.9") // version locked by SystemTray
// dependencies of Tachiyomi extensions, some are duplicate, keeping it here for reference
implementation("com.github.inorichi.injekt:injekt-core:65b0440")
@@ -62,12 +61,15 @@ dependencies {
implementation("com.google.code.gson:gson:2.8.7")
implementation("com.github.salomonbrys.kotson:kotson:2.5.0")
// Sort
implementation("com.github.gpanther:java-nat-sort:natural-comparator-1.1")
// asm for ByteCodeEditor(fixing SimpleDateFormat) (must match Dex2Jar version)
implementation("org.ow2.asm:asm:9.2")
// extracting zip files
// Disk & File
implementation("net.lingala.zip4j:zip4j:2.9.0")
implementation("com.github.junrar:junrar:7.4.0")
// CloudflareInterceptor
implementation("net.sourceforge.htmlunit:htmlunit:2.52.0")
@@ -162,7 +164,7 @@ tasks {
}
named("run") {
dependsOn("formatKotlin", "lintKotlin", "downloadWebUI")
dependsOn("formatKotlin", "lintKotlin")
}
named<Copy>("processResources") {
@@ -1,358 +0,0 @@
package eu.kanade.tachiyomi.source
import android.content.Context
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import rx.Observable
// import com.github.junrar.Archive
// import com.google.gson.JsonParser
// import eu.kanade.tachiyomi.R
// import eu.kanade.tachiyomi.source.model.Filter
// import eu.kanade.tachiyomi.source.model.FilterList
// import eu.kanade.tachiyomi.source.model.MangasPage
// import eu.kanade.tachiyomi.source.model.Page
// import eu.kanade.tachiyomi.source.model.SChapter
// import eu.kanade.tachiyomi.source.model.SManga
// import eu.kanade.tachiyomi.util.chapter.ChapterRecognition
// import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder
// import eu.kanade.tachiyomi.util.storage.DiskUtil
// import eu.kanade.tachiyomi.util.storage.EpubFile
// import eu.kanade.tachiyomi.util.system.ImageUtil
// import rx.Observable
// import timber.log.Timber
// import java.io.File
// import java.io.FileInputStream
// import java.io.InputStream
// import java.util.Locale
// import java.util.concurrent.TimeUnit
// import java.util.zip.ZipFile
class LocalSource(private val context: Context) : CatalogueSource {
companion object {
const val ID = 0L
// const val HELP_URL = "https://tachiyomi.org/help/guides/reading-local-manga/"
//
// private const val COVER_NAME = "cover.jpg"
// private val SUPPORTED_ARCHIVE_TYPES = setOf("zip", "rar", "cbr", "cbz", "epub")
//
// private val POPULAR_FILTERS = FilterList(OrderBy())
// private val LATEST_FILTERS = FilterList(OrderBy().apply { state = Filter.Sort.Selection(1, false) })
// private val LATEST_THRESHOLD = TimeUnit.MILLISECONDS.convert(7, TimeUnit.DAYS)
//
// fun updateCover(context: Context, manga: SManga, input: InputStream): File? {
// val dir = getBaseDirectories(context).firstOrNull()
// if (dir == null) {
// input.close()
// return null
// }
// val cover = File("${dir.absolutePath}/${manga.url}", COVER_NAME)
//
// // It might not exist if using the external SD card
// cover.parentFile?.mkdirs()
// input.use {
// cover.outputStream().use {
// input.copyTo(it)
// }
// }
// return cover
// }
//
// private fun getBaseDirectories(context: Context): List<File> {
// val c = context.getString(R.string.app_name) + File.separator + "local"
// return DiskUtil.getExternalStorages(context).map { File(it.absolutePath, c) }
// }
}
override val id = ID
override val name = "Local source"
override val lang = ""
override val supportsLatest = true
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
TODO("Not yet implemented")
}
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
TODO("Not yet implemented")
}
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
TODO("Not yet implemented")
}
override fun fetchPopularManga(page: Int): Observable<MangasPage> {
TODO("Not yet implemented")
}
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
TODO("Not yet implemented")
}
override fun fetchLatestUpdates(page: Int): Observable<MangasPage> {
TODO("Not yet implemented")
}
override fun getFilterList(): FilterList {
TODO("Not yet implemented")
}
//
// override fun toString() = context.getString(R.string.local_source)
//
// override fun fetchPopularManga(page: Int) = fetchSearchManga(page, "", POPULAR_FILTERS)
//
// override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
// val baseDirs = getBaseDirectories(context)
//
// val time = if (filters === LATEST_FILTERS) System.currentTimeMillis() - LATEST_THRESHOLD else 0L
// var mangaDirs = baseDirs
// .asSequence()
// .mapNotNull { it.listFiles()?.toList() }
// .flatten()
// .filter { it.isDirectory }
// .filterNot { it.name.startsWith('.') }
// .filter { if (time == 0L) it.name.contains(query, ignoreCase = true) else it.lastModified() >= time }
// .distinctBy { it.name }
//
// val state = ((if (filters.isEmpty()) POPULAR_FILTERS else filters)[0] as OrderBy).state
// when (state?.index) {
// 0 -> {
// mangaDirs = if (state.ascending) {
// mangaDirs.sortedBy { it.name.toLowerCase(Locale.ENGLISH) }
// } else {
// mangaDirs.sortedByDescending { it.name.toLowerCase(Locale.ENGLISH) }
// }
// }
// 1 -> {
// mangaDirs = if (state.ascending) {
// mangaDirs.sortedBy(File::lastModified)
// } else {
// mangaDirs.sortedByDescending(File::lastModified)
// }
// }
// }
//
// val mangas = mangaDirs.map { mangaDir ->
// SManga.create().apply {
// title = mangaDir.name
// url = mangaDir.name
//
// // Try to find the cover
// for (dir in baseDirs) {
// val cover = File("${dir.absolutePath}/$url", COVER_NAME)
// if (cover.exists()) {
// thumbnail_url = cover.absolutePath
// break
// }
// }
//
// val chapters = fetchChapterList(this).toBlocking().first()
// if (chapters.isNotEmpty()) {
// val chapter = chapters.last()
// val format = getFormat(chapter)
// if (format is Format.Epub) {
// EpubFile(format.file).use { epub ->
// epub.fillMangaMetadata(this)
// }
// }
//
// // Copy the cover from the first chapter found.
// if (thumbnail_url == null) {
// try {
// val dest = updateCover(chapter, this)
// thumbnail_url = dest?.absolutePath
// } catch (e: Exception) {
// Timber.e(e)
// }
// }
// }
// }
// }
//
// return Observable.just(MangasPage(mangas.toList(), false))
// }
//
// override fun fetchLatestUpdates(page: Int) = fetchSearchManga(page, "", LATEST_FILTERS)
//
// override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
// getBaseDirectories(context)
// .asSequence()
// .mapNotNull { File(it, manga.url).listFiles()?.toList() }
// .flatten()
// .firstOrNull { it.extension == "json" }
// ?.apply {
// val reader = this.inputStream().bufferedReader()
// val json = JsonParser.parseReader(reader).asJsonObject
//
// manga.title = json["title"]?.asString ?: manga.title
// manga.author = json["author"]?.asString ?: manga.author
// manga.artist = json["artist"]?.asString ?: manga.artist
// manga.description = json["description"]?.asString ?: manga.description
// manga.genre = json["genre"]?.asJsonArray?.joinToString(", ") { it.asString }
// ?: manga.genre
// manga.status = json["status"]?.asInt ?: manga.status
// }
//
// return Observable.just(manga)
// }
//
// override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
// val chapters = getBaseDirectories(context)
// .asSequence()
// .mapNotNull { File(it, manga.url).listFiles()?.toList() }
// .flatten()
// .filter { it.isDirectory || isSupportedFile(it.extension) }
// .map { chapterFile ->
// SChapter.create().apply {
// url = "${manga.url}/${chapterFile.name}"
// name = if (chapterFile.isDirectory) {
// chapterFile.name
// } else {
// chapterFile.nameWithoutExtension
// }
// date_upload = chapterFile.lastModified()
//
// val format = getFormat(this)
// if (format is Format.Epub) {
// EpubFile(format.file).use { epub ->
// epub.fillChapterMetadata(this)
// }
// }
//
// val chapNameCut = stripMangaTitle(name, manga.title)
// if (chapNameCut.isNotEmpty()) name = chapNameCut
// ChapterRecognition.parseChapterNumber(this, manga)
// }
// }
// .sortedWith(
// Comparator { c1, c2 ->
// val c = c2.chapter_number.compareTo(c1.chapter_number)
// if (c == 0) c2.name.compareToCaseInsensitiveNaturalOrder(c1.name) else c
// }
// )
// .toList()
//
// return Observable.just(chapters)
// }
//
// /**
// * Strips the manga title from a chapter name, matching only based on alphanumeric and whitespace
// * characters.
// */
// private fun stripMangaTitle(chapterName: String, mangaTitle: String): String {
// var chapterNameIndex = 0
// var mangaTitleIndex = 0
// while (chapterNameIndex < chapterName.length && mangaTitleIndex < mangaTitle.length) {
// val chapterChar = chapterName[chapterNameIndex]
// val mangaChar = mangaTitle[mangaTitleIndex]
// if (!chapterChar.equals(mangaChar, true)) {
// val invalidChapterChar = !chapterChar.isLetterOrDigit() && !chapterChar.isWhitespace()
// val invalidMangaChar = !mangaChar.isLetterOrDigit() && !mangaChar.isWhitespace()
//
// if (!invalidChapterChar && !invalidMangaChar) {
// return chapterName
// }
//
// if (invalidChapterChar) {
// chapterNameIndex++
// }
//
// if (invalidMangaChar) {
// mangaTitleIndex++
// }
// } else {
// chapterNameIndex++
// mangaTitleIndex++
// }
// }
//
// return chapterName.substring(chapterNameIndex).trimStart(' ', '-', '_', ',', ':')
// }
//
// override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
// return Observable.error(Exception("Unused"))
// }
//
// private fun isSupportedFile(extension: String): Boolean {
// return extension.toLowerCase() in SUPPORTED_ARCHIVE_TYPES
// }
//
// fun getFormat(chapter: SChapter): Format {
// val baseDirs = getBaseDirectories(context)
//
// for (dir in baseDirs) {
// val chapFile = File(dir, chapter.url)
// if (!chapFile.exists()) continue
//
// return getFormat(chapFile)
// }
// throw Exception("Chapter not found")
// }
//
// private fun getFormat(file: File): Format {
// val extension = file.extension
// return if (file.isDirectory) {
// Format.Directory(file)
// } else if (extension.equals("zip", true) || extension.equals("cbz", true)) {
// Format.Zip(file)
// } else if (extension.equals("rar", true) || extension.equals("cbr", true)) {
// Format.Rar(file)
// } else if (extension.equals("epub", true)) {
// Format.Epub(file)
// } else {
// throw Exception("Invalid chapter format")
// }
// }
//
// private fun updateCover(chapter: SChapter, manga: SManga): File? {
// return when (val format = getFormat(chapter)) {
// is Format.Directory -> {
// val entry = format.file.listFiles()
// ?.sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) }
// ?.find { !it.isDirectory && ImageUtil.isImage(it.name) { FileInputStream(it) } }
//
// entry?.let { updateCover(context, manga, it.inputStream()) }
// }
// is Format.Zip -> {
// ZipFile(format.file).use { zip ->
// val entry = zip.entries().toList()
// .sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) }
// .find { !it.isDirectory && ImageUtil.isImage(it.name) { zip.getInputStream(it) } }
//
// entry?.let { updateCover(context, manga, zip.getInputStream(it)) }
// }
// }
// is Format.Rar -> {
// Archive(format.file).use { archive ->
// val entry = archive.fileHeaders
// .sortedWith { f1, f2 -> f1.fileName.compareToCaseInsensitiveNaturalOrder(f2.fileName) }
// .find { !it.isDirectory && ImageUtil.isImage(it.fileName) { archive.getInputStream(it) } }
//
// entry?.let { updateCover(context, manga, archive.getInputStream(it)) }
// }
// }
// is Format.Epub -> {
// EpubFile(format.file).use { epub ->
// val entry = epub.getImagesFromPages()
// .firstOrNull()
// ?.let { epub.getEntry(it) }
//
// entry?.let { updateCover(context, manga, epub.getInputStream(it)) }
// }
// }
// }
// }
//
// private class OrderBy : Filter.Sort("Order by", arrayOf("Title", "Date"), Selection(0, true))
//
// override fun getFilterList() = FilterList(OrderBy())
//
// sealed class Format {
// data class Directory(val file: File) : Format()
// data class Zip(val file: File) : Format()
// data class Rar(val file: File) : Format()
// data class Epub(val file: File) : Format()
// }
}
@@ -0,0 +1,470 @@
package eu.kanade.tachiyomi.source.local
import com.github.junrar.Archive
import eu.kanade.tachiyomi.source.local.FileSystemInterceptor.fakeUrlFrom
import eu.kanade.tachiyomi.source.local.LocalSource.Format.Directory
import eu.kanade.tachiyomi.source.local.LocalSource.Format.Epub
import eu.kanade.tachiyomi.source.local.LocalSource.Format.Rar
import eu.kanade.tachiyomi.source.local.LocalSource.Format.Zip
import eu.kanade.tachiyomi.source.local.loader.EpubPageLoader
import eu.kanade.tachiyomi.source.local.loader.RarPageLoader
import eu.kanade.tachiyomi.source.local.loader.ZipPageLoader
import eu.kanade.tachiyomi.source.model.Filter
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.util.chapter.ChapterRecognition
import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder
import eu.kanade.tachiyomi.util.storage.EpubFile
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.decodeFromStream
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonPrimitive
import mu.KotlinLogging
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Protocol
import okhttp3.Request
import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody
import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.insertAndGetId
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction
import org.kodein.di.DI
import org.kodein.di.conf.global
import org.kodein.di.instance
import rx.Observable
import suwayomi.tachidesk.manga.impl.util.storage.ImageUtil
import suwayomi.tachidesk.manga.model.table.ExtensionTable
import suwayomi.tachidesk.manga.model.table.SourceTable
import suwayomi.tachidesk.server.ApplicationDirs
import uy.kohesive.injekt.injectLazy
import java.io.File
import java.io.FileInputStream
import java.io.FileNotFoundException
import java.io.InputStream
import java.net.URL
import java.util.Locale
import java.util.concurrent.TimeUnit
import java.util.zip.ZipFile
class LocalSource : HttpSource() {
companion object {
const val ID = 0L
const val LANG = "localsourcelang"
const val NAME = "Local source"
const val EXTENSION_NAME = "Local Source fake extension"
const val HELP_URL = "https://tachiyomi.org/help/guides/local-manga/"
private val SUPPORTED_ARCHIVE_TYPES = setOf("zip", "rar", "cbr", "cbz", "epub")
private val LATEST_THRESHOLD = TimeUnit.MILLISECONDS.convert(7, TimeUnit.DAYS)
private val logger = KotlinLogging.logger {}
private val applicationDirs by DI.global.instance<ApplicationDirs>()
val pageCache: MutableMap<String, List<() -> InputStream>> = mutableMapOf()
fun updateCover(manga: SManga, input: InputStream): File? {
val cover = getCoverFile(File("${applicationDirs.localMangaRoot}/${manga.url}"))
?: File("${applicationDirs.localMangaRoot}/${manga.url}/cover.jpg")
cover.parentFile?.mkdirs()
input.use {
cover.outputStream().use {
input.copyTo(it)
}
}
return cover
}
/**
* Returns valid cover file inside [parent] directory.
*/
private fun getCoverFile(parent: File): File? {
return parent.listFiles()?.find { it.nameWithoutExtension == "cover" }?.takeIf {
it.isFile && ImageUtil.isImage(it.name) { it.inputStream() }
}
}
fun addDbRecords() {
transaction {
val sourceRecord = SourceTable.select { SourceTable.id eq ID }.firstOrNull()
if (sourceRecord == null) {
// must do this to avoid database integrity errors
val extensionId = ExtensionTable.insertAndGetId {
it[apkName] = "localSource"
it[name] = EXTENSION_NAME
it[pkgName] = LocalSource::class.java.`package`.name
it[versionName] = "1.2"
it[versionCode] = 0
it[lang] = LANG
it[isNsfw] = false
it[isInstalled] = true
}
SourceTable.insert {
it[id] = ID
it[name] = NAME
it[lang] = LANG
it[extension] = extensionId
it[isNsfw] = false
}
}
}
}
}
override val id = ID
override val name = NAME
override val lang = LANG
override val baseUrl: String = ""
override val supportsLatest = true
override val client: OkHttpClient = super.client.newBuilder()
.addInterceptor(FileSystemInterceptor)
.build()
private val json: Json by injectLazy()
override fun toString() = name
override fun fetchPopularManga(page: Int) = fetchSearchManga(page, "", POPULAR_FILTERS)
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
val time = if (filters === LATEST_FILTERS) System.currentTimeMillis() - LATEST_THRESHOLD else 0L
var mangaDirs = File(applicationDirs.localMangaRoot).listFiles().orEmpty().toList()
.filter { it.isDirectory }
.filterNot { it.name.startsWith('.') }
.filter { if (time == 0L) it.name.contains(query, ignoreCase = true) else it.lastModified() >= time }
.distinctBy { it.name }
val state = ((if (filters.isEmpty()) POPULAR_FILTERS else filters)[0] as OrderBy).state
when (state?.index) {
0 -> {
mangaDirs = if (state.ascending) {
mangaDirs.sortedBy { it.name.lowercase(Locale.ENGLISH) }
} else {
mangaDirs.sortedByDescending { it.name.lowercase(Locale.ENGLISH) }
}
}
1 -> {
mangaDirs = if (state.ascending) {
mangaDirs.sortedBy(File::lastModified)
} else {
mangaDirs.sortedByDescending(File::lastModified)
}
}
}
val mangas = mangaDirs.map { mangaDir ->
SManga.create().apply {
title = mangaDir.name
url = mangaDir.name
// Try to find the cover
val cover = getCoverFile(File("${applicationDirs.localMangaRoot}/$url"))
if (cover != null && cover.exists()) {
thumbnail_url = fakeUrlFrom(cover.absolutePath)
}
val chapters = fetchChapterList(this).toBlocking().first()
if (chapters.isNotEmpty()) {
val chapter = chapters.last()
val format = getFormat(chapter)
if (format is Format.Epub) {
EpubFile(format.file).use { epub ->
epub.fillMangaMetadata(this)
}
}
// Copy the cover from the first chapter found.
if (thumbnail_url == null) {
try {
val dest = updateCover(chapter, this)
thumbnail_url = dest?.absolutePath?.let { fakeUrlFrom(it) }
} catch (e: Exception) {
logger.error { e }
}
}
}
}
}
return Observable.just(MangasPage(mangas.toList(), false))
}
override fun fetchLatestUpdates(page: Int) = fetchSearchManga(page, "", LATEST_FILTERS)
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
File(applicationDirs.localMangaRoot, manga.url).listFiles().orEmpty().toList()
.firstOrNull { it.extension == "json" }
?.apply {
val obj = json.decodeFromStream<JsonObject>(inputStream())
manga.title = obj["title"]?.jsonPrimitive?.contentOrNull ?: manga.title
manga.author = obj["author"]?.jsonPrimitive?.contentOrNull ?: manga.author
manga.artist = obj["artist"]?.jsonPrimitive?.contentOrNull ?: manga.artist
manga.description = obj["description"]?.jsonPrimitive?.contentOrNull ?: manga.description
manga.genre = obj["genre"]?.jsonArray?.joinToString(", ") { it.jsonPrimitive.content }
?: manga.genre
manga.status = obj["status"]?.jsonPrimitive?.intOrNull ?: manga.status
}
return Observable.just(manga)
}
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
val chapters = File(applicationDirs.localMangaRoot, manga.url).listFiles().orEmpty().toList()
.filter { it.isDirectory || isSupportedFile(it.extension) }
.map { chapterFile ->
SChapter.create().apply {
url = "${manga.url}/${chapterFile.name}"
name = if (chapterFile.isDirectory) {
chapterFile.name
} else {
chapterFile.nameWithoutExtension
}
date_upload = chapterFile.lastModified()
val format = getFormat(this)
if (format is Format.Epub) {
EpubFile(format.file).use { epub ->
epub.fillChapterMetadata(this)
}
}
val chapNameCut = stripMangaTitle(name, manga.title)
if (chapNameCut.isNotEmpty()) name = chapNameCut
ChapterRecognition.parseChapterNumber(this, manga)
}
}
.sortedWith { c1, c2 ->
val c = c2.chapter_number.compareTo(c1.chapter_number)
if (c == 0) c2.name.compareToCaseInsensitiveNaturalOrder(c1.name) else c
}
.toList()
return Observable.just(chapters)
}
/**
* Strips the manga title from a chapter name, matching only based on alphanumeric and whitespace
* characters.
*/
private fun stripMangaTitle(chapterName: String, mangaTitle: String): String {
var chapterNameIndex = 0
var mangaTitleIndex = 0
while (chapterNameIndex < chapterName.length && mangaTitleIndex < mangaTitle.length) {
val chapterChar = chapterName[chapterNameIndex]
val mangaChar = mangaTitle[mangaTitleIndex]
if (!chapterChar.equals(mangaChar, true)) {
val invalidChapterChar = !chapterChar.isLetterOrDigit() && !chapterChar.isWhitespace()
val invalidMangaChar = !mangaChar.isLetterOrDigit() && !mangaChar.isWhitespace()
if (!invalidChapterChar && !invalidMangaChar) {
return chapterName
}
if (invalidChapterChar) {
chapterNameIndex++
}
if (invalidMangaChar) {
mangaTitleIndex++
}
} else {
chapterNameIndex++
mangaTitleIndex++
}
}
return chapterName.substring(chapterNameIndex).trimStart(' ', '-', '_', ',', ':')
}
private fun isSupportedFile(extension: String): Boolean {
return extension.lowercase() in SUPPORTED_ARCHIVE_TYPES
}
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
val chapterFile = File(applicationDirs.localMangaRoot + "/" + chapter.url)
return when (getFormat(chapterFile)) {
is Directory -> {
Observable.just(
chapterFile.listFiles().orEmpty().sortedBy { it.name }.mapIndexed { index, page ->
Page(
index,
imageUrl = fakeUrlFrom(applicationDirs.localMangaRoot + "/" + chapter.url + "/" + page.name)
)
}
)
}
is Zip -> {
val pages = ZipPageLoader(chapterFile).getPages()
pageCache[chapter.url] = pages.map { it.stream!! }
Observable.just(pages)
}
is Rar -> {
val pages = RarPageLoader(chapterFile).getPages()
pageCache[chapter.url] = pages.map { it.stream!! }
Observable.just(pages)
}
is Epub -> {
val pages = EpubPageLoader(chapterFile).getPages()
pageCache[chapter.url] = pages.map { it.stream!! }
Observable.just(pages)
}
}
}
fun getFormat(chapter: SChapter): Format {
val chapFile = File(applicationDirs.localMangaRoot, chapter.url)
if (chapFile.exists())
return getFormat(chapFile)
throw Exception("Chapter not found")
}
private fun getFormat(file: File): Format {
return with(file) {
when {
isDirectory -> Format.Directory(file)
extension.equals("zip", true) -> Format.Zip(file)
extension.equals("cbz", true) -> Format.Zip(file)
extension.equals("rar", true) -> Format.Rar(file)
extension.equals("cbr", true) -> Format.Rar(file)
extension.equals("epub", true) -> Format.Epub(file)
else -> throw Exception("Invalid chapter format")
}
}
}
private fun updateCover(chapter: SChapter, manga: SManga): File? {
return when (val format = getFormat(chapter)) {
is Format.Directory -> {
val entry = format.file.listFiles()
?.sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) }
?.find { !it.isDirectory && ImageUtil.isImage(it.name) { FileInputStream(it) } }
entry?.let { updateCover(manga, it.inputStream()) }
}
is Format.Zip -> {
ZipFile(format.file).use { zip ->
val entry = zip.entries().toList()
.sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) }
.find { !it.isDirectory && ImageUtil.isImage(it.name) { zip.getInputStream(it) } }
entry?.let { updateCover(manga, zip.getInputStream(it)) }
}
}
is Format.Rar -> {
Archive(format.file).use { archive ->
val entry = archive.fileHeaders
.sortedWith { f1, f2 -> f1.fileName.compareToCaseInsensitiveNaturalOrder(f2.fileName) }
.find { !it.isDirectory && ImageUtil.isImage(it.fileName) { archive.getInputStream(it) } }
entry?.let { updateCover(manga, archive.getInputStream(it)) }
}
}
is Format.Epub -> {
EpubFile(format.file).use { epub ->
val entry = epub.getImagesFromPages()
.firstOrNull()
?.let { epub.getEntry(it) }
entry?.let { updateCover(manga, epub.getInputStream(it)) }
}
}
}
}
override fun getFilterList() = POPULAR_FILTERS
private val POPULAR_FILTERS = FilterList(OrderBy())
private val LATEST_FILTERS = FilterList(OrderBy().apply { state = Filter.Sort.Selection(1, false) })
private class OrderBy : Filter.Sort(
"Order by",
arrayOf("Title", "Date"),
Selection(0, true)
)
sealed class Format {
data class Directory(val file: File) : Format()
data class Zip(val file: File) : Format()
data class Rar(val file: File) : Format()
data class Epub(val file: File) : Format()
}
// ///////////////////// Not used ///////////////////// //
override fun mangaDetailsParse(response: Response): SManga = throw Exception("Not used")
override fun chapterListParse(response: Response): List<SChapter> = throw Exception("Not used")
override fun pageListParse(response: Response): List<Page> = throw Exception("Not used")
override fun imageUrlParse(response: Response): String = throw Exception("Not used")
override fun popularMangaRequest(page: Int): Request = throw Exception("Not used")
override fun popularMangaParse(response: Response): MangasPage = throw Exception("Not used")
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request =
throw Exception("Not used")
override fun searchMangaParse(response: Response): MangasPage = throw Exception("Not used")
override fun latestUpdatesRequest(page: Int): Request = throw Exception("Not used")
override fun latestUpdatesParse(response: Response): MangasPage = throw Exception("Not used")
}
private object FileSystemInterceptor : Interceptor {
fun fakeUrlFrom(path: String) = "http://$path"
private fun restoreFileUrl(markedFakeHttpUrl: String): String {
return markedFakeHttpUrl.replaceFirst("http:", "file:/")
}
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val url = request.url
val fileUrl = restoreFileUrl(url.toString())
return try {
Response.Builder()
.body(URL(fileUrl).readBytes().toResponseBody())
.code(200)
.message("Some file")
.protocol(Protocol.HTTP_1_0)
.request(request)
.build()
} catch (e: FileNotFoundException) {
Response.Builder()
.body("".toResponseBody())
.code(404)
.message(e.message ?: "File not found ($fileUrl)")
.protocol(Protocol.HTTP_1_0)
.request(request)
.build()
}
}
}
@@ -0,0 +1,31 @@
package eu.kanade.tachiyomi.source.local.loader
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.util.storage.EpubFile
import java.io.File
/**
* Loader used to load a chapter from a .epub file.
*/
class EpubPageLoader(file: File) : PageLoader {
/**
* The epub file.
*/
private val epub = EpubFile(file)
/**
* Returns an observable containing the pages found on this zip archive ordered with a natural
* comparator.
*/
override fun getPages(): List<ReaderPage> {
return epub.getImagesFromPages()
.mapIndexed { i, path ->
val streamFn = { epub.getInputStream(epub.getEntry(path)!!) }
ReaderPage(i).apply {
stream = streamFn
status = Page.READY
}
}
}
}
@@ -0,0 +1,10 @@
package eu.kanade.tachiyomi.source.local.loader
// adapted from eu.kanade.tachiyomi.ui.reader.loader.PageLoader
interface PageLoader {
/**
* Returns an observable containing the list of pages of a chapter. Only the first emission
* will be used.
*/
fun getPages(): List<ReaderPage>
}
@@ -0,0 +1,63 @@
package eu.kanade.tachiyomi.source.local.loader
import com.github.junrar.Archive
import com.github.junrar.rarfile.FileHeader
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder
import suwayomi.tachidesk.manga.impl.util.storage.ImageUtil
import java.io.File
import java.io.InputStream
import java.io.PipedInputStream
import java.io.PipedOutputStream
import java.util.concurrent.Executors
/**
* Loader used to load a chapter from a .rar or .cbr file.
*/
class RarPageLoader(file: File) : PageLoader {
/**
* The rar archive to load pages from.
*/
private val archive = Archive(file)
/**
* Pool for copying compressed files to an input stream.
*/
private val pool = Executors.newFixedThreadPool(1)
/**
* Returns an observable containing the pages found on this rar archive ordered with a natural
* comparator.
*/
override fun getPages(): List<ReaderPage> {
return archive.fileHeaders
.filter { !it.isDirectory && ImageUtil.isImage(it.fileName) { archive.getInputStream(it) } }
.sortedWith { f1, f2 -> f1.fileName.compareToCaseInsensitiveNaturalOrder(f2.fileName) }
.mapIndexed { i, header ->
val streamFn = { getStream(header) }
ReaderPage(i).apply {
stream = streamFn
status = Page.READY
}
}
}
/**
* Returns an input stream for the given [header].
*/
private fun getStream(header: FileHeader): InputStream {
val pipeIn = PipedInputStream()
val pipeOut = PipedOutputStream(pipeIn)
pool.execute {
try {
pipeOut.use {
archive.extractFile(header, it)
}
} catch (e: Exception) {
}
}
return pipeIn
}
}
@@ -0,0 +1,11 @@
package eu.kanade.tachiyomi.source.local.loader
import eu.kanade.tachiyomi.source.model.Page
import java.io.InputStream
class ReaderPage(
index: Int,
url: String = "",
imageUrl: String? = null,
var stream: (() -> InputStream)? = null
) : Page(index, url, imageUrl, null)
@@ -0,0 +1,31 @@
package eu.kanade.tachiyomi.source.local.loader
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder
import suwayomi.tachidesk.manga.impl.util.storage.ImageUtil
import java.io.File
import java.util.zip.ZipFile
class ZipPageLoader(file: File) : PageLoader {
/**
* The zip file to load pages from.
*/
private val zip = ZipFile(file)
/**
* Returns an observable containing the pages found on this zip archive ordered with a natural
* comparator.
*/
override fun getPages(): List<ReaderPage> {
return zip.entries().toList()
.filter { !it.isDirectory && ImageUtil.isImage(it.name) { zip.getInputStream(it) } }
.sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) }
.mapIndexed { i, entry ->
val streamFn = { zip.getInputStream(entry) }
ReaderPage(i).apply {
stream = streamFn
status = Page.READY
}
}
}
}
@@ -0,0 +1,152 @@
package eu.kanade.tachiyomi.util.chapter
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
/**
* -R> = regex conversion.
*/
object ChapterRecognition {
/**
* All cases with Ch.xx
* Mokushiroku Alice Vol.1 Ch. 4: Misrepresentation -R> 4
*/
private val basic = Regex("""(?<=ch\.) *([0-9]+)(\.[0-9]+)?(\.?[a-z]+)?""")
/**
* Regex used when only one number occurrence
* Example: Bleach 567: Down With Snowwhite -R> 567
*/
private val occurrence = Regex("""([0-9]+)(\.[0-9]+)?(\.?[a-z]+)?""")
/**
* Regex used when manga title removed
* Example: Solanin 028 Vol. 2 -> 028 Vol.2 -> 028Vol.2 -R> 028
*/
private val withoutManga = Regex("""^([0-9]+)(\.[0-9]+)?(\.?[a-z]+)?""")
/**
* Regex used to remove unwanted tags
* Example Prison School 12 v.1 vol004 version1243 volume64 -R> Prison School 12
*/
private val unwanted = Regex("""(?<![a-z])(v|ver|vol|version|volume|season|s).?[0-9]+""")
/**
* Regex used to remove unwanted whitespace
* Example One Piece 12 special -R> One Piece 12special
*/
private val unwantedWhiteSpace = Regex("""(\s)(extra|special|omake)""")
fun parseChapterNumber(chapter: SChapter, manga: SManga) {
// If chapter number is known return.
if (chapter.chapter_number == -2f || chapter.chapter_number > -1f) {
return
}
// Get chapter title with lower case
var name = chapter.name.lowercase()
// Remove comma's from chapter.
name = name.replace(',', '.')
// Remove unwanted white spaces.
unwantedWhiteSpace.findAll(name).let {
it.forEach { occurrence -> name = name.replace(occurrence.value, occurrence.value.trim()) }
}
// Remove unwanted tags.
unwanted.findAll(name).let {
it.forEach { occurrence -> name = name.replace(occurrence.value, "") }
}
// Check base case ch.xx
if (updateChapter(basic.find(name), chapter)) {
return
}
// Check one number occurrence.
val occurrences: MutableList<MatchResult> = arrayListOf()
occurrence.findAll(name).let {
it.forEach { occurrence -> occurrences.add(occurrence) }
}
if (occurrences.size == 1) {
if (updateChapter(occurrences[0], chapter)) {
return
}
}
// Remove manga title from chapter title.
val nameWithoutManga = name.replace(manga.title.lowercase(), "").trim()
// Check if first value is number after title remove.
if (updateChapter(withoutManga.find(nameWithoutManga), chapter)) {
return
}
// Take the first number encountered.
if (updateChapter(occurrence.find(nameWithoutManga), chapter)) {
return
}
}
/**
* Check if volume is found and update chapter
* @param match result of regex
* @param chapter chapter object
* @return true if volume is found
*/
private fun updateChapter(match: MatchResult?, chapter: SChapter): Boolean {
match?.let {
val initial = it.groups[1]?.value?.toFloat()!!
val subChapterDecimal = it.groups[2]?.value
val subChapterAlpha = it.groups[3]?.value
val addition = checkForDecimal(subChapterDecimal, subChapterAlpha)
chapter.chapter_number = initial.plus(addition)
return true
}
return false
}
/**
* Check for decimal in received strings
* @param decimal decimal value of regex
* @param alpha alpha value of regex
* @return decimal/alpha float value
*/
private fun checkForDecimal(decimal: String?, alpha: String?): Float {
if (!decimal.isNullOrEmpty()) {
return decimal.toFloat()
}
if (!alpha.isNullOrEmpty()) {
if (alpha.contains("extra")) {
return .99f
}
if (alpha.contains("omake")) {
return .98f
}
if (alpha.contains("special")) {
return .97f
}
return if (alpha[0] == '.') {
// Take value after (.)
parseAlphaPostFix(alpha[1])
} else {
parseAlphaPostFix(alpha[0])
}
}
return .0f
}
/**
* x.a -> x.1, x.b -> x.2, etc
*/
private fun parseAlphaPostFix(alpha: Char): Float {
return ("0." + (alpha.code - 96).toString()).toFloat()
}
}
@@ -0,0 +1,58 @@
package eu.kanade.tachiyomi.util.lang
import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator
import kotlin.math.floor
/**
* Replaces the given string to have at most [count] characters using [replacement] at its end.
* If [replacement] is longer than [count] an exception will be thrown when `length > count`.
*/
fun String.chop(count: Int, replacement: String = ""): String {
return if (length > count) {
take(count - replacement.length) + replacement
} else {
this
}
}
/**
* Replaces the given string to have at most [count] characters using [replacement] near the center.
* If [replacement] is longer than [count] an exception will be thrown when `length > count`.
*/
fun String.truncateCenter(count: Int, replacement: String = "..."): String {
if (length <= count) {
return this
}
val pieceLength: Int = floor((count - replacement.length).div(2.0)).toInt()
return "${take(pieceLength)}$replacement${takeLast(pieceLength)}"
}
/**
* Case-insensitive natural comparator for strings.
*/
fun String.compareToCaseInsensitiveNaturalOrder(other: String): Int {
val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance<String>()
return comparator.compare(this, other)
}
/**
* Returns the size of the string as the number of bytes.
*/
fun String.byteSize(): Int {
return toByteArray(Charsets.UTF_8).size
}
/**
* Returns a string containing the first [n] bytes from this string, or the entire string if this
* string is shorter.
*/
fun String.takeBytes(n: Int): String {
val bytes = toByteArray(Charsets.UTF_8)
return if (bytes.size <= n) {
this
} else {
bytes.decodeToString(endIndex = n).replace("\uFFFD", "")
}
}
@@ -0,0 +1,215 @@
package eu.kanade.tachiyomi.util.storage
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import java.io.Closeable
import java.io.File
import java.io.InputStream
import java.text.ParseException
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.zip.ZipEntry
import java.util.zip.ZipFile
/**
* Wrapper over ZipFile to load files in epub format.
*/
class EpubFile(file: File) : Closeable {
/**
* Zip file of this epub.
*/
private val zip = ZipFile(file)
/**
* Path separator used by this epub.
*/
private val pathSeparator = getPathSeparator()
/**
* Closes the underlying zip file.
*/
override fun close() {
zip.close()
}
/**
* Returns an input stream for reading the contents of the specified zip file entry.
*/
fun getInputStream(entry: ZipEntry): InputStream {
return zip.getInputStream(entry)
}
/**
* Returns the zip file entry for the specified name, or null if not found.
*/
fun getEntry(name: String): ZipEntry? {
return zip.getEntry(name)
}
/**
* Fills manga metadata using this epub file's metadata.
*/
fun fillMangaMetadata(manga: SManga) {
val ref = getPackageHref()
val doc = getPackageDocument(ref)
val creator = doc.getElementsByTag("dc:creator").first()
val description = doc.getElementsByTag("dc:description").first()
manga.author = creator?.text()
manga.description = description?.text()
}
/**
* Fills chapter metadata using this epub file's metadata.
*/
fun fillChapterMetadata(chapter: SChapter) {
val ref = getPackageHref()
val doc = getPackageDocument(ref)
val title = doc.getElementsByTag("dc:title").first()
val publisher = doc.getElementsByTag("dc:publisher").first()
val creator = doc.getElementsByTag("dc:creator").first()
var date = doc.getElementsByTag("dc:date").first()
if (date == null) {
date = doc.select("meta[property=dcterms:modified]").first()
}
if (title != null) {
chapter.name = title.text()
}
if (publisher != null) {
chapter.scanlator = publisher.text()
} else if (creator != null) {
chapter.scanlator = creator.text()
}
if (date != null) {
val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.getDefault())
try {
val parsedDate = dateFormat.parse(date.text())
if (parsedDate != null) {
chapter.date_upload = parsedDate.time
}
} catch (e: ParseException) {
// Empty
}
}
}
/**
* Returns the path of all the images found in the epub file.
*/
fun getImagesFromPages(): List<String> {
val ref = getPackageHref()
val doc = getPackageDocument(ref)
val pages = getPagesFromDocument(doc)
return getImagesFromPages(pages, ref)
}
/**
* Returns the path to the package document.
*/
private fun getPackageHref(): String {
val meta = zip.getEntry(resolveZipPath("META-INF", "container.xml"))
if (meta != null) {
val metaDoc = zip.getInputStream(meta).use { Jsoup.parse(it, null, "") }
val path = metaDoc.getElementsByTag("rootfile").first()?.attr("full-path")
if (path != null) {
return path
}
}
return resolveZipPath("OEBPS", "content.opf")
}
/**
* Returns the package document where all the files are listed.
*/
private fun getPackageDocument(ref: String): Document {
val entry = zip.getEntry(ref)
return zip.getInputStream(entry).use { Jsoup.parse(it, null, "") }
}
/**
* Returns all the pages from the epub.
*/
private fun getPagesFromDocument(document: Document): List<String> {
val pages = document.select("manifest > item")
.filter { "application/xhtml+xml" == it.attr("media-type") }
.associateBy { it.attr("id") }
val spine = document.select("spine > itemref").map { it.attr("idref") }
return spine.mapNotNull { pages[it] }.map { it.attr("href") }
}
/**
* Returns all the images contained in every page from the epub.
*/
private fun getImagesFromPages(pages: List<String>, packageHref: String): List<String> {
val result = mutableListOf<String>()
val basePath = getParentDirectory(packageHref)
pages.forEach { page ->
val entryPath = resolveZipPath(basePath, page)
val entry = zip.getEntry(entryPath)
val document = zip.getInputStream(entry).use { Jsoup.parse(it, null, "") }
val imageBasePath = getParentDirectory(entryPath)
document.allElements.forEach {
if (it.tagName() == "img") {
result.add(resolveZipPath(imageBasePath, it.attr("src")))
} else if (it.tagName() == "image") {
result.add(resolveZipPath(imageBasePath, it.attr("xlink:href")))
}
}
}
return result
}
/**
* Returns the path separator used by the epub file.
*/
private fun getPathSeparator(): String {
val meta = zip.getEntry("META-INF\\container.xml")
return if (meta != null) {
"\\"
} else {
"/"
}
}
/**
* Resolves a zip path from base and relative components and a path separator.
*/
private fun resolveZipPath(basePath: String, relativePath: String): String {
if (relativePath.startsWith(pathSeparator)) {
// Path is absolute, so return as-is.
return relativePath
}
var fixedBasePath = basePath.replace(pathSeparator, File.separator)
if (!fixedBasePath.startsWith(File.separator)) {
fixedBasePath = "${File.separator}$fixedBasePath"
}
val fixedRelativePath = relativePath.replace(pathSeparator, File.separator)
val resolvedPath = File(fixedBasePath, fixedRelativePath).canonicalPath
return resolvedPath.replace(File.separator, pathSeparator).substring(1)
}
/**
* Gets the parent directory of a path.
*/
private fun getParentDirectory(path: String): String {
val separatorIndex = path.lastIndexOf(pathSeparator)
return if (separatorIndex >= 0) {
path.substring(0, separatorIndex)
} else {
""
}
}
}
@@ -28,7 +28,7 @@ object AnimeAPI {
fun defineEndpoints(app: Javalin) {
// list all extensions
app.get("/api/v1/anime/extension/list") { ctx ->
ctx.json(
ctx.future(
future {
getExtensionList()
}
@@ -36,10 +36,10 @@ object AnimeAPI {
}
// install extension identified with "pkgName"
app.get("/api/v1/anime/extension/install/:pkgName") { ctx ->
app.get("/api/v1/anime/extension/install/{pkgName}") { ctx ->
val pkgName = ctx.pathParam("pkgName")
ctx.json(
ctx.future(
future {
installExtension(pkgName)
}
@@ -47,10 +47,10 @@ object AnimeAPI {
}
// update extension identified with "pkgName"
app.get("/api/v1/anime/extension/update/:pkgName") { ctx ->
app.get("/api/v1/anime/extension/update/{pkgName}") { ctx ->
val pkgName = ctx.pathParam("pkgName")
ctx.json(
ctx.future(
future {
updateExtension(pkgName)
}
@@ -58,7 +58,7 @@ object AnimeAPI {
}
// uninstall extension identified with "pkgName"
app.get("/api/v1/anime/extension/uninstall/:pkgName") { ctx ->
app.get("/api/v1/anime/extension/uninstall/{pkgName}") { ctx ->
val pkgName = ctx.pathParam("pkgName")
uninstallExtension(pkgName)
@@ -66,10 +66,10 @@ object AnimeAPI {
}
// icon for extension named `apkName`
app.get("/api/v1/anime/extension/icon/:apkName") { ctx -> // TODO: move to pkgName
app.get("/api/v1/anime/extension/icon/{apkName}") { ctx -> // TODO: move to pkgName
val apkName = ctx.pathParam("apkName")
ctx.result(
ctx.future(
future { getExtensionIcon(apkName) }
.thenApply {
ctx.header("content-type", it.second)
@@ -84,16 +84,16 @@ object AnimeAPI {
}
// fetch source with id `sourceId`
app.get("/api/v1/anime/source/:sourceId") { ctx ->
app.get("/api/v1/anime/source/{sourceId}") { ctx ->
val sourceId = ctx.pathParam("sourceId").toLong()
ctx.json(getAnimeSource(sourceId))
}
// popular animes from source with id `sourceId`
app.get("/api/v1/anime/source/:sourceId/popular/:pageNum") { ctx ->
app.get("/api/v1/anime/source/{sourceId}/popular/{pageNum}") { ctx ->
val sourceId = ctx.pathParam("sourceId").toLong()
val pageNum = ctx.pathParam("pageNum").toInt()
ctx.json(
ctx.future(
future {
getAnimeList(sourceId, pageNum, popular = true)
}
@@ -101,10 +101,10 @@ object AnimeAPI {
}
// latest animes from source with id `sourceId`
app.get("/api/v1/anime/source/:sourceId/latest/:pageNum") { ctx ->
app.get("/api/v1/anime/source/{sourceId}/latest/{pageNum}") { ctx ->
val sourceId = ctx.pathParam("sourceId").toLong()
val pageNum = ctx.pathParam("pageNum").toInt()
ctx.json(
ctx.future(
future {
getAnimeList(sourceId, pageNum, popular = false)
}
@@ -112,11 +112,11 @@ object AnimeAPI {
}
// get anime info
app.get("/api/v1/anime/anime/:animeId/") { ctx ->
app.get("/api/v1/anime/anime/{animeId}/") { ctx ->
val animeId = ctx.pathParam("animeId").toInt()
val onlineFetch = ctx.queryParam("onlineFetch", "false").toBoolean()
val onlineFetch = ctx.queryParam("onlineFetch")?.toBoolean() ?: false
ctx.json(
ctx.future(
future {
getAnime(animeId, onlineFetch)
}
@@ -124,10 +124,10 @@ object AnimeAPI {
}
// anime thumbnail
app.get("api/v1/anime/anime/:animeId/thumbnail") { ctx ->
app.get("api/v1/anime/anime/{animeId}/thumbnail") { ctx ->
val animeId = ctx.pathParam("animeId").toInt()
ctx.result(
ctx.future(
future { getAnimeThumbnail(animeId) }
.thenApply {
ctx.header("content-type", it.second)
@@ -137,13 +137,13 @@ object AnimeAPI {
}
//
// // list manga's categories
// app.get("api/v1/manga/:mangaId/category/") { ctx ->
// app.get("api/v1/manga/{mangaId}/category/") { ctx ->
// val mangaId = ctx.pathParam("mangaId").toInt()
// ctx.json(getMangaCategories(mangaId))
// }
//
// // adds the manga to category
// app.get("api/v1/manga/:mangaId/category/:categoryId") { ctx ->
// app.get("api/v1/manga/{mangaId}/category/{categoryId}") { ctx ->
// val mangaId = ctx.pathParam("mangaId").toInt()
// val categoryId = ctx.pathParam("categoryId").toInt()
// addMangaToCategory(mangaId, categoryId)
@@ -151,7 +151,7 @@ object AnimeAPI {
// }
//
// // removes the manga from the category
// app.delete("api/v1/manga/:mangaId/category/:categoryId") { ctx ->
// app.delete("api/v1/manga/{mangaId}/category/{categoryId}") { ctx ->
// val mangaId = ctx.pathParam("mangaId").toInt()
// val categoryId = ctx.pathParam("categoryId").toInt()
// removeMangaFromCategory(mangaId, categoryId)
@@ -159,23 +159,23 @@ object AnimeAPI {
// }
//
// get episode list when showing a anime
app.get("/api/v1/anime/anime/:animeId/episodes") { ctx ->
app.get("/api/v1/anime/anime/{animeId}/episodes") { ctx ->
val animeId = ctx.pathParam("animeId").toInt()
val onlineFetch = ctx.queryParam("onlineFetch")?.toBoolean()
ctx.json(future { getEpisodeList(animeId, onlineFetch) })
ctx.future(future { getEpisodeList(animeId, onlineFetch) })
}
// used to display a episode, get a episode in order to show it's <Quality pending>
app.get("/api/v1/anime/anime/:animeId/episode/:episodeIndex") { ctx ->
app.get("/api/v1/anime/anime/{animeId}/episode/{episodeIndex}") { ctx ->
val episodeIndex = ctx.pathParam("episodeIndex").toInt()
val animeId = ctx.pathParam("animeId").toInt()
ctx.json(future { getEpisode(episodeIndex, animeId) })
ctx.future(future { getEpisode(episodeIndex, animeId) })
}
// used to modify a episode's parameters
app.patch("/api/v1/anime/anime/:animeId/episode/:episodeIndex") { ctx ->
app.patch("/api/v1/anime/anime/{animeId}/episode/{episodeIndex}") { ctx ->
val episodeIndex = ctx.pathParam("episodeIndex").toInt()
val animeId = ctx.pathParam("animeId").toInt()
@@ -190,7 +190,7 @@ object AnimeAPI {
}
//
// // get page at index "index"
// app.get("/api/v1/manga/:mangaId/chapter/:chapterIndex/page/:index") { ctx ->
// app.get("/api/v1/manga/{mangaId}/chapter/{chapterIndex}/page/{index}") { ctx ->
// val mangaId = ctx.pathParam("mangaId").toInt()
// val chapterIndex = ctx.pathParam("chapterIndex").toInt()
// val index = ctx.pathParam("index").toInt()
@@ -205,49 +205,49 @@ object AnimeAPI {
// }
//
// // submit a chapter for download
// app.put("/api/v1/manga/:mangaId/chapter/:chapterIndex/download") { ctx ->
// app.put("/api/v1/manga/{mangaId}/chapter/{chapterIndex}/download") { ctx ->
// // TODO
// }
//
// // cancel a chapter download
// app.delete("/api/v1/manga/:mangaId/chapter/:chapterIndex/download") { ctx ->
// app.delete("/api/v1/manga/{mangaId}/chapter/{chapterIndex}/download") { ctx ->
// // TODO
// }
//
// // global search, Not implemented yet
// app.get("/api/v1/search/:searchTerm") { ctx ->
// app.get("/api/v1/search/{searchTerm}") { ctx ->
// val searchTerm = ctx.pathParam("searchTerm")
// ctx.json(sourceGlobalSearch(searchTerm))
// }
//
// single source search
app.get("/api/v1/anime/source/:sourceId/search/:searchTerm/:pageNum") { ctx ->
app.get("/api/v1/anime/source/{sourceId}/search/{searchTerm}/{pageNum}") { ctx ->
val sourceId = ctx.pathParam("sourceId").toLong()
val searchTerm = ctx.pathParam("searchTerm")
val pageNum = ctx.pathParam("pageNum").toInt()
ctx.json(future { sourceSearch(sourceId, searchTerm, pageNum) })
ctx.future(future { sourceSearch(sourceId, searchTerm, pageNum) })
}
//
// // source filter list
// app.get("/api/v1/source/:sourceId/filters/") { ctx ->
// app.get("/api/v1/source/{sourceId}/filters/") { ctx ->
// val sourceId = ctx.pathParam("sourceId").toLong()
// ctx.json(sourceFilters(sourceId))
// }
//
// // adds the manga to library
// app.get("api/v1/manga/:mangaId/library") { ctx ->
// app.get("api/v1/manga/{mangaId}/library") { ctx ->
// val mangaId = ctx.pathParam("mangaId").toInt()
//
// ctx.result(
// ctx.future(
// JavalinSetup.future { addMangaToLibrary(mangaId) }
// )
// }
//
// // removes the manga from the library
// app.delete("api/v1/manga/:mangaId/library") { ctx ->
// app.delete("api/v1/manga/{mangaId}/library") { ctx ->
// val mangaId = ctx.pathParam("mangaId").toInt()
//
// ctx.result(
// ctx.future(
// JavalinSetup.future { removeMangaFromLibrary(mangaId) }
// )
// }
@@ -275,7 +275,7 @@ object AnimeAPI {
// }
//
// // category modification
// app.patch("/api/v1/category/:categoryId") { ctx ->
// app.patch("/api/v1/category/{categoryId}") { ctx ->
// val categoryId = ctx.pathParam("categoryId").toInt()
// val name = ctx.formParam("name")
// val isDefault = ctx.formParam("default")?.toBoolean()
@@ -284,7 +284,7 @@ object AnimeAPI {
// }
//
// // category re-ordering
// app.patch("/api/v1/category/:categoryId/reorder") { ctx ->
// app.patch("/api/v1/category/{categoryId}/reorder") { ctx ->
// val categoryId = ctx.pathParam("categoryId").toInt()
// val from = ctx.formParam("from")!!.toInt()
// val to = ctx.formParam("to")!!.toInt()
@@ -293,21 +293,21 @@ object AnimeAPI {
// }
//
// // category delete
// app.delete("/api/v1/category/:categoryId") { ctx ->
// app.delete("/api/v1/category/{categoryId}") { ctx ->
// val categoryId = ctx.pathParam("categoryId").toInt()
// Category.removeCategory(categoryId)
// ctx.status(200)
// }
//
// // returns the manga list associated with a category
// app.get("/api/v1/category/:categoryId") { ctx ->
// app.get("/api/v1/category/{categoryId}") { ctx ->
// val categoryId = ctx.pathParam("categoryId").toInt()
// ctx.json(getCategoryMangaList(categoryId))
// }
//
// // expects a Tachiyomi legacy backup json in the body
// app.post("/api/v1/backup/legacy/import") { ctx ->
// ctx.result(
// ctx.future(
// future {
// restoreLegacyBackup(ctx.bodyAsInputStream())
// }
@@ -316,7 +316,7 @@ object AnimeAPI {
//
// // expects a Tachiyomi legacy backup json as a file upload, the file must be named "backup.json"
// app.post("/api/v1/backup/legacy/import/file") { ctx ->
// ctx.result(
// ctx.future(
// JavalinSetup.future {
// restoreLegacyBackup(ctx.uploadedFile("backup.json")!!.content)
// }
@@ -326,7 +326,7 @@ object AnimeAPI {
// // returns a Tachiyomi legacy backup json created from the current database as a json body
// app.get("/api/v1/backup/legacy/export") { ctx ->
// ctx.contentType("application/json")
// ctx.result(
// ctx.future(
// JavalinSetup.future {
// createLegacyBackup(
// BackupFlags(
@@ -348,7 +348,7 @@ object AnimeAPI {
// val currentDate = sdf.format(Date())
//
// ctx.header("Content-Disposition", "attachment; filename=\"tachidesk_$currentDate.json\"")
// ctx.result(
// ctx.future(
// JavalinSetup.future {
// createLegacyBackup(
// BackupFlags(
@@ -23,8 +23,8 @@ import suwayomi.tachidesk.anime.model.table.AnimeStatus
import suwayomi.tachidesk.anime.model.table.AnimeTable
import suwayomi.tachidesk.manga.impl.util.lang.awaitSingle
import suwayomi.tachidesk.manga.impl.util.network.await
import suwayomi.tachidesk.manga.impl.util.storage.CachedImageResponse.clearCachedImage
import suwayomi.tachidesk.manga.impl.util.storage.CachedImageResponse.getCachedImageResponse
import suwayomi.tachidesk.manga.impl.util.storage.ImageResponse.clearCachedImage
import suwayomi.tachidesk.manga.impl.util.storage.ImageResponse.getCachedImageResponse
import suwayomi.tachidesk.server.ApplicationDirs
import java.io.InputStream
@@ -40,7 +40,7 @@ import suwayomi.tachidesk.anime.impl.util.PackageTools.trustedSignatures
import suwayomi.tachidesk.anime.model.table.AnimeExtensionTable
import suwayomi.tachidesk.anime.model.table.AnimeSourceTable
import suwayomi.tachidesk.manga.impl.util.network.await
import suwayomi.tachidesk.manga.impl.util.storage.CachedImageResponse.getCachedImageResponse
import suwayomi.tachidesk.manga.impl.util.storage.ImageResponse.getCachedImageResponse
import suwayomi.tachidesk.server.ApplicationDirs
import uy.kohesive.injekt.injectLazy
import java.io.File
@@ -12,6 +12,7 @@ import org.jetbrains.exposed.dao.id.IntIdTable
import org.jetbrains.exposed.sql.ResultRow
import suwayomi.tachidesk.manga.impl.MangaList.proxyThumbnailUrl
import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass
import suwayomi.tachidesk.manga.model.dataclass.toGenreList
import suwayomi.tachidesk.manga.model.table.MangaStatus.Companion
object AnimeTable : IntIdTable() {
@@ -48,7 +49,7 @@ fun AnimeTable.toDataClass(mangaEntry: ResultRow) =
mangaEntry[artist],
mangaEntry[author],
mangaEntry[description],
mangaEntry[genre],
mangaEntry[genre].toGenreList(),
Companion.valueOf(mangaEntry[status]).name,
mangaEntry[inLibrary]
)
@@ -25,58 +25,60 @@ object MangaAPI {
path("extension") {
get("list", ExtensionController::list)
get("install/:pkgName", ExtensionController::install)
get("update/:pkgName", ExtensionController::update)
get("uninstall/:pkgName", ExtensionController::uninstall)
get("install/{pkgName}", ExtensionController::install)
post("install", ExtensionController::installFile)
get("update/{pkgName}", ExtensionController::update)
get("uninstall/{pkgName}", ExtensionController::uninstall)
get("icon/:apkName", ExtensionController::icon)
get("icon/{apkName}", ExtensionController::icon)
}
path("source") {
get("list", SourceController::list)
get(":sourceId", SourceController::retrieve)
get("{sourceId}", SourceController::retrieve)
get(":sourceId/popular/:pageNum", SourceController::popular)
get(":sourceId/latest/:pageNum", SourceController::latest)
get("{sourceId}/popular/{pageNum}", SourceController::popular)
get("{sourceId}/latest/{pageNum}", SourceController::latest)
get(":sourceId/preferences", SourceController::getPreferences)
post(":sourceId/preferences", SourceController::setPreference)
get("{sourceId}/preferences", SourceController::getPreferences)
post("{sourceId}/preferences", SourceController::setPreference)
get(":sourceId/filters", SourceController::filters)
get("{sourceId}/filters", SourceController::filters)
get(":sourceId/search/:searchTerm/:pageNum", SourceController::searchSingle)
// get("search/:searchTerm/:pageNum", SourceController::searchGlobal)
get("{sourceId}/search/{searchTerm}/{pageNum}", SourceController::searchSingle)
// get("search/{searchTerm}/{pageNum}", SourceController::searchGlobal)
}
path("manga") {
get(":mangaId", MangaController::retrieve)
get(":mangaId/thumbnail", MangaController::thumbnail)
get("{mangaId}", MangaController::retrieve)
get("{mangaId}/thumbnail", MangaController::thumbnail)
get(":mangaId/category", MangaController::categoryList)
get(":mangaId/category/:categoryId", MangaController::addToCategory)
delete(":mangaId/category/:categoryId", MangaController::removeFromCategory)
get("{mangaId}/category", MangaController::categoryList)
get("{mangaId}/category/{categoryId}", MangaController::addToCategory)
delete("{mangaId}/category/{categoryId}", MangaController::removeFromCategory)
get(":mangaId/library", MangaController::addToLibrary)
delete(":mangaId/library", MangaController::removeFromLibrary)
get("{mangaId}/library", MangaController::addToLibrary)
delete("{mangaId}/library", MangaController::removeFromLibrary)
patch(":mangaId/meta", MangaController::meta)
patch("{mangaId}/meta", MangaController::meta)
get(":mangaId/chapters", MangaController::chapterList)
get(":mangaId/chapter/:chapterIndex", MangaController::chapterRetrieve)
patch(":mangaId/chapter/:chapterIndex", MangaController::chapterModify)
get("{mangaId}/chapters", MangaController::chapterList)
get("{mangaId}/chapter/{chapterIndex}", MangaController::chapterRetrieve)
patch("{mangaId}/chapter/{chapterIndex}", MangaController::chapterModify)
delete("{mangaId}/chapter/{chapterIndex}", MangaController::chapterDelete)
patch(":mangaId/chapter/:chapterIndex/meta", MangaController::chapterMeta)
patch("{mangaId}/chapter/{chapterIndex}/meta", MangaController::chapterMeta)
get(":mangaId/chapter/:chapterIndex/page/:index", MangaController::pageRetrieve)
get("{mangaId}/chapter/{chapterIndex}/page/{index}", MangaController::pageRetrieve)
}
path("category") {
get("", CategoryController::categoryList)
post("", CategoryController::categoryCreate)
get(":categoryId", CategoryController::categoryMangas)
patch(":categoryId", CategoryController::categoryModify)
delete(":categoryId", CategoryController::categoryDelete)
get("{categoryId}", CategoryController::categoryMangas)
patch("{categoryId}", CategoryController::categoryModify)
delete("{categoryId}", CategoryController::categoryDelete)
patch("reorder", CategoryController::categoryReorder)
}
@@ -101,8 +103,8 @@ object MangaAPI {
}
path("download") {
get(":mangaId/chapter/:chapterIndex", DownloadController::queueChapter)
delete(":mangaId/chapter/:chapterIndex", DownloadController::unqueueChapter)
get("{mangaId}/chapter/{chapterIndex}", DownloadController::queueChapter)
delete("{mangaId}/chapter/{chapterIndex}", DownloadController::unqueueChapter)
}
}
}
@@ -5,7 +5,7 @@ import suwayomi.tachidesk.manga.impl.backup.BackupFlags
import suwayomi.tachidesk.manga.impl.backup.proto.ProtoBackupExport
import suwayomi.tachidesk.manga.impl.backup.proto.ProtoBackupImport
import suwayomi.tachidesk.manga.impl.backup.proto.ProtoBackupValidator
import suwayomi.tachidesk.server.JavalinSetup
import suwayomi.tachidesk.server.JavalinSetup.future
import java.text.SimpleDateFormat
import java.util.Date
@@ -20,8 +20,8 @@ object BackupController {
/** expects a Tachiyomi protobuf backup in the body */
fun protobufImport(ctx: Context) {
ctx.json(
JavalinSetup.future {
ctx.future(
future {
ProtoBackupImport.performRestore(ctx.bodyAsInputStream())
}
)
@@ -29,8 +29,9 @@ object BackupController {
/** expects a Tachiyomi protobuf backup as a file upload, the file must be named "backup.proto.gz" */
fun protobufImportFile(ctx: Context) {
ctx.json(
JavalinSetup.future {
// TODO: rewrite this with ctx.uploadedFiles(), don't call the multipart field "backup.proto.gz"
ctx.future(
future {
ProtoBackupImport.performRestore(ctx.uploadedFile("backup.proto.gz")!!.content)
}
)
@@ -39,8 +40,8 @@ object BackupController {
/** returns a Tachiyomi protobuf backup created from the current database as a body */
fun protobufExport(ctx: Context) {
ctx.contentType("application/octet-stream")
ctx.result(
JavalinSetup.future {
ctx.future(
future {
ProtoBackupExport.createBackup(
BackupFlags(
includeManga = true,
@@ -60,8 +61,8 @@ object BackupController {
val currentDate = SimpleDateFormat("yyyy-MM-dd_HH-mm").format(Date())
ctx.header("Content-Disposition", """attachment; filename="tachidesk_$currentDate.proto.gz"""")
ctx.result(
JavalinSetup.future {
ctx.future(
future {
ProtoBackupExport.createBackup(
BackupFlags(
includeManga = true,
@@ -77,8 +78,8 @@ object BackupController {
/** Reports missing sources and trackers, expects a Tachiyomi protobuf backup in the body */
fun protobufValidate(ctx: Context) {
ctx.json(
JavalinSetup.future {
ctx.future(
future {
ProtoBackupValidator.validate(ctx.bodyAsInputStream())
}
)
@@ -86,8 +87,8 @@ object BackupController {
/** Reports missing sources and trackers, expects a Tachiyomi protobuf backup as a file upload, the file must be named "backup.proto.gz" */
fun protobufValidateFile(ctx: Context) {
ctx.json(
JavalinSetup.future {
ctx.future(
future {
ProtoBackupValidator.validate(ctx.uploadedFile("backup.proto.gz")!!.content)
}
)
@@ -8,12 +8,12 @@ package suwayomi.tachidesk.manga.controller
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import io.javalin.http.Context
import io.javalin.websocket.WsHandler
import io.javalin.websocket.WsConfig
import suwayomi.tachidesk.manga.impl.download.DownloadManager
object DownloadController {
/** Download queue stats */
fun downloadsWS(ws: WsHandler) {
fun downloadsWS(ws: WsConfig) {
ws.onConnect { ctx ->
DownloadManager.addClient(ctx)
DownloadManager.notifyClient(ctx)
@@ -8,14 +8,17 @@ package suwayomi.tachidesk.manga.controller
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import io.javalin.http.Context
import mu.KotlinLogging
import suwayomi.tachidesk.manga.impl.extension.Extension
import suwayomi.tachidesk.manga.impl.extension.ExtensionsList
import suwayomi.tachidesk.server.JavalinSetup.future
object ExtensionController {
private val logger = KotlinLogging.logger {}
/** list all extensions */
fun list(ctx: Context) {
ctx.json(
ctx.future(
future {
ExtensionsList.getExtensionList()
}
@@ -26,18 +29,31 @@ object ExtensionController {
fun install(ctx: Context) {
val pkgName = ctx.pathParam("pkgName")
ctx.json(
ctx.future(
future {
Extension.installExtension(pkgName)
}
)
}
/** install the uploaded apk file */
fun installFile(ctx: Context) {
val uploadedFile = ctx.uploadedFile("file")!!
logger.debug { "Uploaded extension file name: " + uploadedFile.filename }
ctx.future(
future {
Extension.installExternalExtension(uploadedFile.content, uploadedFile.filename)
}
)
}
/** update extension identified with "pkgName" */
fun update(ctx: Context) {
val pkgName = ctx.pathParam("pkgName")
ctx.json(
ctx.future(
future {
Extension.updateExtension(pkgName)
}
@@ -55,9 +71,10 @@ object ExtensionController {
/** icon for extension named `apkName` */
fun icon(ctx: Context) {
val apkName = ctx.pathParam("apkName")
val useCache = ctx.queryParam("useCache")?.toBoolean() ?: true
ctx.result(
future { Extension.getExtensionIcon(apkName) }
ctx.future(
future { Extension.getExtensionIcon(apkName, useCache) }
.thenApply {
ctx.header("content-type", it.second)
it.first
@@ -19,9 +19,9 @@ object MangaController {
/** get manga info */
fun retrieve(ctx: Context) {
val mangaId = ctx.pathParam("mangaId").toInt()
val onlineFetch = ctx.queryParam("onlineFetch", "false").toBoolean()
val onlineFetch = ctx.queryParam("onlineFetch")?.toBoolean() ?: false
ctx.json(
ctx.future(
future {
Manga.getManga(mangaId, onlineFetch)
}
@@ -31,9 +31,10 @@ object MangaController {
/** manga thumbnail */
fun thumbnail(ctx: Context) {
val mangaId = ctx.pathParam("mangaId").toInt()
val useCache = ctx.queryParam("useCache")?.toBoolean() ?: true
ctx.result(
future { Manga.getMangaThumbnail(mangaId) }
ctx.future(
future { Manga.getMangaThumbnail(mangaId, useCache) }
.thenApply {
ctx.header("content-type", it.second)
it.first
@@ -45,7 +46,7 @@ object MangaController {
fun addToLibrary(ctx: Context) {
val mangaId = ctx.pathParam("mangaId").toInt()
ctx.result(
ctx.future(
future { Library.addMangaToLibrary(mangaId) }
)
}
@@ -54,7 +55,7 @@ object MangaController {
fun removeFromLibrary(ctx: Context) {
val mangaId = ctx.pathParam("mangaId").toInt()
ctx.result(
ctx.future(
future { Library.removeMangaFromLibrary(mangaId) }
)
}
@@ -97,16 +98,16 @@ object MangaController {
fun chapterList(ctx: Context) {
val mangaId = ctx.pathParam("mangaId").toInt()
val onlineFetch = ctx.queryParam("onlineFetch", "false").toBoolean()
val onlineFetch = ctx.queryParam("onlineFetch")?.toBoolean() ?: false
ctx.json(future { Chapter.getChapterList(mangaId, onlineFetch) })
ctx.future(future { Chapter.getChapterList(mangaId, onlineFetch) })
}
/** used to display a chapter, get a chapter in order to show its pages */
fun chapterRetrieve(ctx: Context) {
val chapterIndex = ctx.pathParam("chapterIndex").toInt()
val mangaId = ctx.pathParam("mangaId").toInt()
ctx.json(future { Chapter.getChapter(chapterIndex, mangaId) })
ctx.future(future { Chapter.getChapter(chapterIndex, mangaId) })
}
/** used to modify a chapter's parameters */
@@ -124,6 +125,16 @@ object MangaController {
ctx.status(200)
}
/** delete a downloaded chapter */
fun chapterDelete(ctx: Context) {
val chapterIndex = ctx.pathParam("chapterIndex").toInt()
val mangaId = ctx.pathParam("mangaId").toInt()
Chapter.deleteChapter(mangaId, chapterIndex)
ctx.status(200)
}
/** used to modify a chapter's meta parameters */
fun chapterMeta(ctx: Context) {
val chapterIndex = ctx.pathParam("chapterIndex").toInt()
@@ -142,9 +153,10 @@ object MangaController {
val mangaId = ctx.pathParam("mangaId").toInt()
val chapterIndex = ctx.pathParam("chapterIndex").toInt()
val index = ctx.pathParam("index").toInt()
val useCache = ctx.queryParam("useCache")?.toBoolean() ?: true
ctx.result(
future { Page.getPageImage(mangaId, chapterIndex, index) }
ctx.future(
future { Page.getPageImage(mangaId, chapterIndex, index, useCache) }
.thenApply {
ctx.header("content-type", it.second)
it.first
@@ -30,7 +30,7 @@ object SourceController {
fun popular(ctx: Context) {
val sourceId = ctx.pathParam("sourceId").toLong()
val pageNum = ctx.pathParam("pageNum").toInt()
ctx.json(
ctx.future(
future {
MangaList.getMangaList(sourceId, pageNum, popular = true)
}
@@ -41,7 +41,7 @@ object SourceController {
fun latest(ctx: Context) {
val sourceId = ctx.pathParam("sourceId").toLong()
val pageNum = ctx.pathParam("pageNum").toInt()
ctx.json(
ctx.future(
future {
MangaList.getMangaList(sourceId, pageNum, popular = false)
}
@@ -64,7 +64,7 @@ object SourceController {
/** fetch filters of source with id `sourceId` */
fun filters(ctx: Context) {
val sourceId = ctx.pathParam("sourceId").toLong()
val reset = ctx.queryParam("reset", "false").toBoolean()
val reset = ctx.queryParam("reset")?.toBoolean() ?: false
ctx.json(Search.getInitialFilterList(sourceId, reset))
}
@@ -74,7 +74,7 @@ object SourceController {
val sourceId = ctx.pathParam("sourceId").toLong()
val searchTerm = ctx.pathParam("searchTerm")
val pageNum = ctx.pathParam("pageNum").toInt()
ctx.json(future { Search.sourceSearch(sourceId, searchTerm, pageNum) })
ctx.future(future { Search.sourceSearch(sourceId, searchTerm, pageNum) })
}
/** all source search */
@@ -9,6 +9,7 @@ package suwayomi.tachidesk.manga.impl
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.util.chapter.ChapterRecognition
import org.jetbrains.exposed.dao.id.EntityID
import org.jetbrains.exposed.sql.SortOrder.DESC
import org.jetbrains.exposed.sql.and
@@ -18,14 +19,18 @@ import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.update
import suwayomi.tachidesk.manga.impl.Manga.getManga
import suwayomi.tachidesk.manga.impl.Page.getPageName
import suwayomi.tachidesk.manga.impl.util.GetHttpSource.getHttpSource
import suwayomi.tachidesk.manga.impl.util.getChapterDir
import suwayomi.tachidesk.manga.impl.util.lang.awaitSingle
import suwayomi.tachidesk.manga.impl.util.storage.ImageResponse
import suwayomi.tachidesk.manga.model.dataclass.ChapterDataClass
import suwayomi.tachidesk.manga.model.table.ChapterMetaTable
import suwayomi.tachidesk.manga.model.table.ChapterTable
import suwayomi.tachidesk.manga.model.table.MangaTable
import suwayomi.tachidesk.manga.model.table.PageTable
import suwayomi.tachidesk.manga.model.table.toDataClass
import java.io.File
import java.time.Instant
object Chapter {
@@ -48,12 +53,19 @@ object Chapter {
private suspend fun getSourceChapters(mangaId: Int): List<ChapterDataClass> {
val manga = getManga(mangaId)
val source = getHttpSource(manga.sourceId.toLong())
val chapterList = source.fetchChapterList(
SManga.create().apply {
title = manga.title
url = manga.url
}
).awaitSingle()
val sManga = SManga.create().apply {
title = manga.title
url = manga.url
}
val chapterList = source.fetchChapterList(sManga).awaitSingle()
// Recognize number for new chapters.
chapterList.forEach {
source.prepareNewChapter(it, sManga)
ChapterRecognition.parseChapterNumber(it, sManga)
}
val chapterCount = chapterList.count()
@@ -143,7 +155,15 @@ object Chapter {
}.first()
}
return if (!chapterEntry[ChapterTable.isDownloaded]) {
val isReallyDownloaded =
chapterEntry[ChapterTable.isDownloaded] && firstPageExists(mangaId, chapterEntry[ChapterTable.id].value)
return if (!isReallyDownloaded) {
transaction {
ChapterTable.update({ (ChapterTable.chapterIndex eq chapterIndex) and (ChapterTable.manga eq mangaId) }) {
it[isDownloaded] = false
}
}
val mangaEntry = transaction { MangaTable.select { MangaTable.id eq mangaId }.first() }
val source = getHttpSource(mangaEntry[MangaTable.sourceReference])
@@ -210,6 +230,15 @@ object Chapter {
}
}
private fun firstPageExists(mangaId: Int, chapterId: Int): Boolean {
val chapterDir = getChapterDir(mangaId, chapterId)
return ImageResponse.findFileNameStartingWith(
chapterDir,
getPageName(0, chapterDir)
) != null
}
fun modifyChapter(
mangaId: Int,
chapterIndex: Int,
@@ -251,16 +280,16 @@ object Chapter {
fun modifyChapterMeta(mangaId: Int, chapterIndex: Int, key: String, value: String) {
transaction {
val chapter =
val chapterId =
ChapterTable.select { (ChapterTable.manga eq mangaId) and (ChapterTable.chapterIndex eq chapterIndex) }
.first()[ChapterTable.id]
.first()[ChapterTable.id].value
val meta =
transaction { ChapterMetaTable.select { (ChapterMetaTable.ref eq chapter) and (ChapterMetaTable.key eq key) } }.firstOrNull()
transaction { ChapterMetaTable.select { (ChapterMetaTable.ref eq chapterId) and (ChapterMetaTable.key eq key) } }.firstOrNull()
if (meta == null) {
ChapterMetaTable.insert {
it[ChapterMetaTable.key] = key
it[ChapterMetaTable.value] = value
it[ChapterMetaTable.ref] = chapter
it[ChapterMetaTable.ref] = chapterId
}
} else {
ChapterMetaTable.update {
@@ -269,4 +298,20 @@ object Chapter {
}
}
}
fun deleteChapter(mangaId: Int, chapterIndex: Int) {
transaction {
val chapterId =
ChapterTable.select { (ChapterTable.manga eq mangaId) and (ChapterTable.chapterIndex eq chapterIndex) }
.first()[ChapterTable.id].value
val chapterDir = getChapterDir(mangaId, chapterId)
File(chapterDir).deleteRecursively()
ChapterTable.update({ (ChapterTable.manga eq mangaId) and (ChapterTable.chapterIndex eq chapterIndex) }) {
it[isDownloaded] = false
}
}
}
}
@@ -22,9 +22,11 @@ import suwayomi.tachidesk.manga.impl.Source.getSource
import suwayomi.tachidesk.manga.impl.util.GetHttpSource.getHttpSource
import suwayomi.tachidesk.manga.impl.util.lang.awaitSingle
import suwayomi.tachidesk.manga.impl.util.network.await
import suwayomi.tachidesk.manga.impl.util.storage.CachedImageResponse.clearCachedImage
import suwayomi.tachidesk.manga.impl.util.storage.CachedImageResponse.getCachedImageResponse
import suwayomi.tachidesk.manga.impl.util.storage.ImageResponse.clearCachedImage
import suwayomi.tachidesk.manga.impl.util.storage.ImageResponse.getImageResponse
import suwayomi.tachidesk.manga.impl.util.updateMangaDownloadDir
import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass
import suwayomi.tachidesk.manga.model.dataclass.toGenreList
import suwayomi.tachidesk.manga.model.table.MangaMetaTable
import suwayomi.tachidesk.manga.model.table.MangaStatus
import suwayomi.tachidesk.manga.model.table.MangaTable
@@ -56,7 +58,7 @@ object Manga {
mangaEntry[MangaTable.artist],
mangaEntry[MangaTable.author],
mangaEntry[MangaTable.description],
mangaEntry[MangaTable.genre],
mangaEntry[MangaTable.genre].toGenreList(),
MangaStatus.valueOf(mangaEntry[MangaTable.status]).name,
mangaEntry[MangaTable.inLibrary],
getSource(mangaEntry[MangaTable.sourceReference]),
@@ -75,6 +77,12 @@ object Manga {
transaction {
MangaTable.update({ MangaTable.id eq mangaId }) {
if (fetchedManga.title != mangaEntry[MangaTable.title]) {
val canUpdateTitle = updateMangaDownloadDir(mangaId, fetchedManga.title)
if (canUpdateTitle)
it[MangaTable.title] = fetchedManga.title
}
it[MangaTable.initialized] = true
it[MangaTable.artist] = fetchedManga.artist
@@ -85,7 +93,11 @@ object Manga {
if (fetchedManga.thumbnail_url != null && fetchedManga.thumbnail_url.orEmpty().isNotEmpty())
it[MangaTable.thumbnail_url] = fetchedManga.thumbnail_url
it[MangaTable.realUrl] = try { source.mangaDetailsRequest(sManga).url.toString() } catch (e: Exception) { null }
it[MangaTable.realUrl] = try {
source.mangaDetailsRequest(sManga).url.toString()
} catch (e: Exception) {
null
}
}
}
@@ -106,7 +118,7 @@ object Manga {
fetchedManga.artist,
fetchedManga.author,
fetchedManga.description,
fetchedManga.genre,
fetchedManga.genre.toGenreList(),
MangaStatus.valueOf(fetchedManga.status).name,
mangaEntry[MangaTable.inLibrary],
getSource(mangaEntry[MangaTable.sourceReference]),
@@ -145,19 +157,25 @@ object Manga {
}
private val applicationDirs by DI.global.instance<ApplicationDirs>()
suspend fun getMangaThumbnail(mangaId: Int): Pair<InputStream, String> {
suspend fun getMangaThumbnail(mangaId: Int, useCache: Boolean): Pair<InputStream, String> {
val saveDir = applicationDirs.mangaThumbnailsRoot
val fileName = mangaId.toString()
return getCachedImageResponse(saveDir, fileName) {
getManga(mangaId) // make sure is initialized
return getImageResponse(saveDir, fileName, useCache) {
val mangaEntry = transaction { MangaTable.select { MangaTable.id eq mangaId }.first() }
val sourceId = mangaEntry[MangaTable.sourceReference]
val source = getHttpSource(sourceId)
val thumbnailUrl = mangaEntry[MangaTable.thumbnail_url]!!
val thumbnailUrl = mangaEntry[MangaTable.thumbnail_url]
?: if (!mangaEntry[MangaTable.initialized]) {
// initialize then try again
getManga(mangaId)
transaction { MangaTable.select { MangaTable.id eq mangaId }.first() }[MangaTable.thumbnail_url]!!
} else {
// source provides no thumbnail url for this manga
throw NullPointerException()
}
source.client.newCall(
GET(thumbnailUrl, source.headers)
@@ -8,6 +8,7 @@ package suwayomi.tachidesk.manga.impl
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import eu.kanade.tachiyomi.source.model.MangasPage
import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.insertAndGetId
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction
@@ -16,6 +17,7 @@ import suwayomi.tachidesk.manga.impl.util.GetHttpSource.getHttpSource
import suwayomi.tachidesk.manga.impl.util.lang.awaitSingle
import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass
import suwayomi.tachidesk.manga.model.dataclass.PagedMangaListDataClass
import suwayomi.tachidesk.manga.model.dataclass.toGenreList
import suwayomi.tachidesk.manga.model.table.MangaStatus
import suwayomi.tachidesk.manga.model.table.MangaTable
@@ -41,7 +43,9 @@ object MangaList {
val mangasPage = this
val mangaList = transaction {
return@transaction mangasPage.mangas.map { manga ->
var mangaEntry = MangaTable.select { MangaTable.url eq manga.url }.firstOrNull()
var mangaEntry = MangaTable.select {
(MangaTable.url eq manga.url) and (MangaTable.sourceReference eq sourceId)
}.firstOrNull()
if (mangaEntry == null) { // create manga entry
val mangaId = MangaTable.insertAndGetId {
it[url] = manga.url
@@ -57,7 +61,9 @@ object MangaList {
it[sourceReference] = sourceId
}.value
mangaEntry = MangaTable.select { MangaTable.url eq manga.url }.first()
mangaEntry = MangaTable.select {
(MangaTable.url eq manga.url) and (MangaTable.sourceReference eq sourceId)
}.first()
MangaDataClass(
mangaId,
@@ -72,7 +78,7 @@ object MangaList {
manga.artist,
manga.author,
manga.description,
manga.genre,
manga.genre.toGenreList(),
MangaStatus.valueOf(manga.status).name,
false, // It's a new manga entry
meta = getMangaMetaMap(mangaId),
@@ -94,7 +100,7 @@ object MangaList {
mangaEntry[MangaTable.artist],
mangaEntry[MangaTable.author],
mangaEntry[MangaTable.description],
mangaEntry[MangaTable.genre],
mangaEntry[MangaTable.genre].toGenreList(),
MangaStatus.valueOf(mangaEntry[MangaTable.status]).name,
mangaEntry[MangaTable.inLibrary],
meta = getMangaMetaMap(mangaId),
@@ -7,6 +7,7 @@ package suwayomi.tachidesk.manga.impl
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import eu.kanade.tachiyomi.source.local.LocalSource
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.online.HttpSource
import org.jetbrains.exposed.sql.and
@@ -17,9 +18,10 @@ import org.kodein.di.DI
import org.kodein.di.conf.global
import org.kodein.di.instance
import suwayomi.tachidesk.manga.impl.util.GetHttpSource.getHttpSource
import suwayomi.tachidesk.manga.impl.util.getChapterDir
import suwayomi.tachidesk.manga.impl.util.lang.awaitSingle
import suwayomi.tachidesk.manga.impl.util.storage.CachedImageResponse.getCachedImageResponse
import suwayomi.tachidesk.manga.impl.util.storage.SafePath
import suwayomi.tachidesk.manga.impl.util.storage.ImageResponse
import suwayomi.tachidesk.manga.impl.util.storage.ImageResponse.getImageResponse
import suwayomi.tachidesk.manga.model.table.ChapterTable
import suwayomi.tachidesk.manga.model.table.MangaTable
import suwayomi.tachidesk.manga.model.table.PageTable
@@ -39,7 +41,7 @@ object Page {
return page.imageUrl!!
}
suspend fun getPageImage(mangaId: Int, chapterIndex: Int, index: Int): Pair<InputStream, String> {
suspend fun getPageImage(mangaId: Int, chapterIndex: Int, index: Int, useCache: Boolean = true): Pair<InputStream, String> {
val mangaEntry = transaction { MangaTable.select { MangaTable.id eq mangaId }.first() }
val source = getHttpSource(mangaEntry[MangaTable.sourceReference])
val chapterEntry = transaction {
@@ -49,16 +51,31 @@ object Page {
}
val chapterId = chapterEntry[ChapterTable.id].value
val pageEntry = transaction { PageTable.select { (PageTable.chapter eq chapterId) and (PageTable.index eq index) }.first() }
val pageEntry =
transaction { PageTable.select { (PageTable.chapter eq chapterId) and (PageTable.index eq index) }.first() }
val tachiPage = Page(
val tachiyomiPage = Page(
pageEntry[PageTable.index],
pageEntry[PageTable.url],
pageEntry[PageTable.imageUrl]
)
// we treat Local source differently
if (mangaEntry[MangaTable.sourceReference] == LocalSource.ID) {
// is of archive format
if (LocalSource.pageCache.containsKey(chapterEntry[ChapterTable.url])) {
val pageStream = LocalSource.pageCache[chapterEntry[ChapterTable.url]]!![index]()
return pageStream to "image/jpeg"
}
// is of directory format
return ImageResponse.getNoCacheImageResponse {
source.fetchImage(tachiyomiPage).awaitSingle()
}
}
if (pageEntry[PageTable.imageUrl] == null) {
val trueImageUrl = getTrueImageUrl(tachiPage, source)
val trueImageUrl = getTrueImageUrl(tachiyomiPage, source)
transaction {
PageTable.update({ (PageTable.chapter eq chapterId) and (PageTable.index eq index) }) {
it[imageUrl] = trueImageUrl
@@ -66,30 +83,28 @@ object Page {
}
}
val saveDir = getChapterDir(mangaId, chapterId)
File(saveDir).mkdirs()
val fileName = String.format("%03d", index) // e.g. 001.jpeg
val chapterDir = getChapterDir(mangaId, chapterId)
File(chapterDir).mkdirs()
val fileName = getPageName(index, chapterDir) // e.g. 001
return getCachedImageResponse(saveDir, fileName) {
source.fetchImage(tachiPage).awaitSingle()
return getImageResponse(chapterDir, fileName, useCache) {
source.fetchImage(tachiyomiPage).awaitSingle()
}
}
private val applicationDirs by DI.global.instance<ApplicationDirs>()
private fun getChapterDir(mangaId: Int, chapterId: Int): String {
val mangaEntry = transaction { MangaTable.select { MangaTable.id eq mangaId }.first() }
val source = getHttpSource(mangaEntry[MangaTable.sourceReference])
val chapterEntry = transaction { ChapterTable.select { ChapterTable.id eq chapterId }.first() }
// TODO(v0.6.0) : zero based pages are deprecated
fun getPageName(index: Int, chapterDir: String): String {
val zeroBasedPageExists = ImageResponse.findFileNameStartingWith(
chapterDir,
formatPageName(0)
) != null
val sourceDir = source.toString()
val mangaDir = SafePath.buildValidFilename(mangaEntry[MangaTable.title])
val chapterDir = SafePath.buildValidFilename(
when {
chapterEntry[ChapterTable.scanlator] != null -> "${chapterEntry[ChapterTable.scanlator]}_${chapterEntry[ChapterTable.name]}"
else -> chapterEntry[ChapterTable.name]
}
)
if (zeroBasedPageExists) return formatPageName(index)
return "${applicationDirs.mangaRoot}/$sourceDir/$mangaDir/$chapterDir"
return formatPageName(index + 1)
}
private fun formatPageName(index: Int) = String.format("%03d", index)
private val applicationDirs by DI.global.instance<ApplicationDirs>()
}
@@ -12,6 +12,7 @@ import android.content.Context
import androidx.preference.PreferenceScreen
import eu.kanade.tachiyomi.source.ConfigurableSource
import eu.kanade.tachiyomi.source.getPreferenceKey
import eu.kanade.tachiyomi.source.local.LocalSource
import mu.KotlinLogging
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.selectAll
@@ -45,7 +46,8 @@ object Source {
getExtensionIconUrl(sourceExtension[ExtensionTable.apkName]),
httpSource.supportsLatest,
httpSource is ConfigurableSource,
it[SourceTable.isNsfw]
it[SourceTable.isNsfw],
httpSource.toString(),
)
}
}
@@ -53,6 +55,11 @@ object Source {
fun getSource(sourceId: Long): SourceDataClass { // all the data extracted fresh form the source instance
return transaction {
if (sourceId == LocalSource.ID) {
// initialize local source
getHttpSource(sourceId)
}
val source = SourceTable.select { SourceTable.id eq sourceId }.firstOrNull()
val httpSource = source?.let { getHttpSource(sourceId) }
val extension = source?.let {
@@ -70,7 +77,8 @@ object Source {
},
httpSource?.supportsLatest,
httpSource?.let { it is ConfigurableSource },
source?.get(SourceTable.isNsfw)
source?.get(SourceTable.isNsfw),
httpSource?.toString()
)
}
}
@@ -43,6 +43,7 @@ object ProtoBackupExport : ProtoBackupBase() {
Backup(
backupManga(databaseManga, flags),
backupCategories(),
emptyList(),
backupExtensionInfo(databaseManga)
)
}
@@ -62,7 +62,7 @@ object ProtoBackupImport : ProtoBackupBase() {
}
// Store source mapping for error messages
sourceMapping = backup.backupSources.map { it.sourceId to it.name }.toMap()
sourceMapping = backup.getSourceMap()
// Restore individual manga
backup.backupManga.forEach {
@@ -103,7 +103,7 @@ object ProtoBackupImport : ProtoBackupBase() {
val manga = backupManga.getMangaImpl()
val chapters = backupManga.getChaptersImpl()
val categories = backupManga.categories
val history = backupManga.history
val history = backupManga.brokenHistory.map { BackupHistory(it.url, it.lastRead) } + backupManga.history
val tracks = backupManga.getTrackingImpl()
try {
@@ -24,7 +24,7 @@ object ProtoBackupValidator : AbstractBackupValidator() {
throw Exception("Backup does not contain any manga.")
}
val sources = backup.backupSources.map { it.sourceId to it.name }.toMap()
val sources = backup.getSourceMap()
val missingSources = transaction {
sources
@@ -8,5 +8,11 @@ data class Backup(
@ProtoNumber(1) val backupManga: List<BackupManga>,
@ProtoNumber(2) var backupCategories: List<BackupCategory> = emptyList(),
// Bump by 100 to specify this is a 0.x value
@ProtoNumber(100) var backupSources: List<BackupSource> = emptyList(),
)
@ProtoNumber(100) var brokenBackupSources: List<BrokenBackupSource> = emptyList(),
@ProtoNumber(101) var backupSources: List<BackupSource> = emptyList(),
) {
fun getSourceMap(): Map<Long, String> {
return (brokenBackupSources.map { BackupSource(it.name, it.sourceId) } + backupSources)
.associate { it.sourceId to it.name }
}
}
@@ -4,7 +4,13 @@ import kotlinx.serialization.Serializable
import kotlinx.serialization.protobuf.ProtoNumber
@Serializable
data class BackupHistory(
data class BrokenBackupHistory(
@ProtoNumber(0) var url: String,
@ProtoNumber(1) var lastRead: Long
)
@Serializable
data class BackupHistory(
@ProtoNumber(1) var url: String,
@ProtoNumber(2) var lastRead: Long
)
@@ -33,8 +33,9 @@ data class BackupManga(
// Bump by 100 for values that are not saved/implemented in 1.x but are used in 0.x
@ProtoNumber(100) var favorite: Boolean = true,
@ProtoNumber(101) var chapterFlags: Int = 0,
@ProtoNumber(102) var history: List<BackupHistory> = emptyList(),
@ProtoNumber(103) var viewer_flags: Int? = null
@ProtoNumber(102) var brokenHistory: List<BrokenBackupHistory> = emptyList(),
@ProtoNumber(103) var viewer_flags: Int? = null,
@ProtoNumber(104) var history: List<BackupHistory> = emptyList(),
) {
fun getMangaImpl(): MangaImpl {
return MangaImpl().apply {
@@ -5,9 +5,15 @@ import kotlinx.serialization.Serializable
import kotlinx.serialization.protobuf.ProtoNumber
@Serializable
data class BackupSource(
data class BrokenBackupSource(
@ProtoNumber(0) var name: String = "",
@ProtoNumber(1) var sourceId: Long
)
@Serializable
data class BackupSource(
@ProtoNumber(1) var name: String = "",
@ProtoNumber(2) var sourceId: Long
) {
companion object {
fun copyFrom(source: Source): BackupSource {
@@ -8,6 +8,7 @@ package suwayomi.tachidesk.manga.impl.download
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import kotlinx.coroutines.runBlocking
import mu.KotlinLogging
import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.update
@@ -21,6 +22,8 @@ import suwayomi.tachidesk.manga.impl.download.model.DownloadState.Queued
import suwayomi.tachidesk.manga.model.table.ChapterTable
import java.util.concurrent.CopyOnWriteArrayList
private val logger = KotlinLogging.logger {}
class Downloader(private val downloadQueue: CopyOnWriteArrayList<DownloadChapter>, val notifier: () -> Unit) : Thread() {
var shouldStop: Boolean = false
@@ -67,10 +70,10 @@ class Downloader(private val downloadQueue: CopyOnWriteArrayList<DownloadChapter
downloadQueue.removeIf { it.mangaId == download.mangaId && it.chapterIndex == download.chapterIndex }
step()
} catch (e: DownloadShouldStopException) {
println("Downloader was stopped")
logger.debug("Downloader was stopped")
downloadQueue.filter { it.state == Downloading }.forEach { it.state = Queued }
} catch (e: Exception) {
println("Downloader faced an exception")
logger.debug("Downloader faced an exception")
downloadQueue.filter { it.state == Downloading }.forEach { it.state = Error; it.tries++ }
e.printStackTrace()
} finally {
@@ -17,6 +17,7 @@ import mu.KotlinLogging
import okhttp3.Request
import okio.buffer
import okio.sink
import okio.source
import org.jetbrains.exposed.sql.deleteWhere
import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.select
@@ -27,6 +28,8 @@ import org.kodein.di.conf.global
import org.kodein.di.instance
import suwayomi.tachidesk.manga.impl.extension.ExtensionsList.extensionTableAsDataClass
import suwayomi.tachidesk.manga.impl.extension.github.ExtensionGithubApi
import suwayomi.tachidesk.manga.impl.util.GetHttpSource
import suwayomi.tachidesk.manga.impl.util.PackageTools
import suwayomi.tachidesk.manga.impl.util.PackageTools.EXTENSION_FEATURE
import suwayomi.tachidesk.manga.impl.util.PackageTools.LIB_VERSION_MAX
import suwayomi.tachidesk.manga.impl.util.PackageTools.LIB_VERSION_MIN
@@ -34,11 +37,9 @@ import suwayomi.tachidesk.manga.impl.util.PackageTools.METADATA_NSFW
import suwayomi.tachidesk.manga.impl.util.PackageTools.METADATA_SOURCE_CLASS
import suwayomi.tachidesk.manga.impl.util.PackageTools.dex2jar
import suwayomi.tachidesk.manga.impl.util.PackageTools.getPackageInfo
import suwayomi.tachidesk.manga.impl.util.PackageTools.getSignatureHash
import suwayomi.tachidesk.manga.impl.util.PackageTools.loadExtensionSources
import suwayomi.tachidesk.manga.impl.util.PackageTools.trustedSignatures
import suwayomi.tachidesk.manga.impl.util.network.await
import suwayomi.tachidesk.manga.impl.util.storage.CachedImageResponse.getCachedImageResponse
import suwayomi.tachidesk.manga.impl.util.storage.ImageResponse.getImageResponse
import suwayomi.tachidesk.manga.model.table.ExtensionTable
import suwayomi.tachidesk.manga.model.table.SourceTable
import suwayomi.tachidesk.server.ApplicationDirs
@@ -68,7 +69,23 @@ object Extension {
}
}
suspend fun installAPK(fetcher: suspend () -> String): Int {
suspend fun installExternalExtension(inputStream: InputStream, apkName: String): Int {
return installAPK(true) {
val savePath = "${applicationDirs.extensionsRoot}/$apkName"
logger.debug { "Saving apk at $apkName" }
// download apk file
val downloadedFile = File(savePath)
downloadedFile.sink().buffer().use { sink ->
inputStream.source().use { source ->
sink.writeAll(source)
sink.flush()
}
}
savePath
}
}
suspend fun installAPK(forceReinstall: Boolean = false, fetcher: suspend () -> String): Int {
val apkFilePath = fetcher()
val apkName = File(apkFilePath).name
@@ -78,16 +95,19 @@ object Extension {
ExtensionTable.select { ExtensionTable.apkName eq apkName }.firstOrNull()
}?.get(ExtensionTable.isInstalled) ?: false
if (!isInstalled) {
val fileNameWithoutType = apkName.substringBefore(".apk")
val fileNameWithoutType = apkName.substringBefore(".apk")
val dirPathWithoutType = "${applicationDirs.extensionsRoot}/$fileNameWithoutType"
val jarFilePath = "$dirPathWithoutType.jar"
val dexFilePath = "$dirPathWithoutType.dex"
val dirPathWithoutType = "${applicationDirs.extensionsRoot}/$fileNameWithoutType"
val jarFilePath = "$dirPathWithoutType.jar"
val dexFilePath = "$dirPathWithoutType.dex"
val packageInfo = getPackageInfo(apkFilePath)
val pkgName = packageInfo.packageName
val packageInfo = getPackageInfo(apkFilePath)
val pkgName = packageInfo.packageName
if (isInstalled && forceReinstall) {
uninstallExtension(pkgName)
}
if (!isInstalled || forceReinstall) {
if (!packageInfo.reqFeatures.orEmpty().any { it.name == EXTENSION_FEATURE }) {
throw Exception("This apk is not a Tachiyomi extension")
}
@@ -101,14 +121,14 @@ object Extension {
)
}
val signatureHash = getSignatureHash(packageInfo)
// TODO: allow trusting keys
// val signatureHash = getSignatureHash(packageInfo)
if (signatureHash == null) {
throw Exception("Package $pkgName isn't signed")
} else if (signatureHash !in trustedSignatures) {
// TODO: allow trusting keys
throw Exception("This apk is not a signed with the official tachiyomi signature")
}
// if (signatureHash == null) {
// throw Exception("Package $pkgName isn't signed")
// } else if (signatureHash !in trustedSignatures) {
// throw Exception("This apk is not a signed with the official tachiyomi signature")
// }
val isNsfw = packageInfo.applicationInfo.metaData.getString(METADATA_NSFW) == "1"
@@ -120,7 +140,7 @@ object Extension {
dex2jar(apkFilePath, jarFilePath, fileNameWithoutType)
// clean up
// File(apkFilePath).delete()
File(apkFilePath).delete()
File(dexFilePath).delete()
// collect sources from the extension
@@ -155,6 +175,7 @@ object Extension {
}
ExtensionTable.update({ ExtensionTable.pkgName eq pkgName }) {
it[this.apkName] = apkName
it[this.isInstalled] = true
it[this.classFQName] = className
}
@@ -200,19 +221,30 @@ object Extension {
val extensionRecord = transaction { ExtensionTable.select { ExtensionTable.pkgName eq pkgName }.first() }
val fileNameWithoutType = extensionRecord[ExtensionTable.apkName].substringBefore(".apk")
val jarPath = "${applicationDirs.extensionsRoot}/$fileNameWithoutType.jar"
transaction {
val sources = transaction {
val extensionId = extensionRecord[ExtensionTable.id].value
val sources = SourceTable.select { SourceTable.extension eq extensionId }.map { it[SourceTable.id].value }
SourceTable.deleteWhere { SourceTable.extension eq extensionId }
if (extensionRecord[ExtensionTable.isObsolete])
ExtensionTable.deleteWhere { ExtensionTable.pkgName eq pkgName }
else
ExtensionTable.update({ ExtensionTable.pkgName eq pkgName }) {
it[isInstalled] = false
}
sources
}
if (File(jarPath).exists()) {
// free up the file descriptor if exists
PackageTools.jarLoaderMap.remove(jarPath)?.close()
// clear all loaded sources
sources.forEach { GetHttpSource.invalidateSourceCache(it) }
File(jarPath).delete()
}
}
@@ -235,13 +267,13 @@ object Extension {
return installExtension(pkgName)
}
suspend fun getExtensionIcon(apkName: String): Pair<InputStream, String> {
val iconUrl =
transaction { ExtensionTable.select { ExtensionTable.apkName eq apkName }.first() }[ExtensionTable.iconUrl]
suspend fun getExtensionIcon(apkName: String, useCache: Boolean): Pair<InputStream, String> {
val iconUrl = if (apkName == "localSource") ""
else transaction { ExtensionTable.select { ExtensionTable.apkName eq apkName }.first() }[ExtensionTable.iconUrl]
val saveDir = "${applicationDirs.extensionsRoot}/icon"
return getCachedImageResponse(saveDir, apkName) {
return getImageResponse(saveDir, apkName, useCache) {
network.client.newCall(
GET(iconUrl)
).await()
@@ -7,6 +7,7 @@ package suwayomi.tachidesk.manga.impl.extension
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import eu.kanade.tachiyomi.source.local.LocalSource
import mu.KotlinLogging
import org.jetbrains.exposed.sql.deleteWhere
import org.jetbrains.exposed.sql.insert
@@ -46,7 +47,7 @@ object ExtensionsList {
}
fun extensionTableAsDataClass() = transaction {
ExtensionTable.selectAll().map {
ExtensionTable.selectAll().filter { it[ExtensionTable.name] != LocalSource.EXTENSION_NAME }.map {
ExtensionDataClass(
it[ExtensionTable.apkName],
getExtensionIconUrl(it[ExtensionTable.apkName]),
@@ -0,0 +1,65 @@
package suwayomi.tachidesk.manga.impl.util
/*
* 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.sql.select
import org.jetbrains.exposed.sql.transactions.transaction
import org.kodein.di.DI
import org.kodein.di.conf.global
import org.kodein.di.instance
import suwayomi.tachidesk.manga.impl.util.storage.SafePath
import suwayomi.tachidesk.manga.model.table.ChapterTable
import suwayomi.tachidesk.manga.model.table.MangaTable
import suwayomi.tachidesk.server.ApplicationDirs
import java.io.File
private val applicationDirs by DI.global.instance<ApplicationDirs>()
fun getMangaDir(mangaId: Int): String {
val mangaEntry = transaction { MangaTable.select { MangaTable.id eq mangaId }.first() }
val source = GetHttpSource.getHttpSource(mangaEntry[MangaTable.sourceReference])
val sourceDir = source.toString()
val mangaDir = SafePath.buildValidFilename(mangaEntry[MangaTable.title])
return "${applicationDirs.mangaRoot}/$sourceDir/$mangaDir"
}
fun getChapterDir(mangaId: Int, chapterId: Int): String {
val chapterEntry = transaction { ChapterTable.select { ChapterTable.id eq chapterId }.first() }
val chapterDir = SafePath.buildValidFilename(
when {
chapterEntry[ChapterTable.scanlator] != null -> "${chapterEntry[ChapterTable.scanlator]}_${chapterEntry[ChapterTable.name]}"
else -> chapterEntry[ChapterTable.name]
}
)
return getMangaDir(mangaId) + "/$chapterDir"
}
/** return value says if rename/move was successful */
fun updateMangaDownloadDir(mangaId: Int, newTitle: String): Boolean {
val mangaEntry = transaction { MangaTable.select { MangaTable.id eq mangaId }.first() }
val source = GetHttpSource.getHttpSource(mangaEntry[MangaTable.sourceReference])
val sourceDir = source.toString()
val mangaDir = SafePath.buildValidFilename(mangaEntry[MangaTable.title])
val newMangaDir = SafePath.buildValidFilename(newTitle)
val oldDir = "${applicationDirs.mangaRoot}/$sourceDir/$mangaDir"
val newDir = "${applicationDirs.mangaRoot}/$sourceDir/$newMangaDir"
val oldDirFile = File(oldDir)
val newDirFile = File(newDir)
return if (oldDirFile.exists())
oldDirFile.renameTo(newDirFile)
else true
}
@@ -9,6 +9,7 @@ package suwayomi.tachidesk.manga.impl.util
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceFactory
import eu.kanade.tachiyomi.source.local.LocalSource
import eu.kanade.tachiyomi.source.online.HttpSource
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction
@@ -35,6 +36,10 @@ object GetHttpSource {
SourceTable.select { SourceTable.id eq sourceId }.firstOrNull()!!
}
if (sourceId == LocalSource.ID) {
return LocalSource()
}
val extensionId = sourceRecord[SourceTable.extension]
val extensionRecord = transaction {
ExtensionTable.select { ExtensionTable.id eq extensionId }.first()
@@ -45,7 +45,7 @@ object PackageTools {
private const val officialSignature = "7ce04da7773d41b489f4693a366c36bcd0a11fc39b547168553c285bd7348e23" // inorichi's key
private const val unofficialSignature = "64feb21075ba97ebc9cc981243645b331595c111cef1b0d084236a0403b00581" // ArMor's key
var trustedSignatures = mutableSetOf<String>() + officialSignature + unofficialSignature
val trustedSignatures = mutableSetOf<String>() + officialSignature + unofficialSignature
/**
* Convert dex to jar, a wrapper for the dex2jar library
@@ -136,13 +136,19 @@ object PackageTools {
}
}
val jarLoaderMap = mutableMapOf<String, URLClassLoader>()
/**
* loads the extension main class called [className] from the jar located at [jarPath]
* It may return an instance of HttpSource or SourceFactory depending on the extension.
*/
fun loadExtensionSources(jarPath: String, className: String): Any {
val classLoader = URLClassLoader(arrayOf<URL>(URL("file:$jarPath")))
logger.debug { "loading jar with path: $jarPath" }
val classLoader = jarLoaderMap[jarPath] ?: URLClassLoader(arrayOf<URL>(URL("file:$jarPath")))
val classToLoad = Class.forName(className, false, classLoader)
jarLoaderMap[jarPath] = classLoader
return classToLoad.getDeclaredConstructor().newInstance()
}
}
@@ -0,0 +1,3 @@
package suwayomi.tachidesk.manga.impl.util.lang
fun List<String>.trimAll() = map { it.trim() }
@@ -13,12 +13,12 @@ import java.io.File
import java.io.FileInputStream
import java.io.InputStream
object CachedImageResponse {
object ImageResponse {
private fun pathToInputStream(path: String): InputStream {
return FileInputStream(path).buffered()
}
private fun findFileNameStartingWith(directoryPath: String, fileName: String): String? {
fun findFileNameStartingWith(directoryPath: String, fileName: String): String? {
val target = "$fileName."
File(directoryPath).listFiles().orEmpty().forEach { file ->
if (file.name.startsWith(target))
@@ -68,4 +68,30 @@ object CachedImageResponse {
File(it).delete()
}
}
suspend fun getNoCacheImageResponse(fetcher: suspend () -> Response): Pair<InputStream, String> {
val response = fetcher()
if (response.code == 200) {
val responseBytes = response.body!!.bytes()
// find image type
val imageType = response.headers["content-type"]
?: ImageUtil.findImageType { responseBytes.inputStream() }?.mime
?: "image/jpeg"
return responseBytes.inputStream() to imageType
} else {
response.closeQuietly()
throw Exception("request error! ${response.code}")
}
}
suspend fun getImageResponse(saveDir: String, fileName: String, useCache: Boolean = false, fetcher: suspend () -> Response): Pair<InputStream, String> {
return if (useCache) {
getCachedImageResponse(saveDir, fileName, fetcher)
} else {
getNoCacheImageResponse(fetcher)
}
}
}
@@ -1,11 +1,5 @@
package suwayomi.tachidesk.manga.impl.util.storage
import suwayomi.tachidesk.manga.impl.util.storage.ImageUtil.ImageType.GIF
import suwayomi.tachidesk.manga.impl.util.storage.ImageUtil.ImageType.JPG
import suwayomi.tachidesk.manga.impl.util.storage.ImageUtil.ImageType.PNG
import suwayomi.tachidesk.manga.impl.util.storage.ImageUtil.ImageType.WEBP
import java.io.InputStream
/*
* Copyright (C) Contributors to the Suwayomi project
*
@@ -13,8 +7,23 @@ import java.io.InputStream
* 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/. */
// adopted from: https://github.com/tachiyomiorg/tachiyomi/blob/ff369010074b058bb734ce24c66508300e6e9ac6/app/src/main/java/eu/kanade/tachiyomi/util/system/ImageUtil.kt
import suwayomi.tachidesk.manga.impl.util.storage.ImageUtil.ImageType.GIF
import suwayomi.tachidesk.manga.impl.util.storage.ImageUtil.ImageType.JPG
import suwayomi.tachidesk.manga.impl.util.storage.ImageUtil.ImageType.PNG
import suwayomi.tachidesk.manga.impl.util.storage.ImageUtil.ImageType.WEBP
import java.io.InputStream
import java.net.URLConnection
// adopted from: eu.kanade.tachiyomi.util.system.ImageUtil
object ImageUtil {
fun isImage(name: String, openStream: (() -> InputStream)? = null): Boolean {
val contentType = try {
URLConnection.guessContentTypeFromName(name)
} catch (e: Exception) {
null
} ?: openStream?.let { findImageType(it)?.mime }
return contentType?.startsWith("image/") ?: false
}
fun findImageType(openStream: () -> InputStream): ImageType? {
return openStream().use { findImageType(it) }
@@ -7,6 +7,7 @@ package suwayomi.tachidesk.manga.model.dataclass
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import suwayomi.tachidesk.manga.impl.util.lang.trimAll
import suwayomi.tachidesk.manga.model.table.MangaStatus
data class MangaDataClass(
@@ -22,7 +23,7 @@ data class MangaDataClass(
val artist: String? = null,
val author: String? = null,
val description: String? = null,
val genre: String? = null,
val genre: List<String> = emptyList(),
val status: String = MangaStatus.UNKNOWN.name,
val inLibrary: Boolean = false,
val source: SourceDataClass? = null,
@@ -39,3 +40,5 @@ data class PagedMangaListDataClass(
val mangaList: List<MangaDataClass>,
val hasNextPage: Boolean
)
internal fun String?.toGenreList() = this?.split(",")?.trimAll().orEmpty()
@@ -23,4 +23,7 @@ data class SourceDataClass(
/** The Source class has a @Nsfw annotation */
val isNsfw: Boolean?,
/** A nicer version of [name] */
val displayName: String?,
)
@@ -20,7 +20,7 @@ object ExtensionTable : IntIdTable() {
val pkgName = varchar("pkg_name", 128)
val versionName = varchar("version_name", 16)
val versionCode = integer("version_code")
val lang = varchar("lang", 10)
val lang = varchar("lang", 32)
val isNsfw = bool("is_nsfw")
val isInstalled = bool("is_installed").default(false)
@@ -13,6 +13,7 @@ import org.jetbrains.exposed.sql.ResultRow
import suwayomi.tachidesk.manga.impl.Manga.getMangaMetaMap
import suwayomi.tachidesk.manga.impl.MangaList.proxyThumbnailUrl
import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass
import suwayomi.tachidesk.manga.model.dataclass.toGenreList
import suwayomi.tachidesk.manga.model.table.MangaStatus.Companion
object MangaTable : IntIdTable() {
@@ -20,10 +21,10 @@ object MangaTable : IntIdTable() {
val title = varchar("title", 512)
val initialized = bool("initialized").default(false)
val artist = varchar("artist", 64).nullable()
val author = varchar("author", 64).nullable()
val description = varchar("description", 4096).nullable()
val genre = varchar("genre", 1024).nullable()
val artist = varchar("artist", 512).nullable()
val author = varchar("author", 512).nullable()
val description = varchar("description", Integer.MAX_VALUE).nullable()
val genre = varchar("genre", Integer.MAX_VALUE).nullable()
val status = integer("status").default(SManga.UNKNOWN)
val thumbnail_url = varchar("thumbnail_url", 2048).nullable()
@@ -52,7 +53,7 @@ fun MangaTable.toDataClass(mangaEntry: ResultRow) =
mangaEntry[artist],
mangaEntry[author],
mangaEntry[description],
mangaEntry[genre],
mangaEntry[genre].toGenreList(),
Companion.valueOf(mangaEntry[status]).name,
mangaEntry[inLibrary],
meta = getMangaMetaMap(mangaEntry[id].value),
@@ -12,7 +12,7 @@ import org.jetbrains.exposed.dao.id.IdTable
object SourceTable : IdTable<Long>() {
override val id = long("id").entityId()
val name = varchar("name", 128)
val lang = varchar("lang", 10)
val lang = varchar("lang", 32)
val extension = reference("extension", ExtensionTable)
val isNsfw = bool("is_nsfw").default(false)
}
@@ -9,6 +9,7 @@ package suwayomi.tachidesk.server
import io.javalin.Javalin
import io.javalin.apibuilder.ApiBuilder.path
import io.javalin.core.security.RouteRole
import io.javalin.http.staticfiles.Location
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -78,6 +79,18 @@ object JavalinSetup {
ctx.result(e.message ?: "Internal Server Error")
}
app.before { ctx ->
fun credentialsValid(): Boolean {
val (username, password) = ctx.basicAuthCredentials()
return username == serverConfig.basicAuthUsername && password == serverConfig.basicAuthPassword
}
if (serverConfig.basicAuthEnabled && !(ctx.basicAuthCredentialsExist() && credentialsValid())) {
ctx.header("WWW-Authenticate", "Basic")
ctx.status(401).json("Unauthorized")
}
}
app.routes {
path("api/v1/") {
GlobalAPI.defineEndpoints()
@@ -86,4 +99,8 @@ object JavalinSetup {
}
}
}
object Auth {
enum class Role : RouteRole { ANYONE, USER_READ, USER_WRITE }
}
}
@@ -33,6 +33,11 @@ class ServerConfig(config: Config, moduleName: String = MODULE_NAME) : SystemPro
val webUIInterface: String by overridableConfig
val electronPath: String by overridableConfig
// Authentication
val basicAuthEnabled: Boolean by overridableConfig
val basicAuthUsername: String by overridableConfig
val basicAuthPassword: String by overridableConfig
companion object {
fun register(config: Config) = ServerConfig(config.getConfig(MODULE_NAME))
}
@@ -8,6 +8,7 @@ package suwayomi.tachidesk.server
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import eu.kanade.tachiyomi.App
import eu.kanade.tachiyomi.source.local.LocalSource
import mu.KotlinLogging
import org.kodein.di.DI
import org.kodein.di.bind
@@ -33,6 +34,7 @@ class ApplicationDirs(
val mangaThumbnailsRoot = "$dataRoot/manga-thumbnails"
val animeThumbnailsRoot = "$dataRoot/anime-thumbnails"
val mangaRoot = "$dataRoot/manga"
val localMangaRoot = "$dataRoot/manga-local"
val webUIRoot = "$dataRoot/webUI"
}
@@ -63,6 +65,8 @@ fun applicationSetup() {
applicationDirs.extensionsRoot + "/icon",
applicationDirs.mangaThumbnailsRoot,
applicationDirs.animeThumbnailsRoot,
applicationDirs.mangaRoot,
applicationDirs.localMangaRoot,
).forEach {
File(it).mkdirs()
}
@@ -93,7 +97,21 @@ fun applicationSetup() {
}
}
} catch (e: Exception) {
logger.error("Exception while creating initial server.conf:\n", e)
logger.error("Exception while creating initial server.conf", e)
}
// copy local source icon
try {
val localSourceIconFile = File("${applicationDirs.extensionsRoot}/icon/localSource.png")
if (!localSourceIconFile.exists()) {
JavalinSetup::class.java.getResourceAsStream("/icon/localSource.png").use { input ->
localSourceIconFile.outputStream().use { output ->
input.copyTo(output)
}
}
}
} catch (e: Exception) {
logger.error("Exception while copying Local source's icon", e)
}
// fixes #119 , ref: https://github.com/Suwayomi/Tachidesk-Server/issues/119#issuecomment-894681292 , source Id calculation depends on String.lowercase()
@@ -101,6 +119,8 @@ fun applicationSetup() {
databaseUp()
LocalSource.addDbRecords()
// create system tray
if (serverConfig.systemTrayEnabled) {
try {
@@ -0,0 +1,20 @@
package suwayomi.tachidesk.server.database.migration
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import de.neonew.exposed.migrations.helpers.SQLMigration
@Suppress("ClassName", "unused")
class M0014_MangaRemoveLengthLimit : SQLMigration() {
override val sql = """
ALTER TABLE MANGA ALTER COLUMN ARTIST VARCHAR(512);
ALTER TABLE MANGA ALTER COLUMN AUTHOR VARCHAR(512);
ALTER TABLE MANGA ALTER COLUMN DESCRIPTION VARCHAR; -- the default length is `Integer.MAX_VALUE`
ALTER TABLE MANGA ALTER COLUMN GENRE VARCHAR; -- the default length is `Integer.MAX_VALUE`
""".trimIndent()
}
@@ -0,0 +1,18 @@
package suwayomi.tachidesk.server.database.migration
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import de.neonew.exposed.migrations.helpers.SQLMigration
@Suppress("ClassName", "unused")
class M0015_SourceAndExtensionLangAddLengthLimit : SQLMigration() {
override val sql = """
ALTER TABLE SOURCE ALTER COLUMN LANG VARCHAR(32);
ALTER TABLE EXTENSION ALTER COLUMN LANG VARCHAR(32);
""".trimIndent()
}
@@ -46,7 +46,7 @@ object AppMutex {
}
return try {
JavalinJackson.fromJson(response, AboutDataClass::class.java)
JavalinJackson().fromJsonString(response, AboutDataClass::class.java)
AppMutexState.TachideskInstanceRunning
} catch (e: IOException) {
AppMutexState.OtherApplicationRunning
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

@@ -16,3 +16,8 @@ server.webUIEnabled = true
server.initialOpenInBrowserEnabled = true
server.webUIInterface = "browser" # "browser" or "electron"
server.electronPath = ""
# Authentication
server.basicAuthEnabled = false
server.basicAuthUsername = ""
server.basicAuthPassword = ""