Compare commits

..

37 Commits

Author SHA1 Message Date
Syer10 f40dcafb43 Release v1.1.1
CI Publish / Validate Gradle Wrapper (push) Successful in 11s
CI Publish / Build Jar (push) Failing after 8s
CI Publish / Make debian-all release (push) Has been skipped
CI Publish / Make linux-assets release (push) Has been skipped
CI Publish / Make linux-x64 release (push) Has been skipped
CI Publish / Make macOS-arm64 release (push) Has been skipped
CI Publish / Make macOS-x64 release (push) Has been skipped
CI Publish / Make windows-x64 release (push) Has been skipped
CI Publish / Make windows-x86 release (push) Has been skipped
CI Publish / release (push) Has been skipped
2024-06-15 13:15:00 -04:00
schroda d9cb54b285 Compare webUI version with bundled webUI version (#969)
* Compare webUI version with bundled webUI version

The bundled webUI version was incorrectly compared with the minimum server version.
This worked until the latest release, because the bundled webUI version had a lower revision number than the server revision, however, with the latest release, it is now higher, resulting in no compatible webUI version to be found

* Consider bundled webUI version only for default flavor

* Add "isDefault" util function to WebUIFlavor
2024-06-15 13:05:39 -04:00
schroda f738a162d3 Support for "STABLEPREVIEW" webUI version (#970)
Makes it possible to release new stable webUI versions without having to update the mapping file.
2024-06-15 13:05:28 -04:00
Syer10 fda4cd6783 Release v1.1.0
CI Publish / Validate Gradle Wrapper (push) Successful in 13s
CI Publish / Build Jar (push) Failing after 7s
CI Publish / Make debian-all release (push) Has been skipped
CI Publish / Make linux-assets release (push) Has been skipped
CI Publish / Make linux-x64 release (push) Has been skipped
CI Publish / Make macOS-arm64 release (push) Has been skipped
CI Publish / Make macOS-x64 release (push) Has been skipped
CI Publish / Make windows-x64 release (push) Has been skipped
CI Publish / Make windows-x86 release (push) Has been skipped
CI Publish / release (push) Has been skipped
2024-06-14 21:41:17 -04:00
schroda ecd1604e25 Update metadata in source browse only if new data is not null (#962)
Browsing a source loads only a minimal representation of a manga which does not include some metadata.
This metadata is only loaded when the specific manga gets fetched.

Thus, when the extra metadata of a manga was already loaded, it got removed when browsing the source and a page response included this manga
2024-06-09 12:08:21 -04:00
Mitchell Syer 0f061900af Fix browse source (#961) 2024-06-09 11:23:54 -04:00
Rat Cornu c47f5ea85e [skip ci] doc: add NixOS installation (#959) 2024-06-08 10:48:43 -04:00
Mitchell Syer 306eb0e3c7 Update manga info when browsing if not in library (#958)
* Update manga info when browsing if not in library

* Cleanup
2024-06-06 21:17:17 -04:00
schroda e64025ded8 Correctly set name of logger (#956) 2024-06-02 20:33:32 -04:00
schroda c1fe2da636 Fix/failing thumbnail requests with http 410 (#955)
* Refresh thumbnail url on 410 error

* Refresh thumbnail url on 301 error
2024-06-02 20:33:25 -04:00
schroda ff23f58a4f Support partial mutation responses (#954)
In case e.g. a mutation was made which looked like this

myMutation {
  mutationA { ... }
  mutationB { ... }
  mutationC { ... }
}

and mutation A and B succeeded while mutation C failed, the response only included the error of C and the successful mutation data response of A and B was missing
2024-06-02 20:33:17 -04:00
schroda fc2f5ffdf9 Fix/failing track progress update for logged out trackers (#953)
* Refresh track record only when logged in

In case one tracker was logged out, the refresh failed with an unauthenticated error and caused the other trackers to not get updated

* Prevent chapter track update from failing due to failure of other tracker

* Change level of log to "info"
2024-06-01 12:22:25 -04:00
schroda 6dd9ed7fb0 Fix/prevent importing unsupported trackers from backup II (#945)
* Properly prevent importing unsupported trackers from backup

Missed the early return in case no tracker record exists in the database in 2f362abb91be875e943b1364eb86d70a4144dd6f...

* Remove incorrect non null assertion

Prevented unbinding track records of unsupported trackers
2024-05-07 09:30:45 -04:00
schroda 2f362abb91 Prevent importing unsupported tracker from backup (#944)
* Prevent importing unsupported tracker from backup

This will lead to graphql field validation errors (non null declared field is null) once the track records get used, since they will point to trackers that do not exist

* Delete track records of unsupporter trackers

* Always return all track records of manga

Was already partially changed in 7df5f1c4c4 but this occurrence was missed
2024-05-06 09:29:34 -04:00
FumoVite 96807a64cf [skip ci] Update README.md (#941)
fixed "inctive"
2024-05-05 13:24:31 -04:00
schroda 7df5f1c4c4 Feature/backup tracking (#940)
* Include tracking in validation of backup

* Always return track records

Not clear why an empty list should be returned in case no trackers are logged in

* Include tracking in backup creation

* Restore tracking from backup
2024-05-05 13:24:16 -04:00
schroda cf1ede9cf7 Update lastPageRead on chapter update (#939)
Broken with 729385588a3d8e06ec8be38865a12c47e88f6bcb...
2024-04-28 10:35:33 -04:00
schroda 729385588a Prevent greater last page read than page count (#938)
In case multiple chapters are getting updated, the last page read might be higher than the available pages of a chapter
2024-04-28 00:34:40 -04:00
schroda 668d5cf8f0 Prevent IndexOutOfBoundsException when removing duplicated chapters (#935)
In case the "new" chapters consisted only of re-uploads an out of bound exception was thrown
2024-04-27 20:33:30 -04:00
schroda 72b1b5b0f9 Exit track progress update early in case new chapter is same as current local (#937)
Prevents unnecessary requests
2024-04-27 20:33:19 -04:00
schroda fbf726c174 Use "AsyncExecutionStrategy" for mutations (#932)
Batching only works with "AsyncExecutionStrategy" and by default mutations use "SerialExecutionStrategy"
2024-04-15 17:49:33 -04:00
schroda c441eed847 Exclude duplicated chapters from auto download limit (#923)
In case the new chapters include duplicates from different scanlators, they would be included in the limit causing the auto download to potentially only download duplicated chapters while there might be more non duplicated chapters to download.

Instead, the limit should only consider unique chapters and then should include all duplicates of the chapters that should get downloaded
2024-04-06 23:07:55 -04:00
schroda e8e83ed49c Remove duplicated mangas from gql "mangas" query (#924) 2024-04-06 22:53:56 -04:00
schroda cdc21b067c Fix/recognition of already downloaded chapters (#922)
* Remove overrides of "ChapterFilesProvider::downloadImpl"

* Check final download folder for existing page on download

Downloads were changed to get downloaded to the system temp folder instead to directly into the final download folder.

This broke the check for existing pages, because now only the temp folder was checked instead of both the temp and the final download folder.

Regression introduced with 1c9a139006

* Properly check for already existing downloaded pages

The previous check was always false because the file ending of the page file is unknown and thus, missing from the created file path

* Cleanup cache download folder
2024-04-06 22:53:49 -04:00
schroda 48e19f7914 Feature/auto download of new chapters improve handling of unhandable reuploads (#921)
* Update test/server-reference file

* Properly handle re-uploaded chapters in auto download of new chapters

In case of unhandable re-uploaded chapters (different chapter numbers) they potentially would have prevented auto downloads due being considered as unread.

Additionally, they would not have been considered to get downloaded due to not having a higher chapter number than the previous latest existing chapter before the chapter list fetch.

* Add option to ignore re-uploads for auto downloads

* Extract check for manga category download inclusion

* Extract logic to get new chapter ids to download

* Simplify manga category download inclusion check

In case the DEFAULT category does not exist, someone messed with the database and it is basically corrupted
2024-04-06 22:53:36 -04:00
schroda 89dd570b30 Add mutation to fetch the latest track data from the tracker (#920)
* Add mutation to fetch the latest track data from the tracker

* Update Track.kt

---------

Co-authored-by: Mitchell Syer <Syer10@users.noreply.github.com>
2024-03-31 13:25:58 -04:00
schroda 16474d4328 Feature/tracking gql add option to delete remote binding on tracker (#919)
* Extract unbinding track into function

* Introduce new unbind mutation

* Add option to delete track binding on track service

---------

Co-authored-by: Mitchell Syer <Syer10@users.noreply.github.com>
2024-03-31 13:22:13 -04:00
schroda 9db612bf03 Move trigger for track progress update to client (#918)
Triggering the progress update on server side does not work because the client needs to get the mutation result, otherwise, the clients cache will get outdated
2024-03-31 13:20:37 -04:00
schroda 7d92dbc5c0 Fix/tracking progress update in case local chapter is smaller than remote (#917)
* Update lastReadChapter on bind in case it's greater than remote

* Update lastReadChapter on chapter read in case it's greater than remote

* [Logging] Improve logs
2024-03-31 13:20:27 -04:00
schroda a9efca8687 Add chapter bookmark count field to MangaType (#912) 2024-03-31 13:20:19 -04:00
schroda dbfea5d02b Update inLibraryAt timestamp when adding manga to library (#911) 2024-03-31 13:20:08 -04:00
schroda a6b05c4a27 Feature/refresh outdated thumbnail url on fetch failure (#910)
* Extract thumbnail url fresh into function

* Remove incorrect non-null assertion

According to the typing there is no guarantee that fetching a manga from the source provides a thumbnail url

* Refresh manga thumbnail url on 404 error

* Refresh manga thumbnail url on unreachable origin cloudflare errors
2024-03-31 13:19:58 -04:00
schroda 6d539d3404 Fix/update subscription clear data loader cache (#908)
* Set updater running flag to false only at the end of the update

For clearing the data loader cache properly, the update status subscription requires the update to be running.

For the last completed manga update the flag was immediately set to false which prevented the dataloader cache from getting cleared, returning outdated data for the last updated manga

* Correctly clear the "MangaForIdsDataLoader" cache

The cache keys for this dataloader are lists of manga ids.
Thus, it is not possible to clear only the cached data of the provided manga id and instead each cache entry that includes the manga id has to be cleared

* Ensure that manga dataloader caches gets cleared during global update

The "StateFlow" drops value updates in case the collector is too slow, which was the case for the "UpdateSubscription".

This caused the dataloader cache to not get properly cleared because the running state of the update was already set to false.
2024-03-31 13:19:49 -04:00
Mitchell Syer b2aff1efc9 Fix MAL after restarting the server (#903)
* Fix MAL after restarting the server

* Cleanup MAL interceptor

* Fix

* Cleanup Anilist interceptor

* Use IOException

* Make Anilist private

* Lint
2024-03-16 23:36:45 -04:00
schroda 8a20a1ef50 Add first unread chapter field to MangaType (#900) 2024-03-10 19:01:03 -04:00
schroda 33cbfa9751 Fix/electron launch error not logged (#895)
* Log "Browser::openInBrowser" errors

The error was never written to the log file.
It was only visible in the console

* Remove "printStackTrace" usage with logs
2024-03-10 19:00:54 -04:00
schroda b95a8d44d4 Always fetch thumbnail of manga from local source (#898)
The local manga thumbnail got "downloaded" to thumbnail download folder of in library manga.
Since the "thumbnail url" of a local source manga never changes, the "downloaded" manga thumbnail never got updated

Regression introduced with f2dd67d87f
2024-03-10 19:00:44 -04:00
64 changed files with 1989 additions and 1050 deletions
+1 -1
View File
@@ -23,7 +23,7 @@ Note that the issue will be automatically closed if you do not fill out the titl
---
## Device information
- Suwayomi-Server version: (Example: v1.0.0-r1438-win32)
- Suwayomi-Server version: (Example: v1.1.1-r1535-win32)
- Server Operating System: (Example: Ubuntu 20.04)
- Server Desktop Environment: N/A or (Example: Gnome 40)
- Server JVM version: bundled with win32 or (Example: Java 8 Update 281 or OpenJDK 8u281)
+55
View File
@@ -1,3 +1,58 @@
# Server: v1.1.1 + WevUI: v1.1.0
## TL;DR
- WebUI update bugfixes
## Suwayomi-Server Changelog
- ([r1534](https://github.com/Suwayomi/Suwayomi-Server/commit/d9cb54b28593e4df87522090f03a6e5b9c7d9fa2)) Compare webUI version with bundled webUI version ([#969](https://github.com/Suwayomi/Suwayomi-Server/pull/969) by @schroda)
- ([r1533](https://github.com/Suwayomi/Suwayomi-Server/commit/f738a162d3cd4582612d4986b3d3887e1c309bdd)) Support for "STABLEPREVIEW" webUI version ([#970](https://github.com/Suwayomi/Suwayomi-Server/pull/970) by @schroda)
# Server: v1.1.0 + WevUI: v1.1.0
## TL;DR
- Update Manga Info in browse
- Full Tracking support
- Import Tracking from backups
- Improved support for library filters
- Improved thumbnail handling
- Many minor bugfixes
- WebUI changes: https://github.com/Suwayomi/Suwayomi-WebUI/releases/tag/v1.1.0
## Suwayomi-Server Changelog
- ([r1531](https://github.com/Suwayomi/Suwayomi-Server/commit/ecd1604e25a17a6ef68d568b5d81e69f6e9f7702)) Update metadata in source browse only if new data is not null ([#962](https://github.com/Suwayomi/Suwayomi-Server/pull/962) by @schroda)
- ([r1530](https://github.com/Suwayomi/Suwayomi-Server/commit/0f061900afbd4036b4d081bcb3f39e4f60160ac3)) Fix browse source ([#961](https://github.com/Suwayomi/Suwayomi-Server/pull/961) by @Syer10)
- ([r1529](https://github.com/Suwayomi/Suwayomi-Server/commit/c47f5ea85e75c7119d828a19eda392b2fb9faecd)) [skip ci] doc: add NixOS installation ([#959](https://github.com/Suwayomi/Suwayomi-Server/pull/959) by @RatCornu)
- ([r1528](https://github.com/Suwayomi/Suwayomi-Server/commit/306eb0e3c774b5a8d0d2d4ade2d7091904fe6e58)) Update manga info when browsing if not in library ([#958](https://github.com/Suwayomi/Suwayomi-Server/pull/958) by @Syer10)
- ([r1527](https://github.com/Suwayomi/Suwayomi-Server/commit/e64025ded814a15129999586133d84085e0c5779)) Correctly set name of logger ([#956](https://github.com/Suwayomi/Suwayomi-Server/pull/956) by @schroda)
- ([r1526](https://github.com/Suwayomi/Suwayomi-Server/commit/c1fe2da636b675091ec0ac93162764883938ffc6)) Fix/failing thumbnail requests with http 410 ([#955](https://github.com/Suwayomi/Suwayomi-Server/pull/955) by @schroda)
- ([r1525](https://github.com/Suwayomi/Suwayomi-Server/commit/ff23f58a4f4e8e0b4d459957f0e0701265e0c364)) Support partial mutation responses ([#954](https://github.com/Suwayomi/Suwayomi-Server/pull/954) by @schroda)
- ([r1524](https://github.com/Suwayomi/Suwayomi-Server/commit/fc2f5ffdf9c1e8675f9b978031a49fb4ce3af601)) Fix/failing track progress update for logged out trackers ([#953](https://github.com/Suwayomi/Suwayomi-Server/pull/953) by @schroda)
- ([r1523](https://github.com/Suwayomi/Suwayomi-Server/commit/6dd9ed7fb0816b2f163bec43d04c433099e7e529)) Fix/prevent importing unsupported trackers from backup II ([#945](https://github.com/Suwayomi/Suwayomi-Server/pull/945) by @schroda)
- ([r1522](https://github.com/Suwayomi/Suwayomi-Server/commit/2f362abb91be875e943b1364eb86d70a4144dd6f)) Prevent importing unsupported tracker from backup ([#944](https://github.com/Suwayomi/Suwayomi-Server/pull/944) by @schroda)
- ([r1521](https://github.com/Suwayomi/Suwayomi-Server/commit/96807a64cf1b13b6db655d46e90c42717170ce62)) [skip ci] Update README.md ([#941](https://github.com/Suwayomi/Suwayomi-Server/pull/941) by @FumoVite)
- ([r1520](https://github.com/Suwayomi/Suwayomi-Server/commit/7df5f1c4c4408cfbbd56697ba10f018393df2b4a)) Feature/backup tracking ([#940](https://github.com/Suwayomi/Suwayomi-Server/pull/940) by @schroda)
- ([r1519](https://github.com/Suwayomi/Suwayomi-Server/commit/cf1ede9cf70a2d72a7ff84b9ead24a394ceee2ce)) Update lastPageRead on chapter update ([#939](https://github.com/Suwayomi/Suwayomi-Server/pull/939) by @schroda)
- ([r1518](https://github.com/Suwayomi/Suwayomi-Server/commit/729385588a3d8e06ec8be38865a12c47e88f6bcb)) Prevent greater last page read than page count ([#938](https://github.com/Suwayomi/Suwayomi-Server/pull/938) by @schroda)
- ([r1517](https://github.com/Suwayomi/Suwayomi-Server/commit/668d5cf8f02e35cc53d1430a239ae67837c64f51)) Prevent IndexOutOfBoundsException when removing duplicated chapters ([#935](https://github.com/Suwayomi/Suwayomi-Server/pull/935) by @schroda)
- ([r1516](https://github.com/Suwayomi/Suwayomi-Server/commit/72b1b5b0f9b86f82a0e203802d9a4b6339277c01)) Exit track progress update early in case new chapter is same as current local ([#937](https://github.com/Suwayomi/Suwayomi-Server/pull/937) by @schroda)
- ([r1515](https://github.com/Suwayomi/Suwayomi-Server/commit/fbf726c17434212cdf94b39f52a25a0050d77287)) Use "AsyncExecutionStrategy" for mutations ([#932](https://github.com/Suwayomi/Suwayomi-Server/pull/932) by @schroda)
- ([r1514](https://github.com/Suwayomi/Suwayomi-Server/commit/c441eed84773fdc295e6d004e4f4628453b54659)) Exclude duplicated chapters from auto download limit ([#923](https://github.com/Suwayomi/Suwayomi-Server/pull/923) by @schroda)
- ([r1513](https://github.com/Suwayomi/Suwayomi-Server/commit/e8e83ed49caac2d25f29073d1bd3b5b385aa2d98)) Remove duplicated mangas from gql "mangas" query ([#924](https://github.com/Suwayomi/Suwayomi-Server/pull/924) by @schroda)
- ([r1512](https://github.com/Suwayomi/Suwayomi-Server/commit/cdc21b067c1a341d68ea7a9c1ee565dc3959f552)) Fix/recognition of already downloaded chapters ([#922](https://github.com/Suwayomi/Suwayomi-Server/pull/922) by @schroda)
- ([r1511](https://github.com/Suwayomi/Suwayomi-Server/commit/48e19f7914fee1ea1789b217d5df9b05acb49203)) Feature/auto download of new chapters improve handling of unhandable reuploads ([#921](https://github.com/Suwayomi/Suwayomi-Server/pull/921) by @schroda)
- ([r1510](https://github.com/Suwayomi/Suwayomi-Server/commit/89dd570b3057bee34643858b4a42bfac7d88a82b)) Add mutation to fetch the latest track data from the tracker ([#920](https://github.com/Suwayomi/Suwayomi-Server/pull/920) by @schroda, @Syer10)
- ([r1509](https://github.com/Suwayomi/Suwayomi-Server/commit/16474d4328651f1236722556b7f59628a0f9dbda)) Feature/tracking gql add option to delete remote binding on tracker ([#919](https://github.com/Suwayomi/Suwayomi-Server/pull/919) by @schroda, @Syer10)
- ([r1508](https://github.com/Suwayomi/Suwayomi-Server/commit/9db612bf0317950d0291047b9ee64a0787e49bf2)) Move trigger for track progress update to client ([#918](https://github.com/Suwayomi/Suwayomi-Server/pull/918) by @schroda)
- ([r1507](https://github.com/Suwayomi/Suwayomi-Server/commit/7d92dbc5c0a47176099eb310eaf17a4788ba2ce4)) Fix/tracking progress update in case local chapter is smaller than remote ([#917](https://github.com/Suwayomi/Suwayomi-Server/pull/917) by @schroda)
- ([r1506](https://github.com/Suwayomi/Suwayomi-Server/commit/a9efca86870cec6d74f58535e2e007eb6c8831c2)) Add chapter bookmark count field to MangaType ([#912](https://github.com/Suwayomi/Suwayomi-Server/pull/912) by @schroda)
- ([r1505](https://github.com/Suwayomi/Suwayomi-Server/commit/dbfea5d02b898884fdeb2be2959fe8a73a465704)) Update inLibraryAt timestamp when adding manga to library ([#911](https://github.com/Suwayomi/Suwayomi-Server/pull/911) by @schroda)
- ([r1504](https://github.com/Suwayomi/Suwayomi-Server/commit/a6b05c4a2759d0d5f834a54cad6c8417fe49a0d2)) Feature/refresh outdated thumbnail url on fetch failure ([#910](https://github.com/Suwayomi/Suwayomi-Server/pull/910) by @schroda)
- ([r1503](https://github.com/Suwayomi/Suwayomi-Server/commit/6d539d34040c4e95692b57ce4fedfbeaa73083d0)) Fix/update subscription clear data loader cache ([#908](https://github.com/Suwayomi/Suwayomi-Server/pull/908) by @schroda)
- ([r1502](https://github.com/Suwayomi/Suwayomi-Server/commit/b2aff1efc9e6527e70ba519e5171096394e6ccf7)) Fix MAL after restarting the server ([#903](https://github.com/Suwayomi/Suwayomi-Server/pull/903) by @Syer10)
- ([r1501](https://github.com/Suwayomi/Suwayomi-Server/commit/8a20a1ef5094efc05426ed420bbde40358fdf2dd)) Add first unread chapter field to MangaType ([#900](https://github.com/Suwayomi/Suwayomi-Server/pull/900) by @schroda)
- ([r1500](https://github.com/Suwayomi/Suwayomi-Server/commit/33cbfa9751c3ef7a6babfcff9595782cbac5acae)) Fix/electron launch error not logged ([#895](https://github.com/Suwayomi/Suwayomi-Server/pull/895) by @schroda)
- ([r1499](https://github.com/Suwayomi/Suwayomi-Server/commit/b95a8d44d4bb7c94a04e66b3d6cc0fc101f4880b)) Always fetch thumbnail of manga from local source ([#898](https://github.com/Suwayomi/Suwayomi-Server/pull/898) by @schroda)
## [Suwayomi-WebUI Changelog](https://github.com/Suwayomi/Suwayomi-WebUI/blob/master/CHANGELOG.md#v110-r1689)
# Server: v1.0.0 + WevUI: r1409
## TL;DR
- GraphQL API
+16 -1
View File
@@ -48,7 +48,7 @@ Here's a list of known clients/user interfaces for Suwayomi-Server:
- [Tachidesk-Sorayomi](https://github.com/Suwayomi/Tachidesk-Sorayomi): A Flutter front-end for Desktop(Linux, windows, etc.), Web and Android with a User Interface inspired by Tachiyomi.
- [Tachidesk-VaadinUI](https://github.com/Suwayomi/Tachidesk-VaadinUI): A Web front-end for Suwayomi-Server built with Vaadin.
- [Suwayomi-VUI](https://github.com/Suwayomi/Suwayomi-VUI): A preview focused web frontend built with svelte with some features the other UIs might not have (migration)
##### Inctive/Abandoned Clients
##### Inactive/Abandoned Clients
- [Tachidesk-qtui](https://github.com/Suwayomi/Tachidesk-qtui): A C++/Qt front-end for mobile devices(Android/linux), feature support is basic.
- [Tachidesk-GTK](https://github.com/mahor1221/Tachidesk-GTK): A native Rust/GTK desktop client.
- [Equinox](https://github.com/Suwayomi/Equinox): A web user interface made with Vue.js.
@@ -108,6 +108,21 @@ sudo apt update
sudo apt install suwayomi-server
```
### NixOS
You can deploy Suwayomi on NixOS using the module `services.suwayomi-server` in your configuration:
```
{
services.suwayomi-server = {
enable = true;
};
}
```
For more information, see [the NixOS manual](https://nixos.org/manual/nixos/stable/#module-services-suwayomi-server).
You can also directly use the package from [nixpkgs](https://search.nixos.org/packages?channel=unstable&type=packages&query=suwayomi-server).
### Docker
Check our Official Docker release [Suwayomi Container](https://github.com/orgs/Suwayomi/packages/container/package/tachidesk) for running Suwayomi Server in a docker container. Source code for our container is available at [docker-tachidesk](https://github.com/Suwayomi/docker-tachidesk). By default, the server will be running on http://localhost:4567 open this url in your browser.
+2 -2
View File
@@ -10,9 +10,9 @@ import java.io.BufferedReader
const val MainClass = "suwayomi.tachidesk.MainKt"
// should be bumped with each stable release
val tachideskVersion = System.getenv("ProductVersion") ?: "v1.0.0"
val tachideskVersion = System.getenv("ProductVersion") ?: "v1.1.1"
val webUIRevisionTag = System.getenv("WebUIRevision") ?: "r1409"
val webUIRevisionTag = System.getenv("WebUIRevision") ?: "r1689"
// counts commits on the current checked out branch
val getTachideskRevision = {
@@ -0,0 +1,25 @@
package suwayomi.tachidesk.graphql
import com.expediagroup.graphql.server.extensions.toGraphQLError
import graphql.execution.DataFetcherResult
import mu.KotlinLogging
val logger = KotlinLogging.logger { }
inline fun <T> asDataFetcherResult(block: () -> T): DataFetcherResult<T?> {
val result =
runCatching {
block()
}
if (result.isFailure) {
logger.error(result.exceptionOrNull()) { "asDataFetcherResult: failed due to" }
return DataFetcherResult.newResult<T?>()
.error(result.exceptionOrNull()?.toGraphQLError())
.build()
}
return DataFetcherResult.newResult<T?>()
.data(result.getOrNull())
.build()
}
@@ -0,0 +1,46 @@
package suwayomi.tachidesk.graphql.cache
import org.dataloader.CacheMap
import java.util.concurrent.CompletableFuture
class CustomCacheMap<K, V> : CacheMap<K, V> {
private val cache: MutableMap<K, CompletableFuture<V>>
init {
cache = HashMap()
}
override fun containsKey(key: K): Boolean {
return cache.containsKey(key)
}
override fun get(key: K): CompletableFuture<V> {
return cache[key]!!
}
fun getKeys(): Collection<K> {
return cache.keys.toSet()
}
override fun getAll(): Collection<CompletableFuture<V>> {
return cache.values
}
override fun set(
key: K,
value: CompletableFuture<V>,
): CacheMap<K, V> {
cache[key] = value
return this
}
override fun delete(key: K): CacheMap<K, V> {
cache.remove(key)
return this
}
override fun clear(): CacheMap<K, V> {
cache.clear()
return this
}
}
@@ -99,6 +99,26 @@ class UnreadChapterCountForMangaDataLoader : KotlinDataLoader<Int, Int> {
}
}
class BookmarkedChapterCountForMangaDataLoader : KotlinDataLoader<Int, Int> {
override val dataLoaderName = "BookmarkedChapterCountForMangaDataLoader"
override fun getDataLoader(): DataLoader<Int, Int> =
DataLoaderFactory.newDataLoader<Int, Int> { ids ->
future {
transaction {
addLogger(Slf4jSqlDebugLogger)
val bookmarkedChapterCountByMangaId =
ChapterTable
.slice(ChapterTable.manga, ChapterTable.isBookmarked.count())
.select { (ChapterTable.manga inList ids) and (ChapterTable.isBookmarked eq true) }
.groupBy(ChapterTable.manga)
.associate { it[ChapterTable.manga].value to it[ChapterTable.isBookmarked.count()] }
ids.map { bookmarkedChapterCountByMangaId[it]?.toInt() ?: 0 }
}
}
}
}
class LastReadChapterForMangaDataLoader : KotlinDataLoader<Int, ChapterType?> {
override val dataLoaderName = "LastReadChapterForMangaDataLoader"
@@ -174,3 +194,22 @@ class LatestUploadedChapterForMangaDataLoader : KotlinDataLoader<Int, ChapterTyp
}
}
}
class FirstUnreadChapterForMangaDataLoader : KotlinDataLoader<Int, ChapterType?> {
override val dataLoaderName = "FirstUnreadChapterForMangaDataLoader"
override fun getDataLoader(): DataLoader<Int, ChapterType?> =
DataLoaderFactory.newDataLoader<Int, ChapterType?> { ids ->
future {
transaction {
addLogger(Slf4jSqlDebugLogger)
val firstUnreadChaptersByMangaId =
ChapterTable
.select { (ChapterTable.manga inList ids) and (ChapterTable.isRead eq false) }
.orderBy(ChapterTable.sourceOrder to SortOrder.ASC)
.groupBy { it[ChapterTable.manga].value }
ids.map { id -> firstUnreadChaptersByMangaId[id]?.let { chapters -> ChapterType(chapters.first()) } }
}
}
}
}
@@ -10,11 +10,13 @@ package suwayomi.tachidesk.graphql.dataLoaders
import com.expediagroup.graphql.dataloader.KotlinDataLoader
import org.dataloader.DataLoader
import org.dataloader.DataLoaderFactory
import org.dataloader.DataLoaderOptions
import org.jetbrains.exposed.sql.Slf4jSqlDebugLogger
import org.jetbrains.exposed.sql.addLogger
import org.jetbrains.exposed.sql.andWhere
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction
import suwayomi.tachidesk.graphql.cache.CustomCacheMap
import suwayomi.tachidesk.graphql.types.MangaNodeList
import suwayomi.tachidesk.graphql.types.MangaNodeList.Companion.toNodeList
import suwayomi.tachidesk.graphql.types.MangaType
@@ -95,18 +97,21 @@ class MangaForIdsDataLoader : KotlinDataLoader<List<Int>, MangaNodeList> {
override val dataLoaderName = "MangaForIdsDataLoader"
override fun getDataLoader(): DataLoader<List<Int>, MangaNodeList> =
DataLoaderFactory.newDataLoader { mangaIds ->
future {
transaction {
addLogger(Slf4jSqlDebugLogger)
val ids = mangaIds.flatten().distinct()
val manga =
MangaTable.select { MangaTable.id inList ids }
.map { MangaType(it) }
mangaIds.map { mangaIds ->
manga.filter { it.id in mangaIds }.toNodeList()
DataLoaderFactory.newDataLoader(
{ mangaIds ->
future {
transaction {
addLogger(Slf4jSqlDebugLogger)
val ids = mangaIds.flatten().distinct()
val manga =
MangaTable.select { MangaTable.id inList ids }
.map { MangaType(it) }
mangaIds.map { mangaIds ->
manga.filter { it.id in mangaIds }.toNodeList()
}
}
}
}
}
},
DataLoaderOptions.newOptions().setCacheMap(CustomCacheMap<List<Int>, MangaNodeList>()),
)
}
@@ -1,5 +1,6 @@
package suwayomi.tachidesk.graphql.mutations
import graphql.execution.DataFetcherResult
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.SqlExpressionBuilder.inList
import org.jetbrains.exposed.sql.SqlExpressionBuilder.minus
@@ -12,6 +13,7 @@ import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.update
import suwayomi.tachidesk.graphql.asDataFetcherResult
import suwayomi.tachidesk.graphql.types.CategoryMetaType
import suwayomi.tachidesk.graphql.types.CategoryType
import suwayomi.tachidesk.graphql.types.MangaType
@@ -36,12 +38,14 @@ class CategoryMutation {
val meta: CategoryMetaType,
)
fun setCategoryMeta(input: SetCategoryMetaInput): SetCategoryMetaPayload {
val (clientMutationId, meta) = input
fun setCategoryMeta(input: SetCategoryMetaInput): DataFetcherResult<SetCategoryMetaPayload?> {
return asDataFetcherResult {
val (clientMutationId, meta) = input
Category.modifyMeta(meta.categoryId, meta.key, meta.value)
Category.modifyMeta(meta.categoryId, meta.key, meta.value)
return SetCategoryMetaPayload(clientMutationId, meta)
SetCategoryMetaPayload(clientMutationId, meta)
}
}
data class DeleteCategoryMetaInput(
@@ -56,30 +60,32 @@ class CategoryMutation {
val category: CategoryType,
)
fun deleteCategoryMeta(input: DeleteCategoryMetaInput): DeleteCategoryMetaPayload {
val (clientMutationId, categoryId, key) = input
fun deleteCategoryMeta(input: DeleteCategoryMetaInput): DataFetcherResult<DeleteCategoryMetaPayload?> {
return asDataFetcherResult {
val (clientMutationId, categoryId, key) = input
val (meta, category) =
transaction {
val meta =
CategoryMetaTable.select { (CategoryMetaTable.ref eq categoryId) and (CategoryMetaTable.key eq key) }
.firstOrNull()
val (meta, category) =
transaction {
val meta =
CategoryMetaTable.select { (CategoryMetaTable.ref eq categoryId) and (CategoryMetaTable.key eq key) }
.firstOrNull()
CategoryMetaTable.deleteWhere { (CategoryMetaTable.ref eq categoryId) and (CategoryMetaTable.key eq key) }
CategoryMetaTable.deleteWhere { (CategoryMetaTable.ref eq categoryId) and (CategoryMetaTable.key eq key) }
val category =
transaction {
CategoryType(CategoryTable.select { CategoryTable.id eq categoryId }.first())
}
val category =
transaction {
CategoryType(CategoryTable.select { CategoryTable.id eq categoryId }.first())
}
if (meta != null) {
CategoryMetaType(meta)
} else {
null
} to category
}
if (meta != null) {
CategoryMetaType(meta)
} else {
null
} to category
}
return DeleteCategoryMetaPayload(clientMutationId, meta, category)
DeleteCategoryMetaPayload(clientMutationId, meta, category)
}
}
data class UpdateCategoryPatch(
@@ -147,36 +153,40 @@ class CategoryMutation {
}
}
fun updateCategory(input: UpdateCategoryInput): UpdateCategoryPayload {
val (clientMutationId, id, patch) = input
fun updateCategory(input: UpdateCategoryInput): DataFetcherResult<UpdateCategoryPayload?> {
return asDataFetcherResult {
val (clientMutationId, id, patch) = input
updateCategories(listOf(id), patch)
updateCategories(listOf(id), patch)
val category =
transaction {
CategoryType(CategoryTable.select { CategoryTable.id eq id }.first())
}
val category =
transaction {
CategoryType(CategoryTable.select { CategoryTable.id eq id }.first())
}
return UpdateCategoryPayload(
clientMutationId = clientMutationId,
category = category,
)
UpdateCategoryPayload(
clientMutationId = clientMutationId,
category = category,
)
}
}
fun updateCategories(input: UpdateCategoriesInput): UpdateCategoriesPayload {
val (clientMutationId, ids, patch) = input
fun updateCategories(input: UpdateCategoriesInput): DataFetcherResult<UpdateCategoriesPayload?> {
return asDataFetcherResult {
val (clientMutationId, ids, patch) = input
updateCategories(ids, patch)
updateCategories(ids, patch)
val categories =
transaction {
CategoryTable.select { CategoryTable.id inList ids }.map { CategoryType(it) }
}
val categories =
transaction {
CategoryTable.select { CategoryTable.id inList ids }.map { CategoryType(it) }
}
return UpdateCategoriesPayload(
clientMutationId = clientMutationId,
categories = categories,
)
UpdateCategoriesPayload(
clientMutationId = clientMutationId,
categories = categories,
)
}
}
data class UpdateCategoryOrderPayload(
@@ -190,46 +200,48 @@ class CategoryMutation {
val position: Int,
)
fun updateCategoryOrder(input: UpdateCategoryOrderInput): UpdateCategoryOrderPayload {
val (clientMutationId, categoryId, position) = input
require(position > 0) {
"'order' must not be <= 0"
}
transaction {
val currentOrder =
CategoryTable
.select { CategoryTable.id eq categoryId }
.first()[CategoryTable.order]
if (currentOrder != position) {
if (position < currentOrder) {
CategoryTable.update({ CategoryTable.order greaterEq position }) {
it[CategoryTable.order] = CategoryTable.order + 1
}
} else {
CategoryTable.update({ CategoryTable.order lessEq position }) {
it[CategoryTable.order] = CategoryTable.order - 1
}
}
CategoryTable.update({ CategoryTable.id eq categoryId }) {
it[CategoryTable.order] = position
}
fun updateCategoryOrder(input: UpdateCategoryOrderInput): DataFetcherResult<UpdateCategoryOrderPayload?> {
return asDataFetcherResult {
val (clientMutationId, categoryId, position) = input
require(position > 0) {
"'order' must not be <= 0"
}
}
Category.normalizeCategories()
val categories =
transaction {
CategoryTable.selectAll().orderBy(CategoryTable.order).map { CategoryType(it) }
val currentOrder =
CategoryTable
.select { CategoryTable.id eq categoryId }
.first()[CategoryTable.order]
if (currentOrder != position) {
if (position < currentOrder) {
CategoryTable.update({ CategoryTable.order greaterEq position }) {
it[CategoryTable.order] = CategoryTable.order + 1
}
} else {
CategoryTable.update({ CategoryTable.order lessEq position }) {
it[CategoryTable.order] = CategoryTable.order - 1
}
}
CategoryTable.update({ CategoryTable.id eq categoryId }) {
it[CategoryTable.order] = position
}
}
}
return UpdateCategoryOrderPayload(
clientMutationId = clientMutationId,
categories = categories,
)
Category.normalizeCategories()
val categories =
transaction {
CategoryTable.selectAll().orderBy(CategoryTable.order).map { CategoryType(it) }
}
UpdateCategoryOrderPayload(
clientMutationId = clientMutationId,
categories = categories,
)
}
}
data class CreateCategoryInput(
@@ -246,51 +258,53 @@ class CategoryMutation {
val category: CategoryType,
)
fun createCategory(input: CreateCategoryInput): CreateCategoryPayload {
val (clientMutationId, name, order, default, includeInUpdate, includeInDownload) = input
transaction {
require(CategoryTable.select { CategoryTable.name eq input.name }.isEmpty()) {
"'name' must be unique"
}
}
require(!name.equals(Category.DEFAULT_CATEGORY_NAME, ignoreCase = true)) {
"'name' must not be ${Category.DEFAULT_CATEGORY_NAME}"
}
if (order != null) {
require(order > 0) {
"'order' must not be <= 0"
}
}
val category =
fun createCategory(input: CreateCategoryInput): DataFetcherResult<CreateCategoryPayload?> {
return asDataFetcherResult {
val (clientMutationId, name, order, default, includeInUpdate, includeInDownload) = input
transaction {
if (order != null) {
CategoryTable.update({ CategoryTable.order greaterEq order }) {
it[CategoryTable.order] = CategoryTable.order + 1
require(CategoryTable.select { CategoryTable.name eq input.name }.isEmpty()) {
"'name' must be unique"
}
}
require(!name.equals(Category.DEFAULT_CATEGORY_NAME, ignoreCase = true)) {
"'name' must not be ${Category.DEFAULT_CATEGORY_NAME}"
}
if (order != null) {
require(order > 0) {
"'order' must not be <= 0"
}
}
val category =
transaction {
if (order != null) {
CategoryTable.update({ CategoryTable.order greaterEq order }) {
it[CategoryTable.order] = CategoryTable.order + 1
}
}
val id =
CategoryTable.insertAndGetId {
it[CategoryTable.name] = input.name
it[CategoryTable.order] = order ?: Int.MAX_VALUE
if (default != null) {
it[CategoryTable.isDefault] = default
}
if (includeInUpdate != null) {
it[CategoryTable.includeInUpdate] = includeInUpdate.value
}
if (includeInDownload != null) {
it[CategoryTable.includeInDownload] = includeInDownload.value
}
}
Category.normalizeCategories()
CategoryType(CategoryTable.select { CategoryTable.id eq id }.first())
}
val id =
CategoryTable.insertAndGetId {
it[CategoryTable.name] = input.name
it[CategoryTable.order] = order ?: Int.MAX_VALUE
if (default != null) {
it[CategoryTable.isDefault] = default
}
if (includeInUpdate != null) {
it[CategoryTable.includeInUpdate] = includeInUpdate.value
}
if (includeInDownload != null) {
it[CategoryTable.includeInDownload] = includeInDownload.value
}
}
Category.normalizeCategories()
CategoryType(CategoryTable.select { CategoryTable.id eq id }.first())
}
return CreateCategoryPayload(clientMutationId, category)
CreateCategoryPayload(clientMutationId, category)
}
}
data class DeleteCategoryInput(
@@ -304,41 +318,43 @@ class CategoryMutation {
val mangas: List<MangaType>,
)
fun deleteCategory(input: DeleteCategoryInput): DeleteCategoryPayload {
val (clientMutationId, categoryId) = input
if (categoryId == 0) { // Don't delete default category
return DeleteCategoryPayload(
clientMutationId,
null,
emptyList(),
)
}
val (category, mangas) =
transaction {
val category =
CategoryTable.select { CategoryTable.id eq categoryId }
.firstOrNull()
val mangas =
transaction {
MangaTable.innerJoin(CategoryMangaTable)
.select { CategoryMangaTable.category eq categoryId }
.map { MangaType(it) }
}
CategoryTable.deleteWhere { CategoryTable.id eq categoryId }
Category.normalizeCategories()
if (category != null) {
CategoryType(category)
} else {
null
} to mangas
fun deleteCategory(input: DeleteCategoryInput): DataFetcherResult<DeleteCategoryPayload?> {
return asDataFetcherResult {
val (clientMutationId, categoryId) = input
if (categoryId == 0) { // Don't delete default category
return@asDataFetcherResult DeleteCategoryPayload(
clientMutationId,
null,
emptyList(),
)
}
return DeleteCategoryPayload(clientMutationId, category, mangas)
val (category, mangas) =
transaction {
val category =
CategoryTable.select { CategoryTable.id eq categoryId }
.firstOrNull()
val mangas =
transaction {
MangaTable.innerJoin(CategoryMangaTable)
.select { CategoryMangaTable.category eq categoryId }
.map { MangaType(it) }
}
CategoryTable.deleteWhere { CategoryTable.id eq categoryId }
Category.normalizeCategories()
if (category != null) {
CategoryType(category)
} else {
null
} to mangas
}
DeleteCategoryPayload(clientMutationId, category, mangas)
}
}
data class UpdateMangaCategoriesPatch(
@@ -406,35 +422,39 @@ class CategoryMutation {
}
}
fun updateMangaCategories(input: UpdateMangaCategoriesInput): UpdateMangaCategoriesPayload {
val (clientMutationId, id, patch) = input
fun updateMangaCategories(input: UpdateMangaCategoriesInput): DataFetcherResult<UpdateMangaCategoriesPayload?> {
return asDataFetcherResult {
val (clientMutationId, id, patch) = input
updateMangas(listOf(id), patch)
updateMangas(listOf(id), patch)
val manga =
transaction {
MangaType(MangaTable.select { MangaTable.id eq id }.first())
}
val manga =
transaction {
MangaType(MangaTable.select { MangaTable.id eq id }.first())
}
return UpdateMangaCategoriesPayload(
clientMutationId = clientMutationId,
manga = manga,
)
UpdateMangaCategoriesPayload(
clientMutationId = clientMutationId,
manga = manga,
)
}
}
fun updateMangasCategories(input: UpdateMangasCategoriesInput): UpdateMangasCategoriesPayload {
val (clientMutationId, ids, patch) = input
fun updateMangasCategories(input: UpdateMangasCategoriesInput): DataFetcherResult<UpdateMangasCategoriesPayload?> {
return asDataFetcherResult {
val (clientMutationId, ids, patch) = input
updateMangas(ids, patch)
updateMangas(ids, patch)
val mangas =
transaction {
MangaTable.select { MangaTable.id inList ids }.map { MangaType(it) }
}
val mangas =
transaction {
MangaTable.select { MangaTable.id inList ids }.map { MangaType(it) }
}
return UpdateMangasCategoriesPayload(
clientMutationId = clientMutationId,
mangas = mangas,
)
UpdateMangasCategoriesPayload(
clientMutationId = clientMutationId,
mangas = mangas,
)
}
}
}
@@ -1,16 +1,18 @@
package suwayomi.tachidesk.graphql.mutations
import graphql.execution.DataFetcherResult
import org.jetbrains.exposed.dao.id.EntityID
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.deleteWhere
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.statements.BatchUpdateStatement
import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.update
import suwayomi.tachidesk.graphql.asDataFetcherResult
import suwayomi.tachidesk.graphql.types.ChapterMetaType
import suwayomi.tachidesk.graphql.types.ChapterType
import suwayomi.tachidesk.manga.impl.Chapter
import suwayomi.tachidesk.manga.impl.chapter.getChapterDownloadReadyById
import suwayomi.tachidesk.manga.impl.track.Track
import suwayomi.tachidesk.manga.model.table.ChapterMetaTable
import suwayomi.tachidesk.manga.model.table.ChapterTable
import suwayomi.tachidesk.server.JavalinSetup.future
@@ -56,61 +58,73 @@ class ChapterMutation {
patch: UpdateChapterPatch,
) {
transaction {
val chapterIdToPageCount =
if (patch.lastPageRead != null) {
ChapterTable
.slice(ChapterTable.id, ChapterTable.pageCount)
.select { ChapterTable.id inList ids }
.groupBy { it[ChapterTable.id].value }
.mapValues { it.value.firstOrNull()?.let { it[ChapterTable.pageCount] } }
} else {
emptyMap()
}
if (patch.isRead != null || patch.isBookmarked != null || patch.lastPageRead != null) {
val now = Instant.now().epochSecond
ChapterTable.update({ ChapterTable.id inList ids }) { update ->
patch.isRead?.also {
update[isRead] = it
BatchUpdateStatement(ChapterTable).apply {
ids.forEach { chapterId ->
addBatch(EntityID(chapterId, ChapterTable))
patch.isRead?.also {
this[ChapterTable.isRead] = it
}
patch.isBookmarked?.also {
this[ChapterTable.isBookmarked] = it
}
patch.lastPageRead?.also {
this[ChapterTable.lastPageRead] = it.coerceAtMost(chapterIdToPageCount[chapterId] ?: 0).coerceAtLeast(0)
this[ChapterTable.lastReadAt] = now
}
}
patch.isBookmarked?.also {
update[isBookmarked] = it
}
patch.lastPageRead?.also {
update[lastPageRead] = it
update[lastReadAt] = now
}
}
if (patch.isRead == true) {
val mangaIds =
ChapterTable.slice(ChapterTable.manga).select { ChapterTable.id inList ids }
.map { it[ChapterTable.manga].value }
.toSet()
Track.asyncTrackChapter(mangaIds)
execute(this@transaction)
}
}
}
}
fun updateChapter(input: UpdateChapterInput): UpdateChapterPayload {
val (clientMutationId, id, patch) = input
fun updateChapter(input: UpdateChapterInput): DataFetcherResult<UpdateChapterPayload?> {
return asDataFetcherResult {
val (clientMutationId, id, patch) = input
updateChapters(listOf(id), patch)
updateChapters(listOf(id), patch)
val chapter =
transaction {
ChapterType(ChapterTable.select { ChapterTable.id eq id }.first())
}
val chapter =
transaction {
ChapterType(ChapterTable.select { ChapterTable.id eq id }.first())
}
return UpdateChapterPayload(
clientMutationId = clientMutationId,
chapter = chapter,
)
UpdateChapterPayload(
clientMutationId = clientMutationId,
chapter = chapter,
)
}
}
fun updateChapters(input: UpdateChaptersInput): UpdateChaptersPayload {
val (clientMutationId, ids, patch) = input
fun updateChapters(input: UpdateChaptersInput): DataFetcherResult<UpdateChaptersPayload?> {
return asDataFetcherResult {
val (clientMutationId, ids, patch) = input
updateChapters(ids, patch)
updateChapters(ids, patch)
val chapters =
transaction {
ChapterTable.select { ChapterTable.id inList ids }.map { ChapterType(it) }
}
val chapters =
transaction {
ChapterTable.select { ChapterTable.id inList ids }.map { ChapterType(it) }
}
return UpdateChaptersPayload(
clientMutationId = clientMutationId,
chapters = chapters,
)
UpdateChaptersPayload(
clientMutationId = clientMutationId,
chapters = chapters,
)
}
}
data class FetchChaptersInput(
@@ -123,23 +137,25 @@ class ChapterMutation {
val chapters: List<ChapterType>,
)
fun fetchChapters(input: FetchChaptersInput): CompletableFuture<FetchChaptersPayload> {
fun fetchChapters(input: FetchChaptersInput): CompletableFuture<DataFetcherResult<FetchChaptersPayload?>> {
val (clientMutationId, mangaId) = input
return future {
Chapter.fetchChapterList(mangaId)
}.thenApply {
val chapters =
transaction {
ChapterTable.select { ChapterTable.manga eq mangaId }
.orderBy(ChapterTable.sourceOrder)
.map { ChapterType(it) }
}
asDataFetcherResult {
Chapter.fetchChapterList(mangaId)
FetchChaptersPayload(
clientMutationId = clientMutationId,
chapters = chapters,
)
val chapters =
transaction {
ChapterTable.select { ChapterTable.manga eq mangaId }
.orderBy(ChapterTable.sourceOrder)
.map { ChapterType(it) }
}
FetchChaptersPayload(
clientMutationId = clientMutationId,
chapters = chapters,
)
}
}
}
@@ -153,12 +169,14 @@ class ChapterMutation {
val meta: ChapterMetaType,
)
fun setChapterMeta(input: SetChapterMetaInput): SetChapterMetaPayload {
val (clientMutationId, meta) = input
fun setChapterMeta(input: SetChapterMetaInput): DataFetcherResult<SetChapterMetaPayload?> {
return asDataFetcherResult {
val (clientMutationId, meta) = input
Chapter.modifyChapterMeta(meta.chapterId, meta.key, meta.value)
Chapter.modifyChapterMeta(meta.chapterId, meta.key, meta.value)
return SetChapterMetaPayload(clientMutationId, meta)
SetChapterMetaPayload(clientMutationId, meta)
}
}
data class DeleteChapterMetaInput(
@@ -173,30 +191,32 @@ class ChapterMutation {
val chapter: ChapterType,
)
fun deleteChapterMeta(input: DeleteChapterMetaInput): DeleteChapterMetaPayload {
val (clientMutationId, chapterId, key) = input
fun deleteChapterMeta(input: DeleteChapterMetaInput): DataFetcherResult<DeleteChapterMetaPayload?> {
return asDataFetcherResult {
val (clientMutationId, chapterId, key) = input
val (meta, chapter) =
transaction {
val meta =
ChapterMetaTable.select { (ChapterMetaTable.ref eq chapterId) and (ChapterMetaTable.key eq key) }
.firstOrNull()
val (meta, chapter) =
transaction {
val meta =
ChapterMetaTable.select { (ChapterMetaTable.ref eq chapterId) and (ChapterMetaTable.key eq key) }
.firstOrNull()
ChapterMetaTable.deleteWhere { (ChapterMetaTable.ref eq chapterId) and (ChapterMetaTable.key eq key) }
ChapterMetaTable.deleteWhere { (ChapterMetaTable.ref eq chapterId) and (ChapterMetaTable.key eq key) }
val chapter =
transaction {
ChapterType(ChapterTable.select { ChapterTable.id eq chapterId }.first())
}
val chapter =
transaction {
ChapterType(ChapterTable.select { ChapterTable.id eq chapterId }.first())
}
if (meta != null) {
ChapterMetaType(meta)
} else {
null
} to chapter
}
if (meta != null) {
ChapterMetaType(meta)
} else {
null
} to chapter
}
return DeleteChapterMetaPayload(clientMutationId, meta, chapter)
DeleteChapterMetaPayload(clientMutationId, meta, chapter)
}
}
data class FetchChapterPagesInput(
@@ -210,20 +230,22 @@ class ChapterMutation {
val chapter: ChapterType,
)
fun fetchChapterPages(input: FetchChapterPagesInput): CompletableFuture<FetchChapterPagesPayload> {
fun fetchChapterPages(input: FetchChapterPagesInput): CompletableFuture<DataFetcherResult<FetchChapterPagesPayload?>> {
val (clientMutationId, chapterId) = input
return future {
getChapterDownloadReadyById(chapterId)
}.thenApply { chapter ->
FetchChapterPagesPayload(
clientMutationId = clientMutationId,
pages =
List(chapter.pageCount) { index ->
"/api/v1/manga/${chapter.mangaId}/chapter/${chapter.index}/page/$index"
},
chapter = ChapterType(chapter),
)
asDataFetcherResult {
val chapter = getChapterDownloadReadyById(chapterId)
FetchChapterPagesPayload(
clientMutationId = clientMutationId,
pages =
List(chapter.pageCount) { index ->
"/api/v1/manga/${chapter.mangaId}/chapter/${chapter.index}/page/$index"
},
chapter = ChapterType(chapter),
)
}
}
}
}
@@ -1,9 +1,11 @@
package suwayomi.tachidesk.graphql.mutations
import graphql.execution.DataFetcherResult
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeout
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction
import suwayomi.tachidesk.graphql.asDataFetcherResult
import suwayomi.tachidesk.graphql.types.ChapterType
import suwayomi.tachidesk.graphql.types.DownloadStatus
import suwayomi.tachidesk.manga.impl.Chapter
@@ -25,19 +27,21 @@ class DownloadMutation {
val chapters: List<ChapterType>,
)
fun deleteDownloadedChapters(input: DeleteDownloadedChaptersInput): DeleteDownloadedChaptersPayload {
fun deleteDownloadedChapters(input: DeleteDownloadedChaptersInput): DataFetcherResult<DeleteDownloadedChaptersPayload?> {
val (clientMutationId, chapters) = input
Chapter.deleteChapters(chapters)
return asDataFetcherResult {
Chapter.deleteChapters(chapters)
return DeleteDownloadedChaptersPayload(
clientMutationId = clientMutationId,
chapters =
transaction {
ChapterTable.select { ChapterTable.id inList chapters }
.map { ChapterType(it) }
},
)
DeleteDownloadedChaptersPayload(
clientMutationId = clientMutationId,
chapters =
transaction {
ChapterTable.select { ChapterTable.id inList chapters }
.map { ChapterType(it) }
},
)
}
}
data class DeleteDownloadedChapterInput(
@@ -50,18 +54,20 @@ class DownloadMutation {
val chapters: ChapterType,
)
fun deleteDownloadedChapter(input: DeleteDownloadedChapterInput): DeleteDownloadedChapterPayload {
fun deleteDownloadedChapter(input: DeleteDownloadedChapterInput): DataFetcherResult<DeleteDownloadedChapterPayload?> {
val (clientMutationId, chapter) = input
Chapter.deleteChapters(listOf(chapter))
return asDataFetcherResult {
Chapter.deleteChapters(listOf(chapter))
return DeleteDownloadedChapterPayload(
clientMutationId = clientMutationId,
chapters =
transaction {
ChapterType(ChapterTable.select { ChapterTable.id eq chapter }.first())
},
)
DeleteDownloadedChapterPayload(
clientMutationId = clientMutationId,
chapters =
transaction {
ChapterType(ChapterTable.select { ChapterTable.id eq chapter }.first())
},
)
}
}
data class EnqueueChapterDownloadsInput(
@@ -74,19 +80,23 @@ class DownloadMutation {
val downloadStatus: DownloadStatus,
)
fun enqueueChapterDownloads(input: EnqueueChapterDownloadsInput): CompletableFuture<EnqueueChapterDownloadsPayload> {
fun enqueueChapterDownloads(
input: EnqueueChapterDownloadsInput,
): CompletableFuture<DataFetcherResult<EnqueueChapterDownloadsPayload?>> {
val (clientMutationId, chapters) = input
DownloadManager.enqueue(DownloadManager.EnqueueInput(chapters))
return future {
EnqueueChapterDownloadsPayload(
clientMutationId = clientMutationId,
downloadStatus =
withTimeout(30.seconds) {
DownloadStatus(DownloadManager.status.first { it.queue.any { it.chapter.id in chapters } })
},
)
asDataFetcherResult {
DownloadManager.enqueue(DownloadManager.EnqueueInput(chapters))
EnqueueChapterDownloadsPayload(
clientMutationId = clientMutationId,
downloadStatus =
withTimeout(30.seconds) {
DownloadStatus(DownloadManager.status.first { it.queue.any { it.chapter.id in chapters } })
},
)
}
}
}
@@ -100,19 +110,21 @@ class DownloadMutation {
val downloadStatus: DownloadStatus,
)
fun enqueueChapterDownload(input: EnqueueChapterDownloadInput): CompletableFuture<EnqueueChapterDownloadPayload> {
fun enqueueChapterDownload(input: EnqueueChapterDownloadInput): CompletableFuture<DataFetcherResult<EnqueueChapterDownloadPayload?>> {
val (clientMutationId, chapter) = input
DownloadManager.enqueue(DownloadManager.EnqueueInput(listOf(chapter)))
return future {
EnqueueChapterDownloadPayload(
clientMutationId = clientMutationId,
downloadStatus =
withTimeout(30.seconds) {
DownloadStatus(DownloadManager.status.first { it.queue.any { it.chapter.id == chapter } })
},
)
asDataFetcherResult {
DownloadManager.enqueue(DownloadManager.EnqueueInput(listOf(chapter)))
EnqueueChapterDownloadPayload(
clientMutationId = clientMutationId,
downloadStatus =
withTimeout(30.seconds) {
DownloadStatus(DownloadManager.status.first { it.queue.any { it.chapter.id == chapter } })
},
)
}
}
}
@@ -126,19 +138,23 @@ class DownloadMutation {
val downloadStatus: DownloadStatus,
)
fun dequeueChapterDownloads(input: DequeueChapterDownloadsInput): CompletableFuture<DequeueChapterDownloadsPayload> {
fun dequeueChapterDownloads(
input: DequeueChapterDownloadsInput,
): CompletableFuture<DataFetcherResult<DequeueChapterDownloadsPayload?>> {
val (clientMutationId, chapters) = input
DownloadManager.dequeue(DownloadManager.EnqueueInput(chapters))
return future {
DequeueChapterDownloadsPayload(
clientMutationId = clientMutationId,
downloadStatus =
withTimeout(30.seconds) {
DownloadStatus(DownloadManager.status.first { it.queue.none { it.chapter.id in chapters } })
},
)
asDataFetcherResult {
DownloadManager.dequeue(DownloadManager.EnqueueInput(chapters))
DequeueChapterDownloadsPayload(
clientMutationId = clientMutationId,
downloadStatus =
withTimeout(30.seconds) {
DownloadStatus(DownloadManager.status.first { it.queue.none { it.chapter.id in chapters } })
},
)
}
}
}
@@ -152,19 +168,21 @@ class DownloadMutation {
val downloadStatus: DownloadStatus,
)
fun dequeueChapterDownload(input: DequeueChapterDownloadInput): CompletableFuture<DequeueChapterDownloadPayload> {
fun dequeueChapterDownload(input: DequeueChapterDownloadInput): CompletableFuture<DataFetcherResult<DequeueChapterDownloadPayload?>> {
val (clientMutationId, chapter) = input
DownloadManager.dequeue(DownloadManager.EnqueueInput(listOf(chapter)))
return future {
DequeueChapterDownloadPayload(
clientMutationId = clientMutationId,
downloadStatus =
withTimeout(30.seconds) {
DownloadStatus(DownloadManager.status.first { it.queue.none { it.chapter.id == chapter } })
},
)
asDataFetcherResult {
DownloadManager.dequeue(DownloadManager.EnqueueInput(listOf(chapter)))
DequeueChapterDownloadPayload(
clientMutationId = clientMutationId,
downloadStatus =
withTimeout(30.seconds) {
DownloadStatus(DownloadManager.status.first { it.queue.none { it.chapter.id == chapter } })
},
)
}
}
}
@@ -177,19 +195,21 @@ class DownloadMutation {
val downloadStatus: DownloadStatus,
)
fun startDownloader(input: StartDownloaderInput): CompletableFuture<StartDownloaderPayload> {
DownloadManager.start()
fun startDownloader(input: StartDownloaderInput): CompletableFuture<DataFetcherResult<StartDownloaderPayload?>> {
return future {
StartDownloaderPayload(
input.clientMutationId,
downloadStatus =
withTimeout(30.seconds) {
DownloadStatus(
DownloadManager.status.first { it.status == Status.Started },
)
},
)
asDataFetcherResult {
DownloadManager.start()
StartDownloaderPayload(
input.clientMutationId,
downloadStatus =
withTimeout(30.seconds) {
DownloadStatus(
DownloadManager.status.first { it.status == Status.Started },
)
},
)
}
}
}
@@ -202,18 +222,21 @@ class DownloadMutation {
val downloadStatus: DownloadStatus,
)
fun stopDownloader(input: StopDownloaderInput): CompletableFuture<StopDownloaderPayload> {
fun stopDownloader(input: StopDownloaderInput): CompletableFuture<DataFetcherResult<StopDownloaderPayload?>> {
return future {
DownloadManager.stop()
StopDownloaderPayload(
input.clientMutationId,
downloadStatus =
withTimeout(30.seconds) {
DownloadStatus(
DownloadManager.status.first { it.status == Status.Stopped },
)
},
)
asDataFetcherResult {
DownloadManager.stop()
StopDownloaderPayload(
input.clientMutationId,
downloadStatus =
withTimeout(30.seconds) {
DownloadStatus(
DownloadManager.status.first { it.status == Status.Stopped },
)
},
)
}
}
}
@@ -226,18 +249,21 @@ class DownloadMutation {
val downloadStatus: DownloadStatus,
)
fun clearDownloader(input: ClearDownloaderInput): CompletableFuture<ClearDownloaderPayload> {
fun clearDownloader(input: ClearDownloaderInput): CompletableFuture<DataFetcherResult<ClearDownloaderPayload?>> {
return future {
DownloadManager.clear()
ClearDownloaderPayload(
input.clientMutationId,
downloadStatus =
withTimeout(30.seconds) {
DownloadStatus(
DownloadManager.status.first { it.status == Status.Stopped && it.queue.isEmpty() },
)
},
)
asDataFetcherResult {
DownloadManager.clear()
ClearDownloaderPayload(
input.clientMutationId,
downloadStatus =
withTimeout(30.seconds) {
DownloadStatus(
DownloadManager.status.first { it.status == Status.Stopped && it.queue.isEmpty() },
)
},
)
}
}
}
@@ -252,20 +278,23 @@ class DownloadMutation {
val downloadStatus: DownloadStatus,
)
fun reorderChapterDownload(input: ReorderChapterDownloadInput): CompletableFuture<ReorderChapterDownloadPayload> {
fun reorderChapterDownload(input: ReorderChapterDownloadInput): CompletableFuture<DataFetcherResult<ReorderChapterDownloadPayload?>> {
val (clientMutationId, chapter, to) = input
DownloadManager.reorder(chapter, to)
return future {
ReorderChapterDownloadPayload(
clientMutationId,
downloadStatus =
withTimeout(30.seconds) {
DownloadStatus(
DownloadManager.status.first { it.queue.indexOfFirst { it.chapter.id == chapter } <= to },
)
},
)
asDataFetcherResult {
DownloadManager.reorder(chapter, to)
ReorderChapterDownloadPayload(
clientMutationId,
downloadStatus =
withTimeout(30.seconds) {
DownloadStatus(
DownloadManager.status.first { it.queue.indexOfFirst { it.chapter.id == chapter } <= to },
)
},
)
}
}
}
}
@@ -1,9 +1,11 @@
package suwayomi.tachidesk.graphql.mutations
import eu.kanade.tachiyomi.source.local.LocalSource
import graphql.execution.DataFetcherResult
import io.javalin.http.UploadedFile
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction
import suwayomi.tachidesk.graphql.asDataFetcherResult
import suwayomi.tachidesk.graphql.types.ExtensionType
import suwayomi.tachidesk.manga.impl.extension.Extension
import suwayomi.tachidesk.manga.impl.extension.ExtensionsList
@@ -69,41 +71,45 @@ class ExtensionMutation {
}
}
fun updateExtension(input: UpdateExtensionInput): CompletableFuture<UpdateExtensionPayload> {
fun updateExtension(input: UpdateExtensionInput): CompletableFuture<DataFetcherResult<UpdateExtensionPayload?>> {
val (clientMutationId, id, patch) = input
return future {
updateExtensions(listOf(id), patch)
}.thenApply {
val extension =
transaction {
ExtensionTable.select { ExtensionTable.pkgName eq id }.firstOrNull()
?.let { ExtensionType(it) }
}
asDataFetcherResult {
updateExtensions(listOf(id), patch)
UpdateExtensionPayload(
clientMutationId = clientMutationId,
extension = extension,
)
val extension =
transaction {
ExtensionTable.select { ExtensionTable.pkgName eq id }.firstOrNull()
?.let { ExtensionType(it) }
}
UpdateExtensionPayload(
clientMutationId = clientMutationId,
extension = extension,
)
}
}
}
fun updateExtensions(input: UpdateExtensionsInput): CompletableFuture<UpdateExtensionsPayload> {
fun updateExtensions(input: UpdateExtensionsInput): CompletableFuture<DataFetcherResult<UpdateExtensionsPayload?>> {
val (clientMutationId, ids, patch) = input
return future {
updateExtensions(ids, patch)
}.thenApply {
val extensions =
transaction {
ExtensionTable.select { ExtensionTable.pkgName inList ids }
.map { ExtensionType(it) }
}
asDataFetcherResult {
updateExtensions(ids, patch)
UpdateExtensionsPayload(
clientMutationId = clientMutationId,
extensions = extensions,
)
val extensions =
transaction {
ExtensionTable.select { ExtensionTable.pkgName inList ids }
.map { ExtensionType(it) }
}
UpdateExtensionsPayload(
clientMutationId = clientMutationId,
extensions = extensions,
)
}
}
}
@@ -116,22 +122,24 @@ class ExtensionMutation {
val extensions: List<ExtensionType>,
)
fun fetchExtensions(input: FetchExtensionsInput): CompletableFuture<FetchExtensionsPayload> {
fun fetchExtensions(input: FetchExtensionsInput): CompletableFuture<DataFetcherResult<FetchExtensionsPayload?>> {
val (clientMutationId) = input
return future {
ExtensionsList.fetchExtensions()
}.thenApply {
val extensions =
transaction {
ExtensionTable.select { ExtensionTable.name neq LocalSource.EXTENSION_NAME }
.map { ExtensionType(it) }
}
asDataFetcherResult {
ExtensionsList.fetchExtensions()
FetchExtensionsPayload(
clientMutationId = clientMutationId,
extensions = extensions,
)
val extensions =
transaction {
ExtensionTable.select { ExtensionTable.name neq LocalSource.EXTENSION_NAME }
.map { ExtensionType(it) }
}
FetchExtensionsPayload(
clientMutationId = clientMutationId,
extensions = extensions,
)
}
}
}
@@ -145,18 +153,22 @@ class ExtensionMutation {
val extension: ExtensionType,
)
fun installExternalExtension(input: InstallExternalExtensionInput): CompletableFuture<InstallExternalExtensionPayload> {
fun installExternalExtension(
input: InstallExternalExtensionInput,
): CompletableFuture<DataFetcherResult<InstallExternalExtensionPayload?>> {
val (clientMutationId, extensionFile) = input
return future {
Extension.installExternalExtension(extensionFile.content, extensionFile.filename)
}.thenApply {
val dbExtension = transaction { ExtensionTable.select { ExtensionTable.apkName eq extensionFile.filename }.first() }
asDataFetcherResult {
Extension.installExternalExtension(extensionFile.content, extensionFile.filename)
InstallExternalExtensionPayload(
clientMutationId,
extension = ExtensionType(dbExtension),
)
val dbExtension = transaction { ExtensionTable.select { ExtensionTable.apkName eq extensionFile.filename }.first() }
InstallExternalExtensionPayload(
clientMutationId,
extension = ExtensionType(dbExtension),
)
}
}
}
}
@@ -1,7 +1,9 @@
package suwayomi.tachidesk.graphql.mutations
import graphql.execution.DataFetcherResult
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeout
import suwayomi.tachidesk.graphql.asDataFetcherResult
import suwayomi.tachidesk.graphql.types.UpdateState.DOWNLOADING
import suwayomi.tachidesk.graphql.types.UpdateState.ERROR
import suwayomi.tachidesk.graphql.types.UpdateState.IDLE
@@ -22,50 +24,54 @@ class InfoMutation {
val updateStatus: WebUIUpdateStatus,
)
fun updateWebUI(input: WebUIUpdateInput): CompletableFuture<WebUIUpdatePayload> {
fun updateWebUI(input: WebUIUpdateInput): CompletableFuture<DataFetcherResult<WebUIUpdatePayload?>> {
return future {
withTimeout(30.seconds) {
if (WebInterfaceManager.status.value.state === DOWNLOADING) {
return@withTimeout WebUIUpdatePayload(input.clientMutationId, WebInterfaceManager.status.value)
}
asDataFetcherResult {
withTimeout(30.seconds) {
if (WebInterfaceManager.status.value.state === DOWNLOADING) {
return@withTimeout WebUIUpdatePayload(input.clientMutationId, WebInterfaceManager.status.value)
}
val flavor = WebUIFlavor.current
val flavor = WebUIFlavor.current
val (version, updateAvailable) = WebInterfaceManager.isUpdateAvailable(flavor)
val (version, updateAvailable) = WebInterfaceManager.isUpdateAvailable(flavor)
if (!updateAvailable) {
val didUpdateCheckFail = version.isEmpty()
if (!updateAvailable) {
val didUpdateCheckFail = version.isEmpty()
return@withTimeout WebUIUpdatePayload(
return@withTimeout WebUIUpdatePayload(
input.clientMutationId,
WebInterfaceManager.getStatus(version, if (didUpdateCheckFail) ERROR else IDLE),
)
}
try {
WebInterfaceManager.startDownloadInScope(flavor, version)
} catch (e: Exception) {
// ignore since we use the status anyway
}
WebUIUpdatePayload(
input.clientMutationId,
WebInterfaceManager.getStatus(version, if (didUpdateCheckFail) ERROR else IDLE),
updateStatus = WebInterfaceManager.status.first { it.state == DOWNLOADING },
)
}
try {
WebInterfaceManager.startDownloadInScope(flavor, version)
} catch (e: Exception) {
// ignore since we use the status anyway
}
WebUIUpdatePayload(
input.clientMutationId,
updateStatus = WebInterfaceManager.status.first { it.state == DOWNLOADING },
)
}
}
}
fun resetWebUIUpdateStatus(): CompletableFuture<WebUIUpdateStatus> {
fun resetWebUIUpdateStatus(): CompletableFuture<DataFetcherResult<WebUIUpdateStatus?>> {
return future {
withTimeout(30.seconds) {
val isUpdateFinished = WebInterfaceManager.status.value.state != DOWNLOADING
if (!isUpdateFinished) {
throw Exception("Status reset is not allowed during status \"$DOWNLOADING\"")
asDataFetcherResult {
withTimeout(30.seconds) {
val isUpdateFinished = WebInterfaceManager.status.value.state != DOWNLOADING
if (!isUpdateFinished) {
throw Exception("Status reset is not allowed during status \"$DOWNLOADING\"")
}
WebInterfaceManager.resetStatus()
WebInterfaceManager.status.first { it.state == IDLE }
}
WebInterfaceManager.resetStatus()
WebInterfaceManager.status.first { it.state == IDLE }
}
}
}
@@ -1,11 +1,13 @@
package suwayomi.tachidesk.graphql.mutations
import graphql.execution.DataFetcherResult
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.deleteWhere
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.update
import suwayomi.tachidesk.graphql.asDataFetcherResult
import suwayomi.tachidesk.graphql.types.MangaMetaType
import suwayomi.tachidesk.graphql.types.MangaType
import suwayomi.tachidesk.manga.impl.Library
@@ -13,6 +15,7 @@ import suwayomi.tachidesk.manga.impl.Manga
import suwayomi.tachidesk.manga.model.table.MangaMetaTable
import suwayomi.tachidesk.manga.model.table.MangaTable
import suwayomi.tachidesk.server.JavalinSetup.future
import java.time.Instant
import java.util.concurrent.CompletableFuture
/**
@@ -56,6 +59,7 @@ class MangaMutation {
MangaTable.update({ MangaTable.id inList ids }) { update ->
patch.inLibrary.also {
update[inLibrary] = it
if (it) update[inLibraryAt] = Instant.now().epochSecond
}
}
}
@@ -68,39 +72,43 @@ class MangaMutation {
}
}
fun updateManga(input: UpdateMangaInput): CompletableFuture<UpdateMangaPayload> {
fun updateManga(input: UpdateMangaInput): CompletableFuture<DataFetcherResult<UpdateMangaPayload?>> {
val (clientMutationId, id, patch) = input
return future {
updateMangas(listOf(id), patch)
}.thenApply {
val manga =
transaction {
MangaType(MangaTable.select { MangaTable.id eq id }.first())
}
asDataFetcherResult {
updateMangas(listOf(id), patch)
UpdateMangaPayload(
clientMutationId = clientMutationId,
manga = manga,
)
val manga =
transaction {
MangaType(MangaTable.select { MangaTable.id eq id }.first())
}
UpdateMangaPayload(
clientMutationId = clientMutationId,
manga = manga,
)
}
}
}
fun updateMangas(input: UpdateMangasInput): CompletableFuture<UpdateMangasPayload> {
fun updateMangas(input: UpdateMangasInput): CompletableFuture<DataFetcherResult<UpdateMangasPayload?>> {
val (clientMutationId, ids, patch) = input
return future {
updateMangas(ids, patch)
}.thenApply {
val mangas =
transaction {
MangaTable.select { MangaTable.id inList ids }.map { MangaType(it) }
}
asDataFetcherResult {
updateMangas(ids, patch)
UpdateMangasPayload(
clientMutationId = clientMutationId,
mangas = mangas,
)
val mangas =
transaction {
MangaTable.select { MangaTable.id inList ids }.map { MangaType(it) }
}
UpdateMangasPayload(
clientMutationId = clientMutationId,
mangas = mangas,
)
}
}
}
@@ -114,20 +122,22 @@ class MangaMutation {
val manga: MangaType,
)
fun fetchManga(input: FetchMangaInput): CompletableFuture<FetchMangaPayload> {
fun fetchManga(input: FetchMangaInput): CompletableFuture<DataFetcherResult<FetchMangaPayload?>> {
val (clientMutationId, id) = input
return future {
Manga.fetchManga(id)
}.thenApply {
val manga =
transaction {
MangaTable.select { MangaTable.id eq id }.first()
}
FetchMangaPayload(
clientMutationId = clientMutationId,
manga = MangaType(manga),
)
asDataFetcherResult {
Manga.fetchManga(id)
val manga =
transaction {
MangaTable.select { MangaTable.id eq id }.first()
}
FetchMangaPayload(
clientMutationId = clientMutationId,
manga = MangaType(manga),
)
}
}
}
@@ -141,12 +151,14 @@ class MangaMutation {
val meta: MangaMetaType,
)
fun setMangaMeta(input: SetMangaMetaInput): SetMangaMetaPayload {
fun setMangaMeta(input: SetMangaMetaInput): DataFetcherResult<SetMangaMetaPayload?> {
val (clientMutationId, meta) = input
Manga.modifyMangaMeta(meta.mangaId, meta.key, meta.value)
return asDataFetcherResult {
Manga.modifyMangaMeta(meta.mangaId, meta.key, meta.value)
return SetMangaMetaPayload(clientMutationId, meta)
SetMangaMetaPayload(clientMutationId, meta)
}
}
data class DeleteMangaMetaInput(
@@ -161,29 +173,31 @@ class MangaMutation {
val manga: MangaType,
)
fun deleteMangaMeta(input: DeleteMangaMetaInput): DeleteMangaMetaPayload {
fun deleteMangaMeta(input: DeleteMangaMetaInput): DataFetcherResult<DeleteMangaMetaPayload?> {
val (clientMutationId, mangaId, key) = input
val (meta, manga) =
transaction {
val meta =
MangaMetaTable.select { (MangaMetaTable.ref eq mangaId) and (MangaMetaTable.key eq key) }
.firstOrNull()
return asDataFetcherResult {
val (meta, manga) =
transaction {
val meta =
MangaMetaTable.select { (MangaMetaTable.ref eq mangaId) and (MangaMetaTable.key eq key) }
.firstOrNull()
MangaMetaTable.deleteWhere { (MangaMetaTable.ref eq mangaId) and (MangaMetaTable.key eq key) }
MangaMetaTable.deleteWhere { (MangaMetaTable.ref eq mangaId) and (MangaMetaTable.key eq key) }
val manga =
transaction {
MangaType(MangaTable.select { MangaTable.id eq mangaId }.first())
}
val manga =
transaction {
MangaType(MangaTable.select { MangaTable.id eq mangaId }.first())
}
if (meta != null) {
MangaMetaType(meta)
} else {
null
} to manga
}
if (meta != null) {
MangaMetaType(meta)
} else {
null
} to manga
}
return DeleteMangaMetaPayload(clientMutationId, meta, manga)
DeleteMangaMetaPayload(clientMutationId, meta, manga)
}
}
}
@@ -1,11 +1,13 @@
package suwayomi.tachidesk.graphql.mutations
import graphql.execution.DataFetcherResult
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.deleteWhere
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction
import suwayomi.tachidesk.global.impl.GlobalMeta
import suwayomi.tachidesk.global.model.table.GlobalMetaTable
import suwayomi.tachidesk.graphql.asDataFetcherResult
import suwayomi.tachidesk.graphql.types.GlobalMetaType
class MetaMutation {
@@ -19,12 +21,14 @@ class MetaMutation {
val meta: GlobalMetaType,
)
fun setGlobalMeta(input: SetGlobalMetaInput): SetGlobalMetaPayload {
fun setGlobalMeta(input: SetGlobalMetaInput): DataFetcherResult<SetGlobalMetaPayload?> {
val (clientMutationId, meta) = input
GlobalMeta.modifyMeta(meta.key, meta.value)
return asDataFetcherResult {
GlobalMeta.modifyMeta(meta.key, meta.value)
return SetGlobalMetaPayload(clientMutationId, meta)
SetGlobalMetaPayload(clientMutationId, meta)
}
}
data class DeleteGlobalMetaInput(
@@ -37,24 +41,26 @@ class MetaMutation {
val meta: GlobalMetaType?,
)
fun deleteGlobalMeta(input: DeleteGlobalMetaInput): DeleteGlobalMetaPayload {
fun deleteGlobalMeta(input: DeleteGlobalMetaInput): DataFetcherResult<DeleteGlobalMetaPayload?> {
val (clientMutationId, key) = input
val meta =
transaction {
val meta =
GlobalMetaTable.select { GlobalMetaTable.key eq key }
.firstOrNull()
return asDataFetcherResult {
val meta =
transaction {
val meta =
GlobalMetaTable.select { GlobalMetaTable.key eq key }
.firstOrNull()
GlobalMetaTable.deleteWhere { GlobalMetaTable.key eq key }
GlobalMetaTable.deleteWhere { GlobalMetaTable.key eq key }
if (meta != null) {
GlobalMetaType(meta)
} else {
null
if (meta != null) {
GlobalMetaType(meta)
} else {
null
}
}
}
return DeleteGlobalMetaPayload(clientMutationId, meta)
DeleteGlobalMetaPayload(clientMutationId, meta)
}
}
}
@@ -58,6 +58,7 @@ class SettingsMutation {
updateSetting(settings.excludeEntryWithUnreadChapters, serverConfig.excludeEntryWithUnreadChapters)
updateSetting(settings.autoDownloadAheadLimit, serverConfig.autoDownloadNewChaptersLimit) // deprecated
updateSetting(settings.autoDownloadNewChaptersLimit, serverConfig.autoDownloadNewChaptersLimit)
updateSetting(settings.autoDownloadIgnoreReUploads, serverConfig.autoDownloadIgnoreReUploads)
// extension
updateSetting(settings.extensionRepos, serverConfig.extensionRepos)
@@ -5,11 +5,13 @@ import androidx.preference.EditTextPreference
import androidx.preference.ListPreference
import androidx.preference.MultiSelectListPreference
import androidx.preference.SwitchPreferenceCompat
import graphql.execution.DataFetcherResult
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.deleteWhere
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction
import suwayomi.tachidesk.graphql.asDataFetcherResult
import suwayomi.tachidesk.graphql.types.FilterChange
import suwayomi.tachidesk.graphql.types.MangaType
import suwayomi.tachidesk.graphql.types.Preference
@@ -17,7 +19,7 @@ import suwayomi.tachidesk.graphql.types.SourceMetaType
import suwayomi.tachidesk.graphql.types.SourceType
import suwayomi.tachidesk.graphql.types.preferenceOf
import suwayomi.tachidesk.graphql.types.updateFilterList
import suwayomi.tachidesk.manga.impl.MangaList.insertOrGet
import suwayomi.tachidesk.manga.impl.MangaList.insertOrUpdate
import suwayomi.tachidesk.manga.impl.Source
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource
import suwayomi.tachidesk.manga.model.table.MangaTable
@@ -37,12 +39,14 @@ class SourceMutation {
val meta: SourceMetaType,
)
fun setSourceMeta(input: SetSourceMetaInput): SetSourceMetaPayload {
fun setSourceMeta(input: SetSourceMetaInput): DataFetcherResult<SetSourceMetaPayload?> {
val (clientMutationId, meta) = input
Source.modifyMeta(meta.sourceId, meta.key, meta.value)
return asDataFetcherResult {
Source.modifyMeta(meta.sourceId, meta.key, meta.value)
return SetSourceMetaPayload(clientMutationId, meta)
SetSourceMetaPayload(clientMutationId, meta)
}
}
data class DeleteSourceMetaInput(
@@ -57,31 +61,33 @@ class SourceMutation {
val source: SourceType?,
)
fun deleteSourceMeta(input: DeleteSourceMetaInput): DeleteSourceMetaPayload {
fun deleteSourceMeta(input: DeleteSourceMetaInput): DataFetcherResult<DeleteSourceMetaPayload?> {
val (clientMutationId, sourceId, key) = input
val (meta, source) =
transaction {
val meta =
SourceMetaTable.select { (SourceMetaTable.ref eq sourceId) and (SourceMetaTable.key eq key) }
.firstOrNull()
return asDataFetcherResult {
val (meta, source) =
transaction {
val meta =
SourceMetaTable.select { (SourceMetaTable.ref eq sourceId) and (SourceMetaTable.key eq key) }
.firstOrNull()
SourceMetaTable.deleteWhere { (SourceMetaTable.ref eq sourceId) and (SourceMetaTable.key eq key) }
SourceMetaTable.deleteWhere { (SourceMetaTable.ref eq sourceId) and (SourceMetaTable.key eq key) }
val source =
transaction {
SourceTable.select { SourceTable.id eq sourceId }.firstOrNull()
?.let { SourceType(it) }
}
val source =
transaction {
SourceTable.select { SourceTable.id eq sourceId }.firstOrNull()
?.let { SourceType(it) }
}
if (meta != null) {
SourceMetaType(meta)
} else {
null
} to source
}
if (meta != null) {
SourceMetaType(meta)
} else {
null
} to source
}
return DeleteSourceMetaPayload(clientMutationId, meta, source)
DeleteSourceMetaPayload(clientMutationId, meta, source)
}
}
enum class FetchSourceMangaType {
@@ -105,44 +111,46 @@ class SourceMutation {
val hasNextPage: Boolean,
)
fun fetchSourceManga(input: FetchSourceMangaInput): CompletableFuture<FetchSourceMangaPayload> {
fun fetchSourceManga(input: FetchSourceMangaInput): CompletableFuture<DataFetcherResult<FetchSourceMangaPayload?>> {
val (clientMutationId, sourceId, type, page, query, filters) = input
return future {
val source = GetCatalogueSource.getCatalogueSourceOrNull(sourceId)!!
val mangasPage =
when (type) {
FetchSourceMangaType.SEARCH -> {
source.getSearchManga(
page = page,
query = query.orEmpty(),
filters = updateFilterList(source, filters),
)
asDataFetcherResult {
val source = GetCatalogueSource.getCatalogueSourceOrNull(sourceId)!!
val mangasPage =
when (type) {
FetchSourceMangaType.SEARCH -> {
source.getSearchManga(
page = page,
query = query.orEmpty(),
filters = updateFilterList(source, filters),
)
}
FetchSourceMangaType.POPULAR -> {
source.getPopularManga(page)
}
FetchSourceMangaType.LATEST -> {
if (!source.supportsLatest) throw Exception("Source does not support latest")
source.getLatestUpdates(page)
}
}
FetchSourceMangaType.POPULAR -> {
source.getPopularManga(page)
val mangaIds = mangasPage.insertOrUpdate(sourceId)
val mangas =
transaction {
MangaTable.select { MangaTable.id inList mangaIds }
.map { MangaType(it) }
}.sortedBy {
mangaIds.indexOf(it.id)
}
FetchSourceMangaType.LATEST -> {
if (!source.supportsLatest) throw Exception("Source does not support latest")
source.getLatestUpdates(page)
}
}
val mangaIds = mangasPage.insertOrGet(sourceId)
val mangas =
transaction {
MangaTable.select { MangaTable.id inList mangaIds }
.map { MangaType(it) }
}.sortedBy {
mangaIds.indexOf(it.id)
}
FetchSourceMangaPayload(
clientMutationId = clientMutationId,
mangas = mangas,
hasNextPage = mangasPage.hasNextPage,
)
FetchSourceMangaPayload(
clientMutationId = clientMutationId,
mangas = mangas,
hasNextPage = mangasPage.hasNextPage,
)
}
}
}
@@ -167,27 +175,29 @@ class SourceMutation {
val source: SourceType,
)
fun updateSourcePreference(input: UpdateSourcePreferenceInput): UpdateSourcePreferencePayload {
fun updateSourcePreference(input: UpdateSourcePreferenceInput): DataFetcherResult<UpdateSourcePreferencePayload?> {
val (clientMutationId, sourceId, change) = input
Source.setSourcePreference(sourceId, change.position, "") { preference ->
when (preference) {
is SwitchPreferenceCompat -> change.switchState
is CheckBoxPreference -> change.checkBoxState
is EditTextPreference -> change.editTextState
is ListPreference -> change.listState
is MultiSelectListPreference -> change.multiSelectState?.toSet()
else -> throw RuntimeException("sealed class cannot have more subtypes!")
} ?: throw Exception("Expected change to ${preference::class.simpleName}")
}
return asDataFetcherResult {
Source.setSourcePreference(sourceId, change.position, "") { preference ->
when (preference) {
is SwitchPreferenceCompat -> change.switchState
is CheckBoxPreference -> change.checkBoxState
is EditTextPreference -> change.editTextState
is ListPreference -> change.listState
is MultiSelectListPreference -> change.multiSelectState?.toSet()
else -> throw RuntimeException("sealed class cannot have more subtypes!")
} ?: throw Exception("Expected change to ${preference::class.simpleName}")
}
return UpdateSourcePreferencePayload(
clientMutationId = clientMutationId,
preferences = Source.getSourcePreferencesRaw(sourceId).map { preferenceOf(it) },
source =
transaction {
SourceType(SourceTable.select { SourceTable.id eq sourceId }.first())!!
},
)
UpdateSourcePreferencePayload(
clientMutationId = clientMutationId,
preferences = Source.getSourcePreferencesRaw(sourceId).map { preferenceOf(it) },
source =
transaction {
SourceType(SourceTable.select { SourceTable.id eq sourceId }.first())!!
},
)
}
}
}
@@ -1,8 +1,12 @@
package suwayomi.tachidesk.graphql.mutations
import com.expediagroup.graphql.generator.annotations.GraphQLDeprecated
import com.expediagroup.graphql.generator.annotations.GraphQLDescription
import graphql.execution.DataFetcherResult
import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction
import suwayomi.tachidesk.graphql.asDataFetcherResult
import suwayomi.tachidesk.graphql.types.TrackRecordType
import suwayomi.tachidesk.graphql.types.TrackerType
import suwayomi.tachidesk.manga.impl.track.Track
@@ -133,6 +137,93 @@ class TrackMutation {
}
}
data class FetchTrackInput(
val clientMutationId: String? = null,
val recordId: Int,
)
data class FetchTrackPayload(
val clientMutationId: String?,
val trackRecord: TrackRecordType,
)
fun fetchTrack(input: FetchTrackInput): CompletableFuture<FetchTrackPayload> {
val (clientMutationId, recordId) = input
return future {
Track.refresh(recordId)
val trackRecord =
transaction {
TrackRecordTable.select {
TrackRecordTable.id eq recordId
}.first()
}
FetchTrackPayload(
clientMutationId,
TrackRecordType(trackRecord),
)
}
}
data class UnbindTrackInput(
val clientMutationId: String? = null,
val recordId: Int,
@GraphQLDescription("This will only work if the tracker of the track record supports deleting tracks")
val deleteRemoteTrack: Boolean? = null,
)
data class UnbindTrackPayload(
val clientMutationId: String?,
val trackRecord: TrackRecordType?,
)
fun unbindTrack(input: UnbindTrackInput): CompletableFuture<UnbindTrackPayload> {
val (clientMutationId, recordId, deleteRemoteTrack) = input
return future {
Track.unbind(recordId, deleteRemoteTrack)
val trackRecord =
transaction {
TrackRecordTable.select {
TrackRecordTable.id eq recordId
}.firstOrNull()
}
UnbindTrackPayload(
clientMutationId,
trackRecord?.let { TrackRecordType(it) },
)
}
}
data class TrackProgressInput(
val clientMutationId: String? = null,
val mangaId: Int,
)
data class TrackProgressPayload(
val clientMutationId: String?,
val trackRecords: List<TrackRecordType>,
)
fun trackProgress(input: TrackProgressInput): CompletableFuture<DataFetcherResult<TrackProgressPayload?>> {
val (clientMutationId, mangaId) = input
return future {
asDataFetcherResult {
Track.trackChapter(mangaId)
val trackRecords =
transaction {
TrackRecordTable.select { TrackRecordTable.mangaId eq mangaId }
.toList()
}
TrackProgressPayload(
clientMutationId,
trackRecords.map { TrackRecordType(it) },
)
}
}
}
data class UpdateTrackInput(
val clientMutationId: String? = null,
val recordId: Int,
@@ -141,6 +232,7 @@ class TrackMutation {
val scoreString: String? = null,
val startDate: Long? = null,
val finishDate: Long? = null,
@GraphQLDeprecated("Replaced with \"unbindTrack\" mutation", replaceWith = ReplaceWith("unbindTrack"))
val unbind: Boolean? = null,
)
@@ -1,15 +1,22 @@
package suwayomi.tachidesk.graphql.mutations
import graphql.execution.DataFetcherResult
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeout
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.graphql.asDataFetcherResult
import suwayomi.tachidesk.graphql.types.UpdateStatus
import suwayomi.tachidesk.manga.impl.Category
import suwayomi.tachidesk.manga.impl.update.IUpdater
import suwayomi.tachidesk.manga.model.table.CategoryTable
import suwayomi.tachidesk.manga.model.table.toDataClass
import suwayomi.tachidesk.server.JavalinSetup.future
import java.util.concurrent.CompletableFuture
import kotlin.time.Duration.Companion.seconds
class UpdateMutation {
private val updater by DI.global.instance<IUpdater>()
@@ -23,14 +30,24 @@ class UpdateMutation {
val updateStatus: UpdateStatus,
)
fun updateLibraryManga(input: UpdateLibraryMangaInput): UpdateLibraryMangaPayload {
fun updateLibraryManga(input: UpdateLibraryMangaInput): CompletableFuture<DataFetcherResult<UpdateLibraryMangaPayload?>> {
updater.addCategoriesToUpdateQueue(
Category.getCategoryList(),
clear = true,
forceAll = false,
)
return UpdateLibraryMangaPayload(input.clientMutationId, UpdateStatus(updater.status.value))
return future {
asDataFetcherResult {
UpdateLibraryMangaPayload(
input.clientMutationId,
updateStatus =
withTimeout(30.seconds) {
UpdateStatus(updater.status.first())
},
)
}
}
}
data class UpdateCategoryMangaInput(
@@ -43,7 +60,7 @@ class UpdateMutation {
val updateStatus: UpdateStatus,
)
fun updateCategoryManga(input: UpdateCategoryMangaInput): UpdateCategoryMangaPayload {
fun updateCategoryManga(input: UpdateCategoryMangaInput): CompletableFuture<DataFetcherResult<UpdateCategoryMangaPayload?>> {
val categories =
transaction {
CategoryTable.select { CategoryTable.id inList input.categories }.map {
@@ -52,10 +69,17 @@ class UpdateMutation {
}
updater.addCategoriesToUpdateQueue(categories, clear = true, forceAll = true)
return UpdateCategoryMangaPayload(
clientMutationId = input.clientMutationId,
updateStatus = UpdateStatus(updater.status.value),
)
return future {
asDataFetcherResult {
UpdateCategoryMangaPayload(
input.clientMutationId,
updateStatus =
withTimeout(30.seconds) {
UpdateStatus(updater.status.first())
},
)
}
}
}
data class UpdateStopInput(
@@ -16,14 +16,20 @@ class BackupQuery {
val name: String,
)
data class ValidateBackupTracker(
val name: String,
)
data class ValidateBackupResult(
val missingSources: List<ValidateBackupSource>,
val missingTrackers: List<ValidateBackupTracker>,
)
fun validateBackup(input: ValidateBackupInput): ValidateBackupResult {
val result = ProtoBackupValidator.validate(input.backup.content)
return ValidateBackupResult(
result.missingSourceIds.map { ValidateBackupSource(it.first, it.second) },
result.missingTrackers.map { ValidateBackupTracker(it) },
)
}
@@ -28,6 +28,7 @@ import suwayomi.tachidesk.graphql.queries.filter.andFilterWithCompare
import suwayomi.tachidesk.graphql.queries.filter.andFilterWithCompareEntity
import suwayomi.tachidesk.graphql.queries.filter.andFilterWithCompareString
import suwayomi.tachidesk.graphql.queries.filter.applyOps
import suwayomi.tachidesk.graphql.queries.util.distinctOn
import suwayomi.tachidesk.graphql.server.primitives.Cursor
import suwayomi.tachidesk.graphql.server.primitives.OrderBy
import suwayomi.tachidesk.graphql.server.primitives.PageInfo
@@ -217,7 +218,12 @@ class MangaQuery {
): MangaNodeList {
val queryResults =
transaction {
val res = MangaTable.leftJoin(CategoryMangaTable).selectAll()
val res =
MangaTable.leftJoin(CategoryMangaTable).slice(
distinctOn(MangaTable.id),
*(MangaTable.columns).toTypedArray(),
*(CategoryMangaTable.columns).toTypedArray(),
).selectAll()
res.applyOps(condition, filter)
@@ -1,16 +1,19 @@
package suwayomi.tachidesk.graphql.queries
import kotlinx.coroutines.flow.first
import org.kodein.di.DI
import org.kodein.di.conf.global
import org.kodein.di.instance
import suwayomi.tachidesk.graphql.types.UpdateStatus
import suwayomi.tachidesk.manga.impl.update.IUpdater
import suwayomi.tachidesk.server.JavalinSetup.future
import java.util.concurrent.CompletableFuture
class UpdateQuery {
private val updater by DI.global.instance<IUpdater>()
fun updateStatus(): UpdateStatus {
return UpdateStatus(updater.status.value)
fun updateStatus(): CompletableFuture<UpdateStatus> {
return future { UpdateStatus(updater.status.first()) }
}
data class LastUpdateTimestampPayload(val timestamp: Long)
@@ -0,0 +1,31 @@
package suwayomi.tachidesk.graphql.queries.util
import org.jetbrains.exposed.sql.BooleanColumnType
import org.jetbrains.exposed.sql.CustomFunction
import org.jetbrains.exposed.sql.Expression
import org.jetbrains.exposed.sql.QueryBuilder
/**
* src: https://github.com/JetBrains/Exposed/issues/500#issuecomment-543574151 (2024-04-02 02:20)
*/
fun distinctOn(vararg expressions: Expression<*>): CustomFunction<Boolean?> =
customBooleanFunction(
functionName = "DISTINCT ON",
postfix = " TRUE",
params = expressions,
)
fun customBooleanFunction(
functionName: String,
postfix: String = "",
vararg params: Expression<*>,
): CustomFunction<Boolean?> =
object : CustomFunction<Boolean?>(functionName, BooleanColumnType(), *params) {
override fun toQueryBuilder(queryBuilder: QueryBuilder) {
super.toQueryBuilder(queryBuilder)
if (postfix.isNotEmpty()) {
queryBuilder.append(postfix)
}
}
}
@@ -8,6 +8,7 @@
package suwayomi.tachidesk.graphql.server
import com.expediagroup.graphql.dataloader.KotlinDataLoaderRegistryFactory
import suwayomi.tachidesk.graphql.dataLoaders.BookmarkedChapterCountForMangaDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.CategoriesForMangaDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.CategoryDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.CategoryForIdsDataLoader
@@ -19,6 +20,7 @@ import suwayomi.tachidesk.graphql.dataLoaders.DisplayScoreForTrackRecordDataLoad
import suwayomi.tachidesk.graphql.dataLoaders.DownloadedChapterCountForMangaDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.ExtensionDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.ExtensionForSourceDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.FirstUnreadChapterForMangaDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.GlobalMetaDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.LastReadChapterForMangaDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.LatestFetchedChapterForMangaDataLoader
@@ -50,10 +52,12 @@ class TachideskDataLoaderRegistryFactory {
ChaptersForMangaDataLoader(),
DownloadedChapterCountForMangaDataLoader(),
UnreadChapterCountForMangaDataLoader(),
BookmarkedChapterCountForMangaDataLoader(),
LastReadChapterForMangaDataLoader(),
LatestReadChapterForMangaDataLoader(),
LatestFetchedChapterForMangaDataLoader(),
LatestUploadedChapterForMangaDataLoader(),
FirstUnreadChapterForMangaDataLoader(),
GlobalMetaDataLoader(),
ChapterMetaDataLoader(),
MangaMetaDataLoader(),
@@ -12,6 +12,7 @@ import com.expediagroup.graphql.server.execution.GraphQLRequestHandler
import com.expediagroup.graphql.server.execution.GraphQLServer
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import graphql.GraphQL
import graphql.execution.AsyncExecutionStrategy
import io.javalin.http.Context
import io.javalin.websocket.WsCloseContext
import io.javalin.websocket.WsMessageContext
@@ -47,6 +48,7 @@ class TachideskGraphQLServer(
private fun getGraphQLObject(): GraphQL =
GraphQL.newGraphQL(schema)
.subscriptionExecutionStrategy(FlowSubscriptionExecutionStrategy())
.mutationExecutionStrategy(AsyncExecutionStrategy())
.build()
fun create(): TachideskGraphQLServer {
@@ -11,6 +11,7 @@ import com.expediagroup.graphql.server.extensions.getValueFromDataLoader
import eu.kanade.tachiyomi.source.model.UpdateStrategy
import graphql.schema.DataFetchingEnvironment
import org.jetbrains.exposed.sql.ResultRow
import suwayomi.tachidesk.graphql.cache.CustomCacheMap
import suwayomi.tachidesk.graphql.server.primitives.Cursor
import suwayomi.tachidesk.graphql.server.primitives.Edge
import suwayomi.tachidesk.graphql.server.primitives.Node
@@ -45,18 +46,33 @@ class MangaType(
var chaptersLastFetchedAt: Long?, // todo
) : Node {
companion object {
fun clearCacheFor(
mangaIds: List<Int>,
dataFetchingEnvironment: DataFetchingEnvironment,
) {
mangaIds.forEach { clearCacheFor(it, dataFetchingEnvironment) }
}
fun clearCacheFor(
mangaId: Int,
dataFetchingEnvironment: DataFetchingEnvironment,
) {
dataFetchingEnvironment.getDataLoader<Int, MangaType>("MangaDataLoader").clear(mangaId)
dataFetchingEnvironment.getDataLoader<Int, MangaNodeList>("MangaForIdsDataLoader").clear(mangaId)
val mangaForIdsDataLoader =
dataFetchingEnvironment.getDataLoader<List<Int>, MangaNodeList>("MangaForIdsDataLoader")
@Suppress("UNCHECKED_CAST")
(mangaForIdsDataLoader.cacheMap as CustomCacheMap<List<Int>, MangaNodeList>).getKeys()
.filter { it.contains(mangaId) }.forEach { mangaForIdsDataLoader.clear(it) }
dataFetchingEnvironment.getDataLoader<Int, Int>("DownloadedChapterCountForMangaDataLoader").clear(mangaId)
dataFetchingEnvironment.getDataLoader<Int, Int>("UnreadChapterCountForMangaDataLoader").clear(mangaId)
dataFetchingEnvironment.getDataLoader<Int, Int>("BookmarkedChapterCountForMangaDataLoader").clear(mangaId)
dataFetchingEnvironment.getDataLoader<Int, ChapterType>("LastReadChapterForMangaDataLoader").clear(mangaId)
dataFetchingEnvironment.getDataLoader<Int, ChapterType>("LatestReadChapterForMangaDataLoader").clear(mangaId)
dataFetchingEnvironment.getDataLoader<Int, ChapterType>("LatestFetchedChapterForMangaDataLoader").clear(mangaId)
dataFetchingEnvironment.getDataLoader<Int, ChapterType>("LatestUploadedChapterForMangaDataLoader").clear(mangaId)
dataFetchingEnvironment.getDataLoader<Int, ChapterType>("FirstUnreadChapterForMangaDataLoader").clear(mangaId)
dataFetchingEnvironment.getDataLoader<Int, ChapterNodeList>(
"ChaptersForMangaDataLoader",
).clear(mangaId)
@@ -115,6 +131,10 @@ class MangaType(
return dataFetchingEnvironment.getValueFromDataLoader("UnreadChapterCountForMangaDataLoader", id)
}
fun bookmarkCount(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<Int> {
return dataFetchingEnvironment.getValueFromDataLoader("BookmarkedChapterCountForMangaDataLoader", id)
}
fun lastReadChapter(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<ChapterType?> {
return dataFetchingEnvironment.getValueFromDataLoader("LastReadChapterForMangaDataLoader", id)
}
@@ -131,6 +151,10 @@ class MangaType(
return dataFetchingEnvironment.getValueFromDataLoader("LatestUploadedChapterForMangaDataLoader", id)
}
fun firstUnreadChapter(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<ChapterType?> {
return dataFetchingEnvironment.getValueFromDataLoader("FirstUnreadChapterForMangaDataLoader", id)
}
fun chapters(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<ChapterNodeList> {
return dataFetchingEnvironment.getValueFromDataLoader<Int, ChapterNodeList>("ChaptersForMangaDataLoader", id)
}
@@ -49,6 +49,7 @@ interface Settings : Node {
)
val autoDownloadAheadLimit: Int?
val autoDownloadNewChaptersLimit: Int?
val autoDownloadIgnoreReUploads: Boolean?
// extension
val extensionRepos: List<String>?
@@ -118,6 +119,7 @@ data class PartialSettingsType(
)
override val autoDownloadAheadLimit: Int?,
override val autoDownloadNewChaptersLimit: Int?,
override val autoDownloadIgnoreReUploads: Boolean?,
// extension
override val extensionRepos: List<String>?,
// requests
@@ -179,6 +181,7 @@ class SettingsType(
)
override val autoDownloadAheadLimit: Int,
override val autoDownloadNewChaptersLimit: Int,
override val autoDownloadIgnoreReUploads: Boolean?,
// extension
override val extensionRepos: List<String>,
// requests
@@ -235,6 +238,7 @@ class SettingsType(
config.excludeEntryWithUnreadChapters.value,
config.autoDownloadNewChaptersLimit.value, // deprecated
config.autoDownloadNewChaptersLimit.value,
config.autoDownloadIgnoreReUploads.value,
// extension
config.extensionRepos.value,
// requests
@@ -20,6 +20,7 @@ class TrackerType(
val icon: String,
val isLoggedIn: Boolean,
val authUrl: String?,
val supportsTrackDeletion: Boolean?,
) : Node {
constructor(tracker: Tracker) : this(
tracker.isLoggedIn,
@@ -36,6 +37,7 @@ class TrackerType(
} else {
tracker.authUrl()
},
tracker.supportsTrackDeletion,
)
fun statuses(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<List<TrackStatusType>> {
@@ -117,7 +117,7 @@ object UpdateController {
},
behaviorOf = { ctx ->
val updater by DI.global.instance<IUpdater>()
ctx.json(updater.status.value)
ctx.json(updater.statusDeprecated.value)
},
withResults = {
json<UpdateStatus>(HttpCode.OK)
@@ -20,7 +20,6 @@ import kotlinx.serialization.Serializable
import mu.KotlinLogging
import org.jetbrains.exposed.dao.id.EntityID
import org.jetbrains.exposed.sql.Op
import org.jetbrains.exposed.sql.ResultRow
import org.jetbrains.exposed.sql.SortOrder
import org.jetbrains.exposed.sql.SqlExpressionBuilder.inList
import org.jetbrains.exposed.sql.and
@@ -37,7 +36,6 @@ import suwayomi.tachidesk.manga.impl.download.DownloadManager.EnqueueInput
import suwayomi.tachidesk.manga.impl.track.Track
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrStub
import suwayomi.tachidesk.manga.model.dataclass.ChapterDataClass
import suwayomi.tachidesk.manga.model.dataclass.IncludeOrExclude
import suwayomi.tachidesk.manga.model.dataclass.MangaChapterDataClass
import suwayomi.tachidesk.manga.model.dataclass.PaginatedList
import suwayomi.tachidesk.manga.model.dataclass.paginatedFrom
@@ -52,6 +50,15 @@ import java.util.TreeSet
import java.util.concurrent.TimeUnit
import kotlin.math.max
private fun List<ChapterDataClass>.removeDuplicates(currentChapter: ChapterDataClass): List<ChapterDataClass> {
return groupBy { it.chapterNumber }
.map { (_, chapters) ->
chapters.find { it.id == currentChapter.id }
?: chapters.find { it.scanlator == currentChapter.scanlator }
?: chapters.first()
}
}
object Chapter {
private val logger = KotlinLogging.logger { }
@@ -136,6 +143,7 @@ object Chapter {
url = manga.url
}
val currentLatestChapterNumber = Manga.getLatestChapter(mangaId)?.chapterNumber ?: 0f
val numberOfCurrentChapters = getCountOfMangaChapters(mangaId)
val chapterList = source.getChapterList(sManga)
@@ -164,7 +172,10 @@ object Chapter {
.toList()
}
val chaptersToInsert = mutableListOf<ChapterDataClass>()
// new chapters after they have been added to the database for auto downloads
val insertedChapters = mutableListOf<ChapterDataClass>()
val chaptersToInsert = mutableListOf<ChapterDataClass>() // do not yet have an ID from the database
val chaptersToUpdate = mutableListOf<ChapterDataClass>()
chapterList.reversed().forEachIndexed { index, fetchedChapter ->
@@ -260,7 +271,7 @@ object Chapter {
this[ChapterTable.fetchedAt] = it
}
}
}
}.forEach { insertedChapters.add(ChapterTable.toDataClass(it)) }
}
if (chaptersToUpdate.isNotEmpty()) {
@@ -283,14 +294,8 @@ object Chapter {
}
}
val newChapters =
transaction {
ChapterTable.select { ChapterTable.manga eq mangaId }
.orderBy(ChapterTable.sourceOrder to SortOrder.DESC).toList()
}
if (manga.inLibrary) {
downloadNewChapters(mangaId, numberOfCurrentChapters, newChapters)
downloadNewChapters(mangaId, currentLatestChapterNumber, numberOfCurrentChapters, insertedChapters)
}
chapterList
@@ -301,16 +306,19 @@ object Chapter {
private fun downloadNewChapters(
mangaId: Int,
prevLatestChapterNumber: Float,
prevNumberOfChapters: Int,
updatedChapterList: List<ResultRow>,
newChapters: List<ChapterDataClass>,
) {
val log =
KotlinLogging.logger(
"${logger.name}::downloadNewChapters(" +
"mangaId= $mangaId, " +
"prevLatestChapterNumber= $prevLatestChapterNumber, " +
"prevNumberOfChapters= $prevNumberOfChapters, " +
"updatedChapterList= ${updatedChapterList.size}, " +
"autoDownloadNewChaptersLimit= ${serverConfig.autoDownloadNewChaptersLimit.value}" +
"newChapters= ${newChapters.size}, " +
"autoDownloadNewChaptersLimit= ${serverConfig.autoDownloadNewChaptersLimit.value}, " +
"autoDownloadIgnoreReUploads= ${serverConfig.autoDownloadIgnoreReUploads.value}" +
")",
)
@@ -319,68 +327,22 @@ object Chapter {
return
}
// Only download if there are new chapters, or if this is the first fetch
val newNumberOfChapters = updatedChapterList.size
val numberOfNewChapters = newNumberOfChapters - prevNumberOfChapters
val areNewChaptersAvailable = numberOfNewChapters > 0
val wasInitialFetch = prevNumberOfChapters == 0
if (!areNewChaptersAvailable) {
if (newChapters.isEmpty()) {
log.debug { "no new chapters available" }
return
}
val wasInitialFetch = prevNumberOfChapters == 0
if (wasInitialFetch) {
log.debug { "skipping download on initial fetch" }
return
}
// Verify the manga is configured to be downloaded based on it's categories.
var mangaCategories = CategoryManga.getMangaCategories(mangaId).toSet()
// if the manga has no categories, then it's implicitly in the default category
if (mangaCategories.isEmpty()) {
val defaultCategory = Category.getCategoryById(Category.DEFAULT_CATEGORY_ID)
if (defaultCategory != null) {
mangaCategories = setOf(defaultCategory)
} else {
log.warn { "missing default category" }
}
if (!Manga.isInIncludedDownloadCategory(log, mangaId)) {
return
}
if (mangaCategories.isNotEmpty()) {
val downloadCategoriesMap = Category.getCategoryList().groupBy { it.includeInDownload }
val unsetCategories = downloadCategoriesMap[IncludeOrExclude.UNSET].orEmpty()
// We only download if it's in the include list, and not in the exclude list.
// Use the unset categories as the included categories if the included categories is
// empty
val includedCategories = downloadCategoriesMap[IncludeOrExclude.INCLUDE].orEmpty().ifEmpty { unsetCategories }
val excludedCategories = downloadCategoriesMap[IncludeOrExclude.EXCLUDE].orEmpty()
// Only download manga that aren't in any excluded categories
val mangaExcludeCategories = mangaCategories.intersect(excludedCategories.toSet())
if (mangaExcludeCategories.isNotEmpty()) {
log.debug { "download excluded by categories: '${mangaExcludeCategories.joinToString("', '") { it.name }}'" }
return
}
val mangaDownloadCategories = mangaCategories.intersect(includedCategories.toSet())
if (mangaDownloadCategories.isNotEmpty()) {
log.debug { "download inluded by categories: '${mangaDownloadCategories.joinToString("', '") { it.name }}'" }
} else {
log.debug { "skipping download due to download categories configuration" }
return
}
} else {
log.debug { "no categories configured, skipping check for category download include/excludes" }
}
val newChapters = updatedChapterList.subList(0, numberOfNewChapters)
// make sure to only consider the latest chapters. e.g. old unread chapters should be ignored
val latestReadChapterIndex =
updatedChapterList.indexOfFirst { it[ChapterTable.isRead] }.takeIf { it > -1 } ?: (updatedChapterList.size)
val unreadChapters =
updatedChapterList.subList(numberOfNewChapters, latestReadChapterIndex)
.filter { !it[ChapterTable.isRead] }
val unreadChapters = Manga.getUnreadChapters(mangaId).subtract(newChapters.toSet())
val skipDueToUnreadChapters = serverConfig.excludeEntryWithUnreadChapters.value && unreadChapters.isNotEmpty()
if (skipDueToUnreadChapters) {
@@ -388,17 +350,7 @@ object Chapter {
return
}
val firstChapterToDownloadIndex =
if (serverConfig.autoDownloadNewChaptersLimit.value > 0) {
(numberOfNewChapters - serverConfig.autoDownloadNewChaptersLimit.value).coerceAtLeast(0)
} else {
0
}
val chapterIdsToDownload =
newChapters.subList(firstChapterToDownloadIndex, numberOfNewChapters)
.filter { !it[ChapterTable.isRead] && !it[ChapterTable.isDownloaded] }
.map { it[ChapterTable.id].value }
val chapterIdsToDownload = getNewChapterIdsToDownload(newChapters, prevLatestChapterNumber)
if (chapterIdsToDownload.isEmpty()) {
log.debug { "no chapters available for download" }
@@ -410,6 +362,37 @@ object Chapter {
DownloadManager.enqueue(EnqueueInput(chapterIdsToDownload))
}
private fun getNewChapterIdsToDownload(
newChapters: List<ChapterDataClass>,
prevLatestChapterNumber: Float,
): List<Int> {
val reUploadedChapters = newChapters.filter { it.chapterNumber < prevLatestChapterNumber }
val actualNewChapters = newChapters.subtract(reUploadedChapters.toSet()).toList()
val chaptersToConsiderForDownloadLimit =
if (serverConfig.autoDownloadIgnoreReUploads.value) {
if (actualNewChapters.isNotEmpty()) actualNewChapters.removeDuplicates(actualNewChapters[0]) else emptyList()
} else {
newChapters.removeDuplicates(newChapters[0])
}.sortedBy { it.index }
val latestChapterToDownloadIndex =
if (serverConfig.autoDownloadNewChaptersLimit.value == 0) {
chaptersToConsiderForDownloadLimit.size
} else {
serverConfig.autoDownloadNewChaptersLimit.value.coerceAtMost(chaptersToConsiderForDownloadLimit.size)
}
val limitedChaptersToDownload = chaptersToConsiderForDownloadLimit.subList(0, latestChapterToDownloadIndex)
val limitedChaptersToDownloadWithDuplicates =
(
limitedChaptersToDownload +
newChapters.filter { newChapter ->
limitedChaptersToDownload.find { it.chapterNumber == newChapter.chapterNumber } != null
}
).toSet()
return limitedChaptersToDownloadWithDuplicates.map { it.id }
}
fun modifyChapter(
mangaId: Int,
chapterIndex: Int,
@@ -8,13 +8,18 @@ package suwayomi.tachidesk.manga.impl
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.HttpException
import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.network.awaitSuccess
import eu.kanade.tachiyomi.source.local.LocalSource
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.model.UpdateStrategy
import eu.kanade.tachiyomi.source.online.HttpSource
import io.javalin.http.HttpCode
import mu.KLogger
import mu.KotlinLogging
import okhttp3.CacheControl
import okhttp3.Response
import org.jetbrains.exposed.sql.ResultRow
import org.jetbrains.exposed.sql.SortOrder
import org.jetbrains.exposed.sql.and
@@ -37,6 +42,8 @@ import suwayomi.tachidesk.manga.impl.util.storage.ImageResponse.clearCachedImage
import suwayomi.tachidesk.manga.impl.util.storage.ImageResponse.getImageResponse
import suwayomi.tachidesk.manga.impl.util.storage.ImageUtil
import suwayomi.tachidesk.manga.impl.util.updateMangaDownloadDir
import suwayomi.tachidesk.manga.model.dataclass.ChapterDataClass
import suwayomi.tachidesk.manga.model.dataclass.IncludeOrExclude
import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass
import suwayomi.tachidesk.manga.model.dataclass.toGenreList
import suwayomi.tachidesk.manga.model.table.ChapterTable
@@ -251,9 +258,55 @@ object Manga {
}
}
private suspend fun fetchThumbnailUrl(mangaId: Int): String? {
getManga(mangaId, true)
return transaction {
MangaTable.select { MangaTable.id eq mangaId }.first()
}[MangaTable.thumbnail_url]
}
private val applicationDirs by DI.global.instance<ApplicationDirs>()
private val network: NetworkHelper by injectLazy()
private suspend fun fetchHttpSourceMangaThumbnail(
source: HttpSource,
mangaEntry: ResultRow,
refreshUrl: Boolean = false,
): Response {
val mangaId = mangaEntry[MangaTable.id].value
val requiresInitialization = mangaEntry[MangaTable.thumbnail_url] == null && !mangaEntry[MangaTable.initialized]
val refreshThumbnailUrl = refreshUrl || requiresInitialization
val thumbnailUrl =
if (refreshThumbnailUrl) {
fetchThumbnailUrl(mangaId)
} else {
mangaEntry[MangaTable.thumbnail_url]
} ?: throw NullPointerException("No thumbnail found")
return try {
source.client.newCall(
GET(thumbnailUrl, source.headers, cache = CacheControl.FORCE_NETWORK),
).awaitSuccess()
} catch (e: HttpException) {
val tryToRefreshUrl =
!refreshUrl &&
listOf(
HttpCode.GONE.status,
HttpCode.MOVED_PERMANENTLY.status,
HttpCode.NOT_FOUND.status,
523, // (Cloudflare) Origin Is Unreachable
522, // (Cloudflare) Connection timed out
).contains(e.code)
if (!tryToRefreshUrl) {
throw e
}
fetchHttpSourceMangaThumbnail(source, mangaEntry, refreshUrl = true)
}
}
suspend fun fetchMangaThumbnail(mangaId: Int): Pair<InputStream, String> {
val cacheSaveDir = applicationDirs.tempThumbnailCacheRoot
val fileName = mangaId.toString()
@@ -264,22 +317,7 @@ object Manga {
return when (val source = getCatalogueSourceOrStub(sourceId)) {
is HttpSource ->
getImageResponse(cacheSaveDir, fileName) {
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("No thumbnail found")
}
source.client.newCall(
GET(thumbnailUrl, source.headers, cache = CacheControl.FORCE_NETWORK),
).await()
fetchHttpSourceMangaThumbnail(source, mangaEntry)
}
is LocalSource -> {
@@ -315,7 +353,7 @@ object Manga {
suspend fun getMangaThumbnail(mangaId: Int): Pair<InputStream, String> {
val mangaEntry = transaction { MangaTable.select { MangaTable.id eq mangaId }.first() }
if (mangaEntry[MangaTable.inLibrary]) {
if (mangaEntry[MangaTable.inLibrary] && mangaEntry[MangaTable.sourceReference] != LocalSource.ID) {
return try {
ThumbnailDownloadHelper.getImage(mangaId)
} catch (_: MissingThumbnailException) {
@@ -333,4 +371,56 @@ object Manga {
clearCachedImage(applicationDirs.tempThumbnailCacheRoot, fileName)
clearCachedImage(applicationDirs.thumbnailDownloadsRoot, fileName)
}
fun getLatestChapter(mangaId: Int): ChapterDataClass? {
return transaction {
ChapterTable.select { ChapterTable.manga eq mangaId }.maxByOrNull { it[ChapterTable.sourceOrder] }
}?.let { ChapterTable.toDataClass(it) }
}
fun getUnreadChapters(mangaId: Int): List<ChapterDataClass> {
return transaction {
ChapterTable.select { (ChapterTable.manga eq mangaId) and (ChapterTable.isRead eq false) }
.orderBy(ChapterTable.sourceOrder to SortOrder.DESC)
.map { ChapterTable.toDataClass(it) }
}
}
fun isInIncludedDownloadCategory(
logContext: KLogger = logger,
mangaId: Int,
): Boolean {
val log = KotlinLogging.logger("${logContext.name}::isInExcludedDownloadCategory($mangaId)")
// Verify the manga is configured to be downloaded based on it's categories.
var mangaCategories = CategoryManga.getMangaCategories(mangaId).toSet()
// if the manga has no categories, then it's implicitly in the default category
if (mangaCategories.isEmpty()) {
val defaultCategory = Category.getCategoryById(Category.DEFAULT_CATEGORY_ID)!!
mangaCategories = setOf(defaultCategory)
}
val downloadCategoriesMap = Category.getCategoryList().groupBy { it.includeInDownload }
val unsetCategories = downloadCategoriesMap[IncludeOrExclude.UNSET].orEmpty()
// We only download if it's in the include list, and not in the exclude list.
// Use the unset categories as the included categories if the included categories is
// empty
val includedCategories = downloadCategoriesMap[IncludeOrExclude.INCLUDE].orEmpty().ifEmpty { unsetCategories }
val excludedCategories = downloadCategoriesMap[IncludeOrExclude.EXCLUDE].orEmpty()
// Only download manga that aren't in any excluded categories
val mangaExcludeCategories = mangaCategories.intersect(excludedCategories.toSet())
if (mangaExcludeCategories.isNotEmpty()) {
log.debug { "download excluded by categories: '${mangaExcludeCategories.joinToString("', '") { it.name }}'" }
return false
}
val mangaDownloadCategories = mangaCategories.intersect(includedCategories.toSet())
if (mangaDownloadCategories.isNotEmpty()) {
log.debug { "download inluded by categories: '${mangaDownloadCategories.joinToString("', '") { it.name }}'" }
} else {
log.debug { "skipping download due to download categories configuration" }
return false
}
return true
}
}
@@ -8,14 +8,17 @@ 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.dao.id.EntityID
import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.batchInsert
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.statements.BatchUpdateStatement
import org.jetbrains.exposed.sql.transactions.transaction
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrStub
import suwayomi.tachidesk.manga.model.dataclass.PagedMangaListDataClass
import suwayomi.tachidesk.manga.model.table.MangaTable
import suwayomi.tachidesk.manga.model.table.toDataClass
import java.time.Instant
object MangaList {
fun proxyThumbnailUrl(mangaId: Int): String {
@@ -44,12 +47,12 @@ object MangaList {
return mangasPage.processEntries(sourceId)
}
fun MangasPage.insertOrGet(sourceId: Long): List<Int> {
fun MangasPage.insertOrUpdate(sourceId: Long): List<Int> {
return transaction {
val existingMangaUrlsToId =
MangaTable.slice(MangaTable.url, MangaTable.id).select {
MangaTable.select {
(MangaTable.sourceReference eq sourceId) and (MangaTable.url inList mangas.map { it.url })
}.associate { Pair(it[MangaTable.url], it[MangaTable.id].value) }
}.associateBy { it[MangaTable.url] }
val existingMangaUrls = existingMangaUrlsToId.map { it.key }
val mangasToInsert = mangas.filter { !existingMangaUrls.contains(it.url) }
@@ -73,7 +76,40 @@ object MangaList {
// delete thumbnail in case cached data still exists
insertedMangaUrlsToId.forEach { (_, id) -> Manga.clearThumbnail(id) }
val mangaUrlsToId = existingMangaUrlsToId + insertedMangaUrlsToId
val mangaToUpdate =
mangas.mapNotNull { sManga ->
existingMangaUrlsToId[sManga.url]?.let { sManga to it }
}.filterNot { (_, resultRow) ->
resultRow[MangaTable.inLibrary]
}
if (mangaToUpdate.isNotEmpty()) {
BatchUpdateStatement(MangaTable).apply {
mangaToUpdate.forEach { (sManga, manga) ->
addBatch(EntityID(manga[MangaTable.id].value, MangaTable))
this[MangaTable.title] = sManga.title
this[MangaTable.artist] = sManga.artist ?: manga[MangaTable.artist]
this[MangaTable.author] = sManga.author ?: manga[MangaTable.author]
this[MangaTable.description] = sManga.description ?: manga[MangaTable.description]
this[MangaTable.genre] = sManga.genre ?: manga[MangaTable.genre]
this[MangaTable.status] = sManga.status
this[MangaTable.thumbnail_url] = sManga.thumbnail_url ?: manga[MangaTable.thumbnail_url]
this[MangaTable.updateStrategy] = sManga.update_strategy.name
if (!sManga.thumbnail_url.isNullOrEmpty() && manga[MangaTable.thumbnail_url] != sManga.thumbnail_url) {
this[MangaTable.thumbnailUrlLastFetched] = Instant.now().epochSecond
Manga.clearThumbnail(manga[MangaTable.id].value)
} else {
this[MangaTable.thumbnailUrlLastFetched] =
manga[MangaTable.thumbnailUrlLastFetched]
}
}
execute(this@transaction)
}
}
val mangaUrlsToId =
existingMangaUrlsToId
.mapValues { it.value[MangaTable.id].value } + insertedMangaUrlsToId
mangas.map { manga ->
mangaUrlsToId[manga.url]
@@ -86,7 +122,7 @@ object MangaList {
val mangasPage = this
val mangaList =
transaction {
val mangaIds = insertOrGet(sourceId)
val mangaIds = insertOrUpdate(sourceId)
return@transaction MangaTable.select { MangaTable.id inList mangaIds }.map { MangaTable.toDataClass(it) }
}
return PagedMangaListDataClass(
@@ -11,7 +11,7 @@ interface Track : Serializable {
var sync_id: Int
var media_id: Int
var media_id: Long
var library_id: Long?
@@ -9,7 +9,7 @@ class TrackImpl : Track {
override var sync_id: Int = 0
override var media_id: Int = 0
override var media_id: Long = 0L
override var library_id: Long? = null
@@ -43,7 +43,7 @@ class TrackImpl : Track {
override fun hashCode(): Int {
var result = (manga_id xor manga_id.ushr(32)).toInt()
result = 31 * result + sync_id
result = 31 * result + media_id
result = (31 * result + media_id).toInt()
return result
}
}
@@ -31,6 +31,8 @@ import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupChapter
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupManga
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupSerializer
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupSource
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupTracking
import suwayomi.tachidesk.manga.impl.track.Track
import suwayomi.tachidesk.manga.model.table.CategoryTable
import suwayomi.tachidesk.manga.model.table.ChapterTable
import suwayomi.tachidesk.manga.model.table.MangaStatus
@@ -230,9 +232,32 @@ object ProtoBackupExport : ProtoBackupBase() {
backupManga.categories = CategoryManga.getMangaCategories(mangaId).map { it.order }
}
// if(flags.includeTracking) {
// backupManga.tracking = TODO()
// }
if (flags.includeTracking) {
val tracks =
Track.getTrackRecordsByMangaId(mangaRow[MangaTable.id].value).mapNotNull {
if (it.record == null) {
null
} else {
BackupTracking(
syncId = it.record.trackerId,
// forced not null so its compatible with 1.x backup system
libraryId = it.record.libraryId ?: 0,
mediaId = it.record.remoteId,
title = it.record.title,
lastChapterRead = it.record.lastChapterRead.toFloat(),
totalChapters = it.record.totalChapters,
score = it.record.score.toFloat(),
status = it.record.status,
startedReadingDate = it.record.startDate,
finishedReadingDate = it.record.finishDate,
trackingUrl = it.record.remoteUrl,
)
}
}
if (tracks.isNotEmpty()) {
backupManga.tracking = tracks
}
}
// if (flags.includeHistory) {
// backupManga.history = TODO()
@@ -34,22 +34,27 @@ import suwayomi.tachidesk.manga.impl.CategoryManga
import suwayomi.tachidesk.manga.impl.Manga.clearThumbnail
import suwayomi.tachidesk.manga.impl.backup.models.Chapter
import suwayomi.tachidesk.manga.impl.backup.models.Manga
import suwayomi.tachidesk.manga.impl.backup.models.Track
import suwayomi.tachidesk.manga.impl.backup.proto.ProtoBackupValidator.ValidationResult
import suwayomi.tachidesk.manga.impl.backup.proto.ProtoBackupValidator.validate
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupCategory
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupHistory
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupManga
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupSerializer
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupTracking
import suwayomi.tachidesk.manga.impl.track.tracker.TrackerManager
import suwayomi.tachidesk.manga.impl.track.tracker.model.toTrack
import suwayomi.tachidesk.manga.impl.track.tracker.model.toTrackRecordDataClass
import suwayomi.tachidesk.manga.model.dataclass.TrackRecordDataClass
import suwayomi.tachidesk.manga.model.table.CategoryTable
import suwayomi.tachidesk.manga.model.table.ChapterTable
import suwayomi.tachidesk.manga.model.table.MangaTable
import java.io.InputStream
import java.lang.Integer.max
import java.util.Date
import java.util.Timer
import java.util.TimerTask
import java.util.concurrent.TimeUnit
import kotlin.math.max
import suwayomi.tachidesk.manga.impl.track.Track as Tracker
object ProtoBackupImport : ProtoBackupBase() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@@ -239,10 +244,9 @@ object ProtoBackupImport : ProtoBackupBase() {
val chapters = backupManga.getChaptersImpl()
val categories = backupManga.categories
val history = backupManga.brokenHistory.map { BackupHistory(it.url, it.lastRead) } + backupManga.history
val tracks = backupManga.getTrackingImpl()
try {
restoreMangaData(manga, chapters, categories, history, tracks, backupCategories, categoryMapping)
restoreMangaData(manga, chapters, categories, history, backupManga.tracking, backupCategories, categoryMapping)
} catch (e: Exception) {
val sourceName = sourceMapping[manga.source] ?: manga.source.toString()
errors.add(Date() to "${manga.title} [$sourceName]: ${e.message}")
@@ -255,7 +259,7 @@ object ProtoBackupImport : ProtoBackupBase() {
chapters: List<Chapter>,
categories: List<Int>,
history: List<BackupHistory>,
tracks: List<Track>,
tracks: List<BackupTracking>,
backupCategories: List<BackupCategory>,
categoryMapping: Map<Int, Int>,
) {
@@ -265,127 +269,165 @@ object ProtoBackupImport : ProtoBackupBase() {
.firstOrNull()
}
if (dbManga == null) { // Manga not in database
transaction {
// insert manga to database
val mangaId =
MangaTable.insertAndGetId {
it[url] = manga.url
it[title] = manga.title
val mangaId =
if (dbManga == null) { // Manga not in database
transaction {
// insert manga to database
val mangaId =
MangaTable.insertAndGetId {
it[url] = manga.url
it[title] = manga.title
it[artist] = manga.artist
it[author] = manga.author
it[description] = manga.description
it[genre] = manga.genre
it[artist] = manga.artist
it[author] = manga.author
it[description] = manga.description
it[genre] = manga.genre
it[status] = manga.status
it[thumbnail_url] = manga.thumbnail_url
it[updateStrategy] = manga.update_strategy.name
it[sourceReference] = manga.source
it[initialized] = manga.description != null
it[inLibrary] = manga.favorite
it[inLibraryAt] = TimeUnit.MILLISECONDS.toSeconds(manga.date_added)
}.value
// delete thumbnail in case cached data still exists
clearThumbnail(mangaId)
// insert chapter data
val chaptersLength = chapters.size
ChapterTable.batchInsert(chapters) { chapter ->
this[ChapterTable.url] = chapter.url
this[ChapterTable.name] = chapter.name
if (chapter.date_upload == 0L) {
this[ChapterTable.date_upload] = chapter.date_fetch
} else {
this[ChapterTable.date_upload] = chapter.date_upload
}
this[ChapterTable.chapter_number] = chapter.chapter_number
this[ChapterTable.scanlator] = chapter.scanlator
this[ChapterTable.sourceOrder] = chaptersLength - chapter.source_order
this[ChapterTable.manga] = mangaId
this[ChapterTable.isRead] = chapter.read
this[ChapterTable.lastPageRead] = chapter.last_page_read
this[ChapterTable.isBookmarked] = chapter.bookmark
this[ChapterTable.fetchedAt] = TimeUnit.MILLISECONDS.toSeconds(chapter.date_fetch)
}
// insert categories
categories.forEach { backupCategoryOrder ->
CategoryManga.addMangaToCategory(mangaId, categoryMapping[backupCategoryOrder]!!)
}
mangaId
}
} else { // Manga in database
transaction {
val mangaId = dbManga[MangaTable.id].value
// Merge manga data
MangaTable.update({ MangaTable.id eq mangaId }) {
it[artist] = manga.artist ?: dbManga[artist]
it[author] = manga.author ?: dbManga[author]
it[description] = manga.description ?: dbManga[description]
it[genre] = manga.genre ?: dbManga[genre]
it[status] = manga.status
it[thumbnail_url] = manga.thumbnail_url
it[thumbnail_url] = manga.thumbnail_url ?: dbManga[thumbnail_url]
it[updateStrategy] = manga.update_strategy.name
it[sourceReference] = manga.source
it[initialized] = dbManga[initialized] || manga.description != null
it[initialized] = manga.description != null
it[inLibrary] = manga.favorite
it[inLibrary] = manga.favorite || dbManga[inLibrary]
it[inLibraryAt] = TimeUnit.MILLISECONDS.toSeconds(manga.date_added)
}.value
// delete thumbnail in case cached data still exists
clearThumbnail(mangaId)
// insert chapter data
val chaptersLength = chapters.size
ChapterTable.batchInsert(chapters) { chapter ->
this[ChapterTable.url] = chapter.url
this[ChapterTable.name] = chapter.name
if (chapter.date_upload == 0L) {
this[ChapterTable.date_upload] = chapter.date_fetch
} else {
this[ChapterTable.date_upload] = chapter.date_upload
}
this[ChapterTable.chapter_number] = chapter.chapter_number
this[ChapterTable.scanlator] = chapter.scanlator
this[ChapterTable.sourceOrder] = chaptersLength - chapter.source_order
this[ChapterTable.manga] = mangaId
// merge chapter data
val chaptersLength = chapters.size
val dbChapters = ChapterTable.select { ChapterTable.manga eq mangaId }
this[ChapterTable.isRead] = chapter.read
this[ChapterTable.lastPageRead] = chapter.last_page_read
this[ChapterTable.isBookmarked] = chapter.bookmark
chapters.forEach { chapter ->
val dbChapter = dbChapters.find { it[ChapterTable.url] == chapter.url }
this[ChapterTable.fetchedAt] = TimeUnit.MILLISECONDS.toSeconds(chapter.date_fetch)
}
if (dbChapter == null) {
ChapterTable.insert {
it[url] = chapter.url
it[name] = chapter.name
if (chapter.date_upload == 0L) {
it[date_upload] = chapter.date_fetch
} else {
it[date_upload] = chapter.date_upload
}
it[chapter_number] = chapter.chapter_number
it[scanlator] = chapter.scanlator
// insert categories
categories.forEach { backupCategoryOrder ->
CategoryManga.addMangaToCategory(mangaId, categoryMapping[backupCategoryOrder]!!)
}
}
} else { // Manga in database
transaction {
val mangaId = dbManga[MangaTable.id].value
it[sourceOrder] = chaptersLength - chapter.source_order
it[ChapterTable.manga] = mangaId
// Merge manga data
MangaTable.update({ MangaTable.id eq mangaId }) {
it[artist] = manga.artist ?: dbManga[artist]
it[author] = manga.author ?: dbManga[author]
it[description] = manga.description ?: dbManga[description]
it[genre] = manga.genre ?: dbManga[genre]
it[status] = manga.status
it[thumbnail_url] = manga.thumbnail_url ?: dbManga[thumbnail_url]
it[updateStrategy] = manga.update_strategy.name
it[initialized] = dbManga[initialized] || manga.description != null
it[inLibrary] = manga.favorite || dbManga[inLibrary]
it[inLibraryAt] = TimeUnit.MILLISECONDS.toSeconds(manga.date_added)
}
// merge chapter data
val chaptersLength = chapters.size
val dbChapters = ChapterTable.select { ChapterTable.manga eq mangaId }
chapters.forEach { chapter ->
val dbChapter = dbChapters.find { it[ChapterTable.url] == chapter.url }
if (dbChapter == null) {
ChapterTable.insert {
it[url] = chapter.url
it[name] = chapter.name
if (chapter.date_upload == 0L) {
it[date_upload] = chapter.date_fetch
} else {
it[date_upload] = chapter.date_upload
it[isRead] = chapter.read
it[lastPageRead] = chapter.last_page_read
it[isBookmarked] = chapter.bookmark
}
} else {
ChapterTable.update({ (ChapterTable.url eq dbChapter[ChapterTable.url]) and (ChapterTable.manga eq mangaId) }) {
it[isRead] = chapter.read || dbChapter[isRead]
it[lastPageRead] = max(chapter.last_page_read, dbChapter[lastPageRead])
it[isBookmarked] = chapter.bookmark || dbChapter[isBookmarked]
}
it[chapter_number] = chapter.chapter_number
it[scanlator] = chapter.scanlator
it[sourceOrder] = chaptersLength - chapter.source_order
it[ChapterTable.manga] = mangaId
it[isRead] = chapter.read
it[lastPageRead] = chapter.last_page_read
it[isBookmarked] = chapter.bookmark
}
} else {
ChapterTable.update({ (ChapterTable.url eq dbChapter[ChapterTable.url]) and (ChapterTable.manga eq mangaId) }) {
it[isRead] = chapter.read || dbChapter[isRead]
it[lastPageRead] = max(chapter.last_page_read, dbChapter[lastPageRead])
it[isBookmarked] = chapter.bookmark || dbChapter[isBookmarked]
}
}
}
// merge categories
categories.forEach { backupCategoryOrder ->
CategoryManga.addMangaToCategory(mangaId, categoryMapping[backupCategoryOrder]!!)
// merge categories
categories.forEach { backupCategoryOrder ->
CategoryManga.addMangaToCategory(mangaId, categoryMapping[backupCategoryOrder]!!)
}
mangaId
}
}
}
val dbTrackRecordsByTrackerId =
Tracker.getTrackRecordsByMangaId(mangaId)
.mapNotNull { it.record?.toTrack() }
.associateBy { it.sync_id }
val (existingTracks, newTracks) =
tracks.mapNotNull { backupTrack ->
val track = backupTrack.toTrack(mangaId)
val isUnsupportedTracker = TrackerManager.getTracker(track.sync_id) == null
if (isUnsupportedTracker) {
return@mapNotNull null
}
val dbTrack =
dbTrackRecordsByTrackerId[backupTrack.syncId]
?: // new track
return@mapNotNull track
if (track.toTrackRecordDataClass().forComparison() == dbTrack.toTrackRecordDataClass().forComparison()) {
return@mapNotNull null
}
dbTrack.also {
it.media_id = track.media_id
it.library_id = track.library_id
it.last_chapter_read = max(dbTrack.last_chapter_read, track.last_chapter_read)
}
}.partition { (it.id ?: -1) > 0 }
existingTracks.forEach(Tracker::updateTrackRecord)
newTracks.forEach(Tracker::insertTrackRecord)
// TODO: insert/merge history
// TODO: insert/merge tracking
}
private fun TrackRecordDataClass.forComparison() = this.copy(id = 0, mangaId = 0)
}
@@ -15,6 +15,7 @@ import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction
import suwayomi.tachidesk.manga.impl.backup.proto.models.Backup
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupSerializer
import suwayomi.tachidesk.manga.impl.track.tracker.TrackerManager
import suwayomi.tachidesk.manga.model.table.SourceTable
import java.io.InputStream
@@ -39,17 +40,18 @@ object ProtoBackupValidator {
sources.filter { SourceTable.select { SourceTable.id eq it.key }.firstOrNull() == null }
}
// val trackers = backup.backupManga
// .flatMap { it.tracking }
// .map { it.syncId }
// .distinct()
val trackers =
backup.backupManga
.flatMap { it.tracking }
.map { it.syncId }
.distinct()
val missingTrackers = listOf("")
// val missingTrackers = trackers
// .mapNotNull { trackManager.getService(it) }
// .filter { !it.isLogged }
// .map { context.getString(it.nameRes()) }
// .sorted()
val missingTrackers =
trackers
.mapNotNull { TrackerManager.getTracker(it) }
.filter { !it.isLoggedIn }
.map { it.name }
.sorted()
return ValidationResult(
missingSources
@@ -8,11 +8,12 @@ import suwayomi.tachidesk.manga.impl.backup.models.TrackImpl
@Serializable
data class BackupTracking(
// in 1.x some of these values have different types or names
// syncId is called siteId in 1,x
@ProtoNumber(1) var syncId: Int,
// LibraryId is not null in 1.x
@ProtoNumber(2) var libraryId: Long,
@ProtoNumber(3) var mediaId: Int = 0,
@Deprecated("Use mediaId instead", level = DeprecationLevel.WARNING)
@ProtoNumber(3)
var mediaIdInt: Int = 0,
// trackingUrl is called mediaUrl in 1.x
@ProtoNumber(4) var trackingUrl: String = "",
@ProtoNumber(5) var title: String = "",
@@ -25,11 +26,17 @@ data class BackupTracking(
@ProtoNumber(10) var startedReadingDate: Long = 0,
// finishedReadingDate is called endReadTime in 1.x
@ProtoNumber(11) var finishedReadingDate: Long = 0,
@ProtoNumber(100) var mediaId: Long = 0,
) {
fun getTrackingImpl(): TrackImpl {
return TrackImpl().apply {
sync_id = this@BackupTracking.syncId
media_id = this@BackupTracking.mediaId
media_id =
if (this@BackupTracking.mediaIdInt != 0) {
this@BackupTracking.mediaIdInt.toLong()
} else {
this@BackupTracking.mediaId
}
library_id = this@BackupTracking.libraryId
title = this@BackupTracking.title
// convert from float to int because of 1.x types
@@ -13,6 +13,8 @@ import suwayomi.tachidesk.manga.impl.Page
import suwayomi.tachidesk.manga.impl.download.model.DownloadChapter
import suwayomi.tachidesk.manga.impl.util.createComicInfoFile
import suwayomi.tachidesk.manga.impl.util.getChapterCachePath
import suwayomi.tachidesk.manga.impl.util.getChapterDownloadPath
import suwayomi.tachidesk.manga.impl.util.storage.ImageResponse
import suwayomi.tachidesk.manga.model.table.ChapterTable
import suwayomi.tachidesk.manga.model.table.MangaTable
import java.io.File
@@ -28,21 +30,40 @@ abstract class ChaptersFilesProvider(val mangaId: Int, val chapterId: Int) : Dow
return RetrieveFile1Args(::getImageImpl)
}
/**
* Extract the existing download to the base download folder (see [getChapterDownloadPath])
*/
protected abstract fun extractExistingDownload()
protected abstract suspend fun handleSuccessfulDownload()
@OptIn(FlowPreview::class)
open suspend fun downloadImpl(
private suspend fun downloadImpl(
download: DownloadChapter,
scope: CoroutineScope,
step: suspend (DownloadChapter?, Boolean) -> Unit,
): Boolean {
val pageCount = download.chapter.pageCount
val chapterDir = getChapterCachePath(mangaId, chapterId)
val folder = File(chapterDir)
folder.mkdirs()
extractExistingDownload()
val finalDownloadFolder = getChapterDownloadPath(mangaId, chapterId)
val cacheChapterDir = getChapterCachePath(mangaId, chapterId)
val downloadCacheFolder = File(cacheChapterDir)
downloadCacheFolder.mkdirs()
val pageCount = download.chapter.pageCount
for (pageNum in 0 until pageCount) {
var pageProgressJob: Job? = null
val fileName = Page.getPageName(pageNum) // might have to change this to index stored in database
if (File(folder, fileName).exists()) continue
val pageExistsInFinalDownloadFolder = ImageResponse.findFileNameStartingWith(finalDownloadFolder, fileName) != null
val pageExistsInCacheDownloadFolder = ImageResponse.findFileNameStartingWith(cacheChapterDir, fileName) != null
val doesPageAlreadyExist = pageExistsInFinalDownloadFolder || pageExistsInCacheDownloadFolder
if (doesPageAlreadyExist) {
continue
}
try {
Page.getPageImage(
mangaId = download.mangaId,
@@ -69,7 +90,7 @@ abstract class ChaptersFilesProvider(val mangaId: Int, val chapterId: Int) : Dow
}
createComicInfoFile(
folder.toPath(),
downloadCacheFolder.toPath(),
transaction {
MangaTable.select { MangaTable.id eq mangaId }.first()
},
@@ -78,6 +99,10 @@ abstract class ChaptersFilesProvider(val mangaId: Int, val chapterId: Int) : Dow
},
)
handleSuccessfulDownload()
File(cacheChapterDir).deleteRecursively()
return true
}
@@ -1,6 +1,5 @@
package suwayomi.tachidesk.manga.impl.download.fileProvider.impl
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.apache.commons.compress.archivers.zip.ZipArchiveEntry
@@ -11,7 +10,6 @@ import org.kodein.di.DI
import org.kodein.di.conf.global
import org.kodein.di.instance
import suwayomi.tachidesk.manga.impl.download.fileProvider.ChaptersFilesProvider
import suwayomi.tachidesk.manga.impl.download.model.DownloadChapter
import suwayomi.tachidesk.manga.impl.util.getChapterCachePath
import suwayomi.tachidesk.manga.impl.util.getChapterCbzPath
import suwayomi.tachidesk.manga.impl.util.getMangaDownloadDir
@@ -32,17 +30,21 @@ class ArchiveProvider(mangaId: Int, chapterId: Int) : ChaptersFilesProvider(mang
return Pair(inputStream.buffered(), "image/$fileType")
}
override suspend fun downloadImpl(
download: DownloadChapter,
scope: CoroutineScope,
step: suspend (DownloadChapter?, Boolean) -> Unit,
): Boolean {
override fun extractExistingDownload() {
val outputFile = File(getChapterCbzPath(mangaId, chapterId))
val chapterCacheFolder = File(getChapterCachePath(mangaId, chapterId))
if (!outputFile.exists()) {
return
}
extractCbzFile(outputFile, chapterCacheFolder)
}
override suspend fun handleSuccessfulDownload() {
val mangaDownloadFolder = File(getMangaDownloadDir(mangaId))
val outputFile = File(getChapterCbzPath(mangaId, chapterId))
val chapterCacheFolder = File(getChapterCachePath(mangaId, chapterId))
if (outputFile.exists()) handleExistingCbzFile(outputFile, chapterCacheFolder)
super.downloadImpl(download, scope, step)
withContext(Dispatchers.IO) {
mangaDownloadFolder.mkdirs()
@@ -68,8 +70,6 @@ class ArchiveProvider(mangaId: Int, chapterId: Int) : ChaptersFilesProvider(mang
if (chapterCacheFolder.exists() && chapterCacheFolder.isDirectory) {
chapterCacheFolder.deleteRecursively()
}
return true
}
override fun delete(): Boolean {
@@ -83,7 +83,7 @@ class ArchiveProvider(mangaId: Int, chapterId: Int) : ChaptersFilesProvider(mang
return cbzDeleted
}
private fun handleExistingCbzFile(
private fun extractCbzFile(
cbzFile: File,
chapterFolder: File,
) {
@@ -1,11 +1,9 @@
package suwayomi.tachidesk.manga.impl.download.fileProvider.impl
import kotlinx.coroutines.CoroutineScope
import org.kodein.di.DI
import org.kodein.di.conf.global
import org.kodein.di.instance
import suwayomi.tachidesk.manga.impl.download.fileProvider.ChaptersFilesProvider
import suwayomi.tachidesk.manga.impl.download.model.DownloadChapter
import suwayomi.tachidesk.manga.impl.util.getChapterCachePath
import suwayomi.tachidesk.manga.impl.util.getChapterDownloadPath
import suwayomi.tachidesk.manga.impl.util.storage.FileDeletionHelper
@@ -29,23 +27,16 @@ class FolderProvider(mangaId: Int, chapterId: Int) : ChaptersFilesProvider(manga
return Pair(FileInputStream(file).buffered(), "image/$fileType")
}
override suspend fun downloadImpl(
download: DownloadChapter,
scope: CoroutineScope,
step: suspend (DownloadChapter?, Boolean) -> Unit,
): Boolean {
override fun extractExistingDownload() {
// nothing to do
}
override suspend fun handleSuccessfulDownload() {
val chapterDir = getChapterDownloadPath(mangaId, chapterId)
val folder = File(chapterDir)
val downloadSucceeded = super.downloadImpl(download, scope, step)
if (!downloadSucceeded) {
return false
}
val cacheChapterDir = getChapterCachePath(mangaId, chapterId)
File(cacheChapterDir).copyRecursively(folder, true)
return true
}
override fun delete(): Boolean {
@@ -15,6 +15,7 @@ import org.jetbrains.exposed.sql.insertAndGetId
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.update
import suwayomi.tachidesk.manga.impl.track.tracker.DeletableTrackService
import suwayomi.tachidesk.manga.impl.track.tracker.TrackerManager
import suwayomi.tachidesk.manga.impl.track.tracker.model.Track
import suwayomi.tachidesk.manga.impl.track.tracker.model.toTrack
@@ -73,9 +74,6 @@ object Track {
}
fun getTrackRecordsByMangaId(mangaId: Int): List<MangaTrackerDataClass> {
if (!TrackerManager.hasLoggedTracker()) {
return emptyList()
}
val recordMap =
transaction {
TrackRecordTable.select { TrackRecordTable.mangaId eq mangaId }
@@ -83,27 +81,25 @@ object Track {
}.associateBy { it.trackerId }
val trackers = TrackerManager.services
return trackers
.filter { it.isLoggedIn }
.map {
val record = recordMap[it.id]
if (record != null) {
val track =
Track.create(it.id).also { t ->
t.score = record.score.toFloat()
}
record.scoreString = it.displayScore(track)
}
MangaTrackerDataClass(
id = it.id,
name = it.name,
icon = proxyThumbnailUrl(it.id),
statusList = it.getStatusList(),
statusTextMap = it.getStatusList().associateWith { k -> it.getStatus(k).orEmpty() },
scoreList = it.getScoreList(),
record = record,
)
return trackers.map {
val record = recordMap[it.id]
if (record != null) {
val track =
Track.create(it.id).also { t ->
t.score = record.score.toFloat()
}
record.scoreString = it.displayScore(track)
}
MangaTrackerDataClass(
id = it.id,
name = it.name,
icon = proxyThumbnailUrl(it.id),
statusList = it.getStatusList(),
statusTextMap = it.getStatusList().associateWith { k -> it.getStatus(k).orEmpty() },
scoreList = it.getScoreList(),
record = record,
)
}
}
suspend fun search(input: SearchInput): List<TrackSearchDataClass> {
@@ -158,7 +154,7 @@ object Track {
var lastChapterRead: Double? = null
var startDate: Long? = null
if (chapterNumber != null && chapterNumber > 0) {
if (chapterNumber != null && chapterNumber > 0 && chapterNumber > track.last_chapter_read) {
lastChapterRead = chapterNumber.toDouble()
}
if (track.started_reading_date <= 0) {
@@ -186,11 +182,42 @@ object Track {
}
}
suspend fun refresh(recordId: Int) {
val recordDb =
transaction {
TrackRecordTable.select { TrackRecordTable.id eq recordId }.first()
}
val tracker = TrackerManager.getTracker(recordDb[TrackRecordTable.trackerId])!!
val track = recordDb.toTrack()
tracker.refresh(track)
upsertTrackRecord(track)
}
suspend fun unbind(
recordId: Int,
deleteRemoteTrack: Boolean? = false,
) {
val recordDb =
transaction {
TrackRecordTable.select { TrackRecordTable.id eq recordId }.first()
}
val tracker = TrackerManager.getTracker(recordDb[TrackRecordTable.trackerId])
if (deleteRemoteTrack == true && tracker is DeletableTrackService) {
tracker.delete(recordDb.toTrack())
}
transaction {
TrackRecordTable.deleteWhere { TrackRecordTable.id eq recordId }
}
}
suspend fun update(input: UpdateInput) {
if (input.unbind == true) {
transaction {
TrackRecordTable.deleteWhere { TrackRecordTable.id eq input.recordId }
}
unbind(input.recordId)
return
}
val recordDb =
@@ -250,13 +277,14 @@ object Track {
}
}
private suspend fun trackChapter(mangaId: Int) {
suspend fun trackChapter(mangaId: Int) {
val chapter = queryMaxReadChapter(mangaId)
val chapterNumber = chapter?.get(ChapterTable.chapter_number)
logger.debug {
"[Tracker]mangaId $mangaId chapter:${chapter?.get(ChapterTable.name)} " +
"chapterNumber:$chapterNumber"
logger.info {
"trackChapter(mangaId= $mangaId): maxReadChapter= #$chapterNumber ${chapter?.get(ChapterTable.name)}"
}
if (chapterNumber != null && chapterNumber > 0) {
trackChapter(mangaId, chapterNumber.toDouble())
}
@@ -282,23 +310,55 @@ object Track {
}
records.forEach {
val tracker = TrackerManager.getTracker(it[TrackRecordTable.trackerId])
val lastChapterRead = it[TrackRecordTable.lastChapterRead]
val isLogin = tracker?.isLoggedIn == true
logger.debug {
"[Tracker]trackChapter id:${tracker?.id} login:$isLogin " +
"mangaId:$mangaId dbChapter:$lastChapterRead toChapter:$chapterNumber"
}
if (isLogin && chapterNumber > lastChapterRead) {
it[TrackRecordTable.lastChapterRead] = chapterNumber
val track = it.toTrack()
tracker?.update(track, true)
upsertTrackRecord(track)
try {
trackChapterForTracker(it, chapterNumber)
} catch (e: Exception) {
KotlinLogging.logger("${logger.name}::trackChapter(mangaId= $mangaId, chapterNumber= $chapterNumber)")
.error(e) { "failed due to" }
}
}
}
private fun upsertTrackRecord(track: Track): Int {
private suspend fun trackChapterForTracker(
it: ResultRow,
chapterNumber: Double,
) {
val tracker = TrackerManager.getTracker(it[TrackRecordTable.trackerId]) ?: return
val track = it.toTrack()
val log =
KotlinLogging.logger {
"${logger.name}::trackChapterForTracker(chapterNumber= $chapterNumber, tracker= ${tracker.id}, recordId= ${track.id})"
}
log.debug { "called for $tracker, ${track.title} (recordId= ${track.id}, mangaId= ${track.manga_id})" }
val localLastReadChapter = it[TrackRecordTable.lastChapterRead]
if (localLastReadChapter == chapterNumber) {
log.debug { "new chapter is the same as the local last read chapter" }
return
}
if (!tracker.isLoggedIn) {
upsertTrackRecord(track)
return
}
tracker.refresh(track)
upsertTrackRecord(track)
val lastChapterRead = track.last_chapter_read
log.debug { "remoteLastReadChapter= $lastChapterRead" }
if (chapterNumber > lastChapterRead) {
track.last_chapter_read = chapterNumber.toFloat()
tracker.update(track, true)
upsertTrackRecord(track)
}
}
fun upsertTrackRecord(track: Track): Int {
return transaction {
val existingRecord =
TrackRecordTable.select {
@@ -308,41 +368,53 @@ object Track {
.singleOrNull()
if (existingRecord != null) {
TrackRecordTable.update({
(TrackRecordTable.mangaId eq track.manga_id) and
(TrackRecordTable.trackerId eq track.sync_id)
}) {
it[remoteId] = track.media_id
it[libraryId] = track.library_id
it[title] = track.title
it[lastChapterRead] = track.last_chapter_read.toDouble()
it[totalChapters] = track.total_chapters
it[status] = track.status
it[score] = track.score.toDouble()
it[remoteUrl] = track.tracking_url
it[startDate] = track.started_reading_date
it[finishDate] = track.finished_reading_date
}
updateTrackRecord(track)
existingRecord[TrackRecordTable.id].value
} else {
TrackRecordTable.insertAndGetId {
it[mangaId] = track.manga_id
it[trackerId] = track.sync_id
it[remoteId] = track.media_id
it[libraryId] = track.library_id
it[title] = track.title
it[lastChapterRead] = track.last_chapter_read.toDouble()
it[totalChapters] = track.total_chapters
it[status] = track.status
it[score] = track.score.toDouble()
it[remoteUrl] = track.tracking_url
it[startDate] = track.started_reading_date
it[finishDate] = track.finished_reading_date
}.value
insertTrackRecord(track)
}
}
}
fun updateTrackRecord(track: Track): Int =
transaction {
TrackRecordTable.update(
{
(TrackRecordTable.mangaId eq track.manga_id) and
(TrackRecordTable.trackerId eq track.sync_id)
},
) {
it[remoteId] = track.media_id
it[libraryId] = track.library_id
it[title] = track.title
it[lastChapterRead] = track.last_chapter_read.toDouble()
it[totalChapters] = track.total_chapters
it[status] = track.status
it[score] = track.score.toDouble()
it[remoteUrl] = track.tracking_url
it[startDate] = track.started_reading_date
it[finishDate] = track.finished_reading_date
}
}
fun insertTrackRecord(track: Track): Int =
transaction {
TrackRecordTable.insertAndGetId {
it[mangaId] = track.manga_id
it[trackerId] = track.sync_id
it[remoteId] = track.media_id
it[libraryId] = track.library_id
it[title] = track.title
it[lastChapterRead] = track.last_chapter_read.toDouble()
it[totalChapters] = track.total_chapters
it[status] = track.status
it[score] = track.score.toDouble()
it[remoteUrl] = track.tracking_url
it[startDate] = track.started_reading_date
it[finishDate] = track.finished_reading_date
}.value
}
@Serializable
data class LoginInput(
val trackerId: Int,
@@ -6,5 +6,5 @@ import suwayomi.tachidesk.manga.impl.track.tracker.model.Track
* For track services api that support deleting a manga entry for a user's list
*/
interface DeletableTrackService {
suspend fun delete(track: Track): Track
suspend fun delete(track: Track)
}
@@ -17,6 +17,10 @@ abstract class Tracker(val id: Int, val name: String) {
// Application and remote support for reading dates
open val supportsReadingDates: Boolean = false
abstract val supportsTrackDeletion: Boolean
override fun toString() = "$name ($id) (isLoggedIn= $isLoggedIn, isAuthExpired= ${getIfAuthExpired()})"
abstract fun getLogo(): String
abstract fun getStatusList(): List<Int>
@@ -1,7 +1,6 @@
package suwayomi.tachidesk.manga.impl.track.tracker.anilist
import android.annotation.StringRes
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import mu.KotlinLogging
@@ -29,9 +28,11 @@ class Anilist(id: Int) : Tracker(id, "AniList"), DeletableTrackService {
const val POINT_3 = "POINT_3"
}
override val supportsTrackDeletion: Boolean = true
private val json: Json by injectLazy()
private val interceptor by lazy { AnilistInterceptor(this, getPassword()) }
private val interceptor by lazy { AnilistInterceptor(this) }
private val api by lazy { AnilistApi(client, interceptor) }
@@ -157,13 +158,13 @@ class Anilist(id: Int) : Tracker(id, "AniList"), DeletableTrackService {
return api.updateLibManga(track)
}
override suspend fun delete(track: Track): Track {
override suspend fun delete(track: Track) {
if (track.library_id == null || track.library_id!! == 0L) {
val libManga = api.findLibManga(track, getUsername().toInt()) ?: return track
val libManga = api.findLibManga(track, getUsername().toInt()) ?: return
track.library_id = libManga.library_id
}
return api.deleteLibManga(track)
api.deleteLibManga(track)
}
override suspend fun bind(
@@ -115,7 +115,7 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
}
}
suspend fun deleteLibManga(track: Track): Track {
suspend fun deleteLibManga(track: Track) {
return withIOContext {
val query =
"""
@@ -135,7 +135,6 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
}
authClient.newCall(POST(API_URL, body = payload.toString().toRequestBody(jsonMime)))
.awaitSuccess()
track
}
}
@@ -5,7 +5,7 @@ import okhttp3.Response
import suwayomi.tachidesk.manga.impl.track.tracker.TokenExpired
import java.io.IOException
class AnilistInterceptor(val anilist: Anilist, private var token: String?) : Interceptor {
class AnilistInterceptor(private val anilist: Anilist) : Interceptor {
/**
* OAuth object used for authenticated requests.
*
@@ -17,18 +17,16 @@ class AnilistInterceptor(val anilist: Anilist, private var token: String?) : Int
field = value?.copy(expires = value.expires * 1000 - 60 * 1000)
}
init {
oauth = anilist.loadOAuth()
}
override fun intercept(chain: Interceptor.Chain): Response {
if (anilist.getIfAuthExpired()) {
throw TokenExpired()
}
val originalRequest = chain.request()
if (token.isNullOrEmpty()) {
throw Exception("Not authenticated with Anilist")
}
if (oauth == null) {
oauth = anilist.loadOAuth()
}
// Refresh access token if null or expired.
if (oauth?.isExpired() == true) {
anilist.setAuthExpired()
@@ -37,7 +35,7 @@ class AnilistInterceptor(val anilist: Anilist, private var token: String?) : Int
// Throw on null auth.
if (oauth == null) {
throw IOException("No authentication token")
throw IOException("Anilist: User is not authenticated")
}
// Add the authorization header to the original request.
@@ -54,7 +52,6 @@ class AnilistInterceptor(val anilist: Anilist, private var token: String?) : Int
* and the oauth object.
*/
fun setAuth(oauth: OAuth?) {
token = oauth?.access_token
this.oauth = oauth
anilist.saveOAuth(oauth)
}
@@ -1,5 +1,6 @@
package suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates
import suwayomi.tachidesk.manga.impl.track.tracker.DeletableTrackService
import suwayomi.tachidesk.manga.impl.track.tracker.Tracker
import suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.dto.ListItem
import suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.dto.Rating
@@ -8,8 +9,7 @@ import suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.dto.toTrackSearc
import suwayomi.tachidesk.manga.impl.track.tracker.model.Track
import suwayomi.tachidesk.manga.impl.track.tracker.model.TrackSearch
class MangaUpdates(id: Int) : Tracker(id, "MangaUpdates") {
// , DeletableTracker
class MangaUpdates(id: Int) : Tracker(id, "MangaUpdates"), DeletableTrackService {
companion object {
const val READING_LIST = 0
const val WISH_LIST = 1
@@ -31,6 +31,8 @@ class MangaUpdates(id: Int) : Tracker(id, "MangaUpdates") {
}
}
override val supportsTrackDeletion: Boolean = true
private val interceptor by lazy { MangaUpdatesInterceptor(this) }
private val api by lazy { MangaUpdatesApi(interceptor, client) }
@@ -74,9 +76,9 @@ class MangaUpdates(id: Int) : Tracker(id, "MangaUpdates") {
return track
}
// override suspend fun delete(track: Track) {
// api.deleteSeriesFromList(track)
// }
override suspend fun delete(track: Track) {
api.deleteSeriesFromList(track)
}
override suspend fun bind(
track: Track,
@@ -1,8 +1,11 @@
package suwayomi.tachidesk.manga.impl.track.tracker.model
import org.jetbrains.exposed.sql.ResultRow
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupTracking
import suwayomi.tachidesk.manga.model.dataclass.TrackRecordDataClass
import suwayomi.tachidesk.manga.model.table.TrackRecordTable
import suwayomi.tachidesk.manga.model.table.TrackRecordTable.lastChapterRead
import suwayomi.tachidesk.manga.model.table.TrackRecordTable.remoteUrl
fun ResultRow.toTrackRecordDataClass(): TrackRecordDataClass =
TrackRecordDataClass(
@@ -36,3 +39,52 @@ fun ResultRow.toTrack(): Track =
it.started_reading_date = this[TrackRecordTable.startDate]
it.finished_reading_date = this[TrackRecordTable.finishDate]
}
fun BackupTracking.toTrack(mangaId: Int): Track =
Track.create(syncId).also {
it.id = -1
it.manga_id = mangaId
it.media_id = mediaId
it.library_id = libraryId
it.title = title
it.last_chapter_read = lastChapterRead
it.total_chapters = totalChapters
it.status = status
it.score = score
it.tracking_url = trackingUrl
it.started_reading_date = startedReadingDate
it.finished_reading_date = finishedReadingDate
}
fun TrackRecordDataClass.toTrack(): Track =
Track.create(trackerId).also {
it.id = id
it.manga_id = mangaId
it.media_id = remoteId
it.library_id = libraryId
it.title = title
it.last_chapter_read = lastChapterRead.toFloat()
it.total_chapters = totalChapters
it.status = status
it.score = score.toFloat()
it.tracking_url = remoteUrl
it.started_reading_date = startDate
it.finished_reading_date = finishDate
}
fun Track.toTrackRecordDataClass(): TrackRecordDataClass =
TrackRecordDataClass(
id = id ?: -1,
mangaId = manga_id,
trackerId = sync_id,
remoteId = media_id,
libraryId = library_id,
title = title,
lastChapterRead = last_chapter_read.toDouble(),
totalChapters = total_chapters,
status = status,
score = score.toDouble(),
remoteUrl = tracking_url,
startDate = started_reading_date,
finishDate = finished_reading_date,
)
@@ -1,7 +1,6 @@
package suwayomi.tachidesk.manga.impl.track.tracker.myanimelist
import android.annotation.StringRes
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import mu.KotlinLogging
@@ -26,9 +25,11 @@ class MyAnimeList(id: Int) : Tracker(id, "MyAnimeList"), DeletableTrackService {
private const val SEARCH_LIST_PREFIX = "my:"
}
override val supportsTrackDeletion: Boolean = true
private val json: Json by injectLazy()
private val interceptor by lazy { MyAnimeListInterceptor(this, getPassword()) }
private val interceptor by lazy { MyAnimeListInterceptor(this) }
private val api by lazy { MyAnimeListApi(client, interceptor) }
override val supportsReadingDates: Boolean = true
@@ -94,8 +95,8 @@ class MyAnimeList(id: Int) : Tracker(id, "MyAnimeList"), DeletableTrackService {
return api.updateItem(track)
}
override suspend fun delete(track: Track): Track {
return api.deleteItem(track)
override suspend fun delete(track: Track) {
api.deleteItem(track)
}
override suspend fun bind(
@@ -164,18 +164,15 @@ class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListI
}
}
suspend fun deleteItem(track: Track): Track {
suspend fun deleteItem(track: Track) {
return withIOContext {
val request =
Request.Builder()
.url(mangaUrl(track.media_id).toString())
.delete()
.build()
with(json) {
authClient.newCall(request)
.awaitSuccess()
track
}
authClient.newCall(request)
.awaitSuccess()
}
}
@@ -10,10 +10,10 @@ import suwayomi.tachidesk.manga.impl.track.tracker.TokenRefreshFailed
import uy.kohesive.injekt.injectLazy
import java.io.IOException
class MyAnimeListInterceptor(private val myanimelist: MyAnimeList, private var token: String?) : Interceptor {
class MyAnimeListInterceptor(private val myanimelist: MyAnimeList) : Interceptor {
private val json: Json by injectLazy()
private var oauth: OAuth? = null
private var oauth: OAuth? = myanimelist.loadOAuth()
override fun intercept(chain: Interceptor.Chain): Response {
if (myanimelist.getIfAuthExpired()) {
@@ -44,7 +44,6 @@ class MyAnimeListInterceptor(private val myanimelist: MyAnimeList, private var t
* and the oauth object.
*/
fun setAuth(oauth: OAuth?) {
token = oauth?.access_token
this.oauth = oauth
myanimelist.saveOAuth(oauth)
}
@@ -75,10 +74,7 @@ class MyAnimeListInterceptor(private val myanimelist: MyAnimeList, private var t
}
}
.getOrNull()
?.also {
this.oauth = it
myanimelist.saveOAuth(it)
}
?.also(::setAuth)
?: throw TokenRefreshFailed()
}
}
@@ -1,5 +1,6 @@
package suwayomi.tachidesk.manga.impl.update
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import suwayomi.tachidesk.manga.model.dataclass.CategoryDataClass
@@ -12,7 +13,9 @@ interface IUpdater {
forceAll: Boolean,
)
val status: StateFlow<UpdateStatus>
val status: Flow<UpdateStatus>
val statusDeprecated: StateFlow<UpdateStatus>
fun reset()
}
@@ -6,9 +6,12 @@ import eu.kanade.tachiyomi.source.model.UpdateStrategy
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancelChildren
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
@@ -16,6 +19,8 @@ import kotlinx.coroutines.flow.consumeAsFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
@@ -37,14 +42,33 @@ import java.util.Date
import java.util.concurrent.ConcurrentHashMap
import kotlin.math.absoluteValue
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.seconds
@OptIn(FlowPreview::class)
class Updater : IUpdater {
private val logger = KotlinLogging.logger {}
private val notifyFlowScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _status = MutableStateFlow(UpdateStatus())
override val status = _status.asStateFlow()
private val notifyFlow = MutableSharedFlow<Unit>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
private val statusFlow = MutableSharedFlow<UpdateStatus>()
override val status = statusFlow.onStart { emit(getStatus()) }
init {
// has to be in its own scope (notifyFlowScope), otherwise, the collection gets canceled due to canceling the scopes (scope) children in the reset function
notifyFlowScope.launch {
notifyFlow.sample(1.seconds).collect {
updateStatus(immediate = true)
}
}
}
private val _status = MutableStateFlow(UpdateStatus())
override val statusDeprecated = _status.asStateFlow()
private var updateStatusCategories: Map<CategoryUpdateStatus, List<CategoryDataClass>> = emptyMap()
private var updateStatusSkippedMangas: List<MangaDataClass> = emptyList()
private val tracker = ConcurrentHashMap<Int, UpdateJob>()
private val updateChannels = ConcurrentHashMap<String, Channel<UpdateJob>>()
@@ -87,7 +111,7 @@ class Updater : IUpdater {
val lastAutomatedUpdate = preferences.getLong(lastAutomatedUpdateKey, 0)
preferences.edit().putLong(lastAutomatedUpdateKey, System.currentTimeMillis()).apply()
if (status.value.running) {
if (getStatus().running) {
logger.debug { "Global update is already in progress" }
return
}
@@ -123,23 +147,33 @@ class Updater : IUpdater {
HAScheduler.schedule(::autoUpdateTask, updateInterval, timeToNextExecution, "global-update")
}
/**
* Updates the status and sustains the "skippedMangas"
*/
private fun updateStatus(
jobs: List<UpdateJob>,
running: Boolean? = null,
categories: Map<CategoryUpdateStatus, List<CategoryDataClass>>? = null,
skippedMangas: List<MangaDataClass>? = null,
) {
private fun getStatus(running: Boolean? = null): UpdateStatus {
val jobs = tracker.values.toList()
val isRunning =
running
?: jobs.any { job ->
job.status == JobStatus.PENDING || job.status == JobStatus.RUNNING
}
val updateStatusCategories = categories ?: _status.value.categoryStatusMap
val tmpSkippedMangas = skippedMangas ?: _status.value.mangaStatusMap[JobStatus.SKIPPED] ?: emptyList()
_status.update { UpdateStatus(updateStatusCategories, jobs, tmpSkippedMangas, isRunning) }
return UpdateStatus(this.updateStatusCategories, jobs, this.updateStatusSkippedMangas, isRunning)
}
/**
* Pass "isRunning" to force a specific running state
*/
private suspend fun updateStatus(
immediate: Boolean = false,
isRunning: Boolean? = null,
) {
if (immediate) {
val status = getStatus(running = isRunning)
statusFlow.emit(status)
_status.update { status }
return
}
notifyFlow.emit(Unit)
}
private fun getOrCreateUpdateChannelFor(source: String): Channel<UpdateJob> {
@@ -166,7 +200,7 @@ class Updater : IUpdater {
return channel
}
private fun handleChannelUpdateFailure(source: String) {
private suspend fun handleChannelUpdateFailure(source: String) {
val isFailedSourceUpdate = { job: UpdateJob ->
val isForSource = job.manga.sourceId == source
val hasFailed = job.status == JobStatus.FAILED
@@ -181,17 +215,12 @@ class Updater : IUpdater {
tracker[mangaId] = job.copy(status = JobStatus.FAILED)
}
updateStatus(
tracker.values.toList(),
tracker.any { (_, job) ->
job.status == JobStatus.PENDING || job.status == JobStatus.RUNNING
},
)
updateStatus()
}
private suspend fun process(job: UpdateJob) {
tracker[job.manga.id] = job.copy(status = JobStatus.RUNNING)
updateStatus(tracker.values.toList(), true)
updateStatus()
tracker[job.manga.id] =
try {
@@ -207,7 +236,15 @@ class Updater : IUpdater {
job.copy(status = JobStatus.FAILED)
}
updateStatus(tracker.values.toList())
val wasLastJob = tracker.values.none { it.status == JobStatus.PENDING || it.status == JobStatus.RUNNING }
// in case this is the last update job, the running flag has to be true, before it gets set to false, to be able
// to properly clear the dataloader store in UpdateType
updateStatus(immediate = wasLastJob, isRunning = true)
if (wasLastJob) {
updateStatus(isRunning = false)
}
}
override fun addCategoriesToUpdateQueue(
@@ -274,10 +311,15 @@ class Updater : IUpdater {
.toList()
val skippedMangas = categoriesToUpdateMangas.subtract(mangasToUpdate.toSet()).toList()
// In case no manga gets updated and no update job was running before, the client would never receive an info about its update request
updateStatus(emptyList(), mangasToUpdate.isNotEmpty(), updateStatusCategories, skippedMangas)
this.updateStatusCategories = updateStatusCategories
this.updateStatusSkippedMangas = skippedMangas
if (mangasToUpdate.isEmpty()) {
// In case no manga gets updated and no update job was running before, the client would never receive an info
// about its update request
scope.launch {
updateStatus(immediate = true)
}
return
}
@@ -288,8 +330,9 @@ class Updater : IUpdater {
}
private fun addMangasToQueue(mangasToUpdate: List<MangaDataClass>) {
// create all manga update jobs before adding them to the queue so that the client is able to calculate the
// progress properly right form the start
mangasToUpdate.forEach { tracker[it.id] = UpdateJob(it) }
updateStatus(tracker.values.toList(), mangasToUpdate.isNotEmpty())
mangasToUpdate.forEach { addMangaToQueue(it) }
}
@@ -303,7 +346,11 @@ class Updater : IUpdater {
override fun reset() {
scope.coroutineContext.cancelChildren()
tracker.clear()
updateStatus(emptyList(), false)
this.updateStatusCategories = emptyMap()
this.updateStatusSkippedMangas = emptyList()
scope.launch {
updateStatus(immediate = true, isRunning = false)
}
updateChannels.forEach { (_, channel) -> channel.cancel() }
updateChannels.clear()
}
@@ -23,12 +23,12 @@ object UpdaterSocket : Websocket<UpdateStatus>() {
ctx: WsContext,
value: UpdateStatus?,
) {
ctx.send(value ?: updater.status.value)
ctx.send(value ?: updater.statusDeprecated.value)
}
override fun handleRequest(ctx: WsMessageContext) {
when (ctx.message()) {
"STATUS" -> notifyClient(ctx, updater.status.value)
"STATUS" -> notifyClient(ctx, updater.statusDeprecated.value)
else ->
ctx.send(
"""
@@ -101,6 +101,7 @@ class ServerConfig(getConfig: () -> Config, val moduleName: String = SERVER_CONF
val autoDownloadNewChapters: MutableStateFlow<Boolean> by OverrideConfigValue(BooleanConfigAdapter)
val excludeEntryWithUnreadChapters: MutableStateFlow<Boolean> by OverrideConfigValue(BooleanConfigAdapter)
val autoDownloadNewChaptersLimit: MutableStateFlow<Int> by OverrideConfigValue(IntConfigAdapter)
val autoDownloadIgnoreReUploads: MutableStateFlow<Boolean> by OverrideConfigValue(BooleanConfigAdapter)
// extensions
val extensionRepos: MutableStateFlow<List<String>> by OverrideConfigValues(StringConfigAdapter)
@@ -218,7 +218,7 @@ fun applicationSetup() {
}
} catch (e: Throwable) {
// cover both java.lang.Exception and java.lang.Error
e.printStackTrace()
logger.error(e) { "Failed to create/remove SystemTray due to" }
}
}, ignoreInitialValue = false)
@@ -0,0 +1,19 @@
package suwayomi.tachidesk.server.database.migration
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import de.neonew.exposed.migrations.helpers.SQLMigration
import suwayomi.tachidesk.manga.impl.track.tracker.TrackerManager
@Suppress("ClassName", "unused")
class M0037_RemoveTrackRecordsOfUnsupportedTrackers : SQLMigration() {
override val sql: String =
"""
DELETE FROM TRACKRECORD WHERE SYNC_ID NOT IN (${TrackerManager.MYANIMELIST}, ${TrackerManager.ANILIST}, ${TrackerManager.MANGA_UPDATES})
""".trimIndent()
}
@@ -0,0 +1,19 @@
package suwayomi.tachidesk.server.database.migration
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import de.neonew.exposed.migrations.helpers.SQLMigration
import suwayomi.tachidesk.manga.impl.track.tracker.TrackerManager
@Suppress("ClassName", "unused")
class M0038_RemoveTrackRecordsOfUnsupportedTrackersII : SQLMigration() {
override val sql: String =
"""
DELETE FROM TRACKRECORD WHERE SYNC_ID NOT IN (${TrackerManager.MYANIMELIST}, ${TrackerManager.ANILIST}, ${TrackerManager.MANGA_UPDATES})
""".trimIndent()
}
@@ -8,9 +8,11 @@ package suwayomi.tachidesk.server.util
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import dorkbox.desktop.Desktop
import mu.KotlinLogging
import suwayomi.tachidesk.server.serverConfig
object Browser {
private val logger = KotlinLogging.logger { }
private val electronInstances = mutableListOf<Any>()
private fun getAppBaseUrl(): String {
@@ -28,14 +30,14 @@ object Browser {
electronInstances.add(ProcessBuilder(electronPath, appBaseUrl).start())
} catch (e: Throwable) {
// cover both java.lang.Exception and java.lang.Error
e.printStackTrace()
logger.error(e) { "openInBrowser: failed to launch electron due to" }
}
} else {
try {
Desktop.browseURL(appBaseUrl)
} catch (e: Throwable) {
// cover both java.lang.Exception and java.lang.Error
e.printStackTrace()
logger.error(e) { "openInBrowser: failed to launch browser due to" }
}
}
}
@@ -10,6 +10,7 @@ package suwayomi.tachidesk.server.util
import dorkbox.systemTray.MenuItem
import dorkbox.systemTray.SystemTray
import dorkbox.util.CacheUtil
import mu.KotlinLogging
import suwayomi.tachidesk.server.ServerConfig
import suwayomi.tachidesk.server.generated.BuildConfig
import suwayomi.tachidesk.server.serverConfig
@@ -17,6 +18,7 @@ import suwayomi.tachidesk.server.util.Browser.openInBrowser
import suwayomi.tachidesk.server.util.ExitCode.Success
object SystemTray {
private val logger = KotlinLogging.logger { }
private var instance: SystemTray? = null
fun create() {
@@ -60,7 +62,7 @@ object SystemTray {
systemTray
} catch (e: Exception) {
e.printStackTrace()
logger.error(e) { "create: failed to create SystemTray due to" }
null
}
}
@@ -136,6 +136,8 @@ enum class WebUIFlavor(
}
}
fun WebUIFlavor.isDefault(): Boolean = this == WebUIFlavor.default
object WebInterfaceManager {
private val logger = KotlinLogging.logger {}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@@ -314,10 +316,7 @@ object WebInterfaceManager {
// check if the bundled webUI version is a newer version than the current used version
// this could be the case in case no compatible webUI version is available and a newer server version was installed
val shouldUpdateToBundledVersion =
flavor.uiName == WebUIFlavor.default.uiName && extractVersion(getLocalVersion()) <
extractVersion(
BuildConfig.WEBUI_TAG,
)
flavor.isDefault() && extractVersion(getLocalVersion()) < extractVersion(BuildConfig.WEBUI_TAG)
if (shouldUpdateToBundledVersion) {
log.debug { "update to bundled version \"${BuildConfig.WEBUI_TAG}\"" }
@@ -375,7 +374,7 @@ object WebInterfaceManager {
return
}
if (flavor.uiName != WebUIFlavor.default.uiName) {
if (!flavor.isDefault()) {
log.warn { "fallback to default webUI \"${WebUIFlavor.default.uiName}\"" }
serverConfig.webUIFlavor.value = WebUIFlavor.default.uiName
@@ -597,24 +596,25 @@ object WebInterfaceManager {
?: throw Exception("Invalid mappingFile")
val minServerVersionNumber = extractVersion(minServerVersionString)
// is a STABLE webUI release, without a specified webUI version, which requires same handling as the PREVIEW release
val isUnknownStableVersion = webUIVersion == "STABLEPREVIEW"
if (!WebUIChannel.doesConfigChannelEqual(WebUIChannel.from(webUIVersion))) {
// allow only STABLE versions for STABLE channel
if (WebUIChannel.doesConfigChannelEqual(WebUIChannel.STABLE)) {
if (WebUIChannel.doesConfigChannelEqual(WebUIChannel.STABLE) && !isUnknownStableVersion) {
continue
}
// allow all versions for PREVIEW channel
}
if (webUIVersion == WebUIChannel.PREVIEW.name) {
if (webUIVersion == WebUIChannel.PREVIEW.name || isUnknownStableVersion) {
webUIVersion = fetchPreviewVersion(flavor)
}
val isCompatibleVersion =
minServerVersionNumber <= currentServerVersionNumber && minServerVersionNumber >=
extractVersion(
BuildConfig.WEBUI_TAG,
)
val isNewerThanBundled =
!flavor.isDefault() || extractVersion(webUIVersion) >= extractVersion(BuildConfig.WEBUI_TAG)
val isCompatibleVersion = minServerVersionNumber <= currentServerVersionNumber && isNewerThanBundled
if (isCompatibleVersion) {
return webUIVersion
}
@@ -25,6 +25,7 @@ server.downloadsPath = ""
server.autoDownloadNewChapters = false # if new chapters that have been retrieved should get automatically downloaded
server.excludeEntryWithUnreadChapters = true # ignore automatic chapter downloads of entries with unread chapters
server.autoDownloadNewChaptersLimit = 0 # 0 to disable it - how many unread downloaded chapters should be available - if the limit is reached, new chapters won't be downloaded automatically. this limit will also be applied to the auto download of new chapters on an update
server.autoDownloadIgnoreReUploads = false # decides if re-uploads should be ignored during auto download of new chapters
# extension repos
server.extensionRepos = [
+40 -20
View File
@@ -10,40 +10,60 @@ server.socksProxyPort = ""
server.socksProxyUsername = ""
server.socksProxyPassword = ""
# webUI
server.webUIEnabled = true
server.webUIFlavor = "WebUI" # "WebUI", "VUI" or "Custom"
server.initialOpenInBrowserEnabled = true
server.webUIInterface = "browser" # "browser" or "electron"
server.electronPath = ""
server.webUIChannel = "stable" # "bundled" (the version bundled with the server release), "stable" or "preview" - the webUI version that should be used
server.webUIUpdateCheckInterval = 23 # time in hours - 0 to disable auto update - range: 1 <= n < 24 - default 23 hours - how often the server should check for webUI updates
# downloader
server.downloadAsCbz = false
server.autoDownloadNewChapters = false
server.excludeEntryWithUnreadChapters = true
server.autoDownloadNewChaptersLimit = 0
server.downloadsPath = ""
server.autoDownloadNewChapters = false # if new chapters that have been retrieved should get automatically downloaded
server.excludeEntryWithUnreadChapters = true # ignore automatic chapter downloads of entries with unread chapters
server.autoDownloadNewChaptersLimit = 0 # 0 to disable it - how many unread downloaded chapters should be available - if the limit is reached, new chapters won't be downloaded automatically. this limit will also be applied to the auto download of new chapters on an update
server.autoDownloadIgnoreReUploads = false # decides if re-uploads should be ignored during auto download of new chapters
# extension repos
server.extensionRepos = [
# an example: https://github.com/MY_ACCOUNT/MY_REPO/tree/repo
]
# requests
server.maxSourcesInParallel = 10
server.maxSourcesInParallel = 6 # range: 1 <= n <= 20 - default: 6 - sets how many sources can do requests (updates, downloads) in parallel. updates/downloads are grouped by source and all mangas of a source are updated/downloaded synchronously
# updater
server.excludeUnreadChapters = true
server.excludeNotStarted = true
server.excludeCompleted = true
server.globalUpdateInterval = 12
server.updateMangas = false
server.globalUpdateInterval = 12 # time in hours - 0 to disable it - (doesn't have to be full hours e.g. 12.5) - range: 6 <= n < ∞ - default: 12 hours - interval in which the global update will be automatically triggered
server.updateMangas = false # if the mangas should be updated along with the chapter list during a library/category update
# Authentication
server.basicAuthEnabled = false
server.basicAuthUsername = ""
server.basicAuthPassword = ""
# misc
server.debugLogsEnabled = true
server.gqlDebugLogsEnabled = false
server.systemTrayEnabled = false
# webUI
server.webUIEnabled = true
server.initialOpenInBrowserEnabled = true
server.webUIInterface = "browser" # "browser" or "electron"
server.electronPath = ""
server.webUIChannel = "stable"
server.webUIUpdateCheckInterval = 24
server.debugLogsEnabled = false
server.gqlDebugLogsEnabled = false # this includes logs with non privacy safe information
server.systemTrayEnabled = true
# backup
server.backupPath = ""
server.backupTime = "00:00"
server.backupInterval = 1
server.backupTTL = 14
server.backupTime = "00:00" # range: hour: 0-23, minute: 0-59 - default: "00:00" - time of day at which the automated backup should be triggered
server.backupInterval = 1 # time in days - 0 to disable it - range: 1 <= n < ∞ - default: 1 day - interval in which the server will automatically create a backup
server.backupTTL = 14 # time in days - 0 to disable it - range: 1 <= n < ∞ - default: 14 days - how long backup files will be kept before they will get deleted
# local source
server.localSourcePath = ""
# Cloudflare bypass
server.flareSolverrEnabled = false
server.flareSolverrUrl = "http://localhost:8191"
server.flareSolverrTimeout = 60 # time in seconds
server.flareSolverrSessionName = "suwayomi"
server.flareSolverrSessionTtl = 15 # time in minutes