Compare commits
5
Commits
v0.2.0
..
3c7b563021
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c7b563021 | ||
|
|
0a74332af9 | ||
|
|
a621db9674 | ||
|
|
0d4b62c485 | ||
|
|
23a2af6cad |
No files matched your search
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(npm run *)",
|
||||||
|
"Bash(node --check /home/kyle/Documents/goon_plugin/src/viewer/index.js)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "VidPlex Dev",
|
"name": "Goon Plugin Dev",
|
||||||
"image": "mcr.microsoft.com/devcontainers/javascript-node:22",
|
"image": "mcr.microsoft.com/devcontainers/javascript-node:22",
|
||||||
"features": {
|
"features": {
|
||||||
"ghcr.io/devcontainers/features/sshd:1": {}
|
"ghcr.io/devcontainers/features/sshd:1": {}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
dist/
|
||||||
@@ -8,7 +8,7 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
@@ -19,8 +19,6 @@ 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
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
name: Release
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- 'v*'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
release:
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 22
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Build
|
|
||||||
run: npm run build
|
|
||||||
|
|
||||||
- name: Package dist.zip
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
cd dist
|
|
||||||
zip -r "../vidplex-${{ github.ref_name }}.zip" .
|
|
||||||
|
|
||||||
- name: Create Gitea release
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
|
||||||
GITEA_API: ${{ secrets.GITEA_API || 'http://192.168.1.74:30008/api/v1' }}
|
|
||||||
REPO: ${{ github.repository }}
|
|
||||||
TAG: ${{ github.ref_name }}
|
|
||||||
run: |
|
|
||||||
if [ -z "$GITEA_TOKEN" ]; then
|
|
||||||
echo "::warning::GITEA_TOKEN secret is not set; skipping release creation. Add it in Gitea repo Settings > Secrets (repo scope)."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
json_id() { node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{const j=JSON.parse(d||"{}");if(j.id==null){console.error("no release id:",JSON.stringify(j));process.exit(1)}console.log(j.id)})'; }
|
|
||||||
|
|
||||||
release_id=$(
|
|
||||||
curl -s -o /tmp/rel.json -w "%{http_code}" \
|
|
||||||
-H "Authorization: token $GITEA_TOKEN" \
|
|
||||||
"$GITEA_API/repos/$REPO/releases/tags/$TAG"
|
|
||||||
)
|
|
||||||
|
|
||||||
if [ "$release_id" = "200" ]; then
|
|
||||||
echo "Release for $TAG already exists; reusing it."
|
|
||||||
release_id=$(json_id < /tmp/rel.json)
|
|
||||||
else
|
|
||||||
echo "Creating release for $TAG."
|
|
||||||
release_id=$(
|
|
||||||
curl -s -o /tmp/rel.json -w "%{http_code}" -X POST \
|
|
||||||
-H "Authorization: token $GITEA_TOKEN" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "{\"tag_name\": \"$TAG\", \"name\": \"$TAG\", \"body\": \"Automated release build for $TAG\"}" \
|
|
||||||
"$GITEA_API/repos/$REPO/releases"
|
|
||||||
)
|
|
||||||
case "$release_id" in
|
|
||||||
201|200) release_id=$(json_id < /tmp/rel.json) ;;
|
|
||||||
*) echo "::error::release create returned HTTP $release_id: $(cat /tmp/rel.json)"; exit 1 ;;
|
|
||||||
esac
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Uploading asset to release $release_id."
|
|
||||||
resp=$(curl -s -o /tmp/att.json -w "%{http_code}" -X POST \
|
|
||||||
-H "Authorization: token $GITEA_TOKEN" \
|
|
||||||
-F "attachment=@vidplex-${TAG}.zip" \
|
|
||||||
"$GITEA_API/repos/$REPO/releases/$release_id/assets")
|
|
||||||
if [ "$resp" != "200" ] && [ "$resp" != "201" ]; then
|
|
||||||
echo "::error::asset upload returned HTTP $resp: $(cat /tmp/att.json)"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
cat /tmp/att.json
|
|
||||||
echo
|
|
||||||
@@ -3,4 +3,3 @@ dist/
|
|||||||
.vscode-insiders/
|
.vscode-insiders/
|
||||||
/icons
|
/icons
|
||||||
/icons/*
|
/icons/*
|
||||||
.claude/settings.local.json
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# VidPlex
|
# Goon Plugin
|
||||||
|
|
||||||
A Chrome extension (Manifest V3) that finds videos on the current page and displays them in a 2x2 grid.
|
A Chrome extension (Manifest V3) that finds videos on the current page and displays them in a 2x2 grid.
|
||||||
|
|
||||||
@@ -55,4 +55,4 @@ src/
|
|||||||
|
|
||||||
## VS Code workspace
|
## VS Code workspace
|
||||||
|
|
||||||
Open `vidplex.code-workspace` to get the project with recommended extensions pre-installed.
|
Open `goon_plugin.code-workspace` to get the project with recommended extensions pre-installed.
|
||||||
@@ -44,8 +44,8 @@ describe('Config: build integrity', () => {
|
|||||||
|
|
||||||
if (codeOnly.includes('textContent')) {
|
if (codeOnly.includes('textContent')) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'content.js uses inline script injection (textContent). ' +
|
'content.js uses inline script injection (textContent). ' +
|
||||||
'Some sites block inline scripts via CSP. Use script.src instead.'
|
'Sites like Pornhub block inline scripts via CSP. Use script.src instead.'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!codeOnly.includes('script.src') || !codeOnly.includes('chrome.runtime.getURL')) {
|
if (!codeOnly.includes('script.src') || !codeOnly.includes('chrome.runtime.getURL')) {
|
||||||
@@ -79,24 +79,4 @@ describe('Config: build integrity', () => {
|
|||||||
throw new Error('No content_scripts entry references content.js');
|
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").'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
@@ -1,10 +1,7 @@
|
|||||||
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 { readFileSync } from 'node:fs';
|
import schema from '../src/viewer/schema.json' with { type: 'json' };
|
||||||
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);
|
||||||
@@ -185,35 +182,4 @@ describe('Data flow: popup -> storage -> viewer', () => {
|
|||||||
throw new Error('Entry with invalid mediaType should be rejected');
|
throw new Error('Entry with invalid mediaType should be rejected');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('an HLS entry carrying a scan-time referer passes schema + round-trips', () => {
|
|
||||||
// The referer is bound to the entry at scan time so it survives the
|
|
||||||
// watch-later flow (the source tab may be long closed). Because the schema
|
|
||||||
// sets additionalProperties:false, a `referer` the store carries must be an
|
|
||||||
// explicit property or storage-layer validation would reject the entry.
|
|
||||||
const scrapedEntry = {
|
|
||||||
url: 'https://pageA.example.com/watch',
|
|
||||||
src: 'https://cdn.example.com/hls/live/master.m3u8',
|
|
||||||
hlsManifest: 'https://cdn.example.com/hls/live/master.m3u8',
|
|
||||||
mediaType: 'hls',
|
|
||||||
poster: 'https://pageA.example.com/thumb.jpg',
|
|
||||||
tab: 'Referer-protected stream',
|
|
||||||
referer: 'https://pageA.example.com/watch',
|
|
||||||
};
|
|
||||||
|
|
||||||
const valid = validate(scrapedEntry);
|
|
||||||
if (!valid) {
|
|
||||||
throw new Error(`Entry with referer fails schema: ${JSON.stringify(validate.errors)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The referer must survive the storage round-trip intact so the viewer can
|
|
||||||
// send it to the proxy when the user opens a previously-scanned entry.
|
|
||||||
const roundTripped = JSON.parse(JSON.stringify(scrapedEntry));
|
|
||||||
if (roundTripped.referer !== 'https://pageA.example.com/watch') {
|
|
||||||
throw new Error('referer lost during round-trip');
|
|
||||||
}
|
|
||||||
if (!validate(roundTripped)) {
|
|
||||||
throw new Error('Round-tripped entry with referer fails schema');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
@@ -1,932 +0,0 @@
|
|||||||
// 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
@@ -1,269 +0,0 @@
|
|||||||
import { describe, it } from './runner.mjs';
|
|
||||||
import { JSDOM } from 'jsdom';
|
|
||||||
|
|
||||||
const POPUP_HTML = `
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>VidPlex</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="app">
|
|
||||||
<h1>VidPlex</h1>
|
|
||||||
<section class="instructions-panel" role="region" aria-labelledby="instructions-title">
|
|
||||||
<div class="instructions-header">
|
|
||||||
<div>
|
|
||||||
<p class="instructions-kicker">Quick start</p>
|
|
||||||
<h2 id="instructions-title">How to use VidPlex</h2>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
id="instructions-toggle"
|
|
||||||
type="button"
|
|
||||||
class="instructions-toggle"
|
|
||||||
aria-expanded="true"
|
|
||||||
aria-controls="instructions-body"
|
|
||||||
>
|
|
||||||
Hide instructions
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div id="instructions-body" class="instructions-body">
|
|
||||||
<ol class="instructions-list">
|
|
||||||
<li>
|
|
||||||
<strong>Scan videos</strong>
|
|
||||||
<span>Click <strong>Find Videos</strong> to search the current browser window for videos.</span>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Review the list</strong>
|
|
||||||
<span>Check the popup list; each found video URL is clickable.</span>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Open the viewer</strong>
|
|
||||||
<span>Click <strong>Open Viewer</strong> to open the 2x2 grid player in a new window.</span>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Reset and rescan</strong>
|
|
||||||
<span>Click <strong>Clear</strong> to erase results and scan again.</span>
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
<p class="instructions-note">Warning: Browser pages, extension pages, and restricted frames can hide videos, so results may be incomplete.</p>
|
|
||||||
<p class="instructions-learn-more">Learn more tooltip: Read the VidPlex README and audit notes.</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<button id="find-btn">Find Videos (all tabs)</button>
|
|
||||||
<div id="video-list" class="video-list" style="display:none;"></div>
|
|
||||||
<div id="status" class="status" style="min-height:16px;"></div>
|
|
||||||
<div id="btn-row" class="btn-row" style="display:none;">
|
|
||||||
<button id="clear-btn" class="btn-secondary">Clear</button>
|
|
||||||
<button id="open-btn">Open Viewer</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>`;
|
|
||||||
|
|
||||||
function installPopupDom() {
|
|
||||||
const dom = new JSDOM(POPUP_HTML, { url: 'chrome-extension://test/src/viewer/index.html' });
|
|
||||||
const previous = {
|
|
||||||
window: globalThis.window,
|
|
||||||
document: globalThis.document,
|
|
||||||
navigator: globalThis.navigator,
|
|
||||||
chrome: globalThis.chrome,
|
|
||||||
HTMLElement: globalThis.HTMLElement,
|
|
||||||
Node: globalThis.Node,
|
|
||||||
};
|
|
||||||
|
|
||||||
globalThis.window = dom.window;
|
|
||||||
globalThis.document = dom.window.document;
|
|
||||||
Object.defineProperty(globalThis, 'navigator', {
|
|
||||||
value: dom.window.navigator,
|
|
||||||
configurable: true,
|
|
||||||
writable: true,
|
|
||||||
});
|
|
||||||
Object.defineProperty(globalThis, 'HTMLElement', {
|
|
||||||
value: dom.window.HTMLElement,
|
|
||||||
configurable: true,
|
|
||||||
writable: true,
|
|
||||||
});
|
|
||||||
Object.defineProperty(globalThis, 'Node', {
|
|
||||||
value: dom.window.Node,
|
|
||||||
configurable: true,
|
|
||||||
writable: true,
|
|
||||||
});
|
|
||||||
globalThis.chrome = {
|
|
||||||
storage: {
|
|
||||||
local: {
|
|
||||||
get: async () => ({}),
|
|
||||||
set: async () => undefined,
|
|
||||||
remove: async () => undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
tabs: { query: async () => [] },
|
|
||||||
scripting: { executeScript: async () => [] },
|
|
||||||
runtime: { getURL: (path) => `chrome-extension://test/${path}` },
|
|
||||||
windows: { create: async () => ({ id: 1 }) },
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
dom,
|
|
||||||
cleanup() {
|
|
||||||
dom.window.close();
|
|
||||||
globalThis.window = previous.window;
|
|
||||||
globalThis.document = previous.document;
|
|
||||||
Object.defineProperty(globalThis, 'navigator', {
|
|
||||||
value: previous.navigator,
|
|
||||||
configurable: true,
|
|
||||||
writable: true,
|
|
||||||
});
|
|
||||||
globalThis.chrome = previous.chrome;
|
|
||||||
Object.defineProperty(globalThis, 'HTMLElement', {
|
|
||||||
value: previous.HTMLElement,
|
|
||||||
configurable: true,
|
|
||||||
writable: true,
|
|
||||||
});
|
|
||||||
Object.defineProperty(globalThis, 'Node', {
|
|
||||||
value: previous.Node,
|
|
||||||
configurable: true,
|
|
||||||
writable: true,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function mountPopupWithStorage(storageGet = async () => ({}), captureWrite = null) {
|
|
||||||
const { dom, cleanup } = installPopupDom();
|
|
||||||
// Override the chrome.storage.local stub so we can seed a value and capture writes.
|
|
||||||
const writes = [];
|
|
||||||
globalThis.chrome.storage.local.get = async (...args) => {
|
|
||||||
const res = await storageGet(...args);
|
|
||||||
return res;
|
|
||||||
};
|
|
||||||
globalThis.chrome.storage.local.set = async (items) => {
|
|
||||||
if (captureWrite) captureWrite(items); else writes.push(items);
|
|
||||||
};
|
|
||||||
const moduleUrl = new URL('../src/viewer/index.js?test=' + Date.now(), import.meta.url);
|
|
||||||
await import(moduleUrl.href);
|
|
||||||
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
return { dom, cleanup, writes, flush };
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('Viewer popup instructions', () => {
|
|
||||||
it('renders the drafted instructions copy on mount with accessible labeling', async () => {
|
|
||||||
const { dom, cleanup } = await mountPopupWithStorage();
|
|
||||||
try {
|
|
||||||
const doc = dom.window.document;
|
|
||||||
const region = doc.querySelector('.instructions-panel');
|
|
||||||
const title = doc.getElementById('instructions-title');
|
|
||||||
const toggle = doc.getElementById('instructions-toggle');
|
|
||||||
const body = doc.getElementById('instructions-body');
|
|
||||||
|
|
||||||
if (!region) throw new Error('Instructions region missing');
|
|
||||||
if (region.getAttribute('role') !== 'region') throw new Error('Instructions region missing role="region"');
|
|
||||||
if (region.getAttribute('aria-labelledby') !== 'instructions-title') throw new Error('Instructions region missing aria-labelledby');
|
|
||||||
if (!title || title.textContent !== 'How to use VidPlex') throw new Error('Instructions title not rendered');
|
|
||||||
if (!toggle) throw new Error('Instructions toggle missing');
|
|
||||||
if (toggle.getAttribute('aria-controls') !== 'instructions-body') throw new Error('Toggle missing aria-controls');
|
|
||||||
if (toggle.getAttribute('aria-expanded') !== 'true') throw new Error('Toggle should start expanded');
|
|
||||||
if (!body || body.hidden) throw new Error('Instructions body should be visible on mount');
|
|
||||||
|
|
||||||
const expectedTexts = [
|
|
||||||
'Scan videos',
|
|
||||||
'Click Find Videos to search the current browser window for videos.',
|
|
||||||
'Review the list',
|
|
||||||
'Check the popup list; each found video URL is clickable.',
|
|
||||||
'Open the viewer',
|
|
||||||
'Click Open Viewer to open the 2x2 grid player in a new window.',
|
|
||||||
'Reset and rescan',
|
|
||||||
'Click Clear to erase results and scan again.',
|
|
||||||
'Warning: Browser pages, extension pages, and restricted frames can hide videos, so results may be incomplete.',
|
|
||||||
'Learn more tooltip: Read the VidPlex README and audit notes.',
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const text of expectedTexts) {
|
|
||||||
if (!doc.body.textContent.includes(text)) {
|
|
||||||
throw new Error(`Missing instructions copy: ${text}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('toggles the instructions panel open and closed', async () => {
|
|
||||||
const { dom, cleanup } = await mountPopupWithStorage();
|
|
||||||
try {
|
|
||||||
const doc = dom.window.document;
|
|
||||||
const toggle = doc.getElementById('instructions-toggle');
|
|
||||||
const body = doc.getElementById('instructions-body');
|
|
||||||
|
|
||||||
if (!toggle || !body) throw new Error('Instructions controls missing');
|
|
||||||
|
|
||||||
toggle.click();
|
|
||||||
if (toggle.getAttribute('aria-expanded') !== 'false') throw new Error('Toggle should collapse the instructions');
|
|
||||||
if (!body.hidden) throw new Error('Instructions body should be hidden after collapse');
|
|
||||||
if (toggle.textContent !== 'Show instructions') throw new Error('Collapsed label should switch to Show instructions');
|
|
||||||
|
|
||||||
toggle.click();
|
|
||||||
if (toggle.getAttribute('aria-expanded') !== 'true') throw new Error('Toggle should expand the instructions');
|
|
||||||
if (body.hidden) throw new Error('Instructions body should be visible after expanding');
|
|
||||||
if (toggle.textContent !== 'Hide instructions') throw new Error('Expanded label should switch back to Hide instructions');
|
|
||||||
} finally {
|
|
||||||
cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('honors a stored hidden state on mount', async () => {
|
|
||||||
const { dom, cleanup } = await mountPopupWithStorage(
|
|
||||||
async () => ({ goon_instructions_visible: false })
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
const doc = dom.window.document;
|
|
||||||
const toggle = doc.getElementById('instructions-toggle');
|
|
||||||
const body = doc.getElementById('instructions-body');
|
|
||||||
// Wait for the async storage.get() to resolve and apply.
|
|
||||||
await new Promise((r) => setTimeout(r, 0));
|
|
||||||
if (toggle.getAttribute('aria-expanded') !== 'false') throw new Error('Stored hidden state should start collapsed');
|
|
||||||
if (!body.hidden) throw new Error('Stored hidden state should start hidden');
|
|
||||||
if (toggle.textContent !== 'Show instructions') throw new Error('Stored hidden state should show the "Show instructions" label');
|
|
||||||
} finally {
|
|
||||||
cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('persists the new visibility on every user toggle', async () => {
|
|
||||||
let written = null;
|
|
||||||
const { dom, cleanup, flush } = await mountPopupWithStorage(
|
|
||||||
async () => ({}),
|
|
||||||
(items) => { written = items; }
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
const doc = dom.window.document;
|
|
||||||
const toggle = doc.getElementById('instructions-toggle');
|
|
||||||
// Collapse -> must persist false.
|
|
||||||
toggle.click();
|
|
||||||
await flush();
|
|
||||||
if (written === null) throw new Error('Collapsing the panel must write to chrome.storage.local');
|
|
||||||
if (written.goon_instructions_visible !== false) throw new Error('Collapsing must persist goon_instructions_visible=false');
|
|
||||||
// Expand -> must persist true.
|
|
||||||
toggle.click();
|
|
||||||
await flush();
|
|
||||||
if (written.goon_instructions_visible !== true) throw new Error('Expanding must persist goon_instructions_visible=true');
|
|
||||||
} finally {
|
|
||||||
cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('defaults a first-time user (no stored key) to shown', async () => {
|
|
||||||
const { dom, cleanup } = await mountPopupWithStorage(async () => ({}));
|
|
||||||
try {
|
|
||||||
const doc = dom.window.document;
|
|
||||||
const toggle = doc.getElementById('instructions-toggle');
|
|
||||||
const body = doc.getElementById('instructions-body');
|
|
||||||
await new Promise((r) => setTimeout(r, 0));
|
|
||||||
if (toggle.getAttribute('aria-expanded') !== 'true') throw new Error('First-time user should start expanded');
|
|
||||||
if (body.hidden) throw new Error('First-time instructions body should be visible on mount');
|
|
||||||
} finally {
|
|
||||||
cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,364 +0,0 @@
|
|||||||
/**
|
|
||||||
* referer_proxy.test.mjs — coverage for the background worker's per-CDN-host
|
|
||||||
* referer capture + DNR proxy-host learning, added in PR #5.
|
|
||||||
*
|
|
||||||
* The background worker is not itself a module, so the test transpiles it into
|
|
||||||
* one (wrapping + top-level-statement `async function` transpile) under a mocked
|
|
||||||
* `chrome` + `fetch`/`btoa` environment, then exercises the captured listeners
|
|
||||||
* and the message handler directly.
|
|
||||||
*
|
|
||||||
* Covers the three gaps robot flagged:
|
|
||||||
* (a) closing a tab must NOT wipe the host-keyed `goon_referers` global store,
|
|
||||||
* (b) two hosts proxied concurrently must each keep their OWN referer (no race
|
|
||||||
* that leaves host B's referer live for host A -> 412),
|
|
||||||
* (c) the new referer-capture / proxy-host-learning / onRemoved paths run.
|
|
||||||
*/
|
|
||||||
import { describe, it } from './runner.mjs';
|
|
||||||
import { readFileSync, writeFileSync } from 'node:fs';
|
|
||||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
||||||
import { dirname, join } from 'node:path';
|
|
||||||
import { tmpdir } from 'node:os';
|
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
||||||
const backgroundSource = readFileSync(join(__dirname, '../src/background/background.js'), 'utf8');
|
|
||||||
|
|
||||||
// Transpile the top-level-statement worker into an ES module. Wrapping in an
|
|
||||||
// async IIFE makes its top-level `async function` declarations + `await`-using
|
|
||||||
// nested functions legal inside a module.
|
|
||||||
function buildModule(code) {
|
|
||||||
return `(async () => {\n${code}\n})();\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// A fresh mock of the chrome APIs the worker touches. `onBeforeRequest` and
|
|
||||||
// `tabs.onRemoved` capture their listener, and `tabs.get` serves the source
|
|
||||||
// tab's URL (the captured referer); `declarativeNetRequest.updateSessionRules`
|
|
||||||
// records the live rules.
|
|
||||||
function makeChromeEnv() {
|
|
||||||
const storage = {};
|
|
||||||
const handlers = { onMessage: [] };
|
|
||||||
const tabUrls = {};
|
|
||||||
let sessionRules = {};
|
|
||||||
|
|
||||||
const webRequest = {
|
|
||||||
onBeforeRequest: { addListener: (cb) => { handlers.onBeforeRequest = cb; } },
|
|
||||||
};
|
|
||||||
|
|
||||||
const chrome = {
|
|
||||||
storage: {
|
|
||||||
local: {
|
|
||||||
get: async (keys) => {
|
|
||||||
const out = {};
|
|
||||||
const arr = Array.isArray(keys) ? keys : (keys === undefined ? Object.keys(storage) : [keys]);
|
|
||||||
for (const k of arr) if (k in storage) out[k] = storage[k];
|
|
||||||
return out;
|
|
||||||
},
|
|
||||||
set: async (data) => {
|
|
||||||
for (const [k, v] of Object.entries(data)) storage[k] = v;
|
|
||||||
},
|
|
||||||
remove: async (keys) => {
|
|
||||||
const arr = Array.isArray(keys) ? keys : [keys];
|
|
||||||
for (const k of arr) delete storage[k];
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
webRequest,
|
|
||||||
tabs: {
|
|
||||||
get: async (tabId) => tabUrls[tabId] ? { tabId, url: tabUrls[tabId] } : undefined,
|
|
||||||
onRemoved: { addListener: (cb) => { handlers.onRemoved = cb; } },
|
|
||||||
},
|
|
||||||
runtime: { onMessage: { addListener: (cb) => { handlers.onMessage.push(cb); } } },
|
|
||||||
declarativeNetRequest: {
|
|
||||||
updateSessionRules: async ({ removeRuleIds = [], addRules = [] } = {}) => {
|
|
||||||
const rm = new Set(removeRuleIds);
|
|
||||||
sessionRules = Object.fromEntries(
|
|
||||||
Object.entries(sessionRules).filter(([id]) => !rm.has(id))
|
|
||||||
);
|
|
||||||
for (const r of addRules) sessionRules[r.id] = r;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return { chrome, handlers, storage, tabUrls,
|
|
||||||
setTabUrl: (tabId, url) => { tabUrls[tabId] = url; },
|
|
||||||
getSessionRules: () => sessionRules };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load the worker into a fresh environment. `data:` URLs are cached by Node, so
|
|
||||||
// the module body (listener registration) would only run once; instead we write
|
|
||||||
// a uniquely-named temp file per load so each call registers into its own env.
|
|
||||||
let loadCounter = 0;
|
|
||||||
async function loadBackground(env) {
|
|
||||||
global.chrome = env.chrome;
|
|
||||||
const file = join(tmpdir(), `goon_bg_${process.pid}_${loadCounter++}.mjs`);
|
|
||||||
writeFileSync(file, buildModule(backgroundSource));
|
|
||||||
const module = await import(pathToFileURL(file).href);
|
|
||||||
await module.default;
|
|
||||||
return module;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Drive a referer capture the way the worker now does it: the worker reads the
|
|
||||||
// source tab's URL (via chrome.tabs.get) as the provider referer, so we set the
|
|
||||||
// tab's URL and fire an onBeforeRequest for a video URL on that tab.
|
|
||||||
async function capture(env, videoUrl, referer) {
|
|
||||||
env.setTabUrl(11, referer);
|
|
||||||
await env.handlers.onBeforeRequest({ tabId: 11, url: videoUrl });
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('PR#5: per-host referer capture + proxy race', () => {
|
|
||||||
// (a) Blocking fix: a host-keyed `goon_referers` entry must outlive the close
|
|
||||||
// of ANY tab — including a different tab.
|
|
||||||
it('closing a tab does not wipe goon_referers captured for another host', async () => {
|
|
||||||
const env = makeChromeEnv();
|
|
||||||
global.chrome = env.chrome;
|
|
||||||
await loadBackground(env);
|
|
||||||
|
|
||||||
// A live source tab captures the provider referer for its CDN host.
|
|
||||||
await capture(env, 'https://cdnA.example.com/hls/live/seg-1.ts', 'https://pageA.example.com/watch');
|
|
||||||
|
|
||||||
const rA = env.storage['goon_referers']?.['example.com'];
|
|
||||||
if (!rA) throw new Error('referer was not captured for cdnA');
|
|
||||||
if (rA.referer !== 'https://pageA.example.com/watch') {
|
|
||||||
throw new Error('captured referer wrong: ' + JSON.stringify(rA));
|
|
||||||
}
|
|
||||||
|
|
||||||
// A DIFFERENT tab closes. The per-tab cleanup must NOT remove goon_referers.
|
|
||||||
await env.handlers.onRemoved(22);
|
|
||||||
|
|
||||||
const after = env.storage['goon_referers'];
|
|
||||||
if (!after || !after['example.com']) {
|
|
||||||
throw new Error('closing tab 22 wiped goon_referers — host A referer lost');
|
|
||||||
}
|
|
||||||
if (after['example.com'].referer !== 'https://pageA.example.com/watch') {
|
|
||||||
throw new Error('closing tab 22 corrupted the host A referer: ' + JSON.stringify(after));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('closing the capturing tab itself preserves the referer for later proxy use', async () => {
|
|
||||||
const env = makeChromeEnv();
|
|
||||||
global.chrome = env.chrome;
|
|
||||||
await loadBackground(env);
|
|
||||||
|
|
||||||
await capture(env, 'https://cdnB.example.com/video/x/master.m3u8', 'https://pageB.example.com/v');
|
|
||||||
|
|
||||||
// The very tab that captured the referer now closes.
|
|
||||||
await env.handlers.onRemoved(11);
|
|
||||||
|
|
||||||
const after = env.storage['goon_referers']?.['example.com'];
|
|
||||||
if (!after) throw new Error('goon_referers for the capturing host was wiped on its own tab close');
|
|
||||||
if (after.referer !== 'https://pageB.example.com/v') {
|
|
||||||
throw new Error('capturing-tab close corrupted referer: ' + JSON.stringify(after));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// (b) Should-fix + coverage: two hosts proxied concurrently must each keep
|
|
||||||
// their own referer. With a shared rule id + non-atomic install this regressed;
|
|
||||||
// per-host rule ids + the per-host lock keep them distinct.
|
|
||||||
it('concurrent proxy fetches for two distinct registrable domains keep distinct referer rules', async () => {
|
|
||||||
const env = makeChromeEnv();
|
|
||||||
let fetchCalls = 0;
|
|
||||||
global.chrome = env.chrome;
|
|
||||||
global.fetch = async (url) => {
|
|
||||||
fetchCalls++;
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
status: 200,
|
|
||||||
headers: { get: () => 'application/vnd.apple.mpegurl' },
|
|
||||||
arrayBuffer: async () => new Uint8Array([0]).buffer,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
await loadBackground(env);
|
|
||||||
|
|
||||||
// Two GOON_PROXY_FETCH messages for different CDN hosts, fired together.
|
|
||||||
const handler = env.handlers.onMessage[env.handlers.onMessage.length - 1];
|
|
||||||
let respA, respB;
|
|
||||||
handler({ type: 'GOON_PROXY_FETCH', url: 'https://cdnA.example.com/hls/live/seg-1.ts', referer: 'https://pageA.example.com/watch' }, {}, (r) => { respA = r; });
|
|
||||||
handler({ type: 'GOON_PROXY_FETCH', url: 'https://cdnB.other.com/hls/live/seg-1.ts', referer: 'https://pageB.other.com/watch' }, {}, (r) => { respB = r; });
|
|
||||||
|
|
||||||
await new Promise(r => setTimeout(r, 30)); // let async sends + installs flush
|
|
||||||
|
|
||||||
if (!respA || !respA.ok) throw new Error('proxy A failed: ' + JSON.stringify(respA));
|
|
||||||
if (!respB || !respB.ok) throw new Error('proxy B failed: ' + JSON.stringify(respB));
|
|
||||||
if (fetchCalls < 2) throw new Error('expected both proxy fetches to run, got ' + fetchCalls);
|
|
||||||
|
|
||||||
// Each host must have its OWN installed DNR rule with its OWN referer —
|
|
||||||
// host A must NOT be served host B's referer (the 412 regression).
|
|
||||||
const rules = env.getSessionRules();
|
|
||||||
const ruleA = Object.values(rules).find(r => r.condition?.requestDomains?.includes('example.com'));
|
|
||||||
const ruleB = Object.values(rules).find(r => r.condition?.requestDomains?.includes('other.com'));
|
|
||||||
if (!ruleA) throw new Error('no DNR rule installed for cdna; rules=' + JSON.stringify(rules));
|
|
||||||
if (!ruleB) throw new Error('no DNR rule installed for cdnb; rules=' + JSON.stringify(rules));
|
|
||||||
if (ruleA.id === ruleB.id) {
|
|
||||||
throw new Error('cdnA and cdnB shared a DNR rule id -> concurrent race possible');
|
|
||||||
}
|
|
||||||
const refA = ruleA.action.requestHeaders?.find(h => h.header.toLowerCase() === 'referer')?.value;
|
|
||||||
const refB = ruleB.action.requestHeaders?.find(h => h.header.toLowerCase() === 'referer')?.value;
|
|
||||||
if (refA !== 'https://pageA.example.com/watch') {
|
|
||||||
throw new Error('cdnA served wrong referer: ' + refA);
|
|
||||||
}
|
|
||||||
if (refB !== 'https://pageB.other.com/watch') {
|
|
||||||
throw new Error('cdnB served wrong referer: ' + refB);
|
|
||||||
}
|
|
||||||
if (refA === refB) throw new Error('both hosts got the same referer — cross-host bleed');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('repeated proxy for the same host is idempotent (one rule, one install)', async () => {
|
|
||||||
const env = makeChromeEnv();
|
|
||||||
let installs = 0;
|
|
||||||
env.chrome.declarativeNetRequest.updateSessionRules = async ({ addRules = [] } = {}) => {
|
|
||||||
for (const r of addRules) installs++;
|
|
||||||
};
|
|
||||||
global.chrome = env.chrome;
|
|
||||||
global.fetch = async () => ({
|
|
||||||
ok: true, status: 200,
|
|
||||||
headers: { get: () => 'application/vnd.apple.mpegurl' },
|
|
||||||
arrayBuffer: async () => new Uint8Array([0]).buffer,
|
|
||||||
});
|
|
||||||
|
|
||||||
await loadBackground(env);
|
|
||||||
const handler = env.handlers.onMessage[env.handlers.onMessage.length - 1];
|
|
||||||
|
|
||||||
const run = async (n) => {
|
|
||||||
handler(
|
|
||||||
{ type: 'GOON_PROXY_FETCH', url: 'https://cdnA.example.com/hls/live/seg-1.ts', referer: 'https://pageA.example.com/watch' },
|
|
||||||
{},
|
|
||||||
() => {}
|
|
||||||
);
|
|
||||||
await new Promise(r => setTimeout(r, 5));
|
|
||||||
};
|
|
||||||
await run(1);
|
|
||||||
const installsAfterFirst = installs;
|
|
||||||
await run(2);
|
|
||||||
await run(3);
|
|
||||||
|
|
||||||
// Same host + same referer should not re-add the rule on repeat fetches.
|
|
||||||
if (installs !== installsAfterFirst) {
|
|
||||||
throw new Error(`expected idempotent install, but re-added rule ${installs - installsAfterFirst} extra time(s)`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// (c) Domain generalization — the core 412 fix. A provider referer captured for
|
|
||||||
// ONE edge subdomain must cover a SIBLING subdomain: the DNR rule is scoped to
|
|
||||||
// the registrable domain and the store is domain-keyed, so a proxy for ev-h
|
|
||||||
// needs no per-subdomain capture.
|
|
||||||
it('a proxy for a sibling subdomain reuses the captured referer via the registrable domain', async () => {
|
|
||||||
const env = makeChromeEnv();
|
|
||||||
global.chrome = env.chrome;
|
|
||||||
global.fetch = async () => ({
|
|
||||||
ok: true,
|
|
||||||
status: 200,
|
|
||||||
headers: { get: () => 'application/vnd.apple.mpegurl' },
|
|
||||||
arrayBuffer: async () => new Uint8Array([0]).buffer,
|
|
||||||
});
|
|
||||||
await loadBackground(env);
|
|
||||||
|
|
||||||
// Capture the provider referer for hv-h.example.net (one edge subdomain).
|
|
||||||
await capture(env, 'https://hv-h.example.net/hls/live/seg-1.ts', 'https://provider.example.com/watch');
|
|
||||||
|
|
||||||
// Proxy a SIBLING subdomain ev-h.example.net with NO referer in the message:
|
|
||||||
// the SW must resolve it from the domain-keyed store.
|
|
||||||
const handler = env.handlers.onMessage[env.handlers.onMessage.length - 1];
|
|
||||||
let resp;
|
|
||||||
handler({ type: 'GOON_PROXY_FETCH', url: 'https://ev-h.example.net/hls/live/seg-1.ts' }, {}, (r) => { resp = r; });
|
|
||||||
await new Promise(r => setTimeout(r, 20));
|
|
||||||
|
|
||||||
if (!resp || !resp.ok) throw new Error('sibling proxy failed: ' + JSON.stringify(resp));
|
|
||||||
const rules = env.getSessionRules();
|
|
||||||
const rule = Object.values(rules).find(r => r.condition?.requestDomains?.includes('example.net'));
|
|
||||||
if (!rule) throw new Error('no domain-scoped DNR rule for example.net; rules=' + JSON.stringify(rules));
|
|
||||||
const ref = rule.action.requestHeaders?.find(h => h.header.toLowerCase() === 'referer')?.value;
|
|
||||||
if (ref !== 'https://provider.example.com/watch') {
|
|
||||||
throw new Error('sibling edge served wrong referer: ' + ref);
|
|
||||||
}
|
|
||||||
// The rule is scoped to the registrable domain, NOT the exact subdomain.
|
|
||||||
if (rule.condition.requestDomains?.includes('ev-h.example.net')) {
|
|
||||||
throw new Error('rule scoped to exact subdomain instead of registrable domain');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// (d) With no captured referer and none in the message, install NO rule: the
|
|
||||||
// self-origin fallback (guaranteed-wrong for hotlink protection) is dropped.
|
|
||||||
it('no captured referer and no message referer installs no DNR rule', async () => {
|
|
||||||
const env = makeChromeEnv();
|
|
||||||
let fetchCalls = 0;
|
|
||||||
global.chrome = env.chrome;
|
|
||||||
global.fetch = async () => {
|
|
||||||
fetchCalls++;
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
status: 200,
|
|
||||||
headers: { get: () => 'application/vnd.apple.mpegurl' },
|
|
||||||
arrayBuffer: async () => new Uint8Array([0]).buffer,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
await loadBackground(env);
|
|
||||||
const handler = env.handlers.onMessage[env.handlers.onMessage.length - 1];
|
|
||||||
let resp;
|
|
||||||
handler({ type: 'GOON_PROXY_FETCH', url: 'https://cdnA.example.com/hls/live/seg-1.ts' }, {}, (r) => { resp = r; });
|
|
||||||
await new Promise(r => setTimeout(r, 20));
|
|
||||||
|
|
||||||
if (!resp || !resp.ok) throw new Error('proxy fetch failed: ' + JSON.stringify(resp));
|
|
||||||
if (fetchCalls < 1) throw new Error('proxy fetch did not run');
|
|
||||||
const rules = env.getSessionRules();
|
|
||||||
if (Object.keys(rules).length !== 0) {
|
|
||||||
throw new Error('installed a rule with no referer (self-origin fallback not dropped): ' + JSON.stringify(rules));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// (e) GOON_GET_REFERERS (used by scan-time capture) resolves each manifest host
|
|
||||||
// via the domain-keyed store: a sibling subdomain gets the provider referer,
|
|
||||||
// and an uncaptured host is omitted.
|
|
||||||
it('GOON_GET_REFERERS resolves manifest hosts through the domain-keyed store', async () => {
|
|
||||||
const env = makeChromeEnv();
|
|
||||||
global.chrome = env.chrome;
|
|
||||||
await loadBackground(env);
|
|
||||||
|
|
||||||
// Capture for hv-h.example.net only.
|
|
||||||
await capture(env, 'https://hv-h.example.net/hls/live/seg-1.ts', 'https://provider.example.com/watch');
|
|
||||||
|
|
||||||
const handler = env.handlers.onMessage[env.handlers.onMessage.length - 1];
|
|
||||||
let resp;
|
|
||||||
handler(
|
|
||||||
{ type: 'GOON_GET_REFERERS', hosts: ['ev-h.example.net', 'hv-h.example.net', 'cdnA.example.com'] },
|
|
||||||
{},
|
|
||||||
(r) => { resp = r; });
|
|
||||||
await new Promise(r => setTimeout(r, 15));
|
|
||||||
|
|
||||||
if (!resp || !resp.ok) throw new Error('GOON_GET_REFERERS failed: ' + JSON.stringify(resp));
|
|
||||||
const referers = resp.referers || {};
|
|
||||||
if (referers['ev-h.example.net'] !== 'https://provider.example.com/watch') {
|
|
||||||
throw new Error('sibling subdomain did not resolve the captured referer: ' + JSON.stringify(referers));
|
|
||||||
}
|
|
||||||
if (referers['hv-h.example.net'] !== 'https://provider.example.com/watch') {
|
|
||||||
throw new Error('captured subdomain did not resolve its own referer: ' + JSON.stringify(referers));
|
|
||||||
}
|
|
||||||
if ('cdnA.example.com' in referers) {
|
|
||||||
throw new Error('uncaptured host should be omitted from referers: ' + JSON.stringify(referers));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Regression for the live 412 bug: the worker captures the provider referer
|
|
||||||
// from the source tab's URL via chrome.tabs.get inside onBeforeRequest. The
|
|
||||||
// old path read request headers via onBeforeSendHeaders + extraInfoSpec, which
|
|
||||||
// is rejected by some Chromium builds and fails the whole service worker, so it
|
|
||||||
// was removed; this asserts the new tab-URL capture is wired up and actually
|
|
||||||
// stores a referer.
|
|
||||||
it('the onBeforeRequest capture reads the referer from the source tab URL', async () => {
|
|
||||||
const env = makeChromeEnv();
|
|
||||||
global.chrome = env.chrome;
|
|
||||||
await loadBackground(env);
|
|
||||||
|
|
||||||
const cap = env.handlers.onBeforeRequest;
|
|
||||||
if (!cap) throw new Error('onBeforeRequest listener was not registered');
|
|
||||||
|
|
||||||
// A video request for cdnA from a tab whose URL IS the provider referer.
|
|
||||||
env.setTabUrl(11, 'https://pageA.example.com/watch');
|
|
||||||
await cap({ tabId: 11, url: 'https://cdnA.example.com/hls/live/seg-1.ts' });
|
|
||||||
|
|
||||||
const rA = env.storage['goon_referers']?.['example.com'];
|
|
||||||
if (!rA) throw new Error('referer was not captured from the source tab URL');
|
|
||||||
if (rA.referer !== 'https://pageA.example.com/watch') {
|
|
||||||
throw new Error('captured referer wrong: ' + JSON.stringify(rA));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -3,13 +3,9 @@
|
|||||||
*/
|
*/
|
||||||
import './schema.test.mjs';
|
import './schema.test.mjs';
|
||||||
import './viewer_units.test.mjs';
|
import './viewer_units.test.mjs';
|
||||||
import './instructions_ui.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 './config_validation.test.mjs';
|
||||||
import './hls_qa.test.mjs';
|
|
||||||
import './simple_video.test.mjs';
|
|
||||||
import './referer_proxy.test.mjs';
|
|
||||||
import { run } from './runner.mjs';
|
import { run } from './runner.mjs';
|
||||||
|
|
||||||
const result = await run();
|
const result = await run();
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
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 { readFileSync } from 'node:fs';
|
import schema from '../src/viewer/schema.json' with { type: 'json' };
|
||||||
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);
|
||||||
|
|||||||
@@ -1,131 +0,0 @@
|
|||||||
// Regression tests: simple <video> / direct-media detection.
|
|
||||||
//
|
|
||||||
// The feature branch broke detection of pages that expose a plain <video>
|
|
||||||
// (e.g. a site that serves a direct MP4 with a TRAILING SLASH and a
|
|
||||||
// ?rnd= query: .../14644093/14644093.mp4/?rnd=...). Two site-agnostic fixes
|
|
||||||
// are guarded here:
|
|
||||||
// 1) The URL predicate (isValidManifestUrl) must let concrete media signals
|
|
||||||
// (.mp4/.m3u8/.webm/... and /hls/) WIN over the placeholder/`%`/trailing-
|
|
||||||
// slash rejections, so a real direct-media URL is not mistaken for a
|
|
||||||
// route template.
|
|
||||||
// 2) scrapeTab must fall back to an ISOLATED-world scrape when the MAIN-world
|
|
||||||
// scrape yields nothing — the runtime cause of "detects nothing" on such pages
|
|
||||||
// is a MAIN-world + allFrames rejection on its ad-iframe-heavy page.
|
|
||||||
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 indexSource = fs.readFileSync(
|
|
||||||
path.join(__dirname, '../src/viewer/index.js'),
|
|
||||||
'utf-8'
|
|
||||||
);
|
|
||||||
|
|
||||||
// Extract a `function <name>(...) {…}` by balanced-brace scan. Robust to the
|
|
||||||
// file's quirky/mixed indentation (a column-0 `}` heuristic would mis-truncate
|
|
||||||
// nested or oddly-indented bodies).
|
|
||||||
function extractFn(src, name) {
|
|
||||||
const re = new RegExp('function ' + name + '\\s*\\(');
|
|
||||||
const m = re.exec(src);
|
|
||||||
if (!m) throw new Error('not found: ' + name);
|
|
||||||
const start = m.index;
|
|
||||||
let i = src.indexOf('{', start), depth = 0;
|
|
||||||
for (; i < src.length; i++) {
|
|
||||||
const c = src[i];
|
|
||||||
if (c === '{') depth++;
|
|
||||||
else if (c === '}') { depth--; if (depth === 0) return src.slice(start, i + 1); }
|
|
||||||
}
|
|
||||||
throw new Error('unbalanced: ' + name);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The direct-media URL, shaped exactly like the rejected case (a trailing slash +
|
|
||||||
// ?rnd= query). This is the concrete case the predicate used to reject.
|
|
||||||
const DIRECT_MP4 =
|
|
||||||
'https://example.com/get_file/5/6cae22f8b3ba9d74f10cc2f8d060af738c8d30ddf5/14644000/14644093/14644093.mp4/?rnd=1787811205406';
|
|
||||||
|
|
||||||
describe('HLS QA: simple <video> direct-media predicate', () => {
|
|
||||||
// Extract and evaluate the REAL isValidManifestUrl from index.js in a browser
|
|
||||||
// window so its `new URL(...)` / string behavior matches production.
|
|
||||||
const isValidSrc = extractFn(indexSource, 'isValidManifestUrl');
|
|
||||||
|
|
||||||
function assertValid(url, expectValid, label) {
|
|
||||||
const dom = new JSDOM(
|
|
||||||
'<!DOCTYPE html><html><body></body></html>',
|
|
||||||
{ url: 'https://example.com/videos/x/', runScripts: 'dangerously' }
|
|
||||||
);
|
|
||||||
const got = dom.window.eval('(' + isValidSrc + ')(' + JSON.stringify(url) + ')');
|
|
||||||
if (got !== expectValid) {
|
|
||||||
throw new Error(
|
|
||||||
`${label || 'isValidManifestUrl'}(${url}) = ${got}, expected ${expectValid}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
it('accepts a real direct-MP4 URL WITH a trailing slash + ?rnd=', () => {
|
|
||||||
// Before the fix, `path.endsWith('/')` rejected this real media source.
|
|
||||||
assertValid(DIRECT_MP4, true, 'trailing-slash MP4');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('accepts a direct .mp4 with no trailing slash', () => {
|
|
||||||
assertValid('https://cdn.example.com/vids/abc123.mp4', true, 'plain MP4');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('accepts real .m3u8 / .webm / .mpd / .m4s / /hls/ media signals', () => {
|
|
||||||
assertValid('https://cdn.example.com/live/master.m3u8', true, 'master.m3u8');
|
|
||||||
assertValid('https://cdn.example.com/seg/webm/v.webm', true, 'webm');
|
|
||||||
assertValid('https://cdn.example.com/seg/dash/master.mpd', true, 'mpd');
|
|
||||||
assertValid('https://cdn.example.com/seg/1-0.m4s', true, 'm4s');
|
|
||||||
assertValid('https://cdn.example.com/hls/abc/playlist.m3u8', true, '/hls/');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not over-reject a real .m3u8 carrying a valid %2B/%2F token', () => {
|
|
||||||
// %2B/%2F are valid percent-encoded hex pairs — a genuine auth token, not a
|
|
||||||
// route-template placeholder, so it must survive.
|
|
||||||
assertValid('https://cdn.example.com/live/master.m3u8?h=a%2Bb%2Fc&e=1234', true, '%2B token');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('still rejects route-template / nav URLs (no over-acceptance)', () => {
|
|
||||||
// The fix must not trade one regression for the other: the route templates
|
|
||||||
// a provider's pageContext.langUrls leaks must still be rejected.
|
|
||||||
assertValid('https://example.com/playlists/', false, '/playlists/');
|
|
||||||
assertValid('https://example.com/playlists/liked/', false, '/playlists/liked/');
|
|
||||||
assertValid('https://example.com/playlist/%1%/video/%2%/', false, '/playlist/%1%/');
|
|
||||||
assertValid('https://example.com/members/%ID%/playlists/', false, '/members/%ID%/playlists/');
|
|
||||||
assertValid('https://example.com/videos/%DIR%/', false, '/videos/%DIR%/');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('HLS QA: scrapeTab isolated-world fallback', () => {
|
|
||||||
// Extract the whole scrapeTab function (balanced braces, indentation-agnostic).
|
|
||||||
const scrapeSrc = extractFn(indexSource, 'scrapeTab');
|
|
||||||
|
|
||||||
it('runs an isolated-world scrape attempt (no `world` key) as a fallback', () => {
|
|
||||||
const calls = scrapeSrc.match(/executeScript\(\s*\{[\s\S]*?\}\s*\)/g) || [];
|
|
||||||
if (calls.length === 0) {
|
|
||||||
throw new Error('scrapeTab has no executeScript call');
|
|
||||||
}
|
|
||||||
// At least one executeScript must omit `world` so it runs in the always-
|
|
||||||
// permitted isolated world (recovers simple videos a MAIN-world injection
|
|
||||||
// rejects on sandboxed/opaque-origin ad iframes).
|
|
||||||
const hasIsolated = calls.some((c) => !/\bworld\s*:/.test(c));
|
|
||||||
if (!hasIsolated) {
|
|
||||||
throw new Error(
|
|
||||||
'scrapeTab has no isolated-world (no `world:`) executeScript fallback — ' +
|
|
||||||
'a MAIN-world+allFrames rejection on an ad-iframe-heavy page leaves 0 videos'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('primary scrape still uses world: \'MAIN\' (reads page globals)', () => {
|
|
||||||
const calls = scrapeSrc.match(/executeScript\(\s*\{[\s\S]*?\}\s*\)/g) || [];
|
|
||||||
const hasMain = calls.some((c) => /world\s*:\s*['"]MAIN['"]/.test(c));
|
|
||||||
if (!hasMain) {
|
|
||||||
throw new Error(
|
|
||||||
'scrapeTab primary attempt must use world: "MAIN" to read page globals ' +
|
|
||||||
'(interceptor data, Hls instances, custom <video> props)'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -178,6 +178,15 @@ 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'));
|
||||||
@@ -382,6 +391,15 @@ 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}`));
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
# Instructions UI audit
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
This audit covers the VidPlex popup instructions banner added in `src/viewer/index.html`, `src/viewer/index.css`, and `src/viewer/index.js`.
|
|
||||||
|
|
||||||
## What the UI now explains
|
|
||||||
- How to scan the current page for videos
|
|
||||||
- How to review clickable results
|
|
||||||
- How to open the 2x2 viewer
|
|
||||||
- How to clear and rescan
|
|
||||||
- A caveat that browser pages, extension pages, and restricted frames may hide videos
|
|
||||||
|
|
||||||
## Accessibility checks
|
|
||||||
- The instructions section uses `role="region"` and an accessible label
|
|
||||||
- The collapse toggle is wired with `aria-expanded` and `aria-controls`
|
|
||||||
- The body is hidden with the `hidden` attribute when collapsed
|
|
||||||
|
|
||||||
## UX notes
|
|
||||||
- The panel is visible on mount so first-time users see the steps immediately
|
|
||||||
- The toggle label changes between “Hide instructions” and “Show instructions”
|
|
||||||
- The copy intentionally stays short so the popup remains readable in a narrow extension window
|
|
||||||
|
|
||||||
## Validation
|
|
||||||
- Covered by `__tests__/instructions_ui.test.mjs`
|
|
||||||
- Verified alongside the existing build/test flow in the parent implementation task
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
# Instructions UI copy
|
|
||||||
|
|
||||||
## Banner copy
|
|
||||||
Quick start
|
|
||||||
How to use VidPlex
|
|
||||||
|
|
||||||
## Steps
|
|
||||||
1. Scan videos — Click Find Videos to search the current browser window for videos.
|
|
||||||
2. Review the list — Check the popup list; each found video URL is clickable.
|
|
||||||
3. Open the viewer — Click Open Viewer to open the 2x2 grid player in a new window.
|
|
||||||
4. Reset and rescan — Click Clear to erase results and scan again.
|
|
||||||
|
|
||||||
## Warning copy
|
|
||||||
Warning: Browser pages, extension pages, and restricted frames can hide videos, so results may be incomplete.
|
|
||||||
|
|
||||||
## Learn-more copy
|
|
||||||
Learn more tooltip: Read the VidPlex README and audit notes.
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720" role="img" aria-label="VidPlex popup instructions preview">
|
|
||||||
<defs>
|
|
||||||
<style>
|
|
||||||
.bg { fill: #ffffff; }
|
|
||||||
.panel { fill: #f8fafc; stroke: #e2e8f0; stroke-width: 1; }
|
|
||||||
.text { fill: #1a1a2e; font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; }
|
|
||||||
.muted { fill: #64748b; }
|
|
||||||
.accent { fill: #4361ee; }
|
|
||||||
.warn { fill: #7c3aed; }
|
|
||||||
.small { font-size: 12px; }
|
|
||||||
.tiny { font-size: 11px; letter-spacing: .08em; font-weight: 700; }
|
|
||||||
.title { font-size: 18px; font-weight: 600; }
|
|
||||||
.h2 { font-size: 14px; font-weight: 600; }
|
|
||||||
.step-title { font-size: 12px; font-weight: 600; }
|
|
||||||
.body { font-size: 12px; fill: #475569; }
|
|
||||||
.button-text { font-size: 12px; fill: #4361ee; }
|
|
||||||
.primary-text { fill: #ffffff; font-size: 14px; }
|
|
||||||
</style>
|
|
||||||
</defs>
|
|
||||||
|
|
||||||
<rect class="bg" x="0" y="0" width="1280" height="720"/>
|
|
||||||
<text class="text title" x="18" y="30">VidPlex</text>
|
|
||||||
|
|
||||||
<rect class="panel" x="17" y="50" width="1264" height="276" rx="10"/>
|
|
||||||
<text class="text muted tiny" x="30" y="72">QUICK START</text>
|
|
||||||
<text class="text h2" x="30" y="90">How to use VidPlex</text>
|
|
||||||
|
|
||||||
<rect x="1150" y="62" width="104" height="28" rx="8" fill="#ffffff" stroke="#e2e8f0"/>
|
|
||||||
<text class="button-text" x="1165" y="80">Hide instructions</text>
|
|
||||||
|
|
||||||
<g class="text">
|
|
||||||
<text class="step-title" x="48" y="126">Scan videos</text>
|
|
||||||
<text class="body" x="48" y="144">Click Find Videos to search the current browser window for videos.</text>
|
|
||||||
|
|
||||||
<text class="step-title" x="48" y="176">Review the list</text>
|
|
||||||
<text class="body" x="48" y="194">Check the popup list; each found video URL is clickable.</text>
|
|
||||||
|
|
||||||
<text class="step-title" x="48" y="226">Open the viewer</text>
|
|
||||||
<text class="body" x="48" y="244">Click Open Viewer to open the 2x2 grid player in a new window.</text>
|
|
||||||
|
|
||||||
<text class="step-title" x="48" y="276">Reset and rescan</text>
|
|
||||||
<text class="body" x="48" y="294">Click Clear to erase results and scan again.</text>
|
|
||||||
|
|
||||||
<text class="warn small" x="30" y="319">Warning: Browser pages, extension pages, and restricted frames can hide videos, so results may be incomplete.</text>
|
|
||||||
<text class="body" x="30" y="348">Learn more tooltip: Read the VidPlex README and audit notes.</text>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<rect x="17" y="339" width="1264" height="31" rx="8" fill="#4361ee"/>
|
|
||||||
<text class="primary-text" x="561" y="359">Find Videos (all tabs)</text>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.5 KiB |
@@ -1,26 +0,0 @@
|
|||||||
# Instructions UI design
|
|
||||||
|
|
||||||
## Placement
|
|
||||||
The instructions banner sits above the primary action button so new users can read the steps before interacting with the popup.
|
|
||||||
|
|
||||||
## Structure
|
|
||||||
- Short kicker: “Quick start”
|
|
||||||
- Heading: “How to use VidPlex”
|
|
||||||
- Expand/collapse toggle for users who want a smaller popup
|
|
||||||
- Ordered list of the core tasks
|
|
||||||
- One warning line for edge cases
|
|
||||||
- One short learn-more line that points to the README and audit notes
|
|
||||||
|
|
||||||
## Copy strategy
|
|
||||||
The microcopy avoids jargon and keeps each step tied to a visible control:
|
|
||||||
- Find Videos
|
|
||||||
- Open Viewer
|
|
||||||
- Clear
|
|
||||||
|
|
||||||
## Accessibility strategy
|
|
||||||
- A labelled region exposes the whole panel to assistive tech
|
|
||||||
- The toggle reflects the current visibility state
|
|
||||||
- The collapsed state removes the body from the layout entirely
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
This design intentionally favors clarity over density because extension popups are small and users typically need the instructions only once.
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
# Add user instructions to goon plugin UI
|
|
||||||
|
|
||||||
Branch: `feature/goon-instructions`
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
Adds an always-visible, collapsible quick-start panel to the VidPlex popup so users can understand the scan / review / open / clear flow without reading the README first.
|
|
||||||
|
|
||||||
## Links
|
|
||||||
- Audit notes: [docs/instructions-audit.md](docs/instructions-audit.md)
|
|
||||||
- Design notes: [docs/instructions-design.md](docs/instructions-design.md)
|
|
||||||
- Copy deck: [docs/instructions-copy.md](docs/instructions-copy.md)
|
|
||||||
- Preview render: [docs/instructions-demo.svg](docs/instructions-demo.svg)
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
- `npm run build`
|
|
||||||
- `npm test`
|
|
||||||
|
|
||||||
Manual verification:
|
|
||||||
1. Open the extension popup.
|
|
||||||
2. Confirm the instructions banner renders expanded on mount.
|
|
||||||
3. Click the toggle and confirm the panel collapses / expands and the label changes.
|
|
||||||
4. Confirm the main Scan Videos button still works normally.
|
|
||||||
|
|
||||||
## Changed files
|
|
||||||
- `src/viewer/index.html`
|
|
||||||
- `src/viewer/index.css`
|
|
||||||
- `src/viewer/index.js`
|
|
||||||
- `__tests__/instructions_ui.test.mjs`
|
|
||||||
- `__tests__/run.mjs`
|
|
||||||
- `docs/instructions-audit.md`
|
|
||||||
- `docs/instructions-design.md`
|
|
||||||
- `docs/instructions-copy.md`
|
|
||||||
- `docs/instructions-demo.svg`
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
- A PR exists against `master`.
|
|
||||||
- The PR description includes summary, links to the design/copy docs, a visual preview, testing steps, changed files, and acceptance criteria.
|
|
||||||
- Reviewers are requested before handoff.
|
|
||||||
|
|
||||||
## Open questions / risks
|
|
||||||
- The repo still has some pre-existing lint warnings outside this change.
|
|
||||||
- The preview asset is an SVG render of the UI rather than a raster screenshot.
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
// 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',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
File renamed without changes.
+8
-16
@@ -1,15 +1,13 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "VidPlex",
|
"name": "Goon Plugin",
|
||||||
"version": "0.2.0",
|
"version": "0.1.0",
|
||||||
"description": "Find videos on any page and view them in a grid.",
|
"description": "A Chrome extension",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"storage",
|
"storage",
|
||||||
"tabs",
|
"tabs",
|
||||||
"scripting",
|
"scripting"
|
||||||
"webRequest",
|
],
|
||||||
"declarativeNetRequest"
|
|
||||||
],
|
|
||||||
"host_permissions": [
|
"host_permissions": [
|
||||||
"<all_urls>"
|
"<all_urls>"
|
||||||
],
|
],
|
||||||
@@ -33,12 +31,6 @@
|
|||||||
"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",
|
||||||
|
|||||||
Generated
+11
-2
@@ -1,12 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "vidplex",
|
"name": "goon-plugin",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "vidplex",
|
"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",
|
||||||
@@ -2781,6 +2784,12 @@
|
|||||||
"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",
|
||||||
|
|||||||
+7
-6
@@ -1,18 +1,21 @@
|
|||||||
{
|
{
|
||||||
"name": "vidplex",
|
"name": "goon-plugin",
|
||||||
"version": "0.2.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Chrome extension: find videos on a page and view them in a grid.",
|
"description": "Chrome extension scaffold",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build && node scripts/copy-hlds.mjs",
|
"build": "vite build",
|
||||||
"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",
|
||||||
@@ -23,7 +26,5 @@
|
|||||||
"typescript": "^5.7.0",
|
"typescript": "^5.7.0",
|
||||||
"vite": "^6.0.0",
|
"vite": "^6.0.0",
|
||||||
"vitest": "^3.0.0"
|
"vitest": "^3.0.0"
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
// 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)`);
|
|
||||||
+16
-604
@@ -4,617 +4,29 @@
|
|||||||
* Uses Manifest V3 module-type service worker.
|
* Uses Manifest V3 module-type service worker.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// === HLS manifest tracking via chrome.webRequest ===
|
// Fired when the extension is first installed / updated / Chrome starts.
|
||||||
// Intercepts network requests at the browser level — no world/timing issues.
|
chrome.runtime.onInstalled.addListener((details) => {
|
||||||
// Catches .m3u8 manifest requests from ANY player (HLS.js, Video.js, custom).
|
if (details.reason === 'install') {
|
||||||
//
|
console.log('[goon-plugin] Extension installed');
|
||||||
// IMPORTANT: MV3 service workers are ephemeral — they are killed when idle and
|
} else if (details.reason === 'update') {
|
||||||
// restarted on demand. In-memory state (Map, Set, etc.) is LOST on kill.
|
console.log(`[goon-plugin] Extension updated from ${details.previousVersion}`);
|
||||||
// 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 video requests across any CDN ===
|
|
||||||
// Some players generate HLS manifests in-memory and make zero .m3u8 network
|
|
||||||
// requests, only fetching video segments from a CDN. By logging every video-like
|
|
||||||
// request (any host) 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. No site-specific host list.
|
|
||||||
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 — video 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('[vidplex-bg] Failed to persist video request:', e && e.message ? e.message : e);
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
// --- Token / URL helpers ------------------------------------------
|
|
||||||
//
|
|
||||||
// A media CDN's 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).
|
|
||||||
|
|
||||||
// Group key for a video's URL: the path directory holding its variant/segment
|
|
||||||
// (the last non-file path segment of a CDN URL). Used to match the freshest
|
|
||||||
// live URL by video, host-independent. '' when the URL is malformed.
|
|
||||||
function extractGroupKey(url) {
|
|
||||||
if (!url) return '';
|
|
||||||
try {
|
|
||||||
const path = new URL(url).pathname.split('/');
|
|
||||||
// Drop the final (file/segment or manifest) segment; the remaining
|
|
||||||
// directory joins into a key that is stable across a video's variants.
|
|
||||||
path.pop();
|
|
||||||
return path.join('/');
|
|
||||||
} 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 the parent manifest URL for a segment URL, keeping the FULL path to
|
|
||||||
// the variant directory and the RAW query (token encoding intact). Swaps the
|
|
||||||
// segment filename for a manifest filename in the same directory:
|
|
||||||
// {host}/path/to/variant/seg-1.ts -> {host}/path/to/variant/master.m3u8
|
|
||||||
// The variant directory is MANDATORY — a master playlist lives inside it;
|
|
||||||
// dropping it yields a 404/410. Returns '' for non-segment URLs.
|
|
||||||
function buildManifestForVariant(url) {
|
|
||||||
if (!url || !/\/seg-\d+/i.test(url)) return '';
|
|
||||||
try {
|
|
||||||
const [pathPart, queryPart] = url.split('?');
|
|
||||||
const idx = pathPart.lastIndexOf('/');
|
|
||||||
if (idx === -1) return '';
|
|
||||||
const dir = pathPart.slice(0, idx); // variant directory
|
|
||||||
const rawQuery = queryPart ? '?' + queryPart : '';
|
|
||||||
return `${dir}/master.m3u8${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 video/segment URLs (e.g. .../seg-N.ts).
|
|
||||||
const manifestUrl = buildManifestForVariant(url);
|
|
||||||
if (manifestUrl && !seen.has(manifestUrl)) {
|
|
||||||
seen.add(manifestUrl);
|
|
||||||
manifests.push(manifestUrl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return manifests;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collect captured video 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 collectAllVideoUrls() {
|
|
||||||
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 group key, return the freshest live captured 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: match patterns are brittle (a bad
|
|
||||||
// pattern throws "Invalid url pattern" and silently drops every rescan).
|
|
||||||
// We scan persisted capture data instead, which is always available and
|
|
||||||
// sidesteps the match-pattern API entirely.
|
|
||||||
async function freshLiveForGroup(groupKey, preferManifest) {
|
|
||||||
let all;
|
|
||||||
try {
|
|
||||||
all = await collectAllVideoUrls();
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('[vidplex-bg] collectAllVideoUrls 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 (extractGroupKey(url) !== groupKey) continue;
|
|
||||||
const exp = tokenExpirySeconds(extractRawQuery(url));
|
|
||||||
if (url.includes('.m3u8')) {
|
|
||||||
if (exp > bestManifestExp) { bestManifestExp = exp; bestManifest = url; }
|
|
||||||
} else if (/\/seg-\d+/i.test(url)) {
|
|
||||||
if (exp > bestVariantExp) { bestVariantExp = exp; bestVariant = url; }
|
|
||||||
} else {
|
|
||||||
if (exp > bestTokenExp) { bestTokenExp = exp; bestTokenSource = url; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prefer a captured manifest; else the best segment (reconstructable to a
|
|
||||||
// manifest); else a token source so the proxy can 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 group key so we don't query storage on every proxy
|
|
||||||
// request. Keyed by group key; values resolve once and are reused until a rescan
|
|
||||||
// (GOON_RESOLVE_MANIFEST) or a 410 forces an invalidation.
|
|
||||||
const tokenCache = new Map();
|
|
||||||
|
|
||||||
// Given a CDN 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 group. Returns the URL with a query appended, or the original URL
|
|
||||||
// unchanged when no token is available yet.
|
|
||||||
async function injectFreshToken(url) {
|
|
||||||
if (!url) return url;
|
|
||||||
// Already carries a token — leave it alone.
|
|
||||||
if (url.includes('?')) return url;
|
|
||||||
try {
|
|
||||||
const groupKey = extractGroupKey(url);
|
|
||||||
if (!groupKey) return url;
|
|
||||||
if (tokenCache.has(groupKey)) {
|
|
||||||
const token = tokenCache.get(groupKey);
|
|
||||||
return token ? url + '?' + token : url;
|
|
||||||
}
|
|
||||||
const fresh = await freshLiveForGroup(groupKey, false);
|
|
||||||
const token = fresh ? extractRawQuery(fresh) : '';
|
|
||||||
tokenCache.set(groupKey, token);
|
|
||||||
console.log('[vidplex-bg] injectFreshToken for', groupKey, (token ? token.substring(0, 40) + '…' : '(none captured)'));
|
|
||||||
return token ? url + '?' + token : url;
|
|
||||||
} catch {
|
|
||||||
return 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(); }
|
|
||||||
|
|
||||||
// CDN registrable domains whose referer we have already captured this SW
|
|
||||||
// lifetime, so we do not re-query the tab for every segment of a stream.
|
|
||||||
// Lost if the SW is killed; persistReferer's storage dedupe covers a fresh SW.
|
|
||||||
const refererCapturedDomains = new Set();
|
|
||||||
|
|
||||||
chrome.webRequest.onBeforeRequest.addListener(
|
|
||||||
async (details) => {
|
|
||||||
if (details.tabId < 0) return;
|
|
||||||
|
|
||||||
// Log video-like requests from any host (CDN-agnostic).
|
|
||||||
if (isVideoRequest(details.url)) {
|
|
||||||
console.log('[vidplex-bg] webRequest: video request in tab', details.tabId, ':', details.url.substring(0, 180));
|
|
||||||
await persistVideoRequest(details.tabId, details.url);
|
|
||||||
// Capture the provider referer for this CDN host from the source tab's
|
|
||||||
// URL: the browser sends the referring page's URL as the Referer for a
|
|
||||||
// cross-origin video request, and that IS the tab's URL. extraInfoSpec
|
|
||||||
// (reading the actual request headers) is rejected by some Chromium
|
|
||||||
// builds and fails the whole service worker, so we derive the referer
|
|
||||||
// from the tab instead. The `tabs` + host_permissions already allow it.
|
|
||||||
const host = hostOf(details.url);
|
|
||||||
const domain = host ? registrableDomain(host) : '';
|
|
||||||
if (domain && !refererCapturedDomains.has(domain)) {
|
|
||||||
try {
|
|
||||||
const tab = await chrome.tabs.get(details.tabId);
|
|
||||||
const ref = tab && tab.url;
|
|
||||||
if (ref) {
|
|
||||||
await persistReferer(host, ref);
|
|
||||||
refererCapturedDomains.add(domain);
|
|
||||||
console.log('[vidplex-bg] captured referer for', domain, '->', ref);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Tab may have closed mid-stream; a later request from a live tab
|
|
||||||
// re-captures. Best-effort.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also capture HLS manifest URLs (original logic)
|
|
||||||
if (!isHlsManifest(details.url)) return;
|
|
||||||
|
|
||||||
console.log('[vidplex-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('[vidplex-bg] Failed to persist webRequest manifest:', e && e.message ? e.message : e);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ urls: ['<all_urls>'] }
|
|
||||||
);
|
|
||||||
|
|
||||||
// Clean up stale PER-TAB entries when tabs are closed. NOTE: 'goon_referers' is
|
|
||||||
// a domain-keyed GLOBAL store — it must outlive its capture tab so a proxy fetch
|
|
||||||
// for that host still finds its referer after the source tab is closed. It is
|
|
||||||
// therefore NOT removed here (only the per-tab vr_requests_/wr_manifests_ keys
|
|
||||||
// are). Stale referers are pruned opportunistically in persistReferer.
|
|
||||||
chrome.tabs.onRemoved.addListener(async (tabId) => {
|
|
||||||
const keys = ['wr_manifests_' + tabId, 'vr_requests_' + tabId];
|
|
||||||
try {
|
|
||||||
await chrome.storage.local.remove(keys);
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
});
|
|
||||||
|
|
||||||
// === Capture the provider-origin Referer per CDN host (CDN-agnostic) ===
|
|
||||||
// Some media CDNs reject requests whose `Referer` is not the provider page's
|
|
||||||
// origin (HTTP 412). A fetch() CANNOT set `Referer` — it is a "forbidden
|
|
||||||
// header" in the Fetch API, so a service-worker fetch() would send
|
|
||||||
// `Referer: chrome-extension://<id>`, which the CDN rejects. The
|
|
||||||
// declarativeNetRequest API rewrites request headers at the network layer, below
|
|
||||||
// the Fetch API's restriction, and its rules apply to fetch() calls made inside
|
|
||||||
// the SW.
|
|
||||||
//
|
|
||||||
// We cannot hardcode a target referer: the proxy is invoked from the viewer
|
|
||||||
// (a different origin than the source page), and a DNR rule cannot read a live
|
|
||||||
// page's origin. So we derive the provider referer from the source tab's own URL.
|
|
||||||
// For a cross-origin video request the browser sends the referring page's URL as
|
|
||||||
// the Referer, and that page IS the tab currently loading the video — so the tab
|
|
||||||
// URL is the referer we need. We read it via chrome.webRequest.onBeforeRequest
|
|
||||||
// (no header access — it cannot read the request headers) plus chrome.tabs.get,
|
|
||||||
// which returns the source tab's URL, and persist that per CDN host. The
|
|
||||||
// GOON_PROXY_FETCH handler then installs a DNR rule that re-inserts that
|
|
||||||
// captured referer for just that host, so the proxy's fetch() is accepted.
|
|
||||||
// This requires no knowledge of any particular site. Deriving the referer from
|
|
||||||
// the tab URL (rather than reading the real request header via
|
|
||||||
// onBeforeSendHeaders + extraHeaders) is intentional: extraInfoSpec header
|
|
||||||
// parsing is rejected by some Chromium builds and fails the whole service worker,
|
|
||||||
// and the tabs + host permissions already permit chrome.tabs.get.
|
|
||||||
//
|
|
||||||
// Each CDN host gets its OWN declarativeNetRequest rule id so that concurrent
|
|
||||||
// proxy fetches for different hosts can't clobber each other's rule (a single
|
|
||||||
// shared id + a remove-then-add lets two hosts interleave and leave the wrong
|
|
||||||
// host's referer live). The id is derived from the host via a monotonic counter
|
|
||||||
// keyed by host, so it is stable for a given host across installs.
|
|
||||||
let nextRefererRuleId = 9001;
|
|
||||||
const refererRuleIds = new Map();
|
|
||||||
function refererRuleIdFor(host) {
|
|
||||||
const existing = refererRuleIds.get(host);
|
|
||||||
if (existing !== undefined) return existing;
|
|
||||||
const id = nextRefererRuleId++;
|
|
||||||
refererRuleIds.set(host, id);
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
// host of a URL (CDN host, lowercased); '' when the URL is malformed.
|
|
||||||
function hostOf(url) {
|
|
||||||
if (!url) return '';
|
|
||||||
try { return new URL(url).host.toLowerCase(); } catch { return ''; }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Registrable domain of a CDN host — the label a single provider's referer
|
|
||||||
// applies to. A referer-protecting CDN serves manifests and segments from many
|
|
||||||
// edge subdomains that share ONE
|
|
||||||
// provider referer, so we key the referer store, the DNR rule, and proxy-host
|
|
||||||
// matching on this domain rather than the exact subhost.
|
|
||||||
//
|
|
||||||
// Heuristic: the last two DNS labels. Correct for the overwhelmingly common
|
|
||||||
// single-label public suffixes (.com, .net, .org, .io, ...). Known caveat:
|
|
||||||
// multi-label public suffixes such as .co.uk / .com.au collapse to the public
|
|
||||||
// suffix itself (foo.co.uk -> co.uk); that is an exotic edge case and no worse
|
|
||||||
// than the exact-host behavior it replaces. A single-level host (no dot, or a
|
|
||||||
// bare TLD) is returned unchanged.
|
|
||||||
function registrableDomain(host) {
|
|
||||||
if (!host) return '';
|
|
||||||
const parts = host.split('.');
|
|
||||||
if (parts.length < 2) return host;
|
|
||||||
return parts.slice(-2).join('.');
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// How long a captured referer is trusted before it is pruned on the next
|
|
||||||
// persist. Kept long enough for the scan-now / watch-later flow (a user scans a
|
|
||||||
// page, then opens the viewer much later) without tying cleanup to tab close (a
|
|
||||||
// still-open source tab's referer must survive other tabs closing).
|
|
||||||
const REFERER_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
||||||
|
|
||||||
// Persist the captured referer per CDN registrable domain so the proxy can
|
|
||||||
// re-use it even after the capture tab closes. Keyed by the host's registrable
|
|
||||||
// domain so a single provider referer covers every edge subdomain (a CDN serves
|
|
||||||
// manifests/segments from many subhosts that share one referer), and so a proxy
|
|
||||||
// for any sibling subhost can look it up without knowing the source tab. Each
|
|
||||||
// entry is stamped with a capture time; entries older than the TTL are pruned
|
|
||||||
// opportunistically here (never on tab close).
|
|
||||||
async function persistReferer(host, referer) {
|
|
||||||
const domain = registrableDomain(host);
|
|
||||||
if (!domain || !referer) return;
|
|
||||||
try {
|
|
||||||
const stored = await chrome.storage.local.get('goon_referers');
|
|
||||||
const map = stored['goon_referers'] || {};
|
|
||||||
const now = Date.now();
|
|
||||||
// Opportunistic per-domain prune: drop entries older than the TTL.
|
|
||||||
for (const [h, entry] of Object.entries(map)) {
|
|
||||||
if (h !== domain && (!entry || !entry.at || now - entry.at > REFERER_TTL_MS)) {
|
|
||||||
delete map[h];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const current = map[domain];
|
|
||||||
if (current && current.referer === referer) return;
|
|
||||||
map[domain] = { referer, at: now };
|
|
||||||
await chrome.storage.local.set({ goon_referers: map });
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('[vidplex-bg] persistReferer failed:', e && e.message ? e.message : e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Look up the referer captured for a host's registrable domain. '' when none
|
|
||||||
// has been captured (or the stored entry is malformed).
|
|
||||||
async function getReferer(host) {
|
|
||||||
if (!host) return '';
|
|
||||||
try {
|
|
||||||
const stored = await chrome.storage.local.get('goon_referers');
|
|
||||||
const entry = (stored['goon_referers'] || {})[registrableDomain(host)];
|
|
||||||
return entry && entry.referer ? entry.referer : '';
|
|
||||||
} catch {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Install (idempotently) a DNR rule that sets the captured referer for a given
|
|
||||||
// CDN host. The value comes from capture (or a fallback origin), never a
|
|
||||||
// hardcoded site. Scoped to the requested host so only proxy fetches for that
|
|
||||||
// host get the rewritten header.
|
|
||||||
//
|
|
||||||
// Each host gets its own rule id (refererRuleIdFor) so that concurrent proxy
|
|
||||||
// fetches for DIFFERENT hosts never interleave: host A's rule and host B's rule
|
|
||||||
// are distinct DNR ids and one install can't remove the other. Within a single
|
|
||||||
// host, a per-host async lock (refererRuleLocks) serializes the idempotency
|
|
||||||
// check + updateSessionRules so the install-and-record pair is atomic — no two
|
|
||||||
// installs for the same host can interleave their check/update.
|
|
||||||
const refererRuleInstalled = new Map();
|
|
||||||
const refererRuleLocks = new Map();
|
|
||||||
|
|
||||||
async function ensureRefererRule(host, referer) {
|
|
||||||
const domain = registrableDomain(host);
|
|
||||||
if (!domain || !referer) return;
|
|
||||||
const api = chrome.declarativeNetRequest;
|
|
||||||
if (!api || typeof api.updateSessionRules !== 'function') {
|
|
||||||
console.warn('[vidplex-bg] declarativeNetRequest unavailable; skipping referer rule');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Serialize per host so the idempotency check + updateSessionRules is atomic.
|
|
||||||
const run = (async () => {
|
|
||||||
if (refererRuleInstalled.has(domain) && refererRuleInstalled.get(domain) === referer) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const ruleId = refererRuleIdFor(domain);
|
|
||||||
try {
|
|
||||||
// Remove only THIS host's own rule id, then add it back scoped to the host.
|
|
||||||
await api.updateSessionRules({
|
|
||||||
removeRuleIds: [ruleId],
|
|
||||||
addRules: [{
|
|
||||||
id: ruleId,
|
|
||||||
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: referer },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
condition: {
|
|
||||||
// Host + subdomains ONLY, so only proxy fetches for this host get the
|
|
||||||
// rewritten referer; the source page's own requests keep theirs.
|
|
||||||
requestDomains: [domain],
|
|
||||||
},
|
|
||||||
}],
|
|
||||||
});
|
|
||||||
refererRuleInstalled.set(domain, referer);
|
|
||||||
console.log('[vidplex-bg] DNR referer rule installed for', domain, '(' + ruleId + ')');
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('[vidplex-bg] ensureRefererRule failed:', e && e.message ? e.message : e);
|
|
||||||
}
|
|
||||||
})().finally(() => { refererRuleLocks.set(domain, null); });
|
|
||||||
refererRuleLocks.set(domain, run);
|
|
||||||
return run;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// === Message handlers for popup/data consumers ===
|
|
||||||
|
|
||||||
|
// Listen for messages from popup or content scripts.
|
||||||
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
||||||
|
console.log('[goon-plugin] Received:', message);
|
||||||
|
|
||||||
switch (message.type) {
|
switch (message.type) {
|
||||||
case 'PING':
|
case 'PING':
|
||||||
sendResponse({ pong: true, timestamp: Date.now() });
|
sendResponse({ pong: true, timestamp: Date.now() });
|
||||||
return;
|
break;
|
||||||
|
|
||||||
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_VIDEO_REQUESTS': {
|
|
||||||
// Strategy 8: Return all video 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 manifest for the video encoded 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 groupKey = extractGroupKey(url);
|
|
||||||
if (!groupKey) { sendResponse({ ok: false, error: 'no group key' }); return; }
|
|
||||||
try {
|
|
||||||
const fresh = await freshLiveForGroup(groupKey, true);
|
|
||||||
sendResponse({ ok: true, fresh, groupKey, changed: !!(fresh && fresh !== url) });
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('[vidplex-bg] resolveFreshManifest failed:', e && e.message ? e.message : e);
|
|
||||||
sendResponse({ ok: false, error: e && e.message ? e.message : String(e), groupKey });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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.
|
|
||||||
//
|
|
||||||
// A fetch() can't set the Referer itself (forbidden header — Chrome
|
|
||||||
// replaces it with chrome-extension://...), so a declarativeNetRequest
|
|
||||||
// rule installs the captured provider-origin referer at the network
|
|
||||||
// layer (DNR applies to SW fetch() per Chrome docs). The rule is
|
|
||||||
// installed on demand for the requested host only.
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
const proxyHost = hostOf(message.url);
|
|
||||||
if (proxyHost) {
|
|
||||||
// Prefer the provider's real referer captured from the live
|
|
||||||
// tab (domain-aware), or the entry's scan-time referer the viewer sent.
|
|
||||||
// With neither, install no rule: a self-origin referer is
|
|
||||||
// guaranteed-wrong for hotlink protection, so we never fall
|
|
||||||
// back to one (it would just 412).
|
|
||||||
const captured = await getReferer(proxyHost);
|
|
||||||
const referer = captured || message.referer || '';
|
|
||||||
if (referer) await ensureRefererRule(proxyHost, referer);
|
|
||||||
}
|
|
||||||
// hls.js resolves sub-playslists/segments relative to the master
|
|
||||||
// and can DROP the token query, handing us a token-less
|
|
||||||
// URL that 410s. injectFreshToken supplies the freshest live
|
|
||||||
// token for that video; 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('[vidplex-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('[vidplex-bg] proxyFetch failed:', e && e.message ? e.message : e);
|
|
||||||
sendResponse({ ok: false, error: e && e.message ? e.message : String(e) });
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'GOON_GET_REFERERS': {
|
|
||||||
// Resolve the provider referer captured for each manifest host
|
|
||||||
// (domain-aware: one provider referer covers every edge subdomain).
|
|
||||||
// Used at scan time to bind the referer to a stored video entry so it
|
|
||||||
// survives the watch-later flow.
|
|
||||||
async function getReferers(message, sendResponse) {
|
|
||||||
const hosts = Array.isArray(message.hosts) ? message.hosts : [];
|
|
||||||
const referers = {};
|
|
||||||
for (const host of hosts) {
|
|
||||||
const r = await getReferer(host);
|
|
||||||
if (r) referers[host] = r;
|
|
||||||
}
|
|
||||||
sendResponse({ ok: true, referers });
|
|
||||||
}
|
|
||||||
getReferers(message, sendResponse);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
console.warn('[vidplex-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
|
||||||
});
|
});
|
||||||
+13
-65
@@ -25,15 +25,11 @@
|
|||||||
/**
|
/**
|
||||||
* Inject interceptors into the MAIN world at document_start.
|
* Inject interceptors into the MAIN world at document_start.
|
||||||
*
|
*
|
||||||
* Uses script.src = chrome.runtime.getURL('interceptors.js') instead of
|
* Uses script.src = chrome.runtime.getURL('interceptors.js') instead of
|
||||||
* script.textContent to bypass strict page CSP. Some sites enforce
|
* script.textContent to bypass strict page CSP. Sites like Pornhub enforce
|
||||||
* 'script-src' that blocks inline execution (textContent). Chrome treats
|
* 'script-src' that blocks inline execution (textContent). Chrome treats
|
||||||
* extension-originated <script src="..."> as content scripts — not as
|
* extension-originated <script src="..."> as content scripts — not as
|
||||||
* page inline code — so the page's CSP does not apply.
|
* 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.
|
* Runs BEFORE any page JS (document_start), so we catch all HLS activity.
|
||||||
*/
|
*/
|
||||||
@@ -41,14 +37,9 @@
|
|||||||
const script = document.createElement('script');
|
const script = document.createElement('script');
|
||||||
script.src = chrome.runtime.getURL('interceptors.js');
|
script.src = chrome.runtime.getURL('interceptors.js');
|
||||||
script.setAttribute('type', 'text/javascript');
|
script.setAttribute('type', 'text/javascript');
|
||||||
script.onload = function () {
|
|
||||||
// Script loaded and executed — safe to remove the element now
|
|
||||||
if (script.parentNode) {
|
|
||||||
script.parentNode.removeChild(script);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
(document.head || document.documentElement).appendChild(script);
|
(document.head || document.documentElement).appendChild(script);
|
||||||
// Keep the element in the DOM — onload will remove it after execution
|
// Remove the script element after execution but keep interceptors active
|
||||||
|
script.parentNode.removeChild(script);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inject immediately at document_start
|
// Inject immediately at document_start
|
||||||
@@ -67,8 +58,7 @@
|
|||||||
source: source,
|
source: source,
|
||||||
timestamp: event.data.timestamp
|
timestamp: event.data.timestamp
|
||||||
});
|
});
|
||||||
console.log('[vidplex-plugin] Intercepted HLS manifest via', source, ':', url);
|
console.log('[goon-plugin] Intercepted HLS manifest via', source, ':', url);
|
||||||
console.log('[vidplex-plugin] Total intercepted manifests so far:', interceptedManifests.length);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -95,76 +85,34 @@
|
|||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch(e) {}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get video data: intercepted manifests + DOM scan + poster + Hls.cast probe.
|
* Get video data: intercepted manifests + DOM scan + poster.
|
||||||
* 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('[vidplex-content] getVideoData called. Poster:', poster);
|
|
||||||
console.log('[vidplex-content] Intercepted manifests count:', interceptedManifests.length);
|
|
||||||
if (interceptedManifests.length > 0) {
|
|
||||||
console.log('[vidplex-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('[vidplex-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);
|
||||||
const isVisible = style.display !== 'none' && video.clientWidth > 0 && video.clientHeight > 0;
|
if (style.display !== 'none' && video.clientWidth > 0 && video.clientHeight > 0) {
|
||||||
console.log('[vidplex-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('[vidplex-content] DOM scan error:', e && e.message ? e.message : e);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[vidplex-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,
|
||||||
@@ -183,5 +131,5 @@
|
|||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('[vidplex-plugin] Content script loaded. Interceptors injected at document_start.');
|
console.log('[goon-plugin] Content script loaded. Interceptors injected at document_start.');
|
||||||
})();
|
})();
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
|
||||||
<rect width="128" height="128" rx="24" fill="#4361ee"/>
|
<rect width="128" height="128" rx="24" fill="#4361ee"/>
|
||||||
<text x="64" y="84" text-anchor="middle" dominant-baseline="middle" font-size="64" font-weight="bold" font-family="sans-serif" fill="white">V</text>
|
<text x="64" y="84" text-anchor="middle" dominant-baseline="middle" font-size="64" font-weight="bold" font-family="sans-serif" fill="white">G</text>
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 279 B After Width: | Height: | Size: 279 B |
Vendored
-2
@@ -1,2 +0,0 @@
|
|||||||
!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
|
|
||||||
+26
-61
@@ -2,8 +2,7 @@
|
|||||||
* interceptors.js — Main-world interceptors for HLS manifest detection.
|
* interceptors.js — Main-world interceptors for HLS manifest detection.
|
||||||
*
|
*
|
||||||
* This file is injected into the main world via a <script src="..."> tag
|
* This file is injected into the main world via a <script src="..."> tag
|
||||||
* (NOT inline textContent) to bypass strict CSP on sites that restrict
|
* (NOT inline textContent) to bypass strict CSP on sites like Pornhub.
|
||||||
* script-src.
|
|
||||||
*
|
*
|
||||||
* When loaded as an external script from the extension origin, Chrome treats
|
* 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
|
* it as an extension content script — not as page inline code — so the
|
||||||
@@ -35,13 +34,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
// Helper: report manifest to content script via postMessage
|
||||||
function reportManifest(url, source) {
|
function reportManifest(url, source) {
|
||||||
var resolved = resolveUrl(url);
|
var resolved = resolveUrl(url);
|
||||||
@@ -69,10 +61,15 @@
|
|||||||
this._goon_url = url;
|
this._goon_url = url;
|
||||||
return originalOpen.apply(this, arguments);
|
return originalOpen.apply(this, arguments);
|
||||||
};
|
};
|
||||||
XMLHttpRequest.prototype.send = function (_body) {
|
XMLHttpRequest.prototype.send = function (body) {
|
||||||
if (this._goon_url && typeof this._goon_url === 'string') {
|
if (this._goon_url && typeof this._goon_url === 'string') {
|
||||||
var resolved = resolveUrl(this._goon_url);
|
var resolved = resolveUrl(this._goon_url);
|
||||||
if (resolved && isManifestUrl(resolved)) {
|
if (
|
||||||
|
resolved &&
|
||||||
|
(resolved.includes('.m3u8') ||
|
||||||
|
resolved.includes('/playlist') ||
|
||||||
|
resolved.includes('/manifest'))
|
||||||
|
) {
|
||||||
reportManifest(resolved, 'xhr');
|
reportManifest(resolved, 'xhr');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -81,11 +78,16 @@
|
|||||||
|
|
||||||
// 2. Intercept fetch() for manifest-like requests
|
// 2. Intercept fetch() for manifest-like requests
|
||||||
var originalFetch = window.fetch;
|
var originalFetch = window.fetch;
|
||||||
window.fetch = function (resource, _init) {
|
window.fetch = function (resource, init) {
|
||||||
var url = typeof resource === 'string' ? resource : resource && resource.url;
|
var url = typeof resource === 'string' ? resource : resource && resource.url;
|
||||||
if (url && typeof url === 'string') {
|
if (url && typeof url === 'string') {
|
||||||
var resolved = resolveUrl(url);
|
var resolved = resolveUrl(url);
|
||||||
if (resolved && isManifestUrl(resolved)) {
|
if (
|
||||||
|
resolved &&
|
||||||
|
(resolved.includes('.m3u8') ||
|
||||||
|
resolved.includes('/playlist') ||
|
||||||
|
resolved.includes('/manifest'))
|
||||||
|
) {
|
||||||
reportManifest(resolved, 'fetch');
|
reportManifest(resolved, 'fetch');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -93,12 +95,8 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 3. Intercept HLS.js loadSource (catches HLS.js instances)
|
// 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() {
|
function patchHlsPrototype() {
|
||||||
if (typeof Hls === 'undefined') return;
|
if (typeof Hls === 'undefined') return;
|
||||||
if (hlsPatched) return; // Already patched — skip
|
|
||||||
|
|
||||||
var originalLoadSource = Hls.prototype.loadSource;
|
var originalLoadSource = Hls.prototype.loadSource;
|
||||||
if (!originalLoadSource) return;
|
if (!originalLoadSource) return;
|
||||||
|
|
||||||
@@ -117,53 +115,20 @@
|
|||||||
return originalAttachMedia.apply(this, arguments);
|
return originalAttachMedia.apply(this, arguments);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
hlsPatched = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Patch immediately if HLS is already defined
|
// Patch immediately if HLS is already defined
|
||||||
patchHlsPrototype();
|
patchHlsPrototype();
|
||||||
|
|
||||||
// Watch for HLS.js to be dynamically defined later.
|
// Watch for HLS.js to be dynamically defined later
|
||||||
// Use a combination of:
|
var originalDefineProperty = Object.defineProperty;
|
||||||
// 1. Longer polling window (30s instead of 3s) to catch lazy-loaded players.
|
var hlsPatched = false;
|
||||||
// 2. MutationObserver on <head> for new <script> tags — catches late hls.js loads.
|
Object.defineProperty = function (obj, prop, descriptor) {
|
||||||
if (!hlsPatched) {
|
var result = originalDefineProperty.apply(this, arguments);
|
||||||
var pollCount = 0;
|
if (obj === window && prop === 'Hls' && !hlsPatched) {
|
||||||
var maxPolls = 600; // poll for up to ~30s (50ms interval)
|
hlsPatched = true;
|
||||||
|
patchHlsPrototype();
|
||||||
var pollHls = setInterval(function () {
|
}
|
||||||
pollCount++;
|
return result;
|
||||||
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
@@ -14,10 +14,10 @@
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Media delivery format of the video source.
|
* Media delivery format of the video source.
|
||||||
* - 'video' : plain MP4/WebM blob served via HTTP(S)
|
* - 'direct' : plain MP4/WebM blob served via HTTP(S)
|
||||||
* - 'hls' : HTTP Live Streaming manifest (.m3u8)
|
* - 'hls' : HTTP Live Streaming manifest (.m3u8)
|
||||||
*/
|
*/
|
||||||
export type MediaType = 'video' | 'hls';
|
export type MediaType = 'direct' | 'hls';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A single video entry discovered by a tab scan.
|
* A single video entry discovered by a tab scan.
|
||||||
|
|||||||
@@ -32,90 +32,6 @@ h1 {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.instructions-panel {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 12px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
background: #f8fafc;
|
|
||||||
}
|
|
||||||
|
|
||||||
.instructions-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.instructions-kicker {
|
|
||||||
margin-bottom: 2px;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: #64748b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.instructions-panel h2 {
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.3;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.instructions-toggle {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
padding: 6px 10px;
|
|
||||||
font-size: 12px;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--accent);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.instructions-toggle:hover {
|
|
||||||
background: #eef2ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.instructions-body {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.instructions-body[hidden] {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.instructions-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
padding-left: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.instructions-list li {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.instructions-list strong {
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.instructions-list span,
|
|
||||||
.instructions-note,
|
|
||||||
.instructions-learn-more {
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.45;
|
|
||||||
color: #475569;
|
|
||||||
}
|
|
||||||
|
|
||||||
.instructions-note {
|
|
||||||
color: #7c3aed;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
button {
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
|
|||||||
+2
-44
@@ -3,54 +3,12 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>VidPlex</title>
|
<title>Goon Plugin</title>
|
||||||
<link rel="stylesheet" href="index.css">
|
<link rel="stylesheet" href="index.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app">
|
<div id="app">
|
||||||
<h1>VidPlex</h1>
|
<h1>Goon Plugin</h1>
|
||||||
<section class="instructions-panel" role="region" aria-labelledby="instructions-title">
|
|
||||||
<div class="instructions-header">
|
|
||||||
<div>
|
|
||||||
<p class="instructions-kicker">Quick start</p>
|
|
||||||
<h2 id="instructions-title">How to use VidPlex</h2>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
id="instructions-toggle"
|
|
||||||
type="button"
|
|
||||||
class="instructions-toggle"
|
|
||||||
aria-expanded="true"
|
|
||||||
aria-controls="instructions-body"
|
|
||||||
>
|
|
||||||
Hide instructions
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div id="instructions-body" class="instructions-body">
|
|
||||||
<ol class="instructions-list">
|
|
||||||
<li>
|
|
||||||
<strong>Start your videos</strong>
|
|
||||||
<span>Open the videos you want to play, one per tab, and begin their playback.</span>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Scan videos</strong>
|
|
||||||
<span>Click <strong>Find Videos</strong> to search the current browser window's tabs for playing videos.</span>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Review the list</strong>
|
|
||||||
<span>Check to make sure the right number of videos was found.</span>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Open the viewer</strong>
|
|
||||||
<span>Click <strong>Open Viewer</strong> to open the grid player in a new window.</span>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Reset and rescan</strong>
|
|
||||||
<span>Click <strong>Clear</strong> to erase results and scan again.</span>
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
<p class="instructions-note">Warning: Browser pages, extension pages, and restricted frames can hide videos, so results may be incomplete.</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<button id="find-btn">Find Videos (all tabs)</button>
|
<button id="find-btn">Find Videos (all tabs)</button>
|
||||||
<div id="video-list" class="video-list" style="display:none;"></div>
|
<div id="video-list" class="video-list" style="display:none;"></div>
|
||||||
<div id="status" class="status" style="min-height:16px;"></div>
|
<div id="status" class="status" style="min-height:16px;"></div>
|
||||||
|
|||||||
+303
-1275
File diff suppressed because it is too large.
Load diff
+326
-398
@@ -1,481 +1,409 @@
|
|||||||
/**
|
/**
|
||||||
* index.test.js - Unit tests for popup scanning pipeline helpers.
|
* index.test.js — Integration tests for popup storage pipeline.
|
||||||
*
|
*
|
||||||
* Verifies that the exported pipeline functions correctly handle:
|
* Verifies that HLS metadata (mediaType, hlsManifest) survives the
|
||||||
* - HLS metadata preservation through dedup
|
* full scrape → dedup → storage → viewer cycle.
|
||||||
* - Tab filtering (system URLs excluded)
|
|
||||||
* - Manifest deduplication and prioritization
|
|
||||||
* - Video entry normalization
|
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
/* ========================================================================== */
|
/* ========================================================================== */
|
||||||
/* Simulated pipeline helpers (mirror of index.js exported functions) */
|
/* Helpers: simulate the scrape → dedup → storage pipeline from index.js */
|
||||||
/* ========================================================================== */
|
/* ========================================================================== */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Simulate `filterValidTabs` from index.js.
|
* Simulate the dedup + storage logic from index.js lines 161-197.
|
||||||
*/
|
* This reproduces the exact pipeline so we can verify HLS metadata flow.
|
||||||
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) {
|
function simulateStoragePipeline(tabResults) {
|
||||||
const seen = new Map();
|
const seen = new Map();
|
||||||
|
let totalFound = 0;
|
||||||
|
|
||||||
for (const group of tabResults) {
|
for (const group of tabResults) {
|
||||||
if (!group.result || !group.result.videos?.length) continue;
|
for (const r of group.frames) {
|
||||||
for (const v of group.result.videos) {
|
if (!r.result) continue;
|
||||||
const primarySrc = v.hlsManifest || v.src || '(empty)';
|
for (const v of r.result.videos) {
|
||||||
const key = primarySrc + '||' + (v.poster || '(none)');
|
totalFound++;
|
||||||
if (!seen.has(key)) {
|
const key = (v.src || '(empty)') + '||' + (v.poster || '(none)');
|
||||||
seen.set(key, makeVideoEntry(v, group.tabTitle));
|
if (!seen.has(key)) {
|
||||||
|
const item = { url: v.src || v.poster || '', poster: v.poster || null, tab: group.tabTitle };
|
||||||
|
if (v.src) item.src = v.src;
|
||||||
|
if (v.mediaType) item.mediaType = v.mediaType;
|
||||||
|
if (v.hlsManifest) item.hlsManifest = v.hlsManifest;
|
||||||
|
seen.set(key, item);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const items = [...seen.values()];
|
|
||||||
|
|
||||||
// Storage format
|
const items = [...seen.values()].filter(v => {
|
||||||
|
const url = v.src || v.url || '';
|
||||||
|
return url.startsWith('http://') || url.startsWith('https://');
|
||||||
|
});
|
||||||
|
|
||||||
|
// This is how index.js stores items (line 186-196)
|
||||||
const storedItems = items.map(v => ({
|
const storedItems = items.map(v => ({
|
||||||
src: v.src,
|
url: v.url,
|
||||||
|
src: v.src || v.url,
|
||||||
poster: v.poster,
|
poster: v.poster,
|
||||||
tab: v.tab,
|
tab: v.tab,
|
||||||
mediaType: v.mediaType || 'video',
|
mediaType: v.mediaType || 'video',
|
||||||
hlsManifest: v.hlsManifest || undefined,
|
hlsManifest: v.hlsManifest || undefined,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return { storedItems };
|
return { storedItems, totalFound };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simulate the scrapeFn from index.js lines 112-139.
|
||||||
|
* Takes video element data and returns the scrape result.
|
||||||
|
*/
|
||||||
|
function simulateScrapeFn(videoElements) {
|
||||||
|
const dataKeys = ['src', 'video', 'mp4', 'webm', 'poster'];
|
||||||
|
|
||||||
|
const allVideos = videoElements.map(v => {
|
||||||
|
const area = v.clientWidth * v.clientHeight;
|
||||||
|
// Check for HLS manifest URL in data attributes or <source> children
|
||||||
|
const hlsDataAttr = v.getAttribute('data-hls') || v.getAttribute('data-hls-manifest');
|
||||||
|
const hlsSource = v.sources?.map(s => s.src).find(Boolean) || null;
|
||||||
|
const hlsManifest = hlsDataAttr || hlsSource;
|
||||||
|
const src = dataKeys.map(k => v.getAttribute('data-' + k)).find(Boolean)
|
||||||
|
|| v.currentSrc || v.src || ''
|
||||||
|
|| null;
|
||||||
|
const poster = (v.poster || '')
|
||||||
|
|| dataKeys.filter(k => k !== 'poster').map(k => v.getAttribute('data-' + k)).find(Boolean)
|
||||||
|
|| v.getAttribute('data-poster') || null;
|
||||||
|
const mediaType = hlsManifest ? 'hls' : 'video';
|
||||||
|
return { src, poster, area, width: v.clientWidth, height: v.clientHeight, mediaType, hlsManifest };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort by displayed size descending, pick the largest
|
||||||
|
allVideos.sort((a, b) => b.area - a.area);
|
||||||
|
const largest = allVideos.length > 0 ? allVideos[0] : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
videos: largest ? [largest] : [],
|
||||||
|
videoCount: largest ? 1 : 0,
|
||||||
|
sources: [],
|
||||||
|
audios: 0,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ========================================================================== */
|
/* ========================================================================== */
|
||||||
/* TESTS: Tab filtering */
|
/* TESTS: HLS metadata flow through storage pipeline */
|
||||||
/* ========================================================================== */
|
/* ========================================================================== */
|
||||||
|
|
||||||
describe('filterValidTabs', () => {
|
describe('Storage pipeline — HLS metadata preservation', () => {
|
||||||
it('excludes chrome:// URLs', () => {
|
it('preserves HLS metadata through dedup (mediaType: "hls", hlsManifest present)', () => {
|
||||||
const tabs = [{ url: 'chrome://settings' }, { url: 'https://example.com' }];
|
// Simulate a tab with an HLS video element
|
||||||
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 = [{
|
const tabResults = [{
|
||||||
tabTitle: 'HLS Stream Page',
|
tabTitle: 'HLS Stream Page',
|
||||||
result: {
|
frames: [{
|
||||||
videos: [{
|
result: {
|
||||||
src: 'https://example.com/stream.m3u8',
|
videos: [{
|
||||||
poster: null,
|
src: 'https://example.com/stream.m3u8',
|
||||||
mediaType: 'hls',
|
poster: null,
|
||||||
hlsManifest: 'https://example.com/stream.m3u8',
|
area: 1920 * 1080,
|
||||||
}],
|
width: 1920,
|
||||||
videoCount: 1,
|
height: 1080,
|
||||||
},
|
mediaType: 'hls',
|
||||||
|
hlsManifest: 'https://example.com/stream.m3u8',
|
||||||
|
}],
|
||||||
|
videoCount: 1,
|
||||||
|
sources: [],
|
||||||
|
audios: 0,
|
||||||
|
},
|
||||||
|
}],
|
||||||
}];
|
}];
|
||||||
|
|
||||||
const { storedItems } = simulateStoragePipeline(tabResults);
|
const { storedItems } = simulateStoragePipeline(tabResults);
|
||||||
|
|
||||||
|
// After fix: mediaType is 'hls' and hlsManifest is preserved
|
||||||
expect(storedItems[0].mediaType).toBe('hls');
|
expect(storedItems[0].mediaType).toBe('hls');
|
||||||
expect(storedItems[0].hlsManifest).toBe('https://example.com/stream.m3u8');
|
expect(storedItems[0].hlsManifest).toBe('https://example.com/stream.m3u8');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('preserves hlsManifest for live streams', () => {
|
it('preserves hlsManifest through dedup for live streams', () => {
|
||||||
const tabResults = [{
|
const tabResults = [{
|
||||||
tabTitle: 'Live Stream',
|
tabTitle: 'Live Stream',
|
||||||
result: {
|
frames: [{
|
||||||
videos: [{
|
result: {
|
||||||
src: 'https://cdn.example.com/live.m3u8',
|
videos: [{
|
||||||
poster: 'https://cdn.example.com/thumb.jpg',
|
src: 'https://cdn.example.com/live.m3u8',
|
||||||
mediaType: 'hls',
|
poster: 'https://cdn.example.com/thumb.jpg',
|
||||||
hlsManifest: 'https://cdn.example.com/live.m3u8',
|
area: 1280 * 720,
|
||||||
}],
|
width: 1280,
|
||||||
videoCount: 1,
|
height: 720,
|
||||||
},
|
mediaType: 'hls',
|
||||||
|
hlsManifest: 'https://cdn.example.com/live.m3u8',
|
||||||
|
}],
|
||||||
|
videoCount: 1,
|
||||||
|
sources: [],
|
||||||
|
audios: 0,
|
||||||
|
},
|
||||||
|
}],
|
||||||
}];
|
}];
|
||||||
|
|
||||||
const { storedItems } = simulateStoragePipeline(tabResults);
|
const { storedItems } = simulateStoragePipeline(tabResults);
|
||||||
|
|
||||||
expect(storedItems[0].hlsManifest).toBe('https://cdn.example.com/live.m3u8');
|
expect(storedItems[0].hlsManifest).toBe('https://cdn.example.com/live.m3u8');
|
||||||
expect(storedItems[0].mediaType).toBe('hls');
|
expect(storedItems[0].mediaType).toBe('hls');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('preserves standard video entries correctly', () => {
|
it('Standard video entries are stored correctly', () => {
|
||||||
const tabResults = [{
|
const tabResults = [{
|
||||||
tabTitle: 'MP4 Page',
|
tabTitle: 'MP4 Page',
|
||||||
result: {
|
frames: [{
|
||||||
videos: [{
|
result: {
|
||||||
src: 'https://example.com/video.mp4',
|
videos: [{
|
||||||
poster: 'https://example.com/thumb.jpg',
|
src: 'https://example.com/video.mp4',
|
||||||
mediaType: 'video',
|
poster: 'https://example.com/thumb.jpg',
|
||||||
}],
|
area: 640 * 480,
|
||||||
videoCount: 1,
|
width: 640,
|
||||||
},
|
height: 480,
|
||||||
|
mediaType: 'video',
|
||||||
|
hlsManifest: null,
|
||||||
|
}],
|
||||||
|
videoCount: 1,
|
||||||
|
sources: [],
|
||||||
|
audios: 0,
|
||||||
|
},
|
||||||
|
}],
|
||||||
}];
|
}];
|
||||||
|
|
||||||
const { storedItems } = simulateStoragePipeline(tabResults);
|
const { storedItems } = simulateStoragePipeline(tabResults);
|
||||||
|
|
||||||
expect(storedItems[0].src).toBe('https://example.com/video.mp4');
|
expect(storedItems[0].src).toBe('https://example.com/video.mp4');
|
||||||
expect(storedItems[0].mediaType).toBe('video');
|
expect(storedItems[0].mediaType).toBe('video');
|
||||||
expect(storedItems[0].poster).toBe('https://example.com/thumb.jpg');
|
expect(storedItems[0].poster).toBe('https://example.com/thumb.jpg');
|
||||||
expect(storedItems[0].tab).toBe('MP4 Page');
|
expect(storedItems[0].tab).toBe('MP4 Page');
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('deduplicates across tabs by hlsManifest', () => {
|
describe('Storage pipeline — corrected HLS metadata preservation', () => {
|
||||||
const tabResults = [
|
/**
|
||||||
{
|
* This is what the pipeline SHOULD look like after fixing the bug.
|
||||||
tabTitle: 'Tab 1',
|
* The dedup loop should copy mediaType and hlsManifest from the scraped
|
||||||
|
* result into the stored item.
|
||||||
|
*/
|
||||||
|
function simulateStoragePipelineFixed(tabResults) {
|
||||||
|
const seen = new Map();
|
||||||
|
let totalFound = 0;
|
||||||
|
|
||||||
|
for (const group of tabResults) {
|
||||||
|
for (const r of group.frames) {
|
||||||
|
if (!r.result) continue;
|
||||||
|
for (const v of r.result.videos) {
|
||||||
|
totalFound++;
|
||||||
|
const key = (v.src || '(empty)') + '||' + (v.poster || '(none)');
|
||||||
|
if (!seen.has(key)) {
|
||||||
|
const item = {
|
||||||
|
url: v.src || v.poster || '',
|
||||||
|
poster: v.poster || null,
|
||||||
|
tab: group.tabTitle,
|
||||||
|
};
|
||||||
|
if (v.src) item.src = v.src;
|
||||||
|
// FIX: copy HLS metadata
|
||||||
|
if (v.mediaType) item.mediaType = v.mediaType;
|
||||||
|
if (v.hlsManifest) item.hlsManifest = v.hlsManifest;
|
||||||
|
seen.set(key, item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = [...seen.values()].filter(v => {
|
||||||
|
const url = v.src || v.url || '';
|
||||||
|
return url.startsWith('http://') || url.startsWith('https://');
|
||||||
|
});
|
||||||
|
|
||||||
|
const storedItems = items.map(v => ({
|
||||||
|
url: v.url,
|
||||||
|
src: v.src || v.url,
|
||||||
|
poster: v.poster,
|
||||||
|
tab: v.tab,
|
||||||
|
mediaType: v.mediaType || 'video',
|
||||||
|
hlsManifest: v.hlsManifest || undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { storedItems, totalFound };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('FIXED: HLS metadata survives dedup when mediaType and hlsManifest are copied', () => {
|
||||||
|
const tabResults = [{
|
||||||
|
tabTitle: 'HLS Stream Page',
|
||||||
|
frames: [{
|
||||||
result: {
|
result: {
|
||||||
videos: [{
|
videos: [{
|
||||||
src: 'https://example.com/stream.m3u8',
|
src: 'https://example.com/stream.m3u8',
|
||||||
poster: null,
|
poster: null,
|
||||||
|
area: 1920 * 1080,
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
mediaType: 'hls',
|
mediaType: 'hls',
|
||||||
hlsManifest: 'https://example.com/stream.m3u8',
|
hlsManifest: 'https://example.com/stream.m3u8',
|
||||||
}],
|
}],
|
||||||
videoCount: 1,
|
videoCount: 1,
|
||||||
|
sources: [],
|
||||||
|
audios: 0,
|
||||||
},
|
},
|
||||||
},
|
}],
|
||||||
{
|
}];
|
||||||
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);
|
const { storedItems } = simulateStoragePipelineFixed(tabResults);
|
||||||
expect(storedItems).toHaveLength(1);
|
|
||||||
expect(storedItems[0].tab).toBe('Tab 1'); // first tab wins
|
expect(storedItems[0].mediaType).toBe('hls');
|
||||||
|
expect(storedItems[0].hlsManifest).toBe('https://example.com/stream.m3u8');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps different HLS streams from same tab', () => {
|
it('FIXED: Mixed HLS and standard entries both preserve metadata', () => {
|
||||||
const tabResults = [{
|
const tabResults = [{
|
||||||
tabTitle: 'Mixed Page',
|
tabTitle: 'Mixed Page',
|
||||||
result: {
|
frames: [{
|
||||||
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: {
|
result: {
|
||||||
videos: [{
|
videos: [{
|
||||||
src: 'https://example.com/video.mp4',
|
src: 'https://example.com/live.m3u8',
|
||||||
poster: null,
|
poster: null,
|
||||||
mediaType: 'video',
|
area: 1920 * 1080,
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
mediaType: 'hls',
|
||||||
|
hlsManifest: 'https://example.com/live.m3u8',
|
||||||
}],
|
}],
|
||||||
videoCount: 1,
|
videoCount: 1,
|
||||||
},
|
},
|
||||||
|
}, {
|
||||||
|
result: {
|
||||||
|
videos: [{
|
||||||
|
src: 'https://example.com/recording.mp4',
|
||||||
|
poster: 'https://example.com/thumb.jpg',
|
||||||
|
area: 1280 * 720,
|
||||||
|
width: 1280,
|
||||||
|
height: 720,
|
||||||
|
mediaType: 'video',
|
||||||
|
hlsManifest: null,
|
||||||
|
}],
|
||||||
|
videoCount: 1,
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
}];
|
||||||
|
|
||||||
|
const { storedItems } = simulateStoragePipelineFixed(tabResults);
|
||||||
|
|
||||||
|
// Largest video (HLS)
|
||||||
|
expect(storedItems[0].mediaType).toBe('hls');
|
||||||
|
expect(storedItems[0].hlsManifest).toBe('https://example.com/live.m3u8');
|
||||||
|
|
||||||
|
// Second video (standard)
|
||||||
|
expect(storedItems[1].mediaType).toBe('video');
|
||||||
|
expect(storedItems[1].hlsManifest).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ScrapeFn — HLS detection from DOM elements', () => {
|
||||||
|
it('detects HLS from data-hls attribute', () => {
|
||||||
|
const videoEl = {
|
||||||
|
getAttribute: (attr) => {
|
||||||
|
if (attr === 'data-hls') return 'https://example.com/stream.m3u8';
|
||||||
|
if (attr === 'src') return null;
|
||||||
|
if (attr === 'data-video') return null;
|
||||||
|
if (attr === 'data-mp4') return null;
|
||||||
|
if (attr === 'data-webm') return null;
|
||||||
|
if (attr === 'data-poster') return null;
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
poster: '',
|
||||||
|
currentSrc: '',
|
||||||
|
src: '',
|
||||||
|
clientWidth: 1920,
|
||||||
|
clientHeight: 1080,
|
||||||
|
sources: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = simulateScrapeFn([videoEl]);
|
||||||
|
expect(result.videos[0].mediaType).toBe('hls');
|
||||||
|
expect(result.videos[0].hlsManifest).toBe('https://example.com/stream.m3u8');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects HLS from data-hls-manifest attribute', () => {
|
||||||
|
const videoEl = {
|
||||||
|
getAttribute: (attr) => {
|
||||||
|
if (attr === 'data-hls-manifest') return 'https://example.com/live.m3u8';
|
||||||
|
if (attr === 'data-hls') return null;
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
poster: '',
|
||||||
|
currentSrc: '',
|
||||||
|
src: '',
|
||||||
|
clientWidth: 1280,
|
||||||
|
clientHeight: 720,
|
||||||
|
sources: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = simulateScrapeFn([videoEl]);
|
||||||
|
expect(result.videos[0].mediaType).toBe('hls');
|
||||||
|
expect(result.videos[0].hlsManifest).toBe('https://example.com/live.m3u8');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects HLS from <source> child with m3u8 URL', () => {
|
||||||
|
const videoEl = {
|
||||||
|
getAttribute: () => null,
|
||||||
|
poster: '',
|
||||||
|
currentSrc: '',
|
||||||
|
src: '',
|
||||||
|
clientWidth: 1920,
|
||||||
|
clientHeight: 1080,
|
||||||
|
sources: [{ src: 'https://example.com/stream.m3u8' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = simulateScrapeFn([videoEl]);
|
||||||
|
expect(result.videos[0].mediaType).toBe('hls');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT detect HLS for standard MP4 video', () => {
|
||||||
|
const videoEl = {
|
||||||
|
getAttribute: (attr) => {
|
||||||
|
if (attr === 'src') return null;
|
||||||
|
if (attr === 'data-hls') return null;
|
||||||
|
if (attr === 'data-hls-manifest') return null;
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
poster: '',
|
||||||
|
currentSrc: 'https://example.com/video.mp4',
|
||||||
|
src: 'https://example.com/video.mp4',
|
||||||
|
clientWidth: 1920,
|
||||||
|
clientHeight: 1080,
|
||||||
|
sources: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = simulateScrapeFn([videoEl]);
|
||||||
|
expect(result.videos[0].mediaType).toBe('video');
|
||||||
|
expect(result.videos[0].hlsManifest).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('picks the largest video element', () => {
|
||||||
|
const videos = [
|
||||||
|
{
|
||||||
|
getAttribute: () => null,
|
||||||
|
poster: '',
|
||||||
|
currentSrc: 'https://example.com/small.mp4',
|
||||||
|
src: 'https://example.com/small.mp4',
|
||||||
|
clientWidth: 320,
|
||||||
|
clientHeight: 240,
|
||||||
|
sources: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
getAttribute: () => null,
|
||||||
|
poster: '',
|
||||||
|
currentSrc: 'https://example.com/large.mp4',
|
||||||
|
src: 'https://example.com/large.mp4',
|
||||||
|
clientWidth: 1920,
|
||||||
|
clientHeight: 1080,
|
||||||
|
sources: [],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const { storedItems } = simulateStoragePipeline(tabResults);
|
const result = simulateScrapeFn(videos);
|
||||||
expect(storedItems).toHaveLength(1);
|
expect(result.videos.length).toBe(1); // only largest is picked
|
||||||
expect(storedItems[0].tab).toBe('Has Video');
|
expect(result.videos[0].src).toBe('https://example.com/large.mp4');
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
/* ========================================================================== */
|
|
||||||
/* 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();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -33,11 +33,6 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": ["direct", "hls"],
|
"enum": ["direct", "hls"],
|
||||||
"description": "Media delivery format. Defaults to 'direct' when omitted. Set to 'hls' for HTTP Live Streaming."
|
"description": "Media delivery format. Defaults to 'direct' when omitted. Set to 'hls' for HTTP Live Streaming."
|
||||||
},
|
|
||||||
"referer": {
|
|
||||||
"type": ["string", "null"],
|
|
||||||
"format": "uri",
|
|
||||||
"description": "Provider Referer captured at scan time for the manifest's CDN; the proxy sends it so a referer-protecting CDN accepts sibling edge subdomains. Optional."
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>VidPlex Viewer</title>
|
<title>Goon Viewer</title>
|
||||||
<link rel="stylesheet" href="viewer.css">
|
<link rel="stylesheet" href="viewer.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
+119
-1177
File diff suppressed because it is too large.
Load diff
@@ -514,463 +514,3 @@ describe('Browser HLS support detection (conceptual)', () => {
|
|||||||
expect(supportsNativeHls(brokenVideo)).toBe(false);
|
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+2
-10
@@ -8,8 +8,8 @@
|
|||||||
*/
|
*/
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import { resolve } from 'path';
|
import { resolve } from 'path';
|
||||||
import sharp from 'sharp';
|
|
||||||
import { copyFileSync, mkdirSync, writeFileSync } from 'fs';
|
import { copyFileSync, mkdirSync, writeFileSync } from 'fs';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
root: 'src',
|
root: 'src',
|
||||||
@@ -49,10 +49,8 @@ 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'),
|
||||||
@@ -60,9 +58,6 @@ export default defineConfig({
|
|||||||
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'),
|
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',
|
||||||
@@ -70,8 +65,5 @@ export default defineConfig({
|
|||||||
assetFileNames: '[name][extname]',
|
assetFileNames: '[name][extname]',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
modulePreload: {
|
|
||||||
polyfill: false,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user