feat: Add subanimes (#1109)

This commit is contained in:
Claudemirovsky
2022-12-24 20:11:45 -03:00
committed by GitHub
parent 10e56197dc
commit 9502f170c7
12 changed files with 569 additions and 0 deletions

View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="eu.kanade.tachiyomi.animeextension">
<application>
<activity
android:name=".pt.subanimes.SBUrlActivity"
android:excludeFromRecents="true"
android:exported="true"
android:theme="@android:style/Theme.NoDisplay">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="subanimes.cc"
android:pathPattern="/anime/..*"
android:scheme="https" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@ -0,0 +1,14 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlinx-serialization'
ext {
extName = 'SubAnimes'
pkgNameSuffix = 'pt.subanimes'
extClass = '.SubAnimes'
extVersionCode = 1
libVersion = '13'
}
apply from: "$rootDir/common.gradle"

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@ -0,0 +1,177 @@
package eu.kanade.tachiyomi.animeextension.pt.subanimes
import eu.kanade.tachiyomi.animesource.model.AnimeFilter
import eu.kanade.tachiyomi.animesource.model.AnimeFilterList
object SBFilters {
open class QueryPartFilter(
displayName: String,
val vals: Array<Pair<String, String>>
) : AnimeFilter.Select<String>(
displayName,
vals.map { it.first }.toTypedArray()
) {
fun toQueryPart() = vals[state].second
}
private class CheckBoxVal(name: String, state: Boolean = false) : AnimeFilter.CheckBox(name, state)
open class CheckBoxFilterList(name: String, values: List<CheckBox>) : AnimeFilter.Group<AnimeFilter.CheckBox>(name, values)
private inline fun <reified R> AnimeFilterList.getFirst(): R {
return this.filterIsInstance<R>().first()
}
private inline fun <reified R> AnimeFilterList.asQueryPart(): String {
return this.filterIsInstance<R>().joinToString("") {
(it as QueryPartFilter).toQueryPart()
}
}
class AdultFilter : AnimeFilter.CheckBox("Exibir animes adultos", true)
class FormatFilter : QueryPartFilter("Tipo de série", SBFiltersData.formats)
class StatusFilter : QueryPartFilter("Status do anime", SBFiltersData.status)
class TypeFilter : QueryPartFilter("Tipo de áudio", SBFiltersData.types)
class GenresFilter : CheckBoxFilterList(
"Gêneros",
SBFiltersData.genres.map { CheckBoxVal(it.first, false) }
)
// Mimicking the order of filters on the source
val filterList = AnimeFilterList(
TypeFilter(),
StatusFilter(),
AdultFilter(),
FormatFilter(),
GenresFilter()
)
data class FilterSearchParams(
val adult: Boolean = true,
val format: String = "",
val genres: List<String> = emptyList<String>(),
val status: String = "",
val type: String = "",
)
internal fun getSearchParameters(filters: AnimeFilterList): FilterSearchParams {
if (filters.isEmpty()) return FilterSearchParams()
val genres = filters.getFirst<GenresFilter>().state
.mapNotNull { genre ->
if (genre.state) {
SBFiltersData.genres.find { it.first == genre.name }!!.second
} else { null }
}.toList()
return FilterSearchParams(
filters.getFirst<AdultFilter>().state,
filters.asQueryPart<FormatFilter>(),
genres,
filters.asQueryPart<StatusFilter>(),
filters.asQueryPart<TypeFilter>()
)
}
private object SBFiltersData {
val every = Pair("Qualquer um", "")
val types = arrayOf(
every,
Pair("Japonês/Legendado", "1"),
Pair("Português/Dublado", "2")
)
val status = arrayOf(
every,
Pair("Completo", "Completo"),
Pair("Em lançamento", "Lançamento")
)
val formats = arrayOf(
every,
Pair("Anime", "Anime"),
Pair("Filme", "Filme")
)
val genres = arrayOf(
Pair("Adulto", "334"),
Pair("Animação", "2374"),
Pair("Arte Marcial", "16"),
Pair("Avant Garde", "2846"),
Pair("Avant", "2845"),
Pair("Aventura", "4"),
Pair("Ação", "15"),
Pair("Boys Love", "2435"),
Pair("Card Battles", "1157"),
Pair("Carro", "605"),
Pair("China", "865"),
Pair("Comédia Romântica", "1254"),
Pair("Comédia", "5"),
Pair("Corridas", "1514"),
Pair("Crime", "1962"),
Pair("Culinária", "925"),
Pair("Cultivo", "1133"),
Pair("Demônio", "19"),
Pair("Drama", "36"),
Pair("Ecchi", "49"),
Pair("Escolar", "140"),
Pair("Espacial", "646"),
Pair("Esporte", "106"),
Pair("Família", "1431"),
Pair("Fantasia", "6"),
Pair("Ficção Científica", "99"),
Pair("Ficção Mítica", "1575"),
Pair("Gathering", "2756"),
Pair("Gourmet", "2813"),
Pair("Harém", "189"),
Pair("Histórico", "20"),
Pair("Horror", "256"),
Pair("Insanidade", "387"),
Pair("Isekai", "10"),
Pair("Jogos", "63"),
Pair("Josei", "733"),
Pair("Magia", "82"),
Pair("Maid", "2772"),
Pair("Mecha", "200"),
Pair("Militar", "58"),
Pair("Mistério", "50"),
Pair("Musical", "112"),
Pair("Novel", "951"),
Pair("Paródia", "171"),
Pair("Policial", "249"),
Pair("Psicológico", "66"),
Pair("Pós-Apocalíptico", "470"),
Pair("Reencarnação", "1134"),
Pair("Romance", "7"),
Pair("Samurai", "127"),
Pair("Sci-fi", "203"),
Pair("Seinen", "51"),
Pair("Seven", "1449"),
Pair("Shoujo Ai", "507"),
Pair("Shoujo", "78"),
Pair("Shounen Ai", "1326"),
Pair("Shounen", "17"),
Pair("Slice of Life", "79"),
Pair("Sobrenatural", "8"),
Pair("Studio Deen", "2451"),
Pair("Sunrise", "318"),
Pair("Super Poder", "18"),
Pair("Suspense", "134"),
Pair("Terror", "42"),
Pair("Thriller", "960"),
Pair("Tragédia", "264"),
Pair("Vampiros", "358"),
Pair("Vida Diaria", "1518"),
Pair("Vida Escolar", "67"),
Pair("Violência", "59"),
Pair("Yaoi", "1386"),
Pair("Yuri", "243"),
Pair("Zumbi", "574")
)
}
}

View File

@ -0,0 +1,41 @@
package eu.kanade.tachiyomi.animeextension.pt.subanimes
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Intent
import android.os.Bundle
import android.util.Log
import kotlin.system.exitProcess
/**
* Springboard that accepts https://subanimes.cc/anime/<item> intents
* and redirects them to the main Aniyomi process.
*/
class SBUrlActivity : Activity() {
private val TAG = "SBUrlActivity"
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val pathSegments = intent?.data?.pathSegments
if (pathSegments != null && pathSegments.size > 1) {
val item = pathSegments[1]
val mainIntent = Intent().apply {
action = "eu.kanade.tachiyomi.ANIMESEARCH"
putExtra("query", "${SubAnimes.PREFIX_SEARCH}$item")
putExtra("filter", packageName)
}
try {
startActivity(mainIntent)
} catch (e: ActivityNotFoundException) {
Log.e(TAG, e.toString())
}
} else {
Log.e(TAG, "could not parse uri from intent $intent")
}
finish()
exitProcess(0)
}
}

