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