From a822a2bb5204c6765d69cb52172e39744aec3715 Mon Sep 17 00:00:00 2001 From: debont80 Date: Tue, 23 Jun 2026 06:19:45 -0400 Subject: [PATCH] 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 --- ROADMAP.md | 78 ++++- src/main/backup.ts | 57 ++++ src/main/buildArgs.ts | 37 ++- src/main/download.ts | 126 ++++++-- src/main/errorlog.ts | 41 +++ src/main/history.ts | 7 + src/main/index.ts | 57 +++- src/main/settings.ts | 10 +- src/main/templates.ts | 58 ++++ src/main/ytdlp.ts | 37 ++- src/preload/index.ts | 37 ++- src/renderer/src/components/DownloadBar.tsx | 217 +++++++++++++- src/renderer/src/components/HistoryView.tsx | 263 +++++++++++++---- src/renderer/src/components/QueueItem.tsx | 8 +- src/renderer/src/components/SettingsView.tsx | 279 +++++++++++++++++- .../src/components/TemplateManager.tsx | 178 +++++++++++ src/renderer/src/main.tsx | 40 ++- src/renderer/src/store/downloads.ts | 19 +- src/renderer/src/store/errorlog.ts | 38 +++ src/renderer/src/store/history.ts | 7 + src/renderer/src/store/settings.ts | 5 +- src/renderer/src/store/templates.ts | 39 +++ src/shared/ipc.ts | 86 +++++- test/buildArgs.test.ts | 83 +++++- 24 files changed, 1678 insertions(+), 129 deletions(-) create mode 100644 src/main/backup.ts create mode 100644 src/main/errorlog.ts create mode 100644 src/main/templates.ts create mode 100644 src/renderer/src/components/TemplateManager.tsx create mode 100644 src/renderer/src/store/errorlog.ts create mode 100644 src/renderer/src/store/templates.ts diff --git a/ROADMAP.md b/ROADMAP.md index c7a4c05..eb49c52 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 diff --git a/src/main/backup.ts b/src/main/backup.ts new file mode 100644 index 0000000..58deb76 --- /dev/null +++ b/src/main/backup.ts @@ -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 { + 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 { + 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 + if (file.settings && typeof file.settings === 'object') setSettings(file.settings) + if (Array.isArray(file.templates)) replaceTemplates(file.templates) + return { ok: true } +} diff --git a/src/main/buildArgs.ts b/src/main/buildArgs.ts index 737b672..a5f6b92 100644 --- a/src/main/buildArgs.ts +++ b/src/main/buildArgs.ts @@ -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 diff --git a/src/main/download.ts b/src/main/download.ts index 058677a..c492efb 100644 --- a/src/main/download.ts +++ b/src/main/download.ts @@ -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 { @@ -122,29 +153,21 @@ function probeMeta(ytdlp: string, url: string): Promise { }) } -// --- 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) } }) diff --git a/src/main/errorlog.ts b/src/main/errorlog.ts new file mode 100644 index 0000000..2db33cc --- /dev/null +++ b/src/main/errorlog.ts @@ -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 [] +} diff --git a/src/main/history.ts b/src/main/history.ts index 0499126..4220ebb 100644 --- a/src/main/history.ts +++ b/src/main/history.ts @@ -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 [] diff --git a/src/main/index.ts b/src/main/index.ts index e110838..992ffd7 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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(() => { diff --git a/src/main/settings.ts b/src/main/settings.ts index a61c97d..29e3f06 100644 --- a/src/main/settings.ts +++ b/src/main/settings.ts @@ -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 { 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 diff --git a/src/main/templates.ts b/src/main/templates.ts new file mode 100644 index 0000000..9b5f187 --- /dev/null +++ b/src/main/templates.ts @@ -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 +} diff --git a/src/main/ytdlp.ts b/src/main/ytdlp.ts index bfd0a17..3fe9e7e 100644 --- a/src/main/ytdlp.ts +++ b/src/main/ytdlp.ts @@ -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 { }) }) } + +/** + * Self-update the bundled yt-dlp.exe via its built-in `--update-to ` + * (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 { + 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() }) + } + ) + }) +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 4a469e8..87e8ad0 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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 => ipcRenderer.invoke(IpcChannels.historyRemove, id), + removeManyHistory: (ids: string[]): Promise => + ipcRenderer.invoke(IpcChannels.historyRemoveMany, ids), + clearHistory: (): Promise => 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 => ipcRenderer.invoke(IpcChannels.cookiesClear), + listTemplates: (): Promise => ipcRenderer.invoke(IpcChannels.templatesList), + + saveTemplate: (template: CommandTemplate): Promise => + ipcRenderer.invoke(IpcChannels.templatesSave, template), + + removeTemplate: (id: string): Promise => + ipcRenderer.invoke(IpcChannels.templatesRemove, id), + + /** Build the exact yt-dlp command line for the given form state, without running it. */ + previewCommand: (opts: StartDownloadOptions): Promise => + ipcRenderer.invoke(IpcChannels.commandPreview, opts), + + updateYtdlp: (channel: YtdlpUpdateChannel): Promise => + ipcRenderer.invoke(IpcChannels.ytdlpUpdate, channel), + + listErrorLog: (): Promise => ipcRenderer.invoke(IpcChannels.errorLogList), + + clearErrorLog: (): Promise => ipcRenderer.invoke(IpcChannels.errorLogClear), + + /** Opens a save dialog and writes settings + templates to the chosen JSON file. */ + exportBackup: (): Promise => ipcRenderer.invoke(IpcChannels.backupExport), + + /** Opens an open dialog and restores settings + templates from the chosen JSON file. */ + importBackup: (): Promise => 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) diff --git a/src/renderer/src/components/DownloadBar.tsx b/src/renderer/src/components/DownloadBar.tsx index 58f8862..510bd06 100644 --- a/src/renderer/src/components/DownloadBar.tsx +++ b/src/renderer/src/components/DownloadBar.tsx @@ -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(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(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 { + 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 { + 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 { )} +
+ {incognito ? : } + setIncognito(d.checked)} + label={incognito ? 'Private — won’t be saved to history' : 'Private mode'} + /> +
+
+ {templateOverride !== undefined && ( + <> + Customised for this download + + + )} +
+ + + +
+ + {showCustomCommand && ( +
+ + setQuery(d.value)} + placeholder="Search title, channel, URL…" + contentBefore={} + /> + ({ value: t.id, label: t.name })) + ]} + onChange={(v) => update({ defaultTemplateId: v === 'none' ? null : v })} + /> + + )} + + + +
@@ -395,6 +567,84 @@ export function SettingsView(): React.JSX.Element { + +
+ + Backup & restore +
+ + Save your settings and custom-command templates to a JSON file, or restore them on + another machine. Does not include download history. + + +
+ + +
+ {exportResult?.ok && exportResult.path && ( + Saved to {exportResult.path} + )} + {exportResult && !exportResult.ok && exportResult.error && ( + + {exportResult.error} + + )} + {importResult?.ok && ( + Settings and templates restored. + )} + {importResult && !importResult.ok && importResult.error && ( + + {importResult.error} + + )} +
+ + +
+ + Diagnostics +
+ + Failed downloads are logged here even after you clear the queue, so you can copy the + details into a bug report. + + +
+ + +
+ + {errorEntries.length === 0 ? ( + No errors logged. + ) : ( +
+ {errorEntries.slice(0, 20).map((e) => ( +
+
+ {e.title ?? e.url} + + {new Date(e.occurredAt).toLocaleString()} + +
+ {e.error} +
+ ))} +
+ )} +
+
@@ -417,6 +667,31 @@ export function SettingsView(): React.JSX.Element { {version.error} )} + + + setDraft({ ...draft, name: d.value })} + /> +