View File

@ -0,0 +1,252 @@
package eu.kanade.tachiyomi.animeextension.pt.subanimes
import eu.kanade.tachiyomi.animeextension.pt.subanimes.dto.AnimeDataDto
import eu.kanade.tachiyomi.animeextension.pt.subanimes.dto.SearchResultDto
import eu.kanade.tachiyomi.animeextension.pt.subanimes.extractors.SubAnimesExtractor
import eu.kanade.tachiyomi.animesource.model.AnimeFilterList
import eu.kanade.tachiyomi.animesource.model.AnimesPage
import eu.kanade.tachiyomi.animesource.model.SAnime
import eu.kanade.tachiyomi.animesource.model.SEpisode
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.animesource.online.ParsedAnimeHttpSource
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.asObservableSuccess
import eu.kanade.tachiyomi.util.asJsoup
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import okhttp3.FormBody
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import rx.Observable
import uy.kohesive.injekt.api.get
import kotlin.Exception
class SubAnimes : ParsedAnimeHttpSource() {
override val name = "SubAnimes"
override val baseUrl = "https://subanimes.cc"
private val API_URL = "$baseUrl/wp-admin/admin-ajax.php"
override val lang = "pt-BR"
override val supportsLatest = true
override val client: OkHttpClient = network.client
private val json = Json {
ignoreUnknownKeys = true
}
// ============================== Popular ===============================
override fun popularAnimeSelector() = "div#hype div.aniItem > a"
override fun popularAnimeRequest(page: Int) = GET(baseUrl)
override fun popularAnimeNextPageSelector() = null // disable it
override fun popularAnimeFromElement(element: Element): SAnime =
latestUpdatesFromElement(element)
// ============================== Episodes ==============================
override fun episodeListSelector() = "div#episodios div.animeVideosItem > a"
private fun episodeListNextPageSelector() = latestUpdatesNextPageSelector()
override fun fetchEpisodeList(anime: SAnime): Observable<List<SEpisode>> {
return client.newCall(episodeListRequest(anime))
.asObservableSuccess()
.map { response ->
val realDoc = getRealDoc(response.asJsoup())
episodeListParse(realDoc).reversed()
}
}
override fun episodeListParse(response: Response): List<SEpisode> {
return episodeListParse(response.asJsoup())
}
private fun episodeListParse(doc: Document): List<SEpisode> {
val episodeList = mutableListOf<SEpisode>()
val eps = doc.select(episodeListSelector()).map(::episodeFromElement)
episodeList.addAll(eps)
val nextPageElement = doc.selectFirst(episodeListNextPageSelector())
if (nextPageElement != null) {
val nextUrl = nextPageElement.attr("href")
val res = client.newCall(GET(nextUrl)).execute()
episodeList.addAll(episodeListParse(res))
}
return episodeList
}
override fun episodeFromElement(element: Element): SEpisode {
return SEpisode.create().apply {
setUrlWithoutDomain(element.attr("href"))
val title = element.attr("title")
name = title
episode_number = runCatching {
title.trim().substringAfterLast(" ").toFloat()
}.getOrDefault(0F)
}
}
// ============================ Video Links =============================
override fun videoListParse(response: Response): List<Video> {
val doc = response.asJsoup()
val players = doc.select("section.playerTabs > div.playerTab").map {
val url = it.attr("data-player-url")
Pair(it.text(), url)
}.ifEmpty {
val defaultPlayer = doc.selectFirst("div.playerBoxInfra > iframe")
listOf(Pair("Default", defaultPlayer.attr("src")))
}
val videos = players.flatMap { (playerName, url) ->
SubAnimesExtractor(client).videoListFromUrl(url, playerName, headers)
}
return videos.reversed()
}
override fun videoListSelector() = throw Exception("not used")
override fun videoFromElement(element: Element) = throw Exception("not used")
override fun videoUrlParse(document: Document) = throw Exception("not used")
// =============================== Search ===============================
// We'll be using serialization in the search system,
// so those functions won't be used.
override fun searchAnimeFromElement(element: Element) = throw Exception("not used")
override fun searchAnimeSelector() = throw Exception("not used")
override fun searchAnimeNextPageSelector() = throw Exception("not used")
override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request = throw Exception("not used")
override fun getFilterList(): AnimeFilterList = SBFilters.filterList
override fun fetchSearchAnime(page: Int, query: String, filters: AnimeFilterList): Observable<AnimesPage> {
return if (query.startsWith(PREFIX_SEARCH)) {
val slug = query.removePrefix(PREFIX_SEARCH)
client.newCall(GET("$baseUrl/anime/$slug", headers))
.asObservableSuccess()
.map { searchAnimeBySlugParse(it, slug) }
} else {
val params = SBFilters.getSearchParameters(filters)
client.newCall(searchAnimeRequest(page, query, params))
.asObservableSuccess()
.map { searchAnimeParse(it, page) }
}
}
private fun searchAnimeBySlugParse(response: Response, slug: String): AnimesPage {
val details = animeDetailsParse(response)
details.url = "/anime/$slug"
return AnimesPage(listOf(details), false)
}
private fun searchAnimeRequest(page: Int, query: String, filters: SBFilters.FilterSearchParams): Request {
val body = FormBody.Builder().apply {
add("action", "anime_search")
add("posts_per_page", "12")
if (filters.adult)
add("age", "yes")
else
add("age", "no")
add("format", filters.format)
add("name", query)
add("paged", "$page")
add("status", filters.status)
add("audio_type", filters.type)
filters.genres.forEach { add("genres[]", it) }
}.build()
return POST(API_URL, body = body)
}
private fun searchAnimeParse(response: Response, page: Int): AnimesPage {
val searchData = runCatching {
response.parseAs<SearchResultDto>()
}.getOrDefault(SearchResultDto())
return if (searchData.errors != 0) {
AnimesPage(emptyList<SAnime>(), false)
} else {
val animes = searchData.animes.map(::searchAnimeParseFromObject)
val hasNextPage = searchData.pages > page
AnimesPage(animes, hasNextPage)
}
}
private fun searchAnimeParseFromObject(anime: AnimeDataDto): SAnime {
return SAnime.create().apply {
setUrlWithoutDomain(anime.info.url)
title = anime.info.title
thumbnail_url = anime.thumbnail.url
}
}
// =========================== Anime Details ============================
override fun animeDetailsParse(document: Document): SAnime {
val doc = getRealDoc(document)
return SAnime.create().apply {
val div = doc.selectFirst("div.leftAnime")
thumbnail_url = div.selectFirst("img").attr("src")
title = doc.selectFirst("section.page_title").text()
status = parseStatus(div.selectFirst("div.anime_status"))
val container = doc.selectFirst("div.sinopse_container")
genre = container.select("div.genders_container > span")
.joinToString(", ") { it.text() }
var desc = container.selectFirst("div.sinopse_content").text()
desc += "\n\n" + div.select("div.animeInfosItemSingle").joinToString("\n") {
val key = it.selectFirst("b").text()
val value = it.selectFirst("span").text()
"$key: $value"
}
description = desc
}
}
// =============================== Latest ===============================
override fun latestUpdatesNextPageSelector() = "div.paginacao > a.next"
override fun latestUpdatesSelector() = "div.epiItem > div.epiImg > a"
override fun latestUpdatesFromElement(element: Element): SAnime {
return SAnime.create().apply {
setUrlWithoutDomain(element.attr("href"))
title = element.attr("title")
thumbnail_url = element.selectFirst("img").attr("src")
}
}
override fun latestUpdatesRequest(page: Int): Request =
GET("$baseUrl/lista-de-episodios/page/$page/")
// ============================= Utilities ==============================
private fun getRealDoc(doc: Document): Document {
val controls = doc.selectFirst("div.episodioControles")
if (controls != null) {
val newUrl = controls.select("a").get(1)!!.attr("href")
val res = client.newCall(GET(newUrl)).execute()
return res.asJsoup()
} else {
return doc
}
}
private fun parseStatus(element: Element?): Int {
return when (element?.text()?.trim()) {
"Em Lançamento" -> SAnime.ONGOING
else -> SAnime.COMPLETED
}
}
private inline fun <reified T> Response.parseAs(): T {
val responseBody = body?.string().orEmpty()
return json.decodeFromString(responseBody)
}
companion object {
const val PREFIX_SEARCH = "slug:"
}
}

