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.
|
* 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 {
|
||||||
|
|||||||
+48
-3
@@ -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) => {
|
||||||
state.phase = 'buffering';
|
if (video.paused || video.readyState < 3) {
|
||||||
|
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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in new issue
Block a user