Add Seal-parity download options + playlist support (Phase A)

Phase A of the Seal feature-parity roadmap: configurable post-processing via a
shared DownloadOptions model (src/shared/ipc.ts), editable as persisted defaults
in Settings and overridable per-download in the download bar.

- audio formats (mp3/m4a/opus/flac/wav/aac), video container (mp4/mkv/webm),
  preferred-codec sort (-S vcodec)
- subtitles (download/embed/langs/auto), SponsorBlock (remove/mark + categories
  + force-keyframes-at-cuts), embed chapters/metadata/thumbnail, square-crop
  audio artwork
- playlist downloads: probe via `-J --flat-playlist`, inline selection UI
  (checkbox list, select-all/none, live count); each entry enqueued as its own
  single-video download, reusing the existing queue/concurrency/history
- reusable DownloadOptionsForm shared by Settings and the download bar so the
  defaults and per-download override never drift
- ROADMAP.md tracking full Seal parity (phases A-E)

Also includes in-tree security hardening that landed alongside: preload sandbox
(CJS preload output), http(s)-only window-open + blocked renderer navigation,
argv-injection guard for URLs (src/main/url.ts), extension-allowlisted file
open/reveal (src/main/reveal.ts), yt-dlp version-check timeout, and a minimal
window.electron presence marker.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 19:32:59 -04:00
parent d4b9a0eefa
commit bb5dd6c438
21 changed files with 1101 additions and 91 deletions
+102
View File
@@ -0,0 +1,102 @@
# AeroFetch Roadmap — feature parity with Seal
AeroFetch is the Windows/Electron counterpart to [Seal](https://github.com/JunkFood02/Seal),
the Android Material-You frontend for [yt-dlp](https://github.com/yt-dlp/yt-dlp). The core
loop (probe → format pick → queue → download → history) is already in place. This document
tracks the remaining **gap**: everything Seal does that AeroFetch doesn't yet.
Sources: [Seal GitHub](https://github.com/JunkFood02/Seal) ·
[Seal on F-Droid](https://f-droid.org/packages/com.junkfood.seal/) ·
[It's FOSS overview](https://itsfoss.com/news/seal/).
---
## Already at parity ✅
Video + audio download · audio → mp3 with embedded thumbnail/metadata · interactive format
picker (probe) · quality presets · queue + concurrency cap + cancel · download history
(open / show-in-folder) · clipboard auto-detect · light/dark theme · output folder +
filename template · yt-dlp version check · NSIS installer + portable build.
---
## Phase A — Core download power ✅ COMPLETE
A shared `DownloadOptions` model (`src/shared/ipc.ts`) carries the post-processing
choices end-to-end. Persisted defaults are editable in **Settings → Format &
post-processing** via a reusable `DownloadOptionsForm` component, which the download
bar also hosts in a collapsible per-download **Options** panel. The main process emits
the flags in `buildArgs` (`src/main/download.ts`). All items verified in the UI preview
(typecheck clean). *Caveat: the option flags themselves were validated by reasoning +
typecheck, not yet by live yt-dlp downloads — worth a real-download smoke test pass.*
- [x] **Playlist downloads.** Probe now uses `yt-dlp -J --flat-playlist` and returns either
a single video (with formats) or a flat playlist. The download bar shows a selection
panel (scrollable checkbox list, select-all/none, live count) and enqueues each chosen
entry as its own single-video download — reusing the existing queue/concurrency/history.
- [x] **Subtitles.** Download + embed (`--write-subs`, `--write-auto-subs`, `--sub-langs`,
`--embed-subs`, `--convert-subs srt`); language field + auto-caption toggle.
- [x] **SponsorBlock.** `--sponsorblock-mark` / `--sponsorblock-remove` with a category
multi-select (sponsor, intro, outro, selfpromo, music_offtopic…) + `--force-keyframes-at-cuts`.
- [x] **More audio formats.** Was mp3-only. Now best / mp3 / m4a / opus / flac / wav / aac
via `--audio-format`.
- [x] **Video container + codec preference.** Container choice (mp4/mkv/webm) +
preferred codec (`-S res,fps,vcodec:…`) to nudge toward av1 / vp9 / h264 without
overriding the requested resolution.
- [x] **Embed chapters.** `--embed-chapters` toggle. (`--split-chapters` still TODO.)
- [x] **Embed thumbnail crop-to-square** for audio (Seal's "crop artwork") via thumbnail
post-processor args. *Implemented; needs a real-download smoke test of the ffmpeg crop recipe.*
- [x] **Per-download overrides.** Collapsible Options panel in the download bar (shared
`DownloadOptionsForm`) so one download — or one playlist batch — can deviate from the
persisted defaults; "Reset to defaults" clears it. Threads `options` through
`addFromUrl → DownloadItem → startDownload` and is carried on retry.
## Phase B — Access & networking
- [ ] **Cookies.** `--cookies-from-browser <chrome|edge|firefox…>` (easy Windows win) **and**
a built-in login window (Electron `BrowserWindow` + session cookie export →
`--cookies file.txt`) — Seal's WebView cookie feature, fully doable in Electron.
- [ ] **aria2c external downloader.** Bundle `aria2c.exe`, `--downloader aria2c
--downloader-args`, toggle in settings.
- [ ] **Proxy** (`--proxy`) and **rate limit** (`--limit-rate`).
- [ ] **Restrict filenames** toggle (`--restrict-filenames`) + optional **download archive**
(`--download-archive`, skip already-downloaded).
## Phase C — Custom commands & yt-dlp management
- [ ] **Custom command templates.** Named, editable templates of extra yt-dlp args; pick a
default; a "run custom command" mode. Needs persistence + a template editor view.
- [ ] **Command preview.** Show the exact yt-dlp command line before running (build the argv
and render it).
- [ ] **In-app yt-dlp updater** with stable/nightly channel (`--update-to`, or fetch the
release exe), surfaced next to the existing version check.
## Phase D — Library, UX & notifications
- [ ] **History search + multi-select + filter** (video/audio) + bulk delete + **re-download**.
Schema already exists in `src/shared/ipc.ts`; needs UI + likely the planned move to
better-sqlite3.
- [ ] **Native OS notifications** on completion / failure (Electron `Notification`).
- [ ] **Private / incognito mode** — a download that skips history.
- [ ] **Backup / restore** settings + templates (export / import JSON).
- [ ] **Error log / copy error report** view (Seal's debug report).
## Phase E — Theming & platform polish
Some items are Android-specific in Seal and adapted to Windows here.
- [ ] **Theme presets + "follow system"** and high-contrast (toffee light/dark already exists;
add system-follow + a few accent presets). *Material-You dynamic color is Android-only;
closest Windows analog is accent presets / system accent.*
- [ ] **i18n / language setting** (Seal ships ~40 languages). Large but optional.
- [ ] **Windows "open with" / share integration** — register a URL-protocol handler or Explorer
"Send to" entry (analog of Android's share sheet). *Lower priority, platform-specific.*
- [ ] **Onboarding / welcome screen** on first run.
---
## Not portable from Seal (noted for completeness)
- SAF directory picker (Android storage) → already covered by the native folder dialog.
- Android share-sheet / quick-download-on-share → partially mapped to Phase E.
- F-Droid distribution → N/A (AeroFetch ships NSIS + portable).
+10
View File
@@ -17,6 +17,16 @@ export default defineConfig({
alias: { alias: {
'@shared': resolve('src/shared') '@shared': resolve('src/shared')
} }
},
// Emit CommonJS (.cjs): sandboxed preload scripts must be CJS, and enabling
// the sandbox (webPreferences.sandbox) is part of the security hardening.
build: {
rollupOptions: {
output: {
format: 'cjs',
entryFileNames: 'index.cjs'
}
}
} }
}, },
renderer: { renderer: {
-10
View File
@@ -8,7 +8,6 @@
"name": "aerofetch", "name": "aerofetch",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0", "@electron-toolkit/utils": "^4.0.0",
"@fluentui/react-components": "^9.74.1", "@fluentui/react-components": "^9.74.1",
"@fluentui/react-icons": "^2.0.330", "@fluentui/react-icons": "^2.0.330",
@@ -375,15 +374,6 @@
"node": ">=22.12.0" "node": ">=22.12.0"
} }
}, },
"node_modules/@electron-toolkit/preload": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@electron-toolkit/preload/-/preload-3.0.2.tgz",
"integrity": "sha512-TWWPToXd8qPRfSXwzf5KVhpXMfONaUuRAZJHsKthKgZR/+LqX1dZVSSClQ8OTAEduvLGdecljCsoT2jSshfoUg==",
"license": "MIT",
"peerDependencies": {
"electron": ">=13.0.0"
}
},
"node_modules/@electron-toolkit/tsconfig": { "node_modules/@electron-toolkit/tsconfig": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/@electron-toolkit/tsconfig/-/tsconfig-2.0.0.tgz", "resolved": "https://registry.npmjs.org/@electron-toolkit/tsconfig/-/tsconfig-2.0.0.tgz",
-1
View File
@@ -21,7 +21,6 @@
"typecheck": "npm run typecheck:node && npm run typecheck:web" "typecheck": "npm run typecheck:node && npm run typecheck:web"
}, },
"dependencies": { "dependencies": {
"@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0", "@electron-toolkit/utils": "^4.0.0",
"@fluentui/react-components": "^9.74.1", "@fluentui/react-components": "^9.74.1",
"@fluentui/react-icons": "^2.0.330", "@fluentui/react-icons": "^2.0.330",
+80 -15
View File
@@ -4,6 +4,7 @@ import { join } from 'path'
import { app, type WebContents } from 'electron' import { app, type WebContents } from 'electron'
import { getYtdlpPath, getBinDir } from './binaries' import { getYtdlpPath, getBinDir } from './binaries'
import { getSettings } from './settings' import { getSettings } from './settings'
import { assertHttpUrl } from './url'
import { import {
IpcChannels, IpcChannels,
BEST_FORMAT_ID, BEST_FORMAT_ID,
@@ -11,7 +12,8 @@ import {
type StartDownloadResult, type StartDownloadResult,
type DownloadEvent, type DownloadEvent,
type DownloadMeta, type DownloadMeta,
type DownloadProgress type DownloadProgress,
type DownloadOptions
} from '@shared/ipc' } from '@shared/ipc'
interface ActiveDownload { interface ActiveDownload {
@@ -75,7 +77,48 @@ const PROGRESS_TEMPLATE =
'%(progress.total_bytes)s|%(progress.total_bytes_estimate)s|' + '%(progress.total_bytes)s|%(progress.total_bytes_estimate)s|' +
'%(progress.speed)s|%(progress.eta)s' '%(progress.speed)s|%(progress.eta)s'
function buildArgs(opts: StartDownloadOptions, outputTemplate: string): string[] { // Crop embedded audio artwork to a centred square. The double-quote/single-quote
// dance is deliberate: yt-dlp shlex-splits the `--ppa` value, consuming the outer
// double quotes and leaving ffmpeg single-quoted crop expressions whose commas are
// protected from ffmpeg's filtergraph separator. Side = min(width, height).
const CROP_SQUARE_PPA =
'EmbedThumbnail+ffmpeg_o:-c:v mjpeg -vf ' +
'crop="\'if(gt(ih,iw),iw,ih)\':\'if(gt(iw,ih),ih,iw)\'"'
/** Subtitle / SponsorBlock / chapter / metadata flags shared by both kinds. */
function postProcessArgs(opts: StartDownloadOptions, o: DownloadOptions): string[] {
const args: string[] = []
// Subtitles — video only; audio extraction has nowhere to embed them.
if (opts.kind === 'video' && o.embedSubtitles) {
args.push('--write-subs', '--embed-subs', '--sub-langs', o.subtitleLanguages || 'en')
if (o.autoSubtitles) args.push('--write-auto-subs')
// Normalise to srt first so embedding works across containers (mp4 → mov_text).
args.push('--convert-subs', 'srt')
}
// SponsorBlock.
if (o.sponsorBlock && o.sponsorBlockCategories.length > 0) {
const cats = o.sponsorBlockCategories.join(',')
if (o.sponsorBlockMode === 'remove') {
// Force keyframes at the cut points so segment boundaries are frame-accurate.
args.push('--sponsorblock-remove', cats, '--force-keyframes-at-cuts')
} else {
args.push('--sponsorblock-mark', cats)
}
}
if (o.embedChapters) args.push('--embed-chapters')
if (o.embedMetadata) args.push('--embed-metadata')
return args
}
function buildArgs(
opts: StartDownloadOptions,
outputTemplate: string,
o: DownloadOptions
): string[] {
const args = [ const args = [
'--newline', '--newline',
'--no-color', '--no-color',
@@ -95,21 +138,28 @@ function buildArgs(opts: StartDownloadOptions, outputTemplate: string): string[]
'--no-simulate' '--no-simulate'
] ]
args.push(...postProcessArgs(opts, o))
if (opts.kind === 'audio') { if (opts.kind === 'audio') {
args.push( args.push('-x', '--audio-format', o.audioFormat, '--audio-quality', audioQuality(opts.quality))
'-x', if (o.embedThumbnail) {
'--audio-format', args.push('--embed-thumbnail')
'mp3', if (o.cropThumbnail) args.push('--ppa', CROP_SQUARE_PPA)
'--audio-quality', }
audioQuality(opts.quality),
'--embed-thumbnail',
'--embed-metadata'
)
} else { } else {
args.push('-f', videoSelector(opts), '--merge-output-format', 'mp4') args.push('-f', videoSelector(opts), '--merge-output-format', o.videoContainer)
// Codec preference is a sort tiebreaker AFTER resolution/fps, so it nudges the
// pick toward the chosen codec without overriding the requested quality.
if (o.preferredVideoCodec !== 'any') {
const token = o.preferredVideoCodec === 'av1' ? 'av01' : o.preferredVideoCodec
args.push('-S', `res,fps,vcodec:${token}`)
}
// mp4/mkv carry a cover image; webm has no reliable way to embed one.
if (o.embedThumbnail && o.videoContainer !== 'webm') args.push('--embed-thumbnail')
} }
args.push(opts.url) // `--` terminates option parsing so the URL can never be read as a flag.
args.push('--', opts.url)
return args return args
} }
@@ -193,9 +243,10 @@ function probeMeta(ytdlp: string, url: string): Promise<DownloadMeta | null> {
'uploader', 'uploader',
'--print', '--print',
'duration_string', 'duration_string',
'--',
url url
], ],
{ windowsHide: true, maxBuffer: 4 * 1024 * 1024 }, { windowsHide: true, maxBuffer: 4 * 1024 * 1024, timeout: 30_000 },
(err, stdout) => { (err, stdout) => {
if (err) return resolve(null) if (err) return resolve(null)
const [title, uploader, duration] = stdout.split('\n').map((l) => l.trim()) const [title, uploader, duration] = stdout.split('\n').map((l) => l.trim())
@@ -224,6 +275,12 @@ export function startDownload(
error: `yt-dlp.exe not found at ${ytdlp}\nDrop it into resources/bin/ (see the README there).` 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)) { if (active.has(opts.id)) {
return { ok: false, error: 'A download with this id is already running.' } return { ok: false, error: 'A download with this id is already running.' }
} }
@@ -231,10 +288,12 @@ export function startDownload(
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'
// Per-download override wins; otherwise use the persisted defaults.
const options = opts.options ?? settings.downloadOptions
let child: ChildProcess let child: ChildProcess
try { try {
child = spawn(ytdlp, buildArgs(opts, join(outDir, template)), { windowsHide: true }) child = spawn(ytdlp, buildArgs(opts, join(outDir, template), options), { windowsHide: true })
} catch (e) { } catch (e) {
return { ok: false, error: (e as Error).message } return { ok: false, error: (e as Error).message }
} }
@@ -250,6 +309,8 @@ export function startDownload(
let stdoutBuf = '' let stdoutBuf = ''
let stderrTail = '' let stderrTail = ''
let filePath: string | undefined let filePath: string | undefined
// 'error' and 'close' can both fire for one process; only act on the first.
let settled = false
child.stdout?.on('data', (chunk: Buffer) => { child.stdout?.on('data', (chunk: Buffer) => {
stdoutBuf += chunk.toString() stdoutBuf += chunk.toString()
@@ -271,11 +332,15 @@ export function startDownload(
}) })
child.on('error', (err) => { child.on('error', (err) => {
if (settled) return
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 })
}) })
child.on('close', (code) => { child.on('close', (code) => {
if (settled) return
settled = true
active.delete(opts.id) active.delete(opts.id)
if (rec.canceled) return // renderer already showed 'canceled' optimistically if (rec.canceled) return // renderer already showed 'canceled' optimistically
if (code === 0) { if (code === 0) {
+20 -8
View File
@@ -13,6 +13,7 @@ import { startDownload, cancelDownload } from './download'
import { getSettings, setSettings } from './settings' import { getSettings, setSettings } from './settings'
import { listHistory, addHistory, removeHistory, clearHistory } from './history' import { listHistory, addHistory, removeHistory, clearHistory } from './history'
import { setupPortableData } from './portable' import { setupPortableData } from './portable'
import { safeOpenPath, safeShowInFolder } from './reveal'
// 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 —
@@ -45,8 +46,8 @@ function createWindow(): void {
backgroundColor: THEME_BACKGROUND[getSettings().theme], backgroundColor: THEME_BACKGROUND[getSettings().theme],
autoHideMenuBar: true, autoHideMenuBar: true,
webPreferences: { webPreferences: {
preload: join(__dirname, '../preload/index.mjs'), preload: join(__dirname, '../preload/index.cjs'),
sandbox: false, sandbox: true,
contextIsolation: true, contextIsolation: true,
nodeIntegration: false nodeIntegration: false
} }
@@ -56,12 +57,22 @@ function createWindow(): void {
mainWindow.show() mainWindow.show()
}) })
// Open external links in the OS browser, never in-app. // Open external links in the OS browser, never in-app — and only http(s), so a
// file:// or custom-protocol URL can't be used to launch a local handler.
mainWindow.webContents.setWindowOpenHandler((details) => { mainWindow.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url) try {
const { protocol } = new URL(details.url)
if (protocol === 'http:' || protocol === 'https:') shell.openExternal(details.url)
} catch {
/* unparseable URL — ignore */
}
return { action: 'deny' } return { action: 'deny' }
}) })
// The app shell is local and self-contained — never let the renderer navigate
// away from it (defence in depth; HMR uses websockets, not navigation).
mainWindow.webContents.on('will-navigate', (e) => e.preventDefault())
if (is.dev && process.env['ELECTRON_RENDERER_URL']) { if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']) mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
} else { } else {
@@ -89,8 +100,8 @@ function registerIpcHandlers(): void {
return res.canceled || !res.filePaths[0] ? null : res.filePaths[0] return res.canceled || !res.filePaths[0] ? null : res.filePaths[0]
}) })
ipcMain.handle(IpcChannels.openPath, (_e, p: string) => shell.openPath(p)) ipcMain.handle(IpcChannels.openPath, (_e, p: string) => safeOpenPath(p))
ipcMain.handle(IpcChannels.showInFolder, (_e, p: string) => shell.showItemInFolder(p)) ipcMain.handle(IpcChannels.showInFolder, (_e, p: string) => safeShowInFolder(p))
ipcMain.handle(IpcChannels.clipboardRead, () => clipboard.readText()) ipcMain.handle(IpcChannels.clipboardRead, () => clipboard.readText())
@@ -98,9 +109,10 @@ function registerIpcHandlers(): void {
ipcMain.handle(IpcChannels.settingsSet, (e, partial: Partial<Settings>) => { ipcMain.handle(IpcChannels.settingsSet, (e, partial: Partial<Settings>) => {
const result = setSettings(partial) const result = setSettings(partial)
// Keep the window's native background in sync with the theme so a compositor // Keep the window's native background in sync with the theme so a compositor
// repaint never flashes a mismatched color behind an overlay. // repaint never flashes a mismatched color behind an overlay. Use the
// validated result, not the raw partial (which may hold a bogus value).
if (partial.theme) { if (partial.theme) {
BrowserWindow.fromWebContents(e.sender)?.setBackgroundColor(THEME_BACKGROUND[partial.theme]) BrowserWindow.fromWebContents(e.sender)?.setBackgroundColor(THEME_BACKGROUND[result.theme])
} }
return result return result
}) })
+95 -6
View File
@@ -2,7 +2,15 @@ import { execFile } from 'child_process'
import { existsSync } from 'fs' import { existsSync } from 'fs'
import { getYtdlpPath } from './binaries' import { getYtdlpPath } from './binaries'
import { fmtBytes } from './download' import { fmtBytes } from './download'
import { BEST_FORMAT_ID, type ProbeResult, type MediaInfo, type FormatOption } from '@shared/ipc' import { assertHttpUrl } from './url'
import {
BEST_FORMAT_ID,
type ProbeResult,
type MediaInfo,
type FormatOption,
type PlaylistInfo,
type PlaylistEntry
} from '@shared/ipc'
// The slice of `yt-dlp -J` output we actually read. // The slice of `yt-dlp -J` output we actually read.
interface RawFormat { interface RawFormat {
@@ -17,13 +25,25 @@ interface RawFormat {
filesize_approx?: number filesize_approx?: number
} }
interface RawEntry {
id?: string
title?: string
url?: string
webpage_url?: string
duration?: number
uploader?: string
channel?: string
}
interface RawInfo { interface RawInfo {
_type?: string
title?: string title?: string
uploader?: string uploader?: string
channel?: string channel?: string
duration_string?: string duration_string?: string
thumbnail?: string thumbnail?: string
formats?: RawFormat[] formats?: RawFormat[]
entries?: RawEntry[]
} }
/** Quality proxy for picking the best format at a given height. */ /** Quality proxy for picking the best format at a given height. */
@@ -71,6 +91,51 @@ function buildInfo(data: RawInfo): MediaInfo {
} }
} }
/** Seconds → 'M:SS' or 'H:MM:SS'. Flat playlist entries give duration as a number. */
function fmtDuration(sec?: number): string | undefined {
if (sec == null || !Number.isFinite(sec)) return undefined
const s = Math.max(0, Math.round(sec))
const h = Math.floor(s / 3600)
const m = Math.floor((s % 3600) / 60)
const r = s % 60
const mm = h ? String(m).padStart(2, '0') : String(m)
return `${h ? `${h}:` : ''}${mm}:${String(r).padStart(2, '0')}`
}
/**
* Resolve the URL we'll enqueue for a playlist entry. Prefer an explicit http(s)
* URL; fall back to a YouTube watch URL built from the id (the common case where
* flat entries carry only an id). Returns null when nothing usable is present.
*/
function entryUrl(e: RawEntry): string | null {
const cand = e.url || e.webpage_url
if (cand && /^https?:\/\//i.test(cand)) return cand
if (e.id) return `https://www.youtube.com/watch?v=${e.id}`
return null
}
function buildPlaylist(data: RawInfo): PlaylistInfo {
const entries: PlaylistEntry[] = []
;(data.entries ?? []).forEach((e, i) => {
const url = entryUrl(e)
if (!url) return // skip entries we can't turn into a downloadable URL
entries.push({
index: i + 1,
id: e.id || String(i + 1),
title: e.title || `Item ${i + 1}`,
url,
durationLabel: fmtDuration(e.duration),
uploader: e.uploader || e.channel || undefined
})
})
return {
title: data.title || 'Playlist',
uploader: data.uploader || data.channel || undefined,
count: entries.length,
entries
}
}
function cleanError(stderr: string): string { function cleanError(stderr: string): string {
const lines = stderr const lines = stderr
.split('\n') .split('\n')
@@ -80,7 +145,11 @@ function cleanError(stderr: string): string {
return (errLine ?? lines[lines.length - 1] ?? '').replace(/^error:\s*/i, '').trim() return (errLine ?? lines[lines.length - 1] ?? '').replace(/^error:\s*/i, '').trim()
} }
/** Probe a URL with `yt-dlp -J` and return metadata + available video formats. */ /**
* Probe a URL with `yt-dlp -J --flat-playlist`. Resolves to a single video (with
* its format list) or, when the URL is a playlist, the flat list of its entries.
* `--flat-playlist` is a no-op for a lone video, so one call covers both cases.
*/
export function probeMedia(url: string): Promise<ProbeResult> { export function probeMedia(url: string): Promise<ProbeResult> {
const ytdlp = getYtdlpPath() const ytdlp = getYtdlpPath()
if (!existsSync(ytdlp)) { if (!existsSync(ytdlp)) {
@@ -89,19 +158,39 @@ export function probeMedia(url: string): Promise<ProbeResult> {
error: `yt-dlp.exe not found at ${ytdlp}\nDrop it into resources/bin/ (see the README there).` error: `yt-dlp.exe not found at ${ytdlp}\nDrop it into resources/bin/ (see the README there).`
}) })
} }
try {
assertHttpUrl(url)
} catch (e) {
return Promise.resolve({ ok: false, error: (e as Error).message })
}
return new Promise((resolve) => { return new Promise((resolve) => {
execFile( execFile(
ytdlp, ytdlp,
['-J', '--no-playlist', '--no-warnings', url], // `--` terminates option parsing so the URL can never be read as a flag.
{ windowsHide: true, maxBuffer: 64 * 1024 * 1024 }, ['-J', '--flat-playlist', '--no-warnings', '--', url],
{ windowsHide: true, maxBuffer: 64 * 1024 * 1024, timeout: 60_000 },
(err, stdout, stderr) => { (err, stdout, stderr) => {
if (err) { if (err) {
resolve({ ok: false, error: cleanError(stderr) || err.message }) // execFile sets `killed` when it terminated the process on timeout.
const msg = (err as { killed?: boolean }).killed
? 'Timed out fetching video info. Check the link or your connection.'
: cleanError(stderr) || err.message
resolve({ ok: false, error: msg })
return return
} }
try { try {
resolve({ ok: true, info: buildInfo(JSON.parse(stdout) as RawInfo) }) const data = JSON.parse(stdout) as RawInfo
if (data._type === 'playlist' || Array.isArray(data.entries)) {
const playlist = buildPlaylist(data)
if (playlist.count === 0) {
resolve({ ok: false, error: 'This playlist has no downloadable entries.' })
return
}
resolve({ ok: true, kind: 'playlist', playlist })
} else {
resolve({ ok: true, kind: 'video', info: buildInfo(data) })
}
} catch { } catch {
resolve({ ok: false, error: 'Could not parse video info from yt-dlp.' }) resolve({ ok: false, error: 'Could not parse video info from yt-dlp.' })
} }
+42
View File
@@ -0,0 +1,42 @@
import { shell } from 'electron'
import { existsSync, statSync } from 'fs'
import { extname, isAbsolute } from 'path'
/**
* Open/reveal helpers used by the shell:* IPC handlers.
*
* The renderer supplies the path (it originates from yt-dlp's after-move print),
* but the IPC boundary must not trust it blindly: a compromised renderer could
* otherwise call openPath() on an arbitrary executable and have the OS run it.
* So openPath is confined to existing files with a known media extension
* never .exe/.bat/.ps1/etc.
*/
const OPENABLE_EXTENSIONS = new Set([
// video
'.mp4', '.mkv', '.webm', '.mov', '.avi', '.flv', '.ts', '.m4v', '.3gp', '.ogv',
// audio
'.mp3', '.m4a', '.opus', '.ogg', '.oga', '.aac', '.flac', '.wav', '.wma',
// subtitle sidecars (plain text — safe to open)
'.vtt', '.srt'
])
/** Open a downloaded media file with its default app. Returns '' on success,
* or an error string (matching shell.openPath's contract). */
export async function safeOpenPath(p: unknown): Promise<string> {
if (typeof p !== 'string' || !isAbsolute(p)) return 'Invalid path.'
if (!OPENABLE_EXTENSIONS.has(extname(p).toLowerCase())) {
return 'Refusing to open this file type.'
}
try {
if (!statSync(p).isFile()) return 'Not a file.'
} catch {
return 'File not found.'
}
return shell.openPath(p)
}
/** Reveal a path in the OS file manager. No-op for missing/invalid paths. */
export function safeShowInFolder(p: unknown): void {
if (typeof p !== 'string' || !isAbsolute(p) || !existsSync(p)) return
shell.showItemInFolder(p)
}
+84 -5
View File
@@ -1,6 +1,15 @@
import { app } from 'electron' import { app } from 'electron'
import Store from 'electron-store' import Store from 'electron-store'
import type { Settings } from '@shared/ipc' import {
AUDIO_FORMATS,
VIDEO_CONTAINERS,
VIDEO_CODECS,
SPONSORBLOCK_CATEGORIES,
DEFAULT_DOWNLOAD_OPTIONS,
type Settings,
type DownloadOptions,
type SponsorBlockCategory
} from '@shared/ipc'
const DEFAULTS: Settings = { const DEFAULTS: Settings = {
outputDir: '', // resolved to the OS Downloads folder on first read outputDir: '', // resolved to the OS Downloads folder on first read
@@ -10,7 +19,45 @@ const DEFAULTS: Settings = {
maxConcurrent: 2, maxConcurrent: 2,
filenameTemplate: '%(title)s.%(ext)s', filenameTemplate: '%(title)s.%(ext)s',
theme: 'light', theme: 'light',
clipboardWatch: true clipboardWatch: true,
downloadOptions: DEFAULT_DOWNLOAD_OPTIONS
}
// Coerce an untrusted partial into a valid DownloadOptions, falling back to the
// defaults for any missing/invalid field. Used both to migrate older settings
// files (which predate downloadOptions) and to validate renderer writes.
function sanitizeOptions(input: unknown): DownloadOptions {
const o = (input && typeof input === 'object' ? input : {}) as Partial<DownloadOptions>
const d = DEFAULT_DOWNLOAD_OPTIONS
const bool = (v: unknown, fallback: boolean): boolean =>
typeof v === 'boolean' ? v : fallback
const cats = Array.isArray(o.sponsorBlockCategories)
? (o.sponsorBlockCategories.filter((c) =>
(SPONSORBLOCK_CATEGORIES as readonly string[]).includes(c)
) as SponsorBlockCategory[])
: d.sponsorBlockCategories
return {
audioFormat: AUDIO_FORMATS.includes(o.audioFormat as never) ? o.audioFormat! : d.audioFormat,
videoContainer: VIDEO_CONTAINERS.includes(o.videoContainer as never)
? o.videoContainer!
: d.videoContainer,
preferredVideoCodec: VIDEO_CODECS.includes(o.preferredVideoCodec as never)
? o.preferredVideoCodec!
: d.preferredVideoCodec,
embedSubtitles: bool(o.embedSubtitles, d.embedSubtitles),
subtitleLanguages:
typeof o.subtitleLanguages === 'string' && o.subtitleLanguages.trim()
? o.subtitleLanguages.trim()
: d.subtitleLanguages,
autoSubtitles: bool(o.autoSubtitles, d.autoSubtitles),
sponsorBlock: bool(o.sponsorBlock, d.sponsorBlock),
sponsorBlockMode: o.sponsorBlockMode === 'mark' ? 'mark' : 'remove',
sponsorBlockCategories: cats,
embedChapters: bool(o.embedChapters, d.embedChapters),
embedMetadata: bool(o.embedMetadata, d.embedMetadata),
embedThumbnail: bool(o.embedThumbnail, d.embedThumbnail),
cropThumbnail: bool(o.cropThumbnail, d.cropThumbnail)
}
} }
// Constructed lazily — electron-store needs app paths, which exist only after // Constructed lazily — electron-store needs app paths, which exist only after
@@ -25,14 +72,46 @@ export function getSettings(): Settings {
const s = getStore() const s = getStore()
// Fill in the real Downloads path the first time, and persist it. // Fill in the real Downloads path the first time, and persist it.
if (!s.get('outputDir')) s.set('outputDir', app.getPath('downloads')) if (!s.get('outputDir')) s.set('outputDir', app.getPath('downloads'))
// Migrate settings files that predate downloadOptions (or hold a partial one).
s.set('downloadOptions', sanitizeOptions(s.get('downloadOptions')))
return s.store return s.store
} }
// Validate each key before persisting — the renderer is the only caller today,
// but settings flow into process spawning (maxConcurrent) and the native window
// background (theme), so an out-of-range or malformed value shouldn't get stored.
export function setSettings(partial: Partial<Settings>): Settings { export function setSettings(partial: Partial<Settings>): Settings {
const s = getStore() const s = getStore()
;(Object.keys(partial) as (keyof Settings)[]).forEach((key) => { for (const key of Object.keys(partial) as (keyof Settings)[]) {
const value = partial[key] const value = partial[key]
if (value !== undefined) s.set(key, value) if (value === undefined) continue
}) switch (key) {
case 'theme':
if (value === 'light' || value === 'dark') s.set('theme', value)
break
case 'defaultKind':
if (value === 'video' || value === 'audio') s.set('defaultKind', value)
break
case 'maxConcurrent': {
const n = Number(value)
if (Number.isFinite(n)) s.set('maxConcurrent', Math.min(5, Math.max(1, Math.round(n))))
break
}
case 'clipboardWatch':
if (typeof value === 'boolean') s.set('clipboardWatch', value)
break
case 'downloadOptions':
// Always store a fully-validated object; the renderer sends the whole
// group (it merges field changes locally before calling setSettings).
s.set('downloadOptions', sanitizeOptions(value))
break
case 'outputDir':
case 'defaultVideoQuality':
case 'defaultAudioQuality':
case 'filenameTemplate':
if (typeof value === 'string') s.set(key, value)
break
}
}
return getSettings() return getSettings()
} }
+26
View File
@@ -0,0 +1,26 @@
/**
* Validate that a string is an http(s) URL and return it trimmed.
*
* This is the front-line guard against yt-dlp *argument injection*: the URL is
* passed to yt-dlp as a positional argv element, so a value beginning with `-`
* (e.g. `--config-locations=…`, `--load-info-json=…`, `--exec`) would be parsed
* as an OPTION rather than a URL which can lead to arbitrary command
* execution. A string that `new URL()` accepts with an http/https protocol
* necessarily begins with its scheme, so it can never be read as an option.
* (Call sites also pass `--` before the positional URL as defence in depth.)
*
* Throws a user-friendly Error on anything that isn't an http(s) URL.
*/
export function assertHttpUrl(raw: string): string {
const trimmed = (raw ?? '').trim()
let u: URL
try {
u = new URL(trimmed)
} catch {
throw new Error('That doesnt look like a valid URL.')
}
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
throw new Error('Only http and https links are supported.')
}
return trimmed
}
+5 -2
View File
@@ -18,9 +18,12 @@ export function getYtdlpVersion(): Promise<YtdlpVersionResult> {
} }
return new Promise((resolve) => { return new Promise((resolve) => {
execFile(ytdlpPath, ['--version'], { windowsHide: true }, (err, stdout, stderr) => { execFile(ytdlpPath, ['--version'], { windowsHide: true, timeout: 15_000 }, (err, stdout, stderr) => {
if (err) { if (err) {
resolve({ ok: false, error: (stderr || err.message).trim() }) const msg = (err as { killed?: boolean }).killed
? 'Timed out running yt-dlp.'
: (stderr || err.message).trim()
resolve({ ok: false, error: msg })
return return
} }
resolve({ ok: true, version: stdout.trim() }) resolve({ ok: true, version: stdout.trim() })
+2 -3
View File
@@ -1,9 +1,8 @@
import { ElectronAPI } from '@electron-toolkit/preload' import type { Api, ElectronMarker } from './index'
import type { Api } from './index'
declare global { declare global {
interface Window { interface Window {
electron: ElectronAPI electron: ElectronMarker
api: Api api: Api
} }
} }
+8 -3
View File
@@ -1,5 +1,4 @@
import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron' import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron'
import { electronAPI } from '@electron-toolkit/preload'
import { import {
IpcChannels, IpcChannels,
type YtdlpVersionResult, type YtdlpVersionResult,
@@ -60,16 +59,22 @@ const api = {
export type Api = typeof api export type Api = typeof api
// A minimal presence marker. The renderer only checks `window.electron` for
// truthiness to tell the real app apart from the browser UI preview, so there's
// no reason to expose the full ipcRenderer/webFrame/process surface here.
const electron = { isElectron: true } as const
export type ElectronMarker = typeof electron
if (process.contextIsolated) { if (process.contextIsolated) {
try { try {
contextBridge.exposeInMainWorld('electron', electronAPI) contextBridge.exposeInMainWorld('electron', electron)
contextBridge.exposeInMainWorld('api', api) contextBridge.exposeInMainWorld('api', api)
} catch (error) { } catch (error) {
console.error(error) console.error(error)
} }
} else { } else {
// @ts-ignore (defined in index.d.ts) // @ts-ignore (defined in index.d.ts)
window.electron = electronAPI window.electron = electron
// @ts-ignore (defined in index.d.ts) // @ts-ignore (defined in index.d.ts)
window.api = api window.api = api
} }
+209 -17
View File
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react'
import { import {
Input, Input,
Button, Button,
Checkbox,
Spinner, Spinner,
Text, Text,
Caption1, Caption1,
@@ -19,13 +20,18 @@ import {
MusicNote2Regular, MusicNote2Regular,
ErrorCircleRegular, ErrorCircleRegular,
LinkRegular, LinkRegular,
DismissRegular DismissRegular,
OptionsRegular,
ChevronDownRegular,
ChevronUpRegular,
AppsListRegular
} from '@fluentui/react-icons' } from '@fluentui/react-icons'
import type { MediaInfo, FormatOption } from '@shared/ipc' import type { MediaInfo, FormatOption, PlaylistInfo, DownloadOptions } 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 { Select } from './Select' import { Select } from './Select'
import { Hint } from './Hint' import { Hint } from './Hint'
import { DownloadOptionsForm } from './DownloadOptionsForm'
/** A quick heuristic for "this clipboard text is a link worth offering". */ /** A quick heuristic for "this clipboard text is a link worth offering". */
function looksLikeUrl(text: string): boolean { function looksLikeUrl(text: string): boolean {
@@ -164,6 +170,66 @@ const useStyles = makeStyles({
spacer: { spacer: {
flexGrow: 1 flexGrow: 1
}, },
// --- per-download options panel ---
optionsBar: {
display: 'flex',
alignItems: 'center',
gap: '8px'
},
optionsPanel: {
padding: '14px',
backgroundColor: tokens.colorNeutralBackground2,
...shorthands.borderRadius(tokens.borderRadiusLarge),
border: `1px solid ${tokens.colorNeutralStroke2}`
},
// --- playlist selection ---
plPanel: {
display: 'flex',
flexDirection: 'column',
gap: '8px',
padding: '12px',
backgroundColor: tokens.colorNeutralBackground2,
...shorthands.borderRadius(tokens.borderRadiusLarge),
border: `1px solid ${tokens.colorNeutralStroke2}`
},
plHeader: {
display: 'flex',
alignItems: 'center',
gap: '8px'
},
plHeaderText: {
flexGrow: 1,
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontWeight: tokens.fontWeightSemibold
},
plList: {
display: 'flex',
flexDirection: 'column',
maxHeight: '260px',
overflowY: 'auto',
paddingRight: '4px'
},
plItem: {
display: 'flex',
alignItems: 'flex-start',
padding: '2px 0'
},
plItemLabel: {
display: 'flex',
flexDirection: 'column',
minWidth: 0
},
plItemTitle: {
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
},
plItemMeta: {
color: tokens.colorNeutralForeground3
},
folder: { folder: {
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
@@ -187,11 +253,17 @@ export function DownloadBar(): React.JSX.Element {
const defaultKind = useSettings((s) => s.defaultKind) const defaultKind = useSettings((s) => s.defaultKind)
const defaultVideoQuality = useSettings((s) => s.defaultVideoQuality) const defaultVideoQuality = useSettings((s) => s.defaultVideoQuality)
const defaultAudioQuality = useSettings((s) => s.defaultAudioQuality) const defaultAudioQuality = useSettings((s) => s.defaultAudioQuality)
const downloadOptions = useSettings((s) => s.downloadOptions)
const [url, setUrl] = useState('') const [url, setUrl] = useState('')
const [kind, setKind] = useState<MediaKind>('video') const [kind, setKind] = useState<MediaKind>('video')
const [quality, setQuality] = useState(QUALITY_OPTIONS.video[0]) const [quality, setQuality] = useState(QUALITY_OPTIONS.video[0])
// Per-download options override (null = use the persisted defaults).
const [override, setOverride] = useState<DownloadOptions | null>(null)
const [showOptions, setShowOptions] = useState(false)
const effectiveOptions = override ?? downloadOptions
// 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(() => {
@@ -202,12 +274,16 @@ export function DownloadBar(): React.JSX.Element {
} }
}, [settingsLoaded, defaultKind, defaultVideoQuality, defaultAudioQuality]) }, [settingsLoaded, defaultKind, defaultVideoQuality, defaultAudioQuality])
// Probe state for the format picker. // Probe state for the single-video format picker.
const [info, setInfo] = useState<MediaInfo | null>(null) const [info, setInfo] = useState<MediaInfo | null>(null)
const [probing, setProbing] = useState(false) const [probing, setProbing] = useState(false)
const [probeError, setProbeError] = useState<string | null>(null) const [probeError, setProbeError] = useState<string | null>(null)
const [formatId, setFormatId] = useState<string>('') const [formatId, setFormatId] = useState<string>('')
// Probe state for a playlist URL.
const [playlist, setPlaylist] = useState<PlaylistInfo | null>(null)
const [selected, setSelected] = useState<Set<number>>(new Set())
// Clipboard auto-detect: when the window gains focus and the clipboard holds a // Clipboard auto-detect: when the window gains focus and the clipboard holds a
// fresh link, offer it (without clobbering anything the user is already typing). // fresh link, offer it (without clobbering anything the user is already typing).
const [suggestion, setSuggestion] = useState<string | null>(null) const [suggestion, setSuggestion] = useState<string | null>(null)
@@ -255,14 +331,18 @@ export function DownloadBar(): React.JSX.Element {
? info.formats.find((f) => f.id === formatId) ?? info.formats[0] ? info.formats.find((f) => f.id === formatId) ?? info.formats[0]
: undefined : undefined
function onUrlChange(next: string): void { function clearProbe(): void {
setUrl(next)
// Any probed info is stale once the URL changes.
if (info || probeError) {
setInfo(null) setInfo(null)
setProbeError(null) setProbeError(null)
setFormatId('') setFormatId('')
setPlaylist(null)
setSelected(new Set())
} }
function onUrlChange(next: string): void {
setUrl(next)
// Any probed info is stale once the URL changes.
if (info || probeError || playlist) clearProbe()
} }
function onKindChange(next: MediaKind): void { function onKindChange(next: MediaKind): void {
@@ -276,9 +356,14 @@ export function DownloadBar(): React.JSX.Element {
setProbing(true) setProbing(true)
setProbeError(null) setProbeError(null)
setInfo(null) setInfo(null)
setPlaylist(null)
try { try {
const res = await window.api.probe(trimmed) const res = await window.api.probe(trimmed)
if (res.ok && res.info) { if (res.ok && res.kind === 'playlist' && res.playlist) {
setPlaylist(res.playlist)
// Pre-select every entry; the user can trim the list.
setSelected(new Set(res.playlist.entries.map((e) => e.index)))
} else if (res.ok && res.info) {
setInfo(res.info) setInfo(res.info)
setFormatId(res.info.formats[0]?.id ?? '') setFormatId(res.info.formats[0]?.id ?? '')
} else { } else {
@@ -300,6 +385,21 @@ export function DownloadBar(): React.JSX.Element {
} }
} }
// Selection helpers for the playlist panel.
const allSelected = playlist !== null && selected.size === playlist.entries.length
function toggleEntry(index: number, on: boolean): void {
setSelected((prev) => {
const next = new Set(prev)
if (on) next.add(index)
else next.delete(index)
return next
})
}
function toggleAll(): void {
if (!playlist) return
setSelected(allSelected ? new Set() : new Set(playlist.entries.map((e) => e.index)))
}
function download(): void { function download(): void {
const trimmed = url.trim() const trimmed = url.trim()
if (!trimmed) return if (!trimmed) return
@@ -320,16 +420,30 @@ export function DownloadBar(): React.JSX.Element {
id: selectedFormat.id, id: selectedFormat.id,
hasAudio: selectedFormat.hasAudio, hasAudio: selectedFormat.hasAudio,
label: selectedFormat.label label: selectedFormat.label
} },
options: override ?? undefined
}) })
} else { } else {
addFromUrl(trimmed, kind, quality, meta) addFromUrl(trimmed, kind, quality, { ...meta, options: override ?? undefined })
} }
setUrl('') setUrl('')
setInfo(null) clearProbe()
setProbeError(null) }
setFormatId('')
function addPlaylist(): void {
if (!playlist) return
const chosen = playlist.entries.filter((e) => selected.has(e.index))
for (const e of chosen) {
addFromUrl(e.url, kind, quality, {
title: e.title,
channel: e.uploader,
durationLabel: e.durationLabel,
options: override ?? undefined
})
}
setUrl('')
clearProbe()
} }
return ( return (
@@ -340,17 +454,17 @@ export function DownloadBar(): React.JSX.Element {
value={url} value={url}
onChange={(_, d) => onUrlChange(d.value)} onChange={(_, d) => onUrlChange(d.value)}
onKeyDown={(e) => e.key === 'Enter' && fetchFormats()} onKeyDown={(e) => e.key === 'Enter' && fetchFormats()}
placeholder="Paste a video URL, then fetch formats…" placeholder="Paste a video or playlist URL, then fetch…"
size="large" size="large"
contentBefore={<ArrowDownloadRegular />} contentBefore={<ArrowDownloadRegular />}
/> />
<Hint label="Fetch available formats" placement="bottom" align="end"> <Hint label="Fetch formats / playlist" placement="bottom" align="end">
<Button <Button
size="large" size="large"
icon={probing ? <Spinner size="tiny" /> : <SearchRegular />} icon={probing ? <Spinner size="tiny" /> : <SearchRegular />}
onClick={fetchFormats} onClick={fetchFormats}
disabled={!url.trim() || probing} disabled={!url.trim() || probing}
aria-label="Fetch available formats" aria-label="Fetch formats or playlist"
/> />
</Hint> </Hint>
<Hint label="Paste from clipboard" placement="bottom" align="end"> <Hint label="Paste from clipboard" placement="bottom" align="end">
@@ -383,7 +497,7 @@ export function DownloadBar(): React.JSX.Element {
{probing && ( {probing && (
<div className={styles.statusRow}> <div className={styles.statusRow}>
<Spinner size="tiny" /> <Spinner size="tiny" />
<Caption1>Fetching available formats</Caption1> <Caption1>Fetching</Caption1>
</div> </div>
)} )}
@@ -416,6 +530,46 @@ export function DownloadBar(): React.JSX.Element {
</div> </div>
)} )}
{playlist && (
<div className={styles.plPanel}>
<div className={styles.plHeader}>
<AppsListRegular />
<Caption1 className={styles.plHeaderText}>
{playlist.title}
{playlist.uploader ? `${playlist.uploader}` : ''}
</Caption1>
<Caption1 className={styles.previewMeta}>
{selected.size} of {playlist.count} selected
</Caption1>
<Button size="small" appearance="subtle" onClick={toggleAll}>
{allSelected ? 'Select none' : 'Select all'}
</Button>
</div>
<div className={styles.plList}>
{playlist.entries.map((e) => (
<Checkbox
key={e.index}
className={styles.plItem}
checked={selected.has(e.index)}
onChange={(_, d) => toggleEntry(e.index, !!d.checked)}
label={
<span className={styles.plItemLabel}>
<span className={styles.plItemTitle}>
{e.index}. {e.title}
</span>
{(e.durationLabel || e.uploader) && (
<Caption1 className={styles.plItemMeta}>
{[e.durationLabel, e.uploader].filter(Boolean).join(' • ')}
</Caption1>
)}
</span>
}
/>
))}
</div>
</div>
)}
<div className={styles.controls}> <div className={styles.controls}>
<div className={styles.control}> <div className={styles.control}>
<Caption1>Format</Caption1> <Caption1>Format</Caption1>
@@ -458,6 +612,17 @@ export function DownloadBar(): React.JSX.Element {
<div className={styles.spacer} /> <div className={styles.spacer} />
{playlist ? (
<Button
appearance="primary"
size="large"
icon={<ArrowDownloadRegular />}
onClick={addPlaylist}
disabled={selected.size === 0}
>
Add {selected.size} to queue
</Button>
) : (
<Button <Button
appearance="primary" appearance="primary"
size="large" size="large"
@@ -467,8 +632,35 @@ export function DownloadBar(): React.JSX.Element {
> >
Download Download
</Button> </Button>
)}
</div> </div>
<div className={styles.optionsBar}>
<Button
appearance="subtle"
size="small"
icon={<OptionsRegular />}
iconPosition="before"
onClick={() => setShowOptions((v) => !v)}
>
Options {showOptions ? <ChevronUpRegular /> : <ChevronDownRegular />}
</Button>
{override && (
<>
<Caption1 className={styles.previewMeta}>Customised for this download</Caption1>
<Button appearance="subtle" size="small" onClick={() => setOverride(null)}>
Reset to defaults
</Button>
</>
)}
</div>
{showOptions && (
<div className={styles.optionsPanel}>
<DownloadOptionsForm value={effectiveOptions} onChange={(o) => setOverride(o)} />
</div>
)}
<div className={styles.folder}> <div className={styles.folder}>
<FolderRegular /> <FolderRegular />
<Caption1 className={styles.folderPath} title={folder}> <Caption1 className={styles.folderPath} title={folder}>
@@ -0,0 +1,235 @@
import {
Field,
Input,
Switch,
Checkbox,
Caption1,
makeStyles,
tokens
} from '@fluentui/react-components'
import {
AUDIO_FORMATS,
VIDEO_CONTAINERS,
VIDEO_CODECS,
SPONSORBLOCK_CATEGORIES,
type AudioFormat,
type VideoContainer,
type VideoCodecPref,
type SponsorBlockMode,
type SponsorBlockCategory,
type DownloadOptions
} from '@shared/ipc'
import { Select } from './Select'
// Human labels for the format/category enums.
const AUDIO_FORMAT_LABELS: Record<AudioFormat, string> = {
best: 'Best (keep source)',
mp3: 'MP3',
m4a: 'M4A (AAC)',
opus: 'Opus',
flac: 'FLAC (lossless)',
wav: 'WAV (lossless)',
aac: 'AAC'
}
const VIDEO_CONTAINER_LABELS: Record<VideoContainer, string> = {
mp4: 'MP4',
mkv: 'MKV',
webm: 'WebM'
}
const VIDEO_CODEC_LABELS: Record<VideoCodecPref, string> = {
any: 'No preference',
h264: 'H.264 (most compatible)',
vp9: 'VP9',
av1: 'AV1 (smallest)'
}
const SPONSORBLOCK_LABELS: Record<SponsorBlockCategory, string> = {
sponsor: 'Sponsor',
intro: 'Intro / intermission',
outro: 'Endcards / credits',
selfpromo: 'Self-promotion',
preview: 'Preview / recap',
filler: 'Filler / tangent',
interaction: 'Interaction reminder',
music_offtopic: 'Non-music section'
}
const useStyles = makeStyles({
root: {
display: 'flex',
flexDirection: 'column',
gap: '16px'
},
grid: {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(170px, 1fr))',
gap: '16px'
},
// Indented reveal for options that only matter when their parent is enabled.
subGroup: {
display: 'flex',
flexDirection: 'column',
gap: '12px',
paddingLeft: '14px',
borderLeft: `2px solid ${tokens.colorNeutralStroke2}`
},
categoryGrid: {
display: 'grid',
gridTemplateColumns: '1fr 1fr',
columnGap: '16px',
rowGap: '2px'
}
})
interface Props {
value: DownloadOptions
onChange: (next: DownloadOptions) => void
}
/**
* The full set of post-processing controls, driven by a DownloadOptions value.
* Used both for the persisted defaults (Settings) and for a per-download
* override (the download bar), so the two never drift apart.
*/
export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Element {
const styles = useStyles()
function setOpt<K extends keyof DownloadOptions>(key: K, v: DownloadOptions[K]): void {
onChange({ ...value, [key]: v })
}
function toggleCategory(cat: SponsorBlockCategory, on: boolean): void {
const next = on
? [...value.sponsorBlockCategories, cat]
: value.sponsorBlockCategories.filter((c) => c !== cat)
setOpt('sponsorBlockCategories', next)
}
return (
<div className={styles.root}>
<div className={styles.grid}>
<Field label="Audio format" hint="For audio-only downloads.">
<Select
aria-label="Audio format"
value={value.audioFormat}
options={AUDIO_FORMATS.map((f) => ({ value: f, label: AUDIO_FORMAT_LABELS[f] }))}
onChange={(v) => setOpt('audioFormat', v as AudioFormat)}
/>
</Field>
<Field label="Video container" hint="Container for merged video.">
<Select
aria-label="Video container"
value={value.videoContainer}
options={VIDEO_CONTAINERS.map((c) => ({ value: c, label: VIDEO_CONTAINER_LABELS[c] }))}
onChange={(v) => setOpt('videoContainer', v as VideoContainer)}
/>
</Field>
<Field label="Preferred codec" hint="Tiebreaker, not a hard filter.">
<Select
aria-label="Preferred video codec"
value={value.preferredVideoCodec}
options={VIDEO_CODECS.map((c) => ({ value: c, label: VIDEO_CODEC_LABELS[c] }))}
onChange={(v) => setOpt('preferredVideoCodec', v as VideoCodecPref)}
/>
</Field>
</div>
<Field label="Embed subtitles" hint="Download subtitles and mux them into the video.">
<Switch
checked={value.embedSubtitles}
onChange={(_, d) => setOpt('embedSubtitles', d.checked)}
label={value.embedSubtitles ? 'On' : 'Off'}
/>
</Field>
{value.embedSubtitles && (
<div className={styles.subGroup}>
<Field
label="Subtitle languages"
hint="Comma-separated, e.g. en, es. Use a pattern like en.* to include regional variants."
>
<Input
value={value.subtitleLanguages}
onChange={(_, d) => setOpt('subtitleLanguages', d.value)}
/>
</Field>
<Checkbox
checked={value.autoSubtitles}
onChange={(_, d) => setOpt('autoSubtitles', !!d.checked)}
label="Also include auto-generated captions"
/>
</div>
)}
<Field
label="SponsorBlock"
hint="Skip or label sponsor and other segments using the SponsorBlock database."
>
<Switch
checked={value.sponsorBlock}
onChange={(_, d) => setOpt('sponsorBlock', d.checked)}
label={value.sponsorBlock ? 'On' : 'Off'}
/>
</Field>
{value.sponsorBlock && (
<div className={styles.subGroup}>
<Field label="Action">
<Select
aria-label="SponsorBlock action"
value={value.sponsorBlockMode}
options={[
{ value: 'remove', label: 'Remove segments (cut from file)' },
{ value: 'mark', label: 'Mark as chapters (keep, but labelled)' }
]}
onChange={(v) => setOpt('sponsorBlockMode', v as SponsorBlockMode)}
/>
</Field>
<Field label="Categories">
<div className={styles.categoryGrid}>
{SPONSORBLOCK_CATEGORIES.map((cat) => (
<Checkbox
key={cat}
checked={value.sponsorBlockCategories.includes(cat)}
onChange={(_, d) => toggleCategory(cat, !!d.checked)}
label={SPONSORBLOCK_LABELS[cat]}
/>
))}
</div>
</Field>
</div>
)}
<Field label="Embed chapters">
<Switch
checked={value.embedChapters}
onChange={(_, d) => setOpt('embedChapters', d.checked)}
label={value.embedChapters ? 'On' : 'Off'}
/>
</Field>
<Field label="Embed metadata" hint="Title, artist, date and similar tags.">
<Switch
checked={value.embedMetadata}
onChange={(_, d) => setOpt('embedMetadata', d.checked)}
label={value.embedMetadata ? 'On' : 'Off'}
/>
</Field>
<Field label="Embed thumbnail" hint="Cover art for audio; poster frame for MP4/MKV.">
<Switch
checked={value.embedThumbnail}
onChange={(_, d) => setOpt('embedThumbnail', d.checked)}
label={value.embedThumbnail ? 'On' : 'Off'}
/>
</Field>
{value.embedThumbnail && (
<div className={styles.subGroup}>
<Checkbox
checked={value.cropThumbnail}
onChange={(_, d) => setOpt('cropThumbnail', !!d.checked)}
label="Crop audio artwork to a square"
/>
<Caption1 style={{ color: tokens.colorNeutralForeground3 }}>
Squares the embedded cover for audio files.
</Caption1>
</div>
)}
</div>
)
}
+2 -2
View File
@@ -21,11 +21,11 @@ const useStyles = makeStyles({
...shorthands.borderRadius(tokens.borderRadiusMedium), ...shorthands.borderRadius(tokens.borderRadiusMedium),
cursor: 'pointer', cursor: 'pointer',
':hover': { ':hover': {
borderColor: tokens.colorNeutralStroke1Hover ...shorthands.borderColor(tokens.colorNeutralStroke1Hover)
}, },
':focus-visible': { ':focus-visible': {
outline: 'none', outline: 'none',
borderColor: tokens.colorCompoundBrandStroke ...shorthands.borderColor(tokens.colorCompoundBrandStroke)
} }
} }
}) })
+19 -1
View File
@@ -18,12 +18,14 @@ import {
FolderRegular, FolderRegular,
ArrowDownloadRegular, ArrowDownloadRegular,
DocumentRegular, DocumentRegular,
OptionsRegular,
InfoRegular InfoRegular
} from '@fluentui/react-icons' } from '@fluentui/react-icons'
import type { YtdlpVersionResult, MediaKind } from '@shared/ipc' import { type YtdlpVersionResult, type MediaKind } 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 { Select } from './Select' import { Select } from './Select'
import { DownloadOptionsForm } from './DownloadOptionsForm'
const useStyles = makeStyles({ const useStyles = makeStyles({
root: { root: {
@@ -78,6 +80,7 @@ export function SettingsView(): React.JSX.Element {
const maxConcurrent = useSettings((s) => s.maxConcurrent) const maxConcurrent = useSettings((s) => s.maxConcurrent)
const filenameTemplate = useSettings((s) => s.filenameTemplate) const filenameTemplate = useSettings((s) => s.filenameTemplate)
const clipboardWatch = useSettings((s) => s.clipboardWatch) const clipboardWatch = useSettings((s) => s.clipboardWatch)
const downloadOptions = useSettings((s) => s.downloadOptions)
const update = useSettings((s) => s.update) const update = useSettings((s) => s.update)
const formatQuality = defaultKind === 'audio' ? defaultAudioQuality : defaultVideoQuality const formatQuality = defaultKind === 'audio' ? defaultAudioQuality : defaultVideoQuality
@@ -169,6 +172,21 @@ export function SettingsView(): React.JSX.Element {
</Field> </Field>
</Card> </Card>
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<OptionsRegular className={styles.sectionIcon} />
<Subtitle2>Format &amp; post-processing</Subtitle2>
</div>
<Caption1 className={styles.hint}>
Defaults applied to every new download (yt-dlp and ffmpeg do the work).
</Caption1>
<DownloadOptionsForm
value={downloadOptions}
onChange={(o) => update({ downloadOptions: o })}
/>
</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} />
+26 -2
View File
@@ -1,6 +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 } 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
@@ -11,10 +12,31 @@ import App from './App'
if (import.meta.env.DEV && !window.api) { if (import.meta.env.DEV && !window.api) {
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)' }),
probe: async (_url: string) => { probe: async (url: string) => {
await new Promise((r) => setTimeout(r, 700)) // simulate network latency await new Promise((r) => setTimeout(r, 700)) // simulate network latency
// A 'list'/'playlist' URL exercises the playlist selection UI in preview.
if (/list=|playlist/i.test(url)) {
return { return {
ok: true, ok: true,
kind: 'playlist',
playlist: {
title: 'Full Electron Course — All Episodes',
uploader: 'DevChannel',
count: 5,
entries: Array.from({ length: 5 }, (_, i) => ({
index: i + 1,
id: `vid${i + 1}`,
title: `Episode ${i + 1}${['Setup', 'Main Process', 'IPC', 'Packaging', 'Release'][i]}`,
url: `https://youtube.com/watch?v=vid${i + 1}`,
durationLabel: `${10 + i}:0${i}`,
uploader: 'DevChannel'
}))
}
}
}
return {
ok: true,
kind: 'video',
info: { info: {
title: 'Building a Desktop App with Electron — Full Course', title: 'Building a Desktop App with Electron — Full Course',
channel: 'DevChannel', channel: 'DevChannel',
@@ -45,7 +67,8 @@ if (import.meta.env.DEV && !window.api) {
maxConcurrent: 2, maxConcurrent: 2,
filenameTemplate: '%(title)s.%(ext)s', filenameTemplate: '%(title)s.%(ext)s',
theme: 'light', theme: 'light',
clipboardWatch: true clipboardWatch: true,
downloadOptions: DEFAULT_DOWNLOAD_OPTIONS
}), }),
setSettings: async (partial) => ({ setSettings: async (partial) => ({
outputDir: 'C:\\Users\\you\\Downloads', outputDir: 'C:\\Users\\you\\Downloads',
@@ -56,6 +79,7 @@ if (import.meta.env.DEV && !window.api) {
filenameTemplate: '%(title)s.%(ext)s', filenameTemplate: '%(title)s.%(ext)s',
theme: 'light', theme: 'light',
clipboardWatch: true, clipboardWatch: true,
downloadOptions: DEFAULT_DOWNLOAD_OPTIONS,
...partial ...partial
}), }),
listHistory: async () => [], listHistory: async () => [],
+15 -4
View File
@@ -1,5 +1,5 @@
import { create } from 'zustand' import { create } from 'zustand'
import type { DownloadEvent } from '@shared/ipc' import type { DownloadEvent, DownloadOptions } from '@shared/ipc'
import { useSettings } from './settings' import { useSettings } from './settings'
import { useHistory } from './history' import { useHistory } from './history'
@@ -34,6 +34,8 @@ export interface DownloadItem {
/** exact format carried so retry re-downloads the same thing */ /** exact format carried so retry re-downloads the same thing */
formatId?: string formatId?: string
formatHasAudio?: boolean formatHasAudio?: boolean
/** per-download post-processing override (omitted = use persisted defaults) */
options?: DownloadOptions
} }
export const QUALITY_OPTIONS: Record<MediaKind, string[]> = { export const QUALITY_OPTIONS: Record<MediaKind, string[]> = {
@@ -51,7 +53,14 @@ interface DownloadState {
url: string, url: string,
kind: MediaKind, kind: MediaKind,
quality: string, quality: string,
opts?: { format?: ChosenFormat; title?: string; channel?: string; durationLabel?: string; thumbnail?: string } opts?: {
format?: ChosenFormat
title?: string
channel?: string
durationLabel?: string
thumbnail?: string
options?: DownloadOptions
}
) => void ) => void
retry: (id: string) => void retry: (id: string) => void
cancel: (id: string) => void cancel: (id: string) => void
@@ -220,7 +229,8 @@ export const useDownloads = create<DownloadState>((set, get) => {
quality: item.quality, quality: item.quality,
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
}) })
.then((res) => { .then((res) => {
if (!res.ok) markError(item.id, res.error) if (!res.ok) markError(item.id, res.error)
@@ -265,7 +275,8 @@ export const useDownloads = create<DownloadState>((set, get) => {
status: 'queued', status: 'queued',
progress: 0, progress: 0,
formatId: opts?.format?.id, formatId: opts?.format?.id,
formatHasAudio: opts?.format?.hasAudio formatHasAudio: opts?.format?.hasAudio,
options: opts?.options
} }
set((s) => ({ items: [item, ...s.items] })) set((s) => ({ items: [item, ...s.items] }))
pump() pump()
+3 -2
View File
@@ -1,5 +1,5 @@
import { create } from 'zustand' import { create } from 'zustand'
import type { Settings } from '@shared/ipc' import { DEFAULT_DOWNLOAD_OPTIONS, type Settings } from '@shared/ipc'
// True in the standalone browser preview (no Electron preload). // True in the standalone browser preview (no Electron preload).
const PREVIEW = typeof window === 'undefined' || !window.electron const PREVIEW = typeof window === 'undefined' || !window.electron
@@ -12,7 +12,8 @@ const FALLBACK: Settings = {
maxConcurrent: 2, maxConcurrent: 2,
filenameTemplate: '%(title)s.%(ext)s', filenameTemplate: '%(title)s.%(ext)s',
theme: 'light', theme: 'light',
clipboardWatch: true clipboardWatch: true,
downloadOptions: DEFAULT_DOWNLOAD_OPTIONS
} }
interface SettingsState extends Settings { interface SettingsState extends Settings {
+108
View File
@@ -28,6 +28,84 @@ export const BEST_FORMAT_ID = '__best__'
export type MediaKind = 'video' | 'audio' export type MediaKind = 'video' | 'audio'
// --- Post-processing / format options ---------------------------------------
/** Audio extraction target. 'best' keeps the source codec without re-encoding. */
export type AudioFormat = 'best' | 'mp3' | 'm4a' | 'opus' | 'flac' | 'wav' | 'aac'
export const AUDIO_FORMATS: AudioFormat[] = ['best', 'mp3', 'm4a', 'opus', 'flac', 'wav', 'aac']
/** Container the merged video is muxed into (`--merge-output-format`). */
export type VideoContainer = 'mp4' | 'mkv' | 'webm'
export const VIDEO_CONTAINERS: VideoContainer[] = ['mp4', 'mkv', 'webm']
/** Preferred video codec, applied as a `-S vcodec:…` sort tiebreaker. */
export type VideoCodecPref = 'any' | 'h264' | 'vp9' | 'av1'
export const VIDEO_CODECS: VideoCodecPref[] = ['any', 'h264', 'vp9', 'av1']
/** Whether SponsorBlock cuts the segments out or just marks them as chapters. */
export type SponsorBlockMode = 'remove' | 'mark'
/** SponsorBlock segment categories (subset of yt-dlp's, the ones Seal exposes). */
export const SPONSORBLOCK_CATEGORIES = [
'sponsor',
'intro',
'outro',
'selfpromo',
'preview',
'filler',
'interaction',
'music_offtopic'
] as const
export type SponsorBlockCategory = (typeof SPONSORBLOCK_CATEGORIES)[number]
/**
* Post-processing / format choices applied to a download. The persisted
* defaults live in `Settings.downloadOptions`; an individual download may
* override them via `StartDownloadOptions.options`.
*/
export interface DownloadOptions {
/** audio extraction target (audio downloads only) */
audioFormat: AudioFormat
/** container for merged video downloads */
videoContainer: VideoContainer
/** preferred video codec (sort tiebreaker, not a hard filter) */
preferredVideoCodec: VideoCodecPref
/** download + embed subtitles into video */
embedSubtitles: boolean
/** comma-separated yt-dlp sub-lang selectors, e.g. 'en' or 'en.*,es' */
subtitleLanguages: string
/** also pull auto-generated (ASR) captions */
autoSubtitles: boolean
/** enable SponsorBlock */
sponsorBlock: boolean
sponsorBlockMode: SponsorBlockMode
sponsorBlockCategories: SponsorBlockCategory[]
/** embed chapter markers */
embedChapters: boolean
/** embed metadata (title/artist/etc.) */
embedMetadata: boolean
/** embed the thumbnail (cover art for audio, poster for mp4/mkv video) */
embedThumbnail: boolean
/** crop embedded audio artwork to a centred square (Seal's "crop artwork") */
cropThumbnail: boolean
}
export const DEFAULT_DOWNLOAD_OPTIONS: DownloadOptions = {
audioFormat: 'mp3',
videoContainer: 'mp4',
preferredVideoCodec: 'any',
embedSubtitles: false,
subtitleLanguages: 'en',
autoSubtitles: false,
sponsorBlock: false,
sponsorBlockMode: 'remove',
sponsorBlockCategories: ['sponsor'],
embedChapters: false,
embedMetadata: true,
embedThumbnail: true,
cropThumbnail: false
}
export interface YtdlpVersionResult { export interface YtdlpVersionResult {
ok: boolean ok: boolean
/** yt-dlp version string, present when ok is true */ /** yt-dlp version string, present when ok is true */
@@ -59,9 +137,35 @@ export interface MediaInfo {
formats: FormatOption[] formats: FormatOption[]
} }
/** One entry of a probed playlist (flat — no per-entry format list). */
export interface PlaylistEntry {
/** 1-based position in the playlist */
index: number
id: string
title: string
/** canonical URL for the entry, enqueued as its own single-video download */
url: string
durationLabel?: string
uploader?: string
}
/** A probed playlist: lightweight metadata for each entry. */
export interface PlaylistInfo {
title: string
uploader?: string
count: number
entries: PlaylistEntry[]
}
/**
* A single probe can resolve to either one video (with formats) or a playlist
* (with entries). `kind` discriminates which field is populated.
*/
export interface ProbeResult { export interface ProbeResult {
ok: boolean ok: boolean
kind?: 'video' | 'playlist'
info?: MediaInfo info?: MediaInfo
playlist?: PlaylistInfo
error?: string error?: string
} }
@@ -78,6 +182,8 @@ export interface StartDownloadOptions {
formatId?: string formatId?: string
/** whether the chosen format already includes audio (skips +bestaudio) */ /** whether the chosen format already includes audio (skips +bestaudio) */
formatHasAudio?: boolean formatHasAudio?: boolean
/** per-download post-processing override; main falls back to settings defaults */
options?: DownloadOptions
} }
export interface StartDownloadResult { export interface StartDownloadResult {
@@ -126,6 +232,8 @@ export interface Settings {
theme: 'light' | 'dark' theme: 'light' | 'dark'
/** auto-detect video links from the clipboard on window focus */ /** auto-detect video links from the clipboard on window focus */
clipboardWatch: boolean clipboardWatch: boolean
/** default post-processing / format options for new downloads */
downloadOptions: DownloadOptions
} }
/** A completed download, persisted to history.json (newest-first). */ /** A completed download, persisted to history.json (newest-first). */