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