feat(src/en): Add slothanime (#3055)
This commit is contained in:
parent
e860556080
commit
39bbf479f0
7
src/en/slothanime/build.gradle
Normal file
7
src/en/slothanime/build.gradle
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
ext {
|
||||||
|
extName = 'SlothAnime'
|
||||||
|
extClass = '.SlothAnime'
|
||||||
|
extVersionCode = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: "$rootDir/common.gradle"
|
BIN
src/en/slothanime/res/mipmap-hdpi/ic_launcher.png
Normal file
BIN
src/en/slothanime/res/mipmap-hdpi/ic_launcher.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.6 KiB |
BIN
src/en/slothanime/res/mipmap-mdpi/ic_launcher.png
Normal file
BIN
src/en/slothanime/res/mipmap-mdpi/ic_launcher.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.5 KiB |
BIN
src/en/slothanime/res/mipmap-xhdpi/ic_launcher.png
Normal file
BIN
src/en/slothanime/res/mipmap-xhdpi/ic_launcher.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 6.5 KiB |
BIN
src/en/slothanime/res/mipmap-xxhdpi/ic_launcher.png
Normal file
BIN
src/en/slothanime/res/mipmap-xxhdpi/ic_launcher.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 12 KiB |
BIN
src/en/slothanime/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
BIN
src/en/slothanime/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 20 KiB |
@ -0,0 +1,122 @@
|
|||||||
|
package eu.kanade.tachiyomi.animeextension.en.slothanime
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.animesource.model.AnimeFilter
|
||||||
|
|
||||||
|
open class UriPartFilter(
|
||||||
|
name: String,
|
||||||
|
private val vals: Array<Pair<String, String>>,
|
||||||
|
defaultValue: String? = null,
|
||||||
|
) : AnimeFilter.Select<String>(
|
||||||
|
name,
|
||||||
|
vals.map { it.first }.toTypedArray(),
|
||||||
|
vals.indexOfFirst { it.second == defaultValue }.takeIf { it != -1 } ?: 0,
|
||||||
|
) {
|
||||||
|
fun getValue(): String {
|
||||||
|
return vals[state].second
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
open class UriMultiSelectOption(name: String, val value: String) : AnimeFilter.CheckBox(name)
|
||||||
|
|
||||||
|
open class UriMultiSelectFilter(
|
||||||
|
name: String,
|
||||||
|
private val vals: Array<Pair<String, String>>,
|
||||||
|
) : AnimeFilter.Group<UriMultiSelectOption>(name, vals.map { UriMultiSelectOption(it.first, it.second) }) {
|
||||||
|
fun getValues(): List<String> {
|
||||||
|
return state.filter { it.state }.map { it.value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
open class UriMultiTriSelectOption(name: String, val value: String) : AnimeFilter.TriState(name)
|
||||||
|
|
||||||
|
open class UriMultiTriSelectFilter(
|
||||||
|
name: String,
|
||||||
|
private val vals: Array<Pair<String, String>>,
|
||||||
|
) : AnimeFilter.Group<UriMultiTriSelectOption>(name, vals.map { UriMultiTriSelectOption(it.first, it.second) }) {
|
||||||
|
fun getIncluded(): List<String> {
|
||||||
|
return state.filter { it.state == TriState.STATE_INCLUDE }.map { it.value }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getExcluded(): List<String> {
|
||||||
|
return state.filter { it.state == TriState.STATE_EXCLUDE }.map { it.value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class GenreFilter : UriMultiTriSelectFilter(
|
||||||
|
"Genre",
|
||||||
|
arrayOf(
|
||||||
|
Pair("Action", "action"),
|
||||||
|
Pair("Adventure", "adventure"),
|
||||||
|
Pair("Fantasy", "fantasy"),
|
||||||
|
Pair("Martial Arts", "martial_arts"),
|
||||||
|
Pair("Comedy", "comedy"),
|
||||||
|
Pair("School", "school"),
|
||||||
|
Pair("Slice of Life", "slice_of_life"),
|
||||||
|
Pair("Military", "military"),
|
||||||
|
Pair("Sci-Fi", "scifi"),
|
||||||
|
Pair("Isekai", "isekai"),
|
||||||
|
Pair("Kids", "kids"),
|
||||||
|
Pair("Iyashikei", "iyashikei"),
|
||||||
|
Pair("Horror", "horror"),
|
||||||
|
Pair("Supernatural", "supernatural"),
|
||||||
|
Pair("Avant Garde", "avant_garde"),
|
||||||
|
Pair("Demons", "demons"),
|
||||||
|
Pair("Gourmet", "gourmet"),
|
||||||
|
Pair("Music", "music"),
|
||||||
|
Pair("Drama", "drama"),
|
||||||
|
Pair("Seinen", "seinen"),
|
||||||
|
Pair("Ecchi", "ecchi"),
|
||||||
|
Pair("Harem", "harem"),
|
||||||
|
Pair("Romance", "romance"),
|
||||||
|
Pair("Magic", "magic"),
|
||||||
|
Pair("Mystery", "mystery"),
|
||||||
|
Pair("Suspense", "suspense"),
|
||||||
|
Pair("Parody", "parody"),
|
||||||
|
Pair("Psychological", "psychological"),
|
||||||
|
Pair("Super Power", "super_power"),
|
||||||
|
Pair("Vampire", "vampire"),
|
||||||
|
Pair("Shounen", "shounen"),
|
||||||
|
Pair("Space", "space"),
|
||||||
|
Pair("Mecha", "mecha"),
|
||||||
|
Pair("Sports", "sports"),
|
||||||
|
Pair("Shoujo", "shoujo"),
|
||||||
|
Pair("Girls Love", "girls_love"),
|
||||||
|
Pair("Josei", "josei"),
|
||||||
|
Pair("Mahou Shoujo", "mahou_shoujo"),
|
||||||
|
Pair("Thriller", "thriller"),
|
||||||
|
Pair("Reverse Harem", "reverse_harem"),
|
||||||
|
Pair("Boys Love", "boys_love"),
|
||||||
|
Pair("Uncategorized", "uncategorized"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
class TypeFilter : UriMultiSelectFilter(
|
||||||
|
"Type",
|
||||||
|
arrayOf(
|
||||||
|
Pair("ONA", "ona"),
|
||||||
|
Pair("TV", "tv"),
|
||||||
|
Pair("MOVIE", "movie"),
|
||||||
|
Pair("SPECIAL", "special"),
|
||||||
|
Pair("OVA", "ova"),
|
||||||
|
Pair("MUSIC", "music"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
class StatusFilter : UriPartFilter(
|
||||||
|
"Status",
|
||||||
|
arrayOf(
|
||||||
|
Pair("All", "2"),
|
||||||
|
Pair("Completed", "1"),
|
||||||
|
Pair("Releasing", "0"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
class SortFilter : UriPartFilter(
|
||||||
|
"Sort",
|
||||||
|
arrayOf(
|
||||||
|
Pair("Most Watched", "viewed"),
|
||||||
|
Pair("Scored", "scored"),
|
||||||
|
Pair("Newest", "created_at"),
|
||||||
|
Pair("Latest Update", "updated_at"),
|
||||||
|
),
|
||||||
|
)
|
@ -0,0 +1,197 @@
|
|||||||
|
package eu.kanade.tachiyomi.animeextension.en.slothanime
|
||||||
|
|
||||||
|
import android.util.Base64
|
||||||
|
import eu.kanade.tachiyomi.animesource.model.AnimeFilterList
|
||||||
|
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 okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
|
import okhttp3.Request
|
||||||
|
import org.jsoup.nodes.Document
|
||||||
|
import org.jsoup.nodes.Element
|
||||||
|
import javax.crypto.Cipher
|
||||||
|
import javax.crypto.spec.IvParameterSpec
|
||||||
|
import javax.crypto.spec.SecretKeySpec
|
||||||
|
import kotlin.math.floor
|
||||||
|
|
||||||
|
class SlothAnime : ParsedAnimeHttpSource() {
|
||||||
|
|
||||||
|
override val name = "SlothAnime"
|
||||||
|
|
||||||
|
override val baseUrl = "https://slothanime.com"
|
||||||
|
|
||||||
|
override val lang = "en"
|
||||||
|
|
||||||
|
override val supportsLatest = true
|
||||||
|
|
||||||
|
// ============================== Popular ===============================
|
||||||
|
|
||||||
|
override fun popularAnimeRequest(page: Int): Request {
|
||||||
|
val url = if (page > 1) {
|
||||||
|
"$baseUrl/list/viewed?page=$page"
|
||||||
|
} else {
|
||||||
|
"$baseUrl/list/viewed"
|
||||||
|
}
|
||||||
|
|
||||||
|
return GET(url, headers)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun popularAnimeSelector(): String = ".row > div > .anime-card-md"
|
||||||
|
|
||||||
|
override fun popularAnimeFromElement(element: Element): SAnime = SAnime.create().apply {
|
||||||
|
thumbnail_url = element.selectFirst("img")!!.imgAttr()
|
||||||
|
with(element.selectFirst("a[href~=/anime]")!!) {
|
||||||
|
title = text()
|
||||||
|
setUrlWithoutDomain(attr("abs:href"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun popularAnimeNextPageSelector(): String = ".pagination > .active ~ li:has(a)"
|
||||||
|
|
||||||
|
// =============================== Latest ===============================
|
||||||
|
|
||||||
|
override fun latestUpdatesRequest(page: Int): Request {
|
||||||
|
val url = if (page > 1) {
|
||||||
|
"$baseUrl/list/latest?page=$page"
|
||||||
|
} else {
|
||||||
|
"$baseUrl/list/latest"
|
||||||
|
}
|
||||||
|
|
||||||
|
return GET(url, headers)
|
||||||
|
}
|
||||||
|
override fun latestUpdatesSelector(): String = popularAnimeSelector()
|
||||||
|
|
||||||
|
override fun latestUpdatesFromElement(element: Element): SAnime = popularAnimeFromElement(element)
|
||||||
|
|
||||||
|
override fun latestUpdatesNextPageSelector(): String = popularAnimeNextPageSelector()
|
||||||
|
|
||||||
|
// =============================== Search ===============================
|
||||||
|
|
||||||
|
override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request {
|
||||||
|
val genreFilter = filters.filterIsInstance<GenreFilter>().first()
|
||||||
|
val typeFilter = filters.filterIsInstance<TypeFilter>().first()
|
||||||
|
val statusFilter = filters.filterIsInstance<StatusFilter>().first()
|
||||||
|
val sortFilter = filters.filterIsInstance<SortFilter>().first()
|
||||||
|
|
||||||
|
val url = baseUrl.toHttpUrl().newBuilder().apply {
|
||||||
|
addPathSegment("search")
|
||||||
|
addQueryParameter("q", query)
|
||||||
|
genreFilter.getIncluded().forEachIndexed { idx, value ->
|
||||||
|
addQueryParameter("genre[$idx]", value)
|
||||||
|
}
|
||||||
|
typeFilter.getValues().forEachIndexed { idx, value ->
|
||||||
|
addQueryParameter("type[$idx]", value)
|
||||||
|
}
|
||||||
|
addQueryParameter("status", statusFilter.getValue())
|
||||||
|
addQueryParameter("sort", sortFilter.getValue())
|
||||||
|
genreFilter.getExcluded().forEachIndexed { idx, value ->
|
||||||
|
addQueryParameter("ignore_genre[$idx]", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (page > 1) {
|
||||||
|
addQueryParameter("page", page.toString())
|
||||||
|
}
|
||||||
|
}.build()
|
||||||
|
|
||||||
|
return GET(url, headers)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun searchAnimeSelector(): String = popularAnimeSelector()
|
||||||
|
|
||||||
|
override fun searchAnimeFromElement(element: Element): SAnime = popularAnimeFromElement(element)
|
||||||
|
|
||||||
|
override fun searchAnimeNextPageSelector(): String = popularAnimeNextPageSelector()
|
||||||
|
|
||||||
|
// ============================== Filters ===============================
|
||||||
|
|
||||||
|
override fun getFilterList(): AnimeFilterList = AnimeFilterList(
|
||||||
|
GenreFilter(),
|
||||||
|
TypeFilter(),
|
||||||
|
StatusFilter(),
|
||||||
|
SortFilter(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// =========================== Anime Details ============================
|
||||||
|
|
||||||
|
override fun animeDetailsParse(document: Document): SAnime = SAnime.create().apply {
|
||||||
|
title = document.selectFirst(".single-title > h5")!!.text()
|
||||||
|
thumbnail_url = document.selectFirst(".single-cover > img")!!.imgAttr()
|
||||||
|
description = document.selectFirst(".single-detail:has(span:contains(Description)) .more-content")?.text()
|
||||||
|
genre = document.select(".single-tag > a.tag").joinToString { it.text() }
|
||||||
|
author = document.select(".single-detail:has(span:contains(Studios)) .value a").joinToString { it.text() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================== Episodes ==============================
|
||||||
|
|
||||||
|
override fun episodeListSelector() = ".list-episodes-container > a[class~=episode]"
|
||||||
|
|
||||||
|
override fun episodeFromElement(element: Element): SEpisode = SEpisode.create().apply {
|
||||||
|
setUrlWithoutDomain(element.attr("abs:href"))
|
||||||
|
name = element.text()
|
||||||
|
.replace(Regex("""^EP """), "Episode ")
|
||||||
|
.replace(Regex("""^\d+""")) { m -> "Episode ${m.value}" }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================ Video Links =============================
|
||||||
|
|
||||||
|
fun encryptAES(input: String, key: ByteArray, iv: ByteArray): String {
|
||||||
|
val cipher = Cipher.getInstance("AES/CBC/NoPadding")
|
||||||
|
val secretKey = SecretKeySpec(key, "AES")
|
||||||
|
val ivParameterSpec = IvParameterSpec(iv)
|
||||||
|
|
||||||
|
cipher.init(Cipher.ENCRYPT_MODE, secretKey, ivParameterSpec)
|
||||||
|
val paddedInput = zeroPad(input)
|
||||||
|
val encryptedBytes = cipher.doFinal(paddedInput.toByteArray(Charsets.UTF_8))
|
||||||
|
return Base64.encodeToString(encryptedBytes, Base64.NO_WRAP)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun zeroPad(input: String): String {
|
||||||
|
val blockSize = 16
|
||||||
|
val padLength = blockSize - input.length % blockSize
|
||||||
|
return input.padEnd(input.length + padLength, '\u0000')
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getVideoList(episode: SEpisode): List<Video> {
|
||||||
|
val key = String(Base64.decode(KEY, Base64.DEFAULT)).chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||||
|
val iv = String(Base64.decode(IV, Base64.DEFAULT)).chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||||
|
val time = floor(System.currentTimeMillis() / 1000.0)
|
||||||
|
val vrf = encryptAES(time.toString(), key, iv)
|
||||||
|
val id = episode.url.substringAfterLast("/")
|
||||||
|
|
||||||
|
val url = baseUrl.toHttpUrl().newBuilder().apply {
|
||||||
|
addPathSegment("player-url")
|
||||||
|
addPathSegment(id)
|
||||||
|
addQueryParameter("vrf", vrf)
|
||||||
|
}.build().toString()
|
||||||
|
|
||||||
|
val videoHeaders = headersBuilder().apply {
|
||||||
|
add("Accept", "video/webm,video/ogg,video/*;q=0.9,application/ogg;q=0.7,audio/*;q=0.6,*/*;q=0.5")
|
||||||
|
add("Referer", baseUrl + episode.url)
|
||||||
|
}.build()
|
||||||
|
|
||||||
|
return listOf(
|
||||||
|
Video(url, "Video", url, videoHeaders),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun videoListSelector() = throw UnsupportedOperationException()
|
||||||
|
|
||||||
|
override fun videoFromElement(element: Element) = throw UnsupportedOperationException()
|
||||||
|
|
||||||
|
override fun videoUrlParse(document: Document) = throw UnsupportedOperationException()
|
||||||
|
|
||||||
|
// ============================= Utilities ==============================
|
||||||
|
|
||||||
|
private fun Element.imgAttr(): String = when {
|
||||||
|
hasAttr("data-lazy-src") -> attr("abs:data-lazy-src")
|
||||||
|
hasAttr("data-src") -> attr("abs:data-src")
|
||||||
|
else -> attr("abs:src")
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val KEY = "YWI0OWZkYjllYzE5M2I0YWQzYWFkMGVmMTU4N2Q2OGE0YmYxY2Y5YjJkMjA4YjRjYzIzMDYwZTkwNThiMjA0NA=="
|
||||||
|
private const val IV = "NDI4MzEzNjcxMThiMzFmYjVhNTI1MTMzNTc0ZmJmNGI="
|
||||||
|
}
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user