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:
+8
-2
@@ -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,44 +75,96 @@ export function PlaylistPanel({
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.plList}>
|
||||
{playlist.entries.map((e) => (
|
||||
<div key={e.index} className={styles.plItemRow}>
|
||||
<Checkbox
|
||||
className={styles.plItem}
|
||||
checked={selected.has(e.index)}
|
||||
onChange={(_, d) => onToggleEntry(e.index, !!d.checked)}
|
||||
label={
|
||||
<span className={styles.plItemLabel}>
|
||||
<span className={text.truncate}>
|
||||
{e.index}. {e.title}
|
||||
</span>
|
||||
{(e.durationLabel || e.uploader) && (
|
||||
<Caption1 className={text.muted}>
|
||||
{[e.durationLabel, e.uploader].filter(Boolean).join(META_SEP)}
|
||||
{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)}
|
||||
onChange={(_, d) => onToggleEntry(e.index, !!d.checked)}
|
||||
label={
|
||||
<span className={styles.plItemLabel}>
|
||||
<span className={text.truncate}>
|
||||
{e.index}. {e.title}
|
||||
</span>
|
||||
{(e.durationLabel || e.uploader || chosen) && (
|
||||
<Caption1 className={text.muted}>
|
||||
{[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'
|
||||
? 'Audio -- click for video'
|
||||
: 'Video -- click for audio'
|
||||
}
|
||||
placement="top"
|
||||
align="end"
|
||||
>
|
||||
<IconButton
|
||||
size="sm"
|
||||
style={{ flexShrink: 0 }}
|
||||
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>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<Hint
|
||||
label={
|
||||
effKind(e.index) === 'audio'
|
||||
? 'Audio -- click for video'
|
||||
: 'Video -- click for audio'
|
||||
}
|
||||
placement="top"
|
||||
align="end"
|
||||
>
|
||||
<IconButton
|
||||
size="sm"
|
||||
style={{ flexShrink: 0 }}
|
||||
icon={effKind(e.index) === 'audio' ? <MusicNote2Regular /> : <VideoClipRegular />}
|
||||
onClick={() => onToggleItemKind(e.index)}
|
||||
aria-label={`Download type for ${e.title}: ${effKind(e.index)}`}
|
||||
/>
|
||||
</Hint>
|
||||
</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',
|
||||
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
|
||||
@@ -265,6 +288,11 @@ export function useDownloadBar(): DownloadBarController {
|
||||
setPlaylist(null)
|
||||
setSelected(new Set())
|
||||
setItemKinds({})
|
||||
setFormatExpanded(new Set())
|
||||
setItemFormatOptions({})
|
||||
setItemFormats({})
|
||||
setFormatProbing(new Set())
|
||||
setFormatProbeError({})
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
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 +627,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 +676,12 @@ export function useDownloadBar(): DownloadBarController {
|
||||
playlist,
|
||||
selected,
|
||||
allSelected,
|
||||
// per-item format picker
|
||||
formatExpanded,
|
||||
itemFormatOptions,
|
||||
itemFormat,
|
||||
formatProbing,
|
||||
formatProbeError,
|
||||
// suggestion banner
|
||||
suggestion,
|
||||
suggestionSource,
|
||||
@@ -601,6 +712,8 @@ export function useDownloadBar(): DownloadBarController {
|
||||
toggleAll,
|
||||
effKind,
|
||||
toggleItemKind,
|
||||
setAllKinds
|
||||
setAllKinds,
|
||||
toggleItemFormat,
|
||||
setItemFormat
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user