feat: full HLS.js support — detection via interceptors + deep probe, playback via hls.js in viewer #2
No files matched your search
@@ -753,19 +753,21 @@ describe('HLS QA: Deep probe strategies', () => {
|
|||||||
});
|
});
|
||||||
const win = dom.window;
|
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');
|
const video = win.document.getElementById('v');
|
||||||
|
video.__hlsInstance = { url: 'https://cdn.example.com/video.m3u8' };
|
||||||
win.Hls = class {
|
win.Hls = class {
|
||||||
static cast(v) {
|
// cast() is a no-op identity function in the real hls.js library
|
||||||
if (v === video) return { url: 'https://cdn.example.com/cast.m3u8' };
|
static cast(v) { return v; }
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const results = runProbeInWin(win);
|
const results = runProbeInWin(win);
|
||||||
|
|
||||||
const found = results.find(r => r.player === 'Hls.cast');
|
// Should detect via video element property scan (Strategy B1)
|
||||||
if (!found) throw new Error('Hls.cast manifest not detected. Results: ' + JSON.stringify(results));
|
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', () => {
|
it('should prefer interceptor-collected manifests over inline-script', () => {
|
||||||
|
|||||||
+16
-17
@@ -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
|
// Helper: report manifest to content script via postMessage
|
||||||
function reportManifest(url, source) {
|
function reportManifest(url, source) {
|
||||||
var resolved = resolveUrl(url);
|
var resolved = resolveUrl(url);
|
||||||
@@ -64,12 +71,7 @@
|
|||||||
XMLHttpRequest.prototype.send = function (_body) {
|
XMLHttpRequest.prototype.send = function (_body) {
|
||||||
if (this._goon_url && typeof this._goon_url === 'string') {
|
if (this._goon_url && typeof this._goon_url === 'string') {
|
||||||
var resolved = resolveUrl(this._goon_url);
|
var resolved = resolveUrl(this._goon_url);
|
||||||
if (
|
if (resolved && isManifestUrl(resolved)) {
|
||||||
resolved &&
|
|
||||||
(resolved.includes('.m3u8') ||
|
|
||||||
resolved.includes('/playlist') ||
|
|
||||||
resolved.includes('/manifest'))
|
|
||||||
) {
|
|
||||||
reportManifest(resolved, 'xhr');
|
reportManifest(resolved, 'xhr');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -82,12 +84,7 @@
|
|||||||
var url = typeof resource === 'string' ? resource : resource && resource.url;
|
var url = typeof resource === 'string' ? resource : resource && resource.url;
|
||||||
if (url && typeof url === 'string') {
|
if (url && typeof url === 'string') {
|
||||||
var resolved = resolveUrl(url);
|
var resolved = resolveUrl(url);
|
||||||
if (
|
if (resolved && isManifestUrl(resolved)) {
|
||||||
resolved &&
|
|
||||||
(resolved.includes('.m3u8') ||
|
|
||||||
resolved.includes('/playlist') ||
|
|
||||||
resolved.includes('/manifest'))
|
|
||||||
) {
|
|
||||||
reportManifest(resolved, 'fetch');
|
reportManifest(resolved, 'fetch');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -95,8 +92,12 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 3. Intercept HLS.js loadSource (catches HLS.js instances)
|
// 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() {
|
function patchHlsPrototype() {
|
||||||
if (typeof Hls === 'undefined') return;
|
if (typeof Hls === 'undefined') return;
|
||||||
|
if (hlsPatched) return; // Already patched — skip
|
||||||
|
|
||||||
var originalLoadSource = Hls.prototype.loadSource;
|
var originalLoadSource = Hls.prototype.loadSource;
|
||||||
if (!originalLoadSource) return;
|
if (!originalLoadSource) return;
|
||||||
|
|
||||||
@@ -115,6 +116,7 @@
|
|||||||
return originalAttachMedia.apply(this, arguments);
|
return originalAttachMedia.apply(this, arguments);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
hlsPatched = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Patch immediately if HLS is already defined
|
// Patch immediately if HLS is already defined
|
||||||
@@ -124,7 +126,6 @@
|
|||||||
// Use a combination of:
|
// Use a combination of:
|
||||||
// 1. Longer polling window (30s instead of 3s) to catch lazy-loaded players.
|
// 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.
|
// 2. MutationObserver on <head> for new <script> tags — catches late hls.js loads.
|
||||||
var hlsPatched = false;
|
|
||||||
if (!hlsPatched) {
|
if (!hlsPatched) {
|
||||||
var pollCount = 0;
|
var pollCount = 0;
|
||||||
var maxPolls = 600; // poll for up to ~30s (50ms interval)
|
var maxPolls = 600; // poll for up to ~30s (50ms interval)
|
||||||
@@ -132,9 +133,8 @@
|
|||||||
var pollHls = setInterval(function () {
|
var pollHls = setInterval(function () {
|
||||||
pollCount++;
|
pollCount++;
|
||||||
if (typeof Hls !== 'undefined' && !hlsPatched) {
|
if (typeof Hls !== 'undefined' && !hlsPatched) {
|
||||||
hlsPatched = true;
|
|
||||||
patchHlsPrototype();
|
patchHlsPrototype();
|
||||||
clearInterval(pollHls);
|
if (hlsPatched) clearInterval(pollHls);
|
||||||
}
|
}
|
||||||
if (pollCount >= maxPolls) clearInterval(pollHls);
|
if (pollCount >= maxPolls) clearInterval(pollHls);
|
||||||
}, 50);
|
}, 50);
|
||||||
@@ -154,9 +154,8 @@
|
|||||||
if (src.includes('hls') || src.includes('videojs') || src.includes('player')) {
|
if (src.includes('hls') || src.includes('videojs') || src.includes('player')) {
|
||||||
// Script tag loaded — check if Hls is now defined
|
// Script tag loaded — check if Hls is now defined
|
||||||
if (typeof Hls !== 'undefined' && !hlsPatched) {
|
if (typeof Hls !== 'undefined' && !hlsPatched) {
|
||||||
hlsPatched = true;
|
|
||||||
patchHlsPrototype();
|
patchHlsPrototype();
|
||||||
observer.disconnect();
|
if (hlsPatched) observer.disconnect();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+58
-19
@@ -104,21 +104,45 @@ function m3u8DeepProbeFn() {
|
|||||||
const manifests = [];
|
const manifests = [];
|
||||||
const seen = new Set();
|
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) {
|
function walk(obj, path, depth) {
|
||||||
if (depth > 5) return;
|
if (depth > 5) return;
|
||||||
if (typeof obj === 'string') {
|
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) {
|
if (match) {
|
||||||
for (const u of 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;
|
return;
|
||||||
}
|
}
|
||||||
if (!obj || typeof obj !== 'object') return;
|
if (!obj || typeof obj !== 'object') return;
|
||||||
|
try {
|
||||||
for (const k of Object.keys(obj)) {
|
for (const k of Object.keys(obj)) {
|
||||||
walk(obj[k], `${path}.${k}`, depth + 1);
|
walk(obj[k], `${path}.${k}`, depth + 1);
|
||||||
}
|
}
|
||||||
|
} catch { /* avoid circular ref errors */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategy A: Known player globals (shallow + deep)
|
// Strategy A: Known player globals (shallow + deep)
|
||||||
@@ -127,28 +151,37 @@ function m3u8DeepProbeFn() {
|
|||||||
'__hls', '__hls_player', 'player', 'videoPlayer', 'mainPlayer',
|
'__hls', '__hls_player', 'player', 'videoPlayer', 'mainPlayer',
|
||||||
'fhplayer', '__pj', 'playerInstance',
|
'fhplayer', '__pj', 'playerInstance',
|
||||||
'PHPlayer', 'fh', '__phPlayer', '__ph', 'phPlayer', 'phPlayerInstance',
|
'PHPlayer', 'fh', '__phPlayer', '__ph', 'phPlayer', 'phPlayerInstance',
|
||||||
|
'bitgravity', 'bgPlayer', 'FG', 'Fhls',
|
||||||
];
|
];
|
||||||
for (const name of playerGlobals) {
|
for (const name of playerGlobals) {
|
||||||
const obj = window[name];
|
const obj = window[name];
|
||||||
if (!obj || typeof obj === 'function') continue;
|
if (!obj || typeof obj === 'function') continue;
|
||||||
for (const key of ['url', '_url', 'src', 'manifestUrl', 'hlsUrl', 'playlistUrl']) {
|
for (const key of ['url', '_url', 'src', 'manifestUrl', 'hlsUrl', 'playlistUrl', 'hls_url', 'mp4_url']) {
|
||||||
if (typeof obj[key] === 'string' && obj[key].includes('.m3u8')) {
|
if (typeof obj[key] === 'string' && isManifestUrl(obj[key])) {
|
||||||
if (!seen.has(obj[key])) { seen.add(obj[key]); manifests.push({ url: obj[key], player: `${name}.${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);
|
walk(obj, name, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategy B: Video element HLS instances
|
// Strategy B: Video element HLS instances
|
||||||
// B1: Use Hls.cast(video) — the official hls.js API to get the bound instance
|
// B1: Scan video elements for HLS instance references stored by players.
|
||||||
if (typeof Hls !== 'undefined' && typeof Hls.cast === 'function') {
|
// 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')) {
|
for (const v of document.querySelectorAll('video')) {
|
||||||
try {
|
for (const key of Object.keys(v)) {
|
||||||
const hlsInst = Hls.cast(v);
|
const obj = v[key];
|
||||||
if (hlsInst && hlsInst.url) {
|
if (!obj || typeof obj !== 'object') continue;
|
||||||
if (!seen.has(hlsInst.url)) { seen.add(hlsInst.url); manifests.push({ url: hlsInst.url, player: 'Hls.cast' }); }
|
// 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';
|
mediaType = 'hls';
|
||||||
src = hlsManifest;
|
src = hlsManifest;
|
||||||
}
|
}
|
||||||
// Priority 3: Hls.cast(video) — official hls.js API (works when Hls is loaded)
|
// Priority 3: Scan video element properties for HLS instance references.
|
||||||
else if (typeof Hls !== 'undefined' && typeof Hls.cast === 'function') {
|
// Hls.cast() is a TypeScript identity cast in hls.js — NOT a reverse-lookup.
|
||||||
try {
|
// Real players store the HLS instance on the video element as custom properties.
|
||||||
const hlsInst = Hls.cast(v);
|
if (!hlsManifest) {
|
||||||
if (hlsInst && hlsInst.url) {
|
for (const key of Object.keys(v)) {
|
||||||
hlsManifest = hlsInst.url;
|
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';
|
mediaType = 'hls';
|
||||||
src = hlsManifest;
|
src = hlsManifest;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch { /* ignore */ }
|
|
||||||
}
|
}
|
||||||
// Priority 4: Direct HTTP source (non-blob)
|
// Priority 4: Direct HTTP source (non-blob)
|
||||||
if (!hlsManifest && rawSrc.startsWith('http') && !rawSrc.startsWith('blob:')) {
|
if (!hlsManifest && rawSrc.startsWith('http') && !rawSrc.startsWith('blob:')) {
|
||||||
|
|||||||
Reference in new issue
Block a user