View File

@ -0,0 +1,28 @@
package eu.kanade.tachiyomi.animeextension.pt.subanimes.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class SearchResultDto(
val animes: List<AnimeDataDto> = emptyList(),
val errors: Int = 1,
@SerialName("total_pages")
val pages: Int = 0
)
@Serializable
data class AnimeDataDto(
@SerialName("data")
val info: AnimeInfoDto,
val thumbnail: ThumbnailDto
)
@Serializable
data class AnimeInfoDto(
val url: String,
val title: String
)
@Serializable
data class ThumbnailDto(val url: String)

View File

@ -0,0 +1,33 @@
package eu.kanade.tachiyomi.animeextension.pt.subanimes.extractors
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.util.asJsoup
import okhttp3.Headers
import okhttp3.OkHttpClient
class SubAnimesExtractor(private val client: OkHttpClient) {
fun videoListFromUrl(url: String, player: String, headers: Headers): List<Video> {
val iframeBody = client.newCall(GET(url)).execute().asJsoup()
val newHeaders = headers.newBuilder().set("Referer", url).build()
val script = iframeBody.selectFirst("script:containsData(addButton)").data()
return if (script.contains("vSources")) {
val sources = script.substringAfter("vSources").substringBefore(";")
sources.split("src\":").drop(1).map {
val url = it.substringAfter("\"")
.substringBefore("\"")
.replace("\\", "")
.trim()
val quality = it.substringAfter("size\":").substringBefore("}")
Video(url, "$player - ${quality}p", url, headers)
}
} else {
val url = script.substringAfter("file:")
.substringAfter("'")
.substringBefore("'")
.trim()
listOf(Video(url, player, url, newHeaders))
}
}
}