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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 19:32:59 -04:00
parent d4b9a0eefa
commit bb5dd6c438
21 changed files with 1101 additions and 91 deletions
+80 -15
View File
@@ -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) {