Initial commit

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

View file

@ -0,0 +1,3 @@
plugins {
id("lib-android")
}

View file

@ -0,0 +1,32 @@
package eu.kanade.tachiyomi.lib.bloggerextractor
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.network.GET
import okhttp3.Headers
import okhttp3.OkHttpClient
class BloggerExtractor(private val client: OkHttpClient) {
fun videosFromUrl(url: String, headers: Headers, suffix: String = ""): List<Video> {
return client.newCall(GET(url, headers)).execute()
.body.string()
.takeIf { !it.contains("errorContainer") }
.let { it ?: return emptyList() }
.substringAfter("\"streams\":[")
.substringBefore("]")
.split("},")
.mapNotNull {
val videoUrl = it.substringAfter("\"play_url\":\"").substringBefore('"')
.takeIf(String::isNotBlank)
?: return@mapNotNull null
val format = it.substringAfter("\"format_id\":").substringBefore('}')
val quality = when (format) {
"7" -> "240p"
"18" -> "360p"
"22" -> "720p"
"37" -> "1080p"
else -> "Unknown"
}
Video(videoUrl, "Blogger - $quality $suffix".trimEnd(), videoUrl, headers)
}
}
}

View file

@ -0,0 +1,3 @@
plugins {
id("lib-android")
}

View file

@ -0,0 +1,45 @@
package eu.kanade.tachiyomi.lib.burstcloudextractor
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.util.asJsoup
import kotlinx.serialization.json.Json
import okhttp3.FormBody
import okhttp3.Headers
import okhttp3.OkHttpClient
import uy.kohesive.injekt.injectLazy
class BurstCloudExtractor(private val client: OkHttpClient) {
private val json: Json by injectLazy()
fun videoFromUrl(url: String, headers: Headers, name: String = "BurstCloud", prefix: String = ""): List<Video> {
val newHeaders = headers.newBuilder().set("referer", BURSTCLOUD_URL).build()
return runCatching {
val response = client.newCall(GET(url, newHeaders)).execute()
val document = response.asJsoup()
val videoId = document.selectFirst("div#player")!!.attr("data-file-id")
val formBody = FormBody.Builder()
.add("fileId", videoId)
.build()
val jsonHeaders = headers.newBuilder().set("referer", document.location()).build()
val request = POST("$BURSTCLOUD_URL/file/play-request/", jsonHeaders, formBody)
val jsonString = client.newCall(request).execute().body.string()
val jsonObj = json.decodeFromString<BurstCloudDto>(jsonString)
val videoUrl = jsonObj.purchase.cdnUrl
if (videoUrl.isNotEmpty()) {
val quality = prefix + name
listOf(Video(videoUrl, quality, videoUrl, newHeaders))
} else {
null
}
}.getOrNull().orEmpty()
}
}
private const val BURSTCLOUD_URL = "https://www.burstcloud.co"

View file

@ -0,0 +1,9 @@
package eu.kanade.tachiyomi.lib.burstcloudextractor
import kotlinx.serialization.Serializable
@Serializable
data class BurstCloudDto(val purchase: Purchase)
@Serializable
data class Purchase(val cdnUrl: String)

View file

@ -0,0 +1,3 @@
plugins {
id("lib-android")
}

View file

@ -0,0 +1,126 @@
package eu.kanade.tachiyomi.lib.cdaextractor
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.util.asJsoup
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.toRequestBody
import uy.kohesive.injekt.injectLazy
import java.net.URLDecoder
import java.nio.charset.StandardCharsets
class CdaPlExtractor(private val client: OkHttpClient) {
private val json: Json by injectLazy()
fun getVideosFromUrl(url: String, headers: Headers, prefix: String): List<Video> {
val embedHeaders = headers.newBuilder()
.add("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8")
.add("Host", url.toHttpUrl().host)
.build()
val document = client.newCall(
GET(url, headers = embedHeaders),
).execute().asJsoup()
//Nic lepszego nie wymyśliłem jak ktoś kto przegląda ten kod znajdzie sposób lepszy to chetnie przyjme radę <3
//Do you have any idea how to write it differently? I will accept advice!
val deletedMessage = "Materiał na który wskazywał ten link został usunięty przez jego właściciela lub Administratora!"
if (document.toString().contains(deletedMessage)) return emptyList()
val data = json.decodeFromString<PlayerData>(
document.selectFirst("div[player_data]")!!.attr("player_data"),
)
return data.video.qualities.map { quality ->
if (quality.value == data.video.quality && quality.value != "lq") {
val videoUrl = decryptFile(data.video.file)
Video(videoUrl, "${prefix}cda.pl - ${quality.key}", videoUrl)
} else {
val jsonBody = """
{
"jsonrpc": "2.0",
"method": "videoGetLink",
"id": 1,
"params": [
"${data.video.id}",
"${quality.value}",
${data.video.ts},
"${data.video.hash2}",
{}
]
}
""".trimIndent().toRequestBody("application/json".toMediaType())
val postHeaders = Headers.headersOf(
"Content-Type",
"application/json",
"X-Requested-With",
"XMLHttpRequest",
)
val response = client.newCall(
POST("https://www.cda.pl/", headers = postHeaders, body = jsonBody),
).execute()
val parsed = json.decodeFromString<PostResponse>(
response.body.string(),
)
Video(parsed.result.resp, "${prefix}cda.pl - ${quality.key}", parsed.result.resp)
}
}
}
// Credit: https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/extractor/cda.py
private fun decryptFile(a: String): String {
var decrypted = a
listOf("_XDDD", "_CDA", "_ADC", "_CXD", "_QWE", "_Q5", "_IKSDE").forEach { p ->
decrypted = decrypted.replace(p, "")
}
decrypted = URLDecoder.decode(decrypted, StandardCharsets.UTF_8.toString())
val b = mutableListOf<Char>()
decrypted.forEach { c ->
val f = c.code
b.add(if (f in 33..126) (33 + (f + 14) % 94).toChar() else c)
}
decrypted = b.joinToString("")
decrypted = decrypted.replace(".cda.mp4", "")
listOf(".2cda.pl", ".3cda.pl").forEach { p ->
decrypted = decrypted.replace(p, ".cda.pl")
}
if ("/upstream" in decrypted) {
decrypted = decrypted.replace("/upstream", ".mp4/upstream")
return "https://$decrypted"
}
return "https://$decrypted.mp4"
}
@Serializable
data class PlayerData(
val video: VideoObject,
) {
@Serializable
data class VideoObject(
val id: String,
val file: String,
val quality: String,
val qualities: Map<String, String>,
val ts: Int,
val hash2: String,
)
}
@Serializable
data class PostResponse(
val result: ResultObject,
) {
@Serializable
data class ResultObject(
val resp: String,
)
}
}

View file

@ -0,0 +1,8 @@
plugins {
id("lib-android")
}
dependencies {
implementation(project(":lib:cryptoaes"))
implementation(project(":lib:playlist-utils"))
}

View file

@ -0,0 +1,117 @@
package eu.kanade.tachiyomi.lib.chillxextractor
import eu.kanade.tachiyomi.animesource.model.Track
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.lib.cryptoaes.CryptoAES.decryptWithSalt
import eu.kanade.tachiyomi.lib.playlistutils.PlaylistUtils
import eu.kanade.tachiyomi.network.GET
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import okhttp3.Headers
import okhttp3.OkHttpClient
import org.jsoup.Jsoup
import uy.kohesive.injekt.injectLazy
class ChillxExtractor(private val client: OkHttpClient, private val headers: Headers) {
private val json: Json by injectLazy()
private val playlistUtils by lazy { PlaylistUtils(client, headers) }
companion object {
private val REGEX_MASTER_JS by lazy { Regex("""JScript[\w+]?\s*=\s*'([^']+)""") }
private val REGEX_EVAL_KEY by lazy { Regex("""eval\(\S+\("(\S+)",\d+,"(\S+)",(\d+),(\d+),""") }
private val REGEX_SOURCES by lazy { Regex("""sources:\s*\[\{"file":"([^"]+)""") }
private val REGEX_FILE by lazy { Regex("""file: ?"([^"]+)"""") }
private val REGEX_SOURCE by lazy { Regex("""source = ?"([^"]+)"""") }
// matches "[language]https://...,"
private val REGEX_SUBS by lazy { Regex("""\[(.*?)\](.*?)"?\,""") }
}
fun videoFromUrl(url: String, referer: String, prefix: String = "Chillx - "): List<Video> {
val newHeaders = headers.newBuilder()
.set("Referer", "$referer/")
.set("Accept-Language", "en-US,en;q=0.5")
.build()
val body = client.newCall(GET(url, newHeaders)).execute().body.string()
val master = REGEX_MASTER_JS.find(body)?.groupValues?.get(1) ?: return emptyList()
val aesJson = json.decodeFromString<CryptoInfo>(master)
val key = getKey(body)
val decryptedScript = decryptWithSalt(aesJson.ciphertext, aesJson.salt, key)
.replace("\\n", "\n")
.replace("\\", "")
val masterUrl = REGEX_SOURCES.find(decryptedScript)?.groupValues?.get(1)
?: REGEX_FILE.find(decryptedScript)?.groupValues?.get(1)
?: REGEX_SOURCE.find(decryptedScript)?.groupValues?.get(1)
?: return emptyList()
val subtitleList = buildList<Track> {
body.takeIf { it.contains("<track kind=\"captions\"") }
?.let(Jsoup::parse)
?.select("track[kind=captions]")
?.forEach {
add(Track(it.attr("src"), it.attr("label")))
}
decryptedScript.takeIf { it.contains("subtitle:") }
?.substringAfter("subtitle: ")
?.substringBefore("\n")
?.let(REGEX_SUBS::findAll)
?.forEach { add(Track(it.groupValues[2], it.groupValues[1])) }
decryptedScript.takeIf { it.contains("tracks:") }
?.substringAfter("tracks: ")
?.substringBefore("\n")
?.also {
runCatching {
json.decodeFromString<List<TrackDto>>(it)
.filter { it.kind == "captions" }
.forEach { add(Track(it.file, it.label)) }
}
}
}
return playlistUtils.extractFromHls(
playlistUrl = masterUrl,
referer = url,
videoNameGen = { "$prefix$it" },
subtitleList = subtitleList,
)
}
private fun getKey(body: String): String {
val (encrypted, pass, offset, index) = REGEX_EVAL_KEY.find(body)!!.groupValues.drop(1)
val decrypted = decryptScript(encrypted, pass, offset.toInt(), index.toInt())
return decrypted.substringAfter("'").substringBefore("'")
}
private fun decryptScript(encrypted: String, pass: String, offset: Int, index: Int): String {
val trimmedPass = pass.substring(0, index)
val bits = encrypted.split(pass[index]).map { item ->
trimmedPass.foldIndexed(item) { index, acc, it ->
acc.replace(it.toString(), index.toString())
}
}.filter(String::isNotBlank)
return bits.joinToString("") { Char(it.toInt(index) - offset).toString() }
}
@Serializable
data class CryptoInfo(
@SerialName("ct")
val ciphertext: String,
@SerialName("s")
val salt: String,
)
@Serializable
data class TrackDto(
val kind: String,
val label: String = "",
val file: String,
)
}

View file

@ -0,0 +1,3 @@
plugins {
id("lib-android")
}

View file

@ -0,0 +1,165 @@
package eu.kanade.tachiyomi.lib.cloudflareinterceptor
import android.annotation.SuppressLint
import android.app.Application
import android.os.Handler
import android.os.Looper
import android.webkit.CookieManager
import android.webkit.JavascriptInterface
import android.webkit.WebView
import android.webkit.WebViewClient
import okhttp3.Cookie
import okhttp3.HttpUrl
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
import java.io.IOException
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
class CloudflareInterceptor(private val client: OkHttpClient) : Interceptor {
private val context: Application by injectLazy()
private val handler by lazy { Handler(Looper.getMainLooper()) }
@Synchronized
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
val originalResponse = chain.proceed(chain.request())
// if Cloudflare anti-bot didn't block it, then do nothing and return it
if (!(originalResponse.code in ERROR_CODES && originalResponse.header("Server") in SERVER_CHECK)) {
return originalResponse
}
return try {
originalResponse.close()
val request = resolveWithWebView(originalRequest, client)
chain.proceed(request)
} catch (e: Exception) {
// Because OkHttp's enqueue only handles IOExceptions, wrap the exception so that
// we don't crash the entire app
throw IOException(e)
}
}
class CloudflareJSI(private val latch: CountDownLatch) {
@JavascriptInterface
fun leave() = latch.countDown()
}
@SuppressLint("SetJavaScriptEnabled")
fun resolveWithWebView(request: Request, client: OkHttpClient): Request {
// We need to lock this thread until the WebView finds the challenge solution url, because
// OkHttp doesn't support asynchronous interceptors.
val latch = CountDownLatch(1)
val jsinterface = CloudflareJSI(latch)
var webView: WebView? = null
val origRequestUrl = request.url.toString()
val headers = request.headers.toMultimap().mapValues { it.value.getOrNull(0) ?: "" }.toMutableMap()
handler.post {
val webview = WebView(context)
webView = webview
with(webview.settings) {
javaScriptEnabled = true
domStorageEnabled = true
databaseEnabled = true
useWideViewPort = true
loadWithOverviewMode = false
userAgentString = request.header("User-Agent")
?: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/111.0.0.0 Safari/537.36"
}
webview.addJavascriptInterface(jsinterface, "CloudflareJSI")
webview.webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
view?.evaluateJavascript(CHECK_SCRIPT) {}
}
}
webview.loadUrl(origRequestUrl, headers)
}
// Wait a reasonable amount of time to retrieve the solution. The minimum should be
// around 4 seconds but it can take more due to slow networks or server issues.
latch.await(30, TimeUnit.SECONDS)
handler.post {
webView?.stopLoading()
webView?.destroy()
webView = null
}
val cookies = CookieManager.getInstance()
?.getCookie(origRequestUrl)
?.split(";")
?.mapNotNull { Cookie.parse(request.url, it) }
?: emptyList<Cookie>()
// Copy webview cookies to OkHTTP cookie storage
cookies.forEach {
client.cookieJar.saveFromResponse(
url = HttpUrl.Builder()
.scheme("http")
.host(it.domain)
.build(),
cookies = cookies,
)
}
return createRequestWithCookies(request, cookies)
}
private fun createRequestWithCookies(request: Request, cookies: List<Cookie>): Request {
val convertedForThisRequest = cookies.filter {
it.matches(request.url)
}
val existingCookies = Cookie.parseAll(
request.url,
request.headers,
)
val filteredExisting = existingCookies.filter { existing ->
convertedForThisRequest.none { converted -> converted.name == existing.name }
}
val newCookies = filteredExisting + convertedForThisRequest
return request.newBuilder()
.header("Cookie", newCookies.joinToString("; ") { "${it.name}=${it.value}" })
.build()
}
companion object {
private val ERROR_CODES = listOf(403, 503)
private val SERVER_CHECK = arrayOf("cloudflare-nginx", "cloudflare")
// ref: https://github.com/vvanglro/cf-clearance/blob/0d3455b5b4f299b131f357dd6e0a27316cf26f9a/cf_clearance/retry.py#L15
private val CHECK_SCRIPT by lazy {
"""
setInterval(() => {
if (document.querySelector("#challenge-form") != null) {
// still havent passed, lets try to click in some challenges
const simpleChallenge = document.querySelector("#challenge-stage > div > input[type='button']")
if (simpleChallenge != null) simpleChallenge.click()
const turnstile = document.querySelector("div.hcaptcha-box > iframe")
if (turnstile != null) {
const button = turnstile.contentWindow.document.querySelector("input[type='checkbox']")
if (button != null) button.click()
}
} else {
// passed
CloudflareJSI.leave()
}
}, 2500)
""".trimIndent()
}
}
}

View file

@ -0,0 +1,3 @@
plugins {
id("lib-android")
}

View file

@ -0,0 +1,220 @@
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")
object CryptoAES {
private const val KEY_SIZE = 32 // 256 bits
private const val IV_SIZE = 16 // 128 bits
private const val SALT_SIZE = 8 // 64 bits
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 {
return try {
val ctBytes = Base64.decode(cipherText, Base64.DEFAULT)
val saltBytes = Arrays.copyOfRange(ctBytes, SALT_SIZE, IV_SIZE)
val cipherTextBytes = Arrays.copyOfRange(ctBytes, IV_SIZE, ctBytes.size)
val md5 = MessageDigest.getInstance("MD5")
val keyAndIV = generateKeyAndIV(KEY_SIZE, IV_SIZE, 1, saltBytes, password.toByteArray(Charsets.UTF_8), md5)
decryptAES(
cipherTextBytes,
keyAndIV?.get(0) ?: ByteArray(KEY_SIZE),
keyAndIV?.get(1) ?: ByteArray(IV_SIZE),
)
} catch (e: Exception) {
""
}
}
fun decryptWithSalt(cipherText: String, salt: String, password: String): String {
return try {
val ctBytes = Base64.decode(cipherText, Base64.DEFAULT)
val md5: MessageDigest = MessageDigest.getInstance("MD5")
val keyAndIV = generateKeyAndIV(
KEY_SIZE,
IV_SIZE,
1,
salt.decodeHex(),
password.toByteArray(Charsets.UTF_8),
md5,
)
decryptAES(
ctBytes,
keyAndIV?.get(0) ?: ByteArray(KEY_SIZE),
keyAndIV?.get(1) ?: ByteArray(IV_SIZE),
)
} catch (e: Exception) {
""
}
}
/**
* 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) {
""
}
}
/**
* Encrypt using CryptoJS defaults compatible method.
*
* @param plainText plaintext
* @param keyBytes key as a bytearray
* @param ivBytes iv as a bytearray
*/
fun encrypt(plainText: String, keyBytes: ByteArray, ivBytes: ByteArray): String {
return try {
val cipherTextBytes = plainText.toByteArray()
encryptAES(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) {
""
}
}
/**
* Encrypt using CryptoJS defaults compatible method.
*
* @param plainTextBytes encrypted text as a bytearray
* @param keyBytes key as a bytearray
* @param ivBytes iv as a bytearray
*/
private fun encryptAES(plainTextBytes: 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.ENCRYPT_MODE, keyS, IvParameterSpec(ivBytes))
Base64.encodeToString(cipher.doFinal(plainTextBytes), Base64.DEFAULT)
} 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, SALT_SIZE)
md.digest(generatedData, generatedLength, digestLength)
// additional rounds
for (i in 1 until iterations) {
md.update(generatedData, generatedLength, digestLength)
md.digest(generatedData, generatedLength, digestLength)
}
generatedLength += digestLength
}
// Copy key and IV into separate byte arrays
val result = arrayOfNulls<ByteArray>(2)
result[0] = generatedData.copyOfRange(0, keyLength)
if (ivLength > 0) result[1] = generatedData.copyOfRange(keyLength, keyLength + ivLength)
result
} catch (e: Exception) {
throw e
} finally {
// Clean out temporary data
Arrays.fill(generatedData, 0.toByte())
}
}
// Stolen from AnimixPlay(EN) / GogoCdnExtractor
fun String.decodeHex(): ByteArray {
check(length % 2 == 0) { "Must have an even length" }
return chunked(2)
.map { it.toInt(16).toByte() }
.toByteArray()
}
}

