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:
+63
-15
@@ -84,24 +84,72 @@ smoke test.*
|
|||||||
The archive file lives at a fixed `userData/download-archive.txt`
|
The archive file lives at a fixed `userData/download-archive.txt`
|
||||||
(`getDownloadArchivePath()` in `src/main/settings.ts`), not user-configurable.
|
(`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
|
Named `CommandTemplate`s (`src/shared/ipc.ts`) persist to `templates.json`
|
||||||
default; a "run custom command" mode. Needs persistence + a template editor view.
|
(`src/main/templates.ts`, mirrors `history.ts`'s plain-JSON pattern) and are managed
|
||||||
- [ ] **Command preview.** Show the exact yt-dlp command line before running (build the argv
|
inline in **Settings → Custom commands** via `TemplateManager.tsx` — no modal, matching
|
||||||
and render it).
|
the rest of the app's no-portal-overlay convention (see the Hint/Select comments re:
|
||||||
- [ ] **In-app yt-dlp updater** with stable/nightly channel (`--update-to`, or fetch the
|
this dev machine's GPU/driver). A template's extra yt-dlp flags are shell-split
|
||||||
release exe), surfaced next to the existing version check.
|
(`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**.
|
## Phase D — Library, UX & notifications ✅ COMPLETE
|
||||||
Schema already exists in `src/shared/ipc.ts`; needs UI + likely the planned move to
|
|
||||||
better-sqlite3.
|
History stays plain JSON (`history.json`, 500-entry cap) — search/filter/select are done
|
||||||
- [ ] **Native OS notifications** on completion / failure (Electron `Notification`).
|
client-side over the already-small array, so the planned better-sqlite3 migration wasn't
|
||||||
- [ ] **Private / incognito mode** — a download that skips history.
|
needed for this phase and was deliberately skipped to avoid a native-module build step for
|
||||||
- [ ] **Backup / restore** settings + templates (export / import JSON).
|
no functional gain at this scale. Failed downloads (pre-spawn rejections and in-flight
|
||||||
- [ ] **Error log / copy error report** view (Seal's debug report).
|
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
|
## Phase E — Theming & platform polish
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
// each at least 1MB so tiny files don't get split pointlessly.
|
||||||
export const ARIA2C_ARGS = 'aria2c:-x 16 -s 16 -k 1M'
|
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[] {
|
function accessArgs(a: AccessOptions): string[] {
|
||||||
const args: string[] = []
|
const args: string[] = []
|
||||||
if (a.proxy.trim()) args.push('--proxy', a.proxy.trim())
|
if (a.proxy.trim()) args.push('--proxy', a.proxy.trim())
|
||||||
@@ -152,7 +181,8 @@ export function buildArgs(
|
|||||||
outputTemplate: string,
|
outputTemplate: string,
|
||||||
o: DownloadOptions,
|
o: DownloadOptions,
|
||||||
binDir: string,
|
binDir: string,
|
||||||
access: AccessOptions
|
access: AccessOptions,
|
||||||
|
extraArgs: string[] = []
|
||||||
): string[] {
|
): string[] {
|
||||||
const args = [
|
const args = [
|
||||||
'--newline',
|
'--newline',
|
||||||
@@ -194,6 +224,11 @@ export function buildArgs(
|
|||||||
if (o.embedThumbnail && o.videoContainer !== 'webm') args.push('--embed-thumbnail')
|
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.
|
// `--` terminates option parsing so the URL can never be read as a flag.
|
||||||
args.push('--', opts.url)
|
args.push('--', opts.url)
|
||||||
return args
|
return args
|
||||||
|
|||||||
+99
-27
@@ -1,19 +1,23 @@
|
|||||||
import { spawn, execFile, type ChildProcess } from 'child_process'
|
import { spawn, execFile, type ChildProcess } from 'child_process'
|
||||||
import { existsSync } from 'fs'
|
import { existsSync } from 'fs'
|
||||||
import { join } from 'path'
|
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 { getYtdlpPath, getBinDir, getAria2cPath } from './binaries'
|
||||||
import { getSettings, getDownloadArchivePath } from './settings'
|
import { getSettings, getDownloadArchivePath } from './settings'
|
||||||
import { getCookiesFilePath } from './cookies'
|
import { getCookiesFilePath } from './cookies'
|
||||||
|
import { listTemplates } from './templates'
|
||||||
import { assertHttpUrl } from './url'
|
import { assertHttpUrl } from './url'
|
||||||
import { buildArgs } from './buildArgs'
|
import { buildArgs, parseExtraArgs, formatCommandLine } from './buildArgs'
|
||||||
|
import { addErrorLog } from './errorlog'
|
||||||
import {
|
import {
|
||||||
IpcChannels,
|
IpcChannels,
|
||||||
type StartDownloadOptions,
|
type StartDownloadOptions,
|
||||||
type StartDownloadResult,
|
type StartDownloadResult,
|
||||||
|
type CommandPreviewResult,
|
||||||
type DownloadEvent,
|
type DownloadEvent,
|
||||||
type DownloadMeta,
|
type DownloadMeta,
|
||||||
type DownloadProgress
|
type DownloadProgress,
|
||||||
|
type Settings
|
||||||
} from '@shared/ipc'
|
} from '@shared/ipc'
|
||||||
|
|
||||||
interface ActiveDownload {
|
interface ActiveDownload {
|
||||||
@@ -87,6 +91,33 @@ function send(wc: WebContents, ev: DownloadEvent): void {
|
|||||||
if (!wc.isDestroyed()) wc.send(IpcChannels.downloadEvent, ev)
|
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) ---------------
|
// --- Best-effort metadata probe (runs alongside the download) ---------------
|
||||||
|
|
||||||
function probeMeta(ytdlp: string, url: string): Promise<DownloadMeta | null> {
|
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(
|
// A per-download override (opts.extraArgs, even '') always wins; otherwise
|
||||||
wc: WebContents,
|
// fall back to the persisted default template when custom-command mode is on.
|
||||||
opts: StartDownloadOptions
|
function resolveExtraArgs(opts: StartDownloadOptions, settings: Settings): string[] {
|
||||||
): StartDownloadResult {
|
if (opts.extraArgs !== undefined) return parseExtraArgs(opts.extraArgs)
|
||||||
const ytdlp = getYtdlpPath()
|
if (settings.customCommandEnabled && settings.defaultTemplateId) {
|
||||||
if (!existsSync(ytdlp)) {
|
const tpl = listTemplates().find((t) => t.id === settings.defaultTemplateId)
|
||||||
return {
|
if (tpl) return parseExtraArgs(tpl.args)
|
||||||
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.' }
|
|
||||||
}
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve settings + per-download overrides into the full yt-dlp argv. */
|
||||||
|
export function buildCommand(opts: StartDownloadOptions): string[] {
|
||||||
const settings = getSettings()
|
const settings = getSettings()
|
||||||
const outDir = opts.outputDir?.trim() || settings.outputDir || app.getPath('downloads')
|
const outDir = opts.outputDir?.trim() || settings.outputDir || app.getPath('downloads')
|
||||||
const template = settings.filenameTemplate?.trim() || '%(title)s.%(ext)s'
|
const template = settings.filenameTemplate?.trim() || '%(title)s.%(ext)s'
|
||||||
@@ -168,12 +191,50 @@ export function startDownload(
|
|||||||
restrictFilenames: settings.restrictFilenames,
|
restrictFilenames: settings.restrictFilenames,
|
||||||
downloadArchivePath: settings.downloadArchive ? getDownloadArchivePath() : undefined
|
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
|
let child: ChildProcess
|
||||||
try {
|
try {
|
||||||
child = spawn(ytdlp, buildArgs(opts, join(outDir, template), options, getBinDir(), access), {
|
child = spawn(ytdlp, buildCommand(opts), { windowsHide: true })
|
||||||
windowsHide: true
|
|
||||||
})
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { ok: false, error: (e as Error).message }
|
return { ok: false, error: (e as Error).message }
|
||||||
}
|
}
|
||||||
@@ -182,7 +243,11 @@ export function startDownload(
|
|||||||
active.set(opts.id, rec)
|
active.set(opts.id, rec)
|
||||||
|
|
||||||
// Fetch title/channel/duration in parallel so the card fills in quickly.
|
// 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) => {
|
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 })
|
if (meta && active.has(opts.id)) send(wc, { type: 'meta', id: opts.id, meta })
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -215,7 +280,11 @@ export function startDownload(
|
|||||||
if (settled) return
|
if (settled) return
|
||||||
settled = true
|
settled = true
|
||||||
active.delete(opts.id)
|
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) => {
|
child.on('close', (code) => {
|
||||||
@@ -225,9 +294,12 @@ export function startDownload(
|
|||||||
if (rec.canceled) return // renderer already showed 'canceled' optimistically
|
if (rec.canceled) return // renderer already showed 'canceled' optimistically
|
||||||
if (code === 0) {
|
if (code === 0) {
|
||||||
send(wc, { type: 'done', id: opts.id, filePath })
|
send(wc, { type: 'done', id: opts.id, filePath })
|
||||||
|
notify(wc, resolvedTitle ?? 'Download complete', 'Finished downloading.')
|
||||||
} else {
|
} else {
|
||||||
const msg = cleanError(stderrTail) || `yt-dlp exited with code ${code}`
|
const msg = cleanError(stderrTail) || `yt-dlp exited with code ${code}`
|
||||||
send(wc, { type: 'error', id: opts.id, error: msg })
|
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
|
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[] {
|
export function clearHistory(): HistoryEntry[] {
|
||||||
save([])
|
save([])
|
||||||
return []
|
return []
|
||||||
|
|||||||
+50
-7
@@ -5,16 +5,21 @@ import {
|
|||||||
IpcChannels,
|
IpcChannels,
|
||||||
type StartDownloadOptions,
|
type StartDownloadOptions,
|
||||||
type Settings,
|
type Settings,
|
||||||
type HistoryEntry
|
type HistoryEntry,
|
||||||
|
type CommandTemplate,
|
||||||
|
type YtdlpUpdateChannel
|
||||||
} from '@shared/ipc'
|
} from '@shared/ipc'
|
||||||
import { getYtdlpVersion } from './ytdlp'
|
import { getYtdlpVersion, updateYtdlp } from './ytdlp'
|
||||||
import { probeMedia } from './probe'
|
import { probeMedia } from './probe'
|
||||||
import { startDownload, cancelDownload } from './download'
|
import { startDownload, cancelDownload, previewCommand } from './download'
|
||||||
import { getSettings, setSettings } from './settings'
|
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 { setupPortableData } from './portable'
|
||||||
import { safeOpenPath, safeShowInFolder } from './reveal'
|
import { safeOpenPath, safeShowInFolder } from './reveal'
|
||||||
import { openCookieLoginWindow, getCookiesStatus, clearCookies } from './cookies'
|
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
|
// 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 —
|
// 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.probe, (_e, url: string) => probeMedia(url))
|
||||||
|
|
||||||
ipcMain.handle(IpcChannels.downloadStart, (e, opts: StartDownloadOptions) =>
|
ipcMain.handle(IpcChannels.downloadStart, (e, opts: StartDownloadOptions) => {
|
||||||
startDownload(e.sender, opts)
|
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.downloadCancel, (_e, id: string) => cancelDownload(id))
|
||||||
|
|
||||||
ipcMain.handle(IpcChannels.defaultFolder, () => app.getPath('downloads'))
|
ipcMain.handle(IpcChannels.defaultFolder, () => app.getPath('downloads'))
|
||||||
@@ -121,11 +138,37 @@ function registerIpcHandlers(): void {
|
|||||||
ipcMain.handle(IpcChannels.historyList, () => listHistory())
|
ipcMain.handle(IpcChannels.historyList, () => listHistory())
|
||||||
ipcMain.handle(IpcChannels.historyAdd, (_e, entry: HistoryEntry) => addHistory(entry))
|
ipcMain.handle(IpcChannels.historyAdd, (_e, entry: HistoryEntry) => addHistory(entry))
|
||||||
ipcMain.handle(IpcChannels.historyRemove, (_e, id: string) => removeHistory(id))
|
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.historyClear, () => clearHistory())
|
||||||
|
|
||||||
ipcMain.handle(IpcChannels.cookiesLogin, (_e, url: string) => openCookieLoginWindow(url))
|
ipcMain.handle(IpcChannels.cookiesLogin, (_e, url: string) => openCookieLoginWindow(url))
|
||||||
ipcMain.handle(IpcChannels.cookiesStatus, () => getCookiesStatus())
|
ipcMain.handle(IpcChannels.cookiesStatus, () => getCookiesStatus())
|
||||||
ipcMain.handle(IpcChannels.cookiesClear, () => clearCookies())
|
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(() => {
|
app.whenReady().then(() => {
|
||||||
|
|||||||
@@ -29,7 +29,10 @@ const DEFAULTS: Settings = {
|
|||||||
cookieSource: 'none',
|
cookieSource: 'none',
|
||||||
cookiesBrowser: 'chrome',
|
cookiesBrowser: 'chrome',
|
||||||
restrictFilenames: false,
|
restrictFilenames: false,
|
||||||
downloadArchive: false
|
downloadArchive: false,
|
||||||
|
customCommandEnabled: false,
|
||||||
|
defaultTemplateId: null,
|
||||||
|
notifyOnComplete: true
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fixed path for the --download-archive file; not user-configurable. */
|
/** Fixed path for the --download-archive file; not user-configurable. */
|
||||||
@@ -115,8 +118,13 @@ export function setSettings(partial: Partial<Settings>): Settings {
|
|||||||
case 'useAria2c':
|
case 'useAria2c':
|
||||||
case 'restrictFilenames':
|
case 'restrictFilenames':
|
||||||
case 'downloadArchive':
|
case 'downloadArchive':
|
||||||
|
case 'customCommandEnabled':
|
||||||
|
case 'notifyOnComplete':
|
||||||
if (typeof value === 'boolean') s.set(key, value)
|
if (typeof value === 'boolean') s.set(key, value)
|
||||||
break
|
break
|
||||||
|
case 'defaultTemplateId':
|
||||||
|
if (value === null || typeof value === 'string') s.set('defaultTemplateId', value)
|
||||||
|
break
|
||||||
case 'cookieSource':
|
case 'cookieSource':
|
||||||
if (value === 'none' || value === 'browser' || value === 'login') s.set(key, value)
|
if (value === 'none' || value === 'browser' || value === 'login') s.set(key, value)
|
||||||
break
|
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 { execFile } from 'child_process'
|
||||||
import { existsSync } from 'fs'
|
import { existsSync } from 'fs'
|
||||||
import { getYtdlpPath } from './binaries'
|
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`.
|
* 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
@@ -2,6 +2,8 @@ import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron'
|
|||||||
import {
|
import {
|
||||||
IpcChannels,
|
IpcChannels,
|
||||||
type YtdlpVersionResult,
|
type YtdlpVersionResult,
|
||||||
|
type YtdlpUpdateChannel,
|
||||||
|
type YtdlpUpdateResult,
|
||||||
type ProbeResult,
|
type ProbeResult,
|
||||||
type StartDownloadOptions,
|
type StartDownloadOptions,
|
||||||
type StartDownloadResult,
|
type StartDownloadResult,
|
||||||
@@ -9,7 +11,12 @@ import {
|
|||||||
type Settings,
|
type Settings,
|
||||||
type HistoryEntry,
|
type HistoryEntry,
|
||||||
type CookiesStatus,
|
type CookiesStatus,
|
||||||
type CookiesLoginResult
|
type CookiesLoginResult,
|
||||||
|
type CommandTemplate,
|
||||||
|
type CommandPreviewResult,
|
||||||
|
type ErrorLogEntry,
|
||||||
|
type BackupExportResult,
|
||||||
|
type BackupImportResult
|
||||||
} from '@shared/ipc'
|
} from '@shared/ipc'
|
||||||
|
|
||||||
// The surface exposed to the renderer. Keep this thin: it only forwards to 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[]> =>
|
removeHistory: (id: string): Promise<HistoryEntry[]> =>
|
||||||
ipcRenderer.invoke(IpcChannels.historyRemove, id),
|
ipcRenderer.invoke(IpcChannels.historyRemove, id),
|
||||||
|
|
||||||
|
removeManyHistory: (ids: string[]): Promise<HistoryEntry[]> =>
|
||||||
|
ipcRenderer.invoke(IpcChannels.historyRemoveMany, ids),
|
||||||
|
|
||||||
clearHistory: (): Promise<HistoryEntry[]> => ipcRenderer.invoke(IpcChannels.historyClear),
|
clearHistory: (): Promise<HistoryEntry[]> => ipcRenderer.invoke(IpcChannels.historyClear),
|
||||||
|
|
||||||
/** Open the built-in sign-in window; resolves once the user closes it. */
|
/** 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),
|
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. */
|
/** Subscribe to live download events. Returns an unsubscribe function. */
|
||||||
onDownloadEvent: (cb: (ev: DownloadEvent) => void): (() => void) => {
|
onDownloadEvent: (cb: (ev: DownloadEvent) => void): (() => void) => {
|
||||||
const listener = (_e: IpcRendererEvent, ev: DownloadEvent): void => cb(ev)
|
const listener = (_e: IpcRendererEvent, ev: DownloadEvent): void => cb(ev)
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
Button,
|
Button,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
|
Switch,
|
||||||
|
Field,
|
||||||
Spinner,
|
Spinner,
|
||||||
Text,
|
Text,
|
||||||
Caption1,
|
Caption1,
|
||||||
@@ -24,11 +26,23 @@ import {
|
|||||||
OptionsRegular,
|
OptionsRegular,
|
||||||
ChevronDownRegular,
|
ChevronDownRegular,
|
||||||
ChevronUpRegular,
|
ChevronUpRegular,
|
||||||
AppsListRegular
|
AppsListRegular,
|
||||||
|
CodeRegular,
|
||||||
|
EyeRegular,
|
||||||
|
EyeOffRegular,
|
||||||
|
CopyRegular
|
||||||
} from '@fluentui/react-icons'
|
} 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 { useDownloads, QUALITY_OPTIONS, type MediaKind } from '../store/downloads'
|
||||||
import { useSettings } from '../store/settings'
|
import { useSettings } from '../store/settings'
|
||||||
|
import { useTemplates } from '../store/templates'
|
||||||
import { Select } from './Select'
|
import { Select } from './Select'
|
||||||
import { Hint } from './Hint'
|
import { Hint } from './Hint'
|
||||||
import { DownloadOptionsForm } from './DownloadOptionsForm'
|
import { DownloadOptionsForm } from './DownloadOptionsForm'
|
||||||
@@ -182,6 +196,21 @@ const useStyles = makeStyles({
|
|||||||
...shorthands.borderRadius(tokens.borderRadiusLarge),
|
...shorthands.borderRadius(tokens.borderRadiusLarge),
|
||||||
border: `1px solid ${tokens.colorNeutralStroke2}`
|
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 ---
|
// --- playlist selection ---
|
||||||
plPanel: {
|
plPanel: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -264,6 +293,28 @@ export function DownloadBar(): React.JSX.Element {
|
|||||||
const [showOptions, setShowOptions] = useState(false)
|
const [showOptions, setShowOptions] = useState(false)
|
||||||
const effectiveOptions = override ?? downloadOptions
|
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.
|
// Apply the saved default format once, when persisted settings first arrive.
|
||||||
const appliedDefaults = useRef(false)
|
const appliedDefaults = useRef(false)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -400,6 +451,61 @@ export function DownloadBar(): React.JSX.Element {
|
|||||||
setSelected(allSelected ? new Set() : new Set(playlist.entries.map((e) => e.index)))
|
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 {
|
function download(): void {
|
||||||
const trimmed = url.trim()
|
const trimmed = url.trim()
|
||||||
if (!trimmed) return
|
if (!trimmed) return
|
||||||
@@ -421,10 +527,17 @@ export function DownloadBar(): React.JSX.Element {
|
|||||||
hasAudio: selectedFormat.hasAudio,
|
hasAudio: selectedFormat.hasAudio,
|
||||||
label: selectedFormat.label
|
label: selectedFormat.label
|
||||||
},
|
},
|
||||||
options: override ?? undefined
|
options: override ?? undefined,
|
||||||
|
extraArgs: resolveExtraArgs(),
|
||||||
|
incognito
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
addFromUrl(trimmed, kind, quality, { ...meta, options: override ?? undefined })
|
addFromUrl(trimmed, kind, quality, {
|
||||||
|
...meta,
|
||||||
|
options: override ?? undefined,
|
||||||
|
extraArgs: resolveExtraArgs(),
|
||||||
|
incognito
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
setUrl('')
|
setUrl('')
|
||||||
@@ -439,7 +552,9 @@ export function DownloadBar(): React.JSX.Element {
|
|||||||
title: e.title,
|
title: e.title,
|
||||||
channel: e.uploader,
|
channel: e.uploader,
|
||||||
durationLabel: e.durationLabel,
|
durationLabel: e.durationLabel,
|
||||||
options: override ?? undefined
|
options: override ?? undefined,
|
||||||
|
extraArgs: resolveExtraArgs(),
|
||||||
|
incognito
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
setUrl('')
|
setUrl('')
|
||||||
@@ -635,6 +750,15 @@ export function DownloadBar(): React.JSX.Element {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.optionsBar}>
|
||||||
|
{incognito ? <EyeOffRegular /> : <EyeRegular />}
|
||||||
|
<Switch
|
||||||
|
checked={incognito}
|
||||||
|
onChange={(_, d) => setIncognito(d.checked)}
|
||||||
|
label={incognito ? 'Private — won’t be saved to history' : 'Private mode'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className={styles.optionsBar}>
|
<div className={styles.optionsBar}>
|
||||||
<Button
|
<Button
|
||||||
appearance="subtle"
|
appearance="subtle"
|
||||||
@@ -661,6 +785,89 @@ export function DownloadBar(): React.JSX.Element {
|
|||||||
</div>
|
</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}>
|
<div className={styles.folder}>
|
||||||
<FolderRegular />
|
<FolderRegular />
|
||||||
<Caption1 className={styles.folderPath} title={folder}>
|
<Caption1 className={styles.folderPath} title={folder}>
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
Text,
|
Text,
|
||||||
Caption1,
|
Caption1,
|
||||||
Button,
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Input,
|
||||||
Body1,
|
Body1,
|
||||||
makeStyles,
|
makeStyles,
|
||||||
tokens,
|
tokens,
|
||||||
@@ -13,13 +16,19 @@ import {
|
|||||||
DeleteRegular,
|
DeleteRegular,
|
||||||
VideoClipRegular,
|
VideoClipRegular,
|
||||||
MusicNote2Regular,
|
MusicNote2Regular,
|
||||||
HistoryRegular
|
HistoryRegular,
|
||||||
|
SearchRegular,
|
||||||
|
ArrowClockwiseRegular,
|
||||||
|
CheckmarkSquareRegular,
|
||||||
|
DismissRegular
|
||||||
} from '@fluentui/react-icons'
|
} from '@fluentui/react-icons'
|
||||||
import type { HistoryEntry } from '@shared/ipc'
|
import type { HistoryEntry, MediaKind } from '@shared/ipc'
|
||||||
import { useHistory } from '../store/history'
|
import { useHistory } from '../store/history'
|
||||||
import { useSettings } from '../store/settings'
|
import { useSettings } from '../store/settings'
|
||||||
|
import { useDownloads } from '../store/downloads'
|
||||||
import { thumbColors } from '../theme'
|
import { thumbColors } from '../theme'
|
||||||
import { Hint } from './Hint'
|
import { Hint } from './Hint'
|
||||||
|
import { Select } from './Select'
|
||||||
|
|
||||||
const useStyles = makeStyles({
|
const useStyles = makeStyles({
|
||||||
root: {
|
root: {
|
||||||
@@ -30,10 +39,24 @@ const useStyles = makeStyles({
|
|||||||
header: {
|
header: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between'
|
gap: '8px',
|
||||||
|
flexWrap: 'wrap'
|
||||||
},
|
},
|
||||||
count: {
|
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: {
|
list: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -97,9 +120,20 @@ const useStyles = makeStyles({
|
|||||||
},
|
},
|
||||||
emptyHint: {
|
emptyHint: {
|
||||||
color: tokens.colorNeutralForeground3
|
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 {
|
function formatWhen(ts: number): string {
|
||||||
const d = new Date(ts)
|
const d = new Date(ts)
|
||||||
const time = d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
|
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 openFile = useHistory((s) => s.openFile)
|
||||||
const showInFolder = useHistory((s) => s.showInFolder)
|
const showInFolder = useHistory((s) => s.showInFolder)
|
||||||
const remove = useHistory((s) => s.remove)
|
const remove = useHistory((s) => s.remove)
|
||||||
|
const removeMany = useHistory((s) => s.removeMany)
|
||||||
const clear = useHistory((s) => s.clear)
|
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) {
|
if (entries.length === 0) {
|
||||||
return (
|
return (
|
||||||
@@ -139,19 +221,77 @@ export function HistoryView(): React.JSX.Element {
|
|||||||
return (
|
return (
|
||||||
<div className={styles.root}>
|
<div className={styles.root}>
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
|
{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}>
|
<Caption1 className={styles.count}>
|
||||||
{entries.length} {entries.length === 1 ? 'download' : 'downloads'}
|
{entries.length} {entries.length === 1 ? 'download' : 'downloads'}
|
||||||
</Caption1>
|
</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}>
|
<Button size="small" appearance="subtle" icon={<DeleteRegular />} onClick={clear}>
|
||||||
Clear history
|
Clear history
|
||||||
</Button>
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<Caption1 className={styles.noMatches}>No downloads match your search.</Caption1>
|
||||||
|
) : (
|
||||||
<div className={styles.list}>
|
<div className={styles.list}>
|
||||||
{entries.map((h: HistoryEntry) => {
|
{filtered.map((h: HistoryEntry) => {
|
||||||
const t = h.kind === 'audio' ? tc.audio : tc.video
|
const t = h.kind === 'audio' ? tc.audio : tc.video
|
||||||
return (
|
return (
|
||||||
<div key={h.id} className={styles.row}>
|
<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 }}>
|
<div className={styles.thumb} style={{ backgroundColor: t.bg, color: t.fg }}>
|
||||||
{h.kind === 'audio' ? (
|
{h.kind === 'audio' ? (
|
||||||
<MusicNote2Regular fontSize={22} />
|
<MusicNote2Regular fontSize={22} />
|
||||||
@@ -162,10 +302,20 @@ export function HistoryView(): React.JSX.Element {
|
|||||||
<div className={styles.body}>
|
<div className={styles.body}>
|
||||||
<Text className={styles.title}>{h.title}</Text>
|
<Text className={styles.title}>{h.title}</Text>
|
||||||
<Caption1 className={styles.meta}>
|
<Caption1 className={styles.meta}>
|
||||||
{[h.quality, h.sizeLabel, formatWhen(h.completedAt)].filter(Boolean).join(' • ')}
|
{[h.quality, h.sizeLabel, formatWhen(h.completedAt)]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' • ')}
|
||||||
</Caption1>
|
</Caption1>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.actions}>
|
<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">
|
<Hint label="Open file" placement="top" align="end">
|
||||||
<Button
|
<Button
|
||||||
appearance="subtle"
|
appearance="subtle"
|
||||||
@@ -195,6 +345,7 @@ export function HistoryView(): React.JSX.Element {
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ import {
|
|||||||
VideoClipRegular,
|
VideoClipRegular,
|
||||||
MusicNote2Regular,
|
MusicNote2Regular,
|
||||||
CheckmarkCircleFilled,
|
CheckmarkCircleFilled,
|
||||||
ErrorCircleFilled
|
ErrorCircleFilled,
|
||||||
|
EyeOffRegular
|
||||||
} from '@fluentui/react-icons'
|
} from '@fluentui/react-icons'
|
||||||
import { useDownloads, type DownloadItem, type DownloadStatus } from '../store/downloads'
|
import { useDownloads, type DownloadItem, type DownloadStatus } from '../store/downloads'
|
||||||
import { useSettings } from '../store/settings'
|
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 appearance="tint" color={badge.color}>
|
||||||
{badge.label}
|
{badge.label}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
{item.incognito && (
|
||||||
|
<Hint label="Private — not saved to history" placement="top" align="start">
|
||||||
|
<EyeOffRegular fontSize={14} style={{ color: tokens.colorNeutralForeground3 }} />
|
||||||
|
</Hint>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Caption1 className={styles.meta}>{metaParts.join(' • ')}</Caption1>
|
<Caption1 className={styles.meta}>{metaParts.join(' • ')}</Caption1>
|
||||||
|
|||||||
@@ -21,20 +21,34 @@ import {
|
|||||||
OptionsRegular,
|
OptionsRegular,
|
||||||
InfoRegular,
|
InfoRegular,
|
||||||
GlobeRegular,
|
GlobeRegular,
|
||||||
CookiesRegular
|
CookiesRegular,
|
||||||
|
CodeRegular,
|
||||||
|
ArrowClockwiseRegular,
|
||||||
|
DocumentArrowDownRegular,
|
||||||
|
DocumentArrowUpRegular,
|
||||||
|
BugRegular,
|
||||||
|
CopyRegular,
|
||||||
|
DeleteRegular
|
||||||
} from '@fluentui/react-icons'
|
} from '@fluentui/react-icons'
|
||||||
import {
|
import {
|
||||||
COOKIE_BROWSERS,
|
COOKIE_BROWSERS,
|
||||||
type YtdlpVersionResult,
|
type YtdlpVersionResult,
|
||||||
|
type YtdlpUpdateChannel,
|
||||||
|
type YtdlpUpdateResult,
|
||||||
type MediaKind,
|
type MediaKind,
|
||||||
type CookieSource,
|
type CookieSource,
|
||||||
type CookieBrowser,
|
type CookieBrowser,
|
||||||
type CookiesStatus
|
type CookiesStatus,
|
||||||
|
type BackupExportResult,
|
||||||
|
type BackupImportResult
|
||||||
} from '@shared/ipc'
|
} from '@shared/ipc'
|
||||||
import { useSettings } from '../store/settings'
|
import { useSettings } from '../store/settings'
|
||||||
import { QUALITY_OPTIONS, useDownloads } from '../store/downloads'
|
import { QUALITY_OPTIONS, useDownloads } from '../store/downloads'
|
||||||
|
import { useTemplates } from '../store/templates'
|
||||||
|
import { useErrorLog } from '../store/errorlog'
|
||||||
import { Select } from './Select'
|
import { Select } from './Select'
|
||||||
import { DownloadOptionsForm } from './DownloadOptionsForm'
|
import { DownloadOptionsForm } from './DownloadOptionsForm'
|
||||||
|
import { TemplateManager } from './TemplateManager'
|
||||||
|
|
||||||
const useStyles = makeStyles({
|
const useStyles = makeStyles({
|
||||||
root: {
|
root: {
|
||||||
@@ -69,6 +83,35 @@ const useStyles = makeStyles({
|
|||||||
},
|
},
|
||||||
hint: {
|
hint: {
|
||||||
color: tokens.colorNeutralForeground3
|
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)
|
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 {
|
export function SettingsView(): React.JSX.Element {
|
||||||
const styles = useStyles()
|
const styles = useStyles()
|
||||||
|
|
||||||
@@ -108,7 +156,13 @@ export function SettingsView(): React.JSX.Element {
|
|||||||
const cookiesBrowser = useSettings((s) => s.cookiesBrowser)
|
const cookiesBrowser = useSettings((s) => s.cookiesBrowser)
|
||||||
const restrictFilenames = useSettings((s) => s.restrictFilenames)
|
const restrictFilenames = useSettings((s) => s.restrictFilenames)
|
||||||
const downloadArchive = useSettings((s) => s.downloadArchive)
|
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 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 formatQuality = defaultKind === 'audio' ? defaultAudioQuality : defaultVideoQuality
|
||||||
const formatValue = `${defaultKind}|${formatQuality}`
|
const formatValue = `${defaultKind}|${formatQuality}`
|
||||||
@@ -116,15 +170,69 @@ export function SettingsView(): React.JSX.Element {
|
|||||||
const [checking, setChecking] = useState(false)
|
const [checking, setChecking] = useState(false)
|
||||||
const [version, setVersion] = useState<YtdlpVersionResult | null>(null)
|
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 [cookiesStatus, setCookiesStatus] = useState<CookiesStatus | null>(null)
|
||||||
const [loginUrl, setLoginUrl] = useState('https://www.youtube.com')
|
const [loginUrl, setLoginUrl] = useState('https://www.youtube.com')
|
||||||
const [signingIn, setSigningIn] = useState(false)
|
const [signingIn, setSigningIn] = useState(false)
|
||||||
const [loginError, setLoginError] = useState<string | null>(null)
|
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(() => {
|
useEffect(() => {
|
||||||
window.api.cookiesStatus().then(setCookiesStatus)
|
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> {
|
async function signIn(): Promise<void> {
|
||||||
setSigningIn(true)
|
setSigningIn(true)
|
||||||
setLoginError(null)
|
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 (
|
return (
|
||||||
<div className={styles.root}>
|
<div className={styles.root}>
|
||||||
<Card className={styles.card}>
|
<Card className={styles.card}>
|
||||||
@@ -225,6 +347,17 @@ export function SettingsView(): React.JSX.Element {
|
|||||||
label={clipboardWatch ? 'On' : 'Off'}
|
label={clipboardWatch ? 'On' : 'Off'}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</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>
|
||||||
|
|
||||||
<Card className={styles.card}>
|
<Card className={styles.card}>
|
||||||
@@ -354,6 +487,45 @@ export function SettingsView(): React.JSX.Element {
|
|||||||
)}
|
)}
|
||||||
</Card>
|
</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}>
|
<Card className={styles.card}>
|
||||||
<div className={styles.sectionHeader}>
|
<div className={styles.sectionHeader}>
|
||||||
<DocumentRegular className={styles.sectionIcon} />
|
<DocumentRegular className={styles.sectionIcon} />
|
||||||
@@ -395,6 +567,84 @@ export function SettingsView(): React.JSX.Element {
|
|||||||
</Field>
|
</Field>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card className={styles.card}>
|
||||||
|
<div className={styles.sectionHeader}>
|
||||||
|
<DocumentArrowDownRegular className={styles.sectionIcon} />
|
||||||
|
<Subtitle2>Backup & 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}>
|
<Card className={styles.card}>
|
||||||
<div className={styles.sectionHeader}>
|
<div className={styles.sectionHeader}>
|
||||||
<InfoRegular className={styles.sectionIcon} />
|
<InfoRegular className={styles.sectionIcon} />
|
||||||
@@ -417,6 +667,31 @@ export function SettingsView(): React.JSX.Element {
|
|||||||
{version.error}
|
{version.error}
|
||||||
</Caption1>
|
</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>
|
</Card>
|
||||||
</div>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import './assets/base.css'
|
import './assets/base.css'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import ReactDOM from 'react-dom/client'
|
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'
|
import App from './App'
|
||||||
|
|
||||||
// In the standalone UI preview (browser, no Electron preload) window.api isn't
|
// 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',
|
cookieSource: 'none',
|
||||||
cookiesBrowser: 'chrome',
|
cookiesBrowser: 'chrome',
|
||||||
restrictFilenames: false,
|
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
|
// 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.
|
// sign-in/clear flow be exercised in this browser-only preview.
|
||||||
let mockCookiesSavedAt: number | null = null
|
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 = {
|
window.api = {
|
||||||
getYtdlpVersion: async () => ({ ok: true, version: '2025.06.01 (UI preview mock)' }),
|
getYtdlpVersion: async () => ({ ok: true, version: '2025.06.01 (UI preview mock)' }),
|
||||||
@@ -86,6 +94,7 @@ if (import.meta.env.DEV && !window.api) {
|
|||||||
listHistory: async () => [],
|
listHistory: async () => [],
|
||||||
addHistory: async () => [],
|
addHistory: async () => [],
|
||||||
removeHistory: async () => [],
|
removeHistory: async () => [],
|
||||||
|
removeManyHistory: async () => [],
|
||||||
clearHistory: async () => [],
|
clearHistory: async () => [],
|
||||||
cookiesLogin: async () => {
|
cookiesLogin: async () => {
|
||||||
await new Promise((r) => setTimeout(r, 600)) // simulate the sign-in window
|
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 () => {
|
cookiesClear: async () => {
|
||||||
mockCookiesSavedAt = null
|
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: () => () => {}
|
onDownloadEvent: () => () => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ export interface DownloadItem {
|
|||||||
formatHasAudio?: boolean
|
formatHasAudio?: boolean
|
||||||
/** per-download post-processing override (omitted = use persisted defaults) */
|
/** per-download post-processing override (omitted = use persisted defaults) */
|
||||||
options?: DownloadOptions
|
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[]> = {
|
export const QUALITY_OPTIONS: Record<MediaKind, string[]> = {
|
||||||
@@ -60,6 +64,8 @@ interface DownloadState {
|
|||||||
durationLabel?: string
|
durationLabel?: string
|
||||||
thumbnail?: string
|
thumbnail?: string
|
||||||
options?: DownloadOptions
|
options?: DownloadOptions
|
||||||
|
extraArgs?: string
|
||||||
|
incognito?: boolean
|
||||||
}
|
}
|
||||||
) => void
|
) => void
|
||||||
retry: (id: string) => void
|
retry: (id: string) => void
|
||||||
@@ -188,7 +194,7 @@ function startFakeTicker(
|
|||||||
if (done) {
|
if (done) {
|
||||||
clearInterval(timer)
|
clearInterval(timer)
|
||||||
const finished = get().items.find((i) => i.id === id)
|
const finished = get().items.find((i) => i.id === id)
|
||||||
if (finished) {
|
if (finished && !finished.incognito) {
|
||||||
useHistory.getState().add({
|
useHistory.getState().add({
|
||||||
id: finished.id,
|
id: finished.id,
|
||||||
title: finished.title,
|
title: finished.title,
|
||||||
@@ -230,7 +236,8 @@ export const useDownloads = create<DownloadState>((set, get) => {
|
|||||||
outputDir: useSettings.getState().outputDir || undefined,
|
outputDir: useSettings.getState().outputDir || undefined,
|
||||||
formatId: item.formatId,
|
formatId: item.formatId,
|
||||||
formatHasAudio: item.formatHasAudio,
|
formatHasAudio: item.formatHasAudio,
|
||||||
options: item.options
|
options: item.options,
|
||||||
|
extraArgs: item.extraArgs
|
||||||
})
|
})
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (!res.ok) markError(item.id, res.error)
|
if (!res.ok) markError(item.id, res.error)
|
||||||
@@ -276,7 +283,9 @@ export const useDownloads = create<DownloadState>((set, get) => {
|
|||||||
progress: 0,
|
progress: 0,
|
||||||
formatId: opts?.format?.id,
|
formatId: opts?.format?.id,
|
||||||
formatHasAudio: opts?.format?.hasAudio,
|
formatHasAudio: opts?.format?.hasAudio,
|
||||||
options: opts?.options
|
options: opts?.options,
|
||||||
|
extraArgs: opts?.extraArgs,
|
||||||
|
incognito: opts?.incognito
|
||||||
}
|
}
|
||||||
set((s) => ({ items: [item, ...s.items] }))
|
set((s) => ({ items: [item, ...s.items] }))
|
||||||
pump()
|
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') {
|
if (ev.type === 'done') {
|
||||||
const item = get().items.find((i) => i.id === ev.id)
|
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({
|
useHistory.getState().add({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
title: item.title,
|
title: item.title,
|
||||||
|
|||||||
@@ -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 }))
|
||||||
|
}
|
||||||
@@ -47,6 +47,7 @@ interface HistoryState {
|
|||||||
entries: HistoryEntry[]
|
entries: HistoryEntry[]
|
||||||
add: (entry: HistoryEntry) => void
|
add: (entry: HistoryEntry) => void
|
||||||
remove: (id: string) => void
|
remove: (id: string) => void
|
||||||
|
removeMany: (ids: string[]) => void
|
||||||
clear: () => void
|
clear: () => void
|
||||||
openFile: (id: string) => void
|
openFile: (id: string) => void
|
||||||
showInFolder: (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(() => {})
|
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: () => {
|
clear: () => {
|
||||||
set({ entries: [] })
|
set({ entries: [] })
|
||||||
if (!PREVIEW) window.api.clearHistory().catch(() => {})
|
if (!PREVIEW) window.api.clearHistory().catch(() => {})
|
||||||
|
|||||||
@@ -20,7 +20,10 @@ const FALLBACK: Settings = {
|
|||||||
cookieSource: 'none',
|
cookieSource: 'none',
|
||||||
cookiesBrowser: 'chrome',
|
cookiesBrowser: 'chrome',
|
||||||
restrictFilenames: false,
|
restrictFilenames: false,
|
||||||
downloadArchive: false
|
downloadArchive: false,
|
||||||
|
customCommandEnabled: false,
|
||||||
|
defaultTemplateId: null,
|
||||||
|
notifyOnComplete: true
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SettingsState extends Settings {
|
interface SettingsState extends Settings {
|
||||||
|
|||||||
@@ -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
@@ -18,12 +18,22 @@ export const IpcChannels = {
|
|||||||
historyList: 'history:list',
|
historyList: 'history:list',
|
||||||
historyAdd: 'history:add',
|
historyAdd: 'history:add',
|
||||||
historyRemove: 'history:remove',
|
historyRemove: 'history:remove',
|
||||||
|
historyRemoveMany: 'history:remove-many',
|
||||||
historyClear: 'history:clear',
|
historyClear: 'history:clear',
|
||||||
/** main → renderer push channel for live download events */
|
/** main → renderer push channel for live download events */
|
||||||
downloadEvent: 'download:event',
|
downloadEvent: 'download:event',
|
||||||
cookiesLogin: 'cookies:login',
|
cookiesLogin: 'cookies:login',
|
||||||
cookiesStatus: 'cookies:status',
|
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
|
} as const
|
||||||
|
|
||||||
/** Sentinel format id meaning "let yt-dlp pick the best video+audio". */
|
/** Sentinel format id meaning "let yt-dlp pick the best video+audio". */
|
||||||
@@ -128,6 +138,16 @@ export interface YtdlpVersionResult {
|
|||||||
error?: string
|
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`. */
|
/** A single selectable output format, derived from `yt-dlp -J`. */
|
||||||
export interface FormatOption {
|
export interface FormatOption {
|
||||||
/** yt-dlp format_id, or BEST_FORMAT_ID for the auto-best option */
|
/** yt-dlp format_id, or BEST_FORMAT_ID for the auto-best option */
|
||||||
@@ -198,6 +218,14 @@ export interface StartDownloadOptions {
|
|||||||
formatHasAudio?: boolean
|
formatHasAudio?: boolean
|
||||||
/** per-download post-processing override; main falls back to settings defaults */
|
/** per-download post-processing override; main falls back to settings defaults */
|
||||||
options?: DownloadOptions
|
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 {
|
export interface StartDownloadResult {
|
||||||
@@ -262,6 +290,32 @@ export interface Settings {
|
|||||||
restrictFilenames: boolean
|
restrictFilenames: boolean
|
||||||
/** record completed downloads in a yt-dlp --download-archive file to skip repeats */
|
/** record completed downloads in a yt-dlp --download-archive file to skip repeats */
|
||||||
downloadArchive: boolean
|
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. */
|
/** Whether the built-in sign-in window has ever saved a cookie file, and when. */
|
||||||
@@ -293,3 +347,33 @@ export interface HistoryEntry {
|
|||||||
/** epoch milliseconds */
|
/** epoch milliseconds */
|
||||||
completedAt: number
|
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
@@ -1,5 +1,12 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
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 {
|
import {
|
||||||
DEFAULT_DOWNLOAD_OPTIONS,
|
DEFAULT_DOWNLOAD_OPTIONS,
|
||||||
type DownloadOptions,
|
type DownloadOptions,
|
||||||
@@ -25,9 +32,10 @@ function dlo(overrides: Partial<DownloadOptions> = {}): DownloadOptions {
|
|||||||
function build(
|
function build(
|
||||||
o: StartDownloadOptions,
|
o: StartDownloadOptions,
|
||||||
d: DownloadOptions,
|
d: DownloadOptions,
|
||||||
access: AccessOptions = NO_ACCESS
|
access: AccessOptions = NO_ACCESS,
|
||||||
|
extraArgs: string[] = []
|
||||||
): 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`. */
|
/** True when `seq` appears as a contiguous run inside `argv`. */
|
||||||
@@ -283,3 +291,72 @@ describe('cropThumbnail → --ppa crop', () => {
|
|||||||
expect(argv).not.toContain('--embed-thumbnail')
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user