diff --git a/src/es/mangatigre/AndroidManifest.xml b/src/es/mangatigre/AndroidManifest.xml new file mode 100644 index 000000000..30deb7f79 --- /dev/null +++ b/src/es/mangatigre/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/src/es/mangatigre/build.gradle b/src/es/mangatigre/build.gradle new file mode 100644 index 000000000..a67e7b644 --- /dev/null +++ b/src/es/mangatigre/build.gradle @@ -0,0 +1,13 @@ +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply plugin: 'kotlinx-serialization' + +ext { + extName = 'MangaTigre' + pkgNameSuffix = 'es.mangatigre' + extClass = '.MangaTigre' + extVersionCode = 1 + isNsfw = true +} + +apply from: "$rootDir/common.gradle" diff --git a/src/es/mangatigre/res/mipmap-hdpi/ic_launcher.png b/src/es/mangatigre/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..f96cfabf1 Binary files /dev/null and b/src/es/mangatigre/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/es/mangatigre/res/mipmap-mdpi/ic_launcher.png b/src/es/mangatigre/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..cdd4454db Binary files /dev/null and b/src/es/mangatigre/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/es/mangatigre/res/mipmap-xhdpi/ic_launcher.png b/src/es/mangatigre/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..e4fb3a1de Binary files /dev/null and b/src/es/mangatigre/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/es/mangatigre/res/mipmap-xxhdpi/ic_launcher.png b/src/es/mangatigre/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..130f029b0 Binary files /dev/null and b/src/es/mangatigre/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/es/mangatigre/res/mipmap-xxxhdpi/ic_launcher.png b/src/es/mangatigre/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..a7b64891e Binary files /dev/null and b/src/es/mangatigre/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/es/mangatigre/res/web_hi_res_512.png b/src/es/mangatigre/res/web_hi_res_512.png new file mode 100644 index 000000000..a50992167 Binary files /dev/null and b/src/es/mangatigre/res/web_hi_res_512.png differ diff --git a/src/es/mangatigre/src/eu/kanade/tachiyomi/extension/es/mangatigre/MangaTigre.kt b/src/es/mangatigre/src/eu/kanade/tachiyomi/extension/es/mangatigre/MangaTigre.kt new file mode 100644 index 000000000..41077916c --- /dev/null +++ b/src/es/mangatigre/src/eu/kanade/tachiyomi/extension/es/mangatigre/MangaTigre.kt @@ -0,0 +1,384 @@ +package eu.kanade.tachiyomi.extension.es.mangatigre + +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.POST +import eu.kanade.tachiyomi.network.interceptor.rateLimitHost +import eu.kanade.tachiyomi.source.model.Filter +import eu.kanade.tachiyomi.source.model.FilterList +import eu.kanade.tachiyomi.source.model.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 kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonObject +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import okio.Buffer +import org.jsoup.nodes.Document +import uy.kohesive.injekt.injectLazy +import java.util.Calendar + +class MangaTigre : HttpSource() { + + override val name = "MangaTigre" + + override val baseUrl = "https://www.mangatigre.net" + + override val lang = "es" + + override val supportsLatest = true + + private val json: Json by injectLazy() + + private val imgCDNUrl = "https://i2.mtcdn.xyz" + + private var mtToken = "" + + override val client: OkHttpClient = network.client.newBuilder() + .addInterceptor { chain -> + val request = chain.request() + + if (request.method == "POST") { + val response = chain.proceed(request) + + if (response.code == 419) { + response.close() + getToken() + + val newBody = json.parseToJsonElement(request.bodyString).jsonObject.toMutableMap().apply { + this["_token"] = JsonPrimitive(mtToken) + } + + val payload = Json.encodeToString(JsonObject(newBody)).toRequestBody(JSON_MEDIA_TYPE) + + val apiHeaders = headersBuilder() + .add("Accept", ACCEPT_JSON) + .add("Content-Type", payload.contentType().toString()) + .build() + + val newRequest = request.newBuilder() + .headers(apiHeaders) + .method(request.method, payload) + .build() + + return@addInterceptor chain.proceed(newRequest) + } + return@addInterceptor response + } + chain.proceed(request) + } + .rateLimitHost(baseUrl.toHttpUrl(), 1, 2) + .build() + + private fun getToken() { + val document = client.newCall(GET(baseUrl, headers)).execute().asJsoup() + mtToken = document.selectFirst("input.input-search[data-csrf]")!!.attr("data-csrf") + } + + override fun popularMangaRequest(page: Int): Request { + val payloadObj = PayloadManga( + page = page, + token = mtToken, + ) + + val payload = json.encodeToString(payloadObj).toRequestBody(JSON_MEDIA_TYPE) + + val apiHeaders = headersBuilder() + .add("Accept", ACCEPT_JSON) + .add("Content-Type", payload.contentType().toString()) + .build() + + return POST("$baseUrl/mangas?sort=views", apiHeaders, payload) + } + + override fun popularMangaParse(response: Response): MangasPage { + val jsonString = response.body.string() + + val result = json.decodeFromString(jsonString) + + val mangas = result.mangas.map { + SManga.create().apply { + setUrlWithoutDomain("$baseUrl/manga/${it.slug}") + title = it.title + thumbnail_url = "$imgCDNUrl/mangas/${it.thumbnailFileName}" + } + } + val hasNextPage = result.totalPages > result.page + + return MangasPage(mangas, hasNextPage) + } + + override fun latestUpdatesRequest(page: Int): Request { + val payloadObj = PayloadManga( + page = page, + token = mtToken, + ) + + val payload = json.encodeToString(payloadObj).toRequestBody(JSON_MEDIA_TYPE) + + val apiHeaders = headersBuilder() + .add("Accept", ACCEPT_JSON) + .add("Content-Type", payload.contentType().toString()) + .build() + + return POST("$baseUrl/mangas?sort=date", apiHeaders, payload) + } + + override fun latestUpdatesParse(response: Response) = popularMangaParse(response) + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + if (query.isNotEmpty()) { + if (query.length < 2) throw Exception("La cadena de búsqueda debe tener por lo menos 2 caracteres") + + val payloadObj = PayloadSearch( + query = query, + token = mtToken, + ) + val payload = json.encodeToString(payloadObj).toRequestBody(JSON_MEDIA_TYPE) + + val apiHeaders = headersBuilder() + .add("Accept", ACCEPT_JSON) + .add("Content-Type", payload.contentType().toString()) + .build() + + return POST("$baseUrl/mangas/search#$query", apiHeaders, payload) + } + + val url = "$baseUrl/mangas".toHttpUrlOrNull()!!.newBuilder() + + filters.forEach { filter -> + when (filter) { + is OrderFilter -> { + url.addQueryParameter("sort", filter.toUriPart()) + } + is TypeFilter -> { + filter.state.forEach { content -> + if (content.state) url.addQueryParameter("type[]", content.id) + } + } + is StatusFilter -> { + filter.state.forEach { content -> + if (content.state) url.addQueryParameter("status[]", content.id) + } + } + is DemographicFilter -> { + filter.state.forEach { content -> + if (content.state) url.addQueryParameter("demographic[]", content.id) + } + } + is ContentFilter -> { + filter.state.forEach { content -> + if (content.state) url.addQueryParameter("content[]", content.id) + } + } + is FormatFilter -> { + filter.state.forEach { content -> + if (content.state) url.addQueryParameter("format[]", content.id) + } + } + is GenreFilter -> { + filter.state.forEach { content -> + if (content.state) url.addQueryParameter("genre[]", content.id) + } + } + is ThemeFilter -> { + filter.state.forEach { content -> + if (content.state) url.addQueryParameter("theme[]", content.id) + } + } + else -> {} + } + } + + val payloadObj = PayloadManga( + page = page, + token = mtToken, + ) + val payload = json.encodeToString(payloadObj).toRequestBody(JSON_MEDIA_TYPE) + + val apiHeaders = headersBuilder() + .add("Accept", ACCEPT_JSON) + .add("Content-Type", payload.contentType().toString()) + .build() + + return POST(url.build().toString(), apiHeaders, payload) + } + + override fun searchMangaParse(response: Response): MangasPage { + val query = response.request.url.fragment + val jsonString = response.body.string() + + if (!query.isNullOrEmpty()) { + val result = json.decodeFromString(jsonString) + + val mangas = result.result.map { + SManga.create().apply { + setUrlWithoutDomain("$baseUrl/manga/${it.slug}") + title = it.title + thumbnail_url = "$imgCDNUrl/mangas/${it.thumbnailFileName}" + } + } + + return MangasPage(mangas, false) + } + + val result = json.decodeFromString(jsonString) + + val mangas = result.mangas.map { + SManga.create().apply { + setUrlWithoutDomain("$baseUrl/manga/${it.slug}") + title = it.title + thumbnail_url = "$imgCDNUrl/mangas/${it.thumbnailFileName}" + } + } + val hasNextPage = result.totalPages > result.page + + return MangasPage(mangas, hasNextPage) + } + + override fun mangaDetailsParse(response: Response): SManga { + val document = response.asJsoup() + return SManga.create().apply { + description = createDescription(document) + genre = createGenres(document) + thumbnail_url = document.selectFirst("div.manga-image > img")!!.attr("abs:data-src") + author = document.selectFirst("li.list-group-item:has(strong:contains(Autor)) > a")?.ownText()?.trim() + artist = document.selectFirst("li.list-group-item:has(strong:contains(Artista)) > a")?.ownText()?.trim() + status = document.selectFirst("li.list-group-item:has(strong:contains(Estado))")?.ownText()?.trim()!!.toStatus() + } + } + + private fun createGenres(document: Document): String { + val demographic = document.select("li.list-group-item:has(strong:contains(Demografía)) a").joinToString { it.text() } + val genres = document.select("li.list-group-item:has(strong:contains(Géneros)) a").joinToString { it.text() } + val themes = document.select("li.list-group-item:has(strong:contains(Temas)) a").joinToString { it.text() } + val content = document.select("li.list-group-item:has(strong:contains(Contenido)) a").joinToString { it.text() } + return listOf(demographic, genres, themes, content).joinToString(", ") + } + + private fun createDescription(document: Document): String { + val originalName = document.selectFirst("li.list-group-item:has(strong:contains(Original))")?.ownText()?.trim() ?: "" + val alternativeName = document.select("li.list-group-item:has(strong:contains(Alternativo)) span.alter-name").text() + val year = document.selectFirst("li.list-group-item:has(strong:contains(Año))")?.ownText()?.trim() ?: "" + val animeAdaptation = document.selectFirst("li.list-group-item:has(strong:contains(Anime))")?.ownText()?.trim() ?: "" + val country = document.selectFirst("li.list-group-item:has(strong:contains(País))")?.ownText()?.trim() ?: "" + val summary = document.selectFirst("div.synopsis > p")?.ownText()?.trim() ?: "" + return StringBuilder() + .appendLine("Nombre Original: $originalName") + .appendLine("Títulos Alternativos: $alternativeName") + .appendLine("Año: $year") + .appendLine("Adaptación al Anime: $animeAdaptation") + .appendLine("País: $country") + .appendLine() + .appendLine("Sinopsis: $summary") + .toString() + } + + override fun chapterListRequest(manga: SManga): Request { + val payloadObj = PayloadChapter( + token = mtToken, + ) + + val payload = json.encodeToString(payloadObj).toRequestBody(JSON_MEDIA_TYPE) + + val apiHeaders = headersBuilder() + .add("Accept", ACCEPT_JSON) + .add("Content-Type", payload.contentType().toString()) + .build() + + return POST("$baseUrl${manga.url}", apiHeaders, payload) + } + + override fun chapterListParse(response: Response): List { + return response.asJsoup().select("li").map { + SChapter.create().apply { + setUrlWithoutDomain(it.select("a").attr("href")) + name = it.selectFirst("a")!!.ownText().trim() + date_upload = parseRelativeDate(it.selectFirst("span")!!.ownText().trim()) + } + } + } + + override fun pageListParse(response: Response): List { + val document = response.asJsoup() + val script = document.selectFirst("script:containsData(window.chapter)")!!.data() + val jsonString = CHAPTERS_REGEX.find(script)!!.groupValues[1] + + val result = json.decodeFromString(jsonString) + val slug = result.manga.slug + val number = result.number + + return result.images.map { + val imageUrl = "$imgCDNUrl/chapters/$slug/$number/${it.value.name}.${it.value.format}" + Page(it.key.toInt(), "", imageUrl) + }.sortedBy { it.index } + } + + override fun getFilterList() = FilterList( + Filter.Header("Los filtros serán ignorados si se realiza una búsqueda textual"), + Filter.Separator(), + OrderFilter(), + Filter.Separator(), + TypeFilter(getFilterTypeList()), + Filter.Separator(), + StatusFilter(getFilterStatusList()), + Filter.Separator(), + DemographicFilter(getFilterDemographicList()), + Filter.Separator(), + ContentFilter(getFilterContentList()), + Filter.Separator(), + FormatFilter(getFilterFormatList()), + Filter.Separator(), + GenreFilter(getFilterGenreList()), + Filter.Separator(), + ThemeFilter(getFilterThemeList()), + ) + + override fun imageUrlParse(response: Response) = throw UnsupportedOperationException("Not used.") + + private fun parseRelativeDate(date: String): Long { + val number = Regex("""(\d+)""").find(date)?.value?.toIntOrNull() ?: return 0 + val cal = Calendar.getInstance() + + return when { + WordSet("segundo").anyWordIn(date) -> cal.apply { add(Calendar.SECOND, -number) }.timeInMillis + WordSet("minuto").anyWordIn(date) -> cal.apply { add(Calendar.MINUTE, -number) }.timeInMillis + WordSet("hora").anyWordIn(date) -> cal.apply { add(Calendar.HOUR, -number) }.timeInMillis + WordSet("día").anyWordIn(date) -> cal.apply { add(Calendar.DAY_OF_MONTH, -number) }.timeInMillis + WordSet("semana").anyWordIn(date) -> cal.apply { add(Calendar.DAY_OF_MONTH, -number * 7) }.timeInMillis + WordSet("mes").anyWordIn(date) -> cal.apply { add(Calendar.MONTH, -number) }.timeInMillis + WordSet("año").anyWordIn(date) -> cal.apply { add(Calendar.YEAR, -number) }.timeInMillis + else -> 0 + } + } + class WordSet(private vararg val words: String) { + fun anyWordIn(dateString: String): Boolean = words.any { dateString.contains(it, ignoreCase = true) } + } + + private val Request.bodyString: String + get() { + val requestCopy = newBuilder().build() + val buffer = Buffer() + + return runCatching { buffer.apply { requestCopy.body!!.writeTo(this) }.readUtf8() } + .getOrNull() ?: "" + } + + companion object { + private const val ACCEPT_JSON = "application/json, text/plain, */*" + private val JSON_MEDIA_TYPE = "application/json".toMediaType() + + private val CHAPTERS_REGEX = """window\.chapter\s*=\s*'(.+?)';""".toRegex() + } +} diff --git a/src/es/mangatigre/src/eu/kanade/tachiyomi/extension/es/mangatigre/MangaTigreDto.kt b/src/es/mangatigre/src/eu/kanade/tachiyomi/extension/es/mangatigre/MangaTigreDto.kt new file mode 100644 index 000000000..5418979ac --- /dev/null +++ b/src/es/mangatigre/src/eu/kanade/tachiyomi/extension/es/mangatigre/MangaTigreDto.kt @@ -0,0 +1,75 @@ +package eu.kanade.tachiyomi.extension.es.mangatigre + +import eu.kanade.tachiyomi.source.model.SManga +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class PayloadManga( + val page: Int, + @SerialName("_token") val token: String, +) + +@Serializable +data class PayloadChapter( + @SerialName("_token") val token: String, +) + +@Serializable +data class PayloadSearch( + val query: String, + @SerialName("_token") val token: String, +) + +@Serializable +data class MangasDto( + @SerialName("current_page") val page: Int, + @SerialName("last_page") val totalPages: Int, + @SerialName("data") val mangas: List, +) + +@Serializable +data class MangasDataDto( + @SerialName("name") val title: String, + val slug: String, + @SerialName("image") val thumbnailFileName: String, +) + +@Serializable +data class ChapterDto( + val manga: ChapterMangaInfoDto, + val number: Int, + val images: Map, +) + +@Serializable +data class ChapterMangaInfoDto( + val slug: String, +) + +@Serializable +data class ChapterImagesDto( + val name: String, + val format: String, +) + +@Serializable +data class SearchDto( + val result: List, +) + +@Serializable +data class SearchDataDto( + val id: Int, + @SerialName("name") val title: String, + val slug: String, + @SerialName("image")val thumbnailFileName: String, +) + +fun String.toStatus(): Int = when (this) { + "En Marcha" -> SManga.ONGOING + "Terminado" -> SManga.COMPLETED + "Detenido" -> SManga.ON_HIATUS + "Pausado" -> SManga.ON_HIATUS + else -> SManga.UNKNOWN +} diff --git a/src/es/mangatigre/src/eu/kanade/tachiyomi/extension/es/mangatigre/MangaTigreFilters.kt b/src/es/mangatigre/src/eu/kanade/tachiyomi/extension/es/mangatigre/MangaTigreFilters.kt new file mode 100644 index 000000000..6d6a12d90 --- /dev/null +++ b/src/es/mangatigre/src/eu/kanade/tachiyomi/extension/es/mangatigre/MangaTigreFilters.kt @@ -0,0 +1,148 @@ +package eu.kanade.tachiyomi.extension.es.mangatigre + +import eu.kanade.tachiyomi.source.model.Filter + +class Type(name: String, val id: String) : Filter.CheckBox(name) +class TypeFilter(values: List) : Filter.Group("Tipos", values) + +class Status(name: String, val id: String) : Filter.CheckBox(name) +class StatusFilter(values: List) : Filter.Group("Estado", values) + +class Demographic(name: String, val id: String) : Filter.CheckBox(name) +class DemographicFilter(values: List) : Filter.Group("Demografía", values) + +class Content(name: String, val id: String) : Filter.CheckBox(name) +class ContentFilter(values: List) : Filter.Group("Contenido", values) + +class Format(name: String, val id: String) : Filter.CheckBox(name) +class FormatFilter(values: List) : Filter.Group("Formato", values) + +class Genre(name: String, val id: String) : Filter.CheckBox(name) +class GenreFilter(values: List) : Filter.Group("Géneros", values) + +class Theme(name: String, val id: String) : Filter.CheckBox(name) +class ThemeFilter(values: List) : Filter.Group("Temas", values) + +open class UriPartFilter(displayName: String, val vals: Array>) : + Filter.Select(displayName, vals.map { it.first }.toTypedArray()) { + fun toUriPart() = vals[state].second +} + +class OrderFilter() : UriPartFilter( + "Ordenar por", + arrayOf( + Pair("Alfabético", "name"), + Pair("Vistas", "views"), + Pair("Fecha Estreno", "date"), + ), +) + +fun getFilterTypeList() = listOf( + Type("Manga", "1"), + Type("Manhwa", "2"), + Type("Manhua", "3"), +) + +fun getFilterStatusList() = listOf( + Status("En Marcha", "1"), + Status("Terminado", "2"), + Status("Detenido", "3"), + Status("Pausado", "4"), +) + +fun getFilterDemographicList() = listOf( + Demographic("Shonen", "1"), + Demographic("Seinen", "2"), + Demographic("Shojo", "3"), + Demographic("Josei", "4"), +) + +fun getFilterContentList() = listOf( + Content("Ecchi", "1"), + Content("Gore", "2"), + Content("Smut", "3"), + Content("Violencia Sexual", "4"), +) + +fun getFilterFormatList() = listOf( + Format("Adaptación", "14"), + Format("Antalogía", "9"), + Format("Color Completo", "18"), + Format("Coloreado Oficial", "19"), + Format("Coloreado Por Fan", "15"), + Format("Creado Por Usuario", "20"), + Format("Delincuencia", "16"), + Format("Doujinshi", "10"), + Format("Galardonado", "13"), + Format("One Shot", "11"), + Format("Tira Larga", "17"), + Format("Webcomic", "12"), + Format("YonKoma", "8"), +) + +fun getFilterGenreList() = listOf( + Genre("Acción", "49"), + Genre("Aventura", "50"), + Genre("Boys Love", "75"), + Genre("Chicas Mágicas", "73"), + Genre("Ciencia-Ficción", "64"), + Genre("Comedia", "51"), + Genre("Crimen", "52"), + Genre("Deporte", "65"), + Genre("Drama", "53"), + Genre("Fantasía", "54"), + Genre("Filosófico", "61"), + Genre("Girls Love", "76"), + Genre("Guerra", "74"), + Genre("Histórico", "55"), + Genre("Horror", "56"), + Genre("Isekai", "57"), + Genre("Mecha", "58"), + Genre("Médica", "59"), + Genre("Misterio", "60"), + Genre("Psicológico", "62"), + Genre("Recuentos De La Vida", "72"), + Genre("Romance", "63"), + Genre("Superhéroe", "66"), + Genre("Thriller", "67"), + Genre("Tragedia", "68"), + Genre("Wuxia", "69"), + Genre("Yaoi", "70"), + Genre("Yuri", "71"), +) + +fun getFilterThemeList() = listOf( + Theme("Animales", "52"), + Theme("Apocalíptico", "50"), + Theme("Artes Marciales", "60"), + Theme("Chicas Monstruo", "77"), + Theme("Cocinando", "53"), + Theme("Crossdressing", "79"), + Theme("Delincuencia", "78"), + Theme("Demonios", "54"), + Theme("Extranjeros", "51"), + Theme("Fantasma", "55"), + Theme("Género Bender", "81"), + Theme("Gyaru", "56"), + Theme("Harén", "57"), + Theme("Incesto", "58"), + Theme("Lolicon", "59"), + Theme("Mafia", "64"), + Theme("Magia", "65"), + Theme("Militar", "61"), + Theme("Monstruos", "62"), + Theme("Música", "63"), + Theme("Ninja", "66"), + Theme("Policía", "67"), + Theme("Realidad Virtual", "74"), + Theme("Reencarnación", "68"), + Theme("Samurái", "73"), + Theme("Shotacon", "71"), + Theme("Sobrenatural", "69"), + Theme("Superpoderes", "82"), + Theme("Supervivencia", "72"), + Theme("Vampiros", "75"), + Theme("Vida Escolar", "70"), + Theme("Videojuegos", "80"), + Theme("Zombis", "76"), +)