diff --git a/src/en/slothanime/build.gradle b/src/en/slothanime/build.gradle new file mode 100644 index 0000000000..43dd0c29a1 --- /dev/null +++ b/src/en/slothanime/build.gradle @@ -0,0 +1,7 @@ +ext { + extName = 'SlothAnime' + extClass = '.SlothAnime' + extVersionCode = 1 +} + +apply from: "$rootDir/common.gradle" \ No newline at end of file diff --git a/src/en/slothanime/res/mipmap-hdpi/ic_launcher.png b/src/en/slothanime/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000..69936bbfc7 Binary files /dev/null and b/src/en/slothanime/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/en/slothanime/res/mipmap-mdpi/ic_launcher.png b/src/en/slothanime/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000..c67325c1b4 Binary files /dev/null and b/src/en/slothanime/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/en/slothanime/res/mipmap-xhdpi/ic_launcher.png b/src/en/slothanime/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000000..977c68e707 Binary files /dev/null and b/src/en/slothanime/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/en/slothanime/res/mipmap-xxhdpi/ic_launcher.png b/src/en/slothanime/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000..efeb924b7a Binary files /dev/null and b/src/en/slothanime/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/en/slothanime/res/mipmap-xxxhdpi/ic_launcher.png b/src/en/slothanime/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000..22b3b12035 Binary files /dev/null and b/src/en/slothanime/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/en/slothanime/src/eu/kanade/tachiyomi/animeextension/en/slothanime/Filters.kt b/src/en/slothanime/src/eu/kanade/tachiyomi/animeextension/en/slothanime/Filters.kt new file mode 100644 index 0000000000..fa496d498e --- /dev/null +++ b/src/en/slothanime/src/eu/kanade/tachiyomi/animeextension/en/slothanime/Filters.kt @@ -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>, + defaultValue: String? = null, +) : AnimeFilter.Select( + 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>, +) : AnimeFilter.Group(name, vals.map { UriMultiSelectOption(it.first, it.second) }) { + fun getValues(): List { + 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>, +) : AnimeFilter.Group(name, vals.map { UriMultiTriSelectOption(it.first, it.second) }) { + fun getIncluded(): List { + return state.filter { it.state == TriState.STATE_INCLUDE }.map { it.value } + } + + fun getExcluded(): List { + 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"), + ), +) diff --git a/src/en/slothanime/src/eu/kanade/tachiyomi/animeextension/en/slothanime/SlothAnime.kt b/src/en/slothanime/src/eu/kanade/tachiyomi/animeextension/en/slothanime/SlothAnime.kt new file mode 100644 index 0000000000..dff7fdeab2 --- /dev/null +++ b/src/en/slothanime/src/eu/kanade/tachiyomi/animeextension/en/slothanime/SlothAnime.kt @@ -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().first() + val typeFilter = filters.filterIsInstance().first() + val statusFilter = filters.filterIsInstance().first() + val sortFilter = filters.filterIsInstance().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