feat(src/pt): New source: Open Animes (#1582)

* feat(src/pt): Create OpenAnimes base

* feat(src/pt): Add latest updates page

* feat: Implement anime details page

* feat: Implement episode list page

* feat: Implement video extractor

* feat: Implement search animes page

* feat: Implement popular animes page

* fix: Put description in anime details

* chore: Add source icons

* feat: Add video quality preference

* feat: Add fallback extractor
This commit is contained in:
Claudemirovsky 2023-05-06 09:52:33 -03:00 committed by GitHub
parent d41abc0fff
commit 1fba090236
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 596 additions and 0 deletions

View File

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity
android:name=".pt.openanimes.OpenAnimesUrlActivity"
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="openanimes.com"
android:pathPattern="/animes/..*"
android:scheme="https" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@ -0,0 +1,14 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.serialization)
}
ext {
extName = 'Open Animes'
pkgNameSuffix = 'pt.openanimes'
extClass = '.OpenAnimes'
extVersionCode = 1
}
apply from: "$rootDir/common.gradle"

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@ -0,0 +1,304 @@
package eu.kanade.tachiyomi.animeextension.pt.openanimes
import android.app.Application
import android.content.SharedPreferences
import androidx.preference.ListPreference
import androidx.preference.PreferenceScreen
import eu.kanade.tachiyomi.animeextension.pt.openanimes.OpenAnimesFilters.FilterSearchParams
import eu.kanade.tachiyomi.animeextension.pt.openanimes.dto.SearchResultDto
import eu.kanade.tachiyomi.animeextension.pt.openanimes.extractors.BloggerExtractor
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.json.Json
import okhttp3.FormBody
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Request
import okhttp3.Response
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 uy.kohesive.injekt.injectLazy
import java.text.SimpleDateFormat
import java.util.Locale
class OpenAnimes : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
override val name = "Open Animes"
override val baseUrl = "https://openanimes.com"
override val lang = "pt-BR"
override val supportsLatest = true
override fun headersBuilder() = super.headersBuilder().add("Referer", baseUrl)
private val json: Json by injectLazy()
private val preferences: SharedPreferences by lazy {
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
}
// ============================== Popular ===============================
override fun popularAnimeFromElement(element: Element): SAnime {
throw UnsupportedOperationException("Not used.")
}
override fun popularAnimeNextPageSelector(): String? {
throw UnsupportedOperationException("Not used.")
}
override fun popularAnimeRequest(page: Int) = searchAnimeRequest(page, "", FilterSearchParams())
override fun popularAnimeParse(response: Response) = searchAnimeParse(response)
override fun popularAnimeSelector(): String {
throw UnsupportedOperationException("Not used.")
}
// ============================== Episodes ==============================
override fun episodeListParse(response: Response) =
super.episodeListParse(response).reversed()
override fun episodeFromElement(element: Element): SEpisode {
return SEpisode.create().apply {
setUrlWithoutDomain(element.attr("href"))
val title = element.selectFirst("div.tituloEP > h3")!!.text().trim()
name = title
date_upload = element.selectFirst("span.data")?.text().toDate()
episode_number = title.substringAfterLast(" ").toFloatOrNull() ?: 0F
}
}
override fun episodeListSelector() = "div.listaEp div.episodioItem > a"
// =========================== Anime Details ============================
override fun animeDetailsParse(document: Document): SAnime {
val doc = getRealDoc(document)
return SAnime.create().apply {
setUrlWithoutDomain(doc.location())
artist = doc.getInfo("Estúdio")
author = doc.getInfo("Autor") ?: doc.getInfo("Diretor")
description = doc.selectFirst("div.sinopseEP > p")?.text()
genre = doc.select("div.info span.cat > a").eachText().joinToString()
title = doc.selectFirst("div.tituloPrincipal > h1")!!.text()
.removePrefix("Assistir ")
.removeSuffix(" Temporada Online")
thumbnail_url = doc.selectFirst("div.thumb > img")!!.attr("data-lazy-src")
val statusStr = doc.selectFirst("li:contains(Status) > span[data]")?.text()
status = when (statusStr) {
"Completo" -> SAnime.COMPLETED
"Lançamento" -> SAnime.ONGOING
else -> SAnime.UNKNOWN
}
}
}
// ============================ Video Links =============================
override fun videoListParse(response: Response): List<Video> {
val playerUrl = response.use { it.asJsoup() }
.selectFirst("div.Link > a")
?.attr("href") ?: return emptyList()
return client.newCall(GET(playerUrl, headers)).execute()
.use {
val doc = it.asJsoup()
doc.selectFirst("iframe")?.attr("src")?.let { iframeUrl ->
BloggerExtractor(client).videosFromUrl(iframeUrl, headers)
} ?: run {
val videoUrl = doc.selectFirst("script:containsData(var jw =)")
?.data()
?.substringAfter("file\":\"")
?.substringBefore('"')
?.replace("\\", "")
?: return emptyList()
listOf(Video(videoUrl, "Default", videoUrl, headers))
}
}
}
override fun videoFromElement(element: Element): Video {
throw UnsupportedOperationException("Not used.")
}
override fun videoListSelector(): String {
throw UnsupportedOperationException("Not used.")
}
override fun videoUrlParse(document: Document): String {
throw UnsupportedOperationException("Not used.")
}
// =============================== Search ===============================
override fun searchAnimeFromElement(element: Element): SAnime {
throw UnsupportedOperationException("Not used.")
}
override fun searchAnimeNextPageSelector(): String? {
throw UnsupportedOperationException("Not used.")
}
private val searchToken by lazy {
client.newCall(GET("$baseUrl/lista-de-animes")).execute()
.use {
it.asJsoup().selectFirst("input#token")!!.attr("value")
}
}
override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request {
return searchAnimeRequest(page, query, OpenAnimesFilters.getSearchParameters(filters))
}
override fun getFilterList(): AnimeFilterList = OpenAnimesFilters.filterList
private fun searchAnimeRequest(page: Int, query: String, params: FilterSearchParams): Request {
val body = FormBody.Builder().apply {
add("action", "getListFilter")
add("token", searchToken)
add("filter_pagina", "$page")
val filters = baseUrl.toHttpUrl().newBuilder().apply {
addQueryParameter("filter_type", "animes")
addQueryParameter("filter_audio", params.audio)
addQueryParameter("filter_letter", params.initialLetter)
addQueryParameter("filter_ordem", params.sortBy)
addQueryParameter("filter_search", query.ifEmpty { "0" })
}.build().encodedQuery
val genres = params.genres.joinToString { "\"$it\"" }
add("filters", """{"filter_data": "$filters", "filter_genre": [$genres]}""")
}.build()
return POST("$baseUrl/wp-admin/admin-ajax.php", body = body, headers = headers)
}
override fun searchAnimeParse(response: Response): AnimesPage {
val data = response.use {
json.decodeFromString<SearchResultDto>(it.body.string())
}
val animes = data.results.map {
SAnime.create().apply {
title = it.title
thumbnail_url = it.thumbnail
setUrlWithoutDomain(it.permalink)
}
}
val hasNext = data.page.toIntOrNull()?.let { it < data.totalPage } ?: false
return AnimesPage(animes, hasNext)
}
override fun searchAnimeSelector(): String {
throw UnsupportedOperationException("Not used.")
}
override fun fetchSearchAnime(page: Int, query: String, filters: AnimeFilterList): Observable<AnimesPage> {
return if (query.startsWith(PREFIX_SEARCH)) { // URL intent handler
val id = query.removePrefix(PREFIX_SEARCH)
client.newCall(GET("$baseUrl/animes/$id"))
.asObservableSuccess()
.map(::searchAnimeByIdParse)
} else {
super.fetchSearchAnime(page, query, filters)
}
}
private fun searchAnimeByIdParse(response: Response): AnimesPage {
val details = animeDetailsParse(response.asJsoup())
return AnimesPage(listOf(details), false)
}
// =============================== Latest ===============================
override fun latestUpdatesFromElement(element: Element): SAnime {
return SAnime.create().apply {
element.selectFirst("a.thumb")!!.let {
setUrlWithoutDomain(it.attr("href"))
thumbnail_url = it.selectFirst("img")!!.attr("data-lazy-src")
}
title = element.selectFirst("h3 > a")!!.text()
}
}
override fun latestUpdatesNextPageSelector() = "div.pagination a.pagination__arrow--right"
override fun latestUpdatesRequest(page: Int) = GET("$baseUrl/lancamentos/page/$page")
override fun latestUpdatesSelector() = "div.contents div.itens > div"
// ============================== Settings ==============================
override fun setupPreferenceScreen(screen: PreferenceScreen) {
val preferredQuality = 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(preferredQuality)
}
// ============================= Utilities ==============================
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()
}
private fun getRealDoc(document: Document): Document {
return document.selectFirst("a:has(i.fa-grid)")?.let { link ->
client.newCall(GET(link.attr("href")))
.execute()
.asJsoup()
} ?: document
}
private fun Element.getInfo(key: String): String? {
return selectFirst("div.info li:has(span:containsOwn($key))")
?.ownText()
?.trim()
}
private fun String?.toDate(): Long {
return this?.let {
runCatching {
DATE_FORMATTER.parse(this)?.time
}.getOrNull()
} ?: 0L
}
companion object {
private val DATE_FORMATTER by lazy {
SimpleDateFormat("d 'de' MMMM 'de' yyyy", Locale("pt", "BR"))
}
const val PREFIX_SEARCH = "id:"
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("360p", "720p")
}
}

