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:
@@ -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.' })
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user