Compare commits
39
Commits
7336643ea4
..
v0.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3c8f24857 | ||
|
|
b071dbda87 | ||
|
|
1924834cd5 | ||
|
|
1a3616f026 | ||
|
|
6ed6e4277a | ||
|
|
59456918bc | ||
|
|
62ce7ea788 | ||
|
|
352b14f7d8 | ||
|
|
f46285fe4e | ||
|
|
42c1ad75c8 | ||
|
|
fbc836d9c4 | ||
|
|
de9d537a75 | ||
|
|
23a3f00df9 | ||
|
|
0a3225035c | ||
|
|
0848cd51be | ||
|
|
41723227c0 | ||
|
|
5da2003cd0 | ||
|
|
97abaf42d9 | ||
|
|
c366f0b8c4 | ||
|
|
c826669c7d | ||
|
|
64d792632d | ||
|
|
3e02a6ae74 | ||
|
|
ff463c107a | ||
|
|
bd7d5bfcd0 | ||
|
|
4357ce4094 | ||
|
|
7b43d260a2 | ||
|
|
d3c8c85c58 | ||
|
|
62d1244558 | ||
|
|
0030d031f1 | ||
|
|
7139c5ea6f | ||
|
|
e3b904783f | ||
|
|
66c621825f | ||
|
|
dc2c8092f0 | ||
|
|
cb607306bc | ||
|
|
7d56e836c9 | ||
|
|
602d6b774b | ||
|
|
a54d094bef | ||
|
|
9420bce9c1 | ||
|
|
fd8c36d72e |
No files matched your search
@@ -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)"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "Goon Plugin Dev",
|
"name": "VidPlex Dev",
|
||||||
"image": "mcr.microsoft.com/devcontainers/javascript-node:22",
|
"image": "mcr.microsoft.com/devcontainers/javascript-node:22",
|
||||||
"features": {
|
"features": {
|
||||||
"ghcr.io/devcontainers/features/sshd:1": {}
|
"ghcr.io/devcontainers/features/sshd:1": {}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Package dist.zip
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
cd dist
|
||||||
|
zip -r "../vidplex-${{ github.ref_name }}.zip" .
|
||||||
|
|
||||||
|
- name: Create Gitea release
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
GITEA_API: ${{ secrets.GITEA_API || 'http://192.168.1.74:30008/api/v1' }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
TAG: ${{ github.ref_name }}
|
||||||
|
run: |
|
||||||
|
if [ -z "$GITEA_TOKEN" ]; then
|
||||||
|
echo "::warning::GITEA_TOKEN secret is not set; skipping release creation. Add it in Gitea repo Settings > Secrets (repo scope)."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
json_id() { node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{const j=JSON.parse(d||"{}");if(j.id==null){console.error("no release id:",JSON.stringify(j));process.exit(1)}console.log(j.id)})'; }
|
||||||
|
|
||||||
|
release_id=$(
|
||||||
|
curl -s -o /tmp/rel.json -w "%{http_code}" \
|
||||||
|
-H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
"$GITEA_API/repos/$REPO/releases/tags/$TAG"
|
||||||
|
)
|
||||||
|
|
||||||
|
if [ "$release_id" = "200" ]; then
|
||||||
|
echo "Release for $TAG already exists; reusing it."
|
||||||
|
release_id=$(json_id < /tmp/rel.json)
|
||||||
|
else
|
||||||
|
echo "Creating release for $TAG."
|
||||||
|
release_id=$(
|
||||||
|
curl -s -o /tmp/rel.json -w "%{http_code}" -X POST \
|
||||||
|
-H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"tag_name\": \"$TAG\", \"name\": \"$TAG\", \"body\": \"Automated release build for $TAG\"}" \
|
||||||
|
"$GITEA_API/repos/$REPO/releases"
|
||||||
|
)
|
||||||
|
case "$release_id" in
|
||||||
|
201|200) release_id=$(json_id < /tmp/rel.json) ;;
|
||||||
|
*) echo "::error::release create returned HTTP $release_id: $(cat /tmp/rel.json)"; exit 1 ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Uploading asset to release $release_id."
|
||||||
|
resp=$(curl -s -o /tmp/att.json -w "%{http_code}" -X POST \
|
||||||
|
-H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
-F "attachment=@vidplex-${TAG}.zip" \
|
||||||
|
"$GITEA_API/repos/$REPO/releases/$release_id/assets")
|
||||||
|
if [ "$resp" != "200" ] && [ "$resp" != "201" ]; then
|
||||||
|
echo "::error::asset upload returned HTTP $resp: $(cat /tmp/att.json)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
cat /tmp/att.json
|
||||||
|
echo
|
||||||
+2
-1
@@ -2,4 +2,5 @@ node_modules/
|
|||||||
dist/
|
dist/
|
||||||
.vscode-insiders/
|
.vscode-insiders/
|
||||||
/icons
|
/icons
|
||||||
/icons/*
|
/icons/*
|
||||||
|
.claude/settings.local.json
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# Goon Plugin
|
# VidPlex
|
||||||
|
|
||||||
A Chrome extension (Manifest V3) that finds videos on the current page and displays them in a 2x2 grid.
|
A Chrome extension (Manifest V3) that finds videos on the current page and displays them in a 2x2 grid.
|
||||||
|
|
||||||
@@ -55,4 +55,4 @@ src/
|
|||||||
|
|
||||||
## VS Code workspace
|
## VS Code workspace
|
||||||
|
|
||||||
Open `goon_plugin.code-workspace` to get the project with recommended extensions pre-installed.
|
Open `vidplex.code-workspace` to get the project with recommended extensions pre-installed.
|
||||||
@@ -44,8 +44,8 @@ describe('Config: build integrity', () => {
|
|||||||
|
|
||||||
if (codeOnly.includes('textContent')) {
|
if (codeOnly.includes('textContent')) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'content.js uses inline script injection (textContent). ' +
|
'content.js uses inline script injection (textContent). ' +
|
||||||
'Sites like Pornhub block inline scripts via CSP. Use script.src instead.'
|
'Some sites block inline scripts via CSP. Use script.src instead.'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!codeOnly.includes('script.src') || !codeOnly.includes('chrome.runtime.getURL')) {
|
if (!codeOnly.includes('script.src') || !codeOnly.includes('chrome.runtime.getURL')) {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
+150
-21
@@ -14,8 +14,8 @@ const viewerSource = fs.readFileSync(
|
|||||||
);
|
);
|
||||||
const interceptorSource = fs.readFileSync(
|
const interceptorSource = fs.readFileSync(
|
||||||
path.join(__dirname, '../src/inject/interceptors.js'),
|
path.join(__dirname, '../src/inject/interceptors.js'),
|
||||||
'utf-8'
|
'utf-8'
|
||||||
);
|
);
|
||||||
|
|
||||||
/* ========================================================================== */
|
/* ========================================================================== */
|
||||||
/* Helpers */
|
/* Helpers */
|
||||||
@@ -679,8 +679,43 @@ if (!probeMatch) {
|
|||||||
}
|
}
|
||||||
const probeFnSource = probeMatch[0];
|
const probeFnSource = probeMatch[0];
|
||||||
|
|
||||||
|
// jsdom exposes a set of self-referential cyclic globals as *enumerable own-*
|
||||||
|
// properties on the window object. A real Chrome MAIN-world window does not:
|
||||||
|
// `window`, `self`, `top`, `parent`, `frames` are non-enumerable, and the
|
||||||
|
// jsdom-internal `_globalObject`/`_globalProxy`/`_top`/`_parent` keys don't
|
||||||
|
// exist at all. The deep-probe's Strategy H runs `Object.keys(window)` and a
|
||||||
|
// no-visited-set `walk()` over each player-like value, so these cyclic
|
||||||
|
// jsdom-only keys make the walk explode to O(keys^depth) (~3.5e11 ops) and the
|
||||||
|
// suite never terminates. We can't fix the probe (production src/ is off-limits
|
||||||
|
// for this test-only PR), so instead we make this jsdom window mirror a real
|
||||||
|
// page by marking the cyclic globals non-enumerable before the probe runs.
|
||||||
|
const JSDOM_CYCLIC_GLOBALS = [
|
||||||
|
'_globalObject', '_globalProxy', '_top', '_parent',
|
||||||
|
'window', 'self', 'top', 'parent', 'frames',
|
||||||
|
];
|
||||||
|
function sanitizeJsdomWinForProbe(win) {
|
||||||
|
for (const key of JSDOM_CYCLIC_GLOBALS) {
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(win, key)) continue;
|
||||||
|
let desc;
|
||||||
|
try { desc = Object.getOwnPropertyDescriptor(win, key); } catch { continue; }
|
||||||
|
if (!desc) continue;
|
||||||
|
try {
|
||||||
|
// Re-assert the same descriptor but with enumerable:false so the probe's
|
||||||
|
// `Object.keys(window)` no longer sees jsdom's cyclic self-references.
|
||||||
|
Object.defineProperty(win, key, { ...desc, enumerable: false });
|
||||||
|
} catch {
|
||||||
|
// Non-configurable descriptors (e.g. the `window`/`top` getters) can't be
|
||||||
|
// redefined; skip them. The remaining cyclic keys are already suppressed,
|
||||||
|
// leaving only a shallow 2-branch recursion that terminates in ms.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Helper: eval the probe function inside a JSDOM window, then return results
|
// Helper: eval the probe function inside a JSDOM window, then return results
|
||||||
function runProbeInWin(win) {
|
function runProbeInWin(win) {
|
||||||
|
// Mirror a real page environment (see JSDOM_CYCLIC_GLOBALS note above):
|
||||||
|
// non-enumerable cyclic globals prevent Strategy H's window scan from looping.
|
||||||
|
sanitizeJsdomWinForProbe(win);
|
||||||
// Define the function in the JSDOM context and call it
|
// Define the function in the JSDOM context and call it
|
||||||
const result = win.eval(`
|
const result = win.eval(`
|
||||||
(${probeFnSource})();
|
(${probeFnSource})();
|
||||||
@@ -905,8 +940,8 @@ describe('HLS QA: Configuration and resource management', () => {
|
|||||||
|
|
||||||
it('should vendor HLS bundle locally (not use CDN — avoids CSP blocks)', () => {
|
it('should vendor HLS bundle locally (not use CDN — avoids CSP blocks)', () => {
|
||||||
// Viewer loads hls.js from extension origin via chrome.runtime.getURL('hls.min.js')
|
// Viewer loads hls.js from extension origin via chrome.runtime.getURL('hls.min.js')
|
||||||
// instead of a CDN — sites like Pornhub restrict script-src to 'self', blocking
|
// instead of a CDN — sites that restrict script-src to 'self' block
|
||||||
// cross-origin <script src> injection from CDN URLs.
|
// cross-origin <script src> injection from CDN URLs.
|
||||||
if (!viewerSource.includes("chrome.runtime.getURL('hlds.js')")) {
|
if (!viewerSource.includes("chrome.runtime.getURL('hlds.js')")) {
|
||||||
throw new Error('viewer.js should load hlds.js from extension origin, not CDN');
|
throw new Error('viewer.js should load hlds.js from extension origin, not CDN');
|
||||||
}
|
}
|
||||||
@@ -992,21 +1027,48 @@ describe('HLS QA: iframe and multi-frame scenarios', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should scrape video elements from all frames', () => {
|
it('should scrape video elements from all frames', () => {
|
||||||
const indexSource = fs.readFileSync(
|
const indexSource = fs.readFileSync(
|
||||||
path.join(__dirname, '../src/viewer/index.js'),
|
path.join(__dirname, '../src/viewer/index.js'),
|
||||||
'utf-8'
|
'utf-8'
|
||||||
);
|
);
|
||||||
|
|
||||||
// Verify scrapeTab runs in all frames
|
// Verify scrapeTab runs in all frames
|
||||||
const scrapeMatch = indexSource.match(/scrapeTab[\s\S]*?executeScript[\s\S]*?\}/);
|
const scrapeMatch = indexSource.match(/scrapeTab[\s\S]*?executeScript[\s\S]*?\}/);
|
||||||
if (!scrapeMatch) {
|
if (!scrapeMatch) {
|
||||||
throw new Error('scrapeTab function not found');
|
throw new Error('scrapeTab function not found');
|
||||||
}
|
}
|
||||||
if (!scrapeMatch[0].includes('allFrames: true')) {
|
if (!scrapeMatch[0].includes('allFrames: true')) {
|
||||||
throw new Error('scrapeTab must use allFrames: true');
|
throw new Error('scrapeTab must use allFrames: true');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('deep probe + scrape must run in the MAIN world (see page globals)', () => {
|
||||||
|
// The deep probe and scraper read page globals (window.Hls, player
|
||||||
|
// instances, video._goon_hls_manifest) and window.__goon_plugin_data,
|
||||||
|
// which the main-world interceptors write. The isolated world cannot see
|
||||||
|
// these, so a missing `world: 'MAIN'` makes every main-world strategy find
|
||||||
|
// 0 manifests — the "detects nothing on pages with just <video> elements"
|
||||||
|
// regression. Guard the exact executeScript calls that run each function.
|
||||||
|
const indexSource = fs.readFileSync(
|
||||||
|
path.join(__dirname, '../src/viewer/index.js'),
|
||||||
|
'utf-8'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Capture each call's target + func block (world: 'MAIN' lives on the
|
||||||
|
// target: line, which precedes func:).
|
||||||
|
const probeMatch = indexSource.match(/executeScript\(\{[\s\S]*?func: m3u8DeepProbeFn/);
|
||||||
|
if (!probeMatch) throw new Error('deep probe executeScript call not found');
|
||||||
|
if (!probeMatch[0].includes("world: 'MAIN'")) {
|
||||||
|
throw new Error('deep probe (m3u8DeepProbeFn) must use world: "MAIN" to see page globals');
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrapeMatch = indexSource.match(/executeScript\(\{[\s\S]*?func: scrapeFn/);
|
||||||
|
if (!scrapeMatch) throw new Error('scrape executeScript call not found');
|
||||||
|
if (!scrapeMatch[0].includes("world: 'MAIN'")) {
|
||||||
|
throw new Error('scrape (scrapeFn) must use world: "MAIN" to see page globals');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('should inject interceptors into all frames (content_scripts.all_frames)', () => {
|
it('should inject interceptors into all frames (content_scripts.all_frames)', () => {
|
||||||
const manifestSource = fs.readFileSync(
|
const manifestSource = fs.readFileSync(
|
||||||
@@ -1176,6 +1238,73 @@ describe('HLS QA: Edge cases', () => {
|
|||||||
|
|
||||||
if (intercepted.length !== 1) {
|
if (intercepted.length !== 1) {
|
||||||
throw new Error(`Expected 1 intercept for /manifest URL, got ${intercepted.length}`);
|
throw new Error(`Expected 1 intercept for /manifest URL, got ${intercepted.length}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* ========================================================================== */
|
||||||
|
/* Test: deep probe must not report route-template / nav "manifests" */
|
||||||
|
/* ========================================================================== */
|
||||||
|
|
||||||
|
describe('HLS QA: route-template / nav URL rejection', () => {
|
||||||
|
const probeMatch = indexSource.match(/function m3u8DeepProbeFn\(\)[\s\S]*?^}/m);
|
||||||
|
if (!probeMatch) throw new Error('Could not find m3u8DeepProbeFn in index.js');
|
||||||
|
const probeFnSource = probeMatch[0];
|
||||||
|
|
||||||
|
function runProbeWin(url, globals) {
|
||||||
|
const dom = new JSDOM(
|
||||||
|
'<!DOCTYPE html><html><body><video></video></body></html>',
|
||||||
|
{ url, runScripts: 'dangerously' });
|
||||||
|
const win = dom.window;
|
||||||
|
for (const [k, v] of Object.entries(globals)) win[k] = v;
|
||||||
|
const result = win.eval(`(${probeFnSource})();`);
|
||||||
|
if (!Array.isArray(result)) throw new Error('m3u8DeepProbeFn did not return an array');
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reproduces a provider whose pageContext.langUrls holds server-side route
|
||||||
|
// TEMPLATES / nav links (e.g. /playlists/, /playlist/%1%/video/%2%/) that the
|
||||||
|
// old /playlist|/manifest substring check misclassified as "manifests".
|
||||||
|
const TEMPLATE_URLS = [
|
||||||
|
'https://example.com/playlists/',
|
||||||
|
'https://example.com/playlists/liked/',
|
||||||
|
'https://example.com/playlists/popular/',
|
||||||
|
'https://example.com/playlists/commented/',
|
||||||
|
'https://example.com/playlists/updated/',
|
||||||
|
'https://example.com/playlists/largest/',
|
||||||
|
'https://example.com/playlist/%1%/video/%2%/',
|
||||||
|
'https://example.com/playlists/?category=%DIR%',
|
||||||
|
'https://example.com/members/%ID%/playlists/',
|
||||||
|
'https://example.com/my/playlists/%ID%/',
|
||||||
|
];
|
||||||
|
|
||||||
|
it('route templates / nav links are NOT reported as manifests', () => {
|
||||||
|
const result = runProbeWin('https://example.com/videos/redneck/', {
|
||||||
|
pageContext: { langUrls: TEMPLATE_URLS },
|
||||||
|
kvsplayer: { config: { langUrls: TEMPLATE_URLS } },
|
||||||
|
__goon_plugin_data: { manifests: [], sources: [] },
|
||||||
|
});
|
||||||
|
const urls = result.map(m => m.url);
|
||||||
|
const leaked = TEMPLATE_URLS.filter(u => urls.includes(u));
|
||||||
|
if (leaked.length > 0) {
|
||||||
|
throw new Error(`template/nav URLs leaked as manifests: ${leaked.join(', ')}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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.
|
||||||
|
const real = 'https://cdn.example.com/live/master.m3u8?h=a%2Bb%2Fc&e=1234';
|
||||||
|
const result = runProbeWin('https://example.com/videos/redneck/', {
|
||||||
|
hls: { url: real },
|
||||||
|
pageContext: { langUrls: TEMPLATE_URLS },
|
||||||
|
});
|
||||||
|
if (!result.some(m => m.url === real)) {
|
||||||
|
throw new Error(`real manifest with %2B token was dropped: ${real} — got: ${result.map(m => m.url).join(', ')}`);
|
||||||
|
}
|
||||||
|
// And the templates still must not leak in the same run.
|
||||||
|
const leaked = TEMPLATE_URLS.filter(u => result.some(m => m.url === u));
|
||||||
|
if (leaked.length > 0) {
|
||||||
|
throw new Error(`templates leaked alongside real manifest: ${leaked.join(', ')}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
import { describe, it } from './runner.mjs';
|
||||||
|
import { JSDOM } from 'jsdom';
|
||||||
|
|
||||||
|
const POPUP_HTML = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>VidPlex</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<h1>VidPlex</h1>
|
||||||
|
<section class="instructions-panel" role="region" aria-labelledby="instructions-title">
|
||||||
|
<div class="instructions-header">
|
||||||
|
<div>
|
||||||
|
<p class="instructions-kicker">Quick start</p>
|
||||||
|
<h2 id="instructions-title">How to use VidPlex</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
id="instructions-toggle"
|
||||||
|
type="button"
|
||||||
|
class="instructions-toggle"
|
||||||
|
aria-expanded="true"
|
||||||
|
aria-controls="instructions-body"
|
||||||
|
>
|
||||||
|
Hide instructions
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="instructions-body" class="instructions-body">
|
||||||
|
<ol class="instructions-list">
|
||||||
|
<li>
|
||||||
|
<strong>Scan videos</strong>
|
||||||
|
<span>Click <strong>Find Videos</strong> to search the current browser window for videos.</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Review the list</strong>
|
||||||
|
<span>Check the popup list; each found video URL is clickable.</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Open the viewer</strong>
|
||||||
|
<span>Click <strong>Open Viewer</strong> to open the 2x2 grid player in a new window.</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Reset and rescan</strong>
|
||||||
|
<span>Click <strong>Clear</strong> to erase results and scan again.</span>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
<p class="instructions-note">Warning: Browser pages, extension pages, and restricted frames can hide videos, so results may be incomplete.</p>
|
||||||
|
<p class="instructions-learn-more">Learn more tooltip: Read the VidPlex README and audit notes.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<button id="find-btn">Find Videos (all tabs)</button>
|
||||||
|
<div id="video-list" class="video-list" style="display:none;"></div>
|
||||||
|
<div id="status" class="status" style="min-height:16px;"></div>
|
||||||
|
<div id="btn-row" class="btn-row" style="display:none;">
|
||||||
|
<button id="clear-btn" class="btn-secondary">Clear</button>
|
||||||
|
<button id="open-btn">Open Viewer</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
function installPopupDom() {
|
||||||
|
const dom = new JSDOM(POPUP_HTML, { url: 'chrome-extension://test/src/viewer/index.html' });
|
||||||
|
const previous = {
|
||||||
|
window: globalThis.window,
|
||||||
|
document: globalThis.document,
|
||||||
|
navigator: globalThis.navigator,
|
||||||
|
chrome: globalThis.chrome,
|
||||||
|
HTMLElement: globalThis.HTMLElement,
|
||||||
|
Node: globalThis.Node,
|
||||||
|
};
|
||||||
|
|
||||||
|
globalThis.window = dom.window;
|
||||||
|
globalThis.document = dom.window.document;
|
||||||
|
Object.defineProperty(globalThis, 'navigator', {
|
||||||
|
value: dom.window.navigator,
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, 'HTMLElement', {
|
||||||
|
value: dom.window.HTMLElement,
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, 'Node', {
|
||||||
|
value: dom.window.Node,
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
globalThis.chrome = {
|
||||||
|
storage: {
|
||||||
|
local: {
|
||||||
|
get: async () => ({}),
|
||||||
|
set: async () => undefined,
|
||||||
|
remove: async () => undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tabs: { query: async () => [] },
|
||||||
|
scripting: { executeScript: async () => [] },
|
||||||
|
runtime: { getURL: (path) => `chrome-extension://test/${path}` },
|
||||||
|
windows: { create: async () => ({ id: 1 }) },
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
dom,
|
||||||
|
cleanup() {
|
||||||
|
dom.window.close();
|
||||||
|
globalThis.window = previous.window;
|
||||||
|
globalThis.document = previous.document;
|
||||||
|
Object.defineProperty(globalThis, 'navigator', {
|
||||||
|
value: previous.navigator,
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
globalThis.chrome = previous.chrome;
|
||||||
|
Object.defineProperty(globalThis, 'HTMLElement', {
|
||||||
|
value: previous.HTMLElement,
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, 'Node', {
|
||||||
|
value: previous.Node,
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mountPopupWithStorage(storageGet = async () => ({}), captureWrite = null) {
|
||||||
|
const { dom, cleanup } = installPopupDom();
|
||||||
|
// Override the chrome.storage.local stub so we can seed a value and capture writes.
|
||||||
|
const writes = [];
|
||||||
|
globalThis.chrome.storage.local.get = async (...args) => {
|
||||||
|
const res = await storageGet(...args);
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
globalThis.chrome.storage.local.set = async (items) => {
|
||||||
|
if (captureWrite) captureWrite(items); else writes.push(items);
|
||||||
|
};
|
||||||
|
const moduleUrl = new URL('../src/viewer/index.js?test=' + Date.now(), import.meta.url);
|
||||||
|
await import(moduleUrl.href);
|
||||||
|
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
return { dom, cleanup, writes, flush };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Viewer popup instructions', () => {
|
||||||
|
it('renders the drafted instructions copy on mount with accessible labeling', async () => {
|
||||||
|
const { dom, cleanup } = await mountPopupWithStorage();
|
||||||
|
try {
|
||||||
|
const doc = dom.window.document;
|
||||||
|
const region = doc.querySelector('.instructions-panel');
|
||||||
|
const title = doc.getElementById('instructions-title');
|
||||||
|
const toggle = doc.getElementById('instructions-toggle');
|
||||||
|
const body = doc.getElementById('instructions-body');
|
||||||
|
|
||||||
|
if (!region) throw new Error('Instructions region missing');
|
||||||
|
if (region.getAttribute('role') !== 'region') throw new Error('Instructions region missing role="region"');
|
||||||
|
if (region.getAttribute('aria-labelledby') !== 'instructions-title') throw new Error('Instructions region missing aria-labelledby');
|
||||||
|
if (!title || title.textContent !== 'How to use VidPlex') throw new Error('Instructions title not rendered');
|
||||||
|
if (!toggle) throw new Error('Instructions toggle missing');
|
||||||
|
if (toggle.getAttribute('aria-controls') !== 'instructions-body') throw new Error('Toggle missing aria-controls');
|
||||||
|
if (toggle.getAttribute('aria-expanded') !== 'true') throw new Error('Toggle should start expanded');
|
||||||
|
if (!body || body.hidden) throw new Error('Instructions body should be visible on mount');
|
||||||
|
|
||||||
|
const expectedTexts = [
|
||||||
|
'Scan videos',
|
||||||
|
'Click Find Videos to search the current browser window for videos.',
|
||||||
|
'Review the list',
|
||||||
|
'Check the popup list; each found video URL is clickable.',
|
||||||
|
'Open the viewer',
|
||||||
|
'Click Open Viewer to open the 2x2 grid player in a new window.',
|
||||||
|
'Reset and rescan',
|
||||||
|
'Click Clear to erase results and scan again.',
|
||||||
|
'Warning: Browser pages, extension pages, and restricted frames can hide videos, so results may be incomplete.',
|
||||||
|
'Learn more tooltip: Read the VidPlex README and audit notes.',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const text of expectedTexts) {
|
||||||
|
if (!doc.body.textContent.includes(text)) {
|
||||||
|
throw new Error(`Missing instructions copy: ${text}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggles the instructions panel open and closed', async () => {
|
||||||
|
const { dom, cleanup } = await mountPopupWithStorage();
|
||||||
|
try {
|
||||||
|
const doc = dom.window.document;
|
||||||
|
const toggle = doc.getElementById('instructions-toggle');
|
||||||
|
const body = doc.getElementById('instructions-body');
|
||||||
|
|
||||||
|
if (!toggle || !body) throw new Error('Instructions controls missing');
|
||||||
|
|
||||||
|
toggle.click();
|
||||||
|
if (toggle.getAttribute('aria-expanded') !== 'false') throw new Error('Toggle should collapse the instructions');
|
||||||
|
if (!body.hidden) throw new Error('Instructions body should be hidden after collapse');
|
||||||
|
if (toggle.textContent !== 'Show instructions') throw new Error('Collapsed label should switch to Show instructions');
|
||||||
|
|
||||||
|
toggle.click();
|
||||||
|
if (toggle.getAttribute('aria-expanded') !== 'true') throw new Error('Toggle should expand the instructions');
|
||||||
|
if (body.hidden) throw new Error('Instructions body should be visible after expanding');
|
||||||
|
if (toggle.textContent !== 'Hide instructions') throw new Error('Expanded label should switch back to Hide instructions');
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honors a stored hidden state on mount', async () => {
|
||||||
|
const { dom, cleanup } = await mountPopupWithStorage(
|
||||||
|
async () => ({ goon_instructions_visible: false })
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const doc = dom.window.document;
|
||||||
|
const toggle = doc.getElementById('instructions-toggle');
|
||||||
|
const body = doc.getElementById('instructions-body');
|
||||||
|
// Wait for the async storage.get() to resolve and apply.
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
if (toggle.getAttribute('aria-expanded') !== 'false') throw new Error('Stored hidden state should start collapsed');
|
||||||
|
if (!body.hidden) throw new Error('Stored hidden state should start hidden');
|
||||||
|
if (toggle.textContent !== 'Show instructions') throw new Error('Stored hidden state should show the "Show instructions" label');
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists the new visibility on every user toggle', async () => {
|
||||||
|
let written = null;
|
||||||
|
const { dom, cleanup, flush } = await mountPopupWithStorage(
|
||||||
|
async () => ({}),
|
||||||
|
(items) => { written = items; }
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const doc = dom.window.document;
|
||||||
|
const toggle = doc.getElementById('instructions-toggle');
|
||||||
|
// Collapse -> must persist false.
|
||||||
|
toggle.click();
|
||||||
|
await flush();
|
||||||
|
if (written === null) throw new Error('Collapsing the panel must write to chrome.storage.local');
|
||||||
|
if (written.goon_instructions_visible !== false) throw new Error('Collapsing must persist goon_instructions_visible=false');
|
||||||
|
// Expand -> must persist true.
|
||||||
|
toggle.click();
|
||||||
|
await flush();
|
||||||
|
if (written.goon_instructions_visible !== true) throw new Error('Expanding must persist goon_instructions_visible=true');
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults a first-time user (no stored key) to shown', async () => {
|
||||||
|
const { dom, cleanup } = await mountPopupWithStorage(async () => ({}));
|
||||||
|
try {
|
||||||
|
const doc = dom.window.document;
|
||||||
|
const toggle = doc.getElementById('instructions-toggle');
|
||||||
|
const body = doc.getElementById('instructions-body');
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
if (toggle.getAttribute('aria-expanded') !== 'true') throw new Error('First-time user should start expanded');
|
||||||
|
if (body.hidden) throw new Error('First-time instructions body should be visible on mount');
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
/**
|
||||||
|
* referer_proxy.test.mjs — coverage for the background worker's per-CDN-host
|
||||||
|
* referer capture + DNR proxy-host learning, added in PR #5.
|
||||||
|
*
|
||||||
|
* The background worker is not itself a module, so the test transpiles it into
|
||||||
|
* one (wrapping + top-level-statement `async function` transpile) under a mocked
|
||||||
|
* `chrome` + `fetch`/`btoa` environment, then exercises the captured listeners
|
||||||
|
* and the message handler directly.
|
||||||
|
*
|
||||||
|
* Covers the three gaps robot flagged:
|
||||||
|
* (a) closing a tab must NOT wipe the host-keyed `goon_referers` global store,
|
||||||
|
* (b) two hosts proxied concurrently must each keep their OWN referer (no race
|
||||||
|
* that leaves host B's referer live for host A -> 412),
|
||||||
|
* (c) the new referer-capture / proxy-host-learning / onRemoved paths run.
|
||||||
|
*/
|
||||||
|
import { describe, it } from './runner.mjs';
|
||||||
|
import { readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const backgroundSource = readFileSync(join(__dirname, '../src/background/background.js'), 'utf8');
|
||||||
|
|
||||||
|
// Transpile the top-level-statement worker into an ES module. Wrapping in an
|
||||||
|
// async IIFE makes its top-level `async function` declarations + `await`-using
|
||||||
|
// nested functions legal inside a module.
|
||||||
|
function buildModule(code) {
|
||||||
|
return `(async () => {\n${code}\n})();\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A fresh mock of the chrome APIs the worker touches. `onBeforeRequest` and
|
||||||
|
// `tabs.onRemoved` capture their listener, and `tabs.get` serves the source
|
||||||
|
// tab's URL (the captured referer); `declarativeNetRequest.updateSessionRules`
|
||||||
|
// records the live rules.
|
||||||
|
function makeChromeEnv() {
|
||||||
|
const storage = {};
|
||||||
|
const handlers = { onMessage: [] };
|
||||||
|
const tabUrls = {};
|
||||||
|
let sessionRules = {};
|
||||||
|
|
||||||
|
const webRequest = {
|
||||||
|
onBeforeRequest: { addListener: (cb) => { handlers.onBeforeRequest = cb; } },
|
||||||
|
};
|
||||||
|
|
||||||
|
const chrome = {
|
||||||
|
storage: {
|
||||||
|
local: {
|
||||||
|
get: async (keys) => {
|
||||||
|
const out = {};
|
||||||
|
const arr = Array.isArray(keys) ? keys : (keys === undefined ? Object.keys(storage) : [keys]);
|
||||||
|
for (const k of arr) if (k in storage) out[k] = storage[k];
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
set: async (data) => {
|
||||||
|
for (const [k, v] of Object.entries(data)) storage[k] = v;
|
||||||
|
},
|
||||||
|
remove: async (keys) => {
|
||||||
|
const arr = Array.isArray(keys) ? keys : [keys];
|
||||||
|
for (const k of arr) delete storage[k];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
webRequest,
|
||||||
|
tabs: {
|
||||||
|
get: async (tabId) => tabUrls[tabId] ? { tabId, url: tabUrls[tabId] } : undefined,
|
||||||
|
onRemoved: { addListener: (cb) => { handlers.onRemoved = cb; } },
|
||||||
|
},
|
||||||
|
runtime: { onMessage: { addListener: (cb) => { handlers.onMessage.push(cb); } } },
|
||||||
|
declarativeNetRequest: {
|
||||||
|
updateSessionRules: async ({ removeRuleIds = [], addRules = [] } = {}) => {
|
||||||
|
const rm = new Set(removeRuleIds);
|
||||||
|
sessionRules = Object.fromEntries(
|
||||||
|
Object.entries(sessionRules).filter(([id]) => !rm.has(id))
|
||||||
|
);
|
||||||
|
for (const r of addRules) sessionRules[r.id] = r;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return { chrome, handlers, storage, tabUrls,
|
||||||
|
setTabUrl: (tabId, url) => { tabUrls[tabId] = url; },
|
||||||
|
getSessionRules: () => sessionRules };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the worker into a fresh environment. `data:` URLs are cached by Node, so
|
||||||
|
// the module body (listener registration) would only run once; instead we write
|
||||||
|
// a uniquely-named temp file per load so each call registers into its own env.
|
||||||
|
let loadCounter = 0;
|
||||||
|
async function loadBackground(env) {
|
||||||
|
global.chrome = env.chrome;
|
||||||
|
const file = join(tmpdir(), `goon_bg_${process.pid}_${loadCounter++}.mjs`);
|
||||||
|
writeFileSync(file, buildModule(backgroundSource));
|
||||||
|
const module = await import(pathToFileURL(file).href);
|
||||||
|
await module.default;
|
||||||
|
return module;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drive a referer capture the way the worker now does it: the worker reads the
|
||||||
|
// source tab's URL (via chrome.tabs.get) as the provider referer, so we set the
|
||||||
|
// tab's URL and fire an onBeforeRequest for a video URL on that tab.
|
||||||
|
async function capture(env, videoUrl, referer) {
|
||||||
|
env.setTabUrl(11, referer);
|
||||||
|
await env.handlers.onBeforeRequest({ tabId: 11, url: videoUrl });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('PR#5: per-host referer capture + proxy race', () => {
|
||||||
|
// (a) Blocking fix: a host-keyed `goon_referers` entry must outlive the close
|
||||||
|
// of ANY tab — including a different tab.
|
||||||
|
it('closing a tab does not wipe goon_referers captured for another host', async () => {
|
||||||
|
const env = makeChromeEnv();
|
||||||
|
global.chrome = env.chrome;
|
||||||
|
await loadBackground(env);
|
||||||
|
|
||||||
|
// A live source tab captures the provider referer for its CDN host.
|
||||||
|
await capture(env, 'https://cdnA.example.com/hls/live/seg-1.ts', 'https://pageA.example.com/watch');
|
||||||
|
|
||||||
|
const rA = env.storage['goon_referers']?.['example.com'];
|
||||||
|
if (!rA) throw new Error('referer was not captured for cdnA');
|
||||||
|
if (rA.referer !== 'https://pageA.example.com/watch') {
|
||||||
|
throw new Error('captured referer wrong: ' + JSON.stringify(rA));
|
||||||
|
}
|
||||||
|
|
||||||
|
// A DIFFERENT tab closes. The per-tab cleanup must NOT remove goon_referers.
|
||||||
|
await env.handlers.onRemoved(22);
|
||||||
|
|
||||||
|
const after = env.storage['goon_referers'];
|
||||||
|
if (!after || !after['example.com']) {
|
||||||
|
throw new Error('closing tab 22 wiped goon_referers — host A referer lost');
|
||||||
|
}
|
||||||
|
if (after['example.com'].referer !== 'https://pageA.example.com/watch') {
|
||||||
|
throw new Error('closing tab 22 corrupted the host A referer: ' + JSON.stringify(after));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closing the capturing tab itself preserves the referer for later proxy use', async () => {
|
||||||
|
const env = makeChromeEnv();
|
||||||
|
global.chrome = env.chrome;
|
||||||
|
await loadBackground(env);
|
||||||
|
|
||||||
|
await capture(env, 'https://cdnB.example.com/video/x/master.m3u8', 'https://pageB.example.com/v');
|
||||||
|
|
||||||
|
// The very tab that captured the referer now closes.
|
||||||
|
await env.handlers.onRemoved(11);
|
||||||
|
|
||||||
|
const after = env.storage['goon_referers']?.['example.com'];
|
||||||
|
if (!after) throw new Error('goon_referers for the capturing host was wiped on its own tab close');
|
||||||
|
if (after.referer !== 'https://pageB.example.com/v') {
|
||||||
|
throw new Error('capturing-tab close corrupted referer: ' + JSON.stringify(after));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// (b) Should-fix + coverage: two hosts proxied concurrently must each keep
|
||||||
|
// their own referer. With a shared rule id + non-atomic install this regressed;
|
||||||
|
// per-host rule ids + the per-host lock keep them distinct.
|
||||||
|
it('concurrent proxy fetches for two distinct registrable domains keep distinct referer rules', async () => {
|
||||||
|
const env = makeChromeEnv();
|
||||||
|
let fetchCalls = 0;
|
||||||
|
global.chrome = env.chrome;
|
||||||
|
global.fetch = async (url) => {
|
||||||
|
fetchCalls++;
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
headers: { get: () => 'application/vnd.apple.mpegurl' },
|
||||||
|
arrayBuffer: async () => new Uint8Array([0]).buffer,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
await loadBackground(env);
|
||||||
|
|
||||||
|
// Two GOON_PROXY_FETCH messages for different CDN hosts, fired together.
|
||||||
|
const handler = env.handlers.onMessage[env.handlers.onMessage.length - 1];
|
||||||
|
let respA, respB;
|
||||||
|
handler({ type: 'GOON_PROXY_FETCH', url: 'https://cdnA.example.com/hls/live/seg-1.ts', referer: 'https://pageA.example.com/watch' }, {}, (r) => { respA = r; });
|
||||||
|
handler({ type: 'GOON_PROXY_FETCH', url: 'https://cdnB.other.com/hls/live/seg-1.ts', referer: 'https://pageB.other.com/watch' }, {}, (r) => { respB = r; });
|
||||||
|
|
||||||
|
await new Promise(r => setTimeout(r, 30)); // let async sends + installs flush
|
||||||
|
|
||||||
|
if (!respA || !respA.ok) throw new Error('proxy A failed: ' + JSON.stringify(respA));
|
||||||
|
if (!respB || !respB.ok) throw new Error('proxy B failed: ' + JSON.stringify(respB));
|
||||||
|
if (fetchCalls < 2) throw new Error('expected both proxy fetches to run, got ' + fetchCalls);
|
||||||
|
|
||||||
|
// Each host must have its OWN installed DNR rule with its OWN referer —
|
||||||
|
// host A must NOT be served host B's referer (the 412 regression).
|
||||||
|
const rules = env.getSessionRules();
|
||||||
|
const ruleA = Object.values(rules).find(r => r.condition?.requestDomains?.includes('example.com'));
|
||||||
|
const ruleB = Object.values(rules).find(r => r.condition?.requestDomains?.includes('other.com'));
|
||||||
|
if (!ruleA) throw new Error('no DNR rule installed for cdna; rules=' + JSON.stringify(rules));
|
||||||
|
if (!ruleB) throw new Error('no DNR rule installed for cdnb; rules=' + JSON.stringify(rules));
|
||||||
|
if (ruleA.id === ruleB.id) {
|
||||||
|
throw new Error('cdnA and cdnB shared a DNR rule id -> concurrent race possible');
|
||||||
|
}
|
||||||
|
const refA = ruleA.action.requestHeaders?.find(h => h.header.toLowerCase() === 'referer')?.value;
|
||||||
|
const refB = ruleB.action.requestHeaders?.find(h => h.header.toLowerCase() === 'referer')?.value;
|
||||||
|
if (refA !== 'https://pageA.example.com/watch') {
|
||||||
|
throw new Error('cdnA served wrong referer: ' + refA);
|
||||||
|
}
|
||||||
|
if (refB !== 'https://pageB.other.com/watch') {
|
||||||
|
throw new Error('cdnB served wrong referer: ' + refB);
|
||||||
|
}
|
||||||
|
if (refA === refB) throw new Error('both hosts got the same referer — cross-host bleed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('repeated proxy for the same host is idempotent (one rule, one install)', async () => {
|
||||||
|
const env = makeChromeEnv();
|
||||||
|
let installs = 0;
|
||||||
|
env.chrome.declarativeNetRequest.updateSessionRules = async ({ addRules = [] } = {}) => {
|
||||||
|
for (const r of addRules) installs++;
|
||||||
|
};
|
||||||
|
global.chrome = env.chrome;
|
||||||
|
global.fetch = async () => ({
|
||||||
|
ok: true, status: 200,
|
||||||
|
headers: { get: () => 'application/vnd.apple.mpegurl' },
|
||||||
|
arrayBuffer: async () => new Uint8Array([0]).buffer,
|
||||||
|
});
|
||||||
|
|
||||||
|
await loadBackground(env);
|
||||||
|
const handler = env.handlers.onMessage[env.handlers.onMessage.length - 1];
|
||||||
|
|
||||||
|
const run = async (n) => {
|
||||||
|
handler(
|
||||||
|
{ type: 'GOON_PROXY_FETCH', url: 'https://cdnA.example.com/hls/live/seg-1.ts', referer: 'https://pageA.example.com/watch' },
|
||||||
|
{},
|
||||||
|
() => {}
|
||||||
|
);
|
||||||
|
await new Promise(r => setTimeout(r, 5));
|
||||||
|
};
|
||||||
|
await run(1);
|
||||||
|
const installsAfterFirst = installs;
|
||||||
|
await run(2);
|
||||||
|
await run(3);
|
||||||
|
|
||||||
|
// Same host + same referer should not re-add the rule on repeat fetches.
|
||||||
|
if (installs !== installsAfterFirst) {
|
||||||
|
throw new Error(`expected idempotent install, but re-added rule ${installs - installsAfterFirst} extra time(s)`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// (c) Domain generalization — the core 412 fix. A provider referer captured for
|
||||||
|
// ONE edge subdomain must cover a SIBLING subdomain: the DNR rule is scoped to
|
||||||
|
// the registrable domain and the store is domain-keyed, so a proxy for ev-h
|
||||||
|
// needs no per-subdomain capture.
|
||||||
|
it('a proxy for a sibling subdomain reuses the captured referer via the registrable domain', async () => {
|
||||||
|
const env = makeChromeEnv();
|
||||||
|
global.chrome = env.chrome;
|
||||||
|
global.fetch = async () => ({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
headers: { get: () => 'application/vnd.apple.mpegurl' },
|
||||||
|
arrayBuffer: async () => new Uint8Array([0]).buffer,
|
||||||
|
});
|
||||||
|
await loadBackground(env);
|
||||||
|
|
||||||
|
// Capture the provider referer for hv-h.example.net (one edge subdomain).
|
||||||
|
await capture(env, 'https://hv-h.example.net/hls/live/seg-1.ts', 'https://provider.example.com/watch');
|
||||||
|
|
||||||
|
// Proxy a SIBLING subdomain ev-h.example.net with NO referer in the message:
|
||||||
|
// the SW must resolve it from the domain-keyed store.
|
||||||
|
const handler = env.handlers.onMessage[env.handlers.onMessage.length - 1];
|
||||||
|
let resp;
|
||||||
|
handler({ type: 'GOON_PROXY_FETCH', url: 'https://ev-h.example.net/hls/live/seg-1.ts' }, {}, (r) => { resp = r; });
|
||||||
|
await new Promise(r => setTimeout(r, 20));
|
||||||
|
|
||||||
|
if (!resp || !resp.ok) throw new Error('sibling proxy failed: ' + JSON.stringify(resp));
|
||||||
|
const rules = env.getSessionRules();
|
||||||
|
const rule = Object.values(rules).find(r => r.condition?.requestDomains?.includes('example.net'));
|
||||||
|
if (!rule) throw new Error('no domain-scoped DNR rule for example.net; rules=' + JSON.stringify(rules));
|
||||||
|
const ref = rule.action.requestHeaders?.find(h => h.header.toLowerCase() === 'referer')?.value;
|
||||||
|
if (ref !== 'https://provider.example.com/watch') {
|
||||||
|
throw new Error('sibling edge served wrong referer: ' + ref);
|
||||||
|
}
|
||||||
|
// The rule is scoped to the registrable domain, NOT the exact subdomain.
|
||||||
|
if (rule.condition.requestDomains?.includes('ev-h.example.net')) {
|
||||||
|
throw new Error('rule scoped to exact subdomain instead of registrable domain');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// (d) With no captured referer and none in the message, install NO rule: the
|
||||||
|
// self-origin fallback (guaranteed-wrong for hotlink protection) is dropped.
|
||||||
|
it('no captured referer and no message referer installs no DNR rule', async () => {
|
||||||
|
const env = makeChromeEnv();
|
||||||
|
let fetchCalls = 0;
|
||||||
|
global.chrome = env.chrome;
|
||||||
|
global.fetch = async () => {
|
||||||
|
fetchCalls++;
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
headers: { get: () => 'application/vnd.apple.mpegurl' },
|
||||||
|
arrayBuffer: async () => new Uint8Array([0]).buffer,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
await loadBackground(env);
|
||||||
|
const handler = env.handlers.onMessage[env.handlers.onMessage.length - 1];
|
||||||
|
let resp;
|
||||||
|
handler({ type: 'GOON_PROXY_FETCH', url: 'https://cdnA.example.com/hls/live/seg-1.ts' }, {}, (r) => { resp = r; });
|
||||||
|
await new Promise(r => setTimeout(r, 20));
|
||||||
|
|
||||||
|
if (!resp || !resp.ok) throw new Error('proxy fetch failed: ' + JSON.stringify(resp));
|
||||||
|
if (fetchCalls < 1) throw new Error('proxy fetch did not run');
|
||||||
|
const rules = env.getSessionRules();
|
||||||
|
if (Object.keys(rules).length !== 0) {
|
||||||
|
throw new Error('installed a rule with no referer (self-origin fallback not dropped): ' + JSON.stringify(rules));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// (e) GOON_GET_REFERERS (used by scan-time capture) resolves each manifest host
|
||||||
|
// via the domain-keyed store: a sibling subdomain gets the provider referer,
|
||||||
|
// and an uncaptured host is omitted.
|
||||||
|
it('GOON_GET_REFERERS resolves manifest hosts through the domain-keyed store', async () => {
|
||||||
|
const env = makeChromeEnv();
|
||||||
|
global.chrome = env.chrome;
|
||||||
|
await loadBackground(env);
|
||||||
|
|
||||||
|
// Capture for hv-h.example.net only.
|
||||||
|
await capture(env, 'https://hv-h.example.net/hls/live/seg-1.ts', 'https://provider.example.com/watch');
|
||||||
|
|
||||||
|
const handler = env.handlers.onMessage[env.handlers.onMessage.length - 1];
|
||||||
|
let resp;
|
||||||
|
handler(
|
||||||
|
{ type: 'GOON_GET_REFERERS', hosts: ['ev-h.example.net', 'hv-h.example.net', 'cdnA.example.com'] },
|
||||||
|
{},
|
||||||
|
(r) => { resp = r; });
|
||||||
|
await new Promise(r => setTimeout(r, 15));
|
||||||
|
|
||||||
|
if (!resp || !resp.ok) throw new Error('GOON_GET_REFERERS failed: ' + JSON.stringify(resp));
|
||||||
|
const referers = resp.referers || {};
|
||||||
|
if (referers['ev-h.example.net'] !== 'https://provider.example.com/watch') {
|
||||||
|
throw new Error('sibling subdomain did not resolve the captured referer: ' + JSON.stringify(referers));
|
||||||
|
}
|
||||||
|
if (referers['hv-h.example.net'] !== 'https://provider.example.com/watch') {
|
||||||
|
throw new Error('captured subdomain did not resolve its own referer: ' + JSON.stringify(referers));
|
||||||
|
}
|
||||||
|
if ('cdnA.example.com' in referers) {
|
||||||
|
throw new Error('uncaptured host should be omitted from referers: ' + JSON.stringify(referers));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Regression for the live 412 bug: the worker captures the provider referer
|
||||||
|
// from the source tab's URL via chrome.tabs.get inside onBeforeRequest. The
|
||||||
|
// old path read request headers via onBeforeSendHeaders + extraInfoSpec, which
|
||||||
|
// is rejected by some Chromium builds and fails the whole service worker, so it
|
||||||
|
// was removed; this asserts the new tab-URL capture is wired up and actually
|
||||||
|
// stores a referer.
|
||||||
|
it('the onBeforeRequest capture reads the referer from the source tab URL', async () => {
|
||||||
|
const env = makeChromeEnv();
|
||||||
|
global.chrome = env.chrome;
|
||||||
|
await loadBackground(env);
|
||||||
|
|
||||||
|
const cap = env.handlers.onBeforeRequest;
|
||||||
|
if (!cap) throw new Error('onBeforeRequest listener was not registered');
|
||||||
|
|
||||||
|
// A video request for cdnA from a tab whose URL IS the provider referer.
|
||||||
|
env.setTabUrl(11, 'https://pageA.example.com/watch');
|
||||||
|
await cap({ tabId: 11, url: 'https://cdnA.example.com/hls/live/seg-1.ts' });
|
||||||
|
|
||||||
|
const rA = env.storage['goon_referers']?.['example.com'];
|
||||||
|
if (!rA) throw new Error('referer was not captured from the source tab URL');
|
||||||
|
if (rA.referer !== 'https://pageA.example.com/watch') {
|
||||||
|
throw new Error('captured referer wrong: ' + JSON.stringify(rA));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,10 +3,13 @@
|
|||||||
*/
|
*/
|
||||||
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 { run } from './runner.mjs';
|
import { run } from './runner.mjs';
|
||||||
|
|
||||||
const result = await run();
|
const result = await run();
|
||||||
|
|||||||
@@ -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)'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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 |
@@ -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.
|
||||||
@@ -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.
|
||||||
+10
-10
@@ -1,15 +1,15 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "Goon Plugin",
|
"name": "VidPlex",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"description": "A Chrome extension",
|
"description": "Find videos on any page and view them in a grid.",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"storage",
|
"storage",
|
||||||
"tabs",
|
"tabs",
|
||||||
"scripting",
|
"scripting",
|
||||||
"webRequest",
|
"webRequest",
|
||||||
"declarativeNetRequest"
|
"declarativeNetRequest"
|
||||||
],
|
],
|
||||||
"host_permissions": [
|
"host_permissions": [
|
||||||
"<all_urls>"
|
"<all_urls>"
|
||||||
],
|
],
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "goon-plugin",
|
"name": "vidplex",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "goon-plugin",
|
"name": "vidplex",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"ajv": "^8.17.1",
|
"ajv": "^8.17.1",
|
||||||
|
|||||||
+5
-3
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "goon-plugin",
|
"name": "vidplex",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Chrome extension scaffold",
|
"description": "Chrome extension: find videos on a page and view them in a grid.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -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": {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+409
-250
@@ -23,13 +23,12 @@ function isHlsManifest(url) {
|
|||||||
u.includes('/playlist.m3u') || u.includes('/manifest');
|
u.includes('/playlist.m3u') || u.includes('/manifest');
|
||||||
}
|
}
|
||||||
|
|
||||||
// === STRATEGY 8: Network-level fallback — log ALL phncdn.com and video-related requests ===
|
// === STRATEGY 8: Network-level fallback — log video requests across any CDN ===
|
||||||
// Pornhub's es6player generates HLS manifests in-memory with zero .m3u8 network requests.
|
// Some players generate HLS manifests in-memory and make zero .m3u8 network
|
||||||
// However, the player still fetches video segments from phncdn.com. By logging all phncdn
|
// requests, only fetching video segments from a CDN. By logging every video-like
|
||||||
// requests we can: (a) confirm HLS activity via segment patterns, (b) reconstruct manifest
|
// request (any host) we can: (a) confirm HLS activity via segment patterns,
|
||||||
// URLs from segment paths, (c) detect CDN response headers that reveal playlist endpoints.
|
// (b) reconstruct manifest URLs from segment paths, (c) detect CDN response
|
||||||
|
// headers that reveal playlist endpoints. No site-specific host list.
|
||||||
const PHNCDN_HOSTS = ['phncdn.com', 'pornhubpremium.com', 'cdn.pornhub.com'];
|
|
||||||
const VIDEO_PATH_PATTERNS = ['/hls/', '/dash/', '/video/', '/segment', '/media/', '/stream'];
|
const VIDEO_PATH_PATTERNS = ['/hls/', '/dash/', '/video/', '/segment', '/media/', '/stream'];
|
||||||
|
|
||||||
function isVideoRequest(url) {
|
function isVideoRequest(url) {
|
||||||
@@ -47,7 +46,7 @@ async function persistVideoRequest(tabId, url) {
|
|||||||
try {
|
try {
|
||||||
const stored = await chrome.storage.local.get(key);
|
const stored = await chrome.storage.local.get(key);
|
||||||
const arr = stored[key] || [];
|
const arr = stored[key] || [];
|
||||||
// Avoid duplicates — phncdn segments come in rapid succession
|
// Avoid duplicates — video segments come in rapid succession
|
||||||
if (!arr.some(r => r === url)) {
|
if (!arr.some(r => r === url)) {
|
||||||
arr.push(url);
|
arr.push(url);
|
||||||
// Keep last 200 requests per tab (enough to detect segment patterns)
|
// Keep last 200 requests per tab (enough to detect segment patterns)
|
||||||
@@ -55,30 +54,32 @@ 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- phncdn token / URL helpers ------------------------------------------
|
// --- Token / URL helpers ------------------------------------------
|
||||||
//
|
//
|
||||||
// The phncdn auth token (h= / hash= / hdnea=) is base64 and its %-encoded
|
// A media CDN's auth token (h= / hash= / hdnea=) is base64 and its %-encoded
|
||||||
// escapes are significant: a `+` decoded to a space corrupts it, which is what
|
// escapes are significant: a `+` decoded to a space corrupts it, which is what
|
||||||
// produces the 410 Gone the live player avoids by sending `%2B`. So every
|
// produces the 410 Gone the live player avoids by sending `%2B`. So every
|
||||||
// helper below keeps the query string RAW — it is never passed through
|
// helper below keeps the query string RAW — it is never passed through
|
||||||
// URLSearchParams (which decodes + -> space).
|
// URLSearchParams (which decodes + -> space).
|
||||||
|
|
||||||
// Extract the phncdn videoId from a /hls/videos/ URL; '' if it isn't one.
|
// Group key for a video's URL: the path directory holding its variant/segment
|
||||||
// Path shape: /hls/videos/{year}/{subdir}/{videoId}/{variant}/...
|
// (the last non-file path segment of a CDN URL). Used to match the freshest
|
||||||
function extractVideoId(url) {
|
// live URL by video, host-independent. '' when the URL is malformed.
|
||||||
if (!url || !url.includes('/hls/videos/')) return '';
|
function extractGroupKey(url) {
|
||||||
|
if (!url) return '';
|
||||||
try {
|
try {
|
||||||
const path = new URL(url).pathname.split('/');
|
const path = new URL(url).pathname.split('/');
|
||||||
const hlsIdx = path.findIndex(p => p === 'hls');
|
// Drop the final (file/segment or manifest) segment; the remaining
|
||||||
if (hlsIdx === -1 || hlsIdx + 4 >= path.length) return '';
|
// directory joins into a key that is stable across a video's variants.
|
||||||
return path[hlsIdx + 4];
|
path.pop();
|
||||||
} catch {
|
return path.join('/');
|
||||||
|
} catch {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Raw query (everything after '?') with no decoding. '' when absent.
|
// Raw query (everything after '?') with no decoding. '' when absent.
|
||||||
@@ -99,31 +100,24 @@ function tokenExpirySeconds(rawQuery) {
|
|||||||
return secs;
|
return secs;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build a manifest URL for a variant directory from a raw phncdn URL,
|
// Build the parent manifest URL for a segment URL, keeping the FULL path to
|
||||||
// preserving the FULL variant path and the raw query (token encoding intact).
|
// the variant directory and the RAW query (token encoding intact). Swaps the
|
||||||
// /hls/videos/{year}/{subid}/{videoId}/{variant}/seg-1.ts ->
|
// segment filename for a manifest filename in the same directory:
|
||||||
// /hls/videos/{year}/{subid}/{videoId}/{variant}/master.m3u8
|
// {host}/path/to/variant/seg-1.ts -> {host}/path/to/variant/master.m3u8
|
||||||
// The variant directory (e.g. 720P_4000K_338337051.mp4) is MANDATORY — a master
|
// The variant directory is MANDATORY — a master playlist lives inside it;
|
||||||
// playlist lives inside it; dropping it (the old bug) yields a 410. Returns ''
|
// dropping it yields a 404/410. Returns '' for non-segment URLs.
|
||||||
// when the URL isn't a phncdn HLS URL with a complete variant path.
|
function buildManifestForVariant(url) {
|
||||||
function buildManifestForVariant(rawUrl) {
|
if (!url || !/\/seg-\d+/i.test(url)) return '';
|
||||||
if (!rawUrl || !rawUrl.includes('/hls/videos/')) return '';
|
|
||||||
try {
|
try {
|
||||||
const path = rawUrl.split('?')[0];
|
const [pathPart, queryPart] = url.split('?');
|
||||||
const parts = path.split('/');
|
const idx = pathPart.lastIndexOf('/');
|
||||||
const hlsIdx = parts.findIndex(p => p === 'hls');
|
if (idx === -1) return '';
|
||||||
// Need hls, videos, year, subdir, videoId, variant (6 dirs).
|
const dir = pathPart.slice(0, idx); // variant directory
|
||||||
if (hlsIdx === -1 || hlsIdx + 5 >= parts.length) return '';
|
const rawQuery = queryPart ? '?' + queryPart : '';
|
||||||
const prefix = parts.slice(0, hlsIdx + 2).join('/'); // .../hls/videos
|
return `${dir}/master.m3u8${rawQuery}`;
|
||||||
const year = parts[hlsIdx + 2];
|
} catch {
|
||||||
const subid = parts[hlsIdx + 3];
|
|
||||||
const videoId = parts[hlsIdx + 4];
|
|
||||||
const variant = parts[hlsIdx + 5];
|
|
||||||
const rawQuery = extractRawQuery(rawUrl);
|
|
||||||
return `${prefix}/${year}/${subid}/${videoId}/${variant}/master.m3u8${rawQuery ? '?' + rawQuery : ''}`;
|
|
||||||
} catch {
|
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reconstruct manifest URLs from segment URLs, keeping the raw query intact.
|
// Reconstruct manifest URLs from segment URLs, keeping the raw query intact.
|
||||||
@@ -133,126 +127,147 @@ function reconstructManifestsFromSegments(segments) {
|
|||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
const manifests = [];
|
const manifests = [];
|
||||||
for (const url of segments) {
|
for (const url of segments) {
|
||||||
// Only handle HLS segment URLs: /hls/videos/{..}/{variant}/seg-N.ts
|
// Only handle video/segment URLs (e.g. .../seg-N.ts).
|
||||||
if (!url.includes('/hls/videos/') || !/\/seg-\d+/.test(url)) continue;
|
|
||||||
const manifestUrl = buildManifestForVariant(url);
|
const manifestUrl = buildManifestForVariant(url);
|
||||||
if (manifestUrl && !seen.has(manifestUrl)) {
|
if (manifestUrl && !seen.has(manifestUrl)) {
|
||||||
seen.add(manifestUrl);
|
seen.add(manifestUrl);
|
||||||
manifests.push(manifestUrl);
|
manifests.push(manifestUrl);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return manifests;
|
return manifests;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect phncdn URLs across ALL tabs without a tabs.query match pattern (which
|
// Collect captured video URLs across ALL tabs without a tabs.query match pattern
|
||||||
// throws if the pattern is malformed). We scan every persisted vr_requests_ /
|
// (which throws if the pattern is malformed). We scan every persisted vr_requests_ /
|
||||||
// wr_manifests_ key so the rescan works even if the live source tab is closed.
|
// wr_manifests_ key so the rescan works even if the live source tab is closed.
|
||||||
async function collectAllPhncdnUrls() {
|
async function collectAllVideoUrls() {
|
||||||
const all = await chrome.storage.local.get();
|
const all = await chrome.storage.local.get();
|
||||||
const out = [];
|
const out = [];
|
||||||
for (const key of Object.keys(all)) {
|
for (const key of Object.keys(all)) {
|
||||||
if ((key.startsWith('vr_requests_') || key.startsWith('wr_manifests_')) && Array.isArray(all[key])) {
|
if ((key.startsWith('vr_requests_') || key.startsWith('wr_manifests_')) && Array.isArray(all[key])) {
|
||||||
out.push(...all[key]);
|
out.push(...all[key]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
// For a videoId, return the freshest live phncdn URL (highest token expiry)
|
// For a group key, return the freshest live captured URL (highest token
|
||||||
// across all persisted tabs, preferring captured .m3u8 manifest URLs. null if
|
// expiry) across all persisted tabs, preferring captured .m3u8 manifest URLs.
|
||||||
// nothing usable is captured yet. Rescans use this to replace a stored URL that
|
// null if nothing usable is captured yet. Rescans use this to replace a
|
||||||
// now 410s with the newest token the live tab captured.
|
// stored URL that now 410s with the newest token the live tab captured.
|
||||||
//
|
//
|
||||||
// No tabs.query match pattern is used: '*pornhub.com*' is an INVALID pattern and
|
// No tabs.query match pattern is used: match patterns are brittle (a bad
|
||||||
// made this throw ("Invalid url pattern"), which silently dropped every rescan.
|
// pattern throws "Invalid url pattern" and silently drops every rescan).
|
||||||
// We scan persisted capture data instead, which is always available and
|
// We scan persisted capture data instead, which is always available and
|
||||||
// sidesteps the match-Pattern API entirely.
|
// sidesteps the match-pattern API entirely.
|
||||||
async function freshLiveForVideo(videoId, preferManifest) {
|
async function freshLiveForGroup(groupKey, preferManifest) {
|
||||||
let all;
|
let all;
|
||||||
try {
|
try {
|
||||||
all = await collectAllPhncdnUrls();
|
all = await collectAllVideoUrls();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[goon-bg] collectAllPhncdnUrls failed:', e && e.message ? e.message : e);
|
console.warn('[vidplex-bg] collectAllVideoUrls failed:', e && e.message ? e.message : e);
|
||||||
all = [];
|
all = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
let bestManifest = null, bestManifestExp = 0;
|
let bestManifest = null, bestManifestExp = 0;
|
||||||
let bestVariant = null, bestVariantExp = 0;
|
let bestVariant = null, bestVariantExp = 0;
|
||||||
let bestTokenSource = null, bestTokenExp = 0;
|
let bestTokenSource = null, bestTokenExp = 0;
|
||||||
for (const url of all) {
|
for (const url of all) {
|
||||||
if (extractVideoId(url) !== videoId) continue;
|
if (extractGroupKey(url) !== groupKey) continue;
|
||||||
const exp = tokenExpirySeconds(extractRawQuery(url));
|
const exp = tokenExpirySeconds(extractRawQuery(url));
|
||||||
if (url.includes('.m3u8')) {
|
if (url.includes('.m3u8')) {
|
||||||
if (exp > bestManifestExp) { bestManifestExp = exp; bestManifest = url; }
|
if (exp > bestManifestExp) { bestManifestExp = exp; bestManifest = url; }
|
||||||
} else if (/\/seg-\d+/.test(url)) {
|
} else if (/\/seg-\d+/i.test(url)) {
|
||||||
if (exp > bestVariantExp) { bestVariantExp = exp; bestVariant = url; }
|
if (exp > bestVariantExp) { bestVariantExp = exp; bestVariant = url; }
|
||||||
} else {
|
} else {
|
||||||
if (exp > bestTokenExp) { bestTokenExp = exp; bestTokenSource = url; }
|
if (exp > bestTokenExp) { bestTokenExp = exp; bestTokenSource = url; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Prefer a captured manifest; else reconstruct the variant manifest; else
|
// Prefer a captured manifest; else the best segment (reconstructable to a
|
||||||
// hand back a token source so the proxy can at least re-token a good path.
|
// manifest); else a token source so the proxy can re-token a good path.
|
||||||
if (bestManifest) return bestManifest;
|
if (bestManifest) return bestManifest;
|
||||||
if (!preferManifest && bestVariant) return bestVariant;
|
if (!preferManifest && bestVariant) return bestVariant;
|
||||||
if (!preferManifest && bestTokenSource) return bestTokenSource;
|
if (!preferManifest && bestTokenSource) return bestTokenSource;
|
||||||
if (bestVariant) return bestVariant;
|
if (bestVariant) return bestVariant;
|
||||||
return bestManifest || bestTokenSource || null;
|
return bestManifest || bestTokenSource || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache freshest tokens per videoId so we don't query storage on every proxy
|
// Cache freshest tokens per group key so we don't query storage on every proxy
|
||||||
// request. Keyed by videoId; values resolve once and are reused until a rescan
|
// request. Keyed by group key; values resolve once and are reused until a rescan
|
||||||
// (GOON_RESOLVE_MANIFEST) or a 410 forces an invalidation.
|
// (GOON_RESOLVE_MANIFEST) or a 410 forces an invalidation.
|
||||||
const tokenCache = new Map();
|
const tokenCache = new Map();
|
||||||
|
|
||||||
// Given a phncdn /hls/videos/ URL that arrives WITHOUT a query (hls.js drops the
|
// Given a CDN URL that arrives WITHOUT a query (hls.js drops the relative-URL
|
||||||
// relative-URL token when building sub-playslists/segments), inject the freshest
|
// token when building sub-playslists/segments), inject the freshest live token
|
||||||
// live token for that videoId. Returns the URL with a query appended, or the
|
// for that group. Returns the URL with a query appended, or the original URL
|
||||||
// original URL unchanged when no token is available yet.
|
// unchanged when no token is available yet.
|
||||||
async function injectFreshToken(url) {
|
async function injectFreshToken(url) {
|
||||||
if (!url || !url.includes('/hls/videos/')) return url;
|
if (!url) return url;
|
||||||
// Already carries a token — leave it alone.
|
// Already carries a token — leave it alone.
|
||||||
if (url.includes('?')) return url;
|
if (url.includes('?')) return url;
|
||||||
const videoId = extractVideoId(url);
|
try {
|
||||||
if (!videoId) return url;
|
const groupKey = extractGroupKey(url);
|
||||||
if (tokenCache.has(videoId)) {
|
if (!groupKey) return url;
|
||||||
const token = tokenCache.get(videoId);
|
if (tokenCache.has(groupKey)) {
|
||||||
|
const token = tokenCache.get(groupKey);
|
||||||
|
return token ? url + '?' + token : url;
|
||||||
|
}
|
||||||
|
const fresh = await freshLiveForGroup(groupKey, false);
|
||||||
|
const token = fresh ? extractRawQuery(fresh) : '';
|
||||||
|
tokenCache.set(groupKey, token);
|
||||||
|
console.log('[vidplex-bg] injectFreshToken for', groupKey, (token ? token.substring(0, 40) + '…' : '(none captured)'));
|
||||||
return token ? url + '?' + token : url;
|
return token ? url + '?' + token : url;
|
||||||
}
|
} catch {
|
||||||
const fresh = await freshLiveForVideo(videoId, false);
|
return url;
|
||||||
const token = fresh ? extractRawQuery(fresh) : '';
|
}
|
||||||
tokenCache.set(videoId, token);
|
|
||||||
console.log('[goon-bg] injectFreshToken for', videoId, videoId + '-> ' + (token ? token.substring(0, 40) + '…' : '(none captured)'));
|
|
||||||
return token ? url + '?' + token : url;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear the per-video token cache. Called on rescan so the next proxy fetches
|
// Clear the per-video token cache. Called on rescan so the next proxy fetches
|
||||||
// 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;
|
||||||
|
|
||||||
const u = details.url.toLowerCase();
|
// Log video-like requests from any host (CDN-agnostic).
|
||||||
const isPhncdn = PHNCDN_HOSTS.some(host => details.url.includes(host));
|
if (isVideoRequest(details.url)) {
|
||||||
const isVideo = isVideoRequest(details.url);
|
console.log('[vidplex-bg] webRequest: video request in tab', details.tabId, ':', details.url.substring(0, 180));
|
||||||
|
|
||||||
// Log ALL phncdn.com requests regardless of path
|
|
||||||
if (isPhncdn) {
|
|
||||||
console.log('[goon-bg] webRequest: phncdn request in tab', details.tabId, ':', details.url.substring(0, 180));
|
|
||||||
await persistVideoRequest(details.tabId, details.url);
|
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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Log video-like requests from any host
|
// Also capture HLS manifest URLs (original logic)
|
||||||
if (isVideo && !isPhncdn) {
|
if (!isHlsManifest(details.url)) return;
|
||||||
console.log('[goon-bg] webRequest: video request in tab', details.tabId, ':', details.url.substring(0, 150));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also capture HLS manifest URLs (original logic)
|
console.log('[vidplex-bg] webRequest: HLS manifest in tab', details.tabId, ':', details.url.substring(0, 120));
|
||||||
if (!isHlsManifest(details.url)) return;
|
|
||||||
|
|
||||||
console.log('[goon-bg] webRequest: HLS manifest in tab', details.tabId, ':', details.url.substring(0, 120));
|
|
||||||
|
|
||||||
const key = 'wr_manifests_' + details.tabId;
|
const key = 'wr_manifests_' + details.tabId;
|
||||||
try {
|
try {
|
||||||
@@ -263,82 +278,199 @@ 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 entries when tabs are closed
|
// Clean up stale PER-TAB entries when tabs are closed. NOTE: 'goon_referers' is
|
||||||
|
// a domain-keyed GLOBAL store — it must outlive its capture tab so a proxy fetch
|
||||||
|
// for that host still finds its referer after the source tab is closed. It is
|
||||||
|
// therefore NOT removed here (only the per-tab vr_requests_/wr_manifests_ keys
|
||||||
|
// are). Stale referers are pruned opportunistically in persistReferer.
|
||||||
chrome.tabs.onRemoved.addListener(async (tabId) => {
|
chrome.tabs.onRemoved.addListener(async (tabId) => {
|
||||||
const keys = ['wr_manifests_' + tabId, 'vr_requests_' + tabId];
|
const keys = ['wr_manifests_' + tabId, 'vr_requests_' + tabId];
|
||||||
try {
|
try {
|
||||||
await chrome.storage.local.remove(keys);
|
await chrome.storage.local.remove(keys);
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
});
|
|
||||||
|
|
||||||
// === Force a pornhub Referer on every *.phncdn.com request ===
|
|
||||||
// The phncdn video CDN (hv-h.phncdn.com, pix-fl.phncdn.com, ev-h.phncdn.com,
|
|
||||||
// ... and the auth-token hosts) rejects any request whose Referer is not a
|
|
||||||
// pornhub-origin with HTTP 412 (see round-1 curl test: `Referer:
|
|
||||||
// https://www.pornhub.com/` -> 200, no referer -> 412). Origin is NOT the
|
|
||||||
// gate (curl with a pornhub Referer and no Origin still 200s), so we only
|
|
||||||
// rewrite Referer and never touch Origin.
|
|
||||||
//
|
|
||||||
// A normal fetch() CANNOT set the `Referer` header — it is a "forbidden
|
|
||||||
// header" in the Fetch API, so a service-worker fetch() gets
|
|
||||||
// `Referer: chrome-extension://<id>`, which the CDN rejects (412). The
|
|
||||||
// declarativeNetRequest API rewrites request headers at the network layer,
|
|
||||||
// below the Fetch API's forbidden-header restriction, and — per Chrome docs —
|
|
||||||
// these rules DO apply to fetch() calls made inside the service worker. So we
|
|
||||||
// force a pornhub referer on phncdn.com; the GOON_PROXY_FETCH handler in the
|
|
||||||
// SW then fetches the manifest + segments successfully.
|
|
||||||
const REFERER_RULE_ID = 9001;
|
|
||||||
const REFERER_RULE = {
|
|
||||||
id: REFERER_RULE_ID,
|
|
||||||
priority: 1,
|
|
||||||
action: {
|
|
||||||
type: 'modifyHeaders',
|
|
||||||
// `set` is allowed for any request header (only `append` is restricted to
|
|
||||||
// a fixed allowlist). `referer` is the header the CDN checks.
|
|
||||||
requestHeaders: [
|
|
||||||
{ header: 'referer', operation: 'set', value: 'https://www.pornhub.com/' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
condition: {
|
|
||||||
// phncdn.com (host + subdomains) ONLY. Do NOT include pornhub.com: the
|
|
||||||
// real page player's auth calls (api.pornhub.com) and its own segment
|
|
||||||
// fetches must keep their browser-supplied Origin/Referer or playback
|
|
||||||
// freezes. The proxy only ever fetches phncdn URLs.
|
|
||||||
requestDomains: ['phncdn.com'],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Cached promise so concurrent proxy fetches share one rule install; a
|
|
||||||
// transient failure is cleared so a later fetch retries.
|
|
||||||
let refererRuleReady = null;
|
|
||||||
async function ensureRefererRule() {
|
|
||||||
if (refererRuleReady) return refererRuleReady;
|
|
||||||
refererRuleReady = (async () => {
|
|
||||||
const api = chrome.declarativeNetRequest;
|
|
||||||
if (!api || typeof api.updateSessionRules !== 'function') {
|
|
||||||
throw new Error('declarativeNetRequest.updateSessionRules unavailable');
|
|
||||||
}
|
|
||||||
// Idempotent: remove any stale copy of our rule, then add the fresh one.
|
|
||||||
await api.updateSessionRules({
|
|
||||||
removeRuleIds: [REFERER_RULE_ID],
|
|
||||||
addRules: [REFERER_RULE],
|
|
||||||
});
|
});
|
||||||
console.log('[goon-bg] DNR referer rule installed for *.phncdn.com');
|
|
||||||
})();
|
// === Capture the provider-origin Referer per CDN host (CDN-agnostic) ===
|
||||||
// If it failed, reset so the next call retries instead of caching the
|
// Some media CDNs reject requests whose `Referer` is not the provider page's
|
||||||
// rejected promise forever.
|
// origin (HTTP 412). A fetch() CANNOT set `Referer` — it is a "forbidden
|
||||||
refererRuleReady.catch(() => { refererRuleReady = null; });
|
// header" in the Fetch API, so a service-worker fetch() would send
|
||||||
return refererRuleReady;
|
// `Referer: chrome-extension://<id>`, which the CDN rejects. The
|
||||||
|
// declarativeNetRequest API rewrites request headers at the network layer, below
|
||||||
|
// the Fetch API's restriction, and its rules apply to fetch() calls made inside
|
||||||
|
// the SW.
|
||||||
|
//
|
||||||
|
// We cannot hardcode a target referer: the proxy is invoked from the viewer
|
||||||
|
// (a different origin than the source page), and a DNR rule cannot read a live
|
||||||
|
// page's origin. So we derive the provider referer from the source tab's own URL.
|
||||||
|
// For a cross-origin video request the browser sends the referring page's URL as
|
||||||
|
// the Referer, and that page IS the tab currently loading the video — so the tab
|
||||||
|
// URL is the referer we need. We read it via chrome.webRequest.onBeforeRequest
|
||||||
|
// (no header access — it cannot read the request headers) plus chrome.tabs.get,
|
||||||
|
// which returns the source tab's URL, and persist that per CDN host. The
|
||||||
|
// GOON_PROXY_FETCH handler then installs a DNR rule that re-inserts that
|
||||||
|
// captured referer for just that host, so the proxy's fetch() is accepted.
|
||||||
|
// This requires no knowledge of any particular site. Deriving the referer from
|
||||||
|
// the tab URL (rather than reading the real request header via
|
||||||
|
// onBeforeSendHeaders + extraHeaders) is intentional: extraInfoSpec header
|
||||||
|
// parsing is rejected by some Chromium builds and fails the whole service worker,
|
||||||
|
// and the tabs + host permissions already permit chrome.tabs.get.
|
||||||
|
//
|
||||||
|
// Each CDN host gets its OWN declarativeNetRequest rule id so that concurrent
|
||||||
|
// proxy fetches for different hosts can't clobber each other's rule (a single
|
||||||
|
// shared id + a remove-then-add lets two hosts interleave and leave the wrong
|
||||||
|
// host's referer live). The id is derived from the host via a monotonic counter
|
||||||
|
// keyed by host, so it is stable for a given host across installs.
|
||||||
|
let nextRefererRuleId = 9001;
|
||||||
|
const refererRuleIds = new Map();
|
||||||
|
function refererRuleIdFor(host) {
|
||||||
|
const existing = refererRuleIds.get(host);
|
||||||
|
if (existing !== undefined) return existing;
|
||||||
|
const id = nextRefererRuleId++;
|
||||||
|
refererRuleIds.set(host, id);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// host of a URL (CDN host, lowercased); '' when the URL is malformed.
|
||||||
|
function hostOf(url) {
|
||||||
|
if (!url) return '';
|
||||||
|
try { return new URL(url).host.toLowerCase(); } catch { return ''; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registrable domain of a CDN host — the label a single provider's referer
|
||||||
|
// applies to. A referer-protecting CDN serves manifests and segments from many
|
||||||
|
// edge subdomains that share ONE
|
||||||
|
// provider referer, so we key the referer store, the DNR rule, and proxy-host
|
||||||
|
// matching on this domain rather than the exact subhost.
|
||||||
|
//
|
||||||
|
// Heuristic: the last two DNS labels. Correct for the overwhelmingly common
|
||||||
|
// single-label public suffixes (.com, .net, .org, .io, ...). Known caveat:
|
||||||
|
// multi-label public suffixes such as .co.uk / .com.au collapse to the public
|
||||||
|
// suffix itself (foo.co.uk -> co.uk); that is an exotic edge case and no worse
|
||||||
|
// than the exact-host behavior it replaces. A single-level host (no dot, or a
|
||||||
|
// bare TLD) is returned unchanged.
|
||||||
|
function registrableDomain(host) {
|
||||||
|
if (!host) return '';
|
||||||
|
const parts = host.split('.');
|
||||||
|
if (parts.length < 2) return host;
|
||||||
|
return parts.slice(-2).join('.');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// How long a captured referer is trusted before it is pruned on the next
|
||||||
|
// persist. Kept long enough for the scan-now / watch-later flow (a user scans a
|
||||||
|
// page, then opens the viewer much later) without tying cleanup to tab close (a
|
||||||
|
// still-open source tab's referer must survive other tabs closing).
|
||||||
|
const REFERER_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
// Persist the captured referer per CDN registrable domain so the proxy can
|
||||||
|
// re-use it even after the capture tab closes. Keyed by the host's registrable
|
||||||
|
// domain so a single provider referer covers every edge subdomain (a CDN serves
|
||||||
|
// manifests/segments from many subhosts that share one referer), and so a proxy
|
||||||
|
// for any sibling subhost can look it up without knowing the source tab. Each
|
||||||
|
// entry is stamped with a capture time; entries older than the TTL are pruned
|
||||||
|
// opportunistically here (never on tab close).
|
||||||
|
async function persistReferer(host, referer) {
|
||||||
|
const domain = registrableDomain(host);
|
||||||
|
if (!domain || !referer) return;
|
||||||
|
try {
|
||||||
|
const stored = await chrome.storage.local.get('goon_referers');
|
||||||
|
const map = stored['goon_referers'] || {};
|
||||||
|
const now = Date.now();
|
||||||
|
// Opportunistic per-domain prune: drop entries older than the TTL.
|
||||||
|
for (const [h, entry] of Object.entries(map)) {
|
||||||
|
if (h !== domain && (!entry || !entry.at || now - entry.at > REFERER_TTL_MS)) {
|
||||||
|
delete map[h];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const current = map[domain];
|
||||||
|
if (current && current.referer === referer) return;
|
||||||
|
map[domain] = { referer, at: now };
|
||||||
|
await chrome.storage.local.set({ goon_referers: map });
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[vidplex-bg] persistReferer failed:', e && e.message ? e.message : e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up the referer captured for a host's registrable domain. '' when none
|
||||||
|
// has been captured (or the stored entry is malformed).
|
||||||
|
async function getReferer(host) {
|
||||||
|
if (!host) return '';
|
||||||
|
try {
|
||||||
|
const stored = await chrome.storage.local.get('goon_referers');
|
||||||
|
const entry = (stored['goon_referers'] || {})[registrableDomain(host)];
|
||||||
|
return entry && entry.referer ? entry.referer : '';
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Install (idempotently) a DNR rule that sets the captured referer for a given
|
||||||
|
// CDN host. The value comes from capture (or a fallback origin), never a
|
||||||
|
// hardcoded site. Scoped to the requested host so only proxy fetches for that
|
||||||
|
// host get the rewritten header.
|
||||||
|
//
|
||||||
|
// Each host gets its own rule id (refererRuleIdFor) so that concurrent proxy
|
||||||
|
// fetches for DIFFERENT hosts never interleave: host A's rule and host B's rule
|
||||||
|
// are distinct DNR ids and one install can't remove the other. Within a single
|
||||||
|
// host, a per-host async lock (refererRuleLocks) serializes the idempotency
|
||||||
|
// check + updateSessionRules so the install-and-record pair is atomic — no two
|
||||||
|
// installs for the same host can interleave their check/update.
|
||||||
|
const refererRuleInstalled = new Map();
|
||||||
|
const refererRuleLocks = new Map();
|
||||||
|
|
||||||
|
async function ensureRefererRule(host, referer) {
|
||||||
|
const domain = registrableDomain(host);
|
||||||
|
if (!domain || !referer) return;
|
||||||
|
const api = chrome.declarativeNetRequest;
|
||||||
|
if (!api || typeof api.updateSessionRules !== 'function') {
|
||||||
|
console.warn('[vidplex-bg] declarativeNetRequest unavailable; skipping referer rule');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Install eagerly on SW start so even the first proxy fetch is covered.
|
// Serialize per host so the idempotency check + updateSessionRules is atomic.
|
||||||
ensureRefererRule();
|
const run = (async () => {
|
||||||
|
if (refererRuleInstalled.has(domain) && refererRuleInstalled.get(domain) === referer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ruleId = refererRuleIdFor(domain);
|
||||||
|
try {
|
||||||
|
// Remove only THIS host's own rule id, then add it back scoped to the host.
|
||||||
|
await api.updateSessionRules({
|
||||||
|
removeRuleIds: [ruleId],
|
||||||
|
addRules: [{
|
||||||
|
id: ruleId,
|
||||||
|
priority: 1,
|
||||||
|
action: {
|
||||||
|
type: 'modifyHeaders',
|
||||||
|
// `set` is allowed for any request header (only `append` is restricted
|
||||||
|
// to a fixed allowlist). `referer` is the header the CDN checks.
|
||||||
|
requestHeaders: [
|
||||||
|
{ header: 'referer', operation: 'set', value: referer },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
condition: {
|
||||||
|
// Host + subdomains ONLY, so only proxy fetches for this host get the
|
||||||
|
// rewritten referer; the source page's own requests keep theirs.
|
||||||
|
requestDomains: [domain],
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
refererRuleInstalled.set(domain, referer);
|
||||||
|
console.log('[vidplex-bg] DNR referer rule installed for', domain, '(' + ruleId + ')');
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[vidplex-bg] ensureRefererRule failed:', e && e.message ? e.message : e);
|
||||||
|
}
|
||||||
|
})().finally(() => { refererRuleLocks.set(domain, null); });
|
||||||
|
refererRuleLocks.set(domain, run);
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// === Message handlers for popup/data consumers ===
|
// === Message handlers for popup/data consumers ===
|
||||||
|
|
||||||
@@ -362,11 +494,11 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'GOON_GET_PHNCDN_REQUESTS': {
|
case 'GOON_GET_VIDEO_REQUESTS': {
|
||||||
// Strategy 8: Return all phncdn.com requests for a tab, including reconstructed manifests
|
// Strategy 8: Return all video requests for a tab, including reconstructed manifests
|
||||||
(async () => {
|
(async () => {
|
||||||
const tabId = message.tabId;
|
const tabId = message.tabId;
|
||||||
const [segKey] = ['vr_requests_' + tabId];
|
const [segKey] = ['vr_requests_' + tabId];
|
||||||
try {
|
try {
|
||||||
const stored = await chrome.storage.local.get(segKey);
|
const stored = await chrome.storage.local.get(segKey);
|
||||||
const segments = stored[segKey] || [];
|
const segments = stored[segKey] || [];
|
||||||
@@ -384,78 +516,105 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'GOON_RESOLVE_MANIFEST': {
|
case 'GOON_RESOLVE_MANIFEST': {
|
||||||
// Resolve the freshest live phncdn manifest for the video in a stored
|
// Resolve the freshest live manifest for the video encoded in a stored
|
||||||
// URL. The viewer calls this at startup and on a fatal network error:
|
// URL. The viewer calls this at startup and on a fatal network error:
|
||||||
// the live tab keeps rotating short-lived tokens, so the freshest
|
// the live tab keeps rotating short-lived tokens, so the freshest
|
||||||
// captured manifest (highest expiry) replaces one that has 410'd.
|
// captured manifest (highest expiry) replaces one that has 410'd.
|
||||||
// Clears the token cache first so subsequent proxy fetches re-query for
|
// Clears the token cache first so subsequent proxy fetches re-query for
|
||||||
// the freshly-rotated token rather than the cached (now-dead) one.
|
// the freshly-rotated token rather than the cached (now-dead) one.
|
||||||
async function resolveFreshManifest(message, sendResponse) {
|
async function resolveFreshManifest(message, sendResponse) {
|
||||||
invalidateTokenCache();
|
invalidateTokenCache();
|
||||||
const url = message.url || '';
|
const url = message.url || '';
|
||||||
const videoId = extractVideoId(url);
|
const groupKey = extractGroupKey(url);
|
||||||
if (!videoId) { sendResponse({ ok: false, error: 'no videoId' }); return; }
|
if (!groupKey) { sendResponse({ ok: false, error: 'no group key' }); return; }
|
||||||
try {
|
try {
|
||||||
const fresh = await freshLiveForVideo(videoId, true);
|
const fresh = await freshLiveForGroup(groupKey, true);
|
||||||
sendResponse({ ok: true, fresh, videoId, 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), videoId });
|
sendResponse({ ok: false, error: e && e.message ? e.message : String(e), groupKey });
|
||||||
}
|
|
||||||
}
|
|
||||||
resolveFreshManifest(message, sendResponse);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'GOON_PROXY_FETCH': {
|
|
||||||
// Proxy a fetch request through the service worker (bypasses CORS).
|
|
||||||
// Returns base64-encoded response data. Used by the viewer when
|
|
||||||
// extension-origin requests to video CDNs are blocked by CORS.
|
|
||||||
//
|
|
||||||
// The phncdn CDN 412s requests without a pornhub Referer. A fetch()
|
|
||||||
// can't set Referer itself (forbidden header — Chrome replaces it with
|
|
||||||
// chrome-extension://...), so we install a declarativeNetRequest rule
|
|
||||||
// that forces `Referer: https://www.pornhub.com/` at the network layer
|
|
||||||
// for all *.phncdn.com requests (DNR applies to SW fetch() per Chrome
|
|
||||||
// docs). Await it first so the very next fetch is covered.
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
await ensureRefererRule();
|
|
||||||
// hls.js resolves sub-playslists/segments relative to the master
|
|
||||||
// and can DROP the token query, handing us a token-less phncdn
|
|
||||||
// URL that 410s. injectFreshToken supplies the freshest live token
|
|
||||||
// for that videoId; the final `+` -> `%2B` pass keeps the
|
|
||||||
// (possibly injected or surviving) base64 token intact for the
|
|
||||||
// CDN's query parsing.
|
|
||||||
const resolved = await injectFreshToken(message.url || '');
|
|
||||||
const proxyUrl = resolved.replace(/\+/g, '%2B');
|
|
||||||
const resp = await fetch(proxyUrl);
|
|
||||||
if (!resp.ok) {
|
|
||||||
console.warn('[goon-bg] proxyFetch', resp.status, message.url.substring(0, 120));
|
|
||||||
sendResponse({ ok: false, status: resp.status, error: 'HTTP ' + resp.status });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const buffer = await resp.arrayBuffer();
|
|
||||||
const bytes = new Uint8Array(buffer);
|
|
||||||
// Convert to base64 (btoa can only handle strings up to ~65k chars)
|
|
||||||
let binary = '';
|
|
||||||
const CHUNK_SIZE = 32768;
|
|
||||||
for (let i = 0; i < bytes.length; i += CHUNK_SIZE) {
|
|
||||||
const chunk = bytes.subarray(i, i + CHUNK_SIZE);
|
|
||||||
binary += String.fromCharCode.apply(null, Array.from(chunk));
|
|
||||||
}
|
|
||||||
const base64 = btoa(binary);
|
|
||||||
sendResponse({ ok: true, data: base64, mimeType: resp.headers.get('content-type') || '' });
|
|
||||||
} catch(e) {
|
|
||||||
console.warn('[goon-bg] proxyFetch failed:', e && e.message ? e.message : e);
|
|
||||||
sendResponse({ ok: false, error: e && e.message ? e.message : String(e) });
|
|
||||||
}
|
}
|
||||||
})();
|
}
|
||||||
return true;
|
resolveFreshManifest(message, sendResponse);
|
||||||
}
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'GOON_PROXY_FETCH': {
|
||||||
|
// Proxy a fetch request through the service worker (bypasses CORS).
|
||||||
|
// Returns base64-encoded response data. Used by the viewer when
|
||||||
|
// extension-origin requests to video CDNs are blocked by CORS.
|
||||||
|
//
|
||||||
|
// A fetch() can't set the Referer itself (forbidden header — Chrome
|
||||||
|
// replaces it with chrome-extension://...), so a declarativeNetRequest
|
||||||
|
// rule installs the captured provider-origin referer at the network
|
||||||
|
// layer (DNR applies to SW fetch() per Chrome docs). The rule is
|
||||||
|
// installed on demand for the requested host only.
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const proxyHost = hostOf(message.url);
|
||||||
|
if (proxyHost) {
|
||||||
|
// Prefer the provider's real referer captured from the live
|
||||||
|
// tab (domain-aware), or the entry's scan-time referer the viewer sent.
|
||||||
|
// With neither, install no rule: a self-origin referer is
|
||||||
|
// guaranteed-wrong for hotlink protection, so we never fall
|
||||||
|
// back to one (it would just 412).
|
||||||
|
const captured = await getReferer(proxyHost);
|
||||||
|
const referer = captured || message.referer || '';
|
||||||
|
if (referer) await ensureRefererRule(proxyHost, referer);
|
||||||
|
}
|
||||||
|
// hls.js resolves sub-playslists/segments relative to the master
|
||||||
|
// and can DROP the token query, handing us a token-less
|
||||||
|
// URL that 410s. injectFreshToken supplies the freshest live
|
||||||
|
// token for that video; the final `+` -> `%2B` pass keeps the
|
||||||
|
// (possibly injected or surviving) base64 token intact for the
|
||||||
|
// CDN's query parsing.
|
||||||
|
const resolved = await injectFreshToken(message.url || '');
|
||||||
|
const proxyUrl = resolved.replace(/\+/g, '%2B');
|
||||||
|
const resp = await fetch(proxyUrl);
|
||||||
|
if (!resp.ok) {
|
||||||
|
console.warn('[vidplex-bg] proxyFetch', resp.status, message.url.substring(0, 120));
|
||||||
|
sendResponse({ ok: false, status: resp.status, error: 'HTTP ' + resp.status });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const buffer = await resp.arrayBuffer();
|
||||||
|
const bytes = new Uint8Array(buffer);
|
||||||
|
// Convert to base64 (btoa can only handle strings up to ~65k chars)
|
||||||
|
let binary = '';
|
||||||
|
const CHUNK_SIZE = 32768;
|
||||||
|
for (let i = 0; i < bytes.length; i += CHUNK_SIZE) {
|
||||||
|
const chunk = bytes.subarray(i, i + CHUNK_SIZE);
|
||||||
|
binary += String.fromCharCode.apply(null, Array.from(chunk));
|
||||||
|
}
|
||||||
|
const base64 = btoa(binary);
|
||||||
|
sendResponse({ ok: true, data: base64, mimeType: resp.headers.get('content-type') || '' });
|
||||||
|
} catch(e) {
|
||||||
|
console.warn('[vidplex-bg] proxyFetch failed:', e && e.message ? e.message : e);
|
||||||
|
sendResponse({ ok: false, error: e && e.message ? e.message : String(e) });
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'GOON_GET_REFERERS': {
|
||||||
|
// Resolve the provider referer captured for each manifest host
|
||||||
|
// (domain-aware: one provider referer covers every edge subdomain).
|
||||||
|
// Used at scan time to bind the referer to a stored video entry so it
|
||||||
|
// survives the watch-later flow.
|
||||||
|
async function getReferers(message, sendResponse) {
|
||||||
|
const hosts = Array.isArray(message.hosts) ? message.hosts : [];
|
||||||
|
const referers = {};
|
||||||
|
for (const host of hosts) {
|
||||||
|
const r = await getReferer(host);
|
||||||
|
if (r) referers[host] = r;
|
||||||
|
}
|
||||||
|
sendResponse({ ok: true, referers });
|
||||||
|
}
|
||||||
|
getReferers(message, sendResponse);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
console.warn('[goon-plugin] Unknown message type:', message.type);
|
console.warn('[vidplex-plugin] Unknown message type:', message.type);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
+15
-15
@@ -25,11 +25,11 @@
|
|||||||
/**
|
/**
|
||||||
* Inject interceptors into the MAIN world at document_start.
|
* Inject interceptors into the MAIN world at document_start.
|
||||||
*
|
*
|
||||||
* Uses script.src = chrome.runtime.getURL('interceptors.js') instead of
|
* Uses script.src = chrome.runtime.getURL('interceptors.js') instead of
|
||||||
* script.textContent to bypass strict page CSP. Sites like Pornhub enforce
|
* script.textContent to bypass strict page CSP. Some sites enforce
|
||||||
* 'script-src' that blocks inline execution (textContent). Chrome treats
|
* 'script-src' that blocks inline execution (textContent). Chrome treats
|
||||||
* extension-originated <script src="..."> as content scripts — not as
|
* extension-originated <script src="..."> as content scripts — not as
|
||||||
* page inline code — so the page's CSP does not apply.
|
* page inline code — so the page's CSP does not apply.
|
||||||
*
|
*
|
||||||
* IMPORTANT: Remove the script element ONLY after the load event fires.
|
* IMPORTANT: Remove the script element ONLY after the load event fires.
|
||||||
* Removing it synchronously can prevent the browser from starting the fetch,
|
* Removing it synchronously can prevent the browser from starting the fetch,
|
||||||
@@ -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.');
|
||||||
})();
|
})();
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
|
||||||
<rect width="128" height="128" rx="24" fill="#4361ee"/>
|
<rect width="128" height="128" rx="24" fill="#4361ee"/>
|
||||||
<text x="64" y="84" text-anchor="middle" dominant-baseline="middle" font-size="64" font-weight="bold" font-family="sans-serif" fill="white">G</text>
|
<text x="64" y="84" text-anchor="middle" dominant-baseline="middle" font-size="64" font-weight="bold" font-family="sans-serif" fill="white">V</text>
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 279 B After Width: | Height: | Size: 279 B |
@@ -2,7 +2,8 @@
|
|||||||
* interceptors.js — Main-world interceptors for HLS manifest detection.
|
* interceptors.js — Main-world interceptors for HLS manifest detection.
|
||||||
*
|
*
|
||||||
* This file is injected into the main world via a <script src="..."> tag
|
* This file is injected into the main world via a <script src="..."> tag
|
||||||
* (NOT inline textContent) to bypass strict CSP on sites like Pornhub.
|
* (NOT inline textContent) to bypass strict CSP on sites that restrict
|
||||||
|
* script-src.
|
||||||
*
|
*
|
||||||
* When loaded as an external script from the extension origin, Chrome treats
|
* When loaded as an external script from the extension origin, Chrome treats
|
||||||
* it as an extension content script — not as page inline code — so the
|
* it as an extension content script — not as page inline code — so the
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
+44
-2
@@ -3,12 +3,54 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Goon Plugin</title>
|
<title>VidPlex</title>
|
||||||
<link rel="stylesheet" href="index.css">
|
<link rel="stylesheet" href="index.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app">
|
<div id="app">
|
||||||
<h1>Goon Plugin</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>
|
||||||
|
|||||||
+248
-156
@@ -6,12 +6,17 @@
|
|||||||
|
|
||||||
const STORAGE_KEY = 'goon_videos';
|
const STORAGE_KEY = 'goon_videos';
|
||||||
const TAGGED_KEY = 'goon_tagged';
|
const TAGGED_KEY = 'goon_tagged';
|
||||||
|
// Durable "hide instructions" toggle. Absent key === SHOWN (true), matching the
|
||||||
|
// first-time "expanded on mount" behavior. chrome.storage.local is extension-wide,
|
||||||
|
// so the viewer window and every popup instance read/write the same value — the
|
||||||
|
// preference is respected application-wide and survives reloads / SW termination.
|
||||||
|
const INSTRUCTIONS_VISIBLE_KEY = 'goon_instructions_visible';
|
||||||
|
|
||||||
// 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 +26,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 +39,39 @@ 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) {
|
||||||
|
// On mount, read the stored visibility. An absent key means SHOWN (true), so a
|
||||||
|
// first-time user still sees the steps. The viewer window reads the same
|
||||||
|
// extension-wide key, so the choice is respected across the whole app.
|
||||||
|
chrome.storage.local.get(INSTRUCTIONS_VISIBLE_KEY).then(stored => {
|
||||||
|
const visible = stored[INSTRUCTIONS_VISIBLE_KEY];
|
||||||
|
setInstructionsExpanded(visible === undefined ? true : visible);
|
||||||
|
}).catch(() => {
|
||||||
|
// Any read failure falls back to shown.
|
||||||
|
setInstructionsExpanded(true);
|
||||||
|
});
|
||||||
|
// On every user toggle, apply in-DOM and persist. Both directions write, so
|
||||||
|
// the state survives reloads and SW termination until the user flips it again.
|
||||||
|
instructionsToggle.addEventListener('click', () => {
|
||||||
|
const next = instructionsBody.hidden;
|
||||||
|
setInstructionsExpanded(next);
|
||||||
|
chrome.storage.local.set({ [INSTRUCTIONS_VISIBLE_KEY]: next });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/* ======================================================================== */
|
/* ======================================================================== */
|
||||||
/* UI helpers */
|
/* UI helpers */
|
||||||
/* ======================================================================== */
|
/* ======================================================================== */
|
||||||
@@ -120,9 +154,13 @@ 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) {}
|
||||||
}
|
}
|
||||||
probeLog('Deep probe started on:', window.location.href, 'World:', typeof chrome !== 'undefined' ? 'ISOLATED' : 'MAIN');
|
// 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
|
||||||
|
// NOT a reliable world discriminator (extensions expose `chrome` in the
|
||||||
|
// isolated world too), so don't infer it that way.
|
||||||
|
probeLog('Deep probe started on:', window.location.href, '(main world)');
|
||||||
|
|
||||||
// Helpers available in the MAIN world context
|
// Helpers available in the MAIN world context
|
||||||
function resolveUrl(url) {
|
function resolveUrl(url) {
|
||||||
@@ -134,17 +172,39 @@ function m3u8DeepProbeFn() {
|
|||||||
} catch { return null; }
|
} catch { return null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
function isManifestUrl(url) {
|
function isManifestUrl(url) {
|
||||||
if (!url) return false;
|
if (!url) return false;
|
||||||
const u = url.toLowerCase();
|
const u = url.toLowerCase();
|
||||||
return u.includes('.m3u8') || u.includes('.hlsvod') ||
|
// Concrete HLS/manifest signals win over the template heuristics below: a
|
||||||
(u.includes('/hls/') && u.includes('http')) ||
|
// real .m3u8/.hlsvod//hls/ path is fetchable even if it happens to carry a
|
||||||
(u.includes('/playlist') && u.includes('http')) ||
|
// trailing '%' query token or a trailing slash — so check these first.
|
||||||
(u.includes('/manifest') && u.includes('http'));
|
if (u.includes('.m3u8') || u.includes('.hlsvod') || u.includes('/hls/')) return true;
|
||||||
}
|
// Dynamic route TEMPLATES (e.g. /playlist/%1%/ or /videos/{id}/) are nav
|
||||||
|
// links, not fetchable manifests. A placeholder is a `%` that is NOT the
|
||||||
|
// start of a valid %-encoded hex pair (so real tokens like %2B, %2F are
|
||||||
|
// 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.
|
||||||
|
if (u.includes('/playlists') || u.includes('/manifests')) return false;
|
||||||
|
// 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.
|
||||||
|
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) {
|
||||||
@@ -319,10 +379,10 @@ function m3u8DeepProbeFn() {
|
|||||||
}
|
}
|
||||||
probeLog('Strategy E (interceptor data): found', manifests.length - preECount, 'new manifests');
|
probeLog('Strategy E (interceptor data): found', manifests.length - preECount, 'new manifests');
|
||||||
|
|
||||||
// Strategy F: Dynamic playlist fallback — scan video elements for Hls-like
|
// Strategy F: Dynamic playlist fallback — scan video elements for Hls-like
|
||||||
// instances and extract ANY URL, not just .m3u8. Sites like Pornhub use
|
// instances and extract ANY URL, not just .m3u8. Some players use
|
||||||
// HLS.js dynamic playlists where the "manifest" is generated in-memory,
|
// HLS.js dynamic playlists where the "manifest" is generated in-memory,
|
||||||
// so the URL might be a blob:, CDN endpoint, or data URI.
|
// so the URL might be a blob:, CDN endpoint, or data URI.
|
||||||
let strategyFCount = 0;
|
let strategyFCount = 0;
|
||||||
try {
|
try {
|
||||||
for (const v of document.querySelectorAll('video')) {
|
for (const v of document.querySelectorAll('video')) {
|
||||||
@@ -666,8 +726,9 @@ async function probeHlsManifests(tabId) {
|
|||||||
log('probeHlsManifests: executing deep probe on tab', tabId);
|
log('probeHlsManifests: executing deep probe on tab', tabId);
|
||||||
const raw = await chrome.scripting.executeScript({
|
const raw = await chrome.scripting.executeScript({
|
||||||
target: { tabId, allFrames: true },
|
target: { tabId, allFrames: true },
|
||||||
|
world: 'MAIN',
|
||||||
func: m3u8DeepProbeFn,
|
func: m3u8DeepProbeFn,
|
||||||
});
|
});
|
||||||
// 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 manifests = Array.isArray(raw)
|
const manifests = Array.isArray(raw)
|
||||||
@@ -697,12 +758,12 @@ function dedupManifests(allManifests) {
|
|||||||
});
|
});
|
||||||
// Sort: prefer interceptor-collected > player globals > config > inline-script
|
// Sort: prefer interceptor-collected > player globals > config > inline-script
|
||||||
const playerRank = {
|
const playerRank = {
|
||||||
'__goon_plugin_data': 0,
|
'__goon_plugin_data': 0,
|
||||||
'content-script-intercept': 0,
|
'content-script-intercept': 0,
|
||||||
'webRequest': 1,
|
'webRequest': 1,
|
||||||
'phncdn_reconstructed': 2,
|
'hls_reconstructed': 2,
|
||||||
'video._goon_hls_manifest': 3,
|
'video._goon_hls_manifest': 3,
|
||||||
'inline-script': 99,
|
'inline-script': 99,
|
||||||
};
|
};
|
||||||
unique.sort((a, b) => {
|
unique.sort((a, b) => {
|
||||||
const aRank = playerRank[a.player] ?? 50;
|
const aRank = playerRank[a.player] ?? 50;
|
||||||
@@ -746,26 +807,26 @@ async function queryWebRequestManifests(tabId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Query the service worker for all phncdn.com requests captured by Strategy 8.
|
* Query the service worker for all video requests captured by Strategy 8,
|
||||||
* Returns segments + reconstructed manifest URLs from segment patterns.
|
* including reconstructed manifest URLs derived from segment patterns.
|
||||||
* @param {number} tabId
|
* @param {number} tabId
|
||||||
* @returns {Promise<{segments: Array, reconstructedManifests: Array, totalRequests: number}>}
|
* @returns {Promise<{segments: Array, reconstructedManifests: Array, totalRequests: number}>}
|
||||||
*/
|
*/
|
||||||
async function queryPhncdnRequests(tabId) {
|
async function queryVideoRequests(tabId) {
|
||||||
try {
|
try {
|
||||||
const resp = await chrome.runtime.sendMessage({
|
const resp = await chrome.runtime.sendMessage({
|
||||||
type: 'GOON_GET_PHNCDN_REQUESTS',
|
type: 'GOON_GET_VIDEO_REQUESTS',
|
||||||
tabId: tabId,
|
tabId: tabId,
|
||||||
});
|
});
|
||||||
return resp || { segments: [], reconstructedManifests: [], totalRequests: 0 };
|
return resp || { segments: [], reconstructedManifests: [], totalRequests: 0 };
|
||||||
} catch {
|
} catch {
|
||||||
return { segments: [], reconstructedManifests: [], totalRequests: 0 };
|
return { segments: [], reconstructedManifests: [], totalRequests: 0 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scrape video elements from ALL frames in a tab.
|
* Scrape video elements from ALL frames in a tab.
|
||||||
* On sites like Pornhub, the video player lives inside an iframe — main-frame-only
|
* On many video sites the player lives inside an iframe — main-frame-only
|
||||||
* scraping sees zero <video> elements. Running allFrames catches them.
|
* scraping sees zero <video> elements. Running allFrames catches them.
|
||||||
*
|
*
|
||||||
* Runs in the MAIN world so it can see page globals:
|
* Runs in the MAIN world so it can see page globals:
|
||||||
@@ -778,36 +839,64 @@ async function queryPhncdnRequests(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.
|
||||||
func: scrapeFn,
|
*/
|
||||||
args: [manifests],
|
function reduceScrapeFrames(raw) {
|
||||||
});
|
|
||||||
// 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
|
||||||
@@ -829,101 +918,76 @@ function filterManifests(manifests) {
|
|||||||
(u.includes('/playlist') && !u.endsWith('.ts'));
|
(u.includes('/playlist') && !u.endsWith('.ts'));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reject fake reconstructed manifest paths.
|
// Reject clearly-fake manifest paths (see isValidManifestUrl).
|
||||||
// Real manifests are under /hls/videos/{year}/{subdir}/{videoId}/...
|
const valid = actualManifests.filter(m => isValidManifestUrl(m.url));
|
||||||
// Fakes look like: phncdn.com/videos/{id}/playlist.m3u8 or .../master.m3u8 (no /hls/)
|
|
||||||
const valid = actualManifests.filter(m => {
|
|
||||||
const u = m.url.toLowerCase();
|
|
||||||
try {
|
|
||||||
const urlObj = new URL(u);
|
|
||||||
// phncdn URLs must have /hls/ in the path — fake reconstructed paths lack it
|
|
||||||
if (u.includes('phncdn') || u.includes('pornhubcdn')) {
|
|
||||||
if (!urlObj.pathname.includes('/hls/') && !u.includes('?h=')) {
|
|
||||||
return false; // Fake: phncdn URL without /hls/ or auth query param
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
// If we have actual (non-fake) manifests, use them directly
|
|
||||||
if (valid.length > 0) {
|
if (valid.length > 0) {
|
||||||
return valid.map(m => ({ url: m.url }));
|
return valid.map(m => ({ url: m.url }));
|
||||||
}
|
}
|
||||||
|
|
||||||
// No .m3u8 found — interceptors only captured .ts segments.
|
// No manifest found — interceptors only captured segment files. Reconstruct a
|
||||||
// Reconstruct the parent manifest URL from segment paths.
|
// manifest URL from each segment's directory, carrying over its auth query
|
||||||
// Segment: https://ev-h.phncdn.com/hls/videos/202008/01/338337051/720P_4000K_338337051.mp4/seg-1-v1-a1.ts?h=tmrD...&e=1786932752&f=1
|
// string intact (CDNs reject a path-only URL with 403). CDN-agnostic: the
|
||||||
// Manifest: https://ev-h.phncdn.com/hls/videos/202008/01/338337051/720P_4000K_338337051.mp4/master.m3u8?h=tmrD...&e=1786932752&f=1
|
// segment filename is swapped for a manifest filename in the same directory,
|
||||||
//
|
// e.g. .../variant/seg-1.ts?h=... -> .../variant/master.m3u8?h=...
|
||||||
// Auth tokens (h, e, f or validfrom, validto, hash) are extracted from segment query params
|
|
||||||
// and reapplied to the reconstructed manifest URL. This is critical because CDN domains
|
|
||||||
// require auth — a path-only URL will be rejected with 403.
|
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
const reconstructed = [];
|
const reconstructed = [];
|
||||||
|
// A segment filename: numbered segment, or an .ts/.m4s/.aac media extension.
|
||||||
|
const SEG_RE = /(seg-\d+|\.(?:ts|m4s|aac)(?:$|\?))/i;
|
||||||
for (const m of manifests) {
|
for (const m of manifests) {
|
||||||
const u = m.url.toLowerCase();
|
if (!SEG_RE.test(m.url)) continue;
|
||||||
if (!u.includes('/hls/videos/') || !/\/seg-\d+/.test(u)) continue;
|
|
||||||
try {
|
try {
|
||||||
const urlObj = new URL(m.url);
|
const urlObj = new URL(m.url);
|
||||||
// Skip non-phncdn hosts — we only reconstruct for known video CDNs
|
|
||||||
const host = urlObj.host.toLowerCase();
|
|
||||||
if (!host.includes('phncdn')) continue;
|
|
||||||
|
|
||||||
const pathParts = urlObj.pathname.split('/');
|
const pathParts = urlObj.pathname.split('/');
|
||||||
// Find the /hls/ marker and extract: hls/videos/{year}/{subdir}/{videoId}/{variant}/...
|
// Need a directory to attach the manifest to (skip flat single-file paths).
|
||||||
const hlsIdx = pathParts.findIndex(p => p === 'hls');
|
if (pathParts.length < 3) continue;
|
||||||
if (hlsIdx === -1 || hlsIdx + 5 >= pathParts.length) continue;
|
pathParts.pop(); // drop the segment filename, keep its directory
|
||||||
// Reconstruct with BOTH "hls" prefix AND variant directory included:
|
const dir = pathParts.join('/');
|
||||||
// segment: /hls/videos/202008/01/338337051/720P_4000K_338337051.mp4/seg-1.ts
|
// Carry the raw query (auth token) over unchanged — never re-encode it.
|
||||||
// manifest: /hls/videos/202008/01/338337051/720P_4000K_338337051.mp4/master.m3u8
|
const q = m.url.indexOf('?');
|
||||||
const year = pathParts[hlsIdx + 2]; // e.g. 2020
|
const rawQuery = q === -1 ? '' : m.url.slice(q);
|
||||||
const subDir = pathParts[hlsIdx + 3]; // e.g. 01
|
const manifestUrl = `${urlObj.protocol}//${urlObj.host}/${dir}/master.m3u8${rawQuery}`;
|
||||||
const videoId = pathParts[hlsIdx + 4]; // e.g. 338337051
|
|
||||||
const variant = pathParts[hlsIdx + 5]; // e.g. 720P_4000K_338337051.mp4
|
|
||||||
|
|
||||||
// Extract auth params from segment query string — different CDN variants use different keys:
|
|
||||||
// ev-h/validfrom/validto/hash vs hv-h/h/e/f
|
|
||||||
const authParts = [];
|
|
||||||
for (const [key, val] of urlObj.searchParams) {
|
|
||||||
if (['h', 'e', 'f', 'validfrom', 'validto', 'hash'].includes(key)) {
|
|
||||||
authParts.push(`${key}=${val}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const authStr = authParts.length > 0 ? '?' + authParts.join('&') : '';
|
|
||||||
|
|
||||||
// Build manifest URL: include hls prefix AND variant directory
|
|
||||||
const manifestUrl = `${urlObj.protocol}//${urlObj.host}/${pathParts[hlsIdx]}/${pathParts[hlsIdx + 1]}/${year}/${subDir}/${videoId}/${variant}/master.m3u8${authStr}`;
|
|
||||||
if (!seen.has(manifestUrl)) {
|
if (!seen.has(manifestUrl)) {
|
||||||
seen.add(manifestUrl);
|
seen.add(manifestUrl);
|
||||||
reconstructed.push({ url: manifestUrl });
|
reconstructed.push({ url: manifestUrl });
|
||||||
}
|
}
|
||||||
} catch { /* skip malformed URLs */ }
|
} catch { /* skip malformed URLs */ }
|
||||||
}
|
}
|
||||||
return reconstructed;
|
return reconstructed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate that a manifest URL uses a real phncdn.com path structure.
|
* Validate that a manifest URL is well-formed and generic (CDN-agnostic).
|
||||||
* Rejects fake paths like /videos/{id}/playlist.m3u8 — real manifests use
|
* Rejects malformed paths; a manifest-like URL must live in a directory (have a
|
||||||
* /hls/videos/{year}/{subdir}/{videoId}/master.m3u8 or similar /hls/ 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;
|
||||||
// Real phncdn HLS manifest: must contain /hls/videos/ with sufficient path depth
|
let urlObj;
|
||||||
const hlsIdx = url.indexOf('/hls/videos/');
|
try {
|
||||||
if (hlsIdx === -1) {
|
urlObj = new URL(url);
|
||||||
// Not phncdn — could be a different CDN, allow it
|
} catch {
|
||||||
if (!url.includes('phncdn') && !url.includes('pornhubcdn')) return true;
|
|
||||||
// phncdn without /hls/ prefix is invalid for manifests
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// Check there are at least {year}/{subdir}/{videoId} after /hls/videos/
|
const path = urlObj.pathname.toLowerCase();
|
||||||
const after = url.substring(hlsIdx + '/hls/videos/'.length);
|
// 1) Concrete media/manifest signals win outright: a real file extension or
|
||||||
if (after.indexOf('/') === -1) return false; // need at least one more segment
|
// /hls/ path is fetchable, even with a trailing slash
|
||||||
|
// or a token-bearing query — so check these before the
|
||||||
|
// template heuristics and never let a coincidental '%'/'/' sink a source.
|
||||||
|
if (path.includes('.m3u8') || path.includes('.hlsvod') || path.includes('/hls/') ||
|
||||||
|
path.includes('/manifest') || path.includes('.mp4') || path.includes('.webm') ||
|
||||||
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a normalized video entry from a scraped video object.
|
* Build a normalized video entry from a scraped video object.
|
||||||
@@ -941,16 +1005,16 @@ function makeVideoEntry(video, tabTitle, posterFromContent) {
|
|||||||
mediaType: video.mediaType || 'video',
|
mediaType: video.mediaType || 'video',
|
||||||
};
|
};
|
||||||
if (video.hlsManifest) {
|
if (video.hlsManifest) {
|
||||||
// Reject fake phncdn 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;
|
||||||
@@ -1004,28 +1068,28 @@ async function scanVideos(tabsToScan) {
|
|||||||
}
|
}
|
||||||
await logToPage(`step1.5 (webRequest): found ${totalWebRequestManifests} manifests via network interception`);
|
await logToPage(`step1.5 (webRequest): found ${totalWebRequestManifests} manifests via network interception`);
|
||||||
|
|
||||||
// Step 1.6: Strategy 8 — Query phncdn.com requests and reconstruct manifest URLs.
|
// Step 1.6: Strategy 8 — Query captured video requests and reconstruct manifest URLs.
|
||||||
// When the player uses in-memory dynamic HLS (zero .m3u8 network requests), we can
|
// When the player uses in-memory dynamic HLS (zero .m3u8 network requests), we can
|
||||||
// still detect it by recognizing segment URL patterns from phncdn.com and deriving
|
// still detect it by recognizing segment URL patterns and deriving the parent
|
||||||
// the parent manifest URL: /hls/videos/{id}/master.m3u8
|
// manifest URL from the segment's directory.
|
||||||
const phncdnPromises = tabsToScan.map(t => queryPhncdnRequests(t.id));
|
const videoRequestPromises = tabsToScan.map(t => queryVideoRequests(t.id));
|
||||||
const phncdnResults = await Promise.all(phncdnPromises);
|
const videoRequestResults = await Promise.all(videoRequestPromises);
|
||||||
let totalPhncdnManifests = 0;
|
let totalReconstructedManifests = 0;
|
||||||
for (let i = 0; i < tabsToScan.length; i++) {
|
for (let i = 0; i < tabsToScan.length; i++) {
|
||||||
const reconstructed = phncdnResults[i]?.reconstructedManifests || [];
|
const reconstructed = videoRequestResults[i]?.reconstructedManifests || [];
|
||||||
if (reconstructed.length > 0) {
|
if (reconstructed.length > 0) {
|
||||||
log('scanVideos phncdn: tab', tabsToScan[i].id, 'found', reconstructed.length, 'reconstructed manifests from segment URLs');
|
log('scanVideos strategy8: tab', tabsToScan[i].id, 'found', reconstructed.length, 'reconstructed manifests from segment URLs');
|
||||||
}
|
}
|
||||||
totalPhncdnManifests += reconstructed.length;
|
totalReconstructedManifests += reconstructed.length;
|
||||||
const existing = tabManifestMap.get(tabsToScan[i].id) || [];
|
const existing = tabManifestMap.get(tabsToScan[i].id) || [];
|
||||||
for (const url of reconstructed) {
|
for (const url of reconstructed) {
|
||||||
if (!existing.some(m => m.url === url)) {
|
if (!existing.some(m => m.url === url)) {
|
||||||
existing.push({ url, player: 'phncdn_reconstructed' });
|
existing.push({ url, player: 'hls_reconstructed' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
tabManifestMap.set(tabsToScan[i].id, existing);
|
tabManifestMap.set(tabsToScan[i].id, existing);
|
||||||
}
|
}
|
||||||
await logToPage(`step1.6 (phncdn): found ${totalPhncdnManifests} reconstructed manifests from segment URLs`);
|
await logToPage(`step1.6 (strategy8): found ${totalReconstructedManifests} reconstructed manifests from segment URLs`);
|
||||||
|
|
||||||
// Step 2: Scrape video elements (all frames) AND query content scripts (in parallel)
|
// Step 2: Scrape video elements (all frames) AND query content scripts (in parallel)
|
||||||
const scrapePromises = tabsToScan.map(t =>
|
const scrapePromises = tabsToScan.map(t =>
|
||||||
@@ -1128,6 +1192,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);
|
||||||
@@ -1138,6 +1203,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 => ({
|
||||||
@@ -1146,6 +1236,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,
|
||||||
@@ -1186,6 +1277,7 @@ findBtn.addEventListener('click', async () => {
|
|||||||
try {
|
try {
|
||||||
await chrome.scripting.executeScript({
|
await chrome.scripting.executeScript({
|
||||||
target: { tabId: activeTab.id },
|
target: { tabId: activeTab.id },
|
||||||
|
world: 'MAIN',
|
||||||
func: () => {
|
func: () => {
|
||||||
const diag = [];
|
const diag = [];
|
||||||
// Check interceptor data
|
// Check interceptor data
|
||||||
@@ -1239,7 +1331,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);
|
||||||
}
|
}
|
||||||
@@ -1247,7 +1339,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Goon Viewer</title>
|
<title>VidPlex Viewer</title>
|
||||||
<link rel="stylesheet" href="viewer.css">
|
<link rel="stylesheet" href="viewer.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
+177
-118
@@ -19,10 +19,10 @@
|
|||||||
* overlays and an aggregate status bar.
|
* overlays and an aggregate status bar.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// HLS is vendored locally to avoid CSP blocks on sites like Pornhub that
|
// HLS is vendored locally to avoid CSP blocks on sites that restrict
|
||||||
// restrict script-src to 'self'. The hls.min.js bundle is built by Vite
|
// script-src to 'self'. The hls.js bundle is copied (not minified) so it
|
||||||
// and exposed via chrome.runtime.getURL() so it loads from the extension
|
// loads from the extension origin via chrome.runtime.getURL(), bypassing
|
||||||
// origin, bypassing page CSP entirely.
|
// page CSP entirely.
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Grid-optimized hls.js configuration.
|
* Grid-optimized hls.js configuration.
|
||||||
@@ -54,14 +54,15 @@ const panelsEl = document.getElementById('panels');
|
|||||||
|
|
||||||
/** Track all Hls instances so we can destroy them on unload. */
|
/** Track all Hls instances so we can destroy them on unload. */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom HLS loader that proxies phncdn.com URLs through the service worker
|
* Custom HLS loader that proxies URLs whose host blocks extension-origin
|
||||||
* (bypassing CORS/Referer restrictions) and fetches everything else natively.
|
* requests (via a learned proxy-host set) through the service worker
|
||||||
*
|
* (bypassing CORS/Referer restrictions) and fetches everything else natively.
|
||||||
* hls.js creates one loader instance per request via `new HlsLoader(hls)`, then
|
*
|
||||||
* calls `loader.load(context, config, callbacks)` — callbacks are the 3rd arg
|
* hls.js creates one loader instance per request via `new HlsLoader(hls)`, then
|
||||||
* in v9 (some wrapper paths put them 2nd, so we scan each argument). The stats
|
* calls `loader.load(context, config, callbacks)` — callbacks are the 3rd arg
|
||||||
* object passed to the callbacks is pre-allocated by us and mutated by hls.js
|
* in v9 (some wrapper paths put them 2nd, so we scan each argument). The stats
|
||||||
|
* object passed to the callbacks is pre-allocated by us and mutated by hls.js
|
||||||
* in place, so it must have the correct sub-shape (see createLoadedStats).
|
* in place, so it must have the correct sub-shape (see createLoadedStats).
|
||||||
*/
|
*/
|
||||||
function HlsLoader(config) {
|
function HlsLoader(config) {
|
||||||
@@ -134,8 +135,8 @@ const panelsEl = document.getElementById('panels');
|
|||||||
const stats = this.stats;
|
const stats = this.stats;
|
||||||
stats.loading.start = trequest;
|
stats.loading.start = trequest;
|
||||||
|
|
||||||
const isManifest = /\.m3u8($|\?)/i.test(url || '');
|
const isManifest = /\.m3u8($|\?)/i.test(url || '');
|
||||||
const needProxy = (url || '').includes('phncdn') || (url || '').includes('pornhubcdn');
|
const needProxy = needsProxy(url);
|
||||||
|
|
||||||
// hls.js loader callback contract (verified against the bundled loader):
|
// hls.js loader callback contract (verified against the bundled loader):
|
||||||
// onSuccess(response, stats, context, networkDetails)
|
// onSuccess(response, stats, context, networkDetails)
|
||||||
@@ -148,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();
|
||||||
@@ -160,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);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -176,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 }, (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) {
|
||||||
@@ -233,52 +234,93 @@ const panelsEl = document.getElementById('panels');
|
|||||||
HlsLoader.prototype.destroy = function () { this.abort(); };
|
HlsLoader.prototype.destroy = function () { this.abort(); };
|
||||||
HlsLoader.canSupportLoader = function () { return true; };
|
HlsLoader.canSupportLoader = function () { return true; };
|
||||||
|
|
||||||
// === CORS diagnostics — check if phncdn.com allows extension-origin requests ===
|
// === Service-worker proxy helper (CDN-agnostic) ===
|
||||||
(async function checkCors() {
|
// Proxies a video URL through the service worker (no CORS restrictions). The
|
||||||
|
// SW forces the correct provider-origin Referer for the host (captured by the
|
||||||
|
// background worker), so a referer-protecting CDN accepts the request.
|
||||||
|
// Returns an ArrayBuffer of the response content.
|
||||||
|
async function proxyFetch(url, referer) {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('https://phncdn.com/images/favicon.ico', { method: 'HEAD', mode: 'cors' });
|
const resp = await chrome.runtime.sendMessage({ type: 'GOON_PROXY_FETCH', url, referer });
|
||||||
console.log('[goon-viewer] CORS check phncdn.com:', resp.ok ? 'ALLOWED (status ' + resp.status + ')' : 'BLOCKED');
|
|
||||||
} catch(e) {
|
|
||||||
console.warn('[goon-viewer] CORS check phncdn.com: BLOCKED —', e && e.message ? e.message : e);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
// === HLS segment proxy helper ===
|
|
||||||
// Proxies a video URL through the service worker (no CORS restrictions).
|
|
||||||
// Returns an ArrayBuffer of the response content.
|
|
||||||
async function proxyFetch(url) {
|
|
||||||
try {
|
|
||||||
const resp = await chrome.runtime.sendMessage({ type: 'GOON_PROXY_FETCH', url });
|
|
||||||
if (resp && resp.data && resp.ok) {
|
if (resp && resp.data && resp.ok) {
|
||||||
// Decode base64 → ArrayBuffer
|
// Decode base64 → ArrayBuffer
|
||||||
const binary = atob(resp.data);
|
const binary = atob(resp.data);
|
||||||
const bytes = new Uint8Array(binary.length);
|
const bytes = new Uint8Array(binary.length);
|
||||||
for (let i = 0; i < binary.length; i++) {
|
for (let i = 0; i < binary.length; i++) {
|
||||||
bytes[i] = binary.charCodeAt(i);
|
bytes[i] = binary.charCodeAt(i);
|
||||||
}
|
}
|
||||||
return bytes.buffer;
|
return bytes.buffer;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch(e) {
|
||||||
|
console.warn('[vidplex-viewer] proxyFetch failed for:', url.substring(0, 120), e && e.message ? e.message : e);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
return null;
|
}
|
||||||
} catch(e) {
|
|
||||||
console.warn('[goon-viewer] proxyFetch failed for:', url.substring(0, 120), e && e.message ? e.message : e);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Check if any manifest URLs require proxying (phncdn.com / cdn) ===
|
// CDN hosts that block extension-origin requests are learned by a CORS probe
|
||||||
function urlsNeedProxy(urls) {
|
// at viewer startup (hosts that fail the probe are recorded), then stored in
|
||||||
return urls.some(u => u && typeof u === 'string' && (u.includes('phncdn') || u.includes('pornhubcdn')));
|
// chrome.storage.local. The proxy is used only for those hosts, so we never
|
||||||
}
|
// hardcode a particular CDN.
|
||||||
|
const PROXY_HOSTS_KEY = 'goon_proxy_hosts';
|
||||||
|
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 () => {
|
||||||
|
try {
|
||||||
|
const stored = await chrome.storage.local.get(PROXY_HOSTS_KEY);
|
||||||
|
const hosts = stored[PROXY_HOSTS_KEY];
|
||||||
|
if (Array.isArray(hosts)) for (const h of hosts) proxyHosts.add(registrableDomainOf(String(h).toLowerCase()));
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
})();
|
||||||
|
|
||||||
/** Check whether fetching a URL from the extension page works (CORS test). */
|
// Registrable domain of a CDN host — the label a single provider referer
|
||||||
async function corsCheck(url) {
|
// applies to (a referer-protecting CDN serves from many edge subdomains that
|
||||||
|
// share one referer). Last-two-labels heuristic; mirrors the SW's copy.
|
||||||
|
function registrableDomainOf(host) {
|
||||||
|
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
|
||||||
|
// referer-protected CDN host.
|
||||||
|
function needsProxy(url) {
|
||||||
|
if (!url || typeof url !== 'string') return false;
|
||||||
|
const host = hostOfUrl(url);
|
||||||
|
return host ? proxyHosts.has(registrableDomainOf(host)) : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// host of a URL (lowercased); '' when malformed.
|
||||||
|
function hostOfUrl(url) {
|
||||||
|
try { return new URL(url).host.toLowerCase(); } catch { return ''; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whether any of the given URLs needs proxying.
|
||||||
|
function urlsNeedProxy(urls) {
|
||||||
|
return urls.some(u => needsProxy(u));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check whether fetching a URL from the extension page works (CORS test). */
|
||||||
|
async function corsCheck(url) {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(url, { method: 'HEAD', mode: 'cors' });
|
const resp = await fetch(url, { method: 'HEAD', mode: 'cors' });
|
||||||
return resp.ok;
|
return resp.ok;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Track all Hls instances so we can destroy them on unload. */
|
/** Track all Hls instances so we can destroy them on unload. */
|
||||||
const hlsInstances = new Map();
|
const hlsInstances = new Map();
|
||||||
@@ -702,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);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -750,13 +792,13 @@ function wireHlsEvents(hls, video, panel) {
|
|||||||
renderPanelState(video, panel);
|
renderPanelState(video, panel);
|
||||||
hls.destroy();
|
hls.destroy();
|
||||||
hlsInstances.delete(video);
|
hlsInstances.delete(video);
|
||||||
// A fatal network error on a phncdn stream is almost always an expired
|
// A fatal network error on a token-protected stream is almost always an
|
||||||
// auth token. The live source tab keeps rotating tokens, so pull a fresh
|
// expired auth token. The live source tab keeps rotating tokens, so pull a
|
||||||
// manifest from it and retry — capped at a few attempts per panel so a
|
// fresh manifest from it and retry — capped at a few attempts per panel so a
|
||||||
// tab with no live source doesn't loop forever.
|
// 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 {
|
||||||
@@ -930,12 +972,12 @@ function playHls(video, manifestUrl, panel) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check whether this manifest URL needs proxying (phncdn.com without CORS headers
|
// Check whether this manifest URL needs proxying (a referer-protected host
|
||||||
// blocks extension-origin requests from the viewer page).
|
// without CORS headers blocks extension-origin requests from the viewer page).
|
||||||
const needProxy = manifestUrl.includes('phncdn') || manifestUrl.includes('pornhubcdn');
|
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);
|
||||||
|
|
||||||
@@ -975,11 +1017,11 @@ 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 (extractVideoId keys off this).
|
// the freshest token on the same video (the service worker keys off the path).
|
||||||
video.dataset.hlsManifest = manifestUrl;
|
video.dataset.hlsManifest = manifestUrl;
|
||||||
|
|
||||||
hls.loadSource(manifestUrl);
|
hls.loadSource(manifestUrl);
|
||||||
hls.attachMedia(video);
|
hls.attachMedia(video);
|
||||||
@@ -997,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'));
|
||||||
@@ -1017,32 +1059,32 @@ function loadHlsJs() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ======================================================================== */
|
/* ======================================================================== */
|
||||||
/* Fresh-token resolution — replace expired/revoked phncdn URLs */
|
/* Fresh-token resolution — replace expired/revoked token URLs */
|
||||||
/* ======================================================================== */
|
/* ======================================================================== */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ask the service worker for the freshest live phncdn manifest for the video
|
* Ask the service worker for the freshest live manifest for the video encoded
|
||||||
* encoded in `storedUrl`. The live source tab keeps rotating short-lived
|
* in `storedUrl`. The live source tab keeps rotating short-lived auth tokens
|
||||||
* phncdn tokens (a stored one may already be 410 Gone), so the freshest
|
* (a stored one may already be 410 Gone), so the freshest captured manifest —
|
||||||
* captured manifest — identified by matching the videoId in the path — is the
|
* identified by matching the video's group key in the path — is the one that
|
||||||
* one that will actually validate. Resolves to the new URL, or null when the
|
* will actually validate. Resolves to the new URL, or null when the tab
|
||||||
* tab captured nothing (or the caller can't reach the SW).
|
* captured nothing (or the caller can't reach the SW).
|
||||||
*/
|
*/
|
||||||
async function resolveFreshManifest(storedUrl) {
|
async function resolveFreshManifest(storedUrl) {
|
||||||
if (!storedUrl || !storedUrl.includes('phncdn')) return null;
|
if (!storedUrl || !needsProxy(storedUrl)) return null;
|
||||||
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.videoId || '?'),
|
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Re-attempt playback on `video` after its stored manifest died (fatal
|
* Re-attempt playback on `video` after its stored manifest died (fatal
|
||||||
@@ -1055,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;
|
||||||
}
|
}
|
||||||
@@ -1105,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;
|
||||||
@@ -1140,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),
|
||||||
@@ -1154,18 +1196,35 @@ async function init() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test CORS on all video URLs
|
// Probe CORS on each video URL. Hosts that block extension-origin
|
||||||
const urlsToTest = videoUrls.map(v => v.src || v.hlsManifest || 'none').filter(u => u !== 'none' && typeof u === 'string');
|
// requests are learned as referer-protected and persisted so the viewer
|
||||||
for (const url of [...new Set(urlsToTest)]) {
|
// routes them through the service-worker proxy.
|
||||||
const corsOk = await corsCheck(url);
|
// Bind each entry's scan-time referer to its manifest's registrable
|
||||||
console.log('[goon-viewer] CORS', corsOk ? 'OK' : 'BLOCKED:', url.substring(0, 120));
|
// 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 blockedHosts = new Set();
|
||||||
|
for (const url of [...new Set(urlsToTest)]) {
|
||||||
|
const corsOk = await corsCheck(url);
|
||||||
|
if (!corsOk) blockedHosts.add(registrableDomainOf(hostOfUrl(url)));
|
||||||
|
console.log('[vidplex-viewer] CORS', corsOk ? 'OK' : 'BLOCKED:', url.substring(0, 120));
|
||||||
|
}
|
||||||
|
if (blockedHosts.size > 0) {
|
||||||
|
for (const h of blockedHosts) if (h) proxyHosts.add(h);
|
||||||
|
try { await chrome.storage.local.set({ [PROXY_HOSTS_KEY]: [...proxyHosts] }); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
// Check if any URLs point to phncdn.com (proxy will be needed if CORS blocked)
|
// Report whether any URL needs proxying (referer-protected host).
|
||||||
const needsProxy = urlsToTest.some(u => u.includes('phncdn') || u.includes('pornhubcdn'));
|
const anyNeedsProxy = urlsNeedProxy(urlsToTest);
|
||||||
console.log('[goon-viewer] Proxy required:', needsProxy);
|
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`;
|
||||||
@@ -1194,15 +1253,15 @@ async function init() {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Before playback, swap each stored phncdn manifest for the freshest live
|
// Before playback, swap each referer-protected manifest for the freshest
|
||||||
// one the source tab has captured (a stored token may already be 410
|
// live one the source tab has captured (a stored token may already be 410
|
||||||
// Gone). Non-phncdn manifests are left untouched.
|
// Gone). Non-protected manifests are left untouched.
|
||||||
for (const { video, manifest, panel } of hlsEntries) {
|
for (const { video, manifest } of hlsEntries) {
|
||||||
if (manifest.includes('phncdn')) {
|
if (needsProxy(manifest)) {
|
||||||
const fresh = await resolveFreshManifest(manifest);
|
const fresh = await resolveFreshManifest(manifest);
|
||||||
if (fresh) video.dataset.hlsManifest = fresh;
|
if (fresh) video.dataset.hlsManifest = fresh;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const { video, panel } of hlsEntries) {
|
for (const { video, panel } of hlsEntries) {
|
||||||
playHls(video, video.dataset.hlsManifest, panel);
|
playHls(video, video.dataset.hlsManifest, panel);
|
||||||
}
|
}
|
||||||
|
|||||||
File renamed without changes.
Reference in new issue
Block a user