View File

@ -0,0 +1,163 @@
package eu.kanade.tachiyomi.animeextension.pt.openanimes
import eu.kanade.tachiyomi.animesource.model.AnimeFilter
import eu.kanade.tachiyomi.animesource.model.AnimeFilterList
object OpenAnimesFilters {
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 InitialLetterFilter : QueryPartFilter("Primeira letra", OpenAnimesFiltersData.initialLetter)
class SortFilter : AnimeFilter.Sort(
"Ordenar",
OpenAnimesFiltersData.orders.map { it.first }.toTypedArray(),
Selection(0, false),
)
class AudioFilter : QueryPartFilter("Língua/Áudio", OpenAnimesFiltersData.audios)
class GenresFilter : CheckBoxFilterList(
"Gêneros",
OpenAnimesFiltersData.genres.map { CheckBoxVal(it.first, false) },
)
val filterList = AnimeFilterList(
InitialLetterFilter(),
SortFilter(),
AudioFilter(),
AnimeFilter.Separator(),
GenresFilter(),
)
data class FilterSearchParams(
val sortBy: String = "popu-des",
val audio: String = "0",
val initialLetter: String = "0",
val genres: List<String> = emptyList(),
)
internal fun getSearchParameters(filters: AnimeFilterList): FilterSearchParams {
if (filters.isEmpty()) return FilterSearchParams()
val order = filters.getFirst<SortFilter>().state?.let {
val order = OpenAnimesFiltersData.orders[it.index].second
when {
it.ascending -> "$order-asc"
else -> "$order-des"
}
} ?: "popu-des"
val genres = filters.getFirst<GenresFilter>().state
.mapNotNull { genre ->
if (genre.state) {
OpenAnimesFiltersData.genres.find { it.first == genre.name }!!.second
} else { null }
}.toList()
return FilterSearchParams(
order,
filters.asQueryPart<AudioFilter>(),
filters.asQueryPart<InitialLetterFilter>(),
genres,
)
}
private object OpenAnimesFiltersData {
val orders = arrayOf(
Pair("Populares", "popu"),
Pair("Alfabética", "alfa"),
Pair("Lançamento", "lancamento"),
)
val initialLetter = arrayOf(Pair("Selecione", "0")) + ('A'..'Z').map {
Pair(it.toString(), it.toString().lowercase())
}.toTypedArray()
val audios = arrayOf(
Pair("Todos", "0"),
Pair("Legendado", "legendado"),
Pair("Dublado", "dublado"),
)
val genres = arrayOf(
Pair("Ação", "7"),
Pair("Adaptação de Manga", "49"),
Pair("Animação", "11"),
Pair("Artes Marciais", "8"),
Pair("ASMR", "65"),
Pair("Aventura", "5"),
Pair("Bishounen", "45"),
Pair("Boys Love", "67"),
Pair("Comédia", "9"),
Pair("Comédia Romântica", "44"),
Pair("Cotidiano", "56"),
Pair("Demônios", "35"),
Pair("Drama", "20"),
Pair("Ecchi", "31"),
Pair("Escolar", "38"),
Pair("Esporte", "21"),
Pair("Fantasia", "12"),
Pair("Fatia de Vida", "66"),
Pair("Ficção Científica", "23"),
Pair("Game", "58"),
Pair("Harém", "36"),
Pair("Histórico", "33"),
Pair("Infantil", "62"),
Pair("Isekai", "59"),
Pair("Jogos", "14"),
Pair("Magia", "13"),
Pair("Mecha", "42"),
Pair("Militar", "26"),
Pair("Mistério", "24"),
Pair("Mitologia", "72"),
Pair("Musica", "70"),
Pair("Musical", "34"),
Pair("Paródia", "63"),
Pair("Policial", "30"),
Pair("Psicológico", "39"),
Pair("Romance", "15"),
Pair("Ryuri", "41"),
Pair("Samurai", "32"),
Pair("School", "55"),
Pair("Sci-fi", "48"),
Pair("Seinen", "27"),
Pair("Shoujo", "17"),
Pair("Shoujo-ai", "47"),
Pair("Shounen", "4"),
Pair("Shounen Ai", "57"),
Pair("Sitcom", "61"),
Pair("Slice Of Life", "19"),
Pair("Sobrenatural", "18"),
Pair("Super Poder", "6"),
Pair("Suspense", "25"),
Pair("Terror", "22"),
Pair("Thriller", "43"),
Pair("Vampiros", "28"),
Pair("Vida escolar", "16"),
Pair("Yaoi", "64"),
Pair("Yuri", "40"),
)
}
}

