New source: AnimesTC (#1323)

* feat: Create AnimesTC base

* feat: Implement latest animes page

* feat: Implement anime details page

* feat: Implement episodes list page

* feat: Implement (pseudo) popular animes page

Again, another source without a popular animes page.

* fix: Make URL intent handler work well

* feat: INITIAL video list implementation

i'll just sleep a bit and implement search + video extractors later.

* feat: Implement search

* feat: Add AnonFiles extractor

* feat: Add Sendcm extractor

* feat: Add video quality preference

* feat: Add player preference

* refactor: Remove unnecessary lines at videoListParse
This commit is contained in:
Claudemirovsky
2023-02-24 04:41:41 -03:00
committed by GitHub
parent 26eba8a897
commit 569c2615a0
14 changed files with 732 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.animestc.AnimesTCUrlActivity"
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="wwww.animestc.net"
android:pathPattern="/animes/..*"
android:scheme="https" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@ -0,0 +1,16 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlinx-serialization'
ext {
extName = 'AnimesTC'
pkgNameSuffix = 'pt.animestc'
extClass = '.AnimesTC'
extVersionCode = 1
}
dependencies {
compileOnly(libs.bundles.coroutines)
}
apply from: "$rootDir/common.gradle"

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -0,0 +1,188 @@
package eu.kanade.tachiyomi.animeextension.pt.animestc
import eu.kanade.tachiyomi.animeextension.pt.animestc.dto.AnimeDto
import eu.kanade.tachiyomi.animesource.model.AnimeFilter
import eu.kanade.tachiyomi.animesource.model.AnimeFilterList
import eu.kanade.tachiyomi.animesource.model.SAnime
object ATCFilters {
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 TriStateFilterList(name: String, values: List<TriState>) : AnimeFilter.Group<AnimeFilter.TriState>(name, values)
private class TriStateVal(name: String) : AnimeFilter.TriState(name)
private inline fun <reified R> AnimeFilterList.getFirst(): R {
return this.filterIsInstance<R>().first()
}
private inline fun <reified R> AnimeFilterList.asQueryPart(): String {
return this.getFirst<R>().let {
(it as QueryPartFilter).toQueryPart()
}
}
class InitialLetterFilter : QueryPartFilter("Primeira letra", ATCFiltersData.initialLetter)
class StatusFilter : QueryPartFilter("Status", ATCFiltersData.status)
class SortFilter : AnimeFilter.Sort(
"Ordenar",
ATCFiltersData.orders.map { it.first }.toTypedArray(),
Selection(0, true)
)
class GenresFilter : TriStateFilterList(
"Gêneros",
ATCFiltersData.genres.map { TriStateVal(it) }
)
val filterList = AnimeFilterList(
InitialLetterFilter(),
StatusFilter(),
SortFilter(),
AnimeFilter.Separator(),
GenresFilter(),
)
data class FilterSearchParams(
val initialLetter: String = "",
val status: String = "",
var orderAscending: Boolean = true,
var sortBy: String = "",
val blackListedGenres: ArrayList<String> = ArrayList(),
val includedGenres: ArrayList<String> = ArrayList(),
var animeName: String = ""
)
internal fun getSearchParameters(filters: AnimeFilterList): FilterSearchParams {
if (filters.isEmpty()) return FilterSearchParams()
val searchParams = FilterSearchParams(
filters.asQueryPart<InitialLetterFilter>(),
filters.asQueryPart<StatusFilter>()
)
filters.getFirst<SortFilter>().state?.let {
val order = ATCFiltersData.orders[it.index].second
searchParams.orderAscending = it.ascending
searchParams.sortBy = order
}
filters.getFirst<GenresFilter>()
.state.forEach { genre ->
if (genre.isIncluded()) {
searchParams.includedGenres.add(genre.name)
} else if (genre.isExcluded()) {
searchParams.blackListedGenres.add(genre.name)
}
}
return searchParams
}
private fun compareLower(first: String, second: String): Boolean {
return first.lowercase() in second.lowercase()
}
private fun mustRemove(anime: AnimeDto, params: FilterSearchParams): Boolean {
return when {
params.animeName != "" && !compareLower(params.animeName, anime.title) -> true
params.initialLetter != "" && !anime.title.lowercase().startsWith(params.initialLetter) -> true
params.blackListedGenres.size > 0 && params.blackListedGenres.any {
compareLower(it, anime.genres)
} -> true
params.includedGenres.size > 0 && params.includedGenres.any {
!compareLower(it, anime.genres)
} -> true
params.status != "" && anime.status != SAnime.UNKNOWN && anime.status != params.status.toInt() -> true
else -> false
}
}
private inline fun <T, R : Comparable<R>> List<out T>.sortedByIf(
condition: Boolean,
crossinline selector: (T) -> R?
): List<T> {
return if (condition) sortedBy(selector)
else sortedByDescending(selector)
}
fun List<AnimeDto>.applyFilterParams(params: FilterSearchParams): List<AnimeDto> {
return this.filterNot { mustRemove(it, params) }.let { results ->
when (params.sortBy) {
"A-Z" -> results.sortedByIf(params.orderAscending) { it.title.lowercase() }
"year" -> results.sortedByIf(params.orderAscending) { it.year ?: 0 }
else -> results
}
}
}
private object ATCFiltersData {
val orders = arrayOf(
Pair("Alfabeticamente", "A-Z"),
Pair("Por ano", "year")
)
val status = arrayOf(
Pair("Selecione", ""),
Pair("Completo", SAnime.COMPLETED.toString()),
Pair("Em Lançamento", SAnime.ONGOING.toString())
)
val initialLetter = arrayOf(Pair("Selecione", "")) + ('A'..'Z').map {
Pair(it.toString(), it.toString().lowercase())
}.toTypedArray()
val genres = arrayOf(
"Ação",
"Action",
"Adventure",
"Artes Marciais",
"Aventura",
"Carros",
"Comédia",
"Comédia Romântica",
"Demônios",
"Drama",
"Ecchi",
"Escolar",
"Esporte",
"Fantasia",
"Historical",
"Histórico",
"Horror",
"Jogos",
"Kids",
"Live Action",
"Magia",
"Mecha",
"Militar",
"Mistério",
"Psicológico",
"Romance",
"Samurai",
"School Life",
"Sci-Fi", // Yeah
"SciFi",
"Seinen",
"Shoujo",
"Shounen",
"Sobrenatural",
"Super Poder",
"Supernatural",
"Terror",
"Tragédia",
"Vampiro",
"Vida Escolar"
)
}
}

View File

@ -0,0 +1,308 @@
package eu.kanade.tachiyomi.animeextension.pt.animestc
import android.app.Application
import android.content.SharedPreferences
import androidx.preference.ListPreference
import androidx.preference.PreferenceScreen
import eu.kanade.tachiyomi.animeextension.pt.animestc.ATCFilters.applyFilterParams
import eu.kanade.tachiyomi.animeextension.pt.animestc.dto.AnimeDto
import eu.kanade.tachiyomi.animeextension.pt.animestc.dto.EpisodeDto
import eu.kanade.tachiyomi.animeextension.pt.animestc.dto.ResponseDto
import eu.kanade.tachiyomi.animeextension.pt.animestc.dto.VideoDto
import eu.kanade.tachiyomi.animeextension.pt.animestc.extractors.AnonFilesExtractor
import eu.kanade.tachiyomi.animeextension.pt.animestc.extractors.LinkBypasser
import eu.kanade.tachiyomi.animeextension.pt.animestc.extractors.SendcmExtractor
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.AnimeHttpSource
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.asObservableSuccess
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.CacheControl
import okhttp3.Headers
import okhttp3.Request
import okhttp3.Response
import rx.Observable
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.concurrent.TimeUnit.DAYS
class AnimesTC : ConfigurableAnimeSource, AnimeHttpSource() {
override val name = "AnimesTC"
override val baseUrl = "https://api2.animestc.com"
override val lang = "pt-BR"
override val supportsLatest = true
override fun headersBuilder() = Headers.Builder()
.add("Referer", "https://www.animestc.net/")
private val preferences: SharedPreferences by lazy {
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
}
private val json = Json {
ignoreUnknownKeys = true
}
// ============================== Popular ===============================
// This source doesnt have a popular animes page,
// so we use latest animes page instead.
override fun fetchPopularAnime(page: Int) = fetchLatestUpdates(page)
override fun popularAnimeParse(response: Response): AnimesPage = TODO()
override fun popularAnimeRequest(page: Int): Request = TODO()
// ============================== Episodes ==============================
override fun episodeListParse(response: Response): List<SEpisode> {
val id = response.getAnimeDto().id
return getEpisodeList(id)
}
private fun episodeListRequest(animeId: Int, page: Int) =
GET("$baseUrl/episodes?order=id&direction=desc&page=$page&seriesId=$animeId&specialOrder=true")
private fun getEpisodeList(animeId: Int, page: Int = 1): List<SEpisode> {
val response = client.newCall(episodeListRequest(animeId, page)).execute()
val parsed = response.parseAs<ResponseDto<EpisodeDto>>()
val episodes = parsed.items.map {
SEpisode.create().apply {
name = it.title
setUrlWithoutDomain("/episodes?slug=${it.slug}")
episode_number = it.number.toFloat()
date_upload = it.created_at.toDate()
}
}
if (parsed.page < parsed.lastPage) {
return episodes + getEpisodeList(animeId, page + 1)
} else {
return episodes
}
}
// ============================ Video Links =============================
override fun videoListParse(response: Response): List<Video> {
val videoDto = response.parseAs<ResponseDto<VideoDto>>().items.first()
val links = videoDto.links
val allLinks = listOf(links.low, links.medium, links.high).flatten()
val supportedPlayers = listOf("anonfiles", "send")
val online = links.online?.filterNot { "mega" in it }?.map {
Video(it, "Player ATC", it, headers)
} ?: emptyList<Video>()
return online + allLinks.filter { it.name in supportedPlayers }.parallelMap {
val playerUrl = LinkBypasser(client, json).bypass(it, videoDto.id)
if (playerUrl == null) return@parallelMap null
val quality = when (it.quality) {
"low" -> "SD"
"medium" -> "HD"
"high" -> "FULLHD"
else -> "SD"
}
when (it.name) {
"anonfiles" ->
AnonFilesExtractor(client)
.videoFromUrl(playerUrl, quality)
"send" ->
SendcmExtractor(client)
.videoFromUrl(playerUrl, quality)
else -> null
}
}.filterNotNull()
}
// =========================== Anime Details ============================
override fun animeDetailsParse(response: Response): SAnime {
val anime = response.getAnimeDto()
return SAnime.create().apply {
setUrlWithoutDomain("/series/${anime.id}")
title = anime.title
status = anime.status
genre = anime.genres
description = anime.synopsis
}
}
// =============================== Search ===============================
override fun searchAnimeParse(response: Response): AnimesPage {
TODO("Not yet implemented")
}
override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request {
TODO("Not yet implemented")
}
override fun fetchSearchAnime(page: Int, query: String, filters: AnimeFilterList): Observable<AnimesPage> {
return if (query.startsWith(PREFIX_SEARCH)) { // URL intent handler
val slug = query.removePrefix(PREFIX_SEARCH)
client.newCall(GET("$baseUrl/series?slug=$slug"))
.asObservableSuccess()
.map { response ->
searchAnimeBySlugParse(response)
}
} else {
val params = ATCFilters.getSearchParameters(filters)
return Observable.just(searchAnime(page, query, params))
}
}
private val allAnimesList by lazy {
val cache = CacheControl.Builder().maxAge(1, DAYS).build()
listOf("movie", "ova", "series").map { type ->
val url = "$baseUrl/series?order=title&direction=asc&page=1&full=true&type=$type"
val response = client.newCall(GET(url, cache = cache)).execute()
response.parseAs<ResponseDto<AnimeDto>>().items
}.flatten()
}
override fun getFilterList(): AnimeFilterList = ATCFilters.filterList
private fun searchAnime(page: Int, query: String, filterParams: ATCFilters.FilterSearchParams): AnimesPage {
filterParams.animeName = query
val filtered = allAnimesList.applyFilterParams(filterParams)
val results = filtered.chunked(30)
val hasNextPage = results.size > page
val currentPage = if (results.size == 0) {
emptyList<SAnime>()
} else {
results.get(page - 1).map(::searchAnimeFromObject)
}
return AnimesPage(currentPage, hasNextPage)
}
private fun searchAnimeFromObject(anime: AnimeDto) = SAnime.create().apply {
thumbnail_url = anime.cover.url
title = anime.title
setUrlWithoutDomain("/series/${anime.id}")
}
private fun searchAnimeBySlugParse(response: Response): AnimesPage {
val details = animeDetailsParse(response)
return AnimesPage(listOf(details), false)
}
// =============================== Latest ===============================
override fun latestUpdatesParse(response: Response): AnimesPage {
val parsed = response.parseAs<ResponseDto<EpisodeDto>>()
val hasNextPage = parsed.page < parsed.lastPage
val animes = parsed.items.map {
SAnime.create().apply {
title = it.title
setUrlWithoutDomain("/series/${it.animeId}")
thumbnail_url = it.cover!!.url
}
}
return AnimesPage(animes, hasNextPage)
}
override fun latestUpdatesRequest(page: Int): Request {
return GET("$baseUrl/episodes?order=created_at&direction=desc&page=$page&ignoreIndex=false")
}
// ============================== 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()
}
}
val playerPref = ListPreference(screen.context).apply {
key = PREF_PLAYER_KEY
title = PREF_PLAYER_TITLE
entries = PREF_PLAYER_VALUES
entryValues = PREF_PLAYER_VALUES
setDefaultValue(PREF_PLAYER_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)
screen.addPreference(playerPref)
}
// ============================= Utilities ==============================
private fun <A, B> Iterable<A>.parallelMap(f: suspend (A) -> B): List<B> =
runBlocking {
map { async(Dispatchers.Default) { f(it) } }.awaitAll()
}
private fun Response.getAnimeDto(): AnimeDto {
val responseBody = body?.string().orEmpty()
return try {
parseAs<AnimeDto>(responseBody)
} catch (e: Exception) {
// URL intent handler moment
parseAs<ResponseDto<AnimeDto>>(responseBody).items.first()
}
}
private fun String.toDate(): Long {
return runCatching {
DATE_FORMATTER.parse(this)?.time ?: 0L
}.getOrNull() ?: 0L
}
private inline fun <reified T> Response.parseAs(preloaded: String? = null): T {
val responseBody = preloaded ?: body?.string().orEmpty()
return json.decodeFromString(responseBody)
}
override fun List<Video>.sort(): List<Video> {
val quality = preferences.getString(PREF_QUALITY_KEY, PREF_QUALITY_DEFAULT)!!
val player = preferences.getString(PREF_PLAYER_KEY, PREF_PLAYER_DEFAULT)!!
return sortedWith(
compareBy(
{ it.quality.contains(player) },
{ it.quality.contains("- $quality") },
)
).reversed()
}
companion object {
private val DATE_FORMATTER by lazy {
SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.ENGLISH)
}
const val PREFIX_SEARCH = "slug:"
private const val PREF_QUALITY_KEY = "pref_quality"
private const val PREF_QUALITY_TITLE = "Qualidade preferida"
private const val PREF_QUALITY_DEFAULT = "HD"
private val PREF_QUALITY_VALUES = arrayOf("SD", "HD", "FULLHD")
private const val PREF_PLAYER_KEY = "pref_player"
private const val PREF_PLAYER_TITLE = "Player preferido"
private const val PREF_PLAYER_DEFAULT = "AnonFiles"
private val PREF_PLAYER_VALUES = arrayOf("AnonFiles", "Sendcm", "Player ATC")
}
}

