CatManga: 3.0 updates (#9263)
* CatManga: 3.0 updates * CatManga: Bump version
This commit is contained in:
parent
b6fb13ec97
commit
a6c5e5b826
|
@ -6,7 +6,7 @@ ext {
|
||||||
extName = 'CatManga'
|
extName = 'CatManga'
|
||||||
pkgNameSuffix = "en.catmanga"
|
pkgNameSuffix = "en.catmanga"
|
||||||
extClass = '.CatManga'
|
extClass = '.CatManga'
|
||||||
extVersionCode = 4
|
extVersionCode = 5
|
||||||
}
|
}
|
||||||
|
|
||||||
apply from: "$rootDir/common.gradle"
|
apply from: "$rootDir/common.gradle"
|
||||||
|
|
|
@ -0,0 +1,60 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.en.catmanga
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.source.model.SManga
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class CatSeries(
|
||||||
|
val alt_titles: List<String>,
|
||||||
|
val authors: List<String>,
|
||||||
|
val genres: List<String>,
|
||||||
|
val chapters: List<CatSeriesChapter>,
|
||||||
|
val title: String,
|
||||||
|
val series_id: String,
|
||||||
|
val description: String,
|
||||||
|
val status: String,
|
||||||
|
val cover_art: CatSeriesCover,
|
||||||
|
val all_covers: List<CatSeriesCover>
|
||||||
|
) {
|
||||||
|
fun toSManga() = this.let { series ->
|
||||||
|
SManga.create().apply {
|
||||||
|
url = "/series/${series.series_id}"
|
||||||
|
title = series.title
|
||||||
|
thumbnail_url = series.cover_art.source
|
||||||
|
author = series.authors.joinToString(", ")
|
||||||
|
description = series.description
|
||||||
|
genre = series.genres.joinToString(", ")
|
||||||
|
status = when (series.status) {
|
||||||
|
"ongoing" -> SManga.ONGOING
|
||||||
|
"completed" -> SManga.COMPLETED
|
||||||
|
else -> SManga.UNKNOWN
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chapters.isEmpty()) {
|
||||||
|
description = "[COMING SOON] $description"
|
||||||
|
}
|
||||||
|
if (alt_titles.isNotEmpty()) {
|
||||||
|
description += "\n\nAlternative titles:\n"
|
||||||
|
alt_titles.forEach {
|
||||||
|
description += "• $it\n"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class CatSeriesChapter(
|
||||||
|
val title: String? = null,
|
||||||
|
val groups: List<String>,
|
||||||
|
val number: Float,
|
||||||
|
val display_number: String? = null,
|
||||||
|
val volume: Int? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class CatSeriesCover(
|
||||||
|
val source: String,
|
||||||
|
val width: Int,
|
||||||
|
val height: Int
|
||||||
|
)
|
|
@ -10,128 +10,120 @@ 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 eu.kanade.tachiyomi.util.asJsoup
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.ExperimentalSerializationApi
|
||||||
|
import kotlinx.serialization.decodeFromString
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.json.JsonElement
|
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
|
||||||
import kotlinx.serialization.json.decodeFromJsonElement
|
import kotlinx.serialization.json.decodeFromJsonElement
|
||||||
import kotlinx.serialization.json.jsonObject
|
import kotlinx.serialization.json.jsonObject
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
import okhttp3.Request
|
||||||
import okhttp3.Protocol
|
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
|
||||||
import org.jsoup.nodes.Document
|
import org.jsoup.nodes.Document
|
||||||
import rx.Observable
|
import rx.Observable
|
||||||
import uy.kohesive.injekt.injectLazy
|
import uy.kohesive.injekt.injectLazy
|
||||||
import java.net.HttpURLConnection
|
|
||||||
|
|
||||||
|
@ExperimentalSerializationApi
|
||||||
class CatManga : HttpSource() {
|
class CatManga : HttpSource() {
|
||||||
|
|
||||||
private val application: Application by injectLazy()
|
private val application: Application by injectLazy()
|
||||||
|
|
||||||
override val name = "CatManga"
|
override val name = "CatManga"
|
||||||
override val baseUrl = "https://catmanga.org"
|
override val baseUrl = "https://catmanga.org"
|
||||||
override val supportsLatest = true
|
override val supportsLatest = false
|
||||||
override val lang = "en"
|
override val lang = "en"
|
||||||
private val json: Json by injectLazy()
|
private val json: Json by injectLazy()
|
||||||
|
|
||||||
private lateinit var seriesCache: LinkedHashMap<String, JsonSeries> // LinkedHashMap to preserve insertion order
|
private val allSeriesRequest = GET("$baseUrl/api/series/allSeries")
|
||||||
private lateinit var latestSeries: List<String>
|
|
||||||
override val client = super.client.newBuilder().addInterceptor { chain ->
|
|
||||||
// An interceptor which facilitates caching the data retrieved from the homepage
|
|
||||||
when (chain.request().url) {
|
|
||||||
doNothingRequest.url -> Response.Builder().body(
|
|
||||||
"".toResponseBody("text/plain; charset=utf-8".toMediaType())
|
|
||||||
).code(HttpURLConnection.HTTP_NO_CONTENT).message("").protocol(Protocol.HTTP_1_0).request(chain.request()).build()
|
|
||||||
homepageRequest.url -> {
|
|
||||||
/* Homepage embeds a Json Object with information about every single series in the service */
|
|
||||||
val response = chain.proceed(chain.request())
|
|
||||||
val responseBody = response.peekBody(Long.MAX_VALUE).string()
|
|
||||||
val seriesList = response.asJsoup(responseBody).getDataJsonObject()["props"]!!.jsonObject["pageProps"]!!.jsonObject["series"]!!
|
|
||||||
val latests = response.asJsoup(responseBody).getDataJsonObject()["props"]!!.jsonObject["pageProps"]!!.jsonObject["latests"]!!
|
|
||||||
seriesCache = linkedMapOf(
|
|
||||||
*json.decodeFromJsonElement<List<JsonSeries>>(seriesList).map { it.series_id to it }.toTypedArray()
|
|
||||||
)
|
|
||||||
latestSeries = json.decodeFromJsonElement<List<List<JsonElement>>>(latests).map { json.decodeFromJsonElement<JsonSeries>(it[0]).series_id }
|
|
||||||
response
|
|
||||||
}
|
|
||||||
else -> chain.proceed(chain.request())
|
|
||||||
}
|
|
||||||
}.build()
|
|
||||||
|
|
||||||
private val homepageRequest = GET(baseUrl)
|
override fun popularMangaRequest(page: Int) = allSeriesRequest
|
||||||
private val doNothingRequest = GET("https://dev.null")
|
|
||||||
|
|
||||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList) = if (this::seriesCache.isInitialized) doNothingRequest else homepageRequest
|
override fun searchMangaRequest(page: Int, query: String, filters: FilterList) = allSeriesRequest
|
||||||
override fun popularMangaRequest(page: Int) = if (this::seriesCache.isInitialized) doNothingRequest else homepageRequest
|
|
||||||
override fun latestUpdatesRequest(page: Int) = if (this::seriesCache.isInitialized) doNothingRequest else homepageRequest
|
|
||||||
override fun chapterListRequest(manga: SManga) = homepageRequest
|
|
||||||
|
|
||||||
private fun idOf(manga: SManga) = manga.url.substringAfterLast("/")
|
|
||||||
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
|
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
|
||||||
return client.newCall(searchMangaRequest(page, query, filters))
|
return client.newCall(searchMangaRequest(page, query, filters))
|
||||||
.asObservableSuccess()
|
.asObservableSuccess()
|
||||||
.map {
|
.map { response ->
|
||||||
val manga = seriesCache.asSequence().map { it.value }.filter {
|
val manga = json.decodeFromString<List<CatSeries>>(response.body!!.string())
|
||||||
|
.filter {
|
||||||
if (query.startsWith(SERIES_ID_SEARCH_PREFIX)) {
|
if (query.startsWith(SERIES_ID_SEARCH_PREFIX)) {
|
||||||
return@filter it.series_id.contains(query.removePrefix(SERIES_ID_SEARCH_PREFIX), true)
|
return@filter it.series_id.contains(query.removePrefix(SERIES_ID_SEARCH_PREFIX), true)
|
||||||
}
|
}
|
||||||
sequence { yieldAll(it.alt_titles); yield(it.title) }
|
sequence { yieldAll(it.alt_titles); yield(it.title) }
|
||||||
.any { title -> title.contains(query, true) }
|
.any { title -> title.contains(query, true) }
|
||||||
}.map { it.toSManga() }.toList()
|
}
|
||||||
|
.map { it.toSManga() }
|
||||||
|
.toList()
|
||||||
MangasPage(manga, false)
|
MangasPage(manga, false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun fetchMangaDetails(manga: SManga): Observable<SManga> = client.newCall(homepageRequest)
|
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
|
||||||
|
val seriesId = manga.url.substringAfter("/series/")
|
||||||
|
return client.newCall(allSeriesRequest)
|
||||||
.asObservableSuccess()
|
.asObservableSuccess()
|
||||||
.map { seriesCache[idOf(manga)]?.toSManga() ?: manga }
|
.map { response ->
|
||||||
|
json.decodeFromString<List<CatSeries>>(response.body!!.string())
|
||||||
|
.find { it.series_id == seriesId }
|
||||||
|
?.toSManga() ?: manga
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
|
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
|
||||||
val seriesId = manga.url.substringAfter("/series/")
|
val seriesId = manga.url.substringAfter("/series/")
|
||||||
return client.newCall(chapterListRequest(manga))
|
return client.newCall(allSeriesRequest)
|
||||||
.asObservableSuccess()
|
.asObservableSuccess()
|
||||||
.map {
|
.map { response ->
|
||||||
val seriesPrefs = application.getSharedPreferences("source_${id}_time_found:$seriesId", 0)
|
val seriesPrefs = application.getSharedPreferences("source_${id}_time_found:$seriesId", 0)
|
||||||
val seriesPrefsEditor = seriesPrefs.edit()
|
val seriesPrefsEditor = seriesPrefs.edit()
|
||||||
val cl = seriesCache[idOf(manga)]!!.chapters.asReversed().map {
|
val chapters = json.decodeFromString<List<CatSeries>>(response.body!!.string())
|
||||||
val title = it.title ?: ""
|
.find { it.series_id == seriesId }!!
|
||||||
val groups = it.groups.joinToString(", ")
|
.chapters
|
||||||
val number = it.number.content
|
.asReversed()
|
||||||
val displayNumber = it.display_number ?: number
|
.map { chapter ->
|
||||||
|
val title = chapter.title ?: ""
|
||||||
|
val groups = chapter.groups.joinToString(", ")
|
||||||
|
val numberUrl = chapter.number.chapterNumberToUrlPath()
|
||||||
|
val displayNumber = chapter.display_number ?: numberUrl
|
||||||
SChapter.create().apply {
|
SChapter.create().apply {
|
||||||
url = "${manga.url}/$number"
|
url = "${manga.url}/$numberUrl"
|
||||||
chapter_number = number.toFloat()
|
chapter_number = chapter.number
|
||||||
name = "Chapter $displayNumber" + if (title.isNotBlank()) " - $title" else ""
|
|
||||||
scanlator = groups
|
scanlator = groups
|
||||||
|
|
||||||
// Save current time when a chapter is found for the first time, and reuse it on future checks to
|
name = if (chapter.volume != null) {
|
||||||
// prevent manga entry without any new chapter bumped to the top of "Latest chapter" list
|
"Vol.${chapter.volume} "
|
||||||
// when the library is updated.
|
} else {
|
||||||
val currentTimeMillis = System.currentTimeMillis()
|
""
|
||||||
if (!seriesPrefs.contains(number)) {
|
|
||||||
seriesPrefsEditor.putLong(number, currentTimeMillis)
|
|
||||||
}
|
}
|
||||||
date_upload = seriesPrefs.getLong(number, currentTimeMillis)
|
name += "Ch.$displayNumber"
|
||||||
|
if (title.isNotBlank()) {
|
||||||
|
name += " - $title"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save current time when a chapter is found for the first time, and reuse it on future
|
||||||
|
// checks to prevent manga entry without any new chapter bumped to the top of
|
||||||
|
// "Latest chapter" list when the library is updated.
|
||||||
|
val currentTimeMillis = System.currentTimeMillis()
|
||||||
|
if (!seriesPrefs.contains(numberUrl)) {
|
||||||
|
seriesPrefsEditor.putLong(numberUrl, currentTimeMillis)
|
||||||
|
}
|
||||||
|
date_upload = seriesPrefs.getLong(numberUrl, currentTimeMillis)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
seriesPrefsEditor.apply()
|
seriesPrefsEditor.apply()
|
||||||
cl
|
chapters
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun popularMangaParse(response: Response) = MangasPage(seriesCache.map { it.value.toSManga() }, false)
|
override fun popularMangaParse(response: Response): MangasPage {
|
||||||
|
val mangas = json.decodeFromString<List<CatSeries>>(response.body!!.string()).map { it.toSManga() }
|
||||||
override fun latestUpdatesParse(response: Response) = MangasPage(
|
return MangasPage(mangas, false)
|
||||||
latestSeries.map { seriesCache[it]!!.toSManga() },
|
}
|
||||||
false
|
|
||||||
)
|
|
||||||
|
|
||||||
override fun pageListParse(response: Response): List<Page> {
|
override fun pageListParse(response: Response): List<Page> {
|
||||||
return json.decodeFromJsonElement<List<String>>(response.asJsoup().getDataJsonObject()["props"]!!.jsonObject["pageProps"]!!.jsonObject["pages"]!!).mapIndexed { index, s ->
|
val jsonElement = response.asJsoup()
|
||||||
Page(index, "", s)
|
.getDataJsonObject()["props"]!!
|
||||||
}
|
.jsonObject["pageProps"]!!
|
||||||
|
.jsonObject["pages"]!!
|
||||||
|
return json.decodeFromJsonElement<List<String>>(jsonElement).mapIndexed { index, s -> Page(index, "", s) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -139,6 +131,21 @@ class CatManga : HttpSource() {
|
||||||
*/
|
*/
|
||||||
private fun Document.getDataJsonObject() = json.parseToJsonElement(getElementById("__NEXT_DATA__").html()).jsonObject
|
private fun Document.getDataJsonObject() = json.parseToJsonElement(getElementById("__NEXT_DATA__").html()).jsonObject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns string without decimal when it is not relevant
|
||||||
|
*/
|
||||||
|
private fun Float.chapterNumberToUrlPath(): String {
|
||||||
|
return if (toInt().toFloat() == this) toInt().toString() else toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun latestUpdatesRequest(page: Int): Request {
|
||||||
|
throw UnsupportedOperationException("Not used.")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun latestUpdatesParse(response: Response): MangasPage {
|
||||||
|
throw UnsupportedOperationException("Not used.")
|
||||||
|
}
|
||||||
|
|
||||||
override fun mangaDetailsParse(response: Response): SManga {
|
override fun mangaDetailsParse(response: Response): SManga {
|
||||||
throw UnsupportedOperationException("Not used.")
|
throw UnsupportedOperationException("Not used.")
|
||||||
}
|
}
|
||||||
|
@ -159,28 +166,3 @@ class CatManga : HttpSource() {
|
||||||
const val SERIES_ID_SEARCH_PREFIX = "series_id:"
|
const val SERIES_ID_SEARCH_PREFIX = "series_id:"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Serializable
|
|
||||||
private data class JsonImage(val source: String, val width: Int, val height: Int)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
private data class JsonChapter(val title: String? = null, val groups: List<String>, val number: JsonPrimitive, val display_number: String? = null, val volume: Int? = null)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
private data class JsonSeries(val alt_titles: List<String>, val authors: List<String>, val genres: List<String>, val chapters: List<JsonChapter>, val title: String, val series_id: String, val description: String, val status: String, val cover_art: JsonImage, val all_covers: List<JsonImage>) {
|
|
||||||
fun toSManga() = this.let { jsonSeries ->
|
|
||||||
SManga.create().apply {
|
|
||||||
url = "/series/${jsonSeries.series_id}"
|
|
||||||
title = jsonSeries.title
|
|
||||||
thumbnail_url = jsonSeries.cover_art.source
|
|
||||||
author = jsonSeries.authors.joinToString(", ")
|
|
||||||
description = jsonSeries.description
|
|
||||||
genre = jsonSeries.genres.joinToString(", ")
|
|
||||||
status = when (jsonSeries.status) {
|
|
||||||
"ongoing" -> SManga.ONGOING
|
|
||||||
"completed" -> SManga.COMPLETED
|
|
||||||
else -> SManga.UNKNOWN
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
Loading…
Reference in New Issue