KickAssAnime: Adapt to the current (beta) source (#1509)

This commit is contained in:
Claudemirovsky 2023-04-18 07:39:59 -03:00 committed by GitHub
parent e34f97d80c
commit 2fe9d1741e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 668 additions and 784 deletions

View File

@ -0,0 +1,18 @@
plugins {
id("com.android.library")
kotlin("android")
}
android {
compileSdk = AndroidConfig.compileSdk
namespace = "eu.kanade.tachiyomi.lib.cryptoaes"
defaultConfig {
minSdk = AndroidConfig.minSdk
targetSdk = AndroidConfig.targetSdk
}
}
dependencies {
compileOnly(libs.kotlin.stdlib)
}

View File

@ -0,0 +1,154 @@
package eu.kanade.tachiyomi.lib.cryptoaes
/*
* Copyright (C) The Tachiyomi Open Source Project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
// Thanks to Vlad on Stackoverflow: https://stackoverflow.com/a/63701411
import android.util.Base64
import java.security.MessageDigest
import java.util.Arrays
import javax.crypto.Cipher
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
/**
* Conforming with CryptoJS AES method
*/
@Suppress("unused", "FunctionName")
object CryptoAES {
private const val KEY_SIZE = 256
private const val IV_SIZE = 128
private const val HASH_CIPHER = "AES/CBC/PKCS7PADDING"
private const val HASH_CIPHER_FALLBACK = "AES/CBC/PKCS5PADDING"
private const val AES = "AES"
private const val KDF_DIGEST = "MD5"
/**
* Decrypt using CryptoJS defaults compatible method.
* Uses KDF equivalent to OpenSSL's EVP_BytesToKey function
*
* http://stackoverflow.com/a/29152379/4405051
* @param cipherText base64 encoded ciphertext
* @param password passphrase
*/
fun decrypt(cipherText: String, password: String): String {
try {
val ctBytes = Base64.decode(cipherText, Base64.DEFAULT)
val saltBytes = Arrays.copyOfRange(ctBytes, 8, 16)
val cipherTextBytes = Arrays.copyOfRange(ctBytes, 16, ctBytes.size)
val md5: MessageDigest = MessageDigest.getInstance("MD5")
val keyAndIV = generateKeyAndIV(32, 16, 1, saltBytes, password.toByteArray(Charsets.UTF_8), md5)
return decryptAES(
cipherTextBytes,
keyAndIV?.get(0) ?: ByteArray(32),
keyAndIV?.get(1) ?: ByteArray(16),
)
} catch (e: Exception) {
return ""
}
}
/**
* Decrypt using CryptoJS defaults compatible method.
*
* @param cipherText base64 encoded ciphertext
* @param keyBytes key as a bytearray
* @param ivBytes iv as a bytearray
*/
fun decrypt(cipherText: String, keyBytes: ByteArray, ivBytes: ByteArray): String {
return try {
val cipherTextBytes = Base64.decode(cipherText, Base64.DEFAULT)
decryptAES(cipherTextBytes, keyBytes, ivBytes)
} catch (e: Exception) {
""
}
}
/**
* Decrypt using CryptoJS defaults compatible method.
*
* @param cipherTextBytes encrypted text as a bytearray
* @param keyBytes key as a bytearray
* @param ivBytes iv as a bytearray
*/
private fun decryptAES(cipherTextBytes: ByteArray, keyBytes: ByteArray, ivBytes: ByteArray): String {
return try {
val cipher = try {
Cipher.getInstance(HASH_CIPHER)
} catch (e: Throwable) { Cipher.getInstance(HASH_CIPHER_FALLBACK) }
val keyS = SecretKeySpec(keyBytes, AES)
cipher.init(Cipher.DECRYPT_MODE, keyS, IvParameterSpec(ivBytes))
cipher.doFinal(cipherTextBytes).toString(Charsets.UTF_8)
} catch (e: Exception) {
""
}
}
/**
* Generates a key and an initialization vector (IV) with the given salt and password.
*
* https://stackoverflow.com/a/41434590
* This method is equivalent to OpenSSL's EVP_BytesToKey function
* (see https://github.com/openssl/openssl/blob/master/crypto/evp/evp_key.c).
* By default, OpenSSL uses a single iteration, MD5 as the algorithm and UTF-8 encoded password data.
*
* @param keyLength the length of the generated key (in bytes)
* @param ivLength the length of the generated IV (in bytes)
* @param iterations the number of digestion rounds
* @param salt the salt data (8 bytes of data or `null`)
* @param password the password data (optional)
* @param md the message digest algorithm to use
* @return an two-element array with the generated key and IV
*/
private fun generateKeyAndIV(keyLength: Int, ivLength: Int, iterations: Int, salt: ByteArray, password: ByteArray, md: MessageDigest): Array<ByteArray?>? {
val digestLength = md.digestLength
val requiredLength = (keyLength + ivLength + digestLength - 1) / digestLength * digestLength
val generatedData = ByteArray(requiredLength)
var generatedLength = 0
return try {
md.reset()
// Repeat process until sufficient data has been generated
while (generatedLength < keyLength + ivLength) {
// Digest data (last digest if available, password data, salt if available)
if (generatedLength > 0) md.update(generatedData, generatedLength - digestLength, digestLength)
md.update(password)
md.update(salt, 0, 8)
md.digest(generatedData, generatedLength, digestLength)
// additional rounds
for (i in 1 until iterations) {
md.update(generatedData, generatedLength, digestLength)
md.digest(generatedData, generatedLength, digestLength)
}
generatedLength += digestLength
}
// Copy key and IV into separate byte arrays
val result = arrayOfNulls<ByteArray>(2)
result[0] = generatedData.copyOfRange(0, keyLength)
if (ivLength > 0) result[1] = generatedData.copyOfRange(keyLength, keyLength + ivLength)
result
} catch (e: Exception) {
throw e
} finally {
// Clean out temporary data
Arrays.fill(generatedData, 0.toByte())
}
}
// Stolen from AnimixPlay(EN) / GogoCdnExtractor
fun String.decodeHex(): ByteArray {
check(length % 2 == 0) { "Must have an even length" }
return chunked(2)
.map { it.toInt(16).toByte() }
.toByteArray()
}
}

View File

@ -0,0 +1,82 @@
package eu.kanade.tachiyomi.lib.cryptoaes
/*
* Copyright (C) The Tachiyomi Open Source Project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/**
* Helper class to deobfuscate JavaScript strings encoded in JSFuck style.
*
* More info on JSFuck found [here](https://en.wikipedia.org/wiki/JSFuck).
*
* Currently only supports Numeric and decimal ('.') characters
*/
object Deobfuscator {
fun deobfuscateJsPassword(inputString: String): String {
var idx = 0
val brackets = listOf<Char>('[', '(')
var evaluatedString = StringBuilder()
while (idx < inputString.length) {
val chr = inputString[idx]
if (chr !in brackets) {
idx++
continue
}
val closingIndex = getMatchingBracketIndex(idx, inputString)
if (chr == '[') {
val digit = calculateDigit(inputString.substring(idx, closingIndex))
evaluatedString.append(digit)
} else {
evaluatedString.append('.')
if (inputString.getOrNull(closingIndex + 1) == '[') {
val skippingIndex = getMatchingBracketIndex(closingIndex + 1, inputString)
idx = skippingIndex + 1
continue
}
}
idx = closingIndex + 1
}
return evaluatedString.toString()
}
private fun getMatchingBracketIndex(openingIndex: Int, inputString: String): Int {
val openingBracket = inputString[openingIndex]
val closingBracket = when (openingBracket) {
'[' -> ']'
else -> ')'
}
var counter = 0
for (idx in openingIndex until inputString.length) {
if (inputString[idx] == openingBracket) counter++
if (inputString[idx] == closingBracket) counter--
if (counter == 0) return idx // found matching bracket
if (counter < 0) return -1 // unbalanced brackets
}
return -1 // matching bracket not found
}
private fun calculateDigit(inputSubString: String): Char {
/* 0 == '+[]'
1 == '+!+[]'
2 == '!+[]+!+[]'
3 == '!+[]+!+[]+!+[]'
...
therefore '!+[]' count equals the digit
if count equals 0, check for '+[]' just to be sure
*/
val digit = "\\!\\+\\[\\]".toRegex().findAll(inputSubString).count() // matches '!+[]'
if (digit == 0) {
if ("\\+\\[\\]".toRegex().findAll(inputSubString).count() == 1) { // matches '+[]'
return '0'
}
} else if (digit in 1..9) {
return digit.digitToChar()
}
return '-' // Illegal digit
}
}

View File

@ -1,2 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest package="eu.kanade.tachiyomi.animeextension" />
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="eu.kanade.tachiyomi.animeextension">
<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

@ -1,18 +1,18 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlinx-serialization'
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.serialization)
}
ext {
extName = 'KickAssAnime'
pkgNameSuffix = 'en.kickassanime'
extClass = '.KickAssAnime'
extVersionCode = 21
libVersion = '13'
extVersionCode = 22
}
dependencies {
implementation(project(':lib-streamsb-extractor'))
implementation(project(':lib-dood-extractor'))
implementation(project(":lib-cryptoaes"))
}
apply from: "$rootDir/common.gradle"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

