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:
+31
-9
@@ -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
|
persisted defaults; "Reset to defaults" clears it. Threads `options` through
|
||||||
`addFromUrl → DownloadItem → startDownload` and is carried on retry.
|
`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**
|
`buildArgs`'s former `NetworkOptions` param is now `AccessOptions` (still in
|
||||||
a built-in login window (Electron `BrowserWindow` + session cookie export →
|
`src/main/buildArgs.ts`) — it grew past pure networking once cookies and
|
||||||
`--cookies file.txt`) — Seal's WebView cookie feature, fully doable in Electron.
|
filename/archive flags joined proxy/rate-limit/aria2c. *Caveat: the sign-in
|
||||||
- [ ] **aria2c external downloader.** Bundle `aria2c.exe`, `--downloader aria2c
|
window's cookie export is implemented per the Electron session-cookie and
|
||||||
--downloader-args`, toggle in settings.
|
Netscape cookie-jar formats and verified by reasoning + typecheck, not yet by
|
||||||
- [ ] **Proxy** (`--proxy`) and **rate limit** (`--limit-rate`).
|
an actual live login → yt-dlp download against a gated video — worth a real
|
||||||
- [ ] **Restrict filenames** toggle (`--restrict-filenames`) + optional **download archive**
|
smoke test.*
|
||||||
(`--download-archive`, skip already-downloaded).
|
|
||||||
|
- [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
|
## Phase C — Custom commands & yt-dlp management
|
||||||
|
|
||||||
|
|||||||
+13
-2
@@ -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
|
electron-builder `extraResources` (they ship outside the asar archive). They are
|
||||||
**not** committed to git (see `.gitignore`).
|
**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/
|
resources/bin/
|
||||||
├── yt-dlp.exe
|
├── yt-dlp.exe
|
||||||
└── ffmpeg.exe
|
├── ffmpeg.exe
|
||||||
|
└── aria2c.exe (optional)
|
||||||
```
|
```
|
||||||
|
|
||||||
## yt-dlp.exe
|
## yt-dlp.exe
|
||||||
@@ -26,5 +28,14 @@ resources/bin/
|
|||||||
- Copy `ffmpeg.exe` here. Ship the LGPL license text alongside the installer
|
- Copy `ffmpeg.exe` here. Ship the LGPL license text alongside the installer
|
||||||
before release.
|
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
|
> Branding stays generic: AeroFetch is a "yt-dlp frontend." Keep upstream
|
||||||
> license texts with any distributed build.
|
> license texts with any distributed build.
|
||||||
|
|||||||
@@ -20,3 +20,8 @@ export function getYtdlpPath(): string {
|
|||||||
export function getFfmpegPath(): string {
|
export function getFfmpegPath(): string {
|
||||||
return join(getBinDir(), 'ffmpeg.exe')
|
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
@@ -11,7 +11,8 @@
|
|||||||
import {
|
import {
|
||||||
BEST_FORMAT_ID,
|
BEST_FORMAT_ID,
|
||||||
type StartDownloadOptions,
|
type StartDownloadOptions,
|
||||||
type DownloadOptions
|
type DownloadOptions,
|
||||||
|
type CookieBrowser
|
||||||
} from '@shared/ipc'
|
} from '@shared/ipc'
|
||||||
|
|
||||||
// --- Quality → yt-dlp selector mapping --------------------------------------
|
// --- Quality → yt-dlp selector mapping --------------------------------------
|
||||||
@@ -76,6 +77,47 @@ export const CROP_SQUARE_PPA =
|
|||||||
'EmbedThumbnail+ffmpeg_o:-c:v mjpeg -vf ' +
|
'EmbedThumbnail+ffmpeg_o:-c:v mjpeg -vf ' +
|
||||||
'crop="\'if(gt(ih,iw),iw,ih)\':\'if(gt(iw,ih),ih,iw)\'"'
|
'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. */
|
/** Subtitle / SponsorBlock / chapter / metadata flags shared by both kinds. */
|
||||||
function postProcessArgs(opts: StartDownloadOptions, o: DownloadOptions): string[] {
|
function postProcessArgs(opts: StartDownloadOptions, o: DownloadOptions): string[] {
|
||||||
const args: string[] = []
|
const args: string[] = []
|
||||||
@@ -109,7 +151,8 @@ export function buildArgs(
|
|||||||
opts: StartDownloadOptions,
|
opts: StartDownloadOptions,
|
||||||
outputTemplate: string,
|
outputTemplate: string,
|
||||||
o: DownloadOptions,
|
o: DownloadOptions,
|
||||||
binDir: string
|
binDir: string,
|
||||||
|
access: AccessOptions
|
||||||
): string[] {
|
): string[] {
|
||||||
const args = [
|
const args = [
|
||||||
'--newline',
|
'--newline',
|
||||||
@@ -130,6 +173,7 @@ export function buildArgs(
|
|||||||
'--no-simulate'
|
'--no-simulate'
|
||||||
]
|
]
|
||||||
|
|
||||||
|
args.push(...accessArgs(access))
|
||||||
args.push(...postProcessArgs(opts, o))
|
args.push(...postProcessArgs(opts, o))
|
||||||
|
|
||||||
if (opts.kind === 'audio') {
|
if (opts.kind === 'audio') {
|
||||||
|
|||||||
@@ -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
@@ -2,18 +2,18 @@ import { spawn, execFile, type ChildProcess } from 'child_process'
|
|||||||
import { existsSync } from 'fs'
|
import { existsSync } from 'fs'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { app, type WebContents } from 'electron'
|
import { app, type WebContents } from 'electron'
|
||||||
import { getYtdlpPath, getBinDir } from './binaries'
|
import { getYtdlpPath, getBinDir, getAria2cPath } from './binaries'
|
||||||
import { getSettings } from './settings'
|
import { getSettings, getDownloadArchivePath } from './settings'
|
||||||
|
import { getCookiesFilePath } from './cookies'
|
||||||
import { assertHttpUrl } from './url'
|
import { assertHttpUrl } from './url'
|
||||||
|
import { buildArgs } from './buildArgs'
|
||||||
import {
|
import {
|
||||||
IpcChannels,
|
IpcChannels,
|
||||||
BEST_FORMAT_ID,
|
|
||||||
type StartDownloadOptions,
|
type StartDownloadOptions,
|
||||||
type StartDownloadResult,
|
type StartDownloadResult,
|
||||||
type DownloadEvent,
|
type DownloadEvent,
|
||||||
type DownloadMeta,
|
type DownloadMeta,
|
||||||
type DownloadProgress,
|
type DownloadProgress
|
||||||
type DownloadOptions
|
|
||||||
} from '@shared/ipc'
|
} from '@shared/ipc'
|
||||||
|
|
||||||
interface ActiveDownload {
|
interface ActiveDownload {
|
||||||
@@ -23,146 +23,6 @@ interface ActiveDownload {
|
|||||||
|
|
||||||
const active = new Map<string, 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) ----------------
|
// --- Formatting helpers (raw yt-dlp numbers → human strings) ----------------
|
||||||
|
|
||||||
function num(s?: string): number | undefined {
|
function num(s?: string): number | undefined {
|
||||||
@@ -290,10 +150,30 @@ export function startDownload(
|
|||||||
const template = settings.filenameTemplate?.trim() || '%(title)s.%(ext)s'
|
const template = settings.filenameTemplate?.trim() || '%(title)s.%(ext)s'
|
||||||
// Per-download override wins; otherwise use the persisted defaults.
|
// Per-download override wins; otherwise use the persisted defaults.
|
||||||
const options = opts.options ?? settings.downloadOptions
|
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
|
let child: ChildProcess
|
||||||
try {
|
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) {
|
} catch (e) {
|
||||||
return { ok: false, error: (e as Error).message }
|
return { ok: false, error: (e as Error).message }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { getSettings, setSettings } from './settings'
|
|||||||
import { listHistory, addHistory, removeHistory, clearHistory } from './history'
|
import { listHistory, addHistory, removeHistory, clearHistory } from './history'
|
||||||
import { setupPortableData } from './portable'
|
import { setupPortableData } from './portable'
|
||||||
import { safeOpenPath, safeShowInFolder } from './reveal'
|
import { safeOpenPath, safeShowInFolder } from './reveal'
|
||||||
|
import { openCookieLoginWindow, getCookiesStatus, clearCookies } from './cookies'
|
||||||
|
|
||||||
// Force software compositing (GPU acceleration off). On this dev machine's old GeForce
|
// 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 —
|
// 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.historyAdd, (_e, entry: HistoryEntry) => addHistory(entry))
|
||||||
ipcMain.handle(IpcChannels.historyRemove, (_e, id: string) => removeHistory(id))
|
ipcMain.handle(IpcChannels.historyRemove, (_e, id: string) => removeHistory(id))
|
||||||
ipcMain.handle(IpcChannels.historyClear, () => clearHistory())
|
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(() => {
|
app.whenReady().then(() => {
|
||||||
|
|||||||
+29
-2
@@ -1,10 +1,12 @@
|
|||||||
import { app } from 'electron'
|
import { app } from 'electron'
|
||||||
|
import { join } from 'path'
|
||||||
import Store from 'electron-store'
|
import Store from 'electron-store'
|
||||||
import {
|
import {
|
||||||
AUDIO_FORMATS,
|
AUDIO_FORMATS,
|
||||||
VIDEO_CONTAINERS,
|
VIDEO_CONTAINERS,
|
||||||
VIDEO_CODECS,
|
VIDEO_CODECS,
|
||||||
SPONSORBLOCK_CATEGORIES,
|
SPONSORBLOCK_CATEGORIES,
|
||||||
|
COOKIE_BROWSERS,
|
||||||
DEFAULT_DOWNLOAD_OPTIONS,
|
DEFAULT_DOWNLOAD_OPTIONS,
|
||||||
type Settings,
|
type Settings,
|
||||||
type DownloadOptions,
|
type DownloadOptions,
|
||||||
@@ -20,7 +22,19 @@ const DEFAULTS: Settings = {
|
|||||||
filenameTemplate: '%(title)s.%(ext)s',
|
filenameTemplate: '%(title)s.%(ext)s',
|
||||||
theme: 'light',
|
theme: 'light',
|
||||||
clipboardWatch: true,
|
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
|
// Coerce an untrusted partial into a valid DownloadOptions, falling back to the
|
||||||
@@ -98,7 +112,18 @@ export function setSettings(partial: Partial<Settings>): Settings {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'clipboardWatch':
|
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
|
break
|
||||||
case 'downloadOptions':
|
case 'downloadOptions':
|
||||||
// Always store a fully-validated object; the renderer sends the whole
|
// 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 'defaultVideoQuality':
|
||||||
case 'defaultAudioQuality':
|
case 'defaultAudioQuality':
|
||||||
case 'filenameTemplate':
|
case 'filenameTemplate':
|
||||||
|
case 'proxy':
|
||||||
|
case 'rateLimit':
|
||||||
if (typeof value === 'string') s.set(key, value)
|
if (typeof value === 'string') s.set(key, value)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-1
@@ -7,7 +7,9 @@ import {
|
|||||||
type StartDownloadResult,
|
type StartDownloadResult,
|
||||||
type DownloadEvent,
|
type DownloadEvent,
|
||||||
type Settings,
|
type Settings,
|
||||||
type HistoryEntry
|
type HistoryEntry,
|
||||||
|
type CookiesStatus,
|
||||||
|
type CookiesLoginResult
|
||||||
} from '@shared/ipc'
|
} from '@shared/ipc'
|
||||||
|
|
||||||
// The surface exposed to the renderer. Keep this thin: it only forwards to 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),
|
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. */
|
/** Subscribe to live download events. Returns an unsubscribe function. */
|
||||||
onDownloadEvent: (cb: (ev: DownloadEvent) => void): (() => void) => {
|
onDownloadEvent: (cb: (ev: DownloadEvent) => void): (() => void) => {
|
||||||
const listener = (_e: IpcRendererEvent, ev: DownloadEvent): void => cb(ev)
|
const listener = (_e: IpcRendererEvent, ev: DownloadEvent): void => cb(ev)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
Field,
|
Field,
|
||||||
Input,
|
Input,
|
||||||
@@ -19,9 +19,18 @@ import {
|
|||||||
ArrowDownloadRegular,
|
ArrowDownloadRegular,
|
||||||
DocumentRegular,
|
DocumentRegular,
|
||||||
OptionsRegular,
|
OptionsRegular,
|
||||||
InfoRegular
|
InfoRegular,
|
||||||
|
GlobeRegular,
|
||||||
|
CookiesRegular
|
||||||
} from '@fluentui/react-icons'
|
} 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 { useSettings } from '../store/settings'
|
||||||
import { QUALITY_OPTIONS, useDownloads } from '../store/downloads'
|
import { QUALITY_OPTIONS, useDownloads } from '../store/downloads'
|
||||||
import { Select } from './Select'
|
import { Select } from './Select'
|
||||||
@@ -69,6 +78,17 @@ const FORMAT_OPTIONS = [
|
|||||||
...QUALITY_OPTIONS.audio.map((q) => ({ value: `audio|${q}`, label: `Audio — ${q}` }))
|
...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 {
|
export function SettingsView(): React.JSX.Element {
|
||||||
const styles = useStyles()
|
const styles = useStyles()
|
||||||
|
|
||||||
@@ -81,6 +101,13 @@ export function SettingsView(): React.JSX.Element {
|
|||||||
const filenameTemplate = useSettings((s) => s.filenameTemplate)
|
const filenameTemplate = useSettings((s) => s.filenameTemplate)
|
||||||
const clipboardWatch = useSettings((s) => s.clipboardWatch)
|
const clipboardWatch = useSettings((s) => s.clipboardWatch)
|
||||||
const downloadOptions = useSettings((s) => s.downloadOptions)
|
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 update = useSettings((s) => s.update)
|
||||||
|
|
||||||
const formatQuality = defaultKind === 'audio' ? defaultAudioQuality : defaultVideoQuality
|
const formatQuality = defaultKind === 'audio' ? defaultAudioQuality : defaultVideoQuality
|
||||||
@@ -89,6 +116,34 @@ export function SettingsView(): React.JSX.Element {
|
|||||||
const [checking, setChecking] = useState(false)
|
const [checking, setChecking] = useState(false)
|
||||||
const [version, setVersion] = useState<YtdlpVersionResult | null>(null)
|
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 {
|
function onFormatSelect(value: string): void {
|
||||||
const [kind, quality] = value.split('|') as [MediaKind, string]
|
const [kind, quality] = value.split('|') as [MediaKind, string]
|
||||||
update(
|
update(
|
||||||
@@ -187,6 +242,118 @@ export function SettingsView(): React.JSX.Element {
|
|||||||
/>
|
/>
|
||||||
</Card>
|
</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}>
|
<Card className={styles.card}>
|
||||||
<div className={styles.sectionHeader}>
|
<div className={styles.sectionHeader}>
|
||||||
<DocumentRegular className={styles.sectionIcon} />
|
<DocumentRegular className={styles.sectionIcon} />
|
||||||
@@ -204,6 +371,28 @@ export function SettingsView(): React.JSX.Element {
|
|||||||
<Caption1 className={styles.hint}>
|
<Caption1 className={styles.hint}>
|
||||||
Example: {filenameTemplate.replace('%(title)s', 'My Video').replace('%(ext)s', 'mp4')}
|
Example: {filenameTemplate.replace('%(title)s', 'My Video').replace('%(ext)s', 'mp4')}
|
||||||
</Caption1>
|
</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>
|
||||||
|
|
||||||
<Card className={styles.card}>
|
<Card className={styles.card}>
|
||||||
|
|||||||
+35
-24
@@ -1,7 +1,7 @@
|
|||||||
import './assets/base.css'
|
import './assets/base.css'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import ReactDOM from 'react-dom/client'
|
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'
|
import App from './App'
|
||||||
|
|
||||||
// In the standalone UI preview (browser, no Electron preload) window.api isn't
|
// 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.
|
// 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.
|
// In the real Electron app this branch is skipped — preload provides window.api.
|
||||||
if (import.meta.env.DEV && !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 = {
|
window.api = {
|
||||||
getYtdlpVersion: async () => ({ ok: true, version: '2025.06.01 (UI preview mock)' }),
|
getYtdlpVersion: async () => ({ ok: true, version: '2025.06.01 (UI preview mock)' }),
|
||||||
probe: async (url: string) => {
|
probe: async (url: string) => {
|
||||||
@@ -59,33 +81,22 @@ if (import.meta.env.DEV && !window.api) {
|
|||||||
openPath: async () => '',
|
openPath: async () => '',
|
||||||
showInFolder: async () => {},
|
showInFolder: async () => {},
|
||||||
readClipboard: async () => '',
|
readClipboard: async () => '',
|
||||||
getSettings: async () => ({
|
getSettings: async () => MOCK_SETTINGS,
|
||||||
outputDir: 'C:\\Users\\you\\Downloads',
|
setSettings: async (partial) => Object.assign(MOCK_SETTINGS, partial),
|
||||||
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
|
|
||||||
}),
|
|
||||||
listHistory: async () => [],
|
listHistory: async () => [],
|
||||||
addHistory: async () => [],
|
addHistory: async () => [],
|
||||||
removeHistory: async () => [],
|
removeHistory: async () => [],
|
||||||
clearHistory: 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: () => () => {}
|
onDownloadEvent: () => () => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,14 @@ const FALLBACK: Settings = {
|
|||||||
filenameTemplate: '%(title)s.%(ext)s',
|
filenameTemplate: '%(title)s.%(ext)s',
|
||||||
theme: 'light',
|
theme: 'light',
|
||||||
clipboardWatch: true,
|
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 {
|
interface SettingsState extends Settings {
|
||||||
|
|||||||
+44
-1
@@ -20,7 +20,10 @@ export const IpcChannels = {
|
|||||||
historyRemove: 'history:remove',
|
historyRemove: 'history:remove',
|
||||||
historyClear: 'history:clear',
|
historyClear: 'history:clear',
|
||||||
/** main → renderer push channel for live download events */
|
/** 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
|
} as const
|
||||||
|
|
||||||
/** Sentinel format id meaning "let yt-dlp pick the best video+audio". */
|
/** 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. */
|
/** Whether SponsorBlock cuts the segments out or just marks them as chapters. */
|
||||||
export type SponsorBlockMode = 'remove' | 'mark'
|
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). */
|
/** SponsorBlock segment categories (subset of yt-dlp's, the ones Seal exposes). */
|
||||||
export const SPONSORBLOCK_CATEGORIES = [
|
export const SPONSORBLOCK_CATEGORIES = [
|
||||||
'sponsor',
|
'sponsor',
|
||||||
@@ -234,6 +248,35 @@ export interface Settings {
|
|||||||
clipboardWatch: boolean
|
clipboardWatch: boolean
|
||||||
/** default post-processing / format options for new downloads */
|
/** default post-processing / format options for new downloads */
|
||||||
downloadOptions: DownloadOptions
|
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). */
|
/** A completed download, persisted to history.json (newest-first). */
|
||||||
|
|||||||
+96
-3
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
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 {
|
import {
|
||||||
DEFAULT_DOWNLOAD_OPTIONS,
|
DEFAULT_DOWNLOAD_OPTIONS,
|
||||||
type DownloadOptions,
|
type DownloadOptions,
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
const BIN = 'C:/fake/bin'
|
const BIN = 'C:/fake/bin'
|
||||||
const OUT = 'C:/out/%(title)s.%(ext)s'
|
const OUT = 'C:/out/%(title)s.%(ext)s'
|
||||||
const URL = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'
|
const URL = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'
|
||||||
|
const NO_ACCESS: AccessOptions = { proxy: '', rateLimit: '', restrictFilenames: false }
|
||||||
|
|
||||||
function opts(overrides: Partial<StartDownloadOptions> = {}): StartDownloadOptions {
|
function opts(overrides: Partial<StartDownloadOptions> = {}): StartDownloadOptions {
|
||||||
return { id: 't', url: URL, kind: 'video', quality: '1080p', ...overrides }
|
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 {
|
function dlo(overrides: Partial<DownloadOptions> = {}): DownloadOptions {
|
||||||
return { ...DEFAULT_DOWNLOAD_OPTIONS, ...overrides }
|
return { ...DEFAULT_DOWNLOAD_OPTIONS, ...overrides }
|
||||||
}
|
}
|
||||||
function build(o: StartDownloadOptions, d: DownloadOptions): string[] {
|
function build(
|
||||||
return buildArgs(o, OUT, d, BIN)
|
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`. */
|
/** 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 ------------------------------------------------
|
// --- Audio extraction format ------------------------------------------------
|
||||||
|
|
||||||
describe('audio: -x --audio-format <fmt>', () => {
|
describe('audio: -x --audio-format <fmt>', () => {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const RUN = process.env.AEROFETCH_REAL_DOWNLOAD === '1'
|
|||||||
const BIN_DIR = resolve('resources/bin')
|
const BIN_DIR = resolve('resources/bin')
|
||||||
const YTDLP = join(BIN_DIR, 'yt-dlp.exe')
|
const YTDLP = join(BIN_DIR, 'yt-dlp.exe')
|
||||||
const FFMPEG = join(BIN_DIR, 'ffmpeg.exe')
|
const FFMPEG = join(BIN_DIR, 'ffmpeg.exe')
|
||||||
|
const NO_ACCESS = { proxy: '', rateLimit: '', restrictFilenames: false }
|
||||||
|
|
||||||
interface RunResult {
|
interface RunResult {
|
||||||
status: number | null
|
status: number | null
|
||||||
@@ -105,7 +106,7 @@ describe.skipIf(!RUN)('real yt-dlp downloads (buildArgs end-to-end)', () => {
|
|||||||
embedThumbnail: true,
|
embedThumbnail: true,
|
||||||
cropThumbnail: 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))
|
console.log('[crop] argv:', JSON.stringify(argv))
|
||||||
|
|
||||||
const res = runYtdlp(argv)
|
const res = runYtdlp(argv)
|
||||||
@@ -146,7 +147,7 @@ describe.skipIf(!RUN)('real yt-dlp downloads (buildArgs end-to-end)', () => {
|
|||||||
sponsorBlockMode: 'remove' as const,
|
sponsorBlockMode: 'remove' as const,
|
||||||
sponsorBlockCategories: ['music_offtopic' 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))
|
console.log('[sb] argv:', JSON.stringify(argv))
|
||||||
|
|
||||||
const res = runYtdlp(argv)
|
const res = runYtdlp(argv)
|
||||||
|
|||||||
Reference in New Issue
Block a user