Re-Add BetterAnime (#1433)

* feat(src/pt): Re-Add BetterAnime

* fix: Solve compilation errors

* refactor: General refactoration

* feat: Add warning to non-logged users

* chore: Bump version
This commit is contained in:
Claudemirovsky
2023-03-22 23:00:38 -03:00
committed by GitHub
parent be6742efc2
commit bbae30385d
13 changed files with 674 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.betteranime.BAUrlActivity"
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="betteranime.net"
android:pathPattern="/..*/..*/..*"
android:scheme="https" />
</intent-filter>
</activity>
</application>
</manifest>

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1,132 @@
package eu.kanade.tachiyomi.animeextension.pt.betteranime
import eu.kanade.tachiyomi.animesource.model.AnimeFilter
import eu.kanade.tachiyomi.animesource.model.AnimeFilterList
object BAFilters {
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
}
open class CheckBoxFilterList(name: String, values: List<CheckBox>) : AnimeFilter.Group<AnimeFilter.CheckBox>(name, values)
private class CheckBoxVal(name: String, state: Boolean = false) : AnimeFilter.CheckBox(name, state)
private inline fun <reified R> AnimeFilterList.getFirst(): R {
return first { it is R } as R
}
private inline fun <reified R> AnimeFilterList.asQueryPart(): String {
return getFirst<R>().let {
(it as QueryPartFilter).toQueryPart()
}
}
class LanguageFilter : QueryPartFilter("Idioma", BAFiltersData.languages)
class YearFilter : QueryPartFilter("Ano", BAFiltersData.years)
class GenresFilter : CheckBoxFilterList(
"Gêneros",
BAFiltersData.genres.map { CheckBoxVal(it.first, false) },
)
val filterList = AnimeFilterList(
LanguageFilter(),
YearFilter(),
GenresFilter(),
)
data class FilterSearchParams(
val language: String = "",
val year: String = "",
val genres: List<String> = emptyList<String>(),
)
internal fun getSearchParameters(filters: AnimeFilterList): FilterSearchParams {
if (filters.isEmpty()) return FilterSearchParams()
val genres = listOf("") + filters.getFirst<GenresFilter>().state
.mapNotNull { genre ->
if (genre.state) {
BAFiltersData.genres.find { it.first == genre.name }!!.second
} else { null }
}.toList()
return FilterSearchParams(
filters.asQueryPart<LanguageFilter>(),
filters.asQueryPart<YearFilter>(),
genres,
)
}
private object BAFiltersData {
val every = Pair("Qualquer um", "")
val languages = arrayOf(
every,
Pair("Legendado", "legendado"),
Pair("Dublado", "dublado"),
)
val years = arrayOf(every) + (2023 downTo 1976).map {
Pair(it.toString(), it.toString())
}.toTypedArray()
val genres = arrayOf(
Pair("Ação", "acao"),
Pair("Artes Marciais", "artes-marciais"),
Pair("Aventura", "aventura"),
Pair("Comédia", "comedia"),
Pair("Cotidiano", "cotidiano"),
Pair("Demência", "demencia"),
Pair("Demônios", "demonios"),
Pair("Drama", "drama"),
Pair("Ecchi", "ecchi"),
Pair("Escolar", "escolar"),
Pair("Espacial", "espacial"),
Pair("Esportes", "esportes"),
Pair("Fantasia", "fantasia"),
Pair("Ficção Científica", "ficcao-cientifica"),
Pair("Game", "game"),
Pair("Harém", "harem"),
Pair("Histórico", "historico"),
Pair("Horror", "horror"),
Pair("Infantil", "infantil"),
Pair("Josei", "josei"),
Pair("Magia", "magia"),
Pair("Mecha", "mecha"),
Pair("Militar", "militar"),
Pair("Mistério", "misterio"),
Pair("Musical", "musical"),
Pair("Paródia", "parodia"),
Pair("Policial", "policial"),
Pair("Psicológico", "psicologico"),
Pair("Romance", "romance"),
Pair("Samurai", "samurai"),
Pair("Sci-Fi", "sci-fi"),
Pair("Seinen", "seinen"),
Pair("Shoujo-Ai", "shoujo-ai"),
Pair("Shoujo", "shoujo"),
Pair("Shounen-Ai", "shounen-ai"),
Pair("Shounen", "shounen"),
Pair("Slice of Life", "slice-of-life"),
Pair("Sobrenatural", "sobrenatural"),
Pair("Super Poderes", "super-poderes"),
Pair("Suspense", "suspense"),
Pair("Terror", "terror"),
Pair("Thriller", "thriller"),
Pair("Tragédia", "tragedia"),
Pair("Vampiros", "vampiros"),
Pair("Vida Escolar", "vida-escolar"),
Pair("Yaoi", "yaoi"),
Pair("Yuri", "yuri"),
)
}
}

