Replace json library with kotlinx.serialization in multiple sources (#7407)
* Catmanga: Replace org.json with kotlinx.serialization + Light Refactor of #7451 * Genkan IO: Replace gson + Make livewire interceptor * Genkan IO: Tail Call Optimization to avoid blowing stack * Comick.fun: kotlinx.serialization migration * Remanga: kotlinx.serialzation migration
This commit is contained in:
		
							parent
							
								
									745e57f4e6
								
							
						
					
					
						commit
						1175b0d1c7
					
				@ -1,11 +1,12 @@
 | 
			
		||||
apply plugin: 'com.android.application'
 | 
			
		||||
apply plugin: 'kotlin-android'
 | 
			
		||||
apply plugin: 'kotlinx-serialization'
 | 
			
		||||
 | 
			
		||||
ext {
 | 
			
		||||
    extName = 'Comick.fun'
 | 
			
		||||
    pkgNameSuffix = 'all.comickfun'
 | 
			
		||||
    extClass = '.ComickFunFactory'
 | 
			
		||||
    extVersionCode = 1
 | 
			
		||||
    extVersionCode = 3
 | 
			
		||||
    libVersion = '1.2'
 | 
			
		||||
    containsNsfw = true
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -1,13 +1,5 @@
 | 
			
		||||
package eu.kanade.tachiyomi.extension.all.comickfun
 | 
			
		||||
 | 
			
		||||
import android.os.Build
 | 
			
		||||
import android.text.Html
 | 
			
		||||
import com.github.salomonbrys.kotson.array
 | 
			
		||||
import com.github.salomonbrys.kotson.get
 | 
			
		||||
import com.github.salomonbrys.kotson.nullString
 | 
			
		||||
import com.github.salomonbrys.kotson.obj
 | 
			
		||||
import com.google.gson.JsonElement
 | 
			
		||||
import com.google.gson.JsonParser
 | 
			
		||||
import eu.kanade.tachiyomi.lib.ratelimit.RateLimitInterceptor
 | 
			
		||||
import eu.kanade.tachiyomi.network.GET
 | 
			
		||||
import eu.kanade.tachiyomi.network.asObservableSuccess
 | 
			
		||||
@ -18,6 +10,13 @@ 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.ExperimentalSerializationApi
 | 
			
		||||
import kotlinx.serialization.json.Json
 | 
			
		||||
import kotlinx.serialization.json.JsonArray
 | 
			
		||||
import kotlinx.serialization.json.JsonObject
 | 
			
		||||
import kotlinx.serialization.json.decodeFromJsonElement
 | 
			
		||||
import kotlinx.serialization.modules.SerializersModule
 | 
			
		||||
import kotlinx.serialization.modules.polymorphic
 | 
			
		||||
import okhttp3.CacheControl
 | 
			
		||||
import okhttp3.Headers
 | 
			
		||||
import okhttp3.HttpUrl
 | 
			
		||||
@ -27,10 +26,9 @@ 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.lang.UnsupportedOperationException
 | 
			
		||||
import java.text.SimpleDateFormat
 | 
			
		||||
import kotlin.math.pow
 | 
			
		||||
import kotlin.math.truncate
 | 
			
		||||
 | 
			
		||||
const val SEARCH_PAGE_LIMIT = 100
 | 
			
		||||
 | 
			
		||||
@ -40,7 +38,18 @@ abstract class ComickFun(override val lang: String, private val comickFunLang: S
 | 
			
		||||
    private val apiBase = "$baseUrl/api"
 | 
			
		||||
    override val supportsLatest = true
 | 
			
		||||
 | 
			
		||||
    private val mangaIdCache = mutableMapOf<String, Int>()
 | 
			
		||||
    @ExperimentalSerializationApi
 | 
			
		||||
    private val json: Json by lazy {
 | 
			
		||||
        Json(from = Injekt.get()) {
 | 
			
		||||
            serializersModule = SerializersModule {
 | 
			
		||||
                polymorphic(SManga::class) { default { SMangaDeserializer() } }
 | 
			
		||||
                polymorphic(SChapter::class) { default { SChapterDeserializer() } }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @ExperimentalSerializationApi
 | 
			
		||||
    private val mangaIdCache = SMangaDeserializer.mangaIdCache
 | 
			
		||||
 | 
			
		||||
    final override fun headersBuilder() = Headers.Builder().apply {
 | 
			
		||||
        add("User-Agent", "Tachiyomi " + System.getProperty("http.agent"))
 | 
			
		||||
@ -80,92 +89,37 @@ abstract class ComickFun(override val lang: String, private val comickFunLang: S
 | 
			
		||||
 | 
			
		||||
    /**  Utils **/
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Parses a json object with information suitable for showing an entry of a manga within a
 | 
			
		||||
     * catalogue
 | 
			
		||||
     *
 | 
			
		||||
     * Attempts to cache the manga's numerical Id
 | 
			
		||||
     *
 | 
			
		||||
     * @return SManga - with url, thumbnail_url and title set
 | 
			
		||||
     */
 | 
			
		||||
    private fun parseMangaObj(it: JsonElement) = it.asJsonObject.let { info ->
 | 
			
		||||
        info["id"]?.asInt?.let { mangaIdCache.getOrPut(info["slug"].asString, { it }) }
 | 
			
		||||
        val thumbnail = info["coverURL"]?.nullString
 | 
			
		||||
            ?: info["md_covers"]?.asJsonArray?.get(0)?.asJsonObject?.let { cover ->
 | 
			
		||||
                cover["gpurl"]?.nullString ?: "$baseUrl${cover["url"].asString}"
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
        SManga.create().apply {
 | 
			
		||||
            url = "/comic/${info["slug"].asString}"
 | 
			
		||||
            thumbnail_url = thumbnail
 | 
			
		||||
            title = info["title"].asString
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /** Returns an observable which emits a single value -> the manga's id **/
 | 
			
		||||
    @ExperimentalSerializationApi
 | 
			
		||||
    private fun chapterId(manga: SManga): Observable<Int> {
 | 
			
		||||
        val mangaSlug = slug(manga)
 | 
			
		||||
        return mangaIdCache[mangaSlug]?.let { Observable.just(it) }
 | 
			
		||||
            ?: fetchMangaDetails(manga).map { mangaIdCache[mangaSlug] }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private fun parseStatus(status: Int) = when (status) {
 | 
			
		||||
        1 -> SManga.ONGOING
 | 
			
		||||
        2 -> SManga.COMPLETED
 | 
			
		||||
        else -> SManga.UNKNOWN
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /** 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()
 | 
			
		||||
            ""
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        val ret = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZZZZZ").parse(sNoFraction)?.let {
 | 
			
		||||
            fractionalPart_ms + it.time
 | 
			
		||||
        } ?: 0
 | 
			
		||||
        return ret
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /** 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]
 | 
			
		||||
 | 
			
		||||
    private fun formatChapterTitle(title: String?, chap: String?, vol: String?): String {
 | 
			
		||||
        val numNonNull = listOfNotNull(title.takeIf { !it.isNullOrBlank() }, chap, vol).size
 | 
			
		||||
        if (numNonNull == 0) throw RuntimeException("formatChapterTitle requires at least one non-null argument")
 | 
			
		||||
 | 
			
		||||
        var 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()
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /** Popular Manga **/
 | 
			
		||||
 | 
			
		||||
    @ExperimentalSerializationApi
 | 
			
		||||
    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 **/
 | 
			
		||||
    @ExperimentalSerializationApi
 | 
			
		||||
    override fun latestUpdatesParse(response: Response): MangasPage {
 | 
			
		||||
        val noResults = MangasPage(emptyList(), false)
 | 
			
		||||
        if (response.code == 204)
 | 
			
		||||
            return noResults
 | 
			
		||||
        return JsonParser.parseString(response.body!!.string()).obj["data"]?.array?.let { manga ->
 | 
			
		||||
            MangasPage(manga.map { parseMangaObj(it["md_comics"]) }, true)
 | 
			
		||||
        } ?: noResults
 | 
			
		||||
        return json.decodeFromString(
 | 
			
		||||
            deserializer = deepSelectDeserializer<List<SManga>>("data"),
 | 
			
		||||
            response.body!!.string()
 | 
			
		||||
        ).let { MangasPage(it, true) }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    override fun latestUpdatesRequest(page: Int): Request {
 | 
			
		||||
@ -175,6 +129,7 @@ abstract class ComickFun(override val lang: String, private val comickFunLang: S
 | 
			
		||||
        return GET("$url", headers)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @ExperimentalSerializationApi
 | 
			
		||||
    override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
 | 
			
		||||
        if (!query.startsWith(SLUG_SEARCH_PREFIX))
 | 
			
		||||
            return super.fetchSearchManga(page, query, filters)
 | 
			
		||||
@ -205,11 +160,14 @@ abstract class ComickFun(override val lang: String, private val comickFunLang: S
 | 
			
		||||
        return GET("$url", headers)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    override fun searchMangaParse(response: Response): MangasPage = JsonParser.parseString(response.body!!.string()).let {
 | 
			
		||||
        if (it.isJsonObject)
 | 
			
		||||
            MangasPage(it["comics"].array.map(::parseMangaObj), it["comics"].array.size() == SEARCH_PAGE_LIMIT)
 | 
			
		||||
        else // search_title isn't paginated
 | 
			
		||||
            MangasPage(it.array.map(::parseMangaObj), false)
 | 
			
		||||
    @ExperimentalSerializationApi
 | 
			
		||||
    override fun searchMangaParse(response: Response): MangasPage = json.parseToJsonElement(response.body!!.string()).let { parsed ->
 | 
			
		||||
        when (parsed) {
 | 
			
		||||
            is JsonObject -> json.decodeFromJsonElement<List<SManga>>(parsed["comics"]!!)
 | 
			
		||||
                .let { MangasPage(it, it.size == SEARCH_PAGE_LIMIT) }
 | 
			
		||||
            is JsonArray -> MangasPage(json.decodeFromJsonElement(parsed), false)
 | 
			
		||||
            else -> MangasPage(emptyList(), false)
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /** Manga Details **/
 | 
			
		||||
@ -219,6 +177,7 @@ abstract class ComickFun(override val lang: String, private val comickFunLang: S
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Shenanigans to allow "open in webview" to show a webpage instead of JSON
 | 
			
		||||
    @ExperimentalSerializationApi
 | 
			
		||||
    override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
 | 
			
		||||
        return client.newCall(apiMangaDetailsRequest(manga))
 | 
			
		||||
            .asObservableSuccess()
 | 
			
		||||
@ -227,36 +186,18 @@ abstract class ComickFun(override val lang: String, private val comickFunLang: S
 | 
			
		||||
            }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    override fun mangaDetailsParse(response: Response) = JsonParser.parseString(response.body!!.string())["data"].let { data ->
 | 
			
		||||
        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()
 | 
			
		||||
 | 
			
		||||
        fun nameList(e: JsonElement?) = e?.array?.asSequence()?.map { it["name"].asString }
 | 
			
		||||
        data["comic"]["id"].asInt.let { mangaIdCache.getOrPut(response.request.url.queryParameter("slug")!!, { it }) }
 | 
			
		||||
        SManga.create().apply {
 | 
			
		||||
            title = data["comic"]["title"].asString
 | 
			
		||||
            thumbnail_url = data["coverURL"].asString
 | 
			
		||||
            description = cleanDesc(data["comic"]["desc"].asString)
 | 
			
		||||
            status = parseStatus(data["comic"]["status"].asInt)
 | 
			
		||||
            artist = nameList(data["artists"])?.joinToString(", ")
 | 
			
		||||
            author = nameList(data["authors"])?.joinToString(", ")
 | 
			
		||||
            genre = (
 | 
			
		||||
                (nameList(data["genres"]) ?: sequenceOf()) + sequence {
 | 
			
		||||
                    data["demographic"].nullString?.let { yield(it) }
 | 
			
		||||
                    mapOf("kr" to "Manhwa", "jp" to "Manga", "cn" to "Manhua")[data["comic"]["country"].nullString]
 | 
			
		||||
                        ?.let { yield(it) }
 | 
			
		||||
                }
 | 
			
		||||
                ).joinToString(", ")
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    @ExperimentalSerializationApi
 | 
			
		||||
    override fun mangaDetailsParse(response: Response) = json.decodeFromString(
 | 
			
		||||
        deserializer = deepSelectDeserializer<SManga>("data", tDeserializer = jsonFlatten(objKey = "comic", "id", "title", "desc", "status", "country", "slug")),
 | 
			
		||||
        response.body!!.string()
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    /** Chapter List **/
 | 
			
		||||
 | 
			
		||||
    private fun chapterListRequest(page: Int, mangaId: Int) =
 | 
			
		||||
        GET("$apiBase/get_chapters?comicid=$mangaId&page=$page&limit=$SEARCH_PAGE_LIMIT", headers)
 | 
			
		||||
 | 
			
		||||
    @ExperimentalSerializationApi
 | 
			
		||||
    override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
 | 
			
		||||
        return if (manga.status != SManga.LICENSED) {
 | 
			
		||||
            chapterId(manga).concatMap { id ->
 | 
			
		||||
@ -281,25 +222,22 @@ abstract class ComickFun(override val lang: String, private val comickFunLang: S
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    override fun chapterListParse(response: Response) = JsonParser.parseString(response.body!!.string()).obj["data"]["chapters"].array.map { elem ->
 | 
			
		||||
        val chapter = elem.asJsonObject
 | 
			
		||||
        val num = chapter["chap"].nullString ?: "-1"
 | 
			
		||||
        SChapter.create().apply {
 | 
			
		||||
            date_upload = parseISO8601(chapter["created_at"].asString)
 | 
			
		||||
            name = formatChapterTitle(chapter["title"].nullString, chapter["chap"].nullString, chapter["vol"].nullString)
 | 
			
		||||
            chapter_number = num.toFloat()
 | 
			
		||||
            url = "/${chapter["hid"].asString}-chapter-${chapter["chap"].nullString}-${chapter["iso639_1"].asString}" // incomplete, is finished in fetchChapterList
 | 
			
		||||
            scanlator = chapter.get("md_groups")?.array?.get(0)?.obj?.get("title")?.asString
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    @ExperimentalSerializationApi
 | 
			
		||||
    override fun chapterListParse(response: Response) = json.decodeFromString(
 | 
			
		||||
        deserializer = deepSelectDeserializer<List<SChapter>>("data", "chapters"),
 | 
			
		||||
        response.body!!.string()
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    /** Page List **/
 | 
			
		||||
 | 
			
		||||
    override fun pageListRequest(chapter: SChapter) = GET("$apiBase/get_chapter?hid=${hid(chapter)}", headers, CacheControl.FORCE_NETWORK)
 | 
			
		||||
 | 
			
		||||
    override fun pageListParse(response: Response) = JsonParser.parseString(response.body!!.string())["data"]["chapter"]["images"].array.mapIndexed { i, url ->
 | 
			
		||||
        Page(i, imageUrl = url.asString)
 | 
			
		||||
    }
 | 
			
		||||
    @ExperimentalSerializationApi
 | 
			
		||||
    override fun pageListParse(response: Response) =
 | 
			
		||||
        json.decodeFromString(
 | 
			
		||||
            deserializer = deepSelectDeserializer<List<String>>("data", "chapter", "images"),
 | 
			
		||||
            response.body!!.string()
 | 
			
		||||
        ).mapIndexed { i, url -> Page(i, imageUrl = url) }
 | 
			
		||||
 | 
			
		||||
    override fun imageUrlParse(response: Response) = "" // idk what this does, leave me alone kotlin
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -0,0 +1,261 @@
 | 
			
		||||
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.ExperimentalSerializationApi
 | 
			
		||||
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"
 | 
			
		||||
 */
 | 
			
		||||
@ExperimentalSerializationApi
 | 
			
		||||
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()
 | 
			
		||||
    var a: ((Int) -> KSerializer<T>)? = null
 | 
			
		||||
    val b = { depth: Int ->
 | 
			
		||||
        object : KSerializer<T> {
 | 
			
		||||
            override val descriptor = 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) -> decodeSerializableElement(descriptor, index, a!!(depth + 1)) }
 | 
			
		||||
                        .single()
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            override fun serialize(encoder: Encoder, value: T) = throw UnsupportedOperationException("Not supported")
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    a = b // this is the hackiest of hacky hacks to get around not being able to define recursive inline functions
 | 
			
		||||
    return a(0)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * 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]!!) }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ExperimentalSerializationApi
 | 
			
		||||
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 }
 | 
			
		||||
        )
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ExperimentalSerializationApi
 | 
			
		||||
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) throw RuntimeException("formatChapterTitle requires at least one non-null argument")
 | 
			
		||||
 | 
			
		||||
        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()
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @ExperimentalSerializationApi
 | 
			
		||||
    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 = decodeStringElement(descriptor, index)
 | 
			
		||||
                            chapter_number = chap!!.toFloat()
 | 
			
		||||
                        }
 | 
			
		||||
                        "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")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ExperimentalSerializationApi
 | 
			
		||||
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>("coverURL")
 | 
			
		||||
        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)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @ExperimentalSerializationApi
 | 
			
		||||
    override fun deserialize(decoder: Decoder): SManga {
 | 
			
		||||
        return SManga.create().apply {
 | 
			
		||||
            var id: Int? = null
 | 
			
		||||
            var slug: String? = null
 | 
			
		||||
            val tryTo = (
 | 
			
		||||
                {
 | 
			
		||||
                    var hasThrown = false;
 | 
			
		||||
                    { fn: () -> Unit ->
 | 
			
		||||
                        if (!hasThrown) {
 | 
			
		||||
                            try {
 | 
			
		||||
                                fn()
 | 
			
		||||
                            } catch (_: java.lang.Exception) {
 | 
			
		||||
                                hasThrown = true
 | 
			
		||||
                            }
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
                )()
 | 
			
		||||
            decoder.decodeStructureByKnownName(descriptor) { names ->
 | 
			
		||||
                for ((name, index) in names) {
 | 
			
		||||
                    val sluggedNameSerializer = ListSerializer(deepSelectDeserializer<String>("name"))
 | 
			
		||||
                    fun nameList() = decodeSerializableElement(descriptor, index, sluggedNameSerializer).joinToString(", ")
 | 
			
		||||
                    when (name) {
 | 
			
		||||
                        "slug" -> {
 | 
			
		||||
                            slug = decodeStringElement(descriptor, index)
 | 
			
		||||
                            url = "/comic/$slug"
 | 
			
		||||
                        }
 | 
			
		||||
                        "title" -> title = decodeStringElement(descriptor, index)
 | 
			
		||||
                        "coverURL" -> thumbnail_url = decodeStringElement(descriptor, index)
 | 
			
		||||
                        "id" -> id = decodeIntElement(descriptor, index)
 | 
			
		||||
                        "artists" -> artist = nameList()
 | 
			
		||||
                        "authors" -> author = nameList()
 | 
			
		||||
                        "desc" -> 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>()
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@ -1,11 +1,12 @@
 | 
			
		||||
apply plugin: 'com.android.application'
 | 
			
		||||
apply plugin: 'kotlin-android'
 | 
			
		||||
apply plugin: 'kotlinx-serialization'
 | 
			
		||||
 | 
			
		||||
ext {
 | 
			
		||||
    extName = 'Genkan.io'
 | 
			
		||||
    pkgNameSuffix = "all.genkanio"
 | 
			
		||||
    extClass = '.GenkanIO'
 | 
			
		||||
    extVersionCode = 2
 | 
			
		||||
    extVersionCode = 3
 | 
			
		||||
    libVersion = '1.2'
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -1,32 +1,38 @@
 | 
			
		||||
package eu.kanade.tachiyomi.extension.all.genkanio
 | 
			
		||||
 | 
			
		||||
import android.util.Log
 | 
			
		||||
import com.github.salomonbrys.kotson.keys
 | 
			
		||||
import com.github.salomonbrys.kotson.put
 | 
			
		||||
import com.google.gson.JsonArray
 | 
			
		||||
import com.google.gson.JsonElement
 | 
			
		||||
import com.google.gson.JsonObject
 | 
			
		||||
import com.google.gson.JsonParser
 | 
			
		||||
import eu.kanade.tachiyomi.network.GET
 | 
			
		||||
import eu.kanade.tachiyomi.network.POST
 | 
			
		||||
import eu.kanade.tachiyomi.network.asObservableSuccess
 | 
			
		||||
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.ParsedHttpSource
 | 
			
		||||
import eu.kanade.tachiyomi.util.asJsoup
 | 
			
		||||
import okhttp3.Headers
 | 
			
		||||
import kotlinx.serialization.json.Json
 | 
			
		||||
import kotlinx.serialization.json.JsonElement
 | 
			
		||||
import kotlinx.serialization.json.JsonNull
 | 
			
		||||
import kotlinx.serialization.json.JsonObject
 | 
			
		||||
import kotlinx.serialization.json.JsonPrimitive
 | 
			
		||||
import kotlinx.serialization.json.boolean
 | 
			
		||||
import kotlinx.serialization.json.buildJsonArray
 | 
			
		||||
import kotlinx.serialization.json.buildJsonObject
 | 
			
		||||
import kotlinx.serialization.json.int
 | 
			
		||||
import kotlinx.serialization.json.jsonObject
 | 
			
		||||
import kotlinx.serialization.json.jsonPrimitive
 | 
			
		||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
 | 
			
		||||
import okhttp3.Interceptor
 | 
			
		||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
 | 
			
		||||
import okhttp3.Request
 | 
			
		||||
import okhttp3.RequestBody.Companion.toRequestBody
 | 
			
		||||
import okhttp3.Response
 | 
			
		||||
import okhttp3.ResponseBody.Companion.toResponseBody
 | 
			
		||||
import okio.Buffer
 | 
			
		||||
import org.jsoup.nodes.Document
 | 
			
		||||
import org.jsoup.nodes.Element
 | 
			
		||||
import rx.Observable
 | 
			
		||||
import uy.kohesive.injekt.injectLazy
 | 
			
		||||
import java.util.Calendar
 | 
			
		||||
 | 
			
		||||
open class GenkanIO : ParsedHttpSource() {
 | 
			
		||||
@ -35,37 +41,141 @@ open class GenkanIO : ParsedHttpSource() {
 | 
			
		||||
    final override val baseUrl = "https://genkan.io"
 | 
			
		||||
    final override val supportsLatest = false
 | 
			
		||||
 | 
			
		||||
    data class LiveWireRPC(val csrf: String, val state: JsonObject)
 | 
			
		||||
    private var livewire: LiveWireRPC? = null
 | 
			
		||||
    private val json: Json by injectLazy()
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Given a string encoded with html entities and escape sequences, makes an attempt to decode
 | 
			
		||||
     * and returns decoded string
 | 
			
		||||
     *
 | 
			
		||||
     * Warning: This is not all all exhaustive, and probably misses edge cases
 | 
			
		||||
     *
 | 
			
		||||
     * @Returns decoded string
 | 
			
		||||
    /** An interceptor which encapsulates the logic needed to interoperate with Genkan.io's
 | 
			
		||||
     *  livewire server, which uses a form a Remote Procedure call
 | 
			
		||||
     */
 | 
			
		||||
    private fun htmlDecode(html: String): String {
 | 
			
		||||
        return html.replace(Regex("&([A-Za-z]+);")) { match ->
 | 
			
		||||
            mapOf(
 | 
			
		||||
                "raquo" to "»",
 | 
			
		||||
                "laquo" to "«",
 | 
			
		||||
                "amp" to "&",
 | 
			
		||||
                "lt" to "<",
 | 
			
		||||
                "gt" to ">",
 | 
			
		||||
                "quot" to "\""
 | 
			
		||||
            )[match.groups[1]!!.value] ?: match.groups[0]!!.value
 | 
			
		||||
        }.replace(Regex("\\\\(.)")) { match ->
 | 
			
		||||
            mapOf(
 | 
			
		||||
                "t" to "\t",
 | 
			
		||||
                "n" to "\n",
 | 
			
		||||
                "r" to "\r",
 | 
			
		||||
                "b" to "\b"
 | 
			
		||||
            )[match.groups[1]!!.value] ?: match.groups[1]!!.value
 | 
			
		||||
    private val livewireInterceptor = object : Interceptor {
 | 
			
		||||
        private lateinit var fingerprint: JsonElement
 | 
			
		||||
        lateinit var serverMemo: JsonObject
 | 
			
		||||
        private lateinit var csrf: String
 | 
			
		||||
        var initialized = false
 | 
			
		||||
        val serverUrl = "$baseUrl/livewire/message/manga.list-all-manga"
 | 
			
		||||
 | 
			
		||||
        /**
 | 
			
		||||
         * Given a string encoded with html entities and escape sequences, makes an attempt to decode
 | 
			
		||||
         * and returns decoded string
 | 
			
		||||
         *
 | 
			
		||||
         * Warning: This is not all all exhaustive, and probably misses edge cases
 | 
			
		||||
         *
 | 
			
		||||
         * @Returns decoded string
 | 
			
		||||
         */
 | 
			
		||||
        private fun htmlDecode(html: String): String {
 | 
			
		||||
            return html.replace(Regex("&([A-Za-z]+);")) { match ->
 | 
			
		||||
                mapOf(
 | 
			
		||||
                    "raquo" to "»",
 | 
			
		||||
                    "laquo" to "«",
 | 
			
		||||
                    "amp" to "&",
 | 
			
		||||
                    "lt" to "<",
 | 
			
		||||
                    "gt" to ">",
 | 
			
		||||
                    "quot" to "\""
 | 
			
		||||
                )[match.groups[1]!!.value] ?: match.groups[0]!!.value
 | 
			
		||||
            }.replace(Regex("\\\\(.)")) { match ->
 | 
			
		||||
                mapOf(
 | 
			
		||||
                    "t" to "\t",
 | 
			
		||||
                    "n" to "\n",
 | 
			
		||||
                    "r" to "\r",
 | 
			
		||||
                    "b" to "\b"
 | 
			
		||||
                )[match.groups[1]!!.value] ?: match.groups[1]!!.value
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        /**
 | 
			
		||||
         * Recursively merges j2 onto j1 in place
 | 
			
		||||
         * If j1 and j2 both contain keys whose values aren't both jsonObjects, j2's value overwrites j1's
 | 
			
		||||
         *
 | 
			
		||||
         */
 | 
			
		||||
        private fun mergeLeft(j1: JsonObject, j2: JsonObject): JsonObject = buildJsonObject {
 | 
			
		||||
            j1.keys.forEach { put(it, j1[it]!!) }
 | 
			
		||||
            j2.keys.forEach { k ->
 | 
			
		||||
                when {
 | 
			
		||||
                    j1[k] !is JsonObject -> put(k, j2[k]!!)
 | 
			
		||||
                    j1[k] is JsonObject && j2[k] is JsonObject -> put(k, mergeLeft(j1[k]!!.jsonObject, j2[k]!!.jsonObject))
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        /**
 | 
			
		||||
         * Initializes lateinit member vars
 | 
			
		||||
         */
 | 
			
		||||
        private fun initLivewire(chain: Interceptor.Chain) {
 | 
			
		||||
            val response = chain.proceed(GET("$baseUrl/manga", headers))
 | 
			
		||||
            val soup = response.asJsoup()
 | 
			
		||||
            response.body?.close()
 | 
			
		||||
            val csrfToken = soup.selectFirst("meta[name=csrf-token]")?.attr("content")
 | 
			
		||||
 | 
			
		||||
            val initialProps = soup.selectFirst("div[wire:initial-data]")?.attr("wire:initial-data")?.let {
 | 
			
		||||
                json.parseToJsonElement(htmlDecode(it))
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (csrfToken != null && initialProps is JsonObject) {
 | 
			
		||||
                csrf = csrfToken
 | 
			
		||||
                serverMemo = initialProps["serverMemo"]!!.jsonObject
 | 
			
		||||
                fingerprint = initialProps["fingerprint"]!!
 | 
			
		||||
                initialized = true
 | 
			
		||||
            } else {
 | 
			
		||||
                Log.e("GenkanIo", soup.selectFirst("div[wire:initial-data]")?.toString() ?: "null")
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        /**
 | 
			
		||||
         * Builds a request for livewire, augmenting the request with required body fields and headers
 | 
			
		||||
         *
 | 
			
		||||
         * @param req: Request - A request with a json encoded body, which represent the updates sent to server
 | 
			
		||||
         *
 | 
			
		||||
         */
 | 
			
		||||
        private fun livewireRequest(req: Request): Request {
 | 
			
		||||
            val payload = buildJsonObject {
 | 
			
		||||
                put("fingerprint", fingerprint)
 | 
			
		||||
                put("serverMemo", serverMemo)
 | 
			
		||||
                put("updates", json.parseToJsonElement(Buffer().apply { req.body!!.writeTo(this) }.readUtf8()))
 | 
			
		||||
            }.toString().toRequestBody("application/json; charset=utf-8".toMediaTypeOrNull())
 | 
			
		||||
 | 
			
		||||
            return req.newBuilder()
 | 
			
		||||
                .method(req.method, payload)
 | 
			
		||||
                .addHeader("x-csrf-token", csrf)
 | 
			
		||||
                .addHeader("x-livewire", "true")
 | 
			
		||||
                .build()
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        /**
 | 
			
		||||
         * Transforms  json response from livewire server into a response which returns html
 | 
			
		||||
         *
 | 
			
		||||
         * @param response: Response - The response of sending a message to genkan's livewire server
 | 
			
		||||
         *
 | 
			
		||||
         * @return HTML Response - The html embedded within the provided response
 | 
			
		||||
         */
 | 
			
		||||
        private fun livewireResponse(response: Response): Response {
 | 
			
		||||
            if (!response.isSuccessful) return response
 | 
			
		||||
            val body = response.body!!.string()
 | 
			
		||||
            val responseJson = json.parseToJsonElement(body).jsonObject
 | 
			
		||||
 | 
			
		||||
            // response contains state that we need to preserve
 | 
			
		||||
            serverMemo = mergeLeft(serverMemo, responseJson["serverMemo"]!!.jsonObject)
 | 
			
		||||
 | 
			
		||||
            // this seems to be an error  state, so reset everything
 | 
			
		||||
            if (responseJson["effects"]?.jsonObject?.get("html") is JsonNull) {
 | 
			
		||||
                initialized = false
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // Build html response
 | 
			
		||||
            return response.newBuilder()
 | 
			
		||||
                .body(htmlDecode("${responseJson["effects"]?.jsonObject?.get("html")}").toResponseBody("Content-Type: text/html; charset=UTF-8".toMediaTypeOrNull()))
 | 
			
		||||
                .build()
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        override fun intercept(chain: Interceptor.Chain): Response {
 | 
			
		||||
            if (chain.request().url.toString() != serverUrl)
 | 
			
		||||
                return chain.proceed(chain.request())
 | 
			
		||||
 | 
			
		||||
            if (!initialized) initLivewire(chain)
 | 
			
		||||
            return livewireResponse(chain.proceed(livewireRequest(chain.request())))
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    override val client = super.client.newBuilder().addInterceptor(livewireInterceptor).build()
 | 
			
		||||
 | 
			
		||||
    // popular manga
 | 
			
		||||
 | 
			
		||||
    override fun fetchPopularManga(page: Int) = fetchSearchManga(page, "", FilterList(emptyList()))
 | 
			
		||||
@ -83,119 +193,33 @@ open class GenkanIO : ParsedHttpSource() {
 | 
			
		||||
 | 
			
		||||
    // search
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * initializes `livewire` local variable using data from https://genkan.io/manga
 | 
			
		||||
     */
 | 
			
		||||
    private fun initLiveWire(response: Response) {
 | 
			
		||||
        val soup = response.asJsoup()
 | 
			
		||||
        val csrf = soup.selectFirst("meta[name=csrf-token]")?.attr("content")
 | 
			
		||||
 | 
			
		||||
        val initialProps = soup.selectFirst("div[wire:initial-data]")?.attr("wire:initial-data")?.let {
 | 
			
		||||
            JsonParser.parseString(htmlDecode(it))
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (csrf != null && initialProps?.asJsonObject != null) {
 | 
			
		||||
            livewire = LiveWireRPC(csrf, initialProps.asJsonObject)
 | 
			
		||||
        } else {
 | 
			
		||||
            Log.e("GenkanIo", soup.selectFirst("div[wire:initial-data]")?.toString() ?: "null")
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Prepares  a request which'll send a message to livewire server
 | 
			
		||||
     *
 | 
			
		||||
     * @param url: String - Message endpoint
 | 
			
		||||
     * @param updates: JsonElement - JsonElement which describes the actions taken by server
 | 
			
		||||
     *
 | 
			
		||||
     * @return Request
 | 
			
		||||
     */
 | 
			
		||||
    private fun livewireRequest(url: String, updates: JsonElement): Request {
 | 
			
		||||
        // assert(livewire != null)
 | 
			
		||||
        val payload = JsonObject()
 | 
			
		||||
        payload.put("fingerprint" to livewire!!.state.get("fingerprint"))
 | 
			
		||||
        payload.put("serverMemo" to livewire!!.state.get("serverMemo"))
 | 
			
		||||
        payload.put("updates" to updates)
 | 
			
		||||
 | 
			
		||||
        // not sure why this isn't getting added automatically
 | 
			
		||||
        val cookie = client.cookieJar.loadForRequest(url.toHttpUrlOrNull()!!).joinToString("; ") { "${it.name}=${it.value}" }
 | 
			
		||||
        return POST(
 | 
			
		||||
            url,
 | 
			
		||||
            Headers.headersOf("x-csrf-token", livewire!!.csrf, "x-livewire", "true", "cookie", cookie, "cache-control", "no-cache, private"),
 | 
			
		||||
            payload.toString().toRequestBody("application/json; charset=utf-8".toMediaTypeOrNull())
 | 
			
		||||
        )
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Transforms  json response from livewire server into a response which returns html
 | 
			
		||||
     * Also updates `livewire` variable with state returned by livewire server
 | 
			
		||||
     *
 | 
			
		||||
     * @param response: Response - The response of sending a message to genkan's livewire server
 | 
			
		||||
     *
 | 
			
		||||
     * @return HTML Response - The html embedded within the provided response
 | 
			
		||||
     */
 | 
			
		||||
    private fun livewireResponse(response: Response): Response {
 | 
			
		||||
        val body = response.body?.string()
 | 
			
		||||
        val responseJson = JsonParser.parseString(body).asJsonObject
 | 
			
		||||
 | 
			
		||||
        // response contains state that we need to preserve
 | 
			
		||||
        mergeLeft(livewire!!.state.get("serverMemo").asJsonObject, responseJson.get("serverMemo").asJsonObject)
 | 
			
		||||
 | 
			
		||||
        // this seems to be an error  state, so reset everything
 | 
			
		||||
        if (responseJson.get("effects")?.asJsonObject?.get("html")?.isJsonNull == true) {
 | 
			
		||||
            livewire = null
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Build html response
 | 
			
		||||
        return response.newBuilder()
 | 
			
		||||
            .body(htmlDecode("${responseJson.get("effects")?.asJsonObject?.get("html")}").toResponseBody("Content-Type: text/html; charset=UTF-8".toMediaTypeOrNull()))
 | 
			
		||||
            .build()
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Recursively merges j2 onto j1 in place
 | 
			
		||||
     * If j1 and j2 both contain keys whose values aren't both jsonObjects, j2's value overwrites j1's
 | 
			
		||||
     *
 | 
			
		||||
     */
 | 
			
		||||
    private fun mergeLeft(j1: JsonObject, j2: JsonObject) {
 | 
			
		||||
        j2.keys().forEach { k ->
 | 
			
		||||
            if (j1.get(k)?.isJsonObject != true)
 | 
			
		||||
                j1.put(k to j2.get(k))
 | 
			
		||||
            else if (j1.get(k).isJsonObject && j2.get(k).isJsonObject)
 | 
			
		||||
                mergeLeft(j1.get(k).asJsonObject, j2.get(k).asJsonObject)
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
 | 
			
		||||
        fun searchRequest() = client.newCall(searchMangaRequest(page, query, filters)).asObservableSuccess().map(::livewireResponse)
 | 
			
		||||
        return if (livewire == null) {
 | 
			
		||||
            client.newCall(GET("$baseUrl/manga", headers))
 | 
			
		||||
                .asObservableSuccess()
 | 
			
		||||
                .doOnNext(::initLiveWire)
 | 
			
		||||
                .concatWith(Observable.defer(::searchRequest))
 | 
			
		||||
                .reduce { _, x -> x }
 | 
			
		||||
        } else {
 | 
			
		||||
            searchRequest()
 | 
			
		||||
        }.map(::searchMangaParse)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
 | 
			
		||||
        //        assert(livewire != null)
 | 
			
		||||
        val updates = JsonArray()
 | 
			
		||||
        val data = livewire!!.state.get("serverMemo")?.asJsonObject?.get("data")?.asJsonObject!!
 | 
			
		||||
        if (data["readyToLoad"]?.asBoolean == false) {
 | 
			
		||||
            updates.add(JsonParser.parseString("""{"type":"callMethod","payload":{"method":"loadManga","params":[]}}"""))
 | 
			
		||||
        }
 | 
			
		||||
        val isNewQuery = query != data["search"]?.asString
 | 
			
		||||
        if (isNewQuery) {
 | 
			
		||||
            updates.add(JsonParser.parseString("""{"type": "syncInput", "payload": {"name": "search", "value": "$query"}}"""))
 | 
			
		||||
        val data = if (livewireInterceptor.initialized) livewireInterceptor.serverMemo["data"]!!.jsonObject else buildJsonObject {
 | 
			
		||||
            put("readyToLoad", JsonPrimitive(false))
 | 
			
		||||
            put("page", JsonPrimitive(1))
 | 
			
		||||
            put("search", JsonPrimitive(""))
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        val currPage = if (isNewQuery) 1 else data["page"]?.asInt
 | 
			
		||||
        val updates = buildJsonArray {
 | 
			
		||||
            if (data["readyToLoad"]?.jsonPrimitive?.boolean == false) {
 | 
			
		||||
                add(json.parseToJsonElement("""{"type":"callMethod","payload":{"method":"loadManga","params":[]}}"""))
 | 
			
		||||
            }
 | 
			
		||||
            val isNewQuery = query != data["search"]?.jsonPrimitive?.content
 | 
			
		||||
            if (isNewQuery) {
 | 
			
		||||
                add(json.parseToJsonElement("""{"type": "syncInput", "payload": {"name": "search", "value": "$query"}}"""))
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
        for (i in (currPage!! + 1)..page)
 | 
			
		||||
            updates.add(JsonParser.parseString("""{"type":"callMethod","payload":{"method":"nextPage","params":[]}}"""))
 | 
			
		||||
            val currPage = if (isNewQuery) 1 else data["page"]!!.jsonPrimitive.int
 | 
			
		||||
 | 
			
		||||
        return livewireRequest("$baseUrl/livewire/message/manga.list-all-manga", updates)
 | 
			
		||||
            for (i in (currPage + 1)..page)
 | 
			
		||||
                add(json.parseToJsonElement("""{"type":"callMethod","payload":{"method":"nextPage","params":[]}}"""))
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return POST(
 | 
			
		||||
            livewireInterceptor.serverUrl,
 | 
			
		||||
            headers,
 | 
			
		||||
            updates.toString().toRequestBody("application/json; charset=utf-8".toMediaTypeOrNull())
 | 
			
		||||
        )
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    override fun searchMangaFromElement(element: Element): SManga {
 | 
			
		||||
@ -220,15 +244,15 @@ open class GenkanIO : ParsedHttpSource() {
 | 
			
		||||
        return if (manga.status != SManga.LICENSED) {
 | 
			
		||||
            // Returns an observable which emits the list of chapters found on a page,
 | 
			
		||||
            // for every page starting from specified page
 | 
			
		||||
            fun getAllPagesFrom(page: Int): Observable<List<SChapter>> =
 | 
			
		||||
            fun getAllPagesFrom(page: Int, pred: Observable<List<SChapter>> = Observable.just(emptyList())): Observable<List<SChapter>> =
 | 
			
		||||
                client.newCall(chapterListRequest(manga, page))
 | 
			
		||||
                    .asObservableSuccess()
 | 
			
		||||
                    .concatMap { response ->
 | 
			
		||||
                        val cp = chapterPageParse(response)
 | 
			
		||||
                        if (cp.hasnext)
 | 
			
		||||
                            Observable.just(cp.chapters).concatWith(getAllPagesFrom(page + 1))
 | 
			
		||||
                            getAllPagesFrom(page + 1, pred = pred.concatWith(Observable.just(cp.chapters))) // tail call to avoid blowing the stack
 | 
			
		||||
                        else
 | 
			
		||||
                            Observable.just(cp.chapters)
 | 
			
		||||
                            pred.concatWith(Observable.just(cp.chapters))
 | 
			
		||||
                    }
 | 
			
		||||
            getAllPagesFrom(1).reduce(List<SChapter>::plus)
 | 
			
		||||
        } else {
 | 
			
		||||
 | 
			
		||||
@ -1,5 +1,6 @@
 | 
			
		||||
apply plugin: 'com.android.application'
 | 
			
		||||
apply plugin: 'kotlin-android'
 | 
			
		||||
apply plugin: 'kotlinx-serialization'
 | 
			
		||||
 | 
			
		||||
ext {
 | 
			
		||||
    extName = 'CatManga'
 | 
			
		||||
 | 
			
		||||
@ -10,12 +10,20 @@ import eu.kanade.tachiyomi.source.model.SChapter
 | 
			
		||||
import eu.kanade.tachiyomi.source.model.SManga
 | 
			
		||||
import eu.kanade.tachiyomi.source.online.HttpSource
 | 
			
		||||
import eu.kanade.tachiyomi.util.asJsoup
 | 
			
		||||
import kotlinx.serialization.Serializable
 | 
			
		||||
import kotlinx.serialization.json.Json
 | 
			
		||||
import kotlinx.serialization.json.JsonElement
 | 
			
		||||
import kotlinx.serialization.json.JsonPrimitive
 | 
			
		||||
import kotlinx.serialization.json.decodeFromJsonElement
 | 
			
		||||
import kotlinx.serialization.json.jsonObject
 | 
			
		||||
import okhttp3.MediaType.Companion.toMediaType
 | 
			
		||||
import okhttp3.Protocol
 | 
			
		||||
import okhttp3.Response
 | 
			
		||||
import org.json.JSONArray
 | 
			
		||||
import org.json.JSONObject
 | 
			
		||||
import okhttp3.ResponseBody.Companion.toResponseBody
 | 
			
		||||
import org.jsoup.nodes.Document
 | 
			
		||||
import rx.Observable
 | 
			
		||||
import uy.kohesive.injekt.injectLazy
 | 
			
		||||
import java.net.HttpURLConnection
 | 
			
		||||
 | 
			
		||||
class CatManga : HttpSource() {
 | 
			
		||||
 | 
			
		||||
@ -25,206 +33,111 @@ class CatManga : HttpSource() {
 | 
			
		||||
    override val baseUrl = "https://catmanga.org"
 | 
			
		||||
    override val supportsLatest = true
 | 
			
		||||
    override val lang = "en"
 | 
			
		||||
    private val json: Json by injectLazy()
 | 
			
		||||
 | 
			
		||||
    override fun popularMangaRequest(page: Int) = GET(baseUrl)
 | 
			
		||||
    private lateinit var seriesCache: LinkedHashMap<String, JsonSeries> // LinkedHashMap to preserve insertion order
 | 
			
		||||
    private lateinit var latestSeries: List<String>
 | 
			
		||||
    override val client = super.client.newBuilder().addInterceptor { chain ->
 | 
			
		||||
        // An interceptor which facilitates caching the data retrieved from the homepage
 | 
			
		||||
        when (chain.request().url) {
 | 
			
		||||
            doNothingRequest.url -> Response.Builder().body(
 | 
			
		||||
                "".toResponseBody("text/plain; charset=utf-8".toMediaType())
 | 
			
		||||
            ).code(HttpURLConnection.HTTP_NO_CONTENT).message("").protocol(Protocol.HTTP_1_0).request(chain.request()).build()
 | 
			
		||||
            homepageRequest.url -> {
 | 
			
		||||
                /* Homepage embeds a Json Object with information about every single series in the service */
 | 
			
		||||
                val response = chain.proceed(chain.request())
 | 
			
		||||
                val responseBody = response.peekBody(Long.MAX_VALUE).string()
 | 
			
		||||
                val seriesList = response.asJsoup(responseBody).getDataJsonObject()["props"]!!.jsonObject["pageProps"]!!.jsonObject["series"]!!
 | 
			
		||||
                val latests = response.asJsoup(responseBody).getDataJsonObject()["props"]!!.jsonObject["pageProps"]!!.jsonObject["latests"]!!
 | 
			
		||||
                seriesCache = linkedMapOf(
 | 
			
		||||
                    *json.decodeFromJsonElement<List<JsonSeries>>(seriesList).map { it.series_id to it }.toTypedArray()
 | 
			
		||||
                )
 | 
			
		||||
                latestSeries = json.decodeFromJsonElement<List<List<JsonElement>>>(latests).map { json.decodeFromJsonElement<JsonSeries>(it[0]).series_id }
 | 
			
		||||
                response
 | 
			
		||||
            }
 | 
			
		||||
            else -> chain.proceed(chain.request())
 | 
			
		||||
        }
 | 
			
		||||
    }.build()
 | 
			
		||||
 | 
			
		||||
    override fun latestUpdatesRequest(page: Int) = popularMangaRequest(page)
 | 
			
		||||
    private val homepageRequest = GET(baseUrl)
 | 
			
		||||
    private val doNothingRequest = GET("https://dev.null")
 | 
			
		||||
 | 
			
		||||
    override fun searchMangaRequest(page: Int, query: String, filters: FilterList) = popularMangaRequest(page)
 | 
			
		||||
    override fun searchMangaRequest(page: Int, query: String, filters: FilterList) = if (this::seriesCache.isInitialized) doNothingRequest else homepageRequest
 | 
			
		||||
    override fun popularMangaRequest(page: Int) = if (this::seriesCache.isInitialized) doNothingRequest else homepageRequest
 | 
			
		||||
    override fun latestUpdatesRequest(page: Int) = if (this::seriesCache.isInitialized) doNothingRequest else homepageRequest
 | 
			
		||||
    override fun chapterListRequest(manga: SManga) = homepageRequest
 | 
			
		||||
 | 
			
		||||
    private fun idOf(manga: SManga) = manga.url.substringAfterLast("/")
 | 
			
		||||
    override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
 | 
			
		||||
        return client.newCall(popularMangaRequest(page))
 | 
			
		||||
        return client.newCall(searchMangaRequest(page, query, filters))
 | 
			
		||||
            .asObservableSuccess()
 | 
			
		||||
            .map { response ->
 | 
			
		||||
                val mangas = if (query.startsWith(SERIES_ID_SEARCH_PREFIX)) {
 | 
			
		||||
                    getFilteredSeriesList(
 | 
			
		||||
                        response.asJsoup().getDataJsonObject(),
 | 
			
		||||
                        idFilter = query.removePrefix(SERIES_ID_SEARCH_PREFIX)
 | 
			
		||||
                    )
 | 
			
		||||
                } else {
 | 
			
		||||
                    getFilteredSeriesList(
 | 
			
		||||
                        response.asJsoup().getDataJsonObject(),
 | 
			
		||||
                        titleFilter = query
 | 
			
		||||
                    )
 | 
			
		||||
                }
 | 
			
		||||
                MangasPage(mangas, false)
 | 
			
		||||
            .map {
 | 
			
		||||
                val manga = seriesCache.asSequence().map { it.value }.filter {
 | 
			
		||||
                    if (query.startsWith(SERIES_ID_SEARCH_PREFIX)) {
 | 
			
		||||
                        return@filter it.series_id.contains(query.removePrefix(SERIES_ID_SEARCH_PREFIX), true)
 | 
			
		||||
                    }
 | 
			
		||||
                    sequence { yieldAll(it.alt_titles); yield(it.title) }
 | 
			
		||||
                        .any { title -> title.contains(query, true) }
 | 
			
		||||
                }.map { it.toSManga() }.toList()
 | 
			
		||||
 | 
			
		||||
                MangasPage(manga, false)
 | 
			
		||||
            }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
 | 
			
		||||
        return client.newCall(popularMangaRequest(0))
 | 
			
		||||
            .asObservableSuccess()
 | 
			
		||||
            .map { response ->
 | 
			
		||||
                manga.also {
 | 
			
		||||
                    getSeriesObject(response.asJsoup().getDataJsonObject(), it)?.let { series ->
 | 
			
		||||
                        it.title = series.getString("title")
 | 
			
		||||
                        it.author = series.getJSONArray("authors").joinToString(", ")
 | 
			
		||||
                        it.description = series.getString("description")
 | 
			
		||||
                        it.genre = series.getJSONArray("genres").joinToString(", ")
 | 
			
		||||
                        it.status = when (series.getString("status")) {
 | 
			
		||||
                            "ongoing" -> SManga.ONGOING
 | 
			
		||||
                            "completed" -> SManga.COMPLETED
 | 
			
		||||
                            else -> SManga.UNKNOWN
 | 
			
		||||
                        }
 | 
			
		||||
                        it.thumbnail_url = series.getJSONObject("cover_art").getString("source")
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
    }
 | 
			
		||||
    override fun fetchMangaDetails(manga: SManga): Observable<SManga> = client.newCall(homepageRequest)
 | 
			
		||||
        .asObservableSuccess()
 | 
			
		||||
        .map { seriesCache[idOf(manga)]?.toSManga() ?: manga }
 | 
			
		||||
 | 
			
		||||
    override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
 | 
			
		||||
        val seriesId = manga.url.substringAfter("/series/")
 | 
			
		||||
        return client.newCall(popularMangaRequest(0))
 | 
			
		||||
        return client.newCall(chapterListRequest(manga))
 | 
			
		||||
            .asObservableSuccess()
 | 
			
		||||
            .map { response ->
 | 
			
		||||
                var returnChapter = emptyList<SChapter>()
 | 
			
		||||
            .map {
 | 
			
		||||
                val seriesPrefs = application.getSharedPreferences("source_${id}_time_found:$seriesId", 0)
 | 
			
		||||
                val seriesPrefsEditor = seriesPrefs.edit()
 | 
			
		||||
                val cl = seriesCache[idOf(manga)]!!.chapters.asReversed().map {
 | 
			
		||||
                    val title = it.title ?: ""
 | 
			
		||||
                    val groups = it.groups.joinToString(", ")
 | 
			
		||||
                    val number = it.number.content
 | 
			
		||||
                    val displayNumber = it.display_number ?: number
 | 
			
		||||
                    SChapter.create().apply {
 | 
			
		||||
                        url = "${manga.url}/$number"
 | 
			
		||||
                        chapter_number = number.toFloat()
 | 
			
		||||
                        name = "Chapter $displayNumber" + if (title.isNotBlank()) " - $title" else ""
 | 
			
		||||
                        scanlator = groups
 | 
			
		||||
 | 
			
		||||
                val series = getSeriesObject(response.asJsoup().getDataJsonObject(), manga)
 | 
			
		||||
                if (series != null) {
 | 
			
		||||
                    val seriesPrefs = application.getSharedPreferences("source_${id}_time_found:$seriesId", 0)
 | 
			
		||||
                    val seriesPrefsEditor = seriesPrefs.edit()
 | 
			
		||||
 | 
			
		||||
                    val chapters = series.getJSONArray("chapters")
 | 
			
		||||
                    returnChapter = (0 until chapters.length()).reversed().map { i ->
 | 
			
		||||
                        val chapter = chapters.getJSONObject(i)
 | 
			
		||||
                        val title = chapter.optString("title")
 | 
			
		||||
                        val groups = chapter.getJSONArray("groups").joinToString()
 | 
			
		||||
                        val number = chapter.getString("number")
 | 
			
		||||
                        val displayNumber = chapter.optString("display_number", number)
 | 
			
		||||
                        SChapter.create().apply {
 | 
			
		||||
                            url = "${manga.url}/$number"
 | 
			
		||||
                            chapter_number = number.toFloat()
 | 
			
		||||
                            name = "Chapter $displayNumber" + if (title.isNotBlank()) " - $title" else ""
 | 
			
		||||
                            scanlator = groups
 | 
			
		||||
 | 
			
		||||
                            // Save current time when a chapter is found for the first time, and reuse it on future
 | 
			
		||||
                            // checks to prevent manga entry without any new chapter bumped to the top of
 | 
			
		||||
                            // "Latest chapter" list when the library is updated.
 | 
			
		||||
                            val currentTimeMillis = System.currentTimeMillis()
 | 
			
		||||
                            if (!seriesPrefs.contains(number)) {
 | 
			
		||||
                                seriesPrefsEditor.putLong(number, currentTimeMillis)
 | 
			
		||||
                            }
 | 
			
		||||
                            date_upload = seriesPrefs.getLong(number, currentTimeMillis)
 | 
			
		||||
                        // Save current time when a chapter is found for the first time, and reuse it on future checks to
 | 
			
		||||
                        // prevent manga entry without any new chapter bumped to the top of "Latest chapter" list
 | 
			
		||||
                        // when the library is updated.
 | 
			
		||||
                        val currentTimeMillis = System.currentTimeMillis()
 | 
			
		||||
                        if (!seriesPrefs.contains(number)) {
 | 
			
		||||
                            seriesPrefsEditor.putLong(number, currentTimeMillis)
 | 
			
		||||
                        }
 | 
			
		||||
                        date_upload = seriesPrefs.getLong(number, currentTimeMillis)
 | 
			
		||||
                    }
 | 
			
		||||
                    seriesPrefsEditor.apply()
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                returnChapter
 | 
			
		||||
                seriesPrefsEditor.apply()
 | 
			
		||||
                cl
 | 
			
		||||
            }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    override fun popularMangaParse(response: Response): MangasPage {
 | 
			
		||||
        val mangas = getFilteredSeriesList(response.asJsoup().getDataJsonObject())
 | 
			
		||||
        return MangasPage(mangas, false)
 | 
			
		||||
    }
 | 
			
		||||
    override fun popularMangaParse(response: Response) = MangasPage(seriesCache.map { it.value.toSManga() }, false)
 | 
			
		||||
 | 
			
		||||
    override fun latestUpdatesParse(response: Response): MangasPage {
 | 
			
		||||
        val latests = response.asJsoup().getDataJsonObject()
 | 
			
		||||
            .getJSONObject("props")
 | 
			
		||||
            .getJSONObject("pageProps")
 | 
			
		||||
            .getJSONArray("latests")
 | 
			
		||||
        val mangas = (0 until latests.length()).map { i ->
 | 
			
		||||
            val manga = latests.getJSONArray(i).getJSONObject(0)
 | 
			
		||||
            SManga.create().apply {
 | 
			
		||||
                url = "/series/${manga.getString("series_id")}"
 | 
			
		||||
                title = manga.getString("title")
 | 
			
		||||
                thumbnail_url = manga.getJSONObject("cover_art").getString("source")
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        return MangasPage(mangas, false)
 | 
			
		||||
    }
 | 
			
		||||
    override fun latestUpdatesParse(response: Response) = MangasPage(
 | 
			
		||||
        latestSeries.map { seriesCache[it]!!.toSManga() },
 | 
			
		||||
        false
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    override fun pageListParse(response: Response): List<Page> {
 | 
			
		||||
        val pages = response.asJsoup().getDataJsonObject()
 | 
			
		||||
            .getJSONObject("props")
 | 
			
		||||
            .getJSONObject("pageProps")
 | 
			
		||||
            .getJSONArray("pages")
 | 
			
		||||
        return (0 until pages.length()).map { i -> Page(i, "", pages.getString(i)) }
 | 
			
		||||
        return json.decodeFromJsonElement<List<String>>(response.asJsoup().getDataJsonObject()["props"]!!.jsonObject["pageProps"]!!.jsonObject["pages"]!!).mapIndexed { index, s ->
 | 
			
		||||
            Page(index, "", s)
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Returns json object of site data
 | 
			
		||||
     */
 | 
			
		||||
    private fun Document.getDataJsonObject(): JSONObject {
 | 
			
		||||
        return JSONObject(getElementById("__NEXT_DATA__").html())
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Returns JSONObject for [manga] from site data
 | 
			
		||||
     */
 | 
			
		||||
    private fun getSeriesObject(jsonObject: JSONObject, manga: SManga): JSONObject? {
 | 
			
		||||
        val seriesId = manga.url.substringAfter("/series/")
 | 
			
		||||
        val seriesArray = jsonObject
 | 
			
		||||
            .getJSONObject("props")
 | 
			
		||||
            .getJSONObject("pageProps")
 | 
			
		||||
            .getJSONArray("series")
 | 
			
		||||
        val seriesIndex = (0 until seriesArray.length()).firstOrNull { i ->
 | 
			
		||||
            seriesArray.getJSONObject(i).optString("series_id").takeIf { it.isNotBlank() } == seriesId
 | 
			
		||||
        }
 | 
			
		||||
        return if (seriesIndex != null) seriesArray.getJSONObject(seriesIndex) else null
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * @return filtered series from home page
 | 
			
		||||
     * @param data json data from [getDataJsonObject]
 | 
			
		||||
     * @param titleFilter will be used to check against title and alt_titles, null to disable filter
 | 
			
		||||
     * @param idFilter will be used to check against id, null to disable filter, only used when [titleFilter] is unset
 | 
			
		||||
     */
 | 
			
		||||
    private fun getFilteredSeriesList(
 | 
			
		||||
        data: JSONObject,
 | 
			
		||||
        titleFilter: String? = null,
 | 
			
		||||
        idFilter: String? = null
 | 
			
		||||
    ): List<SManga> {
 | 
			
		||||
        val series = data.getJSONObject("props").getJSONObject("pageProps").getJSONArray("series")
 | 
			
		||||
        val mangas = mutableListOf<SManga>()
 | 
			
		||||
        for (i in 0 until series.length()) {
 | 
			
		||||
            val manga = series.getJSONObject(i)
 | 
			
		||||
            val mangaId = manga.getString("series_id")
 | 
			
		||||
            val mangaTitle = manga.getString("title")
 | 
			
		||||
            val mangaAltTitles = manga.getJSONArray("alt_titles")
 | 
			
		||||
 | 
			
		||||
            // Filtering
 | 
			
		||||
            if (titleFilter != null) {
 | 
			
		||||
                if (!(mangaTitle.contains(titleFilter, true) || mangaAltTitles.contains(titleFilter))) {
 | 
			
		||||
                    continue
 | 
			
		||||
                }
 | 
			
		||||
            } else if (idFilter != null) {
 | 
			
		||||
                if (!mangaId.contains(idFilter, true)) {
 | 
			
		||||
                    continue
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            mangas += SManga.create().apply {
 | 
			
		||||
                url = "/series/$mangaId"
 | 
			
		||||
                title = mangaTitle
 | 
			
		||||
                thumbnail_url = manga.getJSONObject("cover_art").getString("source")
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        return mangas.toList()
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private fun JSONArray.joinToString(separator: String = ", "): String {
 | 
			
		||||
        val stringBuilder = StringBuilder()
 | 
			
		||||
        for (i in 0 until length()) {
 | 
			
		||||
            if (i > 0) stringBuilder.append(separator)
 | 
			
		||||
            val item = getString(i)
 | 
			
		||||
            stringBuilder.append(item)
 | 
			
		||||
        }
 | 
			
		||||
        return stringBuilder.toString()
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * For string objects
 | 
			
		||||
     */
 | 
			
		||||
    private operator fun JSONArray.contains(other: CharSequence): Boolean {
 | 
			
		||||
        for (i in 0 until length()) {
 | 
			
		||||
            if (optString(i, "").contains(other, true)) {
 | 
			
		||||
                return true
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        return false
 | 
			
		||||
    }
 | 
			
		||||
    private fun Document.getDataJsonObject() = json.parseToJsonElement(getElementById("__NEXT_DATA__").html()).jsonObject
 | 
			
		||||
 | 
			
		||||
    override fun mangaDetailsParse(response: Response): SManga {
 | 
			
		||||
        throw UnsupportedOperationException("Not used.")
 | 
			
		||||
@ -246,3 +159,28 @@ class CatManga : HttpSource() {
 | 
			
		||||
        const val SERIES_ID_SEARCH_PREFIX = "series_id:"
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@Serializable
 | 
			
		||||
private data class JsonImage(val source: String, val width: Int, val height: Int)
 | 
			
		||||
 | 
			
		||||
@Serializable
 | 
			
		||||
private data class JsonChapter(val title: String? = null, val groups: List<String>, val number: JsonPrimitive, val display_number: String? = null, val volume: Int? = null)
 | 
			
		||||
 | 
			
		||||
@Serializable
 | 
			
		||||
private data class JsonSeries(val alt_titles: List<String>, val authors: List<String>, val genres: List<String>, val chapters: List<JsonChapter>, val title: String, val series_id: String, val description: String, val status: String, val cover_art: JsonImage, val all_covers: List<JsonImage>) {
 | 
			
		||||
    fun toSManga() = this.let { jsonSeries ->
 | 
			
		||||
        SManga.create().apply {
 | 
			
		||||
            url = "/series/${jsonSeries.series_id}"
 | 
			
		||||
            title = jsonSeries.title
 | 
			
		||||
            thumbnail_url = jsonSeries.cover_art.source
 | 
			
		||||
            author = jsonSeries.authors.joinToString(", ")
 | 
			
		||||
            description = jsonSeries.description
 | 
			
		||||
            genre = jsonSeries.genres.joinToString(", ")
 | 
			
		||||
            status = when (jsonSeries.status) {
 | 
			
		||||
                "ongoing" -> SManga.ONGOING
 | 
			
		||||
                "completed" -> SManga.COMPLETED
 | 
			
		||||
                else -> SManga.UNKNOWN
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -1,11 +1,12 @@
 | 
			
		||||
apply plugin: 'com.android.application'
 | 
			
		||||
apply plugin: 'kotlin-android'
 | 
			
		||||
apply plugin: 'kotlinx-serialization'
 | 
			
		||||
 | 
			
		||||
ext {
 | 
			
		||||
    extName = 'Remanga'
 | 
			
		||||
    pkgNameSuffix = 'ru.remanga'
 | 
			
		||||
    extClass = '.Remanga'
 | 
			
		||||
    extVersionCode = 28
 | 
			
		||||
    extVersionCode = 29
 | 
			
		||||
    libVersion = '1.2'
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -17,10 +17,6 @@ import android.content.SharedPreferences
 | 
			
		||||
import android.os.Build
 | 
			
		||||
import android.text.InputType
 | 
			
		||||
import android.widget.Toast
 | 
			
		||||
import com.github.salomonbrys.kotson.fromJson
 | 
			
		||||
import com.google.gson.Gson
 | 
			
		||||
import com.google.gson.JsonObject
 | 
			
		||||
import com.google.gson.JsonSyntaxException
 | 
			
		||||
import eu.kanade.tachiyomi.lib.dataimage.DataImageInterceptor
 | 
			
		||||
import eu.kanade.tachiyomi.network.GET
 | 
			
		||||
import eu.kanade.tachiyomi.network.POST
 | 
			
		||||
@ -34,6 +30,13 @@ 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.SerializationException
 | 
			
		||||
import kotlinx.serialization.decodeFromString
 | 
			
		||||
import kotlinx.serialization.json.Json
 | 
			
		||||
import kotlinx.serialization.json.JsonObject
 | 
			
		||||
import kotlinx.serialization.json.buildJsonObject
 | 
			
		||||
import kotlinx.serialization.json.decodeFromJsonElement
 | 
			
		||||
import kotlinx.serialization.json.put
 | 
			
		||||
import okhttp3.Headers
 | 
			
		||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
 | 
			
		||||
import okhttp3.Interceptor
 | 
			
		||||
@ -42,17 +45,16 @@ import okhttp3.OkHttpClient
 | 
			
		||||
import okhttp3.Request
 | 
			
		||||
import okhttp3.RequestBody.Companion.toRequestBody
 | 
			
		||||
import okhttp3.Response
 | 
			
		||||
import org.json.JSONObject
 | 
			
		||||
import org.jsoup.Jsoup
 | 
			
		||||
import rx.Observable
 | 
			
		||||
import uy.kohesive.injekt.Injekt
 | 
			
		||||
import uy.kohesive.injekt.api.get
 | 
			
		||||
import uy.kohesive.injekt.injectLazy
 | 
			
		||||
import java.text.SimpleDateFormat
 | 
			
		||||
import java.util.Date
 | 
			
		||||
import java.util.Locale
 | 
			
		||||
import kotlin.math.absoluteValue
 | 
			
		||||
import kotlin.random.Random
 | 
			
		||||
 | 
			
		||||
class Remanga : ConfigurableSource, HttpSource() {
 | 
			
		||||
    override val name = "Remanga"
 | 
			
		||||
 | 
			
		||||
@ -100,15 +102,16 @@ class Remanga : ConfigurableSource, HttpSource() {
 | 
			
		||||
    private var branches = mutableMapOf<String, List<BranchesDto>>()
 | 
			
		||||
 | 
			
		||||
    private fun login(chain: Interceptor.Chain, username: String, password: String): String {
 | 
			
		||||
        val jsonObject = JSONObject()
 | 
			
		||||
        jsonObject.put("user", username)
 | 
			
		||||
        jsonObject.put("password", password)
 | 
			
		||||
        val jsonObject = buildJsonObject {
 | 
			
		||||
            put("user", username)
 | 
			
		||||
            put("password", password)
 | 
			
		||||
        }
 | 
			
		||||
        val body = jsonObject.toString().toRequestBody(MEDIA_TYPE)
 | 
			
		||||
        val response = chain.proceed(POST("$baseUrl/api/users/login/", headers, body))
 | 
			
		||||
        if (response.code >= 400) {
 | 
			
		||||
            throw Exception("Failed to login")
 | 
			
		||||
        }
 | 
			
		||||
        val user = gson.fromJson<SeriesWrapperDto<UserDto>>(response.body?.charStream()!!)
 | 
			
		||||
        val user = json.decodeFromString<SeriesWrapperDto<UserDto>>(response.body!!.string())
 | 
			
		||||
        return user.content.access_token
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -121,7 +124,7 @@ class Remanga : ConfigurableSource, HttpSource() {
 | 
			
		||||
    override fun latestUpdatesParse(response: Response): MangasPage = searchMangaParse(response)
 | 
			
		||||
 | 
			
		||||
    override fun searchMangaParse(response: Response): MangasPage {
 | 
			
		||||
        val page = gson.fromJson<PageWrapperDto<LibraryDto>>(response.body?.charStream()!!)
 | 
			
		||||
        val page = json.decodeFromString<PageWrapperDto<LibraryDto>>(response.body!!.string())
 | 
			
		||||
        val mangas = page.content.map {
 | 
			
		||||
            it.toSManga()
 | 
			
		||||
        }
 | 
			
		||||
@ -159,7 +162,7 @@ class Remanga : ConfigurableSource, HttpSource() {
 | 
			
		||||
            when (filter) {
 | 
			
		||||
                is OrderBy -> {
 | 
			
		||||
                    val ord = arrayOf("id", "chapter_date", "rating", "votes", "views", "count_chapters", "random")[filter.state!!.index]
 | 
			
		||||
                    url.addQueryParameter("ordering", if (filter.state!!.ascending) "$ord" else "-$ord")
 | 
			
		||||
                    url.addQueryParameter("ordering", if (filter.state!!.ascending) ord else "-$ord")
 | 
			
		||||
                }
 | 
			
		||||
                is CategoryList -> filter.state.forEach { category ->
 | 
			
		||||
                    if (category.state != Filter.TriState.STATE_IGNORE) {
 | 
			
		||||
@ -273,7 +276,7 @@ class Remanga : ConfigurableSource, HttpSource() {
 | 
			
		||||
        return GET(baseUrl.replace("api.", "") + "/manga/" + manga.url.substringAfter("/api/titles/", "/"), headers)
 | 
			
		||||
    }
 | 
			
		||||
    override fun mangaDetailsParse(response: Response): SManga {
 | 
			
		||||
        val series = gson.fromJson<SeriesWrapperDto<MangaDetDto>>(response.body?.charStream()!!)
 | 
			
		||||
        val series = json.decodeFromString<SeriesWrapperDto<MangaDetDto>>(response.body!!.string())
 | 
			
		||||
        branches[series.content.en_name] = series.content.branches
 | 
			
		||||
        return series.content.toSManga()
 | 
			
		||||
    }
 | 
			
		||||
@ -281,10 +284,11 @@ class Remanga : ConfigurableSource, HttpSource() {
 | 
			
		||||
    private fun mangaBranches(manga: SManga): List<BranchesDto> {
 | 
			
		||||
        val responseString = client.newCall(GET("$baseUrl/${manga.url}")).execute().body?.string() ?: return emptyList()
 | 
			
		||||
        // manga requiring login return "content" as a JsonArray instead of the JsonObject we expect
 | 
			
		||||
        return if (gson.fromJson<JsonObject>(responseString)["content"].isJsonObject) {
 | 
			
		||||
            val series = gson.fromJson<SeriesWrapperDto<MangaDetDto>>(responseString)
 | 
			
		||||
            branches[series.content.en_name] = series.content.branches
 | 
			
		||||
            series.content.branches
 | 
			
		||||
        val content = json.decodeFromString<JsonObject>(responseString)["content"]
 | 
			
		||||
        return if (content is JsonObject) {
 | 
			
		||||
            val series = json.decodeFromJsonElement<MangaDetDto>(content)
 | 
			
		||||
            branches[series.en_name] = series.branches
 | 
			
		||||
            series.branches
 | 
			
		||||
        } else {
 | 
			
		||||
            emptyList()
 | 
			
		||||
        }
 | 
			
		||||
@ -325,8 +329,8 @@ class Remanga : ConfigurableSource, HttpSource() {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    override fun chapterListParse(response: Response): List<SChapter> {
 | 
			
		||||
        val chapters = gson.fromJson<PageWrapperDto<BookDto>>(response.body?.charStream()!!)
 | 
			
		||||
        return chapters.content.filter { !it.is_paid or it.is_bought }.map { chapter ->
 | 
			
		||||
        val chapters = json.decodeFromString<SeriesWrapperDto<List<BookDto>>>(response.body!!.string())
 | 
			
		||||
        return chapters.content.filter { !it.is_paid or (it.is_bought == true) }.map { chapter ->
 | 
			
		||||
            SChapter.create().apply {
 | 
			
		||||
                chapter_number = chapter.chapter.split(".").take(2).joinToString(".").toFloat()
 | 
			
		||||
                name = chapterName(chapter)
 | 
			
		||||
@ -343,12 +347,12 @@ class Remanga : ConfigurableSource, HttpSource() {
 | 
			
		||||
    override fun pageListParse(response: Response): List<Page> {
 | 
			
		||||
        val body = response.body?.string()!!
 | 
			
		||||
        return try {
 | 
			
		||||
            val page = gson.fromJson<SeriesWrapperDto<PageDto>>(body)
 | 
			
		||||
            val page = json.decodeFromString<SeriesWrapperDto<PageDto>>(body)
 | 
			
		||||
            page.content.pages.filter { it.height > 1 }.map {
 | 
			
		||||
                Page(it.page, "", it.link)
 | 
			
		||||
            }
 | 
			
		||||
        } catch (e: JsonSyntaxException) {
 | 
			
		||||
            val page = gson.fromJson<SeriesWrapperDto<PaidPageDto>>(body)
 | 
			
		||||
        } catch (e: SerializationException) {
 | 
			
		||||
            val page = json.decodeFromString<SeriesWrapperDto<PaidPageDto>>(body)
 | 
			
		||||
            val result = mutableListOf<Page>()
 | 
			
		||||
            page.content.pages.forEach {
 | 
			
		||||
                it.filter { page -> page.height > 10 }.forEach { page ->
 | 
			
		||||
@ -596,7 +600,7 @@ class Remanga : ConfigurableSource, HttpSource() {
 | 
			
		||||
            dialogTitle = title
 | 
			
		||||
 | 
			
		||||
            if (isPassword) {
 | 
			
		||||
                if (!value.isNullOrBlank()) { summary = "*****" }
 | 
			
		||||
                if (value.isNotBlank()) { summary = "*****" }
 | 
			
		||||
                setOnBindEditTextListener {
 | 
			
		||||
                    it.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
 | 
			
		||||
                }
 | 
			
		||||
@ -617,7 +621,7 @@ class Remanga : ConfigurableSource, HttpSource() {
 | 
			
		||||
    private fun getPrefUsername(): String = preferences.getString(USERNAME_TITLE, USERNAME_DEFAULT)!!
 | 
			
		||||
    private fun getPrefPassword(): String = preferences.getString(PASSWORD_TITLE, PASSWORD_DEFAULT)!!
 | 
			
		||||
 | 
			
		||||
    private val gson by lazy { Gson() }
 | 
			
		||||
    private val json: Json by injectLazy()
 | 
			
		||||
    private val username by lazy { getPrefUsername() }
 | 
			
		||||
    private val password by lazy { getPrefPassword() }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -1,19 +1,25 @@
 | 
			
		||||
import kotlinx.serialization.Serializable
 | 
			
		||||
 | 
			
		||||
@Serializable
 | 
			
		||||
data class GenresDto(
 | 
			
		||||
    val id: Int,
 | 
			
		||||
    val name: String
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
@Serializable
 | 
			
		||||
data class BranchesDto(
 | 
			
		||||
    val id: Long,
 | 
			
		||||
    val count_chapters: Int
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
@Serializable
 | 
			
		||||
data class ImgDto(
 | 
			
		||||
    val high: String,
 | 
			
		||||
    val mid: String,
 | 
			
		||||
    val low: String
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
@Serializable
 | 
			
		||||
data class LibraryDto(
 | 
			
		||||
    val id: Long,
 | 
			
		||||
    val en_name: String,
 | 
			
		||||
@ -24,11 +30,13 @@ data class LibraryDto(
 | 
			
		||||
    val img: ImgDto
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
@Serializable
 | 
			
		||||
data class StatusDto(
 | 
			
		||||
    val id: Int,
 | 
			
		||||
    val name: String
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
@Serializable
 | 
			
		||||
data class MangaDetDto(
 | 
			
		||||
    val id: Long,
 | 
			
		||||
    val en_name: String,
 | 
			
		||||
@ -47,30 +55,34 @@ data class MangaDetDto(
 | 
			
		||||
    val age_limit: Int
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
@Serializable
 | 
			
		||||
data class PropsDto(
 | 
			
		||||
    val total_items: Int,
 | 
			
		||||
    val total_pages: Int,
 | 
			
		||||
    val page: Int
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
@Serializable
 | 
			
		||||
data class PageWrapperDto<T>(
 | 
			
		||||
    val msg: String,
 | 
			
		||||
    val content: List<T>,
 | 
			
		||||
    val props: PropsDto,
 | 
			
		||||
    val last: Boolean
 | 
			
		||||
//    val last: Boolean
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
@Serializable
 | 
			
		||||
data class SeriesWrapperDto<T>(
 | 
			
		||||
    val msg: String,
 | 
			
		||||
    val content: T,
 | 
			
		||||
    val props: PropsDto
 | 
			
		||||
//    val props: PropsDto
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
@Serializable
 | 
			
		||||
data class PublisherDto(
 | 
			
		||||
    val name: String,
 | 
			
		||||
    val dir: String
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
@Serializable
 | 
			
		||||
data class BookDto(
 | 
			
		||||
    val id: Long,
 | 
			
		||||
    val tome: Int,
 | 
			
		||||
@ -78,10 +90,11 @@ data class BookDto(
 | 
			
		||||
    val name: String,
 | 
			
		||||
    val upload_date: String,
 | 
			
		||||
    val is_paid: Boolean,
 | 
			
		||||
    val is_bought: Boolean,
 | 
			
		||||
    val is_bought: Boolean?,
 | 
			
		||||
    val publishers: List<PublisherDto>
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
@Serializable
 | 
			
		||||
data class PagesDto(
 | 
			
		||||
    val id: Int,
 | 
			
		||||
    val height: Int,
 | 
			
		||||
@ -90,14 +103,17 @@ data class PagesDto(
 | 
			
		||||
    val count_comments: Int
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
@Serializable
 | 
			
		||||
data class PageDto(
 | 
			
		||||
    val pages: List<PagesDto>
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
@Serializable
 | 
			
		||||
data class UserDto(
 | 
			
		||||
    val access_token: String
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
@Serializable
 | 
			
		||||
data class PaidPagesDto(
 | 
			
		||||
    val id: Long,
 | 
			
		||||
    val link: String,
 | 
			
		||||
@ -105,6 +121,7 @@ data class PaidPagesDto(
 | 
			
		||||
    val page: Int
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
@Serializable
 | 
			
		||||
data class PaidPageDto(
 | 
			
		||||
    val pages: List<List<PaidPagesDto>>
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user