feat: Phase N power-user surface (terminal, palette, per-item, sort, url-templates)

- Built-in yt-dlp terminal: src/main/terminal.ts streams raw yt-dlp runs
  (binary fixed, gated on customCommandEnabled), new TerminalView + sidebar tab
- Command palette (Ctrl/Cmd+K), portal-free CommandPalette.tsx wired in App
- Per-playlist-item editing: per-row video/audio toggle + All video/All audio
  batch; addPlaylist enqueues via addMany with per-item kind
- Weighted format sorting: DownloadOptions.formatSort -> raw -S (overrides codec)
- URL-regex template auto-matching: CommandTemplate.urlPattern + selectExtraArgs
  matchesUrl, threaded through resolveExtraArgs; field in TemplateManager

typecheck + test (192) + electron-vite build all clean. Roadmap: Phase N COMPLETE.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 11:10:22 -04:00
parent 9ac11ceb6c
commit da37690b42
18 changed files with 820 additions and 58 deletions
+23 -21
View File
@@ -342,29 +342,31 @@ Today `src/renderer/src/store/downloads.ts` has cancel + retry only.
for *sources*). **Limitation: the queue isn't persisted, so a schedule only fires while for *sources*). **Limitation: the queue isn't persisted, so a schedule only fires while
AeroFetch is running** — noted in the UI hint and the code.* AeroFetch is running** — noted in the UI hint and the code.*
## Phase N — Power-user surface ## Phase N — Power-user surface ✅ COMPLETE
YTDLnis leans hard into this; AeroFetch has the building blocks (command preview, templates, YTDLnis leans hard into this; AeroFetch had the building blocks (command preview, templates,
per-item `options`) but not the surfaces. per-item `options`) but not the surfaces. typecheck + test + `npm run build` clean.
- [ ] **Built-in terminal mode.** An interactive raw-yt-dlp console with live stdout/stderr — - [x] **Built-in terminal mode.** *Done: `src/main/terminal.ts` runs the bundled yt-dlp with
YTDLnis's "terminal." AeroFetch already has command *preview* (`previewCommand` + raw user args (binary fixed; gated on `customCommandEnabled`, enforced in main), streaming
`formatCommandLine` in `src/main/download.ts`) and a streaming spawn path; a terminal is stdout/stderr line-by-line over a new `terminal:output` channel (`terminal:run`/`cancel`
"spawn arbitrary argv → stream to a log pane → input box." New view + one IPC channel. IPC + preload). New `TerminalView.tsx` (args box, Run/Stop/Clear, live log, Ctrl+Enter)
- [ ] **Per-playlist-item editing.** Edit each playlist entry separately — different and a Terminal sidebar tab.*
format/type per item, multiple audio formats, batch-update type in one click (YTDLnis). - [x] **Per-playlist-item editing.** *Done: the playlist panel in `DownloadBar.tsx` gained a
Extend the playlist selection panel in `src/renderer/src/components/DownloadBar.tsx`; the per-row video/audio toggle + **All video** / **All audio** batch buttons; `addPlaylist`
queue already carries per-item `DownloadItem.options`, so the model supports it. enqueues via `addMany` with each entry's own kind (quality falls back to that kind's
- [ ] **Weighted format sorting ("format aspect importance").** Rank codec / container / default). Per-item exact-format selection (needs probing each entry) left as a further
quality and auto-pick by weighted priority. Extends today's single codec preference extension.*
(`-S res,fps,vcodec:…`) into a fuller `-S` builder. - [x] **Weighted format sorting ("format aspect importance").** *Done: a `formatSort` field on
- [ ] **URL-regex template auto-matching.** A `CommandTemplate` gains an optional URL pattern; `DownloadOptions` (raw yt-dlp `-S` string); when set it emits `-S <value>`, overriding the
`buildCommand` auto-applies the matching template (e.g. always use template X for codec tiebreaker (`buildArgs.ts`); an "advanced" input in `DownloadOptionsForm`. Unit-tested.*
`soundcloud.com`). Small extension to the existing `defaultTemplateId` logic in - [x] **URL-regex template auto-matching.** *Done: `CommandTemplate.urlPattern` (regex);
`src/main/templates.ts`. `selectExtraArgs` auto-applies a matching template ahead of the global default
- [ ] **Command palette + keyboard shortcuts.** A Ctrl+K palette and a few global shortcuts (`matchesUrl`, bad patterns never match), URL threaded via `resolveExtraArgs`. urlPattern
(paste-and-download, focus URL, switch tabs). Today keyboard handling is just Enter in a field in `TemplateManager` + persisted in `templates.ts`. Unit-tested.*
couple of inputs. - [x] **Command palette + keyboard shortcuts.** *Done: a portal-free `CommandPalette.tsx`
(filter, ↑/↓, Enter, Esc) opened with Ctrl/Cmd+K from `App.tsx`; actions = navigate tabs,
new download (focuses the `aerofetch-url` input), toggle theme.*
## Phase O — Windows-native integration & discoverability ## Phase O — Windows-native integration & discoverability
+28 -4
View File
@@ -143,6 +143,7 @@ export function parseExtraArgs(raw: string): string[] {
* *
* - customCommandEnabled off → [] (always) * - customCommandEnabled off → [] (always)
* - perDownloadExtraArgs defined (even '') → those args * - perDownloadExtraArgs defined (even '') → those args
* - else a template whose urlPattern matches → that template's args (most specific)
* - else a matching defaultTemplateId → that template's args * - else a matching defaultTemplateId → that template's args
* - else → [] * - else → []
*/ */
@@ -150,10 +151,20 @@ export function selectExtraArgs(params: {
customCommandEnabled: boolean customCommandEnabled: boolean
perDownloadExtraArgs: string | undefined perDownloadExtraArgs: string | undefined
defaultTemplateId: string | null defaultTemplateId: string | null
templates: Pick<CommandTemplate, 'id' | 'args'>[] templates: Pick<CommandTemplate, 'id' | 'args' | 'urlPattern'>[]
/** the download URL, for urlPattern auto-matching */
url?: string
}): string[] { }): string[] {
if (!params.customCommandEnabled) return [] if (!params.customCommandEnabled) return []
if (params.perDownloadExtraArgs !== undefined) return parseExtraArgs(params.perDownloadExtraArgs) if (params.perDownloadExtraArgs !== undefined) return parseExtraArgs(params.perDownloadExtraArgs)
// A template whose urlPattern matches this URL auto-applies, ahead of the global
// default — it's the more specific choice.
if (params.url) {
const matched = params.templates.find(
(t) => t.urlPattern && matchesUrl(t.urlPattern, params.url!)
)
if (matched) return parseExtraArgs(matched.args)
}
if (params.defaultTemplateId) { if (params.defaultTemplateId) {
const tpl = params.templates.find((t) => t.id === params.defaultTemplateId) const tpl = params.templates.find((t) => t.id === params.defaultTemplateId)
if (tpl) return parseExtraArgs(tpl.args) if (tpl) return parseExtraArgs(tpl.args)
@@ -161,6 +172,15 @@ export function selectExtraArgs(params: {
return [] return []
} }
/** Case-insensitive regex test of a template's urlPattern; a bad pattern never matches. */
export function matchesUrl(pattern: string, url: string): boolean {
try {
return new RegExp(pattern, 'i').test(url)
} catch {
return false
}
}
/** /**
* Parse a raw trim string (one or more time ranges, comma- or newline-separated) * Parse a raw trim string (one or more time ranges, comma- or newline-separated)
* into yt-dlp `--download-sections` specs. Each range is normalised to the * into yt-dlp `--download-sections` specs. Each range is normalised to the
@@ -347,9 +367,13 @@ export function buildArgs(
} }
} else { } else {
args.push('-f', videoSelector(opts), '--merge-output-format', o.videoContainer) args.push('-f', videoSelector(opts), '--merge-output-format', o.videoContainer)
// Codec preference is a sort tiebreaker AFTER resolution/fps, so it nudges the // A raw format-sort string (advanced) wins outright; otherwise the codec
// pick toward the chosen codec without overriding the requested quality. // preference is a sort tiebreaker AFTER resolution/fps, nudging the pick toward
if (o.preferredVideoCodec !== 'any') { // the chosen codec without overriding the requested quality.
const sort = o.formatSort.trim()
if (sort) {
args.push('-S', sort)
} else if (o.preferredVideoCodec !== 'any') {
const token = o.preferredVideoCodec === 'av1' ? 'av01' : o.preferredVideoCodec const token = o.preferredVideoCodec === 'av1' ? 'av01' : o.preferredVideoCodec
args.push('-S', `res,fps,vcodec:${token}`) args.push('-S', `res,fps,vcodec:${token}`)
} }
+2 -1
View File
@@ -170,7 +170,8 @@ function resolveExtraArgs(opts: StartDownloadOptions, settings: Settings): strin
customCommandEnabled: settings.customCommandEnabled, customCommandEnabled: settings.customCommandEnabled,
perDownloadExtraArgs: opts.extraArgs, perDownloadExtraArgs: opts.extraArgs,
defaultTemplateId: settings.defaultTemplateId, defaultTemplateId: settings.defaultTemplateId,
templates: listTemplates() templates: listTemplates(),
url: opts.url
}) })
} }
+5
View File
@@ -15,6 +15,7 @@ import { getFfmpegVersions } from './ffmpeg'
import { checkForAppUpdate, downloadAppUpdate, runAppUpdate } from './updater' import { checkForAppUpdate, downloadAppUpdate, runAppUpdate } from './updater'
import { probeMedia } from './probe' import { probeMedia } from './probe'
import { startDownload, cancelDownload, pauseDownload, previewCommand } from './download' import { startDownload, cancelDownload, pauseDownload, previewCommand } from './download'
import { runTerminal, cancelTerminal } from './terminal'
import { getSettings, setSettings, ensureMediaDirs } from './settings' import { getSettings, setSettings, ensureMediaDirs } from './settings'
import { listHistory, addHistory, removeHistory, removeManyHistory, clearHistory } from './history' import { listHistory, addHistory, removeHistory, removeManyHistory, clearHistory } from './history'
import { listTemplates, saveTemplate, removeTemplate } from './templates' import { listTemplates, saveTemplate, removeTemplate } from './templates'
@@ -212,6 +213,10 @@ function registerIpcHandlers(): void {
}) })
ipcMain.handle(IpcChannels.downloadCancel, (_e, id: string) => cancelDownload(id)) ipcMain.handle(IpcChannels.downloadCancel, (_e, id: string) => cancelDownload(id))
ipcMain.handle(IpcChannels.downloadPause, (_e, id: string) => pauseDownload(id)) ipcMain.handle(IpcChannels.downloadPause, (_e, id: string) => pauseDownload(id))
ipcMain.handle(IpcChannels.terminalRun, (e, id: string, args: string) =>
runTerminal(e.sender, id, args)
)
ipcMain.handle(IpcChannels.terminalCancel, (_e, id: string) => cancelTerminal(id))
ipcMain.handle(IpcChannels.defaultFolder, () => app.getPath('downloads')) ipcMain.handle(IpcChannels.defaultFolder, () => app.getPath('downloads'))
+1
View File
@@ -101,6 +101,7 @@ function sanitizeOptions(input: unknown): DownloadOptions {
preferredVideoCodec: VIDEO_CODECS.includes(o.preferredVideoCodec as never) preferredVideoCodec: VIDEO_CODECS.includes(o.preferredVideoCodec as never)
? o.preferredVideoCodec! ? o.preferredVideoCodec!
: d.preferredVideoCodec, : d.preferredVideoCodec,
formatSort: typeof o.formatSort === 'string' ? o.formatSort.trim() : d.formatSort,
embedSubtitles: bool(o.embedSubtitles, d.embedSubtitles), embedSubtitles: bool(o.embedSubtitles, d.embedSubtitles),
subtitleLanguages: subtitleLanguages:
typeof o.subtitleLanguages === 'string' && o.subtitleLanguages.trim() typeof o.subtitleLanguages === 'string' && o.subtitleLanguages.trim()
+4 -1
View File
@@ -36,10 +36,13 @@ function save(templates: CommandTemplate[]): void {
} }
function sanitize(t: CommandTemplate): CommandTemplate { function sanitize(t: CommandTemplate): CommandTemplate {
const urlPattern = typeof t.urlPattern === 'string' ? t.urlPattern.trim() : ''
return { return {
id: String(t.id), id: String(t.id),
name: (t.name ?? '').trim() || 'Untitled command', name: (t.name ?? '').trim() || 'Untitled command',
args: (t.args ?? '').trim() args: (t.args ?? '').trim(),
// Only persist a urlPattern when one was provided, keeping older files clean.
...(urlPattern ? { urlPattern } : {})
} }
} }
+99
View File
@@ -0,0 +1,99 @@
/**
* Built-in yt-dlp terminal (Phase N): runs the bundled yt-dlp with raw, user-typed
* arguments and streams stdout/stderr back to the renderer line-by-line.
*
* SECURITY: the executable is fixed to the managed yt-dlp (never an arbitrary exe),
* and the whole feature is gated on Settings.customCommandEnabled — the same consent
* flag that guards per-download extra args, because yt-dlp args can run arbitrary
* code (`--exec`). The gate is enforced here in main, not just in the renderer UI.
*/
import { spawn, execFile, type ChildProcess } from 'child_process'
import { existsSync } from 'fs'
import { type WebContents } from 'electron'
import { getYtdlpPath, getBinDir, getSystem32Path } from './binaries'
import { getSettings } from './settings'
import { ensureManagedYtdlp } from './ytdlp'
import { parseExtraArgs } from './buildArgs'
import { IpcChannels, type TerminalEvent, type TerminalRunResult } from '@shared/ipc'
const active = new Map<string, ChildProcess>()
function send(wc: WebContents, ev: TerminalEvent): void {
if (!wc.isDestroyed()) wc.send(IpcChannels.terminalOutput, ev)
}
export function runTerminal(wc: WebContents, id: string, argsRaw: string): TerminalRunResult {
if (!getSettings().customCommandEnabled) {
return {
ok: false,
error: 'Enable "Run custom commands" in Settings → Custom commands to use the terminal.'
}
}
ensureManagedYtdlp()
const ytdlp = getYtdlpPath()
if (!existsSync(ytdlp)) {
return { ok: false, error: 'yt-dlp.exe is missing and could not be restored.' }
}
if (active.has(id)) return { ok: false, error: 'A command is already running.' }
// Always pin ffmpeg so post-processing works; the rest is whatever the user typed.
const args = ['--ffmpeg-location', getBinDir(), ...parseExtraArgs(argsRaw)]
let child: ChildProcess
try {
child = spawn(ytdlp, args, { windowsHide: true })
} catch (e) {
return { ok: false, error: (e as Error).message }
}
active.set(id, child)
// Buffer each stream and emit on newline boundaries (flush the remainder on close).
const buffers: Record<'stdout' | 'stderr', string> = { stdout: '', stderr: '' }
function feed(stream: 'stdout' | 'stderr', chunk: string): void {
buffers[stream] += chunk
let nl: number
while ((nl = buffers[stream].indexOf('\n')) >= 0) {
const line = buffers[stream].slice(0, nl).replace(/\r$/, '')
buffers[stream] = buffers[stream].slice(nl + 1)
send(wc, { type: 'output', id, line, stream })
}
}
child.stdout?.on('data', (c: Buffer) => feed('stdout', c.toString()))
child.stderr?.on('data', (c: Buffer) => feed('stderr', c.toString()))
let settled = false
child.on('error', (err) => {
if (settled) return
settled = true
active.delete(id)
send(wc, { type: 'error', id, error: err.message })
})
child.on('close', (code) => {
if (settled) return
settled = true
active.delete(id)
// Flush any trailing partial lines that never hit a newline.
for (const stream of ['stdout', 'stderr'] as const) {
if (buffers[stream]) send(wc, { type: 'output', id, line: buffers[stream], stream })
}
send(wc, { type: 'done', id, code })
})
return { ok: true }
}
export function cancelTerminal(id: string): void {
const child = active.get(id)
if (!child) return
const pid = child.pid
if (pid != null) {
// Tree-kill via System32 taskkill by absolute path (same hardening as download.ts).
execFile(
getSystem32Path('taskkill.exe'),
['/pid', String(pid), '/T', '/F'],
{ windowsHide: true },
() => {}
)
} else {
child.kill()
}
}
+19 -1
View File
@@ -28,7 +28,9 @@ import {
type IndexProgress, type IndexProgress,
type IndexSourceResult, type IndexSourceResult,
type SyncResult, type SyncResult,
type ScheduledSyncStatus type ScheduledSyncStatus,
type TerminalEvent,
type TerminalRunResult
} from '@shared/ipc' } from '@shared/ipc'
// The surface exposed to the renderer. Keep this thin: it only forwards to IPC. // The surface exposed to the renderer. Keep this thin: it only forwards to IPC.
@@ -209,6 +211,22 @@ const api = {
const listener = (_e: IpcRendererEvent, p: IndexProgress): void => cb(p) const listener = (_e: IpcRendererEvent, p: IndexProgress): void => cb(p)
ipcRenderer.on(IpcChannels.indexProgress, listener) ipcRenderer.on(IpcChannels.indexProgress, listener)
return () => ipcRenderer.removeListener(IpcChannels.indexProgress, listener) return () => ipcRenderer.removeListener(IpcChannels.indexProgress, listener)
},
// --- Built-in yt-dlp terminal (Phase N) ---
/** Run the bundled yt-dlp with raw args; output streams via onTerminalOutput. */
runTerminal: (id: string, args: string): Promise<TerminalRunResult> =>
ipcRenderer.invoke(IpcChannels.terminalRun, id, args),
cancelTerminal: (id: string): Promise<void> =>
ipcRenderer.invoke(IpcChannels.terminalCancel, id),
/** Subscribe to live terminal output. Returns an unsubscribe function. */
onTerminalOutput: (cb: (ev: TerminalEvent) => void): (() => void) => {
const listener = (_e: IpcRendererEvent, ev: TerminalEvent): void => cb(ev)
ipcRenderer.on(IpcChannels.terminalOutput, listener)
return () => ipcRenderer.removeListener(IpcChannels.terminalOutput, listener)
} }
} }
+44
View File
@@ -4,8 +4,10 @@ import { Sidebar, type TabValue } from './components/Sidebar'
import { DownloadsView } from './components/DownloadsView' import { DownloadsView } from './components/DownloadsView'
import { LibraryView } from './components/LibraryView' import { LibraryView } from './components/LibraryView'
import { HistoryView } from './components/HistoryView' import { HistoryView } from './components/HistoryView'
import { TerminalView } from './components/TerminalView'
import { SettingsView } from './components/SettingsView' import { SettingsView } from './components/SettingsView'
import { Onboarding } from './components/Onboarding' import { Onboarding } from './components/Onboarding'
import { CommandPalette, type PaletteAction } from './components/CommandPalette'
import { getTheme, pageBackground } from './theme' import { getTheme, pageBackground } from './theme'
import { useSettings } from './store/settings' import { useSettings } from './store/settings'
import { useResolvedDark } from './store/systemTheme' import { useResolvedDark } from './store/systemTheme'
@@ -34,6 +36,43 @@ function App(): React.JSX.Element {
const updateSettings = useSettings((s) => s.update) const updateSettings = useSettings((s) => s.update)
const isDark = useResolvedDark() const isDark = useResolvedDark()
const [tab, setTab] = useState<TabValue>('downloads') const [tab, setTab] = useState<TabValue>('downloads')
const [paletteOpen, setPaletteOpen] = useState(false)
// Ctrl/Cmd+K toggles the command palette.
useEffect(() => {
function onKey(e: KeyboardEvent): void {
if ((e.ctrlKey || e.metaKey) && (e.key === 'k' || e.key === 'K')) {
e.preventDefault()
setPaletteOpen((o) => !o)
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [])
const paletteActions: PaletteAction[] = [
{ id: 'go-downloads', label: 'Go to Downloads', hint: 'Navigate', run: () => setTab('downloads') },
{ id: 'go-library', label: 'Go to Library', hint: 'Navigate', run: () => setTab('library') },
{ id: 'go-history', label: 'Go to History', hint: 'Navigate', run: () => setTab('history') },
{ id: 'go-terminal', label: 'Go to Terminal', hint: 'Navigate', run: () => setTab('terminal') },
{ id: 'go-settings', label: 'Go to Settings', hint: 'Navigate', run: () => setTab('settings') },
{
id: 'new-download',
label: 'New download',
hint: 'Focus URL',
run: () => {
setTab('downloads')
// Wait for the download bar to mount after the tab switch, then focus.
setTimeout(() => document.getElementById('aerofetch-url')?.focus(), 60)
}
},
{
id: 'toggle-theme',
label: isDark ? 'Switch to light theme' : 'Switch to dark theme',
hint: 'Appearance',
run: () => updateSettings({ theme: isDark ? 'light' : 'dark' })
}
]
// AeroFetch's own version, shown in the sidebar. Loaded once over IPC. // AeroFetch's own version, shown in the sidebar. Loaded once over IPC.
const [version, setVersion] = useState('') const [version, setVersion] = useState('')
@@ -89,8 +128,13 @@ function App(): React.JSX.Element {
{tab === 'downloads' && <DownloadsView />} {tab === 'downloads' && <DownloadsView />}
{tab === 'library' && <LibraryView />} {tab === 'library' && <LibraryView />}
{tab === 'history' && <HistoryView />} {tab === 'history' && <HistoryView />}
{tab === 'terminal' && <TerminalView />}
{tab === 'settings' && <SettingsView />} {tab === 'settings' && <SettingsView />}
</main> </main>
{paletteOpen && (
<CommandPalette actions={paletteActions} onClose={() => setPaletteOpen(false)} />
)}
</> </>
)} )}
</div> </div>
@@ -0,0 +1,167 @@
import { useEffect, useRef, useState } from 'react'
import { makeStyles, mergeClasses, tokens, shorthands } from '@fluentui/react-components'
export interface PaletteAction {
id: string
label: string
/** short right-aligned hint, e.g. a shortcut or category */
hint?: string
run: () => void
}
const useStyles = makeStyles({
// A plain fixed overlay — NOT a Fluent Dialog, since this app avoids Fluent's
// portal-based overlays (GPU/driver blank-overlay issue, see Select.tsx).
backdrop: {
position: 'fixed',
inset: 0,
zIndex: 1000,
display: 'flex',
justifyContent: 'center',
alignItems: 'flex-start',
paddingTop: '14vh',
backgroundColor: 'rgba(0,0,0,0.32)'
},
panel: {
width: 'min(560px, 92vw)',
display: 'flex',
flexDirection: 'column',
backgroundColor: tokens.colorNeutralBackground1,
...shorthands.borderRadius(tokens.borderRadiusXLarge),
border: `1px solid ${tokens.colorNeutralStroke2}`,
boxShadow: tokens.shadow28,
overflow: 'hidden'
},
input: {
appearance: 'none',
border: 'none',
outline: 'none',
padding: '14px 16px',
fontSize: tokens.fontSizeBase400,
fontFamily: tokens.fontFamilyBase,
backgroundColor: 'transparent',
color: tokens.colorNeutralForeground1,
borderBottom: `1px solid ${tokens.colorNeutralStroke2}`
},
list: {
maxHeight: '50vh',
overflowY: 'auto',
padding: '6px'
},
item: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '12px',
width: '100%',
padding: '9px 12px',
border: 'none',
backgroundColor: 'transparent',
color: tokens.colorNeutralForeground1,
fontSize: tokens.fontSizeBase300,
fontFamily: tokens.fontFamilyBase,
textAlign: 'left',
cursor: 'pointer',
...shorthands.borderRadius(tokens.borderRadiusMedium)
},
itemActive: {
backgroundColor: tokens.colorBrandBackground2,
color: tokens.colorBrandForeground2
},
itemHint: {
flexShrink: 0,
color: tokens.colorNeutralForeground3,
fontSize: tokens.fontSizeBase200
},
empty: {
padding: '14px 12px',
color: tokens.colorNeutralForeground3
}
})
/**
* A Ctrl/Cmd+K command palette (Phase N). Filter by typing, ↑/↓ to move, Enter to
* run, Esc or a backdrop click to dismiss. Actions are supplied by App.
*/
export function CommandPalette({
actions,
onClose
}: {
actions: PaletteAction[]
onClose: () => void
}): React.JSX.Element {
const styles = useStyles()
const [q, setQ] = useState('')
const [sel, setSel] = useState(0)
const inputRef = useRef<HTMLInputElement>(null)
const filtered = actions.filter((a) => a.label.toLowerCase().includes(q.trim().toLowerCase()))
useEffect(() => {
inputRef.current?.focus()
}, [])
useEffect(() => {
setSel(0)
}, [q])
function onKeyDown(e: React.KeyboardEvent): void {
if (e.key === 'Escape') {
onClose()
} else if (e.key === 'ArrowDown') {
e.preventDefault()
setSel((s) => Math.min(filtered.length - 1, s + 1))
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setSel((s) => Math.max(0, s - 1))
} else if (e.key === 'Enter') {
e.preventDefault()
const a = filtered[sel]
if (a) {
a.run()
onClose()
}
}
}
return (
<div className={styles.backdrop} onClick={onClose}>
<div
className={styles.panel}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-label="Command palette"
>
<input
ref={inputRef}
className={styles.input}
value={q}
onChange={(e) => setQ(e.target.value)}
onKeyDown={onKeyDown}
placeholder="Type a command…"
aria-label="Command palette search"
/>
<div className={styles.list}>
{filtered.length === 0 ? (
<div className={styles.empty}>No matching commands</div>
) : (
filtered.map((a, i) => (
<button
key={a.id}
type="button"
className={mergeClasses(styles.item, i === sel && styles.itemActive)}
onClick={() => {
a.run()
onClose()
}}
onMouseEnter={() => setSel(i)}
>
<span>{a.label}</span>
{a.hint && <span className={styles.itemHint}>{a.hint}</span>}
</button>
))
)}
</div>
</div>
</div>
)
}
+98 -24
View File
@@ -223,10 +223,35 @@ const useStyles = makeStyles({
overflowY: 'auto', overflowY: 'auto',
paddingRight: '4px' paddingRight: '4px'
}, },
plItemRow: {
display: 'flex',
alignItems: 'center',
gap: '8px'
},
plItem: { plItem: {
display: 'flex', display: 'flex',
alignItems: 'flex-start', alignItems: 'flex-start',
padding: '2px 0' padding: '2px 0',
flexGrow: 1,
minWidth: 0
},
plKindBtn: {
flexShrink: 0,
appearance: 'none',
border: 'none',
backgroundColor: 'transparent',
color: tokens.colorNeutralForeground3,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '28px',
height: '28px',
cursor: 'pointer',
...shorthands.borderRadius(tokens.borderRadiusMedium),
':hover': {
backgroundColor: tokens.colorNeutralBackground1Hover,
color: tokens.colorNeutralForeground2
}
}, },
plItemLabel: { plItemLabel: {
display: 'flex', display: 'flex',
@@ -285,6 +310,7 @@ const useStyles = makeStyles({
export function DownloadBar(): React.JSX.Element { export function DownloadBar(): React.JSX.Element {
const styles = useStyles() const styles = useStyles()
const addFromUrl = useDownloads((s) => s.addFromUrl) const addFromUrl = useDownloads((s) => s.addFromUrl)
const addMany = useDownloads((s) => s.addMany)
const settingsLoaded = useSettings((s) => s.loaded) const settingsLoaded = useSettings((s) => s.loaded)
const defaultKind = useSettings((s) => s.defaultKind) const defaultKind = useSettings((s) => s.defaultKind)
const defaultVideoQuality = useSettings((s) => s.defaultVideoQuality) const defaultVideoQuality = useSettings((s) => s.defaultVideoQuality)
@@ -354,6 +380,9 @@ export function DownloadBar(): React.JSX.Element {
// Probe state for a playlist URL. // Probe state for a playlist URL.
const [playlist, setPlaylist] = useState<PlaylistInfo | null>(null) const [playlist, setPlaylist] = useState<PlaylistInfo | null>(null)
const [selected, setSelected] = useState<Set<number>>(new Set()) const [selected, setSelected] = useState<Set<number>>(new Set())
// Per-entry kind override (entry.index → 'video' | 'audio'); absent = use the
// bar's global kind. Lets a playlist mix video and audio downloads.
const [itemKinds, setItemKinds] = useState<Record<number, MediaKind>>({})
// Clipboard auto-detect: when the window gains focus and the clipboard holds a // Clipboard auto-detect: when the window gains focus and the clipboard holds a
// fresh link, offer it (without clobbering anything the user is already typing). // fresh link, offer it (without clobbering anything the user is already typing).
@@ -424,6 +453,7 @@ export function DownloadBar(): React.JSX.Element {
setFormatId('') setFormatId('')
setPlaylist(null) setPlaylist(null)
setSelected(new Set()) setSelected(new Set())
setItemKinds({})
} }
function onUrlChange(next: string): void { function onUrlChange(next: string): void {
@@ -489,6 +519,20 @@ export function DownloadBar(): React.JSX.Element {
setSelected(allSelected ? new Set() : new Set(playlist.entries.map((e) => e.index))) setSelected(allSelected ? new Set() : new Set(playlist.entries.map((e) => e.index)))
} }
// Per-entry kind: the override if set, else the bar's global kind.
function effKind(index: number): MediaKind {
return itemKinds[index] ?? kind
}
function toggleItemKind(index: number): void {
setItemKinds((m) => ({ ...m, [index]: effKind(index) === 'audio' ? 'video' : 'audio' }))
}
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)
}
function download(): void { function download(): void {
const trimmed = url.trim() const trimmed = url.trim()
if (!trimmed) return if (!trimmed) return
@@ -553,13 +597,19 @@ export function DownloadBar(): React.JSX.Element {
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))
for (const e of chosen) { // Each entry uses its own kind override; quality falls back to that kind's
addFromUrl(e.url, kind, quality, { // default unless the entry matches the bar's current kind/quality.
title: e.title, addMany(
channel: e.uploader, chosen.map((e) => {
durationLabel: e.durationLabel 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 }
}
}) })
} )
setUrl('') setUrl('')
clearProbe() clearProbe()
} }
@@ -574,6 +624,7 @@ export function DownloadBar(): React.JSX.Element {
<div className={styles.urlRow}> <div className={styles.urlRow}>
<Input <Input
className={styles.url} className={styles.url}
input={{ id: 'aerofetch-url' }}
value={url} value={url}
onChange={(_, d) => onUrlChange(d.value)} onChange={(_, d) => onUrlChange(d.value)}
onKeyDown={(e) => e.key === 'Enter' && fetchFormats()} onKeyDown={(e) => e.key === 'Enter' && fetchFormats()}
@@ -687,27 +738,50 @@ export function DownloadBar(): React.JSX.Element {
<Button size="small" appearance="subtle" onClick={toggleAll}> <Button size="small" appearance="subtle" onClick={toggleAll}>
{allSelected ? 'Select none' : 'Select all'} {allSelected ? 'Select none' : 'Select all'}
</Button> </Button>
<Button size="small" appearance="subtle" onClick={() => setAllKinds('video')}>
All video
</Button>
<Button size="small" appearance="subtle" onClick={() => setAllKinds('audio')}>
All audio
</Button>
</div> </div>
<div className={styles.plList}> <div className={styles.plList}>
{playlist.entries.map((e) => ( {playlist.entries.map((e) => (
<Checkbox <div key={e.index} className={styles.plItemRow}>
key={e.index} <Checkbox
className={styles.plItem} className={styles.plItem}
checked={selected.has(e.index)} checked={selected.has(e.index)}
onChange={(_, d) => toggleEntry(e.index, !!d.checked)} onChange={(_, d) => toggleEntry(e.index, !!d.checked)}
label={ label={
<span className={styles.plItemLabel}> <span className={styles.plItemLabel}>
<span className={styles.plItemTitle}> <span className={styles.plItemTitle}>
{e.index}. {e.title} {e.index}. {e.title}
</span>
{(e.durationLabel || e.uploader) && (
<Caption1 className={styles.plItemMeta}>
{[e.durationLabel, e.uploader].filter(Boolean).join(' • ')}
</Caption1>
)}
</span> </span>
{(e.durationLabel || e.uploader) && ( }
<Caption1 className={styles.plItemMeta}> />
{[e.durationLabel, e.uploader].filter(Boolean).join(' • ')} <Hint
</Caption1> label={
)} effKind(e.index) === 'audio' ? 'Audio — click for video' : 'Video — click for audio'
</span> }
} placement="top"
/> align="end"
>
<button
type="button"
className={styles.plKindBtn}
onClick={() => toggleItemKind(e.index)}
aria-label={`Download type for ${e.title}: ${effKind(e.index)}`}
>
{effKind(e.index) === 'audio' ? <MusicNote2Regular /> : <VideoClipRegular />}
</button>
</Hint>
</div>
))} ))}
</div> </div>
</div> </div>
@@ -133,6 +133,17 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
</Field> </Field>
</div> </div>
<Field
label="Format sorting (advanced)"
hint="Raw yt-dlp -S string to rank formats by priority, e.g. res:1080,vcodec:av01,size. Overrides the preferred-codec tiebreaker. Leave empty unless you know yt-dlp's -S syntax."
>
<Input
value={value.formatSort}
placeholder="res,fps,vcodec:av01"
onChange={(_, d) => setOpt('formatSort', d.value)}
/>
</Field>
<Field label="Embed subtitles" hint="Download subtitles and mux them into the video."> <Field label="Embed subtitles" hint="Download subtitles and mux them into the video.">
<Switch <Switch
checked={value.embedSubtitles} checked={value.embedSubtitles}
+3 -1
View File
@@ -4,6 +4,7 @@ import {
ArrowDownloadRegular, ArrowDownloadRegular,
HistoryRegular, HistoryRegular,
LibraryRegular, LibraryRegular,
WindowConsoleRegular,
SettingsRegular, SettingsRegular,
WeatherMoonRegular, WeatherMoonRegular,
WeatherSunnyRegular, WeatherSunnyRegular,
@@ -14,7 +15,7 @@ import {
import type { ThemeMode } from '@shared/ipc' import type { ThemeMode } from '@shared/ipc'
import { Hint } from './Hint' import { Hint } from './Hint'
export type TabValue = 'downloads' | 'library' | 'history' | 'settings' export type TabValue = 'downloads' | 'library' | 'history' | 'terminal' | 'settings'
const useStyles = makeStyles({ const useStyles = makeStyles({
root: { root: {
@@ -179,6 +180,7 @@ const NAV: { value: TabValue; label: string; icon: React.JSX.Element }[] = [
{ value: 'downloads', label: 'Downloads', icon: <ArrowDownloadRegular /> }, { value: 'downloads', label: 'Downloads', icon: <ArrowDownloadRegular /> },
{ value: 'library', label: 'Library', icon: <LibraryRegular /> }, { value: 'library', label: 'Library', icon: <LibraryRegular /> },
{ value: 'history', label: 'History', icon: <HistoryRegular /> }, { value: 'history', label: 'History', icon: <HistoryRegular /> },
{ value: 'terminal', label: 'Terminal', icon: <WindowConsoleRegular /> },
{ value: 'settings', label: 'Settings', icon: <SettingsRegular /> } { value: 'settings', label: 'Settings', icon: <SettingsRegular /> }
] ]
@@ -74,8 +74,10 @@ interface Draft {
id: string | null id: string | null
name: string name: string
args: string args: string
/** optional regex; auto-applies the template to matching URLs */
urlPattern: string
} }
const BLANK_DRAFT: Draft = { id: null, name: '', args: '' } const BLANK_DRAFT: Draft = { id: null, name: '', args: '', urlPattern: '' }
function newId(): string { function newId(): string {
return typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `tpl-${Date.now()}` return typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `tpl-${Date.now()}`
@@ -95,14 +97,20 @@ export function TemplateManager(): React.JSX.Element {
const [draft, setDraft] = useState<Draft | null>(null) const [draft, setDraft] = useState<Draft | null>(null)
function startEdit(t: CommandTemplate): void { function startEdit(t: CommandTemplate): void {
setDraft({ id: t.id, name: t.name, args: t.args }) setDraft({ id: t.id, name: t.name, args: t.args, urlPattern: t.urlPattern ?? '' })
} }
function commit(): void { function commit(): void {
if (!draft) return if (!draft) return
const name = draft.name.trim() const name = draft.name.trim()
if (!name) return if (!name) return
save({ id: draft.id ?? newId(), name, args: draft.args.trim() }) const urlPattern = draft.urlPattern.trim()
save({
id: draft.id ?? newId(),
name,
args: draft.args.trim(),
...(urlPattern ? { urlPattern } : {})
})
setDraft(null) setDraft(null)
} }
@@ -154,6 +162,11 @@ export function TemplateManager(): React.JSX.Element {
resize="vertical" resize="vertical"
onChange={(_, d) => setDraft({ ...draft, args: d.value })} onChange={(_, d) => setDraft({ ...draft, args: d.value })}
/> />
<Input
value={draft.urlPattern}
placeholder="Auto-apply to URLs matching (regex, optional) — e.g. soundcloud\.com"
onChange={(_, d) => setDraft({ ...draft, urlPattern: d.value })}
/>
<div className={styles.formActions}> <div className={styles.formActions}>
<Button appearance="subtle" icon={<DismissRegular />} onClick={() => setDraft(null)}> <Button appearance="subtle" icon={<DismissRegular />} onClick={() => setDraft(null)}>
Cancel Cancel
@@ -0,0 +1,203 @@
import { useEffect, useRef, useState } from 'react'
import {
Button,
Textarea,
Subtitle2,
Body1,
Caption1,
makeStyles,
tokens,
shorthands
} from '@fluentui/react-components'
import { PlayRegular, DismissRegular, DeleteRegular } from '@fluentui/react-icons'
import { useSettings } from '../store/settings'
type LineKind = 'stdout' | 'stderr' | 'cmd' | 'sys'
interface Line {
text: string
kind: LineKind
}
const useStyles = makeStyles({
root: { display: 'flex', flexDirection: 'column', gap: '16px', height: '100%' },
header: { display: 'flex', flexDirection: 'column', gap: '2px' },
sub: { color: tokens.colorNeutralForeground3 },
gate: {
padding: '12px 14px',
backgroundColor: tokens.colorStatusWarningBackground1,
color: tokens.colorStatusWarningForeground1,
...shorthands.borderRadius(tokens.borderRadiusLarge)
},
inputRow: { display: 'flex', gap: '8px', alignItems: 'flex-end' },
inputCol: { flexGrow: 1, display: 'flex', flexDirection: 'column', gap: '4px' },
prefix: { color: tokens.colorNeutralForeground3, fontFamily: tokens.fontFamilyMonospace },
buttons: { display: 'flex', gap: '8px' },
log: {
flexGrow: 1,
minHeight: '160px',
overflowY: 'auto',
margin: 0,
padding: '12px 14px',
backgroundColor: tokens.colorNeutralBackground1,
color: tokens.colorNeutralForeground1,
...shorthands.borderRadius(tokens.borderRadiusLarge),
border: `1px solid ${tokens.colorNeutralStroke2}`,
fontFamily: tokens.fontFamilyMonospace,
fontSize: tokens.fontSizeBase200,
lineHeight: tokens.lineHeightBase300,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word'
},
cmd: { color: tokens.colorBrandForeground1, fontWeight: tokens.fontWeightSemibold },
stderr: { color: tokens.colorPaletteRedForeground1 },
sys: { color: tokens.colorNeutralForeground3 },
empty: { color: tokens.colorNeutralForeground3 }
})
function newId(): string {
return typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `t-${Date.now()}`
}
/**
* Built-in yt-dlp terminal (Phase N): type raw yt-dlp args, run the bundled
* binary, and watch its output stream. Gated on the customCommandEnabled consent
* flag (enforced again in main — see src/main/terminal.ts).
*/
export function TerminalView(): React.JSX.Element {
const styles = useStyles()
const customCommandEnabled = useSettings((s) => s.customCommandEnabled)
const [args, setArgs] = useState('')
const [lines, setLines] = useState<Line[]>([])
const [running, setRunning] = useState(false)
const runId = useRef<string | null>(null)
const logRef = useRef<HTMLPreElement>(null)
// Stream terminal output for the current run into the log.
useEffect(
() =>
window.api.onTerminalOutput((ev) => {
if (ev.id !== runId.current) return
if (ev.type === 'output') {
setLines((ls) => [...ls, { text: ev.line, kind: ev.stream }])
} else if (ev.type === 'error') {
setLines((ls) => [...ls, { text: ev.error, kind: 'stderr' }])
setRunning(false)
runId.current = null
} else {
setLines((ls) => [...ls, { text: `— exited (code ${ev.code ?? '?'})`, kind: 'sys' }])
setRunning(false)
runId.current = null
}
}),
[]
)
// Keep the log pinned to the latest output.
useEffect(() => {
if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight
}, [lines])
function run(): void {
const a = args.trim()
if (!a || running) return
const id = newId()
runId.current = id
setLines((ls) => [...ls, { text: `> yt-dlp ${a}`, kind: 'cmd' }])
setRunning(true)
window.api
.runTerminal(id, a)
.then((res) => {
if (!res.ok) {
setLines((ls) => [...ls, { text: res.error ?? 'Failed to start.', kind: 'stderr' }])
setRunning(false)
runId.current = null
}
})
.catch((e: unknown) => {
setLines((ls) => [...ls, { text: String(e), kind: 'stderr' }])
setRunning(false)
runId.current = null
})
}
function stop(): void {
if (runId.current) window.api.cancelTerminal(runId.current)
}
const lineClass = (kind: LineKind): string | undefined =>
kind === 'cmd' ? styles.cmd : kind === 'stderr' ? styles.stderr : kind === 'sys' ? styles.sys : undefined
return (
<div className={styles.root}>
<div className={styles.header}>
<Subtitle2>Terminal</Subtitle2>
<Caption1 className={styles.sub}>
Run the bundled yt-dlp with your own arguments. The URL goes in the args too, e.g.{' '}
<code>-F https://youtu.be/…</code>. ffmpeg is wired up automatically.
</Caption1>
</div>
{!customCommandEnabled && (
<Body1 className={styles.gate}>
The terminal is part of custom commands. Turn on Run custom commands in Settings
Custom commands to use it.
</Body1>
)}
<div className={styles.inputRow}>
<div className={styles.inputCol}>
<Caption1 className={styles.prefix}>yt-dlp</Caption1>
<Textarea
value={args}
onChange={(_, d) => setArgs(d.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault()
run()
}
}}
placeholder="--version (Ctrl+Enter to run)"
resize="vertical"
disabled={!customCommandEnabled}
/>
</div>
<div className={styles.buttons}>
{running ? (
<Button appearance="secondary" icon={<DismissRegular />} onClick={stop}>
Stop
</Button>
) : (
<Button
appearance="primary"
icon={<PlayRegular />}
onClick={run}
disabled={!customCommandEnabled || !args.trim()}
>
Run
</Button>
)}
<Button
appearance="subtle"
icon={<DeleteRegular />}
onClick={() => setLines([])}
disabled={lines.length === 0}
aria-label="Clear output"
/>
</div>
</div>
<pre className={styles.log} ref={logRef}>
{lines.length === 0 ? (
<span className={styles.empty}>Output will appear here.</span>
) : (
lines.map((l, i) => (
<div key={i} className={lineClass(l.kind)}>
{l.text}
</div>
))
)}
</pre>
</div>
)
}
+4 -1
View File
@@ -220,7 +220,10 @@ if (import.meta.env.DEV && !window.api) {
durationLabel: `${10 + i}:0${i}`, durationLabel: `${10 + i}:0${i}`,
downloaded: i < 2 downloaded: i < 2
})), })),
onIndexProgress: () => () => {} onIndexProgress: () => () => {},
runTerminal: async () => ({ ok: true }),
cancelTerminal: async () => {},
onTerminalOutput: () => () => {}
} }
} }
+39 -1
View File
@@ -71,7 +71,12 @@ export const IpcChannels = {
scheduledSyncGet: 'sources:scheduled-sync-get', scheduledSyncGet: 'sources:scheduled-sync-get',
scheduledSyncSet: 'sources:scheduled-sync-set', scheduledSyncSet: 'sources:scheduled-sync-set',
/** main → renderer push channel for live indexing progress */ /** main → renderer push channel for live indexing progress */
indexProgress: 'sources:index-progress' indexProgress: 'sources:index-progress',
// --- Built-in yt-dlp terminal (Phase N) ---
terminalRun: 'terminal:run',
terminalCancel: 'terminal:cancel',
/** main → renderer push channel for live terminal output lines */
terminalOutput: 'terminal:output'
} as const } as const
/** Sentinel format id meaning "let yt-dlp pick the best video+audio". */ /** Sentinel format id meaning "let yt-dlp pick the best video+audio". */
@@ -147,6 +152,13 @@ export interface DownloadOptions {
videoContainer: VideoContainer videoContainer: VideoContainer
/** preferred video codec (sort tiebreaker, not a hard filter) */ /** preferred video codec (sort tiebreaker, not a hard filter) */
preferredVideoCodec: VideoCodecPref preferredVideoCodec: VideoCodecPref
/**
* Advanced: a raw yt-dlp `-S` format-sort string (e.g. 'res:1080,vcodec:av01,size').
* When non-empty it replaces the codec-derived sort, letting power users rank
* resolution / codec / container / size by weighted priority. Empty = use the
* preferredVideoCodec tiebreaker.
*/
formatSort: string
/** download + embed subtitles into video */ /** download + embed subtitles into video */
embedSubtitles: boolean embedSubtitles: boolean
/** comma-separated yt-dlp sub-lang selectors, e.g. 'en' or 'en.*,es' */ /** comma-separated yt-dlp sub-lang selectors, e.g. 'en' or 'en.*,es' */
@@ -179,6 +191,7 @@ export const DEFAULT_DOWNLOAD_OPTIONS: DownloadOptions = {
audioFormat: 'mp3', audioFormat: 'mp3',
videoContainer: 'mp4', videoContainer: 'mp4',
preferredVideoCodec: 'any', preferredVideoCodec: 'any',
formatSort: '',
embedSubtitles: false, embedSubtitles: false,
subtitleLanguages: 'en', subtitleLanguages: 'en',
autoSubtitles: false, autoSubtitles: false,
@@ -509,6 +522,13 @@ export interface CommandTemplate {
id: string id: string
name: string name: string
args: string args: string
/**
* Optional regex (matched case-insensitively against the download URL). When
* set and custom commands are enabled, this template auto-applies to any URL it
* matches — taking precedence over the global defaultTemplateId. Empty/undefined
* means the template is only applied when explicitly chosen as the default.
*/
urlPattern?: string
} }
/** Result of building the exact yt-dlp command line for the current form state, without running it. */ /** Result of building the exact yt-dlp command line for the current form state, without running it. */
@@ -671,3 +691,21 @@ export interface ScheduledSyncStatus {
enabled: boolean enabled: boolean
error?: string error?: string
} }
// --- Built-in yt-dlp terminal (Phase N) -------------------------------------
// A power-user console that runs the bundled yt-dlp with raw, user-typed args
// and streams its output. The binary is fixed to yt-dlp (never arbitrary exes),
// and the feature is gated on the same customCommandEnabled consent flag as the
// per-download extra args (extra args can run code via --exec — audit F2).
/** Live output pushed on IpcChannels.terminalOutput while a terminal command runs. */
export type TerminalEvent =
| { type: 'output'; id: string; line: string; stream: 'stdout' | 'stderr' }
| { type: 'done'; id: string; code: number | null }
| { type: 'error'; id: string; error: string }
/** Result of starting a terminal command (pre-spawn validation only). */
export interface TerminalRunResult {
ok: boolean
error?: string
}
+54
View File
@@ -461,6 +461,60 @@ describe('sidecar files', () => {
}) })
}) })
describe('format sorting (-S)', () => {
it('emits a raw -S string when formatSort is set, overriding the codec tiebreaker', () => {
const argv = build(opts(), dlo({ formatSort: 'res:1080,vcodec:av01', preferredVideoCodec: 'h264' }))
expect(hasSeq(argv, '-S', 'res:1080,vcodec:av01')).toBe(true)
expect(hasSeq(argv, '-S', 'res,fps,vcodec:h264')).toBe(false)
})
it('falls back to the codec tiebreaker when formatSort is empty', () => {
const argv = build(opts(), dlo({ formatSort: '', preferredVideoCodec: 'vp9' }))
expect(hasSeq(argv, '-S', 'res,fps,vcodec:vp9')).toBe(true)
})
})
describe('selectExtraArgs url-pattern matching', () => {
const base = {
customCommandEnabled: true,
perDownloadExtraArgs: undefined,
defaultTemplateId: null as string | null
}
const templates = [
{ id: 'a', args: '--audio-quality 0', urlPattern: 'soundcloud\\.com' },
{ id: 'b', args: '--write-thumbnail' }
]
it('auto-applies a template whose urlPattern matches the URL', () => {
const out = selectExtraArgs({ ...base, templates, url: 'https://soundcloud.com/x/y' })
expect(out).toEqual(['--audio-quality', '0'])
})
it('does not apply a urlPattern template to a non-matching URL', () => {
const out = selectExtraArgs({ ...base, templates, url: 'https://youtube.com/watch?v=x' })
expect(out).toEqual([])
})
it('urlPattern match takes precedence over the global default template', () => {
const out = selectExtraArgs({
...base,
defaultTemplateId: 'b',
templates,
url: 'https://soundcloud.com/x'
})
expect(out).toEqual(['--audio-quality', '0'])
})
it('a bad regex never matches (and is skipped safely)', () => {
const out = selectExtraArgs({
...base,
templates: [{ id: 'c', args: '--x', urlPattern: '(' }],
url: 'https://soundcloud.com/x'
})
expect(out).toEqual([])
})
})
describe('chapters', () => { describe('chapters', () => {
it('emits --split-chapters when splitChapters is on', () => { it('emits --split-chapters when splitChapters is on', () => {
expect(build(opts(), dlo({ splitChapters: true }))).toContain('--split-chapters') expect(build(opts(), dlo({ splitChapters: true }))).toContain('--split-chapters')