Compare commits

..

42 Commits

Author SHA1 Message Date
Syer10
e4440ad502 Lint 2026-06-27 13:03:53 -04:00
Syer10
e71c94547d Add a index to extesnion table store url 2026-06-27 12:59:16 -04:00
Syer10
84a1789850 Review fixes 2026-06-27 12:44:51 -04:00
Syer10
70a027f6f0 Lint 2026-06-26 11:13:55 -04:00
Syer10
650c075723 Optimize imports 2026-06-26 11:09:39 -04:00
Syer10
a69e29d1a5 Lint 2026-06-26 11:07:08 -04:00
Syer10
eec0951a75 Fix latest Mihon Extension Lib 2026-06-26 11:03:33 -04:00
Syer10
4ace76f508 Update to latest Mihon extension lib 2026-06-23 17:07:25 -04:00
Syer10
cd91b6e6ef Include OkHttp ZSTD 2026-06-22 11:16:22 -04:00
Mitchell Syer
f748bc7f6d Merge branch 'master' into extensions_1.6 2026-06-18 15:02:54 -04:00
Syer10
11ff4bb280 Remove replaceWith and add specific description for GQL APIs 2026-06-18 14:58:19 -04:00
Mitchell Syer
388586f23b Update server/server-config/src/main/kotlin/suwayomi/tachidesk/server/ServerConfig.kt
Co-authored-by: Constantin Piber <59023762+cpiber@users.noreply.github.com>
2026-06-18 14:55:29 -04:00
herowinb
c8f5d83e9c add reportSyncEvent for SyncYomi service (#2110)
* add reportSyncEvent

* Update SyncYomiSyncService.kt
2026-06-17 22:46:21 -04:00
Zeedif
be5e3f022e feat(download): improve chapter download filenames (#2100)
* feat(download): improve chapter download filenames

* refactor(download): use SafePath helper for filename sanitization
2026-06-17 22:41:31 -04:00
renovate[bot]
40a21fabca Update dex2jar to v2.4.37 (#2122)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-17 22:39:37 -04:00
Zeedif
b33069f107 fix(opds): resolve sql group by syntax error when filtering library (#2118) 2026-06-17 22:39:30 -04:00
Zeedif
14ab3aa9f4 fix(opds): handle dead sources and prevent kosync binary hash crashes (#2116) 2026-06-17 22:39:22 -04:00
Weblate (bot)
bfa0038f53 Weblate translations (#1903)
Translate-URL: https://hosted.weblate.org/projects/suwayomi/suwayomi-server/el/
Translate-URL: https://hosted.weblate.org/projects/suwayomi/suwayomi-server/ja/
Translate-URL: https://hosted.weblate.org/projects/suwayomi/suwayomi-server/pl/
Translate-URL: https://hosted.weblate.org/projects/suwayomi/suwayomi-server/ru/
Translation: Suwayomi/Suwayomi-Server

Co-authored-by: Micka149 <dr.mischutckin2017@yandex.ru>
Co-authored-by: Philip Prescott-Decie <presdec@gmail.com>
Co-authored-by: Roland Vezsenyi <miscogd5yf2paqvxvc@farvoid.com>
Co-authored-by: Syer10 <Mitchellptbo@gmail.com>
Co-authored-by: TheRay82 <raycoc1382@gmail.com>
Co-authored-by: UnknownSkyrimPasserby <f7022961@opayq.com>
Co-authored-by: 圭紫 <kaceykoo@gmail.com>
2026-06-17 22:39:15 -04:00
Syer10
b8ffbebc76 Optimize Imports 2026-06-17 22:39:09 -04:00
Syer10
3048df307d Use syncDbToPrefs in ExtensionStoreMutation 2026-06-17 22:38:46 -04:00
Syer10
3b3770cb3d Exclude ServerConfig.extensionStores from GraphQL 2026-06-17 21:38:30 -04:00
Syer10
9667aeba18 Use a single version of ContentRating 2026-06-17 21:29:14 -04:00
Syer10
6c8a024b0f Add ExtenionStores to the fetchExtensions result since its possible for the stores to change. 2026-06-17 21:17:55 -04:00
Syer10
5b2613dad3 Lint 2026-06-17 21:15:44 -04:00
Constantin Piber
396cfa734a fix: re-sync (#2121) 2026-06-17 17:22:54 -04:00
Syer10
a1fdf6d77a Improve extension store sync 2026-06-17 16:12:51 -04:00
Syer10
07ae17105b Add ContentRatingFilter 2026-06-17 16:02:14 -04:00
Syer10
b8772f60bf Optimize import fixes 2026-06-17 14:49:57 -04:00
Syer10
733b9c9919 Proper extension store queries 2026-06-17 14:47:28 -04:00
Syer10
0b0c056bcb Optimize imports and fix unchecked cast warning 2026-06-17 14:13:57 -04:00
Syer10
aff95bfc37 Lint 2026-06-17 14:06:06 -04:00
Syer10
00bc3e39b6 Lint 2026-06-17 14:04:02 -04:00
Syer10
e9c2cc49a6 Fix SearchTest 2026-06-17 13:57:58 -04:00
Syer10
ea310ba54b No magic numbers for ContentRating, improves safety for future versions of extension api 2026-06-17 13:53:54 -04:00
Syer10
72347f45cc Simplify isNsfw in SourceType 2026-06-17 13:44:02 -04:00
Syer10
1e73e526c6 Simplify ContentRating in Source.kt 2026-06-17 13:43:02 -04:00
Syer10
8fd0fdba08 Simplify deprecated isNsfw in SourceQuery 2026-06-17 13:42:15 -04:00
Syer10
4b61d375ed Fixes 2026-06-17 13:39:53 -04:00
Syer10
3cf4cf6cf8 Improve Fetch Extension Store 2026-06-17 13:31:31 -04:00
Mitchell Syer
74ade8a3a3 Update docs/Configuring-Suwayomi‐Server.md
Co-authored-by: Constantin Piber <59023762+cpiber@users.noreply.github.com>
2026-06-17 13:26:09 -04:00
Syer10
3bb2e4329e Use EMPTY JsonObject 2026-06-17 13:25:31 -04:00
Syer10
33ec15c136 Simplify fetching manga and chapters 2026-06-17 13:24:38 -04:00
51 changed files with 966 additions and 379 deletions

View File

@@ -172,7 +172,7 @@ server.maxLogFolderSize = "100mb"
server.extensionStores = [] server.extensionStores = []
server.maxSourcesInParallel = 6 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.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

View File

@@ -8,7 +8,7 @@ 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.2.0" exposed = "1.2.0"
dex2jar = "2.4.36" dex2jar = "2.4.37"
polyglot = "25.0.3" polyglot = "25.0.3"
settings = "1.3.0" settings = "1.3.0"
twelvemonkeys = "3.13.1" twelvemonkeys = "3.13.1"
@@ -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
@@ -228,6 +229,7 @@ okhttp = [
"okhttp-logging", "okhttp-logging",
"okhttp-dnsoverhttps", "okhttp-dnsoverhttps",
"okhttp-brotli", "okhttp-brotli",
"okhttp-zstd",
] ]
javalin = [ javalin = [
"javalin-core", "javalin-core",

View File

@@ -50,6 +50,7 @@
<!-- OPDS errors --> <!-- OPDS errors -->
<string name="opds_error_manga_not_found">Series with ID %1$d not found.</string> <string name="opds_error_manga_not_found">Series with ID %1$d not found.</string>
<string name="opds_error_chapter_not_found">Chapter with index %1$d not found.</string> <string name="opds_error_chapter_not_found">Chapter with index %1$d not found.</string>
<string name="opds_error_chapters_not_found">No chapters found or the source is unreachable on page %1$d.</string>
<!-- OPDS facets (Filters and Sorting) --> <!-- OPDS facets (Filters and Sorting) -->
<string name="opds_facetgroup_sort_order">Sort Order</string> <string name="opds_facetgroup_sort_order">Sort Order</string>

View File

@@ -0,0 +1,125 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="opds_feeds_root">Suwayomi OPDS Κατάλογος</string>
<string name="opds_feeds_manga_chapters">Κεφάλαια %1$s</string>
<string name="opds_feeds_chapter_details">%1$s | %2$s | Λεπτομέρειες</string>
<string name="opds_feeds_explore_title">Εξερεύνηση</string>
<string name="opds_feeds_explore_entry_content">Εξερεύνησε νέες σειρές από τις πηγές σου</string>
<string name="opds_feeds_history_title">Ιστορικό</string>
<string name="opds_feeds_history_entry_content">Πρόσφατα διαβασμένα κεφάλαια</string>
<string name="opds_feeds_all_series_in_library_title">Όλες οι σειρές</string>
<string name="opds_feeds_all_series_in_library_entry_content">Περιήγηση σε όλες τις σειρές της βιβλιοθήκης σου</string>
<string name="opds_feeds_library_sources_title">Πηγές</string>
<string name="opds_feeds_library_sources_entry_content">Περιήγηση σε σειρές της βιβλιοθήκης σου φιλτραρισμένες κατά πηγή</string>
<string name="opds_feeds_categories_title">Κατηγορίες</string>
<string name="opds_feeds_categories_entry_content">Περιήγηση σε σειρές οργανωμένες κατά κατηγορία</string>
<string name="opds_feeds_genres_title">Είδη</string>
<string name="opds_feeds_genres_entry_content">Περιήγηση σε σειρές κατά ετικέτες είδους</string>
<string name="opds_feeds_status_title">Κατάσταση</string>
<string name="opds_feeds_status_entry_content">Περιήγηση σε σειρές κατά κατάσταση δημοσίευσης</string>
<string name="opds_feeds_languages_title">Γλώσσες</string>
<string name="opds_feeds_languages_entry_content">Περιήγηση σε σειρές κατά γλώσσα περιεχομένου</string>
<string name="opds_feeds_library_updates_title">Ιστορικό ενημερώσεων βιβλιοθήκης</string>
<string name="opds_feeds_library_updates_entry_content">Πρόσφατα ενημερωμένα κεφάλαια από τη βιβλιοθήκη σου</string>
<string name="opds_feeds_search_results_title">Αποτελέσματα αναζήτησης</string>
<string name="opds_feeds_sources_title">Όλες οι πηγές</string>
<string name="opds_feeds_category_specific_title">Κατηγορία: %1$s</string>
<string name="opds_feeds_genre_specific_title">Είδος: %1$s</string>
<string name="opds_feeds_status_specific_title">Κατάσταση: %1$s</string>
<string name="opds_feeds_language_specific_title">Γλώσσα: %1$s</string>
<string name="opds_feeds_source_specific_title">Πηγή: %1$s</string>
<string name="opds_feeds_library_source_specific_title">Βιβλιοθήκη - Πηγή: %1$s</string>
<string name="opds_feeds_source_specific_popular_title">Πηγή: %1$s - Δημοφιλές</string>
<string name="opds_feeds_source_specific_latest_title">Πηγή: %1$s - Τελευταίο</string>
<string name="opds_search_shortname">Suwayomi OPDS Αναζήτηση</string>
<string name="opds_search_description">Αναζήτηση σειρών στον κατάλογο.</string>
<string name="opds_error_manga_not_found">Η σειρά με ID %1$d δεν βρέθηκε.</string>
<string name="opds_error_chapter_not_found">Το κεφάλαιο με δείκτη %1$d δεν βρέθηκε.</string>
<string name="opds_facetgroup_sort_order">Σειρά ταξινόμησης</string>
<string name="opds_facetgroup_filter_read_status">Φίλτρο κατά κατάσταση ανάγνωσης</string>
<string name="opds_facetgroup_filter_content">Φίλτρο περιεχομένου</string>
<string name="opds_facetgroup_filter_source">Φίλτρο κατά πηγή</string>
<string name="opds_facetgroup_filter_category">Φίλτρο κατά κατηγορία</string>
<string name="opds_facetgroup_filter_status">Φίλτρο κατά κατάσταση</string>
<string name="opds_facetgroup_filter_language">Φίλτρο κατά γλώσσα</string>
<string name="opds_facetgroup_filter_genre">Φίλτρο κατά είδος</string>
<string name="opds_facet_sort_oldest_first">Παλαιότερα πρώτα</string>
<string name="opds_facet_sort_newest_first">Νεότερα πρώτα</string>
<string name="opds_facet_sort_date_asc">Ημερομηνία αύξουσα</string>
<string name="opds_facet_sort_date_desc">Ημερομηνία φθίνουσα</string>
<string name="opds_facet_sort_popular">Δημοφιλές</string>
<string name="opds_facet_sort_latest">Τελευταίο</string>
<string name="opds_facet_sort_alpha_asc">Αλφαβητικά Α</string>
<string name="opds_facet_sort_alpha_desc">Αλφαβητικά Ω-Α</string>
<string name="opds_facet_sort_last_read_desc">Τελευταία ανάγνωση</string>
<string name="opds_facet_sort_latest_chapter_desc">Τελευταίο κεφάλαιο</string>
<string name="opds_facet_sort_date_added_desc">Ημερομηνία προσθήκης</string>
<string name="opds_facet_sort_unread_desc">Αδιάβαστα κεφάλαια</string>
<string name="opds_facet_filter_all">Όλα</string>
<string name="opds_facet_filter_all_chapters">Όλα τα κεφάλαια</string>
<string name="opds_facet_filter_unread_only">Αδιάβαστα</string>
<string name="opds_facet_filter_read_only">Διαβασμένα</string>
<string name="opds_facet_filter_downloaded">Ληφθέντα</string>
<string name="opds_facet_filter_ongoing">Σε εξέλιξη</string>
<string name="opds_facet_filter_completed">Ολοκληρωμένα</string>
<string name="opds_facet_all_sources">Όλες οι πηγές</string>
<string name="opds_facet_all_categories">Όλες οι κατηγορίες</string>
<string name="opds_facet_all_statuses">Όλες οι καταστάσεις</string>
<string name="opds_facet_all_languages">Όλες οι γλώσσες</string>
<string name="opds_facet_all_genres">Όλα τα είδη</string>
<string name="opds_linktitle_catalog_root">Ρίζα καταλόγου</string>
<string name="opds_linktitle_search_catalog">Αναζήτηση καταλόγου</string>
<string name="opds_linktitle_first_page">Πρώτη σελίδα</string>
<string name="opds_linktitle_previous_page">Προηγούμενη σελίδα</string>
<string name="opds_linktitle_next_page">Επόμενη σελίδα</string>
<string name="opds_linktitle_last_page">Τελευταία σελίδα</string>
<string name="opds_linktitle_self_feed">Τρέχουσα ροή</string>
<string name="opds_linktitle_view_on_web">Προβολή στο Web</string>
<string name="opds_linktitle_stream_pages_start">Διάβασε Online</string>
<string name="opds_linktitle_stream_pages_continue">Συνέχισε να διαβάζεις Online</string>
<string name="opds_linktitle_stream_pages_start_local">Διάβασε Online (Τοπική πρόοδος)</string>
<string name="opds_linktitle_stream_pages_continue_local">Συνέχισε να διαβάζεις Online (Τοπική πρόοδος)</string>
<string name="opds_linktitle_stream_pages_start_remote">Διάβασε Online (Συγχρονισμένο από %1$s)</string>
<string name="opds_linktitle_stream_pages_continue_remote">Συνέχισε να διαβάζεις Online (Συγχρονισμένο από %1$s)</string>
<string name="opds_linktitle_download_cbz">Λήψη CBZ</string>
<string name="opds_linktitle_chapter_cover">Εξώφυλλο κεφαλαίου</string>
<string name="opds_linktitle_view_chapter_details">Λεπτομέρειες κεφαλαίου &amp; Σελίδες</string>
<string name="opds_chapter_status_read"></string>
<string name="opds_chapter_status_in_progress"></string>
<string name="opds_chapter_status_unread"></string>
<string name="opds_chapter_status_downloaded">⬇️</string>
<string name="opds_chapter_status_synced">🌐</string>
<string name="opds_chapter_details_base">Σειρά: %1$s | %2$s</string>
<string name="opds_chapter_details_scanlator">| Από %1$s</string>
<string name="opds_chapter_details_progress">| Πρόοδος: %1$d από %2$d</string>
<string name="opds_manga_summary_status">Κατάσταση: %1$s</string>
<string name="opds_manga_summary_source">Πηγή: %1$s</string>
<string name="opds_manga_summary_language">Γλώσσα: %1$s</string>
<string name="manga_status_ongoing">Σε εξέλιξη</string>
<string name="manga_status_completed">Ολοκληρωμένο</string>
<string name="manga_status_licensed">Υπό άδεια</string>
<string name="manga_status_publishing_finished">Δημοσίευση ολοκληρωμένη</string>
<string name="manga_status_cancelled">Ακυρωμένο</string>
<string name="manga_status_on_hiatus">Σε παύση</string>
<string name="manga_status_unknown">Άγνωστο</string>
<string name="label_error">Σφάλμα</string>
<string name="label_version">Έκδοση <xliff:g id="version" example="v2.0.1833">%1$s</xliff:g></string>
<string name="label_close">Κλείσε</string>
<string name="webview_label_title">Suwayomi WebView</string>
<string name="webview_label_disconnected">Αποσυνδεδεμένο, κάνε ανανέωση</string>
<string name="webview_label_reversescroll">Αντίστροφη κύλιση</string>
<string name="webview_label_bindingshint">Σημείωση: Όταν η εστίαση είναι στο τμήμα WebView, καμία συντόμευση πληκτρολογίου, συμπεριλαμβανομένης της ανανέωσης, δεν θα αντιμετωπίζεται από το πρόγραμμα περιήγησης.</string>
<string name="webview_label_init">Αρχικοποίηση... Παρακαλώ περίμενε</string>
<string name="webview_label_getstarted">Εισήγαγε μια διεύθυνση URL για να ξεκινήσεις</string>
<string name="webview_label_loading">Φόρτωση σελίδας...</string>
<string name="webview_label_copy">Αντιγραφή στο πρόχειρο</string>
<string name="webview_label_copy_description">Η αυτόματη αντιγραφή στο πρόχειρο απέτυχε, χρησιμοποίησε το παρακάτω πεδίο για να αντιγράψεις χειροκίνητα την τιμή.</string>
<string name="webview_label_login_required">Η διαμόρφωσή σου απαιτεί σύνδεση. Εισήγαγε όνομα χρήστη και κωδικό.</string>
<string name="webview_placeholder_url">Εισήγαγε URL...</string>
<string name="login_label_title">Suwayomi Σύνδεση</string>
<string name="login_label_username">Όνομα χρήστη</string>
<string name="login_label_password">Κωδικός</string>
<string name="login_label_login">Σύνδεση</string>
<string name="login_placeholder_username">Πληκτρολόγησε όνομα χρήστη...</string>
<string name="login_placeholder_password">Μυστικό...</string>
</resources>

View File

@@ -284,12 +284,12 @@ class ServerConfig(
defaultValue = emptyList(), defaultValue = emptyList(),
deprecated = deprecated =
SettingsRegistry.SettingDeprecated( SettingsRegistry.SettingDeprecated(
replaceWith = "extensionStores", message = "Replaced with addExtensionStore and removeExtensionStore mutations",
message = "Replaced with extensionStores",
migrateConfigValue = { migrateConfigValue = {
@Suppress("UNCHECKED_CAST")
(it.unwrapped() as? List<String>) (it.unwrapped() as? List<String>)
?.map { ?.map {
if (it.contains("github")) { if (it.contains("github.com")) {
it.replace(repoMatchRegex) { it.replace(repoMatchRegex) {
"https://raw.githubusercontent.com/${it.groupValues[2]}/${it.groupValues[3]}/" + "https://raw.githubusercontent.com/${it.groupValues[2]}/${it.groupValues[3]}/" +
(it.groupValues.getOrNull(4)?.ifBlank { null } ?: "repo") + (it.groupValues.getOrNull(4)?.ifBlank { null } ?: "repo") +
@@ -303,7 +303,7 @@ class ServerConfig(
}, },
), ),
readMigrated = { extensionStores.value }, readMigrated = { extensionStores.value },
setMigrated = { extensionStores.value = it }, setMigrated = { extensionStores.value = it.distinct() },
typeInfo = typeInfo =
SettingsRegistry.PartialTypeInfo( SettingsRegistry.PartialTypeInfo(
specificType = "List<String>", specificType = "List<String>",
@@ -1117,8 +1117,9 @@ class ServerConfig(
val extensionStores: MutableStateFlow<List<String>> by ListSetting<String>( val extensionStores: MutableStateFlow<List<String>> by ListSetting<String>(
protoNumber = 97, protoNumber = 97,
group = SettingGroup.EXTENSION, group = SettingGroup.EXTENSION,
privacySafe = false, privacySafe = true,
defaultValue = emptyList(), defaultValue = emptyList(),
requiresRestart = true,
itemValidator = { url -> itemValidator = { url ->
if (url.isNotEmpty()) { if (url.isNotEmpty()) {
null null
@@ -1127,11 +1128,7 @@ class ServerConfig(
} }
}, },
itemToValidValue = { url -> itemToValidValue = { url ->
if (url.isNotEmpty()) { url.ifEmpty { null }
url
} else {
null
}
}, },
typeInfo = typeInfo =
SettingsRegistry.PartialTypeInfo( SettingsRegistry.PartialTypeInfo(

View File

@@ -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,7 +21,6 @@ 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.GetCatalogueSource
import java.net.CookieHandler import java.net.CookieHandler
@@ -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 =

View File

@@ -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)
}
}

View File

@@ -483,7 +483,7 @@ class LocalSource(
it[versionCode] = 0 it[versionCode] = 0
it[lang] = LANG it[lang] = LANG
it[extensionLib] = "1.2" it[extensionLib] = "1.2"
it[contentRating] = 0 it[contentWarning] = 0
it[isInstalled] = true it[isInstalled] = true
} }

View File

@@ -3,6 +3,7 @@
package eu.kanade.tachiyomi.source.model package eu.kanade.tachiyomi.source.model
import kotlinx.serialization.json.JsonObject 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
@@ -15,5 +16,5 @@ class SChapterImpl : SChapter {
override var date_upload: Long = 0 override var date_upload: Long = 0
override var memo: JsonObject = JsonObject(emptyMap()) override var memo: JsonObject = JsonObject.EMPTY
} }

View File

@@ -3,6 +3,7 @@
package eu.kanade.tachiyomi.source.model package eu.kanade.tachiyomi.source.model
import kotlinx.serialization.json.JsonObject 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
@@ -25,5 +26,5 @@ class SMangaImpl : SManga {
override var initialized: Boolean = false override var initialized: Boolean = false
override var memo: JsonObject = JsonObject(emptyMap()) override var memo: JsonObject = JsonObject.EMPTY
} }

View File

@@ -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}" }
} }
} }

View File

@@ -16,11 +16,11 @@ 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
class ExtensionStoreForExtension : KotlinDataLoader<String, ExtensionStoreType> { class ExtensionStoreDataLoader : KotlinDataLoader<String, ExtensionStoreType> {
override val dataLoaderName = "ExtensionStoreForExtension" override val dataLoaderName = "ExtensionStoreDataLoader"
override fun getDataLoader(graphQLContext: GraphQLContext): DataLoader<String, ExtensionStoreType> = override fun getDataLoader(graphQLContext: GraphQLContext): DataLoader<String, ExtensionStoreType> =
DataLoaderFactory.newDataLoader<String, ExtensionStoreType> { ids -> DataLoaderFactory.newDataLoader { ids ->
future { future {
transaction { transaction {
addLogger(Slf4jSqlDebugLogger) addLogger(Slf4jSqlDebugLogger)
@@ -30,14 +30,14 @@ class ExtensionStoreForExtension : KotlinDataLoader<String, ExtensionStoreType>
.where { ExtensionStoreTable.indexUrl inList ids } .where { ExtensionStoreTable.indexUrl inList ids }
.map { ExtensionStoreType(it) } .map { ExtensionStoreType(it) }
.associateBy { it.indexUrl } .associateBy { it.indexUrl }
ids.map { (extensionStoreByIndexUrl[it]) } ids.map { extensionStoreByIndexUrl[it] }
} }
} }
} }
} }
class ExtensionForExtensionStore : KotlinDataLoader<String, ExtensionNodeList> { class ExtensionsForExtensionStore : KotlinDataLoader<String, ExtensionNodeList> {
override val dataLoaderName = "ExtensionForExtensionStore" override val dataLoaderName = "ExtensionsForExtensionStore"
override fun getDataLoader(graphQLContext: GraphQLContext): DataLoader<String, ExtensionNodeList> = override fun getDataLoader(graphQLContext: GraphQLContext): DataLoader<String, ExtensionNodeList> =
DataLoaderFactory.newDataLoader<String, ExtensionNodeList> { ids -> DataLoaderFactory.newDataLoader<String, ExtensionNodeList> { ids ->

View File

@@ -26,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
@@ -173,7 +174,7 @@ class ChapterMutation {
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 {

View File

@@ -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,
) )
} }
} }

View File

@@ -16,7 +16,6 @@ import suwayomi.tachidesk.graphql.types.ExtensionStoreType
import suwayomi.tachidesk.manga.impl.extension.ExtensionStoreService import suwayomi.tachidesk.manga.impl.extension.ExtensionStoreService
import suwayomi.tachidesk.manga.model.table.ExtensionStoreTable import suwayomi.tachidesk.manga.model.table.ExtensionStoreTable
import suwayomi.tachidesk.server.JavalinSetup.future import suwayomi.tachidesk.server.JavalinSetup.future
import suwayomi.tachidesk.server.serverConfig
import java.util.concurrent.CompletableFuture import java.util.concurrent.CompletableFuture
class ExtensionStoreMutation { class ExtensionStoreMutation {
@@ -37,7 +36,7 @@ class ExtensionStoreMutation {
val store = ExtensionStoreService.fetch(indexUrl) val store = ExtensionStoreService.fetch(indexUrl)
ExtensionStoreService.upsert(store) ExtensionStoreService.upsert(store)
serverConfig.extensionStores.value = (serverConfig.extensionStores.value + indexUrl).distinct() ExtensionStoreService.syncDbToPrefs()
val row = val row =
transaction { transaction {
ExtensionStoreTable ExtensionStoreTable
@@ -82,7 +81,7 @@ class ExtensionStoreMutation {
} }
} }
serverConfig.extensionStores.value = serverConfig.extensionStores.value.filterNot { it == indexUrl } ExtensionStoreService.syncDbToPrefs()
RemoveExtensionStorePayload( RemoveExtensionStorePayload(
clientMutationId = clientMutationId, clientMutationId = clientMutationId,
@@ -96,6 +95,7 @@ class ExtensionStoreMutation {
contactDiscord = it.contactDiscord, contactDiscord = it.contactDiscord,
indexUrl = it.indexUrl, indexUrl = it.indexUrl,
isLegacy = it.isLegacy, isLegacy = it.isLegacy,
extensionListUrl = it.extensionListUrl,
) )
}, },
) )

View File

@@ -19,11 +19,9 @@ 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.Chapter
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.impl.util.source.GetCatalogueSource.getCatalogueSourceOrStub
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.MangaTable import suwayomi.tachidesk.manga.model.table.MangaTable
@@ -156,7 +154,7 @@ class MangaMutation {
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 {
@@ -187,20 +185,11 @@ class MangaMutation {
val (clientMutationId, id, fetchManga, fetchChapters) = input val (clientMutationId, id, fetchManga, fetchChapters) = input
return future { return future {
var mangaEntry = Manga.updateMangaAndChapters(
transaction { MangaTable.selectAll().where { MangaTable.id eq id }.first() } mangaId = id,
val source = getCatalogueSourceOrStub(mangaEntry[MangaTable.sourceReference]) updateManga = fetchManga,
val sMangaUpdate = updateChapters = fetchChapters,
Manga.fetchMangaAndChapters( )
mangaEntry = mangaEntry,
source = source,
fetchDetails = fetchManga,
fetchChapters = fetchChapters,
)
Manga.updateMangaDatabase(mangaEntry, source, sMangaUpdate.manga)
mangaEntry = transaction { MangaTable.selectAll().where { MangaTable.id eq id }.first() }
Chapter.updateChapterListDatabase(mangaEntry, sMangaUpdate.chapters, source)
val (manga, chapters) = val (manga, chapters) =
transaction { transaction {

View File

@@ -21,6 +21,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 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
@@ -28,6 +29,7 @@ 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
@@ -39,9 +41,9 @@ import suwayomi.tachidesk.graphql.server.primitives.applyBeforeAfter
import suwayomi.tachidesk.graphql.server.primitives.greaterNotUnique import suwayomi.tachidesk.graphql.server.primitives.greaterNotUnique
import suwayomi.tachidesk.graphql.server.primitives.lessNotUnique 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.ContentRating
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
@@ -106,9 +108,9 @@ class ExtensionQuery {
val versionCode: Int? = null, val versionCode: Int? = null,
val versionCodeLong: Long? = null, val versionCodeLong: Long? = null,
val lang: String? = null, val lang: String? = null,
@GraphQLDeprecated("", ReplaceWith("contentRating")) @GraphQLDeprecated("", ReplaceWith("contentWarning"))
val isNsfw: Boolean? = null, val isNsfw: Boolean? = null,
val contentRating: ContentRating? = 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,
@@ -126,8 +128,11 @@ class ExtensionQuery {
opAnd.eq(versionCode?.toLong(), ExtensionTable.versionCode) opAnd.eq(versionCode?.toLong(), ExtensionTable.versionCode)
opAnd.eq(versionCodeLong, ExtensionTable.versionCode) opAnd.eq(versionCodeLong, ExtensionTable.versionCode)
opAnd.eq(lang, ExtensionTable.lang) opAnd.eq(lang, ExtensionTable.lang)
opAnd.eq(isNsfw?.let { if (it) 3 else 0 }, ExtensionTable.contentRating) opAnd.eq(
opAnd.eq(contentRating?.ordinal, ExtensionTable.contentRating) 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)
@@ -151,9 +156,9 @@ class ExtensionQuery {
val versionCode: IntFilter? = null, val versionCode: IntFilter? = null,
val versionCodeLong: LongFilter? = null, val versionCodeLong: LongFilter? = null,
val lang: StringFilter? = null, val lang: StringFilter? = null,
@GraphQLDeprecated("", ReplaceWith("storeIndexUrl")) @GraphQLDeprecated("", ReplaceWith("contentWarning"))
val isNsfw: BooleanFilter? = null, val isNsfw: BooleanFilter? = null,
// val contentRating: EnumFilter<ContentRating>? = 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,
@@ -174,7 +179,7 @@ class ExtensionQuery {
andFilterWithCompareString(ExtensionTable.versionName, versionName), andFilterWithCompareString(ExtensionTable.versionName, versionName),
andFilterWithCompare(ExtensionTable.versionCode, versionCodeLong), andFilterWithCompare(ExtensionTable.versionCode, versionCodeLong),
andFilterWithCompareString(ExtensionTable.lang, lang), andFilterWithCompareString(ExtensionTable.lang, lang),
// andFilterWithCompareEnum(ExtensionTable.contentRating, contentRating), 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),

View File

@@ -7,33 +7,184 @@ package suwayomi.tachidesk.graphql.queries
* 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 org.jetbrains.exposed.v1.core.eq 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.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.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.graphql.types.ExtensionStoreType
import suwayomi.tachidesk.manga.model.table.ExtensionStoreTable import suwayomi.tachidesk.manga.model.table.ExtensionStoreTable
import java.util.concurrent.CompletableFuture import java.util.concurrent.CompletableFuture
class ExtensionStoreQuery { class ExtensionStoreQuery {
@RequireAuth @RequireAuth
fun extensionStore(indexUrl: String): CompletableFuture<ExtensionStoreType?> = fun extensionStore(
CompletableFuture.supplyAsync { dataFetchingEnvironment: DataFetchingEnvironment,
transaction { indexUrl: String,
ExtensionStoreTable ): CompletableFuture<ExtensionStoreType> = dataFetchingEnvironment.getValueFromDataLoader("ExtensionStoreDataLoader", indexUrl)
.selectAll()
.where { ExtensionStoreTable.indexUrl eq indexUrl } enum class ExtensionStoreOrderBy(
.firstOrNull() override val column: Column<*>,
?.let { ExtensionStoreType(it) } ) : 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 @RequireAuth
fun extensionStores(): List<ExtensionStoreType> = fun extensionStores(
transaction { condition: ExtensionStoreCondition? = null,
ExtensionStoreTable filter: ExtensionStoreFilter? = null,
.selectAll() order: List<ExtensionStoreOrder>? = null,
.toList() before: Cursor? = null,
.map { ExtensionStoreType(it) } 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(),
)
}
} }

View File

@@ -21,12 +21,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 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.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
@@ -38,9 +40,9 @@ import suwayomi.tachidesk.graphql.server.primitives.applyBeforeAfter
import suwayomi.tachidesk.graphql.server.primitives.greaterNotUnique import suwayomi.tachidesk.graphql.server.primitives.greaterNotUnique
import suwayomi.tachidesk.graphql.server.primitives.lessNotUnique 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.ContentRating
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
@@ -93,17 +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 contentRating == 3", ReplaceWith("contentRating")) @GraphQLDeprecated("replace with contentWarning == ContentRating.MIXED", ReplaceWith("contentWarning"))
val isNsfw: Boolean? = null, val isNsfw: Boolean? = null,
val contentRating: ContentRating? = 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.andWhere(isNsfw) { if (it) SourceTable.contentRating greaterEq 3 else SourceTable.contentRating less 3 } opAnd.andWhere(isNsfw) {
opAnd.andWhere(contentRating) { SourceTable.contentRating eq it.getValue() } 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
} }
@@ -113,9 +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 contentRating == 3", ReplaceWith("contentRating")) @GraphQLDeprecated("replace with contentWarning", ReplaceWith("contentWarning"))
val isNsfw: BooleanFilter? = null, val isNsfw: BooleanFilter? = null,
// val contentRating: EnumFilter<ContentRating>? = 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,
@@ -125,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),
// andFilterWithCompareEnum(SourceTable.contentRating, contentRating) andFilterWithCompareEnum(SourceTable.contentWarning, contentWarning),
) )
} }

View File

@@ -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,23 +330,23 @@ data class DoubleFilter(
) )
} }
data class EnumFilter<T : Enum<T>>( data class ContentWarningFilter(
override val isNull: Boolean? = null, override val isNull: Boolean? = null,
override val equalTo: T? = null, override val equalTo: ContentWarning? = null,
override val notEqualTo: T? = null, override val notEqualTo: ContentWarning? = null,
override val notEqualToAll: List<T>? = null, override val notEqualToAll: List<ContentWarning>? = null,
override val notEqualToAny: List<T>? = null, override val notEqualToAny: List<ContentWarning>? = null,
override val distinctFrom: T? = null, override val distinctFrom: ContentWarning? = null,
override val distinctFromAll: List<T>? = null, override val distinctFromAll: List<ContentWarning>? = null,
override val distinctFromAny: List<T>? = null, override val distinctFromAny: List<ContentWarning>? = null,
override val notDistinctFrom: T? = null, override val notDistinctFrom: ContentWarning? = null,
override val `in`: List<T>? = null, override val `in`: List<ContentWarning>? = null,
override val notIn: List<T>? = null, override val notIn: List<ContentWarning>? = null,
override val lessThan: T? = null, override val lessThan: ContentWarning? = null,
override val lessThanOrEqualTo: T? = null, override val lessThanOrEqualTo: ContentWarning? = null,
override val greaterThan: T? = null, override val greaterThan: ContentWarning? = null,
override val greaterThanOrEqualTo: T? = null, override val greaterThanOrEqualTo: ContentWarning? = null,
) : ComparableScalarFilter<T> ) : ComparableScalarFilter<ContentWarning>
data class StringFilter( data class StringFilter(
override val isNull: Boolean? = null, override val isNull: Boolean? = null,

View File

@@ -20,9 +20,9 @@ import suwayomi.tachidesk.graphql.dataLoaders.DisplayScoreForTrackRecordDataLoad
import suwayomi.tachidesk.graphql.dataLoaders.DisplayScoreForTrackSearchDataLoader import suwayomi.tachidesk.graphql.dataLoaders.DisplayScoreForTrackSearchDataLoader
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.ExtensionForExtensionStore
import suwayomi.tachidesk.graphql.dataLoaders.ExtensionForSourceDataLoader import suwayomi.tachidesk.graphql.dataLoaders.ExtensionForSourceDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.ExtensionStoreForExtension 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
@@ -80,8 +80,8 @@ class TachideskDataLoaderRegistryFactory {
SourceMetaDataLoader(), SourceMetaDataLoader(),
ExtensionDataLoader(), ExtensionDataLoader(),
ExtensionForSourceDataLoader(), ExtensionForSourceDataLoader(),
ExtensionForExtensionStore(), ExtensionsForExtensionStore(),
ExtensionStoreForExtension(), ExtensionStoreDataLoader(),
TrackerDataLoader(), TrackerDataLoader(),
TrackerStatusesDataLoader(), TrackerStatusesDataLoader(),
TrackerScoresDataLoader(), TrackerScoresDataLoader(),

View File

@@ -10,7 +10,11 @@ package suwayomi.tachidesk.graphql.types
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
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.Node
import suwayomi.tachidesk.graphql.server.primitives.NodeList
import suwayomi.tachidesk.graphql.server.primitives.PageInfo
import suwayomi.tachidesk.manga.model.table.ExtensionStoreTable import suwayomi.tachidesk.manga.model.table.ExtensionStoreTable
import java.util.concurrent.CompletableFuture import java.util.concurrent.CompletableFuture
@@ -22,6 +26,7 @@ class ExtensionStoreType(
val contactDiscord: String?, val contactDiscord: String?,
val indexUrl: String, val indexUrl: String,
val isLegacy: Boolean, val isLegacy: Boolean,
val extensionListUrl: String?,
) : Node { ) : Node {
constructor(row: ResultRow) : this( constructor(row: ResultRow) : this(
name = row[ExtensionStoreTable.name], name = row[ExtensionStoreTable.name],
@@ -31,8 +36,51 @@ class ExtensionStoreType(
contactDiscord = row[ExtensionStoreTable.contactDiscord], contactDiscord = row[ExtensionStoreTable.contactDiscord],
indexUrl = row[ExtensionStoreTable.indexUrl], indexUrl = row[ExtensionStoreTable.indexUrl],
isLegacy = row[ExtensionStoreTable.isLegacy], isLegacy = row[ExtensionStoreTable.isLegacy],
extensionListUrl = row[ExtensionStoreTable.extensionListUrl],
) )
fun extension(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<ExtensionNodeList> = fun extensions(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<ExtensionNodeList> =
dataFetchingEnvironment.getValueFromDataLoader<String, ExtensionNodeList>("ExtensionForExtensionStore", indexUrl) 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(),
),
)
}
}
} }

View File

@@ -18,6 +18,7 @@ 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
@@ -40,9 +41,9 @@ class ExtensionType(
val versionCode: Int, val versionCode: Int,
val versionCodeLong: Long, val versionCodeLong: Long,
val lang: String, val lang: String,
@GraphQLDeprecated("Removed in extension api v1.6", ReplaceWith("contentRating")) @GraphQLDeprecated("Removed in extension api v1.6", ReplaceWith("contentWarning"))
val isNsfw: Boolean, val isNsfw: Boolean,
val contentRating: ContentRating, val contentWarning: ContentWarning,
val isInstalled: Boolean, val isInstalled: Boolean,
val hasUpdate: Boolean, val hasUpdate: Boolean,
val isObsolete: Boolean, val isObsolete: Boolean,
@@ -60,8 +61,8 @@ class ExtensionType(
versionCode = row[ExtensionTable.versionCode].toInt(), versionCode = row[ExtensionTable.versionCode].toInt(),
versionCodeLong = row[ExtensionTable.versionCode], versionCodeLong = row[ExtensionTable.versionCode],
lang = row[ExtensionTable.lang], lang = row[ExtensionTable.lang],
isNsfw = row[ExtensionTable.contentRating] == 3, isNsfw = row[ExtensionTable.contentWarning] >= ContentWarning.MIXED.ordinal,
contentRating = ContentRating.valueOf(row[ExtensionTable.contentRating]), 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],
@@ -71,7 +72,7 @@ class ExtensionType(
dataFetchingEnvironment.getValueFromDataLoader<String, SourceNodeList>("SourcesForExtensionDataLoader", pkgName) dataFetchingEnvironment.getValueFromDataLoader<String, SourceNodeList>("SourcesForExtensionDataLoader", pkgName)
fun extensionStore(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<ExtensionStoreType> = fun extensionStore(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<ExtensionStoreType> =
dataFetchingEnvironment.getValueFromDataLoader<String, ExtensionStoreType>("ExtensionStoreForExtension", storeIndexUrl.orEmpty()) dataFetchingEnvironment.getValueFromDataLoader<String, ExtensionStoreType>("ExtensionStoreDataLoader", storeIndexUrl.orEmpty())
} }
data class ExtensionNodeList( data class ExtensionNodeList(

View File

@@ -8,7 +8,6 @@
package suwayomi.tachidesk.graphql.types package suwayomi.tachidesk.graphql.types
import com.expediagroup.graphql.generator.annotations.GraphQLDeprecated import com.expediagroup.graphql.generator.annotations.GraphQLDeprecated
import com.expediagroup.graphql.generator.annotations.GraphQLIgnore
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.CatalogueSource
import eu.kanade.tachiyomi.source.ConfigurableSource import eu.kanade.tachiyomi.source.ConfigurableSource
@@ -27,6 +26,7 @@ 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.GetCatalogueSource
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrStub import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrStub
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
@@ -42,12 +42,11 @@ class SourceType(
val id: Long, val id: Long,
val name: String, val name: String,
val lang: String, val lang: String,
val message: String?, val contentWarning: ContentWarning,
val contentRating: ContentRating,
val iconUrl: String, val iconUrl: String,
val supportsLatest: Boolean, val supportsLatest: Boolean,
val isConfigurable: Boolean, val isConfigurable: Boolean,
@GraphQLDeprecated("", ReplaceWith("contentRating")) @GraphQLDeprecated("", ReplaceWith("contentWarning"))
val isNsfw: Boolean, val isNsfw: Boolean,
val displayName: String, val displayName: String,
val homeUrl: String?, val homeUrl: String?,
@@ -58,12 +57,11 @@ class SourceType(
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],
message = row[SourceTable.message], contentWarning = ContentWarning.valueOf(row[SourceTable.contentWarning]),
contentRating = ContentRating.valueOf(row[SourceTable.contentRating]),
iconUrl = Extension.proxyExtensionIconUrl(sourceExtension[ExtensionTable.pkgName]), iconUrl = Extension.proxyExtensionIconUrl(sourceExtension[ExtensionTable.pkgName]),
supportsLatest = catalogueSource.supportsLatest, supportsLatest = catalogueSource.supportsLatest,
isConfigurable = catalogueSource is ConfigurableSource, isConfigurable = catalogueSource is ConfigurableSource,
isNsfw = row[SourceTable.contentRating] >= 3, isNsfw = row[SourceTable.contentWarning] >= ContentWarning.MIXED.ordinal,
displayName = catalogueSource.toString(), displayName = catalogueSource.toString(),
homeUrl = runCatching { (catalogueSource as? HttpSource)?.getHomeUrl() }.getOrNull(), homeUrl = runCatching { (catalogueSource as? HttpSource)?.getHomeUrl() }.getOrNull(),
baseUrl = runCatching { (catalogueSource as? HttpSource)?.baseUrl }.getOrNull(), baseUrl = runCatching { (catalogueSource as? HttpSource)?.baseUrl }.getOrNull(),
@@ -507,33 +505,3 @@ fun preferenceOf(preference: SourcePreference): Preference =
throw RuntimeException("sealed class cannot have more subtypes!") throw RuntimeException("sealed class cannot have more subtypes!")
} }
} }
enum class ContentRating {
SAFE,
SUGGESTIVE,
EROTICA,
PORNOGRAPHIC,
;
@GraphQLIgnore
fun getValue() =
when (this) {
SAFE -> 0
SUGGESTIVE -> 1
EROTICA -> 2
PORNOGRAPHIC -> 3
}
@GraphQLIgnore
companion object {
@GraphQLIgnore
fun valueOf(contentRating: Int) =
when (contentRating) {
0 -> SAFE
1 -> SUGGESTIVE
2 -> EROTICA
3 -> PORNOGRAPHIC
else -> SAFE
}
}
}

View File

@@ -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)

View File

@@ -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(

View File

@@ -81,7 +81,7 @@ object Manga {
return if (!onlineFetch && mangaEntry[MangaTable.initialized]) { return if (!onlineFetch && mangaEntry[MangaTable.initialized]) {
MangaTable.toDataClass(mangaEntry) MangaTable.toDataClass(mangaEntry)
} else { // initialize manga } else { // initialize manga
fetchManga(mangaId) ?: return MangaTable.toDataClass(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() }
@@ -152,6 +152,38 @@ object Manga {
} }
} }
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 =
getCatalogueSourceOrNull(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( fun updateMangaDatabase(
mangaEntry: ResultRow, mangaEntry: ResultRow,
source: CatalogueSource, source: CatalogueSource,

View File

@@ -29,6 +29,7 @@ import suwayomi.tachidesk.manga.impl.extension.Extension.proxyExtensionIconUrl
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrNull import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrNull
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrStub import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrStub
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.unregisterCatalogueSource import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.unregisterCatalogueSource
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
@@ -52,7 +53,7 @@ object Source {
iconUrl = proxyExtensionIconUrl(sourceExtension[ExtensionTable.pkgName]), iconUrl = proxyExtensionIconUrl(sourceExtension[ExtensionTable.pkgName]),
supportsLatest = catalogueSource.supportsLatest, supportsLatest = catalogueSource.supportsLatest,
isConfigurable = catalogueSource is ConfigurableSource, isConfigurable = catalogueSource is ConfigurableSource,
isNsfw = it[SourceTable.contentRating] >= 3, 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(),
) )
@@ -73,7 +74,7 @@ object Source {
iconUrl = proxyExtensionIconUrl(extension[ExtensionTable.pkgName]), iconUrl = proxyExtensionIconUrl(extension[ExtensionTable.pkgName]),
supportsLatest = catalogueSource.supportsLatest, supportsLatest = catalogueSource.supportsLatest,
isConfigurable = catalogueSource is ConfigurableSource, isConfigurable = catalogueSource is ConfigurableSource,
isNsfw = source[SourceTable.contentRating] >= 3, 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(),
) )

View File

@@ -32,6 +32,9 @@ 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
@@ -150,7 +153,10 @@ 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.getInt(METADATA_NSFW)
}
val className = val className =
packageInfo.packageName + packageInfo.applicationInfo.metaData.getString(METADATA_SOURCE_CLASS) packageInfo.packageName + packageInfo.applicationInfo.metaData.getString(METADATA_SOURCE_CLASS)
@@ -183,9 +189,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 {
@@ -196,8 +209,9 @@ object Extension {
it[this.pkgName] = packageInfo.packageName it[this.pkgName] = packageInfo.packageName
it[versionName] = packageInfo.versionName it[versionName] = packageInfo.versionName
it[versionCode] = packageInfo.versionCode.toLong() it[versionCode] = packageInfo.versionCode.toLong()
it[extensionLib] = extensionLibVersion
it[lang] = extensionLang it[lang] = extensionLang
it[contentRating] = if (isNsfw) 3 else 0 // todo will change it[this.contentWarning] = contentWarning
} }
} }
@@ -222,7 +236,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[contentRating] = if (isNsfw) 3 else 0 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}" }
} }
@@ -390,7 +404,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[contentRating] = targetExtension.contentRating.ordinal it[contentWarning] = targetExtension.contentWarning.ordinal
it[iconUrl] = targetExtension.iconUrl it[iconUrl] = targetExtension.iconUrl
it[hasUpdate] = false it[hasUpdate] = false
} }

View File

@@ -15,6 +15,9 @@ import kotlinx.serialization.decodeFromByteArray
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.okio.decodeFromBufferedSource import kotlinx.serialization.json.okio.decodeFromBufferedSource
import kotlinx.serialization.protobuf.ProtoBuf 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.eq
import org.jetbrains.exposed.v1.core.inList import org.jetbrains.exposed.v1.core.inList
import org.jetbrains.exposed.v1.jdbc.deleteWhere import org.jetbrains.exposed.v1.jdbc.deleteWhere
@@ -41,52 +44,49 @@ object ExtensionStoreService {
val protoBuf: ProtoBuf by injectLazy() val protoBuf: ProtoBuf by injectLazy()
val json: Json by injectLazy() val json: Json by injectLazy()
suspend fun fetch(indexUrl: String): ExtensionStore = fetch(indexUrl, forceV2 = false) suspend fun fetch(indexUrl: String): ExtensionStore {
var updatedIndexUrl: String = indexUrl
private suspend fun fetch(
indexUrl: String,
forceV2: Boolean,
): ExtensionStore {
var updatedIndexUrl = indexUrl
return try { return try {
val response = network.client.newCall(GET(indexUrl)).awaitSuccess() val response = network.client.newCall(GET(updatedIndexUrl)).awaitSuccess()
response.body response.body.source().decompressIfGzipped().use { source ->
.source() val networkStore =
.use { source -> when (source.peek().readByte()) {
try { // "[..."
protoBuf.decodeFromByteArray<NetworkExtensionStore>(source.peek().readByteArray()) 0x5B.toByte() -> {
} catch (e: IllegalArgumentException) { run {
logger.debug { "Failed to decode as protobuf, trying JSON" } if (!indexUrl.endsWith("/index.min.json")) {
if (forceV2) throw e throw IllegalArgumentException("Provided legacy store url is not valid")
try { }
json.decodeFromBufferedSource<NetworkExtensionStore>(source.peek()) updatedIndexUrl = indexUrl.replace("/index.min.json", "/repo.json")
} catch (_: IllegalArgumentException) { network.client.newCall(GET(updatedIndexUrl)).awaitSuccess().body.source().use {
logger.debug { "Failed to decode as NetworkExtensionStore, trying LegacyExtensionRepo" } json.decodeFromBufferedSource<NetworkLegacyExtensionRepo>(it)
val legacyIndex =
try {
json.decodeFromBufferedSource<NetworkLegacyExtensionRepo>(source.peek())
} catch (e: IllegalArgumentException) {
if (!indexUrl.endsWith("/index.min.json")) {
throw e
}
logger.debug { "Retrying with /index.min.json" }
updatedIndexUrl = indexUrl.replace("/index.min.json", "/repo.json")
network.client.newCall(GET(updatedIndexUrl)).awaitSuccess().body.source().use {
json.decodeFromBufferedSource<NetworkLegacyExtensionRepo>(it)
}
} }
if (legacyIndex.indexV2 != null) {
return fetch(legacyIndex.indexV2, forceV2 = true)
} else {
legacyIndex
} }
} }
// "{..."
0x7B.toByte() -> {
try {
json.decodeFromBufferedSource<NetworkLegacyExtensionRepo>(source.peek())
} catch (_: IllegalArgumentException) {
json.decodeFromBufferedSource<NetworkExtensionStore>(source)
}
}
else -> {
protoBuf.decodeFromByteArray<NetworkExtensionStore>(source.readByteArray())
}
} }
}.toExtensionStore(updatedIndexUrl)
if (networkStore is NetworkLegacyExtensionRepo && networkStore.indexV2 != null) {
return fetch(networkStore.indexV2)
}
networkStore.toExtensionStore(updatedIndexUrl)
}
} catch (e: Exception) { } catch (e: Exception) {
if (e is CancellationException) throw e if (e is CancellationException) throw e
logger.debug(e) { "Failed to fetch extension store '$indexUrl'" } logger.error(e) { "Failed to fetch extension store '$indexUrl'" }
throw e throw e
} }
} }
@@ -108,6 +108,7 @@ object ExtensionStoreService {
it[contactDiscord] = store.contact.discord it[contactDiscord] = store.contact.discord
it[indexUrl] = store.indexUrl it[indexUrl] = store.indexUrl
it[isLegacy] = store.isLegacy it[isLegacy] = store.isLegacy
it[extensionListUrl] = store.extensionListUrl
} }
} else { } else {
ExtensionStoreTable.update({ ExtensionStoreTable.indexUrl eq store.indexUrl }) { ExtensionStoreTable.update({ ExtensionStoreTable.indexUrl eq store.indexUrl }) {
@@ -117,6 +118,7 @@ object ExtensionStoreService {
it[contactWebsite] = store.contact.website it[contactWebsite] = store.contact.website
it[contactDiscord] = store.contact.discord it[contactDiscord] = store.contact.discord
it[isLegacy] = store.isLegacy it[isLegacy] = store.isLegacy
it[extensionListUrl] = store.extensionListUrl
} }
} }
} }
@@ -127,24 +129,28 @@ object ExtensionStoreService {
transaction { transaction {
ExtensionStoreTable.selectAll().toList() ExtensionStoreTable.selectAll().toList()
} }
return stores.mapNotNull { storeRow -> var needsPrefUpdate = false
val oldIndexUrl = storeRow[ExtensionStoreTable.indexUrl] val updateStores =
val oldName = storeRow[ExtensionStoreTable.name] stores.mapNotNull { storeRow ->
try { val oldIndexUrl = storeRow[ExtensionStoreTable.indexUrl]
val store = fetch(oldIndexUrl) val oldName = storeRow[ExtensionStoreTable.name]
upsert(store) try {
if (store.indexUrl != oldIndexUrl) { val store = fetch(oldIndexUrl)
transaction { if (store.indexUrl != oldIndexUrl) {
ExtensionStoreTable.deleteWhere { ExtensionStoreTable.indexUrl eq oldIndexUrl } transaction {
ExtensionStoreTable.deleteWhere { ExtensionStoreTable.indexUrl eq oldIndexUrl }
}
needsPrefUpdate = true
} }
syncDbToPrefs() upsert(store)
store
} catch (e: Exception) {
logger.warn(e) { "Failed to fetch extension store '$oldName ($oldIndexUrl)'" }
null
} }
store
} catch (e: Exception) {
logger.warn(e) { "Failed to fetch extension store '$oldName ($oldIndexUrl)'" }
null
} }
} if (needsPrefUpdate) syncDbToPrefs()
return updateStores
} }
fun syncDbToPrefs() { fun syncDbToPrefs() {
@@ -178,37 +184,64 @@ object ExtensionStoreService {
} }
val toAdd = prefUrls - dbStores.keys val toAdd = prefUrls - dbStores.keys
val toRemove = (dbStores.keys - prefUrls).toMutableSet()
var needsPrefUpdate = toRemove.isNotEmpty()
toAdd.forEach { url -> toAdd.forEach { url ->
try { try {
val store = fetch(url) val store = fetch(url)
if (store.indexUrl != url) {
transaction {
ExtensionStoreTable.deleteWhere { ExtensionStoreTable.indexUrl eq url }
}
needsPrefUpdate = true
toRemove -= store.indexUrl
}
upsert(store) upsert(store)
} catch (e: Exception) { } catch (e: Exception) {
logger.warn(e) { "Failed to sync preference store '$url' to database" } logger.warn(e) { "Failed to sync preference store '$url' to database" }
} }
} }
val toRemove = dbStores.keys - prefUrls
if (toRemove.isNotEmpty()) { if (toRemove.isNotEmpty()) {
transaction { transaction {
ExtensionStoreTable.deleteWhere { ExtensionStoreTable.indexUrl inList toRemove.toList() } ExtensionStoreTable.deleteWhere { ExtensionStoreTable.indexUrl inList toRemove.toList() }
} }
} }
if (needsPrefUpdate) {
syncDbToPrefs()
}
} }
suspend fun getExtensions(store: ExtensionStore): List<ExtensionInfo> { suspend fun getExtensions(store: ExtensionStore): List<ExtensionInfo> {
val extensions = val extensions =
if (!store.isLegacy) { if (store.extensionListUrl != null) {
val response = network.client.newCall(GET(store.indexUrl)).awaitSuccess() val response = network.client.newCall(GET(store.extensionListUrl)).awaitSuccess()
response.body response.body.source().decompressIfGzipped().use { source ->
.source() when (source.peek().readByte()) {
.use { source -> // "{..."
try { 0x7B.toByte() -> {
protoBuf.decodeFromByteArray<NetworkExtensionStore>(source.peek().readByteArray()) json.decodeFromBufferedSource<NetworkExtensionStore.ExtensionList>(source)
} catch (_: IllegalArgumentException) { }
json.decodeFromBufferedSource<NetworkExtensionStore>(source.peek())
else -> {
protoBuf.decodeFromByteArray<NetworkExtensionStore.ExtensionList>(
source.readByteArray(),
)
} }
}.toExtensionInfos(store) }.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 { } else {
val storeBaseUrl = store.indexUrl.removeSuffix("/repo.json") val storeBaseUrl = store.indexUrl.removeSuffix("/repo.json")
val response = network.client.newCall(GET("$storeBaseUrl/index.min.json")).awaitSuccess() val response = network.client.newCall(GET("$storeBaseUrl/index.min.json")).awaitSuccess()
@@ -220,4 +253,17 @@ object ExtensionStoreService {
} }
return extensions 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
}
} }

View File

@@ -22,6 +22,7 @@ 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.proxyExtensionIconUrl import suwayomi.tachidesk.manga.impl.extension.Extension.proxyExtensionIconUrl
import suwayomi.tachidesk.manga.model.dataclass.ContentWarning
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.dataclass.ExtensionInfo
import suwayomi.tachidesk.manga.model.table.ExtensionTable import suwayomi.tachidesk.manga.model.table.ExtensionTable
@@ -80,7 +81,7 @@ object ExtensionsList {
versionName = it[ExtensionTable.versionName], versionName = it[ExtensionTable.versionName],
versionCode = it[ExtensionTable.versionCode].toInt(), versionCode = it[ExtensionTable.versionCode].toInt(),
lang = it[ExtensionTable.lang], lang = it[ExtensionTable.lang],
isNsfw = it[ExtensionTable.contentRating] == 3, isNsfw = it[ExtensionTable.contentWarning] >= ContentWarning.MIXED.ordinal,
installed = it[ExtensionTable.isInstalled], installed = it[ExtensionTable.isInstalled],
hasUpdate = it[ExtensionTable.hasUpdate], hasUpdate = it[ExtensionTable.hasUpdate],
obsolete = it[ExtensionTable.isObsolete], obsolete = it[ExtensionTable.isObsolete],
@@ -172,7 +173,7 @@ object ExtensionsList {
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.contentRating] = foundExtension.contentRating.ordinal this[ExtensionTable.contentWarning] = foundExtension.contentWarning.ordinal
this[ExtensionTable.apkUrl] = foundExtension.apkUrl this[ExtensionTable.apkUrl] = foundExtension.apkUrl
this[ExtensionTable.iconUrl] = foundExtension.iconUrl this[ExtensionTable.iconUrl] = foundExtension.iconUrl
} }
@@ -189,7 +190,7 @@ object ExtensionsList {
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.contentRating] = foundExtension.contentRating.ordinal this[ExtensionTable.contentWarning] = foundExtension.contentWarning.ordinal
this[ExtensionTable.apkUrl] = foundExtension.apkUrl this[ExtensionTable.apkUrl] = foundExtension.apkUrl
this[ExtensionTable.iconUrl] = foundExtension.iconUrl this[ExtensionTable.iconUrl] = foundExtension.iconUrl
} }

View File

@@ -11,7 +11,7 @@ import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonNames import kotlinx.serialization.json.JsonNames
import kotlinx.serialization.protobuf.ProtoNumber import kotlinx.serialization.protobuf.ProtoNumber
import suwayomi.tachidesk.manga.model.dataclass.ContentRating import suwayomi.tachidesk.manga.model.dataclass.ContentWarning
import suwayomi.tachidesk.manga.model.dataclass.ExtensionInfo import suwayomi.tachidesk.manga.model.dataclass.ExtensionInfo
import suwayomi.tachidesk.manga.model.dataclass.ExtensionSource import suwayomi.tachidesk.manga.model.dataclass.ExtensionSource
import suwayomi.tachidesk.manga.model.dataclass.ExtensionStore import suwayomi.tachidesk.manga.model.dataclass.ExtensionStore
@@ -23,7 +23,8 @@ data class NetworkExtensionStore(
@ProtoNumber(2) val badgeLabel: String, @ProtoNumber(2) val badgeLabel: String,
@ProtoNumber(3) val signingKey: String, @ProtoNumber(3) val signingKey: String,
@ProtoNumber(4) val contact: Contact, @ProtoNumber(4) val contact: Contact,
@ProtoNumber(5) val extensions: List<Extension>, @ProtoNumber(101) val extensionList: ExtensionList?,
@ProtoNumber(102) val extensionListUrl: String?,
) : BaseNetworkExtensionStore { ) : BaseNetworkExtensionStore {
@Serializable @Serializable
data class Contact( data class Contact(
@@ -31,6 +32,11 @@ data class NetworkExtensionStore(
@ProtoNumber(2) val discord: String?, @ProtoNumber(2) val discord: String?,
) )
@Serializable
data class ExtensionList(
@ProtoNumber(1) val extensions: List<Extension>,
)
@Serializable @Serializable
data class Extension( data class Extension(
@ProtoNumber(1) val name: String, @ProtoNumber(1) val name: String,
@@ -39,7 +45,8 @@ data class NetworkExtensionStore(
@ProtoNumber(4) val extensionLib: String, @ProtoNumber(4) val extensionLib: String,
@ProtoNumber(5) val versionCode: Long, @ProtoNumber(5) val versionCode: Long,
@ProtoNumber(6) val versionName: String, @ProtoNumber(6) val versionName: String,
@ProtoNumber(7) val sources: List<Source>, @ProtoNumber(7) val contentWarning: ContentWarning,
@ProtoNumber(8) val sources: List<Source>,
) )
@Serializable @Serializable
@@ -55,27 +62,27 @@ data class NetworkExtensionStore(
@ProtoNumber(3) val language: String, @ProtoNumber(3) val language: String,
@ProtoNumber(4) val homeUrl: String = "", @ProtoNumber(4) val homeUrl: String = "",
@ProtoNumber(5) val mirrorUrls: List<String> = emptyList(), @ProtoNumber(5) val mirrorUrls: List<String> = emptyList(),
@ProtoNumber(6) val contentRating: ContentRating = ContentRating.SAFE, // @ProtoNumber(6) val contentWarning: ContentWarning = ContentWarning.SAFE,
@ProtoNumber(7) val message: String? = null, @ProtoNumber(7) val message: String? = null,
) )
@Serializable @Serializable
enum class ContentRating { enum class ContentWarning {
@ProtoNumber(0) @ProtoNumber(0)
@JsonNames("CONTENT_RATING_SAFE") @JsonNames("CONTENT_WARNING_UNSPECIFIED")
SAFE, UNSPECIFIED,
@ProtoNumber(1) @ProtoNumber(1)
@JsonNames("CONTENT_RATING_SUGGESTIVE") @JsonNames("CONTENT_WARNING_SAFE")
SUGGESTIVE, SAFE,
@ProtoNumber(2) @ProtoNumber(2)
@JsonNames("CONTENT_RATING_EROTICA") @JsonNames("CONTENT_WARNING_MIXED")
EROTICA, MIXED,
@ProtoNumber(3) @ProtoNumber(3)
@JsonNames("CONTENT_RATING_PORNOGRAPHIC") @JsonNames("CONTENT_WARNING_NSFW")
PORNOGRAPHIC, NSFW,
} }
override fun toExtensionStore(indexUrl: String): ExtensionStore = override fun toExtensionStore(indexUrl: String): ExtensionStore =
@@ -90,10 +97,11 @@ data class NetworkExtensionStore(
discord = contact.discord, discord = contact.discord,
), ),
isLegacy = false, isLegacy = false,
extensionListUrl = extensionListUrl,
) )
} }
fun NetworkExtensionStore.toExtensionInfos(store: ExtensionStore): List<ExtensionInfo> = fun NetworkExtensionStore.ExtensionList.toExtensionInfos(store: ExtensionStore): List<ExtensionInfo> =
extensions.map { extension -> extensions.map { extension ->
val lang = extension.sources.map { it.language }.toSet() val lang = extension.sources.map { it.language }.toSet()
ExtensionInfo( ExtensionInfo(
@@ -106,13 +114,15 @@ fun NetworkExtensionStore.toExtensionInfos(store: ExtensionStore): List<Extensio
versionCode = extension.versionCode, versionCode = extension.versionCode,
versionName = extension.versionName, versionName = extension.versionName,
lang = if (lang.size == 1) lang.first() else "all", lang = if (lang.size == 1) lang.first() else "all",
contentRating = contentWarning =
when (extension.sources.maxOfOrNull { it.contentRating }) { when (extension.contentWarning) {
NetworkExtensionStore.ContentRating.SAFE -> ContentRating.SAFE NetworkExtensionStore.ContentWarning.SAFE,
NetworkExtensionStore.ContentRating.SUGGESTIVE -> ContentRating.SUGGESTIVE NetworkExtensionStore.ContentWarning.UNSPECIFIED,
NetworkExtensionStore.ContentRating.EROTICA -> ContentRating.EROTICA -> ContentWarning.SAFE
NetworkExtensionStore.ContentRating.PORNOGRAPHIC -> ContentRating.PORNOGRAPHIC
null -> ContentRating.SAFE NetworkExtensionStore.ContentWarning.MIXED -> ContentWarning.MIXED
NetworkExtensionStore.ContentWarning.NSFW -> ContentWarning.NSFW
}, },
sources = sources =
extension.sources.map { source -> extension.sources.map { source ->
@@ -121,6 +131,17 @@ fun NetworkExtensionStore.toExtensionInfos(store: ExtensionStore): List<Extensio
name = source.name, name = source.name,
lang = source.language, lang = source.language,
homeUrl = source.homeUrl, 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
},
) )
}, },
) )

View File

@@ -9,7 +9,7 @@ package suwayomi.tachidesk.manga.impl.extension.github
import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import suwayomi.tachidesk.manga.model.dataclass.ContentRating import suwayomi.tachidesk.manga.model.dataclass.ContentWarning
import suwayomi.tachidesk.manga.model.dataclass.ExtensionInfo import suwayomi.tachidesk.manga.model.dataclass.ExtensionInfo
import suwayomi.tachidesk.manga.model.dataclass.ExtensionSource import suwayomi.tachidesk.manga.model.dataclass.ExtensionSource
import suwayomi.tachidesk.manga.model.dataclass.ExtensionStore import suwayomi.tachidesk.manga.model.dataclass.ExtensionStore
@@ -49,7 +49,7 @@ fun NetworkLegacyExtension.toExtensionInfo(
versionCode = code, versionCode = code,
versionName = version, versionName = version,
lang = lang, lang = lang,
contentRating = if (nsfw == 1) ContentRating.PORNOGRAPHIC else ContentRating.SAFE, contentWarning = if (nsfw == 1) ContentWarning.MIXED else ContentWarning.SAFE,
sources = sources =
if (sources.isNullOrEmpty()) { if (sources.isNullOrEmpty()) {
listOf( listOf(
@@ -58,6 +58,8 @@ fun NetworkLegacyExtension.toExtensionInfo(
name = name, name = name,
lang = lang, lang = lang,
homeUrl = "", homeUrl = "",
message = null,
contentWarning = if (nsfw == 1) ContentWarning.MIXED else ContentWarning.SAFE,
), ),
) )
} else { } else {
@@ -67,6 +69,8 @@ fun NetworkLegacyExtension.toExtensionInfo(
name = source.name, name = source.name,
lang = source.lang, lang = source.lang,
homeUrl = source.baseUrl, homeUrl = source.baseUrl,
message = null,
contentWarning = if (nsfw == 1) ContentWarning.MIXED else ContentWarning.SAFE,
) )
} }
}, },

