Files
AeroFetch/src/renderer/src/components/SettingsView.tsx
T
debont80 8ab85da67e Fix H5/H6/M21/L37-40/L47/L55/L68/L72/L76/L91/L98/L108/L165/L166 audit items
H5: HistoryEntry now stores formatId+formatHasAudio; redownload passes them
    back to addFromUrl so the original yt-dlp format is reused, not guessed.
    Compound quality labels stripped to the preset prefix for old history.
L72: thumbnail now passed in redownload so re-queued rows keep their art.
H6: TerminalView log capped at MAX_LOG_LINES=2000 to prevent unbounded growth.
M21: --audio-quality omitted for lossless audioFormats (flac/wav).
L166: fmtEta hour rollover fixed in all 3 locations -- 2h00:00 not 120:00.
L165: queueStats formatSpeed precision aligned with fmtBytes.
L55: QueueItem suppresses sizeLabel when already in probed-format quality label.
L47: probeMeta null sends empty meta event so Resolving clears via SR6.
L68: notifiedBackground resets on window show for subsequent close-while-downloading.
L76: dup warning falls back to URL when title is still a placeholder.
L91: SettingsView onFormatSelect uses indexOf instead of unsafe tuple cast.
L98: Embed chapters field gets a hint text.
L108: BrowserWindow created with explicit title AeroFetch.
L37: pure formatters extracted to src/main/lib/formatters.ts and unit-tested.
L38: buildArgs test covers webm thumbnail suppression.
L39: CROP_SQUARE_PPA test is structural not exact-string.
L40: looksLikeUrl/looksLikeSingleVideo extracted to src/renderer/src/lib/urlHelpers.ts.

typecheck + 242 tests + eslint + prettier green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 13:29:35 -04:00

