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..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 @@ -191,6 +191,78 @@ 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") +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 { + val labels = host.split('.').filter { it.isNotEmpty() } + return when { + labels.size >= 2 -> labels[labels.size - 2] + else -> labels.lastOrNull().orEmpty() + } +} + +/** 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 { + // 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() + 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. + 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: 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() + return HUB_DOMAIN_LABELS.contains(registrableLabel(host)) +} + +/** + * 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 @@ -2967,8 +3039,27 @@ 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 + // 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 + // 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", @@ -3118,7 +3209,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) || + gatedHubHostLabel(host) != null + } + + // 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( @@ -3144,7 +3257,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 @@ -3156,6 +3273,80 @@ 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 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) } + } + 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() + } + + 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) + + 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) } @@ -3363,10 +3554,43 @@ 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 ) + // 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=${redactUrlForLog(resolvedUrl)} -> skip source") + return null + } + 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) }) { + 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) }) { + mergeRequestHeaders(base = explicitHeaders, extra = defaultHeadersForGatedHost(resolvedUrl)) + } else { + explicitHeaders + } val mergedBehaviorHints = when { mergedHeaders.isNotEmpty() -> { val current = stream.behaviorHints @@ -3386,11 +3610,22 @@ class StreamRepository @Inject constructor( } else -> stream.behaviorHints } - val playbackUrl = if (shouldResolveRedirectBeforePlayback(resolvedUrl, stream)) { + val willResolveRedirect = shouldResolveRedirectBeforePlayback(resolvedUrl, stream) + Log.w("HubFix", "resolveStreamInternal url=${redactUrlForLog(resolvedUrl)} willRedirect=$willResolveRedirect gatedHeaders=${defaultHeadersForGatedHost(resolvedUrl)}") + val redirectResolvedUrl = if (willResolveRedirect) { resolveRedirectedPlaybackUrl(resolvedUrl, mergedHeaders) } else { resolvedUrl } + // 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 @@ -3493,6 +3728,16 @@ 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 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) + } + private fun deriveOriginFromReferer(referer: String): String? { return runCatching { val parsed = java.net.URI(referer.trim()) 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/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) { 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..50e3e188 --- /dev/null +++ b/app/src/test/kotlin/com/arflix/tv/data/repository/HubCloudResolverTest.kt @@ -0,0 +1,130 @@ +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")) + } + + @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 + 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")) + assertTrue(isHubCloudPageUrl("https://hubcloud.cx/drive/abc123|Referer=https%3A%2F%2Fhubcloud.cx")) + } + + @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://gamerxyt.com/dl.php?link=x")) + assertTrue(isEmbeddedLinkLandingHost("https://hubcloud.cx/drive/admin")) + } + + @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")) + 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 -------------------------------------------- + + @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(" ")) + } +}