From ba96a8a2ee5875241540734f08bf7df9a1640332 Mon Sep 17 00:00:00 2001 From: radiostar Date: Sat, 23 May 2026 14:03:53 +0900 Subject: [PATCH] Customize Kavita extension for local reader --- .gitignore | 4 + gradle.properties | 2 + .../tachiyomi/extension/all/kavita/Kavita.kt | 141 +++++++++------- .../extension/all/kavita/KavitaHelper.kt | 150 +++++++++++------- 4 files changed, 184 insertions(+), 113 deletions(-) diff --git a/.gitignore b/.gitignore index 1511ff70..4b15f943 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,7 @@ apk/ gen generated-src/ .kotlin +signingkey.jks +output.json +tmp/ +Inspector.jar diff --git a/gradle.properties b/gradle.properties index fd00c0bc..60234450 100644 --- a/gradle.properties +++ b/gradle.properties @@ -25,3 +25,5 @@ android.useAndroidX=true android.enableBuildConfigAsBytecode=true android.defaults.buildfeatures.resvalues=false android.defaults.buildfeatures.shaders=false + +android.overridePathCheck=true 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 137a5951..8b67d053 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 @@ -125,10 +125,10 @@ 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() } - 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 val apiUrl: String get() = getPrefApiUrl() + private val apiKey: String get() = getPrefApiKey() + override val baseUrl: String get() = getPrefBaseUrl() + private val address: String get() = 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. private val LOG_TAG = """Kavita_${"[$suffix]_" + preferences.getString(KavitaConstants.customSourceNamePref, "[$suffix]")!!.replace(' ', '_')}""" private var isLogged = false // Used to know if login was correct and not send login requests anymore @@ -265,14 +265,17 @@ 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 suspend fun fetch(request: Request): MangasPage = withContext(Dispatchers.IO) { + private suspend fun fetch( + request: Request, + parser: (Response) -> MangasPage = ::popularMangaParse, + ): MangasPage = withContext(Dispatchers.IO) { try { val response = client.newCall(request).execute() if (!response.isSuccessful) { val code = response.code throw IOException("Http Error: $code\n ${intl["http_errors_$code"]}\n${intl["check_version"]}") } - popularMangaParse(response) + parser(response) } catch (e: Exception) { Log.e(LOG_TAG, "Error fetching manga", e) throw e @@ -288,7 +291,7 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou popularMangaParse(response) } catch (e: Exception) { Log.e(LOG_TAG, "Error fetching popular manga", e) - MangasPage(emptyList(), false) + throw e } } @@ -303,7 +306,7 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou readingListParse(response) } else -> { // Regular searches (including Want to Read) - fetch(searchMangaRequest(page, query, filters)) + fetch(searchMangaRequest(page, query, filters), ::searchMangaParse) } } } @@ -1693,7 +1696,7 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou scanlatorFormat = preferences.scanlatorFormat, volumePageCount = volume.pages, ) - sChapter.url = "/Chapter/${chapter.id}" + sChapter.url = chapterUrlWithPageCount(chapter, volume.pages) // For singleFileVolume, ensure the scanlator field reflects webtoon terminology sChapter.scanlator = if (isWebtoon) "Season" else "Volume" @@ -1712,7 +1715,7 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou scanlatorFormat = preferences.scanlatorFormat, ) - sChapter.url = "/Chapter/${chapter.id}" + sChapter.url = chapterUrlWithPageCount(chapter) allChapters.add(sChapter) } @@ -1757,6 +1760,48 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou return GET("$apiUrl/$chapterId", headersBuilder().build()) } + private fun chapterUrlWithPageCount(chapter: ChapterDto, pageCount: Int = chapter.pages): String { + val params = mutableListOf() + if (pageCount > 0) { + params += "pages=$pageCount" + } + params += "extractPdf=${shouldExtractPdf(chapter)}" + if (chapter.fileCount > 1) { + params += "split=${chapter.fileCount}" + } + return buildString { + append("/Chapter/${chapter.id}") + if (params.isNotEmpty()) { + append("?") + append(params.joinToString("&")) + } + } + } + + private fun pageCountFromChapterUrl(chapter: SChapter): Int? = + chapter.url.substringAfter("pages=", "") + .substringBefore("&") + .toIntOrNull() + ?.takeIf { it > 0 } + + private fun shouldExtractPdf(chapter: ChapterDto): Boolean = + chapter.files.orEmpty().any { file -> + file.format == MangaFormat.Pdf.format || file.extension.equals("pdf", ignoreCase = true) + } + + private fun extractPdfFromChapterUrl(chapter: SChapter): Boolean = + chapter.url.substringAfter("extractPdf=", "") + .substringBefore("&") + .equals("true", ignoreCase = true) + + private fun readerImageUrl(chapterId: Any, page: Int, extractPdf: Boolean): String = + "$apiUrl/Reader/image?chapterId=$chapterId&page=$page&extractPdf=$extractPdf&apiKey=$apiKey" + + private fun readerPage(index: Int, chapterId: Any, page: Int = index, extractPdf: Boolean = false): Page { + val imageUrl = readerImageUrl(chapterId, page, extractPdf) + return Page(index = index, url = imageUrl, imageUrl = imageUrl) + } + 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=")) { @@ -1804,12 +1849,8 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou } // 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", - ) - } + val extractPdf = shouldExtractPdf(chapterDetails) + return@withContext (0 until chapterDetails.pages).map { i -> readerPage(i, chapterDetails.id, extractPdf = extractPdf) } } catch (e: Exception) { Log.e(LOG_TAG, "Error processing reading list item", e) throw e @@ -1835,31 +1876,22 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou // 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(intl["error_no_chapters_found"]) - - val initialPages = (0 until matchingChapter.pages).map { i -> - Page( - index = i, - imageUrl = "$apiUrl/Reader/image?chapterId=${matchingChapter.id}&page=$i&extractPdf=true&apiKey=$apiKey", - ) + if (volume.chapters.isEmpty()) { + throw IOException(intl["error_no_chapters_found"]) } - val remainingPages = volume.chapters + val volumePages = volume.chapters .sortedBy { it.number.toFloatOrNull() ?: 0f } - .runningFold(initialPages.size) { acc, chapter -> acc + chapter.pages } - .drop(1) + .runningFold(0) { acc, chapter -> acc + chapter.pages } .zip(volume.chapters.sortedBy { it.number.toFloatOrNull() ?: 0f }) - .flatMap { (offset, chapter) -> + .flatMap { (startIndex, chapter) -> + val extractPdf = shouldExtractPdf(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", - ) + readerPage(startIndex + i, chapter.id, i, extractPdf) } } - return@withContext (initialPages + remainingPages).toList() + return@withContext volumePages } else { // Original chapter handling val chapterId = when { @@ -1868,6 +1900,11 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou else -> throw IOException("Invalid chapter URL format") } + val extractPdf = extractPdfFromChapterUrl(chapter) + pageCountFromChapterUrl(chapter)?.let { pageCount -> + return@withContext (0 until pageCount).map { i -> readerPage(i, chapterId, extractPdf = extractPdf) } + } + val chapterRequest = GET("$apiUrl/Chapter?chapterId=$chapterId", headersBuilder().build()) val response = client.newCall(chapterRequest).execute() @@ -1886,23 +1923,19 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou throw IOException(errorMessage) } - return@withContext (0 until chapterDetails.pages).map { i -> - Page( - index = i, - imageUrl = "$apiUrl/Reader/image?chapterId=$chapterId&page=$i&extractPdf=true&apiKey=$apiKey", - ) - } + val fetchedExtractPdf = shouldExtractPdf(chapterDetails) + return@withContext (0 until chapterDetails.pages).map { i -> readerPage(i, chapterId, extractPdf = fetchedExtractPdf) } } } 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", e) val fallbackPageCount = chapter.scanlator?.replace(" pages", "")?.toIntOrNull() ?: 1 - return@withContext (0 until fallbackPageCount).map { i -> - Page( - index = i, - imageUrl = "$apiUrl/Reader/image?chapterId=${chapter.url.substringBefore("_")}&page=$i&extractPdf=true&apiKey=$apiKey", - ) - } + val fallbackChapterId = chapter.url + .substringAfter("/Chapter/", chapter.url) + .substringAfter("chapter_", chapter.url) + .substringBefore("_") + .substringBefore("?") + return@withContext (0 until fallbackPageCount).map { i -> readerPage(i, fallbackChapterId, extractPdf = extractPdfFromChapterUrl(chapter)) } } } @@ -1930,8 +1963,7 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou MangasPage(mangaList, false) } catch (e: Exception) { Log.e(LOG_TAG, "Error parsing search results", e) - // Return empty result instead of throwing exception - MangasPage(emptyList(), false) + throw IOException(intl["check_version"], e) } } @@ -2233,7 +2265,7 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou return EditTextPreference(context).apply { key = preKey this.title = title - val input = preferences.getString(title, null) + val input = preferences.getString(preKey, null) this.summary = if (input == null || input.isEmpty()) summary else input this.setDefaultValue(default) dialogTitle = title @@ -2249,7 +2281,7 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou val opdsUrlInPref = opdsUrlInPreferences(newValue.toString()) // We don't allow hot have multiple sources with same ip or domain if (opdsUrlInPref.isNotEmpty()) { // TODO("Add option to allow multiple sources with same url at the cost of tracking") - preferences.edit().putString(title, "").apply() + preferences.edit().putString(preKey, "").apply() Toast.makeText( context, @@ -2259,7 +2291,9 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou throw OpdsurlExistsInPref(intl["pref_opds_duplicated_source_url"] + opdsUrlInPref) } - val res = preferences.edit().putString(title, newValue as String).commit() + val res = preferences.edit().putString(preKey, newValue as String).commit() + jwtToken = "" + isLogged = false Toast.makeText( context, intl["restartapp_settings"], @@ -2537,14 +2571,15 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou private fun doLogin() { Log.d(LOG_TAG, "Attempting login with address: ${address.takeIf { it.isNotEmpty() } ?: "EMPTY"}") - if (address.isEmpty()) { + val hasStoredLogin = apiUrl.isNotBlank() && getPrefKey().isNotBlank() + if (address.isEmpty() && !hasStoredLogin) { Log.e(LOG_TAG, "OPDS URL is empty or null") throw IOException(intl["pref_opds_must_setup_address"]) } - if (address.split("/opds/").size != 2) { + if (address.isNotEmpty() && address.split("/opds/").size != 2) { throw IOException(intl["pref_opds_badformed_url"]) } - if (jwtToken.isEmpty()) setupLogin() + if (jwtToken.isEmpty() && address.isNotEmpty()) setupLogin() Log.v(LOG_TAG, "[Login] Starting login") val request = POST( "$apiUrl/Plugin/authenticate?apiKey=${getPrefKey()}&pluginName=Tachiyomi-Kavita", 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 4534d41a..ccd71a64 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 @@ -106,16 +106,7 @@ class KavitaHelper { ChapterType.Regular -> { val chapterNum = formatChapterNumber(chapter) val volNum = formatVolumeNumber(volume) - val cleanedTitle = cleanChapterTitle( - titleName, - ChapterTitleContext( - mangaTitle = mangaTitle, - chapterNumber = chapterNum, - volumeNumber = volNum, - volumeName = volume.name.ifBlank { title }, - isWebtoon = isWebtoon, - ), - ) + val cleanedTitle = kavitaDisplayTitle(type, chapter, volume, libraryType, isWebtoon) val finalCleanTitle = cleanedTitle.ifBlank { defaultCleanTitle(type, chapterNum, volNum, isWebtoon) } @@ -160,20 +151,22 @@ class KavitaHelper { pages = volume.pages, fileSize = volume.chapters.flatMap { it.files ?: emptyList() }.sumOf { it.bytes }.toDouble(), volumeNumber = volumeNumber, - cleanTitle = when { - volume.name.isNotBlank() && !volume.name.matches(Regex("^\\d+$")) -> volume.name - volume.name.isNotBlank() && volume.name.matches(Regex("^\\d+$")) -> { - // Handle case where volume name is only a number (e.g., "2") - if (isWebtoon) { - val volNum = volume.name.toIntOrNull()?.toString() ?: volume.name - "Season $volNum" - } else { - val volNum = volume.name.toIntOrNull()?.toString() ?: volume.name - "Volume $volNum" + cleanTitle = kavitaDisplayTitle(type, chapter, volume, libraryType, isWebtoon).ifBlank { + when { + volume.name.isNotBlank() && !volume.name.matches(Regex("^\\d+$")) -> volume.name + volume.name.isNotBlank() && volume.name.matches(Regex("^\\d+$")) -> { + // Handle case where volume name is only a number (e.g., "2") + if (isWebtoon) { + val volNum = volume.name.toIntOrNull()?.toString() ?: volume.name + "Season $volNum" + } else { + val volNum = volume.name.toIntOrNull()?.toString() ?: volume.name + "Volume $volNum" + } + } + else -> { + if (isWebtoon) "Season $volumeNumber" else "Volume $volumeNumber" } - } - else -> { - if (isWebtoon) "Season $volumeNumber" else "Volume $volumeNumber" } }, seriesName = seriesName, @@ -202,26 +195,28 @@ class KavitaHelper { pages = chapter.pages, fileSize = chapter.files?.sumOf { it.bytes }?.toDouble() ?: 0.0, volumeNumber = formatVolumeNumber(volume), - cleanTitle = when { - titleName.isNotBlank() && !titleName.matches(Regex("^\\d+$")) -> titleName - titleName.isNotBlank() && titleName.matches(Regex("^\\d+$")) -> { - // Handle case where titleName is only a number (e.g., "2") - val num = titleName.toIntOrNull()?.toString()?.padStart(2, '0') ?: titleName - "Special $num" + cleanTitle = kavitaDisplayTitle(type, chapter, volume, libraryType, isWebtoon).ifBlank { + when { + titleName.isNotBlank() && !titleName.matches(Regex("^\\d+$")) -> titleName + titleName.isNotBlank() && titleName.matches(Regex("^\\d+$")) -> { + // Handle case where titleName is only a number (e.g., "2") + val num = titleName.toIntOrNull()?.toString()?.padStart(2, '0') ?: titleName + "Special $num" + } + title.isNotBlank() && !title.matches(Regex("^\\d+$")) -> title + title.isNotBlank() && title.matches(Regex("^\\d+$")) -> { + // Handle case where title is only a number (e.g., "2") + val num = title.toIntOrNull()?.toString()?.padStart(2, '0') ?: title + "Special $num" + } + range.isNotBlank() && !range.matches(Regex("^\\d+$")) -> range + range.isNotBlank() && range.matches(Regex("^\\d+$")) -> { + // Handle case where range is only a number (e.g., "2") + val num = range.toIntOrNull()?.toString()?.padStart(2, '0') ?: range + "Special $num" + } + else -> "Special" } - title.isNotBlank() && !title.matches(Regex("^\\d+$")) -> title - title.isNotBlank() && title.matches(Regex("^\\d+$")) -> { - // Handle case where title is only a number (e.g., "2") - val num = title.toIntOrNull()?.toString()?.padStart(2, '0') ?: title - "Special $num" - } - range.isNotBlank() && !range.matches(Regex("^\\d+$")) -> range - range.isNotBlank() && range.matches(Regex("^\\d+$")) -> { - // Handle case where range is only a number (e.g., "2") - val num = range.toIntOrNull()?.toString()?.padStart(2, '0') ?: range - "Special $num" - } - else -> "Special" }, seriesName = seriesName, libraryName = seriesMap.getSeries(volume.seriesId)?.libraryName ?: "", @@ -237,15 +232,7 @@ class KavitaHelper { ChapterType.Chapter -> { val chapterNum = formatChapterNumber(chapter) - val cleanedTitle = cleanChapterTitle( - titleName, - ChapterTitleContext( - mangaTitle = mangaTitle, - chapterNumber = chapterNum, - volumeName = volume.name, - isWebtoon = isWebtoon, - ), - ) + val cleanedTitle = kavitaDisplayTitle(type, chapter, volume, libraryType, isWebtoon) val finalCleanTitle = cleanedTitle.ifBlank { defaultCleanTitle(type, chapterNum, formatVolumeNumber(volume), isWebtoon) } @@ -280,16 +267,18 @@ class KavitaHelper { pages = chapter.pages, fileSize = chapter.files?.sumOf { it.bytes }?.toDouble() ?: 0.0, volumeNumber = formatVolumeNumber(volume), - cleanTitle = when { - titleName.isNotBlank() && !titleName.matches(Regex("^\\d+$")) && titleName.any { it.isLetter() } -> titleName - titleName.isNotBlank() && titleName.matches(Regex("^\\d+$")) -> { - // Handle case where titleName is only a number (e.g., "2") - val num = titleName.toIntOrNull()?.toString()?.padStart(3, '0') ?: titleName - "Issue #$num" + cleanTitle = kavitaDisplayTitle(type, chapter, volume, libraryType, isWebtoon).ifBlank { + when { + titleName.isNotBlank() && !titleName.matches(Regex("^\\d+$")) && titleName.any { it.isLetter() } -> titleName + titleName.isNotBlank() && titleName.matches(Regex("^\\d+$")) -> { + // Handle case where titleName is only a number (e.g., "2") + val num = titleName.toIntOrNull()?.toString()?.padStart(3, '0') ?: titleName + "Issue #$num" + } + else -> "Issue #$issueNum" + }.ifBlank { + defaultCleanTitle(type, issueNum, formatVolumeNumber(volume), isWebtoon) } - else -> "Issue #$issueNum" - }.ifBlank { - defaultCleanTitle(type, issueNum, formatVolumeNumber(volume), isWebtoon) }, seriesName = seriesName, libraryName = seriesMap.getSeries(volume.seriesId)?.libraryName ?: "", @@ -470,6 +459,47 @@ class KavitaHelper { } } + internal fun kavitaDisplayTitle( + type: ChapterType, + chapter: ChapterDto, + volume: VolumeDto, + libraryType: LibraryTypeEnum? = null, + isWebtoon: Boolean = false, + ): String { + val titleName = chapter.titleName?.trim().orEmpty() + val title = chapter.title.trim() + val range = chapter.range.trim() + val volumeName = volume.name.trim() + val usableVolumeName = volumeName.takeUnless { it.isBlank() || it == KavitaConstants.UNNUMBERED_VOLUME_STR } + + fun fallbackChapterLabel(): String { + val number = range.takeUnless { it.isBlank() || it == KavitaConstants.UNNUMBERED_VOLUME_STR } + ?: formatChapterNumber(chapter) + return when (libraryType) { + LibraryTypeEnum.Comic, LibraryTypeEnum.ComicVine -> "Issue #$number" + LibraryTypeEnum.Book, LibraryTypeEnum.LightNovel -> "Book $number" + else -> if (isWebtoon) "Episode $number" else "Chapter $number" + } + } + + fun joinTitle(left: String?, right: String?): String = + listOfNotNull( + left?.takeIf { it.isNotBlank() }, + right?.takeIf { it.isNotBlank() && it != left }, + ).joinToString(" - ") + + return when (type) { + ChapterType.SingleFileVolume -> joinTitle(usableVolumeName, titleName) + .ifBlank { titleName } + .ifBlank { usableVolumeName.orEmpty() } + .ifBlank { title } + ChapterType.Special -> title.ifBlank { titleName }.ifBlank { range } + ChapterType.Regular -> joinTitle(usableVolumeName, titleName.ifBlank { fallbackChapterLabel() }) + ChapterType.Chapter -> titleName.ifBlank { fallbackChapterLabel() } + ChapterType.Issue -> titleName.ifBlank { fallbackChapterLabel() } + } + } + internal fun cleanChapterTitle( originalTitle: String, context: ChapterTitleContext? = null,