feat: full HLS.js support — detection via interceptors + deep probe, playback via hls.js in viewer #2

Merged
kfj001 merged 15 commits from hls-fix into master 2026-08-23 18:22:42 -07:00
3 changed files with 490 additions and 86 deletions
Showing only changes of commit c9f49b58d8 - Show all commits

No files matched your search

+2 -1
View File
@@ -7,7 +7,8 @@
"storage",
"tabs",
"scripting",
"webRequest"
"webRequest",
"declarativeNetRequest"
],
"host_permissions": [
"<all_urls>"
+264 -41
View File
@@ -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 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)) {
const manifestUrl = buildManifestForVariant(url);
if (manifestUrl && !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;
@@ -157,6 +277,69 @@ chrome.tabs.onRemoved.addListener(async (tabId) => {
} 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://<id>`, 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 ===
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
@@ -201,16 +384,56 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
return true;
}
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 });
}
}
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 () => {
const url = message.url;
try {
const resp = await fetch(url);
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) {
sendResponse({ ok: false, status: resp.status });
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();
+224 -44
View File
@@ -56,76 +56,176 @@ const panelsEl = document.getElementById('panels');
/**
* 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().
* (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 () {
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 });
// 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;
}
// 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))));
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;
}
// Simpler approach: use atob on the full string
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(data);
} catch {}
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);
this._onSuccess(config, { response: { headers: {}, body: bytes.buffer }, trequest: Date.now(), tfirst: Date.now(), tload: Date.now(), loadingPeriod: Date.now() - config.trequest });
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 {
// Native XHR for non-proxy URLs
const xhr = new XMLHttpRequest();
this._xhr = xhr;
self._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 });
const data = isManifest
? new TextDecoder().decode(new Uint8Array(xhr.response))
: xhr.response;
fireSuccess(data);
} 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) });
fireError(xhr.status, '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.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();
}
};
@@ -648,9 +748,21 @@ function wireHlsEvents(hls, video, panel) {
state.segmentsFailed++;
state.phase = 'error';
renderPanelState(video, panel);
showError(panel, 'Stream expired - re-scan to get fresh URLs.');
hls.destroy();
hlsInstances.delete(video);
// 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';
@@ -782,12 +894,20 @@ function destroyHls(video) {
/** 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() {
const video = document.createElement('video');
@@ -857,6 +977,10 @@ function playHls(video, manifestUrl, panel) {
// 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);
}
@@ -893,6 +1017,53 @@ function loadHlsJs() {
});
}
/* ======================================================================== */
/* 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.
@@ -1023,8 +1194,17 @@ async function init() {
}
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) {
playHls(video, manifest, panel);
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);
}
}
}