Initial commit

This commit is contained in:
almightyhak 2024-06-20 11:54:12 +07:00
commit 98ed7e8839
2263 changed files with 108711 additions and 0 deletions

View file

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity
android:name=".en.kickassanime.KickAssAnimeUrlActivity"
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="kaas.am"
android:pathPattern="/..*"
android:scheme="https" />
</intent-filter>
</activity>
</application>
</manifest>

View file

@ -0,0 +1,12 @@
ext {
extName = 'KickAssAnime'
extClass = '.KickAssAnime'
extVersionCode = 42
}
apply from: "$rootDir/common.gradle"
dependencies {
implementation(project(":lib:cryptoaes"))
implementation(project(":lib:playlist-utils"))
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

View file

@ -0,0 +1,434 @@
package eu.kanade.tachiyomi.animeextension.en.kickassanime
import android.app.Application
import android.content.SharedPreferences
import android.util.Base64
import androidx.preference.ListPreference
import androidx.preference.MultiSelectListPreference
import androidx.preference.PreferenceScreen
import androidx.preference.SwitchPreferenceCompat
import eu.kanade.tachiyomi.animeextension.en.kickassanime.dto.AnimeInfoDto
import eu.kanade.tachiyomi.animeextension.en.kickassanime.dto.EpisodeResponseDto
import eu.kanade.tachiyomi.animeextension.en.kickassanime.dto.LanguagesDto
import eu.kanade.tachiyomi.animeextension.en.kickassanime.dto.PopularItemDto
import eu.kanade.tachiyomi.animeextension.en.kickassanime.dto.PopularResponseDto
import eu.kanade.tachiyomi.animeextension.en.kickassanime.dto.RecentsResponseDto
import eu.kanade.tachiyomi.animeextension.en.kickassanime.dto.SearchResponseDto
import eu.kanade.tachiyomi.animeextension.en.kickassanime.dto.ServersDto
import eu.kanade.tachiyomi.animeextension.en.kickassanime.extractors.KickAssAnimeExtractor
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.POST
import eu.kanade.tachiyomi.network.awaitSuccess
import eu.kanade.tachiyomi.util.parseAs
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
import java.util.Locale
class KickAssAnime : ConfigurableAnimeSource, AnimeHttpSource() {
override val name = "KickAssAnime"
override val baseUrl by lazy { preferences.getString(PREF_DOMAIN_KEY, PREF_DOMAIN_DEFAULT)!! }
private val apiUrl by lazy { "$baseUrl/api/show" }
override val lang = "en"
override val supportsLatest = true
private val preferences: SharedPreferences by lazy {
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
.clearBaseUrl()
}
private val json: Json by injectLazy()
// ============================== Popular ===============================
override fun popularAnimeRequest(page: Int) = GET("$apiUrl/popular?page=$page")
override fun popularAnimeParse(response: Response): AnimesPage {
val data = response.parseAs<PopularResponseDto>()
val animes = data.result.map(::popularAnimeFromObject)
val page = response.request.url.queryParameter("page")?.toIntOrNull() ?: 0
val hasNext = data.page_count > page
return AnimesPage(animes, hasNext)
}
private fun popularAnimeFromObject(anime: PopularItemDto): SAnime {
return SAnime.create().apply {
val useEnglish = preferences.getBoolean(PREF_USE_ENGLISH_KEY, PREF_USE_ENGLISH_DEFAULT)
title = when {
anime.title_en.isNotBlank() && useEnglish -> anime.title_en
else -> anime.title
}
setUrlWithoutDomain("/${anime.slug}")
thumbnail_url = "$baseUrl/${anime.poster.url}"
}
}
// ============================== Episodes ==============================
private fun episodeListRequest(anime: SAnime, page: Int, lang: String) =
GET("$apiUrl${anime.url}/episodes?page=$page&lang=$lang")
private fun getEpisodeResponse(anime: SAnime, page: Int, lang: String): EpisodeResponseDto {
return client.newCall(episodeListRequest(anime, page, lang))
.execute()
.parseAs()
}
override suspend fun getEpisodeList(anime: SAnime): List<SEpisode> {
val languages = client.newCall(
GET("$apiUrl${anime.url}/language"),
).execute().parseAs<LanguagesDto>()
val prefLang = preferences.getString(PREF_AUDIO_LANG_KEY, PREF_AUDIO_LANG_DEFAULT)!!
val lang = languages.result.firstOrNull { it == prefLang } ?: PREF_AUDIO_LANG_DEFAULT
val first = getEpisodeResponse(anime, 1, lang)
val items = buildList {
addAll(first.result)
first.pages.drop(1).forEachIndexed { index, _ ->
addAll(getEpisodeResponse(anime, index + 2, lang).result)
}
}
val episodes = items.map {
SEpisode.create().apply {
name = "Ep. ${it.episode_string} - ${it.title}"
url = "${anime.url}/ep-${it.episode_string}-${it.slug}"
episode_number = it.episode_string.toFloatOrNull() ?: 0F
scanlator = lang.getLocale()
}
}
return episodes.reversed()
}
override fun episodeListParse(response: Response): List<SEpisode> {
TODO("Not yet implemented")
}
// ============================ Video Links =============================
override fun videoListRequest(episode: SEpisode): Request {
val url = apiUrl + episode.url.replace("/ep-", "/episode/ep-")
return GET(url)
}
override fun videoListParse(response: Response): List<Video> {
val videos = response.parseAs<ServersDto>()
val extractor = KickAssAnimeExtractor(client, json, headers)
val hosterSelection = preferences.getStringSet(PREF_HOSTER_KEY, PREF_HOSTER_DEFAULT)!!
val videoList = videos.servers.mapNotNull {
if (!hosterSelection.contains(it.name)) return@mapNotNull null
runCatching { extractor.videosFromUrl(it.src, it.name) }.getOrNull()
}.flatten()
require(videoList.isNotEmpty()) { "Failed to fetch videos" }
return videoList
}
// =========================== Anime Details ============================
override fun getAnimeUrl(anime: SAnime) = "$baseUrl${anime.url}"
override fun animeDetailsRequest(anime: SAnime) = GET(apiUrl + anime.url)
override fun animeDetailsParse(response: Response): SAnime {
val languages = client.newCall(
GET("${response.request.url}/language"),
).execute().parseAs<LanguagesDto>()
val anime = response.parseAs<AnimeInfoDto>()
return SAnime.create().apply {
val useEnglish = preferences.getBoolean(PREF_USE_ENGLISH_KEY, PREF_USE_ENGLISH_DEFAULT)
title = when {
anime.title_en.isNotBlank() && useEnglish -> anime.title_en
else -> anime.title
}
setUrlWithoutDomain("/${anime.slug}")
thumbnail_url = "$baseUrl/${anime.poster.url}"
genre = anime.genres.joinToString()
status = anime.status.parseStatus()
description = buildString {
append(anime.synopsis + "\n\n")
append("Available Dub Languages: ${languages.result.joinToString(", ") { t -> t.getLocale() }}\n")
append(
"Season: ${anime.season.replaceFirstChar {
if (it.isLowerCase()) {
it.titlecase(
Locale.ROOT,
)
} else {
it.toString()
}
}}\n",
)
append("Year: ${anime.year}")
}
}
}
// =============================== Search ===============================
override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request = throw UnsupportedOperationException()
override fun searchAnimeParse(response: Response) = throw UnsupportedOperationException()
private fun searchAnimeParse(response: Response, page: Int): AnimesPage {
val path = response.request.url.encodedPath
return if (path.endsWith("api/fsearch") || path.endsWith("/anime")) {
val data = response.parseAs<SearchResponseDto>()
val animes = data.result.map(::popularAnimeFromObject)
AnimesPage(animes, page < data.maxPage)
} else if (path.endsWith("/recent")) {
latestUpdatesParse(response)
} else {
popularAnimeParse(response)
}
}
private fun searchAnimeRequest(page: Int, query: String, filters: KickAssAnimeFilters.FilterSearchParams): Request {
val newHeaders = headers.newBuilder()
.add("Accept", "application/json, text/plain, */*")
.add("Host", baseUrl.toHttpUrl().host)
.add("Referer", "$baseUrl/anime")
.build()
if (filters.subPage.isNotBlank()) return GET("$baseUrl/api/${filters.subPage}?page=$page", headers = newHeaders)
val encodedFilters = if (filters.filters == "{}") "" else Base64.encodeToString(filters.filters.encodeToByteArray(), Base64.NO_WRAP)
return if (query.isBlank()) {
val url = buildString {
append(baseUrl)
append("/api/anime")
append("?page=$page")
if (encodedFilters.isNotEmpty()) append("&filters=$encodedFilters")
}
GET(url, headers = newHeaders)
} else {
val data = buildJsonObject {
put("page", page)
put("query", query)
if (encodedFilters.isNotEmpty()) put("filters", encodedFilters)
}.toString().toRequestBody("application/json".toMediaType())
POST("$baseUrl/api/fsearch", body = data, headers = newHeaders)
}
}
override suspend fun getSearchAnime(page: Int, query: String, filters: AnimeFilterList): AnimesPage {
return if (query.startsWith(PREFIX_SEARCH)) { // URL intent handler
val slug = query.removePrefix(PREFIX_SEARCH)
client.newCall(GET("$baseUrl/api/show/$slug"))
.awaitSuccess()
.use(::searchAnimeBySlugParse)
} else {
val params = KickAssAnimeFilters.getSearchParameters(filters)
return client.newCall(searchAnimeRequest(page, query, params))
.awaitSuccess()
.let { response ->
searchAnimeParse(response, page)
}
}
}
private fun searchAnimeBySlugParse(response: Response): AnimesPage {
val details = animeDetailsParse(response)
return AnimesPage(listOf(details), false)
}
// ============================== Filters ===============================
override fun getFilterList(): AnimeFilterList = KickAssAnimeFilters.FILTER_LIST
// =============================== Latest ===============================
override fun latestUpdatesParse(response: Response): AnimesPage {
val data = response.parseAs<RecentsResponseDto>()
val animes = data.result.map(::popularAnimeFromObject)
return AnimesPage(animes, data.hadNext)
}
override fun latestUpdatesRequest(page: Int) = GET("$apiUrl/recent?type=all&page=$page")
// ============================= Utilities ==============================
private fun String.getLocale(): String {
return LOCALE.firstOrNull { it.first == this }?.second ?: ""
}
private fun String.parseStatus() = when (this) {
"finished_airing" -> SAnime.COMPLETED
"currently_airing" -> SAnime.ONGOING
else -> SAnime.UNKNOWN
}
override fun List<Video>.sort(): List<Video> {
val quality = preferences.getString(PREF_QUALITY_KEY, PREF_QUALITY_DEFAULT)!!
val server = preferences.getString(PREF_SERVER_KEY, PREF_SERVER_DEFAULT)!!
return sortedWith(
compareBy(
{ it.quality.contains(quality) },
{ Regex("""(\d+)p""").find(it.quality)?.groupValues?.get(1)?.toIntOrNull() ?: 0 },
{ it.quality.contains(server, true) },
{ Regex("""([\d,]+) [KMGTPE]B/s""").find(it.quality)?.groupValues?.get(1)?.replace(",", ".")?.toFloatOrNull() ?: 0F },
),
).reversed()
}
private fun SharedPreferences.clearBaseUrl(): SharedPreferences {
if (getString(PREF_DOMAIN_KEY, "")!! in PREF_DOMAIN_ENTRY_VALUES) {
return this
}
edit()
.remove(PREF_DOMAIN_KEY)
.putString(PREF_DOMAIN_KEY, PREF_DOMAIN_DEFAULT)
.apply()
return this
}
companion object {
private val SERVERS = arrayOf("DuckStream", "BirdStream", "VidStreaming")
const val PREFIX_SEARCH = "slug:"
private const val PREF_USE_ENGLISH_KEY = "pref_use_english"
private const val PREF_USE_ENGLISH_TITLE = "Use English titles"
private const val PREF_USE_ENGLISH_SUMMARY = "Show Titles in English instead of Romanji when possible."
private const val PREF_USE_ENGLISH_DEFAULT = false
private const val PREF_QUALITY_KEY = "preferred_quality"
private const val PREF_QUALITY_TITLE = "Preferred quality"
private const val PREF_QUALITY_DEFAULT = "1080p"
private val PREF_QUALITY_ENTRIES = arrayOf("240p", "360p", "480p", "720p", "1080p", "2160p")
private const val PREF_AUDIO_LANG_KEY = "preferred_audio_lang"
private const val PREF_AUDIO_LANG_TITLE = "Preferred audio language"
private const val PREF_AUDIO_LANG_DEFAULT = "ja-JP"
// Add new locales to the bottom so it doesn't mess with pref indexes
private val LOCALE = arrayOf(
Pair("en-US", "English"),
Pair("es-ES", "Spanish (España)"),
Pair("ja-JP", "Japanese"),
)
private const val PREF_SERVER_KEY = "preferred_server"
private const val PREF_SERVER_TITLE = "Preferred server"
private const val PREF_SERVER_DEFAULT = "DuckStream"
private val PREF_SERVER_VALUES = SERVERS
private const val PREF_DOMAIN_KEY = "preferred_domain"
private const val PREF_DOMAIN_TITLE = "Preferred domain (requires app restart)"
private const val PREF_DOMAIN_DEFAULT = "https://kaas.to"
private val PREF_DOMAIN_ENTRIES = arrayOf("kaas.to", "kaas.ro", "kickassanimes.io", "www1.kickassanime.mx")
private val PREF_DOMAIN_ENTRY_VALUES = PREF_DOMAIN_ENTRIES.map { "https://$it" }.toTypedArray()
private const val PREF_HOSTER_KEY = "hoster_selection"
private const val PREF_HOSTER_TITLE = "Enable/Disable Hosts"
private val PREF_HOSTER_DEFAULT = SERVERS.toSet()
}
// ============================== Settings ==============================
override fun setupPreferenceScreen(screen: PreferenceScreen) {
ListPreference(screen.context).apply {
key = PREF_DOMAIN_KEY
title = PREF_DOMAIN_TITLE
entries = PREF_DOMAIN_ENTRIES
entryValues = PREF_DOMAIN_ENTRY_VALUES
setDefaultValue(PREF_DOMAIN_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()
}
}.also(screen::addPreference)
SwitchPreferenceCompat(screen.context).apply {
key = PREF_USE_ENGLISH_KEY
title = PREF_USE_ENGLISH_TITLE
summary = PREF_USE_ENGLISH_SUMMARY
setDefaultValue(PREF_USE_ENGLISH_DEFAULT)
setOnPreferenceChangeListener { _, newValue ->
val new = newValue as Boolean
preferences.edit().putBoolean(key, new).commit()
}
}.also(screen::addPreference)
ListPreference(screen.context).apply {
key = PREF_AUDIO_LANG_KEY
title = PREF_AUDIO_LANG_TITLE
entries = LOCALE.map { it.second }.toTypedArray()
entryValues = LOCALE.map { it.first }.toTypedArray()
setDefaultValue(PREF_AUDIO_LANG_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()
}
}.also(screen::addPreference)
ListPreference(screen.context).apply {
key = PREF_QUALITY_KEY
title = PREF_QUALITY_TITLE
entries = PREF_QUALITY_ENTRIES
entryValues = PREF_QUALITY_ENTRIES
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()
}
}.also(screen::addPreference)
ListPreference(screen.context).apply {
key = PREF_SERVER_KEY
title = PREF_SERVER_TITLE
entries = PREF_SERVER_VALUES
entryValues = PREF_SERVER_VALUES
setDefaultValue(PREF_SERVER_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()
}
}.also(screen::addPreference)
MultiSelectListPreference(screen.context).apply {
key = PREF_HOSTER_KEY
title = PREF_HOSTER_TITLE
entries = SERVERS
entryValues = SERVERS
setDefaultValue(PREF_HOSTER_DEFAULT)
setOnPreferenceChangeListener { _, newValue ->
@Suppress("UNCHECKED_CAST")
preferences.edit().putStringSet(key, newValue as Set<String>).commit()
}
}.also(screen::addPreference)
}
}

