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,112 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { isManifestContentType, isManifestUrl, looksLikeManifest, MediaWatcher } from '../../src/background/capture/media.js';
|
||||
|
||||
describe('isManifestUrl', () => {
|
||||
it('matches .m3u8 and .mpd, query string included', () => {
|
||||
expect(isManifestUrl('https://cdn.example/video/master.m3u8')).toBe(true);
|
||||
expect(isManifestUrl('https://cdn.example/video/master.m3u8?token=abc')).toBe(true);
|
||||
expect(isManifestUrl('https://cdn.example/video/stream.mpd')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not match an ordinary page or a segment file', () => {
|
||||
expect(isManifestUrl('https://example.com/watch?v=1')).toBe(false);
|
||||
expect(isManifestUrl('https://cdn.example/video/segment-001.ts')).toBe(false);
|
||||
});
|
||||
|
||||
it('is false on an unparseable URL rather than throwing', () => {
|
||||
expect(isManifestUrl('not a url')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isManifestContentType', () => {
|
||||
it('matches HLS and DASH content types, ignoring a charset suffix', () => {
|
||||
expect(isManifestContentType('application/vnd.apple.mpegurl')).toBe(true);
|
||||
expect(isManifestContentType('application/dash+xml; charset=utf-8')).toBe(true);
|
||||
expect(isManifestContentType('application/x-mpegurl')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not match ordinary types or missing headers', () => {
|
||||
expect(isManifestContentType('text/html')).toBe(false);
|
||||
expect(isManifestContentType(null)).toBe(false);
|
||||
expect(isManifestContentType(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('looksLikeManifest', () => {
|
||||
it('is true on either signal alone', () => {
|
||||
expect(looksLikeManifest('https://cdn.example/x.m3u8', 'text/plain')).toBe(true);
|
||||
expect(looksLikeManifest('https://cdn.example/x', 'application/dash+xml')).toBe(true);
|
||||
expect(looksLikeManifest('https://cdn.example/x', 'text/html')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MediaWatcher', () => {
|
||||
function fakeWebRequest() {
|
||||
let listener: ((d: unknown) => void) | undefined;
|
||||
return {
|
||||
onHeadersReceived: {
|
||||
addListener: (cb: (d: unknown) => void) => {
|
||||
listener = cb;
|
||||
},
|
||||
},
|
||||
fire: (d: unknown) => listener?.(d),
|
||||
};
|
||||
}
|
||||
|
||||
it('reports a detected manifest with its response headers', () => {
|
||||
const wr = fakeWebRequest();
|
||||
const detected: unknown[] = [];
|
||||
const watcher = new MediaWatcher((m) => detected.push(m));
|
||||
watcher.attach(wr as never);
|
||||
|
||||
wr.fire({
|
||||
tabId: 7,
|
||||
url: 'https://cdn.example/master.m3u8',
|
||||
responseHeaders: [{ name: 'Content-Type', value: 'application/vnd.apple.mpegurl' }],
|
||||
});
|
||||
|
||||
expect(detected).toEqual([
|
||||
{
|
||||
tabId: 7,
|
||||
url: 'https://cdn.example/master.m3u8',
|
||||
headers: [{ name: 'Content-Type', value: 'application/vnd.apple.mpegurl' }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores a response that is not a manifest', () => {
|
||||
const wr = fakeWebRequest();
|
||||
const detected: unknown[] = [];
|
||||
const watcher = new MediaWatcher((m) => detected.push(m));
|
||||
watcher.attach(wr as never);
|
||||
|
||||
wr.fire({ tabId: 7, url: 'https://example.com/page.html', responseHeaders: [{ name: 'Content-Type', value: 'text/html' }] });
|
||||
|
||||
expect(detected).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('ignores requests with no associated tab', () => {
|
||||
const wr = fakeWebRequest();
|
||||
const detected: unknown[] = [];
|
||||
const watcher = new MediaWatcher((m) => detected.push(m));
|
||||
watcher.attach(wr as never);
|
||||
|
||||
wr.fire({ tabId: -1, url: 'https://cdn.example/master.m3u8', responseHeaders: [] });
|
||||
|
||||
expect(detected).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('reports the same (tab, url) manifest only once', () => {
|
||||
const wr = fakeWebRequest();
|
||||
const detected: unknown[] = [];
|
||||
const watcher = new MediaWatcher((m) => detected.push(m));
|
||||
watcher.attach(wr as never);
|
||||
|
||||
const details = { tabId: 7, url: 'https://cdn.example/live.m3u8', responseHeaders: [] };
|
||||
wr.fire(details);
|
||||
wr.fire(details); // HLS live-refresh re-requests the same manifest
|
||||
|
||||
expect(detected).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user