View File

@ -1,88 +0,0 @@
package eu.kanade.tachiyomi.animeextension.en.kickassanime;
public class JSONUtil {
public static String escape(String input) {
StringBuilder output = new StringBuilder();
for(int i=0; i<input.length(); i++) {
char ch = input.charAt(i);
int chx = (int) ch;
// let's not put any nulls in our strings
assert(chx != 0);
if(ch == '\n') {
output.append("\\n");
} else if(ch == '\t') {
output.append("\\t");
} else if(ch == '\r') {
output.append("\\r");
} else if(ch == '\\') {
output.append("\\\\");
} else if(ch == '"') {
output.append("\\\"");
} else if(ch == '\b') {
output.append("\\b");
} else if(ch == '\f') {
output.append("\\f");
} else if(chx >= 0x10000) {
assert false : "Java stores as u16, so it should never give us a character that's bigger than 2 bytes. It literally can't.";
} else if(chx > 127) {
output.append(String.format("\\u%04x", chx));
} else {
output.append(ch);
}
}
return output.toString();
}
public static String unescape(String input) {
StringBuilder builder = new StringBuilder();
int i = 0;
while (i < input.length()) {
char delimiter = input.charAt(i); i++; // consume letter or backslash
if(delimiter == '\\' && i < input.length()) {
// consume first after backslash
char ch = input.charAt(i); i++;
if(ch == '\\' || ch == '/' || ch == '"' || ch == '\'') {
builder.append(ch);
}
else if(ch == 'n') builder.append('\n');
else if(ch == 'r') builder.append('\r');
else if(ch == 't') builder.append('\t');
else if(ch == 'b') builder.append('\b');
else if(ch == 'f') builder.append('\f');
else if(ch == 'u') {
StringBuilder hex = new StringBuilder();
// expect 4 digits
if (i+4 > input.length()) {
throw new RuntimeException("Not enough unicode digits! ");
}
for (char x : input.substring(i, i + 4).toCharArray()) {
if(!Character.isLetterOrDigit(x)) {
throw new RuntimeException("Bad character in unicode escape.");
}
hex.append(Character.toLowerCase(x));
}
i+=4; // consume those four digits.
int code = Integer.parseInt(hex.toString(), 16);
builder.append((char) code);
} else {
throw new RuntimeException("Illegal escape sequence: \\"+ch);
}
} else { // it's not a backslash, or it's the last character.
builder.append(delimiter);
}
}
return builder.toString();
}
}

