Kagane: Fix chapters error and search (#10819)

* fix extension

* modify url

Co-authored-by: Vetle Ledaal <vetle.ledaal@gmail.com>

---------

Co-authored-by: Vetle Ledaal <vetle.ledaal@gmail.com>
This commit is contained in:
dngonz 2025-10-03 20:43:30 +02:00 committed by Draff
parent e7e9bc349d
commit e533814cc9
Signed by: Draff
GPG Key ID: E8A89F3211677653
3 changed files with 174 additions and 132 deletions

View File

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

View File

@ -9,43 +9,61 @@ import java.text.SimpleDateFormat
import java.util.Locale import java.util.Locale
@Serializable @Serializable
class BookDto( class SearchDto(
val id: String, val content: List<Book>,
val name: String, val last: Boolean,
val source: String,
val metadata: MetadataDto,
val booksMetadata: BooksMetadataDto,
) { ) {
@Serializable fun hasNextPage() = !last
class MetadataDto(
val genres: List<String>,
val status: String,
val summary: String,
)
@Serializable @Serializable
class BooksMetadataDto( class Book(
val authors: List<AuthorDto>,
) {
@Serializable
class AuthorDto(
val name: String, val name: String,
val role: String, val id: String,
) ) {
}
fun toSManga(domain: String): SManga = SManga.create().apply { fun toSManga(domain: String): SManga = SManga.create().apply {
title = name title = name
url = "/series/$id" url = id
description = buildString { thumbnail_url = "$domain/api/v1/series/$id/thumbnail"
append(metadata.summary)
append("\n\n")
append("Source: ")
append(source)
} }
thumbnail_url = "https://api.$domain/api/v1/series/$id/thumbnail" }
author = getRoles(listOf("writer")) }
artist = getRoles(listOf("inker", "colorist", "penciller"))
@Serializable
class DetailsDto(
val data: Data,
) {
@Serializable
class Data(
val metadata: Metadata,
val source: String,
) {
@Serializable
class Metadata(
val genres: List<String>,
val status: String,
val summary: String,
val alternateTitles: List<Title>,
) {
@Serializable
class Title(
val title: String,
)
}
fun toSManga(): SManga = SManga.create().apply {
val summary = StringBuilder()
summary.append(metadata.summary)
.append("\n\n")
.append("Source: ")
.append(source)
if (metadata.alternateTitles.isNotEmpty()) {
summary.append("\n\nAssociated Name(s):")
metadata.alternateTitles.forEach { summary.append("\n").append("${it.title}") }
}
description = summary.toString()
genre = metadata.genres.joinToString() genre = metadata.genres.joinToString()
status = metadata.status.toStatus() status = metadata.status.toStatus()
} }
@ -53,33 +71,38 @@ class BookDto(
private fun String.toStatus(): Int { private fun String.toStatus(): Int {
return when (this) { return when (this) {
"ONGOING" -> SManga.ONGOING "ONGOING" -> SManga.ONGOING
"ENDED" -> SManga.COMPLETED
else -> SManga.COMPLETED else -> SManga.COMPLETED
} }
} }
private fun getRoles(roles: List<String>): String {
return booksMetadata.authors
.filter { roles.contains(it.role) }
.joinToString { it.name }
} }
} }
@Serializable @Serializable
class ChapterDto( class ChapterDto(
val id: String, val data: Data,
val metadata: MetadataDto,
) { ) {
@Serializable @Serializable
class MetadataDto( class Data(
val releaseDate: String? = null, val content: List<Book>,
) {
@Serializable
class Book(
val metadata: Metadata,
val id: String,
val seriesId: String,
val created: String,
) {
@Serializable
class Metadata(
val title: String, val title: String,
) )
fun toSChapter(seriesId: String): SChapter = SChapter.create().apply { fun toSChapter(): SChapter = SChapter.create().apply {
url = "$seriesId;$id" url = "$seriesId;$id"
name = metadata.title name = metadata.title
date_upload = dateFormat.tryParse(metadata.releaseDate) date_upload = dateFormat.tryParse(created)
}
}
} }
companion object { companion object {
@ -93,11 +116,20 @@ class ChapterDto(
class ChallengeDto( class ChallengeDto(
@SerialName("access_token") @SerialName("access_token")
val accessToken: String, val accessToken: String,
@SerialName("page_count")
val pageCount: Int,
) )
@Serializable @Serializable
class PaginationDto( class PagesCountDto(
val hasNext: Boolean, val data: Data,
) ) {
@Serializable
class Data(
val media: PagesCount,
) {
@Serializable
class PagesCount(
@SerialName("pagesCount")
val pagesCount: Int,
)
}
}

View File

@ -17,13 +17,13 @@ import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.network.POST
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.FilterList import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.util.asJsoup
import keiyoushi.utils.getPreferencesLazy import keiyoushi.utils.getPreferencesLazy
import keiyoushi.utils.parseAs import keiyoushi.utils.parseAs
import keiyoushi.utils.toJsonString import keiyoushi.utils.toJsonString
@ -47,9 +47,6 @@ import java.nio.ByteOrder
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
import java.util.concurrent.CountDownLatch import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlin.collections.forEach
import kotlin.getValue
import kotlin.text.split
class Kagane : HttpSource(), ConfigurableSource { class Kagane : HttpSource(), ConfigurableSource {
@ -61,7 +58,7 @@ class Kagane : HttpSource(), ConfigurableSource {
override val lang = "en" override val lang = "en"
override val supportsLatest = false override val supportsLatest = true
private val preferences by getPreferencesLazy() private val preferences by getPreferencesLazy()
@ -141,76 +138,70 @@ class Kagane : HttpSource(), ConfigurableSource {
// ============================== Popular =============================== // ============================== Popular ===============================
override fun popularMangaRequest(page: Int): Request { override fun popularMangaRequest(page: Int) = searchMangaRequest(page, "", FilterList())
return GET("$baseUrl/?page=$page", headers) override fun popularMangaParse(response: Response) = searchMangaParse(response)
}
override fun popularMangaParse(response: Response): MangasPage {
return pageListParse(response, "initialSeriesData")
}
private fun pageListParse(response: Response, key: String): MangasPage {
val data = response.asJsoup().selectFirst("script:containsData($key)")!!.data()
val mangaList = data.getNextData(key)
.parseAs<List<BookDto>>()
.map { it.toSManga(domain) }
val pagination = data.getNextData("pagination", isList = false, selectFirst = false)
.parseAs<PaginationDto>()
return MangasPage(mangaList, pagination.hasNext)
}
// =============================== Latest =============================== // =============================== Latest ===============================
override fun latestUpdatesRequest(page: Int): Request { override fun latestUpdatesRequest(page: Int) =
throw UnsupportedOperationException() searchMangaRequest(page, "", FilterList(SortFilter(0)))
}
override fun latestUpdatesParse(response: Response): MangasPage { override fun latestUpdatesParse(response: Response) = searchMangaParse(response)
throw UnsupportedOperationException()
}
// =============================== Search =============================== // =============================== Search ===============================
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
val url = "$baseUrl/search".toHttpUrl().newBuilder().apply { val body = buildJsonObject { }
addQueryParameter("name", query) .toJsonString()
addQueryParameter("page", page.toString()) .toRequestBody("application/json".toMediaType())
}.build()
return GET(url, headers) 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)
}
filters.forEach { filter ->
when (filter) {
is SortFilter -> {
addQueryParameter("sort", filter.toUriPart())
}
else -> {}
}
}
}
return POST(url.toString(), headers, body)
} }
override fun searchMangaParse(response: Response): MangasPage { override fun searchMangaParse(response: Response): MangasPage {
return pageListParse(response, "ssrData") val dto = response.parseAs<SearchDto>()
val mangas = dto.content.map { it.toSManga(apiUrl) }
return MangasPage(mangas, hasNextPage = dto.hasNextPage())
} }
// =========================== Manga Details ============================ // =========================== Manga Details ============================
override fun mangaDetailsParse(response: Response): SManga { override fun mangaDetailsParse(response: Response): SManga {
val data = response.asJsoup().selectFirst("script:containsData(initialSeriesData)")!!.data() val dto = response.parseAs<DetailsDto>()
.getNextData("initialSeriesData", isList = false) return dto.data.toSManga()
}
return data.parseAs<BookDto>().toSManga(domain) override fun mangaDetailsRequest(manga: SManga): Request {
return GET("$apiUrl/api/v1/series/${manga.url}", apiHeaders)
} }
// ============================== Chapters ============================== // ============================== Chapters ==============================
override fun chapterListParse(response: Response): List<SChapter> { override fun chapterListParse(response: Response): List<SChapter> {
val seriesId = response.request.url.pathSegments.last() val dto = response.parseAs<ChapterDto>()
val data = response.asJsoup().selectFirst("script:containsData(initialBooksData)")!!.data() return dto.data.content.map { it.toSChapter() }.reversed()
.getNextData("initialBooksData")
.parseAs<List<ChapterDto>>()
.reversed()
return data.map { it.toSChapter(seriesId) }
} }
override fun getChapterUrl(chapter: SChapter): String { override fun chapterListRequest(manga: SManga): Request {
val (seriesId, chapterId) = chapter.url.split(";") return GET("$apiUrl/api/v1/books/${manga.url}", apiHeaders)
return "$baseUrl/series/$seriesId/reader/$chapterId"
} }
// =============================== Pages ================================ // =============================== Pages ================================
@ -231,7 +222,8 @@ class Kagane : HttpSource(), ConfigurableSource {
val challengeResp = getChallengeResponse(seriesId, chapterId) val challengeResp = getChallengeResponse(seriesId, chapterId)
accessToken = challengeResp.accessToken accessToken = challengeResp.accessToken
val pages = (0 until challengeResp.pageCount).map { page -> val pageCount = getPageCountResponse(seriesId, chapterId)
val pages = (0 until pageCount).map { page ->
val pageUrl = "$apiUrl/api/v1/books".toHttpUrl().newBuilder().apply { val pageUrl = "$apiUrl/api/v1/books".toHttpUrl().newBuilder().apply {
addPathSegment(seriesId) addPathSegment(seriesId)
addPathSegment("file") addPathSegment("file")
@ -358,6 +350,15 @@ class Kagane : HttpSource(), ConfigurableSource {
.parseAs<ChallengeDto>() .parseAs<ChallengeDto>()
} }
private fun getPageCountResponse(seriesId: String, chapterId: String): Int {
val challengeUrl = "$apiUrl/api/v1/books/$seriesId/metadata/$chapterId"
val dto = client.newCall(GET(challengeUrl, apiHeaders)).execute()
.parseAs<PagesCountDto>()
return dto.data.media.pagesCount
}
private fun concat(vararg arrays: ByteArray): ByteArray = private fun concat(vararg arrays: ByteArray): ByteArray =
arrays.reduce { acc, bytes -> acc + bytes } arrays.reduce { acc, bytes -> acc + bytes }
@ -413,27 +414,36 @@ class Kagane : HttpSource(), ConfigurableSource {
// ============================= Utilities ============================== // ============================= Utilities ==============================
private fun String.getNextData(key: String, isList: Boolean = true, selectFirst: Boolean = true): String {
val (startDel, endDel) = if (isList) '[' to ']' else '{' to '}'
val keyIndex = if (selectFirst) this.indexOf(key) else this.lastIndexOf(key)
val start = this.indexOf(startDel, keyIndex)
var depth = 1
var i = start + 1
while (i < this.length && depth > 0) {
when (this[i]) {
startDel -> depth++
endDel -> depth--
}
i++
}
return "\"${this.substring(start, i)}\"".parseAs<String>()
}
companion object { companion object {
private const val SHOW_NSFW_KEY = "pref_show_nsfw" private const val SHOW_NSFW_KEY = "pref_show_nsfw"
} }
// ============================= Filters ==============================
override fun getFilterList() = FilterList(
SortFilter(),
)
class SortFilter(state: Int = 0) : UriPartFilter(
"Sort By",
arrayOf(
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
}
} }