67133f13b5
Implements the remaining Phase B (Access & networking) items from ROADMAP.md: cookie sources (browser cookie-store import, or a built-in sign-in window that exports a Netscape cookie file via a persisted Electron session partition), the aria2c external downloader, proxy/rate-limit, restrict-filenames, and a download-archive to skip repeats. Wires download.ts to the buildArgs() module added in the previous commit (it wasn't hooked up yet) and renames its options param NetworkOptions -> AccessOptions to reflect the wider scope now that cookies and filename/archive flags joined proxy/rate-limit/aria2c. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
145 lines
5.3 KiB
TypeScript
145 lines
5.3 KiB
TypeScript
import { app } from 'electron'
|
|
import { join } from 'path'
|
|
import Store from 'electron-store'
|
|
import {
|
|
AUDIO_FORMATS,
|
|
VIDEO_CONTAINERS,
|
|
VIDEO_CODECS,
|
|
SPONSORBLOCK_CATEGORIES,
|
|
COOKIE_BROWSERS,
|
|
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
|
|
defaultKind: 'video',
|
|
defaultVideoQuality: 'Best available',
|
|
defaultAudioQuality: 'Best (MP3)',
|
|
maxConcurrent: 2,
|
|
filenameTemplate: '%(title)s.%(ext)s',
|
|
theme: 'light',
|
|
clipboardWatch: true,
|
|
downloadOptions: DEFAULT_DOWNLOAD_OPTIONS,
|
|
proxy: '',
|
|
rateLimit: '',
|
|
useAria2c: false,
|
|
cookieSource: 'none',
|
|
cookiesBrowser: 'chrome',
|
|
restrictFilenames: false,
|
|
downloadArchive: false
|
|
}
|
|
|
|
/** Fixed path for the --download-archive file; not user-configurable. */
|
|
export function getDownloadArchivePath(): string {
|
|
return join(app.getPath('userData'), 'download-archive.txt')
|
|
}
|
|
|
|
// 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
|
|
// the app is ready (all callers run post-ready).
|
|
let store: Store<Settings> | null = null
|
|
function getStore(): Store<Settings> {
|
|
if (!store) store = new Store<Settings>({ name: 'settings', defaults: DEFAULTS })
|
|
return store
|
|
}
|
|
|
|
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()
|
|
for (const key of Object.keys(partial) as (keyof Settings)[]) {
|
|
const value = partial[key]
|
|
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':
|
|
case 'useAria2c':
|
|
case 'restrictFilenames':
|
|
case 'downloadArchive':
|
|
if (typeof value === 'boolean') s.set(key, value)
|
|
break
|
|
case 'cookieSource':
|
|
if (value === 'none' || value === 'browser' || value === 'login') s.set(key, value)
|
|
break
|
|
case 'cookiesBrowser':
|
|
if ((COOKIE_BROWSERS as readonly string[]).includes(value as string)) {
|
|
s.set(key, value as Settings['cookiesBrowser'])
|
|
}
|
|
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':
|
|
case 'proxy':
|
|
case 'rateLimit':
|
|
if (typeof value === 'string') s.set(key, value)
|
|
break
|
|
}
|
|
}
|
|
return getSettings()
|
|
}
|