View file

@ -0,0 +1,300 @@
package eu.kanade.tachiyomi.animeextension.en.kickassanime
import eu.kanade.tachiyomi.animesource.model.AnimeFilter
import eu.kanade.tachiyomi.animesource.model.AnimeFilterList
object KickAssAnimeFilters {
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 CheckBoxFilterList(name: String, values: List<CheckBox>) : AnimeFilter.Group<AnimeFilter.CheckBox>(name, values)
private class CheckBoxVal(name: String, state: Boolean = false) : AnimeFilter.CheckBox(name, state)
private inline fun <reified R> AnimeFilterList.asQueryPart(): String {
return this.filterIsInstance<R>().joinToString("") {
(it as QueryPartFilter).toQueryPart()
}
}
class GenreFilter : CheckBoxFilterList(
"Genre",
KickAssAnimeFiltersData.GENRE.map { CheckBoxVal(it.first, false) },
)
class YearFilter : QueryPartFilter("Year", KickAssAnimeFiltersData.YEAR)
class StatusFilter : QueryPartFilter("Status", KickAssAnimeFiltersData.STATUS)
class TypeFilter : QueryPartFilter("Type", KickAssAnimeFiltersData.TYPE)
class SubPageFilter : QueryPartFilter("Sub-page", KickAssAnimeFiltersData.SUBPAGE)
val FILTER_LIST get() = AnimeFilterList(
GenreFilter(),
YearFilter(),
StatusFilter(),
TypeFilter(),
AnimeFilter.Separator(),
AnimeFilter.Header("NOTE: Overrides & ignores search and other filters"),
SubPageFilter(),
)
data class FilterSearchParams(
val filters: String = "",
val subPage: String = "",
)
private fun getJsonList(listString: String, name: String): String {
if (listString.isEmpty()) return ""
return "\"$name\":[$listString]"
}
private fun getJsonItem(item: String, name: String): String {
if (item.isEmpty()) return ""
return "\"$name\":$item"
}
internal fun getSearchParameters(filters: AnimeFilterList): FilterSearchParams {
val genre = filters.filterIsInstance<GenreFilter>()
.first()
.state.mapNotNull { format ->
if (format.state) {
KickAssAnimeFiltersData.GENRE.find { it.first == format.name }!!.second
} else { null }
}.joinToString(",") { "\"$it\"" }
val year = filters.asQueryPart<YearFilter>()
val status = filters.asQueryPart<StatusFilter>()
val type = filters.asQueryPart<TypeFilter>()
val filtersQuery = "{${
listOf(
getJsonList(genre, "genres"),
getJsonItem(year, "year"),
getJsonItem(status, "status"),
getJsonItem(type, "type"),
).filter { it.isNotEmpty() }.joinToString(",")
}}"
return FilterSearchParams(
filtersQuery,
filters.asQueryPart<SubPageFilter>(),
)
}
private object KickAssAnimeFiltersData {
val GENRE = arrayOf(
Pair("Action", "Action"),
Pair("Adult Cast", "Adult Cast"),
Pair("Adventure", "Adventure"),
Pair("Anthropomorphic", "Anthropomorphic"),
Pair("Avant Garde", "Avant Garde"),
Pair("Award Winning", "Award Winning"),
Pair("Boys Love", "Boys Love"),
Pair("CGDCT", "CGDCT"),
Pair("Childcare", "Childcare"),
Pair("Combat Sports", "Combat Sports"),
Pair("Comedy", "Comedy"),
Pair("Crossdressing", "Crossdressing"),
Pair("Delinquents", "Delinquents"),
Pair("Detective", "Detective"),
Pair("Drama", "Drama"),
Pair("Ecchi", "Ecchi"),
Pair("Educational", "Educational"),
Pair("Erotica", "Erotica"),
Pair("Fantasy", "Fantasy"),
Pair("Gag Humor", "Gag Humor"),
Pair("Girls Love", "Girls Love"),
Pair("Gore", "Gore"),
Pair("Gourmet", "Gourmet"),
Pair("Harem", "Harem"),
Pair("Hentai", "Hentai"),
Pair("High Stakes Game", "High Stakes Game"),
Pair("Historical", "Historical"),
Pair("Horror", "Horror"),
Pair("Idols (Female)", "Idols (Female)"),
Pair("Idols (Male)", "Idols (Male)"),
Pair("Isekai", "Isekai"),
Pair("Iyashikei", "Iyashikei"),
Pair("Josei", "Josei"),
Pair("Kids", "Kids"),
Pair("Love Polygon", "Love Polygon"),
Pair("Magical Sex Shift", "Magical Sex Shift"),
Pair("Mahou Shoujo", "Mahou Shoujo"),
Pair("Martial Arts", "Martial Arts"),
Pair("Mecha", "Mecha"),
Pair("Medical", "Medical"),
Pair("Military", "Military"),
Pair("Music", "Music"),
Pair("Mystery", "Mystery"),
Pair("Mythology", "Mythology"),
Pair("Organized Crime", "Organized Crime"),
Pair("Otaku Culture", "Otaku Culture"),
Pair("Parody", "Parody"),
Pair("Performing Arts", "Performing Arts"),
Pair("Pets", "Pets"),
Pair("Psychological", "Psychological"),
Pair("Racing", "Racing"),
Pair("Reincarnation", "Reincarnation"),
Pair("Reverse Harem", "Reverse Harem"),
Pair("Romance", "Romance"),
Pair("Romantic Subtext", "Romantic Subtext"),
Pair("Samurai", "Samurai"),
Pair("School", "School"),
Pair("Sci-Fi", "Sci-Fi"),
Pair("Seinen", "Seinen"),
Pair("Shoujo", "Shoujo"),
Pair("Shounen", "Shounen"),
Pair("Showbiz", "Showbiz"),
Pair("Slice of Life", "Slice of Life"),
Pair("Space", "Space"),
Pair("Sports", "Sports"),
Pair("Strategy Game", "Strategy Game"),
Pair("Super Power", "Super Power"),
Pair("Supernatural", "Supernatural"),
Pair("Survival", "Survival"),
Pair("Suspense", "Suspense"),
Pair("Team Sports", "Team Sports"),
Pair("Time Travel", "Time Travel"),
Pair("Vampire", "Vampire"),
Pair("Video Game", "Video Game"),
Pair("Visual Arts", "Visual Arts"),
Pair("Workplace", "Workplace"),
)
val YEAR = arrayOf(
Pair("All", ""),
Pair("1917", "1917"),
Pair("1918", "1918"),
Pair("1924", "1924"),
Pair("1925", "1925"),
Pair("1926", "1926"),
Pair("1927", "1927"),
Pair("1928", "1928"),
Pair("1929", "1929"),
Pair("1930", "1930"),
Pair("1931", "1931"),
Pair("1932", "1932"),
Pair("1933", "1933"),
Pair("1934", "1934"),
Pair("1935", "1935"),
Pair("1936", "1936"),
Pair("1937", "1937"),
Pair("1938", "1938"),
Pair("1939", "1939"),
Pair("1940", "1940"),
Pair("1941", "1941"),
Pair("1942", "1942"),
Pair("1943", "1943"),
Pair("1944", "1944"),
Pair("1945", "1945"),
Pair("1946", "1946"),
Pair("1947", "1947"),
Pair("1948", "1948"),
Pair("1949", "1949"),
Pair("1950", "1950"),
Pair("1951", "1951"),
Pair("1952", "1952"),
Pair("1953", "1953"),
Pair("1954", "1954"),
Pair("1955", "1955"),
Pair("1956", "1956"),
Pair("1957", "1957"),
Pair("1958", "1958"),
Pair("1959", "1959"),
Pair("1960", "1960"),
Pair("1961", "1961"),
Pair("1962", "1962"),
Pair("1963", "1963"),
Pair("1964", "1964"),
Pair("1965", "1965"),
Pair("1966", "1966"),
Pair("1967", "1967"),
Pair("1968", "1968"),
Pair("1969", "1969"),
Pair("1970", "1970"),
Pair("1971", "1971"),
Pair("1972", "1972"),
Pair("1973", "1973"),
Pair("1974", "1974"),
Pair("1975", "1975"),
Pair("1976", "1976"),
Pair("1977", "1977"),
Pair("1978", "1978"),
Pair("1979", "1979"),
Pair("1980", "1980"),
Pair("1981", "1981"),
Pair("1982", "1982"),
Pair("1983", "1983"),
Pair("1984", "1984"),
Pair("1985", "1985"),
Pair("1986", "1986"),
Pair("1987", "1987"),
Pair("1988", "1988"),
Pair("1989", "1989"),
Pair("1990", "1990"),
Pair("1991", "1991"),
Pair("1992", "1992"),
Pair("1993", "1993"),
Pair("1994", "1994"),
Pair("1995", "1995"),
Pair("1996", "1996"),
Pair("1997", "1997"),
Pair("1998", "1998"),
Pair("1999", "1999"),
Pair("2000", "2000"),
Pair("2001", "2001"),
Pair("2002", "2002"),
Pair("2003", "2003"),
Pair("2004", "2004"),
Pair("2005", "2005"),
Pair("2006", "2006"),
Pair("2007", "2007"),
Pair("2008", "2008"),
Pair("2009", "2009"),
Pair("2010", "2010"),
Pair("2011", "2011"),
Pair("2012", "2012"),
Pair("2013", "2013"),
Pair("2014", "2014"),
Pair("2015", "2015"),
Pair("2016", "2016"),
Pair("2017", "2017"),
Pair("2018", "2018"),
Pair("2019", "2019"),
Pair("2020", "2020"),
Pair("2021", "2021"),
Pair("2022", "2022"),
Pair("2023", "2023"),
Pair("2024", "2024"),
)
val STATUS = arrayOf(
Pair("All", ""),
Pair("Finished Airing", "\"finished\""),
Pair("Currently Airing", "\"airing\""),
)
val TYPE = arrayOf(
Pair("All", ""),
Pair("TV", "\"tv\""),
Pair("OVA", "\"ova\""),
Pair("ONA", "\"ona\""),
Pair("SPECIAL", "\"special\""),
Pair("MOVIE", "\"movie\""),
Pair("UNKNOWN", "\"unknown\""),
Pair("MUSIC", "\"music\""),
)
val SUBPAGE = arrayOf(
Pair("<Select>", ""),
Pair("Trending", "show/trending"),
Pair("Anime", "anime"),
Pair("Recently Added", "show/recent"),
Pair("Popular Shows", "show/popular"),
)
}
}

