Files
AeroFetch/src/main/settings.ts
T
debont80 47da3e6746 Add Pinchflat-style media manager: index channels into playlist folders
Index an entire YouTube channel or playlist once, then download it into
organized <Channel>/<Playlist>/<NNN> - <Title> folders, with incremental
re-sync. Built in six rebuild-gated phases (F-K); see ROADMAP-PINCHFLAT.md.

- F: channel-walk indexer (/playlists + /videos) -> persisted Source +
  MediaItem JSON stores; pure classify/dedup logic in indexerCore.ts
- G: Windows-safe folder paths + dir sanitizer (collectionOutputTemplate)
- H: Library tab + source tree + "Download pending" into the existing queue
- I: state-preserving re-index merge + persist-downloaded-on-complete
- J: watched sources, RSS fast-check, Task Scheduler + --sync launch
  (the OS-level scheduling/RSS need a real-install smoke test)
- K: .info.json / thumbnail / .description sidecars for media servers

Also gitignore .gitea-token. 106 unit tests pass; typecheck + build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 20:50:13 -04:00

210 lines
8.1 KiB
TypeScript

import { app } from 'electron'
import { join } from 'path'
import Store from 'electron-store'
import { isSafeFilenameTemplate, isSafeOutputDir } from './validation'
import {
AUDIO_FORMATS,
VIDEO_CONTAINERS,
VIDEO_CODECS,
SPONSORBLOCK_CATEGORIES,
COOKIE_BROWSERS,
ACCENT_COLORS,
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',
accentColor: 'teal',
clipboardWatch: true,
downloadOptions: DEFAULT_DOWNLOAD_OPTIONS,
proxy: '',
rateLimit: '',
useAria2c: false,
cookieSource: 'none',
cookiesBrowser: 'chrome',
restrictFilenames: false,
downloadArchive: false,
customCommandEnabled: false,
defaultTemplateId: null,
notifyOnComplete: true,
autoDownloadNew: true,
hasCompletedOnboarding: 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),
writeInfoJson: bool(o.writeInfoJson, d.writeInfoJson),
writeThumbnailFile: bool(o.writeThumbnailFile, d.writeThumbnailFile),
writeDescription: bool(o.writeDescription, d.writeDescription)
}
}
// 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()
// getSettings() is on hot paths (buildCommand, notification checks, the system-
// theme bridge, several IPC handlers). electron-store writes to disk on every
// `set`, so only write when something actually changed — otherwise this churns
// the settings file on every read. (audit P1)
const cur = s.store
if (!cur.outputDir) {
// Fill in the real Downloads path the first time, and persist it once.
s.set('outputDir', app.getPath('downloads'))
}
// Migrate settings files that predate downloadOptions (or hold a partial one),
// but only persist when sanitizing actually altered the stored value.
const sanitized = sanitizeOptions(cur.downloadOptions)
if (!downloadOptionsEqual(sanitized, cur.downloadOptions)) {
s.set('downloadOptions', sanitized)
}
// Coerce an accentColor left over from a renamed/removed preset (e.g. an old
// 'toffee' or 'indigo' default) onto the current default, so it resolves cleanly.
if (!(ACCENT_COLORS as readonly string[]).includes(cur.accentColor)) {
s.set('accentColor', DEFAULTS.accentColor)
}
return s.store
}
/** Shallow structural equality for DownloadOptions (sponsorBlockCategories compared by value). */
function downloadOptionsEqual(a: DownloadOptions, b: unknown): boolean {
if (!b || typeof b !== 'object') return false
const o = b as Partial<DownloadOptions>
const keys = Object.keys(a) as (keyof DownloadOptions)[]
for (const k of keys) {
if (k === 'sponsorBlockCategories') {
const av = a[k]
const bv = o[k]
if (!Array.isArray(bv) || av.length !== bv.length) return false
for (let i = 0; i < av.length; i++) if (av[i] !== bv[i]) return false
} else if (a[k] !== o[k]) {
return false
}
}
return true
}
// 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' || value === 'system') s.set('theme', value)
break
case 'accentColor':
if ((ACCENT_COLORS as readonly string[]).includes(value as string)) {
s.set('accentColor', value as Settings['accentColor'])
}
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':
case 'customCommandEnabled':
case 'notifyOnComplete':
case 'autoDownloadNew':
case 'hasCompletedOnboarding':
if (typeof value === 'boolean') s.set(key, value)
break
case 'defaultTemplateId':
if (value === null || typeof value === 'string') s.set('defaultTemplateId', 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':
if (typeof value === 'string' && isSafeOutputDir(value.trim())) s.set('outputDir', value.trim())
break
case 'filenameTemplate':
if (typeof value === 'string' && isSafeFilenameTemplate(value.trim())) {
s.set('filenameTemplate', value.trim())
}
break
case 'defaultVideoQuality':
case 'defaultAudioQuality':
// proxy may carry plaintext credentials (user:pass@host); they are stored
// and exported by exportBackup as-is — documented, not masked.
case 'proxy':
case 'rateLimit':
if (typeof value === 'string') s.set(key, value)
break
}
}
return getSettings()
}