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:
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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 []
|
||||
}
|
||||
@@ -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
@@ -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(() => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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() })
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user