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,106 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { MediaBridge, notifyTab } from '../../src/background/media-bridge.js';
|
||||
import type { ContentMessage } from '../../src/background/media-bridge.js';
|
||||
import type { VeloxTransport } from '../../src/background/transport/index.js';
|
||||
|
||||
function fakeOnMessage() {
|
||||
let listener: ((msg: ContentMessage, sender: unknown, sendResponse: (r: unknown) => void) => boolean | void) | undefined;
|
||||
return {
|
||||
addListener: (cb: typeof listener) => {
|
||||
listener = cb;
|
||||
},
|
||||
fire: (msg: ContentMessage) =>
|
||||
new Promise((resolve) => {
|
||||
listener?.(msg, {}, resolve);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function fakeTransport(call: VeloxTransport['call']): VeloxTransport {
|
||||
return {
|
||||
kind: 'ws',
|
||||
state: 'connected',
|
||||
status: {
|
||||
state: 'connected',
|
||||
needsPairing: false,
|
||||
fatal: null,
|
||||
retryAfterSec: null,
|
||||
sessionId: null,
|
||||
daemonVersion: null,
|
||||
capabilities: [],
|
||||
},
|
||||
connect: async () => undefined,
|
||||
disconnect: () => undefined,
|
||||
call,
|
||||
on: () => undefined,
|
||||
off: () => undefined,
|
||||
onStateChange: () => () => undefined,
|
||||
};
|
||||
}
|
||||
|
||||
describe('MediaBridge', () => {
|
||||
it('answers velox-list-variants by calling media.listVariants', async () => {
|
||||
const call = vi.fn(async () => ({ variants: [], manifestType: 'hls', drmProtected: false }));
|
||||
const bridge = new MediaBridge(() => fakeTransport(call as never));
|
||||
const onMessage = fakeOnMessage();
|
||||
bridge.attach(onMessage);
|
||||
|
||||
const response = await onMessage.fire({ type: 'velox-list-variants', params: { manifestUrl: 'https://x/m.m3u8' } });
|
||||
|
||||
expect(call).toHaveBeenCalledWith('media.listVariants', { manifestUrl: 'https://x/m.m3u8' });
|
||||
expect(response).toEqual({ ok: true, result: { variants: [], manifestType: 'hls', drmProtected: false } });
|
||||
});
|
||||
|
||||
it('answers velox-add-variant by calling media.addVariant', async () => {
|
||||
const call = vi.fn(async () => ({ taskId: 't1', state: 'queued' }));
|
||||
const bridge = new MediaBridge(() => fakeTransport(call as never));
|
||||
const onMessage = fakeOnMessage();
|
||||
bridge.attach(onMessage);
|
||||
|
||||
const response = await onMessage.fire({
|
||||
type: 'velox-add-variant',
|
||||
params: { manifestUrl: 'https://x/m.m3u8', variantId: 'v1' },
|
||||
});
|
||||
|
||||
expect(call).toHaveBeenCalledWith('media.addVariant', { manifestUrl: 'https://x/m.m3u8', variantId: 'v1' });
|
||||
expect(response).toEqual({ ok: true, result: { taskId: 't1', state: 'queued' } });
|
||||
});
|
||||
|
||||
it('answers with an error when the transport is not ready', async () => {
|
||||
const bridge = new MediaBridge(() => undefined);
|
||||
const onMessage = fakeOnMessage();
|
||||
bridge.attach(onMessage);
|
||||
|
||||
const response = await onMessage.fire({ type: 'velox-list-variants', params: { manifestUrl: 'https://x/m.m3u8' } });
|
||||
expect(response).toEqual({ ok: false, error: 'transport not ready' });
|
||||
});
|
||||
|
||||
it('answers with an error when the call rejects', async () => {
|
||||
const call = vi.fn(async () => {
|
||||
throw new Error('refused: drm protected');
|
||||
});
|
||||
const bridge = new MediaBridge(() => fakeTransport(call as never));
|
||||
const onMessage = fakeOnMessage();
|
||||
bridge.attach(onMessage);
|
||||
|
||||
const response = await onMessage.fire({ type: 'velox-list-variants', params: { manifestUrl: 'https://x/m.m3u8' } });
|
||||
expect(response).toEqual({ ok: false, error: 'refused: drm protected' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('notifyTab', () => {
|
||||
it('sends a velox-manifest-detected message to the tab', () => {
|
||||
const sent: unknown[] = [];
|
||||
const tabs = { sendMessage: (tabId: number, message: unknown) => { sent.push({ tabId, message }); return Promise.resolve(); } };
|
||||
|
||||
notifyTab(tabs, { tabId: 3, url: 'https://x/m.m3u8', headers: [] });
|
||||
|
||||
expect(sent).toEqual([{ tabId: 3, message: { type: 'velox-manifest-detected', url: 'https://x/m.m3u8', headers: [] } }]);
|
||||
});
|
||||
|
||||
it('swallows a rejection (tab has no content script listening)', () => {
|
||||
const tabs = { sendMessage: () => Promise.reject(new Error('no receiving end')) };
|
||||
expect(() => notifyTab(tabs, { tabId: 3, url: 'https://x/m.m3u8', headers: [] })).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { VideoSrcObserver } from '../../src/content/media-observer.js';
|
||||
|
||||
describe('VideoSrcObserver', () => {
|
||||
it('reports a <video src> already present when start() is called', () => {
|
||||
document.body.innerHTML = '<video src="https://cdn.example/master.m3u8"></video>';
|
||||
const found: string[] = [];
|
||||
const observer = new VideoSrcObserver((url) => found.push(url));
|
||||
|
||||
observer.start();
|
||||
|
||||
expect(found).toEqual(['https://cdn.example/master.m3u8']);
|
||||
observer.stop();
|
||||
});
|
||||
|
||||
it('ignores a <video> whose src is not a manifest', () => {
|
||||
document.body.innerHTML = '<video src="https://cdn.example/movie.mp4"></video>';
|
||||
const found: string[] = [];
|
||||
const observer = new VideoSrcObserver((url) => found.push(url));
|
||||
|
||||
observer.start();
|
||||
|
||||
expect(found).toHaveLength(0);
|
||||
observer.stop();
|
||||
});
|
||||
|
||||
it('reports a <video> added to the DOM after start()', async () => {
|
||||
document.body.innerHTML = '';
|
||||
const found: string[] = [];
|
||||
const observer = new VideoSrcObserver((url) => found.push(url));
|
||||
observer.start();
|
||||
|
||||
const video = document.createElement('video');
|
||||
video.src = 'https://cdn.example/live.mpd';
|
||||
document.body.appendChild(video);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0)); // MutationObserver callbacks are microtask-queued
|
||||
expect(found).toEqual(['https://cdn.example/live.mpd']);
|
||||
observer.stop();
|
||||
});
|
||||
|
||||
it('reports a src attribute change on an existing <video>', async () => {
|
||||
document.body.innerHTML = '<video></video>';
|
||||
const found: string[] = [];
|
||||
const observer = new VideoSrcObserver((url) => found.push(url));
|
||||
observer.start();
|
||||
|
||||
document.querySelector('video')!.setAttribute('src', 'https://cdn.example/switched.m3u8');
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(found).toEqual(['https://cdn.example/switched.m3u8']);
|
||||
observer.stop();
|
||||
});
|
||||
|
||||
it('reports the same URL only once', () => {
|
||||
document.body.innerHTML = '<video src="https://cdn.example/master.m3u8"></video><video src="https://cdn.example/master.m3u8"></video>';
|
||||
const found: string[] = [];
|
||||
const observer = new VideoSrcObserver((url) => found.push(url));
|
||||
|
||||
observer.start();
|
||||
|
||||
expect(found).toEqual(['https://cdn.example/master.m3u8']);
|
||||
observer.stop();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { VideoPanel, type VariantsPort } from '../../src/content/video-panel.js';
|
||||
import type { MediaVariant } from '../../src/shared/protocol/index.js';
|
||||
|
||||
function variant(over: Partial<MediaVariant> = {}): MediaVariant {
|
||||
return { variantId: 'v1', kind: 'muxed', drm: false, ...over };
|
||||
}
|
||||
|
||||
describe('VideoPanel', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('renders a button and an initially-hidden menu', () => {
|
||||
const port: VariantsPort = { listVariants: vi.fn(), addVariant: vi.fn() };
|
||||
const panel = new VideoPanel(port);
|
||||
panel.show('https://cdn.example/m.m3u8');
|
||||
|
||||
const button = document.querySelector('#velox-video-panel button')!;
|
||||
expect(button.textContent).toBe('Download this video ▾');
|
||||
expect((document.querySelector('#velox-video-panel ul') as HTMLUListElement).hidden).toBe(true);
|
||||
});
|
||||
|
||||
it('lists variants on click, skipping the daemon\'s manifest parsing entirely', async () => {
|
||||
const listVariants = vi.fn(async () => ({
|
||||
variants: [variant({ variantId: 'v-1080', resolution: '1920x1080' })],
|
||||
drmProtected: false,
|
||||
}));
|
||||
const port: VariantsPort = { listVariants, addVariant: vi.fn() };
|
||||
const panel = new VideoPanel(port);
|
||||
panel.show('https://cdn.example/m.m3u8');
|
||||
|
||||
document.querySelector('#velox-video-panel button')!.dispatchEvent(new Event('click'));
|
||||
await vi.waitFor(() => expect(listVariants).toHaveBeenCalledWith('https://cdn.example/m.m3u8'));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
const items = document.querySelectorAll('#velox-video-panel li');
|
||||
expect(items.length).toBe(1);
|
||||
expect(items[0]!.textContent).toContain('1920x1080');
|
||||
});
|
||||
|
||||
it('greys out a DRM variant with "Protected content" and does not wire a click handler', async () => {
|
||||
const listVariants = vi.fn(async () => ({
|
||||
variants: [variant({ variantId: 'v-drm', drm: true, resolution: '1920x1080' })],
|
||||
drmProtected: false,
|
||||
}));
|
||||
const addVariant = vi.fn();
|
||||
const panel = new VideoPanel({ listVariants, addVariant });
|
||||
panel.show('https://cdn.example/m.m3u8');
|
||||
|
||||
document.querySelector('#velox-video-panel button')!.dispatchEvent(new Event('click'));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
const item = document.querySelector<HTMLButtonElement>('#velox-video-panel li button')!;
|
||||
expect(item.disabled).toBe(true);
|
||||
expect(item.textContent).toContain('Protected content');
|
||||
|
||||
item.dispatchEvent(new Event('click'));
|
||||
expect(addVariant).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows "Protected content" for a wholly DRM-protected manifest', async () => {
|
||||
const listVariants = vi.fn(async () => ({ variants: [variant()], drmProtected: true }));
|
||||
const panel = new VideoPanel({ listVariants, addVariant: vi.fn() });
|
||||
panel.show('https://cdn.example/m.m3u8');
|
||||
|
||||
document.querySelector('#velox-video-panel button')!.dispatchEvent(new Event('click'));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
expect(document.querySelector('#velox-video-panel li')!.textContent).toBe('Protected content');
|
||||
});
|
||||
|
||||
it('calls addVariant when a non-DRM item is clicked', async () => {
|
||||
const listVariants = vi.fn(async () => ({ variants: [variant({ variantId: 'v-720' })], drmProtected: false }));
|
||||
const addVariant = vi.fn(async () => ({ taskId: 't1' }));
|
||||
const panel = new VideoPanel({ listVariants, addVariant });
|
||||
panel.show('https://cdn.example/m.m3u8');
|
||||
|
||||
document.querySelector('#velox-video-panel button')!.dispatchEvent(new Event('click'));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
document.querySelector<HTMLButtonElement>('#velox-video-panel li button')!.dispatchEvent(new Event('click'));
|
||||
|
||||
expect(addVariant).toHaveBeenCalledWith('https://cdn.example/m.m3u8', 'v-720');
|
||||
});
|
||||
|
||||
it('surfaces an error from listVariants instead of an empty menu', async () => {
|
||||
const listVariants = vi.fn(async () => ({ error: 'refused: not a manifest' }));
|
||||
const panel = new VideoPanel({ listVariants, addVariant: vi.fn() });
|
||||
panel.show('https://cdn.example/m.m3u8');
|
||||
|
||||
document.querySelector('#velox-video-panel button')!.dispatchEvent(new Event('click'));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
expect(document.querySelector('#velox-video-panel li')!.textContent).toContain('refused: not a manifest');
|
||||
});
|
||||
|
||||
it('show() replaces a previous panel rather than stacking them', () => {
|
||||
const port: VariantsPort = { listVariants: vi.fn(), addVariant: vi.fn() };
|
||||
const panel = new VideoPanel(port);
|
||||
panel.show('https://cdn.example/a.m3u8');
|
||||
panel.show('https://cdn.example/b.m3u8');
|
||||
|
||||
expect(document.querySelectorAll('#velox-video-panel')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user