diff --git a/src/pt/mediocretoons/build.gradle b/src/pt/mediocretoons/build.gradle index bc30e188e..cbd47d647 100644 --- a/src/pt/mediocretoons/build.gradle +++ b/src/pt/mediocretoons/build.gradle @@ -1,9 +1,8 @@ ext { extName = 'Mediocre Toons' extClass = '.MediocreToons' - themePkg = 'greenshit' - baseUrl = 'https://mediocretoons.com' - overrideVersionCode = 0 + baseUrl = 'https://mediocretoons.site' + extVersionCode = 7 isNsfw = true } diff --git a/src/pt/mediocretoons/src/eu/kanade/tachiyomi/extension/pt/mediocretoons/MediocreToons.kt b/src/pt/mediocretoons/src/eu/kanade/tachiyomi/extension/pt/mediocretoons/MediocreToons.kt index 584047e5d..2c76244dd 100644 --- a/src/pt/mediocretoons/src/eu/kanade/tachiyomi/extension/pt/mediocretoons/MediocreToons.kt +++ b/src/pt/mediocretoons/src/eu/kanade/tachiyomi/extension/pt/mediocretoons/MediocreToons.kt @@ -1,19 +1,276 @@ package eu.kanade.tachiyomi.extension.pt.mediocretoons -import eu.kanade.tachiyomi.multisrc.greenshit.GreenShit +import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.interceptor.rateLimit +import eu.kanade.tachiyomi.source.model.Filter +import eu.kanade.tachiyomi.source.model.FilterList +import eu.kanade.tachiyomi.source.model.MangasPage +import eu.kanade.tachiyomi.source.model.Page +import eu.kanade.tachiyomi.source.model.SChapter +import eu.kanade.tachiyomi.source.model.SManga +import eu.kanade.tachiyomi.source.online.HttpSource +import keiyoushi.utils.parseAs +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.Request +import okhttp3.Response +import java.text.Normalizer -class MediocreToons : GreenShit( - "Mediocre Toons", - "https://mediocretoons.com", - "pt-BR", - scanId = 2, -) { - override val targetAudience = TargetAudience.Shoujo +class MediocreToons : HttpSource() { - override val contentOrigin = ContentOrigin.Mobile + override val name = "Mediocre Toons" - override val client = super.client.newBuilder() + override val baseUrl = "https://mediocretoons.site" + + override val lang = "pt-BR" + + override val supportsLatest = true + + private val apiUrl = "https://api.mediocretoons.site" + + private val scanId: Long = 2 + + override val client = network.cloudflareClient.newBuilder() .rateLimit(2) .build() + + override fun headersBuilder() = super.headersBuilder() + .set("scan-id", scanId.toString()) + .set("x-app-key", "toons-mediocre-app") + + // ============================== Popular ================================ + override fun popularMangaRequest(page: Int): Request { + val url = "$apiUrl/obras".toHttpUrl().newBuilder() + .addQueryParameter("ordenarPor", "views_hoje") + .addQueryParameter("limite", "20") + .addQueryParameter("pagina", page.toString()) + .build() + return GET(url, headers) + } + + override fun popularMangaParse(response: Response): MangasPage { + val dto = response.parseAs>>() + val mangas = dto.data.map { it.toSManga() } + val hasNext = dto.pagination?.hasNextPage ?: false + return MangasPage(mangas, hasNextPage = hasNext) + } + + // ============================= Latest Updates ========================== + override fun latestUpdatesRequest(page: Int): Request { + val url = "$apiUrl/obras/novos".toHttpUrl().newBuilder() + .addQueryParameter("pagina", page.toString()) + .addQueryParameter("limite", "24") + .addQueryParameter("formato", "4") + .build() + return GET(url, headers) + } + + override fun latestUpdatesParse(response: Response): MangasPage { + val dto = response.parseAs>>() + val mangas = dto.data.map { it.toSManga() } + val hasNext = dto.pagination?.hasNextPage ?: false + return MangasPage(mangas, hasNextPage = hasNext) + } + + // =============================== Search ================================ + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + val url = "$apiUrl/obras".toHttpUrl().newBuilder() + .addQueryParameter("limite", "20") + .addQueryParameter("pagina", page.toString()) + .addQueryParameter("temCapitulo", "true") + + if (query.isNotEmpty()) { + url.addQueryParameter("string", query) + } + + filters.forEach { filter -> + when (filter) { + is FormatoFilter -> { + if (filter.selected.isNotEmpty()) { + url.addQueryParameter("formato", filter.selected) + } + } + is StatusFilter -> { + if (filter.selected.isNotEmpty()) { + url.addQueryParameter("status", filter.selected) + } + } + is TagsFilter -> { + filter.state + .filter { it.state } + .forEach { url.addQueryParameter("tags[]", it.value) } + } + else -> {} + } + } + + return GET(url.build(), headers) + } + + override fun searchMangaParse(response: Response): MangasPage { + val dto = response.parseAs>>() + val mangas = dto.data.map { it.toSManga() } + val hasNext = dto.pagination?.hasNextPage ?: false + return MangasPage(mangas, hasNextPage = hasNext) + } + + // ============================== Filters ================================ + override fun getFilterList() = FilterList( + FormatoFilter(), + StatusFilter(), + TagsFilter(), + ) + + private class FormatoFilter : UriSelectFilter( + "Formato", + arrayOf( + Pair("Todos", ""), + Pair("Novel", "3"), + Pair("Shoujo", "4"), + Pair("Comic", "5"), + Pair("Yaoi", "8"), + Pair("Yuri", "9"), + Pair("Hentai", "10"), + ), + ) + + private class StatusFilter : UriSelectFilter( + "Status", + arrayOf( + Pair("Todos", ""), + Pair("Ativo", "1"), + Pair("Em Andamento", "2"), + Pair("Cancelada", "3"), + Pair("Concluído", "4"), + Pair("Hiato", "6"), + ), + ) + + private class TagsFilter : Filter.Group( + "Tags", + listOf( + TagCheckBox("Ação", "2"), + TagCheckBox("Aventura", "3"), + TagCheckBox("Fantasia", "4"), + TagCheckBox("Romance", "5"), + TagCheckBox("Comédia", "6"), + TagCheckBox("Drama", "7"), + TagCheckBox("Terror", "8"), + TagCheckBox("Horror", "9"), + TagCheckBox("Suspense", "10"), + TagCheckBox("Histórico", "11"), + TagCheckBox("Vida escolar", "12"), + TagCheckBox("Sobrenatural", "13"), + TagCheckBox("Militar", "14"), + TagCheckBox("Shounen", "15"), + TagCheckBox("Shoujo", "16"), + TagCheckBox("Josei", "17"), + TagCheckBox("One-shot", "18"), + TagCheckBox("Isekai", "19"), + TagCheckBox("Retorno", "20"), + TagCheckBox("Reencarnação", "21"), + TagCheckBox("Sistema", "22"), + TagCheckBox("Cultivo", "23"), + TagCheckBox("Artes Marciais", "24"), + TagCheckBox("Dungeon", "25"), + TagCheckBox("Tragédia", "26"), + TagCheckBox("Psicológico", "27"), + TagCheckBox("Culinaria", "28"), + TagCheckBox("Magia", "29"), + TagCheckBox("SuperPoder", "30"), + TagCheckBox("Murim", "31"), + TagCheckBox("Necromante", "32"), + TagCheckBox("Apocalipse", "33"), + TagCheckBox("Seinen", "34"), + TagCheckBox("Luta", "35"), + TagCheckBox("máfia", "36"), + TagCheckBox("Monstros", "37"), + TagCheckBox("Esportes", "38"), + TagCheckBox("Demônios", "39"), + TagCheckBox("Ficção Científica", "40"), + TagCheckBox("Fatia da Vida/Slice of Life", "41"), + TagCheckBox("Ecchi", "42"), + TagCheckBox("Mistério", "43"), + TagCheckBox("Harém", "44"), + TagCheckBox("manhua", "45"), + TagCheckBox("Jogo", "46"), + TagCheckBox("Regressão", "47"), + TagCheckBox("+18", "48"), + TagCheckBox("Oneshot", "49"), + TagCheckBox("Yuri", "50"), + TagCheckBox("Crime", "51"), + TagCheckBox("Policial", "52"), + TagCheckBox("Viagem no Tempo", "53"), + TagCheckBox("Moderno", "54"), + ), + ) + + private class TagCheckBox(name: String, val value: String) : Filter.CheckBox(name) + + private open class UriSelectFilter( + displayName: String, + private val options: Array>, + defaultValue: Int = 0, + ) : Filter.Select( + displayName, + options.map { it.first }.toTypedArray(), + defaultValue, + ) { + val selected get() = options[state].second + } + + // ============================ Manga Details ============================ + override fun getMangaUrl(manga: SManga): String { + // manga.url is "/obra/{id}" for API/internal use. Build webview URL with slug from title. + val id = manga.url.substringAfter("/obra/").substringBefore('/') + val slug = manga.title.toSlug() + return "$baseUrl/obra/$id/$slug" + } + + override fun mangaDetailsRequest(manga: SManga): Request { + val pathSegment = manga.url.replace("/obra/", "/obras/") + return GET("$apiUrl$pathSegment", headers) + } + + override fun mangaDetailsParse(response: Response) = + response.parseAs().toSManga() + + // ============================== Chapters =============================== + override fun getChapterUrl(chapter: SChapter) = "$baseUrl${chapter.url}" + + override fun chapterListRequest(manga: SManga) = mangaDetailsRequest(manga) + + override fun chapterListParse(response: Response): List = + response.parseAs().chapters.map { it.toSChapter() } + .distinctBy(SChapter::url) + + // =============================== Pages ================================= + override fun pageListRequest(chapter: SChapter): Request { + val chapterId = chapter.url.substringAfterLast("/") + return GET("$apiUrl/capitulos/$chapterId", headers) + } + + override fun pageListParse(response: Response): List = + response.parseAs().toPageList() + + override fun imageUrlParse(response: Response): String = "" + + override fun imageUrlRequest(page: Page): Request { + val imageHeaders = headers.newBuilder() + .add("Referer", "$baseUrl/") + .build() + return GET(page.url, imageHeaders) + } + + companion object { + const val CDN_URL = "https://cdn2.fufutebol.com.br" + } +} + +private fun String.toSlug(): String { + val noDiacritics = Normalizer.normalize(this, Normalizer.Form.NFD) + .replace(Regex("\\p{InCombiningDiacriticalMarks}+"), "") + val slug = noDiacritics.lowercase() + .replace(Regex("[^a-z0-9]+"), "-") + .trim('-') + return if (slug.isEmpty()) this.hashCode().toString() else slug } diff --git a/src/pt/mediocretoons/src/eu/kanade/tachiyomi/extension/pt/mediocretoons/MediocreToonsDto.kt b/src/pt/mediocretoons/src/eu/kanade/tachiyomi/extension/pt/mediocretoons/MediocreToonsDto.kt new file mode 100644 index 000000000..006fa6a3b --- /dev/null +++ b/src/pt/mediocretoons/src/eu/kanade/tachiyomi/extension/pt/mediocretoons/MediocreToonsDto.kt @@ -0,0 +1,141 @@ +package eu.kanade.tachiyomi.extension.pt.mediocretoons + +import eu.kanade.tachiyomi.source.model.Page +import eu.kanade.tachiyomi.source.model.SChapter +import eu.kanade.tachiyomi.source.model.SManga +import keiyoushi.utils.tryParse +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.jsoup.Jsoup +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.TimeZone + +@Serializable +data class MediocrePaginationDto( + val currentPage: Int? = null, + val totalPages: Int = 0, + val totalItems: Int = 0, + val itemsPerPage: Int = 0, + val hasNextPage: Boolean = false, + val hasPreviousPage: Boolean = false, +) + +@Serializable +data class MediocreListDto( + val data: T, + val pagination: MediocrePaginationDto? = null, +) + +@Serializable +data class MediocreTagDto( + val id: Int = 0, + @SerialName("nome") val name: String = "", +) + +@Serializable +data class MediocreFormatDto( + val id: Int = 0, + @SerialName("nome") val name: String = "", +) + +@Serializable +data class MediocreStatusDto( + val id: Int = 0, + @SerialName("nome") val name: String = "", +) + +@Serializable +data class MediocreChapterSimpleDto( + val id: Int = 0, + @SerialName("nome") val name: String = "", + @SerialName("numero") val number: Float? = null, + @SerialName("imagem") val image: String? = null, + @SerialName("lancado_em") val releasedAt: String? = null, + @SerialName("criado_em") val createdAt: String? = null, + @SerialName("descricao") val description: String? = null, + @SerialName("tem_paginas") val hasPages: Boolean = false, + val totallinks: Int = 0, + @SerialName("lido") val read: Boolean = false, + val views: Int = 0, +) + +@Serializable +data class MediocreMangaDto( + val id: Int = 0, + val slug: String = "", + @SerialName("nome") val name: String = "", + @SerialName("descricao") val description: String? = null, + @SerialName("imagem") val image: String? = null, + @SerialName("formato") val format: MediocreFormatDto? = null, + val tags: List = emptyList(), + val status: MediocreStatusDto? = null, + @SerialName("total_capitulos") val totalChapters: Int = 0, + @SerialName("capitulos") val chapters: List = emptyList(), +) + +@Serializable +data class MediocrePageSrcDto( + val src: String = "", +) + +@Serializable +data class MediocreChapterDetailDto( + val id: Int = 0, + @SerialName("nome") val name: String = "", + @SerialName("numero") val number: Float? = null, + @SerialName("imagem") val image: String? = null, + @SerialName("paginas") val pages: List = emptyList(), + @SerialName("lancado_em") val releasedAt: String? = null, + @SerialName("criado_em") val createdAt: String? = null, + @SerialName("obra") val manga: MediocreMangaDto? = null, +) + +fun MediocreMangaDto.toSManga(): SManga { + val sManga = SManga.create().apply { + title = name + thumbnail_url = image?.let { + when { + it.startsWith("http") -> it + else -> "${MediocreToons.CDN_URL}/obras/${this@toSManga.id}/$it?v=3" + } + } + initialized = true + url = "/obra/$id" + genre = tags.joinToString { it.name } + } + description?.let { Jsoup.parseBodyFragment(it).let { sManga.description = it.text() } } + status?.let { + sManga.status = when (it.name.lowercase()) { + "em andamento" -> SManga.ONGOING + "completo" -> SManga.COMPLETED + "hiato" -> SManga.ON_HIATUS + else -> SManga.UNKNOWN + } + } + return sManga +} + +fun MediocreChapterSimpleDto.toSChapter(): SChapter { + return SChapter.create().apply { + name = this@toSChapter.name + chapter_number = number ?: 0f + url = "/capitulo/$id" + date_upload = dateFormat.tryParse(createdAt) + } +} + +fun MediocreChapterDetailDto.toPageList(): List { + val obraId = manga?.id ?: 0 + val capituloNome = name + return pages.mapIndexed { idx, p -> + val imageUrl = "${MediocreToons.CDN_URL}/obras/$obraId/capitulos/$capituloNome/${p.src}" + Page(idx, imageUrl = imageUrl) + } +} + +private val dateFormat by lazy { + SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } +}