diff --git a/src/pt/yomumangas/build.gradle b/src/pt/yomumangas/build.gradle new file mode 100644 index 000000000..df40cfef1 --- /dev/null +++ b/src/pt/yomumangas/build.gradle @@ -0,0 +1,8 @@ +ext { + extName = 'Yomu Mangás' + extClass = '.YomuMangas' + extVersionCode = 4 + isNsfw = true +} + +apply from: "$rootDir/common.gradle" diff --git a/src/pt/yomumangas/res/mipmap-hdpi/ic_launcher.png b/src/pt/yomumangas/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..47013f348 Binary files /dev/null and b/src/pt/yomumangas/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/pt/yomumangas/res/mipmap-mdpi/ic_launcher.png b/src/pt/yomumangas/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..dcd5ab52a Binary files /dev/null and b/src/pt/yomumangas/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/pt/yomumangas/res/mipmap-xhdpi/ic_launcher.png b/src/pt/yomumangas/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..1d3ce2077 Binary files /dev/null and b/src/pt/yomumangas/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/pt/yomumangas/res/mipmap-xxhdpi/ic_launcher.png b/src/pt/yomumangas/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..321065398 Binary files /dev/null and b/src/pt/yomumangas/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/pt/yomumangas/res/mipmap-xxxhdpi/ic_launcher.png b/src/pt/yomumangas/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..91ec7f3a6 Binary files /dev/null and b/src/pt/yomumangas/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/pt/yomumangas/src/eu/kanade/tachiyomi/extension/pt/yomumangas/YomuMangas.kt b/src/pt/yomumangas/src/eu/kanade/tachiyomi/extension/pt/yomumangas/YomuMangas.kt new file mode 100644 index 000000000..b456bcd15 --- /dev/null +++ b/src/pt/yomumangas/src/eu/kanade/tachiyomi/extension/pt/yomumangas/YomuMangas.kt @@ -0,0 +1,142 @@ +package eu.kanade.tachiyomi.extension.pt.yomumangas + +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.interceptor.rateLimitHost +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 eu.kanade.tachiyomi.util.asJsoup +import keiyoushi.utils.parseAs +import okhttp3.Headers +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import java.util.concurrent.TimeUnit + +class YomuMangas : HttpSource() { + + override val name = "Yomu Mangás" + + override val baseUrl = "https://yomumangas.com" + + override val lang = "pt-BR" + + override val supportsLatest = true + + override val client: OkHttpClient = network.cloudflareClient.newBuilder() + .rateLimitHost(baseUrl.toHttpUrl(), 1, 1, TimeUnit.SECONDS) + .rateLimitHost(API_URL.toHttpUrl(), 1, 1, TimeUnit.SECONDS) + .rateLimitHost(CDN_URL.toHttpUrl(), 1, 2, TimeUnit.SECONDS) + .build() + + private val apiHeaders: Headers by lazy { apiHeadersBuilder().build() } + + override fun headersBuilder(): Headers.Builder = Headers.Builder() + .add("Origin", baseUrl) + .add("Referer", baseUrl) + + private fun apiHeadersBuilder(): Headers.Builder = headersBuilder() + .add("Accept", ACCEPT_JSON) + + // ================================ Popular ======================================= + + override fun popularMangaRequest(page: Int): Request = searchMangaRequest(page, "", FilterList()) + + override fun popularMangaParse(response: Response): MangasPage = searchMangaParse(response) + + // ================================ Latest ======================================= + + override fun latestUpdatesRequest(page: Int): Request = GET(baseUrl, headers) + + override fun latestUpdatesParse(response: Response): MangasPage { + val document = response.asJsoup() + val mangas = document.select("[class*=styles_Container]:has(h1:contains(capítulos)) [class*=styles_Card]").map { element -> + SManga.create().apply { + with(element.selectFirst("a[class*=styles_Title]")!!) { + title = text() + setUrlWithoutDomain(absUrl("href")) + } + thumbnail_url = element.selectFirst("img")?.absUrl("src") + } + } + return MangasPage(mangas, hasNextPage = false) + } + + // ================================ Search ======================================= + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + val apiUrl = "$API_URL/mangas".toHttpUrl().newBuilder() + .addQueryParameter("query", query) + .addQueryParameter("page", page.toString()) + + filters.filterIsInstance() + .forEach { it.addQueryParameter(apiUrl) } + + return GET(apiUrl.build(), apiHeaders) + } + + override fun searchMangaParse(response: Response): MangasPage { + val result = response.parseAs() + val seriesList = result.mangas.map(YomuMangasSeriesDto::toSManga) + return MangasPage(seriesList, result.hasNextPage) + } + + // ================================ Details ======================================= + + override fun getMangaUrl(manga: SManga): String = "$baseUrl${manga.url}" + + override fun mangaDetailsRequest(manga: SManga): Request = + GET("$API_URL${manga.url.substringBeforeLast("/")}", apiHeaders) + + override fun mangaDetailsParse(response: Response): SManga = + response.parseAs().manga.toSManga() + + // ================================ Chapters ======================================= + + override fun chapterListRequest(manga: SManga): Request = mangaDetailsRequest(manga) + + private fun chapterListApiRequest(mangaId: Int): Request { + return GET("$API_URL/mangas/$mangaId/chapters", apiHeaders) + } + + override fun chapterListParse(response: Response): List { + val series = response.parseAs().manga + + return client.newCall(chapterListApiRequest(series.id)).execute() + .parseAs().chapters + .sortedByDescending(YomuMangasChapterDto::chapter) + .map { it.toSChapter(series) } + } + + // ================================ Pages ======================================= + + override fun pageListParse(response: Response): List { + val document = response.asJsoup() + return document.select("[class*=reader_Pages] img").mapIndexed { index, element -> + Page(index, imageUrl = element.absUrl("src")) + } + } + + override fun imageUrlParse(response: Response): String = "" + + // ================================ Filters ======================================= + + override fun getFilterList(): FilterList = FilterList( + StatusFilter(statusList), + TypeFilter(typesList), + NsfwContentFilter(), + AdultContentFilter(), + GenreFilter(genresList), + ) + + companion object { + private const val ACCEPT_JSON = "application/json" + + private const val API_URL = "https://api.yomumangas.com" + const val CDN_URL = "https://s3.yomumangas.com" + } +} diff --git a/src/pt/yomumangas/src/eu/kanade/tachiyomi/extension/pt/yomumangas/YomuMangasDto.kt b/src/pt/yomumangas/src/eu/kanade/tachiyomi/extension/pt/yomumangas/YomuMangasDto.kt new file mode 100644 index 000000000..617798575 --- /dev/null +++ b/src/pt/yomumangas/src/eu/kanade/tachiyomi/extension/pt/yomumangas/YomuMangasDto.kt @@ -0,0 +1,125 @@ +package eu.kanade.tachiyomi.extension.pt.yomumangas + +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 kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.JsonTransformingSerializer +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonPrimitive +import java.text.SimpleDateFormat +import java.util.Locale + +@Serializable +data class YomuMangasHomeDto( + val updates: List = emptyList(), + val votes: List = emptyList(), +) + +@Serializable +data class YomuMangasSearchDto( + val mangas: List = emptyList(), + val page: Int, + val pages: Int, +) { + + val hasNextPage: Boolean + get() = page < pages +} + +@Serializable +data class YomuMangasDetailsDto(val manga: YomuMangasSeriesDto) + +@Serializable +data class YomuMangasSeriesDto( + val id: Int, + val slug: String, + val title: String, + val cover: String? = null, + val status: String, + val authors: List? = emptyList(), + val artists: List? = emptyList(), + @Serializable(with = YomuMangasGenreDtoSerializer::class) + val genres: List? = emptyList(), + val description: String? = null, +) { + + val genre: String? + get() = genres + ?.filter { it.name.equals("unknown").not() } + ?.joinToString { it.name } + + fun toSManga(): SManga = SManga.create().apply { + title = this@YomuMangasSeriesDto.title + author = authors.orEmpty().joinToString { it.trim() } + artist = artists.orEmpty().joinToString { it.trim() } + genre = this@YomuMangasSeriesDto.genre + description = this@YomuMangasSeriesDto.description?.trim() + status = when (this@YomuMangasSeriesDto.status) { + "ONGOING" -> SManga.ONGOING + "COMPLETE" -> SManga.COMPLETED + "HIATUS" -> SManga.ON_HIATUS + "CANCELLED" -> SManga.CANCELLED + "PLANNED" -> SManga.PUBLISHING_FINISHED + else -> SManga.UNKNOWN + } + thumbnail_url = cover?.let { "${YomuMangas.CDN_URL}/images/${it.substringAfter("//")}" } + url = "/mangas/$id/$slug" + } +} + +@Serializable +data class YomuMangasGenreDto(val name: String) + +private object YomuMangasGenreDtoSerializer : JsonTransformingSerializer>( + ListSerializer(YomuMangasGenreDto.serializer()), +) { + override fun transformDeserialize(element: JsonElement): JsonElement { + return JsonArray( + element.jsonArray.map { jsonElement -> + jsonElement.takeIf { it.isObject } ?: buildJsonObject { + genresList.firstOrNull { it.id.equals(jsonElement.jsonPrimitive.intOrNull) }?.let { + put("name", JsonPrimitive(it.name)) + } ?: put("name", JsonPrimitive("unknown")) + } + }, + ) + } + + private val JsonElement.isObject get() = this is JsonObject +} + +@Serializable +data class YomuMangasChaptersDto(val chapters: List = emptyList()) + +@Serializable +data class YomuMangasChapterDto( + val id: Int, + val chapter: Float, + @SerialName("uploaded_at") val uploadedAt: String, + val images: List? = emptyList(), +) { + + fun toSChapter(series: YomuMangasSeriesDto): SChapter = SChapter.create().apply { + name = "Capítulo ${chapter.toString().removeSuffix(".0")}" + date_upload = DATE_FORMATTER.tryParse(uploadedAt) + url = "/mangas/${series.id}/${series.slug}/$chapter" + } + + companion object { + private val DATE_FORMATTER by lazy { + SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US) + } + } +} + +@Serializable +data class YomuMangasImageDto(val uri: String) diff --git a/src/pt/yomumangas/src/eu/kanade/tachiyomi/extension/pt/yomumangas/YomuMangasFilters.kt b/src/pt/yomumangas/src/eu/kanade/tachiyomi/extension/pt/yomumangas/YomuMangasFilters.kt new file mode 100644 index 000000000..a32377ad9 --- /dev/null +++ b/src/pt/yomumangas/src/eu/kanade/tachiyomi/extension/pt/yomumangas/YomuMangasFilters.kt @@ -0,0 +1,116 @@ +package eu.kanade.tachiyomi.extension.pt.yomumangas + +import eu.kanade.tachiyomi.source.model.Filter +import okhttp3.HttpUrl + +interface UrlQueryFilter { + fun addQueryParameter(url: HttpUrl.Builder) +} + +class NsfwContentFilter : Filter.CheckBox("Conteúdo NSFW"), UrlQueryFilter { + override fun addQueryParameter(url: HttpUrl.Builder) { + if (state) { + url.addQueryParameter("nsfw", "true") + } + } +} + +class AdultContentFilter : Filter.CheckBox("Conteúdo adulto"), UrlQueryFilter { + override fun addQueryParameter(url: HttpUrl.Builder) { + if (state) { + url.addQueryParameter("hentai", "true") + } + } +} + +open class EnhancedSelect(name: String, values: Array) : Filter.Select(name, values) { + val selected: T + get() = values[state] +} + +data class Status(val name: String, val value: String) { + override fun toString() = name +} + +class StatusFilter(statusList: List) : + EnhancedSelect("Status", statusList.toTypedArray()), + UrlQueryFilter { + + override fun addQueryParameter(url: HttpUrl.Builder) { + if (state > 0) { + url.addQueryParameter("status", selected.value) + } + } +} + +data class Type(val name: String, val value: String) { + override fun toString() = name +} + +class TypeFilter(typesList: List) : + EnhancedSelect("Tipo", typesList.toTypedArray()), + UrlQueryFilter { + + override fun addQueryParameter(url: HttpUrl.Builder) { + if (state > 0) { + url.addQueryParameter("type", selected.value) + } + } +} + +class Genre(name: String, val id: String) : Filter.CheckBox(name) { + override fun toString() = name +} + +class GenreFilter(genres: List) : + Filter.Group("Gêneros", genres), + UrlQueryFilter { + + override fun addQueryParameter(url: HttpUrl.Builder) { + state.filter(Genre::state) + .forEach { url.addQueryParameter("genres[]", it.id) } + } +} + +val genresList: List = listOf( + Genre("Ação", "1"), + Genre("Aventura", "8"), + Genre("Comédia", "2"), + Genre("Drama", "3"), + Genre("Ecchi", "15"), + Genre("Esportes", "14"), + Genre("Fantasia", "6"), + Genre("Hentai", "19"), + Genre("Horror", "4"), + Genre("Mahou shoujo", "18"), + Genre("Mecha", "17"), + Genre("Mistério", "7"), + Genre("Música", "16"), + Genre("Psicológico", "9"), + Genre("Romance", "13"), + Genre("Sci-fi", "11"), + Genre("Slice of life", "10"), + Genre("Sobrenatural", "5"), + Genre("Suspense", "12"), +) + +val statusList: List = listOf( + Status("Todos", ""), + Status("Finalizado", "COMPLETE"), + Status("Em lançando", "ONGOING"), + Status("Hiato", "HIATUS"), + Status("Pausado", "ONHOLD"), + Status("Planejado", "PLANNED"), + Status("Arquivado", "ARCHIVED"), + Status("Cancelado", "CANCELLED"), +) + +val typesList: List = listOf( + Type("Todos", ""), + Type("Mangá", "MANGA"), + Type("Manhwa", "MANHWA"), + Type("Manhua", "MANHUA"), + Type("One-shot", "ONESHOT"), + Type("Doujinshi", "DOUJINSHI"), + Type("Outros", "OTHER"), +)