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>
This commit is contained in:
Cuong-Tran 2025-10-25 15:22:45 +07:00 committed by Draff
parent b4b8bbe748
commit 0aca7c467c
Signed by: Draff
GPG Key ID: E8A89F3211677653
4 changed files with 275 additions and 82 deletions

View File

@ -1,7 +1,7 @@
ext { ext {
extName = 'Kagane' extName = 'Kagane'
extClass = '.Kagane' extClass = '.Kagane'
extVersionCode = 6 extVersionCode = 7
isNsfw = true isNsfw = true
} }

View File

@ -101,4 +101,6 @@ class ChapterDto(
class ChallengeDto( class ChallengeDto(
@SerialName("access_token") @SerialName("access_token")
val accessToken: String, val accessToken: String,
@SerialName("cache_url")
val cacheUrl: String,
) )

View File

@ -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<String> = emptyList()
private var tagsList: List<String> = emptyList()
private var sourcesList: List<String> = emptyList()
fun fetchMetadata(apiUrl: String, client: okhttp3.OkHttpClient) {
if (metadataFetchAttempts < 3 && !metadataFetched) {
try {
client.newCall(GET("$apiUrl/api/v1/metadata"))
.execute().parseAs<MetadataDto>()
.let { metadata ->
genresList = metadata.getGenresList()
tagsList = metadata.getTagsList()
sourcesList = metadata.getSourcesList()
metadataFetched = true
}
} catch (_: Exception) {
} finally {
metadataFetchAttempts++
}
}
}
@Serializable
data class MetadataDto(
val genres: List<MetadataTagDto>,
val tags: List<MetadataTagDto>,
val sources: List<MetadataTagDto>,
) {
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<SelectFilterOption> = 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<String>,
ratings: List<FilterData> = 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<FilterData> = genresList.map { FilterData(it, it) },
) : JsonMultiSelectTriFilter(
"Genres",
"genres",
genres.map {
MultiSelectTriOption(it.name, it.id)
},
)
internal class TagsFilter(
tags: List<FilterData> = tagsList.map { FilterData(it, it.replaceFirstChar { c -> c.uppercase() }) },
) : JsonMultiSelectTriFilter(
"Tags",
"tags",
tags.map {
MultiSelectTriOption(it.name, it.id)
},
)
internal class SourcesFilter(
sources: List<FilterData> = 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<MultiSelectOption>,
) : Filter.Group<MultiSelectOption>(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<MultiSelectTriOption>,
) : Filter.Group<MultiSelectTriOption>(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)
}

View File

