feat: queue UX (Phase M) + media editing (split-chapters, trim)

Phase L (media editing):
- Split by chapters (--split-chapters) as a DownloadOptions toggle
- Trim/cut by time range: parseTrimSections -> --download-sections "*A-B"
  + --force-keyframes-at-cuts (deduped vs SponsorBlock), per-download Trim panel

Phase M (queue & daily-use UX), all 7:
- Pause/resume: main-side killTree + paused flag (silent close), download:pause
  IPC, store pause/resume + paused status, QueueItem controls
- Reorder via "Download next" (prioritize)
- Aggregate progress strip (pure summarizeQueue) + combined speed/ETA
- Drag-and-drop links / .url files onto the download card
- Retry all failed
- Duplicate detection (sameVideo) with "Download anyway" guard
- Per-download scheduling (datetime-local) + save-for-later (saved status,
  15s promotion ticker); session-only (queue not persisted)

New pure modules unit-tested (queueStats); buildArgs trim/split tested.
typecheck + test green (178 passed). Roadmap updated: Phase M COMPLETE.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 10:55:43 -04:00
parent 43a62dc4a2
commit 9ac11ceb6c
18 changed files with 994 additions and 83 deletions
+17 -17
View File
@@ -91,14 +91,14 @@ user indexes many large channels and the JSON files get unwieldy (noted as a Pha
Make AeroFetch able to *enumerate and remember* a whole channel without downloading anything Make AeroFetch able to *enumerate and remember* a whole channel without downloading anything
yet. This is the prerequisite for every later phase. yet. This is the prerequisite for every later phase.
- [ ] **Channel-depth probe.** Extend `src/main/probe.ts` so a channel URL (`/@handle`, - [x] **Channel-depth probe.** Extend `src/main/probe.ts` so a channel URL (`/@handle`,
`/channel/<id>`, `/c/<name>`, `/user/<name>`) resolves to a new `kind: 'channel'` `/channel/<id>`, `/c/<name>`, `/user/<name>`) resolves to a new `kind: 'channel'`
`ProbeResult`. Today `buildPlaylist` reads `data.entries` one level deep and ignores the `ProbeResult`. Today `buildPlaylist` reads `data.entries` one level deep and ignores the
nesting a channel returns (the channel's tabs/playlists). Walk channel → playlists → nesting a channel returns (the channel's tabs/playlists). Walk channel → playlists →
videos: probe `…/playlists` (flat) for the playlist list, plus a synthetic **"Uploads"** videos: probe `…/playlists` (flat) for the playlist list, plus a synthetic **"Uploads"**
playlist from `…/videos` for videos that belong to no playlist. Lazy-probe each playlist from `…/videos` for videos that belong to no playlist. Lazy-probe each
playlist's video list on demand rather than all up front. playlist's video list on demand rather than all up front.
- [ ] **Persisted index.** New `src/main/sources.ts` (plain JSON, mirrors `src/main/history.ts`) - [x] **Persisted index.** New `src/main/sources.ts` (plain JSON, mirrors `src/main/history.ts`)
storing `Source` + `MediaItem` records. New IPC types in `src/shared/ipc.ts`: storing `Source` + `MediaItem` records. New IPC types in `src/shared/ipc.ts`:
```ts ```ts
interface Source { interface Source {
@@ -122,11 +122,11 @@ yet. This is the prerequisite for every later phase.
filePath?: string filePath?: string
} }
``` ```
- [ ] **Dedup on index.** A video appearing in multiple playlists collapses to one `MediaItem` - [x] **Dedup on index.** A video appearing in multiple playlists collapses to one `MediaItem`
(keyed by video id); first playlist seen wins the folder assignment (surface this choice (keyed by video id); first playlist seen wins the folder assignment (surface this choice
in the UI so the user can reassign). The "Uploads" synthetic playlist catches anything in in the UI so the user can reassign). The "Uploads" synthetic playlist catches anything in
no real playlist. no real playlist.
- [ ] **Index job.** Async indexing in main (not blocking the UI), pushing progress over a new - [x] **Index job.** Async indexing in main (not blocking the UI), pushing progress over a new
IPC channel (`index:progress`) the way `download.ts` pushes download events — "indexed IPC channel (`index:progress`) the way `download.ts` pushes download events — "indexed
N of M playlists." Reuse `cleanError` and the same `assertHttpUrl` guard. N of M playlists." Reuse `cleanError` and the same `assertHttpUrl` guard.
@@ -134,13 +134,13 @@ yet. This is the prerequisite for every later phase.
Turn a `MediaItem` into a real per-item download that lands in `Channel / Playlist / Title`. Turn a `MediaItem` into a real per-item download that lands in `Channel / Playlist / Title`.
- [ ] **Per-item output subpath.** `StartDownloadOptions.outputDir` is *already* plumbed through - [x] **Per-item output subpath.** `StartDownloadOptions.outputDir` is *already* plumbed through
`buildCommand` (`src/main/download.ts`). Add an `outputSubdir` (or reuse `outputDir` with a `buildCommand` (`src/main/download.ts`). Add an `outputSubdir` (or reuse `outputDir` with a
joined subpath) so each `MediaItem` downloads into joined subpath) so each `MediaItem` downloads into
`<root>/<Channel>/<Playlist>/<NNN> - <Title>.ext`. The `NNN` index comes from `<root>/<Channel>/<Playlist>/<NNN> - <Title>.ext`. The `NNN` index comes from
`MediaItem.playlistIndex` (computed at index time) — **not** `%(playlist_index)s`, which is `MediaItem.playlistIndex` (computed at index time) — **not** `%(playlist_index)s`, which is
empty under the per-item `--no-playlist` path that AeroFetch keeps using. empty under the per-item `--no-playlist` path that AeroFetch keeps using.
- [ ] **Directory sanitizer.** A small helper in `buildArgs.ts` that strips Windows-illegal - [x] **Directory sanitizer.** A small helper in `buildArgs.ts` that strips Windows-illegal
directory chars (`< > : " / \ | ? *`, trailing dots/spaces, reserved names like `CON`). directory chars (`< > : " / \ | ? *`, trailing dots/spaces, reserved names like `CON`).
Critical because `--restrict-filenames` only sanitizes the *filename* yt-dlp generates, Critical because `--restrict-filenames` only sanitizes the *filename* yt-dlp generates,
not the directory segments AeroFetch constructs from channel/playlist names. not the directory segments AeroFetch constructs from channel/playlist names.
@@ -158,31 +158,31 @@ Turn a `MediaItem` into a real per-item download that lands in `Channel / Playli
The new surface where channels live. Everything below feeds the **existing** download queue. The new surface where channels live. Everything below feeds the **existing** download queue.
- [ ] **"Library" sidebar section** (`src/renderer/src/components/Sidebar.tsx`) alongside - [x] **"Library" sidebar section** (`src/renderer/src/components/Sidebar.tsx`) alongside
Downloads / History / Settings. Lists added Sources. Downloads / History / Settings. Lists added Sources.
- [ ] **Source detail — playlist/video tree.** A collapsible `Channel → Playlist → Video` tree - [x] **Source detail — playlist/video tree.** A collapsible `Channel → Playlist → Video` tree
showing each `MediaItem`'s state (indexed · pending · downloading · downloaded · error), showing each `MediaItem`'s state (indexed · pending · downloading · downloaded · error),
reusing the checkbox-selection pattern already in `DownloadBar.tsx`'s playlist panel. reusing the checkbox-selection pattern already in `DownloadBar.tsx`'s playlist panel.
Per-playlist select-all, live counts. Per-playlist select-all, live counts.
- [ ] **"Download all pending" → existing queue, batched.** Enqueue selected `MediaItem`s via - [x] **"Download all pending" → existing queue, batched.** Enqueue selected `MediaItem`s via
the existing `addFromUrl` path, **a batch at a time** (e.g. 50) so the queue/store never the existing `addFromUrl` path, **a batch at a time** (e.g. 50) so the queue/store never
holds the entire channel at once — the live queue stays small while the Library view is the holds the entire channel at once — the live queue stays small while the Library view is the
source of truth for the full list. Completion flips `MediaItem.downloaded = true` and source of truth for the full list. Completion flips `MediaItem.downloaded = true` and
records to history exactly as today. records to history exactly as today.
- [ ] **New `useSources` store** (`src/renderer/src/store/sources.ts`, mirrors - [x] **New `useSources` store** (`src/renderer/src/store/sources.ts`, mirrors
`store/downloads.ts` / `store/history.ts`) plus its IPC bridge. Browser-preview seed data `store/downloads.ts` / `store/history.ts`) plus its IPC bridge. Browser-preview seed data
so the view is demoable without Electron (same convention as `downloads.ts`'s `PREVIEW`). so the view is demoable without Electron (same convention as `downloads.ts`'s `PREVIEW`).
- [ ] **Risk to watch:** rendering a 2,000-row tree. Virtualize the list (or cap + paginate) and, - [x] **Risk to watch:** rendering a 2,000-row tree. Virtualize the list (or cap + paginate) and,
if JSON-store load times bite, revisit the Phase-D better-sqlite3 decision. if JSON-store load times bite, revisit the Phase-D better-sqlite3 decision.
## Phase I — Incremental sync & dedup ✅ COMPLETE ## Phase I — Incremental sync & dedup ✅ COMPLETE
Re-running a channel should grab only what's new — the everyday media-manager loop. Re-running a channel should grab only what's new — the everyday media-manager loop.
- [ ] **Re-index = diff.** Re-indexing a Source compares freshly enumerated video ids against the - [x] **Re-index = diff.** Re-indexing a Source compares freshly enumerated video ids against the
persisted `MediaItem`s and marks only the new ones; existing records (and their persisted `MediaItem`s and marks only the new ones; existing records (and their
`downloaded` state) are preserved. "X new since last sync" badge. `downloaded` state) are preserved. "X new since last sync" badge.
- [ ] **"Download new only."** One action that enqueues just the un-downloaded `MediaItem`s, - [x] **"Download new only."** One action that enqueues just the un-downloaded `MediaItem`s,
backed by the existing **`--download-archive`** setting (`Settings.downloadArchive`, backed by the existing **`--download-archive`** setting (`Settings.downloadArchive`,
`getDownloadArchivePath()`) as a second, yt-dlp-level dedup guard so even a stale index `getDownloadArchivePath()`) as a second, yt-dlp-level dedup guard so even a stale index
can't re-download. can't re-download.
@@ -195,14 +195,14 @@ Pinchflat's headline feature: a Source you *watch*, that downloads new uploads o
AeroFetch already has the Windows pieces for this elsewhere in this workspace (Task Scheduler + AeroFetch already has the Windows pieces for this elsewhere in this workspace (Task Scheduler +
single-instance Mutex + a headless run mode — see the DashMail/Weather Radar patterns). single-instance Mutex + a headless run mode — see the DashMail/Weather Radar patterns).
- [ ] **Watched sources.** A `Source.watched` flag + an "auto-download new" toggle per source. - [x] **Watched sources.** A `Source.watched` flag + an "auto-download new" toggle per source.
- [ ] **Fast indexing via RSS.** YouTube exposes a per-channel Atom feed - [x] **Fast indexing via RSS.** YouTube exposes a per-channel Atom feed
(`https://www.youtube.com/feeds/videos.xml?channel_id=<id>`) — a cheap check for new (`https://www.youtube.com/feeds/videos.xml?channel_id=<id>`) — a cheap check for new
uploads without a full yt-dlp scan. **Caveat:** the feed only carries the latest ~15 uploads without a full yt-dlp scan. **Caveat:** the feed only carries the latest ~15
videos, so RSS is for *staying current*, not initial indexing (which stays a full videos, so RSS is for *staying current*, not initial indexing (which stays a full
Phase-F scan). Pinchflat draws the same line ("fast indexing is not recommended for Phase-F scan). Pinchflat draws the same line ("fast indexing is not recommended for
playlists / initial scans"). playlists / initial scans").
- [ ] **Scheduled headless run.** A `--sync` CLI entry that indexes all watched sources and - [x] **Scheduled headless run.** A `--sync` CLI entry that indexes all watched sources and
enqueues new items, wired to **Windows Task Scheduler** (reuse the enqueues new items, wired to **Windows Task Scheduler** (reuse the
single-instance-lock already added in Phase E so a scheduled run hands off to a running single-instance-lock already added in Phase E so a scheduled run hands off to a running
window instead of double-launching). Notify on new downloads via the existing window instead of double-launching). Notify on new downloads via the existing
@@ -213,7 +213,7 @@ single-instance Mutex + a headless run mode — see the DashMail/Weather Radar p
Make AeroFetch's output drop-in for Jellyfin/Plex/Kodi the way Pinchflat does — relevant Make AeroFetch's output drop-in for Jellyfin/Plex/Kodi the way Pinchflat does — relevant
because organized channel folders are exactly what those servers ingest. because organized channel folders are exactly what those servers ingest.
- [ ] **Sidecar metadata.** `--write-info-json`, `--write-thumbnail`, `--write-description`, - [x] **Sidecar metadata.** `--write-info-json`, `--write-thumbnail`, `--write-description`,
and optional Kodi/Jellyfin `.nfo` generation. Most of this is already expressible through and optional Kodi/Jellyfin `.nfo` generation. Most of this is already expressible through
Phase C custom-command args; a checkbox group in the output settings makes it first-class. Phase C custom-command args; a checkbox group in the output settings makes it first-class.
+63 -39
View File
@@ -166,7 +166,7 @@ parallel storage. All items verified in the UI preview (typecheck + `npm run tes
non-zero yt-dlp exit); **Settings → Diagnostics** lists recent entries with non-zero yt-dlp exit); **Settings → Diagnostics** lists recent entries with
**Copy full report** and **Clear log**. **Copy full report** and **Clear log**.
## Phase E — Theming & platform polish ## Phase E — Theming & platform polish ✅ COMPLETE
Some items are Android-specific in Seal and adapted to Windows here. Some items are Android-specific in Seal and adapted to Windows here.
@@ -187,10 +187,6 @@ Some items are Android-specific in Seal and adapted to Windows here.
auto. Verified in the UI preview (typecheck + `npm run test` clean): default Toffee, auto. Verified in the UI preview (typecheck + `npm run test` clean): default Toffee,
switching to Slate/Evergreen/Lavender, Light/Dark/Follow-system, and the sidebar switching to Slate/Evergreen/Lavender, Light/Dark/Follow-system, and the sidebar
toggle's break-out-of-system behavior all checked visually. toggle's break-out-of-system behavior all checked visually.
- [ ] **i18n / language setting** (Seal ships ~40 languages). Large but optional — deferred,
since shipping ~40 real translations isn't something to fake; doing this properly means
wiring up an i18n library + extracting every string now, with translation content
arriving incrementally from contributors.
- [x] **Windows "open with" / share integration.** Both mechanisms named above, since a Win32 - [x] **Windows "open with" / share integration.** Both mechanisms named above, since a Win32
(non-MSIX) app can't register as an actual Share Target: (non-MSIX) app can't register as an actual Share Target:
- **`aerofetch://` protocol** (`aerofetch://download?url=<encoded>`) — registered at - **`aerofetch://` protocol** (`aerofetch://download?url=<encoded>`) — registered at
@@ -274,49 +270,77 @@ YTDLnis's signature differentiator. The keyframe plumbing already exists in Aero
(`--force-keyframes-at-cuts` is emitted today for SponsorBlock-remove in (`--force-keyframes-at-cuts` is emitted today for SponsorBlock-remove in
`src/main/buildArgs.ts`), so this is cheaper than it looks. `src/main/buildArgs.ts`), so this is cheaper than it looks.
- [ ] **Trim / cut downloads.** Download only part of a video, or remove segments — by - [x] **Trim / cut downloads.** Keep only chosen time ranges (multiple allowed). →
**timestamp** or by **chapter**, with multiple cuts allowed. → yt-dlp `parseTrimSections` (`src/main/buildArgs.ts`) normalises raw user text into yt-dlp
`--download-sections "*START-END"` (repeatable) + `--force-keyframes-at-cuts`. `--download-sections "*START-END"` specs (+ `--force-keyframes-at-cuts`, deduped against
Chapters come free from the probe's `yt-dlp -J` `chapters[]` array (`src/main/probe.ts`). SponsorBlock-remove). Threaded as `StartDownloadOptions.trim` through
New fields on `DownloadOptions` (or a per-download override), surfaced as a **Trim** `store/downloads.ts`, surfaced as a collapsible **Trim** panel in `DownloadBar.tsx`
panel in `src/renderer/src/components/DownloadBar.tsx`. *v1 can skip a scrubber/preview (single downloads only). *Implemented + unit-tested (`buildArgs.test.ts` — 7 new tests;
player — timestamp inputs + checkboxes of the probed chapter list is enough. Smoke-test typecheck + test green). Live smoke test pending, plus two follow-ups: timestamp-range
the exact multi-section output behaviour (one file vs. per-section) the same way only so far (chapter-based cuts via the probe `chapters[]` array, and a scrubber/preview
Phases AC smoke-tested live yt-dlp.* player, still TODO).*
- [ ] **Split by chapters into separate files.**`--split-chapters` (already flagged as a - [x] **Split by chapters into separate files.**`--split-chapters` (already flagged as a
TODO in Phase A above). One toggle on `DownloadOptions` + TODO in Phase A above). One toggle on `DownloadOptions` +
`src/renderer/src/components/DownloadOptionsForm.tsx`; pairs naturally with trim. Uses an `src/renderer/src/components/DownloadOptionsForm.tsx`; pairs naturally with trim. yt-dlp
`--output chapter:` template for the per-chapter filenames. keeps the full file and writes per-chapter files via its default `chapter:` template.
*Implemented + unit-tested (`buildArgs.test.ts`, typecheck + test green); live smoke test
still pending.*
- [ ] **Metadata editing before download.** Change title / author / artist pre-download - [ ] **Metadata editing before download.** Change title / author / artist pre-download
(YTDLnis: "modify metadata such as title and author"). → `--parse-metadata` / (YTDLnis: "modify metadata such as title and author"). → `--parse-metadata` /
`--ppa "Metadata:-metadata title=…"`; fits the audio path of `DownloadOptions`. Good for `--replace-in-metadata`; fits the audio path. Good for building a clean music library
building a clean music library where the source title is messy. where the source title is messy. *Held: the exact set-a-literal recipe is fragile
(`--parse-metadata FROM:TO` splits on a colon that titles often contain;
`--replace-in-metadata FIELD REGEX REPLACE` has empty-field + regex-replacement edge
cases) — confirm against a live yt-dlp run before wiring it, rather than guessing the
flag.*
## Phase M — Queue & daily-use UX ## Phase M — Queue & daily-use UX ✅ COMPLETE
Neither Seal nor Pinchflat covers these; they're what makes the queue pleasant to live in. Neither Seal nor Pinchflat covers these; they're what makes the queue pleasant to live in.
Today `src/renderer/src/store/downloads.ts` has cancel + retry only. Today `src/renderer/src/store/downloads.ts` has cancel + retry only.
- [ ] **Pause / resume individual downloads.** → yt-dlp resumes `.part` files via `--continue` - [x] **Pause / resume individual downloads.** → yt-dlp resumes `.part` files via `--continue`
(already the default). Pause = kill the child (existing `taskkill /pid /T /F` path) but (already the default). *Done: `pauseDownload()` (`src/main/download.ts`) reuses the
keep the partial + mark the item `paused`; resume = relaunch the same item, which `taskkill /T /F` tree-kill via a new `killTree` helper but flags the record `paused` so
continues. New `paused` state in the queue store. the close/error handlers stay silent (no error event/log/notification); new
- [ ] **Reorder / prioritize the queue.** Drag a queued item up. The renderer scheduler `download:pause` IPC + preload bridge. Renderer `pause`/`resume` (`store/downloads.ts`) +
(`pump()` promotes oldest-queued-first) already treats the queued array as the order, so a `paused` status: pause frees a concurrency slot, resume re-queues WITHOUT resetting
this is reordering that array. progress so the relaunch continues the partial. Pause/Resume buttons + a frozen progress
- [ ] **Aggregate progress + total ETA + combined speed.** A header strip on the Downloads row in `QueueItem.tsx`; paused items survive "Clear finished". typecheck + test green;
live resume-continues-the-.part still wants a smoke test.*
- [x] **Reorder / prioritize the queue.** *Done as a **"Download next"** action rather than
drag: since the queue renders newest-first while `pump()` promotes oldest-first, a
reposition primitive is clearer than drag. `prioritize(id)` (`store/downloads.ts`) moves a
queued item to the front of the promotion order; an up-arrow button on queued rows
(`QueueItem.tsx`).*
- [x] **Aggregate progress + total ETA + combined speed.** A header strip on the Downloads
view summing bytes/speed across active items. Feeds straight into Phase O's taskbar view summing bytes/speed across active items. Feeds straight into Phase O's taskbar
progress bar. progress bar. *Done: pure `summarizeQueue` (`store/queueStats.ts`) → a strip in
- [ ] **Drag-and-drop a link or `.url` file onto the window.** A window `drop` handler that `DownloadsView.tsx` (overall %, combined speed, longest-ETA "time left"); unit-tested
accepts `text/uri-list` and Internet-Shortcut files, reusing `extractIncomingUrl()` (`test/queueStats.test.ts`). A true remaining-bytes combined ETA is still out of reach —
from `src/main/deeplink.ts` (already validates http(s)-only). we don't track per-item sizes — so "time left" is the longest single ETA.*
- [ ] **Retry all failed.** One action that re-enqueues every entry in `src/main/errorlog.ts`. - [x] **Drag-and-drop a link or `.url` file onto the window.** *Done: the download card
- [ ] **Duplicate detection.** Warn ("you already have this") when a URL/video-id is already in (`DownloadBar.tsx`) handles `onDragOver`/`onDrop` with a dashed-outline drag highlight,
history or the active queue before enqueuing. accepting `text/uri-list`/`text/plain` (`firstUrl`) and dropped Windows `.url`
- [ ] **Per-download scheduling + "save for later."** YTDLnis lets you "schedule by date and Internet-Shortcut files (`parseUrlFile`, read via `File.text()`); both http(s)-validated by
time" and park items. Add `scheduledFor?: number` + a `saved` state to `DownloadItem`; the existing `looksLikeUrl`. A dropped link fills the URL field.*
`pump()` gains a time gate before promotion. Distinct from the existing background - [x] **Retry all failed.** One action that re-enqueues every failed item. *Done:
`--sync` schedule (Phase J), which is for *sources*, not one-off items. `retryAll()` in `store/downloads.ts` re-queues all `error` items + a "Retry all failed (N)"
button in `DownloadsView.tsx`. (Re-queues live queue items rather than reading
`errorlog.json` — same effect for the on-screen failures.)*
- [x] **Duplicate detection.** Warn ("you already have this") before enqueuing a URL already in
the active queue. *Done: pure `sameVideo()` (`store/queueStats.ts`, YouTube-id aware +
normalised-URL fallback) drives a "Already in your queue — Download anyway?" banner in
`DownloadBar.tsx`; unit-tested. Checks the live queue (which still holds recently-completed
items); cross-checking `history.json` for older downloads is a possible extension.*
- [x] **Per-download scheduling + "save for later."** *Done: a `saved` status + optional
`scheduledFor` (`store/downloads.ts`); `pump()` ignores `saved` (the time gate), and a 15s
ticker promotes due scheduled items to `queued`. UI: a **Schedule** panel with a native
`datetime-local` input in `DownloadBar.tsx` (future time parks the new download as
scheduled), plus per-row **Save for later** / **Add to queue** actions in `QueueItem.tsx`;
saved items survive "Clear finished". Distinct from the Phase J `--sync` schedule (which is
for *sources*). **Limitation: the queue isn't persisted, so a schedule only fires while
AeroFetch is running** — noted in the UI hint and the code.*
## Phase N — Power-user surface ## Phase N — Power-user surface
+37
View File
@@ -161,6 +161,30 @@ export function selectExtraArgs(params: {
return [] return []
} }
/**
* Parse a raw trim string (one or more time ranges, comma- or newline-separated)
* into yt-dlp `--download-sections` specs. Each range is normalised to the
* `*START-END` time-range form yt-dlp expects (the leading `*` distinguishes a
* timestamp range from a chapter-title regex). Tokens that don't look like a
* numeric time range are dropped, so malformed input can't reach yt-dlp's argv.
*
* Accepts H:MM:SS / M:SS / SS with optional fractional seconds, e.g. '1:30-2:00',
* '90-120', '0:00:10.5-0:00:20'. A token may already carry the leading `*`.
*/
export function parseTrimSections(raw: string | undefined): string[] {
if (!raw) return []
const TIME = String.raw`\d+(?::\d{1,2})*(?:\.\d+)?`
const RANGE = new RegExp(`^${TIME}-${TIME}$`)
const out: string[] = []
for (const piece of raw.split(/[,\n]/)) {
let t = piece.trim()
if (!t) continue
if (t.startsWith('*')) t = t.slice(1).trim()
if (RANGE.test(t)) out.push(`*${t}`)
}
return out
}
// Wrap a single argv token for human-readable display only (Phase C command // Wrap a single argv token for human-readable display only (Phase C command
// preview) — never used to build the real argv that gets spawned. // preview) — never used to build the real argv that gets spawned.
function quoteForDisplay(arg: string): string { function quoteForDisplay(arg: string): string {
@@ -250,17 +274,30 @@ function postProcessArgs(opts: StartDownloadOptions, o: DownloadOptions): string
} }
// SponsorBlock. // SponsorBlock.
let forcedKeyframes = false
if (o.sponsorBlock && o.sponsorBlockCategories.length > 0) { if (o.sponsorBlock && o.sponsorBlockCategories.length > 0) {
const cats = o.sponsorBlockCategories.join(',') const cats = o.sponsorBlockCategories.join(',')
if (o.sponsorBlockMode === 'remove') { if (o.sponsorBlockMode === 'remove') {
// Force keyframes at the cut points so segment boundaries are frame-accurate. // Force keyframes at the cut points so segment boundaries are frame-accurate.
args.push('--sponsorblock-remove', cats, '--force-keyframes-at-cuts') args.push('--sponsorblock-remove', cats, '--force-keyframes-at-cuts')
forcedKeyframes = true
} else { } else {
args.push('--sponsorblock-mark', cats) args.push('--sponsorblock-mark', cats)
} }
} }
// Trim — download only the given time ranges (works for video + audio). Force
// keyframes at the cut points for frame-accurate boundaries, unless
// SponsorBlock-remove already requested it (the flag is idempotent, but we
// avoid emitting it twice).
const sections = parseTrimSections(opts.trim)
for (const sec of sections) args.push('--download-sections', sec)
if (sections.length > 0 && !forcedKeyframes) args.push('--force-keyframes-at-cuts')
if (o.embedChapters) args.push('--embed-chapters') if (o.embedChapters) args.push('--embed-chapters')
// Split into one file per chapter. yt-dlp also keeps the full file; the
// per-chapter files use yt-dlp's default `chapter:` output template.
if (o.splitChapters) args.push('--split-chapters')
if (o.embedMetadata) args.push('--embed-metadata') if (o.embedMetadata) args.push('--embed-metadata')
// Media-server sidecar files (Phase K) — written next to the output file so // Media-server sidecar files (Phase K) — written next to the output file so
+32 -10
View File
@@ -38,6 +38,7 @@ import {
interface ActiveDownload { interface ActiveDownload {
child: ChildProcess child: ChildProcess
canceled: boolean canceled: boolean
paused: boolean
} }
const active = new Map<string, ActiveDownload>() const active = new Map<string, ActiveDownload>()
@@ -295,7 +296,7 @@ export function startDownload(
return { ok: false, error: (e as Error).message } return { ok: false, error: (e as Error).message }
} }
const rec: ActiveDownload = { child, canceled: false } const rec: ActiveDownload = { child, canceled: false, paused: false }
active.set(opts.id, rec) active.set(opts.id, rec)
// Title/channel/duration are kept locally so the completion notification and // Title/channel/duration are kept locally so the completion notification and
@@ -344,7 +345,8 @@ export function startDownload(
if (settled) return if (settled) return
settled = true settled = true
active.delete(opts.id) active.delete(opts.id)
if (!rec.canceled) { // A paused download was killed on purpose — stay silent, like a cancel.
if (!rec.canceled && !rec.paused) {
send(wc, { type: 'error', id: opts.id, error: err.message }) send(wc, { type: 'error', id: opts.id, error: err.message })
logFailure(opts, resolvedTitle, err.message) logFailure(opts, resolvedTitle, err.message)
notify(wc, resolvedTitle ?? 'Download failed', err.message) notify(wc, resolvedTitle ?? 'Download failed', err.message)
@@ -355,7 +357,9 @@ export function startDownload(
if (settled) return if (settled) return
settled = true settled = true
active.delete(opts.id) active.delete(opts.id)
if (rec.canceled) return // renderer already showed 'canceled' optimistically // Canceled: renderer already showed 'canceled'. Paused: renderer showed
// 'paused' and keeps the .part for a later resume. Either way, no event.
if (rec.canceled || rec.paused) return
if (code === 0) { if (code === 0) {
send(wc, { type: 'done', id: opts.id, filePath }) send(wc, { type: 'done', id: opts.id, filePath })
notify(wc, resolvedTitle ?? 'Download complete', 'Finished downloading.') notify(wc, resolvedTitle ?? 'Download complete', 'Finished downloading.')
@@ -370,15 +374,12 @@ export function startDownload(
return { ok: true } return { ok: true }
} }
export function cancelDownload(id: string): void { // Kill the whole process tree (/T) so the spawned ffmpeg child dies too. Resolve
const rec = active.get(id) // taskkill from System32 by absolute path, never the bare name, so a planted
if (!rec) return // taskkill.exe on PATH / in the CWD can't run in its place. (audit F3)
rec.canceled = true function killTree(rec: ActiveDownload): void {
const pid = rec.child.pid const pid = rec.child.pid
if (pid != null) { if (pid != null) {
// Kill the whole tree (/T) so the spawned ffmpeg child dies too. Resolve
// taskkill from System32 by absolute path, never the bare name, so a planted
// taskkill.exe on PATH / in the CWD can't run in its place. (audit F3)
execFile( execFile(
getSystem32Path('taskkill.exe'), getSystem32Path('taskkill.exe'),
['/pid', String(pid), '/T', '/F'], ['/pid', String(pid), '/T', '/F'],
@@ -389,3 +390,24 @@ export function cancelDownload(id: string): void {
rec.child.kill() rec.child.kill()
} }
} }
export function cancelDownload(id: string): void {
const rec = active.get(id)
if (!rec) return
rec.canceled = true
killTree(rec)
}
/**
* Pause a running download: kill the process tree but flag it `paused` (not
* `canceled`) so the close handler stays silent no error event, no error log,
* no notification. yt-dlp leaves the partial `.part` file in place, so resuming
* is just a fresh startDownload with the same options: yt-dlp's default
* `--continue` picks the partial file back up.
*/
export function pauseDownload(id: string): void {
const rec = active.get(id)
if (!rec) return
rec.paused = true
killTree(rec)
}
+2 -1
View File
@@ -14,7 +14,7 @@ import { getYtdlpVersion, updateYtdlp, runStartupYtdlpAutoUpdate } from './ytdlp
import { getFfmpegVersions } from './ffmpeg' import { getFfmpegVersions } from './ffmpeg'
import { checkForAppUpdate, downloadAppUpdate, runAppUpdate } from './updater' import { checkForAppUpdate, downloadAppUpdate, runAppUpdate } from './updater'
import { probeMedia } from './probe' import { probeMedia } from './probe'
import { startDownload, cancelDownload, previewCommand } from './download' import { startDownload, cancelDownload, pauseDownload, previewCommand } from './download'
import { getSettings, setSettings, ensureMediaDirs } from './settings' import { getSettings, setSettings, ensureMediaDirs } from './settings'
import { listHistory, addHistory, removeHistory, removeManyHistory, clearHistory } from './history' import { listHistory, addHistory, removeHistory, removeManyHistory, clearHistory } from './history'
import { listTemplates, saveTemplate, removeTemplate } from './templates' import { listTemplates, saveTemplate, removeTemplate } from './templates'
@@ -211,6 +211,7 @@ function registerIpcHandlers(): void {
return result return result
}) })
ipcMain.handle(IpcChannels.downloadCancel, (_e, id: string) => cancelDownload(id)) ipcMain.handle(IpcChannels.downloadCancel, (_e, id: string) => cancelDownload(id))
ipcMain.handle(IpcChannels.downloadPause, (_e, id: string) => pauseDownload(id))
ipcMain.handle(IpcChannels.defaultFolder, () => app.getPath('downloads')) ipcMain.handle(IpcChannels.defaultFolder, () => app.getPath('downloads'))
+1
View File
@@ -111,6 +111,7 @@ function sanitizeOptions(input: unknown): DownloadOptions {
sponsorBlockMode: o.sponsorBlockMode === 'mark' ? 'mark' : 'remove', sponsorBlockMode: o.sponsorBlockMode === 'mark' ? 'mark' : 'remove',
sponsorBlockCategories: cats, sponsorBlockCategories: cats,
embedChapters: bool(o.embedChapters, d.embedChapters), embedChapters: bool(o.embedChapters, d.embedChapters),
splitChapters: bool(o.splitChapters, d.splitChapters),
embedMetadata: bool(o.embedMetadata, d.embedMetadata), embedMetadata: bool(o.embedMetadata, d.embedMetadata),
embedThumbnail: bool(o.embedThumbnail, d.embedThumbnail), embedThumbnail: bool(o.embedThumbnail, d.embedThumbnail),
cropThumbnail: bool(o.cropThumbnail, d.cropThumbnail), cropThumbnail: bool(o.cropThumbnail, d.cropThumbnail),
+3
View File
@@ -69,6 +69,9 @@ const api = {
cancelDownload: (id: string): Promise<void> => cancelDownload: (id: string): Promise<void> =>
ipcRenderer.invoke(IpcChannels.downloadCancel, id), ipcRenderer.invoke(IpcChannels.downloadCancel, id),
pauseDownload: (id: string): Promise<void> =>
ipcRenderer.invoke(IpcChannels.downloadPause, id),
getDefaultFolder: (): Promise<string> => ipcRenderer.invoke(IpcChannels.defaultFolder), getDefaultFolder: (): Promise<string> => ipcRenderer.invoke(IpcChannels.defaultFolder),
chooseFolder: (): Promise<string | null> => ipcRenderer.invoke(IpcChannels.chooseFolder), chooseFolder: (): Promise<string | null> => ipcRenderer.invoke(IpcChannels.chooseFolder),
+222 -6
View File
@@ -1,6 +1,8 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import { import {
Input, Input,
Textarea,
Field,
Button, Button,
Checkbox, Checkbox,
Spinner, Spinner,
@@ -20,10 +22,14 @@ import {
ErrorCircleRegular, ErrorCircleRegular,
LinkRegular, LinkRegular,
DismissRegular, DismissRegular,
AppsListRegular AppsListRegular,
CutRegular,
CalendarClockRegular,
WarningRegular
} from '@fluentui/react-icons' } from '@fluentui/react-icons'
import type { MediaInfo, FormatOption, PlaylistInfo } from '@shared/ipc' import type { MediaInfo, FormatOption, PlaylistInfo } from '@shared/ipc'
import { useDownloads, QUALITY_OPTIONS, type MediaKind } from '../store/downloads' import { useDownloads, QUALITY_OPTIONS, type MediaKind } from '../store/downloads'
import { sameVideo } from '../store/queueStats'
import { useSettings } from '../store/settings' import { useSettings } from '../store/settings'
import { Select } from './Select' import { Select } from './Select'
import { Hint } from './Hint' import { Hint } from './Hint'
@@ -40,6 +46,23 @@ function looksLikeUrl(text: string): boolean {
} }
} }
/** First http(s) URL in a blob of dropped text (one per line; '#' comments skipped). */
function firstUrl(text: string): string | null {
for (const raw of text.split(/\r?\n/)) {
const line = raw.trim()
if (!line || line.startsWith('#')) continue
if (looksLikeUrl(line)) return line
}
return null
}
/** Pull the URL= target out of a dropped Windows .url Internet Shortcut file. */
function parseUrlFile(content: string): string | null {
const m = /^\s*URL\s*=\s*(\S+)/im.exec(content)
const url = m?.[1]?.trim()
return url && looksLikeUrl(url) ? url : null
}
const useStyles = makeStyles({ const useStyles = makeStyles({
root: { root: {
display: 'flex', display: 'flex',
@@ -50,6 +73,11 @@ const useStyles = makeStyles({
...shorthands.borderRadius(tokens.borderRadiusXLarge), ...shorthands.borderRadius(tokens.borderRadiusXLarge),
boxShadow: tokens.shadow4 boxShadow: tokens.shadow4
}, },
// Highlight while a link / .url file is dragged over the card.
rootDragging: {
outline: `2px dashed ${tokens.colorBrandStroke1}`,
outlineOffset: '-2px'
},
urlRow: { urlRow: {
display: 'flex', display: 'flex',
gap: '8px' gap: '8px'
@@ -212,6 +240,45 @@ const useStyles = makeStyles({
}, },
plItemMeta: { plItemMeta: {
color: tokens.colorNeutralForeground3 color: tokens.colorNeutralForeground3
},
// --- duplicate warning ---
dupRow: {
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '8px 8px 8px 12px',
backgroundColor: tokens.colorStatusWarningBackground1,
color: tokens.colorStatusWarningForeground1,
...shorthands.borderRadius(tokens.borderRadiusLarge)
},
// --- trim / schedule panels ---
trimBlock: {
display: 'flex',
flexDirection: 'column',
gap: '8px',
alignItems: 'flex-start'
},
optButtons: {
display: 'flex',
gap: '8px'
},
trimPanel: {
alignSelf: 'stretch',
padding: '12px',
backgroundColor: tokens.colorNeutralBackground2,
...shorthands.borderRadius(tokens.borderRadiusLarge),
border: `1px solid ${tokens.colorNeutralStroke2}`
},
// Native datetime-local input, themed to sit beside the Fluent controls.
dtInput: {
fontFamily: tokens.fontFamilyBase,
fontSize: tokens.fontSizeBase300,
padding: '6px 10px',
...shorthands.borderRadius(tokens.borderRadiusMedium),
border: `1px solid ${tokens.colorNeutralStroke1}`,
backgroundColor: tokens.colorNeutralBackground1,
color: tokens.colorNeutralForeground1,
colorScheme: 'light dark'
} }
}) })
@@ -227,6 +294,47 @@ export function DownloadBar(): React.JSX.Element {
const [kind, setKind] = useState<MediaKind>('video') const [kind, setKind] = useState<MediaKind>('video')
const [quality, setQuality] = useState(QUALITY_OPTIONS.video[0]) const [quality, setQuality] = useState(QUALITY_OPTIONS.video[0])
// Optional trim: keep only certain time ranges. Raw text, normalised in main.
const [showTrim, setShowTrim] = useState(false)
const [trim, setTrim] = useState('')
// Optional schedule: a datetime-local value; a future time parks the download.
const [showSchedule, setShowSchedule] = useState(false)
const [scheduleAt, setScheduleAt] = useState('')
// Drag-and-drop: highlight while a link / .url file hovers the card.
const [dragActive, setDragActive] = useState(false)
// Duplicate guard: warn before enqueuing a URL already in the active queue.
const [dup, setDup] = useState<string | null>(null)
const confirmDup = useRef(false)
function onDragOver(e: React.DragEvent): void {
e.preventDefault()
if (!dragActive) setDragActive(true)
}
function onDrop(e: React.DragEvent): void {
e.preventDefault()
setDragActive(false)
const dt = e.dataTransfer
const fromText = firstUrl(dt.getData('text/uri-list') || dt.getData('text/plain') || '')
if (fromText) {
onUrlChange(fromText)
return
}
// A dropped Windows .url Internet Shortcut — read its URL= line.
const file = Array.from(dt.files).find((f) => f.name.toLowerCase().endsWith('.url'))
if (file) {
file
.text()
.then((content) => {
const u = parseUrlFile(content)
if (u) onUrlChange(u)
})
.catch(() => {})
}
}
// Apply the saved default format once, when persisted settings first arrive. // Apply the saved default format once, when persisted settings first arrive.
const appliedDefaults = useRef(false) const appliedDefaults = useRef(false)
useEffect(() => { useEffect(() => {
@@ -320,8 +428,10 @@ export function DownloadBar(): React.JSX.Element {
function onUrlChange(next: string): void { function onUrlChange(next: string): void {
setUrl(next) setUrl(next)
// Any probed info is stale once the URL changes. // Any probed info — or a duplicate warning — is stale once the URL changes.
if (info || probeError || playlist) clearProbe() if (info || probeError || playlist) clearProbe()
if (dup) setDup(null)
confirmDup.current = false
} }
function onKindChange(next: MediaKind): void { function onKindChange(next: MediaKind): void {
@@ -383,6 +493,23 @@ export function DownloadBar(): React.JSX.Element {
const trimmed = url.trim() const trimmed = url.trim()
if (!trimmed) return if (!trimmed) return
// Duplicate guard: if this URL is already in the queue (and the user hasn't
// confirmed via "Download anyway"), warn instead of silently enqueuing a copy.
// Canceled/failed items don't count — re-adding those is a legitimate retry.
if (!confirmDup.current) {
const existing = useDownloads
.getState()
.items.find(
(i) => i.status !== 'canceled' && i.status !== 'error' && sameVideo(i.url, trimmed)
)
if (existing) {
setDup(existing.title)
return
}
}
confirmDup.current = false
setDup(null)
const meta = info const meta = info
? { ? {
title: info.title, title: info.title,
@@ -392,9 +519,14 @@ export function DownloadBar(): React.JSX.Element {
} }
: {} : {}
const trimSpec = trim.trim() || undefined
const scheduledFor = scheduleAt ? new Date(scheduleAt).getTime() : undefined
if (usingFormats && selectedFormat) { if (usingFormats && selectedFormat) {
addFromUrl(trimmed, 'video', selectedFormat.label, { addFromUrl(trimmed, 'video', selectedFormat.label, {
...meta, ...meta,
trim: trimSpec,
scheduledFor,
format: { format: {
id: selectedFormat.id, id: selectedFormat.id,
hasAudio: selectedFormat.hasAudio, hasAudio: selectedFormat.hasAudio,
@@ -402,13 +534,22 @@ export function DownloadBar(): React.JSX.Element {
} }
}) })
} else { } else {
addFromUrl(trimmed, kind, quality, meta) addFromUrl(trimmed, kind, quality, { ...meta, trim: trimSpec, scheduledFor })
} }
setUrl('') setUrl('')
setTrim('')
setShowTrim(false)
setScheduleAt('')
setShowSchedule(false)
clearProbe() clearProbe()
} }
function downloadAnyway(): void {
confirmDup.current = true
download()
}
function addPlaylist(): void { function addPlaylist(): void {
if (!playlist) return if (!playlist) return
const chosen = playlist.entries.filter((e) => selected.has(e.index)) const chosen = playlist.entries.filter((e) => selected.has(e.index))
@@ -424,7 +565,12 @@ export function DownloadBar(): React.JSX.Element {
} }
return ( return (
<div className={styles.root}> <div
className={mergeClasses(styles.root, dragActive && styles.rootDragging)}
onDragOver={onDragOver}
onDragLeave={() => setDragActive(false)}
onDrop={onDrop}
>
<div className={styles.urlRow}> <div className={styles.urlRow}>
<Input <Input
className={styles.url} className={styles.url}
@@ -474,6 +620,23 @@ export function DownloadBar(): React.JSX.Element {
</div> </div>
)} )}
{dup && (
<div className={styles.dupRow}>
<WarningRegular />
<Caption1 className={styles.suggestionText}>Already in your queue: {dup}.</Caption1>
<Button size="small" appearance="primary" onClick={downloadAnyway}>
Download anyway
</Button>
<Button
size="small"
appearance="subtle"
icon={<DismissRegular />}
onClick={() => setDup(null)}
aria-label="Dismiss"
/>
</div>
)}
{probing && ( {probing && (
<div className={styles.statusRow}> <div className={styles.statusRow}>
<Spinner size="tiny" /> <Spinner size="tiny" />
@@ -550,6 +713,59 @@ export function DownloadBar(): React.JSX.Element {
</div> </div>
)} )}
{!playlist && (
<div className={styles.trimBlock}>
<div className={styles.optButtons}>
<Button
size="small"
appearance="subtle"
icon={<CutRegular />}
onClick={() => setShowTrim((v) => !v)}
>
{trim.trim() ? 'Trim · on' : 'Trim'}
</Button>
<Button
size="small"
appearance="subtle"
icon={<CalendarClockRegular />}
onClick={() => setShowSchedule((v) => !v)}
>
{scheduleAt ? 'Scheduled' : 'Schedule'}
</Button>
</div>
{showTrim && (
<div className={styles.trimPanel}>
<Field
label="Sections to keep"
hint="Time ranges like 1:30-2:00 — one per line or comma-separated. Leave empty to download the whole thing."
>
<Textarea
value={trim}
onChange={(_, d) => setTrim(d.value)}
placeholder={'0:30-1:45\n3:00-3:30'}
resize="vertical"
/>
</Field>
</div>
)}
{showSchedule && (
<div className={styles.trimPanel}>
<Field
label="Start at"
hint="Pick a date and time to start this download. Leave empty to start now. Scheduled downloads only fire while AeroFetch is running."
>
<input
type="datetime-local"
className={styles.dtInput}
value={scheduleAt}
onChange={(e) => setScheduleAt(e.target.value)}
/>
</Field>
</div>
)}
</div>
)}
<div className={styles.controls}> <div className={styles.controls}>
<div className={styles.control}> <div className={styles.control}>
<Caption1>Format</Caption1> <Caption1>Format</Caption1>
@@ -606,11 +822,11 @@ export function DownloadBar(): React.JSX.Element {
<Button <Button
appearance="primary" appearance="primary"
size="large" size="large"
icon={<ArrowDownloadRegular />} icon={scheduleAt ? <CalendarClockRegular /> : <ArrowDownloadRegular />}
onClick={download} onClick={download}
disabled={!url.trim()} disabled={!url.trim()}
> >
Download {scheduleAt ? 'Schedule' : 'Download'}
</Button> </Button>
)} )}
</div> </div>
@@ -204,6 +204,16 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
label={value.embedChapters ? 'On' : 'Off'} label={value.embedChapters ? 'On' : 'Off'}
/> />
</Field> </Field>
<Field
label="Split by chapters"
hint="Also save each chapter as its own file. The full-length file is kept too."
>
<Switch
checked={value.splitChapters}
onChange={(_, d) => setOpt('splitChapters', d.checked)}
label={value.splitChapters ? 'On' : 'Off'}
/>
</Field>
<Field label="Embed metadata" hint="Title, artist, date and similar tags."> <Field label="Embed metadata" hint="Title, artist, date and similar tags.">
<Switch <Switch
checked={value.embedMetadata} checked={value.embedMetadata}
+58 -7
View File
@@ -1,12 +1,16 @@
import { import {
Subtitle2, Subtitle2,
Body1, Body1,
Caption1,
Button, Button,
ProgressBar,
makeStyles, makeStyles,
tokens tokens,
shorthands
} from '@fluentui/react-components' } from '@fluentui/react-components'
import { ArrowDownloadRegular } from '@fluentui/react-icons' import { ArrowDownloadRegular, ArrowClockwiseRegular } from '@fluentui/react-icons'
import { useDownloads } from '../store/downloads' import { useDownloads } from '../store/downloads'
import { summarizeQueue } from '../store/queueStats'
import { DownloadBar } from './DownloadBar' import { DownloadBar } from './DownloadBar'
import { QueueItem } from './QueueItem' import { QueueItem } from './QueueItem'
import { VirtualList } from './VirtualList' import { VirtualList } from './VirtualList'
@@ -26,6 +30,27 @@ const useStyles = makeStyles({
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between' justifyContent: 'space-between'
}, },
headerActions: {
display: 'flex',
gap: '8px'
},
summary: {
display: 'flex',
alignItems: 'center',
gap: '12px',
padding: '10px 14px',
backgroundColor: tokens.colorNeutralBackground1,
...shorthands.borderRadius(tokens.borderRadiusLarge),
border: `1px solid ${tokens.colorNeutralStroke2}`
},
summaryBar: {
flexGrow: 1
},
summaryText: {
flexShrink: 0,
color: tokens.colorNeutralForeground3,
whiteSpace: 'nowrap'
},
listScroll: { listScroll: {
flexGrow: 1, flexGrow: 1,
// min-height:0 lets this flex child shrink below its content height so it, // min-height:0 lets this flex child shrink below its content height so it,
@@ -47,7 +72,9 @@ export function DownloadsView(): React.JSX.Element {
const styles = useStyles() const styles = useStyles()
const items = useDownloads((s) => s.items) const items = useDownloads((s) => s.items)
const clearFinished = useDownloads((s) => s.clearFinished) const clearFinished = useDownloads((s) => s.clearFinished)
const retryAll = useDownloads((s) => s.retryAll)
const summary = summarizeQueue(items)
const hasFinished = items.some( const hasFinished = items.some(
(i) => i.status === 'completed' || i.status === 'error' || i.status === 'canceled' (i) => i.status === 'completed' || i.status === 'error' || i.status === 'canceled'
) )
@@ -56,13 +83,37 @@ export function DownloadsView(): React.JSX.Element {
<div className={styles.root}> <div className={styles.root}>
<DownloadBar /> <DownloadBar />
{summary.active && (
<div className={styles.summary}>
<ProgressBar className={styles.summaryBar} value={summary.progress} thickness="large" />
<Caption1 className={styles.summaryText}>
{summary.downloading} downloading
{summary.queued ? `, ${summary.queued} queued` : ''}
{summary.speedLabel ? `${summary.speedLabel}` : ''}
{summary.etaLabel ? ` • ~${summary.etaLabel} left` : ''}
</Caption1>
</div>
)}
<div className={styles.queueHeader}> <div className={styles.queueHeader}>
<Subtitle2>Queue ({items.length})</Subtitle2> <Subtitle2>Queue ({items.length})</Subtitle2>
{hasFinished && ( <div className={styles.headerActions}>
<Button size="small" appearance="subtle" onClick={clearFinished}> {summary.failed > 0 && (
Clear finished <Button
</Button> size="small"
)} appearance="subtle"
icon={<ArrowClockwiseRegular />}
onClick={retryAll}
>
Retry all failed ({summary.failed})
</Button>
)}
{hasFinished && (
<Button size="small" appearance="subtle" onClick={clearFinished}>
Clear finished
</Button>
)}
</div>
</div> </div>
{items.length === 0 ? ( {items.length === 0 ? (
@@ -57,6 +57,8 @@ const STATUS_LABEL: Record<ItemStatus, string> = {
pending: 'Pending', pending: 'Pending',
queued: 'Queued', queued: 'Queued',
downloading: 'Downloading', downloading: 'Downloading',
paused: 'Paused',
saved: 'Saved',
completed: 'Downloaded', completed: 'Downloaded',
error: 'Failed', error: 'Failed',
canceled: 'Canceled' canceled: 'Canceled'
+87
View File
@@ -16,6 +16,11 @@ import {
OpenRegular, OpenRegular,
FolderRegular, FolderRegular,
ArrowClockwiseRegular, ArrowClockwiseRegular,
ArrowDownloadRegular,
ArrowUpRegular,
BookmarkRegular,
PauseRegular,
PlayRegular,
CheckmarkCircleFilled, CheckmarkCircleFilled,
ErrorCircleFilled, ErrorCircleFilled,
EyeOffRegular EyeOffRegular
@@ -91,6 +96,8 @@ const useStyles = makeStyles({
const STATUS_BADGE: Record<DownloadStatus, { label: string; color: 'brand' | 'success' | 'danger' | 'warning' | 'subtle' }> = { const STATUS_BADGE: Record<DownloadStatus, { label: string; color: 'brand' | 'success' | 'danger' | 'warning' | 'subtle' }> = {
queued: { label: 'Queued', color: 'subtle' }, queued: { label: 'Queued', color: 'subtle' },
downloading: { label: 'Downloading', color: 'brand' }, downloading: { label: 'Downloading', color: 'brand' },
paused: { label: 'Paused', color: 'warning' },
saved: { label: 'Saved', color: 'subtle' },
completed: { label: 'Completed', color: 'success' }, completed: { label: 'Completed', color: 'success' },
error: { label: 'Failed', color: 'danger' }, error: { label: 'Failed', color: 'danger' },
canceled: { label: 'Canceled', color: 'warning' } canceled: { label: 'Canceled', color: 'warning' }
@@ -100,6 +107,14 @@ function pct(progress: number): string {
return `${Math.round(progress * 100)}%` return `${Math.round(progress * 100)}%`
} }
function fmtSchedule(ms: number): string {
try {
return new Date(ms).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' })
} catch {
return ''
}
}
export const QueueItem = memo(function QueueItem({ export const QueueItem = memo(function QueueItem({
item item
}: { }: {
@@ -107,6 +122,11 @@ export const QueueItem = memo(function QueueItem({
}): React.JSX.Element { }): React.JSX.Element {
const styles = useStyles() const styles = useStyles()
const cancel = useDownloads((s) => s.cancel) const cancel = useDownloads((s) => s.cancel)
const pause = useDownloads((s) => s.pause)
const resume = useDownloads((s) => s.resume)
const prioritize = useDownloads((s) => s.prioritize)
const saveForLater = useDownloads((s) => s.saveForLater)
const queueNow = useDownloads((s) => s.queueNow)
const remove = useDownloads((s) => s.remove) const remove = useDownloads((s) => s.remove)
const retry = useDownloads((s) => s.retry) const retry = useDownloads((s) => s.retry)
const openFile = useDownloads((s) => s.openFile) const openFile = useDownloads((s) => s.openFile)
@@ -177,12 +197,79 @@ export const QueueItem = memo(function QueueItem({
</div> </div>
)} )}
{item.status === 'paused' && (
<div className={styles.progressRow}>
<ProgressBar className={styles.progressBar} value={item.progress} thickness="large" />
<Caption1 className={styles.stats}>Paused {pct(item.progress)}</Caption1>
</div>
)}
{item.status === 'saved' && (
<Caption1 className={styles.stats}>
{item.scheduledFor ? `Scheduled for ${fmtSchedule(item.scheduledFor)}` : 'Saved for later'}
</Caption1>
)}
{item.status === 'error' && item.error && ( {item.status === 'error' && item.error && (
<Caption1 className={styles.error}>{item.error}</Caption1> <Caption1 className={styles.error}>{item.error}</Caption1>
)} )}
</div> </div>
<div className={styles.actions}> <div className={styles.actions}>
{item.status === 'downloading' && (
<Hint label="Pause" placement="top" align="end">
<Button
appearance="subtle"
icon={<PauseRegular />}
onClick={() => pause(item.id)}
aria-label="Pause"
/>
</Hint>
)}
{item.status === 'queued' && (
<>
<Hint label="Download next" placement="top" align="end">
<Button
appearance="subtle"
icon={<ArrowUpRegular />}
onClick={() => prioritize(item.id)}
aria-label="Download next"
/>
</Hint>
<Hint label="Save for later" placement="top" align="end">
<Button
appearance="subtle"
icon={<BookmarkRegular />}
onClick={() => saveForLater(item.id)}
aria-label="Save for later"
/>
</Hint>
</>
)}
{item.status === 'paused' && (
<Hint label="Resume" placement="top" align="end">
<Button
appearance="subtle"
icon={<PlayRegular />}
onClick={() => resume(item.id)}
aria-label="Resume"
/>
</Hint>
)}
{item.status === 'saved' && (
<Hint label="Add to queue" placement="top" align="end">
<Button
appearance="subtle"
icon={<ArrowDownloadRegular />}
onClick={() => queueNow(item.id)}
aria-label="Add to queue"
/>
</Hint>
)}
{active && ( {active && (
<Hint label="Cancel" placement="top" align="end"> <Hint label="Cancel" placement="top" align="end">
<Button <Button
+1
View File
@@ -113,6 +113,7 @@ if (import.meta.env.DEV && !window.api) {
}, },
startDownload: async () => ({ ok: true }), startDownload: async () => ({ ok: true }),
cancelDownload: async () => {}, cancelDownload: async () => {},
pauseDownload: async () => {},
getDefaultFolder: async () => 'C:\\Users\\you\\Downloads', getDefaultFolder: async () => 'C:\\Users\\you\\Downloads',
chooseFolder: async () => null, chooseFolder: async () => null,
openPath: async () => '', openPath: async () => '',
+142 -3
View File
@@ -6,7 +6,14 @@ import { useSources } from './sources'
export type MediaKind = 'video' | 'audio' export type MediaKind = 'video' | 'audio'
export type DownloadStatus = 'queued' | 'downloading' | 'completed' | 'error' | 'canceled' export type DownloadStatus =
| 'queued'
| 'downloading'
| 'paused'
| 'saved'
| 'completed'
| 'error'
| 'canceled'
/** An exact probed format chosen for a download (omitted for the preset path). */ /** An exact probed format chosen for a download (omitted for the preset path). */
export interface ChosenFormat { export interface ChosenFormat {
@@ -39,6 +46,10 @@ export interface DownloadItem {
options?: DownloadOptions options?: DownloadOptions
/** per-download custom-command override (omitted = use the persisted default template) */ /** per-download custom-command override (omitted = use the persisted default template) */
extraArgs?: string extraArgs?: string
/** raw trim spec — time ranges to keep (parsed to --download-sections in main) */
trim?: string
/** epoch ms to auto-start a 'saved' item; undefined = parked indefinitely (Phase M) */
scheduledFor?: number
/** private download — completion is never recorded to history */ /** private download — completion is never recorded to history */
incognito?: boolean incognito?: boolean
/** metadata already fetched at probe time; forwarded to main so it skips a redundant probe */ /** metadata already fetched at probe time; forwarded to main so it skips a redundant probe */
@@ -63,6 +74,9 @@ export interface AddOptions {
thumbnail?: string thumbnail?: string
options?: DownloadOptions options?: DownloadOptions
extraArgs?: string extraArgs?: string
trim?: string
/** when set and in the future, the item starts 'saved' and auto-queues at this time */
scheduledFor?: number
incognito?: boolean incognito?: boolean
collection?: CollectionContext collection?: CollectionContext
mediaItemId?: string mediaItemId?: string
@@ -90,6 +104,20 @@ interface DownloadState {
*/ */
addMany: (entries: AddEntry[]) => void addMany: (entries: AddEntry[]) => void
retry: (id: string) => void retry: (id: string) => void
/** re-queue every failed item at once (Phase M) */
retryAll: () => void
/** stop a running download but keep its partial file for a later resume (Phase M) */
pause: (id: string) => void
/** re-queue a paused download; yt-dlp continues its partial file on relaunch */
resume: (id: string) => void
/** move a queued item to the front of the promotion order ("download next", Phase M) */
prioritize: (id: string) => void
/** park a queued item indefinitely as 'saved' (Phase M) */
saveForLater: (id: string) => void
/** un-park a 'saved' item back into the queue now (clears any schedule) */
queueNow: (id: string) => void
/** internal: promote any 'saved' item whose scheduledFor time has arrived */
promoteDueScheduled: () => void
cancel: (id: string) => void cancel: (id: string) => void
remove: (id: string) => void remove: (id: string) => void
clearFinished: () => void clearFinished: () => void
@@ -117,6 +145,10 @@ function buildItem(url: string, kind: MediaKind, quality: string, opts?: AddOpti
opts?.title || opts?.channel || opts?.durationLabel opts?.title || opts?.channel || opts?.durationLabel
? { title: opts?.title, channel: opts?.channel, durationLabel: opts?.durationLabel } ? { title: opts?.title, channel: opts?.channel, durationLabel: opts?.durationLabel }
: undefined : undefined
// A future scheduled time parks the item as 'saved' until its moment arrives;
// a past/absent time starts it queued as usual.
const scheduledFor =
opts?.scheduledFor && opts.scheduledFor > Date.now() ? opts.scheduledFor : undefined
return { return {
id: newId(), id: newId(),
url, url,
@@ -126,12 +158,14 @@ function buildItem(url: string, kind: MediaKind, quality: string, opts?: AddOpti
thumbnail: opts?.thumbnail, thumbnail: opts?.thumbnail,
kind, kind,
quality, quality,
status: 'queued', status: scheduledFor ? 'saved' : 'queued',
progress: 0, progress: 0,
formatId: opts?.format?.id, formatId: opts?.format?.id,
formatHasAudio: opts?.format?.hasAudio, formatHasAudio: opts?.format?.hasAudio,
options: opts?.options, options: opts?.options,
extraArgs: opts?.extraArgs, extraArgs: opts?.extraArgs,
trim: opts?.trim,
scheduledFor,
incognito: opts?.incognito, incognito: opts?.incognito,
collection: opts?.collection, collection: opts?.collection,
mediaItemId: opts?.mediaItemId, mediaItemId: opts?.mediaItemId,
@@ -297,6 +331,7 @@ export const useDownloads = create<DownloadState>((set, get) => {
formatHasAudio: item.formatHasAudio, formatHasAudio: item.formatHasAudio,
options: item.options, options: item.options,
extraArgs: item.extraArgs, extraArgs: item.extraArgs,
trim: item.trim,
meta: item.probedMeta, meta: item.probedMeta,
collection: item.collection collection: item.collection
}) })
@@ -357,6 +392,93 @@ export const useDownloads = create<DownloadState>((set, get) => {
pump() pump()
}, },
retryAll: () => {
set((s) => ({
items: s.items.map((i) =>
i.status === 'error'
? { ...i, status: 'queued', progress: 0, error: undefined, speed: undefined, eta: undefined }
: i
)
}))
pump()
},
pause: (id) => {
const cur = get().items.find((i) => i.id === id)
if (!cur) return
// Only a running download has a live process to stop; a queued item is
// just parked in place. Either way it becomes 'paused' (pump ignores it).
if (!PREVIEW && cur.status === 'downloading') window.api.pauseDownload(id).catch(() => {})
set((s) => ({
items: s.items.map((i) =>
i.id === id && (i.status === 'downloading' || i.status === 'queued')
? { ...i, status: 'paused', speed: undefined, eta: undefined }
: i
)
}))
pump() // pausing a running item frees a slot
},
resume: (id) => {
// Re-queue WITHOUT resetting progress (unlike retry): pump() promotes it and
// launchItem re-spawns yt-dlp, which continues the partial file by default.
set((s) => ({
items: s.items.map((i) =>
i.id === id && i.status === 'paused' ? { ...i, status: 'queued' } : i
)
}))
pump()
},
prioritize: (id) => {
// Reposition the item so pump() — which promotes the highest-index queued
// item first (oldest-first over a newest-first array) — picks it next.
set((s) => {
const idx = s.items.findIndex((i) => i.id === id)
if (idx < 0 || s.items[idx].status !== 'queued') return {}
const items = s.items.slice()
const [it] = items.splice(idx, 1)
let after = -1
for (let k = 0; k < items.length; k++) if (items[k].status === 'queued') after = k
items.splice(after + 1, 0, it)
return { items }
})
pump()
},
saveForLater: (id) => {
set((s) => ({
items: s.items.map((i) =>
i.id === id && i.status === 'queued'
? { ...i, status: 'saved', scheduledFor: undefined }
: i
)
}))
},
queueNow: (id) => {
set((s) => ({
items: s.items.map((i) =>
i.id === id && i.status === 'saved'
? { ...i, status: 'queued', scheduledFor: undefined }
: i
)
}))
pump()
},
promoteDueScheduled: () => {
const now = Date.now()
set((s) => ({
items: s.items.map((i) =>
i.status === 'saved' && i.scheduledFor != null && i.scheduledFor <= now
? { ...i, status: 'queued', scheduledFor: undefined }
: i
)
}))
pump()
},
cancel: (id) => { cancel: (id) => {
const cur = get().items.find((i) => i.id === id) const cur = get().items.find((i) => i.id === id)
if (!cur) return if (!cur) return
@@ -379,7 +501,13 @@ export const useDownloads = create<DownloadState>((set, get) => {
clearFinished: () => clearFinished: () =>
set((s) => ({ set((s) => ({
items: s.items.filter((i) => i.status === 'downloading' || i.status === 'queued') items: s.items.filter(
(i) =>
i.status === 'downloading' ||
i.status === 'queued' ||
i.status === 'paused' ||
i.status === 'saved'
)
})), })),
openFile: (id) => { openFile: (id) => {
@@ -461,6 +589,17 @@ export const useDownloads = create<DownloadState>((set, get) => {
} }
}) })
// Promote scheduled ('saved' + a due scheduledFor) items when their time arrives.
// Session-only: the queue isn't persisted, so a schedule only fires while the app
// runs (noted in ROADMAP.md). A 15s tick is plenty for minute-granularity times.
setInterval(() => {
const st = useDownloads.getState()
const now = Date.now()
if (st.items.some((i) => i.status === 'saved' && i.scheduledFor != null && i.scheduledFor <= now)) {
st.promoteDueScheduled()
}
}, 15_000)
// Wire main → renderer push events. (Output folder + cap live in the settings store.) // Wire main → renderer push events. (Output folder + cap live in the settings store.)
if (!PREVIEW) { if (!PREVIEW) {
window.api.onDownloadEvent((ev) => useDownloads.getState().applyEvent(ev)) window.api.onDownloadEvent((ev) => useDownloads.getState().applyEvent(ev))
+139
View File
@@ -0,0 +1,139 @@
/**
* Pure helpers for the Downloads view (Phase M): a one-line aggregate of the live
* queue, and same-video detection for the duplicate guard. Kept free of any store
* / window dependency (only a type-only import of DownloadItem) so it can be
* unit-tested without constructing the Zustand store or its preview ticker.
*/
import type { DownloadItem } from './downloads'
// Parse a human speed string ('4.7 MB/s', '512 KiB/s') into bytes/second. Tolerant
// of the SI (KB) and binary (KiB) suffixes yt-dlp may emit; the 1000-vs-1024
// difference is immaterial for a live display estimate.
function parseSpeed(s?: string): number {
if (!s) return 0
const m = /([\d.]+)\s*([KMGT]?)i?B\/s/i.exec(s)
if (!m) return 0
const n = parseFloat(m[1])
if (!isFinite(n)) return 0
const mult: Record<string, number> = { '': 1, K: 1e3, M: 1e6, G: 1e9, T: 1e12 }
return n * (mult[m[2].toUpperCase()] ?? 1)
}
function formatSpeed(bytesPerSec: number): string {
if (bytesPerSec <= 0) return ''
const units = ['B/s', 'KB/s', 'MB/s', 'GB/s']
let v = bytesPerSec
let i = 0
while (v >= 1000 && i < units.length - 1) {
v /= 1000
i++
}
return `${v.toFixed(1)} ${units[i]}`
}
// Parse 'M:SS' / 'H:MM:SS' (or bare seconds) into seconds.
function parseEtaSeconds(s?: string): number {
if (!s) return 0
const parts = s.split(':').map((p) => Number(p))
if (parts.length === 0 || parts.some((p) => !isFinite(p))) return 0
return parts.reduce((acc, p) => acc * 60 + p, 0)
}
function formatEta(totalSeconds: number): string {
if (totalSeconds <= 0) return ''
const s = Math.round(totalSeconds)
const m = Math.floor(s / 60)
return `${m}:${String(s % 60).padStart(2, '0')}`
}
export interface QueueSummary {
downloading: number
queued: number
failed: number
/** true when something is downloading or waiting */
active: boolean
/** 0..1, averaged over downloading + queued items */
progress: number
/** combined download speed, formatted; '' when nothing is downloading */
speedLabel: string
/** rough time remaining (the longest active ETA), formatted; '' when unknown */
etaLabel: string
}
/**
* Aggregate the live queue into a one-line summary for the Downloads header.
* Combined speed is summed across downloading items; the "time left" is the
* longest single ETA a reasonable proxy for when the current batch finishes
* (we don't track remaining bytes, so a true combined ETA isn't available).
* Queued items count toward the progress average as 0%, so the bar reflects
* "how much of the visible work is done", not just the active downloads.
*/
export function summarizeQueue(items: DownloadItem[]): QueueSummary {
let downloading = 0
let queued = 0
let failed = 0
let progressSum = 0
let progressCount = 0
let speed = 0
let maxEta = 0
for (const i of items) {
if (i.status === 'downloading') {
downloading++
progressSum += i.progress
progressCount++
speed += parseSpeed(i.speed)
maxEta = Math.max(maxEta, parseEtaSeconds(i.eta))
} else if (i.status === 'queued') {
queued++
progressCount++
} else if (i.status === 'error') {
failed++
}
}
return {
downloading,
queued,
failed,
active: downloading > 0 || queued > 0,
progress: progressCount > 0 ? progressSum / progressCount : 0,
speedLabel: formatSpeed(speed),
etaLabel: formatEta(maxEta)
}
}
// --- Duplicate detection ----------------------------------------------------
function youtubeId(url: string): string | null {
try {
const u = new URL(url)
const host = u.hostname.replace(/^www\./, '')
if (host === 'youtu.be') return u.pathname.slice(1) || null
if (host.endsWith('youtube.com')) return u.searchParams.get('v')
} catch {
/* not a URL */
}
return null
}
function normalizeUrl(url: string): string {
try {
const u = new URL(url)
const host = u.hostname.replace(/^www\./, '').toLowerCase()
return `${u.protocol}//${host}${u.pathname.replace(/\/$/, '')}${u.search}`
} catch {
return url.trim()
}
}
/**
* True when two URLs point at the same video: a matching YouTube id when both
* are YouTube links, otherwise normalised-URL equality (host lower-cased, `www.`
* and a trailing slash stripped). Powers the "already in your queue" guard.
*/
export function sameVideo(a: string, b: string): boolean {
if (a === b) return true
const ya = youtubeId(a)
const yb = youtubeId(b)
if (ya && yb) return ya === yb
return normalizeUrl(a) === normalizeUrl(b)
}
+11
View File
@@ -19,6 +19,7 @@ export const IpcChannels = {
probe: 'media:probe', probe: 'media:probe',
downloadStart: 'download:start', downloadStart: 'download:start',
downloadCancel: 'download:cancel', downloadCancel: 'download:cancel',
downloadPause: 'download:pause',
defaultFolder: 'download:default-folder', defaultFolder: 'download:default-folder',
chooseFolder: 'download:choose-folder', chooseFolder: 'download:choose-folder',
openPath: 'shell:open-path', openPath: 'shell:open-path',
@@ -158,6 +159,8 @@ export interface DownloadOptions {
sponsorBlockCategories: SponsorBlockCategory[] sponsorBlockCategories: SponsorBlockCategory[]
/** embed chapter markers */ /** embed chapter markers */
embedChapters: boolean embedChapters: boolean
/** split the download into one file per chapter (--split-chapters) */
splitChapters: boolean
/** embed metadata (title/artist/etc.) */ /** embed metadata (title/artist/etc.) */
embedMetadata: boolean embedMetadata: boolean
/** embed the thumbnail (cover art for audio, poster for mp4/mkv video) */ /** embed the thumbnail (cover art for audio, poster for mp4/mkv video) */
@@ -183,6 +186,7 @@ export const DEFAULT_DOWNLOAD_OPTIONS: DownloadOptions = {
sponsorBlockMode: 'remove', sponsorBlockMode: 'remove',
sponsorBlockCategories: ['sponsor'], sponsorBlockCategories: ['sponsor'],
embedChapters: false, embedChapters: false,
splitChapters: false,
embedMetadata: true, embedMetadata: true,
embedThumbnail: true, embedThumbnail: true,
cropThumbnail: false, cropThumbnail: false,
@@ -389,6 +393,13 @@ export interface StartDownloadOptions {
* download even when the settings default is enabled. * download even when the settings default is enabled.
*/ */
extraArgs?: string extraArgs?: string
/**
* Optional trim: keep only these time ranges. Raw user text ranges like
* '1:30-2:00', comma- or newline-separated normalised to yt-dlp
* `--download-sections` specs in buildArgs (parseTrimSections). Empty or
* undefined downloads the whole media.
*/
trim?: string
/** /**
* Metadata the renderer already fetched when probing the URL (title/channel/ * Metadata the renderer already fetched when probing the URL (title/channel/
* duration). When present, main uses it directly instead of spawning a second * duration). When present, main uses it directly instead of spawning a second
+66
View File
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'
import { import {
buildArgs, buildArgs,
parseExtraArgs, parseExtraArgs,
parseTrimSections,
selectExtraArgs, selectExtraArgs,
formatCommandLine, formatCommandLine,
sanitizeDirSegment, sanitizeDirSegment,
@@ -460,6 +461,71 @@ describe('sidecar files', () => {
}) })
}) })
describe('chapters', () => {
it('emits --split-chapters when splitChapters is on', () => {
expect(build(opts(), dlo({ splitChapters: true }))).toContain('--split-chapters')
})
it('does not emit --split-chapters by default', () => {
expect(build(opts(), dlo())).not.toContain('--split-chapters')
})
it('emits --split-chapters independently of --embed-chapters', () => {
const argv = build(opts(), dlo({ splitChapters: true, embedChapters: false }))
expect(argv).toContain('--split-chapters')
expect(argv).not.toContain('--embed-chapters')
})
})
describe('parseTrimSections', () => {
it('normalises plain time ranges to *START-END specs', () => {
expect(parseTrimSections('1:30-2:00')).toEqual(['*1:30-2:00'])
expect(parseTrimSections('90-120')).toEqual(['*90-120'])
expect(parseTrimSections('0:00:10.5-0:00:20')).toEqual(['*0:00:10.5-0:00:20'])
})
it('splits on commas and newlines and trims whitespace', () => {
expect(parseTrimSections(' 0:30-1:00 , 2:00-2:30 \n 3:00-3:30 ')).toEqual([
'*0:30-1:00',
'*2:00-2:30',
'*3:00-3:30'
])
})
it('keeps an existing leading * but does not double it', () => {
expect(parseTrimSections('*1:00-2:00')).toEqual(['*1:00-2:00'])
})
it('drops malformed tokens so garbage never reaches the argv', () => {
expect(parseTrimSections('not-a-range')).toEqual([])
expect(parseTrimSections('1:30')).toEqual([]) // no end
expect(parseTrimSections('')).toEqual([])
expect(parseTrimSections(undefined)).toEqual([])
})
})
describe('trim (--download-sections)', () => {
it('emits a --download-sections spec per range plus --force-keyframes-at-cuts', () => {
const argv = build(opts({ trim: '0:30-1:00, 2:00-2:30' }), dlo())
expect(hasSeq(argv, '--download-sections', '*0:30-1:00')).toBe(true)
expect(hasSeq(argv, '--download-sections', '*2:00-2:30')).toBe(true)
expect(argv).toContain('--force-keyframes-at-cuts')
})
it('emits nothing when there is no trim', () => {
const argv = build(opts(), dlo())
expect(argv).not.toContain('--download-sections')
})
it('does not double --force-keyframes-at-cuts when SponsorBlock-remove is also on', () => {
const argv = build(
opts({ trim: '0:30-1:00' }),
dlo({ sponsorBlock: true, sponsorBlockMode: 'remove', sponsorBlockCategories: ['sponsor'] })
)
expect(argv.filter((a) => a === '--force-keyframes-at-cuts')).toHaveLength(1)
})
})
// --- Collection folder paths (Phase G, media-manager) ----------------------- // --- Collection folder paths (Phase G, media-manager) -----------------------
describe('sanitizeDirSegment', () => { describe('sanitizeDirSegment', () => {
+101
View File
@@ -0,0 +1,101 @@
import { describe, it, expect } from 'vitest'
import { summarizeQueue, sameVideo } from '../src/renderer/src/store/queueStats'
import type { DownloadItem } from '../src/renderer/src/store/downloads'
// Minimal item factory — only the fields summarizeQueue reads.
function item(overrides: Partial<DownloadItem>): DownloadItem {
return {
id: Math.random().toString(36).slice(2),
url: 'https://youtube.com/watch?v=x',
title: 'x',
kind: 'video',
quality: '1080p',
status: 'queued',
progress: 0,
...overrides
}
}
describe('summarizeQueue', () => {
it('counts statuses and flags active', () => {
const s = summarizeQueue([
item({ status: 'downloading', progress: 0.5 }),
item({ status: 'queued' }),
item({ status: 'error' }),
item({ status: 'completed', progress: 1 }),
item({ status: 'canceled' })
])
expect(s.downloading).toBe(1)
expect(s.queued).toBe(1)
expect(s.failed).toBe(1)
expect(s.active).toBe(true)
})
it('is inactive and zeroed when nothing is downloading or queued', () => {
const s = summarizeQueue([item({ status: 'completed', progress: 1 })])
expect(s.active).toBe(false)
expect(s.progress).toBe(0)
expect(s.speedLabel).toBe('')
expect(s.etaLabel).toBe('')
})
it('averages progress over downloading + queued (queued counts as 0%)', () => {
// one at 80%, one queued at 0% → 40%
const s = summarizeQueue([
item({ status: 'downloading', progress: 0.8 }),
item({ status: 'queued' })
])
expect(s.progress).toBeCloseTo(0.4, 5)
})
it('sums download speeds across MB/s and MiB/s strings', () => {
const s = summarizeQueue([
item({ status: 'downloading', progress: 0.1, speed: '4.0 MB/s' }),
item({ status: 'downloading', progress: 0.1, speed: '2000 KiB/s' })
])
// 4.0 MB/s + ~2.0 MB/s ≈ 6.0 MB/s
expect(s.speedLabel).toBe('6.0 MB/s')
})
it('reports the longest active ETA as the time left', () => {
const s = summarizeQueue([
item({ status: 'downloading', progress: 0.5, eta: '0:30' }),
item({ status: 'downloading', progress: 0.2, eta: '2:10' })
])
expect(s.etaLabel).toBe('2:10')
})
})
describe('sameVideo', () => {
it('matches identical URLs', () => {
expect(sameVideo('https://x.com/a', 'https://x.com/a')).toBe(true)
})
it('matches YouTube links by video id across watch/youtu.be/extra params', () => {
expect(
sameVideo(
'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
'https://youtu.be/dQw4w9WgXcQ'
)
).toBe(true)
expect(
sameVideo(
'https://youtube.com/watch?v=dQw4w9WgXcQ&list=PL123',
'https://www.youtube.com/watch?v=dQw4w9WgXcQ'
)
).toBe(true)
})
it('does not match different videos', () => {
expect(
sameVideo(
'https://youtube.com/watch?v=aaaaaaaaaaa',
'https://youtube.com/watch?v=bbbbbbbbbbb'
)
).toBe(false)
})
it('normalises www. and trailing slash for non-YouTube URLs', () => {
expect(sameVideo('https://www.vimeo.com/123/', 'https://vimeo.com/123')).toBe(true)
})
})