diff --git a/src/es/brakeout/AndroidManifest.xml b/src/es/brakeout/AndroidManifest.xml new file mode 100644 index 000000000..8072ee00d --- /dev/null +++ b/src/es/brakeout/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/src/es/brakeout/build.gradle b/src/es/brakeout/build.gradle new file mode 100644 index 000000000..aab778909 --- /dev/null +++ b/src/es/brakeout/build.gradle @@ -0,0 +1,13 @@ +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply plugin: 'kotlinx-serialization' + +ext { + extName = 'Brakeout' + pkgNameSuffix = 'es.brakeout' + extClass = '.Brakeout' + extVersionCode = 1 + isNsfw = false +} + +apply from: "$rootDir/common.gradle" diff --git a/src/es/brakeout/res/mipmap-hdpi/ic_launcher.png b/src/es/brakeout/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..dc81be622 Binary files /dev/null and b/src/es/brakeout/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/es/brakeout/res/mipmap-mdpi/ic_launcher.png b/src/es/brakeout/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..669a1dffa Binary files /dev/null and b/src/es/brakeout/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/es/brakeout/res/mipmap-xhdpi/ic_launcher.png b/src/es/brakeout/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..995fba252 Binary files /dev/null and b/src/es/brakeout/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/es/brakeout/res/mipmap-xxhdpi/ic_launcher.png b/src/es/brakeout/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..db75c7495 Binary files /dev/null and b/src/es/brakeout/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/es/brakeout/res/mipmap-xxxhdpi/ic_launcher.png b/src/es/brakeout/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..c303c6392 Binary files /dev/null and b/src/es/brakeout/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/es/brakeout/res/web_hi_res_512.png b/src/es/brakeout/res/web_hi_res_512.png new file mode 100644 index 000000000..2d73619c8 Binary files /dev/null and b/src/es/brakeout/res/web_hi_res_512.png differ diff --git a/src/es/brakeout/src/eu/kanade/tachiyomi/extension/es/brakeout/Brakeout.kt b/src/es/brakeout/src/eu/kanade/tachiyomi/extension/es/brakeout/Brakeout.kt new file mode 100644 index 000000000..038effd2e --- /dev/null +++ b/src/es/brakeout/src/eu/kanade/tachiyomi/extension/es/brakeout/Brakeout.kt @@ -0,0 +1,182 @@ +package eu.kanade.tachiyomi.extension.es.brakeout + +import eu.kanade.tachiyomi.network.GET +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.ParsedHttpSource +import eu.kanade.tachiyomi.util.asJsoup +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +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.Document +import org.jsoup.nodes.Element +import uy.kohesive.injekt.injectLazy +import java.lang.IllegalArgumentException +import java.util.Calendar + +class Brakeout : ParsedHttpSource() { + + override val name = "Brakeout" + + override val baseUrl = "https://brakeout.xyz" + + override val lang = "es" + + override val supportsLatest = true + + override val client: OkHttpClient = network.client.newBuilder() + .rateLimitHost(baseUrl.toHttpUrl(), 2) + .build() + + override fun headersBuilder(): Headers.Builder = Headers.Builder() + .add("Referer", baseUrl) + + private val json: Json by injectLazy() + + override fun popularMangaRequest(page: Int): Request = GET(baseUrl, headers) + + override fun popularMangaSelector(): String = "div#div-diario figure, div#div-semanal figure, div#div-mensual figure" + + override fun popularMangaNextPageSelector(): String? = null + + override fun popularMangaParse(response: Response): MangasPage { + val mangasPage = super.popularMangaParse(response) + val distinctList = mangasPage.mangas.distinctBy { it.url } + + return MangasPage(distinctList, mangasPage.hasNextPage) + } + + override fun popularMangaFromElement(element: Element): SManga = SManga.create().apply { + thumbnail_url = element.selectFirst("img")!!.attr("abs:src") + title = element.selectFirst("figcaption")!!.text() + setUrlWithoutDomain(element.selectFirst("a")!!.attr("href")) + } + + override fun latestUpdatesRequest(page: Int): Request = GET(baseUrl, headers) + + override fun latestUpdatesSelector(): String = "section.flex > div.grid > figure" + + override fun latestUpdatesNextPageSelector(): String? = null + + override fun latestUpdatesFromElement(element: Element): SManga = SManga.create().apply { + thumbnail_url = element.selectFirst("img")!!.attr("abs:src") + title = element.selectFirst("figcaption")!!.text() + setUrlWithoutDomain(element.selectFirst("a")!!.attr("href")) + } + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + if (query.isNotEmpty()) { + if (query.length > 1) return GET("$baseUrl/comics#$query", headers) + throw Exception("La búsqueda debe tener al menos 2 caracteres") + } + return GET("$baseUrl/comics?page=$page", headers) + } + + override fun searchMangaSelector(): String = "section.flex > div.grid > figure" + + override fun searchMangaNextPageSelector(): String = "main.container section.flex > div > a:containsOwn(Siguiente)" + + override fun searchMangaParse(response: Response): MangasPage { + val query = response.request.url.fragment ?: return super.searchMangaParse(response) + val document = response.asJsoup() + val mangas = parseMangaList(document, query) + return MangasPage(mangas, false) + } + + private fun parseMangaList(document: Document, query: String): List { + val docString = document.toString() + val mangaListJson = JSON_PROJECT_LIST.find(docString)?.destructured?.toList()?.get(0).orEmpty() + + return try { + json.decodeFromString>(mangaListJson) + .filter { it.title.contains(query, ignoreCase = true) } + .map { + SManga.create().apply { + title = it.title + thumbnail_url = it.thumbnail + url = "/ver/${it.id}/${it.slug}" + } + } + } catch (_: IllegalArgumentException) { + emptyList() + } + } + + override fun searchMangaFromElement(element: Element): SManga = SManga.create().apply { + thumbnail_url = element.selectFirst("img")!!.attr("abs:src") + title = element.selectFirst("figcaption")!!.text() + setUrlWithoutDomain(element.selectFirst("a")!!.attr("href")) + } + + override fun mangaDetailsParse(document: Document): SManga = SManga.create().apply { + with(document.select("section#section-sinopsis")) { + description = select("p").text() + genre = select("div.flex:has(div:containsOwn(Géneros)) > div > a > span").joinToString { it.text() } + } + } + + override fun chapterListSelector(): String = "section#section-list-cap div.grid-capitulos > div > a.group" + + override fun chapterFromElement(element: Element): SChapter = SChapter.create().apply { + setUrlWithoutDomain(element.attr("href")) + name = element.selectFirst("div#name")!!.text() + date_upload = parseRelativeDate(element.selectFirst("time")!!.text()) + } + + override fun pageListParse(document: Document): List { + return document.select("section > div > img.readImg").mapIndexed { i, element -> + Page(i, "", element.attr("abs:src")) + } + } + + override fun imageUrlParse(document: Document): String = throw UnsupportedOperationException("Not used!") + + override fun getFilterList(): FilterList { + return FilterList( + Filter.Header("Limpie la barra de búsqueda y haga click en 'Filtrar' para mostrar todas las series."), + ) + } + + 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", "dia").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) } + } + + @Serializable + data class SerieDto( + val id: Int, + @SerialName("nombre") val title: String, + val slug: String, + @SerialName("portada") val thumbnail: String, + ) + + companion object { + private val JSON_PROJECT_LIST = """proyectos\s*=\s*(\[[\s\S]+?\])\s*;""".toRegex() + } +}