View file

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

View file

@ -0,0 +1,7 @@
plugins {
id("lib-android")
}
dependencies {
implementation(project(":lib:playlist-utils"))
}

View file

@ -0,0 +1,61 @@
package eu.kanade.tachiyomi.lib.dailymotionextractor
import kotlinx.serialization.Serializable
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonTransformingSerializer
@Serializable
data class DailyQuality(
val qualities: Auto? = null,
val subtitles: Subtitle? = null,
val error: Error? = null,
val id: String? = null,
) {
@Serializable
data class Error(val type: String)
}
@Serializable
data class Auto(val auto: List<Item>) {
@Serializable
data class Item(val type: String, val url: String)
}
@Serializable
data class Subtitle(
@Serializable(with = SubtitleListSerializer::class)
val data: List<SubtitleDto>,
)
@Serializable
data class SubtitleDto(val label: String, val urls: List<String>)
object SubtitleListSerializer :
JsonTransformingSerializer<List<SubtitleDto>>(ListSerializer(SubtitleDto.serializer())) {
override fun transformDeserialize(element: JsonElement): JsonElement =
when (element) {
is JsonObject -> JsonArray(element.values.toList())
else -> JsonArray(emptyList())
}
}
@Serializable
data class TokenResponse(
val access_token: String,
val token_type: String,
)
@Serializable
data class ProtectedResponse(val data: DataObject) {
@Serializable
data class DataObject(val video: VideoObject) {
@Serializable
data class VideoObject(
val id: String,
val xid: String,
)
}
}

View file

@ -0,0 +1,135 @@
package eu.kanade.tachiyomi.lib.dailymotionextractor
import eu.kanade.tachiyomi.animesource.model.Track
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.lib.playlistutils.PlaylistUtils
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.util.parseAs
import kotlinx.serialization.json.Json
import okhttp3.FormBody
import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.toRequestBody
import uy.kohesive.injekt.injectLazy
class DailymotionExtractor(private val client: OkHttpClient, private val headers: Headers) {
companion object {
private const val DAILYMOTION_URL = "https://www.dailymotion.com"
private const val GRAPHQL_URL = "https://graphql.api.dailymotion.com"
}
private fun headersBuilder(block: Headers.Builder.() -> Unit = {}) = headers.newBuilder()
.add("Accept", "*/*")
.set("Referer", "$DAILYMOTION_URL/")
.set("Origin", DAILYMOTION_URL)
.apply { block() }
.build()
private val json: Json by injectLazy()
private val playlistUtils by lazy { PlaylistUtils(client, headers) }
fun videosFromUrl(url: String, prefix: String = "Dailymotion - ", baseUrl: String = "", password: String? = null): List<Video> {
val htmlString = client.newCall(GET(url)).execute().body.string()
val internalData = htmlString.substringAfter("\"dmInternalData\":").substringBefore("</script>")
val ts = internalData.substringAfter("\"ts\":").substringBefore(",")
val v1st = internalData.substringAfter("\"v1st\":\"").substringBefore("\",")
val videoQuery = url.toHttpUrl().run {
queryParameter("video") ?: pathSegments.last()
}
val jsonUrl = "$DAILYMOTION_URL/player/metadata/video/$videoQuery?locale=en-US&dmV1st=$v1st&dmTs=$ts&is_native_app=0"
val parsed = client.newCall(GET(jsonUrl)).execute().parseAs<DailyQuality>()
return when {
parsed.qualities != null && parsed.error == null -> videosFromDailyResponse(parsed, prefix)
parsed.error?.type == "password_protected" && parsed.id != null -> {
videosFromProtectedUrl(url, prefix, parsed.id, htmlString, ts, v1st, baseUrl, password)
}
else -> emptyList()
}
}
private fun videosFromProtectedUrl(
url: String,
prefix: String,
videoId: String,
htmlString: String,
ts: String,
v1st: String,
baseUrl: String,
password: String?,
): List<Video> {
val postUrl = "$GRAPHQL_URL/oauth/token"
val clientId = htmlString.substringAfter("client_id\":\"").substringBefore('"')
val clientSecret = htmlString.substringAfter("client_secret\":\"").substringBefore('"')
val scope = htmlString.substringAfter("client_scope\":\"").substringBefore('"')
val tokenBody = FormBody.Builder()
.add("client_id", clientId)
.add("client_secret", clientSecret)
.add("traffic_segment", ts)
.add("visitor_id", v1st)
.add("grant_type", "client_credentials")
.add("scope", scope)
.build()
val tokenResponse = client.newCall(POST(postUrl, headersBuilder(), tokenBody)).execute()
val tokenParsed = tokenResponse.parseAs<TokenResponse>()
val idUrl = "$GRAPHQL_URL/"
val idHeaders = headersBuilder {
set("Accept", "application/json, text/plain, */*")
add("Authorization", "${tokenParsed.token_type} ${tokenParsed.access_token}")
}
val idData = """
{
"query":"query playerPasswordQuery(${'$'}videoId:String!,${'$'}password:String!){video(xid:${'$'}videoId,password:${'$'}password){id xid}}",
"variables":{
"videoId":"$videoId",
"password":"$password"
}
}
""".trimIndent().toRequestBody("application/json".toMediaType())
val idResponse = client.newCall(POST(idUrl, idHeaders, idData)).execute()
val idParsed = idResponse.parseAs<ProtectedResponse>().data.video
val dmvk = htmlString.substringAfter("\"dmvk\":\"").substringBefore('"')
val getVideoIdUrl = "$DAILYMOTION_URL/player/metadata/video/${idParsed.xid}?embedder=${"$baseUrl/"}&locale=en-US&dmV1st=$v1st&dmTs=$ts&is_native_app=0"
val getVideoIdHeaders = headersBuilder {
add("Cookie", "dmvk=$dmvk; ts=$ts; v1st=$v1st; usprivacy=1---; client_token=${tokenParsed.access_token}")
set("Referer", url)
}
val parsed = client.newCall(GET(getVideoIdUrl, getVideoIdHeaders)).execute()
.parseAs<DailyQuality>()
return videosFromDailyResponse(parsed, prefix, getVideoIdHeaders)
}
private fun videosFromDailyResponse(parsed: DailyQuality, prefix: String, playlistHeaders: Headers? = null): List<Video> {
val masterUrl = parsed.qualities?.auto?.firstOrNull()?.url
?: return emptyList<Video>()
val subtitleList = parsed.subtitles?.data?.map {
Track(it.urls.first(), it.label)
} ?: emptyList<Track>()
val masterHeaders = playlistHeaders ?: headersBuilder()
return playlistUtils.extractFromHls(
masterUrl,
masterHeadersGen = { _, _ -> masterHeaders },
subtitleList = subtitleList,
videoNameGen = { "$prefix$it" },
)
}
}

View file

@ -0,0 +1,3 @@
plugins {
id("lib-android")
}

View file

@ -0,0 +1,61 @@
package eu.kanade.tachiyomi.lib.dataimage
import android.util.Base64
import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.Protocol
import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody
import org.jsoup.nodes.Element
/**
* If a source provides images via a data:image string instead of a URL, use these functions and interceptor
*/
/**
* Use if the attribute tag has a data:image string but real URLs are on a different attribute
*/
fun Element.dataImageAsUrlOrNull(attr: String): String? {
return if (attr(attr).startsWith("data")) {
"https://127.0.0.1/?" + attr(attr).substringAfter(":")
} else {
null
}
}
/**
* Use if the attribute tag could have a data:image string or URL
* Transforms data:image in to a fake URL that OkHttp won't die on
*/
fun Element.dataImageAsUrl(attr: String): String {
return dataImageAsUrlOrNull(attr) ?: attr("abs:$attr")
}
/**
* Interceptor that detects the URLs we created with the above functions, base64 decodes the data if necessary,
* and builds a response with a valid image that Tachiyomi can display
*/
class DataImageInterceptor : Interceptor {
private val mediaTypePattern = Regex("""(^[^;,]*)[;,]""")
override fun intercept(chain: Interceptor.Chain): Response {
val url = chain.request().url.toString()
return if (url.startsWith("https://127.0.0.1/?image")) {
val dataString = url.substringAfter("?")
val byteArray = if (dataString.contains("base64")) {
Base64.decode(dataString.substringAfter("base64,"), Base64.DEFAULT)
} else {
dataString.substringAfter(",").toByteArray()
}
val mediaType = mediaTypePattern.find(dataString)?.value?.toMediaTypeOrNull()
Response.Builder().body(byteArray.toResponseBody(mediaType))
.request(chain.request())
.protocol(Protocol.HTTP_1_0)
.code(200)
.message("")
.build()
} else {
chain.proceed(chain.request())
}
}
}

View file

@ -0,0 +1,3 @@
plugins {
id("lib-android")
}

View file

@ -0,0 +1,61 @@
package eu.kanade.tachiyomi.lib.doodextractor
import eu.kanade.tachiyomi.animesource.model.Track
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.network.GET
import okhttp3.Headers
import okhttp3.OkHttpClient
class DoodExtractor(private val client: OkHttpClient) {
fun videoFromUrl(
url: String,
quality: String? = null,
redirect: Boolean = true,
externalSubs: List<Track> = emptyList(),
): Video? {
val newQuality = quality ?: ("Doodstream" + if (redirect) " mirror" else "")
return runCatching {
val response = client.newCall(GET(url)).execute()
val newUrl = if (redirect) response.request.url.toString() else url
val doodHost = Regex("https://(.*?)/").find(newUrl)!!.groupValues[1]
val content = response.body.string()
if (!content.contains("'/pass_md5/")) return null
val md5 = content.substringAfter("'/pass_md5/").substringBefore("',")
val token = md5.substringAfterLast("/")
val randomString = getRandomString()
val expiry = System.currentTimeMillis()
val videoUrlStart = client.newCall(
GET(
"https://$doodHost/pass_md5/$md5",
Headers.headersOf("referer", newUrl),
),
).execute().body.string()
val videoUrl = "$videoUrlStart$randomString?token=$token&expiry=$expiry"
Video(newUrl, newQuality, videoUrl, headers = doodHeaders(doodHost), subtitleTracks = externalSubs)
}.getOrNull()
}
fun videosFromUrl(
url: String,
quality: String? = null,
redirect: Boolean = true,
): List<Video> {
val video = videoFromUrl(url, quality, redirect)
return video?.let(::listOf) ?: emptyList<Video>()
}
private fun getRandomString(length: Int = 10): String {
val allowedChars = ('A'..'Z') + ('a'..'z') + ('0'..'9')
return (1..length)
.map { allowedChars.random() }
.joinToString("")
}
private fun doodHeaders(host: String) = Headers.Builder().apply {
add("User-Agent", "Aniyomi")
add("Referer", "https://$host/")
}.build()
}

