Compare commits

28 Commits

Author SHA1 Message Date
88c7b31ecc Translate Kavita filters to Korean
Some checks failed
CI / Prepare job (push) Has been cancelled
Create Release on Merge / release (push) Has been cancelled
CI / Build extensions (${{ matrix.chunk.number }}) (push) Has been cancelled
CI / Publish extension repo (push) Has been cancelled
Lock threads / lock (push) Has been cancelled
2026-05-24 13:34:03 +09:00
7df02d1a2a Remove Kavita admin chapters 2026-05-24 13:09:40 +09:00
2ce0f2049a Add Kavita admin actions to extension
Some checks failed
CI / Prepare job (push) Has been cancelled
CI / Build extensions (${{ matrix.chunk.number }}) (push) Has been cancelled
CI / Publish extension repo (push) Has been cancelled
Create Release on Merge / release (push) Has been cancelled
2026-05-24 12:32:32 +09:00
f0de3aaea3 Fix Kavita search pagination
Some checks failed
CI / Prepare job (push) Has been cancelled
CI / Build extensions (${{ matrix.chunk.number }}) (push) Has been cancelled
CI / Publish extension repo (push) Has been cancelled
Create Release on Merge / release (push) Has been cancelled
2026-05-24 11:45:25 +09:00
2ddbcc9fe5 Add Kavita EPUB reader handoff
Some checks failed
CI / Prepare job (push) Has been cancelled
CI / Build extensions (${{ matrix.chunk.number }}) (push) Has been cancelled
CI / Publish extension repo (push) Has been cancelled
Create Release on Merge / release (push) Has been cancelled
2026-05-24 09:11:57 +09:00
0f6e579b0b Report Kavita extension client info as OPDS reader
Some checks failed
CI / Prepare job (push) Has been cancelled
CI / Build extensions (${{ matrix.chunk.number }}) (push) Has been cancelled
CI / Publish extension repo (push) Has been cancelled
Create Release on Merge / release (push) Has been cancelled
Lock threads / lock (push) Has been cancelled
2026-05-24 00:34:37 +09:00
d8937783c5 Include client info in reader image URLs
Some checks failed
CI / Prepare job (push) Has been cancelled
CI / Build extensions (${{ matrix.chunk.number }}) (push) Has been cancelled
CI / Publish extension repo (push) Has been cancelled
Create Release on Merge / release (push) Has been cancelled
2026-05-24 00:10:54 +09:00
c85c009e17 Send Kavita client info headers
Some checks failed
CI / Prepare job (push) Has been cancelled
CI / Build extensions (${{ matrix.chunk.number }}) (push) Has been cancelled
CI / Publish extension repo (push) Has been cancelled
Create Release on Merge / release (push) Has been cancelled
2026-05-23 23:39:20 +09:00
b7f98dee7e Report reader image progress to Kavita
Some checks failed
CI / Prepare job (push) Has been cancelled
CI / Build extensions (${{ matrix.chunk.number }}) (push) Has been cancelled
CI / Publish extension repo (push) Has been cancelled
Create Release on Merge / release (push) Has been cancelled
2026-05-23 22:59:50 +09:00
c551317051 Restore upstream cover URL handling
Some checks failed
CI / Prepare job (push) Has been cancelled
CI / Build extensions (${{ matrix.chunk.number }}) (push) Has been cancelled
CI / Publish extension repo (push) Has been cancelled
Create Release on Merge / release (push) Has been cancelled
2026-05-23 18:19:34 +09:00
b38824219f Use raw Kavita cover query URLs
Some checks failed
CI / Prepare job (push) Has been cancelled
CI / Build extensions (${{ matrix.chunk.number }}) (push) Has been cancelled
CI / Publish extension repo (push) Has been cancelled
Create Release on Merge / release (push) Has been cancelled
2026-05-23 17:59:58 +09:00
ed33de3580 Build cover image URLs safely
Some checks failed
CI / Prepare job (push) Has been cancelled
CI / Build extensions (${{ matrix.chunk.number }}) (push) Has been cancelled
CI / Publish extension repo (push) Has been cancelled
Create Release on Merge / release (push) Has been cancelled
2026-05-23 17:06:57 +09:00
e05060e57c Publish cover route rollback
Some checks failed
CI / Prepare job (push) Has been cancelled
CI / Build extensions (${{ matrix.chunk.number }}) (push) Has been cancelled
CI / Publish extension repo (push) Has been cancelled
Create Release on Merge / release (push) Has been cancelled
2026-05-23 16:55:20 +09:00
5d2f8aa8ce Revert "Use stable cover image routes"
This reverts commit 40abc68a67.
2026-05-23 16:51:55 +09:00
40abc68a67 Use stable cover image routes
Some checks failed
CI / Prepare job (push) Has been cancelled
CI / Build extensions (${{ matrix.chunk.number }}) (push) Has been cancelled
CI / Publish extension repo (push) Has been cancelled
Create Release on Merge / release (push) Has been cancelled
2026-05-23 16:35:24 +09:00
509c1b890a Speed up PDF image reader pages
Some checks failed
CI / Prepare job (push) Has been cancelled
CI / Build extensions (${{ matrix.chunk.number }}) (push) Has been cancelled
CI / Publish extension repo (push) Has been cancelled
Create Release on Merge / release (push) Has been cancelled
2026-05-23 15:20:00 +09:00
ba96a8a2ee Customize Kavita extension for local reader
Some checks failed
CI / Prepare job (push) Has been cancelled
CI / Build extensions (${{ matrix.chunk.number }}) (push) Has been cancelled
CI / Publish extension repo (push) Has been cancelled
Create Release on Merge / release (push) Has been cancelled
2026-05-23 14:03:53 +09:00
Mio.
230cf3764a Merge pull request #51 from MajorTanya/fix-hard-crash-loop
Fix hard crash loop
2025-12-22 05:57:03 +01:00
MajorTanya
600d8ca4ff Bump extVersionCode
This results in version 1.4.23.
2025-12-21 10:11:22 +01:00
MajorTanya
13657ba2dc Fix hard crash loop for unreachable servers
It should be noted that this is a band-aid fix for deeper underlying issues.

The code as-was would attempt a login on every init, including when apps like Mihon would recover from the first crash by going to their global crash handler activity.

This usually happened whenever a user would be unable to connect to their Kavita instance (server not up, different/no networks, etc) and left the user unable to fix the issue by removing the broken API URL or uninstalling the extension through the app's menus.

It doesn't matter if the Kavita instance that is unreachable is disabled in the extension settings, the crash will still occur because the login is performed in the init block, which is called regardless of an extension's specific enable/disable state (to be able to display settings, etc).
2025-12-21 10:04:54 +01:00
Mio.
6780871c0f Merge pull request #46 from dear-clouds/master
Fix(release): Final check
2025-12-10 23:53:06 +01:00
Mio.
05bf6b5904 Fix: Switch back to repo branch before creating release 2025-12-10 23:47:28 +01:00
Mio.
dd0f7dd467 refactor(release): use <details> to avoid weird formatting 2025-12-10 23:44:03 +01:00
Mio.
0168daab08 Fix(release): Handle commits without PR numbers gracefully 2025-12-10 23:36:01 +01:00
Mio.
db005805d5 Merge remote-tracking branch 'origin/master' 2025-12-10 23:29:36 +01:00
Mio.
f207b2a3c2 checkout origin/master to be safe 2025-12-10 23:29:27 +01:00
Mio.
a867c9caf5 Merge branch 'Kareadita:master' into master 2025-12-10 23:25:42 +01:00
Mio.
f04e4c92e7 Merge pull request #45 from dear-clouds/master
Fixing new action
2025-12-10 23:07:24 +01:00
8 changed files with 411 additions and 195 deletions

