Merge pull request 'feat(playlist): exact-format-per-item selection' (#9) from feat/playlist-per-item-format into main

Reviewed-on: #9
This commit was merged in pull request #9.
This commit is contained in:
2026-07-02 17:06:44 -04:00
7 changed files with 435 additions and 57 deletions
+8 -2
View File
@@ -363,8 +363,14 @@ per-item `options`) but not the surfaces. typecheck + test + `npm run build` cle
- [x] **Per-playlist-item editing.** *Done: the playlist panel in `DownloadBar.tsx` gained a
per-row video/audio toggle + **All video** / **All audio** batch buttons; `addPlaylist`
enqueues via `addMany` with each entry's own kind (quality falls back to that kind's
default). Per-item exact-format selection (needs probing each entry) left as a further
extension.*
default).*
- [x] **Per-playlist exact-format-per-item.** *Done: each video row in the playlist panel has an
**exact-format** button that lazily probes just that entry's URL (reusing the single-video
`probe` IPC — no new main code) and reveals a native `<Select>` of that entry's real formats;
the pick is carried as a `ChosenFormat` and flows through `addMany``formatId` per queue
item. Probing is on-demand per row (never all up front — playlists can be huge / rate-limited).
Flipping a row to audio drops its format. Pure mapping in `downloadBar/playlistEntries.ts`,
unit-tested (`test/playlistEntries.test.ts`); verified end-to-end in the preview harness.*
- [x] **Weighted format sorting ("format aspect importance").** *Done: a `formatSort` field on
`DownloadOptions` (raw yt-dlp `-S` string); when set it emits `-S <value>`, overriding the
codec tiebreaker (`buildArgs.ts`); an "advanced" input in `DownloadOptionsForm`. Unit-tested.*
@@ -239,6 +239,13 @@ export function DownloadBar(): React.JSX.Element {
onToggleEntry={bar.toggleEntry}
effKind={bar.effKind}
onToggleItemKind={bar.toggleItemKind}
formatExpanded={bar.formatExpanded}
itemFormatOptions={bar.itemFormatOptions}
itemFormat={bar.itemFormat}
formatProbing={bar.formatProbing}
formatProbeError={bar.formatProbeError}
onToggleFormat={bar.toggleItemFormat}
onSetItemFormat={bar.setItemFormat}
/>
)}
@@ -1,8 +1,15 @@
import { Button, Checkbox, Caption1, mergeClasses } from '@fluentui/react-components'
import { AppsListRegular, VideoClipRegular, MusicNote2Regular } from '@fluentui/react-icons'
import { type PlaylistInfo } from '@shared/ipc'
import { Button, Checkbox, Caption1, Spinner, mergeClasses } from '@fluentui/react-components'
import {
AppsListRegular,
VideoClipRegular,
MusicNote2Regular,
OptionsRegular
} from '@fluentui/react-icons'
import { BEST_FORMAT_ID, type FormatOption, type PlaylistInfo } from '@shared/ipc'
import { type MediaKind } from '../../store/downloads'
import { type ChosenFormat } from '../../store/downloadTypes'
import { Hint } from '../Hint'
import { Select } from '../Select'
import { IconButton } from '../ui/IconButton'
import { useTextStyles } from '../ui/text'
import { META_SEP } from '../ui/tokens'
@@ -17,6 +24,14 @@ interface PlaylistPanelProps {
onToggleEntry: (index: number, on: boolean) => void
effKind: (index: number) => MediaKind
onToggleItemKind: (index: number) => void
// per-item exact-format picker (video rows only)
formatExpanded: Set<number>
itemFormatOptions: Record<number, FormatOption[]>
itemFormat: (index: number) => ChosenFormat | undefined
formatProbing: Set<number>
formatProbeError: Record<number, string>
onToggleFormat: (index: number) => void
onSetItemFormat: (index: number, formatId: string) => void
}
export function PlaylistPanel({
@@ -27,7 +42,14 @@ export function PlaylistPanel({
onSetAllKinds,
onToggleEntry,
effKind,
onToggleItemKind
onToggleItemKind,
formatExpanded,
itemFormatOptions,
itemFormat,
formatProbing,
formatProbeError,
onToggleFormat,
onSetItemFormat
}: PlaylistPanelProps): React.JSX.Element {
const styles = useDownloadBarStyles()
const text = useTextStyles()
@@ -53,8 +75,16 @@ export function PlaylistPanel({
</Button>
</div>
<div className={styles.plList}>
{playlist.entries.map((e) => (
<div key={e.index} className={styles.plItemRow}>
{playlist.entries.map((e) => {
const isVideo = effKind(e.index) === 'video'
const chosen = itemFormat(e.index)
const expanded = formatExpanded.has(e.index)
const probing = formatProbing.has(e.index)
const error = formatProbeError[e.index]
const options = itemFormatOptions[e.index]
return (
<div key={e.index} className={styles.plItemGroup}>
<div className={styles.plItemRow}>
<Checkbox
className={styles.plItem}
checked={selected.has(e.index)}
@@ -64,14 +94,33 @@ export function PlaylistPanel({
<span className={text.truncate}>
{e.index}. {e.title}
</span>
{(e.durationLabel || e.uploader) && (
{(e.durationLabel || e.uploader || chosen) && (
<Caption1 className={text.muted}>
{[e.durationLabel, e.uploader].filter(Boolean).join(META_SEP)}
{[e.durationLabel, e.uploader, chosen?.label]
.filter(Boolean)
.join(META_SEP)}
</Caption1>
)}
</span>
}
/>
{isVideo && (
<Hint
label={expanded ? 'Hide format' : 'Choose exact format'}
placement="top"
align="end"
>
<IconButton
size="sm"
style={{ flexShrink: 0 }}
className={chosen ? styles.plFormatBtnActive : undefined}
aria-pressed={expanded}
icon={<OptionsRegular />}
onClick={() => onToggleFormat(e.index)}
aria-label={`Choose format for ${e.title}`}
/>
</Hint>
)}
<Hint
label={
effKind(e.index) === 'audio'
@@ -84,13 +133,38 @@ export function PlaylistPanel({
<IconButton
size="sm"
style={{ flexShrink: 0 }}
icon={effKind(e.index) === 'audio' ? <MusicNote2Regular /> : <VideoClipRegular />}
icon={
effKind(e.index) === 'audio' ? <MusicNote2Regular /> : <VideoClipRegular />
}
onClick={() => onToggleItemKind(e.index)}
aria-label={`Download type for ${e.title}: ${effKind(e.index)}`}
/>
</Hint>
</div>
))}
{isVideo && expanded && (
<div className={styles.plFormatPanel}>
{probing ? (
<Caption1 className={styles.plFormatStatus}>
<Spinner size="tiny" /> Fetching formats
</Caption1>
) : error ? (
<Caption1 className={styles.plFormatError}>{error}</Caption1>
) : options && options.length > 0 ? (
<Select
className={styles.plFormatSelect}
aria-label={`Format for ${e.title}`}
value={chosen?.id ?? BEST_FORMAT_ID}
options={options.map((o) => ({ value: o.id, label: o.label }))}
onChange={(v) => onSetItemFormat(e.index, v)}
/>
) : (
<Caption1 className={text.muted}>No selectable formats.</Caption1>
)}
</div>
)}
</div>
)
})}
</div>
</div>
)
@@ -0,0 +1,45 @@
import { type PlaylistEntry } from '@shared/ipc'
import { QUALITY_OPTIONS } from '../../lib/qualityOptions'
import { type MediaKind } from '../../store/downloads'
import { type AddEntry, type ChosenFormat } from '../../store/downloadTypes'
/** The download-bar state a playlist add reads to build its per-entry queue rows. */
export interface PlaylistAddContext {
/** the bar's global kind (an entry with no override inherits this) */
barKind: MediaKind
/** the bar's global quality preset (used when an entry's kind matches barKind) */
barQuality: string
/** effective kind for an entry index: its override, else barKind */
effKind: (index: number) => MediaKind
/** exact per-item format the user probed + picked (video rows only) */
itemFormats: Record<number, ChosenFormat>
}
/**
* Map selected playlist entries to queue rows for a single `addMany` batch.
* Each entry uses its own kind override; a video entry the user gave an exact
* probed format downloads that format, otherwise quality falls back to the bar's
* preset (when the kind matches) or that kind's default. Kept pure so the mapping
* is unit-testable apart from the React hook.
*/
export function buildPlaylistAddEntries(
chosen: PlaylistEntry[],
ctx: PlaylistAddContext
): AddEntry[] {
return chosen.map((e) => {
const k = ctx.effKind(e.index)
// A per-item exact format only makes sense for a video row (audio is a preset).
const fmt = k === 'video' ? ctx.itemFormats[e.index] : undefined
return {
url: e.url,
kind: k,
quality: fmt ? fmt.label : k === ctx.barKind ? ctx.barQuality : QUALITY_OPTIONS[k][0],
opts: {
title: e.title,
channel: e.uploader,
durationLabel: e.durationLabel,
...(fmt ? { format: fmt } : {})
}
}
})
}
@@ -131,6 +131,35 @@ export const useDownloadBarStyles = makeStyles({
flexDirection: 'column',
minWidth: 0
},
// Column wrapper per entry: the checkbox/toggle row plus its (optional) format panel.
plItemGroup: {
display: 'flex',
flexDirection: 'column'
},
// The lazily-probed per-item format picker, indented under its entry row.
plFormatPanel: {
display: 'flex',
alignItems: 'center',
gap: '8px',
// Line up under the entry title (past the checkbox), leave room below.
padding: '0 0 6px 28px'
},
plFormatSelect: {
maxWidth: '260px'
},
// Brand-tint the per-item format button once an exact format is chosen for that row.
plFormatBtnActive: {
color: tokens.colorBrandForeground1
},
plFormatStatus: {
display: 'flex',
alignItems: 'center',
gap: '6px',
color: tokens.colorNeutralForeground3
},
plFormatError: {
color: tokens.colorPaletteRedForeground1
},
// --- trim / schedule panels ---
trimBlock: {
display: 'flex',
@@ -1,6 +1,7 @@
import { useState, useEffect, useRef, type Dispatch, type SetStateAction } from 'react'
import {
parseUrlShortcutContent,
BEST_FORMAT_ID,
type MediaInfo,
type FormatOption,
type PlaylistInfo,
@@ -9,6 +10,8 @@ import {
type CommandPreviewResult
} from '@shared/ipc'
import { useDownloads, type MediaKind } from '../../store/downloads'
import { type ChosenFormat } from '../../store/downloadTypes'
import { buildPlaylistAddEntries } from './playlistEntries'
import { useHistory } from '../../store/history'
import { QUALITY_OPTIONS } from '../../lib/qualityOptions'
import { sameVideo } from '../../lib/queueStats'
@@ -78,6 +81,12 @@ export interface DownloadBarController {
playlist: PlaylistInfo | null
selected: Set<number>
allSelected: boolean
// per-item format picker (video rows only)
formatExpanded: Set<number>
itemFormatOptions: Record<number, FormatOption[]>
itemFormat: (index: number) => ChosenFormat | undefined
formatProbing: Set<number>
formatProbeError: Record<number, string>
// suggestion banner
suggestion: string | null
suggestionSource: SuggestionSource
@@ -109,6 +118,8 @@ export interface DownloadBarController {
effKind: (index: number) => MediaKind
toggleItemKind: (index: number) => void
setAllKinds: (k: MediaKind) => void
toggleItemFormat: (index: number) => Promise<void>
setItemFormat: (index: number, formatId: string) => void
}
/**
@@ -172,6 +183,18 @@ export function useDownloadBar(): DownloadBarController {
// bar's global kind. Lets a playlist mix video and audio downloads.
const [itemKinds, setItemKinds] = useState<Record<number, MediaKind>>({})
// Per-entry exact-format selection (video rows only). A playlist probe is flat —
// it carries no per-entry formats — so each row's formats are fetched lazily on
// first expand (never all up front: a playlist can hold thousands of entries and
// per-entry probes are rate-limited). `formatExpanded` = rows showing the picker;
// `itemFormatOptions` caches a row's probed formats; `itemFormats` = the chosen
// exact format per row (absent = use the kind/quality preset path).
const [formatExpanded, setFormatExpanded] = useState<Set<number>>(new Set())
const [itemFormatOptions, setItemFormatOptions] = useState<Record<number, FormatOption[]>>({})
const [itemFormats, setItemFormats] = useState<Record<number, ChosenFormat>>({})
const [formatProbing, setFormatProbing] = useState<Set<number>>(new Set())
const [formatProbeError, setFormatProbeError] = useState<Record<number, string>>({})
// Drag highlight is gated on a depth counter, not raw enter/leave (L158): a
// dragleave fires every time the cursor crosses onto a child element, so toggling
// on leave made the dashed outline blink. Count enters vs leaves and only clear
@@ -258,13 +281,26 @@ export function useDownloadBar(): DownloadBarController {
playlist === null &&
probeError === null
// Per-entry format state is keyed by playlist position, so it must be dropped
// any time the playlist itself is dropped or re-fetched -- otherwise a stale
// exact-format pick from a prior probe can silently reattach to a different
// video that lands at the same index in the new result.
function resetPlaylistItemState(): void {
setSelected(new Set())
setItemKinds({})
setFormatExpanded(new Set())
setItemFormatOptions({})
setItemFormats({})
setFormatProbing(new Set())
setFormatProbeError({})
}
function clearProbe(): void {
setInfo(null)
setProbeError(null)
setFormatId('')
setPlaylist(null)
setSelected(new Set())
setItemKinds({})
resetPlaylistItemState()
}
// Reset the whole bar after a download / playlist is enqueued: clear the URL +
@@ -359,6 +395,7 @@ export function useDownloadBar(): DownloadBarController {
setProbeError(null)
setInfo(null)
setPlaylist(null)
resetPlaylistItemState()
// Probing may switch the URL to the format-picker path, changing the command.
setCommandPreview(null)
try {
@@ -425,13 +462,96 @@ export function useDownloadBar(): DownloadBarController {
return itemKinds[index] ?? kind
}
function toggleItemKind(index: number): void {
setItemKinds((m) => ({ ...m, [index]: effKind(index) === 'audio' ? 'video' : 'audio' }))
const next = effKind(index) === 'audio' ? 'video' : 'audio'
setItemKinds((m) => ({ ...m, [index]: next }))
// An exact format is a video-only choice; drop it (and the picker) when the
// row switches to audio so the audio download uses the quality preset.
if (next === 'audio') dropItemFormat(index)
}
function setAllKinds(k: MediaKind): void {
if (!playlist) return
const all: Record<number, MediaKind> = {}
for (const e of playlist.entries) all[e.index] = k
setItemKinds(all)
// Same reason as toggleItemKind: an "All audio" flip clears every per-item format.
if (k === 'audio') {
setItemFormats({})
setFormatExpanded(new Set())
}
}
// Per-entry exact-format helpers.
function itemFormat(index: number): ChosenFormat | undefined {
return itemFormats[index]
}
function dropItemFormat(index: number): void {
setItemFormats((m) => {
const { [index]: _drop, ...rest } = m
return rest
})
setFormatExpanded((s) => {
const next = new Set(s)
next.delete(index)
return next
})
}
// Expand/collapse a row's format picker. On first expand, lazily probe just that
// entry's URL — the existing single-video probe returns its full format list — and
// cache it so re-expanding is instant.
async function toggleItemFormat(index: number): Promise<void> {
if (formatExpanded.has(index)) {
setFormatExpanded((s) => {
const next = new Set(s)
next.delete(index)
return next
})
return
}
setFormatExpanded((s) => new Set(s).add(index))
if (itemFormatOptions[index] || formatProbing.has(index)) return // already have (or fetching) formats
const entry = playlist?.entries.find((e) => e.index === index)
if (!entry) return
setFormatProbing((s) => new Set(s).add(index))
setFormatProbeError((m) => {
const { [index]: _drop, ...rest } = m
return rest
})
try {
const res = await window.api.probe(entry.url)
if (res.ok && res.kind === 'video' && res.info) {
setItemFormatOptions((m) => ({ ...m, [index]: res.info!.formats }))
} else {
setFormatProbeError((m) => ({ ...m, [index]: res.error ?? 'Could not fetch formats.' }))
}
} catch (e) {
setFormatProbeError((m) => ({ ...m, [index]: String(e) }))
} finally {
setFormatProbing((s) => {
const next = new Set(s)
next.delete(index)
return next
})
}
}
// Pick an exact format for a row (or clear back to the preset for "Best available").
function setItemFormat(index: number, formatId: string): void {
const opt = itemFormatOptions[index]?.find((o) => o.id === formatId)
if (!opt || opt.id === BEST_FORMAT_ID) {
// "Best available" is the auto/preset path — carry no exact format.
setItemFormats((m) => {
const { [index]: _drop, ...rest } = m
return rest
})
return
}
setItemFormats((m) => ({
...m,
[index]: { id: opt.id, hasAudio: opt.hasAudio, label: opt.label }
}))
// Choosing a video format implies this row is a video download.
if (effKind(index) === 'audio') setItemKinds((m) => ({ ...m, [index]: 'video' }))
}
function download(): void {
@@ -516,18 +636,12 @@ export function useDownloadBar(): DownloadBarController {
function addPlaylist(): void {
if (!playlist) return
const chosen = playlist.entries.filter((e) => selected.has(e.index))
// Each entry uses its own kind override; quality falls back to that kind's
// default unless the entry matches the bar's current kind/quality.
// Each entry uses its own kind override and, for a video row the user gave an
// exact probed format, that format; otherwise quality falls back to the bar's
// current preset (when the kind matches) or that kind's default (see the pure
// buildPlaylistAddEntries for the mapping).
addMany(
chosen.map((e) => {
const k = effKind(e.index)
return {
url: e.url,
kind: k,
quality: k === kind ? quality : QUALITY_OPTIONS[k][0],
opts: { title: e.title, channel: e.uploader, durationLabel: e.durationLabel }
}
})
buildPlaylistAddEntries(chosen, { barKind: kind, barQuality: quality, effKind, itemFormats })
)
resetForm()
}
@@ -571,6 +685,12 @@ export function useDownloadBar(): DownloadBarController {
playlist,
selected,
allSelected,
// per-item format picker
formatExpanded,
itemFormatOptions,
itemFormat,
formatProbing,
formatProbeError,
// suggestion banner
suggestion,
suggestionSource,
@@ -601,6 +721,8 @@ export function useDownloadBar(): DownloadBarController {
toggleAll,
effKind,
toggleItemKind,
setAllKinds
setAllKinds,
toggleItemFormat,
setItemFormat
}
}
+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')
})
})