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:
+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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user