Initial commit: AeroFetch — yt-dlp/YouTube downloader for Windows

Electron + React (Fluent UI) desktop frontend for yt-dlp:
- Download queue with live progress, concurrency cap, cancel/retry
- Format/quality picker via yt-dlp probe; audio extraction to MP3
- Settings + history persistence (electron-store / JSON)
- Clipboard link auto-detect; persisted light/dark theme
- NSIS + portable packaging (binaries bundled at build time, not in git)

UI: "Studio" sidebar layout with a toffee-brown theme (light + neutral dark).
Dropdowns use a native <select> and tooltips a CSS-only Hint, to avoid a
dev-machine GPU overlay flicker/blank issue.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 18:17:41 -04:00
commit 1a2270c95e
39 changed files with 11351 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
import { app } from 'electron'
import { join } from 'path'
import { is } from '@electron-toolkit/utils'
/**
* Resolves the directory holding the bundled binaries (yt-dlp.exe, ffmpeg.exe).
*
* In dev they live in the repo at resources/bin/.
* In a packaged build, electron-builder's extraResources copies them to
* <resources>/bin (see electron-builder.yml), reachable via process.resourcesPath.
*/
export function getBinDir(): string {
return is.dev ? join(app.getAppPath(), 'resources', 'bin') : join(process.resourcesPath, 'bin')
}
export function getYtdlpPath(): string {
return join(getBinDir(), 'yt-dlp.exe')
}
export function getFfmpegPath(): string {
return join(getBinDir(), 'ffmpeg.exe')
}
+303
View File
@@ -0,0 +1,303 @@
import { spawn, execFile, type ChildProcess } from 'child_process'
import { existsSync } from 'fs'
import { join } from 'path'
import { app, type WebContents } from 'electron'
import { getYtdlpPath, getBinDir } from './binaries'
import { getSettings } from './settings'
import {
IpcChannels,
BEST_FORMAT_ID,
type StartDownloadOptions,
type StartDownloadResult,
type DownloadEvent,
type DownloadMeta,
type DownloadProgress
} from '@shared/ipc'
interface ActiveDownload {
child: ChildProcess
canceled: boolean
}
const active = new Map<string, ActiveDownload>()
// --- Quality → yt-dlp selector mapping --------------------------------------
function videoFormat(quality: string): string {
switch (quality) {
case '1080p':
return 'bv*[height<=1080]+ba/b[height<=1080]'
case '720p':
return 'bv*[height<=720]+ba/b[height<=720]'
case '480p':
return 'bv*[height<=480]+ba/b[height<=480]'
case '360p':
return 'bv*[height<=360]+ba/b[height<=360]'
default:
return 'bv*+ba/b' // Best available
}
}
/**
* The video format selector. A specific probed format_id wins; otherwise fall
* back to the height-based preset. Video-only formats get +bestaudio so the
* merge produces a file with sound (with a video-only fallback if no audio).
*/
function videoSelector(opts: StartDownloadOptions): string {
if (opts.formatId && opts.formatId !== BEST_FORMAT_ID) {
return opts.formatHasAudio
? opts.formatId
: `${opts.formatId}+bestaudio/${opts.formatId}`
}
return videoFormat(opts.quality)
}
function audioQuality(quality: string): string {
switch (quality) {
case '320 kbps':
return '320K'
case '192 kbps':
return '192K'
case '128 kbps':
return '128K'
default:
return '0' // Best
}
}
// The progress line yt-dlp emits (one per --newline tick). Note the leading
// `download:` is the progress-template TYPE selector and is consumed by yt-dlp
// (it does NOT appear in the output). The literal `prog|` that follows is our
// own marker, so we can tell progress lines apart from the after-move filepath
// print on the same stdout stream.
const PROGRESS_TEMPLATE =
'download:prog|%(progress.status)s|%(progress.downloaded_bytes)s|' +
'%(progress.total_bytes)s|%(progress.total_bytes_estimate)s|' +
'%(progress.speed)s|%(progress.eta)s'
function buildArgs(opts: StartDownloadOptions, outputTemplate: string): string[] {
const args = [
'--newline',
'--no-color',
'--no-playlist',
// --print (below) implies --quiet, which would suppress progress; --progress
// forces the progress template to emit anyway.
'--progress',
'--ffmpeg-location',
getBinDir(),
'-o',
outputTemplate,
'--progress-template',
PROGRESS_TEMPLATE,
// Print the final path after post-processing/move so we can open it later.
'--print',
'after_move:path|%(filepath)s',
'--no-simulate'
]
if (opts.kind === 'audio') {
args.push(
'-x',
'--audio-format',
'mp3',
'--audio-quality',
audioQuality(opts.quality),
'--embed-thumbnail',
'--embed-metadata'
)
} else {
args.push('-f', videoSelector(opts), '--merge-output-format', 'mp4')
}
args.push(opts.url)
return args
}
// --- Formatting helpers (raw yt-dlp numbers → human strings) ----------------
function num(s?: string): number | undefined {
if (!s || s === 'NA') return undefined
const n = Number(s)
return Number.isFinite(n) ? n : undefined
}
export function fmtBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
const units = ['KB', 'MB', 'GB', 'TB']
let v = bytes / 1024
let i = 0
while (v >= 1024 && i < units.length - 1) {
v /= 1024
i++
}
return `${v.toFixed(v >= 100 ? 0 : 1)} ${units[i]}`
}
function fmtSpeed(bytesPerSec?: number): string | undefined {
if (bytesPerSec == null) return undefined
return `${fmtBytes(bytesPerSec)}/s`
}
function fmtEta(seconds?: number): string | undefined {
if (seconds == null) return undefined
const s = Math.max(0, Math.round(seconds))
const m = Math.floor(s / 60)
const r = s % 60
return `${m}:${String(r).padStart(2, '0')}`
}
function parseProgress(rest: string): DownloadProgress | null {
const parts = rest.split('|')
if (parts.length < 6) return null
const [status, dl, total, totalEst, speed, eta] = parts
const downloaded = num(dl)
const totalBytes = num(total) ?? num(totalEst)
let progress = 0
if (totalBytes && downloaded != null) progress = Math.min(1, downloaded / totalBytes)
return {
status: status || 'downloading',
progress,
speed: fmtSpeed(num(speed)),
eta: fmtEta(num(eta)),
sizeLabel: totalBytes ? fmtBytes(totalBytes) : undefined
}
}
/** Trim yt-dlp's noisy stderr down to the most useful trailing error line. */
function cleanError(stderr: string): string {
const lines = stderr
.split('\n')
.map((l) => l.trim())
.filter(Boolean)
const errLine = [...lines].reverse().find((l) => /^error/i.test(l))
return (errLine ?? lines[lines.length - 1] ?? '').replace(/^error:\s*/i, '').trim()
}
function send(wc: WebContents, ev: DownloadEvent): void {
if (!wc.isDestroyed()) wc.send(IpcChannels.downloadEvent, ev)
}
// --- Best-effort metadata probe (runs alongside the download) ---------------
function probeMeta(ytdlp: string, url: string): Promise<DownloadMeta | null> {
return new Promise((resolve) => {
execFile(
ytdlp,
[
'--no-playlist',
'--no-warnings',
'--skip-download',
'--print',
'title',
'--print',
'uploader',
'--print',
'duration_string',
url
],
{ windowsHide: true, maxBuffer: 4 * 1024 * 1024 },
(err, stdout) => {
if (err) return resolve(null)
const [title, uploader, duration] = stdout.split('\n').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)
})
}
)
})
}
// --- Public API -------------------------------------------------------------
export function startDownload(
wc: WebContents,
opts: StartDownloadOptions
): StartDownloadResult {
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).`
}
}
if (active.has(opts.id)) {
return { ok: false, error: 'A download with this id is already running.' }
}
const settings = getSettings()
const outDir = opts.outputDir?.trim() || settings.outputDir || app.getPath('downloads')
const template = settings.filenameTemplate?.trim() || '%(title)s.%(ext)s'
let child: ChildProcess
try {
child = spawn(ytdlp, buildArgs(opts, join(outDir, template)), { windowsHide: true })
} catch (e) {
return { ok: false, error: (e as Error).message }
}
const rec: ActiveDownload = { child, canceled: false }
active.set(opts.id, rec)
// Fetch title/channel/duration in parallel so the card fills in quickly.
probeMeta(ytdlp, opts.url).then((meta) => {
if (meta && active.has(opts.id)) send(wc, { type: 'meta', id: opts.id, meta })
})
let stdoutBuf = ''
let stderrTail = ''
let filePath: string | undefined
child.stdout?.on('data', (chunk: Buffer) => {
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('prog|')) {
const p = parseProgress(line.slice('prog|'.length))
if (p) send(wc, { type: 'progress', id: opts.id, progress: p })
} else if (line.startsWith('path|')) {
filePath = line.slice('path|'.length).trim()
}
}
})
child.stderr?.on('data', (chunk: Buffer) => {
stderrTail = (stderrTail + chunk.toString()).slice(-4000)
})
child.on('error', (err) => {
active.delete(opts.id)
if (!rec.canceled) send(wc, { type: 'error', id: opts.id, error: err.message })
})
child.on('close', (code) => {
active.delete(opts.id)
if (rec.canceled) return // renderer already showed 'canceled' optimistically
if (code === 0) {
send(wc, { type: 'done', id: opts.id, filePath })
} else {
const msg = cleanError(stderrTail) || `yt-dlp exited with code ${code}`
send(wc, { type: 'error', id: opts.id, error: msg })
}
})
return { ok: true }
}
export function cancelDownload(id: string): void {
const rec = active.get(id)
if (!rec) return
rec.canceled = true
const pid = rec.child.pid
if (pid != null) {
// Kill the whole tree (/T) so the spawned ffmpeg child dies too.
execFile('taskkill', ['/pid', String(pid), '/T', '/F'], { windowsHide: true }, () => {})
} else {
rec.child.kill()
}
}
+48
View File
@@ -0,0 +1,48 @@
import { app } from 'electron'
import { join } from 'path'
import { readFileSync, writeFileSync, existsSync } from 'fs'
import type { HistoryEntry } from '@shared/ipc'
// Plain JSON in userData (portable build redirects userData next to the exe).
// Kept simple per the build plan; can migrate to better-sqlite3 later.
const MAX_ENTRIES = 500
function historyFile(): string {
return join(app.getPath('userData'), 'history.json')
}
export function listHistory(): HistoryEntry[] {
try {
if (!existsSync(historyFile())) return []
const data = JSON.parse(readFileSync(historyFile(), 'utf8'))
return Array.isArray(data) ? (data as HistoryEntry[]) : []
} catch {
return []
}
}
function save(entries: HistoryEntry[]): void {
try {
writeFileSync(historyFile(), JSON.stringify(entries.slice(0, MAX_ENTRIES), null, 2))
} catch {
/* best-effort; a read-only data dir just means no persisted history */
}
}
export function addHistory(entry: HistoryEntry): HistoryEntry[] {
// De-dupe by id (a retry of the same item replaces its prior entry).
const entries = [entry, ...listHistory().filter((e) => e.id !== entry.id)]
save(entries)
return entries
}
export function removeHistory(id: string): HistoryEntry[] {
const entries = listHistory().filter((e) => e.id !== id)
save(entries)
return entries
}
export function clearHistory(): HistoryEntry[] {
save([])
return []
}
+133
View File
@@ -0,0 +1,133 @@
import { app, shell, BrowserWindow, ipcMain, dialog, clipboard } from 'electron'
import { join } from 'path'
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import {
IpcChannels,
type StartDownloadOptions,
type Settings,
type HistoryEntry
} from '@shared/ipc'
import { getYtdlpVersion } from './ytdlp'
import { probeMedia } from './probe'
import { startDownload, cancelDownload } from './download'
import { getSettings, setSettings } from './settings'
import { listHistory, addHistory, removeHistory, clearHistory } from './history'
import { setupPortableData } from './portable'
// Force software compositing (GPU acceleration off). On this dev machine's old GeForce
// GT 625 — and plausibly on the old / locked-down public PCs this app targets —
// Chromium's GPU compositor renders blank: under hardware acceleration the WHOLE window
// paints white (popup overlays alone were blank earlier). Updating the NVIDIA driver to
// the last Fermi release did not fix it. Software compositing renders correctly and
// costs no meaningful performance for this lightweight UI, so it is the safe default for
// this app's target hardware. The only downside is a minor overlay-repaint flicker that
// appears on the most broken GPUs. The window backgroundColor is theme-matched (see
// createWindow); native window-occlusion tracking is disabled (a separate documented
// Windows blank/flicker cause). Must run before the app is ready.
app.disableHardwareAcceleration()
app.commandLine.appendSwitch('disable-features', 'CalculateNativeWinOcclusion')
// Redirect user data next to the exe when possible (portable, no-admin). Must run
// before the app is ready and before any user path is read.
setupPortableData()
// Page background per theme — keep in sync with `pageBackground` in
// src/renderer/src/theme.ts. Set as the window's NATIVE background so the
// one-frame compositor repaint (when a tooltip/dropdown overlay first paints on
// Windows) shows the app's current color instead of a mismatched white flash.
const THEME_BACKGROUND = { light: '#f6f1ef', dark: '#161618' } as const
function createWindow(): void {
const mainWindow = new BrowserWindow({
width: 920,
height: 700,
show: false,
backgroundColor: THEME_BACKGROUND[getSettings().theme],
autoHideMenuBar: true,
webPreferences: {
preload: join(__dirname, '../preload/index.mjs'),
sandbox: false,
contextIsolation: true,
nodeIntegration: false
}
})
mainWindow.on('ready-to-show', () => {
mainWindow.show()
})
// Open external links in the OS browser, never in-app.
mainWindow.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url)
return { action: 'deny' }
})
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
} else {
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
}
}
function registerIpcHandlers(): void {
ipcMain.handle(IpcChannels.ytdlpVersion, () => getYtdlpVersion())
ipcMain.handle(IpcChannels.probe, (_e, url: string) => probeMedia(url))
ipcMain.handle(IpcChannels.downloadStart, (e, opts: StartDownloadOptions) =>
startDownload(e.sender, opts)
)
ipcMain.handle(IpcChannels.downloadCancel, (_e, id: string) => cancelDownload(id))
ipcMain.handle(IpcChannels.defaultFolder, () => app.getPath('downloads'))
ipcMain.handle(IpcChannels.chooseFolder, async (e) => {
const win = BrowserWindow.fromWebContents(e.sender) ?? undefined
const res = await dialog.showOpenDialog(win!, {
properties: ['openDirectory', 'createDirectory']
})
return res.canceled || !res.filePaths[0] ? null : res.filePaths[0]
})
ipcMain.handle(IpcChannels.openPath, (_e, p: string) => shell.openPath(p))
ipcMain.handle(IpcChannels.showInFolder, (_e, p: string) => shell.showItemInFolder(p))
ipcMain.handle(IpcChannels.clipboardRead, () => clipboard.readText())
ipcMain.handle(IpcChannels.settingsGet, () => getSettings())
ipcMain.handle(IpcChannels.settingsSet, (e, partial: Partial<Settings>) => {
const result = setSettings(partial)
// Keep the window's native background in sync with the theme so a compositor
// repaint never flashes a mismatched color behind an overlay.
if (partial.theme) {
BrowserWindow.fromWebContents(e.sender)?.setBackgroundColor(THEME_BACKGROUND[partial.theme])
}
return result
})
ipcMain.handle(IpcChannels.historyList, () => listHistory())
ipcMain.handle(IpcChannels.historyAdd, (_e, entry: HistoryEntry) => addHistory(entry))
ipcMain.handle(IpcChannels.historyRemove, (_e, id: string) => removeHistory(id))
ipcMain.handle(IpcChannels.historyClear, () => clearHistory())
}
app.whenReady().then(() => {
electronApp.setAppUserModelId('com.aerofetch.app')
app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window)
})
registerIpcHandlers()
createWindow()
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
}
})
+34
View File
@@ -0,0 +1,34 @@
import { app } from 'electron'
import { join } from 'path'
import { accessSync, mkdirSync, constants } from 'fs'
/**
* When running as the portable build, keep all per-user data (settings, history,
* caches — everything under `userData`) in an `AeroFetch-data` folder right next to
* the portable .exe. That makes the app fully self-contained on a USB stick and
* leaves nothing behind on a shared/library PC.
*
* Detection: electron-builder's `portable` target sets PORTABLE_EXECUTABLE_DIR at
* runtime to the folder the user launched the exe from. The installed (NSIS) build
* and dev don't set it, so they correctly use the default %APPDATA%\AeroFetch —
* which is per-user and never requires admin either.
*
* If the portable folder is read-only (locked-down machine, read-only media), we
* silently fall back to %APPDATA%. Either way the app needs no admin privileges.
*
* Must be called at startup, before the app is ready and before anything reads a
* user path (electron-store, history DB, etc.).
*/
export function setupPortableData(): void {
const portableExeDir = process.env.PORTABLE_EXECUTABLE_DIR
if (!portableExeDir) return
const portableDataDir = join(portableExeDir, 'AeroFetch-data')
try {
mkdirSync(portableDataDir, { recursive: true })
accessSync(portableDataDir, constants.W_OK)
app.setPath('userData', portableDataDir)
} catch {
// Read-only media — keep the default %APPDATA% userData (per-user, no admin).
}
}
+111
View File
@@ -0,0 +1,111 @@
import { execFile } from 'child_process'
import { existsSync } from 'fs'
import { getYtdlpPath } from './binaries'
import { fmtBytes } from './download'
import { BEST_FORMAT_ID, type ProbeResult, type MediaInfo, type FormatOption } from '@shared/ipc'
// The slice of `yt-dlp -J` output we actually read.
interface RawFormat {
format_id: string
ext?: string
vcodec?: string
acodec?: string
height?: number
fps?: number
tbr?: number
filesize?: number
filesize_approx?: number
}
interface RawInfo {
title?: string
uploader?: string
channel?: string
duration_string?: string
thumbnail?: string
formats?: RawFormat[]
}
/** Quality proxy for picking the best format at a given height. */
function score(f: RawFormat): number {
return (f.tbr ?? 0) + (f.ext === 'mp4' ? 0.5 : 0)
}
function toOption(f: RawFormat): FormatOption {
const size = f.filesize ?? f.filesize_approx
const fpsTag = f.fps && f.fps > 30 ? String(Math.round(f.fps)) : ''
const sizeLabel = size ? fmtBytes(size) : undefined
const parts = [`${f.height}p${fpsTag}`, f.ext, sizeLabel].filter(Boolean) as string[]
return {
id: f.format_id,
label: parts.join(' · '),
height: f.height,
ext: f.ext,
filesizeLabel: sizeLabel,
hasAudio: !!f.acodec && f.acodec !== 'none'
}
}
/** One option per distinct height (best at that height), best-first, plus auto. */
function buildVideoFormats(raw: RawFormat[]): FormatOption[] {
const bestByHeight = new Map<number, RawFormat>()
for (const f of raw) {
if (!f.vcodec || f.vcodec === 'none' || !f.height) continue
const cur = bestByHeight.get(f.height)
if (!cur || score(f) > score(cur)) bestByHeight.set(f.height, f)
}
const options = [...bestByHeight.values()]
.sort((a, b) => (b.height ?? 0) - (a.height ?? 0))
.map(toOption)
options.unshift({ id: BEST_FORMAT_ID, label: 'Best available', ext: 'mp4', hasAudio: true })
return options
}
function buildInfo(data: RawInfo): MediaInfo {
return {
title: data.title || 'Untitled',
channel: data.channel || data.uploader || undefined,
durationLabel: data.duration_string || undefined,
thumbnail: data.thumbnail || undefined,
formats: buildVideoFormats(data.formats ?? [])
}
}
function cleanError(stderr: string): string {
const lines = stderr
.split('\n')
.map((l) => l.trim())
.filter(Boolean)
const errLine = [...lines].reverse().find((l) => /^error/i.test(l))
return (errLine ?? lines[lines.length - 1] ?? '').replace(/^error:\s*/i, '').trim()
}
/** Probe a URL with `yt-dlp -J` and return metadata + available video formats. */
export function probeMedia(url: string): Promise<ProbeResult> {
const ytdlp = getYtdlpPath()
if (!existsSync(ytdlp)) {
return Promise.resolve({
ok: false,
error: `yt-dlp.exe not found at ${ytdlp}\nDrop it into resources/bin/ (see the README there).`
})
}
return new Promise((resolve) => {
execFile(
ytdlp,
['-J', '--no-playlist', '--no-warnings', url],
{ windowsHide: true, maxBuffer: 64 * 1024 * 1024 },
(err, stdout, stderr) => {
if (err) {
resolve({ ok: false, error: cleanError(stderr) || err.message })
return
}
try {
resolve({ ok: true, info: buildInfo(JSON.parse(stdout) as RawInfo) })
} catch {
resolve({ ok: false, error: 'Could not parse video info from yt-dlp.' })
}
}
)
})
}
+38
View File
@@ -0,0 +1,38 @@
import { app } from 'electron'
import Store from 'electron-store'
import type { Settings } from '@shared/ipc'
const DEFAULTS: Settings = {
outputDir: '', // resolved to the OS Downloads folder on first read
defaultKind: 'video',
defaultVideoQuality: 'Best available',
defaultAudioQuality: 'Best (MP3)',
maxConcurrent: 2,
filenameTemplate: '%(title)s.%(ext)s',
theme: 'light',
clipboardWatch: true
}
// Constructed lazily — electron-store needs app paths, which exist only after
// the app is ready (all callers run post-ready).
let store: Store<Settings> | null = null
function getStore(): Store<Settings> {
if (!store) store = new Store<Settings>({ name: 'settings', defaults: DEFAULTS })
return store
}
export function getSettings(): Settings {
const s = getStore()
// Fill in the real Downloads path the first time, and persist it.
if (!s.get('outputDir')) s.set('outputDir', app.getPath('downloads'))
return s.store
}
export function setSettings(partial: Partial<Settings>): Settings {
const s = getStore()
;(Object.keys(partial) as (keyof Settings)[]).forEach((key) => {
const value = partial[key]
if (value !== undefined) s.set(key, value)
})
return getSettings()
}
+29
View File
@@ -0,0 +1,29 @@
import { execFile } from 'child_process'
import { existsSync } from 'fs'
import { getYtdlpPath } from './binaries'
import type { YtdlpVersionResult } from '@shared/ipc'
/**
* Step-1 spike: spawn the bundled yt-dlp and read back `--version`.
* Proves the main-process → bundled-binary → IPC path end to end.
*/
export function getYtdlpVersion(): Promise<YtdlpVersionResult> {
const ytdlpPath = getYtdlpPath()
if (!existsSync(ytdlpPath)) {
return Promise.resolve({
ok: false,
error: `yt-dlp.exe not found at ${ytdlpPath}\nDownload it into resources/bin/ (see the README there).`
})
}
return new Promise((resolve) => {
execFile(ytdlpPath, ['--version'], { windowsHide: true }, (err, stdout, stderr) => {
if (err) {
resolve({ ok: false, error: (stderr || err.message).trim() })
return
}
resolve({ ok: true, version: stdout.trim() })
})
})
}