View File

@ -0,0 +1,45 @@
package eu.kanade.tachiyomi.animeextension.pt.openanimes
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://openanimes.com/anime/<item> intents
* and redirects them to the main Aniyomi process.
*/
class OpenAnimesUrlActivity : Activity() {
private val TAG = javaClass.simpleName
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val pathSegments = intent?.data?.pathSegments
if (pathSegments != null && pathSegments.size > 1) {
// https://<host>/<segment 0>/<segment 1>...
// ex: pattern "/anime/..*" -> pathSegments[1]
// ex: pattern "/anime/info/..*" -> pathSegments[2]
// etc..
val item = pathSegments[1]
val mainIntent = Intent().apply {
action = "eu.kanade.tachiyomi.ANIMESEARCH"
putExtra("query", "${OpenAnimes.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,21 @@
package eu.kanade.tachiyomi.animeextension.pt.openanimes.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class SearchResultDto(
val page: String = "1",
@SerialName("total_page")
val totalPage: Int = 0,
val results: List<AnimeDto> = emptyList(),
)
@Serializable
data class AnimeDto(
val permalink: String,
@SerialName("imagem")
val thumbnail: String,
@SerialName("titulo")
val title: String,
)

View File

@ -0,0 +1,26 @@
package eu.kanade.tachiyomi.animeextension.pt.openanimes.extractors
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.network.GET
import okhttp3.Headers
import okhttp3.OkHttpClient
class BloggerExtractor(private val client: OkHttpClient) {
fun videosFromUrl(url: String, headers: Headers): List<Video> {
return client.newCall(GET(url, headers)).execute()
.use { it.body.string() }
.substringAfter("\"streams\":[")
.substringBefore("]")
.split("},")
.map {
val videoUrl = it.substringAfter("{\"play_url\":\"").substringBefore('"')
val format = it.substringAfter("\"format_id\":").substringBefore("}")
val quality = when (format) {
"18" -> "360p"
"22" -> "720p"
else -> "Unknown"
}
Video(videoUrl, quality, videoUrl, headers = headers)
}
}
}