forked from AlmightyHak/extensions-source
Initial commit
This commit is contained in:
commit
98ed7e8839
2263 changed files with 108711 additions and 0 deletions
3
lib/blogger-extractor/build.gradle.kts
Normal file
3
lib/blogger-extractor/build.gradle.kts
Normal file
|
@ -0,0 +1,3 @@
|
|||
plugins {
|
||||
id("lib-android")
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
3
lib/burstcloud-extractor/build.gradle.kts
Normal file
3
lib/burstcloud-extractor/build.gradle.kts
Normal file
|
@ -0,0 +1,3 @@
|
|||
plugins {
|
||||
id("lib-android")
|
||||
}
|
|
@ -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"
|
|
@ -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)
|
3
lib/cda-extractor/build.gradle.kts
Normal file
3
lib/cda-extractor/build.gradle.kts
Normal file
|
@ -0,0 +1,3 @@
|
|||
plugins {
|
||||
id("lib-android")
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
8
lib/chillx-extractor/build.gradle.kts
Normal file
8
lib/chillx-extractor/build.gradle.kts
Normal file
|
@ -0,0 +1,8 @@
|
|||
plugins {
|
||||
id("lib-android")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":lib:cryptoaes"))
|
||||
implementation(project(":lib:playlist-utils"))
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
}
|
3
lib/cloudflare-interceptor/build.gradle.kts
Normal file
3
lib/cloudflare-interceptor/build.gradle.kts
Normal file
|
@ -0,0 +1,3 @@
|
|||
plugins {
|
||||
id("lib-android")
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
3
lib/cryptoaes/build.gradle.kts
Normal file
3
lib/cryptoaes/build.gradle.kts
Normal file
|
@ -0,0 +1,3 @@
|
|||
plugins {
|
||||
id("lib-android")
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
7
lib/dailymotion-extractor/build.gradle.kts
Normal file
7
lib/dailymotion-extractor/build.gradle.kts
Normal file
|
@ -0,0 +1,7 @@
|
|||
plugins {
|
||||
id("lib-android")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":lib:playlist-utils"))
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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" },
|
||||
)
|
||||
}
|
||||
}
|
3
lib/dataimage/build.gradle.kts
Normal file
3
lib/dataimage/build.gradle.kts
Normal file
|
@ -0,0 +1,3 @@
|
|||
plugins {
|
||||
id("lib-android")
|
||||
}
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
3
lib/dood-extractor/build.gradle.kts
Normal file
3
lib/dood-extractor/build.gradle.kts
Normal file
|
@ -0,0 +1,3 @@
|
|||
plugins {
|
||||
id("lib-android")
|
||||
}
|
|
@ -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()
|
||||
}
|
10
lib/fastream-extractor/build.gradle.kts
Normal file
10
lib/fastream-extractor/build.gradle.kts
Normal 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"))
|
||||
}
|
|
@ -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"
|
10
lib/filemoon-extractor/build.gradle.kts
Normal file
10
lib/filemoon-extractor/build.gradle.kts
Normal 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"))
|
||||
}
|
|
@ -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)
|
||||
}
|
7
lib/fusevideo-extractor/build.gradle.kts
Normal file
7
lib/fusevideo-extractor/build.gradle.kts
Normal file
|
@ -0,0 +1,7 @@
|
|||
plugins {
|
||||
id("lib-android")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":lib:playlist-utils"))
|
||||
}
|
|
@ -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())
|
||||
}
|
||||
}
|
8
lib/gdriveplayer-extractor/build.gradle.kts
Normal file
8
lib/gdriveplayer-extractor/build.gradle.kts
Normal file
|
@ -0,0 +1,8 @@
|
|||
plugins {
|
||||
id("lib-android")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":lib:cryptoaes"))
|
||||
implementation(project(":lib:unpacker"))
|
||||
}
|
|
@ -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+)")
|
||||
}
|
||||
}
|
7
lib/gogostream-extractor/build.gradle.kts
Normal file
7
lib/gogostream-extractor/build.gradle.kts
Normal file
|
@ -0,0 +1,7 @@
|
|||
plugins {
|
||||
id("lib-android")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":lib:playlist-utils"))
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
3
lib/googledrive-episodes/build.gradle.kts
Normal file
3
lib/googledrive-episodes/build.gradle.kts
Normal file
|
@ -0,0 +1,3 @@
|
|||
plugins {
|
||||
id("lib-android")
|
||||
}
|
|
@ -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====="
|
||||
}
|
||||
}
|
3
lib/googledrive-extractor/build.gradle.kts
Normal file
3
lib/googledrive-extractor/build.gradle.kts
Normal file
|
@ -0,0 +1,3 @@
|
|||
plugins {
|
||||
id("lib-android")
|
||||
}
|
|
@ -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}" }
|
||||
}
|
||||
}
|
3
lib/javcoverfetcher/build.gradle.kts
Normal file
3
lib/javcoverfetcher/build.gradle.kts
Normal file
|
@ -0,0 +1,3 @@
|
|||
plugins {
|
||||
id("lib-android")
|
||||
}
|
|
@ -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)
|
||||
}
|
8
lib/megacloud-extractor/build.gradle.kts
Normal file
8
lib/megacloud-extractor/build.gradle.kts
Normal file
|
@ -0,0 +1,8 @@
|
|||
plugins {
|
||||
id("lib-android")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":lib:cryptoaes"))
|
||||
implementation(project(":lib:playlist-utils"))
|
||||
}
|
|
@ -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 = "")
|
||||
}
|
7
lib/mixdrop-extractor/build.gradle.kts
Normal file
7
lib/mixdrop-extractor/build.gradle.kts
Normal file
|
@ -0,0 +1,7 @@
|
|||
plugins {
|
||||
id("lib-android")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":lib:unpacker"))
|
||||
}
|
|
@ -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/"
|
9
lib/mp4upload-extractor/build.gradle.kts
Normal file
9
lib/mp4upload-extractor/build.gradle.kts
Normal 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")
|
||||
}
|
||||
}
|
|
@ -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/"
|
||||
}
|
||||
}
|
7
lib/okru-extractor/build.gradle.kts
Normal file
7
lib/okru-extractor/build.gradle.kts
Normal file
|
@ -0,0 +1,7 @@
|
|||
plugins {
|
||||
id("lib-android")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":lib:playlist-utils"))
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
3
lib/playlist-utils/build.gradle.kts
Normal file
3
lib/playlist-utils/build.gradle.kts
Normal file
|
@ -0,0 +1,3 @@
|
|||
plugins {
|
||||
id("lib-android")
|
||||
}
|
|
@ -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="(.*?)"""") }
|
||||
}
|
||||
}
|
7
lib/sendvid-extractor/build.gradle.kts
Normal file
7
lib/sendvid-extractor/build.gradle.kts
Normal file
|
@ -0,0 +1,7 @@
|
|||
plugins {
|
||||
id("lib-android")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":lib:playlist-utils"))
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
3
lib/sibnet-extractor/build.gradle.kts
Normal file
3
lib/sibnet-extractor/build.gradle.kts
Normal file
|
@ -0,0 +1,3 @@
|
|||
plugins {
|
||||
id("lib-android")
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
3
lib/streamdav-extractor/build.gradle.kts
Normal file
3
lib/streamdav-extractor/build.gradle.kts
Normal file
|
@ -0,0 +1,3 @@
|
|||
plugins {
|
||||
id("lib-android")
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
10
lib/streamhidevid-extractor/build.gradle.kts
Normal file
10
lib/streamhidevid-extractor/build.gradle.kts
Normal 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")
|
||||
}
|
||||
}
|
|
@ -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" }
|
||||
)
|
||||
}
|
||||
}
|
7
lib/streamhub-extractor/build.gradle.kts
Normal file
7
lib/streamhub-extractor/build.gradle.kts
Normal file
|
@ -0,0 +1,7 @@
|
|||
plugins {
|
||||
id("lib-android")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":lib:playlist-utils"))
|
||||
}
|
|
@ -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\\|(.*?)\\|")
|
||||
}
|
||||
}
|
3
lib/streamlare-extractor/build.gradle.kts
Normal file
3
lib/streamlare-extractor/build.gradle.kts
Normal file
|
@ -0,0 +1,3 @@
|
|||
plugins {
|
||||
id("lib-android")
|
||||
}
|
|
@ -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")
|
||||
}
|
||||
}
|
3
lib/streamtape-extractor/build.gradle.kts
Normal file
3
lib/streamtape-extractor/build.gradle.kts
Normal file
|
@ -0,0 +1,3 @@
|
|||
plugins {
|
||||
id("lib-android")
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
10
lib/streamvid-extractor/build.gradle.kts
Normal file
10
lib/streamvid-extractor/build.gradle.kts
Normal 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")
|
||||
}
|
||||
}
|
|
@ -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() }
|
||||
}
|
||||
}
|
10
lib/streamwish-extractor/build.gradle.kts
Normal file
10
lib/streamwish-extractor/build.gradle.kts
Normal 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"))
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
3
lib/synchrony/build.gradle.kts
Normal file
3
lib/synchrony/build.gradle.kts
Normal file
|
@ -0,0 +1,3 @@
|
|||
plugins {
|
||||
id("lib-android")
|
||||
}
|
117
lib/synchrony/src/main/assets/synchrony-v2.4.5.1.js
Normal file
117
lib/synchrony/src/main/assets/synchrony-v2.4.5.1.js
Normal file
File diff suppressed because one or more lines are too long
|
@ -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"
|
8
lib/unpacker/build.gradle.kts
Normal file
8
lib/unpacker/build.gradle.kts
Normal file
|
@ -0,0 +1,8 @@
|
|||
plugins {
|
||||
id("lib-kotlin")
|
||||
}
|
||||
|
||||
java {
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
10
lib/upstream-extractor/build.gradle.kts
Normal file
10
lib/upstream-extractor/build.gradle.kts
Normal 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")
|
||||
}
|
||||
}
|
|
@ -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())
|
||||
}
|
3
lib/uqload-extractor/build.gradle.kts
Normal file
3
lib/uqload-extractor/build.gradle.kts
Normal file
|
@ -0,0 +1,3 @@
|
|||
plugins {
|
||||
id("lib-android")
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
}
|
3
lib/vidbom-extractor/build.gradle.kts
Normal file
3
lib/vidbom-extractor/build.gradle.kts
Normal file
|
@ -0,0 +1,3 @@
|
|||
plugins {
|
||||
id("lib-android")
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
7
lib/vidhide-extractor/build.gradle.kts
Normal file
7
lib/vidhide-extractor/build.gradle.kts
Normal file
|
@ -0,0 +1,7 @@
|
|||
plugins {
|
||||
id("lib-android")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":lib:playlist-utils"))
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
}
|
10
lib/vido-extractor/build.gradle.kts
Normal file
10
lib/vido-extractor/build.gradle.kts
Normal 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")
|
||||
}
|
||||
}
|
|
@ -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)" })
|
||||
}
|
||||
}
|
7
lib/vidsrc-extractor/build.gradle.kts
Normal file
7
lib/vidsrc-extractor/build.gradle.kts
Normal file
|
@ -0,0 +1,7 @@
|
|||
plugins {
|
||||
id("lib-android")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":lib:playlist-utils"))
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
3
lib/vk-extractor/build.gradle.kts
Normal file
3
lib/vk-extractor/build.gradle.kts
Normal file
|
@ -0,0 +1,3 @@
|
|||
plugins {
|
||||
id("lib-android")
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
7
lib/voe-extractor/build.gradle.kts
Normal file
7
lib/voe-extractor/build.gradle.kts
Normal file
|
@ -0,0 +1,7 @@
|
|||
plugins {
|
||||
id("lib-android")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":lib:playlist-utils"))
|
||||
}
|
|
@ -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" }
|
||||
)
|
||||
}
|
||||
}
|
3
lib/vudeo-extractor/build.gradle.kts
Normal file
3
lib/vudeo-extractor/build.gradle.kts
Normal file
|
@ -0,0 +1,3 @@
|
|||
plugins {
|
||||
id("lib-android")
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
3
lib/yourupload-extractor/build.gradle.kts
Normal file
3
lib/yourupload-extractor/build.gradle.kts
Normal file
|
@ -0,0 +1,3 @@
|
|||
plugins {
|
||||
id("lib-android")
|
||||
}
|
|
@ -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>()
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue