feat: full HLS.js support — detection via interceptors + deep probe, playback via hls.js in viewer #2
No files matched your search
+84
-4
@@ -467,6 +467,59 @@ async function scrapeTab(tabId, manifests) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter intercepted URLs to prefer actual HLS manifests over segment files.
|
||||
* Interceptors capture both .m3u8 manifests and .ts segment URLs (any URL
|
||||
* matching /hls/ is intercepted). When building video entries from intercept
|
||||
* data alone (no visible <video> elements), we need to filter out segments
|
||||
* and reconstruct manifest URLs from segment paths when needed.
|
||||
*
|
||||
* @param {Array} manifests - array of { url, player } from probing/interception
|
||||
* @returns {Array} filtered { url } entries with only manifest URLs
|
||||
*/
|
||||
function filterManifests(manifests) {
|
||||
if (!manifests || manifests.length === 0) return [];
|
||||
|
||||
// Separate manifests (.m3u8, .hlsvod) from segments (.ts)
|
||||
const actualManifests = manifests.filter(m => {
|
||||
const u = m.url.toLowerCase();
|
||||
return u.includes('.m3u8') || u.includes('.hlsvod') ||
|
||||
(u.includes('/master') && u.includes('.m3u')) ||
|
||||
(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 }));
|
||||
}
|
||||
|
||||
// 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
|
||||
const seen = new Set();
|
||||
const reconstructed = [];
|
||||
for (const m of manifests) {
|
||||
const u = m.url.toLowerCase();
|
||||
if (!u.includes('/hls/') || !u.endsWith('.ts')) continue;
|
||||
try {
|
||||
const url = new URL(m.url);
|
||||
const pathParts = url.pathname.split('/');
|
||||
// Find the /hls/videos/ segment and the video-id directory
|
||||
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 (!seen.has(manifestUrl)) {
|
||||
seen.add(manifestUrl);
|
||||
reconstructed.push({ url: manifestUrl });
|
||||
}
|
||||
} catch { /* skip malformed URLs */ }
|
||||
}
|
||||
return reconstructed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a normalized video entry from a scraped video object.
|
||||
* @param {{ src: string, poster: string|null, mediaType: string, hlsManifest: string|null }} video
|
||||
@@ -517,9 +570,14 @@ async function scanVideos(tabsToScan) {
|
||||
// Step 3: Merge content-script data into tab results
|
||||
// - Enrich video entries with poster URLs from the content script
|
||||
// - Merge content-script intercepted manifests into the deep probe results
|
||||
// - FALLBACK: When scrapeFn found no visible <video> elements (sandboxed
|
||||
// iframe) but interceptors captured manifests, create video entries from
|
||||
// manifest data. The tab is actively playing content even if the DOM is
|
||||
// inaccessible.
|
||||
const tabResults = tabsToScan.map((tab, i) => {
|
||||
const result = scrapeResults[i];
|
||||
const contentData = contentResults[i];
|
||||
const tabManifests = tabManifestMap.get(tab.id) || [];
|
||||
|
||||
// Enrich videos with poster from content script (og:image, twitter:image, etc.)
|
||||
if (result && result.videos?.length > 0 && contentData?.posterUrl) {
|
||||
@@ -530,13 +588,35 @@ async function scanVideos(tabsToScan) {
|
||||
|
||||
// Merge content-script intercepted manifests into the tab's manifest list
|
||||
if (contentData && contentData.interceptedManifests) {
|
||||
const existing = tabManifestMap.get(tab.id) || [];
|
||||
for (const m of contentData.interceptedManifests) {
|
||||
if (m.url && !existing.some(em => em.url === m.url)) {
|
||||
existing.push({ url: m.url, player: 'content-script-intercept' });
|
||||
if (m.url && !tabManifests.some(tm => tm.url === m.url)) {
|
||||
tabManifests.push({ url: m.url, player: 'content-script-intercept' });
|
||||
}
|
||||
}
|
||||
tabManifestMap.set(tab.id, existing);
|
||||
tabManifestMap.set(tab.id, tabManifests);
|
||||
}
|
||||
|
||||
// FALLBACK: If scrapeFn returned no videos but we have manifests,
|
||||
// create video entries from manifest data. This handles sandboxed
|
||||
// iframes where the video player is invisible to content scripts.
|
||||
// Filter: prefer .m3u8 manifest URLs over .ts segment URLs.
|
||||
// Reconstruct manifest URL from segment URLs when needed.
|
||||
if (!result || !result.videos || result.videos.length === 0) {
|
||||
const poster = contentData?.posterUrl || null;
|
||||
const filteredManifests = filterManifests(tabManifests);
|
||||
if (filteredManifests.length > 0) {
|
||||
const fallbackVideos = filteredManifests.map(m => ({
|
||||
src: m.url,
|
||||
poster: poster,
|
||||
mediaType: 'hls',
|
||||
hlsManifest: m.url,
|
||||
}));
|
||||
return {
|
||||
tabTitle: tab.title || 'Unknown',
|
||||
result: { videos: fallbackVideos },
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
+47
-2
@@ -338,8 +338,14 @@ function wireHlsEvents(hls, video, panel) {
|
||||
});
|
||||
|
||||
// --- Segment / fragment tracking ---
|
||||
// FRAG_LOADING fires continuously during playback for each fragment.
|
||||
// Only transition to buffering when the video is actually stalled
|
||||
// (paused or readyState < HAVE_FUTURE_DATA). This prevents the overlay
|
||||
// from flickering between "buffering" and "playing" during smooth playback.
|
||||
hls.on(HlsEvents.FRAG_LOADING, (_ev, _data) => {
|
||||
if (video.paused || video.readyState < 3) {
|
||||
state.phase = 'buffering';
|
||||
}
|
||||
state.lastEvent = 'FRAG_LOADING';
|
||||
state.lastActivity = Date.now();
|
||||
renderPanelState(video, panel);
|
||||
@@ -352,15 +358,41 @@ function wireHlsEvents(hls, video, panel) {
|
||||
renderPanelState(video, panel);
|
||||
});
|
||||
|
||||
// FRAG_BUFFERED: transition to playing only when there's meaningful
|
||||
// buffer ahead (readyState >= HAVE_FUTURE_DATA) and video is running.
|
||||
// Allow transition from any phase except 'error' — not just 'buffering'
|
||||
// — because the video may go straight from 'loading' to playing.
|
||||
hls.on(HlsEvents.FRAG_BUFFERED, (_ev, _data) => {
|
||||
state.lastEvent = 'FRAG_BUFFERED';
|
||||
state.lastActivity = Date.now();
|
||||
if (state.phase === 'buffering' && video.readyState >= 2) {
|
||||
if (state.phase !== 'error' && video.readyState >= 3 && !video.paused) {
|
||||
state.phase = 'playing';
|
||||
}
|
||||
renderPanelState(video, panel);
|
||||
});
|
||||
|
||||
// --- New event tracking: full fragment pipeline visibility ---
|
||||
// FRAG_DECRYPTED: DRM-protected content decryption complete
|
||||
hls.on(HlsEvents.FRAG_DECRYPTED, (_ev, _data) => {
|
||||
state.lastEvent = 'FRAG_DECRYPTED';
|
||||
state.lastActivity = Date.now();
|
||||
renderPanelState(video, panel);
|
||||
});
|
||||
|
||||
// FRAG_PARSING_INIT_SEGMENT: init segment parsed (codec info available)
|
||||
hls.on(HlsEvents.FRAG_PARSING_INIT_SEGMENT, (_ev, _data) => {
|
||||
state.lastEvent = 'FRAG_PARSING_INIT_SEGMENT';
|
||||
state.lastActivity = Date.now();
|
||||
renderPanelState(video, panel);
|
||||
});
|
||||
|
||||
// FRAG_PARSING_USERDATA: ID3/userdata tags in fragment (timed metadata)
|
||||
hls.on(HlsEvents.FRAG_PARSING_USERDATA, (_ev, _data) => {
|
||||
state.lastEvent = 'FRAG_PARSING_USERDATA';
|
||||
state.lastActivity = Date.now();
|
||||
renderPanelState(video, panel);
|
||||
});
|
||||
|
||||
// --- Buffer tracking ---
|
||||
hls.on(HlsEvents.BUFFER_APPENDED, (_ev, _data) => {
|
||||
state.lastEvent = 'BUFFER_APPENDED';
|
||||
@@ -381,6 +413,7 @@ function wireHlsEvents(hls, video, panel) {
|
||||
state.lastActivity = Date.now();
|
||||
state.bufferedAhead = 0;
|
||||
state.segmentsLoaded = 0;
|
||||
state.segmentsBuffered = 0;
|
||||
renderPanelState(video, panel);
|
||||
});
|
||||
|
||||
@@ -388,6 +421,7 @@ function wireHlsEvents(hls, video, panel) {
|
||||
hls.on(HlsEvents.LEVEL_SWITCHED, (_ev, data) => {
|
||||
state.lastEvent = 'LEVEL_SWITCHED';
|
||||
state.lastActivity = Date.now();
|
||||
state.phase = 'switching';
|
||||
state.level = data.level;
|
||||
const levels = hls.levels;
|
||||
if (levels && levels[data.level]) {
|
||||
@@ -450,9 +484,10 @@ function wireHlsEvents(hls, video, panel) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Fatal errors
|
||||
// Fatal errors — attempt recovery before giving up
|
||||
if (data.type === window.Hls.ErrorTypes.NETWORK_ERROR) {
|
||||
state.errorMessage = `Network: ${data.details}`;
|
||||
state.segmentsFailed++;
|
||||
state.phase = 'error';
|
||||
renderPanelState(video, panel);
|
||||
showError(panel, 'Stream expired - re-scan to get fresh URLs.');
|
||||
@@ -461,9 +496,11 @@ function wireHlsEvents(hls, video, panel) {
|
||||
} else if (data.type === window.Hls.ErrorTypes.MEDIA_ERROR) {
|
||||
state.errorMessage = `Media: ${data.details}`;
|
||||
state.phase = 'buffering';
|
||||
state.lastActivity = Date.now();
|
||||
renderPanelState(video, panel);
|
||||
hls.recoverMediaError();
|
||||
} else {
|
||||
// Unknown fatal error — try recovering once before destroying
|
||||
state.errorMessage = `Fatal: ${data.details}`;
|
||||
state.phase = 'error';
|
||||
renderPanelState(video, panel);
|
||||
@@ -472,6 +509,14 @@ function wireHlsEvents(hls, video, panel) {
|
||||
hlsInstances.delete(video);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Stream completion ---
|
||||
hls.on(HlsEvents.ENDLIST, () => {
|
||||
state.lastEvent = 'ENDLIST';
|
||||
state.lastActivity = Date.now();
|
||||
state.phase = 'ended';
|
||||
renderPanelState(video, panel);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user