View file

@ -0,0 +1,10 @@
plugins {
id("lib-android")
}
dependencies {
implementation("dev.datlag.jsunpacker:jsunpacker:1.0.1") {
exclude(group = "org.jetbrains.kotlin", module = "kotlin-stdlib-jdk8")
}
implementation(project(":lib:playlist-utils"))
}

View file

@ -0,0 +1,61 @@
package eu.kanade.tachiyomi.lib.fastreamextractor
import dev.datlag.jsunpacker.JsUnpacker
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.lib.playlistutils.PlaylistUtils
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.util.asJsoup
import okhttp3.FormBody
import okhttp3.Headers
import okhttp3.OkHttpClient
import okhttp3.internal.commonEmptyHeaders
class FastreamExtractor(private val client: OkHttpClient, private val headers: Headers = commonEmptyHeaders) {
private val videoHeaders by lazy {
headers.newBuilder()
.set("Referer", "$FASTREAM_URL/")
.set("Origin", FASTREAM_URL)
.build()
}
private val playlistUtils by lazy { PlaylistUtils(client, videoHeaders) }
fun videosFromUrl(url: String, prefix: String = "Fastream:", needsSleep: Boolean = true): List<Video> {
return runCatching {
val firstDoc = client.newCall(GET(url, videoHeaders)).execute().asJsoup()
if (needsSleep) Thread.sleep(5100L) // 5s is the minimum
val scriptElement = if (firstDoc.select("input[name]").any()) {
val form = FormBody.Builder().apply {
firstDoc.select("input[name]").forEach {
add(it.attr("name"), it.attr("value"))
}
}.build()
val doc = client.newCall(POST(url, videoHeaders, body = form)).execute().asJsoup()
doc.selectFirst("script:containsData(jwplayer):containsData(vplayer)") ?: return emptyList()
} else {
firstDoc.selectFirst("script:containsData(jwplayer):containsData(vplayer)") ?: return emptyList()
}
val scriptData = scriptElement.data().let {
when {
it.contains("eval(function(") -> JsUnpacker.unpackAndCombine(it)
else -> it
}
} ?: return emptyList()
val videoUrl = scriptData.substringAfter("file:\"").substringBefore("\"").trim()
return when {
videoUrl.contains(".m3u8") -> {
playlistUtils.extractFromHls(videoUrl, videoNameGen = { "$prefix$it" })
}
else -> listOf(Video(videoUrl, prefix, videoUrl, videoHeaders))
}
}.getOrElse { emptyList() }
}
}
private const val FASTREAM_URL = "https://fastream.to"

View file

@ -0,0 +1,10 @@
plugins {
id("lib-android")
}
dependencies {
implementation("dev.datlag.jsunpacker:jsunpacker:1.0.1") {
exclude(group = "org.jetbrains.kotlin", module = "kotlin-stdlib-jdk8")
}
implementation(project(":lib:playlist-utils"))
}

View file

@ -0,0 +1,63 @@
package eu.kanade.tachiyomi.lib.filemoonextractor
import dev.datlag.jsunpacker.JsUnpacker
import eu.kanade.tachiyomi.animesource.model.Track
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.lib.playlistutils.PlaylistUtils
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.util.asJsoup
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import uy.kohesive.injekt.injectLazy
class FilemoonExtractor(private val client: OkHttpClient) {
private val playlistUtils by lazy { PlaylistUtils(client) }
private val json: Json by injectLazy()
fun videosFromUrl(url: String, prefix: String = "Filemoon - ", headers: Headers? = null): List<Video> {
val httpUrl = url.toHttpUrl()
val videoHeaders = (headers?.newBuilder() ?: Headers.Builder())
.set("Referer", url)
.set("Origin", "https://${httpUrl.host}")
.build()
val doc = client.newCall(GET(url, videoHeaders)).execute().asJsoup()
val jsEval = doc.selectFirst("script:containsData(eval):containsData(m3u8)")!!.data()
val unpacked = JsUnpacker.unpackAndCombine(jsEval).orEmpty()
val masterUrl = unpacked.takeIf(String::isNotBlank)
?.substringAfter("{file:\"", "")
?.substringBefore("\"}", "")
?.takeIf(String::isNotBlank)
?: return emptyList()
val subtitleTracks = buildList {
// Subtitles from a external URL
val subUrl = httpUrl.queryParameter("sub.info")
?: unpacked.substringAfter("fetch('", "")
.substringBefore("').")
.takeIf(String::isNotBlank)
if (subUrl != null) {
runCatching { // to prevent failures on serialization errors
client.newCall(GET(subUrl, videoHeaders)).execute()
.body.string()
.let { json.decodeFromString<List<SubtitleDto>>(it) }
.forEach { add(Track(it.file, it.label)) }
}
}
}
return playlistUtils.extractFromHls(
masterUrl,
subtitleList = subtitleTracks,
referer = "https://${httpUrl.host}/",
videoNameGen = { "$prefix$it" },
)
}
@Serializable
data class SubtitleDto(val file: String, val label: String)
}

View file

@ -0,0 +1,7 @@
plugins {
id("lib-android")
}
dependencies {
implementation(project(":lib:playlist-utils"))
}

View file

@ -0,0 +1,31 @@
package eu.kanade.tachiyomi.lib.fusevideoextractor
import android.util.Base64
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.lib.playlistutils.PlaylistUtils
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.util.asJsoup
import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
class FusevideoExtractor(private val client: OkHttpClient, private val headers: Headers) {
fun videosFromUrl(url: String, prefix: String = ""): List<Video> {
return runCatching {
val newHeaders = headers.newBuilder()
.set("Accept", "*/*")
.set("Host", url.toHttpUrl().host)
.set("Accept-Language", "en-US,en;q=0.5")
.build()
val document = client.newCall(GET(url, newHeaders)).execute().asJsoup()
val dataUrl = document.selectFirst("script[src~=f/u/u/u/u]")?.attr("src")!!
val dataDoc = client.newCall(GET(dataUrl, newHeaders)).execute().body.string()
val encoded = Regex("atob\\(\"(.*?)\"\\)").find(dataDoc)?.groupValues?.get(1)!!
val data = Base64.decode(encoded, Base64.DEFAULT).toString(Charsets.UTF_8)
val jsonData = data.split("|||")[1].replace("\\", "")
val videoUrl = Regex("\"(https://.*?/m/.*)\"").find(jsonData)?.groupValues?.get(1)!!
PlaylistUtils(client, newHeaders).extractFromHls(videoUrl, videoNameGen = { "${prefix}Fusevideo - $it" })
}.getOrDefault(emptyList())
}
}

View file

@ -0,0 +1,8 @@
plugins {
id("lib-android")
}
dependencies {
implementation(project(":lib:cryptoaes"))
implementation(project(":lib:unpacker"))
}

View file

@ -0,0 +1,68 @@
package eu.kanade.tachiyomi.lib.gdriveplayerextractor
import eu.kanade.tachiyomi.animesource.model.Track
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.lib.cryptoaes.CryptoAES.decryptWithSalt
import eu.kanade.tachiyomi.lib.unpacker.Unpacker
import eu.kanade.tachiyomi.network.GET
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.Headers
import okhttp3.OkHttpClient
import org.jsoup.Jsoup
class GdrivePlayerExtractor(private val client: OkHttpClient) {
fun videosFromUrl(url: String, name: String, headers: Headers): List<Video> {
val newUrl = url.replace(".us", ".to").replace(".me", ".to")
val body = client.newCall(GET(newUrl, headers = headers)).execute()
.body.string()
val subtitleList = Jsoup.parse(body).selectFirst("div:contains(\\.srt)")
?.let { element ->
val subUrl = "https://gdriveplayer.to/?subtitle=" + element.text()
listOf(Track(subUrl, "Subtitles"))
} ?: emptyList()
val eval = Unpacker.unpack(body).replace("\\", "")
val json = Json.decodeFromString<JsonObject>(REGEX_DATAJSON.getFirst(eval))
val sojson = REGEX_SOJSON.getFirst(eval)
.split(Regex("\\D+"))
.joinToString("") {
Char(it.toInt()).toString()
}
val password = REGEX_PASSWORD.getFirst(sojson)
val decrypted = decryptAES(password, json) ?: return emptyList()
val secondEval = Unpacker.unpack(decrypted).replace("\\", "")
return REGEX_VIDEOURL.findAll(secondEval)
.distinctBy { it.groupValues[2] } // remove duplicates by quality
.map {
val qualityStr = it.groupValues[2]
val quality = "$playerName ${qualityStr}p - $name"
val videoUrl = "https:" + it.groupValues[1] + "&res=$qualityStr"
Video(videoUrl, quality, videoUrl, subtitleTracks = subtitleList)
}.toList()
}
private fun decryptAES(password: String, json: JsonObject): String? {
val salt = json["s"]!!.jsonPrimitive.content
val ciphertext = json["ct"]!!.jsonPrimitive.content
return decryptWithSalt(ciphertext, salt, password)
}
private fun Regex.getFirst(item: String): String {
return find(item)?.groups?.elementAt(1)?.value!!
}
companion object {
private const val playerName = "GDRIVE"
private val REGEX_DATAJSON = Regex("data=\"(\\S+?)\";")
private val REGEX_PASSWORD = Regex("var pass = \"(\\S+?)\"")
private val REGEX_SOJSON = Regex("null,['|\"](\\w+)['|\"]")
private val REGEX_VIDEOURL = Regex("file\":\"(\\S+?)\".*?res=(\\d+)")
}
}

View file

@ -0,0 +1,7 @@
plugins {
id("lib-android")
}
dependencies {
implementation(project(":lib:playlist-utils"))
}

View file

@ -0,0 +1,96 @@
package eu.kanade.tachiyomi.lib.gogostreamextractor
import android.util.Base64
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.lib.playlistutils.PlaylistUtils
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.util.asJsoup
import kotlinx.serialization.json.Json
import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import org.jsoup.nodes.Element
import uy.kohesive.injekt.injectLazy
import java.lang.Exception
import javax.crypto.Cipher
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
class GogoStreamExtractor(private val client: OkHttpClient) {
private val json: Json by injectLazy()
private val playlistUtils by lazy { PlaylistUtils(client) }
private fun Element.getBytesAfter(item: String) = className()
.substringAfter(item)
.filter(Char::isDigit)
.toByteArray()
fun videosFromUrl(serverUrl: String): List<Video> {
return runCatching {
val document = client.newCall(GET(serverUrl)).execute().asJsoup()
val iv = document.selectFirst("div.wrapper")!!.getBytesAfter("container-")
val secretKey = document.selectFirst("body[class]")!!.getBytesAfter("container-")
val decryptionKey = document.selectFirst("div.videocontent")!!.getBytesAfter("videocontent-")
val decryptedAjaxParams = cryptoHandler(
document.selectFirst("script[data-value]")!!.attr("data-value"),
iv,
secretKey,
encrypt = 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&$decryptedAjaxParams&alias=$id",
Headers.headersOf(
"X-Requested-With",
"XMLHttpRequest",
),
),
).execute().body.string()
val data = json.decodeFromString<EncryptedDataDto>(jsonResponse).data
val sourceList = cryptoHandler(data, iv, decryptionKey, false)
.let { json.decodeFromString<DecryptedDataDto>(it) }
.source
when {
sourceList.size == 1 && sourceList.first().type == "hls" -> {
val playlistUrl = sourceList.first().file
playlistUtils.extractFromHls(playlistUrl, serverUrl, videoNameGen = { qualityPrefix + it })
}
else -> {
val headers = Headers.headersOf("Referer", serverUrl)
sourceList.map { video ->
Video(video.file, qualityPrefix + video.label, video.file, headers)
}
}
}
}.getOrElse { emptyList() }
}
private fun cryptoHandler(
string: String,
iv: ByteArray,
secretKeyString: ByteArray,
encrypt: Boolean = true,
): String {
val ivParameterSpec = IvParameterSpec(iv)
val secretKey = SecretKeySpec(secretKeyString, "AES")
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
return if (!encrypt) {
cipher.init(Cipher.DECRYPT_MODE, secretKey, ivParameterSpec)
String(cipher.doFinal(Base64.decode(string, Base64.DEFAULT)))
} else {
cipher.init(Cipher.ENCRYPT_MODE, secretKey, ivParameterSpec)
Base64.encodeToString(cipher.doFinal(string.toByteArray()), Base64.NO_WRAP)
}
}
}

View file

@ -0,0 +1,12 @@
package eu.kanade.tachiyomi.lib.gogostreamextractor
import kotlinx.serialization.Serializable
@Serializable
data class EncryptedDataDto(val data: String)
@Serializable
data class DecryptedDataDto(val source: List<SourceDto>)
@Serializable
data class SourceDto(val file: String, val label: String = "", val type: String)

View file

@ -0,0 +1,3 @@
plugins {
id("lib-android")
}

View file

