From 001cf146eef6654ad673d7f8c36c8d61969d9bbb Mon Sep 17 00:00:00 2001 From: kfj001 Date: Sun, 16 Aug 2026 23:00:47 -0700 Subject: [PATCH] Attempted fix at pornhub video --- .claude/settings.local.json | 3 +- QA_REPORT_HLS.md | 160 ----------------------------------- manifest.json | 2 +- package-lock.json | 9 -- package.json | 2 +- scripts/copy-hlds.mjs | 20 +++++ src/background/background.js | 45 +++++++--- src/viewer/index.js | 109 ++++++++++++++++++++---- src/viewer/viewer.js | 41 ++++++--- vite.config.ts | 15 ++-- 10 files changed, 189 insertions(+), 217 deletions(-) delete mode 100644 QA_REPORT_HLS.md create mode 100644 scripts/copy-hlds.mjs diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 4102bd9..9e823bd 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -2,7 +2,8 @@ "permissions": { "allow": [ "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)" ] } } diff --git a/QA_REPORT_HLS.md b/QA_REPORT_HLS.md deleted file mode 100644 index 0dd4bb8..0000000 --- a/QA_REPORT_HLS.md +++ /dev/null @@ -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 diff --git a/manifest.json b/manifest.json index 1638b10..da7e907 100644 --- a/manifest.json +++ b/manifest.json @@ -34,7 +34,7 @@ ], "web_accessible_resources": [ { - "resources": ["interceptors.js", "hls.js"], + "resources": ["interceptors.js", "hlds.js"], "matches": [""] } ], diff --git a/package-lock.json b/package-lock.json index e016be1..9809d17 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,9 +7,6 @@ "": { "name": "goon-plugin", "version": "0.1.0", - "dependencies": { - "hls.js": "^1.5.17" - }, "devDependencies": { "ajv": "^8.17.1", "ajv-formats": "^3.0.1", @@ -2784,12 +2781,6 @@ "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": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", diff --git a/package.json b/package.json index 149dbae..c406242 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "type": "module", "scripts": { "dev": "vite", - "build": "vite build", + "build": "vite build && node scripts/copy-hlds.mjs", "lint": "eslint .", "lint:fix": "eslint . --fix", "test": "node __tests__/run.mjs", diff --git a/scripts/copy-hlds.mjs b/scripts/copy-hlds.mjs new file mode 100644 index 0000000..e2fa815 --- /dev/null +++ b/scripts/copy-hlds.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)`); diff --git a/src/background/background.js b/src/background/background.js index 7f374cc..25df114 100644 --- a/src/background/background.js +++ b/src/background/background.js @@ -60,22 +60,47 @@ async function persistVideoRequest(tabId, url) { } // Reconstruct manifest URL from segment URLs -// Segment: https://phncdn.com/videos/123456/seg-0-v1-a1.ts -// Manifest: https://phncdn.com/videos/123456/playlist.m3u8 +// 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://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) { const seen = new Set(); const manifests = []; 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 { const parsed = new URL(url); - const parts = parsed.pathname.split('/'); - // Find pattern: .../videos/{videoId}/seg-{n}... - const vidIdx = parts.findIndex(p => p === 'videos'); - if (vidIdx === -1 || vidIdx + 2 >= parts.length) continue; - const videoId = parts[vidIdx + 1]; - // Build manifest URL: same origin, /videos/{videoId}/playlist.m3u8 - const manifestUrl = `${parsed.protocol}//${parsed.host}/videos/${videoId}/playlist.m3u8`; + const host = parsed.host.toLowerCase(); + // Skip non-phncdn hosts — we only reconstruct for known video CDNs + if (!host.includes('phncdn')) continue; + + const pathParts = parsed.pathname.split('/'); + // Find the /hls/ marker and extract: hls/videos/{year}/{subdir}/{videoId}/{variant}/... + 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)) { seen.add(manifestUrl); manifests.push(manifestUrl); diff --git a/src/viewer/index.js b/src/viewer/index.js index 5169a42..44b24c4 100644 --- a/src/viewer/index.js +++ b/src/viewer/index.js @@ -829,29 +829,72 @@ function filterManifests(manifests) { (u.includes('/playlist') && !u.endsWith('.ts')); }); - // If we have actual manifests, use them directly - if (actualManifests.length > 0) { - return actualManifests.map(m => ({ url: m.url })); + // Reject fake reconstructed manifest paths. + // Real manifests are under /hls/videos/{year}/{subdir}/{videoId}/... + // 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. - // Reconstruct the manifest URL from segment URLs. - // Segment URLs look like: /hls/videos/{video-id}/seg-{n}-v{ver}-a{aud}.ts - // Manifest URL would be: /hls/videos/{video-id}/playlist.m3u8 + // Reconstruct the parent manifest URL from segment paths. + // 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://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 reconstructed = []; + for (const m of manifests) { const u = m.url.toLowerCase(); - if (!u.includes('/hls/') || !u.endsWith('.ts')) continue; + if (!u.includes('/hls/videos/') || !/\/seg-\d+/.test(u)) continue; try { - const url = new URL(m.url); - const pathParts = url.pathname.split('/'); - // Find the /hls/videos/ segment and the video-id directory + 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('/'); + // Find the /hls/ marker and extract: hls/videos/{year}/{subdir}/{videoId}/{variant}/... const hlsIdx = pathParts.findIndex(p => p === 'hls'); - if (hlsIdx === -1 || hlsIdx + 2 >= pathParts.length) continue; - // Build manifest URL: origin + /hls/videos/{video-id}/playlist.m3u8 - const manifestPath = `${pathParts[hlsIdx]}/${pathParts[hlsIdx + 1]}/${pathParts[hlsIdx + 2]}/playlist.m3u8`; - const manifestUrl = `${url.protocol}//${url.host}${manifestPath}`; + 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 + 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)) { seen.add(manifestUrl); reconstructed.push({ url: manifestUrl }); @@ -861,8 +904,30 @@ function filterManifests(manifests) { 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. + * 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 {string} tabTitle * @param {string|null} posterFromContent - poster URL from content script (OG image, etc.) @@ -875,7 +940,19 @@ function makeVideoEntry(video, tabTitle, posterFromContent) { tab: tabTitle, 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; } @@ -930,7 +1007,7 @@ async function scanVideos(tabsToScan) { // 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 // 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 phncdnResults = await Promise.all(phncdnPromises); let totalPhncdnManifests = 0; diff --git a/src/viewer/viewer.js b/src/viewer/viewer.js index c4b9be7..9920093 100644 --- a/src/viewer/viewer.js +++ b/src/viewer/viewer.js @@ -822,8 +822,11 @@ function playHls(video, manifestUrl, panel) { const config = Object.assign({}, HLS_CONFIG); if (needProxy) { - // Custom fetch loader that uses the service worker proxy for phncdn URLs. - config.loader = window.Hls.DefaultConfig.loader || HlsLoader; + // Override hls.js's default XHRLoader with our custom proxy loader. + // 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); @@ -859,21 +862,33 @@ function playHls(video, manifestUrl, panel) { } /** - * Load 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 - * avoid CSP blocks — sites like Pornhub restrict script-src to 'self', which - * would block any cross-origin