4492eee054
The prior commit kept a pass-through `export { fmtBytes, fmtSpeed, fmtEta }` in
main/lib/formatters.ts purely so existing importers wouldn't have to change —
which re-exported fmtSpeed (zero importers, dead) and left download.ts re-
exporting fmtBytes/fmtEta with no consumer at all, undercutting the "one home"
goal.
Now the real consumers import from the canonical module directly:
- probe.ts and the download.test import from @shared/format.
- formatters.ts keeps only its internal `import { fmtBytes }` (for parseProgress)
and no longer re-exports.
- download.ts re-exports only parseProgress (its own function); the dead
fmtBytes/fmtEta re-export is removed.
No behavior change. typecheck + 248 tests + eslint + prettier green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
486 lines
19 KiB
TypeScript
486 lines
19 KiB
TypeScript
import { spawn, execFile, type ChildProcess } from 'child_process'
|
|
import { existsSync, unlinkSync } from 'fs'
|
|
import { tmpdir } from 'os'
|
|
import { join } from 'path'
|
|
import { BrowserWindow, Notification, type WebContents } from 'electron'
|
|
import {
|
|
getYtdlpPath,
|
|
getBinDir,
|
|
getAria2cPath,
|
|
getFfmpegPath,
|
|
getFfprobePath,
|
|
getSystem32Path,
|
|
getAppIconImage,
|
|
YTDLP_MISSING_MSG
|
|
} from './binaries'
|
|
import { getSettings, getDownloadArchivePath, getDefaultMediaDir } from './settings'
|
|
import { ensureManagedYtdlp } from './ytdlp'
|
|
import { materializeCookies, hasStoredCookies } from './cookies'
|
|
import { listTemplates } from './templates'
|
|
import { assertHttpUrl } from './url'
|
|
import { isSafeOutputDir } from './validation'
|
|
import {
|
|
buildArgs,
|
|
selectExtraArgs,
|
|
formatCommandLine,
|
|
collectionOutputTemplate,
|
|
PROGRESS_MARKER,
|
|
FILEPATH_MARKER
|
|
} from './buildArgs'
|
|
import { cleanError } from './log'
|
|
import { addErrorLog } from './errorlog'
|
|
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<string, ActiveDownload>()
|
|
|
|
// 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)
|
|
}
|
|
|
|
// Backstop for B1: if a spawned yt-dlp goes completely silent — no stdout or
|
|
// stderr — for this long, treat it as wedged, kill it, and error the item so the
|
|
// concurrency slot frees instead of leaking forever. Generous on purpose so a
|
|
// long, output-less post-processing step (e.g. a large ffmpeg merge) isn't
|
|
// mistaken for a stall; --socket-timeout (buildArgs) handles the common
|
|
// dead-connection case at the network layer.
|
|
const STALL_TIMEOUT_MS = 5 * 60_000
|
|
|
|
/**
|
|
* 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): void {
|
|
if (!getSettings().notifyOnComplete || !Notification.isSupported()) return
|
|
const n = new Notification({ title, 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()
|
|
}
|
|
|
|
function logFailure(opts: StartDownloadOptions, title: string | undefined, error: string): void {
|
|
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'
|
|
|
|
function probeMeta(ytdlp: string, url: string): Promise<DownloadMeta | null> {
|
|
return new Promise((resolve) => {
|
|
execFile(
|
|
ytdlp,
|
|
[
|
|
'--no-playlist',
|
|
'--no-warnings',
|
|
'--skip-download',
|
|
'--print',
|
|
`%(title)s${META_SEP}%(uploader)s${META_SEP}%(duration_string)s`,
|
|
'--',
|
|
url
|
|
],
|
|
{ windowsHide: true, maxBuffer: 4 * 1024 * 1024, timeout: 30_000 },
|
|
(err, stdout) => {
|
|
if (err) return resolve(null)
|
|
const [title, uploader, duration] = stdout.split(META_SEP).map((l) => l.trim())
|
|
const clean = (v?: string): string | undefined => (v && v !== 'NA' ? v : undefined)
|
|
resolve({
|
|
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 customCommandEnabled 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.customCommandEnabled) return []
|
|
return selectExtraArgs({
|
|
customCommandEnabled: settings.customCommandEnabled,
|
|
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.audioDir : settings.videoDir)?.trim()
|
|
const outDir = safeOverride || perKindDir || getDefaultMediaDir(opts.kind)
|
|
// A collection (media-manager) download is filed into <channel>/<playlist>/
|
|
// <NNN> - <title> 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,
|
|
cookiesFromBrowser: settings.cookieSource === 'browser' ? settings.cookiesBrowser : undefined,
|
|
cookiesFile,
|
|
restrictFilenames: settings.restrictFilenames,
|
|
downloadArchivePath: settings.downloadArchive ? getDownloadArchivePath() : undefined,
|
|
youtubePlayerClient: settings.youtubePlayerClient,
|
|
youtubePoToken: settings.youtubePoToken
|
|
}
|
|
const extraArgs = resolveExtraArgs(opts, settings)
|
|
return buildArgs(opts, outputTemplate, options, 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.' }
|
|
}
|
|
|
|
// 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
|
|
if (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 }
|
|
}
|
|
|
|
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 ?? {} })
|
|
}
|
|
})
|
|
}
|
|
|
|
let stdoutBuf = ''
|
|
let stderrTail = ''
|
|
let filePath: 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()
|
|
cleanupCookies()
|
|
releaseActive(opts.id, rec)
|
|
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, resolvedTitle, msg)
|
|
notify(wc, resolvedTitle ?? 'Download failed', msg)
|
|
}, STALL_TIMEOUT_MS)
|
|
}
|
|
bumpWatchdog()
|
|
|
|
child.stdout?.on('data', (chunk: Buffer) => {
|
|
bumpWatchdog()
|
|
stdoutBuf += chunk.toString()
|
|
let nl: number
|
|
while ((nl = stdoutBuf.indexOf('\n')) >= 0) {
|
|
const line = stdoutBuf.slice(0, nl).replace(/\r$/, '')
|
|
stdoutBuf = stdoutBuf.slice(nl + 1)
|
|
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()
|
|
}
|
|
}
|
|
})
|
|
|
|
child.stderr?.on('data', (chunk: Buffer) => {
|
|
bumpWatchdog()
|
|
stderrTail = (stderrTail + chunk.toString()).slice(-4000)
|
|
})
|
|
|
|
child.on('error', (err) => {
|
|
if (settled) return
|
|
settled = true
|
|
clearWatchdog()
|
|
cleanupCookies()
|
|
releaseActive(opts.id, rec)
|
|
// 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, resolvedTitle, err.message)
|
|
notify(wc, resolvedTitle ?? 'Download failed', err.message)
|
|
}
|
|
})
|
|
|
|
child.on('close', (code) => {
|
|
if (settled) return
|
|
settled = true
|
|
clearWatchdog()
|
|
cleanupCookies()
|
|
releaseActive(opts.id, rec)
|
|
// Canceled: renderer already showed 'canceled'. Paused: renderer showed
|
|
// 'paused' and keeps the .part for a later resume. Either way, no event.
|
|
if (rec.canceled || rec.paused) return
|
|
if (code === 0) {
|
|
send(wc, { type: 'done', id: opts.id, filePath })
|
|
notify(wc, resolvedTitle ?? 'Download complete', 'Finished downloading.')
|
|
} else {
|
|
const msg = cleanError(stderrTail) || `yt-dlp exited with code ${code}`
|
|
send(wc, { type: 'error', id: opts.id, error: msg })
|
|
logFailure(opts, resolvedTitle, msg)
|
|
notify(wc, resolvedTitle ?? 'Download failed', msg)
|
|
}
|
|
})
|
|
|
|
return { ok: true }
|
|
}
|
|
|
|
// 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()
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|