View File

@ -0,0 +1,44 @@
package eu.kanade.tachiyomi.animeextension.pt.betteranime
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://betteranime.net/<type>/<lang>/<item> intents
* and redirects them to the main Aniyomi process.
*/
class BAUrlActivity : Activity() {
private val TAG = "BAUrlActivity"
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val pathSegments = intent?.data?.pathSegments
if (pathSegments != null && pathSegments.size > 1) {
val type = pathSegments[0]
val lang = pathSegments[1]
val item = pathSegments[2]
val searchQuery = "$type/$lang/$item"
val mainIntent = Intent().apply {
action = "eu.kanade.tachiyomi.ANIMESEARCH"
putExtra("query", "${BetterAnime.PREFIX_SEARCH_PATH}$searchQuery")
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,12 @@
package eu.kanade.tachiyomi.animeextension.pt.betteranime
// Terrible way to reinvent the wheel, i just didnt wanted to use apache commons.
fun String.unescape(): String {
return UNICODE_REGEX.replace(this) {
it.groupValues[1]
.toInt(16)
.toChar()
.toString()
}.replace("\\", "")
}
private val UNICODE_REGEX = "\\\\u(\\d+)".toRegex()

View File

@ -0,0 +1,320 @@
package eu.kanade.tachiyomi.animeextension.pt.betteranime
import android.app.Application
import android.content.SharedPreferences
import androidx.preference.ListPreference
import androidx.preference.PreferenceScreen
import eu.kanade.tachiyomi.animeextension.pt.betteranime.dto.LivewireResponseDto
import eu.kanade.tachiyomi.animeextension.pt.betteranime.dto.PayloadData
import eu.kanade.tachiyomi.animeextension.pt.betteranime.dto.PayloadItem
import eu.kanade.tachiyomi.animeextension.pt.betteranime.extractors.BetterAnimeExtractor
import eu.kanade.tachiyomi.animesource.ConfigurableAnimeSource
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.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonPrimitive
import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import rx.Observable
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.io.IOException
import java.lang.Exception
class BetterAnime : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
override val name = "Better Anime"
override val baseUrl = "https://betteranime.net"
override val lang = "pt-BR"
override val supportsLatest = true
override val client: OkHttpClient = network.cloudflareClient.newBuilder()
.addInterceptor(::loginInterceptor)
.build()
private val json = Json {
ignoreUnknownKeys = true
}
private val preferences: SharedPreferences by lazy {
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
}
override fun headersBuilder() = super.headersBuilder()
.add("Referer", baseUrl)
.add("Accept-Language", ACCEPT_LANGUAGE)
// ============================== Popular ===============================
// The site doesn't have a popular anime tab, so we use the latest anime page instead.
override fun fetchPopularAnime(page: Int) = super.fetchLatestUpdates(page)
override fun popularAnimeSelector() = throw Exception("not used")
override fun popularAnimeFromElement(element: Element) = throw Exception("not used")
override fun popularAnimeRequest(page: Int) = throw Exception("not used")
override fun popularAnimeNextPageSelector() = null
// ============================== Episodes ==============================
override fun episodeListSelector(): String = "ul#episodesList > li.list-group-item-action > a"
override fun episodeListParse(response: Response) =
super.episodeListParse(response).reversed()
override fun episodeFromElement(element: Element): SEpisode {
return SEpisode.create().apply {
val episodeName = element.text()
setUrlWithoutDomain(element.attr("href"))
name = episodeName
episode_number = runCatching {
episodeName.substringAfterLast(" ").toFloat()
}.getOrDefault(0F)
}
}
// ============================ Video Links =============================
override fun videoListParse(response: Response): List<Video> {
val html = response.body.string()
val extractor = BetterAnimeExtractor(client, baseUrl, json)
return extractor.videoListFromHtml(html)
}
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 ===============================
override fun searchAnimeFromElement(element: Element) = latestUpdatesFromElement(element)
override fun searchAnimeSelector() = latestUpdatesSelector()
override fun searchAnimeNextPageSelector() = latestUpdatesNextPageSelector()
override fun searchAnimeParse(response: Response): AnimesPage {
val body = response.body.string()
val data = json.decodeFromString<LivewireResponseDto>(body)
val html = data.effects.html?.unescape() ?: ""
val document = Jsoup.parse(html)
val animes = document.select(searchAnimeSelector()).map { element ->
searchAnimeFromElement(element)
}
val hasNext = document.selectFirst(searchAnimeNextPageSelector()) != null
return AnimesPage(animes, hasNext)
}
override fun fetchSearchAnime(page: Int, query: String, filters: AnimeFilterList): Observable<AnimesPage> {
return if (query.startsWith(PREFIX_SEARCH_PATH)) {
val path = query.removePrefix(PREFIX_SEARCH_PATH)
client.newCall(GET("$baseUrl/$path"))
.asObservableSuccess()
.map { response ->
searchAnimeByPathParse(response, path)
}
} else {
super.fetchSearchAnime(page, query, filters)
}
}
private fun searchAnimeByPathParse(response: Response, path: String): AnimesPage {
val details = animeDetailsParse(response)
details.url = "/$path"
return AnimesPage(listOf(details), false)
}
override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request {
val searchParams = buildList {
add(PayloadItem(PayloadData(method = "search"), "callMethod"))
add(
PayloadItem(
PayloadData(
method = "gotoPage",
params = listOf(
JsonPrimitive(page),
JsonPrimitive("page"),
),
),
"callMethod",
),
)
val params = BAFilters.getSearchParameters(filters)
val data = buildList {
if (params.genres.size > 1) {
add(PayloadData(name = "byGenres", value = params.genres))
}
listOf(
params.year to "byYear",
params.language to "byLanguage",
query to "searchTerm",
).forEach { it.first.toPayloadData(it.second)?.let(::add) }
}
addAll(data.map { PayloadItem(it, "syncInput") })
}
return wireRequest("anime-search", searchParams)
}
// =========================== Anime Details ============================
override fun animeDetailsParse(document: Document): SAnime {
return SAnime.create().apply {
val doc = getRealDoc(document)
val infos = doc.selectFirst("div.infos_left > div.anime-info")!!
val img = doc.selectFirst("div.infos-img > img")!!
thumbnail_url = "https:" + img.attr("src")
title = img.attr("alt")
genre = infos.select("div.anime-genres > a")
.eachText()
.joinToString()
author = infos.getInfo("Produtor")
artist = infos.getInfo("Estúdio")
status = parseStatus(infos.getInfo("Estado"))
var desc = infos.selectFirst("div.anime-description")!!.text() + "\n\n"
desc += infos.select(">p").eachText().joinToString("\n")
description = desc
}
}
// =============================== Latest ===============================
override fun latestUpdatesNextPageSelector() = "ul.pagination li.page-item:contains():not(.disabled)"
override fun latestUpdatesSelector() = "div.list-animes article"
override fun latestUpdatesRequest(page: Int): Request =
GET("$baseUrl/ultimosLancamentos?page=$page")
override fun latestUpdatesFromElement(element: Element): SAnime {
return SAnime.create().apply {
val img = element.selectFirst("img")!!
val url = element.selectFirst("a")?.attr("href")!!
setUrlWithoutDomain(url)
title = element.selectFirst("h3")?.text()!!
thumbnail_url = "https:" + img.attr("src")
}
}
// ============================== Settings ==============================
override fun setupPreferenceScreen(screen: PreferenceScreen) {
val videoQualityPref = ListPreference(screen.context).apply {
key = PREF_QUALITY_KEY
title = PREF_QUALITY_TITLE
entries = PREF_QUALITY_VALUES
entryValues = PREF_QUALITY_VALUES
setDefaultValue(PREF_QUALITY_DEFAULT)
summary = "%s"
setOnPreferenceChangeListener { _, newValue ->
val selected = newValue as String
val index = findIndexOfValue(selected)
val entry = entryValues[index] as String
preferences.edit().putString(key, entry).commit()
}
}
screen.addPreference(videoQualityPref)
}
override fun getFilterList(): AnimeFilterList = BAFilters.filterList
// ============================= Utilities ==============================
private fun loginInterceptor(chain: Interceptor.Chain): Response {
val response = chain.proceed(chain.request())
if ("/dmca" in response.request.url.toString()) {
response.close()
throw IOException(ERROR_LOGIN_MISSING)
}
return response
}
private fun getRealDoc(document: Document): Document {
return document.selectFirst("div.anime-title a")?.let { link ->
client.newCall(GET(link.attr("href")))
.execute()
.asJsoup()
} ?: document
}
private fun parseStatus(statusString: String?): Int {
return when (statusString?.trim()) {
"Completo" -> SAnime.COMPLETED
else -> SAnime.ONGOING
}
}
private var INITIAL_DATA: String = ""
private var WIRE_TOKEN: String = ""
private fun updateInitialData(request: Request) {
val document = client.newCall(request).execute().asJsoup()
val wireElement = document.selectFirst("[wire:id]")
WIRE_TOKEN = document.html()
.substringAfter("livewire_token")
.substringAfter("'")
.substringBefore("'")
INITIAL_DATA = wireElement!!.attr("wire:initial-data").dropLast(1)
}
private fun wireRequest(path: String, updates: List<PayloadItem>): Request {
if (WIRE_TOKEN.isBlank()) {
updateInitialData(GET("$baseUrl/pesquisa"))
}
val url = "$baseUrl/livewire/message/$path"
val items = updates.joinToString(",") { json.encodeToString(it) }
val data = "$INITIAL_DATA, \"updates\": [$items]}"
val reqBody = data.toRequestBody("application/json".toMediaType())
val headers = headersBuilder()
.add("x-livewire", "true")
.add("x-csrf-token", WIRE_TOKEN)
.build()
return POST(url, headers, reqBody)
}
private fun Element.getInfo(key: String): String? {
return selectFirst("p:containsOwn($key) > span")
?.text()
?.trim()
}
private inline fun String.toPayloadData(name: String): PayloadData? {
return when {
isNotBlank() -> PayloadData(name = name, value = listOf(this))
else -> null
}
}
override fun List<Video>.sort(): List<Video> {
val quality = preferences.getString(PREF_QUALITY_KEY, PREF_QUALITY_DEFAULT)!!
return sortedWith(
compareBy { it.quality.contains(quality) },
).reversed()
}
companion object {
private const val ACCEPT_LANGUAGE = "pt-BR,pt;q=0.9,en-US;q=0.8,en;q=0.7"
const val PREFIX_SEARCH_PATH = "path:"
private const val ERROR_LOGIN_MISSING = "Login necessário. " +
"Abra a WebView, insira os dados de sua conta e realize o login."
private const val PREF_QUALITY_KEY = "preferred_quality"
private const val PREF_QUALITY_TITLE = "Qualidade preferida"
private const val PREF_QUALITY_DEFAULT = "720p"
private val PREF_QUALITY_VALUES = arrayOf("480p", "720p", "1080p")
}
}

View File

@ -0,0 +1,55 @@
package eu.kanade.tachiyomi.animeextension.pt.betteranime.dto
import kotlinx.serialization.EncodeDefault
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonTransformingSerializer
@Serializable
data class ChangePlayerDto(
val frameLink: String? = null,
)
@Serializable
data class LivewireResponseDto(
val effects: LivewireEffects,
)
@Serializable
data class LivewireEffects(
val html: String? = null,
)
@ExperimentalSerializationApi
@Serializable
data class PayloadItem(
val payload: PayloadData,
val type: String,
)
@ExperimentalSerializationApi
@Serializable
data class PayloadData(
val name: String = "",
val method: String = "",
@Serializable(with = ValueSerializer::class)
val value: List<String> = emptyList<String>(),
@EncodeDefault val params: List<JsonElement> = emptyList<JsonElement>(),
@EncodeDefault val id: String = "",
)
object ValueSerializer : JsonTransformingSerializer<List<String>>(
ListSerializer(String.serializer()),
) {
override fun transformSerialize(element: JsonElement): JsonElement {
require(element is JsonArray)
if (element.size > 1) {
return JsonArray(element.drop(1))
}
return element.first()
}
}

View File

@ -0,0 +1,74 @@
package eu.kanade.tachiyomi.animeextension.pt.betteranime.extractors
import eu.kanade.tachiyomi.animeextension.pt.betteranime.dto.ChangePlayerDto
import eu.kanade.tachiyomi.animeextension.pt.betteranime.unescape
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import okhttp3.Headers
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.toRequestBody
class BetterAnimeExtractor(
private val client: OkHttpClient,
private val baseUrl: String,
private val json: Json,
) {
private val headers = Headers.headersOf("Referer", baseUrl)
fun videoListFromHtml(html: String): List<Video> {
val qualities = REGEX_QUALITIES.findAll(html).map {
Pair(it.groupValues[1], it.groupValues[2])
}.toList()
val token = html.substringAfter("_token:\"").substringBefore("\"")
return qualities.parallelMap { (quality, qtoken) ->
videoUrlFromToken(qtoken, token)?.let { videoUrl ->
Video(videoUrl, quality, videoUrl)
}
}.filterNotNull()
}
private fun videoUrlFromToken(qtoken: String, _token: String): String? {
val body = """
{
"_token": "$_token",
"info": "$qtoken"
}
""".trimIndent()
val reqBody = body.toRequestBody("application/json".toMediaType())
val request = POST("$baseUrl/changePlayer", headers, reqBody)
return runCatching {
val response = client.newCall(request).execute()
val resJson = json.decodeFromString<ChangePlayerDto>(response.body.string())
resJson.frameLink?.let(::videoUrlFromPlayer)
}.getOrNull()
}
private fun videoUrlFromPlayer(url: String): String {
val html = client.newCall(GET(url, headers)).execute().body.string()
val videoUrl = html.substringAfter("file\":")
.substringAfter("\"")
.substringBefore("\"")
.unescape()
return videoUrl
}
private fun <A, B> Iterable<A>.parallelMap(f: suspend (A) -> B): List<B> =
runBlocking(Dispatchers.Default) {
map { async { f(it) } }.awaitAll()
}
companion object {
private val REGEX_QUALITIES = """qualityString\["(\w+)"\] = "(\S+)"""".toRegex()
}
}