From 0aca7c467c54e80f108c57ab4f85889ffc819db4 Mon Sep 17 00:00:00 2001 From: Cuong-Tran <16017808+cuong-tran@users.noreply.github.com> Date: Sat, 25 Oct 2025 15:22:45 +0700 Subject: [PATCH] Kagane: Fix image loading, add more filters, fix NSFW (#11224) * Kagane: Fix image loading * Kagane: Add content-rating filter Close #11158 * Kagane: Add Sources filter * Kagane: Add Genres/Tags filter * Kagane: Add Scanlations * refactor * fetching image URL from challenge * fetching image URL from challenge * enable scanlations for browsing * fetch genres, tags & sources list * Using `Filter.Sort` --------- Co-authored-by: kana-shii <79055104+kana-shii@users.noreply.github.com> --- src/en/kagane/build.gradle | 2 +- .../tachiyomi/extension/en/kagane/Dto.kt | 2 + .../tachiyomi/extension/en/kagane/Filters.kt | 187 ++++++++++++++++++ .../tachiyomi/extension/en/kagane/Kagane.kt | 166 ++++++++-------- 4 files changed, 275 insertions(+), 82 deletions(-) create mode 100644 src/en/kagane/src/eu/kanade/tachiyomi/extension/en/kagane/Filters.kt diff --git a/src/en/kagane/build.gradle b/src/en/kagane/build.gradle index c37e99cc2..3fd0ef87a 100644 --- a/src/en/kagane/build.gradle +++ b/src/en/kagane/build.gradle @@ -1,7 +1,7 @@ ext { extName = 'Kagane' extClass = '.Kagane' - extVersionCode = 6 + extVersionCode = 7 isNsfw = true } diff --git a/src/en/kagane/src/eu/kanade/tachiyomi/extension/en/kagane/Dto.kt b/src/en/kagane/src/eu/kanade/tachiyomi/extension/en/kagane/Dto.kt index a0a99aa8f..8e28cea7c 100644 --- a/src/en/kagane/src/eu/kanade/tachiyomi/extension/en/kagane/Dto.kt +++ b/src/en/kagane/src/eu/kanade/tachiyomi/extension/en/kagane/Dto.kt @@ -101,4 +101,6 @@ class ChapterDto( class ChallengeDto( @SerialName("access_token") val accessToken: String, + @SerialName("cache_url") + val cacheUrl: String, ) diff --git a/src/en/kagane/src/eu/kanade/tachiyomi/extension/en/kagane/Filters.kt b/src/en/kagane/src/eu/kanade/tachiyomi/extension/en/kagane/Filters.kt new file mode 100644 index 000000000..c23748ebd --- /dev/null +++ b/src/en/kagane/src/eu/kanade/tachiyomi/extension/en/kagane/Filters.kt @@ -0,0 +1,187 @@ +package eu.kanade.tachiyomi.extension.en.kagane + +import eu.kanade.tachiyomi.extension.en.kagane.Kagane.Companion.CONTENT_RATINGS +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.source.model.Filter +import keiyoushi.utils.parseAs +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonObjectBuilder +import kotlinx.serialization.json.add +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonArray +import kotlinx.serialization.json.putJsonObject + +private var metadataFetchAttempts: Int = 0 +private var metadataFetched = false + +private var genresList: List = emptyList() +private var tagsList: List = emptyList() +private var sourcesList: List = emptyList() + +fun fetchMetadata(apiUrl: String, client: okhttp3.OkHttpClient) { + if (metadataFetchAttempts < 3 && !metadataFetched) { + try { + client.newCall(GET("$apiUrl/api/v1/metadata")) + .execute().parseAs() + .let { metadata -> + genresList = metadata.getGenresList() + tagsList = metadata.getTagsList() + sourcesList = metadata.getSourcesList() + metadataFetched = true + } + } catch (_: Exception) { + } finally { + metadataFetchAttempts++ + } + } +} + +@Serializable +data class MetadataDto( + val genres: List, + val tags: List, + val sources: List, +) { + fun getGenresList() = genres.map { it.name } + fun getTagsList() = tags.sortedByDescending { it.count }.slice(0..200).map { it.name } + fun getSourcesList() = sources.map { it.name } +} + +@Serializable +data class MetadataTagDto( + val name: String, + val count: Int = 0, +) + +internal class SortFilter( + selection: Selection = Selection(0, false), + private val options: List = getSortFilter(), +) : Filter.Sort( + "Sort By", + options.map { it.name }.toTypedArray(), + selection, +) { + val selected: SelectFilterOption + get() = state?.index?.let { options.getOrNull(it) } ?: options[0] + + fun toUriPart(): String { + val base = selected.value + val order = if (state?.ascending == true) "" else ",desc" + return if (base.isNotEmpty()) base + order else "" + } +} + +private fun getSortFilter() = listOf( + SelectFilterOption("Relevance", ""), + SelectFilterOption("Popular", "avg_views"), + SelectFilterOption("Latest", "updated_at"), + SelectFilterOption("By Name", "series_name"), + SelectFilterOption("Books count", "books_count"), + SelectFilterOption("Created at", "created_at"), +) + +internal class SelectFilterOption(val name: String, val value: String) + +internal class ContentRatingFilter( + defaultRatings: Set, + ratings: List = CONTENT_RATINGS.map { FilterData(it, it.replaceFirstChar { c -> c.uppercase() }) }, +) : JsonMultiSelectFilter( + "Content Rating", + "content_rating", + ratings.map { + MultiSelectOption(it.name, it.id).apply { + state = defaultRatings.contains(it.id) + } + }, +) + +internal class GenresFilter( + genres: List = genresList.map { FilterData(it, it) }, +) : JsonMultiSelectTriFilter( + "Genres", + "genres", + genres.map { + MultiSelectTriOption(it.name, it.id) + }, +) + +internal class TagsFilter( + tags: List = tagsList.map { FilterData(it, it.replaceFirstChar { c -> c.uppercase() }) }, +) : JsonMultiSelectTriFilter( + "Tags", + "tags", + tags.map { + MultiSelectTriOption(it.name, it.id) + }, +) + +internal class SourcesFilter( + sources: List = sourcesList.map { FilterData(it, it) }, +) : JsonMultiSelectFilter( + "Sources", + "sources", + sources.map { + MultiSelectOption(it.name, it.id) + }, +) + +internal class ScanlationsFilter() : Filter.CheckBox("Show scanlations", true) + +internal class FilterData( + val id: String, + val name: String, +) + +internal open class MultiSelectOption(name: String, val id: String = name) : Filter.CheckBox(name, false) + +internal open class JsonMultiSelectFilter( + name: String, + private val param: String, + genres: List, +) : Filter.Group(name, genres), JsonFilter { + override fun addToJsonObject(builder: JsonObjectBuilder) { + val whatToInclude = state.filter { it.state }.map { it.id } + + if (whatToInclude.isNotEmpty()) { + builder.putJsonArray(param) { + whatToInclude.forEach { add(it) } + } + } + } +} + +internal open class MultiSelectTriOption(name: String, val id: String = name) : Filter.TriState(name) + +internal open class JsonMultiSelectTriFilter( + name: String, + private val param: String, + genres: List, +) : Filter.Group(name, genres), JsonFilter { + override fun addToJsonObject(builder: JsonObjectBuilder) { + val whatToInclude = state.filter { it.state == TriState.STATE_INCLUDE }.map { it.id } + val whatToExclude = state.filter { it.state == TriState.STATE_EXCLUDE }.map { it.id } + + with(builder) { + if (whatToInclude.isNotEmpty()) { + putJsonObject("inclusive_$param") { + putJsonArray("values") { + whatToInclude.forEach { add(it) } + } + put("match_all", true) + } + } + if (whatToExclude.isNotEmpty()) { + putJsonObject("exclusive_$param") { + putJsonArray("values") { + whatToExclude.forEach { add(it) } + } + put("match_all", false) + } + } + } + } +} + +internal interface JsonFilter { + fun addToJsonObject(builder: JsonObjectBuilder) +} diff --git a/src/en/kagane/src/eu/kanade/tachiyomi/extension/en/kagane/Kagane.kt b/src/en/kagane/src/eu/kanade/tachiyomi/extension/en/kagane/Kagane.kt index c4d544c76..17b298808 100644 --- a/src/en/kagane/src/eu/kanade/tachiyomi/extension/en/kagane/Kagane.kt +++ b/src/en/kagane/src/eu/kanade/tachiyomi/extension/en/kagane/Kagane.kt @@ -6,11 +6,11 @@ import android.os.Handler import android.os.Looper import android.util.Base64 import android.view.View -import android.webkit.CookieManager import android.webkit.JavascriptInterface import android.webkit.PermissionRequest import android.webkit.WebChromeClient import android.webkit.WebView +import androidx.preference.ListPreference import androidx.preference.PreferenceScreen import androidx.preference.SwitchPreferenceCompat import eu.kanade.tachiyomi.network.GET @@ -27,11 +27,11 @@ import eu.kanade.tachiyomi.source.online.HttpSource import keiyoushi.utils.getPreferencesLazy import keiyoushi.utils.parseAs import keiyoushi.utils.toJsonString +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put -import okhttp3.Cookie -import okhttp3.CookieJar -import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.Interceptor import okhttp3.MediaType.Companion.toMediaType @@ -63,41 +63,6 @@ class Kagane : HttpSource(), ConfigurableSource { private val preferences by getPreferencesLazy() override val client = network.cloudflareClient.newBuilder() - .cookieJar( - object : CookieJar { - private val cookieManager by lazy { CookieManager.getInstance() } - - override fun saveFromResponse(url: HttpUrl, cookies: List) { - val urlString = url.toString() - cookies.forEach { cookieManager.setCookie(urlString, it.toString()) } - } - - override fun loadForRequest(url: HttpUrl): List { - val cookies = cookieManager.getCookie(url.toString()).orEmpty() - val cookieList = mutableListOf() - var hasNsfwCookie = false - - cookies.split(";").mapNotNullTo(cookieList) { c -> - var cookieValue = c - if (url.host == domain && c.contains("kagane_mature_content")) { - hasNsfwCookie = true - val (key, _) = c.split("=") - cookieValue = "$key=${preferences.showNsfw}" - } - - Cookie.parse(url, cookieValue) - } - - if (!hasNsfwCookie && url.host == domain) { - Cookie.parse(url, "kagane_mature_content=${preferences.showNsfw}")?.let { - cookieList.add(it) - } - } - - return cookieList - } - }, - ) .addInterceptor(ImageInterceptor()) .addInterceptor(::refreshTokenInterceptor) .rateLimit(2) @@ -126,6 +91,7 @@ class Kagane : HttpSource(), ConfigurableSource { throw IOException("Failed to retrieve token") } accessToken = challenge.accessToken + cacheUrl = challenge.cacheUrl response = chain.proceed( request.newBuilder() .url(url.newBuilder().setQueryParameter("token", accessToken).build()) @@ -139,27 +105,55 @@ class Kagane : HttpSource(), ConfigurableSource { // ============================== Popular =============================== override fun popularMangaRequest(page: Int) = - searchMangaRequest(page, "", FilterList(SortFilter(1))) + searchMangaRequest( + page, + "", + FilterList( + SortFilter(Filter.Sort.Selection(1, false)), + ContentRatingFilter( + preferences.contentRating.toSet(), + ), + ScanlationsFilter(), + ), + ) override fun popularMangaParse(response: Response) = searchMangaParse(response) // =============================== Latest =============================== override fun latestUpdatesRequest(page: Int) = - searchMangaRequest(page, "", FilterList(SortFilter(2))) + searchMangaRequest( + page, + "", + FilterList( + SortFilter(Filter.Sort.Selection(2, false)), + ContentRatingFilter( + preferences.contentRating.toSet(), + ), + ScanlationsFilter(), + ), + ) override fun latestUpdatesParse(response: Response) = searchMangaParse(response) // =============================== Search =============================== override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { - val body = buildJsonObject { } + val body = buildJsonObject { + filters.forEach { filter -> + when (filter) { + is JsonFilter -> { + filter.addToJsonObject(this) + } + else -> {} + } + } + } .toJsonString() .toRequestBody("application/json".toMediaType()) val url = "$apiUrl/api/v1/search".toHttpUrl().newBuilder().apply { addQueryParameter("page", (page - 1).toString()) - addQueryParameter("mature", preferences.showNsfw.toString()) addQueryParameter("size", 35.toString()) // Default items per request if (query.isNotBlank()) { addQueryParameter("name", query) @@ -167,9 +161,12 @@ class Kagane : HttpSource(), ConfigurableSource { filters.forEach { filter -> when (filter) { is SortFilter -> { - filter.selected?.let { - addQueryParameter("sort", filter.toUriPart()) - } + filter.toUriPart().takeIf { it.isNotEmpty() } + ?.let { uriPart -> addQueryParameter("sort", uriPart) } + } + + is ScanlationsFilter -> { + addQueryParameter("scanlations", filter.state.toString()) } else -> {} @@ -231,12 +228,13 @@ class Kagane : HttpSource(), ConfigurableSource { val challengeResp = getChallengeResponse(seriesId, chapterId) accessToken = challengeResp.accessToken + cacheUrl = challengeResp.cacheUrl if (preferences.dataSaver) { chapterId = chapterId + "_ds" } val pages = (0 until pageCount.toInt()).map { page -> - val pageUrl = "$apiUrl/api/v1/books".toHttpUrl().newBuilder().apply { + val pageUrl = "$cacheUrl/api/v1/books".toHttpUrl().newBuilder().apply { addPathSegment(seriesId) addPathSegment("file") addPathSegment(chapterId) @@ -250,6 +248,7 @@ class Kagane : HttpSource(), ConfigurableSource { return Observable.just(pages) } + private var cacheUrl = "https://kazana.$domain" private var accessToken: String = "" private fun getChallengeResponse(seriesId: String, chapterId: String): ChallengeDto { val f = "$seriesId:$chapterId".sha256().sliceArray(0 until 16) @@ -410,17 +409,26 @@ class Kagane : HttpSource(), ConfigurableSource { // ============================ Preferences ============================= - private val SharedPreferences.showNsfw - get() = this.getBoolean(SHOW_NSFW_KEY, true) + private val SharedPreferences.contentRating: List + get() { + val maxRating = this.getString(CONTENT_RATING, CONTENT_RATING_DEFAULT) + val index = CONTENT_RATINGS.indexOfFirst { it == maxRating } + return CONTENT_RATINGS.slice(0..index.coerceAtLeast(0)) + } + private val SharedPreferences.dataSaver get() = this.getBoolean(DATA_SAVER, false) override fun setupPreferenceScreen(screen: PreferenceScreen) { - SwitchPreferenceCompat(screen.context).apply { - key = SHOW_NSFW_KEY - title = "Show nsfw entries" - setDefaultValue(true) + ListPreference(screen.context).apply { + key = CONTENT_RATING + title = "Content Rating" + entries = CONTENT_RATINGS.map { it.replaceFirstChar { c -> c.uppercase() } }.toTypedArray() + entryValues = CONTENT_RATINGS + summary = "%s" + setDefaultValue(CONTENT_RATING_DEFAULT) }.let(screen::addPreference) + SwitchPreferenceCompat(screen.context).apply { key = DATA_SAVER title = "Data saver" @@ -431,39 +439,35 @@ class Kagane : HttpSource(), ConfigurableSource { // ============================= Utilities ============================== companion object { - private const val SHOW_NSFW_KEY = "pref_show_nsfw" + private const val CONTENT_RATING = "pref_content_rating" + private const val CONTENT_RATING_DEFAULT = "pornographic" + internal val CONTENT_RATINGS = arrayOf( + "safe", + "suggestive", + "erotica", + "pornographic", + ) + private const val DATA_SAVER = "data_saver_default" } // ============================= Filters ============================== - override fun getFilterList() = FilterList( - SortFilter(), - ) + private val scope = CoroutineScope(Dispatchers.IO) + private fun launchIO(block: () -> Unit) = scope.launch { block() } - class SortFilter(state: Int = 0) : UriPartFilter( - "Sort By", - arrayOf( - Pair("Relevance", ""), - Pair("Popular", "avg_views,desc"), - Pair("Latest", "updated_at"), - Pair("Latest Descending", "updated_at,desc"), - Pair("By Name", "series_name"), - Pair("By Name Descending", "series_name,desc"), - Pair("Books count", "books_count"), - Pair("Books count Descending", "books_count,desc"), - Pair("Created at", "created_at"), - Pair("Created at Descending", "created_at,desc"), - ), - state, - ) - - open class UriPartFilter( - displayName: String, - private val vals: Array>, - state: Int = 0, - ) : Filter.Select(displayName, vals.map { it.first }.toTypedArray(), state) { - fun toUriPart() = vals[state].second - val selected get() = vals[state].second.takeUnless { it.isEmpty() } + override fun getFilterList(): FilterList { + launchIO { fetchMetadata(apiUrl, client) } + return FilterList( + SortFilter(), + ContentRatingFilter( + preferences.contentRating.toSet(), + ), + GenresFilter(), + TagsFilter(), + SourcesFilter(), + Filter.Separator(), + ScanlationsFilter(), + ) } }