Working on pornhub
CI / test (pull_request) Failing after 10m34s

This commit is contained in:
kfj001 committed 2026-08-20 10:59:05 -07:00
1 parent 001cf146ee
commit c9f49b58d8
3 files changed
+576 -172

No files matched your search

+273 -93
View File
@@ -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