feat: full HLS.js support — detection via interceptors + deep probe, playback via hls.js in viewer #2

Merged
kfj001 merged 15 commits from hls-fix into master 2026-08-23 18:22:42 -07:00
3 changed files with 83 additions and 43 deletions
Showing only changes of commit 3cf402d3c7 - Show all commits

No files matched your search

+9 -7
View File
@@ -753,19 +753,21 @@ describe('HLS QA: Deep probe strategies', () => {
});
const win = dom.window;
// Mock Hls with cast API
// Real players attach the HLS instance on the video element as a custom property.
// Hls.cast() is a TypeScript identity cast in hls.js (returns input unchanged),
// NOT a reverse-lookup — so we test what actually works in practice.
const video = win.document.getElementById('v');
video.__hlsInstance = { url: 'https://cdn.example.com/video.m3u8' };
win.Hls = class {
static cast(v) {
if (v === video) return { url: 'https://cdn.example.com/cast.m3u8' };
return null;
}
// cast() is a no-op identity function in the real hls.js library
static cast(v) { return v; }
};
const results = runProbeInWin(win);
const found = results.find(r => r.player === 'Hls.cast');
if (!found) throw new Error('Hls.cast manifest not detected. Results: ' + JSON.stringify(results));
// Should detect via video element property scan (Strategy B1)
const found = results.find(r => r.player && r.player.includes('video.') && r.url.includes('.m3u8'));
if (!found) throw new Error('Video element HLS instance not detected. Results: ' + JSON.stringify(results));
});
it('should prefer interceptor-collected manifests over inline-script', () => {
+16 -17
View File
@@ -34,6 +34,13 @@
}
}
// Helper: check if a URL looks like an HLS manifest
function isManifestUrl(url) {
var u = url.toLowerCase();
return u.includes('.m3u8') || u.includes('.hlsvod') ||
u.includes('/hls/') || u.includes('/playlist') || u.includes('/manifest');
}
// Helper: report manifest to content script via postMessage
function reportManifest(url, source) {
var resolved = resolveUrl(url);
@@ -64,12 +71,7 @@
XMLHttpRequest.prototype.send = function (_body) {
if (this._goon_url && typeof this._goon_url === 'string') {
var resolved = resolveUrl(this._goon_url);
if (
resolved &&
(resolved.includes('.m3u8') ||
resolved.includes('/playlist') ||
resolved.includes('/manifest'))
) {
if (resolved && isManifestUrl(resolved)) {
reportManifest(resolved, 'xhr');
}
}
@@ -82,12 +84,7 @@
var url = typeof resource === 'string' ? resource : resource && resource.url;
if (url && typeof url === 'string') {
var resolved = resolveUrl(url);
if (
resolved &&
(resolved.includes('.m3u8') ||
resolved.includes('/playlist') ||
resolved.includes('/manifest'))
) {
if (resolved && isManifestUrl(resolved)) {
reportManifest(resolved, 'fetch');
}
}
@@ -95,8 +92,12 @@
};
// 3. Intercept HLS.js loadSource (catches HLS.js instances)
// Guard against double-patching: only wrap if the method hasn't been patched yet.
var hlsPatched = false;
function patchHlsPrototype() {
if (typeof Hls === 'undefined') return;
if (hlsPatched) return; // Already patched — skip
var originalLoadSource = Hls.prototype.loadSource;
if (!originalLoadSource) return;
@@ -115,6 +116,7 @@
return originalAttachMedia.apply(this, arguments);
};
}
hlsPatched = true;
}
// Patch immediately if HLS is already defined
@@ -124,7 +126,6 @@
// Use a combination of:
// 1. Longer polling window (30s instead of 3s) to catch lazy-loaded players.
// 2. MutationObserver on <head> for new <script> tags — catches late hls.js loads.
var hlsPatched = false;
if (!hlsPatched) {
var pollCount = 0;
var maxPolls = 600; // poll for up to ~30s (50ms interval)
@@ -132,9 +133,8 @@
var pollHls = setInterval(function () {
pollCount++;
if (typeof Hls !== 'undefined' && !hlsPatched) {
hlsPatched = true;
patchHlsPrototype();
clearInterval(pollHls);
if (hlsPatched) clearInterval(pollHls);
}
if (pollCount >= maxPolls) clearInterval(pollHls);
}, 50);
@@ -154,9 +154,8 @@
if (src.includes('hls') || src.includes('videojs') || src.includes('player')) {
// Script tag loaded — check if Hls is now defined
if (typeof Hls !== 'undefined' && !hlsPatched) {
hlsPatched = true;
patchHlsPrototype();
observer.disconnect();
if (hlsPatched) observer.disconnect();
}
}
}
+58 -19
View File
@@ -104,21 +104,45 @@ function m3u8DeepProbeFn() {
const manifests = [];
const seen = new Set();
// Helpers available in the MAIN world context
function resolveUrl(url) {
if (!url) return null;
try {
if (url.startsWith('//')) return 'https:' + url;
if (url.startsWith('http')) return url;
return new URL(url, window.location.origin).href;
} catch { return null; }
}
function isManifestUrl(url) {
if (!url) return false;
const u = url.toLowerCase();
return u.includes('.m3u8') || u.includes('.hlsvod') ||
(u.includes('/hls/') && u.includes('http')) ||
(u.includes('/playlist') && u.includes('http')) ||
(u.includes('/manifest') && u.includes('http'));
}
function walk(obj, path, depth) {
if (depth > 5) return;
if (typeof obj === 'string') {
const match = obj.match(/https?:\/\/[^"'\s`)]+\.m3u8[^"'\s`)]*/gi);
const match = obj.match(/https?:\/\/[^"'`\s)]+?(?:\.m3u8|\.hlsvod|\/hls\/|\/playlist[^"'`\s]*|\/manifest[^"'`\s]*)[^"'`\s)]*/gi);
if (match) {
for (const u of match) {
if (!seen.has(u)) { seen.add(u); manifests.push({ url: u, player: path }); }
const resolved = resolveUrl(u);
if (resolved && isManifestUrl(resolved) && !seen.has(resolved)) {
seen.add(resolved); manifests.push({ url: resolved, player: path });
}
}
}
return;
}
if (!obj || typeof obj !== 'object') return;
try {
for (const k of Object.keys(obj)) {
walk(obj[k], `${path}.${k}`, depth + 1);
}
} catch { /* avoid circular ref errors */ }
}
// Strategy A: Known player globals (shallow + deep)
@@ -127,28 +151,37 @@ function m3u8DeepProbeFn() {
'__hls', '__hls_player', 'player', 'videoPlayer', 'mainPlayer',
'fhplayer', '__pj', 'playerInstance',
'PHPlayer', 'fh', '__phPlayer', '__ph', 'phPlayer', 'phPlayerInstance',
'bitgravity', 'bgPlayer', 'FG', 'Fhls',
];
for (const name of playerGlobals) {
const obj = window[name];
if (!obj || typeof obj === 'function') continue;
for (const key of ['url', '_url', 'src', 'manifestUrl', 'hlsUrl', 'playlistUrl']) {
if (typeof obj[key] === 'string' && obj[key].includes('.m3u8')) {
if (!seen.has(obj[key])) { seen.add(obj[key]); manifests.push({ url: obj[key], player: `${name}.${key}` }); }
for (const key of ['url', '_url', 'src', 'manifestUrl', 'hlsUrl', 'playlistUrl', 'hls_url', 'mp4_url']) {
if (typeof obj[key] === 'string' && isManifestUrl(obj[key])) {
const resolved = resolveUrl(obj[key]);
if (resolved && !seen.has(resolved)) { seen.add(resolved); manifests.push({ url: resolved, player: `${name}.${key}` }); }
}
}
walk(obj, name, 0);
}
// 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') {
// B1: Scan video elements for HLS instance references stored by players.
// NOTE: Hls.cast() in hls.js is a TypeScript type-cast utility (identity function),
// NOT a reverse-lookup. It returns its input unchanged — Hls.cast(video) just
// returns the <video> element with no .url property. Real players store the
// HLS instance directly on the video element as a custom property.
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' }); }
for (const key of Object.keys(v)) {
const obj = v[key];
if (!obj || typeof obj !== 'object') continue;
// Check for common HLS instance patterns on video elements
if (typeof obj === 'object' && typeof obj.url === 'string' && isManifestUrl(obj.url)) {
const resolved = resolveUrl(obj.url);
if (resolved && !seen.has(resolved)) { seen.add(resolved); manifests.push({ url: resolved, player: `video.${key}.url` }); }
}
} catch { /* ignore */ }
// Walk deep into player objects attached to video elements
walk(obj, `video.${key}`, 0);
}
}
@@ -248,16 +281,22 @@ function scrapeFn(hlsManifests) {
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;
// Priority 3: Scan video element properties for HLS instance references.
// Hls.cast() is a TypeScript identity cast in hls.js — NOT a reverse-lookup.
// Real players store the HLS instance on the video element as custom properties.
if (!hlsManifest) {
for (const key of Object.keys(v)) {
const obj = v[key];
if (obj && typeof obj === 'object' && typeof obj.url === 'string') {
const u = obj.url.toLowerCase();
if (u.includes('.m3u8') || u.includes('.hlsvod') || u.includes('/hls/') || u.includes('/playlist')) {
hlsManifest = obj.url;
mediaType = 'hls';
src = hlsManifest;
break;
}
}
}
} catch { /* ignore */ }
}
// Priority 4: Direct HTTP source (non-blob)
if (!hlsManifest && rawSrc.startsWith('http') && !rawSrc.startsWith('blob:')) {