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'
apply plugin: 'kotlin-android'
apply plugin: 'kotlinx-serialization'
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.serialization)
}
ext {
extName = 'UHD Movies'
pkgNameSuffix = 'en.uhdmovies'
extClass = '.UHDMovies'
extVersionCode = 17
extVersionCode = 18
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
import android.app.Application
import android.content.SharedPreferences
import android.util.Base64
import androidx.preference.EditTextPreference
import androidx.preference.ListPreference
@ -20,16 +19,13 @@ import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import okhttp3.FormBody
import okhttp3.Headers
import okhttp3.OkHttpClient
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.MultipartBody
import okhttp3.Request
import okhttp3.Response
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import rx.Observable
@ -37,186 +33,134 @@ import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
@ExperimentalSerializationApi
class UHDMovies : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
override val name = "UHD Movies"
override val baseUrl by lazy { preferences.getString(PREF_DOMAIN_KEY, PREF_DEFAULT_DOMAIN)!! }
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 {
override val baseUrl by lazy {
val url = preferences.getString(PREF_DOMAIN_KEY, PREF_DOMAIN_DEFAULT)!!
runBlocking {
withContext(Dispatchers.Default) {
client.newBuilder()
.followRedirects(false)
.build()
.newCall(GET("$baseUrl/")).execute().let { resp ->
.newCall(GET("$url/")).execute().use { resp ->
when (resp.code) {
301 -> {
(resp.headers["location"]?.substringBeforeLast("/") ?: baseUrl).also {
(resp.headers["location"]?.substringBeforeLast("/") ?: url).also {
preferences.edit().putString(PREF_DOMAIN_KEY, it).apply()
}
}
else -> baseUrl
else -> url
}
}
}
}
}
// ============================== Popular ===============================
override val lang = "en"
override fun popularAnimeRequest(page: Int): Request = GET("$currentBaseUrl/page/$page/")
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 ===============================
override fun popularAnimeRequest(page: Int): Request = GET("$baseUrl/page/$page/")
override fun popularAnimeSelector(): String = "div#content div.gridlove-posts > div.layout-masonry"
override fun popularAnimeFromElement(element: Element) = SAnime.create().apply {
setUrlWithoutDomain(element.select("div.entry-image > a").attr("abs:href"))
thumbnail_url = element.select("div.entry-image > a > img").attr("abs:src")
title = element.select("div.entry-image > a").attr("title")
.replace("Download", "").trim()
}
override fun popularAnimeNextPageSelector(): String =
"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"))
thumbnail_url = element.select("div.entry-image > a > img").attr("abs:src")
title = element.select("div.entry-image > a").attr("title")
.replace("Download", "").trim()
}
}
// =============================== Latest ===============================
override fun latestUpdatesRequest(page: Int): Request = 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")
// =============================== Search ===============================
override fun latestUpdatesNextPageSelector(): String = throw Exception("Not Used")
// =============================== Search ===============================
override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request {
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 searchAnimeNextPageSelector(): String = popularAnimeNextPageSelector()
override fun searchAnimeFromElement(element: Element): SAnime = popularAnimeFromElement(element)
// =========================== Anime Details ============================
override fun searchAnimeNextPageSelector(): String = popularAnimeNextPageSelector()
override fun animeDetailsParse(document: Document): SAnime {
return SAnime.create().apply {
initialized = true
title = document.selectFirst(".entry-title")?.text()
?.replace("Download", "", true)?.trim() ?: "Movie"
status = SAnime.COMPLETED
description = document.selectFirst("pre:contains(plot)")?.text()
}
// =========================== Anime Details ============================
override fun animeDetailsParse(document: Document) = SAnime.create().apply {
initialized = true
title = document.selectFirst(".entry-title")?.text()
?.replace("Download", "", true)?.trim() ?: "Movie"
status = SAnime.COMPLETED
description = document.selectFirst("pre:contains(plot)")?.text()
}
// ============================== 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 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 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 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 qualityMatch = qualityRegex.find(prevP.text())
val quality = qualityMatch?.value ?: let {
val qualityMatchOwn = qualityRegex.find(row.text())
qualityMatchOwn?.value ?: "HD"
}
val isSerie = doc.selectFirst(episodeListSelector())?.text().orEmpty().run {
contains("Episode", true) ||
contains("Zip", true) ||
contains("Pack", true)
}
row.select("a").filter { it ->
!it.text().contains("Zip", true) &&
!it.text().contains("Pack", true) &&
!it.text().contains("Volume ", true)
}.mapIndexed { index, linkElement ->
val episode = linkElement?.text()
?.replace("Episode", "", true)
?.trim()?.toIntOrNull() ?: index + 1
Triple(
season + "_$episode" + "_$part",
linkElement?.attr("href") ?: return@mapIndexed null,
quality,
)
}.filterNotNull()
}.flatten().groupBy { it.first }.map { group ->
val (season, episode, part) = group.key.split("_")
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()
},
)
val episodeList = episodeElements.map { row ->
val prevP = row.previousElementSibling()!!.text()
val qualityMatch = qualityRegex.find(prevP)
val quality = qualityMatch?.value ?: let {
val qualityMatchOwn = qualityRegex.find(row.text())
qualityMatchOwn?.value ?: "HD"
}
} else {
var collectionIdx = 0F
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 ->
val defaultName = if (isSerie) {
val (source, seasonNumber) = seasonRegex.firstValue(prevP) ?: run {
val prevPre = row.previousElementSiblings().prev("pre,div.mks_separator").first()
?.text()
.orEmpty()
seasonRegex.firstValue(prevPre)
} ?: run {
val title = doc.selectFirst("h1.entry-title")?.text().orEmpty()
seasonTitleRegex.firstValue(title)
} ?: "" to "1"
val part = partRegex.find(source)?.groupValues?.get(1)
?.let { " Pt $it" }
.orEmpty()
"Season ${seasonNumber.toIntOrNull() ?: 1 }$part"
} else {
row.previousElementSiblings().let { prevElem ->
(prevElem.prev("h1,h2,h3,pre:not(:contains(plot))").first()?.text() ?: "Movie - $quality")
.replace("Download", "", true).trim().let {
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++
episodeList.add(
SEpisode.create().apply {
url = EpLinks(
urls = group.value.map {
EpUrl(url = it.first, quality = it.second)
},
).toJson()
name = group.key
episode_number = collectionIdx
},
)
}
if (episodeList.isEmpty()) throw Exception("Only Zip Pack Available")
row.select("a").asSequence()
.filter { el -> el.classNames().none { it.endsWith("-zip") } }
.mapIndexedNotNull { index, linkElement ->
val episode = linkElement.text()
.replace("Episode", "", true)
.trim()
.toIntOrNull() ?: index + 1
val url = linkElement.attr("href").takeUnless(String::isBlank)
?: return@mapIndexedNotNull null
Triple(
Pair(defaultName, episode),
url,
quality,
)
}
}.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())
if (episodeList.isEmpty()) throw Exception("Only Zip Pack Available")
return episodeList.reversed()
}
override fun episodeListSelector(): String = throw Exception("Not Used")
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")
// ============================ Video Links =============================
override fun fetchVideoList(episode: SEpisode): Observable<List<Video>> {
val urlJson = json.decodeFromString<EpLinks>(episode.url)
val failedMediaUrl = mutableListOf<Pair<String, String>>()
val videoList = mutableListOf<Video>()
videoList.addAll(
urlJson.urls.parallelMap { url ->
runCatching {
val (videos, mediaUrl) = extractVideo(url)
if (videos.isEmpty() && mediaUrl.isNotBlank()) failedMediaUrl.add(Pair(mediaUrl, url.quality))
return@runCatching videos
}.getOrNull()
val videoList = urlJson.urls.parallelCatchingFlatMap { eplink ->
val quality = eplink.quality
val url = getMediaUrl(eplink) ?: return@parallelCatchingFlatMap emptyList()
val videos = extractVideo(url, quality)
when {
videos.isEmpty() -> {
extractGDriveLink(url, quality).ifEmpty {
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())
}
@ -290,145 +242,87 @@ class UHDMovies : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
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> {
val noRedirectClient = client.newBuilder().followRedirects(false).build()
val mediaResponse = if (epUrl.url.contains("?id=")) {
val postLink = epUrl.url.substringBefore("?id=").substringAfter("/?")
val initailUrl = epUrl.url.substringAfter("/?http").let {
if (it.startsWith("http")) {
it
} else {
"http$it"
}
}
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")
private fun getMediaUrl(epUrl: EpUrl): String? {
val url = epUrl.url
val mediaResponse = if (url.contains("?sid=")) {
/* redirector bs */
val finalUrl = redirectBypasser.bypass(url) ?: return null
client.newCall(GET(finalUrl)).execute()
} else if (url.contains("r?key=")) {
/* everything under control */
client.newCall(GET(url)).execute()
} else { return null }
val response2 = client.newCall(GET(link, cookieHeader)).execute().body.string()
val (longC2, _, postC) = getCookiesDetail(response2)
val cookieHeader2 = Headers.headersOf("Cookie", "$catC; $longC2; $postC")
val parsedSoup2 = Jsoup.parse(response2)
val link2 = parsedSoup2.selectFirst("center > a")!!.attr("href")
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 path = mediaResponse.use { it.body.string() }.substringAfter("replace(\"").substringBefore("\"")
val tokenResponse = noRedirectClient.newCall(GET(tokenUrl, tokenCookie)).execute().asJsoup()
val redirectUrl = tokenResponse.select("meta[http-equiv=refresh]").attr("content")
.substringAfter("url=").substringBefore("\"")
noRedirectClient.newCall(GET(redirectUrl)).execute()
} else if (epUrl.url.contains("r?key=")) {
client.newCall(GET(epUrl.url)).execute()
} else { throw Exception("Something went wrong") }
if (path == "/404") return null
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>()
return "https://" + mediaResponse.request.url.host + path
}
for (type in 1..3) {
videoList.addAll(
extractWorkerLinks(mediaUrl, epUrl.quality, type),
)
private fun extractVideo(url: String, quality: String): List<Video> {
return (1..3).toList().flatMap { type ->
extractWorkerLinks(url, 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> {
val reqLink = mediaUrl.replace("/file/", "/wfile/") + "?type=$type"
val resp = client.newCall(GET(reqLink)).execute().asJsoup()
val sizeMatch = sizeRegex.find(resp.select("div.card-header").text().trim())
val resp = client.newCall(GET(reqLink)).execute().use { it.asJsoup() }
val sizeMatch = SIZE_REGEX.find(resp.select("div.card-header").text().trim())
val size = sizeMatch?.groups?.get(1)?.value?.let { " - $it" } ?: ""
return try {
resp.select("div.card-body div.mb-4 > a").mapIndexed { index, linkElement ->
val link = linkElement.attr("href")
val decodedLink = if (link.contains("workers.dev")) {
link
} else {
String(Base64.decode(link.substringAfter("download?url="), Base64.DEFAULT))
}
Video(
url = decodedLink,
quality = "$quality - CF $type Worker ${index + 1}$size",
videoUrl = decodedLink,
)
return resp.select("div.card-body div.mb-4 > a").mapIndexed { index, linkElement ->
val link = linkElement.attr("href")
val decodedLink = if (link.contains("workers.dev")) {
link
} else {
String(Base64.decode(link.substringAfter("download?url="), Base64.DEFAULT))
}
} catch (_: Exception) {
emptyList()
Video(
url = decodedLink,
quality = "$quality - CF $type Worker ${index + 1}$size",
videoUrl = decodedLink,
)
}
}
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> {
val tokenClient = client.newBuilder().addInterceptor(TokenInterceptor()).build()
val response = tokenClient.newCall(GET(mediaUrl)).execute().asJsoup()
val neoUrl = getDirectLink(mediaUrl) ?: mediaUrl
val response = client.newCall(GET(neoUrl)).execute().use { it.asJsoup() }
val gdBtn = response.selectFirst("div.card-body a.btn")!!
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 gdResponse = client.newCall(GET(gdLink)).execute().asJsoup()
val gdResponse = client.newCall(GET(gdLink)).execute().use { it.asJsoup() }
val link = gdResponse.select("form#download-form")
return if (link.isEmpty()) {
listOf()
return if (link.isNullOrEmpty()) {
emptyList()
} else {
val realLink = link.attr("action")
listOf(Video(realLink, "$quality - Gdrive$size", realLink))
@ -436,8 +330,8 @@ class UHDMovies : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
}
override fun List<Video>.sort(): List<Video> {
val quality = preferences.getString("preferred_quality", "1080")!!
val ascSort = preferences.getString("preferred_size_sort", "asc")!! == "asc"
val quality = preferences.getString(PREF_QUALITY_KEY, PREF_QUALITY_DEFAULT)!!
val ascSort = preferences.getString(PREF_SIZE_SORT_KEY, PREF_SIZE_SORT_DEFAULT)!! == "asc"
val comparator = compareByDescending<Video> { it.quality.contains(quality) }.let { cmp ->
if (ascSort) {
@ -446,27 +340,27 @@ class UHDMovies : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
cmp.thenByDescending { it.quality.fixQuality() }
}
}
return this.sortedWith(comparator)
return sortedWith(comparator)
}
private fun String.fixQuality(): Float {
val size = this.substringAfterLast("-").trim()
val size = substringAfterLast("-").trim()
return if (size.contains("GB", true)) {
size.replace("GB", "", true)
.toFloat() * 1000
.toFloatOrNull()?.let { it * 1000 } ?: 1F
} else {
size.replace("MB", "", true)
.toFloat()
.toFloatOrNull() ?: 1F
}
}
override fun setupPreferenceScreen(screen: PreferenceScreen) {
val videoQualityPref = ListPreference(screen.context).apply {
key = "preferred_quality"
title = "Preferred quality"
entries = arrayOf("2160p", "1080p", "720p", "480p")
entryValues = arrayOf("2160", "1080", "720", "480")
setDefaultValue("1080")
ListPreference(screen.context).apply {
key = PREF_QUALITY_KEY
title = PREF_QUALITY_TITLE
entries = PREF_QUALITY_ENTRIES
entryValues = PREF_QUALITY_ENTRIES
setDefaultValue(PREF_QUALITY_DEFAULT)
summary = "%s"
setOnPreferenceChangeListener { _, newValue ->
@ -475,16 +369,15 @@ class UHDMovies : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
val entry = entryValues[index] as String
preferences.edit().putString(key, entry).commit()
}
}
val sizeSortPref = ListPreference(screen.context).apply {
key = "preferred_size_sort"
title = "Preferred Size Sort"
entries = arrayOf("Ascending", "Descending")
entryValues = arrayOf("asc", "dec")
setDefaultValue("asc")
summary = """%s
|Sort order to be used after the videos are sorted by their quality.
""".trimMargin()
}.also(screen::addPreference)
ListPreference(screen.context).apply {
key = PREF_SIZE_SORT_KEY
title = PREF_SIZE_SORT_TITLE
entries = PREF_SIZE_SORT_ENTRIES
entryValues = PREF_SIZE_SORT_VALUES
setDefaultValue(PREF_SIZE_SORT_DEFAULT)
summary = PREF_SIZE_SORT_SUMMARY
setOnPreferenceChangeListener { _, newValue ->
val selected = newValue as String
@ -492,30 +385,24 @@ class UHDMovies : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
val entry = entryValues[index] as String
preferences.edit().putString(key, entry).commit()
}
}
val domainPref = EditTextPreference(screen.context).apply {
}.also(screen::addPreference)
EditTextPreference(screen.context).apply {
key = PREF_DOMAIN_KEY
title = "Currently used domain"
dialogTitle = title
setDefaultValue(PREF_DEFAULT_DOMAIN)
val tempText = preferences.getString(key, PREF_DEFAULT_DOMAIN)
summary = """$tempText
|For any change to be applied App restart is required.
""".trimMargin()
title = PREF_DOMAIN_TITLE
dialogTitle = PREF_DOMAIN_DIALOG_TITLE
setDefaultValue(PREF_DOMAIN_DEFAULT)
summary = getDomainPrefSummary()
setOnPreferenceChangeListener { _, newValue ->
val newValueString = newValue as String
preferences.edit().putString(key, newValueString.trim()).commit().also {
summary = """$newValueString
|For any change to be applied App restart is required.
""".trimMargin()
}
runCatching {
val value = (newValue as String).ifEmpty { PREF_DOMAIN_DEFAULT }
preferences.edit().putString(key, value).commit().also {
summary = getDomainPrefSummary()
}
}.getOrDefault(false)
}
}
screen.addPreference(videoQualityPref)
screen.addPreference(sizeSortPref)
screen.addPreference(domainPref)
}.also(screen::addPreference)
}
@Serializable
@ -529,18 +416,49 @@ class UHDMovies : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
val url: String,
)
@Serializable
data class DriveLeechDirect(val url: String? = null)
private fun EpLinks.toJson(): String {
return json.encodeToString(this)
}
// From Dopebox
private fun <A, B> Iterable<A>.parallelMap(f: suspend (A) -> B): List<B> =
private inline fun <A, B> Iterable<A>.parallelCatchingFlatMap(crossinline f: suspend (A) -> Iterable<B>): List<B> =
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 {
const val PREF_DOMAIN_KEY = "pref_domain_new"
const val PREF_DEFAULT_DOMAIN = "https://uhdmovies.life"
private val SIZE_REGEX = "\\[((?:.(?!\\[))+)][ ]*\\$".toRegex(RegexOption.IGNORE_CASE)
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")
}
}