Skip to content

Commit 447588f

Browse files
Enhance video detection capabilities in Chrome extension
- Updated background script to support detection of MPD video manifests alongside existing M3U8 and MP4 formats. - Introduced a new content script to intercept fetch/XHR responses for manifest detection. - Modified the manifest to include the new inject.js script for improved functionality. - Updated scoring and URL validation logic to accommodate the new video formats. - Enhanced side panel to display detected formats accurately. - Refactored background tests to validate new detection logic for MPD and M3U8 formats.
1 parent 3a18acd commit 447588f

8 files changed

Lines changed: 524 additions & 110 deletions

File tree

chrome-extension/background.js

Lines changed: 164 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// Background Service Worker for Video Detection and Download Management
22

3-
// Store detected video URLs (m3u8, mp4)
3+
// Store detected video URLs (m3u8, mpd, mp4)
44
let detectedUrls = new Set();
55
let currentTabUrls = {};
66
let currentTabUrlKeys = {};
@@ -41,7 +41,10 @@ function scoreUrlInfo(info) {
4141

4242
// Prefer manifests / single-file videos over segments.
4343
if (urlLower.includes('.m3u8')) score += 4;
44+
if (urlLower.includes('.mpd')) score += 4;
4445
if (urlLower.includes('.mp4')) score += 1;
46+
const fmt = String(info?.detectedFormat || '').toLowerCase();
47+
if (fmt === 'mpd' || fmt === 'm3u8') score += 4;
4548

4649
// Request type hint (Chrome categorizes actual playback as "media" on many sites).
4750
const rt = String(info?.requestType || '').toLowerCase();
@@ -272,7 +275,7 @@ function isCandidateVideoUrl(rawUrl) {
272275
if (!rawUrl || typeof rawUrl !== 'string') return false;
273276

274277
const urlLower = rawUrl.toLowerCase();
275-
if (!(urlLower.includes('.m3u8') || urlLower.includes('.mp4'))) return false;
278+
if (!(urlLower.includes('.m3u8') || urlLower.includes('.mpd') || urlLower.includes('.mp4'))) return false;
276279

277280
// Reject obvious non-video resources even if they contain ".mp4" or ".m3u8" in the name.
278281
// Example: "preview_720p.mp4.jpg" is an image, not a real mp4.
@@ -301,98 +304,132 @@ function isCandidateVideoUrl(rawUrl) {
301304
return true;
302305
}
303306

304-
// Listen for web requests to detect video URLs (m3u8, mp4)
307+
// Register a detected video URL into per-tab and orphan stores.
308+
// `extra` may carry additional fields like `detectedFormat`.
309+
function registerDetectedUrl(details, extra) {
310+
const isRealTab = (details.tabId != null && typeof details.tabId === 'number' && details.tabId >= 0);
311+
312+
const urlInfo = {
313+
url: details.url,
314+
tabId: isRealTab ? details.tabId : -1,
315+
timestamp: Date.now(),
316+
pageUrl: details.initiator || details.documentUrl,
317+
requestType: details.type,
318+
frameId: details.frameId,
319+
method: details.method,
320+
hitCount: 1,
321+
rangeHitCount: 0,
322+
...(extra || {})
323+
};
324+
325+
detectedUrls.add(details.url);
326+
327+
if (isRealTab) {
328+
if (!currentTabUrls[details.tabId]) {
329+
currentTabUrls[details.tabId] = [];
330+
}
331+
if (!currentTabUrlKeys[details.tabId]) {
332+
currentTabUrlKeys[details.tabId] = new Set();
333+
}
334+
335+
if (!currentTabUrlKeys[details.tabId].has(details.url)) {
336+
currentTabUrlKeys[details.tabId].add(details.url);
337+
currentTabUrls[details.tabId].push(urlInfo);
338+
} else {
339+
const list = currentTabUrls[details.tabId];
340+
const existing = list.find(item => item && item.url === details.url);
341+
if (existing) {
342+
existing.timestamp = urlInfo.timestamp;
343+
existing.pageUrl = urlInfo.pageUrl;
344+
existing.requestType = urlInfo.requestType;
345+
existing.frameId = urlInfo.frameId;
346+
existing.method = urlInfo.method;
347+
existing.hitCount = (Number(existing.hitCount) || 0) + 1;
348+
if (extra && extra.detectedFormat && !existing.detectedFormat) {
349+
existing.detectedFormat = extra.detectedFormat;
350+
}
351+
notifyDetectedUrlsUpdated(details.tabId);
352+
}
353+
}
354+
} else {
355+
if (!orphanUrlKeys.has(details.url)) {
356+
orphanUrlKeys.add(details.url);
357+
orphanUrlInfos.push(urlInfo);
358+
pruneOrphans();
359+
} else {
360+
const existing = orphanUrlInfos.find(item => item && item.url === details.url);
361+
if (existing) {
362+
existing.timestamp = urlInfo.timestamp;
363+
existing.pageUrl = urlInfo.pageUrl;
364+
existing.requestType = urlInfo.requestType;
365+
existing.frameId = urlInfo.frameId;
366+
existing.method = urlInfo.method;
367+
existing.hitCount = (Number(existing.hitCount) || 0) + 1;
368+
if (extra && extra.detectedFormat && !existing.detectedFormat) {
369+
existing.detectedFormat = extra.detectedFormat;
370+
}
371+
pruneOrphans();
372+
}
373+
}
374+
}
375+
376+
if (isRealTab) updateBadge(details.tabId);
377+
chrome.storage.local.set({ detectedUrls: Array.from(detectedUrls) });
378+
}
379+
380+
// Listen for web requests to detect video URLs by extension (m3u8, mpd, mp4)
305381
chrome.webRequest.onBeforeRequest.addListener(
306382
function(details) {
307383
if (!userSettings.autoDetect) return;
308384

309385
if (isCandidateVideoUrl(details.url)) {
310386
console.log('Detected video URL:', details.url);
311-
312-
const isRealTab = (details.tabId != null && typeof details.tabId === 'number' && details.tabId >= 0);
313-
314-
// Store URL with tab info (preserve original URL case)
315-
const urlInfo = {
316-
url: details.url,
317-
tabId: isRealTab ? details.tabId : -1,
318-
timestamp: Date.now(),
319-
pageUrl: details.initiator || details.documentUrl,
320-
requestType: details.type,
321-
frameId: details.frameId,
322-
method: details.method,
323-
hitCount: 1,
324-
rangeHitCount: 0
325-
};
326-
327-
// Add to detected URLs
328-
// Deduplicate globally by exact URL string
329-
detectedUrls.add(details.url);
330-
331-
// Store for current tab
332-
if (isRealTab) {
333-
if (!currentTabUrls[details.tabId]) {
334-
currentTabUrls[details.tabId] = [];
335-
}
336-
if (!currentTabUrlKeys[details.tabId]) {
337-
currentTabUrlKeys[details.tabId] = new Set();
338-
}
339-
340-
// Deduplicate per-tab by exact URL string: only show once in sidepanel
341-
if (!currentTabUrlKeys[details.tabId].has(details.url)) {
342-
currentTabUrlKeys[details.tabId].add(details.url);
343-
currentTabUrls[details.tabId].push(urlInfo);
344-
} else {
345-
// Update the existing entry's metadata (keep ordering stable)
346-
const list = currentTabUrls[details.tabId];
347-
const existing = list.find(item => item && item.url === details.url);
348-
if (existing) {
349-
existing.timestamp = urlInfo.timestamp;
350-
existing.pageUrl = urlInfo.pageUrl;
351-
existing.requestType = urlInfo.requestType;
352-
existing.frameId = urlInfo.frameId;
353-
existing.method = urlInfo.method;
354-
existing.hitCount = (Number(existing.hitCount) || 0) + 1;
355-
notifyDetectedUrlsUpdated(details.tabId);
356-
}
357-
}
358-
} else {
359-
// Orphan request: keep it so sidepanel can still show m3u8 for SW-based sites.
360-
if (!orphanUrlKeys.has(details.url)) {
361-
orphanUrlKeys.add(details.url);
362-
orphanUrlInfos.push(urlInfo);
363-
pruneOrphans();
364-
} else {
365-
const existing = orphanUrlInfos.find(item => item && item.url === details.url);
366-
if (existing) {
367-
existing.timestamp = urlInfo.timestamp;
368-
existing.pageUrl = urlInfo.pageUrl;
369-
existing.requestType = urlInfo.requestType;
370-
existing.frameId = urlInfo.frameId;
371-
existing.method = urlInfo.method;
372-
existing.hitCount = (Number(existing.hitCount) || 0) + 1;
373-
pruneOrphans();
374-
}
375-
}
376-
}
377-
378-
// Update badge
379-
if (isRealTab) updateBadge(details.tabId);
380-
381-
// Store in chrome.storage for popup access
382-
chrome.storage.local.set({ detectedUrls: Array.from(detectedUrls) });
387+
registerDetectedUrl(details);
383388
}
384389
},
385390
{ urls: ["<all_urls>"] }
386391
);
387392

393+
// Detect video manifests by response Content-Type header.
394+
// Catches DASH/HLS manifests served from URLs without .mpd/.m3u8 extensions
395+
// (e.g. API endpoints like /api/video/xxx that return MPD XML).
396+
const MANIFEST_CONTENT_TYPES = {
397+
'application/dash+xml': 'mpd',
398+
'video/vnd.mpeg.dash.mpd': 'mpd',
399+
'application/vnd.apple.mpegurl': 'm3u8',
400+
'application/x-mpegurl': 'm3u8',
401+
'audio/mpegurl': 'm3u8',
402+
'audio/x-mpegurl': 'm3u8',
403+
};
404+
405+
chrome.webRequest.onHeadersReceived.addListener(
406+
function(details) {
407+
if (!userSettings.autoDetect) return;
408+
if (isCandidateVideoUrl(details.url)) return;
409+
410+
const ctHeader = (details.responseHeaders || [])
411+
.find(h => h.name.toLowerCase() === 'content-type');
412+
if (!ctHeader || !ctHeader.value) return;
413+
414+
const ct = ctHeader.value.toLowerCase().split(';')[0].trim();
415+
const format = MANIFEST_CONTENT_TYPES[ct];
416+
if (!format) return;
417+
418+
console.log('Detected video manifest by Content-Type:', details.url, '->', format);
419+
registerDetectedUrl(details, { detectedFormat: format });
420+
},
421+
{ urls: ["<all_urls>"] },
422+
["responseHeaders"]
423+
);
424+
388425
// Capture actual request headers sent by the browser for video URLs
389426
// This includes cookies that Chrome would send to the video domain
390427
chrome.webRequest.onSendHeaders.addListener(
391428
function(details) {
392429
const urlLower = details.url.toLowerCase();
393430

394-
// Only capture headers for video URLs
395-
if (isCandidateVideoUrl(details.url)) {
431+
// Capture headers for video URLs (by extension or Content-Type detection)
432+
if (isCandidateVideoUrl(details.url) || getDetectedFormat(details.url)) {
396433
// Convert headers array to object
397434
const headersObj = {};
398435
const SINGLETON_HEADERS = new Set(['User-Agent', 'Referer', 'Origin']);
@@ -516,7 +553,7 @@ chrome.runtime.onInstalled.addListener(() => {
516553
chrome.contextMenus.onClicked.addListener((info, tab) => {
517554
let url = info.linkUrl || info.pageUrl;
518555

519-
// Check if it's a video URL (m3u8 or mp4)
556+
// Check if it's a video URL (m3u8, mpd, or mp4)
520557
const urlLower = url ? url.toLowerCase() : '';
521558
const isVideoUrl = url && isCandidateVideoUrl(url);
522559
if (isVideoUrl) {
@@ -534,10 +571,25 @@ chrome.contextMenus.onClicked.addListener((info, tab) => {
534571
}
535572
});
536573

574+
// Check if a URL was detected via Content-Type (stored in per-tab or orphan lists)
575+
function getDetectedFormat(url) {
576+
for (const tabId of Object.keys(currentTabUrls)) {
577+
const list = currentTabUrls[tabId];
578+
if (!Array.isArray(list)) continue;
579+
const item = list.find(x => x && x.url === url);
580+
if (item && item.detectedFormat) return item.detectedFormat;
581+
}
582+
for (const item of orphanUrlInfos) {
583+
if (item && item.url === url && item.detectedFormat) return item.detectedFormat;
584+
}
585+
return null;
586+
}
587+
537588
// Send URL to NAS
538589
async function sendToNAS(url, pageTitle, pageUrl) {
539590
try {
540-
if (!isCandidateVideoUrl(url)) {
591+
const formatHint = getDetectedFormat(url);
592+
if (!isCandidateVideoUrl(url) && !formatHint) {
541593
showNotification('Error', 'Not a valid video URL');
542594
return;
543595
}
@@ -600,8 +652,11 @@ async function sendToNAS(url, pageTitle, pageUrl) {
600652
const ku = tryGetUrl(k);
601653
if (!ku || !entry) continue;
602654

603-
// Only consider m3u8 captures
604-
if (!k.toLowerCase().includes('.m3u8')) continue;
655+
// Only consider manifest captures (m3u8/mpd or Content-Type detected)
656+
const kl = k.toLowerCase();
657+
const isManifestByExt = kl.includes('.m3u8') || kl.includes('.mpd');
658+
const isManifestByFormat = !!getDetectedFormat(k);
659+
if (!isManifestByExt && !isManifestByFormat) continue;
605660

606661
let score = 0;
607662
if (tabId != null && entry.tabId === tabId) score += 10;
@@ -645,7 +700,7 @@ async function sendToNAS(url, pageTitle, pageUrl) {
645700
if (shouldUseBest) {
646701
captured = best.entry;
647702
urlToSend = best.url;
648-
console.log('Using best captured m3u8 for this tab:', urlToSend);
703+
console.log('Using best captured manifest for this tab:', urlToSend);
649704
}
650705

651706
if (captured && captured.headers) {
@@ -738,6 +793,9 @@ async function sendToNAS(url, pageTitle, pageUrl) {
738793
referer: pageUrl,
739794
headers: finalHeaders
740795
};
796+
if (formatHint) {
797+
requestBody.format = formatHint;
798+
}
741799

742800
console.log('Sending to NAS:');
743801
console.log(' URL:', requestBody.url);
@@ -824,6 +882,28 @@ chrome.action.onClicked.addListener(async (tab) => {
824882

825883
// Listen for messages from sidepanel and content scripts
826884
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
885+
// Handle manifest detected by inject.js (fetch/XHR interception)
886+
if (request.action === 'manifestDetected') {
887+
const tabId = sender.tab?.id;
888+
const url = request.url;
889+
const format = request.format;
890+
if (url && format) {
891+
console.log('Manifest detected by content interception:', url, '->', format);
892+
const details = {
893+
url: url,
894+
tabId: (tabId != null && tabId >= 0) ? tabId : -1,
895+
initiator: request.pageUrl,
896+
documentUrl: request.pageUrl,
897+
type: 'xmlhttprequest',
898+
frameId: 0,
899+
method: 'GET'
900+
};
901+
registerDetectedUrl(details, { detectedFormat: format });
902+
}
903+
sendResponse({ success: true });
904+
return;
905+
}
906+
827907
// Handle user clicking on a video element (from content script)
828908
if (request.action === 'userClickedVideo' || request.action === 'videoStartedPlaying') {
829909
const tabId = sender.tab?.id;
@@ -849,8 +929,9 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
849929
const list = currentTabUrls[tabId] || [];
850930
// Filter to m3u8 or mp4 URLs in detection order
851931
const m3u8Urls = list.filter(u => String(u.url || '').toLowerCase().includes('.m3u8'));
932+
const mpdUrls = list.filter(u => String(u.url || '').toLowerCase().includes('.mpd'));
852933
const mp4Urls = list.filter(u => String(u.url || '').toLowerCase().includes('.mp4'));
853-
const urlsInOrder = m3u8Urls.length > 0 ? m3u8Urls : mp4Urls;
934+
const urlsInOrder = m3u8Urls.length > 0 ? m3u8Urls : (mpdUrls.length > 0 ? mpdUrls : mp4Urls);
854935

855936
if (urlsInOrder.length > 0 && videoIndex < urlsInOrder.length) {
856937
matchedUrl = urlsInOrder[videoIndex].url;

chrome-extension/content.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,4 +129,31 @@
129129
v.addEventListener('play', handlePlay);
130130
});
131131

132+
// Forward manifest detection to background
133+
function forwardManifest(data) {
134+
if (!data || !data.url || !data.format) return;
135+
try {
136+
chrome.runtime.sendMessage({
137+
action: 'manifestDetected',
138+
url: data.url,
139+
format: data.format,
140+
pageUrl: window.location.href,
141+
timestamp: Date.now()
142+
});
143+
} catch (e) {
144+
// Extension context may be invalid
145+
}
146+
}
147+
148+
// Listen for manifest detections from inject.js (MAIN world)
149+
window.addEventListener('message', (event) => {
150+
if (event.source !== window) return;
151+
if (!event.data || event.data.type !== 'WV2NAS_MANIFEST_DETECTED') return;
152+
forwardManifest(event.data);
153+
});
154+
155+
// Ask inject.js to re-send any manifests detected before we were ready
156+
window.postMessage({ type: 'WV2NAS_CONTENT_READY' }, '*');
157+
158+
132159
})();

0 commit comments

Comments
 (0)