feat: self-managing yt-dlp + ffmpeg version display

yt-dlp is now a managed, auto-updating binary rather than a static bundled
one. On launch AeroFetch seeds a writable copy under userData from the
bundled seed, self-heals it if it goes missing, and — throttled to once a
day — self-updates it to the configured channel (default: nightly). This
keeps the binary current so YouTube changes don't silently cause 403s, and
an app reinstall (or portable re-extraction) can no longer roll it back.
Settings shows an auto-update toggle, the live version, last-checked time,
and the channel selector.

Also surface the bundled ffmpeg/ffprobe versions in Settings (display only:
they have no self-update path and, unlike yt-dlp, don't break as sites
change, so there is no auto-update for them).

- binaries: split the read-only bundled seed from the managed userData copy
- ytdlp: ensureManagedYtdlp (seed + self-heal), startup auto-update runner
- ytdlpPolicy: pure once-a-day throttle decision (unit-tested)
- ffmpeg: parse ffmpeg/ffprobe -version for the Settings panel
- settings/ipc/preload/renderer: new settings, IPC channels, types, and UI

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 07:27:43 -04:00
parent fa699a803d
commit 4854fcc947
13 changed files with 356 additions and 18 deletions
+69 -12
View File
@@ -41,6 +41,7 @@ import {
type YtdlpVersionResult,
type YtdlpUpdateChannel,
type YtdlpUpdateResult,
type FfmpegVersionResult,
type AppUpdateInfo,
type MediaKind,
type CookieSource,
@@ -209,6 +210,9 @@ export function SettingsView(): React.JSX.Element {
const customCommandEnabled = useSettings((s) => s.customCommandEnabled)
const defaultTemplateId = useSettings((s) => s.defaultTemplateId)
const notifyOnComplete = useSettings((s) => s.notifyOnComplete)
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)
@@ -219,8 +223,8 @@ export function SettingsView(): React.JSX.Element {
const [checking, setChecking] = useState(false)
const [version, setVersion] = useState<YtdlpVersionResult | null>(null)
const [ffmpeg, setFfmpeg] = useState<FfmpegVersionResult | null>(null)
const [updateChannel, setUpdateChannel] = useState<YtdlpUpdateChannel>('stable')
const [updating, setUpdating] = useState(false)
const [updateResult, setUpdateResult] = useState<YtdlpUpdateResult | null>(null)
@@ -251,6 +255,28 @@ export function SettingsView(): React.JSX.Element {
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(() => {})
// ffmpeg/ffprobe are display-only (bundled, not auto-updated); load them once.
window.api.getFfmpegVersions().then(setFfmpeg).catch(() => {})
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)
@@ -378,7 +404,7 @@ export function SettingsView(): React.JSX.Element {
setUpdating(true)
setUpdateResult(null)
try {
const result = await window.api.updateYtdlp(updateChannel)
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) {
@@ -900,14 +926,21 @@ export function SettingsView(): React.JSX.Element {
<Subtitle2>About</Subtitle2>
</div>
<Caption1 className={styles.hint}>
AeroFetch is a generic frontend for yt-dlp. It bundles yt-dlp and ffmpeg.
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>
<div className={styles.folderRow}>
<Button onClick={checkVersion} disabled={checking}>
Check yt-dlp version
</Button>
{checking && <Spinner size="tiny" />}
</div>
<Field
label="Keep yt-dlp updated automatically"
hint="Checks once a day on launch and updates yt-dlp in the background, so YouTube changes don't start breaking downloads with 403 errors."
>
<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>
)}
@@ -916,13 +949,37 @@ export function SettingsView(): React.JSX.Element {
{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.">
<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={updateChannel}
value={ytdlpChannel}
options={UPDATE_CHANNEL_OPTIONS}
onChange={(v) => setUpdateChannel(v as YtdlpUpdateChannel)}
onChange={(v) => update({ ytdlpChannel: v as YtdlpUpdateChannel })}
/>
</Field>
<div className={styles.folderRow}>
+6
View File
@@ -29,6 +29,9 @@ if (import.meta.env.DEV && !window.api) {
cookiesBrowser: 'chrome',
restrictFilenames: false,
downloadArchive: false,
autoUpdateYtdlp: true,
ytdlpChannel: 'nightly',
ytdlpLastUpdateCheck: Date.now() - 3_600_000,
customCommandEnabled: false,
defaultTemplateId: null,
notifyOnComplete: true,
@@ -67,6 +70,7 @@ if (import.meta.env.DEV && !window.api) {
runAppUpdate: async () => ({ ok: true }),
onAppUpdateProgress: () => () => {},
getYtdlpVersion: async () => ({ ok: true, version: '2025.06.01 (UI preview mock)' }),
getFfmpegVersions: async () => ({ ffmpeg: '7.1-full_build (UI preview mock)', ffprobe: '7.1-full_build (UI preview mock)' }),
probe: async (url: string) => {
await new Promise((r) => setTimeout(r, 700)) // simulate network latency
// A 'list'/'playlist' URL exercises the playlist selection UI in preview.
@@ -154,6 +158,8 @@ if (import.meta.env.DEV && !window.api) {
await new Promise((r) => setTimeout(r, 600)) // simulate the self-update run
return { ok: true, output: `yt-dlp is already up to date (channel: ${channel}, UI preview mock)` }
},
// No background updater in the browser preview — hand back an inert unsubscribe.
onYtdlpAutoUpdateStatus: () => () => {},
listErrorLog: async () => [],
clearErrorLog: async () => [],
exportBackup: async () => ({ ok: true, path: 'C:\\Users\\you\\Downloads\\aerofetch-backup.json' }),
+3
View File
@@ -23,6 +23,9 @@ const FALLBACK: Settings = {
cookiesBrowser: 'chrome',
restrictFilenames: false,
downloadArchive: false,
autoUpdateYtdlp: true,
ytdlpChannel: 'nightly',
ytdlpLastUpdateCheck: 0,
customCommandEnabled: false,
defaultTemplateId: null,
notifyOnComplete: true,