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
+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.' })
}
}
)
})
}