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 {
extName = 'Kagane'
extClass = '.Kagane'
extVersionCode = 6
extVersionCode = 7
isNsfw = true
}

View File

@ -101,4 +101,6 @@ class ChapterDto(
class ChallengeDto(
@SerialName("access_token")
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.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<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(::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<String>
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<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() }
override fun getFilterList(): FilterList {
launchIO { fetchMetadata(apiUrl, client) }
return FilterList(
SortFilter(),
ContentRatingFilter(
preferences.contentRating.toSet(),
),
GenresFilter(),
TagsFilter(),
SourcesFilter(),
Filter.Separator(),
ScanlationsFilter(),
)
}
}