@ -6,11 +6,11 @@ import android.os.Handler
import android.os.Looper import android.os.Looper
import android.util.Base64 import android.util.Base64
import android.view.View import android.view.View
import android.webkit.CookieManager
import android.webkit.JavascriptInterface import android.webkit.JavascriptInterface
import android.webkit.PermissionRequest import android.webkit.PermissionRequest
import android.webkit.WebChromeClient import android.webkit.WebChromeClient
import android.webkit.WebView import android.webkit.WebView
import androidx.preference.ListPreference
import androidx.preference.PreferenceScreen import androidx.preference.PreferenceScreen
import androidx.preference.SwitchPreferenceCompat import androidx.preference.SwitchPreferenceCompat
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
@ -27,11 +27,11 @@ import eu.kanade.tachiyomi.source.online.HttpSource
import keiyoushi.utils.getPreferencesLazy import keiyoushi.utils.getPreferencesLazy
import keiyoushi.utils.parseAs import keiyoushi.utils.parseAs
import keiyoushi.utils.toJsonString 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.buildJsonObject
import kotlinx.serialization.json.put import kotlinx.serialization.json.put
import okhttp3.Cookie
import okhttp3.CookieJar
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
@ -63,41 +63,6 @@ class Kagane : HttpSource(), ConfigurableSource {
private val preferences by getPreferencesLazy() private val preferences by getPreferencesLazy()
override val client = network.cloudflareClient.newBuilder() override val client = network.cloudflareClient.newBuilder()
.cookieJar(
object : CookieJar {
private val cookieManager by lazy { CookieManager.getInstance() }
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
val urlString = url.toString()
cookies.forEach { cookieManager.setCookie(urlString, it.toString()) }
}
override fun loadForRequest(url: HttpUrl): List<Cookie> {
val cookies = cookieManager.getCookie(url.toString()).orEmpty()
val cookieList = mutableListOf<Cookie>()
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(ImageInterceptor())
.addInterceptor(::refreshTokenInterceptor) .addInterceptor(::refreshTokenInterceptor)
.rateLimit(2) .rateLimit(2)
@ -126,6 +91,7 @@ class Kagane : HttpSource(), ConfigurableSource {
throw IOException("Failed to retrieve token") throw IOException("Failed to retrieve token")
} }
accessToken = challenge.accessToken accessToken = challenge.accessToken
cacheUrl = challenge.cacheUrl
response = chain.proceed( response = chain.proceed(
request.newBuilder() request.newBuilder()
.url(url.newBuilder().setQueryParameter("token", accessToken).build()) .url(url.newBuilder().setQueryParameter("token", accessToken).build())
@ -139,27 +105,55 @@ class Kagane : HttpSource(), ConfigurableSource {
// ============================== Popular =============================== // ============================== Popular ===============================
override fun popularMangaRequest(page: Int) = 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) override fun popularMangaParse(response: Response) = searchMangaParse(response)
// =============================== Latest =============================== // =============================== Latest ===============================
override fun latestUpdatesRequest(page: Int) = 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) override fun latestUpdatesParse(response: Response) = searchMangaParse(response)
// =============================== Search =============================== // =============================== Search ===============================
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { 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() .toJsonString()
.toRequestBody("application/json".toMediaType()) .toRequestBody("application/json".toMediaType())
val url = "$apiUrl/api/v1/search".toHttpUrl().newBuilder().apply { val url = "$apiUrl/api/v1/search".toHttpUrl().newBuilder().apply {
addQueryParameter("page", (page - 1).toString()) addQueryParameter("page", (page - 1).toString())
addQueryParameter("mature", preferences.showNsfw.toString())
addQueryParameter("size", 35.toString()) // Default items per request addQueryParameter("size", 35.toString()) // Default items per request
if (query.isNotBlank()) { if (query.isNotBlank()) {
addQueryParameter("name", query) addQueryParameter("name", query)
@ -167,9 +161,12 @@ class Kagane : HttpSource(), ConfigurableSource {
filters.forEach { filter -> filters.forEach { filter ->
when (filter) { when (filter) {
is SortFilter -> { is SortFilter -> {
filter.selected?.let { filter.toUriPart().takeIf { it.isNotEmpty() }
addQueryParameter("sort", filter.toUriPart()) ?.let { uriPart -> addQueryParameter("sort", uriPart) }
} }
is ScanlationsFilter -> {
addQueryParameter("scanlations", filter.state.toString())
} }
else -> {} else -> {}
@ -231,12 +228,13 @@ class Kagane : HttpSource(), ConfigurableSource {
val challengeResp = getChallengeResponse(seriesId, chapterId) val challengeResp = getChallengeResponse(seriesId, chapterId)
accessToken = challengeResp.accessToken accessToken = challengeResp.accessToken
cacheUrl = challengeResp.cacheUrl
if (preferences.dataSaver) { if (preferences.dataSaver) {
chapterId = chapterId + "_ds" chapterId = chapterId + "_ds"
} }
val pages = (0 until pageCount.toInt()).map { page -> 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(seriesId)
addPathSegment("file") addPathSegment("file")
addPathSegment(chapterId) addPathSegment(chapterId)
@ -250,6 +248,7 @@ class Kagane : HttpSource(), ConfigurableSource {
return Observable.just(pages) return Observable.just(pages)
} }
private var cacheUrl = "https://kazana.$domain"
private var accessToken: String = "" private var accessToken: String = ""
private fun getChallengeResponse(seriesId: String, chapterId: String): ChallengeDto { private fun getChallengeResponse(seriesId: String, chapterId: String): ChallengeDto {
val f = "$seriesId:$chapterId".sha256().sliceArray(0 until 16) val f = "$seriesId:$chapterId".sha256().sliceArray(0 until 16)
@ -410,17 +409,26 @@ class Kagane : HttpSource(), ConfigurableSource {
// ============================ Preferences ============================= // ============================ Preferences =============================
private val SharedPreferences.showNsfw private val SharedPreferences.contentRating: List<String>
get() = this.getBoolean(SHOW_NSFW_KEY, true) 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 private val SharedPreferences.dataSaver
get() = this.getBoolean(DATA_SAVER, false) get() = this.getBoolean(DATA_SAVER, false)
override fun setupPreferenceScreen(screen: PreferenceScreen) { override fun setupPreferenceScreen(screen: PreferenceScreen) {
SwitchPreferenceCompat(screen.context).apply { ListPreference(screen.context).apply {
key = SHOW_NSFW_KEY key = CONTENT_RATING
title = "Show nsfw entries" title = "Content Rating"
setDefaultValue(true) entries = CONTENT_RATINGS.map { it.replaceFirstChar { c -> c.uppercase() } }.toTypedArray()
entryValues = CONTENT_RATINGS
summary = "%s"
setDefaultValue(CONTENT_RATING_DEFAULT)
}.let(screen::addPreference) }.let(screen::addPreference)
SwitchPreferenceCompat(screen.context).apply { SwitchPreferenceCompat(screen.context).apply {
key = DATA_SAVER key = DATA_SAVER
title = "Data saver" title = "Data saver"
@ -431,39 +439,35 @@ class Kagane : HttpSource(), ConfigurableSource {
// ============================= Utilities ============================== // ============================= Utilities ==============================
companion object { 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" private const val DATA_SAVER = "data_saver_default"
} }
// ============================= Filters ============================== // ============================= Filters ==============================
override fun getFilterList() = FilterList( private val scope = CoroutineScope(Dispatchers.IO)
SortFilter(), private fun launchIO(block: () -> Unit) = scope.launch { block() }
)
class SortFilter(state: Int = 0) : UriPartFilter( override fun getFilterList(): FilterList {
"Sort By", launchIO { fetchMetadata(apiUrl, client) }
arrayOf( return FilterList(
Pair("Relevance", ""), SortFilter(),
Pair("Popular", "avg_views,desc"), ContentRatingFilter(
Pair("Latest", "updated_at"), preferences.contentRating.toSet(),
Pair("Latest Descending", "updated_at,desc"), ),
Pair("By Name", "series_name"), GenresFilter(),
Pair("By Name Descending", "series_name,desc"), TagsFilter(),
Pair("Books count", "books_count"), SourcesFilter(),
Pair("Books count Descending", "books_count,desc"), Filter.Separator(),
Pair("Created at", "created_at"), ScanlationsFilter(),
Pair("Created at Descending", "created_at,desc"), )
),
state,
)
open class UriPartFilter(
displayName: String,
private val vals: Array<Pair<String, String>>,
state: Int = 0,
) : Filter.Select<String>(displayName, vals.map { it.first }.toTypedArray(), state) {
fun toUriPart() = vals[state].second
val selected get() = vals[state].second.takeUnless { it.isEmpty() }
} }
} }