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
+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 {