Migration to min/max and add support for range chapters/volumes + customization for Webtoons + revised parsing

This commit is contained in:
Mio.
2025-07-13 02:29:49 +02:00
parent dd3489c191
commit d0b4b26884
5 changed files with 367 additions and 197 deletions

View File

@@ -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.MetadataPeople
import eu.kanade.tachiyomi.extension.all.kavita.dto.MetadataPubStatus 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.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.PersonRole
import eu.kanade.tachiyomi.extension.all.kavita.dto.ReadingListDto import eu.kanade.tachiyomi.extension.all.kavita.dto.ReadingListDto
import eu.kanade.tachiyomi.extension.all.kavita.dto.ReadingListItemDto import eu.kanade.tachiyomi.extension.all.kavita.dto.ReadingListItemDto
@@ -140,30 +141,48 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
* - API-style URLs (e.g., `/Series/123`) * - API-style URLs (e.g., `/Series/123`)
*/ */
override fun getMangaUrl(manga: SManga): String { override fun getMangaUrl(manga: SManga): String {
return when { return try {
// API-style URL (used internally) when {
manga.url.contains("/Series/") -> { // Handle both API-style and web-style URLs
val seriesId = helper.getIdFromUrl(manga.url) manga.url.contains("/series/") || manga.url.contains("/Series/") -> {
val cachedSeries = series.find { it.id == seriesId } // Extract series ID from URL (works for both formats)
val libraryId = cachedSeries?.libraryId ?: run { val seriesId = manga.url
// Fallback: fetch from API if not cached .substringAfterLast("/series/")
val fetched = client.newCall(GET("$apiUrl/Series/$seriesId", headersBuilder().build())) .substringAfterLast("/Series/")
.execute() .substringBefore("?")
.parseAs<SeriesDto>() .substringBefore("/")
fetched.libraryId .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 // Handle reading list URLs
manga.url.contains("readingListId=") -> { manga.url.contains("readingListId=") -> {
val readingListId = manga.url.substringAfter("readingListId=").substringBefore("&") val readingListId = manga.url.substringAfter("readingListId=").substringBefore("&")
"$baseUrl/lists/$readingListId" "$baseUrl/lists/$readingListId"
} }
// Fallback - use original URL if we don't recognize the pattern // Fallback - use original URL if we don't recognize the pattern
else -> manga.url 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) { if (filter.state > 0) {
filterV2.addStatement( filterV2.addStatement(
FilterComparison.GreaterThanEqual, FilterComparison.GreaterThanEqual,
FilterField.AverageRating, FilterField.UserRating,
filter.state.toString(), filter.state.toString(),
) )
} }
@@ -503,22 +522,30 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
} }
} }
is AgeRatingFilterGroup -> { is AgeRatingFilterGroup -> {
filter.state.forEach { ageFilter -> val included = filter.state
val rating = ageRatingsListMeta.find { it.title == (ageFilter as Filter.TriState).name } .filter { it.state == STATE_INCLUDE }
rating?.let { .mapNotNull { ageFilter ->
when (ageFilter.state) { ageRatingsListMeta.find { it.title == ageFilter.name }?.value?.toString()
STATE_INCLUDE -> filterV2.addStatement(
FilterComparison.Contains,
FilterField.AgeRating,
rating.value.toString(),
)
STATE_EXCLUDE -> filterV2.addStatement(
FilterComparison.NotContains,
FilterField.AgeRating,
rating.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 -> { is FormatsFilterGroup -> {
@@ -972,9 +999,9 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
for (volume in volumes) { for (volume in volumes) {
// Issue: use chapter covers // Issue: use chapter covers
if (isComicLibrary && volume.minNumber == KavitaConstants.UNNUMBERED_VOLUME) { if (isComicLibrary && volume.minNumber.toInt() == KavitaConstants.UNNUMBERED_VOLUME) {
coverCandidates += volume.chapters coverCandidates += volume.chapters
.filterNot { it.coverImage.isNullOrBlank() } .filterNot { it.coverImage.isBlank() }
.map { chapter -> .map { chapter ->
Triple( Triple(
"$apiUrl/Image/chapter-cover?chapterId=${chapter.id}&apiKey=$apiKey", "$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 -> val hasSingleFile = volume.chapters.any { chapter ->
ChapterType.of(chapter, volume) == ChapterType.SingleFileVolume ChapterType.of(chapter, volume) == ChapterType.SingleFileVolume
} }
if (hasSingleFile && !volume.coverImage.isNullOrBlank()) { if (hasSingleFile && volume.coverImage.isNotBlank()) {
val isUnread = volume.pagesRead < volume.pages val isUnread = volume.pagesRead < volume.pages
val volumeNumber = volume.minNumber.toFloat() val volumeNumber = volume.minNumber.toFloat()
coverCandidates.add( coverCandidates.add(
@@ -1144,6 +1171,19 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
val items = response.parseAs<List<ReadingListItemDto>>() val items = response.parseAs<List<ReadingListItemDto>>()
val readingListId = response.request.url.queryParameter("readingListId") 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<SeriesDetailPlusDto>()
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 -> return items.map { item ->
SChapter.create().apply { SChapter.create().apply {
url = when { url = when {
@@ -1183,9 +1223,9 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
append("${item.order + 1}. ") append("${item.order + 1}. ")
when (type) { when (type) {
ChapterType.Regular -> { ChapterType.Regular -> {
val chapterNum = item.chapterNumber?.toIntOrNull()?.let { val chapterNum = item.chapterNumber?.toIntOrNull()?.toString()
it.toString().padStart(2, '0') ?.padStart(2, '0')
} ?: item.chapterNumber ?: "" ?: item.chapterNumber ?: ""
when { when {
item.chapterTitleName?.isNotBlank() == true -> item.chapterTitleName?.isNotBlank() == true ->
append("$chapterNum - ${item.chapterTitleName}") append("$chapterNum - ${item.chapterTitleName}")
@@ -1202,9 +1242,9 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
} }
} }
ChapterType.Issue -> { ChapterType.Issue -> {
val issueNum = item.chapterNumber?.toIntOrNull()?.let { val issueNum = item.chapterNumber?.toIntOrNull()?.toString()
it.toString().padStart(3, '0') ?.padStart(3, '0')
} ?: item.chapterNumber ?: "" ?: item.chapterNumber ?: ""
when { when {
item.chapterTitleName?.isNotBlank() == true -> item.chapterTitleName?.isNotBlank() == true ->
append("${item.chapterTitleName} (#$issueNum)") append("${item.chapterTitleName} (#$issueNum)")
@@ -1213,14 +1253,14 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
} }
} }
ChapterType.Chapter -> { ChapterType.Chapter -> {
val chapterNum = item.chapterNumber?.toIntOrNull()?.let { val chapterNum = item.chapterNumber?.toIntOrNull()?.toString()
it.toString().padStart(2, '0') ?.padStart(2, '0')
} ?: item.chapterNumber ?: "" ?: item.chapterNumber ?: ""
when { when {
item.chapterTitleName?.isNotBlank() == true -> item.chapterTitleName?.isNotBlank() == true ->
append("$chapterNum - ${item.chapterTitleName}") append("$chapterNum - ${item.chapterTitleName}")
else -> else ->
append("Chapter $chapterNum") append(if (isWebtoon) "Episode $chapterNum" else "Chapter $chapterNum")
} }
} }
ChapterType.Special -> { ChapterType.Special -> {
@@ -1361,31 +1401,59 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
val chapters = mutableListOf<SChapter>() val chapters = mutableListOf<SChapter>()
val volumeItems = mutableListOf<SChapter>() val volumeItems = mutableListOf<SChapter>()
// Get the series name to use as mangaTitle
val seriesName = seriesId?.let {
runCatching {
client.newCall(GET("$apiUrl/Series/$seriesId", headersBuilder().build()))
.execute().parseAs<SeriesDto>().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<SeriesDetailPlusDto>()
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 -> 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() && volume.minNumber.toInt() != KavitaConstants.SPECIAL_NUMBER) {
if (volume.chapters.size == 1 && volume.name.isNotBlank()) {
val chapter = volume.chapters.first() val chapter = volume.chapters.first()
val sChapter = helper.chapterFromVolume( val sChapter = helper.chapterFromVolume(
chapter, chapter,
volume, volume,
singleFileVolumeNumber = volume.minNumber, singleFileVolumeNumber = volume.minNumber,
libraryType = libraryType, libraryType = libraryType,
isWebtoon = isWebtoon,
mangaTitle = seriesName,
) )
// Force SingleFileVolume type sChapter.url = "/Chapter/${chapter.id}"
sChapter.name = when { sChapter.name = when {
volume.name.any { it.isLetter() } -> "v${volume.minNumber} - ${volume.name}" volume.name.any { it.isLetter() } -> "v${helper.formatVolumeNumber(volume)} - ${volume.name}"
else -> "Volume ${volume.minNumber}" else -> "Volume ${helper.formatVolumeNumber(volume)}"
} }
sChapter.chapter_number = volume.minNumber.toFloat() sChapter.chapter_number = volume.minNumber.toFloat()
sChapter.scanlator = "Volume" sChapter.scanlator = if (isWebtoon) "Season" else "Volume"
volumeItems.add(sChapter) volumeItems.add(sChapter)
} else { } else {
volume.chapters.forEach { chapter -> volume.chapters.forEach { chapter ->
val type = ChapterType.of(chapter, volume, libraryType) 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) { if (type == ChapterType.SingleFileVolume) {
volumeItems.add(sChapter) volumeItems.add(sChapter)
} else { } else {
sChapter.url = "/Chapter/${chapter.id}"
chapters.add(sChapter) chapters.add(sChapter)
} }
} }
@@ -1393,17 +1461,13 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
} }
return when { return when {
// Case 1: Only chapters exist
chapters.isNotEmpty() && volumeItems.isEmpty() -> chapters.isNotEmpty() && volumeItems.isEmpty() ->
chapters.sortedByDescending { it.chapter_number } chapters.sortedByDescending { it.chapter_number }
// Case 2: Only volumes exist
volumeItems.isNotEmpty() && chapters.isEmpty() -> volumeItems.isNotEmpty() && chapters.isEmpty() ->
volumeItems.sortedByDescending { it.chapter_number } volumeItems.sortedByDescending { it.chapter_number }
// Case 3: Mixed content
else -> { else -> {
// Convert volume numbers to negative for proper sorting
volumeItems.forEach { it.chapter_number = -it.chapter_number } volumeItems.forEach { it.chapter_number = -it.chapter_number }
( (
chapters.sortedByDescending { 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 // For normal titles
val chapterId = chapter.url.substringBefore("_")
return Observable.fromCallable { return Observable.fromCallable {
val chapterRequest = GET("$apiUrl/Chapter?chapterId=$chapterId", headersBuilder().build()) // Check if this is a volume URL (contains "Volume/")
val response = client.newCall(chapterRequest).execute() if (chapter.url.contains("/Volume/")) {
val volumeId = chapter.url.substringAfter("/Volume/").substringBefore("?").toIntOrNull()
?: throw IOException("Invalid volume ID")
if (!response.isSuccessful) { // Get all chapters & pages in this volume
throw IOException("Failed to fetch chapter details: HTTP ${response.code}") val volumeRequest = GET("$apiUrl/Volume/$volumeId", headersBuilder().build())
} val volume = client.newCall(volumeRequest).execute().parseAs<VolumeDto>()
val pages = mutableListOf<Page>()
var pageOffset = 0
// Check content type to ensure we got JSON volume.chapters.sortedBy { it.number.toFloatOrNull() ?: 0f }.forEach { chapterDto ->
val contentType = response.header("Content-Type") ?: "" for (i in 0 until chapterDto.pages) {
if (!contentType.contains("application/json")) { pages.add(
val bodyPreview = response.peekBody(128).string() Page(
throw IOException("${helper.intl["error_unexpected_response"]}: $contentType - $bodyPreview") index = pageOffset + i,
} imageUrl = "$apiUrl/Reader/image?chapterId=${chapterDto.id}&page=$i&extractPdf=true&apiKey=$apiKey",
),
)
}
pageOffset += chapterDto.pages
}
val chapterDetails = try { pages.toList()
response.parseAs<ChapterDto>() } else {
} catch (e: Exception) { // Original chapter handling
throw IOException("${helper.intl["error_failed_parse_chapter"]}: ${e.message}") 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<Page>() if (!response.isSuccessful) {
for (i in 0 until chapterDetails.pages) { throw IOException("Failed to fetch chapter details: HTTP ${response.code}")
pages.add( }
Page(
index = i, val chapterDetails = try {
imageUrl = "$apiUrl/Reader/image?chapterId=$chapterId&page=$i&extractPdf=true&apiKey=$apiKey", response.parseAs<ChapterDto>()
), } catch (e: Exception) {
) throw IOException("${helper.intl["error_failed_parse_chapter"]}: ${e.message}")
}
val pages = mutableListOf<Page>()
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 -> }.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) Log.e(LOG_TAG, "Error fetching chapter details, using fallback", error)
val fallbackPageCount = chapter.scanlator?.replace(" pages", "")?.toIntOrNull() ?: 1 val fallbackPageCount = chapter.scanlator?.replace(" pages", "")?.toIntOrNull() ?: 1
val pages = mutableListOf<Page>() val pages = mutableListOf<Page>()
@@ -1540,7 +1623,7 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
pages.add( pages.add(
Page( Page(
index = i, 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<MetadataAgeRatings>() private var ageRatingsListMeta = emptyList<MetadataAgeRatings>()
private var peopleListMeta = emptyList<MetadataPeople>() private var peopleListMeta = emptyList<MetadataPeople>()
private var pubStatusListMeta = emptyList<MetadataPubStatus>() private var pubStatusListMeta = emptyList<MetadataPubStatus>()
private var userRatingsListMeta = emptyList<MetadataUserRatings>()
private var languagesListMeta = emptyList<MetadataLanguages>() private var languagesListMeta = emptyList<MetadataLanguages>()
private var libraryListMeta = emptyList<MetadataLibrary>() private var libraryListMeta = emptyList<MetadataLibrary>()
private var collectionsListMeta = emptyList<MetadataCollections>() private var collectionsListMeta = emptyList<MetadataCollections>()
@@ -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( filtersLoaded.add(
UserRating(), UserRating(),
) )
@@ -1915,9 +1999,6 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
private val SharedPreferences.LastVolumeCover: Boolean private val SharedPreferences.LastVolumeCover: Boolean
get() = getBoolean(LAST_VOLUME_COVER_PREF, LAST_VOLUME_COVER_DEFAULT) 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 private val SharedPreferences.RdDate: Boolean
get() = getBoolean(RD_DATE_PREF, RD_DATE_DEFAULT) 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())) client.newCall(GET("$apiUrl/Metadata/publication-status", headersBuilder().build()))
.execute().use { response -> .execute().use { response ->
pubStatusListMeta = try { if (!response.isSuccessful) {
response.body.use { json.decodeFromString(it.string()) } 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) { } catch (e: Exception) {
Log.e( Log.e(LOG_TAG, "Error decoding publication status JSON", e)
LOG_TAG, pubStatusListMeta = emptyList()
"error while decoding JSON for publicationStatusListMeta filter",
e,
)
emptyList()
} }
} }
client.newCall(GET("$apiUrl/filter", headersBuilder().build())) client.newCall(GET("$apiUrl/filter", headersBuilder().build()))

View File

@@ -4,6 +4,8 @@ object KavitaConstants {
const val UNNUMBERED_VOLUME = -100000 const val UNNUMBERED_VOLUME = -100000
const val UNNUMBERED_VOLUME_STR = "-100000" const val UNNUMBERED_VOLUME_STR = "-100000"
const val SPECIAL_NUMBER = 100_000
const val VOLUME_NUMBER_OFFSET = 100000f
val PERSON_ROLES = listOf( val PERSON_ROLES = listOf(
"Writer", "Penciller", "Inker", "Colorist", "Writer", "Penciller", "Inker", "Colorist",

View File

@@ -1,5 +1,6 @@
package eu.kanade.tachiyomi.extension.all.kavita 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.ChapterDto
import eu.kanade.tachiyomi.extension.all.kavita.dto.ChapterType import eu.kanade.tachiyomi.extension.all.kavita.dto.ChapterType
import eu.kanade.tachiyomi.extension.all.kavita.dto.LibraryTypeEnum import eu.kanade.tachiyomi.extension.all.kavita.dto.LibraryTypeEnum
@@ -12,9 +13,7 @@ import eu.kanade.tachiyomi.source.model.SManga
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import okhttp3.Response import okhttp3.Response
import java.text.SimpleDateFormat
import java.util.Locale import java.util.Locale
import java.util.TimeZone
class KavitaHelper { class KavitaHelper {
val json = Json { val json = Json {
@@ -26,13 +25,6 @@ class KavitaHelper {
useArrayPolymorphism = true useArrayPolymorphism = true
prettyPrint = true prettyPrint = true
} }
inline fun <reified T : Enum<T>> 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 { fun hasNextPage(response: Response): Boolean {
val paginationHeader = response.header("Pagination") val paginationHeader = response.header("Pagination")
@@ -72,56 +64,98 @@ class KavitaHelper {
thumbnail_url = "$baseUrl/image/series-cover?seriesId=${obj.id}&apiKey=$apiKey" 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 { SChapter.create().apply {
val type = ChapterType.of(chapter, volume, libraryType) val type = ChapterType.of(chapter, volume, libraryType)
val titleName = chapter.titleName ?: "" val titleName = chapter.titleName
val title = chapter.title ?: "" val title = chapter.title
val range = chapter.range ?: "" val range = chapter.range
// For webtoon
val chapterLabel = if (isWebtoon) "Episode" else "Chapter"
val volumeLabel = if (isWebtoon) "Season" else "Volume"
name = when (type) { name = when (type) {
ChapterType.Regular -> { ChapterType.Regular -> {
val chapterNum = chapter.number.toIntOrNull()?.let { val chapterNum = formatChapterNumber(chapter)
it.toString().padStart(2, '0')
} ?: chapter.number
when { when {
titleName.any { it.isLetter() } -> "$chapterNum - $titleName" titleName.isNotBlank() && titleName.any { it.isLetter() } ->
else -> "Vol.${volume.minNumber} Ch.$chapterNum" "$chapterNum - ${cleanChapterTitle(
titleName,
ChapterTitleContext(
mangaTitle = mangaTitle,
chapterNumber = chapterNum,
volumeName = volume.name.ifBlank { title },
),
)}"
else ->
"$volumeLabel ${formatVolumeNumber(volume)} $chapterLabel $chapterNum"
} }
} }
ChapterType.SingleFileVolume -> { ChapterType.SingleFileVolume -> {
when { cleanChapterTitle(
volume.name.any { it.isLetter() } -> "v${volume.minNumber} - ${volume.name}" when {
else -> "Volume ${volume.minNumber}" volume.name.any { it.isLetter() } -> "v${volume.minNumber} - ${volume.name}"
} else -> "Volume ${volume.minNumber}"
},
ChapterTitleContext(
mangaTitle = mangaTitle,
volumeName = volume.name,
),
)
} }
ChapterType.Special -> { ChapterType.Special -> {
when { cleanChapterTitle(
title.isNotBlank() -> title when {
range.isNotBlank() -> range title.isNotBlank() -> title
else -> "Special" range.isNotBlank() -> range
} else -> "Special"
},
ChapterTitleContext(
mangaTitle = mangaTitle,
volumeName = volume.name,
),
)
} }
ChapterType.Chapter -> { ChapterType.Chapter -> {
val chapterNum = chapter.number.toIntOrNull()?.let { val chapterNum = formatChapterNumber(chapter)
it.toString().padStart(2, '0')
} ?: chapter.number
when { when {
titleName.any { it.isLetter() } -> "$chapterNum - $titleName" titleName.isNotBlank() && titleName.any { it.isLetter() } ->
else -> "Chapter $chapterNum" "$chapterNum - ${cleanChapterTitle(
titleName,
ChapterTitleContext(
mangaTitle = mangaTitle,
chapterNumber = chapterNum,
volumeName = volume.name,
),
)}"
else ->
"$chapterLabel $chapterNum"
} }
} }
ChapterType.Issue -> { ChapterType.Issue -> {
val issueNum = chapter.number.toIntOrNull()?.let { val issueNum = chapter.number.toIntOrNull()?.toString()?.padStart(3, '0') ?: chapter.number
it.toString().padStart(3, '0') cleanChapterTitle(
} ?: chapter.number when {
when { titleName.any { it.isLetter() } -> "$titleName (#$issueNum)"
titleName.any { it.isLetter() } -> "$titleName (#$issueNum)" else -> "Issue #$issueNum"
else -> "Issue #$issueNum" },
} ChapterTitleContext(
mangaTitle = mangaTitle,
chapterNumber = issueNum,
volumeName = volume.name,
),
)
} }
} }
// Handle decimal chapter numbers properly
chapter_number = when { chapter_number = when {
type == ChapterType.SingleFileVolume && singleFileVolumeNumber != null -> type == ChapterType.SingleFileVolume && singleFileVolumeNumber != null ->
singleFileVolumeNumber.toFloat() singleFileVolumeNumber.toFloat()
@@ -131,42 +165,130 @@ class KavitaHelper {
volume.minNumber.toFloat() volume.minNumber.toFloat()
} }
// If this is a regular chapter (not a volume) // For regular chapters
type != ChapterType.SingleFileVolume -> type != ChapterType.SingleFileVolume && type != ChapterType.Special -> {
chapter.number.toFloatOrNull() ?: 0f // 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 -> { else -> {
val volumeNum = try { 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) { } catch (e: NumberFormatException) {
0f 0f
} }
-volumeNum - VOLUME_NUMBER_OFFSET -volumeNum - KavitaConstants.VOLUME_NUMBER_OFFSET
} }
} }
url = when { url = when {
type == ChapterType.SingleFileVolume && singleFileVolumeNumber != null -> type == ChapterType.SingleFileVolume && singleFileVolumeNumber != null ->
"volume_${volume.id}" "volume_${volume.id}"
else -> chapter.id.toString() else -> "chapter_${chapter.id}"
} }
// Handle multi-file chapters
if (chapter.fileCount > 1) { if (chapter.fileCount > 1) {
// Add small offset to maintain order while keeping the main chapter number
chapter_number += 0.001f * chapter.fileCount chapter_number += 0.001f * chapter.fileCount
// Append file count to URL for reference
url = "${url}_${chapter.fileCount}" url = "${url}_${chapter.fileCount}"
} }
date_upload = parseDateSafe(chapter.created) date_upload = parseDateSafe(chapter.created)
scanlator = when (type) { scanlator = when (type) {
ChapterType.SingleFileVolume -> "Volume" ChapterType.SingleFileVolume -> if (isWebtoon) "Season" else "Volume"
ChapterType.Special -> "Special" ChapterType.Special -> "Special"
ChapterType.Issue -> "Issue" ChapterType.Issue -> "Issue"
ChapterType.Chapter -> "Chapter" ChapterType.Chapter -> if (isWebtoon) "Episode" else "Chapter"
ChapterType.Regular -> "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<String>): Boolean {
return tags.any {
val normalized = it.trim().lowercase()
normalized.contains("webtoon") || normalized.contains("long strip")
}
}
val intl = Intl( val intl = Intl(
language = Locale.getDefault().toString(), language = Locale.getDefault().toString(),
baseLanguage = "en", baseLanguage = "en",
@@ -179,8 +301,4 @@ class KavitaHelper {
} }
}, },
) )
companion object {
private const val VOLUME_NUMBER_OFFSET = 100000f
}
} }

View File

@@ -20,7 +20,6 @@ enum class MangaFormat(val format: Int) {
companion object { companion object {
private val map = values().associateBy(MangaFormat::format) private val map = values().associateBy(MangaFormat::format)
fun fromInt(type: Int): MangaFormat? = map[type]
} }
} }
@@ -190,7 +189,8 @@ data class AuthorDto(
@Serializable @Serializable
data class VolumeDto( data class VolumeDto(
val id: Int, val id: Int,
val minNumber: Int, val minNumber: Double,
val maxNumber: Double,
val name: String = "", val name: String = "",
val pages: Int, val pages: Int,
val pagesRead: Int, val pagesRead: Int,
@@ -211,15 +211,15 @@ enum class ChapterType {
; ;
companion object { companion object {
private const val SPECIAL_NUMBER = 100_000
private const val UNNUMBERED_VOLUME_NUMBER = -100_000 private const val UNNUMBERED_VOLUME_NUMBER = -100_000
fun of(chapter: ChapterDto, volume: VolumeDto, libraryType: LibraryTypeEnum? = null): ChapterType = fun of(chapter: ChapterDto, volume: VolumeDto, libraryType: LibraryTypeEnum? = null): ChapterType =
when { when {
// Special // Special
volume.minNumber == SPECIAL_NUMBER -> Special volume.minNumber.toInt() == KavitaConstants.SPECIAL_NUMBER ||
chapter.minNumber.toInt() == KavitaConstants.SPECIAL_NUMBER -> Special
// Issue // Issue
volume.minNumber == UNNUMBERED_VOLUME_NUMBER -> when (libraryType) { volume.minNumber.toInt() == UNNUMBERED_VOLUME_NUMBER -> when (libraryType) {
LibraryTypeEnum.Comic, LibraryTypeEnum.ComicVine -> Issue LibraryTypeEnum.Comic, LibraryTypeEnum.ComicVine -> Issue
else -> Chapter else -> Chapter
} }
@@ -241,6 +241,8 @@ data class ChapterDto(
val id: Int, val id: Int,
val range: String, val range: String,
val number: String, val number: String,
val minNumber: Double,
val maxNumber: Double,
val pages: Int, val pages: Int,
val isSpecial: Boolean, val isSpecial: Boolean,
val title: String, val title: String,

View File

@@ -22,6 +22,9 @@ data class MetadataTag(val id: Int, val title: String)
@Serializable @Serializable
data class MetadataAgeRatings(val value: Int, val title: String) data class MetadataAgeRatings(val value: Int, val title: String)
@Serializable
data class MetadataUserRatings(val value: Int, val title: String)
@Serializable @Serializable
data class MetadataLanguages(val isoCode: String, val title: String) data class MetadataLanguages(val isoCode: String, val title: String)
@@ -37,40 +40,3 @@ data class SmartFilter(
val name: String, val name: String,
val filter: String, val filter: String,
) )
@Serializable
data class MetadataPayload(
val forceUseMetadataPayload: Boolean = true,
var sorting: Int = 1,
var sorting_asc: Boolean = true,
var readStatus: ArrayList<String> = arrayListOf("notRead", "inProgress", "read"),
var genres_i: ArrayList<Int> = arrayListOf(),
var genres_e: ArrayList<Int> = arrayListOf(),
var tags_i: ArrayList<Int> = arrayListOf(),
var tags_e: ArrayList<Int> = arrayListOf(),
var ageRating_i: ArrayList<Int> = arrayListOf(),
var ageRating_e: ArrayList<Int> = arrayListOf(),
var formats: ArrayList<Int> = arrayListOf(),
var collections_i: ArrayList<Int> = arrayListOf(),
var collections_e: ArrayList<Int> = arrayListOf(),
var userRating: Int = 0,
var people: ArrayList<Int> = arrayListOf(),
var language_i: ArrayList<String> = arrayListOf(),
var language_e: ArrayList<String> = arrayListOf(),
var libraries_i: ArrayList<Int> = arrayListOf(),
var libraries_e: ArrayList<Int> = arrayListOf(),
var pubStatus: ArrayList<Int> = arrayListOf(),
var seriesNameQuery: String = "",
var releaseYearRangeMin: Int = 0,
var releaseYearRangeMax: Int = 0,
var peopleWriters: ArrayList<Int> = arrayListOf(),
var peoplePenciller: ArrayList<Int> = arrayListOf(),
var peopleInker: ArrayList<Int> = arrayListOf(),
var peopleColorist: ArrayList<Int> = arrayListOf(),
var peopleLetterer: ArrayList<Int> = arrayListOf(),
var peopleCoverArtist: ArrayList<Int> = arrayListOf(),
var peopleEditor: ArrayList<Int> = arrayListOf(),
var peoplePublisher: ArrayList<Int> = arrayListOf(),
var peopleCharacter: ArrayList<Int> = arrayListOf(),
var peopleTranslator: ArrayList<Int> = arrayListOf(),
)