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') 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') 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 { export function getFfmpegPath(): string {
return join(getBinDir(), 'ffmpeg.exe') return join(getBinDir(), 'ffmpeg.exe')
} }
+5 -1
View File
@@ -11,6 +11,7 @@ import {
getSystem32Path getSystem32Path
} from './binaries' } from './binaries'
import { getSettings, getDownloadArchivePath, getDefaultMediaDir } from './settings' import { getSettings, getDownloadArchivePath, getDefaultMediaDir } from './settings'
import { ensureManagedYtdlp } from './ytdlp'
import { getCookiesFilePath } from './cookies' import { getCookiesFilePath } from './cookies'
import { listTemplates } from './templates' import { listTemplates } from './templates'
import { assertHttpUrl } from './url' import { assertHttpUrl } from './url'
@@ -240,11 +241,14 @@ export function startDownload(
wc: WebContents, wc: WebContents,
opts: StartDownloadOptions opts: StartDownloadOptions
): StartDownloadResult { ): 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() const ytdlp = getYtdlpPath()
if (!existsSync(ytdlp)) { if (!existsSync(ytdlp)) {
return { return {
ok: false, 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/ // 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 YtdlpUpdateChannel,
type SystemThemeInfo type SystemThemeInfo
} from '@shared/ipc' } 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 { checkForAppUpdate, downloadAppUpdate, runAppUpdate } from './updater'
import { probeMedia } from './probe' import { probeMedia } from './probe'
import { startDownload, cancelDownload, previewCommand } from './download' import { startDownload, cancelDownload, previewCommand } from './download'
@@ -190,6 +191,8 @@ function registerIpcHandlers(): void {
ipcMain.handle(IpcChannels.ytdlpVersion, () => getYtdlpVersion()) ipcMain.handle(IpcChannels.ytdlpVersion, () => getYtdlpVersion())
ipcMain.handle(IpcChannels.ffmpegVersion, () => getFfmpegVersions())
ipcMain.handle(IpcChannels.probe, (_e, url: string) => probeMedia(url)) ipcMain.handle(IpcChannels.probe, (_e, url: string) => probeMedia(url))
ipcMain.handle(IpcChannels.downloadStart, (e, opts: StartDownloadOptions) => { ipcMain.handle(IpcChannels.downloadStart, (e, opts: StartDownloadOptions) => {
@@ -353,6 +356,16 @@ if (isPrimaryInstance) {
registerSendToShortcut() registerSendToShortcut()
createWindow() 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', () => { app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow() if (BrowserWindow.getAllWindows().length === 0) createWindow()
}) })
+18
View File
@@ -11,6 +11,7 @@ import {
COOKIE_BROWSERS, COOKIE_BROWSERS,
ACCENT_COLORS, ACCENT_COLORS,
DEFAULT_DOWNLOAD_OPTIONS, DEFAULT_DOWNLOAD_OPTIONS,
isYtdlpUpdateChannel,
type Settings, type Settings,
type DownloadOptions, type DownloadOptions,
type SponsorBlockCategory type SponsorBlockCategory
@@ -37,6 +38,11 @@ const DEFAULTS: Settings = {
cookiesBrowser: 'chrome', cookiesBrowser: 'chrome',
restrictFilenames: false, restrictFilenames: false,
downloadArchive: 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, customCommandEnabled: false,
defaultTemplateId: null, defaultTemplateId: null,
notifyOnComplete: true, notifyOnComplete: true,
@@ -193,12 +199,24 @@ export function setSettings(partial: Partial<Settings>): Settings {
case 'useAria2c': case 'useAria2c':
case 'restrictFilenames': case 'restrictFilenames':
case 'downloadArchive': case 'downloadArchive':
case 'autoUpdateYtdlp':
case 'customCommandEnabled': case 'customCommandEnabled':
case 'notifyOnComplete': case 'notifyOnComplete':
case 'autoDownloadNew': case 'autoDownloadNew':
case 'hasCompletedOnboarding': case 'hasCompletedOnboarding':
if (typeof value === 'boolean') s.set(key, value) if (typeof value === 'boolean') s.set(key, value)
break 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': case 'defaultTemplateId':
if (value === null || typeof value === 'string') s.set('defaultTemplateId', value) if (value === null || typeof value === 'string') s.set('defaultTemplateId', value)
break break
+72 -3
View File
@@ -1,13 +1,37 @@
import { execFile } from 'child_process' import { execFile } from 'child_process'
import { existsSync } from 'fs' import { existsSync, mkdirSync, copyFileSync } from 'fs'
import { getYtdlpPath } from './binaries' import { dirname } from 'path'
import { getYtdlpPath, getBundledYtdlpPath } from './binaries'
import { getSettings, setSettings } from './settings'
import { shouldAutoCheckYtdlp } from './ytdlpPolicy'
import { import {
isYtdlpUpdateChannel, isYtdlpUpdateChannel,
type YtdlpVersionResult, type YtdlpVersionResult,
type YtdlpUpdateChannel, type YtdlpUpdateChannel,
type YtdlpUpdateResult type YtdlpUpdateResult,
type YtdlpAutoUpdateStatus
} from '@shared/ipc' } 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`. * Step-1 spike: spawn the bundled yt-dlp and read back `--version`.
* Proves the main-process → bundled-binary → IPC path end to end. * 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.' }) 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() const ytdlpPath = getYtdlpPath()
if (!existsSync(ytdlpPath)) { 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
}
+13
View File
@@ -7,6 +7,8 @@ import {
type YtdlpVersionResult, type YtdlpVersionResult,
type YtdlpUpdateChannel, type YtdlpUpdateChannel,
type YtdlpUpdateResult, type YtdlpUpdateResult,
type YtdlpAutoUpdateStatus,
type FfmpegVersionResult,
type ProbeResult, type ProbeResult,
type StartDownloadOptions, type StartDownloadOptions,
type StartDownloadResult, type StartDownloadResult,
@@ -55,6 +57,10 @@ const api = {
getYtdlpVersion: (): Promise<YtdlpVersionResult> => getYtdlpVersion: (): Promise<YtdlpVersionResult> =>
ipcRenderer.invoke(IpcChannels.ytdlpVersion), ipcRenderer.invoke(IpcChannels.ytdlpVersion),
/** Versions of the bundled ffmpeg + ffprobe (display-only; not auto-updated). */
getFfmpegVersions: (): Promise<FfmpegVersionResult> =>
ipcRenderer.invoke(IpcChannels.ffmpegVersion),
probe: (url: string): Promise<ProbeResult> => ipcRenderer.invoke(IpcChannels.probe, url), probe: (url: string): Promise<ProbeResult> => ipcRenderer.invoke(IpcChannels.probe, url),
startDownload: (opts: StartDownloadOptions): Promise<StartDownloadResult> => startDownload: (opts: StartDownloadOptions): Promise<StartDownloadResult> =>
@@ -115,6 +121,13 @@ const api = {
updateYtdlp: (channel: YtdlpUpdateChannel): Promise<YtdlpUpdateResult> => updateYtdlp: (channel: YtdlpUpdateChannel): Promise<YtdlpUpdateResult> =>
ipcRenderer.invoke(IpcChannels.ytdlpUpdate, channel), ipcRenderer.invoke(IpcChannels.ytdlpUpdate, channel),
/** Subscribe to background yt-dlp auto-update status. Returns an unsubscribe function. */
onYtdlpAutoUpdateStatus: (cb: (s: YtdlpAutoUpdateStatus) => void): (() => void) => {
const listener = (_e: IpcRendererEvent, s: YtdlpAutoUpdateStatus): void => cb(s)
ipcRenderer.on(IpcChannels.ytdlpAutoUpdateStatus, listener)
return () => ipcRenderer.removeListener(IpcChannels.ytdlpAutoUpdateStatus, listener)
},
listErrorLog: (): Promise<ErrorLogEntry[]> => ipcRenderer.invoke(IpcChannels.errorLogList), listErrorLog: (): Promise<ErrorLogEntry[]> => ipcRenderer.invoke(IpcChannels.errorLogList),
clearErrorLog: (): Promise<ErrorLogEntry[]> => ipcRenderer.invoke(IpcChannels.errorLogClear), clearErrorLog: (): Promise<ErrorLogEntry[]> => ipcRenderer.invoke(IpcChannels.errorLogClear),
+69 -12
View File
@@ -41,6 +41,7 @@ import {
type YtdlpVersionResult, type YtdlpVersionResult,
type YtdlpUpdateChannel, type YtdlpUpdateChannel,
type YtdlpUpdateResult, type YtdlpUpdateResult,
type FfmpegVersionResult,
type AppUpdateInfo, type AppUpdateInfo,
type MediaKind, type MediaKind,
type CookieSource, type CookieSource,
@@ -209,6 +210,9 @@ export function SettingsView(): React.JSX.Element {
const customCommandEnabled = useSettings((s) => s.customCommandEnabled) const customCommandEnabled = useSettings((s) => s.customCommandEnabled)
const defaultTemplateId = useSettings((s) => s.defaultTemplateId) const defaultTemplateId = useSettings((s) => s.defaultTemplateId)
const notifyOnComplete = useSettings((s) => s.notifyOnComplete) 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 update = useSettings((s) => s.update)
const templates = useTemplates((s) => s.templates) const templates = useTemplates((s) => s.templates)
const errorEntries = useErrorLog((s) => s.entries) const errorEntries = useErrorLog((s) => s.entries)
@@ -219,8 +223,8 @@ export function SettingsView(): React.JSX.Element {
const [checking, setChecking] = useState(false) const [checking, setChecking] = useState(false)
const [version, setVersion] = useState<YtdlpVersionResult | null>(null) 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 [updating, setUpdating] = useState(false)
const [updateResult, setUpdateResult] = useState<YtdlpUpdateResult | null>(null) 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)) 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> { async function checkAppUpdate(): Promise<void> {
setAppChecking(true) setAppChecking(true)
setAppUpd(null) setAppUpd(null)
@@ -378,7 +404,7 @@ export function SettingsView(): React.JSX.Element {
setUpdating(true) setUpdating(true)
setUpdateResult(null) setUpdateResult(null)
try { try {
const result = await window.api.updateYtdlp(updateChannel) const result = await window.api.updateYtdlp(ytdlpChannel)
setUpdateResult(result) setUpdateResult(result)
if (result.ok) setVersion(null) // stale — prompt a re-check rather than show a wrong version if (result.ok) setVersion(null) // stale — prompt a re-check rather than show a wrong version
} catch (e) { } catch (e) {
@@ -900,14 +926,21 @@ export function SettingsView(): React.JSX.Element {
<Subtitle2>About</Subtitle2> <Subtitle2>About</Subtitle2>
</div> </div>
<Caption1 className={styles.hint}> <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> </Caption1>
<div className={styles.folderRow}>
<Button onClick={checkVersion} disabled={checking}> <Field
Check yt-dlp version label="Keep yt-dlp updated automatically"
</Button> 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."
{checking && <Spinner size="tiny" />} >
</div> <Switch
checked={autoUpdateYtdlp}
onChange={(_, d) => update({ autoUpdateYtdlp: d.checked })}
label={autoUpdateYtdlp ? 'On' : 'Off'}
/>
</Field>
{version?.ok && ( {version?.ok && (
<Text style={{ fontFamily: tokens.fontFamilyMonospace }}>yt-dlp {version.version}</Text> <Text style={{ fontFamily: tokens.fontFamilyMonospace }}>yt-dlp {version.version}</Text>
)} )}
@@ -916,13 +949,37 @@ export function SettingsView(): React.JSX.Element {
{version.error} {version.error}
</Caption1> </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 <Select
aria-label="Update channel" aria-label="Update channel"
value={updateChannel} value={ytdlpChannel}
options={UPDATE_CHANNEL_OPTIONS} options={UPDATE_CHANNEL_OPTIONS}
onChange={(v) => setUpdateChannel(v as YtdlpUpdateChannel)} onChange={(v) => update({ ytdlpChannel: v as YtdlpUpdateChannel })}
/> />
</Field> </Field>
<div className={styles.folderRow}> <div className={styles.folderRow}>
+6
View File
@@ -29,6 +29,9 @@ if (import.meta.env.DEV && !window.api) {
cookiesBrowser: 'chrome', cookiesBrowser: 'chrome',
restrictFilenames: false, restrictFilenames: false,
downloadArchive: false, downloadArchive: false,
autoUpdateYtdlp: true,
ytdlpChannel: 'nightly',
ytdlpLastUpdateCheck: Date.now() - 3_600_000,
customCommandEnabled: false, customCommandEnabled: false,
defaultTemplateId: null, defaultTemplateId: null,
notifyOnComplete: true, notifyOnComplete: true,
@@ -67,6 +70,7 @@ if (import.meta.env.DEV && !window.api) {
runAppUpdate: async () => ({ ok: true }), runAppUpdate: async () => ({ ok: true }),
onAppUpdateProgress: () => () => {}, onAppUpdateProgress: () => () => {},
getYtdlpVersion: async () => ({ ok: true, version: '2025.06.01 (UI preview mock)' }), 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) => { probe: async (url: string) => {
await new Promise((r) => setTimeout(r, 700)) // simulate network latency await new Promise((r) => setTimeout(r, 700)) // simulate network latency
// A 'list'/'playlist' URL exercises the playlist selection UI in preview. // 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 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)` } 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 () => [], listErrorLog: async () => [],
clearErrorLog: async () => [], clearErrorLog: async () => [],
exportBackup: async () => ({ ok: true, path: 'C:\\Users\\you\\Downloads\\aerofetch-backup.json' }), exportBackup: async () => ({ ok: true, path: 'C:\\Users\\you\\Downloads\\aerofetch-backup.json' }),
+3
View File
@@ -23,6 +23,9 @@ const FALLBACK: Settings = {
cookiesBrowser: 'chrome', cookiesBrowser: 'chrome',
restrictFilenames: false, restrictFilenames: false,
downloadArchive: false, downloadArchive: false,
autoUpdateYtdlp: true,
ytdlpChannel: 'nightly',
ytdlpLastUpdateCheck: 0,
customCommandEnabled: false, customCommandEnabled: false,
defaultTemplateId: null, defaultTemplateId: null,
notifyOnComplete: true, notifyOnComplete: true,
+39
View File
@@ -15,6 +15,7 @@ export const IpcChannels = {
/** main → renderer push channel for installer download progress */ /** main → renderer push channel for installer download progress */
appUpdateProgress: 'app:update-progress', appUpdateProgress: 'app:update-progress',
ytdlpVersion: 'ytdlp:version', ytdlpVersion: 'ytdlp:version',
ffmpegVersion: 'ffmpeg:version',
probe: 'media:probe', probe: 'media:probe',
downloadStart: 'download:start', downloadStart: 'download:start',
downloadCancel: 'download:cancel', downloadCancel: 'download:cancel',
@@ -40,6 +41,8 @@ export const IpcChannels = {
templatesRemove: 'templates:remove', templatesRemove: 'templates:remove',
commandPreview: 'command:preview', commandPreview: 'command:preview',
ytdlpUpdate: 'ytdlp:update', ytdlpUpdate: 'ytdlp:update',
/** main → renderer push channel for background yt-dlp auto-update status */
ytdlpAutoUpdateStatus: 'ytdlp:auto-update-status',
errorLogList: 'errorlog:list', errorLogList: 'errorlog:list',
errorLogClear: 'errorlog:clear', errorLogClear: 'errorlog:clear',
backupExport: 'backup:export', backupExport: 'backup:export',
@@ -196,6 +199,18 @@ export interface YtdlpVersionResult {
error?: string error?: string
} }
/**
* Versions of the bundled ffmpeg + ffprobe, for display in Settings. Each is the
* parsed version token, or null when that binary is missing or unreadable. These
* ship with AeroFetch and aren't auto-updated (ffmpeg has no self-update and,
* unlike yt-dlp, doesn't break as sites change), so there's no ok/error here
* just "what's installed".
*/
export interface FfmpegVersionResult {
ffmpeg: string | null
ffprobe: string | null
}
/** Result of checking the configured Gitea repo for a newer AeroFetch release. */ /** Result of checking the configured Gitea repo for a newer AeroFetch release. */
export interface AppUpdateInfo { export interface AppUpdateInfo {
ok: boolean ok: boolean
@@ -260,6 +275,24 @@ export interface YtdlpUpdateResult {
error?: string error?: string
} }
/**
* Status of a background (startup) yt-dlp auto-update, pushed main renderer on
* IpcChannels.ytdlpAutoUpdateStatus so the Settings UI can reflect a check that
* ran on its own no button click needed.
*/
export interface YtdlpAutoUpdateStatus {
/** 'checking' when the update starts; the rest are terminal */
phase: 'checking' | 'updated' | 'current' | 'error'
/** which channel was checked */
channel: YtdlpUpdateChannel
/** yt-dlp version after the run, when known (phases 'updated' | 'current') */
version?: string
/** epoch ms the check finished (terminal phases) — feeds the "last checked" line */
checkedAt?: number
/** error text when phase === 'error' */
error?: string
}
/** A single selectable output format, derived from `yt-dlp -J`. */ /** A single selectable output format, derived from `yt-dlp -J`. */
export interface FormatOption { export interface FormatOption {
/** yt-dlp format_id, or BEST_FORMAT_ID for the auto-best option */ /** yt-dlp format_id, or BEST_FORMAT_ID for the auto-best option */
@@ -437,6 +470,12 @@ export interface Settings {
restrictFilenames: boolean restrictFilenames: boolean
/** record completed downloads in a yt-dlp --download-archive file to skip repeats */ /** record completed downloads in a yt-dlp --download-archive file to skip repeats */
downloadArchive: boolean downloadArchive: boolean
/** keep the managed yt-dlp.exe auto-updated in the background on launch */
autoUpdateYtdlp: boolean
/** channel the auto-updater (and the manual "Update yt-dlp" button) updates to */
ytdlpChannel: YtdlpUpdateChannel
/** epoch ms of the last automatic yt-dlp update check; 0 = never run */
ytdlpLastUpdateCheck: number
/** apply a named custom-command template's extra args to every new download */ /** apply a named custom-command template's extra args to every new download */
customCommandEnabled: boolean customCommandEnabled: boolean
/** id of the CommandTemplate applied when customCommandEnabled is on; null = none picked yet */ /** id of the CommandTemplate applied when customCommandEnabled is on; null = none picked yet */
+34
View File
@@ -0,0 +1,34 @@
import { describe, it, expect } from 'vitest'
import { shouldAutoCheckYtdlp, AUTO_UPDATE_INTERVAL_MS } from '../src/main/ytdlpPolicy'
const NOW = 1_782_435_469_486 // arbitrary fixed "now"
describe('shouldAutoCheckYtdlp', () => {
it('never checks when auto-update is disabled', () => {
expect(shouldAutoCheckYtdlp(false, 0, NOW)).toBe(false)
// …even if a full interval has elapsed.
expect(shouldAutoCheckYtdlp(false, NOW - AUTO_UPDATE_INTERVAL_MS * 2, NOW)).toBe(false)
})
it('checks on first run (lastCheck is 0 / never)', () => {
expect(shouldAutoCheckYtdlp(true, 0, NOW)).toBe(true)
})
it('skips while inside the throttle window', () => {
// Checked one minute ago → far inside the 24h window.
expect(shouldAutoCheckYtdlp(true, NOW - 60_000, NOW)).toBe(false)
// Exactly one ms short of the interval still skips.
expect(shouldAutoCheckYtdlp(true, NOW - (AUTO_UPDATE_INTERVAL_MS - 1), NOW)).toBe(false)
})
it('checks once the interval has elapsed (boundary inclusive)', () => {
expect(shouldAutoCheckYtdlp(true, NOW - AUTO_UPDATE_INTERVAL_MS, NOW)).toBe(true)
expect(shouldAutoCheckYtdlp(true, NOW - AUTO_UPDATE_INTERVAL_MS * 3, NOW)).toBe(true)
})
it('honours a custom interval', () => {
const hour = 60 * 60 * 1000
expect(shouldAutoCheckYtdlp(true, NOW - 30 * 60_000, NOW, hour)).toBe(false)
expect(shouldAutoCheckYtdlp(true, NOW - hour, NOW, hour)).toBe(true)
})
})