From bb5dd6c438ec90c85c3ae9e82f88ba6d413d6709 Mon Sep 17 00:00:00 2001 From: debont80 Date: Mon, 22 Jun 2026 19:32:59 -0400 Subject: [PATCH] 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 --- ROADMAP.md | 102 ++++++++ electron.vite.config.ts | 10 + package-lock.json | 10 - package.json | 1 - src/main/download.ts | 95 +++++-- src/main/index.ts | 28 +- src/main/probe.ts | 101 ++++++- src/main/reveal.ts | 42 +++ src/main/settings.ts | 89 ++++++- src/main/url.ts | 26 ++ src/main/ytdlp.ts | 7 +- src/preload/index.d.ts | 5 +- src/preload/index.ts | 11 +- src/renderer/src/components/DownloadBar.tsx | 246 ++++++++++++++++-- .../src/components/DownloadOptionsForm.tsx | 235 +++++++++++++++++ src/renderer/src/components/Select.tsx | 4 +- src/renderer/src/components/SettingsView.tsx | 20 +- src/renderer/src/main.tsx | 28 +- src/renderer/src/store/downloads.ts | 19 +- src/renderer/src/store/settings.ts | 5 +- src/shared/ipc.ts | 108 ++++++++ 21 files changed, 1101 insertions(+), 91 deletions(-) create mode 100644 ROADMAP.md create mode 100644 src/main/reveal.ts create mode 100644 src/main/url.ts create mode 100644 src/renderer/src/components/DownloadOptionsForm.tsx diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..ff0d53c --- /dev/null +++ b/ROADMAP.md @@ -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 ` (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). diff --git a/electron.vite.config.ts b/electron.vite.config.ts index e12a636..baad558 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -17,6 +17,16 @@ export default defineConfig({ alias: { '@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: { diff --git a/package-lock.json b/package-lock.json index 55102cf..cca03ad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,6 @@ "name": "aerofetch", "version": "0.1.0", "dependencies": { - "@electron-toolkit/preload": "^3.0.2", "@electron-toolkit/utils": "^4.0.0", "@fluentui/react-components": "^9.74.1", "@fluentui/react-icons": "^2.0.330", @@ -375,15 +374,6 @@ "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": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@electron-toolkit/tsconfig/-/tsconfig-2.0.0.tgz", diff --git a/package.json b/package.json index 9c481a0..e814885 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,6 @@ "typecheck": "npm run typecheck:node && npm run typecheck:web" }, "dependencies": { - "@electron-toolkit/preload": "^3.0.2", "@electron-toolkit/utils": "^4.0.0", "@fluentui/react-components": "^9.74.1", "@fluentui/react-icons": "^2.0.330", diff --git a/src/main/download.ts b/src/main/download.ts index 6499b57..a3df881 100644 --- a/src/main/download.ts +++ b/src/main/download.ts @@ -4,6 +4,7 @@ import { join } from 'path' import { app, type WebContents } from 'electron' import { getYtdlpPath, getBinDir } from './binaries' import { getSettings } from './settings' +import { assertHttpUrl } from './url' import { IpcChannels, BEST_FORMAT_ID, @@ -11,7 +12,8 @@ import { type StartDownloadResult, type DownloadEvent, type DownloadMeta, - type DownloadProgress + type DownloadProgress, + type DownloadOptions } from '@shared/ipc' interface ActiveDownload { @@ -75,7 +77,48 @@ const PROGRESS_TEMPLATE = '%(progress.total_bytes)s|%(progress.total_bytes_estimate)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 = [ '--newline', '--no-color', @@ -95,21 +138,28 @@ function buildArgs(opts: StartDownloadOptions, outputTemplate: string): string[] '--no-simulate' ] + args.push(...postProcessArgs(opts, o)) + if (opts.kind === 'audio') { - args.push( - '-x', - '--audio-format', - 'mp3', - '--audio-quality', - audioQuality(opts.quality), - '--embed-thumbnail', - '--embed-metadata' - ) + args.push('-x', '--audio-format', o.audioFormat, '--audio-quality', audioQuality(opts.quality)) + if (o.embedThumbnail) { + args.push('--embed-thumbnail') + if (o.cropThumbnail) args.push('--ppa', CROP_SQUARE_PPA) + } } 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 } @@ -193,9 +243,10 @@ function probeMeta(ytdlp: string, url: string): Promise { 'uploader', '--print', 'duration_string', + '--', url ], - { windowsHide: true, maxBuffer: 4 * 1024 * 1024 }, + { windowsHide: true, maxBuffer: 4 * 1024 * 1024, timeout: 30_000 }, (err, stdout) => { if (err) return resolve(null) 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).` } } + // 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.' } } @@ -231,10 +288,12 @@ export function startDownload( const settings = getSettings() const outDir = opts.outputDir?.trim() || settings.outputDir || app.getPath('downloads') 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 try { - child = spawn(ytdlp, buildArgs(opts, join(outDir, template)), { windowsHide: true }) + child = spawn(ytdlp, buildArgs(opts, join(outDir, template), options), { windowsHide: true }) } catch (e) { return { ok: false, error: (e as Error).message } } @@ -250,6 +309,8 @@ export function startDownload( let stdoutBuf = '' let stderrTail = '' 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) => { stdoutBuf += chunk.toString() @@ -271,11 +332,15 @@ export function startDownload( }) child.on('error', (err) => { + if (settled) return + settled = true active.delete(opts.id) if (!rec.canceled) send(wc, { type: 'error', id: opts.id, error: err.message }) }) child.on('close', (code) => { + if (settled) return + settled = true active.delete(opts.id) if (rec.canceled) return // renderer already showed 'canceled' optimistically if (code === 0) { diff --git a/src/main/index.ts b/src/main/index.ts index 770132d..a9274ff 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -13,6 +13,7 @@ import { startDownload, cancelDownload } from './download' import { getSettings, setSettings } from './settings' import { listHistory, addHistory, removeHistory, clearHistory } from './history' import { setupPortableData } from './portable' +import { safeOpenPath, safeShowInFolder } from './reveal' // 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 — @@ -45,8 +46,8 @@ function createWindow(): void { backgroundColor: THEME_BACKGROUND[getSettings().theme], autoHideMenuBar: true, webPreferences: { - preload: join(__dirname, '../preload/index.mjs'), - sandbox: false, + preload: join(__dirname, '../preload/index.cjs'), + sandbox: true, contextIsolation: true, nodeIntegration: false } @@ -56,12 +57,22 @@ function createWindow(): void { 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) => { - 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' } }) + // 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']) { mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']) } else { @@ -89,8 +100,8 @@ function registerIpcHandlers(): void { return res.canceled || !res.filePaths[0] ? null : res.filePaths[0] }) - ipcMain.handle(IpcChannels.openPath, (_e, p: string) => shell.openPath(p)) - ipcMain.handle(IpcChannels.showInFolder, (_e, p: string) => shell.showItemInFolder(p)) + ipcMain.handle(IpcChannels.openPath, (_e, p: string) => safeOpenPath(p)) + ipcMain.handle(IpcChannels.showInFolder, (_e, p: string) => safeShowInFolder(p)) ipcMain.handle(IpcChannels.clipboardRead, () => clipboard.readText()) @@ -98,9 +109,10 @@ function registerIpcHandlers(): void { ipcMain.handle(IpcChannels.settingsSet, (e, partial: Partial) => { const result = setSettings(partial) // 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) { - BrowserWindow.fromWebContents(e.sender)?.setBackgroundColor(THEME_BACKGROUND[partial.theme]) + BrowserWindow.fromWebContents(e.sender)?.setBackgroundColor(THEME_BACKGROUND[result.theme]) } return result }) diff --git a/src/main/probe.ts b/src/main/probe.ts index de2ee4e..112e419 100644 --- a/src/main/probe.ts +++ b/src/main/probe.ts @@ -2,7 +2,15 @@ import { execFile } from 'child_process' import { existsSync } from 'fs' import { getYtdlpPath } from './binaries' 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. interface RawFormat { @@ -17,13 +25,25 @@ interface RawFormat { filesize_approx?: number } +interface RawEntry { + id?: string + title?: string + url?: string + webpage_url?: string + duration?: number + uploader?: string + channel?: string +} + interface RawInfo { + _type?: string title?: string uploader?: string channel?: string duration_string?: string thumbnail?: string formats?: RawFormat[] + entries?: RawEntry[] } /** 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 { const lines = stderr .split('\n') @@ -80,7 +145,11 @@ function cleanError(stderr: string): string { 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 { const ytdlp = getYtdlpPath() if (!existsSync(ytdlp)) { @@ -89,19 +158,39 @@ export function probeMedia(url: string): Promise { 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) => { execFile( ytdlp, - ['-J', '--no-playlist', '--no-warnings', url], - { windowsHide: true, maxBuffer: 64 * 1024 * 1024 }, + // `--` terminates option parsing so the URL can never be read as a flag. + ['-J', '--flat-playlist', '--no-warnings', '--', url], + { windowsHide: true, maxBuffer: 64 * 1024 * 1024, timeout: 60_000 }, (err, stdout, stderr) => { 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 } 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 { resolve({ ok: false, error: 'Could not parse video info from yt-dlp.' }) } diff --git a/src/main/reveal.ts b/src/main/reveal.ts new file mode 100644 index 0000000..1105248 --- /dev/null +++ b/src/main/reveal.ts @@ -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 { + 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) +} diff --git a/src/main/settings.ts b/src/main/settings.ts index 89fc693..31b650c 100644 --- a/src/main/settings.ts +++ b/src/main/settings.ts @@ -1,6 +1,15 @@ import { app } from 'electron' 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 = { outputDir: '', // resolved to the OS Downloads folder on first read @@ -10,7 +19,45 @@ const DEFAULTS: Settings = { maxConcurrent: 2, filenameTemplate: '%(title)s.%(ext)s', 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 + 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 @@ -25,14 +72,46 @@ export function getSettings(): Settings { const s = getStore() // Fill in the real Downloads path the first time, and persist it. 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 } +// 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 { 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] - 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() } diff --git a/src/main/url.ts b/src/main/url.ts new file mode 100644 index 0000000..ae8eba9 --- /dev/null +++ b/src/main/url.ts @@ -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 doesn’t look like a valid URL.') + } + if (u.protocol !== 'http:' && u.protocol !== 'https:') { + throw new Error('Only http and https links are supported.') + } + return trimmed +} diff --git a/src/main/ytdlp.ts b/src/main/ytdlp.ts index ab99f49..bfd0a17 100644 --- a/src/main/ytdlp.ts +++ b/src/main/ytdlp.ts @@ -18,9 +18,12 @@ export function getYtdlpVersion(): Promise { } 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) { - 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 } resolve({ ok: true, version: stdout.trim() }) diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 291c976..298b05c 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -1,9 +1,8 @@ -import { ElectronAPI } from '@electron-toolkit/preload' -import type { Api } from './index' +import type { Api, ElectronMarker } from './index' declare global { interface Window { - electron: ElectronAPI + electron: ElectronMarker api: Api } } diff --git a/src/preload/index.ts b/src/preload/index.ts index 8370c6e..eee2ddf 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,5 +1,4 @@ import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron' -import { electronAPI } from '@electron-toolkit/preload' import { IpcChannels, type YtdlpVersionResult, @@ -60,16 +59,22 @@ const 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) { try { - contextBridge.exposeInMainWorld('electron', electronAPI) + contextBridge.exposeInMainWorld('electron', electron) contextBridge.exposeInMainWorld('api', api) } catch (error) { console.error(error) } } else { // @ts-ignore (defined in index.d.ts) - window.electron = electronAPI + window.electron = electron // @ts-ignore (defined in index.d.ts) window.api = api } diff --git a/src/renderer/src/components/DownloadBar.tsx b/src/renderer/src/components/DownloadBar.tsx index 1507817..58f8862 100644 --- a/src/renderer/src/components/DownloadBar.tsx +++ b/src/renderer/src/components/DownloadBar.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react' import { Input, Button, + Checkbox, Spinner, Text, Caption1, @@ -19,13 +20,18 @@ import { MusicNote2Regular, ErrorCircleRegular, LinkRegular, - DismissRegular + DismissRegular, + OptionsRegular, + ChevronDownRegular, + ChevronUpRegular, + AppsListRegular } 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 { useSettings } from '../store/settings' import { Select } from './Select' import { Hint } from './Hint' +import { DownloadOptionsForm } from './DownloadOptionsForm' /** A quick heuristic for "this clipboard text is a link worth offering". */ function looksLikeUrl(text: string): boolean { @@ -164,6 +170,66 @@ const useStyles = makeStyles({ spacer: { 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: { display: 'flex', alignItems: 'center', @@ -187,11 +253,17 @@ export function DownloadBar(): React.JSX.Element { const defaultKind = useSettings((s) => s.defaultKind) const defaultVideoQuality = useSettings((s) => s.defaultVideoQuality) const defaultAudioQuality = useSettings((s) => s.defaultAudioQuality) + const downloadOptions = useSettings((s) => s.downloadOptions) const [url, setUrl] = useState('') const [kind, setKind] = useState('video') const [quality, setQuality] = useState(QUALITY_OPTIONS.video[0]) + // Per-download options override (null = use the persisted defaults). + const [override, setOverride] = useState(null) + const [showOptions, setShowOptions] = useState(false) + const effectiveOptions = override ?? downloadOptions + // Apply the saved default format once, when persisted settings first arrive. const appliedDefaults = useRef(false) useEffect(() => { @@ -202,12 +274,16 @@ export function DownloadBar(): React.JSX.Element { } }, [settingsLoaded, defaultKind, defaultVideoQuality, defaultAudioQuality]) - // Probe state for the format picker. + // Probe state for the single-video format picker. const [info, setInfo] = useState(null) const [probing, setProbing] = useState(false) const [probeError, setProbeError] = useState(null) const [formatId, setFormatId] = useState('') + // Probe state for a playlist URL. + const [playlist, setPlaylist] = useState(null) + const [selected, setSelected] = useState>(new Set()) + // 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). const [suggestion, setSuggestion] = useState(null) @@ -255,14 +331,18 @@ export function DownloadBar(): React.JSX.Element { ? info.formats.find((f) => f.id === formatId) ?? info.formats[0] : undefined + function clearProbe(): void { + setInfo(null) + setProbeError(null) + 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) { - setInfo(null) - setProbeError(null) - setFormatId('') - } + if (info || probeError || playlist) clearProbe() } function onKindChange(next: MediaKind): void { @@ -276,9 +356,14 @@ export function DownloadBar(): React.JSX.Element { setProbing(true) setProbeError(null) setInfo(null) + setPlaylist(null) try { 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) setFormatId(res.info.formats[0]?.id ?? '') } 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 { const trimmed = url.trim() if (!trimmed) return @@ -320,16 +420,30 @@ export function DownloadBar(): React.JSX.Element { id: selectedFormat.id, hasAudio: selectedFormat.hasAudio, label: selectedFormat.label - } + }, + options: override ?? undefined }) } else { - addFromUrl(trimmed, kind, quality, meta) + addFromUrl(trimmed, kind, quality, { ...meta, options: override ?? undefined }) } setUrl('') - setInfo(null) - setProbeError(null) - setFormatId('') + clearProbe() + } + + 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 ( @@ -340,17 +454,17 @@ export function DownloadBar(): React.JSX.Element { value={url} onChange={(_, d) => onUrlChange(d.value)} 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" contentBefore={} /> - + + +
+ {playlist.entries.map((e) => ( + toggleEntry(e.index, !!d.checked)} + label={ + + + {e.index}. {e.title} + + {(e.durationLabel || e.uploader) && ( + + {[e.durationLabel, e.uploader].filter(Boolean).join(' • ')} + + )} + + } + /> + ))} +
+ + )} +
Format @@ -458,17 +612,55 @@ export function DownloadBar(): React.JSX.Element {
- + {playlist ? ( + + ) : ( + + )}
+
+ + {override && ( + <> + Customised for this download + + + )} +
+ + {showOptions && ( +
+ setOverride(o)} /> +
+ )} +
diff --git a/src/renderer/src/components/DownloadOptionsForm.tsx b/src/renderer/src/components/DownloadOptionsForm.tsx new file mode 100644 index 0000000..f599925 --- /dev/null +++ b/src/renderer/src/components/DownloadOptionsForm.tsx @@ -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 = { + best: 'Best (keep source)', + mp3: 'MP3', + m4a: 'M4A (AAC)', + opus: 'Opus', + flac: 'FLAC (lossless)', + wav: 'WAV (lossless)', + aac: 'AAC' +} +const VIDEO_CONTAINER_LABELS: Record = { + mp4: 'MP4', + mkv: 'MKV', + webm: 'WebM' +} +const VIDEO_CODEC_LABELS: Record = { + any: 'No preference', + h264: 'H.264 (most compatible)', + vp9: 'VP9', + av1: 'AV1 (smallest)' +} +const SPONSORBLOCK_LABELS: Record = { + 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(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 ( +
+
+ + ({ value: c, label: VIDEO_CONTAINER_LABELS[c] }))} + onChange={(v) => setOpt('videoContainer', v as VideoContainer)} + /> + + + setOpt('subtitleLanguages', d.value)} + /> + + setOpt('autoSubtitles', !!d.checked)} + label="Also include auto-generated captions" + /> +
+ )} + + + setOpt('sponsorBlock', d.checked)} + label={value.sponsorBlock ? 'On' : 'Off'} + /> + + {value.sponsorBlock && ( +
+ +