Complete Phase C (custom commands & yt-dlp updater) and Phase D (library, UX & notifications)

Phase C had been implemented in the working tree but never committed:
named custom-command templates (CRUD + per-download override), command
preview, and the in-app yt-dlp self-updater.

Phase D adds: history search/kind-filter/multi-select/bulk-delete/
re-download; native OS notifications on completion/failure; a private/
incognito download mode that skips history; settings+template backup/
restore via JSON; and a persistent error log surfaced as a Diagnostics
report in Settings.

See ROADMAP.md for the full per-item breakdown.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 06:19:45 -04:00
parent 67133f13b5
commit a822a2bb52
24 changed files with 1678 additions and 129 deletions
+63 -15
View File
@@ -84,24 +84,72 @@ smoke test.*
The archive file lives at a fixed `userData/download-archive.txt`
(`getDownloadArchivePath()` in `src/main/settings.ts`), not user-configurable.
## Phase C — Custom commands & yt-dlp management
## Phase C — Custom commands & yt-dlp management ✅ COMPLETE
- [ ] **Custom command templates.** Named, editable templates of extra yt-dlp args; pick a
default; a "run custom command" mode. Needs persistence + a template editor view.
- [ ] **Command preview.** Show the exact yt-dlp command line before running (build the argv
and render it).
- [ ] **In-app yt-dlp updater** with stable/nightly channel (`--update-to`, or fetch the
release exe), surfaced next to the existing version check.
Named `CommandTemplate`s (`src/shared/ipc.ts`) persist to `templates.json`
(`src/main/templates.ts`, mirrors `history.ts`'s plain-JSON pattern) and are managed
inline in **Settings → Custom commands** via `TemplateManager.tsx` — no modal, matching
the rest of the app's no-portal-overlay convention (see the Hint/Select comments re:
this dev machine's GPU/driver). A template's extra yt-dlp flags are shell-split
(`parseExtraArgs`, `src/main/buildArgs.ts`) and appended to the generated argv
immediately before the `--` URL terminator, so a template flag can override anything
chosen earlier. `Settings.customCommandEnabled` + `defaultTemplateId` apply a template
to every new download; the download bar's own **Custom command** panel lets a single
download override that default or opt out entirely (`extraArgs` on
`StartDownloadOptions`, mirroring the existing per-download `options` override).
`buildCommand()` (`src/main/download.ts`) now centralises settings/access/
options/extraArgs resolution, shared by both the real `startDownload` spawn and the new
preview path. All items verified in the UI preview + `npm run typecheck` +
`npm run test` (11 new unit tests covering `parseExtraArgs`, `formatCommandLine`, and
extraArgs ordering). *Caveat: the extra-args splitting and command construction were
validated by reasoning + tests, not yet by a real yt-dlp run with actual custom flags —
worth a real-download smoke test pass, same as Phase A/B.*
## Phase D — Library, UX & notifications
- [x] **Custom command templates.** Named, editable templates of extra yt-dlp args
(`CommandTemplate`), CRUD'd inline via `TemplateManager.tsx`; persisted to
`templates.json`. **Pick a default** via `Settings.defaultTemplateId` (a Select in
the Custom commands card). The **"run custom command" mode** is
`Settings.customCommandEnabled`, applied to every new download unless overridden
per-download in the download bar's Custom command panel.
- [x] **Command preview.** `IpcChannels.commandPreview``previewCommand()`
(`src/main/download.ts`) builds the exact argv via the same `buildCommand()` path
a real download would take, then renders it with `formatCommandLine`. Surfaced as
a **Preview command** button in the download bar, with a Copy button.
- [x] **In-app yt-dlp updater.** `updateYtdlp(channel)` (`src/main/ytdlp.ts`) runs
yt-dlp's own `--update-to stable|nightly`. Surfaced in **Settings → About**, right
next to the existing version check, with a channel picker + result output.
- [ ] **History search + multi-select + filter** (video/audio) + bulk delete + **re-download**.
Schema already exists in `src/shared/ipc.ts`; needs UI + likely the planned move to
better-sqlite3.
- [ ] **Native OS notifications** on completion / failure (Electron `Notification`).
- [ ] **Private / incognito mode** — a download that skips history.
- [ ] **Backup / restore** settings + templates (export / import JSON).
- [ ] **Error log / copy error report** view (Seal's debug report).
## Phase D — Library, UX & notifications ✅ COMPLETE
History stays plain JSON (`history.json`, 500-entry cap) — search/filter/select are done
client-side over the already-small array, so the planned better-sqlite3 migration wasn't
needed for this phase and was deliberately skipped to avoid a native-module build step for
no functional gain at this scale. Failed downloads (pre-spawn rejections and in-flight
yt-dlp failures alike) now persist to a new `errorlog.json` (`src/main/errorlog.ts`,
200-entry cap) independent of the queue, so a report survives `Clear finished`. Native
notifications and the backup file both reuse existing settings/templates plumbing
(`getSettings`/`setSettings`, `listTemplates`/the new `replaceTemplates`) rather than adding
parallel storage. All items verified in the UI preview (typecheck + `npm run test` clean).
- [x] **History search + multi-select + filter** (video/audio) + bulk delete + **re-download**.
`HistoryView.tsx` gained a search box, an All/Video/Audio filter, a "Select" mode with
per-row checkboxes + select-all + **Delete selected** (new `historyRemoveMany` IPC,
`src/main/history.ts`), and a per-row re-download button that re-queues the entry's
URL/kind/quality via the existing `addFromUrl`.
- [x] **Native OS notifications** on completion / failure (Electron `Notification`,
`src/main/download.ts`). Gated by a new **Notify when downloads finish** switch
(`Settings.notifyOnComplete`, default on); clicking a notification refocuses the window.
- [x] **Private / incognito mode** — a download bar toggle (`DownloadBar.tsx`, sticky like an
incognito tab) sets `DownloadItem.incognito`, which `store/downloads.ts` checks before
ever calling `useHistory().add(...)`. Queue items show an eye-off badge when private.
- [x] **Backup / restore** settings + templates (export / import JSON). New
`src/main/backup.ts` writes/reads `{ settings, templates }` via a save/open dialog;
`replaceTemplates()` (`src/main/templates.ts`) does a full restore rather than a
merge-by-id. Surfaced as **Settings → Backup & restore**.
- [x] **Error log / copy error report** view (Seal's debug report). New
`src/main/errorlog.ts` persists every failure (pre-spawn rejection, spawn error, or
non-zero yt-dlp exit); **Settings → Diagnostics** lists recent entries with
**Copy full report** and **Clear log**.
## Phase E — Theming & platform polish
+57
View File
@@ -0,0 +1,57 @@
import { dialog, type BrowserWindow } from 'electron'
import { readFileSync, writeFileSync } from 'fs'
import { getSettings, setSettings } from './settings'
import { listTemplates, replaceTemplates } from './templates'
import type {
BackupExportResult,
BackupImportResult,
Settings,
CommandTemplate
} from '@shared/ipc'
interface BackupFile {
version: 1
settings: Settings
templates: CommandTemplate[]
}
export async function exportBackup(win: BrowserWindow | undefined): Promise<BackupExportResult> {
const res = await dialog.showSaveDialog(win!, {
defaultPath: 'aerofetch-backup.json',
filters: [{ name: 'JSON', extensions: ['json'] }]
})
if (res.canceled || !res.filePath) return { ok: false }
const payload: BackupFile = { version: 1, settings: getSettings(), templates: listTemplates() }
try {
writeFileSync(res.filePath, JSON.stringify(payload, null, 2))
return { ok: true, path: res.filePath }
} catch (e) {
return { ok: false, error: (e as Error).message }
}
}
export async function importBackup(win: BrowserWindow | undefined): Promise<BackupImportResult> {
const res = await dialog.showOpenDialog(win!, {
properties: ['openFile'],
filters: [{ name: 'JSON', extensions: ['json'] }]
})
if (res.canceled || !res.filePaths[0]) return { ok: false }
let parsed: unknown
try {
parsed = JSON.parse(readFileSync(res.filePaths[0], 'utf8'))
} catch (e) {
return { ok: false, error: `Could not read backup file: ${(e as Error).message}` }
}
if (!parsed || typeof parsed !== 'object') {
return { ok: false, error: 'Not a valid AeroFetch backup file.' }
}
// setSettings/replaceTemplates validate and sanitize every field, so a
// hand-edited or partial backup file degrades gracefully rather than
// corrupting the store.
const file = parsed as Partial<BackupFile>
if (file.settings && typeof file.settings === 'object') setSettings(file.settings)
if (Array.isArray(file.templates)) replaceTemplates(file.templates)
return { ok: true }
}
+36 -1
View File
@@ -103,6 +103,35 @@ export interface AccessOptions {
// each at least 1MB so tiny files don't get split pointlessly.
export const ARIA2C_ARGS = 'aria2c:-x 16 -s 16 -k 1M'
/**
* Shell-like split of a raw "extra yt-dlp args" string (Phase C custom-command
* templates) into argv tokens. Supports single- and double-quoted spans so a
* flag value containing spaces (e.g. a --ppa recipe) can be typed as one
* token; no escape-character or nested-quote support beyond that.
*/
export function parseExtraArgs(raw: string): string[] {
const args: string[] = []
const re = /"([^"]*)"|'([^']*)'|(\S+)/g
let m: RegExpExecArray | null
while ((m = re.exec(raw)) !== null) {
args.push(m[1] ?? m[2] ?? m[3])
}
return args
}
// Wrap a single argv token for human-readable display only (Phase C command
// preview) — never used to build the real argv that gets spawned.
function quoteForDisplay(arg: string): string {
if (arg === '') return '""'
if (/[\s"]/.test(arg)) return `"${arg.replace(/"/g, '\\"')}"`
return arg
}
/** Render a binary + argv as a single copy-pasteable command line. */
export function formatCommandLine(exe: string, args: string[]): string {
return [exe, ...args].map(quoteForDisplay).join(' ')
}
function accessArgs(a: AccessOptions): string[] {
const args: string[] = []
if (a.proxy.trim()) args.push('--proxy', a.proxy.trim())
@@ -152,7 +181,8 @@ export function buildArgs(
outputTemplate: string,
o: DownloadOptions,
binDir: string,
access: AccessOptions
access: AccessOptions,
extraArgs: string[] = []
): string[] {
const args = [
'--newline',
@@ -194,6 +224,11 @@ export function buildArgs(
if (o.embedThumbnail && o.videoContainer !== 'webm') args.push('--embed-thumbnail')
}
// Custom-command extra args go last, immediately before the `--` URL
// terminator, so a user-supplied flag can override anything chosen above
// (yt-dlp takes the last occurrence of most options).
if (extraArgs.length > 0) args.push(...extraArgs)
// `--` terminates option parsing so the URL can never be read as a flag.
args.push('--', opts.url)
return args
+99 -27
View File
@@ -1,19 +1,23 @@
import { spawn, execFile, type ChildProcess } from 'child_process'
import { existsSync } from 'fs'
import { join } from 'path'
import { app, type WebContents } from 'electron'
import { app, BrowserWindow, Notification, type WebContents } from 'electron'
import { getYtdlpPath, getBinDir, getAria2cPath } from './binaries'
import { getSettings, getDownloadArchivePath } from './settings'
import { getCookiesFilePath } from './cookies'
import { listTemplates } from './templates'
import { assertHttpUrl } from './url'
import { buildArgs } from './buildArgs'
import { buildArgs, parseExtraArgs, formatCommandLine } from './buildArgs'
import { addErrorLog } from './errorlog'
import {
IpcChannels,
type StartDownloadOptions,
type StartDownloadResult,
type CommandPreviewResult,
type DownloadEvent,
type DownloadMeta,
type DownloadProgress
type DownloadProgress,
type Settings
} from '@shared/ipc'
interface ActiveDownload {
@@ -87,6 +91,33 @@ function send(wc: WebContents, ev: DownloadEvent): void {
if (!wc.isDestroyed()) wc.send(IpcChannels.downloadEvent, ev)
}
/** Native OS notification on completion/failure, gated by Settings.notifyOnComplete. */
function notify(wc: WebContents, title: string, body: string): void {
if (!getSettings().notifyOnComplete || !Notification.isSupported()) return
const n = new Notification({ title, body })
n.on('click', () => {
if (wc.isDestroyed()) return
const win = BrowserWindow.fromWebContents(wc)
if (win) {
if (win.isMinimized()) win.restore()
win.show()
win.focus()
}
})
n.show()
}
function logFailure(opts: StartDownloadOptions, title: string | undefined, error: string): void {
addErrorLog({
id: opts.id,
title,
url: opts.url,
kind: opts.kind,
error,
occurredAt: Date.now()
})
}
// --- Best-effort metadata probe (runs alongside the download) ---------------
function probeMeta(ytdlp: string, url: string): Promise<DownloadMeta | null> {
@@ -122,29 +153,21 @@ function probeMeta(ytdlp: string, url: string): Promise<DownloadMeta | null> {
})
}
// --- Public API -------------------------------------------------------------
// --- Argv construction (shared by startDownload and the command preview) ---
export function startDownload(
wc: WebContents,
opts: StartDownloadOptions
): StartDownloadResult {
const ytdlp = getYtdlpPath()
if (!existsSync(ytdlp)) {
return {
ok: false,
error: `yt-dlp.exe not found at ${ytdlp}\nDrop it into resources/bin/ (see the README there).`
}
}
// Reject anything that isn't an http(s) URL before it reaches yt-dlp's argv.
try {
assertHttpUrl(opts.url)
} catch (e) {
return { ok: false, error: (e as Error).message }
}
if (active.has(opts.id)) {
return { ok: false, error: 'A download with this id is already running.' }
// A per-download override (opts.extraArgs, even '') always wins; otherwise
// fall back to the persisted default template when custom-command mode is on.
function resolveExtraArgs(opts: StartDownloadOptions, settings: Settings): string[] {
if (opts.extraArgs !== undefined) return parseExtraArgs(opts.extraArgs)
if (settings.customCommandEnabled && settings.defaultTemplateId) {
const tpl = listTemplates().find((t) => t.id === settings.defaultTemplateId)
if (tpl) return parseExtraArgs(tpl.args)
}
return []
}
/** Resolve settings + per-download overrides into the full yt-dlp argv. */
export function buildCommand(opts: StartDownloadOptions): string[] {
const settings = getSettings()
const outDir = opts.outputDir?.trim() || settings.outputDir || app.getPath('downloads')
const template = settings.filenameTemplate?.trim() || '%(title)s.%(ext)s'
@@ -168,12 +191,50 @@ export function startDownload(
restrictFilenames: settings.restrictFilenames,
downloadArchivePath: settings.downloadArchive ? getDownloadArchivePath() : undefined
}
const extraArgs = resolveExtraArgs(opts, settings)
return buildArgs(opts, join(outDir, template), options, getBinDir(), access, extraArgs)
}
/** Build the exact command line for the current form state, without running it. */
export function previewCommand(opts: StartDownloadOptions): CommandPreviewResult {
try {
assertHttpUrl(opts.url)
} catch (e) {
return { ok: false, error: (e as Error).message }
}
try {
return { ok: true, command: formatCommandLine(getYtdlpPath(), buildCommand(opts)) }
} catch (e) {
return { ok: false, error: (e as Error).message }
}
}
// --- Public API -------------------------------------------------------------
export function startDownload(
wc: WebContents,
opts: StartDownloadOptions
): StartDownloadResult {
const ytdlp = getYtdlpPath()
if (!existsSync(ytdlp)) {
return {
ok: false,
error: `yt-dlp.exe not found at ${ytdlp}\nDrop it into resources/bin/ (see the README there).`
}
}
// Reject anything that isn't an http(s) URL before it reaches yt-dlp's argv.
try {
assertHttpUrl(opts.url)
} catch (e) {
return { ok: false, error: (e as Error).message }
}
if (active.has(opts.id)) {
return { ok: false, error: 'A download with this id is already running.' }
}
let child: ChildProcess
try {
child = spawn(ytdlp, buildArgs(opts, join(outDir, template), options, getBinDir(), access), {
windowsHide: true
})
child = spawn(ytdlp, buildCommand(opts), { windowsHide: true })
} catch (e) {
return { ok: false, error: (e as Error).message }
}
@@ -182,7 +243,11 @@ export function startDownload(
active.set(opts.id, rec)
// Fetch title/channel/duration in parallel so the card fills in quickly.
// Also kept locally so the completion notification/error log can show a
// real title instead of just the raw URL.
let resolvedTitle: string | undefined
probeMeta(ytdlp, opts.url).then((meta) => {
if (meta?.title) resolvedTitle = meta.title
if (meta && active.has(opts.id)) send(wc, { type: 'meta', id: opts.id, meta })
})
@@ -215,7 +280,11 @@ export function startDownload(
if (settled) return
settled = true
active.delete(opts.id)
if (!rec.canceled) send(wc, { type: 'error', id: opts.id, error: err.message })
if (!rec.canceled) {
send(wc, { type: 'error', id: opts.id, error: err.message })
logFailure(opts, resolvedTitle, err.message)
notify(wc, resolvedTitle ?? 'Download failed', err.message)
}
})
child.on('close', (code) => {
@@ -225,9 +294,12 @@ export function startDownload(
if (rec.canceled) return // renderer already showed 'canceled' optimistically
if (code === 0) {
send(wc, { type: 'done', id: opts.id, filePath })
notify(wc, resolvedTitle ?? 'Download complete', 'Finished downloading.')
} else {
const msg = cleanError(stderrTail) || `yt-dlp exited with code ${code}`
send(wc, { type: 'error', id: opts.id, error: msg })
logFailure(opts, resolvedTitle, msg)
notify(wc, resolvedTitle ?? 'Download failed', msg)
}
})
+41
View File
@@ -0,0 +1,41 @@
import { app } from 'electron'
import { join } from 'path'
import { readFileSync, writeFileSync, existsSync } from 'fs'
import type { ErrorLogEntry } from '@shared/ipc'
// Plain JSON in userData, same shape as history.ts. Persisted so a failure
// report survives the queue item being cleared (Seal's "debug report").
const MAX_ENTRIES = 200
function errorLogFile(): string {
return join(app.getPath('userData'), 'errorlog.json')
}
export function listErrorLog(): ErrorLogEntry[] {
try {
if (!existsSync(errorLogFile())) return []
const data = JSON.parse(readFileSync(errorLogFile(), 'utf8'))
return Array.isArray(data) ? (data as ErrorLogEntry[]) : []
} catch {
return []
}
}
function save(entries: ErrorLogEntry[]): void {
try {
writeFileSync(errorLogFile(), JSON.stringify(entries.slice(0, MAX_ENTRIES), null, 2))
} catch {
/* best-effort; a read-only data dir just means no persisted error log */
}
}
export function addErrorLog(entry: ErrorLogEntry): ErrorLogEntry[] {
const entries = [entry, ...listErrorLog()]
save(entries)
return entries
}
export function clearErrorLog(): ErrorLogEntry[] {
save([])
return []
}
+7
View File
@@ -42,6 +42,13 @@ export function removeHistory(id: string): HistoryEntry[] {
return entries
}
export function removeManyHistory(ids: string[]): HistoryEntry[] {
const remove = new Set(ids)
const entries = listHistory().filter((e) => !remove.has(e.id))
save(entries)
return entries
}
export function clearHistory(): HistoryEntry[] {
save([])
return []
+50 -7
View File
@@ -5,16 +5,21 @@ import {
IpcChannels,
type StartDownloadOptions,
type Settings,
type HistoryEntry
type HistoryEntry,
type CommandTemplate,
type YtdlpUpdateChannel
} from '@shared/ipc'
import { getYtdlpVersion } from './ytdlp'
import { getYtdlpVersion, updateYtdlp } from './ytdlp'
import { probeMedia } from './probe'
import { startDownload, cancelDownload } from './download'
import { startDownload, cancelDownload, previewCommand } from './download'
import { getSettings, setSettings } from './settings'
import { listHistory, addHistory, removeHistory, clearHistory } from './history'
import { listHistory, addHistory, removeHistory, removeManyHistory, clearHistory } from './history'
import { listTemplates, saveTemplate, removeTemplate } from './templates'
import { setupPortableData } from './portable'
import { safeOpenPath, safeShowInFolder } from './reveal'
import { openCookieLoginWindow, getCookiesStatus, clearCookies } from './cookies'
import { listErrorLog, addErrorLog, clearErrorLog } from './errorlog'
import { exportBackup, importBackup } from './backup'
// Force software compositing (GPU acceleration off). On this dev machine's old GeForce
// GT 625 — and plausibly on the old / locked-down public PCs this app targets —
@@ -86,9 +91,21 @@ function registerIpcHandlers(): void {
ipcMain.handle(IpcChannels.probe, (_e, url: string) => probeMedia(url))
ipcMain.handle(IpcChannels.downloadStart, (e, opts: StartDownloadOptions) =>
startDownload(e.sender, opts)
)
ipcMain.handle(IpcChannels.downloadStart, (e, opts: StartDownloadOptions) => {
const result = startDownload(e.sender, opts)
// Pre-spawn failures (missing yt-dlp.exe, bad URL, duplicate id) never reach
// download.ts's own close/error handlers, so log them here instead.
if (!result.ok) {
addErrorLog({
id: opts.id,
url: opts.url,
kind: opts.kind,
error: result.error ?? 'Unknown error',
occurredAt: Date.now()
})
}
return result
})
ipcMain.handle(IpcChannels.downloadCancel, (_e, id: string) => cancelDownload(id))
ipcMain.handle(IpcChannels.defaultFolder, () => app.getPath('downloads'))
@@ -121,11 +138,37 @@ function registerIpcHandlers(): void {
ipcMain.handle(IpcChannels.historyList, () => listHistory())
ipcMain.handle(IpcChannels.historyAdd, (_e, entry: HistoryEntry) => addHistory(entry))
ipcMain.handle(IpcChannels.historyRemove, (_e, id: string) => removeHistory(id))
ipcMain.handle(IpcChannels.historyRemoveMany, (_e, ids: string[]) => removeManyHistory(ids))
ipcMain.handle(IpcChannels.historyClear, () => clearHistory())
ipcMain.handle(IpcChannels.cookiesLogin, (_e, url: string) => openCookieLoginWindow(url))
ipcMain.handle(IpcChannels.cookiesStatus, () => getCookiesStatus())
ipcMain.handle(IpcChannels.cookiesClear, () => clearCookies())
ipcMain.handle(IpcChannels.templatesList, () => listTemplates())
ipcMain.handle(IpcChannels.templatesSave, (_e, template: CommandTemplate) => saveTemplate(template))
ipcMain.handle(IpcChannels.templatesRemove, (_e, id: string) => removeTemplate(id))
ipcMain.handle(IpcChannels.commandPreview, (_e, opts: StartDownloadOptions) =>
previewCommand(opts)
)
ipcMain.handle(IpcChannels.ytdlpUpdate, (_e, channel: YtdlpUpdateChannel) => updateYtdlp(channel))
ipcMain.handle(IpcChannels.errorLogList, () => listErrorLog())
ipcMain.handle(IpcChannels.errorLogClear, () => clearErrorLog())
ipcMain.handle(IpcChannels.backupExport, (e) =>
exportBackup(BrowserWindow.fromWebContents(e.sender) ?? undefined)
)
ipcMain.handle(IpcChannels.backupImport, async (e) => {
const win = BrowserWindow.fromWebContents(e.sender) ?? undefined
const result = await importBackup(win)
// A restored backup may have changed the theme; keep the native window
// background in sync the same way settingsSet does.
if (result.ok) win?.setBackgroundColor(THEME_BACKGROUND[getSettings().theme])
return result
})
}
app.whenReady().then(() => {
+9 -1
View File
@@ -29,7 +29,10 @@ const DEFAULTS: Settings = {
cookieSource: 'none',
cookiesBrowser: 'chrome',
restrictFilenames: false,
downloadArchive: false
downloadArchive: false,
customCommandEnabled: false,
defaultTemplateId: null,
notifyOnComplete: true
}
/** Fixed path for the --download-archive file; not user-configurable. */
@@ -115,8 +118,13 @@ export function setSettings(partial: Partial<Settings>): Settings {
case 'useAria2c':
case 'restrictFilenames':
case 'downloadArchive':
case 'customCommandEnabled':
case 'notifyOnComplete':
if (typeof value === 'boolean') s.set(key, value)
break
case 'defaultTemplateId':
if (value === null || typeof value === 'string') s.set('defaultTemplateId', value)
break
case 'cookieSource':
if (value === 'none' || value === 'browser' || value === 'login') s.set(key, value)
break
+58
View File
@@ -0,0 +1,58 @@
import { app } from 'electron'
import { join } from 'path'
import { readFileSync, writeFileSync, existsSync } from 'fs'
import type { CommandTemplate } from '@shared/ipc'
// Plain JSON in userData, same shape as history.ts.
const MAX_TEMPLATES = 100
function templatesFile(): string {
return join(app.getPath('userData'), 'templates.json')
}
export function listTemplates(): CommandTemplate[] {
try {
if (!existsSync(templatesFile())) return []
const data = JSON.parse(readFileSync(templatesFile(), 'utf8'))
return Array.isArray(data) ? (data as CommandTemplate[]) : []
} catch {
return []
}
}
function save(templates: CommandTemplate[]): void {
try {
writeFileSync(templatesFile(), JSON.stringify(templates.slice(0, MAX_TEMPLATES), null, 2))
} catch {
/* best-effort; a read-only data dir just means no persisted templates */
}
}
function sanitize(t: CommandTemplate): CommandTemplate {
return {
id: String(t.id),
name: (t.name ?? '').trim() || 'Untitled command',
args: (t.args ?? '').trim()
}
}
/** Add a new template, or update an existing one (matched by id). */
export function saveTemplate(template: CommandTemplate): CommandTemplate[] {
const clean = sanitize(template)
const templates = [clean, ...listTemplates().filter((t) => t.id !== clean.id)]
save(templates)
return templates
}
export function removeTemplate(id: string): CommandTemplate[] {
const templates = listTemplates().filter((t) => t.id !== id)
save(templates)
return templates
}
/** Replace the entire template list (backup restore) rather than merge-by-id. */
export function replaceTemplates(templates: CommandTemplate[]): CommandTemplate[] {
const clean = templates.map(sanitize)
save(clean)
return clean
}
+36 -1
View File
@@ -1,7 +1,7 @@
import { execFile } from 'child_process'
import { existsSync } from 'fs'
import { getYtdlpPath } from './binaries'
import type { YtdlpVersionResult } from '@shared/ipc'
import type { YtdlpVersionResult, YtdlpUpdateChannel, YtdlpUpdateResult } from '@shared/ipc'
/**
* Step-1 spike: spawn the bundled yt-dlp and read back `--version`.
@@ -30,3 +30,38 @@ export function getYtdlpVersion(): Promise<YtdlpVersionResult> {
})
})
}
/**
* Self-update the bundled yt-dlp.exe via its built-in `--update-to <channel>`
* (stable releases or the nightly build). Requires write access to the file
* yt-dlp.exe lives in, which holds for both the dev resources/bin checkout and
* a per-user install; it would fail under a locked-down system install.
*/
export function updateYtdlp(channel: YtdlpUpdateChannel): Promise<YtdlpUpdateResult> {
const ytdlpPath = getYtdlpPath()
if (!existsSync(ytdlpPath)) {
return Promise.resolve({
ok: false,
error: `yt-dlp.exe not found at ${ytdlpPath}\nDownload it into resources/bin/ (see the README there).`
})
}
return new Promise((resolve) => {
execFile(
ytdlpPath,
['--update-to', channel],
{ windowsHide: true, timeout: 60_000 },
(err, stdout, stderr) => {
if (err) {
const msg = (err as { killed?: boolean }).killed
? 'Timed out updating yt-dlp.'
: (stderr || err.message).trim()
resolve({ ok: false, error: msg })
return
}
resolve({ ok: true, output: stdout.trim() })
}
)
})
}
+36 -1
View File
@@ -2,6 +2,8 @@ import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron'
import {
IpcChannels,
type YtdlpVersionResult,
type YtdlpUpdateChannel,
type YtdlpUpdateResult,
type ProbeResult,
type StartDownloadOptions,
type StartDownloadResult,
@@ -9,7 +11,12 @@ import {
type Settings,
type HistoryEntry,
type CookiesStatus,
type CookiesLoginResult
type CookiesLoginResult,
type CommandTemplate,
type CommandPreviewResult,
type ErrorLogEntry,
type BackupExportResult,
type BackupImportResult
} from '@shared/ipc'
// The surface exposed to the renderer. Keep this thin: it only forwards to IPC.
@@ -49,6 +56,9 @@ const api = {
removeHistory: (id: string): Promise<HistoryEntry[]> =>
ipcRenderer.invoke(IpcChannels.historyRemove, id),
removeManyHistory: (ids: string[]): Promise<HistoryEntry[]> =>
ipcRenderer.invoke(IpcChannels.historyRemoveMany, ids),
clearHistory: (): Promise<HistoryEntry[]> => ipcRenderer.invoke(IpcChannels.historyClear),
/** Open the built-in sign-in window; resolves once the user closes it. */
@@ -59,6 +69,31 @@ const api = {
cookiesClear: (): Promise<void> => ipcRenderer.invoke(IpcChannels.cookiesClear),
listTemplates: (): Promise<CommandTemplate[]> => ipcRenderer.invoke(IpcChannels.templatesList),
saveTemplate: (template: CommandTemplate): Promise<CommandTemplate[]> =>
ipcRenderer.invoke(IpcChannels.templatesSave, template),
removeTemplate: (id: string): Promise<CommandTemplate[]> =>
ipcRenderer.invoke(IpcChannels.templatesRemove, id),
/** Build the exact yt-dlp command line for the given form state, without running it. */
previewCommand: (opts: StartDownloadOptions): Promise<CommandPreviewResult> =>
ipcRenderer.invoke(IpcChannels.commandPreview, opts),
updateYtdlp: (channel: YtdlpUpdateChannel): Promise<YtdlpUpdateResult> =>
ipcRenderer.invoke(IpcChannels.ytdlpUpdate, channel),
listErrorLog: (): Promise<ErrorLogEntry[]> => ipcRenderer.invoke(IpcChannels.errorLogList),
clearErrorLog: (): Promise<ErrorLogEntry[]> => ipcRenderer.invoke(IpcChannels.errorLogClear),
/** Opens a save dialog and writes settings + templates to the chosen JSON file. */
exportBackup: (): Promise<BackupExportResult> => ipcRenderer.invoke(IpcChannels.backupExport),
/** Opens an open dialog and restores settings + templates from the chosen JSON file. */
importBackup: (): Promise<BackupImportResult> => ipcRenderer.invoke(IpcChannels.backupImport),
/** Subscribe to live download events. Returns an unsubscribe function. */
onDownloadEvent: (cb: (ev: DownloadEvent) => void): (() => void) => {
const listener = (_e: IpcRendererEvent, ev: DownloadEvent): void => cb(ev)
+212 -5
View File
@@ -3,6 +3,8 @@ import {
Input,
Button,
Checkbox,
Switch,
Field,
Spinner,
Text,
Caption1,
@@ -24,11 +26,23 @@ import {
OptionsRegular,
ChevronDownRegular,
ChevronUpRegular,
AppsListRegular
AppsListRegular,
CodeRegular,
EyeRegular,
EyeOffRegular,
CopyRegular
} from '@fluentui/react-icons'
import type { MediaInfo, FormatOption, PlaylistInfo, DownloadOptions } from '@shared/ipc'
import type {
MediaInfo,
FormatOption,
PlaylistInfo,
DownloadOptions,
CommandPreviewResult,
StartDownloadOptions
} from '@shared/ipc'
import { useDownloads, QUALITY_OPTIONS, type MediaKind } from '../store/downloads'
import { useSettings } from '../store/settings'
import { useTemplates } from '../store/templates'
import { Select } from './Select'
import { Hint } from './Hint'
import { DownloadOptionsForm } from './DownloadOptionsForm'
@@ -182,6 +196,21 @@ const useStyles = makeStyles({
...shorthands.borderRadius(tokens.borderRadiusLarge),
border: `1px solid ${tokens.colorNeutralStroke2}`
},
// --- command preview ---
previewPanel: {
display: 'flex',
flexDirection: 'column',
gap: '8px',
padding: '14px',
backgroundColor: tokens.colorNeutralBackground2,
...shorthands.borderRadius(tokens.borderRadiusLarge),
border: `1px solid ${tokens.colorNeutralStroke2}`
},
previewCommandText: {
fontFamily: tokens.fontFamilyMonospace,
whiteSpace: 'pre-wrap',
wordBreak: 'break-all'
},
// --- playlist selection ---
plPanel: {
display: 'flex',
@@ -264,6 +293,28 @@ export function DownloadBar(): React.JSX.Element {
const [showOptions, setShowOptions] = useState(false)
const effectiveOptions = override ?? downloadOptions
// Private mode — sticky like an incognito tab; the download still runs and
// appears in the queue, but its completion is never recorded to history.
const [incognito, setIncognito] = useState(false)
// Per-download custom-command override: undefined = defer to the settings
// default (customCommandEnabled + defaultTemplateId); null = explicit
// "None" for just this download; a string = a chosen template id override.
const [templateOverride, setTemplateOverride] = useState<string | null | undefined>(undefined)
const [showCustomCommand, setShowCustomCommand] = useState(false)
const customCommandEnabled = useSettings((s) => s.customCommandEnabled)
const settingsDefaultTemplateId = useSettings((s) => s.defaultTemplateId)
const templates = useTemplates((s) => s.templates)
const effectiveTemplateId =
templateOverride !== undefined
? templateOverride
: customCommandEnabled
? settingsDefaultTemplateId
: null
const [previewResult, setPreviewResult] = useState<CommandPreviewResult | null>(null)
const [previewing, setPreviewing] = useState(false)
// Apply the saved default format once, when persisted settings first arrive.
const appliedDefaults = useRef(false)
useEffect(() => {
@@ -400,6 +451,61 @@ export function DownloadBar(): React.JSX.Element {
setSelected(allSelected ? new Set() : new Set(playlist.entries.map((e) => e.index)))
}
// Resolves the per-download custom-command override to the raw extra-args
// string sent to main; undefined defers to the settings default there.
function resolveExtraArgs(): string | undefined {
if (templateOverride === undefined) return undefined
if (templateOverride === null) return ''
return templates.find((t) => t.id === templateOverride)?.args ?? ''
}
// The StartDownloadOptions the current form state would produce — shared by
// the real download() call and the command-preview button so they can never
// drift apart.
function currentStartOptions(): StartDownloadOptions {
const base: StartDownloadOptions = {
id: 'preview',
url: url.trim(),
kind,
quality,
outputDir: outputDir || undefined,
options: override ?? undefined,
extraArgs: resolveExtraArgs()
}
if (usingFormats && selectedFormat) {
return {
...base,
kind: 'video',
quality: selectedFormat.label,
formatId: selectedFormat.id,
formatHasAudio: selectedFormat.hasAudio
}
}
return base
}
async function previewCommand(): Promise<void> {
if (!url.trim() || previewing) return
setPreviewing(true)
setPreviewResult(null)
try {
setPreviewResult(await window.api.previewCommand(currentStartOptions()))
} catch (e) {
setPreviewResult({ ok: false, error: e instanceof Error ? e.message : String(e) })
} finally {
setPreviewing(false)
}
}
async function copyPreview(): Promise<void> {
if (!previewResult?.command) return
try {
await navigator.clipboard.writeText(previewResult.command)
} catch {
/* clipboard blocked — ignore in preview */
}
}
function download(): void {
const trimmed = url.trim()
if (!trimmed) return
@@ -421,10 +527,17 @@ export function DownloadBar(): React.JSX.Element {
hasAudio: selectedFormat.hasAudio,
label: selectedFormat.label
},
options: override ?? undefined
options: override ?? undefined,
extraArgs: resolveExtraArgs(),
incognito
})
} else {
addFromUrl(trimmed, kind, quality, { ...meta, options: override ?? undefined })
addFromUrl(trimmed, kind, quality, {
...meta,
options: override ?? undefined,
extraArgs: resolveExtraArgs(),
incognito
})
}
setUrl('')
@@ -439,7 +552,9 @@ export function DownloadBar(): React.JSX.Element {
title: e.title,
channel: e.uploader,
durationLabel: e.durationLabel,
options: override ?? undefined
options: override ?? undefined,
extraArgs: resolveExtraArgs(),
incognito
})
}
setUrl('')
@@ -635,6 +750,15 @@ export function DownloadBar(): React.JSX.Element {
)}
</div>
<div className={styles.optionsBar}>
{incognito ? <EyeOffRegular /> : <EyeRegular />}
<Switch
checked={incognito}
onChange={(_, d) => setIncognito(d.checked)}
label={incognito ? 'Private — wont be saved to history' : 'Private mode'}
/>
</div>
<div className={styles.optionsBar}>
<Button
appearance="subtle"
@@ -661,6 +785,89 @@ export function DownloadBar(): React.JSX.Element {
</div>
)}
<div className={styles.optionsBar}>
<Button
appearance="subtle"
size="small"
icon={<CodeRegular />}
iconPosition="before"
onClick={() => setShowCustomCommand((v) => !v)}
>
Custom command {showCustomCommand ? <ChevronUpRegular /> : <ChevronDownRegular />}
</Button>
{templateOverride !== undefined && (
<>
<Caption1 className={styles.previewMeta}>Customised for this download</Caption1>
<Button appearance="subtle" size="small" onClick={() => setTemplateOverride(undefined)}>
Reset to default
</Button>
</>
)}
<div className={styles.spacer} />
<Hint label="Build and show the exact yt-dlp command line" placement="top" align="end">
<Button
appearance="subtle"
size="small"
icon={previewing ? <Spinner size="tiny" /> : <EyeRegular />}
iconPosition="before"
onClick={previewCommand}
disabled={!url.trim() || previewing}
>
Preview command
</Button>
</Hint>
</div>
{showCustomCommand && (
<div className={styles.optionsPanel}>
<Field label="Template" hint="Extra yt-dlp flags layered onto this download, after every other option.">
<Select
aria-label="Custom command template"
value={effectiveTemplateId ?? 'none'}
options={[
{ value: 'none', label: 'None' },
...templates.map((t) => ({ value: t.id, label: t.name }))
]}
onChange={(v) => setTemplateOverride(v === 'none' ? null : v)}
/>
</Field>
{templates.length === 0 && (
<Caption1 className={styles.previewMeta}>
No templates yet add one in Settings Custom commands.
</Caption1>
)}
</div>
)}
{previewResult && (
<div className={styles.previewPanel}>
<div className={styles.plHeader}>
<Caption1 className={styles.plHeaderText}>
{previewResult.ok ? 'Command preview' : 'Could not build the command'}
</Caption1>
{previewResult.ok && (
<Button size="small" icon={<CopyRegular />} onClick={copyPreview}>
Copy
</Button>
)}
<Button
size="small"
appearance="subtle"
icon={<DismissRegular />}
onClick={() => setPreviewResult(null)}
aria-label="Dismiss preview"
/>
</div>
{previewResult.ok ? (
<Text className={styles.previewCommandText}>{previewResult.command}</Text>
) : (
<Caption1 className={mergeClasses(styles.previewMeta, styles.errorRow)}>
{previewResult.error}
</Caption1>
)}
</div>
)}
<div className={styles.folder}>
<FolderRegular />
<Caption1 className={styles.folderPath} title={folder}>
+207 -56
View File
@@ -1,7 +1,10 @@
import { useMemo, useState } from 'react'
import {
Text,
Caption1,
Button,
Checkbox,
Input,
Body1,
makeStyles,
tokens,
@@ -13,13 +16,19 @@ import {
DeleteRegular,
VideoClipRegular,
MusicNote2Regular,
HistoryRegular
HistoryRegular,
SearchRegular,
ArrowClockwiseRegular,
CheckmarkSquareRegular,
DismissRegular
} from '@fluentui/react-icons'
import type { HistoryEntry } from '@shared/ipc'
import type { HistoryEntry, MediaKind } from '@shared/ipc'
import { useHistory } from '../store/history'
import { useSettings } from '../store/settings'
import { useDownloads } from '../store/downloads'
import { thumbColors } from '../theme'
import { Hint } from './Hint'
import { Select } from './Select'
const useStyles = makeStyles({
root: {
@@ -30,10 +39,24 @@ const useStyles = makeStyles({
header: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
gap: '8px',
flexWrap: 'wrap'
},
count: {
color: tokens.colorNeutralForeground3
color: tokens.colorNeutralForeground3,
flexShrink: 0
},
search: {
minWidth: '180px',
flexGrow: 1,
maxWidth: '320px'
},
kindFilter: {
width: '130px',
flexShrink: 0
},
spacer: {
flexGrow: 1
},
list: {
display: 'flex',
@@ -97,9 +120,20 @@ const useStyles = makeStyles({
},
emptyHint: {
color: tokens.colorNeutralForeground3
},
noMatches: {
padding: '32px 16px',
textAlign: 'center',
color: tokens.colorNeutralForeground3
}
})
const KIND_FILTER_OPTIONS = [
{ value: 'all', label: 'All' },
{ value: 'video', label: 'Video' },
{ value: 'audio', label: 'Audio' }
]
function formatWhen(ts: number): string {
const d = new Date(ts)
const time = d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
@@ -119,7 +153,55 @@ export function HistoryView(): React.JSX.Element {
const openFile = useHistory((s) => s.openFile)
const showInFolder = useHistory((s) => s.showInFolder)
const remove = useHistory((s) => s.remove)
const removeMany = useHistory((s) => s.removeMany)
const clear = useHistory((s) => s.clear)
const addFromUrl = useDownloads((s) => s.addFromUrl)
const [query, setQuery] = useState('')
const [kindFilter, setKindFilter] = useState<'all' | MediaKind>('all')
const [selectMode, setSelectMode] = useState(false)
const [selected, setSelected] = useState<Set<string>>(new Set())
const filtered = useMemo(() => {
const q = query.trim().toLowerCase()
return entries.filter((h) => {
if (kindFilter !== 'all' && h.kind !== kindFilter) return false
if (!q) return true
return (
h.title.toLowerCase().includes(q) ||
(h.channel?.toLowerCase().includes(q) ?? false) ||
h.url.toLowerCase().includes(q)
)
})
}, [entries, query, kindFilter])
function redownload(h: HistoryEntry): void {
addFromUrl(h.url, h.kind, h.quality, { title: h.title, channel: h.channel })
}
function toggleSelected(id: string, on: boolean): void {
setSelected((prev) => {
const next = new Set(prev)
if (on) next.add(id)
else next.delete(id)
return next
})
}
const allFilteredSelected = filtered.length > 0 && filtered.every((h) => selected.has(h.id))
function toggleAll(): void {
setSelected(allFilteredSelected ? new Set() : new Set(filtered.map((h) => h.id)))
}
function exitSelectMode(): void {
setSelectMode(false)
setSelected(new Set())
}
function deleteSelected(): void {
removeMany([...selected])
exitSelectMode()
}
if (entries.length === 0) {
return (
@@ -139,62 +221,131 @@ export function HistoryView(): React.JSX.Element {
return (
<div className={styles.root}>
<div className={styles.header}>
<Caption1 className={styles.count}>
{entries.length} {entries.length === 1 ? 'download' : 'downloads'}
</Caption1>
<Button size="small" appearance="subtle" icon={<DeleteRegular />} onClick={clear}>
Clear history
</Button>
{selectMode ? (
<>
<Caption1 className={styles.count}>{selected.size} selected</Caption1>
<Button size="small" appearance="subtle" onClick={toggleAll}>
{allFilteredSelected ? 'Select none' : 'Select all'}
</Button>
<div className={styles.spacer} />
<Button
size="small"
appearance="primary"
icon={<DeleteRegular />}
onClick={deleteSelected}
disabled={selected.size === 0}
>
Delete selected
</Button>
<Button size="small" appearance="subtle" icon={<DismissRegular />} onClick={exitSelectMode}>
Cancel
</Button>
</>
) : (
<>
<Caption1 className={styles.count}>
{entries.length} {entries.length === 1 ? 'download' : 'downloads'}
</Caption1>
<Input
className={styles.search}
size="small"
value={query}
onChange={(_, d) => setQuery(d.value)}
placeholder="Search title, channel, URL…"
contentBefore={<SearchRegular />}
/>
<Select
className={styles.kindFilter}
aria-label="Filter by type"
value={kindFilter}
options={KIND_FILTER_OPTIONS}
onChange={(v) => setKindFilter(v as 'all' | MediaKind)}
/>
<div className={styles.spacer} />
<Button
size="small"
appearance="subtle"
icon={<CheckmarkSquareRegular />}
onClick={() => setSelectMode(true)}
>
Select
</Button>
<Button size="small" appearance="subtle" icon={<DeleteRegular />} onClick={clear}>
Clear history
</Button>
</>
)}
</div>
<div className={styles.list}>
{entries.map((h: HistoryEntry) => {
const t = h.kind === 'audio' ? tc.audio : tc.video
return (
<div key={h.id} className={styles.row}>
<div className={styles.thumb} style={{ backgroundColor: t.bg, color: t.fg }}>
{h.kind === 'audio' ? (
<MusicNote2Regular fontSize={22} />
) : (
<VideoClipRegular fontSize={22} />
{filtered.length === 0 ? (
<Caption1 className={styles.noMatches}>No downloads match your search.</Caption1>
) : (
<div className={styles.list}>
{filtered.map((h: HistoryEntry) => {
const t = h.kind === 'audio' ? tc.audio : tc.video
return (
<div key={h.id} className={styles.row}>
{selectMode && (
<Checkbox
checked={selected.has(h.id)}
onChange={(_, d) => toggleSelected(h.id, !!d.checked)}
aria-label={`Select ${h.title}`}
/>
)}
<div className={styles.thumb} style={{ backgroundColor: t.bg, color: t.fg }}>
{h.kind === 'audio' ? (
<MusicNote2Regular fontSize={22} />
) : (
<VideoClipRegular fontSize={22} />
)}
</div>
<div className={styles.body}>
<Text className={styles.title}>{h.title}</Text>
<Caption1 className={styles.meta}>
{[h.quality, h.sizeLabel, formatWhen(h.completedAt)]
.filter(Boolean)
.join(' • ')}
</Caption1>
</div>
<div className={styles.actions}>
<Hint label="Re-download" placement="top" align="end">
<Button
appearance="subtle"
icon={<ArrowClockwiseRegular />}
onClick={() => redownload(h)}
aria-label="Re-download"
/>
</Hint>
<Hint label="Open file" placement="top" align="end">
<Button
appearance="subtle"
icon={<OpenRegular />}
onClick={() => openFile(h.id)}
aria-label="Open file"
/>
</Hint>
<Hint label="Show in folder" placement="top" align="end">
<Button
appearance="subtle"
icon={<FolderRegular />}
onClick={() => showInFolder(h.id)}
aria-label="Show in folder"
/>
</Hint>
<Hint label="Remove from history" placement="top" align="end">
<Button
appearance="subtle"
icon={<DeleteRegular />}
onClick={() => remove(h.id)}
aria-label="Remove from history"
/>
</Hint>
</div>
</div>
<div className={styles.body}>
<Text className={styles.title}>{h.title}</Text>
<Caption1 className={styles.meta}>
{[h.quality, h.sizeLabel, formatWhen(h.completedAt)].filter(Boolean).join(' • ')}
</Caption1>
</div>
<div className={styles.actions}>
<Hint label="Open file" placement="top" align="end">
<Button
appearance="subtle"
icon={<OpenRegular />}
onClick={() => openFile(h.id)}
aria-label="Open file"
/>
</Hint>
<Hint label="Show in folder" placement="top" align="end">
<Button
appearance="subtle"
icon={<FolderRegular />}
onClick={() => showInFolder(h.id)}
aria-label="Show in folder"
/>
</Hint>
<Hint label="Remove from history" placement="top" align="end">
<Button
appearance="subtle"
icon={<DeleteRegular />}
onClick={() => remove(h.id)}
aria-label="Remove from history"
/>
</Hint>
</div>
</div>
)
})}
</div>
)
})}
</div>
)}
</div>
)
}
+7 -1
View File
@@ -18,7 +18,8 @@ import {
VideoClipRegular,
MusicNote2Regular,
CheckmarkCircleFilled,
ErrorCircleFilled
ErrorCircleFilled,
EyeOffRegular
} from '@fluentui/react-icons'
import { useDownloads, type DownloadItem, type DownloadStatus } from '../store/downloads'
import { useSettings } from '../store/settings'
@@ -149,6 +150,11 @@ export function QueueItem({ item }: { item: DownloadItem }): React.JSX.Element {
<Badge appearance="tint" color={badge.color}>
{badge.label}
</Badge>
{item.incognito && (
<Hint label="Private — not saved to history" placement="top" align="start">
<EyeOffRegular fontSize={14} style={{ color: tokens.colorNeutralForeground3 }} />
</Hint>
)}
</div>
<Caption1 className={styles.meta}>{metaParts.join(' • ')}</Caption1>
+277 -2
View File
@@ -21,20 +21,34 @@ import {
OptionsRegular,
InfoRegular,
GlobeRegular,
CookiesRegular
CookiesRegular,
CodeRegular,
ArrowClockwiseRegular,
DocumentArrowDownRegular,
DocumentArrowUpRegular,
BugRegular,
CopyRegular,
DeleteRegular
} from '@fluentui/react-icons'
import {
COOKIE_BROWSERS,
type YtdlpVersionResult,
type YtdlpUpdateChannel,
type YtdlpUpdateResult,
type MediaKind,
type CookieSource,
type CookieBrowser,
type CookiesStatus
type CookiesStatus,
type BackupExportResult,
type BackupImportResult
} from '@shared/ipc'
import { useSettings } from '../store/settings'
import { QUALITY_OPTIONS, useDownloads } from '../store/downloads'
import { useTemplates } from '../store/templates'
import { useErrorLog } from '../store/errorlog'
import { Select } from './Select'
import { DownloadOptionsForm } from './DownloadOptionsForm'
import { TemplateManager } from './TemplateManager'
const useStyles = makeStyles({
root: {
@@ -69,6 +83,35 @@ const useStyles = makeStyles({
},
hint: {
color: tokens.colorNeutralForeground3
},
errorList: {
display: 'flex',
flexDirection: 'column',
gap: '8px'
},
errorRow: {
padding: '10px 12px',
backgroundColor: tokens.colorNeutralBackground2,
...shorthands.borderRadius(tokens.borderRadiusLarge),
border: `1px solid ${tokens.colorNeutralStroke2}`
},
errorRowHeader: {
display: 'flex',
alignItems: 'center',
gap: '8px'
},
errorRowTitle: {
flexGrow: 1,
minWidth: 0,
fontWeight: tokens.fontWeightSemibold,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
},
errorRowText: {
color: tokens.colorPaletteRedForeground1,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word'
}
})
@@ -89,6 +132,11 @@ const COOKIE_BROWSER_OPTIONS = COOKIE_BROWSERS.map((b) => ({
label: b[0].toUpperCase() + b.slice(1)
}))
const UPDATE_CHANNEL_OPTIONS = [
{ value: 'stable', label: 'Stable' },
{ value: 'nightly', label: 'Nightly' }
]
export function SettingsView(): React.JSX.Element {
const styles = useStyles()
@@ -108,7 +156,13 @@ export function SettingsView(): React.JSX.Element {
const cookiesBrowser = useSettings((s) => s.cookiesBrowser)
const restrictFilenames = useSettings((s) => s.restrictFilenames)
const downloadArchive = useSettings((s) => s.downloadArchive)
const customCommandEnabled = useSettings((s) => s.customCommandEnabled)
const defaultTemplateId = useSettings((s) => s.defaultTemplateId)
const notifyOnComplete = useSettings((s) => s.notifyOnComplete)
const update = useSettings((s) => s.update)
const templates = useTemplates((s) => s.templates)
const errorEntries = useErrorLog((s) => s.entries)
const clearErrorLog = useErrorLog((s) => s.clear)
const formatQuality = defaultKind === 'audio' ? defaultAudioQuality : defaultVideoQuality
const formatValue = `${defaultKind}|${formatQuality}`
@@ -116,15 +170,69 @@ export function SettingsView(): React.JSX.Element {
const [checking, setChecking] = useState(false)
const [version, setVersion] = useState<YtdlpVersionResult | null>(null)
const [updateChannel, setUpdateChannel] = useState<YtdlpUpdateChannel>('stable')
const [updating, setUpdating] = useState(false)
const [updateResult, setUpdateResult] = useState<YtdlpUpdateResult | null>(null)
const [cookiesStatus, setCookiesStatus] = useState<CookiesStatus | null>(null)
const [loginUrl, setLoginUrl] = useState('https://www.youtube.com')
const [signingIn, setSigningIn] = useState(false)
const [loginError, setLoginError] = useState<string | null>(null)
const [exporting, setExporting] = useState(false)
const [exportResult, setExportResult] = useState<BackupExportResult | null>(null)
const [importing, setImporting] = useState(false)
const [importResult, setImportResult] = useState<BackupImportResult | null>(null)
useEffect(() => {
window.api.cookiesStatus().then(setCookiesStatus)
}, [])
async function exportBackup(): Promise<void> {
setExporting(true)
setExportResult(null)
try {
setExportResult(await window.api.exportBackup())
} catch (e) {
setExportResult({ ok: false, error: e instanceof Error ? e.message : String(e) })
} finally {
setExporting(false)
}
}
async function importBackup(): Promise<void> {
setImporting(true)
setImportResult(null)
try {
const result = await window.api.importBackup()
setImportResult(result)
if (result.ok) {
// Settings + templates changed underneath the stores — reload both.
const [s, t] = await Promise.all([window.api.getSettings(), window.api.listTemplates()])
useSettings.setState({ ...s, loaded: true })
useTemplates.setState({ templates: t })
}
} catch (e) {
setImportResult({ ok: false, error: e instanceof Error ? e.message : String(e) })
} finally {
setImporting(false)
}
}
function copyErrorReport(): void {
const report = errorEntries
.map((e) =>
[
new Date(e.occurredAt).toLocaleString(),
e.title ?? e.url,
e.url,
e.error
].join('\n')
)
.join('\n\n---\n\n')
navigator.clipboard.writeText(report || 'No errors logged.').catch(() => {})
}
async function signIn(): Promise<void> {
setSigningIn(true)
setLoginError(null)
@@ -165,6 +273,20 @@ export function SettingsView(): React.JSX.Element {
}
}
async function runUpdate(): Promise<void> {
setUpdating(true)
setUpdateResult(null)
try {
const result = await window.api.updateYtdlp(updateChannel)
setUpdateResult(result)
if (result.ok) setVersion(null) // stale — prompt a re-check rather than show a wrong version
} catch (e) {
setUpdateResult({ ok: false, error: e instanceof Error ? e.message : String(e) })
} finally {
setUpdating(false)
}
}
return (
<div className={styles.root}>
<Card className={styles.card}>
@@ -225,6 +347,17 @@ export function SettingsView(): React.JSX.Element {
label={clipboardWatch ? 'On' : 'Off'}
/>
</Field>
<Field
label="Notify when downloads finish"
hint="Shows a native Windows notification when a download completes or fails."
>
<Switch
checked={notifyOnComplete}
onChange={(_, d) => update({ notifyOnComplete: d.checked })}
label={notifyOnComplete ? 'On' : 'Off'}
/>
</Field>
</Card>
<Card className={styles.card}>
@@ -354,6 +487,45 @@ export function SettingsView(): React.JSX.Element {
)}
</Card>
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<CodeRegular className={styles.sectionIcon} />
<Subtitle2>Custom commands</Subtitle2>
</div>
<Caption1 className={styles.hint}>
Named templates of extra yt-dlp flags your own power-user recipes (e.g.
--write-thumbnail, --no-mtime, anything yt-dlp supports). Appended after every other
option, so a template flag can override a setting above it.
</Caption1>
<Field
label="Run custom command"
hint="Applies the default template below to every new download (still overridable per download)."
>
<Switch
checked={customCommandEnabled}
onChange={(_, d) => update({ customCommandEnabled: d.checked })}
label={customCommandEnabled ? 'On' : 'Off'}
/>
</Field>
{customCommandEnabled && (
<Field label="Default template">
<Select
aria-label="Default template"
value={defaultTemplateId ?? 'none'}
options={[
{ value: 'none', label: 'None' },
...templates.map((t) => ({ value: t.id, label: t.name }))
]}
onChange={(v) => update({ defaultTemplateId: v === 'none' ? null : v })}
/>
</Field>
)}
<TemplateManager />
</Card>
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<DocumentRegular className={styles.sectionIcon} />
@@ -395,6 +567,84 @@ export function SettingsView(): React.JSX.Element {
</Field>
</Card>
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<DocumentArrowDownRegular className={styles.sectionIcon} />
<Subtitle2>Backup &amp; restore</Subtitle2>
</div>
<Caption1 className={styles.hint}>
Save your settings and custom-command templates to a JSON file, or restore them on
another machine. Does not include download history.
</Caption1>
<div className={styles.folderRow}>
<Button icon={<DocumentArrowDownRegular />} onClick={exportBackup} disabled={exporting}>
{exporting ? 'Exporting…' : 'Export backup…'}
</Button>
<Button icon={<DocumentArrowUpRegular />} onClick={importBackup} disabled={importing}>
{importing ? 'Importing…' : 'Import backup…'}
</Button>
</div>
{exportResult?.ok && exportResult.path && (
<Caption1 className={styles.hint}>Saved to {exportResult.path}</Caption1>
)}
{exportResult && !exportResult.ok && exportResult.error && (
<Caption1 style={{ color: tokens.colorPaletteRedForeground1 }}>
{exportResult.error}
</Caption1>
)}
{importResult?.ok && (
<Caption1 className={styles.hint}>Settings and templates restored.</Caption1>
)}
{importResult && !importResult.ok && importResult.error && (
<Caption1 style={{ color: tokens.colorPaletteRedForeground1 }}>
{importResult.error}
</Caption1>
)}
</Card>
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<BugRegular className={styles.sectionIcon} />
<Subtitle2>Diagnostics</Subtitle2>
</div>
<Caption1 className={styles.hint}>
Failed downloads are logged here even after you clear the queue, so you can copy the
details into a bug report.
</Caption1>
<div className={styles.folderRow}>
<Button
icon={<CopyRegular />}
onClick={copyErrorReport}
disabled={errorEntries.length === 0}
>
Copy full report
</Button>
<Button icon={<DeleteRegular />} onClick={clearErrorLog} disabled={errorEntries.length === 0}>
Clear log
</Button>
</div>
{errorEntries.length === 0 ? (
<Caption1 className={styles.hint}>No errors logged.</Caption1>
) : (
<div className={styles.errorList}>
{errorEntries.slice(0, 20).map((e) => (
<div key={e.id + String(e.occurredAt)} className={styles.errorRow}>
<div className={styles.errorRowHeader}>
<Text className={styles.errorRowTitle}>{e.title ?? e.url}</Text>
<Caption1 className={styles.hint}>
{new Date(e.occurredAt).toLocaleString()}
</Caption1>
</div>
<Caption1 className={styles.errorRowText}>{e.error}</Caption1>
</div>
))}
</div>
)}
</Card>
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<InfoRegular className={styles.sectionIcon} />
@@ -417,6 +667,31 @@ export function SettingsView(): React.JSX.Element {
{version.error}
</Caption1>
)}
<Field label="Update channel" hint="Nightly tracks yt-dlp's daily build; stable is the tagged release.">
<Select
aria-label="Update channel"
value={updateChannel}
options={UPDATE_CHANNEL_OPTIONS}
onChange={(v) => setUpdateChannel(v as YtdlpUpdateChannel)}
/>
</Field>
<div className={styles.folderRow}>
<Button icon={<ArrowClockwiseRegular />} onClick={runUpdate} disabled={updating}>
{updating ? 'Updating…' : 'Update yt-dlp'}
</Button>
{updating && <Spinner size="tiny" />}
</div>
{updateResult?.ok && (
<Text style={{ fontFamily: tokens.fontFamilyMonospace, whiteSpace: 'pre-wrap' }}>
{updateResult.output || 'yt-dlp is already up to date.'}
</Text>
)}
{updateResult && !updateResult.ok && (
<Caption1 style={{ color: tokens.colorPaletteRedForeground1, whiteSpace: 'pre-wrap' }}>
{updateResult.error}
</Caption1>
)}
</Card>
</div>
)
@@ -0,0 +1,178 @@
import { useState } from 'react'
import {
Button,
Input,
Textarea,
Caption1,
Text,
makeStyles,
tokens,
shorthands
} from '@fluentui/react-components'
import { AddRegular, EditRegular, DeleteRegular, SaveRegular, DismissRegular } from '@fluentui/react-icons'
import type { CommandTemplate } from '@shared/ipc'
import { useTemplates } from '../store/templates'
import { Hint } from './Hint'
const useStyles = makeStyles({
root: {
display: 'flex',
flexDirection: 'column',
gap: '10px'
},
row: {
display: 'flex',
alignItems: 'center',
gap: '10px',
padding: '8px 10px',
backgroundColor: tokens.colorNeutralBackground2,
...shorthands.borderRadius(tokens.borderRadiusLarge),
border: `1px solid ${tokens.colorNeutralStroke2}`
},
rowBody: {
flexGrow: 1,
minWidth: 0,
display: 'flex',
flexDirection: 'column'
},
rowName: {
fontWeight: tokens.fontWeightSemibold
},
rowArgs: {
color: tokens.colorNeutralForeground3,
fontFamily: tokens.fontFamilyMonospace,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
},
actions: {
display: 'flex',
gap: '4px',
flexShrink: 0
},
form: {
display: 'flex',
flexDirection: 'column',
gap: '8px',
padding: '12px',
backgroundColor: tokens.colorNeutralBackground2,
...shorthands.borderRadius(tokens.borderRadiusLarge),
border: `1px solid ${tokens.colorNeutralStroke2}`
},
formActions: {
display: 'flex',
gap: '8px',
justifyContent: 'flex-end'
},
empty: {
color: tokens.colorNeutralForeground3
}
})
interface Draft {
/** null while adding a brand-new template */
id: string | null
name: string
args: string
}
const BLANK_DRAFT: Draft = { id: null, name: '', args: '' }
function newId(): string {
return typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `tpl-${Date.now()}`
}
/**
* Inline (no-modal — see the Hint/Select comments re: composited overlays
* flickering on this dev machine's GPU) CRUD list + add/edit form for
* CommandTemplate. Used standalone from SettingsView's "Custom commands" card.
*/
export function TemplateManager(): React.JSX.Element {
const styles = useStyles()
const templates = useTemplates((s) => s.templates)
const save = useTemplates((s) => s.save)
const remove = useTemplates((s) => s.remove)
const [draft, setDraft] = useState<Draft | null>(null)
function startEdit(t: CommandTemplate): void {
setDraft({ id: t.id, name: t.name, args: t.args })
}
function commit(): void {
if (!draft) return
const name = draft.name.trim()
if (!name) return
save({ id: draft.id ?? newId(), name, args: draft.args.trim() })
setDraft(null)
}
return (
<div className={styles.root}>
{templates.length === 0 && !draft && (
<Caption1 className={styles.empty}>No custom command templates yet.</Caption1>
)}
{templates.map((t) => (
<div key={t.id} className={styles.row}>
<div className={styles.rowBody}>
<Text className={styles.rowName}>{t.name}</Text>
<Caption1 className={styles.rowArgs} title={t.args}>
{t.args || '(no extra args)'}
</Caption1>
</div>
<div className={styles.actions}>
<Hint label="Edit" placement="top" align="end">
<Button
appearance="subtle"
icon={<EditRegular />}
onClick={() => startEdit(t)}
aria-label="Edit template"
/>
</Hint>
<Hint label="Delete" placement="top" align="end">
<Button
appearance="subtle"
icon={<DeleteRegular />}
onClick={() => remove(t.id)}
aria-label="Delete template"
/>
</Hint>
</div>
</div>
))}
{draft ? (
<div className={styles.form}>
<Input
value={draft.name}
placeholder="Template name"
onChange={(_, d) => setDraft({ ...draft, name: d.value })}
/>
<Textarea
value={draft.args}
placeholder="Extra yt-dlp flags, e.g. --write-thumbnail --no-mtime"
resize="vertical"
onChange={(_, d) => setDraft({ ...draft, args: d.value })}
/>
<div className={styles.formActions}>
<Button appearance="subtle" icon={<DismissRegular />} onClick={() => setDraft(null)}>
Cancel
</Button>
<Button
appearance="primary"
icon={<SaveRegular />}
onClick={commit}
disabled={!draft.name.trim()}
>
Save
</Button>
</div>
</div>
) : (
<Button appearance="subtle" icon={<AddRegular />} onClick={() => setDraft({ ...BLANK_DRAFT })}>
Add template
</Button>
)}
</div>
)
}
+38 -2
View File
@@ -1,7 +1,7 @@
import './assets/base.css'
import React from 'react'
import ReactDOM from 'react-dom/client'
import { DEFAULT_DOWNLOAD_OPTIONS, type Settings } from '@shared/ipc'
import { DEFAULT_DOWNLOAD_OPTIONS, type Settings, type CommandTemplate } from '@shared/ipc'
import App from './App'
// In the standalone UI preview (browser, no Electron preload) window.api isn't
@@ -26,11 +26,19 @@ if (import.meta.env.DEV && !window.api) {
cookieSource: 'none',
cookiesBrowser: 'chrome',
restrictFilenames: false,
downloadArchive: false
downloadArchive: false,
customCommandEnabled: false,
defaultTemplateId: null,
notifyOnComplete: true
}
// Stands in for the cookies.txt file's mtime — lets the Cookies card's
// sign-in/clear flow be exercised in this browser-only preview.
let mockCookiesSavedAt: number | null = null
// Stands in for templates.json — lets the Custom commands card's CRUD and
// the download bar's template picker be exercised in this browser-only preview.
let mockTemplates: CommandTemplate[] = [
{ id: 'tpl1', name: 'Write thumbnail, no mtime', args: '--write-thumbnail --no-mtime' }
]
window.api = {
getYtdlpVersion: async () => ({ ok: true, version: '2025.06.01 (UI preview mock)' }),
@@ -86,6 +94,7 @@ if (import.meta.env.DEV && !window.api) {
listHistory: async () => [],
addHistory: async () => [],
removeHistory: async () => [],
removeManyHistory: async () => [],
clearHistory: async () => [],
cookiesLogin: async () => {
await new Promise((r) => setTimeout(r, 600)) // simulate the sign-in window
@@ -97,6 +106,33 @@ if (import.meta.env.DEV && !window.api) {
cookiesClear: async () => {
mockCookiesSavedAt = null
},
listTemplates: async () => mockTemplates,
saveTemplate: async (template) => {
mockTemplates = [template, ...mockTemplates.filter((t) => t.id !== template.id)]
return mockTemplates
},
removeTemplate: async (id) => {
mockTemplates = mockTemplates.filter((t) => t.id !== id)
return mockTemplates
},
previewCommand: async (opts) => {
await new Promise((r) => setTimeout(r, 300)) // simulate the main-process round-trip
const extra = opts.extraArgs ? ` ${opts.extraArgs}` : ''
return {
ok: true,
command:
`yt-dlp.exe -f "bv*+ba/b" -o "${MOCK_SETTINGS.outputDir}\\%(title)s.%(ext)s"` +
`${extra} -- ${opts.url}`
}
},
updateYtdlp: async (channel) => {
await new Promise((r) => setTimeout(r, 600)) // simulate the self-update run
return { ok: true, output: `yt-dlp is already up to date (channel: ${channel}, UI preview mock)` }
},
listErrorLog: async () => [],
clearErrorLog: async () => [],
exportBackup: async () => ({ ok: true, path: 'C:\\Users\\you\\Downloads\\aerofetch-backup.json' }),
importBackup: async () => ({ ok: true }),
onDownloadEvent: () => () => {}
}
}
+14 -5
View File
@@ -36,6 +36,10 @@ export interface DownloadItem {
formatHasAudio?: boolean
/** per-download post-processing override (omitted = use persisted defaults) */
options?: DownloadOptions
/** per-download custom-command override (omitted = use the persisted default template) */
extraArgs?: string
/** private download — completion is never recorded to history */
incognito?: boolean
}
export const QUALITY_OPTIONS: Record<MediaKind, string[]> = {
@@ -60,6 +64,8 @@ interface DownloadState {
durationLabel?: string
thumbnail?: string
options?: DownloadOptions
extraArgs?: string
incognito?: boolean
}
) => void
retry: (id: string) => void
@@ -188,7 +194,7 @@ function startFakeTicker(
if (done) {
clearInterval(timer)
const finished = get().items.find((i) => i.id === id)
if (finished) {
if (finished && !finished.incognito) {
useHistory.getState().add({
id: finished.id,
title: finished.title,
@@ -230,7 +236,8 @@ export const useDownloads = create<DownloadState>((set, get) => {
outputDir: useSettings.getState().outputDir || undefined,
formatId: item.formatId,
formatHasAudio: item.formatHasAudio,
options: item.options
options: item.options,
extraArgs: item.extraArgs
})
.then((res) => {
if (!res.ok) markError(item.id, res.error)
@@ -276,7 +283,9 @@ export const useDownloads = create<DownloadState>((set, get) => {
progress: 0,
formatId: opts?.format?.id,
formatHasAudio: opts?.format?.hasAudio,
options: opts?.options
options: opts?.options,
extraArgs: opts?.extraArgs,
incognito: opts?.incognito
}
set((s) => ({ items: [item, ...s.items] }))
pump()
@@ -369,10 +378,10 @@ export const useDownloads = create<DownloadState>((set, get) => {
}
})
}))
// Record completed downloads to history.
// Record completed downloads to history (unless this was a private download).
if (ev.type === 'done') {
const item = get().items.find((i) => i.id === ev.id)
if (item && item.status === 'completed') {
if (item && item.status === 'completed' && !item.incognito) {
useHistory.getState().add({
id: item.id,
title: item.title,
+38
View File
@@ -0,0 +1,38 @@
import { create } from 'zustand'
import type { ErrorLogEntry } from '@shared/ipc'
// True in the standalone browser preview (no Electron preload).
const PREVIEW = typeof window === 'undefined' || !window.electron
// Sample entries so the Diagnostics card has content during UI design.
const seed: ErrorLogEntry[] = PREVIEW
? [
{
id: 'e1',
title: 'Private video',
url: 'https://youtube.com/watch?v=err0r',
kind: 'video',
error: 'ERROR: [youtube] err0r: Private video. Sign in if you have access.',
occurredAt: Date.now() - 1000 * 60 * 12
}
]
: []
interface ErrorLogState {
entries: ErrorLogEntry[]
clear: () => void
}
export const useErrorLog = create<ErrorLogState>((set) => ({
entries: seed,
clear: () => {
set({ entries: [] })
if (!PREVIEW) window.api.clearErrorLog().catch(() => {})
}
}))
// Load the persisted error log on startup.
if (!PREVIEW) {
window.api.listErrorLog().then((entries) => useErrorLog.setState({ entries }))
}
+7
View File
@@ -47,6 +47,7 @@ interface HistoryState {
entries: HistoryEntry[]
add: (entry: HistoryEntry) => void
remove: (id: string) => void
removeMany: (ids: string[]) => void
clear: () => void
openFile: (id: string) => void
showInFolder: (id: string) => void
@@ -65,6 +66,12 @@ export const useHistory = create<HistoryState>((set, get) => ({
if (!PREVIEW) window.api.removeHistory(id).catch(() => {})
},
removeMany: (ids) => {
const remove = new Set(ids)
set((s) => ({ entries: s.entries.filter((e) => !remove.has(e.id)) }))
if (!PREVIEW) window.api.removeManyHistory(ids).catch(() => {})
},
clear: () => {
set({ entries: [] })
if (!PREVIEW) window.api.clearHistory().catch(() => {})
+4 -1
View File
@@ -20,7 +20,10 @@ const FALLBACK: Settings = {
cookieSource: 'none',
cookiesBrowser: 'chrome',
restrictFilenames: false,
downloadArchive: false
downloadArchive: false,
customCommandEnabled: false,
defaultTemplateId: null,
notifyOnComplete: true
}
interface SettingsState extends Settings {
+39
View File
@@ -0,0 +1,39 @@
import { create } from 'zustand'
import type { CommandTemplate } from '@shared/ipc'
// True in the standalone browser preview (no Electron preload).
const PREVIEW = typeof window === 'undefined' || !window.electron
// Sample templates so the Settings tab has content during UI design.
const seed: CommandTemplate[] = PREVIEW
? [
{ id: 't1', name: 'Write thumbnail, no mtime', args: '--write-thumbnail --no-mtime' },
{ id: 't2', name: 'Verbose log', args: '--verbose' }
]
: []
interface TemplatesState {
templates: CommandTemplate[]
/** add a new template or update an existing one (matched by id) */
save: (template: CommandTemplate) => void
remove: (id: string) => void
}
export const useTemplates = create<TemplatesState>((set) => ({
templates: seed,
save: (template) => {
set((s) => ({ templates: [template, ...s.templates.filter((t) => t.id !== template.id)] }))
if (!PREVIEW) window.api.saveTemplate(template).catch(() => {})
},
remove: (id) => {
set((s) => ({ templates: s.templates.filter((t) => t.id !== id) }))
if (!PREVIEW) window.api.removeTemplate(id).catch(() => {})
}
}))
// Load persisted templates on startup.
if (!PREVIEW) {
window.api.listTemplates().then((templates) => useTemplates.setState({ templates }))
}
+85 -1
View File
@@ -18,12 +18,22 @@ export const IpcChannels = {
historyList: 'history:list',
historyAdd: 'history:add',
historyRemove: 'history:remove',
historyRemoveMany: 'history:remove-many',
historyClear: 'history:clear',
/** main → renderer push channel for live download events */
downloadEvent: 'download:event',
cookiesLogin: 'cookies:login',
cookiesStatus: 'cookies:status',
cookiesClear: 'cookies:clear'
cookiesClear: 'cookies:clear',
templatesList: 'templates:list',
templatesSave: 'templates:save',
templatesRemove: 'templates:remove',
commandPreview: 'command:preview',
ytdlpUpdate: 'ytdlp:update',
errorLogList: 'errorlog:list',
errorLogClear: 'errorlog:clear',
backupExport: 'backup:export',
backupImport: 'backup:import'
} as const
/** Sentinel format id meaning "let yt-dlp pick the best video+audio". */
@@ -128,6 +138,16 @@ export interface YtdlpVersionResult {
error?: string
}
/** yt-dlp's self-update release channel (`--update-to <channel>`). */
export type YtdlpUpdateChannel = 'stable' | 'nightly'
export interface YtdlpUpdateResult {
ok: boolean
/** yt-dlp's own update output, e.g. "Updated to ... " or "yt-dlp is up to date" */
output?: string
error?: string
}
/** A single selectable output format, derived from `yt-dlp -J`. */
export interface FormatOption {
/** yt-dlp format_id, or BEST_FORMAT_ID for the auto-best option */
@@ -198,6 +218,14 @@ export interface StartDownloadOptions {
formatHasAudio?: boolean
/** per-download post-processing override; main falls back to settings defaults */
options?: DownloadOptions
/**
* Per-download custom-command override: raw extra yt-dlp flags appended to
* the generated argv (see CommandTemplate). Omit to fall back to the
* persisted settings default (customCommandEnabled + defaultTemplateId);
* pass an empty string to explicitly run with no extra args for just this
* download even when the settings default is enabled.
*/
extraArgs?: string
}
export interface StartDownloadResult {
@@ -262,6 +290,32 @@ export interface Settings {
restrictFilenames: boolean
/** record completed downloads in a yt-dlp --download-archive file to skip repeats */
downloadArchive: boolean
/** apply a named custom-command template's extra args to every new download */
customCommandEnabled: boolean
/** id of the CommandTemplate applied when customCommandEnabled is on; null = none picked yet */
defaultTemplateId: string | null
/** show a native OS notification when a download finishes or fails */
notifyOnComplete: boolean
}
/**
* A named, reusable set of extra yt-dlp CLI flags (Phase C "custom commands"),
* e.g. name: 'Write thumbnail + no mtime', args: '--write-thumbnail --no-mtime'.
* Shell-split (see parseExtraArgs) and appended to the generated argv just
* before the `--` URL terminator, so they can override earlier flags.
*/
export interface CommandTemplate {
id: string
name: string
args: string
}
/** Result of building the exact yt-dlp command line for the current form state, without running it. */
export interface CommandPreviewResult {
ok: boolean
/** full command line (binary + quoted args), for display/copy only */
command?: string
error?: string
}
/** Whether the built-in sign-in window has ever saved a cookie file, and when. */
@@ -293,3 +347,33 @@ export interface HistoryEntry {
/** epoch milliseconds */
completedAt: number
}
/**
* A failed download or download-start attempt, persisted to errorlog.json
* (newest-first) so the report survives the queue item being cleared —
* Seal's "debug report" equivalent.
*/
export interface ErrorLogEntry {
id: string
/** resolved video title when known; falls back to the URL in the UI */
title?: string
url: string
kind: MediaKind
error: string
/** epoch milliseconds */
occurredAt: number
}
/** Result of exporting settings + custom-command templates to a JSON file. */
export interface BackupExportResult {
ok: boolean
/** absolute path written to, present when ok is true */
path?: string
error?: string
}
/** Result of restoring settings + custom-command templates from a JSON file. */
export interface BackupImportResult {
ok: boolean
error?: string
}
+80 -3
View File
@@ -1,5 +1,12 @@
import { describe, it, expect } from 'vitest'
import { buildArgs, CROP_SQUARE_PPA, ARIA2C_ARGS, type AccessOptions } from '../src/main/buildArgs'
import {
buildArgs,
parseExtraArgs,
formatCommandLine,
CROP_SQUARE_PPA,
ARIA2C_ARGS,
type AccessOptions
} from '../src/main/buildArgs'
import {
DEFAULT_DOWNLOAD_OPTIONS,
type DownloadOptions,
@@ -25,9 +32,10 @@ function dlo(overrides: Partial<DownloadOptions> = {}): DownloadOptions {
function build(
o: StartDownloadOptions,
d: DownloadOptions,
access: AccessOptions = NO_ACCESS
access: AccessOptions = NO_ACCESS,
extraArgs: string[] = []
): string[] {
return buildArgs(o, OUT, d, BIN, access)
return buildArgs(o, OUT, d, BIN, access, extraArgs)
}
/** True when `seq` appears as a contiguous run inside `argv`. */
@@ -283,3 +291,72 @@ describe('cropThumbnail → --ppa crop', () => {
expect(argv).not.toContain('--embed-thumbnail')
})
})
// --- Custom commands (Phase C): extra args + display formatting ------------
describe('parseExtraArgs', () => {
it('splits on whitespace', () => {
expect(parseExtraArgs('--write-thumbnail --no-mtime')).toEqual([
'--write-thumbnail',
'--no-mtime'
])
})
it('keeps a double-quoted span with spaces as one token', () => {
expect(parseExtraArgs('--proxy "http://example.com:8080"')).toEqual([
'--proxy',
'http://example.com:8080'
])
})
it('keeps a single-quoted span with spaces as one token', () => {
expect(parseExtraArgs("--output-na 'My Title - %(id)s.%(ext)s'")).toEqual([
'--output-na',
'My Title - %(id)s.%(ext)s'
])
})
it('returns an empty array for blank input', () => {
expect(parseExtraArgs('')).toEqual([])
expect(parseExtraArgs(' ')).toEqual([])
})
it('collapses repeated whitespace between tokens', () => {
expect(parseExtraArgs('--verbose --no-progress')).toEqual(['--verbose', '--no-progress'])
})
})
describe('formatCommandLine', () => {
it('joins the exe and args with spaces when nothing needs quoting', () => {
expect(formatCommandLine('yt-dlp.exe', ['-f', 'best', '--', 'https://x.test/v'])).toBe(
'yt-dlp.exe -f best -- https://x.test/v'
)
})
it('quotes an arg containing a space', () => {
expect(formatCommandLine('yt-dlp.exe', ['--ppa', 'EmbedThumbnail+ffmpeg_o:-c:v mjpeg'])).toBe(
'yt-dlp.exe --ppa "EmbedThumbnail+ffmpeg_o:-c:v mjpeg"'
)
})
it('escapes embedded double quotes', () => {
expect(formatCommandLine('yt-dlp.exe', ['say "hi"'])).toBe('yt-dlp.exe "say \\"hi\\""')
})
it('renders an empty-string arg as ""', () => {
expect(formatCommandLine('yt-dlp.exe', [''])).toBe('yt-dlp.exe ""')
})
})
describe('buildArgs extraArgs', () => {
it('appends extraArgs after every other option, still before -- url', () => {
const argv = build(opts(), dlo(), NO_ACCESS, ['--write-thumbnail', '--no-mtime'])
expect(hasSeq(argv, '--write-thumbnail', '--no-mtime', '--', URL)).toBe(true)
})
it('omits nothing extra when extraArgs is empty', () => {
const withEmpty = build(opts(), dlo(), NO_ACCESS, [])
const withoutParam = build(opts(), dlo())
expect(withEmpty).toEqual(withoutParam)
})
})