Kagane: use site's chapter numbers & cache filters in network cache (#11248)

* Kagane: chapter number from site

* Kagane: fetch and cache filters in network cache
This commit is contained in:
AwkwardPeak7 2025-10-26 12:04:15 +05:00 committed by Draff
parent 2b394c8c38
commit afbbe6991f
Signed by: Draff
GPG Key ID: E8A89F3211677653
4 changed files with 97 additions and 46 deletions

View File

@ -1,8 +1,12 @@
ext { ext {
extName = 'Kagane' extName = 'Kagane'
extClass = '.Kagane' extClass = '.Kagane'
extVersionCode = 7 extVersionCode = 8
isNsfw = true isNsfw = true
} }
apply from: "$rootDir/common.gradle" apply from: "$rootDir/common.gradle"
dependencies {
compileOnly("com.squareup.okhttp3:okhttp-brotli:5.0.0-alpha.11")
}

View File

@ -83,12 +83,14 @@ class ChapterDto(
val releaseDate: String?, val releaseDate: String?,
@SerialName("pages_count") @SerialName("pages_count")
val pagesCount: Int, val pagesCount: Int,
@SerialName("number_sort")
val number: Float,
) { ) {
fun toSChapter(index: Int): SChapter = SChapter.create().apply { fun toSChapter(): SChapter = SChapter.create().apply {
url = "$seriesId;$id;$pagesCount" url = "$seriesId;$id;$pagesCount"
name = title name = title
date_upload = dateFormat.tryParse(releaseDate) date_upload = dateFormat.tryParse(releaseDate)
chapter_number = index.toFloat() chapter_number = number
} }
} }

View File

