diff --git a/.github/ISSUE_TEMPLATE/01_report_issue.yml b/.github/ISSUE_TEMPLATE/01_report_issue.yml index 41379302..619bbf79 100644 --- a/.github/ISSUE_TEMPLATE/01_report_issue.yml +++ b/.github/ISSUE_TEMPLATE/01_report_issue.yml @@ -3,26 +3,6 @@ description: Report a source issue in Tachiyomi labels: [Bug] body: - - type: input - id: source - attributes: - label: Source information - description: | - You can find the extension name and version in **Browse → Extensions**. - placeholder: | - Example: "Mangahere 1.3.18" - validations: - required: true - - - type: input - id: language - attributes: - label: Source language - placeholder: | - Example: "English" - validations: - required: true - - type: textarea id: reproduce-steps attributes: @@ -59,14 +39,25 @@ body: - type: input id: tachiyomi-version attributes: - label: Tachiyomi version + label: Mihon/Komikku version description: | - You can find your Tachiyomi version in **More → About**. + You can find your Mihon/Komikku version in **More → About**. placeholder: | Example: "0.14.7" validations: required: true + - type: input + id: kavita-version + attributes: + label: Kavita version + description: | + You can find your Kavita version in **Settings → System**. + placeholder: | + Example: "0.8.7.2" + validations: + required: true + - type: input id: android-version attributes: @@ -95,13 +86,11 @@ body: required: true - label: I have written a short but informative title. required: true - - label: I have updated the app to version **[0.14.7](https://github.com/tachiyomiorg/tachiyomi/releases/latest)**. + - label: I have updated Mihon/Komikku to last version. required: true - - label: I have updated all installed extensions. + - label: I have updated the extension to last version. required: true - - label: I have tried the [troubleshooting guide](https://tachiyomi.org/help/guides/troubleshooting/). - required: true - - label: If this is an issue with the app itself, I should be opening an issue in the [app repository](https://github.com/tachiyomiorg/tachiyomi/issues/new/choose). + - label: If this is an issue with the app itself, I should be opening an issue in the [app repository](https://github.com/mihonapp/mihon/issues/new/choose). required: true - label: I will fill out all of the requested information in this form. required: true diff --git a/.github/ISSUE_TEMPLATE/05_request_feature.yml b/.github/ISSUE_TEMPLATE/02_request_feature.yml similarity index 67% rename from .github/ISSUE_TEMPLATE/05_request_feature.yml rename to .github/ISSUE_TEMPLATE/02_request_feature.yml index 247467f7..c34351ff 100644 --- a/.github/ISSUE_TEMPLATE/05_request_feature.yml +++ b/.github/ISSUE_TEMPLATE/02_request_feature.yml @@ -3,26 +3,6 @@ description: Suggest a feature to improve an existing source labels: [Feature request] body: - - type: input - id: source - attributes: - label: Source name - description: | - You can find the extension name in **Browse → Extensions**. - placeholder: | - Example: "Mangahere" - validations: - required: true - - - type: input - id: language - attributes: - label: Source language - placeholder: | - Example: "English" - validations: - required: true - - type: textarea id: feature-description attributes: @@ -51,9 +31,7 @@ body: required: true - label: I have written a short but informative title. required: true - - label: If this is an issue with the app itself, I should be opening an issue in the [app repository](https://github.com/tachiyomiorg/tachiyomi/issues/new/choose). - required: true - - label: I have updated the app to version **[0.14.7](https://github.com/tachiyomiorg/tachiyomi/releases/latest)**. + - label: If this is an issue with the app itself, I should be opening an issue in the [app repository](https://github.com/mihonapp/mihon/issues/new/choose). required: true - label: I will fill out all of the requested information in this form. required: true diff --git a/.github/ISSUE_TEMPLATE/02_request_source.yml b/.github/ISSUE_TEMPLATE/02_request_source.yml deleted file mode 100644 index c5752c09..00000000 --- a/.github/ISSUE_TEMPLATE/02_request_source.yml +++ /dev/null @@ -1,59 +0,0 @@ -name: 🌐 Source request -description: Suggest a new source for Tachiyomi -labels: [Source request] -body: - - - type: input - id: name - attributes: - label: Source name - placeholder: | - Example: "Not Real Scans" - validations: - required: true - - - type: input - id: link - attributes: - label: Source link - placeholder: | - Example: "https://notrealscans.org" - validations: - required: true - - - type: input - id: language - attributes: - label: Source language - placeholder: | - Example: "English" - validations: - required: true - - - type: textarea - id: other-details - attributes: - label: Other details - placeholder: | - Additional details and attachments. - Example: - "18+/NSFW = yes" - - - type: checkboxes - id: acknowledgements - attributes: - label: Acknowledgements - description: Your issue will be closed if you haven't done these steps. - options: - - label: I have checked that the extension does not already exist on the [website extensions list](https://tachiyomi.org/extensions/) or the app. - required: true - - label: I have checked that the extension is not on [the removed sources list](https://github.com/tachiyomiorg/tachiyomi-extensions/blob/master/REMOVED_SOURCES.md). - required: true - - label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open or closed issue. - required: true - - label: I have checked that the extension does not already exist by searching the [GitHub repository](https://github.com/tachiyomiorg/tachiyomi-extensions/) and verified it does not appear in the code base. - required: - - label: I have written a meaningful title with the source name. - required: true - - label: I will fill out all of the requested information in this form. - required: true diff --git a/.github/ISSUE_TEMPLATE/03_report_url_change.yml b/.github/ISSUE_TEMPLATE/03_report_url_change.yml deleted file mode 100644 index 80646a71..00000000 --- a/.github/ISSUE_TEMPLATE/03_report_url_change.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: 🔗 URL change report -description: Report URL change of an existing source -labels: [Bug,Domain changed] -body: - - - type: input - id: source - attributes: - label: Source information - description: | - You can find the extension name and version in **Browse → Extensions**. - placeholder: | - Example: "NotRealScans 1.3.1" - validations: - required: true - - - type: input - id: language - attributes: - label: Source language - placeholder: | - Example: "English" - validations: - required: true - - - type: input - id: link - attributes: - label: Source new URL - placeholder: | - Example: "https://notrealscans.org" - validations: - required: true - - - type: textarea - id: other-details - attributes: - label: Other details - placeholder: | - Additional details and attachments. - - - type: checkboxes - id: acknowledgements - attributes: - label: Acknowledgements - description: Your issue will be closed if you haven't done these steps. - options: - - label: I have updated all installed extensions. - required: true - - label: I have opened WebView and checked that the source URL is not updated yet. - required: true - - label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open or closed issue. - required: true - - label: I have written a short but informative title. - required: true - - label: I will fill out all of the requested information in this form. - required: true diff --git a/.github/ISSUE_TEMPLATE/06_request_meta.yml b/.github/ISSUE_TEMPLATE/03_request_meta.yml similarity index 81% rename from .github/ISSUE_TEMPLATE/06_request_meta.yml rename to .github/ISSUE_TEMPLATE/03_request_meta.yml index 26098fa5..94d94887 100644 --- a/.github/ISSUE_TEMPLATE/06_request_meta.yml +++ b/.github/ISSUE_TEMPLATE/03_request_meta.yml @@ -31,11 +31,7 @@ body: required: true - label: I have written a short but informative title. required: true - - label: If this is an issue with the app itself, I should be opening an issue in the [app repository](https://github.com/tachiyomiorg/tachiyomi/issues/new/choose). - required: true - - label: I have updated the app to version **[0.14.7](https://github.com/tachiyomiorg/tachiyomi/releases/latest)**. - required: true - - label: I have updated all installed extensions. + - label: If this is an issue with the app itself, I should be opening an issue in the [app repository](https://github.com/mihonapp/mihon/issues/new/choose). required: true - label: I will fill out all of the requested information in this form. required: true diff --git a/.github/ISSUE_TEMPLATE/04_report_dead_source.yml b/.github/ISSUE_TEMPLATE/04_report_dead_source.yml deleted file mode 100644 index 69ca7a55..00000000 --- a/.github/ISSUE_TEMPLATE/04_report_dead_source.yml +++ /dev/null @@ -1,63 +0,0 @@ -name: ❌ Dead source report -description: Source is down and website is closed -labels: [Source is down] -body: - - - type: markdown - attributes: - value: | - ### Notice - If you have a lot of dead sources to report, please go back and submit a single meta request. - - - type: input - id: source - attributes: - label: Source name - description: | - You can find the extension name in **Browse → Extensions**. - placeholder: | - Example: "NotRealScans" - validations: - required: true - - - type: input - id: language - attributes: - label: Source language - placeholder: | - Example: "English" - validations: - required: true - - - type: input - id: link - attributes: - label: Source link - placeholder: | - Example: "https://notrealscans.org" - validations: - required: true - - - type: textarea - id: other-details - attributes: - label: Other details - placeholder: | - Additional details and attachments. - - - type: checkboxes - id: acknowledgements - attributes: - label: Acknowledgements - description: Your issue will be closed if you haven't done these steps. - options: - - label: I have updated all installed extensions. - required: true - - label: I have opened WebView and checked that the source website is down. - required: true - - label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open or closed issue. - required: true - - label: I have written a meaningful title with the source name. - required: true - - label: I will fill out all of the requested information in this form. - required: true diff --git a/.github/ISSUE_TEMPLATE/07_request_removal.yml b/.github/ISSUE_TEMPLATE/07_request_removal.yml deleted file mode 100644 index 73f251de..00000000 --- a/.github/ISSUE_TEMPLATE/07_request_removal.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: 🗑 Source removal request -description: Scanlators can request their site to be removed -labels: [Meta request] -body: - - - type: input - id: link - attributes: - label: Source link - placeholder: | - Example: "https://notrealscans.org" - validations: - required: true - - - type: textarea - id: other-details - attributes: - label: Other details - placeholder: | - Additional details and attachments. - - - type: checkboxes - id: requirements - attributes: - label: Requirements - description: Your request will be denied if you don't meet these requirements. - options: - - label: I've added a `TXT` DNS record for my domain with the value `tachiyomi-verification` - required: true - - label: Site only hosts content scanlated by the group and not stolen from other scanlators or official releases (i.e., not an aggregator site) - required: true - - label: Site is not infested with user-hostile features (e.g., invasive or malicious ads) - required: true diff --git a/settings.gradle.kts b/settings.gradle.kts index 9b77d054..07fe230a 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -2,7 +2,7 @@ * Add or remove modules to load as needed for local development here. */ loadAllIndividualExtensions() -// loadIndividualExtension("all", "mangadex") + loadIndividualExtension("all", "kavita") /** * ===================================== COMMON CONFIGURATION ====================================== diff --git a/src/all/kavita/assets/i18n/messages_en.properties b/src/all/kavita/assets/i18n/messages_en.properties index 70a3f41b..893d4664 100644 --- a/src/all/kavita/assets/i18n/messages_en.properties +++ b/src/all/kavita/assets/i18n/messages_en.properties @@ -1,32 +1,107 @@ -login_errors_failed_login=Login failed. Something went wrong -login_errors_header_token_empty="Error: The JSON Web Token is empty.\nTry opening the extension first." -login_errors_invalid_url=Invalid URL: -login_errors_parse_tokendto=There was an error parsing the auth token -pref_customsource_title=Displayed name for source -pref_group_tags_title=Group Tags (fork must support grouping) -pref_group_tags_summary_on=Will prefix tags with their type -pref_group_tags_summary_off=List all tags together -pref_last_volume_cover_title=Keep cover updated -pref_last_volume_cover_summary_off=Keep current cover -pref_last_volume_cover_summary_on=Use the last volume cover when updating -pref_show_epub_title=Show EPUB (EPUB can't be read in Mihon) -pref_show_epub_summary_off=Hide EPUB titles in browsing -pref_show_epub_summary_on=Show EPUB titles in browsing +# ================ # +# Authentication # +# ================ # +login_errors_failed_login=Authentication failed.\nPlease check your credentials +login_errors_header_token_empty=Authorization token missing\nPlease open the extension first to generate a token +login_errors_invalid_url=Invalid server URL: +login_errors_parse_tokendto=Failed to process authentication token +http_errors_401=Authentication expired\nPlease log in again +http_errors_500=Server error occurred + +# ============== # +# Preferences # +# ============== # +pref_customsource_title=Source Display Name +pref_edit_customsource_summary=Customize how this Kavita instance appears in your library\nExample: "Home Server" or "Cloud Instance" + +pref_group_tags_title=Tag Grouping (fork must support grouping) +pref_group_tags_summary_on=Show tags with category prefixes +pref_group_tags_summary_off=Display tags as flat list + +pref_last_volume_cover_title=Keep Cover Updated +pref_last_volume_cover_summary_off=Keep series cover +pref_last_volume_cover_summary_on=Always show current Volume/Issue cover on refresh + +pref_show_epub_title=EPUB Visibility +pref_show_epub_summary_off=Hide EPUB entries +pref_show_epub_summary_on=Show EPUB entries (unreadable in Mihon) + pref_rd_date_title=Reading List: Display Release Date -pref_rd_date_summary_off=Do not display the release date -pref_rd_date_summary_on=Display the release date | !! Once you toggle it on, if you toggle it off again, it will stays as N/A !! -pref_reset_title=Reset All Settings -pref_reset_summary=Clear all preferences for this Kavita instance (Only affect the selected instance) -pref_edit_customsource_summary=Here you can change this source name.\nYou can write a descriptive name to identify this OPDS URL. -pref_filters_summary=Show these filters in the filter list -pref_filters_title=Default filters shown -pref_opds_badformed_url=Incorrect OPDS address. Please copy it from User settings \u2192 3rd party apps \u2192 OPDS URL -pref_opds_duplicated_source_url=The URL is configured in a different source -> -pref_opds_must_setup_address=You must set up the address to communicate with Kavita -pref_opds_summary=The OPDS URL copied from User Settings. This should include address and end with the API key. -restartapp_settings=Restart Mihon to apply new setting. -version_exceptions_chapters_parse=Unhandled exception parsing chapters. Send your logs to the Kavita devs. -check_version=Ensure you have the newest version of the extension and Kavita. (0.7.8 or newer.)\nIf the issue persists, report it to the Kavita developers with the accompanying logs. -version_exceptions_smart_filter=Could not decode SmartFilter. Ensure you are using Kavita version 0.7.11 or later. -http_errors_500=Something went wrong -http_errors_401=There was an error logging in. Try again or reload the app +pref_rd_date_summary_off=Hide publication dates +pref_rd_date_summary_on=Show publication dates\nNote: Once you toggle it on, if you toggle it off again, it will stays as N/A. + +pref_reset_title=Reset Settings +pref_reset_summary=Clear all preferences for this Kavita instance + +pref_filters_title=Default Filters +pref_filters_summary=Select which filters appear by default + +# ============== # +# OPDS Setup # +# ============== # +pref_opds_must_setup_address=OPDS URL required for connection +pref_opds_badformed_url=Invalid OPDS address\nCopy from: User Settings > API Key / OPDS > OPDS URL (settings#clients) +pref_opds_duplicated_source_url=URL already used in another source: +pref_opds_summary=Complete OPDS URL including API key\nFormat: http://address:port/opds?apiKey=XXX + +# ============== # +# System Messages # +# ============== # +restartapp_settings=Restart Mihon to apply changes +check_version=Requires Kavita v0.8.7+\nIf issues persist, contact support with logs +version_exceptions_chapters_parse=Chapter parsing error\nPlease report with logs +version_exceptions_smart_filter=Smart Filter error\nRequires Kavita v0.7.11+ + + +# ============== # +# Filters # +# ============== # +# Filter headers +filters_special=These filters can't be combined with other filters +filters_people=People Filters +filters_special_list=Special Lists +filters_smart_filters=Smart Filters + +# @todo +# Basic filters +filters_user_rating=User Rating +filters_status=Status +filters_sort=Sort By +filters_release_year=Release Year +filters_genres=Genres +filters_tags=Tags +filters_age_ratings=Age Ratings +filters_collections=Collections +filters_languages=Languages +filters_libraries=Libraries +filters_pub_status=Publication Status + +# People filters +filters_writers=Writers +filters_pencillers=Pencillers +filters_inkers=Inkers +filters_colorists=Colorists +filters_letterers=Letterers +filters_cover_artists=Cover Artists +filters_editors=Editors +filters_publishers=Publishers +filters_characters=Characters +filters_translators=Translators + +# Status options +filters_status_any=Any +filters_status_unread=Unread +filters_status_in_progress=In Progress +filters_status_read=Read + +# Sort options +filters_sort_title=Title +filters_sort_rating=Rating +filters_sort_date_added=Date Added +filters_sort_release_date=Release Date +filters_sort_read_progress=Read Progress + +# Error messages +filters_error_loading=Failed to load filters +filters_empty_list=No filters available + diff --git a/src/all/kavita/assets/i18n/messages_fr_fr.properties b/src/all/kavita/assets/i18n/messages_fr_fr.properties index 866b7d69..fc6a3880 100644 --- a/src/all/kavita/assets/i18n/messages_fr_fr.properties +++ b/src/all/kavita/assets/i18n/messages_fr_fr.properties @@ -1,18 +1,107 @@ -version_exceptions_chapters_parse=Exception non trait\u00E9e durant l'analyse des chapitres. Envoyez les logs aux d\u00E9velopeurs de Kavita. -pref_customsource_title=Nom d'affichage pour la source -version_exceptions_smart_filter=\u00C9chec du d\u00E9codage de SmartFilter. Assurez-vous que vous utilisez au moins Kavita version 0.7.11 -pref_opds_summary=L'URL OPDS a \u00E9t\u00E9 copi\u00E9e \u00E0 partir des param\u00E8tres de l'utilisateur. Ceci devrait inclure l'adresse et la cl\u00E9 API. -pref_filters_summary=Afficher ces filtres dans la liste des filtres -check_version=Assurez-vous que vous avez l'extension et Kavita mises \u00E0 jour. (version Mini\u202F: 0.7.8)\nSi le probl\u00E8me persiste, signalez-le aux d\u00E9veloppeurs de Kavita en fournissant des journaux -pref_filters_title=Filtres par d\u00E9faut affich\u00E9s -pref_edit_customsource_summary=Ici vous pouvez changer ce nom source.\nVous pouvez \u00E9crire un nom descriptif pour identifier cette URL opds -pref_opds_badformed_url=L'adresse OPDS n'est pas correcte. Veuillez la copier \u00E0 partir des param\u00E8tres de l'utilisateur - > Applis tierces -> URL OPDS -login_errors_parse_tokendto=Il y a eu une erreur pendant l'analyse du jeton d'authentification -restartapp_settings=Red\u00E9marrez Mihon pour appliquer le nouveau r\u00E9glage. -pref_opds_duplicated_source_url=L'URL est configur\u00E9e dans une autre source -> -pref_opds_must_setup_address=Vous devez configurer l'adresse pour communiquer avec Kavita -login_errors_failed_login=\u00C9chec de la connexion. Quelque chose s'est mal pass\u00E9 -http_errors_500=Quelque chose s'est mal pass\u00E9 -login_errors_header_token_empty=\u00AB\u00A0Erreur\u202F: le token jwt est vide.\nEssayez d'abord d'ouvrir l'extension\u00A0\u00BB -login_errors_invalid_url=URL invalide\u202F: -http_errors_401=Il y a eu une erreur. Essayez de nouveau ou relancez l'application +# ================ # +# Authentification # +# ================ # +login_errors_failed_login=chec de l'authentification. Vrifiez vos identifiants +login_errors_header_token_empty=Token d'autorisation manquant\nOuvrez d'abord l'extension pour gnrer un token +login_errors_invalid_url=URL du serveur invalide : +login_errors_parse_tokendto=chec du traitement du token d'authentification +http_errors_401=Authentification expire. Veuillez vous reconnecter +http_errors_500=Erreur du serveur + +# ============== # +# Prfrences # +# ============== # +pref_customsource_title=Nom d'affichage de la source +pref_edit_customsource_summary=Personnalisez l'affichage de cette instance Kavita\nExemple : "Serveur Maison" ou "Instance Cloud" + +pref_group_tags_title=Groupement des tags (fork doit supporter le groupement) +pref_group_tags_summary_on=Afficher les tags avec prfixes +pref_group_tags_summary_off=Afficher les tags en liste plate + +pref_last_volume_cover_title=Garder la couverture jour +pref_last_volume_cover_summary_off=Conserver la couverture de la srie +pref_last_volume_cover_summary_on=Toujours afficher la dernire couverture + +pref_show_epub_title=Visibilit EPUB +pref_show_epub_summary_off=Masquer les EPUB +pref_show_epub_summary_on=Afficher les EPUB (illisibles dans Mihon) + +pref_rd_date_title=Listes de Lecture: Affichage date de publication +pref_rd_date_summary_off=Masquer les dates de publication +pref_rd_date_summary_on=Afficher les dates de publication\nNote : Dsactiver affichera toujours "N/A" aprs activation + +pref_reset_title=Rinitialisation +pref_reset_summary=Effacer toutes les prfrences pour cette instance Kavita + +pref_filters_title=Filtres par dfaut +pref_filters_summary=Slectionnez les filtres visibles par dfaut + +# ============== # +# Configuration OPDS # +# ============== # +pref_opds_must_setup_address=URL OPDS requise pour la connexion +pref_opds_badformed_url=URL OPDS invalide\nCopiez depuis : Paramtres > Cl API / OPDS > URL OPDS +pref_opds_duplicated_source_url=URL dj utilise dans une autre source : +pref_opds_summary=URL OPDS complte incluant la cl API\nFormat : http://adresse:port/opds?apiKey=XXX + +# ============== # +# Messages systme # +# ============== # +restartapp_settings=Redmarrez Mihon pour appliquer les changements +check_version=Ncessite Kavita v0.8.7+\nSi problme persiste, contactez le support avec les logs +version_exceptions_chapters_parse=Erreur d'analyse des chapitres\nMerci de rapporter avec les logs +version_exceptions_smart_filter=Erreur Smart Filter\nNcessite Kavita v0.7.11+ + + +# ============== # +# Filtres # +# ============== # +# Filter headers +filters_special=Ces filtres ne peuvent pas tre combins avec d'autres filtres +filters_people=Filtres Personnalits + +# Special filters +filters_special_list=Listes Spciales +filters_smart_filters=Filtres Intelligents + +# Basic filters +filters_user_rating=Note Utilisateur +filters_status=Statut +filters_sort=Trier Par +filters_release_year=Anne de Sortie +filters_genres=Genres +filters_tags=tiquettes +filters_age_ratings=Classifications d'ge +filters_collections=Collections +filters_languages=Langues +filters_libraries=Bibliothques +filters_pub_status=Statut de Publication + +# People filters +filters_writers=Auteur +filters_pencillers=Dessinateurs +filters_inkers=Encreurs +filters_colorists=Coloristes +filters_letterers=Lettrists +filters_cover_artists=Artistes de Couverture +filters_editors=diteurs +filters_publishers=diteurs +filters_characters=Personnalits +filters_translators=Traducteurs + +# Status options +filters_status_any=Tous +filters_status_unread=Non Lu +filters_status_in_progress=En Cours +filters_status_read=Lu + +# Sort options +filters_sort_title=Titre +filters_sort_rating=Note +filters_sort_date_added=Date d'Ajout +filters_sort_release_date=Date de Sortie +filters_sort_read_progress=Progrs de Lecture + +# Error messages +filters_error_loading=chec du chargement des filtres +filters_empty_list=Aucun filtre disponible diff --git a/src/all/kavita/build.gradle b/src/all/kavita/build.gradle index 8504e134..36826e71 100644 --- a/src/all/kavita/build.gradle +++ b/src/all/kavita/build.gradle @@ -2,7 +2,7 @@ ext { kmkVersionCode = 1 extName = 'Kavita' extClass = '.KavitaFactory' - extVersionCode = 19 + extVersionCode = 20 isNsfw = false } diff --git a/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/Filters.kt b/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/Filters.kt index 1b4e5843..b54304d1 100644 --- a/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/Filters.kt +++ b/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/Filters.kt @@ -3,25 +3,6 @@ package eu.kanade.tachiyomi.extension.all.kavita import eu.kanade.tachiyomi.extension.all.kavita.KavitaConstants.noSmartFilterSelected import eu.kanade.tachiyomi.source.model.Filter -class UserRating : Filter.Select( - "Minimum Rating", - arrayOf( - "Any", - "1 star", - "2 stars", - "3 stars", - "4 stars", - "5 stars", - ).mapIndexed { index, _ -> index }.toTypedArray(), -) { - override fun toString(): String { - return when (state) { - 0 -> "Any" - else -> "$state star${if (state > 1) "s" else ""}" - } - } -} - class SmartFiltersFilter(smartFilters: Array) : Filter.Select("Smart Filters", arrayOf(noSmartFilterSelected) + smartFilters) class SortFilter(sortables: Array) : Filter.Sort("Sort by", sortables, Selection(0, true)) @@ -38,15 +19,22 @@ val sortableList = listOf( Pair("Random", 9), ) -class SpecialListFilter : Filter.Select( - "Special Lists", +class SpecialListFilter(name: String = "Special Lists") : Filter.Select( + name, arrayOf("None", "Want to Read", "Reading Lists"), ) -// Use Filter.CheckBox directly for status -class StatusFilter(name: String) : Filter.CheckBox(name, false) -class StatusFilterGroup(filters: List) : - Filter.Group("Status", filters) +class StatusFilter : Filter.Select( + "Status", + arrayOf("Any", "Unread", "In Progress", "Read"), +) +class StatusSeparator : + Filter.Separator() + +class StatusFilterGroup(name: String = "Status") : Filter.Group( + name, + listOf(StatusFilter()), +) // Use Filter.Text directly for release year range class ReleaseYearRange(name: String) : Filter.Text(name, "") @@ -59,7 +47,8 @@ class GenreFilterGroup(genres: List) : Filter.Group("Genres", genres) class TagFilter(name: String) : Filter.TriState(name) -class TagFilterGroup(tags: List) : Filter.Group("Tags", tags) +class TagFilterGroup(tags: List) : + Filter.Group("Tags", tags) class AgeRatingFilter(name: String) : Filter.TriState(name) class AgeRatingFilterGroup(ageRatings: List) : @@ -85,8 +74,29 @@ class PubStatusFilter(name: String) : Filter.CheckBox(name, false) class PubStatusFilterGroup(status: List) : Filter.Group("Publication Status", status) +class UserRating : Filter.Select( + "Minimum Rating", + arrayOf( + "Any", + "1 star", + "2 stars", + "3 stars", + "4 stars", + "5 stars", + ).mapIndexed { index, _ -> index }.toTypedArray(), +) { + override fun toString(): String { + return when (state) { + 0 -> "Any" + else -> "$state star${if (state > 1) "s" else ""}" + } + } +} +class UserRatingSeparator : + Filter.Separator() + class PeopleHeaderFilter(name: String) : - Filter.Header(name) + Filter.Header(name.toString()) class PeopleSeparatorFilter : Filter.Separator() diff --git a/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/Kavita.kt b/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/Kavita.kt index 4258e847..24239ac3 100644 --- a/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/Kavita.kt +++ b/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/Kavita.kt @@ -8,6 +8,7 @@ import android.util.Log import android.widget.Toast import androidx.preference.EditTextPreference import androidx.preference.MultiSelectListPreference +import androidx.preference.PreferenceScreen import androidx.preference.SwitchPreferenceCompat import eu.kanade.tachiyomi.AppInfo import eu.kanade.tachiyomi.extension.all.kavita.dto.AuthenticationDto @@ -28,6 +29,7 @@ import eu.kanade.tachiyomi.extension.all.kavita.dto.MetadataLibrary import eu.kanade.tachiyomi.extension.all.kavita.dto.MetadataPeople import eu.kanade.tachiyomi.extension.all.kavita.dto.MetadataPubStatus import eu.kanade.tachiyomi.extension.all.kavita.dto.MetadataTag +import eu.kanade.tachiyomi.extension.all.kavita.dto.MetadataUserRatings import eu.kanade.tachiyomi.extension.all.kavita.dto.PersonRole import eu.kanade.tachiyomi.extension.all.kavita.dto.ReadingListDto import eu.kanade.tachiyomi.extension.all.kavita.dto.ReadingListItemDto @@ -42,7 +44,6 @@ import eu.kanade.tachiyomi.extension.all.kavita.dto.SortOptions import eu.kanade.tachiyomi.extension.all.kavita.dto.VolumeDto import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.POST -import eu.kanade.tachiyomi.network.asObservableSuccess import eu.kanade.tachiyomi.source.ConfigurableSource import eu.kanade.tachiyomi.source.UnmeteredSource import eu.kanade.tachiyomi.source.model.Filter @@ -54,6 +55,14 @@ import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.online.HttpSource +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json import kotlinx.serialization.json.buildJsonObject @@ -67,16 +76,12 @@ import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response -import rx.Observable -import rx.Single -import rx.schedulers.Schedulers import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get import uy.kohesive.injekt.injectLazy import java.io.IOException -import java.net.ConnectException import java.security.MessageDigest -import java.util.Locale +import java.util.Calendar class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSource, HttpSource() { private val helper = KavitaHelper() @@ -103,8 +108,8 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou override val name = "${KavitaInt.KAVITA_NAME} (${preferences.getString(KavitaConstants.customSourceNamePref, suffix)})" override val lang = "all" override val supportsLatest = true - private val apiUrl: String by lazy { getPrefApiUrl().ifEmpty { "" } } - private val apiKey: String by lazy { getPrefApiKey().ifEmpty { "" } } + private val apiUrl: String by lazy { getPrefApiUrl() } + private val apiKey: String by lazy { getPrefApiKey() } override val baseUrl by lazy { getPrefBaseUrl() } private val address by lazy { getPrefAddress() } // Address for the Kavita OPDS url. Should be http(s)://host:(port)/api/opds/api-key private var jwtToken = "" // * JWT Token for authentication with the server. Stored in memory. @@ -112,7 +117,9 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou private var isLogged = false // Used to know if login was correct and not send login requests anymore private val json: Json by injectLazy() - private var series = emptyList() // Acts as a cache + // Act as a cache + private var series = emptyList() + private val libraryTypeCache = mutableMapOf() /** * Extension function to parse a network [Response] as a given type [T]. @@ -138,32 +145,46 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou * - API-style URLs (e.g., `/Series/123`) */ override fun getMangaUrl(manga: SManga): String { - return when { - // API-style URL (used internally) - manga.url.contains("/Series/") -> { - val seriesId = helper.getIdFromUrl(manga.url) - val cachedSeries = series.find { it.id == seriesId } - val libraryId = cachedSeries?.libraryId ?: run { - // Fallback: fetch from API if not cached - val fetched = client.newCall(GET("$apiUrl/Series/$seriesId", headersBuilder().build())) - .execute() - .parseAs() - fetched.libraryId + return try { + when { + // Handle both API-style and web-style URLs + manga.url.contains("/series/") || manga.url.contains("/Series/") -> { + val seriesId = manga.url + .substringAfterLast("/series/") + .substringAfterLast("/Series/") + .substringBefore("?") + .substringBefore("/") + .toIntOrNull() + + if (seriesId == null) { + Log.w(LOG_TAG, "Couldn't extract series ID from URL: ${manga.url}") + return manga.url + } + + // Try to get library ID from cached series first + val cachedSeries = series.find { it.id == seriesId } + val libraryId = cachedSeries?.libraryId + + if (libraryId != null) { + // Use web-friendly URL format + "$baseUrl/library/$libraryId/series/$seriesId" + } else { + "$baseUrl/library/1/series/$seriesId" + } } - // Return web-friendly URL for webview while keeping API URL for tracking - "$baseUrl/library/$libraryId/series/$seriesId" - } - // Handle reading list URLs - manga.url.contains("readingListId=") -> { - val readingListId = manga.url.substringAfter("readingListId=").substringBefore("&") - "$baseUrl/lists/$readingListId" - } + // Handle reading list URLs + manga.url.contains("readingListId=") -> { + val readingListId = manga.url.substringAfter("readingListId=").substringBefore("&") + "$baseUrl/lists/$readingListId" + } - // Fallback - use original URL if we don't recognize the pattern - else -> manga.url - }.also { url -> - Log.d(LOG_TAG, "Generated webview URL: $url (tracking URL: ${manga.url})") + // Fallback - use original URL if we don't recognize the pattern + else -> manga.url + } + } catch (e: Exception) { + Log.e(LOG_TAG, "Error in getMangaUrl", e) + manga.url // Always fall back to original URL } } @@ -176,66 +197,40 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou * Custom implementation for fetch popular, latest and search * Handles and logs errors to provide a more detailed exception to the users. */ - private fun fetch(request: Request): Observable { - return client.newCall(request) - .asObservableSuccess() - .onErrorResumeNext { throwable -> - // Get Http code - val field = throwable.javaClass.getDeclaredField("code") - field.isAccessible = true // Make the field accessible - try { - var code = field.get(throwable) // Get the value of the code property - Log.e(LOG_TAG, "Error fetching manga: ${throwable.message}", throwable) - if (code as Int !in intArrayOf(401, 201, 500)) { - code = 500 - } - return@onErrorResumeNext Observable.error(IOException("Http Error: $code\n ${helper.intl["http_errors_$code"]}\n${helper.intl["check_version"]}")) - } catch (e: Exception) { - Log.e(LOG_TAG, e.toString(), e) - return@onErrorResumeNext Observable.error(e) - } - } - .map { response -> - popularMangaParse(response) + private suspend fun fetch(request: Request): MangasPage = withContext(Dispatchers.IO) { + try { + val response = client.newCall(request).execute() + if (!response.isSuccessful) { + val code = response.code + throw IOException("Http Error: $code\n ${helper.intl["http_errors_$code"]}\n${helper.intl["check_version"]}") } + popularMangaParse(response) + } catch (e: Exception) { + Log.e(LOG_TAG, "Error fetching manga", e) + throw e + } } - @Deprecated( - "Use the non-RxJava API instead", - replaceWith = ReplaceWith("getPopularManga(page)"), - ) - override fun fetchPopularManga(page: Int): Observable { - return client.newCall(popularMangaRequest(page)) - .asObservableSuccess() - .map { response -> - if (!response.isSuccessful) { - throw IOException("HTTP error ${response.code}") - } - popularMangaParse(response) - } - .onErrorReturn { error -> - Log.e(LOG_TAG, "Error fetching popular manga", error) - MangasPage(emptyList(), false) + override suspend fun getPopularManga(page: Int): MangasPage = withContext(Dispatchers.IO) { + try { + val response = client.newCall(popularMangaRequest(page)).execute() + if (!response.isSuccessful) { + throw IOException("HTTP error ${response.code}") } + popularMangaParse(response) + } catch (e: Exception) { + Log.e(LOG_TAG, "Error fetching popular manga", e) + MangasPage(emptyList(), false) + } } - @Deprecated( - "Use the non-RxJava API instead", - replaceWith = ReplaceWith("getLatestUpdates(page)"), - ) - override fun fetchLatestUpdates(page: Int) = - fetch(latestUpdatesRequest(page)) + override suspend fun getLatestUpdates(page: Int): MangasPage = fetch(latestUpdatesRequest(page)) - @Deprecated( - "Use the non-RxJava API instead", - replaceWith = ReplaceWith("getSearchManga(page, query, filters)"), - ) - override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable { + override suspend fun getSearchManga(page: Int, query: String, filters: FilterList): MangasPage { val specialListFilter = filters.find { it is SpecialListFilter } as? SpecialListFilter return when (specialListFilter?.state) { - 1 -> { - // Want to Read selected + 1 -> { // Want to Read selected val payload = """{ "id": 0, "name": "", @@ -252,14 +247,12 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou headersBuilder().build(), payload.toRequestBody(JSON_MEDIA_TYPE), ) - return client.newCall(request) - .asObservableSuccess() - .map { popularMangaParse(it) } + val response = client.newCall(request).execute() + popularMangaParse(response) } 2 -> { // Reading Lists - client.newCall(readingListRequest()) - .asObservableSuccess() - .map { readingListParse(it) } + val response = client.newCall(readingListRequest()).execute() + readingListParse(response) } else -> { // Regular searches fetch(searchMangaRequest(page, query, filters)) @@ -267,123 +260,19 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou } } - @Deprecated( - "Use the non-RxJava API instead", - replaceWith = ReplaceWith("getChapterList(manga)"), - ) - override fun fetchChapterList(manga: SManga): Observable> { + override suspend fun getChapterList(manga: SManga): List { return if (manga.url.contains("/ReadingList/")) { fetchReadingListItems(manga) } else { - super.fetchChapterList(manga) + super.getChapterList(manga) } } - private fun readingListRequest(): Request { - return POST( - "$apiUrl/ReadingList/lists?includePromoted=true&sortByLastModified=true", - headersBuilder().build(), - "{}".toRequestBody(JSON_MEDIA_TYPE), - ) - } - - private fun readingListParse(response: Response): MangasPage { - return try { - val readingLists = response.parseAs>() - cachedReadingLists = readingLists - val now = java.util.Calendar.getInstance() - val mangaList = readingLists.map { list -> - val hasDates = list.startingYear > 0 && list.endingYear > 0 - val status = if (hasDates) { - val endCal = java.util.Calendar.getInstance().apply { - set(list.endingYear, list.endingMonth - 1, 1) - } - if (now.after(endCal)) SManga.PUBLISHING_FINISHED else SManga.ONGOING - } else { - SManga.COMPLETED - } - SManga.create().apply { - title = (if (list.promoted) "🔺 " else "") + list.title - artist = "${list.itemCount} items" - author = list.ownerUserName - thumbnail_url = if (!list.coverImage.isNullOrBlank()) { - "$apiUrl/image/${list.coverImage}?apiKey=$apiKey" - } else { - "$apiUrl/Image/readinglist-cover?readingListId=${list.id}&apiKey=$apiKey" - } - description = list.summary ?: "Reading List" - url = "$baseUrl/ReadingList/items?readingListId=${list.id}&source=readinglist" - initialized = true - this.status = status - } - } - MangasPage(mangaList, false) - } catch (e: Exception) { - Log.e(LOG_TAG, "Error parsing reading lists", e) - MangasPage(emptyList(), false) - } - } - - private fun fetchReadingListItems(manga: SManga): Observable> { - val readingListId = manga.url.substringAfter("readingListId=").substringBefore("&") - val request = GET("$apiUrl/ReadingList/items?readingListId=$readingListId", headersBuilder().build()) - - return client.newCall(request) - .asObservableSuccess() - .map { parseReadingListItems(it) } - } - - private fun parseReadingListItems(response: Response): List { - val items = response.parseAs>() - val readingListId = response.request.url.queryParameter("readingListId") - - return items.map { item -> - SChapter.create().apply { - url = when { - item.chapterId != null && item.chapterId > 0 -> - "/Chapter/${item.chapterId}?readingListId=$readingListId&seriesId=${item.seriesId}&chapterId=${item.chapterId}" - item.volumeId != null && item.volumeId > 0 -> - "/Volume/${item.volumeId}?readingListId=$readingListId&seriesId=${item.seriesId}&volumeId=${item.volumeId}" - else -> - // Fallback: use both seriesId and order to distinguish - "/Series/${item.seriesId}?readingListId=$readingListId&seriesId=${item.seriesId}&order=${item.order}" - } - name = buildString { - append("${item.order + 1}. ") - when { - !item.chapterTitleName.isNullOrBlank() && !item.chapterTitleName.matches(Regex("^\\d+\$")) -> { - append(item.chapterTitleName) - } - !item.volumeNumber.isNullOrBlank() && item.volumeNumber != "-100000" -> { - append("Volume ${item.volumeNumber.padStart(2, '0')}") - } - !item.chapterNumber.isNullOrBlank() && item.chapterNumber != "-100000" -> { - val libraryType = getLibraryType(item.seriesId) - when (libraryType) { - LibraryTypeEnum.Comic, LibraryTypeEnum.ComicVine -> - append("Issue #${item.chapterNumber.padStart(3, '0')}") - else -> - append("Chapter ${item.chapterNumber.padStart(2, '0')}") - } - } - } - } - date_upload = if (preferences.RdDate && !item.releaseDate.isNullOrBlank()) { - parseDateSafe(item.releaseDate) - } else { - 0L // Explicitly unset the date - } - chapter_number = item.order.toFloat() - scanlator = item.seriesName - } - }.sortedBy { it.chapter_number } - } - override fun popularMangaParse(response: Response): MangasPage { try { val result = response.parseAs>() series = result - val mangaList = result.map { item -> helper.createSeriesDto(item, apiUrl, baseUrl, apiKey) } + val mangaList = result.map { item -> helper.createSeriesDto(item, apiUrl, apiKey) } return MangasPage(mangaList, helper.hasNextPage(response)) } catch (e: Exception) { Log.e(LOG_TAG, "Unhandled exception", e) @@ -407,8 +296,17 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou sortOptions = SortOptions(SortFieldEnum.AverageRating.type, false), statements = mutableListOf(), ) - val payload = json.encodeToJsonElement(filter).toString() + // Always exclude EPUBs unless explicitly included + if (!preferences.getBoolean(SHOW_EPUB_PREF, SHOW_EPUB_DEFAULT)) { + filter.addStatement( + FilterComparison.NotContains, + FilterField.Formats, + MangaFormat.Epub.format.toString(), + ) + } + + val payload = json.encodeToJsonElement(filter).toString() return POST( "$apiUrl/Series/all-v2?pageNumber=$page&pageSize=20", headersBuilder().build(), @@ -419,14 +317,24 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou override fun latestUpdatesRequest(page: Int): Request { val filter = FilterV2Dto( sortOptions = SortOptions(SortFieldEnum.LastChapterAdded.type, false), - statements = mutableListOf(), // optionally, add statements to filter out EPUB, etc. + statements = mutableListOf(), ) + + // Always exclude EPUBs unless explicitly included + if (!preferences.getBoolean(SHOW_EPUB_PREF, SHOW_EPUB_DEFAULT)) { + filter.addStatement( + FilterComparison.NotContains, + FilterField.Formats, + MangaFormat.Epub.format.toString(), + ) + } + val payload = json.encodeToJsonElement(filter).toString() return prepareRequest(page, payload) } override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { - if (genresListMeta.isEmpty() || tagsListMeta.isEmpty() /*...*/) { + if (genresListMeta.isEmpty() || tagsListMeta.isEmpty()) { Log.w(LOG_TAG, "Metadata not loaded, retrying filters") getFilterList() // Re-initialize metadata } @@ -439,7 +347,7 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou // If a SmartFilter selected, apply its filter and return that if (smartFilterFilter?.state != 0 && smartFilterFilter != null) { val index = try { - smartFilterFilter?.state as Int - 1 + smartFilterFilter.state as Int - 1 } catch (e: Exception) { Log.e(LOG_TAG, e.toString(), e) 0 @@ -476,6 +384,39 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou statements = mutableListOf(), ) + // Handle text query first + if (query.isNotBlank()) { + val queryLower = query.trim().lowercase() + + // First try matching people + val matchedPeople = peopleListMeta.filter { + it.name.equals(query.trim(), ignoreCase = true) || + it.name.trim().lowercase().contains(queryLower) + } + + if (matchedPeople.isNotEmpty()) { + Log.d(LOG_TAG, "Matched people for query '$query': ${matchedPeople.map { it.name }}") + matchedPeople.forEach { person -> + when (PersonRole.fromId(person.role ?: -1)) { + PersonRole.Writer -> filterV2.addStatement(FilterComparison.Matches, FilterField.Writers, person.name) + PersonRole.Penciller -> filterV2.addStatement(FilterComparison.Matches, FilterField.Penciller, person.name) + PersonRole.Inker -> filterV2.addStatement(FilterComparison.Matches, FilterField.Inker, person.name) + PersonRole.Colorist -> filterV2.addStatement(FilterComparison.Matches, FilterField.Colorist, person.name) + PersonRole.Letterer -> filterV2.addStatement(FilterComparison.Matches, FilterField.Letterer, person.name) + PersonRole.CoverArtist -> filterV2.addStatement(FilterComparison.Matches, FilterField.CoverArtist, person.name) + PersonRole.Editor -> filterV2.addStatement(FilterComparison.Matches, FilterField.Editor, person.name) + PersonRole.Publisher -> filterV2.addStatement(FilterComparison.Matches, FilterField.Publisher, person.name) + PersonRole.Character -> filterV2.addStatement(FilterComparison.Matches, FilterField.Characters, person.name) + PersonRole.Translator -> filterV2.addStatement(FilterComparison.Matches, FilterField.Translators, person.name) + else -> Unit + } + } + } + + // Always search in series name + filterV2.addStatement(FilterComparison.Matches, FilterField.SeriesName, query) + } + filters.forEach { filter -> when (filter) { is Filter.Sort -> { @@ -488,7 +429,7 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou if (filter.state > 0) { filterV2.addStatement( FilterComparison.GreaterThanEqual, - FilterField.AverageRating, + FilterField.UserRating, filter.state.toString(), ) } @@ -496,32 +437,36 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou is Filter.Group<*> -> { when (filter) { is StatusFilterGroup -> { - filter.state.forEach { statusFilter -> - if ((statusFilter as Filter.CheckBox).state) { - when (statusFilter.name) { - "notRead" -> filterV2.addStatement( + val statusFilter = filter.state.firstOrNull() + statusFilter?.state?.let { selectedIndex -> + when (selectedIndex) { + 1 -> { // Unread (0%) + filterV2.addStatement( FilterComparison.Equal, FilterField.ReadProgress, "0", ) - "inProgress" -> { - filterV2.addStatement( - FilterComparison.GreaterThan, - FilterField.ReadProgress, - "0", - ) - filterV2.addStatement( - FilterComparison.LessThan, - FilterField.ReadProgress, - "100", - ) - } - "read" -> filterV2.addStatement( + } + 2 -> { // In Progress (1-99%) + filterV2.addStatement( + FilterComparison.GreaterThanEqual, + FilterField.ReadProgress, + "1", + ) + filterV2.addStatement( + FilterComparison.LessThanEqual, + FilterField.ReadProgress, + "99", + ) + } + 3 -> { // Read (100%) + filterV2.addStatement( FilterComparison.Equal, FilterField.ReadProgress, "100", ) } + // 0 is "Any" - no filter applied } } } @@ -566,41 +511,57 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou } is TagFilterGroup -> { - filter.state.forEach { tagFilter -> - val tag = tagsListMeta.find { it.title == (tagFilter as Filter.TriState).name } - tag?.let { - when (tagFilter.state) { - STATE_INCLUDE -> filterV2.addStatement( - FilterComparison.Contains, - FilterField.Tags, - tag.id.toString(), - ) - STATE_EXCLUDE -> filterV2.addStatement( - FilterComparison.NotContains, - FilterField.Tags, - tag.id.toString(), - ) - } + val included = filter.state + .filter { it.state == STATE_INCLUDE } + .mapNotNull { tagFilter -> + tagsListMeta.find { it.title == tagFilter.name }?.id?.toString() } + val excluded = filter.state + .filter { it.state == STATE_EXCLUDE } + .mapNotNull { tagFilter -> + tagsListMeta.find { it.title == tagFilter.name }?.id?.toString() + } + + if (included.isNotEmpty()) { + filterV2.addStatement( + FilterComparison.Contains, + FilterField.Tags, + included.joinToString(","), + ) + } + if (excluded.isNotEmpty()) { + filterV2.addStatement( + FilterComparison.NotContains, + FilterField.Tags, + excluded.joinToString(","), + ) } } is AgeRatingFilterGroup -> { - filter.state.forEach { ageFilter -> - val rating = ageRatingsListMeta.find { it.title == (ageFilter as Filter.TriState).name } - rating?.let { - when (ageFilter.state) { - STATE_INCLUDE -> filterV2.addStatement( - FilterComparison.Contains, - FilterField.AgeRating, - rating.value.toString(), - ) - STATE_EXCLUDE -> filterV2.addStatement( - FilterComparison.NotContains, - FilterField.AgeRating, - rating.value.toString(), - ) - } + val included = filter.state + .filter { it.state == STATE_INCLUDE } + .mapNotNull { ageFilter -> + ageRatingsListMeta.find { it.title == ageFilter.name }?.value?.toString() } + val excluded = filter.state + .filter { it.state == STATE_EXCLUDE } + .mapNotNull { ageFilter -> + ageRatingsListMeta.find { it.title == ageFilter.name }?.value?.toString() + } + + if (included.isNotEmpty()) { + filterV2.addStatement( + FilterComparison.Contains, + FilterField.AgeRating, + included.joinToString(","), + ) + } + if (excluded.isNotEmpty()) { + filterV2.addStatement( + FilterComparison.NotContains, + FilterField.AgeRating, + excluded.joinToString(","), + ) } } is FormatsFilterGroup -> { @@ -610,6 +571,7 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou "Image" -> 0 "Archive" -> 1 "Pdf" -> 4 + "Epub" -> 3 "Unknown" -> 2 else -> null } @@ -624,64 +586,96 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou } } is CollectionFilterGroup -> { - filter.state.forEach { collectionFilter -> - val collection = collectionsListMeta.find { it.title == (collectionFilter as Filter.TriState).name } - collection?.let { - when (collectionFilter.state) { - STATE_INCLUDE -> filterV2.addStatement( - FilterComparison.Contains, - FilterField.CollectionTags, - collection.id.toString(), - ) - STATE_EXCLUDE -> filterV2.addStatement( - FilterComparison.NotContains, - FilterField.CollectionTags, - collection.id.toString(), - ) - } + val included = filter.state + .filter { it.state == STATE_INCLUDE } + .mapNotNull { collectionFilter -> + collectionsListMeta.find { it.title == collectionFilter.name }?.id?.toString() } + val excluded = filter.state + .filter { it.state == STATE_EXCLUDE } + .mapNotNull { collectionFilter -> + collectionsListMeta.find { it.title == collectionFilter.name }?.id?.toString() + } + + if (included.isNotEmpty()) { + filterV2.addStatement( + FilterComparison.Contains, + FilterField.CollectionTags, + included.joinToString(","), + ) + } + if (excluded.isNotEmpty()) { + filterV2.addStatement( + FilterComparison.NotContains, + FilterField.CollectionTags, + excluded.joinToString(","), + ) } } is LanguageFilterGroup -> { - filter.state.forEach { languageFilter -> - when ((languageFilter as Filter.TriState).state) { - STATE_INCLUDE -> filterV2.addStatement( - FilterComparison.Contains, - FilterField.Languages, - languageFilter.name, - ) - STATE_EXCLUDE -> filterV2.addStatement( - FilterComparison.NotContains, - FilterField.Languages, - languageFilter.name, - ) + val included = filter.state + .filter { it.state == STATE_INCLUDE } + .mapNotNull { languageFilter -> + languagesListMeta.find { it.title == languageFilter.name }?.isoCode } + val excluded = filter.state + .filter { it.state == STATE_EXCLUDE } + .mapNotNull { languageFilter -> + languagesListMeta.find { it.title == languageFilter.name }?.isoCode + } + + if (included.isNotEmpty()) { + filterV2.addStatement( + FilterComparison.Contains, + FilterField.Languages, + included.joinToString(","), + ) + } + if (excluded.isNotEmpty()) { + filterV2.addStatement( + FilterComparison.NotContains, + FilterField.Languages, + excluded.joinToString(","), + ) } } is LibrariesFilterGroup -> { filter.state.forEach { libraryFilter -> - when ((libraryFilter as Filter.TriState).state) { - STATE_INCLUDE -> filterV2.addStatement( - FilterComparison.Contains, - FilterField.Libraries, - libraryFilter.name, - ) - STATE_EXCLUDE -> filterV2.addStatement( - FilterComparison.NotContains, - FilterField.Libraries, - libraryFilter.name, - ) + val library = libraryListMeta.find { it.name == (libraryFilter as Filter.TriState).name } + library?.let { + when (libraryFilter.state) { + STATE_INCLUDE -> filterV2.addStatement( + FilterComparison.Contains, + FilterField.Libraries, + library.id.toString(), + ) + STATE_EXCLUDE -> filterV2.addStatement( + FilterComparison.NotContains, + FilterField.Libraries, + library.id.toString(), + ) + } } } } is PubStatusFilterGroup -> { filter.state.forEach { pubStatusFilter -> if ((pubStatusFilter as Filter.CheckBox).state) { - filterV2.addStatement( - FilterComparison.Equal, - FilterField.PublicationStatus, - pubStatusFilter.name, - ) + val statusValue = when (pubStatusFilter.name) { + "Ongoing" -> 0 + "Hiatus" -> 1 + "Completed" -> 2 + "Cancelled" -> 3 + "Ended" -> 4 + else -> null + } + statusValue?.let { + filterV2.addStatement( + FilterComparison.Equal, + FilterField.PublicationStatus, + it.toString(), + ) + } } } } @@ -745,37 +739,37 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou } // @todo Apply search query and people filters via metadata filter - if (query.isNotBlank()) { - val queryLower = query.trim().lowercase() - val matchedPeople = peopleListMeta.filter { - it.name.equals(query.trim(), ignoreCase = true) || - it.name.trim().lowercase().contains(queryLower) - } - - if (matchedPeople.isNotEmpty()) { - Log.d(LOG_TAG, "Matched people for query '$query': ${matchedPeople.map { it.name }}") - matchedPeople.forEach { person -> - when (PersonRole.fromId(person.role ?: -1)) { - PersonRole.Writer -> filterV2.addStatement(FilterComparison.Matches, FilterField.Writers, person.name) - PersonRole.Penciller -> filterV2.addStatement(FilterComparison.Matches, FilterField.Penciller, person.name) - PersonRole.Inker -> filterV2.addStatement(FilterComparison.Matches, FilterField.Inker, person.name) - PersonRole.Colorist -> filterV2.addStatement(FilterComparison.Matches, FilterField.Colorist, person.name) - PersonRole.Letterer -> filterV2.addStatement(FilterComparison.Matches, FilterField.Letterer, person.name) - PersonRole.CoverArtist -> filterV2.addStatement(FilterComparison.Matches, FilterField.CoverArtist, person.name) - PersonRole.Editor -> filterV2.addStatement(FilterComparison.Matches, FilterField.Editor, person.name) - PersonRole.Publisher -> filterV2.addStatement(FilterComparison.Matches, FilterField.Publisher, person.name) - PersonRole.Character -> filterV2.addStatement(FilterComparison.Matches, FilterField.Characters, person.name) - PersonRole.Translator -> filterV2.addStatement(FilterComparison.Matches, FilterField.Translators, person.name) - else -> { - Log.d(LOG_TAG, "Unrecognized role for person: ${person.name}") - } - } - } - } else { - Log.d(LOG_TAG, "No people matched query '$query'. Falling back to SeriesName search.") - filterV2.addStatement(FilterComparison.Matches, FilterField.SeriesName, query) - } - } +// if (query.isNotBlank()) { +// val queryLower = query.trim().lowercase() +// val matchedPeople = peopleListMeta.filter { +// it.name.equals(query.trim(), ignoreCase = true) || +// it.name.trim().lowercase().contains(queryLower) +// } +// +// if (matchedPeople.isNotEmpty()) { +// Log.d(LOG_TAG, "Matched people for query '$query': ${matchedPeople.map { it.name }}") +// matchedPeople.forEach { person -> +// when (PersonRole.fromId(person.role ?: -1)) { +// PersonRole.Writer -> filterV2.addStatement(FilterComparison.Matches, FilterField.Writers, person.name) +// PersonRole.Penciller -> filterV2.addStatement(FilterComparison.Matches, FilterField.Penciller, person.name) +// PersonRole.Inker -> filterV2.addStatement(FilterComparison.Matches, FilterField.Inker, person.name) +// PersonRole.Colorist -> filterV2.addStatement(FilterComparison.Matches, FilterField.Colorist, person.name) +// PersonRole.Letterer -> filterV2.addStatement(FilterComparison.Matches, FilterField.Letterer, person.name) +// PersonRole.CoverArtist -> filterV2.addStatement(FilterComparison.Matches, FilterField.CoverArtist, person.name) +// PersonRole.Editor -> filterV2.addStatement(FilterComparison.Matches, FilterField.Editor, person.name) +// PersonRole.Publisher -> filterV2.addStatement(FilterComparison.Matches, FilterField.Publisher, person.name) +// PersonRole.Character -> filterV2.addStatement(FilterComparison.Matches, FilterField.Characters, person.name) +// PersonRole.Translator -> filterV2.addStatement(FilterComparison.Matches, FilterField.Translators, person.name) +// else -> { +// Log.d(LOG_TAG, "Unrecognized role for person: ${person.name}") +// } +// } +// } +// } else { +// Log.d(LOG_TAG, "No people matched query '$query'. Falling back to SeriesName search.") +// filterV2.addStatement(FilterComparison.Matches, FilterField.SeriesName, query) +// } +// } // Always apply selected people filters (even if query is blank) val roleToField = mapOf( @@ -805,7 +799,7 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou filterV2.addStatement( FilterComparison.NotContains, FilterField.Formats, - "3", + MangaFormat.Epub.format.toString(), ) } val payload = json.encodeToJsonElement(filterV2).toString() @@ -822,103 +816,88 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou "Use the non-RxJava API instead", replaceWith = ReplaceWith("getMangaDetails(manga)"), ) - override fun fetchMangaDetails(manga: SManga): Observable { + override suspend fun getMangaDetails(manga: SManga): SManga { val serieId = helper.getIdFromUrl(manga.url) return if (manga.url.contains("source=readinglist")) { val readingListId = manga.url.substringAfter("readingListId=").substringBefore("&").toIntOrNull() - val now = java.util.Calendar.getInstance() + val now = Calendar.getInstance() + val readingList = cachedReadingLists.find { it.id == readingListId } + if (readingList != null) { + val groupTags = preferences.groupTags + val starting = readingList.startingYear.takeIf { it > 0 }?.let { + "${readingList.startingYear}-${readingList.startingMonth.toString().padStart(2, '0')}" + } + val ending = readingList.endingYear.takeIf { it > 0 }?.let { + "${readingList.endingYear}-${readingList.endingMonth.toString().padStart(2, '0')}" + } - Observable.fromCallable { - val readingList = cachedReadingLists.find { it.id == readingListId } - if (readingList != null) { - val groupTags = preferences.groupTags - val starting = readingList.startingYear.takeIf { it > 0 }?.let { - "${readingList.startingYear}-${readingList.startingMonth.toString().padStart(2, '0')}" - } - val ending = readingList.endingYear.takeIf { it > 0 }?.let { - "${readingList.endingYear}-${readingList.endingMonth.toString().padStart(2, '0')}" - } + val itemsRequest = GET("$apiUrl/ReadingList/items?readingListId=$readingListId", headersBuilder().build()) + val items = try { + client.newCall(itemsRequest).execute().parseAs>() + } catch (e: Exception) { + Log.e(LOG_TAG, "Error parsing reading list items", e) + emptyList() + } - val itemsRequest = GET("$apiUrl/ReadingList/items?readingListId=$readingListId", headersBuilder().build()) - val items = try { - client.newCall(itemsRequest).execute().parseAs>() + val allGenres = items.flatMap { item -> + try { + val seriesMetaRequest = GET("$apiUrl/series/metadata?seriesId=${item.seriesId}", headersBuilder().build()) + client.newCall(seriesMetaRequest).execute() + .parseAs() + .genres.map { it.title } } catch (e: Exception) { - Log.e(LOG_TAG, "Error parsing reading list items", e) + Log.e(LOG_TAG, "Error fetching series metadata for item ${item.seriesId}", e) emptyList() } + }.distinct() - val allGenres = items.flatMap { item -> - try { - val seriesMetaRequest = GET("$apiUrl/series/metadata?seriesId=${item.seriesId}", headersBuilder().build()) - client.newCall(seriesMetaRequest).execute() - .parseAs() - .genres.map { it.title } - } catch (e: Exception) { - Log.e(LOG_TAG, "Error fetching series metadata for item ${item.seriesId}", e) - emptyList() - } - }.distinct() - - val genreString = if (groupTags) { - buildList { - add("Type: Reading List") - starting?.let { add("Starting: $it") } - ending?.let { add("Ending: $it") } - if (allGenres.isNotEmpty()) addAll(allGenres.map { "Genres: $it" }) - }.joinToString(", ") - } else { - buildList { - starting?.let { add("Starting: $it") } - ending?.let { add("Ending: $it") } - add("Reading List") - if (allGenres.isNotEmpty()) addAll(allGenres) - }.joinToString(", ") - } - - val hasDates = readingList.startingYear > 0 && readingList.endingYear > 0 - val status = if (hasDates) { - val endCal = java.util.Calendar.getInstance().apply { - set(readingList.endingYear, readingList.endingMonth - 1, 1) - } - if (now.after(endCal)) SManga.PUBLISHING_FINISHED else SManga.ONGOING - } else { - SManga.COMPLETED - } - - manga.apply { - title = (if (readingList.promoted) "🔺 " else "") + readingList.title - artist = "${readingList.itemCount} items" - thumbnail_url = if (!readingList.coverImage.isNullOrBlank()) { - "$apiUrl/image/${readingList.coverImage}?apiKey=$apiKey" - } else { - "$apiUrl/image/readinglist-cover?readingListId=$readingListId&apiKey=$apiKey" - } - description = readingList.summary ?: "Reading List" - genre = genreString - url = manga.url - initialized = true - this.status = status - } + val genreString = if (groupTags) { + buildList { + add("Type: Reading List") + starting?.let { add("Starting: $it") } + ending?.let { add("Ending: $it") } + if (allGenres.isNotEmpty()) addAll(allGenres.map { "Genres: $it" }) + }.joinToString(", ") } else { - manga.apply { -// description = helper.intl["error_reading_list_not_found"] - initialized = true + buildList { + starting?.let { add("Starting: $it") } + ending?.let { add("Ending: $it") } + add("Reading List") + if (allGenres.isNotEmpty()) addAll(allGenres) + }.joinToString(", ") + } + + val hasDates = readingList.startingYear > 0 && readingList.endingYear > 0 + val status = if (hasDates) { + val endCal = Calendar.getInstance().apply { + set(readingList.endingYear, readingList.endingMonth - 1, 1) } + if (now.after(endCal)) SManga.PUBLISHING_FINISHED else SManga.ONGOING + } else { + SManga.COMPLETED } - }.onErrorReturn { error -> - Log.e(LOG_TAG, "Error fetching reading list details", error) + manga.apply { - description = helper.intl["error_loading_reading_list"] + title = (if (readingList.promoted) "🔺 " else "") + readingList.title + artist = "${readingList.itemCount} items" + thumbnail_url = if (!readingList.coverImage.isNullOrBlank()) { + "$apiUrl/image/${readingList.coverImage}?apiKey=$apiKey" + } else { + "$apiUrl/image/readinglist-cover?readingListId=$readingListId&apiKey=$apiKey" + } + description = readingList.summary ?: "Reading List" + genre = genreString + url = manga.url initialized = true + this.status = status } + } else { + manga.apply { initialized = true } } } else { - client.newCall(GET("$apiUrl/series/metadata?seriesId=$serieId", headersBuilder().build())) - .asObservableSuccess() - .map { response -> - mangaDetailsParse(response).apply { initialized = true } - } + val response = client.newCall(GET("$apiUrl/series/metadata?seriesId=$serieId", headersBuilder().build())).execute() + mangaDetailsParse(response).apply { initialized = true } } } @@ -949,8 +928,8 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou ), ).execute() - val rawResponse = responsePlus.body?.string() - if (rawResponse.isNullOrBlank() || rawResponse.trim().startsWith("<")) { + val rawResponse = responsePlus.body.string() + if (rawResponse.isBlank() || rawResponse.trim().startsWith("<")) { Log.e(LOG_TAG, "Invalid response for series-detail-plus: $rawResponse") // Optionally, show a default or skip averageScore "" @@ -986,9 +965,9 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou .filterNot { it.equals(foundDemographic, ignoreCase = true) } .filterNot { tag -> filteredGenres.any { genre -> genre.equals(tag, ignoreCase = true) } } - val manga = helper.createSeriesDto(existingSeries, apiUrl, baseUrl, apiKey) + val manga = helper.createSeriesDto(existingSeries, apiUrl, apiKey) manga.title = existingSeries.name - manga.url = "$baseUrl/library/${existingSeries.libraryId}/series/${result.seriesId}" // "$apiUrl/Series/${result.seriesId}" + manga.url = "$apiUrl/Series/${result.seriesId}" // "$baseUrl/library/${existingSeries.libraryId}/series/${result.seriesId}" manga.artist = result.coverArtists.joinToString { it.name } manga.description = listOfNotNull( ratingLine.takeIf { it.isNotBlank() }, @@ -1009,64 +988,78 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou manga.thumbnail_url = if (preferences.LastVolumeCover) { try { - try { - Log.d(LOG_TAG, "Fetching volumes for series ${result.seriesId}") - val volumesRequest = GET("$apiUrl/Series/volumes?seriesId=${result.seriesId}", headersBuilder().build()) - val volumes = client.newCall(volumesRequest).execute().parseAs>() + Log.d(LOG_TAG, "Fetching volumes for series ${result.seriesId}") + val seriesId = result.seriesId ?: 0 + val volumes = client.newCall( + GET("$apiUrl/Series/volumes?seriesId=$seriesId", headersBuilder().build()), + ).execute().parseAs>() - Log.d(LOG_TAG, "Found ${volumes.size} volumes for series ${result.seriesId}") -// volumes.forEachIndexed { index, volume -> -// Log.d( -// LOG_TAG, -// "Volume #${index + 1}: " + -// "id=${volume.id}, " + -// "number=${volume.number}, " + -// "name=${volume.name}, " + -// "cover=${volume.coverImage.isNotBlank()}, " + -// "chapters=${volume.chapters.size}", -// ) -// } + val libraryType = getLibraryType(seriesId) + val isComicLibrary = libraryType == LibraryTypeEnum.Comic || libraryType == LibraryTypeEnum.ComicVine + val coverCandidates = mutableListOf>() // (url, isUnread, number) + val chapterMap = mutableMapOf() + val volumeMap = mutableMapOf() - // Find all volumes that have SingleFileVolume chapters and valid covers - val validVolumes = volumes.filter { volume -> + for (volume in volumes) { + // Issue: use chapter covers + if (isComicLibrary && volume.minNumber.toInt() == KavitaConstants.UNNUMBERED_VOLUME) { + for (chapter in volume.chapters) { + if (chapter.coverImage.isNotBlank()) { + val url = "$apiUrl/Image/chapter-cover?chapterId=${chapter.id}&apiKey=$apiKey" + coverCandidates.add( + Triple(url, chapter.pagesRead < chapter.pages, chapter.number.toFloatOrNull() ?: 0f), + ) + chapterMap[url] = chapter + } + } + } + // Manga: use volume cover + else if (!isComicLibrary) { val hasSingleFile = volume.chapters.any { chapter -> ChapterType.of(chapter, volume) == ChapterType.SingleFileVolume } - val hasCover = volume.coverImage.isNotBlank() -// Log.d( -// LOG_TAG, -// "Volume ${volume.number} - " + -// "hasSingleFile: $hasSingleFile, " + -// "hasCover: $hasCover", -// ) - hasSingleFile && hasCover + if (hasSingleFile && volume.coverImage.isNotBlank()) { + val url = "$apiUrl/Image/volume-cover?volumeId=${volume.id}&apiKey=$apiKey" + val isUnread = volume.pagesRead < volume.pages + val number = volume.minNumber.toFloat() + coverCandidates.add(Triple(url, isUnread, number)) + volumeMap[url] = volume + } } - - Log.d(LOG_TAG, "Found ${validVolumes.size} valid volumes with covers") -// validVolumes.forEach { volume -> -// Log.d(LOG_TAG, "Valid volume: #${volume.number} (id=${volume.id})") -// } - - // Get the last volume with a valid cover - val lastValidVolume = validVolumes.maxByOrNull { it.number } - Log.d(LOG_TAG, "Selected last volume: ${lastValidVolume?.number ?: "NONE"} (id=${lastValidVolume?.id ?: "NONE"})") - - if (lastValidVolume != null && lastValidVolume.id > 0) { - "$apiUrl/Image/volume-cover?volumeId=${lastValidVolume.id}&apiKey=$apiKey" - } else { - "$apiUrl/image/series-cover?seriesId=${result.seriesId}&apiKey=$apiKey" - } - } catch (e: Exception) { - Log.e(LOG_TAG, "Error fetching volumes for last cover", e) - "$apiUrl/image/series-cover?seriesId=${result.seriesId}&apiKey=$apiKey" } + + Log.d(LOG_TAG, "Found ${coverCandidates.size} cover candidates") + + // Prefer first unread (lowest number), else most recent (highest number) + val targetCover = coverCandidates + .filter { it.second } + .minByOrNull { it.third } + ?: coverCandidates.maxByOrNull { it.third } + + targetCover?.first?.let { baseUrl -> + val timestamp = when { + baseUrl.contains("chapter-cover") -> { + val chapter = chapterMap[baseUrl] + chapter?.lastModifiedUtc + } + baseUrl.contains("volume-cover") -> { + val volume = volumeMap[baseUrl] + volume?.lastModified + } + else -> null + } ?: System.currentTimeMillis().toString() + + // Append cache-busting timestamp to always get the latest cover + "$baseUrl&ts=$timestamp" + } ?: "$apiUrl/image/series-cover?seriesId=$seriesId&apiKey=$apiKey" } catch (e: Exception) { - Log.e(LOG_TAG, "Error fetching volumes for last cover", e) - "$apiUrl/image/series-cover?seriesId=${result.seriesId}&apiKey=$apiKey" + Log.e(LOG_TAG, "Error fetching volumes for cover selection", e) + "$apiUrl/image/series-cover?seriesId=${result.seriesId ?: 0}&apiKey=$apiKey" } } else { - "$apiUrl/image/series-cover?seriesId=${result.seriesId}&apiKey=$apiKey" + "$apiUrl/image/series-cover?seriesId=${result.seriesId ?: 0}&apiKey=$apiKey" } + manga.status = when (result.publicationStatus) { 4 -> SManga.PUBLISHING_FINISHED 2 -> SManga.COMPLETED @@ -1099,7 +1092,7 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou .filterNot { it.equals(foundDemographic, ignoreCase = true) } .filterNot { tag -> filteredGenres.any { genre -> genre.equals(tag, ignoreCase = true) } } - url = "$baseUrl/library/${result.getLibraryId(serieDto)}/series/${result.seriesId}" // "$apiUrl/Series/${result.seriesId}" + url = "$baseUrl/Series/${result.seriesId}" // "$baseUrl/library/${result.getLibraryId(serieDto)}/series/${result.seriesId}" artist = result.coverArtists.joinToString { it.name } description = listOfNotNull( ratingLine.takeIf { it.isNotBlank() }, @@ -1119,7 +1112,6 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou } title = serieDto.name - thumbnail_url = "$apiUrl/image/series-cover?seriesId=${result.seriesId}&apiKey=$apiKey" status = when (result.publicationStatus) { 4 -> SManga.PUBLISHING_FINISHED 2 -> SManga.COMPLETED @@ -1128,10 +1120,178 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou 1 -> SManga.ON_HIATUS else -> SManga.UNKNOWN } - Log.d(LOG_TAG, "Publication status received: ${result.publicationStatus}") +// Log.d(LOG_TAG, "Publication status received: ${result.publicationStatus}") } } + /** + ** Reading Lists + **/ + private fun readingListRequest(): Request { + return POST( + "$apiUrl/ReadingList/lists?includePromoted=true&sortByLastModified=true", + headersBuilder().build(), + "{}".toRequestBody(JSON_MEDIA_TYPE), + ) + } + + private fun readingListParse(response: Response): MangasPage { + return try { + val readingLists = response.parseAs>() + cachedReadingLists = readingLists + val now = Calendar.getInstance() + val mangaList = readingLists.map { list -> + val hasDates = list.startingYear > 0 && list.endingYear > 0 + val status = if (hasDates) { + val endCal = Calendar.getInstance().apply { + set(list.endingYear, list.endingMonth - 1, 1) + } + if (now.after(endCal)) SManga.PUBLISHING_FINISHED else SManga.ONGOING + } else { + SManga.COMPLETED + } + SManga.create().apply { + title = (if (list.promoted) "🔺 " else "") + list.title + artist = "${list.itemCount} items" + author = list.ownerUserName + thumbnail_url = if (!list.coverImage.isNullOrBlank()) { + "$apiUrl/image/${list.coverImage}?apiKey=$apiKey" + } else { + "$apiUrl/Image/readinglist-cover?readingListId=${list.id}&apiKey=$apiKey" + } + description = list.summary ?: "Reading List" + url = "$baseUrl/ReadingList/items?readingListId=${list.id}&source=readinglist" + initialized = true + this.status = status + } + } + MangasPage(mangaList, false) + } catch (e: Exception) { + Log.e(LOG_TAG, "Error parsing reading lists", e) + MangasPage(emptyList(), false) + } + } + + private fun fetchReadingListItems(manga: SManga): List { + val readingListId = manga.url.substringAfter("readingListId=").substringBefore("&") + val request = GET("$apiUrl/ReadingList/items?readingListId=$readingListId", headersBuilder().build()) + val response = client.newCall(request).execute() + return parseReadingListItems(response) + } + + private fun parseReadingListItems(response: Response): List { + val items = response.parseAs>() + val readingListId = response.request.url.queryParameter("readingListId") + + val firstItem = items.firstOrNull() + val isWebtoon = firstItem?.let { item -> + runCatching { + val meta = client.newCall(GET("$apiUrl/series/metadata?seriesId=${item.seriesId}", headersBuilder().build())) + .execute().parseAs() + + val genreTitles = meta.genres.map { it.title } + val tagTitles = meta.tags.map { it.title } + val typeLabels = listOfNotNull(meta.libraryName) + helper.isWebtoonOrLongStrip(genreTitles + tagTitles + typeLabels) + }.getOrElse { false } + } ?: false + + return items.map { item -> + SChapter.create().apply { + url = when { + item.chapterId != null && item.chapterId > 0 -> + "/Chapter/${item.chapterId}?readingListId=$readingListId&seriesId=${item.seriesId}&chapterId=${item.chapterId}" + item.volumeId != null && item.volumeId > 0 -> + "/Volume/${item.volumeId}?readingListId=$readingListId&seriesId=${item.seriesId}&volumeId=${item.volumeId}" + else -> + "/Series/${item.seriesId}?readingListId=$readingListId&seriesId=${item.seriesId}&order=${item.order}" + } + + val type = when { + item.volumeId != null && item.volumeNumber != null -> { + when { + item.volumeNumber == KavitaConstants.UNNUMBERED_VOLUME_STR -> { + val libraryType = getLibraryType(item.seriesId) + when (libraryType) { + LibraryTypeEnum.Comic, LibraryTypeEnum.ComicVine -> ChapterType.Issue + else -> ChapterType.Chapter + } + } + item.chapterNumber == KavitaConstants.UNNUMBERED_VOLUME_STR -> ChapterType.SingleFileVolume + else -> ChapterType.Regular + } + } + item.chapterId != null && item.chapterNumber != null -> { + val libraryType = getLibraryType(item.seriesId) + when (libraryType) { + LibraryTypeEnum.Comic, LibraryTypeEnum.ComicVine -> ChapterType.Issue + else -> ChapterType.Chapter + } + } + else -> ChapterType.Special + } + + name = buildString { + append("${item.order + 1}. ") + when (type) { + ChapterType.Regular -> { + val chapterNum = item.chapterNumber?.toIntOrNull()?.toString() + ?.padStart(2, '0') + ?: item.chapterNumber ?: "" + when { + item.chapterTitleName?.isNotBlank() == true -> + append("$chapterNum - ${item.chapterTitleName}") + else -> + append("Vol. ${item.volumeNumber} Ch. $chapterNum") + } + } + ChapterType.SingleFileVolume -> { + when { + item.chapterTitleName?.isNotBlank() == true -> + append("v${item.volumeNumber} - ${item.chapterTitleName}") + else -> + append("Volume ${item.volumeNumber}") + } + } + ChapterType.Issue -> { + val issueNum = item.chapterNumber?.toIntOrNull()?.toString() + ?.padStart(3, '0') + ?: item.chapterNumber ?: "" + when { + item.chapterTitleName?.isNotBlank() == true -> + append("${item.chapterTitleName} (#$issueNum)") + else -> + append("Issue #$issueNum") + } + } + ChapterType.Chapter -> { + val chapterNum = item.chapterNumber?.toIntOrNull()?.toString() + ?.padStart(2, '0') + ?: item.chapterNumber ?: "" + when { + item.chapterTitleName?.isNotBlank() == true -> + append("$chapterNum - ${item.chapterTitleName}") + else -> + append(if (isWebtoon) "Episode $chapterNum" else "Chapter $chapterNum") + } + } + ChapterType.Special -> { + append(item.chapterTitleName ?: "Item ${item.order + 1}") + } + } + } + + date_upload = if (preferences.RdDate && !item.releaseDate.isNullOrBlank()) { + parseDateSafe(item.releaseDate) + } else { + 0L + } + chapter_number = item.order.toFloat() + scanlator = item.seriesName + } + }.sortedBy { it.chapter_number } + } + /** * Related Titles / Suggestions * This only works on Komikku @@ -1158,15 +1318,15 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou try { val seriesDto = client.newCall(GET("$apiUrl/Series/${item.seriesId}", headersBuilder().build())) .execute().parseAs() - helper.createSeriesDto(seriesDto, apiUrl, baseUrl, apiKey) + helper.createSeriesDto(seriesDto, apiUrl, apiKey) } catch (e: Exception) { Log.e(LOG_TAG, "Error fetching series for related reading list", e) null } } } else { - val rawResponse = response.body?.string() - if (rawResponse.isNullOrEmpty() || rawResponse.startsWith("<")) { + val rawResponse = response.body.string() + if (rawResponse.isEmpty() || rawResponse.startsWith("<")) { Log.e(LOG_TAG, "Invalid response for related manga: $rawResponse") return emptyList() } @@ -1221,71 +1381,119 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou } private fun getLibraryType(seriesId: Int): LibraryTypeEnum? { - return try { - val seriesDto = client.newCall(GET("$apiUrl/Series/$seriesId", headersBuilder().build())) - .execute() - .parseAs() - - // First try to get library type from library metadata - libraryListMeta.find { it.id == seriesDto.libraryId }?.type?.let { typeInt -> - LibraryTypeEnum.fromInt(typeInt) - } ?: run { - // Fallback: fetch library directly - val library = client.newCall(GET("$apiUrl/Library/${seriesDto.libraryId}", headersBuilder().build())) + return libraryTypeCache[seriesId] ?: run { + try { + val seriesDto = client.newCall(GET("$apiUrl/Series/$seriesId", headersBuilder().build())) .execute() - .parseAs() - LibraryTypeEnum.fromInt(library.type) + .parseAs() + + val type = libraryListMeta.find { it.id == seriesDto.libraryId }?.type?.let { typeInt -> + LibraryTypeEnum.fromInt(typeInt) + } ?: run { + val library = client.newCall(GET("$apiUrl/Library/${seriesDto.libraryId}", headersBuilder().build())) + .execute() + .parseAs() + LibraryTypeEnum.fromInt(library.type) + } + + type?.let { libraryTypeCache[seriesId] = it } + type + } catch (e: Exception) { + Log.e(LOG_TAG, "Error getting library type for series $seriesId", e) + null } - } catch (e: Exception) { - Log.e(LOG_TAG, "Error getting library type for series $seriesId", e) - null } } -// override fun chapterListParse(response: Response): List { -// try { -// val volumes = response.parseAs>() -// val allChapterList = mutableListOf() -// volumes.forEach { volume -> -// // Add all chapters from each volume as individual SChapter -// volume.chapters.forEach { chapter -> -// allChapterList.add(helper.chapterFromVolume(chapter, volume)) -// } -// } -// allChapterList.sortWith(KavitaHelper.CompareChapters) -// return allChapterList -// } catch (e: Exception) { -// Log.e(LOG_TAG, "Unhandled exception parsing chapters. Send logs to kavita devs", e) -// throw IOException(helper.intl["version_exceptions_chapters_parse"]) -// } -// } - override fun chapterListParse(response: Response): List { try { val seriesId = response.request.url.queryParameter("seriesId")?.toIntOrNull() val libraryType = seriesId?.let { getLibraryType(it) } val volumes = response.parseAs>() - val allChapters = mutableListOf() + val chapters = mutableListOf() + val volumeItems = mutableListOf() + + // Get the series name to use as mangaTitle + val seriesName = seriesId?.let { + runCatching { + client.newCall(GET("$apiUrl/Series/$seriesId", headersBuilder().build())) + .execute().parseAs().name + }.getOrNull() + } ?: "" + + // Get series metadata to determine if it's a webtoon + val isWebtoon = seriesId?.let { + runCatching { + val meta = client.newCall(GET("$apiUrl/series/metadata?seriesId=$it", headersBuilder().build())) + .execute().parseAs() + val genreTitles = meta.genres.map { it.title } + val tagTitles = meta.tags.map { it.title } + val typeLabels = listOfNotNull(meta.libraryName) + helper.isWebtoonOrLongStrip(genreTitles + tagTitles + typeLabels) + }.getOrElse { false } + } ?: false volumes.forEach { volume -> - volume.chapters.forEach { chapter -> - val sChapter = helper.chapterFromVolume(chapter, volume, libraryType = libraryType) - allChapters.add(sChapter) + // This fixes specials being parsed as volumes sometimes + if (volume.chapters.size == 1 && volume.minNumber.toInt() != KavitaConstants.SPECIAL_NUMBER) { + val chapter = volume.chapters.first() + val sChapter = helper.chapterFromVolume( + chapter, + volume, + singleFileVolumeNumber = volume.minNumber, + libraryType = libraryType, + isWebtoon = isWebtoon, + mangaTitle = seriesName, + ) + sChapter.url = "/Chapter/${chapter.id}" // Needed to read volumes as chapters + sChapter.chapter_number = volume.minNumber.toFloat() + sChapter.scanlator = if (isWebtoon) "Season" else "Volume" + volumeItems.add(sChapter) + } else { + volume.chapters.forEach { chapter -> + val type = ChapterType.of(chapter, volume, libraryType) + val sChapter = helper.chapterFromVolume( + chapter, + volume, + libraryType = libraryType, + isWebtoon = isWebtoon, + mangaTitle = seriesName, + ) + if (type == ChapterType.SingleFileVolume) { + sChapter.url = "/Chapter/${chapter.id}" // Needed to read volumes as chapters + sChapter.chapter_number = volume.minNumber.toFloat() + volumeItems.add(sChapter) + } else { + chapters.add(sChapter) + } + } } } - // Handle single-file volumes by assigning sequential numbers - if (allChapters.all { it.scanlator == "Volume" }) { - return allChapters.mapIndexed { idx, chapter -> - chapter.apply { - chapter_number = (idx + 1).toFloat() - } - }.sortedByDescending { it.chapter_number } - } + return when { + // Case 1: Only chapters + chapters.isNotEmpty() && volumeItems.isEmpty() -> + chapters.sortedByDescending { it.chapter_number } - return allChapters.sortedByDescending { it.chapter_number } + // Case 2: Only volumes - treat as chapters with positive numbers + volumeItems.isNotEmpty() && chapters.isEmpty() -> + volumeItems.sortedByDescending { it.chapter_number } + + // Case 3: Mixed content - chapters first, then volumes + else -> { + volumeItems.forEachIndexed { index, it -> + if (it.chapter_number <= 0f) { + it.chapter_number = chapters.size + index + 1f + } + } + ( + chapters.sortedByDescending { it.chapter_number } + + volumeItems.sortedByDescending { it.chapter_number } + ) + } + } } catch (e: Exception) { - Log.e(LOG_TAG, "Unhandled exception parsing chapters. Send logs to kavita devs", e) + Log.e(LOG_TAG, "Unhandled exception parsing chapters", e) throw IOException(helper.intl["version_exceptions_chapters_parse"]) } } @@ -1309,16 +1517,21 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou return GET("$apiUrl/${chapterItem.chapterId}", headersBuilder().build()) } + // Handle volume URLs + if (chapter.url.startsWith("Volume/")) { + val volumeId = chapter.url.substringAfter("Volume/").substringBefore("_") + return GET("$apiUrl/Volume/$volumeId", headersBuilder().build()) + } + // Original handling for regular chapters val chapterId = chapter.url.substringBefore("_") return GET("$apiUrl/$chapterId", headersBuilder().build()) } - @Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getPageList(chapter)")) - override fun fetchPageList(chapter: SChapter): Observable> { + override suspend fun getPageList(chapter: SChapter): List = withContext(Dispatchers.IO) { // Check if this is a reading list item (has readingListId in URL) if (chapter.url.contains("readingListId=")) { - return Observable.fromCallable { + try { // Extract seriesId from the chapter URL val seriesId = chapter.url.substringAfter("seriesId=").substringBefore("&").toIntOrNull() val readingListId = chapter.url.substringAfter("readingListId=").substringBefore("&").toIntOrNull() @@ -1343,81 +1556,113 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou } ?: throw IOException("Item not found in reading list") // Get item details based on type - val chapterDetails = if (listItem.chapterId != null) { - val request = GET("$apiUrl/Chapter?chapterId=${listItem.chapterId}", headersBuilder().build()) - client.newCall(request).execute().parseAs() - } else if (listItem.volumeId != null) { - // For volumes, get all chapters and let user choose - val volumeRequest = GET("$apiUrl/Volume/${listItem.volumeId}", headersBuilder().build()) - val volume = client.newCall(volumeRequest).execute().parseAs() - volume.chapters.firstOrNull() ?: throw IOException(helper.intl["error_no_chapters_found"]) - } else { - throw IOException(helper.intl["error_invalid_reading_list_item"]) + val chapterDetails = when { + listItem.chapterId != null -> { + // Standard chapter + val request = GET("$apiUrl/Chapter?chapterId=${listItem.chapterId}", headersBuilder().build()) + client.newCall(request).execute().parseAs() + } + listItem.volumeId != null -> { + // Volume - get first chapter + val volumeRequest = GET("$apiUrl/Volume/${listItem.volumeId}", headersBuilder().build()) + val volume = client.newCall(volumeRequest).execute().parseAs() + volume.chapters.firstOrNull()?.let { + val chapRequest = GET("$apiUrl/Chapter?chapterId=${it.id}", headersBuilder().build()) + client.newCall(chapRequest).execute().parseAs() + } ?: throw IOException(helper.intl["error_no_chapters_found"]) + } + else -> throw IOException(helper.intl["error_invalid_reading_list_item"]) } - val pages = mutableListOf() - for (i in 0 until chapterDetails.pages) { - pages.add( - Page( - index = i, - imageUrl = "$apiUrl/Reader/image?chapterId=${chapterDetails.id}&page=$i&extractPdf=true&apiKey=$apiKey", - ), + // Generate pages with consistent URL format + return@withContext (0 until chapterDetails.pages).map { i -> + Page( + index = i, + imageUrl = "$apiUrl/Reader/image?chapterId=${chapterDetails.id}&page=$i&extractPdf=true&apiKey=$apiKey", ) } - pages.toList() - }.onErrorResumeNext { error -> - Log.e(LOG_TAG, "Error fetching reading list chapter pages", error) - Observable.error(error) + } catch (e: Exception) { + Log.e(LOG_TAG, "Error processing reading list item", e) + throw e } } - // Original handling for regular chapters - val chapterId = chapter.url.substringBefore("_") - return Observable.fromCallable { - val chapterRequest = GET("$apiUrl/Chapter?chapterId=$chapterId", headersBuilder().build()) - val response = client.newCall(chapterRequest).execute() + // For normal titles + try { + // Check if this is a volume URL + if (chapter.url.contains("/Volume/") || chapter.url.startsWith("volume_")) { + val volumeId = when { + chapter.url.startsWith("volume_") -> chapter.url.substringAfter("volume_").substringBefore("_") + chapter.url.contains("/Volume/") -> chapter.url.substringAfter("/Volume/").substringBefore("_") + else -> throw IOException("Invalid volume URL format") + } - if (!response.isSuccessful) { - throw IOException("Failed to fetch chapter details: HTTP ${response.code}") - } + // Get all pages in this volume + val volumeRequest = GET("$apiUrl/Volume/$volumeId", headersBuilder().build()) + val volume = client.newCall(volumeRequest).execute().parseAs() + val matchingChapter = volume.chapters.firstOrNull() // or match a chapterId if available + ?: throw IOException(helper.intl["error_no_chapters_found"]) - // Check content type to ensure we got JSON - val contentType = response.header("Content-Type") ?: "" - if (!contentType.contains("application/json")) { - val bodyPreview = response.peekBody(128).string() - throw IOException("${helper.intl["error_unexpected_response"]}: $contentType - $bodyPreview") - } + val initialPages = (0 until matchingChapter.pages).map { i -> + Page( + index = i, + imageUrl = "$apiUrl/Reader/image?chapterId=${matchingChapter.id}&page=$i&extractPdf=true&apiKey=$apiKey", + ) + } - val chapterDetails = try { - response.parseAs() - } catch (e: Exception) { - throw IOException("${helper.intl["error_failed_parse_chapter"]}: ${e.message}") - } + val remainingPages = volume.chapters + .sortedBy { it.number.toFloatOrNull() ?: 0f } + .runningFold(initialPages.size) { acc, chapter -> acc + chapter.pages } + .drop(1) + .zip(volume.chapters.sortedBy { it.number.toFloatOrNull() ?: 0f }) + .flatMap { (offset, chapter) -> + (0 until chapter.pages).map { i -> + Page( + index = offset - chapter.pages + i, + imageUrl = "$apiUrl/Reader/image?chapterId=${chapter.id}&page=$i&extractPdf=true&apiKey=$apiKey", + ) + } + } - val pages = mutableListOf() - for (i in 0 until chapterDetails.pages) { - pages.add( + return@withContext (initialPages + remainingPages).toList() + } else { + // Original chapter handling + val chapterId = when { + chapter.url.startsWith("chapter_") -> chapter.url.substringAfter("chapter_").substringBefore("_") + chapter.url.contains("/Chapter/") -> chapter.url.substringAfter("/Chapter/").substringBefore("_") + else -> throw IOException("Invalid chapter URL format") + } + + val chapterRequest = GET("$apiUrl/Chapter?chapterId=$chapterId", headersBuilder().build()) + val response = client.newCall(chapterRequest).execute() + + if (!response.isSuccessful) { + throw IOException("Failed to fetch chapter details: HTTP ${response.code}") + } + + val chapterDetails = try { + response.parseAs() + } catch (e: Exception) { + throw IOException("${helper.intl["error_failed_parse_chapter"]}: ${e.message}") + } + + return@withContext (0 until chapterDetails.pages).map { i -> Page( index = i, imageUrl = "$apiUrl/Reader/image?chapterId=$chapterId&page=$i&extractPdf=true&apiKey=$apiKey", - ), - ) + ) + } } - pages.toList() - }.onErrorResumeNext { error -> + } catch (e: Exception) { // Fallback to using the scanlator field if we can't get chapter details - Log.e(LOG_TAG, "Error fetching chapter details, using fallback", error) + Log.e(LOG_TAG, "Error fetching chapter details, using fallback", e) val fallbackPageCount = chapter.scanlator?.replace(" pages", "")?.toIntOrNull() ?: 1 - val pages = mutableListOf() - for (i in 0 until fallbackPageCount) { - pages.add( - Page( - index = i, - imageUrl = "$apiUrl/Reader/image?chapterId=$chapterId&page=$i&extractPdf=true&apiKey=$apiKey", - ), + return@withContext (0 until fallbackPageCount).map { i -> + Page( + index = i, + imageUrl = "$apiUrl/Reader/image?chapterId=${chapter.url.substringBefore("_")}&page=$i&extractPdf=true&apiKey=$apiKey", ) } - Observable.just(pages.toList()) } } @@ -1430,7 +1675,7 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou override fun searchMangaParse(response: Response): MangasPage { return try { val result = response.parseAs>() - val mangaList = result.map { helper.createSeriesDto(it, apiUrl, baseUrl, apiKey) } + val mangaList = result.map { helper.createSeriesDto(it, apiUrl, apiKey) } MangasPage(mangaList, false) } catch (e: Exception) { Log.e(LOG_TAG, "Error parsing search results", e) @@ -1451,119 +1696,85 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou private var ageRatingsListMeta = emptyList() private var peopleListMeta = emptyList() private var pubStatusListMeta = emptyList() + private var userRatingsListMeta = emptyList() private var languagesListMeta = emptyList() private var libraryListMeta = emptyList() private var collectionsListMeta = emptyList() private var smartFilters = emptyList() private var cachedReadingLists: List = emptyList() - private val personRoles = listOf( - "Writer", - "Penciller", - "Inker", - "Colorist", - "Letterer", - "CoverArtist", - "Editor", - "Publisher", - "Character", - "Translator", - ) /** - * Loads the enabled filters if they are not empty so tachiyomi can show them to the user + * Loads the filters if they are not empty so Mihon can show them to the user */ + private fun getFilterList( + genres: List, + tags: List, + ageRatings: List, + collections: List, + languages: List, + libraries: List, + people: List, + pubStatus: List, + smartFilters: Array, + ): FilterList { + return FilterList( + SortFilter(sortableList.map { it.first }.toTypedArray()), + + StatusSeparator(), + StatusFilterGroup(), + + // Release year filter + ReleaseYearRangeGroup( + listOf( + ReleaseYearRange("Min"), + ReleaseYearRange("Max"), + ), + ), + + GenreFilterGroup(genres.map { GenreFilter(it) }), + TagFilterGroup(tags.map { TagFilter(it) }), + CollectionFilterGroup(collections.map { CollectionFilter(it) }), + LibrariesFilterGroup(libraries.map { LibraryFilter(it) }), + LanguageFilterGroup(languages.map { LanguageFilter(it) }), + PubStatusFilterGroup(pubStatus.map { PubStatusFilter(it) }), + AgeRatingFilterGroup(ageRatings.map { AgeRatingFilter(it) }), + + UserRating(), + UserRatingSeparator(), + + // Special filters section + Filter.Header(helper.intl["filters_special"]), + SpecialListFilter(helper.intl["filters_special_list"]), + SmartFiltersFilter(smartFilters), + + // @todo People filters (but not as filters, as a text search) +// PeopleHeaderFilter(helper.intl["filters_people"]), +// PeopleSeparatorFilter(), +// WriterPeopleFilterGroup(people.map { WriterPeopleFilter(it) }), +// PencillerPeopleFilterGroup(people.map { PencillerPeopleFilter(it) }), +// InkerPeopleFilterGroup(people.map { InkerPeopleFilter(it) }), +// ColoristPeopleFilterGroup(people.map { ColoristPeopleFilter(it) }), +// LettererPeopleFilterGroup(people.map { LettererPeopleFilter(it) }), +// CoverArtistPeopleFilterGroup(people.map { CoverArtistPeopleFilter(it) }), +// EditorPeopleFilterGroup(people.map { EditorPeopleFilter(it) }), +// PublisherPeopleFilterGroup(people.map { PublisherPeopleFilter(it) }), +// CharacterPeopleFilterGroup(people.map { CharacterPeopleFilter(it) }), +// TranslatorPeopleFilterGroup(people.map { TranslatorPeopleFilter(it) }), + ) + } + override fun getFilterList(): FilterList { - val toggledFilters = getToggledFilters() - - val filters = try { - val filtersLoaded = mutableListOf>() - - filtersLoaded.add(SortFilter(sortableList.map { it.first }.toTypedArray())) - - if (smartFilters.isNotEmpty()) { - filtersLoaded.add(SmartFiltersFilter(smartFilters.map { it.name }.toTypedArray())) - } - - filtersLoaded.add(SpecialListFilter()) - - if (toggledFilters.contains("Read Status")) { - filtersLoaded.add( - StatusFilterGroup( - listOf( - "notRead", - "inProgress", - "read", - ).map { StatusFilter(it) }, - ), - ) - } - if (toggledFilters.contains("ReleaseYearRange")) { - filtersLoaded.add( - ReleaseYearRangeGroup( - listOf("Min", "Max").map { ReleaseYearRange(it) }, - ), - ) - } - - if (genresListMeta.isNotEmpty() and toggledFilters.contains("Genres")) { - filtersLoaded.add( - GenreFilterGroup(genresListMeta.map { GenreFilter(it.title) }), - ) - } - if (tagsListMeta.isNotEmpty() and toggledFilters.contains("Tags")) { - filtersLoaded.add( - TagFilterGroup(tagsListMeta.map { TagFilter(it.title) }), - ) - } - if (ageRatingsListMeta.isNotEmpty() and toggledFilters.contains("Age Rating")) { - filtersLoaded.add( - AgeRatingFilterGroup(ageRatingsListMeta.map { AgeRatingFilter(it.title) }), - ) - } - if (toggledFilters.contains("Format")) { - filtersLoaded.add( - FormatsFilterGroup( - listOf( - "Image", - "Archive", - "Pdf", - "Unknown", - ).map { FormatFilter(it) }, - ), - ) - } - if (collectionsListMeta.isNotEmpty() and toggledFilters.contains("Collections")) { - filtersLoaded.add( - CollectionFilterGroup(collectionsListMeta.map { CollectionFilter(it.title) }), - ) - } - if (languagesListMeta.isNotEmpty() and toggledFilters.contains("Languages")) { - filtersLoaded.add( - LanguageFilterGroup(languagesListMeta.map { LanguageFilter(it.title) }), - ) - } - if (libraryListMeta.isNotEmpty() and toggledFilters.contains("Libraries")) { - filtersLoaded.add( - LibrariesFilterGroup(libraryListMeta.map { LibraryFilter(it.name) }), - ) - } - if (pubStatusListMeta.isNotEmpty() and toggledFilters.contains("Publication Status")) { - filtersLoaded.add( - PubStatusFilterGroup(pubStatusListMeta.map { PubStatusFilter(it.title) }), - ) - } - if (pubStatusListMeta.isNotEmpty() and toggledFilters.contains("Rating")) { - filtersLoaded.add( - UserRating(), - ) - } - - filtersLoaded - } catch (e: Exception) { - Log.e(LOG_TAG, "[FILTERS] Error while creating filter list", e) - FilterList(emptyList()) - } - return FilterList(filters) + return getFilterList( + genres = genresListMeta.map { it.title }, + tags = tagsListMeta.map { it.title }, + ageRatings = ageRatingsListMeta.map { it.title }, + collections = collectionsListMeta.map { it.title }, + languages = languagesListMeta.map { it.title }, + libraries = libraryListMeta.map { it.name }, + people = peopleListMeta.map { it.name }, + pubStatus = pubStatusListMeta.map { it.title }, + smartFilters = smartFilters.map { it.name }.toTypedArray(), + ) } class LoginErrorException(message: String? = null, cause: Throwable? = null) : Exception(message, cause) { @@ -1600,7 +1811,7 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou .add("Authorization", "Bearer $jwtToken") } - override fun setupPreferenceScreen(screen: androidx.preference.PreferenceScreen) { + override fun setupPreferenceScreen(screen: PreferenceScreen) { val opdsAddressPref = screen.editTextPreference( ADDRESS_TITLE, "OPDS url", @@ -1724,7 +1935,7 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou screen.addPreference(enabledFiltersPref) } - private fun androidx.preference.PreferenceScreen.editTextPreference( + private fun PreferenceScreen.editTextPreference( preKey: String, title: String, default: String, @@ -1780,23 +1991,17 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou } } - private fun getPrefBaseUrl(): String = preferences.getString("BASEURL", "")!! - private fun getPrefApiUrl(): String = preferences.getString("APIURL", "")!! - private fun getPrefKey(): String = preferences.getString("APIKEY", "")!! + private fun getPrefBaseUrl(): String = preferences.getString("BASEURL", "") ?: "" + private fun getPrefApiUrl(): String = preferences.getString("APIURL", "") ?: "" + private fun getPrefKey(): String = preferences.getString("APIKEY", "") ?: "" private fun getToggledFilters() = preferences.getStringSet(KavitaConstants.toggledFiltersPref, KavitaConstants.defaultFilterPrefEntries)!! - private val SharedPreferences.score: Boolean - get() = getBoolean(SCORE_PREF, SCORE_DEFAULT) - private val SharedPreferences.groupTags: Boolean get() = getBoolean(GROUP_TAGS_PREF, GROUP_TAGS_DEFAULT) private val SharedPreferences.LastVolumeCover: Boolean get() = getBoolean(LAST_VOLUME_COVER_PREF, LAST_VOLUME_COVER_DEFAULT) - private val SharedPreferences.showEpub: Boolean - get() = getBoolean(SHOW_EPUB_PREF, SHOW_EPUB_DEFAULT) - private val SharedPreferences.RdDate: Boolean get() = getBoolean(RD_DATE_PREF, RD_DATE_DEFAULT) @@ -1814,7 +2019,7 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou // Clear all preferences preferences.edit().clear().commit() - // Reset all in-memory state + // Reset all in-memory state (for debug) // jwtToken = "" // isLogged = false // genresListMeta = emptyList() @@ -1837,16 +2042,18 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou private fun getPrefApiKey(): String { // http(s)://host:(port)/api/opds/api-key - val existingKey = preferences.getString("APIKEY", "") - return existingKey!!.ifEmpty { preferences.getString(ADDRESS_TITLE, "")!!.split("/opds/")[1] } + val existingKey = preferences.getString(APIKEY, "") + if (!existingKey.isNullOrEmpty()) return existingKey + val address = preferences.getString(ADDRESS_TITLE, "") ?: "" + val parts = address.split("/opds/") + return if (parts.size > 1) parts[1] else "" } companion object { private const val ADDRESS_TITLE = "Address" private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaTypeOrNull() - private const val SCORE_PREF = "Score" - const val SCORE_DEFAULT = true + private const val APIKEY = "APIKEY" private const val LAST_VOLUME_COVER_PREF = "LastVolumeCover" const val LAST_VOLUME_COVER_DEFAULT = false @@ -1861,9 +2068,6 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou const val RD_DATE_DEFAULT = false } - /* - * LOGIN - **/ /** * Used to check if a url is configured already in any of the sources * This is a limitation needed for tracking. @@ -1894,6 +2098,9 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou return "" } + /** + * LOGIN + * **/ private fun setupLogin(addressFromPreference: String = "") { Log.v(LOG_TAG, "[Setup Login] Starting setup") val validAddress = address.ifEmpty { addressFromPreference } @@ -1951,165 +2158,88 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou Log.v(LOG_TAG, "[Login] Login successful") } + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + init { if (apiUrl.isNotBlank()) { - Single.fromCallable { - // Login - doLogin() - try { // Get current version - val requestUrl = "$apiUrl/Server/server-info-slim" - val serverInfoDto = client.newCall(GET(requestUrl, headersBuilder().build())) + scope.launch { + try { + doLogin() + + // Load all filters in parallel + val deferredFilters = listOf( + async { + genresListMeta = loadMetadata("$apiUrl/Metadata/genres") + Log.d(LOG_TAG, "Loaded ${genresListMeta.size} genres") + }, + async { + tagsListMeta = loadMetadata("$apiUrl/Metadata/tags") + Log.d(LOG_TAG, "Loaded ${tagsListMeta.size} tags") + }, + async { + ageRatingsListMeta = loadMetadata("$apiUrl/Metadata/age-ratings") + Log.d(LOG_TAG, "Loaded ${ageRatingsListMeta.size} age ratings") + }, + async { + collectionsListMeta = loadMetadata("$apiUrl/Collection") + Log.d(LOG_TAG, "Loaded ${collectionsListMeta.size} collections") + }, + async { + languagesListMeta = loadMetadata("$apiUrl/Metadata/languages") + Log.d(LOG_TAG, "Loaded ${languagesListMeta.size} languages") + }, + async { + libraryListMeta = loadMetadata("$apiUrl/Library/libraries") + Log.d(LOG_TAG, "Loaded ${libraryListMeta.size} libraries") + }, + async { + peopleListMeta = loadMetadata("$apiUrl/Metadata/people") + Log.d(LOG_TAG, "Loaded ${peopleListMeta.size} people") + }, + async { + pubStatusListMeta = loadMetadata("$apiUrl/Metadata/publication-status") + Log.d(LOG_TAG, "Loaded ${pubStatusListMeta.size} publication statuses") + }, + async { + smartFilters = loadMetadata("$apiUrl/filter") + Log.d(LOG_TAG, "Loaded ${smartFilters.size} smart filters") + }, + ) + + deferredFilters.awaitAll() + + // Get server info + val serverInfoDto = client.newCall(GET("$apiUrl/Server/server-info-slim", headersBuilder().build())) .execute() .parseAs() - Log.e( - LOG_TAG, - "Extension version: code=${AppInfo.getVersionCode()} name=${AppInfo.getVersionName()}" + - " - - Kavita version: ${serverInfoDto.kavitaVersion} - - Lang:${Locale.getDefault()}", - ) // this is not a real error. Using this so it gets printed in dump logs if there's any error - } catch (e: EmptyRequestBody) { - Log.e(LOG_TAG, "Extension version: code=${AppInfo.getVersionCode()} - name=${AppInfo.getVersionName()}") + Log.d(LOG_TAG, "Kavita version: ${serverInfoDto.kavitaVersion}") } catch (e: Exception) { - Log.e(LOG_TAG, "Tachiyomi version: code=${AppInfo.getVersionCode()} - name=${AppInfo.getVersionName()}", e) - } - try { // Load Filters - // Genres - Log.v(LOG_TAG, "[Filter] Fetching filters ") - client.newCall(GET("$apiUrl/Metadata/genres", headersBuilder().build())) - .execute().use { response -> - - genresListMeta = try { - response.body.use { json.decodeFromString(it.string()) } - } catch (e: Exception) { - Log.e(LOG_TAG, "[Filter] Error decoding JSON for genres filter -> ${response.body}", e) - emptyList() - } - } - // tagsListMeta - client.newCall(GET("$apiUrl/Metadata/tags", headersBuilder().build())) - .execute().use { response -> - tagsListMeta = try { - response.body.use { json.decodeFromString(it.string()) } - } catch (e: Exception) { - Log.e(LOG_TAG, "[Filter] Error decoding JSON for tagsList filter", e) - emptyList() - } - } - // age-ratings - client.newCall(GET("$apiUrl/Metadata/age-ratings", headersBuilder().build())) - .execute().use { response -> - ageRatingsListMeta = try { - response.body.use { json.decodeFromString(it.string()) } - } catch (e: Exception) { - Log.e( - LOG_TAG, - "[Filter] Error decoding JSON for age-ratings filter", - e, - ) - emptyList() - } - } - // collectionsListMeta - client.newCall(GET("$apiUrl/Collection", headersBuilder().build())) - .execute().use { response -> - collectionsListMeta = try { - response.body.use { json.decodeFromString(it.string()) } - } catch (e: Exception) { - Log.e( - LOG_TAG, - "[Filter] Error decoding JSON for collectionsListMeta filter", - e, - ) - emptyList() - } - } - // languagesListMeta - client.newCall(GET("$apiUrl/Metadata/languages", headersBuilder().build())) - .execute().use { response -> - languagesListMeta = try { - response.body.use { json.decodeFromString(it.string()) } - } catch (e: Exception) { - Log.e( - LOG_TAG, - "[Filter] Error decoding JSON for languagesListMeta filter", - e, - ) - emptyList() - } - } - // libraries - client.newCall(GET("$apiUrl/Library/libraries", headersBuilder().build())) - .execute().use { response -> - libraryListMeta = try { - response.body.use { json.decodeFromString(it.string()) } - } catch (e: Exception) { - Log.e( - LOG_TAG, - "[Filter] Error decoding JSON for libraries filter", - e, - ) - emptyList() - } - } - // peopleListMeta - client.newCall(GET("$apiUrl/Metadata/people", headersBuilder().build())) - .execute().use { response -> - peopleListMeta = try { - val jsonString = response.body.string() -// Log.d(LOG_TAG, "Raw people metadata: $jsonString") // Debug log - json.decodeFromString>(jsonString) - .filter { it.role != null } // Filter out entries without role - } catch (e: Exception) { - Log.e(LOG_TAG, "Error decoding people metadata", e) - emptyList() - } - } - client.newCall(GET("$apiUrl/Metadata/publication-status", headersBuilder().build())) - .execute().use { response -> - pubStatusListMeta = try { - response.body.use { json.decodeFromString(it.string()) } - } catch (e: Exception) { - Log.e( - LOG_TAG, - "error while decoding JSON for publicationStatusListMeta filter", - e, - ) - emptyList() - } - } - client.newCall(GET("$apiUrl/filter", headersBuilder().build())) - .execute().use { response -> - smartFilters = try { - response.body.use { json.decodeFromString(it.string()) } - } catch (e: Exception) { - Log.e( - LOG_TAG, - "error while decoding JSON for smartfilters", - e, - ) - emptyList() - } - } - } catch (e: Exception) { - throw LoadingFilterFailed("Failed Loading Filters", e.cause) + Log.e(LOG_TAG, "Initialization error", e) } } - .subscribeOn(Schedulers.io()) - .observeOn(Schedulers.io()) - .subscribe( - {}, - { tr -> - // Avoid polluting logs with traces of exception - if (tr is EmptyRequestBody || tr is LoginErrorException) { - Log.e(LOG_TAG, "error while doing initial calls\n${tr.cause}") - return@subscribe - } - if (tr is ConnectException) { // avoid polluting logs with traces of exception - Log.e(LOG_TAG, "Error while doing initial calls\n${tr.cause}") - return@subscribe - } - Log.e(LOG_TAG, "error while doing initial calls", tr) - }, - ) } } + + // Add cleanup for the coroutine scope + fun cleanup() { + scope.cancel() + // Clear any cached data if needed + genresListMeta = emptyList() + tagsListMeta = emptyList() + ageRatingsListMeta = emptyList() + peopleListMeta = emptyList() + pubStatusListMeta = emptyList() + languagesListMeta = emptyList() + libraryListMeta = emptyList() + collectionsListMeta = emptyList() + smartFilters = emptyList() + cachedReadingLists = emptyList() + Log.d(LOG_TAG, "Cleanup completed") + } + + private inline fun loadMetadata(url: String): T { + return client.newCall(GET(url, headersBuilder().build())) + .execute() + .parseAs() + } } diff --git a/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/KavitaConstants.kt b/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/KavitaConstants.kt index 5c41e1d4..8b2fdabf 100644 --- a/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/KavitaConstants.kt +++ b/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/KavitaConstants.kt @@ -2,6 +2,11 @@ package eu.kanade.tachiyomi.extension.all.kavita object KavitaConstants { + const val UNNUMBERED_VOLUME = -100000 + const val UNNUMBERED_VOLUME_STR = "-100000" + const val SPECIAL_NUMBER = 100_000 + const val VOLUME_NUMBER_OFFSET = 10000f + val PERSON_ROLES = listOf( "Writer", "Penciller", "Inker", "Colorist", "Letterer", "CoverArtist", "Editor", @@ -23,18 +28,7 @@ object KavitaConstants { "Publication Status", "Rating", "Age Rating", - "Writers", - "Penciller", - "Inker", - "Colorist", - "Letterer", - "Cover Artist", - "Editor", - "Publisher", - "Character", - "Translators", "ReleaseYearRange", - "Average Rating", "Random", "Read Progress", ) @@ -51,18 +45,7 @@ object KavitaConstants { "Publication Status", "Rating", "Age Rating", - "Writers", - "Penciller", - "Inker", - "Colorist", - "Letterer", - "CoverArtist", - "Editor", - "Publisher", - "Character", - "Translators", "ReleaseYearRange", - "Average Rating", "Random", "Read Progress", ) @@ -79,18 +62,7 @@ object KavitaConstants { "Publication Status", "Rating", "Age Rating", - "Writers", - "Penciller", - "Inker", - "Colorist", - "Letterer", - "CoverArtist", - "Editor", - "Publisher", - "Character", - "Translators", "ReleaseYearRange", - "Average Rating", "Random", "Read Progress", ) diff --git a/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/KavitaHelper.kt b/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/KavitaHelper.kt index 39154752..b10f7981 100644 --- a/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/KavitaHelper.kt +++ b/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/KavitaHelper.kt @@ -1,6 +1,6 @@ package eu.kanade.tachiyomi.extension.all.kavita -import android.util.Log +import android.annotation.SuppressLint import eu.kanade.tachiyomi.extension.all.kavita.dto.ChapterDto import eu.kanade.tachiyomi.extension.all.kavita.dto.ChapterType import eu.kanade.tachiyomi.extension.all.kavita.dto.LibraryTypeEnum @@ -13,170 +13,204 @@ import eu.kanade.tachiyomi.source.model.SManga import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json import okhttp3.Response -import java.text.SimpleDateFormat import java.util.Locale -import java.util.TimeZone class KavitaHelper { val json = Json { isLenient = true ignoreUnknownKeys = true + coerceInputValues = true + explicitNulls = false allowSpecialFloatingPointValues = true useArrayPolymorphism = true prettyPrint = true } - inline fun > safeValueOf(type: String): T { - return java.lang.Enum.valueOf(T::class.java, type) - } - val dateFormatter = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSSSSSS", Locale.US) - .apply { timeZone = TimeZone.getTimeZone("UTC") } - fun parseDate(dateAsString: String): Long = - dateFormatter.parse(dateAsString)?.time ?: 0 fun hasNextPage(response: Response): Boolean { val paginationHeader = response.header("Pagination") var hasNextPage = false if (!paginationHeader.isNullOrEmpty()) { val paginationInfo = json.decodeFromString(paginationHeader) - hasNextPage = paginationInfo.currentPage + 1 > paginationInfo.totalPages + hasNextPage = paginationInfo.currentPage + 1 < paginationInfo.totalPages } - return !hasNextPage + return hasNextPage } fun getIdFromUrl(url: String): Int { return try { val id = url.substringAfterLast("/").toInt() - Log.d("KavitaHelper", "Extracted ID $id from URL: $url") +// Log.d("KavitaHelper", "Extracted ID $id from URL: $url") id } catch (e: Exception) { - Log.e("KavitaHelper", "Failed to extract ID from URL: $url", e) +// Log.e("KavitaHelper", "Failed to extract ID from URL: $url", e) -1 } } - // Rating Providers from Series-Details-Plus - private fun getProviderName(provider: Int): String = when (provider) { - 0 -> "User" - 1 -> "AniList" - 2 -> "MyAnimeList" - 3 -> "MangaUpdates" - else -> "Rating" - } + // @todo Rating Providers from Series-Details-Plus +// private fun getProviderName(provider: Int): String = when (provider) { +// 0 -> "User" +// 1 -> "AniList" +// 2 -> "MyAnimeList" +// 3 -> "MangaUpdates" +// else -> "Rating" +// } - fun createSeriesDto(obj: SeriesDto, baseUrl: String, apiUrl: String, apiKey: String): SManga = + fun createSeriesDto(obj: SeriesDto, baseUrl: String, apiKey: String): SManga = SManga.create().apply { - // url = "$baseUrl/library/${obj.libraryId}/series/${obj.id}" url = "$baseUrl/Series/${obj.id}" title = obj.name thumbnail_url = "$baseUrl/image/series-cover?seriesId=${obj.id}&apiKey=$apiKey" - - // Set status based on read progress - status = when { - obj.pagesRead >= obj.pages -> SManga.COMPLETED - obj.pagesRead > 0 -> SManga.ONGOING - else -> SManga.UNKNOWN - } } -// class CompareChapters { -// companion object : Comparator { -// override fun compare(a: SChapter, b: SChapter): Int { -// if (a.chapter_number < 1.0 && b.chapter_number < 1.0) { -// // Both are volumes, multiply by 100 and do normal sort -// return if ((a.chapter_number * 100) < (b.chapter_number * 100)) { -// 1 -// } else { -// -1 -// } -// } else { -// if (a.chapter_number < 1.0 && b.chapter_number >= 1.0) { -// // A is volume, b is not. A should sort first -// return 1 -// } else if (a.chapter_number >= 1.0 && b.chapter_number < 1.0) { -// return -1 -// } -// } -// if (a.chapter_number < b.chapter_number) return 1 -// if (a.chapter_number > b.chapter_number) return -1 -// return 0 -// } -// } -// } - - fun chapterFromVolume(chapter: ChapterDto, volume: VolumeDto, singleFileVolumeNumber: Int? = null, libraryType: LibraryTypeEnum? = null): SChapter = + fun chapterFromVolume( + chapter: ChapterDto, + volume: VolumeDto, + singleFileVolumeNumber: Double? = null, + libraryType: LibraryTypeEnum? = null, + isWebtoon: Boolean = false, + mangaTitle: String = "", + ): SChapter = SChapter.create().apply { val type = ChapterType.of(chapter, volume, libraryType) + val titleName = chapter.titleName + val title = chapter.title + val range = chapter.range + // For webtoon + val chapterLabel = if (isWebtoon) "Episode" else "Chapter" + val volumeLabel = if (isWebtoon) "Season" else "Volume" - name = when (type) { - ChapterType.Regular -> { - val volumeNum = volume.number.toString().toIntOrNull()?.let { - it.toString().padStart(2, '0') - } ?: volume.number.toString() - val chapterNum = chapter.number.toIntOrNull()?.let { - it.toString().padStart(2, '0') - } ?: chapter.number - when { - chapter.titleName.isBlank() -> "Volume $volumeNum Chapter $chapterNum" - chapter.titleName.trim().matches(Regex("^\\d+$")) -> "Volume $volumeNum Chapter ${chapter.titleName.trim().padStart(2, '0')}" - else -> "Volume $volumeNum $chapterNum - ${chapter.titleName}" + if (titleName != null) { + name = when (type) { + ChapterType.Regular -> { + val chapterNum = formatChapterNumber(chapter) + when { + titleName.isNotBlank() && titleName.any { it.isLetter() } -> + "$chapterNum - ${cleanChapterTitle( + titleName, + ChapterTitleContext( + mangaTitle = mangaTitle, + chapterNumber = chapterNum, + volumeName = volume.name.ifBlank { title }, + ), + )}" + + else -> + "$volumeLabel ${formatVolumeNumber(volume)} $chapterLabel $chapterNum" + } } - } - ChapterType.SingleFileVolume -> { - when { - volume.name.isBlank() -> "Volume ${volume.number}" - volume.name.trim().matches(Regex("^\\d+$")) -> "Volume ${volume.name.trim().toIntOrNull()?.toString() ?: volume.name.trim()}" - else -> "${volume.number} - ${volume.name}" + + ChapterType.SingleFileVolume -> { + val volumeNumber = formatVolumeNumber(volume) + val cleanVolumeName = volume.name.trim() + + // Always use the API's volume number, never parse from title + when { + // If name is empty or just numbers (Kavita default) + cleanVolumeName.isEmpty() || cleanVolumeName.none { it.isLetter() } -> { + cleanChapterTitle("Volume $volumeNumber") + } + // If name already contains volume info (Vol. 3, V3, etc.) + cleanVolumeName.contains(Regex("(?i)(vol|volume|v)[.\\s]*\\d+")) -> { + cleanChapterTitle(cleanVolumeName) + } + // Normal case - prefix with volume number + else -> { + cleanChapterTitle("$volumeNumber - $cleanVolumeName") + } + } } - } - ChapterType.Special -> chapter.titleName.takeIf { it.isNotBlank() } ?: chapter.range - ChapterType.Chapter -> { - val chapterNum = chapter.number.toIntOrNull()?.let { - it.toString().padStart(2, '0') - } ?: chapter.number - when { - chapter.titleName.isBlank() -> "Chapter $chapterNum" - chapter.titleName.trim().matches(Regex("^\\d+$")) -> "Chapter ${chapter.titleName.trim().padStart(2, '0')}" - else -> "$chapterNum - ${chapter.titleName}" + + ChapterType.Special -> { + cleanChapterTitle( + when { + title.isNotBlank() -> title + range.isNotBlank() -> range + else -> "Special" + }, + ChapterTitleContext( + mangaTitle = mangaTitle, + volumeName = volume.name, + ), + ) } - } - ChapterType.Issue -> { - val issueNum = chapter.number.toIntOrNull()?.let { - it.toString().padStart(3, '0') - } ?: chapter.number - when { - chapter.titleName.isNotBlank() && !chapter.titleName.trim().matches(Regex("^\\d+$")) -> - "#$issueNum ${chapter.titleName}" - chapter.title.isNotBlank() && !chapter.title.trim().matches(Regex("^\\d+$")) -> - "#$issueNum ${chapter.title}" - else -> "Issue #$issueNum" + + ChapterType.Chapter -> { + val chapterNum = formatChapterNumber(chapter) + when { + titleName.isNotBlank() && titleName.any { it.isLetter() } -> + "$chapterNum - ${cleanChapterTitle( + titleName, + ChapterTitleContext( + mangaTitle = mangaTitle, + chapterNumber = chapterNum, + volumeName = volume.name, + ), + )}" + + else -> + "$chapterLabel $chapterNum" + } + } + + ChapterType.Issue -> { + val issueNum = chapter.number.toIntOrNull()?.toString()?.padStart(3, '0') ?: chapter.number + cleanChapterTitle( + when { + titleName.any { it.isLetter() } -> "$titleName (#$issueNum)" + else -> "Issue #$issueNum" + }, + ChapterTitleContext( + mangaTitle = mangaTitle, + chapterNumber = issueNum, + volumeName = volume.name, + ), + ) } } } -// chapter_number = try { -// // if (type == ChapterType.SingleFileVolume) { -// // volume.number.toFloat() / 10000 -// // } else { -// // chapter.number.toFloatOrNull() ?: 0f -// // } -// // } catch (e: NumberFormatException) { -// // 0f -// // } - + // Handle decimal chapter numbers properly chapter_number = when { - type == ChapterType.SingleFileVolume && singleFileVolumeNumber != null -> singleFileVolumeNumber.toFloat() - type != ChapterType.SingleFileVolume -> chapter.number.toFloatOrNull() ?: 0f - else -> 0f + type == ChapterType.SingleFileVolume && singleFileVolumeNumber != null -> + volume.minNumber.toFloat() + + type == ChapterType.SingleFileVolume -> { + // For standalone volumes, use positive numbers + volume.minNumber.toFloat() + } + + // For regular chapters + type != ChapterType.SingleFileVolume && type != ChapterType.Special -> { + // Handle decimal chapter numbers + if (chapter.minNumber % 1 != 0.0) { + chapter.minNumber.toFloat() + } else { + chapter.minNumber.toInt().toFloat() + } + } + + // For volumes/specials in mixed content, place them below chapter 0 + else -> { + val volumeNum = try { + if (volume.minNumber % 1 != 0.0) { + volume.minNumber.toFloat() + } else { + volume.minNumber.toInt().toFloat() + } + } catch (e: NumberFormatException) { + 0f + } + volumeNum / KavitaConstants.VOLUME_NUMBER_OFFSET + } } url = when { type == ChapterType.SingleFileVolume && singleFileVolumeNumber != null -> "volume_${volume.id}" - else -> chapter.id.toString() + else -> "chapter_${chapter.id}" } - // url = chapter.id.toString() - if (chapter.fileCount > 1) { // salt/offset to recognize chapters with new merged part-chapters as new and hence unread chapter_number += 0.001f * chapter.fileCount @@ -185,14 +219,89 @@ class KavitaHelper { date_upload = parseDateSafe(chapter.created) scanlator = when (type) { - ChapterType.SingleFileVolume -> "Volume" + ChapterType.SingleFileVolume -> if (isWebtoon) "Season" else "Volume" ChapterType.Special -> "Special" ChapterType.Issue -> "Issue" - ChapterType.Chapter -> "Chapter" - ChapterType.Regular -> "Chapter" + ChapterType.Chapter -> if (isWebtoon) "Episode" else "Chapter" + ChapterType.Regular -> if (isWebtoon) "Episode" else "Chapter" } } + private fun formatVolumeNumber(volume: VolumeDto): String { + return when { + volume.maxNumber > volume.minNumber -> + "${removeTrailingZero(volume.minNumber)}-${removeTrailingZero(volume.maxNumber)}" + else -> removeTrailingZero(volume.minNumber) + } + } + + private fun formatChapterNumber(chapter: ChapterDto, padLength: Int = 2): String { + val chapterNum = when { + chapter.maxNumber > chapter.minNumber -> + "${removeTrailingZero(chapter.minNumber)}-${removeTrailingZero(chapter.maxNumber)}" + else -> removeTrailingZero(chapter.minNumber) + } + + // Only pad if it's a single whole number (not a range and not decimal) + return if (!chapterNum.contains('-') && !chapterNum.contains('.')) { + chapterNum.toIntOrNull()?.toString()?.padStart(padLength, '0') ?: chapterNum + } else { + chapterNum + } + } + + // Helper function to remove .0 from whole numbers while preserving actual decimals + @SuppressLint("DefaultLocale") + private fun removeTrailingZero(number: Double): String { + return if (number % 1 == 0.0) { + number.toInt().toString() + } else { + // Use String.format to avoid scientific notation for very small decimals + String.format("%.10f", number).replace(",", ".").trimEnd('0').trimEnd('.') + } + } + + private fun cleanChapterTitle( + originalTitle: String, + context: ChapterTitleContext? = null, + ): String { + var title = originalTitle.trim() + val mangaTitle = context?.mangaTitle ?: "" + + // Remove manga name if present + if (mangaTitle.isNotBlank()) { + title = title.replace(Regex("(?i)\\b${Regex.escape(mangaTitle)}\\b[\\s\\-:]*"), "").trim() + } + + // Remove chapter number patterns like c0116, c001, etc. + title = title.replace(Regex("""(?i)\bc\d+\b"""), "").trim() + + // Clean up problematic hyphen/space patterns + title = title.replace(Regex("""\s*[-.]\s*[-.]\s*"""), " - ") // Handles -.-, - . -, etc. + .replace(Regex("""\s*-\s*-\s*"""), " - ") // Double hyphens with spaces + .replace(Regex("""^\s*[-.]\s*|\s*[-.]\s*$"""), "") // Leading/trailing hyphens or dots + .trim() + + return title.ifBlank { originalTitle } + } + + /** + * Data class to hold context information for cleaning chapter titles + */ + data class ChapterTitleContext( + val mangaTitle: String = "", + val chapterNumber: String = "", + val volumeName: String = "", + val isWebtoon: Boolean = false, + ) + + fun isWebtoonOrLongStrip(tags: List): Boolean { + return tags.any { + val normalized = it.trim().lowercase() + normalized.contains("webtoon") || normalized.contains("long strip") + } + } + val intl = Intl( language = Locale.getDefault().toString(), baseLanguage = "en", diff --git a/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/KavitaUtils.kt b/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/KavitaUtils.kt index 68e8363d..711195d2 100644 --- a/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/KavitaUtils.kt +++ b/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/KavitaUtils.kt @@ -10,11 +10,6 @@ import java.text.SimpleDateFormat import java.util.Locale import java.util.TimeZone -val formatterDate = SimpleDateFormat("yyyy-MM-dd", Locale.US) - .apply { timeZone = TimeZone.getTimeZone("UTC") } -val formatterDateTime = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US) - .apply { timeZone = TimeZone.getTimeZone("UTC") } - fun parseDateSafe(date: String?): Long { return date?.let { try { @@ -80,7 +75,7 @@ fun PreferenceScreen.addEditTextPreference( val result = text.isBlank() || validate?.invoke(text) ?: true if (restartRequired && result) { - Toast.makeText(context, "Restart Tachiyomi to apply new setting.", Toast.LENGTH_LONG).show() + Toast.makeText(context, "Restart Mihon to apply new setting.", Toast.LENGTH_LONG).show() } result diff --git a/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/dto/MangaDto.kt b/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/dto/MangaDto.kt index 7a282308..e7c3160e 100644 --- a/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/dto/MangaDto.kt +++ b/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/dto/MangaDto.kt @@ -1,5 +1,6 @@ package eu.kanade.tachiyomi.extension.all.kavita.dto +import eu.kanade.tachiyomi.extension.all.kavita.KavitaConstants import eu.kanade.tachiyomi.source.model.SManga import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -8,7 +9,7 @@ interface ConvertibleToSManga { fun toSManga(baseUrl: String, apiUrl: String, apiKey: String): SManga } -@Serializable +@Serializable // https://github.com/Kareadita/Kavita/blob/develop/API/Entities/Enums/MangaFormat.cs enum class MangaFormat(val format: Int) { Image(0), Archive(1), @@ -19,7 +20,6 @@ enum class MangaFormat(val format: Int) { companion object { private val map = values().associateBy(MangaFormat::format) - fun fromInt(type: Int): MangaFormat? = map[type] } } @@ -189,8 +189,9 @@ data class AuthorDto( @Serializable data class VolumeDto( val id: Int, - val number: Int, - val name: String, + val minNumber: Double, + val maxNumber: Double, + val name: String = "", val pages: Int, val pagesRead: Int, val lastModified: String, @@ -202,27 +203,34 @@ data class VolumeDto( @Serializable enum class ChapterType { - Regular, // chapter with volume information - Chapter, // manga chapter without volume information + Regular, // Chapter with volume information + Chapter, // Chapter without volume information SingleFileVolume, Special, Issue, // For comics ; companion object { + private const val UNNUMBERED_VOLUME_NUMBER = -100_000 + fun of(chapter: ChapterDto, volume: VolumeDto, libraryType: LibraryTypeEnum? = null): ChapterType = when { - volume.number == 100_000 -> Special - volume.number == -100_000 -> when (libraryType) { + // Special + volume.minNumber.toInt() == KavitaConstants.SPECIAL_NUMBER || + chapter.minNumber.toInt() == KavitaConstants.SPECIAL_NUMBER -> Special + // Issue + volume.minNumber.toInt() == UNNUMBERED_VOLUME_NUMBER -> when (libraryType) { LibraryTypeEnum.Comic, LibraryTypeEnum.ComicVine -> Issue - LibraryTypeEnum.Manga, LibraryTypeEnum.LightNovel, LibraryTypeEnum.Book -> Chapter - else -> Chapter // Default to Chapter for other types + else -> Chapter } - chapter.number == "-100000" -> SingleFileVolume + // SingleFileVolume + chapter.number == KavitaConstants.UNNUMBERED_VOLUME_STR -> SingleFileVolume + // Regular + volume.minNumber > 0 -> Regular + // Everything else depends on library type else -> when (libraryType) { LibraryTypeEnum.Comic, LibraryTypeEnum.ComicVine -> Issue - LibraryTypeEnum.Manga, LibraryTypeEnum.LightNovel, LibraryTypeEnum.Book -> Chapter - else -> Regular + else -> Chapter } } } @@ -233,12 +241,15 @@ data class ChapterDto( val id: Int, val range: String, val number: String, + val minNumber: Double, + val maxNumber: Double, val pages: Int, val isSpecial: Boolean, val title: String, - val titleName: String, + val titleName: String?, val pagesRead: Int, val coverImageLocked: Boolean, + val coverImage: String, val volumeId: Int, val created: String, val lastModifiedUtc: String, @@ -259,13 +270,13 @@ data class ReadingListDto( val title: String, val coverImage: String? = null, val promoted: Boolean = false, - val summary: String?, + val summary: String? = null, val itemCount: Int, val startingYear: Int, val startingMonth: Int, val endingYear: Int, val endingMonth: Int, - val ownerUserName: String?, + val ownerUserName: String? = null, @SerialName("items") val items: List = emptyList(), ) @@ -278,11 +289,11 @@ data class ReadingListItemDto( val seriesName: String, val chapterNumber: String?, val volumeNumber: String?, - val chapterTitleName: String?, + val chapterTitleName: String? = null, val volumeId: Int?, - val title: String?, - val summary: String?, - val releaseDate: String?, + val title: String? = null, + val summary: String? = null, + val releaseDate: String? = null, val libraryName: String?, @SerialName("readingListId") val readingListId: Int, ) diff --git a/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/dto/MetadataDto.kt b/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/dto/MetadataDto.kt index ae3aef41..2d52327d 100644 --- a/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/dto/MetadataDto.kt +++ b/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/dto/MetadataDto.kt @@ -22,6 +22,9 @@ data class MetadataTag(val id: Int, val title: String) @Serializable data class MetadataAgeRatings(val value: Int, val title: String) +@Serializable +data class MetadataUserRatings(val value: Int, val title: String) + @Serializable data class MetadataLanguages(val isoCode: String, val title: String) @@ -37,40 +40,3 @@ data class SmartFilter( val name: String, val filter: String, ) - -@Serializable -data class MetadataPayload( - val forceUseMetadataPayload: Boolean = true, - var sorting: Int = 1, - var sorting_asc: Boolean = true, - var readStatus: ArrayList = arrayListOf("notRead", "inProgress", "read"), - var genres_i: ArrayList = arrayListOf(), - var genres_e: ArrayList = arrayListOf(), - var tags_i: ArrayList = arrayListOf(), - var tags_e: ArrayList = arrayListOf(), - var ageRating_i: ArrayList = arrayListOf(), - var ageRating_e: ArrayList = arrayListOf(), - var formats: ArrayList = arrayListOf(), - var collections_i: ArrayList = arrayListOf(), - var collections_e: ArrayList = arrayListOf(), - var userRating: Int = 0, - var people: ArrayList = arrayListOf(), - var language_i: ArrayList = arrayListOf(), - var language_e: ArrayList = arrayListOf(), - var libraries_i: ArrayList = arrayListOf(), - var libraries_e: ArrayList = arrayListOf(), - var pubStatus: ArrayList = arrayListOf(), - var seriesNameQuery: String = "", - var releaseYearRangeMin: Int = 0, - var releaseYearRangeMax: Int = 0, - var peopleWriters: ArrayList = arrayListOf(), - var peoplePenciller: ArrayList = arrayListOf(), - var peopleInker: ArrayList = arrayListOf(), - var peopleColorist: ArrayList = arrayListOf(), - var peopleLetterer: ArrayList = arrayListOf(), - var peopleCoverArtist: ArrayList = arrayListOf(), - var peopleEditor: ArrayList = arrayListOf(), - var peoplePublisher: ArrayList = arrayListOf(), - var peopleCharacter: ArrayList = arrayListOf(), - var peopleTranslator: ArrayList = arrayListOf(), -)