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
+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)
}
})