@ -1,9 +1,7 @@
package eu.kanade.tachiyomi.extension.en.kagane package eu.kanade.tachiyomi.extension.en.kagane
import eu.kanade.tachiyomi.extension.en.kagane.Kagane.Companion.CONTENT_RATINGS 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 eu.kanade.tachiyomi.source.model.Filter
import keiyoushi.utils.parseAs
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonObjectBuilder import kotlinx.serialization.json.JsonObjectBuilder
import kotlinx.serialization.json.add import kotlinx.serialization.json.add
@ -11,40 +9,19 @@ import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonArray import kotlinx.serialization.json.putJsonArray
import kotlinx.serialization.json.putJsonObject 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 @Serializable
data class MetadataDto( data class MetadataDto(
val genres: List<MetadataTagDto>, val genres: List<MetadataTagDto>,
val tags: List<MetadataTagDto>, val tags: List<MetadataTagDto>,
val sources: List<MetadataTagDto>, val sources: List<MetadataTagDto>,
) { ) {
fun getGenresList() = genres.map { it.name } fun getGenresList() = genres
fun getTagsList() = tags.sortedByDescending { it.count }.slice(0..200).map { it.name } .map { FilterData(it.name, it.name) }
fun getSourcesList() = sources.map { it.name } fun getTagsList() = tags.sortedByDescending { it.count }
.slice(0..200)
.map { FilterData(it.name, it.name.replaceFirstChar { c -> c.uppercase() }) }
fun getSourcesList() = sources
.map { FilterData(it.name, it.name) }
} }
@Serializable @Serializable
@ -96,7 +73,7 @@ internal class ContentRatingFilter(
) )
internal class GenresFilter( internal class GenresFilter(
genres: List<FilterData> = genresList.map { FilterData(it, it) }, genres: List<FilterData>,
) : JsonMultiSelectTriFilter( ) : JsonMultiSelectTriFilter(
"Genres", "Genres",
"genres", "genres",
@ -106,7 +83,7 @@ internal class GenresFilter(
) )
internal class TagsFilter( internal class TagsFilter(
tags: List<FilterData> = tagsList.map { FilterData(it, it.replaceFirstChar { c -> c.uppercase() }) }, tags: List<FilterData>,
) : JsonMultiSelectTriFilter( ) : JsonMultiSelectTriFilter(
"Tags", "Tags",
"tags", "tags",
@ -116,7 +93,7 @@ internal class TagsFilter(
) )
internal class SourcesFilter( internal class SourcesFilter(
sources: List<FilterData> = sourcesList.map { FilterData(it, it) }, sources: List<FilterData>,
) : JsonMultiSelectFilter( ) : JsonMultiSelectFilter(
"Sources", "Sources",
"sources", "sources",
@ -127,7 +104,7 @@ internal class SourcesFilter(
internal class ScanlationsFilter() : Filter.CheckBox("Show scanlations", true) internal class ScanlationsFilter() : Filter.CheckBox("Show scanlations", true)
internal class FilterData( class FilterData(
val id: String, val id: String,
val name: String, val name: String,
) )

View File

@ -1,10 +1,12 @@
package eu.kanade.tachiyomi.extension.en.kagane package eu.kanade.tachiyomi.extension.en.kagane
import android.annotation.SuppressLint
import android.app.Application import android.app.Application
import android.content.SharedPreferences import android.content.SharedPreferences
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.util.Base64 import android.util.Base64
import android.util.Log
import android.view.View import android.view.View
import android.webkit.JavascriptInterface import android.webkit.JavascriptInterface
import android.webkit.PermissionRequest import android.webkit.PermissionRequest
@ -15,6 +17,7 @@ import androidx.preference.PreferenceScreen
import androidx.preference.SwitchPreferenceCompat import androidx.preference.SwitchPreferenceCompat
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.network.interceptor.rateLimit import eu.kanade.tachiyomi.network.interceptor.rateLimit
import eu.kanade.tachiyomi.source.ConfigurableSource import eu.kanade.tachiyomi.source.ConfigurableSource
import eu.kanade.tachiyomi.source.model.Filter import eu.kanade.tachiyomi.source.model.Filter
@ -30,14 +33,18 @@ import keiyoushi.utils.toJsonString
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put import kotlinx.serialization.json.put
import okhttp3.CacheControl
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
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response import okhttp3.Response
import okhttp3.brotli.BrotliInterceptor
import okhttp3.internal.closeQuietly
import okio.IOException import okio.IOException
import rx.Observable import rx.Observable
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
@ -66,6 +73,11 @@ class Kagane : HttpSource(), ConfigurableSource {
.addInterceptor(ImageInterceptor()) .addInterceptor(ImageInterceptor())
.addInterceptor(::refreshTokenInterceptor) .addInterceptor(::refreshTokenInterceptor)
.rateLimit(2) .rateLimit(2)
// fix disk cache
.apply {
val index = networkInterceptors().indexOfFirst { it is BrotliInterceptor }
if (index >= 0) interceptors().add(networkInterceptors().removeAt(index))
}
.build() .build()
private fun refreshTokenInterceptor(chain: Interceptor.Chain): Response { private fun refreshTokenInterceptor(chain: Interceptor.Chain): Response {
@ -202,7 +214,7 @@ class Kagane : HttpSource(), ConfigurableSource {
override fun chapterListParse(response: Response): List<SChapter> { override fun chapterListParse(response: Response): List<SChapter> {
val dto = response.parseAs<ChapterDto>() val dto = response.parseAs<ChapterDto>()
return dto.content.mapIndexed { i, it -> it.toSChapter(i + 1) }.reversed() return dto.content.map { it -> it.toSChapter() }.reversed()
} }
override fun chapterListRequest(manga: SManga): Request { override fun chapterListRequest(manga: SManga): Request {
@ -250,6 +262,8 @@ class Kagane : HttpSource(), ConfigurableSource {
private var cacheUrl = "https://kazana.$domain" private var cacheUrl = "https://kazana.$domain"
private var accessToken: String = "" private var accessToken: String = ""
@SuppressLint("SetJavaScriptEnabled")
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)
@ -453,21 +467,75 @@ class Kagane : HttpSource(), ConfigurableSource {
// ============================= Filters ============================== // ============================= Filters ==============================
private val scope = CoroutineScope(Dispatchers.IO) private val metadataClient = client.newBuilder()
private fun launchIO(block: () -> Unit) = scope.launch { block() } .addNetworkInterceptor { chain ->
chain.proceed(chain.request()).newBuilder()
.header("Cache-Control", "max-age=${24 * 60 * 60}")
.removeHeader("Pragma")
.removeHeader("Expires")
.build()
}.build()
override fun getFilterList(): FilterList { override fun getFilterList(): FilterList = runBlocking(Dispatchers.IO) {
launchIO { fetchMetadata(apiUrl, client) } val filters: MutableList<Filter<*>> = mutableListOf(
return FilterList(
SortFilter(), SortFilter(),
ContentRatingFilter( ContentRatingFilter(
preferences.contentRating.toSet(), preferences.contentRating.toSet(),
), ),
GenresFilter(), // GenresFilter(),
TagsFilter(), // TagsFilter(),
SourcesFilter(), // SourcesFilter(),
Filter.Separator(), Filter.Separator(),
ScanlationsFilter(), ScanlationsFilter(),
) )
val response = metadataClient.newCall(
GET("$apiUrl/api/v1/metadata", headers, CacheControl.FORCE_CACHE),
).await()
// the cache only request fails if it was not cached already
if (!response.isSuccessful) {
CoroutineScope(Dispatchers.IO).launch {
metadataClient.newCall(
GET("$apiUrl/api/v1/metadata", headers, CacheControl.FORCE_NETWORK),
).await().closeQuietly()
}
filters.addAll(
index = 0,
listOf(
Filter.Header("Press 'reset' to load more filters"),
Filter.Separator(),
),
)
return@runBlocking FilterList(filters)
}
val metadata = try {
response.parseAs<MetadataDto>()
} catch (e: Throwable) {
Log.e(name, "Unable to parse filters", e)
filters.addAll(
index = 0,
listOf(
Filter.Header("Failed to parse additional filters"),
Filter.Separator(),
),
)
return@runBlocking FilterList(filters)
}
filters.addAll(
index = 2,
listOf(
GenresFilter(metadata.getGenresList()),
TagsFilter(metadata.getTagsList()),
SourcesFilter(metadata.getSourcesList()),
),
)
FilterList(filters)
} }
} }