@ -0,0 +1,176 @@
package eu.kanade.tachiyomi.lib.googledriveepisodes
import eu.kanade.tachiyomi.animesource.model.SEpisode
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.util.asJsoup
import eu.kanade.tachiyomi.util.parseAs
import kotlinx.serialization.Serializable
import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.toRequestBody
import java.security.MessageDigest
class GoogleDriveEpisodes(private val client: OkHttpClient, private val headers: Headers) {
// Lots of code borrowed from https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/extractor/googledrive.py under the `GoogleDriveFolderIE` class
fun getEpisodesFromFolder(folderId: String, path: String, maxRecDepth: Int, trimNames: Boolean): List<SEpisode> {
val episodeList = mutableListOf<SEpisode>()
fun traverseFolder(folderId: String, path: String, recursionDepth: Int = 0) {
if (recursionDepth == maxRecDepth) return
val driveHeaders = headers.newBuilder()
.add("Accept", "*/*")
.add("Connection", "keep-alive")
.add("Cookie", getCookie("https://drive.google.com"))
.add("Host", "drive.google.com")
.build()
val driveDocument = client.newCall(
GET("https://drive.google.com/drive/folders/$folderId", headers = driveHeaders),
).execute().asJsoup()
if (driveDocument.selectFirst("title:contains(Error 404 \\(Not found\\))") != null) return
val keyScript = driveDocument.select("script").first { script ->
KEY_REGEX.find(script.data()) != null
}.data()
val key = KEY_REGEX.find(keyScript)?.groupValues?.get(1) ?: ""
val versionScript = driveDocument.select("script").first { script ->
KEY_REGEX.find(script.data()) != null
}.data()
val driveVersion = VERSION_REGEX.find(versionScript)?.groupValues?.get(1) ?: ""
val sapisid = client.cookieJar.loadForRequest("https://drive.google.com".toHttpUrl()).firstOrNull {
it.name == "SAPISID" || it.name == "__Secure-3PAPISID"
}?.value ?: ""
var pageToken: String? = ""
while (pageToken != null) {
val requestUrl = "/drive/v2internal/files?openDrive=false&reason=102&syncType=0&errorRecovery=false&q=trashed%20%3D%20false%20and%20'$folderId'%20in%20parents&fields=kind%2CnextPageToken%2Citems(kind%2CmodifiedDate%2ChasVisitorPermissions%2CcontainsUnsubscribedChildren%2CmodifiedByMeDate%2ClastViewedByMeDate%2CalternateLink%2CfileSize%2Cowners(kind%2CpermissionId%2CemailAddressFromAccount%2Cdomain%2Cid)%2ClastModifyingUser(kind%2CpermissionId%2CemailAddressFromAccount%2Cid)%2CcustomerId%2CancestorHasAugmentedPermissions%2ChasThumbnail%2CthumbnailVersion%2Ctitle%2Cid%2CresourceKey%2CabuseIsAppealable%2CabuseNoticeReason%2Cshared%2CaccessRequestsCount%2CsharedWithMeDate%2CuserPermission(role)%2CexplicitlyTrashed%2CmimeType%2CquotaBytesUsed%2Ccopyable%2Csubscribed%2CfolderColor%2ChasChildFolders%2CfileExtension%2CprimarySyncParentId%2CsharingUser(kind%2CpermissionId%2CemailAddressFromAccount%2Cid)%2CflaggedForAbuse%2CfolderFeatures%2Cspaces%2CsourceAppId%2Crecency%2CrecencyReason%2Cversion%2CactionItems%2CteamDriveId%2ChasAugmentedPermissions%2CcreatedDate%2CprimaryDomainName%2CorganizationDisplayName%2CpassivelySubscribed%2CtrashingUser(kind%2CpermissionId%2CemailAddressFromAccount%2Cid)%2CtrashedDate%2Cparents(id)%2Ccapabilities(canMoveItemIntoTeamDrive%2CcanUntrash%2CcanMoveItemWithinTeamDrive%2CcanMoveItemOutOfTeamDrive%2CcanDeleteChildren%2CcanTrashChildren%2CcanRequestApproval%2CcanReadCategoryMetadata%2CcanEditCategoryMetadata%2CcanAddMyDriveParent%2CcanRemoveMyDriveParent%2CcanShareChildFiles%2CcanShareChildFolders%2CcanRead%2CcanMoveItemWithinDrive%2CcanMoveChildrenWithinDrive%2CcanAddFolderFromAnotherDrive%2CcanChangeSecurityUpdateEnabled%2CcanBlockOwner%2CcanReportSpamOrAbuse%2CcanCopy%2CcanDownload%2CcanEdit%2CcanAddChildren%2CcanDelete%2CcanRemoveChildren%2CcanShare%2CcanTrash%2CcanRename%2CcanReadTeamDrive%2CcanMoveTeamDriveItem)%2CcontentRestrictions(readOnly)%2CapprovalMetadata(approvalVersion%2CapprovalSummaries%2ChasIncomingApproval)%2CshortcutDetails(targetId%2CtargetMimeType%2CtargetLookupStatus%2CtargetFile%2CcanRequestAccessToTarget)%2CspamMetadata(markedAsSpamDate%2CinSpamView)%2Clabels(starred%2Ctrashed%2Crestricted%2Cviewed))%2CincompleteSearch&appDataFilter=NO_APP_DATA&spaces=drive&pageToken=$pageToken&maxResults=100&supportsTeamDrives=true&includeItemsFromAllDrives=true&corpora=default&orderBy=folder%2Ctitle_natural%20asc&retryCount=0&key=$key HTTP/1.1"
val body = """--$BOUNDARY
|content-type: application/http
|content-transfer-encoding: binary
|
|GET $requestUrl
|X-Goog-Drive-Client-Version: $driveVersion
|authorization: ${generateSapisidhashHeader(sapisid)}
|x-goog-authuser: 0
|
|--$BOUNDARY--""".trimMargin("|").toRequestBody("multipart/mixed; boundary=\"$BOUNDARY\"".toMediaType())
val postUrl = buildString {
append("https://clients6.google.com/batch/drive/v2internal")
append("?${'$'}ct=multipart/mixed; boundary=\"$BOUNDARY\"")
append("&key=$key")
}
val postHeaders = headers.newBuilder()
.add("Content-Type", "text/plain; charset=UTF-8")
.add("Origin", "https://drive.google.com")
.add("Cookie", getCookie("https://drive.google.com"))
.build()
val response = client.newCall(
POST(postUrl, body = body, headers = postHeaders),
).execute()
val parsed = response.parseAs<GDrivePostResponse> {
JSON_REGEX.find(it)!!.groupValues[1]
}
if (parsed.items == null) throw Exception("Failed to load items, please log in to google drive through webview")
parsed.items.forEachIndexed { index, it ->
if (it.mimeType.startsWith("video")) {
val size = it.fileSize?.toLongOrNull()?.let { formatBytes(it) }
val pathName = path.trimInfo()
episodeList.add(
SEpisode.create().apply {
name = if (trimNames) it.title.trimInfo() else it.title
this.url = "https://drive.google.com/uc?id=${it.id}"
episode_number = ITEM_NUMBER_REGEX.find(it.title.trimInfo())?.groupValues?.get(1)?.toFloatOrNull() ?: index.toFloat()
date_upload = -1L
scanlator = "$size • /$pathName"
},
)
}
if (it.mimeType.endsWith(".folder")) {
traverseFolder(it.id, "$path/${it.title}", recursionDepth + 1)
}
}
pageToken = parsed.nextPageToken
}
}
traverseFolder(folderId, path)
return episodeList
}
// https://github.com/yt-dlp/yt-dlp/blob/8f0be90ecb3b8d862397177bb226f17b245ef933/yt_dlp/extractor/youtube.py#L573
private fun generateSapisidhashHeader(SAPISID: String, origin: String = "https://drive.google.com"): String {
val timeNow = System.currentTimeMillis() / 1000
// SAPISIDHASH algorithm from https://stackoverflow.com/a/32065323
val sapisidhash = MessageDigest
.getInstance("SHA-1")
.digest("$timeNow $SAPISID $origin".toByteArray())
.joinToString("") { "%02x".format(it) }
return "SAPISIDHASH ${timeNow}_$sapisidhash"
}
@Serializable
data class GDrivePostResponse(
val nextPageToken: String? = null,
val items: List<ResponseItem>? = null,
) {
@Serializable
data class ResponseItem(
val id: String,
val title: String,
val mimeType: String,
val fileSize: String? = null,
)
}
private fun String.trimInfo(): String {
var newString = this.replaceFirst("""^\[\w+\] ?""".toRegex(), "")
val regex = """( ?\[[\s\w-]+\]| ?\([\s\w-]+\))(\.mkv|\.mp4|\.avi)?${'$'}""".toRegex()
while (regex.containsMatchIn(newString)) {
newString = regex.replace(newString) { matchResult ->
matchResult.groups[2]?.value ?: ""
}
}
return newString.trim()
}
private fun formatBytes(bytes: Long): String = when {
bytes >= 1_000_000_000 -> "%.2f GB".format(bytes / 1_000_000_000.0)
bytes >= 1_000_000 -> "%.2f MB".format(bytes / 1_000_000.0)
bytes >= 1_000 -> "%.2f KB".format(bytes / 1_000.0)
bytes > 1 -> "$bytes bytes"
bytes == 1L -> "$bytes byte"
else -> ""
}
private fun getCookie(url: String): String {
val cookieList = client.cookieJar.loadForRequest(url.toHttpUrl())
return if (cookieList.isNotEmpty()) {
cookieList.joinToString("; ") { "${it.name}=${it.value}" }
} else {
""
}
}
companion object {
private val ITEM_NUMBER_REGEX = """ - (?:S\d+E)?(\d+)""".toRegex()
private val KEY_REGEX = """"(\w{39})"""".toRegex()
private val VERSION_REGEX = """"([^"]+web-frontend[^"]+)"""".toRegex()
private val JSON_REGEX = """(?:)\s*(\{(.+)\})\s*(?:)""".toRegex(RegexOption.DOT_MATCHES_ALL)
private const val BOUNDARY = "=====vc17a3rwnndj====="
}
}

View file

@ -0,0 +1,3 @@
plugins {
id("lib-android")
}

View file

@ -0,0 +1,56 @@
package eu.kanade.tachiyomi.lib.googledriveextractor
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.util.asJsoup
import okhttp3.Cookie
import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
class GoogleDriveExtractor(private val client: OkHttpClient, private val headers: Headers) {
companion object {
private const val ACCEPT = "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8"
}
private val cookieList = client.cookieJar.loadForRequest("https://drive.google.com".toHttpUrl())
fun videosFromUrl(itemId: String, videoName: String = "Video"): List<Video> {
val url = "https://drive.usercontent.google.com/download?id=$itemId"
val docHeaders = headers.newBuilder().apply {
add("Accept", ACCEPT)
add("Cookie", cookieList.toStr())
}.build()
val docResp = client.newCall(
GET(url, docHeaders)
).execute()
if (!docResp.peekBody(15).string().equals("<!DOCTYPE html>", true)) {
return listOf(
Video(url, videoName, url, docHeaders)
)
}
val document = docResp.asJsoup()
val itemSize = document.selectFirst("span.uc-name-size")
?.let { " ${it.ownText().trim()} " }
?: ""
val videoUrl = url.toHttpUrl().newBuilder().apply {
document.select("input[type=hidden]").forEach {
setQueryParameter(it.attr("name"), it.attr("value"))
}
}.build().toString()
return listOf(
Video(videoUrl, videoName + itemSize, videoUrl, docHeaders)
)
}
private fun List<Cookie>.toStr(): String {
return this.joinToString("; ") { "${it.name}=${it.value}" }
}
}

View file

@ -0,0 +1,3 @@
plugins {
id("lib-android")
}

View file

@ -0,0 +1,156 @@
package eu.kanade.tachiyomi.lib.javcoverfetcher
import android.content.SharedPreferences
import android.util.Log
import androidx.preference.PreferenceScreen
import androidx.preference.SwitchPreferenceCompat
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.util.asJsoup
import okhttp3.FormBody
import okhttp3.Request
import okhttp3.internal.commonEmptyHeaders
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
object JavCoverFetcher {
private val CLIENT by lazy {
Injekt.get<NetworkHelper>().client
}
/**
* Get HD Jav Cover from Amazon
*
* @param jpTitle title of jav in japanese
*/
fun getCoverByTitle(jpTitle: String): String? {
return runCatching {
val amazonUrl = getDDGSearchResult(jpTitle)
?: return@runCatching null
getHDCoverFromAmazonUrl(amazonUrl)
}.getOrElse {
Log.e("JavCoverFetcher", it.stackTraceToString())
null
}
}
/**
* Get HD Jav Cover from Amazon
*
* @param javId standard JAV code e.g PRIN-006
*/
fun getCoverById(javId: String): String? {
return runCatching {
val jpTitle = getJPTitleFromID(javId)
?: return@runCatching null
val amazonUrl = getDDGSearchResult(jpTitle)
?: return@runCatching null
getHDCoverFromAmazonUrl(amazonUrl)
}.getOrElse {
Log.e("JavCoverFetcher", it.stackTraceToString())
null
}
}
private fun getJPTitleFromID(javId: String): String? {
val url = "https://www.javlibrary.com/ja/vl_searchbyid.php?keyword=$javId"
val request = GET(url, commonEmptyHeaders)
val response = CLIENT.newCall(request).execute()
var document = response.asJsoup()
// possibly multiple results or none
if (response.request.url.pathSegments.contains("vl_searchbyid.php")) {
val targetUrl = document.selectFirst(".videos a[href*=\"?v=\"]")?.attr("abs:href")
?: return null
document = CLIENT.newCall(GET(targetUrl, commonEmptyHeaders)).execute().asJsoup()
}
val dirtyTitle = document.selectFirst(".post-title")?.text()
val id = document.select("#video_info tr > td:contains(品番) + td").text()
return dirtyTitle?.substringAfter(id)?.trim()
}
private fun getDDGSearchResult(jpTitle: String): String? {
val url = "https://lite.duckduckgo.com/lite/"
val form = FormBody.Builder()
.add("q", "site:amazon.co.jp inurl:/dp/$jpTitle")
.build()
val headers = commonEmptyHeaders.newBuilder().apply {
add("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8")
add("Host", "lite.duckduckgo.com")
add("Referer", "https://lite.duckduckgo.com/")
add("Origin", "https://lite.duckduckgo.com")
add("Accept-Language", "en-US,en;q=0.5")
add("DNT", "1")
add("Sec-Fetch-Dest", "document")
add("Sec-Fetch-Mode", "navigate")
add("Sec-Fetch-Site", "same-origin")
add("Sec-Fetch-User", "?1")
add("TE", "trailers")
}.build()
val request = POST(url, headers, form)
val response = CLIENT.newCall(request).execute()
val document = response.asJsoup()
return document.selectFirst("a.result-link")?.attr("href")
}
private fun getHDCoverFromAmazonUrl(amazonUrl: String): String? {
val basicCoverUrl = "https://m.media-amazon.com/images/P/%s.01.MAIN._SCRM_.jpg"
val asinRegex = Regex("""/dp/(\w+)""")
val asin = asinRegex.find(amazonUrl)?.groupValues?.get(1)
?: return null
var cover = basicCoverUrl.replace("%s", asin)
if (!checkCover(cover)) {
cover = cover.replace(".01.", ".")
}
return cover
}
private fun checkCover(cover: String): Boolean {
return getContentLength(cover) > 100
}
private fun getContentLength(url: String): Long {
val request = Request.Builder()
.head()
.url(url)
.build()
val res = CLIENT.newCall(request).execute()
return res.use { it.headers["content-length"] }?.toLongOrNull() ?: 0
}
fun addPreferenceToScreen(screen: PreferenceScreen) {
SwitchPreferenceCompat(screen.context).apply {
key = "JavCoverFetcherPref"
title = "Fetch HD covers from Amazon"
summary = "Attempts to fetch vertical HD covers from Amazon.\nMay result in incorrect cover."
setDefaultValue(false)
}.also(screen::addPreference)
}
val SharedPreferences.fetchHDCovers
get() = getBoolean("JavCoverFetcherPref", false)
}

