From d0b4b26884f429f849cc397c6fd66781b8590ce6 Mon Sep 17 00:00:00 2001 From: "Mio." Date: Sun, 13 Jul 2025 02:29:49 +0200 Subject: [PATCH] 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(), -)