KickAssAnime: Adapt to the current (beta) source (#1509)
This commit is contained in:
parent
e34f97d80c
commit
2fe9d1741e
18
lib/cryptoaes/build.gradle.kts
Normal file
18
lib/cryptoaes/build.gradle.kts
Normal 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)
|
||||||
|
}
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
@ -1,2 +1,24 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?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>
|
||||||
|
@ -1,18 +1,18 @@
|
|||||||
apply plugin: 'com.android.application'
|
plugins {
|
||||||
apply plugin: 'kotlin-android'
|
alias(libs.plugins.android.application)
|
||||||
apply plugin: 'kotlinx-serialization'
|
alias(libs.plugins.kotlin.android)
|
||||||
|
alias(libs.plugins.kotlin.serialization)
|
||||||
|
}
|
||||||
|
|
||||||
ext {
|
ext {
|
||||||
extName = 'KickAssAnime'
|
extName = 'KickAssAnime'
|
||||||
pkgNameSuffix = 'en.kickassanime'
|
pkgNameSuffix = 'en.kickassanime'
|
||||||
extClass = '.KickAssAnime'
|
extClass = '.KickAssAnime'
|
||||||
extVersionCode = 21
|
extVersionCode = 22
|
||||||
libVersion = '13'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(project(':lib-streamsb-extractor'))
|
implementation(project(":lib-cryptoaes"))
|
||||||
implementation(project(':lib-dood-extractor'))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
apply from: "$rootDir/common.gradle"
|
apply from: "$rootDir/common.gradle"
|
||||||
|
Binary file not shown.
Before Width: | Height: | Size: 36 KiB |
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
@ -2,610 +2,254 @@ package eu.kanade.tachiyomi.animeextension.en.kickassanime
|
|||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
import android.net.Uri
|
|
||||||
import android.util.Base64
|
|
||||||
import androidx.preference.ListPreference
|
import androidx.preference.ListPreference
|
||||||
import androidx.preference.PreferenceScreen
|
import androidx.preference.PreferenceScreen
|
||||||
import eu.kanade.tachiyomi.animeextension.en.kickassanime.extractors.GogoCdnExtractor
|
import androidx.preference.SwitchPreferenceCompat
|
||||||
import eu.kanade.tachiyomi.animeextension.en.kickassanime.extractors.PinkBird
|
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.ConfigurableAnimeSource
|
||||||
import eu.kanade.tachiyomi.animesource.model.AnimeFilterList
|
import eu.kanade.tachiyomi.animesource.model.AnimeFilterList
|
||||||
import eu.kanade.tachiyomi.animesource.model.AnimesPage
|
import eu.kanade.tachiyomi.animesource.model.AnimesPage
|
||||||
import eu.kanade.tachiyomi.animesource.model.SAnime
|
import eu.kanade.tachiyomi.animesource.model.SAnime
|
||||||
import eu.kanade.tachiyomi.animesource.model.SEpisode
|
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.model.Video
|
||||||
import eu.kanade.tachiyomi.animesource.online.AnimeHttpSource
|
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.network.GET
|
||||||
import eu.kanade.tachiyomi.util.asJsoup
|
import eu.kanade.tachiyomi.network.POST
|
||||||
import kotlinx.coroutines.Dispatchers
|
import eu.kanade.tachiyomi.network.asObservableSuccess
|
||||||
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 kotlinx.serialization.decodeFromString
|
import kotlinx.serialization.decodeFromString
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.json.JsonArray
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
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.Request
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import org.jsoup.nodes.Document
|
import rx.Observable
|
||||||
import uy.kohesive.injekt.Injekt
|
import uy.kohesive.injekt.Injekt
|
||||||
import uy.kohesive.injekt.api.get
|
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() {
|
class KickAssAnime : ConfigurableAnimeSource, AnimeHttpSource() {
|
||||||
|
|
||||||
override val name = "KickAssAnime"
|
override val name = "KickAssAnime"
|
||||||
|
|
||||||
override val baseUrl by lazy {
|
override val baseUrl = "https://kaas.am"
|
||||||
preferences.getString(
|
|
||||||
"preferred_domain",
|
private val API_URL = "$baseUrl/api/show"
|
||||||
"https://www2.kickassanime.ro",
|
|
||||||
)!!
|
|
||||||
}
|
|
||||||
|
|
||||||
override val lang = "en"
|
override val lang = "en"
|
||||||
|
|
||||||
override val supportsLatest = false
|
override val supportsLatest = true
|
||||||
|
|
||||||
override val client: OkHttpClient = network.cloudflareClient
|
|
||||||
|
|
||||||
private val json: Json by injectLazy()
|
|
||||||
|
|
||||||
private val preferences: SharedPreferences by lazy {
|
private val preferences: SharedPreferences by lazy {
|
||||||
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
|
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
private val json = Json {
|
||||||
private val DateFormatter by lazy {
|
ignoreUnknownKeys = true
|
||||||
SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.ENGLISH)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add non working server names here
|
// ============================== Popular ===============================
|
||||||
private val deadServers = listOf(
|
override fun popularAnimeRequest(page: Int) = GET("$API_URL/popular?page=$page")
|
||||||
"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")
|
|
||||||
|
|
||||||
override fun popularAnimeParse(response: Response): AnimesPage {
|
override fun popularAnimeParse(response: Response): AnimesPage {
|
||||||
val responseObject = json.decodeFromString<JsonObject>(response.body.string())
|
val data = response.parseAs<PopularResponseDto>()
|
||||||
val data = responseObject["data"]!!.jsonArray
|
val animes = data.result.map(::popularAnimeFromObject)
|
||||||
val animes = data.map { item ->
|
val page = response.request.url.queryParameter("page")?.toIntOrNull() ?: 0
|
||||||
SAnime.create().apply {
|
val hasNext = data.page_count > page
|
||||||
setUrlWithoutDomain(
|
return AnimesPage(animes, hasNext)
|
||||||
item.jsonObject["slug"]!!.jsonPrimitive.content.substringBefore(
|
}
|
||||||
"/episode",
|
|
||||||
),
|
private fun popularAnimeFromObject(anime: PopularItemDto): SAnime {
|
||||||
)
|
return SAnime.create().apply {
|
||||||
thumbnail_url =
|
val useEnglish = preferences.getBoolean(PREF_USE_ENGLISH_KEY, PREF_USE_ENGLISH_DEFAULT)
|
||||||
"$baseUrl/uploads/" + item.jsonObject["poster"]!!.jsonPrimitive.content
|
title = when {
|
||||||
title = item.jsonObject["name"]!!.jsonPrimitive.content
|
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> {
|
override fun episodeListParse(response: Response): List<SEpisode> {
|
||||||
val data = getAppdata(response.asJsoup())
|
TODO("Not yet implemented")
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parseDate(dateStr: String): Long {
|
// ============================ Video Links =============================
|
||||||
return runCatching { DateFormatter.parse(dateStr)?.time }
|
override fun videoListRequest(episode: SEpisode): Request {
|
||||||
.getOrNull() ?: 0L
|
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> {
|
override fun videoListParse(response: Response): List<Video> {
|
||||||
val data = getAppdata(response.asJsoup())
|
val videos = response.parseAs<ServersDto>()
|
||||||
val episode = data["episode"]!!.jsonObject
|
// Just to see the responses at mitmproxy
|
||||||
var link = episode["link1"]!!.jsonPrimitive.content
|
val extractor = KickAssAnimeExtractor(client, json)
|
||||||
// check if link1 is not blank (link2-4 doesn't work), if so check external servers for gogo links
|
return videos.servers.flatMap(extractor::videosFromUrl)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun extractMavrick(serverLink: String, server: String): List<Video> {
|
// =========================== Anime Details ============================
|
||||||
val playlist = mutableListOf<Video>()
|
// Uncomment when extensions-lib v14 gets released
|
||||||
val subsList = mutableListOf<Track>()
|
// tested with extensions-lib:9d3dcb0
|
||||||
val apiLink = serverLink.replace("embed", "api/source")
|
// override fun getAnimeUrl(anime: SAnime) = "$baseUrl${anime.url}"
|
||||||
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)
|
|
||||||
|
|
||||||
json["subtitles"]!!.jsonArray.forEach {
|
override fun animeDetailsRequest(anime: SAnime) = GET("$API_URL/${anime.url}")
|
||||||
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()
|
|
||||||
|
|
||||||
resp.body.string().substringAfter("#EXT-X-STREAM-INF:")
|
override fun animeDetailsParse(response: Response): SAnime {
|
||||||
.split("#EXT-X-STREAM-INF:").map {
|
val anime = response.parseAs<AnimeInfoDto>()
|
||||||
val quality = it.substringAfter("RESOLUTION=").split(",")[0].split("\n")[0].substringAfter("x") + "p $server" +
|
return SAnime.create().apply {
|
||||||
if (subsList.size > 0) { " (Toggleable Sub Available)" } else { "" }
|
val useEnglish = preferences.getBoolean(PREF_USE_ENGLISH_KEY, PREF_USE_ENGLISH_DEFAULT)
|
||||||
var videoUrl = it.substringAfter("\n").substringBefore("\n")
|
title = when {
|
||||||
if (videoUrl.startsWith("https").not()) {
|
anime.title_en.isNotBlank() && useEnglish -> anime.title_en
|
||||||
videoUrl = resp.request.url.toString().substringBeforeLast("/") + "/$videoUrl"
|
else -> anime.title
|
||||||
}
|
}
|
||||||
try {
|
setUrlWithoutDomain("/${anime.slug}")
|
||||||
playlist.add(Video(videoUrl, quality, videoUrl, subtitleTracks = subsList, headers = embedHeader))
|
thumbnail_url = "$baseUrl/${anime.poster.url}"
|
||||||
} catch (e: Error) {
|
genre = anime.genres.joinToString()
|
||||||
playlist.add(Video(videoUrl, quality, videoUrl, headers = embedHeader))
|
status = anime.status.parseStatus()
|
||||||
}
|
description = buildString {
|
||||||
}
|
append(anime.synopsis + "\n\n")
|
||||||
return playlist
|
append("Season: ${anime.season.capitalize()}\n")
|
||||||
}
|
append("Year: ${anime.year}")
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val playlist = mutableListOf<Video>()
|
// =============================== Search ===============================
|
||||||
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())}")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun searchAnimeParse(response: Response): AnimesPage {
|
override fun searchAnimeParse(response: Response): AnimesPage {
|
||||||
val data = getAppdata(response.asJsoup())
|
val data = response.parseAs<List<PopularItemDto>>()
|
||||||
val animeList = data["animes"]!!.jsonArray
|
val animes = data.map(::popularAnimeFromObject)
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return AnimesPage(animes, false)
|
return AnimesPage(animes, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun animeDetailsParse(response: Response): SAnime {
|
override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request {
|
||||||
val anime = SAnime.create()
|
val data = """{"query":"$query"}"""
|
||||||
val appData = getAppdata(response.asJsoup())
|
val reqBody = data.toRequestBody("application/json".toMediaType())
|
||||||
if (appData.isEmpty().not()) {
|
return POST("$baseUrl/api/search", headers, reqBody)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parseStatus(statusString: String): Int {
|
override fun fetchSearchAnime(page: Int, query: String, filters: AnimeFilterList): Observable<AnimesPage> {
|
||||||
return when (statusString) {
|
return if (query.startsWith(PREFIX_SEARCH)) { // URL intent handler
|
||||||
"Currently Airing" -> SAnime.ONGOING
|
val slug = query.removePrefix(PREFIX_SEARCH)
|
||||||
"Finished Airing" -> SAnime.COMPLETED
|
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
|
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 {
|
companion object {
|
||||||
val scripts = document.getElementsByTag("script")
|
const val PREFIX_SEARCH = "slug:"
|
||||||
|
|
||||||
for (element in scripts) {
|
private const val PREF_USE_ENGLISH_KEY = "pref_use_english"
|
||||||
if (element.data().contains("appData")) {
|
private const val PREF_USE_ENGLISH_TITLE = "Use English titles"
|
||||||
val pattern = Pattern.compile(".*appData = (.*) \\|\\|")
|
private const val PREF_USE_ENGLISH_SUMMARY = "Show Titles in English instead of Romanji when possible."
|
||||||
val matcher = pattern.matcher(element.data())
|
private const val PREF_USE_ENGLISH_DEFAULT = false
|
||||||
if (matcher.find()) {
|
|
||||||
return json.decodeFromString(matcher.group(1)!!.toString())
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return json.decodeFromString("")
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getVideoSource(document: Document): JsonArray {
|
private const val PREF_QUALITY_KEY = "preferred_quality"
|
||||||
val scripts = document.getElementsByTag("script")
|
private const val PREF_QUALITY_TITLE = "Preferred quality"
|
||||||
for (element in scripts) {
|
private const val PREF_QUALITY_DEFAULT = "720p"
|
||||||
if (element.data().contains("sources")) {
|
private val PREF_QUALITY_VALUES = arrayOf("240p", "360p", "480p", "720p", "1080p")
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
@ -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)
|
||||||
|
}
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -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("&", "&")
|
||||||
|
if (isStable) {
|
||||||
|
Video(url, "$prefix - $resolution", url)
|
||||||
|
} else {
|
||||||
|
Video(url, "$prefix - $resolution", url, subtitleTracks = subs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
Loading…
x
Reference in New Issue
Block a user