diff --git a/src/main.ts b/src/main.ts
index 081290a..daaf037 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -19,6 +19,8 @@ import {
import { tBackend as tBackendCore, type BackendMessageKey } from './main/domain/i18n-backend';
import { watchRendererChanges } from './main/dev-reload';
import { createPausableOutput, type PausableOutput } from './main/domain/pausable-output';
+import { createTokenBucketTransform } from './main/domain/token-bucket-transform';
+import { decideDownloadStart, normalizeDownloadPolicy, type DownloadPolicy } from './main/domain/download-policy';
import { PartialDownloadRegistry } from './main/domain/partial-download';
import { QueueProcessRegistry, QueueRunLifecycle, waitForChildProcessExit } from './main/queue/process-registry';
import { openDatabase, type DbHandle } from './main/infra/db';
@@ -182,6 +184,7 @@ interface Config {
streamlink_quality: string;
notify_on_each_completion: boolean;
streamlink_disable_ads: boolean;
+ download_policy: DownloadPolicy;
auto_record_streamers: string[];
auto_record_poll_seconds: number;
download_chat_replay: boolean;
@@ -203,6 +206,12 @@ interface Config {
delete_parts_after_merge: boolean;
}
+interface DownloadPolicyStatus {
+ waiting: boolean;
+ reason: 'outside-window' | null;
+ nextStart: string | null;
+}
+
interface RuntimeMetrics {
cacheHits: number;
cacheMisses: number;
@@ -378,6 +387,7 @@ const defaultConfig: Config = {
streamlink_quality: 'best',
notify_on_each_completion: false,
streamlink_disable_ads: true,
+ download_policy: { throttle: null, windows: [] },
auto_record_streamers: [],
auto_record_poll_seconds: 90,
download_chat_replay: false,
@@ -445,6 +455,7 @@ function normalizeConfigTemplates(input: Config): Config {
// Default-true on first launch (most users hit this), but respect
// an explicit `false` from the loaded config.
streamlink_disable_ads: input.streamlink_disable_ads !== false,
+ download_policy: normalizeDownloadPolicy(input.download_policy),
auto_record_streamers: normalizeAutoRecordList(input.auto_record_streamers),
auto_record_poll_seconds: normalizeAutoRecordPollSeconds(input.auto_record_poll_seconds),
download_chat_replay: input.download_chat_replay === true,
@@ -798,6 +809,8 @@ const activeDownloads = new Map
();
const cancelledItemIds = new Set();
const queueProcessRegistry = new QueueProcessRegistry();
const queueRunLifecycle = new QueueRunLifecycle(queueProcessRegistry);
+let downloadPolicyWakeTimer: NodeJS.Timeout | null = null;
+let lastDownloadPolicyStatusFingerprint = '';
function registerQueuePartialFile(itemId: string, filePath: string): void {
queueProcessRegistry.register(itemId, 'post-processing', {
@@ -1568,6 +1581,8 @@ function getQueueBroadcastFingerprint(queueData: QueueItem[] = downloadQueue): s
}
function emitQueueUpdated(force = false): void {
+ if (!downloadQueue.some((item) => item.status === 'pending')) clearDownloadPolicyWakeTimer();
+ emitDownloadPolicyStatus();
const nextFingerprint = getQueueBroadcastFingerprint(downloadQueue);
if (!force && nextFingerprint === lastQueueBroadcastFingerprint) {
return;
@@ -2350,6 +2365,53 @@ interface PublicStreamerProfileResult {
stream: PublicStreamInfo | null;
}
+function getDownloadPolicyStatus(): DownloadPolicyStatus {
+ const hasPendingItems = downloadQueue.some((item) => item.status === 'pending');
+ const decision = decideDownloadStart(config.download_policy, new Date());
+ const waiting = hasPendingItems && !isDownloading && !queuePaused && !decision.allowed;
+ return {
+ waiting,
+ reason: waiting ? 'outside-window' : null,
+ nextStart: waiting ? decision.nextStart?.toISOString() ?? null : null,
+ };
+}
+
+function emitDownloadPolicyStatus(force = false): void {
+ const status = getDownloadPolicyStatus();
+ const fingerprint = JSON.stringify(status);
+ if (!force && fingerprint === lastDownloadPolicyStatusFingerprint) return;
+ lastDownloadPolicyStatusFingerprint = fingerprint;
+ mainWindow?.webContents.send('download-policy-status', status);
+}
+
+function clearDownloadPolicyWakeTimer(): void {
+ if (!downloadPolicyWakeTimer) return;
+ clearTimeout(downloadPolicyWakeTimer);
+ downloadPolicyWakeTimer = null;
+}
+
+function scheduleDownloadPolicyWake(nextStart: Date | null): void {
+ clearDownloadPolicyWakeTimer();
+ if (!nextStart || appShutdownStarted) return;
+ const delayMs = Math.max(0, nextStart.getTime() - Date.now());
+ downloadPolicyWakeTimer = setTimeout(() => {
+ downloadPolicyWakeTimer = null;
+ scheduleQueueProcessing();
+ }, delayMs);
+}
+
+function canStartDownloadQueue(manualOverride: boolean): boolean {
+ const decision = decideDownloadStart(config.download_policy, new Date(), manualOverride);
+ if (!decision.allowed) {
+ scheduleDownloadPolicyWake(decision.nextStart);
+ emitDownloadPolicyStatus(true);
+ return false;
+ }
+ clearDownloadPolicyWakeTimer();
+ emitDownloadPolicyStatus(true);
+ return true;
+}
+
interface PublicDisplayNameQueryResult {
user: {
login: string;
@@ -3977,7 +4039,12 @@ function downloadVODPart(
resolve({ success: false, error: tBackend('unknownDownloadError') });
return;
}
- const output = createPausableOutput(proc.stdout, outputStream);
+ const maxBytesPerSecond = config.download_policy.throttle?.maxBytesPerSecond;
+ const output = createPausableOutput(
+ proc.stdout,
+ outputStream,
+ maxBytesPerSecond ? createTokenBucketTransform(maxBytesPerSecond) : undefined,
+ );
const outputFinished = output.finished.then(() => null, (error) => error);
const processRegistration = queueProcessRegistry.register(itemId, 'streamlink', {
kill: () => proc.kill(),
@@ -6881,15 +6948,17 @@ async function processOneQueueItem(item: QueueItem): Promise {
}
}
-function scheduleQueueProcessing(): boolean {
+function scheduleQueueProcessing(manualOverride = false): boolean {
if (appShutdownStarted) return false;
- return queueRunLifecycle.schedule(processQueue, (error) => {
+ if (!isDownloading && !canStartDownloadQueue(manualOverride)) return false;
+ return queueRunLifecycle.schedule(() => processQueue(manualOverride), (error) => {
appendDebugLog('queue-run-failed', String(error));
});
}
-async function processQueue(): Promise {
+async function processQueue(manualOverride = false): Promise {
if (appShutdownStarted || isDownloading || !downloadQueue.some((item) => item.status === 'pending')) return;
+ if (!canStartDownloadQueue(manualOverride)) return;
appendDebugLog('queue-start', {
items: downloadQueue.length,
@@ -7019,6 +7088,7 @@ function createWindow(): void {
mainWindow.webContents.on('did-finish-load', () => {
emitQueueUpdated(true);
+ emitDownloadPolicyStatus(true);
if (isDownloading) {
mainWindow?.webContents.send('download-started');
}
@@ -7342,6 +7412,11 @@ function setupAutoUpdater() {
// ==========================================
ipcMain.handle('get-config', () => config);
+ipcMain.handle('get-download-policy-status', (event) => {
+ if (!isTrustedRendererEvent(event)) return getDownloadPolicyStatus();
+ return getDownloadPolicyStatus();
+});
+
ipcMain.handle('get-secret-status', (event) => {
if (!isTrustedRendererEvent(event) || !appSecretStore) {
return { encryptionAvailable: false, clientSecretConfigured: false, discordWebhookConfigured: false };
@@ -7425,6 +7500,7 @@ ipcMain.handle('save-config', (event, newConfig: Partial, fileCapability
const previousAutoVodList = JSON.stringify(config.auto_vod_download_streamers || []);
const previousAutoVodMinutes = config.auto_vod_download_poll_minutes;
const previousStreamerList = JSON.stringify(config.streamers || []);
+ const previousDownloadPolicy = JSON.stringify(config.download_policy);
const acceptedConfig = { ...newConfig };
delete (acceptedConfig as Record).client_secret;
@@ -7439,6 +7515,11 @@ ipcMain.handle('save-config', (event, newConfig: Partial, fileCapability
}
const nextConfig = normalizeConfigTemplates({ ...config, ...acceptedConfig });
config = persistStateChange(config, () => nextConfig, saveConfig);
+ if (JSON.stringify(config.download_policy) !== previousDownloadPolicy && !isDownloading && downloadQueue.some((item) => item.status === 'pending')) {
+ scheduleQueueProcessing();
+ } else {
+ emitDownloadPolicyStatus(true);
+ }
if (config.client_id !== previousClientId) {
accessToken = null;
@@ -7766,7 +7847,7 @@ ipcMain.handle('create-merge-group', (event, itemIds: string[]) => {
return downloadQueue;
});
-ipcMain.handle('start-download', async (event) => {
+ipcMain.handle('start-download', async (event, manualOverride: unknown = false) => {
if (!isTrustedRendererEvent(event)) return false;
if (isDownloading && queuePaused) {
const nextQueue = downloadQueue.map((item) => item.status === 'paused' ? { ...item, status: 'downloading' as const } : item);
@@ -7790,7 +7871,7 @@ ipcMain.handle('start-download', async (event) => {
emitQueueUpdated();
if (!isDownloading) {
- scheduleQueueProcessing();
+ scheduleQueueProcessing(manualOverride === true);
}
return true;
});
@@ -8084,7 +8165,12 @@ registerTrustedIpcHandler(ipcMain, 'download-clip', isTrustedRendererEvent, () =
resolve({ success: false, error: tBackend('unknownDownloadError') });
return;
}
- const output = createPausableOutput(proc.stdout, fs.createWriteStream(partialFilename, { flags: 'w' }));
+ const maxBytesPerSecond = config.download_policy.throttle?.maxBytesPerSecond;
+ const output = createPausableOutput(
+ proc.stdout,
+ fs.createWriteStream(partialFilename, { flags: 'w' }),
+ maxBytesPerSecond ? createTokenBucketTransform(maxBytesPerSecond) : undefined,
+ );
const outputFinished = output.finished.then(() => null, (error) => error);
activeClipProcesses.set(clipId, { process: proc, output, partialFilename });
@@ -8725,6 +8811,7 @@ async function shutdownCleanup(reason: 'window-all-closed' | 'before-quit'): Pro
if (shutdownCleanupDone) return;
shutdownCleanupDone = true;
appShutdownStarted = true;
+ clearDownloadPolicyWakeTimer();
if (queueSaveTimer) {
clearTimeout(queueSaveTimer);
queueSaveTimer = null;
diff --git a/src/main/domain/download-policy-integration.test.ts b/src/main/domain/download-policy-integration.test.ts
new file mode 100644
index 0000000..1c4300d
--- /dev/null
+++ b/src/main/domain/download-policy-integration.test.ts
@@ -0,0 +1,59 @@
+import { readFileSync } from 'node:fs';
+import { join } from 'node:path';
+import { describe, expect, it } from 'vitest';
+import { decideDownloadStart, normalizeDownloadPolicy } from './download-policy';
+
+describe('download policy integration contract', () => {
+ it('keeps a normalized persisted policy after a config-shaped restart payload', () => {
+ const persisted = JSON.parse(JSON.stringify({
+ download_policy: {
+ throttle: { maxBytesPerSecond: 1_572_864 },
+ windows: [{ start: '22:00', end: '06:00' }, { start: '22:00', end: '06:00' }, { start: 'bad', end: '12:00' }]
+ }
+ }));
+
+ expect(normalizeDownloadPolicy(persisted.download_policy)).toEqual({
+ throttle: { maxBytesPerSecond: 1_572_864 },
+ windows: [{ start: '22:00', end: '06:00' }]
+ });
+
+ const mainSource = readFileSync(join(process.cwd(), 'src', 'main.ts'), 'utf8');
+ expect(mainSource).toContain('download_policy: { throttle: null, windows: [] }');
+ expect(mainSource).toContain('download_policy: normalizeDownloadPolicy(input.download_policy)');
+ });
+
+ it('blocks automatic queue starts outside the local window but allows a manual override', () => {
+ const policy = normalizeDownloadPolicy({
+ throttle: { maxBytesPerSecond: 1_048_576 },
+ windows: [{ start: '22:00', end: '06:00' }]
+ });
+ const now = new Date(2026, 0, 13, 13, 0);
+
+ expect(decideDownloadStart(policy, now)).toMatchObject({
+ allowed: false,
+ reason: 'outside-window',
+ nextStart: new Date(2026, 0, 13, 22, 0)
+ });
+ expect(decideDownloadStart(policy, now, true)).toMatchObject({
+ allowed: true,
+ reason: 'manual-override',
+ maxBytesPerSecond: 1_048_576
+ });
+
+ const mainSource = readFileSync(join(process.cwd(), 'src', 'main.ts'), 'utf8');
+ expect(mainSource).toContain('function scheduleQueueProcessing(manualOverride = false)');
+ expect(mainSource).toContain('scheduleQueueProcessing(manualOverride === true)');
+ expect(mainSource).toContain('scheduleDownloadPolicyWake(decision.nextStart)');
+ });
+
+ it('uses the app-side stdout transform and retains the existing Streamlink argument pipeline', () => {
+ const source = readFileSync(join(process.cwd(), 'src', 'main.ts'), 'utf8');
+ const start = source.indexOf('function downloadVODPart(');
+ const end = source.indexOf('const outputFinished = output.finished', start);
+ const section = source.slice(start, end);
+
+ expect(section).toContain('createTokenBucketTransform');
+ expect(section).toContain("const args = [...streamlinkCmd.prefixArgs, url, getStreamlinkStreamArg(), '--stdout'];");
+ expect(section).not.toMatch(/args\.push\([^\n]*(?:bandwidth|rate-limit|max-rate|throttle)/i);
+ });
+});
diff --git a/src/main/domain/pausable-output.test.ts b/src/main/domain/pausable-output.test.ts
index 9b956ce..cd83c27 100644
--- a/src/main/domain/pausable-output.test.ts
+++ b/src/main/domain/pausable-output.test.ts
@@ -1,6 +1,7 @@
import { PassThrough, Writable } from 'stream';
import { describe, expect, it } from 'vitest';
import { createPausableOutput } from './pausable-output';
+import { createTokenBucketTransform } from './token-bucket-transform';
function waitForTurn(): Promise {
return new Promise((resolve) => setImmediate(resolve));
@@ -86,4 +87,23 @@ describe('createPausableOutput', () => {
expect(target.destroyed).toBe(true);
expect(Buffer.concat(chunks).toString()).toBe('behalten');
});
+
+ it('bricht eine wartende app-seitige Drosselung zusammen mit dem Ausgabestrom ab', async () => {
+ const source = new PassThrough();
+ const target = new Writable({
+ write(_chunk, _encoding, callback) {
+ callback();
+ }
+ });
+ const throttle = createTokenBucketTransform(1);
+ const output = createPausableOutput(source, target, throttle);
+
+ source.write('a');
+ source.write('b');
+ await output.cancel();
+
+ expect(source.destroyed).toBe(true);
+ expect(target.destroyed).toBe(true);
+ expect(throttle.destroyed).toBe(true);
+ });
});
diff --git a/src/main/domain/pausable-output.ts b/src/main/domain/pausable-output.ts
index e8b1f94..649cc8d 100644
--- a/src/main/domain/pausable-output.ts
+++ b/src/main/domain/pausable-output.ts
@@ -1,4 +1,4 @@
-import { Readable, Writable } from 'stream';
+import { Readable, Transform, Writable } from 'stream';
export interface PausableOutput {
pause(): void;
@@ -8,7 +8,7 @@ export interface PausableOutput {
finished: Promise;
}
-export function createPausableOutput(source: Readable, target: Writable): PausableOutput {
+export function createPausableOutput(source: Readable, target: Writable, transform?: Transform): PausableOutput {
let paused = false;
let settled = false;
let resolveFinished: () => void = () => {};
@@ -21,7 +21,8 @@ export function createPausableOutput(source: Readable, target: Writable): Pausab
const closed = new Promise((resolve) => {
resolveClosed = resolve;
});
- const attach = () => source.pipe(target, { end: false });
+ const outputSource = transform ? source.pipe(transform) : source;
+ const attach = () => outputSource.pipe(target, { end: false });
const finish = () => {
if (!settled) target.end();
};
@@ -42,15 +43,16 @@ export function createPausableOutput(source: Readable, target: Writable): Pausab
source.destroy(error);
rejectFinished(error);
});
- source.once('end', finish);
+ outputSource.once('end', finish);
source.once('error', (error) => target.destroy(error));
+ if (transform) transform.once('error', (error) => target.destroy(error));
attach();
return {
pause() {
if (paused || settled) return;
paused = true;
- source.unpipe(target);
+ outputSource.unpipe(target);
source.pause();
},
resume() {
@@ -62,8 +64,9 @@ export function createPausableOutput(source: Readable, target: Writable): Pausab
async cancel() {
if (!settled) {
paused = false;
- source.unpipe(target);
+ outputSource.unpipe(target);
source.destroy();
+ transform?.destroy();
target.destroy();
}
await closed;
diff --git a/src/main/domain/token-bucket-transform.test.ts b/src/main/domain/token-bucket-transform.test.ts
new file mode 100644
index 0000000..38d3430
--- /dev/null
+++ b/src/main/domain/token-bucket-transform.test.ts
@@ -0,0 +1,78 @@
+import { PassThrough } from 'node:stream';
+import { describe, expect, it } from 'vitest';
+import { createTokenBucketTransform, type TokenBucketClock } from './token-bucket-transform';
+
+class ManualClock implements TokenBucketClock {
+ private nextTimerId = 0;
+ private readonly timers = new Map void }>();
+ nowMs = 0;
+
+ now(): number {
+ return this.nowMs;
+ }
+
+ setTimeout(callback: () => void, delayMs: number): ReturnType {
+ const id = ++this.nextTimerId;
+ this.timers.set(id, { dueAt: this.nowMs + delayMs, callback });
+ return id as unknown as ReturnType;
+ }
+
+ clearTimeout(handle: ReturnType): void {
+ this.timers.delete(handle as unknown as number);
+ }
+
+ advance(ms: number): void {
+ this.nowMs += ms;
+ while (true) {
+ const due = [...this.timers.entries()]
+ .filter(([, timer]) => timer.dueAt <= this.nowMs)
+ .sort(([, left], [, right]) => left.dueAt - right.dueAt)[0];
+ if (!due) return;
+ this.timers.delete(due[0]);
+ due[1].callback();
+ }
+ }
+
+ get timerCount(): number {
+ return this.timers.size;
+ }
+}
+
+describe('app-side token bucket transform', () => {
+ it('backpressures stdout after its initial bucket without changing the source bytes', () => {
+ const clock = new ManualClock();
+ const transform = createTokenBucketTransform(2, clock);
+ const output: Buffer[] = [];
+ transform.on('data', (chunk: Buffer) => output.push(Buffer.from(chunk)));
+
+ transform.write(Buffer.from('ab'));
+ transform.write(Buffer.from('cd'));
+
+ expect(Buffer.concat(output).toString()).toBe('ab');
+ expect(clock.timerCount).toBe(1);
+
+ clock.advance(999);
+ expect(Buffer.concat(output).toString()).toBe('ab');
+
+ clock.advance(1);
+ expect(Buffer.concat(output).toString()).toBe('abcd');
+ });
+
+ it('cancels a pending throttle timer when the output stream is destroyed', () => {
+ const clock = new ManualClock();
+ const source = new PassThrough();
+ const transform = createTokenBucketTransform(1, clock);
+ const output: Buffer[] = [];
+ source.pipe(transform).on('data', (chunk: Buffer) => output.push(Buffer.from(chunk)));
+
+ source.write(Buffer.from('a'));
+ source.write(Buffer.from('b'));
+ expect(clock.timerCount).toBe(1);
+
+ transform.destroy();
+ clock.advance(10_000);
+
+ expect(clock.timerCount).toBe(0);
+ expect(Buffer.concat(output).toString()).toBe('a');
+ });
+});
diff --git a/src/main/domain/token-bucket-transform.ts b/src/main/domain/token-bucket-transform.ts
new file mode 100644
index 0000000..201687f
--- /dev/null
+++ b/src/main/domain/token-bucket-transform.ts
@@ -0,0 +1,58 @@
+import { Transform } from 'node:stream';
+
+export interface TokenBucketClock {
+ now(): number;
+ setTimeout(callback: () => void, delayMs: number): ReturnType;
+ clearTimeout(handle: ReturnType): void;
+}
+
+const systemClock: TokenBucketClock = {
+ now: () => Date.now(),
+ setTimeout: (callback, delayMs) => setTimeout(callback, delayMs),
+ clearTimeout: (handle) => clearTimeout(handle),
+};
+
+class TokenBucketTransform extends Transform {
+ private availableBytes: number;
+ private lastRefillAt: number;
+ private timer: ReturnType | null = null;
+
+ constructor(private readonly maxBytesPerSecond: number, private readonly clock: TokenBucketClock) {
+ super();
+ this.availableBytes = maxBytesPerSecond;
+ this.lastRefillAt = clock.now();
+ }
+
+ override _transform(chunk: Buffer, _encoding: BufferEncoding, callback: (error?: Error | null) => void): void {
+ const output = Buffer.from(chunk);
+ const capacity = Math.max(this.maxBytesPerSecond, output.length);
+ const release = (): void => {
+ this.timer = null;
+ if (this.destroyed) return;
+ const now = this.clock.now();
+ const elapsed = Math.max(0, now - this.lastRefillAt);
+ this.availableBytes = Math.min(capacity, this.availableBytes + (elapsed * this.maxBytesPerSecond) / 1000);
+ this.lastRefillAt = now;
+ if (this.availableBytes >= output.length) {
+ this.availableBytes -= output.length;
+ this.push(output);
+ callback();
+ return;
+ }
+ const delayMs = Math.max(1, Math.ceil(((output.length - this.availableBytes) * 1000) / this.maxBytesPerSecond));
+ this.timer = this.clock.setTimeout(release, delayMs);
+ };
+ release();
+ }
+
+ override _destroy(error: Error | null, callback: (error: Error | null) => void): void {
+ if (this.timer) this.clock.clearTimeout(this.timer);
+ this.timer = null;
+ callback(error);
+ }
+}
+
+export function createTokenBucketTransform(maxBytesPerSecond: number, clock: TokenBucketClock = systemClock): Transform {
+ if (!Number.isSafeInteger(maxBytesPerSecond) || maxBytesPerSecond <= 0) throw new RangeError('maxBytesPerSecond must be a positive safe integer');
+ return new TokenBucketTransform(maxBytesPerSecond, clock);
+}
diff --git a/src/preload.ts b/src/preload.ts
index efa6cbf..dfc73a2 100644
--- a/src/preload.ts
+++ b/src/preload.ts
@@ -103,6 +103,7 @@ interface FileCapabilityReference {
contextBridge.exposeInMainWorld('api', {
// Config
getConfig: () => ipcRenderer.invoke('get-config'),
+ getDownloadPolicyStatus: () => ipcRenderer.invoke('get-download-policy-status'),
saveConfig: (config: any, fileCapability?: string) => ipcRenderer.invoke('save-config', config, fileCapability),
getSecretStatus: () => ipcRenderer.invoke('get-secret-status'),
setClientSecret: (value: string) => ipcRenderer.invoke('set-client-secret', value),
@@ -129,7 +130,7 @@ contextBridge.exposeInMainWorld('api', {
createMergeGroup: (itemIds: string[]) => ipcRenderer.invoke('create-merge-group', itemIds),
// Download
- startDownload: () => ipcRenderer.invoke('start-download'),
+ startDownload: (manualOverride: boolean = true) => ipcRenderer.invoke('start-download', manualOverride),
pauseDownload: () => ipcRenderer.invoke('pause-download'),
cancelDownload: () => ipcRenderer.invoke('cancel-download'),
isDownloading: () => ipcRenderer.invoke('is-downloading'),
@@ -249,6 +250,9 @@ contextBridge.exposeInMainWorld('api', {
onDownloadFinished: (callback: () => void) => {
ipcRenderer.on('download-finished', () => callback());
},
+ onDownloadPolicyStatus: (callback: (status: DownloadPolicyStatus) => void) => {
+ ipcRenderer.on('download-policy-status', (_, status) => callback(status));
+ },
onCutProgress: (callback: (percent: number) => void) => {
ipcRenderer.on('cut-progress', (_, percent) => callback(percent));
},
diff --git a/src/renderer-globals.d.ts b/src/renderer-globals.d.ts
index d720111..7087177 100644
--- a/src/renderer-globals.d.ts
+++ b/src/renderer-globals.d.ts
@@ -22,6 +22,7 @@ interface AppConfig {
streamlink_quality?: string;
notify_on_each_completion?: boolean;
streamlink_disable_ads?: boolean;
+ download_policy?: DownloadPolicy;
auto_record_streamers?: string[];
auto_record_poll_seconds?: number;
download_chat_replay?: boolean;
@@ -172,6 +173,17 @@ interface VideoInfo {
audioStreams: Array<{ index: number; codec: string; channels: number; language: string | null }>;
}
+interface DownloadPolicy {
+ throttle: { maxBytesPerSecond: number } | null;
+ windows: Array<{ start: string; end: string }>;
+}
+
+interface DownloadPolicyStatus {
+ waiting: boolean;
+ reason: 'outside-window' | null;
+ nextStart: string | null;
+}
+
interface SecretStatus {
encryptionAvailable: boolean;
clientSecretConfigured: boolean;
@@ -409,6 +421,7 @@ interface ArchiveStats {
interface ApiBridge {
getConfig(): Promise;
+ getDownloadPolicyStatus(): Promise;
saveConfig(config: Partial, fileCapability?: string): Promise;
getSecretStatus(): Promise;
setClientSecret(value: string): Promise;
@@ -427,7 +440,7 @@ interface ApiBridge {
retryFailedDownloads(): Promise;
retryQueueItem(id: string): Promise;
createMergeGroup(itemIds: string[]): Promise;
- startDownload(): Promise;
+ startDownload(manualOverride?: boolean): Promise;
pauseDownload(): Promise;
cancelDownload(): Promise;
isDownloading(): Promise;
@@ -504,6 +517,7 @@ interface ApiBridge {
onDownloadStarted(callback: () => void): void;
onDownloadPaused(callback: () => void): void;
onDownloadFinished(callback: () => void): void;
+ onDownloadPolicyStatus(callback: (status: DownloadPolicyStatus) => void): void;
onCutProgress(callback: (percent: number) => void): void;
onMergeProgress(callback: (percent: number) => void): void;
onUpdateChecking(callback: () => void): void;
diff --git a/src/renderer-locale-de.ts b/src/renderer-locale-de.ts
index e770f12..2415461 100644
--- a/src/renderer-locale-de.ts
+++ b/src/renderer-locale-de.ts
@@ -218,6 +218,16 @@ const UI_TEXT_DE = {
streamlinkQualityBest: 'Best (Standard)',
streamlinkQualitySource: 'Source (Original)',
streamlinkQualityAudio: 'Nur Audio',
+ downloadPolicyTitle: 'Drosselung und Zeitfenster',
+ downloadThrottleLabel: 'Maximale Downloadrate (MiB/s)',
+ downloadThrottleHint: 'Leer lassen für keine Drosselung. Dezimalwerte wie 1,5 sind möglich.',
+ downloadWindowsLabel: 'Lokale Download-Zeitfenster',
+ downloadWindowsHint: 'Ein Zeitfenster pro Zeile oder durch Semikolon getrennt. Über Mitternacht ist erlaubt.',
+ downloadPolicyOverride: 'Jetzt starten',
+ downloadPolicyWaiting: 'Die Queue wartet bis {time}.',
+ downloadPolicyReady: 'Ausstehende Downloads dürfen jetzt starten.',
+ downloadThrottleInvalid: 'Gib eine positive Downloadrate in MiB/s ein.',
+ downloadWindowsInvalid: 'Nutze lokale Zeitfenster im Format HH:MM-HH:MM.',
downloadPathNotWritable: 'Download-Ordner ist nicht beschreibbar. Wähle einen anderen Ordner oder prüfe die Schreibrechte.',
streamerSectionTitle: 'Streamer',
streamerListFilterPlaceholder: 'Filtern...',
diff --git a/src/renderer-locale-en.ts b/src/renderer-locale-en.ts
index 6f78cc3..b57be0e 100644
--- a/src/renderer-locale-en.ts
+++ b/src/renderer-locale-en.ts
@@ -218,6 +218,16 @@ const UI_TEXT_EN = {
streamlinkQualityBest: 'Best (default)',
streamlinkQualitySource: 'Source (original)',
streamlinkQualityAudio: 'Audio only',
+ downloadPolicyTitle: 'Throttle and schedule',
+ downloadThrottleLabel: 'Maximum download rate (MiB/s)',
+ downloadThrottleHint: 'Leave empty for no throttle. Decimal values such as 1.5 are supported.',
+ downloadWindowsLabel: 'Local download windows',
+ downloadWindowsHint: 'Use one window per line or separate them with semicolons. Overnight windows are supported.',
+ downloadPolicyOverride: 'Start now',
+ downloadPolicyWaiting: 'Queue is waiting until {time}.',
+ downloadPolicyReady: 'Queued downloads may start now.',
+ downloadThrottleInvalid: 'Enter a positive download rate in MiB/s.',
+ downloadWindowsInvalid: 'Use local windows in HH:MM-HH:MM format.',
downloadPathNotWritable: 'Download folder is not writable. Pick another folder or grant write permission.',
streamerSectionTitle: 'Streamer',
streamerListFilterPlaceholder: 'Filter...',
diff --git a/src/renderer-settings-download-policy.test.ts b/src/renderer-settings-download-policy.test.ts
new file mode 100644
index 0000000..766f519
--- /dev/null
+++ b/src/renderer-settings-download-policy.test.ts
@@ -0,0 +1,40 @@
+import { readFileSync } from 'node:fs';
+import { join } from 'node:path';
+import { runInNewContext } from 'node:vm';
+import { ModuleKind, ScriptTarget, transpileModule } from 'typescript';
+import { describe, expect, it } from 'vitest';
+
+function readParser(): (rate: string, windows: string) => { value: unknown; error: string | null } {
+ const source = readFileSync(join(process.cwd(), 'src', 'renderer-settings.ts'), 'utf8');
+ const start = source.indexOf('function parseDownloadPolicyFormValue');
+ const end = source.indexOf('function updateDownloadPolicyValidation', start);
+ if (start < 0 || end < 0) throw new Error('Download policy form parser is unavailable');
+ const compiled = transpileModule(`${source.slice(start, end)}\nglobalThis.__parseDownloadPolicyFormValue = parseDownloadPolicyFormValue;`, {
+ compilerOptions: { target: ScriptTarget.ES2022, module: ModuleKind.None },
+ }).outputText;
+ const context: Record = {};
+ runInNewContext(compiled, context);
+ return context.__parseDownloadPolicyFormValue as (rate: string, windows: string) => { value: unknown; error: string | null };
+}
+
+describe('download policy settings input', () => {
+ it('converts a human-friendly MiB/s value into a whole safe byte rate and accepts flexible local windows', () => {
+ const parse = readParser();
+
+ expect(parse('1,5', '22:00-06:00; 09:30 - 12:00')).toEqual({
+ value: {
+ throttle: { maxBytesPerSecond: 1_572_864 },
+ windows: [{ start: '22:00', end: '06:00' }, { start: '09:30', end: '12:00' }]
+ },
+ error: null
+ });
+ });
+
+ it('rejects invalid byte-rate and local-window values without producing a persistence payload', () => {
+ const parse = readParser();
+
+ expect(parse('0', '22:00-06:00')).toEqual({ value: null, error: 'rate' });
+ expect(parse('1.25', '22:00-22:00')).toEqual({ value: null, error: 'window' });
+ expect(parse('999999999999999999999', '')).toEqual({ value: null, error: 'rate' });
+ });
+});
diff --git a/src/renderer-settings.ts b/src/renderer-settings.ts
index e866419..0b303bc 100644
--- a/src/renderer-settings.ts
+++ b/src/renderer-settings.ts
@@ -694,7 +694,75 @@ function syncPartMinutesFieldState(): void {
label.classList.toggle('input-disabled', !isSplitMode);
}
+function parseDownloadPolicyFormValue(rateValue: string, windowsValue: string): { value: DownloadPolicy | null; error: string | null } {
+ const rate = rateValue.trim();
+ let throttle: DownloadPolicy['throttle'] = null;
+ if (rate) {
+ const numberParts = rate.replace(',', '.').split('.');
+ if (numberParts.length > 2 || numberParts.some((part) => !part || [...part].some((character) => character < '0' || character > '9'))) {
+ return { value: null, error: 'rate' };
+ }
+ const maxBytesPerSecond = Math.round(Number(numberParts.join('.')) * 1024 * 1024);
+ if (!Number.isSafeInteger(maxBytesPerSecond) || maxBytesPerSecond <= 0) return { value: null, error: 'rate' };
+ throttle = { maxBytesPerSecond };
+ }
+ const windows: DownloadPolicy['windows'] = [];
+ const seen = new Set();
+ for (const value of windowsValue.split(/[;,\n]+/).map((entry) => entry.trim()).filter(Boolean)) {
+ const match = /^(\d{2}:\d{2})\s*[-–]\s*(\d{2}:\d{2})$/.exec(value);
+ if (!match) return { value: null, error: 'window' };
+ const [startHour, startMinute] = match[1].split(':').map(Number);
+ const [endHour, endMinute] = match[2].split(':').map(Number);
+ if (startHour > 23 || startMinute > 59 || endHour > 23 || endMinute > 59 || match[1] === match[2]) {
+ return { value: null, error: 'window' };
+ }
+ const key = `${match[1]}-${match[2]}`;
+ if (!seen.has(key)) {
+ seen.add(key);
+ windows.push({ start: match[1], end: match[2] });
+ }
+ }
+ return { value: { throttle, windows }, error: null };
+}
+
+function updateDownloadPolicyValidation(error: string | null): void {
+ const status = byId('downloadPolicyValidation');
+ status.textContent = error === 'rate'
+ ? UI_TEXT.static.downloadThrottleInvalid
+ : error === 'window'
+ ? UI_TEXT.static.downloadWindowsInvalid
+ : '';
+}
+
+function formatDownloadThrottle(maxBytesPerSecond: number | null | undefined): string {
+ if (!maxBytesPerSecond) return '';
+ return (maxBytesPerSecond / (1024 * 1024)).toFixed(6).replace(/0+$/, '').replace(/\.$/, '');
+}
+
+function renderDownloadPolicyStatus(status: DownloadPolicyStatus): void {
+ const node = byId('downloadPolicyStatus');
+ if (status.waiting && status.nextStart) {
+ node.textContent = UI_TEXT.static.downloadPolicyWaiting.replace('{time}', formatUiDateTime(status.nextStart));
+ return;
+ }
+ node.textContent = UI_TEXT.static.downloadPolicyReady;
+}
+
+async function refreshDownloadPolicyStatus(): Promise {
+ renderDownloadPolicyStatus(await window.api.getDownloadPolicyStatus());
+}
+
+async function startDownloadPolicyOverride(): Promise {
+ await window.api.startDownload(true);
+ await refreshDownloadPolicyStatus();
+}
+
function collectDownloadSettingsPayload(): Partial {
+ const parsedPolicy = parseDownloadPolicyFormValue(
+ byId('downloadThrottleMiBps').value,
+ byId('downloadWindows').value,
+ );
+ updateDownloadPolicyValidation(parsedPolicy.error);
return {
sidebar_split_view: byId('sidebarSplitViewToggle').checked,
download_mode: byId('downloadMode').value as 'parts' | 'full',
@@ -724,7 +792,8 @@ function collectDownloadSettingsPayload(): Partial {
auto_cleanup_target: byId('autoCleanupTarget').value === 'all' ? 'all' : 'live_only',
auto_cleanup_action: byId('autoCleanupAction').value === 'delete' ? 'delete' : 'archive',
streamlink_quality: byId('streamlinkQuality').value,
- metadata_cache_minutes: parseInt(byId('metadataCacheMinutes').value, 10) || 10
+ metadata_cache_minutes: parseInt(byId('metadataCacheMinutes').value, 10) || 10,
+ download_policy: parsedPolicy.value ?? config.download_policy ?? { throttle: null, windows: [] }
};
}
@@ -811,6 +880,9 @@ function syncSettingsFormFromConfig(syncSecrets = true): void {
byId('autoResumeQueueToggle').checked = (config.auto_resume_queue_on_startup as boolean) === true;
byId('notifyEachCompletionToggle').checked = (config.notify_on_each_completion as boolean) === true;
byId('streamlinkDisableAdsToggle').checked = (config.streamlink_disable_ads as boolean) !== false;
+ byId('downloadThrottleMiBps').value = formatDownloadThrottle(config.download_policy?.throttle?.maxBytesPerSecond);
+ byId('downloadWindows').value = (config.download_policy?.windows ?? []).map((window) => `${window.start}-${window.end}`).join('\n');
+ updateDownloadPolicyValidation(null);
byId('downloadChatReplayToggle').checked = (config.download_chat_replay as boolean) === true;
byId('captureLiveChatToggle').checked = (config.capture_live_chat as boolean) === true;
byId('logStreamEventsToggle').checked = (config.log_stream_events as boolean) !== false;
@@ -833,6 +905,7 @@ function syncSettingsFormFromConfig(syncSecrets = true): void {
byId('partsFilenameTemplate').value = (config.filename_template_parts as string) || '{date}_Part{part_padded}.mp4';
byId('defaultClipFilenameTemplate').value = (config.filename_template_clip as string) || '{date}_{part}.mp4';
syncPartMinutesFieldState();
+ void refreshDownloadPolicyStatus();
validateFilenameTemplates();
lastPersistedSettingsFingerprint = getSettingsFingerprint({});
}
@@ -938,6 +1011,7 @@ function initSettingsAutoSave(): void {
settingsAutoSaveBound = true;
syncSettingsFormFromConfig();
+ window.api.onDownloadPolicyStatus(renderDownloadPolicyStatus);
const immediateSaveIds = [
'downloadMode',
@@ -969,7 +1043,9 @@ function initSettingsAutoSave(): void {
'partsFilenameTemplate',
'defaultClipFilenameTemplate',
'discordWebhookUrl',
- 'autoCleanupDays'
+ 'autoCleanupDays',
+ 'downloadThrottleMiBps',
+ 'downloadWindows'
] as const;
const credentialIds = [
diff --git a/src/renderer-texts.ts b/src/renderer-texts.ts
index 8252b9b..3369b05 100644
--- a/src/renderer-texts.ts
+++ b/src/renderer-texts.ts
@@ -280,6 +280,12 @@ function applyLanguageToStaticUI(): void {
setText('streamlinkQualityBest', UI_TEXT.static.streamlinkQualityBest);
setText('streamlinkQualitySource', UI_TEXT.static.streamlinkQualitySource);
setText('streamlinkQualityAudio', UI_TEXT.static.streamlinkQualityAudio);
+ setText('downloadPolicyTitle', UI_TEXT.static.downloadPolicyTitle);
+ setText('downloadThrottleLabel', UI_TEXT.static.downloadThrottleLabel);
+ setText('downloadThrottleHint', UI_TEXT.static.downloadThrottleHint);
+ setText('downloadWindowsLabel', UI_TEXT.static.downloadWindowsLabel);
+ setText('downloadWindowsHint', UI_TEXT.static.downloadWindowsHint);
+ setText('downloadPolicyOverrideBtn', UI_TEXT.static.downloadPolicyOverride);
setText('streamerSectionTitleText', UI_TEXT.static.streamerSectionTitle);
setPlaceholder('streamerListFilter', UI_TEXT.static.streamerListFilterPlaceholder);
setAriaLabel('streamerListFilter', UI_TEXT.static.streamerListFilterAria);
diff --git a/src/workspace.css b/src/workspace.css
index c21e2e1..fbc4aa8 100644
--- a/src/workspace.css
+++ b/src/workspace.css
@@ -3598,6 +3598,18 @@ input[type="checkbox"].vod-select-checkbox:focus-visible {
margin-top: 9px;
}
+#settingsTab .download-policy-settings textarea {
+ min-height: 72px;
+}
+
+#settingsTab .download-policy-settings #downloadPolicyValidation:empty {
+ display: none;
+}
+
+#settingsTab .download-policy-settings #downloadPolicyStatus {
+ margin: 8px 0 12px;
+}
+
.sidebar-layout-setting {
margin-top: 18px;
padding-top: 16px;