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
2 changed files with 131 additions and 6 deletions
Showing only changes of commit e11be9a20a - Show all commits

No files matched your search

+84 -4
View File
@@ -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. * Build a normalized video entry from a scraped video object.
* @param {{ src: string, poster: string|null, mediaType: string, hlsManifest: string|null }} video * @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 // Step 3: Merge content-script data into tab results
// - Enrich video entries with poster URLs from the content script // - Enrich video entries with poster URLs from the content script
// - Merge content-script intercepted manifests into the deep probe results // - 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 tabResults = tabsToScan.map((tab, i) => {
const result = scrapeResults[i]; const result = scrapeResults[i];
const contentData = contentResults[i]; const contentData = contentResults[i];
const tabManifests = tabManifestMap.get(tab.id) || [];
// Enrich videos with poster from content script (og:image, twitter:image, etc.) // Enrich videos with poster from content script (og:image, twitter:image, etc.)
if (result && result.videos?.length > 0 && contentData?.posterUrl) { 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 // Merge content-script intercepted manifests into the tab's manifest list
if (contentData && contentData.interceptedManifests) { if (contentData && contentData.interceptedManifests) {
const existing = tabManifestMap.get(tab.id) || [];
for (const m of contentData.interceptedManifests) { for (const m of contentData.interceptedManifests) {
if (m.url && !existing.some(em => em.url === m.url)) { if (m.url && !tabManifests.some(tm => tm.url === m.url)) {
existing.push({ url: m.url, player: 'content-script-intercept' }); 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 { return {
+47 -2
View File
@@ -338,8 +338,14 @@ function wireHlsEvents(hls, video, panel) {
}); });
// --- Segment / fragment tracking --- // --- 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) => { hls.on(HlsEvents.FRAG_LOADING, (_ev, _data) => {
if (video.paused || video.readyState < 3) {
state.phase = 'buffering'; state.phase = 'buffering';
}
state.lastEvent = 'FRAG_LOADING'; state.lastEvent = 'FRAG_LOADING';
state.lastActivity = Date.now(); state.lastActivity = Date.now();
renderPanelState(video, panel); renderPanelState(video, panel);
@@ -352,15 +358,41 @@ function wireHlsEvents(hls, video, panel) {
renderPanelState(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) => { hls.on(HlsEvents.FRAG_BUFFERED, (_ev, _data) => {
state.lastEvent = 'FRAG_BUFFERED'; state.lastEvent = 'FRAG_BUFFERED';
state.lastActivity = Date.now(); state.lastActivity = Date.now();
if (state.phase === 'buffering' && video.readyState >= 2) { if (state.phase !== 'error' && video.readyState >= 3 && !video.paused) {
state.phase = 'playing'; state.phase = 'playing';
} }
renderPanelState(video, panel); 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 --- // --- Buffer tracking ---
hls.on(HlsEvents.BUFFER_APPENDED, (_ev, _data) => { hls.on(HlsEvents.BUFFER_APPENDED, (_ev, _data) => {
state.lastEvent = 'BUFFER_APPENDED'; state.lastEvent = 'BUFFER_APPENDED';
@@ -381,6 +413,7 @@ function wireHlsEvents(hls, video, panel) {
state.lastActivity = Date.now(); state.lastActivity = Date.now();
state.bufferedAhead = 0; state.bufferedAhead = 0;
state.segmentsLoaded = 0; state.segmentsLoaded = 0;
state.segmentsBuffered = 0;
renderPanelState(video, panel); renderPanelState(video, panel);
}); });
@@ -388,6 +421,7 @@ function wireHlsEvents(hls, video, panel) {
hls.on(HlsEvents.LEVEL_SWITCHED, (_ev, data) => { hls.on(HlsEvents.LEVEL_SWITCHED, (_ev, data) => {
state.lastEvent = 'LEVEL_SWITCHED'; state.lastEvent = 'LEVEL_SWITCHED';
state.lastActivity = Date.now(); state.lastActivity = Date.now();
state.phase = 'switching';
state.level = data.level; state.level = data.level;
const levels = hls.levels; const levels = hls.levels;
if (levels && levels[data.level]) { if (levels && levels[data.level]) {
@@ -450,9 +484,10 @@ function wireHlsEvents(hls, video, panel) {
return; return;
} }
// Fatal errors // Fatal errors — attempt recovery before giving up
if (data.type === window.Hls.ErrorTypes.NETWORK_ERROR) { if (data.type === window.Hls.ErrorTypes.NETWORK_ERROR) {
state.errorMessage = `Network: ${data.details}`; state.errorMessage = `Network: ${data.details}`;
state.segmentsFailed++;
state.phase = 'error'; state.phase = 'error';
renderPanelState(video, panel); renderPanelState(video, panel);
showError(panel, 'Stream expired - re-scan to get fresh URLs.'); 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) { } else if (data.type === window.Hls.ErrorTypes.MEDIA_ERROR) {
state.errorMessage = `Media: ${data.details}`; state.errorMessage = `Media: ${data.details}`;
state.phase = 'buffering'; state.phase = 'buffering';
state.lastActivity = Date.now();
renderPanelState(video, panel); renderPanelState(video, panel);
hls.recoverMediaError(); hls.recoverMediaError();
} else { } else {
// Unknown fatal error — try recovering once before destroying
state.errorMessage = `Fatal: ${data.details}`; state.errorMessage = `Fatal: ${data.details}`;
state.phase = 'error'; state.phase = 'error';
renderPanelState(video, panel); renderPanelState(video, panel);
@@ -472,6 +509,14 @@ function wireHlsEvents(hls, video, panel) {
hlsInstances.delete(video); hlsInstances.delete(video);
} }
}); });
// --- Stream completion ---
hls.on(HlsEvents.ENDLIST, () => {
state.lastEvent = 'ENDLIST';
state.lastActivity = Date.now();
state.phase = 'ended';
renderPanelState(video, panel);
});
} }
/** /**