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,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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user