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'
|
extName = 'Comick.fun'
|
||||||
pkgNameSuffix = 'all.comickfun'
|
pkgNameSuffix = 'all.comickfun'
|
||||||
extClass = '.ComickFunFactory'
|
extClass = '.ComickFunFactory'
|
||||||
extVersionCode = 12
|
extVersionCode = 13
|
||||||
isNsfw = true
|
isNsfw = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,450 +1,283 @@
|
||||||
package eu.kanade.tachiyomi.extension.all.comickfun
|
package eu.kanade.tachiyomi.extension.all.comickfun
|
||||||
|
|
||||||
import eu.kanade.tachiyomi.network.GET
|
import eu.kanade.tachiyomi.network.GET
|
||||||
import eu.kanade.tachiyomi.network.asObservableSuccess
|
import eu.kanade.tachiyomi.network.interceptor.rateLimit
|
||||||
import eu.kanade.tachiyomi.network.interceptor.rateLimitHost
|
|
||||||
import eu.kanade.tachiyomi.source.model.Filter
|
|
||||||
import eu.kanade.tachiyomi.source.model.FilterList
|
import eu.kanade.tachiyomi.source.model.FilterList
|
||||||
import eu.kanade.tachiyomi.source.model.MangasPage
|
import eu.kanade.tachiyomi.source.model.MangasPage
|
||||||
import eu.kanade.tachiyomi.source.model.Page
|
import eu.kanade.tachiyomi.source.model.Page
|
||||||
import eu.kanade.tachiyomi.source.model.SChapter
|
import eu.kanade.tachiyomi.source.model.SChapter
|
||||||
import eu.kanade.tachiyomi.source.model.SManga
|
import eu.kanade.tachiyomi.source.model.SManga
|
||||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||||
import kotlinx.serialization.builtins.ListSerializer
|
|
||||||
import kotlinx.serialization.decodeFromString
|
import kotlinx.serialization.decodeFromString
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.modules.SerializersModule
|
|
||||||
import kotlinx.serialization.modules.polymorphic
|
|
||||||
import okhttp3.CacheControl
|
|
||||||
import okhttp3.Headers
|
import okhttp3.Headers
|
||||||
import okhttp3.HttpUrl
|
|
||||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
import okhttp3.Interceptor
|
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import rx.Observable
|
import java.text.SimpleDateFormat
|
||||||
import uy.kohesive.injekt.Injekt
|
|
||||||
import uy.kohesive.injekt.api.get
|
|
||||||
|
|
||||||
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"
|
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
|
override val supportsLatest = true
|
||||||
|
|
||||||
private val json: Json by lazy {
|
private val json = Json {
|
||||||
Json(from = Injekt.get()) {
|
ignoreUnknownKeys = true
|
||||||
serializersModule = SerializersModule {
|
isLenient = true
|
||||||
polymorphic(SManga::class) { default { SMangaDeserializer() } }
|
coerceInputValues = true
|
||||||
polymorphic(SChapter::class) { default { SChapterDeserializer() } }
|
explicitNulls = true
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private val mangaIdCache = SMangaDeserializer.mangaIdCache
|
override fun headersBuilder() = Headers.Builder().apply {
|
||||||
|
add("Referer", "$baseUrl/")
|
||||||
final override fun headersBuilder() = Headers.Builder().apply {
|
add("User-Agent", "Tachiyomi ${System.getProperty("http.agent")}")
|
||||||
add("User-Agent", "Tachiyomi " + System.getProperty("http.agent"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final override val client: OkHttpClient
|
override val client: OkHttpClient = network.client.newBuilder().rateLimit(4, 1).build()
|
||||||
|
|
||||||
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 fun getFilterList() = FilterList(
|
override fun getFilterList() = FilterList(
|
||||||
Filter.Header("NOTE: Ignored if using text search!"),
|
getFilters()
|
||||||
GenreFilter(),
|
|
||||||
DemographicFilter(),
|
|
||||||
TypesFilter(),
|
|
||||||
CreatedAtFilter(),
|
|
||||||
MinChaptersFilter(),
|
|
||||||
SortFilter()
|
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun GenreFilter() = object : MultiTriSelect<LabeledValue>("Genre", getGenreList()), UrlEncoded {
|
private val DATE_FORMATTER by lazy {
|
||||||
val included = object : ArrayUrlParam {
|
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZZZZ")
|
||||||
override val paramName = "genres"
|
}
|
||||||
override var selected: Sequence<LabeledValue> = sequence {}
|
|
||||||
}
|
|
||||||
val excluded = object : ArrayUrlParam {
|
|
||||||
override val paramName = "excludes"
|
|
||||||
override var selected: Sequence<LabeledValue> = sequence {}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun encode(url: HttpUrl.Builder) {
|
/** Popular Manga **/
|
||||||
this.selected.let { (includedGenres, excludedGenres) ->
|
override fun popularMangaRequest(page: Int): Request {
|
||||||
included.apply { selected = includedGenres }.encode(url)
|
return GET(
|
||||||
excluded.apply { selected = excludedGenres }.encode(url)
|
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<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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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<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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun SortFilter() = object : Select<LabeledValue>("Sort", getSorts()), QueryParam {
|
/** Chapter Pages **/
|
||||||
override val paramName = "sort"
|
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<LabeledValue>("Demographic", getDemographics()), ArrayUrlParam {
|
override fun pageListParse(response: Response): List<Page> {
|
||||||
override val paramName = "demographic"
|
val result = json.decodeFromString<PageList>(response.body!!.string())
|
||||||
}
|
return result.chapter.images.mapIndexed { index, data ->
|
||||||
|
Page(index = index, imageUrl = data.url)
|
||||||
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")
|
|
||||||
)
|
|
||||||
|
|
||||||
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 {
|
companion object {
|
||||||
const val SLUG_SEARCH_PREFIX = "id:"
|
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 {
|
class ComickFunFactory : SourceFactory {
|
||||||
override fun createSources(): List<Source> = listOf(
|
override fun createSources(): List<Source> = listOf(
|
||||||
"all",
|
|
||||||
"en",
|
"en",
|
||||||
"pt-br",
|
"pt-br",
|
||||||
"ru",
|
"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