Files
samiandClaude Sonnet 5 03b6253b5a 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
2026-09-11 12:26:48 +04:00

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