New source: SukiAnimes (#1102)

* feat: SukiAnimes base, nothing works for now

* feat: Implement latest animes page

* feat: Implement anime details page

* feat: Implement popular animes page

* feat: Implement episodes list page

* feat: Implement search engine

* feat: Implement URL intent handler

* feat: Implement video list

Almost finished....

* fix: Fixes episodes order and episode list after latest animes page
This commit is contained in:
Claudemirovsky
2022-12-22 09:03:32 -03:00
committed by GitHub
parent 4a16eda024
commit f3cf2a1161
12 changed files with 567 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.sukianimes.SKUrlActivity"
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="sukianimes.com"
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 = 'SukiAnimes'
pkgNameSuffix = 'pt.sukianimes'
extClass = '.SukiAnimes'
extVersionCode = 1
libVersion = '13'
}
apply from: "$rootDir/common.gradle"

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -0,0 +1,170 @@
package eu.kanade.tachiyomi.animeextension.pt.sukianimes
import eu.kanade.tachiyomi.animesource.model.AnimeFilter
import eu.kanade.tachiyomi.animesource.model.AnimeFilterList
object SKFilters {
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("Formato", SKFiltersData.formats)
class StatusFilter : QueryPartFilter("Status do anime", SKFiltersData.status)
class TypeFilter : QueryPartFilter("Tipo de vídeo", SKFiltersData.types)
class GenresFilter : CheckBoxFilterList(
"Gêneros",
SKFiltersData.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 {
val genres = filters.getFirst<GenresFilter>().state
.mapNotNull { genre ->
if (genre.state) {
SKFiltersData.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 SKFiltersData {
val every = Pair("Qualquer um", "")
val types = arrayOf(
every,
Pair("Legendado", "1"),
Pair("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("Artes Marciais", "12"),
Pair("Aventura", "13"),
Pair("Ação", "5"),
Pair("Boys Love", "1125"),
Pair("Carros", "945"),
Pair("Chinês", "1032"),
Pair("Comédia Romântica", "15"),
Pair("Comédia", "14"),
Pair("Corrida", "1690"),
Pair("Culinária", "576"),
Pair("Dementia", "164"),
Pair("Demônios", "35"),
Pair("Drama", "9"),
Pair("Ecchi", "16"),
Pair("Erótico", "1203"),
Pair("Escolar", "812"),
Pair("Espaço", "429"),
Pair("Esporte", "17"),
Pair("Fantasia", "10"),
Pair("Ficção Científica", "18"),
Pair("Game", "156"),
Pair("Girls Love", "1228"),
Pair("Gore", "1708"),
Pair("Harém", "69"),
Pair("Histórico", "88"),
Pair("Horror", "165"),
Pair("Idols", "1702"),
Pair("Insanidade", "891"),
Pair("Isekai", "1138"),
Pair("Jogos", "19"),
Pair("Josei", "1345"),
Pair("Kids", "847"),
Pair("Magia", "20"),
Pair("Maid", "1677"),
Pair("Mecha", "21"),
Pair("Militar", "6"),
Pair("Mistério", "7"),
Pair("Munyuu", "1074"),
Pair("Musical", "22"),
Pair("Novel", "252"),
Pair("Parody", "1197"),
Pair("Paródia", "207"),
Pair("Performing Arts", "1564"),
Pair("Piratas", "172"),
Pair("Polícia", "229"),
Pair("Psicológico", "50"),
Pair("RPG", "94"),
Pair("Romance", "23"),
Pair("Samurai", "439"),
Pair("School", "1065"),
Pair("Sci-Fi", "42"),
Pair("Seinen", "24"),
Pair("Shoujo", "210"),
Pair("Shoujo-ai", "25"),
Pair("Shounen", "11"),
Pair("Shounen-AI", "322"),
Pair("Slice of Life", "26"),
Pair("Sobrenatural", "27"),
Pair("Super Poder", "8"),
Pair("Suspense", "230"),
Pair("Terror", "28"),
Pair("Thriller", "51"),
Pair("Tragedia", "269"),
Pair("Vampiro", "134"),
Pair("Vida Diaria", "253"),
Pair("Vida Escolar", "29"),
Pair("Violência", "440"),
Pair("Yaoi", "612"),
Pair("Yuri", "1497")
)
}
}

View File

@ -0,0 +1,41 @@
package eu.kanade.tachiyomi.animeextension.pt.sukianimes
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://sukianimes.com/anime/<item> intents
* and redirects them to the main Aniyomi process.
*/
class SKUrlActivity : Activity() {
private val TAG = "SKUrlActivity"
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", "${SukiAnimes.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,274 @@
package eu.kanade.tachiyomi.animeextension.pt.sukianimes
import eu.kanade.tachiyomi.animeextension.pt.sukianimes.dto.AnimeDto
import eu.kanade.tachiyomi.animeextension.pt.sukianimes.dto.SearchResultDto
import eu.kanade.tachiyomi.animeextension.pt.sukianimes.extractors.BloggerExtractor
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 SukiAnimes : ParsedAnimeHttpSource() {
override val name = "SukiAnimes"
override val baseUrl = "https://sukianimes.com"
private val API_URL = "$baseUrl/wp-admin/admin-ajax.php"
private val NONCE_URL = "$baseUrl/?js_global=1&ver=6.1.1"
override val lang = "pt-BR"
override val supportsLatest = true
override val client: OkHttpClient = network.client
private val json = Json {
ignoreUnknownKeys = true
}
// ============================== Popular ===============================
// This source doesn't have a popular anime page, so we'll grab
// the latest anime additions instead.
override fun popularAnimeSelector() = "section.animeslancamentos div.aniItem > a"
override fun popularAnimeRequest(page: Int) = GET(baseUrl)
override fun popularAnimeNextPageSelector() = null // disable it
override fun popularAnimeFromElement(element: Element): SAnime {
return SAnime.create().apply {
setUrlWithoutDomain(element.attr("href"))
title = element.attr("title")
thumbnail_url = element.selectFirst("img").attr("src")
}
}
// ============================== Episodes ==============================
override fun episodeListSelector() = "div.ultEpsContainerItem > 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("li.abasPlayers").map {
val url = it.attr("data-playerurl")
Pair(it.text(), url)
}.ifEmpty {
val defaultPlayer = doc.selectFirst("div.playerBoxInfra > iframe")
?: doc.selectFirst("video#player")
listOf(Pair("Default", defaultPlayer.attr("src")))
}
val videos = players.mapNotNull { (name, url) ->
when {
url.contains("$baseUrl/player") ->
BloggerExtractor(client).videoFromUrl(url, name, headers)
// Unfortunately, most of the links are broken.
url.contains("fy.v.vrv.co") -> null
else -> listOf(Video(url, name, url, headers = headers))
}
}.flatten()
return videos
}
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 = SKFilters.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 = if (filters.size > 0) {
SKFilters.getSearchParameters(filters)
} else {
// default implementation, prevents "List is empty" error.
SKFilters.FilterSearchParams()
}
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: SKFilters.FilterSearchParams): Request {
val body = FormBody.Builder().apply {
val nonceReq = client.newCall(GET(NONCE_URL)).execute()
val nonce = nonceReq.body?.string()
.orEmpty()
.substringAfter("'")
.substringBefore("'")
add("action", "show_animes_ajax")
if (filters.adult)
add("adulto", "yes")
else
add("adulto", "no")
add("formato", filters.format)
add("nome", query)
add("paged", "$page")
add("search_nonce", nonce)
add("status", filters.status)
add("tipo", filters.type)
filters.genres.forEach { add("generos[]", it) }
}.build()
return POST(API_URL, body = body)
}
private fun searchAnimeParse(response: Response, page: Int): AnimesPage {
val searchData = runCatching {
response.parseAs<SearchResultDto>()
}.getOrDefault(SearchResultDto())
val animes = searchData.animes.map(::searchAnimeParseFromObject)
val hasNextPage = searchData.pages > 0 && searchData.pages != page
return AnimesPage(animes, hasNextPage)
}
private fun searchAnimeParseFromObject(anime: AnimeDto): SAnime {
return SAnime.create().apply {
setUrlWithoutDomain(anime.permalink)
title = anime.title
thumbnail_url = anime.thumbnail_url
}
}
// =========================== Anime Details ============================
override fun animeDetailsParse(document: Document): SAnime {
val doc = getRealDoc(document)
return SAnime.create().apply {
thumbnail_url = doc.selectFirst("div.animeimgleft > img").attr("src")
val section = doc.selectFirst("section.rightnew")
val titleSection = section.selectFirst("section.anime_titulo")
title = titleSection.selectFirst("h1").text()
status = parseStatus(titleSection.selectFirst("div.anime_status"))
genre = titleSection.select("div.anime_generos > span")
.joinToString(", ") { it.text() }
var desc = doc.selectFirst("span#sinopse_content").text()
desc += "\n\n" + section.select("div.anime_info").joinToString("\n") {
val key = it.selectFirst("span.anime_info_title").text()
val value = it.selectFirst("span.anime_info_content").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,21 @@
package eu.kanade.tachiyomi.animeextension.pt.sukianimes.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class SearchResultDto(
val animes: List<AnimeDto> = emptyList(),
@SerialName("total_pages")
val pages: Int = 0
)
@Serializable
data class AnimeDto(
@SerialName("anime_permalink")
val permalink: String,
@SerialName("anime_capa")
val thumbnail_url: String,
@SerialName("anime_title")
val title: String
)

View File

@ -0,0 +1,23 @@
package eu.kanade.tachiyomi.animeextension.pt.sukianimes.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 BloggerExtractor(private val client: OkHttpClient) {
fun videoFromUrl(url: String, player: String, headers: Headers): List<Video> {
val iframeBody = client.newCall(GET(url)).execute().asJsoup()
val iframeUrl = iframeBody.selectFirst("iframe").attr("src")
val response = client.newCall(GET(iframeUrl, headers)).execute()
val html = response.body?.string().orEmpty()
return html.split("play_url").drop(1).map {
val url = it.substringAfter(":\"").substringBefore("\"")
val format = it.substringAfter("format_id\":").substringBefore("}")
val quality = if (format.equals("18")) "SD" else "HD"
Video(url, "$player - $quality", url, headers = headers)
}.reversed()
}
}