View File

@@ -36,5 +36,6 @@ data class NetworkLegacyExtensionRepo(
discord = null, discord = null,
), ),
isLegacy = true, isLegacy = true,
extensionListUrl = null,
) )
} }

View File

@@ -127,29 +127,36 @@ object KoreaderSyncService {
} }
val mangaId = chapterRow[ChapterTable.manga].value val mangaId = chapterRow[ChapterTable.manga].value
val isDownloaded = chapterRow[ChapterTable.isDownloaded]
val checksumMethod = serverConfig.koreaderSyncChecksumMethod.value val checksumMethod = serverConfig.koreaderSyncChecksumMethod.value
val newHash = val newHash =
when (checksumMethod) { when (checksumMethod) {
KoreaderSyncChecksumMethod.BINARY -> { KoreaderSyncChecksumMethod.BINARY -> {
logger.debug { "[KOSYNC HASH] No hash for chapterId=$chapterId. Generating from downloaded content." } // Only generate binary hash if the chapter is downloaded to avoid fetching missing files
try { if (isDownloaded) {
// Always create a CBZ in memory if it doesn't exist logger.debug { "[KOSYNC HASH] No hash for chapterId=$chapterId. Generating from downloaded content." }
val (stream, _) = ChapterDownloadHelper.getArchiveStreamWithSize(mangaId, chapterId)
// Write the stream to a temp file for partial hashing
val tempFile = File.createTempFile("kosync-hash-", ".cbz")
try { try {
tempFile.outputStream().use { fos -> // Always create a CBZ in memory if it doesn't exist
stream.use { it.copyTo(fos) } val (stream, _) = ChapterDownloadHelper.getArchiveStreamWithSize(mangaId, chapterId)
// Write the stream to a temp file for partial hashing
val tempFile = File.createTempFile("kosync-hash-", ".cbz")
try {
tempFile.outputStream().use { fos ->
stream.use { it.copyTo(fos) }
}
// Use the same hashing method as for downloads
KoreaderHelper.hashContents(tempFile)
} finally {
// Always delete the temp file
tempFile.delete()
} }
// Use the same hashing method as for downloads } catch (e: Exception) {
KoreaderHelper.hashContents(tempFile) logger.warn(e) { "[KOSYNC HASH] Failed to generate archive stream for chapterId=$chapterId." }
} finally { null
// Always delete the temp file
tempFile.delete()
} }
} catch (e: Exception) { } else {
logger.warn(e) { "[KOSYNC HASH] Failed to generate archive stream for chapterId=$chapterId." } logger.debug { "[KOSYNC HASH] Skipping binary hash for chapterId=$chapterId because it is not downloaded." }
null null
} }
} }
@@ -175,7 +182,7 @@ object KoreaderSyncService {
} }
logger.info { "[KOSYNC HASH] Generated and saved new hash for chapterId=$chapterId" } logger.info { "[KOSYNC HASH] Generated and saved new hash for chapterId=$chapterId" }
} else { } else {
logger.warn { "[KOSYNC HASH] Hashing failed for chapterId=$chapterId." } logger.warn { "[KOSYNC HASH] Hashing failed or skipped for chapterId=$chapterId." }
} }
newHash newHash
} }

