diff --git a/ROADMAP.md b/ROADMAP.md index ff0d53c..c7a4c05 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 ` (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 ` (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 --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 diff --git a/resources/bin/README.md b/resources/bin/README.md index 508c4c3..a8f1fd3 100644 --- a/resources/bin/README.md +++ b/resources/bin/README.md @@ -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. diff --git a/src/main/binaries.ts b/src/main/binaries.ts index 6cfa489..79313f2 100644 --- a/src/main/binaries.ts +++ b/src/main/binaries.ts @@ -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') +} diff --git a/src/main/buildArgs.ts b/src/main/buildArgs.ts index ecf9dee..737b672 100644 --- a/src/main/buildArgs.ts +++ b/src/main/buildArgs.ts @@ -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') { diff --git a/src/main/cookies.ts b/src/main/cookies.ts new file mode 100644 index 0000000..6967cf3 --- /dev/null +++ b/src/main/cookies.ts @@ -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 { + 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 { + 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 */ + }) + }) +} diff --git a/src/main/download.ts b/src/main/download.ts index a3df881..058677a 100644 --- a/src/main/download.ts +++ b/src/main/download.ts @@ -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() -// --- 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 } } diff --git a/src/main/index.ts b/src/main/index.ts index a9274ff..e110838 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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(() => { diff --git a/src/main/settings.ts b/src/main/settings.ts index 31b650c..a61c97d 100644 --- a/src/main/settings.ts +++ b/src/main/settings.ts @@ -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 { 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 { case 'defaultVideoQuality': case 'defaultAudioQuality': case 'filenameTemplate': + case 'proxy': + case 'rateLimit': if (typeof value === 'string') s.set(key, value) break } diff --git a/src/preload/index.ts b/src/preload/index.ts index eee2ddf..4a469e8 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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 => ipcRenderer.invoke(IpcChannels.historyClear), + /** Open the built-in sign-in window; resolves once the user closes it. */ + cookiesLogin: (url: string): Promise => + ipcRenderer.invoke(IpcChannels.cookiesLogin, url), + + cookiesStatus: (): Promise => ipcRenderer.invoke(IpcChannels.cookiesStatus), + + cookiesClear: (): Promise => 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) diff --git a/src/renderer/src/components/SettingsView.tsx b/src/renderer/src/components/SettingsView.tsx index 1edc7cc..fc0b8b2 100644 --- a/src/renderer/src/components/SettingsView.tsx +++ b/src/renderer/src/components/SettingsView.tsx @@ -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(null) + const [cookiesStatus, setCookiesStatus] = useState(null) + const [loginUrl, setLoginUrl] = useState('https://www.youtube.com') + const [signingIn, setSigningIn] = useState(false) + const [loginError, setLoginError] = useState(null) + + useEffect(() => { + window.api.cookiesStatus().then(setCookiesStatus) + }, []) + + async function signIn(): Promise { + 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 { + 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 { /> + +
+ + Network +
+ + + update({ proxy: d.value })} + /> + + + + update({ rateLimit: d.value })} + /> + + + + update({ useAria2c: d.checked })} + label={useAria2c ? 'On' : 'Off'} + /> + +
+ + +
+ + Cookies +
+ + 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. + + + + update({ cookiesBrowser: v as CookieBrowser })} + /> + + )} + + {cookieSource === 'login' && ( + <> + +
+ setLoginUrl(d.value)} + /> + +
+
+ +
+ + {cookiesStatus?.exists + ? `Cookies saved ${new Date(cookiesStatus.savedAt!).toLocaleString()}.` + : 'Not signed in yet.'} + + {cookiesStatus?.exists && ( + + )} +
+ + {loginError && ( + {loginError} + )} + + )} +
+
@@ -204,6 +371,28 @@ export function SettingsView(): React.JSX.Element { Example: {filenameTemplate.replace('%(title)s', 'My Video').replace('%(ext)s', 'mp4')} + + + update({ restrictFilenames: d.checked })} + label={restrictFilenames ? 'On' : 'Off'} + /> + + + + update({ downloadArchive: d.checked })} + label={downloadArchive ? 'On' : 'Off'} + /> + diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index eb3cdb7..346d9e6 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -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: () => () => {} } } diff --git a/src/renderer/src/store/settings.ts b/src/renderer/src/store/settings.ts index 0466934..7f5f762 100644 --- a/src/renderer/src/store/settings.ts +++ b/src/renderer/src/store/settings.ts @@ -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 { diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index a9ebcde..34a6f45 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -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). */ diff --git a/test/buildArgs.test.ts b/test/buildArgs.test.ts index e4656f8..cd24d9e 100644 --- a/test/buildArgs.test.ts +++ b/test/buildArgs.test.ts @@ -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 { return { id: 't', url: URL, kind: 'video', quality: '1080p', ...overrides } @@ -21,8 +22,12 @@ function audio(overrides: Partial = {}): StartDownloadOpti function dlo(overrides: Partial = {}): 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 --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 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 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 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 ', () => { diff --git a/test/real-download.integration.test.ts b/test/real-download.integration.test.ts index decf9a4..7fe6ace 100644 --- a/test/real-download.integration.test.ts +++ b/test/real-download.integration.test.ts @@ -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)