View file

@ -0,0 +1,41 @@
package eu.kanade.tachiyomi.animeextension.en.kickassanime
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://kaas.am/<item> intents
* and redirects them to the main Aniyomi process.
*/
class KickAssAnimeUrlActivity : 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 slug = pathSegments[0]
val mainIntent = Intent().apply {
action = "eu.kanade.tachiyomi.ANIMESEARCH"
putExtra("query", "${KickAssAnime.PREFIX_SEARCH}$slug")
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,95 @@
package eu.kanade.tachiyomi.animeextension.en.kickassanime.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonObject
@Serializable
data class PopularResponseDto(
val page_count: Int,
val result: List<PopularItemDto>,
)
@Serializable
data class PopularItemDto(
val title: String,
val title_en: String = "",
val slug: String,
val poster: PosterDto,
)
@Serializable
data class SearchResponseDto(
val result: List<PopularItemDto>,
val maxPage: Int,
)
@Serializable
data class PosterDto(@SerialName("hq") val slug: String) {
val url by lazy { "image/poster/$slug.webp" }
}
@Serializable
data class RecentsResponseDto(
val hadNext: Boolean,
val result: List<PopularItemDto>,
)
@Serializable
data class AnimeInfoDto(
val genres: List<String>,
val poster: PosterDto,
val season: String,
val slug: String,
val status: String,
val synopsis: String,
val title: String,
val title_en: String = "",
val year: Int,
)
@Serializable
data class EpisodeResponseDto(
val pages: List<JsonObject>, // We dont care about its contents, only the size
val result: List<EpisodeDto> = emptyList(),
) {
@Serializable
data class EpisodeDto(
val slug: String,
val title: String? = "",
val episode_string: String,
)
}
@Serializable
data class ServersDto(val servers: List<Server>) {
@Serializable
data class Server(
val name: String,
val src: String,
)
}
@Serializable
data class VideoDto(
val hls: String = "",
val dash: String = "",
val subtitles: List<SubtitlesDto> = emptyList(),
) {
val playlistUrl by lazy {
hls.ifEmpty { dash }.let { uri ->
when {
uri.startsWith("//") -> "https:$uri"
else -> uri
}
}
}
@Serializable
data class SubtitlesDto(val name: String, val language: String, val src: String)
}
@Serializable
data class LanguagesDto(
val result: List<String>,
)

View file

@ -0,0 +1,146 @@
package eu.kanade.tachiyomi.animeextension.en.kickassanime.extractors
import eu.kanade.tachiyomi.animeextension.en.kickassanime.dto.VideoDto
import eu.kanade.tachiyomi.animesource.model.Track
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.lib.cryptoaes.CryptoAES
import eu.kanade.tachiyomi.lib.cryptoaes.CryptoAES.decodeHex
import eu.kanade.tachiyomi.lib.playlistutils.PlaylistUtils
import eu.kanade.tachiyomi.network.GET
import kotlinx.serialization.json.Json
import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import java.security.MessageDigest
class KickAssAnimeExtractor(
private val client: OkHttpClient,
private val json: Json,
private val headers: Headers,
) {
fun videosFromUrl(url: String, name: String): List<Video> {
val host = url.toHttpUrl().host
val mid = if (name == "DuckStream") "mid" else "id"
val isBird = name == "BirdStream"
val query = url.toHttpUrl().queryParameter(mid)!!
val html = client.newCall(GET(url, headers)).execute().body.string()
val key = when (name) {
"VidStreaming" -> "e13d38099bf562e8b9851a652d2043d3"
"DuckStream" -> "4504447b74641ad972980a6b8ffd7631"
"BirdStream" -> "4b14d0ff625163e3c9c7a47926484bf2"
else -> return emptyList()
}.toByteArray()
val (sig, timeStamp, route) = getSignature(html, name, query, key) ?: return emptyList()
val sourceUrl = buildString {
append("https://")
append(host)
append(route)
append("?$mid=$query")
if (!isBird) append("&e=$timeStamp")
append("&s=$sig")
}
val request = GET(sourceUrl, headers.newBuilder().add("Referer", url).build())
val response = client.newCall(request).execute()
.body.string()
val (encryptedData, ivhex) = response.substringAfter(":\"")
.substringBefore('"')
.replace("\\", "")
.split(":")
val iv = ivhex.decodeHex()
val videoObject = try {
val decrypted = CryptoAES.decrypt(encryptedData, key, iv)
json.decodeFromString<VideoDto>(decrypted)
} catch (e: Exception) {
e.printStackTrace()
return emptyList()
}
val subtitles = videoObject.subtitles.map {
val subUrl: String = it.src.let { src ->
if (src.startsWith("//")) {
"https:$src"
} else if (src.startsWith("/")) {
"https://$host$src"
} else {
src
}
}
val language = "${it.name} (${it.language})"
Track(subUrl, language)
}
fun getVideoHeaders(baseHeaders: Headers, referer: String, videoUrl: String): Headers {
return baseHeaders.newBuilder().apply {
add("Accept", "*/*")
add("Accept-Language", "en-US,en;q=0.5")
add("Origin", "https://$host")
add("Sec-Fetch-Dest", "empty")
add("Sec-Fetch-Mode", "cors")
add("Sec-Fetch-Site", "cross-site")
}.build()
}
return when {
videoObject.hls.isBlank() ->
PlaylistUtils(client, headers).extractFromDash(videoObject.playlistUrl, videoNameGen = { res -> "$name - $res" }, subtitleList = subtitles)
else -> PlaylistUtils(client, headers).extractFromHls(
videoObject.playlistUrl,
videoNameGen = { "$name - $it" },
videoHeadersGen = ::getVideoHeaders,
subtitleList = subtitles,
)
}
}
private fun getSignature(html: String, server: String, query: String, key: ByteArray): Triple<String, String, String>? {
val order = when (server) {
"VidStreaming" -> listOf("IP", "USERAGENT", "ROUTE", "MID", "TIMESTAMP", "KEY")
"DuckStream" -> listOf("IP", "USERAGENT", "ROUTE", "MID", "TIMESTAMP", "KEY")
"BirdStream" -> listOf("IP", "USERAGENT", "ROUTE", "MID", "KEY")
else -> return null
}
val cid = String(html.substringAfter("cid: '").substringBefore("'").decodeHex()).split("|")
val timeStamp = (System.currentTimeMillis() / 1000 + 60).toString()
val route = cid[1].replace("player.php", "source.php")
val signature = buildString {
order.forEach {
when (it) {
"IP" -> append(cid[0])
"USERAGENT" -> append(headers["User-Agent"] ?: "")
"ROUTE" -> append(route)
"MID" -> append(query)
"TIMESTAMP" -> append(timeStamp)
"KEY" -> append(String(key))
"SIG" -> append(html.substringAfter("signature: '").substringBefore("'"))
else -> {}
}
}
}
return Triple(sha1sum(signature), timeStamp, route)
}
private fun sha1sum(value: String): String {
return try {
val md = MessageDigest.getInstance("SHA-1")
val bytes = md.digest(value.toByteArray())
bytes.joinToString("") { "%02x".format(it) }
} catch (e: Exception) {
throw Exception("Attempt to create the signature failed miserably.")
}
}
// ============================= Utilities ==============================
}