View File

@@ -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}" }

View File

@@ -40,6 +40,11 @@ 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.6 const val LIB_VERSION_MAX = 1.6

View File

@@ -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

View File

@@ -17,7 +17,7 @@ data class ExtensionInfo(
val versionCode: Long, val versionCode: Long,
val versionName: String, val versionName: String,
val lang: String, val lang: String,
val contentRating: ContentRating, val contentWarning: ContentWarning,
val sources: List<ExtensionSource>, val sources: List<ExtensionSource>,
) )
@@ -26,11 +26,17 @@ data class ExtensionSource(
val name: String, val name: String,
val lang: String, val lang: String,
val homeUrl: String, val homeUrl: String,
val message: String?,
val contentWarning: ContentWarning,
) )
enum class ContentRating { enum class ContentWarning {
SAFE, SAFE,
SUGGESTIVE, MIXED,
EROTICA, NSFW,
PORNOGRAPHIC, ;
companion object {
fun valueOf(contentWarning: Int) = entries.find { it.ordinal == contentWarning } ?: SAFE
}
} }

View File

@@ -14,6 +14,7 @@ data class ExtensionStore(
val signingKey: String, val signingKey: String,
val contact: Contact, val contact: Contact,
val isLegacy: Boolean, val isLegacy: Boolean,
val extensionListUrl: String?,
) { ) {
data class Contact( data class Contact(
val website: String, val website: String,

View File

@@ -10,11 +10,12 @@ 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 ExtensionStoreTable : IntIdTable() { object ExtensionStoreTable : IntIdTable() {
val indexUrl = varchar("index_url", 2048) val indexUrl = varchar("index_url", 2048).uniqueIndex()
val name = varchar("name", 256) val name = varchar("name", 256)
val badgeLabel = varchar("badge_label", 32) val badgeLabel = varchar("badge_label", 32)
val signingKey = varchar("signing_key", 512) val signingKey = varchar("signing_key", 512)
val contactWebsite = varchar("contact_website", 2048) val contactWebsite = varchar("contact_website", 2048)
val contactDiscord = varchar("contact_discord", 2048).nullable() val contactDiscord = varchar("contact_discord", 2048).nullable()
val isLegacy = bool("is_legacy").default(false) val isLegacy = bool("is_legacy").default(false)
val extensionListUrl = varchar("extension_list_url", 2048).nullable()
} }

View File

@@ -11,7 +11,7 @@ import org.jetbrains.exposed.v1.core.dao.id.IntIdTable
object ExtensionTable : IntIdTable() { object ExtensionTable : IntIdTable() {
val apkName = varchar("apk_name", 1024).nullable() val apkName = varchar("apk_name", 1024).nullable()
val storeIndexUrl = varchar("store_index_url", 2048).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")
@@ -28,7 +28,7 @@ object ExtensionTable : IntIdTable() {
val versionName = varchar("version_name", 16) val versionName = varchar("version_name", 16)
val versionCode = long("version_code") val versionCode = long("version_code")
val lang = varchar("lang", 32) val lang = varchar("lang", 32)
val contentRating = integer("content_rating") 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)

View File

@@ -8,13 +8,11 @@ package suwayomi.tachidesk.manga.model.table
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import org.jetbrains.exposed.v1.core.dao.id.IdTable import org.jetbrains.exposed.v1.core.dao.id.IdTable
import suwayomi.tachidesk.manga.model.table.columns.unlimitedVarchar
object SourceTable : IdTable<Long>() { object SourceTable : IdTable<Long>() {
override val id = long("id").entityId() override val id = long("id").entityId()
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 message = unlimitedVarchar("message").nullable() val contentWarning = integer("content_warning").default(0)
val contentRating = integer("content_rating").default(0)
} }

View File

@@ -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
@@ -644,11 +644,20 @@ object OpdsFeedBuilder {
skipMetadata, skipMetadata,
) )
// Return a not-found feed if all available chapters are filtered out as unreachable
if (skipMetadata && chapterEntries.isEmpty() && totalChapters > 0L) {
return buildNotFoundFeed(
baseUrl = baseUrl,
locale = locale,
idPath = "series/$mangaId/chapters",
title = MR.strings.opds_error_chapters_not_found.localized(locale, pageNum),
)
}
// 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) =

View File

@@ -169,6 +169,8 @@ object ChapterRepository {
} }
} }
}.awaitAll() }.awaitAll()
// Exclude unreachable chapters that are not downloaded and have no page count
.filter { it.downloaded || it.pageCount > 0 }
return Pair(enrichedChapters, totalCount) return Pair(enrichedChapters, totalCount)
} }

