MMRCMS: Dynamic filter rework (#1315)

* MMRCMS: Dynamic filter rework, remove Last updated sort in Mangas.in

* Formatting

* Show the reset message when filters are fetching

* Linting

* Dynamically fetch sort options

* Add i18n support

* Remove unused import
This commit is contained in:
beerpsi 2024-02-17 13:26:49 +07:00 committed by Draff
parent 5acf24daa9
commit 93c5dbc650
8 changed files with 189 additions and 187 deletions

View File

@ -0,0 +1,10 @@
filter_warning=Ignored if using text search
filter_missing_warning=Press 'Reset' to attempt to show filters
category_filter_title=Category
status_filter_title=Status
type_filter_title=Type
year_filter_title=Year of release
author_filter_title=Author
tag_filter_title=Tag
title_begins_with_filter_title=Title begins with
sort_by_filter_title=Sort by

View File

@ -2,4 +2,8 @@ plugins {
id("lib-multisrc") id("lib-multisrc")
} }
baseVersionCode = 9 baseVersionCode = 10
dependencies {
api(project(":lib:i18n"))
}

View File

@ -2,11 +2,11 @@ package eu.kanade.tachiyomi.multisrc.mmrcms
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.util.Log import android.util.Log
import eu.kanade.tachiyomi.multisrc.mmrcms.MMRCMSUtils.imgAttr import eu.kanade.tachiyomi.lib.i18n.Intl
import eu.kanade.tachiyomi.multisrc.mmrcms.MMRCMSUtils.textWithNewlines
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.asObservableSuccess import eu.kanade.tachiyomi.network.asObservableSuccess
import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.source.model.Filter 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
@ -15,6 +15,9 @@ 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.ParsedHttpSource import eu.kanade.tachiyomi.source.online.ParsedHttpSource
import eu.kanade.tachiyomi.util.asJsoup import eu.kanade.tachiyomi.util.asJsoup
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import okhttp3.FormBody import okhttp3.FormBody
@ -23,14 +26,12 @@ import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import org.jsoup.nodes.Document import org.jsoup.nodes.Document
import org.jsoup.nodes.Element import org.jsoup.nodes.Element
import org.jsoup.select.Elements
import rx.Observable import rx.Observable
import rx.Single
import rx.Subscription
import rx.schedulers.Schedulers
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.text.ParseException
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Locale import java.util.Locale
import java.util.concurrent.locks.ReentrantLock
/** /**
* @param dateFormat The date format used for parsing chapter dates. * @param dateFormat The date format used for parsing chapter dates.
@ -50,7 +51,7 @@ constructor(
vararg useNamedArgumentsBelow: Forbidden, vararg useNamedArgumentsBelow: Forbidden,
private val dateFormat: SimpleDateFormat = SimpleDateFormat("d MMM. yyyy", Locale.US), protected val dateFormat: SimpleDateFormat = SimpleDateFormat("d MMM. yyyy", Locale.US),
protected val itemPath: String = "manga", protected val itemPath: String = "manga",
private val fetchFilterOptions: Boolean = true, private val fetchFilterOptions: Boolean = true,
private val supportsAdvancedSearch: Boolean = true, private val supportsAdvancedSearch: Boolean = true,
@ -70,6 +71,13 @@ constructor(
protected val json: Json by injectLazy() protected val json: Json by injectLazy()
protected val intl = Intl(
lang,
setOf("en"),
"en",
this::class.java.classLoader!!,
)
override fun popularMangaRequest(page: Int) = GET("$baseUrl/filterList?page=$page&sortBy=views&asc=false") override fun popularMangaRequest(page: Int) = GET("$baseUrl/filterList?page=$page&sortBy=views&asc=false")
override fun popularMangaSelector() = searchMangaSelector() override fun popularMangaSelector() = searchMangaSelector()
@ -117,16 +125,13 @@ constructor(
protected var searchDirectory = emptyList<SuggestionDto>() protected var searchDirectory = emptyList<SuggestionDto>()
private var searchQuery = ""
override fun fetchSearchManga( override fun fetchSearchManga(
page: Int, page: Int,
query: String, query: String,
filters: FilterList, filters: FilterList,
): Observable<MangasPage> { ): Observable<MangasPage> {
return if (query.isNotEmpty()) { return if (query.isNotEmpty()) {
if (page == 1 && query != searchQuery) { if (page == 1) {
searchQuery = query
client.newCall(searchMangaRequest(page, query, filters)) client.newCall(searchMangaRequest(page, query, filters))
.asObservableSuccess() .asObservableSuccess()
.map { searchMangaParse(it) } .map { searchMangaParse(it) }
@ -197,26 +202,23 @@ constructor(
setUrlWithoutDomain(anchor.attr("href")) setUrlWithoutDomain(anchor.attr("href"))
title = anchor.text() title = anchor.text()
thumbnail_url = MMRCMSUtils.guessCover(baseUrl, url, element.selectFirst("img")?.imgAttr()) thumbnail_url = guessCover(url, element.selectFirst("img")?.imgAttr())
} }
override fun searchMangaNextPageSelector(): String? = ".pagination a[rel=next]" override fun searchMangaNextPageSelector(): String? = ".pagination a[rel=next]"
protected fun parseSearchDirectory(page: Int): MangasPage { protected fun parseSearchDirectory(page: Int): MangasPage {
val manga = mutableListOf<SManga>() val manga = searchDirectory.subList((page - 1) * 24, page * 24)
val endRange = ((page * 24) - 1).let { if (it <= searchDirectory.lastIndex) it else searchDirectory.lastIndex } .map {
for (i in (((page - 1) * 24)..endRange)) {
manga.add(
SManga.create().apply { SManga.create().apply {
url = "/$itemPath/${searchDirectory[i].data}" url = "/$itemPath/${it.data}"
title = searchDirectory[i].value title = it.value
thumbnail_url = MMRCMSUtils.guessCover(baseUrl, url, null) thumbnail_url = guessCover(url, null)
},
)
} }
}
val hasNextPage = (page + 1) * 24 <= searchDirectory.size
return MangasPage(manga, endRange < searchDirectory.lastIndex) return MangasPage(manga, hasNextPage)
} }
protected val detailAuthor = hashSetOf("author(s)", "autor(es)", "auteur(s)", "著作", "yazar(lar)", "mangaka(lar)", "pengarang/penulis", "pengarang", "penulis", "autor", "المؤلف", "перевод", "autor/autorzy") protected val detailAuthor = hashSetOf("author(s)", "autor(es)", "auteur(s)", "著作", "yazar(lar)", "mangaka(lar)", "pengarang/penulis", "pengarang", "penulis", "autor", "المؤلف", "перевод", "autor/autorzy")
@ -230,8 +232,7 @@ constructor(
@SuppressLint("DefaultLocale") @SuppressLint("DefaultLocale")
override fun mangaDetailsParse(document: Document) = SManga.create().apply { override fun mangaDetailsParse(document: Document) = SManga.create().apply {
title = document.selectFirst(detailsTitleSelector)!!.text() title = document.selectFirst(detailsTitleSelector)!!.text()
thumbnail_url = MMRCMSUtils.guessCover( thumbnail_url = guessCover(
baseUrl,
document.location(), document.location(),
document.selectFirst(".row img.img-responsive")?.imgAttr(), document.selectFirst(".row img.img-responsive")?.imgAttr(),
) )
@ -274,16 +275,16 @@ constructor(
setUrlWithoutDomain(anchor.attr("href")) setUrlWithoutDomain(anchor.attr("href"))
name = cleanChapterName(mangaTitle, titleWrapper.text()) name = cleanChapterName(mangaTitle, titleWrapper.text())
date_upload = runCatching { date_upload = try {
val date = element.selectFirst(".date-chapter-title-rtl")!!.text() val date = element.selectFirst(".date-chapter-title-rtl")!!.text()
dateFormat.parse(date)!!.time dateFormat.parse(date)!!.time
}.getOrDefault(0L) } catch (_: ParseException) {
0L
} catch (_: NullPointerException) {
0L
}
} }
/**
* The word for "Chapter" in your language.
*/
/** /**
* Function to clean up chapter names. Mostly useful for sites that * Function to clean up chapter names. Mostly useful for sites that
@ -309,22 +310,20 @@ constructor(
override fun imageUrlParse(document: Document) = throw UnsupportedOperationException() override fun imageUrlParse(document: Document) = throw UnsupportedOperationException()
override fun getFilterList(): FilterList { override fun getFilterList(): FilterList {
runCatching { fetchFilterOptions() } fetchFilterOptions()
val filters = buildList<Filter<*>> { val filters = buildList {
add(Filter.Header("Note: Ignored if using text search!")) add(Filter.Header(intl["filter_warning"]))
if (fetchFilterOptions && fetchFiltersStatus != FetchFilterStatus.FETCHED) {
if (supportsAdvancedSearch) { add(Filter.Header(intl["filter_missing_warning"]))
if (fetchFilterOptions && (categories.isEmpty() || statuses.isEmpty())) {
add(Filter.Header("Press 'Reset' to attempt to show filter options"))
} }
add(Filter.Separator()) add(Filter.Separator())
if (supportsAdvancedSearch) {
if (categories.isNotEmpty()) { if (categories.isNotEmpty()) {
add( add(
UriMultiSelectFilter( UriMultiSelectFilter(
"Categories", intl["category_filter_title"],
"categories[]", "categories[]",
categories.toTypedArray(), categories.toTypedArray(),
), ),
@ -334,7 +333,7 @@ constructor(
if (statuses.isNotEmpty()) { if (statuses.isNotEmpty()) {
add( add(
UriMultiSelectFilter( UriMultiSelectFilter(
"Statuses", intl["status_filter_title"],
"status[]", "status[]",
statuses.toTypedArray(), statuses.toTypedArray(),
), ),
@ -344,26 +343,20 @@ constructor(
if (tags.isNotEmpty()) { if (tags.isNotEmpty()) {
add( add(
UriMultiSelectFilter( UriMultiSelectFilter(
"Types", intl["type_filter_title"],
"types[]", "types[]",
tags.toTypedArray(), tags.toTypedArray(),
), ),
) )
} }
add(TextFilter("Year of release", "release")) add(TextFilter(intl["year_filter_title"], "release"))
add(TextFilter("Author", "author")) add(TextFilter(intl["author_filter_title"], "author"))
} else { } else {
if (fetchFilterOptions && categories.isEmpty()) {
add(Filter.Header("Press 'Reset' to attempt to show filter options"))
}
add(Filter.Separator())
if (categories.isNotEmpty()) { if (categories.isNotEmpty()) {
add( add(
UriPartFilter( UriPartFilter(
"Category", intl["category_filter_title"],
"cat", "cat",
arrayOf( arrayOf(
"Any" to "", "Any" to "",
@ -373,23 +366,12 @@ constructor(
) )
} }
add( add(UriPartFilter(intl["title_begins_with_filter_title"], "alpha", alphaOptions))
UriPartFilter(
"Title begins with",
"alpha",
arrayOf(
"Any" to "",
*"#ABCDEFGHIJKLMNOPQRSTUVWXYZ".toCharArray().map {
Pair(it.toString(), it.toString())
}.toTypedArray(),
),
),
)
if (tags.isNotEmpty()) { if (tags.isNotEmpty()) {
add( add(
UriPartFilter( UriPartFilter(
"Tag", intl["tag_filter_title"],
"tag", "tag",
arrayOf( arrayOf(
"Any" to "", "Any" to "",
@ -399,40 +381,49 @@ constructor(
) )
} }
add(SortFilter()) if (sortOptions.isNotEmpty()) {
add(SortFilter(intl, sortOptions))
}
} }
} }
return FilterList(filters) return FilterList(filters)
} }
private val alphaOptions by lazy {
arrayOf(
"Any" to "",
*"#ABCDEFGHIJKLMNOPQRSTUVWXYZ".toCharArray().map {
Pair(it.toString(), it.toString())
}.toTypedArray(),
)
}
private var categories = emptyList<Pair<String, String>>() private var categories = emptyList<Pair<String, String>>()
private var statuses = emptyList<Pair<String, String>>() private var statuses = emptyList<Pair<String, String>>()
private var tags = emptyList<Pair<String, String>>() private var tags = emptyList<Pair<String, String>>()
private var sortOptions = emptyArray<Pair<String, String>>()
private var fetchFiltersFailed = false private var fetchFiltersStatus = FetchFilterStatus.NOT_FETCHED
private var fetchFiltersAttempts = 0 private var fetchFiltersAttempts = 0
private val scope = CoroutineScope(Dispatchers.IO)
private val fetchFiltersLock = ReentrantLock() protected open fun fetchFilterOptions() {
protected open fun fetchFilterOptions(): Subscription = Single.fromCallable {
if (!fetchFilterOptions) { if (!fetchFilterOptions) {
return@fromCallable return
} }
fetchFiltersLock.lock() if (fetchFiltersStatus != FetchFilterStatus.NOT_FETCHED || fetchFiltersAttempts >= 3) {
return
if (fetchFiltersAttempts > 3 || (fetchFiltersAttempts > 0 && !fetchFiltersFailed)) {
fetchFiltersLock.unlock()
return@fromCallable
} }
fetchFiltersFailed = try { fetchFiltersStatus = FetchFilterStatus.FETCHING
fetchFiltersAttempts++
scope.launch {
try {
if (supportsAdvancedSearch) { if (supportsAdvancedSearch) {
val document = client.newCall(GET("$baseUrl/advanced-search", headers)).execute().asJsoup() val document = client.newCall(GET("$baseUrl/advanced-search", headers))
.await()
.asJsoup()
categories = document.select("select[name='categories[]'] option").map { categories = document.select("select[name='categories[]'] option").map {
it.text() to it.attr("value") it.text() to it.attr("value")
@ -444,7 +435,9 @@ constructor(
it.text() to it.attr("value") it.text() to it.attr("value")
} }
} else { } else {
val document = client.newCall(GET("$baseUrl/$itemPath-list", headers)).execute().asJsoup() val document = client.newCall(GET("$baseUrl/$itemPath-list", headers))
.await()
.asJsoup()
categories = document.select("a.category").map { categories = document.select("a.category").map {
it.text() to it.attr("href").toHttpUrl().queryParameter("cat")!! it.text() to it.attr("href").toHttpUrl().queryParameter("cat")!!
@ -452,18 +445,43 @@ constructor(
tags = document.select("div.tag-links a").map { tags = document.select("div.tag-links a").map {
it.text() to it.attr("href").toHttpUrl().pathSegments.last() it.text() to it.attr("href").toHttpUrl().pathSegments.last()
} }
sortOptions = document.select("#sort-types label:has(input)").map {
it.ownText() to it.selectFirst("input")!!.id()
}.toTypedArray()
} }
false fetchFiltersStatus = FetchFilterStatus.FETCHED
} catch (e: Throwable) { } catch (e: Exception) {
Log.e(name, "Could not fetch filtering options", e) fetchFiltersStatus = FetchFilterStatus.NOT_FETCHED
true Log.e("MMRCMS/$name", "Could not fetch filters", e)
}
}
} }
fetchFiltersAttempts++ protected fun guessCover(mangaUrl: String, url: String?): String {
fetchFiltersLock.unlock() return if (url == null || url.endsWith("no-image.png")) {
"$baseUrl/uploads/manga/${mangaUrl.substringAfterLast('/')}/cover/cover_250x350.jpg"
} else {
url
} }
.subscribeOn(Schedulers.io()) }
.observeOn(Schedulers.io())
.subscribe() protected fun Element.imgAttr(): String = when {
hasAttr("data-background-image") -> absUrl("data-background-image")
hasAttr("data-cfsrc") -> absUrl("data-cfsrc")
hasAttr("data-lazy-src") -> absUrl("data-lazy-src")
hasAttr("data-src") -> absUrl("data-src")
else -> absUrl("src")
}
protected fun Elements.textWithNewlines() = run {
select("p, br").prepend("\\n")
text().replace("\\n", "\n").replace("\n ", "\n")
}
}
private enum class FetchFilterStatus {
NOT_FETCHED,
FETCHED,
FETCHING,
} }

View File

@ -3,12 +3,12 @@ package eu.kanade.tachiyomi.multisrc.mmrcms
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
data class SearchResultDto( class SearchResultDto(
val suggestions: List<SuggestionDto>, val suggestions: List<SuggestionDto>,
) )
@Serializable @Serializable
data class SuggestionDto( class SuggestionDto(
val value: String, val value: String,
val data: String, val data: String,
) )

View File

@ -1,5 +1,6 @@
package eu.kanade.tachiyomi.multisrc.mmrcms package eu.kanade.tachiyomi.multisrc.mmrcms
import eu.kanade.tachiyomi.lib.i18n.Intl
import eu.kanade.tachiyomi.source.model.Filter import eu.kanade.tachiyomi.source.model.Filter
import okhttp3.HttpUrl import okhttp3.HttpUrl
@ -47,27 +48,23 @@ class UriMultiSelectFilter(
} }
} }
class SortFilter(selection: Selection = Selection(0, true)) : class SortFilter(
intl: Intl,
private val sortables: Array<Pair<String, String>>,
selection: Selection = Selection(0, true),
) :
Filter.Sort( Filter.Sort(
"Sort by", intl["sort_by_filter_title"],
sortables.map { it.second }.toTypedArray(), sortables.map { it.first }.toTypedArray(),
selection, selection,
), ),
UriFilter { UriFilter {
override fun addToUri(builder: HttpUrl.Builder) { override fun addToUri(builder: HttpUrl.Builder) {
val state = state!! val state = state ?: return
builder.apply { builder.apply {
addQueryParameter("sortBy", sortables[state.index].first) addQueryParameter("sortBy", sortables[state.index].second)
addQueryParameter("asc", state.ascending.toString()) addQueryParameter("asc", state.ascending.toString())
} }
} }
companion object {
private val sortables = arrayOf(
"name" to "Name",
"views" to "Popularity",
"last_release" to "Last update",
)
}
} }

View File

@ -1,27 +0,0 @@
package eu.kanade.tachiyomi.multisrc.mmrcms
import org.jsoup.nodes.Element
import org.jsoup.select.Elements
object MMRCMSUtils {
fun guessCover(baseUrl: String, mangaUrl: String, url: String?): String {
return if (url == null || url.endsWith("no-image.png")) {
"$baseUrl/uploads/manga/${mangaUrl.substringAfterLast('/')}/cover/cover_250x350.jpg"
} else {
url
}
}
fun Element.imgAttr(): String = when {
hasAttr("data-background-image") -> absUrl("data-background-image")
hasAttr("data-cfsrc") -> absUrl("data-cfsrc")
hasAttr("data-lazy-src") -> absUrl("data-lazy-src")
hasAttr("data-src") -> absUrl("data-src")
else -> absUrl("src")
}
fun Elements.textWithNewlines() = run {
select("p, br").prepend("\\n")
text().replace("\\n", "\n").replace("\n ", "\n")
}
}

View File

@ -4,7 +4,6 @@ import android.util.Base64
import eu.kanade.tachiyomi.lib.cryptoaes.CryptoAES import eu.kanade.tachiyomi.lib.cryptoaes.CryptoAES
import eu.kanade.tachiyomi.lib.synchrony.Deobfuscator import eu.kanade.tachiyomi.lib.synchrony.Deobfuscator
import eu.kanade.tachiyomi.multisrc.mmrcms.MMRCMS import eu.kanade.tachiyomi.multisrc.mmrcms.MMRCMS
import eu.kanade.tachiyomi.multisrc.mmrcms.MMRCMSUtils
import eu.kanade.tachiyomi.multisrc.mmrcms.SuggestionDto import eu.kanade.tachiyomi.multisrc.mmrcms.SuggestionDto
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.interceptor.rateLimitHost import eu.kanade.tachiyomi.network.interceptor.rateLimitHost
@ -18,6 +17,7 @@ import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import org.jsoup.nodes.Document import org.jsoup.nodes.Document
import java.text.ParseException
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Locale import java.util.Locale
@ -26,6 +26,7 @@ class MangasIn : MMRCMS(
"https://mangas.in", "https://mangas.in",
"es", "es",
supportsAdvancedSearch = false, supportsAdvancedSearch = false,
dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US),
) { ) {
override val client = super.client.newBuilder() override val client = super.client.newBuilder()
.rateLimitHost(baseUrl.toHttpUrl(), 1, 1) .rateLimitHost(baseUrl.toHttpUrl(), 1, 1)
@ -44,7 +45,7 @@ class MangasIn : MMRCMS(
SManga.create().apply { SManga.create().apply {
url = "/$itemPath/${it.slug}" url = "/$itemPath/${it.slug}"
title = it.name title = it.name
thumbnail_url = MMRCMSUtils.guessCover(baseUrl, url, null) thumbnail_url = guessCover(url, null)
} }
} }
val hasNextPage = response.request.url.queryParameter("p")!!.toInt() < data.totalPages val hasNextPage = response.request.url.queryParameter("p")!!.toInt() < data.totalPages
@ -124,7 +125,12 @@ class MangasIn : MMRCMS(
"Capítulo ${it.number}: ${it.name}" "Capítulo ${it.number}: ${it.name}"
} }
date_upload = it.createdAt.parseDate() date_upload = try {
dateFormat.parse(it.createdAt)!!.time
} catch (_: ParseException) {
0L
}
setUrlWithoutDomain("$mangaUrl/${it.slug}") setUrlWithoutDomain("$mangaUrl/${it.slug}")
} }
} }
@ -163,15 +169,9 @@ class MangasIn : MMRCMS(
.map { it.toInt(16).toByte() } .map { it.toInt(16).toByte() }
.toByteArray() .toByteArray()
} }
}
companion object { private val UNESCAPE_REGEX = """\\(.)""".toRegex()
val UNESCAPE_REGEX = """\\(.)""".toRegex() private val RECEIVED_DATA_REGEX = """receivedData\s*=\s*["'](.*)["']\s*;""".toRegex()
val RECEIVED_DATA_REGEX = """receivedData\s*=\s*["'](.*)["']\s*;""".toRegex() private val KEY_REGEX = """decrypt\(.*'(.*)'.*\)""".toRegex()
val KEY_REGEX = """decrypt\(.*'(.*)'.*\)""".toRegex() private val SALTED = "Salted__".toByteArray(Charsets.UTF_8)
val SALTED = "Salted__".toByteArray(Charsets.UTF_8)
val dateFormat by lazy {
SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
}
}
}

View File

@ -4,10 +4,10 @@ import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
data class CDT(val ct: String, val s: String) class CDT(val ct: String, val s: String)
@Serializable @Serializable
data class Chapter( class Chapter(
val slug: String, val slug: String,
val name: String, val name: String,
val number: String, val number: String,
@ -15,13 +15,13 @@ data class Chapter(
) )
@Serializable @Serializable
data class LatestManga( class LatestManga(
@SerialName("manga_name") val name: String, @SerialName("manga_name") val name: String,
@SerialName("manga_slug") val slug: String, @SerialName("manga_slug") val slug: String,
) )
@Serializable @Serializable
data class LatestUpdateResponse( class LatestUpdateResponse(
val data: List<LatestManga>, val data: List<LatestManga>,
val totalPages: Int, val totalPages: Int,
) )