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
+37
View File
@@ -161,6 +161,30 @@ export function selectExtraArgs(params: {
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
// preview) — never used to build the real argv that gets spawned.
function quoteForDisplay(arg: string): string {
@@ -250,17 +274,30 @@ function postProcessArgs(opts: StartDownloadOptions, o: DownloadOptions): string
}
// SponsorBlock.
let forcedKeyframes = false
if (o.sponsorBlock && o.sponsorBlockCategories.length > 0) {
const cats = o.sponsorBlockCategories.join(',')
if (o.sponsorBlockMode === 'remove') {
// Force keyframes at the cut points so segment boundaries are frame-accurate.
args.push('--sponsorblock-remove', cats, '--force-keyframes-at-cuts')
forcedKeyframes = true
} else {
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')
// 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')
// Media-server sidecar files (Phase K) — written next to the output file so