diff --git a/src/main/binaries.ts b/src/main/binaries.ts index 18eef62..c2843e7 100644 --- a/src/main/binaries.ts +++ b/src/main/binaries.ts @@ -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 `. + */ +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') } diff --git a/src/main/download.ts b/src/main/download.ts index c4fe548..492fdca 100644 --- a/src/main/download.ts +++ b/src/main/download.ts @@ -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/ diff --git a/src/main/ffmpeg.ts b/src/main/ffmpeg.ts new file mode 100644 index 0000000..e6886b8 --- /dev/null +++ b/src/main/ffmpeg.ts @@ -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 " 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 { + 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 { + const [ffmpeg, ffprobe] = await Promise.all([ + readToolVersion(getFfmpegPath()), + readToolVersion(getFfprobePath()) + ]) + return { ffmpeg, ffprobe } +} diff --git a/src/main/index.ts b/src/main/index.ts index fc0184c..7673b68 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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() }) diff --git a/src/main/settings.ts b/src/main/settings.ts index f99a049..17b08de 100644 --- a/src/main/settings.ts +++ b/src/main/settings.ts @@ -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 { 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 diff --git a/src/main/ytdlp.ts b/src/main/ytdlp.ts index 4890cd8..ffec8fa 100644 --- a/src/main/ytdlp.ts +++ b/src/main/ytdlp.ts @@ -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 void +): Promise { + 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() + }) +} diff --git a/src/main/ytdlpPolicy.ts b/src/main/ytdlpPolicy.ts new file mode 100644 index 0000000..a2d54f5 --- /dev/null +++ b/src/main/ytdlpPolicy.ts @@ -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 +} diff --git a/src/preload/index.ts b/src/preload/index.ts index eb95485..0ccd75b 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -7,6 +7,8 @@ import { type YtdlpVersionResult, type YtdlpUpdateChannel, type YtdlpUpdateResult, + type YtdlpAutoUpdateStatus, + type FfmpegVersionResult, type ProbeResult, type StartDownloadOptions, type StartDownloadResult, @@ -55,6 +57,10 @@ const api = { getYtdlpVersion: (): Promise => ipcRenderer.invoke(IpcChannels.ytdlpVersion), + /** Versions of the bundled ffmpeg + ffprobe (display-only; not auto-updated). */ + getFfmpegVersions: (): Promise => + ipcRenderer.invoke(IpcChannels.ffmpegVersion), + probe: (url: string): Promise => ipcRenderer.invoke(IpcChannels.probe, url), startDownload: (opts: StartDownloadOptions): Promise => @@ -115,6 +121,13 @@ const api = { updateYtdlp: (channel: YtdlpUpdateChannel): Promise => 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 => ipcRenderer.invoke(IpcChannels.errorLogList), clearErrorLog: (): Promise => ipcRenderer.invoke(IpcChannels.errorLogClear), diff --git a/src/renderer/src/components/SettingsView.tsx b/src/renderer/src/components/SettingsView.tsx index 8f52a91..e1a3bb9 100644 --- a/src/renderer/src/components/SettingsView.tsx +++ b/src/renderer/src/components/SettingsView.tsx @@ -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(null) + const [ffmpeg, setFfmpeg] = useState(null) - const [updateChannel, setUpdateChannel] = useState('stable') const [updating, setUpdating] = useState(false) const [updateResult, setUpdateResult] = useState(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 { 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 { About - 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. -
- - {checking && } -
+ + + update({ autoUpdateYtdlp: d.checked })} + label={autoUpdateYtdlp ? 'On' : 'Off'} + /> + + {version?.ok && ( yt-dlp {version.version} )} @@ -916,13 +949,37 @@ export function SettingsView(): React.JSX.Element { {version.error} )} + {ffmpeg && ( + <> + + ffmpeg {ffmpeg.ffmpeg ?? 'not found'} + + + ffprobe {ffmpeg.ffprobe ?? 'not found'} + + + )} + {ytdlpLastUpdateCheck > 0 && ( + + Last checked for updates {new Date(ytdlpLastUpdateCheck).toLocaleString()}. + + )} +
+ + {checking && } +
- +