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
+22 -1
View File
@@ -13,10 +13,31 @@ export function getBinDir(): string {
return is.dev ? join(app.getAppPath(), 'resources', 'bin') : join(process.resourcesPath, 'bin')
}
export function getYtdlpPath(): string {
/**
* AeroFetch keeps its OWN writable copy of yt-dlp.exe under userData, separate
* from the read-only bundled seed in resources/bin. The managed copy is what
* actually gets spawned and self-updated (`--update-to`), so an app reinstall or
* portable re-extraction — which only ever replace the bundled seed — can never
* roll a freshly-updated yt-dlp back to a stale version. See ensureManagedYtdlp
* in ytdlp.ts, which seeds this from getBundledYtdlpPath() on first run.
*
* ffmpeg/ffprobe/aria2c stay in getBinDir(): they're not self-updating and
* yt-dlp finds them via `--ffmpeg-location <binDir>`.
*/
export function getManagedBinDir(): string {
return join(app.getPath('userData'), 'bin')
}
/** The bundled, read-only yt-dlp.exe seeded into the managed copy when missing. */
export function getBundledYtdlpPath(): string {
return join(getBinDir(), 'yt-dlp.exe')
}
/** The managed (spawned + auto-updated) yt-dlp.exe under userData. */
export function getYtdlpPath(): string {
return join(getManagedBinDir(), 'yt-dlp.exe')
}
export function getFfmpegPath(): string {
return join(getBinDir(), 'ffmpeg.exe')
}
+5 -1
View File
@@ -11,6 +11,7 @@ import {
getSystem32Path
} from './binaries'
import { getSettings, getDownloadArchivePath, getDefaultMediaDir } from './settings'
import { ensureManagedYtdlp } from './ytdlp'
import { getCookiesFilePath } from './cookies'
import { listTemplates } from './templates'
import { assertHttpUrl } from './url'
@@ -240,11 +241,14 @@ export function startDownload(
wc: WebContents,
opts: StartDownloadOptions
): StartDownloadResult {
// Self-heal the managed copy from the bundled seed before spawning, so a
// never-seeded or deleted yt-dlp.exe doesn't fail an otherwise-fine download.
ensureManagedYtdlp()
const ytdlp = getYtdlpPath()
if (!existsSync(ytdlp)) {
return {
ok: false,
error: `yt-dlp.exe not found at ${ytdlp}\nDrop it into resources/bin/ (see the README there).`
error: `yt-dlp.exe is missing and couldn't be restored from the bundle.\nReinstall AeroFetch, or drop yt-dlp.exe into resources/bin/ (see the README there).`
}
}
// ffmpeg is used by nearly every download (merge, audio extract, thumbnail/
+36
View File
@@ -0,0 +1,36 @@
import { execFile } from 'child_process'
import { existsSync } from 'fs'
import { getFfmpegPath, getFfprobePath } from './binaries'
import type { FfmpegVersionResult } from '@shared/ipc'
/**
* Read the version token from an ffmpeg/ffprobe binary. Both print a first line
* of the form "<tool> version <VERSION> Copyright ..." (e.g.
* "ffmpeg version 6.1.1-full_build-www.gyan.dev Copyright ..."), so we return the
* token after "version". Resolves null if the binary is absent, errors, times
* out, or the line doesn't parse — Settings then shows "not found" instead of
* failing. Unlike yt-dlp these are never self-updated, so there's no update path.
*/
function readToolVersion(path: string): Promise<string | null> {
if (!existsSync(path)) return Promise.resolve(null)
return new Promise((resolve) => {
execFile(path, ['-version'], { windowsHide: true, timeout: 15_000 }, (err, stdout) => {
if (err) {
resolve(null)
return
}
const firstLine = stdout.split('\n', 1)[0] ?? ''
const m = firstLine.match(/version\s+(\S+)/i)
resolve(m ? m[1] : null)
})
})
}
/** Versions of the bundled ffmpeg + ffprobe, read in parallel for the Settings panel. */
export async function getFfmpegVersions(): Promise<FfmpegVersionResult> {
const [ffmpeg, ffprobe] = await Promise.all([
readToolVersion(getFfmpegPath()),
readToolVersion(getFfprobePath())
])
return { ffmpeg, ffprobe }
}
+14 -1
View File
@@ -10,7 +10,8 @@ import {
type YtdlpUpdateChannel,
type SystemThemeInfo
} from '@shared/ipc'
import { getYtdlpVersion, updateYtdlp } from './ytdlp'
import { getYtdlpVersion, updateYtdlp, runStartupYtdlpAutoUpdate } from './ytdlp'
import { getFfmpegVersions } from './ffmpeg'
import { checkForAppUpdate, downloadAppUpdate, runAppUpdate } from './updater'
import { probeMedia } from './probe'
import { startDownload, cancelDownload, previewCommand } from './download'
@@ -190,6 +191,8 @@ function registerIpcHandlers(): void {
ipcMain.handle(IpcChannels.ytdlpVersion, () => getYtdlpVersion())
ipcMain.handle(IpcChannels.ffmpegVersion, () => getFfmpegVersions())
ipcMain.handle(IpcChannels.probe, (_e, url: string) => probeMedia(url))
ipcMain.handle(IpcChannels.downloadStart, (e, opts: StartDownloadOptions) => {
@@ -353,6 +356,16 @@ if (isPrimaryInstance) {
registerSendToShortcut()
createWindow()
// Keep yt-dlp current on its own: seed the managed copy, then (throttled to
// once a day) self-update it to the chosen channel so a stale binary can't
// silently cause YouTube 403s. Best-effort and off the critical path — it
// never blocks startup, and status is pushed to the window if it's listening.
runStartupYtdlpAutoUpdate((status) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send(IpcChannels.ytdlpAutoUpdateStatus, status)
}
}).catch(() => {})
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
+18
View File
@@ -11,6 +11,7 @@ import {
COOKIE_BROWSERS,
ACCENT_COLORS,
DEFAULT_DOWNLOAD_OPTIONS,
isYtdlpUpdateChannel,
type Settings,
type DownloadOptions,
type SponsorBlockCategory
@@ -37,6 +38,11 @@ const DEFAULTS: Settings = {
cookiesBrowser: 'chrome',
restrictFilenames: false,
downloadArchive: false,
// yt-dlp is self-managed: a writable copy under userData, auto-updated on the
// nightly channel (fastest to follow YouTube changes that cause 403s).
autoUpdateYtdlp: true,
ytdlpChannel: 'nightly',
ytdlpLastUpdateCheck: 0,
customCommandEnabled: false,
defaultTemplateId: null,
notifyOnComplete: true,
@@ -193,12 +199,24 @@ export function setSettings(partial: Partial<Settings>): Settings {
case 'useAria2c':
case 'restrictFilenames':
case 'downloadArchive':
case 'autoUpdateYtdlp':
case 'customCommandEnabled':
case 'notifyOnComplete':
case 'autoDownloadNew':
case 'hasCompletedOnboarding':
if (typeof value === 'boolean') s.set(key, value)
break
case 'ytdlpChannel':
// Same allowlist that guards the `--update-to` flag (audit F1).
if (isYtdlpUpdateChannel(value)) s.set('ytdlpChannel', value)
break
case 'ytdlpLastUpdateCheck': {
// Set by the main-process auto-updater; validated here since setSettings
// is the only writer. A bogus value at worst skips/forces one check.
const n = Number(value)
if (Number.isFinite(n) && n >= 0) s.set('ytdlpLastUpdateCheck', n)
break
}
case 'defaultTemplateId':
if (value === null || typeof value === 'string') s.set('defaultTemplateId', value)
break
+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()
})
}
+25
View File
@@ -0,0 +1,25 @@
/**
* Pure auto-update scheduling policy for yt-dlp. Kept free of any electron /
* node-runtime dependency (like buildArgs.ts) so the throttle can be unit-tested
* without spinning up Electron. ytdlp.ts imports these to drive the real check.
*/
/** Once-a-day throttle for the startup auto-update check. */
export const AUTO_UPDATE_INTERVAL_MS = 24 * 60 * 60 * 1000
/**
* Whether a background yt-dlp update check should run now: only when auto-update
* is enabled AND at least one interval has elapsed since the last check. A
* zero/absent lastCheck means "never checked" → run. `now` and the interval are
* passed in so the decision is a pure function of its inputs.
*/
export function shouldAutoCheckYtdlp(
enabled: boolean,
lastCheck: number,
now: number,
intervalMs: number = AUTO_UPDATE_INTERVAL_MS
): boolean {
if (!enabled) return false
if (!lastCheck) return true
return now - lastCheck >= intervalMs
}