feat(playlist): exact-format-per-item selection

Each video row in the playlist panel gets an exact-format button that
lazily probes just that entry's URL (reusing the existing single-video
probe IPC, no new main-process code) and shows a native Select of its
real formats. The choice flows through addMany -> formatId per queue
item. Probing is on-demand per row, never all up front, since a
playlist can hold thousands of entries and per-entry probes are
rate-limited.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 16:55:58 -04:00
parent 5c88d9c8e0
commit 4c2b54a599
7 changed files with 424 additions and 55 deletions
+95
View File
@@ -0,0 +1,95 @@
import { describe, it, expect } from 'vitest'
import type { PlaylistEntry } from '../src/shared/ipc'
import { buildPlaylistAddEntries } from '../src/renderer/src/components/downloadBar/playlistEntries'
import type { ChosenFormat } from '../src/renderer/src/store/downloadTypes'
import type { MediaKind } from '../src/renderer/src/store/downloads'
function entry(index: number, over: Partial<PlaylistEntry> = {}): PlaylistEntry {
return {
index,
id: `v${index}`,
title: `Item ${index}`,
url: `https://youtube.com/watch?v=v${index}`,
durationLabel: '10:00',
uploader: 'Chan',
...over
}
}
/** Build the ctx the way useDownloadBar does, from per-entry override maps. */
function ctx(opts: {
barKind?: MediaKind
barQuality?: string
kinds?: Record<number, MediaKind>
formats?: Record<number, ChosenFormat>
}) {
const { barKind = 'video', barQuality = '1080p', kinds = {}, formats = {} } = opts
return {
barKind,
barQuality,
effKind: (i: number): MediaKind => kinds[i] ?? barKind,
itemFormats: formats
}
}
describe('buildPlaylistAddEntries', () => {
it('uses the bar preset when an entry matches the bar kind and has no override', () => {
const [row] = buildPlaylistAddEntries([entry(1)], ctx({ barKind: 'video', barQuality: '720p' }))
expect(row.kind).toBe('video')
expect(row.quality).toBe('720p')
expect(row.opts?.format).toBeUndefined()
// metadata is forwarded so the queue row shows a title before probing
expect(row.opts?.title).toBe('Item 1')
expect(row.opts?.channel).toBe('Chan')
})
it('falls back to that kind default when an entry override differs from the bar kind', () => {
const [row] = buildPlaylistAddEntries(
[entry(1)],
ctx({ barKind: 'video', barQuality: '720p', kinds: { 1: 'audio' } })
)
expect(row.kind).toBe('audio')
// audio's first quality option, not the bar's video '720p'
expect(row.quality).toBe('Best')
expect(row.opts?.format).toBeUndefined()
})
it('carries an exact per-item format on a video row', () => {
const fmt: ChosenFormat = { id: '299', hasAudio: false, label: '1080p60 · mp4' }
const [row] = buildPlaylistAddEntries([entry(1)], ctx({ formats: { 1: fmt } }))
expect(row.kind).toBe('video')
expect(row.opts?.format).toEqual(fmt)
// the chosen format's label doubles as the queue-row quality text
expect(row.quality).toBe('1080p60 · mp4')
})
it('ignores a stored format on a row that is set to audio', () => {
const fmt: ChosenFormat = { id: '299', hasAudio: false, label: '1080p60 · mp4' }
const [row] = buildPlaylistAddEntries(
[entry(1)],
ctx({ kinds: { 1: 'audio' }, formats: { 1: fmt } })
)
expect(row.kind).toBe('audio')
expect(row.opts?.format).toBeUndefined()
expect(row.quality).toBe('Best')
})
it('maps a mixed batch independently per entry', () => {
const fmt: ChosenFormat = { id: '136', hasAudio: false, label: '720p · mp4' }
const rows = buildPlaylistAddEntries(
[entry(1), entry(2), entry(3)],
ctx({
barKind: 'video',
barQuality: '1080p',
kinds: { 2: 'audio' },
formats: { 3: fmt }
})
)
expect(rows.map((r) => r.kind)).toEqual(['video', 'audio', 'video'])
expect(rows[0].opts?.format).toBeUndefined()
expect(rows[0].quality).toBe('1080p')
expect(rows[1].quality).toBe('Best')
expect(rows[2].opts?.format).toEqual(fmt)
expect(rows[2].quality).toBe('720p · mp4')
})
})