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
113 lines
3.9 KiB
TypeScript
113 lines
3.9 KiB
TypeScript
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);
|
|
});
|
|
});
|