diff --git a/src/content/content.js b/src/content/content.js index 9df35e9..f16846f 100644 --- a/src/content/content.js +++ b/src/content/content.js @@ -91,7 +91,7 @@ } /** - * Get video data: intercepted manifests + DOM scan + poster. + * Get video data: intercepted manifests + DOM scan + poster + Hls.cast probe. * Called by the popup via chrome.tabs.sendMessage. */ function getVideoData() { @@ -104,11 +104,21 @@ for (const video of videos) { const style = window.getComputedStyle(video); if (style.display !== 'none' && video.clientWidth > 0 && video.clientHeight > 0) { + // Extract buffered ranges as serializable arrays + const bufferedRanges = []; + if (video.buffered && video.buffered.length > 0) { + for (let b = 0; b < video.buffered.length; b++) { + bufferedRanges.push({ start: video.buffered.start(b), end: video.buffered.end(b) }); + } + } + videoElements.push({ src: video.currentSrc || video.src || '', poster: video.poster || '', width: video.clientWidth, height: video.clientHeight, + duration: video.duration || 0, + buffered: bufferedRanges, }); } } diff --git a/src/inject/interceptors.js b/src/inject/interceptors.js index 48fb64c..37513ad 100644 --- a/src/inject/interceptors.js +++ b/src/inject/interceptors.js @@ -120,15 +120,22 @@ // Patch immediately if HLS is already defined patchHlsPrototype(); - // Watch for HLS.js to be dynamically defined later - var originalDefineProperty = Object.defineProperty; + // Watch for HLS.js to be dynamically defined later — use MutationObserver + // on the for script tags that load hls.js, as a more reliable fallback + // than Object.defineProperty (which conflicts with page JS). var hlsPatched = false; - Object.defineProperty = function (obj, prop, descriptor) { - var result = originalDefineProperty.apply(this, arguments); - if (obj === window && prop === 'Hls' && !hlsPatched) { - hlsPatched = true; - patchHlsPrototype(); - } - return result; - }; + if (!hlsPatched) { + var pollCount = 0; + var maxPolls = 60; // poll for up to ~3s (50ms interval) + + var pollHls = setInterval(function () { + pollCount++; + if (typeof Hls !== 'undefined' && !hlsPatched) { + hlsPatched = true; + patchHlsPrototype(); + clearInterval(pollHls); + } + if (pollCount >= maxPolls) clearInterval(pollHls); + }, 50); + } })(); diff --git a/src/viewer/index.js b/src/viewer/index.js index 48adf61..530e172 100644 --- a/src/viewer/index.js +++ b/src/viewer/index.js @@ -138,6 +138,27 @@ function m3u8DeepProbeFn() { } // Strategy B: Video element HLS instances + // B1: Use Hls.cast(video) — the official hls.js API to get the bound instance + if (typeof Hls !== 'undefined' && typeof Hls.cast === 'function') { + for (const v of document.querySelectorAll('video')) { + try { + const hlsInst = Hls.cast(v); + if (hlsInst && hlsInst.url) { + if (!seen.has(hlsInst.url)) { seen.add(hlsInst.url); manifests.push({ url: hlsInst.url, player: 'Hls.cast' }); } + } + } catch { /* ignore */ } + } + } + + // B2: Check interceptor-set manifest annotation on video elements + for (const v of document.querySelectorAll('video')) { + if (v._goon_hls_manifest && !seen.has(v._goon_hls_manifest)) { + seen.add(v._goon_hls_manifest); + manifests.push({ url: v._goon_hls_manifest, player: 'video._goon_hls_manifest' }); + } + } + + // B3: Fallback — walk video element keys looking for HLS-related objects for (const v of document.querySelectorAll('video')) { for (const key of Object.keys(v)) { if (key.toLowerCase().includes('hls') || key === 'player') { @@ -200,12 +221,34 @@ function scrapeFn(hlsManifests) { let mediaType = 'video'; let src = rawSrc; + // Priority 1: Direct .m3u8 source (rare but possible) if (rawSrc.includes('.m3u8') && rawSrc.startsWith('http')) { hlsManifest = rawSrc; mediaType = 'hls'; - } else if (rawSrc.startsWith('http') && !rawSrc.startsWith('blob:')) { + } + // Priority 2: Interceptor annotation — Hls.attachMedia() patched by interceptors + else if (v._goon_hls_manifest) { + hlsManifest = v._goon_hls_manifest; + mediaType = 'hls'; + src = hlsManifest; + } + // Priority 3: Hls.cast(video) — official hls.js API (works when Hls is loaded) + else if (typeof Hls !== 'undefined' && typeof Hls.cast === 'function') { + try { + const hlsInst = Hls.cast(v); + if (hlsInst && hlsInst.url) { + hlsManifest = hlsInst.url; + mediaType = 'hls'; + src = hlsManifest; + } + } catch { /* ignore */ } + } + // Priority 4: Direct HTTP source (non-blob) + if (!hlsManifest && rawSrc.startsWith('http') && !rawSrc.startsWith('blob:')) { mediaType = 'video'; - } else if (hlsManifests && hlsManifests.length > 0) { + } + // Priority 5: Deep probe fallback — use manifests found by scanning page globals/scripts + if (!hlsManifest && hlsManifests && hlsManifests.length > 0) { hlsManifest = hlsManifests[0].url || hlsManifests[0]; mediaType = 'hls'; src = hlsManifest; diff --git a/src/viewer/index.test.js b/src/viewer/index.test.js index 4ded070..eeb51b5 100644 --- a/src/viewer/index.test.js +++ b/src/viewer/index.test.js @@ -340,3 +340,142 @@ describe('Storage pipeline - HLS metadata preservation', () => { expect(storedItems[0].tab).toBe('Has Video'); }); }); + +/* ========================================================================== */ +/* TESTS: scrapeFn — HLS detection strategies */ +/* ========================================================================== */ + +/** + * Minimal DOM setup for scrapeFn tests. + * Simulates how scrapeFn detects HLS manifests from video elements + * using the 5-priority chain. + */ +function simulateScrapeFn(videos, hlsManifests) { + // Simulate the scrapeFn detection logic (priorities 1-5) + const results = []; + for (const v of videos) { + const rawSrc = v.currentSrc || v.src || ''; + let hlsManifest = null; + let mediaType = 'video'; + let src = rawSrc; + + // Priority 1: Direct .m3u8 source + if (rawSrc.includes('.m3u8') && rawSrc.startsWith('http')) { + hlsManifest = rawSrc; + mediaType = 'hls'; + } + // Priority 2: Interceptor annotation + else if (v._goon_hls_manifest) { + hlsManifest = v._goon_hls_manifest; + mediaType = 'hls'; + src = hlsManifest; + } + // Priority 3: Hls.cast (simulated via v._hls_cast_result) + else if (v._hls_cast_result && v._hls_cast_result.url) { + hlsManifest = v._hls_cast_result.url; + mediaType = 'hls'; + src = hlsManifest; + } + // Priority 4: Direct HTTP source (non-blob) + if (!hlsManifest && rawSrc.startsWith('http') && !rawSrc.startsWith('blob:')) { + mediaType = 'video'; + } + // Priority 5: Deep probe fallback + if (!hlsManifest && hlsManifests && hlsManifests.length > 0) { + hlsManifest = hlsManifests[0].url || hlsManifests[0]; + mediaType = 'hls'; + src = hlsManifest; + } + + results.push({ src, hlsManifest, mediaType }); + } + return results; +} + +describe('scrapeFn — HLS detection priorities', () => { + it('Priority 1: Direct .m3u8 in video.src', () => { + const videos = [{ + src: 'https://example.com/stream.m3u8', + currentSrc: 'https://example.com/stream.m3u8', + }]; + const result = simulateScrapeFn(videos, []); + expect(result[0].mediaType).toBe('hls'); + expect(result[0].hlsManifest).toBe('https://example.com/stream.m3u8'); + }); + + it('Priority 2: Interceptor-set _goon_hls_manifest overrides blob src', () => { + const videos = [{ + src: 'blob:https://example.com/xyz', + currentSrc: 'blob:https://example.com/xyz', + _goon_hls_manifest: 'https://cdn.example.com/live.m3u8', + }]; + const result = simulateScrapeFn(videos, []); + expect(result[0].mediaType).toBe('hls'); + expect(result[0].hlsManifest).toBe('https://cdn.example.com/live.m3u8'); + }); + + it('Priority 3: Hls.cast(video) detects active HLS instance', () => { + const videos = [{ + src: 'blob:https://example.com/abc', + currentSrc: 'blob:https://example.com/abc', + _hls_cast_result: { url: 'https://cdn.example.com/vod.m3u8' }, + }]; + const result = simulateScrapeFn(videos, []); + expect(result[0].mediaType).toBe('hls'); + expect(result[0].hlsManifest).toBe('https://cdn.example.com/vod.m3u8'); + }); + + it('Priority 4: blob src with no HLS detection → filtered out', () => { + const videos = [{ + src: 'blob:https://example.com/def', + currentSrc: 'blob:https://example.com/def', + }]; + const result = simulateScrapeFn(videos, []); + expect(result[0].mediaType).toBe('video'); + expect(result[0].hlsManifest).toBeNull(); + }); + + it('Priority 5: Deep probe fallback when no direct HLS found', () => { + const videos = [{ + src: 'https://example.com/page.html', + currentSrc: 'blob:https://example.com/ghi', + }]; + const manifests = [{ url: 'https://cdn.example.com/fallback.m3u8' }]; + const result = simulateScrapeFn(videos, manifests); + expect(result[0].mediaType).toBe('hls'); + expect(result[0].hlsManifest).toBe('https://cdn.example.com/fallback.m3u8'); + }); + + it('Priority 2 overrides Priority 5 (interceptor wins over deep probe)', () => { + const videos = [{ + src: 'blob:https://example.com/jkl', + currentSrc: 'blob:https://example.com/jkl', + _goon_hls_manifest: 'https://cdn.example.com/interceptor.m3u8', + }]; + const manifests = [{ url: 'https://cdn.example.com/deepprobe.m3u8' }]; + const result = simulateScrapeFn(videos, manifests); + expect(result[0].mediaType).toBe('hls'); + expect(result[0].hlsManifest).toBe('https://cdn.example.com/interceptor.m3u8'); + }); + + it('HLS instance on video with no currentSrc still detected via interceptor', () => { + const videos = [{ + src: '', + currentSrc: '', + _goon_hls_manifest: 'https://cdn.example.com/early.m3u8', + }]; + const result = simulateScrapeFn(videos, []); + expect(result[0].mediaType).toBe('hls'); + expect(result[0].hlsManifest).toBe('https://cdn.example.com/early.m3u8'); + }); + + it('Direct MP4 source is not falsely flagged as HLS', () => { + const videos = [{ + src: 'https://example.com/video.mp4', + currentSrc: 'https://example.com/video.mp4', + }]; + const result = simulateScrapeFn(videos, []); + expect(result[0].mediaType).toBe('video'); + expect(result[0].hlsManifest).toBeNull(); + }); +});