View File

@ -0,0 +1,41 @@
package eu.kanade.tachiyomi.animeextension.pt.animestc
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://wwww.animestc.net/animes/<item> intents
* and redirects them to the main Aniyomi process.
*/
class AnimesTCUrlActivity : 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) {
val item = pathSegments[1]
val mainIntent = Intent().apply {
action = "eu.kanade.tachiyomi.ANIMESEARCH"
putExtra("query", "${AnimesTC.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,76 @@
package eu.kanade.tachiyomi.animeextension.pt.animestc.dto
import eu.kanade.tachiyomi.animesource.model.SAnime
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class ResponseDto<T>(
@SerialName("data")
val items: List<T>,
val lastPage: Int,
val page: Int
)
@Serializable
data class AnimeDto(
val cover: CoverDto,
val id: Int,
val releaseStatus: String,
val synopsis: String,
val tags: List<TagDto>,
val title: String,
val year: Int?
) {
val status by lazy {
when (releaseStatus) {
"complete" -> SAnime.COMPLETED
"airing" -> SAnime.ONGOING
else -> SAnime.UNKNOWN
}
}
val genres by lazy { tags.joinToString(", ") { it.name } }
@Serializable
data class TagDto(val name: String)
}
@Serializable
data class EpisodeDto(
@SerialName("seriesId")
val animeId: Int,
val cover: CoverDto?,
val created_at: String,
val number: String,
val slug: String,
val title: String
)
@Serializable
data class VideoDto(
val id: Int,
val links: VideoLinksDto
) {
@Serializable
data class VideoLinksDto(
val low: List<VideoLink> = emptyList(),
val medium: List<VideoLink> = emptyList(),
val high: List<VideoLink> = emptyList(),
val online: List<String>? = null
)
@Serializable
data class VideoLink(
val index: Int,
val name: String,
val quality: String,
)
}
@Serializable
data class CoverDto(
val originalName: String
) {
val url by lazy { "https://stc.animestc.com/$originalName" }
}

View File

@ -0,0 +1,18 @@
package eu.kanade.tachiyomi.animeextension.pt.animestc.extractors
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.util.asJsoup
import okhttp3.OkHttpClient
class AnonFilesExtractor(private val client: OkHttpClient) {
private val PLAYER_NAME = "AnonFiles"
fun videoFromUrl(url: String, quality: String): Video? {
val doc = client.newCall(GET(url)).execute().asJsoup()
val downloadUrl = doc.selectFirst("a#download-url")?.attr("href")
return downloadUrl?.let {
Video(it, "$PLAYER_NAME - $quality", it)
}
}
}

View File

@ -0,0 +1,41 @@
package eu.kanade.tachiyomi.animeextension.pt.animestc.extractors
import android.util.Base64
import eu.kanade.tachiyomi.animeextension.pt.animestc.dto.VideoDto.VideoLink
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.util.asJsoup
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
class LinkBypasser(
private val client: OkHttpClient,
private val json: Json
) {
fun bypass(video: VideoLink, episodeId: Int): String? {
val joined = "$episodeId/${video.quality}/${video.index}"
val encoded = Base64.encodeToString(joined.toByteArray(), Base64.NO_WRAP)
val url = "$PROTECTOR_URL/link/$encoded"
val res = client.newCall(GET(url)).execute()
if (res.code != 200)
return null
// Sadly we MUST wait 6s or we are going to get a HTTP 500
Thread.sleep(6000L)
val id = res.asJsoup().selectFirst("meta#link-id")!!.attr("value")
val apiCall = client.newCall(GET("$PROTECTOR_URL/api/link/$id")).execute()
if (apiCall.code != 200)
return null
val apiBody = apiCall.body?.string().orEmpty()
return json.decodeFromString<LinkDto>(apiBody).link
}
@Serializable
data class LinkDto(val link: String)
companion object {
private const val PROTECTOR_URL = "https://protetor.animestc.xyz"
}
}

View File

@ -0,0 +1,20 @@
package eu.kanade.tachiyomi.animeextension.pt.animestc.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 SendcmExtractor(private val client: OkHttpClient) {
private val PLAYER_NAME = "Sendcm"
fun videoFromUrl(url: String, quality: String): Video? {
val doc = client.newCall(GET(url)).execute().asJsoup()
val videoUrl = doc.selectFirst("video#vjsplayer > source")?.attr("src")
return videoUrl?.let {
val headers = Headers.headersOf("Referer", url)
Video(it, "$PLAYER_NAME - $quality", it, headers = headers)
}
}
}