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:
@@ -20,3 +20,8 @@ export function getYtdlpPath(): string {
|
||||
export function getFfmpegPath(): string {
|
||||
return join(getBinDir(), 'ffmpeg.exe')
|
||||
}
|
||||
|
||||
/** Optional bundled external downloader; absent unless dropped into resources/bin. */
|
||||
export function getAria2cPath(): string {
|
||||
return join(getBinDir(), 'aria2c.exe')
|
||||
}
|
||||
|
||||
+46
-2
@@ -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') {
|
||||
|
||||
@@ -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 { join } from 'path'
|
||||
import { app, type WebContents } from 'electron'
|
||||
import { getYtdlpPath, getBinDir } from './binaries'
|
||||
import { getSettings } from './settings'
|
||||
import { getYtdlpPath, getBinDir, getAria2cPath } from './binaries'
|
||||
import { getSettings, getDownloadArchivePath } from './settings'
|
||||
import { getCookiesFilePath } from './cookies'
|
||||
import { assertHttpUrl } from './url'
|
||||
import { buildArgs } from './buildArgs'
|
||||
import {
|
||||
IpcChannels,
|
||||
BEST_FORMAT_ID,
|
||||
type StartDownloadOptions,
|
||||
type StartDownloadResult,
|
||||
type DownloadEvent,
|
||||
type DownloadMeta,
|
||||
type DownloadProgress,
|
||||
type DownloadOptions
|
||||
type DownloadProgress
|
||||
} from '@shared/ipc'
|
||||
|
||||
interface ActiveDownload {
|
||||
@@ -23,146 +23,6 @@ interface ActiveDownload {
|
||||
|
||||
const active = new Map<string, ActiveDownload>()
|
||||
|
||||
// --- Quality → yt-dlp selector mapping --------------------------------------
|
||||
|
||||
function videoFormat(quality: string): string {
|
||||
switch (quality) {
|
||||
case '1080p':
|
||||
return 'bv*[height<=1080]+ba/b[height<=1080]'
|
||||
case '720p':
|
||||
return 'bv*[height<=720]+ba/b[height<=720]'
|
||||
case '480p':
|
||||
return 'bv*[height<=480]+ba/b[height<=480]'
|
||||
case '360p':
|
||||
return 'bv*[height<=360]+ba/b[height<=360]'
|
||||
default:
|
||||
return 'bv*+ba/b' // Best available
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The video format selector. A specific probed format_id wins; otherwise fall
|
||||
* back to the height-based preset. Video-only formats get +bestaudio so the
|
||||
* merge produces a file with sound (with a video-only fallback if no audio).
|
||||
*/
|
||||
function videoSelector(opts: StartDownloadOptions): string {
|
||||
if (opts.formatId && opts.formatId !== BEST_FORMAT_ID) {
|
||||
return opts.formatHasAudio
|
||||
? opts.formatId
|
||||
: `${opts.formatId}+bestaudio/${opts.formatId}`
|
||||
}
|
||||
return videoFormat(opts.quality)
|
||||
}
|
||||
|
||||
function audioQuality(quality: string): string {
|
||||
switch (quality) {
|
||||
case '320 kbps':
|
||||
return '320K'
|
||||
case '192 kbps':
|
||||
return '192K'
|
||||
case '128 kbps':
|
||||
return '128K'
|
||||
default:
|
||||
return '0' // Best
|
||||
}
|
||||
}
|
||||
|
||||
// The progress line yt-dlp emits (one per --newline tick). Note the leading
|
||||
// `download:` is the progress-template TYPE selector and is consumed by yt-dlp
|
||||
// (it does NOT appear in the output). The literal `prog|` that follows is our
|
||||
// own marker, so we can tell progress lines apart from the after-move filepath
|
||||
// print on the same stdout stream.
|
||||
const PROGRESS_TEMPLATE =
|
||||
'download:prog|%(progress.status)s|%(progress.downloaded_bytes)s|' +
|
||||
'%(progress.total_bytes)s|%(progress.total_bytes_estimate)s|' +
|
||||
'%(progress.speed)s|%(progress.eta)s'
|
||||
|
||||
// Crop embedded audio artwork to a centred square. The double-quote/single-quote
|
||||
// dance is deliberate: yt-dlp shlex-splits the `--ppa` value, consuming the outer
|
||||
// double quotes and leaving ffmpeg single-quoted crop expressions whose commas are
|
||||
// protected from ffmpeg's filtergraph separator. Side = min(width, height).
|
||||
const CROP_SQUARE_PPA =
|
||||
'EmbedThumbnail+ffmpeg_o:-c:v mjpeg -vf ' +
|
||||
'crop="\'if(gt(ih,iw),iw,ih)\':\'if(gt(iw,ih),ih,iw)\'"'
|
||||
|
||||
/** Subtitle / SponsorBlock / chapter / metadata flags shared by both kinds. */
|
||||
function postProcessArgs(opts: StartDownloadOptions, o: DownloadOptions): string[] {
|
||||
const args: string[] = []
|
||||
|
||||
// Subtitles — video only; audio extraction has nowhere to embed them.
|
||||
if (opts.kind === 'video' && o.embedSubtitles) {
|
||||
args.push('--write-subs', '--embed-subs', '--sub-langs', o.subtitleLanguages || 'en')
|
||||
if (o.autoSubtitles) args.push('--write-auto-subs')
|
||||
// Normalise to srt first so embedding works across containers (mp4 → mov_text).
|
||||
args.push('--convert-subs', 'srt')
|
||||
}
|
||||
|
||||
// SponsorBlock.
|
||||
if (o.sponsorBlock && o.sponsorBlockCategories.length > 0) {
|
||||
const cats = o.sponsorBlockCategories.join(',')
|
||||
if (o.sponsorBlockMode === 'remove') {
|
||||
// Force keyframes at the cut points so segment boundaries are frame-accurate.
|
||||
args.push('--sponsorblock-remove', cats, '--force-keyframes-at-cuts')
|
||||
} else {
|
||||
args.push('--sponsorblock-mark', cats)
|
||||
}
|
||||
}
|
||||
|
||||
if (o.embedChapters) args.push('--embed-chapters')
|
||||
if (o.embedMetadata) args.push('--embed-metadata')
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
function buildArgs(
|
||||
opts: StartDownloadOptions,
|
||||
outputTemplate: string,
|
||||
o: DownloadOptions
|
||||
): string[] {
|
||||
const args = [
|
||||
'--newline',
|
||||
'--no-color',
|
||||
'--no-playlist',
|
||||
// --print (below) implies --quiet, which would suppress progress; --progress
|
||||
// forces the progress template to emit anyway.
|
||||
'--progress',
|
||||
'--ffmpeg-location',
|
||||
getBinDir(),
|
||||
'-o',
|
||||
outputTemplate,
|
||||
'--progress-template',
|
||||
PROGRESS_TEMPLATE,
|
||||
// Print the final path after post-processing/move so we can open it later.
|
||||
'--print',
|
||||
'after_move:path|%(filepath)s',
|
||||
'--no-simulate'
|
||||
]
|
||||
|
||||
args.push(...postProcessArgs(opts, o))
|
||||
|
||||
if (opts.kind === 'audio') {
|
||||
args.push('-x', '--audio-format', o.audioFormat, '--audio-quality', audioQuality(opts.quality))
|
||||
if (o.embedThumbnail) {
|
||||
args.push('--embed-thumbnail')
|
||||
if (o.cropThumbnail) args.push('--ppa', CROP_SQUARE_PPA)
|
||||
}
|
||||
} else {
|
||||
args.push('-f', videoSelector(opts), '--merge-output-format', o.videoContainer)
|
||||
// Codec preference is a sort tiebreaker AFTER resolution/fps, so it nudges the
|
||||
// pick toward the chosen codec without overriding the requested quality.
|
||||
if (o.preferredVideoCodec !== 'any') {
|
||||
const token = o.preferredVideoCodec === 'av1' ? 'av01' : o.preferredVideoCodec
|
||||
args.push('-S', `res,fps,vcodec:${token}`)
|
||||
}
|
||||
// mp4/mkv carry a cover image; webm has no reliable way to embed one.
|
||||
if (o.embedThumbnail && o.videoContainer !== 'webm') args.push('--embed-thumbnail')
|
||||
}
|
||||
|
||||
// `--` terminates option parsing so the URL can never be read as a flag.
|
||||
args.push('--', opts.url)
|
||||
return args
|
||||
}
|
||||
|
||||
// --- Formatting helpers (raw yt-dlp numbers → human strings) ----------------
|
||||
|
||||
function num(s?: string): number | undefined {
|
||||
@@ -290,10 +150,30 @@ export function startDownload(
|
||||
const template = settings.filenameTemplate?.trim() || '%(title)s.%(ext)s'
|
||||
// Per-download override wins; otherwise use the persisted defaults.
|
||||
const options = opts.options ?? settings.downloadOptions
|
||||
// Silently fall back to yt-dlp's own downloader if aria2c.exe wasn't dropped
|
||||
// into resources/bin — the toggle shouldn't turn into a hard error.
|
||||
const aria2cPath = settings.useAria2c && existsSync(getAria2cPath()) ? getAria2cPath() : undefined
|
||||
// Same idea: 'login' cookies only apply once the sign-in window has actually
|
||||
// exported a file; otherwise the download proceeds cookie-less rather than failing.
|
||||
const cookiesFile =
|
||||
settings.cookieSource === 'login' && existsSync(getCookiesFilePath())
|
||||
? getCookiesFilePath()
|
||||
: undefined
|
||||
const access = {
|
||||
proxy: settings.proxy,
|
||||
rateLimit: settings.rateLimit,
|
||||
aria2cPath,
|
||||
cookiesFromBrowser: settings.cookieSource === 'browser' ? settings.cookiesBrowser : undefined,
|
||||
cookiesFile,
|
||||
restrictFilenames: settings.restrictFilenames,
|
||||
downloadArchivePath: settings.downloadArchive ? getDownloadArchivePath() : undefined
|
||||
}
|
||||
|
||||
let child: ChildProcess
|
||||
try {
|
||||
child = spawn(ytdlp, buildArgs(opts, join(outDir, template), options), { windowsHide: true })
|
||||
child = spawn(ytdlp, buildArgs(opts, join(outDir, template), options, getBinDir(), access), {
|
||||
windowsHide: true
|
||||
})
|
||||
} catch (e) {
|
||||
return { ok: false, error: (e as Error).message }
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { getSettings, setSettings } from './settings'
|
||||
import { listHistory, addHistory, removeHistory, clearHistory } from './history'
|
||||
import { setupPortableData } from './portable'
|
||||
import { safeOpenPath, safeShowInFolder } from './reveal'
|
||||
import { openCookieLoginWindow, getCookiesStatus, clearCookies } from './cookies'
|
||||
|
||||
// Force software compositing (GPU acceleration off). On this dev machine's old GeForce
|
||||
// GT 625 — and plausibly on the old / locked-down public PCs this app targets —
|
||||
@@ -121,6 +122,10 @@ function registerIpcHandlers(): void {
|
||||
ipcMain.handle(IpcChannels.historyAdd, (_e, entry: HistoryEntry) => addHistory(entry))
|
||||
ipcMain.handle(IpcChannels.historyRemove, (_e, id: string) => removeHistory(id))
|
||||
ipcMain.handle(IpcChannels.historyClear, () => clearHistory())
|
||||
|
||||
ipcMain.handle(IpcChannels.cookiesLogin, (_e, url: string) => openCookieLoginWindow(url))
|
||||
ipcMain.handle(IpcChannels.cookiesStatus, () => getCookiesStatus())
|
||||
ipcMain.handle(IpcChannels.cookiesClear, () => clearCookies())
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
|
||||
+29
-2
@@ -1,10 +1,12 @@
|
||||
import { app } from 'electron'
|
||||
import { join } from 'path'
|
||||
import Store from 'electron-store'
|
||||
import {
|
||||
AUDIO_FORMATS,
|
||||
VIDEO_CONTAINERS,
|
||||
VIDEO_CODECS,
|
||||
SPONSORBLOCK_CATEGORIES,
|
||||
COOKIE_BROWSERS,
|
||||
DEFAULT_DOWNLOAD_OPTIONS,
|
||||
type Settings,
|
||||
type DownloadOptions,
|
||||
@@ -20,7 +22,19 @@ const DEFAULTS: Settings = {
|
||||
filenameTemplate: '%(title)s.%(ext)s',
|
||||
theme: 'light',
|
||||
clipboardWatch: true,
|
||||
downloadOptions: DEFAULT_DOWNLOAD_OPTIONS
|
||||
downloadOptions: DEFAULT_DOWNLOAD_OPTIONS,
|
||||
proxy: '',
|
||||
rateLimit: '',
|
||||
useAria2c: false,
|
||||
cookieSource: 'none',
|
||||
cookiesBrowser: 'chrome',
|
||||
restrictFilenames: false,
|
||||
downloadArchive: false
|
||||
}
|
||||
|
||||
/** Fixed path for the --download-archive file; not user-configurable. */
|
||||
export function getDownloadArchivePath(): string {
|
||||
return join(app.getPath('userData'), 'download-archive.txt')
|
||||
}
|
||||
|
||||
// Coerce an untrusted partial into a valid DownloadOptions, falling back to the
|
||||
@@ -98,7 +112,18 @@ export function setSettings(partial: Partial<Settings>): Settings {
|
||||
break
|
||||
}
|
||||
case 'clipboardWatch':
|
||||
if (typeof value === 'boolean') s.set('clipboardWatch', value)
|
||||
case 'useAria2c':
|
||||
case 'restrictFilenames':
|
||||
case 'downloadArchive':
|
||||
if (typeof value === 'boolean') s.set(key, value)
|
||||
break
|
||||
case 'cookieSource':
|
||||
if (value === 'none' || value === 'browser' || value === 'login') s.set(key, value)
|
||||
break
|
||||
case 'cookiesBrowser':
|
||||
if ((COOKIE_BROWSERS as readonly string[]).includes(value as string)) {
|
||||
s.set(key, value as Settings['cookiesBrowser'])
|
||||
}
|
||||
break
|
||||
case 'downloadOptions':
|
||||
// Always store a fully-validated object; the renderer sends the whole
|
||||
@@ -109,6 +134,8 @@ export function setSettings(partial: Partial<Settings>): Settings {
|
||||
case 'defaultVideoQuality':
|
||||
case 'defaultAudioQuality':
|
||||
case 'filenameTemplate':
|
||||
case 'proxy':
|
||||
case 'rateLimit':
|
||||
if (typeof value === 'string') s.set(key, value)
|
||||
break
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user