mirror of
https://github.com/Suwayomi/Suwayomi-Server.git
synced 2026-07-04 19:34:35 -05:00
Compare commits
13 Commits
8e63dfffab
...
renovate/o
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55b09caeb2 | ||
|
|
323d58717e | ||
|
|
4d7b7617a9 | ||
|
|
35b48114c6 | ||
|
|
3031aa7ccd | ||
|
|
c79486b8be | ||
|
|
e2fd15158c | ||
|
|
b6de3c3e39 | ||
|
|
656d86c6f6 | ||
|
|
a0fbff5756 | ||
|
|
2d535b44d8 | ||
|
|
c8f5d83e9c | ||
|
|
be5e3f022e |
4
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
4
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -143,11 +143,13 @@ body:
|
|||||||
options:
|
options:
|
||||||
- label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open or closed issue.
|
- label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open or closed issue.
|
||||||
required: true
|
required: true
|
||||||
|
- label: I have checked the ongoing preview changelog of **[Suwayomi-WebUI](https://github.com/Suwayomi/Suwayomi-WebUI/blob/master/CHANGELOG.md)** and **[Suwayomi-Server](https://github.com/Suwayomi/Suwayomi-Server/blob/master/CHANGELOG.md)** and this bug has **NOT** been listed as fixed
|
||||||
|
required: true
|
||||||
- label: I have written a short but informative title (ideally less than ~100 characters).
|
- label: I have written a short but informative title (ideally less than ~100 characters).
|
||||||
required: true
|
required: true
|
||||||
- label: I have tried the troubleshooting guide described in [README.md](https://github.com/Suwayomi/Suwayomi-Server?tab=readme-ov-file#troubleshooting-and-support)
|
- label: I have tried the troubleshooting guide described in [README.md](https://github.com/Suwayomi/Suwayomi-Server?tab=readme-ov-file#troubleshooting-and-support)
|
||||||
required: true
|
required: true
|
||||||
- label: I have updated to the **[latest version](https://github.com/suwayomi/suwayomi-server/releases/latest)**.
|
- label: I have updated the (**[Suwayomi-WebUI](https://github.com/suwayomi/suwayomi-webui/releases/latest)** and **[Suwayomi-Server](https://github.com/suwayomi/suwayomi-server/releases/latest)**) to the latest versions
|
||||||
required: true
|
required: true
|
||||||
- label: I have filled out all of the requested information in this form, including specific version numbers.
|
- label: I have filled out all of the requested information in this form, including specific version numbers.
|
||||||
required: true
|
required: true
|
||||||
|
|||||||
2
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
2
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@@ -31,7 +31,7 @@ body:
|
|||||||
required: true
|
required: true
|
||||||
- label: I have written a short but informative title (ideally less than ~100 characters).
|
- label: I have written a short but informative title (ideally less than ~100 characters).
|
||||||
required: true
|
required: true
|
||||||
- label: I have updated to the **[latest version](https://github.com/suwayomi/suwayomi-server/releases/latest)**.
|
- label: I have updated the (**[Suwayomi-WebUI](https://github.com/suwayomi/suwayomi-webui/releases/latest)** and **[Suwayomi-Server](https://github.com/suwayomi/suwayomi-server/releases/latest)**) to the latest versions
|
||||||
required: true
|
required: true
|
||||||
- label: I have filled out all of the requested information in this form, including specific version numbers.
|
- label: I have filled out all of the requested information in this form, including specific version numbers.
|
||||||
required: true
|
required: true
|
||||||
@@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|||||||
### Added
|
### Added
|
||||||
- (**Sync**) Added [SyncYomi](https://github.com/syncyomi/syncyomi) support
|
- (**Sync**) Added [SyncYomi](https://github.com/syncyomi/syncyomi) support
|
||||||
- (**OPDS**) Add option to skip chapter metadata feed providing direct stream/download links
|
- (**OPDS**) Add option to skip chapter metadata feed providing direct stream/download links
|
||||||
|
- (**Extension/API**) Support Extensions API v1.6
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- (**Database/H2**) Use the latest H2 database engine
|
- (**Database/H2**) Use the latest H2 database engine
|
||||||
|
|||||||
@@ -159,15 +159,20 @@ server.systemTrayEnabled = true
|
|||||||
server.maxLogFiles = 31
|
server.maxLogFiles = 31
|
||||||
server.maxLogFileSize = "10mb"
|
server.maxLogFileSize = "10mb"
|
||||||
server.maxLogFolderSize = "100mb"
|
server.maxLogFolderSize = "100mb"
|
||||||
server.extensionRepos = []
|
|
||||||
server.maxSourcesInParallel = 6
|
|
||||||
```
|
```
|
||||||
- `server.debugLogsEnabled` controls whether if Suwayomi-Server should print more information while being run inside a Terminal/CMD/Powershell window.
|
- `server.debugLogsEnabled` controls whether if Suwayomi-Server should print more information while being run inside a Terminal/CMD/Powershell window.
|
||||||
- `server.systemTrayEnabled = true` whether if Suwayomi-Server should show a System Tray Icon, disabling this on headless servers is recommended.
|
- `server.systemTrayEnabled = true` whether if Suwayomi-Server should show a System Tray Icon, disabling this on headless servers is recommended.
|
||||||
- `server.maxLogFiles = 31` sets the maximum number of days to keep files before they get deleted.
|
- `server.maxLogFiles = 31` sets the maximum number of days to keep files before they get deleted.
|
||||||
- `server.maxLogFileSize = "10mb"` sets the maximum size of a log file - values are formatted like: 1 (bytes), 1KB (kilobytes), 1MB (megabytes), 1GB (gigabytes)
|
- `server.maxLogFileSize = "10mb"` sets the maximum size of a log file - values are formatted like: 1 (bytes), 1KB (kilobytes), 1MB (megabytes), 1GB (gigabytes)
|
||||||
- `server.maxLogFolderSize = "100mb"` sets the maximum size of all saved log files - values are formatted like: 1 (bytes), 1KB (kilobytes), 1MB (megabytes), 1GB (gigabytes)
|
- `server.maxLogFolderSize = "100mb"` sets the maximum size of all saved log files - values are formatted like: 1 (bytes), 1KB (kilobytes), 1MB (megabytes), 1GB (gigabytes)
|
||||||
- `server.extensionRepos` is a list of extension repositories for custom sources. Uses the same format as Mihon; each entry is expected to be a string URL pointing to a JSON file representing the repository.
|
|
||||||
|
### Extension/Source
|
||||||
|
```
|
||||||
|
server.extensionStores = []
|
||||||
|
server.maxSourcesInParallel = 6
|
||||||
|
```
|
||||||
|
- `server.extensionStores` is a list of extension stores (previously called repositories) for custom sources. Uses the same format as Mihon; each entry is expected to be a string URL pointing to a JSON or PROTOBUF file representing the repository.
|
||||||
- `server.maxSourcesInParallel = 6` sets how many sources can do requests (updates, downloads) in parallel. Updates/downloads are grouped by source and all mangas of a source are updated/downloaded synchronously. Range: 1 <= n <= 20.
|
- `server.maxSourcesInParallel = 6` sets how many sources can do requests (updates, downloads) in parallel. Updates/downloads are grouped by source and all mangas of a source are updated/downloaded synchronously. Range: 1 <= n <= 20.
|
||||||
|
|
||||||
### Backup
|
### Backup
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ okhttp = "5.4.0" # Major version is locked by Tachiyomi extensions
|
|||||||
javalin = "7.2.2"
|
javalin = "7.2.2"
|
||||||
jte = "3.2.4"
|
jte = "3.2.4"
|
||||||
jackson = "3.2.0" # jackson version locked by javalin, ref: `io.javalin.core.util.OptionalDependency`
|
jackson = "3.2.0" # jackson version locked by javalin, ref: `io.javalin.core.util.OptionalDependency`
|
||||||
exposed = "1.3.0"
|
exposed = "1.2.0"
|
||||||
dex2jar = "2.4.37"
|
dex2jar = "2.4.37"
|
||||||
polyglot = "25.0.3"
|
polyglot = "25.0.3"
|
||||||
settings = "1.3.0"
|
settings = "1.3.0"
|
||||||
@@ -47,6 +47,7 @@ okhttp-core = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
|
|||||||
okhttp-logging = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" }
|
okhttp-logging = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" }
|
||||||
okhttp-dnsoverhttps = { module = "com.squareup.okhttp3:okhttp-dnsoverhttps", version.ref = "okhttp" }
|
okhttp-dnsoverhttps = { module = "com.squareup.okhttp3:okhttp-dnsoverhttps", version.ref = "okhttp" }
|
||||||
okhttp-brotli = { module = "com.squareup.okhttp3:okhttp-brotli", version.ref = "okhttp" }
|
okhttp-brotli = { module = "com.squareup.okhttp3:okhttp-brotli", version.ref = "okhttp" }
|
||||||
|
okhttp-zstd = { module = "com.squareup.okhttp3:okhttp-zstd", version.ref = "okhttp" }
|
||||||
okio = "com.squareup.okio:okio:3.17.0"
|
okio = "com.squareup.okio:okio:3.17.0"
|
||||||
|
|
||||||
# Javalin api
|
# Javalin api
|
||||||
@@ -70,7 +71,8 @@ exposed-dao = { module = "org.jetbrains.exposed:exposed-dao", version.ref = "exp
|
|||||||
exposed-jdbc = { module = "org.jetbrains.exposed:exposed-jdbc", version.ref = "exposed" }
|
exposed-jdbc = { module = "org.jetbrains.exposed:exposed-jdbc", version.ref = "exposed" }
|
||||||
exposed-javatime = { module = "org.jetbrains.exposed:exposed-java-time", version.ref = "exposed" }
|
exposed-javatime = { module = "org.jetbrains.exposed:exposed-java-time", version.ref = "exposed" }
|
||||||
exposed-kotlintime = { module = "org.jetbrains.exposed:exposed-kotlin-datetime", version.ref = "exposed" }
|
exposed-kotlintime = { module = "org.jetbrains.exposed:exposed-kotlin-datetime", version.ref = "exposed" }
|
||||||
postgres = "org.postgresql:postgresql:42.7.11"
|
exposed-json = { module = "org.jetbrains.exposed:exposed-json ", version.ref = "exposed" }
|
||||||
|
postgres = "org.postgresql:postgresql:42.7.12"
|
||||||
h2 = "com.h2database:h2:2.4.240"
|
h2 = "com.h2database:h2:2.4.240"
|
||||||
hikaricp = "com.zaxxer:HikariCP:7.1.0"
|
hikaricp = "com.zaxxer:HikariCP:7.1.0"
|
||||||
|
|
||||||
@@ -227,6 +229,7 @@ okhttp = [
|
|||||||
"okhttp-logging",
|
"okhttp-logging",
|
||||||
"okhttp-dnsoverhttps",
|
"okhttp-dnsoverhttps",
|
||||||
"okhttp-brotli",
|
"okhttp-brotli",
|
||||||
|
"okhttp-zstd",
|
||||||
]
|
]
|
||||||
javalin = [
|
javalin = [
|
||||||
"javalin-core",
|
"javalin-core",
|
||||||
@@ -245,6 +248,7 @@ exposed = [
|
|||||||
"exposed-jdbc",
|
"exposed-jdbc",
|
||||||
"exposed-javatime",
|
"exposed-javatime",
|
||||||
"exposed-kotlintime",
|
"exposed-kotlintime",
|
||||||
|
"exposed-json",
|
||||||
]
|
]
|
||||||
systemtray = [
|
systemtray = [
|
||||||
"systemtray-core",
|
"systemtray-core",
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<string name="opds_feeds_root">Suwayomi OPDS Katalog</string>
|
<string name="opds_feeds_root">Suwayomi OPDS Katalog</string>
|
||||||
<string name="opds_feeds_chapter_details">%1$s | %2$s | Details</string>
|
<string name="opds_feeds_chapter_details">%1$s | %2$s | Details</string>
|
||||||
<string name="opds_feeds_sources_title">Alle Quellen</string>
|
<string name="opds_feeds_sources_title">Alle Quellen</string>
|
||||||
<string name="opds_feeds_genres_title">Genres</string>
|
<string name="opds_feeds_genres_title">Genren</string>
|
||||||
<string name="opds_feeds_genres_entry_content">Durchsuche Serien nach Genre</string>
|
<string name="opds_feeds_genres_entry_content">Durchsuche Serien nach Genre</string>
|
||||||
<string name="opds_feeds_status_entry_content">Durchsuche Serien nach Publikationsstatus</string>
|
<string name="opds_feeds_status_entry_content">Durchsuche Serien nach Publikationsstatus</string>
|
||||||
<string name="opds_feeds_languages_title">Sprachen</string>
|
<string name="opds_feeds_languages_title">Sprachen</string>
|
||||||
@@ -122,4 +122,7 @@
|
|||||||
<string name="webview_label_login_required">Deine Konfiguration erfordert die Anmeldung. Bitte gib Benutzername und Passwort ein.</string>
|
<string name="webview_label_login_required">Deine Konfiguration erfordert die Anmeldung. Bitte gib Benutzername und Passwort ein.</string>
|
||||||
<string name="opds_linktitle_first_page">Erste Seite</string>
|
<string name="opds_linktitle_first_page">Erste Seite</string>
|
||||||
<string name="opds_linktitle_last_page">Letzte Seite</string>
|
<string name="opds_linktitle_last_page">Letzte Seite</string>
|
||||||
|
<string name="opds_error_chapters_not_found">Keine Kapitel gefunden oder die Quelle ist nicht erreichbar auf Seite %1$d.</string>
|
||||||
|
<string name="opds_chapter_title_fallback">Kapitel %1$s</string>
|
||||||
|
<string name="opds_chapter_title_oneshot">Oneshot</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -122,4 +122,6 @@
|
|||||||
<string name="login_label_login">Σύνδεση</string>
|
<string name="login_label_login">Σύνδεση</string>
|
||||||
<string name="login_placeholder_username">Πληκτρολόγησε όνομα χρήστη...</string>
|
<string name="login_placeholder_username">Πληκτρολόγησε όνομα χρήστη...</string>
|
||||||
<string name="login_placeholder_password">Μυστικό...</string>
|
<string name="login_placeholder_password">Μυστικό...</string>
|
||||||
|
<string name="opds_error_chapters_not_found">Δεν βρέθηκαν κεφάλαια ή η πηγή είναι μη διαθέσιμη στη σελίδα %1$d.</string>
|
||||||
|
<string name="opds_chapter_title_fallback">Κεφάλαιο %1$s</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -122,4 +122,6 @@
|
|||||||
<string name="webview_label_login_required">Su configuración requiere que inicie sesión. Introduzca su nombre de usuario y contraseña.</string>
|
<string name="webview_label_login_required">Su configuración requiere que inicie sesión. Introduzca su nombre de usuario y contraseña.</string>
|
||||||
<string name="opds_linktitle_first_page">Primera página</string>
|
<string name="opds_linktitle_first_page">Primera página</string>
|
||||||
<string name="opds_linktitle_last_page">Última página</string>
|
<string name="opds_linktitle_last_page">Última página</string>
|
||||||
|
<string name="opds_error_chapters_not_found">No se encontraron capítulos o la fuente no está disponible en la página %1$d.</string>
|
||||||
|
<string name="opds_chapter_title_fallback">Capítulo %1$s</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -122,4 +122,7 @@
|
|||||||
<string name="login_label_login">Se connecter</string>
|
<string name="login_label_login">Se connecter</string>
|
||||||
<string name="login_placeholder_username">Tapez le nom d\'utilisateur…</string>
|
<string name="login_placeholder_username">Tapez le nom d\'utilisateur…</string>
|
||||||
<string name="login_placeholder_password">Secret…</string>
|
<string name="login_placeholder_password">Secret…</string>
|
||||||
|
<string name="opds_error_chapters_not_found">Aucun chapitre trouvé ou la source est inaccessible à la page %1$d.</string>
|
||||||
|
<string name="opds_chapter_title_fallback">Chapitre %1$s</string>
|
||||||
|
<string name="opds_chapter_title_oneshot">One shot</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -122,4 +122,6 @@
|
|||||||
<string name="login_label_login">Accedi</string>
|
<string name="login_label_login">Accedi</string>
|
||||||
<string name="login_placeholder_username">Digita il nome utente...</string>
|
<string name="login_placeholder_username">Digita il nome utente...</string>
|
||||||
<string name="login_placeholder_password">Segreto...</string>
|
<string name="login_placeholder_password">Segreto...</string>
|
||||||
|
<string name="opds_error_chapters_not_found">Nessun capitolo trovato o la fonte non è raggiungibile alla pagina %1$d.</string>
|
||||||
|
<string name="opds_chapter_title_fallback">Capitolo %1$s</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -60,4 +60,7 @@
|
|||||||
<string name="opds_feeds_library_sources_title">ソース</string>
|
<string name="opds_feeds_library_sources_title">ソース</string>
|
||||||
<string name="opds_feeds_library_sources_entry_content">ソース別にライブラリ内のマンガを閲覧</string>
|
<string name="opds_feeds_library_sources_entry_content">ソース別にライブラリ内のマンガを閲覧</string>
|
||||||
<string name="opds_feeds_search_results_title">検索結果</string>
|
<string name="opds_feeds_search_results_title">検索結果</string>
|
||||||
|
<string name="opds_error_chapters_not_found">ページ %1$d で章が見つからないか、ソースに接続できません。</string>
|
||||||
|
<string name="opds_chapter_title_oneshot">読み切り</string>
|
||||||
|
<string name="opds_chapter_title_fallback">第 %1$s 話</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -76,4 +76,6 @@
|
|||||||
<string name="opds_facet_filter_all">Wszystkie</string>
|
<string name="opds_facet_filter_all">Wszystkie</string>
|
||||||
<string name="opds_facet_filter_downloaded">Pobrane</string>
|
<string name="opds_facet_filter_downloaded">Pobrane</string>
|
||||||
<string name="opds_facet_filter_ongoing">Trwające</string>
|
<string name="opds_facet_filter_ongoing">Trwające</string>
|
||||||
|
<string name="opds_error_chapters_not_found">Nie znaleziono rozdziałów lub źródło jest nieosiągalne na stronie %1$d.</string>
|
||||||
|
<string name="opds_chapter_title_fallback">Rozdział %1$s</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -122,4 +122,6 @@
|
|||||||
<string name="login_label_login">Entrar</string>
|
<string name="login_label_login">Entrar</string>
|
||||||
<string name="login_placeholder_username">Digite o nome de usuário...</string>
|
<string name="login_placeholder_username">Digite o nome de usuário...</string>
|
||||||
<string name="login_placeholder_password">Segredo...</string>
|
<string name="login_placeholder_password">Segredo...</string>
|
||||||
|
<string name="opds_error_chapters_not_found">Nenhum capítulo encontrado ou a fonte está inacessível na página %1$d.</string>
|
||||||
|
<string name="opds_chapter_title_fallback">Capítulo %1$s</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -122,4 +122,7 @@
|
|||||||
<string name="opds_search_description">Ищите тайтлы в каталоге.</string>
|
<string name="opds_search_description">Ищите тайтлы в каталоге.</string>
|
||||||
<string name="opds_error_manga_not_found">Тайтл с ID %1$d не найден.</string>
|
<string name="opds_error_manga_not_found">Тайтл с ID %1$d не найден.</string>
|
||||||
<string name="opds_chapter_details_base">Тайтл: %1$s | %2$s</string>
|
<string name="opds_chapter_details_base">Тайтл: %1$s | %2$s</string>
|
||||||
|
<string name="opds_error_chapters_not_found">Главы не найдены или источник недоступен на странице %1$d.</string>
|
||||||
|
<string name="opds_chapter_title_fallback">Глава %1$s</string>
|
||||||
|
<string name="opds_chapter_title_oneshot">Ваншот</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -53,4 +53,7 @@
|
|||||||
<string name="opds_chapter_status_unread">⭕</string>
|
<string name="opds_chapter_status_unread">⭕</string>
|
||||||
<string name="opds_chapter_details_base">%1$s | %2$s</string>
|
<string name="opds_chapter_details_base">%1$s | %2$s</string>
|
||||||
<string name="opds_feeds_genre_specific_title">இசைவகை: %1$s</string>
|
<string name="opds_feeds_genre_specific_title">இசைவகை: %1$s</string>
|
||||||
|
<string name="opds_error_chapters_not_found">பக்கம் %1$d இல் அத்தியாயங்கள் எதுவும் காணப்படவில்லை அல்லது மூலத்தை அணுக முடியவில்லை.</string>
|
||||||
|
<string name="opds_chapter_title_oneshot">ஒன்-ஷாட்</string>
|
||||||
|
<string name="opds_chapter_title_fallback">அத்தியாயம் %1$s</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -122,4 +122,6 @@
|
|||||||
<string name="webview_label_login_required">Cấu hình của bạn yêu cầu bạn phải đăng nhập. Vui lòng nhập tên người dùng và mật khẩu.</string>
|
<string name="webview_label_login_required">Cấu hình của bạn yêu cầu bạn phải đăng nhập. Vui lòng nhập tên người dùng và mật khẩu.</string>
|
||||||
<string name="opds_linktitle_first_page">Trang đầu</string>
|
<string name="opds_linktitle_first_page">Trang đầu</string>
|
||||||
<string name="opds_linktitle_last_page">Trang cuối</string>
|
<string name="opds_linktitle_last_page">Trang cuối</string>
|
||||||
|
<string name="opds_error_chapters_not_found">Không tìm thấy chương nào hoặc nguồn không thể truy cập tại trang %1$d.</string>
|
||||||
|
<string name="opds_chapter_title_fallback">Chương %1$s</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -122,4 +122,7 @@
|
|||||||
<string name="login_placeholder_username">输入用户名…</string>
|
<string name="login_placeholder_username">输入用户名…</string>
|
||||||
<string name="login_placeholder_password">密匙…</string>
|
<string name="login_placeholder_password">密匙…</string>
|
||||||
<string name="label_error">错误</string>
|
<string name="label_error">错误</string>
|
||||||
|
<string name="opds_error_chapters_not_found">第 %1$d 页未找到任何章节,或图源无法访问。</string>
|
||||||
|
<string name="opds_chapter_title_fallback">第 %1$s 章</string>
|
||||||
|
<string name="opds_chapter_title_oneshot">单篇</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -276,30 +276,38 @@ class ServerConfig(
|
|||||||
description = "Ignore re-uploaded chapters from auto-download",
|
description = "Ignore re-uploaded chapters from auto-download",
|
||||||
)
|
)
|
||||||
|
|
||||||
val extensionRepos: MutableStateFlow<List<String>> by ListSetting<String>(
|
@Deprecated("Will get removed", replaceWith = ReplaceWith("extensionStores"))
|
||||||
|
val extensionRepos: MutableStateFlow<List<String>> by MigratedConfigValue(
|
||||||
protoNumber = 22,
|
protoNumber = 22,
|
||||||
group = SettingGroup.EXTENSION,
|
group = SettingGroup.EXTENSION,
|
||||||
privacySafe = false,
|
privacySafe = false,
|
||||||
defaultValue = emptyList(),
|
defaultValue = emptyList(),
|
||||||
itemValidator = { url ->
|
deprecated =
|
||||||
if (url.matches(repoMatchRegex)) {
|
SettingsRegistry.SettingDeprecated(
|
||||||
null
|
message = "Replaced with addExtensionStore and removeExtensionStore mutations",
|
||||||
} else {
|
migrateConfigValue = {
|
||||||
"Invalid repository URL format"
|
@Suppress("UNCHECKED_CAST")
|
||||||
}
|
(it.unwrapped() as? List<String>)
|
||||||
},
|
?.map {
|
||||||
itemToValidValue = { url ->
|
if (it.contains("github.com")) {
|
||||||
if (url.matches(repoMatchRegex)) {
|
it.replace(repoMatchRegex) {
|
||||||
url
|
"https://raw.githubusercontent.com/${it.groupValues[2]}/${it.groupValues[3]}/" +
|
||||||
} else {
|
(it.groupValues.getOrNull(4)?.ifBlank { null } ?: "repo") +
|
||||||
null
|
"/" +
|
||||||
}
|
(it.groupValues.getOrNull(5)?.ifBlank { null } ?: "index.min.json")
|
||||||
},
|
}
|
||||||
|
} else {
|
||||||
|
it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
readMigrated = { extensionStores.value },
|
||||||
|
setMigrated = { extensionStores.value = it.distinct() },
|
||||||
typeInfo =
|
typeInfo =
|
||||||
SettingsRegistry.PartialTypeInfo(
|
SettingsRegistry.PartialTypeInfo(
|
||||||
specificType = "List<String>",
|
specificType = "List<String>",
|
||||||
),
|
),
|
||||||
description = "example: [\"https://github.com/MY_ACCOUNT/MY_REPO/tree/repo\"]",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
val maxSourcesInParallel: MutableStateFlow<Int> by IntSetting(
|
val maxSourcesInParallel: MutableStateFlow<Int> by IntSetting(
|
||||||
@@ -1104,7 +1112,29 @@ class ServerConfig(
|
|||||||
privacySafe = true,
|
privacySafe = true,
|
||||||
defaultValue = false,
|
defaultValue = false,
|
||||||
description = "Skips the metadata feed and provides download/stream links directly in the chapter list. Improves compatibility with KOReader auto-downloader. KoSync strategies are applied, but PROMPT conflicts are ignored (treating local progress as priority)."
|
description = "Skips the metadata feed and provides download/stream links directly in the chapter list. Improves compatibility with KOReader auto-downloader. KoSync strategies are applied, but PROMPT conflicts are ignored (treating local progress as priority)."
|
||||||
|
)
|
||||||
|
|
||||||
|
val extensionStores: MutableStateFlow<List<String>> by ListSetting<String>(
|
||||||
|
protoNumber = 97,
|
||||||
|
group = SettingGroup.EXTENSION,
|
||||||
|
privacySafe = true,
|
||||||
|
defaultValue = emptyList(),
|
||||||
|
requiresRestart = true,
|
||||||
|
itemValidator = { url ->
|
||||||
|
if (url.isNotEmpty()) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
"Invalid store URL format"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
itemToValidValue = { url ->
|
||||||
|
url.ifEmpty { null }
|
||||||
|
},
|
||||||
|
typeInfo =
|
||||||
|
SettingsRegistry.PartialTypeInfo(
|
||||||
|
specificType = "List<String>",
|
||||||
|
),
|
||||||
|
description = "List of extension store index URLs",
|
||||||
)
|
)
|
||||||
|
|
||||||
/** ****************************************************************** **/
|
/** ****************************************************************** **/
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package eu.kanade.tachiyomi.network
|
||||||
|
|
||||||
|
import okhttp3.Response
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exception that handles HTTP codes considered not successful by OkHttp.
|
||||||
|
* Use it to have a standardized error message in the app across the extensions.
|
||||||
|
*
|
||||||
|
* @see Response.isSuccessful
|
||||||
|
* @since tachiyomix 1.6
|
||||||
|
* @param code [Int] the HTTP status code
|
||||||
|
*/
|
||||||
|
class HttpException(
|
||||||
|
val code: Int,
|
||||||
|
) : IllegalStateException("HTTP error $code")
|
||||||
@@ -2,26 +2,25 @@ package eu.kanade.tachiyomi.network
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import app.cash.quickjs.QuickJs
|
import app.cash.quickjs.QuickJs
|
||||||
import kotlinx.coroutines.Dispatchers
|
import eu.kanade.tachiyomi.util.lang.withIOContext
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Util for evaluating JavaScript in sources.
|
* Util for evaluating JavaScript in sources.
|
||||||
*/
|
*/
|
||||||
|
@Suppress("UNUSED", "UNCHECKED_CAST")
|
||||||
class JavaScriptEngine(
|
class JavaScriptEngine(
|
||||||
@Suppress("UNUSED_PARAMETER") context: Context,
|
context: Context,
|
||||||
) {
|
) {
|
||||||
/**
|
/**
|
||||||
* Evaluate arbitrary JavaScript code and get the result as a primitive type
|
* Evaluate arbitrary JavaScript code and get the result as a primitive type
|
||||||
* (e.g., String, Int).
|
* (e.g., String, Int).
|
||||||
*
|
*
|
||||||
* @since extensions-lib 1.4
|
* @since tachiyomix 1.4
|
||||||
* @param script JavaScript to execute.
|
* @param script JavaScript to execute.
|
||||||
* @return Result of JavaScript code as a primitive type.
|
* @return Result of JavaScript code as a primitive type.
|
||||||
*/
|
*/
|
||||||
@Suppress("UNUSED", "UNCHECKED_CAST")
|
|
||||||
suspend fun <T> evaluate(script: String): T =
|
suspend fun <T> evaluate(script: String): T =
|
||||||
withContext(Dispatchers.IO) {
|
withIOContext {
|
||||||
QuickJs.create().use {
|
QuickJs.create().use {
|
||||||
it.evaluate(script) as T
|
it.evaluate(script) as T
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ package eu.kanade.tachiyomi.network
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import eu.kanade.tachiyomi.network.interceptor.CloudflareInterceptor
|
import eu.kanade.tachiyomi.network.interceptor.CloudflareInterceptor
|
||||||
import eu.kanade.tachiyomi.network.interceptor.IgnoreGzipInterceptor
|
|
||||||
import eu.kanade.tachiyomi.network.interceptor.UncaughtExceptionInterceptor
|
import eu.kanade.tachiyomi.network.interceptor.UncaughtExceptionInterceptor
|
||||||
import eu.kanade.tachiyomi.network.interceptor.UserAgentInterceptor
|
import eu.kanade.tachiyomi.network.interceptor.UserAgentInterceptor
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
@@ -22,9 +21,8 @@ import kotlinx.coroutines.flow.launchIn
|
|||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import okhttp3.Cache
|
import okhttp3.Cache
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.brotli.BrotliInterceptor
|
|
||||||
import okhttp3.logging.HttpLoggingInterceptor
|
import okhttp3.logging.HttpLoggingInterceptor
|
||||||
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource
|
import suwayomi.tachidesk.manga.impl.util.source.GetSource
|
||||||
import java.net.CookieHandler
|
import java.net.CookieHandler
|
||||||
import java.net.CookieManager
|
import java.net.CookieManager
|
||||||
import java.net.CookiePolicy
|
import java.net.CookiePolicy
|
||||||
@@ -64,7 +62,7 @@ class NetworkHelper(
|
|||||||
userAgent
|
userAgent
|
||||||
.drop(1)
|
.drop(1)
|
||||||
.onEach {
|
.onEach {
|
||||||
GetCatalogueSource.unregisterAllCatalogueSources() // need to reset the headers
|
GetSource.unregisterAllSources() // need to reset the headers
|
||||||
}.launchIn(GlobalScope)
|
}.launchIn(GlobalScope)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,8 +82,6 @@ class NetworkHelper(
|
|||||||
),
|
),
|
||||||
).addInterceptor(UncaughtExceptionInterceptor())
|
).addInterceptor(UncaughtExceptionInterceptor())
|
||||||
.addInterceptor(UserAgentInterceptor(::defaultUserAgentProvider))
|
.addInterceptor(UserAgentInterceptor(::defaultUserAgentProvider))
|
||||||
.addNetworkInterceptor(IgnoreGzipInterceptor())
|
|
||||||
.addNetworkInterceptor(BrotliInterceptor)
|
|
||||||
|
|
||||||
// if (preferences.verboseLogging().get()) {
|
// if (preferences.verboseLogging().get()) {
|
||||||
val httpLoggingInterceptor =
|
val httpLoggingInterceptor =
|
||||||
@@ -128,5 +124,7 @@ class NetworkHelper(
|
|||||||
// val client by lazy { baseClientBuilder.cache(Cache(cacheDir, cacheSize)).build() }
|
// val client by lazy { baseClientBuilder.cache(Cache(cacheDir, cacheSize)).build() }
|
||||||
val client by lazy { baseClientBuilder.build() }
|
val client by lazy { baseClientBuilder.build() }
|
||||||
|
|
||||||
|
@Deprecated("The regular client handles Cloudflare by default")
|
||||||
|
@Suppress("UNUSED")
|
||||||
val cloudflareClient by lazy { client }
|
val cloudflareClient by lazy { client }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,11 +15,14 @@ import rx.Observable
|
|||||||
import rx.Producer
|
import rx.Producer
|
||||||
import rx.Subscription
|
import rx.Subscription
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import kotlin.concurrent.atomics.AtomicBoolean
|
||||||
|
import kotlin.concurrent.atomics.ExperimentalAtomicApi
|
||||||
import kotlin.coroutines.resumeWithException
|
import kotlin.coroutines.resumeWithException
|
||||||
|
|
||||||
val jsonMime = "application/json; charset=utf-8".toMediaType()
|
val jsonMime = "application/json; charset=utf-8".toMediaType()
|
||||||
|
|
||||||
|
@OptIn(ExperimentalAtomicApi::class)
|
||||||
|
@Deprecated("Use suspend APIs instead")
|
||||||
fun Call.asObservable(): Observable<Response> {
|
fun Call.asObservable(): Observable<Response> {
|
||||||
return Observable.unsafeCreate { subscriber ->
|
return Observable.unsafeCreate { subscriber ->
|
||||||
// Since Call is a one-shot type, clone it for each new subscriber.
|
// Since Call is a one-shot type, clone it for each new subscriber.
|
||||||
@@ -27,9 +30,11 @@ fun Call.asObservable(): Observable<Response> {
|
|||||||
|
|
||||||
// Wrap the call in a helper which handles both unsubscription and backpressure.
|
// Wrap the call in a helper which handles both unsubscription and backpressure.
|
||||||
val requestArbiter =
|
val requestArbiter =
|
||||||
object : AtomicBoolean(), Producer, Subscription {
|
object : Producer, Subscription {
|
||||||
|
val boolean = AtomicBoolean(false)
|
||||||
|
|
||||||
override fun request(n: Long) {
|
override fun request(n: Long) {
|
||||||
if (n == 0L || !compareAndSet(false, true)) return
|
if (n == 0L || !boolean.compareAndSet(expectedValue = false, newValue = true)) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val response = call.execute()
|
val response = call.execute()
|
||||||
@@ -37,15 +42,15 @@ fun Call.asObservable(): Observable<Response> {
|
|||||||
subscriber.onNext(response)
|
subscriber.onNext(response)
|
||||||
subscriber.onCompleted()
|
subscriber.onCompleted()
|
||||||
}
|
}
|
||||||
} catch (error: Exception) {
|
} catch (e: Exception) {
|
||||||
if (!subscriber.isUnsubscribed) {
|
if (!subscriber.isUnsubscribed) {
|
||||||
subscriber.onError(error)
|
subscriber.onError(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun unsubscribe() {
|
override fun unsubscribe() {
|
||||||
// call.cancel()
|
call.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun isUnsubscribed(): Boolean = call.isCanceled()
|
override fun isUnsubscribed(): Boolean = call.isCanceled()
|
||||||
@@ -56,50 +61,50 @@ fun Call.asObservable(): Observable<Response> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun Call.asObservableSuccess(): Observable<Response> =
|
@Deprecated("Use suspend APIs instead")
|
||||||
asObservable()
|
fun Call.asObservableSuccess(): Observable<Response> {
|
||||||
.doOnNext { response ->
|
@Suppress("DEPRECATION")
|
||||||
if (!response.isSuccessful) {
|
return asObservable().doOnNext { response ->
|
||||||
response.close()
|
if (!response.isSuccessful) {
|
||||||
throw HttpException(response.code)
|
response.close()
|
||||||
|
throw HttpException(response.code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Based on https://github.com/square/okhttp/blob/master/okhttp-coroutines/src/main/kotlin/okhttp3/coroutines/ExecuteAsync.kt
|
||||||
|
// and https://github.com/gildor/kotlin-coroutines-okhttp
|
||||||
|
private suspend fun Call.await(callStack: Array<StackTraceElement>): Response {
|
||||||
|
return suspendCancellableCoroutine { continuation ->
|
||||||
|
continuation.invokeOnCancellation {
|
||||||
|
try {
|
||||||
|
this.cancel()
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
// ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Based on https://github.com/gildor/kotlin-coroutines-okhttp
|
this.enqueue(
|
||||||
private suspend fun Call.await(callStack: Array<StackTraceElement>): Response {
|
|
||||||
return suspendCancellableCoroutine { continuation ->
|
|
||||||
val callback =
|
|
||||||
object : Callback {
|
object : Callback {
|
||||||
override fun onResponse(
|
|
||||||
call: Call,
|
|
||||||
response: Response,
|
|
||||||
) {
|
|
||||||
continuation.resume(response) { _, resourceToClose, _ ->
|
|
||||||
response.body.close()
|
|
||||||
resourceToClose.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onFailure(
|
override fun onFailure(
|
||||||
call: Call,
|
call: Call,
|
||||||
e: IOException,
|
e: IOException,
|
||||||
) {
|
) {
|
||||||
// Don't bother with resuming the continuation if it is already cancelled.
|
|
||||||
if (continuation.isCancelled) return
|
if (continuation.isCancelled) return
|
||||||
val exception = IOException(e.message, e).apply { stackTrace = callStack }
|
val exception = IOException(e.message, e).apply { stackTrace = callStack }
|
||||||
continuation.resumeWithException(exception)
|
continuation.resumeWithException(exception)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
enqueue(callback)
|
override fun onResponse(
|
||||||
|
call: Call,
|
||||||
continuation.invokeOnCancellation {
|
response: Response,
|
||||||
try {
|
) {
|
||||||
cancel()
|
continuation.resume(response) { _, value, _ ->
|
||||||
} catch (ex: Throwable) {
|
value.close()
|
||||||
// Ignore cancel exception
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,7 +114,7 @@ suspend fun Call.await(): Response {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @since extensions-lib 1.5
|
* Similar to [await] but throws [HttpException] if [Response.isSuccessful] returns false
|
||||||
*/
|
*/
|
||||||
suspend fun Call.awaitSuccess(): Response {
|
suspend fun Call.awaitSuccess(): Response {
|
||||||
val callStack = Exception().stackTrace.run { copyOfRange(1, size) }
|
val callStack = Exception().stackTrace.run { copyOfRange(1, size) }
|
||||||
@@ -150,7 +155,3 @@ fun <T> decodeFromJsonResponse(
|
|||||||
response.body.source().use {
|
response.body.source().use {
|
||||||
json.decodeFromBufferedSource(deserializer, it)
|
json.decodeFromBufferedSource(deserializer, it)
|
||||||
}
|
}
|
||||||
|
|
||||||
class HttpException(
|
|
||||||
val code: Int,
|
|
||||||
) : IllegalStateException("HTTP error $code")
|
|
||||||
|
|||||||
@@ -35,7 +35,11 @@ class ProgressResponseBody(
|
|||||||
val bytesRead = super.read(sink, byteCount)
|
val bytesRead = super.read(sink, byteCount)
|
||||||
// read() returns the number of bytes read, or -1 if this source is exhausted.
|
// read() returns the number of bytes read, or -1 if this source is exhausted.
|
||||||
totalBytesRead += if (bytesRead != -1L) bytesRead else 0
|
totalBytesRead += if (bytesRead != -1L) bytesRead else 0
|
||||||
progressListener.update(totalBytesRead, responseBody.contentLength(), bytesRead == -1L)
|
progressListener.update(
|
||||||
|
totalBytesRead,
|
||||||
|
responseBody.contentLength(),
|
||||||
|
bytesRead == -1L,
|
||||||
|
)
|
||||||
return bytesRead
|
return bytesRead
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import okhttp3.CacheControl
|
|||||||
import okhttp3.FormBody
|
import okhttp3.FormBody
|
||||||
import okhttp3.Headers
|
import okhttp3.Headers
|
||||||
import okhttp3.HttpUrl
|
import okhttp3.HttpUrl
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.RequestBody
|
import okhttp3.RequestBody
|
||||||
import java.util.concurrent.TimeUnit.MINUTES
|
import java.util.concurrent.TimeUnit.MINUTES
|
||||||
@@ -18,13 +19,7 @@ fun GET(
|
|||||||
url: String,
|
url: String,
|
||||||
headers: Headers = DEFAULT_HEADERS,
|
headers: Headers = DEFAULT_HEADERS,
|
||||||
cache: CacheControl = DEFAULT_CACHE_CONTROL,
|
cache: CacheControl = DEFAULT_CACHE_CONTROL,
|
||||||
): Request =
|
): Request = GET(url.toHttpUrl(), headers, cache)
|
||||||
Request
|
|
||||||
.Builder()
|
|
||||||
.url(url)
|
|
||||||
.headers(headers)
|
|
||||||
.cacheControl(cache)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @since extensions-lib 1.4
|
* @since extensions-lib 1.4
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
package eu.kanade.tachiyomi.network.interceptor
|
|
||||||
|
|
||||||
import okhttp3.Interceptor
|
|
||||||
import okhttp3.Response
|
|
||||||
|
|
||||||
/**
|
|
||||||
* To use [okhttp3.brotli.BrotliInterceptor] as a network interceptor,
|
|
||||||
* add [IgnoreGzipInterceptor] right before it.
|
|
||||||
*
|
|
||||||
* This nullifies the transparent gzip of [okhttp3.internal.http.BridgeInterceptor]
|
|
||||||
* so gzip and Brotli are explicitly handled by the [okhttp3.brotli.BrotliInterceptor].
|
|
||||||
*/
|
|
||||||
class IgnoreGzipInterceptor : Interceptor {
|
|
||||||
override fun intercept(chain: Interceptor.Chain): Response {
|
|
||||||
var request = chain.request()
|
|
||||||
if (request.header("Accept-Encoding") == "gzip") {
|
|
||||||
request = request.newBuilder().removeHeader("Accept-Encoding").build()
|
|
||||||
}
|
|
||||||
return chain.proceed(request)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,6 +2,12 @@ package eu.kanade.tachiyomi.source
|
|||||||
|
|
||||||
import eu.kanade.tachiyomi.source.model.FilterList
|
import eu.kanade.tachiyomi.source.model.FilterList
|
||||||
import eu.kanade.tachiyomi.source.model.MangasPage
|
import eu.kanade.tachiyomi.source.model.MangasPage
|
||||||
|
import eu.kanade.tachiyomi.source.model.Page
|
||||||
|
import eu.kanade.tachiyomi.source.model.SChapter
|
||||||
|
import eu.kanade.tachiyomi.source.model.SManga
|
||||||
|
import eu.kanade.tachiyomi.source.model.SMangaUpdate
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.supervisorScope
|
||||||
import rx.Observable
|
import rx.Observable
|
||||||
import suwayomi.tachidesk.manga.impl.util.lang.awaitSingle
|
import suwayomi.tachidesk.manga.impl.util.lang.awaitSingle
|
||||||
|
|
||||||
@@ -11,68 +17,62 @@ interface CatalogueSource : Source {
|
|||||||
*/
|
*/
|
||||||
override val lang: String
|
override val lang: String
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether the source has support for latest updates.
|
|
||||||
*/
|
|
||||||
val supportsLatest: Boolean
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a page with a list of manga.
|
|
||||||
*
|
|
||||||
* @since extensions-lib 1.5
|
|
||||||
* @param page the page number to retrieve.
|
|
||||||
*/
|
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
suspend fun getPopularManga(page: Int): MangasPage = fetchPopularManga(page).awaitSingle()
|
override suspend fun getPopularManga(page: Int): MangasPage = fetchPopularManga(page).awaitSingle()
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a page with a list of manga.
|
|
||||||
*
|
|
||||||
* @since extensions-lib 1.5
|
|
||||||
* @param page the page number to retrieve.
|
|
||||||
* @param query the search query.
|
|
||||||
* @param filters the list of filters to apply.
|
|
||||||
*/
|
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
suspend fun getSearchManga(
|
override suspend fun getLatestUpdates(page: Int): MangasPage = fetchLatestUpdates(page).awaitSingle()
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
override suspend fun getSearchManga(
|
||||||
page: Int,
|
page: Int,
|
||||||
query: String,
|
query: String,
|
||||||
filters: FilterList,
|
filters: FilterList,
|
||||||
): MangasPage = fetchSearchManga(page, query, filters).awaitSingle()
|
): MangasPage = fetchSearchManga(page, query, filters).awaitSingle()
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
override suspend fun getMangaUpdate(
|
||||||
|
manga: SManga,
|
||||||
|
chapters: List<SChapter>,
|
||||||
|
fetchDetails: Boolean,
|
||||||
|
fetchChapters: Boolean,
|
||||||
|
): SMangaUpdate =
|
||||||
|
supervisorScope {
|
||||||
|
val asyncManga = if (fetchDetails) async { fetchMangaDetails(manga).awaitSingle() } else null
|
||||||
|
val asyncChapters = if (fetchChapters) async { fetchChapterList(manga).awaitSingle() } else null
|
||||||
|
SMangaUpdate(asyncManga?.await() ?: manga, asyncChapters?.await() ?: chapters)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
override suspend fun getPageList(chapter: SChapter): List<Page> = fetchPageList(chapter).awaitSingle()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a page with a list of latest manga updates.
|
* Returns an observable containing a page with a list of manga.
|
||||||
*
|
*
|
||||||
* @since extensions-lib 1.5
|
|
||||||
* @param page the page number to retrieve.
|
* @param page the page number to retrieve.
|
||||||
*/
|
*/
|
||||||
@Suppress("DEPRECATION")
|
@Deprecated("Use the suspend API instead", ReplaceWith("getPopularManga"))
|
||||||
suspend fun getLatestUpdates(page: Int): MangasPage = fetchLatestUpdates(page).awaitSingle()
|
fun fetchPopularManga(page: Int): Observable<MangasPage> = throw UnsupportedOperationException()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the list of filters for the source.
|
* Returns an observable containing a page with a list of manga.
|
||||||
|
*
|
||||||
|
* @param page the page number to retrieve.
|
||||||
|
* @param query the search query.
|
||||||
|
* @param filters the list of filters to apply.
|
||||||
*/
|
*/
|
||||||
fun getFilterList(): FilterList
|
@Deprecated("Use the suspend API instead", ReplaceWith("getSearchManga"))
|
||||||
|
|
||||||
@Deprecated(
|
|
||||||
"Use the non-RxJava API instead",
|
|
||||||
ReplaceWith("getPopularManga"),
|
|
||||||
)
|
|
||||||
fun fetchPopularManga(page: Int): Observable<MangasPage> = throw IllegalStateException("Not used")
|
|
||||||
|
|
||||||
@Deprecated(
|
|
||||||
"Use the non-RxJava API instead",
|
|
||||||
ReplaceWith("getSearchManga"),
|
|
||||||
)
|
|
||||||
fun fetchSearchManga(
|
fun fetchSearchManga(
|
||||||
page: Int,
|
page: Int,
|
||||||
query: String,
|
query: String,
|
||||||
filters: FilterList,
|
filters: FilterList,
|
||||||
): Observable<MangasPage> = throw IllegalStateException("Not used")
|
): Observable<MangasPage> = throw UnsupportedOperationException()
|
||||||
|
|
||||||
@Deprecated(
|
/**
|
||||||
"Use the non-RxJava API instead",
|
* Returns an observable containing a page with a list of latest manga updates.
|
||||||
ReplaceWith("getLatestUpdates"),
|
*
|
||||||
)
|
* @param page the page number to retrieve.
|
||||||
fun fetchLatestUpdates(page: Int): Observable<MangasPage> = throw IllegalStateException("Not used")
|
*/
|
||||||
|
@Deprecated("Use the suspend API instead", ReplaceWith("getLatestUpdates"))
|
||||||
|
fun fetchLatestUpdates(page: Int): Observable<MangasPage> = throw UnsupportedOperationException()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package eu.kanade.tachiyomi.source
|
||||||
|
|
||||||
|
@Suppress("unused")
|
||||||
|
typealias PreferenceScreen = androidx.preference.PreferenceScreen
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
package eu.kanade.tachiyomi.source
|
package eu.kanade.tachiyomi.source
|
||||||
|
|
||||||
|
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.Page
|
||||||
import eu.kanade.tachiyomi.source.model.SChapter
|
import eu.kanade.tachiyomi.source.model.SChapter
|
||||||
import eu.kanade.tachiyomi.source.model.SManga
|
import eu.kanade.tachiyomi.source.model.SManga
|
||||||
|
import eu.kanade.tachiyomi.source.model.SMangaUpdate
|
||||||
import rx.Observable
|
import rx.Observable
|
||||||
import suwayomi.tachidesk.manga.impl.util.lang.awaitSingle
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A basic interface for creating a source. It could be an online source, a local source, etc.
|
* A basic interface for creating a source. It could be an online source, a local source, etc.
|
||||||
@@ -24,53 +26,86 @@ interface Source {
|
|||||||
get() = ""
|
get() = ""
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the updated details for a manga.
|
* Whether the source has support for latest updates.
|
||||||
*
|
|
||||||
* @since extensions-lib 1.5
|
|
||||||
* @param manga the manga to update.
|
|
||||||
* @return the updated manga.
|
|
||||||
*/
|
*/
|
||||||
@Suppress("DEPRECATION")
|
val supportsLatest: Boolean
|
||||||
suspend fun getMangaDetails(manga: SManga): SManga = fetchMangaDetails(manga).awaitSingle()
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all the available chapters for a manga.
|
* Returns the list of filters for the source.
|
||||||
*
|
|
||||||
* @since extensions-lib 1.5
|
|
||||||
* @param manga the manga to update.
|
|
||||||
* @return the chapters for the manga.
|
|
||||||
*/
|
*/
|
||||||
@Suppress("DEPRECATION")
|
fun getFilterList(): FilterList = FilterList()
|
||||||
suspend fun getChapterList(manga: SManga): List<SChapter> = fetchChapterList(manga).awaitSingle()
|
|
||||||
|
/**
|
||||||
|
* Get a page with a list of manga.
|
||||||
|
*
|
||||||
|
* @since tachiyomix 1.6
|
||||||
|
* @param page the page number to retrieve.
|
||||||
|
*/
|
||||||
|
suspend fun getPopularManga(page: Int): MangasPage
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a page with a list of latest manga updates.
|
||||||
|
*
|
||||||
|
* @since tachiyomix 1.6
|
||||||
|
* @param page the page number to retrieve.
|
||||||
|
*/
|
||||||
|
suspend fun getLatestUpdates(page: Int): MangasPage
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a page with a list of manga.
|
||||||
|
*
|
||||||
|
* @since tachiyomix 1.6
|
||||||
|
* @param page the page number to retrieve.
|
||||||
|
* @param query the search query.
|
||||||
|
* @param filters the list of filters to apply.
|
||||||
|
*/
|
||||||
|
suspend fun getSearchManga(
|
||||||
|
page: Int,
|
||||||
|
query: String,
|
||||||
|
filters: FilterList,
|
||||||
|
): MangasPage
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches updated information for a manga.
|
||||||
|
*
|
||||||
|
* Depending on the provided flags or source availability, this may include
|
||||||
|
* updated manga metadata, available chapters, or both.
|
||||||
|
*
|
||||||
|
* If a value is not requested, the existing provided value can be returned as-is.
|
||||||
|
* The host app may apply any returned updates regardless of the flags,
|
||||||
|
* so care should be taken to only return accurate and intentional changes.
|
||||||
|
*
|
||||||
|
* @since tachiyomix 1.6
|
||||||
|
* @param manga The manga to fetch updates for.
|
||||||
|
* @param chapters Existing chapters of the manga
|
||||||
|
* @param fetchDetails Whether to fetch updated manga details.
|
||||||
|
* @param fetchChapters Whether to fetch available chapters.
|
||||||
|
*/
|
||||||
|
suspend fun getMangaUpdate(
|
||||||
|
manga: SManga,
|
||||||
|
chapters: List<SChapter>,
|
||||||
|
fetchDetails: Boolean,
|
||||||
|
fetchChapters: Boolean,
|
||||||
|
): SMangaUpdate
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the list of pages a chapter has. Pages should be returned
|
* Get the list of pages a chapter has. Pages should be returned
|
||||||
* in the expected order; the index is ignored.
|
* in the expected order; the index is ignored.
|
||||||
*
|
*
|
||||||
* @since extensions-lib 1.5
|
* @since tachiyomix 1.6
|
||||||
* @param chapter the chapter.
|
* @param chapter the chapter.
|
||||||
* @return the pages for the chapter.
|
* @return the pages for the chapter.
|
||||||
*/
|
*/
|
||||||
@Suppress("DEPRECATION")
|
suspend fun getPageList(chapter: SChapter): List<Page>
|
||||||
suspend fun getPageList(chapter: SChapter): List<Page> = fetchPageList(chapter).awaitSingle()
|
|
||||||
|
|
||||||
@Deprecated(
|
@Deprecated("Use the combined suspend API instead", ReplaceWith("getMangaUpdate"))
|
||||||
"Use the non-RxJava API instead",
|
fun fetchMangaDetails(manga: SManga): Observable<SManga> = throw UnsupportedOperationException()
|
||||||
ReplaceWith("getMangaDetails"),
|
|
||||||
)
|
|
||||||
fun fetchMangaDetails(manga: SManga): Observable<SManga> = throw IllegalStateException("Not used")
|
|
||||||
|
|
||||||
@Deprecated(
|
@Deprecated("Use the combined suspend API instead", ReplaceWith("getMangaUpdate"))
|
||||||
"Use the non-RxJava API instead",
|
fun fetchChapterList(manga: SManga): Observable<List<SChapter>> = throw UnsupportedOperationException()
|
||||||
ReplaceWith("getChapterList"),
|
|
||||||
)
|
|
||||||
fun fetchChapterList(manga: SManga): Observable<List<SChapter>> = throw IllegalStateException("Not used")
|
|
||||||
|
|
||||||
@Deprecated(
|
@Deprecated("Use the suspend API instead", ReplaceWith("getPageList"))
|
||||||
"Use the non-RxJava API instead",
|
fun fetchPageList(chapter: SChapter): Observable<List<Page>> = throw UnsupportedOperationException()
|
||||||
ReplaceWith("getPageList"),
|
|
||||||
)
|
|
||||||
fun fetchPageList(chapter: SChapter): Observable<List<Page>> = throw IllegalStateException("Not used")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// fun Source.icon(): Drawable? = Injekt.get<ExtensionManager>().getAppIconForSource(this)
|
// fun Source.icon(): Drawable? = Injekt.get<ExtensionManager>().getAppIconForSource(this)
|
||||||
|
|||||||
@@ -23,12 +23,15 @@ import eu.kanade.tachiyomi.source.model.MangasPage
|
|||||||
import eu.kanade.tachiyomi.source.model.Page
|
import eu.kanade.tachiyomi.source.model.Page
|
||||||
import eu.kanade.tachiyomi.source.model.SChapter
|
import eu.kanade.tachiyomi.source.model.SChapter
|
||||||
import eu.kanade.tachiyomi.source.model.SManga
|
import eu.kanade.tachiyomi.source.model.SManga
|
||||||
|
import eu.kanade.tachiyomi.source.model.SMangaUpdate
|
||||||
import eu.kanade.tachiyomi.util.chapter.ChapterRecognition
|
import eu.kanade.tachiyomi.util.chapter.ChapterRecognition
|
||||||
import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder
|
import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder
|
||||||
import eu.kanade.tachiyomi.util.storage.EpubFile
|
import eu.kanade.tachiyomi.util.storage.EpubFile
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.supervisorScope
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.json.decodeFromStream
|
import kotlinx.serialization.json.decodeFromStream
|
||||||
@@ -41,7 +44,7 @@ import org.jetbrains.exposed.v1.jdbc.insert
|
|||||||
import org.jetbrains.exposed.v1.jdbc.insertAndGetId
|
import org.jetbrains.exposed.v1.jdbc.insertAndGetId
|
||||||
import org.jetbrains.exposed.v1.jdbc.selectAll
|
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||||
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.registerCatalogueSource
|
import suwayomi.tachidesk.manga.impl.util.source.GetSource.registerSource
|
||||||
import suwayomi.tachidesk.manga.impl.util.storage.ImageUtil
|
import suwayomi.tachidesk.manga.impl.util.storage.ImageUtil
|
||||||
import suwayomi.tachidesk.manga.model.table.ExtensionTable
|
import suwayomi.tachidesk.manga.model.table.ExtensionTable
|
||||||
import suwayomi.tachidesk.manga.model.table.SourceTable
|
import suwayomi.tachidesk.manga.model.table.SourceTable
|
||||||
@@ -167,8 +170,20 @@ class LocalSource(
|
|||||||
return MangasPage(mangas.toList(), false)
|
return MangasPage(mangas.toList(), false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun getMangaUpdate(
|
||||||
|
manga: SManga,
|
||||||
|
chapters: List<SChapter>,
|
||||||
|
fetchDetails: Boolean,
|
||||||
|
fetchChapters: Boolean,
|
||||||
|
): SMangaUpdate =
|
||||||
|
supervisorScope {
|
||||||
|
val asyncManga = if (fetchDetails) async { getMangaDetails(manga) } else null
|
||||||
|
val asyncChapters = if (fetchChapters) async { getChapterList(manga) } else null
|
||||||
|
SMangaUpdate(asyncManga?.await() ?: manga, asyncChapters?.await() ?: chapters)
|
||||||
|
}
|
||||||
|
|
||||||
// Manga details related
|
// Manga details related
|
||||||
override suspend fun getMangaDetails(manga: SManga): SManga =
|
private suspend fun getMangaDetails(manga: SManga): SManga =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
coverManager.find(manga.url)?.let {
|
coverManager.find(manga.url)?.let {
|
||||||
manga.thumbnail_url = it.absolutePath
|
manga.thumbnail_url = it.absolutePath
|
||||||
@@ -289,7 +304,7 @@ class LocalSource(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Chapters
|
// Chapters
|
||||||
override suspend fun getChapterList(manga: SManga): List<SChapter> =
|
private suspend fun getChapterList(manga: SManga): List<SChapter> =
|
||||||
fileSystem
|
fileSystem
|
||||||
.getFilesInMangaDirectory(manga.url)
|
.getFilesInMangaDirectory(manga.url)
|
||||||
// Only keep supported formats
|
// Only keep supported formats
|
||||||
@@ -467,7 +482,8 @@ class LocalSource(
|
|||||||
it[versionName] = "1.2"
|
it[versionName] = "1.2"
|
||||||
it[versionCode] = 0
|
it[versionCode] = 0
|
||||||
it[lang] = LANG
|
it[lang] = LANG
|
||||||
it[isNsfw] = false
|
it[extensionLib] = "1.2"
|
||||||
|
it[contentWarning] = 0
|
||||||
it[isInstalled] = true
|
it[isInstalled] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -476,13 +492,12 @@ class LocalSource(
|
|||||||
it[name] = NAME
|
it[name] = NAME
|
||||||
it[lang] = LANG
|
it[lang] = LANG
|
||||||
it[extension] = extensionId
|
it[extension] = extensionId
|
||||||
it[isNsfw] = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val fs = LocalSourceFileSystem(applicationDirs)
|
val fs = LocalSourceFileSystem(applicationDirs)
|
||||||
registerCatalogueSource(ID to LocalSource(fs, LocalCoverManager(fs)))
|
registerSource(ID to LocalSource(fs, LocalCoverManager(fs)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,22 @@
|
|||||||
package eu.kanade.tachiyomi.source.model
|
package eu.kanade.tachiyomi.source.model
|
||||||
|
|
||||||
data class MangasPage(
|
class MangasPage(
|
||||||
val mangas: List<SManga>,
|
val mangas: List<SManga>,
|
||||||
val hasNextPage: Boolean,
|
val hasNextPage: Boolean,
|
||||||
)
|
) {
|
||||||
|
@Deprecated("MangasPage is now a regular class")
|
||||||
|
operator fun component1(): List<SManga> = mangas
|
||||||
|
|
||||||
|
@Deprecated("MangasPage is now a regular class")
|
||||||
|
operator fun component2(): Boolean = hasNextPage
|
||||||
|
|
||||||
|
@Deprecated("MangasPage is now a regular class")
|
||||||
|
fun copy(
|
||||||
|
mangas: List<SManga> = this.mangas,
|
||||||
|
hasNextPage: Boolean = this.hasNextPage,
|
||||||
|
): MangasPage =
|
||||||
|
MangasPage(
|
||||||
|
mangas = mangas,
|
||||||
|
hasNextPage = hasNextPage,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,12 +27,4 @@ open class Page(
|
|||||||
-1
|
-1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val QUEUE = 0
|
|
||||||
const val LOAD_PAGE = 1
|
|
||||||
const val DOWNLOAD_IMAGE = 2
|
|
||||||
const val READY = 3
|
|
||||||
const val ERROR = 4
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
package eu.kanade.tachiyomi.source.model
|
package eu.kanade.tachiyomi.source.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
import java.io.Serializable
|
import java.io.Serializable
|
||||||
|
|
||||||
interface SChapter : Serializable {
|
interface SChapter : Serializable {
|
||||||
@@ -9,12 +10,25 @@ interface SChapter : Serializable {
|
|||||||
|
|
||||||
var name: String
|
var name: String
|
||||||
|
|
||||||
var date_upload: Long
|
|
||||||
|
|
||||||
var chapter_number: Float
|
var chapter_number: Float
|
||||||
|
|
||||||
var scanlator: String?
|
var scanlator: String?
|
||||||
|
|
||||||
|
var date_upload: Long
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extra metadata associated with the chapter.
|
||||||
|
*
|
||||||
|
* The JSON object is not visible to users and intended for internal or source-specific
|
||||||
|
* purposes. Apps may define their own namespaced keys (e.g., `"mihon.*"`) for sources to populate.
|
||||||
|
*
|
||||||
|
* This allows apps to attach and ask for custom information without affecting the visible
|
||||||
|
* chapter data.
|
||||||
|
*
|
||||||
|
* @since tachiyomix 1.6
|
||||||
|
*/
|
||||||
|
var memo: JsonObject
|
||||||
|
|
||||||
fun copyFrom(other: SChapter) {
|
fun copyFrom(other: SChapter) {
|
||||||
name = other.name
|
name = other.name
|
||||||
url = other.url
|
url = other.url
|
||||||
|
|||||||
@@ -2,14 +2,19 @@
|
|||||||
|
|
||||||
package eu.kanade.tachiyomi.source.model
|
package eu.kanade.tachiyomi.source.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import suwayomi.tachidesk.manga.impl.util.lang.EMPTY
|
||||||
|
|
||||||
class SChapterImpl : SChapter {
|
class SChapterImpl : SChapter {
|
||||||
override lateinit var url: String
|
override lateinit var url: String
|
||||||
|
|
||||||
override lateinit var name: String
|
override lateinit var name: String
|
||||||
|
|
||||||
override var date_upload: Long = 0
|
|
||||||
|
|
||||||
override var chapter_number: Float = -1f
|
override var chapter_number: Float = -1f
|
||||||
|
|
||||||
override var scanlator: String? = null
|
override var scanlator: String? = null
|
||||||
|
|
||||||
|
override var date_upload: Long = 0
|
||||||
|
|
||||||
|
override var memo: JsonObject = JsonObject.EMPTY
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
package eu.kanade.tachiyomi.source.model
|
package eu.kanade.tachiyomi.source.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
import java.io.Serializable
|
import java.io.Serializable
|
||||||
|
|
||||||
interface SManga : Serializable {
|
interface SManga : Serializable {
|
||||||
@@ -9,22 +10,58 @@ interface SManga : Serializable {
|
|||||||
|
|
||||||
var title: String
|
var title: String
|
||||||
|
|
||||||
|
var thumbnail_url: String?
|
||||||
|
|
||||||
var artist: String?
|
var artist: String?
|
||||||
|
|
||||||
var author: String?
|
var author: String?
|
||||||
|
|
||||||
|
var status: Int
|
||||||
|
|
||||||
var description: String?
|
var description: String?
|
||||||
|
|
||||||
var genre: String?
|
var genre: String?
|
||||||
|
|
||||||
var status: Int
|
|
||||||
|
|
||||||
var thumbnail_url: String?
|
|
||||||
|
|
||||||
var update_strategy: UpdateStrategy
|
var update_strategy: UpdateStrategy
|
||||||
|
|
||||||
var initialized: Boolean
|
var initialized: Boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extra metadata associated with the manga.
|
||||||
|
*
|
||||||
|
* The JSON object is not visible to users and intended for internal or source-specific
|
||||||
|
* purposes. Apps may define their own namespaced keys (e.g., `"mihon.*"`) for sources to populate.
|
||||||
|
*
|
||||||
|
* This allows apps to attach and ask for custom information without affecting the visible
|
||||||
|
* manga data.
|
||||||
|
*
|
||||||
|
* @since tachiyomix 1.6
|
||||||
|
*/
|
||||||
|
var memo: JsonObject
|
||||||
|
|
||||||
|
fun getGenres(): List<String>? {
|
||||||
|
if (genre.isNullOrBlank()) return null
|
||||||
|
return genre
|
||||||
|
?.split(", ")
|
||||||
|
?.map { it.trim() }
|
||||||
|
?.filterNot { it.isBlank() }
|
||||||
|
?.distinct()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun copy() =
|
||||||
|
create().also {
|
||||||
|
it.url = url
|
||||||
|
it.title = title
|
||||||
|
it.artist = artist
|
||||||
|
it.author = author
|
||||||
|
it.description = description
|
||||||
|
it.genre = genre
|
||||||
|
it.status = status
|
||||||
|
it.thumbnail_url = thumbnail_url
|
||||||
|
it.update_strategy = update_strategy
|
||||||
|
it.initialized = initialized
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val UNKNOWN = 0
|
const val UNKNOWN = 0
|
||||||
const val ONGOING = 1
|
const val ONGOING = 1
|
||||||
@@ -37,30 +74,3 @@ interface SManga : Serializable {
|
|||||||
fun create(): SManga = SMangaImpl()
|
fun create(): SManga = SMangaImpl()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|||||||
@@ -2,24 +2,29 @@
|
|||||||
|
|
||||||
package eu.kanade.tachiyomi.source.model
|
package eu.kanade.tachiyomi.source.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import suwayomi.tachidesk.manga.impl.util.lang.EMPTY
|
||||||
|
|
||||||
class SMangaImpl : SManga {
|
class SMangaImpl : SManga {
|
||||||
override lateinit var url: String
|
override lateinit var url: String
|
||||||
|
|
||||||
override lateinit var title: String
|
override lateinit var title: String
|
||||||
|
|
||||||
|
override var thumbnail_url: String? = null
|
||||||
|
|
||||||
override var artist: String? = null
|
override var artist: String? = null
|
||||||
|
|
||||||
override var author: String? = null
|
override var author: String? = null
|
||||||
|
|
||||||
|
override var status: Int = 0
|
||||||
|
|
||||||
override var description: String? = null
|
override var description: String? = null
|
||||||
|
|
||||||
override var genre: String? = null
|
override var genre: String? = null
|
||||||
|
|
||||||
override var status: Int = 0
|
|
||||||
|
|
||||||
override var thumbnail_url: String? = null
|
|
||||||
|
|
||||||
override var update_strategy: UpdateStrategy = UpdateStrategy.ALWAYS_UPDATE
|
override var update_strategy: UpdateStrategy = UpdateStrategy.ALWAYS_UPDATE
|
||||||
|
|
||||||
override var initialized: Boolean = false
|
override var initialized: Boolean = false
|
||||||
|
|
||||||
|
override var memo: JsonObject = JsonObject.EMPTY
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package eu.kanade.tachiyomi.source.model
|
||||||
|
|
||||||
|
@Suppress("UNUSED")
|
||||||
|
class SMangaUpdate(
|
||||||
|
val manga: SManga,
|
||||||
|
val chapters: List<SChapter>,
|
||||||
|
)
|
||||||
@@ -1,6 +1,22 @@
|
|||||||
package eu.kanade.tachiyomi.source.model
|
package eu.kanade.tachiyomi.source.model
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define the update strategy for a single [SManga].
|
||||||
|
* The strategy used will only take effect on the library update.
|
||||||
|
*
|
||||||
|
* @since extensions-lib 1.4
|
||||||
|
*/
|
||||||
enum class UpdateStrategy {
|
enum class UpdateStrategy {
|
||||||
|
/**
|
||||||
|
* Series marked as always update will be included in the library
|
||||||
|
* update if they aren't excluded by additional restrictions.
|
||||||
|
*/
|
||||||
ALWAYS_UPDATE,
|
ALWAYS_UPDATE,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Series marked as only fetch once will be automatically skipped
|
||||||
|
* during library updates. Useful for cases where the series is previously
|
||||||
|
* known to be finished and have only a single chapter, for example.
|
||||||
|
*/
|
||||||
ONLY_FETCH_ONCE,
|
ONLY_FETCH_ONCE,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ import java.security.MessageDigest
|
|||||||
/**
|
/**
|
||||||
* A simple implementation for sources from a website.
|
* A simple implementation for sources from a website.
|
||||||
*/
|
*/
|
||||||
@Suppress("unused")
|
|
||||||
abstract class HttpSource : CatalogueSource {
|
abstract class HttpSource : CatalogueSource {
|
||||||
/**
|
/**
|
||||||
* Network service.
|
* Network service.
|
||||||
@@ -37,11 +36,24 @@ abstract class HttpSource : CatalogueSource {
|
|||||||
*/
|
*/
|
||||||
abstract val baseUrl: String
|
abstract val baseUrl: String
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the base (home) URL of the website as a string.
|
||||||
|
*
|
||||||
|
* This is typically the root address that serves as the main entry point
|
||||||
|
* to the site's content, such as "https://mihon.tech".
|
||||||
|
*
|
||||||
|
* This method is used in the browse screen to determine the URL
|
||||||
|
* opened when tapping "Open in WebView".
|
||||||
|
*
|
||||||
|
* @return The website’s home page URL. Defaults to [baseUrl].
|
||||||
|
*/
|
||||||
|
open fun getHomeUrl(): String = baseUrl
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Version id used to generate the source id. If the site completely changes and urls are
|
* 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.
|
* incompatible, you may increase this value and it'll be considered as a new source.
|
||||||
*/
|
*/
|
||||||
open val versionId = 1
|
open val versionId: Int = 1
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ID of the source. By default it uses a generated id using the first 16 characters (64 bits)
|
* ID of the source. By default it uses a generated id using the first 16 characters (64 bits)
|
||||||
@@ -53,7 +65,7 @@ abstract class HttpSource : CatalogueSource {
|
|||||||
*
|
*
|
||||||
* Note: the generated ID sets the sign bit to `0`.
|
* Note: the generated ID sets the sign bit to `0`.
|
||||||
*/
|
*/
|
||||||
override val id by lazy { generateId() }
|
override val id: Long by lazy { generateId(name, lang, versionId) }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Headers used for requests.
|
* Headers used for requests.
|
||||||
@@ -63,10 +75,7 @@ abstract class HttpSource : CatalogueSource {
|
|||||||
/**
|
/**
|
||||||
* Default network client for doing requests.
|
* Default network client for doing requests.
|
||||||
*/
|
*/
|
||||||
open val client: OkHttpClient
|
open val client: OkHttpClient get() = network.client
|
||||||
get() = network.client
|
|
||||||
|
|
||||||
private fun generateId(): Long = generateId("${name.lowercase()}/$lang/$versionId")
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates a unique ID for the source based on the provided [name], [lang] and
|
* Generates a unique ID for the source based on the provided [name], [lang] and
|
||||||
@@ -91,10 +100,6 @@ abstract class HttpSource : CatalogueSource {
|
|||||||
versionId: Int,
|
versionId: Int,
|
||||||
): Long {
|
): Long {
|
||||||
val key = "${name.lowercase()}/$lang/$versionId"
|
val key = "${name.lowercase()}/$lang/$versionId"
|
||||||
return generateId(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun generateId(key: String): Long {
|
|
||||||
val bytes = MessageDigest.getInstance("MD5").digest(key.toByteArray())
|
val bytes = MessageDigest.getInstance("MD5").digest(key.toByteArray())
|
||||||
return (0..7).map { bytes[it].toLong() and 0xff shl 8 * (7 - it) }.reduce(Long::or) and Long.MAX_VALUE
|
return (0..7).map { bytes[it].toLong() and 0xff shl 8 * (7 - it) }.reduce(Long::or) and Long.MAX_VALUE
|
||||||
}
|
}
|
||||||
@@ -102,7 +107,7 @@ abstract class HttpSource : CatalogueSource {
|
|||||||
/**
|
/**
|
||||||
* Headers builder for requests. Implementations can override this method for custom headers.
|
* Headers builder for requests. Implementations can override this method for custom headers.
|
||||||
*/
|
*/
|
||||||
protected open fun headersBuilder() =
|
protected open fun headersBuilder(): Headers.Builder =
|
||||||
Headers.Builder().apply {
|
Headers.Builder().apply {
|
||||||
add("User-Agent", network.defaultUserAgentProvider())
|
add("User-Agent", network.defaultUserAgentProvider())
|
||||||
}
|
}
|
||||||
@@ -110,7 +115,7 @@ abstract class HttpSource : CatalogueSource {
|
|||||||
/**
|
/**
|
||||||
* Visible name of the source.
|
* Visible name of the source.
|
||||||
*/
|
*/
|
||||||
override fun toString() = "$name (${lang.uppercase()})"
|
override fun toString(): String = "$name (${lang.uppercase()})"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns an observable containing a page with a list of manga. Normally it's not needed to
|
* Returns an observable containing a page with a list of manga. Normally it's not needed to
|
||||||
@@ -118,7 +123,8 @@ abstract class HttpSource : CatalogueSource {
|
|||||||
*
|
*
|
||||||
* @param page the page number to retrieve.
|
* @param page the page number to retrieve.
|
||||||
*/
|
*/
|
||||||
@Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getPopularManga"))
|
@Suppress("DEPRECATION")
|
||||||
|
@Deprecated("Use the suspend API instead", ReplaceWith("getPopularManga"))
|
||||||
override fun fetchPopularManga(page: Int): Observable<MangasPage> =
|
override fun fetchPopularManga(page: Int): Observable<MangasPage> =
|
||||||
client
|
client
|
||||||
.newCall(popularMangaRequest(page))
|
.newCall(popularMangaRequest(page))
|
||||||
@@ -132,14 +138,24 @@ abstract class HttpSource : CatalogueSource {
|
|||||||
*
|
*
|
||||||
* @param page the page number to retrieve.
|
* @param page the page number to retrieve.
|
||||||
*/
|
*/
|
||||||
protected abstract fun popularMangaRequest(page: Int): Request
|
@Deprecated(
|
||||||
|
message =
|
||||||
|
"The helper functions are inherently limiting and hides the underlying implementation. " +
|
||||||
|
"Source developers should make their own implementation according to their needs.",
|
||||||
|
)
|
||||||
|
protected open fun popularMangaRequest(page: Int): Request = throw UnsupportedOperationException()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses the response from the site and returns a [MangasPage] object.
|
* Parses the response from the site and returns a [MangasPage] object.
|
||||||
*
|
*
|
||||||
* @param response the response from the site.
|
* @param response the response from the site.
|
||||||
*/
|
*/
|
||||||
protected abstract fun popularMangaParse(response: Response): MangasPage
|
@Deprecated(
|
||||||
|
message =
|
||||||
|
"The helper functions are inherently limiting and hides the underlying implementation. " +
|
||||||
|
"Source developers should make their own implementation according to their needs.",
|
||||||
|
)
|
||||||
|
protected open fun popularMangaParse(response: Response): MangasPage = throw UnsupportedOperationException()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns an observable containing a page with a list of manga. Normally it's not needed to
|
* Returns an observable containing a page with a list of manga. Normally it's not needed to
|
||||||
@@ -149,22 +165,17 @@ abstract class HttpSource : CatalogueSource {
|
|||||||
* @param query the search query.
|
* @param query the search query.
|
||||||
* @param filters the list of filters to apply.
|
* @param filters the list of filters to apply.
|
||||||
*/
|
*/
|
||||||
@Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getSearchManga"))
|
@Suppress("DEPRECATION")
|
||||||
|
@Deprecated("Use the suspend API instead", ReplaceWith("getSearchManga"))
|
||||||
override fun fetchSearchManga(
|
override fun fetchSearchManga(
|
||||||
page: Int,
|
page: Int,
|
||||||
query: String,
|
query: String,
|
||||||
filters: FilterList,
|
filters: FilterList,
|
||||||
): Observable<MangasPage> =
|
): Observable<MangasPage> =
|
||||||
Observable
|
client
|
||||||
.defer {
|
.newCall(searchMangaRequest(page, query, filters))
|
||||||
try {
|
.asObservableSuccess()
|
||||||
client.newCall(searchMangaRequest(page, query, filters)).asObservableSuccess()
|
.map { response ->
|
||||||
} catch (e: NoClassDefFoundError) {
|
|
||||||
// RxJava doesn't handle Errors, which tends to happen during global searches
|
|
||||||
// if an old extension using non-existent classes is still around
|
|
||||||
throw RuntimeException(e)
|
|
||||||
}
|
|
||||||
}.map { response ->
|
|
||||||
searchMangaParse(response)
|
searchMangaParse(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,25 +186,36 @@ abstract class HttpSource : CatalogueSource {
|
|||||||
* @param query the search query.
|
* @param query the search query.
|
||||||
* @param filters the list of filters to apply.
|
* @param filters the list of filters to apply.
|
||||||
*/
|
*/
|
||||||
protected abstract fun searchMangaRequest(
|
@Deprecated(
|
||||||
|
message =
|
||||||
|
"The helper functions are inherently limiting and hides the underlying implementation. " +
|
||||||
|
"Source developers should make their own implementation according to their needs.",
|
||||||
|
)
|
||||||
|
protected open fun searchMangaRequest(
|
||||||
page: Int,
|
page: Int,
|
||||||
query: String,
|
query: String,
|
||||||
filters: FilterList,
|
filters: FilterList,
|
||||||
): Request
|
): Request = throw UnsupportedOperationException()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses the response from the site and returns a [MangasPage] object.
|
* Parses the response from the site and returns a [MangasPage] object.
|
||||||
*
|
*
|
||||||
* @param response the response from the site.
|
* @param response the response from the site.
|
||||||
*/
|
*/
|
||||||
protected abstract fun searchMangaParse(response: Response): MangasPage
|
@Deprecated(
|
||||||
|
message =
|
||||||
|
"The helper functions are inherently limiting and hides the underlying implementation. " +
|
||||||
|
"Source developers should make their own implementation according to their needs.",
|
||||||
|
)
|
||||||
|
protected open fun searchMangaParse(response: Response): MangasPage = throw UnsupportedOperationException()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns an observable containing a page with a list of latest manga updates.
|
* Returns an observable containing a page with a list of latest manga updates.
|
||||||
*
|
*
|
||||||
* @param page the page number to retrieve.
|
* @param page the page number to retrieve.
|
||||||
*/
|
*/
|
||||||
@Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getLatestUpdates"))
|
@Suppress("DEPRECATION")
|
||||||
|
@Deprecated("Use the suspend API instead", ReplaceWith("getLatestUpdates"))
|
||||||
override fun fetchLatestUpdates(page: Int): Observable<MangasPage> =
|
override fun fetchLatestUpdates(page: Int): Observable<MangasPage> =
|
||||||
client
|
client
|
||||||
.newCall(latestUpdatesRequest(page))
|
.newCall(latestUpdatesRequest(page))
|
||||||
@@ -207,26 +229,33 @@ abstract class HttpSource : CatalogueSource {
|
|||||||
*
|
*
|
||||||
* @param page the page number to retrieve.
|
* @param page the page number to retrieve.
|
||||||
*/
|
*/
|
||||||
protected abstract fun latestUpdatesRequest(page: Int): Request
|
@Deprecated(
|
||||||
|
message =
|
||||||
|
"The helper functions are inherently limiting and hides the underlying implementation. " +
|
||||||
|
"Source developers should make their own implementation according to their needs.",
|
||||||
|
)
|
||||||
|
protected open fun latestUpdatesRequest(page: Int): Request = throw UnsupportedOperationException()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses the response from the site and returns a [MangasPage] object.
|
* Parses the response from the site and returns a [MangasPage] object.
|
||||||
*
|
*
|
||||||
* @param response the response from the site.
|
* @param response the response from the site.
|
||||||
*/
|
*/
|
||||||
protected abstract fun latestUpdatesParse(response: Response): MangasPage
|
@Deprecated(
|
||||||
|
message =
|
||||||
|
"The helper functions are inherently limiting and hides the underlying implementation. " +
|
||||||
|
"Source developers should make their own implementation according to their needs.",
|
||||||
|
)
|
||||||
|
protected open fun latestUpdatesParse(response: Response): MangasPage = throw UnsupportedOperationException()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the updated details for a manga.
|
* Returns an observable with the updated details for a manga. Normally it's not needed to
|
||||||
* Normally it's not needed to override this method.
|
* override this method.
|
||||||
*
|
*
|
||||||
* @param manga the manga to update.
|
* @param manga the manga to be updated.
|
||||||
* @return the updated manga.
|
|
||||||
*/
|
*/
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
override suspend fun getMangaDetails(manga: SManga): SManga = fetchMangaDetails(manga).awaitSingle()
|
@Deprecated("Use the combined suspend API instead", replaceWith = ReplaceWith("getMangaUpdate"))
|
||||||
|
|
||||||
@Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getMangaDetails"))
|
|
||||||
override fun fetchMangaDetails(manga: SManga): Observable<SManga> =
|
override fun fetchMangaDetails(manga: SManga): Observable<SManga> =
|
||||||
client
|
client
|
||||||
.newCall(mangaDetailsRequest(manga))
|
.newCall(mangaDetailsRequest(manga))
|
||||||
@@ -241,6 +270,11 @@ abstract class HttpSource : CatalogueSource {
|
|||||||
*
|
*
|
||||||
* @param manga the manga to be updated.
|
* @param manga the manga to be updated.
|
||||||
*/
|
*/
|
||||||
|
@Deprecated(
|
||||||
|
message =
|
||||||
|
"The helper functions are inherently limiting and hides the underlying implementation. " +
|
||||||
|
"Source developers should make their own implementation according to their needs.",
|
||||||
|
)
|
||||||
open fun mangaDetailsRequest(manga: SManga): Request = GET(baseUrl + manga.url, headers)
|
open fun mangaDetailsRequest(manga: SManga): Request = GET(baseUrl + manga.url, headers)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -248,37 +282,28 @@ abstract class HttpSource : CatalogueSource {
|
|||||||
*
|
*
|
||||||
* @param response the response from the site.
|
* @param response the response from the site.
|
||||||
*/
|
*/
|
||||||
protected abstract fun mangaDetailsParse(response: Response): SManga
|
@Deprecated(
|
||||||
|
message =
|
||||||
|
"The helper functions are inherently limiting and hides the underlying implementation. " +
|
||||||
|
"Source developers should make their own implementation according to their needs.",
|
||||||
|
)
|
||||||
|
protected open fun mangaDetailsParse(response: Response): SManga = throw UnsupportedOperationException()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all the available chapters for a manga.
|
* Returns an observable with the updated chapter list for a manga. Normally it's not needed to
|
||||||
* Normally it's not needed to override this method.
|
* override this method.
|
||||||
*
|
*
|
||||||
* @param manga the manga to update.
|
* @param manga the manga to look for chapters.
|
||||||
* @return the chapters for the manga.
|
|
||||||
* @throws LicensedMangaChaptersException if a manga is licensed and therefore no chapters are available.
|
|
||||||
*/
|
*/
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
override suspend fun getChapterList(manga: SManga): List<SChapter> {
|
@Deprecated("Use the combined suspend API instead", replaceWith = ReplaceWith("getMangaUpdate"))
|
||||||
if (manga.status == SManga.LICENSED) {
|
|
||||||
throw LicensedMangaChaptersException()
|
|
||||||
}
|
|
||||||
|
|
||||||
return fetchChapterList(manga).awaitSingle()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getChapterList"))
|
|
||||||
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> =
|
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> =
|
||||||
if (manga.status != SManga.LICENSED) {
|
client
|
||||||
client
|
.newCall(chapterListRequest(manga))
|
||||||
.newCall(chapterListRequest(manga))
|
.asObservableSuccess()
|
||||||
.asObservableSuccess()
|
.map { response ->
|
||||||
.map { response ->
|
chapterListParse(response)
|
||||||
chapterListParse(response)
|
}
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Observable.error(LicensedMangaChaptersException())
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the request for updating the chapter list. Override only if it's needed to override
|
* Returns the request for updating the chapter list. Override only if it's needed to override
|
||||||
@@ -286,6 +311,11 @@ abstract class HttpSource : CatalogueSource {
|
|||||||
*
|
*
|
||||||
* @param manga the manga to look for chapters.
|
* @param manga the manga to look for chapters.
|
||||||
*/
|
*/
|
||||||
|
@Deprecated(
|
||||||
|
message =
|
||||||
|
"The helper functions are inherently limiting and hides the underlying implementation. " +
|
||||||
|
"Source developers should make their own implementation according to their needs.",
|
||||||
|
)
|
||||||
protected open fun chapterListRequest(manga: SManga): Request = GET(baseUrl + manga.url, headers)
|
protected open fun chapterListRequest(manga: SManga): Request = GET(baseUrl + manga.url, headers)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -293,19 +323,20 @@ abstract class HttpSource : CatalogueSource {
|
|||||||
*
|
*
|
||||||
* @param response the response from the site.
|
* @param response the response from the site.
|
||||||
*/
|
*/
|
||||||
protected abstract fun chapterListParse(response: Response): List<SChapter>
|
@Deprecated(
|
||||||
|
message =
|
||||||
|
"The helper functions are inherently limiting and hides the underlying implementation. " +
|
||||||
|
"Source developers should make their own implementation according to their needs.",
|
||||||
|
)
|
||||||
|
protected open fun chapterListParse(response: Response): List<SChapter> = throw UnsupportedOperationException()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the list of pages a chapter has. Pages should be returned
|
* Returns an observable with the page list for a chapter.
|
||||||
* in the expected order; the index is ignored.
|
|
||||||
*
|
*
|
||||||
* @param chapter the chapter.
|
* @param chapter the chapter whose page list has to be fetched.
|
||||||
* @return the pages for the chapter.
|
|
||||||
*/
|
*/
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
override suspend fun getPageList(chapter: SChapter): List<Page> = fetchPageList(chapter).awaitSingle()
|
@Deprecated("Use the suspend API instead", ReplaceWith("getPageList"))
|
||||||
|
|
||||||
@Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getPageList"))
|
|
||||||
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> =
|
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> =
|
||||||
client
|
client
|
||||||
.newCall(pageListRequest(chapter))
|
.newCall(pageListRequest(chapter))
|
||||||
@@ -320,6 +351,11 @@ abstract class HttpSource : CatalogueSource {
|
|||||||
*
|
*
|
||||||
* @param chapter the chapter whose page list has to be fetched.
|
* @param chapter the chapter whose page list has to be fetched.
|
||||||
*/
|
*/
|
||||||
|
@Deprecated(
|
||||||
|
message =
|
||||||
|
"The helper functions are inherently limiting and hides the underlying implementation. " +
|
||||||
|
"Source developers should make their own implementation according to their needs.",
|
||||||
|
)
|
||||||
protected open fun pageListRequest(chapter: SChapter): Request = GET(baseUrl + chapter.url, headers)
|
protected open fun pageListRequest(chapter: SChapter): Request = GET(baseUrl + chapter.url, headers)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -327,31 +363,47 @@ abstract class HttpSource : CatalogueSource {
|
|||||||
*
|
*
|
||||||
* @param response the response from the site.
|
* @param response the response from the site.
|
||||||
*/
|
*/
|
||||||
protected abstract fun pageListParse(response: Response): List<Page>
|
@Deprecated(
|
||||||
|
message =
|
||||||
|
"The helper functions are inherently limiting and hides the underlying implementation. " +
|
||||||
|
"Source developers should make their own implementation according to their needs.",
|
||||||
|
)
|
||||||
|
protected open fun pageListParse(response: Response): List<Page> = throw UnsupportedOperationException()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns an observable with the page containing the source url of the image. If there's any
|
* 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.
|
* error, it will return null instead of throwing an exception.
|
||||||
*
|
*
|
||||||
* @since extensions-lib 1.5
|
|
||||||
* @param page the page whose source image has to be fetched.
|
* @param page the page whose source image has to be fetched.
|
||||||
*/
|
*/
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
open suspend fun getImageUrl(page: Page): String = fetchImageUrl(page).awaitSingle()
|
@Deprecated("Use the suspend API instead", ReplaceWith("getImageUrl"))
|
||||||
|
|
||||||
@Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getImageUrl"))
|
|
||||||
open fun fetchImageUrl(page: Page): Observable<String> =
|
open fun fetchImageUrl(page: Page): Observable<String> =
|
||||||
client
|
client
|
||||||
.newCall(imageUrlRequest(page))
|
.newCall(imageUrlRequest(page))
|
||||||
.asObservableSuccess()
|
.asObservableSuccess()
|
||||||
.map { imageUrlParse(it) }
|
.map { imageUrlParse(it) }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the image url for the provided [page]. The function is only called if [Page.imageUrl] is null.
|
||||||
|
*
|
||||||
|
* @since tachiyomix 1.6
|
||||||
|
* @param page the page whose source image has to be fetched.
|
||||||
|
*/
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
open suspend fun getImageUrl(page: Page): String = fetchImageUrl(page).awaitSingle()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the request for getting the url to the source image. Override only if it's needed to
|
* 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.
|
* override the url, send different headers or request method like POST.
|
||||||
*
|
*
|
||||||
* @param page the chapter whose page list has to be fetched
|
* @param page the chapter whose page list has to be fetched
|
||||||
*/
|
*/
|
||||||
|
@Deprecated(
|
||||||
|
message =
|
||||||
|
"The helper functions are inherently limiting and hides the underlying implementation. " +
|
||||||
|
"Source developers should make their own implementation according to their needs.",
|
||||||
|
)
|
||||||
protected open fun imageUrlRequest(page: Page): Request = GET(page.url, headers)
|
protected open fun imageUrlRequest(page: Page): Request = GET(page.url, headers)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -359,16 +411,14 @@ abstract class HttpSource : CatalogueSource {
|
|||||||
*
|
*
|
||||||
* @param response the response from the site.
|
* @param response the response from the site.
|
||||||
*/
|
*/
|
||||||
protected abstract fun imageUrlParse(response: Response): String
|
@Deprecated(
|
||||||
|
message =
|
||||||
|
"The helper functions are inherently limiting and hides the underlying implementation. " +
|
||||||
|
"Source developers should make their own implementation according to their needs.",
|
||||||
|
)
|
||||||
|
protected open fun imageUrlParse(response: Response): String = throw UnsupportedOperationException()
|
||||||
|
|
||||||
/**
|
suspend fun getImage(page: Page): Response =
|
||||||
* Returns the response of the source image.
|
|
||||||
* Typically does not need to be overridden.
|
|
||||||
*
|
|
||||||
* @since extensions-lib 1.5
|
|
||||||
* @param page the page whose source image has to be downloaded.
|
|
||||||
*/
|
|
||||||
open suspend fun getImage(page: Page): Response =
|
|
||||||
client
|
client
|
||||||
.newCachelessCallWithProgress(imageRequest(page), page)
|
.newCachelessCallWithProgress(imageRequest(page), page)
|
||||||
.awaitSuccess()
|
.awaitSuccess()
|
||||||
@@ -387,6 +437,7 @@ abstract class HttpSource : CatalogueSource {
|
|||||||
*
|
*
|
||||||
* @param url the full url to the chapter.
|
* @param url the full url to the chapter.
|
||||||
*/
|
*/
|
||||||
|
@Suppress("Unused")
|
||||||
fun SChapter.setUrlWithoutDomain(url: String) {
|
fun SChapter.setUrlWithoutDomain(url: String) {
|
||||||
this.url = getUrlWithoutDomain(url)
|
this.url = getUrlWithoutDomain(url)
|
||||||
}
|
}
|
||||||
@@ -397,6 +448,7 @@ abstract class HttpSource : CatalogueSource {
|
|||||||
*
|
*
|
||||||
* @param url the full url to the manga.
|
* @param url the full url to the manga.
|
||||||
*/
|
*/
|
||||||
|
@Suppress("Unused")
|
||||||
fun SManga.setUrlWithoutDomain(url: String) {
|
fun SManga.setUrlWithoutDomain(url: String) {
|
||||||
this.url = getUrlWithoutDomain(url)
|
this.url = getUrlWithoutDomain(url)
|
||||||
}
|
}
|
||||||
@@ -417,7 +469,7 @@ abstract class HttpSource : CatalogueSource {
|
|||||||
out += "#" + uri.fragment
|
out += "#" + uri.fragment
|
||||||
}
|
}
|
||||||
out
|
out
|
||||||
} catch (e: URISyntaxException) {
|
} catch (_: URISyntaxException) {
|
||||||
orig
|
orig
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -428,6 +480,7 @@ abstract class HttpSource : CatalogueSource {
|
|||||||
* @param manga the manga
|
* @param manga the manga
|
||||||
* @return url of the manga
|
* @return url of the manga
|
||||||
*/
|
*/
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
open fun getMangaUrl(manga: SManga): String = mangaDetailsRequest(manga).url.toString()
|
open fun getMangaUrl(manga: SManga): String = mangaDetailsRequest(manga).url.toString()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -437,6 +490,7 @@ abstract class HttpSource : CatalogueSource {
|
|||||||
* @param chapter the chapter
|
* @param chapter the chapter
|
||||||
* @return url of the chapter
|
* @return url of the chapter
|
||||||
*/
|
*/
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
open fun getChapterUrl(chapter: SChapter): String = pageListRequest(chapter).url.toString()
|
open fun getChapterUrl(chapter: SChapter): String = pageListRequest(chapter).url.toString()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -446,15 +500,9 @@ abstract class HttpSource : CatalogueSource {
|
|||||||
* @param chapter the chapter to be added.
|
* @param chapter the chapter to be added.
|
||||||
* @param manga the manga of the chapter.
|
* @param manga the manga of the chapter.
|
||||||
*/
|
*/
|
||||||
|
@Deprecated("All modifications should be done when constructing the chapter")
|
||||||
open fun prepareNewChapter(
|
open fun prepareNewChapter(
|
||||||
chapter: SChapter,
|
chapter: SChapter,
|
||||||
manga: SManga,
|
manga: SManga,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the list of filters for the source.
|
|
||||||
*/
|
|
||||||
override fun getFilterList() = FilterList()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class LicensedMangaChaptersException : Exception("Licensed - No chapters to show")
|
|
||||||
|
|||||||
@@ -12,12 +12,20 @@ import org.jsoup.nodes.Element
|
|||||||
/**
|
/**
|
||||||
* A simple implementation for sources from a website using Jsoup, an HTML parser.
|
* A simple implementation for sources from a website using Jsoup, an HTML parser.
|
||||||
*/
|
*/
|
||||||
|
@Deprecated(
|
||||||
|
message =
|
||||||
|
"In most cases sources only require a subset of the methods from this class. " +
|
||||||
|
"Source developers should make their own implementation according to their needs.",
|
||||||
|
)
|
||||||
abstract class ParsedHttpSource : HttpSource() {
|
abstract class ParsedHttpSource : HttpSource() {
|
||||||
/**
|
/**
|
||||||
* Parses the response from the site and returns a [MangasPage] object.
|
* Parses the response from the site and returns a [MangasPage] object.
|
||||||
*
|
*
|
||||||
* @param response the response from the site.
|
* @param response the response from the site.
|
||||||
*/
|
*/
|
||||||
|
@Deprecated(
|
||||||
|
"The helper functions are inherently limiting and hides the underlying implementation. Source developers should make their own implementation according to their needs.",
|
||||||
|
)
|
||||||
override fun popularMangaParse(response: Response): MangasPage {
|
override fun popularMangaParse(response: Response): MangasPage {
|
||||||
val document = response.asJsoup()
|
val document = response.asJsoup()
|
||||||
|
|
||||||
@@ -58,6 +66,9 @@ abstract class ParsedHttpSource : HttpSource() {
|
|||||||
*
|
*
|
||||||
* @param response the response from the site.
|
* @param response the response from the site.
|
||||||
*/
|
*/
|
||||||
|
@Deprecated(
|
||||||
|
"The helper functions are inherently limiting and hides the underlying implementation. Source developers should make their own implementation according to their needs.",
|
||||||
|
)
|
||||||
override fun searchMangaParse(response: Response): MangasPage {
|
override fun searchMangaParse(response: Response): MangasPage {
|
||||||
val document = response.asJsoup()
|
val document = response.asJsoup()
|
||||||
|
|
||||||
@@ -98,6 +109,9 @@ abstract class ParsedHttpSource : HttpSource() {
|
|||||||
*
|
*
|
||||||
* @param response the response from the site.
|
* @param response the response from the site.
|
||||||
*/
|
*/
|
||||||
|
@Deprecated(
|
||||||
|
"The helper functions are inherently limiting and hides the underlying implementation. Source developers should make their own implementation according to their needs.",
|
||||||
|
)
|
||||||
override fun latestUpdatesParse(response: Response): MangasPage {
|
override fun latestUpdatesParse(response: Response): MangasPage {
|
||||||
val document = response.asJsoup()
|
val document = response.asJsoup()
|
||||||
|
|
||||||
@@ -138,6 +152,9 @@ abstract class ParsedHttpSource : HttpSource() {
|
|||||||
*
|
*
|
||||||
* @param response the response from the site.
|
* @param response the response from the site.
|
||||||
*/
|
*/
|
||||||
|
@Deprecated(
|
||||||
|
"The helper functions are inherently limiting and hides the underlying implementation. Source developers should make their own implementation according to their needs.",
|
||||||
|
)
|
||||||
override fun mangaDetailsParse(response: Response): SManga = mangaDetailsParse(response.asJsoup())
|
override fun mangaDetailsParse(response: Response): SManga = mangaDetailsParse(response.asJsoup())
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -152,6 +169,9 @@ abstract class ParsedHttpSource : HttpSource() {
|
|||||||
*
|
*
|
||||||
* @param response the response from the site.
|
* @param response the response from the site.
|
||||||
*/
|
*/
|
||||||
|
@Deprecated(
|
||||||
|
"The helper functions are inherently limiting and hides the underlying implementation. Source developers should make their own implementation according to their needs.",
|
||||||
|
)
|
||||||
override fun chapterListParse(response: Response): List<SChapter> {
|
override fun chapterListParse(response: Response): List<SChapter> {
|
||||||
val document = response.asJsoup()
|
val document = response.asJsoup()
|
||||||
return document.select(chapterListSelector()).map { chapterFromElement(it) }
|
return document.select(chapterListSelector()).map { chapterFromElement(it) }
|
||||||
@@ -174,6 +194,9 @@ abstract class ParsedHttpSource : HttpSource() {
|
|||||||
*
|
*
|
||||||
* @param response the response from the site.
|
* @param response the response from the site.
|
||||||
*/
|
*/
|
||||||
|
@Deprecated(
|
||||||
|
"The helper functions are inherently limiting and hides the underlying implementation. Source developers should make their own implementation according to their needs.",
|
||||||
|
)
|
||||||
override fun pageListParse(response: Response): List<Page> = pageListParse(response.asJsoup())
|
override fun pageListParse(response: Response): List<Page> = pageListParse(response.asJsoup())
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -188,6 +211,9 @@ abstract class ParsedHttpSource : HttpSource() {
|
|||||||
*
|
*
|
||||||
* @param response the response from the site.
|
* @param response the response from the site.
|
||||||
*/
|
*/
|
||||||
|
@Deprecated(
|
||||||
|
"The helper functions are inherently limiting and hides the underlying implementation. Source developers should make their own implementation according to their needs.",
|
||||||
|
)
|
||||||
override fun imageUrlParse(response: Response): String = imageUrlParse(response.asJsoup())
|
override fun imageUrlParse(response: Response): String = imageUrlParse(response.asJsoup())
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,26 +1,44 @@
|
|||||||
package eu.kanade.tachiyomi.source.online
|
package eu.kanade.tachiyomi.source.online
|
||||||
|
|
||||||
import eu.kanade.tachiyomi.source.Source
|
import eu.kanade.tachiyomi.source.Source
|
||||||
|
import eu.kanade.tachiyomi.source.model.SChapter
|
||||||
import eu.kanade.tachiyomi.source.model.SManga
|
import eu.kanade.tachiyomi.source.model.SManga
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A source that may handle opening an SManga for a given URI.
|
* A source that may handle opening an SManga or SChapter for a given URI.
|
||||||
*
|
*
|
||||||
* @since extensions-lib 1.5
|
* @since extensions-lib 1.5
|
||||||
*/
|
*/
|
||||||
@Suppress("unused")
|
|
||||||
interface ResolvableSource : Source {
|
interface ResolvableSource : Source {
|
||||||
/**
|
/**
|
||||||
* Whether this source may potentially handle the given URI.
|
* Returns what the given URI may open.
|
||||||
|
* Returns [UriType.Unknown] if the source is not able to resolve the URI.
|
||||||
*
|
*
|
||||||
* @since extensions-lib 1.5
|
* @since extensions-lib 1.5
|
||||||
*/
|
*/
|
||||||
fun canResolveUri(uri: String): Boolean
|
fun getUriType(uri: String): UriType
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called if canHandleUri is true. Returns the corresponding SManga, if possible.
|
* Called if [getUriType] is [UriType.Manga].
|
||||||
|
* Returns the corresponding SManga, if possible.
|
||||||
*
|
*
|
||||||
* @since extensions-lib 1.5
|
* @since extensions-lib 1.5
|
||||||
*/
|
*/
|
||||||
suspend fun getManga(uri: String): SManga?
|
suspend fun getManga(uri: String): SManga?
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called if [getUriType] is [UriType.Chapter].
|
||||||
|
* Returns the corresponding SChapter, if possible.
|
||||||
|
*
|
||||||
|
* @since extensions-lib 1.5
|
||||||
|
*/
|
||||||
|
suspend fun getChapter(uri: String): SChapter?
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface UriType {
|
||||||
|
data object Manga : UriType
|
||||||
|
|
||||||
|
data object Chapter : UriType
|
||||||
|
|
||||||
|
data object Unknown : UriType
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,15 @@ import android.app.Application
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import eu.kanade.tachiyomi.network.GET
|
import eu.kanade.tachiyomi.network.GET
|
||||||
import eu.kanade.tachiyomi.network.NetworkHelper
|
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||||
|
import eu.kanade.tachiyomi.network.POST
|
||||||
import eu.kanade.tachiyomi.network.PUT
|
import eu.kanade.tachiyomi.network.PUT
|
||||||
import eu.kanade.tachiyomi.network.await
|
import eu.kanade.tachiyomi.network.await
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
import io.javalin.http.HttpStatus
|
import io.javalin.http.HttpStatus
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.SerializationException
|
import kotlinx.serialization.SerializationException
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.protobuf.ProtoBuf
|
import kotlinx.serialization.protobuf.ProtoBuf
|
||||||
import okhttp3.Headers
|
import okhttp3.Headers
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
@@ -36,31 +40,66 @@ object SyncYomiSyncService {
|
|||||||
message: String?,
|
message: String?,
|
||||||
) : Exception(message)
|
) : Exception(message)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class SyncEvent(
|
||||||
|
val event: SyncEventStatus,
|
||||||
|
val device_Name: String? = null,
|
||||||
|
val message: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private enum class SyncEventStatus {
|
||||||
|
SYNC_STARTED,
|
||||||
|
SYNC_SUCCESS,
|
||||||
|
SYNC_FAILED,
|
||||||
|
SYNC_ERROR,
|
||||||
|
SYNC_CANCELLED,
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun doSync(
|
suspend fun doSync(
|
||||||
syncData: SyncData,
|
syncData: SyncData,
|
||||||
startDate: Instant,
|
startDate: Instant,
|
||||||
setSyncState: (SyncManager.SyncState) -> Unit,
|
setSyncState: (SyncManager.SyncState) -> Unit,
|
||||||
): Backup? {
|
): Backup? {
|
||||||
|
reportSyncEvent(SyncEventStatus.SYNC_STARTED)
|
||||||
setSyncState(SyncManager.SyncState.Downloading(startDate))
|
setSyncState(SyncManager.SyncState.Downloading(startDate))
|
||||||
val (remoteData, etag) = pullSyncData()
|
|
||||||
|
|
||||||
val finalSyncData =
|
return try {
|
||||||
if (remoteData != null) {
|
val (remoteData, etag) = pullSyncData()
|
||||||
require(etag.isNotEmpty()) { "ETag should never be empty if remote data is not null" }
|
|
||||||
logger.debug { "Try update remote data with ETag($etag)" }
|
val finalSyncData =
|
||||||
setSyncState(SyncManager.SyncState.Merging(startDate))
|
if (remoteData != null) {
|
||||||
mergeSyncData(syncData, remoteData)
|
require(etag.isNotEmpty()) { "ETag should never be empty if remote data is not null" }
|
||||||
} else {
|
logger.debug { "Try update remote data with ETag($etag)" }
|
||||||
// init or overwrite remote data
|
setSyncState(SyncManager.SyncState.Merging(startDate))
|
||||||
logger.debug { "Try overwrite remote data with ETag($etag)" }
|
mergeSyncData(syncData, remoteData)
|
||||||
syncData
|
} else {
|
||||||
|
// init or overwrite remote data
|
||||||
|
logger.debug { "Try overwrite remote data with ETag($etag)" }
|
||||||
|
syncData
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finalSyncData.backup != null) {
|
||||||
|
setSyncState(SyncManager.SyncState.Uploading(startDate))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (finalSyncData.backup != null) {
|
val success = pushSyncData(finalSyncData, etag)
|
||||||
setSyncState(SyncManager.SyncState.Uploading(startDate))
|
if (success) {
|
||||||
|
reportSyncEvent(SyncEventStatus.SYNC_SUCCESS)
|
||||||
|
} else {
|
||||||
|
reportSyncEvent(SyncEventStatus.SYNC_FAILED, "Failed to push sync data")
|
||||||
|
}
|
||||||
|
|
||||||
|
finalSyncData.backup
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (e is CancellationException) {
|
||||||
|
reportSyncEvent(SyncEventStatus.SYNC_CANCELLED, e.message)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
logger.error { "Error syncing: ${e.message}" }
|
||||||
|
reportSyncEvent(SyncEventStatus.SYNC_ERROR, e.message)
|
||||||
|
throw e
|
||||||
}
|
}
|
||||||
pushSyncData(finalSyncData, etag)
|
|
||||||
return finalSyncData.backup
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun pullSyncData(): Pair<SyncData?, String> {
|
private suspend fun pullSyncData(): Pair<SyncData?, String> {
|
||||||
@@ -122,8 +161,8 @@ object SyncYomiSyncService {
|
|||||||
private suspend fun pushSyncData(
|
private suspend fun pushSyncData(
|
||||||
syncData: SyncData,
|
syncData: SyncData,
|
||||||
eTag: String,
|
eTag: String,
|
||||||
) {
|
): Boolean {
|
||||||
val backup = syncData.backup ?: return
|
val backup = syncData.backup ?: return true
|
||||||
|
|
||||||
val host = serverConfig.syncYomiHost.value
|
val host = serverConfig.syncYomiHost.value
|
||||||
val apiKey = serverConfig.syncYomiApiKey.value
|
val apiKey = serverConfig.syncYomiApiKey.value
|
||||||
@@ -160,7 +199,7 @@ object SyncYomiSyncService {
|
|||||||
|
|
||||||
val response = client.newCall(uploadRequest).await()
|
val response = client.newCall(uploadRequest).await()
|
||||||
|
|
||||||
if (response.isSuccessful) {
|
return if (response.isSuccessful) {
|
||||||
val newETag =
|
val newETag =
|
||||||
response.headers["ETag"]
|
response.headers["ETag"]
|
||||||
?.takeIf { it.isNotEmpty() } ?: throw SyncYomiException("Missing ETag")
|
?.takeIf { it.isNotEmpty() } ?: throw SyncYomiException("Missing ETag")
|
||||||
@@ -169,12 +208,53 @@ object SyncYomiSyncService {
|
|||||||
.putString("last_sync_etag", newETag)
|
.putString("last_sync_etag", newETag)
|
||||||
.apply()
|
.apply()
|
||||||
logger.debug { "SyncYomi sync completed" }
|
logger.debug { "SyncYomi sync completed" }
|
||||||
|
true
|
||||||
} else if (response.code == HttpStatus.PRECONDITION_FAILED.code) {
|
} else if (response.code == HttpStatus.PRECONDITION_FAILED.code) {
|
||||||
// other clients updated remote data, will try next time
|
// other clients updated remote data, will try next time
|
||||||
logger.debug { "SyncYomi sync failed with 412" }
|
logger.debug { "SyncYomi sync failed with 412" }
|
||||||
|
false
|
||||||
} else {
|
} else {
|
||||||
val responseBody = response.body.string()
|
val responseBody = response.body.string()
|
||||||
logger.error { "SyncError: $responseBody" }
|
logger.error { "SyncError: $responseBody" }
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun reportSyncEvent(
|
||||||
|
event: SyncEventStatus,
|
||||||
|
message: String? = null,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
val host = serverConfig.syncYomiHost.value
|
||||||
|
val apiKey = serverConfig.syncYomiApiKey.value
|
||||||
|
val url = "$host/api/sync/event"
|
||||||
|
|
||||||
|
val headers = Headers.Builder().add("X-API-Token", apiKey).build()
|
||||||
|
|
||||||
|
// Use a fixed server name.
|
||||||
|
val bodyObj =
|
||||||
|
SyncEvent(
|
||||||
|
event = event,
|
||||||
|
device_Name = "Suwayomi Server",
|
||||||
|
message = message,
|
||||||
|
)
|
||||||
|
|
||||||
|
val jsonBody = Json.encodeToString(SyncEvent.serializer(), bodyObj)
|
||||||
|
val requestBody = jsonBody.toRequestBody("application/json; charset=utf-8".toMediaType())
|
||||||
|
|
||||||
|
val request =
|
||||||
|
POST(
|
||||||
|
url = url,
|
||||||
|
headers = headers,
|
||||||
|
body = requestBody,
|
||||||
|
)
|
||||||
|
|
||||||
|
network.client
|
||||||
|
.newCall(request)
|
||||||
|
.await()
|
||||||
|
.close()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error { "Failed to report sync event: ${e.message}" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,8 +305,7 @@ object SyncYomiSyncService {
|
|||||||
|
|
||||||
logger.debug { "Starting merge. Local list size: ${localMangaListSafe.size}, Remote list size: ${remoteMangaListSafe.size}" }
|
logger.debug { "Starting merge. Local list size: ${localMangaListSafe.size}, Remote list size: ${remoteMangaListSafe.size}" }
|
||||||
|
|
||||||
fun mangaCompositeKey(manga: BackupManga): String =
|
fun mangaCompositeKey(manga: BackupManga): String = "${manga.source}|${manga.url}"
|
||||||
"${manga.source}|${manga.url}|${manga.title.lowercase().trim()}|${manga.author?.lowercase()?.trim()}"
|
|
||||||
|
|
||||||
// Create maps using composite keys
|
// Create maps using composite keys
|
||||||
val localMangaMap = localMangaListSafe.associateBy { mangaCompositeKey(it) }
|
val localMangaMap = localMangaListSafe.associateBy { mangaCompositeKey(it) }
|
||||||
@@ -335,7 +414,7 @@ object SyncYomiSyncService {
|
|||||||
return remoteChapters // If not syncing chapters, keep remote untouched
|
return remoteChapters // If not syncing chapters, keep remote untouched
|
||||||
}
|
}
|
||||||
|
|
||||||
fun chapterCompositeKey(chapter: BackupChapter): String = "${chapter.url}|${chapter.name}|${chapter.chapterNumber}"
|
fun chapterCompositeKey(chapter: BackupChapter): String = chapter.url
|
||||||
|
|
||||||
val localChapterMap = localChapters.associateBy { chapterCompositeKey(it) }
|
val localChapterMap = localChapters.associateBy { chapterCompositeKey(it) }
|
||||||
val remoteChapterMap = remoteChapters.associateBy { chapterCompositeKey(it) }
|
val remoteChapterMap = remoteChapters.associateBy { chapterCompositeKey(it) }
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package suwayomi.tachidesk.graphql.dataLoaders
|
||||||
|
|
||||||
|
import com.expediagroup.graphql.dataloader.KotlinDataLoader
|
||||||
|
import graphql.GraphQLContext
|
||||||
|
import org.dataloader.DataLoader
|
||||||
|
import org.dataloader.DataLoaderFactory
|
||||||
|
import org.jetbrains.exposed.v1.core.Slf4jSqlDebugLogger
|
||||||
|
import org.jetbrains.exposed.v1.core.inList
|
||||||
|
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||||
|
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||||
|
import suwayomi.tachidesk.graphql.types.ExtensionNodeList
|
||||||
|
import suwayomi.tachidesk.graphql.types.ExtensionNodeList.Companion.toNodeList
|
||||||
|
import suwayomi.tachidesk.graphql.types.ExtensionStoreType
|
||||||
|
import suwayomi.tachidesk.graphql.types.ExtensionType
|
||||||
|
import suwayomi.tachidesk.manga.model.table.ExtensionStoreTable
|
||||||
|
import suwayomi.tachidesk.manga.model.table.ExtensionTable
|
||||||
|
import suwayomi.tachidesk.server.JavalinSetup.future
|
||||||
|
|
||||||
|
class ExtensionStoreDataLoader : KotlinDataLoader<String, ExtensionStoreType> {
|
||||||
|
override val dataLoaderName = "ExtensionStoreDataLoader"
|
||||||
|
|
||||||
|
override fun getDataLoader(graphQLContext: GraphQLContext): DataLoader<String, ExtensionStoreType> =
|
||||||
|
DataLoaderFactory.newDataLoader { ids ->
|
||||||
|
future {
|
||||||
|
transaction {
|
||||||
|
addLogger(Slf4jSqlDebugLogger)
|
||||||
|
val extensionStoreByIndexUrl =
|
||||||
|
ExtensionStoreTable
|
||||||
|
.selectAll()
|
||||||
|
.where { ExtensionStoreTable.indexUrl inList ids }
|
||||||
|
.map { ExtensionStoreType(it) }
|
||||||
|
.associateBy { it.indexUrl }
|
||||||
|
ids.map { extensionStoreByIndexUrl[it] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ExtensionsForExtensionStore : KotlinDataLoader<String, ExtensionNodeList> {
|
||||||
|
override val dataLoaderName = "ExtensionsForExtensionStore"
|
||||||
|
|
||||||
|
override fun getDataLoader(graphQLContext: GraphQLContext): DataLoader<String, ExtensionNodeList> =
|
||||||
|
DataLoaderFactory.newDataLoader<String, ExtensionNodeList> { ids ->
|
||||||
|
future {
|
||||||
|
transaction {
|
||||||
|
addLogger(Slf4jSqlDebugLogger)
|
||||||
|
val extensionByIndexUrl =
|
||||||
|
ExtensionTable
|
||||||
|
.selectAll()
|
||||||
|
.where { ExtensionTable.storeIndexUrl inList ids }
|
||||||
|
.map { ExtensionType(it) }
|
||||||
|
.groupBy { it.storeIndexUrl }
|
||||||
|
ids.map { (extensionByIndexUrl[it] ?: emptyList()).toNodeList() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
package suwayomi.tachidesk.graphql.mutations
|
package suwayomi.tachidesk.graphql.mutations
|
||||||
|
|
||||||
|
import com.expediagroup.graphql.generator.annotations.GraphQLDeprecated
|
||||||
import kotlinx.coroutines.GlobalScope
|
import kotlinx.coroutines.GlobalScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jetbrains.exposed.v1.core.LikePattern
|
import org.jetbrains.exposed.v1.core.LikePattern
|
||||||
@@ -25,6 +26,7 @@ import suwayomi.tachidesk.graphql.types.ChapterType
|
|||||||
import suwayomi.tachidesk.graphql.types.MetaInput
|
import suwayomi.tachidesk.graphql.types.MetaInput
|
||||||
import suwayomi.tachidesk.graphql.types.SyncConflictInfoType
|
import suwayomi.tachidesk.graphql.types.SyncConflictInfoType
|
||||||
import suwayomi.tachidesk.manga.impl.Chapter
|
import suwayomi.tachidesk.manga.impl.Chapter
|
||||||
|
import suwayomi.tachidesk.manga.impl.Manga
|
||||||
import suwayomi.tachidesk.manga.impl.chapter.getChapterDownloadReadyById
|
import suwayomi.tachidesk.manga.impl.chapter.getChapterDownloadReadyById
|
||||||
import suwayomi.tachidesk.manga.impl.sync.KoreaderSyncService
|
import suwayomi.tachidesk.manga.impl.sync.KoreaderSyncService
|
||||||
import suwayomi.tachidesk.manga.model.table.ChapterMetaTable
|
import suwayomi.tachidesk.manga.model.table.ChapterMetaTable
|
||||||
@@ -167,11 +169,12 @@ class ChapterMutation {
|
|||||||
)
|
)
|
||||||
|
|
||||||
@RequireAuth
|
@RequireAuth
|
||||||
|
@GraphQLDeprecated("Deprecated in Tachiyomix 1.6", ReplaceWith("fetchMangaAndChapters"))
|
||||||
fun fetchChapters(input: FetchChaptersInput): CompletableFuture<FetchChaptersPayload?> {
|
fun fetchChapters(input: FetchChaptersInput): CompletableFuture<FetchChaptersPayload?> {
|
||||||
val (clientMutationId, mangaId) = input
|
val (clientMutationId, mangaId) = input
|
||||||
|
|
||||||
return future {
|
return future {
|
||||||
Chapter.fetchChapterList(mangaId)
|
Manga.updateMangaAndChapters(mangaId, updateManga = false)
|
||||||
|
|
||||||
val chapters =
|
val chapters =
|
||||||
transaction {
|
transaction {
|
||||||
|
|||||||
@@ -10,9 +10,11 @@ import org.jetbrains.exposed.v1.core.neq
|
|||||||
import org.jetbrains.exposed.v1.jdbc.selectAll
|
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||||
import suwayomi.tachidesk.graphql.directives.RequireAuth
|
import suwayomi.tachidesk.graphql.directives.RequireAuth
|
||||||
|
import suwayomi.tachidesk.graphql.types.ExtensionStoreType
|
||||||
import suwayomi.tachidesk.graphql.types.ExtensionType
|
import suwayomi.tachidesk.graphql.types.ExtensionType
|
||||||
import suwayomi.tachidesk.manga.impl.extension.Extension
|
import suwayomi.tachidesk.manga.impl.extension.Extension
|
||||||
import suwayomi.tachidesk.manga.impl.extension.ExtensionsList
|
import suwayomi.tachidesk.manga.impl.extension.ExtensionsList
|
||||||
|
import suwayomi.tachidesk.manga.model.table.ExtensionStoreTable
|
||||||
import suwayomi.tachidesk.manga.model.table.ExtensionTable
|
import suwayomi.tachidesk.manga.model.table.ExtensionTable
|
||||||
import suwayomi.tachidesk.server.JavalinSetup.future
|
import suwayomi.tachidesk.server.JavalinSetup.future
|
||||||
import java.util.concurrent.CompletableFuture
|
import java.util.concurrent.CompletableFuture
|
||||||
@@ -129,6 +131,7 @@ class ExtensionMutation {
|
|||||||
data class FetchExtensionsPayload(
|
data class FetchExtensionsPayload(
|
||||||
val clientMutationId: String?,
|
val clientMutationId: String?,
|
||||||
val extensions: List<ExtensionType>,
|
val extensions: List<ExtensionType>,
|
||||||
|
val extensionStores: List<ExtensionStoreType>,
|
||||||
)
|
)
|
||||||
|
|
||||||
@RequireAuth
|
@RequireAuth
|
||||||
@@ -146,9 +149,17 @@ class ExtensionMutation {
|
|||||||
.map { ExtensionType(it) }
|
.map { ExtensionType(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val extensionStores =
|
||||||
|
transaction {
|
||||||
|
ExtensionStoreTable
|
||||||
|
.selectAll()
|
||||||
|
.map { ExtensionStoreType(it) }
|
||||||
|
}
|
||||||
|
|
||||||
FetchExtensionsPayload(
|
FetchExtensionsPayload(
|
||||||
clientMutationId = clientMutationId,
|
clientMutationId = clientMutationId,
|
||||||
extensions = extensions,
|
extensions = extensions,
|
||||||
|
extensionStores = extensionStores,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package suwayomi.tachidesk.graphql.mutations
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Copyright (C) Contributors to the Suwayomi project
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
||||||
|
|
||||||
|
import org.jetbrains.exposed.v1.core.eq
|
||||||
|
import org.jetbrains.exposed.v1.jdbc.deleteWhere
|
||||||
|
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||||
|
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||||
|
import suwayomi.tachidesk.graphql.directives.RequireAuth
|
||||||
|
import suwayomi.tachidesk.graphql.types.ExtensionStoreType
|
||||||
|
import suwayomi.tachidesk.manga.impl.extension.ExtensionStoreService
|
||||||
|
import suwayomi.tachidesk.manga.model.table.ExtensionStoreTable
|
||||||
|
import suwayomi.tachidesk.server.JavalinSetup.future
|
||||||
|
import java.util.concurrent.CompletableFuture
|
||||||
|
|
||||||
|
class ExtensionStoreMutation {
|
||||||
|
data class AddExtensionStoreInput(
|
||||||
|
val clientMutationId: String? = null,
|
||||||
|
val indexUrl: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class AddExtensionStorePayload(
|
||||||
|
val clientMutationId: String?,
|
||||||
|
val extensionStore: ExtensionStoreType,
|
||||||
|
)
|
||||||
|
|
||||||
|
@RequireAuth
|
||||||
|
fun addExtensionStore(input: AddExtensionStoreInput): CompletableFuture<AddExtensionStorePayload?> {
|
||||||
|
val (clientMutationId, indexUrl) = input
|
||||||
|
return future {
|
||||||
|
val store = ExtensionStoreService.fetch(indexUrl)
|
||||||
|
|
||||||
|
ExtensionStoreService.upsert(store)
|
||||||
|
ExtensionStoreService.syncDbToPrefs()
|
||||||
|
val row =
|
||||||
|
transaction {
|
||||||
|
ExtensionStoreTable
|
||||||
|
.selectAll()
|
||||||
|
.where { ExtensionStoreTable.indexUrl eq store.indexUrl }
|
||||||
|
.first()
|
||||||
|
}
|
||||||
|
|
||||||
|
AddExtensionStorePayload(
|
||||||
|
clientMutationId = clientMutationId,
|
||||||
|
extensionStore = ExtensionStoreType(row),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class RemoveExtensionStoreInput(
|
||||||
|
val clientMutationId: String? = null,
|
||||||
|
val indexUrl: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class RemoveExtensionStorePayload(
|
||||||
|
val clientMutationId: String?,
|
||||||
|
val extensionStore: ExtensionStoreType?,
|
||||||
|
)
|
||||||
|
|
||||||
|
@RequireAuth
|
||||||
|
fun removeExtensionStore(input: RemoveExtensionStoreInput): CompletableFuture<RemoveExtensionStorePayload?> {
|
||||||
|
val (clientMutationId, indexUrl) = input
|
||||||
|
return future {
|
||||||
|
val store =
|
||||||
|
transaction {
|
||||||
|
ExtensionStoreTable
|
||||||
|
.selectAll()
|
||||||
|
.where { ExtensionStoreTable.indexUrl eq indexUrl }
|
||||||
|
.firstOrNull()
|
||||||
|
?.let { ExtensionStoreType(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
store?.let {
|
||||||
|
transaction {
|
||||||
|
ExtensionStoreTable.deleteWhere { ExtensionStoreTable.indexUrl eq indexUrl }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ExtensionStoreService.syncDbToPrefs()
|
||||||
|
|
||||||
|
RemoveExtensionStorePayload(
|
||||||
|
clientMutationId = clientMutationId,
|
||||||
|
extensionStore =
|
||||||
|
store?.let {
|
||||||
|
ExtensionStoreType(
|
||||||
|
name = it.name,
|
||||||
|
badgeLabel = it.badgeLabel,
|
||||||
|
signingKey = it.signingKey,
|
||||||
|
contactWebsite = it.contactWebsite,
|
||||||
|
contactDiscord = it.contactDiscord,
|
||||||
|
indexUrl = it.indexUrl,
|
||||||
|
isLegacy = it.isLegacy,
|
||||||
|
extensionListUrl = it.extensionListUrl,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
package suwayomi.tachidesk.graphql.mutations
|
package suwayomi.tachidesk.graphql.mutations
|
||||||
|
|
||||||
|
import com.expediagroup.graphql.generator.annotations.GraphQLDeprecated
|
||||||
import org.jetbrains.exposed.v1.core.LikePattern
|
import org.jetbrains.exposed.v1.core.LikePattern
|
||||||
import org.jetbrains.exposed.v1.core.Op
|
import org.jetbrains.exposed.v1.core.Op
|
||||||
import org.jetbrains.exposed.v1.core.and
|
import org.jetbrains.exposed.v1.core.and
|
||||||
@@ -14,12 +15,14 @@ import org.jetbrains.exposed.v1.jdbc.selectAll
|
|||||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||||
import org.jetbrains.exposed.v1.jdbc.update
|
import org.jetbrains.exposed.v1.jdbc.update
|
||||||
import suwayomi.tachidesk.graphql.directives.RequireAuth
|
import suwayomi.tachidesk.graphql.directives.RequireAuth
|
||||||
|
import suwayomi.tachidesk.graphql.types.ChapterType
|
||||||
import suwayomi.tachidesk.graphql.types.MangaMetaType
|
import suwayomi.tachidesk.graphql.types.MangaMetaType
|
||||||
import suwayomi.tachidesk.graphql.types.MangaType
|
import suwayomi.tachidesk.graphql.types.MangaType
|
||||||
import suwayomi.tachidesk.graphql.types.MetaInput
|
import suwayomi.tachidesk.graphql.types.MetaInput
|
||||||
import suwayomi.tachidesk.manga.impl.Library
|
import suwayomi.tachidesk.manga.impl.Library
|
||||||
import suwayomi.tachidesk.manga.impl.Manga
|
import suwayomi.tachidesk.manga.impl.Manga
|
||||||
import suwayomi.tachidesk.manga.impl.update.IUpdater
|
import suwayomi.tachidesk.manga.impl.update.IUpdater
|
||||||
|
import suwayomi.tachidesk.manga.model.table.ChapterTable
|
||||||
import suwayomi.tachidesk.manga.model.table.MangaMetaTable
|
import suwayomi.tachidesk.manga.model.table.MangaMetaTable
|
||||||
import suwayomi.tachidesk.manga.model.table.MangaTable
|
import suwayomi.tachidesk.manga.model.table.MangaTable
|
||||||
import suwayomi.tachidesk.manga.model.table.toDataClass
|
import suwayomi.tachidesk.manga.model.table.toDataClass
|
||||||
@@ -146,11 +149,12 @@ class MangaMutation {
|
|||||||
)
|
)
|
||||||
|
|
||||||
@RequireAuth
|
@RequireAuth
|
||||||
|
@GraphQLDeprecated("Deprecated in Tachiyomix 1.6", ReplaceWith("fetchMangaAndChapters"))
|
||||||
fun fetchManga(input: FetchMangaInput): CompletableFuture<FetchMangaPayload?> {
|
fun fetchManga(input: FetchMangaInput): CompletableFuture<FetchMangaPayload?> {
|
||||||
val (clientMutationId, id) = input
|
val (clientMutationId, id) = input
|
||||||
|
|
||||||
return future {
|
return future {
|
||||||
Manga.fetchManga(id)
|
Manga.updateMangaAndChapters(id, updateChapters = false)
|
||||||
|
|
||||||
val manga =
|
val manga =
|
||||||
transaction {
|
transaction {
|
||||||
@@ -163,6 +167,49 @@ class MangaMutation {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class FetchMangaAndChaptersInput(
|
||||||
|
val clientMutationId: String? = null,
|
||||||
|
val id: Int,
|
||||||
|
val fetchManga: Boolean,
|
||||||
|
val fetchChapters: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class FetchMangaAndChaptersPayload(
|
||||||
|
val clientMutationId: String?,
|
||||||
|
val manga: MangaType,
|
||||||
|
val chapters: List<ChapterType>,
|
||||||
|
)
|
||||||
|
|
||||||
|
@RequireAuth
|
||||||
|
fun fetchMangaAndChapters(input: FetchMangaAndChaptersInput): CompletableFuture<FetchMangaAndChaptersPayload?> {
|
||||||
|
val (clientMutationId, id, fetchManga, fetchChapters) = input
|
||||||
|
|
||||||
|
return future {
|
||||||
|
Manga.updateMangaAndChapters(
|
||||||
|
mangaId = id,
|
||||||
|
updateManga = fetchManga,
|
||||||
|
updateChapters = fetchChapters,
|
||||||
|
)
|
||||||
|
|
||||||
|
val (manga, chapters) =
|
||||||
|
transaction {
|
||||||
|
Pair(
|
||||||
|
MangaTable.selectAll().where { MangaTable.id eq id }.first(),
|
||||||
|
ChapterTable
|
||||||
|
.selectAll()
|
||||||
|
.where { ChapterTable.manga eq id }
|
||||||
|
.orderBy(ChapterTable.sourceOrder)
|
||||||
|
.map { ChapterType(it) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
FetchMangaAndChaptersPayload(
|
||||||
|
clientMutationId = clientMutationId,
|
||||||
|
manga = MangaType(manga),
|
||||||
|
chapters = chapters,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
data class SetMangaMetaInput(
|
data class SetMangaMetaInput(
|
||||||
val clientMutationId: String? = null,
|
val clientMutationId: String? = null,
|
||||||
val meta: MangaMetaType,
|
val meta: MangaMetaType,
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import suwayomi.tachidesk.graphql.types.preferenceOf
|
|||||||
import suwayomi.tachidesk.graphql.types.updateFilterList
|
import suwayomi.tachidesk.graphql.types.updateFilterList
|
||||||
import suwayomi.tachidesk.manga.impl.MangaList.insertOrUpdate
|
import suwayomi.tachidesk.manga.impl.MangaList.insertOrUpdate
|
||||||
import suwayomi.tachidesk.manga.impl.Source
|
import suwayomi.tachidesk.manga.impl.Source
|
||||||
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource
|
import suwayomi.tachidesk.manga.impl.util.source.GetSource
|
||||||
import suwayomi.tachidesk.manga.model.table.MangaTable
|
import suwayomi.tachidesk.manga.model.table.MangaTable
|
||||||
import suwayomi.tachidesk.manga.model.table.SourceMetaTable
|
import suwayomi.tachidesk.manga.model.table.SourceMetaTable
|
||||||
import suwayomi.tachidesk.manga.model.table.SourceTable
|
import suwayomi.tachidesk.manga.model.table.SourceTable
|
||||||
@@ -256,7 +256,7 @@ class SourceMutation {
|
|||||||
val (clientMutationId, sourceId, type, page, query, filters) = input
|
val (clientMutationId, sourceId, type, page, query, filters) = input
|
||||||
|
|
||||||
return future {
|
return future {
|
||||||
val source = GetCatalogueSource.getCatalogueSourceOrNull(sourceId)!!
|
val source = GetSource.getSourceOrNull(sourceId)!!
|
||||||
val mangasPage =
|
val mangasPage =
|
||||||
when (type) {
|
when (type) {
|
||||||
FetchSourceMangaType.SEARCH -> {
|
FetchSourceMangaType.SEARCH -> {
|
||||||
|
|||||||
@@ -148,6 +148,36 @@ class TrackMutation {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class BindTrackRecordInput(
|
||||||
|
val clientMutationId: String? = null,
|
||||||
|
val mangaId: Int,
|
||||||
|
val trackRecordId: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class BindTrackRecordPayload(
|
||||||
|
val clientMutationId: String?,
|
||||||
|
val trackRecord: TrackRecordType,
|
||||||
|
)
|
||||||
|
|
||||||
|
@RequireAuth
|
||||||
|
fun bindTrackRecord(input: BindTrackRecordInput): CompletableFuture<BindTrackRecordPayload?> {
|
||||||
|
val (clientMutationId, mangaId, trackRecordId) = input
|
||||||
|
|
||||||
|
return future {
|
||||||
|
val boundTrackRecordId = Track.bindTrackRecord(mangaId, trackRecordId)
|
||||||
|
|
||||||
|
val trackRecord =
|
||||||
|
transaction {
|
||||||
|
TrackRecordTable.selectAll().where { TrackRecordTable.id eq boundTrackRecordId }.first()
|
||||||
|
}
|
||||||
|
|
||||||
|
BindTrackRecordPayload(
|
||||||
|
clientMutationId,
|
||||||
|
TrackRecordType(trackRecord),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
data class FetchTrackInput(
|
data class FetchTrackInput(
|
||||||
val clientMutationId: String? = null,
|
val clientMutationId: String? = null,
|
||||||
val recordId: Int,
|
val recordId: Int,
|
||||||
|
|||||||
@@ -21,12 +21,15 @@ import org.jetbrains.exposed.v1.jdbc.selectAll
|
|||||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||||
import suwayomi.tachidesk.graphql.directives.RequireAuth
|
import suwayomi.tachidesk.graphql.directives.RequireAuth
|
||||||
import suwayomi.tachidesk.graphql.queries.filter.BooleanFilter
|
import suwayomi.tachidesk.graphql.queries.filter.BooleanFilter
|
||||||
|
import suwayomi.tachidesk.graphql.queries.filter.ContentWarningFilter
|
||||||
import suwayomi.tachidesk.graphql.queries.filter.Filter
|
import suwayomi.tachidesk.graphql.queries.filter.Filter
|
||||||
import suwayomi.tachidesk.graphql.queries.filter.HasGetOp
|
import suwayomi.tachidesk.graphql.queries.filter.HasGetOp
|
||||||
import suwayomi.tachidesk.graphql.queries.filter.IntFilter
|
import suwayomi.tachidesk.graphql.queries.filter.IntFilter
|
||||||
|
import suwayomi.tachidesk.graphql.queries.filter.LongFilter
|
||||||
import suwayomi.tachidesk.graphql.queries.filter.OpAnd
|
import suwayomi.tachidesk.graphql.queries.filter.OpAnd
|
||||||
import suwayomi.tachidesk.graphql.queries.filter.StringFilter
|
import suwayomi.tachidesk.graphql.queries.filter.StringFilter
|
||||||
import suwayomi.tachidesk.graphql.queries.filter.andFilterWithCompare
|
import suwayomi.tachidesk.graphql.queries.filter.andFilterWithCompare
|
||||||
|
import suwayomi.tachidesk.graphql.queries.filter.andFilterWithCompareEnum
|
||||||
import suwayomi.tachidesk.graphql.queries.filter.andFilterWithCompareString
|
import suwayomi.tachidesk.graphql.queries.filter.andFilterWithCompareString
|
||||||
import suwayomi.tachidesk.graphql.queries.filter.applyOps
|
import suwayomi.tachidesk.graphql.queries.filter.applyOps
|
||||||
import suwayomi.tachidesk.graphql.server.primitives.Cursor
|
import suwayomi.tachidesk.graphql.server.primitives.Cursor
|
||||||
@@ -40,6 +43,7 @@ import suwayomi.tachidesk.graphql.server.primitives.lessNotUnique
|
|||||||
import suwayomi.tachidesk.graphql.server.primitives.maybeSwap
|
import suwayomi.tachidesk.graphql.server.primitives.maybeSwap
|
||||||
import suwayomi.tachidesk.graphql.types.ExtensionNodeList
|
import suwayomi.tachidesk.graphql.types.ExtensionNodeList
|
||||||
import suwayomi.tachidesk.graphql.types.ExtensionType
|
import suwayomi.tachidesk.graphql.types.ExtensionType
|
||||||
|
import suwayomi.tachidesk.manga.model.dataclass.ContentWarning
|
||||||
import suwayomi.tachidesk.manga.model.table.ExtensionTable
|
import suwayomi.tachidesk.manga.model.table.ExtensionTable
|
||||||
import java.util.concurrent.CompletableFuture
|
import java.util.concurrent.CompletableFuture
|
||||||
|
|
||||||
@@ -55,21 +59,23 @@ class ExtensionQuery {
|
|||||||
) : OrderBy<ExtensionType> {
|
) : OrderBy<ExtensionType> {
|
||||||
PKG_NAME(ExtensionTable.pkgName),
|
PKG_NAME(ExtensionTable.pkgName),
|
||||||
NAME(ExtensionTable.name),
|
NAME(ExtensionTable.name),
|
||||||
APK_NAME(ExtensionTable.apkName),
|
|
||||||
|
@GraphQLDeprecated("")
|
||||||
|
APK_NAME(ExtensionTable.pkgName),
|
||||||
;
|
;
|
||||||
|
|
||||||
override fun greater(cursor: Cursor): Op<Boolean> =
|
override fun greater(cursor: Cursor): Op<Boolean> =
|
||||||
when (this) {
|
when (this) {
|
||||||
PKG_NAME -> ExtensionTable.pkgName greater cursor.value
|
PKG_NAME -> ExtensionTable.pkgName greater cursor.value
|
||||||
NAME -> greaterNotUnique(ExtensionTable.name, ExtensionTable.pkgName, cursor, String::toString)
|
NAME -> greaterNotUnique(ExtensionTable.name, ExtensionTable.pkgName, cursor, String::toString)
|
||||||
APK_NAME -> greaterNotUnique(ExtensionTable.apkName, ExtensionTable.pkgName, cursor, String::toString)
|
APK_NAME -> ExtensionTable.pkgName greater cursor.value
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun less(cursor: Cursor): Op<Boolean> =
|
override fun less(cursor: Cursor): Op<Boolean> =
|
||||||
when (this) {
|
when (this) {
|
||||||
PKG_NAME -> ExtensionTable.pkgName less cursor.value
|
PKG_NAME -> ExtensionTable.pkgName less cursor.value
|
||||||
NAME -> lessNotUnique(ExtensionTable.name, ExtensionTable.pkgName, cursor, String::toString)
|
NAME -> lessNotUnique(ExtensionTable.name, ExtensionTable.pkgName, cursor, String::toString)
|
||||||
APK_NAME -> lessNotUnique(ExtensionTable.apkName, ExtensionTable.pkgName, cursor, String::toString)
|
APK_NAME -> ExtensionTable.pkgName less cursor.value
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun asCursor(type: ExtensionType): Cursor {
|
override fun asCursor(type: ExtensionType): Cursor {
|
||||||
@@ -89,29 +95,44 @@ class ExtensionQuery {
|
|||||||
) : Order<ExtensionOrderBy>
|
) : Order<ExtensionOrderBy>
|
||||||
|
|
||||||
data class ExtensionCondition(
|
data class ExtensionCondition(
|
||||||
|
val storeIndexUrl: String? = null,
|
||||||
|
@GraphQLDeprecated("", ReplaceWith("storeIndexUrl"))
|
||||||
val repo: String? = null,
|
val repo: String? = null,
|
||||||
val apkName: String? = null,
|
val apkName: String? = null,
|
||||||
val iconUrl: String? = null,
|
val iconUrl: String? = null,
|
||||||
val name: String? = null,
|
val name: String? = null,
|
||||||
val pkgName: String? = null,
|
val pkgName: String? = null,
|
||||||
|
val apkUrl: String? = null,
|
||||||
|
val extensionLib: String? = null,
|
||||||
val versionName: String? = null,
|
val versionName: String? = null,
|
||||||
val versionCode: Int? = null,
|
val versionCode: Int? = null,
|
||||||
|
val versionCodeLong: Long? = null,
|
||||||
val lang: String? = null,
|
val lang: String? = null,
|
||||||
|
@GraphQLDeprecated("", ReplaceWith("contentWarning"))
|
||||||
val isNsfw: Boolean? = null,
|
val isNsfw: Boolean? = null,
|
||||||
|
val contentWarning: ContentWarning? = null,
|
||||||
val isInstalled: Boolean? = null,
|
val isInstalled: Boolean? = null,
|
||||||
val hasUpdate: Boolean? = null,
|
val hasUpdate: Boolean? = null,
|
||||||
val isObsolete: Boolean? = null,
|
val isObsolete: Boolean? = null,
|
||||||
) : HasGetOp {
|
) : HasGetOp {
|
||||||
override fun getOp(): Op<Boolean>? {
|
override fun getOp(): Op<Boolean>? {
|
||||||
val opAnd = OpAnd()
|
val opAnd = OpAnd()
|
||||||
opAnd.eq(repo, ExtensionTable.repo)
|
opAnd.eq(storeIndexUrl, ExtensionTable.storeIndexUrl)
|
||||||
|
opAnd.eq(repo, ExtensionTable.storeIndexUrl)
|
||||||
opAnd.eq(apkName, ExtensionTable.apkName)
|
opAnd.eq(apkName, ExtensionTable.apkName)
|
||||||
opAnd.eq(iconUrl, ExtensionTable.iconUrl)
|
opAnd.eq(iconUrl, ExtensionTable.iconUrl)
|
||||||
|
opAnd.eq(apkUrl, ExtensionTable.apkUrl)
|
||||||
opAnd.eq(name, ExtensionTable.name)
|
opAnd.eq(name, ExtensionTable.name)
|
||||||
|
opAnd.eq(extensionLib, ExtensionTable.extensionLib)
|
||||||
opAnd.eq(versionName, ExtensionTable.versionName)
|
opAnd.eq(versionName, ExtensionTable.versionName)
|
||||||
opAnd.eq(versionCode, ExtensionTable.versionCode)
|
opAnd.eq(versionCode?.toLong(), ExtensionTable.versionCode)
|
||||||
|
opAnd.eq(versionCodeLong, ExtensionTable.versionCode)
|
||||||
opAnd.eq(lang, ExtensionTable.lang)
|
opAnd.eq(lang, ExtensionTable.lang)
|
||||||
opAnd.eq(isNsfw, ExtensionTable.isNsfw)
|
opAnd.eq(
|
||||||
|
isNsfw?.let { if (it) ContentWarning.MIXED.ordinal else ContentWarning.SAFE.ordinal },
|
||||||
|
ExtensionTable.contentWarning,
|
||||||
|
)
|
||||||
|
opAnd.eq(contentWarning?.ordinal, ExtensionTable.contentWarning)
|
||||||
opAnd.eq(isInstalled, ExtensionTable.isInstalled)
|
opAnd.eq(isInstalled, ExtensionTable.isInstalled)
|
||||||
opAnd.eq(hasUpdate, ExtensionTable.hasUpdate)
|
opAnd.eq(hasUpdate, ExtensionTable.hasUpdate)
|
||||||
opAnd.eq(isObsolete, ExtensionTable.isObsolete)
|
opAnd.eq(isObsolete, ExtensionTable.isObsolete)
|
||||||
@@ -121,15 +142,23 @@ class ExtensionQuery {
|
|||||||
}
|
}
|
||||||
|
|
||||||
data class ExtensionFilter(
|
data class ExtensionFilter(
|
||||||
|
val storeIndexUrl: StringFilter? = null,
|
||||||
|
@GraphQLDeprecated("", ReplaceWith("storeIndexUrl"))
|
||||||
val repo: StringFilter? = null,
|
val repo: StringFilter? = null,
|
||||||
val apkName: StringFilter? = null,
|
val apkName: StringFilter? = null,
|
||||||
val iconUrl: StringFilter? = null,
|
val iconUrl: StringFilter? = null,
|
||||||
val name: StringFilter? = null,
|
val name: StringFilter? = null,
|
||||||
val pkgName: StringFilter? = null,
|
val pkgName: StringFilter? = null,
|
||||||
|
val apkUrl: StringFilter? = null,
|
||||||
val versionName: StringFilter? = null,
|
val versionName: StringFilter? = null,
|
||||||
|
val extensionLib: StringFilter? = null,
|
||||||
|
@GraphQLDeprecated("", ReplaceWith("versionCodeLong"))
|
||||||
val versionCode: IntFilter? = null,
|
val versionCode: IntFilter? = null,
|
||||||
|
val versionCodeLong: LongFilter? = null,
|
||||||
val lang: StringFilter? = null,
|
val lang: StringFilter? = null,
|
||||||
|
@GraphQLDeprecated("", ReplaceWith("contentWarning"))
|
||||||
val isNsfw: BooleanFilter? = null,
|
val isNsfw: BooleanFilter? = null,
|
||||||
|
val contentWarning: ContentWarningFilter? = null,
|
||||||
val isInstalled: BooleanFilter? = null,
|
val isInstalled: BooleanFilter? = null,
|
||||||
val hasUpdate: BooleanFilter? = null,
|
val hasUpdate: BooleanFilter? = null,
|
||||||
val isObsolete: BooleanFilter? = null,
|
val isObsolete: BooleanFilter? = null,
|
||||||
@@ -139,15 +168,18 @@ class ExtensionQuery {
|
|||||||
) : Filter<ExtensionFilter> {
|
) : Filter<ExtensionFilter> {
|
||||||
override fun getOpList(): List<Op<Boolean>> =
|
override fun getOpList(): List<Op<Boolean>> =
|
||||||
listOfNotNull(
|
listOfNotNull(
|
||||||
andFilterWithCompareString(ExtensionTable.repo, repo),
|
andFilterWithCompareString(ExtensionTable.storeIndexUrl, storeIndexUrl),
|
||||||
|
andFilterWithCompareString(ExtensionTable.storeIndexUrl, repo),
|
||||||
andFilterWithCompareString(ExtensionTable.apkName, apkName),
|
andFilterWithCompareString(ExtensionTable.apkName, apkName),
|
||||||
andFilterWithCompareString(ExtensionTable.iconUrl, iconUrl),
|
andFilterWithCompareString(ExtensionTable.iconUrl, iconUrl),
|
||||||
andFilterWithCompareString(ExtensionTable.name, name),
|
andFilterWithCompareString(ExtensionTable.name, name),
|
||||||
andFilterWithCompareString(ExtensionTable.pkgName, pkgName),
|
andFilterWithCompareString(ExtensionTable.pkgName, pkgName),
|
||||||
|
andFilterWithCompareString(ExtensionTable.apkUrl, apkUrl),
|
||||||
|
andFilterWithCompareString(ExtensionTable.extensionLib, extensionLib),
|
||||||
andFilterWithCompareString(ExtensionTable.versionName, versionName),
|
andFilterWithCompareString(ExtensionTable.versionName, versionName),
|
||||||
andFilterWithCompare(ExtensionTable.versionCode, versionCode),
|
andFilterWithCompare(ExtensionTable.versionCode, versionCodeLong),
|
||||||
andFilterWithCompareString(ExtensionTable.lang, lang),
|
andFilterWithCompareString(ExtensionTable.lang, lang),
|
||||||
andFilterWithCompare(ExtensionTable.isNsfw, isNsfw),
|
andFilterWithCompareEnum(ExtensionTable.contentWarning, contentWarning),
|
||||||
andFilterWithCompare(ExtensionTable.isInstalled, isInstalled),
|
andFilterWithCompare(ExtensionTable.isInstalled, isInstalled),
|
||||||
andFilterWithCompare(ExtensionTable.hasUpdate, hasUpdate),
|
andFilterWithCompare(ExtensionTable.hasUpdate, hasUpdate),
|
||||||
andFilterWithCompare(ExtensionTable.isObsolete, isObsolete),
|
andFilterWithCompare(ExtensionTable.isObsolete, isObsolete),
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
package suwayomi.tachidesk.graphql.queries
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 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.expediagroup.graphql.server.extensions.getValueFromDataLoader
|
||||||
|
import graphql.schema.DataFetchingEnvironment
|
||||||
|
import org.jetbrains.exposed.v1.core.Column
|
||||||
|
import org.jetbrains.exposed.v1.core.Op
|
||||||
|
import org.jetbrains.exposed.v1.core.SortOrder
|
||||||
|
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||||
|
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||||
|
import suwayomi.tachidesk.graphql.directives.RequireAuth
|
||||||
|
import suwayomi.tachidesk.graphql.queries.filter.Filter
|
||||||
|
import suwayomi.tachidesk.graphql.queries.filter.HasGetOp
|
||||||
|
import suwayomi.tachidesk.graphql.queries.filter.OpAnd
|
||||||
|
import suwayomi.tachidesk.graphql.queries.filter.StringFilter
|
||||||
|
import suwayomi.tachidesk.graphql.queries.filter.andFilterWithCompareString
|
||||||
|
import suwayomi.tachidesk.graphql.queries.filter.applyOps
|
||||||
|
import suwayomi.tachidesk.graphql.server.primitives.Cursor
|
||||||
|
import suwayomi.tachidesk.graphql.server.primitives.Order
|
||||||
|
import suwayomi.tachidesk.graphql.server.primitives.OrderBy
|
||||||
|
import suwayomi.tachidesk.graphql.server.primitives.PageInfo
|
||||||
|
import suwayomi.tachidesk.graphql.server.primitives.QueryResults
|
||||||
|
import suwayomi.tachidesk.graphql.server.primitives.applyBeforeAfter
|
||||||
|
import suwayomi.tachidesk.graphql.server.primitives.greaterNotUnique
|
||||||
|
import suwayomi.tachidesk.graphql.server.primitives.lessNotUnique
|
||||||
|
import suwayomi.tachidesk.graphql.server.primitives.maybeSwap
|
||||||
|
import suwayomi.tachidesk.graphql.types.ExtensionStoreNodeList
|
||||||
|
import suwayomi.tachidesk.graphql.types.ExtensionStoreType
|
||||||
|
import suwayomi.tachidesk.manga.model.table.ExtensionStoreTable
|
||||||
|
import java.util.concurrent.CompletableFuture
|
||||||
|
|
||||||
|
class ExtensionStoreQuery {
|
||||||
|
@RequireAuth
|
||||||
|
fun extensionStore(
|
||||||
|
dataFetchingEnvironment: DataFetchingEnvironment,
|
||||||
|
indexUrl: String,
|
||||||
|
): CompletableFuture<ExtensionStoreType> = dataFetchingEnvironment.getValueFromDataLoader("ExtensionStoreDataLoader", indexUrl)
|
||||||
|
|
||||||
|
enum class ExtensionStoreOrderBy(
|
||||||
|
override val column: Column<*>,
|
||||||
|
) : OrderBy<ExtensionStoreType> {
|
||||||
|
NAME(ExtensionStoreTable.name),
|
||||||
|
INDEX_URL(ExtensionStoreTable.indexUrl),
|
||||||
|
;
|
||||||
|
|
||||||
|
override fun greater(cursor: Cursor): Op<Boolean> =
|
||||||
|
when (this) {
|
||||||
|
NAME -> greaterNotUnique(ExtensionStoreTable.name, ExtensionStoreTable.id, cursor, String::toString)
|
||||||
|
INDEX_URL -> greaterNotUnique(ExtensionStoreTable.indexUrl, ExtensionStoreTable.id, cursor, String::toString)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun less(cursor: Cursor): Op<Boolean> =
|
||||||
|
when (this) {
|
||||||
|
NAME -> lessNotUnique(ExtensionStoreTable.name, ExtensionStoreTable.id, cursor, String::toString)
|
||||||
|
INDEX_URL -> lessNotUnique(ExtensionStoreTable.indexUrl, ExtensionStoreTable.id, cursor, String::toString)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun asCursor(type: ExtensionStoreType): Cursor {
|
||||||
|
val value =
|
||||||
|
when (this) {
|
||||||
|
INDEX_URL -> type.indexUrl
|
||||||
|
NAME -> type.indexUrl + "-" + type.name
|
||||||
|
}
|
||||||
|
return Cursor(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class ExtensionStoreOrder(
|
||||||
|
override val by: ExtensionStoreOrderBy,
|
||||||
|
override val byType: SortOrder? = null,
|
||||||
|
) : Order<ExtensionStoreOrderBy>
|
||||||
|
|
||||||
|
data class ExtensionStoreCondition(
|
||||||
|
val id: Int? = null,
|
||||||
|
val indexUrl: String? = null,
|
||||||
|
val name: String? = null,
|
||||||
|
) : HasGetOp {
|
||||||
|
override fun getOp(): Op<Boolean>? {
|
||||||
|
val opAnd = OpAnd()
|
||||||
|
opAnd.eq(id, ExtensionStoreTable.id)
|
||||||
|
opAnd.eq(indexUrl, ExtensionStoreTable.indexUrl)
|
||||||
|
opAnd.eq(name, ExtensionStoreTable.name)
|
||||||
|
|
||||||
|
return opAnd.op
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class ExtensionStoreFilter(
|
||||||
|
val indexUrl: StringFilter? = null,
|
||||||
|
val name: StringFilter? = null,
|
||||||
|
override val and: List<ExtensionStoreFilter>? = null,
|
||||||
|
override val or: List<ExtensionStoreFilter>? = null,
|
||||||
|
override val not: ExtensionStoreFilter? = null,
|
||||||
|
) : Filter<ExtensionStoreFilter> {
|
||||||
|
override fun getOpList(): List<Op<Boolean>> =
|
||||||
|
listOfNotNull(
|
||||||
|
andFilterWithCompareString(ExtensionStoreTable.indexUrl, indexUrl),
|
||||||
|
andFilterWithCompareString(ExtensionStoreTable.name, name),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequireAuth
|
||||||
|
fun extensionStores(
|
||||||
|
condition: ExtensionStoreCondition? = null,
|
||||||
|
filter: ExtensionStoreFilter? = null,
|
||||||
|
order: List<ExtensionStoreOrder>? = null,
|
||||||
|
before: Cursor? = null,
|
||||||
|
after: Cursor? = null,
|
||||||
|
first: Int? = null,
|
||||||
|
last: Int? = null,
|
||||||
|
offset: Int? = null,
|
||||||
|
): ExtensionStoreNodeList {
|
||||||
|
val queryResults =
|
||||||
|
transaction {
|
||||||
|
val res = ExtensionStoreTable.selectAll()
|
||||||
|
|
||||||
|
res.applyOps(condition, filter)
|
||||||
|
|
||||||
|
if (order != null || (last != null || before != null)) {
|
||||||
|
val baseSort = listOf(ExtensionStoreOrder(ExtensionStoreOrderBy.INDEX_URL, SortOrder.ASC))
|
||||||
|
val actualSort = (order.orEmpty() + baseSort)
|
||||||
|
actualSort.forEach { (orderBy, orderByType) ->
|
||||||
|
val orderByColumn = orderBy.column
|
||||||
|
val orderType = orderByType.maybeSwap(last ?: before)
|
||||||
|
|
||||||
|
res.orderBy(orderByColumn to orderType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val total = res.count()
|
||||||
|
val firstResult = res.firstOrNull()?.get(ExtensionStoreTable.indexUrl)
|
||||||
|
val lastResult = res.lastOrNull()?.get(ExtensionStoreTable.indexUrl)
|
||||||
|
|
||||||
|
res.applyBeforeAfter(
|
||||||
|
before = before,
|
||||||
|
after = after,
|
||||||
|
orderBy = order?.firstOrNull()?.by ?: ExtensionStoreOrderBy.INDEX_URL,
|
||||||
|
orderByType = order?.firstOrNull()?.byType,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (first != null) {
|
||||||
|
res.limit(first).offset(offset?.toLong() ?: 0)
|
||||||
|
} else if (last != null) {
|
||||||
|
res.limit(last)
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryResults(total, firstResult, lastResult, res.toList())
|
||||||
|
}
|
||||||
|
|
||||||
|
val getAsCursor: (ExtensionStoreType) -> Cursor = (order?.firstOrNull()?.by ?: ExtensionStoreOrderBy.INDEX_URL)::asCursor
|
||||||
|
|
||||||
|
val resultsAsType = queryResults.results.map { ExtensionStoreType(it) }
|
||||||
|
|
||||||
|
return ExtensionStoreNodeList(
|
||||||
|
resultsAsType,
|
||||||
|
if (resultsAsType.isEmpty()) {
|
||||||
|
emptyList()
|
||||||
|
} else {
|
||||||
|
listOfNotNull(
|
||||||
|
resultsAsType.firstOrNull()?.let {
|
||||||
|
ExtensionStoreNodeList.ExtensionStoreEdge(
|
||||||
|
getAsCursor(it),
|
||||||
|
it,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
resultsAsType.lastOrNull()?.let {
|
||||||
|
ExtensionStoreNodeList.ExtensionStoreEdge(
|
||||||
|
getAsCursor(it),
|
||||||
|
it,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
pageInfo =
|
||||||
|
PageInfo(
|
||||||
|
hasNextPage = queryResults.lastKey != resultsAsType.lastOrNull()?.indexUrl,
|
||||||
|
hasPreviousPage = queryResults.firstKey != resultsAsType.firstOrNull()?.indexUrl,
|
||||||
|
startCursor = resultsAsType.firstOrNull()?.let { getAsCursor(it) },
|
||||||
|
endCursor = resultsAsType.lastOrNull()?.let { getAsCursor(it) },
|
||||||
|
),
|
||||||
|
totalCount = queryResults.total.toInt(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,19 +13,22 @@ import graphql.schema.DataFetchingEnvironment
|
|||||||
import org.jetbrains.exposed.v1.core.Column
|
import org.jetbrains.exposed.v1.core.Column
|
||||||
import org.jetbrains.exposed.v1.core.Op
|
import org.jetbrains.exposed.v1.core.Op
|
||||||
import org.jetbrains.exposed.v1.core.SortOrder
|
import org.jetbrains.exposed.v1.core.SortOrder
|
||||||
|
import org.jetbrains.exposed.v1.core.eq
|
||||||
import org.jetbrains.exposed.v1.core.greater
|
import org.jetbrains.exposed.v1.core.greater
|
||||||
|
import org.jetbrains.exposed.v1.core.greaterEq
|
||||||
import org.jetbrains.exposed.v1.core.less
|
import org.jetbrains.exposed.v1.core.less
|
||||||
import org.jetbrains.exposed.v1.jdbc.selectAll
|
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||||
import suwayomi.tachidesk.graphql.directives.RequireAuth
|
import suwayomi.tachidesk.graphql.directives.RequireAuth
|
||||||
import suwayomi.tachidesk.graphql.queries.filter.BooleanFilter
|
import suwayomi.tachidesk.graphql.queries.filter.BooleanFilter
|
||||||
|
import suwayomi.tachidesk.graphql.queries.filter.ContentWarningFilter
|
||||||
import suwayomi.tachidesk.graphql.queries.filter.Filter
|
import suwayomi.tachidesk.graphql.queries.filter.Filter
|
||||||
import suwayomi.tachidesk.graphql.queries.filter.HasGetOp
|
import suwayomi.tachidesk.graphql.queries.filter.HasGetOp
|
||||||
import suwayomi.tachidesk.graphql.queries.filter.LongFilter
|
import suwayomi.tachidesk.graphql.queries.filter.LongFilter
|
||||||
import suwayomi.tachidesk.graphql.queries.filter.OpAnd
|
import suwayomi.tachidesk.graphql.queries.filter.OpAnd
|
||||||
import suwayomi.tachidesk.graphql.queries.filter.StringFilter
|
import suwayomi.tachidesk.graphql.queries.filter.StringFilter
|
||||||
import suwayomi.tachidesk.graphql.queries.filter.andFilterWithCompare
|
|
||||||
import suwayomi.tachidesk.graphql.queries.filter.andFilterWithCompareEntity
|
import suwayomi.tachidesk.graphql.queries.filter.andFilterWithCompareEntity
|
||||||
|
import suwayomi.tachidesk.graphql.queries.filter.andFilterWithCompareEnum
|
||||||
import suwayomi.tachidesk.graphql.queries.filter.andFilterWithCompareString
|
import suwayomi.tachidesk.graphql.queries.filter.andFilterWithCompareString
|
||||||
import suwayomi.tachidesk.graphql.queries.filter.applyOps
|
import suwayomi.tachidesk.graphql.queries.filter.applyOps
|
||||||
import suwayomi.tachidesk.graphql.server.primitives.Cursor
|
import suwayomi.tachidesk.graphql.server.primitives.Cursor
|
||||||
@@ -39,6 +42,7 @@ import suwayomi.tachidesk.graphql.server.primitives.lessNotUnique
|
|||||||
import suwayomi.tachidesk.graphql.server.primitives.maybeSwap
|
import suwayomi.tachidesk.graphql.server.primitives.maybeSwap
|
||||||
import suwayomi.tachidesk.graphql.types.SourceNodeList
|
import suwayomi.tachidesk.graphql.types.SourceNodeList
|
||||||
import suwayomi.tachidesk.graphql.types.SourceType
|
import suwayomi.tachidesk.graphql.types.SourceType
|
||||||
|
import suwayomi.tachidesk.manga.model.dataclass.ContentWarning
|
||||||
import suwayomi.tachidesk.manga.model.table.SourceTable
|
import suwayomi.tachidesk.manga.model.table.SourceTable
|
||||||
import java.util.concurrent.CompletableFuture
|
import java.util.concurrent.CompletableFuture
|
||||||
|
|
||||||
@@ -91,14 +95,23 @@ class SourceQuery {
|
|||||||
val id: Long? = null,
|
val id: Long? = null,
|
||||||
val name: String? = null,
|
val name: String? = null,
|
||||||
val lang: String? = null,
|
val lang: String? = null,
|
||||||
|
@GraphQLDeprecated("replace with contentWarning == ContentRating.MIXED", ReplaceWith("contentWarning"))
|
||||||
val isNsfw: Boolean? = null,
|
val isNsfw: Boolean? = null,
|
||||||
|
val contentWarning: ContentWarning? = null,
|
||||||
) : HasGetOp {
|
) : HasGetOp {
|
||||||
override fun getOp(): Op<Boolean>? {
|
override fun getOp(): Op<Boolean>? {
|
||||||
val opAnd = OpAnd()
|
val opAnd = OpAnd()
|
||||||
opAnd.eq(id, SourceTable.id)
|
opAnd.eq(id, SourceTable.id)
|
||||||
opAnd.eq(name, SourceTable.name)
|
opAnd.eq(name, SourceTable.name)
|
||||||
opAnd.eq(lang, SourceTable.lang)
|
opAnd.eq(lang, SourceTable.lang)
|
||||||
opAnd.eq(isNsfw, SourceTable.isNsfw)
|
opAnd.andWhere(isNsfw) {
|
||||||
|
if (it) {
|
||||||
|
SourceTable.contentWarning greaterEq ContentWarning.MIXED.ordinal
|
||||||
|
} else {
|
||||||
|
SourceTable.contentWarning less ContentWarning.MIXED.ordinal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
opAnd.andWhere(contentWarning) { SourceTable.contentWarning eq it.ordinal }
|
||||||
|
|
||||||
return opAnd.op
|
return opAnd.op
|
||||||
}
|
}
|
||||||
@@ -108,7 +121,9 @@ class SourceQuery {
|
|||||||
val id: LongFilter? = null,
|
val id: LongFilter? = null,
|
||||||
val name: StringFilter? = null,
|
val name: StringFilter? = null,
|
||||||
val lang: StringFilter? = null,
|
val lang: StringFilter? = null,
|
||||||
|
@GraphQLDeprecated("replace with contentWarning", ReplaceWith("contentWarning"))
|
||||||
val isNsfw: BooleanFilter? = null,
|
val isNsfw: BooleanFilter? = null,
|
||||||
|
val contentWarning: ContentWarningFilter? = null,
|
||||||
override val and: List<SourceFilter>? = null,
|
override val and: List<SourceFilter>? = null,
|
||||||
override val or: List<SourceFilter>? = null,
|
override val or: List<SourceFilter>? = null,
|
||||||
override val not: SourceFilter? = null,
|
override val not: SourceFilter? = null,
|
||||||
@@ -118,7 +133,7 @@ class SourceQuery {
|
|||||||
andFilterWithCompareEntity(SourceTable.id, id),
|
andFilterWithCompareEntity(SourceTable.id, id),
|
||||||
andFilterWithCompareString(SourceTable.name, name),
|
andFilterWithCompareString(SourceTable.name, name),
|
||||||
andFilterWithCompareString(SourceTable.lang, lang),
|
andFilterWithCompareString(SourceTable.lang, lang),
|
||||||
andFilterWithCompare(SourceTable.isNsfw, isNsfw),
|
andFilterWithCompareEnum(SourceTable.contentWarning, contentWarning),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import org.jetbrains.exposed.v1.core.upperCase
|
|||||||
import org.jetbrains.exposed.v1.core.wrap
|
import org.jetbrains.exposed.v1.core.wrap
|
||||||
import org.jetbrains.exposed.v1.jdbc.Query
|
import org.jetbrains.exposed.v1.jdbc.Query
|
||||||
import org.jetbrains.exposed.v1.jdbc.andWhere
|
import org.jetbrains.exposed.v1.jdbc.andWhere
|
||||||
|
import suwayomi.tachidesk.manga.model.dataclass.ContentWarning
|
||||||
|
|
||||||
class ILikeEscapeOp(
|
class ILikeEscapeOp(
|
||||||
expr1: Expression<*>,
|
expr1: Expression<*>,
|
||||||
@@ -329,6 +330,24 @@ data class DoubleFilter(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class ContentWarningFilter(
|
||||||
|
override val isNull: Boolean? = null,
|
||||||
|
override val equalTo: ContentWarning? = null,
|
||||||
|
override val notEqualTo: ContentWarning? = null,
|
||||||
|
override val notEqualToAll: List<ContentWarning>? = null,
|
||||||
|
override val notEqualToAny: List<ContentWarning>? = null,
|
||||||
|
override val distinctFrom: ContentWarning? = null,
|
||||||
|
override val distinctFromAll: List<ContentWarning>? = null,
|
||||||
|
override val distinctFromAny: List<ContentWarning>? = null,
|
||||||
|
override val notDistinctFrom: ContentWarning? = null,
|
||||||
|
override val `in`: List<ContentWarning>? = null,
|
||||||
|
override val notIn: List<ContentWarning>? = null,
|
||||||
|
override val lessThan: ContentWarning? = null,
|
||||||
|
override val lessThanOrEqualTo: ContentWarning? = null,
|
||||||
|
override val greaterThan: ContentWarning? = null,
|
||||||
|
override val greaterThanOrEqualTo: ContentWarning? = null,
|
||||||
|
) : ComparableScalarFilter<ContentWarning>
|
||||||
|
|
||||||
data class StringFilter(
|
data class StringFilter(
|
||||||
override val isNull: Boolean? = null,
|
override val isNull: Boolean? = null,
|
||||||
override val equalTo: String? = null,
|
override val equalTo: String? = null,
|
||||||
@@ -618,6 +637,35 @@ fun <T : Comparable<T>, S : T?> andFilterWithCompare(
|
|||||||
return opAnd.op
|
return opAnd.op
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
fun <T : Enum<T>> andFilterWithCompareEnum(
|
||||||
|
column: Column<Int>,
|
||||||
|
filter: ComparableScalarFilter<T>?,
|
||||||
|
): Op<Boolean>? {
|
||||||
|
filter ?: return null
|
||||||
|
val opAnd = OpAnd()
|
||||||
|
|
||||||
|
opAnd.andWhere(filter.lessThan) { column less it.ordinal }
|
||||||
|
opAnd.andWhere(filter.lessThanOrEqualTo) { column lessEq it.ordinal }
|
||||||
|
opAnd.andWhere(filter.greaterThan) { column greater it.ordinal }
|
||||||
|
opAnd.andWhere(filter.greaterThanOrEqualTo) { column greaterEq it.ordinal }
|
||||||
|
|
||||||
|
opAnd.andWhere(filter.isNull) { if (it) column.isNull() else column.isNotNull() }
|
||||||
|
|
||||||
|
opAnd.andWhere(filter.equalTo) { column eq it.ordinal }
|
||||||
|
opAnd.andNotWhere(filter.notEqualTo, filter.notEqualToAll, filter.notEqualToAny) { column neq it.ordinal }
|
||||||
|
opAnd.andWhere(filter.distinctFrom, filter.distinctFromAll, filter.distinctFromAny) { DistinctFromOp.distinctFrom(column, it.ordinal) }
|
||||||
|
opAnd.andWhere(filter.notDistinctFrom) { DistinctFromOp.notDistinctFrom(column, it.ordinal) }
|
||||||
|
if (!filter.`in`.isNullOrEmpty()) {
|
||||||
|
opAnd.andWhere(filter.`in`) { column inList it.map { it.ordinal } }
|
||||||
|
}
|
||||||
|
if (!filter.notIn.isNullOrEmpty()) {
|
||||||
|
opAnd.andWhere(filter.notIn) { column notInList it.map { it.ordinal } }
|
||||||
|
}
|
||||||
|
|
||||||
|
return opAnd.op
|
||||||
|
}
|
||||||
|
|
||||||
fun <T : Comparable<T>> andFilterWithCompareEntity(
|
fun <T : Comparable<T>> andFilterWithCompareEntity(
|
||||||
column: Column<EntityID<T>>,
|
column: Column<EntityID<T>>,
|
||||||
filter: ComparableScalarFilter<T>?,
|
filter: ComparableScalarFilter<T>?,
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import suwayomi.tachidesk.graphql.dataLoaders.DisplayScoreForTrackSearchDataLoad
|
|||||||
import suwayomi.tachidesk.graphql.dataLoaders.DownloadedChapterCountForMangaDataLoader
|
import suwayomi.tachidesk.graphql.dataLoaders.DownloadedChapterCountForMangaDataLoader
|
||||||
import suwayomi.tachidesk.graphql.dataLoaders.ExtensionDataLoader
|
import suwayomi.tachidesk.graphql.dataLoaders.ExtensionDataLoader
|
||||||
import suwayomi.tachidesk.graphql.dataLoaders.ExtensionForSourceDataLoader
|
import suwayomi.tachidesk.graphql.dataLoaders.ExtensionForSourceDataLoader
|
||||||
|
import suwayomi.tachidesk.graphql.dataLoaders.ExtensionStoreDataLoader
|
||||||
|
import suwayomi.tachidesk.graphql.dataLoaders.ExtensionsForExtensionStore
|
||||||
import suwayomi.tachidesk.graphql.dataLoaders.FirstUnreadChapterForMangaDataLoader
|
import suwayomi.tachidesk.graphql.dataLoaders.FirstUnreadChapterForMangaDataLoader
|
||||||
import suwayomi.tachidesk.graphql.dataLoaders.GlobalMetaDataLoader
|
import suwayomi.tachidesk.graphql.dataLoaders.GlobalMetaDataLoader
|
||||||
import suwayomi.tachidesk.graphql.dataLoaders.HasDuplicateChaptersForMangaDataLoader
|
import suwayomi.tachidesk.graphql.dataLoaders.HasDuplicateChaptersForMangaDataLoader
|
||||||
@@ -78,6 +80,8 @@ class TachideskDataLoaderRegistryFactory {
|
|||||||
SourceMetaDataLoader(),
|
SourceMetaDataLoader(),
|
||||||
ExtensionDataLoader(),
|
ExtensionDataLoader(),
|
||||||
ExtensionForSourceDataLoader(),
|
ExtensionForSourceDataLoader(),
|
||||||
|
ExtensionsForExtensionStore(),
|
||||||
|
ExtensionStoreDataLoader(),
|
||||||
TrackerDataLoader(),
|
TrackerDataLoader(),
|
||||||
TrackerStatusesDataLoader(),
|
TrackerStatusesDataLoader(),
|
||||||
TrackerScoresDataLoader(),
|
TrackerScoresDataLoader(),
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import suwayomi.tachidesk.graphql.mutations.CategoryMutation
|
|||||||
import suwayomi.tachidesk.graphql.mutations.ChapterMutation
|
import suwayomi.tachidesk.graphql.mutations.ChapterMutation
|
||||||
import suwayomi.tachidesk.graphql.mutations.DownloadMutation
|
import suwayomi.tachidesk.graphql.mutations.DownloadMutation
|
||||||
import suwayomi.tachidesk.graphql.mutations.ExtensionMutation
|
import suwayomi.tachidesk.graphql.mutations.ExtensionMutation
|
||||||
|
import suwayomi.tachidesk.graphql.mutations.ExtensionStoreMutation
|
||||||
import suwayomi.tachidesk.graphql.mutations.ImageMutation
|
import suwayomi.tachidesk.graphql.mutations.ImageMutation
|
||||||
import suwayomi.tachidesk.graphql.mutations.InfoMutation
|
import suwayomi.tachidesk.graphql.mutations.InfoMutation
|
||||||
import suwayomi.tachidesk.graphql.mutations.KoreaderSyncMutation
|
import suwayomi.tachidesk.graphql.mutations.KoreaderSyncMutation
|
||||||
@@ -36,6 +37,7 @@ import suwayomi.tachidesk.graphql.queries.CategoryQuery
|
|||||||
import suwayomi.tachidesk.graphql.queries.ChapterQuery
|
import suwayomi.tachidesk.graphql.queries.ChapterQuery
|
||||||
import suwayomi.tachidesk.graphql.queries.DownloadQuery
|
import suwayomi.tachidesk.graphql.queries.DownloadQuery
|
||||||
import suwayomi.tachidesk.graphql.queries.ExtensionQuery
|
import suwayomi.tachidesk.graphql.queries.ExtensionQuery
|
||||||
|
import suwayomi.tachidesk.graphql.queries.ExtensionStoreQuery
|
||||||
import suwayomi.tachidesk.graphql.queries.InfoQuery
|
import suwayomi.tachidesk.graphql.queries.InfoQuery
|
||||||
import suwayomi.tachidesk.graphql.queries.KoreaderSyncQuery
|
import suwayomi.tachidesk.graphql.queries.KoreaderSyncQuery
|
||||||
import suwayomi.tachidesk.graphql.queries.MangaQuery
|
import suwayomi.tachidesk.graphql.queries.MangaQuery
|
||||||
@@ -95,6 +97,7 @@ val schema =
|
|||||||
TopLevelObject(ChapterQuery()),
|
TopLevelObject(ChapterQuery()),
|
||||||
TopLevelObject(DownloadQuery()),
|
TopLevelObject(DownloadQuery()),
|
||||||
TopLevelObject(ExtensionQuery()),
|
TopLevelObject(ExtensionQuery()),
|
||||||
|
TopLevelObject(ExtensionStoreQuery()),
|
||||||
TopLevelObject(InfoQuery()),
|
TopLevelObject(InfoQuery()),
|
||||||
TopLevelObject(KoreaderSyncQuery()),
|
TopLevelObject(KoreaderSyncQuery()),
|
||||||
TopLevelObject(MangaQuery()),
|
TopLevelObject(MangaQuery()),
|
||||||
@@ -112,6 +115,7 @@ val schema =
|
|||||||
TopLevelObject(ChapterMutation()),
|
TopLevelObject(ChapterMutation()),
|
||||||
TopLevelObject(DownloadMutation()),
|
TopLevelObject(DownloadMutation()),
|
||||||
TopLevelObject(ExtensionMutation()),
|
TopLevelObject(ExtensionMutation()),
|
||||||
|
TopLevelObject(ExtensionStoreMutation()),
|
||||||
TopLevelObject(ImageMutation()),
|
TopLevelObject(ImageMutation()),
|
||||||
TopLevelObject(InfoMutation()),
|
TopLevelObject(InfoMutation()),
|
||||||
TopLevelObject(KoreaderSyncMutation()),
|
TopLevelObject(KoreaderSyncMutation()),
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package suwayomi.tachidesk.graphql.types
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 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.expediagroup.graphql.server.extensions.getValueFromDataLoader
|
||||||
|
import graphql.schema.DataFetchingEnvironment
|
||||||
|
import org.jetbrains.exposed.v1.core.ResultRow
|
||||||
|
import suwayomi.tachidesk.graphql.server.primitives.Cursor
|
||||||
|
import suwayomi.tachidesk.graphql.server.primitives.Edge
|
||||||
|
import suwayomi.tachidesk.graphql.server.primitives.Node
|
||||||
|
import suwayomi.tachidesk.graphql.server.primitives.NodeList
|
||||||
|
import suwayomi.tachidesk.graphql.server.primitives.PageInfo
|
||||||
|
import suwayomi.tachidesk.manga.model.table.ExtensionStoreTable
|
||||||
|
import java.util.concurrent.CompletableFuture
|
||||||
|
|
||||||
|
class ExtensionStoreType(
|
||||||
|
val name: String,
|
||||||
|
val badgeLabel: String,
|
||||||
|
val signingKey: String,
|
||||||
|
val contactWebsite: String,
|
||||||
|
val contactDiscord: String?,
|
||||||
|
val indexUrl: String,
|
||||||
|
val isLegacy: Boolean,
|
||||||
|
val extensionListUrl: String?,
|
||||||
|
) : Node {
|
||||||
|
constructor(row: ResultRow) : this(
|
||||||
|
name = row[ExtensionStoreTable.name],
|
||||||
|
badgeLabel = row[ExtensionStoreTable.badgeLabel],
|
||||||
|
signingKey = row[ExtensionStoreTable.signingKey],
|
||||||
|
contactWebsite = row[ExtensionStoreTable.contactWebsite],
|
||||||
|
contactDiscord = row[ExtensionStoreTable.contactDiscord],
|
||||||
|
indexUrl = row[ExtensionStoreTable.indexUrl],
|
||||||
|
isLegacy = row[ExtensionStoreTable.isLegacy],
|
||||||
|
extensionListUrl = row[ExtensionStoreTable.extensionListUrl],
|
||||||
|
)
|
||||||
|
|
||||||
|
fun extensions(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<ExtensionNodeList> =
|
||||||
|
dataFetchingEnvironment.getValueFromDataLoader<String, ExtensionNodeList>("ExtensionsForExtensionStore", indexUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
data class ExtensionStoreNodeList(
|
||||||
|
override val nodes: List<ExtensionStoreType>,
|
||||||
|
override val edges: List<ExtensionStoreEdge>,
|
||||||
|
override val pageInfo: PageInfo,
|
||||||
|
override val totalCount: Int,
|
||||||
|
) : NodeList() {
|
||||||
|
data class ExtensionStoreEdge(
|
||||||
|
override val cursor: Cursor,
|
||||||
|
override val node: ExtensionStoreType,
|
||||||
|
) : Edge()
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun List<ExtensionStoreType>.toNodeList(): ExtensionStoreNodeList =
|
||||||
|
ExtensionStoreNodeList(
|
||||||
|
nodes = this,
|
||||||
|
edges = getEdges(),
|
||||||
|
pageInfo =
|
||||||
|
PageInfo(
|
||||||
|
hasNextPage = false,
|
||||||
|
hasPreviousPage = false,
|
||||||
|
startCursor = Cursor(0.toString()),
|
||||||
|
endCursor = Cursor(lastIndex.toString()),
|
||||||
|
),
|
||||||
|
totalCount = size,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun List<ExtensionStoreType>.getEdges(): List<ExtensionStoreEdge> {
|
||||||
|
if (isEmpty()) return emptyList()
|
||||||
|
return listOf(
|
||||||
|
ExtensionStoreEdge(
|
||||||
|
cursor = Cursor("0"),
|
||||||
|
node = first(),
|
||||||
|
),
|
||||||
|
ExtensionStoreEdge(
|
||||||
|
cursor = Cursor(lastIndex.toString()),
|
||||||
|
node = last(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@
|
|||||||
|
|
||||||
package suwayomi.tachidesk.graphql.types
|
package suwayomi.tachidesk.graphql.types
|
||||||
|
|
||||||
|
import com.expediagroup.graphql.generator.annotations.GraphQLDeprecated
|
||||||
|
import com.expediagroup.graphql.generator.annotations.GraphQLDescription
|
||||||
import com.expediagroup.graphql.server.extensions.getValueFromDataLoader
|
import com.expediagroup.graphql.server.extensions.getValueFromDataLoader
|
||||||
import graphql.schema.DataFetchingEnvironment
|
import graphql.schema.DataFetchingEnvironment
|
||||||
import org.jetbrains.exposed.v1.core.ResultRow
|
import org.jetbrains.exposed.v1.core.ResultRow
|
||||||
@@ -16,33 +18,51 @@ import suwayomi.tachidesk.graphql.server.primitives.Node
|
|||||||
import suwayomi.tachidesk.graphql.server.primitives.NodeList
|
import suwayomi.tachidesk.graphql.server.primitives.NodeList
|
||||||
import suwayomi.tachidesk.graphql.server.primitives.PageInfo
|
import suwayomi.tachidesk.graphql.server.primitives.PageInfo
|
||||||
import suwayomi.tachidesk.manga.impl.extension.Extension
|
import suwayomi.tachidesk.manga.impl.extension.Extension
|
||||||
|
import suwayomi.tachidesk.manga.model.dataclass.ContentWarning
|
||||||
import suwayomi.tachidesk.manga.model.table.ExtensionTable
|
import suwayomi.tachidesk.manga.model.table.ExtensionTable
|
||||||
import java.util.concurrent.CompletableFuture
|
import java.util.concurrent.CompletableFuture
|
||||||
|
|
||||||
class ExtensionType(
|
class ExtensionType(
|
||||||
|
val storeIndexUrl: String?,
|
||||||
|
@GraphQLDeprecated("Removed in extension api v1.6", ReplaceWith("storeIndexUrl"))
|
||||||
val repo: String?,
|
val repo: String?,
|
||||||
val apkName: String,
|
@GraphQLDescription("This will be nullable in the future")
|
||||||
|
val apkName: String?,
|
||||||
val iconUrl: String,
|
val iconUrl: String,
|
||||||
val name: String,
|
val name: String,
|
||||||
val pkgName: String,
|
val pkgName: String,
|
||||||
|
val apkUrl: String?,
|
||||||
|
val extensionLib: String?,
|
||||||
val versionName: String,
|
val versionName: String,
|
||||||
|
@GraphQLDeprecated(
|
||||||
|
"Type was changed to Long, will be switched back to this variable name in the future.",
|
||||||
|
ReplaceWith("versionCodeLong"),
|
||||||
|
)
|
||||||
val versionCode: Int,
|
val versionCode: Int,
|
||||||
|
val versionCodeLong: Long,
|
||||||
val lang: String,
|
val lang: String,
|
||||||
|
@GraphQLDeprecated("Removed in extension api v1.6", ReplaceWith("contentWarning"))
|
||||||
val isNsfw: Boolean,
|
val isNsfw: Boolean,
|
||||||
|
val contentWarning: ContentWarning,
|
||||||
val isInstalled: Boolean,
|
val isInstalled: Boolean,
|
||||||
val hasUpdate: Boolean,
|
val hasUpdate: Boolean,
|
||||||
val isObsolete: Boolean,
|
val isObsolete: Boolean,
|
||||||
) : Node {
|
) : Node {
|
||||||
constructor(row: ResultRow) : this(
|
constructor(row: ResultRow) : this(
|
||||||
repo = row[ExtensionTable.repo],
|
storeIndexUrl = row[ExtensionTable.storeIndexUrl],
|
||||||
apkName = row[ExtensionTable.apkName],
|
repo = row[ExtensionTable.storeIndexUrl],
|
||||||
iconUrl = Extension.getExtensionIconUrl(row[ExtensionTable.apkName]),
|
apkName = row[ExtensionTable.apkName].orEmpty(),
|
||||||
|
iconUrl = Extension.proxyExtensionIconUrl(row[ExtensionTable.pkgName]),
|
||||||
name = row[ExtensionTable.name],
|
name = row[ExtensionTable.name],
|
||||||
pkgName = row[ExtensionTable.pkgName],
|
pkgName = row[ExtensionTable.pkgName],
|
||||||
|
apkUrl = row[ExtensionTable.apkUrl],
|
||||||
|
extensionLib = row[ExtensionTable.extensionLib],
|
||||||
versionName = row[ExtensionTable.versionName],
|
versionName = row[ExtensionTable.versionName],
|
||||||
versionCode = row[ExtensionTable.versionCode],
|
versionCode = row[ExtensionTable.versionCode].toInt(),
|
||||||
|
versionCodeLong = row[ExtensionTable.versionCode],
|
||||||
lang = row[ExtensionTable.lang],
|
lang = row[ExtensionTable.lang],
|
||||||
isNsfw = row[ExtensionTable.isNsfw],
|
isNsfw = row[ExtensionTable.contentWarning] >= ContentWarning.MIXED.ordinal,
|
||||||
|
contentWarning = ContentWarning.valueOf(row[ExtensionTable.contentWarning]),
|
||||||
isInstalled = row[ExtensionTable.isInstalled],
|
isInstalled = row[ExtensionTable.isInstalled],
|
||||||
hasUpdate = row[ExtensionTable.hasUpdate],
|
hasUpdate = row[ExtensionTable.hasUpdate],
|
||||||
isObsolete = row[ExtensionTable.isObsolete],
|
isObsolete = row[ExtensionTable.isObsolete],
|
||||||
@@ -50,6 +70,9 @@ class ExtensionType(
|
|||||||
|
|
||||||
fun source(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<SourceNodeList> =
|
fun source(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<SourceNodeList> =
|
||||||
dataFetchingEnvironment.getValueFromDataLoader<String, SourceNodeList>("SourcesForExtensionDataLoader", pkgName)
|
dataFetchingEnvironment.getValueFromDataLoader<String, SourceNodeList>("SourcesForExtensionDataLoader", pkgName)
|
||||||
|
|
||||||
|
fun extensionStore(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<ExtensionStoreType> =
|
||||||
|
dataFetchingEnvironment.getValueFromDataLoader<String, ExtensionStoreType>("ExtensionStoreDataLoader", storeIndexUrl.orEmpty())
|
||||||
}
|
}
|
||||||
|
|
||||||
data class ExtensionNodeList(
|
data class ExtensionNodeList(
|
||||||
|
|||||||
@@ -7,9 +7,10 @@
|
|||||||
|
|
||||||
package suwayomi.tachidesk.graphql.types
|
package suwayomi.tachidesk.graphql.types
|
||||||
|
|
||||||
|
import com.expediagroup.graphql.generator.annotations.GraphQLDeprecated
|
||||||
import com.expediagroup.graphql.server.extensions.getValueFromDataLoader
|
import com.expediagroup.graphql.server.extensions.getValueFromDataLoader
|
||||||
import eu.kanade.tachiyomi.source.CatalogueSource
|
|
||||||
import eu.kanade.tachiyomi.source.ConfigurableSource
|
import eu.kanade.tachiyomi.source.ConfigurableSource
|
||||||
|
import eu.kanade.tachiyomi.source.Source
|
||||||
import eu.kanade.tachiyomi.source.model.FilterList
|
import eu.kanade.tachiyomi.source.model.FilterList
|
||||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||||
import graphql.schema.DataFetchingEnvironment
|
import graphql.schema.DataFetchingEnvironment
|
||||||
@@ -23,9 +24,9 @@ import suwayomi.tachidesk.graphql.server.primitives.NodeList
|
|||||||
import suwayomi.tachidesk.graphql.server.primitives.PageInfo
|
import suwayomi.tachidesk.graphql.server.primitives.PageInfo
|
||||||
import suwayomi.tachidesk.manga.impl.Source.getSourcePreferencesRaw
|
import suwayomi.tachidesk.manga.impl.Source.getSourcePreferencesRaw
|
||||||
import suwayomi.tachidesk.manga.impl.extension.Extension
|
import suwayomi.tachidesk.manga.impl.extension.Extension
|
||||||
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource
|
import suwayomi.tachidesk.manga.impl.util.source.GetSource
|
||||||
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrStub
|
import suwayomi.tachidesk.manga.impl.util.source.GetSource.getSourceOrStub
|
||||||
import suwayomi.tachidesk.manga.model.dataclass.SourceDataClass
|
import suwayomi.tachidesk.manga.model.dataclass.ContentWarning
|
||||||
import suwayomi.tachidesk.manga.model.table.ExtensionTable
|
import suwayomi.tachidesk.manga.model.table.ExtensionTable
|
||||||
import suwayomi.tachidesk.manga.model.table.SourceTable
|
import suwayomi.tachidesk.manga.model.table.SourceTable
|
||||||
import java.util.concurrent.CompletableFuture
|
import java.util.concurrent.CompletableFuture
|
||||||
@@ -41,35 +42,29 @@ class SourceType(
|
|||||||
val id: Long,
|
val id: Long,
|
||||||
val name: String,
|
val name: String,
|
||||||
val lang: String,
|
val lang: String,
|
||||||
|
val contentWarning: ContentWarning,
|
||||||
val iconUrl: String,
|
val iconUrl: String,
|
||||||
val supportsLatest: Boolean,
|
val supportsLatest: Boolean,
|
||||||
val isConfigurable: Boolean,
|
val isConfigurable: Boolean,
|
||||||
|
@GraphQLDeprecated("", ReplaceWith("contentWarning"))
|
||||||
val isNsfw: Boolean,
|
val isNsfw: Boolean,
|
||||||
val displayName: String,
|
val displayName: String,
|
||||||
|
val homeUrl: String?,
|
||||||
|
@GraphQLDeprecated("", ReplaceWith("homeUrl"))
|
||||||
val baseUrl: String?,
|
val baseUrl: String?,
|
||||||
) : Node {
|
) : Node {
|
||||||
constructor(source: SourceDataClass) : this(
|
constructor(row: ResultRow, sourceExtension: ResultRow, source: Source) : this(
|
||||||
id = source.id.toLong(),
|
|
||||||
name = source.name,
|
|
||||||
lang = source.lang,
|
|
||||||
iconUrl = source.iconUrl,
|
|
||||||
supportsLatest = source.supportsLatest,
|
|
||||||
isConfigurable = source.isConfigurable,
|
|
||||||
isNsfw = source.isNsfw,
|
|
||||||
displayName = source.displayName,
|
|
||||||
baseUrl = source.baseUrl,
|
|
||||||
)
|
|
||||||
|
|
||||||
constructor(row: ResultRow, sourceExtension: ResultRow, catalogueSource: CatalogueSource) : this(
|
|
||||||
id = row[SourceTable.id].value,
|
id = row[SourceTable.id].value,
|
||||||
name = row[SourceTable.name],
|
name = row[SourceTable.name],
|
||||||
lang = row[SourceTable.lang],
|
lang = row[SourceTable.lang],
|
||||||
iconUrl = Extension.getExtensionIconUrl(sourceExtension[ExtensionTable.apkName]),
|
contentWarning = ContentWarning.valueOf(row[SourceTable.contentWarning]),
|
||||||
supportsLatest = catalogueSource.supportsLatest,
|
iconUrl = Extension.proxyExtensionIconUrl(sourceExtension[ExtensionTable.pkgName]),
|
||||||
isConfigurable = catalogueSource is ConfigurableSource,
|
supportsLatest = source.supportsLatest,
|
||||||
isNsfw = row[SourceTable.isNsfw],
|
isConfigurable = source is ConfigurableSource,
|
||||||
displayName = catalogueSource.toString(),
|
isNsfw = row[SourceTable.contentWarning] >= ContentWarning.MIXED.ordinal,
|
||||||
baseUrl = catalogueSource.runCatching { (catalogueSource as? HttpSource)?.baseUrl }.getOrNull(),
|
displayName = source.toString(),
|
||||||
|
homeUrl = runCatching { (source as? HttpSource)?.getHomeUrl() }.getOrNull(),
|
||||||
|
baseUrl = runCatching { (source as? HttpSource)?.baseUrl }.getOrNull(),
|
||||||
)
|
)
|
||||||
|
|
||||||
fun manga(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<MangaNodeList> =
|
fun manga(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<MangaNodeList> =
|
||||||
@@ -80,7 +75,7 @@ class SourceType(
|
|||||||
|
|
||||||
fun preferences(): List<Preference> = getSourcePreferencesRaw(id).map { preferenceOf(it) }
|
fun preferences(): List<Preference> = getSourcePreferencesRaw(id).map { preferenceOf(it) }
|
||||||
|
|
||||||
fun filters(): List<Filter> = getCatalogueSourceOrStub(id).getFilterList().map { filterOf(it) }
|
fun filters(): List<Filter> = getSourceOrStub(id).getFilterList().map { filterOf(it) }
|
||||||
|
|
||||||
fun meta(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<List<SourceMetaType>> =
|
fun meta(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<List<SourceMetaType>> =
|
||||||
dataFetchingEnvironment.getValueFromDataLoader<Long, List<SourceMetaType>>("SourceMetaDataLoader", id)
|
dataFetchingEnvironment.getValueFromDataLoader<Long, List<SourceMetaType>>("SourceMetaDataLoader", id)
|
||||||
@@ -89,8 +84,8 @@ class SourceType(
|
|||||||
@Suppress("ktlint:standard:function-naming")
|
@Suppress("ktlint:standard:function-naming")
|
||||||
fun SourceType(row: ResultRow): SourceType? {
|
fun SourceType(row: ResultRow): SourceType? {
|
||||||
val catalogueSource =
|
val catalogueSource =
|
||||||
GetCatalogueSource
|
GetSource
|
||||||
.getCatalogueSourceOrNull(row[SourceTable.id].value)
|
.getSourceOrNull(row[SourceTable.id].value)
|
||||||
?: return null
|
?: return null
|
||||||
val sourceExtension =
|
val sourceExtension =
|
||||||
if (row.hasValue(ExtensionTable.id)) {
|
if (row.hasValue(ExtensionTable.id)) {
|
||||||
@@ -301,7 +296,7 @@ data class FilterChange(
|
|||||||
)
|
)
|
||||||
|
|
||||||
fun updateFilterList(
|
fun updateFilterList(
|
||||||
source: CatalogueSource,
|
source: Source,
|
||||||
changes: List<FilterChange>?,
|
changes: List<FilterChange>?,
|
||||||
): FilterList {
|
): FilterList {
|
||||||
val filterList = source.getFilterList()
|
val filterList = source.getFilterList()
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ object MangaAPI {
|
|||||||
get("update/{pkgName}", ExtensionController.update)
|
get("update/{pkgName}", ExtensionController.update)
|
||||||
get("uninstall/{pkgName}", ExtensionController.uninstall)
|
get("uninstall/{pkgName}", ExtensionController.uninstall)
|
||||||
|
|
||||||
get("icon/{apkName}", ExtensionController.icon)
|
get("icon/{pkgName}", ExtensionController.icon)
|
||||||
}
|
}
|
||||||
|
|
||||||
path("source") {
|
path("source") {
|
||||||
|
|||||||
@@ -165,17 +165,17 @@ object ExtensionController {
|
|||||||
/** icon for extension named `apkName` */
|
/** icon for extension named `apkName` */
|
||||||
val icon =
|
val icon =
|
||||||
handler(
|
handler(
|
||||||
pathParam<String>("apkName"),
|
pathParam<String>("pkgName"),
|
||||||
documentWith = {
|
documentWith = {
|
||||||
withOperation {
|
withOperation {
|
||||||
summary("Extension icon")
|
summary("Extension icon")
|
||||||
description("Icon for extension named `apkName`")
|
description("Icon for extension named `apkName`")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
behaviorOf = { ctx, apkName ->
|
behaviorOf = { ctx, pkgName ->
|
||||||
ctx.getAttribute(Attribute.TachideskUser).requireUser()
|
ctx.getAttribute(Attribute.TachideskUser).requireUser()
|
||||||
ctx.future {
|
ctx.future {
|
||||||
future { Extension.getExtensionIcon(apkName) }
|
future { Extension.getExtensionIcon(pkgName) }
|
||||||
.thenApply {
|
.thenApply {
|
||||||
ctx.header("content-type", it.second)
|
ctx.header("content-type", it.second)
|
||||||
val httpCacheSeconds = 365.days.inWholeSeconds
|
val httpCacheSeconds = 365.days.inWholeSeconds
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ object UpdateController {
|
|||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Class made for handling return type in the documentation for [recentChapters],
|
* Class made for handling return type in the documentation for [UpdateController.recentChapters],
|
||||||
* since OpenApi cannot handle runtime generics.
|
* since OpenApi cannot handle runtime generics.
|
||||||
*/
|
*/
|
||||||
private class PagedMangaChapterListDataClass : PaginatedList<MangaChapterDataClass>(emptyList(), false)
|
private class PagedMangaChapterListDataClass : PaginatedList<MangaChapterDataClass>(emptyList(), false)
|
||||||
|
|||||||
@@ -7,16 +7,19 @@ package suwayomi.tachidesk.manga.impl
|
|||||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.source.Source
|
||||||
import eu.kanade.tachiyomi.source.model.SChapter
|
import eu.kanade.tachiyomi.source.model.SChapter
|
||||||
import eu.kanade.tachiyomi.source.model.SManga
|
import eu.kanade.tachiyomi.source.model.SManga
|
||||||
|
import eu.kanade.tachiyomi.source.model.UpdateStrategy
|
||||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||||
import eu.kanade.tachiyomi.util.chapter.ChapterRecognition
|
import eu.kanade.tachiyomi.util.chapter.ChapterRecognition
|
||||||
import eu.kanade.tachiyomi.util.chapter.ChapterSanitizer.sanitize
|
import eu.kanade.tachiyomi.util.chapter.ChapterSanitizer.sanitize
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
import io.github.reactivecircus.cache4k.Cache
|
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import org.jetbrains.exposed.v1.core.ResultRow
|
||||||
import org.jetbrains.exposed.v1.core.SortOrder
|
import org.jetbrains.exposed.v1.core.SortOrder
|
||||||
import org.jetbrains.exposed.v1.core.and
|
import org.jetbrains.exposed.v1.core.and
|
||||||
import org.jetbrains.exposed.v1.core.dao.id.EntityID
|
import org.jetbrains.exposed.v1.core.dao.id.EntityID
|
||||||
@@ -32,11 +35,10 @@ import org.jetbrains.exposed.v1.jdbc.selectAll
|
|||||||
import org.jetbrains.exposed.v1.jdbc.statements.toExecutable
|
import org.jetbrains.exposed.v1.jdbc.statements.toExecutable
|
||||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||||
import org.jetbrains.exposed.v1.jdbc.update
|
import org.jetbrains.exposed.v1.jdbc.update
|
||||||
import suwayomi.tachidesk.manga.impl.Manga.getManga
|
|
||||||
import suwayomi.tachidesk.manga.impl.download.DownloadManager
|
import suwayomi.tachidesk.manga.impl.download.DownloadManager
|
||||||
import suwayomi.tachidesk.manga.impl.download.DownloadManager.EnqueueInput
|
import suwayomi.tachidesk.manga.impl.download.DownloadManager.EnqueueInput
|
||||||
import suwayomi.tachidesk.manga.impl.track.Track
|
import suwayomi.tachidesk.manga.impl.track.Track
|
||||||
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrStub
|
import suwayomi.tachidesk.manga.impl.util.source.GetSource.getSourceOrStub
|
||||||
import suwayomi.tachidesk.manga.model.dataclass.ChapterDataClass
|
import suwayomi.tachidesk.manga.model.dataclass.ChapterDataClass
|
||||||
import suwayomi.tachidesk.manga.model.dataclass.MangaChapterDataClass
|
import suwayomi.tachidesk.manga.model.dataclass.MangaChapterDataClass
|
||||||
import suwayomi.tachidesk.manga.model.dataclass.PaginatedList
|
import suwayomi.tachidesk.manga.model.dataclass.PaginatedList
|
||||||
@@ -50,7 +52,6 @@ import suwayomi.tachidesk.server.serverConfig
|
|||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.util.TreeSet
|
import java.util.TreeSet
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
import kotlin.time.Duration.Companion.minutes
|
|
||||||
|
|
||||||
private fun List<ChapterDataClass>.removeDuplicates(currentChapter: ChapterDataClass): List<ChapterDataClass> =
|
private fun List<ChapterDataClass>.removeDuplicates(currentChapter: ChapterDataClass): List<ChapterDataClass> =
|
||||||
groupBy { it.chapterNumber }
|
groupBy { it.chapterNumber }
|
||||||
@@ -104,267 +105,277 @@ object Chapter {
|
|||||||
.associateBy({ it[ChapterTable.url] }, { it })
|
.associateBy({ it[ChapterTable.url] }, { it })
|
||||||
}
|
}
|
||||||
|
|
||||||
return chapterList.mapIndexed { index, it ->
|
return chapterList.map {
|
||||||
|
|
||||||
val dbChapter = dbChapterMap.getValue(it.url)
|
val dbChapter = dbChapterMap.getValue(it.url)
|
||||||
|
ChapterTable.toDataClass(dbChapter)
|
||||||
ChapterDataClass(
|
|
||||||
id = dbChapter[ChapterTable.id].value,
|
|
||||||
url = it.url,
|
|
||||||
name = it.name,
|
|
||||||
uploadDate = it.date_upload,
|
|
||||||
chapterNumber = it.chapter_number,
|
|
||||||
scanlator = it.scanlator,
|
|
||||||
mangaId = mangaId,
|
|
||||||
read = dbChapter[ChapterTable.isRead],
|
|
||||||
bookmarked = dbChapter[ChapterTable.isBookmarked],
|
|
||||||
lastPageRead = dbChapter[ChapterTable.lastPageRead],
|
|
||||||
lastReadAt = dbChapter[ChapterTable.lastReadAt],
|
|
||||||
index = chapterList.size - index,
|
|
||||||
fetchedAt = dbChapter[ChapterTable.fetchedAt],
|
|
||||||
realUrl = dbChapter[ChapterTable.realUrl],
|
|
||||||
downloaded = dbChapter[ChapterTable.isDownloaded],
|
|
||||||
pageCount = dbChapter[ChapterTable.pageCount],
|
|
||||||
lastModifiedAt = dbChapter[ChapterTable.lastModifiedAt],
|
|
||||||
version = dbChapter[ChapterTable.version],
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val map: Cache<Int, Mutex> =
|
|
||||||
Cache
|
|
||||||
.Builder<Int, Mutex>()
|
|
||||||
.expireAfterAccess(10.minutes)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
suspend fun fetchChapterList(mangaId: Int): List<SChapter> {
|
suspend fun fetchChapterList(mangaId: Int): List<SChapter> {
|
||||||
val mutex = map.get(mangaId) { Mutex() }
|
val mutex = Manga.mangaInfoMutex.get(mangaId) { Mutex() }
|
||||||
val chapterList =
|
val chapterList =
|
||||||
mutex.withLock {
|
mutex.withLock {
|
||||||
val manga = getManga(mangaId)
|
val mangaEntry =
|
||||||
val source = getCatalogueSourceOrStub(manga.sourceId.toLong())
|
|
||||||
|
|
||||||
val sManga =
|
|
||||||
SManga.create().apply {
|
|
||||||
title = manga.title
|
|
||||||
url = manga.url
|
|
||||||
description = manga.description
|
|
||||||
}
|
|
||||||
|
|
||||||
val currentLatestChapterNumber = Manga.getLatestChapter(mangaId)?.chapterNumber ?: 0f
|
|
||||||
val numberOfCurrentChapters = getCountOfMangaChapters(mangaId)
|
|
||||||
|
|
||||||
val chapters = source.getChapterList(sManga)
|
|
||||||
// it's possible that the source returns a list containing chapters with the same url
|
|
||||||
// once such duplicated chapters have been added, they aren't being removed anymore as long as there is
|
|
||||||
// a chapter with the same url in the fetched chapter list, even if the duplicated chapter itself
|
|
||||||
// does not exist anymore on the source
|
|
||||||
val uniqueChapters = chapters.distinctBy { it.url }
|
|
||||||
|
|
||||||
if (uniqueChapters.isEmpty()) {
|
|
||||||
throw Exception("No chapters found")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recognize number for new chapters.
|
|
||||||
uniqueChapters.forEach { chapter ->
|
|
||||||
(source as? HttpSource)?.prepareNewChapter(chapter, sManga)
|
|
||||||
val chapterNumber = ChapterRecognition.parseChapterNumber(manga.title, chapter.name, chapter.chapter_number.toDouble())
|
|
||||||
chapter.chapter_number = chapterNumber.toFloat()
|
|
||||||
chapter.name = chapter.name.sanitize(manga.title)
|
|
||||||
chapter.scanlator = chapter.scanlator?.ifBlank { null }?.trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
val now = Instant.now().epochSecond
|
|
||||||
// Used to not set upload date of older chapters
|
|
||||||
// to a higher value than newer chapters
|
|
||||||
var maxSeenUploadDate = 0L
|
|
||||||
|
|
||||||
val chaptersInDb =
|
|
||||||
transaction {
|
transaction {
|
||||||
ChapterTable
|
MangaTable.selectAll().where { MangaTable.id eq mangaId }.first()
|
||||||
.selectAll()
|
|
||||||
.where { ChapterTable.manga eq mangaId }
|
|
||||||
.map { ChapterTable.toDataClass(it) }
|
|
||||||
.toList()
|
|
||||||
}
|
}
|
||||||
|
val source = getSourceOrStub(mangaEntry[MangaTable.sourceReference])
|
||||||
|
|
||||||
// new chapters after they have been added to the database for auto downloads
|
val chapters =
|
||||||
val insertedChapterIds = mutableListOf<Int>()
|
Manga
|
||||||
|
.fetchMangaAndChapters(
|
||||||
|
mangaEntry = mangaEntry,
|
||||||
|
source = source,
|
||||||
|
fetchDetails = false,
|
||||||
|
fetchChapters = true,
|
||||||
|
).chapters
|
||||||
|
|
||||||
val chaptersToInsert = mutableListOf<ChapterDataClass>() // do not yet have an ID from the database
|
updateChapterListDatabase(mangaEntry, chapters, source)
|
||||||
val chaptersToUpdate = mutableListOf<ChapterDataClass>()
|
|
||||||
|
|
||||||
uniqueChapters.reversed().forEachIndexed { index, fetchedChapter ->
|
|
||||||
val chapterEntry = chaptersInDb.find { it.url == fetchedChapter.url }
|
|
||||||
|
|
||||||
val chapterData =
|
|
||||||
ChapterDataClass.fromSChapter(
|
|
||||||
fetchedChapter,
|
|
||||||
chapterEntry?.id ?: 0,
|
|
||||||
index + 1,
|
|
||||||
now,
|
|
||||||
mangaId,
|
|
||||||
runCatching {
|
|
||||||
(source as? HttpSource)?.getChapterUrl(fetchedChapter)
|
|
||||||
}.getOrNull(),
|
|
||||||
)
|
|
||||||
|
|
||||||
if (chapterEntry == null) {
|
|
||||||
val newChapterData =
|
|
||||||
if (chapterData.uploadDate == 0L) {
|
|
||||||
val altDateUpload = if (maxSeenUploadDate == 0L) now else maxSeenUploadDate
|
|
||||||
chapterData.copy(uploadDate = altDateUpload)
|
|
||||||
} else {
|
|
||||||
maxSeenUploadDate = max(maxSeenUploadDate, chapterData.uploadDate)
|
|
||||||
chapterData
|
|
||||||
}
|
|
||||||
chaptersToInsert.add(newChapterData)
|
|
||||||
} else {
|
|
||||||
val newChapterData =
|
|
||||||
if (chapterData.uploadDate == 0L) {
|
|
||||||
chapterData.copy(uploadDate = chapterEntry.uploadDate)
|
|
||||||
} else {
|
|
||||||
chapterData
|
|
||||||
}
|
|
||||||
chaptersToUpdate.add(newChapterData)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val deletedChapterNumbers = TreeSet<Float>()
|
|
||||||
val deletedReadChapterNumbers = TreeSet<Float>()
|
|
||||||
val deletedBookmarkedChapterNumbers = TreeSet<Float>()
|
|
||||||
val deletedDownloadedChapterNumberToChapter = mutableMapOf<Float, ChapterDataClass>()
|
|
||||||
val deletedChapterNumberDateFetchMap = mutableMapOf<Float, Long>()
|
|
||||||
|
|
||||||
// clear any orphaned/duplicate chapters that are in the db but not in `chapterList`
|
|
||||||
val chapterUrls = uniqueChapters.map { it.url }.toSet()
|
|
||||||
|
|
||||||
val chaptersIdsToDelete =
|
|
||||||
chaptersInDb.mapNotNull { dbChapter ->
|
|
||||||
if (!chapterUrls.contains(dbChapter.url)) {
|
|
||||||
if (dbChapter.read) deletedReadChapterNumbers.add(dbChapter.chapterNumber)
|
|
||||||
if (dbChapter.bookmarked) deletedBookmarkedChapterNumbers.add(dbChapter.chapterNumber)
|
|
||||||
if (dbChapter.downloaded) deletedDownloadedChapterNumberToChapter[dbChapter.chapterNumber] = dbChapter
|
|
||||||
deletedChapterNumbers.add(dbChapter.chapterNumber)
|
|
||||||
deletedChapterNumberDateFetchMap[dbChapter.chapterNumber] = dbChapter.fetchedAt
|
|
||||||
dbChapter.id
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
transaction {
|
|
||||||
// we got some clean up due
|
|
||||||
if (chaptersIdsToDelete.isNotEmpty()) {
|
|
||||||
DownloadManager.dequeue(chaptersIdsToDelete)
|
|
||||||
PageTable.deleteWhere { chapter inList chaptersIdsToDelete }
|
|
||||||
ChapterTable.deleteWhere { id inList chaptersIdsToDelete }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (chaptersToInsert.isNotEmpty()) {
|
|
||||||
ChapterTable
|
|
||||||
.batchInsert(chaptersToInsert) { chapter ->
|
|
||||||
this[ChapterTable.url] = chapter.url
|
|
||||||
this[ChapterTable.name] = chapter.name
|
|
||||||
this[ChapterTable.date_upload] = chapter.uploadDate
|
|
||||||
this[ChapterTable.chapter_number] = chapter.chapterNumber
|
|
||||||
this[ChapterTable.scanlator] = chapter.scanlator
|
|
||||||
this[ChapterTable.sourceOrder] = chapter.index
|
|
||||||
this[ChapterTable.fetchedAt] = chapter.fetchedAt
|
|
||||||
this[ChapterTable.manga] = chapter.mangaId
|
|
||||||
this[ChapterTable.realUrl] = chapter.realUrl
|
|
||||||
this[ChapterTable.isRead] = false
|
|
||||||
this[ChapterTable.isBookmarked] = false
|
|
||||||
this[ChapterTable.isDownloaded] = false
|
|
||||||
this[ChapterTable.lastModifiedAt] = chapter.lastModifiedAt
|
|
||||||
this[ChapterTable.version] = chapter.version
|
|
||||||
this[ChapterTable.pageCount] = -1
|
|
||||||
|
|
||||||
// is recognized chapter number
|
|
||||||
if (chapter.chapterNumber >= 0f && chapter.chapterNumber in deletedChapterNumbers) {
|
|
||||||
this[ChapterTable.isRead] = chapter.chapterNumber in deletedReadChapterNumbers
|
|
||||||
this[ChapterTable.isBookmarked] = chapter.chapterNumber in deletedBookmarkedChapterNumbers
|
|
||||||
|
|
||||||
// Try to use the fetch date of the original entry to not pollute 'Updates' tab
|
|
||||||
deletedChapterNumberDateFetchMap[chapter.chapterNumber]?.let {
|
|
||||||
this[ChapterTable.fetchedAt] = it
|
|
||||||
}
|
|
||||||
|
|
||||||
deletedDownloadedChapterNumberToChapter[chapter.chapterNumber]?.let {
|
|
||||||
val hasDownloadedPages = it.pageCount > 0
|
|
||||||
val isSameName = it.name == chapter.name
|
|
||||||
val isSameScanlator = it.scanlator == chapter.scanlator
|
|
||||||
|
|
||||||
// Only preserve download status for chapters with the same name and of the same scanlator; otherwise,
|
|
||||||
// the downloaded files won't be found anyway
|
|
||||||
val isDownloadPreservable = hasDownloadedPages && isSameName && isSameScanlator
|
|
||||||
if (isDownloadPreservable) {
|
|
||||||
this[ChapterTable.isDownloaded] = true
|
|
||||||
this[ChapterTable.pageCount] = it.pageCount
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}.forEach { insertedChapterIds.add(it[ChapterTable.id].value) }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (chaptersToUpdate.isNotEmpty()) {
|
|
||||||
BatchUpdateStatement(ChapterTable)
|
|
||||||
.apply {
|
|
||||||
chaptersToUpdate.forEach {
|
|
||||||
addBatch(EntityID(it.id, ChapterTable))
|
|
||||||
|
|
||||||
val currentChapter = chaptersInDb.find { dbChapter -> dbChapter.id == it.id }!!
|
|
||||||
|
|
||||||
this[ChapterTable.name] = it.name
|
|
||||||
this[ChapterTable.date_upload] = it.uploadDate
|
|
||||||
this[ChapterTable.chapter_number] = it.chapterNumber
|
|
||||||
this[ChapterTable.scanlator] = it.scanlator
|
|
||||||
this[ChapterTable.sourceOrder] = it.index
|
|
||||||
this[ChapterTable.realUrl] = it.realUrl
|
|
||||||
this[ChapterTable.lastModifiedAt] = it.lastModifiedAt
|
|
||||||
this[ChapterTable.version] = it.version
|
|
||||||
this[ChapterTable.isDownloaded] = currentChapter.downloaded
|
|
||||||
this[ChapterTable.pageCount] = currentChapter.pageCount
|
|
||||||
|
|
||||||
if (!currentChapter.downloaded) {
|
|
||||||
return@forEach
|
|
||||||
}
|
|
||||||
|
|
||||||
val isSameScanlator = currentChapter.scanlator == it.scanlator
|
|
||||||
val isSameName = currentChapter.name == it.name
|
|
||||||
|
|
||||||
val isDownloadPreservable = isSameName && isSameScanlator
|
|
||||||
if (!isDownloadPreservable) {
|
|
||||||
this[ChapterTable.isDownloaded] = false
|
|
||||||
this[ChapterTable.pageCount] = -1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}.toExecutable()
|
|
||||||
.execute(this@transaction)
|
|
||||||
}
|
|
||||||
|
|
||||||
MangaTable.update({ MangaTable.id eq mangaId }) {
|
|
||||||
it[chaptersLastFetchedAt] = Instant.now().epochSecond
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (manga.inLibrary) {
|
|
||||||
// We have to query the inserted chapters to get the up-to-date data. I.e. "last_modified_at" is not returned by the insert statement, due to being set by a DB trigger
|
|
||||||
val insertedChapters =
|
|
||||||
transaction {
|
|
||||||
ChapterTable.selectAll().where { ChapterTable.id inList insertedChapterIds }.map(
|
|
||||||
ChapterTable::toDataClass,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
downloadNewChapters(mangaId, currentLatestChapterNumber, numberOfCurrentChapters, insertedChapters)
|
|
||||||
}
|
|
||||||
|
|
||||||
uniqueChapters
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return chapterList
|
return chapterList
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun updateChapterListDatabase(
|
||||||
|
mangaEntry: ResultRow,
|
||||||
|
chapters: List<SChapter>,
|
||||||
|
source: Source,
|
||||||
|
): List<SChapter> {
|
||||||
|
val currentLatestChapterNumber = Manga.getLatestChapter(mangaEntry[MangaTable.id].value)?.chapterNumber ?: 0f
|
||||||
|
val numberOfCurrentChapters = getCountOfMangaChapters(mangaEntry[MangaTable.id].value)
|
||||||
|
// it's possible that the source returns a list containing chapters with the same url
|
||||||
|
// once such duplicated chapters have been added, they aren't being removed anymore as long as there is
|
||||||
|
// a chapter with the same url in the fetched chapter list, even if the duplicated chapter itself
|
||||||
|
// does not exist anymore on the source
|
||||||
|
val uniqueChapters = chapters.distinctBy { it.url }
|
||||||
|
|
||||||
|
if (uniqueChapters.isEmpty()) {
|
||||||
|
throw Exception("No chapters found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recognize number for new chapters.
|
||||||
|
val sManga =
|
||||||
|
SManga.create().apply {
|
||||||
|
url = mangaEntry[MangaTable.url]
|
||||||
|
title = mangaEntry[MangaTable.title]
|
||||||
|
thumbnail_url = mangaEntry[MangaTable.thumbnail_url]
|
||||||
|
artist = mangaEntry[MangaTable.artist]
|
||||||
|
author = mangaEntry[MangaTable.author]
|
||||||
|
description = mangaEntry[MangaTable.description]
|
||||||
|
genre = mangaEntry[MangaTable.genre]
|
||||||
|
status = mangaEntry[MangaTable.status]
|
||||||
|
update_strategy = UpdateStrategy.valueOf(mangaEntry[MangaTable.updateStrategy])
|
||||||
|
memo = Json.decodeFromString(mangaEntry[MangaTable.memo])
|
||||||
|
initialized = mangaEntry[MangaTable.initialized]
|
||||||
|
}
|
||||||
|
uniqueChapters.forEach { chapter ->
|
||||||
|
(source as? HttpSource)?.prepareNewChapter(chapter, sManga)
|
||||||
|
val chapterNumber =
|
||||||
|
ChapterRecognition.parseChapterNumber(
|
||||||
|
mangaEntry[MangaTable.title],
|
||||||
|
chapter.name,
|
||||||
|
chapter.chapter_number.toDouble(),
|
||||||
|
)
|
||||||
|
chapter.chapter_number = chapterNumber.toFloat()
|
||||||
|
chapter.name = chapter.name.sanitize(mangaEntry[MangaTable.title])
|
||||||
|
chapter.scanlator = chapter.scanlator?.ifBlank { null }?.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
val now = Instant.now().epochSecond
|
||||||
|
// Used to not set upload date of older chapters
|
||||||
|
// to a higher value than newer chapters
|
||||||
|
var maxSeenUploadDate = 0L
|
||||||
|
|
||||||
|
val chaptersInDb =
|
||||||
|
transaction {
|
||||||
|
ChapterTable
|
||||||
|
.selectAll()
|
||||||
|
.where { ChapterTable.manga eq mangaEntry[MangaTable.id].value }
|
||||||
|
.map { ChapterTable.toDataClass(it) }
|
||||||
|
.toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
// new chapters after they have been added to the database for auto downloads
|
||||||
|
val insertedChapterIds = mutableListOf<Int>()
|
||||||
|
|
||||||
|
val chaptersToInsert = mutableListOf<ChapterDataClass>() // do not yet have an ID from the database
|
||||||
|
val chaptersToUpdate = mutableListOf<ChapterDataClass>()
|
||||||
|
|
||||||
|
uniqueChapters.reversed().forEachIndexed { index, fetchedChapter ->
|
||||||
|
val chapterEntry = chaptersInDb.find { it.url == fetchedChapter.url }
|
||||||
|
|
||||||
|
val chapterData =
|
||||||
|
ChapterDataClass.fromSChapter(
|
||||||
|
fetchedChapter,
|
||||||
|
chapterEntry?.id ?: 0,
|
||||||
|
index + 1,
|
||||||
|
now,
|
||||||
|
mangaEntry[MangaTable.id].value,
|
||||||
|
runCatching {
|
||||||
|
(source as? HttpSource)?.getChapterUrl(fetchedChapter)
|
||||||
|
}.getOrNull(),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (chapterEntry == null) {
|
||||||
|
val newChapterData =
|
||||||
|
if (chapterData.uploadDate == 0L) {
|
||||||
|
val altDateUpload = if (maxSeenUploadDate == 0L) now else maxSeenUploadDate
|
||||||
|
chapterData.copy(uploadDate = altDateUpload)
|
||||||
|
} else {
|
||||||
|
maxSeenUploadDate = max(maxSeenUploadDate, chapterData.uploadDate)
|
||||||
|
chapterData
|
||||||
|
}
|
||||||
|
chaptersToInsert.add(newChapterData)
|
||||||
|
} else {
|
||||||
|
val newChapterData =
|
||||||
|
if (chapterData.uploadDate == 0L) {
|
||||||
|
chapterData.copy(uploadDate = chapterEntry.uploadDate)
|
||||||
|
} else {
|
||||||
|
chapterData
|
||||||
|
}
|
||||||
|
chaptersToUpdate.add(newChapterData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val deletedChapterNumbers = TreeSet<Float>()
|
||||||
|
val deletedReadChapterNumbers = TreeSet<Float>()
|
||||||
|
val deletedBookmarkedChapterNumbers = TreeSet<Float>()
|
||||||
|
val deletedDownloadedChapterNumberToChapter = mutableMapOf<Float, ChapterDataClass>()
|
||||||
|
val deletedChapterNumberDateFetchMap = mutableMapOf<Float, Long>()
|
||||||
|
|
||||||
|
// clear any orphaned/duplicate chapters that are in the db but not in `chapterList`
|
||||||
|
val chapterUrls = uniqueChapters.map { it.url }.toSet()
|
||||||
|
|
||||||
|
val chaptersIdsToDelete =
|
||||||
|
chaptersInDb.mapNotNull { dbChapter ->
|
||||||
|
if (!chapterUrls.contains(dbChapter.url)) {
|
||||||
|
if (dbChapter.read) deletedReadChapterNumbers.add(dbChapter.chapterNumber)
|
||||||
|
if (dbChapter.bookmarked) deletedBookmarkedChapterNumbers.add(dbChapter.chapterNumber)
|
||||||
|
if (dbChapter.downloaded) deletedDownloadedChapterNumberToChapter[dbChapter.chapterNumber] = dbChapter
|
||||||
|
deletedChapterNumbers.add(dbChapter.chapterNumber)
|
||||||
|
deletedChapterNumberDateFetchMap[dbChapter.chapterNumber] = dbChapter.fetchedAt
|
||||||
|
dbChapter.id
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
transaction {
|
||||||
|
// we got some clean up due
|
||||||
|
if (chaptersIdsToDelete.isNotEmpty()) {
|
||||||
|
DownloadManager.dequeue(chaptersIdsToDelete)
|
||||||
|
PageTable.deleteWhere { chapter inList chaptersIdsToDelete }
|
||||||
|
ChapterTable.deleteWhere { id inList chaptersIdsToDelete }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chaptersToInsert.isNotEmpty()) {
|
||||||
|
ChapterTable
|
||||||
|
.batchInsert(chaptersToInsert) { chapter ->
|
||||||
|
this[ChapterTable.url] = chapter.url
|
||||||
|
this[ChapterTable.name] = chapter.name
|
||||||
|
this[ChapterTable.date_upload] = chapter.uploadDate
|
||||||
|
this[ChapterTable.chapter_number] = chapter.chapterNumber
|
||||||
|
this[ChapterTable.scanlator] = chapter.scanlator
|
||||||
|
this[ChapterTable.sourceOrder] = chapter.index
|
||||||
|
this[ChapterTable.fetchedAt] = chapter.fetchedAt
|
||||||
|
this[ChapterTable.manga] = chapter.mangaId
|
||||||
|
this[ChapterTable.realUrl] = chapter.realUrl
|
||||||
|
this[ChapterTable.memo] = Json.encodeToString(chapter.memo)
|
||||||
|
this[ChapterTable.isRead] = false
|
||||||
|
this[ChapterTable.isBookmarked] = false
|
||||||
|
this[ChapterTable.isDownloaded] = false
|
||||||
|
this[ChapterTable.lastModifiedAt] = chapter.lastModifiedAt
|
||||||
|
this[ChapterTable.version] = chapter.version
|
||||||
|
this[ChapterTable.pageCount] = -1
|
||||||
|
|
||||||
|
// is recognized chapter number
|
||||||
|
if (chapter.chapterNumber >= 0f && chapter.chapterNumber in deletedChapterNumbers) {
|
||||||
|
this[ChapterTable.isRead] = chapter.chapterNumber in deletedReadChapterNumbers
|
||||||
|
this[ChapterTable.isBookmarked] = chapter.chapterNumber in deletedBookmarkedChapterNumbers
|
||||||
|
|
||||||
|
// Try to use the fetch date of the original entry to not pollute 'Updates' tab
|
||||||
|
deletedChapterNumberDateFetchMap[chapter.chapterNumber]?.let {
|
||||||
|
this[ChapterTable.fetchedAt] = it
|
||||||
|
}
|
||||||
|
|
||||||
|
deletedDownloadedChapterNumberToChapter[chapter.chapterNumber]?.let {
|
||||||
|
val hasDownloadedPages = it.pageCount > 0
|
||||||
|
val isSameName = it.name == chapter.name
|
||||||
|
val isSameScanlator = it.scanlator == chapter.scanlator
|
||||||
|
|
||||||
|
// Only preserve download status for chapters with the same name and of the same scanlator; otherwise,
|
||||||
|
// the downloaded files won't be found anyway
|
||||||
|
val isDownloadPreservable = hasDownloadedPages && isSameName && isSameScanlator
|
||||||
|
if (isDownloadPreservable) {
|
||||||
|
this[ChapterTable.isDownloaded] = true
|
||||||
|
this[ChapterTable.pageCount] = it.pageCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.forEach { insertedChapterIds.add(it[ChapterTable.id].value) }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chaptersToUpdate.isNotEmpty()) {
|
||||||
|
BatchUpdateStatement(ChapterTable)
|
||||||
|
.apply {
|
||||||
|
chaptersToUpdate.forEach {
|
||||||
|
addBatch(EntityID(it.id, ChapterTable))
|
||||||
|
|
||||||
|
val currentChapter = chaptersInDb.find { dbChapter -> dbChapter.id == it.id }!!
|
||||||
|
|
||||||
|
this[ChapterTable.name] = it.name
|
||||||
|
this[ChapterTable.date_upload] = it.uploadDate
|
||||||
|
this[ChapterTable.chapter_number] = it.chapterNumber
|
||||||
|
this[ChapterTable.scanlator] = it.scanlator
|
||||||
|
this[ChapterTable.sourceOrder] = it.index
|
||||||
|
this[ChapterTable.realUrl] = it.realUrl
|
||||||
|
this[ChapterTable.lastModifiedAt] = it.lastModifiedAt
|
||||||
|
this[ChapterTable.version] = it.version
|
||||||
|
this[ChapterTable.memo] = Json.encodeToString(it.memo)
|
||||||
|
this[ChapterTable.isDownloaded] = currentChapter.downloaded
|
||||||
|
this[ChapterTable.pageCount] = currentChapter.pageCount
|
||||||
|
|
||||||
|
if (!currentChapter.downloaded) {
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
|
||||||
|
val isSameScanlator = currentChapter.scanlator == it.scanlator
|
||||||
|
val isSameName = currentChapter.name == it.name
|
||||||
|
|
||||||
|
val isDownloadPreservable = isSameName && isSameScanlator
|
||||||
|
if (!isDownloadPreservable) {
|
||||||
|
this[ChapterTable.isDownloaded] = false
|
||||||
|
this[ChapterTable.pageCount] = -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.toExecutable()
|
||||||
|
.execute(this@transaction)
|
||||||
|
}
|
||||||
|
|
||||||
|
MangaTable.update({ MangaTable.id eq mangaEntry[MangaTable.id].value }) {
|
||||||
|
it[chaptersLastFetchedAt] = Instant.now().epochSecond
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mangaEntry[MangaTable.inLibrary]) {
|
||||||
|
// We have to query the inserted chapters to get the up-to-date data. I.e. "last_modified_at" is not returned by the insert statement, due to being set by a DB trigger
|
||||||
|
val insertedChapters =
|
||||||
|
transaction {
|
||||||
|
ChapterTable.selectAll().where { ChapterTable.id inList insertedChapterIds }.map(
|
||||||
|
ChapterTable::toDataClass,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
downloadNewChapters(
|
||||||
|
mangaEntry[MangaTable.id].value,
|
||||||
|
currentLatestChapterNumber,
|
||||||
|
numberOfCurrentChapters,
|
||||||
|
insertedChapters,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return uniqueChapters
|
||||||
|
}
|
||||||
|
|
||||||
private fun downloadNewChapters(
|
private fun downloadNewChapters(
|
||||||
mangaId: Int,
|
mangaId: Int,
|
||||||
prevLatestChapterNumber: Float,
|
prevLatestChapterNumber: Float,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import suwayomi.tachidesk.manga.model.table.ChapterTable
|
|||||||
import suwayomi.tachidesk.manga.model.table.MangaTable
|
import suwayomi.tachidesk.manga.model.table.MangaTable
|
||||||
import suwayomi.tachidesk.manga.model.table.toDataClass
|
import suwayomi.tachidesk.manga.model.table.toDataClass
|
||||||
import suwayomi.tachidesk.server.serverConfig
|
import suwayomi.tachidesk.server.serverConfig
|
||||||
|
import xyz.nulldev.androidcompat.util.SafePath
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
|
|
||||||
@@ -76,13 +77,46 @@ object ChapterDownloadHelper {
|
|||||||
.select(ChapterTable.columns + MangaTable.columns)
|
.select(ChapterTable.columns + MangaTable.columns)
|
||||||
.where { ChapterTable.id eq chapterId }
|
.where { ChapterTable.id eq chapterId }
|
||||||
.firstOrNull() ?: throw IllegalArgumentException("ChapterId $chapterId not found")
|
.firstOrNull() ?: throw IllegalArgumentException("ChapterId $chapterId not found")
|
||||||
|
|
||||||
val chapter = ChapterTable.toDataClass(row)
|
val chapter = ChapterTable.toDataClass(row)
|
||||||
val mangaTitle = row[MangaTable.title]
|
val mangaTitle = row[MangaTable.title].trim()
|
||||||
|
|
||||||
val scanlatorPart = chapter.scanlator?.let { "[$it] " } ?: ""
|
val scanlatorName = chapter.scanlator?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
val fileName = "$mangaTitle - $scanlatorPart${chapter.name}.cbz"
|
val chapterName = chapter.name.trim().takeIf { it.isNotEmpty() }
|
||||||
|
|
||||||
Pair(chapter, fileName)
|
val fileName =
|
||||||
|
buildString {
|
||||||
|
append(mangaTitle)
|
||||||
|
append(" - ")
|
||||||
|
|
||||||
|
if (chapterName != null) {
|
||||||
|
append(chapterName)
|
||||||
|
} else if (chapter.chapterNumber >= 0f) {
|
||||||
|
// chapterNumber is stored as Float, drop .0 for whole numbers.
|
||||||
|
val formatNumber =
|
||||||
|
if (chapter.chapterNumber % 1 == 0f) {
|
||||||
|
chapter.chapterNumber.toInt().toString()
|
||||||
|
} else {
|
||||||
|
chapter.chapterNumber.toString()
|
||||||
|
}
|
||||||
|
append("#$formatNumber")
|
||||||
|
} else {
|
||||||
|
// Fallback when neither name nor valid chapter number exists
|
||||||
|
append("#${chapter.index}")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scanlatorName != null) {
|
||||||
|
append(" [")
|
||||||
|
append(scanlatorName)
|
||||||
|
append("]")
|
||||||
|
}
|
||||||
|
append(".cbz")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize filename for OS compatibility
|
||||||
|
val safeFileName = SafePath.buildValidFilename(fileName)
|
||||||
|
|
||||||
|
Pair(chapter, safeFileName)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getCbzForDownload(
|
fun getCbzForDownload(
|
||||||
|
|||||||
@@ -11,13 +11,20 @@ import eu.kanade.tachiyomi.network.GET
|
|||||||
import eu.kanade.tachiyomi.network.HttpException
|
import eu.kanade.tachiyomi.network.HttpException
|
||||||
import eu.kanade.tachiyomi.network.NetworkHelper
|
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||||
import eu.kanade.tachiyomi.network.awaitSuccess
|
import eu.kanade.tachiyomi.network.awaitSuccess
|
||||||
|
import eu.kanade.tachiyomi.source.Source
|
||||||
import eu.kanade.tachiyomi.source.local.LocalSource
|
import eu.kanade.tachiyomi.source.local.LocalSource
|
||||||
|
import eu.kanade.tachiyomi.source.model.SChapter
|
||||||
import eu.kanade.tachiyomi.source.model.SManga
|
import eu.kanade.tachiyomi.source.model.SManga
|
||||||
|
import eu.kanade.tachiyomi.source.model.SMangaUpdate
|
||||||
import eu.kanade.tachiyomi.source.model.UpdateStrategy
|
import eu.kanade.tachiyomi.source.model.UpdateStrategy
|
||||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||||
import io.github.oshai.kotlinlogging.KLogger
|
import io.github.oshai.kotlinlogging.KLogger
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
|
import io.github.reactivecircus.cache4k.Cache
|
||||||
import io.javalin.http.HttpStatus
|
import io.javalin.http.HttpStatus
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
import okhttp3.CacheControl
|
import okhttp3.CacheControl
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import org.jetbrains.exposed.v1.core.ResultRow
|
import org.jetbrains.exposed.v1.core.ResultRow
|
||||||
@@ -32,13 +39,10 @@ import org.jetbrains.exposed.v1.jdbc.selectAll
|
|||||||
import org.jetbrains.exposed.v1.jdbc.statements.toExecutable
|
import org.jetbrains.exposed.v1.jdbc.statements.toExecutable
|
||||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||||
import org.jetbrains.exposed.v1.jdbc.update
|
import org.jetbrains.exposed.v1.jdbc.update
|
||||||
import suwayomi.tachidesk.manga.impl.MangaList.proxyThumbnailUrl
|
|
||||||
import suwayomi.tachidesk.manga.impl.Source.getSource
|
|
||||||
import suwayomi.tachidesk.manga.impl.download.fileProvider.impl.MissingThumbnailException
|
import suwayomi.tachidesk.manga.impl.download.fileProvider.impl.MissingThumbnailException
|
||||||
import suwayomi.tachidesk.manga.impl.track.Track
|
|
||||||
import suwayomi.tachidesk.manga.impl.util.network.await
|
import suwayomi.tachidesk.manga.impl.util.network.await
|
||||||
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrNull
|
import suwayomi.tachidesk.manga.impl.util.source.GetSource.getSourceOrNull
|
||||||
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrStub
|
import suwayomi.tachidesk.manga.impl.util.source.GetSource.getSourceOrStub
|
||||||
import suwayomi.tachidesk.manga.impl.util.source.StubSource
|
import suwayomi.tachidesk.manga.impl.util.source.StubSource
|
||||||
import suwayomi.tachidesk.manga.impl.util.storage.ImageResponse.clearCachedImage
|
import suwayomi.tachidesk.manga.impl.util.storage.ImageResponse.clearCachedImage
|
||||||
import suwayomi.tachidesk.manga.impl.util.storage.ImageResponse.getImageResponse
|
import suwayomi.tachidesk.manga.impl.util.storage.ImageResponse.getImageResponse
|
||||||
@@ -47,10 +51,8 @@ import suwayomi.tachidesk.manga.impl.util.updateMangaDownloadDir
|
|||||||
import suwayomi.tachidesk.manga.model.dataclass.ChapterDataClass
|
import suwayomi.tachidesk.manga.model.dataclass.ChapterDataClass
|
||||||
import suwayomi.tachidesk.manga.model.dataclass.IncludeOrExclude
|
import suwayomi.tachidesk.manga.model.dataclass.IncludeOrExclude
|
||||||
import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass
|
import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass
|
||||||
import suwayomi.tachidesk.manga.model.dataclass.toGenreList
|
|
||||||
import suwayomi.tachidesk.manga.model.table.ChapterTable
|
import suwayomi.tachidesk.manga.model.table.ChapterTable
|
||||||
import suwayomi.tachidesk.manga.model.table.MangaMetaTable
|
import suwayomi.tachidesk.manga.model.table.MangaMetaTable
|
||||||
import suwayomi.tachidesk.manga.model.table.MangaStatus
|
|
||||||
import suwayomi.tachidesk.manga.model.table.MangaTable
|
import suwayomi.tachidesk.manga.model.table.MangaTable
|
||||||
import suwayomi.tachidesk.manga.model.table.toDataClass
|
import suwayomi.tachidesk.manga.model.table.toDataClass
|
||||||
import suwayomi.tachidesk.server.ApplicationDirs
|
import suwayomi.tachidesk.server.ApplicationDirs
|
||||||
@@ -59,10 +61,17 @@ import java.io.File
|
|||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
import kotlin.time.Duration.Companion.minutes
|
||||||
|
|
||||||
private val logger = KotlinLogging.logger { }
|
private val logger = KotlinLogging.logger { }
|
||||||
|
|
||||||
object Manga {
|
object Manga {
|
||||||
|
val mangaInfoMutex: Cache<Int, Mutex> =
|
||||||
|
Cache
|
||||||
|
.Builder<Int, Mutex>()
|
||||||
|
.expireAfterAccess(10.minutes)
|
||||||
|
.build()
|
||||||
|
|
||||||
suspend fun getManga(
|
suspend fun getManga(
|
||||||
mangaId: Int,
|
mangaId: Int,
|
||||||
onlineFetch: Boolean = false,
|
onlineFetch: Boolean = false,
|
||||||
@@ -70,63 +79,118 @@ object Manga {
|
|||||||
var mangaEntry = transaction { MangaTable.selectAll().where { MangaTable.id eq mangaId }.first() }
|
var mangaEntry = transaction { MangaTable.selectAll().where { MangaTable.id eq mangaId }.first() }
|
||||||
|
|
||||||
return if (!onlineFetch && mangaEntry[MangaTable.initialized]) {
|
return if (!onlineFetch && mangaEntry[MangaTable.initialized]) {
|
||||||
getMangaDataClass(mangaId, mangaEntry)
|
MangaTable.toDataClass(mangaEntry)
|
||||||
} else { // initialize manga
|
} else { // initialize manga
|
||||||
val sManga = fetchManga(mangaId) ?: return getMangaDataClass(mangaId, mangaEntry)
|
updateMangaAndChapters(mangaId, updateChapters = false)
|
||||||
|
|
||||||
mangaEntry = transaction { MangaTable.selectAll().where { MangaTable.id eq mangaId }.first() }
|
mangaEntry = transaction { MangaTable.selectAll().where { MangaTable.id eq mangaId }.first() }
|
||||||
|
|
||||||
MangaDataClass(
|
MangaTable.toDataClass(mangaEntry).copy(freshData = true)
|
||||||
id = mangaId,
|
|
||||||
sourceId = mangaEntry[MangaTable.sourceReference].toString(),
|
|
||||||
url = mangaEntry[MangaTable.url],
|
|
||||||
title = mangaEntry[MangaTable.title],
|
|
||||||
thumbnailUrl = proxyThumbnailUrl(mangaId),
|
|
||||||
thumbnailUrlLastFetched = mangaEntry[MangaTable.thumbnailUrlLastFetched],
|
|
||||||
initialized = true,
|
|
||||||
artist = sManga.artist,
|
|
||||||
author = sManga.author,
|
|
||||||
description = sManga.description,
|
|
||||||
genre = sManga.genre.toGenreList(),
|
|
||||||
status = MangaStatus.valueOf(sManga.status).name,
|
|
||||||
inLibrary = mangaEntry[MangaTable.inLibrary],
|
|
||||||
inLibraryAt = mangaEntry[MangaTable.inLibraryAt],
|
|
||||||
source = getSource(mangaEntry[MangaTable.sourceReference]),
|
|
||||||
realUrl = mangaEntry[MangaTable.realUrl],
|
|
||||||
lastFetchedAt = mangaEntry[MangaTable.lastFetchedAt],
|
|
||||||
chaptersLastFetchedAt = mangaEntry[MangaTable.chaptersLastFetchedAt],
|
|
||||||
updateStrategy = UpdateStrategy.valueOf(mangaEntry[MangaTable.updateStrategy]),
|
|
||||||
freshData = true,
|
|
||||||
trackers = Track.getTrackRecordsByMangaId(mangaId),
|
|
||||||
lastModifiedAt = mangaEntry[MangaTable.lastModifiedAt],
|
|
||||||
version = mangaEntry[MangaTable.version],
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun fetchManga(mangaId: Int): SManga? {
|
suspend fun fetchMangaAndChapters(
|
||||||
val mangaEntry = transaction { MangaTable.selectAll().where { MangaTable.id eq mangaId }.first() }
|
mangaEntry: ResultRow,
|
||||||
|
source: Source,
|
||||||
val source =
|
fetchDetails: Boolean,
|
||||||
getCatalogueSourceOrNull(mangaEntry[MangaTable.sourceReference])
|
fetchChapters: Boolean,
|
||||||
?: return null
|
): SMangaUpdate {
|
||||||
val sManga =
|
val sManga =
|
||||||
source.getMangaDetails(
|
SManga.create().apply {
|
||||||
SManga.create().apply {
|
url = mangaEntry[MangaTable.url]
|
||||||
url = mangaEntry[MangaTable.url]
|
title = mangaEntry[MangaTable.title]
|
||||||
title = mangaEntry[MangaTable.title]
|
thumbnail_url = mangaEntry[MangaTable.thumbnail_url]
|
||||||
thumbnail_url = mangaEntry[MangaTable.thumbnail_url]
|
artist = mangaEntry[MangaTable.artist]
|
||||||
artist = mangaEntry[MangaTable.artist]
|
author = mangaEntry[MangaTable.author]
|
||||||
author = mangaEntry[MangaTable.author]
|
description = mangaEntry[MangaTable.description]
|
||||||
description = mangaEntry[MangaTable.description]
|
genre = mangaEntry[MangaTable.genre]
|
||||||
genre = mangaEntry[MangaTable.genre]
|
status = mangaEntry[MangaTable.status]
|
||||||
status = mangaEntry[MangaTable.status]
|
update_strategy = UpdateStrategy.valueOf(mangaEntry[MangaTable.updateStrategy])
|
||||||
update_strategy = UpdateStrategy.valueOf(mangaEntry[MangaTable.updateStrategy])
|
memo = Json.decodeFromString(mangaEntry[MangaTable.memo])
|
||||||
},
|
initialized = mangaEntry[MangaTable.initialized]
|
||||||
)
|
}
|
||||||
|
val sChapters =
|
||||||
|
transaction {
|
||||||
|
ChapterTable
|
||||||
|
.selectAll()
|
||||||
|
.where { ChapterTable.manga eq mangaEntry[MangaTable.id] }
|
||||||
|
.orderBy(ChapterTable.sourceOrder to SortOrder.DESC)
|
||||||
|
.map {
|
||||||
|
SChapter.create().apply {
|
||||||
|
url = it[ChapterTable.url]
|
||||||
|
name = it[ChapterTable.name]
|
||||||
|
chapter_number = it[ChapterTable.chapter_number]
|
||||||
|
scanlator = it[ChapterTable.scanlator]
|
||||||
|
date_upload = it[ChapterTable.date_upload]
|
||||||
|
memo = Json.decodeFromString(it[ChapterTable.memo])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return source.getMangaUpdate(
|
||||||
|
sManga,
|
||||||
|
sChapters,
|
||||||
|
fetchDetails = fetchDetails,
|
||||||
|
fetchChapters = fetchChapters,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun fetchManga(mangaId: Int): SManga? {
|
||||||
|
return mangaInfoMutex.get(mangaId) { Mutex() }.withLock {
|
||||||
|
val mangaEntry =
|
||||||
|
transaction { MangaTable.selectAll().where { MangaTable.id eq mangaId }.first() }
|
||||||
|
val source = getSourceOrNull(mangaEntry[MangaTable.sourceReference]) ?: return null
|
||||||
|
val sManga =
|
||||||
|
fetchMangaAndChapters(
|
||||||
|
mangaEntry,
|
||||||
|
source,
|
||||||
|
fetchDetails = true,
|
||||||
|
fetchChapters = false,
|
||||||
|
).manga
|
||||||
|
|
||||||
|
updateMangaDatabase(mangaEntry, source, sManga)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updateMangaAndChapters(
|
||||||
|
mangaId: Int,
|
||||||
|
updateManga: Boolean = true,
|
||||||
|
updateChapters: Boolean = true,
|
||||||
|
) {
|
||||||
|
mangaInfoMutex.get(mangaId) { Mutex() }.withLock {
|
||||||
|
var mangaEntry =
|
||||||
|
transaction { MangaTable.selectAll().where { MangaTable.id eq mangaId }.first() }
|
||||||
|
val source =
|
||||||
|
getSourceOrNull(mangaEntry[MangaTable.sourceReference])
|
||||||
|
?: throw NullPointerException("Missing source ${mangaEntry[MangaTable.sourceReference]}")
|
||||||
|
val mangaUpdate =
|
||||||
|
fetchMangaAndChapters(
|
||||||
|
mangaEntry,
|
||||||
|
source,
|
||||||
|
fetchDetails = updateManga,
|
||||||
|
fetchChapters = updateChapters,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (updateManga) {
|
||||||
|
updateMangaDatabase(mangaEntry, source, mangaUpdate.manga)
|
||||||
|
mangaEntry =
|
||||||
|
transaction {
|
||||||
|
MangaTable.selectAll().where { MangaTable.id eq mangaId }.first()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (updateChapters) {
|
||||||
|
Chapter.updateChapterListDatabase(mangaEntry, mangaUpdate.chapters, source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateMangaDatabase(
|
||||||
|
mangaEntry: ResultRow,
|
||||||
|
source: Source,
|
||||||
|
sManga: SManga,
|
||||||
|
): SManga {
|
||||||
transaction {
|
transaction {
|
||||||
MangaTable.update({ MangaTable.id eq mangaId }) {
|
MangaTable.update({ MangaTable.id eq mangaEntry[MangaTable.id] }) {
|
||||||
val remoteTitle =
|
val remoteTitle =
|
||||||
try {
|
try {
|
||||||
sManga.title
|
sManga.title
|
||||||
@@ -151,7 +215,7 @@ object Manga {
|
|||||||
if (!sManga.thumbnail_url.isNullOrEmpty()) {
|
if (!sManga.thumbnail_url.isNullOrEmpty()) {
|
||||||
it[MangaTable.thumbnail_url] = sManga.thumbnail_url
|
it[MangaTable.thumbnail_url] = sManga.thumbnail_url
|
||||||
it[MangaTable.thumbnailUrlLastFetched] = Instant.now().epochSecond
|
it[MangaTable.thumbnailUrlLastFetched] = Instant.now().epochSecond
|
||||||
clearThumbnail(mangaId)
|
clearThumbnail(mangaEntry[MangaTable.id].value)
|
||||||
}
|
}
|
||||||
|
|
||||||
it[MangaTable.realUrl] =
|
it[MangaTable.realUrl] =
|
||||||
@@ -174,6 +238,7 @@ object Manga {
|
|||||||
it[MangaTable.lastFetchedAt] = Instant.now().epochSecond
|
it[MangaTable.lastFetchedAt] = Instant.now().epochSecond
|
||||||
|
|
||||||
it[MangaTable.updateStrategy] = sManga.update_strategy.name
|
it[MangaTable.updateStrategy] = sManga.update_strategy.name
|
||||||
|
it[MangaTable.memo] = Json.encodeToString(sManga.memo)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,35 +286,6 @@ object Manga {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getMangaDataClass(
|
|
||||||
mangaId: Int,
|
|
||||||
mangaEntry: ResultRow,
|
|
||||||
) = MangaDataClass(
|
|
||||||
id = mangaId,
|
|
||||||
sourceId = mangaEntry[MangaTable.sourceReference].toString(),
|
|
||||||
url = mangaEntry[MangaTable.url],
|
|
||||||
title = mangaEntry[MangaTable.title],
|
|
||||||
thumbnailUrl = proxyThumbnailUrl(mangaId),
|
|
||||||
thumbnailUrlLastFetched = mangaEntry[MangaTable.thumbnailUrlLastFetched],
|
|
||||||
initialized = true,
|
|
||||||
artist = mangaEntry[MangaTable.artist],
|
|
||||||
author = mangaEntry[MangaTable.author],
|
|
||||||
description = mangaEntry[MangaTable.description],
|
|
||||||
genre = mangaEntry[MangaTable.genre].toGenreList(),
|
|
||||||
status = MangaStatus.valueOf(mangaEntry[MangaTable.status]).name,
|
|
||||||
inLibrary = mangaEntry[MangaTable.inLibrary],
|
|
||||||
inLibraryAt = mangaEntry[MangaTable.inLibraryAt],
|
|
||||||
source = getSource(mangaEntry[MangaTable.sourceReference]),
|
|
||||||
realUrl = mangaEntry[MangaTable.realUrl],
|
|
||||||
lastFetchedAt = mangaEntry[MangaTable.lastFetchedAt],
|
|
||||||
chaptersLastFetchedAt = mangaEntry[MangaTable.chaptersLastFetchedAt],
|
|
||||||
updateStrategy = UpdateStrategy.valueOf(mangaEntry[MangaTable.updateStrategy]),
|
|
||||||
freshData = false,
|
|
||||||
trackers = Track.getTrackRecordsByMangaId(mangaId),
|
|
||||||
lastModifiedAt = mangaEntry[MangaTable.lastModifiedAt],
|
|
||||||
version = mangaEntry[MangaTable.version],
|
|
||||||
)
|
|
||||||
|
|
||||||
fun getMangaMetaMap(mangaId: Int): Map<String, String> =
|
fun getMangaMetaMap(mangaId: Int): Map<String, String> =
|
||||||
transaction {
|
transaction {
|
||||||
MangaMetaTable
|
MangaMetaTable
|
||||||
@@ -377,7 +413,7 @@ object Manga {
|
|||||||
val mangaEntry = transaction { MangaTable.selectAll().where { MangaTable.id eq mangaId }.first() }
|
val mangaEntry = transaction { MangaTable.selectAll().where { MangaTable.id eq mangaId }.first() }
|
||||||
val sourceId = mangaEntry[MangaTable.sourceReference]
|
val sourceId = mangaEntry[MangaTable.sourceReference]
|
||||||
|
|
||||||
return when (val source = getCatalogueSourceOrStub(sourceId)) {
|
return when (val source = getSourceOrStub(sourceId)) {
|
||||||
is HttpSource -> {
|
is HttpSource -> {
|
||||||
getImageResponse(cacheSaveDir, fileName) {
|
getImageResponse(cacheSaveDir, fileName) {
|
||||||
fetchHttpSourceMangaThumbnail(source, mangaEntry)
|
fetchHttpSourceMangaThumbnail(source, mangaEntry)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ package suwayomi.tachidesk.manga.impl
|
|||||||
|
|
||||||
import eu.kanade.tachiyomi.source.local.LocalSource
|
import eu.kanade.tachiyomi.source.local.LocalSource
|
||||||
import eu.kanade.tachiyomi.source.model.MangasPage
|
import eu.kanade.tachiyomi.source.model.MangasPage
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
import org.jetbrains.exposed.v1.core.and
|
import org.jetbrains.exposed.v1.core.and
|
||||||
import org.jetbrains.exposed.v1.core.dao.id.EntityID
|
import org.jetbrains.exposed.v1.core.dao.id.EntityID
|
||||||
import org.jetbrains.exposed.v1.core.eq
|
import org.jetbrains.exposed.v1.core.eq
|
||||||
@@ -18,7 +19,7 @@ import org.jetbrains.exposed.v1.jdbc.batchInsert
|
|||||||
import org.jetbrains.exposed.v1.jdbc.selectAll
|
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||||
import org.jetbrains.exposed.v1.jdbc.statements.toExecutable
|
import org.jetbrains.exposed.v1.jdbc.statements.toExecutable
|
||||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||||
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrStub
|
import suwayomi.tachidesk.manga.impl.util.source.GetSource.getSourceOrStub
|
||||||
import suwayomi.tachidesk.manga.model.dataclass.PagedMangaListDataClass
|
import suwayomi.tachidesk.manga.model.dataclass.PagedMangaListDataClass
|
||||||
import suwayomi.tachidesk.manga.model.table.MangaTable
|
import suwayomi.tachidesk.manga.model.table.MangaTable
|
||||||
import suwayomi.tachidesk.manga.model.table.toDataClass
|
import suwayomi.tachidesk.manga.model.table.toDataClass
|
||||||
@@ -35,7 +36,7 @@ object MangaList {
|
|||||||
require(pageNum > 0) {
|
require(pageNum > 0) {
|
||||||
"pageNum = $pageNum is not in valid range"
|
"pageNum = $pageNum is not in valid range"
|
||||||
}
|
}
|
||||||
val source = getCatalogueSourceOrStub(sourceId)
|
val source = getSourceOrStub(sourceId)
|
||||||
val mangasPage =
|
val mangasPage =
|
||||||
if (popular) {
|
if (popular) {
|
||||||
source.getPopularManga(pageNum)
|
source.getPopularManga(pageNum)
|
||||||
@@ -75,6 +76,7 @@ object MangaList {
|
|||||||
this[MangaTable.status] = it.status
|
this[MangaTable.status] = it.status
|
||||||
this[MangaTable.thumbnail_url] = it.thumbnail_url
|
this[MangaTable.thumbnail_url] = it.thumbnail_url
|
||||||
this[MangaTable.updateStrategy] = it.update_strategy.name
|
this[MangaTable.updateStrategy] = it.update_strategy.name
|
||||||
|
this[MangaTable.memo] = Json.encodeToString(it.memo)
|
||||||
|
|
||||||
this[MangaTable.sourceReference] = sourceId
|
this[MangaTable.sourceReference] = sourceId
|
||||||
}.associate { Pair(it[MangaTable.url], it[MangaTable.id].value) }
|
}.associate { Pair(it[MangaTable.url], it[MangaTable.id].value) }
|
||||||
@@ -103,6 +105,7 @@ object MangaList {
|
|||||||
this[MangaTable.status] = sManga.status
|
this[MangaTable.status] = sManga.status
|
||||||
this[MangaTable.thumbnail_url] = sManga.thumbnail_url ?: manga[MangaTable.thumbnail_url]
|
this[MangaTable.thumbnail_url] = sManga.thumbnail_url ?: manga[MangaTable.thumbnail_url]
|
||||||
this[MangaTable.updateStrategy] = sManga.update_strategy.name
|
this[MangaTable.updateStrategy] = sManga.update_strategy.name
|
||||||
|
this[MangaTable.memo] = Json.encodeToString(sManga.memo)
|
||||||
if (!sManga.thumbnail_url.isNullOrEmpty() && manga[MangaTable.thumbnail_url] != sManga.thumbnail_url) {
|
if (!sManga.thumbnail_url.isNullOrEmpty() && manga[MangaTable.thumbnail_url] != sManga.thumbnail_url) {
|
||||||
this[MangaTable.thumbnailUrlLastFetched] = Instant.now().epochSecond
|
this[MangaTable.thumbnailUrlLastFetched] = Instant.now().epochSecond
|
||||||
Manga.clearThumbnail(manga[MangaTable.id].value)
|
Manga.clearThumbnail(manga[MangaTable.id].value)
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
|||||||
import org.jetbrains.exposed.v1.jdbc.update
|
import org.jetbrains.exposed.v1.jdbc.update
|
||||||
import suwayomi.tachidesk.graphql.types.DownloadConversion
|
import suwayomi.tachidesk.graphql.types.DownloadConversion
|
||||||
import suwayomi.tachidesk.manga.impl.util.getChapterCachePath
|
import suwayomi.tachidesk.manga.impl.util.getChapterCachePath
|
||||||
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrStub
|
import suwayomi.tachidesk.manga.impl.util.source.GetSource.getSourceOrStub
|
||||||
import suwayomi.tachidesk.manga.impl.util.storage.ImageResponse.getImageResponse
|
import suwayomi.tachidesk.manga.impl.util.storage.ImageResponse.getImageResponse
|
||||||
import suwayomi.tachidesk.manga.impl.util.storage.ImageUtil
|
import suwayomi.tachidesk.manga.impl.util.storage.ImageUtil
|
||||||
import suwayomi.tachidesk.manga.model.table.ChapterTable
|
import suwayomi.tachidesk.manga.model.table.ChapterTable
|
||||||
@@ -118,7 +118,7 @@ object Page {
|
|||||||
return imageFile.inputStream() to (ImageUtil.findImageType { imageFile.inputStream() }?.mime ?: "image/jpeg")
|
return imageFile.inputStream() to (ImageUtil.findImageType { imageFile.inputStream() }?.mime ?: "image/jpeg")
|
||||||
}
|
}
|
||||||
|
|
||||||
val source = getCatalogueSourceOrStub(mangaEntry[MangaTable.sourceReference])
|
val source = getSourceOrStub(mangaEntry[MangaTable.sourceReference])
|
||||||
source as HttpSource
|
source as HttpSource
|
||||||
|
|
||||||
if (pageEntry[PageTable.imageUrl] == null) {
|
if (pageEntry[PageTable.imageUrl] == null) {
|
||||||
|
|||||||
@@ -7,14 +7,14 @@ package suwayomi.tachidesk.manga.impl
|
|||||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
||||||
|
|
||||||
import eu.kanade.tachiyomi.source.CatalogueSource
|
import eu.kanade.tachiyomi.source.Source
|
||||||
import eu.kanade.tachiyomi.source.model.Filter
|
import eu.kanade.tachiyomi.source.model.Filter
|
||||||
import eu.kanade.tachiyomi.source.model.FilterList
|
import eu.kanade.tachiyomi.source.model.FilterList
|
||||||
import io.javalin.json.JsonMapper
|
import io.javalin.json.JsonMapper
|
||||||
import io.javalin.json.fromJsonString
|
import io.javalin.json.fromJsonString
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import suwayomi.tachidesk.manga.impl.MangaList.processEntries
|
import suwayomi.tachidesk.manga.impl.MangaList.processEntries
|
||||||
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrStub
|
import suwayomi.tachidesk.manga.impl.util.source.GetSource.getSourceOrStub
|
||||||
import suwayomi.tachidesk.manga.model.dataclass.PagedMangaListDataClass
|
import suwayomi.tachidesk.manga.model.dataclass.PagedMangaListDataClass
|
||||||
import uy.kohesive.injekt.injectLazy
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@ object Search {
|
|||||||
searchTerm: String,
|
searchTerm: String,
|
||||||
pageNum: Int,
|
pageNum: Int,
|
||||||
): PagedMangaListDataClass {
|
): PagedMangaListDataClass {
|
||||||
val source = getCatalogueSourceOrStub(sourceId)
|
val source = getSourceOrStub(sourceId)
|
||||||
val searchManga = source.getSearchManga(pageNum, searchTerm, getFilterListOf(source))
|
val searchManga = source.getSearchManga(pageNum, searchTerm, getFilterListOf(source))
|
||||||
return searchManga.processEntries(sourceId)
|
return searchManga.processEntries(sourceId)
|
||||||
}
|
}
|
||||||
@@ -34,7 +34,7 @@ object Search {
|
|||||||
pageNum: Int,
|
pageNum: Int,
|
||||||
filter: FilterData,
|
filter: FilterData,
|
||||||
): PagedMangaListDataClass {
|
): PagedMangaListDataClass {
|
||||||
val source = getCatalogueSourceOrStub(sourceId)
|
val source = getSourceOrStub(sourceId)
|
||||||
val filterList = if (filter.filter != null) buildFilterList(sourceId, filter.filter) else source.getFilterList()
|
val filterList = if (filter.filter != null) buildFilterList(sourceId, filter.filter) else source.getFilterList()
|
||||||
val searchManga = source.getSearchManga(pageNum, filter.searchTerm ?: "", filterList)
|
val searchManga = source.getSearchManga(pageNum, filter.searchTerm ?: "", filterList)
|
||||||
return searchManga.processEntries(sourceId)
|
return searchManga.processEntries(sourceId)
|
||||||
@@ -43,7 +43,7 @@ object Search {
|
|||||||
private val filterListCache = mutableMapOf<Long, FilterList>()
|
private val filterListCache = mutableMapOf<Long, FilterList>()
|
||||||
|
|
||||||
private fun getFilterListOf(
|
private fun getFilterListOf(
|
||||||
source: CatalogueSource,
|
source: Source,
|
||||||
reset: Boolean = false,
|
reset: Boolean = false,
|
||||||
): FilterList {
|
): FilterList {
|
||||||
if (reset || !filterListCache.containsKey(source.id)) {
|
if (reset || !filterListCache.containsKey(source.id)) {
|
||||||
@@ -56,7 +56,7 @@ object Search {
|
|||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
reset: Boolean,
|
reset: Boolean,
|
||||||
): List<FilterObject> {
|
): List<FilterObject> {
|
||||||
val source = getCatalogueSourceOrStub(sourceId)
|
val source = getSourceOrStub(sourceId)
|
||||||
|
|
||||||
return getFilterListOf(source, reset).list.map {
|
return getFilterListOf(source, reset).list.map {
|
||||||
FilterObject(
|
FilterObject(
|
||||||
@@ -111,7 +111,7 @@ object Search {
|
|||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
changes: List<FilterChange>,
|
changes: List<FilterChange>,
|
||||||
) {
|
) {
|
||||||
val source = getCatalogueSourceOrStub(sourceId)
|
val source = getSourceOrStub(sourceId)
|
||||||
val filterList = getFilterListOf(source, false)
|
val filterList = getFilterListOf(source, false)
|
||||||
updateFilterList(filterList, changes)
|
updateFilterList(filterList, changes)
|
||||||
}
|
}
|
||||||
@@ -169,7 +169,7 @@ object Search {
|
|||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
changes: List<FilterChange>,
|
changes: List<FilterChange>,
|
||||||
): FilterList {
|
): FilterList {
|
||||||
val source = getCatalogueSourceOrStub(sourceId)
|
val source = getSourceOrStub(sourceId)
|
||||||
val filterList = source.getFilterList()
|
val filterList = source.getFilterList()
|
||||||
return updateFilterList(filterList, changes)
|
return updateFilterList(filterList, changes)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,10 +25,11 @@ import org.jetbrains.exposed.v1.jdbc.selectAll
|
|||||||
import org.jetbrains.exposed.v1.jdbc.statements.toExecutable
|
import org.jetbrains.exposed.v1.jdbc.statements.toExecutable
|
||||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||||
import suwayomi.tachidesk.manga.impl.Source.preferenceScreenMap
|
import suwayomi.tachidesk.manga.impl.Source.preferenceScreenMap
|
||||||
import suwayomi.tachidesk.manga.impl.extension.Extension.getExtensionIconUrl
|
import suwayomi.tachidesk.manga.impl.extension.Extension.proxyExtensionIconUrl
|
||||||
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrNull
|
import suwayomi.tachidesk.manga.impl.util.source.GetSource.getSourceOrNull
|
||||||
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrStub
|
import suwayomi.tachidesk.manga.impl.util.source.GetSource.getSourceOrStub
|
||||||
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.unregisterCatalogueSource
|
import suwayomi.tachidesk.manga.impl.util.source.GetSource.unregisterSource
|
||||||
|
import suwayomi.tachidesk.manga.model.dataclass.ContentWarning
|
||||||
import suwayomi.tachidesk.manga.model.dataclass.SourceDataClass
|
import suwayomi.tachidesk.manga.model.dataclass.SourceDataClass
|
||||||
import suwayomi.tachidesk.manga.model.table.ExtensionTable
|
import suwayomi.tachidesk.manga.model.table.ExtensionTable
|
||||||
import suwayomi.tachidesk.manga.model.table.SourceMetaTable
|
import suwayomi.tachidesk.manga.model.table.SourceMetaTable
|
||||||
@@ -42,17 +43,17 @@ object Source {
|
|||||||
fun getSourceList(): List<SourceDataClass> {
|
fun getSourceList(): List<SourceDataClass> {
|
||||||
return transaction {
|
return transaction {
|
||||||
SourceTable.selectAll().mapNotNull {
|
SourceTable.selectAll().mapNotNull {
|
||||||
val catalogueSource = getCatalogueSourceOrNull(it[SourceTable.id].value) ?: return@mapNotNull null
|
val catalogueSource = getSourceOrNull(it[SourceTable.id].value) ?: return@mapNotNull null
|
||||||
val sourceExtension = ExtensionTable.selectAll().where { ExtensionTable.id eq it[SourceTable.extension] }.first()
|
val sourceExtension = ExtensionTable.selectAll().where { ExtensionTable.id eq it[SourceTable.extension] }.first()
|
||||||
|
|
||||||
SourceDataClass(
|
SourceDataClass(
|
||||||
id = it[SourceTable.id].value.toString(),
|
id = it[SourceTable.id].value.toString(),
|
||||||
name = it[SourceTable.name],
|
name = it[SourceTable.name],
|
||||||
lang = it[SourceTable.lang],
|
lang = it[SourceTable.lang],
|
||||||
iconUrl = getExtensionIconUrl(sourceExtension[ExtensionTable.apkName]),
|
iconUrl = proxyExtensionIconUrl(sourceExtension[ExtensionTable.pkgName]),
|
||||||
supportsLatest = catalogueSource.supportsLatest,
|
supportsLatest = catalogueSource.supportsLatest,
|
||||||
isConfigurable = catalogueSource is ConfigurableSource,
|
isConfigurable = catalogueSource is ConfigurableSource,
|
||||||
isNsfw = it[SourceTable.isNsfw],
|
isNsfw = it[SourceTable.contentWarning] >= ContentWarning.MIXED.ordinal,
|
||||||
displayName = catalogueSource.toString(),
|
displayName = catalogueSource.toString(),
|
||||||
baseUrl = runCatching { (catalogueSource as? HttpSource)?.baseUrl }.getOrNull(),
|
baseUrl = runCatching { (catalogueSource as? HttpSource)?.baseUrl }.getOrNull(),
|
||||||
)
|
)
|
||||||
@@ -63,20 +64,17 @@ object Source {
|
|||||||
fun getSource(sourceId: Long): SourceDataClass? { // all the data extracted fresh form the source instance
|
fun getSource(sourceId: Long): SourceDataClass? { // all the data extracted fresh form the source instance
|
||||||
return transaction {
|
return transaction {
|
||||||
val source = SourceTable.selectAll().where { SourceTable.id eq sourceId }.firstOrNull() ?: return@transaction null
|
val source = SourceTable.selectAll().where { SourceTable.id eq sourceId }.firstOrNull() ?: return@transaction null
|
||||||
val catalogueSource = getCatalogueSourceOrNull(sourceId) ?: return@transaction null
|
val catalogueSource = getSourceOrNull(sourceId) ?: return@transaction null
|
||||||
val extension = ExtensionTable.selectAll().where { ExtensionTable.id eq source[SourceTable.extension] }.first()
|
val extension = ExtensionTable.selectAll().where { ExtensionTable.id eq source[SourceTable.extension] }.first()
|
||||||
|
|
||||||
SourceDataClass(
|
SourceDataClass(
|
||||||
id = sourceId.toString(),
|
id = sourceId.toString(),
|
||||||
name = source[SourceTable.name],
|
name = source[SourceTable.name],
|
||||||
lang = source[SourceTable.lang],
|
lang = source[SourceTable.lang],
|
||||||
iconUrl =
|
iconUrl = proxyExtensionIconUrl(extension[ExtensionTable.pkgName]),
|
||||||
getExtensionIconUrl(
|
|
||||||
extension[ExtensionTable.apkName],
|
|
||||||
),
|
|
||||||
supportsLatest = catalogueSource.supportsLatest,
|
supportsLatest = catalogueSource.supportsLatest,
|
||||||
isConfigurable = catalogueSource is ConfigurableSource,
|
isConfigurable = catalogueSource is ConfigurableSource,
|
||||||
isNsfw = source[SourceTable.isNsfw],
|
isNsfw = source[SourceTable.contentWarning] >= ContentWarning.MIXED.ordinal,
|
||||||
displayName = catalogueSource.toString(),
|
displayName = catalogueSource.toString(),
|
||||||
baseUrl = runCatching { (catalogueSource as? HttpSource)?.baseUrl }.getOrNull(),
|
baseUrl = runCatching { (catalogueSource as? HttpSource)?.baseUrl }.getOrNull(),
|
||||||
)
|
)
|
||||||
@@ -109,7 +107,7 @@ object Source {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun getSourcePreferencesRaw(sourceId: Long): List<Preference> {
|
fun getSourcePreferencesRaw(sourceId: Long): List<Preference> {
|
||||||
val source = getCatalogueSourceOrStub(sourceId)
|
val source = getSourceOrStub(sourceId)
|
||||||
|
|
||||||
if (source is ConfigurableSource) {
|
if (source is ConfigurableSource) {
|
||||||
val sourceShardPreferences = source.sourcePreferences()
|
val sourceShardPreferences = source.sourcePreferences()
|
||||||
@@ -159,7 +157,7 @@ object Source {
|
|||||||
pref.callChangeListener(newValue)
|
pref.callChangeListener(newValue)
|
||||||
|
|
||||||
// must reload the source because a preference was changed
|
// must reload the source because a preference was changed
|
||||||
unregisterCatalogueSource(sourceId)
|
unregisterSource(sourceId)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getSourcesMetaMaps(ids: List<Long>): Map<Long, Map<String, String>> =
|
fun getSourcesMetaMaps(ids: List<Long>): Map<Long, Map<String, String>> =
|
||||||
|
|||||||
@@ -76,6 +76,8 @@ object BackupMangaHandler {
|
|||||||
updateStrategy = UpdateStrategy.valueOf(mangaRow[MangaTable.updateStrategy]),
|
updateStrategy = UpdateStrategy.valueOf(mangaRow[MangaTable.updateStrategy]),
|
||||||
lastModifiedAt = mangaRow[MangaTable.lastModifiedAt],
|
lastModifiedAt = mangaRow[MangaTable.lastModifiedAt],
|
||||||
version = mangaRow[MangaTable.version],
|
version = mangaRow[MangaTable.version],
|
||||||
|
initialized = mangaRow[MangaTable.initialized],
|
||||||
|
memo = mangaRow[MangaTable.memo].encodeToByteArray(),
|
||||||
)
|
)
|
||||||
|
|
||||||
val mangaId = mangaRow[MangaTable.id].value
|
val mangaId = mangaRow[MangaTable.id].value
|
||||||
@@ -113,6 +115,7 @@ object BackupMangaHandler {
|
|||||||
sourceOrder = chapters.size - it[ChapterTable.sourceOrder],
|
sourceOrder = chapters.size - it[ChapterTable.sourceOrder],
|
||||||
lastModifiedAt = it[ChapterTable.lastModifiedAt],
|
lastModifiedAt = it[ChapterTable.lastModifiedAt],
|
||||||
version = it[ChapterTable.version],
|
version = it[ChapterTable.version],
|
||||||
|
memo = it[ChapterTable.memo].encodeToByteArray(),
|
||||||
).apply {
|
).apply {
|
||||||
if (flags.includeClientData) {
|
if (flags.includeClientData) {
|
||||||
this.meta = chapterToMeta[it[ChapterTable.id].value] ?: emptyMap()
|
this.meta = chapterToMeta[it[ChapterTable.id].value] ?: emptyMap()
|
||||||
@@ -238,6 +241,7 @@ object BackupMangaHandler {
|
|||||||
|
|
||||||
it[lastModifiedAt] = manga.lastModifiedAt
|
it[lastModifiedAt] = manga.lastModifiedAt
|
||||||
it[version] = manga.version
|
it[version] = manga.version
|
||||||
|
it[memo] = manga.memo.decodeToString()
|
||||||
}.value
|
}.value
|
||||||
} else {
|
} else {
|
||||||
val dbMangaId = dbManga[MangaTable.id].value
|
val dbMangaId = dbManga[MangaTable.id].value
|
||||||
@@ -260,6 +264,7 @@ object BackupMangaHandler {
|
|||||||
|
|
||||||
it[lastModifiedAt] = manga.lastModifiedAt
|
it[lastModifiedAt] = manga.lastModifiedAt
|
||||||
it[version] = manga.version
|
it[version] = manga.version
|
||||||
|
it[memo] = manga.memo.decodeToString()
|
||||||
}
|
}
|
||||||
|
|
||||||
dbMangaId
|
dbMangaId
|
||||||
@@ -351,6 +356,7 @@ object BackupMangaHandler {
|
|||||||
|
|
||||||
this[ChapterTable.lastModifiedAt] = chapter.lastModifiedAt
|
this[ChapterTable.lastModifiedAt] = chapter.lastModifiedAt
|
||||||
this[ChapterTable.version] = chapter.version
|
this[ChapterTable.version] = chapter.version
|
||||||
|
this[ChapterTable.memo] = chapter.memo.decodeToString()
|
||||||
}.map { it[ChapterTable.id].value }
|
}.map { it[ChapterTable.id].value }
|
||||||
} else {
|
} else {
|
||||||
emptyList()
|
emptyList()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package suwayomi.tachidesk.manga.impl.backup.proto.models
|
|||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.protobuf.ProtoNumber
|
import kotlinx.serialization.protobuf.ProtoNumber
|
||||||
|
import suwayomi.tachidesk.manga.impl.util.lang.JsonObjectEmptyBytes
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class BackupChapter(
|
data class BackupChapter(
|
||||||
@@ -22,6 +23,7 @@ data class BackupChapter(
|
|||||||
// syncyomi
|
// syncyomi
|
||||||
@ProtoNumber(11) var lastModifiedAt: Long = 0,
|
@ProtoNumber(11) var lastModifiedAt: Long = 0,
|
||||||
@ProtoNumber(12) var version: Long = 0,
|
@ProtoNumber(12) var version: Long = 0,
|
||||||
|
@ProtoNumber(13) var memo: ByteArray = JsonObjectEmptyBytes,
|
||||||
// suwayomi
|
// suwayomi
|
||||||
@ProtoNumber(9000) var meta: Map<String, String> = emptyMap(),
|
@ProtoNumber(9000) var meta: Map<String, String> = emptyMap(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package suwayomi.tachidesk.manga.impl.backup.proto.models
|
|||||||
import eu.kanade.tachiyomi.source.model.UpdateStrategy
|
import eu.kanade.tachiyomi.source.model.UpdateStrategy
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.protobuf.ProtoNumber
|
import kotlinx.serialization.protobuf.ProtoNumber
|
||||||
|
import suwayomi.tachidesk.manga.impl.util.lang.JsonObjectEmptyBytes
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class BackupManga(
|
data class BackupManga(
|
||||||
@@ -37,6 +38,8 @@ data class BackupManga(
|
|||||||
// syncyomi
|
// syncyomi
|
||||||
@ProtoNumber(106) var lastModifiedAt: Long = 0,
|
@ProtoNumber(106) var lastModifiedAt: Long = 0,
|
||||||
@ProtoNumber(109) var version: Long = 0,
|
@ProtoNumber(109) var version: Long = 0,
|
||||||
|
@ProtoNumber(111) var initialized: Boolean = false,
|
||||||
|
@ProtoNumber(112) var memo: ByteArray = JsonObjectEmptyBytes,
|
||||||
// suwayomi
|
// suwayomi
|
||||||
@ProtoNumber(9000) var meta: Map<String, String> = emptyMap(),
|
@ProtoNumber(9000) var meta: Map<String, String> = emptyMap(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import org.jetbrains.exposed.v1.jdbc.selectAll
|
|||||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||||
import org.jetbrains.exposed.v1.jdbc.update
|
import org.jetbrains.exposed.v1.jdbc.update
|
||||||
import suwayomi.tachidesk.manga.impl.ChapterDownloadHelper
|
import suwayomi.tachidesk.manga.impl.ChapterDownloadHelper
|
||||||
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrStub
|
import suwayomi.tachidesk.manga.impl.util.source.GetSource.getSourceOrStub
|
||||||
import suwayomi.tachidesk.manga.model.dataclass.ChapterDataClass
|
import suwayomi.tachidesk.manga.model.dataclass.ChapterDataClass
|
||||||
import suwayomi.tachidesk.manga.model.table.ChapterTable
|
import suwayomi.tachidesk.manga.model.table.ChapterTable
|
||||||
import suwayomi.tachidesk.manga.model.table.MangaTable
|
import suwayomi.tachidesk.manga.model.table.MangaTable
|
||||||
@@ -77,7 +77,7 @@ suspend fun refreshChapterPageList(
|
|||||||
return mutex.withLock {
|
return mutex.withLock {
|
||||||
val chapterEntry = existingChapterEntry ?: transaction { ChapterTable.selectAll().where { ChapterTable.id eq chapterId }.first() }
|
val chapterEntry = existingChapterEntry ?: transaction { ChapterTable.selectAll().where { ChapterTable.id eq chapterId }.first() }
|
||||||
val mangaEntry = transaction { MangaTable.selectAll().where { MangaTable.id eq mangaId }.first() }
|
val mangaEntry = transaction { MangaTable.selectAll().where { MangaTable.id eq mangaId }.first() }
|
||||||
val source = getCatalogueSourceOrStub(mangaEntry[MangaTable.sourceReference])
|
val source = getSourceOrStub(mangaEntry[MangaTable.sourceReference])
|
||||||
|
|
||||||
val pageList =
|
val pageList =
|
||||||
source
|
source
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ package suwayomi.tachidesk.manga.impl.extension
|
|||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import eu.kanade.tachiyomi.network.GET
|
import eu.kanade.tachiyomi.network.GET
|
||||||
import eu.kanade.tachiyomi.network.NetworkHelper
|
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||||
import eu.kanade.tachiyomi.source.CatalogueSource
|
|
||||||
import eu.kanade.tachiyomi.source.Source
|
import eu.kanade.tachiyomi.source.Source
|
||||||
import eu.kanade.tachiyomi.source.SourceFactory
|
import eu.kanade.tachiyomi.source.SourceFactory
|
||||||
|
import eu.kanade.tachiyomi.source.local.LocalSource
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
import net.dongliu.apk.parser.ApkFile
|
import net.dongliu.apk.parser.ApkFile
|
||||||
import net.dongliu.apk.parser.bean.Icon
|
import net.dongliu.apk.parser.bean.Icon
|
||||||
@@ -23,22 +23,24 @@ import okio.source
|
|||||||
import org.jetbrains.exposed.v1.core.eq
|
import org.jetbrains.exposed.v1.core.eq
|
||||||
import org.jetbrains.exposed.v1.jdbc.deleteWhere
|
import org.jetbrains.exposed.v1.jdbc.deleteWhere
|
||||||
import org.jetbrains.exposed.v1.jdbc.insert
|
import org.jetbrains.exposed.v1.jdbc.insert
|
||||||
|
import org.jetbrains.exposed.v1.jdbc.select
|
||||||
import org.jetbrains.exposed.v1.jdbc.selectAll
|
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||||
import org.jetbrains.exposed.v1.jdbc.update
|
import org.jetbrains.exposed.v1.jdbc.update
|
||||||
import suwayomi.tachidesk.manga.impl.extension.ExtensionsList.extensionTableAsDataClass
|
|
||||||
import suwayomi.tachidesk.manga.impl.extension.github.ExtensionGithubApi
|
|
||||||
import suwayomi.tachidesk.manga.impl.util.PackageTools
|
import suwayomi.tachidesk.manga.impl.util.PackageTools
|
||||||
import suwayomi.tachidesk.manga.impl.util.PackageTools.EXTENSION_FEATURE
|
import suwayomi.tachidesk.manga.impl.util.PackageTools.EXTENSION_FEATURE
|
||||||
import suwayomi.tachidesk.manga.impl.util.PackageTools.LIB_VERSION_MAX
|
import suwayomi.tachidesk.manga.impl.util.PackageTools.LIB_VERSION_MAX
|
||||||
import suwayomi.tachidesk.manga.impl.util.PackageTools.LIB_VERSION_MIN
|
import suwayomi.tachidesk.manga.impl.util.PackageTools.LIB_VERSION_MIN
|
||||||
|
import suwayomi.tachidesk.manga.impl.util.PackageTools.METADATA_CONTENT_WARNING
|
||||||
|
import suwayomi.tachidesk.manga.impl.util.PackageTools.METADATA_EXTENSION_LIB
|
||||||
|
import suwayomi.tachidesk.manga.impl.util.PackageTools.METADATA_NAME
|
||||||
import suwayomi.tachidesk.manga.impl.util.PackageTools.METADATA_NSFW
|
import suwayomi.tachidesk.manga.impl.util.PackageTools.METADATA_NSFW
|
||||||
import suwayomi.tachidesk.manga.impl.util.PackageTools.METADATA_SOURCE_CLASS
|
import suwayomi.tachidesk.manga.impl.util.PackageTools.METADATA_SOURCE_CLASS
|
||||||
import suwayomi.tachidesk.manga.impl.util.PackageTools.dex2jar
|
import suwayomi.tachidesk.manga.impl.util.PackageTools.dex2jar
|
||||||
import suwayomi.tachidesk.manga.impl.util.PackageTools.getPackageInfo
|
import suwayomi.tachidesk.manga.impl.util.PackageTools.getPackageInfo
|
||||||
import suwayomi.tachidesk.manga.impl.util.PackageTools.loadExtensionSources
|
import suwayomi.tachidesk.manga.impl.util.PackageTools.loadExtensionSources
|
||||||
import suwayomi.tachidesk.manga.impl.util.network.await
|
import suwayomi.tachidesk.manga.impl.util.network.await
|
||||||
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource
|
import suwayomi.tachidesk.manga.impl.util.source.GetSource
|
||||||
import suwayomi.tachidesk.manga.impl.util.storage.ImageResponse.clearCachedImage
|
import suwayomi.tachidesk.manga.impl.util.storage.ImageResponse.clearCachedImage
|
||||||
import suwayomi.tachidesk.manga.impl.util.storage.ImageResponse.getImageResponse
|
import suwayomi.tachidesk.manga.impl.util.storage.ImageResponse.getImageResponse
|
||||||
import suwayomi.tachidesk.manga.impl.util.storage.ImageResponse.saveImage
|
import suwayomi.tachidesk.manga.impl.util.storage.ImageResponse.saveImage
|
||||||
@@ -62,18 +64,20 @@ object Extension {
|
|||||||
|
|
||||||
suspend fun installExtension(pkgName: String): Int {
|
suspend fun installExtension(pkgName: String): Int {
|
||||||
logger.debug { "Installing $pkgName" }
|
logger.debug { "Installing $pkgName" }
|
||||||
val extensionRecord = extensionTableAsDataClass().first { it.pkgName == pkgName }
|
val apkUrl =
|
||||||
|
transaction {
|
||||||
|
ExtensionTable
|
||||||
|
.select(ExtensionTable.apkUrl)
|
||||||
|
.where { ExtensionTable.pkgName eq pkgName }
|
||||||
|
.firstOrNull()
|
||||||
|
?.get(ExtensionTable.apkUrl)
|
||||||
|
} ?: throw NullPointerException("Could not find extension $pkgName")
|
||||||
|
|
||||||
return installAPK {
|
return installAPK {
|
||||||
val apkURL =
|
val apkName = Uri.parse(apkUrl).lastPathSegment!!
|
||||||
ExtensionGithubApi.getApkUrl(
|
|
||||||
extensionRecord.repo ?: throw NullPointerException("Could not find extension repo"),
|
|
||||||
extensionRecord.apkName,
|
|
||||||
)
|
|
||||||
val apkName = Uri.parse(apkURL).lastPathSegment!!
|
|
||||||
val apkSavePath = "${applicationDirs.extensionsRoot}/$apkName"
|
val apkSavePath = "${applicationDirs.extensionsRoot}/$apkName"
|
||||||
// download apk file
|
// download apk file
|
||||||
downloadAPKFile(apkURL, apkSavePath)
|
downloadAPKFile(apkUrl, apkSavePath)
|
||||||
|
|
||||||
apkSavePath
|
apkSavePath
|
||||||
}
|
}
|
||||||
@@ -148,7 +152,19 @@ object Extension {
|
|||||||
// throw Exception("This apk is not a signed with the official tachiyomi signature")
|
// throw Exception("This apk is not a signed with the official tachiyomi signature")
|
||||||
// }
|
// }
|
||||||
|
|
||||||
val isNsfw = packageInfo.applicationInfo.metaData.getString(METADATA_NSFW) == "1"
|
var contentWarning = packageInfo.applicationInfo.metaData.getInt(METADATA_CONTENT_WARNING)
|
||||||
|
if (contentWarning == 0) {
|
||||||
|
contentWarning = packageInfo.applicationInfo.metaData
|
||||||
|
.getString(METADATA_CONTENT_WARNING)
|
||||||
|
?.toIntOrNull()
|
||||||
|
?: 0
|
||||||
|
if (contentWarning == 0) {
|
||||||
|
contentWarning = packageInfo.applicationInfo.metaData
|
||||||
|
.getString(METADATA_NSFW)
|
||||||
|
?.toIntOrNull()
|
||||||
|
?: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val className =
|
val className =
|
||||||
packageInfo.packageName + packageInfo.applicationInfo.metaData.getString(METADATA_SOURCE_CLASS)
|
packageInfo.packageName + packageInfo.applicationInfo.metaData.getString(METADATA_SOURCE_CLASS)
|
||||||
@@ -157,7 +173,7 @@ object Extension {
|
|||||||
|
|
||||||
dex2jar(apkFilePath, jarFilePath, fileNameWithoutType)
|
dex2jar(apkFilePath, jarFilePath, fileNameWithoutType)
|
||||||
extractAssetsFromApk(apkFilePath, jarFilePath)
|
extractAssetsFromApk(apkFilePath, jarFilePath)
|
||||||
extractAndCacheApkIcon(apkFilePath, apkName)
|
extractAndCacheApkIcon(apkFilePath, packageInfo.packageName)
|
||||||
|
|
||||||
// clean up
|
// clean up
|
||||||
File(apkFilePath).delete()
|
File(apkFilePath).delete()
|
||||||
@@ -165,12 +181,12 @@ object Extension {
|
|||||||
try {
|
try {
|
||||||
// collect sources from the extension
|
// collect sources from the extension
|
||||||
val extensionMainClassInstance = loadExtensionSources(jarFilePath, className)
|
val extensionMainClassInstance = loadExtensionSources(jarFilePath, className)
|
||||||
val sources: List<CatalogueSource> =
|
val sources: List<Source> =
|
||||||
when (extensionMainClassInstance) {
|
when (extensionMainClassInstance) {
|
||||||
is Source -> listOf(extensionMainClassInstance)
|
is Source -> listOf(extensionMainClassInstance)
|
||||||
is SourceFactory -> extensionMainClassInstance.createSources()
|
is SourceFactory -> extensionMainClassInstance.createSources()
|
||||||
else -> throw RuntimeException("Unknown source class type! ${extensionMainClassInstance.javaClass}")
|
else -> throw RuntimeException("Unknown source class type! ${extensionMainClassInstance.javaClass}")
|
||||||
}.map { it as CatalogueSource }
|
}
|
||||||
|
|
||||||
val langs = sources.map { it.lang }.toSet()
|
val langs = sources.map { it.lang }.toSet()
|
||||||
val extensionLang =
|
val extensionLang =
|
||||||
@@ -181,9 +197,16 @@ object Extension {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val extensionName =
|
val extensionName =
|
||||||
packageInfo.applicationInfo.nonLocalizedLabel
|
packageInfo.applicationInfo.metaData.getString(METADATA_NAME)
|
||||||
.toString()
|
?: packageInfo.applicationInfo.nonLocalizedLabel
|
||||||
.substringAfter("Tachiyomi: ")
|
.toString()
|
||||||
|
.substringAfter("Tachiyomi: ")
|
||||||
|
|
||||||
|
val extensionLibVersion =
|
||||||
|
packageInfo.applicationInfo.metaData
|
||||||
|
.getString(METADATA_EXTENSION_LIB)
|
||||||
|
.takeUnless { it == "0" }
|
||||||
|
?: packageInfo.versionName.substringBeforeLast('.')
|
||||||
|
|
||||||
// update extension info
|
// update extension info
|
||||||
transaction {
|
transaction {
|
||||||
@@ -193,9 +216,10 @@ object Extension {
|
|||||||
it[name] = extensionName
|
it[name] = extensionName
|
||||||
it[this.pkgName] = packageInfo.packageName
|
it[this.pkgName] = packageInfo.packageName
|
||||||
it[versionName] = packageInfo.versionName
|
it[versionName] = packageInfo.versionName
|
||||||
it[versionCode] = packageInfo.versionCode
|
it[versionCode] = packageInfo.versionCode.toLong()
|
||||||
|
it[extensionLib] = extensionLibVersion
|
||||||
it[lang] = extensionLang
|
it[lang] = extensionLang
|
||||||
it[this.isNsfw] = isNsfw
|
it[this.contentWarning] = contentWarning
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,7 +228,7 @@ object Extension {
|
|||||||
it[this.isInstalled] = true
|
it[this.isInstalled] = true
|
||||||
it[this.classFQName] = className
|
it[this.classFQName] = className
|
||||||
it[versionName] = packageInfo.versionName
|
it[versionName] = packageInfo.versionName
|
||||||
it[versionCode] = packageInfo.versionCode
|
it[versionCode] = packageInfo.versionCode.toLong()
|
||||||
}
|
}
|
||||||
|
|
||||||
val extensionId =
|
val extensionId =
|
||||||
@@ -220,7 +244,7 @@ object Extension {
|
|||||||
it[name] = httpSource.name
|
it[name] = httpSource.name
|
||||||
it[lang] = httpSource.lang
|
it[lang] = httpSource.lang
|
||||||
it[extension] = extensionId
|
it[extension] = extensionId
|
||||||
it[SourceTable.isNsfw] = isNsfw
|
it[this.contentWarning] = contentWarning
|
||||||
}
|
}
|
||||||
logger.debug { "Installed source ${httpSource.name} (${httpSource.lang}) with id:${httpSource.id}" }
|
logger.debug { "Installed source ${httpSource.name} (${httpSource.lang}) with id:${httpSource.id}" }
|
||||||
}
|
}
|
||||||
@@ -241,7 +265,7 @@ object Extension {
|
|||||||
|
|
||||||
private fun extractAndCacheApkIcon(
|
private fun extractAndCacheApkIcon(
|
||||||
apkFilePath: String,
|
apkFilePath: String,
|
||||||
apkName: String,
|
pkgName: String,
|
||||||
) {
|
) {
|
||||||
val iconCacheDir = "${applicationDirs.extensionsRoot}/icon"
|
val iconCacheDir = "${applicationDirs.extensionsRoot}/icon"
|
||||||
try {
|
try {
|
||||||
@@ -254,15 +278,15 @@ object Extension {
|
|||||||
?.first
|
?.first
|
||||||
}
|
}
|
||||||
if (iconData == null) {
|
if (iconData == null) {
|
||||||
logger.warn { "No icon found in APK $apkName" }
|
logger.warn { "No icon found in APK $pkgName" }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
File(iconCacheDir).mkdirs()
|
File(iconCacheDir).mkdirs()
|
||||||
clearCachedImage(iconCacheDir, apkName)
|
clearCachedImage(iconCacheDir, pkgName)
|
||||||
saveImage("$iconCacheDir/$apkName", iconData.inputStream(), null)
|
saveImage("$iconCacheDir/$pkgName", iconData.inputStream(), null)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.warn(e) { "Failed to extract icon from APK $apkName" }
|
logger.warn(e) { "Failed to extract icon from APK $pkgName" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,7 +367,9 @@ object Extension {
|
|||||||
logger.debug { "Uninstalling $pkgName" }
|
logger.debug { "Uninstalling $pkgName" }
|
||||||
|
|
||||||
val extensionRecord = transaction { ExtensionTable.selectAll().where { ExtensionTable.pkgName eq pkgName }.first() }
|
val extensionRecord = transaction { ExtensionTable.selectAll().where { ExtensionTable.pkgName eq pkgName }.first() }
|
||||||
val fileNameWithoutType = extensionRecord[ExtensionTable.apkName].substringBefore(".apk")
|
val fileNameWithoutType =
|
||||||
|
extensionRecord[ExtensionTable.apkName]?.substringBefore(".apk")
|
||||||
|
?: throw NullPointerException("Missing $pkgName apkName")
|
||||||
val jarPath = "${applicationDirs.extensionsRoot}/$fileNameWithoutType.jar"
|
val jarPath = "${applicationDirs.extensionsRoot}/$fileNameWithoutType.jar"
|
||||||
val sources =
|
val sources =
|
||||||
transaction {
|
transaction {
|
||||||
@@ -353,12 +379,13 @@ object Extension {
|
|||||||
|
|
||||||
SourceTable.deleteWhere { SourceTable.extension eq extensionId }
|
SourceTable.deleteWhere { SourceTable.extension eq extensionId }
|
||||||
|
|
||||||
if (extensionRecord[ExtensionTable.isObsolete]) {
|
if (extensionRecord[ExtensionTable.isObsolete] || extensionRecord[ExtensionTable.apkUrl] == null) {
|
||||||
ExtensionTable.deleteWhere { ExtensionTable.pkgName eq pkgName }
|
ExtensionTable.deleteWhere { ExtensionTable.pkgName eq pkgName }
|
||||||
} else {
|
} else {
|
||||||
ExtensionTable.update({ ExtensionTable.pkgName eq pkgName }) {
|
ExtensionTable.update({ ExtensionTable.pkgName eq pkgName }) {
|
||||||
it[isInstalled] = false
|
it[isInstalled] = false
|
||||||
it[hasUpdate] = false
|
it[hasUpdate] = false
|
||||||
|
it[apkName] = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,7 +397,7 @@ object Extension {
|
|||||||
PackageTools.jarLoaderMap.remove(jarPath)?.close()
|
PackageTools.jarLoaderMap.remove(jarPath)?.close()
|
||||||
|
|
||||||
// clear all loaded sources
|
// clear all loaded sources
|
||||||
sources.forEach { GetCatalogueSource.unregisterCatalogueSource(it) }
|
sources.forEach { GetSource.unregisterSource(it) }
|
||||||
|
|
||||||
File(jarPath).delete()
|
File(jarPath).delete()
|
||||||
}
|
}
|
||||||
@@ -385,8 +412,7 @@ object Extension {
|
|||||||
it[versionName] = targetExtension.versionName
|
it[versionName] = targetExtension.versionName
|
||||||
it[versionCode] = targetExtension.versionCode
|
it[versionCode] = targetExtension.versionCode
|
||||||
it[lang] = targetExtension.lang
|
it[lang] = targetExtension.lang
|
||||||
it[isNsfw] = targetExtension.isNsfw
|
it[contentWarning] = targetExtension.contentWarning.ordinal
|
||||||
it[apkName] = targetExtension.apkName
|
|
||||||
it[iconUrl] = targetExtension.iconUrl
|
it[iconUrl] = targetExtension.iconUrl
|
||||||
it[hasUpdate] = false
|
it[hasUpdate] = false
|
||||||
}
|
}
|
||||||
@@ -394,17 +420,21 @@ object Extension {
|
|||||||
return installExtension(pkgName)
|
return installExtension(pkgName)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getExtensionIcon(apkName: String): Pair<InputStream, String> {
|
suspend fun getExtensionIcon(pkgName: String): Pair<InputStream, String> {
|
||||||
val iconUrl =
|
|
||||||
if (apkName == "localSource") {
|
|
||||||
""
|
|
||||||
} else {
|
|
||||||
transaction { ExtensionTable.selectAll().where { ExtensionTable.apkName eq apkName }.first() }[ExtensionTable.iconUrl]
|
|
||||||
}
|
|
||||||
|
|
||||||
val cacheSaveDir = "${applicationDirs.extensionsRoot}/icon"
|
val cacheSaveDir = "${applicationDirs.extensionsRoot}/icon"
|
||||||
|
|
||||||
return getImageResponse(cacheSaveDir, apkName) {
|
if (pkgName == LocalSource::class.java.`package`.name) {
|
||||||
|
return getImageResponse(cacheSaveDir, "localSource") {
|
||||||
|
network.client
|
||||||
|
.newCall(GET("", cache = CacheControl.FORCE_NETWORK))
|
||||||
|
.await()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val iconUrl =
|
||||||
|
transaction { ExtensionTable.selectAll().where { ExtensionTable.pkgName eq pkgName }.first() }[ExtensionTable.iconUrl]
|
||||||
|
|
||||||
|
return getImageResponse(cacheSaveDir, pkgName) {
|
||||||
network.client
|
network.client
|
||||||
.newCall(
|
.newCall(
|
||||||
GET(iconUrl, cache = CacheControl.FORCE_NETWORK),
|
GET(iconUrl, cache = CacheControl.FORCE_NETWORK),
|
||||||
@@ -412,5 +442,5 @@ object Extension {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getExtensionIconUrl(apkName: String): String = "/api/v1/extension/icon/$apkName"
|
fun proxyExtensionIconUrl(pkgName: String): String = "/api/v1/extension/icon/$pkgName"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,269 @@
|
|||||||
|
package suwayomi.tachidesk.manga.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 eu.kanade.tachiyomi.network.GET
|
||||||
|
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||||
|
import eu.kanade.tachiyomi.network.awaitSuccess
|
||||||
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
|
import kotlinx.serialization.decodeFromByteArray
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.okio.decodeFromBufferedSource
|
||||||
|
import kotlinx.serialization.protobuf.ProtoBuf
|
||||||
|
import okio.BufferedSource
|
||||||
|
import okio.buffer
|
||||||
|
import okio.gzip
|
||||||
|
import org.jetbrains.exposed.v1.core.eq
|
||||||
|
import org.jetbrains.exposed.v1.core.inList
|
||||||
|
import org.jetbrains.exposed.v1.jdbc.deleteWhere
|
||||||
|
import org.jetbrains.exposed.v1.jdbc.insert
|
||||||
|
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||||
|
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||||
|
import org.jetbrains.exposed.v1.jdbc.update
|
||||||
|
import suwayomi.tachidesk.manga.impl.extension.github.NetworkExtensionStore
|
||||||
|
import suwayomi.tachidesk.manga.impl.extension.github.NetworkLegacyExtension
|
||||||
|
import suwayomi.tachidesk.manga.impl.extension.github.NetworkLegacyExtensionRepo
|
||||||
|
import suwayomi.tachidesk.manga.impl.extension.github.toExtensionInfo
|
||||||
|
import suwayomi.tachidesk.manga.impl.extension.github.toExtensionInfos
|
||||||
|
import suwayomi.tachidesk.manga.model.dataclass.ExtensionInfo
|
||||||
|
import suwayomi.tachidesk.manga.model.dataclass.ExtensionStore
|
||||||
|
import suwayomi.tachidesk.manga.model.table.ExtensionStoreTable
|
||||||
|
import suwayomi.tachidesk.server.serverConfig
|
||||||
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
|
|
||||||
|
object ExtensionStoreService {
|
||||||
|
private val logger = KotlinLogging.logger {}
|
||||||
|
|
||||||
|
val network: NetworkHelper by injectLazy()
|
||||||
|
val protoBuf: ProtoBuf by injectLazy()
|
||||||
|
val json: Json by injectLazy()
|
||||||
|
|
||||||
|
suspend fun fetch(indexUrl: String): ExtensionStore {
|
||||||
|
var updatedIndexUrl: String = indexUrl
|
||||||
|
return try {
|
||||||
|
val response = network.client.newCall(GET(updatedIndexUrl)).awaitSuccess()
|
||||||
|
response.body.source().decompressIfGzipped().use { source ->
|
||||||
|
val networkStore =
|
||||||
|
when (source.peek().readByte()) {
|
||||||
|
// "[..."
|
||||||
|
0x5B.toByte() -> {
|
||||||
|
run {
|
||||||
|
if (!indexUrl.endsWith("/index.min.json")) {
|
||||||
|
throw IllegalArgumentException("Provided legacy store url is not valid")
|
||||||
|
}
|
||||||
|
updatedIndexUrl = indexUrl.replace("/index.min.json", "/repo.json")
|
||||||
|
network.client.newCall(GET(updatedIndexUrl)).awaitSuccess().body.source().use {
|
||||||
|
json.decodeFromBufferedSource<NetworkLegacyExtensionRepo>(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "{..."
|
||||||
|
0x7B.toByte() -> {
|
||||||
|
try {
|
||||||
|
json.decodeFromBufferedSource<NetworkLegacyExtensionRepo>(source.peek())
|
||||||
|
} catch (_: IllegalArgumentException) {
|
||||||
|
json.decodeFromBufferedSource<NetworkExtensionStore>(source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
protoBuf.decodeFromByteArray<NetworkExtensionStore>(source.readByteArray())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (networkStore is NetworkLegacyExtensionRepo && networkStore.indexV2 != null) {
|
||||||
|
return fetch(networkStore.indexV2)
|
||||||
|
}
|
||||||
|
|
||||||
|
networkStore.toExtensionStore(updatedIndexUrl)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (e is CancellationException) throw e
|
||||||
|
logger.error(e) { "Failed to fetch extension store '$indexUrl'" }
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun upsert(store: ExtensionStore) {
|
||||||
|
transaction {
|
||||||
|
val existing =
|
||||||
|
ExtensionStoreTable
|
||||||
|
.selectAll()
|
||||||
|
.where { ExtensionStoreTable.indexUrl eq store.indexUrl }
|
||||||
|
.firstOrNull()
|
||||||
|
|
||||||
|
if (existing == null) {
|
||||||
|
ExtensionStoreTable.insert {
|
||||||
|
it[name] = store.name
|
||||||
|
it[badgeLabel] = store.badgeLabel
|
||||||
|
it[signingKey] = store.signingKey
|
||||||
|
it[contactWebsite] = store.contact.website
|
||||||
|
it[contactDiscord] = store.contact.discord
|
||||||
|
it[indexUrl] = store.indexUrl
|
||||||
|
it[isLegacy] = store.isLegacy
|
||||||
|
it[extensionListUrl] = store.extensionListUrl
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ExtensionStoreTable.update({ ExtensionStoreTable.indexUrl eq store.indexUrl }) {
|
||||||
|
it[name] = store.name
|
||||||
|
it[badgeLabel] = store.badgeLabel
|
||||||
|
it[signingKey] = store.signingKey
|
||||||
|
it[contactWebsite] = store.contact.website
|
||||||
|
it[contactDiscord] = store.contact.discord
|
||||||
|
it[isLegacy] = store.isLegacy
|
||||||
|
it[extensionListUrl] = store.extensionListUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getAndRefresh(): List<ExtensionStore> {
|
||||||
|
val stores =
|
||||||
|
transaction {
|
||||||
|
ExtensionStoreTable.selectAll().toList()
|
||||||
|
}
|
||||||
|
var needsPrefUpdate = false
|
||||||
|
val updateStores =
|
||||||
|
stores.mapNotNull { storeRow ->
|
||||||
|
val oldIndexUrl = storeRow[ExtensionStoreTable.indexUrl]
|
||||||
|
val oldName = storeRow[ExtensionStoreTable.name]
|
||||||
|
try {
|
||||||
|
val store = fetch(oldIndexUrl)
|
||||||
|
if (store.indexUrl != oldIndexUrl) {
|
||||||
|
transaction {
|
||||||
|
ExtensionStoreTable.deleteWhere { ExtensionStoreTable.indexUrl eq oldIndexUrl }
|
||||||
|
}
|
||||||
|
needsPrefUpdate = true
|
||||||
|
}
|
||||||
|
upsert(store)
|
||||||
|
store
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.warn(e) { "Failed to fetch extension store '$oldName ($oldIndexUrl)'" }
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (needsPrefUpdate) syncDbToPrefs()
|
||||||
|
return updateStores
|
||||||
|
}
|
||||||
|
|
||||||
|
fun syncDbToPrefs() {
|
||||||
|
val dbStores =
|
||||||
|
transaction {
|
||||||
|
ExtensionStoreTable
|
||||||
|
.selectAll()
|
||||||
|
.map { it[ExtensionStoreTable.indexUrl] }
|
||||||
|
.toSet()
|
||||||
|
}
|
||||||
|
|
||||||
|
val currentPrefs = serverConfig.extensionStores.value.toSet()
|
||||||
|
val toAdd = dbStores - currentPrefs
|
||||||
|
val toRemove = currentPrefs - dbStores
|
||||||
|
|
||||||
|
if (toAdd.isNotEmpty()) {
|
||||||
|
serverConfig.extensionStores.value = (serverConfig.extensionStores.value + toAdd).distinct()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toRemove.isNotEmpty()) {
|
||||||
|
serverConfig.extensionStores.value = serverConfig.extensionStores.value.filterNot { it in toRemove }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun syncPrefsToDb() {
|
||||||
|
val prefUrls = serverConfig.extensionStores.value.toSet()
|
||||||
|
|
||||||
|
val dbStores =
|
||||||
|
transaction {
|
||||||
|
ExtensionStoreTable.selectAll().associateBy { it[ExtensionStoreTable.indexUrl] }
|
||||||
|
}
|
||||||
|
|
||||||
|
val toAdd = prefUrls - dbStores.keys
|
||||||
|
val toRemove = (dbStores.keys - prefUrls).toMutableSet()
|
||||||
|
var needsPrefUpdate = toRemove.isNotEmpty()
|
||||||
|
|
||||||
|
toAdd.forEach { url ->
|
||||||
|
try {
|
||||||
|
val store = fetch(url)
|
||||||
|
if (store.indexUrl != url) {
|
||||||
|
transaction {
|
||||||
|
ExtensionStoreTable.deleteWhere { ExtensionStoreTable.indexUrl eq url }
|
||||||
|
}
|
||||||
|
needsPrefUpdate = true
|
||||||
|
toRemove -= store.indexUrl
|
||||||
|
}
|
||||||
|
upsert(store)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.warn(e) { "Failed to sync preference store '$url' to database" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toRemove.isNotEmpty()) {
|
||||||
|
transaction {
|
||||||
|
ExtensionStoreTable.deleteWhere { ExtensionStoreTable.indexUrl inList toRemove.toList() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (needsPrefUpdate) {
|
||||||
|
syncDbToPrefs()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getExtensions(store: ExtensionStore): List<ExtensionInfo> {
|
||||||
|
val extensions =
|
||||||
|
if (store.extensionListUrl != null) {
|
||||||
|
val response = network.client.newCall(GET(store.extensionListUrl)).awaitSuccess()
|
||||||
|
response.body.source().decompressIfGzipped().use { source ->
|
||||||
|
when (source.peek().readByte()) {
|
||||||
|
// "{..."
|
||||||
|
0x7B.toByte() -> {
|
||||||
|
json.decodeFromBufferedSource<NetworkExtensionStore.ExtensionList>(source)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
protoBuf.decodeFromByteArray<NetworkExtensionStore.ExtensionList>(
|
||||||
|
source.readByteArray(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}.toExtensionInfos(store)
|
||||||
|
}
|
||||||
|
} else if (!store.isLegacy) {
|
||||||
|
val response = network.client.newCall(GET(store.indexUrl)).awaitSuccess()
|
||||||
|
response.body.source().decompressIfGzipped().use { source ->
|
||||||
|
when (source.peek().readByte()) {
|
||||||
|
// "{..."
|
||||||
|
0x7B.toByte() -> json.decodeFromBufferedSource<NetworkExtensionStore>(source)
|
||||||
|
|
||||||
|
else -> protoBuf.decodeFromByteArray<NetworkExtensionStore>(source.readByteArray())
|
||||||
|
}.extensionList!!
|
||||||
|
.toExtensionInfos(store)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val storeBaseUrl = store.indexUrl.removeSuffix("/repo.json")
|
||||||
|
val response = network.client.newCall(GET("$storeBaseUrl/index.min.json")).awaitSuccess()
|
||||||
|
response.body.source().use { source ->
|
||||||
|
json
|
||||||
|
.decodeFromBufferedSource<List<NetworkLegacyExtension>>(source)
|
||||||
|
.map { it.toExtensionInfo(store, storeBaseUrl) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return extensions
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun BufferedSource.decompressIfGzipped(): BufferedSource {
|
||||||
|
val isGzip =
|
||||||
|
peek().use { peeked ->
|
||||||
|
try {
|
||||||
|
peeked.readShort().toInt() == 0x1f8b
|
||||||
|
} catch (_: Exception) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return if (isGzip) gzip().buffer() else this
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,12 +21,11 @@ import org.jetbrains.exposed.v1.jdbc.selectAll
|
|||||||
import org.jetbrains.exposed.v1.jdbc.statements.toExecutable
|
import org.jetbrains.exposed.v1.jdbc.statements.toExecutable
|
||||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||||
import org.jetbrains.exposed.v1.jdbc.update
|
import org.jetbrains.exposed.v1.jdbc.update
|
||||||
import suwayomi.tachidesk.manga.impl.extension.Extension.getExtensionIconUrl
|
import suwayomi.tachidesk.manga.impl.extension.Extension.proxyExtensionIconUrl
|
||||||
import suwayomi.tachidesk.manga.impl.extension.github.ExtensionGithubApi
|
import suwayomi.tachidesk.manga.model.dataclass.ContentWarning
|
||||||
import suwayomi.tachidesk.manga.impl.extension.github.OnlineExtension
|
|
||||||
import suwayomi.tachidesk.manga.model.dataclass.ExtensionDataClass
|
import suwayomi.tachidesk.manga.model.dataclass.ExtensionDataClass
|
||||||
|
import suwayomi.tachidesk.manga.model.dataclass.ExtensionInfo
|
||||||
import suwayomi.tachidesk.manga.model.table.ExtensionTable
|
import suwayomi.tachidesk.manga.model.table.ExtensionTable
|
||||||
import suwayomi.tachidesk.server.serverConfig
|
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
@@ -34,23 +33,23 @@ object ExtensionsList {
|
|||||||
private val logger = KotlinLogging.logger {}
|
private val logger = KotlinLogging.logger {}
|
||||||
|
|
||||||
var lastUpdateCheck: Long = 0
|
var lastUpdateCheck: Long = 0
|
||||||
var updateMap = ConcurrentHashMap<String, OnlineExtension>()
|
var updateMap = ConcurrentHashMap<String, ExtensionInfo>()
|
||||||
|
|
||||||
suspend fun fetchExtensions() {
|
suspend fun fetchExtensions() {
|
||||||
// update if 60 seconds has passed or requested offline and database is empty
|
val allExtensions = mutableListOf<ExtensionInfo>()
|
||||||
val extensions =
|
|
||||||
serverConfig.extensionRepos.value.map { repo ->
|
ExtensionStoreService.getAndRefresh().forEach { store ->
|
||||||
kotlin
|
try {
|
||||||
.runCatching {
|
val extensions = ExtensionStoreService.getExtensions(store)
|
||||||
ExtensionGithubApi.findExtensions(repo.repoUrlReplace())
|
allExtensions.addAll(extensions)
|
||||||
}.onFailure {
|
} catch (e: Exception) {
|
||||||
logger.warn(it) {
|
logger.warn(e) {
|
||||||
"Failed to fetch extensions for repo: $repo"
|
"Failed to fetch extensions for store: ${store.indexUrl}"
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
val foundExtensions = extensions.mapNotNull { it.getOrNull() }.flatten()
|
}
|
||||||
updateExtensionDatabase(foundExtensions)
|
|
||||||
|
updateExtensionDatabase(allExtensions)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun fetchExtensionsCached() {
|
suspend fun fetchExtensionsCached() {
|
||||||
@@ -74,25 +73,25 @@ object ExtensionsList {
|
|||||||
transaction {
|
transaction {
|
||||||
ExtensionTable.selectAll().filter { it[ExtensionTable.name] != LocalSource.EXTENSION_NAME }.map {
|
ExtensionTable.selectAll().filter { it[ExtensionTable.name] != LocalSource.EXTENSION_NAME }.map {
|
||||||
ExtensionDataClass(
|
ExtensionDataClass(
|
||||||
it[ExtensionTable.repo],
|
repo = it[ExtensionTable.storeIndexUrl],
|
||||||
it[ExtensionTable.apkName],
|
apkName = it[ExtensionTable.apkName].orEmpty(),
|
||||||
getExtensionIconUrl(it[ExtensionTable.apkName]),
|
iconUrl = proxyExtensionIconUrl(it[ExtensionTable.pkgName]),
|
||||||
it[ExtensionTable.name],
|
name = it[ExtensionTable.name],
|
||||||
it[ExtensionTable.pkgName],
|
pkgName = it[ExtensionTable.pkgName],
|
||||||
it[ExtensionTable.versionName],
|
versionName = it[ExtensionTable.versionName],
|
||||||
it[ExtensionTable.versionCode],
|
versionCode = it[ExtensionTable.versionCode].toInt(),
|
||||||
it[ExtensionTable.lang],
|
lang = it[ExtensionTable.lang],
|
||||||
it[ExtensionTable.isNsfw],
|
isNsfw = it[ExtensionTable.contentWarning] >= ContentWarning.MIXED.ordinal,
|
||||||
it[ExtensionTable.isInstalled],
|
installed = it[ExtensionTable.isInstalled],
|
||||||
it[ExtensionTable.hasUpdate],
|
hasUpdate = it[ExtensionTable.hasUpdate],
|
||||||
it[ExtensionTable.isObsolete],
|
obsolete = it[ExtensionTable.isObsolete],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val updateExtensionDatabaseMutex = Mutex()
|
private val updateExtensionDatabaseMutex = Mutex()
|
||||||
|
|
||||||
private suspend fun updateExtensionDatabase(foundExtensions: List<OnlineExtension>) {
|
private suspend fun updateExtensionDatabase(foundExtensions: List<ExtensionInfo>) {
|
||||||
updateExtensionDatabaseMutex.withLock {
|
updateExtensionDatabaseMutex.withLock {
|
||||||
transaction {
|
transaction {
|
||||||
val uniqueExtensions =
|
val uniqueExtensions =
|
||||||
@@ -106,10 +105,10 @@ object ExtensionsList {
|
|||||||
.selectAll()
|
.selectAll()
|
||||||
.toList()
|
.toList()
|
||||||
.associateBy { it[ExtensionTable.pkgName] }
|
.associateBy { it[ExtensionTable.pkgName] }
|
||||||
val extensionsToUpdate = mutableListOf<Pair<OnlineExtension, ResultRow>>()
|
val extensionsToUpdate = mutableListOf<Pair<ExtensionInfo, ResultRow>>()
|
||||||
val extensionsToInsert = mutableListOf<OnlineExtension>()
|
val extensionsToInsert = mutableListOf<ExtensionInfo>()
|
||||||
val extensionsToDelete =
|
val extensionsToDelete =
|
||||||
installedExtensions.filter { it.value[ExtensionTable.repo] != null }.mapNotNull { (pkgName, extension) ->
|
installedExtensions.filter { it.value[ExtensionTable.storeIndexUrl] != null }.mapNotNull { (pkgName, extension) ->
|
||||||
extension.takeUnless { uniqueExtensions.any { it.pkgName == pkgName } }
|
extension.takeUnless { uniqueExtensions.any { it.pkgName == pkgName } }
|
||||||
}
|
}
|
||||||
uniqueExtensions.forEach {
|
uniqueExtensions.forEach {
|
||||||
@@ -132,7 +131,7 @@ object ExtensionsList {
|
|||||||
addBatch(EntityID(extensionRecord[ExtensionTable.id].value, ExtensionTable))
|
addBatch(EntityID(extensionRecord[ExtensionTable.id].value, ExtensionTable))
|
||||||
// Always update icon url and repo
|
// Always update icon url and repo
|
||||||
this[ExtensionTable.iconUrl] = foundExtension.iconUrl
|
this[ExtensionTable.iconUrl] = foundExtension.iconUrl
|
||||||
this[ExtensionTable.repo] = foundExtension.repo
|
this[ExtensionTable.storeIndexUrl] = foundExtension.storeIndexUrl
|
||||||
|
|
||||||
// add these because batch updates need matching columns
|
// add these because batch updates need matching columns
|
||||||
this[ExtensionTable.hasUpdate] = extensionRecord[ExtensionTable.hasUpdate]
|
this[ExtensionTable.hasUpdate] = extensionRecord[ExtensionTable.hasUpdate]
|
||||||
@@ -168,13 +167,14 @@ object ExtensionsList {
|
|||||||
extensionsToFullyUpdate.forEach { (foundExtension, extensionRecord) ->
|
extensionsToFullyUpdate.forEach { (foundExtension, extensionRecord) ->
|
||||||
addBatch(EntityID(extensionRecord[ExtensionTable.id].value, ExtensionTable))
|
addBatch(EntityID(extensionRecord[ExtensionTable.id].value, ExtensionTable))
|
||||||
// extension is not installed, so we can overwrite the data without a care
|
// extension is not installed, so we can overwrite the data without a care
|
||||||
this[ExtensionTable.repo] = foundExtension.repo
|
this[ExtensionTable.storeIndexUrl] = foundExtension.storeIndexUrl
|
||||||
this[ExtensionTable.name] = foundExtension.name
|
this[ExtensionTable.name] = foundExtension.name
|
||||||
|
this[ExtensionTable.extensionLib] = foundExtension.extensionLib
|
||||||
this[ExtensionTable.versionName] = foundExtension.versionName
|
this[ExtensionTable.versionName] = foundExtension.versionName
|
||||||
this[ExtensionTable.versionCode] = foundExtension.versionCode
|
this[ExtensionTable.versionCode] = foundExtension.versionCode
|
||||||
this[ExtensionTable.lang] = foundExtension.lang
|
this[ExtensionTable.lang] = foundExtension.lang
|
||||||
this[ExtensionTable.isNsfw] = foundExtension.isNsfw
|
this[ExtensionTable.contentWarning] = foundExtension.contentWarning.ordinal
|
||||||
this[ExtensionTable.apkName] = foundExtension.apkName
|
this[ExtensionTable.apkUrl] = foundExtension.apkUrl
|
||||||
this[ExtensionTable.iconUrl] = foundExtension.iconUrl
|
this[ExtensionTable.iconUrl] = foundExtension.iconUrl
|
||||||
}
|
}
|
||||||
}.toExecutable()
|
}.toExecutable()
|
||||||
@@ -183,14 +183,15 @@ object ExtensionsList {
|
|||||||
}
|
}
|
||||||
if (extensionsToInsert.isNotEmpty()) {
|
if (extensionsToInsert.isNotEmpty()) {
|
||||||
ExtensionTable.batchInsert(extensionsToInsert) { foundExtension ->
|
ExtensionTable.batchInsert(extensionsToInsert) { foundExtension ->
|
||||||
this[ExtensionTable.repo] = foundExtension.repo
|
this[ExtensionTable.storeIndexUrl] = foundExtension.storeIndexUrl
|
||||||
this[ExtensionTable.name] = foundExtension.name
|
this[ExtensionTable.name] = foundExtension.name
|
||||||
this[ExtensionTable.pkgName] = foundExtension.pkgName
|
this[ExtensionTable.pkgName] = foundExtension.pkgName
|
||||||
|
this[ExtensionTable.extensionLib] = foundExtension.extensionLib
|
||||||
this[ExtensionTable.versionName] = foundExtension.versionName
|
this[ExtensionTable.versionName] = foundExtension.versionName
|
||||||
this[ExtensionTable.versionCode] = foundExtension.versionCode
|
this[ExtensionTable.versionCode] = foundExtension.versionCode
|
||||||
this[ExtensionTable.lang] = foundExtension.lang
|
this[ExtensionTable.lang] = foundExtension.lang
|
||||||
this[ExtensionTable.isNsfw] = foundExtension.isNsfw
|
this[ExtensionTable.contentWarning] = foundExtension.contentWarning.ordinal
|
||||||
this[ExtensionTable.apkName] = foundExtension.apkName
|
this[ExtensionTable.apkUrl] = foundExtension.apkUrl
|
||||||
this[ExtensionTable.iconUrl] = foundExtension.iconUrl
|
this[ExtensionTable.iconUrl] = foundExtension.iconUrl
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -215,16 +216,4 @@ object ExtensionsList {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun String.repoUrlReplace(): String =
|
|
||||||
if (contains("github")) {
|
|
||||||
replace(repoMatchRegex) {
|
|
||||||
"https://raw.githubusercontent.com/${it.groupValues[2]}/${it.groupValues[3]}/" +
|
|
||||||
(it.groupValues.getOrNull(4)?.ifBlank { null } ?: "repo") +
|
|
||||||
"/" +
|
|
||||||
(it.groupValues.getOrNull(5)?.ifBlank { null } ?: "index.min.json")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package suwayomi.tachidesk.manga.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 suwayomi.tachidesk.manga.model.dataclass.ExtensionStore
|
||||||
|
|
||||||
|
interface BaseNetworkExtensionStore {
|
||||||
|
fun toExtensionStore(indexUrl: String): ExtensionStore
|
||||||
|
}
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
package suwayomi.tachidesk.manga.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 eu.kanade.tachiyomi.network.GET
|
|
||||||
import eu.kanade.tachiyomi.network.NetworkHelper
|
|
||||||
import eu.kanade.tachiyomi.network.awaitSuccess
|
|
||||||
import eu.kanade.tachiyomi.network.parseAs
|
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import suwayomi.tachidesk.manga.impl.util.PackageTools.LIB_VERSION_MAX
|
|
||||||
import suwayomi.tachidesk.manga.impl.util.PackageTools.LIB_VERSION_MIN
|
|
||||||
import uy.kohesive.injekt.injectLazy
|
|
||||||
|
|
||||||
object ExtensionGithubApi {
|
|
||||||
private val logger = KotlinLogging.logger {}
|
|
||||||
private val json: Json by injectLazy()
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
private data class ExtensionJsonObject(
|
|
||||||
val name: String,
|
|
||||||
val pkg: String,
|
|
||||||
val apk: String,
|
|
||||||
val lang: String,
|
|
||||||
val code: Int,
|
|
||||||
val version: String,
|
|
||||||
val nsfw: Int,
|
|
||||||
val hasReadme: Int = 0,
|
|
||||||
val hasChangelog: Int = 0,
|
|
||||||
val sources: List<ExtensionSourceJsonObject>?,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
private data class ExtensionSourceJsonObject(
|
|
||||||
val name: String,
|
|
||||||
val lang: String,
|
|
||||||
val id: Long,
|
|
||||||
val baseUrl: String,
|
|
||||||
)
|
|
||||||
|
|
||||||
suspend fun findExtensions(repo: String): List<OnlineExtension> {
|
|
||||||
val response =
|
|
||||||
client.newCall(GET(repo)).awaitSuccess()
|
|
||||||
|
|
||||||
return with(json) {
|
|
||||||
response
|
|
||||||
.parseAs<List<ExtensionJsonObject>>()
|
|
||||||
.toExtensions(repo.substringBeforeLast('/') + '/')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getApkUrl(
|
|
||||||
repo: String,
|
|
||||||
apkName: String,
|
|
||||||
): String = "${repo}apk/$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-Type", "application/json")
|
|
||||||
.build()
|
|
||||||
}.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun List<ExtensionJsonObject>.toExtensions(repo: String): List<OnlineExtension> =
|
|
||||||
this
|
|
||||||
.filter {
|
|
||||||
val libVersion = it.version.substringBeforeLast('.').toDouble()
|
|
||||||
libVersion in LIB_VERSION_MIN..LIB_VERSION_MAX
|
|
||||||
}.map {
|
|
||||||
OnlineExtension(
|
|
||||||
repo = repo,
|
|
||||||
name = it.name.substringAfter("Tachiyomi: "),
|
|
||||||
pkgName = it.pkg,
|
|
||||||
versionName = it.version,
|
|
||||||
versionCode = it.code,
|
|
||||||
lang = it.lang,
|
|
||||||
isNsfw = it.nsfw == 1,
|
|
||||||
hasReadme = it.hasReadme == 1,
|
|
||||||
hasChangelog = it.hasChangelog == 1,
|
|
||||||
sources = it.sources?.toExtensionSources() ?: emptyList(),
|
|
||||||
apkName = it.apk,
|
|
||||||
iconUrl = "${repo}icon/${it.pkg}.png",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun List<ExtensionSourceJsonObject>.toExtensionSources(): List<OnlineExtensionSource> =
|
|
||||||
this.map {
|
|
||||||
OnlineExtensionSource(
|
|
||||||
name = it.name,
|
|
||||||
lang = it.lang,
|
|
||||||
id = it.id,
|
|
||||||
baseUrl = it.baseUrl,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package suwayomi.tachidesk.manga.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 kotlinx.serialization.ExperimentalSerializationApi
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.JsonNames
|
||||||
|
import kotlinx.serialization.protobuf.ProtoNumber
|
||||||
|
import suwayomi.tachidesk.manga.model.dataclass.ContentWarning
|
||||||
|
import suwayomi.tachidesk.manga.model.dataclass.ExtensionInfo
|
||||||
|
import suwayomi.tachidesk.manga.model.dataclass.ExtensionSource
|
||||||
|
import suwayomi.tachidesk.manga.model.dataclass.ExtensionStore
|
||||||
|
|
||||||
|
@OptIn(ExperimentalSerializationApi::class)
|
||||||
|
@Serializable
|
||||||
|
data class NetworkExtensionStore(
|
||||||
|
@ProtoNumber(1) val name: String,
|
||||||
|
@ProtoNumber(2) val badgeLabel: String,
|
||||||
|
@ProtoNumber(3) val signingKey: String,
|
||||||
|
@ProtoNumber(4) val contact: Contact,
|
||||||
|
@ProtoNumber(101) val extensionList: ExtensionList?,
|
||||||
|
@ProtoNumber(102) val extensionListUrl: String?,
|
||||||
|
) : BaseNetworkExtensionStore {
|
||||||
|
@Serializable
|
||||||
|
data class Contact(
|
||||||
|
@ProtoNumber(1) val website: String,
|
||||||
|
@ProtoNumber(2) val discord: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ExtensionList(
|
||||||
|
@ProtoNumber(1) val extensions: List<Extension>,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Extension(
|
||||||
|
@ProtoNumber(1) val name: String,
|
||||||
|
@ProtoNumber(2) val packageName: String,
|
||||||
|
@ProtoNumber(3) val resources: Resources,
|
||||||
|
@ProtoNumber(4) val extensionLib: String,
|
||||||
|
@ProtoNumber(5) val versionCode: Long,
|
||||||
|
@ProtoNumber(6) val versionName: String,
|
||||||
|
@ProtoNumber(7) val contentWarning: ContentWarning,
|
||||||
|
@ProtoNumber(8) val sources: List<Source>,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Resources(
|
||||||
|
@ProtoNumber(1) val apkUrl: String,
|
||||||
|
@ProtoNumber(2) val iconUrl: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Source(
|
||||||
|
@ProtoNumber(1) val id: Long,
|
||||||
|
@ProtoNumber(2) val name: String,
|
||||||
|
@ProtoNumber(3) val language: String,
|
||||||
|
@ProtoNumber(4) val homeUrl: String = "",
|
||||||
|
@ProtoNumber(5) val mirrorUrls: List<String> = emptyList(),
|
||||||
|
// @ProtoNumber(6) val contentWarning: ContentWarning = ContentWarning.SAFE,
|
||||||
|
@ProtoNumber(7) val message: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class ContentWarning {
|
||||||
|
@ProtoNumber(0)
|
||||||
|
@JsonNames("CONTENT_WARNING_UNSPECIFIED")
|
||||||
|
UNSPECIFIED,
|
||||||
|
|
||||||
|
@ProtoNumber(1)
|
||||||
|
@JsonNames("CONTENT_WARNING_SAFE")
|
||||||
|
SAFE,
|
||||||
|
|
||||||
|
@ProtoNumber(2)
|
||||||
|
@JsonNames("CONTENT_WARNING_MIXED")
|
||||||
|
MIXED,
|
||||||
|
|
||||||
|
@ProtoNumber(3)
|
||||||
|
@JsonNames("CONTENT_WARNING_NSFW")
|
||||||
|
NSFW,
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun toExtensionStore(indexUrl: String): ExtensionStore =
|
||||||
|
ExtensionStore(
|
||||||
|
indexUrl = indexUrl,
|
||||||
|
name = name,
|
||||||
|
badgeLabel = badgeLabel,
|
||||||
|
signingKey = signingKey,
|
||||||
|
contact =
|
||||||
|
ExtensionStore.Contact(
|
||||||
|
website = contact.website,
|
||||||
|
discord = contact.discord,
|
||||||
|
),
|
||||||
|
isLegacy = false,
|
||||||
|
extensionListUrl = extensionListUrl,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun NetworkExtensionStore.ExtensionList.toExtensionInfos(store: ExtensionStore): List<ExtensionInfo> =
|
||||||
|
extensions.map { extension ->
|
||||||
|
val lang = extension.sources.map { it.language }.toSet()
|
||||||
|
ExtensionInfo(
|
||||||
|
storeIndexUrl = store.indexUrl,
|
||||||
|
name = extension.name,
|
||||||
|
pkgName = extension.packageName,
|
||||||
|
apkUrl = extension.resources.apkUrl,
|
||||||
|
iconUrl = extension.resources.iconUrl,
|
||||||
|
extensionLib = extension.extensionLib,
|
||||||
|
versionCode = extension.versionCode,
|
||||||
|
versionName = extension.versionName,
|
||||||
|
lang = if (lang.size == 1) lang.first() else "all",
|
||||||
|
contentWarning =
|
||||||
|
when (extension.contentWarning) {
|
||||||
|
NetworkExtensionStore.ContentWarning.SAFE,
|
||||||
|
NetworkExtensionStore.ContentWarning.UNSPECIFIED,
|
||||||
|
-> ContentWarning.SAFE
|
||||||
|
|
||||||
|
NetworkExtensionStore.ContentWarning.MIXED -> ContentWarning.MIXED
|
||||||
|
|
||||||
|
NetworkExtensionStore.ContentWarning.NSFW -> ContentWarning.NSFW
|
||||||
|
},
|
||||||
|
sources =
|
||||||
|
extension.sources.map { source ->
|
||||||
|
ExtensionSource(
|
||||||
|
id = source.id,
|
||||||
|
name = source.name,
|
||||||
|
lang = source.language,
|
||||||
|
homeUrl = source.homeUrl,
|
||||||
|
message = source.message,
|
||||||
|
contentWarning =
|
||||||
|
when (extension.contentWarning) { // todo source.contentWarning
|
||||||
|
NetworkExtensionStore.ContentWarning.SAFE,
|
||||||
|
NetworkExtensionStore.ContentWarning.UNSPECIFIED,
|
||||||
|
-> ContentWarning.SAFE
|
||||||
|
|
||||||
|
NetworkExtensionStore.ContentWarning.MIXED -> ContentWarning.MIXED
|
||||||
|
|
||||||
|
NetworkExtensionStore.ContentWarning.NSFW -> ContentWarning.NSFW
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package suwayomi.tachidesk.manga.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 kotlinx.serialization.ExperimentalSerializationApi
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import suwayomi.tachidesk.manga.model.dataclass.ContentWarning
|
||||||
|
import suwayomi.tachidesk.manga.model.dataclass.ExtensionInfo
|
||||||
|
import suwayomi.tachidesk.manga.model.dataclass.ExtensionSource
|
||||||
|
import suwayomi.tachidesk.manga.model.dataclass.ExtensionStore
|
||||||
|
|
||||||
|
@OptIn(ExperimentalSerializationApi::class)
|
||||||
|
@Serializable
|
||||||
|
data class NetworkLegacyExtension(
|
||||||
|
val name: String,
|
||||||
|
val pkg: String,
|
||||||
|
val apk: String,
|
||||||
|
val lang: String,
|
||||||
|
val version: String,
|
||||||
|
val code: Long,
|
||||||
|
val nsfw: Int,
|
||||||
|
val sources: List<Source>? = null,
|
||||||
|
) {
|
||||||
|
@Serializable
|
||||||
|
data class Source(
|
||||||
|
val id: Long,
|
||||||
|
val lang: String,
|
||||||
|
val name: String,
|
||||||
|
val baseUrl: String,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun NetworkLegacyExtension.toExtensionInfo(
|
||||||
|
store: ExtensionStore,
|
||||||
|
storeBaseUrl: String,
|
||||||
|
): ExtensionInfo =
|
||||||
|
ExtensionInfo(
|
||||||
|
storeIndexUrl = store.indexUrl,
|
||||||
|
name = name.substringAfter("Tachiyomi: "),
|
||||||
|
pkgName = pkg,
|
||||||
|
apkUrl = "$storeBaseUrl/apk/$apk",
|
||||||
|
iconUrl = "$storeBaseUrl/icon/$pkg.png",
|
||||||
|
extensionLib = version.substringBeforeLast('.'),
|
||||||
|
versionCode = code,
|
||||||
|
versionName = version,
|
||||||
|
lang = lang,
|
||||||
|
contentWarning = if (nsfw == 1) ContentWarning.MIXED else ContentWarning.SAFE,
|
||||||
|
sources =
|
||||||
|
if (sources.isNullOrEmpty()) {
|
||||||
|
listOf(
|
||||||
|
ExtensionSource(
|
||||||
|
id = 0,
|
||||||
|
name = name,
|
||||||
|
lang = lang,
|
||||||
|
homeUrl = "",
|
||||||
|
message = null,
|
||||||
|
contentWarning = if (nsfw == 1) ContentWarning.MIXED else ContentWarning.SAFE,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
sources.map { source ->
|
||||||
|
ExtensionSource(
|
||||||
|
id = source.id,
|
||||||
|
name = source.name,
|
||||||
|
lang = source.lang,
|
||||||
|
homeUrl = source.baseUrl,
|
||||||
|
message = null,
|
||||||
|
contentWarning = if (nsfw == 1) ContentWarning.MIXED else ContentWarning.SAFE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package suwayomi.tachidesk.manga.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 kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import suwayomi.tachidesk.manga.model.dataclass.ExtensionStore
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class NetworkLegacyExtensionRepo(
|
||||||
|
@SerialName("index_v2") val indexV2: String?,
|
||||||
|
val meta: Meta,
|
||||||
|
) : BaseNetworkExtensionStore {
|
||||||
|
@Serializable
|
||||||
|
data class Meta(
|
||||||
|
val name: String,
|
||||||
|
val shortName: String?,
|
||||||
|
val website: String,
|
||||||
|
val signingKeyFingerprint: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun toExtensionStore(indexUrl: String): ExtensionStore =
|
||||||
|
ExtensionStore(
|
||||||
|
indexUrl = indexUrl,
|
||||||
|
name = meta.name,
|
||||||
|
badgeLabel = meta.shortName ?: meta.name,
|
||||||
|
signingKey = meta.signingKeyFingerprint,
|
||||||
|
contact =
|
||||||
|
ExtensionStore.Contact(
|
||||||
|
website = meta.website,
|
||||||
|
discord = null,
|
||||||
|
),
|
||||||
|
isLegacy = true,
|
||||||
|
extensionListUrl = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
package suwayomi.tachidesk.manga.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 OnlineExtensionSource(
|
|
||||||
val name: String,
|
|
||||||
val lang: String,
|
|
||||||
val id: Long,
|
|
||||||
val baseUrl: String,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class OnlineExtension(
|
|
||||||
val repo: String,
|
|
||||||
val name: String,
|
|
||||||
val pkgName: String,
|
|
||||||
val apkName: String,
|
|
||||||
val lang: String,
|
|
||||||
val versionCode: Int,
|
|
||||||
val versionName: String,
|
|
||||||
val isNsfw: Boolean,
|
|
||||||
val hasReadme: Boolean,
|
|
||||||
val hasChangelog: Boolean,
|
|
||||||
val sources: List<OnlineExtensionSource>,
|
|
||||||
val iconUrl: String,
|
|
||||||
)
|
|
||||||
@@ -222,6 +222,48 @@ object Track {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun bindTrackRecord(
|
||||||
|
mangaId: Int,
|
||||||
|
trackRecordId: Int,
|
||||||
|
): Int {
|
||||||
|
val (trackRecord, existingTrackRecord) =
|
||||||
|
transaction {
|
||||||
|
val trackRecord =
|
||||||
|
TrackRecordTable
|
||||||
|
.selectAll()
|
||||||
|
.where {
|
||||||
|
(TrackRecordTable.id eq trackRecordId)
|
||||||
|
}.first()
|
||||||
|
.toTrackRecordDataClass()
|
||||||
|
|
||||||
|
val existingTrackRecord =
|
||||||
|
TrackRecordTable
|
||||||
|
.selectAll()
|
||||||
|
.where {
|
||||||
|
(TrackRecordTable.mangaId eq mangaId) and (TrackRecordTable.trackerId eq trackRecord.trackerId)
|
||||||
|
}.firstOrNull()
|
||||||
|
?.toTrackRecordDataClass()
|
||||||
|
|
||||||
|
trackRecord to existingTrackRecord
|
||||||
|
}
|
||||||
|
|
||||||
|
val isAlreadyBoundToManga = trackRecord.mangaId == mangaId
|
||||||
|
if (isAlreadyBoundToManga) {
|
||||||
|
return trackRecordId
|
||||||
|
}
|
||||||
|
|
||||||
|
val hasRecordForTracker = existingTrackRecord != null
|
||||||
|
if (hasRecordForTracker) {
|
||||||
|
val updatedTrack = trackRecord.copy(id = existingTrackRecord.id, mangaId = mangaId).toTrack()
|
||||||
|
|
||||||
|
return updateTrackRecord(updatedTrack)
|
||||||
|
}
|
||||||
|
|
||||||
|
val newTrack = trackRecord.copy(mangaId = mangaId).toTrack()
|
||||||
|
|
||||||
|
return insertTrackRecord(newTrack)
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun refresh(recordId: Int) {
|
suspend fun refresh(recordId: Int) {
|
||||||
val recordDb =
|
val recordDb =
|
||||||
transaction {
|
transaction {
|
||||||
@@ -423,9 +465,9 @@ object Track {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateTrackRecord(track: Track) = updateTrackRecords(listOf(track))
|
fun updateTrackRecord(track: Track): Int = updateTrackRecords(listOf(track)).first()
|
||||||
|
|
||||||
fun updateTrackRecords(tracks: List<Track>) =
|
fun updateTrackRecords(tracks: List<Track>): List<Int> =
|
||||||
transaction {
|
transaction {
|
||||||
if (tracks.isNotEmpty()) {
|
if (tracks.isNotEmpty()) {
|
||||||
BatchUpdateStatement(TrackRecordTable)
|
BatchUpdateStatement(TrackRecordTable)
|
||||||
@@ -447,6 +489,8 @@ object Track {
|
|||||||
}.toExecutable()
|
}.toExecutable()
|
||||||
.execute(this@transaction)
|
.execute(this@transaction)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tracks.map { it.id!! }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun insertTrackRecord(track: Track): Int = insertTrackRecords(listOf(track)).first()
|
fun insertTrackRecord(track: Track): Int = insertTrackRecords(listOf(track)).first()
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ import kotlinx.coroutines.sync.withPermit
|
|||||||
import suwayomi.tachidesk.global.impl.sync.SyncManager
|
import suwayomi.tachidesk.global.impl.sync.SyncManager
|
||||||
import suwayomi.tachidesk.manga.impl.Category
|
import suwayomi.tachidesk.manga.impl.Category
|
||||||
import suwayomi.tachidesk.manga.impl.CategoryManga
|
import suwayomi.tachidesk.manga.impl.CategoryManga
|
||||||
import suwayomi.tachidesk.manga.impl.Chapter
|
|
||||||
import suwayomi.tachidesk.manga.impl.Manga
|
import suwayomi.tachidesk.manga.impl.Manga
|
||||||
import suwayomi.tachidesk.manga.model.dataclass.CategoryDataClass
|
import suwayomi.tachidesk.manga.model.dataclass.CategoryDataClass
|
||||||
import suwayomi.tachidesk.manga.model.dataclass.IncludeOrExclude
|
import suwayomi.tachidesk.manga.model.dataclass.IncludeOrExclude
|
||||||
@@ -311,10 +310,10 @@ class Updater : IUpdater {
|
|||||||
tracker[job.manga.id] =
|
tracker[job.manga.id] =
|
||||||
try {
|
try {
|
||||||
logger.info { "Updating ${job.manga}" }
|
logger.info { "Updating ${job.manga}" }
|
||||||
if (serverConfig.updateMangas.value || !job.manga.initialized) {
|
Manga.updateMangaAndChapters(
|
||||||
Manga.getManga(job.manga.id, true)
|
job.manga.id,
|
||||||
}
|
updateManga = serverConfig.updateMangas.value || !job.manga.initialized,
|
||||||
Chapter.getChapterList(job.manga.id, true)
|
)
|
||||||
job.copy(status = JobStatus.COMPLETE)
|
job.copy(status = JobStatus.COMPLETE)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error(e) { "Error while updating ${job.manga}" }
|
logger.error(e) { "Error while updating ${job.manga}" }
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import io.github.oshai.kotlinlogging.KotlinLogging
|
|||||||
import org.jetbrains.exposed.v1.core.eq
|
import org.jetbrains.exposed.v1.core.eq
|
||||||
import org.jetbrains.exposed.v1.jdbc.selectAll
|
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||||
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource
|
import suwayomi.tachidesk.manga.impl.util.source.GetSource
|
||||||
import suwayomi.tachidesk.manga.model.table.ChapterTable
|
import suwayomi.tachidesk.manga.model.table.ChapterTable
|
||||||
import suwayomi.tachidesk.manga.model.table.MangaTable
|
import suwayomi.tachidesk.manga.model.table.MangaTable
|
||||||
import suwayomi.tachidesk.server.ApplicationDirs
|
import suwayomi.tachidesk.server.ApplicationDirs
|
||||||
@@ -37,7 +37,7 @@ private fun getMangaDir(
|
|||||||
private fun getMangaDir(mangaId: Int): String =
|
private fun getMangaDir(mangaId: Int): String =
|
||||||
transaction {
|
transaction {
|
||||||
val mangaEntry = MangaTable.selectAll().where { MangaTable.id eq mangaId }.first()
|
val mangaEntry = MangaTable.selectAll().where { MangaTable.id eq mangaId }.first()
|
||||||
val source = GetCatalogueSource.getCatalogueSourceOrStub(mangaEntry[MangaTable.sourceReference])
|
val source = GetSource.getSourceOrStub(mangaEntry[MangaTable.sourceReference])
|
||||||
|
|
||||||
getMangaDir(mangaEntry[MangaTable.title], source.toString())
|
getMangaDir(mangaEntry[MangaTable.title], source.toString())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,8 +40,13 @@ object PackageTools {
|
|||||||
const val METADATA_SOURCE_CLASS = "tachiyomi.extension.class"
|
const val METADATA_SOURCE_CLASS = "tachiyomi.extension.class"
|
||||||
const val METADATA_SOURCE_FACTORY = "tachiyomi.extension.factory"
|
const val METADATA_SOURCE_FACTORY = "tachiyomi.extension.factory"
|
||||||
const val METADATA_NSFW = "tachiyomi.extension.nsfw"
|
const val METADATA_NSFW = "tachiyomi.extension.nsfw"
|
||||||
|
|
||||||
|
const val METADATA_NAME = "tachiyomix.name"
|
||||||
|
const val METADATA_EXTENSION_LIB = "tachiyomix.extensionLib"
|
||||||
|
const val METADATA_CONTENT_WARNING = "tachiyomix.contentWarning"
|
||||||
|
|
||||||
const val LIB_VERSION_MIN = 1.3
|
const val LIB_VERSION_MIN = 1.3
|
||||||
const val LIB_VERSION_MAX = 1.5
|
const val LIB_VERSION_MAX = 1.6
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert dex to jar, a wrapper for the dex2jar library
|
* Convert dex to jar, a wrapper for the dex2jar library
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package suwayomi.tachidesk.manga.impl.util.lang
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
|
||||||
|
val JsonObjectEmpty = JsonObject(emptyMap())
|
||||||
|
|
||||||
|
val JsonObjectEmptyBytes = byteArrayOf(0x7B, 0x7D)
|
||||||
|
|
||||||
|
val JsonObject.Companion.EMPTY: JsonObject
|
||||||
|
inline get() = JsonObjectEmpty
|
||||||
@@ -7,7 +7,6 @@ package suwayomi.tachidesk.manga.impl.util.source
|
|||||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
||||||
|
|
||||||
import eu.kanade.tachiyomi.source.CatalogueSource
|
|
||||||
import eu.kanade.tachiyomi.source.Source
|
import eu.kanade.tachiyomi.source.Source
|
||||||
import eu.kanade.tachiyomi.source.SourceFactory
|
import eu.kanade.tachiyomi.source.SourceFactory
|
||||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||||
@@ -22,14 +21,14 @@ import suwayomi.tachidesk.server.ApplicationDirs
|
|||||||
import uy.kohesive.injekt.injectLazy
|
import uy.kohesive.injekt.injectLazy
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
object GetCatalogueSource {
|
object GetSource {
|
||||||
private val logger = KotlinLogging.logger { }
|
private val logger = KotlinLogging.logger { }
|
||||||
|
|
||||||
private val sourceCache = ConcurrentHashMap<Long, CatalogueSource>()
|
private val sourceCache = ConcurrentHashMap<Long, Source>()
|
||||||
private val applicationDirs: ApplicationDirs by injectLazy()
|
private val applicationDirs: ApplicationDirs by injectLazy()
|
||||||
|
|
||||||
private fun getCatalogueSource(sourceId: Long): CatalogueSource? {
|
private fun getSource(sourceId: Long): Source? {
|
||||||
val cachedResult: CatalogueSource? = sourceCache[sourceId]
|
val cachedResult: Source? = sourceCache[sourceId]
|
||||||
if (cachedResult != null) {
|
if (cachedResult != null) {
|
||||||
return cachedResult
|
return cachedResult
|
||||||
}
|
}
|
||||||
@@ -45,7 +44,9 @@ object GetCatalogueSource {
|
|||||||
ExtensionTable.selectAll().where { ExtensionTable.id eq extensionId }.first()
|
ExtensionTable.selectAll().where { ExtensionTable.id eq extensionId }.first()
|
||||||
}
|
}
|
||||||
|
|
||||||
val apkName = extensionRecord[ExtensionTable.apkName]
|
val apkName =
|
||||||
|
extensionRecord[ExtensionTable.apkName]
|
||||||
|
?: throw NullPointerException("Missing apkName")
|
||||||
val className = extensionRecord[ExtensionTable.classFQName]
|
val className = extensionRecord[ExtensionTable.classFQName]
|
||||||
val jarName = apkName.substringBefore(".apk") + ".jar"
|
val jarName = apkName.substringBefore(".apk") + ".jar"
|
||||||
val jarPath = "${applicationDirs.extensionsRoot}/$jarName"
|
val jarPath = "${applicationDirs.extensionsRoot}/$jarName"
|
||||||
@@ -60,25 +61,25 @@ object GetCatalogueSource {
|
|||||||
return sourceCache[sourceId]!!
|
return sourceCache[sourceId]!!
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getCatalogueSourceOrNull(sourceId: Long): CatalogueSource? =
|
fun getSourceOrNull(sourceId: Long): Source? =
|
||||||
try {
|
try {
|
||||||
getCatalogueSource(sourceId)
|
getSource(sourceId)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.warn(e) { "getCatalogueSource($sourceId) failed" }
|
logger.warn(e) { "getCatalogueSource($sourceId) failed" }
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getCatalogueSourceOrStub(sourceId: Long): CatalogueSource = getCatalogueSourceOrNull(sourceId) ?: StubSource(sourceId)
|
fun getSourceOrStub(sourceId: Long): Source = getSourceOrNull(sourceId) ?: StubSource(sourceId)
|
||||||
|
|
||||||
fun registerCatalogueSource(sourcePair: Pair<Long, CatalogueSource>) {
|
fun registerSource(sourcePair: Pair<Long, Source>) {
|
||||||
sourceCache += sourcePair
|
sourceCache += sourcePair
|
||||||
}
|
}
|
||||||
|
|
||||||
fun unregisterCatalogueSource(sourceId: Long) {
|
fun unregisterSource(sourceId: Long) {
|
||||||
sourceCache.remove(sourceId)
|
sourceCache.remove(sourceId)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun unregisterAllCatalogueSources() {
|
fun unregisterAllSources() {
|
||||||
(sourceCache - 0L).forEach { (id, _) ->
|
(sourceCache - 0L).forEach { (id, _) ->
|
||||||
sourceCache.remove(id)
|
sourceCache.remove(id)
|
||||||
}
|
}
|
||||||
@@ -13,6 +13,7 @@ import eu.kanade.tachiyomi.source.model.MangasPage
|
|||||||
import eu.kanade.tachiyomi.source.model.Page
|
import eu.kanade.tachiyomi.source.model.Page
|
||||||
import eu.kanade.tachiyomi.source.model.SChapter
|
import eu.kanade.tachiyomi.source.model.SChapter
|
||||||
import eu.kanade.tachiyomi.source.model.SManga
|
import eu.kanade.tachiyomi.source.model.SManga
|
||||||
|
import eu.kanade.tachiyomi.source.model.SMangaUpdate
|
||||||
import rx.Observable
|
import rx.Observable
|
||||||
|
|
||||||
open class StubSource(
|
open class StubSource(
|
||||||
@@ -23,9 +24,17 @@ open class StubSource(
|
|||||||
override val name: String
|
override val name: String
|
||||||
get() = id.toString()
|
get() = id.toString()
|
||||||
|
|
||||||
|
override suspend fun getPopularManga(page: Int): MangasPage = throw getSourceNotInstalledException()
|
||||||
|
|
||||||
@Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getPopularManga"))
|
@Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getPopularManga"))
|
||||||
override fun fetchPopularManga(page: Int): Observable<MangasPage> = Observable.error(getSourceNotInstalledException())
|
override fun fetchPopularManga(page: Int): Observable<MangasPage> = Observable.error(getSourceNotInstalledException())
|
||||||
|
|
||||||
|
override suspend fun getSearchManga(
|
||||||
|
page: Int,
|
||||||
|
query: String,
|
||||||
|
filters: FilterList,
|
||||||
|
): MangasPage = throw getSourceNotInstalledException()
|
||||||
|
|
||||||
@Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getSearchManga"))
|
@Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getSearchManga"))
|
||||||
override fun fetchSearchManga(
|
override fun fetchSearchManga(
|
||||||
page: Int,
|
page: Int,
|
||||||
@@ -33,17 +42,28 @@ open class StubSource(
|
|||||||
filters: FilterList,
|
filters: FilterList,
|
||||||
): Observable<MangasPage> = Observable.error(getSourceNotInstalledException())
|
): Observable<MangasPage> = Observable.error(getSourceNotInstalledException())
|
||||||
|
|
||||||
|
override suspend fun getLatestUpdates(page: Int): MangasPage = throw getSourceNotInstalledException()
|
||||||
|
|
||||||
@Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getLatestUpdates"))
|
@Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getLatestUpdates"))
|
||||||
override fun fetchLatestUpdates(page: Int): Observable<MangasPage> = Observable.error(getSourceNotInstalledException())
|
override fun fetchLatestUpdates(page: Int): Observable<MangasPage> = Observable.error(getSourceNotInstalledException())
|
||||||
|
|
||||||
override fun getFilterList(): FilterList = FilterList()
|
override fun getFilterList(): FilterList = FilterList()
|
||||||
|
|
||||||
|
override suspend fun getMangaUpdate(
|
||||||
|
manga: SManga,
|
||||||
|
chapters: List<SChapter>,
|
||||||
|
fetchDetails: Boolean,
|
||||||
|
fetchChapters: Boolean,
|
||||||
|
): SMangaUpdate = throw getSourceNotInstalledException()
|
||||||
|
|
||||||
@Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getMangaDetails"))
|
@Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getMangaDetails"))
|
||||||
override fun fetchMangaDetails(manga: SManga): Observable<SManga> = Observable.error(getSourceNotInstalledException())
|
override fun fetchMangaDetails(manga: SManga): Observable<SManga> = Observable.error(getSourceNotInstalledException())
|
||||||
|
|
||||||
@Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getChapterList"))
|
@Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getChapterList"))
|
||||||
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> = Observable.error(getSourceNotInstalledException())
|
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> = Observable.error(getSourceNotInstalledException())
|
||||||
|
|
||||||
|
override suspend fun getPageList(chapter: SChapter): List<Page> = throw getSourceNotInstalledException()
|
||||||
|
|
||||||
@Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getPageList"))
|
@Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getPageList"))
|
||||||
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> = Observable.error(getSourceNotInstalledException())
|
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> = Observable.error(getSourceNotInstalledException())
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ object ImageResponse {
|
|||||||
/**
|
/**
|
||||||
* Get a cached image response
|
* Get a cached image response
|
||||||
*
|
*
|
||||||
* Note: The caller should also call [clearCachedImage] when appropriate
|
* Note: The caller should also call [ImageResponse.clearCachedImage] when appropriate
|
||||||
*
|
*
|
||||||
* @param cacheSavePath where to save the cached image. Caller should decide to use perma cache or temp cache (OS temp dir)
|
* @param cacheSavePath where to save the cached image. Caller should decide to use perma cache or temp cache (OS temp dir)
|
||||||
* @param fileName what the saved cache file should be named
|
* @param fileName what the saved cache file should be named
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
package suwayomi.tachidesk.manga.model.dataclass
|
package suwayomi.tachidesk.manga.model.dataclass
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||||
import eu.kanade.tachiyomi.source.model.SChapter
|
import eu.kanade.tachiyomi.source.model.SChapter
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
import org.jetbrains.exposed.v1.core.eq
|
import org.jetbrains.exposed.v1.core.eq
|
||||||
import org.jetbrains.exposed.v1.jdbc.selectAll
|
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||||
import suwayomi.tachidesk.manga.impl.Chapter.getChapterMetaMap
|
import suwayomi.tachidesk.manga.impl.Chapter.getChapterMetaMap
|
||||||
|
import suwayomi.tachidesk.manga.impl.util.lang.EMPTY
|
||||||
import suwayomi.tachidesk.manga.model.table.ChapterTable
|
import suwayomi.tachidesk.manga.model.table.ChapterTable
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -43,6 +46,8 @@ data class ChapterDataClass(
|
|||||||
val pageCount: Int = -1,
|
val pageCount: Int = -1,
|
||||||
val lastModifiedAt: Long = 0,
|
val lastModifiedAt: Long = 0,
|
||||||
val version: Long = 0,
|
val version: Long = 0,
|
||||||
|
@JsonIgnore
|
||||||
|
val memo: JsonObject = JsonObject.EMPTY,
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
fun fromSChapter(
|
fun fromSChapter(
|
||||||
@@ -60,6 +65,7 @@ data class ChapterDataClass(
|
|||||||
uploadDate = sChapter.date_upload,
|
uploadDate = sChapter.date_upload,
|
||||||
chapterNumber = sChapter.chapter_number,
|
chapterNumber = sChapter.chapter_number,
|
||||||
scanlator = sChapter.scanlator,
|
scanlator = sChapter.scanlator,
|
||||||
|
memo = sChapter.memo,
|
||||||
index = index,
|
index = index,
|
||||||
fetchedAt = fetchedAt,
|
fetchedAt = fetchedAt,
|
||||||
realUrl = realUrl,
|
realUrl = realUrl,
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package suwayomi.tachidesk.manga.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 ExtensionInfo(
|
||||||
|
val storeIndexUrl: String,
|
||||||
|
val name: String,
|
||||||
|
val pkgName: String,
|
||||||
|
val apkUrl: String,
|
||||||
|
val iconUrl: String,
|
||||||
|
val extensionLib: String,
|
||||||
|
val versionCode: Long,
|
||||||
|
val versionName: String,
|
||||||
|
val lang: String,
|
||||||
|
val contentWarning: ContentWarning,
|
||||||
|
val sources: List<ExtensionSource>,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ExtensionSource(
|
||||||
|
val id: Long,
|
||||||
|
val name: String,
|
||||||
|
val lang: String,
|
||||||
|
val homeUrl: String,
|
||||||
|
val message: String?,
|
||||||
|
val contentWarning: ContentWarning,
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class ContentWarning {
|
||||||
|
SAFE,
|
||||||
|
MIXED,
|
||||||
|
NSFW,
|
||||||
|
;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun valueOf(contentWarning: Int) = entries.find { it.ordinal == contentWarning } ?: SAFE
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package suwayomi.tachidesk.manga.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 ExtensionStore(
|
||||||
|
val indexUrl: String,
|
||||||
|
val name: String,
|
||||||
|
val badgeLabel: String,
|
||||||
|
val signingKey: String,
|
||||||
|
val contact: Contact,
|
||||||
|
val isLegacy: Boolean,
|
||||||
|
val extensionListUrl: String?,
|
||||||
|
) {
|
||||||
|
data class Contact(
|
||||||
|
val website: String,
|
||||||
|
val discord: String?,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -7,8 +7,11 @@ package suwayomi.tachidesk.manga.model.dataclass
|
|||||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||||
import eu.kanade.tachiyomi.source.model.UpdateStrategy
|
import eu.kanade.tachiyomi.source.model.UpdateStrategy
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
import suwayomi.tachidesk.manga.impl.Manga.getMangaMetaMap
|
import suwayomi.tachidesk.manga.impl.Manga.getMangaMetaMap
|
||||||
|
import suwayomi.tachidesk.manga.impl.util.lang.EMPTY
|
||||||
import suwayomi.tachidesk.manga.impl.util.lang.trimAll
|
import suwayomi.tachidesk.manga.impl.util.lang.trimAll
|
||||||
import suwayomi.tachidesk.manga.model.table.MangaStatus
|
import suwayomi.tachidesk.manga.model.table.MangaStatus
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
@@ -44,6 +47,8 @@ data class MangaDataClass(
|
|||||||
val trackers: List<MangaTrackerDataClass>? = null,
|
val trackers: List<MangaTrackerDataClass>? = null,
|
||||||
val lastModifiedAt: Long = 0,
|
val lastModifiedAt: Long = 0,
|
||||||
val version: Long = 0,
|
val version: Long = 0,
|
||||||
|
@JsonIgnore
|
||||||
|
val memo: JsonObject = JsonObject.EMPTY,
|
||||||
) {
|
) {
|
||||||
override fun toString(): String = "\"$title\" (id= $id) (sourceId= $sourceId)"
|
override fun toString(): String = "\"$title\" (id= $id) (sourceId= $sourceId)"
|
||||||
|
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ package suwayomi.tachidesk.manga.model.table
|
|||||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
import org.jetbrains.exposed.v1.core.ReferenceOption
|
import org.jetbrains.exposed.v1.core.ReferenceOption
|
||||||
import org.jetbrains.exposed.v1.core.ResultRow
|
import org.jetbrains.exposed.v1.core.ResultRow
|
||||||
import org.jetbrains.exposed.v1.core.dao.id.IntIdTable
|
import org.jetbrains.exposed.v1.core.dao.id.IntIdTable
|
||||||
import suwayomi.tachidesk.manga.model.dataclass.ChapterDataClass
|
import suwayomi.tachidesk.manga.model.dataclass.ChapterDataClass
|
||||||
import suwayomi.tachidesk.manga.model.table.columns.truncatingVarchar
|
import suwayomi.tachidesk.manga.model.table.columns.truncatingVarchar
|
||||||
|
import suwayomi.tachidesk.manga.model.table.columns.unlimitedVarchar
|
||||||
|
|
||||||
object ChapterTable : IntIdTable() {
|
object ChapterTable : IntIdTable() {
|
||||||
val url = varchar("url", 2048)
|
val url = varchar("url", 2048)
|
||||||
@@ -42,6 +44,8 @@ object ChapterTable : IntIdTable() {
|
|||||||
val lastModifiedAt = long("last_modified_at").default(0)
|
val lastModifiedAt = long("last_modified_at").default(0)
|
||||||
val version = long("version").default(0)
|
val version = long("version").default(0)
|
||||||
val isSyncing = bool("is_syncing").default(false)
|
val isSyncing = bool("is_syncing").default(false)
|
||||||
|
|
||||||
|
val memo = unlimitedVarchar("memo")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun ChapterTable.toDataClass(chapterEntry: ResultRow) =
|
fun ChapterTable.toDataClass(chapterEntry: ResultRow) =
|
||||||
@@ -64,4 +68,5 @@ fun ChapterTable.toDataClass(chapterEntry: ResultRow) =
|
|||||||
pageCount = chapterEntry[pageCount],
|
pageCount = chapterEntry[pageCount],
|
||||||
lastModifiedAt = chapterEntry[lastModifiedAt],
|
lastModifiedAt = chapterEntry[lastModifiedAt],
|
||||||
version = chapterEntry[version],
|
version = chapterEntry[version],
|
||||||
|
memo = Json.decodeFromString(chapterEntry[memo]),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package suwayomi.tachidesk.manga.model.table
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Copyright (C) Contributors to the Suwayomi project
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
||||||
|
|
||||||
|
import org.jetbrains.exposed.v1.core.dao.id.IntIdTable
|
||||||
|
|
||||||
|
object ExtensionStoreTable : IntIdTable() {
|
||||||
|
val indexUrl = varchar("index_url", 2048).uniqueIndex()
|
||||||
|
val name = varchar("name", 256)
|
||||||
|
val badgeLabel = varchar("badge_label", 32)
|
||||||
|
val signingKey = varchar("signing_key", 512)
|
||||||
|
val contactWebsite = varchar("contact_website", 2048)
|
||||||
|
val contactDiscord = varchar("contact_discord", 2048).nullable()
|
||||||
|
val isLegacy = bool("is_legacy").default(false)
|
||||||
|
val extensionListUrl = varchar("extension_list_url", 2048).nullable()
|
||||||
|
}
|
||||||
@@ -10,8 +10,8 @@ package suwayomi.tachidesk.manga.model.table
|
|||||||
import org.jetbrains.exposed.v1.core.dao.id.IntIdTable
|
import org.jetbrains.exposed.v1.core.dao.id.IntIdTable
|
||||||
|
|
||||||
object ExtensionTable : IntIdTable() {
|
object ExtensionTable : IntIdTable() {
|
||||||
val apkName = varchar("apk_name", 1024)
|
val apkName = varchar("apk_name", 1024).nullable()
|
||||||
val repo = varchar("repo", 1024).nullable()
|
val storeIndexUrl = varchar("store_index_url", 2048).nullable().index()
|
||||||
|
|
||||||
// default is the local source icon from tachiyomi
|
// default is the local source icon from tachiyomi
|
||||||
@Suppress("ktlint:standard:max-line-length")
|
@Suppress("ktlint:standard:max-line-length")
|
||||||
@@ -23,10 +23,12 @@ object ExtensionTable : IntIdTable() {
|
|||||||
|
|
||||||
val name = varchar("name", 128)
|
val name = varchar("name", 128)
|
||||||
val pkgName = varchar("pkg_name", 128)
|
val pkgName = varchar("pkg_name", 128)
|
||||||
|
val apkUrl = varchar("apk_url", 2048).nullable()
|
||||||
|
val extensionLib = varchar("extension_lib", 16).nullable()
|
||||||
val versionName = varchar("version_name", 16)
|
val versionName = varchar("version_name", 16)
|
||||||
val versionCode = integer("version_code")
|
val versionCode = long("version_code")
|
||||||
val lang = varchar("lang", 32)
|
val lang = varchar("lang", 32)
|
||||||
val isNsfw = bool("is_nsfw")
|
val contentWarning = integer("content_warning")
|
||||||
|
|
||||||
val isInstalled = bool("is_installed").default(false)
|
val isInstalled = bool("is_installed").default(false)
|
||||||
val hasUpdate = bool("has_update").default(false)
|
val hasUpdate = bool("has_update").default(false)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ package suwayomi.tachidesk.manga.model.table
|
|||||||
|
|
||||||
import eu.kanade.tachiyomi.source.model.SManga
|
import eu.kanade.tachiyomi.source.model.SManga
|
||||||
import eu.kanade.tachiyomi.source.model.UpdateStrategy
|
import eu.kanade.tachiyomi.source.model.UpdateStrategy
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
import org.jetbrains.exposed.v1.core.ResultRow
|
import org.jetbrains.exposed.v1.core.ResultRow
|
||||||
import org.jetbrains.exposed.v1.core.dao.id.IntIdTable
|
import org.jetbrains.exposed.v1.core.dao.id.IntIdTable
|
||||||
import suwayomi.tachidesk.manga.impl.MangaList.proxyThumbnailUrl
|
import suwayomi.tachidesk.manga.impl.MangaList.proxyThumbnailUrl
|
||||||
@@ -48,6 +49,7 @@ object MangaTable : IntIdTable() {
|
|||||||
val lastModifiedAt = long("last_modified_at").default(0)
|
val lastModifiedAt = long("last_modified_at").default(0)
|
||||||
val version = long("version").default(0)
|
val version = long("version").default(0)
|
||||||
val isSyncing = bool("is_syncing").default(false)
|
val isSyncing = bool("is_syncing").default(false)
|
||||||
|
val memo = unlimitedVarchar("memo")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun MangaTable.toDataClass(mangaEntry: ResultRow) =
|
fun MangaTable.toDataClass(mangaEntry: ResultRow) =
|
||||||
@@ -72,6 +74,7 @@ fun MangaTable.toDataClass(mangaEntry: ResultRow) =
|
|||||||
updateStrategy = UpdateStrategy.valueOf(mangaEntry[updateStrategy]),
|
updateStrategy = UpdateStrategy.valueOf(mangaEntry[updateStrategy]),
|
||||||
lastModifiedAt = mangaEntry[lastModifiedAt],
|
lastModifiedAt = mangaEntry[lastModifiedAt],
|
||||||
version = mangaEntry[version],
|
version = mangaEntry[version],
|
||||||
|
memo = Json.decodeFromString(mangaEntry[memo]),
|
||||||
)
|
)
|
||||||
|
|
||||||
enum class MangaStatus(
|
enum class MangaStatus(
|
||||||
|
|||||||
@@ -14,5 +14,5 @@ object SourceTable : IdTable<Long>() {
|
|||||||
val name = varchar("name", 128)
|
val name = varchar("name", 128)
|
||||||
val lang = varchar("lang", 32)
|
val lang = varchar("lang", 32)
|
||||||
val extension = reference("extension", ExtensionTable)
|
val extension = reference("extension", ExtensionTable)
|
||||||
val isNsfw = bool("is_nsfw").default(false)
|
val contentWarning = integer("content_warning").default(0)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package suwayomi.tachidesk.opds.impl
|
|||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
import org.jetbrains.exposed.v1.core.SortOrder
|
import org.jetbrains.exposed.v1.core.SortOrder
|
||||||
import suwayomi.tachidesk.i18n.MR
|
import suwayomi.tachidesk.i18n.MR
|
||||||
|
import suwayomi.tachidesk.manga.impl.Manga
|
||||||
import suwayomi.tachidesk.manga.impl.MangaList.proxyThumbnailUrl
|
import suwayomi.tachidesk.manga.impl.MangaList.proxyThumbnailUrl
|
||||||
import suwayomi.tachidesk.manga.model.table.ChapterTable
|
import suwayomi.tachidesk.manga.model.table.ChapterTable
|
||||||
import suwayomi.tachidesk.opds.constants.OpdsConstants
|
import suwayomi.tachidesk.opds.constants.OpdsConstants
|
||||||
@@ -17,7 +18,6 @@ import suwayomi.tachidesk.opds.repository.ChapterRepository
|
|||||||
import suwayomi.tachidesk.opds.repository.MangaRepository
|
import suwayomi.tachidesk.opds.repository.MangaRepository
|
||||||
import suwayomi.tachidesk.opds.repository.NavigationRepository
|
import suwayomi.tachidesk.opds.repository.NavigationRepository
|
||||||
import suwayomi.tachidesk.opds.util.OpdsDateUtil
|
import suwayomi.tachidesk.opds.util.OpdsDateUtil
|
||||||
import suwayomi.tachidesk.opds.util.OpdsStringUtil
|
|
||||||
import suwayomi.tachidesk.opds.util.OpdsXmlUtil
|
import suwayomi.tachidesk.opds.util.OpdsXmlUtil
|
||||||
import suwayomi.tachidesk.server.serverConfig
|
import suwayomi.tachidesk.server.serverConfig
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
@@ -657,8 +657,7 @@ object OpdsFeedBuilder {
|
|||||||
// If no chapters are found in the database, attempt to fetch them from the source.
|
// If no chapters are found in the database, attempt to fetch them from the source.
|
||||||
if (chapterEntries.isEmpty() && totalChapters == 0L) {
|
if (chapterEntries.isEmpty() && totalChapters == 0L) {
|
||||||
try {
|
try {
|
||||||
suwayomi.tachidesk.manga.impl.Chapter
|
Manga.updateMangaAndChapters(mangaId, updateManga = false)
|
||||||
.fetchChapterList(mangaId)
|
|
||||||
|
|
||||||
// Re-query after fetching.
|
// Re-query after fetching.
|
||||||
val (refetchedChapters, refetchedTotal) =
|
val (refetchedChapters, refetchedTotal) =
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import org.jetbrains.exposed.v1.core.SortOrder
|
|||||||
import org.jetbrains.exposed.v1.core.alias
|
import org.jetbrains.exposed.v1.core.alias
|
||||||
import org.jetbrains.exposed.v1.core.and
|
import org.jetbrains.exposed.v1.core.and
|
||||||
import org.jetbrains.exposed.v1.core.eq
|
import org.jetbrains.exposed.v1.core.eq
|
||||||
import org.jetbrains.exposed.v1.core.greater
|
|
||||||
import org.jetbrains.exposed.v1.core.inList
|
import org.jetbrains.exposed.v1.core.inList
|
||||||
import org.jetbrains.exposed.v1.core.inSubQuery
|
import org.jetbrains.exposed.v1.core.inSubQuery
|
||||||
import org.jetbrains.exposed.v1.core.intLiteral
|
import org.jetbrains.exposed.v1.core.intLiteral
|
||||||
@@ -23,7 +22,7 @@ import org.jetbrains.exposed.v1.jdbc.andWhere
|
|||||||
import org.jetbrains.exposed.v1.jdbc.select
|
import org.jetbrains.exposed.v1.jdbc.select
|
||||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||||
import suwayomi.tachidesk.manga.impl.MangaList.insertOrUpdate
|
import suwayomi.tachidesk.manga.impl.MangaList.insertOrUpdate
|
||||||
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource
|
import suwayomi.tachidesk.manga.impl.util.source.GetSource
|
||||||
import suwayomi.tachidesk.manga.model.dataclass.toGenreList
|
import suwayomi.tachidesk.manga.model.dataclass.toGenreList
|
||||||
import suwayomi.tachidesk.manga.model.table.CategoryMangaTable
|
import suwayomi.tachidesk.manga.model.table.CategoryMangaTable
|
||||||
import suwayomi.tachidesk.manga.model.table.CategoryTable
|
import suwayomi.tachidesk.manga.model.table.CategoryTable
|
||||||
@@ -232,7 +231,7 @@ object MangaRepository {
|
|||||||
pageNum: Int,
|
pageNum: Int,
|
||||||
sort: String,
|
sort: String,
|
||||||
): Pair<List<OpdsMangaAcqEntry>, Boolean> {
|
): Pair<List<OpdsMangaAcqEntry>, Boolean> {
|
||||||
val source = GetCatalogueSource.getCatalogueSourceOrStub(sourceId)
|
val source = GetSource.getSourceOrStub(sourceId)
|
||||||
val mangasPage: MangasPage =
|
val mangasPage: MangasPage =
|
||||||
if (sort == "latest" && source.supportsLatest) {
|
if (sort == "latest" && source.supportsLatest) {
|
||||||
source.getLatestUpdates(pageNum)
|
source.getLatestUpdates(pageNum)
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import dev.icerock.moko.resources.StringResource
|
|||||||
import org.jetbrains.exposed.v1.core.JoinType
|
import org.jetbrains.exposed.v1.core.JoinType
|
||||||
import org.jetbrains.exposed.v1.core.SortOrder
|
import org.jetbrains.exposed.v1.core.SortOrder
|
||||||
import org.jetbrains.exposed.v1.core.alias
|
import org.jetbrains.exposed.v1.core.alias
|
||||||
import org.jetbrains.exposed.v1.core.count
|
|
||||||
import org.jetbrains.exposed.v1.core.countDistinct
|
import org.jetbrains.exposed.v1.core.countDistinct
|
||||||
import org.jetbrains.exposed.v1.core.eq
|
import org.jetbrains.exposed.v1.core.eq
|
||||||
import org.jetbrains.exposed.v1.jdbc.select
|
import org.jetbrains.exposed.v1.jdbc.select
|
||||||
@@ -138,9 +137,9 @@ object NavigationRepository {
|
|||||||
val query =
|
val query =
|
||||||
SourceTable
|
SourceTable
|
||||||
.join(ExtensionTable, JoinType.LEFT, onColumn = SourceTable.extension, otherColumn = ExtensionTable.id)
|
.join(ExtensionTable, JoinType.LEFT, onColumn = SourceTable.extension, otherColumn = ExtensionTable.id)
|
||||||
.select(SourceTable.id, SourceTable.name, SourceTable.lang, ExtensionTable.apkName)
|
.select(SourceTable.id, SourceTable.name, SourceTable.lang, ExtensionTable.pkgName)
|
||||||
.where { ExtensionTable.isInstalled eq true }
|
.where { ExtensionTable.isInstalled eq true }
|
||||||
.groupBy(SourceTable.id, SourceTable.name, SourceTable.lang, ExtensionTable.apkName)
|
.groupBy(SourceTable.id, SourceTable.name, SourceTable.lang, ExtensionTable.pkgName)
|
||||||
.orderBy(SourceTable.name to SortOrder.ASC)
|
.orderBy(SourceTable.name to SortOrder.ASC)
|
||||||
|
|
||||||
val totalCount = query.count()
|
val totalCount = query.count()
|
||||||
@@ -152,7 +151,7 @@ object NavigationRepository {
|
|||||||
OpdsSourceNavEntry(
|
OpdsSourceNavEntry(
|
||||||
id = it[SourceTable.id].value,
|
id = it[SourceTable.id].value,
|
||||||
name = formatSourceName(it[SourceTable.name], it[SourceTable.lang]),
|
name = formatSourceName(it[SourceTable.name], it[SourceTable.lang]),
|
||||||
iconUrl = it[ExtensionTable.apkName].let { apkName -> Extension.getExtensionIconUrl(apkName) },
|
iconUrl = it[ExtensionTable.pkgName].let { pkgName -> Extension.proxyExtensionIconUrl(pkgName) },
|
||||||
mangaCount = null,
|
mangaCount = null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -177,13 +176,13 @@ object NavigationRepository {
|
|||||||
|
|
||||||
val query =
|
val query =
|
||||||
baseJoin
|
baseJoin
|
||||||
.select(SourceTable.id, SourceTable.name, SourceTable.lang, ExtensionTable.apkName, mangaCount)
|
.select(SourceTable.id, SourceTable.name, SourceTable.lang, ExtensionTable.pkgName, mangaCount)
|
||||||
.where { MangaTable.inLibrary eq true }
|
.where { MangaTable.inLibrary eq true }
|
||||||
|
|
||||||
query.applyOpdsMangaFilter(activeFilters, excludeField = "source_id")
|
query.applyOpdsMangaFilter(activeFilters, excludeField = "source_id")
|
||||||
|
|
||||||
query
|
query
|
||||||
.groupBy(SourceTable.id, SourceTable.name, SourceTable.lang, ExtensionTable.apkName)
|
.groupBy(SourceTable.id, SourceTable.name, SourceTable.lang, ExtensionTable.pkgName)
|
||||||
.orderBy(SourceTable.name to SortOrder.ASC)
|
.orderBy(SourceTable.name to SortOrder.ASC)
|
||||||
|
|
||||||
val totalCount = query.count()
|
val totalCount = query.count()
|
||||||
@@ -199,7 +198,7 @@ object NavigationRepository {
|
|||||||
OpdsSourceNavEntry(
|
OpdsSourceNavEntry(
|
||||||
id = it[SourceTable.id].value,
|
id = it[SourceTable.id].value,
|
||||||
name = formatSourceName(it[SourceTable.name], it[SourceTable.lang]),
|
name = formatSourceName(it[SourceTable.name], it[SourceTable.lang]),
|
||||||
iconUrl = it[ExtensionTable.apkName].let { apkName -> Extension.getExtensionIconUrl(apkName) },
|
iconUrl = it[ExtensionTable.pkgName].let { pkgName -> Extension.proxyExtensionIconUrl(pkgName) },
|
||||||
mangaCount = it[mangaCount],
|
mangaCount = it[mangaCount],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -210,12 +209,12 @@ object NavigationRepository {
|
|||||||
transaction {
|
transaction {
|
||||||
SourceTable
|
SourceTable
|
||||||
.join(ExtensionTable, JoinType.LEFT, onColumn = SourceTable.extension, otherColumn = ExtensionTable.id)
|
.join(ExtensionTable, JoinType.LEFT, onColumn = SourceTable.extension, otherColumn = ExtensionTable.id)
|
||||||
.select(SourceTable.name, SourceTable.lang, ExtensionTable.apkName)
|
.select(SourceTable.name, SourceTable.lang, ExtensionTable.pkgName)
|
||||||
.where { SourceTable.id eq sourceId }
|
.where { SourceTable.id eq sourceId }
|
||||||
.firstOrNull()
|
.firstOrNull()
|
||||||
?.let {
|
?.let {
|
||||||
val name = formatSourceName(it[SourceTable.name], it[SourceTable.lang])
|
val name = formatSourceName(it[SourceTable.name], it[SourceTable.lang])
|
||||||
val icon = Extension.getExtensionIconUrl(it[ExtensionTable.apkName])
|
val icon = Extension.proxyExtensionIconUrl(it[ExtensionTable.pkgName])
|
||||||
Pair(name, icon)
|
Pair(name, icon)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user