From 2286b24a0dd2a1add53d315fc40849df42720e5d Mon Sep 17 00:00:00 2001 From: test01203 Date: Sat, 1 Aug 2026 10:37:44 +0300 Subject: [PATCH 1/7] Fix playback failures on HubCloud-gated hosts missing Referer Scraper plugins (e.g. 4KHDHub via HubCloud) sometimes return a final playback URL without any request headers, even though the same host required a Referer during the plugin's own link resolution. ExoPlayer then fails extractor sniffing (NoDeclaredBrand) because it receives an HTML interstitial/anti-bot page instead of the actual video. Add a fallback in StreamRepository.resolveStreamInternal that injects a default Referer/Origin for a small list of known-gated hosts (hubcloud, hubdrive) only when the addon/plugin didn't already supply a Referer, so this never overrides explicit values. --- .../tv/data/repository/StreamRepository.kt | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt index 35a63284..78029a5b 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt @@ -2969,6 +2969,17 @@ class StreamRepository @Inject constructor( private val STREAM_PREWARM_NETWORK_TIMEOUT_MS = 700L private val STREAM_REDIRECT_RESOLUTION_TIMEOUT_MS = 1_800L private val PLAYBACK_HOST_BAD_TTL_MS = 5 * 60_000L + + // Some scraper plugins (e.g. 4KHDHub, DVDPlay via HubCloud) return a final playback URL + // without any request headers, even though the host requires a same-site Referer/Origin to + // serve the actual video instead of an interstitial/anti-bot HTML page. ExoPlayer then fails + // extractor sniffing ("NoDeclaredBrand") because it received HTML, not a media container. + // These hosts are known to need a Referer pointing back at themselves; only applied when the + // plugin didn't already supply its own headers, so this never overrides addon-provided values. + private val GATED_HOST_DEFAULT_REFERERS = mapOf( + "hubcloud" to "https://hubcloud.cx/", + "hubdrive" to "https://hubdrive.dev/" + ) private val SIDE_EFFECT_PRONE_PREWARM_HOST_MARKERS = setOf( "torrentio", "torbox", @@ -3363,10 +3374,17 @@ class StreamRepository @Inject constructor( normalizedUrl.startsWith("https://", ignoreCase = true) ) { val (resolvedUrl, urlHeaders) = splitUrlAndHeaders(normalizedUrl) - val mergedHeaders = mergeRequestHeaders( + val explicitHeaders = mergeRequestHeaders( base = stream.behaviorHints?.proxyHeaders?.request.orEmpty(), extra = urlHeaders ) + // Only fall back to a known-gated-host Referer/Origin when the addon/plugin didn't + // already provide its own Referer — never override an explicit value. + val mergedHeaders = if (explicitHeaders.keys.none { it.equals("Referer", ignoreCase = true) }) { + mergeRequestHeaders(base = explicitHeaders, extra = defaultHeadersForGatedHost(resolvedUrl)) + } else { + explicitHeaders + } val mergedBehaviorHints = when { mergedHeaders.isNotEmpty() -> { val current = stream.behaviorHints @@ -3493,6 +3511,18 @@ class StreamRepository @Inject constructor( return baseUrl to parsed } + // See GATED_HOST_DEFAULT_REFERERS above for why this exists. + private fun defaultHeadersForGatedHost(url: String): Map { + val host = runCatching { java.net.URI(url).host?.lowercase(Locale.US) }.getOrNull().orEmpty() + if (host.isBlank()) return emptyMap() + val referer = GATED_HOST_DEFAULT_REFERERS.entries + .firstOrNull { (marker, _) -> host.contains(marker) } + ?.value + ?: return emptyMap() + val origin = deriveOriginFromReferer(referer) ?: referer.trimEnd('/') + return mapOf("Referer" to referer, "Origin" to origin) + } + private fun deriveOriginFromReferer(referer: String): String? { return runCatching { val parsed = java.net.URI(referer.trim()) From 418af66de23df386e86f3e7263d6ce3b2fd486fc Mon Sep 17 00:00:00 2001 From: test01203 Date: Sat, 1 Aug 2026 10:53:31 +0300 Subject: [PATCH 2/7] Unwrap HubCloud anti-leech landing pages to the real direct link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified against live HubCloud servers: the "10Gbps" link chain redirects through a Cloudflare Worker and lands on an HTML page (gamerxyt.com/dl.php?link=) whose own URL already carries the real direct link as a query parameter — a browser follows it via client-side JS, but ExoPlayer/OkHttp just get the HTML and fail extractor sniffing. Extend redirect resolution to gated hosts (reusing the Referer fix's host list) and unwrap the "link"/"url" query parameter from the resolved URL when present, so playback uses the real direct link. Confirmed live: the extracted link serves content-type: video/mkv directly (no further gating needed on this specific CDN). --- .../tv/data/repository/StreamRepository.kt | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt index 78029a5b..67cec9d5 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt @@ -3129,7 +3129,29 @@ class StreamRepository @Inject constructor( host.contains("comet", ignoreCase = true) || host.contains("mediafusion", ignoreCase = true) || host.contains("stremthru", ignoreCase = true) || - host.contains("jackettio", ignoreCase = true) + host.contains("jackettio", ignoreCase = true) || + GATED_HOST_DEFAULT_REFERERS.keys.any { host.contains(it) } + } + + // HubCloud-style "10Gbps" links redirect through a Cloudflare Worker and land on + // an anti-leech HTML page (e.g. gamerxyt.com/dl.php?link=) whose + // own URL already carries the real direct link as a query parameter. A browser + // would follow this via client-side JS; ExoPlayer/OkHttp won't, so it just gets + // the landing page's HTML and fails extractor sniffing. Unwrap it here instead. + private fun unwrapEmbeddedLinkParam(url: String): String { + val parsed = runCatching { java.net.URI(url) }.getOrNull() ?: return url + val query = parsed.rawQuery ?: return url + val embedded = query.split('&') + .asSequence() + .mapNotNull { pair -> + val idx = pair.indexOf('=') + if (idx < 0) return@mapNotNull null + val key = pair.substring(0, idx) + if (!key.equals("link", ignoreCase = true) && !key.equals("url", ignoreCase = true)) return@mapNotNull null + runCatching { URLDecoder.decode(pair.substring(idx + 1), "UTF-8") }.getOrNull() + } + .firstOrNull { it.startsWith("http://", ignoreCase = true) || it.startsWith("https://", ignoreCase = true) } + return embedded ?: url } private suspend fun resolveRedirectedPlaybackUrl( @@ -3404,11 +3426,12 @@ class StreamRepository @Inject constructor( } else -> stream.behaviorHints } - val playbackUrl = if (shouldResolveRedirectBeforePlayback(resolvedUrl, stream)) { + val redirectResolvedUrl = if (shouldResolveRedirectBeforePlayback(resolvedUrl, stream)) { resolveRedirectedPlaybackUrl(resolvedUrl, mergedHeaders) } else { resolvedUrl } + val playbackUrl = unwrapEmbeddedLinkParam(redirectResolvedUrl) return stream.copy( url = playbackUrl, behaviorHints = mergedBehaviorHints From 1a6250e69f4201b59000c886a6e58065453962ce Mon Sep 17 00:00:00 2001 From: test01203 Date: Fri, 7 Aug 2026 14:40:45 +0300 Subject: [PATCH 3/7] Fix plugin scraper runtime + HubCloud/HubDrive playback resolution Three runtime-side fixes so CloudStream-style scraper plugins that return HubCloud/HubDrive links resolve to a playable file, without touching any plugin code: - PluginRuntime: add setTimeout/clearTimeout/setInterval/clearInterval polyfills. The QuickJS runtime lacked them, so any plugin that wraps fetch in `setTimeout(() => controller.abort(), ms)` threw ReferenceError, the fetch helper swallowed it, every request returned null, and the scraper produced zero results. - StreamRepository: add a HubCloud/HubDrive resolver. A drive/video page URL is followed through its `var url` chain to the links page and reduced to a direct media file (Cloudflare R2 / FSL / PixelServer). Login/nav pages resolve to null so the source is skipped instead of handing ExoPlayer an HTML page (NoDeclaredBrand). - PlayerViewModel: during failover, a null resolution now skips the candidate instead of probing the raw URL, which would look reachable (HTTP 200 HTML) and then fail extractor sniffing. Co-Authored-By: Claude Opus 4.8 --- .../tv/data/repository/StreamRepository.kt | 129 +++++++++++++++++- .../tv/ui/screens/player/PlayerViewModel.kt | 5 +- .../arflix/tv/core/plugin/PluginRuntime.kt | 34 +++++ 3 files changed, 165 insertions(+), 3 deletions(-) diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt index 67cec9d5..41132091 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt @@ -2967,7 +2967,11 @@ class StreamRepository @Inject constructor( private val STREAM_PREWARM_TTL_MS = 90_000L private val STREAM_PREWARM_EPHEMERAL_TTL_MS = 25_000L private val STREAM_PREWARM_NETWORK_TIMEOUT_MS = 700L - private val STREAM_REDIRECT_RESOLUTION_TIMEOUT_MS = 1_800L + // Gated hosts like HubCloud bounce through a Cloudflare Worker + anti-leech + // landing page before exposing the real link; 1.8s was too tight to follow + // that whole chain, so redirect resolution silently returned the raw + // (unplayable) URL. 8s covers the multi-hop chain without stalling startup. + private val STREAM_REDIRECT_RESOLUTION_TIMEOUT_MS = 8_000L private val PLAYBACK_HOST_BAD_TTL_MS = 5 * 60_000L // Some scraper plugins (e.g. 4KHDHub, DVDPlay via HubCloud) return a final playback URL @@ -3189,6 +3193,98 @@ class StreamRepository @Inject constructor( } } + // --- HubCloud / HubDrive playback resolver --------------------------------- + // + // Many scraper plugins hand back a HubCloud/HubDrive *page* URL (e.g. + // https://hubcloud.cx/drive/) rather than a direct media file — some even + // return the page's "Login" nav link by mistake. That page is HTML: ExoPlayer + // can't play it and fails extractor sniffing ("NoDeclaredBrand"). A browser + // would run the page's JS to reach the real file; we replicate that chain here + // so *any* plugin that emits a HubCloud/HubDrive link plays, regardless of how + // completely the plugin itself resolved it. + // + // Chain: drive page -> `var url = ''` -> the links page + // exposes direct download anchors (Cloudflare R2 signed URL, FSL, PixelServer). + // Returns a direct media URL, or null when the page can't be resolved (e.g. a + // login/nav page) so the caller skips the source and fails over to the next. + private fun isHubCloudPageUrl(url: String): Boolean { + val parsed = runCatching { java.net.URI(url) } .getOrNull() ?: return false + val host = parsed.host?.lowercase(Locale.US)?.removePrefix("www.").orEmpty() + if (!host.contains("hubcloud") && !host.contains("hubdrive")) return false + val path = parsed.path?.lowercase(Locale.US).orEmpty() + // Only treat *page* URLs as resolvable. Direct file endpoints on the same + // domain (e.g. pixel.hubcloud.cx/?id=...) have no such path and are left as-is. + return path.contains("/drive/") || path.contains("/video/") || + path.contains("/file/") || path.contains("/s/") + } + + private fun httpGetStringOrNull(url: String, referer: String?): String? { + return runCatching { + val builder = Request.Builder().url(url).get() + .header("User-Agent", OkHttpProvider.userAgent) + .header("Accept", "*/*") + if (!referer.isNullOrBlank()) { + builder.header("Referer", referer) + deriveOriginFromReferer(referer)?.let { builder.header("Origin", it) } + } + OkHttpProvider.client.newCall(builder.build()).execute().use { response -> + if (!response.isSuccessful) return null + response.body?.string() + } + }.getOrNull() + } + + private fun htmlUnescape(value: String): String = + value.replace("&", "&").replace("&", "&").replace(""", "\"").replace("'", "'") + + private val hubHrefRegex = Regex("""href\s*=\s*["']([^"']+)["']""", RegexOption.IGNORE_CASE) + private val hubVarUrlRegex = Regex("""var\s+url\s*=\s*['"]([^'"]+)['"]""", RegexOption.IGNORE_CASE) + + // Ordered best-first: a signed R2 object plays most reliably (range + no gate), + // then FSL file servers, then the PixelServer 10Gbps redirect endpoint. + private fun pickHubCloudDirectLink(hrefs: List): String? { + fun firstMatching(predicate: (String) -> Boolean): String? = hrefs.firstOrNull(predicate) + return firstMatching { it.contains("r2.cloudflarestorage.com", true) || it.contains("r2.dev", true) || + it.contains("response-content-disposition=attachment", true) } + ?: firstMatching { it.contains("fsl.", true) && (it.contains("token=", true) || + it.contains(".mkv", true) || it.contains(".mp4", true)) } + ?: firstMatching { it.contains("pixel.", true) } + ?: firstMatching { it.contains("workers.dev", true) } + } + + private suspend fun resolveHubCloudChain(pageUrl: String): String? = withContext(Dispatchers.IO) { + runCatching { + withTimeout(STREAM_REDIRECT_RESOLUTION_TIMEOUT_MS) { + var driveUrl = pageUrl + val host = runCatching { java.net.URI(pageUrl).host?.lowercase(Locale.US) }.getOrNull().orEmpty() + + // HubDrive pages wrap a HubCloud link — hop to it first. + if (host.contains("hubdrive")) { + val driveHtml = httpGetStringOrNull(pageUrl, pageUrl) ?: return@withTimeout null + val innerHub = hubHrefRegex.findAll(driveHtml) + .map { htmlUnescape(it.groupValues[1]) } + .firstOrNull { it.contains("hubcloud", true) && it.contains("/drive/", true) } + ?: return@withTimeout null + driveUrl = innerHub + } + + val driveHtml = httpGetStringOrNull(driveUrl, driveUrl) ?: return@withTimeout null + // The real links live on the gamerxyt page referenced by `var url`. + val linksPageUrl = hubVarUrlRegex.find(driveHtml)?.groupValues?.get(1) + ?: return@withTimeout null + + val linksHtml = httpGetStringOrNull(htmlUnescape(linksPageUrl), driveUrl) ?: return@withTimeout null + val hrefs = hubHrefRegex.findAll(linksHtml) + .map { htmlUnescape(it.groupValues[1]) } + .filter { it.startsWith("http", true) } + // Drop nav/util links (Login points back at /drive/, plus VPN/TG/etc). + .filterNot { it.contains("/drive/", true) || it.contains("favicon", true) } + .toList() + pickHubCloudDirectLink(hrefs) + } + }.getOrNull() + } + private fun hostContainsAny(host: String, markers: Set): Boolean { val normalized = host.lowercase(Locale.US).removePrefix("www.") return markers.any { marker -> normalized.contains(marker) } @@ -3400,6 +3496,32 @@ class StreamRepository @Inject constructor( base = stream.behaviorHints?.proxyHeaders?.request.orEmpty(), extra = urlHeaders ) + // HubCloud/HubDrive page URLs aren't playable as-is: resolve the JS chain + // to a direct media file. A null result (login/nav page, expired link) + // marks the source unresolvable so the caller fails over to the next one. + if (isHubCloudPageUrl(resolvedUrl)) { + val direct = resolveHubCloudChain(resolvedUrl) + if (direct.isNullOrBlank()) { + Log.w("HubFix", "hubcloud unresolved url=$resolvedUrl -> skip source") + return null + } + Log.w("HubFix", "hubcloud resolved url=$resolvedUrl -> $direct") + val directHeaders = mergeRequestHeaders( + base = explicitHeaders, + extra = if (explicitHeaders.keys.none { it.equals("Referer", ignoreCase = true) }) { + defaultHeadersForGatedHost(direct) + } else { + emptyMap() + } + ) + val directBehaviorHints = if (directHeaders.isNotEmpty()) { + (stream.behaviorHints ?: ModelStreamBehaviorHints(notWebReady = false)) + .copy(proxyHeaders = ModelProxyHeaders(request = directHeaders)) + } else { + stream.behaviorHints + } + return stream.copy(url = direct, behaviorHints = directBehaviorHints) + } // Only fall back to a known-gated-host Referer/Origin when the addon/plugin didn't // already provide its own Referer — never override an explicit value. val mergedHeaders = if (explicitHeaders.keys.none { it.equals("Referer", ignoreCase = true) }) { @@ -3426,12 +3548,15 @@ class StreamRepository @Inject constructor( } else -> stream.behaviorHints } - val redirectResolvedUrl = if (shouldResolveRedirectBeforePlayback(resolvedUrl, stream)) { + val willResolveRedirect = shouldResolveRedirectBeforePlayback(resolvedUrl, stream) + Log.w("HubFix", "resolveStreamInternal url=$resolvedUrl willRedirect=$willResolveRedirect gatedHeaders=${defaultHeadersForGatedHost(resolvedUrl)}") + val redirectResolvedUrl = if (willResolveRedirect) { resolveRedirectedPlaybackUrl(resolvedUrl, mergedHeaders) } else { resolvedUrl } val playbackUrl = unwrapEmbeddedLinkParam(redirectResolvedUrl) + Log.w("HubFix", "resolveStreamInternal redirectResolved=$redirectResolvedUrl finalPlaybackUrl=$playbackUrl") return stream.copy( url = playbackUrl, behaviorHints = mergedBehaviorHints diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerViewModel.kt index 330da96d..7ed6e2f0 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerViewModel.kt @@ -2454,9 +2454,12 @@ class PlayerViewModel @Inject constructor( .take(maxAttempts) for (candidate in candidates) { + // A null resolution means the source is unplayable (e.g. an unresolvable + // HubCloud login/nav page). Skip it rather than probing the raw URL, which + // would look "reachable" (HTTP 200 HTML) and then fail ExoPlayer sniffing. val resolved = runCatching { streamRepository.resolveStreamForPlayback(candidate) - }.getOrNull() ?: candidate + }.getOrNull() ?: continue val candidateUrl = resolved.url?.trim().orEmpty() if (candidateUrl.isBlank()) continue diff --git a/app/src/sideload/kotlin/com/arflix/tv/core/plugin/PluginRuntime.kt b/app/src/sideload/kotlin/com/arflix/tv/core/plugin/PluginRuntime.kt index 6a3f2426..5cdee089 100644 --- a/app/src/sideload/kotlin/com/arflix/tv/core/plugin/PluginRuntime.kt +++ b/app/src/sideload/kotlin/com/arflix/tv/core/plugin/PluginRuntime.kt @@ -1223,6 +1223,40 @@ class PluginRuntime @Inject constructor() { }; } + // Timer polyfills (setTimeout / clearTimeout / setInterval / clearInterval). + // + // The plugin runtime executes synchronously: okhttp fetches block the + // QuickJS thread and there is no wall-clock event loop pumping timers + // between calls, so a delayed callback has no safe point to fire during a + // run. We therefore register the timer and hand back a numeric handle, but + // never invoke the callback mid-run. + // + // This lets plugins that use these standard web APIs run unchanged instead + // of throwing ReferenceError. The most common pattern is a fetch guarded by + // `setTimeout(() => controller.abort(), ms)`: without this shim the timer + // call throws, the fetch helper's catch swallows it, every request returns + // null, and the scraper yields zero results. With the shim the abort simply + // never trips and the request completes under okhttp's own 30s timeout — + // the behavior the plugin author intended. + if (typeof globalThis.setTimeout === 'undefined') { + var __timerCallbacks = {}; + var __timerNextId = 1; + globalThis.setTimeout = function(callback, delay) { + var id = __timerNextId++; + if (typeof callback === 'function') { + __timerCallbacks[id] = callback; + } + return id; + }; + globalThis.clearTimeout = function(id) { + delete __timerCallbacks[id]; + }; + } + if (typeof globalThis.setInterval === 'undefined') { + globalThis.setInterval = function() { return 0; }; + globalThis.clearInterval = function() {}; + } + // String.prototype.replaceAll polyfill if (!String.prototype.replaceAll) { String.prototype.replaceAll = function(search, replace) { From f968daae47512723938679e32a5149406d729f25 Mon Sep 17 00:00:00 2001 From: test01203 Date: Sat, 8 Aug 2026 14:21:59 +0300 Subject: [PATCH 4/7] Address PR #528 review: gate unwrap, enforce resolver timeout, redact logs - Gate unwrapEmbeddedLinkParam behind isEmbeddedLinkLandingHost so ?url=/?link= is only unwrapped on known anti-leech landing hosts (gamerxyt/hubcloud/ hubdrive/hubcdn/dl.php), never on arbitrary proxy/auth URLs. - Enforce a real per-call timeout in the HubCloud resolver via call.timeout() (4s each); the outer withTimeout cannot interrupt a blocking OkHttp execute(). - Redact query strings from all HubFix logs (redactUrlForLog) so signed R2/FSL tokens no longer reach logcat. - Revert the PlayerViewModel failover tweak: findFirstReachableStreamInAddon is dead code, so the change was never wired in. The resolver's connected path (resolveStreamInternal) is unchanged; existing tryAdvanceToNextStream handles auto-advance on unresolvable sources. Co-Authored-By: Claude Opus 4.8 --- .../tv/data/repository/StreamRepository.kt | 50 ++++++++++++++++--- .../tv/ui/screens/player/PlayerViewModel.kt | 5 +- 2 files changed, 45 insertions(+), 10 deletions(-) diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt index 41132091..3590e999 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt @@ -2972,6 +2972,10 @@ class StreamRepository @Inject constructor( // that whole chain, so redirect resolution silently returned the raw // (unplayable) URL. 8s covers the multi-hop chain without stalling startup. private val STREAM_REDIRECT_RESOLUTION_TIMEOUT_MS = 8_000L + // Per-request cap for the HubCloud/HubDrive resolver's blocking HTTP calls. + // Kept small so the (up to three) sequential hops stay bounded even though the + // enclosing withTimeout can't interrupt a blocking OkHttp execute(). + private val HUBCLOUD_HTTP_CALL_TIMEOUT_MS = 4_000L private val PLAYBACK_HOST_BAD_TTL_MS = 5 * 60_000L // Some scraper plugins (e.g. 4KHDHub, DVDPlay via HubCloud) return a final playback URL @@ -3137,6 +3141,20 @@ class StreamRepository @Inject constructor( GATED_HOST_DEFAULT_REFERERS.keys.any { host.contains(it) } } + // Hosts known to be anti-leech landing pages that carry the real file in a + // ?link=/?url= parameter. Unwrapping is gated to these so we never rewrite a + // legitimate proxy/auth URL that happens to have such a parameter. + private val EMBEDDED_LINK_LANDING_MARKERS = setOf( + "gamerxyt", "hubcloud", "hubdrive", "hubcdn", "/dl.php" + ) + + private fun isEmbeddedLinkLandingHost(url: String): Boolean { + val parsed = runCatching { java.net.URI(url) }.getOrNull() ?: return false + val host = parsed.host?.lowercase(Locale.US)?.removePrefix("www.").orEmpty() + val path = parsed.path?.lowercase(Locale.US).orEmpty() + return EMBEDDED_LINK_LANDING_MARKERS.any { host.contains(it) || path.contains(it) } + } + // HubCloud-style "10Gbps" links redirect through a Cloudflare Worker and land on // an anti-leech HTML page (e.g. gamerxyt.com/dl.php?link=) whose // own URL already carries the real direct link as a query parameter. A browser @@ -3227,13 +3245,26 @@ class StreamRepository @Inject constructor( builder.header("Referer", referer) deriveOriginFromReferer(referer)?.let { builder.header("Origin", it) } } - OkHttpProvider.client.newCall(builder.build()).execute().use { response -> + val call = OkHttpProvider.client.newCall(builder.build()) + // A blocking execute() inside withTimeout can't be cancelled by the + // coroutine, and the resolver chains up to three of them. A per-call + // timeout is the only thing that actually caps a slow HubCloud host. + call.timeout().timeout(HUBCLOUD_HTTP_CALL_TIMEOUT_MS, TimeUnit.MILLISECONDS) + call.execute().use { response -> if (!response.isSuccessful) return null response.body?.string() } }.getOrNull() } + /** URL with the query string stripped — safe for logging (drops signed tokens). */ + private fun redactUrlForLog(url: String?): String { + val raw = url?.trim().orEmpty() + if (raw.isBlank()) return "" + val cut = raw.indexOf('?') + return if (cut >= 0) raw.substring(0, cut) + "?…" else raw + } + private fun htmlUnescape(value: String): String = value.replace("&", "&").replace("&", "&").replace(""", "\"").replace("'", "'") @@ -3502,10 +3533,10 @@ class StreamRepository @Inject constructor( if (isHubCloudPageUrl(resolvedUrl)) { val direct = resolveHubCloudChain(resolvedUrl) if (direct.isNullOrBlank()) { - Log.w("HubFix", "hubcloud unresolved url=$resolvedUrl -> skip source") + Log.w("HubFix", "hubcloud unresolved url=${redactUrlForLog(resolvedUrl)} -> skip source") return null } - Log.w("HubFix", "hubcloud resolved url=$resolvedUrl -> $direct") + Log.w("HubFix", "hubcloud resolved url=${redactUrlForLog(resolvedUrl)} -> ${redactUrlForLog(direct)}") val directHeaders = mergeRequestHeaders( base = explicitHeaders, extra = if (explicitHeaders.keys.none { it.equals("Referer", ignoreCase = true) }) { @@ -3549,14 +3580,21 @@ class StreamRepository @Inject constructor( else -> stream.behaviorHints } val willResolveRedirect = shouldResolveRedirectBeforePlayback(resolvedUrl, stream) - Log.w("HubFix", "resolveStreamInternal url=$resolvedUrl willRedirect=$willResolveRedirect gatedHeaders=${defaultHeadersForGatedHost(resolvedUrl)}") + Log.w("HubFix", "resolveStreamInternal url=${redactUrlForLog(resolvedUrl)} willRedirect=$willResolveRedirect gatedHeaders=${defaultHeadersForGatedHost(resolvedUrl)}") val redirectResolvedUrl = if (willResolveRedirect) { resolveRedirectedPlaybackUrl(resolvedUrl, mergedHeaders) } else { resolvedUrl } - val playbackUrl = unwrapEmbeddedLinkParam(redirectResolvedUrl) - Log.w("HubFix", "resolveStreamInternal redirectResolved=$redirectResolvedUrl finalPlaybackUrl=$playbackUrl") + // Only unwrap ?link=/?url= for the known anti-leech landing hosts that embed + // the real file there. Doing it for every stream can strip legitimate proxy + // URLs and break addon auth, so gate it by host. + val playbackUrl = if (isEmbeddedLinkLandingHost(redirectResolvedUrl)) { + unwrapEmbeddedLinkParam(redirectResolvedUrl) + } else { + redirectResolvedUrl + } + Log.w("HubFix", "resolveStreamInternal redirectResolved=${redactUrlForLog(redirectResolvedUrl)} finalPlaybackUrl=${redactUrlForLog(playbackUrl)}") return stream.copy( url = playbackUrl, behaviorHints = mergedBehaviorHints diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerViewModel.kt index 7ed6e2f0..330da96d 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerViewModel.kt @@ -2454,12 +2454,9 @@ class PlayerViewModel @Inject constructor( .take(maxAttempts) for (candidate in candidates) { - // A null resolution means the source is unplayable (e.g. an unresolvable - // HubCloud login/nav page). Skip it rather than probing the raw URL, which - // would look "reachable" (HTTP 200 HTML) and then fail ExoPlayer sniffing. val resolved = runCatching { streamRepository.resolveStreamForPlayback(candidate) - }.getOrNull() ?: continue + }.getOrNull() ?: candidate val candidateUrl = resolved.url?.trim().orEmpty() if (candidateUrl.isBlank()) continue From 2db14e12f8271455a6e14638f600947a8385cd6d Mon Sep 17 00:00:00 2001 From: test01203 Date: Sat, 8 Aug 2026 16:34:35 +0300 Subject: [PATCH 5/7] PR #528 review round 2: exact host matching + enforce redirect timeout - Match HubCloud/HubDrive/landing hosts by exact second-level label (registrableLabel) instead of host.contains(), so look-alikes like hubcloud.evil.com are rejected. /dl.php stays a separate path signal. Applied to both isEmbeddedLinkLandingHost and isHubCloudPageUrl. - Set a real per-call timeout on resolveRedirectedPlaybackUrl's blocking OkHttp call (call.timeout()), since the enclosing withTimeout cannot interrupt a blocking execute(). Co-Authored-By: Claude Opus 4.8 --- .../tv/data/repository/StreamRepository.kt | 36 ++++++++++++++----- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt index 3590e999..a456805d 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt @@ -3141,18 +3141,31 @@ class StreamRepository @Inject constructor( GATED_HOST_DEFAULT_REFERERS.keys.any { host.contains(it) } } - // Hosts known to be anti-leech landing pages that carry the real file in a - // ?link=/?url= parameter. Unwrapping is gated to these so we never rewrite a - // legitimate proxy/auth URL that happens to have such a parameter. - private val EMBEDDED_LINK_LANDING_MARKERS = setOf( - "gamerxyt", "hubcloud", "hubdrive", "hubcdn", "/dl.php" - ) + // Registrable-name labels of the HubCloud/HubDrive family. Matched exactly + // against a host's second-level label (see [registrableLabel]) so that a + // look-alike such as `hubcloud.evil.com` — which merely *contains* "hubcloud" + // — is not treated as one of ours. TLDs vary (.cx/.ist/.one/.dev), so we key + // on the label rather than the full domain. + private val HUB_DOMAIN_LABELS = setOf("hubcloud", "hubdrive", "hubcdn", "gamerxyt") + + /** Second-level label of a host: hubcloud.cx -> "hubcloud", pixel.hubcloud.cx -> "hubcloud". */ + private fun registrableLabel(host: String): String { + val labels = host.split('.').filter { it.isNotEmpty() } + return when { + labels.size >= 2 -> labels[labels.size - 2] + else -> labels.lastOrNull().orEmpty() + } + } + // Anti-leech landing pages that carry the real file in a ?link=/?url= + // parameter. Gated so we never rewrite a legitimate proxy/auth URL that + // happens to have such a parameter. Host is matched by exact registrable + // label; `/dl.php` is a separate path signal for generic wrapper hosts. private fun isEmbeddedLinkLandingHost(url: String): Boolean { val parsed = runCatching { java.net.URI(url) }.getOrNull() ?: return false val host = parsed.host?.lowercase(Locale.US)?.removePrefix("www.").orEmpty() val path = parsed.path?.lowercase(Locale.US).orEmpty() - return EMBEDDED_LINK_LANDING_MARKERS.any { host.contains(it) || path.contains(it) } + return HUB_DOMAIN_LABELS.contains(registrableLabel(host)) || path.contains("/dl.php") } // HubCloud-style "10Gbps" links redirect through a Cloudflare Worker and land on @@ -3199,7 +3212,11 @@ class StreamRepository @Inject constructor( .apply { requestHeaders.forEach { (key, value) -> addHeader(key, value) } } .build() - OkHttpProvider.playbackClient.newCall(request).execute().use { response -> + val call = OkHttpProvider.playbackClient.newCall(request) + // withTimeout can't interrupt a blocking execute(); a per-call + // timeout is what actually caps a stalled redirect host. + call.timeout().timeout(STREAM_REDIRECT_RESOLUTION_TIMEOUT_MS, TimeUnit.MILLISECONDS) + call.execute().use { response -> response.request.url.toString().takeIf { finalUrl -> finalUrl.isNotBlank() && !finalUrl.equals(url, ignoreCase = true) } ?: url @@ -3228,7 +3245,8 @@ class StreamRepository @Inject constructor( private fun isHubCloudPageUrl(url: String): Boolean { val parsed = runCatching { java.net.URI(url) } .getOrNull() ?: return false val host = parsed.host?.lowercase(Locale.US)?.removePrefix("www.").orEmpty() - if (!host.contains("hubcloud") && !host.contains("hubdrive")) return false + val label = registrableLabel(host) + if (label != "hubcloud" && label != "hubdrive") return false val path = parsed.path?.lowercase(Locale.US).orEmpty() // Only treat *page* URLs as resolvable. Direct file endpoints on the same // domain (e.g. pixel.hubcloud.cx/?id=...) have no such path and are left as-is. From c888e3c4342a733fcae3928ce7ba7c0ccba59a25 Mon Sep 17 00:00:00 2001 From: test01203 Date: Sat, 8 Aug 2026 16:44:13 +0300 Subject: [PATCH 6/7] PR #528: add unit tests for HubCloud URL gating and link selection Extract the pure URL-classification helpers (registrableLabel, isHubCloudPageUrl, isEmbeddedLinkLandingHost, pickHubCloudDirectLink, redactUrlForLog) to top-level internal functions so they can be tested without the repository or network, matching the existing usesSlowAggregatorTimeout pattern. HubCloudResolverTest covers: second-level-label matching, look-alike domain rejection (hubcloud.evil.com), direct-endpoint pass-through, proxy-URL non-unwrapping, R2>FSL>Pixel selection priority, and query-string redaction. All green. Co-Authored-By: Claude Opus 4.8 --- .../tv/data/repository/StreamRepository.kt | 126 ++++++++++-------- .../data/repository/HubCloudResolverTest.kt | 118 ++++++++++++++++ 2 files changed, 185 insertions(+), 59 deletions(-) create mode 100644 app/src/test/kotlin/com/arflix/tv/data/repository/HubCloudResolverTest.kt diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt index a456805d..e9b3c743 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt @@ -191,6 +191,73 @@ internal fun usesSlowAggregatorTimeout(addon: Addon): Boolean { haystack.contains("hdhub") } +// --- HubCloud/HubDrive URL classification (pure, unit-tested) -------------------- +// Kept top-level + internal so the host-gating and link-selection logic can be +// exercised without spinning up the repository or the network. + +// Registrable-name labels of the HubCloud/HubDrive family. Matched exactly against +// a host's second-level label (see [registrableLabel]) so a look-alike such as +// `hubcloud.evil.com` — which merely *contains* "hubcloud" — is not treated as one +// of ours. TLDs vary (.cx/.ist/.one/.dev), so we key on the label, not the domain. +internal val HUB_DOMAIN_LABELS = setOf("hubcloud", "hubdrive", "hubcdn", "gamerxyt") + +/** Second-level label of a host: hubcloud.cx -> "hubcloud", pixel.hubcloud.cx -> "hubcloud". */ +internal fun registrableLabel(host: String): String { + val labels = host.split('.').filter { it.isNotEmpty() } + return when { + labels.size >= 2 -> labels[labels.size - 2] + else -> labels.lastOrNull().orEmpty() + } +} + +/** True when the URL is a resolvable HubCloud/HubDrive *page* (not a direct file endpoint). */ +internal fun isHubCloudPageUrl(url: String): Boolean { + val parsed = runCatching { java.net.URI(url) }.getOrNull() ?: return false + val host = parsed.host?.lowercase(Locale.US)?.removePrefix("www.").orEmpty() + val label = registrableLabel(host) + if (label != "hubcloud" && label != "hubdrive") return false + val path = parsed.path?.lowercase(Locale.US).orEmpty() + // Direct file endpoints on the same domain (e.g. pixel.hubcloud.cx/?id=...) have + // no such path and are left as-is. + return path.contains("/drive/") || path.contains("/video/") || + path.contains("/file/") || path.contains("/s/") +} + +/** + * True for anti-leech landing pages that carry the real file in a ?link=/?url= + * parameter. Gated so a legitimate proxy/auth URL with such a parameter is never + * rewritten: host is matched by exact registrable label, `/dl.php` is a separate + * path signal for generic wrapper hosts. + */ +internal fun isEmbeddedLinkLandingHost(url: String): Boolean { + val parsed = runCatching { java.net.URI(url) }.getOrNull() ?: return false + val host = parsed.host?.lowercase(Locale.US)?.removePrefix("www.").orEmpty() + val path = parsed.path?.lowercase(Locale.US).orEmpty() + return HUB_DOMAIN_LABELS.contains(registrableLabel(host)) || path.contains("/dl.php") +} + +/** + * Best direct link from a HubCloud links page, best-first: a signed R2 object + * (range + no gate), then FSL file servers, then the PixelServer 10Gbps redirect. + */ +internal fun pickHubCloudDirectLink(hrefs: List): String? { + fun firstMatching(predicate: (String) -> Boolean): String? = hrefs.firstOrNull(predicate) + return firstMatching { it.contains("r2.cloudflarestorage.com", true) || it.contains("r2.dev", true) || + it.contains("response-content-disposition=attachment", true) } + ?: firstMatching { it.contains("fsl.", true) && (it.contains("token=", true) || + it.contains(".mkv", true) || it.contains(".mp4", true)) } + ?: firstMatching { it.contains("pixel.", true) } + ?: firstMatching { it.contains("workers.dev", true) } +} + +/** URL with the query string stripped — safe for logging (drops signed tokens). */ +internal fun redactUrlForLog(url: String?): String { + val raw = url?.trim().orEmpty() + if (raw.isBlank()) return "" + val cut = raw.indexOf('?') + return if (cut >= 0) raw.substring(0, cut) + "?…" else raw +} + /** * Repository for stream resolution from Stremio addons * Enhanced with addon management @@ -3141,33 +3208,6 @@ class StreamRepository @Inject constructor( GATED_HOST_DEFAULT_REFERERS.keys.any { host.contains(it) } } - // Registrable-name labels of the HubCloud/HubDrive family. Matched exactly - // against a host's second-level label (see [registrableLabel]) so that a - // look-alike such as `hubcloud.evil.com` — which merely *contains* "hubcloud" - // — is not treated as one of ours. TLDs vary (.cx/.ist/.one/.dev), so we key - // on the label rather than the full domain. - private val HUB_DOMAIN_LABELS = setOf("hubcloud", "hubdrive", "hubcdn", "gamerxyt") - - /** Second-level label of a host: hubcloud.cx -> "hubcloud", pixel.hubcloud.cx -> "hubcloud". */ - private fun registrableLabel(host: String): String { - val labels = host.split('.').filter { it.isNotEmpty() } - return when { - labels.size >= 2 -> labels[labels.size - 2] - else -> labels.lastOrNull().orEmpty() - } - } - - // Anti-leech landing pages that carry the real file in a ?link=/?url= - // parameter. Gated so we never rewrite a legitimate proxy/auth URL that - // happens to have such a parameter. Host is matched by exact registrable - // label; `/dl.php` is a separate path signal for generic wrapper hosts. - private fun isEmbeddedLinkLandingHost(url: String): Boolean { - val parsed = runCatching { java.net.URI(url) }.getOrNull() ?: return false - val host = parsed.host?.lowercase(Locale.US)?.removePrefix("www.").orEmpty() - val path = parsed.path?.lowercase(Locale.US).orEmpty() - return HUB_DOMAIN_LABELS.contains(registrableLabel(host)) || path.contains("/dl.php") - } - // HubCloud-style "10Gbps" links redirect through a Cloudflare Worker and land on // an anti-leech HTML page (e.g. gamerxyt.com/dl.php?link=) whose // own URL already carries the real direct link as a query parameter. A browser @@ -3242,18 +3282,6 @@ class StreamRepository @Inject constructor( // exposes direct download anchors (Cloudflare R2 signed URL, FSL, PixelServer). // Returns a direct media URL, or null when the page can't be resolved (e.g. a // login/nav page) so the caller skips the source and fails over to the next. - private fun isHubCloudPageUrl(url: String): Boolean { - val parsed = runCatching { java.net.URI(url) } .getOrNull() ?: return false - val host = parsed.host?.lowercase(Locale.US)?.removePrefix("www.").orEmpty() - val label = registrableLabel(host) - if (label != "hubcloud" && label != "hubdrive") return false - val path = parsed.path?.lowercase(Locale.US).orEmpty() - // Only treat *page* URLs as resolvable. Direct file endpoints on the same - // domain (e.g. pixel.hubcloud.cx/?id=...) have no such path and are left as-is. - return path.contains("/drive/") || path.contains("/video/") || - path.contains("/file/") || path.contains("/s/") - } - private fun httpGetStringOrNull(url: String, referer: String?): String? { return runCatching { val builder = Request.Builder().url(url).get() @@ -3275,32 +3303,12 @@ class StreamRepository @Inject constructor( }.getOrNull() } - /** URL with the query string stripped — safe for logging (drops signed tokens). */ - private fun redactUrlForLog(url: String?): String { - val raw = url?.trim().orEmpty() - if (raw.isBlank()) return "" - val cut = raw.indexOf('?') - return if (cut >= 0) raw.substring(0, cut) + "?…" else raw - } - private fun htmlUnescape(value: String): String = value.replace("&", "&").replace("&", "&").replace(""", "\"").replace("'", "'") private val hubHrefRegex = Regex("""href\s*=\s*["']([^"']+)["']""", RegexOption.IGNORE_CASE) private val hubVarUrlRegex = Regex("""var\s+url\s*=\s*['"]([^'"]+)['"]""", RegexOption.IGNORE_CASE) - // Ordered best-first: a signed R2 object plays most reliably (range + no gate), - // then FSL file servers, then the PixelServer 10Gbps redirect endpoint. - private fun pickHubCloudDirectLink(hrefs: List): String? { - fun firstMatching(predicate: (String) -> Boolean): String? = hrefs.firstOrNull(predicate) - return firstMatching { it.contains("r2.cloudflarestorage.com", true) || it.contains("r2.dev", true) || - it.contains("response-content-disposition=attachment", true) } - ?: firstMatching { it.contains("fsl.", true) && (it.contains("token=", true) || - it.contains(".mkv", true) || it.contains(".mp4", true)) } - ?: firstMatching { it.contains("pixel.", true) } - ?: firstMatching { it.contains("workers.dev", true) } - } - private suspend fun resolveHubCloudChain(pageUrl: String): String? = withContext(Dispatchers.IO) { runCatching { withTimeout(STREAM_REDIRECT_RESOLUTION_TIMEOUT_MS) { diff --git a/app/src/test/kotlin/com/arflix/tv/data/repository/HubCloudResolverTest.kt b/app/src/test/kotlin/com/arflix/tv/data/repository/HubCloudResolverTest.kt new file mode 100644 index 00000000..677e1748 --- /dev/null +++ b/app/src/test/kotlin/com/arflix/tv/data/repository/HubCloudResolverTest.kt @@ -0,0 +1,118 @@ +package com.arflix.tv.data.repository + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Unit tests for the pure HubCloud/HubDrive URL classification and link-selection + * logic that backs the playback resolver (PR #528 review follow-up). + */ +class HubCloudResolverTest { + + // --- registrableLabel --------------------------------------------------- + + @Test + fun `registrable label is the second-level domain`() { + assertEquals("hubcloud", registrableLabel("hubcloud.cx")) + assertEquals("hubcloud", registrableLabel("hubcloud.ist")) + assertEquals("hubcloud", registrableLabel("pixel.hubcloud.cx")) + assertEquals("gamerxyt", registrableLabel("gamerxyt.com")) + assertEquals("evil", registrableLabel("hubcloud.evil.com")) + } + + // --- isHubCloudPageUrl -------------------------------------------------- + + @Test + fun `hubcloud drive and video pages are resolvable`() { + assertTrue(isHubCloudPageUrl("https://hubcloud.cx/drive/abc123")) + assertTrue(isHubCloudPageUrl("https://hubcloud.ist/video/xyz")) + assertTrue(isHubCloudPageUrl("https://hubdrive.dev/file/def")) + } + + @Test + fun `look-alike domain is not treated as hubcloud`() { + assertFalse(isHubCloudPageUrl("https://hubcloud.evil.com/drive/abc123")) + assertFalse(isHubCloudPageUrl("https://nothubcloud.com/drive/abc123")) + } + + @Test + fun `direct file endpoint on hubcloud domain is left as-is`() { + // pixel.hubcloud.cx is a real direct endpoint (no drive/video path) — must + // not be sent back through the page resolver. + assertFalse(isHubCloudPageUrl("https://pixel.hubcloud.cx/?id=deadbeef")) + } + + @Test + fun `unrelated hosts are not hubcloud pages`() { + assertFalse(isHubCloudPageUrl("https://example.com/drive/abc")) + assertFalse(isHubCloudPageUrl("not a url")) + } + + // --- isEmbeddedLinkLandingHost ----------------------------------------- + + @Test + fun `landing hosts are recognised by exact label`() { + assertTrue(isEmbeddedLinkLandingHost("https://gamerxyt.com/hubcloud.php?id=1")) + assertTrue(isEmbeddedLinkLandingHost("https://hubcloud.cx/drive/admin")) + assertTrue(isEmbeddedLinkLandingHost("https://any.host.example/dl.php?link=x")) + } + + @Test + fun `arbitrary proxy url with link param is not unwrapped`() { + // The whole point of the gate: a legitimate proxy/auth URL that merely + // carries ?url=/?link= must not be classified as a landing page. + assertFalse(isEmbeddedLinkLandingHost("https://myproxy.example.com/stream?url=https://cdn/x.mkv")) + assertFalse(isEmbeddedLinkLandingHost("https://hubcloud.evil.com/go?link=https://cdn/x.mkv")) + } + + // --- pickHubCloudDirectLink -------------------------------------------- + + @Test + fun `r2 signed link wins over fsl and pixel`() { + val hrefs = listOf( + "https://pixel.hubcloud.cx/?id=abc", + "https://fsl.gigabytes.icu/movie.mkv?token=abc", + "https://x.r2.cloudflarestorage.com/hub2/y?X-Amz-Signature=z" + ) + assertEquals("https://x.r2.cloudflarestorage.com/hub2/y?X-Amz-Signature=z", pickHubCloudDirectLink(hrefs)) + } + + @Test + fun `fsl chosen when no r2 present`() { + val hrefs = listOf( + "https://pixel.hubcloud.cx/?id=abc", + "https://fsl.gigabytes.icu/movie.mkv?token=abc" + ) + assertEquals("https://fsl.gigabytes.icu/movie.mkv?token=abc", pickHubCloudDirectLink(hrefs)) + } + + @Test + fun `nav and junk links yield no direct link`() { + val hrefs = listOf( + "https://hubcloud.cx/drive/admin", + "https://t.me/hubcloudreport", + "https://one.one.one.one/" + ) + assertNull(pickHubCloudDirectLink(hrefs)) + } + + // --- redactUrlForLog ---------------------------------------------------- + + @Test + fun `redaction strips query string with its tokens`() { + assertEquals( + "https://x.r2.cloudflarestorage.com/hub2/y?…", + redactUrlForLog("https://x.r2.cloudflarestorage.com/hub2/y?X-Amz-Signature=secret&X-Amz-Expires=28800") + ) + } + + @Test + fun `redaction keeps a url without query untouched`() { + assertEquals("https://hubcloud.cx/drive/abc", redactUrlForLog("https://hubcloud.cx/drive/abc")) + assertEquals("", redactUrlForLog(null)) + assertEquals("", redactUrlForLog(" ")) + } +} From 4293ee46e957568c377cbe46c49c1daa5574940b Mon Sep 17 00:00:00 2001 From: test01203 Date: Sun, 9 Aug 2026 10:38:12 +0200 Subject: [PATCH 7/7] fix(streams): harden HubCloud resolution fallbacks --- .../tv/data/repository/StreamRepository.kt | 27 ++-- .../tv/ui/screens/player/PlayerViewModel.kt | 122 ++++++++++++------ .../data/repository/HubCloudResolverTest.kt | 14 +- 3 files changed, 112 insertions(+), 51 deletions(-) diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt index e9b3c743..bf80487b 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt @@ -200,6 +200,7 @@ internal fun usesSlowAggregatorTimeout(addon: Addon): Boolean { // `hubcloud.evil.com` — which merely *contains* "hubcloud" — is not treated as one // of ours. TLDs vary (.cx/.ist/.one/.dev), so we key on the label, not the domain. internal val HUB_DOMAIN_LABELS = setOf("hubcloud", "hubdrive", "hubcdn", "gamerxyt") +internal val HUB_GATED_DOMAIN_LABELS = setOf("hubcloud", "hubdrive") /** Second-level label of a host: hubcloud.cx -> "hubcloud", pixel.hubcloud.cx -> "hubcloud". */ internal fun registrableLabel(host: String): String { @@ -210,12 +211,18 @@ internal fun registrableLabel(host: String): String { } } +/** Exact HubCloud/HubDrive label for a host, or null for unrelated/look-alike hosts. */ +internal fun gatedHubHostLabel(host: String): String? { + val label = registrableLabel(host.lowercase(Locale.US).removePrefix("www.")) + return label.takeIf(HUB_GATED_DOMAIN_LABELS::contains) +} + /** True when the URL is a resolvable HubCloud/HubDrive *page* (not a direct file endpoint). */ internal fun isHubCloudPageUrl(url: String): Boolean { - val parsed = runCatching { java.net.URI(url) }.getOrNull() ?: return false + // Stream URLs may append request headers after `|`; classify the URL portion only. + val parsed = runCatching { java.net.URI(url.substringBefore('|').trim()) }.getOrNull() ?: return false val host = parsed.host?.lowercase(Locale.US)?.removePrefix("www.").orEmpty() - val label = registrableLabel(host) - if (label != "hubcloud" && label != "hubdrive") return false + if (gatedHubHostLabel(host) == null) return false val path = parsed.path?.lowercase(Locale.US).orEmpty() // Direct file endpoints on the same domain (e.g. pixel.hubcloud.cx/?id=...) have // no such path and are left as-is. @@ -226,14 +233,12 @@ internal fun isHubCloudPageUrl(url: String): Boolean { /** * True for anti-leech landing pages that carry the real file in a ?link=/?url= * parameter. Gated so a legitimate proxy/auth URL with such a parameter is never - * rewritten: host is matched by exact registrable label, `/dl.php` is a separate - * path signal for generic wrapper hosts. + * rewritten: the host must match an explicitly supported registrable label. */ internal fun isEmbeddedLinkLandingHost(url: String): Boolean { val parsed = runCatching { java.net.URI(url) }.getOrNull() ?: return false val host = parsed.host?.lowercase(Locale.US)?.removePrefix("www.").orEmpty() - val path = parsed.path?.lowercase(Locale.US).orEmpty() - return HUB_DOMAIN_LABELS.contains(registrableLabel(host)) || path.contains("/dl.php") + return HUB_DOMAIN_LABELS.contains(registrableLabel(host)) } /** @@ -3205,7 +3210,7 @@ class StreamRepository @Inject constructor( host.contains("mediafusion", ignoreCase = true) || host.contains("stremthru", ignoreCase = true) || host.contains("jackettio", ignoreCase = true) || - GATED_HOST_DEFAULT_REFERERS.keys.any { host.contains(it) } + gatedHubHostLabel(host) != null } // HubCloud-style "10Gbps" links redirect through a Cloudflare Worker and land on @@ -3727,10 +3732,8 @@ class StreamRepository @Inject constructor( private fun defaultHeadersForGatedHost(url: String): Map { val host = runCatching { java.net.URI(url).host?.lowercase(Locale.US) }.getOrNull().orEmpty() if (host.isBlank()) return emptyMap() - val referer = GATED_HOST_DEFAULT_REFERERS.entries - .firstOrNull { (marker, _) -> host.contains(marker) } - ?.value - ?: return emptyMap() + val label = gatedHubHostLabel(host) ?: return emptyMap() + val referer = GATED_HOST_DEFAULT_REFERERS[label] ?: return emptyMap() val origin = deriveOriginFromReferer(referer) ?: referer.trimEnd('/') return mapOf("Referer" to referer, "Origin" to origin) } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerViewModel.kt index 330da96d..42b0a900 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerViewModel.kt @@ -23,6 +23,7 @@ import com.arflix.tv.data.repository.ProfileManager import com.arflix.tv.data.repository.SkipInterval import com.arflix.tv.data.repository.SkipIntroRepository import com.arflix.tv.data.repository.StreamRepository +import com.arflix.tv.data.repository.isHubCloudPageUrl import com.arflix.tv.data.repository.providerScopedStreamIdentity import com.arflix.tv.data.repository.CloudSyncRepository import com.arflix.tv.data.repository.LauncherContinueWatchingRepository @@ -589,38 +590,65 @@ class PlayerViewModel @Inject constructor( subtitlePreloadComplete = normalizeLanguage(preferredSub).isBlank() ) - // If stream URL provided, use it directly (except magnet links, which require resolution). - if (providedStreamUrl != null) { - val resumeData = resolveResumeData( - mediaType = mediaType, - mediaId = mediaId, - seasonNumber = seasonNumber, - episodeNumber = episodeNumber, - navigationStartPositionMs = startPositionMs - ) - val isMagnet = providedStreamUrl.startsWith("magnet:", ignoreCase = true) - val providedStream = if (isMagnet) { - null - } else { + val providedIsMagnet = providedStreamUrl?.startsWith("magnet:", ignoreCase = true) == true + val providedStreamCandidate = providedStreamUrl + ?.takeUnless { providedIsMagnet } + ?.let { url -> StreamSource( source = currentPreferredSourceName ?: "Selected source", addonName = currentPreferredAddonId ?: "", addonId = currentPreferredAddonId.orEmpty(), quality = "", size = "", - url = providedStreamUrl + url = url ) } + val providedIsHubPage = providedStreamCandidate?.url + ?.let(::isHubCloudPageUrl) == true + val preResolvedHubStream = if (providedIsHubPage) { + _uiState.value = _uiState.value.copy(streamLoadPhase = "Preparing stream") + providedStreamCandidate?.let { stream -> + runCatching { streamRepository.resolveStreamForPlayback(stream) }.getOrNull() + } + } else { + null + } + if (providedIsHubPage && preResolvedHubStream == null) { + AppLogger.breadcrumb( + tag = "Sources", + message = "provided_hubcloud_unresolved_falling_back_to_source_search", + severity = "warning" + ) + } + val effectiveProvidedStreamUrl = when { + preResolvedHubStream != null -> preResolvedHubStream.url + providedIsHubPage -> null + else -> providedStreamUrl + } + + // If a playable stream URL was provided, use it directly. An unresolved HubCloud + // page deliberately falls through to normal source discovery instead of playing HTML. + if (effectiveProvidedStreamUrl != null) { + val resumeData = resolveResumeData( + mediaType = mediaType, + mediaId = mediaId, + seasonNumber = seasonNumber, + episodeNumber = episodeNumber, + navigationStartPositionMs = startPositionMs + ) + val isMagnet = providedIsMagnet + val providedStream = preResolvedHubStream ?: providedStreamCandidate // Show a status while the debrid/source link resolves. Without this the initial-play // path sat 5-10s with no overlay text (selectedStreamUrl not set yet, so startupPhase // is gated off), unlike the manual selectStream() path which already labels this step. if (providedStream != null) { _uiState.value = _uiState.value.copy(streamLoadPhase = "Preparing stream") } - val resolvedProvidedStream = providedStream?.let { stream -> + val resolvedProvidedStream = preResolvedHubStream ?: providedStream?.let { stream -> runCatching { streamRepository.resolveStreamForPlayback(stream) }.getOrNull() ?: stream } - val resolvedProvidedUrl = resolvedProvidedStream?.url ?: if (isMagnet) null else providedStreamUrl + val resolvedProvidedUrl = resolvedProvidedStream?.url + ?: if (isMagnet) null else effectiveProvidedStreamUrl playbackDiag( "providedStream resolved=${streamDiag(resolvedProvidedStream)} " + "host=${hostFromUrl(resolvedProvidedUrl)}" @@ -2268,7 +2296,7 @@ class PlayerViewModel @Inject constructor( streamSelectionJob = viewModelScope.launch { val selectionStartMs = System.currentTimeMillis() val requestedResumePosition = resumePositionMs?.coerceAtLeast(0L) - val selectedOriginal = stream + var selectedOriginal = stream playbackDiag("selectStream request=${streamDiag(stream)}") _uiState.value = _uiState.value.copy( selectedStream = stream, @@ -2288,7 +2316,29 @@ class PlayerViewModel @Inject constructor( context = playbackDiagnosticContext("manual_stream_resolve_exception", stream) ) } - val resolvedStream = resolvedResult.getOrNull() ?: stream + val directResolvedStream = resolvedResult.getOrNull() + val selection = when { + directResolvedStream != null -> ReachableStreamSelection(stream, directResolvedStream) + isHubCloudPageUrl(stream.url.orEmpty()) -> findFirstResolvableAlternative(stream) + else -> ReachableStreamSelection(stream, stream) + } + if (selection == null) { + AppLogger.recordException( + throwable = IllegalStateException("HubCloud source could not be resolved"), + context = playbackDiagnosticContext("selected_hubcloud_unresolved", stream) + ) + _uiState.value = _uiState.value.copy( + isLoading = false, + isLoadingStreams = false, + sourceSearchActive = false, + streamProgress = null, + streamLoadPhase = null, + error = "Failed to resolve stream. Try another source." + ) + return@launch + } + selectedOriginal = selection.original + val resolvedStream = selection.resolved val resolveMs = System.currentTimeMillis() - selectionStartMs val url = resolvedStream.url if (url.isNullOrBlank()) { @@ -2342,7 +2392,7 @@ class PlayerViewModel @Inject constructor( } // Merge stream's embedded subtitles with existing subtitles - val streamSubs = stream.subtitles + val streamSubs = selectedOriginal.subtitles if (streamSubs.isNotEmpty()) { val existingSubs = _uiState.value.subtitles val newSubs = streamSubs.filter { newSub -> @@ -2434,41 +2484,37 @@ class PlayerViewModel @Inject constructor( } } - private suspend fun findFirstReachableStreamInAddon( + private suspend fun findFirstResolvableAlternative( selected: StreamSource, - maxAttempts: Int = 8 + maxAttempts: Int = 4 ): ReachableStreamSelection? { val streams = _uiState.value.streams if (streams.isEmpty()) return null - val selectedIndex = streams.indexOf(selected).takeIf { it >= 0 } ?: 0 - val candidateIndexes = (0 until streams.size) - .map { offset -> (selectedIndex + offset) % streams.size } + val selectedIndex = streams.indexOf(selected) + val candidateIndexes = if (selectedIndex >= 0) { + (1 until streams.size).map { offset -> (selectedIndex + offset) % streams.size } + } else { + streams.indices.toList() + } val candidates = candidateIndexes .map { idx -> streams[idx] } .filter { candidate -> - candidate.addonId == selected.addonId && - !candidate.url.isNullOrBlank() + !candidate.url.isNullOrBlank() } .take(maxAttempts) for (candidate in candidates) { val resolved = runCatching { streamRepository.resolveStreamForPlayback(candidate) - }.getOrNull() ?: candidate - - val candidateUrl = resolved.url?.trim().orEmpty() - if (candidateUrl.isBlank()) continue - if (!(candidateUrl.startsWith("http://", true) || candidateUrl.startsWith("https://", true))) { - return ReachableStreamSelection(original = candidate, resolved = resolved) - } + }.getOrNull() + if (resolved != null) return ReachableStreamSelection(candidate, resolved) - val reachable = runCatching { - streamRepository.isHttpStreamReachable(resolved) - }.getOrDefault(false) - if (reachable) { - return ReachableStreamSelection(original = candidate, resolved = resolved) + // Preserve the existing raw-URL fallback for ordinary HTTP streams, but never + // hand another unresolved HubCloud HTML page to ExoPlayer. + if (!isHubCloudPageUrl(candidate.url.orEmpty())) { + return ReachableStreamSelection(candidate, candidate) } } return null diff --git a/app/src/test/kotlin/com/arflix/tv/data/repository/HubCloudResolverTest.kt b/app/src/test/kotlin/com/arflix/tv/data/repository/HubCloudResolverTest.kt index 677e1748..50e3e188 100644 --- a/app/src/test/kotlin/com/arflix/tv/data/repository/HubCloudResolverTest.kt +++ b/app/src/test/kotlin/com/arflix/tv/data/repository/HubCloudResolverTest.kt @@ -23,6 +23,15 @@ class HubCloudResolverTest { assertEquals("evil", registrableLabel("hubcloud.evil.com")) } + @Test + fun `gated host label rejects substring look-alikes`() { + assertEquals("hubcloud", gatedHubHostLabel("hubcloud.cx")) + assertEquals("hubcloud", gatedHubHostLabel("pixel.hubcloud.cx")) + assertEquals("hubdrive", gatedHubHostLabel("www.hubdrive.dev")) + assertNull(gatedHubHostLabel("hubcloud.evil.com")) + assertNull(gatedHubHostLabel("not-hubcloud.com")) + } + // --- isHubCloudPageUrl -------------------------------------------------- @Test @@ -30,6 +39,7 @@ class HubCloudResolverTest { assertTrue(isHubCloudPageUrl("https://hubcloud.cx/drive/abc123")) assertTrue(isHubCloudPageUrl("https://hubcloud.ist/video/xyz")) assertTrue(isHubCloudPageUrl("https://hubdrive.dev/file/def")) + assertTrue(isHubCloudPageUrl("https://hubcloud.cx/drive/abc123|Referer=https%3A%2F%2Fhubcloud.cx")) } @Test @@ -56,8 +66,8 @@ class HubCloudResolverTest { @Test fun `landing hosts are recognised by exact label`() { assertTrue(isEmbeddedLinkLandingHost("https://gamerxyt.com/hubcloud.php?id=1")) + assertTrue(isEmbeddedLinkLandingHost("https://gamerxyt.com/dl.php?link=x")) assertTrue(isEmbeddedLinkLandingHost("https://hubcloud.cx/drive/admin")) - assertTrue(isEmbeddedLinkLandingHost("https://any.host.example/dl.php?link=x")) } @Test @@ -66,6 +76,8 @@ class HubCloudResolverTest { // carries ?url=/?link= must not be classified as a landing page. assertFalse(isEmbeddedLinkLandingHost("https://myproxy.example.com/stream?url=https://cdn/x.mkv")) assertFalse(isEmbeddedLinkLandingHost("https://hubcloud.evil.com/go?link=https://cdn/x.mkv")) + assertFalse(isEmbeddedLinkLandingHost("https://any.host.example/dl.php?link=https://cdn/x.mkv")) + assertFalse(isEmbeddedLinkLandingHost("https://hubcloud.evil.com/dl.php?link=https://cdn/x.mkv")) } // --- pickHubCloudDirectLink --------------------------------------------