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"?>
|
||||
<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'
|
||||
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 |
@ -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.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")
|
||||
}
|
||||
}
|
||||
|
@ -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