feat: full HLS.js support — detection via interceptors + deep probe, playback via hls.js in viewer #2
No files matched your search
+7
-6
@@ -3,12 +3,13 @@
|
|||||||
"name": "Goon Plugin",
|
"name": "Goon Plugin",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "A Chrome extension",
|
"description": "A Chrome extension",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"storage",
|
"storage",
|
||||||
"tabs",
|
"tabs",
|
||||||
"scripting",
|
"scripting",
|
||||||
"webRequest"
|
"webRequest",
|
||||||
],
|
"declarativeNetRequest"
|
||||||
|
],
|
||||||
"host_permissions": [
|
"host_permissions": [
|
||||||
"<all_urls>"
|
"<all_urls>"
|
||||||
],
|
],
|
||||||
|
|||||||
+296
-73
@@ -59,57 +59,177 @@ async function persistVideoRequest(tabId, url) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reconstruct manifest URL from segment URLs
|
// --- phncdn token / URL helpers ------------------------------------------
|
||||||
// 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
|
|
||||||
//
|
//
|
||||||
// Auth tokens (h, e, f or validfrom, validto, hash) are extracted from segment query params
|
// The phncdn auth token (h= / hash= / hdnea=) is base64 and its %-encoded
|
||||||
// and reapplied to the reconstructed manifest URL. Without auth, CDN returns 403.
|
// 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) {
|
function reconstructManifestsFromSegments(segments) {
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
const manifests = [];
|
const manifests = [];
|
||||||
for (const url of segments) {
|
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;
|
if (!url.includes('/hls/videos/') || !/\/seg-\d+/.test(url)) continue;
|
||||||
try {
|
const manifestUrl = buildManifestForVariant(url);
|
||||||
const parsed = new URL(url);
|
if (manifestUrl && !seen.has(manifestUrl)) {
|
||||||
const host = parsed.host.toLowerCase();
|
seen.add(manifestUrl);
|
||||||
// Skip non-phncdn hosts — we only reconstruct for known video CDNs
|
manifests.push(manifestUrl);
|
||||||
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)) {
|
|
||||||
seen.add(manifestUrl);
|
|
||||||
manifests.push(manifestUrl);
|
|
||||||
}
|
|
||||||
} catch { /* skip malformed */ }
|
|
||||||
}
|
|
||||||
return manifests;
|
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(
|
chrome.webRequest.onBeforeRequest.addListener(
|
||||||
async (details) => {
|
async (details) => {
|
||||||
if (details.tabId < 0) return;
|
if (details.tabId < 0) return;
|
||||||
@@ -154,8 +274,71 @@ chrome.tabs.onRemoved.addListener(async (tabId) => {
|
|||||||
const keys = ['wr_manifests_' + tabId, 'vr_requests_' + tabId];
|
const keys = ['wr_manifests_' + tabId, 'vr_requests_' + tabId];
|
||||||
try {
|
try {
|
||||||
await chrome.storage.local.remove(keys);
|
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://<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 ===
|
// === Message handlers for popup/data consumers ===
|
||||||
|
|
||||||
@@ -197,40 +380,80 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
|||||||
} catch(e) {
|
} catch(e) {
|
||||||
sendResponse({ segments: [], reconstructedManifests: [], totalRequests: 0 });
|
sendResponse({ segments: [], reconstructedManifests: [], totalRequests: 0 });
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'GOON_PROXY_FETCH': {
|
case 'GOON_RESOLVE_MANIFEST': {
|
||||||
// Proxy a fetch request through the service worker (bypasses CORS).
|
// Resolve the freshest live phncdn manifest for the video in a stored
|
||||||
// Returns base64-encoded response data. Used by the viewer when
|
// URL. The viewer calls this at startup and on a fatal network error:
|
||||||
// extension-origin requests to video CDNs are blocked by CORS.
|
// the live tab keeps rotating short-lived tokens, so the freshest
|
||||||
(async () => {
|
// captured manifest (highest expiry) replaces one that has 410'd.
|
||||||
const url = message.url;
|
// Clears the token cache first so subsequent proxy fetches re-query for
|
||||||
try {
|
// the freshly-rotated token rather than the cached (now-dead) one.
|
||||||
const resp = await fetch(url);
|
async function resolveFreshManifest(message, sendResponse) {
|
||||||
if (!resp.ok) {
|
invalidateTokenCache();
|
||||||
sendResponse({ ok: false, status: resp.status });
|
const url = message.url || '';
|
||||||
return;
|
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) });
|
|
||||||
}
|
}
|
||||||
})();
|
resolveFreshManifest(message, sendResponse);
|
||||||
return true;
|
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:
|
default:
|
||||||
console.warn('[goon-plugin] Unknown message type:', message.type);
|
console.warn('[goon-plugin] Unknown message type:', message.type);
|
||||||
|
|||||||
+273
-93
@@ -54,80 +54,180 @@ const panelsEl = document.getElementById('panels');
|
|||||||
|
|
||||||
/** Track all Hls instances so we can destroy them on unload. */
|
/** Track all Hls instances so we can destroy them on unload. */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom HLS loader that proxies phncdn.com URLs through the service worker
|
* Custom HLS loader that proxies phncdn.com URLs through the service worker
|
||||||
* (bypassing CORS) and fetches everything else natively.
|
* (bypassing CORS/Referer restrictions) and fetches everything else natively.
|
||||||
* Implements hls.js Loader interface: load(config), abort().
|
*
|
||||||
*/
|
* hls.js creates one loader instance per request via `new HlsLoader(hls)`, then
|
||||||
function HlsLoader(config) {
|
* calls `loader.load(context, config, callbacks)` — callbacks are the 3rd arg
|
||||||
this._config = config;
|
* in v9 (some wrapper paths put them 2nd, so we scan each argument). The stats
|
||||||
this._xhr = null;
|
* 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 () {
|
HlsLoader.prototype.abort = function () {
|
||||||
if (this._xhr) { this._xhr.abort(); this._xhr = null; }
|
this._aborted = true;
|
||||||
};
|
if (this._xhr) { this._xhr.abort(); this._xhr = null; }
|
||||||
|
};
|
||||||
|
|
||||||
HlsLoader.prototype.load = function (config) {
|
/**
|
||||||
this.abort();
|
* Build a hls.js-compatible LoadedStats object. hls.js's loader `load()`
|
||||||
this._loaderContext = config.type + ': ' + (config.url || '').substring(0, 100);
|
* pre-allocates one of these and passes it (by reference) as the 2nd arg to
|
||||||
this._onSuccess = config.onSuccess;
|
* onSuccess/onError/onTimeout; hls.js then mutates its sub-objects
|
||||||
this._onError = config.onError;
|
* (`stats.parsing.start`, `stats.loading.first`, ...). A flat object crashes
|
||||||
this._onTimeout = config.onTimeout;
|
* with "Cannot set properties of undefined (setting 'start')".
|
||||||
this._timeout = config.timeout || 10000;
|
*/
|
||||||
|
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;
|
HlsLoader.prototype.load = function (context, loaderConfig, callbacksArg) {
|
||||||
const needProxy = url.includes('phncdn') || url.includes('pornhubcdn');
|
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 });
|
// hls.js invokes loaders as load(context, config, callbacks) in v9, but
|
||||||
});
|
// some paths/wrappers pass callbacks as the 2nd arg. Scan every argument
|
||||||
} else {
|
// for the object carrying {onSuccess,onError,onTimeout} so it works either
|
||||||
// Native XHR for non-proxy URLs
|
// way.
|
||||||
const xhr = new XMLHttpRequest();
|
const args = [callbacksArg, loaderConfig, context].filter(Boolean);
|
||||||
this._xhr = xhr;
|
let cb = null;
|
||||||
xhr.open('GET', url, true);
|
for (const c of args) {
|
||||||
xhr.withCredentials = false;
|
if (c && (typeof c.onSuccess === 'function' || typeof c.onError === 'function' || typeof c.onTimeout === 'function')) {
|
||||||
xhr.onload = () => {
|
cb = c;
|
||||||
if (xhr.status >= 200 && xhr.status < 300) {
|
break;
|
||||||
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 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 {
|
} 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) });
|
const xhr = new XMLHttpRequest();
|
||||||
}
|
self._xhr = xhr;
|
||||||
};
|
xhr.open('GET', url, true);
|
||||||
xhr.onerror = () => {
|
xhr.withCredentials = false;
|
||||||
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.onload = () => {
|
||||||
};
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
xhr.ontimeout = () => {
|
const data = isManifest
|
||||||
this._onTimeout(config, { url, context: this._loaderContext, stats: { trequest: config.trequest, tfirst: 0, tload: Date.now(), loadingPeriod: 0 } });
|
? new TextDecoder().decode(new Uint8Array(xhr.response))
|
||||||
};
|
: xhr.response;
|
||||||
xhr.timeout = this._timeout;
|
fireSuccess(data);
|
||||||
xhr.responseType = needProxy ? 'arraybuffer' : 'arraybuffer';
|
} else {
|
||||||
xhr.send();
|
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(); };
|
HlsLoader.prototype.destroy = function () { this.abort(); };
|
||||||
@@ -233,11 +333,11 @@ function createPanelState() {
|
|||||||
lastEvent: 'none',
|
lastEvent: 'none',
|
||||||
lastActivity: 0,
|
lastActivity: 0,
|
||||||
errorMessage: null,
|
errorMessage: null,
|
||||||
// HLS delivers fragments out-of-order, but the browser plays them back
|
// HLS delivers fragments out-of-order, but the browser plays them back
|
||||||
// sequentially. currentTime and duration give the user-facing progress.
|
// sequentially. currentTime and duration give the user-facing progress.
|
||||||
currentTime: 0,
|
currentTime: 0,
|
||||||
duration: 0,
|
duration: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ======================================================================== */
|
/* ======================================================================== */
|
||||||
@@ -642,16 +742,28 @@ function wireHlsEvents(hls, video, panel) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fatal errors — attempt recovery before giving up
|
// Fatal errors — attempt recovery before giving up
|
||||||
if (data.type === window.Hls.ErrorTypes.NETWORK_ERROR) {
|
if (data.type === window.Hls.ErrorTypes.NETWORK_ERROR) {
|
||||||
state.errorMessage = `Network: ${data.details}`;
|
state.errorMessage = `Network: ${data.details}`;
|
||||||
state.segmentsFailed++;
|
state.segmentsFailed++;
|
||||||
state.phase = 'error';
|
state.phase = 'error';
|
||||||
renderPanelState(video, panel);
|
renderPanelState(video, panel);
|
||||||
showError(panel, 'Stream expired - re-scan to get fresh URLs.');
|
|
||||||
hls.destroy();
|
hls.destroy();
|
||||||
hlsInstances.delete(video);
|
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.errorMessage = `Media: ${data.details}`;
|
||||||
state.phase = 'buffering';
|
state.phase = 'buffering';
|
||||||
state.lastActivity = Date.now();
|
state.lastActivity = Date.now();
|
||||||
@@ -780,13 +892,21 @@ function destroyHls(video) {
|
|||||||
/* PanelFactory — DOM panel creation */
|
/* PanelFactory — DOM panel creation */
|
||||||
/* ======================================================================== */
|
/* ======================================================================== */
|
||||||
|
|
||||||
/** Show an error message inside a panel's video placeholder. */
|
/** Show an error message inside a panel's video placeholder. */
|
||||||
function showError(panel, message) {
|
function showError(panel, message) {
|
||||||
const err = document.createElement('div');
|
removeErrorOverlay(panel);
|
||||||
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;';
|
const err = document.createElement('div');
|
||||||
err.textContent = message;
|
err.className = 'goon-error-overlay';
|
||||||
panel.appendChild(err);
|
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. */
|
/** Create a video element with standard autoplay attributes. */
|
||||||
function createVideoElement() {
|
function createVideoElement() {
|
||||||
@@ -854,9 +974,13 @@ function playHls(video, manifestUrl, panel) {
|
|||||||
origDestroy();
|
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));
|
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.loadSource(manifestUrl);
|
||||||
hls.attachMedia(video);
|
hls.attachMedia(video);
|
||||||
}
|
}
|
||||||
@@ -889,12 +1013,59 @@ function loadHlsJs() {
|
|||||||
script.onerror = () => {
|
script.onerror = () => {
|
||||||
reject(new Error('hlds.js failed to load: resource not found or CSP blocked'));
|
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.
|
* Returns { panel, hlsEntry } — hlsEntry is set for deferred HLS playback.
|
||||||
*/
|
*/
|
||||||
function createPanel(entry, tag) {
|
function createPanel(entry, tag) {
|
||||||
@@ -1013,20 +1184,29 @@ async function init() {
|
|||||||
panelsEl.style.gridTemplateColumns = `repeat(${gridN}, 1fr)`;
|
panelsEl.style.gridTemplateColumns = `repeat(${gridN}, 1fr)`;
|
||||||
panelsEl.style.gridTemplateRows = `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) {
|
if (hlsEntries.length > 0) {
|
||||||
try {
|
try {
|
||||||
await loadHlsJs();
|
await loadHlsJs();
|
||||||
} catch {
|
} catch {
|
||||||
for (const { panel } of hlsEntries) {
|
for (const { panel } of hlsEntries) {
|
||||||
showError(panel, 'HLS player unavailable - check network.');
|
showError(panel, 'HLS player unavailable - check network.');
|
||||||
}
|
}
|
||||||
return;
|
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
|
// Clean up storage entry and HLS instances when the viewer is closed
|
||||||
|
|||||||
Reference in new issue
Block a user