Add Phase B: cookies, aria2c, proxy/rate-limit, restrict-filenames & archive

Implements the remaining Phase B (Access & networking) items from
ROADMAP.md: cookie sources (browser cookie-store import, or a built-in
sign-in window that exports a Netscape cookie file via a persisted Electron
session partition), the aria2c external downloader, proxy/rate-limit,
restrict-filenames, and a download-archive to skip repeats.

Wires download.ts to the buildArgs() module added in the previous commit
(it wasn't hooked up yet) and renames its options param
NetworkOptions -> AccessOptions to reflect the wider scope now that cookies
and filename/archive flags joined proxy/rate-limit/aria2c.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 20:53:18 -04:00
parent 26cd7fc7b0
commit 67133f13b5
15 changed files with 683 additions and 196 deletions
+31 -9
View File
@@ -51,16 +51,38 @@ typecheck, not yet by live yt-dlp downloads — worth a real-download smoke test
persisted defaults; "Reset to defaults" clears it. Threads `options` through
`addFromUrl → DownloadItem → startDownload` and is carried on retry.
## Phase B — Access & networking
## Phase B — Access & networking ✅ COMPLETE
- [ ] **Cookies.** `--cookies-from-browser <chrome|edge|firefox…>` (easy Windows win) **and**
a built-in login window (Electron `BrowserWindow` + session cookie export →
`--cookies file.txt`) — Seal's WebView cookie feature, fully doable in Electron.
- [ ] **aria2c external downloader.** Bundle `aria2c.exe`, `--downloader aria2c
--downloader-args`, toggle in settings.
- [ ] **Proxy** (`--proxy`) and **rate limit** (`--limit-rate`).
- [ ] **Restrict filenames** toggle (`--restrict-filenames`) + optional **download archive**
(`--download-archive`, skip already-downloaded).
`buildArgs`'s former `NetworkOptions` param is now `AccessOptions` (still in
`src/main/buildArgs.ts`) — it grew past pure networking once cookies and
filename/archive flags joined proxy/rate-limit/aria2c. *Caveat: the sign-in
window's cookie export is implemented per the Electron session-cookie and
Netscape cookie-jar formats and verified by reasoning + typecheck, not yet by
an actual live login → yt-dlp download against a gated video — worth a real
smoke test.*
- [x] **Cookies.** Settings → Cookies has a source picker:
- *From a browser's cookie store* → `--cookies-from-browser <name>` (chrome/edge/
firefox/brave/opera/vivaldi — `COOKIE_BROWSERS` in `src/shared/ipc.ts`).
- *Sign-in window (built into AeroFetch)* → opens an Electron `BrowserWindow` on a
persisted session partition (`persist:aerofetch-login`, see `src/main/cookies.ts`);
OAuth/SSO popups are allowed through onto the same partition. Closing the window
exports its cookies to a Netscape-format file (`userData/cookies.txt`) which
`buildArgs` passes via `--cookies`. Settings → Cookies shows last-saved time + a
"Clear saved cookies" button (also clears the partition's storage).
- [x] **aria2c external downloader.** Settings → Network has a "Use aria2c downloader"
toggle; when on and `resources/bin/aria2c.exe` exists, `buildArgs` emits
`--downloader <path> --downloader-args` (`ARIA2C_ARGS` in `src/main/buildArgs.ts`:
`-x 16 -s 16 -k 1M`). Missing binary falls back silently to yt-dlp's own downloader
(checked in `src/main/download.ts`). Binary is optional and not committed — see
`resources/bin/README.md`.
- [x] **Proxy** (`--proxy`) and **rate limit** (`--limit-rate`). Settings → Network fields
(`Settings.proxy` / `Settings.rateLimit`), threaded through `buildArgs`'s
`AccessOptions` param.
- [x] **Restrict filenames** toggle (`--restrict-filenames`) + **download archive**
(`--download-archive`, skip already-downloaded) — both in Settings → Filenames.
The archive file lives at a fixed `userData/download-archive.txt`
(`getDownloadArchivePath()` in `src/main/settings.ts`), not user-configurable.
## Phase C — Custom commands & yt-dlp management
+13 -2
View File
@@ -4,12 +4,14 @@ AeroFetch spawns these from the **main process** and bundles them via
electron-builder `extraResources` (they ship outside the asar archive). They are
**not** committed to git (see `.gitignore`).
Drop the two Windows executables here before running `npm run dev` or building:
Drop the two required Windows executables here before running `npm run dev` or
building; `aria2c.exe` is optional:
```
resources/bin/
├── yt-dlp.exe
── ffmpeg.exe
── ffmpeg.exe
└── aria2c.exe (optional)
```
## yt-dlp.exe
@@ -26,5 +28,14 @@ resources/bin/
- Copy `ffmpeg.exe` here. Ship the LGPL license text alongside the installer
before release.
## aria2c.exe (optional)
- Powers the "Use aria2c downloader" toggle in Settings → Network for faster
multi-connection downloads. If it's missing, that toggle silently falls back
to yt-dlp's built-in downloader — nothing breaks.
- Download: https://github.com/aria2/aria2/releases (grab the `aria2-*-win-64bit-build1.zip`,
copy `aria2c.exe` from it here).
- GPL-2.0 — ship its license text alongside the installer before release if bundled.
> Branding stays generic: AeroFetch is a "yt-dlp frontend." Keep upstream
> license texts with any distributed build.
+5
View File
@@ -20,3 +20,8 @@ export function getYtdlpPath(): string {
export function getFfmpegPath(): string {
return join(getBinDir(), 'ffmpeg.exe')
}
/** Optional bundled external downloader; absent unless dropped into resources/bin. */
export function getAria2cPath(): string {
return join(getBinDir(), 'aria2c.exe')
}
+46 -2
View File
@@ -11,7 +11,8 @@
import {
BEST_FORMAT_ID,
type StartDownloadOptions,
type DownloadOptions
type DownloadOptions,
type CookieBrowser
} from '@shared/ipc'
// --- Quality → yt-dlp selector mapping --------------------------------------
@@ -76,6 +77,47 @@ export const CROP_SQUARE_PPA =
'EmbedThumbnail+ffmpeg_o:-c:v mjpeg -vf ' +
'crop="\'if(gt(ih,iw),iw,ih)\':\'if(gt(iw,ih),ih,iw)\'"'
/**
* Phase B "Access & networking" settings — not per-download options, always
* applied from the global settings (there's no per-download override for
* these, unlike DownloadOptions).
*/
export interface AccessOptions {
/** yt-dlp --proxy value; empty disables it */
proxy: string
/** yt-dlp --limit-rate value, e.g. '2M'; empty means unlimited */
rateLimit: string
/** absolute path to aria2c.exe; omit to use yt-dlp's built-in downloader */
aria2cPath?: string
/** read auth cookies from an installed browser's own cookie store */
cookiesFromBrowser?: CookieBrowser
/** absolute path to a Netscape-format cookie file (the sign-in window's export) */
cookiesFile?: string
/** sanitize output filenames to a portable ASCII-only subset */
restrictFilenames: boolean
/** absolute path to a --download-archive file; omit to disable archive tracking */
downloadArchivePath?: string
}
// Tuned for typical CDN-served video: 16 connections split across 16 segments,
// each at least 1MB so tiny files don't get split pointlessly.
export const ARIA2C_ARGS = 'aria2c:-x 16 -s 16 -k 1M'
function accessArgs(a: AccessOptions): string[] {
const args: string[] = []
if (a.proxy.trim()) args.push('--proxy', a.proxy.trim())
if (a.rateLimit.trim()) args.push('--limit-rate', a.rateLimit.trim())
if (a.aria2cPath) args.push('--downloader', a.aria2cPath, '--downloader-args', ARIA2C_ARGS)
// cookiesFromBrowser and cookiesFile are mutually exclusive sources — the UI
// only ever sets one (driven by Settings.cookieSource), but browser wins if
// a caller somehow sets both.
if (a.cookiesFromBrowser) args.push('--cookies-from-browser', a.cookiesFromBrowser)
else if (a.cookiesFile) args.push('--cookies', a.cookiesFile)
if (a.restrictFilenames) args.push('--restrict-filenames')
if (a.downloadArchivePath) args.push('--download-archive', a.downloadArchivePath)
return args
}
/** Subtitle / SponsorBlock / chapter / metadata flags shared by both kinds. */
function postProcessArgs(opts: StartDownloadOptions, o: DownloadOptions): string[] {
const args: string[] = []
@@ -109,7 +151,8 @@ export function buildArgs(
opts: StartDownloadOptions,
outputTemplate: string,
o: DownloadOptions,
binDir: string
binDir: string,
access: AccessOptions
): string[] {
const args = [
'--newline',
@@ -130,6 +173,7 @@ export function buildArgs(
'--no-simulate'
]
args.push(...accessArgs(access))
args.push(...postProcessArgs(opts, o))
if (opts.kind === 'audio') {
+139
View File
@@ -0,0 +1,139 @@
import { app, session, BrowserWindow, type Cookie } from 'electron'
import { existsSync, statSync, unlinkSync, writeFileSync } from 'fs'
import { join } from 'path'
import { assertHttpUrl } from './url'
import type { CookiesStatus, CookiesLoginResult } from '@shared/ipc'
/**
* Persisted session AeroFetch's built-in sign-in window uses. Kept separate
* from the main window's (default) session so a logged-in site can't see or
* touch anything the app itself loads.
*/
const PARTITION = 'persist:aerofetch-login'
export function getCookiesFilePath(): string {
return join(app.getPath('userData'), 'cookies.txt')
}
export function getCookiesStatus(): CookiesStatus {
const p = getCookiesFilePath()
if (!existsSync(p)) return { exists: false }
return { exists: true, savedAt: statSync(p).mtimeMs }
}
export async function clearCookies(): Promise<void> {
const p = getCookiesFilePath()
if (existsSync(p)) unlinkSync(p)
await session.fromPartition(PARTITION).clearStorageData()
}
/**
* Render cookies in the Netscape cookie-jar format yt-dlp's `--cookies` reads.
* Columns: domain, includeSubdomains, path, secure, expiry (unix seconds, 0
* for session cookies), name, value.
*/
function toNetscapeCookieFile(cookies: Cookie[]): string {
const lines = ['# Netscape HTTP Cookie File', '# Generated by AeroFetch — do not edit.', '']
for (const c of cookies) {
if (!c.domain || !c.name) continue
const domain = c.hostOnly || c.domain.startsWith('.') ? c.domain : `.${c.domain}`
const includeSubdomains = domain.startsWith('.') ? 'TRUE' : 'FALSE'
const expiry = c.session || !c.expirationDate ? 0 : Math.round(c.expirationDate)
lines.push(
[domain, includeSubdomains, c.path || '/', c.secure ? 'TRUE' : 'FALSE', expiry, c.name, c.value].join(
'\t'
)
)
}
return lines.join('\n') + '\n'
}
let loginWindow: BrowserWindow | null = null
let pendingResolvers: Array<(r: CookiesLoginResult) => void> = []
function exportAndResolve(): void {
session
.fromPartition(PARTITION)
.cookies.get({})
.then((cookies) => {
writeFileSync(getCookiesFilePath(), toNetscapeCookieFile(cookies))
const result: CookiesLoginResult = { ok: true, cookieCount: cookies.length }
pendingResolvers.forEach((resolve) => resolve(result))
})
.catch((e) => {
const result: CookiesLoginResult = { ok: false, error: (e as Error).message }
pendingResolvers.forEach((resolve) => resolve(result))
})
.finally(() => {
pendingResolvers = []
})
}
/**
* Open (or focus + navigate) a sign-in window backed by a persisted session
* partition. The user logs into whatever site they need, then closes the
* window — cookies are exported to a Netscape-format file at that point,
* ready for yt-dlp's `--cookies`. Mirrors Seal's "log in via WebView" feature.
*/
export function openCookieLoginWindow(url: string): Promise<CookiesLoginResult> {
return new Promise((resolve) => {
let validUrl: string
try {
validUrl = assertHttpUrl(url)
} catch (e) {
resolve({ ok: false, error: (e as Error).message })
return
}
if (loginWindow && !loginWindow.isDestroyed()) {
pendingResolvers.push(resolve)
loginWindow.loadURL(validUrl).catch(() => {})
loginWindow.focus()
return
}
pendingResolvers = [resolve]
let win: BrowserWindow
try {
win = new BrowserWindow({
width: 480,
height: 720,
title: 'Sign in — AeroFetch',
autoHideMenuBar: true,
webPreferences: {
partition: PARTITION,
sandbox: true,
contextIsolation: true,
nodeIntegration: false
}
})
} catch (e) {
pendingResolvers = []
resolve({ ok: false, error: (e as Error).message })
return
}
loginWindow = win
// Some sites log in via an OAuth/SSO popup. Let those open as real windows
// sharing the same partition, rather than silently swallowing the click.
win.webContents.setWindowOpenHandler(() => ({
action: 'allow',
overrideBrowserWindowOptions: {
autoHideMenuBar: true,
webPreferences: { partition: PARTITION, sandbox: true, contextIsolation: true, nodeIntegration: false }
}
}))
win.on('closed', () => {
loginWindow = null
})
// Closing the window IS "I'm done" — export whatever the partition
// collected. The partition itself outlives the window, so this is safe
// even though cookies.get() resolves after 'close' has already fired.
win.on('close', exportAndResolve)
win.loadURL(validUrl).catch(() => {
/* navigation errors surface as Chromium's own error page */
})
})
}
+26 -146
View File
@@ -2,18 +2,18 @@ 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 { getYtdlpPath, getBinDir, getAria2cPath } from './binaries'
import { getSettings, getDownloadArchivePath } from './settings'
import { getCookiesFilePath } from './cookies'
import { assertHttpUrl } from './url'
import { buildArgs } from './buildArgs'
import {
IpcChannels,
BEST_FORMAT_ID,
type StartDownloadOptions,
type StartDownloadResult,
type DownloadEvent,
type DownloadMeta,
type DownloadProgress,
type DownloadOptions
type DownloadProgress
} from '@shared/ipc'
interface ActiveDownload {
@@ -23,146 +23,6 @@ interface ActiveDownload {
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'
// Crop embedded audio artwork to a centred square. The double-quote/single-quote
// dance is deliberate: yt-dlp shlex-splits the `--ppa` value, consuming the outer
// double quotes and leaving ffmpeg single-quoted crop expressions whose commas are
// protected from ffmpeg's filtergraph separator. Side = min(width, height).
const CROP_SQUARE_PPA =
'EmbedThumbnail+ffmpeg_o:-c:v mjpeg -vf ' +
'crop="\'if(gt(ih,iw),iw,ih)\':\'if(gt(iw,ih),ih,iw)\'"'
/** Subtitle / SponsorBlock / chapter / metadata flags shared by both kinds. */
function postProcessArgs(opts: StartDownloadOptions, o: DownloadOptions): string[] {
const args: string[] = []
// Subtitles — video only; audio extraction has nowhere to embed them.
if (opts.kind === 'video' && o.embedSubtitles) {
args.push('--write-subs', '--embed-subs', '--sub-langs', o.subtitleLanguages || 'en')
if (o.autoSubtitles) args.push('--write-auto-subs')
// Normalise to srt first so embedding works across containers (mp4 → mov_text).
args.push('--convert-subs', 'srt')
}
// SponsorBlock.
if (o.sponsorBlock && o.sponsorBlockCategories.length > 0) {
const cats = o.sponsorBlockCategories.join(',')
if (o.sponsorBlockMode === 'remove') {
// Force keyframes at the cut points so segment boundaries are frame-accurate.
args.push('--sponsorblock-remove', cats, '--force-keyframes-at-cuts')
} else {
args.push('--sponsorblock-mark', cats)
}
}
if (o.embedChapters) args.push('--embed-chapters')
if (o.embedMetadata) args.push('--embed-metadata')
return args
}
function buildArgs(
opts: StartDownloadOptions,
outputTemplate: string,
o: DownloadOptions
): 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'
]
args.push(...postProcessArgs(opts, o))
if (opts.kind === 'audio') {
args.push('-x', '--audio-format', o.audioFormat, '--audio-quality', audioQuality(opts.quality))
if (o.embedThumbnail) {
args.push('--embed-thumbnail')
if (o.cropThumbnail) args.push('--ppa', CROP_SQUARE_PPA)
}
} else {
args.push('-f', videoSelector(opts), '--merge-output-format', o.videoContainer)
// Codec preference is a sort tiebreaker AFTER resolution/fps, so it nudges the
// pick toward the chosen codec without overriding the requested quality.
if (o.preferredVideoCodec !== 'any') {
const token = o.preferredVideoCodec === 'av1' ? 'av01' : o.preferredVideoCodec
args.push('-S', `res,fps,vcodec:${token}`)
}
// mp4/mkv carry a cover image; webm has no reliable way to embed one.
if (o.embedThumbnail && o.videoContainer !== 'webm') args.push('--embed-thumbnail')
}
// `--` terminates option parsing so the URL can never be read as a flag.
args.push('--', opts.url)
return args
}
// --- Formatting helpers (raw yt-dlp numbers → human strings) ----------------
function num(s?: string): number | undefined {
@@ -290,10 +150,30 @@ export function startDownload(
const template = settings.filenameTemplate?.trim() || '%(title)s.%(ext)s'
// Per-download override wins; otherwise use the persisted defaults.
const options = opts.options ?? settings.downloadOptions
// Silently fall back to yt-dlp's own downloader if aria2c.exe wasn't dropped
// into resources/bin — the toggle shouldn't turn into a hard error.
const aria2cPath = settings.useAria2c && existsSync(getAria2cPath()) ? getAria2cPath() : undefined
// Same idea: 'login' cookies only apply once the sign-in window has actually
// exported a file; otherwise the download proceeds cookie-less rather than failing.
const cookiesFile =
settings.cookieSource === 'login' && existsSync(getCookiesFilePath())
? getCookiesFilePath()
: undefined
const access = {
proxy: settings.proxy,
rateLimit: settings.rateLimit,
aria2cPath,
cookiesFromBrowser: settings.cookieSource === 'browser' ? settings.cookiesBrowser : undefined,
cookiesFile,
restrictFilenames: settings.restrictFilenames,
downloadArchivePath: settings.downloadArchive ? getDownloadArchivePath() : undefined
}
let child: ChildProcess
try {
child = spawn(ytdlp, buildArgs(opts, join(outDir, template), options), { windowsHide: true })
child = spawn(ytdlp, buildArgs(opts, join(outDir, template), options, getBinDir(), access), {
windowsHide: true
})
} catch (e) {
return { ok: false, error: (e as Error).message }
}
+5
View File
@@ -14,6 +14,7 @@ import { getSettings, setSettings } from './settings'
import { listHistory, addHistory, removeHistory, clearHistory } from './history'
import { setupPortableData } from './portable'
import { safeOpenPath, safeShowInFolder } from './reveal'
import { openCookieLoginWindow, getCookiesStatus, clearCookies } from './cookies'
// 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 —
@@ -121,6 +122,10 @@ function registerIpcHandlers(): void {
ipcMain.handle(IpcChannels.historyAdd, (_e, entry: HistoryEntry) => addHistory(entry))
ipcMain.handle(IpcChannels.historyRemove, (_e, id: string) => removeHistory(id))
ipcMain.handle(IpcChannels.historyClear, () => clearHistory())
ipcMain.handle(IpcChannels.cookiesLogin, (_e, url: string) => openCookieLoginWindow(url))
ipcMain.handle(IpcChannels.cookiesStatus, () => getCookiesStatus())
ipcMain.handle(IpcChannels.cookiesClear, () => clearCookies())
}
app.whenReady().then(() => {
+29 -2
View File
@@ -1,10 +1,12 @@
import { app } from 'electron'
import { join } from 'path'
import Store from 'electron-store'
import {
AUDIO_FORMATS,
VIDEO_CONTAINERS,
VIDEO_CODECS,
SPONSORBLOCK_CATEGORIES,
COOKIE_BROWSERS,
DEFAULT_DOWNLOAD_OPTIONS,
type Settings,
type DownloadOptions,
@@ -20,7 +22,19 @@ const DEFAULTS: Settings = {
filenameTemplate: '%(title)s.%(ext)s',
theme: 'light',
clipboardWatch: true,
downloadOptions: DEFAULT_DOWNLOAD_OPTIONS
downloadOptions: DEFAULT_DOWNLOAD_OPTIONS,
proxy: '',
rateLimit: '',
useAria2c: false,
cookieSource: 'none',
cookiesBrowser: 'chrome',
restrictFilenames: false,
downloadArchive: false
}
/** Fixed path for the --download-archive file; not user-configurable. */
export function getDownloadArchivePath(): string {
return join(app.getPath('userData'), 'download-archive.txt')
}
// Coerce an untrusted partial into a valid DownloadOptions, falling back to the
@@ -98,7 +112,18 @@ export function setSettings(partial: Partial<Settings>): Settings {
break
}
case 'clipboardWatch':
if (typeof value === 'boolean') s.set('clipboardWatch', value)
case 'useAria2c':
case 'restrictFilenames':
case 'downloadArchive':
if (typeof value === 'boolean') s.set(key, value)
break
case 'cookieSource':
if (value === 'none' || value === 'browser' || value === 'login') s.set(key, value)
break
case 'cookiesBrowser':
if ((COOKIE_BROWSERS as readonly string[]).includes(value as string)) {
s.set(key, value as Settings['cookiesBrowser'])
}
break
case 'downloadOptions':
// Always store a fully-validated object; the renderer sends the whole
@@ -109,6 +134,8 @@ export function setSettings(partial: Partial<Settings>): Settings {
case 'defaultVideoQuality':
case 'defaultAudioQuality':
case 'filenameTemplate':
case 'proxy':
case 'rateLimit':
if (typeof value === 'string') s.set(key, value)
break
}
+11 -1
View File
@@ -7,7 +7,9 @@ import {
type StartDownloadResult,
type DownloadEvent,
type Settings,
type HistoryEntry
type HistoryEntry,
type CookiesStatus,
type CookiesLoginResult
} from '@shared/ipc'
// The surface exposed to the renderer. Keep this thin: it only forwards to IPC.
@@ -49,6 +51,14 @@ const api = {
clearHistory: (): Promise<HistoryEntry[]> => ipcRenderer.invoke(IpcChannels.historyClear),
/** Open the built-in sign-in window; resolves once the user closes it. */
cookiesLogin: (url: string): Promise<CookiesLoginResult> =>
ipcRenderer.invoke(IpcChannels.cookiesLogin, url),
cookiesStatus: (): Promise<CookiesStatus> => ipcRenderer.invoke(IpcChannels.cookiesStatus),
cookiesClear: (): Promise<void> => ipcRenderer.invoke(IpcChannels.cookiesClear),
/** Subscribe to live download events. Returns an unsubscribe function. */
onDownloadEvent: (cb: (ev: DownloadEvent) => void): (() => void) => {
const listener = (_e: IpcRendererEvent, ev: DownloadEvent): void => cb(ev)
+192 -3
View File
@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useEffect, useState } from 'react'
import {
Field,
Input,
@@ -19,9 +19,18 @@ import {
ArrowDownloadRegular,
DocumentRegular,
OptionsRegular,
InfoRegular
InfoRegular,
GlobeRegular,
CookiesRegular
} from '@fluentui/react-icons'
import { type YtdlpVersionResult, type MediaKind } from '@shared/ipc'
import {
COOKIE_BROWSERS,
type YtdlpVersionResult,
type MediaKind,
type CookieSource,
type CookieBrowser,
type CookiesStatus
} from '@shared/ipc'
import { useSettings } from '../store/settings'
import { QUALITY_OPTIONS, useDownloads } from '../store/downloads'
import { Select } from './Select'
@@ -69,6 +78,17 @@ const FORMAT_OPTIONS = [
...QUALITY_OPTIONS.audio.map((q) => ({ value: `audio|${q}`, label: `Audio — ${q}` }))
]
const COOKIE_SOURCE_OPTIONS = [
{ value: 'none', label: 'None' },
{ value: 'browser', label: "From a browser's cookie store" },
{ value: 'login', label: 'Sign-in window (built into AeroFetch)' }
]
const COOKIE_BROWSER_OPTIONS = COOKIE_BROWSERS.map((b) => ({
value: b,
label: b[0].toUpperCase() + b.slice(1)
}))
export function SettingsView(): React.JSX.Element {
const styles = useStyles()
@@ -81,6 +101,13 @@ export function SettingsView(): React.JSX.Element {
const filenameTemplate = useSettings((s) => s.filenameTemplate)
const clipboardWatch = useSettings((s) => s.clipboardWatch)
const downloadOptions = useSettings((s) => s.downloadOptions)
const proxy = useSettings((s) => s.proxy)
const rateLimit = useSettings((s) => s.rateLimit)
const useAria2c = useSettings((s) => s.useAria2c)
const cookieSource = useSettings((s) => s.cookieSource)
const cookiesBrowser = useSettings((s) => s.cookiesBrowser)
const restrictFilenames = useSettings((s) => s.restrictFilenames)
const downloadArchive = useSettings((s) => s.downloadArchive)
const update = useSettings((s) => s.update)
const formatQuality = defaultKind === 'audio' ? defaultAudioQuality : defaultVideoQuality
@@ -89,6 +116,34 @@ export function SettingsView(): React.JSX.Element {
const [checking, setChecking] = useState(false)
const [version, setVersion] = useState<YtdlpVersionResult | null>(null)
const [cookiesStatus, setCookiesStatus] = useState<CookiesStatus | null>(null)
const [loginUrl, setLoginUrl] = useState('https://www.youtube.com')
const [signingIn, setSigningIn] = useState(false)
const [loginError, setLoginError] = useState<string | null>(null)
useEffect(() => {
window.api.cookiesStatus().then(setCookiesStatus)
}, [])
async function signIn(): Promise<void> {
setSigningIn(true)
setLoginError(null)
try {
const result = await window.api.cookiesLogin(loginUrl)
if (result.ok) setCookiesStatus(await window.api.cookiesStatus())
else setLoginError(result.error ?? 'Sign-in failed.')
} catch (e) {
setLoginError(e instanceof Error ? e.message : String(e))
} finally {
setSigningIn(false)
}
}
async function clearSavedCookies(): Promise<void> {
await window.api.cookiesClear()
setCookiesStatus({ exists: false })
}
function onFormatSelect(value: string): void {
const [kind, quality] = value.split('|') as [MediaKind, string]
update(
@@ -187,6 +242,118 @@ export function SettingsView(): React.JSX.Element {
/>
</Card>
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<GlobeRegular className={styles.sectionIcon} />
<Subtitle2>Network</Subtitle2>
</div>
<Field
label="Proxy"
hint="HTTP/HTTPS/SOCKS proxy URL, e.g. socks5://127.0.0.1:1080. Leave blank to use the system default."
>
<Input
value={proxy}
placeholder="socks5://127.0.0.1:1080"
onChange={(_, d) => update({ proxy: d.value })}
/>
</Field>
<Field
label="Rate limit"
hint="Caps download speed (e.g. 500K or 2M). Leave blank for unlimited."
>
<Input
value={rateLimit}
placeholder="2M"
onChange={(_, d) => update({ rateLimit: d.value })}
/>
</Field>
<Field
label="Use aria2c downloader"
hint="Multi-connection downloads for faster speeds on supported sites. Requires aria2c.exe in resources/bin (see the README there) — falls back to yt-dlp's downloader if it's missing."
>
<Switch
checked={useAria2c}
onChange={(_, d) => update({ useAria2c: d.checked })}
label={useAria2c ? 'On' : 'Off'}
/>
</Field>
</Card>
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<CookiesRegular className={styles.sectionIcon} />
<Subtitle2>Cookies</Subtitle2>
</div>
<Caption1 className={styles.hint}>
Some sites only serve full quality, age-restricted, or members-only video to a
logged-in session. Supply cookies so yt-dlp can act like one.
</Caption1>
<Field label="Cookie source">
<Select
aria-label="Cookie source"
value={cookieSource}
options={COOKIE_SOURCE_OPTIONS}
onChange={(v) => update({ cookieSource: v as CookieSource })}
/>
</Field>
{cookieSource === 'browser' && (
<Field
label="Browser"
hint="Reads that browser's saved cookies directly. Close it first if yt-dlp can't open a locked cookie database."
>
<Select
aria-label="Browser"
value={cookiesBrowser}
options={COOKIE_BROWSER_OPTIONS}
onChange={(v) => update({ cookiesBrowser: v as CookieBrowser })}
/>
</Field>
)}
{cookieSource === 'login' && (
<>
<Field
label="Site to sign in to"
hint="Opens a sign-in window. Log in, then close the window — your cookies are saved automatically."
>
<div className={styles.folderRow}>
<Input
className={styles.folderInput}
value={loginUrl}
placeholder="https://www.youtube.com"
onChange={(_, d) => setLoginUrl(d.value)}
/>
<Button appearance="primary" onClick={signIn} disabled={signingIn}>
{signingIn ? 'Signing in…' : 'Sign in…'}
</Button>
</div>
</Field>
<div className={styles.folderRow}>
<Caption1 className={styles.hint}>
{cookiesStatus?.exists
? `Cookies saved ${new Date(cookiesStatus.savedAt!).toLocaleString()}.`
: 'Not signed in yet.'}
</Caption1>
{cookiesStatus?.exists && (
<Button size="small" onClick={clearSavedCookies}>
Clear saved cookies
</Button>
)}
</div>
{loginError && (
<Caption1 style={{ color: tokens.colorPaletteRedForeground1 }}>{loginError}</Caption1>
)}
</>
)}
</Card>
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<DocumentRegular className={styles.sectionIcon} />
@@ -204,6 +371,28 @@ export function SettingsView(): React.JSX.Element {
<Caption1 className={styles.hint}>
Example: {filenameTemplate.replace('%(title)s', 'My Video').replace('%(ext)s', 'mp4')}
</Caption1>
<Field
label="Restrict filenames"
hint="Sanitize titles to plain ASCII letters/digits, no spaces — safer for old filesystems and shells."
>
<Switch
checked={restrictFilenames}
onChange={(_, d) => update({ restrictFilenames: d.checked })}
label={restrictFilenames ? 'On' : 'Off'}
/>
</Field>
<Field
label="Skip already-downloaded videos"
hint="Keeps a record of completed downloads (--download-archive) and skips them on repeat playlist/channel runs."
>
<Switch
checked={downloadArchive}
onChange={(_, d) => update({ downloadArchive: d.checked })}
label={downloadArchive ? 'On' : 'Off'}
/>
</Field>
</Card>
<Card className={styles.card}>
+35 -24
View File
@@ -1,7 +1,7 @@
import './assets/base.css'
import React from 'react'
import ReactDOM from 'react-dom/client'
import { DEFAULT_DOWNLOAD_OPTIONS } from '@shared/ipc'
import { DEFAULT_DOWNLOAD_OPTIONS, type Settings } from '@shared/ipc'
import App from './App'
// In the standalone UI preview (browser, no Electron preload) window.api isn't
@@ -10,6 +10,28 @@ import App from './App'
// fake progress ticker instead of calling these, so most are inert no-ops.
// In the real Electron app this branch is skipped — preload provides window.api.
if (import.meta.env.DEV && !window.api) {
const MOCK_SETTINGS: Settings = {
outputDir: 'C:\\Users\\you\\Downloads',
defaultKind: 'video',
defaultVideoQuality: 'Best available',
defaultAudioQuality: 'Best (MP3)',
maxConcurrent: 2,
filenameTemplate: '%(title)s.%(ext)s',
theme: 'light',
clipboardWatch: true,
downloadOptions: DEFAULT_DOWNLOAD_OPTIONS,
proxy: '',
rateLimit: '',
useAria2c: false,
cookieSource: 'none',
cookiesBrowser: 'chrome',
restrictFilenames: false,
downloadArchive: false
}
// Stands in for the cookies.txt file's mtime — lets the Cookies card's
// sign-in/clear flow be exercised in this browser-only preview.
let mockCookiesSavedAt: number | null = null
window.api = {
getYtdlpVersion: async () => ({ ok: true, version: '2025.06.01 (UI preview mock)' }),
probe: async (url: string) => {
@@ -59,33 +81,22 @@ if (import.meta.env.DEV && !window.api) {
openPath: async () => '',
showInFolder: async () => {},
readClipboard: async () => '',
getSettings: async () => ({
outputDir: 'C:\\Users\\you\\Downloads',
defaultKind: 'video',
defaultVideoQuality: 'Best available',
defaultAudioQuality: 'Best (MP3)',
maxConcurrent: 2,
filenameTemplate: '%(title)s.%(ext)s',
theme: 'light',
clipboardWatch: true,
downloadOptions: DEFAULT_DOWNLOAD_OPTIONS
}),
setSettings: async (partial) => ({
outputDir: 'C:\\Users\\you\\Downloads',
defaultKind: 'video',
defaultVideoQuality: 'Best available',
defaultAudioQuality: 'Best (MP3)',
maxConcurrent: 2,
filenameTemplate: '%(title)s.%(ext)s',
theme: 'light',
clipboardWatch: true,
downloadOptions: DEFAULT_DOWNLOAD_OPTIONS,
...partial
}),
getSettings: async () => MOCK_SETTINGS,
setSettings: async (partial) => Object.assign(MOCK_SETTINGS, partial),
listHistory: async () => [],
addHistory: async () => [],
removeHistory: async () => [],
clearHistory: async () => [],
cookiesLogin: async () => {
await new Promise((r) => setTimeout(r, 600)) // simulate the sign-in window
mockCookiesSavedAt = Date.now()
return { ok: true, cookieCount: 14 }
},
cookiesStatus: async () =>
mockCookiesSavedAt ? { exists: true, savedAt: mockCookiesSavedAt } : { exists: false },
cookiesClear: async () => {
mockCookiesSavedAt = null
},
onDownloadEvent: () => () => {}
}
}
+8 -1
View File
@@ -13,7 +13,14 @@ const FALLBACK: Settings = {
filenameTemplate: '%(title)s.%(ext)s',
theme: 'light',
clipboardWatch: true,
downloadOptions: DEFAULT_DOWNLOAD_OPTIONS
downloadOptions: DEFAULT_DOWNLOAD_OPTIONS,
proxy: '',
rateLimit: '',
useAria2c: false,
cookieSource: 'none',
cookiesBrowser: 'chrome',
restrictFilenames: false,
downloadArchive: false
}
interface SettingsState extends Settings {
+44 -1
View File
@@ -20,7 +20,10 @@ export const IpcChannels = {
historyRemove: 'history:remove',
historyClear: 'history:clear',
/** main → renderer push channel for live download events */
downloadEvent: 'download:event'
downloadEvent: 'download:event',
cookiesLogin: 'cookies:login',
cookiesStatus: 'cookies:status',
cookiesClear: 'cookies:clear'
} as const
/** Sentinel format id meaning "let yt-dlp pick the best video+audio". */
@@ -45,6 +48,17 @@ export const VIDEO_CODECS: VideoCodecPref[] = ['any', 'h264', 'vp9', 'av1']
/** Whether SponsorBlock cuts the segments out or just marks them as chapters. */
export type SponsorBlockMode = 'remove' | 'mark'
/**
* Where yt-dlp's auth cookies come from. 'browser' reads a logged-in browser's
* cookie store directly; 'login' uses a cookie file exported from AeroFetch's
* own built-in sign-in window (see src/main/cookies.ts).
*/
export type CookieSource = 'none' | 'browser' | 'login'
/** Browsers yt-dlp's --cookies-from-browser supports that ship on Windows. */
export const COOKIE_BROWSERS = ['chrome', 'edge', 'firefox', 'brave', 'opera', 'vivaldi'] as const
export type CookieBrowser = (typeof COOKIE_BROWSERS)[number]
/** SponsorBlock segment categories (subset of yt-dlp's, the ones Seal exposes). */
export const SPONSORBLOCK_CATEGORIES = [
'sponsor',
@@ -234,6 +248,35 @@ export interface Settings {
clipboardWatch: boolean
/** default post-processing / format options for new downloads */
downloadOptions: DownloadOptions
/** yt-dlp --proxy value, e.g. 'socks5://127.0.0.1:1080'. Empty disables it. */
proxy: string
/** yt-dlp --limit-rate value, e.g. '2M' or '500K'. Empty means unlimited. */
rateLimit: string
/** use bundled aria2c (resources/bin/aria2c.exe) as the external downloader */
useAria2c: boolean
/** where auth cookies come from: none, a browser's store, or the sign-in window */
cookieSource: CookieSource
/** which browser to read cookies from when cookieSource is 'browser' */
cookiesBrowser: CookieBrowser
/** sanitize output filenames to a portable ASCII-only subset (--restrict-filenames) */
restrictFilenames: boolean
/** record completed downloads in a yt-dlp --download-archive file to skip repeats */
downloadArchive: boolean
}
/** Whether the built-in sign-in window has ever saved a cookie file, and when. */
export interface CookiesStatus {
exists: boolean
/** epoch milliseconds the cookie file was last written */
savedAt?: number
}
/** Result of a built-in sign-in window session, resolved once the window closes. */
export interface CookiesLoginResult {
ok: boolean
/** number of cookies captured into the exported file */
cookieCount?: number
error?: string
}
/** A completed download, persisted to history.json (newest-first). */
+96 -3
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { buildArgs, CROP_SQUARE_PPA } from '../src/main/buildArgs'
import { buildArgs, CROP_SQUARE_PPA, ARIA2C_ARGS, type AccessOptions } from '../src/main/buildArgs'
import {
DEFAULT_DOWNLOAD_OPTIONS,
type DownloadOptions,
@@ -11,6 +11,7 @@ import {
const BIN = 'C:/fake/bin'
const OUT = 'C:/out/%(title)s.%(ext)s'
const URL = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'
const NO_ACCESS: AccessOptions = { proxy: '', rateLimit: '', restrictFilenames: false }
function opts(overrides: Partial<StartDownloadOptions> = {}): StartDownloadOptions {
return { id: 't', url: URL, kind: 'video', quality: '1080p', ...overrides }
@@ -21,8 +22,12 @@ function audio(overrides: Partial<StartDownloadOptions> = {}): StartDownloadOpti
function dlo(overrides: Partial<DownloadOptions> = {}): DownloadOptions {
return { ...DEFAULT_DOWNLOAD_OPTIONS, ...overrides }
}
function build(o: StartDownloadOptions, d: DownloadOptions): string[] {
return buildArgs(o, OUT, d, BIN)
function build(
o: StartDownloadOptions,
d: DownloadOptions,
access: AccessOptions = NO_ACCESS
): string[] {
return buildArgs(o, OUT, d, BIN, access)
}
/** True when `seq` appears as a contiguous run inside `argv`. */
@@ -48,6 +53,94 @@ describe('buildArgs scaffolding', () => {
})
})
// --- Network: proxy / rate limit / aria2c -----------------------------------
describe('network options', () => {
it('emits nothing when proxy/rateLimit are empty and aria2c is unset', () => {
const argv = build(opts(), dlo(), NO_ACCESS)
expect(argv).not.toContain('--proxy')
expect(argv).not.toContain('--limit-rate')
expect(argv).not.toContain('--downloader')
})
it('emits --proxy with the trimmed value', () => {
const argv = build(opts(), dlo(), { ...NO_ACCESS, proxy: ' socks5://127.0.0.1:1080 ' })
expect(hasSeq(argv, '--proxy', 'socks5://127.0.0.1:1080')).toBe(true)
})
it('emits --limit-rate with the trimmed value', () => {
const argv = build(opts(), dlo(), { ...NO_ACCESS, rateLimit: ' 2M ' })
expect(hasSeq(argv, '--limit-rate', '2M')).toBe(true)
})
it('emits --downloader <path> --downloader-args when an aria2c path is given', () => {
const argv = build(opts(), dlo(), { ...NO_ACCESS, aria2cPath: 'C:/fake/bin/aria2c.exe' })
expect(hasSeq(argv, '--downloader', 'C:/fake/bin/aria2c.exe', '--downloader-args', ARIA2C_ARGS)).toBe(
true
)
})
it('omits --downloader when aria2cPath is not set', () => {
const argv = build(opts(), dlo(), NO_ACCESS)
expect(argv).not.toContain('--downloader-args')
})
})
// --- Cookies: browser store vs. sign-in window's exported file --------------
describe('cookies', () => {
it('emits nothing when no cookie source is configured', () => {
const argv = build(opts(), dlo(), NO_ACCESS)
expect(argv).not.toContain('--cookies-from-browser')
expect(argv).not.toContain('--cookies')
})
it('emits --cookies-from-browser <name> when cookiesFromBrowser is set', () => {
const argv = build(opts(), dlo(), { ...NO_ACCESS, cookiesFromBrowser: 'firefox' })
expect(hasSeq(argv, '--cookies-from-browser', 'firefox')).toBe(true)
expect(argv).not.toContain('--cookies')
})
it('emits --cookies <path> when only cookiesFile is set', () => {
const argv = build(opts(), dlo(), { ...NO_ACCESS, cookiesFile: 'C:/userdata/cookies.txt' })
expect(hasSeq(argv, '--cookies', 'C:/userdata/cookies.txt')).toBe(true)
expect(argv).not.toContain('--cookies-from-browser')
})
it('prefers cookiesFromBrowser over cookiesFile when both are set', () => {
const argv = build(opts(), dlo(), {
...NO_ACCESS,
cookiesFromBrowser: 'chrome',
cookiesFile: 'C:/userdata/cookies.txt'
})
expect(hasSeq(argv, '--cookies-from-browser', 'chrome')).toBe(true)
expect(argv).not.toContain('--cookies')
})
})
// --- Restrict filenames / download archive -----------------------------------
describe('restrictFilenames / downloadArchivePath', () => {
it('emits --restrict-filenames when set', () => {
expect(build(opts(), dlo(), { ...NO_ACCESS, restrictFilenames: true })).toContain(
'--restrict-filenames'
)
})
it('omits --restrict-filenames by default', () => {
expect(build(opts(), dlo(), NO_ACCESS)).not.toContain('--restrict-filenames')
})
it('emits --download-archive <path> when a path is given', () => {
const argv = build(opts(), dlo(), { ...NO_ACCESS, downloadArchivePath: 'C:/userdata/archive.txt' })
expect(hasSeq(argv, '--download-archive', 'C:/userdata/archive.txt')).toBe(true)
})
it('omits --download-archive when no path is given', () => {
expect(build(opts(), dlo(), NO_ACCESS)).not.toContain('--download-archive')
})
})
// --- Audio extraction format ------------------------------------------------
describe('audio: -x --audio-format <fmt>', () => {
+3 -2
View File
@@ -21,6 +21,7 @@ const RUN = process.env.AEROFETCH_REAL_DOWNLOAD === '1'
const BIN_DIR = resolve('resources/bin')
const YTDLP = join(BIN_DIR, 'yt-dlp.exe')
const FFMPEG = join(BIN_DIR, 'ffmpeg.exe')
const NO_ACCESS = { proxy: '', rateLimit: '', restrictFilenames: false }
interface RunResult {
status: number | null
@@ -105,7 +106,7 @@ describe.skipIf(!RUN)('real yt-dlp downloads (buildArgs end-to-end)', () => {
embedThumbnail: true,
cropThumbnail: true
}
const argv = buildArgs(opts, join(outDir, '%(id)s.%(ext)s'), options, BIN_DIR)
const argv = buildArgs(opts, join(outDir, '%(id)s.%(ext)s'), options, BIN_DIR, NO_ACCESS)
console.log('[crop] argv:', JSON.stringify(argv))
const res = runYtdlp(argv)
@@ -146,7 +147,7 @@ describe.skipIf(!RUN)('real yt-dlp downloads (buildArgs end-to-end)', () => {
sponsorBlockMode: 'remove' as const,
sponsorBlockCategories: ['music_offtopic' as const]
}
const argv = buildArgs(opts, join(outDir, '%(id)s.%(ext)s'), options, BIN_DIR)
const argv = buildArgs(opts, join(outDir, '%(id)s.%(ext)s'), options, BIN_DIR, NO_ACCESS)
console.log('[sb] argv:', JSON.stringify(argv))
const res = runYtdlp(argv)