Skip to content

Commit

Permalink
feat(src/en): Add slothanime (#3055)
Browse files Browse the repository at this point in the history
  • Loading branch information
Secozzi authored Mar 16, 2024
1 parent e860556 commit 39bbf47
Show file tree
Hide file tree
Showing 8 changed files with 326 additions and 0 deletions.
7 changes: 7 additions & 0 deletions src/en/slothanime/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
ext {
extName = 'SlothAnime'
extClass = '.SlothAnime'
extVersionCode = 1
}

apply from: "$rootDir/common.gradle"
Binary file added src/en/slothanime/res/mipmap-hdpi/ic_launcher.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/en/slothanime/res/mipmap-mdpi/ic_launcher.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -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"),
),
)
Original file line number Diff line number Diff line change
@@ -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="
}
}

0 comments on commit 39bbf47

Please sign in to comment.