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
+66
View File
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'
import {
buildArgs,
parseExtraArgs,
parseTrimSections,
selectExtraArgs,
formatCommandLine,
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) -----------------------
describe('sanitizeDirSegment', () => {