View File

@@ -9,8 +9,8 @@ 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.intLiteral import org.jetbrains.exposed.v1.core.intLiteral
import org.jetbrains.exposed.v1.core.like import org.jetbrains.exposed.v1.core.like
import org.jetbrains.exposed.v1.core.lowerCase import org.jetbrains.exposed.v1.core.lowerCase
@@ -75,13 +75,28 @@ fun Query.applyOpdsMangaFilter(
} }
if (excludeField != "filter") { if (excludeField != "filter") {
criteria.filter?.let { filterVal -> criteria.filter?.let { filterVal ->
val unreadCountExpr = Case().When(ChapterTable.isRead eq false, intLiteral(1)).Else(intLiteral(0)).sum()
val downloadedCountExpr = Case().When(ChapterTable.isDownloaded eq true, intLiteral(1)).Else(intLiteral(0)).sum()
when (filterVal) { when (filterVal) {
"unread" -> having { unreadCountExpr greater 0 } "unread" -> {
"downloaded" -> having { downloadedCountExpr greater 0 } andWhere {
"ongoing" -> andWhere { MangaTable.status eq MangaStatus.ONGOING.value } MangaTable.id inSubQuery
"completed" -> andWhere { MangaTable.status eq MangaStatus.COMPLETED.value } ChapterTable.select(ChapterTable.manga).where { ChapterTable.isRead eq false }
}
}
"downloaded" -> {
andWhere {
MangaTable.id inSubQuery
ChapterTable.select(ChapterTable.manga).where { ChapterTable.isDownloaded eq true }
}
}
"ongoing" -> {
andWhere { MangaTable.status eq MangaStatus.ONGOING.value }
}
"completed" -> {
andWhere { MangaTable.status eq MangaStatus.COMPLETED.value }
}
} }
} }
} }
@@ -133,11 +148,17 @@ object MangaRepository {
val unreadCount = unreadCountExpr.alias("unread_count") val unreadCount = unreadCountExpr.alias("unread_count")
// Base query with necessary joins for filtering and sorting // Base query with necessary joins for filtering and sorting
val query = var baseJoin =
MangaTable MangaTable
.join(SourceTable, JoinType.INNER, MangaTable.sourceReference, SourceTable.id) .join(SourceTable, JoinType.INNER, MangaTable.sourceReference, SourceTable.id)
.join(ChapterTable, JoinType.LEFT, MangaTable.id, ChapterTable.manga) .join(ChapterTable, JoinType.LEFT, MangaTable.id, ChapterTable.manga)
.join(CategoryMangaTable, JoinType.LEFT, MangaTable.id, CategoryMangaTable.manga)
if (criteria.categoryId != null) {
baseJoin = baseJoin.join(CategoryMangaTable, JoinType.LEFT, MangaTable.id, CategoryMangaTable.manga)
}
val query =
baseJoin
.select(MangaTable.columns + SourceTable.lang + SourceTable.name + unreadCount) .select(MangaTable.columns + SourceTable.lang + SourceTable.name + unreadCount)
.where { MangaTable.inLibrary eq true } .where { MangaTable.inLibrary eq true }
@@ -301,7 +322,6 @@ object MangaRepository {
* Applies sorting and filtering logic to a manga library query. * Applies sorting and filtering logic to a manga library query.
* @param query The Exposed SQL query to modify. * @param query The Exposed SQL query to modify.
* @param sort The sorting parameter. * @param sort The sorting parameter.
* @param filter The filtering parameter.
*/ */
private fun applyMangaLibrarySort( private fun applyMangaLibrarySort(
query: Query, query: Query,
@@ -330,36 +350,38 @@ object MangaRepository {
*/ */
fun getLibraryFilterCounts(activeFilters: OpdsMangaFilter): Map<String, Long> = fun getLibraryFilterCounts(activeFilters: OpdsMangaFilter): Map<String, Long> =
transaction { transaction {
val unreadCountExpr = Case().When(ChapterTable.isRead eq false, intLiteral(1)).Else(intLiteral(0)).sum() var baseJoin =
val downloadedCountExpr = Case().When(ChapterTable.isDownloaded eq true, intLiteral(1)).Else(intLiteral(0)).sum() MangaTable
.join(SourceTable, JoinType.INNER, MangaTable.sourceReference, SourceTable.id)
if (activeFilters.categoryId != null) {
baseJoin = baseJoin.join(CategoryMangaTable, JoinType.LEFT, MangaTable.id, CategoryMangaTable.manga)
}
val baseQuery = val baseQuery =
MangaTable baseJoin
.join(ChapterTable, JoinType.LEFT, MangaTable.id, ChapterTable.manga)
.join(SourceTable, JoinType.INNER, MangaTable.sourceReference, SourceTable.id)
.join(CategoryMangaTable, JoinType.LEFT, MangaTable.id, CategoryMangaTable.manga)
.select(MangaTable.id) .select(MangaTable.id)
.where { MangaTable.inLibrary eq true } .where { MangaTable.inLibrary eq true }
.withDistinct()
baseQuery.applyOpdsMangaFilter(activeFilters, excludeField = "filter") baseQuery.applyOpdsMangaFilter(activeFilters, excludeField = "filter")
baseQuery.groupBy(MangaTable.id)
val unreadCount = baseQuery.copy().having { unreadCountExpr greater 0 }.count() val unreadCount =
val downloadedCount = baseQuery.copy().having { downloadedCountExpr greater 0 }.count() baseQuery
.copy()
val statusBaseQuery = .andWhere {
MangaTable MangaTable.id inSubQuery
.join(SourceTable, JoinType.INNER, MangaTable.sourceReference, SourceTable.id) ChapterTable.select(ChapterTable.manga).where { ChapterTable.isRead eq false }
.join(CategoryMangaTable, JoinType.LEFT, MangaTable.id, CategoryMangaTable.manga) }.count()
.join(ChapterTable, JoinType.LEFT, MangaTable.id, ChapterTable.manga) val downloadedCount =
.select(MangaTable.id) baseQuery
.where { MangaTable.inLibrary eq true } .copy()
.andWhere {
statusBaseQuery.applyOpdsMangaFilter(activeFilters, excludeField = "filter") MangaTable.id inSubQuery
statusBaseQuery.groupBy(MangaTable.id) ChapterTable.select(ChapterTable.manga).where { ChapterTable.isDownloaded eq true }
}.count()
val ongoingCount = statusBaseQuery.copy().andWhere { MangaTable.status eq MangaStatus.ONGOING.value }.count() val ongoingCount = baseQuery.copy().andWhere { MangaTable.status eq MangaStatus.ONGOING.value }.count()
val completedCount = statusBaseQuery.copy().andWhere { MangaTable.status eq MangaStatus.COMPLETED.value }.count() val completedCount = baseQuery.copy().andWhere { MangaTable.status eq MangaStatus.COMPLETED.value }.count()
mapOf( mapOf(
"unread" to unreadCount, "unread" to unreadCount,

View File

@@ -12,7 +12,6 @@ import suwayomi.tachidesk.i18n.MR
import suwayomi.tachidesk.manga.impl.extension.Extension import suwayomi.tachidesk.manga.impl.extension.Extension
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
import suwayomi.tachidesk.manga.model.table.ChapterTable
import suwayomi.tachidesk.manga.model.table.ExtensionTable import suwayomi.tachidesk.manga.model.table.ExtensionTable
import suwayomi.tachidesk.manga.model.table.MangaStatus import suwayomi.tachidesk.manga.model.table.MangaStatus
import suwayomi.tachidesk.manga.model.table.MangaTable import suwayomi.tachidesk.manga.model.table.MangaTable
@@ -166,12 +165,17 @@ object NavigationRepository {
transaction { transaction {
val mangaCount = MangaTable.id.countDistinct().alias("manga_count") val mangaCount = MangaTable.id.countDistinct().alias("manga_count")
val query = var baseJoin =
SourceTable SourceTable
.join(MangaTable, JoinType.INNER, SourceTable.id, MangaTable.sourceReference) .join(MangaTable, JoinType.INNER, SourceTable.id, MangaTable.sourceReference)
.join(ExtensionTable, JoinType.LEFT, onColumn = SourceTable.extension, otherColumn = ExtensionTable.id) .join(ExtensionTable, JoinType.LEFT, onColumn = SourceTable.extension, otherColumn = ExtensionTable.id)
.join(CategoryMangaTable, JoinType.LEFT, MangaTable.id, CategoryMangaTable.manga)
.join(ChapterTable, JoinType.LEFT, MangaTable.id, ChapterTable.manga) if (activeFilters.categoryId != null) {
baseJoin = baseJoin.join(CategoryMangaTable, JoinType.LEFT, MangaTable.id, CategoryMangaTable.manga)
}
val query =
baseJoin
.select(SourceTable.id, SourceTable.name, SourceTable.lang, ExtensionTable.pkgName, mangaCount) .select(SourceTable.id, SourceTable.name, SourceTable.lang, ExtensionTable.pkgName, mangaCount)
.where { MangaTable.inLibrary eq true } .where { MangaTable.inLibrary eq true }
@@ -227,7 +231,6 @@ object NavigationRepository {
.join(CategoryMangaTable, JoinType.INNER, CategoryTable.id, CategoryMangaTable.category) .join(CategoryMangaTable, JoinType.INNER, CategoryTable.id, CategoryMangaTable.category)
.join(MangaTable, JoinType.INNER, CategoryMangaTable.manga, MangaTable.id) .join(MangaTable, JoinType.INNER, CategoryMangaTable.manga, MangaTable.id)
.join(SourceTable, JoinType.INNER, MangaTable.sourceReference, SourceTable.id) .join(SourceTable, JoinType.INNER, MangaTable.sourceReference, SourceTable.id)
.join(ChapterTable, JoinType.LEFT, MangaTable.id, ChapterTable.manga)
.select(CategoryTable.id, CategoryTable.name, mangaCount) .select(CategoryTable.id, CategoryTable.name, mangaCount)
.where { MangaTable.inLibrary eq true } .where { MangaTable.inLibrary eq true }
@@ -262,11 +265,15 @@ object NavigationRepository {
activeFilters: OpdsMangaFilter = OpdsMangaFilter(), activeFilters: OpdsMangaFilter = OpdsMangaFilter(),
): Pair<List<OpdsGenreNavEntry>, Long> = ): Pair<List<OpdsGenreNavEntry>, Long> =
transaction { transaction {
val query = var baseJoin =
MangaTable MangaTable
.join(SourceTable, JoinType.INNER, MangaTable.sourceReference, SourceTable.id) .join(SourceTable, JoinType.INNER, MangaTable.sourceReference, SourceTable.id)
.join(CategoryMangaTable, JoinType.LEFT, MangaTable.id, CategoryMangaTable.manga) if (activeFilters.categoryId != null) {
.join(ChapterTable, JoinType.LEFT, MangaTable.id, ChapterTable.manga) baseJoin = baseJoin.join(CategoryMangaTable, JoinType.LEFT, MangaTable.id, CategoryMangaTable.manga)
}
val query =
baseJoin
.select(MangaTable.genre) .select(MangaTable.genre)
.where { MangaTable.inLibrary eq true } .where { MangaTable.inLibrary eq true }
@@ -321,11 +328,16 @@ object NavigationRepository {
val statusCounts = val statusCounts =
transaction { transaction {
val countExpr = MangaTable.id.countDistinct().alias("manga_count") val countExpr = MangaTable.id.countDistinct().alias("manga_count")
val query =
var baseJoin =
MangaTable MangaTable
.join(SourceTable, JoinType.INNER, MangaTable.sourceReference, SourceTable.id) .join(SourceTable, JoinType.INNER, MangaTable.sourceReference, SourceTable.id)
.join(CategoryMangaTable, JoinType.LEFT, MangaTable.id, CategoryMangaTable.manga) if (activeFilters.categoryId != null) {
.join(ChapterTable, JoinType.LEFT, MangaTable.id, ChapterTable.manga) baseJoin = baseJoin.join(CategoryMangaTable, JoinType.LEFT, MangaTable.id, CategoryMangaTable.manga)
}
val query =
baseJoin
.select(MangaTable.status, countExpr) .select(MangaTable.status, countExpr)
.where { MangaTable.inLibrary eq true } .where { MangaTable.inLibrary eq true }
@@ -368,11 +380,16 @@ object NavigationRepository {
): Pair<List<OpdsLanguageNavEntry>, Long> = ): Pair<List<OpdsLanguageNavEntry>, Long> =
transaction { transaction {
val mangaCount = MangaTable.id.countDistinct().alias("manga_count") val mangaCount = MangaTable.id.countDistinct().alias("manga_count")
val query =
var baseJoin =
SourceTable SourceTable
.join(MangaTable, JoinType.INNER, SourceTable.id, MangaTable.sourceReference) .join(MangaTable, JoinType.INNER, SourceTable.id, MangaTable.sourceReference)
.join(CategoryMangaTable, JoinType.LEFT, MangaTable.id, CategoryMangaTable.manga) if (activeFilters.categoryId != null) {
.join(ChapterTable, JoinType.LEFT, MangaTable.id, ChapterTable.manga) baseJoin = baseJoin.join(CategoryMangaTable, JoinType.LEFT, MangaTable.id, CategoryMangaTable.manga)
}
val query =
baseJoin
.select(SourceTable.lang, mangaCount) .select(SourceTable.lang, mangaCount)
.where { MangaTable.inLibrary eq true } .where { MangaTable.inLibrary eq true }

View File

@@ -9,6 +9,7 @@ package suwayomi.tachidesk.server.database.migration
import de.neonew.exposed.migrations.helpers.SQLMigration import de.neonew.exposed.migrations.helpers.SQLMigration
import suwayomi.tachidesk.graphql.types.DatabaseType import suwayomi.tachidesk.graphql.types.DatabaseType
import suwayomi.tachidesk.manga.model.dataclass.ContentWarning
import suwayomi.tachidesk.server.database.migration.helpers.MAYBE_TYPE_PREFIX import suwayomi.tachidesk.server.database.migration.helpers.MAYBE_TYPE_PREFIX
import suwayomi.tachidesk.server.database.migration.helpers.UNLIMITED_TEXT import suwayomi.tachidesk.server.database.migration.helpers.UNLIMITED_TEXT
import suwayomi.tachidesk.server.database.migration.helpers.toSqlName import suwayomi.tachidesk.server.database.migration.helpers.toSqlName
@@ -33,6 +34,7 @@ class M0057_AddNewExtensionApiFields : SQLMigration() {
} }
} }
ALTER TABLE EXTENSION ALTER COLUMN store_index_url ${MAYBE_TYPE_PREFIX}VARCHAR(2048); ALTER TABLE EXTENSION ALTER COLUMN store_index_url ${MAYBE_TYPE_PREFIX}VARCHAR(2048);
CREATE INDEX extension_store_index_url ON EXTENSION (store_index_url);
ALTER TABLE EXTENSION ALTER COLUMN version_code ${MAYBE_TYPE_PREFIX}BIGINT; ALTER TABLE EXTENSION ALTER COLUMN version_code ${MAYBE_TYPE_PREFIX}BIGINT;
ALTER TABLE EXTENSION ALTER COLUMN apk_name DROP NOT NULL; ALTER TABLE EXTENSION ALTER COLUMN apk_name DROP NOT NULL;
${ ${
@@ -42,12 +44,11 @@ class M0057_AddNewExtensionApiFields : SQLMigration() {
} }
} }
ALTER TABLE EXTENSION ADD COLUMN apk_url VARCHAR(2048); ALTER TABLE EXTENSION ADD COLUMN apk_url VARCHAR(2048);
ALTER TABLE EXTENSION ADD COLUMN content_rating INTEGER DEFAULT 0; ALTER TABLE EXTENSION ADD COLUMN content_warning INTEGER DEFAULT 0;
UPDATE EXTENSION SET content_rating = 3 WHERE is_nsfw = TRUE; UPDATE EXTENSION SET content_warning = ${ContentWarning.MIXED.ordinal} WHERE is_nsfw = TRUE;
ALTER TABLE EXTENSION DROP COLUMN is_nsfw; ALTER TABLE EXTENSION DROP COLUMN is_nsfw;
ALTER TABLE SOURCE ADD COLUMN message $UNLIMITED_TEXT; ALTER TABLE SOURCE ADD COLUMN content_warning INTEGER DEFAULT 0;
ALTER TABLE SOURCE ADD COLUMN content_rating INTEGER DEFAULT 0; UPDATE SOURCE SET content_warning = ${ContentWarning.MIXED.ordinal} WHERE is_nsfw = TRUE;
UPDATE SOURCE SET content_rating = 3 WHERE is_nsfw = TRUE;
ALTER TABLE SOURCE DROP COLUMN is_nsfw; ALTER TABLE SOURCE DROP COLUMN is_nsfw;

View File

@@ -14,13 +14,14 @@ import org.jetbrains.exposed.v1.core.dao.id.IntIdTable
@Suppress("ClassName", "unused") @Suppress("ClassName", "unused")
class M0058_AddExtensionStore : AddTableMigration() { class M0058_AddExtensionStore : AddTableMigration() {
private class ExtensionStoreTable : IntIdTable() { private class ExtensionStoreTable : IntIdTable() {
val indexUrl = varchar("index_url", 2048) val indexUrl = varchar("index_url", 2048).uniqueIndex()
val name = varchar("name", 256) val name = varchar("name", 256)
val badgeLabel = varchar("badge_label", 32) val badgeLabel = varchar("badge_label", 32)
val signingKey = varchar("signing_key", 512) val signingKey = varchar("signing_key", 512)
val contactWebsite = varchar("contact_website", 2048) val contactWebsite = varchar("contact_website", 2048)
val contactDiscord = varchar("contact_discord", 2048).nullable() val contactDiscord = varchar("contact_discord", 2048).nullable()
val isLegacy = bool("is_legacy").default(false) val isLegacy = bool("is_legacy").default(false)
val extensionListUrl = varchar("extension_list_url", 2048).nullable()
} }
override val tables: Array<Table> override val tables: Array<Table>

View File

@@ -18,7 +18,6 @@ import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.BeforeAll import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance import org.junit.jupiter.api.TestInstance
import rx.Observable
import suwayomi.tachidesk.manga.impl.Search.FilterChange import suwayomi.tachidesk.manga.impl.Search.FilterChange
import suwayomi.tachidesk.manga.impl.Search.FilterObject import suwayomi.tachidesk.manga.impl.Search.FilterObject
import suwayomi.tachidesk.manga.impl.Search.SerializableGroup import suwayomi.tachidesk.manga.impl.Search.SerializableGroup
@@ -41,12 +40,11 @@ class SearchTest : ApplicationTest() {
) : StubSource(id) { ) : StubSource(id) {
var mangas: List<SManga> = emptyList() var mangas: List<SManga> = emptyList()
@Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getSearchManga")) override suspend fun getSearchManga(
override fun fetchSearchManga(
page: Int, page: Int,
query: String, query: String,
filters: FilterList, filters: FilterList,
): Observable<MangasPage> = Observable.just(MangasPage(mangas, false)) ): MangasPage = MangasPage(mangas, false)
} }
private val sourceId = 1L private val sourceId = 1L

View File

@@ -66,6 +66,7 @@ fun createChapters(
this[ChapterTable.sourceOrder] = it this[ChapterTable.sourceOrder] = it
this[ChapterTable.isRead] = read this[ChapterTable.isRead] = read
this[ChapterTable.manga] = mangaId this[ChapterTable.manga] = mangaId
this[ChapterTable.memo] = "{}"
} }
} }
} }