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
+32 -10
View File
@@ -38,6 +38,7 @@ import {
interface ActiveDownload {
child: ChildProcess
canceled: boolean
paused: boolean
}
const active = new Map<string, ActiveDownload>()
@@ -295,7 +296,7 @@ export function startDownload(
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)
// Title/channel/duration are kept locally so the completion notification and
@@ -344,7 +345,8 @@ export function startDownload(
if (settled) return
settled = true
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 })
logFailure(opts, resolvedTitle, err.message)
notify(wc, resolvedTitle ?? 'Download failed', err.message)
@@ -355,7 +357,9 @@ export function startDownload(
if (settled) return
settled = true
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) {
send(wc, { type: 'done', id: opts.id, filePath })
notify(wc, resolvedTitle ?? 'Download complete', 'Finished downloading.')
@@ -370,15 +374,12 @@ export function startDownload(
return { ok: true }
}
export function cancelDownload(id: string): void {
const rec = active.get(id)
if (!rec) return
rec.canceled = true
// Kill the whole process 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)
function killTree(rec: ActiveDownload): void {
const pid = rec.child.pid
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(
getSystem32Path('taskkill.exe'),
['/pid', String(pid), '/T', '/F'],
@@ -389,3 +390,24 @@ export function cancelDownload(id: string): void {
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)
}