View File

@@ -25,8 +25,8 @@ jobs:
id: pr_info id: pr_info
run: | run: |
# Fetch and checkout master to analyze history # Fetch and checkout master to analyze history
git fetch origin master:master git fetch origin master
git checkout master git checkout -B master origin/master
# Initialize variables # Initialize variables
FOUND_PR=false FOUND_PR=false
@@ -36,9 +36,11 @@ jobs:
for COMMIT in $MERGE_COMMITS; do for COMMIT in $MERGE_COMMITS; do
# Extract PR number # Extract PR number
PR_NUMBER=$(git show --format=%B -s $COMMIT | grep -oP 'Merge pull request #\K\d+') PR_NUMBER=$(git show --format=%B -s $COMMIT | grep -oP 'Merge pull request #\K\d+' || true)
# Fallback: Try to match squash merge pattern "Title (#123)"
if [ -z "$PR_NUMBER" ]; then if [ -z "$PR_NUMBER" ]; then
PR_NUMBER=$(git show --format=%B -s $COMMIT | grep -oP '\(#\K\d+(?=\))') PR_NUMBER=$(git show --format=%B -s $COMMIT | grep -oP '\(#\K\d+(?=\))' || true)
fi fi
if [ -n "$PR_NUMBER" ]; then if [ -n "$PR_NUMBER" ]; then
@@ -153,6 +155,12 @@ jobs:
echo "EOF" echo "EOF"
} >> $GITHUB_OUTPUT } >> $GITHUB_OUTPUT
# Switch back to 'repo' branch to upload the APK
- name: Final Switch to repo branch
if: steps.check_release.outputs.exists == 'false'
run: |
git checkout repo
- name: Create Release - name: Create Release
if: steps.check_release.outputs.exists == 'false' if: steps.check_release.outputs.exists == 'false'
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
@@ -163,15 +171,15 @@ jobs:
body: | body: |
## 🚀 Release ${{ steps.apk_info.outputs.version }} ## 🚀 Release ${{ steps.apk_info.outputs.version }}
### 📝 Pull Request ${{ steps.pr_info.outputs.pr_body }}
${{ steps.pr_info.outputs.pr_number && format('- Pull Request #%s by @%s', steps.pr_info.outputs.pr_number, steps.pr_info.outputs.pr_author) || 'Manual release' }}
${{ steps.pr_info.outputs.pr_title && format('- Title: %s', steps.pr_info.outputs.pr_title) || '' }}
${{ steps.pr_info.outputs.is_first_contributor == 'true' && format('### 🎉 Welcome @%s on their first contribution!', steps.pr_info.outputs.pr_author) || '' }}
${{ steps.pr_info.outputs.pr_body && format('### 📋 Description:\n%s', steps.pr_info.outputs.pr_body) || '' }} ---
<details>
<summary>📦 View Full Commit Log</summary>
### 📦 Changes since last release:
${{ steps.changelog.outputs.changelog }} ${{ steps.changelog.outputs.changelog }}
</details>
--- ---
🤖 *This release was automatically created* 🤖 *This release was automatically created*

4
.gitignore vendored
View File

@@ -11,3 +11,7 @@ apk/
gen gen
generated-src/ generated-src/
.kotlin .kotlin
signingkey.jks
output.json
tmp/
Inspector.jar

View File

@@ -25,3 +25,5 @@ android.useAndroidX=true
android.enableBuildConfigAsBytecode=true android.enableBuildConfigAsBytecode=true
android.defaults.buildfeatures.resvalues=false android.defaults.buildfeatures.resvalues=false
android.defaults.buildfeatures.shaders=false android.defaults.buildfeatures.shaders=false
android.overridePathCheck=true

View File

@@ -2,7 +2,7 @@ ext {
kmkVersionCode = 1 kmkVersionCode = 1
extName = 'Kavita' extName = 'Kavita'
extClass = '.KavitaFactory' extClass = '.KavitaFactory'
extVersionCode = 21 extVersionCode = 37
isNsfw = false isNsfw = false
} }

View File

