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:
+102
@@ -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).
|
||||
@@ -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: {
|
||||
|
||||
Generated
-10
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
+80
-15
@@ -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<DownloadMeta | null> {
|
||||
'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) {
|
||||
|
||||
+20
-8
@@ -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<Settings>) => {
|
||||
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
|
||||
})
|
||||
|
||||
+95
-6
@@ -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<ProbeResult> {
|
||||
const ytdlp = getYtdlpPath()
|
||||
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).`
|
||||
})
|
||||
}
|
||||
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.' })
|
||||
}
|
||||
|
||||
@@ -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
@@ -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<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
|
||||
@@ -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>): 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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+5
-2
@@ -18,9 +18,12 @@ export function getYtdlpVersion(): Promise<YtdlpVersionResult> {
|
||||
}
|
||||
|
||||
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() })
|
||||
|
||||
Vendored
+2
-3
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<MediaKind>('video')
|
||||
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.
|
||||
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<MediaInfo | null>(null)
|
||||
const [probing, setProbing] = useState(false)
|
||||
const [probeError, setProbeError] = useState<string | null>(null)
|
||||
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
|
||||
// fresh link, offer it (without clobbering anything the user is already typing).
|
||||
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]
|
||||
: 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={<ArrowDownloadRegular />}
|
||||
/>
|
||||
<Hint label="Fetch available formats" placement="bottom" align="end">
|
||||
<Hint label="Fetch formats / playlist" placement="bottom" align="end">
|
||||
<Button
|
||||
size="large"
|
||||
icon={probing ? <Spinner size="tiny" /> : <SearchRegular />}
|
||||
onClick={fetchFormats}
|
||||
disabled={!url.trim() || probing}
|
||||
aria-label="Fetch available formats"
|
||||
aria-label="Fetch formats or playlist"
|
||||
/>
|
||||
</Hint>
|
||||
<Hint label="Paste from clipboard" placement="bottom" align="end">
|
||||
@@ -383,7 +497,7 @@ export function DownloadBar(): React.JSX.Element {
|
||||
{probing && (
|
||||
<div className={styles.statusRow}>
|
||||
<Spinner size="tiny" />
|
||||
<Caption1>Fetching available formats…</Caption1>
|
||||
<Caption1>Fetching…</Caption1>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -416,6 +530,46 @@ export function DownloadBar(): React.JSX.Element {
|
||||
</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.control}>
|
||||
<Caption1>Format</Caption1>
|
||||
@@ -458,17 +612,55 @@ export function DownloadBar(): React.JSX.Element {
|
||||
|
||||
<div className={styles.spacer} />
|
||||
|
||||
<Button
|
||||
appearance="primary"
|
||||
size="large"
|
||||
icon={<ArrowDownloadRegular />}
|
||||
onClick={download}
|
||||
disabled={!url.trim()}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
{playlist ? (
|
||||
<Button
|
||||
appearance="primary"
|
||||
size="large"
|
||||
icon={<ArrowDownloadRegular />}
|
||||
onClick={addPlaylist}
|
||||
disabled={selected.size === 0}
|
||||
>
|
||||
Add {selected.size} to queue
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
appearance="primary"
|
||||
size="large"
|
||||
icon={<ArrowDownloadRegular />}
|
||||
onClick={download}
|
||||
disabled={!url.trim()}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
)}
|
||||
</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}>
|
||||
<FolderRegular />
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -21,11 +21,11 @@ const useStyles = makeStyles({
|
||||
...shorthands.borderRadius(tokens.borderRadiusMedium),
|
||||
cursor: 'pointer',
|
||||
':hover': {
|
||||
borderColor: tokens.colorNeutralStroke1Hover
|
||||
...shorthands.borderColor(tokens.colorNeutralStroke1Hover)
|
||||
},
|
||||
':focus-visible': {
|
||||
outline: 'none',
|
||||
borderColor: tokens.colorCompoundBrandStroke
|
||||
...shorthands.borderColor(tokens.colorCompoundBrandStroke)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -18,12 +18,14 @@ import {
|
||||
FolderRegular,
|
||||
ArrowDownloadRegular,
|
||||
DocumentRegular,
|
||||
OptionsRegular,
|
||||
InfoRegular
|
||||
} 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 { QUALITY_OPTIONS, useDownloads } from '../store/downloads'
|
||||
import { Select } from './Select'
|
||||
import { DownloadOptionsForm } from './DownloadOptionsForm'
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: {
|
||||
@@ -78,6 +80,7 @@ export function SettingsView(): React.JSX.Element {
|
||||
const maxConcurrent = useSettings((s) => s.maxConcurrent)
|
||||
const filenameTemplate = useSettings((s) => s.filenameTemplate)
|
||||
const clipboardWatch = useSettings((s) => s.clipboardWatch)
|
||||
const downloadOptions = useSettings((s) => s.downloadOptions)
|
||||
const update = useSettings((s) => s.update)
|
||||
|
||||
const formatQuality = defaultKind === 'audio' ? defaultAudioQuality : defaultVideoQuality
|
||||
@@ -169,6 +172,21 @@ export function SettingsView(): React.JSX.Element {
|
||||
</Field>
|
||||
</Card>
|
||||
|
||||
<Card className={styles.card}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<OptionsRegular className={styles.sectionIcon} />
|
||||
<Subtitle2>Format & 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}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<DocumentRegular className={styles.sectionIcon} />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import './assets/base.css'
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { DEFAULT_DOWNLOAD_OPTIONS } from '@shared/ipc'
|
||||
import App from './App'
|
||||
|
||||
// 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) {
|
||||
window.api = {
|
||||
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
|
||||
// A 'list'/'playlist' URL exercises the playlist selection UI in preview.
|
||||
if (/list=|playlist/i.test(url)) {
|
||||
return {
|
||||
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: {
|
||||
title: 'Building a Desktop App with Electron — Full Course',
|
||||
channel: 'DevChannel',
|
||||
@@ -45,7 +67,8 @@ if (import.meta.env.DEV && !window.api) {
|
||||
maxConcurrent: 2,
|
||||
filenameTemplate: '%(title)s.%(ext)s',
|
||||
theme: 'light',
|
||||
clipboardWatch: true
|
||||
clipboardWatch: true,
|
||||
downloadOptions: DEFAULT_DOWNLOAD_OPTIONS
|
||||
}),
|
||||
setSettings: async (partial) => ({
|
||||
outputDir: 'C:\\Users\\you\\Downloads',
|
||||
@@ -56,6 +79,7 @@ if (import.meta.env.DEV && !window.api) {
|
||||
filenameTemplate: '%(title)s.%(ext)s',
|
||||
theme: 'light',
|
||||
clipboardWatch: true,
|
||||
downloadOptions: DEFAULT_DOWNLOAD_OPTIONS,
|
||||
...partial
|
||||
}),
|
||||
listHistory: async () => [],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { create } from 'zustand'
|
||||
import type { DownloadEvent } from '@shared/ipc'
|
||||
import type { DownloadEvent, DownloadOptions } from '@shared/ipc'
|
||||
import { useSettings } from './settings'
|
||||
import { useHistory } from './history'
|
||||
|
||||
@@ -34,6 +34,8 @@ export interface DownloadItem {
|
||||
/** exact format carried so retry re-downloads the same thing */
|
||||
formatId?: string
|
||||
formatHasAudio?: boolean
|
||||
/** per-download post-processing override (omitted = use persisted defaults) */
|
||||
options?: DownloadOptions
|
||||
}
|
||||
|
||||
export const QUALITY_OPTIONS: Record<MediaKind, string[]> = {
|
||||
@@ -51,7 +53,14 @@ interface DownloadState {
|
||||
url: string,
|
||||
kind: MediaKind,
|
||||
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
|
||||
retry: (id: string) => void
|
||||
cancel: (id: string) => void
|
||||
@@ -220,7 +229,8 @@ export const useDownloads = create<DownloadState>((set, get) => {
|
||||
quality: item.quality,
|
||||
outputDir: useSettings.getState().outputDir || undefined,
|
||||
formatId: item.formatId,
|
||||
formatHasAudio: item.formatHasAudio
|
||||
formatHasAudio: item.formatHasAudio,
|
||||
options: item.options
|
||||
})
|
||||
.then((res) => {
|
||||
if (!res.ok) markError(item.id, res.error)
|
||||
@@ -265,7 +275,8 @@ export const useDownloads = create<DownloadState>((set, get) => {
|
||||
status: 'queued',
|
||||
progress: 0,
|
||||
formatId: opts?.format?.id,
|
||||
formatHasAudio: opts?.format?.hasAudio
|
||||
formatHasAudio: opts?.format?.hasAudio,
|
||||
options: opts?.options
|
||||
}
|
||||
set((s) => ({ items: [item, ...s.items] }))
|
||||
pump()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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).
|
||||
const PREVIEW = typeof window === 'undefined' || !window.electron
|
||||
@@ -12,7 +12,8 @@ const FALLBACK: Settings = {
|
||||
maxConcurrent: 2,
|
||||
filenameTemplate: '%(title)s.%(ext)s',
|
||||
theme: 'light',
|
||||
clipboardWatch: true
|
||||
clipboardWatch: true,
|
||||
downloadOptions: DEFAULT_DOWNLOAD_OPTIONS
|
||||
}
|
||||
|
||||
interface SettingsState extends Settings {
|
||||
|
||||
@@ -28,6 +28,84 @@ export const BEST_FORMAT_ID = '__best__'
|
||||
|
||||
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 {
|
||||
ok: boolean
|
||||
/** yt-dlp version string, present when ok is true */
|
||||
@@ -59,9 +137,35 @@ export interface MediaInfo {
|
||||
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 {
|
||||
ok: boolean
|
||||
kind?: 'video' | 'playlist'
|
||||
info?: MediaInfo
|
||||
playlist?: PlaylistInfo
|
||||
error?: string
|
||||
}
|
||||
|
||||
@@ -78,6 +182,8 @@ export interface StartDownloadOptions {
|
||||
formatId?: string
|
||||
/** whether the chosen format already includes audio (skips +bestaudio) */
|
||||
formatHasAudio?: boolean
|
||||
/** per-download post-processing override; main falls back to settings defaults */
|
||||
options?: DownloadOptions
|
||||
}
|
||||
|
||||
export interface StartDownloadResult {
|
||||
@@ -126,6 +232,8 @@ export interface Settings {
|
||||
theme: 'light' | 'dark'
|
||||
/** auto-detect video links from the clipboard on window focus */
|
||||
clipboardWatch: boolean
|
||||
/** default post-processing / format options for new downloads */
|
||||
downloadOptions: DownloadOptions
|
||||
}
|
||||
|
||||
/** A completed download, persisted to history.json (newest-first). */
|
||||
|
||||
Reference in New Issue
Block a user