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 diff --git a/package-lock.json b/package-lock.json index 988221d..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", @@ -511,66 +512,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 +539,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 +553,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 +567,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 +581,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 +595,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 +609,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 +623,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 +640,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 +657,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 +674,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 +691,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 +725,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 +759,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 +776,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 +793,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 +810,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 +827,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 +872,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 +886,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 +900,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 +914,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,19 +1023,13 @@ "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, + "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", - "optional": true, - "peer": true, - "bin": { - "acorn": "bin/acorn" - }, "engines": { - "node": ">=0.4.0" + "node": ">=10.0.0" } }, "node_modules/anymatch": { @@ -1056,15 +1072,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,13 +1269,29 @@ } }, "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" } }, + "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", @@ -1414,9 +1437,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 +1453,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 +1490,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 +1500,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", 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..29075ad --- /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 "../thumbnail-utils"; +import { isLive, hasDrm } from "../../parsers/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/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..7318adf 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, @@ -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 new file mode 100644 index 0000000..8e5dea9 --- /dev/null +++ b/src/core/downloader/base-recording-handler.ts @@ -0,0 +1,277 @@ +/** + * 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 "../ffmpeg/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 audioSegmentIndex: number = 0; + + protected override resetDownloadState( + stateId: string, + abortSignal?: AbortSignal, + ): void { + super.resetDownloadState(stateId, abortSignal); + this.segmentIndex = 0; + this.audioSegmentIndex = 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 { mediaUrl, finalUrl } = await this.resolveMediaUrl(url, abortSignal); + const headerRuleIds = await this.tryAddHeaderRules(stateId, finalUrl, pageUrl); + + try { + 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. + * 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: fetches MPD once to capture response.url, returns original url as mediaUrl. + */ + protected abstract resolveMediaUrl( + url: string, + abortSignal: AbortSignal, + ): Promise<{ mediaUrl: string; finalUrl: string }>; + + /** + * 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[]; audioFragments?: 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, audioFragments: newAudioFragments, pollIntervalMs: interval, ended } = result; + pollIntervalMs = interval; + + if (abortSignal.aborted) break; + + logger.info( + `[REC] Poll #${pollCount}: new=${newFragments.length}, audio=${newAudioFragments?.length ?? 0}, 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); + } + + 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); + } + + 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, + 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, targetId); + 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/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 new file mode 100644 index 0000000..edebe83 --- /dev/null +++ b/src/core/downloader/dash/dash-download-handler.ts @@ -0,0 +1,195 @@ +/** + * 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 "../../ffmpeg/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, + getVideoPlaylist, + getVideoPlaylistByBandwidth, + getAudioPlaylist, +} from "../../parsers/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, + selectedBandwidth?: number, + ): Promise<{ filePath: string; fileExtension?: string }> { + this.resetDownloadState(stateId, abortSignal); + const audioId = this.downloadId + "_a"; + + 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); + + const videoPlaylist = selectedBandwidth + ? getVideoPlaylistByBandwidth(manifest, selectedBandwidth) + : getVideoPlaylist(manifest); + if (!videoPlaylist) { + throw new Error("No video streams found in MPD manifest"); + } + + const audioPlaylist = 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; + + const downloadJobs: Promise[] = [ + this.downloadAllFragments(videoFragments, this.downloadId, stateId), + ]; + + // Download audio stream concurrently if present (separate namespace) + this.audioLength = 0; + if (audioPlaylist) { + const audioFragments = parseLevelsPlaylist(audioPlaylist, 0); + logger.info(`Found ${audioFragments.length} DASH audio fragments`); + this.audioLength = audioFragments.length; + 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, + "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, + audioDownloadId: this.audioLength > 0 ? audioId : undefined, + }, + 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 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_DASH, + responseType: MessageType.OFFSCREEN_PROCESS_DASH_RESPONSE, + payload: { + videoLength: this.videoLength, + audioLength: this.audioLength, + audioDownloadId: this.audioLength > 0 ? audioId : undefined, + }, + }); + 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); + await this.cleanupChunks((this.downloadId || stateId) + "_a"); + } + } +} 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..1738a4b --- /dev/null +++ b/src/core/downloader/dash/dash-recording-handler.ts @@ -0,0 +1,118 @@ +/** + * DASH live recording handler + * + * Polls a live DASH MPD at a fixed interval, collects new segments as they + * 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. + * Video segments are stored under downloadId, audio under downloadId + "_a". + */ + +import { Fragment } from "../../types"; +import { fetchText, fetchTextWithFinalUrl } 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, + 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(); + } + + /** + * 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<{ mediaUrl: string; finalUrl: string }> { + const { finalUrl } = await fetchTextWithFinalUrl(url, 1, abortSignal, false); + return { mediaUrl: url, finalUrl }; + } + + /** + * 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[]; audioFragments?: Fragment[]; pollIntervalMs: number; ended: boolean }> { + const mpdText = await fetchText(mpdUrl, 3, abortSignal, true); + + const manifest = parseManifest(mpdText, mpdUrl); + + // 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)); + + // 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] video new=${newVideoFragments.length}, audio new=${newAudioFragments?.length ?? 0}, ended=${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: { + 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 0be4fd3..c28be01 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, + }); } /** @@ -99,6 +109,7 @@ export class DownloadManager { manifestQuality?: { videoPlaylistUrl?: string | null; audioPlaylistUrl?: string | null; + selectedBandwidth?: number; }, isManual?: boolean, abortSignal?: AbortSignal, @@ -165,6 +176,16 @@ 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, + manifestQuality?.selectedBandwidth, + ); } else { throw new Error(`Unsupported format: ${format}`); } 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 4cf099c..36dd958 100644 --- a/src/core/downloader/hls/hls-download-handler.ts +++ b/src/core/downloader/hls/hls-download-handler.ts @@ -14,11 +14,12 @@ import { Level, DownloadStage } from "../../types"; import { logger } from "../../utils/logger"; 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"; @@ -83,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); @@ -144,6 +146,8 @@ export class HlsDownloadHandler extends BasePlaylistHandler { throwIfAborted(this.abortSignal); + const downloadJobs: Promise[] = []; + // Process video playlist if (videoPlaylistUrl) { if (!videoPlaylistText) { @@ -152,8 +156,8 @@ export class HlsDownloadHandler extends BasePlaylistHandler { } const videoFragments = parseLevelsPlaylist( - videoPlaylistText, - videoPlaylistUrl, + parseMediaPlaylist(videoPlaylistText, videoPlaylistUrl), + 0, ); if (videoFragments.length === 0) { @@ -161,23 +165,13 @@ export class HlsDownloadHandler extends BasePlaylistHandler { } logger.info(`Found ${videoFragments.length} video fragments`); + this.videoLength = videoFragments.length; - const indexedVideoFragments = videoFragments.map((frag, idx) => ({ - ...frag, - index: idx, - })); - - this.videoLength = indexedVideoFragments.length; - - await this.downloadAllFragments( - indexedVideoFragments, - this.downloadId, - stateId, + downloadJobs.push( + this.downloadAllFragments(videoFragments, this.downloadId, stateId), ); } - throwIfAborted(this.abortSignal); - // Process audio playlist if (audioPlaylistUrl) { if (!audioPlaylistText) { @@ -186,8 +180,8 @@ export class HlsDownloadHandler extends BasePlaylistHandler { } const audioFragments = parseLevelsPlaylist( - audioPlaylistText, - audioPlaylistUrl, + parseMediaPlaylist(audioPlaylistText, audioPlaylistUrl), + 0, ); if (audioFragments.length === 0) { @@ -195,22 +189,20 @@ export class HlsDownloadHandler extends BasePlaylistHandler { } logger.info(`Found ${audioFragments.length} audio fragments`); + this.audioLength = audioFragments.length; - const indexedAudioFragments = audioFragments.map((frag, idx) => ({ - ...frag, - index: this.videoLength + idx, - })); - - this.audioLength = indexedAudioFragments.length; - - await this.downloadAllFragments( - indexedAudioFragments, - 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..."); @@ -220,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, @@ -250,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; @@ -276,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/core/downloader/hls/hls-recording-handler.ts b/src/core/downloader/hls/hls-recording-handler.ts index 830414b..a900806 100644 --- a/src/core/downloader/hls/hls-recording-handler.ts +++ b/src/core/downloader/hls/hls-recording-handler.ts @@ -6,29 +6,21 @@ * #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 { fetchText } from "../../utils/fetch-utils"; +import { Fragment } from "../../types"; +import { fetchText, fetchTextWithFinalUrl } from "../../utils/fetch-utils"; import { parseMasterPlaylist, + parseMediaPlaylist, parseLevelsPlaylist, -} from "../../utils/m3u8-parser"; +} from "../../parsers/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,118 +34,38 @@ 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. + * Returns mediaUrl (URL to poll) and finalUrl (post-redirect URL for DNR header rules). */ - 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 { - 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)); @@ -161,128 +73,37 @@ export class HlsRecordingHandler extends BasePlaylistHandler { 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 }; } - 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(parseMediaPlaylist(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/downloader/m3u8/m3u8-download-handler.ts b/src/core/downloader/m3u8/m3u8-download-handler.ts index 10d3ef8..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 { 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"; @@ -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/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 68% rename from src/core/utils/m3u8-parser.ts rename to src/core/parsers/m3u8-parser.ts index 09a9c56..3eb30fe 100644 --- a/src/core/utils/m3u8-parser.ts +++ b/src/core/parsers/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 { normalizeUrl } from "./url-utils"; -import { logger } from "./logger"; +import { Level, LevelType } from "../types"; +import type { ParsedPlaylist, ParsedSegment } from "./playlist-utils"; +import { normalizeUrl } from "../utils/url-utils"; +import { logger } from "../utils/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/parsers/mpd-parser.ts b/src/core/parsers/mpd-parser.ts new file mode 100644 index 0000000..d5581ee --- /dev/null +++ b/src/core/parsers/mpd-parser.ts @@ -0,0 +1,134 @@ +/** + * 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, MpdManifest, MpdPlaylist } from "mpd-parser"; +import { v4 as uuidv4 } from "uuid"; +import { Level, LevelType } from "../types"; +import type { ParsedPlaylist, ParsedSegment } from "./playlist-utils"; +import { parseLevelsPlaylist } from "./playlist-utils"; + +// Re-export for callers that want the unified Fragment conversion. +export { parseLevelsPlaylist } from "./playlist-utils"; + +export type { MpdManifest } from "mpd-parser"; + +/** + * 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 }; +} + +/** + * 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, + })); +} + +/** + * 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 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]!); +} + +/** + * 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(). + * 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]!); + } + } + return null; +} + +/** + * 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 / = () => Promise; - // HLS Playlist Types export type LevelType = "stream" | "audio"; @@ -124,3 +123,4 @@ export interface Level { height?: number; width?: number; } + diff --git a/src/core/utils/fetch-utils.ts b/src/core/utils/fetch-utils.ts index 6e2d1fa..345db80 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 */ @@ -148,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/core/utils/url-utils.ts b/src/core/utils/url-utils.ts index 71d494a..516632f 100644 --- a/src/core/utils/url-utils.ts +++ b/src/core/utils/url-utils.ts @@ -44,6 +44,11 @@ export function detectFormatFromUrl(url: string): VideoFormat { const pathnameLower = urlObj.pathname.toLowerCase(); + // Check for DASH manifest files (.mpd) on pathname only + if (pathnameLower.endsWith(".mpd")) { + return VideoFormat.DASH; + } + // Check for HLS playlist files (.m3u8) on pathname only if (pathnameLower.endsWith(".m3u8")) { return VideoFormat.HLS; diff --git a/src/offscreen/offscreen.ts b/src/offscreen/offscreen.ts index db214af..eedf1d4 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. @@ -275,6 +223,7 @@ async function processHLSChunks( downloadId: string, videoLength: number, audioLength: number, + audioDownloadId?: string, onProgress?: (progress: number, message: string) => void, ): Promise { validateDownloadId(downloadId); @@ -287,10 +236,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 +295,198 @@ 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]); + } +} + +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]); + } +} + +/** + * 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", + "-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]); + } +} + +async function processDashChunks( + downloadId: string, + videoLength: number, + audioLength: number, + audioDownloadId?: string, + 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) { + 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, + 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 */ @@ -468,6 +614,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, ), ) @@ -490,6 +637,24 @@ 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, + payload.audioDownloadId as string | undefined, + onProgress, + ), + ) + ) + return true; + // Pre-warm FFmpeg while segments are downloading if (message.type === MessageType.WARMUP_FFMPEG) { sendResponse({ acknowledged: true }); 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 a86ac34..3f04d5b 100644 --- a/src/popup/render-manifest.ts +++ b/src/popup/render-manifest.ts @@ -4,8 +4,9 @@ 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 { 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,171 @@ export async function handleLoadManifestPlaylist(): Promise { try { const playlistText = await fetchTextViaBackground(normalizedUrl); - - 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; - } - - 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"; + 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; } - 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."; + + const live = MpdParser.isLive(playlistText); + setIsLiveManifest(live); + + 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."; + } } } - 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"); - - setIsLiveManifest(false); - if (videoLevels.length > 0) { - try { - const variantText = await fetchTextViaBackground(videoLevels[0]!.uri); - setIsLiveManifest(!variantText.includes("#EXT-X-ENDLIST")); - } catch {} - } + 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. 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 {} + } + + 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."; + } + } - updateDownloadButtonState(); + 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(); @@ -290,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 }; } } @@ -339,7 +417,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, @@ -354,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/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; } diff --git a/src/service-worker.ts b/src/service-worker.ts index 6e5b25a..4f6d1f7 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, @@ -34,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, @@ -132,6 +133,7 @@ async function handleDownloadRequestMessage(payload: { manifestQuality?: { videoPlaylistUrl?: string | null; audioPlaylistUrl?: string | null; + selectedBandwidth?: number; }; }): Promise<{ success: boolean; error?: string }> { try { @@ -323,6 +325,10 @@ chrome.webRequest.onCompleted.addListener( "https://*/*.m3u8", "http://*/*.m3u8?*", "https://*/*.m3u8?*", + "http://*/*.mpd", + "https://*/*.mpd", + "http://*/*.mpd?*", + "https://*/*.mpd?*", ], types: ["xmlhttprequest"], }, @@ -387,6 +393,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 +417,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; } @@ -454,6 +462,7 @@ async function handleDownloadRequest(payload: { manifestQuality?: { videoPlaylistUrl?: string | null; audioPlaylistUrl?: string | null; + selectedBandwidth?: number; }; isManual?: boolean; }): Promise<{ error?: string } | void> { @@ -576,8 +585,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)); @@ -723,6 +732,7 @@ async function startDownload( manifestQuality?: { videoPlaylistUrl?: string | null; audioPlaylistUrl?: string | null; + selectedBandwidth?: number; }, isManual?: boolean, abortSignal?: AbortSignal, @@ -731,10 +741,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 +846,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); @@ -872,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; @@ -927,19 +942,21 @@ async function handleStartRecordingMessage(payload: { } catch (_) {} }; - const handler = new HlsRecordingHandler({ - onProgress, - maxConcurrent, - ffmpegTimeout, - }); + const handler = + metadata.format === VideoFormat.DASH + ? new DashRecordingHandler({ onProgress, maxConcurrent, ffmpegTimeout, selectedBandwidth: payload.selectedBandwidth }) + : 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 { @@ -975,7 +992,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) { 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..63951ea --- /dev/null +++ b/src/types/mpd-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 }; +}