diff --git a/src/main/core/buildArgs.ts b/src/main/core/buildArgs.ts index e828edc..9fdaa49 100644 --- a/src/main/core/buildArgs.ts +++ b/src/main/core/buildArgs.ts @@ -102,6 +102,12 @@ export interface AccessOptions { proxy: string /** yt-dlp --limit-rate value, e.g. '2M'; empty means unlimited */ rateLimit: string + /** `--sleep-requests` seconds (pause between extraction requests); 0/omitted = off */ + sleepRequests?: number + /** `--sleep-interval` seconds (min pause before each download); 0/omitted = off */ + sleepInterval?: number + /** `--max-sleep-interval` seconds (max of the random pre-download pause); applies only with a non-zero sleepInterval */ + maxSleepInterval?: number /** absolute path to aria2c.exe; omit to use yt-dlp's built-in downloader */ aria2cPath?: string /** aria2c parallel connections (-x/-s), clamped to 1–16; omitted = 16 (L64) */ @@ -323,6 +329,19 @@ 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()) + // Request throttle — the antidote to YouTube's rate-limit/403 wall on big + // channel pulls. `--sleep-requests` spaces out the extraction requests; the + // sleep-interval pair brackets a random pause before each download. yt-dlp + // requires --sleep-interval to be present for --max-sleep-interval to be + // meaningful, and a max below the min is nonsensical, so max is gated on both. + const sleepRequests = a.sleepRequests ?? 0 + if (sleepRequests > 0) args.push('--sleep-requests', String(sleepRequests)) + const sleepInterval = a.sleepInterval ?? 0 + if (sleepInterval > 0) { + args.push('--sleep-interval', String(sleepInterval)) + const maxSleep = a.maxSleepInterval ?? 0 + if (maxSleep > sleepInterval) args.push('--max-sleep-interval', String(maxSleep)) + } if (a.aria2cPath) { args.push('--downloader', a.aria2cPath, '--downloader-args', aria2cArgs(a.aria2cConnections)) } diff --git a/src/main/download.ts b/src/main/download.ts index ba86508..688e3d1 100644 --- a/src/main/download.ts +++ b/src/main/download.ts @@ -222,6 +222,9 @@ export function buildCommand(opts: StartDownloadOptions, cookiesFile?: string): const access = { proxy: settings.proxy, rateLimit: settings.rateLimit, + sleepRequests: settings.sleepRequests, + sleepInterval: settings.sleepInterval, + maxSleepInterval: settings.maxSleepInterval, aria2cPath, aria2cConnections: settings.aria2cConnections, // L136/M6: incognito attaches no cookies from the browser either. diff --git a/src/main/settingsSchema.ts b/src/main/settingsSchema.ts index 1544b3e..873ca5a 100644 --- a/src/main/settingsSchema.ts +++ b/src/main/settingsSchema.ts @@ -94,6 +94,10 @@ export const SETTINGS_FIELD_SCHEMAS: { [K in keyof Settings]: z.ZodType RATE_LIMIT_RE.test(v)), + // Throttle seconds (0–300, 0 = off) to dodge YouTube's rate-limit/403 wall. + sleepRequests: clampedInt(0, 300), + sleepInterval: clampedInt(0, 300), + maxSleepInterval: clampedInt(0, 300), // Power-user field; yt-dlp's client list evolves. Accept any trimmed string. youtubePlayerClient: z.string().transform((v) => v.trim()), // Credential-bearing fields — settings.ts encrypts these AFTER parsing. diff --git a/src/renderer/src/views/settings/NetworkCard.tsx b/src/renderer/src/views/settings/NetworkCard.tsx index 6520d7c..28e533c 100644 --- a/src/renderer/src/views/settings/NetworkCard.tsx +++ b/src/renderer/src/views/settings/NetworkCard.tsx @@ -17,6 +17,9 @@ export function NetworkCard(): React.JSX.Element { const styles = useSettingsStyles() const proxy = useSettings((s) => s.proxy) const rateLimit = useSettings((s) => s.rateLimit) + const sleepRequests = useSettings((s) => s.sleepRequests) + const sleepInterval = useSettings((s) => s.sleepInterval) + const maxSleepInterval = useSettings((s) => s.maxSleepInterval) const useAria2c = useSettings((s) => s.useAria2c) const aria2cConnections = useSettings((s) => s.aria2cConnections) const youtubePlayerClient = useSettings((s) => s.youtubePlayerClient) @@ -56,6 +59,58 @@ export function NetworkCard(): React.JSX.Element { /> + + { + const v = d.value ?? Number(d.displayValue) + if (typeof v === 'number' && Number.isFinite(v)) { + update({ sleepRequests: Math.min(300, Math.max(0, Math.round(v))) }) + } + }} + /> + + + + { + const v = d.value ?? Number(d.displayValue) + if (typeof v === 'number' && Number.isFinite(v)) { + update({ sleepInterval: Math.min(300, Math.max(0, Math.round(v))) }) + } + }} + /> + + + + { + const v = d.value ?? Number(d.displayValue) + if (typeof v === 'number' && Number.isFinite(v)) { + update({ maxSleepInterval: Math.min(300, Math.max(0, Math.round(v))) }) + } + }} + /> + + { }) }) +// --- Request throttle: sleep-requests / sleep-interval ---------------------- + +describe('throttle options', () => { + it('emits no sleep flags when all throttle values are 0/unset', () => { + const argv = build(opts(), dlo(), NO_ACCESS) + expect(argv).not.toContain('--sleep-requests') + expect(argv).not.toContain('--sleep-interval') + expect(argv).not.toContain('--max-sleep-interval') + }) + + it('emits --sleep-requests when sleepRequests > 0', () => { + const argv = build(opts(), dlo(), { ...NO_ACCESS, sleepRequests: 3 }) + expect(hasSeq(argv, '--sleep-requests', '3')).toBe(true) + }) + + it('emits --sleep-interval when sleepInterval > 0', () => { + const argv = build(opts(), dlo(), { ...NO_ACCESS, sleepInterval: 5 }) + expect(hasSeq(argv, '--sleep-interval', '5')).toBe(true) + expect(argv).not.toContain('--max-sleep-interval') + }) + + it('emits --max-sleep-interval only when it exceeds a non-zero sleepInterval', () => { + const argv = build(opts(), dlo(), { ...NO_ACCESS, sleepInterval: 5, maxSleepInterval: 10 }) + expect(hasSeq(argv, '--sleep-interval', '5')).toBe(true) + expect(hasSeq(argv, '--max-sleep-interval', '10')).toBe(true) + }) + + it('drops --max-sleep-interval when sleepInterval is 0 (yt-dlp needs the min)', () => { + const argv = build(opts(), dlo(), { ...NO_ACCESS, sleepInterval: 0, maxSleepInterval: 10 }) + expect(argv).not.toContain('--sleep-interval') + expect(argv).not.toContain('--max-sleep-interval') + }) + + it('drops --max-sleep-interval when it is not above the min', () => { + const argv = build(opts(), dlo(), { ...NO_ACCESS, sleepInterval: 8, maxSleepInterval: 5 }) + expect(hasSeq(argv, '--sleep-interval', '8')).toBe(true) + expect(argv).not.toContain('--max-sleep-interval') + }) +}) + // --- Cookies: browser store vs. sign-in window's exported file -------------- describe('cookies', () => { diff --git a/test/settingsSchema.test.ts b/test/settingsSchema.test.ts index 64529dd..11ffb2d 100644 --- a/test/settingsSchema.test.ts +++ b/test/settingsSchema.test.ts @@ -106,6 +106,14 @@ describe('parseSettingsField (CC9 schema parity)', () => { expect(parseSettingsField('rateLimit', 'fast')).toEqual(fail) }) + it('sleepRequests/sleepInterval/maxSleepInterval: coerce, round, clamp to [0,300]', () => { + expect(parseSettingsField('sleepRequests', 5)).toEqual(ok(5)) + expect(parseSettingsField('sleepRequests', -3)).toEqual(ok(0)) + expect(parseSettingsField('sleepRequests', 999)).toEqual(ok(300)) + expect(parseSettingsField('sleepInterval', '4')).toEqual(ok(4)) + expect(parseSettingsField('maxSleepInterval', 7.6)).toEqual(ok(8)) + }) + it('quality presets: allowlisted', () => { expect(parseSettingsField('defaultVideoQuality', '720p')).toEqual(ok('720p')) expect(parseSettingsField('defaultVideoQuality', '4K')).toEqual(fail)