fix: HLS playback tracking and detection fallback for sandboxed iframes
CI / test (pull_request) Successful in 23s

Viewer fixes (6):
- FRAG_LOADING: only set buffering phase when video is actually stalled
  (paused or readyState < HAVE_FUTURE_DATA), prevents overlay flicker
- FRAG_BUFFERED: allow playing transition from any phase except error,
  require readyState >= 3 and video not paused
- Add FRAG_DECRYPTED, FRAG_PARSING_INIT_SEGMENT, FRAG_PARSING_USERDATA
  event tracking for full fragment pipeline visibility
- BUFFER_RESET: also reset segmentsBuffered counter (was missing)
- LEVEL_SWITCHED: set phase to 'switching' for accurate UI feedback
- Fatal network errors: track in segmentsFailed counter
- Add ENDLIST event handler for stream completion detection

Detection fixes:
- Add filterManifests(): prefer .m3u8 manifest URLs over .ts segments,
  reconstruct manifest URL from segment paths when needed
- Add fallback in scanVideos(): when scrapeFn returns no visible <video>
  elements (sandboxed iframe), create video entries from intercepted
  manifest data. Fixes '0 videos detected' issue on Pornhub and similar
  sites where the video player is in an inaccessible iframe.
This commit is contained in:
robot committed 2026-08-12 23:51:32 +00:00
1 parent 3cf402d3c7
commit e11be9a20a
2 files changed
+131 -6

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);
});
} }
/** /**