From c44314ea2cf251876ad77d056dded6184c6a4879 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe <259325684+Sucukdeluxe@users.noreply.github.com> Date: Wed, 12 Aug 2026 02:43:06 +0200 Subject: [PATCH] feat(downloads): apply bandwidth and schedule policy --- src/index.html | 16 +++ src/main.ts | 101 ++++++++++++++++-- .../download-policy-integration.test.ts | 59 ++++++++++ src/main/domain/pausable-output.test.ts | 20 ++++ src/main/domain/pausable-output.ts | 15 +-- .../domain/token-bucket-transform.test.ts | 78 ++++++++++++++ src/main/domain/token-bucket-transform.ts | 58 ++++++++++ src/preload.ts | 6 +- src/renderer-globals.d.ts | 16 ++- src/renderer-locale-de.ts | 10 ++ src/renderer-locale-en.ts | 10 ++ src/renderer-settings-download-policy.test.ts | 40 +++++++ src/renderer-settings.ts | 80 +++++++++++++- src/renderer-texts.ts | 6 ++ src/workspace.css | 12 +++ 15 files changed, 510 insertions(+), 17 deletions(-) create mode 100644 src/main/domain/download-policy-integration.test.ts create mode 100644 src/main/domain/token-bucket-transform.test.ts create mode 100644 src/main/domain/token-bucket-transform.ts create mode 100644 src/renderer-settings-download-policy.test.ts diff --git a/src/index.html b/src/index.html index 90bcfea..dd0965f 100644 --- a/src/index.html +++ b/src/index.html @@ -902,6 +902,22 @@ +
+

Drosselung und Zeitfenster

+
+ + +
Leer lassen für keine Drosselung. Dezimalwerte wie 1,5 sind möglich.
+
+
+ + +
Ein Zeitfenster pro Zeile oder durch Semikolon getrennt. Über Mitternacht ist erlaubt.
+
+ +
+ +

Aufnahmen und Metadaten

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;