Comick.fun: Code rewritten (#13620)
* Rewritten the entire extension to improve readability, speed and pave the way for future upgrades * Request changes * Move date formatter to a constant to avoid being recreated every chapter * Code clean * Changed chapter url * Changed chapter url again and change split method for substring Co-authored-by: sergiohabitant <sergio.malagon@habitant.es>
This commit is contained in:
parent
23727459b2
commit
c72a027702
|
@ -6,7 +6,7 @@ ext {
|
|||
extName = 'Comick.fun'
|
||||
pkgNameSuffix = 'all.comickfun'
|
||||
extClass = '.ComickFunFactory'
|
||||
extVersionCode = 12
|
||||
extVersionCode = 13
|
||||
isNsfw = true
|
||||
}
|
||||
|
||||
|
|
|
@ -1,450 +1,283 @@
|
|||
package eu.kanade.tachiyomi.extension.all.comickfun
|
||||
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.asObservableSuccess
|
||||
import eu.kanade.tachiyomi.network.interceptor.rateLimitHost
|
||||
import eu.kanade.tachiyomi.source.model.Filter
|
||||
import eu.kanade.tachiyomi.network.interceptor.rateLimit
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
import eu.kanade.tachiyomi.source.model.MangasPage
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.modules.SerializersModule
|
||||
import kotlinx.serialization.modules.polymorphic
|
||||
import okhttp3.CacheControl
|
||||
import okhttp3.Headers
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import rx.Observable
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.text.SimpleDateFormat
|
||||
|
||||
const val SEARCH_PAGE_LIMIT = 100
|
||||
const val API_BASE = "https://api.comick.fun"
|
||||
|
||||
abstract class ComickFun(override val lang: String, private val comickFunLang: String) :
|
||||
HttpSource() {
|
||||
|
||||
abstract class ComickFun(override val lang: String, private val comickFunLang: String) : HttpSource() {
|
||||
override val name = "Comick.fun"
|
||||
final override val baseUrl = "https://comick.fun"
|
||||
private val apiBase = "https://api.comick.fun"
|
||||
|
||||
override val baseUrl = "https://comick.fun"
|
||||
|
||||
override val supportsLatest = true
|
||||
|
||||
private val json: Json by lazy {
|
||||
Json(from = Injekt.get()) {
|
||||
serializersModule = SerializersModule {
|
||||
polymorphic(SManga::class) { default { SMangaDeserializer() } }
|
||||
polymorphic(SChapter::class) { default { SChapterDeserializer() } }
|
||||
}
|
||||
}
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
coerceInputValues = true
|
||||
explicitNulls = true
|
||||
}
|
||||
|
||||
private val mangaIdCache = SMangaDeserializer.mangaIdCache
|
||||
|
||||
final override fun headersBuilder() = Headers.Builder().apply {
|
||||
add("User-Agent", "Tachiyomi " + System.getProperty("http.agent"))
|
||||
override fun headersBuilder() = Headers.Builder().apply {
|
||||
add("Referer", "$baseUrl/")
|
||||
add("User-Agent", "Tachiyomi ${System.getProperty("http.agent")}")
|
||||
}
|
||||
|
||||
final override val client: OkHttpClient
|
||||
|
||||
init {
|
||||
val builder = super.client.newBuilder()
|
||||
if (comickFunLang != "all")
|
||||
// Add interceptor to enforce language
|
||||
builder.addInterceptor(
|
||||
Interceptor { chain ->
|
||||
val request = chain.request()
|
||||
val path = request.url.pathSegments
|
||||
when {
|
||||
((path.size == 1) && (path[0] == "chapter")) ||
|
||||
((path.size == 3) && (path[0] == "comic") && (path[2] == "chapter")) ->
|
||||
chain.proceed(request.newBuilder().url(request.url.newBuilder().addQueryParameter("lang", comickFunLang).build()).build())
|
||||
else -> chain.proceed(request)
|
||||
}
|
||||
}
|
||||
)
|
||||
// Add interceptor to append "tachiyomi=true" to all requests (api returns slightly different response to 3rd parties)
|
||||
builder.addInterceptor(
|
||||
Interceptor { chain ->
|
||||
val request = chain.request()
|
||||
return@Interceptor when (request.url.toString().startsWith(apiBase)) {
|
||||
true -> chain.proceed(request.newBuilder().url(request.url.newBuilder().addQueryParameter("tachiyomi", "true").build()).build())
|
||||
false -> chain.proceed(request)
|
||||
}
|
||||
}
|
||||
)
|
||||
// Add interceptor to ratelimit api calls
|
||||
builder.rateLimitHost(apiBase.toHttpUrl(), 2)
|
||||
this.client = builder.build()
|
||||
}
|
||||
|
||||
/** Utils **/
|
||||
|
||||
/** Returns an observable which emits a single value -> the manga's id **/
|
||||
private fun chapterId(manga: SManga): Observable<Int> {
|
||||
val mangaSlug = slug(manga)
|
||||
return mangaIdCache[mangaSlug]?.let { Observable.just(it) }
|
||||
?: fetchMangaDetails(manga).map { mangaIdCache[mangaSlug] }
|
||||
}
|
||||
|
||||
/** Returns an identifier referred to as `hid` for chapter **/
|
||||
private fun hid(chapter: SChapter) = "$baseUrl${chapter.url}".toHttpUrl().pathSegments[2].substringBefore("-")
|
||||
|
||||
/** Returns an identifier referred to as a `slug` for manga **/
|
||||
private fun slug(manga: SManga) = "$baseUrl${manga.url}".toHttpUrl().pathSegments[1]
|
||||
|
||||
/** Popular Manga **/
|
||||
|
||||
override fun fetchPopularManga(page: Int) = fetchSearchManga(page, "", FilterList(emptyList()))
|
||||
override fun popularMangaRequest(page: Int) = throw UnsupportedOperationException("Not used")
|
||||
override fun popularMangaParse(response: Response) = throw UnsupportedOperationException("Not used")
|
||||
|
||||
/** Latest Manga **/
|
||||
override fun latestUpdatesParse(response: Response): MangasPage {
|
||||
val noResults = MangasPage(emptyList(), false)
|
||||
if (response.code == 204)
|
||||
return noResults
|
||||
return json.decodeFromString<List<SManga>>(
|
||||
deserializer = ListSerializer(deepSelectDeserializer("md_comics")),
|
||||
response.body!!.string()
|
||||
).let { MangasPage(it, true) }
|
||||
}
|
||||
|
||||
override fun latestUpdatesRequest(page: Int): Request {
|
||||
val url = "$apiBase/chapter".toHttpUrl().newBuilder()
|
||||
.addQueryParameter("page", "${page - 1}")
|
||||
.addQueryParameter("device-memory", "8")
|
||||
return GET("$url", headers)
|
||||
}
|
||||
|
||||
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
|
||||
if (!query.startsWith(SLUG_SEARCH_PREFIX))
|
||||
return super.fetchSearchManga(page, query, filters)
|
||||
|
||||
// deeplinking
|
||||
val potentialUrl = "/comic/${query.substringAfter(SLUG_SEARCH_PREFIX)}"
|
||||
return fetchMangaDetails(SManga.create().apply { this.url = potentialUrl })
|
||||
.map { MangasPage(listOf(it.apply { this.url = potentialUrl }), false) }
|
||||
.onErrorReturn { MangasPage(emptyList(), false) }
|
||||
}
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
val url = apiBase.toHttpUrl().newBuilder().addPathSegment("search")
|
||||
if (query.isNotEmpty()) {
|
||||
url.addQueryParameter("q", query)
|
||||
} else {
|
||||
url.addQueryParameter("page", "$page")
|
||||
.addQueryParameter("limit", "$SEARCH_PAGE_LIMIT")
|
||||
filters.forEach { filter ->
|
||||
when (filter) {
|
||||
is UrlEncoded -> filter.encode(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
return GET("$url", headers)
|
||||
}
|
||||
|
||||
override fun searchMangaParse(response: Response) = json.decodeFromString<List<SManga>>(response.body!!.string())
|
||||
.let { MangasPage(it, it.size == SEARCH_PAGE_LIMIT) }
|
||||
|
||||
/** Manga Details **/
|
||||
|
||||
private fun apiMangaDetailsRequest(manga: SManga): Request {
|
||||
return GET("$apiBase/comic/${slug(manga)}", headers)
|
||||
}
|
||||
|
||||
// Shenanigans to allow "open in webview" to show a webpage instead of JSON
|
||||
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
|
||||
return client.newCall(apiMangaDetailsRequest(manga))
|
||||
.asObservableSuccess()
|
||||
.map { response ->
|
||||
mangaDetailsParse(response).apply { initialized = true }
|
||||
}
|
||||
}
|
||||
|
||||
override fun mangaDetailsParse(response: Response) = json.decodeFromString(
|
||||
deserializer = jsonFlatten<SManga>(objKey = "comic", "id", "title", "desc", "status", "country", "slug"),
|
||||
response.body!!.string()
|
||||
)
|
||||
|
||||
/** Chapter List **/
|
||||
|
||||
private fun chapterListRequest(page: Int, mangaId: Int) =
|
||||
GET("$apiBase/comic/$mangaId/chapter?page=$page&limit=$SEARCH_PAGE_LIMIT", headers)
|
||||
|
||||
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
|
||||
return if (manga.status != SManga.LICENSED) {
|
||||
chapterId(manga).concatMap { id ->
|
||||
/**
|
||||
* Returns an observable which emits the list of chapters found on a page,
|
||||
* for every page starting from specified page
|
||||
*/
|
||||
fun getAllPagesFrom(page: Int, pred: Observable<List<SChapter>> = Observable.just(emptyList())): Observable<List<SChapter>> =
|
||||
client.newCall(chapterListRequest(page, id))
|
||||
.asObservableSuccess()
|
||||
.concatMap { response ->
|
||||
val cp = chapterListParse(response).map { it.apply { this.url = "${manga.url}${this.url}" } }
|
||||
if (cp.size == SEARCH_PAGE_LIMIT)
|
||||
getAllPagesFrom(page + 1, pred = pred.concatWith(Observable.just(cp))) // tail call to avoid blowing the stack
|
||||
else // by the pigeon-hole principle
|
||||
pred.concatWith(Observable.just(cp))
|
||||
}
|
||||
getAllPagesFrom(1).reduce(List<SChapter>::plus)
|
||||
}
|
||||
} else {
|
||||
Observable.error(Exception("Licensed - No chapters to show"))
|
||||
}
|
||||
}
|
||||
|
||||
override fun chapterListParse(response: Response) = json.decodeFromString(
|
||||
deserializer = deepSelectDeserializer<List<SChapter>>("chapters"),
|
||||
response.body!!.string()
|
||||
)
|
||||
|
||||
/** Page List **/
|
||||
|
||||
override fun pageListRequest(chapter: SChapter) = GET("$apiBase/chapter/${hid(chapter)}", headers, CacheControl.FORCE_NETWORK)
|
||||
|
||||
override fun pageListParse(response: Response) =
|
||||
json.decodeFromString(
|
||||
deserializer = deepSelectDeserializer<List<String>>("chapter", "images", tDeserializer = ListSerializer(deepSelectDeserializer("url"))),
|
||||
response.body!!.string()
|
||||
).mapIndexed { i, url -> Page(i, imageUrl = url) }
|
||||
|
||||
override fun imageUrlParse(response: Response) = "" // idk what this does, leave me alone kotlin
|
||||
|
||||
/** Filters **/
|
||||
|
||||
private interface UrlEncoded {
|
||||
fun encode(url: HttpUrl.Builder)
|
||||
}
|
||||
|
||||
private interface ArrayUrlParam : UrlEncoded {
|
||||
val paramName: String
|
||||
val selected: Sequence<LabeledValue>
|
||||
override fun encode(url: HttpUrl.Builder) {
|
||||
selected.forEach { url.addQueryParameter(paramName, it.value) }
|
||||
}
|
||||
}
|
||||
|
||||
private interface QueryParam : UrlEncoded {
|
||||
val paramName: String
|
||||
val selected: LabeledValue
|
||||
override fun encode(url: HttpUrl.Builder) {
|
||||
url.addQueryParameter(paramName, selected.value)
|
||||
}
|
||||
}
|
||||
|
||||
// essentially a named pair
|
||||
protected class LabeledValue(private val displayname: String, private val _value: String?) {
|
||||
val value: String get() = _value ?: displayname
|
||||
override fun toString(): String = displayname
|
||||
}
|
||||
|
||||
private open class Select<T>(header: String, values: Array<T>, state: Int = 0) : Filter.Select<T>(header, values, state) {
|
||||
val selected: T
|
||||
get() = this.values[this.state]
|
||||
}
|
||||
|
||||
private open class MultiSelect<T>(header: String, val elems: List<T>) :
|
||||
Filter.Group<Filter.CheckBox>(header, elems.map { object : Filter.CheckBox("$it") {} }) {
|
||||
val selected: Sequence<T>
|
||||
get() = this.elems.asSequence().filterIndexed { i, _ -> this.state[i].state }
|
||||
}
|
||||
|
||||
private open class MultiTriSelect<T>(header: String, val elems: List<T>) :
|
||||
Filter.Group<Filter.TriState>(header, elems.map { object : Filter.TriState("$it") {} }) {
|
||||
val selected: Pair<Sequence<T>, Sequence<T>>
|
||||
get() {
|
||||
return this.elems.asSequence()
|
||||
.mapIndexed { index, it -> index to it }
|
||||
.filterNot { (index, _) -> this.state[index].isIgnored() }
|
||||
.partition { (index, _) -> this.state[index].isIncluded() }
|
||||
.let { (included, excluded) ->
|
||||
included.asSequence().map { it.second } to excluded.asSequence().map { it.second }
|
||||
}
|
||||
}
|
||||
}
|
||||
override val client: OkHttpClient = network.client.newBuilder().rateLimit(4, 1).build()
|
||||
|
||||
override fun getFilterList() = FilterList(
|
||||
Filter.Header("NOTE: Ignored if using text search!"),
|
||||
GenreFilter(),
|
||||
DemographicFilter(),
|
||||
TypesFilter(),
|
||||
CreatedAtFilter(),
|
||||
MinChaptersFilter(),
|
||||
SortFilter()
|
||||
getFilters()
|
||||
)
|
||||
|
||||
private fun GenreFilter() = object : MultiTriSelect<LabeledValue>("Genre", getGenreList()), UrlEncoded {
|
||||
val included = object : ArrayUrlParam {
|
||||
override val paramName = "genres"
|
||||
override var selected: Sequence<LabeledValue> = sequence {}
|
||||
}
|
||||
val excluded = object : ArrayUrlParam {
|
||||
override val paramName = "excludes"
|
||||
override var selected: Sequence<LabeledValue> = sequence {}
|
||||
private val DATE_FORMATTER by lazy {
|
||||
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZZZZ")
|
||||
}
|
||||
|
||||
override fun encode(url: HttpUrl.Builder) {
|
||||
this.selected.let { (includedGenres, excludedGenres) ->
|
||||
included.apply { selected = includedGenres }.encode(url)
|
||||
excluded.apply { selected = excludedGenres }.encode(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun SortFilter() = object : Select<LabeledValue>("Sort", getSorts()), QueryParam {
|
||||
override val paramName = "sort"
|
||||
}
|
||||
|
||||
private fun DemographicFilter() = object : MultiSelect<LabeledValue>("Demographic", getDemographics()), ArrayUrlParam {
|
||||
override val paramName = "demographic"
|
||||
}
|
||||
|
||||
private fun TypesFilter() = object : MultiSelect<LabeledValue>("Type", getContentType()), ArrayUrlParam {
|
||||
override val paramName = "country"
|
||||
}
|
||||
|
||||
private fun CreatedAtFilter() = object : Select<LabeledValue>("Created At", getCreatedAt()), QueryParam {
|
||||
override val paramName = "time"
|
||||
override fun encode(url: HttpUrl.Builder) {
|
||||
// api will reject a request with an empty time
|
||||
if (selected.value.isNotBlank()) super.encode(url)
|
||||
}
|
||||
}
|
||||
|
||||
private fun MinChaptersFilter() = object : Filter.Text("Minimum Chapters", ""), UrlEncoded {
|
||||
override fun encode(url: HttpUrl.Builder) {
|
||||
if (state.isBlank()) return
|
||||
state.toIntOrNull()?.takeUnless { it < 0 }?.let {
|
||||
url.addQueryParameter("minimum", "$it")
|
||||
} ?: throw RuntimeException("Minimum must be an integer greater than 0")
|
||||
}
|
||||
}
|
||||
|
||||
protected fun getGenreList() = listOf(
|
||||
LabeledValue("4-Koma", "4-koma"),
|
||||
LabeledValue("Action", "action"),
|
||||
LabeledValue("Adaptation", "adaptation"),
|
||||
LabeledValue("Adult", "adult"),
|
||||
LabeledValue("Adventure", "adventure"),
|
||||
LabeledValue("Aliens", "aliens"),
|
||||
LabeledValue("Animals", "animals"),
|
||||
LabeledValue("Anthology", "anthology"),
|
||||
LabeledValue("Award Winning", "award-winning"),
|
||||
LabeledValue("Comedy", "comedy"),
|
||||
LabeledValue("Cooking", "cooking"),
|
||||
LabeledValue("Crime", "crime"),
|
||||
LabeledValue("Crossdressing", "crossdressing"),
|
||||
LabeledValue("Delinquents", "delinquents"),
|
||||
LabeledValue("Demons", "demons"),
|
||||
LabeledValue("Doujinshi", "doujinshi"),
|
||||
LabeledValue("Drama", "drama"),
|
||||
LabeledValue("Ecchi", "ecchi"),
|
||||
LabeledValue("Fan Colored", "fan-colored"),
|
||||
LabeledValue("Fantasy", "fantasy"),
|
||||
LabeledValue("Full Color", "full-color"),
|
||||
LabeledValue("Gender Bender", "gender-bender"),
|
||||
LabeledValue("Genderswap", "genderswap"),
|
||||
LabeledValue("Ghosts", "ghosts"),
|
||||
LabeledValue("Gore", "gore"),
|
||||
LabeledValue("Gyaru", "gyaru"),
|
||||
LabeledValue("Harem", "harem"),
|
||||
LabeledValue("Historical", "historical"),
|
||||
LabeledValue("Horror", "horror"),
|
||||
LabeledValue("Incest", "incest"),
|
||||
LabeledValue("Isekai", "isekai"),
|
||||
LabeledValue("Loli", "loli"),
|
||||
LabeledValue("Long Strip", "long-strip"),
|
||||
LabeledValue("Mafia", "mafia"),
|
||||
LabeledValue("Magic", "magic"),
|
||||
LabeledValue("Magical Girls", "magical-girls"),
|
||||
LabeledValue("Martial Arts", "martial-arts"),
|
||||
LabeledValue("Mature", "mature"),
|
||||
LabeledValue("Mecha", "mecha"),
|
||||
LabeledValue("Medical", "medical"),
|
||||
LabeledValue("Military", "military"),
|
||||
LabeledValue("Monster Girls", "monster-girls"),
|
||||
LabeledValue("Monsters", "monsters"),
|
||||
LabeledValue("Music", "music"),
|
||||
LabeledValue("Mystery", "mystery"),
|
||||
LabeledValue("Ninja", "ninja"),
|
||||
LabeledValue("Office Workers", "office-workers"),
|
||||
LabeledValue("Official Colored", "official-colored"),
|
||||
LabeledValue("Oneshot", "oneshot"),
|
||||
LabeledValue("Philosophical", "philosophical"),
|
||||
LabeledValue("Police", "police"),
|
||||
LabeledValue("Post-Apocalyptic", "post-apocalyptic"),
|
||||
LabeledValue("Psychological", "psychological"),
|
||||
LabeledValue("Reincarnation", "reincarnation"),
|
||||
LabeledValue("Reverse Harem", "reverse-harem"),
|
||||
LabeledValue("Romance", "romance"),
|
||||
LabeledValue("Samurai", "samurai"),
|
||||
LabeledValue("School Life", "school-life"),
|
||||
LabeledValue("Sci-Fi", "sci-fi"),
|
||||
LabeledValue("Sexual Violence", "sexual-violence"),
|
||||
LabeledValue("Shota", "shota"),
|
||||
LabeledValue("Shoujo Ai", "shoujo-ai"),
|
||||
LabeledValue("Shounen Ai", "shounen-ai"),
|
||||
LabeledValue("Slice of Life", "slice-of-life"),
|
||||
LabeledValue("Smut", "smut"),
|
||||
LabeledValue("Sports", "sports"),
|
||||
LabeledValue("Superhero", "superhero"),
|
||||
LabeledValue("Supernatural", "supernatural"),
|
||||
LabeledValue("Survival", "survival"),
|
||||
LabeledValue("Thriller", "thriller"),
|
||||
LabeledValue("Time Travel", "time-travel"),
|
||||
LabeledValue("Traditional Games", "traditional-games"),
|
||||
LabeledValue("Tragedy", "tragedy"),
|
||||
LabeledValue("User Created", "user-created"),
|
||||
LabeledValue("Vampires", "vampires"),
|
||||
LabeledValue("Video Games", "video-games"),
|
||||
LabeledValue("Villainess", "villainess"),
|
||||
LabeledValue("Virtual Reality", "virtual-reality"),
|
||||
LabeledValue("Web Comic", "web-comic"),
|
||||
LabeledValue("Wuxia", "wuxia"),
|
||||
LabeledValue("Yaoi", "yaoi"),
|
||||
LabeledValue("Yuri", "yuri"),
|
||||
LabeledValue("Zombies", "zombies")
|
||||
/** Popular Manga **/
|
||||
override fun popularMangaRequest(page: Int): Request {
|
||||
return GET(
|
||||
API_BASE.toHttpUrl().newBuilder().apply {
|
||||
addPathSegment("search")
|
||||
addQueryParameter("sort", "user_follow_count")
|
||||
addQueryParameter("page", "$page")
|
||||
addQueryParameter("tachiyomi", "true")
|
||||
}.toString(), headers
|
||||
)
|
||||
}
|
||||
|
||||
private fun getDemographics() = listOf(
|
||||
LabeledValue("Shonen", "1"),
|
||||
LabeledValue("Shoujo", "2"),
|
||||
LabeledValue("Seinen", "3"),
|
||||
LabeledValue("Josei", "4"),
|
||||
|
||||
override fun popularMangaParse(response: Response): MangasPage {
|
||||
val result = json.decodeFromString<List<Manga>>(response.body!!.string())
|
||||
return MangasPage(
|
||||
result.map { data ->
|
||||
SManga.create().apply {
|
||||
url = "/comic/${data.slug}"
|
||||
title = data.title
|
||||
thumbnail_url = data.cover_url
|
||||
}
|
||||
}, true
|
||||
)
|
||||
}
|
||||
|
||||
private fun getContentType() = listOf(
|
||||
LabeledValue("Manga", "jp"),
|
||||
LabeledValue("Manhwa", "kr"),
|
||||
LabeledValue("Manhua", "cn"),
|
||||
/** Latest Manga **/
|
||||
override fun latestUpdatesRequest(page: Int): Request {
|
||||
return GET(
|
||||
API_BASE.toHttpUrl().newBuilder().apply {
|
||||
addPathSegment("chapter")
|
||||
addQueryParameter("lang", comickFunLang)
|
||||
addQueryParameter("page", "$page")
|
||||
addQueryParameter("order", "new")
|
||||
addQueryParameter("tachiyomi", "true")
|
||||
}.toString(), headers
|
||||
)
|
||||
}
|
||||
|
||||
private fun getCreatedAt() = arrayOf(
|
||||
LabeledValue("", ""),
|
||||
LabeledValue("30 days", "30"),
|
||||
LabeledValue("3 months", "90"),
|
||||
LabeledValue("6 months", "180"),
|
||||
LabeledValue("1 year", "365"),
|
||||
override fun latestUpdatesParse(response: Response): MangasPage {
|
||||
val result = json.decodeFromString<List<LatestChapters>>(response.body!!.string())
|
||||
return MangasPage(
|
||||
result.map { data ->
|
||||
SManga.create().apply {
|
||||
url = "/comic/${data.md_comics.slug}"
|
||||
title = data.md_comics.title
|
||||
thumbnail_url = data.md_comics.cover_url
|
||||
}
|
||||
}, true
|
||||
)
|
||||
}
|
||||
|
||||
private fun getSorts() = arrayOf(
|
||||
LabeledValue("", ""),
|
||||
LabeledValue("Most follows", "follow"),
|
||||
LabeledValue("Most views", "view"),
|
||||
LabeledValue("High rating", "rating"),
|
||||
LabeledValue("Last updated", "uploaded")
|
||||
/** Manga Search **/
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
val url: String = API_BASE.toHttpUrl().newBuilder().apply {
|
||||
addPathSegment("search")
|
||||
if (query.isEmpty()) {
|
||||
filters.forEach { it ->
|
||||
when (it) {
|
||||
is CompletedFilter -> {
|
||||
if (it.state) {
|
||||
addQueryParameter("completed", "true")
|
||||
}
|
||||
}
|
||||
is GenreFilter -> {
|
||||
it.state.filter { (it as TriState).isIncluded() }.forEach {
|
||||
addQueryParameter(
|
||||
"genres", (it as TriState).value
|
||||
)
|
||||
}
|
||||
|
||||
it.state.filter { (it as TriState).isExcluded() }.forEach {
|
||||
addQueryParameter(
|
||||
"excludes", (it as TriState).value
|
||||
)
|
||||
}
|
||||
}
|
||||
is DemographicFilter -> {
|
||||
it.state.filter { (it as CheckBox).state }.forEach {
|
||||
addQueryParameter(
|
||||
"demographic", (it as CheckBox).value
|
||||
)
|
||||
}
|
||||
}
|
||||
is TypeFilter -> {
|
||||
it.state.filter { (it as CheckBox).state }.forEach {
|
||||
addQueryParameter(
|
||||
"country", (it as CheckBox).value
|
||||
)
|
||||
}
|
||||
}
|
||||
is SortFilter -> {
|
||||
addQueryParameter("sort", it.getValue())
|
||||
}
|
||||
is CreatedAtFilter -> {
|
||||
if (it.state > 0) {
|
||||
addQueryParameter("time", it.getValue())
|
||||
}
|
||||
}
|
||||
is MinimumFilter -> {
|
||||
if (it.state.isNotEmpty()) {
|
||||
addQueryParameter("minimum", it.state)
|
||||
}
|
||||
}
|
||||
is FromYearFilter -> {
|
||||
if (it.state.isNotEmpty()) {
|
||||
addQueryParameter("from", it.state)
|
||||
}
|
||||
}
|
||||
is ToYearFilter -> {
|
||||
if (it.state.isNotEmpty()) {
|
||||
addQueryParameter("to", it.state)
|
||||
}
|
||||
}
|
||||
is TagFilter -> {
|
||||
if (it.state.isNotEmpty()) {
|
||||
it.state.split(",").forEach {
|
||||
addQueryParameter("tags", it.trim())
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
addQueryParameter("q", query)
|
||||
}
|
||||
addQueryParameter("tachiyomi", "true")
|
||||
addQueryParameter("page", "$page")
|
||||
}.toString()
|
||||
return GET(url, headers)
|
||||
}
|
||||
|
||||
override fun searchMangaParse(response: Response): MangasPage {
|
||||
val result = json.decodeFromString<List<Manga>>(response.body!!.string())
|
||||
return MangasPage(
|
||||
result.map { data ->
|
||||
SManga.create().apply {
|
||||
url = "/comic/${data.slug}"
|
||||
title = data.title
|
||||
thumbnail_url = data.cover_url
|
||||
}
|
||||
}, result.size >= 50
|
||||
)
|
||||
}
|
||||
|
||||
/** Manga Details **/
|
||||
override fun mangaDetailsRequest(manga: SManga): Request {
|
||||
return GET(
|
||||
"$API_BASE${manga.url}".toHttpUrl().newBuilder().apply {
|
||||
addQueryParameter("tachiyomi", "true")
|
||||
}.toString(), headers
|
||||
)
|
||||
}
|
||||
|
||||
override fun mangaDetailsParse(response: Response): SManga {
|
||||
val mangaData = json.decodeFromString<MangaDetails>(response.body!!.string())
|
||||
return SManga.create().apply {
|
||||
title = mangaData.comic.title
|
||||
artist = mangaData.artists.joinToString { it.name.trim() }
|
||||
author = mangaData.authors.joinToString { it.name.trim() }
|
||||
description = beautifyDescription(mangaData.comic.desc)
|
||||
genre = mangaData.genres.joinToString { it.name.trim() }
|
||||
status = parseStatus(mangaData.comic.status)
|
||||
thumbnail_url = mangaData.comic.cover_url
|
||||
initialized = true
|
||||
}
|
||||
}
|
||||
|
||||
/** Manga Chapter List **/
|
||||
override fun chapterListRequest(manga: SManga): Request {
|
||||
return GET(
|
||||
"$API_BASE${manga.url}".toHttpUrl().newBuilder().apply {
|
||||
addQueryParameter("tachiyomi", "true")
|
||||
}.toString(), headers
|
||||
)
|
||||
}
|
||||
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
val mangaData = json.decodeFromString<MangaDetails>(response.body!!.string())
|
||||
val chapterData = client.newCall(
|
||||
GET(
|
||||
API_BASE.toHttpUrl().newBuilder().apply {
|
||||
addPathSegment("comic")
|
||||
addPathSegments(mangaData.comic.id.toString())
|
||||
addPathSegments("chapter")
|
||||
addQueryParameter("lang", comickFunLang)
|
||||
addQueryParameter(
|
||||
"limit", mangaData.comic.chapter_count.toString()
|
||||
)
|
||||
}.toString(), headers
|
||||
)
|
||||
).execute()
|
||||
val result = json.decodeFromString<ChapterList>(chapterData.body!!.string())
|
||||
return result.chapters.map { chapter ->
|
||||
SChapter.create().apply {
|
||||
url = "/comic/${mangaData.comic.slug}/${chapter.hid}-chapter-${chapter.chap}-$comickFunLang"
|
||||
name = beautifyChapterName(chapter.vol, chapter.chap, chapter.title)
|
||||
date_upload = DATE_FORMATTER.parse(chapter.created_at)!!.time
|
||||
scanlator = chapter.group_name.joinToString().takeUnless { it.isBlank() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Chapter Pages **/
|
||||
override fun pageListRequest(chapter: SChapter): Request {
|
||||
val chapterHid = chapter.url.substringAfterLast("/").substringBefore("-")
|
||||
return GET(
|
||||
API_BASE.toHttpUrl().newBuilder().apply {
|
||||
addPathSegment("chapter")
|
||||
addPathSegment(chapterHid)
|
||||
addQueryParameter("tachiyomi", "true")
|
||||
}.toString(), headers
|
||||
)
|
||||
}
|
||||
|
||||
override fun pageListParse(response: Response): List<Page> {
|
||||
val result = json.decodeFromString<PageList>(response.body!!.string())
|
||||
return result.chapter.images.mapIndexed { index, data ->
|
||||
Page(index = index, imageUrl = data.url)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val SLUG_SEARCH_PREFIX = "id:"
|
||||
}
|
||||
|
||||
/** Don't touch this, Tachiyomi forces you to declare the following methods even I you don't use them **/
|
||||
override fun imageUrlParse(response: Response): String {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,89 @@
|
|||
package eu.kanade.tachiyomi.extension.all.comickfun
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class Manga(
|
||||
val slug: String,
|
||||
val title: String,
|
||||
val cover_url: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LatestChapters(
|
||||
val md_comics: MdComics
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MdComics(
|
||||
val title: String,
|
||||
val slug: String,
|
||||
val cover_url: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MangaDetails(
|
||||
val comic: Comic,
|
||||
val artists: Array<Artist>,
|
||||
val authors: Array<Author>,
|
||||
val genres: Array<Genre>
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Comic(
|
||||
val id: Int,
|
||||
val title: String,
|
||||
val slug: String,
|
||||
val desc: String,
|
||||
val status: Int,
|
||||
val chapter_count: Int?,
|
||||
val cover_url: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Artist(
|
||||
val name: String,
|
||||
val slug: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Author(
|
||||
val name: String,
|
||||
val slug: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Genre(
|
||||
val slug: String,
|
||||
val name: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChapterList(
|
||||
val chapters: Array<Chapter>
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Chapter(
|
||||
val hid: String = "",
|
||||
val title: String = "",
|
||||
val created_at: String = "",
|
||||
val chap: String = "",
|
||||
val vol: String = "",
|
||||
val group_name: Array<String> = arrayOf("")
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PageList(
|
||||
val chapter: ChapterPageData
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChapterPageData(
|
||||
val images: Array<Page>
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Page(
|
||||
val url: String
|
||||
)
|
|
@ -12,7 +12,6 @@ val legacyLanguageMappings = mapOf(
|
|||
|
||||
class ComickFunFactory : SourceFactory {
|
||||
override fun createSources(): List<Source> = listOf(
|
||||
"all",
|
||||
"en",
|
||||
"pt-br",
|
||||
"ru",
|
||||
|
|
|
@ -0,0 +1,179 @@
|
|||
package eu.kanade.tachiyomi.extension.all.comickfun
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.Filter
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
|
||||
internal fun getFilters(): FilterList {
|
||||
return FilterList(
|
||||
Filter.Header(name = "NOTE: Everything below is ignored if using text search"),
|
||||
CompletedFilter("Completed translation"),
|
||||
GenreFilter("Genre", getGenresList),
|
||||
DemographicFilter("Demographic", getDemographicList),
|
||||
TypeFilter("Type", getTypeList),
|
||||
SortFilter("Short", getSortsList),
|
||||
CreatedAtFilter("Created At", getCreatedAtList),
|
||||
MinimumFilter("Minimum Chapters"),
|
||||
Filter.Header("From Year, ex: 2010"),
|
||||
FromYearFilter("From"),
|
||||
Filter.Header("To Year, ex: 2021"),
|
||||
ToYearFilter("To"),
|
||||
Filter.Header("Separate tags with commas"),
|
||||
TagFilter("Tags"),
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
/** Filters **/
|
||||
internal class GenreFilter(name: String, genreList: List<TriState>) : Group(name, genreList)
|
||||
|
||||
internal class TagFilter(name: String) : Text(name)
|
||||
|
||||
internal class DemographicFilter(name: String, demographicList: List<CheckBox>) :
|
||||
Group(name, demographicList)
|
||||
|
||||
internal class TypeFilter(name: String, typeList: List<CheckBox>) :
|
||||
Group(name, typeList)
|
||||
|
||||
internal class CompletedFilter(name: String) : CheckBox(name)
|
||||
|
||||
internal class CreatedAtFilter(name: String, createdAtList: Array<Pair<String, String>>) :
|
||||
Select(name, createdAtList)
|
||||
|
||||
internal class MinimumFilter(name: String) : Text(name)
|
||||
|
||||
internal class FromYearFilter(name: String) : Text(name)
|
||||
|
||||
internal class ToYearFilter(name: String) : Text(name)
|
||||
|
||||
internal class SortFilter(name: String, sortList: Array<Pair<String, String>>) :
|
||||
Select(name, sortList)
|
||||
|
||||
/** Generics **/
|
||||
internal open class Group(name: String, values: List<Any>) :
|
||||
Filter.Group<Any>(name, values)
|
||||
|
||||
internal open class TriState(name: String, val value: String) : Filter.TriState(name)
|
||||
|
||||
internal open class Text(name: String) : Filter.Text(name)
|
||||
|
||||
internal open class CheckBox(name: String, val value: String = "") : Filter.CheckBox(name)
|
||||
|
||||
internal open class Select(name: String, private val vals: Array<Pair<String, String>>) :
|
||||
Filter.Select<String>(name, vals.map { it.first }.toTypedArray()) {
|
||||
fun getValue() = vals[state].second
|
||||
}
|
||||
|
||||
/** Filters Data **/
|
||||
private val getGenresList: List<TriState> = listOf(
|
||||
TriState("4-Koma", "4-koma"),
|
||||
TriState("Action", "action"),
|
||||
TriState("Adaptation", "adaptation"),
|
||||
TriState("Adult", "adult"),
|
||||
TriState("Adventure", "adventure"),
|
||||
TriState("Aliens", "aliens"),
|
||||
TriState("Animals", "animals"),
|
||||
TriState("Anthology", "anthology"),
|
||||
TriState("Award Winning", "award-winning"),
|
||||
TriState("Comedy", "comedy"),
|
||||
TriState("Cooking", "cooking"),
|
||||
TriState("Crime", "crime"),
|
||||
TriState("Crossdressing", "crossdressing"),
|
||||
TriState("Delinquents", "delinquents"),
|
||||
TriState("Demons", "demons"),
|
||||
TriState("Doujinshi", "doujinshi"),
|
||||
TriState("Drama", "drama"),
|
||||
TriState("Ecchi", "ecchi"),
|
||||
TriState("Fan Colored", "fan-colored"),
|
||||
TriState("Fantasy", "fantasy"),
|
||||
TriState("Full Color", "full-color"),
|
||||
TriState("Gender Bender", "gender-bender"),
|
||||
TriState("Genderswap", "genderswap"),
|
||||
TriState("Ghosts", "ghosts"),
|
||||
TriState("Gore", "gore"),
|
||||
TriState("Gyaru", "gyaru"),
|
||||
TriState("Harem", "harem"),
|
||||
TriState("Historical", "historical"),
|
||||
TriState("Horror", "horror"),
|
||||
TriState("Incest", "incest"),
|
||||
TriState("Isekai", "isekai"),
|
||||
TriState("Loli", "loli"),
|
||||
TriState("Long Strip", "long-strip"),
|
||||
TriState("Mafia", "mafia"),
|
||||
TriState("Magic", "magic"),
|
||||
TriState("Magical Girls", "magical-girls"),
|
||||
TriState("Martial Arts", "martial-arts"),
|
||||
TriState("Mature", "mature"),
|
||||
TriState("Mecha", "mecha"),
|
||||
TriState("Medical", "medical"),
|
||||
TriState("Military", "military"),
|
||||
TriState("Monster Girls", "monster-girls"),
|
||||
TriState("Monsters", "monsters"),
|
||||
TriState("Music", "music"),
|
||||
TriState("Mystery", "mystery"),
|
||||
TriState("Ninja", "ninja"),
|
||||
TriState("Office Workers", "office-workers"),
|
||||
TriState("Official Colored", "official-colored"),
|
||||
TriState("Oneshot", "oneshot"),
|
||||
TriState("Philosophical", "philosophical"),
|
||||
TriState("Police", "police"),
|
||||
TriState("Post-Apocalyptic", "post-apocalyptic"),
|
||||
TriState("Psychological", "psychological"),
|
||||
TriState("Reincarnation", "reincarnation"),
|
||||
TriState("Reverse Harem", "reverse-harem"),
|
||||
TriState("Romance", "romance"),
|
||||
TriState("Samurai", "samurai"),
|
||||
TriState("School Life", "school-life"),
|
||||
TriState("Sci-Fi", "sci-fi"),
|
||||
TriState("Sexual Violence", "sexual-violence"),
|
||||
TriState("Shota", "shota"),
|
||||
TriState("Shoujo Ai", "shoujo-ai"),
|
||||
TriState("Shounen Ai", "shounen-ai"),
|
||||
TriState("Slice of Life", "slice-of-life"),
|
||||
TriState("Smut", "smut"),
|
||||
TriState("Sports", "sports"),
|
||||
TriState("Superhero", "superhero"),
|
||||
TriState("Supernatural", "supernatural"),
|
||||
TriState("Survival", "survival"),
|
||||
TriState("Thriller", "thriller"),
|
||||
TriState("Time Travel", "time-travel"),
|
||||
TriState("Traditional Games", "traditional-games"),
|
||||
TriState("Tragedy", "tragedy"),
|
||||
TriState("User Created", "user-created"),
|
||||
TriState("Vampires", "vampires"),
|
||||
TriState("Video Games", "video-games"),
|
||||
TriState("Villainess", "villainess"),
|
||||
TriState("Virtual Reality", "virtual-reality"),
|
||||
TriState("Web Comic", "web-comic"),
|
||||
TriState("Wuxia", "wuxia"),
|
||||
TriState("Yaoi", "yaoi"),
|
||||
TriState("Yuri", "yuri"),
|
||||
TriState("Zombies", "zombies")
|
||||
)
|
||||
|
||||
private val getDemographicList: List<CheckBox> = listOf(
|
||||
CheckBox("Shounen", "1"),
|
||||
CheckBox("Shoujo", "2"),
|
||||
CheckBox("Seinen", "3"),
|
||||
CheckBox("Josei", "4"),
|
||||
)
|
||||
|
||||
private val getTypeList: List<CheckBox> = listOf(
|
||||
CheckBox("Manga", "jp"),
|
||||
CheckBox("Manhwa", "kr"),
|
||||
CheckBox("Manhua", "cn"),
|
||||
)
|
||||
|
||||
private val getCreatedAtList: Array<Pair<String, String>> = arrayOf(
|
||||
Pair("", ""),
|
||||
Pair("30 days", "30"),
|
||||
Pair("3 months", "90"),
|
||||
Pair("6 months", "180"),
|
||||
Pair("1 year", "365"),
|
||||
)
|
||||
|
||||
private val getSortsList: Array<Pair<String, String>> = arrayOf(
|
||||
Pair("Most follows", "user_follow_count"),
|
||||
Pair("Most views", "view"),
|
||||
Pair("High rating", "rating"),
|
||||
Pair("Last updated", "uploaded")
|
||||
)
|
|
@ -0,0 +1,37 @@
|
|||
package eu.kanade.tachiyomi.extension.all.comickfun
|
||||
|
||||
import android.os.Build
|
||||
import android.text.Html
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import org.jsoup.Jsoup
|
||||
|
||||
internal fun beautifyDescription(description: String): String {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
return Html.fromHtml(description, Html.FROM_HTML_MODE_LEGACY).toString()
|
||||
}
|
||||
return Jsoup.parse(description).text()
|
||||
}
|
||||
|
||||
internal fun parseStatus(status: Int): Int {
|
||||
return when (status) {
|
||||
1 -> SManga.ONGOING
|
||||
2 -> SManga.COMPLETED
|
||||
3 -> SManga.CANCELLED
|
||||
4 -> SManga.ON_HIATUS
|
||||
else -> SManga.UNKNOWN
|
||||
}
|
||||
}
|
||||
|
||||
internal fun beautifyChapterName(vol: String, chap: String, title: String): String {
|
||||
return buildString {
|
||||
if (vol.isNotEmpty()) {
|
||||
if (chap.isEmpty()) append("Volume $vol") else append("Vol. $vol")
|
||||
}
|
||||
if (chap.isNotEmpty()) {
|
||||
if (vol.isEmpty()) append("Chapter $chap") else append(", Ch. $chap")
|
||||
}
|
||||
if (title.isNotEmpty()) {
|
||||
if (chap.isEmpty()) append(title) else append(": $title")
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,256 +0,0 @@
|
|||
package eu.kanade.tachiyomi.extension.all.comickfun
|
||||
|
||||
import android.os.Build
|
||||
import android.text.Html
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||
import kotlinx.serialization.descriptors.buildClassSerialDescriptor
|
||||
import kotlinx.serialization.descriptors.element
|
||||
import kotlinx.serialization.encoding.CompositeDecoder
|
||||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
import kotlinx.serialization.encoding.decodeStructure
|
||||
import kotlinx.serialization.json.JsonDecoder
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonTransformingSerializer
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.serializer
|
||||
import java.text.SimpleDateFormat
|
||||
import kotlin.math.pow
|
||||
import kotlin.math.truncate
|
||||
|
||||
/**
|
||||
* A serializer of type T which selects the value of type T by traversing down a chain of json objects
|
||||
*
|
||||
* e.g
|
||||
* {
|
||||
* "user": {
|
||||
* "name": {
|
||||
* "first": "John",
|
||||
* "last": "Smith"
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* deepSelectDeserializer<String>("user", "name", "first") deserializes the above into "John"
|
||||
*/
|
||||
inline fun <reified T : Any> deepSelectDeserializer(vararg keys: String, tDeserializer: KSerializer<T> = serializer()): KSerializer<T> {
|
||||
val descriptors = keys.foldRight(listOf(tDeserializer.descriptor)) { x, acc ->
|
||||
acc + acc.last().let {
|
||||
buildClassSerialDescriptor("$x\$${it.serialName}") { element(x, it) }
|
||||
}
|
||||
}.asReversed()
|
||||
|
||||
return object : KSerializer<T> {
|
||||
private var depth = 0
|
||||
private fun <S> asChild(fn: (KSerializer<T>) -> S) = fn(this.apply { depth += 1 }).also { depth -= 1 }
|
||||
override val descriptor get() = descriptors[depth]
|
||||
|
||||
override fun deserialize(decoder: Decoder): T {
|
||||
return if (depth == keys.size) decoder.decodeSerializableValue(tDeserializer)
|
||||
else decoder.decodeStructureByKnownName(descriptor) { names ->
|
||||
names.filter { (name, _) -> name == keys[depth] }
|
||||
.map { (_, index) -> asChild { decodeSerializableElement(descriptors[depth - 1]/* find something more elegant */, index, it) } }
|
||||
.single()
|
||||
}
|
||||
}
|
||||
|
||||
override fun serialize(encoder: Encoder, value: T) = throw UnsupportedOperationException("Not supported")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms given json element by lifting specified keys in `element[objKey]` up into `element`
|
||||
* Existing conflicts are overwritten
|
||||
*
|
||||
* @param objKey: String - A key identifying an object in JsonElement
|
||||
* @param keys: vararg String - Keys identifying values to lift from objKey
|
||||
*/
|
||||
inline fun <reified T : Any> jsonFlatten(
|
||||
objKey: String,
|
||||
vararg keys: String,
|
||||
tDeserializer: KSerializer<T> = serializer()
|
||||
): JsonTransformingSerializer<T> {
|
||||
return object : JsonTransformingSerializer<T>(tDeserializer) {
|
||||
override fun transformDeserialize(element: JsonElement) = buildJsonObject {
|
||||
require(element is JsonObject)
|
||||
element.entries.forEach { (key, value) -> put(key, value) }
|
||||
val fromObj = element[objKey]
|
||||
require(fromObj is JsonObject)
|
||||
keys.forEach { put(it, fromObj[it]!!) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inline fun <T> Decoder.decodeStructureByKnownName(descriptor: SerialDescriptor, decodeFn: CompositeDecoder.(Sequence<Pair<String, Int>>) -> T): T {
|
||||
return decodeStructure(descriptor) {
|
||||
decodeFn(
|
||||
generateSequence { decodeElementIndex(descriptor) }
|
||||
.takeWhile { it != CompositeDecoder.DECODE_DONE }
|
||||
.filter { it != CompositeDecoder.UNKNOWN_NAME }
|
||||
.map { descriptor.getElementName(it) to it }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class SChapterDeserializer : KSerializer<SChapter> {
|
||||
override val descriptor = buildClassSerialDescriptor(SChapter::class.qualifiedName!!) {
|
||||
element<String>("chap")
|
||||
element<String>("hid")
|
||||
element<String?>("title")
|
||||
element<String?>("vol", isOptional = true)
|
||||
element<String>("created_at")
|
||||
element<String>("iso639_1")
|
||||
element<List<String>>("images", isOptional = true)
|
||||
element<List<JsonObject>>("md_groups", isOptional = true)
|
||||
}
|
||||
|
||||
/** Attempts to parse an ISO-8601 compliant Date Time string with offset to epoch.
|
||||
* @returns epochtime on success, 0 on failure
|
||||
**/
|
||||
private fun parseISO8601(s: String): Long {
|
||||
var fractionalPart_ms: Long = 0
|
||||
val sNoFraction = Regex("""\.\d+""").replace(s) { match ->
|
||||
fractionalPart_ms = truncate(
|
||||
match.value.substringAfter(".").toFloat() * 10.0f.pow(-(match.value.length - 1)) * // seconds
|
||||
1000 // milliseconds
|
||||
).toLong()
|
||||
""
|
||||
}
|
||||
|
||||
return SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZZZZZ").parse(sNoFraction)?.let {
|
||||
fractionalPart_ms + it.time
|
||||
} ?: 0
|
||||
}
|
||||
|
||||
private fun formatChapterTitle(title: String?, chap: String?, vol: String?): String {
|
||||
val numNonNull = listOfNotNull(title.takeIf { !it.isNullOrBlank() }, chap, vol).size
|
||||
if (numNonNull == 0) return "unknown"
|
||||
|
||||
val formattedTitle = StringBuilder()
|
||||
if (vol != null) formattedTitle.append("${numNonNull.takeIf { it > 1 }?.let { "Vol." } ?: "Volume"} $vol")
|
||||
if (vol != null && chap != null) formattedTitle.append(", ")
|
||||
if (chap != null) formattedTitle.append("${numNonNull.takeIf { it > 1 }?.let { "Ch." } ?: "Chapter"} $chap")
|
||||
if (!title.isNullOrBlank()) formattedTitle.append("${numNonNull.takeIf { it > 1 }?.let { ": " } ?: ""} $title")
|
||||
return formattedTitle.toString()
|
||||
}
|
||||
|
||||
override fun deserialize(decoder: Decoder): SChapter {
|
||||
return SChapter.create().apply {
|
||||
var chap: String? = null
|
||||
var vol: String? = null
|
||||
var title: String? = null
|
||||
var hid = ""
|
||||
var iso639_1 = ""
|
||||
require(decoder is JsonDecoder)
|
||||
decoder.decodeStructureByKnownName(descriptor) { names ->
|
||||
for ((name, index) in names) {
|
||||
when (name) {
|
||||
"created_at" -> date_upload = parseISO8601(decodeStringElement(descriptor, index))
|
||||
"title" -> title = decodeNullableSerializableElement(descriptor, index, serializer())
|
||||
"vol" -> vol = decodeNullableSerializableElement(descriptor, index, serializer())
|
||||
"chap" -> {
|
||||
chap = decodeNullableSerializableElement(descriptor, index, serializer())
|
||||
chapter_number = chap?.substringBefore('-')?.toFloatOrNull() ?: -1f
|
||||
}
|
||||
"hid" -> hid = decodeStringElement(descriptor, index)
|
||||
"iso639_1" -> iso639_1 = decodeStringElement(descriptor, index)
|
||||
"md_groups" -> scanlator = decodeSerializableElement(descriptor, index, ListSerializer(deepSelectDeserializer<String>("title"))).joinToString(", ")
|
||||
}
|
||||
}
|
||||
}
|
||||
name = formatChapterTitle(title, chap, vol)
|
||||
url = "/$hid-chapter-$chap-$iso639_1" // incomplete, is finished in fetchChapterList
|
||||
}
|
||||
}
|
||||
|
||||
override fun serialize(encoder: Encoder, value: SChapter) = throw UnsupportedOperationException("Unsupported")
|
||||
}
|
||||
|
||||
class SMangaDeserializer : KSerializer<SManga> {
|
||||
private fun cleanDesc(s: String) = (
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N)
|
||||
Html.fromHtml(s, Html.FROM_HTML_MODE_LEGACY) else Html.fromHtml(s)
|
||||
).toString()
|
||||
|
||||
private fun parseStatus(status: Int) = when (status) {
|
||||
1 -> SManga.ONGOING
|
||||
2 -> SManga.COMPLETED
|
||||
else -> SManga.UNKNOWN
|
||||
}
|
||||
|
||||
override val descriptor = buildClassSerialDescriptor(SManga::class.qualifiedName!!) {
|
||||
element<String>("slug")
|
||||
element<String>("title")
|
||||
element<String>("cover_url")
|
||||
element<String>("id", isOptional = true)
|
||||
element<List<JsonObject>>("artists", isOptional = true)
|
||||
element<List<JsonObject>>("authors", isOptional = true)
|
||||
element<String>("desc", isOptional = true)
|
||||
element<String>("demographic", isOptional = true)
|
||||
element<List<JsonObject>>("genres", isOptional = true)
|
||||
element<Int>("status", isOptional = true)
|
||||
element<String>("country", isOptional = true)
|
||||
}
|
||||
|
||||
override fun deserialize(decoder: Decoder): SManga {
|
||||
return SManga.create().apply {
|
||||
var id: Int? = null
|
||||
var slug: String? = null
|
||||
val tryTo = { fn: () -> Unit ->
|
||||
try {
|
||||
fn()
|
||||
} catch (_: Exception) {
|
||||
// Do nothing when fn fails to decode due to type mismatch
|
||||
}
|
||||
}
|
||||
decoder.decodeStructureByKnownName(descriptor) { names ->
|
||||
for ((name, index) in names) {
|
||||
val sluggedNameSerializer = ListSerializer(deepSelectDeserializer<String>("name"))
|
||||
fun nameList(): String? {
|
||||
val list = decodeSerializableElement(descriptor, index, sluggedNameSerializer)
|
||||
return if (list.isEmpty()) {
|
||||
null
|
||||
} else {
|
||||
list.joinToString(", ")
|
||||
}
|
||||
}
|
||||
when (name) {
|
||||
"slug" -> {
|
||||
slug = decodeStringElement(descriptor, index)
|
||||
url = "/comic/$slug"
|
||||
}
|
||||
"title" -> title = decodeStringElement(descriptor, index)
|
||||
"cover_url" -> thumbnail_url = decodeStringElement(descriptor, index)
|
||||
"id" -> id = decodeIntElement(descriptor, index)
|
||||
"artists" -> artist = nameList()
|
||||
"authors" -> author = nameList()
|
||||
"desc" -> tryTo { description = cleanDesc(decodeStringElement(descriptor, index)) }
|
||||
// Isn't always a string in every api call
|
||||
"demographic" -> tryTo { genre = listOfNotNull(genre, decodeStringElement(descriptor, index)).joinToString(", ") }
|
||||
// Isn't always a list of objects in every api call
|
||||
"genres" -> tryTo { genre = listOfNotNull(genre, nameList()).joinToString(", ") }
|
||||
"status" -> status = parseStatus(decodeIntElement(descriptor, index))
|
||||
"country" -> genre = listOfNotNull(
|
||||
genre,
|
||||
mapOf("kr" to "Manhwa", "jp" to "Manga", "cn" to "Manhua")[decodeStringElement(descriptor, index)]
|
||||
).joinToString(", ")
|
||||
}
|
||||
}
|
||||
}
|
||||
if (id != null && slug != null) {
|
||||
mangaIdCache[slug!!] = id!!
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun serialize(encoder: Encoder, value: SManga) = throw UnsupportedOperationException("Not supported")
|
||||
|
||||
companion object {
|
||||
val mangaIdCache = mutableMapOf<String, Int>()
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue