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
+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 - [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` 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 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 default).*
extension.* - [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 - [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 `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.* codec tiebreaker (`buildArgs.ts`); an "advanced" input in `DownloadOptionsForm`. Unit-tested.*
@@ -239,6 +239,13 @@ export function DownloadBar(): React.JSX.Element {
onToggleEntry={bar.toggleEntry} onToggleEntry={bar.toggleEntry}
effKind={bar.effKind} effKind={bar.effKind}
onToggleItemKind={bar.toggleItemKind} 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 { Button, Checkbox, Caption1, Spinner, mergeClasses } from '@fluentui/react-components'
import { AppsListRegular, VideoClipRegular, MusicNote2Regular } from '@fluentui/react-icons' import {
import { type PlaylistInfo } from '@shared/ipc' 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 MediaKind } from '../../store/downloads'
import { type ChosenFormat } from '../../store/downloadTypes'
import { Hint } from '../Hint' import { Hint } from '../Hint'
import { Select } from '../Select'
import { IconButton } from '../ui/IconButton' import { IconButton } from '../ui/IconButton'
import { useTextStyles } from '../ui/text' import { useTextStyles } from '../ui/text'
import { META_SEP } from '../ui/tokens' import { META_SEP } from '../ui/tokens'
@@ -17,6 +24,14 @@ interface PlaylistPanelProps {
onToggleEntry: (index: number, on: boolean) => void onToggleEntry: (index: number, on: boolean) => void
effKind: (index: number) => MediaKind effKind: (index: number) => MediaKind
onToggleItemKind: (index: number) => void 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({ export function PlaylistPanel({
@@ -27,7 +42,14 @@ export function PlaylistPanel({
onSetAllKinds, onSetAllKinds,
onToggleEntry, onToggleEntry,
effKind, effKind,
onToggleItemKind onToggleItemKind,
formatExpanded,
itemFormatOptions,
itemFormat,
formatProbing,
formatProbeError,
onToggleFormat,
onSetItemFormat
}: PlaylistPanelProps): React.JSX.Element { }: PlaylistPanelProps): React.JSX.Element {
const styles = useDownloadBarStyles() const styles = useDownloadBarStyles()
const text = useTextStyles() const text = useTextStyles()
@@ -53,8 +75,16 @@ export function PlaylistPanel({
</Button> </Button>
</div> </div>
<div className={styles.plList}> <div className={styles.plList}>
{playlist.entries.map((e) => ( {playlist.entries.map((e) => {
<div key={e.index} className={styles.plItemRow}> 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 <Checkbox
className={styles.plItem} className={styles.plItem}
checked={selected.has(e.index)} checked={selected.has(e.index)}
@@ -64,14 +94,33 @@ export function PlaylistPanel({
<span className={text.truncate}> <span className={text.truncate}>
{e.index}. {e.title} {e.index}. {e.title}
</span> </span>
{(e.durationLabel || e.uploader) && ( {(e.durationLabel || e.uploader || chosen) && (
<Caption1 className={text.muted}> <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> </Caption1>
)} )}
</span> </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 <Hint
label={ label={
effKind(e.index) === 'audio' effKind(e.index) === 'audio'
@@ -84,13 +133,38 @@ export function PlaylistPanel({
<IconButton <IconButton
size="sm" size="sm"
style={{ flexShrink: 0 }} style={{ flexShrink: 0 }}
icon={effKind(e.index) === 'audio' ? <MusicNote2Regular /> : <VideoClipRegular />} icon={
effKind(e.index) === 'audio' ? <MusicNote2Regular /> : <VideoClipRegular />
}
onClick={() => onToggleItemKind(e.index)} onClick={() => onToggleItemKind(e.index)}
aria-label={`Download type for ${e.title}: ${effKind(e.index)}`} aria-label={`Download type for ${e.title}: ${effKind(e.index)}`}
/> />
</Hint> </Hint>
</div> </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>
</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', flexDirection: 'column',
minWidth: 0 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 --- // --- trim / schedule panels ---
trimBlock: { trimBlock: {
display: 'flex', display: 'flex',
@@ -1,6 +1,7 @@
import { useState, useEffect, useRef, type Dispatch, type SetStateAction } from 'react' import { useState, useEffect, useRef, type Dispatch, type SetStateAction } from 'react'
import { import {
parseUrlShortcutContent, parseUrlShortcutContent,
BEST_FORMAT_ID,
type MediaInfo, type MediaInfo,
type FormatOption, type FormatOption,
type PlaylistInfo, type PlaylistInfo,
@@ -9,6 +10,8 @@ import {
type CommandPreviewResult type CommandPreviewResult
} from '@shared/ipc' } from '@shared/ipc'
import { useDownloads, type MediaKind } from '../../store/downloads' import { useDownloads, type MediaKind } from '../../store/downloads'
import { type ChosenFormat } from '../../store/downloadTypes'
import { buildPlaylistAddEntries } from './playlistEntries'
import { useHistory } from '../../store/history' import { useHistory } from '../../store/history'
import { QUALITY_OPTIONS } from '../../lib/qualityOptions' import { QUALITY_OPTIONS } from '../../lib/qualityOptions'
import { sameVideo } from '../../lib/queueStats' import { sameVideo } from '../../lib/queueStats'
@@ -78,6 +81,12 @@ export interface DownloadBarController {
playlist: PlaylistInfo | null playlist: PlaylistInfo | null
selected: Set<number> selected: Set<number>
allSelected: boolean 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 banner
suggestion: string | null suggestion: string | null
suggestionSource: SuggestionSource suggestionSource: SuggestionSource
@@ -109,6 +118,8 @@ export interface DownloadBarController {
effKind: (index: number) => MediaKind effKind: (index: number) => MediaKind
toggleItemKind: (index: number) => void toggleItemKind: (index: number) => void
setAllKinds: (k: MediaKind) => 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. // bar's global kind. Lets a playlist mix video and audio downloads.
const [itemKinds, setItemKinds] = useState<Record<number, MediaKind>>({}) 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 // 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 // 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 // on leave made the dashed outline blink. Count enters vs leaves and only clear
@@ -265,6 +288,11 @@ export function useDownloadBar(): DownloadBarController {
setPlaylist(null) setPlaylist(null)
setSelected(new Set()) setSelected(new Set())
setItemKinds({}) setItemKinds({})
setFormatExpanded(new Set())
setItemFormatOptions({})
setItemFormats({})
setFormatProbing(new Set())
setFormatProbeError({})
} }
// Reset the whole bar after a download / playlist is enqueued: clear the URL + // Reset the whole bar after a download / playlist is enqueued: clear the URL +
@@ -425,13 +453,96 @@ export function useDownloadBar(): DownloadBarController {
return itemKinds[index] ?? kind return itemKinds[index] ?? kind
} }
function toggleItemKind(index: number): void { 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 { function setAllKinds(k: MediaKind): void {
if (!playlist) return if (!playlist) return
const all: Record<number, MediaKind> = {} const all: Record<number, MediaKind> = {}
for (const e of playlist.entries) all[e.index] = k for (const e of playlist.entries) all[e.index] = k
setItemKinds(all) 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 { function download(): void {
@@ -516,18 +627,12 @@ export function useDownloadBar(): DownloadBarController {
function addPlaylist(): void { function addPlaylist(): void {
if (!playlist) return if (!playlist) return
const chosen = playlist.entries.filter((e) => selected.has(e.index)) const chosen = playlist.entries.filter((e) => selected.has(e.index))
// Each entry uses its own kind override; quality falls back to that kind's // Each entry uses its own kind override and, for a video row the user gave an
// default unless the entry matches the bar's current kind/quality. // 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( addMany(
chosen.map((e) => { buildPlaylistAddEntries(chosen, { barKind: kind, barQuality: quality, effKind, itemFormats })
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 }
}
})
) )
resetForm() resetForm()
} }
@@ -571,6 +676,12 @@ export function useDownloadBar(): DownloadBarController {
playlist, playlist,
selected, selected,
allSelected, allSelected,
// per-item format picker
formatExpanded,
itemFormatOptions,
itemFormat,
formatProbing,
formatProbeError,
// suggestion banner // suggestion banner
suggestion, suggestion,
suggestionSource, suggestionSource,
@@ -601,6 +712,8 @@ export function useDownloadBar(): DownloadBarController {
toggleAll, toggleAll,
effKind, effKind,
toggleItemKind, 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')
})
})