1 parent
001cf146ee
commit
c9f49b58d8
3 files changed
+576
-172
No files matched your search
+273
-93
@@ -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
|
||||
|
||||
Reference in new issue
Block a user