This commit is contained in:
1 parent
b234c8dcae
commit
001cf146ee
10 files changed
+189
-217
No files matched your search
@@ -2,7 +2,8 @@
|
|||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": [
|
||||||
"Bash(npm run *)",
|
"Bash(npm run *)",
|
||||||
"Bash(node --check /home/kyle/Documents/goon_plugin/src/viewer/index.js)"
|
"Bash(node --check /home/kyle/Documents/goon_plugin/src/viewer/index.js)",
|
||||||
|
"Bash(open -a \"Microsoft Edge\" --args --remote-debugging-port=9222 --profile-directory=Default)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
# Goon Plugin — HLS QA Report
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
**Scope**: Comprehensive QA verification of HLS (HTTP Live Streaming) support in the Goon Plugin Chrome extension, including detection, playback, tracking, interceptors, deep probe strategies, and edge cases.
|
|
||||||
|
|
||||||
**Result**: 83/83 tests passing (39 existing + 44 new HLS-specific QA tests). 1 bug found and documented. 2 enhancement recommendations.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Test Coverage
|
|
||||||
|
|
||||||
### New Test Suite: `hls_qa.test.mjs` (44 tests)
|
|
||||||
|
|
||||||
#### Fragmented Playback Tracking (5 tests)
|
|
||||||
- Segment loaded/failed counting via FRAG_LOADED, FRAG_BUFFERED, ERROR events
|
|
||||||
- Buffer health tracking (BUFFER_APPENDED, BUFFER_FLUSHED, BUFFER_RESET)
|
|
||||||
- Quality level switch tracking (LEVEL_SWITCHED, LEVEL_UPDATED)
|
|
||||||
- FPS drop tracking (FPS_DROP, FPS_MAX_LEVEL_DROP)
|
|
||||||
- Video element lifecycle events (autoplay, muted, loop, playsInline)
|
|
||||||
|
|
||||||
**Verdict**: PASS — All HLS.js event handlers properly wired to panel state machine.
|
|
||||||
|
|
||||||
#### Manifest Fallback Behavior (5 tests)
|
|
||||||
- hlsManifest field used when present
|
|
||||||
- src falls back when hlsManifest missing AND src looks like .m3u8
|
|
||||||
- Non-.m3u8 src NOT treated as HLS manifest (mediaType=hls but src=.mp4)
|
|
||||||
- blob: URLs not treated as HLS
|
|
||||||
- data: URLs handled gracefully
|
|
||||||
|
|
||||||
**Verdict**: PASS — Fallback chain works correctly: hlsManifest > src (if .m3u8) > native playback. Warning logged when HLS entry has no valid manifest.
|
|
||||||
|
|
||||||
#### Interceptor Detection (8 tests)
|
|
||||||
- XHR .m3u8 URL detection
|
|
||||||
- fetch .m3u8 URL detection
|
|
||||||
- Hls.prototype.loadSource interception
|
|
||||||
- Deduplication of same manifest URL
|
|
||||||
- Relative URL resolution to absolute
|
|
||||||
- Non-HLS URLs NOT intercepted
|
|
||||||
- /playlist and /manifest URL detection (even without .m3u8 extension)
|
|
||||||
- Idempotent double-injection prevention
|
|
||||||
|
|
||||||
**Verdict**: PASS — Interceptors detect all HLS request patterns, deduplicate, resolve relative URLs, and are safe against double injection.
|
|
||||||
|
|
||||||
#### Deep Probe Strategies (7 tests)
|
|
||||||
- Player global detection (hls, videojs, etc.)
|
|
||||||
- Inline script .m3u8 URL extraction
|
|
||||||
- Interceptor-collected manifest detection (__goon_plugin_data)
|
|
||||||
- Hls.cast(video) API support
|
|
||||||
- Interceptor-collected manifests preferred over inline-script (priority order)
|
|
||||||
- Config globals (__NEXT_DATA__) — BUG DOCUMENTED
|
|
||||||
- Config globals with top-level .m3u8 values — BUG DOCUMENTED
|
|
||||||
|
|
||||||
**Verdict**: PASS with BUG — 5 of 7 probes work correctly. 2 config-global tests document a walk() dead-code bug (see below).
|
|
||||||
|
|
||||||
#### Configuration and Resource Management (5 tests)
|
|
||||||
- Grid-optimized HLS config (maxBufferLength: 10s, capLevelToPlayerSize, maxBufferSize: 2MB, lowLatencyMode: false)
|
|
||||||
- CDN version pinned to @1.5.17 (not floating)
|
|
||||||
- HLS instance destruction on page unload
|
|
||||||
- Storage cleanup on viewer close
|
|
||||||
- CDN failure graceful handling
|
|
||||||
|
|
||||||
**Verdict**: PASS — Config values appropriate for grid-panel usage. Resource lifecycle is correct.
|
|
||||||
|
|
||||||
#### iframe and Multi-frame Scenarios (3 tests)
|
|
||||||
- HLS probe uses allFrames: true
|
|
||||||
- Video scraping uses allFrames: true
|
|
||||||
- content_scripts manifest has all_frames: true + run_at: document_start
|
|
||||||
|
|
||||||
**Verdict**: PASS — Iframe detection properly configured at all levels.
|
|
||||||
|
|
||||||
#### Edge Cases (6 tests)
|
|
||||||
- Empty HLS manifest URL (empty string) — falls back to native
|
|
||||||
- Null hlsManifest field — falls back to native
|
|
||||||
- Multiple HLS streams in same grid (3 unique instances)
|
|
||||||
- Oversized grid (3x3 with 1 video — 8 empty panels)
|
|
||||||
- Mixed HLS + blob source in same grid
|
|
||||||
- /manifest URLs without .m3u8 extension
|
|
||||||
|
|
||||||
**Verdict**: PASS — All edge cases handled gracefully without crashes.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Bug Found
|
|
||||||
|
|
||||||
### BUG: walk() dead code in m3u8DeepProbeFn — config globals not fully scanned
|
|
||||||
|
|
||||||
**Severity**: MEDIUM
|
|
||||||
**Location**: `src/viewer/index.js`, lines 107-120
|
|
||||||
**Affected probe**: Strategy D (Config globals: __NEXT_DATA__, appData, __INITIAL_STATE__, etc.)
|
|
||||||
|
|
||||||
**Root Cause**:
|
|
||||||
```js
|
|
||||||
function walk(obj, path, depth) {
|
|
||||||
if (!obj || typeof obj !== 'object' || depth > 5) return; // GUARD
|
|
||||||
if (typeof obj === 'string') { // NEVER REACHED — dead code
|
|
||||||
const match = obj.match(/https?:\/\/.../gi);
|
|
||||||
...
|
|
||||||
}
|
|
||||||
for (const k of Object.keys(obj)) {
|
|
||||||
walk(obj[k], `${path}.${k}`, depth + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The guard `typeof obj !== 'object'` returns early for string values. When `walk()` recurses into `__NEXT_DATA__.props.pageProps.streamUrl`, the string value is rejected by the guard before the regex match can run. The `typeof obj === 'string'` branch is dead code.
|
|
||||||
|
|
||||||
**Impact**: Config globals (Next.js, React SSR, etc.) that embed .m3u8 URLs deeply nested in JSON objects are NOT detected. Shallow player globals (hls.url, etc.) still work via Strategy A's direct key check.
|
|
||||||
|
|
||||||
**Fix**: Move the string check before the object guard:
|
|
||||||
```js
|
|
||||||
function walk(obj, path, depth) {
|
|
||||||
if (typeof obj === 'string') {
|
|
||||||
const match = obj.match(/https?:\/\/.../gi);
|
|
||||||
// ...
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!obj || typeof obj !== 'object' || depth > 5) return;
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Mitigation**: The interceptors (Strategy E) are the primary detection method and work correctly. This only affects pages that rely solely on config globals without active HLS players.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Enhancement Recommendations
|
|
||||||
|
|
||||||
### 1. MediaSource MIME type validation for blob: URLs
|
|
||||||
blob: URLs in `src` are currently treated as direct MediaSource playback. There is no verification that the content type is actually HLS-compatible (`application/x-mpegURL`). If a page sets `mediaType: 'hls'` with a `blob:` src, the viewer warns about missing manifest but falls through to native playback. Consider adding `MediaSource.isTypeSupported()` checks for blob URLs that claim to be HLS.
|
|
||||||
|
|
||||||
### 2. HLS.js error recovery in grid panels
|
|
||||||
When `onError` fires for one panel, `recoverMediaError()` is called. If recovery fails, the panel shows a persistent error overlay. There is no retry after recovery — the panel stays broken. Consider adding a configurable retry count (e.g., 3 attempts with backoff) before marking the panel as permanently failed.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Existing Test Suite
|
|
||||||
|
|
||||||
All 39 pre-existing tests continue to pass:
|
|
||||||
- 10 Schema validation tests
|
|
||||||
- 4 resolveSrc() tests
|
|
||||||
- 5 isHls() tests
|
|
||||||
- 3 resolveHlsManifest() tests
|
|
||||||
- 3 DOM setup tests
|
|
||||||
- 5 Data flow tests
|
|
||||||
- 9 Integration tests
|
|
||||||
- 5 Config/build integrity tests
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Files Changed
|
|
||||||
|
|
||||||
- `__tests__/hls_qa.test.mjs` — NEW: 44 HLS-specific QA tests (83 total tests in suite)
|
|
||||||
- `__tests__/run.mjs` — ADDED: import for hls_qa.test.mjs
|
|
||||||
|
|
||||||
## Build/Deploy Status
|
|
||||||
|
|
||||||
- 83/83 tests passing in 12s
|
|
||||||
- No linting errors in test files
|
|
||||||
- No changes to production source files
|
|
||||||
+1
-1
@@ -34,7 +34,7 @@
|
|||||||
],
|
],
|
||||||
"web_accessible_resources": [
|
"web_accessible_resources": [
|
||||||
{
|
{
|
||||||
"resources": ["interceptors.js", "hls.js"],
|
"resources": ["interceptors.js", "hlds.js"],
|
||||||
"matches": ["<all_urls>"]
|
"matches": ["<all_urls>"]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
Generated
-9
@@ -7,9 +7,6 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "goon-plugin",
|
"name": "goon-plugin",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
|
||||||
"hls.js": "^1.5.17"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"ajv": "^8.17.1",
|
"ajv": "^8.17.1",
|
||||||
"ajv-formats": "^3.0.1",
|
"ajv-formats": "^3.0.1",
|
||||||
@@ -2784,12 +2781,6 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/hls.js": {
|
|
||||||
"version": "1.6.17",
|
|
||||||
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.17.tgz",
|
|
||||||
"integrity": "sha512-NUplVGVuc1hSPwdB/9/cbRkUmLrYi75/hqiXKdA+l300pJNxDu96R7jRb2imDzWJqIUF4I5ThmAdp9GvOCXsuQ==",
|
|
||||||
"license": "Apache-2.0"
|
|
||||||
},
|
|
||||||
"node_modules/html-encoding-sniffer": {
|
"node_modules/html-encoding-sniffer": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build && node scripts/copy-hlds.mjs",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"lint:fix": "eslint . --fix",
|
"lint:fix": "eslint . --fix",
|
||||||
"test": "node __tests__/run.mjs",
|
"test": "node __tests__/run.mjs",
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// Copy the original ES5 hls.min.js source directly to dist/hlds.js.
|
||||||
|
// This avoids Vite/esbuild adding arrow functions (which Chrome flags as "ES module content"
|
||||||
|
// in extension URLs, preventing window.Hls from registering).
|
||||||
|
|
||||||
|
import { readFileSync, writeFileSync } from 'fs';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { dirname, resolve } from 'path';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
const src = resolve(__dirname, '../src/inject/hls.min.js');
|
||||||
|
const dst = resolve(__dirname, '../dist/hlds.js');
|
||||||
|
let data = readFileSync(src, 'utf-8');
|
||||||
|
|
||||||
|
// Remove sourceMappingURL line (avoids Chrome trying to fetch it from an invalid chrome-extension:// URL)
|
||||||
|
data = data.replace(/[\r\n]+\/\/# sourceMappingURL=[^\s]*[\r\n]*/, '');
|
||||||
|
|
||||||
|
writeFileSync(dst, data);
|
||||||
|
console.log(`[copy-hlds] Copied hls.min.js -> dist/hlds.js (${data.length} bytes, pure ES5 source)`);
|
||||||
@@ -60,22 +60,47 @@ async function persistVideoRequest(tabId, url) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Reconstruct manifest URL from segment URLs
|
// Reconstruct manifest URL from segment URLs
|
||||||
// Segment: https://phncdn.com/videos/123456/seg-0-v1-a1.ts
|
// 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
|
||||||
// Manifest: https://phncdn.com/videos/123456/playlist.m3u8
|
// Manifest: https://ev-h.phncdn.com/hls/videos/202008/01/338337051/master.m3u8?h=tmrD...&e=1786932752&f=1
|
||||||
|
//
|
||||||
|
// Auth tokens (h, e, f or validfrom, validto, hash) are extracted from segment query params
|
||||||
|
// and reapplied to the reconstructed manifest URL. Without auth, CDN returns 403.
|
||||||
function reconstructManifestsFromSegments(segments) {
|
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) {
|
||||||
if (!url.includes('/videos/') || !url.includes('/seg-')) continue;
|
// Only handle HLS segment URLs with the full path: /hls/videos/{year}/{subdir}/{vid}...
|
||||||
|
if (!url.includes('/hls/videos/') || !/\/seg-\d+/.test(url)) continue;
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(url);
|
const parsed = new URL(url);
|
||||||
const parts = parsed.pathname.split('/');
|
const host = parsed.host.toLowerCase();
|
||||||
// Find pattern: .../videos/{videoId}/seg-{n}...
|
// Skip non-phncdn hosts — we only reconstruct for known video CDNs
|
||||||
const vidIdx = parts.findIndex(p => p === 'videos');
|
if (!host.includes('phncdn')) continue;
|
||||||
if (vidIdx === -1 || vidIdx + 2 >= parts.length) continue;
|
|
||||||
const videoId = parts[vidIdx + 1];
|
const pathParts = parsed.pathname.split('/');
|
||||||
// Build manifest URL: same origin, /videos/{videoId}/playlist.m3u8
|
// Find the /hls/ marker and extract: hls/videos/{year}/{subdir}/{videoId}/{variant}/...
|
||||||
const manifestUrl = `${parsed.protocol}//${parsed.host}/videos/${videoId}/playlist.m3u8`;
|
const hlsIdx = pathParts.findIndex(p => p === 'hls');
|
||||||
|
if (hlsIdx === -1 || hlsIdx + 5 >= pathParts.length) continue;
|
||||||
|
// Reconstruct with BOTH "hls" prefix AND variant directory included:
|
||||||
|
// segment: /hls/videos/202008/01/338337051/720P_4000K_338337051.mp4/seg-1.ts
|
||||||
|
// manifest: /hls/videos/202008/01/338337051/720P_4000K_338337051.mp4/master.m3u8
|
||||||
|
const year = pathParts[hlsIdx + 2]; // e.g. 2020
|
||||||
|
const subDir = pathParts[hlsIdx + 3]; // e.g. 01 (sub-category)
|
||||||
|
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 parsed.searchParams) {
|
||||||
|
if (['h', 'e', 'f', 'validfrom', 'validto', 'hash'].includes(key)) {
|
||||||
|
authParts.push(`${key}=${val}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const authStr = authParts.length > 0 ? '?' + authParts.join('&') : '';
|
||||||
|
|
||||||
|
// Build manifest: include hls prefix AND variant directory
|
||||||
|
const manifestUrl = `${parsed.protocol}//${parsed.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);
|
||||||
manifests.push(manifestUrl);
|
manifests.push(manifestUrl);
|
||||||
|
|||||||
+93
-16
@@ -829,29 +829,72 @@ function filterManifests(manifests) {
|
|||||||
(u.includes('/playlist') && !u.endsWith('.ts'));
|
(u.includes('/playlist') && !u.endsWith('.ts'));
|
||||||
});
|
});
|
||||||
|
|
||||||
// If we have actual manifests, use them directly
|
// Reject fake reconstructed manifest paths.
|
||||||
if (actualManifests.length > 0) {
|
// Real manifests are under /hls/videos/{year}/{subdir}/{videoId}/...
|
||||||
return actualManifests.map(m => ({ url: 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) {
|
||||||
|
return valid.map(m => ({ url: m.url }));
|
||||||
}
|
}
|
||||||
|
|
||||||
// No .m3u8 found — interceptors only captured .ts segments.
|
// No .m3u8 found — interceptors only captured .ts segments.
|
||||||
// Reconstruct the manifest URL from segment URLs.
|
// Reconstruct the parent manifest URL from segment paths.
|
||||||
// Segment URLs look like: /hls/videos/{video-id}/seg-{n}-v{ver}-a{aud}.ts
|
// 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
|
||||||
// Manifest URL would be: /hls/videos/{video-id}/playlist.m3u8
|
// Manifest: https://ev-h.phncdn.com/hls/videos/202008/01/338337051/720P_4000K_338337051.mp4/master.m3u8?h=tmrD...&e=1786932752&f=1
|
||||||
|
//
|
||||||
|
// 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 = [];
|
||||||
|
|
||||||
for (const m of manifests) {
|
for (const m of manifests) {
|
||||||
const u = m.url.toLowerCase();
|
const u = m.url.toLowerCase();
|
||||||
if (!u.includes('/hls/') || !u.endsWith('.ts')) continue;
|
if (!u.includes('/hls/videos/') || !/\/seg-\d+/.test(u)) continue;
|
||||||
try {
|
try {
|
||||||
const url = new URL(m.url);
|
const urlObj = new URL(m.url);
|
||||||
const pathParts = url.pathname.split('/');
|
// Skip non-phncdn hosts — we only reconstruct for known video CDNs
|
||||||
// Find the /hls/videos/ segment and the video-id directory
|
const host = urlObj.host.toLowerCase();
|
||||||
|
if (!host.includes('phncdn')) continue;
|
||||||
|
|
||||||
|
const pathParts = urlObj.pathname.split('/');
|
||||||
|
// Find the /hls/ marker and extract: hls/videos/{year}/{subdir}/{videoId}/{variant}/...
|
||||||
const hlsIdx = pathParts.findIndex(p => p === 'hls');
|
const hlsIdx = pathParts.findIndex(p => p === 'hls');
|
||||||
if (hlsIdx === -1 || hlsIdx + 2 >= pathParts.length) continue;
|
if (hlsIdx === -1 || hlsIdx + 5 >= pathParts.length) continue;
|
||||||
// Build manifest URL: origin + /hls/videos/{video-id}/playlist.m3u8
|
// Reconstruct with BOTH "hls" prefix AND variant directory included:
|
||||||
const manifestPath = `${pathParts[hlsIdx]}/${pathParts[hlsIdx + 1]}/${pathParts[hlsIdx + 2]}/playlist.m3u8`;
|
// segment: /hls/videos/202008/01/338337051/720P_4000K_338337051.mp4/seg-1.ts
|
||||||
const manifestUrl = `${url.protocol}//${url.host}${manifestPath}`;
|
// manifest: /hls/videos/202008/01/338337051/720P_4000K_338337051.mp4/master.m3u8
|
||||||
|
const year = pathParts[hlsIdx + 2]; // e.g. 2020
|
||||||
|
const subDir = pathParts[hlsIdx + 3]; // e.g. 01
|
||||||
|
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 });
|
||||||
@@ -861,8 +904,30 @@ function filterManifests(manifests) {
|
|||||||
return reconstructed;
|
return reconstructed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that a manifest URL uses a real phncdn.com path structure.
|
||||||
|
* Rejects fake paths like /videos/{id}/playlist.m3u8 — real manifests use
|
||||||
|
* /hls/videos/{year}/{subdir}/{videoId}/master.m3u8 or similar /hls/ paths.
|
||||||
|
*/
|
||||||
|
function isValidManifestUrl(url) {
|
||||||
|
if (!url || typeof url !== 'string') return false;
|
||||||
|
// Real phncdn HLS manifest: must contain /hls/videos/ with sufficient path depth
|
||||||
|
const hlsIdx = url.indexOf('/hls/videos/');
|
||||||
|
if (hlsIdx === -1) {
|
||||||
|
// Not phncdn — could be a different CDN, allow it
|
||||||
|
if (!url.includes('phncdn') && !url.includes('pornhubcdn')) return true;
|
||||||
|
// phncdn without /hls/ prefix is invalid for manifests
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Check there are at least {year}/{subdir}/{videoId} after /hls/videos/
|
||||||
|
const after = url.substring(hlsIdx + '/hls/videos/'.length);
|
||||||
|
if (after.indexOf('/') === -1) return false; // need at least one more segment
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a normalized video entry from a scraped video object.
|
* Build a normalized video entry from a scraped video object.
|
||||||
|
* Validates manifest URLs to prevent storing fake paths like /videos/{id}/playlist.m3u8.
|
||||||
* @param {{ src: string, poster: string|null, mediaType: string, hlsManifest: string|null }} video
|
* @param {{ src: string, poster: string|null, mediaType: string, hlsManifest: string|null }} video
|
||||||
* @param {string} tabTitle
|
* @param {string} tabTitle
|
||||||
* @param {string|null} posterFromContent - poster URL from content script (OG image, etc.)
|
* @param {string|null} posterFromContent - poster URL from content script (OG image, etc.)
|
||||||
@@ -875,7 +940,19 @@ function makeVideoEntry(video, tabTitle, posterFromContent) {
|
|||||||
tab: tabTitle,
|
tab: tabTitle,
|
||||||
mediaType: video.mediaType || 'video',
|
mediaType: video.mediaType || 'video',
|
||||||
};
|
};
|
||||||
if (video.hlsManifest) entry.hlsManifest = video.hlsManifest;
|
if (video.hlsManifest) {
|
||||||
|
// Reject fake phncdn manifest paths before storing
|
||||||
|
if (!isValidManifestUrl(video.hlsManifest)) {
|
||||||
|
console.warn('[goon] Rejecting fake manifest URL:', video.hlsManifest.substring(0, 120));
|
||||||
|
} else {
|
||||||
|
entry.hlsManifest = video.hlsManifest;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 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)) {
|
||||||
|
console.warn('[goon] 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)
|
||||||
|
}
|
||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -930,7 +1007,7 @@ async function scanVideos(tabsToScan) {
|
|||||||
// Step 1.6: Strategy 8 — Query phncdn.com requests and reconstruct manifest URLs.
|
// Step 1.6: Strategy 8 — Query phncdn.com 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 from phncdn.com and deriving
|
||||||
// the parent manifest URL: /videos/{id}/playlist.m3u8
|
// the parent manifest URL: /hls/videos/{id}/master.m3u8
|
||||||
const phncdnPromises = tabsToScan.map(t => queryPhncdnRequests(t.id));
|
const phncdnPromises = tabsToScan.map(t => queryPhncdnRequests(t.id));
|
||||||
const phncdnResults = await Promise.all(phncdnPromises);
|
const phncdnResults = await Promise.all(phncdnPromises);
|
||||||
let totalPhncdnManifests = 0;
|
let totalPhncdnManifests = 0;
|
||||||
|
|||||||
+28
-13
@@ -822,8 +822,11 @@ function playHls(video, manifestUrl, panel) {
|
|||||||
const config = Object.assign({}, HLS_CONFIG);
|
const config = Object.assign({}, HLS_CONFIG);
|
||||||
|
|
||||||
if (needProxy) {
|
if (needProxy) {
|
||||||
// Custom fetch loader that uses the service worker proxy for phncdn URLs.
|
// Override hls.js's default XHRLoader with our custom proxy loader.
|
||||||
config.loader = window.Hls.DefaultConfig.loader || HlsLoader;
|
// Must NOT use DefaultConfig.loader || HlsLoader — the XHRLoader is always truthy,
|
||||||
|
// so that would never apply HlsLoader. Set it directly on config (hls.js uses
|
||||||
|
// the instance config, not DefaultConfig, when present).
|
||||||
|
config.loader = HlsLoader;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hls = new window.Hls(config);
|
const hls = new window.Hls(config);
|
||||||
@@ -859,21 +862,33 @@ function playHls(video, manifestUrl, panel) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load hls.js from the extension bundle. Returns a Promise that resolves when ready.
|
* Load hlds.js (renamed from hls.js) from the extension bundle. Returns a Promise that resolves when ready.
|
||||||
* We load from chrome.runtime.getURL() (extension origin) instead of a CDN to
|
* Strategy: <script src="chrome-extension://.../hlds.js"> — esbuild minification is disabled so the
|
||||||
* avoid CSP blocks — sites like Pornhub restrict script-src to 'self', which
|
* original ES5 UMD bundle preserves function expressions (no arrow functions), avoiding Chrome's
|
||||||
* would block any cross-origin <script src> injection in the viewer window.
|
* content-based ESM auto-detection that prevents window.Hls from registering globally.
|
||||||
*/
|
*/
|
||||||
function loadHlsJs() {
|
function loadHlsJs() {
|
||||||
return new Promise((resolve, reject) => {
|
if (window.Hls && typeof window.Hls.isSupported === 'function') {
|
||||||
if (window.Hls && window.Hls.isSupported) {
|
return Promise.resolve();
|
||||||
resolve();
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hldsSrc = chrome.runtime.getURL('hlds.js');
|
||||||
|
console.log('[goon-viewer] Trying script src:', hldsSrc);
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
const script = document.createElement('script');
|
const script = document.createElement('script');
|
||||||
script.src = chrome.runtime.getURL('hls.js');
|
script.src = hldsSrc;
|
||||||
script.onload = () => resolve();
|
script.onload = () => {
|
||||||
script.onerror = () => reject(new Error('Hls.js bundle unavailable - rebuild the extension.'));
|
if (window.Hls && typeof window.Hls.isSupported === 'function') {
|
||||||
|
console.log('[goon-viewer] hlds.js via script src OK, Hls.isSupported:', window.Hls.isSupported());
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
reject(new Error('hlds.js loaded but did not register global Hls'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
script.onerror = () => {
|
||||||
|
reject(new Error('hlds.js failed to load: resource not found or CSP blocked'));
|
||||||
|
};
|
||||||
document.head.appendChild(script);
|
document.head.appendChild(script);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-6
@@ -8,8 +8,8 @@
|
|||||||
*/
|
*/
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import { resolve } from 'path';
|
import { resolve } from 'path';
|
||||||
import { copyFileSync, mkdirSync, writeFileSync } from 'fs';
|
|
||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
|
import { copyFileSync, mkdirSync, writeFileSync } from 'fs';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
root: 'src',
|
root: 'src',
|
||||||
@@ -51,10 +51,8 @@ export default defineConfig({
|
|||||||
build: {
|
build: {
|
||||||
target: 'chrome110',
|
target: 'chrome110',
|
||||||
outDir: '../dist',
|
outDir: '../dist',
|
||||||
emptyOutDir: true,
|
emptyOutdir: true,
|
||||||
modulePreload: {
|
minify: false,
|
||||||
polyfill: false,
|
|
||||||
},
|
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
input: {
|
input: {
|
||||||
background: resolve(__dirname, 'src/background/background.js'),
|
background: resolve(__dirname, 'src/background/background.js'),
|
||||||
@@ -62,7 +60,9 @@ export default defineConfig({
|
|||||||
viewer: resolve(__dirname, 'src/viewer/viewer.html'),
|
viewer: resolve(__dirname, 'src/viewer/viewer.html'),
|
||||||
viewerIndex: resolve(__dirname, 'src/viewer/index.html'),
|
viewerIndex: resolve(__dirname, 'src/viewer/index.html'),
|
||||||
interceptors: resolve(__dirname, 'src/inject/interceptors.js'),
|
interceptors: resolve(__dirname, 'src/inject/interceptors.js'),
|
||||||
hls: resolve(__dirname, 'src/inject/hls.min.js'),
|
// NOTE: hlds is NOT built by Vite — copied from source as pure ES5 to avoid
|
||||||
|
// esbuild injecting arrow functions (Chrome flags these as "ES module content"
|
||||||
|
// in extension URLs, preventing window.Hls registration).
|
||||||
},
|
},
|
||||||
output: {
|
output: {
|
||||||
entryFileNames: '[name].js',
|
entryFileNames: '[name].js',
|
||||||
@@ -70,5 +70,8 @@ export default defineConfig({
|
|||||||
assetFileNames: '[name][extname]',
|
assetFileNames: '[name][extname]',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
modulePreload: {
|
||||||
|
polyfill: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user