@@ -4,34 +4,34 @@ import eu.kanade.tachiyomi.extension.all.kavita.KavitaConstants.noSmartFilterSel
import eu.kanade.tachiyomi.source.model.Filter import eu.kanade.tachiyomi.source.model.Filter
class SmartFiltersFilter(smartFilters: Array<String>) : class SmartFiltersFilter(smartFilters: Array<String>) :
Filter.Select<String>("Smart Filters", arrayOf(noSmartFilterSelected) + smartFilters) Filter.Select<String>("스마트 필터", arrayOf(noSmartFilterSelected) + smartFilters)
class SortFilter(sortables: Array<String>) : Filter.Sort("Sort by", sortables, Selection(0, true)) class SortFilter(sortables: Array<String>) : Filter.Sort("정렬", sortables, Selection(0, true))
val sortableList = listOf( val sortableList = listOf(
Pair("Sort name", 1), Pair("정렬명", 1),
Pair("Created", 2), Pair("생성일", 2),
Pair("Last modified", 3), Pair("마지막 수정", 3),
Pair("Item added", 4), Pair("추가일", 4),
Pair("Time to Read", 5), Pair("읽는 시간", 5),
Pair("Release year", 6), Pair("발매 연도", 6),
Pair("Read Progress", 7), Pair("읽은 진행률", 7),
Pair("Average Rating", 8), Pair("평균 평점", 8),
Pair("Random", 9), Pair("무작위", 9),
) )
class SpecialListFilter(name: String = "Special Lists") : Filter.Select<String>( class SpecialListFilter(name: String = "특수 목록") : Filter.Select<String>(
name, name,
arrayOf("None", "Want to Read", "Reading Lists"), arrayOf("없음", "읽고 싶음", "읽기 목록"),
) )
class StatusFilter : Filter.Select<String>( class StatusFilter : Filter.Select<String>(
"Status", "읽기 상태",
arrayOf("Any", "Unread", "In Progress", "Read"), arrayOf("전체", "안 읽음", "읽는 중", "읽음"),
) )
class StatusSeparator : class StatusSeparator :
Filter.Separator() Filter.Separator()
class StatusFilterGroup(name: String = "Status") : Filter.Group<StatusFilter>( class StatusFilterGroup(name: String = "읽기 상태") : Filter.Group<StatusFilter>(
name, name,
listOf(StatusFilter()), listOf(StatusFilter()),
) )
@@ -39,56 +39,56 @@ class StatusFilterGroup(name: String = "Status") : Filter.Group<StatusFilter>(
// Use Filter.Text directly for release year range // Use Filter.Text directly for release year range
class ReleaseYearRange(name: String) : Filter.Text(name, "") class ReleaseYearRange(name: String) : Filter.Text(name, "")
class ReleaseYearRangeGroup(filters: List<ReleaseYearRange>) : class ReleaseYearRangeGroup(filters: List<ReleaseYearRange>) :
Filter.Group<ReleaseYearRange>("Release Year", filters) Filter.Group<ReleaseYearRange>("발매 연도", filters)
// Use Filter.TriState for genres/tags/age ratings/collections/languages/libraries // Use Filter.TriState for genres/tags/age ratings/collections/languages/libraries
class GenreFilter(name: String) : Filter.TriState(name) class GenreFilter(name: String) : Filter.TriState(name)
class GenreFilterGroup(genres: List<GenreFilter>) : class GenreFilterGroup(genres: List<GenreFilter>) :
Filter.Group<GenreFilter>("Genres", genres) Filter.Group<GenreFilter>("장르", genres)
class TagFilter(name: String) : Filter.TriState(name) class TagFilter(name: String) : Filter.TriState(name)
class TagFilterGroup(tags: List<TagFilter>) : class TagFilterGroup(tags: List<TagFilter>) :
Filter.Group<TagFilter>("Tags", tags) Filter.Group<TagFilter>("태그", tags)
class AgeRatingFilter(name: String) : Filter.TriState(name) class AgeRatingFilter(name: String) : Filter.TriState(name)
class AgeRatingFilterGroup(ageRatings: List<AgeRatingFilter>) : class AgeRatingFilterGroup(ageRatings: List<AgeRatingFilter>) :
Filter.Group<AgeRatingFilter>("Age Rating", ageRatings) Filter.Group<AgeRatingFilter>("연령 등급", ageRatings)
class FormatFilter(name: String) : Filter.CheckBox(name, false) class FormatFilter(name: String) : Filter.CheckBox(name, false)
class FormatsFilterGroup(formats: List<FormatFilter>) : class FormatsFilterGroup(formats: List<FormatFilter>) :
Filter.Group<FormatFilter>("Formats", formats) Filter.Group<FormatFilter>("형식", formats)
class CollectionFilter(name: String) : Filter.TriState(name) class CollectionFilter(name: String) : Filter.TriState(name)
class CollectionFilterGroup(collections: List<CollectionFilter>) : class CollectionFilterGroup(collections: List<CollectionFilter>) :
Filter.Group<CollectionFilter>("Collection", collections) Filter.Group<CollectionFilter>("컬렉션", collections)
class LanguageFilter(name: String) : Filter.TriState(name) class LanguageFilter(name: String) : Filter.TriState(name)
class LanguageFilterGroup(languages: List<LanguageFilter>) : class LanguageFilterGroup(languages: List<LanguageFilter>) :
Filter.Group<LanguageFilter>("Language", languages) Filter.Group<LanguageFilter>("언어", languages)
class LibraryFilter(library: String) : Filter.TriState(library) class LibraryFilter(library: String) : Filter.TriState(library)
class LibrariesFilterGroup(libraries: List<LibraryFilter>) : class LibrariesFilterGroup(libraries: List<LibraryFilter>) :
Filter.Group<LibraryFilter>("Libraries", libraries) Filter.Group<LibraryFilter>("라이브러리", libraries)
class PubStatusFilter(name: String) : Filter.CheckBox(name, false) class PubStatusFilter(name: String) : Filter.CheckBox(name, false)
class PubStatusFilterGroup(status: List<PubStatusFilter>) : class PubStatusFilterGroup(status: List<PubStatusFilter>) :
Filter.Group<PubStatusFilter>("Publication Status", status) Filter.Group<PubStatusFilter>("연재 상태", status)
class UserRating : Filter.Select<Int>( class UserRating : Filter.Select<Int>(
"Minimum Rating", "최소 평점",
arrayOf( arrayOf(
"Any", "전체",
"1 star", "1",
"2 stars", "2",
"3 stars", "3",
"4 stars", "4",
"5 stars", "5",
).mapIndexed { index, _ -> index }.toTypedArray(), ).mapIndexed { index, _ -> index }.toTypedArray(),
) { ) {
override fun toString(): String { override fun toString(): String {
return when (state) { return when (state) {
0 -> "Any" 0 -> "전체"
else -> "$state star${if (state > 1) "s" else ""}" else -> "${state}"
} }
} }
} }
@@ -102,40 +102,40 @@ class PeopleSeparatorFilter :
class WriterPeopleFilter(name: String) : Filter.CheckBox(name, false) class WriterPeopleFilter(name: String) : Filter.CheckBox(name, false)
class WriterPeopleFilterGroup(peoples: List<WriterPeopleFilter>) : class WriterPeopleFilterGroup(peoples: List<WriterPeopleFilter>) :
Filter.Group<WriterPeopleFilter>("Writer", peoples) Filter.Group<WriterPeopleFilter>("작가", peoples)
class PencillerPeopleFilter(name: String) : Filter.CheckBox(name, false) class PencillerPeopleFilter(name: String) : Filter.CheckBox(name, false)
class PencillerPeopleFilterGroup(peoples: List<PencillerPeopleFilter>) : class PencillerPeopleFilterGroup(peoples: List<PencillerPeopleFilter>) :
Filter.Group<PencillerPeopleFilter>("Penciller", peoples) Filter.Group<PencillerPeopleFilter>("펜슬러", peoples)
class InkerPeopleFilter(name: String) : Filter.CheckBox(name, false) class InkerPeopleFilter(name: String) : Filter.CheckBox(name, false)
class InkerPeopleFilterGroup(peoples: List<InkerPeopleFilter>) : class InkerPeopleFilterGroup(peoples: List<InkerPeopleFilter>) :
Filter.Group<InkerPeopleFilter>("Inker", peoples) Filter.Group<InkerPeopleFilter>("잉커", peoples)
class ColoristPeopleFilter(name: String) : Filter.CheckBox(name, false) class ColoristPeopleFilter(name: String) : Filter.CheckBox(name, false)
class ColoristPeopleFilterGroup(peoples: List<ColoristPeopleFilter>) : class ColoristPeopleFilterGroup(peoples: List<ColoristPeopleFilter>) :
Filter.Group<ColoristPeopleFilter>("Colorist", peoples) Filter.Group<ColoristPeopleFilter>("컬러리스트", peoples)
class LettererPeopleFilter(name: String) : Filter.CheckBox(name, false) class LettererPeopleFilter(name: String) : Filter.CheckBox(name, false)
class LettererPeopleFilterGroup(peoples: List<LettererPeopleFilter>) : class LettererPeopleFilterGroup(peoples: List<LettererPeopleFilter>) :
Filter.Group<LettererPeopleFilter>("Letterer", peoples) Filter.Group<LettererPeopleFilter>("레터러", peoples)
class CoverArtistPeopleFilter(name: String) : Filter.CheckBox(name, false) class CoverArtistPeopleFilter(name: String) : Filter.CheckBox(name, false)
class CoverArtistPeopleFilterGroup(peoples: List<CoverArtistPeopleFilter>) : class CoverArtistPeopleFilterGroup(peoples: List<CoverArtistPeopleFilter>) :
Filter.Group<CoverArtistPeopleFilter>("Cover Artist", peoples) Filter.Group<CoverArtistPeopleFilter>("표지 작가", peoples)
class EditorPeopleFilter(name: String) : Filter.CheckBox(name, false) class EditorPeopleFilter(name: String) : Filter.CheckBox(name, false)
class EditorPeopleFilterGroup(peoples: List<EditorPeopleFilter>) : class EditorPeopleFilterGroup(peoples: List<EditorPeopleFilter>) :
Filter.Group<EditorPeopleFilter>("Editor", peoples) Filter.Group<EditorPeopleFilter>("편집자", peoples)
class PublisherPeopleFilter(name: String) : Filter.CheckBox(name, false) class PublisherPeopleFilter(name: String) : Filter.CheckBox(name, false)
class PublisherPeopleFilterGroup(peoples: List<PublisherPeopleFilter>) : class PublisherPeopleFilterGroup(peoples: List<PublisherPeopleFilter>) :
Filter.Group<PublisherPeopleFilter>("Publisher", peoples) Filter.Group<PublisherPeopleFilter>("출판사", peoples)
class CharacterPeopleFilter(name: String) : Filter.CheckBox(name, false) class CharacterPeopleFilter(name: String) : Filter.CheckBox(name, false)
class CharacterPeopleFilterGroup(peoples: List<CharacterPeopleFilter>) : class CharacterPeopleFilterGroup(peoples: List<CharacterPeopleFilter>) :
Filter.Group<CharacterPeopleFilter>("Character", peoples) Filter.Group<CharacterPeopleFilter>("캐릭터", peoples)
class TranslatorPeopleFilter(name: String) : Filter.CheckBox(name, false) class TranslatorPeopleFilter(name: String) : Filter.CheckBox(name, false)
class TranslatorPeopleFilterGroup(peoples: List<TranslatorPeopleFilter>) : class TranslatorPeopleFilterGroup(peoples: List<TranslatorPeopleFilter>) :
Filter.Group<TranslatorPeopleFilter>("Translator", peoples) Filter.Group<TranslatorPeopleFilter>("번역자", peoples)

View File

@@ -81,9 +81,11 @@ import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.io.IOException import java.io.IOException
import java.net.URLEncoder
import java.security.MessageDigest import java.security.MessageDigest
import java.util.Calendar import java.util.Calendar
import java.util.Locale import java.util.Locale
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import kotlin.let import kotlin.let
import kotlin.runCatching import kotlin.runCatching
@@ -122,13 +124,14 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
private val preferences: SharedPreferences by lazy { private val preferences: SharedPreferences by lazy {
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000) Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
} }
private val application: Application by lazy { Injekt.get() }
override val name = "${KavitaInt.KAVITA_NAME} (${preferences.getString(KavitaConstants.customSourceNamePref, suffix)})" override val name = "${KavitaInt.KAVITA_NAME} (${preferences.getString(KavitaConstants.customSourceNamePref, suffix)})"
override val lang = "all" override val lang = "all"
override val supportsLatest = true override val supportsLatest = true
private val apiUrl: String by lazy { getPrefApiUrl() } private val apiUrl: String get() = getPrefApiUrl()
private val apiKey: String by lazy { getPrefApiKey() } private val apiKey: String get() = getPrefApiKey()
override val baseUrl by lazy { getPrefBaseUrl() } override val baseUrl: String get() = 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 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 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 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 private var isLogged = false // Used to know if login was correct and not send login requests anymore
@@ -265,14 +268,17 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
* Custom implementation for fetch popular, latest and search * Custom implementation for fetch popular, latest and search
* Handles and logs errors to provide a more detailed exception to the users. * 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 { try {
val response = client.newCall(request).execute() val response = client.newCall(request).execute()
if (!response.isSuccessful) { if (!response.isSuccessful) {
val code = response.code val code = response.code
throw IOException("Http Error: $code\n ${intl["http_errors_$code"]}\n${intl["check_version"]}") throw IOException("Http Error: $code\n ${intl["http_errors_$code"]}\n${intl["check_version"]}")
} }
popularMangaParse(response) parser(response)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(LOG_TAG, "Error fetching manga", e) Log.e(LOG_TAG, "Error fetching manga", e)
throw e throw e
@@ -288,7 +294,7 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
popularMangaParse(response) popularMangaParse(response)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(LOG_TAG, "Error fetching popular manga", e) Log.e(LOG_TAG, "Error fetching popular manga", e)
MangasPage(emptyList(), false) throw e
} }
} }
@@ -303,7 +309,7 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
readingListParse(response) readingListParse(response)
} }
else -> { // Regular searches (including Want to Read) else -> { // Regular searches (including Want to Read)
fetch(searchMangaRequest(page, query, filters)) fetch(searchMangaRequest(page, query, filters), ::searchMangaParse)
} }
} }
} }
@@ -580,12 +586,12 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
filter.state.forEach { rangeFilter -> filter.state.forEach { rangeFilter ->
if ((rangeFilter as Filter.Text).state.isNotEmpty()) { if ((rangeFilter as Filter.Text).state.isNotEmpty()) {
when (rangeFilter.name) { when (rangeFilter.name) {
"Min" -> filterV2.addStatement( "Min", "최소" -> filterV2.addStatement(
FilterComparison.GreaterThanEqual, FilterComparison.GreaterThanEqual,
FilterField.ReleaseYear, FilterField.ReleaseYear,
rangeFilter.state, rangeFilter.state,
) )
"Max" -> filterV2.addStatement( "Max", "최대" -> filterV2.addStatement(
FilterComparison.LessThanEqual, FilterComparison.LessThanEqual,
FilterField.ReleaseYear, FilterField.ReleaseYear,
rangeFilter.state, rangeFilter.state,
@@ -647,12 +653,12 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
val included = filter.state val included = filter.state
.filter { it.state == STATE_INCLUDE } .filter { it.state == STATE_INCLUDE }
.mapNotNull { ageFilter -> .mapNotNull { ageFilter ->
ageRatingsListMeta.find { it.title == ageFilter.name }?.value?.toString() ageRatingsListMeta.find { it.title == ageFilter.name || translateAgeRating(it.title) == ageFilter.name }?.value?.toString()
} }
val excluded = filter.state val excluded = filter.state
.filter { it.state == STATE_EXCLUDE } .filter { it.state == STATE_EXCLUDE }
.mapNotNull { ageFilter -> .mapNotNull { ageFilter ->
ageRatingsListMeta.find { it.title == ageFilter.name }?.value?.toString() ageRatingsListMeta.find { it.title == ageFilter.name || translateAgeRating(it.title) == ageFilter.name }?.value?.toString()
} }
if (included.isNotEmpty()) { if (included.isNotEmpty()) {
@@ -722,12 +728,12 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
val included = filter.state val included = filter.state
.filter { it.state == STATE_INCLUDE } .filter { it.state == STATE_INCLUDE }
.mapNotNull { languageFilter -> .mapNotNull { languageFilter ->
languagesListMeta.find { it.title == languageFilter.name }?.isoCode languagesListMeta.find { it.title == languageFilter.name || translateLanguage(it.title) == languageFilter.name }?.isoCode
} }
val excluded = filter.state val excluded = filter.state
.filter { it.state == STATE_EXCLUDE } .filter { it.state == STATE_EXCLUDE }
.mapNotNull { languageFilter -> .mapNotNull { languageFilter ->
languagesListMeta.find { it.title == languageFilter.name }?.isoCode languagesListMeta.find { it.title == languageFilter.name || translateLanguage(it.title) == languageFilter.name }?.isoCode
} }
if (included.isNotEmpty()) { if (included.isNotEmpty()) {
@@ -778,7 +784,7 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
is PubStatusFilterGroup -> { is PubStatusFilterGroup -> {
filter.state.forEach { pubStatusFilter -> filter.state.forEach { pubStatusFilter ->
if ((pubStatusFilter as Filter.CheckBox).state) { if ((pubStatusFilter as Filter.CheckBox).state) {
val statusValue = when (pubStatusFilter.name) { val statusValue = when (normalizePublicationStatus(pubStatusFilter.name)) {
"Ongoing" -> 0 "Ongoing" -> 0
"Hiatus" -> 1 "Hiatus" -> 1
"Completed" -> 2 "Completed" -> 2
@@ -1583,15 +1589,17 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
val allChapters = mutableListOf<SChapter>() val allChapters = mutableListOf<SChapter>()
// Get the series name to use as mangaTitle val seriesDtoForContext = seriesId?.let { id: Int ->
val seriesName = seriesId?.let { id: Int -> series.find { it.id == id }
// First try from cache
series.find { it.id == id }?.name
?: runCatching { ?: runCatching {
client.newCall(GET("$apiUrl/Series/$id", headersBuilder().build())) client.newCall(GET("$apiUrl/Series/$id", headersBuilder().build()))
.execute().parseAs<SeriesDto>().name .execute().parseAs<SeriesDto>()
}.getOrNull() }.getOrNull()
} ?: "" }
// Get the series name to use as mangaTitle
val seriesName = seriesDtoForContext?.name ?: ""
val isPdfSeries = seriesDtoForContext?.format == MangaFormat.Pdf.format
Log.d(LOG_TAG, "Processing chapters for series $seriesId with name: '$seriesName'") Log.d(LOG_TAG, "Processing chapters for series $seriesId with name: '$seriesName'")
@@ -1693,7 +1701,7 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
scanlatorFormat = preferences.scanlatorFormat, scanlatorFormat = preferences.scanlatorFormat,
volumePageCount = volume.pages, volumePageCount = volume.pages,
) )
sChapter.url = "/Chapter/${chapter.id}" sChapter.url = chapterUrlWithPageCount(chapter, volume.pages, forceExtractPdf = isPdfSeries)
// For singleFileVolume, ensure the scanlator field reflects webtoon terminology // For singleFileVolume, ensure the scanlator field reflects webtoon terminology
sChapter.scanlator = if (isWebtoon) "Season" else "Volume" sChapter.scanlator = if (isWebtoon) "Season" else "Volume"
@@ -1712,7 +1720,7 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
scanlatorFormat = preferences.scanlatorFormat, scanlatorFormat = preferences.scanlatorFormat,
) )
sChapter.url = "/Chapter/${chapter.id}" sChapter.url = chapterUrlWithPageCount(chapter, forceExtractPdf = isPdfSeries)
allChapters.add(sChapter) allChapters.add(sChapter)
} }
@@ -1757,6 +1765,101 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
return GET("$apiUrl/$chapterId", headersBuilder().build()) return GET("$apiUrl/$chapterId", headersBuilder().build())
} }
private fun chapterUrlWithPageCount(
chapter: ChapterDto,
pageCount: Int = chapter.pages,
forceExtractPdf: Boolean = false,
): String {
val params = mutableListOf<String>()
if (pageCount > 0) {
params += "pages=$pageCount"
}
params += "extractPdf=${shouldExtractPdf(chapter, forceExtractPdf)}"
if (isEpubChapter(chapter)) {
params += "epub=true"
}
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, forceExtractPdf: Boolean = false): Boolean =
forceExtractPdf || chapter.files.orEmpty().any { file ->
file.format == MangaFormat.Pdf.format || file.extension.equals("pdf", ignoreCase = true)
}
private fun isEpubChapter(chapter: ChapterDto): Boolean =
chapter.files.orEmpty().any { file ->
file.format == MangaFormat.Epub.format || file.extension.equals("epub", ignoreCase = true)
}
private fun isEpubFromChapterUrl(chapter: SChapter): Boolean =
chapter.url.substringAfter("epub=", "")
.substringBefore("&")
.equals("true", ignoreCase = true)
private fun isPdfSeries(seriesId: Int?): Boolean =
seriesId?.let { id ->
series.find { it.id == id }?.format == MangaFormat.Pdf.format ||
runCatching {
client.newCall(GET("$apiUrl/Series/$id", headersBuilder().build()))
.execute()
.parseAs<SeriesDto>()
.format == MangaFormat.Pdf.format
}.getOrDefault(false)
} ?: false
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".toHttpUrlOrNull()!!.newBuilder()
.addQueryParameter("chapterId", chapterId.toString())
.addQueryParameter("page", page.toString())
.addQueryParameter("extractPdf", extractPdf.toString())
.addQueryParameter("saveProgress", "true")
.addQueryParameter("apiKey", apiKey)
.addQueryParameter("kavitaClient", kavitaClientHeader())
.addQueryParameter("deviceId", deviceFingerprint())
.build()
.toString()
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)
}
private fun readerEpubPage(chapterId: Any, pageCount: Int, title: String? = null): Page {
val params = mutableListOf(
"apiUrl=${apiUrl.urlEncode()}",
"apiKey=${apiKey.urlEncode()}",
"chapterId=${chapterId.toString().urlEncode()}",
"pages=${pageCount.coerceAtLeast(1)}",
)
if (!title.isNullOrBlank()) {
params += "title=${title.urlEncode()}"
}
val url = "kavita-epub://reader?${params.joinToString("&")}"
return Page(index = 0, url = url, imageUrl = null)
}
private fun String.urlEncode(): String = URLEncoder.encode(this, Charsets.UTF_8.name())
override suspend fun getPageList(chapter: SChapter): List<Page> = withContext(Dispatchers.IO) { override suspend fun getPageList(chapter: SChapter): List<Page> = withContext(Dispatchers.IO) {
// Check if this is a reading list item (has readingListId in URL) // Check if this is a reading list item (has readingListId in URL)
if (chapter.url.contains("readingListId=")) { if (chapter.url.contains("readingListId=")) {
@@ -1804,12 +1907,11 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
} }
// Generate pages with consistent URL format // Generate pages with consistent URL format
return@withContext (0 until chapterDetails.pages).map { i -> if (isEpubChapter(chapterDetails)) {
Page( return@withContext listOf(readerEpubPage(chapterDetails.id, chapterDetails.pages, chapterDetails.titleName ?: chapterDetails.title))
index = i,
imageUrl = "$apiUrl/Reader/image?chapterId=${chapterDetails.id}&page=$i&extractPdf=true&apiKey=$apiKey",
)
} }
val extractPdf = shouldExtractPdf(chapterDetails, isPdfSeries(seriesId))
return@withContext (0 until chapterDetails.pages).map { i -> readerPage(i, chapterDetails.id, extractPdf = extractPdf) }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(LOG_TAG, "Error processing reading list item", e) Log.e(LOG_TAG, "Error processing reading list item", e)
throw e throw e
@@ -1835,31 +1937,25 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
// Get all pages in this volume // Get all pages in this volume
val volumeRequest = GET("$apiUrl/Volume/$volumeId", headersBuilder().build()) val volumeRequest = GET("$apiUrl/Volume/$volumeId", headersBuilder().build())
val volume = client.newCall(volumeRequest).execute().parseAs<VolumeDto>() val volume = client.newCall(volumeRequest).execute().parseAs<VolumeDto>()
val matchingChapter = volume.chapters.firstOrNull() // or match a chapterId if available if (volume.chapters.isEmpty()) {
?: throw IOException(intl["error_no_chapters_found"]) 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",
)
} }
val remainingPages = volume.chapters val volumePages = volume.chapters
.sortedBy { it.number.toFloatOrNull() ?: 0f } .sortedBy { it.number.toFloatOrNull() ?: 0f }
.runningFold(initialPages.size) { acc, chapter -> acc + chapter.pages } .runningFold(0) { acc, chapter -> acc + chapter.pages }
.drop(1)
.zip(volume.chapters.sortedBy { it.number.toFloatOrNull() ?: 0f }) .zip(volume.chapters.sortedBy { it.number.toFloatOrNull() ?: 0f })
.flatMap { (offset, chapter) -> .flatMap { (startIndex, chapter) ->
if (isEpubChapter(chapter)) {
return@withContext listOf(readerEpubPage(chapter.id, chapter.pages, chapter.titleName ?: chapter.title))
}
val extractPdf = shouldExtractPdf(chapter, isPdfSeries(volume.seriesId))
(0 until chapter.pages).map { i -> (0 until chapter.pages).map { i ->
Page( readerPage(startIndex + i, chapter.id, i, extractPdf)
index = offset - chapter.pages + i,
imageUrl = "$apiUrl/Reader/image?chapterId=${chapter.id}&page=$i&extractPdf=true&apiKey=$apiKey",
)
} }
} }
return@withContext (initialPages + remainingPages).toList() return@withContext volumePages
} else { } else {
// Original chapter handling // Original chapter handling
val chapterId = when { val chapterId = when {
@@ -1868,6 +1964,14 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
else -> throw IOException("Invalid chapter URL format") else -> throw IOException("Invalid chapter URL format")
} }
val extractPdf = extractPdfFromChapterUrl(chapter)
pageCountFromChapterUrl(chapter)?.let { pageCount ->
if (isEpubFromChapterUrl(chapter)) {
return@withContext listOf(readerEpubPage(chapterId, pageCount, chapter.name))
}
return@withContext (0 until pageCount).map { i -> readerPage(i, chapterId, extractPdf = extractPdf) }
}
val chapterRequest = GET("$apiUrl/Chapter?chapterId=$chapterId", headersBuilder().build()) val chapterRequest = GET("$apiUrl/Chapter?chapterId=$chapterId", headersBuilder().build())
val response = client.newCall(chapterRequest).execute() val response = client.newCall(chapterRequest).execute()
@@ -1886,23 +1990,22 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
throw IOException(errorMessage) throw IOException(errorMessage)
} }
return@withContext (0 until chapterDetails.pages).map { i -> val fetchedExtractPdf = extractPdf || shouldExtractPdf(chapterDetails)
Page( if (isEpubChapter(chapterDetails)) {
index = i, return@withContext listOf(readerEpubPage(chapterId, chapterDetails.pages, chapterDetails.titleName ?: chapterDetails.title))
imageUrl = "$apiUrl/Reader/image?chapterId=$chapterId&page=$i&extractPdf=true&apiKey=$apiKey",
)
} }
return@withContext (0 until chapterDetails.pages).map { i -> readerPage(i, chapterId, extractPdf = fetchedExtractPdf) }
} }
} catch (e: Exception) { } catch (e: Exception) {
// Fallback to using the scanlator field if we can't get chapter details // Fallback to using the scanlator field if we can't get chapter details
Log.e(LOG_TAG, "Error fetching chapter details, using fallback", e) Log.e(LOG_TAG, "Error fetching chapter details, using fallback", e)
val fallbackPageCount = chapter.scanlator?.replace(" pages", "")?.toIntOrNull() ?: 1 val fallbackPageCount = chapter.scanlator?.replace(" pages", "")?.toIntOrNull() ?: 1
return@withContext (0 until fallbackPageCount).map { i -> val fallbackChapterId = chapter.url
Page( .substringAfter("/Chapter/", chapter.url)
index = i, .substringAfter("chapter_", chapter.url)
imageUrl = "$apiUrl/Reader/image?chapterId=${chapter.url.substringBefore("_")}&page=$i&extractPdf=true&apiKey=$apiKey", .substringBefore("_")
) .substringBefore("?")
} return@withContext (0 until fallbackPageCount).map { i -> readerPage(i, fallbackChapterId, extractPdf = extractPdfFromChapterUrl(chapter)) }
} }
} }
@@ -1927,11 +2030,10 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
} }
val mangaList = filteredSeries.map { helper.createSeriesDto(it, apiUrl, apiKey) } val mangaList = filteredSeries.map { helper.createSeriesDto(it, apiUrl, apiKey) }
MangasPage(mangaList, false) MangasPage(mangaList, helper.hasNextPage(response, result.size))
} catch (e: Exception) { } catch (e: Exception) {
Log.e(LOG_TAG, "Error parsing search results", e) Log.e(LOG_TAG, "Error parsing search results", e)
// Return empty result instead of throwing exception throw IOException(intl["check_version"], e)
MangasPage(emptyList(), false)
} }
} }
@@ -1976,8 +2078,8 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
// Release year filter // Release year filter
ReleaseYearRangeGroup( ReleaseYearRangeGroup(
listOf( listOf(
ReleaseYearRange("Min"), ReleaseYearRange("최소"),
ReleaseYearRange("Max"), ReleaseYearRange("최대"),
), ),
), ),
@@ -1985,21 +2087,64 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
TagFilterGroup(tags.map { TagFilter(it) }), TagFilterGroup(tags.map { TagFilter(it) }),
CollectionFilterGroup(collections.map { CollectionFilter(it) }), CollectionFilterGroup(collections.map { CollectionFilter(it) }),
LibrariesFilterGroup(libraries.map { LibraryFilter(it) }), LibrariesFilterGroup(libraries.map { LibraryFilter(it) }),
LanguageFilterGroup(languages.map { LanguageFilter(it) }), LanguageFilterGroup(languages.map { LanguageFilter(translateLanguage(it)) }),
PubStatusFilterGroup(pubStatus.map { PubStatusFilter(it) }), PubStatusFilterGroup(pubStatus.map { PubStatusFilter(translatePublicationStatus(it)) }),
AgeRatingFilterGroup(ageRatings.map { AgeRatingFilter(it) }), AgeRatingFilterGroup(ageRatings.map { AgeRatingFilter(translateAgeRating(it)) }),
UserRating(), UserRating(),
UserRatingSeparator(), UserRatingSeparator(),
// Special filters section // Special filters section
Filter.Header(intl["filters_special"]), Filter.Header("특수 필터"),
SpecialListFilter(intl["filters_special_list"]), SpecialListFilter("특수 목록"),
SmartFiltersFilter(smartFilters), SmartFiltersFilter(smartFilters),
) )
} }
private fun translatePublicationStatus(value: String): String =
when (value) {
"Ongoing" -> "연재중"
"Hiatus" -> "휴재"
"Completed" -> "완결"
"Cancelled" -> "취소됨"
"Ended" -> "종료됨"
else -> value
}
private fun normalizePublicationStatus(value: String): String =
when (value) {
"연재중" -> "Ongoing"
"휴재" -> "Hiatus"
"완결" -> "Completed"
"취소됨" -> "Cancelled"
"종료됨" -> "Ended"
else -> value
}
private fun translateAgeRating(value: String): String =
when (value) {
"Unknown" -> "알 수 없음"
"Everyone" -> "전체 이용가"
"Teen" -> "청소년"
"Mature" -> "성인"
"Explicit" -> "노골적"
else -> value
}
private fun translateLanguage(value: String): String =
when (value) {
"Unknown" -> "알 수 없음"
"English" -> "영어"
"Korean" -> "한국어"
"Japanese" -> "일본어"
"Chinese" -> "중국어"
"Spanish" -> "스페인어"
"French" -> "프랑스어"
"German" -> "독일어"
else -> value
}
override fun getFilterList(): FilterList { override fun getFilterList(): FilterList {
return getFilterList( return getFilterList(
genres = genresListMeta.map { it.title }, genres = genresListMeta.map { it.title },
@@ -2037,6 +2182,8 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
} }
return Headers.Builder() return Headers.Builder()
.add("User-Agent", "Tachiyomi Kavita v${AppInfo.getVersionName()}") .add("User-Agent", "Tachiyomi Kavita v${AppInfo.getVersionName()}")
.add("X-Kavita-Client", kavitaClientHeader())
.add("X-Device-Id", deviceFingerprint())
.add("Content-Type", "application/json") .add("Content-Type", "application/json")
.add("Authorization", "Bearer $jwtToken") .add("Authorization", "Bearer $jwtToken")
} }
@@ -2044,10 +2191,34 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
private fun setupLoginHeaders(): Headers.Builder { private fun setupLoginHeaders(): Headers.Builder {
return Headers.Builder() return Headers.Builder()
.add("User-Agent", "Tachiyomi Kavita v${AppInfo.getVersionName()}") .add("User-Agent", "Tachiyomi Kavita v${AppInfo.getVersionName()}")
.add("X-Kavita-Client", kavitaClientHeader())
.add("X-Device-Id", deviceFingerprint())
.add("Content-Type", "application/json") .add("Content-Type", "application/json")
.add("Authorization", "Bearer $jwtToken") .add("Authorization", "Bearer $jwtToken")
} }
private fun kavitaClientHeader(): String {
val resources = application.resources
val configuration = resources.configuration
val smallestWidthDp = configuration.smallestScreenWidthDp
val deviceType = when {
smallestWidthDp >= 600 -> "Tablet"
else -> "Mobile"
}
return "opds-client/${AppInfo.getVersionName()} (Kavita Extension; Android; $deviceType)"
}
private fun deviceFingerprint(): String {
val key = "kavita_extension_device_id"
val existing = preferences.getString(key, null)
if (!existing.isNullOrBlank()) return existing
val generated = UUID.randomUUID().toString()
preferences.edit().putString(key, generated).apply()
return generated
}
override fun setupPreferenceScreen(screen: PreferenceScreen) { override fun setupPreferenceScreen(screen: PreferenceScreen) {
val opdsAddressPref = screen.editTextPreference( val opdsAddressPref = screen.editTextPreference(
ADDRESS_TITLE, ADDRESS_TITLE,
@@ -2233,7 +2404,7 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
return EditTextPreference(context).apply { return EditTextPreference(context).apply {
key = preKey key = preKey
this.title = title 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.summary = if (input == null || input.isEmpty()) summary else input
this.setDefaultValue(default) this.setDefaultValue(default)
dialogTitle = title dialogTitle = title
@@ -2249,7 +2420,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 val opdsUrlInPref = opdsUrlInPreferences(newValue.toString()) // We don't allow hot have multiple sources with same ip or domain
if (opdsUrlInPref.isNotEmpty()) { if (opdsUrlInPref.isNotEmpty()) {
// TODO("Add option to allow multiple sources with same url at the cost of tracking") // 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( Toast.makeText(
context, context,
@@ -2259,7 +2430,9 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
throw OpdsurlExistsInPref(intl["pref_opds_duplicated_source_url"] + opdsUrlInPref) throw OpdsurlExistsInPref(intl["pref_opds_duplicated_source_url"] + opdsUrlInPref)
} }
val res = preferences.edit().putString(title, newValue as String).commit() val res = preferences.edit().putString(preKey, newValue as String).commit()
jwtToken = ""
isLogged = false
Toast.makeText( Toast.makeText(
context, context,
intl["restartapp_settings"], intl["restartapp_settings"],
@@ -2537,14 +2710,15 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
private fun doLogin() { private fun doLogin() {
Log.d(LOG_TAG, "Attempting login with address: ${address.takeIf { it.isNotEmpty() } ?: "EMPTY"}") 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") Log.e(LOG_TAG, "OPDS URL is empty or null")
throw IOException(intl["pref_opds_must_setup_address"]) 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"]) throw IOException(intl["pref_opds_badformed_url"])
} }
if (jwtToken.isEmpty()) setupLogin() if (jwtToken.isEmpty() && address.isNotEmpty()) setupLogin()
Log.v(LOG_TAG, "[Login] Starting login") Log.v(LOG_TAG, "[Login] Starting login")
val request = POST( val request = POST(
"$apiUrl/Plugin/authenticate?apiKey=${getPrefKey()}&pluginName=Tachiyomi-Kavita", "$apiUrl/Plugin/authenticate?apiKey=${getPrefKey()}&pluginName=Tachiyomi-Kavita",
@@ -2587,10 +2761,8 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
try { try {
Log.d(LOG_TAG, "Starting Kavita extension initialization") Log.d(LOG_TAG, "Starting Kavita extension initialization")
// First attempt login with timeout
val loginJob = async { doLogin() }
try { try {
loginJob.await() doLogin()
} catch (e: Exception) { } catch (e: Exception) {
Log.e(LOG_TAG, "Login failed during initialization", e) Log.e(LOG_TAG, "Login failed during initialization", e)
initializationError = e initializationError = e

View File

@@ -9,7 +9,7 @@ object KavitaConstants {
const val SPECIAL_NUMBER_OFFSET = 10000000000f const val SPECIAL_NUMBER_OFFSET = 10000000000f
const val customSourceNamePref = "customSourceName" const val customSourceNamePref = "customSourceName"
const val noSmartFilterSelected = "No smart filter loaded" const val noSmartFilterSelected = "스마트 필터 없음"
// Template formatting preferences // Template formatting preferences
const val CHAPTER_TITLE_FORMAT_DEFAULT = "\$CleanTitle" const val CHAPTER_TITLE_FORMAT_DEFAULT = "\$CleanTitle"

View File

@@ -45,14 +45,14 @@ class KavitaHelper {
val seriesMap = SeriesMapper() val seriesMap = SeriesMapper()
fun hasNextPage(response: Response): Boolean { fun hasNextPage(response: Response, itemCount: Int? = null, pageSize: Int = 20): Boolean {
val paginationHeader = response.header("Pagination") val paginationHeader = response.header("Pagination")
var hasNextPage = false
if (!paginationHeader.isNullOrEmpty()) { if (!paginationHeader.isNullOrEmpty()) {
val paginationInfo = json.decodeFromString<PaginationInfo>(paginationHeader) val paginationInfo = json.decodeFromString<PaginationInfo>(paginationHeader)
hasNextPage = paginationInfo.currentPage < paginationInfo.totalPages return paginationInfo.currentPage < paginationInfo.totalPages
} }
return hasNextPage
return itemCount != null && itemCount >= pageSize
} }
fun getIdFromUrl(url: String): Int { fun getIdFromUrl(url: String): Int {
@@ -106,16 +106,7 @@ class KavitaHelper {
ChapterType.Regular -> { ChapterType.Regular -> {
val chapterNum = formatChapterNumber(chapter) val chapterNum = formatChapterNumber(chapter)
val volNum = formatVolumeNumber(volume) val volNum = formatVolumeNumber(volume)
val cleanedTitle = cleanChapterTitle( val cleanedTitle = kavitaDisplayTitle(type, chapter, volume, libraryType, isWebtoon)
titleName,
ChapterTitleContext(
mangaTitle = mangaTitle,
chapterNumber = chapterNum,
volumeNumber = volNum,
volumeName = volume.name.ifBlank { title },
isWebtoon = isWebtoon,
),
)
val finalCleanTitle = cleanedTitle.ifBlank { val finalCleanTitle = cleanedTitle.ifBlank {
defaultCleanTitle(type, chapterNum, volNum, isWebtoon) defaultCleanTitle(type, chapterNum, volNum, isWebtoon)
} }
@@ -160,20 +151,22 @@ class KavitaHelper {
pages = volume.pages, pages = volume.pages,
fileSize = volume.chapters.flatMap { it.files ?: emptyList() }.sumOf { it.bytes }.toDouble(), fileSize = volume.chapters.flatMap { it.files ?: emptyList() }.sumOf { it.bytes }.toDouble(),
volumeNumber = volumeNumber, volumeNumber = volumeNumber,
cleanTitle = when { cleanTitle = kavitaDisplayTitle(type, chapter, volume, libraryType, isWebtoon).ifBlank {
volume.name.isNotBlank() && !volume.name.matches(Regex("^\\d+$")) -> volume.name when {
volume.name.isNotBlank() && volume.name.matches(Regex("^\\d+$")) -> { volume.name.isNotBlank() && !volume.name.matches(Regex("^\\d+$")) -> volume.name
// Handle case where volume name is only a number (e.g., "2") volume.name.isNotBlank() && volume.name.matches(Regex("^\\d+$")) -> {
if (isWebtoon) { // Handle case where volume name is only a number (e.g., "2")
val volNum = volume.name.toIntOrNull()?.toString() ?: volume.name if (isWebtoon) {
"Season $volNum" val volNum = volume.name.toIntOrNull()?.toString() ?: volume.name
} else { "Season $volNum"
val volNum = volume.name.toIntOrNull()?.toString() ?: volume.name } else {
"Volume $volNum" 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, seriesName = seriesName,
@@ -202,26 +195,28 @@ class KavitaHelper {
pages = chapter.pages, pages = chapter.pages,
fileSize = chapter.files?.sumOf { it.bytes }?.toDouble() ?: 0.0, fileSize = chapter.files?.sumOf { it.bytes }?.toDouble() ?: 0.0,
volumeNumber = formatVolumeNumber(volume), volumeNumber = formatVolumeNumber(volume),
cleanTitle = when { cleanTitle = kavitaDisplayTitle(type, chapter, volume, libraryType, isWebtoon).ifBlank {
titleName.isNotBlank() && !titleName.matches(Regex("^\\d+$")) -> titleName when {
titleName.isNotBlank() && titleName.matches(Regex("^\\d+$")) -> { titleName.isNotBlank() && !titleName.matches(Regex("^\\d+$")) -> titleName
// Handle case where titleName is only a number (e.g., "2") titleName.isNotBlank() && titleName.matches(Regex("^\\d+$")) -> {
val num = titleName.toIntOrNull()?.toString()?.padStart(2, '0') ?: titleName // Handle case where titleName is only a number (e.g., "2")
"Special $num" 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, seriesName = seriesName,
libraryName = seriesMap.getSeries(volume.seriesId)?.libraryName ?: "", libraryName = seriesMap.getSeries(volume.seriesId)?.libraryName ?: "",
@@ -237,15 +232,7 @@ class KavitaHelper {
ChapterType.Chapter -> { ChapterType.Chapter -> {
val chapterNum = formatChapterNumber(chapter) val chapterNum = formatChapterNumber(chapter)
val cleanedTitle = cleanChapterTitle( val cleanedTitle = kavitaDisplayTitle(type, chapter, volume, libraryType, isWebtoon)
titleName,
ChapterTitleContext(
mangaTitle = mangaTitle,
chapterNumber = chapterNum,
volumeName = volume.name,
isWebtoon = isWebtoon,
),
)
val finalCleanTitle = cleanedTitle.ifBlank { val finalCleanTitle = cleanedTitle.ifBlank {
defaultCleanTitle(type, chapterNum, formatVolumeNumber(volume), isWebtoon) defaultCleanTitle(type, chapterNum, formatVolumeNumber(volume), isWebtoon)
} }
@@ -280,16 +267,18 @@ class KavitaHelper {
pages = chapter.pages, pages = chapter.pages,
fileSize = chapter.files?.sumOf { it.bytes }?.toDouble() ?: 0.0, fileSize = chapter.files?.sumOf { it.bytes }?.toDouble() ?: 0.0,
volumeNumber = formatVolumeNumber(volume), volumeNumber = formatVolumeNumber(volume),
cleanTitle = when { cleanTitle = kavitaDisplayTitle(type, chapter, volume, libraryType, isWebtoon).ifBlank {
titleName.isNotBlank() && !titleName.matches(Regex("^\\d+$")) && titleName.any { it.isLetter() } -> titleName when {
titleName.isNotBlank() && titleName.matches(Regex("^\\d+$")) -> { titleName.isNotBlank() && !titleName.matches(Regex("^\\d+$")) && titleName.any { it.isLetter() } -> titleName
// Handle case where titleName is only a number (e.g., "2") titleName.isNotBlank() && titleName.matches(Regex("^\\d+$")) -> {
val num = titleName.toIntOrNull()?.toString()?.padStart(3, '0') ?: titleName // Handle case where titleName is only a number (e.g., "2")
"Issue #$num" 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, seriesName = seriesName,
libraryName = seriesMap.getSeries(volume.seriesId)?.libraryName ?: "", 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( internal fun cleanChapterTitle(
originalTitle: String, originalTitle: String,
context: ChapterTitleContext? = null, context: ChapterTitleContext? = null,