import { existsSync, mkdirSync, copyFileSync } from 'fs' import { dirname } from 'path' import { getYtdlpPath, getBundledYtdlpPath, YTDLP_MISSING_MSG } from './binaries' import { execFileAsync } from './lib/exec' import { cleanError } from './log' import { VERSION_TIMEOUT_MS, YTDLP_UPDATE_TIMEOUT_MS } from './constants' import { getSettings, setSettings } from './settings' import { shouldAutoCheckYtdlp } from './ytdlpPolicy' import { beginYtdlpUpdate, endYtdlpUpdate, liveSpawnCount } from './ytdlpLock' import { isYtdlpUpdateChannel, type YtdlpVersionResult, type YtdlpUpdateChannel, 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 */ } } /** Spawn the bundled yt-dlp and read back its `--version` for the Settings panel. */ export async function getYtdlpVersion(): Promise { const ytdlpPath = getYtdlpPath() if (!existsSync(ytdlpPath)) { return { ok: false, error: YTDLP_MISSING_MSG } } const r = await execFileAsync(ytdlpPath, ['--version'], { timeout: VERSION_TIMEOUT_MS }) if (!r.ok) { // Run stderr through the shared cleaner so yt-dlp's noisy output collapses to // its trailing error line, consistent with download/probe/indexer (CC6). const msg = r.timedOut ? 'Timed out running yt-dlp.' : cleanError(r.stderr) || r.error?.message || '' return { ok: false, error: msg } } return { ok: true, version: r.stdout.trim() } } /** * Self-update the bundled yt-dlp.exe via its built-in `--update-to ` * (stable releases or the nightly build). Requires write access to the file * yt-dlp.exe lives in, which holds for both the dev resources/bin checkout and * a per-user install; it would fail under a locked-down system install. */ export async function updateYtdlp(channel: YtdlpUpdateChannel): Promise { // Validate against the channel allowlist BEFORE the value reaches `--update-to`. // That flag also accepts `OWNER/REPO@TAG`, which would download and install an // arbitrary binary over yt-dlp.exe — so an unrecognised value (e.g. forged by a // compromised renderer over IPC) must never be forwarded. (audit F1) if (!isYtdlpUpdateChannel(channel)) { return { 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)) { return { ok: false, error: YTDLP_MISSING_MSG } } // L139: `--update-to` rewrites yt-dlp.exe in place. Take the spawn interlock so a // download/terminal can't execute the exe mid-swap (Windows sharing violation / // half-written binary). If a spawn (or another update) is already live, defer // rather than fight it — the update is best-effort; the startup path retries next // launch and the Settings "Update now" button surfaces this message for a manual retry. if (!beginYtdlpUpdate()) { return { ok: false, error: 'yt-dlp is busy (a download or another update is running) — try again shortly.' } } try { const r = await execFileAsync(ytdlpPath, ['--update-to', channel], { timeout: YTDLP_UPDATE_TIMEOUT_MS }) if (!r.ok) { const msg = r.timedOut ? 'Timed out updating yt-dlp.' : cleanError(r.stderr) || r.error?.message || '' return { ok: false, error: msg } } return { ok: true, output: r.stdout.trim() } } finally { endYtdlpUpdate() } } /** * 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 { ensureManagedYtdlp() const settings = getSettings() if (!shouldAutoCheckYtdlp(settings.autoUpdateYtdlp, settings.ytdlpLastUpdateCheck, Date.now())) { return } // L139: never rewrite the exe while a download/terminal is live (e.g. an auto- // resumed persisted-queue item). Skip WITHOUT recording the check so the daily // throttle isn't burned on a deferral — retry next launch. Re-checked right before // the rewrite too, since the version probe below awaits. if (liveSpawnCount() > 0) return const channel = settings.ytdlpChannel send({ phase: 'checking', channel }) const before = await getYtdlpVersion() if (liveSpawnCount() > 0) { // A spawn started during the version probe — defer to next launch (no record). send({ phase: 'current', channel, version: before.ok ? before.version : undefined }) return } 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() }) }