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:
+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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user