diff --git a/src/es/mangaesp/build.gradle b/src/es/mangaesp/build.gradle new file mode 100644 index 000000000..5c587dc3e --- /dev/null +++ b/src/es/mangaesp/build.gradle @@ -0,0 +1,8 @@ +ext { + extName = 'MangaEsp' + extClass = '.MangaEsp' + extVersionCode = 1 + isNsfw = false +} + +apply from: "$rootDir/common.gradle" diff --git a/src/es/mangaesp/res/mipmap-hdpi/ic_launcher.png b/src/es/mangaesp/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..04cbad788 Binary files /dev/null and b/src/es/mangaesp/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/es/mangaesp/res/mipmap-mdpi/ic_launcher.png b/src/es/mangaesp/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..c30ca36c5 Binary files /dev/null and b/src/es/mangaesp/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/es/mangaesp/res/mipmap-xhdpi/ic_launcher.png b/src/es/mangaesp/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..4232beaa7 Binary files /dev/null and b/src/es/mangaesp/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/es/mangaesp/res/mipmap-xxhdpi/ic_launcher.png b/src/es/mangaesp/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..f712909c5 Binary files /dev/null and b/src/es/mangaesp/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/es/mangaesp/res/mipmap-xxxhdpi/ic_launcher.png b/src/es/mangaesp/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..07243de80 Binary files /dev/null and b/src/es/mangaesp/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/es/mangaesp/src/eu/kanade/tachiyomi/extension/es/mangaesp/MangaEsp.kt b/src/es/mangaesp/src/eu/kanade/tachiyomi/extension/es/mangaesp/MangaEsp.kt new file mode 100644 index 000000000..15a980ef5 --- /dev/null +++ b/src/es/mangaesp/src/eu/kanade/tachiyomi/extension/es/mangaesp/MangaEsp.kt @@ -0,0 +1,261 @@ +package eu.kanade.tachiyomi.extension.es.mangaesp + +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.asObservableSuccess +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.json.Json +import okhttp3.Headers +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import org.jsoup.nodes.Element +import rx.Observable +import uy.kohesive.injekt.injectLazy +import java.text.SimpleDateFormat +import java.util.Locale +import kotlin.math.min + +class MangaEsp : HttpSource() { + + override val name = "MangaEsp" + + override val baseUrl = "https://mangaesp.co" + + private val apiBaseUrl = "https://apis.mangaesp.co" + + override val lang = "es" + + override val supportsLatest = true + + private val json: Json by injectLazy() + + private val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US) + + override val client: OkHttpClient = network.client.newBuilder() + .rateLimitHost(baseUrl.toHttpUrl(), 2) + .build() + + override fun headersBuilder(): Headers.Builder = Headers.Builder() + .add("Referer", "$baseUrl/") + + override fun popularMangaRequest(page: Int): Request = GET("$apiBaseUrl/api/topSerie", headers) + + override fun popularMangaParse(response: Response): MangasPage { + val responseData = json.decodeFromString(response.body.string()) + + val topDaily = responseData.response.topDaily.flatten().map { it.data } + val topWeekly = responseData.response.topWeekly.flatten().map { it.data } + val topMonthly = responseData.response.topMonthly.flatten().map { it.data } + + val mangas = (topDaily + topWeekly + topMonthly).distinctBy { it.slug }.map { series -> + SManga.create().apply { + title = series.name + thumbnail_url = series.thumbnail + url = "/ver/${series.slug}" + } + } + + return MangasPage(mangas, false) + } + + override fun latestUpdatesRequest(page: Int): Request = GET("$apiBaseUrl/api/lastUpdates", headers) + + override fun latestUpdatesParse(response: Response): MangasPage { + val responseData = json.decodeFromString(response.body.string()) + + val mangas = responseData.response.map { series -> + SManga.create().apply { + title = series.name + thumbnail_url = series.thumbnail + url = "/ver/${series.slug}" + } + } + + return MangasPage(mangas, false) + } + + private var comicsList = mutableListOf() + + override fun fetchSearchManga( + page: Int, + query: String, + filters: FilterList, + ): Observable { + return if (comicsList.isEmpty()) { + client.newCall(searchMangaRequest(page, query, filters)) + .asObservableSuccess() + .map { searchMangaParse(it, page, query, filters) } + } else { + Observable.just(parseComicsList(page, query, filters)) + } + } + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request = GET("$baseUrl/api/comics", headers) + + override fun searchMangaParse(response: Response): MangasPage = throw UnsupportedOperationException() + + private fun searchMangaParse(response: Response, page: Int, query: String, filters: FilterList): MangasPage { + val responseData = json.decodeFromString(response.body.string()) + comicsList = responseData.response.toMutableList() + return parseComicsList(page, query, filters) + } + + private var filteredList = mutableListOf() + + private fun parseComicsList(page: Int, query: String, filterList: FilterList): MangasPage { + if (page == 1) { + filteredList.clear() + + if (query.isNotBlank()) { + if (query.length < 2) throw Exception("La búsqueda debe tener al menos 2 caracteres") + filteredList.addAll(comicsList.filter { it.name.contains(query, ignoreCase = true) }) + } else { + filteredList.addAll(comicsList) + } + + val statusFilter = filterList.firstInstanceOrNull() + + if (statusFilter != null) { + filteredList = filteredList.filter { it.status == statusFilter.toUriPart() }.toMutableList() + } + + val sortByFilter = filterList.firstInstanceOrNull() + + if (sortByFilter != null) { + if (sortByFilter.state?.ascending == true) { + when (sortByFilter.selected) { + "name" -> filteredList.sortBy { it.name } + "views" -> filteredList.sortBy { it.trending?.views } + "updated_at" -> filteredList.sortBy { it.lastChapterDate } + "created_at" -> filteredList.sortBy { it.createdAt } + } + } else { + when (sortByFilter.selected) { + "name" -> filteredList.sortByDescending { it.name } + "views" -> filteredList.sortByDescending { it.trending?.views } + "updated_at" -> filteredList.sortByDescending { it.lastChapterDate } + "created_at" -> filteredList.sortByDescending { it.createdAt } + } + } + } + } + + val hasNextPage = filteredList.size > page * MANGAS_PER_PAGE + + return MangasPage( + filteredList.subList((page - 1) * MANGAS_PER_PAGE, min(page * MANGAS_PER_PAGE, filteredList.size)) + .map { it.toSimpleSManga() }, + hasNextPage, + ) + } + + override fun mangaDetailsParse(response: Response): SManga { + val responseBody = response.body.string() + val mangaDetailsJson = MANGA_DETAILS_REGEX.find(responseBody)?.groupValues?.get(1) + ?: throw Exception("No se pudo encontrar los detalles del manga") + val unescapedJson = mangaDetailsJson.replace("\\", "") + + val series = json.decodeFromString(unescapedJson) + return SManga.create().apply { + title = series.name + thumbnail_url = series.thumbnail + description = series.synopsis + genre = series.genders.joinToString { it.gender.name } + author = series.authors.joinToString { it.author.name } + artist = series.artists.joinToString { it.artist.name } + } + } + + override fun chapterListParse(response: Response): List { + val responseBody = response.body.string() + val mangaDetailsJson = MANGA_DETAILS_REGEX.find(responseBody)?.groupValues?.get(1) + ?: throw Exception("No se pudo encontrar la lista de capítulos") + val unescapedJson = mangaDetailsJson.replace("\\", "") + val series = json.decodeFromString(unescapedJson) + return series.chapters.map { chapter -> + SChapter.create().apply { + name = if (chapter.name.isNullOrBlank()) { + "Capítulo ${chapter.number.toString().removeSuffix(".0")}" + } else { + "Capítulo ${chapter.number.toString().removeSuffix(".0")} - ${chapter.name}" + } + date_upload = runCatching { dateFormat.parse(chapter.date)?.time }.getOrNull() ?: 0L + url = "/ver/${series.slug}/${chapter.slug}" + } + } + } + + override fun pageListParse(response: Response): List { + val document = response.asJsoup() + return document.select("main.contenedor.read img").mapIndexed { i, img -> + Page(i, "", img.imgAttr()) + } + } + + override fun getFilterList() = FilterList( + SortByFilter("Ordenar por", getSortProperties()), + StatusFilter(), + ) + + private fun getSortProperties(): List = listOf( + SortProperty("Nombre", "name"), + SortProperty("Visitas", "views"), + SortProperty("Actualización", "updated_at"), + SortProperty("Agregado", "created_at"), + ) + + data class SortProperty(val name: String, val value: String) { + override fun toString(): String = name + } + + class SortByFilter(title: String, private val sortProperties: List) : Filter.Sort( + title, + sortProperties.map { it.name }.toTypedArray(), + Selection(2, ascending = false), + ) { + val selected: String + get() = sortProperties[state!!.index].value + } + + private class StatusFilter : UriPartFilter( + "Estado", + arrayOf( + Pair("En emisión", 1), + Pair("En pausa", 2), + Pair("Abandonado", 3), + Pair("Finalizado", 4), + ), + ) + + private open class UriPartFilter(displayName: String, private val vals: Array>) : + Filter.Select(displayName, vals.map { it.first }.toTypedArray()) { + fun toUriPart() = vals[state].second + } + + private inline fun List<*>.firstInstanceOrNull(): R? = + filterIsInstance().firstOrNull() + + override fun imageUrlParse(response: Response): String = throw UnsupportedOperationException() + + private fun Element.imgAttr(): String = when { + hasAttr("data-lazy-src") -> attr("abs:data-lazy-src") + hasAttr("data-src") -> attr("abs:data-src") + hasAttr("data-cfsrc") -> attr("abs:data-cfsrc") + else -> attr("abs:src") + } + + companion object { + private val MANGA_DETAILS_REGEX = """self.__next_f.push\(.*data\\":(\{.*lastChapters.*\}).*numFollow""".toRegex() + private const val MANGAS_PER_PAGE = 15 + } +} diff --git a/src/es/mangaesp/src/eu/kanade/tachiyomi/extension/es/mangaesp/MangaEspDto.kt b/src/es/mangaesp/src/eu/kanade/tachiyomi/extension/es/mangaesp/MangaEspDto.kt new file mode 100644 index 000000000..7be67694f --- /dev/null +++ b/src/es/mangaesp/src/eu/kanade/tachiyomi/extension/es/mangaesp/MangaEspDto.kt @@ -0,0 +1,91 @@ +package eu.kanade.tachiyomi.extension.es.mangaesp + +import eu.kanade.tachiyomi.source.model.SManga +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class TopSeriesDto( + val response: TopSeriesResponseDto, +) + +@Serializable +data class LastUpdatesDto( + val response: List, +) + +@Serializable +data class ComicsDto( + val response: List, +) + +@Serializable +data class TopSeriesResponseDto( + @SerialName("mensual") val topMonthly: List>, + @SerialName("semanal") val topWeekly: List>, + @SerialName("diario") val topDaily: List>, +) + +@Serializable +data class PayloadSeriesDto( + @SerialName("project") val data: SeriesDto, +) + +@Serializable +data class SeriesDto( + val name: String, + val slug: String, + @SerialName("sinopsis") val synopsis: String? = null, + @SerialName("urlImg") val thumbnail: String? = null, + val isVisible: Boolean, + @SerialName("actualizacionCap") val lastChapterDate: String? = null, + @SerialName("created_at") val createdAt: String? = null, + @SerialName("state_id") val status: Int? = 0, + val genders: List = emptyList(), + @SerialName("lastChapters") val chapters: List = emptyList(), + val trending: SeriesTrendingDto? = null, + @SerialName("autors") val authors: List = emptyList(), + val artists: List = emptyList(), + +) { + fun toSimpleSManga(): SManga { + return SManga.create().apply { + title = name + thumbnail_url = thumbnail + url = "/ver/$slug" + } + } +} + +@Serializable +data class SeriesTrendingDto( + @SerialName("visitas") val views: Int? = 0, +) + +@Serializable +data class SeriesGenderDto( + val gender: SeriesDetailDataNameDto, +) + +@Serializable +data class SeriesAuthorDto( + @SerialName("autor") val author: SeriesDetailDataNameDto, +) + +@Serializable +data class SeriesArtistDto( + val artist: SeriesDetailDataNameDto, +) + +@Serializable +data class SeriesDetailDataNameDto( + val name: String, +) + +@Serializable +data class SeriesChapterDto( + @SerialName("num") val number: Float, + val name: String? = null, + val slug: String, + @SerialName("created_at") val date: String, +)