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:
@@ -162,4 +162,34 @@ extension/
|
|||||||
|
|
||||||
`<all_urls>` is unavoidable for a download manager but is the main review-friction item:
|
`<all_urls>` is unavoidable for a download manager but is the main review-friction item:
|
||||||
document *why* in the AMO submission notes and in the source, and make the exclusion list
|
document *why* in the AMO submission notes and in the source, and make the exclusion list
|
||||||
prominent in Options.
|
prominent in Options. The submission-ready copy of this table lives in
|
||||||
|
`extension/docs/amo-permissions.md`, committed alongside the manifest it explains.
|
||||||
|
|
||||||
|
## 8. Popup and Options talk to the background page over a relay, not directly
|
||||||
|
|
||||||
|
`popup/` and `options/` are separate documents (a browser action popup and an
|
||||||
|
`options_ui` page) — they cannot import `background/index.ts`'s live `VeloxTransport`.
|
||||||
|
`background/bridge.ts` relays it over one `browser.runtime.connect` port per document:
|
||||||
|
`call`/`subscribe`/`getStatus`/`reconnect`/`pair`/`unpair`/`setOverride` requests in,
|
||||||
|
`result`/`event`/`status`/`pairError` responses out. `shared/panel-client.ts` is the
|
||||||
|
client side both surfaces use. The status payload carries `kind` (which transport
|
||||||
|
implementation is live) alongside the existing `TransportStatus`, because Options needs
|
||||||
|
it: `settings.set` and `rules.upsert` are privileged, uds-only methods (see
|
||||||
|
`shared/protocol/methods.ts`'s `METHODS` table), so the capture-policy edit form only
|
||||||
|
ever unlocks when the active transport is native messaging. Over WebSocket — the
|
||||||
|
default, guaranteed path per ADR 0003 — Options renders the daemon's policy read-only
|
||||||
|
via `capture.getRules` rather than pretending it can write settings the protocol
|
||||||
|
refuses over that transport. The one Options setting that genuinely belongs to the
|
||||||
|
extension (not the daemon) — the default category applied to extension-initiated
|
||||||
|
downloads — lives in `browser.storage.local` via `options/prefs.ts`, the same place the
|
||||||
|
pairing token and transport override already live.
|
||||||
|
|
||||||
|
Streaming-media detection (build step 7) also splits down this line: `capture/media.ts`
|
||||||
|
(background, webRequest-based) recognizes `.m3u8`/`.mpd` URLs and their content types
|
||||||
|
and tells the tab's content script over `runtime.sendMessage`
|
||||||
|
(`background/media-bridge.ts`); `content/media-observer.ts` independently watches the
|
||||||
|
page's own `<video>` elements for the same signal. Either one showing up opens
|
||||||
|
`content/video-panel.ts`'s "Download this video ▾" panel, which calls
|
||||||
|
`media.listVariants`/`media.addVariant` through the background page and greys out any
|
||||||
|
variant (or the whole manifest) flagged `drm`/`drmProtected` with "Protected content" —
|
||||||
|
the extension never parses the manifest itself.
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// Wires capture/media.ts's manifest detection to the content scripts, and answers the
|
||||||
|
// two calls the in-page video panel needs (media.listVariants, media.addVariant). A
|
||||||
|
// separate, simpler channel from bridge.ts's port because content scripts are per-tab
|
||||||
|
// and use one-shot runtime.sendMessage rather than a long-lived port.
|
||||||
|
|
||||||
|
import type { MediaAddVariantParams, MediaListVariantsParams } from '../shared/protocol/index.js';
|
||||||
|
import type { VeloxTransport } from './transport/index.js';
|
||||||
|
import type { DetectedManifest } from './capture/media.js';
|
||||||
|
|
||||||
|
export type ContentMessage =
|
||||||
|
| { type: 'velox-list-variants'; params: MediaListVariantsParams }
|
||||||
|
| { type: 'velox-add-variant'; params: MediaAddVariantParams };
|
||||||
|
|
||||||
|
export type ManifestDetectedMessage = { type: 'velox-manifest-detected'; url: string; headers: DetectedManifest['headers'] };
|
||||||
|
|
||||||
|
export interface TabsLike {
|
||||||
|
sendMessage(tabId: number, message: unknown): Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuntimeOnMessageLike {
|
||||||
|
addListener(cb: (msg: ContentMessage, sender: unknown, sendResponse: (r: unknown) => void) => boolean | void): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tells the tab's content script a manifest was seen, swallowing the "no content
|
||||||
|
* script listening" error a tab without one (or the daemon's own tab) throws. */
|
||||||
|
export function notifyTab(tabs: TabsLike, manifest: DetectedManifest): void {
|
||||||
|
const message: ManifestDetectedMessage = { type: 'velox-manifest-detected', url: manifest.url, headers: manifest.headers };
|
||||||
|
tabs.sendMessage(manifest.tabId, message).catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MediaBridge {
|
||||||
|
constructor(private readonly getTransport: () => VeloxTransport | undefined) {}
|
||||||
|
|
||||||
|
attach(onMessage: RuntimeOnMessageLike): void {
|
||||||
|
onMessage.addListener((msg, _sender, sendResponse) => {
|
||||||
|
if (msg.type !== 'velox-list-variants' && msg.type !== 'velox-add-variant') return undefined;
|
||||||
|
void this.handle(msg).then(sendResponse);
|
||||||
|
return true; // sendResponse is called asynchronously
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handle(msg: ContentMessage): Promise<{ ok: true; result: unknown } | { ok: false; error: string }> {
|
||||||
|
const t = this.getTransport();
|
||||||
|
if (!t) return { ok: false, error: 'transport not ready' };
|
||||||
|
try {
|
||||||
|
const result =
|
||||||
|
msg.type === 'velox-list-variants'
|
||||||
|
? await t.call('media.listVariants', msg.params)
|
||||||
|
: await t.call('media.addVariant', msg.params);
|
||||||
|
return { ok: true, result };
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// Content script entry: registered for <all_urls> in manifest.json. Watches for
|
||||||
|
// streaming media (docs/05 §3) and shows the "Download this video ▾" panel when either
|
||||||
|
// half of detection fires — the background webRequest sniffer (capture/media.ts, relayed
|
||||||
|
// via media-bridge.ts) or this page's own <video> elements (media-observer.ts).
|
||||||
|
//
|
||||||
|
// No download logic here either: this only asks the background page to call
|
||||||
|
// media.listVariants / media.addVariant and renders what comes back.
|
||||||
|
|
||||||
|
import type { MediaAddVariantResult, MediaListVariantsResult } from '../shared/protocol/index.js';
|
||||||
|
import { VideoSrcObserver } from './media-observer.js';
|
||||||
|
import { VideoPanel, type VariantsPort } from './video-panel.js';
|
||||||
|
|
||||||
|
interface ManifestDetectedMessage {
|
||||||
|
type: 'velox-manifest-detected';
|
||||||
|
url: string;
|
||||||
|
headers: Array<{ name: string; value: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const headersByManifest = new Map<string, Array<{ name: string; value: string }>>();
|
||||||
|
|
||||||
|
const port: VariantsPort = {
|
||||||
|
async listVariants(manifestUrl) {
|
||||||
|
const headers = headersByManifest.get(manifestUrl);
|
||||||
|
const response = (await browser.runtime.sendMessage({
|
||||||
|
type: 'velox-list-variants',
|
||||||
|
params: { manifestUrl, headers: headers?.length ? headers : null },
|
||||||
|
})) as { ok: true; result: MediaListVariantsResult } | { ok: false; error: string };
|
||||||
|
return response.ok ? response.result : { error: response.error };
|
||||||
|
},
|
||||||
|
async addVariant(manifestUrl, variantId) {
|
||||||
|
const response = (await browser.runtime.sendMessage({
|
||||||
|
type: 'velox-add-variant',
|
||||||
|
params: { manifestUrl, variantId },
|
||||||
|
})) as { ok: true; result: MediaAddVariantResult } | { ok: false; error: string };
|
||||||
|
return response.ok ? { taskId: response.result.taskId } : { error: response.error };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const panel = new VideoPanel(port);
|
||||||
|
|
||||||
|
function onManifestSeen(url: string): void {
|
||||||
|
panel.show(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
browser.runtime.onMessage.addListener((msg: unknown) => {
|
||||||
|
const m = msg as Partial<ManifestDetectedMessage>;
|
||||||
|
if (m.type !== 'velox-manifest-detected' || typeof m.url !== 'string') return undefined;
|
||||||
|
headersByManifest.set(m.url, m.headers ?? []);
|
||||||
|
onManifestSeen(m.url);
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
const observer = new VideoSrcObserver((url) => onManifestSeen(url));
|
||||||
|
observer.start();
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
// DOM half of streaming-media detection (docs/05 §3: "a content script observing
|
||||||
|
// MediaSource.addSourceBuffer and <video> src changes"). Pure DOM watching, no network:
|
||||||
|
// the background half (capture/media.ts) is what actually inspects responses. This
|
||||||
|
// module only recognizes "a <video> on this page points at something that smells like
|
||||||
|
// an HLS/DASH manifest" and reports the URL up to whoever is watching (content/index.ts).
|
||||||
|
|
||||||
|
const MANIFEST_EXTENSIONS = ['.m3u8', '.mpd'];
|
||||||
|
|
||||||
|
function looksLikeManifestUrl(url: string): boolean {
|
||||||
|
try {
|
||||||
|
const pathname = new URL(url, document.baseURI).pathname.toLowerCase();
|
||||||
|
return MANIFEST_EXTENSIONS.some((ext) => pathname.endsWith(ext));
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Watches every <video> on the page (present now or added/changed later) for a src
|
||||||
|
* that looks like a manifest URL, and reports each distinct URL once. */
|
||||||
|
export class VideoSrcObserver {
|
||||||
|
private readonly seen = new Set<string>();
|
||||||
|
private mutationObserver: MutationObserver | null = null;
|
||||||
|
|
||||||
|
constructor(private readonly onCandidate: (url: string) => void) {}
|
||||||
|
|
||||||
|
start(root: ParentNode = document): void {
|
||||||
|
this.scan(root);
|
||||||
|
this.mutationObserver = new MutationObserver((mutations) => {
|
||||||
|
for (const m of mutations) {
|
||||||
|
if (m.type === 'attributes' && m.target instanceof HTMLVideoElement) {
|
||||||
|
this.check(m.target);
|
||||||
|
}
|
||||||
|
for (const node of m.addedNodes) {
|
||||||
|
if (node instanceof HTMLVideoElement) this.check(node);
|
||||||
|
else if (node instanceof Element) this.scan(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.mutationObserver.observe(document.documentElement ?? document, {
|
||||||
|
subtree: true,
|
||||||
|
childList: true,
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['src'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
this.mutationObserver?.disconnect();
|
||||||
|
this.mutationObserver = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private scan(root: ParentNode): void {
|
||||||
|
for (const video of root.querySelectorAll('video')) this.check(video);
|
||||||
|
if (root instanceof HTMLVideoElement) this.check(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
private check(video: HTMLVideoElement): void {
|
||||||
|
const candidates = [video.currentSrc, video.src].filter(Boolean);
|
||||||
|
for (const url of candidates) {
|
||||||
|
if (this.seen.has(url) || !looksLikeManifestUrl(url)) continue;
|
||||||
|
this.seen.add(url);
|
||||||
|
this.onCandidate(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
// In-page "Download this video ▾" panel (docs/05 §3). Lists variants the daemon
|
||||||
|
// enumerated via media.listVariants — the extension never parses the manifest itself.
|
||||||
|
// DRM-protected variants (and a wholly DRM-protected manifest) are shown greyed out
|
||||||
|
// with "Protected content" rather than attempted.
|
||||||
|
|
||||||
|
import type { MediaVariant } from '../shared/protocol/index.js';
|
||||||
|
|
||||||
|
export interface VariantsPort {
|
||||||
|
listVariants(manifestUrl: string): Promise<{ variants: MediaVariant[]; drmProtected: boolean } | { error: string }>;
|
||||||
|
addVariant(manifestUrl: string, variantId: string): Promise<{ taskId: string } | { error: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function variantLabel(v: MediaVariant): string {
|
||||||
|
const bits = [v.resolution, v.codec, v.bitrateBps ? `${Math.round(v.bitrateBps / 1000)} kbps` : null].filter(Boolean);
|
||||||
|
return bits.length > 0 ? bits.join(' · ') : v.variantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PANEL_ID = 'velox-video-panel';
|
||||||
|
|
||||||
|
export class VideoPanel {
|
||||||
|
private root: HTMLElement | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly port: VariantsPort,
|
||||||
|
private readonly doc: Document = document,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Shows (or replaces) the floating panel for one detected manifest URL. */
|
||||||
|
show(manifestUrl: string): void {
|
||||||
|
this.remove();
|
||||||
|
|
||||||
|
const root = this.doc.createElement('div');
|
||||||
|
root.id = PANEL_ID;
|
||||||
|
Object.assign(root.style, {
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: '16px',
|
||||||
|
right: '16px',
|
||||||
|
zIndex: '2147483647',
|
||||||
|
font: '13px sans-serif',
|
||||||
|
} satisfies Partial<CSSStyleDeclaration>);
|
||||||
|
|
||||||
|
const button = this.doc.createElement('button');
|
||||||
|
button.textContent = 'Download this video ▾';
|
||||||
|
root.appendChild(button);
|
||||||
|
|
||||||
|
const menu = this.doc.createElement('ul');
|
||||||
|
menu.hidden = true;
|
||||||
|
Object.assign(menu.style, { listStyle: 'none', margin: '4px 0 0', padding: '4px' } satisfies Partial<CSSStyleDeclaration>);
|
||||||
|
root.appendChild(menu);
|
||||||
|
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
menu.hidden = !menu.hidden;
|
||||||
|
if (!menu.hidden) void this.populate(menu, manifestUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.doc.body.appendChild(root);
|
||||||
|
this.root = root;
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(): void {
|
||||||
|
this.root?.remove();
|
||||||
|
this.root = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async populate(menu: HTMLUListElement, manifestUrl: string): Promise<void> {
|
||||||
|
menu.replaceChildren(this.loadingItem());
|
||||||
|
const result = await this.port.listVariants(manifestUrl);
|
||||||
|
|
||||||
|
if ('error' in result) {
|
||||||
|
menu.replaceChildren(this.messageItem(`Could not read variants: ${result.error}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result.drmProtected || result.variants.length === 0) {
|
||||||
|
menu.replaceChildren(this.messageItem(result.drmProtected ? 'Protected content' : 'No downloadable variants found'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
menu.replaceChildren(
|
||||||
|
...result.variants.map((v) => {
|
||||||
|
const li = this.doc.createElement('li');
|
||||||
|
const item = this.doc.createElement('button');
|
||||||
|
item.textContent = v.drm ? `${variantLabel(v)} — Protected content` : variantLabel(v);
|
||||||
|
item.disabled = v.drm;
|
||||||
|
if (!v.drm) {
|
||||||
|
item.addEventListener('click', () => void this.port.addVariant(manifestUrl, v.variantId));
|
||||||
|
}
|
||||||
|
li.appendChild(item);
|
||||||
|
return li;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadingItem(): HTMLLIElement {
|
||||||
|
return this.messageItem('Loading…');
|
||||||
|
}
|
||||||
|
|
||||||
|
private messageItem(text: string): HTMLLIElement {
|
||||||
|
const li = this.doc.createElement('li');
|
||||||
|
li.textContent = text;
|
||||||
|
return li;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"lib": ["ES2022", "DOM"],
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
"module": "NodeNext",
|
"module": "NodeNext",
|
||||||
"moduleResolution": "NodeNext",
|
"moduleResolution": "NodeNext",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
|||||||
Reference in New Issue
Block a user