From c9f49b58d85c7b4555643b1293d58a9105eae836 Mon Sep 17 00:00:00 2001 From: kfj001 Date: Thu, 20 Aug 2026 10:59:05 -0700 Subject: [PATCH] Working on pornhub --- manifest.json | 13 +- src/background/background.js | 369 ++++++++++++++++++++++++++++------- src/viewer/viewer.js | 366 +++++++++++++++++++++++++--------- 3 files changed, 576 insertions(+), 172 deletions(-) diff --git a/manifest.json b/manifest.json index da7e907..9258d2a 100644 --- a/manifest.json +++ b/manifest.json @@ -3,12 +3,13 @@ "name": "Goon Plugin", "version": "0.1.0", "description": "A Chrome extension", - "permissions": [ - "storage", - "tabs", - "scripting", - "webRequest" - ], + "permissions": [ + "storage", + "tabs", + "scripting", + "webRequest", + "declarativeNetRequest" + ], "host_permissions": [ "" ], diff --git a/src/background/background.js b/src/background/background.js index 25df114..3f50ce1 100644 --- a/src/background/background.js +++ b/src/background/background.js @@ -59,57 +59,177 @@ async function persistVideoRequest(tabId, url) { } } -// Reconstruct manifest URL from segment URLs -// Segment: https://ev-h.phncdn.com/hls/videos/202008/01/338337051/720P_4000K_338337051.mp4/seg-1-v1-a1.ts?h=tmrD...&e=1786932752&f=1 -// Manifest: https://ev-h.phncdn.com/hls/videos/202008/01/338337051/master.m3u8?h=tmrD...&e=1786932752&f=1 +// --- phncdn token / URL helpers ------------------------------------------ // -// Auth tokens (h, e, f or validfrom, validto, hash) are extracted from segment query params -// and reapplied to the reconstructed manifest URL. Without auth, CDN returns 403. +// The phncdn auth token (h= / hash= / hdnea=) is base64 and its %-encoded +// escapes are significant: a `+` decoded to a space corrupts it, which is what +// produces the 410 Gone the live player avoids by sending `%2B`. So every +// helper below keeps the query string RAW — it is never passed through +// URLSearchParams (which decodes + -> space). + +// Extract the phncdn videoId from a /hls/videos/ URL; '' if it isn't one. +// Path shape: /hls/videos/{year}/{subdir}/{videoId}/{variant}/... +function extractVideoId(url) { + if (!url || !url.includes('/hls/videos/')) return ''; + try { + const path = new URL(url).pathname.split('/'); + const hlsIdx = path.findIndex(p => p === 'hls'); + if (hlsIdx === -1 || hlsIdx + 4 >= path.length) return ''; + return path[hlsIdx + 4]; + } catch { + return ''; + } +} + +// Raw query (everything after '?') with no decoding. '' when absent. +function extractRawQuery(url) { + const idx = url.indexOf('?'); + return idx === -1 ? '' : url.slice(idx + 1); +} + +// Best-effort token expiry (epoch seconds) from a RAW query; 0 = unknown. +// Used to rank tokens freshest-first so rescans pick the one most likely to +// still validate. +function tokenExpirySeconds(rawQuery) { + let secs = 0; + for (const key of ['validto', 'exp', 'e']) { + const m = new RegExp(key + '=(\\d+)').exec(rawQuery); + if (m) { secs = Math.max(secs, parseInt(m[1], 10)); break; } + } + return secs; +} + + // Build a manifest URL for a variant directory from a raw phncdn URL, + // preserving the FULL variant path and the raw query (token encoding intact). + // /hls/videos/{year}/{subid}/{videoId}/{variant}/seg-1.ts -> + // /hls/videos/{year}/{subid}/{videoId}/{variant}/master.m3u8 + // The variant directory (e.g. 720P_4000K_338337051.mp4) is MANDATORY — a master + // playlist lives inside it; dropping it (the old bug) yields a 410. Returns '' + // when the URL isn't a phncdn HLS URL with a complete variant path. + function buildManifestForVariant(rawUrl) { + if (!rawUrl || !rawUrl.includes('/hls/videos/')) return ''; + try { + const path = rawUrl.split('?')[0]; + const parts = path.split('/'); + const hlsIdx = parts.findIndex(p => p === 'hls'); + // Need hls, videos, year, subdir, videoId, variant (6 dirs). + if (hlsIdx === -1 || hlsIdx + 5 >= parts.length) return ''; + const prefix = parts.slice(0, hlsIdx + 2).join('/'); // .../hls/videos + const year = parts[hlsIdx + 2]; + const subid = parts[hlsIdx + 3]; + const videoId = parts[hlsIdx + 4]; + const variant = parts[hlsIdx + 5]; + const rawQuery = extractRawQuery(rawUrl); + return `${prefix}/${year}/${subid}/${videoId}/${variant}/master.m3u8${rawQuery ? '?' + rawQuery : ''}`; + } catch { + return ''; + } +} + +// Reconstruct manifest URLs from segment URLs, keeping the raw query intact. +// The live tab keeps rotating short-lived tokens; each captured segment's query +// is the most recent token seen for that variant. function reconstructManifestsFromSegments(segments) { const seen = new Set(); const manifests = []; for (const url of segments) { - // Only handle HLS segment URLs with the full path: /hls/videos/{year}/{subdir}/{vid}... + // Only handle HLS segment URLs: /hls/videos/{..}/{variant}/seg-N.ts if (!url.includes('/hls/videos/') || !/\/seg-\d+/.test(url)) continue; - try { - const parsed = new URL(url); - const host = parsed.host.toLowerCase(); - // Skip non-phncdn hosts — we only reconstruct for known video CDNs - if (!host.includes('phncdn')) continue; - - const pathParts = parsed.pathname.split('/'); - // Find the /hls/ marker and extract: hls/videos/{year}/{subdir}/{videoId}/{variant}/... - const hlsIdx = pathParts.findIndex(p => p === 'hls'); - if (hlsIdx === -1 || hlsIdx + 5 >= pathParts.length) continue; - // Reconstruct with BOTH "hls" prefix AND variant directory included: - // segment: /hls/videos/202008/01/338337051/720P_4000K_338337051.mp4/seg-1.ts - // manifest: /hls/videos/202008/01/338337051/720P_4000K_338337051.mp4/master.m3u8 - const year = pathParts[hlsIdx + 2]; // e.g. 2020 - const subDir = pathParts[hlsIdx + 3]; // e.g. 01 (sub-category) - const videoId = pathParts[hlsIdx + 4]; // e.g. 338337051 - const variant = pathParts[hlsIdx + 5]; // e.g. 720P_4000K_338337051.mp4 - - // Extract auth params from segment query string — different CDN variants use different keys: - // ev-h/validfrom/validto/hash vs hv-h/h/e/f - const authParts = []; - for (const [key, val] of parsed.searchParams) { - if (['h', 'e', 'f', 'validfrom', 'validto', 'hash'].includes(key)) { - authParts.push(`${key}=${val}`); - } + const manifestUrl = buildManifestForVariant(url); + if (manifestUrl && !seen.has(manifestUrl)) { + seen.add(manifestUrl); + manifests.push(manifestUrl); + } } - const authStr = authParts.length > 0 ? '?' + authParts.join('&') : ''; - - // Build manifest: include hls prefix AND variant directory - const manifestUrl = `${parsed.protocol}//${parsed.host}/${pathParts[hlsIdx]}/${pathParts[hlsIdx + 1]}/${year}/${subDir}/${videoId}/${variant}/master.m3u8${authStr}`; - if (!seen.has(manifestUrl)) { - seen.add(manifestUrl); - manifests.push(manifestUrl); - } - } catch { /* skip malformed */ } - } return manifests; } +// Collect phncdn URLs across ALL tabs without a tabs.query match pattern (which +// throws if the pattern is malformed). We scan every persisted vr_requests_ / +// wr_manifests_ key so the rescan works even if the live source tab is closed. +async function collectAllPhncdnUrls() { + const all = await chrome.storage.local.get(); + const out = []; + for (const key of Object.keys(all)) { + if ((key.startsWith('vr_requests_') || key.startsWith('wr_manifests_')) && Array.isArray(all[key])) { + out.push(...all[key]); + } + } + return out; +} + + // For a videoId, return the freshest live phncdn URL (highest token expiry) + // across all persisted tabs, preferring captured .m3u8 manifest URLs. null if + // nothing usable is captured yet. Rescans use this to replace a stored URL that + // now 410s with the newest token the live tab captured. + // + // No tabs.query match pattern is used: '*pornhub.com*' is an INVALID pattern and + // made this throw ("Invalid url pattern"), which silently dropped every rescan. + // We scan persisted capture data instead, which is always available and + // sidesteps the match-Pattern API entirely. +async function freshLiveForVideo(videoId, preferManifest) { + let all; + try { + all = await collectAllPhncdnUrls(); + } catch (e) { + console.warn('[goon-bg] collectAllPhncdnUrls failed:', e && e.message ? e.message : e); + all = []; + } + + let bestManifest = null, bestManifestExp = 0; + let bestVariant = null, bestVariantExp = 0; + let bestTokenSource = null, bestTokenExp = 0; + for (const url of all) { + if (extractVideoId(url) !== videoId) continue; + const exp = tokenExpirySeconds(extractRawQuery(url)); + if (url.includes('.m3u8')) { + if (exp > bestManifestExp) { bestManifestExp = exp; bestManifest = url; } + } else if (/\/seg-\d+/.test(url)) { + if (exp > bestVariantExp) { bestVariantExp = exp; bestVariant = url; } + } else { + if (exp > bestTokenExp) { bestTokenExp = exp; bestTokenSource = url; } + } + } + + // Prefer a captured manifest; else reconstruct the variant manifest; else + // hand back a token source so the proxy can at least re-token a good path. + if (bestManifest) return bestManifest; + if (!preferManifest && bestVariant) return bestVariant; + if (!preferManifest && bestTokenSource) return bestTokenSource; + if (bestVariant) return bestVariant; + return bestManifest || bestTokenSource || null; + } + +// Cache freshest tokens per videoId so we don't query storage on every proxy +// request. Keyed by videoId; values resolve once and are reused until a rescan +// (GOON_RESOLVE_MANIFEST) or a 410 forces an invalidation. +const tokenCache = new Map(); + +// Given a phncdn /hls/videos/ URL that arrives WITHOUT a query (hls.js drops the +// relative-URL token when building sub-playslists/segments), inject the freshest +// live token for that videoId. Returns the URL with a query appended, or the +// original URL unchanged when no token is available yet. +async function injectFreshToken(url) { + if (!url || !url.includes('/hls/videos/')) return url; + // Already carries a token — leave it alone. + if (url.includes('?')) return url; + const videoId = extractVideoId(url); + if (!videoId) return url; + if (tokenCache.has(videoId)) { + const token = tokenCache.get(videoId); + return token ? url + '?' + token : url; + } + const fresh = await freshLiveForVideo(videoId, false); + const token = fresh ? extractRawQuery(fresh) : ''; + tokenCache.set(videoId, token); + console.log('[goon-bg] injectFreshToken for', videoId, videoId + '-> ' + (token ? token.substring(0, 40) + '…' : '(none captured)')); + return token ? url + '?' + token : url; +} + +// Clear the per-video token cache. Called on rescan so the next proxy fetches +// re-query storage for a freshly-rotated token. +function invalidateTokenCache() { tokenCache.clear(); } + chrome.webRequest.onBeforeRequest.addListener( async (details) => { if (details.tabId < 0) return; @@ -154,8 +274,71 @@ chrome.tabs.onRemoved.addListener(async (tabId) => { const keys = ['wr_manifests_' + tabId, 'vr_requests_' + tabId]; try { await chrome.storage.local.remove(keys); - } catch { /* ignore */ } -}); + } catch { /* ignore */ } + }); + +// === Force a pornhub Referer on every *.phncdn.com request === +// The phncdn video CDN (hv-h.phncdn.com, pix-fl.phncdn.com, ev-h.phncdn.com, +// ... and the auth-token hosts) rejects any request whose Referer is not a +// pornhub-origin with HTTP 412 (see round-1 curl test: `Referer: +// https://www.pornhub.com/` -> 200, no referer -> 412). Origin is NOT the +// gate (curl with a pornhub Referer and no Origin still 200s), so we only +// rewrite Referer and never touch Origin. +// +// A normal fetch() CANNOT set the `Referer` header — it is a "forbidden +// header" in the Fetch API, so a service-worker fetch() gets +// `Referer: chrome-extension://`, which the CDN rejects (412). The +// declarativeNetRequest API rewrites request headers at the network layer, +// below the Fetch API's forbidden-header restriction, and — per Chrome docs — +// these rules DO apply to fetch() calls made inside the service worker. So we +// force a pornhub referer on phncdn.com; the GOON_PROXY_FETCH handler in the +// SW then fetches the manifest + segments successfully. +const REFERER_RULE_ID = 9001; +const REFERER_RULE = { + id: REFERER_RULE_ID, + priority: 1, + action: { + type: 'modifyHeaders', + // `set` is allowed for any request header (only `append` is restricted to + // a fixed allowlist). `referer` is the header the CDN checks. + requestHeaders: [ + { header: 'referer', operation: 'set', value: 'https://www.pornhub.com/' }, + ], + }, + condition: { + // phncdn.com (host + subdomains) ONLY. Do NOT include pornhub.com: the + // real page player's auth calls (api.pornhub.com) and its own segment + // fetches must keep their browser-supplied Origin/Referer or playback + // freezes. The proxy only ever fetches phncdn URLs. + requestDomains: ['phncdn.com'], + }, + }; + +// Cached promise so concurrent proxy fetches share one rule install; a +// transient failure is cleared so a later fetch retries. +let refererRuleReady = null; +async function ensureRefererRule() { + if (refererRuleReady) return refererRuleReady; + refererRuleReady = (async () => { + const api = chrome.declarativeNetRequest; + if (!api || typeof api.updateSessionRules !== 'function') { + throw new Error('declarativeNetRequest.updateSessionRules unavailable'); + } + // Idempotent: remove any stale copy of our rule, then add the fresh one. + await api.updateSessionRules({ + removeRuleIds: [REFERER_RULE_ID], + addRules: [REFERER_RULE], + }); + console.log('[goon-bg] DNR referer rule installed for *.phncdn.com'); + })(); + // If it failed, reset so the next call retries instead of caching the + // rejected promise forever. + refererRuleReady.catch(() => { refererRuleReady = null; }); + return refererRuleReady; + } + +// Install eagerly on SW start so even the first proxy fetch is covered. +ensureRefererRule(); // === Message handlers for popup/data consumers === @@ -197,40 +380,80 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { } catch(e) { sendResponse({ segments: [], reconstructedManifests: [], totalRequests: 0 }); } - })(); + })(); return true; - } + } - case 'GOON_PROXY_FETCH': { - // Proxy a fetch request through the service worker (bypasses CORS). - // Returns base64-encoded response data. Used by the viewer when - // extension-origin requests to video CDNs are blocked by CORS. - (async () => { - const url = message.url; - try { - const resp = await fetch(url); - if (!resp.ok) { - sendResponse({ ok: false, status: resp.status }); - return; + case 'GOON_RESOLVE_MANIFEST': { + // Resolve the freshest live phncdn manifest for the video in a stored + // URL. The viewer calls this at startup and on a fatal network error: + // the live tab keeps rotating short-lived tokens, so the freshest + // captured manifest (highest expiry) replaces one that has 410'd. + // Clears the token cache first so subsequent proxy fetches re-query for + // the freshly-rotated token rather than the cached (now-dead) one. + async function resolveFreshManifest(message, sendResponse) { + invalidateTokenCache(); + const url = message.url || ''; + const videoId = extractVideoId(url); + if (!videoId) { sendResponse({ ok: false, error: 'no videoId' }); return; } + try { + const fresh = await freshLiveForVideo(videoId, true); + sendResponse({ ok: true, fresh, videoId, changed: !!(fresh && fresh !== url) }); + } catch (e) { + console.warn('[goon-bg] resolveFreshManifest failed:', e && e.message ? e.message : e); + sendResponse({ ok: false, error: e && e.message ? e.message : String(e), videoId }); } - const buffer = await resp.arrayBuffer(); - const bytes = new Uint8Array(buffer); - // Convert to base64 (btoa can only handle strings up to ~65k chars) - let binary = ''; - const CHUNK_SIZE = 32768; - for (let i = 0; i < bytes.length; i += CHUNK_SIZE) { - const chunk = bytes.subarray(i, i + CHUNK_SIZE); - binary += String.fromCharCode.apply(null, Array.from(chunk)); - } - const base64 = btoa(binary); - sendResponse({ ok: true, data: base64, mimeType: resp.headers.get('content-type') || '' }); - } catch(e) { - console.warn('[goon-bg] proxyFetch failed:', e && e.message ? e.message : e); - sendResponse({ ok: false, error: e && e.message ? e.message : String(e) }); } - })(); - return true; - } + resolveFreshManifest(message, sendResponse); + return true; + } + + case 'GOON_PROXY_FETCH': { + // Proxy a fetch request through the service worker (bypasses CORS). + // Returns base64-encoded response data. Used by the viewer when + // extension-origin requests to video CDNs are blocked by CORS. + // + // The phncdn CDN 412s requests without a pornhub Referer. A fetch() + // can't set Referer itself (forbidden header — Chrome replaces it with + // chrome-extension://...), so we install a declarativeNetRequest rule + // that forces `Referer: https://www.pornhub.com/` at the network layer + // for all *.phncdn.com requests (DNR applies to SW fetch() per Chrome + // docs). Await it first so the very next fetch is covered. + (async () => { + try { + await ensureRefererRule(); + // hls.js resolves sub-playslists/segments relative to the master + // and can DROP the token query, handing us a token-less phncdn + // URL that 410s. injectFreshToken supplies the freshest live token + // for that videoId; the final `+` -> `%2B` pass keeps the + // (possibly injected or surviving) base64 token intact for the + // CDN's query parsing. + const resolved = await injectFreshToken(message.url || ''); + const proxyUrl = resolved.replace(/\+/g, '%2B'); + const resp = await fetch(proxyUrl); + if (!resp.ok) { + console.warn('[goon-bg] proxyFetch', resp.status, message.url.substring(0, 120)); + sendResponse({ ok: false, status: resp.status, error: 'HTTP ' + resp.status }); + return; + } + const buffer = await resp.arrayBuffer(); + const bytes = new Uint8Array(buffer); + // Convert to base64 (btoa can only handle strings up to ~65k chars) + let binary = ''; + const CHUNK_SIZE = 32768; + for (let i = 0; i < bytes.length; i += CHUNK_SIZE) { + const chunk = bytes.subarray(i, i + CHUNK_SIZE); + binary += String.fromCharCode.apply(null, Array.from(chunk)); + } + const base64 = btoa(binary); + sendResponse({ ok: true, data: base64, mimeType: resp.headers.get('content-type') || '' }); + } catch(e) { + console.warn('[goon-bg] proxyFetch failed:', e && e.message ? e.message : e); + sendResponse({ ok: false, error: e && e.message ? e.message : String(e) }); + } + })(); + return true; + } default: console.warn('[goon-plugin] Unknown message type:', message.type); diff --git a/src/viewer/viewer.js b/src/viewer/viewer.js index 9920093..7a84ef6 100644 --- a/src/viewer/viewer.js +++ b/src/viewer/viewer.js @@ -54,80 +54,180 @@ const panelsEl = document.getElementById('panels'); /** Track all Hls instances so we can destroy them on unload. */ -/** - * Custom HLS loader that proxies phncdn.com URLs through the service worker - * (bypassing CORS) and fetches everything else natively. - * Implements hls.js Loader interface: load(config), abort(). - */ -function HlsLoader(config) { - this._config = config; - this._xhr = null; -} + /** + * Custom HLS loader that proxies phncdn.com URLs through the service worker + * (bypassing CORS/Referer restrictions) and fetches everything else natively. + * + * hls.js creates one loader instance per request via `new HlsLoader(hls)`, then + * calls `loader.load(context, config, callbacks)` — callbacks are the 3rd arg + * in v9 (some wrapper paths put them 2nd, so we scan each argument). The stats + * object passed to the callbacks is pre-allocated by us and mutated by hls.js + * in place, so it must have the correct sub-shape (see createLoadedStats). + */ + function HlsLoader(config) { + this._config = config; + this._xhr = null; + // hls.js's loadFragment does `t.stats = h.stats` BEFORE calling h.load(), + // so the LoadedStats object must exist on the instance at construction. + // Without it every fragment's `stats` becomes undefined and hls.js's + // auto-level `_abandonRulesCheck` crashes on `o.loading.start`. + this.stats = createLoadedStats(); + } -HlsLoader.prototype.abort = function () { - if (this._xhr) { this._xhr.abort(); this._xhr = null; } -}; + HlsLoader.prototype.abort = function () { + this._aborted = true; + if (this._xhr) { this._xhr.abort(); this._xhr = null; } + }; -HlsLoader.prototype.load = function (config) { - this.abort(); - this._loaderContext = config.type + ': ' + (config.url || '').substring(0, 100); - this._onSuccess = config.onSuccess; - this._onError = config.onError; - this._onTimeout = config.onTimeout; - this._timeout = config.timeout || 10000; + /** + * Build a hls.js-compatible LoadedStats object. hls.js's loader `load()` + * pre-allocates one of these and passes it (by reference) as the 2nd arg to + * onSuccess/onError/onTimeout; hls.js then mutates its sub-objects + * (`stats.parsing.start`, `stats.loading.first`, ...). A flat object crashes + * with "Cannot set properties of undefined (setting 'start')". + */ + function createLoadedStats() { + return { + aborted: false, + loaded: 0, + retry: 0, + total: 0, + chunkCount: 0, + bwEstimate: 0, + loading: { start: 0, first: 0, end: 0 }, + parsing: { start: 0, end: 0 }, + buffering: { start: 0, first: 0, end: 0 }, + }; + } - const url = config.url; - const needProxy = url.includes('phncdn') || url.includes('pornhubcdn'); + HlsLoader.prototype.load = function (context, loaderConfig, callbacksArg) { + const self = this; + self._aborted = false; - if (needProxy) { - // Use service worker proxy to bypass CORS - console.log('[goon-viewer] Proxying via SW:', url.substring(0, 120)); - chrome.runtime.sendMessage({ type: 'GOON_PROXY_FETCH', url }, (resp) => { - if (chrome.runtime.lastError || !resp || !resp.ok) { - const err = new Error('Proxy fetch failed: ' + (chrome.runtime.lastError?.message || resp?.error || 'unknown')); - console.error('[goon-viewer] Proxy error:', err.message); - this._onError(config, { url, context: this._loaderContext, body: null, stats: { trequest: Date.now(), tfirst: 0, tload: Date.now(), loadingPeriod: 0 }, error: err }); - return; - } - // Decode base64 → ArrayBuffer - let binary = ''; - const data = resp.data || ''; - const CHUNK = 32768; - for (let i = 0; i < data.length; i += CHUNK) { - binary += String.fromCharCode.apply(null, Array.from(data.subarray?.(i, i + CHUNK) || new Uint8Array().subarray(0, Math.min(CHUNK, data.length - i)))); - } - // Simpler approach: use atob on the full string - try { - binary = atob(data); - } catch {} - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); - this._onSuccess(config, { response: { headers: {}, body: bytes.buffer }, trequest: Date.now(), tfirst: Date.now(), tload: Date.now(), loadingPeriod: Date.now() - config.trequest }); - }); - } else { - // Native XHR for non-proxy URLs - const xhr = new XMLHttpRequest(); - this._xhr = xhr; - xhr.open('GET', url, true); - xhr.withCredentials = false; - xhr.onload = () => { - if (xhr.status >= 200 && xhr.status < 300) { - this._onSuccess(config, { response: { headers: xhr.getAllResponseHeaders(), body: xhr.response }, trequest: config.trequest, tfirst: Date.now(), tload: Date.now(), loadingPeriod: Date.now() - config.trequest }); + // hls.js invokes loaders as load(context, config, callbacks) in v9, but + // some paths/wrappers pass callbacks as the 2nd arg. Scan every argument + // for the object carrying {onSuccess,onError,onTimeout} so it works either + // way. + const args = [callbacksArg, loaderConfig, context].filter(Boolean); + let cb = null; + for (const c of args) { + if (c && (typeof c.onSuccess === 'function' || typeof c.onError === 'function' || typeof c.onTimeout === 'function')) { + cb = c; + break; + } + } + const onSuccess = cb && typeof cb.onSuccess === 'function' ? cb.onSuccess : null; + const onError = cb && typeof cb.onError === 'function' ? cb.onError : null; + const onTimeout = cb && typeof cb.onTimeout === 'function' ? cb.onTimeout : null; + + // `context` carries the request URL/type; `loaderConfig` may carry a + // per-loader timeout. Fall back defensively. + const url = (context && context.url) || (loaderConfig && loaderConfig.url); + self._loaderContext = ((context && context.type) || '?') + ': ' + (url || '').substring(0, 100); + self._timeout = (loaderConfig && loaderConfig.timeout) || (context && context.timeout) || 10000; + + const trequest = Date.now(); + // Reuse this.stats: hls.js already copied this reference onto every + // fragment via `t.stats = h.stats` before load(), so the object we + // mutate here is the very one `_abandonRulesCheck` reads. + const stats = this.stats; + stats.loading.start = trequest; + + const isManifest = /\.m3u8($|\?)/i.test(url || ''); + const needProxy = (url || '').includes('phncdn') || (url || '').includes('pornhubcdn'); + + // hls.js loader callback contract (verified against the bundled loader): + // onSuccess(response, stats, context, networkDetails) + // onError(response, stats, context, networkDetails) + // onTimeout(response, stats, context) + // `response` is {url, data, code, headers}; `data` is a *string* for + // .m3u8 manifests (hls.js runs indexOf/regex on it) and an ArrayBuffer + // for .ts segments. hls.js mutates `stats` and `context` in place, so we + // pass the SAME object references we received — passing a timestamp there + // caused "Cannot create property 'levelDetails' on number". + const fireSuccess = (data) => { + if (!onSuccess) { + console.warn('[goon-viewer] no onSuccess callback for', (url || '').substring(0, 100)); + return; + } + const tLoad = Date.now(); + stats.loading.first = tLoad; + stats.loading.end = tLoad; + stats.loaded = (data instanceof ArrayBuffer ? data.byteLength : (data && data.length) || 0); + onSuccess({ url, data, code: 200, headers: {} }, stats, context, null); + }; + + const fireError = (code, text) => { + if (!onError) { + console.error('[goon-viewer] no onError callback; error:', text || code, (url || '').substring(0, 100)); + return; + } + stats.loading.end = Date.now(); + console.warn('[goon-viewer] HlsLoader error:', code, text, (url || '').substring(0, 100)); + onError({ code: code || 0, text: text || '', url, headers: {}, data: null }, stats, context, null); + }; + + const fireTimeout = () => { + if (!onTimeout) return; + onTimeout({ url, code: 0, text: 'timeout', headers: {}, data: null }, stats, context); + }; + + if (needProxy) { + // Route through the service worker to bypass the CDN's CORS/Referer + // restrictions. The SW adds the required Referer and returns base64. + console.log('[goon-viewer] Proxying via SW:', (url || '').substring(0, 120)); + chrome.runtime.sendMessage({ type: 'GOON_PROXY_FETCH', url }, (resp) => { + if (self._aborted) return; + const lastErr = chrome.runtime.lastError; + if (lastErr || !resp || !resp.ok) { + const status = resp && resp.status ? resp.status : 0; + const text = lastErr && lastErr.message ? lastErr.message : (resp && resp.error) || ('HTTP ' + status || 'unknown'); + fireError(status || 0, text); + return; + } + // resp.data is base64. Decode to a binary string, then to a UTF-8 + // string (manifests) or an ArrayBuffer (segments). + let binary = ''; + try { + binary = atob(resp.data || ''); + } catch { + fireError(0, 'Failed to decode proxy response base64'); + return; + } + if (isManifest) { + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + fireSuccess(new TextDecoder().decode(bytes)); + return; + } + const segBytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) segBytes[i] = binary.charCodeAt(i); + // Pass an ArrayBuffer to match hls.js's default XHR loader. + fireSuccess(segBytes.buffer); + }); } else { - this._onError(config, { url, context: this._loaderContext, body: null, stats: { trequest: config.trequest, tfirst: 0, tload: Date.now(), loadingPeriod: 0 }, error: new Error('HTTP ' + xhr.status) }); - } - }; - xhr.onerror = () => { - this._onError(config, { url, context: this._loaderContext, body: null, stats: { trequest: config.trequest, tfirst: 0, tload: Date.now(), loadingPeriod: 0 }, error: new Error('XHR network error') }); - }; - xhr.ontimeout = () => { - this._onTimeout(config, { url, context: this._loaderContext, stats: { trequest: config.trequest, tfirst: 0, tload: Date.now(), loadingPeriod: 0 } }); - }; - xhr.timeout = this._timeout; - xhr.responseType = needProxy ? 'arraybuffer' : 'arraybuffer'; - xhr.send(); - } + const xhr = new XMLHttpRequest(); + self._xhr = xhr; + xhr.open('GET', url, true); + xhr.withCredentials = false; + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + const data = isManifest + ? new TextDecoder().decode(new Uint8Array(xhr.response)) + : xhr.response; + fireSuccess(data); + } else { + fireError(xhr.status, 'HTTP ' + xhr.status); + } + }; + xhr.onerror = () => fireError(0, 'XHR network error'); + xhr.ontimeout = () => fireTimeout(); + xhr.timeout = self._timeout; + // hls.js requests ArrayBuffer for segments; manifests are decoded above. + xhr.responseType = 'arraybuffer'; + xhr.send(); + } }; HlsLoader.prototype.destroy = function () { this.abort(); }; @@ -233,11 +333,11 @@ function createPanelState() { lastEvent: 'none', lastActivity: 0, errorMessage: null, - // HLS delivers fragments out-of-order, but the browser plays them back - // sequentially. currentTime and duration give the user-facing progress. + // HLS delivers fragments out-of-order, but the browser plays them back + // sequentially. currentTime and duration give the user-facing progress. currentTime: 0, duration: 0, - }; + }; } /* ======================================================================== */ @@ -642,16 +742,28 @@ function wireHlsEvents(hls, video, panel) { return; } - // Fatal errors — attempt recovery before giving up + // Fatal errors — attempt recovery before giving up if (data.type === window.Hls.ErrorTypes.NETWORK_ERROR) { state.errorMessage = `Network: ${data.details}`; state.segmentsFailed++; state.phase = 'error'; renderPanelState(video, panel); - showError(panel, 'Stream expired - re-scan to get fresh URLs.'); hls.destroy(); hlsInstances.delete(video); - } else if (data.type === window.Hls.ErrorTypes.MEDIA_ERROR) { + // A fatal network error on a phncdn stream is almost always an expired + // auth token. The live source tab keeps rotating tokens, so pull a fresh + // manifest from it and retry — capped at a few attempts per panel so a + // tab with no live source doesn't loop forever. + if ((video.dataset.hlsRescans || '0') | 0 < 3) { + video.dataset.hlsRescans = ((video.dataset.hlsRescans || '0') | 0) + 1; + console.log('[goon-viewer] fatal NETWORK_ERROR → auto-rescan attempt #' + video.dataset.hlsRescans); + showError(panel, 'Refreshing stream…'); + rescanAndRetry(video, panel, video.dataset.hlsManifest || ''); + } else { + showError(panel, 'Stream expired - re-scan to get fresh URLs.'); + video.dataset.hlsRescans = '0'; + } + } else if (data.type === window.Hls.ErrorTypes.MEDIA_ERROR) { state.errorMessage = `Media: ${data.details}`; state.phase = 'buffering'; state.lastActivity = Date.now(); @@ -780,13 +892,21 @@ function destroyHls(video) { /* PanelFactory — DOM panel creation */ /* ======================================================================== */ -/** Show an error message inside a panel's video placeholder. */ -function showError(panel, message) { - const err = document.createElement('div'); - err.style.cssText = 'position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:#f87171;background:rgba(0,0,0,0.6);font-family:sans-serif;font-size:13px;text-align:center;padding:12px;'; - err.textContent = message; - panel.appendChild(err); -} + /** Show an error message inside a panel's video placeholder. */ + function showError(panel, message) { + removeErrorOverlay(panel); + const err = document.createElement('div'); + err.className = 'goon-error-overlay'; + err.style.cssText = 'position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:#f87171;background:rgba(0,0,0,0.6);font-family:sans-serif;font-size:13px;text-align:center;padding:12px;'; + err.textContent = message; + panel.appendChild(err); + } + + /** Remove the error overlay from a panel (used before a retry re-plays). */ + function removeErrorOverlay(panel) { + const existing = panel.querySelectorAll('.goon-error-overlay'); + for (const el of existing) el.remove(); + } /** Create a video element with standard autoplay attributes. */ function createVideoElement() { @@ -854,9 +974,13 @@ function playHls(video, manifestUrl, panel) { origDestroy(); }; - // Log the manifest URL being loaded for debugging (helps diagnose playback failures). + // Log the manifest URL being loaded for debugging (helps diagnose playback failures). console.log(`[goon-viewer] Loading HLS manifest:`, manifestUrl.substring(0, 200)); + // Remember the manifest actually loaded so a later auto-rescan can retry from + // the freshest token on the same video (extractVideoId keys off this). + video.dataset.hlsManifest = manifestUrl; + hls.loadSource(manifestUrl); hls.attachMedia(video); } @@ -889,12 +1013,59 @@ function loadHlsJs() { script.onerror = () => { reject(new Error('hlds.js failed to load: resource not found or CSP blocked')); }; - document.head.appendChild(script); - }); + document.head.appendChild(script); + }); } -/** - * Create a single panel (video or empty). + /* ======================================================================== */ + /* Fresh-token resolution — replace expired/revoked phncdn URLs */ + /* ======================================================================== */ + + /** + * Ask the service worker for the freshest live phncdn manifest for the video + * encoded in `storedUrl`. The live source tab keeps rotating short-lived + * phncdn tokens (a stored one may already be 410 Gone), so the freshest + * captured manifest — identified by matching the videoId in the path — is the + * one that will actually validate. Resolves to the new URL, or null when the + * tab captured nothing (or the caller can't reach the SW). + */ + async function resolveFreshManifest(storedUrl) { + if (!storedUrl || !storedUrl.includes('phncdn')) return null; + try { + const resp = await chrome.runtime.sendMessage({ type: 'GOON_RESOLVE_MANIFEST', url: storedUrl }); + if (resp && resp.ok && resp.changed && resp.fresh) { + console.log('[goon-viewer] resolved fresh manifest for', (resp.videoId || '?'), + '→', (resp.fresh || '').substring(0, 100)); + return resp.fresh; + } + } catch (e) { + console.warn('[goon-viewer] resolveFreshManifest failed:', e && e.message ? e.message : e); + } + return null; + } + + /** + * Re-attempt playback on `video` after its stored manifest died (fatal + * network error). Pulls a fresh manifest from the live tab's rotating token + * stream and, if one is found, replays it. No-op (and false) when the tab has + * captured nothing usable yet — the user must let the source tab load a bit + * longer, then re-open. + */ + async function rescanAndRetry(video, panel, originalUrl) { + const fresh = await resolveFreshManifest(originalUrl); + if (!fresh) { + showError(panel, 'Stream expired & no live source — reload the source tab, then re-open viewer.'); + console.warn('[goon-viewer] rescan gave up: no fresh manifest for', (originalUrl || '').substring(0, 100)); + return false; + } + if (panel) removeErrorOverlay(panel); + console.log('[goon-viewer] retrying with fresh manifest:', fresh.substring(0, 120)); + playHls(video, fresh, panel); + return true; + } + + /** + * Create a single panel (video or empty). * Returns { panel, hlsEntry } — hlsEntry is set for deferred HLS playback. */ function createPanel(entry, tag) { @@ -1013,20 +1184,29 @@ async function init() { panelsEl.style.gridTemplateColumns = `repeat(${gridN}, 1fr)`; panelsEl.style.gridTemplateRows = `repeat(${gridN}, 1fr)`; - // Load hls.js and start HLS playback + // Load hls.js and start HLS playback if (hlsEntries.length > 0) { try { await loadHlsJs(); - } catch { + } catch { for (const { panel } of hlsEntries) { showError(panel, 'HLS player unavailable - check network.'); - } + } return; + } + // Before playback, swap each stored phncdn manifest for the freshest live + // one the source tab has captured (a stored token may already be 410 + // Gone). Non-phncdn manifests are left untouched. + for (const { video, manifest, panel } of hlsEntries) { + if (manifest.includes('phncdn')) { + const fresh = await resolveFreshManifest(manifest); + if (fresh) video.dataset.hlsManifest = fresh; + } + } + for (const { video, panel } of hlsEntries) { + playHls(video, video.dataset.hlsManifest, panel); + } } - for (const { video, manifest, panel } of hlsEntries) { - playHls(video, manifest, panel); - } - } } // Clean up storage entry and HLS instances when the viewer is closed