Compare commits

...
19 Commits
Author SHA1 Message Date
kfj001 fbc836d9c4 Merge pull request 'ci: make release step idempotent + fix Gitea asset upload' (#9) from feature/gitea-release-workflow into master
CI / test (push) Successful in 5m43s
Release / release (push) Successful in 13s
Reviewed-on: http://192.168.1.74:30008/kfj001/goon_plugin/pulls/9
Reviewed-by: robot <robot@westerntechnologies.duckdns.org>
2026-08-31 10:27:12 -07:00
kfj001 de9d537a75 ci: make release step idempotent + fix Gitea asset upload
CI / test (pull_request) Successful in 5m37s
- resolve-or-create the release by tag to avoid the 409 'Release is has no Tag'
  when a release already exists for the tag (e.g. one made manually in the UI)
- use POST /releases/{id}/assets with multipart field 'attachment'
  (was the obsolete /_attachments route with field 'file')
2026-08-31 00:33:53 -07:00
kfj001 23a3f00df9 Merge pull request 'ci: add tag-based Gitea release workflow + bump to 0.1.1' (#8) from feature/gitea-release-workflow into master
CI / test (push) Successful in 5m32s
Release / release (push) Failing after 13s
Reviewed-on: http://192.168.1.74:30008/kfj001/goon_plugin/pulls/8
2026-08-30 23:57:59 -07:00
kfj001 0a3225035c ci: add tag-based Gitea release workflow + bump to 0.1.1
CI / test (pull_request) Successful in 5m48s
2026-08-30 23:31:12 -07:00
kfj001 0848cd51be Merge pull request 'Add user instructions to goon plugin UI' (#7) from feature/goon-instructions into master
CI / test (push) Successful in 5m39s
Reviewed-on: http://192.168.1.74:30008/kfj001/goon_plugin/pulls/7
Reviewed-by: kfj001 <kfj001@gmail.com>
2026-08-30 21:27:52 -07:00
kfj001 41723227c0 Slight copy update
CI / test (pull_request) Successful in 5m32s
2026-08-31 04:22:12 +00:00
robot 5da2003cd0 docs: draft PR description
CI / test (pull_request) Successful in 5m31s
2026-08-31 03:27:06 +00:00
robot 97abaf42d9 feat: add user instructions to viewer popup 2026-08-31 03:26:50 +00:00
kfj001 c366f0b8c4 Merge pull request 'Rebrand: rename 'goon' -> 'vidplex' + docs' (#6) from rebrand/vidplex-rename-kj into master
CI / test (push) Successful in 5m39s
Reviewed-on: http://192.168.1.74:30008/kfj001/goon_plugin/pulls/6
Reviewed-by: kfj001 <kfj001@gmail.com>
2026-08-30 19:19:26 -07:00
robot c826669c7d rename log prefixes: '[goon' -> '[vidplex' in runtime logs
CI / test (pull_request) Successful in 5m34s
2026-08-31 01:55:27 +00:00
kfj001 64d792632d Merge pull request 'refactor: decouple from site-specific CDN references' (#5) from feature/no_mention_of_pornhub into master
CI / test (push) Successful in 5m34s
Reviewed-on: http://192.168.1.74:30008/kfj001/goon_plugin/pulls/5
Reviewed-by: robot <robot@westerntechnologies.duckdns.org>
2026-08-30 17:07:51 -07:00
kfj001 3e02a6ae74 F1 BLOCKING — walk() cycle detection (src/viewer/index.js:161)
CI / test (pull_request) Successful in 5m41s
Added a per-tree WeakSet visited-guard so the probe terminates. The depth cap alone didn't stop the infinite recursion: window/_parent/_top/frames/_globalObject/_globalProxy are mutually-reachable circular Proxies, so Strategy H's window scan re-walked the same identities along exponentially many paths. The set is declared inside walk (not the outer function) so the separate player trees stay independent. This is what was hanging npm test (exit 124); it now completes in ~15s.
F2 SHOULD-FIX — doc/behavior mismatch (src/background/background.js:299-322)
Reworded the referer-capture comment block. It claimed the referer was read "via onBeforeSendHeaders, which sees the request headers," but the code uses onBeforeRequest + chrome.tabs.get (deriving the referer from the source tab's URL). Now states the actual mechanism and documents why onBeforeSendHeaders+extraHeaders was avoided (extraInfoSpec is rejected by some Chromium builds and fails the whole SW).
F3 SHOULD-FIX — leaked settings file
git rm --cached .claude/settings.local.json and added it to .gitignore. It carried machine-specific absolute paths (/Users/kyle/.claude/...).
F4 FYI — package-lock churn (package-lock.json)
Removed the spurious root "dependencies": { @rollup/rollup-darwin-arm64 } and restored the dev: true / optional: true flags on that platform package so it matches the rest of the @rollup/rollup-* siblings.
F5 FYI — real provider hostnames in test fixtures
Swapped phncdn.com→neutral example.net (registrable-domain sibling test) and thisvid.com→example.com in data_flow/hls_qa/simple_video/referer_proxy test files, rewording comments that named the real sites. src/ and __tests__/ now have zero phncdn/thisvid/pornhub references.
2026-08-30 21:56:05 +00:00
kfj001 ff463c107a fixes broken tests
CI / test (pull_request) Successful in 5m32s
2026-08-30 06:34:44 +00:00
kfj001 bd7d5bfcd0 removes unneeded dependency
CI / test (pull_request) Failing after 5m34s
2026-08-30 05:48:15 +00:00
kfj001 4357ce4094 Fixes issue with pornhub. Removes references.
CI / test (pull_request) Failing after 20s
2026-08-30 04:48:23 +00:00
kfj001 7b43d260a2 Additional fixes
CI / test (pull_request) Successful in 5m42s
2026-08-28 18:03:14 +00:00
kfj001 d3c8c85c58 removes thisvid reference from comment
CI / test (pull_request) Successful in 5m42s
2026-08-28 08:51:49 +00:00
kfj001 62d1244558 additional files 2026-08-28 08:51:27 +00:00
kfj001andClaude 0030d031f1 fix(scan): recover simple <video> detection site-agnostically
The no-mention-of-pornhub branch stopped finding plain <video> elements on
pages like thisvid.com. Two site-agnostic causes, both fixed without any
site-specific references:

1. Predicate over-rejection: isValidManifestUrl (and the nested isManifestUrl
   in m3u8DeepProbeFn) ran the placeholder/`%`/trailing-slash rejections
   BEFORE the concrete media signals. thisvid serves its direct MP4 WITH a
   trailing slash (.../14644093.mp4/?rnd=...), so `path.endsWith('/')`
   rejected a real, fetchable source. Concrete media signals (.mp4/.webm/.ogv/
   .mpd/.m4s/.m3u8/.hlsvod//hls/) now win outright, so a coincidental '%' or
   trailing slash never sinks a real source — while genuine route templates
   (/playlists/, /playlist/%1%/, /videos/{id}/) are still rejected.

2. Runtime cause on ad-iframe-heavy pages: scrapeTab ran only a MAIN-world
   scrape. A MAIN-world + allFrames injection can reject on pages with
   sandboxed/opaque-origin ad iframes, returning 0 videos even when a <video>
   is visible. scrapeTab now falls back to an isolated-world scrape (no `world`
   key — always permitted) when the MAIN-world run finds nothing. The per-frame
   reduction was extracted into a shared reduceScrapeFrames helper.

Adds __tests__/simple_video.test.mjs: a thisvid isValidManifestUrl battery
(trailing-slash MP4 accepted, templates still rejected, %2B/%2F tokens
preserved) plus a structural guard that scrapeTab keeps both a MAIN-world
primary and an isolated-world fallback.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-28 01:17:35 -07:00
23 changed files with 1258 additions and 248 deletions

No files matched your search

-9
View File
@@ -1,9 +0,0 @@
{
"permissions": {
"allow": [
"Bash(npm run *)",
"Bash(node --check /home/kyle/Documents/goon_plugin/src/viewer/index.js)",
"Bash(open -a \"Microsoft Edge\" --args --remote-debugging-port=9222 --profile-directory=Default)"
]
}
}
+81
View File
@@ -0,0 +1,81 @@
name: Release
on:
push:
tags:
- 'v*'
jobs:
release:
runs-on: ubuntu-latest
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
+1
View File
@@ -3,3 +3,4 @@ dist/
.vscode-insiders/ .vscode-insiders/
/icons /icons
/icons/* /icons/*
.claude/settings.local.json
+31
View File
@@ -185,4 +185,35 @@ 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');
}
});
}); });
+13 -13
View File
@@ -1262,24 +1262,24 @@ describe('HLS QA: route-template / nav URL rejection', () => {
return result; return result;
} }
// Reproduces thisvid.com: pageContext.langUrls holds server-side route // Reproduces a provider whose pageContext.langUrls holds server-side route
// TEMPLATES / nav links (e.g. /playlists/, /playlist/%1%/video/%2%/) that the // TEMPLATES / nav links (e.g. /playlists/, /playlist/%1%/video/%2%/) that the
// old /playlist|/manifest substring check misclassified as "manifests". // old /playlist|/manifest substring check misclassified as "manifests".
const TEMPLATE_URLS = [ const TEMPLATE_URLS = [
'https://thisvid.com/playlists/', 'https://example.com/playlists/',
'https://thisvid.com/playlists/liked/', 'https://example.com/playlists/liked/',
'https://thisvid.com/playlists/popular/', 'https://example.com/playlists/popular/',
'https://thisvid.com/playlists/commented/', 'https://example.com/playlists/commented/',
'https://thisvid.com/playlists/updated/', 'https://example.com/playlists/updated/',
'https://thisvid.com/playlists/largest/', 'https://example.com/playlists/largest/',
'https://thisvid.com/playlist/%1%/video/%2%/', 'https://example.com/playlist/%1%/video/%2%/',
'https://thisvid.com/playlists/?category=%DIR%', 'https://example.com/playlists/?category=%DIR%',
'https://thisvid.com/members/%ID%/playlists/', 'https://example.com/members/%ID%/playlists/',
'https://thisvid.com/my/playlists/%ID%/', 'https://example.com/my/playlists/%ID%/',
]; ];
it('route templates / nav links are NOT reported as manifests', () => { it('route templates / nav links are NOT reported as manifests', () => {
const result = runProbeWin('https://thisvid.com/videos/redneck/', { const result = runProbeWin('https://example.com/videos/redneck/', {
pageContext: { langUrls: TEMPLATE_URLS }, pageContext: { langUrls: TEMPLATE_URLS },
kvsplayer: { config: { langUrls: TEMPLATE_URLS } }, kvsplayer: { config: { langUrls: TEMPLATE_URLS } },
__goon_plugin_data: { manifests: [], sources: [] }, __goon_plugin_data: { manifests: [], sources: [] },
@@ -1294,7 +1294,7 @@ describe('HLS QA: route-template / nav URL rejection', () => {
it('a real .m3u8 with a %2B token is still found (no over-rejection)', () => { it('a real .m3u8 with a %2B token is still found (no over-rejection)', () => {
// %2B is a valid percent-encoded token, not a placeholder — it must survive. // %2B is a valid percent-encoded token, not a placeholder — it must survive.
const real = 'https://cdn.example.com/live/master.m3u8?h=a%2Bb%2Fc&e=1234'; const real = 'https://cdn.example.com/live/master.m3u8?h=a%2Bb%2Fc&e=1234';
const result = runProbeWin('https://thisvid.com/videos/redneck/', { const result = runProbeWin('https://example.com/videos/redneck/', {
hls: { url: real }, hls: { url: real },
pageContext: { langUrls: TEMPLATE_URLS }, pageContext: { langUrls: TEMPLATE_URLS },
}); });
+204
View File
@@ -0,0 +1,204 @@
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 mountPopup() {
const { dom, cleanup } = installPopupDom();
const moduleUrl = new URL('../src/viewer/index.js?test=' + Date.now(), import.meta.url);
await import(moduleUrl.href);
return { dom, cleanup };
}
describe('Viewer popup instructions', () => {
it('renders the drafted instructions copy on mount with accessible labeling', async () => {
const { dom, cleanup } = await mountPopup();
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 mountPopup();
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();
}
});
});
+155 -30
View File
@@ -29,18 +29,19 @@ function buildModule(code) {
return `(async () => {\n${code}\n})();\n`; return `(async () => {\n${code}\n})();\n`;
} }
// A fresh mock of the chrome APIs the worker touches. `onBeforeSendHeaders`, // A fresh mock of the chrome APIs the worker touches. `onBeforeRequest` and
// `onBeforeRequest`, and `tabs.onRemoved` capture their listener so the test can // `tabs.onRemoved` capture their listener, and `tabs.get` serves the source
// drive them; `declarativeNetRequest.updateSessionRules` records the live rules. // tab's URL (the captured referer); `declarativeNetRequest.updateSessionRules`
// records the live rules.
function makeChromeEnv() { function makeChromeEnv() {
const storage = {}; const storage = {};
const handlers = { onMessage: [] }; const handlers = { onMessage: [] };
const tabUrls = {};
let sessionRules = {}; let sessionRules = {};
const webRequest = { const webRequest = {
onBeforeSendHeaders: { addListener: (cb) => { handlers.onBeforeSendHeaders = cb; } },
onBeforeRequest: { addListener: (cb) => { handlers.onBeforeRequest = cb; } }, onBeforeRequest: { addListener: (cb) => { handlers.onBeforeRequest = cb; } },
}; };
const chrome = { const chrome = {
storage: { storage: {
@@ -62,8 +63,9 @@ function makeChromeEnv() {
}, },
webRequest, webRequest,
tabs: { tabs: {
get: async (tabId) => tabUrls[tabId] ? { tabId, url: tabUrls[tabId] } : undefined,
onRemoved: { addListener: (cb) => { handlers.onRemoved = cb; } }, onRemoved: { addListener: (cb) => { handlers.onRemoved = cb; } },
}, },
runtime: { onMessage: { addListener: (cb) => { handlers.onMessage.push(cb); } } }, runtime: { onMessage: { addListener: (cb) => { handlers.onMessage.push(cb); } } },
declarativeNetRequest: { declarativeNetRequest: {
updateSessionRules: async ({ removeRuleIds = [], addRules = [] } = {}) => { updateSessionRules: async ({ removeRuleIds = [], addRules = [] } = {}) => {
@@ -76,7 +78,9 @@ function makeChromeEnv() {
}, },
}; };
return { chrome, handlers, storage, getSessionRules: () => sessionRules }; 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 // Load the worker into a fresh environment. `data:` URLs are cached by Node, so
@@ -92,12 +96,12 @@ async function loadBackground(env) {
return module; return module;
} }
function videoDetails(tabId, url, referer) { // Drive a referer capture the way the worker now does it: the worker reads the
return { // source tab's URL (via chrome.tabs.get) as the provider referer, so we set the
tabId, // tab's URL and fire an onBeforeRequest for a video URL on that tab.
url, async function capture(env, videoUrl, referer) {
requestHeaders: referer ? [{ name: 'Referer', value: referer }] : [], env.setTabUrl(11, referer);
}; await env.handlers.onBeforeRequest({ tabId: 11, url: videoUrl });
} }
describe('PR#5: per-host referer capture + proxy race', () => { describe('PR#5: per-host referer capture + proxy race', () => {
@@ -108,12 +112,10 @@ describe('PR#5: per-host referer capture + proxy race', () => {
global.chrome = env.chrome; global.chrome = env.chrome;
await loadBackground(env); await loadBackground(env);
// A live source tab captures the provider referer for its CDN host. // A live source tab captures the provider referer for its CDN host.
const cap = env.handlers.onBeforeSendHeaders; await capture(env, 'https://cdnA.example.com/hls/live/seg-1.ts', 'https://pageA.example.com/watch');
if (!cap) throw new Error('onBeforeSendHeaders listener was not registered');
await cap(videoDetails(11, 'https://cdnA.example.com/hls/live/seg-1.ts', 'https://pageA.example.com/watch'));
const rA = env.storage['goon_referers']?.['cdna.example.com']; const rA = env.storage['goon_referers']?.['example.com'];
if (!rA) throw new Error('referer was not captured for cdnA'); if (!rA) throw new Error('referer was not captured for cdnA');
if (rA.referer !== 'https://pageA.example.com/watch') { if (rA.referer !== 'https://pageA.example.com/watch') {
throw new Error('captured referer wrong: ' + JSON.stringify(rA)); throw new Error('captured referer wrong: ' + JSON.stringify(rA));
@@ -123,10 +125,10 @@ describe('PR#5: per-host referer capture + proxy race', () => {
await env.handlers.onRemoved(22); await env.handlers.onRemoved(22);
const after = env.storage['goon_referers']; const after = env.storage['goon_referers'];
if (!after || !after['cdna.example.com']) { if (!after || !after['example.com']) {
throw new Error('closing tab 22 wiped goon_referers — host A referer lost'); throw new Error('closing tab 22 wiped goon_referers — host A referer lost');
} }
if (after['cdna.example.com'].referer !== 'https://pageA.example.com/watch') { if (after['example.com'].referer !== 'https://pageA.example.com/watch') {
throw new Error('closing tab 22 corrupted the host A referer: ' + JSON.stringify(after)); throw new Error('closing tab 22 corrupted the host A referer: ' + JSON.stringify(after));
} }
}); });
@@ -136,14 +138,12 @@ describe('PR#5: per-host referer capture + proxy race', () => {
global.chrome = env.chrome; global.chrome = env.chrome;
await loadBackground(env); await loadBackground(env);
await env.handlers.onBeforeSendHeaders( await capture(env, 'https://cdnB.example.com/video/x/master.m3u8', 'https://pageB.example.com/v');
videoDetails(11, 'https://cdnB.example.com/video/x/master.m3u8', 'https://pageB.example.com/v')
);
// The very tab that captured the referer now closes. // The very tab that captured the referer now closes.
await env.handlers.onRemoved(11); await env.handlers.onRemoved(11);
const after = env.storage['goon_referers']?.['cdnb.example.com']; 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) throw new Error('goon_referers for the capturing host was wiped on its own tab close');
if (after.referer !== 'https://pageB.example.com/v') { if (after.referer !== 'https://pageB.example.com/v') {
throw new Error('capturing-tab close corrupted referer: ' + JSON.stringify(after)); throw new Error('capturing-tab close corrupted referer: ' + JSON.stringify(after));
@@ -153,7 +153,7 @@ describe('PR#5: per-host referer capture + proxy race', () => {
// (b) Should-fix + coverage: two hosts proxied concurrently must each keep // (b) Should-fix + coverage: two hosts proxied concurrently must each keep
// their own referer. With a shared rule id + non-atomic install this regressed; // their own referer. With a shared rule id + non-atomic install this regressed;
// per-host rule ids + the per-host lock keep them distinct. // per-host rule ids + the per-host lock keep them distinct.
it('concurrent proxy fetches for two hosts keep distinct per-host referer rules', async () => { it('concurrent proxy fetches for two distinct registrable domains keep distinct referer rules', async () => {
const env = makeChromeEnv(); const env = makeChromeEnv();
let fetchCalls = 0; let fetchCalls = 0;
global.chrome = env.chrome; global.chrome = env.chrome;
@@ -173,7 +173,7 @@ describe('PR#5: per-host referer capture + proxy race', () => {
const handler = env.handlers.onMessage[env.handlers.onMessage.length - 1]; const handler = env.handlers.onMessage[env.handlers.onMessage.length - 1];
let respA, respB; 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://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.example.com/hls/live/seg-1.ts', referer: 'https://pageB.example.com/watch' }, {}, (r) => { respB = 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 await new Promise(r => setTimeout(r, 30)); // let async sends + installs flush
@@ -184,8 +184,8 @@ describe('PR#5: per-host referer capture + proxy race', () => {
// Each host must have its OWN installed DNR rule with its OWN referer — // 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). // host A must NOT be served host B's referer (the 412 regression).
const rules = env.getSessionRules(); const rules = env.getSessionRules();
const ruleA = Object.values(rules).find(r => r.condition?.requestDomains?.includes('cdna.example.com')); const ruleA = Object.values(rules).find(r => r.condition?.requestDomains?.includes('example.com'));
const ruleB = Object.values(rules).find(r => r.condition?.requestDomains?.includes('cdnb.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 (!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 (!ruleB) throw new Error('no DNR rule installed for cdnb; rules=' + JSON.stringify(rules));
if (ruleA.id === ruleB.id) { if (ruleA.id === ruleB.id) {
@@ -196,7 +196,7 @@ describe('PR#5: per-host referer capture + proxy race', () => {
if (refA !== 'https://pageA.example.com/watch') { if (refA !== 'https://pageA.example.com/watch') {
throw new Error('cdnA served wrong referer: ' + refA); throw new Error('cdnA served wrong referer: ' + refA);
} }
if (refB !== 'https://pageB.example.com/watch') { if (refB !== 'https://pageB.other.com/watch') {
throw new Error('cdnB served wrong referer: ' + refB); throw new Error('cdnB served wrong referer: ' + refB);
} }
if (refA === refB) throw new Error('both hosts got the same referer — cross-host bleed'); if (refA === refB) throw new Error('both hosts got the same referer — cross-host bleed');
@@ -236,4 +236,129 @@ describe('PR#5: per-host referer capture + proxy race', () => {
throw new Error(`expected idempotent install, but re-added rule ${installs - installsAfterFirst} extra time(s)`); 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));
}
});
});
+2
View File
@@ -3,10 +3,12 @@
*/ */
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 './hls_qa.test.mjs';
import './simple_video.test.mjs';
import './referer_proxy.test.mjs'; import './referer_proxy.test.mjs';
import { run } from './runner.mjs'; import { run } from './runner.mjs';
+131
View File
@@ -0,0 +1,131 @@
// 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)'
);
}
});
});
+25
View File
@@ -0,0 +1,25 @@
# 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
+17
View File
@@ -0,0 +1,17 @@
# 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.
+50
View File
@@ -0,0 +1,50 @@
<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>

After

Width:  |  Height:  |  Size: 2.5 KiB

+26
View File
@@ -0,0 +1,26 @@
# 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.
+42
View File
@@ -0,0 +1,42 @@
# 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.
+3 -4
View File
@@ -1,15 +1,14 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "VidPlex", "name": "VidPlex",
"version": "0.1.0", "version": "0.1.1",
"description": "Find videos on any page and view them in a grid.", "description": "Find videos on any page and view them in a grid.",
"permissions": [ "permissions": [
"storage", "storage",
"tabs", "tabs",
"scripting", "scripting",
"webRequest", "webRequest",
"declarativeNetRequest", "declarativeNetRequest"
"extraHeaders"
], ],
"host_permissions": [ "host_permissions": [
"<all_urls>" "<all_urls>"
+3 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "vidplex", "name": "vidplex",
"version": "0.1.0", "version": "0.1.1",
"private": true, "private": true,
"description": "Chrome extension: find videos on a page and view them in a grid.", "description": "Chrome extension: find videos on a page and view them in a grid.",
"type": "module", "type": "module",
@@ -23,5 +23,7 @@
"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": {
} }
} }
+122 -78
View File
@@ -54,7 +54,7 @@ async function persistVideoRequest(tabId, url) {
await chrome.storage.local.set({ [key]: arr }); await chrome.storage.local.set({ [key]: arr });
} }
} catch(e) { } catch(e) {
console.warn('[goon-bg] Failed to persist video request:', e && e.message ? e.message : e); console.warn('[vidplex-bg] Failed to persist video request:', e && e.message ? e.message : e);
} }
} }
@@ -165,7 +165,7 @@ async function collectAllVideoUrls() {
try { try {
all = await collectAllVideoUrls(); all = await collectAllVideoUrls();
} catch (e) { } catch (e) {
console.warn('[goon-bg] collectAllVideoUrls failed:', e && e.message ? e.message : e); console.warn('[vidplex-bg] collectAllVideoUrls failed:', e && e.message ? e.message : e);
all = []; all = [];
} }
@@ -216,7 +216,7 @@ async function injectFreshToken(url) {
const fresh = await freshLiveForGroup(groupKey, false); const fresh = await freshLiveForGroup(groupKey, false);
const token = fresh ? extractRawQuery(fresh) : ''; const token = fresh ? extractRawQuery(fresh) : '';
tokenCache.set(groupKey, token); tokenCache.set(groupKey, token);
console.log('[goon-bg] injectFreshToken for', groupKey, (token ? token.substring(0, 40) + '…' : '(none captured)')); console.log('[vidplex-bg] injectFreshToken for', groupKey, (token ? token.substring(0, 40) + '…' : '(none captured)'));
return token ? url + '?' + token : url; return token ? url + '?' + token : url;
} catch { } catch {
return url; return url;
@@ -227,20 +227,47 @@ async function injectFreshToken(url) {
// re-query storage for a freshly-rotated token. // re-query storage for a freshly-rotated token.
function invalidateTokenCache() { tokenCache.clear(); } 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( chrome.webRequest.onBeforeRequest.addListener(
async (details) => { async (details) => {
if (details.tabId < 0) return; if (details.tabId < 0) return;
// Log video-like requests from any host (CDN-agnostic). // Log video-like requests from any host (CDN-agnostic).
if (isVideoRequest(details.url)) { if (isVideoRequest(details.url)) {
console.log('[goon-bg] webRequest: video request in tab', details.tabId, ':', details.url.substring(0, 180)); console.log('[vidplex-bg] webRequest: video request in tab', details.tabId, ':', details.url.substring(0, 180));
await persistVideoRequest(details.tabId, details.url); 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) // Also capture HLS manifest URLs (original logic)
if (!isHlsManifest(details.url)) return; if (!isHlsManifest(details.url)) return;
console.log('[goon-bg] webRequest: HLS manifest in tab', details.tabId, ':', details.url.substring(0, 120)); console.log('[vidplex-bg] webRequest: HLS manifest in tab', details.tabId, ':', details.url.substring(0, 120));
const key = 'wr_manifests_' + details.tabId; const key = 'wr_manifests_' + details.tabId;
try { try {
@@ -251,14 +278,14 @@ chrome.webRequest.onBeforeRequest.addListener(
await chrome.storage.local.set({ [key]: arr }); await chrome.storage.local.set({ [key]: arr });
} }
} catch(e) { } catch(e) {
console.warn('[goon-bg] Failed to persist webRequest manifest:', e && e.message ? e.message : e); console.warn('[vidplex-bg] Failed to persist webRequest manifest:', e && e.message ? e.message : e);
} }
}, },
{ urls: ['<all_urls>'] } { urls: ['<all_urls>'] }
); );
// Clean up stale PER-TAB entries when tabs are closed. NOTE: 'goon_referers' is // Clean up stale PER-TAB entries when tabs are closed. NOTE: 'goon_referers' is
// a host-keyed GLOBAL store — it must outlive its capture tab so a proxy fetch // 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 // 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 // therefore NOT removed here (only the per-tab vr_requests_/wr_manifests_ keys
// are). Stale referers are pruned opportunistically in persistReferer. // are). Stale referers are pruned opportunistically in persistReferer.
@@ -280,12 +307,19 @@ chrome.tabs.onRemoved.addListener(async (tabId) => {
// //
// We cannot hardcode a target referer: the proxy is invoked from the viewer // 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 // (a different origin than the source page), and a DNR rule cannot read a live
// page's origin. So we capture the referer that the REAL page uses for its video // page's origin. So we derive the provider referer from the source tab's own URL.
// requests — via chrome.webRequest.onBeforeSendHeaders, which sees the request // For a cross-origin video request the browser sends the referring page's URL as
// headers the browser already built — and persist it per CDN host. The // 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 // 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. // captured referer for just that host, so the proxy's fetch() is accepted.
// This requires no knowledge of any particular site. // 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 // 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 // proxy fetches for different hosts can't clobber each other's rule (a single
@@ -308,86 +342,74 @@ function hostOf(url) {
try { return new URL(url).host.toLowerCase(); } catch { return ''; } try { return new URL(url).host.toLowerCase(); } catch { return ''; }
} }
// Best-effort origin for a host when no referer was captured for it. Used only // Registrable domain of a CDN host — the label a single provider's referer
// as a fallback; a captured referer always wins. // applies to. A referer-protecting CDN serves manifests and segments from many
function originFromHost(host) { // edge subdomains that share ONE
return host ? 'https://' + host + '/' : ''; // 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('.');
} }
// Read the `Referer` request header (the browser may title-case it; the
// `extraHeaders` option exposes it to MV3 onBeforeSendHeaders).
function readReferer(headers) {
if (!Array.isArray(headers)) return '';
for (const h of headers) {
if (h && h.name && h.name.toLowerCase() === 'referer') return h.value || '';
}
return '';
}
// How long a captured referer is trusted before it is pruned on the next // How long a captured referer is trusted before it is pruned on the next
// persist. Referers are short-lived session tokens; this keeps the per-host // persist. Kept long enough for the scan-now / watch-later flow (a user scans a
// global store from accumulating stale entries without tying cleanup to tab // page, then opens the viewer much later) without tying cleanup to tab close (a
// close (a still-open source tab's referer must survive other tabs closing). // still-open source tab's referer must survive other tabs closing).
const REFERER_TTL_MS = 30 * 60 * 1000; const REFERER_TTL_MS = 7 * 24 * 60 * 60 * 1000;
// Persist the captured referer per CDN host so the proxy can re-use it even // Persist the captured referer per CDN registrable domain so the proxy can
// after this capture tab closes. Keyed by host so a proxy for that host can // re-use it even after the capture tab closes. Keyed by the host's registrable
// look its referer up without knowing the source tab. Each entry is stamped // domain so a single provider referer covers every edge subdomain (a CDN serves
// with a capture time; entries older than the TTL are pruned opportunistically // manifests/segments from many subhosts that share one referer), and so a proxy
// here (never on tab close). // 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) { async function persistReferer(host, referer) {
if (!host || !referer) return; const domain = registrableDomain(host);
if (!domain || !referer) return;
try { try {
const stored = await chrome.storage.local.get('goon_referers'); const stored = await chrome.storage.local.get('goon_referers');
const map = stored['goon_referers'] || {}; const map = stored['goon_referers'] || {};
const now = Date.now(); const now = Date.now();
// Opportunistic per-host prune: drop entries older than the TTL. // Opportunistic per-domain prune: drop entries older than the TTL.
for (const [h, entry] of Object.entries(map)) { for (const [h, entry] of Object.entries(map)) {
if (h !== host && (!entry || !entry.at || now - entry.at > REFERER_TTL_MS)) { if (h !== domain && (!entry || !entry.at || now - entry.at > REFERER_TTL_MS)) {
delete map[h]; delete map[h];
} }
} }
const current = map[host]; const current = map[domain];
if (current && current.referer === referer) return; if (current && current.referer === referer) return;
map[host] = { referer, at: now }; map[domain] = { referer, at: now };
await chrome.storage.local.set({ goon_referers: map }); await chrome.storage.local.set({ goon_referers: map });
} catch (e) { } catch (e) {
console.warn('[goon-bg] persistReferer failed:', e && e.message ? e.message : e); console.warn('[vidplex-bg] persistReferer failed:', e && e.message ? e.message : e);
} }
} }
// Look up the referer captured for a host. '' when none has been captured // Look up the referer captured for a host's registrable domain. '' when none
// (or the stored entry is malformed). // has been captured (or the stored entry is malformed).
async function getReferer(host) { async function getReferer(host) {
if (!host) return ''; if (!host) return '';
try { try {
const stored = await chrome.storage.local.get('goon_referers'); const stored = await chrome.storage.local.get('goon_referers');
const entry = (stored['goon_referers'] || {})[host]; const entry = (stored['goon_referers'] || {})[registrableDomain(host)];
return entry && entry.referer ? entry.referer : ''; return entry && entry.referer ? entry.referer : '';
} catch { } catch {
return ''; return '';
} }
} }
// Capture the referer the real page sends for its video requests.
chrome.webRequest.onBeforeSendHeaders.addListener(
async (details) => {
if (details.tabId < 0) return;
if (!isVideoRequest(details.url)) return;
const host = hostOf(details.url);
if (!host) return;
const referer = readReferer(details.requestHeaders);
if (referer) {
// The browser already built a referer from the source page — use it.
await persistReferer(host, referer);
} else {
// No referer was sent; assume the page's origin is the expected referer.
await persistReferer(host, originFromHost(host));
}
},
{ urls: ['<all_urls>'], extraHeaders: ['Referer'] }
);
// Install (idempotently) a DNR rule that sets the captured referer for a given // 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 // 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 // hardcoded site. Scoped to the requested host so only proxy fetches for that
@@ -403,19 +425,20 @@ const refererRuleInstalled = new Map();
const refererRuleLocks = new Map(); const refererRuleLocks = new Map();
async function ensureRefererRule(host, referer) { async function ensureRefererRule(host, referer) {
if (!host || !referer) return; const domain = registrableDomain(host);
if (!domain || !referer) return;
const api = chrome.declarativeNetRequest; const api = chrome.declarativeNetRequest;
if (!api || typeof api.updateSessionRules !== 'function') { if (!api || typeof api.updateSessionRules !== 'function') {
console.warn('[goon-bg] declarativeNetRequest unavailable; skipping referer rule'); console.warn('[vidplex-bg] declarativeNetRequest unavailable; skipping referer rule');
return; return;
} }
// Serialize per host so the idempotency check + updateSessionRules is atomic. // Serialize per host so the idempotency check + updateSessionRules is atomic.
const run = (async () => { const run = (async () => {
if (refererRuleInstalled.has(host) && refererRuleInstalled.get(host) === referer) { if (refererRuleInstalled.has(domain) && refererRuleInstalled.get(domain) === referer) {
return; return;
} }
const ruleId = refererRuleIdFor(host); const ruleId = refererRuleIdFor(domain);
try { try {
// Remove only THIS host's own rule id, then add it back scoped to the host. // Remove only THIS host's own rule id, then add it back scoped to the host.
await api.updateSessionRules({ await api.updateSessionRules({
@@ -434,17 +457,17 @@ async function ensureRefererRule(host, referer) {
condition: { condition: {
// Host + subdomains ONLY, so only proxy fetches for this host get the // Host + subdomains ONLY, so only proxy fetches for this host get the
// rewritten referer; the source page's own requests keep theirs. // rewritten referer; the source page's own requests keep theirs.
requestDomains: [host], requestDomains: [domain],
}, },
}], }],
}); });
refererRuleInstalled.set(host, referer); refererRuleInstalled.set(domain, referer);
console.log('[goon-bg] DNR referer rule installed for', host, '(' + ruleId + ')'); console.log('[vidplex-bg] DNR referer rule installed for', domain, '(' + ruleId + ')');
} catch (e) { } catch (e) {
console.warn('[goon-bg] ensureRefererRule failed:', e && e.message ? e.message : e); console.warn('[vidplex-bg] ensureRefererRule failed:', e && e.message ? e.message : e);
} }
})().finally(() => { refererRuleLocks.set(host, null); }); })().finally(() => { refererRuleLocks.set(domain, null); });
refererRuleLocks.set(host, run); refererRuleLocks.set(domain, run);
return run; return run;
} }
@@ -509,7 +532,7 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
const fresh = await freshLiveForGroup(groupKey, true); const fresh = await freshLiveForGroup(groupKey, true);
sendResponse({ ok: true, fresh, groupKey, changed: !!(fresh && fresh !== url) }); sendResponse({ ok: true, fresh, groupKey, changed: !!(fresh && fresh !== url) });
} catch (e) { } catch (e) {
console.warn('[goon-bg] resolveFreshManifest failed:', e && e.message ? e.message : 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 }); sendResponse({ ok: false, error: e && e.message ? e.message : String(e), groupKey });
} }
} }
@@ -532,10 +555,13 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
const proxyHost = hostOf(message.url); const proxyHost = hostOf(message.url);
if (proxyHost) { if (proxyHost) {
// Prefer the provider's real referer captured from the live // Prefer the provider's real referer captured from the live
// tab; fall back to a viewer-supplied or host-derived origin. // 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 captured = await getReferer(proxyHost);
const referer = captured || message.referer || originFromHost(proxyHost); const referer = captured || message.referer || '';
await ensureRefererRule(proxyHost, referer); if (referer) await ensureRefererRule(proxyHost, referer);
} }
// hls.js resolves sub-playslists/segments relative to the master // hls.js resolves sub-playslists/segments relative to the master
// and can DROP the token query, handing us a token-less // and can DROP the token query, handing us a token-less
@@ -547,7 +573,7 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
const proxyUrl = resolved.replace(/\+/g, '%2B'); const proxyUrl = resolved.replace(/\+/g, '%2B');
const resp = await fetch(proxyUrl); const resp = await fetch(proxyUrl);
if (!resp.ok) { if (!resp.ok) {
console.warn('[goon-bg] proxyFetch', resp.status, message.url.substring(0, 120)); console.warn('[vidplex-bg] proxyFetch', resp.status, message.url.substring(0, 120));
sendResponse({ ok: false, status: resp.status, error: 'HTTP ' + resp.status }); sendResponse({ ok: false, status: resp.status, error: 'HTTP ' + resp.status });
return; return;
} }
@@ -563,14 +589,32 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
const base64 = btoa(binary); const base64 = btoa(binary);
sendResponse({ ok: true, data: base64, mimeType: resp.headers.get('content-type') || '' }); sendResponse({ ok: true, data: base64, mimeType: resp.headers.get('content-type') || '' });
} catch(e) { } catch(e) {
console.warn('[goon-bg] proxyFetch failed:', e && e.message ? e.message : e); console.warn('[vidplex-bg] proxyFetch failed:', e && e.message ? e.message : e);
sendResponse({ ok: false, error: e && e.message ? e.message : String(e) }); sendResponse({ ok: false, error: e && e.message ? e.message : String(e) });
} }
})(); })();
return true; 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('[goon-plugin] Unknown message type:', message.type); console.warn('[vidplex-plugin] Unknown message type:', message.type);
} }
}); });
+10 -10
View File
@@ -67,8 +67,8 @@
source: source, source: source,
timestamp: event.data.timestamp timestamp: event.data.timestamp
}); });
console.log('[goon-plugin] Intercepted HLS manifest via', source, ':', url); console.log('[vidplex-plugin] Intercepted HLS manifest via', source, ':', url);
console.log('[goon-plugin] Total intercepted manifests so far:', interceptedManifests.length); console.log('[vidplex-plugin] Total intercepted manifests so far:', interceptedManifests.length);
} }
} }
}); });
@@ -106,21 +106,21 @@
*/ */
function getVideoData() { function getVideoData() {
const poster = extractPoster(); const poster = extractPoster();
console.log('[goon-content] getVideoData called. Poster:', poster); console.log('[vidplex-content] getVideoData called. Poster:', poster);
console.log('[goon-content] Intercepted manifests count:', interceptedManifests.length); console.log('[vidplex-content] Intercepted manifests count:', interceptedManifests.length);
if (interceptedManifests.length > 0) { if (interceptedManifests.length > 0) {
console.log('[goon-content] Manifest details:', JSON.stringify(interceptedManifests.map(m => ({ url: m.url, source: m.source })))); 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('[goon-content] Found', videos.length, '<video> elements in DOM'); 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; const isVisible = style.display !== 'none' && video.clientWidth > 0 && video.clientHeight > 0;
console.log('[goon-content] Video:', { console.log('[vidplex-content] Video:', {
src: (video.currentSrc || video.src || '').substring(0, 120), src: (video.currentSrc || video.src || '').substring(0, 120),
visible: isVisible, visible: isVisible,
dimensions: video.clientWidth + 'x' + video.clientHeight, dimensions: video.clientWidth + 'x' + video.clientHeight,
@@ -147,10 +147,10 @@
} }
} }
} catch(e) { /* ignore DOM access errors */ } catch(e) { /* ignore DOM access errors */
console.warn('[goon-content] DOM scan error:', e && e.message ? e.message : e); console.warn('[vidplex-content] DOM scan error:', e && e.message ? e.message : e);
} }
console.log('[goon-content] Visible video elements:', videoElements.length); console.log('[vidplex-content] Visible video elements:', videoElements.length);
// Expose interceptor data globally so the popup's deep probe can read it. // Expose interceptor data globally so the popup's deep probe can read it.
window.__goon_plugin_data = window.__goon_plugin_data || { window.__goon_plugin_data = window.__goon_plugin_data || {
manifests: [], manifests: [],
@@ -183,5 +183,5 @@
return false; return false;
}); });
console.log('[goon-plugin] Content script loaded. Interceptors injected at document_start.'); console.log('[vidplex-plugin] Content script loaded. Interceptors injected at document_start.');
})(); })();
+84
View File
@@ -32,6 +32,90 @@ 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);
+42
View File
@@ -9,6 +9,48 @@
<body> <body>
<div id="app"> <div id="app">
<h1>VidPlex</h1> <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>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>
+155 -71
View File
@@ -10,8 +10,8 @@ const TAGGED_KEY = 'goon_tagged';
// Set to true to enable verbose console output in the popup. // Set to true to enable verbose console output in the popup.
const DEBUG = true; // TEMP: enabled for debugging — revert to false when done const DEBUG = true; // TEMP: enabled for debugging — revert to false when done
function log(...args) { if (DEBUG) console.log('[goon]', ...args); } function log(...args) { if (DEBUG) console.log('[vidplex]', ...args); }
function warn(...args) { if (DEBUG) console.warn('[goon]', ...args); } function warn(...args) { if (DEBUG) console.warn('[vidplex]', ...args); }
/** Log a message to the active tab's page console (visible to the user in DevTools). */ /** Log a message to the active tab's page console (visible to the user in DevTools). */
async function logToPage(msg) { async function logToPage(msg) {
@@ -21,7 +21,7 @@ async function logToPage(msg) {
if (!tab) return; if (!tab) return;
await chrome.scripting.executeScript({ await chrome.scripting.executeScript({
target: { tabId: tab.id }, target: { tabId: tab.id },
func: (m) => { console.log('[goon-page]', m); }, func: (m) => { console.log('[vidplex-page]', m); },
args: [msg], args: [msg],
}); });
} catch { /* ignore — tab may have navigated away */ } } catch { /* ignore — tab may have navigated away */ }
@@ -34,10 +34,26 @@ const openBtn = document.getElementById('open-btn');
const statusEl = document.getElementById('status'); const statusEl = document.getElementById('status');
const listEl = document.getElementById('video-list'); const listEl = document.getElementById('video-list');
const btnRow = document.getElementById('btn-row'); const btnRow = document.getElementById('btn-row');
const instructionsToggle = document.getElementById('instructions-toggle');
const instructionsBody = document.getElementById('instructions-body');
listEl.style.wordBreak = 'break-word'; listEl.style.wordBreak = 'break-word';
listEl.style.whiteSpace = 'pre-wrap'; listEl.style.whiteSpace = 'pre-wrap';
function setInstructionsExpanded(expanded) {
if (!instructionsToggle || !instructionsBody) return;
instructionsBody.hidden = !expanded;
instructionsToggle.setAttribute('aria-expanded', String(expanded));
instructionsToggle.textContent = expanded ? 'Hide instructions' : 'Show instructions';
}
if (instructionsToggle && instructionsBody) {
setInstructionsExpanded(true);
instructionsToggle.addEventListener('click', () => {
setInstructionsExpanded(instructionsBody.hidden);
});
}
/* ======================================================================== */ /* ======================================================================== */
/* UI helpers */ /* UI helpers */
/* ======================================================================== */ /* ======================================================================== */
@@ -120,7 +136,7 @@ function m3u8DeepProbeFn() {
// DEBUG: Log probe activity to console // DEBUG: Log probe activity to console
function probeLog(...args) { function probeLog(...args) {
try { console.log('[goon-probe]', ...args); } catch(e) {} try { console.log('[vidplex-probe]', ...args); } catch(e) {}
} }
// NOTE: the probe is injected with chrome.scripting's `world: 'MAIN'`, so it // NOTE: the probe is injected with chrome.scripting's `world: 'MAIN'`, so it
// runs in the page's main world and can see page globals. `typeof chrome` is // runs in the page's main world and can see page globals. `typeof chrome` is
@@ -139,25 +155,38 @@ function m3u8DeepProbeFn() {
} }
function isManifestUrl(url) { function isManifestUrl(url) {
if (!url) return false; if (!url) return false;
const u = url.toLowerCase(); const u = url.toLowerCase();
// Dynamic route TEMPLATES (e.g. /playlist/%1%/ or /videos/{id}/) are nav // Concrete HLS/manifest signals win over the template heuristics below: a
// links, not fetchable manifests. A placeholder is a `%` that is NOT the // real .m3u8/.hlsvod//hls/ path is fetchable even if it happens to carry a
// start of a valid %-encoded hex pair (so real tokens like %2B, %2F are // trailing '%' query token or a trailing slash — so check these first.
// preserved) or a {name} brace token — so genuine %-encoded auth tokens are if (u.includes('.m3u8') || u.includes('.hlsvod') || u.includes('/hls/')) return true;
// never mistaken for template placeholders. // Dynamic route TEMPLATES (e.g. /playlist/%1%/ or /videos/{id}/) are nav
if (/%(?!([0-9A-Fa-f]{2}))/i.test(u) || /\{[a-z0-9_]+\}/i.test(u)) return false; // links, not fetchable manifests. A placeholder is a `%` that is NOT the
// .m3u8 / .hlsvod / /hls/ are concrete HLS signals. // start of a valid %-encoded hex pair (so real tokens like %2B, %2F are
if (u.includes('.m3u8') || u.includes('.hlsvod') || u.includes('/hls/')) return true; // preserved) or a {name} brace token — so genuine %-encoded auth tokens are
// never mistaken for template placeholders.
if (/%(?!([0-9A-Fa-f]{2}))/i.test(u) || /\{[a-z0-9_]+\}/i.test(u)) return false;
// Plural collection paths are nav menus, not manifests. // Plural collection paths are nav menus, not manifests.
if (u.includes('/playlists') || u.includes('/manifests')) return false; if (u.includes('/playlists') || u.includes('/manifests')) return false;
// A singular manifest-ish path segment with no concrete file may still be a // A singular manifest-ish path segment with no concrete file may still be a
// real (rare, extensionless) endpoint, so allow it when it carries http. // real (rare, extensionless) endpoint, so allow it when it carries http.
return (u.includes('/manifest') || u.includes('/playlist')) && u.includes('http'); return (u.includes('/manifest') || u.includes('/playlist')) && u.includes('http');
} }
function walk(obj, path, depth) { function walk(obj, path, depth) {
if (depth > 5) return; if (depth > 5) return;
// Cycle guard: the live window objects hold circular references
// (window/_parent/_top/frames/_globalObject/_globalProxy are mutually
// reachable), so a depth cap alone does NOT terminate — descending a cycle
// re-walks the same identities along exponentially many paths. Each object
// tree gets its own visited set so an identity is walked at most once per
// tree (trees are independent, so the set is declared per call).
const visited = new WeakSet();
if (obj && typeof obj === 'object') {
if (visited.has(obj)) return;
visited.add(obj);
}
if (typeof obj === 'string') { if (typeof obj === 'string') {
const match = obj.match(/https?:\/\/[^"'`\s)]+?(?:\.m3u8|\.hlsvod|\/hls\/|\/playlist[^"'`\s]*|\/manifest[^"'`\s]*)[^"'`\s)]*/gi); const match = obj.match(/https?:\/\/[^"'`\s)]+?(?:\.m3u8|\.hlsvod|\/hls\/|\/playlist[^"'`\s]*|\/manifest[^"'`\s]*)[^"'`\s)]*/gi);
if (match) { if (match) {
@@ -792,37 +821,64 @@ async function queryVideoRequests(tabId) {
* @param {Array} manifests - HLS manifests found by probing * @param {Array} manifests - HLS manifests found by probing
* @returns {Promise<Object|null>} scrape result or null on error * @returns {Promise<Object|null>} scrape result or null on error
*/ */
async function scrapeTab(tabId, manifests) { /**
try { * Reduce a per-frame executeScript result array to the single largest-video
const raw = await chrome.scripting.executeScript({ * result (or a "no videos" result / null). Shared by the MAIN- and
target: { tabId, allFrames: true }, * isolated-world scrape attempts in scrapeTab.
world: 'MAIN', */
func: scrapeFn, function reduceScrapeFrames(raw) {
args: [manifests],
});
// When allFrames is true, destroyed / sandboxed frames may return null entries. // When allFrames is true, destroyed / sandboxed frames may return null entries.
// Guard: ensure raw is an array, then filter to objects with a defined .result. // Guard: ensure raw is an array, then filter to objects with a defined .result.
const frameResults = Array.isArray(raw) const frameResults = Array.isArray(raw)
? raw.filter(r => r != null && typeof r === 'object' && 'result' in r) ? raw.filter(r => r != null && typeof r === 'object' && 'result' in r)
.map(r => r.result) .map(r => r.result)
: []; : [];
const valid = frameResults.filter(r => r && r.videos && r.videos.length > 0); const valid = frameResults.filter(r => r && r.videos && r.videos.length > 0);
if (valid.length === 0) { if (valid.length === 0) {
const firstWithResult = frameResults.find(r => r != null); const firstWithResult = frameResults.find(r => r != null);
return firstWithResult || null; return firstWithResult || null;
} }
valid.sort((a, b) => { valid.sort((a, b) => {
const aArea = a.videos[0]?.area || 0; const aArea = a.videos[0]?.area || 0;
const bArea = b.videos[0]?.area || 0; const bArea = b.videos[0]?.area || 0;
return bArea - aArea; return bArea - aArea;
}); });
return valid[0]; return valid[0];
} catch(e) {
warn('scrapeTab: tab', tabId, 'error:', e && e.message ? e.message : e);
return null;
}
} }
async function scrapeTab(tabId, manifests) {
// Primary: MAIN world — scrapeFn can read page globals the main frame
// exposes (interceptor annotations, Hls instances, custom <video> props).
try {
const mainRaw = await chrome.scripting.executeScript({
target: { tabId, allFrames: true },
world: 'MAIN',
func: scrapeFn,
args: [manifests],
});
let result = reduceScrapeFrames(mainRaw);
// Isolated-world fallback: some pages embed sandboxed / opaque-origin ad
// iframes that make the MAIN-world injection reject, so the primary run can
// return no videos even when a <video> is visible. scrapeFn only needs the
// shared DOM plus the passed-in manifest list, so an isolated-world run (no
// `world` key — always permitted) still sees the same <video> elements.
if (!result || !(result.videos && result.videos.length > 0)) {
const isoRaw = await chrome.scripting.executeScript({
target: { tabId, allFrames: true },
func: scrapeFn,
args: [manifests],
});
const iso = reduceScrapeFrames(isoRaw);
if (iso && iso.videos && iso.videos.length > 0) result = iso;
else if (!result) result = iso;
}
return result;
} catch(e) {
warn('scrapeTab: tab', tabId, 'error:', e && e.message ? e.message : e);
return null;
}
}
/** /**
* Filter intercepted URLs to prefer actual HLS manifests over segment files. * Filter intercepted URLs to prefer actual HLS manifests over segment files.
* Interceptors capture both .m3u8 manifests and .ts segment URLs (any URL * Interceptors capture both .m3u8 manifests and .ts segment URLs (any URL
@@ -887,31 +943,32 @@ function filterManifests(manifests) {
* path separator), which filters out obviously-broken single-segment paths. * path separator), which filters out obviously-broken single-segment paths.
*/ */
function isValidManifestUrl(url) { function isValidManifestUrl(url) {
if (!url || typeof url !== 'string') return false; if (!url || typeof url !== 'string') return false;
// Dynamic route templates / nav links are not fetchable manifests. A let urlObj;
// placeholder is a `%` not followed by two hex digits (real %2B etc. are try {
// kept) or a {name} brace token. urlObj = new URL(url);
if (/%(?!([0-9A-Fa-f]{2}))/i.test(url) || /\{[a-z0-9_]+\}/i.test(url)) return false; } catch {
let urlObj; return false;
try {
urlObj = new URL(url);
} catch {
return false;
}
const path = urlObj.pathname.toLowerCase();
// Plural collection paths and trailing-slash directory listings are nav menus,
// not manifest files (e.g. /playlists/, /playlists/liked/, /playlist/%id%/).
if (path.includes('/playlists') || path.includes('/manifests') || path.endsWith('/')) {
return false;
} }
const isManifestLike = path.includes('.m3u8') || path.includes('.hlsvod') || const path = urlObj.pathname.toLowerCase();
(path.includes('/master') && path.includes('.m3u')) || // 1) Concrete media/manifest signals win outright: a real file extension or
(path.includes('/playlist') && !path.endsWith('.ts')) || // /hls/ path is fetchable, even with a trailing slash
path.includes('/manifest'); // or a token-bearing query — so check these before the
// Not a manifest path — allow it (it may be a direct media source, not HLS). // template heuristics and never let a coincidental '%'/'/' sink a source.
if (!isManifestLike) return true; if (path.includes('.m3u8') || path.includes('.hlsvod') || path.includes('/hls/') ||
// A manifest path must sit in a directory, not be a bare top-level file. path.includes('/manifest') || path.includes('.mp4') || path.includes('.webm') ||
return path.includes('/'); path.includes('.ogv') || path.includes('.mpd') || path.includes('.m4s')) return true;
// 2) Dynamic route templates / nav links: a `%` that isn't a valid %-encoded
// hex pair (real %2B etc. are kept) or a {name} brace token.
if (/%(?!([0-9A-Fa-f]{2}))/i.test(url) || /\{[a-z0-9_]+\}/i.test(url)) return false;
// 3) Plural collection paths and trailing-slash directory listings are nav
// menus, not manifest files (e.g. /playlists/, /playlists/liked/).
if (path.includes('/playlists') || path.includes('/manifests') || path.endsWith('/')) return false;
// 4) A singular /playlist endpoint may be a real (extensionless) manifest;
// allow it when it sits in a directory.
if (path.includes('/playlist')) return path.includes('/');
// 5) Otherwise treat as a direct media source — allow it.
return true;
} }
/** /**
@@ -932,14 +989,14 @@ function makeVideoEntry(video, tabTitle, posterFromContent) {
if (video.hlsManifest) { if (video.hlsManifest) {
// Reject fake/invalid manifest paths before storing // Reject fake/invalid manifest paths before storing
if (!isValidManifestUrl(video.hlsManifest)) { if (!isValidManifestUrl(video.hlsManifest)) {
console.warn('[goon] Rejecting fake manifest URL:', video.hlsManifest.substring(0, 120)); console.warn('[vidplex] Rejecting fake manifest URL:', video.hlsManifest.substring(0, 120));
} else { } else {
entry.hlsManifest = video.hlsManifest; entry.hlsManifest = video.hlsManifest;
} }
} }
// Also validate src when it looks like a manifest URL (no hlsManifest set but src has .m3u8) // Also validate src when it looks like a manifest URL (no hlsManifest set but src has .m3u8)
if (!entry.hlsManifest && entry.src?.includes('.m3u8') && !isValidManifestUrl(entry.src)) { if (!entry.hlsManifest && entry.src?.includes('.m3u8') && !isValidManifestUrl(entry.src)) {
console.warn('[goon] Rejecting fake manifest URL from src:', entry.src.substring(0, 120)); console.warn('[vidplex] Rejecting fake manifest URL from src:', entry.src.substring(0, 120));
// Don't treat it as HLS — leave mediaType as-is (likely 'video' or unset) // Don't treat it as HLS — leave mediaType as-is (likely 'video' or unset)
} }
return entry; return entry;
@@ -1117,6 +1174,7 @@ async function scanVideos(tabsToScan) {
} }
const items = [...seen.values()]; const items = [...seen.values()];
await attachReferers(items);
const skipped = scrapeResults.filter(r => r === null).length; const skipped = scrapeResults.filter(r => r === null).length;
log('scanVideos complete: scanned=', tabResults.length, 'skipped=', skipped, 'unique items=', items.length); log('scanVideos complete: scanned=', tabResults.length, 'skipped=', skipped, 'unique items=', items.length);
@@ -1127,6 +1185,31 @@ async function scanVideos(tabsToScan) {
return { items, scanned: tabResults.length, skipped }; return { items, scanned: tabResults.length, skipped };
} }
/**
* Bind the provider referer the SW captured (domain-aware) to each item so it
* survives the watch-later flow and covers the sibling edge subdomains a segment
* may resolve to. Best-effort: playback still falls back to the SW's live store.
*/
async function attachReferers(items) {
const hostOf = (url) => { try { return new URL(url).host.toLowerCase(); } catch { return ''; } };
const hosts = [];
for (const it of items) {
const host = hostOf(it.hlsManifest || it.src || '');
if (host) hosts.push(host);
}
if (hosts.length === 0) return;
let referers = {};
try {
const resp = await chrome.runtime.sendMessage({ type: 'GOON_GET_REFERERS', hosts });
if (resp && resp.referers) referers = resp.referers;
} catch { /* referer is best-effort; the SW still resolves it at play time */ }
if (!Object.keys(referers).length) return;
for (const it of items) {
const host = hostOf(it.hlsManifest || it.src || '');
if (host && referers[host]) it.referer = referers[host];
}
}
/** Store video entries in chrome.storage.local. */ /** Store video entries in chrome.storage.local. */
async function storeVideos(items) { async function storeVideos(items) {
const stored = items.map(v => ({ const stored = items.map(v => ({
@@ -1135,6 +1218,7 @@ async function storeVideos(items) {
tab: v.tab, tab: v.tab,
mediaType: v.mediaType || 'video', mediaType: v.mediaType || 'video',
hlsManifest: v.hlsManifest || undefined, hlsManifest: v.hlsManifest || undefined,
referer: v.referer || undefined,
})); }));
await chrome.storage.local.set({ await chrome.storage.local.set({
[STORAGE_KEY]: stored, [STORAGE_KEY]: stored,
@@ -1229,7 +1313,7 @@ findBtn.addEventListener('click', async () => {
} }
} }
} }
console.group('[goon-diag] HLS diagnostics'); console.group('[vidplex-diag] HLS diagnostics');
for (const line of diag) { for (const line of diag) {
console.log(line); console.log(line);
} }
@@ -1237,7 +1321,7 @@ findBtn.addEventListener('click', async () => {
}, },
}); });
} catch (e) { } catch (e) {
console.warn('[goon-diag] Diagnostic failed:', e.message); console.warn('[vidplex-diag] Diagnostic failed:', e.message);
} }
} }
} }
+5
View File
@@ -33,6 +33,11 @@
"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,
+56 -32
View File
@@ -149,7 +149,7 @@ const panelsEl = document.getElementById('panels');
// caused "Cannot create property 'levelDetails' on number". // caused "Cannot create property 'levelDetails' on number".
const fireSuccess = (data) => { const fireSuccess = (data) => {
if (!onSuccess) { if (!onSuccess) {
console.warn('[goon-viewer] no onSuccess callback for', (url || '').substring(0, 100)); console.warn('[vidplex-viewer] no onSuccess callback for', (url || '').substring(0, 100));
return; return;
} }
const tLoad = Date.now(); const tLoad = Date.now();
@@ -161,11 +161,11 @@ const panelsEl = document.getElementById('panels');
const fireError = (code, text) => { const fireError = (code, text) => {
if (!onError) { if (!onError) {
console.error('[goon-viewer] no onError callback; error:', text || code, (url || '').substring(0, 100)); console.error('[vidplex-viewer] no onError callback; error:', text || code, (url || '').substring(0, 100));
return; return;
} }
stats.loading.end = Date.now(); stats.loading.end = Date.now();
console.warn('[goon-viewer] HlsLoader error:', code, text, (url || '').substring(0, 100)); console.warn('[vidplex-viewer] HlsLoader error:', code, text, (url || '').substring(0, 100));
onError({ code: code || 0, text: text || '', url, headers: {}, data: null }, stats, context, null); onError({ code: code || 0, text: text || '', url, headers: {}, data: null }, stats, context, null);
}; };
@@ -177,8 +177,8 @@ const panelsEl = document.getElementById('panels');
if (needProxy) { if (needProxy) {
// Route through the service worker to bypass the CDN's CORS/Referer // Route through the service worker to bypass the CDN's CORS/Referer
// restrictions. The SW adds the required Referer and returns base64. // restrictions. The SW adds the required Referer and returns base64.
console.log('[goon-viewer] Proxying via SW:', (url || '').substring(0, 120)); console.log('[vidplex-viewer] Proxying via SW:', (url || '').substring(0, 120));
chrome.runtime.sendMessage({ type: 'GOON_PROXY_FETCH', url, referer: originForUrl(url) }, (resp) => { chrome.runtime.sendMessage({ type: 'GOON_PROXY_FETCH', url, referer: refererForUrl(url) }, (resp) => {
if (self._aborted) return; if (self._aborted) return;
const lastErr = chrome.runtime.lastError; const lastErr = chrome.runtime.lastError;
if (lastErr || !resp || !resp.ok) { if (lastErr || !resp || !resp.ok) {
@@ -253,7 +253,7 @@ HlsLoader.canSupportLoader = function () { return true; };
} }
return null; return null;
} catch(e) { } catch(e) {
console.warn('[goon-viewer] proxyFetch failed for:', url.substring(0, 120), e && e.message ? e.message : e); console.warn('[vidplex-viewer] proxyFetch failed for:', url.substring(0, 120), e && e.message ? e.message : e);
return null; return null;
} }
} }
@@ -264,19 +264,34 @@ HlsLoader.canSupportLoader = function () { return true; };
// hardcode a particular CDN. // hardcode a particular CDN.
const PROXY_HOSTS_KEY = 'goon_proxy_hosts'; const PROXY_HOSTS_KEY = 'goon_proxy_hosts';
const proxyHosts = new Set(); const proxyHosts = new Set();
// Provider referers bound to a stored entry at scan time, keyed by the entry's
// manifest registrable domain. A single provider referer covers every edge
// subdomain, so a segment on a sibling subhost resolves to the same referer.
const refererByDomain = new Map();
(async () => { (async () => {
try { try {
const stored = await chrome.storage.local.get(PROXY_HOSTS_KEY); const stored = await chrome.storage.local.get(PROXY_HOSTS_KEY);
const hosts = stored[PROXY_HOSTS_KEY]; const hosts = stored[PROXY_HOSTS_KEY];
if (Array.isArray(hosts)) for (const h of hosts) proxyHosts.add(String(h).toLowerCase()); if (Array.isArray(hosts)) for (const h of hosts) proxyHosts.add(registrableDomainOf(String(h).toLowerCase()));
} catch { /* ignore */ } } catch { /* ignore */ }
})(); })();
// Origin for a URL's host — used as the Referer to request when a proxy fetch // Registrable domain of a CDN host — the label a single provider referer
// is made for that host (the SW also captures the live referer per host). // applies to (a referer-protecting CDN serves from many edge subdomains that
function originForUrl(url) { // share one referer). Last-two-labels heuristic; mirrors the SW's copy.
if (!url) return ''; function registrableDomainOf(host) {
try { return new URL(url).origin + '/'; } catch { return ''; } if (!host) return '';
const parts = host.split('.');
if (parts.length < 2) return host;
return parts.slice(-2).join('.');
}
// The provider referer to send for a proxied request: the referer bound to the
// entry at scan time (domain-keyed), or '' when none was captured. No self-origin
// fallback — a self-origin referer is guaranteed-wrong for hotlink protection.
function refererForUrl(url) {
const ref = refererByDomain.get(registrableDomainOf(hostOfUrl(url)));
return ref || '';
} }
// Whether a given video URL should be proxied: its host is a known // Whether a given video URL should be proxied: its host is a known
@@ -284,7 +299,7 @@ HlsLoader.canSupportLoader = function () { return true; };
function needsProxy(url) { function needsProxy(url) {
if (!url || typeof url !== 'string') return false; if (!url || typeof url !== 'string') return false;
const host = hostOfUrl(url); const host = hostOfUrl(url);
return host ? proxyHosts.has(host) : false; return host ? proxyHosts.has(registrableDomainOf(host)) : false;
} }
// host of a URL (lowercased); '' when malformed. // host of a URL (lowercased); '' when malformed.
@@ -729,14 +744,14 @@ function wireHlsEvents(hls, video, panel) {
state.fpsDropCount++; state.fpsDropCount++;
state.lastEvent = 'FPS_DROP'; state.lastEvent = 'FPS_DROP';
state.lastActivity = Date.now(); state.lastActivity = Date.now();
console.warn(`[goon-viewer] FPS drop: current=${data.currentFPS}, threshold=${data.thresholdFPS}`); console.warn(`[vidplex-viewer] FPS drop: current=${data.currentFPS}, threshold=${data.thresholdFPS}`);
renderPanelState(video, panel); renderPanelState(video, panel);
}); });
hls.on(HlsEvents.FPS_MAX_LEVEL_DROP, (_ev, data) => { hls.on(HlsEvents.FPS_MAX_LEVEL_DROP, (_ev, data) => {
state.lastEvent = 'FPS_MAX_LEVEL_DROP'; state.lastEvent = 'FPS_MAX_LEVEL_DROP';
state.lastActivity = Date.now(); state.lastActivity = Date.now();
console.warn(`[goon-viewer] Max level dropped due to FPS: ${data.currentLevel}`); console.warn(`[vidplex-viewer] Max level dropped due to FPS: ${data.currentLevel}`);
renderPanelState(video, panel); renderPanelState(video, panel);
}); });
@@ -783,7 +798,7 @@ function wireHlsEvents(hls, video, panel) {
// tab with no live source doesn't loop forever. // tab with no live source doesn't loop forever.
if ((video.dataset.hlsRescans || '0') | 0 < 3) { if ((video.dataset.hlsRescans || '0') | 0 < 3) {
video.dataset.hlsRescans = ((video.dataset.hlsRescans || '0') | 0) + 1; video.dataset.hlsRescans = ((video.dataset.hlsRescans || '0') | 0) + 1;
console.log('[goon-viewer] fatal NETWORK_ERROR → auto-rescan attempt #' + video.dataset.hlsRescans); console.log('[vidplex-viewer] fatal NETWORK_ERROR → auto-rescan attempt #' + video.dataset.hlsRescans);
showError(panel, 'Refreshing stream…'); showError(panel, 'Refreshing stream…');
rescanAndRetry(video, panel, video.dataset.hlsManifest || ''); rescanAndRetry(video, panel, video.dataset.hlsManifest || '');
} else { } else {
@@ -961,7 +976,7 @@ function playHls(video, manifestUrl, panel) {
// without CORS headers blocks extension-origin requests from the viewer page). // without CORS headers blocks extension-origin requests from the viewer page).
const needProxy = needsProxy(manifestUrl); const needProxy = needsProxy(manifestUrl);
if (needProxy) { if (needProxy) {
console.log('[goon-viewer] Manifest needs proxying via service worker'); console.log('[vidplex-viewer] Manifest needs proxying via service worker');
} }
destroyHls(video); destroyHls(video);
@@ -1002,7 +1017,7 @@ function playHls(video, manifestUrl, panel) {
}; };
// Log the manifest URL being loaded for debugging (helps diagnose playback failures). // Log the manifest URL being loaded for debugging (helps diagnose playback failures).
console.log(`[goon-viewer] Loading HLS manifest:`, manifestUrl.substring(0, 200)); console.log(`[vidplex-viewer] Loading HLS manifest:`, manifestUrl.substring(0, 200));
// Remember the manifest actually loaded so a later auto-rescan can retry from // Remember the manifest actually loaded so a later auto-rescan can retry from
// the freshest token on the same video (the service worker keys off the path). // the freshest token on the same video (the service worker keys off the path).
@@ -1024,14 +1039,14 @@ function loadHlsJs() {
} }
const hldsSrc = chrome.runtime.getURL('hlds.js'); const hldsSrc = chrome.runtime.getURL('hlds.js');
console.log('[goon-viewer] Trying script src:', hldsSrc); console.log('[vidplex-viewer] Trying script src:', hldsSrc);
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const script = document.createElement('script'); const script = document.createElement('script');
script.src = hldsSrc; script.src = hldsSrc;
script.onload = () => { script.onload = () => {
if (window.Hls && typeof window.Hls.isSupported === 'function') { if (window.Hls && typeof window.Hls.isSupported === 'function') {
console.log('[goon-viewer] hlds.js via script src OK, Hls.isSupported:', window.Hls.isSupported()); console.log('[vidplex-viewer] hlds.js via script src OK, Hls.isSupported:', window.Hls.isSupported());
resolve(); resolve();
} else { } else {
reject(new Error('hlds.js loaded but did not register global Hls')); reject(new Error('hlds.js loaded but did not register global Hls'));
@@ -1061,12 +1076,12 @@ function loadHlsJs() {
try { try {
const resp = await chrome.runtime.sendMessage({ type: 'GOON_RESOLVE_MANIFEST', url: storedUrl }); const resp = await chrome.runtime.sendMessage({ type: 'GOON_RESOLVE_MANIFEST', url: storedUrl });
if (resp && resp.ok && resp.changed && resp.fresh) { if (resp && resp.ok && resp.changed && resp.fresh) {
console.log('[goon-viewer] resolved fresh manifest for', (resp.groupKey || '?'), console.log('[vidplex-viewer] resolved fresh manifest for', (resp.groupKey || '?'),
'→', (resp.fresh || '').substring(0, 100)); '→', (resp.fresh || '').substring(0, 100));
return resp.fresh; return resp.fresh;
} }
} catch (e) { } catch (e) {
console.warn('[goon-viewer] resolveFreshManifest failed:', e && e.message ? e.message : e); console.warn('[vidplex-viewer] resolveFreshManifest failed:', e && e.message ? e.message : e);
} }
return null; return null;
} }
@@ -1082,11 +1097,11 @@ function loadHlsJs() {
const fresh = await resolveFreshManifest(originalUrl); const fresh = await resolveFreshManifest(originalUrl);
if (!fresh) { if (!fresh) {
showError(panel, 'Stream expired & no live source — reload the source tab, then re-open viewer.'); showError(panel, 'Stream expired & no live source — reload the source tab, then re-open viewer.');
console.warn('[goon-viewer] rescan gave up: no fresh manifest for', (originalUrl || '').substring(0, 100)); console.warn('[vidplex-viewer] rescan gave up: no fresh manifest for', (originalUrl || '').substring(0, 100));
return false; return false;
} }
if (panel) removeErrorOverlay(panel); if (panel) removeErrorOverlay(panel);
console.log('[goon-viewer] retrying with fresh manifest:', fresh.substring(0, 120)); console.log('[vidplex-viewer] retrying with fresh manifest:', fresh.substring(0, 120));
playHls(video, fresh, panel); playHls(video, fresh, panel);
return true; return true;
} }
@@ -1132,7 +1147,7 @@ function createPanel(entry, tag) {
} }
// mediaType says HLS but we have no manifest URL — fall through to native playback. // mediaType says HLS but we have no manifest URL — fall through to native playback.
// On Safari this may still work for .m3u8; on Chrome it will fail. // On Safari this may still work for .m3u8; on Chrome it will fail.
console.warn('[goon-viewer] HLS entry missing manifest URL, src:', src); console.warn('[vidplex-viewer] HLS entry missing manifest URL, src:', src);
} }
video.src = src; video.src = src;
@@ -1167,13 +1182,13 @@ async function init() {
document.body.insertBefore(overlay, document.body.firstChild); document.body.insertBefore(overlay, document.body.firstChild);
// === DIAGNOSTIC: Check storage + CORS === // === DIAGNOSTIC: Check storage + CORS ===
console.log('[goon-viewer] Viewer ID:', id, 'Grid:', gridN + 'x' + gridN); console.log('[vidplex-viewer] Viewer ID:', id, 'Grid:', gridN + 'x' + gridN);
console.log('[goon-viewer] Raw stored data:', JSON.stringify(videoUrls)); console.log('[vidplex-viewer] Raw stored data:', JSON.stringify(videoUrls));
if (videoUrls.length > 0) { if (videoUrls.length > 0) {
for (const entry of videoUrls) { for (const entry of videoUrls) {
const src = (entry.src || '').substring(0, 120); const src = (entry.src || '').substring(0, 120);
console.log('[goon-viewer] Entry:', { console.log('[vidplex-viewer] Entry:', {
src: src, src: src,
mediaType: entry.mediaType, mediaType: entry.mediaType,
hlsManifest: (entry.hlsManifest || '').substring(0, 120), hlsManifest: (entry.hlsManifest || '').substring(0, 120),
@@ -1184,12 +1199,21 @@ async function init() {
// Probe CORS on each video URL. Hosts that block extension-origin // Probe CORS on each video URL. Hosts that block extension-origin
// requests are learned as referer-protected and persisted so the viewer // requests are learned as referer-protected and persisted so the viewer
// routes them through the service-worker proxy. // routes them through the service-worker proxy.
// Bind each entry's scan-time referer to its manifest's registrable
// domain, so the proxy sends it for every edge subdomain the provider
// serves from (segments often resolve to a sibling subhost).
for (const entry of videoUrls) {
const ref = entry && entry.referer;
if (!ref) continue;
const dom = registrableDomainOf(hostOfUrl(entry.src || entry.hlsManifest || ''));
if (dom) refererByDomain.set(dom, ref);
}
const urlsToTest = videoUrls.map(v => v.src || v.hlsManifest || 'none').filter(u => u !== 'none' && typeof u === 'string'); const urlsToTest = videoUrls.map(v => v.src || v.hlsManifest || 'none').filter(u => u !== 'none' && typeof u === 'string');
const blockedHosts = new Set(); const blockedHosts = new Set();
for (const url of [...new Set(urlsToTest)]) { for (const url of [...new Set(urlsToTest)]) {
const corsOk = await corsCheck(url); const corsOk = await corsCheck(url);
if (!corsOk) blockedHosts.add(hostOfUrl(url)); if (!corsOk) blockedHosts.add(registrableDomainOf(hostOfUrl(url)));
console.log('[goon-viewer] CORS', corsOk ? 'OK' : 'BLOCKED:', url.substring(0, 120)); console.log('[vidplex-viewer] CORS', corsOk ? 'OK' : 'BLOCKED:', url.substring(0, 120));
} }
if (blockedHosts.size > 0) { if (blockedHosts.size > 0) {
for (const h of blockedHosts) if (h) proxyHosts.add(h); for (const h of blockedHosts) if (h) proxyHosts.add(h);
@@ -1198,9 +1222,9 @@ async function init() {
// Report whether any URL needs proxying (referer-protected host). // Report whether any URL needs proxying (referer-protected host).
const anyNeedsProxy = urlsNeedProxy(urlsToTest); const anyNeedsProxy = urlsNeedProxy(urlsToTest);
console.log('[goon-viewer] Proxy required:', anyNeedsProxy); console.log('[vidplex-viewer] Proxy required:', anyNeedsProxy);
} else { } else {
console.warn('[goon-viewer] No video entries in storage for ID:', id, '— data may have been cleared or never saved'); console.warn('[vidplex-viewer] No video entries in storage for ID:', id, '— data may have been cleared or never saved');
} }
overlay.textContent = `Found ${videoUrls.length} video URL(s) in ${gridN}x${gridN} grid`; overlay.textContent = `Found ${videoUrls.length} video URL(s) in ${gridN}x${gridN} grid`;