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:
+22
-1
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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
@@ -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()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user