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:
+23
-21
@@ -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
|
||||
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,
|
||||
per-item `options`) but not the surfaces.
|
||||
YTDLnis leans hard into this; AeroFetch had the building blocks (command preview, templates,
|
||||
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 —
|
||||
YTDLnis's "terminal." AeroFetch already has command *preview* (`previewCommand` +
|
||||
`formatCommandLine` in `src/main/download.ts`) and a streaming spawn path; a terminal is
|
||||
"spawn arbitrary argv → stream to a log pane → input box." New view + one IPC channel.
|
||||
- [ ] **Per-playlist-item editing.** Edit each playlist entry separately — different
|
||||
format/type per item, multiple audio formats, batch-update type in one click (YTDLnis).
|
||||
Extend the playlist selection panel in `src/renderer/src/components/DownloadBar.tsx`; the
|
||||
queue already carries per-item `DownloadItem.options`, so the model supports it.
|
||||
- [ ] **Weighted format sorting ("format aspect importance").** Rank codec / container /
|
||||
quality and auto-pick by weighted priority. Extends today's single codec preference
|
||||
(`-S res,fps,vcodec:…`) into a fuller `-S` builder.
|
||||
- [ ] **URL-regex template auto-matching.** A `CommandTemplate` gains an optional URL pattern;
|
||||
`buildCommand` auto-applies the matching template (e.g. always use template X for
|
||||
`soundcloud.com`). Small extension to the existing `defaultTemplateId` logic in
|
||||
`src/main/templates.ts`.
|
||||
- [ ] **Command palette + keyboard shortcuts.** A Ctrl+K palette and a few global shortcuts
|
||||
(paste-and-download, focus URL, switch tabs). Today keyboard handling is just Enter in a
|
||||
couple of inputs.
|
||||
- [x] **Built-in terminal mode.** *Done: `src/main/terminal.ts` runs the bundled yt-dlp with
|
||||
raw user args (binary fixed; gated on `customCommandEnabled`, enforced in main), streaming
|
||||
stdout/stderr line-by-line over a new `terminal:output` channel (`terminal:run`/`cancel`
|
||||
IPC + preload). New `TerminalView.tsx` (args box, Run/Stop/Clear, live log, Ctrl+Enter)
|
||||
and a Terminal sidebar tab.*
|
||||
- [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.*
|
||||
- [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.*
|
||||
- [x] **URL-regex template auto-matching.** *Done: `CommandTemplate.urlPattern` (regex);
|
||||
`selectExtraArgs` auto-applies a matching template ahead of the global default
|
||||
(`matchesUrl`, bad patterns never match), URL threaded via `resolveExtraArgs`. urlPattern
|
||||
field in `TemplateManager` + persisted in `templates.ts`. Unit-tested.*
|
||||
- [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
|
||||
|
||||
|
||||
+28
-4
@@ -143,6 +143,7 @@ export function parseExtraArgs(raw: string): string[] {
|
||||
*
|
||||
* - customCommandEnabled off → [] (always)
|
||||
* - 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 → []
|
||||
*/
|
||||
@@ -150,10 +151,20 @@ export function selectExtraArgs(params: {
|
||||
customCommandEnabled: boolean
|
||||
perDownloadExtraArgs: string | undefined
|
||||
defaultTemplateId: string | null
|
||||
templates: Pick<CommandTemplate, 'id' | 'args'>[]
|
||||
templates: Pick<CommandTemplate, 'id' | 'args' | 'urlPattern'>[]
|
||||
/** the download URL, for urlPattern auto-matching */
|
||||
url?: string
|
||||
}): string[] {
|
||||
if (!params.customCommandEnabled) return []
|
||||
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) {
|
||||
const tpl = params.templates.find((t) => t.id === params.defaultTemplateId)
|
||||
if (tpl) return parseExtraArgs(tpl.args)
|
||||
@@ -161,6 +172,15 @@ export function selectExtraArgs(params: {
|
||||
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)
|
||||
* into yt-dlp `--download-sections` specs. Each range is normalised to the
|
||||
@@ -347,9 +367,13 @@ export function buildArgs(
|
||||
}
|
||||
} else {
|
||||
args.push('-f', videoSelector(opts), '--merge-output-format', o.videoContainer)
|
||||
// Codec preference is a sort tiebreaker AFTER resolution/fps, so it nudges the
|
||||
// pick toward the chosen codec without overriding the requested quality.
|
||||
if (o.preferredVideoCodec !== 'any') {
|
||||
// A raw format-sort string (advanced) wins outright; otherwise the codec
|
||||
// preference is a sort tiebreaker AFTER resolution/fps, nudging the pick toward
|
||||
// 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
|
||||
args.push('-S', `res,fps,vcodec:${token}`)
|
||||
}
|
||||
|
||||
@@ -170,7 +170,8 @@ function resolveExtraArgs(opts: StartDownloadOptions, settings: Settings): strin
|
||||
customCommandEnabled: settings.customCommandEnabled,
|
||||
perDownloadExtraArgs: opts.extraArgs,
|
||||
defaultTemplateId: settings.defaultTemplateId,
|
||||
templates: listTemplates()
|
||||
templates: listTemplates(),
|
||||
url: opts.url
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import { getFfmpegVersions } from './ffmpeg'
|
||||
import { checkForAppUpdate, downloadAppUpdate, runAppUpdate } from './updater'
|
||||
import { probeMedia } from './probe'
|
||||
import { startDownload, cancelDownload, pauseDownload, previewCommand } from './download'
|
||||
import { runTerminal, cancelTerminal } from './terminal'
|
||||
import { getSettings, setSettings, ensureMediaDirs } from './settings'
|
||||
import { listHistory, addHistory, removeHistory, removeManyHistory, clearHistory } from './history'
|
||||
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.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'))
|
||||
|
||||
|
||||
@@ -101,6 +101,7 @@ function sanitizeOptions(input: unknown): DownloadOptions {
|
||||
preferredVideoCodec: VIDEO_CODECS.includes(o.preferredVideoCodec as never)
|
||||
? o.preferredVideoCodec!
|
||||
: d.preferredVideoCodec,
|
||||
formatSort: typeof o.formatSort === 'string' ? o.formatSort.trim() : d.formatSort,
|
||||
embedSubtitles: bool(o.embedSubtitles, d.embedSubtitles),
|
||||
subtitleLanguages:
|
||||
typeof o.subtitleLanguages === 'string' && o.subtitleLanguages.trim()
|
||||
|
||||
@@ -36,10 +36,13 @@ function save(templates: CommandTemplate[]): void {
|
||||
}
|
||||
|
||||
function sanitize(t: CommandTemplate): CommandTemplate {
|
||||
const urlPattern = typeof t.urlPattern === 'string' ? t.urlPattern.trim() : ''
|
||||
return {
|
||||
id: String(t.id),
|
||||
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 } : {})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -28,7 +28,9 @@ import {
|
||||
type IndexProgress,
|
||||
type IndexSourceResult,
|
||||
type SyncResult,
|
||||
type ScheduledSyncStatus
|
||||
type ScheduledSyncStatus,
|
||||
type TerminalEvent,
|
||||
type TerminalRunResult
|
||||
} from '@shared/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)
|
||||
ipcRenderer.on(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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,10 @@ import { Sidebar, type TabValue } from './components/Sidebar'
|
||||
import { DownloadsView } from './components/DownloadsView'
|
||||
import { LibraryView } from './components/LibraryView'
|
||||
import { HistoryView } from './components/HistoryView'
|
||||
import { TerminalView } from './components/TerminalView'
|
||||
import { SettingsView } from './components/SettingsView'
|
||||
import { Onboarding } from './components/Onboarding'
|
||||
import { CommandPalette, type PaletteAction } from './components/CommandPalette'
|
||||
import { getTheme, pageBackground } from './theme'
|
||||
import { useSettings } from './store/settings'
|
||||
import { useResolvedDark } from './store/systemTheme'
|
||||
@@ -34,6 +36,43 @@ function App(): React.JSX.Element {
|
||||
const updateSettings = useSettings((s) => s.update)
|
||||
const isDark = useResolvedDark()
|
||||
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.
|
||||
const [version, setVersion] = useState('')
|
||||
@@ -89,8 +128,13 @@ function App(): React.JSX.Element {
|
||||
{tab === 'downloads' && <DownloadsView />}
|
||||
{tab === 'library' && <LibraryView />}
|
||||
{tab === 'history' && <HistoryView />}
|
||||
{tab === 'terminal' && <TerminalView />}
|
||||
{tab === 'settings' && <SettingsView />}
|
||||
</main>
|
||||
|
||||
{paletteOpen && (
|
||||
<CommandPalette actions={paletteActions} onClose={() => setPaletteOpen(false)} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -223,10 +223,35 @@ const useStyles = makeStyles({
|
||||
overflowY: 'auto',
|
||||
paddingRight: '4px'
|
||||
},
|
||||
plItemRow: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
},
|
||||
plItem: {
|
||||
display: 'flex',
|
||||
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: {
|
||||
display: 'flex',
|
||||
@@ -285,6 +310,7 @@ const useStyles = makeStyles({
|
||||
export function DownloadBar(): React.JSX.Element {
|
||||
const styles = useStyles()
|
||||
const addFromUrl = useDownloads((s) => s.addFromUrl)
|
||||
const addMany = useDownloads((s) => s.addMany)
|
||||
const settingsLoaded = useSettings((s) => s.loaded)
|
||||
const defaultKind = useSettings((s) => s.defaultKind)
|
||||
const defaultVideoQuality = useSettings((s) => s.defaultVideoQuality)
|
||||
@@ -354,6 +380,9 @@ export function DownloadBar(): React.JSX.Element {
|
||||
// Probe state for a playlist URL.
|
||||
const [playlist, setPlaylist] = useState<PlaylistInfo | null>(null)
|
||||
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
|
||||
// fresh link, offer it (without clobbering anything the user is already typing).
|
||||
@@ -424,6 +453,7 @@ export function DownloadBar(): React.JSX.Element {
|
||||
setFormatId('')
|
||||
setPlaylist(null)
|
||||
setSelected(new Set())
|
||||
setItemKinds({})
|
||||
}
|
||||
|
||||
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)))
|
||||
}
|
||||
|
||||
// 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 {
|
||||
const trimmed = url.trim()
|
||||
if (!trimmed) return
|
||||
@@ -553,13 +597,19 @@ export function DownloadBar(): React.JSX.Element {
|
||||
function addPlaylist(): void {
|
||||
if (!playlist) return
|
||||
const chosen = playlist.entries.filter((e) => selected.has(e.index))
|
||||
for (const e of chosen) {
|
||||
addFromUrl(e.url, kind, quality, {
|
||||
title: e.title,
|
||||
channel: e.uploader,
|
||||
durationLabel: e.durationLabel
|
||||
// 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.
|
||||
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 }
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
setUrl('')
|
||||
clearProbe()
|
||||
}
|
||||
@@ -574,6 +624,7 @@ export function DownloadBar(): React.JSX.Element {
|
||||
<div className={styles.urlRow}>
|
||||
<Input
|
||||
className={styles.url}
|
||||
input={{ id: 'aerofetch-url' }}
|
||||
value={url}
|
||||
onChange={(_, d) => onUrlChange(d.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && fetchFormats()}
|
||||
@@ -687,27 +738,50 @@ export function DownloadBar(): React.JSX.Element {
|
||||
<Button size="small" appearance="subtle" onClick={toggleAll}>
|
||||
{allSelected ? 'Select none' : 'Select all'}
|
||||
</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 className={styles.plList}>
|
||||
{playlist.entries.map((e) => (
|
||||
<Checkbox
|
||||
key={e.index}
|
||||
className={styles.plItem}
|
||||
checked={selected.has(e.index)}
|
||||
onChange={(_, d) => toggleEntry(e.index, !!d.checked)}
|
||||
label={
|
||||
<span className={styles.plItemLabel}>
|
||||
<span className={styles.plItemTitle}>
|
||||
{e.index}. {e.title}
|
||||
<div key={e.index} className={styles.plItemRow}>
|
||||
<Checkbox
|
||||
className={styles.plItem}
|
||||
checked={selected.has(e.index)}
|
||||
onChange={(_, d) => toggleEntry(e.index, !!d.checked)}
|
||||
label={
|
||||
<span className={styles.plItemLabel}>
|
||||
<span className={styles.plItemTitle}>
|
||||
{e.index}. {e.title}
|
||||
</span>
|
||||
{(e.durationLabel || e.uploader) && (
|
||||
<Caption1 className={styles.plItemMeta}>
|
||||
{[e.durationLabel, e.uploader].filter(Boolean).join(' • ')}
|
||||
</Caption1>
|
||||
)}
|
||||
</span>
|
||||
{(e.durationLabel || e.uploader) && (
|
||||
<Caption1 className={styles.plItemMeta}>
|
||||
{[e.durationLabel, e.uploader].filter(Boolean).join(' • ')}
|
||||
</Caption1>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Hint
|
||||
label={
|
||||
effKind(e.index) === 'audio' ? 'Audio — click for video' : 'Video — click for audio'
|
||||
}
|
||||
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>
|
||||
|
||||
@@ -133,6 +133,17 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
|
||||
</Field>
|
||||
</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.">
|
||||
<Switch
|
||||
checked={value.embedSubtitles}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
ArrowDownloadRegular,
|
||||
HistoryRegular,
|
||||
LibraryRegular,
|
||||
WindowConsoleRegular,
|
||||
SettingsRegular,
|
||||
WeatherMoonRegular,
|
||||
WeatherSunnyRegular,
|
||||
@@ -14,7 +15,7 @@ import {
|
||||
import type { ThemeMode } from '@shared/ipc'
|
||||
import { Hint } from './Hint'
|
||||
|
||||
export type TabValue = 'downloads' | 'library' | 'history' | 'settings'
|
||||
export type TabValue = 'downloads' | 'library' | 'history' | 'terminal' | 'settings'
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: {
|
||||
@@ -179,6 +180,7 @@ const NAV: { value: TabValue; label: string; icon: React.JSX.Element }[] = [
|
||||
{ value: 'downloads', label: 'Downloads', icon: <ArrowDownloadRegular /> },
|
||||
{ value: 'library', label: 'Library', icon: <LibraryRegular /> },
|
||||
{ value: 'history', label: 'History', icon: <HistoryRegular /> },
|
||||
{ value: 'terminal', label: 'Terminal', icon: <WindowConsoleRegular /> },
|
||||
{ value: 'settings', label: 'Settings', icon: <SettingsRegular /> }
|
||||
]
|
||||
|
||||
|
||||
@@ -74,8 +74,10 @@ interface Draft {
|
||||
id: string | null
|
||||
name: 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 {
|
||||
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)
|
||||
|
||||
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 {
|
||||
if (!draft) return
|
||||
const name = draft.name.trim()
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -154,6 +162,11 @@ export function TemplateManager(): React.JSX.Element {
|
||||
resize="vertical"
|
||||
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}>
|
||||
<Button appearance="subtle" icon={<DismissRegular />} onClick={() => setDraft(null)}>
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -220,7 +220,10 @@ if (import.meta.env.DEV && !window.api) {
|
||||
durationLabel: `${10 + i}:0${i}`,
|
||||
downloaded: i < 2
|
||||
})),
|
||||
onIndexProgress: () => () => {}
|
||||
onIndexProgress: () => () => {},
|
||||
runTerminal: async () => ({ ok: true }),
|
||||
cancelTerminal: async () => {},
|
||||
onTerminalOutput: () => () => {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+39
-1
@@ -71,7 +71,12 @@ export const IpcChannels = {
|
||||
scheduledSyncGet: 'sources:scheduled-sync-get',
|
||||
scheduledSyncSet: 'sources:scheduled-sync-set',
|
||||
/** 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
|
||||
|
||||
/** Sentinel format id meaning "let yt-dlp pick the best video+audio". */
|
||||
@@ -147,6 +152,13 @@ export interface DownloadOptions {
|
||||
videoContainer: VideoContainer
|
||||
/** preferred video codec (sort tiebreaker, not a hard filter) */
|
||||
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 */
|
||||
embedSubtitles: boolean
|
||||
/** 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',
|
||||
videoContainer: 'mp4',
|
||||
preferredVideoCodec: 'any',
|
||||
formatSort: '',
|
||||
embedSubtitles: false,
|
||||
subtitleLanguages: 'en',
|
||||
autoSubtitles: false,
|
||||
@@ -509,6 +522,13 @@ export interface CommandTemplate {
|
||||
id: string
|
||||
name: 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. */
|
||||
@@ -671,3 +691,21 @@ export interface ScheduledSyncStatus {
|
||||
enabled: boolean
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
it('emits --split-chapters when splitChapters is on', () => {
|
||||
expect(build(opts(), dlo({ splitChapters: true }))).toContain('--split-chapters')
|
||||
|
||||
Reference in New Issue
Block a user