fix(en/uhdmovies): Fix episode list & video extractor (#2604)

This commit is contained in:
Claudemirovsky 2023-12-04 05:56:23 -03:00 committed by GitHub
parent bd04dd87ce
commit aef6ac42d7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 331 additions and 433 deletions

View File

@ -1,12 +1,14 @@
apply plugin: 'com.android.application' plugins {
apply plugin: 'kotlin-android' alias(libs.plugins.android.application)
apply plugin: 'kotlinx-serialization' alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.serialization)
}
ext { ext {
extName = 'UHD Movies' extName = 'UHD Movies'
pkgNameSuffix = 'en.uhdmovies' pkgNameSuffix = 'en.uhdmovies'
extClass = '.UHDMovies' extClass = '.UHDMovies'
extVersionCode = 17 extVersionCode = 18
libVersion = '13' libVersion = '13'
} }

View File

@ -0,0 +1,65 @@
package eu.kanade.tachiyomi.animeextension.en.uhdmovies
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.util.asJsoup
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.Cookie
import okhttp3.FormBody
import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import org.jsoup.nodes.Document
class RedirectorBypasser(private val client: OkHttpClient, private val headers: Headers) {
fun bypass(url: String): String? {
val lastDoc = client.newCall(GET(url, headers)).execute()
.use { recursiveDoc(it.asJsoup()) }
val script = lastDoc.selectFirst("script:containsData(/?go=):containsData(href)")
?.data()
?: return null
val nextUrl = script.substringAfter("\"href\",\"").substringBefore('"')
val httpUrl = nextUrl.toHttpUrlOrNull() ?: return null
val cookieName = httpUrl.queryParameter("go") ?: return null
val cookieValue = script.substringAfter("'$cookieName', '").substringBefore("'")
val cookie = Cookie.parse(httpUrl, "$cookieName=$cookieValue")!!
val headers = headers.newBuilder().set("referer", lastDoc.location()).build()
val doc = runBlocking(Dispatchers.IO) {
MUTEX.withLock { // Mutex to prevent overwriting cookies from parallel requests
client.cookieJar.saveFromResponse(httpUrl, listOf(cookie))
client.newCall(GET(nextUrl, headers)).execute().use { it.asJsoup() }
}
}
return doc.selectFirst("meta[http-equiv]")?.attr("content")
?.substringAfter("url=")
}
private fun recursiveDoc(doc: Document): Document {
val form = doc.selectFirst("form#landing") ?: return doc
val url = form.attr("action")
val body = FormBody.Builder().apply {
form.select("input").forEach {
add(it.attr("name"), it.attr("value"))
}
}.build()
val headers = headers.newBuilder()
.set("referer", doc.location())
.build()
return client.newCall(POST(url, headers, body)).execute().use {
recursiveDoc(it.asJsoup())
}
}
companion object {
private val MUTEX by lazy { Mutex() }
}
}

View File

@ -1,87 +0,0 @@
package eu.kanade.tachiyomi.animeextension.en.uhdmovies
import android.annotation.SuppressLint
import android.app.Application
import android.os.Handler
import android.os.Looper
import android.webkit.JavascriptInterface
import android.webkit.WebView
import android.webkit.WebViewClient
import eu.kanade.tachiyomi.network.GET
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.util.concurrent.CountDownLatch
class TokenInterceptor : Interceptor {
private val context = Injekt.get<Application>()
private val handler by lazy { Handler(Looper.getMainLooper()) }
class JsObject(private val latch: CountDownLatch, var payload: String = "") {
@JavascriptInterface
fun passPayload(passedPayload: String) {
payload = passedPayload
latch.countDown()
}
}
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
val newRequest = resolveWithWebView(originalRequest) ?: originalRequest
return chain.proceed(newRequest)
}
@SuppressLint("SetJavaScriptEnabled")
private fun resolveWithWebView(request: Request): Request? {
val latch = CountDownLatch(1)
var webView: WebView? = null
val origRequestUrl = request.url.toString()
val jsinterface = JsObject(latch)
// Get url with token with promise
val jsScript = """
(async () => {
var data = await generate("direct");
window.android.passPayload(data.url);
})();""".trim()
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 = false
loadWithOverviewMode = false
userAgentString = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/109.0"
webview.addJavascriptInterface(jsinterface, "android")
webview.webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
view?.evaluateJavascript(jsScript) {}
}
}
webView?.loadUrl(origRequestUrl, headers)
}
}
latch.await()
handler.post {
webView?.stopLoading()
webView?.destroy()
webView = null
}
return if (jsinterface.payload.isNotBlank()) GET(jsinterface.payload) else null
}
}

View File