View File

@ -2,610 +2,254 @@ package eu.kanade.tachiyomi.animeextension.en.kickassanime
import android.app.Application
import android.content.SharedPreferences
import android.net.Uri
import android.util.Base64
import androidx.preference.ListPreference
import androidx.preference.PreferenceScreen
import eu.kanade.tachiyomi.animeextension.en.kickassanime.extractors.GogoCdnExtractor
import eu.kanade.tachiyomi.animeextension.en.kickassanime.extractors.PinkBird
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.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.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.Track
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.animesource.online.AnimeHttpSource
import eu.kanade.tachiyomi.lib.doodextractor.DoodExtractor
import eu.kanade.tachiyomi.lib.streamsbextractor.StreamSBExtractor
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.util.asJsoup
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.asObservableSuccess
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.float
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import org.jsoup.nodes.Document
import rx.Observable
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.regex.Pattern
@ExperimentalSerializationApi
class KickAssAnime : ConfigurableAnimeSource, AnimeHttpSource() {
override val name = "KickAssAnime"
override val baseUrl by lazy {
preferences.getString(
"preferred_domain",
"https://www2.kickassanime.ro",
)!!
}
override val baseUrl = "https://kaas.am"
private val API_URL = "$baseUrl/api/show"
override val lang = "en"
override val supportsLatest = false
override val client: OkHttpClient = network.cloudflareClient
private val json: Json by injectLazy()
override val supportsLatest = true
private val preferences: SharedPreferences by lazy {
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
}
companion object {
private val DateFormatter by lazy {
SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.ENGLISH)
}
private val json = Json {
ignoreUnknownKeys = true
}
// Add non working server names here
private val deadServers = listOf(
"BETASERVER1",
"BETASERVER3",
"DEVSTREAM",
"THETA-ORIGINAL-V4",
"KICKASSANIME1",
)
private val workingServers = arrayOf(
"StreamSB", "PINK-BIRD", "Doodstream", "MAVERICKKI", "BETA-SERVER", "DAILYMOTION",
"BETAPLAYER", "Vidstreaming", "SAPPHIRE-DUCK", "KICKASSANIMEV2", "ORIGINAL-QUALITY-V2",
)
override fun popularAnimeRequest(page: Int): Request =
GET("$baseUrl/api/get_anime_list/all/$page")
// ============================== Popular ===============================
override fun popularAnimeRequest(page: Int) = GET("$API_URL/popular?page=$page")
override fun popularAnimeParse(response: Response): AnimesPage {
val responseObject = json.decodeFromString<JsonObject>(response.body.string())
val data = responseObject["data"]!!.jsonArray
val animes = data.map { item ->
SAnime.create().apply {
setUrlWithoutDomain(
item.jsonObject["slug"]!!.jsonPrimitive.content.substringBefore(
"/episode",
),
)
thumbnail_url =
"$baseUrl/uploads/" + item.jsonObject["poster"]!!.jsonPrimitive.content
title = item.jsonObject["name"]!!.jsonPrimitive.content
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}"
}
}
return AnimesPage(animes, true)
// ============================== Episodes ==============================
private fun episodeListRequest(anime: SAnime, page: Int) =
GET("$API_URL/${anime.url}/episodes?page=$page&lang=ja-JP")
private fun getEpisodeResponse(anime: SAnime, page: Int): EpisodeResponseDto {
return client.newCall(episodeListRequest(anime, page))
.execute()
.parseAs<EpisodeResponseDto>()
}
override fun fetchEpisodeList(anime: SAnime): Observable<List<SEpisode>> {
val first = getEpisodeResponse(anime, 1)
val items = buildList {
addAll(first.result)
first.pages.drop(1).forEachIndexed { index, _ ->
addAll(getEpisodeResponse(anime, index + 2).result)
}
}
val episodes = items.map {
SEpisode.create().apply {
name = it.title
url = "${anime.url}/ep-${it.episode_string}-${it.slug}"
episode_number = it.episode_string.toFloatOrNull() ?: 0F
}
}
return Observable.just(episodes.reversed())
}
override fun episodeListParse(response: Response): List<SEpisode> {
val data = getAppdata(response.asJsoup())
val anime = data["anime"]!!.jsonObject
val episodeList = anime["episodes"]!!.jsonArray
return episodeList.map { item ->
SEpisode.create().apply {
url = item.jsonObject["slug"]!!.jsonPrimitive.content
episode_number = item.jsonObject["num"]!!.jsonPrimitive.float
name = item.jsonObject["epnum"]!!.jsonPrimitive.content
date_upload = parseDate(item.jsonObject["createddate"]!!.jsonPrimitive.content)
}
}
TODO("Not yet implemented")
}
private fun parseDate(dateStr: String): Long {
return runCatching { DateFormatter.parse(dateStr)?.time }
.getOrNull() ?: 0L
// ============================ Video Links =============================
override fun videoListRequest(episode: SEpisode): Request {
val url = API_URL + episode.url.replace("/ep-", "/episode/ep-")
return GET(url)
}
override fun latestUpdatesParse(response: Response) = throw Exception("not used")
override fun latestUpdatesRequest(page: Int) = throw Exception("not used")
override fun videoListParse(response: Response): List<Video> {
val data = getAppdata(response.asJsoup())
val episode = data["episode"]!!.jsonObject
var link = episode["link1"]!!.jsonPrimitive.content
// check if link1 is not blank (link2-4 doesn't work), if so check external servers for gogo links
if (link.isBlank()) {
for (li in data["ext_servers"]!!.jsonArray) {
if (li.jsonObject["name"]!!.jsonPrimitive.content == "Vidcdn") {
link = li.jsonObject["link"]!!.jsonPrimitive.content
break
}
}
}
if (link.isBlank()) return listOf()
val videoList = mutableListOf<Video>()
when {
link.contains("gogoplay4.com") -> {
videoList.addAll(
extractGogoVideo(link),
)
}
link.contains("betaplayer.life") -> {
var url = decode(link).substringAfter("data=").substringBefore("&vref")
if (url.startsWith("https").not()) {
url = "https:$url"
}
videoList.addAll(
extractBetaVideo(url, "BETAPLAYER"),
)
}
else -> {
val resp = client.newCall(GET(link)).execute()
val sources = getVideoSource(resp.asJsoup())
videoList.addAll(
sources.parallelMap { source ->
runCatching {
val src = source.jsonObject["src"]!!.jsonPrimitive.content
val name = source.jsonObject["name"]!!.jsonPrimitive.content
when (name) {
in deadServers -> { null }
"SAPPHIRE-DUCK" -> {
extractSapphireVideo(src, name)
}
"PINK-BIRD" -> {
PinkBird(client, json).videosFromUrl(src, name)
}
"BETAPLAYER" -> {
extractBetaVideo(src, name)
}
"KICKASSANIMEV2", "ORIGINAL-QUALITY-V2", "BETA-SERVER" -> {
extractKickasssVideo(src, name)
}
"DAILYMOTION" -> {
extractDailymotion(src, name)
}
"MAVERICKKI" -> {
extractMavrick(src, name)
}
else -> null
}
}.getOrNull()
}.filterNotNull().flatten(),
)
}
}
return videoList
val videos = response.parseAs<ServersDto>()
// Just to see the responses at mitmproxy
val extractor = KickAssAnimeExtractor(client, json)
return videos.servers.flatMap(extractor::videosFromUrl)
}
private fun extractMavrick(serverLink: String, server: String): List<Video> {
val playlist = mutableListOf<Video>()
val subsList = mutableListOf<Track>()
val apiLink = serverLink.replace("embed", "api/source")
val embedHeader = Headers.headersOf("referer", serverLink)
val apiResponse = client.newCall(GET(apiLink, embedHeader)).execute()
val json = Json.decodeFromString<JsonObject>(apiResponse.body.string())
val uri = Uri.parse(serverLink)
// =========================== Anime Details ============================
// Uncomment when extensions-lib v14 gets released
// tested with extensions-lib:9d3dcb0
// override fun getAnimeUrl(anime: SAnime) = "$baseUrl${anime.url}"
json["subtitles"]!!.jsonArray.forEach {
val subLang = it.jsonObject["name"]!!.jsonPrimitive.content
val subUrl = "${uri.scheme}://${uri.host}" + it.jsonObject["src"]!!.jsonPrimitive.content
try {
subsList.add(Track(subUrl, subLang))
} catch (_: Error) {}
}
val resp = client.newCall(GET("${uri.scheme}://${uri.host}" + json["hls"]!!.jsonPrimitive.content, embedHeader)).execute()
override fun animeDetailsRequest(anime: SAnime) = GET("$API_URL/${anime.url}")
resp.body.string().substringAfter("#EXT-X-STREAM-INF:")
.split("#EXT-X-STREAM-INF:").map {
val quality = it.substringAfter("RESOLUTION=").split(",")[0].split("\n")[0].substringAfter("x") + "p $server" +
if (subsList.size > 0) { " (Toggleable Sub Available)" } else { "" }
var videoUrl = it.substringAfter("\n").substringBefore("\n")
if (videoUrl.startsWith("https").not()) {
videoUrl = resp.request.url.toString().substringBeforeLast("/") + "/$videoUrl"
override fun animeDetailsParse(response: Response): SAnime {
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
}
try {
playlist.add(Video(videoUrl, quality, videoUrl, subtitleTracks = subsList, headers = embedHeader))
} catch (e: Error) {
playlist.add(Video(videoUrl, quality, videoUrl, headers = embedHeader))
}
}
return playlist
}
private fun extractBetaVideo(serverLink: String, server: String): List<Video> {
val headers = Headers.headersOf("referer", "https://kaast1.com/")
val document = client.newCall(GET(serverLink, headers)).execute().asJsoup()
var playlistArray = JsonArray(arrayListOf())
document.selectFirst("script:containsData(window.files)")?.data()?.let {
val pattern = Pattern.compile(".*JSON\\.parse\\('(.*)'\\)")
val matcher = pattern.matcher(it)
if (matcher.find()) {
playlistArray = json.decodeFromString(matcher.group(1)!!.toString())
}
}
val playlist = mutableListOf<Video>()
playlistArray.forEach {
val quality = it.jsonObject["label"]!!.jsonPrimitive.content + " $server"
val videoUrl = it.jsonObject["file"]!!.jsonPrimitive.content
playlist.add(
Video(videoUrl, quality, videoUrl, headers = headers),
)
}
return playlist
}
private fun extractKickasssVideo(serverLink: String, server: String): List<Video> {
val url = serverLink.replace("(?:embed|player)\\.php".toRegex(), "pref.php")
val document = client.newCall(GET(url)).execute().asJsoup()
var playlistArray = JsonArray(arrayListOf())
document.selectFirst("script:containsData(document.write)")?.data()?.let {
val pattern = if (server.contains("Beta", true)) {
Pattern.compile(".*decode\\(\"(.*)\"\\)")
} else {
Pattern.compile(".*atob\\(\"(.*)\"\\)")
}
val matcher = pattern.matcher(it)
if (matcher.find()) {
val player = matcher.group(1)!!.toString().decodeBase64()
val playerPattern = Pattern.compile(".*sources:[ ]*\\[(.*)\\]")
val playerMatcher = playerPattern.matcher(player)
if (playerMatcher.find()) {
val playlistString = "[" + playerMatcher.group(1)!!.toString() + "]"
playlistArray = json.decodeFromString(playlistString)
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("Season: ${anime.season.capitalize()}\n")
append("Year: ${anime.year}")
}
}
}
val playlist = mutableListOf<Video>()
playlistArray.forEach {
val quality = it.jsonObject["label"]!!.jsonPrimitive.content + " $server"
val videoUrl = it.jsonObject["file"]!!.jsonPrimitive.content
playlist.add(
Video(videoUrl, quality, videoUrl, headers = headers),
)
}
return playlist
}
private fun extractDailymotion(serverLink: String, server: String): List<Video> {
val url = serverLink.replace("player.php", "pref.php")
val document = client.newCall(GET(url)).execute().asJsoup()
var masterPlaylist = listOf<Video>()
document.selectFirst("script:containsData(Base64.decode)")?.data()?.let { iframe ->
val embedUrl = iframe.substringAfter("decode(\"").substringBefore("\"").decodeBase64()
.substringAfter("src=\"").substringBefore("\"").substringBefore("?")
.replace("/embed/", "/player/metadata/")
val response = client.newCall(GET(embedUrl, headers)).execute()
val decodedJson = json.decodeFromString<DailyQuality>(response.body.string())
masterPlaylist = decodedJson.qualities.auto.parallelMap { item ->
runCatching {
val resp = client.newCall(GET(item.url)).execute().body.string()
resp.substringAfter("#EXT-X-STREAM-INF:")
.split("#EXT-X-STREAM-INF:").map {
val videoUrl = it.substringAfter("\n").substringBefore("\n")
val proxy = videoUrl.substringAfter("proxy-").substringBefore(".")
val quality = it.substringAfter("RESOLUTION=").split(",")[0].split("\n")[0].substringAfter("x") + "p $server" +
if (proxy.isNotBlank()) " $proxy" else ""
Video(videoUrl, quality, videoUrl, headers = Headers.headersOf("referer", "https://www.dailymotion.com/"))
}
}.getOrNull()
}.filterNotNull().flatten().distinct()
}
return masterPlaylist
}
private fun String.decodeBase64(): String {
return Base64.decode(this, Base64.DEFAULT).toString(Charsets.UTF_8)
}
private fun extractSapphireVideo(serverLink: String, server: String): List<Video> {
val url = serverLink.toHttpUrl().newBuilder().addQueryParameter("action", "config").build()
val response = client.newCall(GET(url.toString(), Headers.headersOf("referer", serverLink))).execute()
val rawJson = response.body.string().let {
var decoded = it
while (!decoded.startsWith("{\"id")) decoded = decoded.decodeBase64()
return@let decoded
}
val decodedJson = json.decodeFromString<Sapphire>(rawJson)
val subsList = decodedJson.subtitles.mapNotNull {
try {
Track(it.url, it.language.getLocale())
} catch (_: Error) {
null
}
}
return decodedJson.streams.filter { it.format == "adaptive_hls" }.parallelMap { stream ->
runCatching {
val playlist = client.newCall(GET(stream.url)).execute().body.string()
playlist.substringAfter("#EXT-X-STREAM-INF:")
.split("#EXT-X-STREAM-INF:").map {
val quality = it.substringAfter("RESOLUTION=").split(",")[0].split("\n")[0].substringAfter("x") + "p $server" +
(if (stream.audio.getLocale().isNotBlank()) " - Aud: ${stream.audio.getLocale()}" else "") +
(if (stream.hardSub.getLocale().isNotBlank()) " - HardSub: ${stream.hardSub}" else "")
val videoUrl = it.substringAfter("\n").substringBefore("\n")
try {
Video(videoUrl, quality, videoUrl, subtitleTracks = subsList)
} catch (e: Error) {
Video(videoUrl, quality, videoUrl)
}
}
}.getOrNull()
}
.filterNotNull()
.flatten()
}
private fun extractGogoVideo(link: String): List<Video> {
var url = decode(link).substringAfter("data=").substringBefore("&vref")
if (url.startsWith("https").not()) {
url = "https:$url"
}
val videoList = mutableListOf<Video>()
val document = client.newCall(GET(url)).execute().asJsoup()
// Vidstreaming:
videoList.addAll(GogoCdnExtractor(client, json).videosFromUrl(url))
// Doodstream mirror:
document.select("div#list-server-more > ul > li.linkserver:contains(Doodstream)")
.firstOrNull()?.attr("data-video")
?.let { videoList.addAll(DoodExtractor(client).videosFromUrl(it)) }
// StreamSB mirror:
document.select("div#list-server-more > ul > li.linkserver:contains(StreamSB)")
.firstOrNull()?.attr("data-video")
?.let { videoList.addAll(StreamSBExtractor(client).videosFromUrl(it, headers)) }
return videoList
}
override fun List<Video>.sort(): List<Video> {
val quality = preferences.getString("preferred_quality", "1080")!!
val server = preferences.getString("preferred_server", "MAVERICKKI")!!
return this.sortedWith(
compareBy(
{ it.quality.contains(quality) },
{ it.quality.contains(server) },
),
).reversed()
}
override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request {
return GET("$baseUrl/search?q=${encode(query.trim())}")
}
// =============================== Search ===============================
override fun searchAnimeParse(response: Response): AnimesPage {
val data = getAppdata(response.asJsoup())
val animeList = data["animes"]!!.jsonArray
val animes = animeList.map { item ->
SAnime.create().apply {
setUrlWithoutDomain(item.jsonObject["slug"]!!.jsonPrimitive.content)
thumbnail_url =
"$baseUrl/uploads/" + item.jsonObject["poster"]!!.jsonPrimitive.content
title = item.jsonObject["name"]!!.jsonPrimitive.content
}
}
val data = response.parseAs<List<PopularItemDto>>()
val animes = data.map(::popularAnimeFromObject)
return AnimesPage(animes, false)
}
override fun animeDetailsParse(response: Response): SAnime {
val anime = SAnime.create()
val appData = getAppdata(response.asJsoup())
if (appData.isEmpty().not()) {
val ani = appData["anime"]!!.jsonObject
anime.title = ani["name"]!!.jsonPrimitive.content
anime.genre =
ani["genres"]!!.jsonArray.joinToString { it.jsonObject["name"]!!.jsonPrimitive.content }
anime.description = JSONUtil.unescape(ani["description"]!!.jsonPrimitive.content)
anime.status = parseStatus(ani["status"]!!.jsonPrimitive.content)
val altName = "Other name(s): "
json.decodeFromString<JsonArray>(ani["alternate"].toString().replace("\"\"", "[]"))
.let { altArray ->
if (altArray.isEmpty().not()) {
anime.description = when {
anime.description.isNullOrBlank() -> altName + altArray.joinToString { it.jsonPrimitive.content }
else -> anime.description + "\n\n$altName" + altArray.joinToString { it.jsonPrimitive.content }
}
}
}
}
return anime
override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request {
val data = """{"query":"$query"}"""
val reqBody = data.toRequestBody("application/json".toMediaType())
return POST("$baseUrl/api/search", headers, reqBody)
}
private fun parseStatus(statusString: String): Int {
return when (statusString) {
"Currently Airing" -> SAnime.ONGOING
"Finished Airing" -> SAnime.COMPLETED
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/api/show/$slug"))
.asObservableSuccess()
.map(::searchAnimeBySlugParse)
} else {
super.fetchSearchAnime(page, query, filters)
}
}
private fun searchAnimeBySlugParse(response: Response): AnimesPage {
val details = animeDetailsParse(response)
return AnimesPage(listOf(details), false)
}
// =============================== 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("$API_URL/recent?type=all&page=$page")
// ============================== Settings ==============================
override fun setupPreferenceScreen(screen: PreferenceScreen) {
val titlePref = 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()
}
}
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()
}
}
screen.addPreference(videoQualityPref)
screen.addPreference(titlePref)
}
// ============================= Utilities ==============================
private inline fun <reified T> Response.parseAs(): T {
return body.string().let(json::decodeFromString)
}
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)!!
return sortedWith(
compareBy { it.quality.contains(quality) },
).reversed()
}
private fun getAppdata(document: Document): JsonObject {
val scripts = document.getElementsByTag("script")
companion object {
const val PREFIX_SEARCH = "slug:"
for (element in scripts) {
if (element.data().contains("appData")) {
val pattern = Pattern.compile(".*appData = (.*) \\|\\|")
val matcher = pattern.matcher(element.data())
if (matcher.find()) {
return json.decodeFromString(matcher.group(1)!!.toString())
}
break
}
}
return json.decodeFromString("")
}
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 fun getVideoSource(document: Document): JsonArray {
val scripts = document.getElementsByTag("script")
for (element in scripts) {
if (element.data().contains("sources")) {
val pattern = Pattern.compile(".*var sources = (.*);")
val matcher = pattern.matcher(element.data())
if (matcher.find()) {
return json.decodeFromString(matcher.group(1)!!.toString())
}
break
}
}
return json.decodeFromString("")
}
override fun setupPreferenceScreen(screen: PreferenceScreen) {
val domainPref = ListPreference(screen.context).apply {
key = "preferred_domain"
title = "Preferred domain (requires app restart)"
entries = arrayOf("kickassanime.ro")
entryValues = arrayOf("https://www2.kickassanime.ro")
setDefaultValue("https://www2.kickassanime.ro")
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 videoQualityPref = ListPreference(screen.context).apply {
key = "preferred_quality"
title = "Preferred quality"
entries = arrayOf("1080p", "720p", "480p", "360p", "240p")
entryValues = arrayOf("1080", "720", "480", "360", "240")
setDefaultValue("1080")
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 serverPref = ListPreference(screen.context).apply {
key = "preferred_server"
title = "Preferred server"
entries = workingServers
entryValues = workingServers
setDefaultValue("MAVERICKKI")
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(domainPref)
screen.addPreference(videoQualityPref)
screen.addPreference(serverPref)
}
private fun encode(input: String): String = java.net.URLEncoder.encode(input, "utf-8")
private fun decode(input: String): String = java.net.URLDecoder.decode(input, "utf-8")
private fun String.getLocale(): String {
return arrayOf(
Pair("ar-ME", "Arabic"),
Pair("ar-SA", "Arabic (Saudi Arabia)"),
Pair("de-DE", "German"),
Pair("en-US", "English"),
Pair("es-419", "Spanish"),
Pair("es-ES", "Spanish (Spain)"),
Pair("es-LA", "Spanish (Spanish)"),
Pair("fr-FR", "French"),
Pair("ja-JP", "Japanese"),
Pair("it-IT", "Italian"),
Pair("pt-BR", "Portuguese (Brazil)"),
Pair("pl-PL", "Polish"),
Pair("ru-RU", "Russian"),
Pair("tr-TR", "Turkish"),
Pair("uk-UK", "Ukrainian"),
Pair("he-IL", "Hebrew"),
Pair("ro-RO", "Romanian"),
Pair("sv-SE", "Swedish"),
).firstOrNull { it.first == this }?.second ?: ""
}
@Serializable
data class DailyQuality(
val qualities: Auto,
) {
@Serializable
data class Auto(
val auto: List<Item>,
) {
@Serializable
data class Item(
val type: String,
val url: String,
)
}
}
@Serializable
data class Sapphire(
val subtitles: List<Subtitle>,
val streams: List<Stream>,
) {
@Serializable
data class Subtitle(
val language: String,
val url: String,
)
@Serializable
data class Stream(
@SerialName("audio_lang")
val audio: String,
@SerialName("hardsub_lang")
val hardSub: String,
val url: String,
val format: String,
)
}
// From Dopebox
private fun <A, B> Iterable<A>.parallelMap(f: suspend (A) -> B): List<B> =
runBlocking {
map { async(Dispatchers.Default) { f(it) } }.awaitAll()
private const val PREF_QUALITY_KEY = "preferred_quality"
private const val PREF_QUALITY_TITLE = "Preferred quality"
private const val PREF_QUALITY_DEFAULT = "720p"
private val PREF_QUALITY_VALUES = arrayOf("240p", "360p", "480p", "720p", "1080p")
}
}

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,71 @@
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 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<String>)
@Serializable
data class VideoDto(
val hls: String = "",
val dash: String = "",
val subtitles: List<SubtitlesDto> = emptyList(),
) {
val playlistUrl by lazy { if (hls.isBlank()) "https:$dash" else hls }
@Serializable
data class SubtitlesDto(val name: String, val language: String, val src: String)
}

View File

@ -1,123 +0,0 @@
package eu.kanade.tachiyomi.animeextension.en.kickassanime.extractors
import android.util.Base64
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.util.asJsoup
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import java.lang.Exception
import java.util.Locale
import javax.crypto.Cipher
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
@ExperimentalSerializationApi
class GogoCdnExtractor(private val client: OkHttpClient, private val json: Json) {
fun videosFromUrl(serverUrl: String): List<Video> {
try {
val document = client.newCall(GET(serverUrl)).execute().asJsoup()
val iv = document.select("div.wrapper")
.attr("class").substringAfter("container-")
.filter { it.isDigit() }.toByteArray()
val secretKey = document.select("body[class]")
.attr("class").substringAfter("container-")
.filter { it.isDigit() }.toByteArray()
val decryptionKey = document.select("div.videocontent")
.attr("class").substringAfter("videocontent-")
.filter { it.isDigit() }.toByteArray()
val encryptAjaxParams = cryptoHandler(
document.select("script[data-value]")
.attr("data-value"),
iv,
secretKey,
false,
).substringAfter("&")
val httpUrl = serverUrl.toHttpUrl()
val host = "https://" + httpUrl.host + "/"
val id = httpUrl.queryParameter("id") ?: throw Exception("error getting id")
val encryptedId = cryptoHandler(id, iv, secretKey)
val token = httpUrl.queryParameter("token")
val qualityPrefix = if (token != null) "Gogostream: " else "Vidstreaming: "
val jsonResponse = client.newCall(
GET(
"${host}encrypt-ajax.php?id=$encryptedId&$encryptAjaxParams&alias=$id",
Headers.headersOf(
"X-Requested-With",
"XMLHttpRequest",
),
),
).execute().body.string()
val data = json.decodeFromString<JsonObject>(jsonResponse)["data"]!!.jsonPrimitive.content
val decryptedData = cryptoHandler(data, iv, decryptionKey, false)
val videoList = mutableListOf<Video>()
val autoList = mutableListOf<Video>()
val array = json.decodeFromString<JsonObject>(decryptedData)["source"]!!.jsonArray
if (array.size == 1 && array[0].jsonObject["type"]!!.jsonPrimitive.content == "hls") {
val fileURL = array[0].jsonObject["file"].toString().trim('"')
val masterPlaylist = client.newCall(GET(fileURL)).execute().body.string()
masterPlaylist.substringAfter("#EXT-X-STREAM-INF:")
.split("#EXT-X-STREAM-INF:").forEach {
val quality = it.substringAfter("RESOLUTION=").substringAfter("x").substringBefore(",").substringBefore("\n") + "p"
var videoUrl = it.substringAfter("\n").substringBefore("\n")
if (!videoUrl.startsWith("http")) {
videoUrl = fileURL.substringBeforeLast("/") + "/$videoUrl"
}
videoList.add(Video(videoUrl, qualityPrefix + quality, videoUrl))
}
} else {
array.forEach {
val label = it.jsonObject["label"].toString().lowercase(Locale.ROOT)
.trim('"').replace(" ", "")
val fileURL = it.jsonObject["file"].toString().trim('"')
val videoHeaders = Headers.headersOf("Referer", serverUrl)
if (label == "auto") {
autoList.add(
Video(
fileURL,
qualityPrefix + label,
fileURL,
headers = videoHeaders,
),
)
} else {
videoList.add(Video(fileURL, qualityPrefix + label, fileURL, headers = videoHeaders))
}
}
}
return videoList.sortedByDescending {
it.quality.substringAfter(qualityPrefix).substringBefore("p").toIntOrNull() ?: -1
} + autoList
} catch (e: Exception) {
return emptyList()
}
}
private fun cryptoHandler(
string: String,
iv: ByteArray,
secretKeyString: ByteArray,
encrypt: Boolean = true,
): String {
val ivParameterSpec = IvParameterSpec(iv)
val secretKey = SecretKeySpec(secretKeyString, "AES")
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
return if (!encrypt) {
cipher.init(Cipher.DECRYPT_MODE, secretKey, ivParameterSpec)
String(cipher.doFinal(Base64.decode(string, Base64.DEFAULT)))
} else {
cipher.init(Cipher.ENCRYPT_MODE, secretKey, ivParameterSpec)
Base64.encodeToString(cipher.doFinal(string.toByteArray()), Base64.NO_WRAP)
}
}
}

View File

@ -0,0 +1,108 @@
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.network.GET
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
class KickAssAnimeExtractor(private val client: OkHttpClient, private val json: Json) {
private val isStable by lazy {
runCatching {
Track("", "")
false
}.getOrDefault(true)
}
fun videosFromUrl(url: String): List<Video> {
val idQuery = url.substringAfterLast("?")
val baseUrl = url.substringBeforeLast("/") // baseUrl + endpoint/player
val response = client.newCall(GET("$baseUrl/source.php?$idQuery")).execute()
.body.string()
val (encryptedData, ivhex) = response.substringAfter(":\"")
.substringBefore('"')
.replace("\\", "")
.split(":")
// TODO: Create something to get the key dynamically.
// Maybe we can do something like what is being used at Dopebox, Sflix and Zoro:
// Leave the hard work to github actions and make the extension just fetch the key
// from the repository.
val key = "7191d608bd4deb4dc36f656c4bbca1b7".toByteArray()
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 = if (isStable || videoObject.subtitles.isEmpty()) {
emptyList()
} else {
videoObject.subtitles.map {
val subUrl: String = it.src.let { src ->
if (src.startsWith("/")) {
baseUrl.substringBeforeLast("/") + "/$src"
} else {
src
}
}
val language = "${it.name} (${it.language})"
println("subUrl -> $subUrl")
Track(subUrl, language)
}
}
val masterPlaylist = client.newCall(GET(videoObject.playlistUrl)).execute()
.body.string()
val prefix = if ("pink" in url) "PinkBird" else "SapphireDuck"
return when {
videoObject.hls.isBlank() ->
extractVideosFromDash(masterPlaylist, prefix, subtitles)
else -> extractVideosFromHLS(masterPlaylist, prefix, subtitles)
}
}
private fun extractVideosFromHLS(playlist: String, prefix: String, subs: List<Track>): List<Video> {
val separator = "#EXT-X-STREAM-INF"
return playlist.substringAfter(separator).split(separator).map {
val resolution = it.substringAfter("RESOLUTION=")
.substringBefore("\n")
.substringAfter("x")
.substringBefore(",") + "p"
val videoUrl = it.substringAfter("\n").substringBefore("\n")
if (isStable) {
Video(videoUrl, "$prefix - $resolution", videoUrl)
} else {
Video(videoUrl, "$prefix - $resolution", videoUrl, subtitleTracks = subs)
}
}
}
private fun extractVideosFromDash(playlist: String, prefix: String, subs: List<Track>): List<Video> {
return playlist.split("<Representation").drop(1).dropLast(1).map {
val resolution = it.substringAfter("height=\"").substringBefore('"') + "p"
val url = it.substringAfter("<BaseURL>").substringBefore("</Base")
.replace("&amp;", "&")
if (isStable) {
Video(url, "$prefix - $resolution", url)
} else {
Video(url, "$prefix - $resolution", url, subtitleTracks = subs)
}
}
}
}

View File

@ -1,45 +0,0 @@
package eu.kanade.tachiyomi.animeextension.en.kickassanime.extractors
import android.util.Base64
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.network.GET
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.OkHttpClient
import java.lang.Exception
@ExperimentalSerializationApi
class PinkBird(private val client: OkHttpClient, private val json: Json) {
fun videosFromUrl(serverUrl: String, server: String): List<Video> {
return try {
val apiLink = serverUrl.replace("player.php", "pref.php")
val resp = client.newCall(GET(apiLink)).execute()
val jsonResp = json.decodeFromString<JsonObject>(resp.body.string())
jsonResp["data"]!!.jsonArray.map { el ->
val eid = el.jsonObject["eid"]!!.jsonPrimitive.content.decodeBase64()
val response = client.newCall(GET("https://pb.kaast1.com/manifest/$eid/master.m3u8")).execute()
if (response.code != 200) return emptyList()
response.body.string().substringAfter("#EXT-X-STREAM-INF:")
.split("#EXT-X-STREAM-INF:").map {
val quality = it.substringAfter("RESOLUTION=").split(",")[0].split("\n")[0].substringAfter("x") + "p $server"
var videoUrl = it.substringAfter("\n").substringBefore("\n")
if (videoUrl.startsWith("https").not()) {
videoUrl = "https://${response.request.url.host}$videoUrl"
}
Video(videoUrl, quality, videoUrl)
}
}.flatten()
} catch (e: Exception) {
emptyList()
}
}
private fun String.decodeBase64(): String {
return Base64.decode(this, Base64.DEFAULT).toString(Charsets.UTF_8)
}
}