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