View file

@ -0,0 +1,8 @@
plugins {
id("lib-android")
}
dependencies {
implementation(project(":lib:cryptoaes"))
implementation(project(":lib:playlist-utils"))
}

View file

@ -0,0 +1,180 @@
package eu.kanade.tachiyomi.lib.megacloudextractor
import android.content.SharedPreferences
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.playlistutils.PlaylistUtils
import eu.kanade.tachiyomi.network.GET
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.CacheControl
import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import uy.kohesive.injekt.injectLazy
class MegaCloudExtractor(
private val client: OkHttpClient,
private val headers: Headers,
private val preferences: SharedPreferences,
) {
private val json: Json by injectLazy()
private val playlistUtils by lazy { PlaylistUtils(client, headers) }
private val cacheControl = CacheControl.Builder().noStore().build()
private val noCacheClient = client.newBuilder()
.cache(null)
.build()
companion object {
private val SERVER_URL = arrayOf("https://megacloud.tv", "https://rapid-cloud.co")
private val SOURCES_URL = arrayOf("/embed-2/ajax/e-1/getSources?id=", "/ajax/embed-6-v2/getSources?id=")
private val SOURCES_SPLITTER = arrayOf("/e-1/", "/embed-6-v2/")
private val SOURCES_KEY = arrayOf("1", "6")
private const val E1_SCRIPT_URL = "https://megacloud.tv/js/player/a/prod/e1-player.min.js"
private const val E6_SCRIPT_URL = "https://rapid-cloud.co/js/player/prod/e6-player-v2.min.js"
private val MUTEX = Mutex()
private var shouldUpdateKey = false
private const val PREF_KEY_KEY = "megacloud_key_"
private const val PREF_KEY_DEFAULT = "[[0, 0]]"
private inline fun <reified R> runLocked(crossinline block: () -> R) = runBlocking(Dispatchers.IO) {
MUTEX.withLock { block() }
}
}
// Stolen from TurkAnime
private fun getKey(type: String): List<List<Int>> = runLocked {
if (shouldUpdateKey) {
updateKey(type)
shouldUpdateKey = false
}
json.decodeFromString<List<List<Int>>>(
preferences.getString(PREF_KEY_KEY + type, PREF_KEY_DEFAULT)!!,
)
}
private fun updateKey(type: String) {
val scriptUrl = when (type) {
"1" -> E1_SCRIPT_URL
"6" -> E6_SCRIPT_URL
else -> throw Exception("Unknown key type")
}
val script = noCacheClient.newCall(GET(scriptUrl, cache = cacheControl))
.execute()
.body.string()
val regex =
Regex("case\\s*0x[0-9a-f]+:(?![^;]*=partKey)\\s*\\w+\\s*=\\s*(\\w+)\\s*,\\s*\\w+\\s*=\\s*(\\w+);")
val matches = regex.findAll(script).toList()
val indexPairs = matches.map { match ->
val var1 = match.groupValues[1]
val var2 = match.groupValues[2]
val regexVar1 = Regex(",$var1=((?:0x)?([0-9a-fA-F]+))")
val regexVar2 = Regex(",$var2=((?:0x)?([0-9a-fA-F]+))")
val matchVar1 = regexVar1.find(script)?.groupValues?.get(1)?.removePrefix("0x")
val matchVar2 = regexVar2.find(script)?.groupValues?.get(1)?.removePrefix("0x")
if (matchVar1 != null && matchVar2 != null) {
try {
listOf(matchVar1.toInt(16), matchVar2.toInt(16))
} catch (e: NumberFormatException) {
emptyList()
}
} else {
emptyList()
}
}.filter { it.isNotEmpty() }
val encoded = json.encodeToString(indexPairs)
preferences.edit().putString(PREF_KEY_KEY + type, encoded).apply()
}
private fun cipherTextCleaner(data: String, type: String): Pair<String, String> {
val indexPairs = getKey(type)
val (password, ciphertext, _) = indexPairs.fold(Triple("", data, 0)) { previous, item ->
val start = item.first() + previous.third
val end = start + item.last()
val passSubstr = data.substring(start, end)
val passPart = previous.first + passSubstr
val cipherPart = previous.second.replace(passSubstr, "")
Triple(passPart, cipherPart, previous.third + item.last())
}
return Pair(ciphertext, password)
}
private fun tryDecrypting(ciphered: String, type: String, attempts: Int = 0): String {
if (attempts > 2) throw Exception("PLEASE NUKE ANIWATCH AND CLOUDFLARE")
val (ciphertext, password) = cipherTextCleaner(ciphered, type)
return CryptoAES.decrypt(ciphertext, password).ifEmpty {
// Update index pairs
shouldUpdateKey = true
tryDecrypting(ciphered, type, attempts + 1)
}
}
fun getVideosFromUrl(url: String, type: String, name: String): List<Video> {
val video = getVideoDto(url)
val masterUrl = video.sources.first().file
val subs2 = video.tracks
?.filter { it.kind == "captions" }
?.map { Track(it.file, it.label) }
.orEmpty()
return playlistUtils.extractFromHls(
masterUrl,
videoNameGen = { "$name - $it - $type" },
subtitleList = subs2,
referer = "https://${url.toHttpUrl().host}/",
)
}
private fun getVideoDto(url: String): VideoDto {
val type = if (url.startsWith("https://megacloud.tv")) 0 else 1
val keyType = SOURCES_KEY[type]
val id = url.substringAfter(SOURCES_SPLITTER[type], "")
.substringBefore("?", "").ifEmpty { throw Exception("I HATE THE ANTICHRIST") }
val srcRes = client.newCall(GET(SERVER_URL[type] + SOURCES_URL[type] + id))
.execute()
.body.string()
val data = json.decodeFromString<SourceResponseDto>(srcRes)
if (!data.encrypted) return json.decodeFromString<VideoDto>(srcRes)
val ciphered = data.sources.jsonPrimitive.content
val decrypted = json.decodeFromString<List<VideoLink>>(tryDecrypting(ciphered, keyType))
return VideoDto(decrypted, data.tracks)
}
@Serializable
data class VideoDto(
val sources: List<VideoLink>,
val tracks: List<TrackDto>? = null,
)
@Serializable
data class SourceResponseDto(
val sources: JsonElement,
val encrypted: Boolean = true,
val tracks: List<TrackDto>? = null,
)
@Serializable
data class VideoLink(val file: String = "")
@Serializable
data class TrackDto(val file: String, val kind: String, val label: String = "")
}

View file

@ -0,0 +1,7 @@
plugins {
id("lib-android")
}
dependencies {
implementation(project(":lib:unpacker"))
}

View file

@ -0,0 +1,52 @@
package eu.kanade.tachiyomi.lib.mixdropextractor
import eu.kanade.tachiyomi.animesource.model.Track
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.lib.unpacker.Unpacker
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.util.asJsoup
import okhttp3.Headers
import okhttp3.OkHttpClient
import java.net.URLDecoder
class MixDropExtractor(private val client: OkHttpClient) {
fun videoFromUrl(
url: String,
lang: String = "",
prefix: String = "",
externalSubs: List<Track> = emptyList(),
referer: String = DEFAULT_REFERER,
): List<Video> {
val headers = Headers.headersOf("Referer", referer)
val doc = client.newCall(GET(url, headers)).execute().asJsoup()
val unpacked = doc.selectFirst("script:containsData(eval):containsData(MDCore)")
?.data()
?.let(Unpacker::unpack)
?: return emptyList()
val videoUrl = "https:" + unpacked.substringAfter("Core.wurl=\"")
.substringBefore("\"")
val subs = unpacked.substringAfter("Core.remotesub=\"").substringBefore('"')
.takeIf(String::isNotBlank)
?.let { listOf(Track(URLDecoder.decode(it, "utf-8"), "sub")) }
?: emptyList()
val quality = buildString {
append("${prefix}MixDrop")
if (lang.isNotBlank()) append("($lang)")
}
return listOf(Video(videoUrl, quality, videoUrl, headers = headers, subtitleTracks = subs + externalSubs))
}
fun videosFromUrl(
url: String,
lang: String = "",
prefix: String = "",
externalSubs: List<Track> = emptyList(),
referer: String = DEFAULT_REFERER,
) = videoFromUrl(url, lang, prefix, externalSubs, referer)
}
private const val DEFAULT_REFERER = "https://mixdrop.co/"

View file

@ -0,0 +1,9 @@
plugins {
id("lib-android")
}
dependencies {
implementation("dev.datlag.jsunpacker:jsunpacker:1.0.1") {
exclude(group = "org.jetbrains.kotlin", module = "kotlin-stdlib-jdk8")
}
}

View file

@ -0,0 +1,36 @@
package eu.kanade.tachiyomi.lib.mp4uploadextractor
import dev.datlag.jsunpacker.JsUnpacker
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.util.asJsoup
import okhttp3.Headers
import okhttp3.OkHttpClient
class Mp4uploadExtractor(private val client: OkHttpClient) {
fun videosFromUrl(url: String, headers: Headers, prefix: String = "", suffix: String = ""): List<Video> {
val newHeaders = headers.newBuilder()
.set("referer", REFERER)
.build()
val doc = client.newCall(GET(url, newHeaders)).execute().asJsoup()
val script = doc.selectFirst("script:containsData(eval):containsData(p,a,c,k,e,d)")?.data()
?.let(JsUnpacker::unpackAndCombine)
?: doc.selectFirst("script:containsData(player.src)")?.data()
?: return emptyList()
val videoUrl = script.substringAfter(".src(").substringBefore(")")
.substringAfter("src:").substringAfter('"').substringBefore('"')
val resolution = QUALITY_REGEX.find(script)?.groupValues?.let { "${it[1]}p" } ?: "Unknown resolution"
val quality = "${prefix}Mp4Upload - $resolution$suffix"
return listOf(Video(videoUrl, quality, videoUrl, newHeaders))
}
companion object {
private val QUALITY_REGEX by lazy { """\WHEIGHT=(\d+)""".toRegex() }
private const val REFERER = "https://mp4upload.com/"
}
}

View file

@ -0,0 +1,7 @@
plugins {
id("lib-android")
}
dependencies {
implementation(project(":lib:playlist-utils"))
}

View file

@ -0,0 +1,77 @@
package eu.kanade.tachiyomi.lib.okruextractor
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.lib.playlistutils.PlaylistUtils
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.util.asJsoup
import okhttp3.OkHttpClient
class OkruExtractor(private val client: OkHttpClient) {
private val playlistUtils by lazy { PlaylistUtils(client) }
private fun fixQuality(quality: String): String {
val qualities = listOf(
Pair("ultra", "2160p"),
Pair("quad", "1440p"),
Pair("full", "1080p"),
Pair("hd", "720p"),
Pair("sd", "480p"),
Pair("low", "360p"),
Pair("lowest", "240p"),
Pair("mobile", "144p"),
)
return qualities.find { it.first == quality }?.second ?: quality
}
fun videosFromUrl(url: String, prefix: String = "", fixQualities: Boolean = true): List<Video> {
val document = client.newCall(GET(url)).execute().asJsoup()
val videoString = document.selectFirst("div[data-options]")
?.attr("data-options")
?: return emptyList<Video>()
return when {
"ondemandHls" in videoString -> {
val playlistUrl = videoString.extractLink("ondemandHls")
playlistUtils.extractFromHls(playlistUrl, videoNameGen = { "Okru:$it".addPrefix(prefix) })
}
"ondemandDash" in videoString -> {
val playlistUrl = videoString.extractLink("ondemandDash")
playlistUtils.extractFromDash(playlistUrl, videoNameGen = { it -> "Okru:$it".addPrefix(prefix) })
}
else -> videosFromJson(videoString, prefix, fixQualities)
}
}
private fun String.addPrefix(prefix: String) =
prefix.takeIf(String::isNotBlank)
?.let { "$prefix $this" }
?: this
private fun String.extractLink(attr: String) =
substringAfter("$attr\\\":\\\"")
.substringBefore("\\\"")
.replace("\\\\u0026", "&")
private fun videosFromJson(videoString: String, prefix: String = "", fixQualities: Boolean = true): List<Video> {
val arrayData = videoString.substringAfter("\\\"videos\\\":[{\\\"name\\\":\\\"")
.substringBefore("]")
return arrayData.split("{\\\"name\\\":\\\"").reversed().mapNotNull {
val videoUrl = it.extractLink("url")
val quality = it.substringBefore("\\\"").let {
if (fixQualities) {
fixQuality(it)
} else {
it
}
}
val videoQuality = "Okru:$quality".addPrefix(prefix)
if (videoUrl.startsWith("https://")) {
Video(videoUrl, videoQuality, videoUrl)
} else {
null
}
}
}
}

View file

@ -0,0 +1,3 @@
plugins {
id("lib-android")
}

View file

