From fb13d3e81b3ecf64bded9481847bb8b31c6d13a3 Mon Sep 17 00:00:00 2001 From: Jvillegasd Date: Sat, 28 Feb 2026 04:04:33 -0500 Subject: [PATCH 01/13] feat: pre-warm FFmpeg when live stream recording starts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the FFmpeg pre-warm logic to cover HlsRecordingHandler. Previously, createOffscreenDocument + WARMUP_FFMPEG only fired for HLS/M3U8 format downloads in handleDownloadRequest. Recordings went through handleStartRecordingMessage which had no pre-warm, so FFmpeg only began loading when the user hit Stop — adding latency at the start of the merge phase. Now recordings pre-warm on the same condition (first active task), matching the behaviour of regular HLS/M3U8 downloads. Co-Authored-By: Claude Sonnet 4.6 --- src/service-worker.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/service-worker.ts b/src/service-worker.ts index 6e5b25a..4fbe282 100644 --- a/src/service-worker.ts +++ b/src/service-worker.ts @@ -975,7 +975,13 @@ async function handleStartRecordingMessage(payload: { }); activeDownloads.set(normalizedUrl, recordingPromise); - if (activeDownloads.size === 1) keepAlive(true); + if (activeDownloads.size === 1) { + keepAlive(true); + // Pre-warm FFmpeg — recordings always need FFmpeg for the merge phase + createOffscreenDocument() + .then(() => chrome.runtime.sendMessage({ type: MessageType.WARMUP_FFMPEG })) + .catch((err) => logger.error("FFmpeg pre-warm failed for recording:", err)); + } return { success: true }; } catch (error) { From 167915d009955018c6f66815c9d5c3c8faea1428 Mon Sep 17 00:00:00 2001 From: Jvillegasd Date: Sat, 28 Feb 2026 16:26:37 -0500 Subject: [PATCH 02/13] docs: expand CLAUDE.md with state persistence and progress update design Document why IndexedDB is used as the cross-context shared state store, the dual-channel progress update strategy (IDB + sendMessage), and the BasePlaylistHandler cachedState/throttle optimizations. Include an explicit warning against adding getDownload() calls in the onProgress hot path, referencing the UI freeze bug from commit 9f2a21e. Also correct VideoFormat from string union to string enum. Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index a5ace54..1a4d097 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,6 +50,26 @@ Download state is persisted in **IndexedDB** (not `chrome.storage`), in the `med Configuration (Google Drive settings, FFmpeg timeout, max concurrent) lives in `chrome.storage.local` via `ChromeStorage` (`core/storage/chrome-storage.ts`). +IndexedDB is used as the shared state store because the five execution contexts don't share memory. The service worker writes state via `storeDownload()` (`core/database/downloads.ts`), which is a single IDB `put` upsert keyed by `id`. The popup reads the full list via `getAllDownloads()` on open. The offscreen document reads raw chunks from the `chunks` store during FFmpeg processing. `chrome.storage` is only used for config because it has a 10MB quota and can't store `ArrayBuffer`. + +Progress updates use two complementary channels: +- **IndexedDB** — durable source of truth; survives popup close/reopen and service worker restarts. Popup reads this on mount. +- **`chrome.runtime.sendMessage` (`DOWNLOAD_PROGRESS`)** — low-latency live updates broadcast by the service worker while the popup is open. Fire-and-forget; missed if popup is closed. + +### Progress Update Design (BasePlaylistHandler) + +`updateProgress()` (`core/downloader/base-playlist-handler.ts`) is the hot-path progress method called after every segment download. It uses two optimizations to avoid overwhelming the service worker event loop: + +1. **`cachedState`** — a class field holding the `DownloadState` object read from IDB on the first call. Every subsequent call mutates this same in-memory object directly (updating `downloaded`, `total`, `percentage`, `speed`, etc.) — zero DB reads. The cache is invalidated (`cachedState = null`) only on `resetDownloadState()` (new download) and `updateStage()` (stage transition), which forces a fresh IDB read to pick up any external changes. + +2. **`DB_SYNC_INTERVAL_MS = 500ms` throttle** — `storeDownload()` is only called if at least 500ms have elapsed since the last write. The popup still receives every update via `notifyProgress()` (which fires unconditionally), but IDB writes are capped at ~2/second regardless of segment download frequency. + +`updateStage()` bypasses both optimizations — it always does a full IDB read + write because stage transitions are rare and need to reflect the true persisted state. + +`HlsRecordingHandler.updateRecordingProgress()` also always does a full IDB read + write, but is naturally rate-limited to once per poll cycle (every 1–10 seconds), so throttling is unnecessary. + +**Do not add `getDownload()` calls inside `updateProgress()` or the `onProgress` callback** — that was the root cause of the UI freezing bug fixed in commit `9f2a21e`. With 3 concurrent downloads each firing per segment, even one extra IDB read per callback produces dozens of blocking reads per second that queue up behind user interaction messages in the service worker event loop. + ### Message Protocol All inter-component communication uses the `MessageType` enum in `src/shared/messages.ts`. When adding new message types, add them to this enum and handle them in the service worker's `onMessage` listener switch statement. @@ -68,7 +88,7 @@ FFmpeg WASM files are served from `public/ffmpeg/` and copied to `dist/ffmpeg/` ### Format Detection -`VideoFormat` is `"direct" | "hls" | "m3u8" | "unknown"`. The distinction between `hls` (master playlist with quality variants) and `m3u8` (direct media playlist with segments) is significant — they use different handlers and FFmpeg processing paths. +`VideoFormat` is a string enum (`VideoFormat.DIRECT | HLS | M3U8 | UNKNOWN`). The distinction between `HLS` (master playlist with quality variants) and `M3U8` (direct media playlist with segments) is significant — they use different handlers and FFmpeg processing paths. Use enum values everywhere; the underlying strings are lowercase for IndexedDB backward compatibility. ### Live Stream Recording From bf95f88df18e3018acb3b587e78dd9dd8fdb9d42 Mon Sep 17 00:00:00 2001 From: Jvillegasd Date: Sun, 1 Mar 2026 19:47:50 -0500 Subject: [PATCH 03/13] fix: npm lib fix --- package-lock.json | 412 ++++++++++++++++++++-------------------------- 1 file changed, 179 insertions(+), 233 deletions(-) diff --git a/package-lock.json b/package-lock.json index 988221d..fc16976 100644 --- a/package-lock.json +++ b/package-lock.json @@ -511,66 +511,6 @@ "node": ">=18.x" } }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/source-map": { - "version": "0.3.11", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", - "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, "node_modules/@reduxjs/toolkit": { "version": "2.10.1", "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.10.1.tgz", @@ -598,9 +538,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.5.tgz", - "integrity": "sha512-8c1vW4ocv3UOMp9K+gToY5zL2XiiVw3k7f1ksf4yO1FlDFQ1C2u72iACFnSOceJFsWskc2WZNqeRhFRPzv+wtQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", "cpu": [ "arm" ], @@ -612,9 +552,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.5.tgz", - "integrity": "sha512-mQGfsIEFcu21mvqkEKKu2dYmtuSZOBMmAl5CFlPGLY94Vlcm+zWApK7F/eocsNzp8tKmbeBP8yXyAbx0XHsFNA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", "cpu": [ "arm64" ], @@ -626,9 +566,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.5.tgz", - "integrity": "sha512-takF3CR71mCAGA+v794QUZ0b6ZSrgJkArC+gUiG6LB6TQty9T0Mqh3m2ImRBOxS2IeYBo4lKWIieSvnEk2OQWA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", "cpu": [ "arm64" ], @@ -640,9 +580,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.5.tgz", - "integrity": "sha512-W901Pla8Ya95WpxDn//VF9K9u2JbocwV/v75TE0YIHNTbhqUTv9w4VuQ9MaWlNOkkEfFwkdNhXgcLqPSmHy0fA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", "cpu": [ "x64" ], @@ -654,9 +594,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.5.tgz", - "integrity": "sha512-QofO7i7JycsYOWxe0GFqhLmF6l1TqBswJMvICnRUjqCx8b47MTo46W8AoeQwiokAx3zVryVnxtBMcGcnX12LvA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", "cpu": [ "arm64" ], @@ -668,9 +608,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.5.tgz", - "integrity": "sha512-jr21b/99ew8ujZubPo9skbrItHEIE50WdV86cdSoRkKtmWa+DDr6fu2c/xyRT0F/WazZpam6kk7IHBerSL7LDQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", "cpu": [ "x64" ], @@ -682,13 +622,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.5.tgz", - "integrity": "sha512-PsNAbcyv9CcecAUagQefwX8fQn9LQ4nZkpDboBOttmyffnInRy8R8dSg6hxxl2Re5QhHBf6FYIDhIj5v982ATQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", "cpu": [ "arm" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -696,13 +639,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.5.tgz", - "integrity": "sha512-Fw4tysRutyQc/wwkmcyoqFtJhh0u31K+Q6jYjeicsGJJ7bbEq8LwPWV/w0cnzOqR2m694/Af6hpFayLJZkG2VQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", "cpu": [ "arm" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -710,13 +656,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.5.tgz", - "integrity": "sha512-a+3wVnAYdQClOTlyapKmyI6BLPAFYs0JM8HRpgYZQO02rMR09ZcV9LbQB+NL6sljzG38869YqThrRnfPMCDtZg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -724,13 +673,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.5.tgz", - "integrity": "sha512-AvttBOMwO9Pcuuf7m9PkC1PUIKsfaAJ4AYhy944qeTJgQOqJYJ9oVl2nYgY7Rk0mkbsuOpCAYSs6wLYB2Xiw0Q==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -738,13 +690,33 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.5.tgz", - "integrity": "sha512-DkDk8pmXQV2wVrF6oq5tONK6UHLz/XcEVow4JTTerdeV1uqPeHxwcg7aFsfnSm9L+OO8WJsWotKM2JJPMWrQtA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", "cpu": [ "loong64" ], "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -752,13 +724,33 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.5.tgz", - "integrity": "sha512-W/b9ZN/U9+hPQVvlGwjzi+Wy4xdoH2I8EjaCkMvzpI7wJUs8sWJ03Rq96jRnHkSrcHTpQe8h5Tg3ZzUPGauvAw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -766,13 +758,16 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.5.tgz", - "integrity": "sha512-sjQLr9BW7R/ZiXnQiWPkErNfLMkkWIoCz7YMn27HldKsADEKa5WYdobaa1hmN6slu9oWQbB6/jFpJ+P2IkVrmw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -780,13 +775,16 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.5.tgz", - "integrity": "sha512-hq3jU/kGyjXWTvAh2awn8oHroCbrPm8JqM7RUpKjalIRWWXE01CQOf/tUNWNHjmbMHg/hmNCwc/Pz3k1T/j/Lg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -794,13 +792,16 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.5.tgz", - "integrity": "sha512-gn8kHOrku8D4NGHMK1Y7NA7INQTRdVOntt1OCYypZPRt6skGbddska44K8iocdpxHTMMNui5oH4elPH4QOLrFQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -808,13 +809,16 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.5.tgz", - "integrity": "sha512-hXGLYpdhiNElzN770+H2nlx+jRog8TyynpTVzdlc6bndktjKWyZyiCsuDAlpd+j+W+WNqfcyAWz9HxxIGfZm1Q==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -822,23 +826,40 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.5.tgz", - "integrity": "sha512-arCGIcuNKjBoKAXD+y7XomR9gY6Mw7HnFBv5Rw7wQRvwYLR7gBAgV7Mb2QTyjXfTveBNFAtPt46/36vV9STLNg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ "linux" ] }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.5.tgz", - "integrity": "sha512-QoFqB6+/9Rly/RiPjaomPLmR/13cgkIGfA40LHly9zcH1S0bN2HVFYk3a1eAyHQyjs3ZJYlXvIGtcCs5tko9Cw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", "cpu": [ "arm64" ], @@ -850,9 +871,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.5.tgz", - "integrity": "sha512-w0cDWVR6MlTstla1cIfOGyl8+qb93FlAVutcor14Gf5Md5ap5ySfQ7R9S/NjNaMLSFdUnKGEasmVnu3lCMqB7w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", "cpu": [ "arm64" ], @@ -864,9 +885,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.5.tgz", - "integrity": "sha512-Aufdpzp7DpOTULJCuvzqcItSGDH73pF3ko/f+ckJhxQyHtp67rHw3HMNxoIdDMUITJESNE6a8uh4Lo4SLouOUg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", "cpu": [ "ia32" ], @@ -878,9 +899,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.5.tgz", - "integrity": "sha512-UGBUGPFp1vkj6p8wCRraqNhqwX/4kNQPS57BCFc8wYh0g94iVIW33wJtQAx3G7vrjjNtRaxiMUylM0ktp/TRSQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", "cpu": [ "x64" ], @@ -892,9 +913,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.5.tgz", - "integrity": "sha512-TAcgQh2sSkykPRWLrdyy2AiceMckNf5loITqXxFI5VuQjS5tSuw3WlwdN8qv8vzjLAUTvYaH/mVjSFpbkFbpTg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", "cpu": [ "x64" ], @@ -1001,21 +1022,6 @@ "npm": ">=5" } }, - "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -1056,15 +1062,6 @@ "node": ">=8" } }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -1262,9 +1259,10 @@ } }, "node_modules/min-document": { - "version": "2.19.0", - "resolved": "https://registry.npmjs.org/min-document/-/min-document-2.19.0.tgz", - "integrity": "sha512-9Wy1B3m3f66bPPmU5hdA4DR4PB2OfDU/+GS3yAB7IQozE3tqXaVv2zOjgla7MEGSRv95+ILmOuvhLkOK6wJtCQ==", + "version": "2.19.2", + "resolved": "https://registry.npmjs.org/min-document/-/min-document-2.19.2.tgz", + "integrity": "sha512-8S5I8db/uZN8r9HSLFVWPdJCvYOejMcEC82VIzNUc6Zkklf/d1gg2psfE79/vyhWOj4+J8MtwmoOz3TmvaGu5A==", + "license": "MIT", "dependencies": { "dom-walk": "^0.1.0" } @@ -1414,9 +1412,9 @@ "license": "MIT" }, "node_modules/rollup": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.5.tgz", - "integrity": "sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "dev": true, "license": "MIT", "dependencies": { @@ -1430,28 +1428,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.52.5", - "@rollup/rollup-android-arm64": "4.52.5", - "@rollup/rollup-darwin-arm64": "4.52.5", - "@rollup/rollup-darwin-x64": "4.52.5", - "@rollup/rollup-freebsd-arm64": "4.52.5", - "@rollup/rollup-freebsd-x64": "4.52.5", - "@rollup/rollup-linux-arm-gnueabihf": "4.52.5", - "@rollup/rollup-linux-arm-musleabihf": "4.52.5", - "@rollup/rollup-linux-arm64-gnu": "4.52.5", - "@rollup/rollup-linux-arm64-musl": "4.52.5", - "@rollup/rollup-linux-loong64-gnu": "4.52.5", - "@rollup/rollup-linux-ppc64-gnu": "4.52.5", - "@rollup/rollup-linux-riscv64-gnu": "4.52.5", - "@rollup/rollup-linux-riscv64-musl": "4.52.5", - "@rollup/rollup-linux-s390x-gnu": "4.52.5", - "@rollup/rollup-linux-x64-gnu": "4.52.5", - "@rollup/rollup-linux-x64-musl": "4.52.5", - "@rollup/rollup-openharmony-arm64": "4.52.5", - "@rollup/rollup-win32-arm64-msvc": "4.52.5", - "@rollup/rollup-win32-ia32-msvc": "4.52.5", - "@rollup/rollup-win32-x64-gnu": "4.52.5", - "@rollup/rollup-win32-x64-msvc": "4.52.5", + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" } }, @@ -1464,18 +1465,6 @@ "tslib": "^2.1.0" } }, - "node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "license": "BSD-3-Clause", - "optional": true, - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1486,49 +1475,6 @@ "node": ">=0.10.0" } }, - "node_modules/source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, - "node_modules/terser": { - "version": "5.44.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.44.0.tgz", - "integrity": "sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==", - "dev": true, - "license": "BSD-2-Clause", - "optional": true, - "peer": true, - "dependencies": { - "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.15.0", - "commander": "^2.20.0", - "source-map-support": "~0.5.20" - }, - "bin": { - "terser": "bin/terser" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/terser/node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", From 66f91f7b12227a8ba257c0993cfd83f71cbb26bb Mon Sep 17 00:00:00 2001 From: Jvillegasd Date: Sun, 1 Mar 2026 23:48:48 -0500 Subject: [PATCH 04/13] feat: add MPEG-DASH support (VOD download, live recording, detection) - Add VideoFormat.DASH and OFFSCREEN_PROCESS_DASH message types - Add mpd-parser utility wrapping the mpd-parser npm package - Add DashDetectionHandler for .mpd URL detection with DRM/live detection - Add DashDownloadHandler for static DASH VOD downloads (video+audio streams) - Add DashRecordingHandler for live DASH stream recording - Extract BaseRecordingHandler from HlsRecordingHandler (template method pattern) - Refactor HlsRecordingHandler to extend BaseRecordingHandler - Add processDashChunks() to offscreen FFmpeg worker (no bsf filter, .mp4 intermediates) - Wire DASH into DetectionManager, DownloadManager, and service worker Co-Authored-By: Claude Sonnet 4.6 --- package-lock.json | 25 ++ package.json | 1 + .../detection/dash/dash-detection-handler.ts | 118 ++++++++ src/core/detection/detection-manager.ts | 11 + src/core/downloader/base-recording-handler.ts | 258 ++++++++++++++++++ .../downloader/dash/dash-download-handler.ts | 189 +++++++++++++ .../downloader/dash/dash-recording-handler.ts | 80 ++++++ src/core/downloader/download-manager.ts | 19 ++ .../downloader/hls/hls-recording-handler.ts | 247 +++-------------- src/core/types.ts | 1 + src/core/utils/mpd-parser.ts | 166 +++++++++++ src/core/utils/url-utils.ts | 5 + src/offscreen/offscreen.ts | 155 +++++++++++ src/service-worker.ts | 51 ++-- src/shared/messages.ts | 2 + src/types/mpd-parser.d.ts | 11 + 16 files changed, 1105 insertions(+), 234 deletions(-) create mode 100644 src/core/detection/dash/dash-detection-handler.ts create mode 100644 src/core/downloader/base-recording-handler.ts create mode 100644 src/core/downloader/dash/dash-download-handler.ts create mode 100644 src/core/downloader/dash/dash-recording-handler.ts create mode 100644 src/core/utils/mpd-parser.ts create mode 100644 src/types/mpd-parser.d.ts diff --git a/package-lock.json b/package-lock.json index fc16976..4cdf533 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@reduxjs/toolkit": "^2.10.1", "idb": "^8.0.3", "m3u8-parser": "^7.2.0", + "mpd-parser": "^1.3.1", "redux": "^5.0.1", "redux-observable": "^3.0.0-rc.2", "rxjs": "^7.8.2", @@ -1022,6 +1023,15 @@ "npm": ">=5" } }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.11", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz", + "integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -1267,6 +1277,21 @@ "dom-walk": "^0.1.0" } }, + "node_modules/mpd-parser": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/mpd-parser/-/mpd-parser-1.3.1.tgz", + "integrity": "sha512-1FuyEWI5k2HcmhS1HkKnUAQV7yFPfXPht2DnRRGtoiiAAW+ESTbtEXIDpRkwdU+XyrQuwrIym7UkoPKsZ0SyFw==", + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime": "^7.12.5", + "@videojs/vhs-utils": "^4.0.0", + "@xmldom/xmldom": "^0.8.3", + "global": "^4.4.0" + }, + "bin": { + "mpd-to-m3u8-json": "bin/parse.js" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", diff --git a/package.json b/package.json index a4f6155..0d824ce 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@reduxjs/toolkit": "^2.10.1", "idb": "^8.0.3", "m3u8-parser": "^7.2.0", + "mpd-parser": "^1.3.1", "redux": "^5.0.1", "redux-observable": "^3.0.0-rc.2", "rxjs": "^7.8.2", diff --git a/src/core/detection/dash/dash-detection-handler.ts b/src/core/detection/dash/dash-detection-handler.ts new file mode 100644 index 0000000..155cd6c --- /dev/null +++ b/src/core/detection/dash/dash-detection-handler.ts @@ -0,0 +1,118 @@ +/** + * DASH detection handler + * + * Detects MPEG-DASH streams from network requests by recognizing .mpd URLs, + * fetching the manifest, and extracting metadata including DRM status and + * live/VOD distinction. + * + * Mirrors HlsDetectionHandler in structure and deduplication strategy. + */ + +import { VideoMetadata, VideoFormat } from "../../types"; +import { fetchText } from "../../utils/fetch-utils"; +import { normalizeUrl } from "../../utils/url-utils"; +import { logger } from "../../utils/logger"; +import { extractThumbnail } from "../../utils/thumbnail-utils"; +import { isLive, hasDrm } from "../../utils/mpd-parser"; + +export interface DashDetectionHandlerOptions { + onVideoDetected?: (video: VideoMetadata) => void; +} + +const MAX_SEEN_PATH_KEYS = 500; + +/** + * DASH detection handler — detects .mpd manifest URLs + */ +export class DashDetectionHandler { + private onVideoDetected?: (video: VideoMetadata) => void; + private seenPathKeys: Set = new Set(); + + constructor(options: DashDetectionHandlerOptions = {}) { + this.onVideoDetected = options.onVideoDetected; + } + + destroy(): void { + this.seenPathKeys.clear(); + } + + handleNetworkRequest(url: string): void { + if (this.isDashUrl(url)) { + this.detect(url); + } + } + + async detect(url: string): Promise { + if (!this.isDashUrl(url)) return null; + + // Deduplicate by origin+pathname — ignores auth tokens in query params + const pathKey = this.getPathKey(url); + if (this.seenPathKeys.has(pathKey)) return null; + if (this.seenPathKeys.size >= MAX_SEEN_PATH_KEYS) { + const first = this.seenPathKeys.values().next().value; + if (first) this.seenPathKeys.delete(first); + } + this.seenPathKeys.add(pathKey); + + try { + const mpdText = await fetchText(url, 1); + const metadata = this.extractMetadata(url, mpdText); + if (metadata && this.onVideoDetected) { + this.onVideoDetected(metadata); + } + return metadata; + } catch (error) { + logger.error("[Media Bridge] Failed to fetch DASH manifest:", error); + return null; + } + } + + private isDashUrl(url: string): boolean { + try { + const pathname = new URL(url).pathname.toLowerCase(); + return pathname.endsWith(".mpd"); + } catch { + return url.toLowerCase().includes(".mpd"); + } + } + + private getPathKey(url: string): string { + try { + const u = new URL(url); + return u.origin + u.pathname; + } catch { + return url; + } + } + + private extractMetadata(url: string, mpdText: string): VideoMetadata { + const drm = hasDrm(mpdText); + const live = isLive(mpdText); + + const metadata: VideoMetadata = { + url, + format: VideoFormat.DASH, + pageUrl: window.location.href, + title: document.title, + fileExtension: "mpd", + hasDrm: drm, + unsupported: drm, // DRM-protected DASH cannot be downloaded + isLive: live, + }; + + const thumbnail = extractThumbnail(); + if (thumbnail) metadata.thumbnail = thumbnail; + + return metadata; + } + + /** Check whether a Content-Type header indicates a DASH manifest */ + isDashContentType(contentType: string): boolean { + return contentType.toLowerCase().includes("application/dash+xml"); + } + + /** Normalize URL for deduplication — removes hash fragment */ + normalizeUrl(url: string): string { + return normalizeUrl(url); + } +} diff --git a/src/core/detection/detection-manager.ts b/src/core/detection/detection-manager.ts index ab79f68..0baffe1 100644 --- a/src/core/detection/detection-manager.ts +++ b/src/core/detection/detection-manager.ts @@ -25,6 +25,7 @@ import { logger } from "../utils/logger"; import { detectFormatFromUrl } from "../utils/url-utils"; import { DirectDetectionHandler } from "./direct/direct-detection-handler"; import { HlsDetectionHandler } from "./hls/hls-detection-handler"; +import { DashDetectionHandler } from "./dash/dash-detection-handler"; /** Configuration options for DetectionManager */ export interface DetectionManagerOptions { @@ -43,6 +44,7 @@ export class DetectionManager { private onVideoRemoved?: (url: string) => void; public readonly directHandler: DirectDetectionHandler; private hlsHandler: HlsDetectionHandler; + private dashHandler: DashDetectionHandler; /** * Create a new DetectionManager instance @@ -58,6 +60,9 @@ export class DetectionManager { onVideoDetected: (video) => this.handleVideoDetected(video), onVideoRemoved: (url) => this.handleVideoRemoved(url), }); + this.dashHandler = new DashDetectionHandler({ + onVideoDetected: (video) => this.handleVideoDetected(video), + }); } /** @@ -78,6 +83,11 @@ export class DetectionManager { this.hlsHandler.handleNetworkRequest(url); break; + case VideoFormat.DASH: + logger.debug("[Media Bridge] DASH video detected", { url }); + this.dashHandler.handleNetworkRequest(url); + break; + default: // Reject unknown formats - don't process them logger.debug("[Media Bridge] Unknown format detected", { url }); @@ -103,6 +113,7 @@ export class DetectionManager { destroy(): void { this.directHandler.destroy(); this.hlsHandler.destroy(); + this.dashHandler.destroy(); } /** diff --git a/src/core/downloader/base-recording-handler.ts b/src/core/downloader/base-recording-handler.ts new file mode 100644 index 0000000..9d86fb6 --- /dev/null +++ b/src/core/downloader/base-recording-handler.ts @@ -0,0 +1,258 @@ +/** + * Abstract base class for live stream recording handlers. + * + * Extracts the shared polling/recording orchestration pattern used by both + * HlsRecordingHandler and DashRecordingHandler. Protocol-specific details + * (manifest resolution, segment discovery, FFmpeg options) are delegated to + * abstract methods implemented by each subclass. + * + * Template method pattern: + * record() → resolveMediaUrl() → collectSegments() → fetchNewSegments() → buildFfmpegOptions() + */ + +import { DownloadStage, Fragment } from "../types"; +import { getDownload, storeDownload } from "../database/downloads"; +import { logger } from "../utils/logger"; +import { MessageType } from "../../shared/messages"; +import { saveBlobUrlToFile } from "../utils/blob-utils"; +import { processWithFFmpeg } from "../utils/ffmpeg-bridge"; +import { BasePlaylistHandler } from "./base-playlist-handler"; +import { runConcurrentWorkers } from "./concurrent-workers"; +import { SAVING_STAGE_PERCENTAGE } from "../../shared/constants"; + +export abstract class BaseRecordingHandler extends BasePlaylistHandler { + protected segmentIndex: number = 0; + + protected override resetDownloadState( + stateId: string, + abortSignal?: AbortSignal, + ): void { + super.resetDownloadState(stateId, abortSignal); + this.segmentIndex = 0; + } + + /** + * Start recording a live stream. + * Returns after user abort or natural stream end, then merges and saves. + */ + async record( + url: string, + filename: string, + stateId: string, + abortSignal: AbortSignal, + pageUrl?: string, + ): Promise<{ filePath: string; fileExtension?: string }> { + this.resetDownloadState(stateId, abortSignal); + + const headerRuleIds = await this.tryAddHeaderRules(stateId, url, pageUrl); + + try { + const mediaUrl = await this.resolveMediaUrl(url, abortSignal); + + await this.collectSegments(mediaUrl, stateId, abortSignal); + + if (this.segmentIndex === 0) { + throw new Error("No segments were recorded"); + } + + await this.updateStage( + stateId, + DownloadStage.MERGING, + "Merging recorded segments...", + ); + + const baseFileName = this.sanitizeBaseFilename(filename, "recording"); + const ffmpegOpts = this.buildFfmpegOptions(); + + const { blobUrl, warning } = await processWithFFmpeg({ + requestType: ffmpegOpts.requestType, + responseType: ffmpegOpts.responseType, + downloadId: this.downloadId, + payload: ffmpegOpts.payload, + filename: baseFileName, + timeout: this.ffmpegTimeout, + onProgress: this.createMergingProgressCallback(stateId), + }); + + await this.updateStage( + stateId, + DownloadStage.SAVING, + "Saving file...", + SAVING_STAGE_PERCENTAGE, + ); + + const filePath = await saveBlobUrlToFile( + blobUrl, + `${baseFileName}.mp4`, + stateId, + ); + + const completionMessage = warning + ? `Recording saved — ${warning}` + : "Recording saved"; + await this.markCompleted(stateId, filePath, completionMessage); + + return { filePath, fileExtension: "mp4" }; + } catch (error) { + logger.error("Recording failed:", error); + throw error; + } finally { + await this.tryRemoveHeaderRules(headerRuleIds); + await this.cleanupChunks(this.downloadId || stateId); + } + } + + // --------------------------------------------------------------------------- + // Abstract methods — implemented by protocol-specific subclasses + // --------------------------------------------------------------------------- + + /** + * Resolve the URL to poll for manifest/playlist updates. + * HLS: may follow a master playlist → media playlist redirect. + * DASH: returns the MPD URL as-is. + */ + protected abstract resolveMediaUrl( + url: string, + abortSignal: AbortSignal, + ): Promise; + + /** + * Fetch the latest manifest and return new segments not already in seenUris. + * @param url - the polling URL (media playlist or MPD) + * @param abortSignal - cancelled when user stops recording + * @param seenUris - set of segment URIs already downloaded (read-only here) + * @returns new fragments, poll interval, and whether stream has ended + */ + protected abstract fetchNewSegments( + url: string, + abortSignal: AbortSignal, + seenUris: Set, + ): Promise<{ fragments: Fragment[]; pollIntervalMs: number; ended: boolean }>; + + /** + * Return the FFmpeg request/response/payload options for merging. + */ + protected abstract buildFfmpegOptions(): { + requestType: MessageType; + responseType: MessageType; + payload: Record; + }; + + // --------------------------------------------------------------------------- + // Shared helpers + // --------------------------------------------------------------------------- + + /** + * Polling loop — fetches new segments until aborted or stream ends. + */ + protected async collectSegments( + mediaUrl: string, + stateId: string, + abortSignal: AbortSignal, + ): Promise { + const seenUris = new Set(); + let pollIntervalMs = 3000; + + logger.info( + `[REC] Starting polling loop, abortSignal.aborted=${abortSignal.aborted}`, + ); + let pollCount = 0; + + while (!abortSignal.aborted) { + pollCount++; + + let result: Awaited>; + try { + result = await this.fetchNewSegments(mediaUrl, abortSignal, seenUris); + } catch (err) { + if (abortSignal.aborted) break; + logger.warn( + "Failed to fetch manifest during recording, retrying...", + err, + ); + await this.sleep(pollIntervalMs, abortSignal); + continue; + } + + const { fragments: newFragments, pollIntervalMs: interval, ended } = result; + pollIntervalMs = interval; + + if (abortSignal.aborted) break; + + logger.info( + `[REC] Poll #${pollCount}: new=${newFragments.length}, seen=${seenUris.size}, ended=${ended}`, + ); + + if (newFragments.length > 0) { + const indexedFragments: Fragment[] = newFragments.map((f) => ({ + ...f, + index: this.segmentIndex++, + })); + + indexedFragments.forEach((f) => seenUris.add(f.uri)); + + await this.downloadFragmentsConcurrently(indexedFragments, abortSignal); + await this.updateRecordingProgress(stateId); + } + + if (ended) { + logger.info("[REC] Stream ended naturally"); + break; + } + + await this.sleep(pollIntervalMs, abortSignal); + } + + logger.info( + `[REC] Polling loop exited after ${pollCount} polls, segments=${this.segmentIndex}, aborted=${abortSignal.aborted}`, + ); + } + + protected async downloadFragmentsConcurrently( + fragments: Fragment[], + abortSignal: AbortSignal, + ): Promise { + await runConcurrentWorkers({ + items: fragments, + maxConcurrent: this.maxConcurrent, + shouldStop: () => abortSignal.aborted, + processItem: async (fragment) => { + const size = await this.downloadFragment(fragment, this.downloadId); + this.bytesDownloaded += size; + }, + onError: (fragment, err) => { + logger.warn(`Failed to download segment ${fragment.index}:`, err); + }, + }); + } + + protected async updateRecordingProgress(stateId: string): Promise { + const state = await getDownload(stateId); + if (!state) return; + state.progress.stage = DownloadStage.RECORDING; + state.progress.segmentsCollected = this.segmentIndex; + state.progress.downloaded = this.bytesDownloaded; + state.progress.message = `${this.segmentIndex} segments collected`; + state.updatedAt = Date.now(); + await storeDownload(state); + this.notifyProgress(state); + } + + protected sleep(ms: number, abortSignal: AbortSignal): Promise { + return new Promise((resolve) => { + if (abortSignal.aborted) { + resolve(); + return; + } + const id = setTimeout(resolve, ms); + abortSignal.addEventListener( + "abort", + () => { + clearTimeout(id); + resolve(); + }, + { once: true }, + ); + }); + } +} diff --git a/src/core/downloader/dash/dash-download-handler.ts b/src/core/downloader/dash/dash-download-handler.ts new file mode 100644 index 0000000..bed26e1 --- /dev/null +++ b/src/core/downloader/dash/dash-download-handler.ts @@ -0,0 +1,189 @@ +/** + * DASH VOD download handler + * + * Downloads static MPEG-DASH streams by parsing the MPD manifest, downloading + * video and audio segments (with init segments), and merging them into an MP4 + * via the offscreen FFmpeg worker. + * + * Extends BasePlaylistHandler for shared download/progress/cleanup logic. + * Key differences from HlsDownloadHandler: + * - Uses mpd-parser instead of m3u8-parser + * - Handles ISOBMF (.m4s) segments — no MPEG-TS bitstream filter needed + * - Uses OFFSCREEN_PROCESS_DASH message type + */ + +import { CancellationError } from "../../utils/errors"; +import { throwIfAborted } from "../../utils/cancellation"; +import { DownloadStage } from "../../types"; +import { logger } from "../../utils/logger"; +import { getChunkCount } from "../../database/chunks"; +import { MessageType } from "../../../shared/messages"; +import { processWithFFmpeg } from "../../utils/ffmpeg-bridge"; +import { saveBlobUrlToFile } from "../../utils/blob-utils"; +import { BasePlaylistHandler } from "../base-playlist-handler"; +import { SAVING_STAGE_PERCENTAGE } from "../../../shared/constants"; +import { DownloadError } from "../../utils/errors"; +import { + parseManifest, + parseLevelsPlaylist, + hasDrm, + getAudioPlaylist, + MpdPlaylist, +} from "../../utils/mpd-parser"; + +export class DashDownloadHandler extends BasePlaylistHandler { + private videoLength: number = 0; + private audioLength: number = 0; + + protected override resetDownloadState( + stateId: string, + abortSignal?: AbortSignal, + ): void { + super.resetDownloadState(stateId, abortSignal); + this.videoLength = 0; + this.audioLength = 0; + } + + /** + * Download a static DASH stream from the given MPD URL. + */ + async download( + mpdUrl: string, + filename: string, + stateId: string, + abortSignal?: AbortSignal, + pageUrl?: string, + ): Promise<{ filePath: string; fileExtension?: string }> { + this.resetDownloadState(stateId, abortSignal); + + const headerRuleIds = await this.tryAddHeaderRules(stateId, mpdUrl, pageUrl); + + try { + logger.info(`Starting DASH download from ${mpdUrl}`); + + await this.updateProgress(stateId, 0, 0, "Parsing MPD manifest..."); + + const mpdText = await this.fetchTextCancellable(mpdUrl); + + if (hasDrm(mpdText)) { + throw new DownloadError("Cannot download DRM-protected DASH content"); + } + + const manifest = parseManifest(mpdText, mpdUrl); + + // Select highest bandwidth video playlist + const videoPlaylists = [...(manifest.playlists || [])]; + if (videoPlaylists.length === 0) { + throw new Error("No video streams found in MPD manifest"); + } + videoPlaylists.sort( + (a, b) => (b.attributes?.BANDWIDTH || 0) - (a.attributes?.BANDWIDTH || 0), + ); + const videoPlaylist = videoPlaylists[0]!; + + const audioPlaylist: MpdPlaylist | null = getAudioPlaylist(manifest); + + throwIfAborted(this.abortSignal); + + // Download video stream (init at index 0, media at 1..N) + const videoFragments = parseLevelsPlaylist(videoPlaylist, 0); + if (videoFragments.length === 0) { + throw new Error("No video segments found in MPD manifest"); + } + logger.info(`Found ${videoFragments.length} DASH video fragments`); + this.videoLength = videoFragments.length; + + await this.downloadAllFragments(videoFragments, this.downloadId, stateId); + + throwIfAborted(this.abortSignal); + + // Download audio stream if present (starts at videoLength) + this.audioLength = 0; + if (audioPlaylist) { + const audioFragments = parseLevelsPlaylist(audioPlaylist, this.videoLength); + logger.info(`Found ${audioFragments.length} DASH audio fragments`); + this.audioLength = audioFragments.length; + + await this.downloadAllFragments(audioFragments, this.downloadId, stateId); + + throwIfAborted(this.abortSignal); + } + + await this.updateStage( + stateId, + DownloadStage.MERGING, + "Merging DASH streams...", + ); + + const baseFileName = this.sanitizeBaseFilename(filename); + + const { blobUrl, warning } = await processWithFFmpeg({ + requestType: MessageType.OFFSCREEN_PROCESS_DASH, + responseType: MessageType.OFFSCREEN_PROCESS_DASH_RESPONSE, + downloadId: this.downloadId, + payload: { + videoLength: this.videoLength, + audioLength: this.audioLength, + }, + filename: baseFileName, + timeout: this.ffmpegTimeout, + abortSignal: this.abortSignal, + onProgress: this.createMergingProgressCallback(stateId), + }); + + await this.updateStage( + stateId, + DownloadStage.SAVING, + "Saving file...", + SAVING_STAGE_PERCENTAGE, + ); + + const filePath = await saveBlobUrlToFile( + blobUrl, + `${baseFileName}.mp4`, + stateId, + ); + + const completionMessage = warning + ? `Download completed — ${warning}` + : "Download completed"; + await this.markCompleted(stateId, filePath, completionMessage); + + logger.info(`DASH download completed: ${filePath}`); + return { filePath, fileExtension: "mp4" }; + } catch (error) { + if (error instanceof CancellationError && this.shouldSaveOnCancel?.()) { + try { + const chunkCount = await getChunkCount(this.downloadId); + const effectiveVideoLength = Math.min(chunkCount, this.videoLength); + const effectiveAudioLength = Math.max( + 0, + chunkCount - this.videoLength, + ); + this.videoLength = effectiveVideoLength; + this.audioLength = effectiveAudioLength; + + const result = await this.savePartialDownload(stateId, filename, { + requestType: MessageType.OFFSCREEN_PROCESS_DASH, + responseType: MessageType.OFFSCREEN_PROCESS_DASH_RESPONSE, + payload: { + videoLength: this.videoLength, + audioLength: this.audioLength, + }, + }); + logger.info(`DASH partial download saved: ${result.filePath}`); + return result; + } catch (saveError) { + if (!(saveError instanceof CancellationError)) { + logger.error("Failed to save partial DASH download:", saveError); + } + } + } + logger.error("DASH download failed:", error); + throw error; + } finally { + await this.tryRemoveHeaderRules(headerRuleIds); + await this.cleanupChunks(this.downloadId || stateId); + } + } +} diff --git a/src/core/downloader/dash/dash-recording-handler.ts b/src/core/downloader/dash/dash-recording-handler.ts new file mode 100644 index 0000000..b9fb73d --- /dev/null +++ b/src/core/downloader/dash/dash-recording-handler.ts @@ -0,0 +1,80 @@ +/** + * DASH live recording handler + * + * Polls a live DASH MPD at a fixed interval, collects new segments as they + * appear (single video stream, audio muxed), and — when the user stops + * recording or the stream transitions to `type="static"` — merges and saves. + * + * Extends BaseRecordingHandler for the shared polling/recording orchestration. + * Uses a single-track approach: init segment at index 0, media segments at 1..N. + */ + +import { Fragment } from "../../types"; +import { fetchText } from "../../utils/fetch-utils"; +import { logger } from "../../utils/logger"; +import { MessageType } from "../../../shared/messages"; +import { BaseRecordingHandler } from "../base-recording-handler"; +import { + parseManifest, + parseLevelsPlaylist, + isLive, + getPollIntervalMs, + MpdPlaylist, +} from "../../utils/mpd-parser"; + +export class DashRecordingHandler extends BaseRecordingHandler { + /** + * DASH recordings poll the MPD URL itself — no separate media playlist URL. + */ + protected async resolveMediaUrl(url: string, _abortSignal: AbortSignal): Promise { + return url; + } + + /** + * Fetch and re-parse the MPD, returning new segments not yet seen. + */ + protected async fetchNewSegments( + mpdUrl: string, + abortSignal: AbortSignal, + seenUris: Set, + ): Promise<{ fragments: Fragment[]; pollIntervalMs: number; ended: boolean }> { + const mpdText = await fetchText(mpdUrl, 3, abortSignal, true); + + const manifest = parseManifest(mpdText, mpdUrl); + + // Select highest bandwidth video playlist (single stream — audio typically muxed) + const playlists: MpdPlaylist[] = [...(manifest.playlists || [])]; + if (playlists.length === 0) { + return { fragments: [], pollIntervalMs: getPollIntervalMs(mpdText), ended: false }; + } + playlists.sort( + (a, b) => (b.attributes?.BANDWIDTH || 0) - (a.attributes?.BANDWIDTH || 0), + ); + const playlist = playlists[0]!; + + // Parse all segments for this playlist and filter to only new ones + const allFragments = parseLevelsPlaylist(playlist, 0); + const newFragments = allFragments.filter((f) => !seenUris.has(f.uri)); + + // Stream ended when manifest switches from dynamic to static + const ended = !isLive(mpdText); + const pollIntervalMs = getPollIntervalMs(mpdText); + + logger.info( + `[DASH REC] manifest segments=${allFragments.length}, new=${newFragments.length}, ended=${ended}`, + ); + + return { fragments: newFragments, pollIntervalMs, ended }; + } + + /** + * FFmpeg options for merging DASH recording segments (ISOBMF → MP4). + */ + protected buildFfmpegOptions() { + return { + requestType: MessageType.OFFSCREEN_PROCESS_DASH, + responseType: MessageType.OFFSCREEN_PROCESS_DASH_RESPONSE, + payload: { fragmentCount: this.segmentIndex } as Record, + }; + } +} diff --git a/src/core/downloader/download-manager.ts b/src/core/downloader/download-manager.ts index 0be4fd3..7af8319 100644 --- a/src/core/downloader/download-manager.ts +++ b/src/core/downloader/download-manager.ts @@ -16,6 +16,7 @@ import { DownloadProgressCallback } from "./types"; import { DirectDownloadHandler } from "./direct/direct-download-handler"; import { HlsDownloadHandler } from "./hls/hls-download-handler"; import { M3u8DownloadHandler } from "./m3u8/m3u8-download-handler"; +import { DashDownloadHandler } from "./dash/dash-download-handler"; import { DEFAULT_MAX_CONCURRENT, DEFAULT_FFMPEG_TIMEOUT_MS } from "../../shared/constants"; /** @@ -49,6 +50,7 @@ export class DownloadManager { private readonly directDownloadHandler: DirectDownloadHandler; private readonly hlsDownloadHandler: HlsDownloadHandler; private readonly m3u8DownloadHandler: M3u8DownloadHandler; + private readonly dashDownloadHandler: DashDownloadHandler; /** * Creates a new DownloadManager instance @@ -80,6 +82,14 @@ export class DownloadManager { ffmpegTimeout, shouldSaveOnCancel: options.shouldSaveOnCancel, }); + + // Initialize DASH download handler + this.dashDownloadHandler = new DashDownloadHandler({ + onProgress: this.onProgress, + maxConcurrent: this.maxConcurrent, + ffmpegTimeout, + shouldSaveOnCancel: options.shouldSaveOnCancel, + }); } /** @@ -165,6 +175,15 @@ export class DownloadManager { abortSignal, metadata.pageUrl, ); + } else if (format === VideoFormat.DASH) { + // Use DASH download handler + await this.dashDownloadHandler.download( + actualVideoUrl, + filename, + state.id, + abortSignal, + metadata.pageUrl, + ); } else { throw new Error(`Unsupported format: ${format}`); } diff --git a/src/core/downloader/hls/hls-recording-handler.ts b/src/core/downloader/hls/hls-recording-handler.ts index 830414b..3c43cf6 100644 --- a/src/core/downloader/hls/hls-recording-handler.ts +++ b/src/core/downloader/hls/hls-recording-handler.ts @@ -6,29 +6,20 @@ * #EXT-X-ENDLIST is detected) — hands off to the existing M3U8/FFmpeg merge path * to produce an MP4 file. * - * Extends BasePlaylistHandler for shared constructor, state, notifyProgress, - * sanitizeBaseFilename, updateStage, and cleanupChunks. - * - * The handler is controlled via an AbortSignal: - * - Normal abort (user clicks STOP) → polling loop exits, merge begins. - * - #EXT-X-ENDLIST detected → polling loop exits naturally, merge begins. + * Extends BaseRecordingHandler for the shared polling/recording orchestration. + * Implements the HLS-specific abstract methods: resolveMediaUrl, fetchNewSegments, + * and buildFfmpegOptions. */ -import { DownloadStage, Fragment } from "../../types"; -import { getDownload, storeDownload } from "../../database/downloads"; +import { Fragment } from "../../types"; import { fetchText } from "../../utils/fetch-utils"; import { parseMasterPlaylist, parseLevelsPlaylist, } from "../../utils/m3u8-parser"; import { logger } from "../../utils/logger"; -import { CancellationError } from "../../utils/errors"; import { MessageType } from "../../../shared/messages"; -import { saveBlobUrlToFile } from "../../utils/blob-utils"; -import { processWithFFmpeg } from "../../utils/ffmpeg-bridge"; -import { BasePlaylistHandler } from "../base-playlist-handler"; -import { runConcurrentWorkers } from "../concurrent-workers"; -import { SAVING_STAGE_PERCENTAGE } from "../../../shared/constants"; +import { BaseRecordingHandler } from "../base-recording-handler"; const DEFAULT_POLL_INTERVAL_MS = 3000; const MIN_POLL_INTERVAL_MS = 1000; @@ -42,99 +33,17 @@ const POLL_INTERVAL_FRACTION = 0.5; function computePollInterval(playlistText: string): number { const match = playlistText.match(/#EXT-X-TARGETDURATION:\s*(\d+(?:\.\d+)?)/); if (!match) return DEFAULT_POLL_INTERVAL_MS; - const targetDuration = parseFloat(match[1]!) * 1000; // convert to ms + const targetDuration = parseFloat(match[1]!) * 1000; const interval = Math.round(targetDuration * POLL_INTERVAL_FRACTION); return Math.max(MIN_POLL_INTERVAL_MS, Math.min(interval, MAX_POLL_INTERVAL_MS)); } -export class HlsRecordingHandler extends BasePlaylistHandler { - private segmentIndex: number = 0; - - protected override resetDownloadState( - stateId: string, - abortSignal?: AbortSignal, - ): void { - super.resetDownloadState(stateId, abortSignal); - this.segmentIndex = 0; - } - +export class HlsRecordingHandler extends BaseRecordingHandler { /** - * Start recording a live HLS stream. - * Returns when the user aborts or #EXT-X-ENDLIST is detected, - * then merges collected segments and saves the file. + * Resolve the media playlist URL from a master or media playlist URL. + * If the URL points to a master playlist, selects the highest-bandwidth variant. */ - async record( - manifestUrl: string, - filename: string, - stateId: string, - abortSignal: AbortSignal, - pageUrl?: string, - ): Promise<{ filePath: string; fileExtension?: string }> { - this.resetDownloadState(stateId, abortSignal); - - const headerRuleIds = await this.tryAddHeaderRules(stateId, manifestUrl, pageUrl); - - try { - const mediaPlaylistUrl = await this.resolveMediaPlaylistUrl( - manifestUrl, - abortSignal, - ); - - await this.collectSegments(mediaPlaylistUrl, stateId, abortSignal); - - if (this.segmentIndex === 0) { - throw new Error("No segments were recorded"); - } - - await this.updateStage( - stateId, - DownloadStage.MERGING, - "Merging recorded segments...", - ); - - const baseFileName = this.sanitizeBaseFilename(filename, "recording"); - - const { blobUrl, warning } = await processWithFFmpeg({ - requestType: MessageType.OFFSCREEN_PROCESS_M3U8, - responseType: MessageType.OFFSCREEN_PROCESS_M3U8_RESPONSE, - downloadId: this.downloadId, - payload: { fragmentCount: this.segmentIndex }, - filename: baseFileName, - timeout: this.ffmpegTimeout, - onProgress: this.createMergingProgressCallback(stateId), - }); - - await this.updateStage( - stateId, - DownloadStage.SAVING, - "Saving file...", - SAVING_STAGE_PERCENTAGE, - ); - - const finalFilename = `${baseFileName}.mp4`; - const filePath = await saveBlobUrlToFile(blobUrl, finalFilename, stateId); - - const completionMessage = warning - ? `Recording saved — ${warning}` - : "Recording saved"; - await this.markCompleted(stateId, filePath, completionMessage); - - logger.info(`HLS recording completed: ${filePath}`); - return { filePath, fileExtension: "mp4" }; - } catch (error) { - logger.error("HLS recording failed:", error); - throw error; - } finally { - await this.tryRemoveHeaderRules(headerRuleIds); - await this.cleanupChunks(this.downloadId || stateId); - } - } - - // --------------------------------------------------------------------------- - // Private helpers - // --------------------------------------------------------------------------- - - private async resolveMediaPlaylistUrl( + protected async resolveMediaUrl( url: string, abortSignal: AbortSignal, ): Promise { @@ -164,125 +73,33 @@ export class HlsRecordingHandler extends BasePlaylistHandler { return resolvedUrl; } - private async collectSegments( - mediaPlaylistUrl: string, - stateId: string, + /** + * Fetch the media playlist and return new segments not yet seen. + */ + protected async fetchNewSegments( + url: string, abortSignal: AbortSignal, - ): Promise { - const seenUris = new Set(); - let pollIntervalMs = DEFAULT_POLL_INTERVAL_MS; + seenUris: Set, + ): Promise<{ fragments: Fragment[]; pollIntervalMs: number; ended: boolean }> { + const playlistText = await fetchText(url, 3, abortSignal, true); - logger.info( - `[REC] Starting polling loop, abortSignal.aborted=${abortSignal.aborted}`, - ); - let pollCount = 0; - - while (!abortSignal.aborted) { - pollCount++; - let playlistText: string; - try { - playlistText = await fetchText(mediaPlaylistUrl, 3, abortSignal, true); - } catch (err) { - if (abortSignal.aborted) break; - logger.warn( - "Failed to fetch manifest during recording, retrying...", - err, - ); - await this.sleep(pollIntervalMs, abortSignal); - continue; - } - - // Adapt poll interval from target duration on first successful fetch - if (pollCount === 1) { - pollIntervalMs = computePollInterval(playlistText); - logger.info(`[REC] Adaptive poll interval: ${pollIntervalMs}ms`); - } - - if (abortSignal.aborted) break; - - logger.info( - `[REC] Poll #${pollCount}: playlist length=${playlistText.length}, aborted=${abortSignal.aborted}`, - ); - - const allFragments = parseLevelsPlaylist(playlistText, mediaPlaylistUrl); - const newFragments = allFragments.filter((f) => !seenUris.has(f.uri)); - - logger.info( - `[REC] Poll #${pollCount}: total fragments=${allFragments.length}, new=${newFragments.length}, seen=${seenUris.size}`, - ); - - if (newFragments.length > 0) { - const indexedFragments: Fragment[] = newFragments.map((f) => ({ - ...f, - index: this.segmentIndex++, - })); - - indexedFragments.forEach((f) => seenUris.add(f.uri)); + const allFragments = parseLevelsPlaylist(playlistText, url); + const newFragments = allFragments.filter((f) => !seenUris.has(f.uri)); - await this.downloadFragmentsConcurrently(indexedFragments, abortSignal); + const ended = playlistText.includes("#EXT-X-ENDLIST"); + const pollIntervalMs = computePollInterval(playlistText); - await this.updateRecordingProgress(stateId); - } - - const hasEndList = playlistText.includes("#EXT-X-ENDLIST"); - logger.info(`[REC] Poll #${pollCount}: hasEndList=${hasEndList}`); - if (hasEndList) { - logger.info("HLS stream ended naturally (#EXT-X-ENDLIST detected)"); - break; - } - - await this.sleep(pollIntervalMs, abortSignal); - } - logger.info( - `[REC] Polling loop exited after ${pollCount} polls, segments=${this.segmentIndex}, aborted=${abortSignal.aborted}`, - ); + return { fragments: newFragments, pollIntervalMs, ended }; } - private async downloadFragmentsConcurrently( - fragments: Fragment[], - abortSignal: AbortSignal, - ): Promise { - await runConcurrentWorkers({ - items: fragments, - maxConcurrent: this.maxConcurrent, - shouldStop: () => abortSignal.aborted, - processItem: async (fragment) => { - const size = await this.downloadFragment(fragment, this.downloadId); - this.bytesDownloaded += size; - }, - onError: (fragment, err) => { - logger.warn(`Failed to download segment ${fragment.index}:`, err); - }, - }); - } - - private async updateRecordingProgress(stateId: string): Promise { - const state = await getDownload(stateId); - if (!state) return; - state.progress.stage = DownloadStage.RECORDING; - state.progress.segmentsCollected = this.segmentIndex; - state.progress.downloaded = this.bytesDownloaded; - state.progress.message = `${this.segmentIndex} segments collected`; - state.updatedAt = Date.now(); - await storeDownload(state); - this.notifyProgress(state); - } - - private sleep(ms: number, abortSignal: AbortSignal): Promise { - return new Promise((resolve) => { - if (abortSignal.aborted) { - resolve(); - return; - } - const id = setTimeout(resolve, ms); - abortSignal.addEventListener( - "abort", - () => { - clearTimeout(id); - resolve(); - }, - { once: true }, - ); - }); + /** + * FFmpeg options for merging HLS recording segments (MPEG-TS → MP4). + */ + protected buildFfmpegOptions() { + return { + requestType: MessageType.OFFSCREEN_PROCESS_M3U8, + responseType: MessageType.OFFSCREEN_PROCESS_M3U8_RESPONSE, + payload: { fragmentCount: this.segmentIndex } as Record, + }; } } diff --git a/src/core/types.ts b/src/core/types.ts index 6ec995f..fc0a4fb 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -6,6 +6,7 @@ export enum VideoFormat { DIRECT = "direct", HLS = "hls", M3U8 = "m3u8", + DASH = "dash", UNKNOWN = "unknown", } diff --git a/src/core/utils/mpd-parser.ts b/src/core/utils/mpd-parser.ts new file mode 100644 index 0000000..291d458 --- /dev/null +++ b/src/core/utils/mpd-parser.ts @@ -0,0 +1,166 @@ +/** + * MPD (MPEG-DASH) manifest parser utility + * + * Wraps the `mpd-parser` npm package and converts its output to the + * project's Fragment[] and Level[] types. Mirrors the structure of m3u8-parser.ts. + */ + +import { parse } from "mpd-parser"; +import { v4 as uuidv4 } from "uuid"; +import { Fragment, Level, LevelType } from "../types"; + +// Typed shapes for mpd-parser output (no bundled @types, so we define what we need) +export interface MpdSegment { + uri: string; + resolvedUri: string; + duration: number; + map?: { + uri: string; + resolvedUri: string; + byterange?: { offset: number | bigint; length: number | bigint }; + }; +} + +export interface MpdPlaylist { + uri: string; + attributes: { + BANDWIDTH?: number; + RESOLUTION?: { width: number; height: number }; + CODECS?: string; + [key: string]: unknown; + }; + segments: MpdSegment[]; + contentProtection?: Record; +} + +export interface MpdManifest { + playlists: MpdPlaylist[]; + mediaGroups: { + AUDIO?: { + audio?: Record; + }; + }; + minimumUpdatePeriod?: number; // in ms; always present in mpd-parser output + [key: string]: unknown; +} + +/** + * Parse an MPD manifest string into a structured manifest object. + */ +export function parseManifest(mpdText: string, mpdUrl: string): MpdManifest { + return parse(mpdText, { manifestUri: mpdUrl }) as MpdManifest; +} + +/** + * Parse an MPD manifest into Level[] (one Level per representation). + * Mirrors parseMasterPlaylist() from m3u8-parser.ts. + */ +export function parseMasterPlaylist(mpdText: string, mpdUrl: string): Level[] { + const manifest = parseManifest(mpdText, mpdUrl); + return (manifest.playlists || []).map((playlist) => ({ + type: "stream" as LevelType, + id: uuidv4(), + playlistID: mpdUrl, + uri: mpdUrl, // DASH representations all derive from the same MPD URL + bitrate: playlist.attributes?.BANDWIDTH, + height: playlist.attributes?.RESOLUTION?.height, + width: playlist.attributes?.RESOLUTION?.width, + })); +} + +/** + * Parse a single representation's segments into Fragment[]. + * + * Handles the init segment (segment.map) the same way m3u8-parser handles + * EXT-X-MAP: inserts it as a Fragment at the current index, deduplicated by URI. + * Indices are assigned sequentially from `startIndex`. + * Mirrors parseLevelsPlaylist() from m3u8-parser.ts. + */ +export function parseLevelsPlaylist( + playlist: MpdPlaylist, + startIndex: number = 0, +): Fragment[] { + const fragments: Fragment[] = []; + const segments = playlist.segments || []; + let index = startIndex; + let currentMapUri: string | null = null; + + for (const segment of segments) { + // Handle init segment (like EXT-X-MAP) — deduplicate by URI + if (segment.map) { + const mapUri = segment.map.resolvedUri || segment.map.uri; + if (mapUri && mapUri !== currentMapUri) { + fragments.push({ + index, + key: { iv: null, uri: null }, + uri: mapUri, + }); + index++; + currentMapUri = mapUri; + } + } + + const segUri = segment.resolvedUri || segment.uri; + fragments.push({ + index, + key: { iv: null, uri: null }, + uri: segUri, + }); + index++; + } + + return fragments; +} + +/** + * Detect whether an MPD describes a live (dynamic) stream. + * Checks for `type="dynamic"` in the raw XML — more reliable than inspecting + * the parsed output, which always sets minimumUpdatePeriod. + */ +export function isLive(mpdText: string): boolean { + return /type\s*=\s*["']dynamic["']/i.test(mpdText); +} + +/** + * Check whether an MPD contains DRM (ContentProtection elements). + */ +export function hasDrm(mpdText: string): boolean { + return / void, +): Promise { + const videoFile = `${downloadId}_video.mp4`; + const audioFile = `${downloadId}_audio.mp4`; + + try { + onProgress?.(0.1, "Concatenating chunks"); + const [videoResult, audioResult] = await Promise.all([ + concatenateChunks(downloadId, 0, videoLength), + concatenateChunks(downloadId, videoLength, audioLength), + ]); + + onProgress?.(0.5, "Writing video stream"); + await ffmpeg.writeFile(videoFile, await fetchFile(videoResult.blob)); + + onProgress?.(0.6, "Writing audio stream"); + await ffmpeg.writeFile(audioFile, await fetchFile(audioResult.blob)); + + onProgress?.(0.7, "Merging video and audio"); + await ffmpeg.exec([ + "-y", + "-i", videoFile, + "-i", audioFile, + "-c:v", "copy", + "-c:a", "copy", + "-movflags", "+faststart", + outputFileName, + ]); + + const totalMissing = videoResult.missingCount + audioResult.missingCount; + const totalChunks = videoResult.totalCount + audioResult.totalCount; + return buildMissingChunksWarning(totalMissing, totalChunks); + } finally { + await cleanupFiles(ffmpeg, [videoFile, audioFile]); + } +} + +async function processDashSingleStream( + ffmpeg: FFmpeg, + downloadId: string, + fragmentCount: number, + outputFileName: string, + onProgress?: (progress: number, message: string) => void, +): Promise { + const inputFile = `${downloadId}_media.mp4`; + + try { + onProgress?.(0.2, "Concatenating segments"); + const result = await concatenateChunks(downloadId, 0, fragmentCount); + + onProgress?.(0.5, "Writing media stream"); + await ffmpeg.writeFile(inputFile, await fetchFile(result.blob)); + + onProgress?.(0.7, "Converting to MP4"); + await ffmpeg.exec([ + "-y", + "-i", inputFile, + "-c", "copy", + "-movflags", "+faststart", + outputFileName, + ]); + + return buildMissingChunksWarning(result.missingCount, result.totalCount); + } finally { + await cleanupFiles(ffmpeg, [inputFile]); + } +} + +async function processDashChunks( + downloadId: string, + videoLength: number, + audioLength: number, + onProgress?: (progress: number, message: string) => void, +): Promise { + validateDownloadId(downloadId); + const ffmpeg = await getFFmpeg(); + + const outputFileName = `/tmp/${downloadId}.mp4`; + + try { + let warning: string | undefined; + + if (videoLength > 0 && audioLength > 0) { + warning = await processDashVideoAndAudio( + ffmpeg, + downloadId, + videoLength, + audioLength, + outputFileName, + onProgress, + ); + } else if (videoLength > 0) { + warning = await processDashSingleStream( + ffmpeg, + downloadId, + videoLength, + outputFileName, + onProgress, + ); + } else { + throw new Error("No DASH chunks to process"); + } + + const data = await ffmpeg.readFile(outputFileName); + onProgress?.(1, "Done"); + + const blob = new Blob([data as BlobPart], { type: "video/mp4" }); + const blobUrl = URL.createObjectURL(blob); + + try { + await ffmpeg.deleteFile(outputFileName); + } catch { + // File may not exist, ignore error + } + + return { blobUrl, warning }; + } catch (error) { + resetFFmpeg(); + logger.error(`FFmpeg DASH processing failed for ${downloadId}:`, error); + throw error; + } +} + /** * Process M3U8 media playlist chunks and convert to MP4 */ @@ -490,6 +628,23 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { ) return true; + if ( + handleProcessingMessage( + message, + sendResponse, + MessageType.OFFSCREEN_PROCESS_DASH, + MessageType.OFFSCREEN_PROCESS_DASH_RESPONSE, + (payload, onProgress) => + processDashChunks( + payload.downloadId as string, + payload.videoLength as number, + payload.audioLength as number, + onProgress, + ), + ) + ) + return true; + // Pre-warm FFmpeg while segments are downloading if (message.type === MessageType.WARMUP_FFMPEG) { sendResponse({ acknowledged: true }); diff --git a/src/service-worker.ts b/src/service-worker.ts index 4fbe282..30a3e5d 100644 --- a/src/service-worker.ts +++ b/src/service-worker.ts @@ -5,6 +5,7 @@ import { DownloadManager } from "./core/downloader/download-manager"; import { HlsRecordingHandler } from "./core/downloader/hls/hls-recording-handler"; +import { DashRecordingHandler } from "./core/downloader/dash/dash-recording-handler"; import { getAllDownloads, getDownload, @@ -323,6 +324,10 @@ chrome.webRequest.onCompleted.addListener( "https://*/*.m3u8", "http://*/*.m3u8?*", "https://*/*.m3u8?*", + "http://*/*.mpd", + "https://*/*.mpd", + "http://*/*.mpd?*", + "https://*/*.mpd?*", ], types: ["xmlhttprequest"], }, @@ -387,6 +392,7 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { case MessageType.OFFSCREEN_PROCESS_HLS_RESPONSE: case MessageType.OFFSCREEN_PROCESS_M3U8_RESPONSE: + case MessageType.OFFSCREEN_PROCESS_DASH_RESPONSE: // Handled by ffmpeg-bridge's dynamic onMessage listener in processWithFFmpeg() return false; @@ -410,7 +416,8 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { async function cleanupOrphanedChunks(existing: DownloadState) { if ( existing.metadata.format !== VideoFormat.HLS && - existing.metadata.format !== VideoFormat.M3U8 + existing.metadata.format !== VideoFormat.M3U8 && + existing.metadata.format !== VideoFormat.DASH ) { return; } @@ -576,8 +583,8 @@ async function handleDownloadRequest(payload: { // Start keep-alive if this is the first active download if (activeDownloads.size === 1) { keepAlive(true); - // Pre-warm FFmpeg for HLS/M3U8 downloads while segments download - if (metadata.format === VideoFormat.HLS || metadata.format === VideoFormat.M3U8) { + // Pre-warm FFmpeg for HLS/M3U8/DASH downloads while segments download + if (metadata.format === VideoFormat.HLS || metadata.format === VideoFormat.M3U8 || metadata.format === VideoFormat.DASH) { createOffscreenDocument() .then(() => chrome.runtime.sendMessage({ type: MessageType.WARMUP_FFMPEG })) .catch((err) => logger.error("FFmpeg pre-warm failed:", err)); @@ -731,10 +738,13 @@ async function startDownload( // Generate filename if not provided let finalFilename = filename; if (!finalFilename) { - // HLS/M3U8 formats always produce MP4 after FFmpeg processing - const extension = metadata.format === VideoFormat.HLS || metadata.format === VideoFormat.M3U8 - ? "mp4" - : metadata.fileExtension || "mp4"; + // HLS/M3U8/DASH formats always produce MP4 after FFmpeg processing + const extension = + metadata.format === VideoFormat.HLS || + metadata.format === VideoFormat.M3U8 || + metadata.format === VideoFormat.DASH + ? "mp4" + : metadata.fileExtension || "mp4"; // Use tab info if available, otherwise fall back to URL-based generation if (tabTitle || website) { finalFilename = generateFilenameFromTabInfo( @@ -833,11 +843,12 @@ async function handleCancelDownload(id: string): Promise { // 2. Cancel Chrome downloads await cancelChromeDownloads(download); - // 3. Clean up chunks for HLS/M3U8 downloads - // Only cleanup if format is HLS or M3U8 (these use IndexedDB chunks) + // 3. Clean up chunks for HLS/M3U8/DASH downloads + // Only cleanup if format uses IndexedDB chunks if ( download.metadata.format === VideoFormat.HLS || - download.metadata.format === VideoFormat.M3U8 + download.metadata.format === VideoFormat.M3U8 || + download.metadata.format === VideoFormat.DASH ) { try { await deleteChunks(download.id); @@ -927,19 +938,21 @@ async function handleStartRecordingMessage(payload: { } catch (_) {} }; - const handler = new HlsRecordingHandler({ - onProgress, - maxConcurrent, - ffmpegTimeout, - }); + const handler = + metadata.format === VideoFormat.DASH + ? new DashRecordingHandler({ onProgress, maxConcurrent, ffmpegTimeout }) + : new HlsRecordingHandler({ onProgress, maxConcurrent, ffmpegTimeout }); // Resolve filename let finalFilename = filename; if (!finalFilename) { - // HLS/M3U8 formats always produce MP4 after FFmpeg processing - const extension = metadata.format === VideoFormat.HLS || metadata.format === VideoFormat.M3U8 - ? "mp4" - : metadata.fileExtension || "mp4"; + // HLS/M3U8/DASH formats always produce MP4 after FFmpeg processing + const extension = + metadata.format === VideoFormat.HLS || + metadata.format === VideoFormat.M3U8 || + metadata.format === VideoFormat.DASH + ? "mp4" + : metadata.fileExtension || "mp4"; if (tabTitle || website) { finalFilename = generateFilenameFromTabInfo(tabTitle, website, extension); } else { diff --git a/src/shared/messages.ts b/src/shared/messages.ts index 0849572..6df1096 100644 --- a/src/shared/messages.ts +++ b/src/shared/messages.ts @@ -47,6 +47,8 @@ export enum MessageType { OFFSCREEN_PROCESS_HLS_RESPONSE = "OFFSCREEN_PROCESS_HLS_RESPONSE", OFFSCREEN_PROCESS_M3U8 = "OFFSCREEN_PROCESS_M3U8", OFFSCREEN_PROCESS_M3U8_RESPONSE = "OFFSCREEN_PROCESS_M3U8_RESPONSE", + OFFSCREEN_PROCESS_DASH = "OFFSCREEN_PROCESS_DASH", + OFFSCREEN_PROCESS_DASH_RESPONSE = "OFFSCREEN_PROCESS_DASH_RESPONSE", // Icon management SET_ICON_BLUE = "SET_ICON_BLUE", diff --git a/src/types/mpd-parser.d.ts b/src/types/mpd-parser.d.ts new file mode 100644 index 0000000..b1a2179 --- /dev/null +++ b/src/types/mpd-parser.d.ts @@ -0,0 +1,11 @@ +declare module "mpd-parser" { + interface ParseOptions { + manifestUri?: string; + previousManifest?: unknown; + sidxMapping?: Record; + } + + function parse(manifestString: string, options?: ParseOptions): unknown; + + export { parse }; +} From 1311cdcf150758a288a13b250bdb896673f6b324 Mon Sep 17 00:00:00 2001 From: Jvillegasd Date: Mon, 2 Mar 2026 03:49:03 -0500 Subject: [PATCH 05/13] refactor: unify parser utils and reorganize types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Split parseLevelsPlaylist into two steps: parseMediaPlaylist (HLS) / getVideoPlaylist / getAudioPlaylist (DASH) produce a ParsedPlaylist, then a single parseLevelsPlaylist in playlist-utils.ts converts to Fragment[] for both protocols - Add ParsedSegment and ParsedPlaylist to core types as the shared protocol-agnostic intermediate - Move src/core/types.ts → src/core/types/index.ts; add parser.d.ts alongside it for mpd-parser ambient declarations (replaces orphan src/types/ folder) - Remove MpdPlaylist from public API; handlers now use getVideoPlaylist() instead of inline sort logic Co-Authored-By: Claude Sonnet 4.6 --- .../downloader/dash/dash-download-handler.ts | 13 +- .../downloader/dash/dash-recording-handler.ts | 10 +- .../downloader/hls/hls-download-handler.ts | 29 ++-- .../downloader/hls/hls-recording-handler.ts | 3 +- .../downloader/m3u8/m3u8-download-handler.ts | 5 +- src/core/{types.ts => types/index.ts} | 22 ++- src/core/types/parser.d.ts | 45 +++++++ src/core/utils/m3u8-parser.ts | 88 ++++-------- src/core/utils/mpd-parser.ts | 125 ++++++------------ src/core/utils/playlist-utils.ts | 47 +++++++ src/types/mpd-parser.d.ts | 11 -- 11 files changed, 198 insertions(+), 200 deletions(-) rename src/core/{types.ts => types/index.ts} (83%) create mode 100644 src/core/types/parser.d.ts create mode 100644 src/core/utils/playlist-utils.ts delete mode 100644 src/types/mpd-parser.d.ts diff --git a/src/core/downloader/dash/dash-download-handler.ts b/src/core/downloader/dash/dash-download-handler.ts index bed26e1..89789a4 100644 --- a/src/core/downloader/dash/dash-download-handler.ts +++ b/src/core/downloader/dash/dash-download-handler.ts @@ -27,8 +27,8 @@ import { parseManifest, parseLevelsPlaylist, hasDrm, + getVideoPlaylist, getAudioPlaylist, - MpdPlaylist, } from "../../utils/mpd-parser"; export class DashDownloadHandler extends BasePlaylistHandler { @@ -71,17 +71,12 @@ export class DashDownloadHandler extends BasePlaylistHandler { const manifest = parseManifest(mpdText, mpdUrl); - // Select highest bandwidth video playlist - const videoPlaylists = [...(manifest.playlists || [])]; - if (videoPlaylists.length === 0) { + const videoPlaylist = getVideoPlaylist(manifest); + if (!videoPlaylist) { throw new Error("No video streams found in MPD manifest"); } - videoPlaylists.sort( - (a, b) => (b.attributes?.BANDWIDTH || 0) - (a.attributes?.BANDWIDTH || 0), - ); - const videoPlaylist = videoPlaylists[0]!; - const audioPlaylist: MpdPlaylist | null = getAudioPlaylist(manifest); + const audioPlaylist = getAudioPlaylist(manifest); throwIfAborted(this.abortSignal); diff --git a/src/core/downloader/dash/dash-recording-handler.ts b/src/core/downloader/dash/dash-recording-handler.ts index b9fb73d..0e6caba 100644 --- a/src/core/downloader/dash/dash-recording-handler.ts +++ b/src/core/downloader/dash/dash-recording-handler.ts @@ -19,7 +19,7 @@ import { parseLevelsPlaylist, isLive, getPollIntervalMs, - MpdPlaylist, + getVideoPlaylist, } from "../../utils/mpd-parser"; export class DashRecordingHandler extends BaseRecordingHandler { @@ -43,14 +43,10 @@ export class DashRecordingHandler extends BaseRecordingHandler { const manifest = parseManifest(mpdText, mpdUrl); // Select highest bandwidth video playlist (single stream — audio typically muxed) - const playlists: MpdPlaylist[] = [...(manifest.playlists || [])]; - if (playlists.length === 0) { + const playlist = getVideoPlaylist(manifest); + if (!playlist) { return { fragments: [], pollIntervalMs: getPollIntervalMs(mpdText), ended: false }; } - playlists.sort( - (a, b) => (b.attributes?.BANDWIDTH || 0) - (a.attributes?.BANDWIDTH || 0), - ); - const playlist = playlists[0]!; // Parse all segments for this playlist and filter to only new ones const allFragments = parseLevelsPlaylist(playlist, 0); diff --git a/src/core/downloader/hls/hls-download-handler.ts b/src/core/downloader/hls/hls-download-handler.ts index 4cf099c..19984df 100644 --- a/src/core/downloader/hls/hls-download-handler.ts +++ b/src/core/downloader/hls/hls-download-handler.ts @@ -14,6 +14,7 @@ import { Level, DownloadStage } from "../../types"; import { logger } from "../../utils/logger"; import { parseMasterPlaylist, + parseMediaPlaylist, parseLevelsPlaylist, } from "../../utils/m3u8-parser"; import { getChunkCount } from "../../database/chunks"; @@ -152,8 +153,8 @@ export class HlsDownloadHandler extends BasePlaylistHandler { } const videoFragments = parseLevelsPlaylist( - videoPlaylistText, - videoPlaylistUrl, + parseMediaPlaylist(videoPlaylistText, videoPlaylistUrl), + 0, ); if (videoFragments.length === 0) { @@ -161,16 +162,10 @@ export class HlsDownloadHandler extends BasePlaylistHandler { } logger.info(`Found ${videoFragments.length} video fragments`); - - const indexedVideoFragments = videoFragments.map((frag, idx) => ({ - ...frag, - index: idx, - })); - - this.videoLength = indexedVideoFragments.length; + this.videoLength = videoFragments.length; await this.downloadAllFragments( - indexedVideoFragments, + videoFragments, this.downloadId, stateId, ); @@ -186,8 +181,8 @@ export class HlsDownloadHandler extends BasePlaylistHandler { } const audioFragments = parseLevelsPlaylist( - audioPlaylistText, - audioPlaylistUrl, + parseMediaPlaylist(audioPlaylistText, audioPlaylistUrl), + this.videoLength, ); if (audioFragments.length === 0) { @@ -195,16 +190,10 @@ export class HlsDownloadHandler extends BasePlaylistHandler { } logger.info(`Found ${audioFragments.length} audio fragments`); - - const indexedAudioFragments = audioFragments.map((frag, idx) => ({ - ...frag, - index: this.videoLength + idx, - })); - - this.audioLength = indexedAudioFragments.length; + this.audioLength = audioFragments.length; await this.downloadAllFragments( - indexedAudioFragments, + audioFragments, this.downloadId, stateId, ); diff --git a/src/core/downloader/hls/hls-recording-handler.ts b/src/core/downloader/hls/hls-recording-handler.ts index 3c43cf6..afb73d9 100644 --- a/src/core/downloader/hls/hls-recording-handler.ts +++ b/src/core/downloader/hls/hls-recording-handler.ts @@ -15,6 +15,7 @@ import { Fragment } from "../../types"; import { fetchText } from "../../utils/fetch-utils"; import { parseMasterPlaylist, + parseMediaPlaylist, parseLevelsPlaylist, } from "../../utils/m3u8-parser"; import { logger } from "../../utils/logger"; @@ -83,7 +84,7 @@ export class HlsRecordingHandler extends BaseRecordingHandler { ): Promise<{ fragments: Fragment[]; pollIntervalMs: number; ended: boolean }> { const playlistText = await fetchText(url, 3, abortSignal, true); - const allFragments = parseLevelsPlaylist(playlistText, url); + const allFragments = parseLevelsPlaylist(parseMediaPlaylist(playlistText, url)); const newFragments = allFragments.filter((f) => !seenUris.has(f.uri)); const ended = playlistText.includes("#EXT-X-ENDLIST"); diff --git a/src/core/downloader/m3u8/m3u8-download-handler.ts b/src/core/downloader/m3u8/m3u8-download-handler.ts index 10d3ef8..e4d29f6 100644 --- a/src/core/downloader/m3u8/m3u8-download-handler.ts +++ b/src/core/downloader/m3u8/m3u8-download-handler.ts @@ -11,7 +11,7 @@ import { CancellationError } from "../../utils/errors"; import { DownloadStage } from "../../types"; import { logger } from "../../utils/logger"; -import { parseLevelsPlaylist } from "../../utils/m3u8-parser"; +import { parseMediaPlaylist, parseLevelsPlaylist } from "../../utils/m3u8-parser"; import { getChunkCount } from "../../database/chunks"; import { MessageType } from "../../../shared/messages"; import { processWithFFmpeg } from "../../utils/ffmpeg-bridge"; @@ -61,8 +61,7 @@ export class M3u8DownloadHandler extends BasePlaylistHandler { canDownloadHLSManifest(mediaPlaylistText); const fragments = parseLevelsPlaylist( - mediaPlaylistText, - mediaPlaylistUrl, + parseMediaPlaylist(mediaPlaylistText, mediaPlaylistUrl), ); if (fragments.length === 0) { diff --git a/src/core/types.ts b/src/core/types/index.ts similarity index 83% rename from src/core/types.ts rename to src/core/types/index.ts index fc0a4fb..6f3401d 100644 --- a/src/core/types.ts +++ b/src/core/types/index.ts @@ -3,10 +3,10 @@ */ export enum VideoFormat { - DIRECT = "direct", - HLS = "hls", - M3U8 = "m3u8", - DASH = "dash", + DIRECT = "direct", + HLS = "hls", + M3U8 = "m3u8", + DASH = "dash", UNKNOWN = "unknown", } @@ -125,3 +125,17 @@ export interface Level { height?: number; width?: number; } + +// Protocol-agnostic intermediate segment produced by parseMediaPlaylist (HLS) +// or getVideoPlaylist/getAudioPlaylist (DASH) before Fragment[] conversion. +export interface ParsedSegment { + uri: string; + initUri?: string; // EXT-X-MAP (HLS) or segment.map.resolvedUri (DASH) + initByteRange?: string; // "offset:length" — HLS only + key?: { iv: string | null; uri: string | null }; // HLS only +} + +// Protocol-agnostic playlist ready for Fragment[] conversion via parseLevelsPlaylist. +export interface ParsedPlaylist { + segments: ParsedSegment[]; +} diff --git a/src/core/types/parser.d.ts b/src/core/types/parser.d.ts new file mode 100644 index 0000000..63951ea --- /dev/null +++ b/src/core/types/parser.d.ts @@ -0,0 +1,45 @@ +declare module "mpd-parser" { + interface ParseOptions { + manifestUri?: string; + previousManifest?: unknown; + sidxMapping?: Record; + } + + interface MpdSegment { + uri: string; + resolvedUri: string; + duration: number; + map?: { + uri: string; + resolvedUri: string; + byterange?: { offset: number | bigint; length: number | bigint }; + }; + } + + interface MpdPlaylist { + uri: string; + attributes: { + BANDWIDTH?: number; + RESOLUTION?: { width: number; height: number }; + CODECS?: string; + [key: string]: unknown; + }; + segments: MpdSegment[]; + contentProtection?: Record; + } + + interface MpdManifest { + playlists: MpdPlaylist[]; + mediaGroups: { + AUDIO?: { + audio?: Record; + }; + }; + minimumUpdatePeriod?: number; + [key: string]: unknown; + } + + function parse(manifestString: string, options?: ParseOptions): MpdManifest; + + export { parse, MpdSegment, MpdPlaylist, MpdManifest }; +} diff --git a/src/core/utils/m3u8-parser.ts b/src/core/utils/m3u8-parser.ts index 09a9c56..558dbf4 100644 --- a/src/core/utils/m3u8-parser.ts +++ b/src/core/utils/m3u8-parser.ts @@ -5,79 +5,48 @@ import { Parser } from "m3u8-parser"; import { buildAbsoluteURL } from "url-toolkit"; import { v4 as uuidv4 } from "uuid"; -import { Fragment, Level, LevelType } from "../types"; +import { Level, LevelType } from "../types"; +import type { ParsedPlaylist, ParsedSegment } from "../types"; import { normalizeUrl } from "./url-utils"; import { logger } from "./logger"; +export { parseLevelsPlaylist } from "./playlist-utils"; +import { parseLevelsPlaylist } from "./playlist-utils"; + /** - * Parse a level playlist into fragments + * Parse a media playlist into a ParsedPlaylist (protocol-agnostic intermediate). + * Pass the result to parseLevelsPlaylist() to get Fragment[]. */ -export function parseLevelsPlaylist( - playlistText: string, - baseUrl: string, -): Fragment[] { +export function parseMediaPlaylist(playlistText: string, baseUrl: string): ParsedPlaylist { const parser = new Parser(); parser.push(playlistText); parser.end(); - const segments = parser.manifest.segments || []; - const fragments: Fragment[] = []; - - let index = 0; - let currentMapUri: string | null = null; - let currentMapByteRange: string | null = null; - - segments.forEach((segment) => { - // Handle initialization segments (EXT-X-MAP) - if (segment.map && segment.map.uri) { - const mapUri = buildAbsoluteURL(baseUrl, segment.map.uri); - const mapByteRange = segment.map.byterange - ? `${segment.map.byterange.offset}:${segment.map.byterange.length}` - : null; - - // Only add map if it's different from the current one - if (mapUri !== currentMapUri || mapByteRange !== currentMapByteRange) { - fragments.push({ - index, - key: - segment.key && segment.key.uri - ? { - iv: segment.key.iv - ? Array.from(segment.key.iv) - .map((b) => b.toString(16).padStart(2, "0")) - .join("") - : null, - uri: buildAbsoluteURL(baseUrl, segment.key.uri), - } - : { iv: null, uri: null }, - uri: mapUri, - }); - index++; - currentMapUri = mapUri; - currentMapByteRange = mapByteRange; + const segments: ParsedSegment[] = (parser.manifest.segments || []).map((segment) => { + const ps: ParsedSegment = { uri: buildAbsoluteURL(baseUrl, segment.uri) }; + + if (segment.map?.uri) { + ps.initUri = buildAbsoluteURL(baseUrl, segment.map.uri); + if (segment.map.byterange) { + ps.initByteRange = `${segment.map.byterange.offset}:${segment.map.byterange.length}`; } } - // Add the segment fragment - fragments.push({ - index, - key: - segment.key && segment.key.uri - ? { - iv: segment.key.iv - ? Array.from(segment.key.iv) - .map((b) => b.toString(16).padStart(2, "0")) - .join("") - : null, - uri: buildAbsoluteURL(baseUrl, segment.key.uri), - } - : { iv: null, uri: null }, - uri: buildAbsoluteURL(baseUrl, segment.uri), - }); - index++; + if (segment.key?.uri) { + ps.key = { + uri: buildAbsoluteURL(baseUrl, segment.key.uri), + iv: segment.key.iv + ? Array.from(segment.key.iv) + .map((b) => b.toString(16).padStart(2, "0")) + .join("") + : null, + }; + } + + return ps; }); - return fragments; + return { segments }; } /** @@ -202,6 +171,7 @@ export function belongsToMasterPlaylist( export const M3u8Parser = { parseLevelsPlaylist, + parseMediaPlaylist, parseMasterPlaylist, isMasterPlaylist, isMediaPlaylist, diff --git a/src/core/utils/mpd-parser.ts b/src/core/utils/mpd-parser.ts index 291d458..59d2aa9 100644 --- a/src/core/utils/mpd-parser.ts +++ b/src/core/utils/mpd-parser.ts @@ -5,43 +5,26 @@ * project's Fragment[] and Level[] types. Mirrors the structure of m3u8-parser.ts. */ -import { parse } from "mpd-parser"; +import { parse, MpdManifest, MpdPlaylist } from "mpd-parser"; import { v4 as uuidv4 } from "uuid"; -import { Fragment, Level, LevelType } from "../types"; +import { Level, LevelType } from "../types"; +import type { ParsedPlaylist, ParsedSegment } from "../types"; +import { parseLevelsPlaylist } from "./playlist-utils"; -// Typed shapes for mpd-parser output (no bundled @types, so we define what we need) -export interface MpdSegment { - uri: string; - resolvedUri: string; - duration: number; - map?: { - uri: string; - resolvedUri: string; - byterange?: { offset: number | bigint; length: number | bigint }; - }; -} +// Re-export for callers that want the unified Fragment conversion. +export { parseLevelsPlaylist } from "./playlist-utils"; -export interface MpdPlaylist { - uri: string; - attributes: { - BANDWIDTH?: number; - RESOLUTION?: { width: number; height: number }; - CODECS?: string; - [key: string]: unknown; - }; - segments: MpdSegment[]; - contentProtection?: Record; -} +export type { MpdManifest } from "mpd-parser"; -export interface MpdManifest { - playlists: MpdPlaylist[]; - mediaGroups: { - AUDIO?: { - audio?: Record; - }; - }; - minimumUpdatePeriod?: number; // in ms; always present in mpd-parser output - [key: string]: unknown; +/** + * Convert an mpd-parser MpdPlaylist into a ParsedPlaylist. + */ +function mpdPlaylistToParsedPlaylist(playlist: MpdPlaylist): ParsedPlaylist { + const segments: ParsedSegment[] = (playlist.segments || []).map((segment) => ({ + uri: segment.resolvedUri || segment.uri, + ...(segment.map ? { initUri: segment.map.resolvedUri || segment.map.uri } : {}), + })); + return { segments }; } /** @@ -69,47 +52,31 @@ export function parseMasterPlaylist(mpdText: string, mpdUrl: string): Level[] { } /** - * Parse a single representation's segments into Fragment[]. - * - * Handles the init segment (segment.map) the same way m3u8-parser handles - * EXT-X-MAP: inserts it as a Fragment at the current index, deduplicated by URI. - * Indices are assigned sequentially from `startIndex`. - * Mirrors parseLevelsPlaylist() from m3u8-parser.ts. + * Select the highest-bandwidth video playlist from a parsed MPD manifest + * and return it as a ParsedPlaylist, ready for parseLevelsPlaylist(). + * Returns null if no video playlists are present. */ -export function parseLevelsPlaylist( - playlist: MpdPlaylist, - startIndex: number = 0, -): Fragment[] { - const fragments: Fragment[] = []; - const segments = playlist.segments || []; - let index = startIndex; - let currentMapUri: string | null = null; +export function getVideoPlaylist(manifest: MpdManifest): ParsedPlaylist | null { + const playlists = [...(manifest.playlists || [])]; + if (!playlists.length) return null; + playlists.sort((a, b) => (b.attributes?.BANDWIDTH || 0) - (a.attributes?.BANDWIDTH || 0)); + return mpdPlaylistToParsedPlaylist(playlists[0]!); +} - for (const segment of segments) { - // Handle init segment (like EXT-X-MAP) — deduplicate by URI - if (segment.map) { - const mapUri = segment.map.resolvedUri || segment.map.uri; - if (mapUri && mapUri !== currentMapUri) { - fragments.push({ - index, - key: { iv: null, uri: null }, - uri: mapUri, - }); - index++; - currentMapUri = mapUri; - } +/** + * Extract the first audio playlist from the parsed manifest's mediaGroups + * and return it as a ParsedPlaylist, ready for parseLevelsPlaylist(). + * Returns null if no audio adaptation set is present. + */ +export function getAudioPlaylist(manifest: MpdManifest): ParsedPlaylist | null { + const audioGroup = manifest.mediaGroups?.AUDIO?.audio; + if (!audioGroup) return null; + for (const group of Object.values(audioGroup)) { + if (group.playlists?.length) { + return mpdPlaylistToParsedPlaylist(group.playlists[0]!); } - - const segUri = segment.resolvedUri || segment.uri; - fragments.push({ - index, - key: { iv: null, uri: null }, - uri: segUri, - }); - index++; } - - return fragments; + return null; } /** @@ -140,27 +107,13 @@ export function getPollIntervalMs(mpdText: string): number { return Math.max(1000, Math.min(ms, 10000)); } -/** - * Extract the first audio playlist from the parsed manifest's mediaGroups. - * Returns null if no audio adaptation set is present. - */ -export function getAudioPlaylist(manifest: MpdManifest): MpdPlaylist | null { - const audioGroup = manifest.mediaGroups?.AUDIO?.audio; - if (!audioGroup) return null; - for (const group of Object.values(audioGroup)) { - if (group.playlists?.length) { - return group.playlists[0]!; - } - } - return null; -} - export const MpdParser = { parseManifest, parseMasterPlaylist, parseLevelsPlaylist, + getVideoPlaylist, + getAudioPlaylist, isLive, hasDrm, getPollIntervalMs, - getAudioPlaylist, }; diff --git a/src/core/utils/playlist-utils.ts b/src/core/utils/playlist-utils.ts new file mode 100644 index 0000000..29879fb --- /dev/null +++ b/src/core/utils/playlist-utils.ts @@ -0,0 +1,47 @@ +import { Fragment } from "../types"; +import type { ParsedPlaylist } from "../types"; + +/** + * Convert a ParsedPlaylist into a Fragment[] with sequential indices. + * + * Handles init segments (EXT-X-MAP for HLS, segment.map for DASH): inserts + * each distinct init URI as a Fragment before the first media segment that + * references it. Deduplicates by URI+byteRange so a shared init is only + * downloaded once per run. + * + * `startIndex` allows audio fragments to continue numbering from where + * video fragments left off (used by HLS/DASH dual-stream downloads). + */ +export function parseLevelsPlaylist( + playlist: ParsedPlaylist, + startIndex: number = 0, +): Fragment[] { + const fragments: Fragment[] = []; + let index = startIndex; + let currentInitUri: string | null = null; + let currentInitByteRange: string | null = null; + + for (const segment of playlist.segments) { + if (segment.initUri) { + const byteRange = segment.initByteRange ?? null; + if (segment.initUri !== currentInitUri || byteRange !== currentInitByteRange) { + fragments.push({ + index, + key: segment.key ?? { iv: null, uri: null }, + uri: segment.initUri, + }); + index++; + currentInitUri = segment.initUri; + currentInitByteRange = byteRange; + } + } + fragments.push({ + index, + key: segment.key ?? { iv: null, uri: null }, + uri: segment.uri, + }); + index++; + } + + return fragments; +} diff --git a/src/types/mpd-parser.d.ts b/src/types/mpd-parser.d.ts deleted file mode 100644 index b1a2179..0000000 --- a/src/types/mpd-parser.d.ts +++ /dev/null @@ -1,11 +0,0 @@ -declare module "mpd-parser" { - interface ParseOptions { - manifestUri?: string; - previousManifest?: unknown; - sidxMapping?: Record; - } - - function parse(manifestString: string, options?: ParseOptions): unknown; - - export { parse }; -} From d3382c916c576ce911dea3c5ada17961440ab465 Mon Sep 17 00:00:00 2001 From: Jvillegasd Date: Mon, 2 Mar 2026 04:00:57 -0500 Subject: [PATCH 06/13] refactor: colocate ParsedSegment/ParsedPlaylist/FetchFn with their owning modules Move ParsedSegment and ParsedPlaylist from core/types/index.ts to playlist-utils.ts, where they are produced and consumed. Move FetchFn to fetch-utils.ts as an unexported internal type. Update imports in m3u8-parser.ts and mpd-parser.ts accordingly. Co-Authored-By: Claude Sonnet 4.6 --- src/core/types/index.ts | 15 --------------- src/core/utils/fetch-utils.ts | 3 ++- src/core/utils/m3u8-parser.ts | 2 +- src/core/utils/mpd-parser.ts | 2 +- src/core/utils/playlist-utils.ts | 12 +++++++++++- 5 files changed, 15 insertions(+), 19 deletions(-) diff --git a/src/core/types/index.ts b/src/core/types/index.ts index 6f3401d..d7e3ecf 100644 --- a/src/core/types/index.ts +++ b/src/core/types/index.ts @@ -101,8 +101,6 @@ export interface MessageResponse { error?: string; } -export type FetchFn = () => Promise; - // HLS Playlist Types export type LevelType = "stream" | "audio"; @@ -126,16 +124,3 @@ export interface Level { width?: number; } -// Protocol-agnostic intermediate segment produced by parseMediaPlaylist (HLS) -// or getVideoPlaylist/getAudioPlaylist (DASH) before Fragment[] conversion. -export interface ParsedSegment { - uri: string; - initUri?: string; // EXT-X-MAP (HLS) or segment.map.resolvedUri (DASH) - initByteRange?: string; // "offset:length" — HLS only - key?: { iv: string | null; uri: string | null }; // HLS only -} - -// Protocol-agnostic playlist ready for Fragment[] conversion via parseLevelsPlaylist. -export interface ParsedPlaylist { - segments: ParsedSegment[]; -} diff --git a/src/core/utils/fetch-utils.ts b/src/core/utils/fetch-utils.ts index 6e2d1fa..e23bc6d 100644 --- a/src/core/utils/fetch-utils.ts +++ b/src/core/utils/fetch-utils.ts @@ -2,10 +2,11 @@ * Fetch utility functions with retry logic */ -import { FetchFn } from "../types"; import { MessageType } from "../../shared/messages"; import { INITIAL_RETRY_DELAY_MS, RETRY_BACKOFF_FACTOR } from "../../shared/constants"; +type FetchFn = () => Promise; + /** * Check if we're running in a service worker/background context */ diff --git a/src/core/utils/m3u8-parser.ts b/src/core/utils/m3u8-parser.ts index 558dbf4..a361b57 100644 --- a/src/core/utils/m3u8-parser.ts +++ b/src/core/utils/m3u8-parser.ts @@ -6,7 +6,7 @@ import { Parser } from "m3u8-parser"; import { buildAbsoluteURL } from "url-toolkit"; import { v4 as uuidv4 } from "uuid"; import { Level, LevelType } from "../types"; -import type { ParsedPlaylist, ParsedSegment } from "../types"; +import type { ParsedPlaylist, ParsedSegment } from "./playlist-utils"; import { normalizeUrl } from "./url-utils"; import { logger } from "./logger"; diff --git a/src/core/utils/mpd-parser.ts b/src/core/utils/mpd-parser.ts index 59d2aa9..704ab67 100644 --- a/src/core/utils/mpd-parser.ts +++ b/src/core/utils/mpd-parser.ts @@ -8,7 +8,7 @@ import { parse, MpdManifest, MpdPlaylist } from "mpd-parser"; import { v4 as uuidv4 } from "uuid"; import { Level, LevelType } from "../types"; -import type { ParsedPlaylist, ParsedSegment } from "../types"; +import type { ParsedPlaylist, ParsedSegment } from "./playlist-utils"; import { parseLevelsPlaylist } from "./playlist-utils"; // Re-export for callers that want the unified Fragment conversion. diff --git a/src/core/utils/playlist-utils.ts b/src/core/utils/playlist-utils.ts index 29879fb..d34ed24 100644 --- a/src/core/utils/playlist-utils.ts +++ b/src/core/utils/playlist-utils.ts @@ -1,5 +1,15 @@ import { Fragment } from "../types"; -import type { ParsedPlaylist } from "../types"; + +export interface ParsedSegment { + uri: string; + initUri?: string; // EXT-X-MAP (HLS) or segment.map.resolvedUri (DASH) + initByteRange?: string; // "offset:length" — HLS only + key?: { iv: string | null; uri: string | null }; // HLS only +} + +export interface ParsedPlaylist { + segments: ParsedSegment[]; +} /** * Convert a ParsedPlaylist into a Fragment[] with sequential indices. From 5295bf58914edad441fd75c102a205f4cd0f7f76 Mon Sep 17 00:00:00 2001 From: Jvillegasd Date: Mon, 2 Mar 2026 04:02:43 -0500 Subject: [PATCH 07/13] refactor: move mpd-parser ambient shim to src/types/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Relocate src/core/types/parser.d.ts → src/types/mpd-parser.d.ts to separate vendor type shims from project domain types. Co-Authored-By: Claude Sonnet 4.6 --- src/{core/types/parser.d.ts => types/mpd-parser.d.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/{core/types/parser.d.ts => types/mpd-parser.d.ts} (100%) diff --git a/src/core/types/parser.d.ts b/src/types/mpd-parser.d.ts similarity index 100% rename from src/core/types/parser.d.ts rename to src/types/mpd-parser.d.ts From 223796cd556ff7080e17567bce9ee54549c557bd Mon Sep 17 00:00:00 2001 From: Jvillegasd Date: Mon, 2 Mar 2026 04:21:48 -0500 Subject: [PATCH 08/13] =?UTF-8?q?refactor:=20reorganize=20core/utils/=20?= =?UTF-8?q?=E2=80=94=20move=209=20files=20to=20domain-specific=20dirs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reduces src/core/utils/ from 20 to 11 files by relocating subsystem-specific modules to the directories that own them: core/ffmpeg/ ← ffmpeg-bridge, ffmpeg-singleton, offscreen-manager core/parsers/ ← m3u8-parser, mpd-parser, playlist-utils core/detection/ ← thumbnail-utils core/downloader/ ← crypto-utils, header-rules No logic changes — pure file-move + import-update refactor. Co-Authored-By: Claude Sonnet 4.6 --- src/core/detection/dash/dash-detection-handler.ts | 4 ++-- src/core/detection/direct/direct-detection-handler.ts | 2 +- src/core/detection/hls/hls-detection-handler.ts | 4 ++-- src/core/{utils => detection}/thumbnail-utils.ts | 1 - src/core/downloader/base-playlist-handler.ts | 6 +++--- src/core/downloader/base-recording-handler.ts | 2 +- src/core/{utils => downloader}/crypto-utils.ts | 4 ++-- src/core/downloader/dash/dash-download-handler.ts | 4 ++-- src/core/downloader/dash/dash-recording-handler.ts | 2 +- src/core/{utils => downloader}/header-rules.ts | 2 +- src/core/downloader/hls/hls-download-handler.ts | 4 ++-- src/core/downloader/hls/hls-recording-handler.ts | 2 +- src/core/downloader/m3u8/m3u8-download-handler.ts | 4 ++-- src/core/{utils => ffmpeg}/ffmpeg-bridge.ts | 4 ++-- src/core/{utils => ffmpeg}/ffmpeg-singleton.ts | 2 +- src/core/{utils => ffmpeg}/offscreen-manager.ts | 2 +- src/core/{utils => parsers}/m3u8-parser.ts | 4 ++-- src/core/{utils => parsers}/mpd-parser.ts | 0 src/core/{utils => parsers}/playlist-utils.ts | 0 src/popup/render-manifest.ts | 2 +- src/service-worker.ts | 2 +- 21 files changed, 28 insertions(+), 29 deletions(-) rename src/core/{utils => detection}/thumbnail-utils.ts (99%) rename src/core/{utils => downloader}/crypto-utils.ts (96%) rename src/core/{utils => downloader}/header-rules.ts (98%) rename src/core/{utils => ffmpeg}/ffmpeg-bridge.ts (98%) rename src/core/{utils => ffmpeg}/ffmpeg-singleton.ts (96%) rename src/core/{utils => ffmpeg}/offscreen-manager.ts (97%) rename src/core/{utils => parsers}/m3u8-parser.ts (98%) rename src/core/{utils => parsers}/mpd-parser.ts (100%) rename src/core/{utils => parsers}/playlist-utils.ts (100%) diff --git a/src/core/detection/dash/dash-detection-handler.ts b/src/core/detection/dash/dash-detection-handler.ts index 155cd6c..29075ad 100644 --- a/src/core/detection/dash/dash-detection-handler.ts +++ b/src/core/detection/dash/dash-detection-handler.ts @@ -12,8 +12,8 @@ import { VideoMetadata, VideoFormat } from "../../types"; import { fetchText } from "../../utils/fetch-utils"; import { normalizeUrl } from "../../utils/url-utils"; import { logger } from "../../utils/logger"; -import { extractThumbnail } from "../../utils/thumbnail-utils"; -import { isLive, hasDrm } from "../../utils/mpd-parser"; +import { extractThumbnail } from "../thumbnail-utils"; +import { isLive, hasDrm } from "../../parsers/mpd-parser"; export interface DashDetectionHandlerOptions { onVideoDetected?: (video: VideoMetadata) => void; diff --git a/src/core/detection/direct/direct-detection-handler.ts b/src/core/detection/direct/direct-detection-handler.ts index 90bad85..426fb23 100644 --- a/src/core/detection/direct/direct-detection-handler.ts +++ b/src/core/detection/direct/direct-detection-handler.ts @@ -25,7 +25,7 @@ import { VideoMetadata, VideoFormat } from "../../types"; import { detectFormatFromUrl } from "../../utils/url-utils"; -import { extractThumbnail } from "../../utils/thumbnail-utils"; +import { extractThumbnail } from "../thumbnail-utils"; const DOM_SCAN_DEBOUNCE_MS = 1000; const MAX_HEADING_SEARCH_DEPTH = 3; diff --git a/src/core/detection/hls/hls-detection-handler.ts b/src/core/detection/hls/hls-detection-handler.ts index 661997c..b7c9067 100644 --- a/src/core/detection/hls/hls-detection-handler.ts +++ b/src/core/detection/hls/hls-detection-handler.ts @@ -30,11 +30,11 @@ import { isMasterPlaylist, isMediaPlaylist, parseMasterPlaylist, -} from "../../utils/m3u8-parser"; +} from "../../parsers/m3u8-parser"; import { fetchText } from "../../utils/fetch-utils"; import { normalizeUrl } from "../../utils/url-utils"; import { logger } from "../../utils/logger"; -import { extractThumbnail } from "../../utils/thumbnail-utils"; +import { extractThumbnail } from "../thumbnail-utils"; import { hasDrm, canDecrypt } from "../../utils/drm-utils"; /** Configuration options for HlsDetectionHandler */ diff --git a/src/core/utils/thumbnail-utils.ts b/src/core/detection/thumbnail-utils.ts similarity index 99% rename from src/core/utils/thumbnail-utils.ts rename to src/core/detection/thumbnail-utils.ts index fd50714..58dbda9 100644 --- a/src/core/utils/thumbnail-utils.ts +++ b/src/core/detection/thumbnail-utils.ts @@ -82,4 +82,3 @@ function extractThumbnailFromPage(): string | undefined { return undefined; } - diff --git a/src/core/downloader/base-playlist-handler.ts b/src/core/downloader/base-playlist-handler.ts index d7d6b28..789ca5a 100644 --- a/src/core/downloader/base-playlist-handler.ts +++ b/src/core/downloader/base-playlist-handler.ts @@ -10,16 +10,16 @@ import { cancelIfAborted, throwIfAborted } from "../utils/cancellation"; import { getDownload, storeDownload } from "../database/downloads"; import { DownloadState, Fragment, DownloadStage } from "../types"; import { logger } from "../utils/logger"; -import { decryptFragment } from "../utils/crypto-utils"; +import { decryptFragment } from "./crypto-utils"; import { fetchArrayBuffer, fetchText } from "../utils/fetch-utils"; import { storeChunk, deleteChunks, getChunkCount } from "../database/chunks"; import { sanitizeFilename } from "../utils/file-utils"; import { formatFileSize } from "../utils/format-utils"; import { DownloadProgressCallback } from "./types"; import { MessageType } from "../../shared/messages"; -import { processWithFFmpeg } from "../utils/ffmpeg-bridge"; +import { processWithFFmpeg } from "../ffmpeg/ffmpeg-bridge"; import { saveBlobUrlToFile } from "../utils/blob-utils"; -import { addHeaderRules, removeHeaderRules } from "../utils/header-rules"; +import { addHeaderRules, removeHeaderRules } from "./header-rules"; import { DEFAULT_MAX_CONCURRENT, DEFAULT_FFMPEG_TIMEOUT_MS, diff --git a/src/core/downloader/base-recording-handler.ts b/src/core/downloader/base-recording-handler.ts index 9d86fb6..f407993 100644 --- a/src/core/downloader/base-recording-handler.ts +++ b/src/core/downloader/base-recording-handler.ts @@ -15,7 +15,7 @@ import { getDownload, storeDownload } from "../database/downloads"; import { logger } from "../utils/logger"; import { MessageType } from "../../shared/messages"; import { saveBlobUrlToFile } from "../utils/blob-utils"; -import { processWithFFmpeg } from "../utils/ffmpeg-bridge"; +import { processWithFFmpeg } from "../ffmpeg/ffmpeg-bridge"; import { BasePlaylistHandler } from "./base-playlist-handler"; import { runConcurrentWorkers } from "./concurrent-workers"; import { SAVING_STAGE_PERCENTAGE } from "../../shared/constants"; diff --git a/src/core/utils/crypto-utils.ts b/src/core/downloader/crypto-utils.ts similarity index 96% rename from src/core/utils/crypto-utils.ts rename to src/core/downloader/crypto-utils.ts index 6b39d2c..0f84481 100644 --- a/src/core/utils/crypto-utils.ts +++ b/src/core/downloader/crypto-utils.ts @@ -3,8 +3,8 @@ * Supports AES-CBC decryption for encrypted video segments (e.g., HLS with AES-128) */ -import { fetchArrayBuffer } from "./fetch-utils"; -import { logger } from "./logger"; +import { fetchArrayBuffer } from "../utils/fetch-utils"; +import { logger } from "../utils/logger"; /** Encryption key information for fragment decryption */ export interface FragmentKey { diff --git a/src/core/downloader/dash/dash-download-handler.ts b/src/core/downloader/dash/dash-download-handler.ts index 89789a4..429f336 100644 --- a/src/core/downloader/dash/dash-download-handler.ts +++ b/src/core/downloader/dash/dash-download-handler.ts @@ -18,7 +18,7 @@ import { DownloadStage } from "../../types"; import { logger } from "../../utils/logger"; import { getChunkCount } from "../../database/chunks"; import { MessageType } from "../../../shared/messages"; -import { processWithFFmpeg } from "../../utils/ffmpeg-bridge"; +import { processWithFFmpeg } from "../../ffmpeg/ffmpeg-bridge"; import { saveBlobUrlToFile } from "../../utils/blob-utils"; import { BasePlaylistHandler } from "../base-playlist-handler"; import { SAVING_STAGE_PERCENTAGE } from "../../../shared/constants"; @@ -29,7 +29,7 @@ import { hasDrm, getVideoPlaylist, getAudioPlaylist, -} from "../../utils/mpd-parser"; +} from "../../parsers/mpd-parser"; export class DashDownloadHandler extends BasePlaylistHandler { private videoLength: number = 0; diff --git a/src/core/downloader/dash/dash-recording-handler.ts b/src/core/downloader/dash/dash-recording-handler.ts index 0e6caba..7748274 100644 --- a/src/core/downloader/dash/dash-recording-handler.ts +++ b/src/core/downloader/dash/dash-recording-handler.ts @@ -20,7 +20,7 @@ import { isLive, getPollIntervalMs, getVideoPlaylist, -} from "../../utils/mpd-parser"; +} from "../../parsers/mpd-parser"; export class DashRecordingHandler extends BaseRecordingHandler { /** diff --git a/src/core/utils/header-rules.ts b/src/core/downloader/header-rules.ts similarity index 98% rename from src/core/utils/header-rules.ts rename to src/core/downloader/header-rules.ts index 4fd9376..e17e353 100644 --- a/src/core/utils/header-rules.ts +++ b/src/core/downloader/header-rules.ts @@ -7,7 +7,7 @@ * network layer for requests matching a specific URL prefix. */ -import { logger } from "./logger"; +import { logger } from "../utils/logger"; /** * Derive two deterministic rule IDs from a download ID string. diff --git a/src/core/downloader/hls/hls-download-handler.ts b/src/core/downloader/hls/hls-download-handler.ts index 19984df..3e3b892 100644 --- a/src/core/downloader/hls/hls-download-handler.ts +++ b/src/core/downloader/hls/hls-download-handler.ts @@ -16,10 +16,10 @@ import { parseMasterPlaylist, parseMediaPlaylist, parseLevelsPlaylist, -} from "../../utils/m3u8-parser"; +} from "../../parsers/m3u8-parser"; import { getChunkCount } from "../../database/chunks"; import { MessageType } from "../../../shared/messages"; -import { processWithFFmpeg } from "../../utils/ffmpeg-bridge"; +import { processWithFFmpeg } from "../../ffmpeg/ffmpeg-bridge"; import { canDownloadHLSManifest } from "../../utils/drm-utils"; import { saveBlobUrlToFile } from "../../utils/blob-utils"; import { BasePlaylistHandler } from "../base-playlist-handler"; diff --git a/src/core/downloader/hls/hls-recording-handler.ts b/src/core/downloader/hls/hls-recording-handler.ts index afb73d9..fe9b54f 100644 --- a/src/core/downloader/hls/hls-recording-handler.ts +++ b/src/core/downloader/hls/hls-recording-handler.ts @@ -17,7 +17,7 @@ import { parseMasterPlaylist, parseMediaPlaylist, parseLevelsPlaylist, -} from "../../utils/m3u8-parser"; +} from "../../parsers/m3u8-parser"; import { logger } from "../../utils/logger"; import { MessageType } from "../../../shared/messages"; import { BaseRecordingHandler } from "../base-recording-handler"; diff --git a/src/core/downloader/m3u8/m3u8-download-handler.ts b/src/core/downloader/m3u8/m3u8-download-handler.ts index e4d29f6..8bf6bbe 100644 --- a/src/core/downloader/m3u8/m3u8-download-handler.ts +++ b/src/core/downloader/m3u8/m3u8-download-handler.ts @@ -11,10 +11,10 @@ import { CancellationError } from "../../utils/errors"; import { DownloadStage } from "../../types"; import { logger } from "../../utils/logger"; -import { parseMediaPlaylist, parseLevelsPlaylist } from "../../utils/m3u8-parser"; +import { parseMediaPlaylist, parseLevelsPlaylist } from "../../parsers/m3u8-parser"; import { getChunkCount } from "../../database/chunks"; import { MessageType } from "../../../shared/messages"; -import { processWithFFmpeg } from "../../utils/ffmpeg-bridge"; +import { processWithFFmpeg } from "../../ffmpeg/ffmpeg-bridge"; import { throwIfAborted } from "../../utils/cancellation"; import { canDownloadHLSManifest } from "../../utils/drm-utils"; import { saveBlobUrlToFile } from "../../utils/blob-utils"; diff --git a/src/core/utils/ffmpeg-bridge.ts b/src/core/ffmpeg/ffmpeg-bridge.ts similarity index 98% rename from src/core/utils/ffmpeg-bridge.ts rename to src/core/ffmpeg/ffmpeg-bridge.ts index f522ff6..8ff1618 100644 --- a/src/core/utils/ffmpeg-bridge.ts +++ b/src/core/ffmpeg/ffmpeg-bridge.ts @@ -6,9 +6,9 @@ * duplicated streamToMp4Blob / mergeToMp4 methods across handlers. */ -import { CancellationError } from "./errors"; +import { CancellationError } from "../utils/errors"; import { createOffscreenDocument } from "./offscreen-manager"; -import { revokeBlobUrl } from "./blob-utils"; +import { revokeBlobUrl } from "../utils/blob-utils"; import { MessageType } from "../../shared/messages"; export interface ProcessWithFFmpegOptions { diff --git a/src/core/utils/ffmpeg-singleton.ts b/src/core/ffmpeg/ffmpeg-singleton.ts similarity index 96% rename from src/core/utils/ffmpeg-singleton.ts rename to src/core/ffmpeg/ffmpeg-singleton.ts index 31f2d61..5fcf90e 100644 --- a/src/core/utils/ffmpeg-singleton.ts +++ b/src/core/ffmpeg/ffmpeg-singleton.ts @@ -3,7 +3,7 @@ */ import { FFmpeg } from "@ffmpeg/ffmpeg"; -import { logger } from "./logger"; +import { logger } from "../utils/logger"; /** * Singleton FFmpeg instance diff --git a/src/core/utils/offscreen-manager.ts b/src/core/ffmpeg/offscreen-manager.ts similarity index 97% rename from src/core/utils/offscreen-manager.ts rename to src/core/ffmpeg/offscreen-manager.ts index 436e06b..cc02ed5 100644 --- a/src/core/utils/offscreen-manager.ts +++ b/src/core/ffmpeg/offscreen-manager.ts @@ -3,7 +3,7 @@ * Creates and manages the offscreen document lifecycle */ -import { logger } from "./logger"; +import { logger } from "../utils/logger"; const OFFSCREEN_DOCUMENT_PATH = "offscreen/offscreen.html"; const OFFSCREEN_LOAD_DELAY_MS = 100; diff --git a/src/core/utils/m3u8-parser.ts b/src/core/parsers/m3u8-parser.ts similarity index 98% rename from src/core/utils/m3u8-parser.ts rename to src/core/parsers/m3u8-parser.ts index a361b57..3eb30fe 100644 --- a/src/core/utils/m3u8-parser.ts +++ b/src/core/parsers/m3u8-parser.ts @@ -7,8 +7,8 @@ import { buildAbsoluteURL } from "url-toolkit"; import { v4 as uuidv4 } from "uuid"; import { Level, LevelType } from "../types"; import type { ParsedPlaylist, ParsedSegment } from "./playlist-utils"; -import { normalizeUrl } from "./url-utils"; -import { logger } from "./logger"; +import { normalizeUrl } from "../utils/url-utils"; +import { logger } from "../utils/logger"; export { parseLevelsPlaylist } from "./playlist-utils"; import { parseLevelsPlaylist } from "./playlist-utils"; diff --git a/src/core/utils/mpd-parser.ts b/src/core/parsers/mpd-parser.ts similarity index 100% rename from src/core/utils/mpd-parser.ts rename to src/core/parsers/mpd-parser.ts diff --git a/src/core/utils/playlist-utils.ts b/src/core/parsers/playlist-utils.ts similarity index 100% rename from src/core/utils/playlist-utils.ts rename to src/core/parsers/playlist-utils.ts diff --git a/src/popup/render-manifest.ts b/src/popup/render-manifest.ts index a86ac34..6151ffa 100644 --- a/src/popup/render-manifest.ts +++ b/src/popup/render-manifest.ts @@ -4,7 +4,7 @@ import { VideoMetadata, DownloadStage, VideoFormat } from "../core/types"; import { normalizeUrl, detectFormatFromUrl } from "../core/utils/url-utils"; -import { parseMasterPlaylist, isMasterPlaylist, isMediaPlaylist } from "../core/utils/m3u8-parser"; +import { parseMasterPlaylist, isMasterPlaylist, isMediaPlaylist } from "../core/parsers/m3u8-parser"; import { hasDrm, canDecrypt } from "../core/utils/drm-utils"; import { MessageType } from "../shared/messages"; import { diff --git a/src/service-worker.ts b/src/service-worker.ts index 30a3e5d..c818e1a 100644 --- a/src/service-worker.ts +++ b/src/service-worker.ts @@ -35,7 +35,7 @@ import { generateFilenameFromTabInfo, } from "./core/utils/file-utils"; import { deleteChunks, getChunkCount, getAllChunkDownloadIds } from "./core/database/chunks"; -import { createOffscreenDocument, closeOffscreenDocument } from "./core/utils/offscreen-manager"; +import { createOffscreenDocument, closeOffscreenDocument } from "./core/ffmpeg/offscreen-manager"; import { DEFAULT_MAX_CONCURRENT, DEFAULT_FFMPEG_TIMEOUT_MS, From a4083b73b0c73ecee90294f6fab035661fe6c016 Mon Sep 17 00:00:00 2001 From: Jvillegasd Date: Mon, 2 Mar 2026 05:07:42 -0500 Subject: [PATCH 09/13] fix: load MPD manifest in manifest tab --- src/popup/render-downloads.ts | 12 +- src/popup/render-manifest.ts | 202 ++++++++++++++++++++-------------- src/popup/render-videos.ts | 8 +- src/popup/state.ts | 4 +- 4 files changed, 138 insertions(+), 88 deletions(-) diff --git a/src/popup/render-downloads.ts b/src/popup/render-downloads.ts index b2b8e46..35a1599 100644 --- a/src/popup/render-downloads.ts +++ b/src/popup/render-downloads.ts @@ -47,7 +47,9 @@ function updateDownloadCardProgress(card: HTMLElement, download: DownloadState): const isRecording = stage === DownloadStage.RECORDING; const isManifestDownload = - (download.metadata.format === VideoFormat.HLS || download.metadata.format === VideoFormat.M3U8) && + (download.metadata.format === VideoFormat.HLS || + download.metadata.format === VideoFormat.M3U8 || + download.metadata.format === VideoFormat.DASH) && (stage === DownloadStage.DOWNLOADING || stage === DownloadStage.MERGING); if (isRecording) { @@ -174,7 +176,8 @@ function renderDownloadItem(download: DownloadState): string { const isRecording = stage === DownloadStage.RECORDING; const isManifestDownload = (download.metadata.format === VideoFormat.HLS || - download.metadata.format === VideoFormat.M3U8) && + download.metadata.format === VideoFormat.M3U8 || + download.metadata.format === VideoFormat.DASH) && (stage === DownloadStage.DOWNLOADING || stage === DownloadStage.MERGING); if (isRecording) { @@ -287,7 +290,10 @@ function renderDownloadItem(download: DownloadState): string { `; } else { const isDownloading = download.progress.stage === DownloadStage.DOWNLOADING; - const isManifestType = download.metadata.format === VideoFormat.HLS || download.metadata.format === VideoFormat.M3U8; + const isManifestType = + download.metadata.format === VideoFormat.HLS || + download.metadata.format === VideoFormat.M3U8 || + download.metadata.format === VideoFormat.DASH; actionButtons = `
${isDownloading && isManifestType ? `` : ``} diff --git a/src/popup/render-manifest.ts b/src/popup/render-manifest.ts index 6151ffa..df273b6 100644 --- a/src/popup/render-manifest.ts +++ b/src/popup/render-manifest.ts @@ -6,6 +6,7 @@ import { VideoMetadata, DownloadStage, VideoFormat } from "../core/types"; import { normalizeUrl, detectFormatFromUrl } from "../core/utils/url-utils"; import { parseMasterPlaylist, isMasterPlaylist, isMediaPlaylist } from "../core/parsers/m3u8-parser"; import { hasDrm, canDecrypt } from "../core/utils/drm-utils"; +import { MpdParser } from "../core/parsers/mpd-parser"; import { MessageType } from "../shared/messages"; import { dom, @@ -21,6 +22,8 @@ import { setCurrentManualManifestUrl, setHasDrmInManifest, setUnsupportedManifest, + currentManifestFormat, + setCurrentManifestFormat, } from "./state"; import { fetchTextViaBackground, formatQualityLabel } from "./utils"; import { renderDownloads } from "./render-downloads"; @@ -130,7 +133,7 @@ export async function handleLoadManifestPlaylist(): Promise { const normalizedUrl = normalizeUrl(rawUrl); const format = detectFormatFromUrl(normalizedUrl); - if (format !== VideoFormat.HLS) { + if (format !== VideoFormat.HLS && format !== VideoFormat.DASH) { alert("Please enter a valid manifest URL (.m3u8 or .mpd)"); return; } @@ -152,106 +155,139 @@ export async function handleLoadManifestPlaylist(): Promise { try { const playlistText = await fetchTextViaBackground(normalizedUrl); + setCurrentManifestFormat(format); + + if (format === VideoFormat.DASH) { + setHasDrmInManifest(MpdParser.hasDrm(playlistText)); + setUnsupportedManifest(false); + + if (hasDrmInManifest) { + if (dom.manifestDrmWarning) dom.manifestDrmWarning.style.display = "block"; + if (dom.manifestUnsupportedWarning) dom.manifestUnsupportedWarning.style.display = "none"; + if (dom.manifestMediaPlaylistWarning) dom.manifestMediaPlaylistWarning.style.display = "none"; + if (dom.manifestLiveStreamInfo) dom.manifestLiveStreamInfo.style.display = "none"; + if (dom.manifestQualitySelection) dom.manifestQualitySelection.style.display = "none"; + if (dom.startManifestDownloadBtn) dom.startManifestDownloadBtn.disabled = true; + updateManualManifestFormState(); + return; + } - setHasDrmInManifest(hasDrm(playlistText)); - setUnsupportedManifest(!canDecrypt(playlistText)); - - if (hasDrmInManifest) { - if (dom.manifestDrmWarning) dom.manifestDrmWarning.style.display = "block"; - if (dom.manifestUnsupportedWarning) dom.manifestUnsupportedWarning.style.display = "none"; - if (dom.manifestMediaPlaylistWarning) dom.manifestMediaPlaylistWarning.style.display = "none"; - if (dom.manifestLiveStreamInfo) dom.manifestLiveStreamInfo.style.display = "none"; - if (dom.manifestQualitySelection) dom.manifestQualitySelection.style.display = "none"; - if (dom.startManifestDownloadBtn) dom.startManifestDownloadBtn.disabled = true; - updateManualManifestFormState(); - return; - } + const live = MpdParser.isLive(playlistText); + setIsLiveManifest(live); + setIsMediaPlaylistMode(true); // DASH auto-selects quality — no quality picker needed - if (unsupportedManifest) { - if (dom.manifestUnsupportedWarning) dom.manifestUnsupportedWarning.style.display = "block"; - if (dom.manifestDrmWarning) dom.manifestDrmWarning.style.display = "none"; if (dom.manifestMediaPlaylistWarning) dom.manifestMediaPlaylistWarning.style.display = "none"; - if (dom.manifestLiveStreamInfo) dom.manifestLiveStreamInfo.style.display = "none"; if (dom.manifestQualitySelection) dom.manifestQualitySelection.style.display = "none"; - if (dom.startManifestDownloadBtn) dom.startManifestDownloadBtn.disabled = true; - updateManualManifestFormState(); - return; - } - - if (isMediaPlaylist(playlistText)) { - setIsMediaPlaylistMode(true); - setIsLiveManifest(!playlistText.includes("#EXT-X-ENDLIST")); - if (dom.manifestMediaPlaylistWarning) { - dom.manifestMediaPlaylistWarning.style.display = isLiveManifest ? "none" : "block"; - } if (dom.manifestLiveStreamInfo) { - dom.manifestLiveStreamInfo.style.display = isLiveManifest ? "block" : "none"; - const infoText = document.getElementById("hlsLiveStreamInfoText"); - if (infoText) { - infoText.textContent = "This is a live stream. Click Record to start capturing the stream."; + dom.manifestLiveStreamInfo.style.display = live ? "block" : "none"; + if (live) { + const infoText = document.getElementById("hlsLiveStreamInfoText"); + if (infoText) { + infoText.textContent = "This is a live DASH stream. Click Record to start capturing."; + } } } - if (dom.manifestQualitySelection) { - dom.manifestQualitySelection.style.display = "none"; + } else { + setHasDrmInManifest(hasDrm(playlistText)); + setUnsupportedManifest(!canDecrypt(playlistText)); + + if (hasDrmInManifest) { + if (dom.manifestDrmWarning) dom.manifestDrmWarning.style.display = "block"; + if (dom.manifestUnsupportedWarning) dom.manifestUnsupportedWarning.style.display = "none"; + if (dom.manifestMediaPlaylistWarning) dom.manifestMediaPlaylistWarning.style.display = "none"; + if (dom.manifestLiveStreamInfo) dom.manifestLiveStreamInfo.style.display = "none"; + if (dom.manifestQualitySelection) dom.manifestQualitySelection.style.display = "none"; + if (dom.startManifestDownloadBtn) dom.startManifestDownloadBtn.disabled = true; + updateManualManifestFormState(); + return; } - } else if (isMasterPlaylist(playlistText)) { - setIsMediaPlaylistMode(false); - if (dom.manifestMediaPlaylistWarning) dom.manifestMediaPlaylistWarning.style.display = "none"; - const { videoQualitySelect, audioQualitySelect, manifestQualitySelection } = dom; - if (manifestQualitySelection && videoQualitySelect && audioQualitySelect) { - manifestQualitySelection.style.display = "block"; - const levels = parseMasterPlaylist(playlistText, normalizedUrl); - const videoLevels = levels.filter((level) => level.type === "stream"); - const audioLevels = levels.filter((level) => level.type === "audio"); + if (unsupportedManifest) { + if (dom.manifestUnsupportedWarning) dom.manifestUnsupportedWarning.style.display = "block"; + if (dom.manifestDrmWarning) dom.manifestDrmWarning.style.display = "none"; + if (dom.manifestMediaPlaylistWarning) dom.manifestMediaPlaylistWarning.style.display = "none"; + if (dom.manifestLiveStreamInfo) dom.manifestLiveStreamInfo.style.display = "none"; + if (dom.manifestQualitySelection) dom.manifestQualitySelection.style.display = "none"; + if (dom.startManifestDownloadBtn) dom.startManifestDownloadBtn.disabled = true; + updateManualManifestFormState(); + return; + } - setIsLiveManifest(false); - if (videoLevels.length > 0) { - try { - const variantText = await fetchTextViaBackground(videoLevels[0]!.uri); - setIsLiveManifest(!variantText.includes("#EXT-X-ENDLIST")); - } catch {} + if (isMediaPlaylist(playlistText)) { + setIsMediaPlaylistMode(true); + setIsLiveManifest(!playlistText.includes("#EXT-X-ENDLIST")); + if (dom.manifestMediaPlaylistWarning) { + dom.manifestMediaPlaylistWarning.style.display = isLiveManifest ? "none" : "block"; } - if (dom.manifestLiveStreamInfo) { dom.manifestLiveStreamInfo.style.display = isLiveManifest ? "block" : "none"; const infoText = document.getElementById("hlsLiveStreamInfoText"); if (infoText) { - infoText.textContent = "This is a live stream. Select a quality and click Record to start capturing the stream."; + infoText.textContent = "This is a live stream. Click Record to start capturing the stream."; } } - - videoQualitySelect.innerHTML = ''; - videoLevels.forEach((level, index) => { - const option = document.createElement("option"); - option.value = level.uri; - option.textContent = formatQualityLabel(level); - option.setAttribute("data-level-index", index.toString()); - videoQualitySelect!.appendChild(option); - }); - videoQualitySelect.disabled = false; - - audioQualitySelect.innerHTML = ''; - audioLevels.forEach((level, index) => { - const option = document.createElement("option"); - option.value = level.uri; - option.textContent = level.id; - option.setAttribute("data-level-index", index.toString()); - audioQualitySelect!.appendChild(option); - }); - audioQualitySelect.disabled = false; - - if (videoLevels.length > 0 && videoQualitySelect.options.length > 1) { - videoQualitySelect.selectedIndex = 1; - } - if (audioLevels.length > 0 && audioQualitySelect.options.length > 1) { - audioQualitySelect.selectedIndex = 1; + if (dom.manifestQualitySelection) { + dom.manifestQualitySelection.style.display = "none"; } + } else if (isMasterPlaylist(playlistText)) { + setIsMediaPlaylistMode(false); + if (dom.manifestMediaPlaylistWarning) dom.manifestMediaPlaylistWarning.style.display = "none"; + const { videoQualitySelect, audioQualitySelect, manifestQualitySelection } = dom; + if (manifestQualitySelection && videoQualitySelect && audioQualitySelect) { + manifestQualitySelection.style.display = "block"; + + const levels = parseMasterPlaylist(playlistText, normalizedUrl); + const videoLevels = levels.filter((level) => level.type === "stream"); + const audioLevels = levels.filter((level) => level.type === "audio"); + + setIsLiveManifest(false); + if (videoLevels.length > 0) { + try { + const variantText = await fetchTextViaBackground(videoLevels[0]!.uri); + setIsLiveManifest(!variantText.includes("#EXT-X-ENDLIST")); + } catch {} + } - updateDownloadButtonState(); + if (dom.manifestLiveStreamInfo) { + dom.manifestLiveStreamInfo.style.display = isLiveManifest ? "block" : "none"; + const infoText = document.getElementById("hlsLiveStreamInfoText"); + if (infoText) { + infoText.textContent = "This is a live stream. Select a quality and click Record to start capturing the stream."; + } + } + + videoQualitySelect.innerHTML = ''; + videoLevels.forEach((level, index) => { + const option = document.createElement("option"); + option.value = level.uri; + option.textContent = formatQualityLabel(level); + option.setAttribute("data-level-index", index.toString()); + videoQualitySelect!.appendChild(option); + }); + videoQualitySelect.disabled = false; + + audioQualitySelect.innerHTML = ''; + audioLevels.forEach((level, index) => { + const option = document.createElement("option"); + option.value = level.uri; + option.textContent = level.id; + option.setAttribute("data-level-index", index.toString()); + audioQualitySelect!.appendChild(option); + }); + audioQualitySelect.disabled = false; + + if (videoLevels.length > 0 && videoQualitySelect.options.length > 1) { + videoQualitySelect.selectedIndex = 1; + } + if (audioLevels.length > 0 && audioQualitySelect.options.length > 1) { + audioQualitySelect.selectedIndex = 1; + } + + updateDownloadButtonState(); + } + } else { + throw new Error("Invalid playlist format"); } - } else { - throw new Error("Invalid playlist format"); } updateManualManifestFormState(); @@ -339,7 +375,9 @@ export async function handleStartManifestDownload(): Promise { const metadata: VideoMetadata = { url: playlistUrl, - format: isMediaPlaylistMode ? VideoFormat.M3U8 : VideoFormat.HLS, + format: currentManifestFormat === VideoFormat.DASH + ? VideoFormat.DASH + : (isMediaPlaylistMode ? VideoFormat.M3U8 : VideoFormat.HLS), title: tabTitle || "Manifest Video", pageUrl: pageUrl || window.location.href, isLive: isLiveManifest, diff --git a/src/popup/render-videos.ts b/src/popup/render-videos.ts index 315fd46..bb4f2b4 100644 --- a/src/popup/render-videos.ts +++ b/src/popup/render-videos.ts @@ -63,7 +63,9 @@ function updateVideoCardProgress(card: HTMLElement, video: VideoMetadata): boole } const isManifestDownload = - (video.format === VideoFormat.HLS || video.format === VideoFormat.M3U8) && + (video.format === VideoFormat.HLS || + video.format === VideoFormat.M3U8 || + video.format === VideoFormat.DASH) && (stage === DownloadStage.DOWNLOADING || stage === DownloadStage.MERGING); if (isManifestDownload && stage === DownloadStage.DOWNLOADING) { @@ -357,7 +359,9 @@ function renderVideoItem(video: VideoMetadata): string { } const isManifestDownload = - (video.format === VideoFormat.HLS || video.format === VideoFormat.M3U8) && + (video.format === VideoFormat.HLS || + video.format === VideoFormat.M3U8 || + video.format === VideoFormat.DASH) && (stage === DownloadStage.DOWNLOADING || stage === DownloadStage.MERGING); if (stage === DownloadStage.RECORDING) { diff --git a/src/popup/state.ts b/src/popup/state.ts index e3063e6..44acab8 100644 --- a/src/popup/state.ts +++ b/src/popup/state.ts @@ -6,7 +6,7 @@ * and the manifest tab. */ -import { DownloadState, VideoMetadata } from "../core/types"; +import { DownloadState, VideoFormat, VideoMetadata } from "../core/types"; import { getAllDownloads } from "../core/database/downloads"; // ---- Detected videos ---- @@ -60,9 +60,11 @@ export let isLiveManifest = false; export let currentManualManifestUrl: string | null = null; export let hasDrmInManifest = false; export let unsupportedManifest = false; +export let currentManifestFormat: VideoFormat = VideoFormat.HLS; export function setIsMediaPlaylistMode(v: boolean): void { isMediaPlaylistMode = v; } export function setIsLiveManifest(v: boolean): void { isLiveManifest = v; } export function setCurrentManualManifestUrl(v: string | null): void { currentManualManifestUrl = v; } export function setHasDrmInManifest(v: boolean): void { hasDrmInManifest = v; } export function setUnsupportedManifest(v: boolean): void { unsupportedManifest = v; } +export function setCurrentManifestFormat(v: VideoFormat): void { currentManifestFormat = v; } From a27999ce1e3bed81eb474a210743607f54b127c2 Mon Sep 17 00:00:00 2001 From: Jvillegasd Date: Mon, 2 Mar 2026 12:09:51 -0500 Subject: [PATCH 10/13] feat: DASH quality selection for VOD and live recording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show a quality picker in the Manifest tab when a DASH MPD contains multiple representations. Highest bandwidth is pre-selected; audio is always auto-included. Single-rep MPDs continue to auto-select with no picker shown. The selected bandwidth flows through the full stack: popup → service worker → DownloadManager / DashRecordingHandler → mpd-parser.getVideoPlaylistByBandwidth(). Also completes DASH live recording audio support: BaseRecordingHandler now carries a separate audioSegmentIndex and storeId for concurrent audio fragment downloads, and resolveMediaUrl() returns both the polling URL and the post-redirect URL for declarativeNetRequest rules. Co-Authored-By: Claude Sonnet 4.6 --- src/core/downloader/base-playlist-handler.ts | 3 + src/core/downloader/base-recording-handler.ts | 37 ++++++-- .../downloader/dash/dash-download-handler.ts | 6 +- .../downloader/dash/dash-recording-handler.ts | 80 +++++++++++++---- src/core/downloader/download-manager.ts | 2 + .../downloader/hls/hls-recording-handler.ts | 17 ++-- src/core/parsers/mpd-parser.ts | 15 ++++ src/core/utils/fetch-utils.ts | 30 +++++++ src/offscreen/offscreen.ts | 81 +++++++++++++++-- src/popup/render-manifest.ts | 89 ++++++++++++++----- src/service-worker.ts | 6 +- 11 files changed, 298 insertions(+), 68 deletions(-) diff --git a/src/core/downloader/base-playlist-handler.ts b/src/core/downloader/base-playlist-handler.ts index 789ca5a..7318adf 100644 --- a/src/core/downloader/base-playlist-handler.ts +++ b/src/core/downloader/base-playlist-handler.ts @@ -32,6 +32,7 @@ export interface BasePlaylistHandlerOptions { maxConcurrent?: number; ffmpegTimeout?: number; shouldSaveOnCancel?: () => boolean; + selectedBandwidth?: number; } export abstract class BasePlaylistHandler { @@ -39,6 +40,7 @@ export abstract class BasePlaylistHandler { protected readonly maxConcurrent: number; protected readonly ffmpegTimeout: number; protected readonly shouldSaveOnCancel?: () => boolean; + protected readonly selectedBandwidth?: number; protected downloadId: string = ""; protected bytesDownloaded: number = 0; @@ -57,6 +59,7 @@ export abstract class BasePlaylistHandler { this.maxConcurrent = options.maxConcurrent || DEFAULT_MAX_CONCURRENT; this.ffmpegTimeout = options.ffmpegTimeout || DEFAULT_FFMPEG_TIMEOUT_MS; this.shouldSaveOnCancel = options.shouldSaveOnCancel; + this.selectedBandwidth = options.selectedBandwidth; } // ---- Shared utility methods ---- diff --git a/src/core/downloader/base-recording-handler.ts b/src/core/downloader/base-recording-handler.ts index f407993..8e5dea9 100644 --- a/src/core/downloader/base-recording-handler.ts +++ b/src/core/downloader/base-recording-handler.ts @@ -22,6 +22,7 @@ import { SAVING_STAGE_PERCENTAGE } from "../../shared/constants"; export abstract class BaseRecordingHandler extends BasePlaylistHandler { protected segmentIndex: number = 0; + protected audioSegmentIndex: number = 0; protected override resetDownloadState( stateId: string, @@ -29,6 +30,7 @@ export abstract class BaseRecordingHandler extends BasePlaylistHandler { ): void { super.resetDownloadState(stateId, abortSignal); this.segmentIndex = 0; + this.audioSegmentIndex = 0; } /** @@ -44,11 +46,10 @@ export abstract class BaseRecordingHandler extends BasePlaylistHandler { ): Promise<{ filePath: string; fileExtension?: string }> { this.resetDownloadState(stateId, abortSignal); - const headerRuleIds = await this.tryAddHeaderRules(stateId, url, pageUrl); + const { mediaUrl, finalUrl } = await this.resolveMediaUrl(url, abortSignal); + const headerRuleIds = await this.tryAddHeaderRules(stateId, finalUrl, pageUrl); try { - const mediaUrl = await this.resolveMediaUrl(url, abortSignal); - await this.collectSegments(mediaUrl, stateId, abortSignal); if (this.segmentIndex === 0) { @@ -108,13 +109,14 @@ export abstract class BaseRecordingHandler extends BasePlaylistHandler { /** * Resolve the URL to poll for manifest/playlist updates. + * Returns both the mediaUrl (URL to poll) and finalUrl (post-redirect URL for DNR header rules). * HLS: may follow a master playlist → media playlist redirect. - * DASH: returns the MPD URL as-is. + * DASH: fetches MPD once to capture response.url, returns original url as mediaUrl. */ protected abstract resolveMediaUrl( url: string, abortSignal: AbortSignal, - ): Promise; + ): Promise<{ mediaUrl: string; finalUrl: string }>; /** * Fetch the latest manifest and return new segments not already in seenUris. @@ -127,7 +129,7 @@ export abstract class BaseRecordingHandler extends BasePlaylistHandler { url: string, abortSignal: AbortSignal, seenUris: Set, - ): Promise<{ fragments: Fragment[]; pollIntervalMs: number; ended: boolean }>; + ): Promise<{ fragments: Fragment[]; audioFragments?: Fragment[]; pollIntervalMs: number; ended: boolean }>; /** * Return the FFmpeg request/response/payload options for merging. @@ -174,13 +176,13 @@ export abstract class BaseRecordingHandler extends BasePlaylistHandler { continue; } - const { fragments: newFragments, pollIntervalMs: interval, ended } = result; + const { fragments: newFragments, audioFragments: newAudioFragments, pollIntervalMs: interval, ended } = result; pollIntervalMs = interval; if (abortSignal.aborted) break; logger.info( - `[REC] Poll #${pollCount}: new=${newFragments.length}, seen=${seenUris.size}, ended=${ended}`, + `[REC] Poll #${pollCount}: new=${newFragments.length}, audio=${newAudioFragments?.length ?? 0}, seen=${seenUris.size}, ended=${ended}`, ); if (newFragments.length > 0) { @@ -192,6 +194,21 @@ export abstract class BaseRecordingHandler extends BasePlaylistHandler { indexedFragments.forEach((f) => seenUris.add(f.uri)); await this.downloadFragmentsConcurrently(indexedFragments, abortSignal); + } + + if (newAudioFragments && newAudioFragments.length > 0) { + const indexedAudioFragments: Fragment[] = newAudioFragments.map((f) => ({ + ...f, + index: this.audioSegmentIndex++, + })); + await this.downloadFragmentsConcurrently( + indexedAudioFragments, + abortSignal, + this.downloadId + "_a", + ); + } + + if (newFragments.length > 0 || (newAudioFragments && newAudioFragments.length > 0)) { await this.updateRecordingProgress(stateId); } @@ -211,13 +228,15 @@ export abstract class BaseRecordingHandler extends BasePlaylistHandler { protected async downloadFragmentsConcurrently( fragments: Fragment[], abortSignal: AbortSignal, + storeId?: string, ): Promise { + const targetId = storeId ?? this.downloadId; await runConcurrentWorkers({ items: fragments, maxConcurrent: this.maxConcurrent, shouldStop: () => abortSignal.aborted, processItem: async (fragment) => { - const size = await this.downloadFragment(fragment, this.downloadId); + const size = await this.downloadFragment(fragment, targetId); this.bytesDownloaded += size; }, onError: (fragment, err) => { diff --git a/src/core/downloader/dash/dash-download-handler.ts b/src/core/downloader/dash/dash-download-handler.ts index 429f336..8eeaf88 100644 --- a/src/core/downloader/dash/dash-download-handler.ts +++ b/src/core/downloader/dash/dash-download-handler.ts @@ -28,6 +28,7 @@ import { parseLevelsPlaylist, hasDrm, getVideoPlaylist, + getVideoPlaylistByBandwidth, getAudioPlaylist, } from "../../parsers/mpd-parser"; @@ -53,6 +54,7 @@ export class DashDownloadHandler extends BasePlaylistHandler { stateId: string, abortSignal?: AbortSignal, pageUrl?: string, + selectedBandwidth?: number, ): Promise<{ filePath: string; fileExtension?: string }> { this.resetDownloadState(stateId, abortSignal); @@ -71,7 +73,9 @@ export class DashDownloadHandler extends BasePlaylistHandler { const manifest = parseManifest(mpdText, mpdUrl); - const videoPlaylist = getVideoPlaylist(manifest); + const videoPlaylist = selectedBandwidth + ? getVideoPlaylistByBandwidth(manifest, selectedBandwidth) + : getVideoPlaylist(manifest); if (!videoPlaylist) { throw new Error("No video streams found in MPD manifest"); } diff --git a/src/core/downloader/dash/dash-recording-handler.ts b/src/core/downloader/dash/dash-recording-handler.ts index 7748274..1738a4b 100644 --- a/src/core/downloader/dash/dash-recording-handler.ts +++ b/src/core/downloader/dash/dash-recording-handler.ts @@ -2,15 +2,15 @@ * DASH live recording handler * * Polls a live DASH MPD at a fixed interval, collects new segments as they - * appear (single video stream, audio muxed), and — when the user stops + * appear (separate video and audio streams), and — when the user stops * recording or the stream transitions to `type="static"` — merges and saves. * * Extends BaseRecordingHandler for the shared polling/recording orchestration. - * Uses a single-track approach: init segment at index 0, media segments at 1..N. + * Video segments are stored under downloadId, audio under downloadId + "_a". */ import { Fragment } from "../../types"; -import { fetchText } from "../../utils/fetch-utils"; +import { fetchText, fetchTextWithFinalUrl } from "../../utils/fetch-utils"; import { logger } from "../../utils/logger"; import { MessageType } from "../../../shared/messages"; import { BaseRecordingHandler } from "../base-recording-handler"; @@ -20,57 +20,99 @@ import { isLive, getPollIntervalMs, getVideoPlaylist, + getVideoPlaylistByBandwidth, + getAudioPlaylist, } from "../../parsers/mpd-parser"; export class DashRecordingHandler extends BaseRecordingHandler { + private seenAudioUris: Set = new Set(); + + protected override resetDownloadState( + stateId: string, + abortSignal?: AbortSignal, + ): void { + super.resetDownloadState(stateId, abortSignal); + this.seenAudioUris = new Set(); + } + /** - * DASH recordings poll the MPD URL itself — no separate media playlist URL. + * Fetch the MPD once to capture the post-redirect URL for DNR header rules. + * Polling continues against the original url to avoid repeated redirects. */ - protected async resolveMediaUrl(url: string, _abortSignal: AbortSignal): Promise { - return url; + protected async resolveMediaUrl( + url: string, + abortSignal: AbortSignal, + ): Promise<{ mediaUrl: string; finalUrl: string }> { + const { finalUrl } = await fetchTextWithFinalUrl(url, 1, abortSignal, false); + return { mediaUrl: url, finalUrl }; } /** - * Fetch and re-parse the MPD, returning new segments not yet seen. + * Fetch and re-parse the MPD, returning new video and audio segments not yet seen. */ protected async fetchNewSegments( mpdUrl: string, abortSignal: AbortSignal, seenUris: Set, - ): Promise<{ fragments: Fragment[]; pollIntervalMs: number; ended: boolean }> { + ): Promise<{ fragments: Fragment[]; audioFragments?: Fragment[]; pollIntervalMs: number; ended: boolean }> { const mpdText = await fetchText(mpdUrl, 3, abortSignal, true); const manifest = parseManifest(mpdText, mpdUrl); - // Select highest bandwidth video playlist (single stream — audio typically muxed) - const playlist = getVideoPlaylist(manifest); - if (!playlist) { - return { fragments: [], pollIntervalMs: getPollIntervalMs(mpdText), ended: false }; - } + // Video: select by bandwidth if specified, otherwise highest + const videoPlaylist = this.selectedBandwidth + ? getVideoPlaylistByBandwidth(manifest, this.selectedBandwidth) + : getVideoPlaylist(manifest); + const allVideoFragments = videoPlaylist ? parseLevelsPlaylist(videoPlaylist, 0) : []; + const newVideoFragments = allVideoFragments.filter((f) => !seenUris.has(f.uri)); - // Parse all segments for this playlist and filter to only new ones - const allFragments = parseLevelsPlaylist(playlist, 0); - const newFragments = allFragments.filter((f) => !seenUris.has(f.uri)); + // Audio: first audio adaptation set + const audioPlaylist = getAudioPlaylist(manifest); + let newAudioFragments: Fragment[] | undefined; + if (audioPlaylist) { + const allAudioFragments = parseLevelsPlaylist(audioPlaylist, 0); + const fresh = allAudioFragments.filter((f) => !this.seenAudioUris.has(f.uri)); + if (fresh.length > 0) { + fresh.forEach((f) => this.seenAudioUris.add(f.uri)); + newAudioFragments = fresh; + } + } // Stream ended when manifest switches from dynamic to static const ended = !isLive(mpdText); const pollIntervalMs = getPollIntervalMs(mpdText); logger.info( - `[DASH REC] manifest segments=${allFragments.length}, new=${newFragments.length}, ended=${ended}`, + `[DASH REC] video new=${newVideoFragments.length}, audio new=${newAudioFragments?.length ?? 0}, ended=${ended}`, ); - return { fragments: newFragments, pollIntervalMs, ended }; + return { fragments: newVideoFragments, audioFragments: newAudioFragments, pollIntervalMs, ended }; + } + + /** + * Clean up video chunks and, if audio was recorded, audio chunks too. + */ + protected override async cleanupChunks(downloadId: string): Promise { + await super.cleanupChunks(downloadId); + if (this.audioSegmentIndex > 0) { + await super.cleanupChunks(downloadId + "_a"); + } } /** * FFmpeg options for merging DASH recording segments (ISOBMF → MP4). + * Bug 1 fix: use videoLength/audioLength keys (not fragmentCount). + * Bug 2 fix: include audioDownloadId for the separate audio namespace. */ protected buildFfmpegOptions() { return { requestType: MessageType.OFFSCREEN_PROCESS_DASH, responseType: MessageType.OFFSCREEN_PROCESS_DASH_RESPONSE, - payload: { fragmentCount: this.segmentIndex } as Record, + payload: { + videoLength: this.segmentIndex, + audioLength: this.audioSegmentIndex, + audioDownloadId: this.audioSegmentIndex > 0 ? this.downloadId + "_a" : undefined, + } as Record, }; } } diff --git a/src/core/downloader/download-manager.ts b/src/core/downloader/download-manager.ts index 7af8319..c28be01 100644 --- a/src/core/downloader/download-manager.ts +++ b/src/core/downloader/download-manager.ts @@ -109,6 +109,7 @@ export class DownloadManager { manifestQuality?: { videoPlaylistUrl?: string | null; audioPlaylistUrl?: string | null; + selectedBandwidth?: number; }, isManual?: boolean, abortSignal?: AbortSignal, @@ -183,6 +184,7 @@ export class DownloadManager { state.id, abortSignal, metadata.pageUrl, + manifestQuality?.selectedBandwidth, ); } else { throw new Error(`Unsupported format: ${format}`); diff --git a/src/core/downloader/hls/hls-recording-handler.ts b/src/core/downloader/hls/hls-recording-handler.ts index fe9b54f..a900806 100644 --- a/src/core/downloader/hls/hls-recording-handler.ts +++ b/src/core/downloader/hls/hls-recording-handler.ts @@ -12,7 +12,7 @@ */ import { Fragment } from "../../types"; -import { fetchText } from "../../utils/fetch-utils"; +import { fetchText, fetchTextWithFinalUrl } from "../../utils/fetch-utils"; import { parseMasterPlaylist, parseMediaPlaylist, @@ -43,27 +43,29 @@ export class HlsRecordingHandler extends BaseRecordingHandler { /** * Resolve the media playlist URL from a master or media playlist URL. * If the URL points to a master playlist, selects the highest-bandwidth variant. + * Returns mediaUrl (URL to poll) and finalUrl (post-redirect URL for DNR header rules). */ protected async resolveMediaUrl( url: string, abortSignal: AbortSignal, - ): Promise { - const text = await fetchText(url, 3, abortSignal); + ): Promise<{ mediaUrl: string; finalUrl: string }> { + const { text, finalUrl: masterFinalUrl } = await fetchTextWithFinalUrl(url, 3, abortSignal); if (!text.includes("#EXT-X-STREAM-INF")) { logger.info( `[REC] URL is already a media playlist: ${url.substring(0, 100)}...`, ); - return url; + return { mediaUrl: url, finalUrl: masterFinalUrl }; } - const levels = parseMasterPlaylist(text, url); + // Use masterFinalUrl for relative URI resolution in case of redirect + const levels = parseMasterPlaylist(text, masterFinalUrl); const videoLevels = levels.filter((l) => l.type === "stream"); if (videoLevels.length === 0) { logger.warn( `[REC] No video levels found in master playlist, using URL as-is`, ); - return url; + return { mediaUrl: url, finalUrl: masterFinalUrl }; } videoLevels.sort((a, b) => (b.bitrate || 0) - (a.bitrate || 0)); @@ -71,7 +73,8 @@ export class HlsRecordingHandler extends BaseRecordingHandler { logger.info( `[REC] Resolved media playlist: ${resolvedUrl.substring(0, 100)}...`, ); - return resolvedUrl; + // finalUrl for segments is the media playlist URL itself + return { mediaUrl: resolvedUrl, finalUrl: resolvedUrl }; } /** diff --git a/src/core/parsers/mpd-parser.ts b/src/core/parsers/mpd-parser.ts index 704ab67..d5581ee 100644 --- a/src/core/parsers/mpd-parser.ts +++ b/src/core/parsers/mpd-parser.ts @@ -63,6 +63,20 @@ export function getVideoPlaylist(manifest: MpdManifest): ParsedPlaylist | null { return mpdPlaylistToParsedPlaylist(playlists[0]!); } +/** + * Select a video playlist by bandwidth, falling back to highest if not found. + */ +export function getVideoPlaylistByBandwidth( + manifest: MpdManifest, + bandwidth: number, +): ParsedPlaylist | null { + const match = (manifest.playlists || []).find( + (p) => p.attributes?.BANDWIDTH === bandwidth, + ); + if (!match) return getVideoPlaylist(manifest); + return mpdPlaylistToParsedPlaylist(match); +} + /** * Extract the first audio playlist from the parsed manifest's mediaGroups * and return it as a ParsedPlaylist, ready for parseLevelsPlaylist(). @@ -112,6 +126,7 @@ export const MpdParser = { parseMasterPlaylist, parseLevelsPlaylist, getVideoPlaylist, + getVideoPlaylistByBandwidth, getAudioPlaylist, isLive, hasDrm, diff --git a/src/core/utils/fetch-utils.ts b/src/core/utils/fetch-utils.ts index e23bc6d..345db80 100644 --- a/src/core/utils/fetch-utils.ts +++ b/src/core/utils/fetch-utils.ts @@ -149,6 +149,36 @@ export async function fetchText( return fetchWithRetry(fetchFn, attempts); } +/** + * Like fetchText, but also returns the post-redirect URL (response.url). + * In service worker context, captures the real final URL after any HTTP redirects. + * In content script context, falls back to the original URL (response.url unavailable via proxy). + */ +export async function fetchTextWithFinalUrl( + url: string, + attempts: number = 1, + signal?: AbortSignal, + noCache?: boolean, +): Promise<{ text: string; finalUrl: string }> { + if (isServiceWorkerContext()) { + const fetchFn: FetchFn<{ text: string; finalUrl: string }> = async () => { + const response = await fetch(url, { + signal, + cache: noCache ? "no-store" : undefined, + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status} ${response.statusText} for ${url}`); + } + const text = await response.text(); + return { text, finalUrl: response.url || url }; + }; + return fetchWithRetry(fetchFn, attempts); + } + // In content script context: response.url unavailable after proxy, fall back to original url + const text = await fetchText(url, attempts, signal, noCache); + return { text, finalUrl: url }; +} + export async function fetchArrayBuffer( url: string, attempts: number = 1, diff --git a/src/offscreen/offscreen.ts b/src/offscreen/offscreen.ts index 0653582..81519ae 100644 --- a/src/offscreen/offscreen.ts +++ b/src/offscreen/offscreen.ts @@ -424,10 +424,59 @@ async function processDashSingleStream( } } +/** + * Process DASH recording: video and audio stored under separate downloadId namespaces. + * Video: (videoDownloadId, 0, videoLength), Audio: (audioDownloadId, 0, audioLength). + */ +async function processDashVideoAndAudioSeparate( + ffmpeg: FFmpeg, + videoDownloadId: string, + videoLength: number, + audioDownloadId: string, + audioLength: number, + outputFileName: string, + onProgress?: (progress: number, message: string) => void, +): Promise { + const videoFile = `${videoDownloadId}_video.mp4`; + const audioFile = `${audioDownloadId}_audio.mp4`; + + try { + onProgress?.(0.1, "Concatenating chunks"); + const [videoResult, audioResult] = await Promise.all([ + concatenateChunks(videoDownloadId, 0, videoLength), + concatenateChunks(audioDownloadId, 0, audioLength), + ]); + + onProgress?.(0.5, "Writing video stream"); + await ffmpeg.writeFile(videoFile, await fetchFile(videoResult.blob)); + + onProgress?.(0.6, "Writing audio stream"); + await ffmpeg.writeFile(audioFile, await fetchFile(audioResult.blob)); + + onProgress?.(0.7, "Merging video and audio"); + await ffmpeg.exec([ + "-y", + "-i", videoFile, + "-i", audioFile, + "-c:v", "copy", + "-c:a", "copy", + "-movflags", "+faststart", + outputFileName, + ]); + + const totalMissing = videoResult.missingCount + audioResult.missingCount; + const totalChunks = videoResult.totalCount + audioResult.totalCount; + return buildMissingChunksWarning(totalMissing, totalChunks); + } finally { + await cleanupFiles(ffmpeg, [videoFile, audioFile]); + } +} + async function processDashChunks( downloadId: string, videoLength: number, audioLength: number, + audioDownloadId?: string, onProgress?: (progress: number, message: string) => void, ): Promise { validateDownloadId(downloadId); @@ -439,14 +488,29 @@ async function processDashChunks( let warning: string | undefined; if (videoLength > 0 && audioLength > 0) { - warning = await processDashVideoAndAudio( - ffmpeg, - downloadId, - videoLength, - audioLength, - outputFileName, - onProgress, - ); + if (audioDownloadId) { + // Recording path: audio stored under a separate namespace + validateDownloadId(audioDownloadId); + warning = await processDashVideoAndAudioSeparate( + ffmpeg, + downloadId, + videoLength, + audioDownloadId, + audioLength, + outputFileName, + onProgress, + ); + } else { + // VOD path: audio follows video in same namespace (unchanged) + warning = await processDashVideoAndAudio( + ffmpeg, + downloadId, + videoLength, + audioLength, + outputFileName, + onProgress, + ); + } } else if (videoLength > 0) { warning = await processDashSingleStream( ffmpeg, @@ -639,6 +703,7 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { payload.downloadId as string, payload.videoLength as number, payload.audioLength as number, + payload.audioDownloadId as string | undefined, onProgress, ), ) diff --git a/src/popup/render-manifest.ts b/src/popup/render-manifest.ts index df273b6..3f04d5b 100644 --- a/src/popup/render-manifest.ts +++ b/src/popup/render-manifest.ts @@ -174,16 +174,48 @@ export async function handleLoadManifestPlaylist(): Promise { const live = MpdParser.isLive(playlistText); setIsLiveManifest(live); - setIsMediaPlaylistMode(true); // DASH auto-selects quality — no quality picker needed - if (dom.manifestMediaPlaylistWarning) dom.manifestMediaPlaylistWarning.style.display = "none"; - if (dom.manifestQualitySelection) dom.manifestQualitySelection.style.display = "none"; - if (dom.manifestLiveStreamInfo) { - dom.manifestLiveStreamInfo.style.display = live ? "block" : "none"; - if (live) { - const infoText = document.getElementById("hlsLiveStreamInfoText"); - if (infoText) { - infoText.textContent = "This is a live DASH stream. Click Record to start capturing."; + const videoReps = MpdParser.parseMasterPlaylist(playlistText, normalizedUrl) + .filter((l) => l.type === "stream"); + + if (videoReps.length > 1) { + // Multiple representations → show quality picker (both VOD and live) + setIsMediaPlaylistMode(false); + if (dom.manifestMediaPlaylistWarning) dom.manifestMediaPlaylistWarning.style.display = "none"; + const { videoQualitySelect, audioQualitySelect, manifestQualitySelection } = dom; + if (manifestQualitySelection && videoQualitySelect && audioQualitySelect) { + manifestQualitySelection.style.display = "block"; + videoReps.sort((a, b) => (b.bitrate || 0) - (a.bitrate || 0)); + videoQualitySelect.innerHTML = ""; + videoReps.forEach((level) => { + const option = document.createElement("option"); + option.value = String(level.bitrate); + option.textContent = formatQualityLabel(level); + videoQualitySelect!.appendChild(option); + }); + videoQualitySelect.selectedIndex = 0; + videoQualitySelect.disabled = false; + // Audio is always auto-included for DASH + audioQualitySelect.innerHTML = ''; + audioQualitySelect.disabled = true; + } + if (dom.manifestLiveStreamInfo) { + dom.manifestLiveStreamInfo.style.display = live ? "block" : "none"; + if (live) { + const infoText = document.getElementById("hlsLiveStreamInfoText"); + if (infoText) infoText.textContent = "This is a live DASH stream. Select a quality and click Record to start capturing."; + } + } + } else { + // Single representation → auto-select, no picker + setIsMediaPlaylistMode(true); + if (dom.manifestMediaPlaylistWarning) dom.manifestMediaPlaylistWarning.style.display = "none"; + if (dom.manifestQualitySelection) dom.manifestQualitySelection.style.display = "none"; + if (dom.manifestLiveStreamInfo) { + dom.manifestLiveStreamInfo.style.display = live ? "block" : "none"; + if (live) { + const infoText = document.getElementById("hlsLiveStreamInfoText"); + if (infoText) infoText.textContent = "This is a live DASH stream. Click Record to start capturing."; } } } @@ -326,19 +358,29 @@ export async function handleStartManifestDownload(): Promise { let videoPlaylistUrl: string | null = null; let audioPlaylistUrl: string | null = null; + let manifestQualityPayload: + | { videoPlaylistUrl?: string | null; audioPlaylistUrl?: string | null; selectedBandwidth?: number } + | undefined; if (!isMediaPlaylistMode) { if (!videoQualitySelect || !audioQualitySelect) return; - const rawVideoUrl = videoQualitySelect.value || null; - const rawAudioUrl = audioQualitySelect.value || null; + if (currentManifestFormat === VideoFormat.DASH) { + const bw = videoQualitySelect.value ? parseInt(videoQualitySelect.value, 10) : undefined; + manifestQualityPayload = { selectedBandwidth: bw }; + } else { + // HLS: existing URL-based behaviour unchanged + const rawVideoUrl = videoQualitySelect.value || null; + const rawAudioUrl = audioQualitySelect.value || null; - videoPlaylistUrl = rawVideoUrl ? normalizeUrl(rawVideoUrl) : null; - audioPlaylistUrl = rawAudioUrl ? normalizeUrl(rawAudioUrl) : null; + videoPlaylistUrl = rawVideoUrl ? normalizeUrl(rawVideoUrl) : null; + audioPlaylistUrl = rawAudioUrl ? normalizeUrl(rawAudioUrl) : null; - if (!videoPlaylistUrl && !audioPlaylistUrl) { - alert("Please select at least one quality (video or audio)"); - return; + if (!videoPlaylistUrl && !audioPlaylistUrl) { + alert("Please select at least one quality (video or audio)"); + return; + } + manifestQualityPayload = { videoPlaylistUrl, audioPlaylistUrl }; } } @@ -392,18 +434,19 @@ export async function handleStartManifestDownload(): Promise { : playlistUrl; const payload = isLiveManifest - ? { url: recordingUrl, metadata, tabTitle, website } + ? { + url: recordingUrl, + metadata, + tabTitle, + website, + selectedBandwidth: manifestQualityPayload?.selectedBandwidth, + } : { url: playlistUrl, metadata, tabTitle, website, - manifestQuality: isMediaPlaylistMode - ? undefined - : { - videoPlaylistUrl, - audioPlaylistUrl, - }, + manifestQuality: isMediaPlaylistMode ? undefined : manifestQualityPayload, isManual: true, }; diff --git a/src/service-worker.ts b/src/service-worker.ts index c818e1a..4f6d1f7 100644 --- a/src/service-worker.ts +++ b/src/service-worker.ts @@ -133,6 +133,7 @@ async function handleDownloadRequestMessage(payload: { manifestQuality?: { videoPlaylistUrl?: string | null; audioPlaylistUrl?: string | null; + selectedBandwidth?: number; }; }): Promise<{ success: boolean; error?: string }> { try { @@ -461,6 +462,7 @@ async function handleDownloadRequest(payload: { manifestQuality?: { videoPlaylistUrl?: string | null; audioPlaylistUrl?: string | null; + selectedBandwidth?: number; }; isManual?: boolean; }): Promise<{ error?: string } | void> { @@ -730,6 +732,7 @@ async function startDownload( manifestQuality?: { videoPlaylistUrl?: string | null; audioPlaylistUrl?: string | null; + selectedBandwidth?: number; }, isManual?: boolean, abortSignal?: AbortSignal, @@ -883,6 +886,7 @@ async function handleStartRecordingMessage(payload: { filename?: string; tabTitle?: string; website?: string; + selectedBandwidth?: number; }): Promise<{ success: boolean; error?: string }> { try { const { url, metadata, filename, tabTitle, website } = payload; @@ -940,7 +944,7 @@ async function handleStartRecordingMessage(payload: { const handler = metadata.format === VideoFormat.DASH - ? new DashRecordingHandler({ onProgress, maxConcurrent, ffmpegTimeout }) + ? new DashRecordingHandler({ onProgress, maxConcurrent, ffmpegTimeout, selectedBandwidth: payload.selectedBandwidth }) : new HlsRecordingHandler({ onProgress, maxConcurrent, ffmpegTimeout }); // Resolve filename From c391f43a54b5413def8d440cacfdf8537a56222e Mon Sep 17 00:00:00 2001 From: Jvillegasd Date: Mon, 2 Mar 2026 13:53:57 -0500 Subject: [PATCH 11/13] fix: concurrent download + separate audio namespace to fix stop-and-save audio loss MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DASH VOD and HLS master playlist downloads used sequential video-then-audio segment downloading. If the user stopped during the video phase, chunkCount <= videoLength so the stop-and-save formula produced effectiveAudioLength = 0 — no audio in the saved file. Fix: switch both handlers to concurrent download using Promise.allSettled, with audio stored under a separate `downloadId + "_a"` namespace (matching DashRecordingHandler). Stop-and-save now counts video and audio chunks independently via getChunkCount. The offscreen adds processHlsVideoAndAudioSeparate (mirrors the DASH equivalent) and processHLSChunks now requires audioDownloadId for dual-stream mux. Co-Authored-By: Claude Sonnet 4.6 --- .../downloader/dash/dash-download-handler.ts | 37 +++++++----- .../downloader/hls/hls-download-handler.ts | 47 +++++++++------ src/offscreen/offscreen.ts | 60 ++++++++++++++++++- 3 files changed, 111 insertions(+), 33 deletions(-) diff --git a/src/core/downloader/dash/dash-download-handler.ts b/src/core/downloader/dash/dash-download-handler.ts index 8eeaf88..edebe83 100644 --- a/src/core/downloader/dash/dash-download-handler.ts +++ b/src/core/downloader/dash/dash-download-handler.ts @@ -57,6 +57,7 @@ export class DashDownloadHandler extends BasePlaylistHandler { selectedBandwidth?: number, ): Promise<{ filePath: string; fileExtension?: string }> { this.resetDownloadState(stateId, abortSignal); + const audioId = this.downloadId + "_a"; const headerRuleIds = await this.tryAddHeaderRules(stateId, mpdUrl, pageUrl); @@ -92,22 +93,27 @@ export class DashDownloadHandler extends BasePlaylistHandler { logger.info(`Found ${videoFragments.length} DASH video fragments`); this.videoLength = videoFragments.length; - await this.downloadAllFragments(videoFragments, this.downloadId, stateId); + const downloadJobs: Promise[] = [ + this.downloadAllFragments(videoFragments, this.downloadId, stateId), + ]; - throwIfAborted(this.abortSignal); - - // Download audio stream if present (starts at videoLength) + // Download audio stream concurrently if present (separate namespace) this.audioLength = 0; if (audioPlaylist) { - const audioFragments = parseLevelsPlaylist(audioPlaylist, this.videoLength); + const audioFragments = parseLevelsPlaylist(audioPlaylist, 0); logger.info(`Found ${audioFragments.length} DASH audio fragments`); this.audioLength = audioFragments.length; - - await this.downloadAllFragments(audioFragments, this.downloadId, stateId); - - throwIfAborted(this.abortSignal); + downloadJobs.push(this.downloadAllFragments(audioFragments, audioId, stateId)); } + const results = await Promise.allSettled(downloadJobs); + const cancelled = results.find( + (r) => r.status === "rejected" && r.reason instanceof CancellationError, + ); + if (cancelled) throw new CancellationError(); + const failed = results.find((r) => r.status === "rejected"); + if (failed) throw (failed as PromiseRejectedResult).reason; + await this.updateStage( stateId, DownloadStage.MERGING, @@ -123,6 +129,7 @@ export class DashDownloadHandler extends BasePlaylistHandler { payload: { videoLength: this.videoLength, audioLength: this.audioLength, + audioDownloadId: this.audioLength > 0 ? audioId : undefined, }, filename: baseFileName, timeout: this.ffmpegTimeout, @@ -153,12 +160,10 @@ export class DashDownloadHandler extends BasePlaylistHandler { } catch (error) { if (error instanceof CancellationError && this.shouldSaveOnCancel?.()) { try { - const chunkCount = await getChunkCount(this.downloadId); - const effectiveVideoLength = Math.min(chunkCount, this.videoLength); - const effectiveAudioLength = Math.max( - 0, - chunkCount - this.videoLength, - ); + const videoChunkCount = await getChunkCount(this.downloadId); + const audioChunkCount = this.audioLength > 0 ? await getChunkCount(audioId) : 0; + const effectiveVideoLength = Math.min(videoChunkCount, this.videoLength); + const effectiveAudioLength = Math.min(audioChunkCount, this.audioLength); this.videoLength = effectiveVideoLength; this.audioLength = effectiveAudioLength; @@ -168,6 +173,7 @@ export class DashDownloadHandler extends BasePlaylistHandler { payload: { videoLength: this.videoLength, audioLength: this.audioLength, + audioDownloadId: this.audioLength > 0 ? audioId : undefined, }, }); logger.info(`DASH partial download saved: ${result.filePath}`); @@ -183,6 +189,7 @@ export class DashDownloadHandler extends BasePlaylistHandler { } finally { await this.tryRemoveHeaderRules(headerRuleIds); await this.cleanupChunks(this.downloadId || stateId); + await this.cleanupChunks((this.downloadId || stateId) + "_a"); } } } diff --git a/src/core/downloader/hls/hls-download-handler.ts b/src/core/downloader/hls/hls-download-handler.ts index 3e3b892..36dd958 100644 --- a/src/core/downloader/hls/hls-download-handler.ts +++ b/src/core/downloader/hls/hls-download-handler.ts @@ -84,6 +84,7 @@ export class HlsDownloadHandler extends BasePlaylistHandler { pageUrl?: string, ): Promise<{ filePath: string; fileExtension?: string }> { this.resetDownloadState(stateId, abortSignal); + const audioId = this.downloadId + "_a"; const headerRuleIds = await this.tryAddHeaderRules(stateId, masterPlaylistUrl, pageUrl); @@ -145,6 +146,8 @@ export class HlsDownloadHandler extends BasePlaylistHandler { throwIfAborted(this.abortSignal); + const downloadJobs: Promise[] = []; + // Process video playlist if (videoPlaylistUrl) { if (!videoPlaylistText) { @@ -164,15 +167,11 @@ export class HlsDownloadHandler extends BasePlaylistHandler { logger.info(`Found ${videoFragments.length} video fragments`); this.videoLength = videoFragments.length; - await this.downloadAllFragments( - videoFragments, - this.downloadId, - stateId, + downloadJobs.push( + this.downloadAllFragments(videoFragments, this.downloadId, stateId), ); } - throwIfAborted(this.abortSignal); - // Process audio playlist if (audioPlaylistUrl) { if (!audioPlaylistText) { @@ -182,7 +181,7 @@ export class HlsDownloadHandler extends BasePlaylistHandler { const audioFragments = parseLevelsPlaylist( parseMediaPlaylist(audioPlaylistText, audioPlaylistUrl), - this.videoLength, + 0, ); if (audioFragments.length === 0) { @@ -192,14 +191,18 @@ export class HlsDownloadHandler extends BasePlaylistHandler { logger.info(`Found ${audioFragments.length} audio fragments`); this.audioLength = audioFragments.length; - await this.downloadAllFragments( - audioFragments, - this.downloadId, - stateId, + downloadJobs.push( + this.downloadAllFragments(audioFragments, audioId, stateId), ); } - throwIfAborted(this.abortSignal); + const results = await Promise.allSettled(downloadJobs); + const cancelled = results.find( + (r) => r.status === "rejected" && r.reason instanceof CancellationError, + ); + if (cancelled) throw new CancellationError(); + const failed = results.find((r) => r.status === "rejected"); + if (failed) throw (failed as PromiseRejectedResult).reason; await this.updateStage(stateId, DownloadStage.MERGING, "Merging streams..."); @@ -209,7 +212,11 @@ export class HlsDownloadHandler extends BasePlaylistHandler { requestType: MessageType.OFFSCREEN_PROCESS_HLS, responseType: MessageType.OFFSCREEN_PROCESS_HLS_RESPONSE, downloadId: this.downloadId, - payload: { videoLength: this.videoLength, audioLength: this.audioLength }, + payload: { + videoLength: this.videoLength, + audioLength: this.audioLength, + audioDownloadId: this.audioLength > 0 ? audioId : undefined, + }, filename: baseFileName, timeout: this.ffmpegTimeout, abortSignal: this.abortSignal, @@ -239,16 +246,21 @@ export class HlsDownloadHandler extends BasePlaylistHandler { } catch (error) { if (error instanceof CancellationError && this.shouldSaveOnCancel?.()) { try { - const chunkCount = await getChunkCount(this.downloadId); - const effectiveVideoLength = Math.min(chunkCount, this.videoLength); - const effectiveAudioLength = Math.max(0, chunkCount - this.videoLength); + const videoChunkCount = await getChunkCount(this.downloadId); + const audioChunkCount = this.audioLength > 0 ? await getChunkCount(audioId) : 0; + const effectiveVideoLength = Math.min(videoChunkCount, this.videoLength); + const effectiveAudioLength = Math.min(audioChunkCount, this.audioLength); this.videoLength = effectiveVideoLength; this.audioLength = effectiveAudioLength; const result = await this.savePartialDownload(stateId, filename, { requestType: MessageType.OFFSCREEN_PROCESS_HLS, responseType: MessageType.OFFSCREEN_PROCESS_HLS_RESPONSE, - payload: { videoLength: this.videoLength, audioLength: this.audioLength }, + payload: { + videoLength: this.videoLength, + audioLength: this.audioLength, + audioDownloadId: this.audioLength > 0 ? audioId : undefined, + }, }); logger.info(`HLS partial download saved: ${result.filePath}`); return result; @@ -265,6 +277,7 @@ export class HlsDownloadHandler extends BasePlaylistHandler { } finally { await this.tryRemoveHeaderRules(headerRuleIds); await this.cleanupChunks(this.downloadId || stateId); + await this.cleanupChunks((this.downloadId || stateId) + "_a"); } } } diff --git a/src/offscreen/offscreen.ts b/src/offscreen/offscreen.ts index 81519ae..ea0dbae 100644 --- a/src/offscreen/offscreen.ts +++ b/src/offscreen/offscreen.ts @@ -275,6 +275,7 @@ async function processHLSChunks( downloadId: string, videoLength: number, audioLength: number, + audioDownloadId?: string, onProgress?: (progress: number, message: string) => void, ): Promise { validateDownloadId(downloadId); @@ -287,10 +288,15 @@ async function processHLSChunks( let warning: string | undefined; if (videoLength > 0 && audioLength > 0) { - warning = await processVideoAndAudio( + if (!audioDownloadId) { + throw new Error("audioDownloadId required for HLS video+audio mux"); + } + validateDownloadId(audioDownloadId); + warning = await processHlsVideoAndAudioSeparate( ffmpeg, downloadId, videoLength, + audioDownloadId, audioLength, outputFileName, onProgress, @@ -341,6 +347,57 @@ async function processHLSChunks( } } +/** + * Process HLS recording: video and audio stored under separate downloadId namespaces. + * Video: (videoDownloadId, 0, videoLength), Audio: (audioDownloadId, 0, audioLength). + * Uses .ts intermediate files and -bsf:a aac_adtstoasc (HLS/MPEG-TS container). + */ +async function processHlsVideoAndAudioSeparate( + ffmpeg: FFmpeg, + videoDownloadId: string, + videoLength: number, + audioDownloadId: string, + audioLength: number, + outputFileName: string, + onProgress?: (progress: number, message: string) => void, +): Promise { + const videoFile = `${videoDownloadId}_video.ts`; + const audioFile = `${audioDownloadId}_audio.ts`; + + try { + onProgress?.(0.1, "Concatenating chunks"); + const [videoResult, audioResult] = await Promise.all([ + concatenateChunks(videoDownloadId, 0, videoLength), + concatenateChunks(audioDownloadId, 0, audioLength), + ]); + + onProgress?.(0.5, "Writing video stream"); + await ffmpeg.writeFile(videoFile, await fetchFile(videoResult.blob)); + + onProgress?.(0.6, "Writing audio stream"); + await ffmpeg.writeFile(audioFile, await fetchFile(audioResult.blob)); + + onProgress?.(0.7, "Merging video and audio"); + await ffmpeg.exec([ + "-y", + "-i", videoFile, + "-i", audioFile, + "-c:v", "copy", + "-c:a", "copy", + "-bsf:a", "aac_adtstoasc", + "-shortest", + "-movflags", "+faststart", + outputFileName, + ]); + + const totalMissing = videoResult.missingCount + audioResult.missingCount; + const totalChunks = videoResult.totalCount + audioResult.totalCount; + return buildMissingChunksWarning(totalMissing, totalChunks); + } finally { + await cleanupFiles(ffmpeg, [videoFile, audioFile]); + } +} + /** * Process DASH chunks (ISOBMF/.m4s segments) and convert to MP4. * @@ -670,6 +727,7 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { payload.downloadId as string, payload.videoLength as number, payload.audioLength as number, + payload.audioDownloadId as string | undefined, onProgress, ), ) From 203138b98d586d20c4801d95cce8ef64b25f5623 Mon Sep 17 00:00:00 2001 From: Jvillegasd Date: Mon, 2 Mar 2026 14:20:17 -0500 Subject: [PATCH 12/13] refactor: remove dead same-namespace audio+video processing paths processVideoAndAudio() and processDashVideoAndAudio() are unreachable now that both HLS and DASH VOD handlers always pass audioDownloadId for separate namespace processing. Replace the if/else branches with a guard throw, matching the pattern already used in processHLSChunks. Co-Authored-By: Claude Sonnet 4.6 --- src/offscreen/offscreen.ts | 138 ++++--------------------------------- 1 file changed, 12 insertions(+), 126 deletions(-) diff --git a/src/offscreen/offscreen.ts b/src/offscreen/offscreen.ts index ea0dbae..ccb2dea 100644 --- a/src/offscreen/offscreen.ts +++ b/src/offscreen/offscreen.ts @@ -153,58 +153,6 @@ function buildMissingChunksWarning( return `${missingCount} of ${totalCount} chunks were missing (${pct}%) — video may have gaps`; } -async function processVideoAndAudio( - ffmpeg: FFmpeg, - downloadId: string, - videoLength: number, - audioLength: number, - outputFileName: string, - onProgress?: (progress: number, message: string) => void, -): Promise { - const videoFile = `${downloadId}_video.ts`; - const audioFile = `${downloadId}_audio.ts`; - const intermediateFiles = [videoFile, audioFile]; - - try { - onProgress?.(0.1, "Concatenating chunks"); - const [videoResult, audioResult] = await Promise.all([ - concatenateChunks(downloadId, 0, videoLength), - concatenateChunks(downloadId, videoLength, audioLength), - ]); - - onProgress?.(0.5, "Writing video stream"); - await ffmpeg.writeFile(videoFile, await fetchFile(videoResult.blob)); - - onProgress?.(0.6, "Writing audio stream"); - await ffmpeg.writeFile(audioFile, await fetchFile(audioResult.blob)); - - onProgress?.(0.7, "Merging video and audio"); - await ffmpeg.exec([ - "-y", - "-i", - videoFile, - "-i", - audioFile, - "-c:v", - "copy", - "-c:a", - "copy", - "-bsf:a", - "aac_adtstoasc", - "-shortest", - "-movflags", - "+faststart", - outputFileName, - ]); - - const totalMissing = videoResult.missingCount + audioResult.missingCount; - const totalChunks = videoResult.totalCount + audioResult.totalCount; - return buildMissingChunksWarning(totalMissing, totalChunks); - } finally { - await cleanupFiles(ffmpeg, intermediateFiles); - } -} - /** * Process a single stream (video-only, media playlist, or audio-only muxed content). * Handles concatenation, writing, and converting to MP4. @@ -398,58 +346,6 @@ async function processHlsVideoAndAudioSeparate( } } -/** - * Process DASH chunks (ISOBMF/.m4s segments) and convert to MP4. - * - * Nearly identical to processHLSChunks() but: - * - Intermediate files use .mp4 extension (ISOBMF demuxer, not MPEG-TS) - * - No -bsf:a aac_adtstoasc bitstream filter (not needed for MP4 container) - * - Handles both dual-stream VOD (video+audio) and single-stream recording - */ - -async function processDashVideoAndAudio( - ffmpeg: FFmpeg, - downloadId: string, - videoLength: number, - audioLength: number, - outputFileName: string, - onProgress?: (progress: number, message: string) => void, -): Promise { - const videoFile = `${downloadId}_video.mp4`; - const audioFile = `${downloadId}_audio.mp4`; - - try { - onProgress?.(0.1, "Concatenating chunks"); - const [videoResult, audioResult] = await Promise.all([ - concatenateChunks(downloadId, 0, videoLength), - concatenateChunks(downloadId, videoLength, audioLength), - ]); - - onProgress?.(0.5, "Writing video stream"); - await ffmpeg.writeFile(videoFile, await fetchFile(videoResult.blob)); - - onProgress?.(0.6, "Writing audio stream"); - await ffmpeg.writeFile(audioFile, await fetchFile(audioResult.blob)); - - onProgress?.(0.7, "Merging video and audio"); - await ffmpeg.exec([ - "-y", - "-i", videoFile, - "-i", audioFile, - "-c:v", "copy", - "-c:a", "copy", - "-movflags", "+faststart", - outputFileName, - ]); - - const totalMissing = videoResult.missingCount + audioResult.missingCount; - const totalChunks = videoResult.totalCount + audioResult.totalCount; - return buildMissingChunksWarning(totalMissing, totalChunks); - } finally { - await cleanupFiles(ffmpeg, [videoFile, audioFile]); - } -} - async function processDashSingleStream( ffmpeg: FFmpeg, downloadId: string, @@ -545,29 +441,19 @@ async function processDashChunks( let warning: string | undefined; if (videoLength > 0 && audioLength > 0) { - if (audioDownloadId) { - // Recording path: audio stored under a separate namespace - validateDownloadId(audioDownloadId); - warning = await processDashVideoAndAudioSeparate( - ffmpeg, - downloadId, - videoLength, - audioDownloadId, - audioLength, - outputFileName, - onProgress, - ); - } else { - // VOD path: audio follows video in same namespace (unchanged) - warning = await processDashVideoAndAudio( - ffmpeg, - downloadId, - videoLength, - audioLength, - outputFileName, - onProgress, - ); + if (!audioDownloadId) { + throw new Error("audioDownloadId required for DASH video+audio mux"); } + validateDownloadId(audioDownloadId); + warning = await processDashVideoAndAudioSeparate( + ffmpeg, + downloadId, + videoLength, + audioDownloadId, + audioLength, + outputFileName, + onProgress, + ); } else if (videoLength > 0) { warning = await processDashSingleStream( ffmpeg, From 4286b809445116ce1e3d00969c1ca0897110ae44 Mon Sep 17 00:00:00 2001 From: Jvillegasd Date: Mon, 2 Mar 2026 14:28:42 -0500 Subject: [PATCH 13/13] fix: add -shortest to DASH video+audio merge to handle mismatched segment counts Without -shortest, if audio downloaded more segments than video (common since audio segments are smaller and finish faster), the output would have audio extending beyond video end causing a frozen/black frame with audio. Matches the existing -shortest behavior in processHlsVideoAndAudioSeparate. Co-Authored-By: Claude Sonnet 4.6 --- src/offscreen/offscreen.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/offscreen/offscreen.ts b/src/offscreen/offscreen.ts index ccb2dea..eedf1d4 100644 --- a/src/offscreen/offscreen.ts +++ b/src/offscreen/offscreen.ts @@ -413,6 +413,7 @@ async function processDashVideoAndAudioSeparate( "-i", audioFile, "-c:v", "copy", "-c:a", "copy", + "-shortest", "-movflags", "+faststart", outputFileName, ]);