ext: content/ media detection (build order step 7)
Two independent signals feed one panel, per docs/05 §3: - capture/media.ts (background): webRequest-based, recognizes .m3u8/.mpd URLs and the HLS/DASH content types, deduped per (tab, url) so an HLS live-refresh doesn't re-fire. media-bridge.ts relays a hit to the tab's content script over runtime.sendMessage, and separately answers the content script's media.listVariants/media.addVariant calls by forwarding them to the background page's transport. - content/media-observer.ts: watches the page's own <video> elements (present now, added later, or with src changed) for the same extension signal, independent of what the network sniffer saw. Either firing opens content/video-panel.ts's "Download this video ▾" panel (built with createElement, matching the popup's innerHTML-free approach), which lists variants from media.listVariants and greys out any variant.drm or a wholly drmProtected manifest with "Protected content" rather than attempting it. The extension still never parses a manifest itself — that stays in the daemon, one language, one place. content/index.ts is the manifest-registered entry (content_scripts in manifest.json, added in the previous commit); build.mjs builds it as an IIFE rather than ESM, since a manifest content script has no "type": "module" declaration and an emitted top-level export would be a syntax error there. tsconfig.json adds DOM.Iterable for NodeList iteration. docs/05-extension-spec.md gets a short addendum (§8) documenting the popup/options bridge and this media-detection split, since neither was in the original design write-up. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Ed8KEmAW48v4YHdxLtqsMB
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
// Streaming-media detection, background half. docs/05 §3: sniff `.m3u8`/`.mpd` and the
|
||||
// HLS/DASH content types on the wire, then tell the tab's content script a manifest was
|
||||
// seen. The extension never parses the manifest itself — media.listVariants (daemon-side)
|
||||
// does that; this module only recognizes "this response is probably a manifest".
|
||||
|
||||
const MANIFEST_EXTENSIONS = ['.m3u8', '.mpd'];
|
||||
const MANIFEST_CONTENT_TYPES = [
|
||||
'application/vnd.apple.mpegurl',
|
||||
'application/x-mpegurl',
|
||||
'audio/mpegurl',
|
||||
'application/dash+xml',
|
||||
];
|
||||
|
||||
/** Extension check ignores the query string — `manifest.m3u8?token=…` still counts. */
|
||||
export function isManifestUrl(url: string): boolean {
|
||||
let pathname: string;
|
||||
try {
|
||||
pathname = new URL(url).pathname.toLowerCase();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return MANIFEST_EXTENSIONS.some((ext) => pathname.endsWith(ext));
|
||||
}
|
||||
|
||||
export function isManifestContentType(contentType: string | null | undefined): boolean {
|
||||
if (!contentType) return false;
|
||||
const base = contentType.split(';')[0]!.trim().toLowerCase();
|
||||
return MANIFEST_CONTENT_TYPES.includes(base);
|
||||
}
|
||||
|
||||
export function looksLikeManifest(url: string, contentType: string | null | undefined): boolean {
|
||||
return isManifestUrl(url) || isManifestContentType(contentType);
|
||||
}
|
||||
|
||||
export interface DetectedManifest {
|
||||
tabId: number;
|
||||
url: string;
|
||||
headers: Array<{ name: string; value: string }>;
|
||||
}
|
||||
|
||||
interface HeadersReceivedDetails {
|
||||
tabId: number;
|
||||
url: string;
|
||||
responseHeaders?: Array<{ name: string; value: string }>;
|
||||
}
|
||||
|
||||
export interface MediaWebRequest {
|
||||
onHeadersReceived: {
|
||||
addListener(
|
||||
cb: (details: HeadersReceivedDetails) => void,
|
||||
filter: { urls: string[]; types?: string[] },
|
||||
extraInfoSpec?: string[],
|
||||
): void;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Watches responses for HLS/DASH manifests and reports each hit once per (tab, url) —
|
||||
* a page can request the same manifest repeatedly (HLS live-refresh) and the content
|
||||
* script only needs to hear about it once to show the panel.
|
||||
*/
|
||||
export class MediaWatcher {
|
||||
private readonly seen = new Set<string>();
|
||||
|
||||
constructor(private readonly onDetected: (m: DetectedManifest) => void) {}
|
||||
|
||||
attach(webRequest: MediaWebRequest): void {
|
||||
webRequest.onHeadersReceived.addListener(
|
||||
(details) => this.handle(details),
|
||||
{ urls: ['<all_urls>'] },
|
||||
['responseHeaders'],
|
||||
);
|
||||
}
|
||||
|
||||
private handle(details: HeadersReceivedDetails): void {
|
||||
if (details.tabId < 0) return; // not a request associated with any tab
|
||||
const headers = details.responseHeaders ?? [];
|
||||
const contentType = headers.find((h) => h.name.toLowerCase() === 'content-type')?.value;
|
||||
if (!looksLikeManifest(details.url, contentType)) return;
|
||||
|
||||
const key = `${details.tabId}:${details.url}`;
|
||||
if (this.seen.has(key)) return;
|
||||
this.seen.add(key);
|
||||
|
||||
this.onDetected({ tabId: details.tabId, url: details.url, headers });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user