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:
2026-09-11 12:26:48 +04:00
co-authored by Claude Sonnet 5
parent 55932a2e11
commit 03b6253b5a
11 changed files with 787 additions and 2 deletions
+87
View File
@@ -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 });
}
}
+55
View File
@@ -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) };
}
}
}
+54
View File
@@ -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();
+65
View File
@@ -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);
}
}
}
+102
View File
@@ -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;
}
}