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:
@@ -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', () => {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user