Compare commits

..

281 Commits

Author SHA1 Message Date
Aria Moradi 2c6d043277 bump version
CI Publish / Validate Gradle Wrapper (push) Successful in 12s
CI Publish / Build artifacts and release (push) Failing after 15s
2021-11-29 19:24:07 +03:30
Aria Moradi 9a226f7b64 update 2021-11-29 19:14:40 +03:30
Aria Moradi 3b73a0fd72 empty searchTerm support (#259)
* linter run

* convert search params to queryParams
2021-11-29 18:42:53 +03:30
Aria Moradi 2478aa77cd add support for MultiSelectListPreference (#258)
* add support for MultiSelectListPreference

* Update AndroidCompat/src/main/java/xyz/nulldev/androidcompat/io/sharedprefs/JavaSharedPreferences.kt

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

* don't convert to list

* fix by @Syer10

Co-authored-by: Mitchell Syer <Mitchellptbo@gmail.com>
2021-11-28 23:11:56 +03:30
Aria Moradi a41068dbc9 migrate application directories (#255) 2021-11-28 19:56:06 +03:30
Aria Moradi 5e47b7ae6b Update README.md 2021-11-16 21:37:44 +03:30
Aria Moradi debf45a7d5 Update README.md 2021-11-16 21:09:16 +03:30
Aria Moradi e7041e8c8c Fix first page not being detected correctly (#253)
* fix first page not being recognized correctly

* fix first page not being recognized correctly
2021-11-15 23:49:02 +03:30
Aria Moradi bd960992bc cleanup directory names (#251) 2021-11-15 23:40:49 +03:30
Aria Moradi 0c5f6b432c update 2021-11-15 12:22:49 +03:30
Aria Moradi 49232edbd5 update 2021-11-15 12:06:04 +03:30
Mitchell Syer b02884f58d Add a Kotlin DSL for endpoint documentation (#249) 2021-11-14 18:16:39 +03:30
Aria Moradi 845b588426 Mention the existence of Mahor's Tachidesk-GTK 2021-11-13 13:51:20 +03:30
Aria Moradi 3a97d7c8be update 2021-11-13 13:27:17 +03:30
Sascha Hahne 2cb2ded2d9 Implement Update of Library/Category (#235)
* Implement Update Controller tests

* Basic Threading and notify

* WIP

* Reworked using coroutines

* Use Map for JobSummary Tracking

* Change Tests

* Clean up

* Changes based on review

* Rethrow cancellationexception

* Clean up

* Fix Merge Error

* Actually handle messages

* Clean up

* Remove useless annotation
2021-11-10 22:38:41 +03:30
Aria Moradi 14e02bee6c update 2021-11-10 12:17:26 +03:30
Aria Moradi 30f7cdc1ba add pagination to recentChapters (#246)
* add pagination to recentChapters

* Use kotlin native library

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

Co-authored-by: Mitchell Syer <Mitchellptbo@gmail.com>
2021-11-08 18:45:52 +03:30
Mitchell Syer 420d14fc37 Fix Manga Meta, add Manga Meta test (#245)
* Fix Manga Meta, add Manga Meta test

* Tweak assertion strings
2021-11-08 03:08:22 +03:30
Aria Moradi 3d7953d977 add manga data to download queue object (#244)
* add manga data to download queue object

* fix lint erro
2021-11-07 21:32:57 +03:30
Aria Moradi 35238b3da1 stop supporting zero based image storage (#242)
* stop supporting zero based image storage, closes #210

* add test
2021-11-07 21:27:11 +03:30
Aria Moradi 446f4283e0 Update CONTRIBUTING.md 2021-11-02 15:58:30 +03:30
Aria Moradi 8062dd3709 convert request type 2021-11-02 12:14:44 +03:30
Aria Moradi 41c8fde8c5 ignore build artifacts generated by teting 2021-11-02 12:13:33 +03:30
Aria Moradi d90b986d19 implement Source Filters 2021-11-02 04:15:08 +03:30
Aria Moradi 64ea8416b2 refactor 2021-11-01 23:46:46 +03:30
Aria Moradi 100a4c9d35 refactor 2021-11-01 18:02:06 +03:30
Aria Moradi 4ef6dec89a cleanup 2021-11-01 02:10:51 +03:30
Aria Moradi a14cdc48bd fix credit 2021-11-01 02:09:14 +03:30
Aria Moradi 8a4ddbc6df update 2021-11-01 02:05:02 +03:30
Aria Moradi ee33acc561 allow injecting Sources 2021-10-31 18:05:55 +03:30
Aria Moradi 5fe69becf3 improve tests 2021-10-31 17:31:46 +03:30
Aria Moradi d460d3ccdf change windows bundle names 2021-10-31 14:06:59 +03:30
Sascha Hahne 0ee74943e8 Fix category reorder Endpoint. Added Test for Category Reorder (#232)
* Fix category reorder Endpoint. Added Test for Category Reorder

* Remove streams and use kotlin filtering
2021-10-30 14:46:22 +03:30
Aria Moradi 82837e38d2 update 2021-10-30 14:25:23 +03:30
Sascha Hahne 91df90d760 Fix broken test (#231) 2021-10-29 19:40:20 +03:30
Mitchell Syer 1ee37da720 Fix unread and download counts casing (#230) 2021-10-28 19:11:10 +03:30
Aria Moradi 6f8fc5b69d Update README.md 2021-10-28 12:35:24 +03:30
Sascha Hahne be1918c769 add Cache Header to Thumbnail Response for improved library performance (#228) 2021-10-26 21:46:38 +03:30
Sascha Hahne 0057b35a0a Expose unread and download count of Manga in category api (#227)
* #224 Created view for unread and download badges

* #224 Basic test structure

* Created test and cleaned up a bit

* Move counts to MangaDataClass and delete MangaViewDataClass

* Readded trailing space

* Removed SQL view and calculate with joins now
2021-10-25 13:41:07 +03:30
Sascha Hahne d12974702a Fix tests (#226) 2021-10-24 23:19:27 +03:30
Aria Moradi 921c41689d update 2021-10-24 23:08:56 +03:30
Aria Moradi 6389899507 remove anime support 2021-10-24 22:58:25 +03:30
Aria Moradi 92ede2a2b3 Update README.md 2021-10-24 17:28:02 +03:30
Aria Moradi 826a63ed71 Update README.md 2021-10-24 16:57:06 +03:30
Aria Moradi d1576a2a72 Update README.md 2021-10-24 14:27:12 +03:30
Aria Moradi 95f218d704 Update README.md 2021-10-23 20:36:45 +03:30
Aria Moradi 315d3a0ac0 Update CONTRIBUTING.md 2021-10-23 20:04:10 +03:30
Aria Moradi 954818cef2 Update CONTRIBUTING.md 2021-10-23 20:02:42 +03:30
Aria Moradi 5a95ca9b1b Update CONTRIBUTING.md 2021-10-23 20:00:35 +03:30
Aria Moradi c1e6f4c26e better cleaning algorithm 2021-10-23 19:27:50 +03:30
Aria Moradi 7c603258fb Update README.md 2021-10-21 14:32:58 +03:30
Mahor fcbc582686 Update README.md (#223)
Add PPA's description for Ubuntu-based distributions
2021-10-20 12:15:46 +03:30
Aria Moradi 9c4906b90b cleanup 2021-10-20 08:34:07 +03:30
Aria Moradi d35e31e02d bump version
CI Publish / Validate Gradle Wrapper (push) Successful in 16s
CI Publish / Build artifacts and release (push) Failing after 15s
2021-10-18 14:41:01 +03:30
Aria Moradi 6c4ca36c09 update WebUI 2021-10-18 14:37:13 +03:30
Aria Moradi 70355dc505 update 2021-10-11 00:21:10 +03:30
Aria Moradi 20aeaf2a05 update 2021-10-11 00:18:49 +03:30
Mitchell Syer 1f13e1d08b Use a custom task to run electron (#220) 2021-10-11 00:16:32 +03:30
Aria Moradi eb9d35c123 code cleanup 2021-10-11 00:15:15 +03:30
Mitchell Syer 45808cd530 Support using a CatalogueSource instead of only HttpSources (#219) 2021-10-11 00:01:04 +03:30
Aria Moradi fd715a3f92 fix workflow 2021-10-10 23:52:35 +03:30
Aria Moradi e3b32367a7 update 2021-10-10 23:51:21 +03:30
Aria Moradi bf9554a746 update 2021-10-10 23:32:19 +03:30
Aria Moradi b9f8ca1488 update 2021-10-10 23:01:58 +03:30
Aria Moradi e8c4159678 use correct conversion units 2021-10-10 21:31:38 +03:30
Aria Moradi e57e71629e update 2021-10-10 21:05:24 +03:30
Aria Moradi 2bfd9d24a4 remove isNsfw annotation detection 2021-10-10 21:04:28 +03:30
Aria Moradi b18b8fe22f update 2021-10-10 20:58:10 +03:30
Aria Moradi b154ff2f9d fix export chapter ordering, include new props in backup 2021-10-10 20:53:50 +03:30
Aria Moradi e9b764b63c update 2021-10-10 12:27:50 +03:30
Aria Moradi 7216b97d92 mimic Tachyomi's behaviour more closely, fixes ReadComicOnline (EN) 2021-10-10 12:18:21 +03:30
Aria Moradi 0e9d93b194 prepare CHANGELOG 2021-10-06 21:18:21 +03:30
Aria Moradi 2cbee62f0a update docs 2021-09-28 18:06:20 +03:30
Aria Moradi 379e9da5fe clenup 2021-09-28 18:00:24 +03:30
Aria Moradi ae7caa4901 merge 2021-09-28 17:57:55 +03:30
Aria Moradi cd8b4c9dd7 convert android.jar lib to a maven repo 2021-09-28 17:55:50 +03:30
Aria Moradi 60cd61dfd2 Update README.md 2021-09-28 03:42:20 +03:30
Aria Moradi 5a6637d9fc Update README.md 2021-09-28 03:39:45 +03:30
Aria Moradi dca7ed23f5 Update README.md 2021-09-28 03:36:06 +03:30
Aria Moradi 8cb5791f3b Update README.md 2021-09-28 03:34:14 +03:30
Aria Moradi 9b67f2c58f Update CHANGELOG.md 2021-09-28 03:24:57 +03:30
Aria Moradi 3815810d4f Update for release 2021-09-28 01:11:24 +03:30
Aria Moradi 819ceba17d bump version
CI Publish / Validate Gradle Wrapper (push) Successful in 12s
CI Publish / Build artifacts and release (push) Failing after 16s
2021-09-28 00:52:49 +03:30
Aria Moradi 0aa0d62e03 update changelog file and it's template 2021-09-28 00:51:05 +03:30
Aria Moradi b3e2a35880 update WebUI 2021-09-28 00:50:33 +03:30
Aria Moradi 15ec20c65d fix sorting 2021-09-27 20:27:40 +03:30
Aria Moradi d4d6d7e12f add recentChapters endpoint 2021-09-27 18:27:05 +03:30
Aria Moradi 2e7a4f1421 remove no longer relevant comment 2021-09-27 14:44:48 +03:30
Aria Moradi ab8a52faf3 rename ChapterTable.chapterIndex to ChapterTable.sourceOrder 2021-09-27 14:36:06 +03:30
Aria Moradi bd465559fb Update README.md 2021-09-26 23:48:29 +03:30
Aria Moradi 13ec45a95c aftermath of adding kotlinter to all modules 2021-09-25 04:34:02 +03:30
Mitchell Syer 13b034875b Workaround StdLib issue and add KtLint to all modules (#206)
* Workaround buildconfig kotlin stdlib issue

* Add KtLint to all modules
2021-09-25 04:31:03 +03:30
Aria Moradi bb701fb088 fix macOS-arm64 java path 2021-09-24 14:06:19 +03:30
Aria Moradi b367414865 changes 2021-09-24 13:56:26 +03:30
Aria Moradi 4b00eec608 update CHANGELOG 2021-09-19 18:01:13 +04:30
Aria Moradi 5e11b51152 update CHANGELOG 2021-09-19 17:59:37 +04:30
Aria Moradi 9fb43b996e CHANGELOG update 2021-09-19 17:39:28 +04:30
Aria Moradi bc2072e81f bump version
CI Publish / Validate Gradle Wrapper (push) Successful in 13s
CI Publish / Build artifacts and release (push) Failing after 15s
2021-09-19 17:36:46 +04:30
Aria Moradi f36bc3f643 update WebUI 2021-09-19 17:34:18 +04:30
Aria Moradi f7901ad843 fix windows paths 2021-09-19 16:43:16 +04:30
Aria Moradi 3771030ed6 closes #202 2021-09-19 14:24:13 +04:30
Aria Moradi 57197e58b5 fix Task path 2021-09-19 14:14:42 +04:30
Aria Moradi ac601399ac update WebUI 2021-09-19 14:14:21 +04:30
Aria Moradi 6a0e221153 fix compile 2021-09-19 01:01:20 +04:30
Aria Moradi 6a949fc851 Minor cleanup 2021-09-19 00:59:04 +04:30
Aria Moradi f1a077dc2f update CHANGELOG 2021-09-18 22:09:34 +04:30
Mitchell Syer f20962b02b Gradle Updates (#199)
* Cleanup and update gradle, update dependencies

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

Add docker build workflow

* Rename docker_build_stable to docker_build_stable.yml

* Update docker_build_stable.yml

* Update docker_build_stable.yml
2021-09-10 21:42:31 +04:30
Aria Moradi 6726f008c1 add dummy file to trigger workflow 2021-09-10 21:22:28 +04:30
Mosei 89cf0c140f Update publish.yml (#190) 2021-09-10 20:49:57 +04:30
Mosei 504025ce80 Update build_push.yml (#191) 2021-09-10 20:49:33 +04:30
Aria Moradi fee9e914f1 fix exposed crash 2021-09-10 00:45:00 +04:30
Aria Moradi 76efa71c68 update WebUI 2021-09-09 23:05:58 +04:30
Aria Moradi 26e61959ae update WebUI 2021-09-09 16:46:19 +04:30
Aria Moradi 9a8956ef9d update dependencies 2021-09-09 16:41:41 +04:30
Aria Moradi 10d3ffc2f6 better UX 2021-09-09 06:05:09 +04:30
Aria Moradi 090399f61d add support for installing external APK 2021-09-09 05:22:45 +04:30
Aria Moradi ae7d975a92 run won't get stuck now 2021-09-09 05:15:51 +04:30
Aria Moradi 55ec6bcafe add TODO for changing later 2021-09-09 05:15:08 +04:30
Aria Moradi f0566d15af fix compiler warning 2021-09-08 20:27:29 +04:30
Aria Moradi a730b692bc update WebUI 2021-09-08 20:24:22 +04:30
Aria Moradi 826d767423 [SKIP CI] update CHANGELOG.md 2021-09-08 20:23:45 +04:30
Aria Moradi 6d227c7fcd Fix typo 2021-09-06 22:23:26 +04:30
Aria Moradi 7d9d97840e Update README.md 2021-09-06 14:41:00 +04:30
Mosei 110ded45a0 Update README.md (#189)
Remove depreciated arbuilder’s docker link
2021-09-05 21:16:25 +04:30
Aria Moradi 7872b593c7 Credit to @Syer10 2021-09-05 17:50:45 +04:30
Aria Moradi 90be30bddb add support for the new gnere type in WebUI 2021-09-05 01:40:34 +04:30
Mitchell Syer a298c61dab Change type of Manga.genre to a List<String> (#188) 2021-09-05 01:36:52 +04:30
Aria Moradi eb416c45bd remove not-code changes from CHANGELOG 2021-09-05 00:54:01 +04:30
Aria Moradi b05b817aeb Update README.md 2021-09-03 18:52:06 +04:30
Aria Moradi 666602283a update
CI Publish / Validate Gradle Wrapper (push) Successful in 12s
CI Publish / Build artifacts and release (push) Failing after 15s
2021-09-03 07:59:41 +04:30
Aria Moradi ac040a4bae fix oopsies n'shit 2021-09-03 07:57:53 +04:30
Aria Moradi b4982c8f22 update 2021-09-03 07:55:15 +04:30
Aria Moradi ce3ad92095 remove node module cache, won't need it anymore 2021-09-03 07:54:07 +04:30
Aria Moradi 8e1ac8698c bump version 2021-09-03 07:48:31 +04:30
Aria Moradi b60a39c7cb update 2021-09-03 07:46:11 +04:30
Aria Moradi 3b3e8ba4c8 add new unix bundles to release 2021-09-03 07:45:11 +04:30
Aria Moradi e387f6d3be update 2021-09-03 07:44:00 +04:30
Aria Moradi 799d469cb6 bump electron version 2021-09-03 07:43:13 +04:30
Aria Moradi a54a596fa7 fix macOS bundler 2021-09-03 07:39:28 +04:30
Aria Moradi 92d73d0285 add linux and macOS bundler script 2021-09-03 06:50:42 +04:30
Aria Moradi acb752e4e8 update jre 2021-09-03 05:43:33 +04:30
Aria Moradi 9e377abba6 update CHANGELOG 2021-09-03 04:55:01 +04:30
Aria Moradi 04552c0923 update WebUI 2021-09-03 04:52:34 +04:30
Aria Moradi 2e1fb85b73 [skip ci] update changelog 2021-09-03 04:49:38 +04:30
Aria Moradi 50db32d9b4 this is dumb 2021-09-03 04:48:32 +04:30
Aria Moradi 28743d953e [skip ci] r857 2021-09-03 04:41:00 +04:30
Aria Moradi b109d26aa7 fixed typo 2021-09-03 04:39:45 +04:30
Aria Moradi 7fd21bd06d [skip ci] r855 2021-09-02 17:45:52 +04:30
Aria Moradi 12e0ffb466 text cleanup 2021-09-02 17:43:43 +04:30
Aria Moradi 9259341df8 missed this changelog 2021-09-02 17:31:30 +04:30
Aria Moradi 8e8aca7e7b Update CONTRIBUTING.md 2021-09-02 17:29:26 +04:30
Aria Moradi 7f0bcd987b Update CONTRIBUTING.md 2021-09-02 17:24:32 +04:30
Aria Moradi ef21de95cb add continues changelog file 2021-09-02 17:20:53 +04:30
Aria Moradi ca3246de02 bump version 2021-09-02 15:21:27 +04:30
Aria Moradi f0940b7926 bump version
CI Publish / Validate Gradle Wrapper (push) Successful in 15s
CI Publish / Build artifacts and release (push) Failing after 17s
2021-08-31 17:43:23 +04:30
Aria Moradi 0066e0b901 suppress warnings 2021-08-31 17:33:29 +04:30
Aria Moradi 9771f566b0 better comments 2021-08-30 02:48:10 +04:30
Aria Moradi 38ad4c6dec refactor 2021-08-30 02:44:35 +04:30
Aria Moradi 37cf80a188 code cleanup 2021-08-30 02:38:15 +04:30
Aria Moradi c86ee53f66 resolve compiler warnings 2021-08-29 22:25:43 +04:30
Aria Moradi c2cea7e797 can serialize Search Filters 2021-08-29 22:19:44 +04:30
Aria Moradi a8ef6cdd4f change category re-order url 2021-08-29 21:52:23 +04:30
Aria Moradi 53d157fee8 update 2021-08-29 21:47:09 +04:30
Aria Moradi c2e07b13f6 fix categories not being normalized 2021-08-29 20:09:17 +04:30
Aria Moradi 2e8cc48311 update WebUI 2021-08-29 02:11:11 +04:30
Aria Moradi f6f811eb77 update WebUI 2021-08-29 01:59:36 +04:30
Aria Moradi ac5528fb15 add when the statement was true 2021-08-27 04:49:53 +04:30
Aria Moradi 940d2b7862 bump version 2021-08-26 22:31:06 +04:30
Aria Moradi 835fe3dad3 sorround with try, catch as it might throw an exception 2021-08-26 22:24:54 +04:30
Aria Moradi dfaecc08c5 add realUrl to Manga, reperesents open in WebView URL 2021-08-26 22:11:51 +04:30
Aria Moradi 87f5e9b847 fix migration number 2021-08-26 22:10:51 +04:30
Aria Moradi 3d3939e808 better logs 2021-08-26 22:10:27 +04:30
Aria Moradi 90822e3858 merge manga data while restoring backup 2021-08-26 16:28:45 +04:30
Aria Moradi 14eec47e9c correct value for inLibrary 2021-08-26 01:34:56 +04:30
Aria Moradi 15ed3fcc69 actual fix for source order 2021-08-26 01:31:59 +04:30
Aria Moradi fd8fa9f3ef fix chapter restore order 2021-08-26 01:28:42 +04:30
Aria Moradi b81075f4a7 fix docker builds faling? 2021-08-24 22:23:39 +04:30
Aria Moradi f11a52e8e1 we don't need that feild anymore 2021-08-24 22:23:00 +04:30
Aria Moradi 9c007483d4 better method of detemining if a source is Nsfw 2021-08-24 02:44:13 +04:30
Aria Moradi ff4e818e4c add some comments 2021-08-23 21:48:27 +04:30
Aria Moradi 45a50ca0c1 add isNsfw to SourceDataClass 2021-08-23 21:46:28 +04:30
Aria Moradi 65d9021c37 close response 2021-08-23 06:10:31 +04:30
Aria Moradi 66481a0391 NPE fix suggested by @syer10 2021-08-23 06:05:04 +04:30
Aria Moradi a14a82bc9a fix oppsie, sync dependencies with tachiyomi 2021-08-23 05:27:39 +04:30
Aria Moradi 756c57a16e also intercept on 403 2021-08-23 04:56:27 +04:30
Aria Moradi 8b19e34dc5 Update README.md 2021-08-23 04:38:32 +04:30
Aria Moradi 50083019ee add copyright notices 2021-08-23 04:37:30 +04:30
Aria Moradi 155272e638 add new keys 2021-08-23 04:28:07 +04:30
Aria Moradi 08443ceb3d remove comment 2021-08-23 04:20:04 +04:30
Aria Moradi c215696f04 have a lighter log level 2021-08-23 04:17:40 +04:30
Aria Moradi 5ca42bf9b6 make it compile 2021-08-23 04:02:55 +04:30
Aria Moradi 3272b9dec5 add CloudflareInterceptor from TachiWeb-Server 2021-08-23 03:45:10 +04:30
Aria Moradi 2ebd5da4aa bump kotlinter version 2021-08-22 19:00:33 +04:30
Aria Moradi 34f024ace2 migrate dex2jar dependency to @ThexXTURBOXx version 2021-08-21 16:36:34 +04:30
Aria Moradi b31f2d50f6 No more legacy backup 2021-08-21 06:39:12 +04:30
Aria Moradi da44d3b2b4 bump to v0.4.7
CI Publish / Validate Gradle Wrapper (push) Successful in 12s
CI Publish / Build artifacts and release (push) Failing after 17s
2021-08-21 06:30:45 +04:30
Aria Moradi 99ec2aca6a update WebUI 2021-08-21 06:27:14 +04:30
Aria Moradi 6c278604ec got rid of legacy backups 2021-08-21 06:23:58 +04:30
Aria Moradi 1e094a467a not TODO 2021-08-21 06:13:55 +04:30
Aria Moradi 978ccfeeba the true commit 2021-08-21 06:12:22 +04:30
Aria Moradi e93d66d8a1 add backup validation endpoints 2021-08-21 06:08:17 +04:30
Aria Moradi c29a749833 proto export support 2021-08-21 05:48:05 +04:30
Aria Moradi b08d5d1261 all forms of Default are illegal 2021-08-21 05:10:09 +04:30
Aria Moradi 9b129789e9 all forms of Default are illegal 2021-08-21 05:05:01 +04:30
Aria Moradi a76a6d2798 creating a categort named Default is illegal 2021-08-21 03:58:46 +04:30
Aria Moradi 086a760378 update WebUI 2021-08-21 03:55:22 +04:30
Aria Moradi f78c8d4fd8 Default is now a category, no more library 2021-08-21 03:54:16 +04:30
Aria Moradi 7b91489997 better print 2021-08-21 01:20:18 +04:30
Aria Moradi 36a8980c95 TODO block no longer relevant 2021-08-21 01:11:47 +04:30
Aria Moradi 7c65640cb7 include extra chapter data in restore 2021-08-21 00:37:50 +04:30
Aria Moradi d70e68495a restoring with clean db and not installed extensions work 2021-08-21 00:18:03 +04:30
Aria Moradi 2586202772 better comments 2021-08-19 21:11:53 +04:30
Aria Moradi b5f771368a put back dex2jar where it should be 2021-08-19 03:15:35 +04:30
Aria Moradi 0c28320ce3 better debug launcher 2021-08-19 02:54:36 +04:30
Aria Moradi c8b4fbc36b new observation 2021-08-19 02:06:48 +04:30
Aria Moradi e9b07849fe move dex2jar to server, lint 2021-08-19 01:47:26 +04:30
Aria Moradi 409260af6f Merge pull request #176 from Suwayomi/protobuf
protobuf backup support
2021-08-19 00:47:32 +04:30
Aria Moradi d3d53d1a4e initial support for portobuf backup 2021-08-19 00:46:45 +04:30
Aria Moradi e2db191f70 consolidate the external backup api 2021-08-18 23:34:39 +04:30
Aria Moradi d61816734d add all proto backup classes we need 2021-08-18 22:58:56 +04:30
Aria Moradi f4dad8058f Merge branch 'master' into protobuf 2021-08-18 21:51:12 +04:30
Aria Moradi 70bdb375c3 Update README.md 2021-08-18 19:10:46 +04:30
Aria Moradi e724ab0a29 Update README.md 2021-08-18 19:10:16 +04:30
Aria Moradi 7d0ee2ac11 Update README.md 2021-08-18 06:17:04 +04:30
Aria Moradi 59b7e852e2 Update README.md 2021-08-18 06:15:44 +04:30
Aria Moradi b2eb1a391d Update README.md 2021-08-18 05:31:34 +04:30
Aria Moradi 9b3aee98d3 Update CONTRIBUTING.md 2021-08-18 05:30:19 +04:30
Aria Moradi 0476f4144c Update CONTRIBUTING.md 2021-08-18 05:29:24 +04:30
Aria Moradi ed77f45fae Update CONTRIBUTING.md 2021-08-18 05:28:26 +04:30
Aria Moradi 0cd529e746 Update CONTRIBUTING.md 2021-08-18 05:26:55 +04:30
Aria Moradi 5969048318 Update README.md 2021-08-18 05:19:42 +04:30
Aria Moradi d1a7f8baa0 Update README.md 2021-08-18 05:04:22 +04:30
Aria Moradi 18dc936002 Update README.md (#177)
* Update README.md

* Update README.md
2021-08-18 04:50:10 +04:30
Aria Moradi 5af64892e7 Merge branch 'master' into protobuf 2021-08-18 00:28:42 +04:30
Aria Moradi 9bdd9f8aa6 better endpoint urls based on suggestion from @mgn-norm 2021-08-17 21:45:19 +04:30
Aria Moradi f3856f051b protobuf backup endpoints 2021-08-17 20:09:31 +04:30
212 changed files with 6271 additions and 5368 deletions
-4
View File
@@ -45,10 +45,6 @@ jobs:
mkdir -p ~/.gradle
cp .github/runner-files/ci-gradle.properties ~/.gradle/gradle.properties
- name: Download android.jar
run: |
cd master
curl https://raw.githubusercontent.com/Suwayomi/Tachidesk/android-jar/android.jar -o AndroidCompat/lib/android.jar
- name: Build Jar
uses: eskatos/gradle-command-action@v1
+5 -22
View File
@@ -47,11 +47,6 @@ jobs:
mkdir -p ~/.gradle
cp .github/runner-files/ci-gradle.properties ~/.gradle/gradle.properties
- name: Download android.jar
run: |
cd master
curl https://raw.githubusercontent.com/Suwayomi/Tachidesk/android-jar/android.jar -o AndroidCompat/lib/android.jar
- name: Build Jar
uses: eskatos/gradle-command-action@v1
env:
@@ -64,12 +59,6 @@ jobs:
dependencies-cache-enabled: true
configuration-cache-enabled: true
# - name: Mock Build and copy webUI, Build Jar
# run: |
# mkdir -p master/server/build
# cd master/server/build
# echo "test" > Tachidesk-v0.3.8-r583.jar
- name: Generate Tag Name
id: GenTagName
run: |
@@ -78,16 +67,14 @@ jobs:
echo "$genTag"
echo "::set-output name=value::$genTag"
- name: make windows packages
- name: make bundle packages
run: |
cd master/scripts
./windows-bundler.sh win32
./windows-bundler.sh win64
# - name: Mock make windows packages
# run: |
# cd master/server/build
# echo test > Tachidesk-v0.3.8-r580-win32.zip
./unix-bundler.sh linux-x64
./unix-bundler.sh macOS-x64
./unix-bundler.sh macOS-arm64
- name: Checkout preview branch
uses: actions/checkout@v2
@@ -116,11 +103,7 @@ jobs:
uses: ncipollo/release-action@v1
with:
token: ${{ secrets.DEPLOY_PREVIEW_TOKEN }}
artifacts: "master/server/build/*.jar,master/server/build/*.zip"
artifacts: "master/server/build/*.jar,master/server/build/*.zip,master/server/build/*.tar.gz"
owner: "Suwayomi"
repo: "Tachidesk-Server-preview"
tag: ${{ steps.GenTagName.outputs.value }}
- name: Run Docker build workflow
run: |
curl -X POST -H "Accept: application/vnd.github.v3+json" -H "Authorization: token ${{ secrets.DEPLOY_PREVIEW_TOKEN }}" -d '{"ref":"main", "inputs":{"tachidesk_release_type": "preview"}}' https://api.github.com/repos/suwayomi/docker-tachidesk/actions/workflows/build_container_images.yml/dispatches
+15
View File
@@ -0,0 +1,15 @@
name: Docker Build Stable
on:
release:
types: [published]
jobs:
build_publish_docker_container:
runs-on: ubuntu-latest
steps:
- name: run docker build and publish script
run: |
curl -X POST -H "Accept: application/vnd.github.v3+json" -H "Authorization: token ${{ secrets.DEPLOY_PREVIEW_TOKEN }}" -d '{"ref":"main", "inputs":{"tachidesk_release_type": "stable"}}' https://api.github.com/repos/suwayomi/docker-tachidesk/actions/workflows/build_container_images.yml/dispatches
+5 -19
View File
@@ -46,18 +46,6 @@ jobs:
mkdir -p ~/.gradle
cp .github/runner-files/ci-gradle.properties ~/.gradle/gradle.properties
- name: Download android.jar
run: |
cd master
curl https://raw.githubusercontent.com/Suwayomi/Tachidesk/android-jar/android.jar -o AndroidCompat/lib/android.jar
- name: Cache node_modules
uses: actions/cache@v2
with:
path: |
**/webUI/node_modules
key: ${{ runner.os }}-${{ hashFiles('**/webUI/yarn.lock') }}
- name: Build and copy webUI, Build Jar
uses: eskatos/gradle-command-action@v1
env:
@@ -70,23 +58,21 @@ jobs:
dependencies-cache-enabled: true
configuration-cache-enabled: true
- name: make windows packages
- name: make bundle packages
run: |
cd master/scripts
./windows-bundler.sh win32
./windows-bundler.sh win64
./unix-bundler.sh linux-x64
./unix-bundler.sh macOS-x64
./unix-bundler.sh macOS-arm64
- name: Upload Release
uses: xresloader/upload-to-github-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
file: "master/server/build/*.jar;master/server/build/*.zip"
file: "master/server/build/*.jar;master/server/build/*.zip;master/server/build/*.tar.gz"
tags: true
draft: true
verbose: true
- name: Run Docker build workflow
run: |
curl -X POST -H "Accept: application/vnd.github.v3+json" -H "Authorization: token ${{ secrets.DEPLOY_PREVIEW_TOKEN }}" -d '{"ref":"main", "inputs":{"tachidesk_release_type": "stable"}}' https://api.github.com/repos/suwayomi/docker-tachidesk/actions/workflows/build_container_images.yml/dispatches
+12 -7
View File
@@ -1,16 +1,21 @@
# Ignore Gradle project-specific cache directory
# Ignore project-specific local files and dirs
.gradle
.idea
gradle.properties
# But we need these
!.idea/runConfigurations
# Ignore Gradle build output directory
build
server/out
AndroidCompat/out
# WebUI is either to be downloaded on-demand or is a dynamic build asset
server/src/main/resources/WebUI.zip
server/tmp/
server/tachiserver-data/
# bundle asset downlaods
OpenJDK*.zip
electron-*.zip
rcedit-*
# bundling stage downlaoded assets
scripts/OpenJDK*
scripts/zulu*
scripts/electron-*
scripts/rcedit-*
@@ -9,11 +9,12 @@ package xyz.nulldev.ts.config
import net.harawata.appdirs.AppDirsFactory
const val CONFIG_PREFIX = "suwayomi.tachidesk.config"
val ApplicationRootDir: String
get(): String {
return System.getProperty(
"suwayomi.tachidesk.server.rootDir",
AppDirsFactory.getInstance().getUserDataDir("Tachidesk", null, null)
"$CONFIG_PREFIX.server.rootDir",
AppDirsFactory.getInstance().getUserDataDir("Tachidesk", null, null)
)
}
}
@@ -6,7 +6,7 @@ import org.kodein.di.singleton
class ConfigKodeinModule {
fun create() = DI.Module("ConfigManager") {
//Config module
// Config module
bind<ConfigManager>() with singleton { GlobalConfigManager }
}
}
}
@@ -21,7 +21,7 @@ open class ConfigManager {
private val generatedModules = mutableMapOf<Class<out ConfigModule>, ConfigModule>()
val config by lazy { loadConfigs() }
//Public read-only view of modules
// Public read-only view of modules
val loadedModules: Map<Class<out ConfigModule>, ConfigModule>
get() = generatedModules
@@ -42,29 +42,28 @@ open class ConfigManager {
* Load configs
*/
fun loadConfigs(): Config {
//Load reference configs
// Load reference configs
val compatConfig = ConfigFactory.parseResources("compat-reference.conf")
val serverConfig = ConfigFactory.parseResources("server-reference.conf")
val baseConfig =
ConfigFactory.parseMap(
mapOf(
"ts.server.rootDir" to ApplicationRootDir
)
ConfigFactory.parseMap(
mapOf(
"androidcompat.rootDir" to "$ApplicationRootDir/android-compat" // override AndroidCompat's rootDir
)
)
//Load user config
// Load user config
val userConfig =
File(ApplicationRootDir, "server.conf").let {
ConfigFactory.parseFile(it)
}
File(ApplicationRootDir, "server.conf").let {
ConfigFactory.parseFile(it)
}
val config = ConfigFactory.empty()
.withFallback(baseConfig)
.withFallback(userConfig)
.withFallback(compatConfig)
.withFallback(serverConfig)
.resolve()
.withFallback(baseConfig)
.withFallback(userConfig)
.withFallback(compatConfig)
.withFallback(serverConfig)
.resolve()
// set log level early
if (debugLogsEnabled(config)) {
@@ -14,26 +14,31 @@ import kotlin.reflect.KProperty
/**
* Abstract config module.
*/
abstract class ConfigModule(config: Config, moduleName: String = "") {
val overridableWithSysProperty = SystemPropertyOverrideDelegate(config, moduleName)
@Suppress("UNUSED_PARAMETER")
abstract class ConfigModule(config: Config)
/**
* Abstract jvm-commandline-argument-overridable config module.
*/
abstract class SystemPropertyOverridableConfigModule(config: Config, moduleName: String) : ConfigModule(config) {
val overridableConfig = SystemPropertyOverrideDelegate(config, moduleName)
}
/** Defines a config property that is overridable with jvm `-D` commandline arguments prefixed with [CONFIG_PREFIX] */
class SystemPropertyOverrideDelegate(val config: Config, val moduleName: String) {
inline operator fun <R, reified T> getValue(thisRef: R, property: KProperty<*>): T {
val configValue: T = config.getValue(thisRef, property)
val combined = System.getProperty(
"suwayomi.tachidesk.config.$moduleName.${property.name}",
"$CONFIG_PREFIX.$moduleName.${property.name}",
configValue.toString()
)
val asT = when(T::class.simpleName) {
return when (T::class.simpleName) {
"Int" -> combined.toInt()
"Boolean" -> combined.toBoolean()
// add more types as needed
else -> combined
}
return asT as T
else -> combined // covers String
} as T
}
}
@@ -16,5 +16,5 @@ fun setLogLevel(level: Level) {
(KotlinLogging.logger(Logger.ROOT_LOGGER_NAME).underlyingLogger as ch.qos.logback.classic.Logger).level = level
}
fun debugLogsEnabled(config: Config)
= System.getProperty("suwayomi.tachidesk.config.server.debugLogsEnabled", config.getString("server.debugLogsEnabled")).toBoolean()
fun debugLogsEnabled(config: Config) =
System.getProperty("suwayomi.tachidesk.config.server.debugLogsEnabled", config.getString("server.debugLogsEnabled")).toBoolean()
@@ -3,4 +3,4 @@ package xyz.nulldev.ts.config.util
import com.typesafe.config.Config
operator fun Config.get(key: String) = getString(key)
?: throw IllegalStateException("Could not find value for config entry: $key!")
?: throw IllegalStateException("Could not find value for config entry: $key!")
+5 -45
View File
@@ -1,68 +1,28 @@
plugins {
application
kotlin("plugin.serialization")
}
repositories {
mavenCentral()
maven {
url = uri("https://jitpack.io")
}
maven {
url = uri("https://maven.google.com")
}
}
dependencies {
// Android stub library
implementation(fileTree("lib/"))
// JSON
compileOnly("com.google.code.gson:gson:2.8.6")
implementation("com.github.Suwayomi:android-jar:1.0.0")
// XML
compileOnly(group= "xmlpull", name= "xmlpull", version= "1.1.3.1")
compileOnly("xmlpull:xmlpull:1.1.3.4a")
// Config API
implementation(project(":AndroidCompat:Config"))
// APK sig verifier
compileOnly("com.android.tools.build:apksig:4.2.0-alpha13")
compileOnly("com.android.tools.build:apksig:7.1.0-alpha12")
// AndroidX annotations
compileOnly("androidx.annotation:annotation:1.2.0-alpha01")
compileOnly("androidx.annotation:annotation:1.2.0")
// substitute for duktape-android
implementation("org.mozilla:rhino-runtime:1.7.13") // slimmer version of 'org.mozilla:rhino'
implementation("org.mozilla:rhino-engine:1.7.13") // provides the same interface as 'javax.script' a.k.a Nashorn
// Kotlin wrapper around Java Preferences, makes certain things easier
val multiplatformSettingsVersion = "0.7.7"
val multiplatformSettingsVersion = "0.8"
implementation("com.russhwolf:multiplatform-settings-jvm:$multiplatformSettingsVersion")
implementation("com.russhwolf:multiplatform-settings-serialization-jvm:$multiplatformSettingsVersion")
// Android version of SimpleDateFormat
implementation("com.ibm.icu:icu4j:69.1")
}
tasks {
withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
kotlinOptions.freeCompilerArgs = listOf("-Xopt-in=kotlin.RequiresOptIn")
}
}
//def fatJarTask = tasks.getByPath(':AndroidCompat:JVMPatch:fatJar')
//
//// Copy JVM core patches
//task copyJVMPatches(type: Copy) {
// from fatJarTask.outputs.files
// into 'src/main/resources/patches'
//}
//
//compileOnly(Java.dependsOn gradle.includedBuild('dex2jar').task(':dex-translator:assemble')
//compileOnly(Java.dependsOn copyJVMPatches
//copyJVMPatches.dependsOn fatJarTask
//
-1
View File
@@ -1 +0,0 @@
android.jar
@@ -9,8 +9,10 @@ import android.content.Context
class PreferenceManager {
companion object {
@JvmStatic
fun getDefaultSharedPreferences(context: Context)
= context.getSharedPreferences(context.applicationInfo.packageName,
Context.MODE_PRIVATE)!!
fun getDefaultSharedPreferences(context: Context) =
context.getSharedPreferences(
context.applicationInfo.packageName,
Context.MODE_PRIVATE
)!!
}
}
@@ -13,33 +13,49 @@ import com.fasterxml.jackson.annotation.JsonIgnore;
import java.util.Set;
public class MultiSelectListPreference extends DialogPreference {
// reference: https://android.googlesource.com/platform/frameworks/support/+/996971f962fcd554339a7cb2859cef9ca89dbcb7/preference/preference/src/main/java/androidx/preference/MultiSelectListPreference.java
// Note: remove @JsonIgnore and implement methods if any extension ever uses these methods or the variables behind them
public MultiSelectListPreference(Context context) { super(context); }
private CharSequence[] entries;
private CharSequence[] entryValues;
public MultiSelectListPreference(Context context) {
super(context);
}
public void setEntries(CharSequence[] entries) {
this.entries = entries;
}
public CharSequence[] getEntries() {
return entries;
}
public void setEntryValues(CharSequence[] entryValues) {
this.entryValues = entryValues;
}
public CharSequence[] getEntryValues() {
return entryValues;
}
@JsonIgnore
public void setEntries(CharSequence[] entries) { throw new RuntimeException("Stub!"); }
public void setValues(Set<String> values) {
throw new RuntimeException("Stub!");
}
@JsonIgnore
public CharSequence[] getEntries() { throw new RuntimeException("Stub!"); }
public Set<String> getValues() {
throw new RuntimeException("Stub!");
}
@JsonIgnore
public void setEntryValues(CharSequence[] entryValues) { throw new RuntimeException("Stub!"); }
@JsonIgnore
public CharSequence[] getEntryValues() { throw new RuntimeException("Stub!"); }
@JsonIgnore
public void setValues(Set<String> values) { throw new RuntimeException("Stub!"); }
@JsonIgnore
public Set<String> getValues() { throw new RuntimeException("Stub!"); }
public int findIndexOfValue(String value) { throw new RuntimeException("Stub!"); }
public int findIndexOfValue(String value) {
throw new RuntimeException("Stub!");
}
/** Tachidesk specific API */
@Override
public String getDefaultValueType() {
return "Set";
return "Set<String>";
}
}
@@ -10,6 +10,7 @@ package androidx.preference;
import android.content.Context;
import android.content.SharedPreferences;
import com.fasterxml.jackson.annotation.JsonIgnore;
import java.util.Set;
/**
* A minimal implementation of androidx.preference.Preference
@@ -113,18 +114,22 @@ public class Preference {
}
/** Tachidesk specific API */
@SuppressWarnings("unchecked")
public Object getCurrentValue() {
switch (getDefaultValueType()) {
case "String":
return sharedPreferences.getString(key, (String)defaultValue);
case "Boolean":
return sharedPreferences.getBoolean(key, (Boolean)defaultValue);
case "Set<String>":
return sharedPreferences.getStringSet(key, (Set<String>)defaultValue);
default:
throw new RuntimeException("Unsupported type");
}
}
/** Tachidesk specific API */
@SuppressWarnings("unchecked")
public void saveNewValue(Object value) {
switch (getDefaultValueType()) {
case "String":
@@ -133,6 +138,9 @@ public class Preference {
case "Boolean":
sharedPreferences.edit().putBoolean(key, (Boolean)value).apply();
break;
case "Set<String>":
sharedPreferences.edit().putStringSet(key, (Set<String>)value).apply();
break;
default:
throw new RuntimeException("Unsupported type");
}
@@ -13,7 +13,10 @@ import com.fasterxml.jackson.annotation.JsonIgnore;
public class TwoStatePreference extends Preference {
// Note: remove @JsonIgnore and implement methods if any extension ever uses these methods or the variables behind them
public TwoStatePreference(Context context) { super(context); }
public TwoStatePreference(Context context) {
super(context);
setDefaultValue(false);
}
@JsonIgnore
public boolean isChecked() { throw new RuntimeException("Stub!"); }
@@ -5,4 +5,4 @@ import android.arch.persistence.db.framework.FrameworkSQLiteOpenHelperFactory
class RequerySQLiteOpenHelperFactory {
fun create(configuration: SupportSQLiteOpenHelper.Configuration) = FrameworkSQLiteOpenHelperFactory().create(configuration)
}
}
@@ -14,4 +14,4 @@ class AndroidCompat {
application.attach(context)
application.onCreate()
}
}
}
@@ -14,7 +14,7 @@ class AndroidCompatInitializer {
fun init() {
DI.global.addImport(AndroidCompatModule().create())
//Register config modules
// Register config modules
GlobalConfigManager.registerModules(
FilesConfigModule.register(GlobalConfigManager.config),
ApplicationInfoConfigModule.register(GlobalConfigManager.config),
@@ -29,7 +29,7 @@ class AndroidCompatModule {
bind<PackageController>() with singleton { PackageController() }
//Context
// Context
bind<CustomContext>() with singleton { CustomContext() }
bind<Context>() with singleton {
val context: Context by DI.global.instance<CustomContext>()
@@ -1,6 +1,7 @@
package xyz.nulldev.androidcompat.config
import com.typesafe.config.Config
import io.github.config4k.getValue
import xyz.nulldev.ts.config.ConfigModule
/**
@@ -8,11 +9,11 @@ import xyz.nulldev.ts.config.ConfigModule
*/
class ApplicationInfoConfigModule(config: Config) : ConfigModule(config) {
val packageName = config.getString("packageName")!!
val debug = config.getBoolean("debug")
val packageName: String by config
val debug: Boolean by config
companion object {
fun register(config: Config)
= ApplicationInfoConfigModule(config.getConfig("android.app"))
fun register(config: Config) =
ApplicationInfoConfigModule(config.getConfig("android.app"))
}
}
@@ -1,6 +1,7 @@
package xyz.nulldev.androidcompat.config
import com.typesafe.config.Config
import io.github.config4k.getValue
import xyz.nulldev.ts.config.ConfigModule
/**
@@ -8,26 +9,26 @@ import xyz.nulldev.ts.config.ConfigModule
*/
class FilesConfigModule(config: Config) : ConfigModule(config) {
val dataDir = config.getString("dataDir")!!
val filesDir = config.getString("filesDir")!!
val noBackupFilesDir = config.getString("noBackupFilesDir")!!
val externalFilesDirs: MutableList<String> = config.getStringList("externalFilesDirs")!!
val obbDirs: MutableList<String> = config.getStringList("obbDirs")!!
val cacheDir = config.getString("cacheDir")!!
val codeCacheDir = config.getString("codeCacheDir")!!
val externalCacheDirs: MutableList<String> = config.getStringList("externalCacheDirs")!!
val externalMediaDirs: MutableList<String> = config.getStringList("externalMediaDirs")!!
val rootDir = config.getString("rootDir")!!
val externalStorageDir = config.getString("externalStorageDir")!!
val downloadCacheDir = config.getString("downloadCacheDir")!!
val databasesDir = config.getString("databasesDir")!!
val dataDir: String by config
val filesDir: String by config
val noBackupFilesDir: String by config
val externalFilesDirs: MutableList<String> by config
val obbDirs: MutableList<String> by config
val cacheDir: String by config
val codeCacheDir: String by config
val externalCacheDirs: MutableList<String> by config
val externalMediaDirs: MutableList<String> by config
val rootDir: String by config
val externalStorageDir: String by config
val downloadCacheDir: String by config
val databasesDir: String by config
val prefsDir = config.getString("prefsDir")!!
val prefsDir: String by config
val packageDir = config.getString("packageDir")!!
val packageDir: String by config
companion object {
fun register(config: Config)
= FilesConfigModule(config.getConfig("android.files"))
fun register(config: Config) =
FilesConfigModule(config.getConfig("android.files"))
}
}
}
@@ -1,10 +1,11 @@
package xyz.nulldev.androidcompat.config
import com.typesafe.config.Config
import io.github.config4k.getValue
import xyz.nulldev.ts.config.ConfigModule
class SystemConfigModule(val config: Config) : ConfigModule(config) {
val isDebuggable = config.getBoolean("isDebuggable")
val isDebuggable: Boolean by config
val propertyPrefix = "properties."
@@ -15,7 +16,7 @@ class SystemConfigModule(val config: Config) : ConfigModule(config) {
fun hasProperty(property: String) = config.hasPath("$propertyPrefix$property")
companion object {
fun register(config: Config)
= SystemConfigModule(config.getConfig("android.system"))
fun register(config: Config) =
SystemConfigModule(config.getConfig("android.system"))
}
}
@@ -29,7 +29,7 @@ class ScrollableResultSet(val parent: ResultSet) : ResultSet by parent {
val parentMetadata = parent.metaData
val columnCount = parentMetadata.columnCount
val columnLabels = (1 .. columnCount).map {
val columnLabels = (1..columnCount).map {
parentMetadata.getColumnLabel(it)
}.toTypedArray()
@@ -41,10 +41,10 @@ class ScrollableResultSet(val parent: ResultSet) : ResultSet by parent {
// How can we optimize this?
// We need to fill the cache as the set is loaded
//Fill cache
while(parent.next()) {
// Fill cache
while (parent.next()) {
cachedContent += ResultSetEntry().apply {
for(i in 1 .. columnCount)
for (i in 1..columnCount)
data += parent.getObject(i)
}
resultSetLength++
@@ -60,8 +60,8 @@ class ScrollableResultSet(val parent: ResultSet) : ResultSet by parent {
}
private fun internalMove(row: Int) {
if(cursor < 0) cursor = 0
else if(cursor > resultSetLength + 1) cursor = resultSetLength + 1
if (cursor < 0) cursor = 0
else if (cursor > resultSetLength + 1) cursor = resultSetLength + 1
else cursor = row
}
@@ -75,10 +75,10 @@ class ScrollableResultSet(val parent: ResultSet) : ResultSet by parent {
return obj(cachedFindColumn(column))
}
private fun cachedFindColumn(column: String?)
= columnCache.getOrPut(column!!, {
findColumn(column)
})
private fun cachedFindColumn(column: String?) =
columnCache.getOrPut(column!!, {
findColumn(column)
})
override fun getNClob(columnIndex: Int): NClob {
return obj(columnIndex) as NClob
@@ -157,27 +157,27 @@ class ScrollableResultSet(val parent: ResultSet) : ResultSet by parent {
}
override fun getDate(columnIndex: Int): Date {
//TODO Maybe?
// TODO Maybe?
notImplemented()
}
override fun getDate(columnLabel: String?): Date {
//TODO Maybe?
// TODO Maybe?
notImplemented()
}
override fun getDate(columnIndex: Int, cal: Calendar?): Date {
//TODO Maybe?
// TODO Maybe?
notImplemented()
}
override fun getDate(columnLabel: String?, cal: Calendar?): Date {
//TODO Maybe?
// TODO Maybe?
notImplemented()
}
override fun beforeFirst() {
//TODO Maybe?
// TODO Maybe?
notImplemented()
}
@@ -202,12 +202,12 @@ class ScrollableResultSet(val parent: ResultSet) : ResultSet by parent {
}
override fun getBigDecimal(columnIndex: Int, scale: Int): BigDecimal {
//TODO Maybe?
// TODO Maybe?
notImplemented()
}
override fun getBigDecimal(columnLabel: String?, scale: Int): BigDecimal {
//TODO Maybe?
// TODO Maybe?
notImplemented()
}
@@ -236,22 +236,22 @@ class ScrollableResultSet(val parent: ResultSet) : ResultSet by parent {
}
override fun getTime(columnIndex: Int): Time {
//TODO Maybe?
// TODO Maybe?
notImplemented()
}
override fun getTime(columnLabel: String?): Time {
//TODO Maybe?
// TODO Maybe?
notImplemented()
}
override fun getTime(columnIndex: Int, cal: Calendar?): Time {
//TODO Maybe?
// TODO Maybe?
notImplemented()
}
override fun getTime(columnLabel: String?, cal: Calendar?): Time {
//TODO Maybe?
// TODO Maybe?
notImplemented()
}
@@ -272,28 +272,28 @@ class ScrollableResultSet(val parent: ResultSet) : ResultSet by parent {
}
override fun absolute(row: Int): Boolean {
if(row > 0) {
if (row > 0) {
internalMove(row)
} else {
last()
for(i in 1 .. row)
for (i in 1..row)
previous()
}
return cursorValid()
}
override fun getSQLXML(columnIndex: Int): SQLXML? {
//TODO Maybe?
// TODO Maybe?
notImplemented()
}
override fun getSQLXML(columnLabel: String?): SQLXML? {
//TODO Maybe?
// TODO Maybe?
notImplemented()
}
override fun <T : Any?> unwrap(iface: Class<T>?): T {
if(thisIsWrapperFor(iface))
if (thisIsWrapperFor(iface))
return this as T
else
return parent.unwrap(iface)
@@ -426,12 +426,12 @@ class ScrollableResultSet(val parent: ResultSet) : ResultSet by parent {
}
override fun getBlob(columnIndex: Int): Blob {
//TODO Maybe?
// TODO Maybe?
notImplemented()
}
override fun getBlob(columnLabel: String?): Blob {
//TODO Maybe?
// TODO Maybe?
notImplemented()
}
@@ -500,12 +500,12 @@ class ScrollableResultSet(val parent: ResultSet) : ResultSet by parent {
}
override fun getObject(columnIndex: Int, map: MutableMap<String, Class<*>>?): Any {
//TODO Maybe?
// TODO Maybe?
notImplemented()
}
override fun getObject(columnLabel: String?, map: MutableMap<String, Class<*>>?): Any {
//TODO Maybe?
// TODO Maybe?
notImplemented()
}
@@ -531,9 +531,9 @@ class ScrollableResultSet(val parent: ResultSet) : ResultSet by parent {
}
private fun castToLong(obj: Any?): Long {
if(obj == null) return 0
else if(obj is Long) return obj
else if(obj is Number) return obj.toLong()
if (obj == null) return 0
else if (obj is Long) return obj
else if (obj is Number) return obj.toLong()
else throw IllegalStateException("Object is not a long!")
}
@@ -546,12 +546,12 @@ class ScrollableResultSet(val parent: ResultSet) : ResultSet by parent {
}
override fun getClob(columnIndex: Int): Clob {
//TODO Maybe?
// TODO Maybe?
notImplemented()
}
override fun getClob(columnLabel: String?): Clob {
//TODO Maybe?
// TODO Maybe?
notImplemented()
}
@@ -604,12 +604,12 @@ class ScrollableResultSet(val parent: ResultSet) : ResultSet by parent {
}
override fun getArray(columnIndex: Int): Array {
//TODO Maybe?
// TODO Maybe?
notImplemented()
}
override fun getArray(columnLabel: String?): Array {
//TODO Maybe?
// TODO Maybe?
notImplemented()
}
@@ -688,32 +688,32 @@ class ScrollableResultSet(val parent: ResultSet) : ResultSet by parent {
}
override fun getTimestamp(columnIndex: Int): Timestamp {
//TODO Maybe?
// TODO Maybe?
notImplemented()
}
override fun getTimestamp(columnLabel: String?): Timestamp {
//TODO Maybe?
// TODO Maybe?
notImplemented()
}
override fun getTimestamp(columnIndex: Int, cal: Calendar?): Timestamp {
//TODO Maybe?
// TODO Maybe?
notImplemented()
}
override fun getTimestamp(columnLabel: String?, cal: Calendar?): Timestamp {
//TODO Maybe?
// TODO Maybe?
notImplemented()
}
override fun getRef(columnIndex: Int): Ref {
//TODO Maybe?
// TODO Maybe?
notImplemented()
}
override fun getRef(columnLabel: String?): Ref {
//TODO Maybe?
// TODO Maybe?
notImplemented()
}
@@ -792,12 +792,12 @@ class ScrollableResultSet(val parent: ResultSet) : ResultSet by parent {
}
override fun getRowId(columnIndex: Int): RowId {
//TODO Maybe?
// TODO Maybe?
notImplemented()
}
override fun getRowId(columnLabel: String?): RowId {
//TODO Maybe?
// TODO Maybe?
notImplemented()
}
@@ -848,4 +848,4 @@ class ScrollableResultSet(val parent: ResultSet) : ResultSet by parent {
class ResultSetEntry {
val data = mutableListOf<Any?>()
}
}
}
@@ -12,11 +12,11 @@ import com.russhwolf.settings.ExperimentalSettingsApi
import com.russhwolf.settings.ExperimentalSettingsImplementation
import com.russhwolf.settings.JvmPreferencesSettings
import com.russhwolf.settings.serialization.decodeValue
import com.russhwolf.settings.serialization.decodeValueOrNull
import com.russhwolf.settings.serialization.encodeValue
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerializationException
import kotlinx.serialization.builtins.SetSerializer
import kotlinx.serialization.builtins.nullable
import kotlinx.serialization.builtins.serializer
import java.util.prefs.PreferenceChangeListener
import java.util.prefs.Preferences
@@ -40,13 +40,13 @@ class JavaSharedPreferences(key: String) : SharedPreferences {
}
}
override fun getStringSet(key: String, defValues: MutableSet<String>?): MutableSet<String>? {
override fun getStringSet(key: String, defValues: Set<String>?): Set<String>? {
try {
return if (defValues != null) {
preferences.decodeValue(SetSerializer(String.serializer()).nullable, key, defValues)
preferences.decodeValue(SetSerializer(String.serializer()), key, defValues)
} else {
preferences.decodeValue(SetSerializer(String.serializer()).nullable, key, null)
}?.toMutableSet()
preferences.decodeValueOrNull(SetSerializer(String.serializer()), key)
}
} catch (e: SerializationException) {
throw ClassCastException("$key was not a StringSet")
}
@@ -174,4 +174,4 @@ class JavaSharedPreferences(key: String) : SharedPreferences {
javaPreferences.removeNode()
return true
}
}
}
@@ -14,8 +14,6 @@ import java.io.File
import javax.imageio.ImageIO
import javax.xml.parsers.DocumentBuilderFactory
data class InstalledPackage(val root: File) {
val apk = File(root, "package.apk")
val jar = File(root, "translated.jar")
@@ -40,20 +38,24 @@ data class InstalledPackage(val root: File) {
}?.filter {
it.tagName == "meta-data"
}?.map {
putString(it.attributes.getNamedItem("android:name").nodeValue,
it.attributes.getNamedItem("android:value").nodeValue)
putString(
it.attributes.getNamedItem("android:name").nodeValue,
it.attributes.getNamedItem("android:value").nodeValue
)
}
}
it.signatures = (parsed.apkSingers.flatMap { it.certificateMetas }
/*+ parsed.apkV2Singers.flatMap { it.certificateMetas }*/) // Blocked by: https://github.com/hsiafan/apk-parser/issues/72
.map { Signature(it.data) }.toTypedArray()
it.signatures = (
parsed.apkSingers.flatMap { it.certificateMetas }
/*+ parsed.apkV2Singers.flatMap { it.certificateMetas }*/
) // Blocked by: https://github.com/hsiafan/apk-parser/issues/72
.map { Signature(it.data) }.toTypedArray()
}
fun verify(): Boolean {
val res = ApkVerifier.Builder(apk)
.build()
.verify()
.build()
.verify()
return res.isVerified
}
@@ -69,7 +71,7 @@ data class InstalledPackage(val root: File) {
}.sortedByDescending { it.width * it.height }.firstOrNull() ?: return
ImageIO.write(read, "png", icon)
} catch(e: Exception) {
} catch (e: Exception) {
icon.delete()
}
}
@@ -77,7 +79,7 @@ data class InstalledPackage(val root: File) {
fun writeJar() {
try {
Dex2jar.from(apk).to(jar.toPath())
} catch(e: Exception) {
} catch (e: Exception) {
jar.delete()
}
}
@@ -92,4 +94,4 @@ data class InstalledPackage(val root: File) {
return out
}
}
}
}
@@ -48,7 +48,7 @@ class PackageController {
if (!installed.jar.exists()) {
throw IllegalStateException("Failed to translate APK dex!")
}
} catch(t: Throwable) {
} catch (t: Throwable) {
root.deleteRecursively()
throw t
}
@@ -63,7 +63,7 @@ class PackageController {
}
fun deletePackage(pack: InstalledPackage) {
if(!pack.root.exists()) error("Package was never installed!")
if (!pack.root.exists()) error("Package was never installed!")
val packageName = pack.info.packageName
pack.root.deleteRecursively()
@@ -74,7 +74,7 @@ class PackageController {
fun findPackage(packageName: String): InstalledPackage? {
val file = File(androidFiles.packagesDir, packageName)
return if(file.exists())
return if (file.exists())
InstalledPackage(file)
else
null
@@ -84,4 +84,4 @@ class PackageController {
val pkgName = ApkParsers.getMetaInfo(apkFile).packageName
return findPackage(pkgName)?.jar
}
}
}
@@ -24,4 +24,4 @@ fun ApkMeta.toPackageInfo(apk: File): PackageInfo {
sourceDir = apk.absolutePath
}
}
}
}
@@ -24,4 +24,4 @@ interface Resource {
fun getType(): Class<out Resource>
fun getValue(): Any?
}
}
@@ -27,10 +27,10 @@ class ServiceSupport {
runningServices[name] = service
//Setup service
// Setup service
thread {
callOnCreate(service)
//TODO Handle more complex cases
// TODO Handle more complex cases
service.onStartCommand(intent, 0, 0)
}
}
@@ -43,7 +43,7 @@ class ServiceSupport {
fun stopService(name: String) {
logger.debug { "Stopping service: $name" }
val service = runningServices.remove(name)
if(service == null) {
if (service == null) {
logger.warn { "An attempt was made to stop a service that is not running: $name" }
} else {
thread {
@@ -63,6 +63,6 @@ class ServiceSupport {
fun serviceInstanceFromClass(className: String): Service {
val clazzObj = Class.forName(className)
return clazzObj.getDeclaredConstructor().newInstance() as? Service
?: throw IllegalArgumentException("$className is not a Service!")
?: throw IllegalArgumentException("$className is not a Service!")
}
}
@@ -27,7 +27,7 @@ object KodeinGlobalHelper {
@JvmStatic
@Suppress("UNCHECKED_CAST")
fun <T : Any> instance(type: Class<T>, kodein: DI? = null): T {
return when(type) {
return when (type) {
AndroidFiles::class.java -> {
val instance: AndroidFiles by (kodein ?: kodein()).instance()
instance as T
@@ -64,5 +64,4 @@ object KodeinGlobalHelper {
fun <T : Any> instance(type: Class<T>): T {
return instance(type, null)
}
}
@@ -1,36 +1,12 @@
# AndroidComapt Root dir
androidcompat.rootDir = androidcompat-root
# Allow/disallow preference changes (useful for demos)
ts.server.allowConfigChanges = true
# Enable the WebUI? Note: The API and multi-user sync server ui will remain available even if the WebUI is disabled
ts.server.enableWebUi = true
# 'true' to use the old, buggy/memory-leaking WebUI
ts.server.useOldWebUi = false
# 'true' to pretty print all JSON API responses
ts.server.prettyPrintApi = false
# List of blacklisted/whitelisted API endpoints/operation IDs
ts.server.disabledApiEndpoints = []
ts.server.enabledApiEndpoints = []
# Message to print in the console when the API has finished booting
ts.server.httpInitializedPrintMessage = ""
# Use external folder for static files
ts.server.useExternalStaticFiles = false
ts.server.externalStaticFilesFolder = ""
# Root storage dir
ts.server.rootDir = tachiserver-data
# Dir to store JVM patches
ts.server.patchesDir = ${ts.server.rootDir}/patches
####################### `android.files` (FilesConfigModule) #######################
# Storage dir for the emulated Android app
android.files.rootDir = ${ts.server.rootDir}/android-compat/appdata
android.files.rootDir = ${androidcompat.rootDir}/appdata
# External storage dir for the emulated Android app's
android.files.externalStorageDir = ${ts.server.rootDir}/android-compat/extappdata
android.files.externalStorageDir = ${androidcompat.rootDir}/extappdata
# Internal Android directories
android.files.dataDir = ${android.files.rootDir}/data
@@ -48,37 +24,16 @@ android.files.externalCacheDirs = [${android.files.externalStorageDir}/cache]
android.files.externalMediaDirs = [${android.files.externalStorageDir}/media]
android.files.downloadCacheDir = ${android.files.externalStorageDir}/downloadCache
android.files.packageDir = ${ts.server.rootDir}/android-compat/packages
android.files.packageDir = ${androidcompat.rootDir}/android-compat/packages
####################### `android.app` (ApplicationInfoConfigModule) #######################
# Emulated Android app package name
android.app.packageName = eu.kanade.tachiyomi
# Debug mode for the emulated Android app
android.app.debug = true
####################### `android.system` (SystemConfigModule) #######################
# Whether or not the emulated Android system is debuggable
android.system.isDebuggable = true
# Is the multi-user sync server enabled? Does not affect the single-user sync server included in the API.
ts.syncd.enable = false
# The URL of this server (displayed in the sync server web ui)
ts.syncd.baseUrl = "http://example.com"
# 'true' to disable the API and only enable the multi-user sync server
ts.syncd.syncOnlyMode = false
# The root directory to store synchronized data
ts.syncd.rootDir = ${ts.server.rootDir}/sync/accounts
# Location to store config files for the sandbox
ts.syncd.sandboxedConfig = ${ts.server.rootDir}/sync/sandboxed_config.config
# Recaptcha stuff for signup/login
ts.syncd.recaptcha.siteKey = ""
ts.syncd.recaptcha.secret = ""
# Sync server display name
ts.syncd.name = "Tachiyomi sync server"
# Header used to forward the IP to the multi-user sync server if the server is behind a reverse proxy
ts.syncd.ipHeader = ""
+11
View File
@@ -0,0 +1,11 @@
# Server: v0.X.Y-next + WebUI: rXXX
## TL;DR
- N/A
## Tachidesk-Server Changelog
- N/A
## Tachidesk-WebUI Changelog
- N/A
+387
View File
@@ -0,0 +1,387 @@
# Server: v0.6.0 + WebUI: r893
## TL;DR
- WebUI design went through a whole lot of changes, including
- Got rid of hamburger menu, now we have a custom mobile navbar
- Unread and Download count badges
- Back button so better electron experience
- There's a whole lot more that I'm too lazy to explore.
- Completely removed anime support
- Fixed category reordering
- Added support for search filters(Server side only)
- Added support for updating library(Server side only)
- A bunch of API breaking changes(hence why bumping to v0.6.0)!
## Tachidesk-Server Changelog
- (r996) cleanup (by @AriaMoradi)
- (r999) better cleaning algorithm (by @AriaMoradi)
- (r1007) remove anime support (by @AriaMoradi)
- (r1009) Fix tests ([#226](https://github.com/Suwayomi/Tachidesk-Server/pull/226) by @ntbm)
- (r1010) Expose unread and download count of Manga in category api ([#227](https://github.com/Suwayomi/Tachidesk-Server/pull/227) by @ntbm)
- (r1011) add Cache Header to Thumbnail Response for improved library performance ([#228](https://github.com/Suwayomi/Tachidesk-Server/pull/228) by @ntbm)
- (r1013) Fix unread and download counts casing ([#230](https://github.com/Suwayomi/Tachidesk-Server/pull/230) by @Syer10)
- (r1014) Fix broken test ([#231](https://github.com/Suwayomi/Tachidesk-Server/pull/231) by @ntbm)
- (r1016) Fix category reorder Endpoint. Added Test for Category Reorder ([#232](https://github.com/Suwayomi/Tachidesk-Server/pull/232) by @ntbm)
- (r1017) change windows bundle names (by @AriaMoradi)
- (r1018) improve tests (by @AriaMoradi)
- (r1019) allow injecting Sources (by @AriaMoradi)
- (r1020) update (by @AriaMoradi)
- (r1021) fix credit (by @AriaMoradi)
- (r1022) cleanup (by @AriaMoradi)
- (r1023) refactor (by @AriaMoradi)
- (r1024) refactor (by @AriaMoradi)
- (r1025) implement Source Filters (by @AriaMoradi)
- (r1026) ignore build artifacts generated by teting (by @AriaMoradi)
- (r1027) convert request type (by @AriaMoradi)
- (r1028) Update CONTRIBUTING.md (by @AriaMoradi)
- (r1029) stop supporting zero based image storage ([#242](https://github.com/Suwayomi/src/pull/242) by @AriaMoradi)
- (r1030) add manga data to download queue object ([#244](https://github.com/Suwayomi/src/pull/244) by @AriaMoradi)
- (r1031) Fix Manga Meta, add Manga Meta test ([#245](https://github.com/Suwayomi/src/pull/245) by @Syer10)
- (r1032) add pagination to recentChapters ([#246](https://github.com/Suwayomi/src/pull/246) by @AriaMoradi)
- (r1033) update (by @AriaMoradi)
- (r1034) Implement Update of Library/Category ([#235](https://github.com/Suwayomi/src/pull/235) by @ntbm)
- (r1035) update (by @AriaMoradi)
- (r1036) Mention the existence of Mahor's Tachidesk-GTK (by @AriaMoradi)
- (r1037) Add a Kotlin DSL for endpoint documentation ([#249](https://github.com/Suwayomi/Tachidesk-Server/pull/249) by @Syer10)
- (r1038) update (by @AriaMoradi)
- (r1039) update (by @AriaMoradi)
- (r1040) cleanup directory names ([#251](https://github.com/Suwayomi/Tachidesk-Server/pull/251) by @AriaMoradi)
- (r1041) Fix first page not being detected correctly ([#253](https://github.com/Suwayomi/Tachidesk-Server/pull/253) by @AriaMoradi)
- (r1042) Update README.md (by @AriaMoradi)
- (r1043) Update README.md (by @AriaMoradi)
- (r1044) migrate application directories ([#255](https://github.com/Suwayomi/Tachidesk-Server/pull/255) by @AriaMoradi)
- (r1045) add support for MultiSelectListPreference ([#258](https://github.com/Suwayomi/Tachidesk-Server/pull/258) by @AriaMoradi)
- (r1046) empty searchTerm support ([#259](https://github.com/Suwayomi/Tachidesk-Server/pull/259) by @AriaMoradi)
## Tachidesk-WebUI
- (r821) add Permanent sidebar for desktop widths([#46](https://github.com/Suwayomi/Tachidesk-WebUI/pull/46) by @abhijeetChawla)
- (r822) Fix Local Source being missing (by @AriaMoradi)
- (r823) fix the ugliness of bare messages (by @AriaMoradi)
- (r824) add pull request template (by @AriaMoradi)
- (r825) add Unread badges ([#48](https://github.com/Suwayomi/Tachidesk-WebUI/pull/48) by @ntbm)
- (r826) Back button implementation ([#47](https://github.com/Suwayomi/Tachidesk-WebUI/pull/47) by @abhijeetChawla)
- (r827) remove redundant '/manga' prefix from paths (by @AriaMoradi)
- (r828) refactor (by @AriaMoradi)
- (r829) put Sources and Extensions in the same screen (by @AriaMoradi)
- (r830) Set Fallback Image for broken Thumbnails ([#50](https://github.com/Suwayomi/Tachidesk-WebUI/pull/50) by @ntbm)
- (r833) Apply Api changes for unread badges ([#52](https://github.com/Suwayomi/Tachidesk-WebUI/pull/52) by @ntbm)
- (r834) add EmptyView to DownloadQueue, refactro strings ([#53](https://github.com/Suwayomi/Tachidesk-WebUI/pull/53) by @abhijeetChawla)
- (r835) Bottom navbar for mobile ([#51](https://github.com/Suwayomi/Tachidesk-WebUI/pull/51) by @abhijeetChawla)
- (r836) Implement Unread Filter for Library ([#54](https://github.com/Suwayomi/Tachidesk-WebUI/pull/54) by @ntbm)
- (r837) fix navbar broken logic (by @AriaMoradi)
- (r838) fix navbar (by @AriaMoradi)
- (r839) refactor (by @AriaMoradi)
- (r840) refactor (by @AriaMoradi)
- (r841) refactor (by @AriaMoradi)
- (r842) show different NavbarItems depending on device width (by @AriaMoradi)
- (r843) remove text decoration (by @AriaMoradi)
- (r844) fancy icon based on if path selected (by @AriaMoradi)
- (r845) custom Extension icon, google's version is shit (by @AriaMoradi)
- (r846) refactor (by @AriaMoradi)
- (r848) move info (by @AriaMoradi)
- (r849) add Search to Library ([#55](https://github.com/Suwayomi/Tachidesk-WebUI/pull/55) by @ntbm)
- (r850) add aspect ratio to the manga card. ([#56](https://github.com/Suwayomi/Tachidesk-WebUI/pull/56) by @abhijeetChawla)
- (r851) better wording (by @AriaMoradi)
- (r852) reorder nav buttons (by @AriaMoradi)
- (r853) nicer gradient (by @AriaMoradi)
- (r854) refactor MangaCard (by @AriaMoradi)
- (r855) closes #58 (by @AriaMoradi
- (r856) Add Resume Reading FAB Manga screen ([#59](https://github.com/Suwayomi/Tachidesk-WebUI/pull/59) by @abhijeetChawla)
- (r857) add filter and badge for `downloadCount` ([#62](https://github.com/Suwayomi/Tachidesk-WebUI/pull/62) by @abhijeetChawla)
- (r858) add issue template (by @AriaMoradi)
- (r859) Change color of navbar in light mode ([#65](https://github.com/Suwayomi/Tachidesk-WebUI/pull/65) by @abhijeetChawla)
- (r860) fix manga FAB margins ([#66](https://github.com/Suwayomi/Tachidesk-WebUI/pull/66) by @AriaMoradi)
- (r861) remove extra scrollbar on mobile ([#67](https://github.com/Suwayomi/Tachidesk-WebUI/pull/67) by @AriaMoradi)
- (r862) Fix Bad messages in Library Appbar search ([#70](https://github.com/Suwayomi/Tachidesk-WebUI/pull/70) by @ntbm)
- (r863) ban the style prop (by @AriaMoradi)
- (r864) Updates pagination update ([#68](https://github.com/Suwayomi/Tachidesk-WebUI/pull/68) by @AriaMoradi)
- (r865) make the whole chapter card into a button ([#73](https://github.com/Suwayomi/Tachidesk-WebUI/pull/73) by @AriaMoradi)
- (r866) fix chapter actions not working if manga is not fetched online ([#74](https://github.com/Suwayomi/Tachidesk-WebUI/pull/74) by @AriaMoradi)
- (r867) migrate some components to Mui5 new styling system ([#72](https://github.com/Suwayomi/Tachidesk-WebUI/pull/72) by @abhijeetChawla)
- (r868) load first page on read manga ([#76](https://github.com/Suwayomi/Tachidesk-WebUI/pull/76) by @AriaMoradi)
- (r869) Revert "migrate some components to Mui5 new styling system ([#72](https://github.com/Suwayomi/Tachidesk-WebUI/pull/72))" (by @AriaMoradi)
- (r870) migrate Backup to Mui 5 ([#106](https://github.com/Suwayomi/Tachidesk-WebUI/pull/106) by @AriaMoradi)
- (r871) migrate EmptyView to Mui 5 ([#95](https://github.com/Suwayomi/Tachidesk-WebUI/pull/95) by @AriaMoradi)
- (r872) migrate CategorySelect to Mui 5 ([#85](https://github.com/Suwayomi/Tachidesk-WebUI/pull/85) by @AriaMoradi)
- (r873) migrate LibraryOptions to Mui 5 ([#83](https://github.com/Suwayomi/Tachidesk-WebUI/pull/83) by @AriaMoradi)
- (r874) migrate ChapterCard.tsx to Mui 5 ([#80](https://github.com/Suwayomi/Tachidesk-WebUI/pull/80) by @AriaMoradi)
- (r875) migrate App.tsx to Mui 5 ([#79](https://github.com/Suwayomi/Tachidesk-WebUI/pull/79) by @AriaMoradi)
- (r876) migrate SourceConfigure to Mui 5 ([#103](https://github.com/Suwayomi/Tachidesk-WebUI/pull/103) by @AriaMoradi)
- (r877) migrate Settings to Mui 5 ([#102](https://github.com/Suwayomi/Tachidesk-WebUI/pull/102) by @AriaMoradi)
- (r878) migrate Updates to Mui 5 ([#104](https://github.com/Suwayomi/Tachidesk-WebUI/pull/104) by @AriaMoradi)
- (r879) Save tabs number in Url to persist tab when go to other paths ([#78](https://github.com/Suwayomi/Tachidesk-WebUI/pull/78) by @abhijeetChawla)
- (r880) migrate LangSelect to Mui 5 ([#86](https://github.com/Suwayomi/Tachidesk-WebUI/pull/86) by @AriaMoradi)
- (r881) migrate ExtensionCard.tsx to Mui 5 ([#81](https://github.com/Suwayomi/Tachidesk-WebUI/pull/81) by @AriaMoradi)
- (r882) migrate SingleSearch to Mui 5 ([#101](https://github.com/Suwayomi/Tachidesk-WebUI/pull/101) by @AriaMoradi)
- (r883) migrate LoadingPlaceholder to Mui 5 ([#96](https://github.com/Suwayomi/Tachidesk-WebUI/pull/96) by @AriaMoradi)
- (r884) migrate About to Mui 5 ([#105](https://github.com/Suwayomi/Tachidesk-WebUI/pull/105) by @AriaMoradi)
- (r885) migrate SourceCard to Mui 5 ([#82](https://github.com/Suwayomi/Tachidesk-WebUI/pull/82) by @AriaMoradi)
- (r886) migrate Manga to Mui 5 ([#99](https://github.com/Suwayomi/Tachidesk-WebUI/pull/99) by @AriaMoradi)
- (r887) migrate Browse to Mui 5 ([#98](https://github.com/Suwayomi/Tachidesk-WebUI/pull/98) by @AriaMoradi)
- (r888) migrate DesktopSideBar to Mui 5 ([#87](https://github.com/Suwayomi/Tachidesk-WebUI/pull/87) by @AriaMoradi)
- (r889) cleanup library ([#107](https://github.com/Suwayomi/Tachidesk-WebUI/pull/107) by @AriaMoradi)
- (r890) support for new searchTerm (by @AriaMoradi)
- (r891) Revert "support for new searchTerm" (by @AriaMoradi)
- (r892) add support for emptySearch ([#109](https://github.com/Suwayomi/Tachidesk-WebUI/pull/109) by @AriaMoradi)
- (r893) add support for MultiSelectListPreference ([#108](https://github.com/Suwayomi/Tachidesk-WebUI/pull/108) by @AriaMoradi)
# Server: v0.5.4 + WebUI: r820
## TL;DR
- Fixed ReadComicOnline, Toonily and possibly other sources not working
- Backup and Restore now includes Updates tab data
- Removed Anime support from WebUI, Anime support will also be removed from Tachidesk-Server in a future update
## Tachidesk-Server Changelog
- (r973) convert android.jar lib to a maven repo
- (r978) mimic Tachiyomi's behaviour more closely, fixes ReadComicOnline (EN)
- (r980) fix export chapter ordering, include new props in backup
- (r982) remove isNsfw annotation detection
- (r984) use correct time conversion units when doing backups
- (r989) Support using a CatalogueSource instead of only HttpSources ([#219](https://github.com/Suwayomi/Tachidesk-Server/pull/219) by @Syer10)
- (r991) Use a custom task to run electron ([#220](https://github.com/Suwayomi/Tachidesk-Server/pull/220) by @Syer10)
## Tachidesk-WebUI Changelog
- (r810) fix wrong strings in set Server Address dialog, fixes [#39](https://github.com/Suwayomi/Tachidesk-WebUI/issues/39)
- (r811) fix chapterFetch loop
- (r812) fix overlapping requests
- (r813) fix typo
- (r814) Better portrait support ([#41](https://github.com/Suwayomi/Tachidesk-WebUI/issues/41) by @minhe7735)
- (r815) fixes Reader navbar colors when in light mode ([#43](https://github.com/Suwayomi/Tachidesk-WebUI/issues/43) by @abhijeetChawla)
- (r816) default languages cleanup, force Local source enabled
- (r817) force Local source at LangSelect
- (r818) rename ExtensionLangSelect: generic name for generic use
- (r819) don't show anime anymore
- (r820) Remove Anime support
# Server: v0.5.3 + WebUI: r809
## TL;DR
- added support for a equivalent page to Tachiyomi's Updates tab
- fix launchers not working on macOS M1/arm64
## Tachidesk-Server Changelog
- (r956) fix macOS-arm64 bundle launchers not working
- (r957) Workaround StdLib issue and add KtLint to all modules ([#206](https://github.com/Suwayomi/Tachidesk-Server/pull/206) by @Syer10)
- (r960-r963) Add recently updated chapters(Updates) endpoint
## Tachidesk-WebUI Changelog
- (r808) fix chapter list not calling onlineFetch=true
- (r809) add support for Updates
# Server: v0.5.2 + WebUI: r807
## TL;DR
- Fixed Local source not working on Windows
- Fixed Chapter numbers being shown incorrectly
## Tachidesk-Server
### Public API
#### Non-breaking changes
- N/A
#### Breaking changes
- N/A
#### Bug fixes
- (r948) Fix ManaToki (KO) and NewToki (KO) (issue [#202](https://github.com/Suwayomi/Tachidesk-Server/issue/202))
- (r949) Local source: fix windows paths
### Private API
- (r941) Update BytecodeEditor to use Java NIO Paths ([#200](https://github.com/Suwayomi/Tachidesk-Server/pull/200) by @Syer10)
- (r942) Gradle Updates ([#199](https://github.com/Suwayomi/Tachidesk-Server/pull/199) by @Syer10)
## Tachidesk-WebUI
#### Visible changes
- (r804) update text positioning on Reader and Player ([#35](https://github.com/Suwayomi/Tachidesk-WebUI/pull/35) by @voltrare)
- (r806) Source card for Local source is different
- (r807) add Local source guide
#### Bug fixes
- (r805) fix chapter name
#### Internal changes
- N/A
# Server: v0.5.1 + WebUI: r803
## TL;DR
- Loading sources' manga list is at least twice as fast
- Added support for Tachiyomi's Local source
- Added BasicAuth support, now you can protect your Tachidesk instance if you are running it on a public server
- Added ability to turn off cache for image requests
## Tachidesk-Server
### Public API
#### Non-breaking changes
- (r915) add BasicAuth support
- (r918) add ability to delete downloaded chapters
- (r923-r930) add Local Source
- (r938) add ability to turn off cache for image requests
#### Breaking changes
- N/A
#### Bug fixes
- (r917) detect if a downloaded chapter is missing
### Private API
- (r913) remove expand char limit on MangaTable columns
- (r914) migrate to Javalin 4
- (r921) depricate zero based chapters
- (r937) add ChapterRecognition from tachiyomi, closes #10
## Tachidesk-WebUI
#### Visible changes
- (r790) nice looking progress percentage
- (r791) show a Delete button for downloaded chapters
- (r792) Update hover effect using more of Material-UI color pallete ([#29](https://github.com/Suwayomi/Tachidesk-WebUI/pull/29) by @voltrare)
- (r793) Optimize images ([#32](https://github.com/Suwayomi/Tachidesk-WebUI/pull/32) by @phanirithvij)
- (r794) try fix #30 ([#31](https://github.com/Suwayomi/Tachidesk-WebUI/pull/31) by @phanirithvij)
- (r795) fix viewing page number when the string is long
- (r796) show proper display name for source
- (r797) fail gracefully when a thumbnail has errors
- (r798) fix when a source fails to load mangas
- (r800) add Local source ([#31](https://github.com/Suwayomi/Tachidesk-WebUI/pull/31))
- (r803) add support for useCache
#### Bug fixes
- N/A
#### Internal changes
- N/A
# Server: v0.5.0 + WebUI: r789
## TL;DR
- You can now install APK extensions from the extensions page
- WebUI now comes with an updated Material Design looks and is faster a little bit.
- WebUI now shows Nsfw content by default, disable it in settings if you prefer to not see Nsfw stuff
- Added support for configuration of sources, this enables MangaDex, Komga, Cubari and many other sources
- Chapters in the Manga page and Sources in the source page now look nicer and will glow with mouse hover
## Tachidesk-Server
### Public API
#### Non-breaking changes
- (r888) add installing APK from external sources endpoint
#### Breaking changes
- (r877 [#188](https://github.com/Suwayomi/Tachidesk-Server/pull/188) by @Syer10) `MangaDataClass.genre` changed type to `List<String>`
#### Bug fixes
- (r899-r901) fix when an external apk is installed and it doesn't have the default tachiyomi-extensions name
- (r905) fix a bug where if two sources return the same URL, a false duplicate might be detected
### Private API
- (r887) the `run` task won't call `downloadWebUI` now
- (r902) cleanup print/ln instances
- (r906) better handling of uninstalling Extensions
## Tachidesk-WebUI
#### Visible changes
- (r770) add support for the new genre type
- (r771) set the default value of `showNsfw` to `true` so we won't have visual artifacts with a clean install
- (r774 [#21](https://github.com/Suwayomi/Tachidesk-WebUI/pull/21) by @voltrare) `ReaderNavbar.jsx`: Swap close and retract Navbar buttons
- (r775 [#23](https://github.com/Suwayomi/Tachidesk-WebUI/pull/23) by @voltrare) `yarn.lock`: Fixes version inconsistency after commit 9b866811b
- (r776 [#23](https://github.com/Suwayomi/Tachidesk-WebUI/pull/23) by @voltrare) add margin between Source and Extension cards, make the Search button look nicer
- (r777) add support for installing external APK files
- (r778) fix the makeToaster?
- (r779) Action button for installing external extension
- (r780 Suwayomi/Tachidesk-WebUI#25) add on hover, active effect to Chapter/Episode card
- (r782-r785) updating material-ui to v5 changed the theme
- (r785-r788) better `SourceCard` looks on mobile, move `SourceDataClass.isConfigurable` gear button to `SourceMangas`
- (r789) implement source configuration
#### Bug fixes
- N/A
#### Internal changes
- (r782-r785) update dependencies, migrate material-ui from v4 to v5
# Server: v0.4.9 + WebUI: r769
## Tachidesk-Server
### Public API
#### Non-breaking changes
- N/A
#### Breaking changes
- (r857) renamed: `SourceDataClass.isNSFW` -> `SourceDataClass.isNsfw`
#### Bug fixes
- N/A
### Private API
- (r850) Bump WebUI version to r767
- (r861) Bump WebUI version to r769
#### Non-code changes
- (r851) Add this changelog file and `CHANGELOG-TEMPLATE.md`
- (r852-r853) `CONTRIBUTING.md`: Add a note about this maintaining this file changelog
- (r855) `CONTRIBUTING.md`: text cleanup
- (r859) `CONTRIBUTING.md`: remove dumb rule
- (r862) `windows-bundler.sh`: update jre
- (r864) add linux and macOS bundler script and launcher scripts
- (r865) fix macOS bundler script and launcher scripts
- (r866) bump electron version to v14.0.0
- (r868) add linux and macOS bundlers to the publish workflow
- (r871) `publish.yml`: remove node module cache, won't need it anymore
- (r873) `publish.yml` and `build_push.yml`: fix oopsies
## Tachidesk-WebUI
#### Visible changes
- (r767-r769) Support for hiding NSFW content in settings screen, extensions screen, sources screen
#### Bug fixes
- N/A
#### Internal changes
- (r767) Remove some duplicate dependency declaration from `package.json`
#### Non-code changes
- (r42-r45) Change `README.md`: some links and stuff
- (r45-r765) Add all of the commit history from when WebUI was separated from Server, jumping from r45 to r765 (r45 is exactly the same as r765)
- (r766) Steal `.gitattributes` from Tachidesk-Server
- (r767) Dependency cleanup in `package.json`
# Server: v0.4.8 + WebUI: r41
## Tachidesk-Server
### Public API
#### Non-breaking changes
- Added support for serializing Search Filters
- `SourceDataClass` now has a `isNsfw` key
#### Breaking changes
- N/A
#### Bug fixes
- Fixed a bug where backup restore reversed chapter order
- Open Site feature now works properly (https://github.com/Suwayomi/Tachidesk-WebUI/issues/19)
### Private API
- Added `CloudflareInterceptor` from TachiWeb-Server
- Restoring backup for mangas in library(merging manga data) is now supported
## Tachidesk-WebUI
#### Visible changes
- Better looking manga card titles
- Better reader title, next, prev buttons
#### Bug fixes
- Open Site feature now works properly (https://github.com/Suwayomi/Tachidesk-WebUI/issues/19)
- Re-ordering categories now works
#### Internal changes
- N/A
+41 -37
View File
@@ -2,51 +2,55 @@
## Where should I start?
Checkout [This Kanban Board](https://github.com/Suwayomi/Tachidesk/projects/1) to see the rough development roadmap.
**Note to potential contributors:** Notify the developers on Suwayomi discord (#programming channel) or open a WIP pull request before starting if you decide to take on working on anything from/not from the roadmap in order to avoid parallel efforts on the same issue/feature.
**Note 1:** Notify the developers on [Suwayomi discord](https://discord.gg/DDZdqZWaHA) (#tachidesk-server and #tachidesk-webui channels) or open a WIP pull request before starting if you decide to take on working on anything from/not from the roadmap in order to avoid parallel efforts on the same issue/feature.
## How does Tachidesk work?
**Note 2:** Your pull request will be squashed into a single commit.
### Project goals and vision
- Porting Tachiyomi and covering it's features
- Syncing with Tachiyomi, [main issue](https://github.com/Suwayomi/Tachidesk-Server/issues/159)
- Generally rejecting features that Tachiyomi(main app) doesn't have,
- Unless it's something that makes sense for desktop sizes or desktop form factor (keyboard + mouse)
- Additional/crazy features can go in forks and alternative clients
- [Tachidesk-WebUI](https://github.com/Suwayomi/Tachidesk-WebUI) should
- be responsive
- support both desktop and mobile form factors well
## How does Tachidesk-Server work?
This project has two components:
1. **server:** contains the implementation of [tachiyomi's extensions library](https://github.com/tachiyomiorg/extensions-lib) and uses an Android compatibility library to run apk extensions. All this concludes to serving a REST API to `webUI`.
2. **webUI:** A react SPA(`create-react-app`) project that works with the server to do the presentation.
1. **Server:** contains the implementation of [tachiyomi's extensions library](https://github.com/tachiyomiorg/extensions-lib) and uses an Android compatibility library to run jar libraries converted from apk extensions. All this concludes to serving a REST API.
2. **WebUI:** A React SPA(`create-react-app`) project that works with the server to do the presentation located at https://github.com/Suwayomi/Tachidesk-WebUI
## Why a web app?
This structure is chosen to
- Achieve the maximum multi-platform-ness
- Gives the ability to acces Tachidesk from a remote web browser e.g. your phone, tablet or smart TV
- Eaise development of alternative user intefaces for Tachidesk
## User Interfaces for Tachidesk server
Currently, there are three known interfaces for Tachidesk:
1. [webUI](https://github.com/Suwayomi/Tachidesk/tree/master/webUI/react): The react SPA that Tachidesk is traditionally shipped with.
2. [TachideskJUI](https://github.com/Suwayomi/TachideskJUI): A Jetbrains Compose Native app, re-uses components made for the upcoming Tachiyomi 1.x
3. [Equinox](https://github.com/Suwayomi/Equinox): A web user interface made with Vue.js, in super early stages of development.
- Gives the ability to access Tachidesk-Server from a remote client e.g., your phone, tablet or smart TV
- Ease development of user interfaces for Tachidesk
## Building from source
### Prerequisites
You need these software packages installed in order to build the project
### Server
- Java Development Kit and Java Runtime Environment version 8 or newer(both Oracle JDK and OpenJDK works)
- Android stubs jar
- Manual download: Download [android.jar](https://raw.githubusercontent.com/Suwayomi/Tachidesk/android-jar/android.jar) and put it under `AndroidCompat/lib`.
- Automated download: Run `AndroidCompat/getAndroid.sh`(MacOS/Linux) or `AndroidCompat/getAndroid.ps1`(Windows) from project's root directory to download and rebuild the jar file from Google's repository.
### webUI
- Nodejs LTS or latest
- Yarn
- Git
### building the full-blown jar
Run `./gradlew :webUI:copyBuild server:shadowJar`, the resulting built jar file will be `server/build/Tachidesk-vX.Y.Z-rxxx.jar`.
### building without `webUI` bundled(server only)
Delete the `server/src/main/resources/react` directory if exists from previous runs, then run `./gradlew server:shadowJar`, the resulting built jar file will be `server/build/Tachidesk-vX.Y.Z-rxxx.jar`.
### building the Windows package
First Build the jar, then cd into the `scripts` directory and run `./windows<bits>-bundler.sh` (or `./windows<bits>-bundler.ps1` if you are on windows), the resulting built zip package file will be `server/build/Tachidesk-vX.Y.Z-rxxx-win64.zip`.
## Running in development mode
First satisfy [the prerequisites](#prerequisites)
### server
run `./gradlew :server:run --stacktrace` to run the server
### webUI
How to do it is described in `webUI/react/README.md` but for short,
first cd into `webUI/react` then run `yarn` to install the node modules(do this only once)
then `yarn start` to start the development server, if a new browser window doesn't get opened automatically,
then open `http://127.0.0.1:3000` in a modern browser. This is a `create-react-app` project
and supports HMR and all the other goodies you'll need.
- Java Development Kit and Java Runtime Environment version 8 or newer(both Oracle JDK and OpenJDK works)
### building the full-blown jar (Tachidesk-Server + Tachidesk-WebUI bundle)
Run `./gradlew server:downloadWebUI server:shadowJar`, the resulting built jar file will be `server/build/Tachidesk-Server-vX.Y.Z-rxxx.jar`.
### building without `webUI` bundled (server only)
Delete `server/src/main/resources/WebUI.zip` if exists from previous runs, then run `./gradlew server:shadowJar`, the resulting built jar file will be `server/build/Tachidesk-Server-vX.Y.Z-rxxx.jar`.
### building the Windows package
First Build the jar, then cd into the `scripts` directory and run `./windows-bundler.sh win32` or `./windows-bundler.sh win64` depending on the target architecture, the resulting built zip package file will be `server/build/Tachidesk-Server-vX.Y.Z-rxxx-winXX.zip`.
## Running in development mode
run `./gradlew :server:run --stacktrace` to run the server
## Running tests
run `./gradlew :server:test` to execute all tests
to test a specific class run `./gradlew :server:test --tests <package.with.classname>`
## Building the android-jar maven repository
Run `AndroidCompat/getAndroid.sh`(macOS/Linux) or `AndroidCompat/getAndroid.ps1`(Windows)
from project's root directory to download and rebuild the jar file from Google's repository,
then use `AndroidCompat/lib/android.jar` to manually create a maven repository inside the `android-jar` git branch.
Update the dependency declaration afterwards.
+106 -26
View File
@@ -3,56 +3,111 @@
|-------|----------|---------|---------|
| ![CI](https://github.com/Suwayomi/Tachidesk/actions/workflows/build_push.yml/badge.svg) | [![stable release](https://img.shields.io/github/release/Suwayomi/Tachidesk.svg?maxAge=3600&label=download)](https://github.com/Suwayomi/Tachidesk/releases) | [![preview](https://img.shields.io/badge/dynamic/json?url=https://github.com/Suwayomi/Tachidesk-preview/raw/main/index.json&label=download&query=$.latest&color=blue)](https://github.com/Suwayomi/Tachidesk-preview/releases/latest) | [![Discord](https://img.shields.io/discord/801021177333940224.svg?label=discord&labelColor=7289da&color=2c2f33&style=flat)](https://discord.gg/DDZdqZWaHA) |
# Tachidesk
## Table of Content
- [What is Tachidesk?](#what-is-tachidesk)
- [Tachidesk client projects](#tachidesk-client-projects)
* [Is this application usable? Should I test it?](#is-this-application-usable-should-i-test-it)
- [Downloading and Running the app](#downloading-and-running-the-app)
* [Using Operating System Specific Bundles](#using-operating-system-specific-bundles)
- [Launcher Scripts](#launcher-scripts)
+ [Windows](#windows)
+ [macOS](#macos)
+ [GNU/Linux](#gnulinux)
* [Other methods of getting Tachidesk](#other-methods-of-getting-tachidesk)
+ [Arch Linux](#arch-linux)
+ [Ubuntu-based distributions](#ubuntu-based-distributions)
+ [Docker](#docker)
* [Advanced Methods](#advanced-methods)
+ [Running the jar release directly](#running-the-jar-release-directly)
+ [Using Tachidesk Remotely](#using-tachidesk-remotely)
- [Syncing With Tachiyomi](#syncing-with-tachiyomi)
- [Troubleshooting and Support](#troubleshooting-and-support)
- [Contributing and Technical info](#contributing-and-technical-info)
- [Credit](#credit)
- [License](#license)
<!-- Generated with https://ecotrust-canada.github.io/markdown-toc/ -->
# What is Tachidesk?
<img src="https://github.com/Suwayomi/Tachidesk/raw/master/server/src/main/resources/icon/faviconlogo.png" alt="drawing" width="200"/>
A free and open source manga reader that runs extensions built for [Tachiyomi](https://tachiyomi.org/).
A free and open source manga reader server that runs extensions built for [Tachiyomi](https://tachiyomi.org/).
Tachidesk is an independent Tachiyomi compatible software and is **not a Fork of** Tachiyomi.
Tachidesk is as multi-platform as you can get. Any platform that runs java and/or has a modern browser can run it. This includes Windows, Linux, macOS, chrome OS, etc. Follow [Downloading and Running the app](#downloading-and-running-the-app) for installation instructions.
`Tachidesk` is a general term used to describe the combination of Tachidesk-Server(this project) and one of our clients.
Think of it roughly like the concept of "distribution" in GNU/Linux distributions, in which Linux(Tachidesk-Server) is the kernel and the difference is which desktop environment(Tachidesk client) you get with it.
Ability to read and write Tachiyomi compatible backups and syncing is a planned feature.
Tachidesk-Server is as multi-platform as you can get. Any platform that runs java and/or has a modern browser can run it. This includes Windows, Linux, macOS, chrome OS, etc. Follow [Downloading and Running the app](#downloading-and-running-the-app) for installation instructions.
**Tachidesk needs serious front-end dev help for it's reader and other parts, if you like the app and want to see it become better please don't hesitate to contribute some code!**
Ability to sync with Tachiyomi is a planned feature.
# Tachidesk client projects
**You need a client/user interface app as a front-end for Tachidesk-Server, if you Directly Download Tachidesk-Server you'll get a bundled version of [Tachidesk-WebUI](https://github.com/Suwayomi/Tachidesk-WebUI) with it.**
Here's a list of known clients/user interfaces for Tachidesk-Server:
- [Tachidesk-WebUI](https://github.com/Suwayomi/Tachidesk-WebUI): The web/ElectronJS front-end that Tachidesk-Server is traditionally shipped with. Usually gets new features faster.
- [Tachidesk-JUI](https://github.com/Suwayomi/Tachidesk-JUI): The native desktop front-end for Tachidesk-Server. Currently the most advanced. Downlading Tachidesk-JUI is not recommened for now, the current release is getting obsolete, a new version is to be released soon(TM).
- [Tachidesk-qtui](https://github.com/Suwayomi/Tachidesk-qtui): A C++/Qt front-end for mobile devices(Android/linux), in super early stage of development.
- [Tachidesk-GTK](https://github.com/mahor1221/Tachidesk-GTK): A native Rust/GTK desktop client, in super early stage of development.
- [Equinox](https://github.com/Suwayomi/Equinox): A web user interface made with Vue.js, in super early stage of development. Seemingly abandoned.
## Is this application usable? Should I test it?
Here is a list of current features:
- Installing and executing Tachiyomi's Extensions, So you'll get the same sources.
- A library to save your mangas and categories to put them into.
- Searching and browsing installed sources.
- A decent chapter reader.
- Installing and executing Tachiyomi's Extensions, So you'll get the same sources
- A library to save your mangas and categories to put them into
- Searching and browsing installed sources
- Ability to download Manga for offline read
- Backup and restore support powered by Tachiyomi Legacy Backups
- Backup and restore support powered by Tachiyomi-compatible Backups
- Viewing latest updated chapters.
**Note:** Keep in mind that Tachidesk is alpha software and can break rarely and/or with each update. See [Troubleshooting](https://github.com/Suwayomi/Tachidesk/wiki/Troubleshooting) if it happens.
**Note:** These are capabilities of Tachidesk-Server, the actual working support is provided by each front-end app, checkout their respective readme for more info.
## Downloading and Running the app
### All Operating Systems
You should have The Java Runtime Environment(JRE) 8 or newer and a modern browser installed(Google is your friend for seeking assitance). Also an internet connection is required as almost everything this app does is downloading stuff.
# Downloading and Running the app
## Using Operating System Specific Bundles
To facilitate the use of Tachidesk we provide bundle releases that include The Java Runtime Environment, ElectronJS and 3 Tachidesk Launcher Scripts.
Download the latest "Stable" jar release from [the releases section](https://github.com/Suwayomi/Tachidesk/releases) or a preview jar build from [the preview repository](https://github.com/Suwayomi/Tachidesk-preview/releases).
If a bundle for your operating system or cpu architecture is not provided then refer to [Advanced Methods](#advanced-methods)
Double click on the jar file or run `java -jar Tachidesk-vX.Y.Z-rxxx.jar` (or `java -jar Tachidesk-latest.jar` if you have the latest preview) from a Terminal/Command Prompt window to run the app which will open a new browser window automatically. Also the System Tray Icon is your friend if you need to open the browser window again or close Tachidesk.
#### Launcher Scripts
- `Tachidesk Electron Launcher`: Launches Tachidesk inside Electron as a desktop applicaton
- `Tachidesk Browser Launcher`: Launches Tachidesk in a browser window
- `Tachidesk Debug Launcher`: Launches Tachidesk with debug logs attached. If Tachidesk doesn't work for you, running this can give you insight into why.
**Node:** Linux launcher scripts are named a bit differently but work the same.
### Windows
Download the latest "Stable" win32 or win64 (depending on your system, usually you want win64) release from [the releases section](https://github.com/Suwayomi/Tachidesk/releases) or a preview one from [the preview repository](https://github.com/Suwayomi/Tachidesk-preview/releases).
Download the latest `win32`(Windows 32-bit) or `win64`(Windows 64-bit) release from [the releases section](https://github.com/Suwayomi/Tachidesk/releases) or a preview one from [the preview repository](https://github.com/Suwayomi/Tachidesk-preview/releases).
The Windows specific build has java bundled inside, so you don't have to install java to use it. Unzip `Tachidesk-vX.Y.Z-rxxx-win64.zip` and run one of the Launcher files depending on what you want(see bellow). The rest works like the previous section.
#### Windows Launchers
- `Tachidesk Electron Launcher.bat`: Launches Tachidesk inside Electron as a desktop applicaton
- `Tachidesk Browser Launcher.bat`: Launches Tachidesk in a browser window
- `Tachidesk Debug Launcher.bat`: Launches Tachidesk with debug logs attached. If Tachidesk doesn't work for you, running this can give you insight into why.
Unzip the downloaded file and double click on one of the launcher scripts.
### macOS
Download the latest `macOS-x64`(older macOS systems) or `macOS-arm64`(Apple M1) release from [the releases section](https://github.com/Suwayomi/Tachidesk/releases) or a preview one from [the preview repository](https://github.com/Suwayomi/Tachidesk-preview/releases).
Unzip the downloaded file and double click on one of the launcher scripts.
### GNU/Linux
Download the latest `linux-x64`(x86_64) release from [the releases section](https://github.com/Suwayomi/Tachidesk/releases) or a preview one from [the preview repository](https://github.com/Suwayomi/Tachidesk-preview/releases).
`tar xvf` the downloaded file and double click on one of the launcher scripts or run them using the terminal.
## Other methods of getting Tachidesk
### Arch Linux
You can install Tachidesk from the AUR
```
yay -S tachidesk
```
### Ubuntu-based distributions
More information can be found on the [PPA's page](https://launchpad.net/~suwayomi/+archive/ubuntu/tachidesk).
```
sudo add-apt-repository ppa:suwayomi/tachidesk
sudo apt update
sudo apt install tachidesk
```
### Docker
Check our Offical Docker release [Tachidesk Container](https://github.com/orgs/Suwayomi/packages/container/package/tachidesk) or use [arbuilder's](https://github.com/arbuilder/Tachidesk-docker) tachidesk docker repo for installation. Source code for our container is available at [docker-tachidesk](https://github.com/Suwayomi/docker-tachidesk). By default the server will be running on http://localhost:4567 open this url in your browser.
Check our Official Docker release [Tachidesk Container](https://github.com/orgs/Suwayomi/packages/container/package/tachidesk) for running Tachidesk Server in a docker container. Source code for our container is available at [docker-tachidesk](https://github.com/Suwayomi/docker-tachidesk). By default the server will be running on http://localhost:4567 open this url in your browser.
Install from the command line:
```
@@ -63,8 +118,33 @@ Run Container from the command line:
$ docker run -p 4567:4567 ghcr.io/suwayomi/tachidesk
```
## Advanced Methods
### Running the jar release directly
In order to run the app you need the following:
- The jar release of Tachidesk-Server
- The Java Runtime Environment(JRE) 8 or newer
- A Browser like Google Chrome, Firefox, Edge, etc.
- ElectronJS (optional)
Download the latest `.jar` release from [the releases section](https://github.com/Suwayomi/Tachidesk-Server/releases) or a preview jar build from [the preview repository](https://github.com/Suwayomi/Tachidesk-preview/releases).
Make sure you have The Java Runtime Environment installed on your system, Double click on the jar file or run `java -jar Tachidesk-vX.Y.Z-rxxxx.jar` from a Terminal/Command Prompt window to run the app which will open a new browser window automatically.
### Using Tachidesk Remotely
You can run Tachidesk on your computer or a server and connect to it remotely through the web interface with a web browser on any device including a mobile or tablet or even your smart TV!, this method of using Tachidesk is only recommended if you are a power user and know what you are doing.
You can run Tachidesk on your computer or a server and connect to it remotely through one of our clients or the bundled web interface with a web browser. This method of using Tachidesk is requires a bit of networking/firewall/port forwarding/server configuration/etc. knowledge on your side, if you can run a Minecraft server and configure it, then you are good to go.
Check out [this wiki page](https://github.com/Suwayomi/Tachidesk-Server/wiki/Configuring-Tachidesk-Server) for a guide on configuring Tachidesk-Server.
If you face issues with your setup then we are happy to provide help, just join our discord server(a discord badge is on the top of the page, you are just a click clack away!).
## Syncing With Tachiyomi
### The Tachidesk extension
- You can install the `Tachidesk` extension inside tachiyomi.
- The extension will load Tachidesk library.
- By manipulating filters you can browse your categories.
### Other methods
Checkout [this issue](https://github.com/Suwayomi/Tachidesk-Server/issues/159) for tracking progress.
## Troubleshooting and Support
See [this troubleshooting wiki page](https://github.com/Suwayomi/Tachidesk/wiki/Troubleshooting).
@@ -75,9 +155,9 @@ See [CONTRIBUTING.md](./CONTRIBUTING.md).
## Credit
This project is a spiritual successor of [TachiWeb-Server](https://github.com/Tachiweb/TachiWeb-server), Many of the ideas and the groundwork adopted in this project comes from TachiWeb.
The `AndroidCompat` module was originally developed by [@null-dev](https://github.com/null-dev) for [TachiWeb-Server](https://github.com/Tachiweb/TachiWeb-server) and is licensed under `Apache License Version 2.0`.
The `AndroidCompat` module was originally developed by [@null-dev](https://github.com/null-dev) for [TachiWeb-Server](https://github.com/Tachiweb/TachiWeb-server) and is licensed under `Apache License Version 2.0` and `Copyright 2019 Andy Bao and contributors`.
Parts of [tachiyomi](https://github.com/tachiyomiorg/tachiyomi) is adopted into this codebase, also licensed under `Apache License Version 2.0`.
Parts of [tachiyomi](https://github.com/tachiyomiorg/tachiyomi) is adopted into this codebase, also licensed under `Apache License Version 2.0` and `Copyright 2015 Javier Tomás`.
You can obtain a copy of `Apache License Version 2.0` from http://www.apache.org/licenses/LICENSE-2.0
+43 -20
View File
@@ -1,8 +1,12 @@
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
import org.jmailen.gradle.kotlinter.tasks.FormatTask
import org.jmailen.gradle.kotlinter.tasks.LintTask
plugins {
kotlin("jvm") version kotlinVersion
kotlin("plugin.serialization") version kotlinVersion
id("org.jmailen.kotlinter") version "3.6.0"
id("com.github.gmazzo.buildconfig") version "3.0.3" apply false
}
allprojects {
@@ -12,10 +16,9 @@ allprojects {
repositories {
mavenCentral()
maven("https://maven.google.com/")
google()
maven("https://jitpack.io")
maven("https://oss.sonatype.org/content/repositories/snapshots/")
maven("https://dl.google.com/dl/android/maven2/")
maven("https://github.com/Suwayomi/Tachidesk-Server/raw/android-jar/")
}
}
@@ -27,18 +30,38 @@ val projects = listOf(
configure(projects) {
apply(plugin = "org.jetbrains.kotlin.jvm")
apply(plugin = "org.jetbrains.kotlin.plugin.serialization")
apply(plugin = "org.jmailen.kotlinter")
java {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
tasks.withType<KotlinCompile> {
kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8.toString()
tasks {
withType<KotlinCompile> {
dependsOn(formatKotlin)
kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8.toString()
freeCompilerArgs = listOf(
"-Xopt-in=kotlin.RequiresOptIn",
"-Xopt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
"-Xopt-in=kotlinx.coroutines.InternalCoroutinesApi",
"-Xopt-in=kotlinx.serialization.ExperimentalSerializationApi",
)
}
}
withType<LintTask> {
source(files("src/kotlin"))
}
withType<FormatTask> {
source(files("src/kotlin"))
}
}
dependencies {
// Kotlin
implementation(kotlin("stdlib-jdk8"))
@@ -46,31 +69,30 @@ configure(projects) {
testImplementation(kotlin("test-junit5"))
// coroutines
val coroutinesVersion = "1.5.0"
val coroutinesVersion = "1.5.2"
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-jdk8:$coroutinesVersion")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion")
val kotlinSerializationVersion = "1.2.1"
val kotlinSerializationVersion = "1.3.0-RC"
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinSerializationVersion")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-protobuf:$kotlinSerializationVersion")
// Dependency Injection
implementation("org.kodein.di:kodein-di-conf-jvm:7.5.0")
implementation("org.kodein.di:kodein-di-conf-jvm:7.8.0")
// Logging
implementation("org.slf4j:slf4j-api:1.7.30")
implementation("ch.qos.logback:logback-classic:1.2.3")
implementation("io.github.microutils:kotlin-logging:2.0.6")
implementation("org.slf4j:slf4j-api:1.7.32")
implementation("ch.qos.logback:logback-classic:1.2.6")
implementation("io.github.microutils:kotlin-logging:2.0.11")
// ReactiveX
implementation("io.reactivex:rxjava:1.3.8")
implementation("io.reactivex:rxkotlin:1.0.0")
implementation("com.jakewharton.rxrelay:rxrelay:1.2.0")
// JSoup
implementation("org.jsoup:jsoup:1.13.1")
// dependency both in AndroidCompat and extensions, version locked by Tachiyomi app/extensions
implementation("org.jsoup:jsoup:1.14.2")
// dependency of :AndroidCompat:Config
implementation("com.typesafe:config:1.4.1")
@@ -79,14 +101,15 @@ configure(projects) {
// to get application content root
implementation("net.harawata:appdirs:1.2.1")
// dex2jar: https://github.com/DexPatcher/dex2jar/releases/tag/v2.1-20190905-lanchon
// note: watch https://github.com/ThexXTURBOXx/dex2jar for future development
implementation("com.github.DexPatcher.dex2jar:dex-tools:v2.1-20190905-lanchon")
// dex2jar
val dex2jarVersion = "v26"
implementation("com.github.ThexXTURBOXx.dex2jar:dex-translator:$dex2jarVersion")
implementation("com.github.ThexXTURBOXx.dex2jar:dex-tools:$dex2jarVersion")
// APK parser
implementation("net.dongliu:apk-parser:2.6.10")
// Jackson
implementation("com.fasterxml.jackson.core:jackson-annotations:2.10.3")
// dependency both in AndroidCompat and server, version locked by javalin
implementation("com.fasterxml.jackson.core:jackson-annotations:2.12.4")
}
}
+4 -4
View File
@@ -7,16 +7,16 @@ import java.io.BufferedReader
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
const val kotlinVersion = "1.5.21"
const val kotlinVersion = "1.5.30"
const val MainClass = "suwayomi.tachidesk.MainKt"
// should be bumped with each stable release
val tachideskVersion = System.getenv("ProductVersion") ?: "v0.4.6"
val tachideskVersion = System.getenv("ProductVersion") ?: "v0.6.0"
val webUIRevisionTag = System.getenv("WebUIRevision") ?: "r24"
val webUIRevisionTag = System.getenv("WebUIRevision") ?: "r893"
// counts commit count on master
// counts commits on the master branch
val tachideskRevision = runCatching {
System.getenv("ProductRevision") ?: Runtime
.getRuntime()
+1 -1
View File
@@ -1,5 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.1.1-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-all.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
+3
View File
@@ -0,0 +1,3 @@
cd "`dirname "$0"`"
./jre/Contents/Home/bin/java -jar Tachidesk.jar
@@ -1 +1,7 @@
:: cleaner output
@echo off
jre\bin\java -Dsuwayomi.tachidesk.config.server.debugLogsEnabled=true -jar Tachidesk.jar
:: Prevent cmd from closing when Tachidesk crashes
pause
+3
View File
@@ -0,0 +1,3 @@
cd "`dirname "$0"`"
./jre/Contents/Home/bin/java -Dsuwayomi.tachidesk.config.server.debugLogsEnabled=true -jar Tachidesk.jar
+3
View File
@@ -0,0 +1,3 @@
cd "`dirname "$0"`"
./jre/Contents/Home/bin/java "-Dsuwayomi.tachidesk.config.server.webUIInterface=electron" "-Dsuwayomi.tachidesk.config.server.electronPath=electron/Electron.app/Contents/MacOS/Electron" -jar Tachidesk.jar
+3
View File
@@ -0,0 +1,3 @@
#!/bin/sh
./jre/bin/java -jar Tachidesk.jar
+3
View File
@@ -0,0 +1,3 @@
#!/bin/sh
./jre/bin/java -Dsuwayomi.tachidesk.config.server.debugLogsEnabled=true -jar Tachidesk.jar
+3
View File
@@ -0,0 +1,3 @@
#!/bin/sh
./jre/bin/java "-Dsuwayomi.tachidesk.config.server.webUIInterface=electron" "-Dsuwayomi.tachidesk.config.server.electronPath=electron/electron" -jar Tachidesk.jar
+89
View File
@@ -0,0 +1,89 @@
#/bin/bash
# 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/.
electron_version="v14.0.0"
if [ $1 = "linux-x64" ]; then
jre="OpenJDK8U-jre_x64_linux_hotspot_8u302b08.tar.gz"
jre_release="jdk8u302-b08"
jre_url="https://github.com/adoptium/temurin8-binaries/releases/download/$jre_release/$jre"
jre_dir="$jre_release-jre"
electron="electron-$electron_version-linux-x64.zip"
elif [ $1 = "macOS-x64" ]; then
jre="OpenJDK8U-jre_x64_mac_hotspot_8u302b08.tar.gz"
jre_release="jdk8u302-b08"
jre_url="https://github.com/adoptium/temurin8-binaries/releases/download/$jre_release/$jre"
jre_dir="$jre_release-jre"
electron="electron-$electron_version-darwin-x64.zip"
elif [ $1 = "macOS-arm64" ]; then
jre="zulu8.56.0.23-ca-jre8.0.302-macosx_aarch64.tar.gz"
jre_release="zulu8.56.0.23-ca-jre8.0.302-macosx_aarch64"
jre_url="https://cdn.azul.com/zulu/bin/$jre"
jre_dir="$jre_release/zulu-8.jre"
electron="electron-$electron_version-darwin-arm64.zip"
else
echo "Unsupported arch value: $1"
exit 1
fi
arch="$1"
os=$(echo $arch | cut -d '-' -f1)
echo "creating $arch bundle"
jar=$(ls ../server/build/*.jar | tail -n1)
jar_name=$(echo $jar | cut -d'/' -f4)
release_name=$(echo $jar_name | sed 's/.jar//')-$arch
# make release dir
mkdir $release_name
echo "Dealing with jre..."
if [ ! -f $jre ]; then
curl -L $jre_url -o $jre
fi
tar xvf $jre
mv $jre_dir $release_name/jre
echo "Dealing with electron"
if [ ! -f $electron ]; then
curl -L "https://github.com/electron/electron/releases/download/$electron_version/$electron" -o $electron
fi
unzip $electron -d $release_name/electron
# copy artifacts
cp $jar $release_name/Tachidesk.jar
if [ $os = linux ]; then
cp "resources/tachidesk-browser-launcher.sh" $release_name
cp "resources/tachidesk-debug-launcher.sh" $release_name
cp "resources/tachidesk-electron-launcher.sh" $release_name
elif [ $os = macOS ]; then
cp "resources/Tachidesk Browser Launcher.command" $release_name
cp "resources/Tachidesk Debug Launcher.command" $release_name
cp "resources/Tachidesk Electron Launcher.command" $release_name
fi
archive_name=""
if [ $os = linux ]; then
archive_name=$release_name.tar.gz
GZIP=-9 tar cvzf $archive_name $release_name
elif [ $os = macOS ]; then
archive_name=$release_name.zip
zip -9 -r $archive_name $release_name
fi
rm -rf $release_name
# clean up from possible previous runs
if [ -f ../server/build/$archive_name ]; then
rm ../server/build/$archive_name
fi
mv $archive_name ../server/build/
+10 -6
View File
@@ -6,19 +6,23 @@
# 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/.
electron_version="v12.0.9"
electron_version="v14.0.0"
if [ $1 = "win32" ]; then
jre="OpenJDK8U-jre_x86-32_windows_hotspot_8u292b10.zip"
arch="win32"
jre_release="jdk8u292-b10"
jre_url="https://github.com/AdoptOpenJDK/openjdk8-binaries/releases/download/$jre_release/$jre"
arch="windows-x86"
electron="electron-$electron_version-win32-ia32.zip"
else
jre="OpenJDK8U-jre_x64_windows_hotspot_8u292b10.zip"
arch="win64"
jre="OpenJDK8U-jre_x64_windows_hotspot_8u302b08.zip"
jre_release="jdk8u302-b08"
jre_url="https://github.com/adoptium/temurin8-binaries/releases/download/$jre_release/$jre"
arch="windows-x64"
electron="electron-$electron_version-win32-x64.zip"
fi
jre_dir="jdk8u292-b10-jre"
jre_dir="$jre_release-jre"
echo "creating windows bundle"
@@ -33,7 +37,7 @@ mkdir $release_name
echo "Dealing with jre..."
if [ ! -f $jre ]; then
curl -L "https://github.com/AdoptOpenJDK/openjdk8-binaries/releases/download/jdk8u292-b10/$jre" -o $jre
curl -L $jre_url -o $jre
fi
unzip $jre
mv $jre_dir $release_name/jre
+49 -69
View File
@@ -1,24 +1,10 @@
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
import org.jmailen.gradle.kotlinter.tasks.FormatTask
import org.jmailen.gradle.kotlinter.tasks.LintTask
import de.undercouch.gradle.tasks.download.Download
import java.time.Instant
plugins {
application
id("com.github.johnrengelman.shadow") version "7.0.0"
id("org.jmailen.kotlinter") version "3.4.3"
id("com.github.gmazzo.buildconfig") version "3.0.2"
}
repositories {
maven {
url = uri("https://repo1.maven.org/maven2/")
}
maven {
url = uri("https://jitpack.io")
}
id("com.github.gmazzo.buildconfig")
}
dependencies {
@@ -30,13 +16,15 @@ dependencies {
implementation("com.squareup.okio:okio:2.10.0")
// Javalin api
implementation("io.javalin:javalin:3.13.6")
// jackson version is tied to javalin, ref: `io.javalin.core.util.OptionalDependency`
implementation("com.fasterxml.jackson.core:jackson-databind:2.10.3")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.10.3")
implementation("io.javalin:javalin:4.1.1")
implementation("io.javalin:javalin-openapi:4.1.1")
// jackson version locked by javalin, ref: `io.javalin.core.util.OptionalDependency`
val jacksonVersion = "2.12.4"
implementation("com.fasterxml.jackson.core:jackson-databind:$jacksonVersion")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin:$jacksonVersion")
// Exposed ORM
val exposedVersion = "0.31.1"
val exposedVersion = "0.34.1"
implementation("org.jetbrains.exposed:exposed-core:$exposedVersion")
implementation("org.jetbrains.exposed:exposed-dao:$exposedVersion")
implementation("org.jetbrains.exposed:exposed-jdbc:$exposedVersion")
@@ -45,28 +33,32 @@ dependencies {
implementation("com.h2database:h2:1.4.200")
// Exposed Migrations
val exposedMigrationsVersion = "3.1.0"
implementation("com.github.Suwayomi:exposed-migrations:$exposedMigrationsVersion")
implementation("com.github.Suwayomi:exposed-migrations:3.1.4")
// tray icon
implementation("com.dorkbox:SystemTray:4.1")
implementation("com.dorkbox:Utilities:1.9")
implementation("com.dorkbox:Utilities:1.9") // version locked by SystemTray
// dependencies of Tachiyomi extensions, some are duplicate, keeping it here for reference
implementation("com.github.inorichi.injekt:injekt-core:65b0440")
implementation("com.squareup.okhttp3:okhttp:4.9.1")
implementation("io.reactivex:rxjava:1.3.8")
implementation("org.jsoup:jsoup:1.13.1")
implementation("com.google.code.gson:gson:2.8.6")
implementation("org.jsoup:jsoup:1.14.2")
implementation("com.google.code.gson:gson:2.8.8")
implementation("com.github.salomonbrys.kotson:kotson:2.5.0")
// Sort
implementation("com.github.gpanther:java-nat-sort:natural-comparator-1.1")
// asm for fixing SimpleDateFormat (must match Dex2Jar version)
implementation("org.ow2.asm:asm-debug-all:5.0.3")
// asm for ByteCodeEditor(fixing SimpleDateFormat) (must match Dex2Jar version)
implementation("org.ow2.asm:asm:9.2")
// extracting zip files
// Disk & File
implementation("net.lingala.zip4j:zip4j:2.9.0")
implementation("com.github.junrar:junrar:7.4.0")
// CloudflareInterceptor
implementation("net.sourceforge.htmlunit:htmlunit:2.52.0")
// Source models and interfaces from Tachiyomi 1.x
// using source class from tachiyomi commit 9493577de27c40ce8b2b6122cc447d025e34c477 to not depend on tachiyomi.sourceapi
@@ -78,16 +70,13 @@ dependencies {
// uncomment to test extensions directly
// implementation(fileTree("lib/"))
implementation(kotlin("script-runtime"))
testImplementation("io.mockk:mockk:1.9.3")
}
application {
mainClass.set(MainClass)
// uncomment for testing electron
// applicationDefaultJvmArgs = listOf(
// "-Dsuwayomi.tachidesk.config.server.webUIInterface=electron",
// "-Dsuwayomi.tachidesk.config.server.electronPath=/usr/bin/electron"
// )
}
sourceSets {
@@ -125,39 +114,25 @@ tasks {
shadowJar {
manifest {
attributes(
mapOf(
"Main-Class" to MainClass,
"Implementation-Title" to rootProject.name,
"Implementation-Vendor" to "The Suwayomi Project",
"Specification-Version" to tachideskVersion,
"Implementation-Version" to tachideskRevision
)
"Main-Class" to MainClass,
"Implementation-Title" to rootProject.name,
"Implementation-Vendor" to "The Suwayomi Project",
"Specification-Version" to tachideskVersion,
"Implementation-Version" to tachideskRevision
)
}
archiveBaseName.set(rootProject.name)
archiveVersion.set(tachideskVersion)
archiveClassifier.set(tachideskRevision)
}
withType<KotlinCompile> {
kotlinOptions {
freeCompilerArgs = listOf(
"-Xopt-in=kotlin.RequiresOptIn",
"-Xopt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
"-Xopt-in=kotlinx.coroutines.InternalCoroutinesApi"
)
}
}
test {
useJUnit()
}
withType<ShadowJar> {
destinationDirectory.set(File("$rootDir/server/build"))
}
named("run") {
dependsOn("formatKotlin", "lintKotlin", "downloadWebUI")
test {
useJUnitPlatform()
testLogging {
showStandardStreams = true
events("passed", "skipped", "failed")
}
}
named<Copy>("processResources") {
@@ -165,7 +140,7 @@ tasks {
mustRunAfter("downloadWebUI")
}
register<de.undercouch.gradle.tasks.download.Download>("downloadWebUI") {
register<Download>("downloadWebUI") {
src("https://github.com/Suwayomi/Tachidesk-WebUI-preview/releases/download/$webUIRevisionTag/Tachidesk-WebUI-$webUIRevisionTag.zip")
dest("src/main/resources/WebUI.zip")
@@ -180,8 +155,9 @@ tasks {
it.readText().trim()
}
if (zipRevision == webUIRevisionTag)
if (zipRevision == webUIRevisionTag) {
shouldOverwrite = false
}
}
return shouldOverwrite
@@ -190,11 +166,15 @@ tasks {
overwrite(shouldOverwrite())
}
withType<LintTask> {
source(files("src/kotlin"))
}
withType<FormatTask> {
source(files("src/kotlin"))
register("runElectron") {
group = "application"
finalizedBy(run)
doFirst {
application.applicationDefaultJvmArgs = listOf(
"-Dsuwayomi.tachidesk.config.server.webUIInterface=electron",
// Change this to the installed electron application
"-Dsuwayomi.tachidesk.config.server.electronPath=/usr/bin/electron"
)
}
}
}
@@ -0,0 +1,6 @@
package eu.kanade.tachiyomi;
public class BuildConfig {
public static final int VERSION_CODE = -1;
public static final String VERSION_NAME = "stub";
}
@@ -1,46 +0,0 @@
package eu.kanade.tachiyomi.animesource
import eu.kanade.tachiyomi.animesource.model.AnimeFilterList
import eu.kanade.tachiyomi.animesource.model.AnimesPage
import rx.Observable
interface AnimeCatalogueSource : AnimeSource {
/**
* An ISO 639-1 compliant language code (two letters in lower case).
*/
override val lang: String
/**
* Whether the source has support for latest updates.
*/
val supportsLatest: Boolean
/**
* Returns an observable containing a page with a list of anime.
*
* @param page the page number to retrieve.
*/
fun fetchPopularAnime(page: Int): Observable<AnimesPage>
/**
* Returns an observable containing a page with a list of anime.
*
* @param page the page number to retrieve.
* @param query the search query.
* @param filters the list of filters to apply.
*/
fun fetchSearchAnime(page: Int, query: String, filters: AnimeFilterList): Observable<AnimesPage>
/**
* Returns an observable containing a page with a list of latest anime updates.
*
* @param page the page number to retrieve.
*/
fun fetchLatestUpdates(page: Int): Observable<AnimesPage>
/**
* Returns the list of filters for the source.
*/
fun getFilterList(): AnimeFilterList
}
@@ -1,81 +0,0 @@
package eu.kanade.tachiyomi.animesource
import eu.kanade.tachiyomi.animesource.model.SAnime
import eu.kanade.tachiyomi.animesource.model.SEpisode
import eu.kanade.tachiyomi.animesource.model.Video
import rx.Observable
/**
* A basic interface for creating a source. It could be an online source, a local source, etc...
*/
interface AnimeSource {
/**
* Id for the source. Must be unique.
*/
val id: Long
/**
* Name of the source.
*/
val name: String
val lang: String
get() = ""
/**
* Returns an observable with the updated details for a anime.
*
* @param anime the anime to update.
*/
// @Deprecated("Use getAnimeDetails instead")
fun fetchAnimeDetails(anime: SAnime): Observable<SAnime>
/**
* Returns an observable with all the available episodes for an anime.
*
* @param anime the anime to update.
*/
// @Deprecated("Use getEpisodeList instead")
fun fetchEpisodeList(anime: SAnime): Observable<List<SEpisode>>
/**
* Returns an observable with a list of video for the episode of an anime.
*
* @param episode the episode to get the link for.
*/
// @Deprecated("Use getEpisodeList instead")
fun fetchVideoList(episode: SEpisode): Observable<List<Video>>
// /**
// * [1.x API] Get the updated details for a anime.
// */
// @Suppress("DEPRECATION")
// override suspend fun getAnimeDetails(anime: AnimeInfo): AnimeInfo {
// val sAnime = anime.toSAnime()
// val networkAnime = fetchAnimeDetails(sAnime).awaitSingle()
// sAnime.copyFrom(networkAnime)
// return sAnime.toAnimeInfo()
// }
// /**
// * [1.x API] Get all the available episodes for a anime.
// */
// @Suppress("DEPRECATION")
// override suspend fun getEpisodeList(anime: AnimeInfo): List<EpisodeInfo> {
// return fetchEpisodeList(anime.toSAnime()).awaitSingle()
// .map { it.toEpisodeInfo() }
// }
// /**
// * [1.x API] Get a link for the episode of an anime.
// */
// @Suppress("DEPRECATION")
// override suspend fun getEpisodeLink(episode: EpisodeInfo): String {
// return fetchEpisodeLink(episode.toSEpisode()).awaitSingle()
// }
}
// fun AnimeSource.icon(): Drawable? = Injekt.get<AnimeExtensionManager>().getAppIconForSource(this)
fun AnimeSource.getPreferenceKey(): String = "source_$id"
@@ -1,12 +0,0 @@
package eu.kanade.tachiyomi.animesource
/**
* A factory for creating sources at runtime.
*/
interface AnimeSourceFactory {
/**
* Create a new copy of the sources
* @return The created sources
*/
fun createSources(): List<AnimeSource>
}
@@ -1,8 +0,0 @@
package eu.kanade.tachiyomi.animesource
import androidx.preference.PreferenceScreen
interface ConfigurableAnimeSource : AnimeSource {
fun setupPreferenceScreen(screen: PreferenceScreen)
}
@@ -1,40 +0,0 @@
package eu.kanade.tachiyomi.animesource.model
sealed class AnimeFilter<T>(val name: String, var state: T) {
open class Header(name: String) : AnimeFilter<Any>(name, 0)
open class Separator(name: String = "") : AnimeFilter<Any>(name, 0)
abstract class Select<V>(name: String, val values: Array<V>, state: Int = 0) : AnimeFilter<Int>(name, state)
abstract class Text(name: String, state: String = "") : AnimeFilter<String>(name, state)
abstract class CheckBox(name: String, state: Boolean = false) : AnimeFilter<Boolean>(name, state)
abstract class TriState(name: String, state: Int = STATE_IGNORE) : AnimeFilter<Int>(name, state) {
fun isIgnored() = state == STATE_IGNORE
fun isIncluded() = state == STATE_INCLUDE
fun isExcluded() = state == STATE_EXCLUDE
companion object {
const val STATE_IGNORE = 0
const val STATE_INCLUDE = 1
const val STATE_EXCLUDE = 2
}
}
abstract class Group<V>(name: String, state: List<V>) : AnimeFilter<List<V>>(name, state)
abstract class Sort(name: String, val values: Array<String>, state: Selection? = null) :
AnimeFilter<Sort.Selection?>(name, state) {
data class Selection(val index: Int, val ascending: Boolean)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is AnimeFilter<*>) return false
return name == other.name && state == other.state
}
override fun hashCode(): Int {
var result = name.hashCode()
result = 31 * result + (state?.hashCode() ?: 0)
return result
}
}
@@ -1,6 +0,0 @@
package eu.kanade.tachiyomi.animesource.model
data class AnimeFilterList(val list: List<AnimeFilter<*>>) : List<AnimeFilter<*>> by list {
constructor(vararg fs: AnimeFilter<*>) : this(if (fs.isNotEmpty()) fs.asList() else emptyList())
}
@@ -1,3 +0,0 @@
package eu.kanade.tachiyomi.animesource.model
data class AnimesPage(val animes: List<SAnime>, val hasNextPage: Boolean)
@@ -1,93 +0,0 @@
package eu.kanade.tachiyomi.animesource.model
// import tachiyomi.animesource.model.AnimeInfo
import java.io.Serializable
interface SAnime : Serializable {
var url: String
var title: String
var artist: String?
var author: String?
var description: String?
var genre: String?
var status: Int
var thumbnail_url: String?
var initialized: Boolean
fun copyFrom(other: SAnime) {
title = other.title
if (other.author != null) {
author = other.author
}
if (other.artist != null) {
artist = other.artist
}
if (other.description != null) {
description = other.description
}
if (other.genre != null) {
genre = other.genre
}
if (other.thumbnail_url != null) {
thumbnail_url = other.thumbnail_url
}
status = other.status
if (!initialized) {
initialized = other.initialized
}
}
companion object {
const val UNKNOWN = 0
const val ONGOING = 1
const val COMPLETED = 2
const val LICENSED = 3
fun create(): SAnime {
return SAnimeImpl()
}
}
}
// fun SAnime.toAnimeInfo(): AnimeInfo {
// return AnimeInfo(
// key = this.url,
// title = this.title,
// artist = this.artist ?: "",
// author = this.author ?: "",
// description = this.description ?: "",
// genres = this.genre?.split(", ") ?: emptyList(),
// status = this.status,
// cover = this.thumbnail_url ?: ""
// )
// }
// fun AnimeInfo.toSAnime(): SAnime {
// val animeInfo = this
// return SAnime.create().apply {
// url = animeInfo.key
// title = animeInfo.title
// artist = animeInfo.artist
// author = animeInfo.author
// description = animeInfo.description
// genre = animeInfo.genres.joinToString(", ")
// status = animeInfo.status
// thumbnail_url = animeInfo.cover
// }
// }
@@ -1,22 +0,0 @@
package eu.kanade.tachiyomi.animesource.model
class SAnimeImpl : SAnime {
override lateinit var url: String
override lateinit var title: String
override var artist: String? = null
override var author: String? = null
override var description: String? = null
override var genre: String? = null
override var status: Int = 0
override var thumbnail_url: String? = null
override var initialized: Boolean = false
}
@@ -1,52 +0,0 @@
package eu.kanade.tachiyomi.animesource.model
// import tachiyomi.animesource.model.EpisodeInfo
import java.io.Serializable
interface SEpisode : Serializable {
var url: String
var name: String
var date_upload: Long
var episode_number: Float
var scanlator: String?
fun copyFrom(other: SEpisode) {
name = other.name
url = other.url
date_upload = other.date_upload
episode_number = other.episode_number
scanlator = other.scanlator
}
companion object {
fun create(): SEpisode {
return SEpisodeImpl()
}
}
}
// fun SEpisode.toEpisodeInfo(): EpisodeInfo {
// return EpisodeInfo(
// dateUpload = this.date_upload,
// key = this.url,
// name = this.name,
// number = this.episode_number,
// scanlator = this.scanlator ?: ""
// )
// }
//
// fun EpisodeInfo.toSEpisode(): SEpisode {
// val episode = this
// return SEpisode.create().apply {
// url = episode.key
// name = episode.name
// date_upload = episode.dateUpload
// episode_number = episode.number
// scanlator = episode.scanlator
// }
// }
@@ -1,14 +0,0 @@
package eu.kanade.tachiyomi.animesource.model
class SEpisodeImpl : SEpisode {
override lateinit var url: String
override lateinit var name: String
override var date_upload: Long = 0
override var episode_number: Float = -1f
override var scanlator: String? = null
}
@@ -1,73 +0,0 @@
package eu.kanade.tachiyomi.animesource.model
import android.net.Uri
import eu.kanade.tachiyomi.network.ProgressListener
import rx.subjects.Subject
// import tachiyomi.animesource.model.VideoUrl
open class Video(
val url: String = "",
val quality: String = "",
var videoUrl: String? = null,
@Transient var uri: Uri? = null // Deprecated but can't be deleted due to extensions
) : ProgressListener {
@Transient
@Volatile
var status: Int = 0
set(value) {
field = value
statusSubject?.onNext(value)
statusCallback?.invoke(this)
}
@Transient
@Volatile
var progress: Int = 0
set(value) {
field = value
statusCallback?.invoke(this)
}
@Transient
private var statusSubject: Subject<Int, Int>? = null
@Transient
private var statusCallback: ((Video) -> Unit)? = null
override fun update(bytesRead: Long, contentLength: Long, done: Boolean) {
progress = if (contentLength > 0) {
(100 * bytesRead / contentLength).toInt()
} else {
-1
}
}
fun setStatusSubject(subject: Subject<Int, Int>?) {
this.statusSubject = subject
}
fun setStatusCallback(f: ((Video) -> Unit)?) {
statusCallback = f
}
companion object {
const val QUEUE = 0
const val LOAD_VIDEO = 1
const val DOWNLOAD_IMAGE = 2
const val READY = 3
const val ERROR = 4
}
}
// fun Video.toVideoUrl(): VideoUrl {
// return VideoUrl(
// url = this.videoUrl ?: this.url
// )
// }
//
// fun VideoUrl.toVideo(index: Int): Video {
// return Video(
// videoUrl = this.url
// )
// }
@@ -1,376 +0,0 @@
package eu.kanade.tachiyomi.animesource.online
import eu.kanade.tachiyomi.animesource.AnimeCatalogueSource
import eu.kanade.tachiyomi.animesource.model.AnimeFilterList
import eu.kanade.tachiyomi.animesource.model.AnimesPage
import eu.kanade.tachiyomi.animesource.model.SAnime
import eu.kanade.tachiyomi.animesource.model.SEpisode
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.network.asObservableSuccess
import eu.kanade.tachiyomi.network.newCallWithProgress
import okhttp3.Headers
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import rx.Observable
import uy.kohesive.injekt.injectLazy
import java.net.URI
import java.net.URISyntaxException
import java.security.MessageDigest
/**
* A simple implementation for sources from a website.
*/
abstract class AnimeHttpSource : AnimeCatalogueSource {
/**
* Network service.
*/
protected val network: NetworkHelper by injectLazy()
// /**
// * Preferences that a source may need.
// */
// val preferences: SharedPreferences by lazy {
// Injekt.get<Application>().getSharedPreferences(source.getPreferenceKey(), Context.MODE_PRIVATE)
// }
/**
* Base url of the website without the trailing slash, like: http://mysite.com
*/
abstract val baseUrl: String
/**
* Version id used to generate the source id. If the site completely changes and urls are
* incompatible, you may increase this value and it'll be considered as a new source.
*/
open val versionId = 1
/**
* Id of the source. By default it uses a generated id using the first 16 characters (64 bits)
* of the MD5 of the string: sourcename/language/versionId
* Note the generated id sets the sign bit to 0.
*/
override val id by lazy {
val key = "${name.lowercase()}/$lang/$versionId"
val bytes = MessageDigest.getInstance("MD5").digest(key.toByteArray())
(0..7).map { bytes[it].toLong() and 0xff shl 8 * (7 - it) }.reduce(Long::or) and Long.MAX_VALUE
}
/**
* Headers used for requests.
*/
val headers: Headers by lazy { headersBuilder().build() }
/**
* Default network client for doing requests.
*/
open val client: OkHttpClient
get() = network.client
/**
* Headers builder for requests. Implementations can override this method for custom headers.
*/
protected open fun headersBuilder() = Headers.Builder().apply {
add("User-Agent", DEFAULT_USER_AGENT)
}
/**
* Visible name of the source.
*/
override fun toString() = "$name (${lang.uppercase()})"
/**
* Returns an observable containing a page with a list of anime. Normally it's not needed to
* override this method.
*
* @param page the page number to retrieve.
*/
override fun fetchPopularAnime(page: Int): Observable<AnimesPage> {
return client.newCall(popularAnimeRequest(page))
.asObservableSuccess()
.map { response ->
popularAnimeParse(response)
}
}
/**
* Returns the request for the popular anime given the page.
*
* @param page the page number to retrieve.
*/
protected abstract fun popularAnimeRequest(page: Int): Request
/**
* Parses the response from the site and returns a [AnimesPage] object.
*
* @param response the response from the site.
*/
protected abstract fun popularAnimeParse(response: Response): AnimesPage
/**
* Returns an observable containing a page with a list of anime. Normally it's not needed to
* override this method.
*
* @param page the page number to retrieve.
* @param query the search query.
* @param filters the list of filters to apply.
*/
override fun fetchSearchAnime(page: Int, query: String, filters: AnimeFilterList): Observable<AnimesPage> {
return client.newCall(searchAnimeRequest(page, query, filters))
.asObservableSuccess()
.map { response ->
searchAnimeParse(response)
}
}
/**
* Returns the request for the search anime given the page.
*
* @param page the page number to retrieve.
* @param query the search query.
* @param filters the list of filters to apply.
*/
protected abstract fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request
/**
* Parses the response from the site and returns a [AnimesPage] object.
*
* @param response the response from the site.
*/
protected abstract fun searchAnimeParse(response: Response): AnimesPage
/**
* Returns an observable containing a page with a list of latest anime updates.
*
* @param page the page number to retrieve.
*/
override fun fetchLatestUpdates(page: Int): Observable<AnimesPage> {
return client.newCall(latestUpdatesRequest(page))
.asObservableSuccess()
.map { response ->
latestUpdatesParse(response)
}
}
/**
* Returns the request for latest anime given the page.
*
* @param page the page number to retrieve.
*/
protected abstract fun latestUpdatesRequest(page: Int): Request
/**
* Parses the response from the site and returns a [AnimesPage] object.
*
* @param response the response from the site.
*/
protected abstract fun latestUpdatesParse(response: Response): AnimesPage
/**
* Returns an observable with the updated details for a anime. Normally it's not needed to
* override this method.
*
* @param anime the anime to be updated.
*/
override fun fetchAnimeDetails(anime: SAnime): Observable<SAnime> {
return client.newCall(animeDetailsRequest(anime))
.asObservableSuccess()
.map { response ->
animeDetailsParse(response).apply { initialized = true }
}
}
/**
* Returns the request for the details of a anime. Override only if it's needed to change the
* url, send different headers or request method like POST.
*
* @param anime the anime to be updated.
*/
open fun animeDetailsRequest(anime: SAnime): Request {
return GET(baseUrl + anime.url, headers)
}
/**
* Parses the response from the site and returns the details of a anime.
*
* @param response the response from the site.
*/
protected abstract fun animeDetailsParse(response: Response): SAnime
/**
* Returns an observable with the updated episode list for a anime. Normally it's not needed to
* override this method. If a anime is licensed an empty episode list observable is returned
*
* @param anime the anime to look for episodes.
*/
override fun fetchEpisodeList(anime: SAnime): Observable<List<SEpisode>> {
return if (anime.status != SAnime.LICENSED) {
client.newCall(episodeListRequest(anime))
.asObservableSuccess()
.map { response ->
episodeListParse(response)
}
} else {
Observable.error(Exception("Licensed - No episodes to show"))
}
}
/**
* Returns the request for updating the episode list. Override only if it's needed to override
* the url, send different headers or request method like POST.
*
* @param anime the anime to look for episodes.
*/
protected open fun episodeListRequest(anime: SAnime): Request {
return GET(baseUrl + anime.url, headers)
}
/**
* Parses the response from the site and returns a list of episodes.
*
* @param response the response from the site.
*/
protected abstract fun episodeListParse(response: Response): List<SEpisode>
/**
* Returns an observable with the page list for a chapter.
*
* @param chapter the chapter whose page list has to be fetched.
*/
override fun fetchVideoList(episode: SEpisode): Observable<List<Video>> {
return client.newCall(videoListRequest(episode))
.asObservableSuccess()
.map { response ->
videoListParse(response)
}
}
/**
* Returns the request for getting the episode link. Override only if it's needed to override
* the url, send different headers or request method like POST.
*
* @param episode the episode to look for links.
*/
protected open fun videoListRequest(episode: SEpisode): Request {
return GET(baseUrl + episode.url, headers)
}
/**
* Parses the response from the site and returns a list of pages.
*
* @param response the response from the site.
*/
protected abstract fun videoListParse(response: Response): List<Video>
/**
* Returns an observable with the page containing the source url of the image. If there's any
* error, it will return null instead of throwing an exception.
*
* @param page the page whose source image has to be fetched.
*/
open fun fetchVideoUrl(video: Video): Observable<String> {
return client.newCall(videoUrlRequest(video))
.asObservableSuccess()
.map { videoUrlParse(it) }
}
/**
* Returns the request for getting the url to the source image. Override only if it's needed to
* override the url, send different headers or request method like POST.
*
* @param page the chapter whose page list has to be fetched
*/
protected open fun videoUrlRequest(video: Video): Request {
return GET(video.url, headers)
}
/**
* Parses the response from the site and returns the absolute url to the source image.
*
* @param response the response from the site.
*/
protected abstract fun videoUrlParse(response: Response): String
/**
* Returns an observable with the response of the source image.
*
* @param page the page whose source image has to be downloaded.
*/
fun fetchVideo(video: Video): Observable<Response> {
return client.newCallWithProgress(videoRequest(video), video)
.asObservableSuccess()
}
/**
* Returns the request for getting the source image. Override only if it's needed to override
* the url, send different headers or request method like POST.
*
* @param video the video whose link has to be fetched
*/
protected open fun videoRequest(video: Video): Request {
return GET(video.videoUrl!!, headers)
}
/**
* Assigns the url of the episode without the scheme and domain. It saves some redundancy from
* database and the urls could still work after a domain change.
*
* @param url the full url to the episode.
*/
fun SEpisode.setUrlWithoutDomain(url: String) {
this.url = getUrlWithoutDomain(url)
}
/**
* Assigns the url of the anime without the scheme and domain. It saves some redundancy from
* database and the urls could still work after a domain change.
*
* @param url the full url to the anime.
*/
fun SAnime.setUrlWithoutDomain(url: String) {
this.url = getUrlWithoutDomain(url)
}
/**
* Returns the url of the given string without the scheme and domain.
*
* @param orig the full url.
*/
private fun getUrlWithoutDomain(orig: String): String {
return try {
val uri = URI(orig)
var out = uri.path
if (uri.query != null) {
out += "?" + uri.query
}
if (uri.fragment != null) {
out += "#" + uri.fragment
}
out
} catch (e: URISyntaxException) {
orig
}
}
/**
* Called before inserting a new episode into database. Use it if you need to override episode
* fields, like the title or the episode number. Do not change anything to [anime].
*
* @param episode the episode to be added.
* @param anime the anime of the episode.
*/
open fun prepareNewEpisode(episode: SEpisode, anime: SAnime) {
}
/**
* Returns the list of filters for the source.
*/
override fun getFilterList() = AnimeFilterList()
companion object {
const val DEFAULT_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.150 Safari/537.36 Edg/88.0.705.63"
}
}
@@ -1,25 +0,0 @@
package eu.kanade.tachiyomi.animesource.online
import eu.kanade.tachiyomi.animesource.model.Video
import rx.Observable
fun AnimeHttpSource.getVideoUrl(video: Video): Observable<Video> {
video.status = Video.LOAD_VIDEO
return fetchVideoUrl(video)
.doOnError { video.status = Video.ERROR }
.onErrorReturn { null }
.doOnNext { video.videoUrl = it }
.map { video }
}
fun AnimeHttpSource.fetchUrlFromVideo(video: Video): Observable<Video> {
return Observable.just(video)
.filter { !it.videoUrl.isNullOrEmpty() }
.mergeWith(fetchRemainingVideoUrlsFromVideoList(video))
}
fun AnimeHttpSource.fetchRemainingVideoUrlsFromVideoList(video: Video): Observable<Video> {
return Observable.just(video)
.filter { it.videoUrl.isNullOrEmpty() }
.concatMap { getVideoUrl(it) }
}
@@ -1,206 +0,0 @@
package eu.kanade.tachiyomi.animesource.online
import eu.kanade.tachiyomi.animesource.model.AnimesPage
import eu.kanade.tachiyomi.animesource.model.SAnime
import eu.kanade.tachiyomi.animesource.model.SEpisode
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.util.asJsoup
import okhttp3.Response
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
/**
* A simple implementation for sources from a website using Jsoup, an HTML parser.
*/
abstract class ParsedAnimeHttpSource : AnimeHttpSource() {
/**
* Parses the response from the site and returns a [AnimesPage] object.
*
* @param response the response from the site.
*/
override fun popularAnimeParse(response: Response): AnimesPage {
val document = response.asJsoup()
val animes = document.select(popularAnimeSelector()).map { element ->
popularAnimeFromElement(element)
}
val hasNextPage = popularAnimeNextPageSelector()?.let { selector ->
document.select(selector).first()
} != null
return AnimesPage(animes, hasNextPage)
}
/**
* Returns the Jsoup selector that returns a list of [Element] corresponding to each anime.
*/
protected abstract fun popularAnimeSelector(): String
/**
* Returns a anime from the given [element]. Most sites only show the title and the url, it's
* totally fine to fill only those two values.
*
* @param element an element obtained from [popularAnimeSelector].
*/
protected abstract fun popularAnimeFromElement(element: Element): SAnime
/**
* Returns the Jsoup selector that returns the <a> tag linking to the next page, or null if
* there's no next page.
*/
protected abstract fun popularAnimeNextPageSelector(): String?
/**
* Parses the response from the site and returns a [AnimesPage] object.
*
* @param response the response from the site.
*/
override fun searchAnimeParse(response: Response): AnimesPage {
val document = response.asJsoup()
val animes = document.select(searchAnimeSelector()).map { element ->
searchAnimeFromElement(element)
}
val hasNextPage = searchAnimeNextPageSelector()?.let { selector ->
document.select(selector).first()
} != null
return AnimesPage(animes, hasNextPage)
}
/**
* Returns the Jsoup selector that returns a list of [Element] corresponding to each anime.
*/
protected abstract fun searchAnimeSelector(): String
/**
* Returns a anime from the given [element]. Most sites only show the title and the url, it's
* totally fine to fill only those two values.
*
* @param element an element obtained from [searchAnimeSelector].
*/
protected abstract fun searchAnimeFromElement(element: Element): SAnime
/**
* Returns the Jsoup selector that returns the <a> tag linking to the next page, or null if
* there's no next page.
*/
protected abstract fun searchAnimeNextPageSelector(): String?
/**
* Parses the response from the site and returns a [AnimesPage] object.
*
* @param response the response from the site.
*/
override fun latestUpdatesParse(response: Response): AnimesPage {
val document = response.asJsoup()
val animes = document.select(latestUpdatesSelector()).map { element ->
latestUpdatesFromElement(element)
}
val hasNextPage = latestUpdatesNextPageSelector()?.let { selector ->
document.select(selector).first()
} != null
return AnimesPage(animes, hasNextPage)
}
/**
* Returns the Jsoup selector that returns a list of [Element] corresponding to each anime.
*/
protected abstract fun latestUpdatesSelector(): String
/**
* Returns a anime from the given [element]. Most sites only show the title and the url, it's
* totally fine to fill only those two values.
*
* @param element an element obtained from [latestUpdatesSelector].
*/
protected abstract fun latestUpdatesFromElement(element: Element): SAnime
/**
* Returns the Jsoup selector that returns the <a> tag linking to the next page, or null if
* there's no next page.
*/
protected abstract fun latestUpdatesNextPageSelector(): String?
/**
* Parses the response from the site and returns the details of a anime.
*
* @param response the response from the site.
*/
override fun animeDetailsParse(response: Response): SAnime {
return animeDetailsParse(response.asJsoup())
}
/**
* Returns the details of the anime from the given [document].
*
* @param document the parsed document.
*/
protected abstract fun animeDetailsParse(document: Document): SAnime
/**
* Parses the response from the site and returns a list of episodes.
*
* @param response the response from the site.
*/
override fun episodeListParse(response: Response): List<SEpisode> {
val document = response.asJsoup()
return document.select(episodeListSelector()).map { episodeFromElement(it) }
}
/**
* Returns the Jsoup selector that returns a list of [Element] corresponding to each episode.
*/
protected abstract fun episodeListSelector(): String
/**
* Returns a episode from the given element.
*
* @param element an element obtained from [episodeListSelector].
*/
protected abstract fun episodeFromElement(element: Element): SEpisode
/**
* Parses the response from the site and returns the page list.
*
* @param response the response from the site.
*/
override fun videoListParse(response: Response): List<Video> {
val document = response.asJsoup()
return document.select(videoListSelector()).map { videoFromElement(it) }
}
/**
* Returns the Jsoup selector that returns a list of [Element] corresponding to each video.
*/
protected abstract fun videoListSelector(): String
/**
* Returns a video from the given element.
*
* @param element an element obtained from [videoListSelector].
*/
protected abstract fun videoFromElement(element: Element): Video
/**
* Parse the response from the site and returns the absolute url to the source video.
*
* @param response the response from the site.
*/
override fun videoUrlParse(response: Response): String {
return videoUrlParse(response.asJsoup())
}
/**
* Returns the absolute url to the source image from the document.
*
* @param document the parsed document.
*/
protected abstract fun videoUrlParse(document: Document): String
}
@@ -1,5 +0,0 @@
package eu.kanade.tachiyomi.annoations
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.CLASS)
annotation class Nsfw
@@ -1,177 +0,0 @@
package eu.kanade.tachiyomi.network
/*
* 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 android.annotation.SuppressLint
// import android.content.Context
// import android.os.Build
// import android.os.Handler
// import android.os.Looper
// import android.webkit.WebSettings
// import android.webkit.WebView
// import android.widget.Toast
// import eu.kanade.tachiyomi.R
// import eu.kanade.tachiyomi.util.lang.launchUI
// import eu.kanade.tachiyomi.util.system.WebViewClientCompat
// import eu.kanade.tachiyomi.util.system.WebViewUtil
// import eu.kanade.tachiyomi.util.system.isOutdated
// import eu.kanade.tachiyomi.util.system.setDefaultSettings
// import eu.kanade.tachiyomi.util.system.toast
import okhttp3.Interceptor
import okhttp3.Response
// import uy.kohesive.injekt.injectLazy
class CloudflareInterceptor() : Interceptor {
// private val handler = Handler(Looper.getMainLooper())
// private val networkHelper = NetworkHelper()
/**
* When this is called, it initializes the WebView if it wasn't already. We use this to avoid
* blocking the main thread too much. If used too often we could consider moving it to the
* Application class.
*/
// private val initWebView by lazy {
// WebSettings.getDefaultUserAgent(context)
// }
@Synchronized
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
return chain.proceed(originalRequest)
// if (!WebViewUtil.supportsWebView(context)) {
// launchUI {
// context.toast(R.string.information_webview_required, Toast.LENGTH_LONG)
// }
// return chain.proceed(originalRequest)
// }
//
// initWebView
//
// val response = chain.proceed(originalRequest)
//
// // Check if Cloudflare anti-bot is on
// if (response.code != 503 || response.header("Server") !in SERVER_CHECK) {
// return response
// }
//
// try {
// response.close()
// networkHelper.cookieManager.remove(originalRequest.url, COOKIE_NAMES, 0)
// val oldCookie = networkHelper.cookieManager.get(originalRequest.url)
// .firstOrNull { it.name == "cf_clearance" }
// resolveWithWebView(originalRequest, oldCookie)
//
// return chain.proceed(originalRequest)
// } catch (e: Exception) {
// // Because OkHttp's enqueue only handles IOExceptions, wrap the exception so that
// // we don't crash the entire app
// throw IOException(e)
// }
}
//
// // @SuppressLint("SetJavaScriptEnabled")
// private fun resolveWithWebView(request: Request, oldCookie: Cookie?) {
// // We need to lock this thread until the WebView finds the challenge solution url, because
// // OkHttp doesn't support asynchronous interceptors.
// val latch = CountDownLatch(1)
//
// var webView: WebView? = null
//
// var challengeFound = false
// var cloudflareBypassed = false
// var isWebViewOutdated = false
//
// val origRequestUrl = request.url.toString()
// val headers = request.headers.toMultimap().mapValues { it.value.getOrNull(0) ?: "" }.toMutableMap()
// headers["X-Requested-With"] = WebViewUtil.REQUESTED_WITH
//
// handler.post {
// val webview = WebView(context)
// webView = webview
// webview.setDefaultSettings()
//
// // Avoid sending empty User-Agent, Chromium WebView will reset to default if empty
// webview.settings.userAgentString = request.header("User-Agent")
// ?: HttpSource.DEFAULT_USERAGENT
//
// webview.webViewClient = object : WebViewClientCompat() {
// override fun onPageFinished(view: WebView, url: String) {
// fun isCloudFlareBypassed(): Boolean {
// return networkHelper.cookieManager.get(origRequestUrl.toHttpUrl())
// .firstOrNull { it.name == "cf_clearance" }
// .let { it != null && it != oldCookie }
// }
//
// if (isCloudFlareBypassed()) {
// cloudflareBypassed = true
// latch.countDown()
// }
//
// // HTTP error codes are only received since M
// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M &&
// url == origRequestUrl && !challengeFound
// ) {
// // The first request didn't return the challenge, abort.
// latch.countDown()
// }
// }
//
// override fun onReceivedErrorCompat(
// view: WebView,
// errorCode: Int,
// description: String?,
// failingUrl: String,
// isMainFrame: Boolean
// ) {
// if (isMainFrame) {
// if (errorCode == 503) {
// // Found the Cloudflare challenge page.
// challengeFound = true
// } else {
// // Unlock thread, the challenge wasn't found.
// latch.countDown()
// }
// }
// }
// }
//
// webView?.loadUrl(origRequestUrl, headers)
// }
//
// // Wait a reasonable amount of time to retrieve the solution. The minimum should be
// // around 4 seconds but it can take more due to slow networks or server issues.
// latch.await(12, TimeUnit.SECONDS)
//
// handler.post {
// if (!cloudflareBypassed) {
// isWebViewOutdated = webView?.isOutdated() == true
// }
//
// webView?.stopLoading()
// webView?.destroy()
// }
//
// // Throw exception if we failed to bypass Cloudflare
// if (!cloudflareBypassed) {
// // Prompt user to update WebView if it seems too outdated
// if (isWebViewOutdated) {
// context.toast(R.string.information_webview_outdated, Toast.LENGTH_LONG)
// }
//
// throw Exception(context.getString(R.string.information_cloudflare_bypass_failure))
// }
// }
//
// companion object {
// private val SERVER_CHECK = arrayOf("cloudflare-nginx", "cloudflare")
// private val COOKIE_NAMES = listOf("__cfduid", "cf_clearance")
// }
}
@@ -10,12 +10,16 @@ package eu.kanade.tachiyomi.network
// import android.content.Context
// import eu.kanade.tachiyomi.BuildConfig
// import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import android.content.Context
// import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
// import okhttp3.dnsoverhttps.DnsOverHttps
// import okhttp3.logging.HttpLoggingInterceptor
// import uy.kohesive.injekt.injectLazy
import android.content.Context
import eu.kanade.tachiyomi.network.interceptor.CloudflareInterceptor
import eu.kanade.tachiyomi.network.interceptor.UserAgentInterceptor
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import suwayomi.tachidesk.server.serverConfig
import java.util.concurrent.TimeUnit
@Suppress("UNUSED_PARAMETER")
@@ -25,55 +29,44 @@ class NetworkHelper(context: Context) {
// private val cacheDir = File(context.cacheDir, "network_cache")
private val cacheSize = 5L * 1024 * 1024 // 5 MiB
// private val cacheSize = 5L * 1024 * 1024 // 5 MiB
val cookieManager = MemoryCookieJar()
val cookieManager = PersistentCookieJar(context)
val client by lazy {
val builder = OkHttpClient.Builder()
.cookieJar(cookieManager)
// .cache(Cache(cacheDir, cacheSize))
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(5, TimeUnit.MINUTES)
.writeTimeout(5, TimeUnit.MINUTES)
// .dispatcher(Dispatcher(Executors.newFixedThreadPool(1)))
private val baseClientBuilder: OkHttpClient.Builder
get() {
val builder = OkHttpClient.Builder()
.cookieJar(cookieManager)
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.addInterceptor(UserAgentInterceptor())
// .addInterceptor(UserAgentInterceptor())
if (serverConfig.debugLogsEnabled) {
val httpLoggingInterceptor = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BASIC
}
builder.addInterceptor(httpLoggingInterceptor)
}
// if (BuildConfig.DEBUG) {
// val httpLoggingInterceptor = HttpLoggingInterceptor().apply {
// level = HttpLoggingInterceptor.Level.HEADERS
// when (preferences.dohProvider()) {
// PREF_DOH_CLOUDFLARE -> builder.dohCloudflare()
// PREF_DOH_GOOGLE -> builder.dohGoogle()
// }
// builder.addInterceptor(httpLoggingInterceptor)
// }
// if (preferences.enableDoh()) {
// builder.dns(
// DnsOverHttps.Builder().client(builder.build())
// .url("https://cloudflare-dns.com/dns-query".toHttpUrl())
// .bootstrapDnsHosts(
// listOf(
// InetAddress.getByName("162.159.36.1"),
// InetAddress.getByName("162.159.46.1"),
// InetAddress.getByName("1.1.1.1"),
// InetAddress.getByName("1.0.0.1"),
// InetAddress.getByName("162.159.132.53"),
// InetAddress.getByName("2606:4700:4700::1111"),
// InetAddress.getByName("2606:4700:4700::1001"),
// InetAddress.getByName("2606:4700:4700::0064"),
// InetAddress.getByName("2606:4700:4700::6400")
// )
// )
// .build()
// )
// }
return builder
}
builder.build()
}
// val client by lazy { baseClientBuilder.cache(Cache(cacheDir, cacheSize)).build() }
val client by lazy { baseClientBuilder.build() }
val cloudflareClient by lazy {
client.newBuilder()
.addInterceptor(CloudflareInterceptor())
.build()
}
// Tachidesk -->
val cookies: PersistentCookieStore
get() = cookieManager.store
// Tachidesk <--
}
@@ -0,0 +1,20 @@
package eu.kanade.tachiyomi.network
import android.content.Context
import okhttp3.Cookie
import okhttp3.CookieJar
import okhttp3.HttpUrl
// from TachiWeb-Server
class PersistentCookieJar(context: Context) : CookieJar {
val store = PersistentCookieStore(context)
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
store.addAll(url, cookies)
}
override fun loadForRequest(url: HttpUrl): List<Cookie> {
return store.get(url)
}
}
@@ -0,0 +1,79 @@
package eu.kanade.tachiyomi.network
import android.content.Context
import okhttp3.Cookie
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import java.net.URI
import java.util.concurrent.ConcurrentHashMap
// from TachiWeb-Server
class PersistentCookieStore(context: Context) {
private val cookieMap = ConcurrentHashMap<String, List<Cookie>>()
private val prefs = context.getSharedPreferences("cookie_store", Context.MODE_PRIVATE)
init {
for ((key, value) in prefs.all) {
@Suppress("UNCHECKED_CAST")
val cookies = value as? Set<String>
if (cookies != null) {
try {
val url = "http://$key".toHttpUrlOrNull() ?: continue
val nonExpiredCookies = cookies.mapNotNull { Cookie.parse(url, it) }
.filter { !it.hasExpired() }
cookieMap.put(key, nonExpiredCookies)
} catch (e: Exception) {
// Ignore
}
}
}
}
@Synchronized
fun addAll(url: HttpUrl, cookies: List<Cookie>) {
val key = url.toUri().host
// Append or replace the cookies for this domain.
val cookiesForDomain = cookieMap[key].orEmpty().toMutableList()
for (cookie in cookies) {
// Find a cookie with the same name. Replace it if found, otherwise add a new one.
val pos = cookiesForDomain.indexOfFirst { it.name == cookie.name }
if (pos == -1) {
cookiesForDomain.add(cookie)
} else {
cookiesForDomain[pos] = cookie
}
}
cookieMap.put(key, cookiesForDomain)
// Get cookies to be stored in disk
val newValues = cookiesForDomain.asSequence()
.filter { it.persistent && !it.hasExpired() }
.map(Cookie::toString)
.toSet()
prefs.edit().putStringSet(key, newValues).apply()
}
@Synchronized
fun removeAll() {
prefs.edit().clear().apply()
cookieMap.clear()
}
fun remove(uri: URI) {
prefs.edit().remove(uri.host).apply()
cookieMap.remove(uri.host)
}
fun get(url: HttpUrl) = get(url.toUri().host)
fun get(uri: URI) = get(uri.host)
private fun get(url: String): List<Cookie> {
return cookieMap[url].orEmpty().filter { !it.hasExpired() }
}
private fun Cookie.hasExpired() = System.currentTimeMillis() >= expiresAt
}
@@ -0,0 +1,110 @@
package eu.kanade.tachiyomi.network.interceptor
import com.gargoylesoftware.htmlunit.BrowserVersion
import com.gargoylesoftware.htmlunit.WebClient
import com.gargoylesoftware.htmlunit.html.HtmlPage
import eu.kanade.tachiyomi.network.NetworkHelper
import mu.KotlinLogging
import okhttp3.Cookie
import okhttp3.HttpUrl
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
import uy.kohesive.injekt.injectLazy
import java.io.IOException
// from TachiWeb-Server
class CloudflareInterceptor : Interceptor {
private val logger = KotlinLogging.logger {}
private val network: NetworkHelper by injectLazy()
@Synchronized
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
logger.trace { "CloudflareInterceptor is being used." }
val response = chain.proceed(originalRequest)
// Check if Cloudflare anti-bot is on
if (response.code != 503 || response.header("Server") !in SERVER_CHECK) {
return response
}
logger.debug { "Cloudflare anti-bot is on, CloudflareInterceptor is kicking in..." }
return try {
response.close()
network.cookies.remove(originalRequest.url.toUri())
chain.proceed(resolveChallenge(response))
} catch (e: Exception) {
// Because OkHttp's enqueue only handles IOExceptions, wrap the exception so that
// we don't crash the entire app
throw IOException(e)
}
}
private fun resolveChallenge(response: Response): Request {
val browserVersion = BrowserVersion.BrowserVersionBuilder(BrowserVersion.BEST_SUPPORTED)
.setUserAgent(response.request.header("User-Agent") ?: BrowserVersion.BEST_SUPPORTED.userAgent)
.build()
val convertedCookies = WebClient(browserVersion).use { webClient ->
webClient.options.isThrowExceptionOnFailingStatusCode = false
webClient.options.isThrowExceptionOnScriptError = false
webClient.getPage<HtmlPage>(response.request.url.toString())
webClient.waitForBackgroundJavaScript(10000)
// Challenge solved, process cookies
webClient.cookieManager.cookies.filter {
// Only include Cloudflare cookies
it.name.startsWith("__cf") || it.name.startsWith("cf_")
}.map {
// Convert cookies -> OkHttp format
Cookie.Builder()
.domain(it.domain.removePrefix("."))
.expiresAt(it.expires?.time ?: Long.MAX_VALUE)
.name(it.name)
.path(it.path)
.value(it.value).apply {
if (it.isHttpOnly) httpOnly()
if (it.isSecure) secure()
}.build()
}
}
// Copy cookies to cookie store
convertedCookies.forEach {
network.cookies.addAll(
HttpUrl.Builder()
.scheme("http")
.host(it.domain)
.build(),
listOf(it)
)
}
// Merge new and existing cookies for this request
// Find the cookies that we need to merge into this request
val convertedForThisRequest = convertedCookies.filter {
it.matches(response.request.url)
}
// Extract cookies from current request
val existingCookies = Cookie.parseAll(
response.request.url,
response.request.headers
)
// Filter out existing values of cookies that we are about to merge in
val filteredExisting = existingCookies.filter { existing ->
convertedForThisRequest.none { converted -> converted.name == existing.name }
}
val newCookies = filteredExisting + convertedForThisRequest
return response.request.newBuilder()
.header("Cookie", newCookies.map { it.toString() }.joinToString("; "))
.build()
}
companion object {
private val SERVER_CHECK = arrayOf("cloudflare-nginx", "cloudflare")
private val COOKIE_NAMES = listOf("cf_clearance")
}
}
@@ -0,0 +1,22 @@
package eu.kanade.tachiyomi.network.interceptor
import eu.kanade.tachiyomi.source.online.HttpSource
import okhttp3.Interceptor
import okhttp3.Response
class UserAgentInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
return if (originalRequest.header("User-Agent").isNullOrEmpty()) {
val newRequest = originalRequest
.newBuilder()
.removeHeader("User-Agent")
.addHeader("User-Agent", HttpSource.DEFAULT_USER_AGENT)
.build()
chain.proceed(newRequest)
} else {
chain.proceed(originalRequest)
}
}
}
@@ -1,358 +0,0 @@
package eu.kanade.tachiyomi.source
import android.content.Context
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import rx.Observable
// import com.github.junrar.Archive
// import com.google.gson.JsonParser
// import eu.kanade.tachiyomi.R
// import eu.kanade.tachiyomi.source.model.Filter
// import eu.kanade.tachiyomi.source.model.FilterList
// import eu.kanade.tachiyomi.source.model.MangasPage
// import eu.kanade.tachiyomi.source.model.Page
// import eu.kanade.tachiyomi.source.model.SChapter
// import eu.kanade.tachiyomi.source.model.SManga
// import eu.kanade.tachiyomi.util.chapter.ChapterRecognition
// import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder
// import eu.kanade.tachiyomi.util.storage.DiskUtil
// import eu.kanade.tachiyomi.util.storage.EpubFile
// import eu.kanade.tachiyomi.util.system.ImageUtil
// import rx.Observable
// import timber.log.Timber
// import java.io.File
// import java.io.FileInputStream
// import java.io.InputStream
// import java.util.Locale
// import java.util.concurrent.TimeUnit
// import java.util.zip.ZipFile
class LocalSource(private val context: Context) : CatalogueSource {
companion object {
const val ID = 0L
// const val HELP_URL = "https://tachiyomi.org/help/guides/reading-local-manga/"
//
// private const val COVER_NAME = "cover.jpg"
// private val SUPPORTED_ARCHIVE_TYPES = setOf("zip", "rar", "cbr", "cbz", "epub")
//
// private val POPULAR_FILTERS = FilterList(OrderBy())
// private val LATEST_FILTERS = FilterList(OrderBy().apply { state = Filter.Sort.Selection(1, false) })
// private val LATEST_THRESHOLD = TimeUnit.MILLISECONDS.convert(7, TimeUnit.DAYS)
//
// fun updateCover(context: Context, manga: SManga, input: InputStream): File? {
// val dir = getBaseDirectories(context).firstOrNull()
// if (dir == null) {
// input.close()
// return null
// }
// val cover = File("${dir.absolutePath}/${manga.url}", COVER_NAME)
//
// // It might not exist if using the external SD card
// cover.parentFile?.mkdirs()
// input.use {
// cover.outputStream().use {
// input.copyTo(it)
// }
// }
// return cover
// }
//
// private fun getBaseDirectories(context: Context): List<File> {
// val c = context.getString(R.string.app_name) + File.separator + "local"
// return DiskUtil.getExternalStorages(context).map { File(it.absolutePath, c) }
// }
}
override val id = ID
override val name = "Local source"
override val lang = ""
override val supportsLatest = true
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
TODO("Not yet implemented")
}
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
TODO("Not yet implemented")
}
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
TODO("Not yet implemented")
}
override fun fetchPopularManga(page: Int): Observable<MangasPage> {
TODO("Not yet implemented")
}
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
TODO("Not yet implemented")
}
override fun fetchLatestUpdates(page: Int): Observable<MangasPage> {
TODO("Not yet implemented")
}
override fun getFilterList(): FilterList {
TODO("Not yet implemented")
}
//
// override fun toString() = context.getString(R.string.local_source)
//
// override fun fetchPopularManga(page: Int) = fetchSearchManga(page, "", POPULAR_FILTERS)
//
// override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
// val baseDirs = getBaseDirectories(context)
//
// val time = if (filters === LATEST_FILTERS) System.currentTimeMillis() - LATEST_THRESHOLD else 0L
// var mangaDirs = baseDirs
// .asSequence()
// .mapNotNull { it.listFiles()?.toList() }
// .flatten()
// .filter { it.isDirectory }
// .filterNot { it.name.startsWith('.') }
// .filter { if (time == 0L) it.name.contains(query, ignoreCase = true) else it.lastModified() >= time }
// .distinctBy { it.name }
//
// val state = ((if (filters.isEmpty()) POPULAR_FILTERS else filters)[0] as OrderBy).state
// when (state?.index) {
// 0 -> {
// mangaDirs = if (state.ascending) {
// mangaDirs.sortedBy { it.name.toLowerCase(Locale.ENGLISH) }
// } else {
// mangaDirs.sortedByDescending { it.name.toLowerCase(Locale.ENGLISH) }
// }
// }
// 1 -> {
// mangaDirs = if (state.ascending) {
// mangaDirs.sortedBy(File::lastModified)
// } else {
// mangaDirs.sortedByDescending(File::lastModified)
// }
// }
// }
//
// val mangas = mangaDirs.map { mangaDir ->
// SManga.create().apply {
// title = mangaDir.name
// url = mangaDir.name
//
// // Try to find the cover
// for (dir in baseDirs) {
// val cover = File("${dir.absolutePath}/$url", COVER_NAME)
// if (cover.exists()) {
// thumbnail_url = cover.absolutePath
// break
// }
// }
//
// val chapters = fetchChapterList(this).toBlocking().first()
// if (chapters.isNotEmpty()) {
// val chapter = chapters.last()
// val format = getFormat(chapter)
// if (format is Format.Epub) {
// EpubFile(format.file).use { epub ->
// epub.fillMangaMetadata(this)
// }
// }
//
// // Copy the cover from the first chapter found.
// if (thumbnail_url == null) {
// try {
// val dest = updateCover(chapter, this)
// thumbnail_url = dest?.absolutePath
// } catch (e: Exception) {
// Timber.e(e)
// }
// }
// }
// }
// }
//
// return Observable.just(MangasPage(mangas.toList(), false))
// }
//
// override fun fetchLatestUpdates(page: Int) = fetchSearchManga(page, "", LATEST_FILTERS)
//
// override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
// getBaseDirectories(context)
// .asSequence()
// .mapNotNull { File(it, manga.url).listFiles()?.toList() }
// .flatten()
// .firstOrNull { it.extension == "json" }
// ?.apply {
// val reader = this.inputStream().bufferedReader()
// val json = JsonParser.parseReader(reader).asJsonObject
//
// manga.title = json["title"]?.asString ?: manga.title
// manga.author = json["author"]?.asString ?: manga.author
// manga.artist = json["artist"]?.asString ?: manga.artist
// manga.description = json["description"]?.asString ?: manga.description
// manga.genre = json["genre"]?.asJsonArray?.joinToString(", ") { it.asString }
// ?: manga.genre
// manga.status = json["status"]?.asInt ?: manga.status
// }
//
// return Observable.just(manga)
// }
//
// override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
// val chapters = getBaseDirectories(context)
// .asSequence()
// .mapNotNull { File(it, manga.url).listFiles()?.toList() }
// .flatten()
// .filter { it.isDirectory || isSupportedFile(it.extension) }
// .map { chapterFile ->
// SChapter.create().apply {
// url = "${manga.url}/${chapterFile.name}"
// name = if (chapterFile.isDirectory) {
// chapterFile.name
// } else {
// chapterFile.nameWithoutExtension
// }
// date_upload = chapterFile.lastModified()
//
// val format = getFormat(this)
// if (format is Format.Epub) {
// EpubFile(format.file).use { epub ->
// epub.fillChapterMetadata(this)
// }
// }
//
// val chapNameCut = stripMangaTitle(name, manga.title)
// if (chapNameCut.isNotEmpty()) name = chapNameCut
// ChapterRecognition.parseChapterNumber(this, manga)
// }
// }
// .sortedWith(
// Comparator { c1, c2 ->
// val c = c2.chapter_number.compareTo(c1.chapter_number)
// if (c == 0) c2.name.compareToCaseInsensitiveNaturalOrder(c1.name) else c
// }
// )
// .toList()
//
// return Observable.just(chapters)
// }
//
// /**
// * Strips the manga title from a chapter name, matching only based on alphanumeric and whitespace
// * characters.
// */
// private fun stripMangaTitle(chapterName: String, mangaTitle: String): String {
// var chapterNameIndex = 0
// var mangaTitleIndex = 0
// while (chapterNameIndex < chapterName.length && mangaTitleIndex < mangaTitle.length) {
// val chapterChar = chapterName[chapterNameIndex]
// val mangaChar = mangaTitle[mangaTitleIndex]
// if (!chapterChar.equals(mangaChar, true)) {
// val invalidChapterChar = !chapterChar.isLetterOrDigit() && !chapterChar.isWhitespace()
// val invalidMangaChar = !mangaChar.isLetterOrDigit() && !mangaChar.isWhitespace()
//
// if (!invalidChapterChar && !invalidMangaChar) {
// return chapterName
// }
//
// if (invalidChapterChar) {
// chapterNameIndex++
// }
//
// if (invalidMangaChar) {
// mangaTitleIndex++
// }
// } else {
// chapterNameIndex++
// mangaTitleIndex++
// }
// }
//
// return chapterName.substring(chapterNameIndex).trimStart(' ', '-', '_', ',', ':')
// }
//
// override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
// return Observable.error(Exception("Unused"))
// }
//
// private fun isSupportedFile(extension: String): Boolean {
// return extension.toLowerCase() in SUPPORTED_ARCHIVE_TYPES
// }
//
// fun getFormat(chapter: SChapter): Format {
// val baseDirs = getBaseDirectories(context)
//
// for (dir in baseDirs) {
// val chapFile = File(dir, chapter.url)
// if (!chapFile.exists()) continue
//
// return getFormat(chapFile)
// }
// throw Exception("Chapter not found")
// }
//
// private fun getFormat(file: File): Format {
// val extension = file.extension
// return if (file.isDirectory) {
// Format.Directory(file)
// } else if (extension.equals("zip", true) || extension.equals("cbz", true)) {
// Format.Zip(file)
// } else if (extension.equals("rar", true) || extension.equals("cbr", true)) {
// Format.Rar(file)
// } else if (extension.equals("epub", true)) {
// Format.Epub(file)
// } else {
// throw Exception("Invalid chapter format")
// }
// }
//
// private fun updateCover(chapter: SChapter, manga: SManga): File? {
// return when (val format = getFormat(chapter)) {
// is Format.Directory -> {
// val entry = format.file.listFiles()
// ?.sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) }
// ?.find { !it.isDirectory && ImageUtil.isImage(it.name) { FileInputStream(it) } }
//
// entry?.let { updateCover(context, manga, it.inputStream()) }
// }
// is Format.Zip -> {
// ZipFile(format.file).use { zip ->
// val entry = zip.entries().toList()
// .sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) }
// .find { !it.isDirectory && ImageUtil.isImage(it.name) { zip.getInputStream(it) } }
//
// entry?.let { updateCover(context, manga, zip.getInputStream(it)) }
// }
// }
// is Format.Rar -> {
// Archive(format.file).use { archive ->
// val entry = archive.fileHeaders
// .sortedWith { f1, f2 -> f1.fileName.compareToCaseInsensitiveNaturalOrder(f2.fileName) }
// .find { !it.isDirectory && ImageUtil.isImage(it.fileName) { archive.getInputStream(it) } }
//
// entry?.let { updateCover(context, manga, archive.getInputStream(it)) }
// }
// }
// is Format.Epub -> {
// EpubFile(format.file).use { epub ->
// val entry = epub.getImagesFromPages()
// .firstOrNull()
// ?.let { epub.getEntry(it) }
//
// entry?.let { updateCover(context, manga, epub.getInputStream(it)) }
// }
// }
// }
// }
//
// private class OrderBy : Filter.Sort("Order by", arrayOf("Title", "Date"), Selection(0, true))
//
// override fun getFilterList() = FilterList(OrderBy())
//
// sealed class Format {
// data class Directory(val file: File) : Format()
// data class Zip(val file: File) : Format()
// data class Rar(val file: File) : Format()
// data class Epub(val file: File) : Format()
// }
}
@@ -0,0 +1,400 @@
package eu.kanade.tachiyomi.source.local
import com.github.junrar.Archive
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.local.LocalSource.Format.Directory
import eu.kanade.tachiyomi.source.local.LocalSource.Format.Epub
import eu.kanade.tachiyomi.source.local.LocalSource.Format.Rar
import eu.kanade.tachiyomi.source.local.LocalSource.Format.Zip
import eu.kanade.tachiyomi.source.local.loader.EpubPageLoader
import eu.kanade.tachiyomi.source.local.loader.RarPageLoader
import eu.kanade.tachiyomi.source.local.loader.ZipPageLoader
import eu.kanade.tachiyomi.source.model.Filter
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.util.chapter.ChapterRecognition
import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder
import eu.kanade.tachiyomi.util.storage.EpubFile
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.decodeFromStream
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonPrimitive
import mu.KotlinLogging
import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.insertAndGetId
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction
import org.kodein.di.DI
import org.kodein.di.conf.global
import org.kodein.di.instance
import rx.Observable
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.registerCatalogueSource
import suwayomi.tachidesk.manga.impl.util.storage.ImageUtil
import suwayomi.tachidesk.manga.model.table.ExtensionTable
import suwayomi.tachidesk.manga.model.table.SourceTable
import suwayomi.tachidesk.server.ApplicationDirs
import uy.kohesive.injekt.injectLazy
import java.io.File
import java.io.FileInputStream
import java.io.InputStream
import java.util.Locale
import java.util.concurrent.TimeUnit
import java.util.zip.ZipFile
class LocalSource : CatalogueSource {
companion object {
const val ID = 0L
const val LANG = "localsourcelang"
const val NAME = "Local source"
const val EXTENSION_NAME = "Local Source fake extension"
const val HELP_URL = "https://tachiyomi.org/help/guides/local-manga/"
private val SUPPORTED_ARCHIVE_TYPES = setOf("zip", "rar", "cbr", "cbz", "epub")
private val LATEST_THRESHOLD = TimeUnit.MILLISECONDS.convert(7, TimeUnit.DAYS)
private val logger = KotlinLogging.logger {}
private val applicationDirs by DI.global.instance<ApplicationDirs>()
val pageCache: MutableMap<String, List<() -> InputStream>> = mutableMapOf()
fun updateCover(manga: SManga, input: InputStream): File? {
val cover = getCoverFile(File("${applicationDirs.localMangaRoot}/${manga.url}"))
?: File("${applicationDirs.localMangaRoot}/${manga.url}/cover.jpg")
cover.parentFile?.mkdirs()
input.use {
cover.outputStream().use {
input.copyTo(it)
}
}
return cover
}
/**
* Returns valid cover file inside [parent] directory.
*/
private fun getCoverFile(parent: File): File? {
return parent.listFiles()?.find { it.nameWithoutExtension == "cover" }?.takeIf {
it.isFile && ImageUtil.isImage(it.name) { it.inputStream() }
}
}
fun register() {
transaction {
val sourceRecord = SourceTable.select { SourceTable.id eq ID }.firstOrNull()
if (sourceRecord == null) {
// must do this to avoid database integrity errors
val extensionId = ExtensionTable.insertAndGetId {
it[apkName] = "localSource"
it[name] = EXTENSION_NAME
it[pkgName] = LocalSource::class.java.`package`.name
it[versionName] = "1.2"
it[versionCode] = 0
it[lang] = LANG
it[isNsfw] = false
it[isInstalled] = true
}
SourceTable.insert {
it[id] = ID
it[name] = NAME
it[lang] = LANG
it[extension] = extensionId
it[isNsfw] = false
}
}
}
registerCatalogueSource(ID to LocalSource())
}
}
override val id = ID
override val name = NAME
override val lang = LANG
override val supportsLatest = true
private val json: Json by injectLazy()
override fun toString() = name
override fun fetchPopularManga(page: Int) = fetchSearchManga(page, "", POPULAR_FILTERS)
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
val time = if (filters === LATEST_FILTERS) System.currentTimeMillis() - LATEST_THRESHOLD else 0L
var mangaDirs = File(applicationDirs.localMangaRoot).listFiles().orEmpty().toList()
.filter { it.isDirectory }
.filterNot { it.name.startsWith('.') }
.filter { if (time == 0L) it.name.contains(query, ignoreCase = true) else it.lastModified() >= time }
.distinctBy { it.name }
val state = ((if (filters.isEmpty()) POPULAR_FILTERS else filters)[0] as OrderBy).state
when (state?.index) {
0 -> {
mangaDirs = if (state.ascending) {
mangaDirs.sortedBy { it.name.lowercase(Locale.ENGLISH) }
} else {
mangaDirs.sortedByDescending { it.name.lowercase(Locale.ENGLISH) }
}
}
1 -> {
mangaDirs = if (state.ascending) {
mangaDirs.sortedBy(File::lastModified)
} else {
mangaDirs.sortedByDescending(File::lastModified)
}
}
}
val mangas = mangaDirs.map { mangaDir ->
SManga.create().apply {
title = mangaDir.name
url = mangaDir.name
// Try to find the cover
val cover = getCoverFile(File("${applicationDirs.localMangaRoot}/$url"))
if (cover != null && cover.exists()) {
thumbnail_url = cover.absolutePath
}
val chapters = fetchChapterList(this).toBlocking().first()
if (chapters.isNotEmpty()) {
val chapter = chapters.last()
val format = getFormat(chapter)
if (format is Format.Epub) {
EpubFile(format.file).use { epub ->
epub.fillMangaMetadata(this)
}
}
// Copy the cover from the first chapter found.
if (thumbnail_url == null) {
try {
thumbnail_url = updateCover(chapter, this)?.absolutePath
} catch (e: Exception) {
logger.error { e }
}
}
}
}
}
return Observable.just(MangasPage(mangas.toList(), false))
}
override fun fetchLatestUpdates(page: Int) = fetchSearchManga(page, "", LATEST_FILTERS)
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
File(applicationDirs.localMangaRoot, manga.url).listFiles().orEmpty().toList()
.firstOrNull { it.extension == "json" }
?.apply {
val obj = json.decodeFromStream<JsonObject>(inputStream())
manga.title = obj["title"]?.jsonPrimitive?.contentOrNull ?: manga.title
manga.author = obj["author"]?.jsonPrimitive?.contentOrNull ?: manga.author
manga.artist = obj["artist"]?.jsonPrimitive?.contentOrNull ?: manga.artist
manga.description = obj["description"]?.jsonPrimitive?.contentOrNull ?: manga.description
manga.genre = obj["genre"]?.jsonArray?.joinToString(", ") { it.jsonPrimitive.content }
?: manga.genre
manga.status = obj["status"]?.jsonPrimitive?.intOrNull ?: manga.status
}
return Observable.just(manga)
}
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
val chapters = File(applicationDirs.localMangaRoot, manga.url).listFiles().orEmpty().toList()
.filter { it.isDirectory || isSupportedFile(it.extension) }
.map { chapterFile ->
SChapter.create().apply {
url = "${manga.url}/${chapterFile.name}"
name = if (chapterFile.isDirectory) {
chapterFile.name
} else {
chapterFile.nameWithoutExtension
}
date_upload = chapterFile.lastModified()
val format = getFormat(this)
if (format is Format.Epub) {
EpubFile(format.file).use { epub ->
epub.fillChapterMetadata(this)
}
}
val chapNameCut = stripMangaTitle(name, manga.title)
if (chapNameCut.isNotEmpty()) name = chapNameCut
ChapterRecognition.parseChapterNumber(this, manga)
}
}
.sortedWith { c1, c2 ->
val c = c2.chapter_number.compareTo(c1.chapter_number)
if (c == 0) c2.name.compareToCaseInsensitiveNaturalOrder(c1.name) else c
}
.toList()
return Observable.just(chapters)
}
/**
* Strips the manga title from a chapter name, matching only based on alphanumeric and whitespace
* characters.
*/
private fun stripMangaTitle(chapterName: String, mangaTitle: String): String {
var chapterNameIndex = 0
var mangaTitleIndex = 0
while (chapterNameIndex < chapterName.length && mangaTitleIndex < mangaTitle.length) {
val chapterChar = chapterName[chapterNameIndex]
val mangaChar = mangaTitle[mangaTitleIndex]
if (!chapterChar.equals(mangaChar, true)) {
val invalidChapterChar = !chapterChar.isLetterOrDigit() && !chapterChar.isWhitespace()
val invalidMangaChar = !mangaChar.isLetterOrDigit() && !mangaChar.isWhitespace()
if (!invalidChapterChar && !invalidMangaChar) {
return chapterName
}
if (invalidChapterChar) {
chapterNameIndex++
}
if (invalidMangaChar) {
mangaTitleIndex++
}
} else {
chapterNameIndex++
mangaTitleIndex++
}
}
return chapterName.substring(chapterNameIndex).trimStart(' ', '-', '_', ',', ':')
}
private fun isSupportedFile(extension: String): Boolean {
return extension.lowercase() in SUPPORTED_ARCHIVE_TYPES
}
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
val chapterFile = File(applicationDirs.localMangaRoot + "/" + chapter.url)
return when (getFormat(chapterFile)) {
is Directory -> {
Observable.just(
chapterFile.listFiles().orEmpty().sortedBy { it.name }.mapIndexed { index, page ->
Page(
index,
imageUrl = applicationDirs.localMangaRoot + "/" + chapter.url + "/" + page.name
)
}
)
}
is Zip -> {
val pages = ZipPageLoader(chapterFile).getPages()
pageCache[chapter.url] = pages.map { it.stream!! }
Observable.just(pages)
}
is Rar -> {
val pages = RarPageLoader(chapterFile).getPages()
pageCache[chapter.url] = pages.map { it.stream!! }
Observable.just(pages)
}
is Epub -> {
val pages = EpubPageLoader(chapterFile).getPages()
pageCache[chapter.url] = pages.map { it.stream!! }
Observable.just(pages)
}
}
}
fun getFormat(chapter: SChapter): Format {
val chapFile = File(applicationDirs.localMangaRoot, chapter.url)
if (chapFile.exists())
return getFormat(chapFile)
throw Exception("Chapter not found")
}
private fun getFormat(file: File): Format = with(file) {
when {
isDirectory -> Format.Directory(this)
extension.equals("zip", true) || extension.equals("cbz", true) -> Format.Zip(this)
extension.equals("rar", true) || extension.equals("cbr", true) -> Format.Rar(this)
extension.equals("epub", true) -> Format.Epub(this)
else -> throw Exception("Invalid chapter format")
}
}
private fun updateCover(chapter: SChapter, manga: SManga): File? {
return when (val format = getFormat(chapter)) {
is Format.Directory -> {
val entry = format.file.listFiles()
?.sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) }
?.find { !it.isDirectory && ImageUtil.isImage(it.name) { FileInputStream(it) } }
entry?.let { updateCover(manga, it.inputStream()) }
}
is Format.Zip -> {
ZipFile(format.file).use { zip ->
val entry = zip.entries().toList()
.sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) }
.find { !it.isDirectory && ImageUtil.isImage(it.name) { zip.getInputStream(it) } }
entry?.let { updateCover(manga, zip.getInputStream(it)) }
}
}
is Format.Rar -> {
Archive(format.file).use { archive ->
val entry = archive.fileHeaders
.sortedWith { f1, f2 -> f1.fileName.compareToCaseInsensitiveNaturalOrder(f2.fileName) }
.find { !it.isDirectory && ImageUtil.isImage(it.fileName) { archive.getInputStream(it) } }
entry?.let { updateCover(manga, archive.getInputStream(it)) }
}
}
is Format.Epub -> {
EpubFile(format.file).use { epub ->
val entry = epub.getImagesFromPages()
.firstOrNull()
?.let { epub.getEntry(it) }
entry?.let { updateCover(manga, epub.getInputStream(it)) }
}
}
}
}
override fun getFilterList() = POPULAR_FILTERS
private val POPULAR_FILTERS = FilterList(OrderBy())
private val LATEST_FILTERS = FilterList(OrderBy().apply { state = Filter.Sort.Selection(1, false) })
private class OrderBy : Filter.Sort(
"Order by",
arrayOf("Title", "Date"),
Selection(0, true)
)
sealed class Format {
data class Directory(val file: File) : Format()
data class Zip(val file: File) : Format()
data class Rar(val file: File) : Format()
data class Epub(val file: File) : Format()
}
}
@@ -0,0 +1,31 @@
package eu.kanade.tachiyomi.source.local.loader
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.util.storage.EpubFile
import java.io.File
/**
* Loader used to load a chapter from a .epub file.
*/
class EpubPageLoader(file: File) : PageLoader {
/**
* The epub file.
*/
private val epub = EpubFile(file)
/**
* Returns an observable containing the pages found on this zip archive ordered with a natural
* comparator.
*/
override fun getPages(): List<ReaderPage> {
return epub.getImagesFromPages()
.mapIndexed { i, path ->
val streamFn = { epub.getInputStream(epub.getEntry(path)!!) }
ReaderPage(i).apply {
stream = streamFn
status = Page.READY
}
}
}
}
@@ -0,0 +1,10 @@
package eu.kanade.tachiyomi.source.local.loader
// adapted from eu.kanade.tachiyomi.ui.reader.loader.PageLoader
interface PageLoader {
/**
* Returns an observable containing the list of pages of a chapter. Only the first emission
* will be used.
*/
fun getPages(): List<ReaderPage>
}
@@ -0,0 +1,63 @@
package eu.kanade.tachiyomi.source.local.loader
import com.github.junrar.Archive
import com.github.junrar.rarfile.FileHeader
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder
import suwayomi.tachidesk.manga.impl.util.storage.ImageUtil
import java.io.File
import java.io.InputStream
import java.io.PipedInputStream
import java.io.PipedOutputStream
import java.util.concurrent.Executors
/**
* Loader used to load a chapter from a .rar or .cbr file.
*/
class RarPageLoader(file: File) : PageLoader {
/**
* The rar archive to load pages from.
*/
private val archive = Archive(file)
/**
* Pool for copying compressed files to an input stream.
*/
private val pool = Executors.newFixedThreadPool(1)
/**
* Returns an observable containing the pages found on this rar archive ordered with a natural
* comparator.
*/
override fun getPages(): List<ReaderPage> {
return archive.fileHeaders
.filter { !it.isDirectory && ImageUtil.isImage(it.fileName) { archive.getInputStream(it) } }
.sortedWith { f1, f2 -> f1.fileName.compareToCaseInsensitiveNaturalOrder(f2.fileName) }
.mapIndexed { i, header ->
val streamFn = { getStream(header) }
ReaderPage(i).apply {
stream = streamFn
status = Page.READY
}
}
}
/**
* Returns an input stream for the given [header].
*/
private fun getStream(header: FileHeader): InputStream {
val pipeIn = PipedInputStream()
val pipeOut = PipedOutputStream(pipeIn)
pool.execute {
try {
pipeOut.use {
archive.extractFile(header, it)
}
} catch (e: Exception) {
}
}
return pipeIn
}
}
@@ -0,0 +1,11 @@
package eu.kanade.tachiyomi.source.local.loader
import eu.kanade.tachiyomi.source.model.Page
import java.io.InputStream
class ReaderPage(
index: Int,
url: String = "",
imageUrl: String? = null,
var stream: (() -> InputStream)? = null
) : Page(index, url, imageUrl, null)
@@ -0,0 +1,31 @@
package eu.kanade.tachiyomi.source.local.loader
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder
import suwayomi.tachidesk.manga.impl.util.storage.ImageUtil
import java.io.File
import java.util.zip.ZipFile
class ZipPageLoader(file: File) : PageLoader {
/**
* The zip file to load pages from.
*/
private val zip = ZipFile(file)
/**
* Returns an observable containing the pages found on this zip archive ordered with a natural
* comparator.
*/
override fun getPages(): List<ReaderPage> {
return zip.entries().toList()
.filter { !it.isDirectory && ImageUtil.isImage(it.name) { zip.getInputStream(it) } }
.sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) }
.mapIndexed { i, entry ->
val streamFn = { zip.getInputStream(entry) }
ReaderPage(i).apply {
stream = streamFn
status = Page.READY
}
}
}
}
@@ -1,6 +1,8 @@
package eu.kanade.tachiyomi.source.model
sealed class Filter<T>(val name: String, var state: T) {
// The class is originally sealed, Tachidesk adds new subclasses for serialization
// sealed class Filter<T>(val name: String, var state: T) {
open class Filter<T>(val name: String, var state: T) {
open class Header(name: String) : Filter<Any>(name, 0)
open class Separator(name: String = "") : Filter<Any>(name, 0)
abstract class Select<V>(name: String, val values: Array<V>, state: Int = 0) : Filter<Int>(name, state)
@@ -61,3 +61,30 @@ interface SManga : Serializable {
}
}
}
// fun SManga.toMangaInfo(): MangaInfo {
// return MangaInfo(
// key = this.url,
// title = this.title,
// artist = this.artist ?: "",
// author = this.author ?: "",
// description = this.description ?: "",
// genres = this.genre?.split(", ") ?: emptyList(),
// status = this.status,
// cover = this.thumbnail_url ?: ""
// )
// }
//
// fun MangaInfo.toSManga(): SManga {
// val mangaInfo = this
// return SManga.create().apply {
// url = mangaInfo.key
// title = mangaInfo.title
// artist = mangaInfo.artist
// author = mangaInfo.author
// description = mangaInfo.description
// genre = mangaInfo.genres.joinToString(", ")
// status = mangaInfo.status
// thumbnail_url = mangaInfo.cover
// }
// }
@@ -75,7 +75,7 @@ abstract class HttpSource : CatalogueSource {
* Headers builder for requests. Implementations can override this method for custom headers.
*/
protected open fun headersBuilder() = Headers.Builder().apply {
add("User-Agent", DEFAULT_USERAGENT)
add("User-Agent", DEFAULT_USER_AGENT)
}
/**
@@ -372,6 +372,6 @@ abstract class HttpSource : CatalogueSource {
override fun getFilterList() = FilterList()
companion object {
const val DEFAULT_USERAGENT = "Mozilla/5.0 (Windows NT 6.3; WOW64)"
const val DEFAULT_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.150 Safari/537.36 Edg/88.0.705.63"
}
}
@@ -0,0 +1,152 @@
package eu.kanade.tachiyomi.util.chapter
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
/**
* -R> = regex conversion.
*/
object ChapterRecognition {
/**
* All cases with Ch.xx
* Mokushiroku Alice Vol.1 Ch. 4: Misrepresentation -R> 4
*/
private val basic = Regex("""(?<=ch\.) *([0-9]+)(\.[0-9]+)?(\.?[a-z]+)?""")
/**
* Regex used when only one number occurrence
* Example: Bleach 567: Down With Snowwhite -R> 567
*/
private val occurrence = Regex("""([0-9]+)(\.[0-9]+)?(\.?[a-z]+)?""")
/**
* Regex used when manga title removed
* Example: Solanin 028 Vol. 2 -> 028 Vol.2 -> 028Vol.2 -R> 028
*/
private val withoutManga = Regex("""^([0-9]+)(\.[0-9]+)?(\.?[a-z]+)?""")
/**
* Regex used to remove unwanted tags
* Example Prison School 12 v.1 vol004 version1243 volume64 -R> Prison School 12
*/
private val unwanted = Regex("""(?<![a-z])(v|ver|vol|version|volume|season|s).?[0-9]+""")
/**
* Regex used to remove unwanted whitespace
* Example One Piece 12 special -R> One Piece 12special
*/
private val unwantedWhiteSpace = Regex("""(\s)(extra|special|omake)""")
fun parseChapterNumber(chapter: SChapter, manga: SManga) {
// If chapter number is known return.
if (chapter.chapter_number == -2f || chapter.chapter_number > -1f) {
return
}
// Get chapter title with lower case
var name = chapter.name.lowercase()
// Remove comma's from chapter.
name = name.replace(',', '.')
// Remove unwanted white spaces.
unwantedWhiteSpace.findAll(name).let {
it.forEach { occurrence -> name = name.replace(occurrence.value, occurrence.value.trim()) }
}
// Remove unwanted tags.
unwanted.findAll(name).let {
it.forEach { occurrence -> name = name.replace(occurrence.value, "") }
}
// Check base case ch.xx
if (updateChapter(basic.find(name), chapter)) {
return
}
// Check one number occurrence.
val occurrences: MutableList<MatchResult> = arrayListOf()
occurrence.findAll(name).let {
it.forEach { occurrence -> occurrences.add(occurrence) }
}
if (occurrences.size == 1) {
if (updateChapter(occurrences[0], chapter)) {
return
}
}
// Remove manga title from chapter title.
val nameWithoutManga = name.replace(manga.title.lowercase(), "").trim()
// Check if first value is number after title remove.
if (updateChapter(withoutManga.find(nameWithoutManga), chapter)) {
return
}
// Take the first number encountered.
if (updateChapter(occurrence.find(nameWithoutManga), chapter)) {
return
}
}
/**
* Check if volume is found and update chapter
* @param match result of regex
* @param chapter chapter object
* @return true if volume is found
*/
private fun updateChapter(match: MatchResult?, chapter: SChapter): Boolean {
match?.let {
val initial = it.groups[1]?.value?.toFloat()!!
val subChapterDecimal = it.groups[2]?.value
val subChapterAlpha = it.groups[3]?.value
val addition = checkForDecimal(subChapterDecimal, subChapterAlpha)
chapter.chapter_number = initial.plus(addition)
return true
}
return false
}
/**
* Check for decimal in received strings
* @param decimal decimal value of regex
* @param alpha alpha value of regex
* @return decimal/alpha float value
*/
private fun checkForDecimal(decimal: String?, alpha: String?): Float {
if (!decimal.isNullOrEmpty()) {
return decimal.toFloat()
}
if (!alpha.isNullOrEmpty()) {
if (alpha.contains("extra")) {
return .99f
}
if (alpha.contains("omake")) {
return .98f
}
if (alpha.contains("special")) {
return .97f
}
return if (alpha[0] == '.') {
// Take value after (.)
parseAlphaPostFix(alpha[1])
} else {
parseAlphaPostFix(alpha[0])
}
}
return .0f
}
/**
* x.a -> x.1, x.b -> x.2, etc
*/
private fun parseAlphaPostFix(alpha: Char): Float {
return ("0." + (alpha.code - 96).toString()).toFloat()
}
}
@@ -0,0 +1,58 @@
package eu.kanade.tachiyomi.util.lang
import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator
import kotlin.math.floor
/**
* Replaces the given string to have at most [count] characters using [replacement] at its end.
* If [replacement] is longer than [count] an exception will be thrown when `length > count`.
*/
fun String.chop(count: Int, replacement: String = ""): String {
return if (length > count) {
take(count - replacement.length) + replacement
} else {
this
}
}
/**
* Replaces the given string to have at most [count] characters using [replacement] near the center.
* If [replacement] is longer than [count] an exception will be thrown when `length > count`.
*/
fun String.truncateCenter(count: Int, replacement: String = "..."): String {
if (length <= count) {
return this
}
val pieceLength: Int = floor((count - replacement.length).div(2.0)).toInt()
return "${take(pieceLength)}$replacement${takeLast(pieceLength)}"
}
/**
* Case-insensitive natural comparator for strings.
*/
fun String.compareToCaseInsensitiveNaturalOrder(other: String): Int {
val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance<String>()
return comparator.compare(this, other)
}
/**
* Returns the size of the string as the number of bytes.
*/
fun String.byteSize(): Int {
return toByteArray(Charsets.UTF_8).size
}
/**
* Returns a string containing the first [n] bytes from this string, or the entire string if this
* string is shorter.
*/
fun String.takeBytes(n: Int): String {
val bytes = toByteArray(Charsets.UTF_8)
return if (bytes.size <= n) {
this
} else {
bytes.decodeToString(endIndex = n).replace("\uFFFD", "")
}
}
@@ -0,0 +1,215 @@
package eu.kanade.tachiyomi.util.storage
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import java.io.Closeable
import java.io.File
import java.io.InputStream
import java.text.ParseException
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.zip.ZipEntry
import java.util.zip.ZipFile
/**
* Wrapper over ZipFile to load files in epub format.
*/
class EpubFile(file: File) : Closeable {
/**
* Zip file of this epub.
*/
private val zip = ZipFile(file)
/**
* Path separator used by this epub.
*/
private val pathSeparator = getPathSeparator()
/**
* Closes the underlying zip file.
*/
override fun close() {
zip.close()
}
/**
* Returns an input stream for reading the contents of the specified zip file entry.
*/
fun getInputStream(entry: ZipEntry): InputStream {
return zip.getInputStream(entry)
}
/**
* Returns the zip file entry for the specified name, or null if not found.
*/
fun getEntry(name: String): ZipEntry? {
return zip.getEntry(name)
}
/**
* Fills manga metadata using this epub file's metadata.
*/
fun fillMangaMetadata(manga: SManga) {
val ref = getPackageHref()
val doc = getPackageDocument(ref)
val creator = doc.getElementsByTag("dc:creator").first()
val description = doc.getElementsByTag("dc:description").first()
manga.author = creator?.text()
manga.description = description?.text()
}
/**
* Fills chapter metadata using this epub file's metadata.
*/
fun fillChapterMetadata(chapter: SChapter) {
val ref = getPackageHref()
val doc = getPackageDocument(ref)
val title = doc.getElementsByTag("dc:title").first()
val publisher = doc.getElementsByTag("dc:publisher").first()
val creator = doc.getElementsByTag("dc:creator").first()
var date = doc.getElementsByTag("dc:date").first()
if (date == null) {
date = doc.select("meta[property=dcterms:modified]").first()
}
if (title != null) {
chapter.name = title.text()
}
if (publisher != null) {
chapter.scanlator = publisher.text()
} else if (creator != null) {
chapter.scanlator = creator.text()
}
if (date != null) {
val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.getDefault())
try {
val parsedDate = dateFormat.parse(date.text())
if (parsedDate != null) {
chapter.date_upload = parsedDate.time
}
} catch (e: ParseException) {
// Empty
}
}
}
/**
* Returns the path of all the images found in the epub file.
*/
fun getImagesFromPages(): List<String> {
val ref = getPackageHref()
val doc = getPackageDocument(ref)
val pages = getPagesFromDocument(doc)
return getImagesFromPages(pages, ref)
}
/**
* Returns the path to the package document.
*/
private fun getPackageHref(): String {
val meta = zip.getEntry(resolveZipPath("META-INF", "container.xml"))
if (meta != null) {
val metaDoc = zip.getInputStream(meta).use { Jsoup.parse(it, null, "") }
val path = metaDoc.getElementsByTag("rootfile").first()?.attr("full-path")
if (path != null) {
return path
}
}
return resolveZipPath("OEBPS", "content.opf")
}
/**
* Returns the package document where all the files are listed.
*/
private fun getPackageDocument(ref: String): Document {
val entry = zip.getEntry(ref)
return zip.getInputStream(entry).use { Jsoup.parse(it, null, "") }
}
/**
* Returns all the pages from the epub.
*/
private fun getPagesFromDocument(document: Document): List<String> {
val pages = document.select("manifest > item")
.filter { "application/xhtml+xml" == it.attr("media-type") }
.associateBy { it.attr("id") }
val spine = document.select("spine > itemref").map { it.attr("idref") }
return spine.mapNotNull { pages[it] }.map { it.attr("href") }
}
/**
* Returns all the images contained in every page from the epub.
*/
private fun getImagesFromPages(pages: List<String>, packageHref: String): List<String> {
val result = mutableListOf<String>()
val basePath = getParentDirectory(packageHref)
pages.forEach { page ->
val entryPath = resolveZipPath(basePath, page)
val entry = zip.getEntry(entryPath)
val document = zip.getInputStream(entry).use { Jsoup.parse(it, null, "") }
val imageBasePath = getParentDirectory(entryPath)
document.allElements.forEach {
if (it.tagName() == "img") {
result.add(resolveZipPath(imageBasePath, it.attr("src")))
} else if (it.tagName() == "image") {
result.add(resolveZipPath(imageBasePath, it.attr("xlink:href")))
}
}
}
return result
}
/**
* Returns the path separator used by the epub file.
*/
private fun getPathSeparator(): String {
val meta = zip.getEntry("META-INF\\container.xml")
return if (meta != null) {
"\\"
} else {
"/"
}
}
/**
* Resolves a zip path from base and relative components and a path separator.
*/
private fun resolveZipPath(basePath: String, relativePath: String): String {
if (relativePath.startsWith(pathSeparator)) {
// Path is absolute, so return as-is.
return relativePath
}
var fixedBasePath = basePath.replace(pathSeparator, File.separator)
if (!fixedBasePath.startsWith(File.separator)) {
fixedBasePath = "${File.separator}$fixedBasePath"
}
val fixedRelativePath = relativePath.replace(pathSeparator, File.separator)
val resolvedPath = File(fixedBasePath, fixedRelativePath).canonicalPath
return resolvedPath.replace(File.separator, pathSeparator).substring(1)
}
/**
* Gets the parent directory of a path.
*/
private fun getParentDirectory(path: String): String {
val separatorIndex = path.lastIndexOf(pathSeparator)
return if (separatorIndex >= 0) {
path.substring(0, separatorIndex)
} else {
""
}
}
}
@@ -1,380 +0,0 @@
package suwayomi.tachidesk.anime
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import io.javalin.Javalin
import suwayomi.tachidesk.anime.impl.Anime.getAnime
import suwayomi.tachidesk.anime.impl.Anime.getAnimeThumbnail
import suwayomi.tachidesk.anime.impl.AnimeList.getAnimeList
import suwayomi.tachidesk.anime.impl.Episode.getEpisode
import suwayomi.tachidesk.anime.impl.Episode.getEpisodeList
import suwayomi.tachidesk.anime.impl.Episode.modifyEpisode
import suwayomi.tachidesk.anime.impl.Search.sourceSearch
import suwayomi.tachidesk.anime.impl.Source.getAnimeSource
import suwayomi.tachidesk.anime.impl.Source.getSourceList
import suwayomi.tachidesk.anime.impl.extension.Extension.getExtensionIcon
import suwayomi.tachidesk.anime.impl.extension.Extension.installExtension
import suwayomi.tachidesk.anime.impl.extension.Extension.uninstallExtension
import suwayomi.tachidesk.anime.impl.extension.Extension.updateExtension
import suwayomi.tachidesk.anime.impl.extension.ExtensionsList.getExtensionList
import suwayomi.tachidesk.server.JavalinSetup.future
object AnimeAPI {
fun defineEndpoints(app: Javalin) {
// list all extensions
app.get("/api/v1/anime/extension/list") { ctx ->
ctx.json(
future {
getExtensionList()
}
)
}
// install extension identified with "pkgName"
app.get("/api/v1/anime/extension/install/:pkgName") { ctx ->
val pkgName = ctx.pathParam("pkgName")
ctx.json(
future {
installExtension(pkgName)
}
)
}
// update extension identified with "pkgName"
app.get("/api/v1/anime/extension/update/:pkgName") { ctx ->
val pkgName = ctx.pathParam("pkgName")
ctx.json(
future {
updateExtension(pkgName)
}
)
}
// uninstall extension identified with "pkgName"
app.get("/api/v1/anime/extension/uninstall/:pkgName") { ctx ->
val pkgName = ctx.pathParam("pkgName")
uninstallExtension(pkgName)
ctx.status(200)
}
// icon for extension named `apkName`
app.get("/api/v1/anime/extension/icon/:apkName") { ctx -> // TODO: move to pkgName
val apkName = ctx.pathParam("apkName")
ctx.result(
future { getExtensionIcon(apkName) }
.thenApply {
ctx.header("content-type", it.second)
it.first
}
)
}
// list of sources
app.get("/api/v1/anime/source/list") { ctx ->
ctx.json(getSourceList())
}
// fetch source with id `sourceId`
app.get("/api/v1/anime/source/:sourceId") { ctx ->
val sourceId = ctx.pathParam("sourceId").toLong()
ctx.json(getAnimeSource(sourceId))
}
// popular animes from source with id `sourceId`
app.get("/api/v1/anime/source/:sourceId/popular/:pageNum") { ctx ->
val sourceId = ctx.pathParam("sourceId").toLong()
val pageNum = ctx.pathParam("pageNum").toInt()
ctx.json(
future {
getAnimeList(sourceId, pageNum, popular = true)
}
)
}
// latest animes from source with id `sourceId`
app.get("/api/v1/anime/source/:sourceId/latest/:pageNum") { ctx ->
val sourceId = ctx.pathParam("sourceId").toLong()
val pageNum = ctx.pathParam("pageNum").toInt()
ctx.json(
future {
getAnimeList(sourceId, pageNum, popular = false)
}
)
}
// get anime info
app.get("/api/v1/anime/anime/:animeId/") { ctx ->
val animeId = ctx.pathParam("animeId").toInt()
val onlineFetch = ctx.queryParam("onlineFetch", "false").toBoolean()
ctx.json(
future {
getAnime(animeId, onlineFetch)
}
)
}
// anime thumbnail
app.get("api/v1/anime/anime/:animeId/thumbnail") { ctx ->
val animeId = ctx.pathParam("animeId").toInt()
ctx.result(
future { getAnimeThumbnail(animeId) }
.thenApply {
ctx.header("content-type", it.second)
it.first
}
)
}
//
// // list manga's categories
// app.get("api/v1/manga/:mangaId/category/") { ctx ->
// val mangaId = ctx.pathParam("mangaId").toInt()
// ctx.json(getMangaCategories(mangaId))
// }
//
// // adds the manga to category
// app.get("api/v1/manga/:mangaId/category/:categoryId") { ctx ->
// val mangaId = ctx.pathParam("mangaId").toInt()
// val categoryId = ctx.pathParam("categoryId").toInt()
// addMangaToCategory(mangaId, categoryId)
// ctx.status(200)
// }
//
// // removes the manga from the category
// app.delete("api/v1/manga/:mangaId/category/:categoryId") { ctx ->
// val mangaId = ctx.pathParam("mangaId").toInt()
// val categoryId = ctx.pathParam("categoryId").toInt()
// removeMangaFromCategory(mangaId, categoryId)
// ctx.status(200)
// }
//
// get episode list when showing a anime
app.get("/api/v1/anime/anime/:animeId/episodes") { ctx ->
val animeId = ctx.pathParam("animeId").toInt()
val onlineFetch = ctx.queryParam("onlineFetch")?.toBoolean()
ctx.json(future { getEpisodeList(animeId, onlineFetch) })
}
// used to display a episode, get a episode in order to show it's <Quality pending>
app.get("/api/v1/anime/anime/:animeId/episode/:episodeIndex") { ctx ->
val episodeIndex = ctx.pathParam("episodeIndex").toInt()
val animeId = ctx.pathParam("animeId").toInt()
ctx.json(future { getEpisode(episodeIndex, animeId) })
}
// used to modify a episode's parameters
app.patch("/api/v1/anime/anime/:animeId/episode/:episodeIndex") { ctx ->
val episodeIndex = ctx.pathParam("episodeIndex").toInt()
val animeId = ctx.pathParam("animeId").toInt()
val read = ctx.formParam("read")?.toBoolean()
val bookmarked = ctx.formParam("bookmarked")?.toBoolean()
val markPrevRead = ctx.formParam("markPrevRead")?.toBoolean()
val lastPageRead = ctx.formParam("lastPageRead")?.toInt()
modifyEpisode(animeId, episodeIndex, read, bookmarked, markPrevRead, lastPageRead)
ctx.status(200)
}
//
// // get page at index "index"
// app.get("/api/v1/manga/:mangaId/chapter/:chapterIndex/page/:index") { ctx ->
// val mangaId = ctx.pathParam("mangaId").toInt()
// val chapterIndex = ctx.pathParam("chapterIndex").toInt()
// val index = ctx.pathParam("index").toInt()
//
// ctx.result(
// JavalinSetup.future { getPageImage(mangaId, chapterIndex, index) }
// .thenApply {
// ctx.header("content-type", it.second)
// it.first
// }
// )
// }
//
// // submit a chapter for download
// app.put("/api/v1/manga/:mangaId/chapter/:chapterIndex/download") { ctx ->
// // TODO
// }
//
// // cancel a chapter download
// app.delete("/api/v1/manga/:mangaId/chapter/:chapterIndex/download") { ctx ->
// // TODO
// }
//
// // global search, Not implemented yet
// app.get("/api/v1/search/:searchTerm") { ctx ->
// val searchTerm = ctx.pathParam("searchTerm")
// ctx.json(sourceGlobalSearch(searchTerm))
// }
//
// single source search
app.get("/api/v1/anime/source/:sourceId/search/:searchTerm/:pageNum") { ctx ->
val sourceId = ctx.pathParam("sourceId").toLong()
val searchTerm = ctx.pathParam("searchTerm")
val pageNum = ctx.pathParam("pageNum").toInt()
ctx.json(future { sourceSearch(sourceId, searchTerm, pageNum) })
}
//
// // source filter list
// app.get("/api/v1/source/:sourceId/filters/") { ctx ->
// val sourceId = ctx.pathParam("sourceId").toLong()
// ctx.json(sourceFilters(sourceId))
// }
//
// // adds the manga to library
// app.get("api/v1/manga/:mangaId/library") { ctx ->
// val mangaId = ctx.pathParam("mangaId").toInt()
//
// ctx.result(
// JavalinSetup.future { addMangaToLibrary(mangaId) }
// )
// }
//
// // removes the manga from the library
// app.delete("api/v1/manga/:mangaId/library") { ctx ->
// val mangaId = ctx.pathParam("mangaId").toInt()
//
// ctx.result(
// JavalinSetup.future { removeMangaFromLibrary(mangaId) }
// )
// }
//
// // lists mangas that have no category assigned
// app.get("/api/v1/library/") { ctx ->
// ctx.json(getLibraryMangas())
// }
//
// // category list
// app.get("/api/v1/category/") { ctx ->
// ctx.json(Category.getCategoryList())
// }
//
// // category create
// app.post("/api/v1/category/") { ctx ->
// val name = ctx.formParam("name")!!
// Category.createCategory(name)
// ctx.status(200)
// }
//
// // returns some static info of the current app build
// app.get("/api/v1/about/") { ctx ->
// ctx.json(About.getAbout())
// }
//
// // category modification
// app.patch("/api/v1/category/:categoryId") { ctx ->
// val categoryId = ctx.pathParam("categoryId").toInt()
// val name = ctx.formParam("name")
// val isDefault = ctx.formParam("default")?.toBoolean()
// Category.updateCategory(categoryId, name, isDefault)
// ctx.status(200)
// }
//
// // category re-ordering
// app.patch("/api/v1/category/:categoryId/reorder") { ctx ->
// val categoryId = ctx.pathParam("categoryId").toInt()
// val from = ctx.formParam("from")!!.toInt()
// val to = ctx.formParam("to")!!.toInt()
// Category.reorderCategory(categoryId, from, to)
// ctx.status(200)
// }
//
// // category delete
// app.delete("/api/v1/category/:categoryId") { ctx ->
// val categoryId = ctx.pathParam("categoryId").toInt()
// Category.removeCategory(categoryId)
// ctx.status(200)
// }
//
// // returns the manga list associated with a category
// app.get("/api/v1/category/:categoryId") { ctx ->
// val categoryId = ctx.pathParam("categoryId").toInt()
// ctx.json(getCategoryMangaList(categoryId))
// }
//
// // expects a Tachiyomi legacy backup json in the body
// app.post("/api/v1/backup/legacy/import") { ctx ->
// ctx.result(
// future {
// restoreLegacyBackup(ctx.bodyAsInputStream())
// }
// )
// }
//
// // expects a Tachiyomi legacy backup json as a file upload, the file must be named "backup.json"
// app.post("/api/v1/backup/legacy/import/file") { ctx ->
// ctx.result(
// JavalinSetup.future {
// restoreLegacyBackup(ctx.uploadedFile("backup.json")!!.content)
// }
// )
// }
//
// // returns a Tachiyomi legacy backup json created from the current database as a json body
// app.get("/api/v1/backup/legacy/export") { ctx ->
// ctx.contentType("application/json")
// ctx.result(
// JavalinSetup.future {
// createLegacyBackup(
// BackupFlags(
// includeManga = true,
// includeCategories = true,
// includeChapters = true,
// includeTracking = true,
// includeHistory = true,
// )
// )
// }
// )
// }
//
// // returns a Tachiyomi legacy backup json created from the current database as a file
// app.get("/api/v1/backup/legacy/export/file") { ctx ->
// ctx.contentType("application/json")
// val sdf = SimpleDateFormat("yyyy-MM-dd_HH-mm")
// val currentDate = sdf.format(Date())
//
// ctx.header("Content-Disposition", "attachment; filename=\"tachidesk_$currentDate.json\"")
// ctx.result(
// JavalinSetup.future {
// createLegacyBackup(
// BackupFlags(
// includeManga = true,
// includeCategories = true,
// includeChapters = true,
// includeTracking = true,
// includeHistory = true,
// )
// )
// }
// )
// }
//
// // Download queue stats
// app.ws("/api/v1/downloads") { ws ->
// ws.onConnect { ctx ->
// // TODO: send current stat
// // TODO: add to downlad subscribers
// }
// ws.onMessage {
// // TODO: send current stat
// }
// ws.onClose { ctx ->
// // TODO: remove from subscribers
// }
// }
}
}
@@ -1,139 +0,0 @@
package suwayomi.tachidesk.anime.impl
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import eu.kanade.tachiyomi.animesource.model.SAnime
import eu.kanade.tachiyomi.network.GET
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.update
import org.kodein.di.DI
import org.kodein.di.conf.global
import org.kodein.di.instance
import suwayomi.tachidesk.anime.impl.AnimeList.proxyThumbnailUrl
import suwayomi.tachidesk.anime.impl.Source.getAnimeSource
import suwayomi.tachidesk.anime.impl.util.GetAnimeHttpSource.getAnimeHttpSource
import suwayomi.tachidesk.anime.model.dataclass.AnimeDataClass
import suwayomi.tachidesk.anime.model.table.AnimeStatus
import suwayomi.tachidesk.anime.model.table.AnimeTable
import suwayomi.tachidesk.manga.impl.util.lang.awaitSingle
import suwayomi.tachidesk.manga.impl.util.network.await
import suwayomi.tachidesk.manga.impl.util.storage.CachedImageResponse.clearCachedImage
import suwayomi.tachidesk.manga.impl.util.storage.CachedImageResponse.getCachedImageResponse
import suwayomi.tachidesk.server.ApplicationDirs
import java.io.InputStream
object Anime {
private fun truncate(text: String?, maxLength: Int): String? {
return if (text?.length ?: 0 > maxLength)
text?.take(maxLength - 3) + "..."
else
text
}
suspend fun getAnime(animeId: Int, onlineFetch: Boolean = false): AnimeDataClass {
var animeEntry = transaction { AnimeTable.select { AnimeTable.id eq animeId }.first() }
return if (animeEntry[AnimeTable.initialized] && !onlineFetch) {
AnimeDataClass(
animeId,
animeEntry[AnimeTable.sourceReference].toString(),
animeEntry[AnimeTable.url],
animeEntry[AnimeTable.title],
proxyThumbnailUrl(animeId),
true,
animeEntry[AnimeTable.artist],
animeEntry[AnimeTable.author],
animeEntry[AnimeTable.description],
animeEntry[AnimeTable.genre],
AnimeStatus.valueOf(animeEntry[AnimeTable.status]).name,
animeEntry[AnimeTable.inLibrary],
getAnimeSource(animeEntry[AnimeTable.sourceReference]),
false
)
} else { // initialize anime
val source = getAnimeHttpSource(animeEntry[AnimeTable.sourceReference])
val fetchedAnime = source.fetchAnimeDetails(
SAnime.create().apply {
url = animeEntry[AnimeTable.url]
title = animeEntry[AnimeTable.title]
}
).awaitSingle()
transaction {
AnimeTable.update({ AnimeTable.id eq animeId }) {
it[AnimeTable.initialized] = true
it[AnimeTable.artist] = fetchedAnime.artist
it[AnimeTable.author] = fetchedAnime.author
it[AnimeTable.description] = truncate(fetchedAnime.description, 4096)
it[AnimeTable.genre] = fetchedAnime.genre
it[AnimeTable.status] = fetchedAnime.status
if (fetchedAnime.thumbnail_url != null && fetchedAnime.thumbnail_url.orEmpty().isNotEmpty())
it[AnimeTable.thumbnail_url] = fetchedAnime.thumbnail_url
}
}
clearAnimeThumbnail(animeId)
animeEntry = transaction { AnimeTable.select { AnimeTable.id eq animeId }.first() }
AnimeDataClass(
animeId,
animeEntry[AnimeTable.sourceReference].toString(),
animeEntry[AnimeTable.url],
animeEntry[AnimeTable.title],
proxyThumbnailUrl(animeId),
true,
fetchedAnime.artist,
fetchedAnime.author,
fetchedAnime.description,
fetchedAnime.genre,
AnimeStatus.valueOf(fetchedAnime.status).name,
animeEntry[AnimeTable.inLibrary],
getAnimeSource(animeEntry[AnimeTable.sourceReference]),
true
)
}
}
private val applicationDirs by DI.global.instance<ApplicationDirs>()
suspend fun getAnimeThumbnail(animeId: Int): Pair<InputStream, String> {
val saveDir = applicationDirs.animeThumbnailsRoot
val fileName = animeId.toString()
return getCachedImageResponse(saveDir, fileName) {
getAnime(animeId) // make sure is initialized
val animeEntry = transaction { AnimeTable.select { AnimeTable.id eq animeId }.first() }
val sourceId = animeEntry[AnimeTable.sourceReference]
val source = getAnimeHttpSource(sourceId)
val thumbnailUrl = animeEntry[AnimeTable.thumbnail_url]!!
source.client.newCall(
GET(thumbnailUrl, source.headers)
).await()
}
}
private fun clearAnimeThumbnail(animeId: Int) {
val saveDir = applicationDirs.animeThumbnailsRoot
val fileName = animeId.toString()
clearCachedImage(saveDir, fileName)
}
}
@@ -1,102 +0,0 @@
package suwayomi.tachidesk.anime.impl
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import eu.kanade.tachiyomi.animesource.model.AnimesPage
import org.jetbrains.exposed.sql.insertAndGetId
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction
import suwayomi.tachidesk.anime.impl.util.GetAnimeHttpSource.getAnimeHttpSource
import suwayomi.tachidesk.anime.model.dataclass.AnimeDataClass
import suwayomi.tachidesk.anime.model.dataclass.PagedAnimeListDataClass
import suwayomi.tachidesk.anime.model.table.AnimeStatus
import suwayomi.tachidesk.anime.model.table.AnimeTable
import suwayomi.tachidesk.manga.impl.util.lang.awaitSingle
object AnimeList {
fun proxyThumbnailUrl(animeId: Int): String {
return "/api/v1/anime/anime/$animeId/thumbnail"
}
suspend fun getAnimeList(sourceId: Long, pageNum: Int = 1, popular: Boolean): PagedAnimeListDataClass {
val source = getAnimeHttpSource(sourceId)
val animesPage = if (popular) {
source.fetchPopularAnime(pageNum).awaitSingle()
} else {
if (source.supportsLatest)
source.fetchLatestUpdates(pageNum).awaitSingle()
else
throw Exception("Source $source doesn't support latest")
}
return animesPage.processEntries(sourceId)
}
fun AnimesPage.processEntries(sourceId: Long): PagedAnimeListDataClass {
val animesPage = this
val animeList = transaction {
return@transaction animesPage.animes.map { anime ->
val animeEntry = AnimeTable.select { AnimeTable.url eq anime.url }.firstOrNull()
if (animeEntry == null) { // create anime entry
val animeId = AnimeTable.insertAndGetId {
it[url] = anime.url
it[title] = anime.title
it[artist] = anime.artist
it[author] = anime.author
it[description] = anime.description
it[genre] = anime.genre
it[status] = anime.status
it[thumbnail_url] = anime.thumbnail_url
it[sourceReference] = sourceId
}.value
AnimeDataClass(
animeId,
sourceId.toString(),
anime.url,
anime.title,
proxyThumbnailUrl(animeId),
anime.initialized,
anime.artist,
anime.author,
anime.description,
anime.genre,
AnimeStatus.valueOf(anime.status).name
)
} else {
val animeId = animeEntry[AnimeTable.id].value
AnimeDataClass(
animeId,
sourceId.toString(),
anime.url,
anime.title,
proxyThumbnailUrl(animeId),
true,
animeEntry[AnimeTable.artist],
animeEntry[AnimeTable.author],
animeEntry[AnimeTable.description],
animeEntry[AnimeTable.genre],
AnimeStatus.valueOf(animeEntry[AnimeTable.status]).name,
animeEntry[AnimeTable.inLibrary]
)
}
}
}
return PagedAnimeListDataClass(
animeList,
animesPage.hasNextPage
)
}
}
@@ -1,248 +0,0 @@
package suwayomi.tachidesk.anime.impl
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import eu.kanade.tachiyomi.animesource.model.SAnime
import eu.kanade.tachiyomi.animesource.model.SEpisode
import org.jetbrains.exposed.sql.SortOrder.DESC
import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.deleteWhere
import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.update
import suwayomi.tachidesk.anime.impl.Anime.getAnime
import suwayomi.tachidesk.anime.impl.util.GetAnimeHttpSource.getAnimeHttpSource
import suwayomi.tachidesk.anime.model.dataclass.EpisodeDataClass
import suwayomi.tachidesk.anime.model.dataclass.VideoDataClass
import suwayomi.tachidesk.anime.model.table.AnimeTable
import suwayomi.tachidesk.anime.model.table.EpisodeTable
import suwayomi.tachidesk.anime.model.table.toDataClass
import suwayomi.tachidesk.manga.impl.util.lang.awaitSingle
object Episode {
/** get episode list when showing an anime */
suspend fun getEpisodeList(animeId: Int, onlineFetch: Boolean?): List<EpisodeDataClass> {
return if (onlineFetch == true) {
getSourceEpisodes(animeId)
} else {
transaction {
EpisodeTable.select { EpisodeTable.anime eq animeId }.orderBy(EpisodeTable.episodeIndex to DESC)
.map {
EpisodeTable.toDataClass(it)
}
}.ifEmpty {
// If it was explicitly set to offline dont grab episodes
if (onlineFetch == null) {
getSourceEpisodes(animeId)
} else emptyList()
}
}
}
private suspend fun getSourceEpisodes(animeId: Int): List<EpisodeDataClass> {
val animeDetails = getAnime(animeId)
val source = getAnimeHttpSource(animeDetails.sourceId.toLong())
val episodeList = source.fetchEpisodeList(
SAnime.create().apply {
title = animeDetails.title
url = animeDetails.url
}
).awaitSingle()
val episodeCount = episodeList.count()
transaction {
episodeList.reversed().forEachIndexed { index, fetchedEpisode ->
val episodeEntry = EpisodeTable.select { EpisodeTable.url eq fetchedEpisode.url }.firstOrNull()
if (episodeEntry == null) {
EpisodeTable.insert {
it[url] = fetchedEpisode.url
it[name] = fetchedEpisode.name
it[date_upload] = fetchedEpisode.date_upload
it[episode_number] = fetchedEpisode.episode_number
it[scanlator] = fetchedEpisode.scanlator
it[episodeIndex] = index + 1
it[anime] = animeId
}
} else {
EpisodeTable.update({ EpisodeTable.url eq fetchedEpisode.url }) {
it[name] = fetchedEpisode.name
it[date_upload] = fetchedEpisode.date_upload
it[episode_number] = fetchedEpisode.episode_number
it[scanlator] = fetchedEpisode.scanlator
it[episodeIndex] = index + 1
it[anime] = animeId
}
}
}
}
// clear any orphaned episodes that are in the db but not in `episodeList`
val dbEpisodeCount = transaction { EpisodeTable.select { EpisodeTable.anime eq animeId }.count() }
if (dbEpisodeCount > episodeCount) { // we got some clean up due
val dbEpisodeList = transaction { EpisodeTable.select { EpisodeTable.anime eq animeId } }
dbEpisodeList.forEach {
if (it[EpisodeTable.episodeIndex] >= episodeList.size ||
episodeList[it[EpisodeTable.episodeIndex] - 1].url != it[EpisodeTable.url]
) {
transaction {
// PageTable.deleteWhere { PageTable.episode eq it[EpisodeTable.id] }
EpisodeTable.deleteWhere { EpisodeTable.id eq it[EpisodeTable.id] }
}
}
}
}
val dbEpisodeMap = transaction {
EpisodeTable.select { EpisodeTable.anime eq animeId }
.associateBy({ it[EpisodeTable.url] }, { it })
}
return episodeList.mapIndexed { index, it ->
val dbEpisode = dbEpisodeMap.getValue(it.url)
EpisodeDataClass(
it.url,
it.name,
it.date_upload,
it.episode_number,
it.scanlator,
animeId,
dbEpisode[EpisodeTable.isRead],
dbEpisode[EpisodeTable.isBookmarked],
dbEpisode[EpisodeTable.lastPageRead],
episodeCount - index,
episodeList.size
)
}
}
/** used to display a episode, get a episode in order to show it's video */
suspend fun getEpisode(episodeIndex: Int, animeId: Int): EpisodeDataClass {
val episode = getEpisodeList(animeId, false)
.first { it.index == episodeIndex }
val animeEntry = transaction { AnimeTable.select { AnimeTable.id eq animeId }.first() }
val source = getAnimeHttpSource(animeEntry[AnimeTable.sourceReference])
val fetchedVideos = source.fetchVideoList(
SEpisode.create().also {
it.url = episode.url
it.name = episode.name
}
).awaitSingle()
return EpisodeDataClass(
episode.url,
episode.name,
episode.uploadDate,
episode.episodeNumber,
episode.scanlator,
animeId,
episode.read,
episode.bookmarked,
episode.lastPageRead,
episode.index,
episode.episodeCount,
fetchedVideos.map {
VideoDataClass(
it.url,
it.quality,
it.videoUrl,
)
}
)
}
// /** used to display a episode, get a episode in order to show it's pages */
// suspend fun getEpisode(episodeIndex: Int, animeId: Int): EpisodeDataClass {
// val episodeEntry = transaction {
// EpisodeTable.select {
// (EpisodeTable.episodeIndex eq episodeIndex) and (EpisodeTable.anime eq animeId)
// }.first()
// }
// val animeEntry = transaction { MangaTable.select { MangaTable.id eq animeId }.first() }
// val source = getAnimeHttpSource(animeEntry[MangaTable.sourceReference])
//
// val pageList = source.fetchPageList(
// SEpisode.create().apply {
// url = episodeEntry[EpisodeTable.url]
// name = episodeEntry[EpisodeTable.name]
// }
// ).awaitSingle()
//
// val episodeId = episodeEntry[EpisodeTable.id].value
// val episodeCount = transaction { EpisodeTable.select { EpisodeTable.anime eq animeId }.count() }
//
// // update page list for this episode
// transaction {
// pageList.forEach { page ->
// val pageEntry = transaction { PageTable.select { (PageTable.episode eq episodeId) and (PageTable.index eq page.index) }.firstOrNull() }
// if (pageEntry == null) {
// PageTable.insert {
// it[index] = page.index
// it[url] = page.url
// it[imageUrl] = page.imageUrl
// it[episode] = episodeId
// }
// } else {
// PageTable.update({ (PageTable.episode eq episodeId) and (PageTable.index eq page.index) }) {
// it[url] = page.url
// it[imageUrl] = page.imageUrl
// }
// }
// }
// }
//
// return EpisodeDataClass(
// episodeEntry[EpisodeTable.url],
// episodeEntry[EpisodeTable.name],
// episodeEntry[EpisodeTable.date_upload],
// episodeEntry[EpisodeTable.episode_number],
// episodeEntry[EpisodeTable.scanlator],
// animeId,
// episodeEntry[EpisodeTable.isRead],
// episodeEntry[EpisodeTable.isBookmarked],
// episodeEntry[EpisodeTable.lastPageRead],
//
// episodeEntry[EpisodeTable.episodeIndex],
// episodeCount.toInt(),
// pageList.count()
// )
// }
fun modifyEpisode(animeId: Int, episodeIndex: Int, isRead: Boolean?, isBookmarked: Boolean?, markPrevRead: Boolean?, lastPageRead: Int?) {
transaction {
if (listOf(isRead, isBookmarked, lastPageRead).any { it != null }) {
EpisodeTable.update({ (EpisodeTable.anime eq animeId) and (EpisodeTable.episodeIndex eq episodeIndex) }) { update ->
isRead?.also {
update[EpisodeTable.isRead] = it
}
isBookmarked?.also {
update[EpisodeTable.isBookmarked] = it
}
lastPageRead?.also {
update[EpisodeTable.lastPageRead] = it
}
}
}
markPrevRead?.let {
EpisodeTable.update({ (EpisodeTable.anime eq animeId) and (EpisodeTable.episodeIndex less episodeIndex) }) {
it[EpisodeTable.isRead] = markPrevRead
}
}
}
}
}
@@ -1,21 +0,0 @@
package suwayomi.tachidesk.anime.impl
/*
* 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 suwayomi.tachidesk.anime.impl.AnimeList.processEntries
import suwayomi.tachidesk.anime.impl.util.GetAnimeHttpSource.getAnimeHttpSource
import suwayomi.tachidesk.anime.model.dataclass.PagedAnimeListDataClass
import suwayomi.tachidesk.manga.impl.util.lang.awaitSingle
object Search {
suspend fun sourceSearch(sourceId: Long, searchTerm: String, pageNum: Int): PagedAnimeListDataClass {
val source = getAnimeHttpSource(sourceId)
val searchManga = source.fetchSearchAnime(pageNum, searchTerm, source.getFilterList()).awaitSingle()
return searchManga.processEntries(sourceId)
}
}
@@ -1,50 +0,0 @@
package suwayomi.tachidesk.anime.impl
/*
* 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 mu.KotlinLogging
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.transactions.transaction
import suwayomi.tachidesk.anime.impl.extension.Extension.getExtensionIconUrl
import suwayomi.tachidesk.anime.impl.util.GetAnimeHttpSource.getAnimeHttpSource
import suwayomi.tachidesk.anime.model.dataclass.AnimeSourceDataClass
import suwayomi.tachidesk.anime.model.table.AnimeExtensionTable
import suwayomi.tachidesk.anime.model.table.AnimeSourceTable
object Source {
private val logger = KotlinLogging.logger {}
fun getSourceList(): List<AnimeSourceDataClass> {
return transaction {
AnimeSourceTable.selectAll().map {
AnimeSourceDataClass(
it[AnimeSourceTable.id].value.toString(),
it[AnimeSourceTable.name],
it[AnimeSourceTable.lang],
getExtensionIconUrl(AnimeExtensionTable.select { AnimeExtensionTable.id eq it[AnimeSourceTable.extension] }.first()[AnimeExtensionTable.apkName]),
getAnimeHttpSource(it[AnimeSourceTable.id].value).supportsLatest
)
}
}
}
fun getAnimeSource(sourceId: Long): AnimeSourceDataClass {
return transaction {
val source = AnimeSourceTable.select { AnimeSourceTable.id eq sourceId }.firstOrNull()
AnimeSourceDataClass(
sourceId.toString(),
source?.get(AnimeSourceTable.name),
source?.get(AnimeSourceTable.lang),
source?.let { AnimeExtensionTable.select { AnimeExtensionTable.id eq source[AnimeSourceTable.extension] }.first()[AnimeExtensionTable.iconUrl] },
source?.let { getAnimeHttpSource(sourceId).supportsLatest }
)
}
}
}
@@ -1,251 +0,0 @@
package suwayomi.tachidesk.anime.impl.extension
/*
* 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 android.net.Uri
import eu.kanade.tachiyomi.animesource.AnimeCatalogueSource
import eu.kanade.tachiyomi.animesource.AnimeSource
import eu.kanade.tachiyomi.animesource.AnimeSourceFactory
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.NetworkHelper
import mu.KotlinLogging
import okhttp3.Request
import okio.buffer
import okio.sink
import org.jetbrains.exposed.sql.deleteWhere
import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.update
import org.kodein.di.DI
import org.kodein.di.conf.global
import org.kodein.di.instance
import suwayomi.tachidesk.anime.impl.extension.ExtensionsList.extensionTableAsDataClass
import suwayomi.tachidesk.anime.impl.extension.github.ExtensionGithubApi
import suwayomi.tachidesk.anime.impl.util.PackageTools.EXTENSION_FEATURE
import suwayomi.tachidesk.anime.impl.util.PackageTools.LIB_VERSION_MAX
import suwayomi.tachidesk.anime.impl.util.PackageTools.LIB_VERSION_MIN
import suwayomi.tachidesk.anime.impl.util.PackageTools.METADATA_NSFW
import suwayomi.tachidesk.anime.impl.util.PackageTools.METADATA_SOURCE_CLASS
import suwayomi.tachidesk.anime.impl.util.PackageTools.dex2jar
import suwayomi.tachidesk.anime.impl.util.PackageTools.getPackageInfo
import suwayomi.tachidesk.anime.impl.util.PackageTools.getSignatureHash
import suwayomi.tachidesk.anime.impl.util.PackageTools.loadExtensionSources
import suwayomi.tachidesk.anime.impl.util.PackageTools.trustedSignatures
import suwayomi.tachidesk.anime.model.table.AnimeExtensionTable
import suwayomi.tachidesk.anime.model.table.AnimeSourceTable
import suwayomi.tachidesk.manga.impl.util.network.await
import suwayomi.tachidesk.manga.impl.util.storage.CachedImageResponse.getCachedImageResponse
import suwayomi.tachidesk.server.ApplicationDirs
import uy.kohesive.injekt.injectLazy
import java.io.File
import java.io.InputStream
object Extension {
private val logger = KotlinLogging.logger {}
private val applicationDirs by DI.global.instance<ApplicationDirs>()
data class InstallableAPK(
val apkFilePath: String,
val pkgName: String
)
suspend fun installExtension(pkgName: String): Int {
logger.debug("Installing $pkgName")
val extensionRecord = extensionTableAsDataClass().first { it.pkgName == pkgName }
return installAPK {
val apkURL = ExtensionGithubApi.getApkUrl(extensionRecord)
val apkName = Uri.parse(apkURL).lastPathSegment!!
val apkSavePath = "${applicationDirs.extensionsRoot}/$apkName"
// download apk file
downloadAPKFile(apkURL, apkSavePath)
apkSavePath
}
}
suspend fun installAPK(fetcher: suspend () -> String): Int {
val apkFilePath = fetcher()
val apkName = File(apkFilePath).name
// check if we don't have the extension already installed
// if it's installed and we want to update, it first has to be uninstalled
val isInstalled = transaction {
AnimeExtensionTable.select { AnimeExtensionTable.apkName eq apkName }.firstOrNull()
}?.get(AnimeExtensionTable.isInstalled) ?: false
if (!isInstalled) {
val fileNameWithoutType = apkName.substringBefore(".apk")
val dirPathWithoutType = "${applicationDirs.extensionsRoot}/$fileNameWithoutType"
val jarFilePath = "$dirPathWithoutType.jar"
val dexFilePath = "$dirPathWithoutType.dex"
val packageInfo = getPackageInfo(apkFilePath)
val pkgName = packageInfo.packageName
if (!packageInfo.reqFeatures.orEmpty().any { it.name == EXTENSION_FEATURE }) {
throw Exception("This apk is not a Tachiyomi extension")
}
// Validate lib version
val libVersion = packageInfo.versionName.substringBeforeLast('.').toDouble()
if (libVersion < LIB_VERSION_MIN || libVersion > LIB_VERSION_MAX) {
throw Exception(
"Lib version is $libVersion, while only versions " +
"$LIB_VERSION_MIN to $LIB_VERSION_MAX are allowed"
)
}
val signatureHash = getSignatureHash(packageInfo)
if (signatureHash == null) {
throw Exception("Package $pkgName isn't signed")
} else if (signatureHash !in trustedSignatures) {
// TODO: allow trusting keys
throw Exception("This apk is not a signed with the official tachiyomi signature")
}
val isNsfw = packageInfo.applicationInfo.metaData.getString(METADATA_NSFW) == "1"
val className = packageInfo.packageName + packageInfo.applicationInfo.metaData.getString(METADATA_SOURCE_CLASS)
logger.debug("Main class for extension is $className")
dex2jar(apkFilePath, jarFilePath, fileNameWithoutType)
// clean up
// File(apkFilePath).delete()
File(dexFilePath).delete()
// collect sources from the extension
val sources: List<AnimeCatalogueSource> = when (val instance = loadExtensionSources(jarFilePath, className)) {
is AnimeSource -> listOf(instance)
is AnimeSourceFactory -> instance.createSources()
else -> throw RuntimeException("Unknown source class type! ${instance.javaClass}")
}.map { it as AnimeCatalogueSource }
val langs = sources.map { it.lang }.toSet()
val extensionLang = when (langs.size) {
0 -> ""
1 -> langs.first()
else -> "all"
}
val extensionName = packageInfo.applicationInfo.nonLocalizedLabel.toString().substringAfter("Aniyomi: ")
// update extension info
transaction {
if (AnimeExtensionTable.select { AnimeExtensionTable.pkgName eq pkgName }.firstOrNull() == null) {
AnimeExtensionTable.insert {
it[this.apkName] = apkName
it[name] = extensionName
it[this.pkgName] = packageInfo.packageName
it[versionName] = packageInfo.versionName
it[versionCode] = packageInfo.versionCode
it[lang] = extensionLang
it[this.isNsfw] = isNsfw
}
}
AnimeExtensionTable.update({ AnimeExtensionTable.pkgName eq pkgName }) {
it[this.isInstalled] = true
it[this.classFQName] = className
}
val extensionId = AnimeExtensionTable.select { AnimeExtensionTable.pkgName eq pkgName }.first()[AnimeExtensionTable.id].value
sources.forEach { httpSource ->
AnimeSourceTable.insert {
it[id] = httpSource.id
it[name] = httpSource.name
it[lang] = httpSource.lang
it[extension] = extensionId
}
logger.debug("Installed source ${httpSource.name} (${httpSource.lang}) with id:${httpSource.id}")
}
}
return 201 // we installed successfully
} else {
return 302 // extension was already installed
}
}
private val network: NetworkHelper by injectLazy()
private suspend fun downloadAPKFile(url: String, savePath: String) {
val request = Request.Builder().url(url).build()
val response = network.client.newCall(request).await()
val downloadedFile = File(savePath)
downloadedFile.sink().buffer().use { sink ->
response.body!!.source().use { source ->
sink.writeAll(source)
sink.flush()
}
}
}
fun uninstallExtension(pkgName: String) {
logger.debug("Uninstalling $pkgName")
val extensionRecord = transaction { AnimeExtensionTable.select { AnimeExtensionTable.pkgName eq pkgName }.first() }
val fileNameWithoutType = extensionRecord[AnimeExtensionTable.apkName].substringBefore(".apk")
val jarPath = "${applicationDirs.extensionsRoot}/$fileNameWithoutType.jar"
transaction {
val extensionId = extensionRecord[AnimeExtensionTable.id].value
AnimeSourceTable.deleteWhere { AnimeSourceTable.extension eq extensionId }
if (extensionRecord[AnimeExtensionTable.isObsolete])
AnimeExtensionTable.deleteWhere { AnimeExtensionTable.pkgName eq pkgName }
else
AnimeExtensionTable.update({ AnimeExtensionTable.pkgName eq pkgName }) {
it[isInstalled] = false
}
}
if (File(jarPath).exists()) {
File(jarPath).delete()
}
}
suspend fun updateExtension(pkgName: String): Int {
val targetExtension = ExtensionsList.updateMap.remove(pkgName)!!
uninstallExtension(pkgName)
transaction {
AnimeExtensionTable.update({ AnimeExtensionTable.pkgName eq pkgName }) {
it[name] = targetExtension.name
it[versionName] = targetExtension.versionName
it[versionCode] = targetExtension.versionCode
it[lang] = targetExtension.lang
it[isNsfw] = targetExtension.isNsfw
it[apkName] = targetExtension.apkName
it[iconUrl] = targetExtension.iconUrl
it[hasUpdate] = false
}
}
return installExtension(pkgName)
}
suspend fun getExtensionIcon(apkName: String): Pair<InputStream, String> {
val iconUrl = transaction { AnimeExtensionTable.select { AnimeExtensionTable.apkName eq apkName }.first() }[AnimeExtensionTable.iconUrl]
val saveDir = "${applicationDirs.extensionsRoot}/icon"
return getCachedImageResponse(saveDir, apkName) {
network.client.newCall(
GET(iconUrl)
).await()
}
}
fun getExtensionIconUrl(apkName: String): String {
return "/api/v1/anime/extension/icon/$apkName"
}
}
@@ -1,132 +0,0 @@
package suwayomi.tachidesk.anime.impl.extension
/*
* 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 mu.KotlinLogging
import org.jetbrains.exposed.sql.deleteWhere
import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.update
import suwayomi.tachidesk.anime.impl.extension.Extension.getExtensionIconUrl
import suwayomi.tachidesk.anime.impl.extension.github.ExtensionGithubApi
import suwayomi.tachidesk.anime.impl.extension.github.OnlineExtension
import suwayomi.tachidesk.anime.model.dataclass.AnimeExtensionDataClass
import suwayomi.tachidesk.anime.model.table.AnimeExtensionTable
import java.util.concurrent.ConcurrentHashMap
object ExtensionsList {
private val logger = KotlinLogging.logger {}
var lastUpdateCheck: Long = 0
var updateMap = ConcurrentHashMap<String, OnlineExtension>()
/** 60,000 milliseconds = 60 seconds */
private const val ExtensionUpdateDelayTime = 60 * 1000
suspend fun getExtensionList(): List<AnimeExtensionDataClass> {
// update if {ExtensionUpdateDelayTime} seconds has passed or requested offline and database is empty
if (lastUpdateCheck + ExtensionUpdateDelayTime < System.currentTimeMillis()) {
logger.debug("Getting extensions list from the internet")
lastUpdateCheck = System.currentTimeMillis()
val foundExtensions = ExtensionGithubApi.findExtensions()
updateExtensionDatabase(foundExtensions)
} else {
logger.debug("used cached extension list")
}
return extensionTableAsDataClass()
}
fun extensionTableAsDataClass() = transaction {
AnimeExtensionTable.selectAll().map {
AnimeExtensionDataClass(
it[AnimeExtensionTable.apkName],
getExtensionIconUrl(it[AnimeExtensionTable.apkName]),
it[AnimeExtensionTable.name],
it[AnimeExtensionTable.pkgName],
it[AnimeExtensionTable.versionName],
it[AnimeExtensionTable.versionCode],
it[AnimeExtensionTable.lang],
it[AnimeExtensionTable.isNsfw],
it[AnimeExtensionTable.isInstalled],
it[AnimeExtensionTable.hasUpdate],
it[AnimeExtensionTable.isObsolete],
)
}
}
private fun updateExtensionDatabase(foundExtensions: List<OnlineExtension>) {
transaction {
foundExtensions.forEach { foundExtension ->
val extensionRecord = AnimeExtensionTable.select { AnimeExtensionTable.pkgName eq foundExtension.pkgName }.firstOrNull()
if (extensionRecord != null) {
if (extensionRecord[AnimeExtensionTable.isInstalled]) {
when {
foundExtension.versionCode > extensionRecord[AnimeExtensionTable.versionCode] -> {
// there is an update
AnimeExtensionTable.update({ AnimeExtensionTable.pkgName eq foundExtension.pkgName }) {
it[hasUpdate] = true
}
updateMap.putIfAbsent(foundExtension.pkgName, foundExtension)
}
foundExtension.versionCode < extensionRecord[AnimeExtensionTable.versionCode] -> {
// some how the user installed an invalid version
AnimeExtensionTable.update({ AnimeExtensionTable.pkgName eq foundExtension.pkgName }) {
it[isObsolete] = true
}
}
}
} else {
// extension is not installed so we can overwrite the data without a care
AnimeExtensionTable.update({ AnimeExtensionTable.pkgName eq foundExtension.pkgName }) {
it[name] = foundExtension.name
it[versionName] = foundExtension.versionName
it[versionCode] = foundExtension.versionCode
it[lang] = foundExtension.lang
it[isNsfw] = foundExtension.isNsfw
it[apkName] = foundExtension.apkName
it[iconUrl] = foundExtension.iconUrl
}
}
} else {
// insert new record
AnimeExtensionTable.insert {
it[name] = foundExtension.name
it[pkgName] = foundExtension.pkgName
it[versionName] = foundExtension.versionName
it[versionCode] = foundExtension.versionCode
it[lang] = foundExtension.lang
it[isNsfw] = foundExtension.isNsfw
it[apkName] = foundExtension.apkName
it[iconUrl] = foundExtension.iconUrl
}
}
}
// deal with obsolete extensions
AnimeExtensionTable.selectAll().forEach { extensionRecord ->
val foundExtension = foundExtensions.find { it.pkgName == extensionRecord[AnimeExtensionTable.pkgName] }
if (foundExtension == null) {
// not in the repo, so this extensions is obsolete
if (extensionRecord[AnimeExtensionTable.isInstalled]) {
// is installed so we should mark it as obsolete
AnimeExtensionTable.update({ AnimeExtensionTable.pkgName eq extensionRecord[AnimeExtensionTable.pkgName] }) {
it[isObsolete] = true
}
} else {
// is not installed so we can remove the record without a care
AnimeExtensionTable.deleteWhere { AnimeExtensionTable.pkgName eq extensionRecord[AnimeExtensionTable.pkgName] }
}
}
}
}
}
}
@@ -1,79 +0,0 @@
package suwayomi.tachidesk.anime.impl.extension.github
/*
* 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 com.github.salomonbrys.kotson.int
import com.github.salomonbrys.kotson.string
import com.google.gson.JsonArray
import com.google.gson.JsonParser
import eu.kanade.tachiyomi.network.NetworkHelper
import okhttp3.Request
import suwayomi.tachidesk.anime.impl.util.PackageTools.LIB_VERSION_MAX
import suwayomi.tachidesk.anime.impl.util.PackageTools.LIB_VERSION_MIN
import suwayomi.tachidesk.anime.model.dataclass.AnimeExtensionDataClass
import suwayomi.tachidesk.manga.impl.util.network.UnzippingInterceptor
import uy.kohesive.injekt.injectLazy
object ExtensionGithubApi {
private const val BASE_URL = "https://raw.githubusercontent.com"
private const val REPO_URL_PREFIX = "$BASE_URL/jmir1/tachiyomi-extensions/repo"
private fun parseResponse(json: JsonArray): List<OnlineExtension> {
return json
.map { it.asJsonObject }
.filter { element ->
val versionName = element["version"].string
val libVersion = versionName.substringBeforeLast('.').toInt()
libVersion in LIB_VERSION_MIN..LIB_VERSION_MAX
}
.map { element ->
val name = element["name"].string.substringAfter("Aniyomi: ")
val pkgName = element["pkg"].string
val apkName = element["apk"].string
val versionName = element["version"].string
val versionCode = element["code"].int
val lang = element["lang"].string
val nsfw = element["nsfw"].int == 1
val icon = "$REPO_URL_PREFIX/icon/${apkName.replace(".apk", ".png")}"
OnlineExtension(name, pkgName, versionName, versionCode, lang, nsfw, apkName, icon)
}
}
suspend fun findExtensions(): List<OnlineExtension> {
val response = getRepo()
return parseResponse(response)
}
fun getApkUrl(extension: AnimeExtensionDataClass): String {
return "$REPO_URL_PREFIX/apk/${extension.apkName}"
}
private val client by lazy {
val network: NetworkHelper by injectLazy()
network.client.newBuilder()
.addNetworkInterceptor { chain ->
val originalResponse = chain.proceed(chain.request())
originalResponse.newBuilder()
.header("Content-Encoding", "gzip")
.header("Content-Type", "application/json")
.build()
}
.addInterceptor(UnzippingInterceptor())
.build()
}
private fun getRepo(): JsonArray {
val request = Request.Builder()
.url("$REPO_URL_PREFIX/index.json.gz")
.build()
val response = client.newCall(request).execute().use { response -> response.body!!.string() }
return JsonParser.parseString(response).asJsonArray
}
}
@@ -1,19 +0,0 @@
package suwayomi.tachidesk.anime.impl.extension.github
/*
* 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/. */
data class OnlineExtension(
val name: String,
val pkgName: String,
val versionName: String,
val versionCode: Int,
val lang: String,
val isNsfw: Boolean,
val apkName: String,
val iconUrl: String
)
@@ -1,57 +0,0 @@
package suwayomi.tachidesk.anime.impl.util
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import eu.kanade.tachiyomi.animesource.AnimeSource
import eu.kanade.tachiyomi.animesource.AnimeSourceFactory
import eu.kanade.tachiyomi.animesource.online.AnimeHttpSource
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.anime.impl.util.PackageTools.loadExtensionSources
import suwayomi.tachidesk.anime.model.table.AnimeExtensionTable
import suwayomi.tachidesk.anime.model.table.AnimeSourceTable
import suwayomi.tachidesk.server.ApplicationDirs
import java.util.concurrent.ConcurrentHashMap
object GetAnimeHttpSource {
private val sourceCache = ConcurrentHashMap<Long, AnimeHttpSource>()
private val applicationDirs by DI.global.instance<ApplicationDirs>()
fun getAnimeHttpSource(sourceId: Long): AnimeHttpSource {
val cachedResult: AnimeHttpSource? = sourceCache[sourceId]
if (cachedResult != null) {
return cachedResult
}
val sourceRecord = transaction {
AnimeSourceTable.select { AnimeSourceTable.id eq sourceId }.first()
}
val extensionId = sourceRecord[AnimeSourceTable.extension]
val extensionRecord = transaction {
AnimeExtensionTable.select { AnimeExtensionTable.id eq extensionId }.first()
}
val apkName = extensionRecord[AnimeExtensionTable.apkName]
val className = extensionRecord[AnimeExtensionTable.classFQName]
val jarName = apkName.substringBefore(".apk") + ".jar"
val jarPath = "${applicationDirs.extensionsRoot}/$jarName"
when (val instance = loadExtensionSources(jarPath, className)) {
is AnimeSource -> listOf(instance)
is AnimeSourceFactory -> instance.createSources()
else -> throw Exception("Unknown source class type! ${instance.javaClass}")
}.forEach {
sourceCache[it.id] = it as AnimeHttpSource
}
return sourceCache[sourceId]!!
}
}
@@ -1,148 +0,0 @@
package suwayomi.tachidesk.anime.impl.util
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import android.content.pm.PackageInfo
import android.content.pm.Signature
import android.os.Bundle
import com.googlecode.d2j.dex.Dex2jar
import com.googlecode.d2j.reader.MultiDexFileReader
import com.googlecode.dex2jar.tools.BaksmaliBaseDexExceptionHandler
import eu.kanade.tachiyomi.util.lang.Hash
import mu.KotlinLogging
import net.dongliu.apk.parser.ApkFile
import net.dongliu.apk.parser.ApkParsers
import org.kodein.di.DI
import org.kodein.di.conf.global
import org.kodein.di.instance
import org.w3c.dom.Element
import org.w3c.dom.Node
import suwayomi.tachidesk.manga.impl.util.BytecodeEditor
import suwayomi.tachidesk.server.ApplicationDirs
import xyz.nulldev.androidcompat.pm.InstalledPackage.Companion.toList
import xyz.nulldev.androidcompat.pm.toPackageInfo
import java.io.File
import java.net.URL
import java.net.URLClassLoader
import java.nio.file.Files
import java.nio.file.Path
import javax.xml.parsers.DocumentBuilderFactory
object PackageTools {
private val logger = KotlinLogging.logger {}
private val applicationDirs by DI.global.instance<ApplicationDirs>()
const val EXTENSION_FEATURE = "tachiyomi.animeextension"
const val METADATA_SOURCE_CLASS = "tachiyomi.animeextension.class"
const val METADATA_SOURCE_FACTORY = "tachiyomi.animeextension.factory"
const val METADATA_NSFW = "tachiyomi.animeextension.nsfw"
const val LIB_VERSION_MIN = 12
const val LIB_VERSION_MAX = 12
private const val officialSignature = "50ab1d1e3a20d204d0ad6d334c7691c632e41b98dfa132bf385695fdfa63839c" // jmir1's key
var trustedSignatures = mutableSetOf<String>() + officialSignature
/**
* Convert dex to jar, a wrapper for the dex2jar library
*/
fun dex2jar(dexFile: String, jarFile: String, fileNameWithoutType: String) {
// adopted from com.googlecode.dex2jar.tools.Dex2jarCmd.doCommandLine
// source at: https://github.com/DexPatcher/dex2jar/tree/v2.1-20190905-lanchon/dex-tools/src/main/java/com/googlecode/dex2jar/tools/Dex2jarCmd.java
val jarFilePath = File(jarFile).toPath()
val reader = MultiDexFileReader.open(Files.readAllBytes(File(dexFile).toPath()))
val handler = BaksmaliBaseDexExceptionHandler()
Dex2jar
.from(reader)
.withExceptionHandler(handler)
.reUseReg(false)
.topoLogicalSort()
.skipDebug(true)
.optimizeSynchronized(false)
.printIR(false)
.noCode(false)
.skipExceptions(false)
.to(jarFilePath)
if (handler.hasException()) {
val errorFile: Path = File(applicationDirs.extensionsRoot).toPath().resolve("$fileNameWithoutType-error.txt")
logger.error(
"""
Detail Error Information in File $errorFile
Please report this file to one of following link if possible (any one).
https://sourceforge.net/p/dex2jar/tickets/
https://bitbucket.org/pxb1988/dex2jar/issues
https://github.com/pxb1988/dex2jar/issues
dex2jar@googlegroups.com
""".trimIndent()
)
handler.dump(errorFile, emptyArray<String>())
} else {
BytecodeEditor.fixAndroidClasses(jarFilePath.toFile())
}
}
/** A modified version of `xyz.nulldev.androidcompat.pm.InstalledPackage.info` */
fun getPackageInfo(apkFilePath: String): PackageInfo {
val apk = File(apkFilePath)
return ApkParsers.getMetaInfo(apk).toPackageInfo(apk).apply {
val parsed = ApkFile(apk)
val dbFactory = DocumentBuilderFactory.newInstance()
val dBuilder = dbFactory.newDocumentBuilder()
val doc = parsed.manifestXml.byteInputStream().use {
dBuilder.parse(it)
}
logger.debug(parsed.manifestXml)
applicationInfo.metaData = Bundle().apply {
val appTag = doc.getElementsByTagName("application").item(0)
appTag?.childNodes?.toList()
.orEmpty()
.asSequence()
.filter {
it.nodeType == Node.ELEMENT_NODE
}.map {
it as Element
}.filter {
it.tagName == "meta-data"
}.forEach {
putString(
it.attributes.getNamedItem("android:name").nodeValue,
it.attributes.getNamedItem("android:value").nodeValue
)
}
}
signatures = (
parsed.apkSingers.flatMap { it.certificateMetas }
/*+ parsed.apkV2Singers.flatMap { it.certificateMetas }*/
) // Blocked by: https://github.com/hsiafan/apk-parser/issues/72
.map { Signature(it.data) }.toTypedArray()
}
}
fun getSignatureHash(pkgInfo: PackageInfo): String? {
val signatures = pkgInfo.signatures
return if (signatures != null && signatures.isNotEmpty()) {
Hash.sha256(signatures.first().toByteArray())
} else {
null
}
}
/**
* loads the extension main class called $className from the jar located at $jarPath
* It may return an instance of HttpSource or SourceFactory depending on the extension.
*/
fun loadExtensionSources(jarPath: String, className: String): Any {
val classLoader = URLClassLoader(arrayOf<URL>(URL("file:$jarPath")))
val classToLoad = Class.forName(className, false, classLoader)
return classToLoad.getDeclaredConstructor().newInstance()
}
}
@@ -1,36 +0,0 @@
package suwayomi.tachidesk.anime.model.dataclass
/*
* 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 suwayomi.tachidesk.anime.model.table.AnimeStatus
data class AnimeDataClass(
val id: Int,
val sourceId: String,
val url: String,
val title: String,
val thumbnailUrl: String? = null,
val initialized: Boolean = false,
val artist: String? = null,
val author: String? = null,
val description: String? = null,
val genre: String? = null,
val status: String = AnimeStatus.UNKNOWN.name,
val inLibrary: Boolean = false,
val source: AnimeSourceDataClass? = null,
val freshData: Boolean = false
)
data class PagedAnimeListDataClass(
val mangaList: List<AnimeDataClass>,
val hasNextPage: Boolean
)
@@ -1,24 +0,0 @@
package suwayomi.tachidesk.anime.model.dataclass
/*
* 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/. */
data class AnimeExtensionDataClass(
val apkName: String,
val iconUrl: String,
val name: String,
val pkgName: String,
val versionName: String,
val versionCode: Int,
val lang: String,
val isNsfw: Boolean,
val installed: Boolean,
val hasUpdate: Boolean,
val obsolete: Boolean,
)

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