@ -0,0 +1,337 @@
package eu.kanade.tachiyomi.lib.playlistutils
import eu.kanade.tachiyomi.animesource.model.Track
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.util.asJsoup
import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okhttp3.internal.commonEmptyHeaders
class PlaylistUtils(private val client: OkHttpClient, private val headers: Headers = commonEmptyHeaders) {
// ================================ M3U8 ================================
/**
* Extracts videos from a .m3u8 file.
*
* @param playlistUrl the URL of the HLS playlist
* @param referer the referer header value to be sent in the HTTP request (default: "")
* @param masterHeaders header for the master playlist
* @param videoHeaders headers for each video
* @param videoNameGen a function that generates a custom name for each video based on its quality
* - The parameter `quality` represents the quality of the video
* - Returns the custom name for the video (default: identity function)
* @param subtitleList a list of subtitle tracks associated with the HLS playlist, will append to subtitles present in the m3u8 playlist (default: empty list)
* @param audioList a list of audio tracks associated with the HLS playlist, will append to audio tracks present in the m3u8 playlist (default: empty list)
* @return a list of Video objects
*/
fun extractFromHls(
playlistUrl: String,
referer: String = "",
masterHeaders: Headers,
videoHeaders: Headers,
videoNameGen: (String) -> String = { quality -> quality },
subtitleList: List<Track> = emptyList(),
audioList: List<Track> = emptyList(),
): List<Video> {
return extractFromHls(
playlistUrl,
referer,
{ _, _ -> masterHeaders },
{ _, _, _ -> videoHeaders },
videoNameGen,
subtitleList,
audioList,
)
}
/**
* Extracts videos from a .m3u8 file.
*
* @param playlistUrl the URL of the HLS playlist
* @param referer the referer header value to be sent in the HTTP request (default: "")
* @param masterHeadersGen a function that generates headers for the master playlist request
* - The first parameter `baseHeaders` represents the class constructor `headers`
* - The second parameter `referer` represents the referer header value
* - Returns the updated headers for the master playlist request (default: generateMasterHeaders(baseHeaders, referer))
* @param videoHeadersGen a function that generates headers for each video request
* - The first parameter `baseHeaders` represents the class constructor `headers`
* - The second parameter `referer` represents the referer header value
* - The third parameter `videoUrl` represents the URL of the video
* - Returns the updated headers for the video request (default: generateMasterHeaders(baseHeaders, referer))
* @param videoNameGen a function that generates a custom name for each video based on its quality
* - The parameter `quality` represents the quality of the video
* - Returns the custom name for the video (default: identity function)
* @param subtitleList a list of subtitle tracks associated with the HLS playlist, will append to subtitles present in the m3u8 playlist (default: empty list)
* @param audioList a list of audio tracks associated with the HLS playlist, will append to audio tracks present in the m3u8 playlist (default: empty list)
* @return a list of Video objects
*/
fun extractFromHls(
playlistUrl: String,
referer: String = "",
masterHeadersGen: (Headers, String) -> Headers = { baseHeaders, referer ->
generateMasterHeaders(baseHeaders, referer)
},
videoHeadersGen: (Headers, String, String) -> Headers = { baseHeaders, referer, videoUrl ->
generateMasterHeaders(baseHeaders, referer)
},
videoNameGen: (String) -> String = { quality -> quality },
subtitleList: List<Track> = emptyList(),
audioList: List<Track> = emptyList(),
): List<Video> {
val masterHeaders = masterHeadersGen(headers, referer)
val masterPlaylist = client.newCall(GET(playlistUrl, masterHeaders)).execute()
.body.string()
// Check if there isn't multiple streams available
if (PLAYLIST_SEPARATOR !in masterPlaylist) {
return listOf(
Video(
playlistUrl,
videoNameGen("Video"),
playlistUrl,
headers = masterHeaders,
subtitleTracks = subtitleList,
audioTracks = audioList,
),
)
}
val playlistHttpUrl = playlistUrl.toHttpUrl()
val masterUrlBasePath = playlistHttpUrl.newBuilder().apply {
removePathSegment(playlistHttpUrl.pathSize - 1)
addPathSegment("")
query(null)
fragment(null)
}.build().toString()
// Get subtitles
val subtitleTracks = subtitleList + SUBTITLE_REGEX.findAll(masterPlaylist).mapNotNull {
Track(
getAbsoluteUrl(it.groupValues[2], playlistUrl, masterUrlBasePath) ?: return@mapNotNull null,
it.groupValues[1],
)
}.toList()
// Get audio tracks
val audioTracks = audioList + AUDIO_REGEX.findAll(masterPlaylist).mapNotNull {
Track(
getAbsoluteUrl(it.groupValues[2], playlistUrl, masterUrlBasePath) ?: return@mapNotNull null,
it.groupValues[1],
)
}.toList()
return masterPlaylist.substringAfter(PLAYLIST_SEPARATOR).split(PLAYLIST_SEPARATOR).mapNotNull {
val resolution = it.substringAfter("RESOLUTION=")
.substringBefore("\n")
.substringAfter("x")
.substringBefore(",") + "p"
val videoUrl = it.substringAfter("\n").substringBefore("\n").let { url ->
getAbsoluteUrl(url, playlistUrl, masterUrlBasePath)?.trimEnd()
} ?: return@mapNotNull null
Video(
videoUrl,
videoNameGen(resolution),
videoUrl,
headers = videoHeadersGen(headers, referer, videoUrl),
subtitleTracks = subtitleTracks,
audioTracks = audioTracks,
)
}
}
private fun getAbsoluteUrl(url: String, playlistUrl: String, masterBase: String): String? {
return when {
url.isEmpty() -> null
url.startsWith("http") -> url
url.startsWith("//") -> "https:$url"
url.startsWith("/") -> playlistUrl.toHttpUrl().newBuilder().encodedPath("/").build().toString()
.substringBeforeLast("/") + url
else -> masterBase + url
}
}
fun generateMasterHeaders(baseHeaders: Headers, referer: String): Headers {
return baseHeaders.newBuilder().apply {
set("Accept", "*/*")
if (referer.isNotEmpty()) {
set("Origin", "https://${referer.toHttpUrl().host}")
set("Referer", referer)
}
}.build()
}
// ================================ DASH ================================
/**
* Extracts video information from a DASH .mpd file.
*
* @param mpdUrl the URL of the .mpd file
* @param videoNameGen a function that generates a custom name for each video based on its quality
* - The parameter `quality` represents the quality of the video
* - Returns the custom name for the video
* @param mpdHeaders the headers to be sent in the HTTP request for the MPD file
* @param videoHeaders the headers to be sent in the HTTP requests for video segments
* @param referer the referer header value to be sent in the HTTP requests (default: "")
* @param subtitleList a list of subtitle tracks associated with the DASH file, will append to subtitles present in the dash file (default: empty list)
* @param audioList a list of audio tracks associated with the DASH file, will append to audio tracks present in the dash file (default: empty list)
* @return a list of Video objects
*/
fun extractFromDash(
mpdUrl: String,
videoNameGen: (String) -> String,
mpdHeaders: Headers,
videoHeaders: Headers,
referer: String = "",
subtitleList: List<Track> = emptyList(),
audioList: List<Track> = emptyList(),
): List<Video> {
return extractFromDash(
mpdUrl,
{ videoRes, bandwidth ->
videoNameGen(videoRes) + " - ${formatBytes(bandwidth.toLongOrNull())}"
},
referer,
{ _, _ -> mpdHeaders },
{ _, _, _ -> videoHeaders },
subtitleList,
audioList,
)
}
/**
* Extracts video information from a DASH .mpd file.
*
* @param mpdUrl the URL of the .mpd file
* @param videoNameGen a function that generates a custom name for each video based on its quality
* - The parameter `quality` represents the quality of the video
* - Returns the custom name for the video, with ` - <BANDWIDTH>` added to the end
* @param referer the referer header value to be sent in the HTTP requests (default: "")
* @param mpdHeadersGen a function that generates headers for the .mpd request
* - The first parameter `baseHeaders` represents the class constructor `headers`
* - The second parameter `referer` represents the referer header value
* - Returns the updated headers for the .mpd request (default: generateMasterHeaders(baseHeaders, referer))
* @param videoHeadersGen a function that generates headers for each video request
* - The first parameter `baseHeaders` represents the class constructor `headers`
* - The second parameter `referer` represents the referer header value
* - The third parameter `videoUrl` represents the URL of the video
* - Returns the updated headers for the video segment request (default: generateMasterHeaders(baseHeaders, referer))
* @param subtitleList a list of subtitle tracks associated with the DASH file, will append to subtitles present in the dash file (default: empty list)
* @param audioList a list of audio tracks associated with the DASH file, will append to audio tracks present in the dash file (default: empty list)
* @return a list of Video objects
*/
fun extractFromDash(
mpdUrl: String,
videoNameGen: (String) -> String,
referer: String = "",
mpdHeadersGen: (Headers, String) -> Headers = { baseHeaders, referer ->
generateMasterHeaders(baseHeaders, referer)
},
videoHeadersGen: (Headers, String, String) -> Headers = { baseHeaders, referer, videoUrl ->
generateMasterHeaders(baseHeaders, referer)
},
subtitleList: List<Track> = emptyList(),
audioList: List<Track> = emptyList(),
): List<Video> {
return extractFromDash(
mpdUrl,
{ videoRes, bandwidth ->
videoNameGen(videoRes) + " - ${formatBytes(bandwidth.toLongOrNull())}"
},
referer,
mpdHeadersGen,
videoHeadersGen,
subtitleList,
audioList,
)
}
/**
* Extracts video information from a DASH .mpd file.
*
* @param mpdUrl the URL of the .mpd file
* @param videoNameGen a function that generates a custom name for each video based on its quality and bandwidth
* - The parameter `quality` represents the quality of the video segment
* - The parameter `bandwidth` represents the bandwidth of the video segment, in bytes
* - Returns the custom name for the video
* @param referer the referer header value to be sent in the HTTP requests (default: "")
* @param mpdHeadersGen a function that generates headers for the .mpd request
* - The first parameter `baseHeaders` represents the class constructor `headers`
* - The second parameter `referer` represents the referer header value
* - Returns the updated headers for the .mpd request (default: generateMasterHeaders(baseHeaders, referer))
* @param videoHeadersGen a function that generates headers for each video request
* - The first parameter `baseHeaders` represents the class constructor `headers`
* - The second parameter `referer` represents the referer header value
* - The third parameter `videoUrl` represents the URL of the video
* - Returns the updated headers for the video segment request (default: generateMasterHeaders(baseHeaders, referer))
* @param subtitleList a list of subtitle tracks associated with the DASH file, will append to subtitles present in the dash file (default: empty list)
* @param audioList a list of audio tracks associated with the DASH file, will append to audio tracks present in the dash file (default: empty list)
* @return a list of Video objects
*/
fun extractFromDash(
mpdUrl: String,
videoNameGen: (String, String) -> String,
referer: String = "",
mpdHeadersGen: (Headers, String) -> Headers = { baseHeaders, referer ->
generateMasterHeaders(baseHeaders, referer)
},
videoHeadersGen: (Headers, String, String) -> Headers = { baseHeaders, referer, videoUrl ->
generateMasterHeaders(baseHeaders, referer)
},
subtitleList: List<Track> = emptyList(),
audioList: List<Track> = emptyList(),
): List<Video> {
val mpdHeaders = mpdHeadersGen(headers, referer)
val doc = client.newCall(GET(mpdUrl, mpdHeaders)).execute()
.asJsoup()
// Get audio tracks
val audioTracks = audioList + doc.select("Representation[mimetype~=audio]").map { audioSrc ->
val bandwidth = audioSrc.attr("bandwidth").toLongOrNull()
Track(audioSrc.text(), formatBytes(bandwidth))
}
return doc.select("Representation[mimetype~=video]").map { videoSrc ->
val bandwidth = videoSrc.attr("bandwidth")
val res = videoSrc.attr("height") + "p"
val videoUrl = videoSrc.text()
Video(
videoUrl,
videoNameGen(res, bandwidth),
videoUrl,
audioTracks = audioTracks,
subtitleTracks = subtitleList,
headers = videoHeadersGen(headers, referer, videoUrl),
)
}
}
private fun formatBytes(bytes: Long?): String {
return when {
bytes == null -> ""
bytes >= 1_000_000_000 -> "%.2f GB/s".format(bytes / 1_000_000_000.0)
bytes >= 1_000_000 -> "%.2f MB/s".format(bytes / 1_000_000.0)
bytes >= 1_000 -> "%.2f KB/s".format(bytes / 1_000.0)
bytes > 1 -> "$bytes bytes/s"
bytes == 1L -> "$bytes byte/s"
else -> ""
}
}
// ============================= Utilities ==============================
companion object {
private const val PLAYLIST_SEPARATOR = "#EXT-X-STREAM-INF:"
private val SUBTITLE_REGEX by lazy { Regex("""#EXT-X-MEDIA:TYPE=SUBTITLES.*?NAME="(.*?)".*?URI="(.*?)"""") }
private val AUDIO_REGEX by lazy { Regex("""#EXT-X-MEDIA:TYPE=AUDIO.*?NAME="(.*?)".*?URI="(.*?)"""") }
}
}

View file

@ -0,0 +1,7 @@
plugins {
id("lib-android")
}
dependencies {
implementation(project(":lib:playlist-utils"))
}

View file

@ -0,0 +1,29 @@
package eu.kanade.tachiyomi.lib.sendvidextractor
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.lib.playlistutils.PlaylistUtils
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.util.asJsoup
import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
class SendvidExtractor(private val client: OkHttpClient, private val headers: Headers) {
private val playlistUtils by lazy { PlaylistUtils(client, headers) }
fun videosFromUrl(url: String, prefix: String = ""): List<Video> {
val document = client.newCall(GET(url, headers)).execute().asJsoup()
val masterUrl = document.selectFirst("source#video_source")?.attr("src") ?: return emptyList()
return if (masterUrl.contains(".m3u8")) {
playlistUtils.extractFromHls(masterUrl, url, videoNameGen = { prefix + "Sendvid:$it" })
} else {
val httpUrl = "https://${url.toHttpUrl()}"
val newHeaders = headers.newBuilder()
.set("Origin", httpUrl)
.set("Referer", "$httpUrl/")
.build()
listOf(Video(masterUrl, prefix + "Sendvid:default", masterUrl, newHeaders))
}
}
}

View file

@ -0,0 +1,3 @@
plugins {
id("lib-android")
}

View file

@ -0,0 +1,39 @@
package eu.kanade.tachiyomi.lib.sibnetextractor
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.util.asJsoup
import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
class SibnetExtractor(private val client: OkHttpClient) {
fun videosFromUrl(url: String, prefix: String = ""): List<Video> {
val videoList = mutableListOf<Video>()
val document = client.newCall(
GET(url),
).execute().asJsoup()
val script = document.selectFirst("script:containsData(player.src)")?.data() ?: return emptyList()
val slug = script.substringAfter("player.src").substringAfter("src:")
.substringAfter("\"").substringBefore("\"")
val videoUrl = if (slug.contains("http")) {
slug
} else {
"https://${url.toHttpUrl().host}$slug"
}
val videoHeaders = Headers.headersOf(
"Referer",
url,
)
videoList.add(
Video(videoUrl, "${prefix}Sibnet", videoUrl, headers = videoHeaders),
)
return videoList
}
}

View file

@ -0,0 +1,3 @@
plugins {
id("lib-android")
}

View file

@ -0,0 +1,17 @@
package eu.kanade.tachiyomi.lib.streamdavextractor
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.util.asJsoup
import okhttp3.OkHttpClient
class StreamDavExtractor(private val client: OkHttpClient) {
fun videosFromUrl(url: String, prefix: String = ""): List<Video> {
val document = client.newCall(GET(url)).execute().asJsoup()
return document.select("source").map {
val videoUrl = it.attr("src")
val quality = it.attr("label")
Video(url, "${prefix}StreamDav - ($quality)", videoUrl)
}
}
}

View file

@ -0,0 +1,10 @@
plugins {
id("lib-android")
}
dependencies {
implementation(project(":lib:playlist-utils"))
implementation("dev.datlag.jsunpacker:jsunpacker:1.0.1") {
exclude(group = "org.jetbrains.kotlin", module = "kotlin-stdlib-jdk8")
}
}

View file

@ -0,0 +1,25 @@
package eu.kanade.tachiyomi.lib.streamhidevidextractor
import dev.datlag.jsunpacker.JsUnpacker
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.lib.playlistutils.PlaylistUtils
import eu.kanade.tachiyomi.network.GET
import okhttp3.OkHttpClient
class StreamHideVidExtractor(private val client: OkHttpClient) {
private val playlistUtils by lazy { PlaylistUtils(client) }
fun videosFromUrl(url: String, prefix: String = ""): List<Video> {
val page = client.newCall(GET(url)).execute().body.string()
val playlistUrl = (JsUnpacker.unpackAndCombine(page) ?: page)
.substringAfter("sources:")
.substringAfter("file:\"") // StreamHide
.substringAfter("src:\"") // StreamVid
.substringBefore('"')
if (!playlistUrl.startsWith("http")) return emptyList()
return playlistUtils.extractFromHls(playlistUrl,
videoNameGen = { "${prefix}StreamHideVid - $it" }
)
}
}

View file

@ -0,0 +1,7 @@
plugins {
id("lib-android")
}
dependencies {
implementation(project(":lib:playlist-utils"))
}

View file

@ -0,0 +1,23 @@
package eu.kanade.tachiyomi.lib.streamhubextractor
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.lib.playlistutils.PlaylistUtils
import eu.kanade.tachiyomi.network.GET
import okhttp3.OkHttpClient
class StreamHubExtractor(private val client: OkHttpClient) {
private val playlistUtils by lazy { PlaylistUtils(client) }
fun videosFromUrl(url: String, prefix: String = ""): List<Video> {
val document = client.newCall(GET(url)).execute().body.string()
val id = REGEX_ID.find(document)?.groupValues?.get(1)
val sub = REGEX_SUB.find(document)?.groupValues?.get(1)
val masterUrl = "https://$sub.streamhub.ink/hls/,$id,.urlset/master.m3u8"
return playlistUtils.extractFromHls(masterUrl, videoNameGen = { "${prefix}StreamHub - ($it)" })
}
companion object {
private val REGEX_ID = Regex("urlset\\|(.*?)\\|")
private val REGEX_SUB = Regex("width\\|(.*?)\\|")
}
}

View file

@ -0,0 +1,3 @@
plugins {
id("lib-android")
}

View file

@ -0,0 +1,57 @@
package eu.kanade.tachiyomi.lib.streamlareextractor
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.toRequestBody
class StreamlareExtractor(private val client: OkHttpClient) {
fun videosFromUrl(url: String, prefix: String = "", suffix: String = ""): List<Video> {
val id = url.split("/").last()
val playlist = client.newCall(
POST(
"https://slwatch.co/api/video/stream/get",
body = "{\"id\":\"$id\"}"
.toRequestBody("application/json".toMediaType()),
),
).execute().body.string()
val type = playlist.substringAfter("\"type\":\"").substringBefore("\"")
return if (type == "hls") {
val masterPlaylistUrl = playlist.substringAfter("\"file\":\"").substringBefore("\"").replace("\\/", "/")
val masterPlaylist = client.newCall(GET(masterPlaylistUrl)).execute().body.string()
val separator = "#EXT-X-STREAM-INF"
masterPlaylist.substringAfter(separator).split(separator).map {
val quality = it.substringAfter("RESOLUTION=").substringAfter("x").substringBefore(",") + "p"
val videoUrl = it.substringAfter("\n").substringBefore("\n").let { urlPart ->
when {
!urlPart.startsWith("http") ->
masterPlaylistUrl.substringBefore("master.m3u8") + urlPart
else -> urlPart
}
}
Video(videoUrl, buildQuality(quality, prefix, suffix), videoUrl)
}
} else {
val separator = "\"label\":\""
playlist.substringAfter(separator).split(separator).map {
val quality = it.substringAfter(separator).substringBefore("\",")
val apiUrl = it.substringAfter("\"file\":\"").substringBefore("\",")
.replace("\\", "")
val response = client.newCall(POST(apiUrl)).execute()
val videoUrl = response.request.url.toString()
Video(videoUrl, buildQuality(quality, prefix, suffix), videoUrl)
}
}
}
private fun buildQuality(resolution: String, prefix: String = "", suffix: String = "") =
buildString {
if (prefix.isNotBlank()) append("$prefix ")
append("Streamlare:$resolution")
if (suffix.isNotBlank()) append(" $suffix")
}
}

View file

@ -0,0 +1,3 @@
plugins {
id("lib-android")
}

View file

@ -0,0 +1,33 @@
package eu.kanade.tachiyomi.lib.streamtapeextractor
import eu.kanade.tachiyomi.animesource.model.Track
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.util.asJsoup
import okhttp3.OkHttpClient
class StreamTapeExtractor(private val client: OkHttpClient) {
fun videoFromUrl(url: String, quality: String = "Streamtape", subtitleList: List<Track> = emptyList()): Video? {
val baseUrl = "https://streamtape.com/e/"
val newUrl = if (!url.startsWith(baseUrl)) {
// ["https", "", "<domain>", "<???>", "<id>", ...]
val id = url.split("/").getOrNull(4) ?: return null
baseUrl + id
} else { url }
val document = client.newCall(GET(newUrl)).execute().asJsoup()
val targetLine = "document.getElementById('robotlink')"
val script = document.selectFirst("script:containsData($targetLine)")
?.data()
?.substringAfter("$targetLine.innerHTML = '")
?: return null
val videoUrl = "https:" + script.substringBefore("'") +
script.substringAfter("+ ('xcd").substringBefore("'")
return Video(videoUrl, quality, videoUrl, subtitleTracks = subtitleList)
}
fun videosFromUrl(url: String, quality: String = "Streamtape", subtitleList: List<Track> = emptyList()): List<Video> {
return videoFromUrl(url, quality, subtitleList)?.let(::listOf).orEmpty()
}
}

View file

@ -0,0 +1,10 @@
plugins {
id("lib-android")
}
dependencies {
implementation(project(":lib:playlist-utils"))
implementation("dev.datlag.jsunpacker:jsunpacker:1.0.1") {
exclude(group = "org.jetbrains.kotlin", module = "kotlin-stdlib-jdk8")
}
}

View file

@ -0,0 +1,26 @@
package eu.kanade.tachiyomi.lib.streamvidextractor
import dev.datlag.jsunpacker.JsUnpacker
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.lib.playlistutils.PlaylistUtils
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.util.asJsoup
import okhttp3.OkHttpClient
class StreamVidExtractor(private val client: OkHttpClient) {
fun videosFromUrl(url: String, prefix: String = "", sourceChange: Boolean = false): List<Video> {
return runCatching {
val doc = client.newCall(GET(url)).execute().asJsoup()
val script = doc.selectFirst("script:containsData(eval):containsData(p,a,c,k,e,d)")?.data()
?.let(JsUnpacker::unpackAndCombine)
?: return emptyList()
val masterUrl = if (!sourceChange) {
script.substringAfter("sources:[{src:\"").substringBefore("\",")
} else {
script.substringAfter("sources:[{file:\"").substringBefore("\"")
}
PlaylistUtils(client).extractFromHls(masterUrl, videoNameGen = { "${prefix}StreamVid - (${it}p)" })
}.getOrElse { emptyList() }
}
}

View file

@ -0,0 +1,10 @@
plugins {
id("lib-android")
}
dependencies {
implementation("dev.datlag.jsunpacker:jsunpacker:1.0.1") {
exclude(group = "org.jetbrains.kotlin", module = "kotlin-stdlib-jdk8")
}
implementation(project(":lib:playlist-utils"))
}

View file

@ -0,0 +1,38 @@
package eu.kanade.tachiyomi.lib.streamwishextractor
import dev.datlag.jsunpacker.JsUnpacker
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.lib.playlistutils.PlaylistUtils
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.util.asJsoup
import okhttp3.Headers
import okhttp3.OkHttpClient
class StreamWishExtractor(private val client: OkHttpClient, private val headers: Headers) {
private val playlistUtils by lazy { PlaylistUtils(client, headers) }
fun videosFromUrl(url: String, prefix: String) = videosFromUrl(url) { "$prefix - $it" }
fun videosFromUrl(url: String, videoNameGen: (String) -> String = { quality -> "StreamWish - $quality" }): List<Video> {
val doc = client.newCall(GET(url, headers)).execute()
.asJsoup()
// Sometimes the script body is packed, sometimes it isn't
val scriptBody = doc.selectFirst("script:containsData(m3u8)")?.data()
?.let { script ->
if (script.contains("eval(function(p,a,c")) {
JsUnpacker.unpackAndCombine(script)
} else {
script
}
}
val masterUrl = scriptBody
?.substringAfter("source", "")
?.substringAfter("file:\"", "")
?.substringBefore("\"", "")
?.takeIf(String::isNotBlank)
?: return emptyList()
return playlistUtils.extractFromHls(masterUrl, url, videoNameGen = videoNameGen)
}
}

View file

@ -0,0 +1,3 @@
plugins {
id("lib-android")
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,38 @@
package eu.kanade.tachiyomi.lib.synchrony
import app.cash.quickjs.QuickJs
/**
* Helper class to deobfuscate JavaScript strings with synchrony.
*/
object Deobfuscator {
fun deobfuscateScript(source: String): String? {
val originalScript = javaClass.getResource("/assets/$SCRIPT_NAME")
?.readText() ?: return null
// Sadly needed until QuickJS properly supports module imports:
// Regex for finding one and two in "export{one as Deobfuscator,two as Transformer};"
val regex = """export\{(.*) as Deobfuscator,(.*) as Transformer\};""".toRegex()
val synchronyScript = regex.find(originalScript)?.let { match ->
val (deob, trans) = match.destructured
val replacement = "const Deobfuscator = $deob, Transformer = $trans;"
originalScript.replace(match.value, replacement)
} ?: return null
return QuickJs.create().use { engine ->
engine.evaluate("globalThis.console = { log: () => {}, warn: () => {}, error: () => {}, trace: () => {} };")
engine.evaluate(synchronyScript)
engine.set("source", TestInterface::class.java, object : TestInterface { override fun getValue() = source })
engine.evaluate("new Deobfuscator().deobfuscateSource(source.getValue())") as? String
}
}
@Suppress("unused")
private interface TestInterface {
fun getValue(): String
}
}
// Update this when the script is updated!
private const val SCRIPT_NAME = "synchrony-v2.4.5.1.js"

View file

@ -0,0 +1,8 @@
plugins {
id("lib-kotlin")
}
java {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}

View file

@ -0,0 +1,42 @@
package eu.kanade.tachiyomi.lib.unpacker
/*
* 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/.
*/
/**
* A helper class to extract substrings efficiently.
*
* Note that all methods move [startIndex] over the ending delimiter.
*/
class SubstringExtractor(private val text: String) {
private var startIndex = 0
fun skipOver(str: String) {
val index = text.indexOf(str, startIndex)
if (index == -1) return
startIndex = index + str.length
}
fun substringBefore(str: String): String {
val index = text.indexOf(str, startIndex)
if (index == -1) return ""
val result = text.substring(startIndex, index)
startIndex = index + str.length
return result
}
fun substringBetween(left: String, right: String): String {
val index = text.indexOf(left, startIndex)
if (index == -1) return ""
val leftIndex = index + left.length
val rightIndex = text.indexOf(right, leftIndex)
if (rightIndex == -1) return ""
startIndex = rightIndex + right.length
return text.substring(leftIndex, rightIndex)
}
}

View file

@ -0,0 +1,84 @@
package eu.kanade.tachiyomi.lib.unpacker
/*
* 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 unpack JavaScript code compressed by [packer](http://dean.edwards.name/packer/).
*
* Source code of packer can be found [here](https://github.com/evanw/packer/blob/master/packer.js).
*/
object Unpacker {
/**
* Unpacks JavaScript code compressed by packer.
*
* Specify [left] and [right] to unpack only the data between them.
*
* Note: single quotes `\'` in the data will be replaced with double quotes `"`.
*/
fun unpack(script: String, left: String? = null, right: String? = null): String =
unpack(SubstringExtractor(script), left, right)
/**
* Unpacks JavaScript code compressed by packer.
*
* Specify [left] and [right] to unpack only the data between them.
*
* Note: single quotes `\'` in the data will be replaced with double quotes `"`.
*/
fun unpack(script: SubstringExtractor, left: String? = null, right: String? = null): String {
val packed = script
.substringBetween("}('", ".split('|'),0,{}))")
.replace("\\'", "\"")
val parser = SubstringExtractor(packed)
val data: String
if (left != null && right != null) {
data = parser.substringBetween(left, right)
parser.skipOver("',")
} else {
data = parser.substringBefore("',")
}
if (data.isEmpty()) return ""
val dictionary = parser.substringBetween("'", "'").split("|")
val size = dictionary.size
return wordRegex.replace(data) {
val key = it.value
val index = parseRadix62(key)
if (index >= size) return@replace key
dictionary[index].ifEmpty { key }
}
}
private val wordRegex by lazy { Regex("""[0-9A-Za-z]+""") }
private fun parseRadix62(str: String): Int {
var result = 0
for (ch in str.toCharArray()) {
result = result * 62 + when {
ch.code <= '9'.code -> { // 0-9
ch.code - '0'.code
}
ch.code >= 'a'.code -> { // a-z
// ch - 'a' + 10
ch.code - ('a'.code - 10)
}
else -> { // A-Z
// ch - 'A' + 36
ch.code - ('A'.code - 36)
}
}
}
return result
}
}

View file

@ -0,0 +1,10 @@
plugins {
id("lib-android")
}
dependencies {
implementation(project(":lib:playlist-utils"))
implementation("dev.datlag.jsunpacker:jsunpacker:1.0.1") {
exclude(group = "org.jetbrains.kotlin", module = "kotlin-stdlib-jdk8")
}
}

View file

@ -0,0 +1,17 @@
package eu.kanade.tachiyomi.lib.upstreamextractor
import dev.datlag.jsunpacker.JsUnpacker
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.lib.playlistutils.PlaylistUtils
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.util.asJsoup
import okhttp3.OkHttpClient
class UpstreamExtractor(private val client: OkHttpClient) {
fun videosFromUrl(url: String, prefix: String = ""): List<Video> =
runCatching {
val jsE = client.newCall(GET(url)).execute().asJsoup().selectFirst("script:containsData(eval)")!!.data()
val masterUrl = JsUnpacker.unpackAndCombine(jsE)!!.substringAfter("{file:\"").substringBefore("\"}")
PlaylistUtils(client).extractFromHls(masterUrl, videoNameGen = { "${prefix}Upstream - $it" })
}.getOrDefault(emptyList())
}

View file

@ -0,0 +1,3 @@
plugins {
id("lib-android")
}

View file

@ -0,0 +1,25 @@
package eu.kanade.tachiyomi.lib.uqloadextractor
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.util.asJsoup
import okhttp3.Headers
import okhttp3.OkHttpClient
class UqloadExtractor(private val client: OkHttpClient) {
fun videosFromUrl(url: String, prefix: String = ""): List<Video> {
val doc = client.newCall(GET(url)).execute().asJsoup()
val script = doc.selectFirst("script:containsData(sources:)")?.data()
?: return emptyList()
val videoUrl = script.substringAfter("sources: [\"").substringBefore('"')
.takeIf(String::isNotBlank)
?.takeIf { it.startsWith("http") }
?: return emptyList()
val videoHeaders = Headers.headersOf("Referer", "https://uqload.co/")
val quality = if (prefix.isNotBlank()) "$prefix Uqload" else "Uqload"
return listOf(Video(videoUrl, quality, videoUrl, videoHeaders))
}
}

View file

@ -0,0 +1,3 @@
plugins {
id("lib-android")
}

View file

@ -0,0 +1,23 @@
package eu.kanade.tachiyomi.lib.vidbomextractor
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.util.asJsoup
import okhttp3.OkHttpClient
class VidBomExtractor(private val client: OkHttpClient) {
fun videosFromUrl(url: String): List<Video> {
val doc = client.newCall(GET(url)).execute().asJsoup()
val script = doc.selectFirst("script:containsData(sources)")!!
val data = script.data().substringAfter("sources: [").substringBefore("],")
return data.split("file:\"").drop(1).map { source ->
val src = source.substringBefore("\"")
var quality = "Vidbom: " + source.substringAfter("label:\"").substringBefore("\"")
if (quality.length > 15) {
quality = "Vidshare: 480p"
}
Video(src, quality, src)
}
}
}

View file

@ -0,0 +1,7 @@
plugins {
id("lib-android")
}
dependencies {
implementation(project(":lib:playlist-utils"))
}

View file

@ -0,0 +1,64 @@
package eu.kanade.tachiyomi.lib.vidhideextractor
import eu.kanade.tachiyomi.animesource.model.Track
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.lib.playlistutils.PlaylistUtils
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.util.asJsoup
import kotlinx.serialization.Serializable
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import okhttp3.Headers
import okhttp3.OkHttpClient
class VidHideExtractor(private val client: OkHttpClient, private val headers: Headers) {
private val playlistUtils by lazy { PlaylistUtils(client, headers) }
val json = Json {
isLenient = true
ignoreUnknownKeys = true
}
fun videosFromUrl(url: String, videoNameGen: (String) -> String = { quality -> "VidHide - $quality" }): List<Video> {
val doc = client.newCall(GET(url, headers)).execute()
.asJsoup()
val scriptBody = doc.selectFirst("script:containsData(m3u8)")
?.data()
?: return emptyList()
val masterUrl = scriptBody
.substringAfter("source", "")
.substringAfter("file:\"", "")
.substringBefore("\"", "")
.takeIf(String::isNotBlank)
?: return emptyList()
val subtitleList = try {
val subtitleStr = scriptBody
.substringAfter("tracks")
.substringAfter("[")
.substringBefore("]")
val parsed = json.decodeFromString<List<TrackDto>>("[$subtitleStr]")
parsed.filter { it.kind.equals("captions", true) }
.map { Track(it.file, it.label!!) }
} catch (e: SerializationException) {
emptyList()
}
return playlistUtils.extractFromHls(
masterUrl,
url,
videoNameGen = videoNameGen,
subtitleList = subtitleList,
)
}
@Serializable
class TrackDto(
val file: String,
val kind: String,
val label: String? = null,
)
}

View file

@ -0,0 +1,10 @@
plugins {
id("lib-android")
}
dependencies {
implementation(project(":lib:playlist-utils"))
implementation("dev.datlag.jsunpacker:jsunpacker:1.0.1") {
exclude(group = "org.jetbrains.kotlin", module = "kotlin-stdlib-jdk8")
}
}

View file

@ -0,0 +1,22 @@
package eu.kanade.tachiyomi.lib.vidoextractor
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.lib.playlistutils.PlaylistUtils
import eu.kanade.tachiyomi.network.GET
import okhttp3.OkHttpClient
class VidoExtractor(private val client: OkHttpClient) {
companion object {
private const val VIDO_URL = "https://pink.vido.lol"
private val REGEX_ID = Regex("master\\|(.*?)\\|")
}
private val playlistUtils by lazy { PlaylistUtils(client) }
fun videosFromUrl(url: String, prefix: String = ""): List<Video> {
val document = client.newCall(GET(url)).execute().body.string()
val id = REGEX_ID.find(document)?.groupValues?.get(1)
val masterUrl = "$VIDO_URL/hls/$id/master.m3u8"
return playlistUtils.extractFromHls(masterUrl, videoNameGen = { "${prefix}Vido - ($it)" })
}
}

View file

@ -0,0 +1,7 @@
plugins {
id("lib-android")
}
dependencies {
implementation(project(":lib:playlist-utils"))
}

View file

@ -0,0 +1,174 @@
package eu.kanade.tachiyomi.lib.vidsrcextractor
import android.util.Base64
import app.cash.quickjs.QuickJs
import eu.kanade.tachiyomi.animesource.model.Track
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.lib.playlistutils.PlaylistUtils
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.util.parseAs
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import okhttp3.CacheControl
import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import java.net.URLDecoder
import javax.crypto.Cipher
import javax.crypto.spec.SecretKeySpec
@OptIn(ExperimentalSerializationApi::class)
class VidsrcExtractor(private val client: OkHttpClient, private val headers: Headers) {
private val playlistUtils by lazy { PlaylistUtils(client, headers) }
private val cacheControl = CacheControl.Builder().noStore().build()
private val noCacheClient = client.newBuilder()
.cache(null)
.build()
private val keys by lazy {
noCacheClient.newCall(
GET("https://raw.githubusercontent.com/KillerDogeEmpire/vidplay-keys/keys/keys.json", cache = cacheControl),
).execute().parseAs<List<String>>()
}
fun videosFromUrl(embedLink: String, hosterName: String, type: String = "", subtitleList: List<Track> = emptyList()): List<Video> {
val host = embedLink.toHttpUrl().host
val apiUrl = getApiUrl(embedLink, keys)
val apiHeaders = headers.newBuilder().apply {
add("Accept", "application/json, text/javascript, */*; q=0.01")
add("Host", host)
add("Referer", URLDecoder.decode(embedLink, "UTF-8"))
add("X-Requested-With", "XMLHttpRequest")
}.build()
val response = client.newCall(
GET(apiUrl, apiHeaders),
).execute()
val data = runCatching {
response.parseAs<MediaResponseBody>()
}.getOrElse { // Keys are out of date
val newKeys = noCacheClient.newCall(
GET("https://raw.githubusercontent.com/KillerDogeEmpire/vidplay-keys/keys/keys.json", cache = cacheControl),
).execute().parseAs<List<String>>()
val newApiUrL = getApiUrl(embedLink, newKeys)
client.newCall(
GET(newApiUrL, apiHeaders),
).execute().parseAs()
}
return playlistUtils.extractFromHls(
data.result.sources.first().file,
referer = "https://$host/",
videoNameGen = { q -> hosterName + (if (type.isBlank()) "" else " - $type") + " - $q" },
subtitleList = subtitleList + data.result.tracks.toTracks(),
)
}
private fun getApiUrl(embedLink: String, keyList: List<String>): String {
val host = embedLink.toHttpUrl().host
val params = embedLink.toHttpUrl().let { url ->
url.queryParameterNames.map {
Pair(it, url.queryParameter(it) ?: "")
}
}
val vidId = embedLink.substringAfterLast("/").substringBefore("?")
val encodedID = encodeID(vidId, keyList)
val apiSlug = callFromFuToken(host, encodedID, embedLink)
return buildString {
append("https://")
append(host)
append("/")
append(apiSlug)
if (params.isNotEmpty()) {
append("?")
append(
params.joinToString("&") {
"${it.first}=${it.second}"
},
)
}
}
}
private fun encodeID(videoID: String, keyList: List<String>): String {
val rc4Key1 = SecretKeySpec(keyList[0].toByteArray(), "RC4")
val rc4Key2 = SecretKeySpec(keyList[1].toByteArray(), "RC4")
val cipher1 = Cipher.getInstance("RC4")
val cipher2 = Cipher.getInstance("RC4")
cipher1.init(Cipher.DECRYPT_MODE, rc4Key1, cipher1.parameters)
cipher2.init(Cipher.DECRYPT_MODE, rc4Key2, cipher2.parameters)
var encoded = videoID.toByteArray()
encoded = cipher1.doFinal(encoded)
encoded = cipher2.doFinal(encoded)
encoded = Base64.encode(encoded, Base64.DEFAULT)
return encoded.toString(Charsets.UTF_8).replace("/", "_").trim()
}
private fun callFromFuToken(host: String, data: String, embedLink: String): String {
val refererHeaders = headers.newBuilder().apply {
add("Referer", embedLink)
}.build()
val fuTokenScript = client.newCall(
GET("https://$host/futoken", headers = refererHeaders),
).execute().body.string()
val js = buildString {
append("(function")
append(
fuTokenScript.substringAfter("window")
.substringAfter("function")
.replace("jQuery.ajax(", "")
.substringBefore("+location.search"),
)
append("}(\"$data\"))")
}
return QuickJs.create().use {
it.evaluate(js)?.toString()!!
}
}
private fun List<MediaResponseBody.Result.SubTrack>.toTracks(): List<Track> {
return filter {
it.kind == "captions"
}.mapNotNull {
runCatching {
Track(
it.file,
it.label,
)
}.getOrNull()
}
}
}
@Serializable
data class MediaResponseBody(
val status: Int,
val result: Result,
) {
@Serializable
data class Result(
val sources: ArrayList<Source>,
val tracks: ArrayList<SubTrack> = ArrayList(),
) {
@Serializable
data class Source(
val file: String,
)
@Serializable
data class SubTrack(
val file: String,
val label: String = "",
val kind: String,
)
}
}

View file

@ -0,0 +1,3 @@
plugins {
id("lib-android")
}

View file

@ -0,0 +1,38 @@
package eu.kanade.tachiyomi.lib.vkextractor
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.network.GET
import okhttp3.Headers
import okhttp3.OkHttpClient
class VkExtractor(private val client: OkHttpClient, private val headers: Headers) {
private val documentHeaders by lazy {
headers.newBuilder()
.add("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8")
.build()
}
private val videoHeaders by lazy {
headers.newBuilder()
.add("Accept", "*/*")
.add("Origin", VK_URL)
.add("Referer", "$VK_URL/")
.build()
}
fun videosFromUrl(url: String, prefix: String = ""): List<Video> {
val data = client.newCall(GET(url, documentHeaders)).execute()
.body.string()
return REGEX_VIDEO.findAll(data).map {
val quality = it.groupValues[1]
val videoUrl = it.groupValues[2].replace("\\/", "/")
Video(videoUrl, "${prefix}vk.com - ${quality}p", videoUrl, videoHeaders)
}.toList()
}
companion object {
private const val VK_URL = "https://vk.com"
private val REGEX_VIDEO = """"url(\d+)":"(.*?)"""".toRegex()
}
}

View file

@ -0,0 +1,7 @@
plugins {
id("lib-android")
}
dependencies {
implementation(project(":lib:playlist-utils"))
}

View file

@ -0,0 +1,49 @@
package eu.kanade.tachiyomi.lib.voeextractor
import android.util.Base64
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.lib.playlistutils.PlaylistUtils
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.util.asJsoup
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import uy.kohesive.injekt.injectLazy
class VoeExtractor(private val client: OkHttpClient) {
private val json: Json by injectLazy()
private val playlistUtils by lazy { PlaylistUtils(client) }
private val linkRegex = "(http|https)://([\\w_-]+(?:\\.[\\w_-]+)+)([\\w.,@?^=%&:/~+#-]*[\\w@?^=%&/~+#-])".toRegex()
private val base64Regex = Regex("'.*'")
@Serializable
data class VideoLinkDTO(val file: String)
fun videosFromUrl(url: String, prefix: String = ""): List<Video> {
val document = client.newCall(GET(url)).execute().asJsoup()
val script = document.selectFirst("script:containsData(const sources), script:containsData(var sources), script:containsData(wc0)")
?.data()
?: return emptyList()
val playlistUrl = when {
// Layout 1
script.contains("sources") -> {
val link = script.substringAfter("hls': '").substringBefore("'")
if (linkRegex.matches(link)) link else String(Base64.decode(link, Base64.DEFAULT))
}
// Layout 2
script.contains("wc0") -> {
val base64 = base64Regex.find(script)!!.value
val decoded = Base64.decode(base64, Base64.DEFAULT).let(::String)
json.decodeFromString<VideoLinkDTO>(decoded).file
}
else -> return emptyList()
}
return playlistUtils.extractFromHls(playlistUrl,
videoNameGen = { quality -> "${prefix}Voe: $quality" }
)
}
}

View file

@ -0,0 +1,3 @@
plugins {
id("lib-android")
}

View file

@ -0,0 +1,30 @@
package eu.kanade.tachiyomi.lib.vudeoextractor
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.util.asJsoup
import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
class VudeoExtractor(private val client: OkHttpClient) {
fun videosFromUrl(url: String, prefix: String = ""): List<Video> {
val doc = client.newCall(GET(url)).execute()
.asJsoup()
val sources = doc.selectFirst("script:containsData(sources: [)")?.data()
?: return emptyList()
val referer = "https://" + url.toHttpUrl().host + "/"
val headers = Headers.headersOf("referer", referer)
return sources.substringAfter("sources: [").substringBefore("]")
.replace("\"", "")
.split(',')
.filter { it.startsWith("https") } // remove invalid links
.map { videoUrl ->
Video(videoUrl, "${prefix}Vudeo", videoUrl, headers)
}
}
}

View file

@ -0,0 +1,3 @@
plugins {
id("lib-android")
}

View file

@ -0,0 +1,25 @@
package eu.kanade.tachiyomi.lib.youruploadextractor
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.util.asJsoup
import okhttp3.Headers
import okhttp3.OkHttpClient
class YourUploadExtractor(private val client: OkHttpClient) {
fun videoFromUrl(url: String, headers: Headers, name: String = "YourUpload", prefix: String = ""): List<Video> {
val newHeaders = headers.newBuilder().add("referer", "https://www.yourupload.com/").build()
return runCatching {
val request = client.newCall(GET(url, headers = newHeaders)).execute()
val document = request.asJsoup()
val baseData = document.selectFirst("script:containsData(jwplayerOptions)")?.data()
if (!baseData.isNullOrEmpty()) {
val basicUrl = baseData.substringAfter("file: '").substringBefore("',")
val quality = prefix + name
listOf(Video(basicUrl, quality, basicUrl, headers = newHeaders))
} else {
null
}
}.getOrNull() ?: emptyList<Video>()
}
}