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:
Sergio Malagon 2022-09-29 19:16:23 +02:00 committed by GitHub
parent 23727459b2
commit c72a027702
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 546 additions and 665 deletions

View File

@ -6,7 +6,7 @@ ext {
extName = 'Comick.fun'
pkgNameSuffix = 'all.comickfun'
extClass = '.ComickFunFactory'
extVersionCode = 12
extVersionCode = 13
isNsfw = true
}

View File

@ -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 ""
}
}

View File

@ -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
)

View File

@ -12,7 +12,6 @@ val legacyLanguageMappings = mapOf(
class ComickFunFactory : SourceFactory {
override fun createSources(): List<Source> = listOf(
"all",
"en",
"pt-br",
"ru",

View File

@ -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")
)

View File

@ -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")
}
}
}

View File

@ -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&lt;String&gt;("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>()
}
}