diff --git a/.github/screenshots/Komikku_1.png b/.github/screenshots/Komikku_1.png new file mode 100644 index 00000000..d20c9b6b Binary files /dev/null and b/.github/screenshots/Komikku_1.png differ diff --git a/.github/screenshots/Komikku_2.png b/.github/screenshots/Komikku_2.png new file mode 100644 index 00000000..62e47118 Binary files /dev/null and b/.github/screenshots/Komikku_2.png differ diff --git a/.github/screenshots/Mihon_1.png b/.github/screenshots/Mihon_1.png new file mode 100644 index 00000000..2457930a Binary files /dev/null and b/.github/screenshots/Mihon_1.png differ diff --git a/.github/screenshots/Mihon_2.png b/.github/screenshots/Mihon_2.png new file mode 100644 index 00000000..a1fc04d6 Binary files /dev/null and b/.github/screenshots/Mihon_2.png differ diff --git a/.github/scripts/merge-repo.py b/.github/scripts/merge-repo.py index 7b788788..8c521a31 100644 --- a/.github/scripts/merge-repo.py +++ b/.github/scripts/merge-repo.py @@ -21,6 +21,7 @@ for module in to_delete: shutil.copytree(src=LOCAL_REPO.joinpath("apk"), dst=REMOTE_REPO.joinpath("apk"), dirs_exist_ok = True) shutil.copytree(src=LOCAL_REPO.joinpath("icon"), dst=REMOTE_REPO.joinpath("icon"), dirs_exist_ok = True) +shutil.copy2(src=LOCAL_REPO.joinpath("badge-version.json"), dst=REMOTE_REPO.joinpath("badge-version.json")) with REMOTE_REPO.joinpath("index.min.json").open() as remote_index_file: remote_index = json.load(remote_index_file) diff --git a/.github/workflows/create_release.yml b/.github/workflows/create_release.yml new file mode 100644 index 00000000..5a2c07e5 --- /dev/null +++ b/.github/workflows/create_release.yml @@ -0,0 +1,148 @@ +name: Create Release on Merge + +on: + push: + branches: + - master + workflow_dispatch: + +jobs: + release: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: read + + steps: + - name: Checkout repo branch + uses: actions/checkout@v4 + with: + ref: repo + fetch-depth: 0 + + # Get the merged PR info (only if not triggered manually) + - name: Get PR information + id: pr_info + if: github.event_name == 'push' + run: | + # Check out the master branch to get the PR info + git fetch origin master:master + git checkout master + + # Find the merge commit that triggered this workflow + MERGE_COMMIT=$(git rev-parse HEAD) + + # Check if this is a merge commit + if git show --summary $MERGE_COMMIT | grep -q "Merge pull request"; then + # Extract PR number from merge commit message + PR_NUMBER=$(git log -1 --pretty=%B | grep -oP 'Merge pull request #\K\d+') + + if [ -n "$PR_NUMBER" ]; then + echo "pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT + + # Get PR details + PR_JSON=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ + https://api.github.com/repos/${{ github.repository }}/pulls/$PR_NUMBER) + + PR_TITLE=$(echo "$PR_JSON" | jq -r .title) + echo "pr_title=$PR_TITLE" >> $GITHUB_OUTPUT + + PR_AUTHOR=$(echo "$PR_JSON" | jq -r .user.login) + echo "pr_author=$PR_AUTHOR" >> $GITHUB_OUTPUT + + # Check if first-time contributor + CONTRIBUTOR_JSON=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ + https://api.github.com/repos/${{ github.repository }}/contributors/$PR_AUTHOR) + CONTRIBUTIONS=$(echo "$CONTRIBUTOR_JSON" | jq -r .contributions) + + if [ "$CONTRIBUTIONS" = "1" ]; then + echo "is_first_contributor=true" >> $GITHUB_OUTPUT + fi + + # Get PR body + PR_BODY=$(echo "$PR_JSON" | jq -r .body) + echo "pr_body=$PR_BODY" >> $GITHUB_OUTPUT + fi + fi + + # Find the Kavita APK and extract version + - name: Find APK and extract version + id: apk_info + run: | + # Find the Kavita APK in the apk folder + APK_PATH=$(find ./apk -name "tachiyomi-all.kavita-*.apk" -type f | head -n 1) + + if [ -z "$APK_PATH" ]; then + echo "No Kavita APK found in ./apk folder!" + exit 1 + fi + + echo "apk_path=$APK_PATH" >> $GITHUB_OUTPUT + + # Extract version from filename (tachiyomi-all.kavita-v1.4.21.apk -> v1.4.21) + APK_FILENAME=$(basename "$APK_PATH") + VERSION=$(echo "$APK_FILENAME" | grep -oP 'v\d+\.\d+\.\d+') + + if [ -z "$VERSION" ]; then + echo "Could not extract version from filename: $APK_FILENAME" + VERSION="v$(date +%Y%m%d-%H%M%S)" + fi + + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "apk_name=$APK_FILENAME" >> $GITHUB_OUTPUT + + echo "Found APK: $APK_PATH" + echo "Extracted version: $VERSION" + + # Get commit messages since last tag + - name: Get changelog + id: changelog + run: | + # Switch to master branch to get git history + git checkout master + + # Get the last tag + LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") + + if [ -n "$LAST_TAG" ]; then + COMMITS=$(git log $LAST_TAG..HEAD --pretty=format:"- %s (%h)") + else + COMMITS=$(git log --pretty=format:"- %s (%h)" -n 20) + fi + + echo "changelog<> $GITHUB_OUTPUT + echo "$COMMITS" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + # Create the release (on the repo branch) + - name: Create Release + uses: softprops/action-gh-release@v2 + with: + name: Release ${{ steps.apk_info.outputs.version }} + tag_name: ${{ steps.apk_info.outputs.version }} + target_commitish: repo + body: | + ## 🚀 Release ${{ steps.apk_info.outputs.version }} + + ### 📝 Pull Request + ${{ steps.pr_info.outputs.pr_number && format('- Pull Request #%s by @%s', steps.pr_info.outputs.pr_number, steps.pr_info.outputs.pr_author) || 'Manual release' }} + ${{ steps.pr_info.outputs.pr_title && format('- Title: %s', steps.pr_info.outputs.pr_title) || '' }} + ${{ steps.pr_info.outputs.is_first_contributor == 'true' && format('### 🎉 Welcome @%s on their first contribution!', steps.pr_info.outputs.pr_author) || '' }} + + ${{ steps.pr_info.outputs.pr_body && format('### 📋 Description:\n%s', steps.pr_info.outputs.pr_body) || '' }} + + ### 📦 Changes since last release: + ${{ steps.changelog.outputs.changelog }} + + --- + 🤖 *This release was automatically created* + files: ${{ steps.apk_info.outputs.apk_path }} + draft: false + prerelease: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Notify on failure + if: failure() + run: | + echo "Release creation failed. Check the logs for details." diff --git a/README.md b/README.md index 68ed87dd..f507e25c 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ This repository contains the Kavita extension, compatible with [Komikku](https://github.com/komikku-app/komikku), [Mihon](https://github.com/mihonapp/mihon), Tachiyomi, and other forks. -## Recommended Apps +## Recommended & Tested ### - [Mihon](https://github.com/mihonapp/mihon) @@ -22,16 +22,22 @@ This repository contains the Kavita extension, compatible with [Komikku](https:/ ## How to add the repo - + Install Kavita Repo -Or copy & paste this URL into your app: +Or copy & paste this URL into your instance (Suwayomi included): ``` -https://raw.githubusercontent.com/Kareadita/tach-extensions/repo/index.min.json +https://raw.githubusercontent.com/Kareadita/tach-extension/repo/index.min.json ``` +**Please, follow [our guide](https://wiki.kavitareader.com/guides/3rdparty/tachi-like/) for more detailed instructions with steps and screenshots.** + +# Screenshots +Komikku1 Mihon1 Komikku2 Mihon2 + + # Requests To request a new feature or bug fix, [create an issue](https://github.com/Kareadita/tach-extensions/issues/new/choose). diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c8e19fe5..d3125f22 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,30 +1,30 @@ [versions] -kotlin_version = "1.7.21" -coroutines_version = "1.6.4" -serialization_version = "1.4.0" -coreVersion = "1.16.0" +commonsText = "1.14.0" +kotlin = "1.7.21" +coroutines = "1.6.4" +serialization = "1.4.0" [libraries] -gradle-agp = { module = "com.android.tools.build:gradle", version = "8.10.1" } -gradle-kotlin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin_version" } -gradle-serialization = { module = "org.jetbrains.kotlin:kotlin-serialization", version.ref = "kotlin_version" } +commons-text = { module = "org.apache.commons:commons-text", version.ref = "commonsText" } +gradle-agp = { module = "com.android.tools.build:gradle", version = "8.13.0" } +gradle-kotlin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } +gradle-serialization = { module = "org.jetbrains.kotlin:kotlin-serialization", version.ref = "kotlin" } gradle-kotlinter = { module = "org.jmailen.gradle:kotlinter-gradle", version = "3.13.0" } tachiyomi-lib = { module = "com.github.komikku-app:extensions-lib", version = "1.7.1" } -kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk8", version.ref = "kotlin_version" } -kotlin-protobuf = { module = "org.jetbrains.kotlinx:kotlinx-serialization-protobuf", version.ref = "serialization_version" } -kotlin-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization_version" } +kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk8", version.ref = "kotlin" } +kotlin-protobuf = { module = "org.jetbrains.kotlinx:kotlinx-serialization-protobuf", version.ref = "serialization" } +kotlin-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" } -coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines_version" } -coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines_version" } +coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } +coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } injekt-core = { module = "com.github.null2264.injekt:injekt-core", version = "4135455a2a" } rxjava = { module = "io.reactivex:rxjava", version = "1.3.8" } -jsoup = { module = "org.jsoup:jsoup", version = "1.15.1" } +jsoup = { module = "org.jsoup:jsoup", version = "1.15.3" } okhttp = { module = "com.squareup.okhttp3:okhttp", version = "5.0.0-alpha.11" } quickjs = { module = "app.cash.quickjs:quickjs-android", version = "0.9.2" } -core = { group = "androidx.core", name = "core", version.ref = "coreVersion" } [bundles] common = ["kotlin-stdlib", "coroutines-core", "coroutines-android", "injekt-core", "rxjava", "kotlin-protobuf", "kotlin-json", "jsoup", "okhttp", "tachiyomi-lib", "quickjs"] diff --git a/src/all/kavita/README.md b/src/all/kavita/README.md deleted file mode 100644 index 569b0614..00000000 --- a/src/all/kavita/README.md +++ /dev/null @@ -1,41 +0,0 @@ -# Komga - -Table of Content -- [FAQ](#FAQ) - - [Why do I see no manga?](#why-do-i-see-no-manga) - - [Where can I get more information about Komga?](#where-can-i-get-more-information-about-komga) - - [The Komga extension stopped working?](#the-komga-extension-stopped-working) - - [Can I add more than one Komga server or user?](#can-i-add-more-than-one-komga-server-or-user) - - [Can I test the Komga extension before setting up my own server?](#can-i-test-the-komga-extension-before-setting-up-my-own-server) -- [Guides](#Guides) - - [How do I add my Komga server to the app?](#how-do-i-add-my-komga-server-to-the-app) - -## FAQ - -### Why do I see no manga? -Komga is a self-hosted comic/manga media server. - -### Where can I get more information about Komga? -You can visit the [Komga](https://komga.org/) website for for more information. - -### The Komga extension stopped working? -Make sure that your Komga server and extension are on the newest version. - -### Can I add more than one Komga server or user? -Yes, currently you can add up to 11 different Komga instances to the app. The number of instances -available can be customized in extension settings. - -### Can I test the Komga extension before setting up my own server? -Yes, you can try it out with the DEMO server `https://demo.komga.org`, username `demo@komga.org` and -password `komga-demo`. - -### Why are EPUB chapters/books missing? -Mihon/Tachiyomi does not support reading text. EPUB files containing only images will be available -if your Komga server version is [1.9.0](https://github.com/gotson/komga/releases/tag/1.9.0) -or newer. - -## Guides - -### How do I add my Komga server to the app? -Go into the settings of the Komga extension (under Browse -> Extensions) and fill in your server -address and login details. diff --git a/src/all/kavita/assets/i18n/messages_en.properties b/src/all/kavita/assets/i18n/messages_en.properties index 893d4664..cb42c23d 100644 --- a/src/all/kavita/assets/i18n/messages_en.properties +++ b/src/all/kavita/assets/i18n/messages_en.properties @@ -1,12 +1,10 @@ # ================ # # 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 +login_errors_failed_login=Authentication failed.\nPlease check your credentials and server URL +login_errors_header_token_empty=Authentication required\nPlease open this extension once to generate a login token +login_errors_invalid_url=Invalid server URL format: +login_errors_parse_tokendto=Server response invalid\nPlease check your Kavita version (requires v0.8.7+) # ============== # # Preferences # @@ -15,42 +13,58 @@ 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_group_tags_summary_on=Show tags with category prefixes (e.g., "Genre: Action") +pref_group_tags_summary_off=Display all tags in a 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_last_volume_cover_title=Dynamic Cover Updates +pref_last_volume_cover_summary_off=Always show the main series cover +pref_last_volume_cover_summary_on=Update cover on refresh to show current volume/issue when reading 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_show_epub_summary_off=Hide EPUB files +pref_show_epub_summary_on=Show EPUB entries (not supported by Mihon) + +pref_release_date_title=Chapters: Display Release Dates +pref_release_date_summary_off=Show when chapters were added to Kavita +pref_release_date_summary_on=Show original publication dates\nNote: May affect chapter sorting if dates are missing pref_rd_date_title=Reading List: Display Release Date -pref_rd_date_summary_off=Hide publication dates +pref_rd_date_summary_off=Hide publication dates from reading lists 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_title=Reset All 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 +pref_allowed_libraries_feed_title=Allowed Libraries on Latest/Popular Feeds & Suggestions +pref_allowed_libraries_feed_summary=Select which libraries appear in the Latest/Popular Feeds & Suggestions (Komikku only) + +pref_allowed_libraries_search_title=Allowed Libraries in Search +pref_allowed_libraries_search_summary=Select which libraries appear in search results + +pref_chapter_title_format_title=Chapter Title Format +pref_chapter_title_format_summary=Customize chapter titles using variables\nCurrent: %s +pref_chapter_title_format_dialog=Available variables: (Use variables without curly braces, e.g., $CleanTitle instead of ${CleanTitle})\n\n\n\${Type} - Chapter/Episode/Volume/Issue/Special\n\${No} - Chapter number\n\${Title} - Original chapter title\n\${CleanTitle} - Custom-cleaned chapter title (default)\n\${Pages} - Number of pages\n\${Size} - File size\n\${Volume} - Volume number (If available)\n\${SeriesName} - Series name\n\${LibraryName} - Library name\n\${Format} - File format (CBZ, PDF, etc.)\n\${Created} - Creation date\n\${ReleaseDate} - Release date + +pref_scanlator_format_title=Scanlator Format +pref_scanlator_format_summary=Customize scanlator field using variables\nCurrent: %s +pref_scanlator_format_dialog=Available variables: (Use variables without curly braces, e.g., $CleanTitle instead of ${CleanTitle})\n\n\n\${Type} - Chapter/Episode/Volume/Issue/Special (default)\n\${No} - Chapter number\n\${Title} - Original chapter title\n\${CleanTitle} - Custom-cleaned chapter title\n\${Pages} - Number of pages\n\${Size} - File size\n\${Volume} - Volume number (If available)\n\${SeriesName} - Series name\n\${LibraryName} - Library name\n\${Format} - File format (CBZ, PDF, etc.)\n\${Created} - Creation date\n\${ReleaseDate} - Release date + # ============== # # 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_must_setup_address=OPDS URL required\nPlease configure your Kavita server connection +pref_opds_badformed_url=Invalid OPDS address format\nGet this 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+ +check_version=Requires Kavita v0.8.7 or newer\nIf issues persist, open an issue on GitHub +version_exceptions_chapters_parse=Failed to parse chapter information\nPlease report this with error logs on Github +version_exceptions_smart_filter=Smart filter not supported\nRequires Kavita v0.7.11 or newer # ============== # @@ -102,6 +116,17 @@ filters_sort_release_date=Release Date filters_sort_read_progress=Read Progress # Error messages +http_errors_401=Authentication expired\nPlease log in again +http_errors_500=Server error occurred +http_errors_400=Bad request - Invalid parameters +http_errors_403=Access forbidden - Insufficient permissions +http_errors_404=Resource not found - The requested item doesn't exist +http_errors_429=Too many requests - Please try again later +http_errors_502=Bad gateway - Server communication error +http_errors_503=Service unavailable - Server is temporarily down filters_error_loading=Failed to load filters filters_empty_list=No filters available - +error_chapter_not_found=Chapter not found in reading list +error_no_chapters_found=No chapters found +error_invalid_reading_list_item=Invalid reading list item +error_failed_parse_chapter=Failed to parse chapter details diff --git a/src/all/kavita/build.gradle b/src/all/kavita/build.gradle index 36826e71..6dd3e405 100644 --- a/src/all/kavita/build.gradle +++ b/src/all/kavita/build.gradle @@ -2,13 +2,16 @@ ext { kmkVersionCode = 1 extName = 'Kavita' extClass = '.KavitaFactory' - extVersionCode = 20 + extVersionCode = 21 isNsfw = false } apply from: "$rootDir/common.gradle" dependencies { - implementation("org.apache.commons:commons-text:1.11.0") +// implementation("org.apache.commons:commons-text:1.14.0") +// implementation(libs.lib.i18n) implementation project(':lib:i18n') + implementation(libs.commons.text) + } 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 24239ac3..6469f011 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 @@ -3,6 +3,8 @@ package eu.kanade.tachiyomi.extension.all.kavita import android.app.AlertDialog import android.app.Application import android.content.SharedPreferences +import android.os.Handler +import android.os.Looper import android.text.InputType import android.util.Log import android.widget.Toast @@ -29,8 +31,6 @@ 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 import eu.kanade.tachiyomi.extension.all.kavita.dto.RelatedSeriesResponse @@ -42,6 +42,7 @@ import eu.kanade.tachiyomi.extension.all.kavita.dto.SmartFilter import eu.kanade.tachiyomi.extension.all.kavita.dto.SortFieldEnum import eu.kanade.tachiyomi.extension.all.kavita.dto.SortOptions import eu.kanade.tachiyomi.extension.all.kavita.dto.VolumeDto +import eu.kanade.tachiyomi.lib.i18n.Intl import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.source.ConfigurableSource @@ -82,11 +83,27 @@ import uy.kohesive.injekt.injectLazy import java.io.IOException import java.security.MessageDigest import java.util.Calendar +import java.util.Locale +import java.util.concurrent.ConcurrentHashMap +import kotlin.let +import kotlin.runCatching class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSource, HttpSource() { private val helper = KavitaHelper() - /** OkHttp client for network requests. */ + // Initialize the intl object + val intl = Intl( + language = Locale.getDefault().toString(), + baseLanguage = "en", + availableLanguages = KavitaInt.AVAILABLE_LANGS, + classLoader = this::class.java.classLoader!!, + createMessageFileName = { lang -> + when (lang) { + KavitaInt.SPANISH_LATAM -> Intl.createDefaultMessageFileName(KavitaInt.SPANISH) + else -> Intl.createDefaultMessageFileName(lang) + } + }, + ) override val client: OkHttpClient = network.client.newBuilder() .dns(Dns.SYSTEM) @@ -121,15 +138,46 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou private var series = emptyList() private val libraryTypeCache = mutableMapOf() + // Metadata caching with TTL (Time To Live) + private data class CacheEntry( + val data: T, + val timestamp: Long = System.currentTimeMillis(), + ) { + fun isExpired(ttl: Long): Boolean = System.currentTimeMillis() - timestamp > ttl + } + + private val metadataCache = ConcurrentHashMap>() + private val CACHE_TTL = 30 * 60 * 1000L // 30 minutes in milliseconds + /** * Extension function to parse a network [Response] as a given type [T]. - * Throws IOException if the response is not successful or body is empty. + * Throws IOException with detailed error message if the response is not successful or body is empty. */ private inline fun Response.parseAs(): T = use { if (!it.isSuccessful) { - Log.e(LOG_TAG, "HTTP error ${it.code}: ${it.request.url}") - throw IOException("HTTP error ${it.code}") + val errorMessage = try { + when (it.code) { + 400 -> intl["http_errors_400"] + 401 -> intl["http_errors_401"] + 403 -> intl["http_errors_403"] + 404 -> intl["http_errors_404"] + 429 -> intl["http_errors_429"] + 500 -> intl["http_errors_500"] + 502 -> intl["http_errors_502"] + 503 -> intl["http_errors_503"] + else -> "HTTP error ${it.code}" + } + } catch (e: Exception) { + "HTTP error ${it.code}" + } + Log.e(LOG_TAG, "HTTP error ${it.code}: $errorMessage - ${it.request.url}") + val versionCheck = try { + intl["check_version"] + } catch (e: Exception) { + "If issues persist, contact support with logs" + } + throw IOException("$errorMessage\n$versionCheck") } val body = it.body.string() @@ -140,6 +188,26 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou json.decodeFromString(body) } + // @todo To remove next update + private fun showMigrationToast() { + val prefs = Injekt.get().getSharedPreferences("kavita_update_1_22", 0) + + // Check if we already showed the message + if (!prefs.getBoolean("shown_fix_toast", false)) { + // Run on UI Thread (Safe for background updates) + Handler(Looper.getMainLooper()).post { + Toast.makeText( + Injekt.get(), + "Kavita Update: Please pull-to-refresh your chapter list to apply the changes.", + Toast.LENGTH_LONG, + ).show() + } + + // Mark as shown so it doesn't appear again + prefs.edit().putBoolean("shown_fix_toast", true).apply() + } + } + /** * For Webview URLs, we need to handle different URL formats: * - API-style URLs (e.g., `/Series/123`) @@ -202,7 +270,7 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou 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"]}") + throw IOException("Http Error: $code\n ${intl["http_errors_$code"]}\n${intl["check_version"]}") } popularMangaParse(response) } catch (e: Exception) { @@ -230,31 +298,11 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou val specialListFilter = filters.find { it is SpecialListFilter } as? SpecialListFilter return when (specialListFilter?.state) { - 1 -> { // Want to Read selected - val payload = """{ - "id": 0, - "name": "", - "statements": [], - "combination": 0, - "sortOptions": { - "sortField": 1, - "isAscending": true - }, - "limitTo": 0 - }""" - val request = POST( - "$apiUrl/want-to-read/v2?pageNumber=$page&pageSize=20", - headersBuilder().build(), - payload.toRequestBody(JSON_MEDIA_TYPE), - ) - val response = client.newCall(request).execute() - popularMangaParse(response) - } 2 -> { // Reading Lists val response = client.newCall(readingListRequest()).execute() readingListParse(response) } - else -> { // Regular searches + else -> { // Regular searches (including Want to Read) fetch(searchMangaRequest(page, query, filters)) } } @@ -272,11 +320,32 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou try { val result = response.parseAs>() series = result - val mangaList = result.map { item -> helper.createSeriesDto(item, apiUrl, apiKey) } - return MangasPage(mangaList, helper.hasNextPage(response)) + result.forEach { helper.seriesMap.addSeries(it) } + + Log.d(LOG_TAG, "Parsing ${result.size} series for popular feed") + + val allManga = result.map { item -> + helper.createSeriesDto(item, apiUrl, apiKey) + } + + Log.d(LOG_TAG, "Created ${allManga.size} titles") + + val filteredManga = allManga.filter { manga -> + isLibraryAllowedForFeed(manga) + } + + Log.d(LOG_TAG, "After filtering: ${filteredManga.size} titles remain") + + if (filteredManga.isEmpty()) { + Log.w(LOG_TAG, "All titles were filtered out. This might indicate a library name mismatch.") + Log.d(LOG_TAG, "Available library names in series: ${result.map { it.libraryName }.distinct()}") + Log.d(LOG_TAG, "Allowed libraries from preferences: ${preferences.allowedLibrariesFeed}") + } + + return MangasPage(filteredManga, helper.hasNextPage(response)) } catch (e: Exception) { Log.e(LOG_TAG, "Unhandled exception", e) - throw IOException(helper.intl["check_version"]) + throw IOException(intl["check_version"]) } } @@ -339,6 +408,9 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou getFilterList() // Re-initialize metadata } + val specialListFilter = filters.find { it is SpecialListFilter } as? SpecialListFilter + val wantToReadSelected = specialListFilter?.state == 1 + Log.d(LOG_TAG, "Building search request for query: '$query' with filters: $filters") // Handle filtered search (no text query) @@ -370,10 +442,19 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou decoded_filter.statements.add(FilterStatementDto(FilterComparison.NotContains.type, FilterField.Formats.type, "3")) // Make request with selected filters - return prepareRequest(page, json.encodeToJsonElement(decoded_filter).toString()) + val url = if (wantToReadSelected) { + "$apiUrl/want-to-read/v2?pageNumber=$page&pageSize=20" + } else { + "$apiUrl/Series/all-v2?pageNumber=$page&pageSize=20" + } + return POST( + url, + headersBuilder().build(), + json.encodeToJsonElement(decoded_filter).toString().toRequestBody(JSON_MEDIA_TYPE), + ) } else { Log.e(LOG_TAG, "Failed to decode SmartFilter: ${it.code}\n" + it.message) - throw IOException(helper.intl["version_exceptions_smart_filter"]) + throw IOException(intl["version_exceptions_smart_filter"]) } } } @@ -384,37 +465,62 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou statements = mutableListOf(), ) - // Handle text query first + // Handle text query first - search with prefix modifiers 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) - } + // Check for people search prefix modifiers + when { + queryLower.startsWith("artist:") -> { + val artistQuery = query.substringAfter(":").trim() - 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 + if (artistQuery.isNotBlank()) { + Log.d(LOG_TAG, "Searching for artist (text contains): '$artistQuery'") + + val artistFields = listOf( + FilterField.CoverArtist, + FilterField.Penciller, + FilterField.Inker, + FilterField.Colorist, + ) + artistFields.forEach { field -> + filterV2.addStatement(FilterComparison.Contains, field, artistQuery) + } + // Also hit aggregated People by text to keep prefix behavior + filterV2.addStatement(FilterComparison.Contains, FilterField.People, artistQuery) } } - } + queryLower.startsWith("people:") -> { + val peopleQuery = query.substringAfter(":").trim() - // Always search in series name - filterV2.addStatement(FilterComparison.Matches, FilterField.SeriesName, query) + if (peopleQuery.isNotBlank()) { + Log.d(LOG_TAG, "Searching for people (text contains): '$peopleQuery'") + + val peopleFields = listOf( + FilterField.Writers, + FilterField.Penciller, + FilterField.Inker, + FilterField.Colorist, + FilterField.Letterer, + FilterField.CoverArtist, + FilterField.Editor, + FilterField.Publisher, + FilterField.Characters, + FilterField.Translators, + ) + + peopleFields.forEach { field -> + filterV2.addStatement(FilterComparison.Contains, field, peopleQuery) + } + // Also hit aggregated People by text to keep prefix behavior + filterV2.addStatement(FilterComparison.Contains, FilterField.People, peopleQuery) + } + } + else -> { + // Regular search - only search series name + filterV2.addStatement(FilterComparison.Matches, FilterField.SeriesName, query) + } + } } filters.forEach { filter -> @@ -492,7 +598,7 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou is GenreFilterGroup -> { filter.state.forEach { genreFilter -> val genre = genresListMeta.find { it.title == (genreFilter as Filter.TriState).name } -// Log.d(LOG_TAG, "Checking genre: ${genreFilter.name}, state=${genreFilter.state}, match=${genre?.id}") + Log.d(LOG_TAG, "Checking genre: ${genreFilter.name}, state=${genreFilter.state}, match=${genre?.id}") genre?.let { when (genreFilter.state) { STATE_INCLUDE -> filterV2.addStatement( @@ -640,23 +746,34 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou } } is LibrariesFilterGroup -> { + val includedLibraries = mutableListOf() + val excludedLibraries = mutableListOf() + filter.state.forEach { libraryFilter -> 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(), - ) + STATE_INCLUDE -> includedLibraries.add(library.id.toString()) + STATE_EXCLUDE -> excludedLibraries.add(library.id.toString()) } } } + + // Add combined statements to avoid conflicts + if (includedLibraries.isNotEmpty()) { + filterV2.addStatement( + FilterComparison.Contains, + FilterField.Libraries, + includedLibraries.joinToString(","), + ) + } + if (excludedLibraries.isNotEmpty()) { + filterV2.addStatement( + FilterComparison.NotContains, + FilterField.Libraries, + excludedLibraries.joinToString(","), + ) + } } is PubStatusFilterGroup -> { filter.state.forEach { pubStatusFilter -> @@ -738,39 +855,6 @@ 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) -// } -// } - // Always apply selected people filters (even if query is blank) val roleToField = mapOf( WriterPeopleFilterGroup::class to FilterField.Writers, @@ -805,7 +889,18 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou val payload = json.encodeToJsonElement(filterV2).toString() Log.d(LOG_TAG, "Search payload: $payload") Log.d(LOG_TAG, "Filter statements: ${filterV2.statements}") - return prepareRequest(page, payload) + + val url = if (wantToReadSelected) { + "$apiUrl/want-to-read/v2?pageNumber=$page&pageSize=20" + } else { + "$apiUrl/Series/all-v2?pageNumber=$page&pageSize=20" + } + + return POST( + url, + headersBuilder().build(), + payload.toRequestBody(JSON_MEDIA_TYPE), + ) } /* @@ -917,57 +1012,66 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou } ?: throw IOException("Invalid series details response") } catch (e: Exception) { Log.e(LOG_TAG, "Error parsing series details", e) - throw IOException("Failed to parse series details") + throw IOException("${intl["version_exceptions_chapters_parse"]}\n${intl["check_version"]}") } val existingSeries = series.find { dto -> dto.id == result.seriesId } val ratingLine = try { - val responsePlus = client.newCall( - GET( - "$apiUrl/Metadata/series-detail-plus?seriesId=${result.seriesId}", - headersBuilder().build(), - ), - ).execute() + val responsePlus = try { + client.newCall( + GET( + "$apiUrl/Metadata/series-detail-plus?seriesId=${result.seriesId}", + headersBuilder().build(), + ), + ).execute() + } catch (e: Exception) { + Log.w(LOG_TAG, "series-detail-plus endpoint failed, falling back to ratings endpoint", e) + null + } - 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 - "" + if (responsePlus == null || !responsePlus.isSuccessful) { + // Fallback: just get ratings from the main metadata + val score = result.ratings.firstOrNull()?.averageScore ?: 0f + if (score > 0) "⭐ Score: ${"%.1f".format(score)}\n" else "" } else { - try { - val detailPlusWrapper = json.decodeFromString(rawResponse) - val score = detailPlusWrapper.series?.averageScore?.takeIf { it > 0f } - ?: detailPlusWrapper.ratings.firstOrNull()?.averageScore ?: 0f + val rawResponse = responsePlus.body.string() + if (rawResponse.isBlank() || rawResponse.trim().startsWith("<")) { + Log.e(LOG_TAG, "Invalid response for series-detail-plus: $rawResponse") + val score = result.ratings.firstOrNull()?.averageScore ?: 0f if (score > 0) "⭐ Score: ${"%.1f".format(score)}\n" else "" - } catch (e: Exception) { - Log.e(LOG_TAG, "Error parsing series detail plus", e) - "" + } else { + try { + val detailPlusWrapper = json.decodeFromString(rawResponse) + val score = detailPlusWrapper.series?.averageScore?.takeIf { it > 0f } + ?: detailPlusWrapper.ratings.firstOrNull()?.averageScore + ?: result.ratings.firstOrNull()?.averageScore + ?: 0f + if (score > 0) "⭐ Score: ${"%.1f".format(score)}\n" else "" + } catch (e: Exception) { + Log.e(LOG_TAG, "Error parsing series detail plus, using fallback", e) + val score = result.ratings.firstOrNull()?.averageScore ?: 0f + if (score > 0) "⭐ Score: ${"%.1f".format(score)}\n" else "" + } } } } catch (e: Exception) { - Log.e(LOG_TAG, "Error fetching ratings", e) - "" + Log.e(LOG_TAG, "Error fetching ratings, using fallback", e) + val score = result.ratings.firstOrNull()?.averageScore ?: 0f + if (score > 0) "⭐ Score: ${"%.1f".format(score)}\n" else "" } if (existingSeries != null) { - val demographicKeywords = listOf("Shounen", "Seinen", "Josei", "Shoujo", "Hentai", "Doujinshi") val genreTitles = result.genres.map { it.title } val tagTitles = result.tags.map { it.title } - // Find the demographic, if any - val foundDemographic = demographicKeywords.firstOrNull { demo -> - genreTitles.any { it.equals(demo, ignoreCase = true) } || - tagTitles.any { it.equals(demo, ignoreCase = true) } - } - - // Remove demographic from genres and tags - val filteredGenres = genreTitles.filterNot { it.equals(foundDemographic, ignoreCase = true) } - val filteredTags = tagTitles - .filterNot { it.equals(foundDemographic, ignoreCase = true) } - .filterNot { tag -> filteredGenres.any { genre -> genre.equals(tag, ignoreCase = true) } } + // Extract demographic, formats, and filtered genres/tags using helper + val triple = helper.extractDemographicAndFormat(genreTitles, tagTitles) + val foundDemographic = triple.first + val foundFormats = triple.second + val filteredGenres = triple.third.first + val filteredTags = triple.third.second val manga = helper.createSeriesDto(existingSeries, apiUrl, apiKey) manga.title = existingSeries.name - manga.url = "$apiUrl/Series/${result.seriesId}" // "$baseUrl/library/${existingSeries.libraryId}/series/${result.seriesId}" + manga.url = "$apiUrl/Series/${result.seriesId}" manga.artist = result.coverArtists.joinToString { it.name } manga.description = listOfNotNull( ratingLine.takeIf { it.isNotBlank() }, @@ -975,16 +1079,14 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou ).joinToString("\n") manga.author = result.writers.joinToString { it.name } - manga.genre = if (preferences.groupTags) { - buildList { - existingSeries.libraryName?.takeIf { it.isNotEmpty() }?.let { add("Type:$it") } - foundDemographic?.let { add("Demographic:$it") } - filteredGenres.forEach { add("Genres:$it") } - filteredTags.forEach { add("Tags:$it") } - }.joinToString(", ") - } else { - (genreTitles + tagTitles).toSet().toList().sorted().joinToString(", ") - } + manga.genre = helper.buildGenreString( + libraryName = existingSeries.libraryName, + demographic = foundDemographic, + formats = foundFormats, + genres = filteredGenres, + tags = filteredTags, + groupTags = preferences.groupTags, + ) manga.thumbnail_url = if (preferences.LastVolumeCover) { try { @@ -1076,21 +1178,15 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou .parseAs() return SManga.create().apply { - val demographicKeywords = listOf("Shounen", "Seinen", "Josei", "Shoujo", "Hentai", "Doujinshi") val genreTitles = result.genres.map { it.title } val tagTitles = result.tags.map { it.title } - // Find the demographic, if any - val foundDemographic = demographicKeywords.firstOrNull { demo -> - genreTitles.any { it.equals(demo, ignoreCase = true) } || - tagTitles.any { it.equals(demo, ignoreCase = true) } - } - - // Remove demographic from genres and tags - val filteredGenres = genreTitles.filterNot { it.equals(foundDemographic, ignoreCase = true) } - val filteredTags = tagTitles - .filterNot { it.equals(foundDemographic, ignoreCase = true) } - .filterNot { tag -> filteredGenres.any { genre -> genre.equals(tag, ignoreCase = true) } } + // Extract demographic, formats, and filtered genres/tags using helper + val triple = helper.extractDemographicAndFormat(genreTitles, tagTitles) + val foundDemographic = triple.first + val foundFormats = triple.second + val filteredGenres = triple.third.first + val filteredTags = triple.third.second url = "$baseUrl/Series/${result.seriesId}" // "$baseUrl/library/${result.getLibraryId(serieDto)}/series/${result.seriesId}" artist = result.coverArtists.joinToString { it.name } @@ -1100,16 +1196,14 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou ).joinToString("\n") author = result.writers.joinToString { it.name } - genre = if (preferences.groupTags) { - buildList { - result.getLibraryName(serieDto)?.takeIf { it.isNotEmpty() }?.let { add("Type:$it") } - foundDemographic?.let { add("Demographic:$it") } - filteredGenres.forEach { add("Genres:$it") } - filteredTags.forEach { add("Tags:$it") } - }.joinToString(", ") - } else { - (genreTitles + tagTitles).toSet().toList().sorted().joinToString(", ") - } + genre = helper.buildGenreString( + libraryName = result.getLibraryName(serieDto), + demographic = foundDemographic, + formats = foundFormats, + genres = filteredGenres, + tags = filteredTags, + groupTags = preferences.groupTags, + ) title = serieDto.name status = when (result.publicationStatus) { @@ -1185,14 +1279,46 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou val firstItem = items.firstOrNull() val isWebtoon = firstItem?.let { item -> - runCatching { + 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) + + // Extract demographic and formats information + val triple = helper.extractDemographicAndFormat(genreTitles, tagTitles) + val foundDemographic = triple.first + val foundFormats = triple.second + + // Get all tags when GroupTags is disabled + val allTagsForGrouping = if (preferences.groupTags) { + emptyList() + } else { + genreTitles + tagTitles + } + + // Get library information + val libraryName = meta.libraryName.takeIf { !it.isNullOrEmpty() } + ?: series.find { it.id == item.seriesId }?.libraryName + ?: runCatching { + val seriesDto = client.newCall(GET("$apiUrl/Series/${item.seriesId}", headersBuilder().build())) + .execute().parseAs() + seriesDto.libraryName + }.getOrNull() + + Log.d(LOG_TAG, "Reading list item ${item.seriesId} library: $libraryName") + + val isWebtoonDetected = helper.isWebtoonOrLongStrip( + genres = genreTitles, + tags = tagTitles, + libraryName = libraryName, + format = foundFormats.firstOrNull(), + demographic = foundDemographic, + allTags = allTagsForGrouping, + ) + + isWebtoonDetected }.getOrElse { false } } ?: false @@ -1294,6 +1420,7 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou /** * Related Titles / Suggestions + * Respect the Allowed Libraries in Latest/Popular Feeds * This only works on Komikku */ override fun relatedMangaListRequest(manga: SManga): Request { @@ -1318,7 +1445,19 @@ 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, apiKey) + + val manga = helper.createSeriesDto(seriesDto, apiUrl, apiKey) + val suggestionLibraryName = seriesDto.libraryName + val suggestionLibraryId = seriesDto.libraryId + + manga.takeIf { + isLibraryAllowedForSuggestions( + it, + currentMangaLibrary = null, + suggestedLibraryName = suggestionLibraryName, + suggestedLibraryId = suggestionLibraryId, + ) + } } catch (e: Exception) { Log.e(LOG_TAG, "Error fetching series for related reading list", e) null @@ -1331,6 +1470,13 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou return emptyList() } val result = json.decodeFromString(rawResponse) + + // Extract the current manga's library from the request URL + val currentSeriesId = requestUrl.substringAfter("seriesId=").toIntOrNull() + val currentMangaLibrary = currentSeriesId?.let { seriesId -> + series.find { it.id == seriesId }?.libraryName + } + val allRelated = listOf( result.sequels.map { it to "Sequel" }, result.prequels.map { it to "Prequel" }, @@ -1355,15 +1501,34 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou allRelated } - filteredRelated.map { (item, relation) -> - item.toSManga(baseUrl, apiUrl, apiKey).apply { + Log.d(LOG_TAG, "Processing suggestions for current manga library: $currentMangaLibrary") + Log.d(LOG_TAG, "Total related items before filtering: ${filteredRelated.size}") + + val suggestions = filteredRelated.mapNotNull { (item, relation) -> + val suggestionLibraryName = item.libraryName + ?: libraryListMeta.find { it.id == item.libraryId }?.name + + val manga = item.toSManga(baseUrl, apiUrl, apiKey).apply { if (relation.isNotBlank()) { title = "[$relation] $title" } else { Log.e(LOG_TAG, "No related title") } } + // Show suggestions only from the current library or allowed search libraries + manga.takeIf { + isLibraryAllowedForSuggestions( + it, + currentMangaLibrary, + suggestionLibraryName, + item.libraryId, + ) + } } + + Log.d(LOG_TAG, "Final suggestions count: ${suggestions.size}") + Log.d(LOG_TAG, "Allowed libraries for search: ${preferences.allowedLibrariesSearch}") + suggestions } } catch (e: Exception) { Log.e(LOG_TAG, "Error parsing related manga", e) @@ -1380,7 +1545,7 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou return GET(url, headersBuilder().build()) } - private fun getLibraryType(seriesId: Int): LibraryTypeEnum? { + private fun getLibraryType(seriesId: Int): LibraryTypeEnum { return libraryTypeCache[seriesId] ?: run { try { val seriesDto = client.newCall(GET("$apiUrl/Series/$seriesId", headersBuilder().build())) @@ -1396,47 +1561,126 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou LibraryTypeEnum.fromInt(library.type) } - type?.let { libraryTypeCache[seriesId] = it } - type + val result = type ?: LibraryTypeEnum.Manga // Default fallback + libraryTypeCache[seriesId] = result + result } catch (e: Exception) { Log.e(LOG_TAG, "Error getting library type for series $seriesId", e) - null + LibraryTypeEnum.Manga // Default fallback } } } override fun chapterListParse(response: Response): List { + val seriesId = response.request.url.queryParameter("seriesId")?.toIntOrNull() + if (seriesId != null) { + showMigrationToast() + } try { val seriesId = response.request.url.queryParameter("seriesId")?.toIntOrNull() val libraryType = seriesId?.let { getLibraryType(it) } val volumes = response.parseAs>() - val chapters = mutableListOf() - val volumeItems = mutableListOf() + + val allChapters = 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() + val seriesName = seriesId?.let { id: Int -> + // First try from cache + series.find { it.id == id }?.name + ?: runCatching { + client.newCall(GET("$apiUrl/Series/$id", headersBuilder().build())) + .execute().parseAs().name + }.getOrNull() } ?: "" + Log.d(LOG_TAG, "Processing chapters for series $seriesId with name: '$seriesName'") + // Get series metadata to determine if it's a webtoon val isWebtoon = seriesId?.let { - runCatching { + 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 } + + // Extract demographic and formats information + val triple = helper.extractDemographicAndFormat(genreTitles, tagTitles) + val foundDemographic = triple.first + val foundFormats = triple.second + + // Get all tags when GroupTags is disabled + val allTagsForGrouping = if (preferences.groupTags) { + emptyList() + } else { + genreTitles + tagTitles + } + + // Get library information + val libraryName = meta.libraryName.takeIf { !it.isNullOrEmpty() } + ?: series.find { it.id == seriesId }?.libraryName + ?: runCatching { + val seriesDto = client.newCall(GET("$apiUrl/Series/$seriesId", headersBuilder().build())) + .execute().parseAs() + seriesDto.libraryName + }.getOrNull() + + Log.d(LOG_TAG, "Checking webtoon detection for series $seriesId:") + Log.d(LOG_TAG, " Genres: $genreTitles") + Log.d(LOG_TAG, " Tags: $tagTitles") + Log.d(LOG_TAG, " Library: $libraryName") + Log.d(LOG_TAG, " Formats: $foundFormats") + Log.d(LOG_TAG, " Demographic: $foundDemographic") + Log.d(LOG_TAG, " AllTags (GroupTags off): $allTagsForGrouping") + + val isWebtoonDetected = helper.isWebtoonOrLongStrip( + genres = genreTitles, + tags = tagTitles, + libraryName = libraryName, + format = foundFormats.firstOrNull(), + demographic = foundDemographic, + allTags = allTagsForGrouping, + ) + + Log.d(LOG_TAG, "Webtoon detected for series $seriesId: $isWebtoonDetected") + isWebtoonDetected + }.getOrElse { + Log.e(LOG_TAG, "Error checking webtoon status for series $seriesId", it) + false + } } ?: false + // Pre-populate series map for this series ID to ensure context is available + seriesId?.let { id: Int -> + // First try from cache + series.find { it.id == id }?.let { sDto: SeriesDto -> + helper.seriesMap.addSeries(sDto) + } ?: run { + // If not in cache, fetch it before processing any volumes + runCatching { + val seriesDto = client.newCall(GET("$apiUrl/Series/$id", headersBuilder().build())) + .execute().parseAs() + helper.seriesMap.addSeries(seriesDto) + }.onFailure { e -> + Log.e(LOG_TAG, "Failed to fetch series $id for context", e) + } + } + } + volumes.forEach { volume -> - // This fixes specials being parsed as volumes sometimes - if (volume.chapters.size == 1 && volume.minNumber.toInt() != KavitaConstants.SPECIAL_NUMBER) { + // Ensure we have the series name from cache before processing chapters + seriesId?.let { id -> + series.find { it.id == id }?.let { seriesDto -> + helper.seriesMap.addSeries(seriesDto) + } + } + + // Check if this is a Single File Volume + val isSingleFileVolume = volume.chapters.size == 1 && + volume.chapters.first().number == KavitaConstants.UNNUMBERED_VOLUME_STR + + if (isSingleFileVolume) { val chapter = volume.chapters.first() + // Pass singleFileVolumeNumber explicitly to trigger SFV logic in helper val sChapter = helper.chapterFromVolume( chapter, volume, @@ -1444,57 +1688,42 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou libraryType = libraryType, isWebtoon = isWebtoon, mangaTitle = seriesName, + useReleaseDate = preferences.ReleaseDate, + chapterTitleFormat = preferences.chapterTitleFormat, + scanlatorFormat = preferences.scanlatorFormat, + volumePageCount = volume.pages, ) - sChapter.url = "/Chapter/${chapter.id}" // Needed to read volumes as chapters - sChapter.chapter_number = volume.minNumber.toFloat() + sChapter.url = "/Chapter/${chapter.id}" + + // For singleFileVolume, ensure the scanlator field reflects webtoon terminology sChapter.scanlator = if (isWebtoon) "Season" else "Volume" - volumeItems.add(sChapter) + allChapters.add(sChapter) } else { + // Handle Regular Chapters, Issues, and Specials volume.chapters.forEach { chapter -> - val type = ChapterType.of(chapter, volume, libraryType) val sChapter = helper.chapterFromVolume( chapter, volume, libraryType = libraryType, isWebtoon = isWebtoon, mangaTitle = seriesName, + useReleaseDate = preferences.ReleaseDate, + chapterTitleFormat = preferences.chapterTitleFormat, + scanlatorFormat = preferences.scanlatorFormat, ) - 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) - } + + sChapter.url = "/Chapter/${chapter.id}" + + allChapters.add(sChapter) } } } - return when { - // Case 1: Only chapters - chapters.isNotEmpty() && volumeItems.isEmpty() -> - chapters.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 } - ) - } - } + // 1.0 (Chapter) > 0.0001 (Volume) > 0.00001 (Special) + return allChapters.sortedByDescending { it.chapter_number } } catch (e: Exception) { Log.e(LOG_TAG, "Unhandled exception parsing chapters", e) - throw IOException(helper.intl["version_exceptions_chapters_parse"]) + throw IOException(intl["version_exceptions_chapters_parse"]) } } @@ -1512,7 +1741,7 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou val items = client.newCall(itemsRequest).execute().parseAs>() val chapterItem = items.find { it.seriesId == seriesId && it.chapterId != null } - ?: throw IOException(helper.intl["error_chapter_not_found"]) + ?: throw IOException(intl["error_chapter_not_found"]) return GET("$apiUrl/${chapterItem.chapterId}", headersBuilder().build()) } @@ -1569,9 +1798,9 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou 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"]) + } ?: throw IOException(intl["error_no_chapters_found"]) } - else -> throw IOException(helper.intl["error_invalid_reading_list_item"]) + else -> throw IOException(intl["error_invalid_reading_list_item"]) } // Generate pages with consistent URL format @@ -1592,8 +1821,14 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou // 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("_") + chapter.url.startsWith("volume_") -> { + val parts = chapter.url.substringAfter("volume_").split("_", "?") + parts.firstOrNull() ?: throw IOException("Invalid volume URL format") + } + chapter.url.contains("/Volume/") -> { + val parts = chapter.url.substringAfter("/Volume/").split("_", "?") + parts.firstOrNull() ?: throw IOException("Invalid volume URL format") + } else -> throw IOException("Invalid volume URL format") } @@ -1601,7 +1836,7 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou 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"]) + ?: throw IOException(intl["error_no_chapters_found"]) val initialPages = (0 until matchingChapter.pages).map { i -> Page( @@ -1628,8 +1863,8 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou } 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("_") + chapter.url.startsWith("chapter_") -> chapter.url.substringAfter("chapter_").substringBefore("_").substringBefore("?") + chapter.url.contains("/Chapter/") -> chapter.url.substringAfter("/Chapter/").substringBefore("?") else -> throw IOException("Invalid chapter URL format") } @@ -1643,7 +1878,12 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou val chapterDetails = try { response.parseAs() } catch (e: Exception) { - throw IOException("${helper.intl["error_failed_parse_chapter"]}: ${e.message}") + val errorMessage = try { + "${intl["version_exceptions_chapters_parse"]}: ${e.message}" + } catch (ex: Exception) { + "Failed to parse chapter details: ${e.message}" + } + throw IOException(errorMessage) } return@withContext (0 until chapterDetails.pages).map { i -> @@ -1675,7 +1915,18 @@ 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, apiKey) } + // Refresh cache with search results for downstream lookups + series = result + result.forEach { helper.seriesMap.addSeries(it) } + + val allowed = preferences.allowedLibrariesSearch + val filteredSeries = if (allowed.isEmpty()) { + result + } else { + result.filter { it.libraryName != null && allowed.contains(it.libraryName) } + } + + val mangaList = filteredSeries.map { helper.createSeriesDto(it, apiUrl, apiKey) } MangasPage(mangaList, false) } catch (e: Exception) { Log.e(LOG_TAG, "Error parsing search results", e) @@ -1696,7 +1947,6 @@ 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() @@ -1743,23 +1993,10 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou UserRatingSeparator(), // Special filters section - Filter.Header(helper.intl["filters_special"]), - SpecialListFilter(helper.intl["filters_special_list"]), + Filter.Header(intl["filters_special"]), + SpecialListFilter(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) }), ) } @@ -1796,7 +2033,7 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou override fun headersBuilder(): Headers.Builder { if (jwtToken.isEmpty()) { doLogin() - if (jwtToken.isEmpty()) throw LoginErrorException(helper.intl["login_errors_header_token_empty"]) + if (jwtToken.isEmpty()) throw LoginErrorException(intl["login_errors_header_token_empty"]) } return Headers.Builder() .add("User-Agent", "Tachiyomi Kavita v${AppInfo.getVersionName()}") @@ -1816,35 +2053,20 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou ADDRESS_TITLE, "OPDS url", "", - helper.intl["pref_opds_summary"], + intl["pref_opds_summary"], ) - val enabledFiltersPref = MultiSelectListPreference(screen.context).apply { - key = KavitaConstants.toggledFiltersPref - title = helper.intl["pref_filters_title"] - summary = helper.intl["pref_filters_summary"] - entries = KavitaConstants.filterPrefEntries - entryValues = KavitaConstants.filterPrefEntriesValue - setDefaultValue(KavitaConstants.defaultFilterPrefEntries) - setOnPreferenceChangeListener { _, newValue -> - @Suppress("UNCHECKED_CAST") - val checkValue = newValue as Set - preferences.edit() - .putStringSet(KavitaConstants.toggledFiltersPref, checkValue) - .commit() - } - } val customSourceNamePref = EditTextPreference(screen.context).apply { key = KavitaConstants.customSourceNamePref - title = helper.intl["pref_customsource_title"] - summary = helper.intl["pref_edit_customsource_summary"] + title = intl["pref_customsource_title"] + summary = intl["pref_edit_customsource_summary"] setOnPreferenceChangeListener { _, newValue -> val res = preferences.edit() .putString(KavitaConstants.customSourceNamePref, newValue.toString()) .commit() Toast.makeText( screen.context, - helper.intl["restartapp_settings"], + intl["restartapp_settings"], Toast.LENGTH_LONG, ).show() Log.v(LOG_TAG, "[Preferences] Successfully modified custom source name: $newValue") @@ -1856,9 +2078,9 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou SwitchPreferenceCompat(screen.context).apply { key = GROUP_TAGS_PREF - title = helper.intl["pref_group_tags_title"] - summaryOn = helper.intl["pref_group_tags_summary_on"] - summaryOff = helper.intl["pref_group_tags_summary_off"] + title = intl["pref_group_tags_title"] + summaryOn = intl["pref_group_tags_summary_on"] + summaryOff = intl["pref_group_tags_summary_off"] setDefaultValue(GROUP_TAGS_DEFAULT) setOnPreferenceChangeListener { _, newValue -> @@ -1870,9 +2092,9 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou SwitchPreferenceCompat(screen.context).apply { key = LAST_VOLUME_COVER_PREF - title = helper.intl["pref_last_volume_cover_title"] - summaryOff = helper.intl["pref_last_volume_cover_summary_off"] - summaryOn = helper.intl["pref_last_volume_cover_summary_on"] + title = intl["pref_last_volume_cover_title"] + summaryOff = intl["pref_last_volume_cover_summary_off"] + summaryOn = intl["pref_last_volume_cover_summary_on"] setDefaultValue(LAST_VOLUME_COVER_DEFAULT) setOnPreferenceChangeListener { _, newValue -> @@ -1884,9 +2106,9 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou SwitchPreferenceCompat(screen.context).apply { key = SHOW_EPUB_PREF - title = helper.intl["pref_show_epub_title"] - summaryOff = helper.intl["pref_show_epub_summary_off"] - summaryOn = helper.intl["pref_show_epub_summary_on"] + title = intl["pref_show_epub_title"] + summaryOff = intl["pref_show_epub_summary_off"] + summaryOn = intl["pref_show_epub_summary_on"] setDefaultValue(SHOW_EPUB_DEFAULT) setOnPreferenceChangeListener { _, newValue -> @@ -1898,9 +2120,9 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou SwitchPreferenceCompat(screen.context).apply { key = RD_DATE_PREF - title = helper.intl["pref_rd_date_title"] - summaryOff = helper.intl["pref_rd_date_summary_off"] - summaryOn = helper.intl["pref_rd_date_summary_on"] + title = intl["pref_rd_date_title"] + summaryOff = intl["pref_rd_date_summary_off"] + summaryOn = intl["pref_rd_date_summary_on"] setDefaultValue(RD_DATE_DEFAULT) setOnPreferenceChangeListener { _, newValue -> @@ -1910,29 +2132,95 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou } }.also(screen::addPreference) + SwitchPreferenceCompat(screen.context).apply { + key = RELEASE_DATE_PREF + title = intl["pref_release_date_title"] + summaryOff = intl["pref_release_date_summary_off"] + summaryOn = intl["pref_release_date_summary_on"] + setDefaultValue(RELEASE_DATE_DEFAULT) + + setOnPreferenceChangeListener { _, newValue -> + preferences.edit() + .putBoolean(RELEASE_DATE_PREF, newValue as Boolean) + .commit() + } + }.also(screen::addPreference) + + screen.addEditTextPreference( + title = intl["pref_chapter_title_format_title"], + default = KavitaConstants.CHAPTER_TITLE_FORMAT_DEFAULT, + summary = intl.format("pref_chapter_title_format_summary", preferences.chapterTitleFormat), + dialogMessage = intl["pref_chapter_title_format_dialog"], + key = CHAPTER_TITLE_FORMAT_PREF, + restartRequired = false, + ) + + screen.addEditTextPreference( + title = intl["pref_scanlator_format_title"], + default = KavitaConstants.SCANLATOR_FORMAT_DEFAULT, + summary = intl.format("pref_scanlator_format_summary", preferences.scanlatorFormat), + dialogMessage = intl["pref_scanlator_format_dialog"], + key = SCANLATOR_FORMAT_PREF, + restartRequired = false, + ) + + // Library filtering preferences + val allowedLibrariesFeedPref = MultiSelectListPreference(screen.context).apply { + key = KavitaConstants.allowedLibrariesFeedPref + title = intl["pref_allowed_libraries_feed_title"] + summary = intl["pref_allowed_libraries_feed_summary"] + entries = libraryListMeta.map { it.name }.toTypedArray() + entryValues = libraryListMeta.map { it.name }.toTypedArray() + setDefaultValue(emptySet()) + setOnPreferenceChangeListener { _, newValue -> + @Suppress("UNCHECKED_CAST") + val checkValue = newValue as Set + preferences.edit() + .putStringSet(KavitaConstants.allowedLibrariesFeedPref, checkValue) + .commit() + } + } + + val allowedLibrariesSearchPref = MultiSelectListPreference(screen.context).apply { + key = KavitaConstants.allowedLibrariesSearchPref + title = intl["pref_allowed_libraries_search_title"] + summary = intl["pref_allowed_libraries_search_summary"] + entries = libraryListMeta.map { it.name }.toTypedArray() + entryValues = libraryListMeta.map { it.name }.toTypedArray() + setDefaultValue(emptySet()) + setOnPreferenceChangeListener { _, newValue -> + @Suppress("UNCHECKED_CAST") + val checkValue = newValue as Set + preferences.edit() + .putStringSet(KavitaConstants.allowedLibrariesSearchPref, checkValue) + .commit() + } + } + + screen.addPreference(allowedLibrariesFeedPref) + screen.addPreference(allowedLibrariesSearchPref) + SwitchPreferenceCompat(screen.context).apply { key = "reset_preferences" - title = helper.intl["pref_reset_title"] - summary = helper.intl["pref_reset_summary"] + title = intl["pref_reset_title"] + summary = intl["pref_reset_summary"] setOnPreferenceClickListener { AlertDialog.Builder(screen.context) - .setTitle(helper.intl["dialog_reset_title"]) - .setMessage(helper.intl["dialog_reset_message"]) - .setPositiveButton(helper.intl["dialog_reset_positive"]) { _, _ -> + .setTitle(intl["dialog_reset_title"]) + .setMessage(intl["dialog_reset_message"]) + .setPositiveButton(intl["dialog_reset_positive"]) { _, _ -> resetAllPreferences() Toast.makeText( screen.context, - helper.intl["toast_settings_reset"], + intl["toast_settings_reset"], Toast.LENGTH_LONG, ).show() } - .setNegativeButton(helper.intl["dialog_reset_negative"], null) + .setNegativeButton(intl["dialog_reset_negative"], null) .show() true } }.also(screen::addPreference) - - screen.addPreference(enabledFiltersPref) } private fun PreferenceScreen.editTextPreference( @@ -1965,16 +2253,16 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou Toast.makeText( context, - helper.intl["pref_opds_duplicated_source_url"] + ": " + opdsUrlInPref, + intl["pref_opds_duplicated_source_url"] + ": " + opdsUrlInPref, Toast.LENGTH_LONG, ).show() - throw OpdsurlExistsInPref(helper.intl["pref_opds_duplicated_source_url"] + opdsUrlInPref) + throw OpdsurlExistsInPref(intl["pref_opds_duplicated_source_url"] + opdsUrlInPref) } val res = preferences.edit().putString(title, newValue as String).commit() Toast.makeText( context, - helper.intl["restartapp_settings"], + intl["restartapp_settings"], Toast.LENGTH_LONG, ).show() setupLogin(newValue) @@ -1994,7 +2282,6 @@ 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 getToggledFilters() = preferences.getStringSet(KavitaConstants.toggledFiltersPref, KavitaConstants.defaultFilterPrefEntries)!! private val SharedPreferences.groupTags: Boolean get() = getBoolean(GROUP_TAGS_PREF, GROUP_TAGS_DEFAULT) @@ -2005,6 +2292,117 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou private val SharedPreferences.RdDate: Boolean get() = getBoolean(RD_DATE_PREF, RD_DATE_DEFAULT) + private val SharedPreferences.ReleaseDate: Boolean + get() = getBoolean(RELEASE_DATE_PREF, RELEASE_DATE_DEFAULT) + + private val SharedPreferences.chapterTitleFormat: String + get() = getString(CHAPTER_TITLE_FORMAT_PREF, CHAPTER_TITLE_FORMAT_DEFAULT) + .orEmpty() + .ifBlank { CHAPTER_TITLE_FORMAT_DEFAULT } + + private val SharedPreferences.scanlatorFormat: String + get() = getString(SCANLATOR_FORMAT_PREF, SCANLATOR_FORMAT_DEFAULT) ?: SCANLATOR_FORMAT_DEFAULT + + // Library filtering preferences + private val SharedPreferences.allowedLibrariesFeed: Set + get() = getStringSet(KavitaConstants.allowedLibrariesFeedPref, emptySet()) ?: emptySet() + + private val SharedPreferences.allowedLibrariesSearch: Set + get() = getStringSet(KavitaConstants.allowedLibrariesSearchPref, emptySet()) ?: emptySet() + + // Helper functions for library filtering + private fun isLibraryAllowedForFeed(manga: SManga): Boolean { + val allowedLibraries = preferences.allowedLibrariesFeed + if (allowedLibraries.isEmpty()) return true // No filtering applied + + val seriesId = helper.getIdFromUrl(manga.url) + val series = series.find { it.id == seriesId } + + // Debug logging + Log.d(LOG_TAG, "Feed filtering check:") + Log.d(LOG_TAG, " Allowed libraries: $allowedLibraries") + Log.d(LOG_TAG, " Manga URL: ${manga.url}") + Log.d(LOG_TAG, " Extracted seriesId: $seriesId") + Log.d(LOG_TAG, " Found series: ${series?.name}") + Log.d(LOG_TAG, " Series libraryName: ${series?.libraryName}") + + if (series == null) { + val allow = allowedLibraries.isEmpty() + Log.w( + LOG_TAG, + " Series not found in cache, ${if (allow) "no feed restriction set - allowing" else "blocking (cannot resolve library to apply feed filter)"}", + ) + return allow + } + + val libraryName = series.libraryName + val isAllowed = allowedLibraries.contains(libraryName) + Log.d(LOG_TAG, " Library '$libraryName' allowed: $isAllowed") + + return isAllowed + } + + private fun isLibraryAllowedForSearch(manga: SManga): Boolean { + val allowedLibraries = preferences.allowedLibrariesSearch + if (allowedLibraries.isEmpty()) return true // No filtering applied + + val seriesId = helper.getIdFromUrl(manga.url) + val series = series.find { it.id == seriesId } + if (series == null) { + Log.d(LOG_TAG, "Search filter: series not in cache for url=${manga.url}, blocking because allowedLibrariesSearch is set.") + return false + } + + val libraryName = series.libraryName + val allowed = libraryName != null && allowedLibraries.contains(libraryName) + Log.d(LOG_TAG, "Search filter: library='$libraryName' allowed=$allowed") + return allowed + } + + private fun isLibraryAllowedForSuggestions( + manga: SManga, + currentMangaLibrary: String? = null, + suggestedLibraryName: String? = null, + suggestedLibraryId: Int? = null, + ): Boolean { + val allowedLibrariesFeed = preferences.allowedLibrariesFeed + val allowedLibrariesSearch = preferences.allowedLibrariesSearch + val seriesId = helper.getIdFromUrl(manga.url) + + val libraryName = suggestedLibraryName + ?: series.find { it.id == seriesId }?.libraryName + ?: libraryListMeta.find { it.id == suggestedLibraryId }?.name + + if (libraryName == null) { + Log.d(LOG_TAG, "Suggestion '${manga.title}' BLOCKED: no library information available") + return false + } + + // Always allow suggestions from the same library as the current manga + if (currentMangaLibrary != null && libraryName == currentMangaLibrary) { + Log.d(LOG_TAG, "Suggestion '${manga.title}' ALLOWED: same library as current manga ($libraryName)") + return true + } + + // Prioritize feed restrictions (Latest/Popular) + if (allowedLibrariesFeed.isNotEmpty()) { + val isAllowedFeed = allowedLibrariesFeed.contains(libraryName) + Log.d(LOG_TAG, "Suggestion '${manga.title}' from '$libraryName' ${if (isAllowedFeed) "ALLOWED" else "BLOCKED"} by feed allowance") + return isAllowedFeed + } + + // Fall back to search restrictions when feed is unrestricted + if (allowedLibrariesSearch.isNotEmpty()) { + val isAllowedSearch = allowedLibrariesSearch.contains(libraryName) + Log.d(LOG_TAG, "Suggestion '${manga.title}' from '$libraryName' ${if (isAllowedSearch) "ALLOWED" else "BLOCKED"} by search allowance") + return isAllowedSearch + } + + // No restrictions configured + Log.d(LOG_TAG, "Suggestion '${manga.title}' ALLOWED: no library restrictions configured") + return true + } + // We strip the last slash since we will append it above private fun getPrefAddress(): String { var path = preferences.getString(ADDRESS_TITLE, "")!! @@ -2016,9 +2414,19 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou private fun resetAllPreferences() { try { + // Save the current template settings + val currentChapterTitleFormat = preferences.chapterTitleFormat + val currentScanlatorFormat = preferences.scanlatorFormat + // Clear all preferences preferences.edit().clear().commit() + // Restore template settings with defaults + preferences.edit() + .putString(CHAPTER_TITLE_FORMAT_PREF, currentChapterTitleFormat ?: KavitaConstants.CHAPTER_TITLE_FORMAT_DEFAULT) + .putString(SCANLATOR_FORMAT_PREF, currentScanlatorFormat ?: KavitaConstants.SCANLATOR_FORMAT_DEFAULT) + .commit() + // Reset all in-memory state (for debug) // jwtToken = "" // isLogged = false @@ -2066,6 +2474,15 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou private const val RD_DATE_PREF = "RdDate" const val RD_DATE_DEFAULT = false + + private const val RELEASE_DATE_PREF = "ReleaseDate" + const val RELEASE_DATE_DEFAULT = false + + private const val CHAPTER_TITLE_FORMAT_PREF = "chapterTitleFormat" + const val CHAPTER_TITLE_FORMAT_DEFAULT = "\$CleanTitle" + + private const val SCANLATOR_FORMAT_PREF = "scanlatorFormat" + const val SCANLATOR_FORMAT_DEFAULT = "\$Type" } /** @@ -2110,7 +2527,7 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou if (baseUrlSetup.toHttpUrlOrNull() == null) { Log.e(LOG_TAG, "Invalid URL $baseUrlSetup") - throw Exception("${helper.intl["login_errors_invalid_url"]}: $baseUrlSetup") + throw Exception("${intl["login_errors_invalid_url"]}: $baseUrlSetup") } preferences.edit().putString("BASEURL", baseUrlSetup).apply() preferences.edit().putString("APIKEY", apiKey).apply() @@ -2122,10 +2539,10 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou Log.d(LOG_TAG, "Attempting login with address: ${address.takeIf { it.isNotEmpty() } ?: "EMPTY"}") if (address.isEmpty()) { Log.e(LOG_TAG, "OPDS URL is empty or null") - throw IOException(helper.intl["pref_opds_must_setup_address"]) + throw IOException(intl["pref_opds_must_setup_address"]) } if (address.split("/opds/").size != 2) { - throw IOException(helper.intl["pref_opds_badformed_url"]) + throw IOException(intl["pref_opds_badformed_url"]) } if (jwtToken.isEmpty()) setupLogin() Log.v(LOG_TAG, "[Login] Starting login") @@ -2143,15 +2560,15 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou isLogged = true } catch (e: Exception) { Log.e(LOG_TAG, "Possible outdated kavita", e) - throw IOException(helper.intl["login_errors_parse_tokendto"]) + throw IOException(intl["login_errors_parse_tokendto"]) } } else { if (it.code == 500) { Log.e(LOG_TAG, "[LOGIN] login failed. There was some error -> Code: ${it.code}.Response message: ${it.message} Response body: $peekbody.") - throw LoginErrorException(helper.intl["login_errors_failed_login"]) + throw LoginErrorException(intl["login_errors_failed_login"]) } else { Log.e(LOG_TAG, "[LOGIN] login failed. Authentication was not successful -> Code: ${it.code}.Response message: ${it.message} Response body: $peekbody.") - throw LoginErrorException(helper.intl["login_errors_failed_login"]) + throw LoginErrorException(intl["login_errors_failed_login"]) } } } @@ -2160,63 +2577,126 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + // Add initialization state tracking + private var isInitialized = false + private var initializationError: Exception? = null + init { if (apiUrl.isNotBlank()) { scope.launch { try { - doLogin() + Log.d(LOG_TAG, "Starting Kavita extension initialization") - // Load all filters in parallel + // First attempt login with timeout + val loginJob = async { doLogin() } + try { + loginJob.await() + } catch (e: Exception) { + Log.e(LOG_TAG, "Login failed during initialization", e) + initializationError = e + return@launch + } + + // Load all filters in parallel with error handling val deferredFilters = listOf( async { - genresListMeta = loadMetadata("$apiUrl/Metadata/genres") - Log.d(LOG_TAG, "Loaded ${genresListMeta.size} genres") + try { + genresListMeta = loadMetadata("$apiUrl/Metadata/genres") + Log.d(LOG_TAG, "Loaded ${genresListMeta.size} genres") + } catch (e: Exception) { + Log.e(LOG_TAG, "Failed to load genres", e) + } }, async { - tagsListMeta = loadMetadata("$apiUrl/Metadata/tags") - Log.d(LOG_TAG, "Loaded ${tagsListMeta.size} tags") + try { + tagsListMeta = loadMetadata("$apiUrl/Metadata/tags") + Log.d(LOG_TAG, "Loaded ${tagsListMeta.size} tags") + } catch (e: Exception) { + Log.e(LOG_TAG, "Failed to load tags", e) + } }, async { - ageRatingsListMeta = loadMetadata("$apiUrl/Metadata/age-ratings") - Log.d(LOG_TAG, "Loaded ${ageRatingsListMeta.size} age ratings") + try { + ageRatingsListMeta = loadMetadata("$apiUrl/Metadata/age-ratings") + Log.d(LOG_TAG, "Loaded ${ageRatingsListMeta.size} age ratings") + } catch (e: Exception) { + Log.e(LOG_TAG, "Failed to load age ratings", e) + } }, async { - collectionsListMeta = loadMetadata("$apiUrl/Collection") - Log.d(LOG_TAG, "Loaded ${collectionsListMeta.size} collections") + try { + collectionsListMeta = loadMetadata("$apiUrl/Collection") + Log.d(LOG_TAG, "Loaded ${collectionsListMeta.size} collections") + } catch (e: Exception) { + Log.e(LOG_TAG, "Failed to load collections", e) + } }, async { - languagesListMeta = loadMetadata("$apiUrl/Metadata/languages") - Log.d(LOG_TAG, "Loaded ${languagesListMeta.size} languages") + try { + languagesListMeta = loadMetadata("$apiUrl/Metadata/languages") + Log.d(LOG_TAG, "Loaded ${languagesListMeta.size} languages") + } catch (e: Exception) { + Log.e(LOG_TAG, "Failed to load languages", e) + } }, async { - libraryListMeta = loadMetadata("$apiUrl/Library/libraries") - Log.d(LOG_TAG, "Loaded ${libraryListMeta.size} libraries") + try { + libraryListMeta = loadMetadata("$apiUrl/Library/libraries") + Log.d(LOG_TAG, "Loaded ${libraryListMeta.size} libraries") + } catch (e: Exception) { + Log.e(LOG_TAG, "Failed to load libraries", e) + } }, async { - peopleListMeta = loadMetadata("$apiUrl/Metadata/people") - Log.d(LOG_TAG, "Loaded ${peopleListMeta.size} people") + try { + peopleListMeta = loadMetadata("$apiUrl/Metadata/people") + Log.d(LOG_TAG, "Loaded ${peopleListMeta.size} people") + } catch (e: Exception) { + Log.e(LOG_TAG, "Failed to load people", e) + } }, async { - pubStatusListMeta = loadMetadata("$apiUrl/Metadata/publication-status") - Log.d(LOG_TAG, "Loaded ${pubStatusListMeta.size} publication statuses") + try { + pubStatusListMeta = loadMetadata("$apiUrl/Metadata/publication-status") + Log.d(LOG_TAG, "Loaded ${pubStatusListMeta.size} publication statuses") + } catch (e: Exception) { + Log.e(LOG_TAG, "Failed to load publication statuses", e) + } }, async { - smartFilters = loadMetadata("$apiUrl/filter") - Log.d(LOG_TAG, "Loaded ${smartFilters.size} smart filters") + try { + smartFilters = loadMetadata("$apiUrl/filter") + Log.d(LOG_TAG, "Loaded ${smartFilters.size} smart filters") + } catch (e: Exception) { + Log.e(LOG_TAG, "Failed to load smart filters", e) + } }, ) deferredFilters.awaitAll() - // Get server info - val serverInfoDto = client.newCall(GET("$apiUrl/Server/server-info-slim", headersBuilder().build())) - .execute() - .parseAs() - Log.d(LOG_TAG, "Kavita version: ${serverInfoDto.kavitaVersion}") + // Get server info with error handling + try { + val serverInfoDto = client.newCall(GET("$apiUrl/Server/server-info-slim", headersBuilder().build())) + .execute() + .parseAs() + Log.d(LOG_TAG, "Kavita version: ${serverInfoDto.kavitaVersion}") + + // Mark as successfully initialized + isInitialized = true + Log.d(LOG_TAG, "Kavita extension initialization completed successfully") + } catch (e: Exception) { + Log.e(LOG_TAG, "Failed to get server info", e) + // Don't fail initialization for server info + isInitialized = true + } } catch (e: Exception) { - Log.e(LOG_TAG, "Initialization error", e) + Log.e(LOG_TAG, "Critical initialization error", e) + initializationError = e } } + } else { + Log.w(LOG_TAG, "API URL is blank, skipping initialization") } } @@ -2234,12 +2714,55 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou collectionsListMeta = emptyList() smartFilters = emptyList() cachedReadingLists = emptyList() - Log.d(LOG_TAG, "Cleanup completed") + + // Clear metadata cache + metadataCache.clear() + libraryTypeCache.clear() + + Log.d(LOG_TAG, "Cleanup completed - cache cleared") } + // Cached metadata loading with TTL private inline fun loadMetadata(url: String): T { - return client.newCall(GET(url, headersBuilder().build())) + cleanupExpiredCache() + + val cacheKey = "${T::class.simpleName}_$url" + + @Suppress("UNCHECKED_CAST") + val cachedEntry = metadataCache[cacheKey] as? CacheEntry + + if (cachedEntry != null && !cachedEntry.isExpired(CACHE_TTL)) { + Log.d(LOG_TAG, "Using cached metadata for ${T::class.simpleName}") + return cachedEntry.data + } + + Log.d(LOG_TAG, "Fetching fresh metadata for ${T::class.simpleName} from $url") + val data = client.newCall(GET(url, headersBuilder().build())) .execute() - .parseAs() + .parseAs() + + metadataCache[cacheKey] = CacheEntry(data) + return data + } + + // Clear expired cache entries + private fun cleanupExpiredCache() { + val now = System.currentTimeMillis() + val expiredKeys = mutableListOf() + metadataCache.entries.forEach { (key, entry) -> + if (entry.isExpired(CACHE_TTL)) { + expiredKeys.add(key) + } + } + expiredKeys.forEach { key -> + metadataCache.remove(key) + } + } + + // Get cache statistics for debugging + private fun getCacheStats(): String { + val totalEntries = metadataCache.size + val expiredEntries = metadataCache.values.count { it.isExpired(CACHE_TTL) } + return "Cache stats: $totalEntries total entries, $expiredEntries expired" } } 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 8b2fdabf..a733fed4 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 @@ -6,67 +6,16 @@ object KavitaConstants { 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", - "Publisher", "Character", "Translator", - ) - - // toggle filters - const val toggledFiltersPref = "toggledFilters" - val filterPrefEntries = arrayOf( - "Sort Options", - "Special Lists", - "Format", - "Libraries", - "Read Status", - "Genres", - "Tags", - "Collections", - "Languages", - "Publication Status", - "Rating", - "Age Rating", - "ReleaseYearRange", - "Random", - "Read Progress", - ) - val filterPrefEntriesValue = arrayOf( - "Sort Options", - "Special Lists", - "Format", - "Libraries", - "Read Status", - "Genres", - "Tags", - "Collections", - "Languages", - "Publication Status", - "Rating", - "Age Rating", - "ReleaseYearRange", - "Random", - "Read Progress", - ) - val defaultFilterPrefEntries = setOf( - "Sort Options", - "Special Lists", - "Format", - "Libraries", - "Read Status", - "Genres", - "Tags", - "Collections", - "Languages", - "Publication Status", - "Rating", - "Age Rating", - "ReleaseYearRange", - "Random", - "Read Progress", - ) + const val SPECIAL_NUMBER_OFFSET = 10000000000f const val customSourceNamePref = "customSourceName" const val noSmartFilterSelected = "No smart filter loaded" + + // Template formatting preferences + const val CHAPTER_TITLE_FORMAT_DEFAULT = "\$CleanTitle" + const val SCANLATOR_FORMAT_DEFAULT = "\$Type" + + // Library filtering preferences + const val allowedLibrariesFeedPref = "allowedLibrariesFeed" + const val allowedLibrariesSearchPref = "allowedLibrariesSearch" } 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 f5a740d6..4534d41a 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,19 +1,22 @@ package eu.kanade.tachiyomi.extension.all.kavita import android.annotation.SuppressLint +import android.util.Log 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 import eu.kanade.tachiyomi.extension.all.kavita.dto.PaginationInfo import eu.kanade.tachiyomi.extension.all.kavita.dto.SeriesDto import eu.kanade.tachiyomi.extension.all.kavita.dto.VolumeDto -import eu.kanade.tachiyomi.lib.i18n.Intl import eu.kanade.tachiyomi.source.model.SChapter 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 +import java.util.concurrent.ConcurrentHashMap class KavitaHelper { val json = Json { @@ -26,6 +29,22 @@ class KavitaHelper { prettyPrint = true } + // Cache for series information + private val seriesCache = ConcurrentHashMap() + + // Inner class to provide access to series info + inner class SeriesMapper { + fun addSeries(series: SeriesDto) { + seriesCache[series.id] = series + } + + fun getSeries(id: Int): SeriesDto? = seriesCache[id] + + fun clear() = seriesCache.clear() + } + + val seriesMap = SeriesMapper() + fun hasNextPage(response: Response): Boolean { val paginationHeader = response.header("Pagination") var hasNextPage = false @@ -38,24 +57,18 @@ class KavitaHelper { fun getIdFromUrl(url: String): Int { return try { - val id = url.substringAfterLast("/").toInt() -// Log.d("KavitaHelper", "Extracted ID $id from URL: $url") - id + val lastSegment = url.substringAfterLast("/") + val cleaned = lastSegment + .substringBefore("?") + .substringBefore("#") + .substringBefore("/") + cleaned.toInt() } 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 } } - // @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, apiKey: String): SManga = SManga.create().apply { url = "$baseUrl/Series/${obj.id}" @@ -70,119 +83,232 @@ class KavitaHelper { libraryType: LibraryTypeEnum? = null, isWebtoon: Boolean = false, mangaTitle: String = "", + useReleaseDate: Boolean = false, + chapterTitleFormat: String = "\$CleanTitle", + scanlatorFormat: String = "\$Type", + volumePageCount: Int? = null, ): SChapter = SChapter.create().apply { val type = ChapterType.of(chapter, volume, libraryType) - val titleName = chapter.titleName + 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" - 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 }, - ), - )}" + // Always ensure we have the series name, even if mangaTitle is blank + val seriesName = mangaTitle.ifBlank { + seriesMap.getSeries(volume.seriesId)?.name ?: "" + } - else -> - "$volumeLabel ${formatVolumeNumber(volume)} $chapterLabel $chapterNum" - } + // Debug logging + Log.d("KavitaHelper", "Chapter ${chapter.id}: seriesName = '$seriesName', mangaTitle = '$mangaTitle'") + + name = when (type) { + ChapterType.Regular -> { + val chapterNum = formatChapterNumber(chapter) + val volNum = formatVolumeNumber(volume) + val cleanedTitle = cleanChapterTitle( + titleName, + ChapterTitleContext( + mangaTitle = mangaTitle, + chapterNumber = chapterNum, + volumeNumber = volNum, + volumeName = volume.name.ifBlank { title }, + isWebtoon = isWebtoon, + ), + ) + val finalCleanTitle = cleanedTitle.ifBlank { + defaultCleanTitle(type, chapterNum, volNum, isWebtoon) } - ChapterType.SingleFileVolume -> { - val volumeNumber = formatVolumeNumber(volume) - val cleanVolumeName = volume.name.trim() + val variables = ChapterTemplateVariables( + type = if (isWebtoon) "Episode" else "Chapter", + number = chapterNum, + title = titleName, + pages = chapter.pages, + fileSize = chapter.files?.sumOf { it.bytes }?.toDouble() ?: 0.0, + volumeNumber = volNum, + cleanTitle = finalCleanTitle, + seriesName = seriesName, // SeriesName should NOT be processed through cleanChapterTitle + libraryName = volume.let { v -> + seriesMap.getSeries(v.seriesId)?.libraryName ?: "" + }, + formats = chapter.files?.firstOrNull()?.extension?.uppercase() ?: "", + created = chapter.created, + releaseDate = chapter.releaseDate, + ) - // 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") + // Debug logging for Chapter Title Format variables + Log.d("KavitaHelper", "ChapterTitleFormat variables for chapter ${chapter.id}:") + Log.d("KavitaHelper", " chapterTitleFormat: '$chapterTitleFormat'") + Log.d("KavitaHelper", " seriesName: '${variables.seriesName}'") + Log.d("KavitaHelper", " libraryName: '${variables.libraryName}'") + Log.d("KavitaHelper", " title: '${variables.title}'") + Log.d("KavitaHelper", " cleanTitle: '${variables.cleanTitle}'") + + val processedName = processChapterTemplate(chapterTitleFormat, variables) + Log.d("KavitaHelper", "Final processed chapter name: '$processedName'") + processedName + } + + ChapterType.SingleFileVolume -> { + val volumeNumber = formatVolumeNumber(volume) + + val variables = ChapterTemplateVariables( + type = if (isWebtoon) "Season" else "Volume", + number = volumeNumber, + title = volume.name, + pages = volume.pages, + fileSize = volume.chapters.flatMap { it.files ?: emptyList() }.sumOf { it.bytes }.toDouble(), + volumeNumber = volumeNumber, + cleanTitle = when { + volume.name.isNotBlank() && !volume.name.matches(Regex("^\\d+$")) -> volume.name + volume.name.isNotBlank() && volume.name.matches(Regex("^\\d+$")) -> { + // Handle case where volume name is only a number (e.g., "2") + if (isWebtoon) { + val volNum = volume.name.toIntOrNull()?.toString() ?: volume.name + "Season $volNum" + } else { + val volNum = volume.name.toIntOrNull()?.toString() ?: volume.name + "Volume $volNum" + } } - // 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") + if (isWebtoon) "Season $volumeNumber" else "Volume $volumeNumber" } - } + }, + seriesName = seriesName, + libraryName = seriesMap.getSeries(volume.seriesId)?.libraryName ?: "", + formats = volume.chapters.flatMap { it.files ?: emptyList() }.firstOrNull()?.extension?.uppercase() ?: "", + created = volume.created, + releaseDate = "", + ) + + val processedName = processChapterTemplate(chapterTitleFormat, variables) + Log.d("KavitaHelper", "Final processed SFV name: '$processedName'") + processedName + } + + ChapterType.Special -> { + val specialTitle = when { + title.isNotBlank() -> title + range.isNotBlank() -> range + else -> "Special" } - ChapterType.Special -> { - cleanChapterTitle( - when { - title.isNotBlank() -> title - range.isNotBlank() -> range - else -> "Special" - }, - ChapterTitleContext( - mangaTitle = mangaTitle, - volumeName = volume.name, - ), - ) + val variables = ChapterTemplateVariables( + type = "Special", + number = "", + title = specialTitle, + pages = chapter.pages, + fileSize = chapter.files?.sumOf { it.bytes }?.toDouble() ?: 0.0, + volumeNumber = formatVolumeNumber(volume), + cleanTitle = when { + titleName.isNotBlank() && !titleName.matches(Regex("^\\d+$")) -> titleName + titleName.isNotBlank() && titleName.matches(Regex("^\\d+$")) -> { + // Handle case where titleName is only a number (e.g., "2") + val num = titleName.toIntOrNull()?.toString()?.padStart(2, '0') ?: titleName + "Special $num" + } + title.isNotBlank() && !title.matches(Regex("^\\d+$")) -> title + title.isNotBlank() && title.matches(Regex("^\\d+$")) -> { + // Handle case where title is only a number (e.g., "2") + val num = title.toIntOrNull()?.toString()?.padStart(2, '0') ?: title + "Special $num" + } + range.isNotBlank() && !range.matches(Regex("^\\d+$")) -> range + range.isNotBlank() && range.matches(Regex("^\\d+$")) -> { + // Handle case where range is only a number (e.g., "2") + val num = range.toIntOrNull()?.toString()?.padStart(2, '0') ?: range + "Special $num" + } + else -> "Special" + }, + seriesName = seriesName, + libraryName = seriesMap.getSeries(volume.seriesId)?.libraryName ?: "", + formats = chapter.files?.firstOrNull()?.extension?.uppercase() ?: "", + created = chapter.created, + releaseDate = chapter.releaseDate, + ) + + val processedName = processChapterTemplate(chapterTitleFormat, variables) + Log.d("KavitaHelper", "Final processed Special name: '$processedName'") + processedName + } + + ChapterType.Chapter -> { + val chapterNum = formatChapterNumber(chapter) + val cleanedTitle = cleanChapterTitle( + titleName, + ChapterTitleContext( + mangaTitle = mangaTitle, + chapterNumber = chapterNum, + volumeName = volume.name, + isWebtoon = isWebtoon, + ), + ) + val finalCleanTitle = cleanedTitle.ifBlank { + defaultCleanTitle(type, chapterNum, formatVolumeNumber(volume), isWebtoon) } - ChapterType.Chapter -> { - val chapterNum = formatChapterNumber(chapter) - when { - titleName.isNotBlank() && titleName.any { it.isLetter() } -> - "$chapterNum - ${cleanChapterTitle( - titleName, - ChapterTitleContext( - mangaTitle = mangaTitle, - chapterNumber = chapterNum, - volumeName = volume.name, - ), - )}" + val variables = ChapterTemplateVariables( + type = if (isWebtoon) "Episode" else "Chapter", + number = chapterNum, + title = titleName, + pages = chapter.pages, + fileSize = chapter.files?.sumOf { it.bytes }?.toDouble() ?: 0.0, + volumeNumber = "", + cleanTitle = finalCleanTitle, + seriesName = seriesName, + libraryName = seriesMap.getSeries(volume.seriesId)?.libraryName ?: "", + formats = chapter.files?.firstOrNull()?.extension?.uppercase() ?: "", + created = chapter.created, + releaseDate = chapter.releaseDate, + ) - else -> - "$chapterLabel $chapterNum" - } - } + val processedName = processChapterTemplate(chapterTitleFormat, variables) + Log.d("KavitaHelper", "Final processed Chapter name: '$processedName'") + processedName + } - 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, - ), - ) - } + ChapterType.Issue -> { + val issueNum = chapter.number.toIntOrNull()?.toString()?.padStart(3, '0') ?: chapter.number + + val variables = ChapterTemplateVariables( + type = "Issue", + number = issueNum, + title = titleName, + pages = chapter.pages, + fileSize = chapter.files?.sumOf { it.bytes }?.toDouble() ?: 0.0, + volumeNumber = formatVolumeNumber(volume), + cleanTitle = when { + titleName.isNotBlank() && !titleName.matches(Regex("^\\d+$")) && titleName.any { it.isLetter() } -> titleName + titleName.isNotBlank() && titleName.matches(Regex("^\\d+$")) -> { + // Handle case where titleName is only a number (e.g., "2") + val num = titleName.toIntOrNull()?.toString()?.padStart(3, '0') ?: titleName + "Issue #$num" + } + else -> "Issue #$issueNum" + }.ifBlank { + defaultCleanTitle(type, issueNum, formatVolumeNumber(volume), isWebtoon) + }, + seriesName = seriesName, + libraryName = seriesMap.getSeries(volume.seriesId)?.libraryName ?: "", + formats = chapter.files?.firstOrNull()?.extension?.uppercase() ?: "", + created = chapter.created, + releaseDate = chapter.releaseDate, + ) + + val processedName = processChapterTemplate(chapterTitleFormat, variables) + Log.d("KavitaHelper", "Final processed Issue name: '$processedName'") + processedName } } - // Handle decimal chapter numbers properly chapter_number = when { - 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 + // Regular, Chapter, Issue (1.0, 2.0...) + type == ChapterType.Regular || + type == ChapterType.Chapter || + type == ChapterType.Issue -> { if (chapter.minNumber % 1 != 0.0) { chapter.minNumber.toFloat() } else { @@ -190,9 +316,9 @@ class KavitaHelper { } } - // For volumes/specials in mixed content, place them below chapter 0 + // Volumes and Specials (< 1.0) else -> { - val volumeNum = try { + val rawNum = try { if (volume.minNumber % 1 != 0.0) { volume.minNumber.toFloat() } else { @@ -201,33 +327,116 @@ class KavitaHelper { } catch (e: NumberFormatException) { 0f } - volumeNum / KavitaConstants.VOLUME_NUMBER_OFFSET + + when (type) { + // Volume 1 -> 0.0001 + ChapterType.SingleFileVolume -> rawNum / KavitaConstants.VOLUME_NUMBER_OFFSET + // Special 100k -> 0.00001 + ChapterType.Special -> rawNum / KavitaConstants.SPECIAL_NUMBER_OFFSET + else -> rawNum + } } } - url = when { - type == ChapterType.SingleFileVolume && singleFileVolumeNumber != null -> - "volume_${volume.id}" - else -> "chapter_${chapter.id}" - } + url = "/Chapter/${chapter.id}" - if (chapter.fileCount > 1) { - // salt/offset to recognize chapters with new merged part-chapters as new and hence unread + // Only apply salt to Chapters. + // DO NOT apply to Volume or Special as it corrupts the 0.0001/0.00001 sorting logic. + if (chapter.fileCount > 1 && + (type == ChapterType.Regular || type == ChapterType.Chapter || type == ChapterType.Issue) + ) { chapter_number += 0.001f * chapter.fileCount - url = "${url}_${chapter.fileCount}" + url = "$url?split=${chapter.fileCount}" } - date_upload = parseDateSafe(chapter.created) - scanlator = when (type) { - ChapterType.SingleFileVolume -> if (isWebtoon) "Season" else "Volume" - ChapterType.Special -> "Special" - ChapterType.Issue -> "Issue" - ChapterType.Chapter -> if (isWebtoon) "Episode" else "Chapter" - ChapterType.Regular -> if (isWebtoon) "Episode" else "Chapter" + date_upload = if (useReleaseDate && chapter.releaseDate.isNotBlank()) { + parseDateSafe(chapter.releaseDate) + } else { + parseDateSafe(chapter.created) } + + // Prepare template variables for scanlator + val scanlatorVariables = ChapterTemplateVariables( + type = when (type) { + ChapterType.SingleFileVolume -> if (isWebtoon) "Season" else "Volume" + ChapterType.Special -> "Special" + ChapterType.Issue -> "Issue" + ChapterType.Chapter -> if (isWebtoon) "Episode" else "Chapter" + ChapterType.Regular -> if (isWebtoon) "Episode" else "Chapter" + }, + number = when (type) { + ChapterType.Regular, ChapterType.Chapter, ChapterType.Issue -> formatChapterNumber(chapter) + ChapterType.SingleFileVolume -> formatVolumeNumber(volume) + else -> "" + }, + title = titleName, + pages = if (singleFileVolumeNumber != null && volumePageCount != null) { + volumePageCount + } else { + chapter.pages + }, + fileSize = if (singleFileVolumeNumber != null) { + volume.chapters.flatMap { it.files ?: emptyList() }.sumOf { it.bytes }.toDouble() + } else { + chapter.files?.sumOf { it.bytes }?.toDouble() ?: 0.0 + }, + volumeNumber = formatVolumeNumber(volume), + cleanTitle = when { + titleName.isNotBlank() && !titleName.matches(Regex("^\\d+$")) -> titleName + titleName.isNotBlank() && titleName.matches(Regex("^\\d+$")) -> { + // Handle numeric titles + when (type) { + ChapterType.Issue -> { + val num = titleName.toIntOrNull()?.toString()?.padStart(3, '0') ?: titleName + "Issue #$num" + } + else -> { + val num = titleName.toIntOrNull()?.toString()?.padStart(2, '0') ?: titleName + if (isWebtoon) "Episode $num" else "Chapter $num" + } + } + } + else -> { + // Generate fallback based on chapter type and volume info + val chNum = when (type) { + ChapterType.Regular, ChapterType.Chapter -> formatChapterNumber(chapter) + ChapterType.Issue -> chapter.number.toIntOrNull()?.toString()?.padStart(3, '0') ?: chapter.number + else -> "" + } + val volNum = formatVolumeNumber(volume) + when { + isWebtoon && volNum != "0" -> "Season $volNum Episode $chNum" + isWebtoon -> "Episode $chNum" + volNum != "0" -> "Volume $volNum Chapter $chNum" + else -> "Chapter $chNum" + } + } + }, + seriesName = seriesName, + libraryName = volume.let { v -> + seriesMap.getSeries(v.seriesId)?.libraryName ?: "" + }, + formats = if (singleFileVolumeNumber != null) { + volume.chapters.flatMap { it.files ?: emptyList() }.firstOrNull()?.extension?.uppercase() ?: "" + } else { + chapter.files?.firstOrNull()?.extension?.uppercase() ?: "" + }, + created = chapter.created, + releaseDate = chapter.releaseDate, + ) + + // Debug logging for Scanlator Format variables + Log.d("KavitaHelper", "ScanlatorFormat variables for chapter ${chapter.id}:") + Log.d("KavitaHelper", " scanlatorFormat: '$scanlatorFormat'") + Log.d("KavitaHelper", " seriesName: '${scanlatorVariables.seriesName}'") + Log.d("KavitaHelper", " libraryName: '${scanlatorVariables.libraryName}'") + Log.d("KavitaHelper", " title: '${scanlatorVariables.title}'") + Log.d("KavitaHelper", " cleanTitle: '${scanlatorVariables.cleanTitle}'") + + scanlator = processChapterTemplate(scanlatorFormat, scanlatorVariables) } - private fun formatVolumeNumber(volume: VolumeDto): String { + internal fun formatVolumeNumber(volume: VolumeDto): String { return when { volume.maxNumber > volume.minNumber -> "${removeTrailingZero(volume.minNumber)}-${removeTrailingZero(volume.maxNumber)}" @@ -235,7 +444,7 @@ class KavitaHelper { } } - private fun formatChapterNumber(chapter: ChapterDto, padLength: Int = 2): String { + internal fun formatChapterNumber(chapter: ChapterDto, padLength: Int = 2): String { val chapterNum = when { chapter.maxNumber > chapter.minNumber -> "${removeTrailingZero(chapter.minNumber)}-${removeTrailingZero(chapter.maxNumber)}" @@ -252,7 +461,7 @@ class KavitaHelper { // Helper function to remove .0 from whole numbers while preserving actual decimals @SuppressLint("DefaultLocale") - private fun removeTrailingZero(number: Double): String { + internal fun removeTrailingZero(number: Double): String { return if (number % 1 == 0.0) { number.toInt().toString() } else { @@ -261,28 +470,158 @@ class KavitaHelper { } } - private fun cleanChapterTitle( + internal fun cleanChapterTitle( originalTitle: String, context: ChapterTitleContext? = null, ): String { var title = originalTitle.trim() val mangaTitle = context?.mangaTitle ?: "" + val isWebtoon = context?.isWebtoon ?: false + + // Don't modify titles that start with a number followed by a pattern like "12. It's my fate" + if (title.matches(Regex("^\\d+\\..*"))) { + return title + } // 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() + // Helper function to check if we have to shorten terms + fun hasMoreContent(currentTitle: String, match: MatchResult): Boolean { + val beforeMatch = currentTitle.take(match.range.first).trim() + val afterMatch = currentTitle.substring(match.range.last + 1).trim() + return (beforeMatch.isNotEmpty() && beforeMatch.any { it.isLetterOrDigit() }) || + (afterMatch.isNotEmpty() && afterMatch.any { it.isLetterOrDigit() }) + } - // 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() + // Process patterns with conditional shortening + title = title.replace(Regex("""(?i)\bvolume\s+(\d+)""")) { match -> + val number = match.groupValues[1].padStart(2, '0') + if (isWebtoon) { + if (hasMoreContent(title, match)) "S. $number" else "Season $number" + } else { + if (hasMoreContent(title, match)) "Vol. $number" else "Volume $number" + } + } - return title.ifBlank { originalTitle } + title = title.replace(Regex("""(?i)\bvol\s+(\d+)""")) { match -> + val number = match.groupValues[1].padStart(2, '0') + if (isWebtoon) { + if (hasMoreContent(title, match)) "S. $number" else "Season $number" + } else { + if (hasMoreContent(title, match)) "Vol. $number" else "Vol. $number" + } + } + + // Chapter/Episode handling + title = title.replace(Regex("""(?i)\bchapter\s+(\d+)""")) { match -> + val number = match.groupValues[1].padStart(2, '0') + if (isWebtoon) { + if (hasMoreContent(title, match)) "Ep. $number" else "Episode $number" + } else { + if (hasMoreContent(title, match)) "Ch. $number" else "Chapter $number" + } + } + + title = title.replace(Regex("""(?i)\bch\s+(\d+)""")) { match -> + val number = match.groupValues[1].padStart(2, '0') + val word = if (isWebtoon && hasMoreContent(title, match)) "Ep." else if (isWebtoon) "Episode" else "Ch." + "$word $number" + } + + title = title.replace(Regex("""(?i)\bepisode\s+(\d+)""")) { match -> + val number = match.groupValues[1].padStart(2, '0') + if (hasMoreContent(title, match)) "Ep. $number" else "Episode $number" + } + + title = title.replace(Regex("""(?i)\bep\s+(\d+)""")) { match -> + val number = match.groupValues[1].padStart(2, '0') + if (hasMoreContent(title, match)) "Ep. $number" else "Ep. $number" + } + + // cXXX pattern always shortened if more content + title = title.replace(Regex("""(?i)\bc(\d+)\b""")) { match -> + val number = match.groupValues[1].padStart(2, '0') + val term = if (isWebtoon) "Ep." else "Ch." + if (hasMoreContent(title, match)) "$term $number" else "c$number" + } + + // Clean up spaces + title = title.replace(Regex("""\s+"""), " ").trim() + + // Final check: If title is just "Ch. 08" or "Ep. 08" with no other content, expand it + val justNumberedPattern = Regex("""^(Ch|Ep|Vol|S)\. (\d{2})$""", RegexOption.IGNORE_CASE) + title = title.replace(justNumberedPattern) { match -> + val type = match.groupValues[1].lowercase() + val number = match.groupValues[2] + when { + isWebtoon -> { + when (type) { + "ep", "ch" -> "Episode ${number.toInt()}" + "vol", "s" -> "Season ${number.toInt()}" + else -> "Episode ${number.toInt()}" + } + } + else -> { + when (type) { + "ep" -> "Episode ${number.toInt()}" + "vol" -> "Volume ${number.toInt()}" + "s" -> "Season ${number.toInt()}" + else -> "Chapter ${number.toInt()}" + } + } + } + } + + // Remove leading numbers if no other patterns present + if (!title.contains(Regex("""(?i)(vol|volume|ch|chapter|ep|episode|c\d+)"""))) { + title = title.replace(Regex("""^\d+(\s*-\s*)?"""), "").trim() + } + + // If title becomes blank, return the original title to prevent empty titles + val finalTitle = title.ifBlank { originalTitle } + + // Debug logging for troubleshooting + if (finalTitle.isBlank() && originalTitle.isNotBlank()) { + Log.d("KavitaHelper", "Warning: Title processing resulted in blank title for '$originalTitle'. Using original.") + } + + return finalTitle + } + + /** + * Checks if a title contains chapter/episode/volume numbering patterns + */ + internal fun hasNumberingPattern(title: String, isWebtoon: Boolean = false): Boolean { + return title.contains(Regex("""(?i)(ch\.?\s*\d+|\bchapter\s+\d+|\bc\d+\b|\bep\.?\s*\d+|\bepisode\s+\d+)""")) || + title.contains(Regex("""(?i)(vol\.?\s*\d+|\bvolume\s+\d+)""")) + } + + internal fun defaultCleanTitle( + type: ChapterType, + number: String, + volumeNumber: String, + isWebtoon: Boolean, + ): String { + val safeNumber = number.ifBlank { "01" } + val safeVolume = volumeNumber.ifBlank { "0" } + return when (type) { + ChapterType.Regular -> { + when { + isWebtoon -> "Episode $safeNumber" + safeVolume != "0" -> "Volume $safeVolume Chapter $safeNumber" + else -> "Chapter $safeNumber" + } + } + ChapterType.Chapter -> { + if (isWebtoon) "Episode $safeNumber" else "Chapter $safeNumber" + } + ChapterType.Issue -> "Issue #${safeNumber.padStart(3, '0')}" + ChapterType.SingleFileVolume -> if (isWebtoon) "Season $safeVolume" else "Volume $safeVolume" + ChapterType.Special -> "Special ${safeNumber.ifBlank { safeVolume.padStart(2, '0') }}" + } } /** @@ -291,27 +630,260 @@ class KavitaHelper { data class ChapterTitleContext( val mangaTitle: String = "", val chapterNumber: String = "", + val volumeNumber: String = "", val volumeName: String = "", val isWebtoon: Boolean = false, ) - fun isWebtoonOrLongStrip(tags: List): Boolean { - return tags.any { - val normalized = it.trim().lowercase() + fun isWebtoonOrLongStrip( + genres: List, + tags: List, + libraryName: String? = null, + format: String? = null, + demographic: String? = null, + allTags: List? = null, + ): Boolean { + val allFields = mutableListOf().apply { + addAll(genres) + addAll(tags) + libraryName?.let { add(it) } + format?.let { add(it) } + demographic?.let { add(it) } + allTags?.let { addAll(it) } + } + + return allFields.any { field -> + val normalized = field.trim().lowercase() normalized.contains("webtoon") || normalized.contains("long strip") } } - val intl = Intl( - language = Locale.getDefault().toString(), - baseLanguage = "en", - availableLanguages = KavitaInt.AVAILABLE_LANGS, - classLoader = this::class.java.classLoader!!, - createMessageFileName = { lang -> - when (lang) { - KavitaInt.SPANISH_LATAM -> Intl.createDefaultMessageFileName(KavitaInt.SPANISH) - else -> Intl.createDefaultMessageFileName(lang) + // Legacy compatibility + fun isWebtoonOrLongStrip(tags: List): Boolean { + return isWebtoonOrLongStrip( + genres = emptyList(), + tags = tags, + libraryName = null, + format = null, + demographic = null, + allTags = null, + ) + } + + /** + * Extracts demographic and format information from genres and tags + * @param genres List of genres + * @param tags List of tags + * @param format List of formats + * @return Triple of found demographic (if any), and filtered lists + */ + fun extractDemographicAndFormat(genres: List, tags: List): Triple, Pair, List>> { + val demographicKeywords = listOf("Shounen", "Seinen", "Josei", "Shoujo", "Hentai", "Doujinshi") + val formatKeywords = listOf("Long Strip", "4-koma", "4 Koma", "Full Color", "Full Colour", "Color", "Colour", "Graphic Novel", "Manga", "Manhua", "Manhwa") + + val foundDemographic = demographicKeywords.firstOrNull { demo -> + genres.any { it.equals(demo, ignoreCase = true) } || + tags.any { it.equals(demo, ignoreCase = true) } + }?.let { demo -> + // Get the actual matched keyword with correct case + genres.find { it.equals(demo, ignoreCase = true) } + ?: tags.find { it.equals(demo, ignoreCase = true) } + } + + // Find ALL matching formats, preserving original case + val foundFormats = formatKeywords.mapNotNull { formats -> + genres.find { it.equals(formats, ignoreCase = true) } + ?: tags.find { it.equals(formats, ignoreCase = true) } + }.distinct() + + val filteredGenres = genres.filterNot { genre -> + genre.equals(foundDemographic, ignoreCase = true) || + foundFormats.any { it.equals(genre, ignoreCase = true) } + } + val filteredTags = tags + .filterNot { tag -> + tag.equals(foundDemographic, ignoreCase = true) || + foundFormats.any { it.equals(tag, ignoreCase = true) } } - }, + .filterNot { tag -> filteredGenres.any { genre -> genre.equals(tag, ignoreCase = true) } } + + return Triple(foundDemographic, foundFormats, filteredGenres to filteredTags) + } + + /** + * Builds genre string based on user preferences + * @param libraryName Library name for grouping + * @param demographic Demographic information + * @param formats Format information (list of all formats) + * @param genres List of genres + * @param tags List of tags + * @param groupTags Whether to group tags with prefixes + * @return Formatted genre string + */ + fun buildGenreString( + libraryName: String?, + demographic: String?, + formats: List, + genres: List, + tags: List, + groupTags: Boolean, + ): String { + return if (groupTags) { + buildList { + libraryName?.takeIf { it.isNotEmpty() }?.let { add("Type:$it") } + demographic?.let { add("Demographic:$it") } + formats.forEach { format -> + if (format.isNotBlank()) { + add("Formats:$format") + } + } + genres.forEach { add("Genres:$it") } + tags.forEach { add("Tags:$it") } + }.joinToString(", ") + } else { + (genres + tags + formats).toSet().toList().sorted().joinToString(", ") + } + } + + /** + * Extracts demographic information from genres and tags (legacy compatibility) + * @param genres List of genre titles + * @param tags List of tag titles + * @return Pair of found demographic (if any) and filtered lists + */ + fun extractDemographic(genres: List, tags: List): Pair, List>> { + val (demographic, _, filteredPair) = extractDemographicAndFormat(genres, tags) + return demographic to filteredPair + } + + /** + * Safely parses a string to Int with null safety + */ + fun parseStringToIntSafely(value: String?): Int? { + return value?.toIntOrNull() + } + + /** + * Safely extracts series ID from URL with null safety + */ + fun extractSeriesIdFromUrl(url: String): Int? { + return try { + url.substringAfterLast("/").toIntOrNull() + } catch (e: Exception) { + null + } + } + + /** + * Safely parses date string with null safety + */ + fun parseDateSafe(date: String?): Long { + return date?.let { + try { + val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US) + sdf.timeZone = TimeZone.getTimeZone("UTC") + sdf.parse(it)?.time ?: 0L + } catch (_: Exception) { + 0L + } + } ?: 0L + } + + /** + * Data class to hold template variables for chapter formatting + */ + data class ChapterTemplateVariables( + val type: String, + val number: String, + val title: String, + val pages: Int, + val fileSize: Double, + val volumeNumber: String = "", + val cleanTitle: String = "", + val seriesName: String = "", + val libraryName: String = "", + val formats: String = "", + val created: String = "", + val releaseDate: String = "", ) + + /** + * Processes a template string with chapter variables + * Available variables: $Type, $No, $Title, $CleanTitle, $Pages, $Size, $Volume, $SeriesName, $LibraryName, $Format, $Created, $ReleaseDate + */ + fun processChapterTemplate( + template: String, + variables: ChapterTemplateVariables, + ): String { + if (template.isBlank()) return "" + + val fileSizeMB = if (variables.fileSize > 0) { + "%.1f".format(variables.fileSize / (1024.0 * 1024.0)) + } else { + "" + } + + val formattedSize = if (fileSizeMB.isNotEmpty()) "$fileSizeMB MB" else "" + + // Create replacement map + val replacements = mutableMapOf() + replacements["Type"] = variables.type + replacements["No"] = variables.number + replacements["Title"] = variables.title + replacements["CleanTitle"] = variables.cleanTitle + replacements["Pages"] = if (variables.pages > 0) variables.pages.toString() else "" + replacements["Size"] = formattedSize + replacements["Volume"] = variables.volumeNumber + replacements["SeriesName"] = variables.seriesName.ifEmpty { "" } + replacements["LibraryName"] = variables.libraryName + replacements["Format"] = variables.formats + replacements["Created"] = variables.created + replacements["ReleaseDate"] = variables.releaseDate + + // Debug logging before processing + Log.d("KavitaHelper", "Template processing - BEFORE: template='$template', seriesName='${variables.seriesName}', libraryName='${variables.libraryName}'") + + // Process the template + var result = template + + // First replace escaped dollar signs with temporary placeholder + result = result.replace("\\$", "\u0000TEMP_DOLLAR\u0000") + + // Replace all variables using regex for more reliable matching + replacements.forEach { (key, value) -> + // Fixed regex pattern - use negative lookahead to prevent partial matches + val pattern = Regex("\\\$${Regex.escape(key)}(?!\\w)") + val before = result + result = result.replace(pattern, value) + // Debug logging for all variables + if (before != result) { + Log.d("KavitaHelper", "Template processing: Replaced \$$key with: '$value'") + } + } + + // Remove any remaining unrecognized variables + result = result.replace(Regex("\\$\\w+"), "") + + // Normalize whitespace + result = result.replace(Regex("\\s+"), " ").trim() + + // Restore escaped dollar signs + result = result.replace("\u0000TEMP_DOLLAR\u0000", "$") + + // Debug logging after processing + Log.d("KavitaHelper", "Template processing - AFTER: result='$result'") + + // WORKAROUND: If the result starts with the series name, add a zero-width space to prevent truncation + val finalResult = if (result.startsWith(variables.seriesName)) { + "\u200B$result" // Zero-width space prevents UI truncation + } else { + result + } + + if (finalResult != result) { + Log.d("KavitaHelper", "Template processing - WORKAROUND applied (zero-width space)") + } + + return finalResult + } } diff --git a/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/dto/FilterDto.kt b/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/dto/FilterDto.kt index 2b1284ae..70046299 100644 --- a/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/dto/FilterDto.kt +++ b/src/all/kavita/src/eu/kanade/tachiyomi/extension/all/kavita/dto/FilterDto.kt @@ -111,6 +111,7 @@ enum class FilterField(val type: Int) { Team(30), Location(31), ReadLast(32), + People(33), ; companion object { 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 e7c3160e..16ebcdb4 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 @@ -88,11 +88,6 @@ data class SeriesDetailPlusDto( seriesDto?.libraryName } } - - // Helper function to get the library ID from SeriesDto if needed - fun getLibraryId(seriesDto: SeriesDto?): Int? { - return libraryId ?: seriesDto?.libraryId - } } @Serializable @@ -253,6 +248,7 @@ data class ChapterDto( val volumeId: Int, val created: String, val lastModifiedUtc: String, + val releaseDate: String, val files: List? = null, ) { val fileCount: Int @@ -262,6 +258,12 @@ data class ChapterDto( @Serializable data class FileDto( val id: Int, + val bytes: Long = 0, + val pages: Int = 0, + val filePath: String = "", + val format: Int = 0, + val created: String = "", + val extension: String = "", ) @Serializable