@ -1,7 +1,6 @@
package eu.kanade.tachiyomi.animeextension.en.uhdmovies package eu.kanade.tachiyomi.animeextension.en.uhdmovies
import android.app.Application import android.app.Application
import android.content.SharedPreferences
import android.util.Base64 import android.util.Base64
import androidx.preference.EditTextPreference import androidx.preference.EditTextPreference
import androidx.preference.ListPreference import androidx.preference.ListPreference
@ -20,16 +19,13 @@ import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import okhttp3.FormBody import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Headers import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import org.jsoup.Jsoup
import org.jsoup.nodes.Document import org.jsoup.nodes.Document
import org.jsoup.nodes.Element import org.jsoup.nodes.Element
import rx.Observable import rx.Observable
@ -37,186 +33,134 @@ import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
@ExperimentalSerializationApi
class UHDMovies : ConfigurableAnimeSource, ParsedAnimeHttpSource() { class UHDMovies : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
override val name = "UHD Movies" override val name = "UHD Movies"
override val baseUrl by lazy { preferences.getString(PREF_DOMAIN_KEY, PREF_DEFAULT_DOMAIN)!! } override val baseUrl by lazy {
val url = preferences.getString(PREF_DOMAIN_KEY, PREF_DOMAIN_DEFAULT)!!
override val lang = "en"
override val supportsLatest = false
override val client: OkHttpClient = network.cloudflareClient
private val json: Json by injectLazy()
private val preferences: SharedPreferences by lazy {
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
}
private val currentBaseUrl by lazy {
runBlocking { runBlocking {
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
client.newBuilder() client.newBuilder()
.followRedirects(false) .followRedirects(false)
.build() .build()
.newCall(GET("$baseUrl/")).execute().let { resp -> .newCall(GET("$url/")).execute().use { resp ->
when (resp.code) { when (resp.code) {
301 -> { 301 -> {
(resp.headers["location"]?.substringBeforeLast("/") ?: baseUrl).also { (resp.headers["location"]?.substringBeforeLast("/") ?: url).also {
preferences.edit().putString(PREF_DOMAIN_KEY, it).apply() preferences.edit().putString(PREF_DOMAIN_KEY, it).apply()
} }
} }
else -> baseUrl else -> url
} }
} }
} }
} }
} }
override val lang = "en"
override val supportsLatest = false
override val client = network.cloudflareClient
private val json: Json by injectLazy()
private val preferences by lazy {
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
}
// ============================== Popular =============================== // ============================== Popular ===============================
override fun popularAnimeRequest(page: Int): Request = GET("$baseUrl/page/$page/")
override fun popularAnimeRequest(page: Int): Request = GET("$currentBaseUrl/page/$page/")
override fun popularAnimeSelector(): String = "div#content div.gridlove-posts > div.layout-masonry" override fun popularAnimeSelector(): String = "div#content div.gridlove-posts > div.layout-masonry"
override fun popularAnimeNextPageSelector(): String = override fun popularAnimeFromElement(element: Element) = SAnime.create().apply {
"div#content > nav.gridlove-pagination > a.next"
override fun popularAnimeFromElement(element: Element): SAnime {
return SAnime.create().apply {
setUrlWithoutDomain(element.select("div.entry-image > a").attr("abs:href")) setUrlWithoutDomain(element.select("div.entry-image > a").attr("abs:href"))
thumbnail_url = element.select("div.entry-image > a > img").attr("abs:src") thumbnail_url = element.select("div.entry-image > a > img").attr("abs:src")
title = element.select("div.entry-image > a").attr("title") title = element.select("div.entry-image > a").attr("title")
.replace("Download", "").trim() .replace("Download", "").trim()
} }
}
override fun popularAnimeNextPageSelector(): String =
"div#content > nav.gridlove-pagination > a.next"
// =============================== Latest =============================== // =============================== Latest ===============================
override fun latestUpdatesRequest(page: Int): Request = throw Exception("Not Used") override fun latestUpdatesRequest(page: Int): Request = throw Exception("Not Used")
override fun latestUpdatesSelector(): String = throw Exception("Not Used") override fun latestUpdatesSelector(): String = throw Exception("Not Used")
override fun latestUpdatesNextPageSelector(): String = throw Exception("Not Used")
override fun latestUpdatesFromElement(element: Element): SAnime = throw Exception("Not Used") override fun latestUpdatesFromElement(element: Element): SAnime = throw Exception("Not Used")
// =============================== Search =============================== override fun latestUpdatesNextPageSelector(): String = throw Exception("Not Used")
// =============================== Search ===============================
override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request { override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request {
val cleanQuery = query.replace(" ", "+").lowercase() val cleanQuery = query.replace(" ", "+").lowercase()
return GET("$currentBaseUrl/page/$page/?s=$cleanQuery") return GET("$baseUrl/page/$page/?s=$cleanQuery")
} }
override fun searchAnimeSelector(): String = popularAnimeSelector() override fun searchAnimeSelector(): String = popularAnimeSelector()
override fun searchAnimeNextPageSelector(): String = popularAnimeNextPageSelector()
override fun searchAnimeFromElement(element: Element): SAnime = popularAnimeFromElement(element) override fun searchAnimeFromElement(element: Element): SAnime = popularAnimeFromElement(element)
// =========================== Anime Details ============================ override fun searchAnimeNextPageSelector(): String = popularAnimeNextPageSelector()
override fun animeDetailsParse(document: Document): SAnime { // =========================== Anime Details ============================
return SAnime.create().apply { override fun animeDetailsParse(document: Document) = SAnime.create().apply {
initialized = true initialized = true
title = document.selectFirst(".entry-title")?.text() title = document.selectFirst(".entry-title")?.text()
?.replace("Download", "", true)?.trim() ?: "Movie" ?.replace("Download", "", true)?.trim() ?: "Movie"
status = SAnime.COMPLETED status = SAnime.COMPLETED
description = document.selectFirst("pre:contains(plot)")?.text() description = document.selectFirst("pre:contains(plot)")?.text()
} }
}
// ============================== Episodes ============================== // ============================== Episodes ==============================
private fun Regex.firstValue(text: String) =
find(text)?.groupValues?.get(1)?.let { Pair(text, it) }
override fun episodeListParse(response: Response): List<SEpisode> {
val doc = response.use { it.asJsoup() }
val episodeElements = doc.select(episodeListSelector())
.asSequence()
override fun fetchEpisodeList(anime: SAnime): Observable<List<SEpisode>> {
val resp = client.newCall(GET(currentBaseUrl + anime.url)).execute().asJsoup()
val episodeList = mutableListOf<SEpisode>()
val episodeElements = resp.select("p:has(a[href*=?id=],a[href*=r?key=]):has(a[class*=maxbutton])[style*=center]")
val qualityRegex = "\\d{3,4}p".toRegex(RegexOption.IGNORE_CASE) val qualityRegex = "\\d{3,4}p".toRegex(RegexOption.IGNORE_CASE)
val firstText = episodeElements.first()?.text() ?: ""
if (firstText.contains("Episode", true) ||
firstText.contains("Zip", true) ||
firstText.contains("Pack", true)
) {
episodeElements.map { row ->
val prevP = row.previousElementSibling()!!
val seasonRegex = "[ .]?S(?:eason)?[ .]?(\\d{1,2})[ .]?".toRegex(RegexOption.IGNORE_CASE) val seasonRegex = "[ .]?S(?:eason)?[ .]?(\\d{1,2})[ .]?".toRegex(RegexOption.IGNORE_CASE)
val seasonTitleRegex = "[ .\\[(]?S(?:eason)?[ .]?(\\d{1,2})[ .\\])]?".toRegex(RegexOption.IGNORE_CASE)
val partRegex = "Part ?(\\d{1,2})".toRegex(RegexOption.IGNORE_CASE) val partRegex = "Part ?(\\d{1,2})".toRegex(RegexOption.IGNORE_CASE)
val result = seasonRegex.find(prevP.text())
var part = ""
val season = (
result?.groups?.get(1)?.value?.also {
part = partRegex.find(prevP.text())?.groups?.get(1)?.value ?: ""
} ?: let {
val prevPre = row.previousElementSiblings().prev("pre,div.mks_separator")
val preResult = seasonRegex.find(prevPre.first()?.text() ?: "")
preResult?.groups?.get(1)?.value?.also {
part = partRegex.find(prevPre.first()?.text() ?: "")?.groups?.get(1)?.value ?: ""
} ?: let {
val title = resp.select("h1.entry-title")
val titleResult = "[ .\\[(]?S(?:eason)?[ .]?(\\d{1,2})[ .\\])]?"
.toRegex(RegexOption.IGNORE_CASE)
.find(title.text())
titleResult?.groups?.get(1)?.value?.also {
part = partRegex.find(title.text())?.groups?.get(1)?.value ?: ""
} ?: "-1"
}
}
).replaceFirst("^0+(?!$)".toRegex(), "")
val qualityMatch = qualityRegex.find(prevP.text()) val isSerie = doc.selectFirst(episodeListSelector())?.text().orEmpty().run {
contains("Episode", true) ||
contains("Zip", true) ||
contains("Pack", true)
}
val episodeList = episodeElements.map { row ->
val prevP = row.previousElementSibling()!!.text()
val qualityMatch = qualityRegex.find(prevP)
val quality = qualityMatch?.value ?: let { val quality = qualityMatch?.value ?: let {
val qualityMatchOwn = qualityRegex.find(row.text()) val qualityMatchOwn = qualityRegex.find(row.text())
qualityMatchOwn?.value ?: "HD" qualityMatchOwn?.value ?: "HD"
} }
row.select("a").filter { it -> val defaultName = if (isSerie) {
!it.text().contains("Zip", true) && val (source, seasonNumber) = seasonRegex.firstValue(prevP) ?: run {
!it.text().contains("Pack", true) && val prevPre = row.previousElementSiblings().prev("pre,div.mks_separator").first()
!it.text().contains("Volume ", true) ?.text()
}.mapIndexed { index, linkElement -> .orEmpty()
val episode = linkElement?.text() seasonRegex.firstValue(prevPre)
?.replace("Episode", "", true) } ?: run {
?.trim()?.toIntOrNull() ?: index + 1 val title = doc.selectFirst("h1.entry-title")?.text().orEmpty()
Triple( seasonTitleRegex.firstValue(title)
season + "_$episode" + "_$part", } ?: "" to "1"
linkElement?.attr("href") ?: return@mapIndexed null,
quality, val part = partRegex.find(source)?.groupValues?.get(1)
) ?.let { " Pt $it" }
}.filterNotNull() .orEmpty()
}.flatten().groupBy { it.first }.map { group ->
val (season, episode, part) = group.key.split("_") "Season ${seasonNumber.toIntOrNull() ?: 1 }$part"
val partText = if (part.isBlank()) "" else " Pt $part"
episodeList.add(
SEpisode.create().apply {
url = EpLinks(
urls = group.value.map {
EpUrl(url = it.second, quality = it.third)
},
).toJson()
name = "Season $season$partText Ep $episode"
episode_number = episode.toFloat()
},
)
}
} else { } else {
var collectionIdx = 0F row.previousElementSiblings().let { prevElem ->
episodeElements.asSequence().filter {
!it.text().contains("Zip", true) &&
!it.text().contains("Pack", true) &&
!it.text().contains("Volume ", true)
}.map { row ->
val prevP = row.previousElementSibling()!!
val qualityMatch = qualityRegex.find(prevP.text())
val quality = qualityMatch?.value ?: let {
val qualityMatchOwn = qualityRegex.find(row.text())
qualityMatchOwn?.value ?: "HD"
}
val collectionName = row.previousElementSiblings().let { prevElem ->
(prevElem.prev("h1,h2,h3,pre:not(:contains(plot))").first()?.text() ?: "Movie - $quality") (prevElem.prev("h1,h2,h3,pre:not(:contains(plot))").first()?.text() ?: "Movie - $quality")
.replace("Download", "", true).trim().let { .replace("Download", "", true).trim().let {
if (it.contains("Collection", true)) { if (it.contains("Collection", true)) {
@ -226,60 +170,68 @@ class UHDMovies : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
} }
} }
} }
row.select("a").map { linkElement ->
Triple(linkElement.attr("href"), quality, collectionName)
} }
}.flatten().groupBy { it.third }.map { group ->
collectionIdx++ row.select("a").asSequence()
episodeList.add( .filter { el -> el.classNames().none { it.endsWith("-zip") } }
SEpisode.create().apply { .mapIndexedNotNull { index, linkElement ->
url = EpLinks( val episode = linkElement.text()
urls = group.value.map { .replace("Episode", "", true)
EpUrl(url = it.first, quality = it.second) .trim()
}, .toIntOrNull() ?: index + 1
).toJson()
name = group.key val url = linkElement.attr("href").takeUnless(String::isBlank)
episode_number = collectionIdx ?: return@mapIndexedNotNull null
},
Triple(
Pair(defaultName, episode),
url,
quality,
) )
} }
if (episodeList.isEmpty()) throw Exception("Only Zip Pack Available") }.flatten().groupBy { it.first }.values.mapIndexed { index, items ->
val (itemName, episodeNum) = items.first().first
SEpisode.create().apply {
url = EpLinks(
urls = items.map { triple ->
EpUrl(url = triple.second, quality = triple.third)
},
).toJson()
name = if (isSerie) "$itemName Ep $episodeNum" else itemName
episode_number = if (isSerie) episodeNum.toFloat() else (index + 1).toFloat()
} }
return Observable.just(episodeList.reversed())
} }
override fun episodeListSelector(): String = throw Exception("Not Used") if (episodeList.isEmpty()) throw Exception("Only Zip Pack Available")
return episodeList.reversed()
}
override fun episodeListSelector(): String = "p:has(a[href*=?sid=],a[href*=r?key=]):has(a[class*=maxbutton])[style*=center]"
override fun episodeFromElement(element: Element): SEpisode = throw Exception("Not Used") override fun episodeFromElement(element: Element): SEpisode = throw Exception("Not Used")
// ============================ Video Links ============================= // ============================ Video Links =============================
override fun fetchVideoList(episode: SEpisode): Observable<List<Video>> { override fun fetchVideoList(episode: SEpisode): Observable<List<Video>> {
val urlJson = json.decodeFromString<EpLinks>(episode.url) val urlJson = json.decodeFromString<EpLinks>(episode.url)
val failedMediaUrl = mutableListOf<Pair<String, String>>()
val videoList = mutableListOf<Video>() val videoList = urlJson.urls.parallelCatchingFlatMap { eplink ->
videoList.addAll( val quality = eplink.quality
urlJson.urls.parallelMap { url -> val url = getMediaUrl(eplink) ?: return@parallelCatchingFlatMap emptyList()
runCatching { val videos = extractVideo(url, quality)
val (videos, mediaUrl) = extractVideo(url) when {
if (videos.isEmpty() && mediaUrl.isNotBlank()) failedMediaUrl.add(Pair(mediaUrl, url.quality)) videos.isEmpty() -> {
return@runCatching videos extractGDriveLink(url, quality).ifEmpty {
}.getOrNull() getDirectLink(url, "instant", "/mfile/")?.let {
listOf(Video(it, "$quality - GDrive Instant link", it))
} ?: emptyList()
}
}
else -> videos
}
} }
.filterNotNull()
.flatten(),
)
videoList.addAll(
failedMediaUrl.mapNotNull { (url, quality) ->
runCatching {
extractGDriveLink(url, quality)
}.getOrNull()
}.flatten(),
)
if (videoList.isEmpty()) throw Exception("No working links found")
return Observable.just(videoList.sort()) return Observable.just(videoList.sort())
} }
@ -291,115 +243,38 @@ class UHDMovies : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
override fun videoUrlParse(document: Document): String = throw Exception("Not Used") override fun videoUrlParse(document: Document): String = throw Exception("Not Used")
// ============================= Utilities ============================== // ============================= Utilities ==============================
private val redirectBypasser by lazy { RedirectorBypasser(client, headers) }
private fun extractVideo(epUrl: EpUrl): Pair<List<Video>, String> { private fun getMediaUrl(epUrl: EpUrl): String? {
val noRedirectClient = client.newBuilder().followRedirects(false).build() val url = epUrl.url
val mediaResponse = if (epUrl.url.contains("?id=")) { val mediaResponse = if (url.contains("?sid=")) {
val postLink = epUrl.url.substringBefore("?id=").substringAfter("/?") /* redirector bs */
val initailUrl = epUrl.url.substringAfter("/?http").let { val finalUrl = redirectBypasser.bypass(url) ?: return null
if (it.startsWith("http")) { client.newCall(GET(finalUrl)).execute()
it } else if (url.contains("r?key=")) {
} else { /* everything under control */
"http$it" client.newCall(GET(url)).execute()
} } else { return null }
}
val initialResp = noRedirectClient.newCall(GET(initailUrl)).execute().asJsoup()
val (tokenUrl, tokenCookie) = if (initialResp.selectFirst("form#landing input[name=_wp_http_c]") != null) {
val formData = FormBody.Builder().add("_wp_http_c", epUrl.url.substringAfter("?id=")).build()
val response = client.newCall(POST(postLink, body = formData)).execute().body.string()
val (longC, catC, _) = getCookiesDetail(response)
val cookieHeader = Headers.headersOf("Cookie", "$longC; $catC")
val parsedSoup = Jsoup.parse(response)
val link = parsedSoup.selectFirst("center > a")!!.attr("href")
val response2 = client.newCall(GET(link, cookieHeader)).execute().body.string() val path = mediaResponse.use { it.body.string() }.substringAfter("replace(\"").substringBefore("\"")
val (longC2, _, postC) = getCookiesDetail(response2)
val cookieHeader2 = Headers.headersOf("Cookie", "$catC; $longC2; $postC") if (path == "/404") return null
val parsedSoup2 = Jsoup.parse(response2)
val link2 = parsedSoup2.selectFirst("center > a")!!.attr("href") return "https://" + mediaResponse.request.url.host + path
val tokenResp = client.newCall(GET(link2, cookieHeader2)).execute().body.string()
val goToken = tokenResp.substringAfter("?go=").substringBefore("\"")
val tokenUrl = "$postLink?go=$goToken"
val newLongC = "$goToken=" + longC2.substringAfter("=")
val tokenCookie = Headers.headersOf("Cookie", "$catC; rdst_post=; $newLongC")
Pair(tokenUrl, tokenCookie)
} else {
val secondResp = initialResp.getNextResp().asJsoup()
val thirdResp = secondResp.getNextResp().body.string()
val goToken = thirdResp.substringAfter("?go=").substringBefore("\"")
val tokenUrl = "$postLink?go=$goToken"
val cookie = secondResp.selectFirst("form#landing input[name=_wp_http2]")?.attr("value")
val tokenCookie = Headers.headersOf("Cookie", "$goToken=$cookie")
Pair(tokenUrl, tokenCookie)
} }
val tokenResponse = noRedirectClient.newCall(GET(tokenUrl, tokenCookie)).execute().asJsoup() private fun extractVideo(url: String, quality: String): List<Video> {
val redirectUrl = tokenResponse.select("meta[http-equiv=refresh]").attr("content") return (1..3).toList().flatMap { type ->
.substringAfter("url=").substringBefore("\"") extractWorkerLinks(url, quality, type)
noRedirectClient.newCall(GET(redirectUrl)).execute()
} else if (epUrl.url.contains("r?key=")) {
client.newCall(GET(epUrl.url)).execute()
} else { throw Exception("Something went wrong") }
val path = mediaResponse.body.string().substringAfter("replace(\"").substringBefore("\"")
if (path == "/404") return Pair(emptyList(), "")
val mediaUrl = "https://" + mediaResponse.request.url.host + path
val videoList = mutableListOf<Video>()
for (type in 1..3) {
videoList.addAll(
extractWorkerLinks(mediaUrl, epUrl.quality, type),
)
} }
return Pair(videoList, mediaUrl)
} }
private fun Document.getNextResp(): Response {
val form = this.selectFirst("form#landing") ?: throw Exception("Failed to find form")
val postLink = form.attr("action")
val formData = FormBody.Builder().let { fd ->
form.select("input").map {
fd.add(it.attr("name"), it.attr("value"))
}
fd.build()
}
return client.newCall(POST(postLink, body = formData)).execute()
}
private fun getCookiesDetail(page: String): Triple<String, String, String> {
val cat = "rdst_cat"
val post = "rdst_post"
val longC = page.substringAfter(".setTime")
.substringAfter("document.cookie = \"")
.substringBefore("\"")
.substringBefore(";")
val catC = if (page.contains("$cat=")) {
page.substringAfterLast("$cat=")
.substringBefore(";").let {
"$cat=$it"
}
} else { "" }
val postC = if (page.contains("$post=")) {
page.substringAfterLast("$post=")
.substringBefore(";").let {
"$post=$it"
}
} else { "" }
return Triple(longC, catC, postC)
}
private val sizeRegex = "\\[((?:.(?!\\[))+)] *\$".toRegex(RegexOption.IGNORE_CASE)
private fun extractWorkerLinks(mediaUrl: String, quality: String, type: Int): List<Video> { private fun extractWorkerLinks(mediaUrl: String, quality: String, type: Int): List<Video> {
val reqLink = mediaUrl.replace("/file/", "/wfile/") + "?type=$type" val reqLink = mediaUrl.replace("/file/", "/wfile/") + "?type=$type"
val resp = client.newCall(GET(reqLink)).execute().asJsoup() val resp = client.newCall(GET(reqLink)).execute().use { it.asJsoup() }
val sizeMatch = sizeRegex.find(resp.select("div.card-header").text().trim()) val sizeMatch = SIZE_REGEX.find(resp.select("div.card-header").text().trim())
val size = sizeMatch?.groups?.get(1)?.value?.let { " - $it" } ?: "" val size = sizeMatch?.groups?.get(1)?.value?.let { " - $it" } ?: ""
return try { return resp.select("div.card-body div.mb-4 > a").mapIndexed { index, linkElement ->
resp.select("div.card-body div.mb-4 > a").mapIndexed { index, linkElement ->
val link = linkElement.attr("href") val link = linkElement.attr("href")
val decodedLink = if (link.contains("workers.dev")) { val decodedLink = if (link.contains("workers.dev")) {
link link
@ -413,22 +288,41 @@ class UHDMovies : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
videoUrl = decodedLink, videoUrl = decodedLink,
) )
} }
} catch (_: Exception) {
emptyList()
} }
private fun getDirectLink(url: String, action: String = "direct", newPath: String = "/file/"): String? {
val doc = client.newCall(GET(url, headers)).execute().use { it.asJsoup() }
val script = doc.selectFirst("script:containsData(async function taskaction)")
?.data()
?: return url
val key = script.substringAfter("key\", \"").substringBefore('"')
val form = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("action", action)
.addFormDataPart("key", key)
.addFormDataPart("action_token", "")
.build()
val headers = headersBuilder().set("x-token", url.toHttpUrl().host).build()
val req = client.newCall(POST(url.replace("/file/", newPath), headers, form)).execute()
return runCatching {
json.decodeFromString<DriveLeechDirect>(req.use { it.body.string() }).url
}.getOrNull()
} }
private fun extractGDriveLink(mediaUrl: String, quality: String): List<Video> { private fun extractGDriveLink(mediaUrl: String, quality: String): List<Video> {
val tokenClient = client.newBuilder().addInterceptor(TokenInterceptor()).build() val neoUrl = getDirectLink(mediaUrl) ?: mediaUrl
val response = tokenClient.newCall(GET(mediaUrl)).execute().asJsoup() val response = client.newCall(GET(neoUrl)).execute().use { it.asJsoup() }
val gdBtn = response.selectFirst("div.card-body a.btn")!! val gdBtn = response.selectFirst("div.card-body a.btn")!!
val gdLink = gdBtn.attr("href") val gdLink = gdBtn.attr("href")
val sizeMatch = sizeRegex.find(gdBtn.text()) val sizeMatch = SIZE_REGEX.find(gdBtn.text())
val size = sizeMatch?.groups?.get(1)?.value?.let { " - $it" } ?: "" val size = sizeMatch?.groups?.get(1)?.value?.let { " - $it" } ?: ""
val gdResponse = client.newCall(GET(gdLink)).execute().asJsoup() val gdResponse = client.newCall(GET(gdLink)).execute().use { it.asJsoup() }
val link = gdResponse.select("form#download-form") val link = gdResponse.select("form#download-form")
return if (link.isEmpty()) { return if (link.isNullOrEmpty()) {
listOf() emptyList()
} else { } else {
val realLink = link.attr("action") val realLink = link.attr("action")
listOf(Video(realLink, "$quality - Gdrive$size", realLink)) listOf(Video(realLink, "$quality - Gdrive$size", realLink))
@ -436,8 +330,8 @@ class UHDMovies : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
} }
override fun List<Video>.sort(): List<Video> { override fun List<Video>.sort(): List<Video> {
val quality = preferences.getString("preferred_quality", "1080")!! val quality = preferences.getString(PREF_QUALITY_KEY, PREF_QUALITY_DEFAULT)!!
val ascSort = preferences.getString("preferred_size_sort", "asc")!! == "asc" val ascSort = preferences.getString(PREF_SIZE_SORT_KEY, PREF_SIZE_SORT_DEFAULT)!! == "asc"
val comparator = compareByDescending<Video> { it.quality.contains(quality) }.let { cmp -> val comparator = compareByDescending<Video> { it.quality.contains(quality) }.let { cmp ->
if (ascSort) { if (ascSort) {
@ -446,27 +340,27 @@ class UHDMovies : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
cmp.thenByDescending { it.quality.fixQuality() } cmp.thenByDescending { it.quality.fixQuality() }
} }
} }
return this.sortedWith(comparator) return sortedWith(comparator)
} }
private fun String.fixQuality(): Float { private fun String.fixQuality(): Float {
val size = this.substringAfterLast("-").trim() val size = substringAfterLast("-").trim()
return if (size.contains("GB", true)) { return if (size.contains("GB", true)) {
size.replace("GB", "", true) size.replace("GB", "", true)
.toFloat() * 1000 .toFloatOrNull()?.let { it * 1000 } ?: 1F
} else { } else {
size.replace("MB", "", true) size.replace("MB", "", true)
.toFloat() .toFloatOrNull() ?: 1F
} }
} }
override fun setupPreferenceScreen(screen: PreferenceScreen) { override fun setupPreferenceScreen(screen: PreferenceScreen) {
val videoQualityPref = ListPreference(screen.context).apply { ListPreference(screen.context).apply {
key = "preferred_quality" key = PREF_QUALITY_KEY
title = "Preferred quality" title = PREF_QUALITY_TITLE
entries = arrayOf("2160p", "1080p", "720p", "480p") entries = PREF_QUALITY_ENTRIES
entryValues = arrayOf("2160", "1080", "720", "480") entryValues = PREF_QUALITY_ENTRIES
setDefaultValue("1080") setDefaultValue(PREF_QUALITY_DEFAULT)
summary = "%s" summary = "%s"
setOnPreferenceChangeListener { _, newValue -> setOnPreferenceChangeListener { _, newValue ->
@ -475,16 +369,15 @@ class UHDMovies : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
val entry = entryValues[index] as String val entry = entryValues[index] as String
preferences.edit().putString(key, entry).commit() preferences.edit().putString(key, entry).commit()
} }
} }.also(screen::addPreference)
val sizeSortPref = ListPreference(screen.context).apply {
key = "preferred_size_sort" ListPreference(screen.context).apply {
title = "Preferred Size Sort" key = PREF_SIZE_SORT_KEY
entries = arrayOf("Ascending", "Descending") title = PREF_SIZE_SORT_TITLE
entryValues = arrayOf("asc", "dec") entries = PREF_SIZE_SORT_ENTRIES
setDefaultValue("asc") entryValues = PREF_SIZE_SORT_VALUES
summary = """%s setDefaultValue(PREF_SIZE_SORT_DEFAULT)
|Sort order to be used after the videos are sorted by their quality. summary = PREF_SIZE_SORT_SUMMARY
""".trimMargin()
setOnPreferenceChangeListener { _, newValue -> setOnPreferenceChangeListener { _, newValue ->
val selected = newValue as String val selected = newValue as String
@ -492,30 +385,24 @@ class UHDMovies : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
val entry = entryValues[index] as String val entry = entryValues[index] as String
preferences.edit().putString(key, entry).commit() preferences.edit().putString(key, entry).commit()
} }
} }.also(screen::addPreference)
val domainPref = EditTextPreference(screen.context).apply {
EditTextPreference(screen.context).apply {
key = PREF_DOMAIN_KEY key = PREF_DOMAIN_KEY
title = "Currently used domain" title = PREF_DOMAIN_TITLE
dialogTitle = title dialogTitle = PREF_DOMAIN_DIALOG_TITLE
setDefaultValue(PREF_DEFAULT_DOMAIN) setDefaultValue(PREF_DOMAIN_DEFAULT)
val tempText = preferences.getString(key, PREF_DEFAULT_DOMAIN) summary = getDomainPrefSummary()
summary = """$tempText
|For any change to be applied App restart is required.
""".trimMargin()
setOnPreferenceChangeListener { _, newValue -> setOnPreferenceChangeListener { _, newValue ->
val newValueString = newValue as String runCatching {
preferences.edit().putString(key, newValueString.trim()).commit().also { val value = (newValue as String).ifEmpty { PREF_DOMAIN_DEFAULT }
summary = """$newValueString preferences.edit().putString(key, value).commit().also {
|For any change to be applied App restart is required. summary = getDomainPrefSummary()
""".trimMargin()
} }
}.getOrDefault(false)
} }
} }.also(screen::addPreference)
screen.addPreference(videoQualityPref)
screen.addPreference(sizeSortPref)
screen.addPreference(domainPref)
} }
@Serializable @Serializable
@ -529,18 +416,49 @@ class UHDMovies : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
val url: String, val url: String,
) )
@Serializable
data class DriveLeechDirect(val url: String? = null)
private fun EpLinks.toJson(): String { private fun EpLinks.toJson(): String {
return json.encodeToString(this) return json.encodeToString(this)
} }
// From Dopebox private inline fun <A, B> Iterable<A>.parallelCatchingFlatMap(crossinline f: suspend (A) -> Iterable<B>): List<B> =
private fun <A, B> Iterable<A>.parallelMap(f: suspend (A) -> B): List<B> =
runBlocking { runBlocking {
map { async(Dispatchers.Default) { f(it) } }.awaitAll() map {
async(Dispatchers.Default) {
runCatching { f(it) }.getOrElse { emptyList() }
}
}.awaitAll().flatten()
}
private fun getDomainPrefSummary(): String =
preferences.getString(PREF_DOMAIN_KEY, PREF_DOMAIN_DEFAULT)!!.let {
"""$it
|For any change to be applied App restart is required.
""".trimMargin()
} }
companion object { companion object {
const val PREF_DOMAIN_KEY = "pref_domain_new" private val SIZE_REGEX = "\\[((?:.(?!\\[))+)][ ]*\\$".toRegex(RegexOption.IGNORE_CASE)
const val PREF_DEFAULT_DOMAIN = "https://uhdmovies.life"
private const val PREF_DOMAIN_KEY = "pref_domain_new"
private const val PREF_DOMAIN_TITLE = "Currently used domain"
private const val PREF_DOMAIN_DEFAULT = "https://uhdmovies.vip"
private const val PREF_DOMAIN_DIALOG_TITLE = PREF_DOMAIN_TITLE
private const val PREF_QUALITY_KEY = "preferred_quality"
private const val PREF_QUALITY_TITLE = "Prefferred quality"
private const val PREF_QUALITY_DEFAULT = "1080p"
private val PREF_QUALITY_ENTRIES = arrayOf("2160p", "1080p", "720p", "480p")
private const val PREF_SIZE_SORT_KEY = "preferred_size_sort"
private const val PREF_SIZE_SORT_TITLE = "Preferred Size Sort"
private const val PREF_SIZE_SORT_DEFAULT = "asc"
private val PREF_SIZE_SORT_SUMMARY = """%s
|Sort order to be used after the videos are sorted by their quality.
""".trimMargin()
private val PREF_SIZE_SORT_ENTRIES = arrayOf("Ascending", "Descending")
private val PREF_SIZE_SORT_VALUES = arrayOf("asc", "desc")
} }
} }