import { spawn, execFile, type ChildProcess } from 'child_process' import { existsSync, unlinkSync, readdirSync } from 'fs' import { tmpdir } from 'os' import { join, parse } from 'path' import { BrowserWindow, Notification, type WebContents } from 'electron' import { getYtdlpPath, getBinDir, getAria2cPath, getFfmpegPath, getFfprobePath, getSystem32Path, getAppIconImage, YTDLP_MISSING_MSG } from './binaries' import { getSettings } from './settings' import { execFileAsync } from './lib/exec' import { createLineBuffer } from './lib/lineBuffer' import { orphanPartials } from './lib/partials' import { isYtdlpUpdating, acquireSpawn, releaseSpawn } from './ytdlpLock' import { getDownloadArchivePath, getDefaultMediaDir } from './paths' import { ensureManagedYtdlp } from './ytdlp' import { materializeCookies, hasStoredCookies } from './cookies' import { listTemplates } from './templates' import { assertHttpUrl } from './url' import { isSafeOutputDir } from './core/validation' import { buildArgs, selectExtraArgs, formatCommandLine, collectionOutputTemplate, PROGRESS_MARKER, FILEPATH_MARKER, DEST_MARKER } from './core/buildArgs' import { cleanError } from './log' import { addErrorLog } from './errorlog' import { STALL_TIMEOUT_MS, META_PROBE_TIMEOUT_MS, META_MAX_BUFFER, STDERR_TAIL_BYTES } from './constants' import { IpcChannels, type StartDownloadOptions, type StartDownloadResult, type CommandPreviewResult, type DownloadEvent, type DownloadMeta, type Settings } from '@shared/ipc' interface ActiveDownload { child: ChildProcess canceled: boolean paused: boolean } const active = new Map() // Per-spawn sequence, so a retry/resume that reuses the item id still gets a // unique transient cookie file (below) — otherwise a superseded download's // teardown could unlink the new spawn's jar mid-read. let spawnSeq = 0 // Remove an item from the active map, but only if THIS rec still owns the slot. // cancel/pause release the slot synchronously (so the renderer's just-promoted // next item isn't rejected by the maxConcurrent guard while the killed tree is // still tearing down); the doomed child's later 'close'/'error' then runs this as // a no-op. The identity check matters because a retry/resume reuses the item id, // so a stale teardown must not evict the newer same-id spawn. (L140/L148) function releaseActive(id: string, rec: ActiveDownload): void { if (active.get(id) === rec) active.delete(id) } /** * Whether any yt-dlp download is currently running. Used by the window's close * handler to keep the app alive in the tray (instead of quitting and killing the * spawned processes) when the user closes the window mid-download. */ export function hasActiveDownloads(): boolean { return active.size > 0 } // --- Formatting helpers (raw yt-dlp numbers → human strings) ---------------- // parseProgress lives in lib/formatters.ts (no electron import chain) so it can // be unit-tested in isolation (L37). Byte/ETA formatters live in @shared/format. export { parseProgress } from './lib/formatters' import { parseProgress } from './lib/formatters' function send(wc: WebContents, ev: DownloadEvent): void { if (!wc.isDestroyed()) wc.send(IpcChannels.downloadEvent, ev) } /** Native OS notification on completion/failure, gated by Settings.notifyOnComplete. */ function notify(wc: WebContents, title: string, body: string, incognito = false): void { if (!getSettings().notifyOnComplete || !Notification.isSupported()) return // L136: an incognito ("private") download must not leak its title/URL into an OS // toast (it persists in the Windows Action Center). Callers pass a generic outcome // title for the private case; here we additionally drop the detail body. const n = new Notification({ title, body: incognito ? '' : body, icon: getAppIconImage() }) n.on('click', () => { if (wc.isDestroyed()) return const win = BrowserWindow.fromWebContents(wc) if (win) { if (win.isMinimized()) win.restore() win.show() win.focus() } }) n.show() // W10: flash the taskbar button for attention when the event happened in the // background (window unfocused/minimized/hidden). Windows stops the flash on its // own once the window gets focus, so there's no explicit stop to manage. const flashWin = BrowserWindow.fromWebContents(wc) if (flashWin && !flashWin.isDestroyed() && !flashWin.isFocused()) flashWin.flashFrame(true) } function logFailure(opts: StartDownloadOptions, title: string | undefined, error: string): void { // L136: incognito downloads are never recorded — not even a failure entry, which // would persist the title + URL in the user-facing diagnostics log. if (opts.incognito) return addErrorLog({ id: opts.id, title, url: opts.url, kind: opts.kind, error, occurredAt: Date.now() }) } // --- Best-effort metadata probe (runs alongside the download) --------------- // Unit Separator (0x1F): a control char that can't appear in a title/uploader, // so it safely delimits the three fields in ONE --print template. Splitting on // newlines instead (B4) would mis-assign channel/duration whenever a title // itself contains a newline, since each --print field is emitted on its own line. const META_SEP = '\u001f' async function probeMeta(ytdlp: string, url: string): Promise { const r = await execFileAsync( ytdlp, [ '--no-playlist', '--no-warnings', '--skip-download', '--print', `%(title)s${META_SEP}%(uploader)s${META_SEP}%(duration_string)s`, '--', url ], { maxBuffer: META_MAX_BUFFER, timeout: META_PROBE_TIMEOUT_MS } ) if (!r.ok) return null const [title, uploader, duration] = r.stdout.split(META_SEP).map((l) => l.trim()) const clean = (v?: string): string | undefined => (v && v !== 'NA' ? v : undefined) return { title: clean(title), channel: clean(uploader), durationLabel: clean(duration) } } // --- Argv construction (shared by startDownload and the command preview) --- // A per-download override (opts.extraArgs, even '') wins over the persisted // default template — but BOTH are gated on the enableCustomCommands consent flag // (see selectExtraArgs / audit F2). The gate is enforced here in main, not just // in the renderer UI, so the renderer can't be trusted to apply it. function resolveExtraArgs(opts: StartDownloadOptions, settings: Settings): string[] { // Gate the file read: listTemplates() parses templates.json on every call, so // skip it entirely when the feature is off (PERF2). if (!settings.enableCustomCommands) return [] return selectExtraArgs({ enableCustomCommands: settings.enableCustomCommands, perDownloadExtraArgs: opts.extraArgs, defaultTemplateId: settings.defaultTemplateId, templates: listTemplates(), url: opts.url }) } /** Resolve settings + per-download overrides into the full yt-dlp argv. * `cookiesFile` is the transient decrypted jar the caller materialises for the * 'login' cookie source (H7); the 'browser' source uses --cookies-from-browser. */ export function buildCommand(opts: StartDownloadOptions, cookiesFile?: string): string[] { const settings = getSettings() // Output dir resolution: a per-download override wins, then the user's explicit // per-kind folder (Settings → Video/Audio folder), and finally — when that's // blank — the per-kind default (Documents\Video for video, Documents\Audio for audio). // // A per-download outputDir override must clear the same safety check the persisted // setting does (absolute path only); a renderer-supplied override is otherwise // untrusted — it dictates where downloaded files get written. An unsafe override is // ignored in favour of the per-kind folder, then the per-kind default. (audit F4) const override = opts.outputDir?.trim() const safeOverride = override && isSafeOutputDir(override) ? override : '' const perKindDir = (opts.kind === 'audio' ? settings.audioFolder : settings.videoFolder)?.trim() const outDir = safeOverride || perKindDir || getDefaultMediaDir(opts.kind) // A collection (media-manager) download is filed into // // - folders; an ordinary download uses the flat filenameTemplate. const filenameTemplate = settings.filenameTemplate?.trim() || '%(title)s.%(ext)s' const outputTemplate = opts.collection ? collectionOutputTemplate(outDir, opts.collection, '%(title)s.%(ext)s') : join(outDir, filenameTemplate) // Per-download override wins; otherwise use the persisted defaults. const options = opts.options ?? settings.downloadOptions // Silently fall back to yt-dlp's own downloader if aria2c.exe wasn't dropped // into resources/bin — the toggle shouldn't turn into a hard error. const aria2cPath = settings.useAria2c && existsSync(getAria2cPath()) ? getAria2cPath() : undefined const access = { proxy: settings.proxy, rateLimit: settings.rateLimit, aria2cPath, aria2cConnections: settings.aria2cConnections, // L136/M6: incognito attaches no cookies from the browser either. cookiesFromBrowser: !opts.incognito && settings.cookieSource === 'browser' ? settings.cookiesBrowser : undefined, cookiesFile, restrictFilenames: settings.restrictFilenames, downloadArchivePath: settings.useDownloadArchive ? getDownloadArchivePath() : undefined, youtubePlayerClient: settings.youtubePlayerClient, youtubePoToken: settings.youtubePoToken } const extraArgs = resolveExtraArgs(opts, settings) return buildArgs({ opts, outputTemplate, options, binDir: getBinDir(), access, extraArgs }) } /** Build the exact command line for the current form state, without running it. */ export function previewCommand(opts: StartDownloadOptions): CommandPreviewResult { let normalized: StartDownloadOptions try { // Use the parser-normalised URL (audit F5), so the previewed command matches // exactly what startDownload would spawn. normalized = { ...opts, url: assertHttpUrl(opts.url) } } catch (e) { return { ok: false, error: (e as Error).message } } try { return { ok: true, command: formatCommandLine(getYtdlpPath(), buildCommand(normalized)) } } catch (e) { return { ok: false, error: (e as Error).message } } } // --- Public API ------------------------------------------------------------- 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: YTDLP_MISSING_MSG } } // ffmpeg is used by nearly every download (merge, audio extract, thumbnail/ // metadata embed) and ffprobe by duration-aware post-processing (SponsorBlock- // remove, --force-keyframes-at-cuts, --split-chapters). Assert both up front so a // missing binary is a clear AeroFetch message, not a cryptic mid-download yt-dlp // postprocessing error (e.g. "Unable to determine video duration: ffprobe not found"). const missingBins: string[] = [] if (!existsSync(getFfmpegPath())) missingBins.push('ffmpeg.exe') if (!existsSync(getFfprobePath())) missingBins.push('ffprobe.exe') if (missingBins.length > 0) { return { ok: false, error: `${missingBins.join(' and ')} not found in ${getBinDir()}. Add the ffmpeg build's binaries to resources/bin/ (see the README there).` } } // Reject anything that isn't an http(s) URL before it reaches yt-dlp's argv, // and replace opts.url with the parser-normalised form so the exact string we // validated is the one that gets spawned/probed — not a raw variant carrying // interior tabs/newlines or leading control chars that URL parsing silently // tolerates. (audit F5) try { opts = { ...opts, url: assertHttpUrl(opts.url) } } catch (e) { return { ok: false, error: (e as Error).message } } if (active.has(opts.id)) { return { ok: false, error: 'A download with this id is already running.' } } // Defence-in-depth: the renderer's pump() already caps concurrency, but the main // process shouldn't trust it — a buggy or compromised renderer could otherwise // spawn unbounded yt-dlp processes. Enforce the same cap here on active spawns. const maxConcurrent = getSettings().maxConcurrent if (active.size >= maxConcurrent) { return { ok: false, error: 'Max concurrent downloads reached. Wait for a slot to free up.' } } // L139: don't launch the managed yt-dlp while a self-update is rewriting the exe — // on Windows that races into a sharing violation or a half-written binary. The // updater backs off whenever a download is live and only holds the lock for the // brief rewrite, so this is a rare, retryable refusal. if (isYtdlpUpdating()) { return { ok: false, error: 'yt-dlp is updating in the background — try this download again in a moment.' } } // Decrypt the stored cookie jar to a short-lived per-download temp file (H7). // yt-dlp reads --cookies once at startup; we delete it the moment the download // settles, so the plaintext never lingers at rest. let cookiesFile: string | undefined // L136/M6: an incognito download attaches no saved login cookies (the UI promises // "no cookies"), so it can't be tied to the user's signed-in identity. if (!opts.incognito && getSettings().cookieSource === 'login' && hasStoredCookies()) { // Unique per spawn (not just per id): a retry/resume reuses opts.id, and the // superseded download's cleanupCookies() must not unlink the new spawn's jar. const tmp = join(tmpdir(), `aerofetch-cookies-${opts.id}-${++spawnSeq}.txt`) if (materializeCookies(tmp)) cookiesFile = tmp } function cleanupCookies(): void { if (cookiesFile) { try { unlinkSync(cookiesFile) } catch { /* best-effort */ } cookiesFile = undefined } } let child: ChildProcess try { child = spawn(ytdlp, buildCommand(opts, cookiesFile), { windowsHide: true }) } catch (e) { cleanupCookies() return { ok: false, error: (e as Error).message } } // L139: a live yt-dlp process now exists — the updater must defer to it until the // matching releaseSpawn() on settle (close/error/stall) below. acquireSpawn() const rec: ActiveDownload = { child, canceled: false, paused: false } active.set(opts.id, rec) // Title/channel/duration are kept locally so the completion notification and // error log can show a real title instead of just the raw URL. let resolvedTitle: string | undefined if (opts.meta && (opts.meta.title || opts.meta.channel || opts.meta.durationLabel)) { // The renderer already probed this URL and passed the metadata along — reuse // it instead of spawning a redundant second yt-dlp probe (audit P2). resolvedTitle = opts.meta.title send(wc, { type: 'meta', id: opts.id, meta: opts.meta }) } else { // No pre-probed metadata (e.g. a direct paste that skipped the probe) — fetch // it in parallel so the card fills in quickly. probeMeta(ytdlp, opts.url).then((meta) => { if (meta?.title) resolvedTitle = meta.title if (active.has(opts.id)) { // Always emit a meta event: on success it fills in title/channel/duration; // on failure (null from timeout/error) the renderer clears the // "Resolving…" placeholder via applyEvent('meta') (SR6 / L47). send(wc, { type: 'meta', id: opts.id, meta: meta ?? {} }) } }) } // Wire the child's streams + teardown (CL3). resolvedTitle is passed as a getter // because the parallel probeMeta above may fill it in after this returns. wireChildProcess({ wc, opts, rec, cleanup: cleanupCookies, getTitle: () => resolvedTitle }) return { ok: true } } // --- Child-process wiring --------------------------------------------------- /** * Wire a spawned yt-dlp child's stdout/stderr/close/error to download events, and * run the B1 idle watchdog. Extracted from startDownload so that function reads as * a linear spawn + pre-flight and this owns the streaming/teardown lifecycle (CL3). * * `getTitle` is read lazily on each event: the completion/error paths need the * best title known *at settle time*, which the parallel metadata probe may only * fill in after wiring is set up. */ function wireChildProcess(params: { wc: WebContents opts: StartDownloadOptions rec: ActiveDownload cleanup: () => void getTitle: () => string | undefined }): void { const { wc, opts, rec, cleanup, getTitle } = params const child = rec.child let stderrTail = '' let filePath: string | undefined // The resolved FINAL output path (from the before_dl `dest|` print), captured // while the download is still running so a cancel can delete this download's // orphaned partials by stem (R4). Undefined until the first stream starts. let outputPath: string | undefined // Latched once the first download stream reports 'finished'; flags later // progress as the merge/post-processing "finishing" phase (SR7). let finishing = false // 'error' and 'close' can both fire for one process; only act on the first. let settled = false // Idle watchdog (B1): reset on any output; if it ever fires, the child has been // silent for STALL_TIMEOUT_MS, so kill + error it and free the slot. let stallTimer: ReturnType<typeof setTimeout> | null = null function clearWatchdog(): void { if (stallTimer) { clearTimeout(stallTimer) stallTimer = null } } function bumpWatchdog(): void { clearWatchdog() stallTimer = setTimeout(() => { // A cancel/pause races the timer: it already released the slot and the // child's close stays silent, so don't fire a spurious stall error (mirrors // the canceled/paused guards on the close handler below). if (settled || rec.canceled || rec.paused) return settled = true clearWatchdog() cleanup() releaseActive(opts.id, rec) releaseSpawn() killTree(rec) const msg = `Download stalled — no activity for ${Math.round(STALL_TIMEOUT_MS / 60_000)} min. Stopped; retry to resume.` send(wc, { type: 'error', id: opts.id, error: msg }) logFailure(opts, getTitle(), msg) notify( wc, opts.incognito ? 'Download stopped' : (getTitle() ?? 'Download failed'), msg, opts.incognito ) }, STALL_TIMEOUT_MS) } bumpWatchdog() // Split stdout into lines (shared helper, CC3/CC4) and parse our --print markers. // No flush on close: yt-dlp's progress/path template lines are always // newline-terminated, so a trailing partial is never a real marker. const stdoutLines = createLineBuffer((line) => { if (line.startsWith(PROGRESS_MARKER)) { const p = parseProgress(line.slice(PROGRESS_MARKER.length)) if (p) { // SR7: yt-dlp reports a 'finished' status when each download stream // completes. A video+audio download has two streams, so the bar would // otherwise fill 0→100% twice. Latch on the first 'finished' and flag // every later tick as "finishing" so the renderer shows an indeterminate // merge state instead of a visible restart. if (p.status === 'finished') finishing = true send(wc, { type: 'progress', id: opts.id, progress: { ...p, finishing } }) } } else if (line.startsWith(FILEPATH_MARKER)) { filePath = line.slice(FILEPATH_MARKER.length).trim() } else if (line.startsWith(DEST_MARKER)) { // before_dl fires once per stream; every emission carries the same final // path, so the last-write-wins overwrite is harmless. outputPath = line.slice(DEST_MARKER.length).trim() } }) child.stdout?.on('data', (chunk: Buffer) => { bumpWatchdog() stdoutLines.push(chunk.toString()) }) child.stderr?.on('data', (chunk: Buffer) => { bumpWatchdog() stderrTail = (stderrTail + chunk.toString()).slice(-STDERR_TAIL_BYTES) }) child.on('error', (err) => { if (settled) return settled = true clearWatchdog() cleanup() releaseActive(opts.id, rec) releaseSpawn() // A canceled download's partials are now orphans — remove them (R4). A paused // download was also killed on purpose, but keeps its .part for a later resume. if (rec.canceled) cleanupPartials(outputPath) // A paused download was killed on purpose — stay silent, like a cancel. if (!rec.canceled && !rec.paused) { send(wc, { type: 'error', id: opts.id, error: err.message }) logFailure(opts, getTitle(), err.message) notify( wc, opts.incognito ? 'Download failed' : (getTitle() ?? 'Download failed'), err.message, opts.incognito ) } }) child.on('close', (code) => { if (settled) return settled = true clearWatchdog() cleanup() releaseActive(opts.id, rec) releaseSpawn() // Canceled: renderer already showed 'canceled'; the process is now gone (its // file handles released), so delete this download's orphaned partials (R4). // Paused: renderer showed 'paused' and KEEPS the .part for a later resume. // Either way, no event. if (rec.canceled) { cleanupPartials(outputPath) return } if (rec.paused) return if (code === 0) { send(wc, { type: 'done', id: opts.id, filePath }) notify( wc, opts.incognito ? 'Download complete' : (getTitle() ?? 'Download complete'), 'Finished downloading.', opts.incognito ) } else { const msg = cleanError(stderrTail) || `yt-dlp exited with code ${code}` send(wc, { type: 'error', id: opts.id, error: msg }) logFailure(opts, getTitle(), msg) notify( wc, opts.incognito ? 'Download failed' : (getTitle() ?? 'Download failed'), msg, opts.incognito ) } }) } // Kill the whole process tree (/T) so the spawned ffmpeg child dies too. Resolve // taskkill from System32 by absolute path, never the bare name, so a planted // taskkill.exe on PATH / in the CWD can't run in its place. (audit F3) function killTree(rec: ActiveDownload): void { const pid = rec.child.pid if (pid != null) { execFile( getSystem32Path('taskkill.exe'), ['/pid', String(pid), '/T', '/F'], { windowsHide: true }, () => {} ) } else { rec.child.kill() } } /** * Delete a canceled download's orphaned intermediate files (R4). `outputPath` is the * resolved FINAL destination captured from the before_dl `dest|` print; orphanPartials * (pure, unit-tested in lib/partials.ts) turns its dir listing into the exact set of * this download's `.part` / `.part-Frag*` / `.ytdl` / `.fNNN.<ext>` intermediates — * never a completed `<stem>.<ext>` or another download's files. Best-effort: a * still-locked or already-gone entry is skipped. Called from the child's close/error * handler, so the process (and its file handles) are already gone. */ function cleanupPartials(outputPath: string | undefined): void { if (!outputPath) return const { dir } = parse(outputPath) if (!dir) return let entries: string[] try { entries = readdirSync(dir) } catch { return // folder vanished / unreadable — nothing to clean } for (const entry of orphanPartials(outputPath, entries)) { try { unlinkSync(join(dir, entry)) } catch { /* best-effort: locked or already removed */ } } } export function cancelDownload(id: string): void { const rec = active.get(id) if (!rec) return rec.canceled = true // Free the slot now (not on the async 'close') so the renderer's just-promoted // next item isn't rejected by the maxConcurrent guard — or run briefly over the // cap — while taskkill tears down this tree. The child's later 'close' stays // silent (rec.canceled) and releaseActive() no-ops. (L148) active.delete(id) killTree(rec) } /** * Pause a running download: kill the process tree but flag it `paused` (not * `canceled`) so the close handler stays silent — no error event, no error log, * no notification. yt-dlp leaves the partial `.part` file in place, so resuming * is just a fresh startDownload with the same options: yt-dlp's default * `--continue` picks the partial file back up. */ export function pauseDownload(id: string): void { const rec = active.get(id) if (!rec) return rec.paused = true // Free the slot now (see cancelDownload / L148). The partial .part stays on disk // for a later resume, which reuses this id — releaseActive()'s identity check // keeps the doomed child's 'close' from evicting that fresh spawn. (L140) active.delete(id) killTree(rec) }