1168 lines
40 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useRef, useState } from 'react'
import {
Field,
Input,
SpinButton,
Switch,
Button,
Card,
Subtitle2,
Caption1,
Spinner,
Text,
ProgressBar,
makeStyles,
mergeClasses,
tokens,
shorthands
} from '@fluentui/react-components'
import {
FolderRegular,
ArrowDownloadRegular,
DocumentRegular,
OptionsRegular,
InfoRegular,
GlobeRegular,
CookiesRegular,
CodeRegular,
ArrowClockwiseRegular,
DocumentArrowDownRegular,
DocumentArrowUpRegular,
BugRegular,
CopyRegular,
DeleteRegular,
PaintBucketRegular,
AccessibilityRegular,
ArrowSyncRegular,
OpenRegular,
SearchRegular
} from '@fluentui/react-icons'
import {
COOKIE_BROWSERS,
type YtdlpVersionResult,
type YtdlpUpdateChannel,
type YtdlpUpdateResult,
type FfmpegVersionResult,
type AppUpdateInfo,
type MediaKind,
type CookieSource,
type CookieBrowser,
type CookiesStatus,
type BackupExportResult,
type BackupImportResult,
type ThemeMode,
type AccentColor
} from '@shared/ipc'
import { useSettings } from '../store/settings'
import { useSystemTheme } from '../store/systemTheme'
import { QUALITY_OPTIONS, useDownloads } from '../store/downloads'
import { useTemplates } from '../store/templates'
import { useErrorLog } from '../store/errorlog'
import { Select } from './Select'
import { DownloadOptionsForm } from './DownloadOptionsForm'
import { TemplateManager } from './TemplateManager'
import { ScreenHeader, useScreenStyles } from './ui/Screen'
import { useErrorTextStyles } from './ui/errorText'
import { ACCENT_OPTIONS } from '../theme'
import { logError } from '../reportError'
const useStyles = makeStyles({
root: {
display: 'flex',
flexDirection: 'column',
gap: '16px'
},
card: {
display: 'flex',
flexDirection: 'column',
gap: '16px',
padding: '20px',
...shorthands.borderRadius(tokens.borderRadiusXLarge)
},
notesBox: {
maxHeight: '180px',
overflowY: 'auto',
padding: '10px 12px',
backgroundColor: tokens.colorNeutralBackground2,
...shorthands.borderRadius(tokens.borderRadiusMedium),
border: `1px solid ${tokens.colorNeutralStroke2}`,
whiteSpace: 'pre-wrap',
fontSize: tokens.fontSizeBase200,
lineHeight: tokens.lineHeightBase300
},
sectionHeader: {
display: 'flex',
alignItems: 'center',
gap: '10px'
},
sectionIcon: {
fontSize: '20px',
color: tokens.colorCompoundBrandForeground1
},
folderRow: {
display: 'flex',
gap: '8px',
alignItems: 'flex-end'
},
folderInput: {
flexGrow: 1
},
hint: {
color: tokens.colorNeutralForeground3
},
swatchRow: {
display: 'flex',
gap: '10px'
},
swatch: {
boxSizing: 'border-box',
width: '28px',
height: '28px',
flexShrink: 0,
...shorthands.borderRadius(tokens.borderRadiusCircular),
...shorthands.border('2px', 'solid', 'transparent'),
padding: 0,
cursor: 'pointer'
},
swatchActive: {
...shorthands.borderColor(tokens.colorNeutralForeground1)
},
errorList: {
display: 'flex',
flexDirection: 'column',
gap: '8px'
},
errorRow: {
padding: '10px 12px',
backgroundColor: tokens.colorNeutralBackground2,
...shorthands.borderRadius(tokens.borderRadiusLarge),
border: `1px solid ${tokens.colorNeutralStroke2}`
},
errorRowHeader: {
display: 'flex',
alignItems: 'center',
gap: '8px'
},
errorRowTitle: {
flexGrow: 1,
minWidth: 0,
fontWeight: tokens.fontWeightSemibold,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}
})
// Combined "Type — Quality" options for the default-format dropdown.
const FORMAT_OPTIONS = [
...QUALITY_OPTIONS.video.map((q) => ({ value: `video|${q}`, label: `Video — ${q}` })),
...QUALITY_OPTIONS.audio.map((q) => ({ value: `audio|${q}`, label: `Audio — ${q}` }))
]
const THEME_MODE_OPTIONS = [
{ value: 'light', label: 'Light' },
{ value: 'dark', label: 'Dark' },
{ value: 'system', label: 'Follow system' }
]
const COOKIE_SOURCE_OPTIONS = [
{ value: 'none', label: 'None' },
{ value: 'browser', label: "From a browser's cookie store" },
{ value: 'login', label: 'Sign-in window (built into AeroFetch)' }
]
const COOKIE_BROWSER_OPTIONS = COOKIE_BROWSERS.map((b) => ({
value: b,
label: b.charAt(0).toUpperCase() + b.slice(1)
}))
const UPDATE_CHANNEL_OPTIONS = [
{ value: 'stable', label: 'Stable' },
{ value: 'nightly', label: 'Nightly' }
]
export function SettingsView(): React.JSX.Element {
const styles = useStyles()
const errText = useErrorTextStyles()
const screen = useScreenStyles()
// Settings search (Phase O). Rather than thread a query through all ~11 cards,
// filter by toggling each card's `display` based on whether its text matches.
// The cards are the root's direct children; the search box (marked
// data-settings-search) is skipped. React never sets `style` on the cards, so
// our inline display survives their re-renders.
const [search, setSearch] = useState('')
const [noResults, setNoResults] = useState(false)
const rootRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const root = rootRef.current
if (!root) return
const q = search.trim().toLowerCase()
let shown = 0
for (const el of Array.from(root.children) as HTMLElement[]) {
if (el.dataset.settingsSearch !== undefined) continue
const match = !q || (el.textContent ?? '').toLowerCase().includes(q)
el.style.display = match ? '' : 'none'
if (match) shown++
}
setNoResults(q !== '' && shown === 0)
}, [search])
const videoDir = useSettings((s) => s.videoDir)
const audioDir = useSettings((s) => s.audioDir)
const chooseDir = useSettings((s) => s.chooseDir)
const clearDir = useSettings((s) => s.clearDir)
const defaultKind = useSettings((s) => s.defaultKind)
const defaultVideoQuality = useSettings((s) => s.defaultVideoQuality)
const defaultAudioQuality = useSettings((s) => s.defaultAudioQuality)
const maxConcurrent = useSettings((s) => s.maxConcurrent)
const filenameTemplate = useSettings((s) => s.filenameTemplate)
const theme = useSettings((s) => s.theme)
const accentColor = useSettings((s) => s.accentColor)
const highContrast = useSystemTheme((s) => s.shouldUseHighContrastColors)
const clipboardWatch = useSettings((s) => s.clipboardWatch)
const downloadOptions = useSettings((s) => s.downloadOptions)
const proxy = useSettings((s) => s.proxy)
const rateLimit = useSettings((s) => s.rateLimit)
const useAria2c = useSettings((s) => s.useAria2c)
const youtubePlayerClient = useSettings((s) => s.youtubePlayerClient)
const youtubePoToken = useSettings((s) => s.youtubePoToken)
const cookieSource = useSettings((s) => s.cookieSource)
const cookiesBrowser = useSettings((s) => s.cookiesBrowser)
const restrictFilenames = useSettings((s) => s.restrictFilenames)
const downloadArchive = useSettings((s) => s.downloadArchive)
const customCommandEnabled = useSettings((s) => s.customCommandEnabled)
const defaultTemplateId = useSettings((s) => s.defaultTemplateId)
const notifyOnComplete = useSettings((s) => s.notifyOnComplete)
const minimizeToTray = useSettings((s) => s.minimizeToTray)
const launchAtStartup = useSettings((s) => s.launchAtStartup)
const updateToken = useSettings((s) => s.updateToken)
const autoUpdateYtdlp = useSettings((s) => s.autoUpdateYtdlp)
const ytdlpChannel = useSettings((s) => s.ytdlpChannel)
const ytdlpLastUpdateCheck = useSettings((s) => s.ytdlpLastUpdateCheck)
const update = useSettings((s) => s.update)
const templates = useTemplates((s) => s.templates)
const errorEntries = useErrorLog((s) => s.entries)
const clearErrorLog = useErrorLog((s) => s.clear)
const formatQuality = defaultKind === 'audio' ? defaultAudioQuality : defaultVideoQuality
const formatValue = `${defaultKind}|${formatQuality}`
const [checking, setChecking] = useState(false)
const [version, setVersion] = useState<YtdlpVersionResult | null>(null)
const [ffmpeg, setFfmpeg] = useState<FfmpegVersionResult | null>(null)
const [mintingPot, setMintingPot] = useState(false)
const [potHint, setPotHint] = useState<string | null>(null)
const [updating, setUpdating] = useState(false)
const [updateResult, setUpdateResult] = useState<YtdlpUpdateResult | null>(null)
// --- AeroFetch app self-update ---
const [appVersion, setAppVersion] = useState('')
const [appUpd, setAppUpd] = useState<AppUpdateInfo | null>(null)
const [appChecking, setAppChecking] = useState(false)
const [appDownloading, setAppDownloading] = useState(false)
const [appFraction, setAppFraction] = useState<number | undefined>(undefined)
const [appUpdError, setAppUpdError] = useState<string | null>(null)
const [cookiesStatus, setCookiesStatus] = useState<CookiesStatus | null>(null)
const [loginUrl, setLoginUrl] = useState('https://www.youtube.com')
const [signingIn, setSigningIn] = useState(false)
const [loginError, setLoginError] = useState<string | null>(null)
const [exporting, setExporting] = useState(false)
const [exportResult, setExportResult] = useState<BackupExportResult | null>(null)
const [importing, setImporting] = useState(false)
const [importResult, setImportResult] = useState<BackupImportResult | null>(null)
const [confirmClearLog, setConfirmClearLog] = useState(false)
useEffect(() => {
window.api.cookiesStatus().then(setCookiesStatus)
}, [])
useEffect(() => {
window.api.getAppVersion().then(setAppVersion).catch(logError('getAppVersion'))
return window.api.onAppUpdateProgress((p) => setAppFraction(p.fraction))
}, [])
useEffect(() => {
// Show the current yt-dlp version without a manual click, and reflect a
// background auto-update (which may run on launch) live in this panel.
window.api.getYtdlpVersion().then(setVersion).catch(logError('getYtdlpVersion'))
// ffmpeg/ffprobe are display-only (bundled, not auto-updated); load them once.
window.api.getFfmpegVersions().then(setFfmpeg).catch(logError('getFfmpegVersions'))
return window.api.onYtdlpAutoUpdateStatus((s) => {
if (s.phase === 'checking') {
setChecking(true)
return
}
setChecking(false)
if (s.version) setVersion({ ok: true, version: s.version })
if (s.checkedAt) useSettings.setState({ ytdlpLastUpdateCheck: s.checkedAt })
if (s.phase === 'updated') {
setUpdateResult({ ok: true, output: `Updated to yt-dlp ${s.version ?? ''}`.trim() })
} else if (s.phase === 'error' && s.error) {
setUpdateResult({ ok: false, error: s.error })
}
})
}, [])
async function checkAppUpdate(): Promise<void> {
setAppChecking(true)
setAppUpd(null)
setAppUpdError(null)
try {
const info = await window.api.checkForAppUpdate()
// Funnel a failed check into the single error slot rather than storing a
// second not-ok AppUpdateInfo, so only one error message can ever render.
if (info.ok) setAppUpd(info)
else setAppUpdError(info.error ?? 'Update check failed.')
} catch (e) {
setAppUpdError(e instanceof Error ? e.message : String(e))
} finally {
setAppChecking(false)
}
}
async function installAppUpdate(): Promise<void> {
if (!appUpd?.downloadUrl) return
setAppDownloading(true)
setAppFraction(undefined)
setAppUpdError(null)
try {
const dl = await window.api.downloadAppUpdate(appUpd.downloadUrl)
if (!dl.ok || !dl.filePath) {
setAppUpdError(dl.error ?? 'Download failed.')
return
}
const run = await window.api.runAppUpdate(dl.filePath)
// On success the app quits as the installer launches; only errors return here.
if (!run.ok) setAppUpdError(run.error ?? 'Could not start the installer.')
} catch (e) {
setAppUpdError(e instanceof Error ? e.message : String(e))
} finally {
setAppDownloading(false)
}
}
async function exportBackup(): Promise<void> {
setExporting(true)
setExportResult(null)
try {
setExportResult(await window.api.exportBackup())
} catch (e) {
setExportResult({ ok: false, error: e instanceof Error ? e.message : String(e) })
} finally {
setExporting(false)
}
}
async function importBackup(): Promise<void> {
setImporting(true)
setImportResult(null)
try {
const result = await window.api.importBackup()
setImportResult(result)
if (result.ok) {
// Settings + templates changed underneath the stores — reload both.
const [s, t] = await Promise.all([window.api.getSettings(), window.api.listTemplates()])
useSettings.setState({ ...s, loaded: true })
useTemplates.setState({ templates: t })
}
} catch (e) {
setImportResult({ ok: false, error: e instanceof Error ? e.message : String(e) })
} finally {
setImporting(false)
}
}
function copyErrorReport(): void {
// The button is disabled when there are no entries, so `report` is always
// non-empty here — no need for an unreachable "No errors logged." fallback (L159).
const report = errorEntries
.map((e) =>
[new Date(e.occurredAt).toLocaleString(), e.title ?? e.url, e.url, e.error].join('\n')
)
.join('\n\n---\n\n')
navigator.clipboard.writeText(report).catch(() => {})
}
async function signIn(): Promise<void> {
setSigningIn(true)
setLoginError(null)
try {
const result = await window.api.cookiesLogin(loginUrl)
if (result.ok && result.cookieCount === 0) {
// Window closed without capturing anything — don't imply success (L50).
setLoginError('No cookies were captured — did you sign in before closing the window?')
} else if (result.ok) {
setCookiesStatus(await window.api.cookiesStatus())
} else {
setLoginError(result.error ?? 'Sign-in failed.')
}
} catch (e) {
setLoginError(e instanceof Error ? e.message : String(e))
} finally {
setSigningIn(false)
}
}
async function clearSavedCookies(): Promise<void> {
await window.api.cookiesClear()
setCookiesStatus({ exists: false })
}
function onFormatSelect(value: string): void {
const sep = value.indexOf('|')
const kind: MediaKind = sep >= 0 && value.slice(0, sep) === 'audio' ? 'audio' : 'video'
const quality = sep >= 0 ? value.slice(sep + 1) : value
update(
kind === 'audio'
? { defaultKind: 'audio', defaultAudioQuality: quality }
: { defaultKind: 'video', defaultVideoQuality: quality }
)
}
async function checkVersion(): Promise<void> {
setChecking(true)
setVersion(null)
try {
setVersion(await window.api.getYtdlpVersion())
} catch (e) {
setVersion({ ok: false, error: e instanceof Error ? e.message : String(e) })
} finally {
setChecking(false)
}
}
async function runUpdate(): Promise<void> {
setUpdating(true)
setUpdateResult(null)
try {
const result = await window.api.updateYtdlp(ytdlpChannel)
setUpdateResult(result)
if (result.ok) setVersion(null) // stale — prompt a re-check rather than show a wrong version
} catch (e) {
setUpdateResult({ ok: false, error: e instanceof Error ? e.message : String(e) })
} finally {
setUpdating(false)
}
}
return (
<div className={mergeClasses(styles.root, screen.width)} ref={rootRef}>
<div data-settings-search>
<ScreenHeader
title="Settings"
description="Folders, formats, network, cookies, and more."
/>
</div>
<div data-settings-search>
<Input
input={{ 'aria-label': 'Search settings' }}
value={search}
onChange={(_, d) => setSearch(d.value)}
placeholder="Search settings…"
contentBefore={<SearchRegular />}
style={{ width: '100%' }}
/>
{noResults && (
<Caption1 style={{ color: tokens.colorNeutralForeground3, marginTop: '8px' }}>
No settings match {search}.
</Caption1>
)}
</div>
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<ArrowDownloadRegular className={styles.sectionIcon} />
<Subtitle2>Downloads</Subtitle2>
</div>
<Field
label="Video folder"
hint="Where video downloads are saved. Leave blank to use Documents\Video."
>
<div className={styles.folderRow}>
<Input
readOnly
className={styles.folderInput}
value={videoDir}
placeholder="Documents\Video (default)"
contentBefore={<FolderRegular />}
/>
<Button icon={<FolderRegular />} onClick={() => chooseDir('videoDir')}>
Browse
</Button>
{videoDir && (
<Button appearance="subtle" onClick={() => clearDir('videoDir')}>
Reset
</Button>
)}
</div>
</Field>
<Field
label="Audio folder"
hint="Where audio downloads are saved. Leave blank to use Documents\Audio."
>
<div className={styles.folderRow}>
<Input
readOnly
className={styles.folderInput}
value={audioDir}
placeholder="Documents\Audio (default)"
contentBefore={<FolderRegular />}
/>
<Button icon={<FolderRegular />} onClick={() => chooseDir('audioDir')}>
Browse
</Button>
{audioDir && (
<Button appearance="subtle" onClick={() => clearDir('audioDir')}>
Reset
</Button>
)}
</div>
</Field>
<Field label="Default format">
<Select
aria-label="Default format"
value={formatValue}
options={FORMAT_OPTIONS}
onChange={onFormatSelect}
/>
</Field>
<Field
label="Maximum simultaneous downloads"
hint="How many downloads run at once. 23 is a good balance."
>
<SpinButton
value={maxConcurrent}
min={1}
max={5}
onChange={(_, d) => {
const v = d.value ?? Number(d.displayValue)
if (typeof v === 'number' && Number.isFinite(v)) {
update({ maxConcurrent: Math.min(5, Math.max(1, Math.round(v))) })
// Raising the cap should start queued items right away.
useDownloads.getState().pump()
}
}}
/>
</Field>
<Field
label="Detect links from clipboard"
hint="When AeroFetch gains focus, offer a video link you've just copied."
>
<Switch
checked={clipboardWatch}
onChange={(_, d) => update({ clipboardWatch: d.checked })}
label={clipboardWatch ? 'On' : 'Off'}
/>
</Field>
<Field
label="Notify when downloads finish"
hint="Shows a native Windows notification when a download completes or fails."
>
<Switch
checked={notifyOnComplete}
onChange={(_, d) => update({ notifyOnComplete: d.checked })}
label={notifyOnComplete ? 'On' : 'Off'}
/>
</Field>
<Field
label="Keep running in the tray"
hint="When on, closing the window minimizes AeroFetch to the system tray instead of quitting — so downloads keep going and watched channels can auto-download new uploads. Use the tray icon to reopen or quit. (A download in progress always keeps AeroFetch running, even when this is off.)"
>
<Switch
checked={minimizeToTray}
onChange={(_, d) => update({ minimizeToTray: d.checked })}
label={minimizeToTray ? 'On' : 'Off'}
/>
</Field>
<Field
label="Start with Windows"
hint="Launch AeroFetch automatically when you sign in — useful with auto-download so watched channels stay current in the background."
>
<Switch
checked={launchAtStartup}
onChange={(_, d) => update({ launchAtStartup: d.checked })}
label={launchAtStartup ? 'On' : 'Off'}
/>
</Field>
</Card>
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<PaintBucketRegular className={styles.sectionIcon} />
<Subtitle2>Appearance</Subtitle2>
</div>
<Field label="Theme">
<Select
aria-label="Theme"
value={theme}
options={THEME_MODE_OPTIONS}
onChange={(v) => update({ theme: v as ThemeMode })}
/>
</Field>
<Field label="Accent color">
<div className={styles.swatchRow}>
{ACCENT_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
className={mergeClasses(
styles.swatch,
accentColor === opt.value && styles.swatchActive
)}
style={{ backgroundColor: opt.swatch }}
onClick={() => update({ accentColor: opt.value as AccentColor })}
aria-pressed={accentColor === opt.value}
aria-label={opt.label}
title={opt.label}
/>
))}
</div>
</Field>
<Caption1 className={styles.hint}>
{highContrast
? 'A Windows high-contrast theme is active — AeroFetch follows your system colors.'
: 'AeroFetch automatically follows a Windows high-contrast theme if you turn one on.'}
</Caption1>
{highContrast && (
<div className={styles.folderRow}>
<Button
size="small"
icon={<AccessibilityRegular />}
onClick={() => window.api.openHighContrastSettings()}
>
Open accessibility settings
</Button>
</div>
)}
</Card>
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<OptionsRegular className={styles.sectionIcon} />
<Subtitle2>Format &amp; 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}>
<GlobeRegular className={styles.sectionIcon} />
<Subtitle2>Network</Subtitle2>
</div>
<Field
label="Proxy"
hint="HTTP/HTTPS/SOCKS proxy URL, e.g. socks5://127.0.0.1:1080. Leave blank to use the system default."
>
<Input
value={proxy}
placeholder="socks5://127.0.0.1:1080"
onChange={(_, d) => update({ proxy: d.value })}
/>
</Field>
<Field
label="Rate limit"
hint="Caps download speed (e.g. 500K or 2M). Leave blank for unlimited."
>
<Input
value={rateLimit}
placeholder="2M"
onChange={(_, d) => update({ rateLimit: d.value })}
/>
</Field>
<Field
label="Use aria2c downloader"
hint="Multi-connection downloads for faster speeds on supported sites. Falls back to the standard downloader if the accelerator isn't available."
>
<Switch
checked={useAria2c}
onChange={(_, d) => update({ useAria2c: d.checked })}
label={useAria2c ? 'On' : 'Off'}
/>
</Field>
<Field
label="YouTube client (advanced)"
hint="Override the YouTube extraction client when downloads start failing the bot check. Try web_safari, tv, or mweb. Leave blank for yt-dlp's default."
>
<Input
value={youtubePlayerClient}
placeholder="web_safari"
onChange={(_, d) => update({ youtubePlayerClient: d.value })}
/>
</Field>
<Field
label="YouTube PO token (advanced)"
hint={
potHint ??
'A token that helps get past YouTube\'s bot check. Click "Fetch" to grab one automatically, or paste it manually. Signing in with cookies (above) is usually the easier fix.'
}
>
<div className={styles.folderRow}>
<Input
style={{ flexGrow: 1 }}
value={youtubePoToken}
placeholder="(none)"
onChange={(_, d) => {
update({ youtubePoToken: d.value })
setPotHint(null)
}}
/>
<Button
size="small"
disabled={mintingPot}
icon={mintingPot ? <Spinner size="tiny" /> : undefined}
onClick={async () => {
setMintingPot(true)
setPotHint(null)
try {
const token = await window.api.mintPoToken()
if (token) {
setPotHint('Token saved.')
} else {
setPotHint(
'Token not found — try signing into YouTube first via Cookies above.'
)
}
} catch {
setPotHint('Failed to open YouTube window.')
} finally {
setMintingPot(false)
}
}}
>
{mintingPot ? 'Fetching…' : 'Fetch'}
</Button>
</div>
</Field>
</Card>
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<CookiesRegular className={styles.sectionIcon} />
<Subtitle2>Cookies</Subtitle2>
</div>
<Caption1 className={styles.hint}>
Some sites only serve full quality, age-restricted, or members-only video to a logged-in
session. Supply cookies so yt-dlp can act like one.
</Caption1>
<Field label="Cookie source">
<Select
aria-label="Cookie source"
value={cookieSource}
options={COOKIE_SOURCE_OPTIONS}
onChange={(v) => update({ cookieSource: v as CookieSource })}
/>
</Field>
{cookieSource === 'browser' && (
<Field
label="Browser"
hint="Reads that browser's saved cookies directly. Close the browser first if it won't let AeroFetch read them."
>
<Select
aria-label="Browser"
value={cookiesBrowser}
options={COOKIE_BROWSER_OPTIONS}
onChange={(v) => update({ cookiesBrowser: v as CookieBrowser })}
/>
</Field>
)}
{cookieSource === 'login' && (
<>
<Field
label="Site to sign in to"
hint="Opens a sign-in window. Log in, then close the window — your cookies are saved automatically."
>
<div className={styles.folderRow}>
<Input
className={styles.folderInput}
value={loginUrl}
placeholder="https://www.youtube.com"
onChange={(_, d) => setLoginUrl(d.value)}
/>
<Button appearance="primary" onClick={signIn} disabled={signingIn}>
{signingIn ? 'Signing in…' : 'Sign in…'}
</Button>
</div>
</Field>
<div className={styles.folderRow}>
<Caption1 className={styles.hint}>
{cookiesStatus?.exists
? `Cookies saved ${new Date(cookiesStatus.savedAt!).toLocaleString()}.`
: 'Not signed in yet.'}
</Caption1>
{cookiesStatus?.exists && (
<Button size="small" onClick={clearSavedCookies}>
Clear saved cookies
</Button>
)}
</div>
{loginError && <Caption1 className={errText.error}>{loginError}</Caption1>}
</>
)}
</Card>
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<CodeRegular className={styles.sectionIcon} />
<Subtitle2>Custom commands</Subtitle2>
</div>
<Caption1 className={styles.hint}>
Named templates of extra yt-dlp flags your own power-user recipes (e.g.
--write-thumbnail, --no-mtime, anything yt-dlp supports). Appended after every other
option, so a template flag can override a setting above it.
</Caption1>
<Field
label="Run custom command"
hint="Applies the default template below to every new download (still overridable per download)."
>
<Switch
checked={customCommandEnabled}
onChange={(_, d) => update({ customCommandEnabled: d.checked })}
label={customCommandEnabled ? 'On' : 'Off'}
/>
</Field>
{customCommandEnabled && (
<Field label="Default template">
<Select
aria-label="Default template"
value={defaultTemplateId ?? 'none'}
options={[
{ value: 'none', label: 'None' },
...templates.map((t) => ({ value: t.id, label: t.name }))
]}
onChange={(v) => update({ defaultTemplateId: v === 'none' ? null : v })}
/>
</Field>
)}
<TemplateManager />
</Card>
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<DocumentRegular className={styles.sectionIcon} />
<Subtitle2>Filenames</Subtitle2>
</div>
<Field
label="Filename template"
hint="yt-dlp output template. Tokens like %(title)s and %(ext)s are filled in per video."
>
<Input
value={filenameTemplate}
onChange={(_, d) => update({ filenameTemplate: d.value })}
/>
</Field>
<Caption1 className={styles.hint}>
Example: {filenameTemplate.replace('%(title)s', 'My Video').replace('%(ext)s', 'mp4')}
</Caption1>
<Field
label="Restrict filenames"
hint="Sanitize titles to plain ASCII letters/digits, no spaces — safer for old filesystems and shells."
>
<Switch
checked={restrictFilenames}
onChange={(_, d) => update({ restrictFilenames: d.checked })}
label={restrictFilenames ? 'On' : 'Off'}
/>
</Field>
<Field
label="Skip already-downloaded videos"
hint="Keeps a record of completed downloads (--download-archive) and skips them on repeat playlist/channel runs."
>
<Switch
checked={downloadArchive}
onChange={(_, d) => update({ downloadArchive: d.checked })}
label={downloadArchive ? 'On' : 'Off'}
/>
</Field>
</Card>
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<DocumentArrowDownRegular className={styles.sectionIcon} />
<Subtitle2>Backup &amp; restore</Subtitle2>
</div>
<Caption1 className={styles.hint}>
Save your settings and custom-command templates to a JSON file, or restore them on another
machine. Does not include download history or credentials (proxy, API tokens re-enter
those after import).
</Caption1>
<div className={styles.folderRow}>
<Button icon={<DocumentArrowDownRegular />} onClick={exportBackup} disabled={exporting}>
{exporting ? 'Exporting…' : 'Export backup…'}
</Button>
<Button icon={<DocumentArrowUpRegular />} onClick={importBackup} disabled={importing}>
{importing ? 'Importing…' : 'Import backup…'}
</Button>
</div>
{exportResult?.ok && exportResult.path && (
<Caption1 className={styles.hint}>Saved to {exportResult.path}</Caption1>
)}
{exportResult && !exportResult.ok && exportResult.error && (
<Caption1 className={errText.error}>{exportResult.error}</Caption1>
)}
{importResult?.ok && (
<Caption1 className={styles.hint}>Settings and templates restored.</Caption1>
)}
{importResult && !importResult.ok && importResult.error && (
<Caption1 className={errText.error}>{importResult.error}</Caption1>
)}
</Card>
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<BugRegular className={styles.sectionIcon} />
<Subtitle2>Diagnostics</Subtitle2>
</div>
<Caption1 className={styles.hint}>
Failed downloads are logged here even after you clear the queue, so you can copy the
details into a bug report.
</Caption1>
<div className={styles.folderRow}>
<Button
icon={<CopyRegular />}
onClick={copyErrorReport}
disabled={errorEntries.length === 0}
>
Copy full report
</Button>
{confirmClearLog ? (
<>
<Caption1>
Clear all {errorEntries.length} {errorEntries.length === 1 ? 'error' : 'errors'}?
</Caption1>
<Button
icon={<DeleteRegular />}
appearance="primary"
onClick={() => {
clearErrorLog()
setConfirmClearLog(false)
}}
>
Clear log
</Button>
<Button appearance="subtle" onClick={() => setConfirmClearLog(false)}>
Cancel
</Button>
</>
) : (
<Button
icon={<DeleteRegular />}
onClick={() => setConfirmClearLog(true)}
disabled={errorEntries.length === 0}
>
Clear log
</Button>
)}
</div>
{errorEntries.length === 0 ? (
<Caption1 className={styles.hint}>No errors logged.</Caption1>
) : (
<div className={styles.errorList}>
{errorEntries.slice(0, 20).map((e) => (
<div key={e.id + String(e.occurredAt)} className={styles.errorRow}>
<div className={styles.errorRowHeader}>
<Text className={styles.errorRowTitle}>{e.title ?? e.url}</Text>
<Caption1 className={styles.hint}>
{new Date(e.occurredAt).toLocaleString()}
</Caption1>
</div>
<Caption1 className={errText.errorPre}>{e.error}</Caption1>
</div>
))}
</div>
)}
</Card>
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<ArrowSyncRegular className={styles.sectionIcon} />
<Subtitle2>Software update</Subtitle2>
</div>
<Caption1 className={styles.hint}>
{appVersion
? `You're running AeroFetch v${appVersion}. Updates are fetched from the AeroFetch release repo.`
: 'Checking your AeroFetch version…'}
</Caption1>
<div className={styles.folderRow}>
<Button
icon={<ArrowSyncRegular />}
onClick={checkAppUpdate}
disabled={appChecking || appDownloading}
>
{appChecking ? 'Checking…' : 'Check for updates'}
</Button>
{appChecking && <Spinner size="tiny" />}
</div>
<Field
label="Update access token"
hint="Only needed if the update server requires sign-in (you'll see “could not reach the update server” without it). Paste a read-only access token to enable update checks and downloads. Stored encrypted on this device; leave blank for anonymous access."
>
<Input
type="password"
value={updateToken}
placeholder="Optional — access token"
onChange={(_, d) => update({ updateToken: d.value })}
contentBefore={<ArrowSyncRegular />}
/>
</Field>
{appUpd?.ok && appUpd.available && (
<>
<Text style={{ fontWeight: tokens.fontWeightSemibold }}>
Version {appUpd.latestVersion} is available here&apos;s what changed:
</Text>
{appUpd.notes && <div className={styles.notesBox}>{appUpd.notes}</div>}
<div className={styles.folderRow}>
<Button
appearance="primary"
icon={<ArrowDownloadRegular />}
onClick={installAppUpdate}
disabled={appDownloading || !appUpd.downloadUrl}
>
{appDownloading ? 'Downloading…' : 'Update now'}
</Button>
{appUpd.htmlUrl && (
<Button
icon={<OpenRegular />}
onClick={() => window.open(appUpd.htmlUrl, '_blank')}
>
View release
</Button>
)}
</div>
{!appUpd.downloadUrl && (
<Caption1 className={styles.hint}>
This release has no installer attached use View release to download it manually.
</Caption1>
)}
{appDownloading && <ProgressBar value={appFraction} />}
</>
)}
{appUpd?.ok && !appUpd.available && (
<Text>You&apos;re up to date v{appUpd.currentVersion} is the latest.</Text>
)}
{appUpdError && <Caption1 className={errText.errorPre}>{appUpdError}</Caption1>}
</Card>
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<InfoRegular className={styles.sectionIcon} />
<Subtitle2>About</Subtitle2>
</div>
<Caption1 className={styles.hint}>
AeroFetch is a generic frontend for yt-dlp. It bundles ffmpeg and manages its own copy of
yt-dlp, keeping it up to date automatically.
</Caption1>
<Field
label="Keep yt-dlp updated automatically"
hint="Checks once a day on launch and updates the downloader in the background, so site changes don't start breaking your downloads."
>
<Switch
checked={autoUpdateYtdlp}
onChange={(_, d) => update({ autoUpdateYtdlp: d.checked })}
label={autoUpdateYtdlp ? 'On' : 'Off'}
/>
</Field>
{version?.ok && (
<Text style={{ fontFamily: tokens.fontFamilyMonospace }}>yt-dlp {version.version}</Text>
)}
{version && !version.ok && (
<Caption1 className={errText.errorPre}>{version.error}</Caption1>
)}
{ffmpeg && (
<>
<Text style={{ fontFamily: tokens.fontFamilyMonospace }}>
ffmpeg {ffmpeg.ffmpeg ?? 'not found'}
</Text>
<Text style={{ fontFamily: tokens.fontFamilyMonospace }}>
ffprobe {ffmpeg.ffprobe ?? 'not found'}
</Text>
</>
)}
{ytdlpLastUpdateCheck > 0 && (
<Caption1 className={styles.hint}>
Last checked for updates {new Date(ytdlpLastUpdateCheck).toLocaleString()}.
</Caption1>
)}
<div className={styles.folderRow}>
<Button onClick={checkVersion} disabled={checking}>
Check yt-dlp version
</Button>
{checking && <Spinner size="tiny" />}
</div>
<Field
label="Update channel"
hint="Nightly tracks yt-dlp's daily build; stable is the tagged release. Nightly follows YouTube changes fastest."
>
<Select
aria-label="Update channel"
value={ytdlpChannel}
options={UPDATE_CHANNEL_OPTIONS}
onChange={(v) => update({ ytdlpChannel: v as YtdlpUpdateChannel })}
/>
</Field>
<div className={styles.folderRow}>
<Button icon={<ArrowClockwiseRegular />} onClick={runUpdate} disabled={updating}>
{updating ? 'Updating…' : 'Update yt-dlp'}
</Button>
{updating && <Spinner size="tiny" />}
</div>
{updateResult?.ok && (
<Text style={{ fontFamily: tokens.fontFamilyMonospace, whiteSpace: 'pre-wrap' }}>
{updateResult.output || 'yt-dlp is already up to date.'}
</Text>
)}
{updateResult && !updateResult.ok && (
<Caption1 className={errText.errorPre}>{updateResult.error}</Caption1>
)}
</Card>
</div>
)
}