From c72a027702ab24c222abf844e6877a5714c468c3 Mon Sep 17 00:00:00 2001 From: Sergio Malagon <42176762+sergiomalagon@users.noreply.github.com> Date: Thu, 29 Sep 2022 19:16:23 +0200 Subject: [PATCH] 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 --- src/all/comickfun/build.gradle | 2 +- .../extension/all/comickfun/ComickFun.kt | 647 +++++++----------- .../extension/all/comickfun/ComickFunDto.kt | 89 +++ .../all/comickfun/ComickFunFactory.kt | 1 - .../all/comickfun/ComickFunFilters.kt | 179 +++++ .../all/comickfun/ComickFunHelper.kt | 37 + .../all/comickfun/ComickFunSerialization.kt | 256 ------- 7 files changed, 546 insertions(+), 665 deletions(-) create mode 100644 src/all/comickfun/src/eu/kanade/tachiyomi/extension/all/comickfun/ComickFunDto.kt create mode 100644 src/all/comickfun/src/eu/kanade/tachiyomi/extension/all/comickfun/ComickFunFilters.kt create mode 100644 src/all/comickfun/src/eu/kanade/tachiyomi/extension/all/comickfun/ComickFunHelper.kt delete mode 100644 src/all/comickfun/src/eu/kanade/tachiyomi/extension/all/comickfun/ComickFunSerialization.kt diff --git a/src/all/comickfun/build.gradle b/src/all/comickfun/build.gradle index 4196ced66..1079458bb 100644 --- a/src/all/comickfun/build.gradle +++ b/src/all/comickfun/build.gradle @@ -6,7 +6,7 @@ ext { extName = 'Comick.fun' pkgNameSuffix = 'all.comickfun' extClass = '.ComickFunFactory' - extVersionCode = 12 + extVersionCode = 13 isNsfw = true } diff --git a/src/all/comickfun/src/eu/kanade/tachiyomi/extension/all/comickfun/ComickFun.kt b/src/all/comickfun/src/eu/kanade/tachiyomi/extension/all/comickfun/ComickFun.kt index 4fb4b6d43..d55f62cd1 100644 --- a/src/all/comickfun/src/eu/kanade/tachiyomi/extension/all/comickfun/ComickFun.kt +++ b/src/all/comickfun/src/eu/kanade/tachiyomi/extension/all/comickfun/ComickFun.kt @@ -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 { - 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>( - 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 { - 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>(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 { - return client.newCall(apiMangaDetailsRequest(manga)) - .asObservableSuccess() - .map { response -> - mangaDetailsParse(response).apply { initialized = true } - } - } - - override fun mangaDetailsParse(response: Response) = json.decodeFromString( - deserializer = jsonFlatten(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> { - 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> = Observable.just(emptyList())): Observable> = - 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::plus) - } - } else { - Observable.error(Exception("Licensed - No chapters to show")) - } - } - - override fun chapterListParse(response: Response) = json.decodeFromString( - deserializer = deepSelectDeserializer>("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>("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 - 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(header: String, values: Array, state: Int = 0) : Filter.Select(header, values, state) { - val selected: T - get() = this.values[this.state] - } - - private open class MultiSelect(header: String, val elems: List) : - Filter.Group(header, elems.map { object : Filter.CheckBox("$it") {} }) { - val selected: Sequence - get() = this.elems.asSequence().filterIndexed { i, _ -> this.state[i].state } - } - - private open class MultiTriSelect(header: String, val elems: List) : - Filter.Group(header, elems.map { object : Filter.TriState("$it") {} }) { - val selected: Pair, Sequence> - 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("Genre", getGenreList()), UrlEncoded { - val included = object : ArrayUrlParam { - override val paramName = "genres" - override var selected: Sequence = sequence {} - } - val excluded = object : ArrayUrlParam { - override val paramName = "excludes" - override var selected: Sequence = 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) + /** 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 + ) + } + + override fun popularMangaParse(response: Response): MangasPage { + val result = json.decodeFromString>(response.body!!.string()) + return MangasPage( + result.map { data -> + SManga.create().apply { + url = "/comic/${data.slug}" + title = data.title + thumbnail_url = data.cover_url + } + }, true + ) + } + + /** 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 + ) + } + + override fun latestUpdatesParse(response: Response): MangasPage { + val result = json.decodeFromString>(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 + ) + } + + /** 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>(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(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 { + val mangaData = json.decodeFromString(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(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() } } } } - private fun SortFilter() = object : Select("Sort", getSorts()), QueryParam { - override val paramName = "sort" + /** 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 + ) } - private fun DemographicFilter() = object : MultiSelect("Demographic", getDemographics()), ArrayUrlParam { - override val paramName = "demographic" - } - - private fun TypesFilter() = object : MultiSelect("Type", getContentType()), ArrayUrlParam { - override val paramName = "country" - } - - private fun CreatedAtFilter() = object : Select("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) + override fun pageListParse(response: Response): List { + val result = json.decodeFromString(response.body!!.string()) + return result.chapter.images.mapIndexed { index, data -> + Page(index = index, imageUrl = data.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") - ) - - private fun getDemographics() = listOf( - LabeledValue("Shonen", "1"), - LabeledValue("Shoujo", "2"), - LabeledValue("Seinen", "3"), - LabeledValue("Josei", "4"), - - ) - - private fun getContentType() = listOf( - LabeledValue("Manga", "jp"), - LabeledValue("Manhwa", "kr"), - LabeledValue("Manhua", "cn"), - ) - - private fun getCreatedAt() = arrayOf( - LabeledValue("", ""), - LabeledValue("30 days", "30"), - LabeledValue("3 months", "90"), - LabeledValue("6 months", "180"), - LabeledValue("1 year", "365"), - ) - - private fun getSorts() = arrayOf( - LabeledValue("", ""), - LabeledValue("Most follows", "follow"), - LabeledValue("Most views", "view"), - LabeledValue("High rating", "rating"), - LabeledValue("Last updated", "uploaded") - ) - 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 "" + } } diff --git a/src/all/comickfun/src/eu/kanade/tachiyomi/extension/all/comickfun/ComickFunDto.kt b/src/all/comickfun/src/eu/kanade/tachiyomi/extension/all/comickfun/ComickFunDto.kt new file mode 100644 index 000000000..9c76f237c --- /dev/null +++ b/src/all/comickfun/src/eu/kanade/tachiyomi/extension/all/comickfun/ComickFunDto.kt @@ -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, + val authors: Array, + val genres: Array +) + +@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 +) + +@Serializable +data class Chapter( + val hid: String = "", + val title: String = "", + val created_at: String = "", + val chap: String = "", + val vol: String = "", + val group_name: Array = arrayOf("") +) + +@Serializable +data class PageList( + val chapter: ChapterPageData +) + +@Serializable +data class ChapterPageData( + val images: Array +) + +@Serializable +data class Page( + val url: String +) diff --git a/src/all/comickfun/src/eu/kanade/tachiyomi/extension/all/comickfun/ComickFunFactory.kt b/src/all/comickfun/src/eu/kanade/tachiyomi/extension/all/comickfun/ComickFunFactory.kt index a60d02778..013f5a083 100644 --- a/src/all/comickfun/src/eu/kanade/tachiyomi/extension/all/comickfun/ComickFunFactory.kt +++ b/src/all/comickfun/src/eu/kanade/tachiyomi/extension/all/comickfun/ComickFunFactory.kt @@ -12,7 +12,6 @@ val legacyLanguageMappings = mapOf( class ComickFunFactory : SourceFactory { override fun createSources(): List = listOf( - "all", "en", "pt-br", "ru", diff --git a/src/all/comickfun/src/eu/kanade/tachiyomi/extension/all/comickfun/ComickFunFilters.kt b/src/all/comickfun/src/eu/kanade/tachiyomi/extension/all/comickfun/ComickFunFilters.kt new file mode 100644 index 000000000..8d6b88b9e --- /dev/null +++ b/src/all/comickfun/src/eu/kanade/tachiyomi/extension/all/comickfun/ComickFunFilters.kt @@ -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) : Group(name, genreList) + +internal class TagFilter(name: String) : Text(name) + +internal class DemographicFilter(name: String, demographicList: List) : + Group(name, demographicList) + +internal class TypeFilter(name: String, typeList: List) : + Group(name, typeList) + +internal class CompletedFilter(name: String) : CheckBox(name) + +internal class CreatedAtFilter(name: String, createdAtList: Array>) : + 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>) : + Select(name, sortList) + +/** Generics **/ +internal open class Group(name: String, values: List) : + Filter.Group(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>) : + Filter.Select(name, vals.map { it.first }.toTypedArray()) { + fun getValue() = vals[state].second +} + +/** Filters Data **/ +private val getGenresList: List = 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 = listOf( + CheckBox("Shounen", "1"), + CheckBox("Shoujo", "2"), + CheckBox("Seinen", "3"), + CheckBox("Josei", "4"), +) + +private val getTypeList: List = listOf( + CheckBox("Manga", "jp"), + CheckBox("Manhwa", "kr"), + CheckBox("Manhua", "cn"), +) + +private val getCreatedAtList: Array> = arrayOf( + Pair("", ""), + Pair("30 days", "30"), + Pair("3 months", "90"), + Pair("6 months", "180"), + Pair("1 year", "365"), +) + +private val getSortsList: Array> = arrayOf( + Pair("Most follows", "user_follow_count"), + Pair("Most views", "view"), + Pair("High rating", "rating"), + Pair("Last updated", "uploaded") +) diff --git a/src/all/comickfun/src/eu/kanade/tachiyomi/extension/all/comickfun/ComickFunHelper.kt b/src/all/comickfun/src/eu/kanade/tachiyomi/extension/all/comickfun/ComickFunHelper.kt new file mode 100644 index 000000000..950cf78b3 --- /dev/null +++ b/src/all/comickfun/src/eu/kanade/tachiyomi/extension/all/comickfun/ComickFunHelper.kt @@ -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") + } + } +} diff --git a/src/all/comickfun/src/eu/kanade/tachiyomi/extension/all/comickfun/ComickFunSerialization.kt b/src/all/comickfun/src/eu/kanade/tachiyomi/extension/all/comickfun/ComickFunSerialization.kt deleted file mode 100644 index 9622e3166..000000000 --- a/src/all/comickfun/src/eu/kanade/tachiyomi/extension/all/comickfun/ComickFunSerialization.kt +++ /dev/null @@ -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 deepSelectDeserializer(vararg keys: String, tDeserializer: KSerializer = serializer()): KSerializer { - val descriptors = keys.foldRight(listOf(tDeserializer.descriptor)) { x, acc -> - acc + acc.last().let { - buildClassSerialDescriptor("$x\$${it.serialName}") { element(x, it) } - } - }.asReversed() - - return object : KSerializer { - private var depth = 0 - private fun asChild(fn: (KSerializer) -> 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 jsonFlatten( - objKey: String, - vararg keys: String, - tDeserializer: KSerializer = serializer() -): JsonTransformingSerializer { - return object : JsonTransformingSerializer(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 Decoder.decodeStructureByKnownName(descriptor: SerialDescriptor, decodeFn: CompositeDecoder.(Sequence>) -> 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 { - override val descriptor = buildClassSerialDescriptor(SChapter::class.qualifiedName!!) { - element("chap") - element("hid") - element("title") - element("vol", isOptional = true) - element("created_at") - element("iso639_1") - element>("images", isOptional = true) - element>("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("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 { - 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("slug") - element("title") - element("cover_url") - element("id", isOptional = true) - element>("artists", isOptional = true) - element>("authors", isOptional = true) - element("desc", isOptional = true) - element("demographic", isOptional = true) - element>("genres", isOptional = true) - element("status", isOptional = true) - element("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("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() - } -}