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:
@@ -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
@@ -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: () => () => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user