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
+72 -3
View File
@@ -1,13 +1,37 @@
import { execFile } from 'child_process'
import { existsSync } from 'fs'
import { getYtdlpPath } from './binaries'
import { existsSync, mkdirSync, copyFileSync } from 'fs'
import { dirname } from 'path'
import { getYtdlpPath, getBundledYtdlpPath } from './binaries'
import { getSettings, setSettings } from './settings'
import { shouldAutoCheckYtdlp } from './ytdlpPolicy'
import {
isYtdlpUpdateChannel,
type YtdlpVersionResult,
type YtdlpUpdateChannel,
type YtdlpUpdateResult
type YtdlpUpdateResult,
type YtdlpAutoUpdateStatus
} from '@shared/ipc'
/**
* Ensure the writable managed yt-dlp.exe exists, copying the bundled seed in if
* it doesn't (first run, or after the user/AV deleted it). Best-effort and
* idempotent — when the copy is already present this is just one existsSync, so
* it's cheap to call before any spawn/update. Does nothing if the seed itself is
* gone (a broken install); callers surface their own missing-binary error then.
*/
export function ensureManagedYtdlp(): void {
const managed = getYtdlpPath()
if (existsSync(managed)) return
const seed = getBundledYtdlpPath()
if (!existsSync(seed)) return
try {
mkdirSync(dirname(managed), { recursive: true })
copyFileSync(seed, managed)
} catch {
/* best-effort; a failed seed just leaves the managed copy absent */
}
}
/**
* Step-1 spike: spawn the bundled yt-dlp and read back `--version`.
* Proves the main-process → bundled-binary → IPC path end to end.
@@ -51,6 +75,9 @@ export function updateYtdlp(channel: YtdlpUpdateChannel): Promise<YtdlpUpdateRes
return Promise.resolve({ ok: false, error: 'Unsupported update channel.' })
}
// Self-heal: restore the managed copy from the bundled seed before updating, so
// a deleted yt-dlp.exe is re-seeded and then updated in one click.
ensureManagedYtdlp()
const ytdlpPath = getYtdlpPath()
if (!existsSync(ytdlpPath)) {
@@ -78,3 +105,45 @@ export function updateYtdlp(channel: YtdlpUpdateChannel): Promise<YtdlpUpdateRes
)
})
}
/**
* Startup yt-dlp maintenance: always seed the managed copy, then — if auto-update
* is on and the daily throttle has elapsed — self-update it to the configured
* channel. Whether it changed is decided by comparing `--version` before/after
* (robust against yt-dlp's localized "up to date" wording). Status is pushed to
* the renderer so the Settings UI reflects a check it didn't trigger.
*
* Best-effort: every failure path still records the attempt time (so an
* unreachable update server doesn't re-hit the network every launch) and never
* throws — a stale binary must never block the app from starting.
*/
export async function runStartupYtdlpAutoUpdate(
send: (status: YtdlpAutoUpdateStatus) => void
): Promise<void> {
ensureManagedYtdlp()
const settings = getSettings()
if (!shouldAutoCheckYtdlp(settings.autoUpdateYtdlp, settings.ytdlpLastUpdateCheck, Date.now())) {
return
}
const channel = settings.ytdlpChannel
send({ phase: 'checking', channel })
const before = await getYtdlpVersion()
const result = await updateYtdlp(channel)
setSettings({ ytdlpLastUpdateCheck: Date.now() })
if (!result.ok) {
send({ phase: 'error', channel, error: result.error, checkedAt: Date.now() })
return
}
const after = await getYtdlpVersion()
const changed = before.ok && after.ok && before.version !== after.version
send({
phase: changed ? 'updated' : 'current',
channel,
version: after.ok ? after.version : undefined,
checkedAt: Date.now()
})
}