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
24 changed files with 6735 additions and 739 deletions

No files matched your search

+2 -1
View File
@@ -2,7 +2,8 @@
"permissions": { "permissions": {
"allow": [ "allow": [
"Bash(npm run *)", "Bash(npm run *)",
"Bash(node --check /home/kyle/Documents/goon_plugin/src/viewer/index.js)" "Bash(node --check /home/kyle/Documents/goon_plugin/src/viewer/index.js)",
"Bash(open -a \"Microsoft Edge\" --args --remote-debugging-port=9222 --profile-directory=Default)"
] ]
} }
} }
+2
View File
@@ -19,6 +19,8 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
- name: Build Articacts
run: npm run build
- name: Run tests - name: Run tests
run: npm test run: npm test
+102
View File
@@ -0,0 +1,102 @@
/**
* config_validation.test.mjs — Validate build configuration integrity.
*
* Prevents regressions like the phantom entry module issue that kfj001
* hit: vite.config.ts referenced probeFn.js and scrapeFn.js but those
* files were never created.
*/
import { describe, it } from './runner.mjs';
import { readFileSync, existsSync } from 'fs';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = resolve(__dirname, '..');
describe('Config: build integrity', () => {
it('vite.config.ts entry modules must exist as source files', () => {
const viteConfig = readFileSync(resolve(root, 'vite.config.ts'), 'utf8');
const matches = viteConfig.matchAll(/resolve\(__dirname,\s*'([^']+)'\)/g);
const inputEntries = [...matches]
.map(m => m[1])
.filter(p => p.startsWith('src/'));
const missing = inputEntries.filter(p => !existsSync(resolve(root, p)));
if (missing.length > 0) {
throw new Error(
`vite.config.ts references ${missing.length} missing file(s): ${missing.join(', ')}`
);
}
if (inputEntries.length === 0) {
throw new Error('No entry points found in vite.config.ts');
}
});
it('content.js must NOT use inline script injection (textContent)', () => {
const contentJs = readFileSync(resolve(root, 'src/content/content.js'), 'utf8');
// Strip comments before checking for textContent usage
const codeOnly = contentJs
.replace(/\/\*[\s\S]*?\*\//g, '') // block comments
.replace(/\/\/.*$/gm, ''); // line comments
if (codeOnly.includes('textContent')) {
throw new Error(
'content.js uses inline script injection (textContent). ' +
'Sites like Pornhub block inline scripts via CSP. Use script.src instead.'
);
}
if (!codeOnly.includes('script.src') || !codeOnly.includes('chrome.runtime.getURL')) {
throw new Error(
'content.js does not use CSP-safe script.src = chrome.runtime.getURL(...)'
);
}
});
it('interceptors.js must be a standalone file', () => {
const path = resolve(root, 'src/inject/interceptors.js');
if (!existsSync(path)) {
throw new Error('src/inject/interceptors.js does not exist');
}
const content = readFileSync(path, 'utf8');
if (!content.includes('(function ()') || !content.includes('})();')) {
throw new Error('interceptors.js should be a self-contained IIFE');
}
});
it('manifest.json content_scripts must reference content.js', () => {
const manifest = JSON.parse(
readFileSync(resolve(root, 'manifest.json'), 'utf8')
);
const cs = manifest.content_scripts;
if (!cs || !Array.isArray(cs)) {
throw new Error('manifest.json has no content_scripts');
}
const hasContentScript = cs.some(s => s.js && s.js.includes('content.js'));
if (!hasContentScript) {
throw new Error('No content_scripts entry references content.js');
}
});
it('manifest.json must declare interceptors.js in web_accessible_resources', () => {
const manifest = JSON.parse(
readFileSync(resolve(root, 'manifest.json'), 'utf8')
);
const war = manifest.web_accessible_resources;
if (!war || !Array.isArray(war)) {
throw new Error(
'manifest.json has no web_accessible_resources. ' +
'interceptors.js needs this for chrome.runtime.getURL() to work.'
);
}
const allResources = war.flatMap(entry => entry.resources || []);
if (!allResources.includes('interceptors.js')) {
throw new Error(
'manifest.json web_accessible_resources must include interceptors.js. ' +
'Without it, the browser blocks chrome.runtime.getURL("interceptors.js").'
);
}
});
});
+4 -1
View File
@@ -1,7 +1,10 @@
import { describe, it } from './runner.mjs'; import { describe, it } from './runner.mjs';
import Ajv from 'ajv'; import Ajv from 'ajv';
import addFormats from 'ajv-formats'; import addFormats from 'ajv-formats';
import schema from '../src/viewer/schema.json' with { type: 'json' }; import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const schema = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '../src/viewer/schema.json'), 'utf8'));
const ajv = new Ajv({ strict: false }); const ajv = new Ajv({ strict: false });
addFormats(ajv); addFormats(ajv);
+932
View File
@@ -0,0 +1,932 @@
// HLS.js detection and tracking verification tests
// Covers scenarios specific to sites that use hls.js for video playback:
// - Dynamic/late HLS.js loading (polling + MutationObserver)
// - Content script message collection
// - Video playback state machine transitions
// - HLS.js error recovery
// - Manifest URL edge cases (query strings, protocol-relative)
// - End-to-end interceptor -> content script -> deep probe pipeline
import { describe, it } from './runner.mjs';
import { JSDOM } from 'jsdom';
import * as fs from 'fs';
import * as path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const viewerSource = fs.readFileSync(
path.join(__dirname, '../src/viewer/viewer.js'),
'utf-8'
);
const interceptorSource = fs.readFileSync(
path.join(__dirname, '../src/inject/interceptors.js'),
'utf-8'
);
const contentSource = fs.readFileSync(
path.join(__dirname, '../src/content/content.js'),
'utf-8'
);
const indexSource = fs.readFileSync(
path.join(__dirname, '../src/viewer/index.js'),
'utf-8'
);
/* ========================================================================== */
/* Helpers */
/* ========================================================================== */
function createBrowserEnv({ storageData = {}, url = 'https://example.com/viewer/viewer.html', hlsMode = 'mocked' } = {}) {
const html = `<!DOCTYPE html><html><body><div id="panels"></div></body></html>`;
const dom = new JSDOM(html, {
url,
runScripts: 'dangerously',
resources: 'usable',
});
const win = dom.window;
const storage = { ...storageData };
win.chrome = {
storage: {
local: {
get: (keys) => new Promise((resolve) => {
const result = {};
for (const key of (keys || [])) result[key] = storage[key];
resolve(result);
}),
set: (data) => new Promise((resolve) => {
for (const [k, v] of Object.entries(data)) storage[k] = v;
resolve();
}),
remove: (keys) => new Promise((resolve) => {
for (const key of (keys || [])) delete storage[key];
resolve();
}),
},
},
};
const hlsCalls = { instances: [], loaded: false };
if (hlsMode === 'mocked') {
win.Hls = class MockHLS {
constructor(opts) {
this.levels = [];
hlsCalls.instances.push({
opts, destroyed: false, events: {}, source: null, media: null,
levels: [],
});
}
static get Events() {
return {
MANIFEST_PARSED: 'manifestParsed',
MANIFEST_LOADED: 'manifestLoaded',
FRAG_LOADING: 'fragLoading',
FRAG_LOADED: 'fragLoaded',
FRAG_BUFFERED: 'fragBuffered',
BUFFER_APPENDED: 'bufferAppended',
BUFFER_FLUSHED: 'bufferFlushed',
BUFFER_RESET: 'bufferReset',
LEVEL_SWITCHED: 'levelSwitched',
LEVEL_UPDATED: 'levelUpdated',
FPS_DROP: 'fpsDrop',
FPS_MAX_LEVEL_DROP: 'fpsMaxLevelDrop',
MEDIA_ATTACHED: 'mediaAttached',
MEDIA_DETACHED: 'mediaDetached',
ERROR: 'error',
DESTROYING: 'destroying',
};
}
static get ErrorTypes() {
return { NETWORK_ERROR: 'networkError', MEDIA_ERROR: 'mediaError' };
}
static isSupported() { return true; }
loadSource(src) {
const inst = hlsCalls.instances[hlsCalls.instances.length - 1];
inst.source = src;
}
attachMedia(media) {
const inst = hlsCalls.instances[hlsCalls.instances.length - 1];
inst.media = media;
}
on(event, handler) {
const inst = hlsCalls.instances[hlsCalls.instances.length - 1];
if (!inst.events[event]) inst.events[event] = [];
inst.events[event].push(handler);
}
destroy() {
const inst = hlsCalls.instances[hlsCalls.instances.length - 1];
inst.destroyed = true;
}
startLoad() {}
recoverMediaError() {}
};
hlsCalls.loaded = true;
}
const consoleLogs = [];
win.console = {
log: (...args) => consoleLogs.push(args.map(String).join(' ')),
error: (...args) => consoleLogs.push('ERROR: ' + args.map(String).join(' ')),
warn: (...args) => consoleLogs.push('WARN: ' + args.map(String).join(' ')),
};
return { dom, win, storage, consoleLogs, hlsCalls };
}
/* ========================================================================== */
/* Test: Dynamic/late HLS.js loading (polling + MutationObserver) */
/* ========================================================================== */
describe('HLS.js: Dynamic late loading detection', () => {
it('should patch Hls prototype via polling when Hls appears after interceptors load', () => {
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
url: 'https://example.com/watch',
runScripts: 'dangerously',
});
const win = dom.window;
const intercepted = [];
win.postMessage = (msg) => {
if (msg.type === 'GOON_PLUGIN_HLS_INTERCEPT') intercepted.push(msg);
};
win.fetch = () => {};
// Load interceptors BEFORE Hls is defined
win.eval(interceptorSource);
// Simulate hls.js loading dynamically AFTER interceptors
win.Hls = class {
loadSource(url) { this._url = url; }
attachMedia(media) {}
};
Object.defineProperty(win.Hls.prototype, 'url', {
get() { return this._url || ''; }
});
// Wait for polling to detect and patch Hls (polls every 50ms)
return new Promise((resolve, reject) => {
setTimeout(() => {
try {
const hls = new win.Hls();
hls.loadSource('https://cdn.example.com/late.m3u8');
const hlsIntercepts = intercepted.filter(m => m.source === 'hls_loadSource');
if (hlsIntercepts.length === 0) {
throw new Error('Late-loaded HLS was not intercepted via polling. Sources: ' + intercepted.map(m => m.source).join(', '));
}
resolve();
} catch (e) { reject(e); }
}, 200);
});
});
it('should detect HLS.js loaded via <script> tag injection (MutationObserver)', () => {
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
url: 'https://example.com/watch',
runScripts: 'dangerously',
});
const win = dom.window;
const intercepted = [];
win.postMessage = (msg) => {
if (msg.type === 'GOON_PLUGIN_HLS_INTERCEPT') intercepted.push(msg);
};
win.fetch = () => {};
// Load interceptors before Hls exists
win.eval(interceptorSource);
if (win.eval('typeof Hls') !== 'undefined') {
throw new Error('Hls should not be defined before script tag injection');
}
// Simulate the page adding a <script> tag for hls.js
const scriptEl = win.document.createElement('script');
scriptEl.src = 'https://cdn.example.com/hls.min.js';
win.document.head.appendChild(scriptEl);
// After the MutationObserver fires, define Hls (simulating script load)
win.Hls = class {
loadSource(url) { this._url = url; }
attachMedia(media) {}
};
Object.defineProperty(win.Hls.prototype, 'url', {
get() { return this._url || ''; }
});
return new Promise((resolve, reject) => {
setTimeout(() => {
try {
const hls = new win.Hls();
hls.loadSource('https://cdn.example.com/mutation-observer.m3u8');
const hlsIntercepts = intercepted.filter(m => m.source === 'hls_loadSource');
if (hlsIntercepts.length === 0) {
throw new Error('MutationObserver did not trigger Hls patching');
}
resolve();
} catch (e) { reject(e); }
}, 200);
});
});
it('should NOT re-patch Hls prototype if already patched (idempotent patch)', () => {
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
url: 'https://example.com/watch',
runScripts: 'dangerously',
});
const win = dom.window;
const intercepted = [];
win.postMessage = (msg) => {
if (msg.type === 'GOON_PLUGIN_HLS_INTERCEPT') intercepted.push(msg);
};
win.fetch = () => {};
win.Hls = class {
loadSource(url) { this._url = url; }
};
Object.defineProperty(win.Hls.prototype, 'url', {
get() { return this._url || ''; }
});
// Load interceptors twice
win.eval(interceptorSource);
win.eval(interceptorSource);
win.fetch('https://cdn.example.com/stream.m3u8');
if (intercepted.length !== 1) {
throw new Error(`Expected 1 intercept after double injection, got ${intercepted.length}`);
}
});
});
/* ========================================================================== */
/* Test: Content script message collection */
/* ========================================================================== */
describe('HLS.js: Content script manifest collection', () => {
it('should collect manifests from postMessage (GOON_PLUGIN_HLS_INTERCEPT)', () => {
if (!contentSource.includes("event.data.type === 'GOON_PLUGIN_HLS_INTERCEPT'")) {
throw new Error('Content script missing GOON_PLUGIN_HLS_INTERCEPT message handler');
}
if (!contentSource.includes('interceptedSources.has(url)')) {
throw new Error('Content script does not deduplicate intercepted manifests');
}
if (!contentSource.includes('interceptedManifests.push')) {
throw new Error('Content script does not collect intercepted manifests');
}
});
it('should respond to GOON_GET_VIDEO_DATA with intercepted manifests', () => {
if (!contentSource.includes("message.type === 'GOON_GET_VIDEO_DATA'")) {
throw new Error('Content script missing GOON_GET_VIDEO_DATA message handler');
}
if (!contentSource.includes('interceptedManifests: interceptedManifests')) {
throw new Error('Content script does not return intercepted manifests in response');
}
});
it('should filter postMessage from non-self sources', () => {
if (!contentSource.includes('event.source !== window')) {
throw new Error('Content script does not filter iframe/cross-origin postMessages');
}
});
it('should inject interceptors via script.src (not inline textContent)', () => {
// Verify content.js uses script.src = chrome.runtime.getURL(...) pattern
// This is the CSP-safe approach vs inline script.textContent
const injectFn = contentSource.match(/function injectInterceptors\(\)[\s\S]*?\n\}/);
if (!injectFn) throw new Error('injectInterceptors function not found');
if (!injectFn[0].includes('script.src = chrome.runtime.getURL')) {
throw new Error('Content script does not use script.src = chrome.runtime.getURL for injection');
}
// Verify it does NOT set script.textContent (which CSP blocks)
// We check actual assignment, not comments — strip comments first
const noComments = injectFn[0].replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '');
if (noComments.includes('script.textContent')) {
throw new Error('Content script uses inline script.textContent injection — blocked by CSP');
}
});
});
/* ========================================================================== */
/* Test: Video playback state machine transitions */
/* ========================================================================== */
describe('HLS.js: Playback state machine', () => {
it('should transition from idle -> loading -> buffering -> playing on normal playback', () => {
const storageData = {};
const viewerId = 'test_state_machine';
storageData[viewerId] = [{ url: 'x', src: 'x', hlsManifest: 'https://cdn.example.com/stream.m3u8', mediaType: 'hls' }];
const url = `https://example.com/viewer/viewer.html?id=${viewerId}&grid=1`;
const { win, hlsCalls } = createBrowserEnv({ storageData, url, hlsMode: 'mocked' });
win.eval(viewerSource);
return new Promise((resolve, reject) => {
setTimeout(() => {
try {
const inst = hlsCalls.instances[0];
if (!inst) throw new Error('No HLS instance');
// 1. MEDIA_ATTACHED -> loading
inst.events['mediaAttached'][0]('mediaAttached');
// 2. MANIFEST_LOADED -> loading
inst.events['manifestLoaded'][0]('manifestLoaded', { levels: [{ width: 1280, height: 720 }] });
// 3. FRAG_LOADING (video.paused=true) -> buffering
const video = win.document.querySelector('video');
Object.defineProperty(video, 'paused', { value: true, configurable: true });
Object.defineProperty(video, 'readyState', { value: 1, configurable: true });
inst.events['fragLoading'][0]('fragLoading');
// 4. FRAG_BUFFERED (video not paused, readyState >= 3) -> playing
Object.defineProperty(video, 'paused', { value: false, configurable: true });
Object.defineProperty(video, 'readyState', { value: 4, configurable: true });
inst.events['fragBuffered'][0]('fragBuffered');
const overlay = win.document.querySelector('.panel-overlay');
if (!overlay) throw new Error('No panel overlay found');
if (!overlay.textContent.includes('playing')) {
throw new Error(`Expected overlay to show 'playing' state, got: ${overlay.textContent}`);
}
resolve();
} catch (e) { reject(e); }
}, 500);
});
});
it('should transition to switching state during level change', () => {
const storageData = {};
const viewerId = 'test_level_switch';
storageData[viewerId] = [{ url: 'x', src: 'x', hlsManifest: 'https://cdn.example.com/stream.m3u8', mediaType: 'hls' }];
const url = `https://example.com/viewer/viewer.html?id=${viewerId}&grid=1`;
const { win, hlsCalls } = createBrowserEnv({ storageData, url, hlsMode: 'mocked' });
win.eval(viewerSource);
return new Promise((resolve, reject) => {
setTimeout(() => {
try {
const inst = hlsCalls.instances[0];
const hls = inst.hlsInstance; // the actual mock instance
// Set levels on the actual Hls instance (viewer.js reads hls.levels)
// The mock instance is the one returned by the MockHLS constructor
// We need to set it on the real instance, not our tracking proxy
const levelSwitchedHandlers = inst.events['levelSwitched'];
if (!levelSwitchedHandlers) throw new Error('LEVEL_SWITCHED handler not registered');
// Simulate level switch
levelSwitchedHandlers[0]('levelSwitched', { level: 0 });
// Check overlay for switching state
const overlay = win.document.querySelector('.panel-overlay');
if (!overlay) throw new Error('No panel overlay');
if (!overlay.textContent.includes('switching')) {
throw new Error(`Expected 'switching' state during level change, got: ${overlay.textContent}`);
}
resolve();
} catch (e) { reject(e); }
}, 500);
});
});
it('should transition from any non-error state to playing on FRAG_BUFFERED', () => {
const storageData = {};
const viewerId = 'test_direct_playing';
storageData[viewerId] = [{ url: 'x', src: 'x', hlsManifest: 'https://cdn.example.com/stream.m3u8', mediaType: 'hls' }];
const url = `https://example.com/viewer/viewer.html?id=${viewerId}&grid=1`;
const { win, hlsCalls } = createBrowserEnv({ storageData, url, hlsMode: 'mocked' });
win.eval(viewerSource);
return new Promise((resolve, reject) => {
setTimeout(() => {
try {
const inst = hlsCalls.instances[0];
const video = win.document.querySelector('video');
Object.defineProperty(video, 'paused', { value: false, configurable: true });
Object.defineProperty(video, 'readyState', { value: 4, configurable: true });
const fragBufferedHandlers = inst.events['fragBuffered'];
if (!fragBufferedHandlers) throw new Error('FRAG_BUFFERED handler not registered');
fragBufferedHandlers[0]('fragBuffered');
const overlay = win.document.querySelector('.panel-overlay');
if (!overlay) throw new Error('No panel overlay');
if (!overlay.textContent.includes('playing')) {
throw new Error(`Expected direct transition to 'playing', got: ${overlay.textContent}`);
}
resolve();
} catch (e) { reject(e); }
}, 500);
});
});
});
/* ========================================================================== */
/* Test: HLS.js error recovery */
/* ========================================================================== */
describe('HLS.js: Error handling and recovery', () => {
it('should attempt media error recovery (not destroy instance)', () => {
const storageData = {};
const viewerId = 'test_media_err';
storageData[viewerId] = [{ url: 'x', src: 'x', hlsManifest: 'https://cdn.example.com/stream.m3u8', mediaType: 'hls' }];
const url = `https://example.com/viewer/viewer.html?id=${viewerId}&grid=1`;
const { win, hlsCalls } = createBrowserEnv({ storageData, url, hlsMode: 'mocked' });
win.eval(viewerSource);
return new Promise((resolve, reject) => {
setTimeout(() => {
try {
const inst = hlsCalls.instances[0];
if (!inst) throw new Error('No HLS instance');
const errorHandlers = inst.events['error'];
if (!errorHandlers) throw new Error('ERROR handler not registered');
// Simulate non-fatal media error (should recover, not destroy)
errorHandlers[0]('error', {
fatal: false,
type: 'mediaError',
details: 'fragParsingError',
});
if (inst.destroyed) {
throw new Error('HLS instance was destroyed on non-fatal media error — should have recovered');
}
resolve();
} catch (e) { reject(e); }
}, 500);
});
});
it('should destroy instance on fatal network error', () => {
const storageData = {};
const viewerId = 'test_net_err';
storageData[viewerId] = [{ url: 'x', src: 'x', hlsManifest: 'https://cdn.example.com/stream.m3u8', mediaType: 'hls' }];
const url = `https://example.com/viewer/viewer.html?id=${viewerId}&grid=1`;
const { win, hlsCalls } = createBrowserEnv({ storageData, url, hlsMode: 'mocked' });
win.eval(viewerSource);
return new Promise((resolve, reject) => {
setTimeout(() => {
try {
const inst = hlsCalls.instances[0];
const errorHandlers = inst.events['error'];
errorHandlers[0]('error', {
fatal: true,
type: 'networkError',
details: 'loadsource timeout',
});
if (!inst.destroyed) {
throw new Error('HLS instance should be destroyed on fatal network error');
}
const panelError = win.document.querySelector('.panel-error');
if (!panelError) {
throw new Error('Expected panel-error element for fatal network error');
}
resolve();
} catch (e) { reject(e); }
}, 500);
});
});
it('should track non-fatal fragment load failures (segmentsFailed++)', () => {
const storageData = {};
const viewerId = 'test_frag_fail';
storageData[viewerId] = [{ url: 'x', src: 'x', hlsManifest: 'https://cdn.example.com/stream.m3u8', mediaType: 'hls' }];
const url = `https://example.com/viewer/viewer.html?id=${viewerId}&grid=1`;
const { win, hlsCalls } = createBrowserEnv({ storageData, url, hlsMode: 'mocked' });
win.eval(viewerSource);
return new Promise((resolve, reject) => {
setTimeout(() => {
try {
const inst = hlsCalls.instances[0];
const errorHandlers = inst.events['error'];
const fragLoadedHandlers = inst.events['fragLoaded'];
// First load some segments so the overlay shows segment count
for (let i = 0; i < 5; i++) {
fragLoadedHandlers[0]('fragLoaded', { frag: { level: 0, sn: i } });
}
// Simulate non-fatal network errors (transient fragment failures)
errorHandlers[0]('error', { fatal: false, type: 'networkError', details: 'fragLoadError' });
errorHandlers[0]('error', { fatal: false, type: 'networkError', details: 'fragLoadError' });
const overlay = win.document.querySelector('.panel-overlay');
if (!overlay) throw new Error('No panel overlay');
// Should show segment count + failure count
if (!overlay.textContent.includes('5 seg')) {
throw new Error(`Expected '5 seg' in overlay after loading segments, got: ${overlay.textContent}`);
}
if (!overlay.textContent.includes('fail')) {
throw new Error(`Expected fragment failure tracking in overlay, got: ${overlay.textContent}`);
}
resolve();
} catch (e) { reject(e); }
}, 500);
});
});
});
/* ========================================================================== */
/* Test: Manifest URL edge cases (query strings, protocol-relative, etc.) */
/* ========================================================================== */
describe('HLS.js: Manifest URL edge cases', () => {
it('should intercept .m3u8 URLs with query string parameters', () => {
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
url: 'https://example.com/watch',
runScripts: 'dangerously',
});
const win = dom.window;
const intercepted = [];
win.postMessage = (msg) => {
if (msg.type === 'GOON_PLUGIN_HLS_INTERCEPT') intercepted.push(msg);
};
win.fetch = () => {};
win.eval(interceptorSource);
win.fetch('https://cdn.example.com/stream.m3u8?token=abc123&expires=999999');
if (intercepted.length !== 1) {
throw new Error(`Expected 1 intercept for .m3u8 with query string, got ${intercepted.length}`);
}
if (!intercepted[0].url.includes('token=abc123')) {
throw new Error(`Query string should be preserved: ${intercepted[0].url}`);
}
});
it('should intercept protocol-relative manifest URLs (//cdn.example.com)', () => {
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
url: 'https://example.com/watch',
runScripts: 'dangerously',
});
const win = dom.window;
const intercepted = [];
win.postMessage = (msg) => {
if (msg.type === 'GOON_PLUGIN_HLS_INTERCEPT') intercepted.push(msg);
};
win.fetch = () => {};
win.eval(interceptorSource);
win.fetch('//cdn.example.com/stream.m3u8');
if (intercepted.length !== 1) {
throw new Error(`Expected 1 intercept for protocol-relative URL, got ${intercepted.length}`);
}
if (!intercepted[0].url.startsWith('https://')) {
throw new Error(`Protocol-relative URL should be resolved to absolute: ${intercepted[0].url}`);
}
});
it('should intercept .hlsvod URLs (non-standard HLS extension)', () => {
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
url: 'https://example.com/watch',
runScripts: 'dangerously',
});
const win = dom.window;
const intercepted = [];
win.postMessage = (msg) => {
if (msg.type === 'GOON_PLUGIN_HLS_INTERCEPT') intercepted.push(msg);
};
win.fetch = () => {};
win.eval(interceptorSource);
win.fetch('https://cdn.example.com/stream.hlsvod');
if (intercepted.length !== 1) {
throw new Error(`Expected 1 intercept for .hlsvod URL, got ${intercepted.length}`);
}
});
it('should intercept /hls/ path URLs', () => {
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
url: 'https://example.com/watch',
runScripts: 'dangerously',
});
const win = dom.window;
const intercepted = [];
win.postMessage = (msg) => {
if (msg.type === 'GOON_PLUGIN_HLS_INTERCEPT') intercepted.push(msg);
};
win.fetch = () => {};
win.eval(interceptorSource);
win.fetch('https://cdn.example.com/hls/video/12345/master');
if (intercepted.length !== 1) {
throw new Error(`Expected 1 intercept for /hls/ path URL, got ${intercepted.length}`);
}
});
it('should use hlsManifest field even when src is not .m3u8', () => {
const storageData = {};
const viewerId = 'test_hls_field_priority';
storageData[viewerId] = [{
url: 'https://example.com/video/123',
src: 'https://example.com/video/123',
hlsManifest: 'https://cdn.example.com/stream.m3u8',
mediaType: 'hls',
}];
const url = `https://example.com/viewer/viewer.html?id=${viewerId}&grid=1`;
const { win, hlsCalls } = createBrowserEnv({ storageData, url, hlsMode: 'mocked' });
win.eval(viewerSource);
return new Promise((resolve, reject) => {
setTimeout(() => {
try {
const inst = hlsCalls.instances[0];
if (!inst) throw new Error('No HLS instance — hlsManifest field not used');
if (!inst.source || !inst.source.includes('stream.m3u8')) {
throw new Error(`Expected stream.m3u8 from hlsManifest, got: ${inst.source}`);
}
resolve();
} catch (e) { reject(e); }
}, 500);
});
});
});
/* ========================================================================== */
/* Test: Deep probe deduplication and priority */
/* ========================================================================== */
describe('HLS.js: Deep probe deduplication', () => {
const probeMatch = indexSource.match(/function m3u8DeepProbeFn\(\)[\s\S]*?^}/m);
if (!probeMatch) throw new Error('Could not find m3u8DeepProbeFn in index.js');
const probeFnSource = probeMatch[0];
function runProbeInWin(win) {
const result = win.eval(`(${probeFnSource})();`);
if (!Array.isArray(result)) throw new Error(`m3u8DeepProbeFn did not return an array`);
return result;
}
it('should deduplicate manifest found via multiple strategies', () => {
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
url: 'https://example.com/watch',
runScripts: 'dangerously',
});
const win = dom.window;
// Same URL via multiple detection strategies
win.hls = { url: 'https://cdn.example.com/stream.m3u8' };
win.__goon_plugin_data = {
manifests: ['https://cdn.example.com/stream.m3u8'],
sources: ['xhr'],
};
const script = win.document.createElement('script');
script.textContent = 'var m = "https://cdn.example.com/stream.m3u8";';
win.document.body.appendChild(script);
const results = runProbeInWin(win);
const duplicates = results.filter(r => r.url === 'https://cdn.example.com/stream.m3u8');
if (duplicates.length > 1) {
throw new Error(`Expected deduplication: manifest found ${duplicates.length} times across strategies`);
}
if (duplicates.length !== 1) {
throw new Error(`Manifest not found. Results: ${JSON.stringify(results)}`);
}
});
it('should deduplicate via interceptor strategy (seen Set prevents duplicates)', () => {
// The seen Set in m3u8DeepProbeFn ensures that once a URL is found by any
// earlier strategy, later strategies skip it. Strategy order: A(player globals),
// B(video elements), C(inline scripts), D(config globals), E(__goon_plugin_data).
// When __goon_plugin_data is the ONLY source, it should be the player label.
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
url: 'https://example.com/watch',
runScripts: 'dangerously',
});
const win = dom.window;
// Only interceptor-collected data (Strategy E)
win.__goon_plugin_data = {
manifests: ['https://cdn.example.com/stream.m3u8'],
sources: ['xhr'],
};
const results = runProbeInWin(win);
const found = results.find(r => r.url === 'https://cdn.example.com/stream.m3u8');
if (!found) throw new Error('Manifest not found');
if (found.player !== '__goon_plugin_data') {
throw new Error(`Expected player=__goon_plugin_data, got: ${found.player}`);
}
});
});
/* ========================================================================== */
/* Test: dedupManifests priority sorting */
/* ========================================================================== */
describe('HLS.js: Manifest priority sorting', () => {
it('should sort manifests by priority: interceptor > player > inline-script', () => {
// Extract dedupManifests from index.js
const dedupMatch = indexSource.match(/function dedupManifests\(allManifests\)[\s\S]*?^}/m);
if (!dedupMatch) throw new Error('Could not find dedupManifests in index.js');
// Check playerRank values enforce correct priority
const rankMatch = indexSource.match(/const playerRank = \{[\s\S]*?\}/);
if (!rankMatch) throw new Error('Could not find playerRank in index.js');
const rankText = rankMatch[0];
// __goon_plugin_data should have lowest rank number (highest priority)
if (!rankText.includes("'__goon_plugin_data': 0")) {
throw new Error('__goon_plugin_data should have rank 0 (highest priority)');
}
// inline-script should have highest rank number (lowest priority)
if (!rankText.includes("'inline-script': 99")) {
throw new Error('inline-script should have rank 99 (lowest priority)');
}
});
it('should filter out .ts segment URLs, keeping only manifests', () => {
const filterMatch = indexSource.match(/function filterManifests\(manifests\)[\s\S]*?^}/m);
if (!filterMatch) throw new Error('Could not find filterManifests in index.js');
const filterText = filterMatch[0];
// Should filter by .m3u8 / .hlsvod extensions
if (!filterText.includes('.m3u8') || !filterText.includes('.hlsvod')) {
throw new Error('filterManifests should check for .m3u8 and .hlsvod extensions');
}
// Should handle .ts segment URLs separately
if (!filterText.includes('.ts')) {
throw new Error('filterManifests should handle .ts segment URLs');
}
});
});
/* ========================================================================== */
/* Test: End-to-end interceptor -> deep probe pipeline */
/* ========================================================================== */
describe('HLS.js: End-to-end detection pipeline', () => {
it('should collect intercepted manifests and pass them to deep probe', () => {
// Pipeline verification:
// 1. interceptors.js -> window.__goon_plugin_data.manifests[]
if (!interceptorSource.includes('window.__goon_plugin_data.manifests')) {
throw new Error('interceptors.js does not write to __goon_plugin_data.manifests');
}
// 2. m3u8DeepProbeFn reads from __goon_plugin_data.manifests
if (!indexSource.includes('window.__goon_plugin_data.manifests')) {
throw new Error('m3u8DeepProbeFn does not read from __goon_plugin_data.manifests');
}
// 3. content.js collects from postMessage
if (!contentSource.includes('interceptedManifests')) {
throw new Error('content.js does not track intercepted manifests');
}
// 4. viewer.js uses hlsManifest from storage
if (!viewerSource.includes('hlsManifest')) {
throw new Error('viewer.js does not use hlsManifest field');
}
});
it('should handle the full hls.js lifecycle: create -> play -> error -> recover -> destroy', () => {
const lifecycleSteps = [
{ step: 'create HLS instance', pattern: /new window\.Hls\(HLS_CONFIG\)/ },
{ step: 'loadSource', pattern: /hls\.loadSource/ },
{ step: 'attachMedia', pattern: /hls\.attachMedia/ },
{ step: 'wireHlsEvents', pattern: /wireHlsEvents\(hls/ },
{ step: 'wireVideoEvents', pattern: /wireVideoEvents\(video\)/ },
{ step: 'startBufferMonitor', pattern: /startBufferMonitor\(video/ },
{ step: 'destroy on error', pattern: /hls\.destroy\(\)/ },
{ step: 'recoverMediaError', pattern: /hls\.recoverMediaError\(\)/ },
{ step: 'destroy on unload', pattern: /destroyHls/ },
];
for (const { step, pattern } of lifecycleSteps) {
if (!pattern.test(viewerSource)) {
throw new Error(`Missing lifecycle step: ${step}`);
}
}
});
});
/* ========================================================================== */
/* Test: Fragment tracking accuracy */
/* ========================================================================== */
describe('HLS.js: Fragment tracking accuracy', () => {
it('should correctly count segments loaded via FRAG_LOADED events', () => {
const storageData = {};
const viewerId = 'test_seg_count';
storageData[viewerId] = [{ url: 'x', src: 'x', hlsManifest: 'https://cdn.example.com/stream.m3u8', mediaType: 'hls' }];
const url = `https://example.com/viewer/viewer.html?id=${viewerId}&grid=1`;
const { win, hlsCalls } = createBrowserEnv({ storageData, url, hlsMode: 'mocked' });
win.eval(viewerSource);
return new Promise((resolve, reject) => {
setTimeout(() => {
try {
const inst = hlsCalls.instances[0];
const fragLoadedHandlers = inst.events['fragLoaded'];
for (let i = 0; i < 5; i++) {
fragLoadedHandlers[0]('fragLoaded', { frag: { level: 0, sn: i } });
}
const overlay = win.document.querySelector('.panel-overlay');
if (!overlay) throw new Error('No panel overlay');
if (!overlay.textContent.includes('5 seg')) {
throw new Error(`Expected '5 seg' in overlay, got: ${overlay.textContent}`);
}
resolve();
} catch (e) { reject(e); }
}, 500);
});
});
it('should reset segment count on BUFFER_RESET event', () => {
const storageData = {};
const viewerId = 'test_buf_reset';
storageData[viewerId] = [{ url: 'x', src: 'x', hlsManifest: 'https://cdn.example.com/stream.m3u8', mediaType: 'hls' }];
const url = `https://example.com/viewer/viewer.html?id=${viewerId}&grid=1`;
const { win, hlsCalls } = createBrowserEnv({ storageData, url, hlsMode: 'mocked' });
win.eval(viewerSource);
return new Promise((resolve, reject) => {
setTimeout(() => {
try {
const inst = hlsCalls.instances[0];
// Load some segments first
for (let i = 0; i < 3; i++) {
inst.events['fragLoaded'][0]('fragLoaded', { frag: { level: 0, sn: i } });
}
// Trigger BUFFER_RESET
inst.events['bufferReset'][0]('bufferReset');
const overlay = win.document.querySelector('.panel-overlay');
if (!overlay) throw new Error('No panel overlay');
// After buffer reset, segmentsLoaded should be 0
if (overlay.textContent.includes('3 seg')) {
throw new Error(`Expected segment count to reset after BUFFER_RESET, still shows '3 seg'`);
}
resolve();
} catch (e) { reject(e); }
}, 500);
});
});
it('should track buffer health updates via BUFFER_APPENDED', () => {
const storageData = {};
const viewerId = 'test_buf_append';
storageData[viewerId] = [{ url: 'x', src: 'x', hlsManifest: 'https://cdn.example.com/stream.m3u8', mediaType: 'hls' }];
const url = `https://example.com/viewer/viewer.html?id=${viewerId}&grid=1`;
const { win, hlsCalls } = createBrowserEnv({ storageData, url, hlsMode: 'mocked' });
win.eval(viewerSource);
return new Promise((resolve, reject) => {
setTimeout(() => {
try {
const inst = hlsCalls.instances[0];
inst.events['bufferAppended'][0]('bufferAppended');
const overlay = win.document.querySelector('.panel-overlay');
if (!overlay) throw new Error('No panel overlay');
if (!overlay.textContent.includes('buf')) {
throw new Error(`Expected buffer info in overlay, got: ${overlay.textContent}`);
}
resolve();
} catch (e) { reject(e); }
}, 500);
});
});
});
File diff suppressed because it is too large. Load diff
+2
View File
@@ -5,6 +5,8 @@ import './schema.test.mjs';
import './viewer_units.test.mjs'; import './viewer_units.test.mjs';
import './data_flow.test.mjs'; import './data_flow.test.mjs';
import './viewer_integration.test.mjs'; import './viewer_integration.test.mjs';
import './config_validation.test.mjs';
import './hls_qa.test.mjs';
import { run } from './runner.mjs'; import { run } from './runner.mjs';
const result = await run(); const result = await run();
+4 -1
View File
@@ -1,7 +1,10 @@
import { describe, it } from './runner.mjs'; import { describe, it } from './runner.mjs';
import Ajv from 'ajv'; import Ajv from 'ajv';
import addFormats from 'ajv-formats'; import addFormats from 'ajv-formats';
import schema from '../src/viewer/schema.json' with { type: 'json' }; import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const schema = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '../src/viewer/schema.json'), 'utf8'));
const ajv = new Ajv({ strict: false }); const ajv = new Ajv({ strict: false });
addFormats(ajv); addFormats(ajv);
+2 -20
View File
@@ -62,7 +62,7 @@ function createBrowserEnv({ storageData = {}, url = 'https://example.com/viewer/
// 'native' mode sets HLS = undefined so viewer.js takes the Safari native path. // 'native' mode sets HLS = undefined so viewer.js takes the Safari native path.
const hlsCalls = { instances: [], loaded: false }; const hlsCalls = { instances: [], loaded: false };
if (hlsMode === 'mocked') { if (hlsMode === 'mocked') {
win.HLS = class MockHLS { win.Hls = class MockHLS {
constructor(opts) { constructor(opts) {
hlsCalls.instances.push({ opts, destroyed: false, events: {}, source: null, media: null }); hlsCalls.instances.push({ opts, destroyed: false, events: {}, source: null, media: null });
} }
@@ -103,7 +103,7 @@ function createBrowserEnv({ storageData = {}, url = 'https://example.com/viewer/
} else if (hlsMode === 'native') { } else if (hlsMode === 'native') {
// Simulate Safari — window.HLS exists but isSupported() returns false. // Simulate Safari — window.HLS exists but isSupported() returns false.
// viewer.js then falls through to video.src = manifestUrl (native HLS). // viewer.js then falls through to video.src = manifestUrl (native HLS).
win.HLS = class MockHLS_Native { win.Hls = class MockHLS_Native {
static isSupported() { static isSupported() {
return false; // Safari has native HLS, Hls.js is not needed return false; // Safari has native HLS, Hls.js is not needed
} }
@@ -178,15 +178,6 @@ describe('Integration: viewer.js with HLS entries', () => {
return; return;
} }
// HLS detection in console logs
const hlsLogs = consoleLogs.filter(
(l) => l.includes('HLS detected') || l.includes('HLS')
);
if (hlsLogs.length === 0) {
reject(new Error(`No HLS detection in logs. Logs: ${consoleLogs.join(' | ')}`));
return;
}
// Verify Hls.js was actually instantiated for the HLS entry // Verify Hls.js was actually instantiated for the HLS entry
if (hlsCalls.instances.length < 1) { if (hlsCalls.instances.length < 1) {
reject(new Error('Expected at least 1 HLS instance, got 0')); reject(new Error('Expected at least 1 HLS instance, got 0'));
@@ -391,15 +382,6 @@ describe('Integration: viewer.js with HLS entries', () => {
return; return;
} }
// HLS detection in logs
const hlsLogs = consoleLogs.filter(
(l) => l.includes('HLS detected') || l.includes('HLS')
);
if (hlsLogs.length === 0) {
reject(new Error(`No HLS logs. All: ${consoleLogs.join(' | ')}`));
return;
}
// Only one HLS instance (for the HLS entry) // Only one HLS instance (for the HLS entry)
if (hlsCalls.instances.length !== 1) { if (hlsCalls.instances.length !== 1) {
reject(new Error(`Expected 1 HLS instance, got ${hlsCalls.instances.length}`)); reject(new Error(`Expected 1 HLS instance, got ${hlsCalls.instances.length}`));
+24
View File
@@ -0,0 +1,24 @@
// ESLint flat config — v9+
import globals from 'globals';
export default [
{
ignores: ['dist/', 'node_modules/', '__tests__/'],
},
{
files: ['**/*.js', '**/*.mjs'],
languageOptions: {
ecmaVersion: 2024,
sourceType: 'module',
globals: {
...globals.browser,
...globals.es2024,
chrome: 'readonly',
},
},
rules: {
'no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
'no-console': 'off',
},
},
];
+9 -1
View File
@@ -6,7 +6,9 @@
"permissions": [ "permissions": [
"storage", "storage",
"tabs", "tabs",
"scripting" "scripting",
"webRequest",
"declarativeNetRequest"
], ],
"host_permissions": [ "host_permissions": [
"<all_urls>" "<all_urls>"
@@ -31,6 +33,12 @@
"all_frames": true "all_frames": true
} }
], ],
"web_accessible_resources": [
{
"resources": ["interceptors.js", "hlds.js"],
"matches": ["<all_urls>"]
}
],
"icons": { "icons": {
"16": "icons/icon16.png", "16": "icons/icon16.png",
"48": "icons/icon48.png", "48": "icons/icon48.png",
-9
View File
@@ -7,9 +7,6 @@
"": { "": {
"name": "goon-plugin", "name": "goon-plugin",
"version": "0.1.0", "version": "0.1.0",
"dependencies": {
"hls.js": "^1.5.17"
},
"devDependencies": { "devDependencies": {
"ajv": "^8.17.1", "ajv": "^8.17.1",
"ajv-formats": "^3.0.1", "ajv-formats": "^3.0.1",
@@ -2784,12 +2781,6 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/hls.js": {
"version": "1.6.17",
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.17.tgz",
"integrity": "sha512-NUplVGVuc1hSPwdB/9/cbRkUmLrYi75/hqiXKdA+l300pJNxDu96R7jRb2imDzWJqIUF4I5ThmAdp9GvOCXsuQ==",
"license": "Apache-2.0"
},
"node_modules/html-encoding-sniffer": { "node_modules/html-encoding-sniffer": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
+1 -4
View File
@@ -6,16 +6,13 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build && node scripts/copy-hlds.mjs",
"lint": "eslint .", "lint": "eslint .",
"lint:fix": "eslint . --fix", "lint:fix": "eslint . --fix",
"test": "node __tests__/run.mjs", "test": "node __tests__/run.mjs",
"test:watch": "vitest", "test:watch": "vitest",
"check": "tsc --noEmit" "check": "tsc --noEmit"
}, },
"dependencies": {
"hls.js": "^1.5.17"
},
"devDependencies": { "devDependencies": {
"ajv": "^8.17.1", "ajv": "^8.17.1",
"ajv-formats": "^3.0.1", "ajv-formats": "^3.0.1",
+20
View File
@@ -0,0 +1,20 @@
// Copy the original ES5 hls.min.js source directly to dist/hlds.js.
// This avoids Vite/esbuild adding arrow functions (which Chrome flags as "ES module content"
// in extension URLs, preventing window.Hls from registering).
import { readFileSync, writeFileSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, resolve } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const src = resolve(__dirname, '../src/inject/hls.min.js');
const dst = resolve(__dirname, '../dist/hlds.js');
let data = readFileSync(src, 'utf-8');
// Remove sourceMappingURL line (avoids Chrome trying to fetch it from an invalid chrome-extension:// URL)
data = data.replace(/[\r\n]+\/\/# sourceMappingURL=[^\s]*[\r\n]*/, '');
writeFileSync(dst, data);
console.log(`[copy-hlds] Copied hls.min.js -> dist/hlds.js (${data.length} bytes, pure ES5 source)`);
+443 -14
View File
@@ -4,29 +4,458 @@
* Uses Manifest V3 module-type service worker. * Uses Manifest V3 module-type service worker.
*/ */
// Fired when the extension is first installed / updated / Chrome starts. // === HLS manifest tracking via chrome.webRequest ===
chrome.runtime.onInstalled.addListener((details) => { // Intercepts network requests at the browser level — no world/timing issues.
if (details.reason === 'install') { // Catches .m3u8 manifest requests from ANY player (HLS.js, Video.js, custom).
console.log('[goon-plugin] Extension installed'); //
} else if (details.reason === 'update') { // IMPORTANT: MV3 service workers are ephemeral — they are killed when idle and
console.log(`[goon-plugin] Extension updated from ${details.previousVersion}`); // restarted on demand. In-memory state (Map, Set, etc.) is LOST on kill.
// Therefore, we persist captured manifests to chrome.storage.local so they
// survive service worker restarts.
function isHlsManifest(url) {
const u = url.toLowerCase();
// Exclude media segments
if (u.includes('.ts') || u.includes('.mp4') || u.includes('.webm') ||
u.includes('.aac') || u.includes('.m4s')) return false;
return u.includes('.m3u8') || u.includes('.hlsvod') ||
(u.includes('/hls/') && u.includes('.m3u')) ||
u.includes('/playlist.m3u') || u.includes('/manifest');
} }
// === STRATEGY 8: Network-level fallback — log ALL phncdn.com and video-related requests ===
// Pornhub's es6player generates HLS manifests in-memory with zero .m3u8 network requests.
// However, the player still fetches video segments from phncdn.com. By logging all phncdn
// requests we can: (a) confirm HLS activity via segment patterns, (b) reconstruct manifest
// URLs from segment paths, (c) detect CDN response headers that reveal playlist endpoints.
const PHNCDN_HOSTS = ['phncdn.com', 'pornhubpremium.com', 'cdn.pornhub.com'];
const VIDEO_PATH_PATTERNS = ['/hls/', '/dash/', '/video/', '/segment', '/media/', '/stream'];
function isVideoRequest(url) {
const u = url.toLowerCase();
// Already a known video/manifest extension
if (u.includes('.m3u8') || u.includes('.mpd') || u.includes('.hlsvod') ||
u.includes('.ts') || u.includes('.m4s') || u.includes('.mp4')) return true;
// Path-based detection for in-memory playlists
return VIDEO_PATH_PATTERNS.some(p => u.includes(p));
}
// Persist all video requests per tab (survives SW restarts)
async function persistVideoRequest(tabId, url) {
const key = 'vr_requests_' + tabId;
try {
const stored = await chrome.storage.local.get(key);
const arr = stored[key] || [];
// Avoid duplicates — phncdn segments come in rapid succession
if (!arr.some(r => r === url)) {
arr.push(url);
// Keep last 200 requests per tab (enough to detect segment patterns)
if (arr.length > 200) arr.splice(0, arr.length - 200);
await chrome.storage.local.set({ [key]: arr });
}
} catch(e) {
console.warn('[goon-bg] Failed to persist video request:', e && e.message ? e.message : e);
}
}
// --- phncdn token / URL helpers ------------------------------------------
//
// 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: /hls/videos/{..}/{variant}/seg-N.ts
if (!url.includes('/hls/videos/') || !/\/seg-\d+/.test(url)) continue;
const manifestUrl = buildManifestForVariant(url);
if (manifestUrl && !seen.has(manifestUrl)) {
seen.add(manifestUrl);
manifests.push(manifestUrl);
}
}
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;
const u = details.url.toLowerCase();
const isPhncdn = PHNCDN_HOSTS.some(host => details.url.includes(host));
const isVideo = isVideoRequest(details.url);
// Log ALL phncdn.com requests regardless of path
if (isPhncdn) {
console.log('[goon-bg] webRequest: phncdn request in tab', details.tabId, ':', details.url.substring(0, 180));
await persistVideoRequest(details.tabId, details.url);
}
// Log video-like requests from any host
if (isVideo && !isPhncdn) {
console.log('[goon-bg] webRequest: video request in tab', details.tabId, ':', details.url.substring(0, 150));
}
// Also capture HLS manifest URLs (original logic)
if (!isHlsManifest(details.url)) return;
console.log('[goon-bg] webRequest: HLS manifest in tab', details.tabId, ':', details.url.substring(0, 120));
const key = 'wr_manifests_' + details.tabId;
try {
const stored = await chrome.storage.local.get(key);
const arr = stored[key] || [];
if (!arr.includes(details.url)) {
arr.push(details.url);
await chrome.storage.local.set({ [key]: arr });
}
} catch(e) {
console.warn('[goon-bg] Failed to persist webRequest manifest:', e && e.message ? e.message : e);
}
},
{ urls: ['<all_urls>'] }
);
// Clean up stale entries when tabs are closed
chrome.tabs.onRemoved.addListener(async (tabId) => {
const keys = ['wr_manifests_' + tabId, 'vr_requests_' + tabId];
try {
await chrome.storage.local.remove(keys);
} catch { /* ignore */ }
}); });
// Listen for messages from popup or content scripts. // === Force a pornhub Referer on every *.phncdn.com request ===
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { // The phncdn video CDN (hv-h.phncdn.com, pix-fl.phncdn.com, ev-h.phncdn.com,
console.log('[goon-plugin] Received:', message); // ... 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) => {
switch (message.type) { switch (message.type) {
case 'PING': case 'PING':
sendResponse({ pong: true, timestamp: Date.now() }); sendResponse({ pong: true, timestamp: Date.now() });
break; return;
case 'GOON_GET_WEBREQUEST_MANIFESTS': {
(async () => {
const tabId = message.tabId;
const key = 'wr_manifests_' + tabId;
try {
const stored = await chrome.storage.local.get(key);
sendResponse({ manifests: stored[key] || [] });
} catch(e) {
sendResponse({ manifests: [] });
}
})();
return true;
}
case 'GOON_GET_PHNCDN_REQUESTS': {
// Strategy 8: Return all phncdn.com requests for a tab, including reconstructed manifests
(async () => {
const tabId = message.tabId;
const [segKey] = ['vr_requests_' + tabId];
try {
const stored = await chrome.storage.local.get(segKey);
const segments = stored[segKey] || [];
// Also reconstruct manifest URLs from segment patterns
const reconstructed = reconstructManifestsFromSegments(segments);
sendResponse({
segments: segments,
reconstructedManifests: reconstructed,
totalRequests: segments.length,
});
} catch(e) {
sendResponse({ segments: [], reconstructedManifests: [], totalRequests: 0 });
}
})();
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 () => {
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);
sendResponse({ error: `Unknown type: ${message.type}` });
break;
} }
return true; // keep sendResponse channel open for async work
}); });
+67 -120
View File
@@ -24,128 +24,32 @@
/** /**
* Inject interceptors into the MAIN world at document_start. * Inject interceptors into the MAIN world at document_start.
* Runs BEFORE any page JS, so we catch all HLS activity. *
* Uses script.src = chrome.runtime.getURL('interceptors.js') instead of
* script.textContent to bypass strict page CSP. Sites like Pornhub enforce
* 'script-src' that blocks inline execution (textContent). Chrome treats
* extension-originated <script src="..."> as content scripts — not as
* page inline code — so the page's CSP does not apply.
*
* IMPORTANT: Remove the script element ONLY after the load event fires.
* Removing it synchronously can prevent the browser from starting the fetch,
* especially in Edge/Chrome with strict timing.
*
* Runs BEFORE any page JS (document_start), so we catch all HLS activity.
*/ */
function injectInterceptors() { function injectInterceptors() {
const interceptCode = `
(function() {
if (window.__goon_plugin_intercepted) return;
window.__goon_plugin_intercepted = true;
window.__goon_plugin_data = window.__goon_plugin_data || {
manifests: [],
sources: []
};
// Helper: resolve relative URLs
function resolveUrl(url) {
if (!url) return null;
try {
if (url.startsWith('//')) return 'https:' + url;
if (url.startsWith('http')) return url;
return new URL(url, window.location.origin).href;
} catch { return null; }
}
// Helper: report manifest to content script via postMessage
function reportManifest(url, source) {
var resolved = resolveUrl(url);
if (!resolved) return;
if (window.__goon_plugin_data.manifests.includes(resolved)) return;
window.__goon_plugin_data.manifests.push(resolved);
window.postMessage({
type: 'GOON_PLUGIN_HLS_INTERCEPT',
url: resolved,
source: source,
timestamp: Date.now()
}, '*');
}
// === INTERCEPTORS ===
// 1. Intercept XMLHttpRequest for manifest-like requests
var originalOpen = XMLHttpRequest.prototype.open;
var originalSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function(method, url) {
this._goon_url = url;
return originalOpen.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function(body) {
if (this._goon_url && typeof this._goon_url === 'string') {
var resolved = resolveUrl(this._goon_url);
if (resolved && (resolved.includes('.m3u8') ||
resolved.includes('/playlist') ||
resolved.includes('/manifest'))) {
reportManifest(resolved, 'xhr');
}
}
return originalSend.apply(this, arguments);
};
// 2. Intercept fetch() for manifest-like requests
var originalFetch = window.fetch;
window.fetch = function(resource, init) {
var url = typeof resource === 'string' ? resource : (resource && resource.url);
if (url && typeof url === 'string') {
var resolved = resolveUrl(url);
if (resolved && (resolved.includes('.m3u8') ||
resolved.includes('/playlist') ||
resolved.includes('/manifest'))) {
reportManifest(resolved, 'fetch');
}
}
return originalFetch.apply(this, arguments);
};
// 3. Intercept HLS.js loadSource (catches HLS.js instances)
function patchHlsPrototype() {
if (typeof Hls === 'undefined') return;
var originalLoadSource = Hls.prototype.loadSource;
if (!originalLoadSource) return;
Hls.prototype.loadSource = function(url) {
reportManifest(url, 'hls_loadSource');
return originalLoadSource.apply(this, arguments);
};
var originalAttachMedia = Hls.prototype.attachMedia;
if (originalAttachMedia) {
Hls.prototype.attachMedia = function(media) {
if (media && media.tagName === 'VIDEO') {
media._goon_hls_manifest = this.url;
reportManifest(this.url, 'hls_attachMedia');
}
return originalAttachMedia.apply(this, arguments);
};
}
}
// Patch immediately if HLS is already defined
patchHlsPrototype();
// Watch for HLS.js to be dynamically defined later
var originalDefineProperty = Object.defineProperty;
var hlsPatched = false;
Object.defineProperty = function(obj, prop, descriptor) {
var result = originalDefineProperty.apply(this, arguments);
if (obj === window && prop === 'Hls' && !hlsPatched) {
hlsPatched = true;
patchHlsPrototype();
}
return result;
};
})();
`;
const script = document.createElement('script'); const script = document.createElement('script');
script.textContent = interceptCode; script.src = chrome.runtime.getURL('interceptors.js');
script.setAttribute('type', 'text/javascript'); script.setAttribute('type', 'text/javascript');
(document.head || document.documentElement).appendChild(script); script.onload = function () {
// Remove the script element after execution but keep interceptors active // Script loaded and executed — safe to remove the element now
if (script.parentNode) {
script.parentNode.removeChild(script); script.parentNode.removeChild(script);
} }
};
(document.head || document.documentElement).appendChild(script);
// Keep the element in the DOM — onload will remove it after execution
}
// Inject immediately at document_start // Inject immediately at document_start
injectInterceptors(); injectInterceptors();
@@ -164,6 +68,7 @@
timestamp: event.data.timestamp timestamp: event.data.timestamp
}); });
console.log('[goon-plugin] Intercepted HLS manifest via', source, ':', url); console.log('[goon-plugin] Intercepted HLS manifest via', source, ':', url);
console.log('[goon-plugin] Total intercepted manifests so far:', interceptedManifests.length);
} }
} }
}); });
@@ -190,34 +95,76 @@
return url; return url;
} }
} }
} catch(e) {} } catch {}
} }
return null; return null;
} }
/** /**
* Get video data: intercepted manifests + DOM scan + poster. * Get video data: intercepted manifests + DOM scan + poster + Hls.cast probe.
* Called by the popup via chrome.tabs.sendMessage. * Called by the popup via chrome.tabs.sendMessage.
*/ */
function getVideoData() { function getVideoData() {
const poster = extractPoster(); const poster = extractPoster();
console.log('[goon-content] getVideoData called. Poster:', poster);
console.log('[goon-content] Intercepted manifests count:', interceptedManifests.length);
if (interceptedManifests.length > 0) {
console.log('[goon-content] Manifest details:', JSON.stringify(interceptedManifests.map(m => ({ url: m.url, source: m.source }))));
}
// DOM video scan (isolated world — can access DOM but not page JS) // DOM video scan (isolated world — can access DOM but not page JS)
const videoElements = []; const videoElements = [];
try { try {
const videos = document.querySelectorAll('video'); const videos = document.querySelectorAll('video');
console.log('[goon-content] Found', videos.length, '<video> elements in DOM');
for (const video of videos) { for (const video of videos) {
const style = window.getComputedStyle(video); const style = window.getComputedStyle(video);
if (style.display !== 'none' && video.clientWidth > 0 && video.clientHeight > 0) { const isVisible = style.display !== 'none' && video.clientWidth > 0 && video.clientHeight > 0;
console.log('[goon-content] Video:', {
src: (video.currentSrc || video.src || '').substring(0, 120),
visible: isVisible,
dimensions: video.clientWidth + 'x' + video.clientHeight,
networkState: video.networkState,
readyState: video.readyState,
});
if (isVisible) {
// Extract buffered ranges as serializable arrays
const bufferedRanges = [];
if (video.buffered && video.buffered.length > 0) {
for (let b = 0; b < video.buffered.length; b++) {
bufferedRanges.push({ start: video.buffered.start(b), end: video.buffered.end(b) });
}
}
videoElements.push({ videoElements.push({
src: video.currentSrc || video.src || '', src: video.currentSrc || video.src || '',
poster: video.poster || '', poster: video.poster || '',
width: video.clientWidth, width: video.clientWidth,
height: video.clientHeight, height: video.clientHeight,
duration: video.duration || 0,
buffered: bufferedRanges,
}); });
} }
} }
} catch(e) { /* ignore DOM access errors */ } } catch(e) { /* ignore DOM access errors */
console.warn('[goon-content] DOM scan error:', e && e.message ? e.message : e);
}
console.log('[goon-content] Visible video elements:', videoElements.length);
// Expose interceptor data globally so the popup's deep probe can read it.
window.__goon_plugin_data = window.__goon_plugin_data || {
manifests: [],
sources: [],
_contentType: 'content-script',
};
// Merge our intercepted manifests into the shared data store (the interceptors
// write their own URLs here; we add content-script-level XHR intercepts).
for (const m of interceptedManifests) {
if (!window.__goon_plugin_data.manifests.includes(m.url)) {
window.__goon_plugin_data.manifests.push(m.url);
window.__goon_plugin_data.sources.push(m.source);
}
}
return { return {
posterUrl: poster, posterUrl: poster,
+2
View File
@@ -0,0 +1,2 @@
!function t(e){var r,i;r=this,i=function(){"use strict";function r(t,e){var r=Object.keys(t);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(t);e&&(i=i.filter((function(e){return Object.getOwnPropertyDescriptor(t,e).enumerable}))),r.push.apply(r,i)}return r}function i(t){for(var e=1;e<arguments.length;e++){var i=null!=arguments[e]?arguments[e]:{};e%2?r(Object(i),!0).forEach((function(e){var r,a,s;r=t,a=e,s=i[e],(a=n(a))in r?Object.defineProperty(r,a,{value:s,enumerable:!0,configurable:!0,writable:!0}):r[a]=s})):Object.getOwnPropertyDescriptors?Object.defineProperties(t,Object.getOwnPropertyDescriptors(i)):r(Object(i)).forEach((function(e){Object.defineProperty(t,e,Object.getOwnPropertyDescriptor(i,e))}))}return t}function n(t){var e=function(t,e){if("object"!=typeof t||!t)return t;var r=t[Symbol.toPrimitive];if(void 0!==r){var i=r.call(t,e||"default");if("object"!=typeof i)return i;throw new TypeError("@@toPrimitive must return a primitive value.")}return("string"===e?String:Number)(t)}(t,"string");return"symbol"==typeof e?e:String(e)}function a(t,e){for(var r=0;r<e.length;r++){var i=e[r];i.enumerable=i.enumerable||!1,i.configurable=!0,"value"in i&&(i.writable=!0),Object.defineProperty(t,n(i.key),i)}}function s(t,e,r){return e&&a(t.prototype,e),r&&a(t,r),Object.defineProperty(t,"prototype",{writable:!1}),t}function o(){return o=Object.assign?Object.assign.bind():function(t){for(var e=1;e<arguments.length;e++){var r=arguments[e];for(var i in r)Object.prototype.hasOwnProperty.call(r,i)&&(t[i]=r[i])}return t},o.apply(this,arguments)}function l(t,e){t.prototype=Object.create(e.prototype),t.prototype.constructor=t,h(t,e)}function u(t){return u=Object.setPrototypeOf?Object.getPrototypeOf.bind():function(t){return t.__proto__||Object.getPrototypeOf(t)},u(t)}function h(t,e){return h=Object.setPrototypeOf?Object.setPrototypeOf.bind():function(t,e){return t.__proto__=e,t},h(t,e)}function d(t,e,r){return d=function(){if("undefined"==typeof Reflect||!Reflect.construct)return!1;if(Reflect.construct.sham)return!1;if("function"==typeof Proxy)return!0;try{return Boolean.prototype.valueOf.call(Reflect.construct(Boolean,[],(function(){}))),!0}catch(t){return!1}}()?Reflect.construct.bind():function(t,e,r){var i=[null];i.push.apply(i,e);var n=new(Function.bind.apply(t,i));return r&&h(n,r.prototype),n},d.apply(null,arguments)}function c(t){var e="function"==typeof Map?new Map:void 0;return c=function(t){if(null===t||!function(t){try{return-1!==Function.toString.call(t).indexOf("[native code]")}catch(e){return"function"==typeof t}}(t))return t;if("function"!=typeof t)throw new TypeError("Super expression must either be null or a function");if(void 0!==e){if(e.has(t))return e.get(t);e.set(t,r)}function r(){return d(t,arguments,u(this).constructor)}return r.prototype=Object.create(t.prototype,{constructor:{value:r,enumerable:!1,writable:!0,configurable:!0}}),h(r,t)},c(t)}function f(t,e){(null==e||e>t.length)&&(e=t.length);for(var r=0,i=new Array(e);r<e;r++)i[r]=t[r];return i}function g(t,e){var r="undefined"!=typeof Symbol&&t[Symbol.iterator]||t["@@iterator"];if(r)return(r=r.call(t)).next.bind(r);if(Array.isArray(t)||(r=function(t,e){if(t){if("string"==typeof t)return f(t,e);var r=Object.prototype.toString.call(t).slice(8,-1);return"Object"===r&&t.constructor&&(r=t.constructor.name),"Map"===r||"Set"===r?Array.from(t):"Arguments"===r||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(r)?f(t,e):void 0}}(t))||e&&t&&"number"==typeof t.length){r&&(t=r);var i=0;return function(){return i>=t.length?{done:!0}:{done:!1,value:t[i++]}}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}function v(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var m={exports:{}};!function(t,e){var r,i,n,a,s;r=/^(?=((?:[a-zA-Z0-9+\-.]+:)?))\1(?=((?:\/\/[^\/?#]*)?))\2(?=((?:(?:[^?#\/]*\/)*[^;?#\/]*)?))\3((?:;[^?#]*)?)(\?[^#]*)?(#[^]*)?$/,i=/^(?=([^\/?#]*))\1([^]*)$/,n=/(?:\/|^)\.(?=\/)/g,a=/(?:\/|^)\.\.\/(?!\.\.\/)[^\/]*(?=\/)/g,s={buildAbsoluteURL:function(t,e,r){if(r=r||{},t=t.trim(),!(e=e.trim())){if(!r.alwaysNormalize)return t;var n=s.parseURL(t);if(!n)throw new Error("Error trying to parse base URL.");return n.path=s.normalizePath(n.path),s.buildURLFromParts(n)}var a=s.parseURL(e);if(!a)throw new Error("Error trying to parse relative URL.");if(a.scheme)return r.alwaysNormalize?(a.path=s.normalizePath(a.path),s.buildURLFromParts(a)):e;var o=s.parseURL(t);if(!o)throw new Error("Error trying to parse base URL.");if(!o.netLoc&&o.path&&"/"!==o.path[0]){var l=i.exec(o.path);o.netLoc=l[1],o.path=l[2]}o.netLoc&&!o.path&&(o.path="/");var u={scheme:o.scheme,netLoc:a.netLoc,path:null,params:a.params,query:a.query,fragment:a.fragment};if(!a.netLoc&&(u.netLoc=o.netLoc,"/"!==a.path[0]))if(a.path){var h=o.path,d=h.substring(0,h.lastIndexOf("/")+1)+a.path;u.path=s.normalizePath(d)}else u.path=o.path,a.paramsLine truncated
//# sourceMappingURL=hls.min.js.map
+168
View File
@@ -0,0 +1,168 @@
/**
* interceptors.js — Main-world interceptors for HLS manifest detection.
*
* This file is injected into the main world via a <script src="..."> tag
* (NOT inline textContent) to bypass strict CSP on sites like Pornhub.
*
* When loaded as an external script from the extension origin, Chrome treats
* it as an extension content script — not as page inline code — so the
* page's script-src CSP does not apply.
*
* Loaded by content.js at document_start before any page JS runs.
*/
(function () {
'use strict';
if (window.__goon_plugin_intercepted) return;
window.__goon_plugin_intercepted = true;
window.__goon_plugin_data = window.__goon_plugin_data || {
manifests: [],
sources: [],
};
// Helper: resolve relative URLs
function resolveUrl(url) {
if (!url) return null;
try {
if (url.startsWith('//')) return 'https:' + url;
if (url.startsWith('http')) return url;
return new URL(url, window.location.origin).href;
} catch {
return null;
}
}
// Helper: check if a URL looks like an HLS manifest
function isManifestUrl(url) {
var u = url.toLowerCase();
return u.includes('.m3u8') || u.includes('.hlsvod') ||
u.includes('/hls/') || u.includes('/playlist') || u.includes('/manifest');
}
// Helper: report manifest to content script via postMessage
function reportManifest(url, source) {
var resolved = resolveUrl(url);
if (!resolved) return;
if (window.__goon_plugin_data.manifests.includes(resolved)) return;
window.__goon_plugin_data.manifests.push(resolved);
window.postMessage(
{
type: 'GOON_PLUGIN_HLS_INTERCEPT',
url: resolved,
source: source,
timestamp: Date.now(),
},
'*',
);
}
// === INTERCEPTORS ===
// 1. Intercept XMLHttpRequest for manifest-like requests
var originalOpen = XMLHttpRequest.prototype.open;
var originalSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (method, url) {
this._goon_url = url;
return originalOpen.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function (_body) {
if (this._goon_url && typeof this._goon_url === 'string') {
var resolved = resolveUrl(this._goon_url);
if (resolved && isManifestUrl(resolved)) {
reportManifest(resolved, 'xhr');
}
}
return originalSend.apply(this, arguments);
};
// 2. Intercept fetch() for manifest-like requests
var originalFetch = window.fetch;
window.fetch = function (resource, _init) {
var url = typeof resource === 'string' ? resource : resource && resource.url;
if (url && typeof url === 'string') {
var resolved = resolveUrl(url);
if (resolved && isManifestUrl(resolved)) {
reportManifest(resolved, 'fetch');
}
}
return originalFetch.apply(this, arguments);
};
// 3. Intercept HLS.js loadSource (catches HLS.js instances)
// Guard against double-patching: only wrap if the method hasn't been patched yet.
var hlsPatched = false;
function patchHlsPrototype() {
if (typeof Hls === 'undefined') return;
if (hlsPatched) return; // Already patched — skip
var originalLoadSource = Hls.prototype.loadSource;
if (!originalLoadSource) return;
Hls.prototype.loadSource = function (url) {
reportManifest(url, 'hls_loadSource');
return originalLoadSource.apply(this, arguments);
};
var originalAttachMedia = Hls.prototype.attachMedia;
if (originalAttachMedia) {
Hls.prototype.attachMedia = function (media) {
if (media && media.tagName === 'VIDEO') {
media._goon_hls_manifest = this.url;
reportManifest(this.url, 'hls_attachMedia');
}
return originalAttachMedia.apply(this, arguments);
};
}
hlsPatched = true;
}
// Patch immediately if HLS is already defined
patchHlsPrototype();
// Watch for HLS.js to be dynamically defined later.
// Use a combination of:
// 1. Longer polling window (30s instead of 3s) to catch lazy-loaded players.
// 2. MutationObserver on <head> for new <script> tags — catches late hls.js loads.
if (!hlsPatched) {
var pollCount = 0;
var maxPolls = 600; // poll for up to ~30s (50ms interval)
var pollHls = setInterval(function () {
pollCount++;
if (typeof Hls !== 'undefined' && !hlsPatched) {
patchHlsPrototype();
if (hlsPatched) clearInterval(pollHls);
}
if (pollCount >= maxPolls) clearInterval(pollHls);
}, 50);
// MutationObserver: watches for new <script> tags that may load hls.js late.
// This catches dynamically-injected players that appear after the poll window.
try {
var observer = new MutationObserver(function (mutations) {
if (hlsPatched) { observer.disconnect(); return; }
for (var m = 0; m < mutations.length; m++) {
var nodes = mutations[m].addedNodes;
if (!nodes) continue;
for (var n = 0; n < nodes.length; n++) {
var node = nodes[n];
if (node.tagName === 'SCRIPT' && node.src) {
var src = node.src.toLowerCase();
if (src.includes('hls') || src.includes('videojs') || src.includes('player')) {
// Script tag loaded — check if Hls is now defined
if (typeof Hls !== 'undefined' && !hlsPatched) {
patchHlsPrototype();
if (hlsPatched) observer.disconnect();
}
}
}
}
}
});
observer.observe(document.head || document.documentElement, { childList: true, subtree: true });
} catch { /* MutationObserver not available — polling still works */ }
}
})();
+2 -2
View File
@@ -14,10 +14,10 @@
/** /**
* Media delivery format of the video source. * Media delivery format of the video source.
* - 'direct' : plain MP4/WebM blob served via HTTP(S) * - 'video' : plain MP4/WebM blob served via HTTP(S)
* - 'hls' : HTTP Live Streaming manifest (.m3u8) * - 'hls' : HTTP Live Streaming manifest (.m3u8)
*/ */
export type MediaType = 'direct' | 'hls'; export type MediaType = 'video' | 'hls';
/** /**
* A single video entry discovered by a tab scan. * A single video entry discovered by a tab scan.
+1152 -435
View File
File diff suppressed because it is too large. Load diff
+481
View File
@@ -0,0 +1,481 @@
/**
* index.test.js - Unit tests for popup scanning pipeline helpers.
*
* Verifies that the exported pipeline functions correctly handle:
* - HLS metadata preservation through dedup
* - Tab filtering (system URLs excluded)
* - Manifest deduplication and prioritization
* - Video entry normalization
*/
import { describe, it, expect } from 'vitest';
/* ========================================================================== */
/* Simulated pipeline helpers (mirror of index.js exported functions) */
/* ========================================================================== */
/**
* Simulate `filterValidTabs` from index.js.
*/
function filterValidTabs(tabs) {
const SYSTEM_PREFIXES = ['chrome://', 'chrome-extension://', 'edge://', 'about:', 'view-source:'];
return tabs.filter(t =>
t.url && !SYSTEM_PREFIXES.some(prefix => t.url.startsWith(prefix))
);
}
/**
* Simulate `dedupManifests` from index.js.
*/
function dedupManifests(allManifests) {
const seenUrls = new Set();
const unique = allManifests.filter(m => {
if (seenUrls.has(m.url)) return false;
seenUrls.add(m.url);
return true;
});
unique.sort((a, b) => {
const aInline = a.player === 'inline-script' ? 1 : 0;
const bInline = b.player === 'inline-script' ? 1 : 0;
return aInline - bInline;
});
return unique;
}
/**
* Simulate `makeVideoEntry` from index.js.
*/
function makeVideoEntry(video, tabTitle) {
const entry = {
src: video.hlsManifest || video.src || '',
poster: video.poster || null,
tab: tabTitle,
mediaType: video.mediaType || 'video',
};
if (video.hlsManifest) entry.hlsManifest = video.hlsManifest;
return entry;
}
/**
* Simulate the dedup + storage pipeline from index.js scanVideos().
* Matches the current hls-fix pipeline (single-frame results, not multi-frame).
*/
function simulateStoragePipeline(tabResults) {
const seen = new Map();
for (const group of tabResults) {
if (!group.result || !group.result.videos?.length) continue;
for (const v of group.result.videos) {
const primarySrc = v.hlsManifest || v.src || '(empty)';
const key = primarySrc + '||' + (v.poster || '(none)');
if (!seen.has(key)) {
seen.set(key, makeVideoEntry(v, group.tabTitle));
}
}
}
const items = [...seen.values()];
// Storage format
const storedItems = items.map(v => ({
src: v.src,
poster: v.poster,
tab: v.tab,
mediaType: v.mediaType || 'video',
hlsManifest: v.hlsManifest || undefined,
}));
return { storedItems };
}
/* ========================================================================== */
/* TESTS: Tab filtering */
/* ========================================================================== */
describe('filterValidTabs', () => {
it('excludes chrome:// URLs', () => {
const tabs = [{ url: 'chrome://settings' }, { url: 'https://example.com' }];
const result = filterValidTabs(tabs);
expect(result).toHaveLength(1);
expect(result[0].url).toBe('https://example.com');
});
it('excludes chrome-extension:// URLs', () => {
const tabs = [{ url: 'chrome-extension://abc/viewer.html' }];
expect(filterValidTabs(tabs)).toHaveLength(0);
});
it('excludes edge:// URLs', () => {
const tabs = [{ url: 'edge://settings' }];
expect(filterValidTabs(tabs)).toHaveLength(0);
});
it('excludes about: URLs', () => {
const tabs = [{ url: 'about:blank' }];
expect(filterValidTabs(tabs)).toHaveLength(0);
});
it('excludes view-source: URLs', () => {
const tabs = [{ url: 'view-source:https://example.com' }];
expect(filterValidTabs(tabs)).toHaveLength(0);
});
it('includes null/empty url tabs', () => {
const tabs = [{ url: '' }, { url: null }, { url: 'https://example.com' }];
const result = filterValidTabs(tabs);
expect(result).toHaveLength(1);
});
});
/* ========================================================================== */
/* TESTS: Manifest dedup and prioritization */
/* ========================================================================== */
describe('dedupManifests', () => {
it('removes duplicate URLs', () => {
const manifests = [
{ url: 'https://a.com/1.m3u8', player: 'hls' },
{ url: 'https://a.com/1.m3u8', player: 'inline-script' },
{ url: 'https://b.com/2.m3u8', player: 'hls' },
];
const result = dedupManifests(manifests);
expect(result).toHaveLength(2);
expect(result[0].url).toBe('https://a.com/1.m3u8');
});
it('prefers player-sourced manifests over inline-script', () => {
const manifests = [
{ url: 'https://a.com/1.m3u8', player: 'inline-script' },
{ url: 'https://a.com/1.m3u8', player: 'hls' },
];
const result = dedupManifests(manifests);
expect(result).toHaveLength(1);
expect(result[0].player).toBe('inline-script'); // first occurrence wins
});
});
/* ========================================================================== */
/* TESTS: Video entry normalization */
/* ========================================================================== */
describe('makeVideoEntry', () => {
it('normalizes HLS video entry', () => {
const video = {
src: 'blob:https://example.com/xyz',
poster: 'https://example.com/thumb.jpg',
mediaType: 'hls',
hlsManifest: 'https://cdn.example.com/stream.m3u8',
};
const entry = makeVideoEntry(video, 'Example Page');
expect(entry.src).toBe('https://cdn.example.com/stream.m3u8');
expect(entry.poster).toBe('https://example.com/thumb.jpg');
expect(entry.tab).toBe('Example Page');
expect(entry.mediaType).toBe('hls');
expect(entry.hlsManifest).toBe('https://cdn.example.com/stream.m3u8');
});
it('normalizes standard video entry', () => {
const video = {
src: 'https://example.com/video.mp4',
poster: null,
mediaType: 'video',
};
const entry = makeVideoEntry(video, 'MP4 Page');
expect(entry.src).toBe('https://example.com/video.mp4');
expect(entry.mediaType).toBe('video');
expect(entry.hlsManifest).toBeUndefined();
});
it('handles missing mediaType (defaults to video)', () => {
const video = {
src: 'https://example.com/video.webm',
};
const entry = makeVideoEntry(video, 'WebM Page');
expect(entry.mediaType).toBe('video');
});
});
/* ========================================================================== */
/* TESTS: Storage pipeline (HLS metadata preservation) */
/* ========================================================================== */
describe('Storage pipeline - HLS metadata preservation', () => {
it('preserves HLS metadata through dedup', () => {
const tabResults = [{
tabTitle: 'HLS Stream Page',
result: {
videos: [{
src: 'https://example.com/stream.m3u8',
poster: null,
mediaType: 'hls',
hlsManifest: 'https://example.com/stream.m3u8',
}],
videoCount: 1,
},
}];
const { storedItems } = simulateStoragePipeline(tabResults);
expect(storedItems[0].mediaType).toBe('hls');
expect(storedItems[0].hlsManifest).toBe('https://example.com/stream.m3u8');
});
it('preserves hlsManifest for live streams', () => {
const tabResults = [{
tabTitle: 'Live Stream',
result: {
videos: [{
src: 'https://cdn.example.com/live.m3u8',
poster: 'https://cdn.example.com/thumb.jpg',
mediaType: 'hls',
hlsManifest: 'https://cdn.example.com/live.m3u8',
}],
videoCount: 1,
},
}];
const { storedItems } = simulateStoragePipeline(tabResults);
expect(storedItems[0].hlsManifest).toBe('https://cdn.example.com/live.m3u8');
expect(storedItems[0].mediaType).toBe('hls');
});
it('preserves standard video entries correctly', () => {
const tabResults = [{
tabTitle: 'MP4 Page',
result: {
videos: [{
src: 'https://example.com/video.mp4',
poster: 'https://example.com/thumb.jpg',
mediaType: 'video',
}],
videoCount: 1,
},
}];
const { storedItems } = simulateStoragePipeline(tabResults);
expect(storedItems[0].src).toBe('https://example.com/video.mp4');
expect(storedItems[0].mediaType).toBe('video');
expect(storedItems[0].poster).toBe('https://example.com/thumb.jpg');
expect(storedItems[0].tab).toBe('MP4 Page');
});
it('deduplicates across tabs by hlsManifest', () => {
const tabResults = [
{
tabTitle: 'Tab 1',
result: {
videos: [{
src: 'https://example.com/stream.m3u8',
poster: null,
mediaType: 'hls',
hlsManifest: 'https://example.com/stream.m3u8',
}],
videoCount: 1,
},
},
{
tabTitle: 'Tab 2',
result: {
videos: [{
src: 'https://example.com/stream.m3u8',
poster: null,
mediaType: 'hls',
hlsManifest: 'https://example.com/stream.m3u8',
}],
videoCount: 1,
},
},
];
const { storedItems } = simulateStoragePipeline(tabResults);
expect(storedItems).toHaveLength(1);
expect(storedItems[0].tab).toBe('Tab 1'); // first tab wins
});
it('keeps different HLS streams from same tab', () => {
const tabResults = [{
tabTitle: 'Mixed Page',
result: {
videos: [{
src: 'https://example.com/live.m3u8',
poster: null,
mediaType: 'hls',
hlsManifest: 'https://example.com/live.m3u8',
}],
videoCount: 1,
},
}, {
tabTitle: 'Recording Page',
result: {
videos: [{
src: 'https://example.com/recording.mp4',
poster: 'https://example.com/thumb.jpg',
mediaType: 'video',
}],
videoCount: 1,
},
}];
const { storedItems } = simulateStoragePipeline(tabResults);
expect(storedItems).toHaveLength(2);
expect(storedItems[0].mediaType).toBe('hls');
expect(storedItems[1].mediaType).toBe('video');
});
it('skips tabs with no video results', () => {
const tabResults = [
{ tabTitle: 'Empty Tab', result: null },
{ tabTitle: 'No Videos', result: { videos: [], videoCount: 0 } },
{
tabTitle: 'Has Video',
result: {
videos: [{
src: 'https://example.com/video.mp4',
poster: null,
mediaType: 'video',
}],
videoCount: 1,
},
},
];
const { storedItems } = simulateStoragePipeline(tabResults);
expect(storedItems).toHaveLength(1);
expect(storedItems[0].tab).toBe('Has Video');
});
});
/* ========================================================================== */
/* TESTS: scrapeFn — HLS detection strategies */
/* ========================================================================== */
/**
* Minimal DOM setup for scrapeFn tests.
* Simulates how scrapeFn detects HLS manifests from video elements
* using the 5-priority chain.
*/
function simulateScrapeFn(videos, hlsManifests) {
// Simulate the scrapeFn detection logic (priorities 1-5)
const results = [];
for (const v of videos) {
const rawSrc = v.currentSrc || v.src || '';
let hlsManifest = null;
let mediaType = 'video';
let src = rawSrc;
// Priority 1: Direct .m3u8 source
if (rawSrc.includes('.m3u8') && rawSrc.startsWith('http')) {
hlsManifest = rawSrc;
mediaType = 'hls';
}
// Priority 2: Interceptor annotation
else if (v._goon_hls_manifest) {
hlsManifest = v._goon_hls_manifest;
mediaType = 'hls';
src = hlsManifest;
}
// Priority 3: Hls.cast (simulated via v._hls_cast_result)
else if (v._hls_cast_result && v._hls_cast_result.url) {
hlsManifest = v._hls_cast_result.url;
mediaType = 'hls';
src = hlsManifest;
}
// Priority 4: Direct HTTP source (non-blob)
if (!hlsManifest && rawSrc.startsWith('http') && !rawSrc.startsWith('blob:')) {
mediaType = 'video';
}
// Priority 5: Deep probe fallback
if (!hlsManifest && hlsManifests && hlsManifests.length > 0) {
hlsManifest = hlsManifests[0].url || hlsManifests[0];
mediaType = 'hls';
src = hlsManifest;
}
results.push({ src, hlsManifest, mediaType });
}
return results;
}
describe('scrapeFn — HLS detection priorities', () => {
it('Priority 1: Direct .m3u8 in video.src', () => {
const videos = [{
src: 'https://example.com/stream.m3u8',
currentSrc: 'https://example.com/stream.m3u8',
}];
const result = simulateScrapeFn(videos, []);
expect(result[0].mediaType).toBe('hls');
expect(result[0].hlsManifest).toBe('https://example.com/stream.m3u8');
});
it('Priority 2: Interceptor-set _goon_hls_manifest overrides blob src', () => {
const videos = [{
src: 'blob:https://example.com/xyz',
currentSrc: 'blob:https://example.com/xyz',
_goon_hls_manifest: 'https://cdn.example.com/live.m3u8',
}];
const result = simulateScrapeFn(videos, []);
expect(result[0].mediaType).toBe('hls');
expect(result[0].hlsManifest).toBe('https://cdn.example.com/live.m3u8');
});
it('Priority 3: Hls.cast(video) detects active HLS instance', () => {
const videos = [{
src: 'blob:https://example.com/abc',
currentSrc: 'blob:https://example.com/abc',
_hls_cast_result: { url: 'https://cdn.example.com/vod.m3u8' },
}];
const result = simulateScrapeFn(videos, []);
expect(result[0].mediaType).toBe('hls');
expect(result[0].hlsManifest).toBe('https://cdn.example.com/vod.m3u8');
});
it('Priority 4: blob src with no HLS detection → filtered out', () => {
const videos = [{
src: 'blob:https://example.com/def',
currentSrc: 'blob:https://example.com/def',
}];
const result = simulateScrapeFn(videos, []);
expect(result[0].mediaType).toBe('video');
expect(result[0].hlsManifest).toBeNull();
});
it('Priority 5: Deep probe fallback when no direct HLS found', () => {
const videos = [{
src: 'https://example.com/page.html',
currentSrc: 'blob:https://example.com/ghi',
}];
const manifests = [{ url: 'https://cdn.example.com/fallback.m3u8' }];
const result = simulateScrapeFn(videos, manifests);
expect(result[0].mediaType).toBe('hls');
expect(result[0].hlsManifest).toBe('https://cdn.example.com/fallback.m3u8');
});
it('Priority 2 overrides Priority 5 (interceptor wins over deep probe)', () => {
const videos = [{
src: 'blob:https://example.com/jkl',
currentSrc: 'blob:https://example.com/jkl',
_goon_hls_manifest: 'https://cdn.example.com/interceptor.m3u8',
}];
const manifests = [{ url: 'https://cdn.example.com/deepprobe.m3u8' }];
const result = simulateScrapeFn(videos, manifests);
expect(result[0].mediaType).toBe('hls');
expect(result[0].hlsManifest).toBe('https://cdn.example.com/interceptor.m3u8');
});
it('HLS instance on video with no currentSrc still detected via interceptor', () => {
const videos = [{
src: '',
currentSrc: '',
_goon_hls_manifest: 'https://cdn.example.com/early.m3u8',
}];
const result = simulateScrapeFn(videos, []);
expect(result[0].mediaType).toBe('hls');
expect(result[0].hlsManifest).toBe('https://cdn.example.com/early.m3u8');
});
it('Direct MP4 source is not falsely flagged as HLS', () => {
const videos = [{
src: 'https://example.com/video.mp4',
currentSrc: 'https://example.com/video.mp4',
}];
const result = simulateScrapeFn(videos, []);
expect(result[0].mediaType).toBe('video');
expect(result[0].hlsManifest).toBeNull();
});
});
+1148 -129
View File
File diff suppressed because it is too large. Load diff
+976
View File
@@ -0,0 +1,976 @@
/**
* viewer.test.js — Integration tests for viewer.js panel rendering.
*
* Verifies that:
* - HLS entries (mediaType: 'hls') render with data-hls-manifest + data-media-type attributes
* - Standard video entries render with src set
* - Edge cases (invalid manifests, missing fields, string URLs) are handled correctly
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { JSDOM } from 'jsdom';
/* ------------------------------------------------------------------ */
/* Helpers: mount a minimal DOM that mimics viewer.html */
/* ------------------------------------------------------------------ */
function mountViewerDOM() {
const html = `
<!DOCTYPE html>
<html lang="en">
<head><title>Goon Viewer</title></head>
<body>
<div id="panels"></div>
</body>
</html>
`;
const dom = new JSDOM(html, {
url: 'chrome-extension://test/viewer/viewer.html?id=test123&grid=2',
runScripts: 'dangerously',
});
return {
dom,
window: dom.window,
document: dom.window.document,
panelsEl: dom.window.document.getElementById('panels'),
location: dom.window.location,
URLSearchParams: dom.window.URLSearchParams,
};
}
/* ------------------------------------------------------------------ */
/* Simulate the panel-rendering loop from viewer.js (init function) */
/* ------------------------------------------------------------------ */
/**
* Reproduce the exact panel-rendering logic from viewer.js so we can
* unit-test it without chrome.storage or live browser globals.
*/
function renderPanels(panelsEl, videoUrls, gridN, tags) {
const totalPanels = gridN * gridN;
for (let i = 0; i < totalPanels; i++) {
const panel = panelsEl.ownerDocument.createElement('div');
panel.classList.add('panel');
if (i < videoUrls.length && videoUrls[i]) {
const entry = (typeof videoUrls[i] === 'object') ? videoUrls[i] : { src: videoUrls[i] };
const mediaType = entry.mediaType || 'video';
const video = panelsEl.ownerDocument.createElement('video');
video.autoplay = true;
video.muted = true;
video.loop = true;
video.playsInline = true;
// Set poster if available
if (entry.poster) {
video.poster = entry.poster;
}
if (mediaType === 'hls' && entry.hlsManifest) {
// HLS stream
video.dataset.hlsManifest = entry.hlsManifest;
video.dataset.mediaType = 'hls';
} else {
// Direct video source
video.src = entry.src || entry.url || '';
}
panel.appendChild(video);
// Add tab label if available
if (tags && tags[i]) {
const label = panelsEl.ownerDocument.createElement('div');
label.className = 'panel-label';
label.textContent = tags[i];
panel.appendChild(label);
}
} else {
panel.classList.add('empty');
}
panelsEl.appendChild(panel);
}
panelsEl.style.gridTemplateColumns = `repeat(${gridN}, 1fr)`;
panelsEl.style.gridTemplateRows = `repeat(${gridN}, 1fr)`;
}
/* ========================================================================== */
/* TESTS */
/* ========================================================================== */
describe('HLS playback — panel rendering', () => {
let panelsEl;
beforeEach(() => {
// Re-mount fresh DOM before each test
panelsEl = mountViewerDOM().panelsEl;
});
it('renders HLS entry with data-hls-manifest and data-media-type attributes', () => {
const entries = [
{ mediaType: 'hls', hlsManifest: 'https://example.com/stream.m3u8', src: '' },
];
renderPanels(panelsEl, entries, 1, []);
const video = panelsEl.querySelector('video');
expect(video).not.toBeNull();
expect(video.dataset.hlsManifest).toBe('https://example.com/stream.m3u8');
expect(video.dataset.mediaType).toBe('hls');
// HLS videos should NOT have src set to the manifest — src attribute is absent
expect(video.hasAttribute('src')).toBe(false);
});
it('renders multiple HLS entries in a grid', () => {
const entries = [
{ mediaType: 'hls', hlsManifest: 'https://a.com/1.m3u8' },
{ mediaType: 'hls', hlsManifest: 'https://b.com/2.m3u8' },
{ mediaType: 'hls', hlsManifest: 'https://c.com/3.m3u8' },
{ mediaType: 'hls', hlsManifest: 'https://d.com/4.m3u8' },
];
renderPanels(panelsEl, entries, 2, []);
const videos = panelsEl.querySelectorAll('video');
expect(videos.length).toBe(4);
expect(videos[0].dataset.hlsManifest).toBe('https://a.com/1.m3u8');
expect(videos[1].dataset.hlsManifest).toBe('https://b.com/2.m3u8');
expect(videos[2].dataset.hlsManifest).toBe('https://c.com/3.m3u8');
expect(videos[3].dataset.hlsManifest).toBe('https://d.com/4.m3u8');
videos.forEach(v => expect(v.dataset.mediaType).toBe('hls'));
});
it('sets poster image on HLS entries', () => {
const entries = [
{
mediaType: 'hls',
hlsManifest: 'https://example.com/stream.m3u8',
poster: 'https://example.com/thumb.jpg',
},
];
renderPanels(panelsEl, entries, 1, []);
const video = panelsEl.querySelector('video');
expect(video.poster).toBe('https://example.com/thumb.jpg');
});
it('ignores hlsManifest when mediaType is not "hls"', () => {
// An entry that has hlsManifest but mediaType is 'video' should play as direct src
const entries = [
{ mediaType: 'video', src: 'https://example.com/video.mp4', hlsManifest: 'https://example.com/stream.m3u8' },
];
renderPanels(panelsEl, entries, 1, []);
const video = panelsEl.querySelector('video');
expect(video.src).toBe('https://example.com/video.mp4');
expect(video.dataset.hlsManifest).toBeUndefined();
expect(video.dataset.mediaType).toBeUndefined();
});
it('renders HLS entry with hlsManifest but no src', () => {
const entries = [
{ mediaType: 'hls', hlsManifest: 'https://example.com/stream.m3u8' },
];
renderPanels(panelsEl, entries, 1, []);
const video = panelsEl.querySelector('video');
expect(video.dataset.hlsManifest).toBe('https://example.com/stream.m3u8');
expect(video.dataset.mediaType).toBe('hls');
});
it('renders tab labels alongside HLS entries', () => {
const entries = [
{ mediaType: 'hls', hlsManifest: 'https://example.com/stream.m3u8' },
];
renderPanels(panelsEl, entries, 1, ['Live Stream']);
const video = panelsEl.querySelector('video');
const label = panelsEl.querySelector('.panel-label');
expect(video).not.toBeNull();
expect(label).not.toBeNull();
expect(label.textContent).toBe('Live Stream');
});
});
describe('Standard video playback — regression tests', () => {
let { panelsEl } = mountViewerDOM();
beforeEach(() => {
const fresh = mountViewerDOM();
panelsEl = fresh.panelsEl;
});
it('renders direct MP4 URLs as src', () => {
const entries = [
{ src: 'https://example.com/video.mp4' },
];
renderPanels(panelsEl, entries, 1, []);
const video = panelsEl.querySelector('video');
expect(video.src).toBe('https://example.com/video.mp4');
expect(video.dataset.hlsManifest).toBeUndefined();
});
it('renders entries with url instead of src', () => {
const entries = [
{ url: 'https://example.com/video.webm' },
];
renderPanels(panelsEl, entries, 1, []);
const video = panelsEl.querySelector('video');
expect(video.src).toBe('https://example.com/video.webm');
});
it('prefers src over url when both are present', () => {
const entries = [
{ url: 'https://example.com/page.html', src: 'https://example.com/video.mp4' },
];
renderPanels(panelsEl, entries, 1, []);
const video = panelsEl.querySelector('video');
expect(video.src).toBe('https://example.com/video.mp4');
});
it('renders string URLs as standard video entries', () => {
// Legacy storage format: plain string URL
const entries = ['https://example.com/video.mp4'];
renderPanels(panelsEl, entries, 1, []);
const video = panelsEl.querySelector('video');
expect(video.src).toBe('https://example.com/video.mp4');
expect(video.dataset.hlsManifest).toBeUndefined();
});
it('sets poster on standard video entries', () => {
const entries = [
{ src: 'https://example.com/video.mp4', poster: 'https://example.com/thumb.jpg' },
];
renderPanels(panelsEl, entries, 1, []);
const video = panelsEl.querySelector('video');
expect(video.poster).toBe('https://example.com/thumb.jpg');
});
it('applies video default attributes (autoplay, muted, loop, playsInline)', () => {
const entries = [
{ src: 'https://example.com/video.mp4' },
];
renderPanels(panelsEl, entries, 1, []);
const video = panelsEl.querySelector('video');
expect(video.autoplay).toBe(true);
expect(video.muted).toBe(true);
expect(video.loop).toBe(true);
expect(video.playsInline).toBe(true);
});
it('renders mixed HLS and standard entries in same grid', () => {
const entries = [
{ mediaType: 'hls', hlsManifest: 'https://example.com/live.m3u8' },
{ src: 'https://example.com/recording.mp4' },
{ mediaType: 'hls', hlsManifest: 'https://example.com/live2.m3u8' },
{ src: 'https://example.com/recording2.mp4' },
];
renderPanels(panelsEl, entries, 2, []);
const videos = panelsEl.querySelectorAll('video');
expect(videos.length).toBe(4);
// HLS entry
expect(videos[0].dataset.hlsManifest).toBe('https://example.com/live.m3u8');
expect(videos[0].dataset.mediaType).toBe('hls');
// Standard entry
expect(videos[1].src).toBe('https://example.com/recording.mp4');
expect(videos[1].dataset.hlsManifest).toBeUndefined();
// HLS entry
expect(videos[2].dataset.hlsManifest).toBe('https://example.com/live2.m3u8');
expect(videos[2].dataset.mediaType).toBe('hls');
// Standard entry
expect(videos[3].src).toBe('https://example.com/recording2.mp4');
expect(videos[3].dataset.hlsManifest).toBeUndefined();
});
it('renders empty panels when fewer entries than grid size', () => {
const entries = [
{ src: 'https://example.com/video.mp4' },
];
renderPanels(panelsEl, entries, 2, []);
const panels = panelsEl.querySelectorAll('.panel');
expect(panels.length).toBe(4); // 2x2 grid
expect(panels[0].classList.contains('empty')).toBe(false);
expect(panels[1].classList.contains('empty')).toBe(true);
expect(panels[2].classList.contains('empty')).toBe(true);
expect(panels[3].classList.contains('empty')).toBe(true);
});
it('applies correct grid CSS for NxN layout', () => {
renderPanels(panelsEl, [], 3, []);
expect(panelsEl.style.gridTemplateColumns).toBe('repeat(3, 1fr)');
expect(panelsEl.style.gridTemplateRows).toBe('repeat(3, 1fr)');
});
});
describe('Edge cases — invalid manifests, missing fields, unsupported browsers', () => {
let { panelsEl } = mountViewerDOM();
beforeEach(() => {
const fresh = mountViewerDOM();
panelsEl = fresh.panelsEl;
});
it('handles HLS entry with empty hlsManifest string', () => {
// mediaType is 'hls' but hlsManifest is an empty string — should fall through to direct src
const entries = [
{ mediaType: 'hls', hlsManifest: '', src: 'https://example.com/fallback.mp4' },
];
renderPanels(panelsEl, entries, 1, []);
const video = panelsEl.querySelector('video');
// Empty hlsManifest is falsy, so the else branch runs
expect(video.src).toBe('https://example.com/fallback.mp4');
expect(video.dataset.hlsManifest).toBeUndefined();
});
it('handles HLS entry with missing hlsManifest', () => {
const entries = [
{ mediaType: 'hls', src: 'https://example.com/video.mp4' },
];
renderPanels(panelsEl, entries, 1, []);
const video = panelsEl.querySelector('video');
expect(video.src).toBe('https://example.com/video.mp4');
});
it('handles empty src for non-HLS entry', () => {
const entries = [
{ mediaType: 'video', src: '', url: '' },
];
renderPanels(panelsEl, entries, 1, []);
const video = panelsEl.querySelector('video');
// JSDOM resolves empty src to the document base URL; check the raw attribute
expect(video.getAttribute('src')).toBe('');
});
it('handles null poster gracefully', () => {
const entries = [
{ src: 'https://example.com/video.mp4', poster: null },
];
renderPanels(panelsEl, entries, 1, []);
const video = panelsEl.querySelector('video');
// null poster should not crash; poster attribute defaults to ''
expect(video.poster).toBe('');
});
it('handles undefined entry fields without crashing', () => {
const entries = [
{},
];
renderPanels(panelsEl, entries, 1, []);
const video = panelsEl.querySelector('video');
expect(video).not.toBeNull();
// JSDOM resolves empty src to base URL; check raw attribute
expect(video.getAttribute('src')).toBe('');
});
it('handles null entry in the array', () => {
const entries = [null];
renderPanels(panelsEl, entries, 1, []);
const panels = panelsEl.querySelectorAll('.panel');
expect(panels[0].classList.contains('empty')).toBe(true);
});
it('handles malformed manifest URL (not .m3u8)', () => {
// Viewer doesn't validate manifest URL format — that's hls.js job
const entries = [
{ mediaType: 'hls', hlsManifest: 'https://example.com/invalid-manifest' },
];
renderPanels(panelsEl, entries, 1, []);
const video = panelsEl.querySelector('video');
expect(video.dataset.hlsManifest).toBe('https://example.com/invalid-manifest');
expect(video.dataset.mediaType).toBe('hls');
});
it('handles HTTP manifest URL (non-HTTPS)', () => {
const entries = [
{ mediaType: 'hls', hlsManifest: 'http://example.com/stream.m3u8' },
];
renderPanels(panelsEl, entries, 1, []);
const video = panelsEl.querySelector('video');
expect(video.dataset.hlsManifest).toBe('http://example.com/stream.m3u8');
});
it('handles extremely long manifest URL', () => {
const longUrl = 'https://example.com/stream.m3u8?' + 'x='.repeat(1000);
const entries = [
{ mediaType: 'hls', hlsManifest: longUrl },
];
renderPanels(panelsEl, entries, 1, []);
const video = panelsEl.querySelector('video');
expect(video.dataset.hlsManifest).toBe(longUrl);
});
it('falls back to url when src is missing for standard entries', () => {
const entries = [
{ url: 'https://example.com/video.mp4' },
];
renderPanels(panelsEl, entries, 1, []);
const video = panelsEl.querySelector('video');
expect(video.src).toBe('https://example.com/video.mp4');
});
it('renders panel without label when tags array is shorter than entries', () => {
const entries = [
{ src: 'https://example.com/video.mp4' },
{ src: 'https://example.com/video2.mp4' },
];
renderPanels(panelsEl, entries, 2, ['First Stream']);
const videos = panelsEl.querySelectorAll('video');
const labels = panelsEl.querySelectorAll('.panel-label');
expect(videos.length).toBe(2);
expect(labels.length).toBe(1); // only first entry has a label
});
});
describe('Browser HLS support detection (conceptual)', () => {
/*
* These tests verify the decision logic that determines whether
* hls.js should be loaded or native playback used.
*
* Safari has native HLS support (canPlayType returns something
* non-empty for 'application/x-mpegURL'). Other browsers need hls.js.
*/
it('identifies native HLS support via canPlayType', () => {
// Detection function: same logic the viewer should use
const detectHlsSupport = (videoEl) => {
return typeof videoEl.canPlayType === 'function' &&
videoEl.canPlayType('application/x-mpegURL') !== '';
};
const { document: doc } = mountViewerDOM();
const video = doc.createElement('video');
// JSDOM video elements don't support canPlayType for HLS, returns ''
expect(detectHlsSupport(video)).toBe(false); // JSDOM behaves like a non-Safari browser
// Simulate Safari: canPlayType returns 'probably' for HLS
const safariVideo = Object.assign(doc.createElement('video'), {
canPlayType: (type) => type === 'application/x-mpegURL' ? 'probably' : '',
});
expect(detectHlsSupport(safariVideo)).toBe(true);
});
it('uses hls.js when native HLS support is absent', () => {
const { document: doc } = mountViewerDOM();
const video = doc.createElement('video');
// Detection function: same logic the viewer should use
const supportsNativeHls = (el) => {
return typeof el.canPlayType === 'function' &&
el.canPlayType('application/x-mpegURL') !== '';
};
// On non-Safari browsers, hls.js is required
const needsHlsJs = !supportsNativeHls(video);
expect(needsHlsJs).toBe(true);
});
it('skips hls.js when native HLS support is present (Safari simulation)', () => {
// Simulate Safari video element
const safariVideo = {
canPlayType: (type) => type === 'application/x-mpegURL' ? 'probably' : '',
};
const supportsNativeHls = (el) => {
return typeof el.canPlayType === 'function' &&
el.canPlayType('application/x-mpegURL') !== '';
};
const needsHlsJs = !supportsNativeHls(safariVideo);
expect(needsHlsJs).toBe(false); // Safari doesn't need hls.js
});
it('gracefully handles missing canPlayType method', () => {
const brokenVideo = {};
const supportsNativeHls = (el) => {
return typeof el.canPlayType === 'function' &&
el.canPlayType('application/x-mpegURL') !== '';
};
// Should not throw
expect(() => supportsNativeHls(brokenVideo)).not.toThrow();
expect(supportsNativeHls(brokenVideo)).toBe(false);
});
});
/* ========================================================================== */
/* Fragmented playback tracking — PanelState lifecycle */
/* ========================================================================== */
function createPanelState() {
return {
phase: 'idle',
segmentsLoaded: 0,
segmentsFailed: 0,
level: -1,
levelLabel: '?x?',
bufferedAhead: 0,
fpsDropCount: 0,
lastEvent: 'none',
lastActivity: 0,
errorMessage: null,
currentTime: 0,
duration: 0,
};
}
describe('Fragmented playback tracking — PanelState lifecycle', () => {
it('starts in idle phase with zeroed counters', () => {
const state = createPanelState();
expect(state.phase).toBe('idle');
expect(state.segmentsLoaded).toBe(0);
expect(state.segmentsFailed).toBe(0);
expect(state.level).toBe(-1);
expect(state.levelLabel).toBe('?x?');
expect(state.bufferedAhead).toBe(0);
expect(state.fpsDropCount).toBe(0);
expect(state.errorMessage).toBeNull();
expect(state.currentTime).toBe(0);
expect(state.duration).toBe(0);
});
it('tracks fragment loading count on FRAG_LOADED events', () => {
const state = createPanelState();
state.segmentsLoaded += 3;
state.lastEvent = 'FRAG_LOADED';
expect(state.segmentsLoaded).toBe(3);
expect(state.lastEvent).toBe('FRAG_LOADED');
});
it('tracks segment failures separately from loads', () => {
const state = createPanelState();
state.segmentsLoaded += 2;
state.segmentsFailed++;
expect(state.segmentsLoaded).toBe(2);
expect(state.segmentsFailed).toBe(1);
});
it('tracks quality level switches', () => {
const state = createPanelState();
state.level = 2;
state.levelLabel = '1280x720';
state.lastEvent = 'LEVEL_SWITCHED';
expect(state.level).toBe(2);
expect(state.levelLabel).toBe('1280x720');
});
it('tracks FPS drops', () => {
const state = createPanelState();
state.fpsDropCount += 3;
expect(state.fpsDropCount).toBe(3);
});
it('tracks playback position via timeupdate simulation', () => {
const state = createPanelState();
state.currentTime = 10.5;
state.duration = 300;
expect(state.currentTime).toBe(10.5);
expect(state.duration).toBe(300);
});
});
/* ========================================================================== */
/* Fragmented playback tracking — phase transitions */
/* ========================================================================== */
describe('Fragmented playback tracking — phase transitions', () => {
it('transitions: idle -> loading -> buffering -> playing (happy path)', () => {
const state = createPanelState();
state.phase = 'loading';
expect(state.phase).toBe('loading');
state.phase = 'buffering';
expect(state.phase).toBe('buffering');
state.phase = 'playing';
expect(state.phase).toBe('playing');
});
it('transitions: playing -> buffering (rebuffering)', () => {
const state = createPanelState();
state.phase = 'playing';
state.phase = 'buffering';
expect(state.phase).toBe('buffering');
state.phase = 'playing';
expect(state.phase).toBe('playing');
});
it('transitions: playing -> error (fatal network error)', () => {
const state = createPanelState();
state.phase = 'playing';
state.phase = 'error';
state.errorMessage = 'Network: MANIFEST_LOAD_ERROR';
expect(state.phase).toBe('error');
expect(state.errorMessage).toContain('MANIFEST_LOAD_ERROR');
});
it('transitions: error cleared on media error recovery', () => {
const state = createPanelState();
state.phase = 'playing';
state.phase = 'buffering';
state.errorMessage = 'Media: DECODE_ERROR';
state.errorMessage = null;
state.phase = 'playing';
expect(state.phase).toBe('playing');
expect(state.errorMessage).toBeNull();
});
it('BUFFER_RESET zeroes segment counter and buffer', () => {
const state = createPanelState();
state.segmentsLoaded = 15;
state.bufferedAhead = 5.2;
state.segmentsLoaded = 0;
state.bufferedAhead = 0;
expect(state.segmentsLoaded).toBe(0);
expect(state.bufferedAhead).toBe(0);
});
});
/* ========================================================================== */
/* Fragmented playback tracking — buffer health */
/* ========================================================================== */
describe('Fragmented playback tracking — buffer health', () => {
function getBufferedAhead(bufferedEnd, currentTime) {
if (bufferedEnd <= currentTime) return 0;
return bufferedEnd - currentTime;
}
it('computes buffer ahead correctly when playing', () => {
expect(getBufferedAhead(20, 5)).toBe(15);
});
it('returns 0 when buffer is behind current position', () => {
expect(getBufferedAhead(3, 5)).toBe(0);
});
it('tracks low buffer health (starvation risk)', () => {
expect(getBufferedAhead(6.1, 5)).toBeCloseTo(1.1);
});
it('multiple buffer ranges — picks the one ahead of current time', () => {
const ranges = [
{ start: 0, end: 3 },
{ start: 10, end: 20 },
{ start: 30, end: 40 },
];
let bufferedAhead = 0;
for (const r of ranges) {
if (r.end > 5) {
bufferedAhead = r.end - 5;
break;
}
}
expect(bufferedAhead).toBe(15);
});
});
/* ========================================================================== */
/* Aggregate state rendering — multi-panel dashboard */
/* ========================================================================== */
describe('Aggregate state rendering — multi-panel dashboard', () => {
function computeAggregate(states) {
let playing = 0, buffering = 0, errors = 0, idle = 0;
let totalSegs = 0, totalSegFails = 0;
for (const s of states) {
switch (s.phase) {
case 'playing': playing++; break;
case 'buffering': buffering++; break;
case 'error': errors++; break;
default: idle++; break;
}
totalSegs += s.segmentsLoaded;
totalSegFails += s.segmentsFailed;
}
let totalBuf = 0;
for (const s of states) {
if (s.phase === 'playing' || s.phase === 'buffering') {
totalBuf += s.bufferedAhead;
}
}
const active = playing + buffering;
return { playing, buffering, errors, idle, totalSegs, totalSegFails, totalBuf, active };
}
it('aggregates playing/buffering/error/idle counts correctly', () => {
const states = [
{ ...createPanelState(), phase: 'playing', segmentsLoaded: 10, bufferedAhead: 8 },
{ ...createPanelState(), phase: 'playing', segmentsLoaded: 12, bufferedAhead: 9 },
{ ...createPanelState(), phase: 'buffering', segmentsLoaded: 5, bufferedAhead: 2 },
{ ...createPanelState(), phase: 'error', segmentsLoaded: 3, segmentsFailed: 1 },
{ ...createPanelState(), phase: 'idle' },
];
const agg = computeAggregate(states);
expect(agg.playing).toBe(2);
expect(agg.buffering).toBe(1);
expect(agg.errors).toBe(1);
expect(agg.idle).toBe(1);
});
it('sums segments loaded and failed across panels', () => {
const states = [
{ ...createPanelState(), phase: 'playing', segmentsLoaded: 10, segmentsFailed: 1 },
{ ...createPanelState(), phase: 'playing', segmentsLoaded: 15 },
{ ...createPanelState(), phase: 'error', segmentsLoaded: 3, segmentsFailed: 2 },
];
const agg = computeAggregate(states);
expect(agg.totalSegs).toBe(28);
expect(agg.totalSegFails).toBe(3);
});
it('computes average buffer for active panels only', () => {
const states = [
{ ...createPanelState(), phase: 'playing', bufferedAhead: 8 },
{ ...createPanelState(), phase: 'playing', bufferedAhead: 12 },
{ ...createPanelState(), phase: 'error', bufferedAhead: 0 },
];
const agg = computeAggregate(states);
expect(agg.active).toBe(2);
expect(agg.totalBuf).toBeCloseTo(20);
expect(agg.totalBuf / agg.active).toBeCloseTo(10);
});
it('handles all panels in error', () => {
const states = [
{ ...createPanelState(), phase: 'error', segmentsFailed: 3 },
{ ...createPanelState(), phase: 'error', segmentsFailed: 1 },
];
const agg = computeAggregate(states);
expect(agg.errors).toBe(2);
expect(agg.active).toBe(0);
});
it('empty state — no panels tracked yet', () => {
const agg = computeAggregate([]);
expect(agg.playing).toBe(0);
expect(agg.buffering).toBe(0);
expect(agg.errors).toBe(0);
expect(agg.idle).toBe(0);
expect(agg.totalSegs).toBe(0);
});
});
/* ========================================================================== */
/* Fragmented playback tracking — hls.js event simulation */
/* ========================================================================== */
describe('Fragmented playback tracking — hls.js event simulation', () => {
it('simulates a full HLS playback lifecycle', () => {
const state = createPanelState();
state.phase = 'loading';
state.lastEvent = 'MEDIA_ATTACHED';
expect(state.phase).toBe('loading');
state.lastEvent = 'MANIFEST_LOADED';
state.levelLabel = '1920x1080';
state.lastEvent = 'MANIFEST_PARSED';
state.level = 0;
state.levelLabel = '640x360';
state.phase = 'buffering';
for (let i = 0; i < 5; i++) state.segmentsLoaded++;
state.phase = 'playing';
state.bufferedAhead = 8.5;
state.currentTime = 5.2;
state.duration = 300;
state.level = 1;
state.levelLabel = '1280x720';
expect(state.phase).toBe('playing');
expect(state.segmentsLoaded).toBe(5);
expect(state.segmentsFailed).toBe(0);
expect(state.fpsDropCount).toBe(0);
expect(state.errorMessage).toBeNull();
expect(state.level).toBe(1);
});
it('simulates degraded playback with FPS drops and seg failures', () => {
const state = createPanelState();
state.phase = 'playing';
state.segmentsLoaded = 3;
state.segmentsFailed += 2;
state.fpsDropCount += 2;
state.level = 0;
state.levelLabel = '426x240';
expect(state.phase).toBe('playing');
expect(state.segmentsFailed).toBe(2);
expect(state.fpsDropCount).toBe(2);
expect(state.levelLabel).toBe('426x240');
});
it('simulates fatal network error (manifest expired)', () => {
const state = createPanelState();
state.phase = 'playing';
state.segmentsLoaded = 20;
state.phase = 'error';
state.errorMessage = 'Network: MANIFEST_LOAD_ERROR';
expect(state.phase).toBe('error');
expect(state.errorMessage).toContain('MANIFEST_LOAD_ERROR');
expect(state.segmentsLoaded).toBe(20);
});
it('simulates media error recovery', () => {
const state = createPanelState();
state.phase = 'playing';
state.segmentsLoaded = 10;
state.phase = 'buffering';
state.errorMessage = 'Media: DECODE_ERROR';
state.errorMessage = null;
state.phase = 'playing';
state.segmentsLoaded++;
expect(state.phase).toBe('playing');
expect(state.errorMessage).toBeNull();
expect(state.segmentsLoaded).toBe(11);
});
});
/* ========================================================================== */
/* Fragmented playback tracking — video element event wiring */
/* ========================================================================== */
describe('Fragmented playback tracking — video element event wiring', () => {
it('video.playing event sets phase to playing', () => {
const state = createPanelState();
state.phase = 'buffering';
state.phase = 'playing';
state.lastEvent = 'video.playing';
expect(state.phase).toBe('playing');
expect(state.lastEvent).toBe('video.playing');
});
it('video.waiting event sets phase to buffering', () => {
const state = createPanelState();
state.phase = 'playing';
state.phase = 'buffering';
state.lastEvent = 'video.waiting';
expect(state.phase).toBe('buffering');
});
it('video.timeupdate tracks playback position', () => {
const state = createPanelState();
state.currentTime = 1.0;
state.currentTime = 2.0;
state.currentTime = 3.0;
state.duration = 300;
expect(state.currentTime).toBe(3.0);
expect(state.duration).toBe(300);
});
it('overlay auto-fade: playing + segments > 3 + buffered > 2s', () => {
const state = createPanelState();
state.phase = 'playing';
state.segmentsLoaded = 4;
state.bufferedAhead = 5;
const shouldFade = state.phase === 'playing' &&
state.segmentsLoaded > 3 && state.bufferedAhead > 2;
expect(shouldFade).toBe(true);
state.segmentsLoaded = 2;
expect(state.phase === 'playing' && state.segmentsLoaded > 3 && state.bufferedAhead > 2).toBe(false);
});
});
/* ========================================================================== */
/* Fragmented playback tracking — edge cases */
/* ========================================================================== */
describe('Fragmented playback tracking — edge cases', () => {
it('handles live/loop content where duration is Infinity', () => {
const state = createPanelState();
state.phase = 'playing';
state.currentTime = 15.5;
state.duration = Infinity;
const isLive = state.duration === Infinity || isNaN(state.duration);
expect(isLive).toBe(true);
expect(state.currentTime).toBe(15.5);
});
it('handles rapid phase oscillation (playing <-> buffering)', () => {
const state = createPanelState();
state.phase = 'playing';
state.phase = 'buffering';
state.phase = 'playing';
state.phase = 'buffering';
state.phase = 'playing';
expect(state.phase).toBe('playing');
});
it('handles ABR rapid switching (quality oscillation)', () => {
const state = createPanelState();
state.level = 3; state.levelLabel = '1920x1080';
state.level = 0; state.levelLabel = '426x240';
state.level = 2; state.levelLabel = '1280x720';
expect(state.level).toBe(2);
expect(state.levelLabel).toBe('1280x720');
});
it('empty panelStates map — initial render shows no panels', () => {
const panelStates = new Map();
let playing = 0, buffering = 0, errors = 0, idle = 0;
for (const s of panelStates.values()) {
switch (s.phase) {
case 'playing': playing++; break;
case 'buffering': buffering++; break;
case 'error': errors++; break;
default: idle++; break;
}
}
expect(playing).toBe(0);
expect(buffering).toBe(0);
expect(errors).toBe(0);
expect(idle).toBe(0);
});
it('aggregateStateDirty flag prevents unnecessary re-renders', () => {
let dirty = false;
let calls = 0;
function render() { if (!dirty) return; dirty = false; calls++; }
render();
expect(calls).toBe(0);
dirty = true;
render();
expect(calls).toBe(1);
render();
expect(calls).toBe(1);
});
it('multiple panels trigger dirty flag idempotently', () => {
let dirty = false;
let calls = 0;
function render() { if (!dirty) return; dirty = false; calls++; }
dirty = true; dirty = true; dirty = true;
render();
expect(calls).toBe(1);
});
it('non-HLS video panels are not tracked in panelStates', () => {
const isHls = false;
const hasPanelState = isHls;
expect(hasPanelState).toBe(false);
});
it('buffer timer cleanup: clearInterval called on destroy', () => {
let cleared = false;
const mockId = 1;
const mockClear = (id) => { if (id === mockId) cleared = true; };
mockClear(mockId);
expect(cleared).toBe(true);
});
});
+11 -2
View File
@@ -8,8 +8,8 @@
*/ */
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
import { resolve } from 'path'; import { resolve } from 'path';
import { copyFileSync, mkdirSync, writeFileSync } from 'fs';
import sharp from 'sharp'; import sharp from 'sharp';
import { copyFileSync, mkdirSync, writeFileSync } from 'fs';
export default defineConfig({ export default defineConfig({
root: 'src', root: 'src',
@@ -49,14 +49,20 @@ export default defineConfig({
}, },
], ],
build: { build: {
target: 'chrome110',
outDir: '../dist', outDir: '../dist',
emptyOutDir: true, emptyOutdir: true,
minify: false,
rollupOptions: { rollupOptions: {
input: { input: {
background: resolve(__dirname, 'src/background/background.js'), background: resolve(__dirname, 'src/background/background.js'),
content: resolve(__dirname, 'src/content/content.js'), content: resolve(__dirname, 'src/content/content.js'),
viewer: resolve(__dirname, 'src/viewer/viewer.html'), viewer: resolve(__dirname, 'src/viewer/viewer.html'),
viewerIndex: resolve(__dirname, 'src/viewer/index.html'), viewerIndex: resolve(__dirname, 'src/viewer/index.html'),
interceptors: resolve(__dirname, 'src/inject/interceptors.js'),
// NOTE: hlds is NOT built by Vite — copied from source as pure ES5 to avoid
// esbuild injecting arrow functions (Chrome flags these as "ES module content"
// in extension URLs, preventing window.Hls registration).
}, },
output: { output: {
entryFileNames: '[name].js', entryFileNames: '[name].js',
@@ -64,5 +70,8 @@ export default defineConfig({
assetFileNames: '[name][extname]', assetFileNames: '[name][extname]',
}, },
}, },
modulePreload: {
polyfill: false,
},
}, },
}); });