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;
|
||||
|
||||
// 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
@@ -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
@@ -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:')) {
|
||||
|
||||
Reference in new issue
Block a user