feat(downloads): apply bandwidth and schedule policy
This commit is contained in:
@@ -902,6 +902,22 @@
|
|||||||
<label class="toggle-row"><input type="checkbox" id="notifyEachCompletionToggle"><span id="notifyEachCompletionLabel">Benachrichtigung bei jedem fertigen Download</span></label>
|
<label class="toggle-row"><input type="checkbox" id="notifyEachCompletionToggle"><span id="notifyEachCompletionLabel">Benachrichtigung bei jedem fertigen Download</span></label>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<section class="download-settings-section download-policy-settings">
|
||||||
|
<h4 id="downloadPolicyTitle">Drosselung und Zeitfenster</h4>
|
||||||
|
<div class="form-group">
|
||||||
|
<label id="downloadThrottleLabel" for="downloadThrottleMiBps">Maximale Downloadrate (MiB/s)</label>
|
||||||
|
<input type="text" id="downloadThrottleMiBps" inputmode="decimal" autocomplete="off" aria-describedby="downloadThrottleHint downloadPolicyValidation">
|
||||||
|
<div id="downloadThrottleHint" class="form-note">Leer lassen für keine Drosselung. Dezimalwerte wie 1,5 sind möglich.</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label id="downloadWindowsLabel" for="downloadWindows">Lokale Download-Zeitfenster</label>
|
||||||
|
<textarea id="downloadWindows" rows="3" aria-describedby="downloadWindowsHint downloadPolicyValidation" placeholder="22:00-06:00"></textarea>
|
||||||
|
<div id="downloadWindowsHint" class="form-note">Ein Zeitfenster pro Zeile oder durch Semikolon getrennt. Über Mitternacht ist erlaubt.</div>
|
||||||
|
</div>
|
||||||
|
<div id="downloadPolicyValidation" class="form-note" role="alert"></div>
|
||||||
|
<div id="downloadPolicyStatus" class="form-note" role="status" aria-live="polite"></div>
|
||||||
|
<button type="button" class="btn-secondary" id="downloadPolicyOverrideBtn" onclick="startDownloadPolicyOverride()">Jetzt starten</button>
|
||||||
|
</section>
|
||||||
<section class="download-settings-section">
|
<section class="download-settings-section">
|
||||||
<h4 id="recordingMetadataTitle">Aufnahmen und Metadaten</h4>
|
<h4 id="recordingMetadataTitle">Aufnahmen und Metadaten</h4>
|
||||||
<div class="download-settings-toggles">
|
<div class="download-settings-toggles">
|
||||||
|
|||||||
+94
-7
@@ -19,6 +19,8 @@ import {
|
|||||||
import { tBackend as tBackendCore, type BackendMessageKey } from './main/domain/i18n-backend';
|
import { tBackend as tBackendCore, type BackendMessageKey } from './main/domain/i18n-backend';
|
||||||
import { watchRendererChanges } from './main/dev-reload';
|
import { watchRendererChanges } from './main/dev-reload';
|
||||||
import { createPausableOutput, type PausableOutput } from './main/domain/pausable-output';
|
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 { PartialDownloadRegistry } from './main/domain/partial-download';
|
||||||
import { QueueProcessRegistry, QueueRunLifecycle, waitForChildProcessExit } from './main/queue/process-registry';
|
import { QueueProcessRegistry, QueueRunLifecycle, waitForChildProcessExit } from './main/queue/process-registry';
|
||||||
import { openDatabase, type DbHandle } from './main/infra/db';
|
import { openDatabase, type DbHandle } from './main/infra/db';
|
||||||
@@ -182,6 +184,7 @@ interface Config {
|
|||||||
streamlink_quality: string;
|
streamlink_quality: string;
|
||||||
notify_on_each_completion: boolean;
|
notify_on_each_completion: boolean;
|
||||||
streamlink_disable_ads: boolean;
|
streamlink_disable_ads: boolean;
|
||||||
|
download_policy: DownloadPolicy;
|
||||||
auto_record_streamers: string[];
|
auto_record_streamers: string[];
|
||||||
auto_record_poll_seconds: number;
|
auto_record_poll_seconds: number;
|
||||||
download_chat_replay: boolean;
|
download_chat_replay: boolean;
|
||||||
@@ -203,6 +206,12 @@ interface Config {
|
|||||||
delete_parts_after_merge: boolean;
|
delete_parts_after_merge: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DownloadPolicyStatus {
|
||||||
|
waiting: boolean;
|
||||||
|
reason: 'outside-window' | null;
|
||||||
|
nextStart: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
interface RuntimeMetrics {
|
interface RuntimeMetrics {
|
||||||
cacheHits: number;
|
cacheHits: number;
|
||||||
cacheMisses: number;
|
cacheMisses: number;
|
||||||
@@ -378,6 +387,7 @@ const defaultConfig: Config = {
|
|||||||
streamlink_quality: 'best',
|
streamlink_quality: 'best',
|
||||||
notify_on_each_completion: false,
|
notify_on_each_completion: false,
|
||||||
streamlink_disable_ads: true,
|
streamlink_disable_ads: true,
|
||||||
|
download_policy: { throttle: null, windows: [] },
|
||||||
auto_record_streamers: [],
|
auto_record_streamers: [],
|
||||||
auto_record_poll_seconds: 90,
|
auto_record_poll_seconds: 90,
|
||||||
download_chat_replay: false,
|
download_chat_replay: false,
|
||||||
@@ -445,6 +455,7 @@ function normalizeConfigTemplates(input: Config): Config {
|
|||||||
// Default-true on first launch (most users hit this), but respect
|
// Default-true on first launch (most users hit this), but respect
|
||||||
// an explicit `false` from the loaded config.
|
// an explicit `false` from the loaded config.
|
||||||
streamlink_disable_ads: input.streamlink_disable_ads !== false,
|
streamlink_disable_ads: input.streamlink_disable_ads !== false,
|
||||||
|
download_policy: normalizeDownloadPolicy(input.download_policy),
|
||||||
auto_record_streamers: normalizeAutoRecordList(input.auto_record_streamers),
|
auto_record_streamers: normalizeAutoRecordList(input.auto_record_streamers),
|
||||||
auto_record_poll_seconds: normalizeAutoRecordPollSeconds(input.auto_record_poll_seconds),
|
auto_record_poll_seconds: normalizeAutoRecordPollSeconds(input.auto_record_poll_seconds),
|
||||||
download_chat_replay: input.download_chat_replay === true,
|
download_chat_replay: input.download_chat_replay === true,
|
||||||
@@ -798,6 +809,8 @@ const activeDownloads = new Map<string, ActiveDownloadTracking>();
|
|||||||
const cancelledItemIds = new Set<string>();
|
const cancelledItemIds = new Set<string>();
|
||||||
const queueProcessRegistry = new QueueProcessRegistry();
|
const queueProcessRegistry = new QueueProcessRegistry();
|
||||||
const queueRunLifecycle = new QueueRunLifecycle(queueProcessRegistry);
|
const queueRunLifecycle = new QueueRunLifecycle(queueProcessRegistry);
|
||||||
|
let downloadPolicyWakeTimer: NodeJS.Timeout | null = null;
|
||||||
|
let lastDownloadPolicyStatusFingerprint = '';
|
||||||
|
|
||||||
function registerQueuePartialFile(itemId: string, filePath: string): void {
|
function registerQueuePartialFile(itemId: string, filePath: string): void {
|
||||||
queueProcessRegistry.register(itemId, 'post-processing', {
|
queueProcessRegistry.register(itemId, 'post-processing', {
|
||||||
@@ -1568,6 +1581,8 @@ function getQueueBroadcastFingerprint(queueData: QueueItem[] = downloadQueue): s
|
|||||||
}
|
}
|
||||||
|
|
||||||
function emitQueueUpdated(force = false): void {
|
function emitQueueUpdated(force = false): void {
|
||||||
|
if (!downloadQueue.some((item) => item.status === 'pending')) clearDownloadPolicyWakeTimer();
|
||||||
|
emitDownloadPolicyStatus();
|
||||||
const nextFingerprint = getQueueBroadcastFingerprint(downloadQueue);
|
const nextFingerprint = getQueueBroadcastFingerprint(downloadQueue);
|
||||||
if (!force && nextFingerprint === lastQueueBroadcastFingerprint) {
|
if (!force && nextFingerprint === lastQueueBroadcastFingerprint) {
|
||||||
return;
|
return;
|
||||||
@@ -2350,6 +2365,53 @@ interface PublicStreamerProfileResult {
|
|||||||
stream: PublicStreamInfo | null;
|
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 {
|
interface PublicDisplayNameQueryResult {
|
||||||
user: {
|
user: {
|
||||||
login: string;
|
login: string;
|
||||||
@@ -3977,7 +4039,12 @@ function downloadVODPart(
|
|||||||
resolve({ success: false, error: tBackend('unknownDownloadError') });
|
resolve({ success: false, error: tBackend('unknownDownloadError') });
|
||||||
return;
|
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 outputFinished = output.finished.then(() => null, (error) => error);
|
||||||
const processRegistration = queueProcessRegistry.register(itemId, 'streamlink', {
|
const processRegistration = queueProcessRegistry.register(itemId, 'streamlink', {
|
||||||
kill: () => proc.kill(),
|
kill: () => proc.kill(),
|
||||||
@@ -6881,15 +6948,17 @@ async function processOneQueueItem(item: QueueItem): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleQueueProcessing(): boolean {
|
function scheduleQueueProcessing(manualOverride = false): boolean {
|
||||||
if (appShutdownStarted) return false;
|
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));
|
appendDebugLog('queue-run-failed', String(error));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function processQueue(): Promise<void> {
|
async function processQueue(manualOverride = false): Promise<void> {
|
||||||
if (appShutdownStarted || isDownloading || !downloadQueue.some((item) => item.status === 'pending')) return;
|
if (appShutdownStarted || isDownloading || !downloadQueue.some((item) => item.status === 'pending')) return;
|
||||||
|
if (!canStartDownloadQueue(manualOverride)) return;
|
||||||
|
|
||||||
appendDebugLog('queue-start', {
|
appendDebugLog('queue-start', {
|
||||||
items: downloadQueue.length,
|
items: downloadQueue.length,
|
||||||
@@ -7019,6 +7088,7 @@ function createWindow(): void {
|
|||||||
|
|
||||||
mainWindow.webContents.on('did-finish-load', () => {
|
mainWindow.webContents.on('did-finish-load', () => {
|
||||||
emitQueueUpdated(true);
|
emitQueueUpdated(true);
|
||||||
|
emitDownloadPolicyStatus(true);
|
||||||
if (isDownloading) {
|
if (isDownloading) {
|
||||||
mainWindow?.webContents.send('download-started');
|
mainWindow?.webContents.send('download-started');
|
||||||
}
|
}
|
||||||
@@ -7342,6 +7412,11 @@ function setupAutoUpdater() {
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
ipcMain.handle('get-config', () => config);
|
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) => {
|
ipcMain.handle('get-secret-status', (event) => {
|
||||||
if (!isTrustedRendererEvent(event) || !appSecretStore) {
|
if (!isTrustedRendererEvent(event) || !appSecretStore) {
|
||||||
return { encryptionAvailable: false, clientSecretConfigured: false, discordWebhookConfigured: false };
|
return { encryptionAvailable: false, clientSecretConfigured: false, discordWebhookConfigured: false };
|
||||||
@@ -7425,6 +7500,7 @@ ipcMain.handle('save-config', (event, newConfig: Partial<Config>, fileCapability
|
|||||||
const previousAutoVodList = JSON.stringify(config.auto_vod_download_streamers || []);
|
const previousAutoVodList = JSON.stringify(config.auto_vod_download_streamers || []);
|
||||||
const previousAutoVodMinutes = config.auto_vod_download_poll_minutes;
|
const previousAutoVodMinutes = config.auto_vod_download_poll_minutes;
|
||||||
const previousStreamerList = JSON.stringify(config.streamers || []);
|
const previousStreamerList = JSON.stringify(config.streamers || []);
|
||||||
|
const previousDownloadPolicy = JSON.stringify(config.download_policy);
|
||||||
|
|
||||||
const acceptedConfig = { ...newConfig };
|
const acceptedConfig = { ...newConfig };
|
||||||
delete (acceptedConfig as Record<string, unknown>).client_secret;
|
delete (acceptedConfig as Record<string, unknown>).client_secret;
|
||||||
@@ -7439,6 +7515,11 @@ ipcMain.handle('save-config', (event, newConfig: Partial<Config>, fileCapability
|
|||||||
}
|
}
|
||||||
const nextConfig = normalizeConfigTemplates({ ...config, ...acceptedConfig });
|
const nextConfig = normalizeConfigTemplates({ ...config, ...acceptedConfig });
|
||||||
config = persistStateChange(config, () => nextConfig, saveConfig);
|
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) {
|
if (config.client_id !== previousClientId) {
|
||||||
accessToken = null;
|
accessToken = null;
|
||||||
@@ -7766,7 +7847,7 @@ ipcMain.handle('create-merge-group', (event, itemIds: string[]) => {
|
|||||||
return downloadQueue;
|
return downloadQueue;
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('start-download', async (event) => {
|
ipcMain.handle('start-download', async (event, manualOverride: unknown = false) => {
|
||||||
if (!isTrustedRendererEvent(event)) return false;
|
if (!isTrustedRendererEvent(event)) return false;
|
||||||
if (isDownloading && queuePaused) {
|
if (isDownloading && queuePaused) {
|
||||||
const nextQueue = downloadQueue.map((item) => item.status === 'paused' ? { ...item, status: 'downloading' as const } : item);
|
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();
|
emitQueueUpdated();
|
||||||
|
|
||||||
if (!isDownloading) {
|
if (!isDownloading) {
|
||||||
scheduleQueueProcessing();
|
scheduleQueueProcessing(manualOverride === true);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
@@ -8084,7 +8165,12 @@ registerTrustedIpcHandler(ipcMain, 'download-clip', isTrustedRendererEvent, () =
|
|||||||
resolve({ success: false, error: tBackend('unknownDownloadError') });
|
resolve({ success: false, error: tBackend('unknownDownloadError') });
|
||||||
return;
|
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);
|
const outputFinished = output.finished.then(() => null, (error) => error);
|
||||||
|
|
||||||
activeClipProcesses.set(clipId, { process: proc, output, partialFilename });
|
activeClipProcesses.set(clipId, { process: proc, output, partialFilename });
|
||||||
@@ -8725,6 +8811,7 @@ async function shutdownCleanup(reason: 'window-all-closed' | 'before-quit'): Pro
|
|||||||
if (shutdownCleanupDone) return;
|
if (shutdownCleanupDone) return;
|
||||||
shutdownCleanupDone = true;
|
shutdownCleanupDone = true;
|
||||||
appShutdownStarted = true;
|
appShutdownStarted = true;
|
||||||
|
clearDownloadPolicyWakeTimer();
|
||||||
if (queueSaveTimer) {
|
if (queueSaveTimer) {
|
||||||
clearTimeout(queueSaveTimer);
|
clearTimeout(queueSaveTimer);
|
||||||
queueSaveTimer = null;
|
queueSaveTimer = null;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { PassThrough, Writable } from 'stream';
|
import { PassThrough, Writable } from 'stream';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { createPausableOutput } from './pausable-output';
|
import { createPausableOutput } from './pausable-output';
|
||||||
|
import { createTokenBucketTransform } from './token-bucket-transform';
|
||||||
|
|
||||||
function waitForTurn(): Promise<void> {
|
function waitForTurn(): Promise<void> {
|
||||||
return new Promise((resolve) => setImmediate(resolve));
|
return new Promise((resolve) => setImmediate(resolve));
|
||||||
@@ -86,4 +87,23 @@ describe('createPausableOutput', () => {
|
|||||||
expect(target.destroyed).toBe(true);
|
expect(target.destroyed).toBe(true);
|
||||||
expect(Buffer.concat(chunks).toString()).toBe('behalten');
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Readable, Writable } from 'stream';
|
import { Readable, Transform, Writable } from 'stream';
|
||||||
|
|
||||||
export interface PausableOutput {
|
export interface PausableOutput {
|
||||||
pause(): void;
|
pause(): void;
|
||||||
@@ -8,7 +8,7 @@ export interface PausableOutput {
|
|||||||
finished: Promise<void>;
|
finished: Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createPausableOutput(source: Readable, target: Writable): PausableOutput {
|
export function createPausableOutput(source: Readable, target: Writable, transform?: Transform): PausableOutput {
|
||||||
let paused = false;
|
let paused = false;
|
||||||
let settled = false;
|
let settled = false;
|
||||||
let resolveFinished: () => void = () => {};
|
let resolveFinished: () => void = () => {};
|
||||||
@@ -21,7 +21,8 @@ export function createPausableOutput(source: Readable, target: Writable): Pausab
|
|||||||
const closed = new Promise<void>((resolve) => {
|
const closed = new Promise<void>((resolve) => {
|
||||||
resolveClosed = 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 = () => {
|
const finish = () => {
|
||||||
if (!settled) target.end();
|
if (!settled) target.end();
|
||||||
};
|
};
|
||||||
@@ -42,15 +43,16 @@ export function createPausableOutput(source: Readable, target: Writable): Pausab
|
|||||||
source.destroy(error);
|
source.destroy(error);
|
||||||
rejectFinished(error);
|
rejectFinished(error);
|
||||||
});
|
});
|
||||||
source.once('end', finish);
|
outputSource.once('end', finish);
|
||||||
source.once('error', (error) => target.destroy(error));
|
source.once('error', (error) => target.destroy(error));
|
||||||
|
if (transform) transform.once('error', (error) => target.destroy(error));
|
||||||
attach();
|
attach();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
pause() {
|
pause() {
|
||||||
if (paused || settled) return;
|
if (paused || settled) return;
|
||||||
paused = true;
|
paused = true;
|
||||||
source.unpipe(target);
|
outputSource.unpipe(target);
|
||||||
source.pause();
|
source.pause();
|
||||||
},
|
},
|
||||||
resume() {
|
resume() {
|
||||||
@@ -62,8 +64,9 @@ export function createPausableOutput(source: Readable, target: Writable): Pausab
|
|||||||
async cancel() {
|
async cancel() {
|
||||||
if (!settled) {
|
if (!settled) {
|
||||||
paused = false;
|
paused = false;
|
||||||
source.unpipe(target);
|
outputSource.unpipe(target);
|
||||||
source.destroy();
|
source.destroy();
|
||||||
|
transform?.destroy();
|
||||||
target.destroy();
|
target.destroy();
|
||||||
}
|
}
|
||||||
await closed;
|
await closed;
|
||||||
|
|||||||
@@ -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<number, { dueAt: number; callback: () => void }>();
|
||||||
|
nowMs = 0;
|
||||||
|
|
||||||
|
now(): number {
|
||||||
|
return this.nowMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(callback: () => void, delayMs: number): ReturnType<typeof setTimeout> {
|
||||||
|
const id = ++this.nextTimerId;
|
||||||
|
this.timers.set(id, { dueAt: this.nowMs + delayMs, callback });
|
||||||
|
return id as unknown as ReturnType<typeof setTimeout>;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTimeout(handle: ReturnType<typeof setTimeout>): 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { Transform } from 'node:stream';
|
||||||
|
|
||||||
|
export interface TokenBucketClock {
|
||||||
|
now(): number;
|
||||||
|
setTimeout(callback: () => void, delayMs: number): ReturnType<typeof setTimeout>;
|
||||||
|
clearTimeout(handle: ReturnType<typeof setTimeout>): 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<typeof setTimeout> | 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);
|
||||||
|
}
|
||||||
+5
-1
@@ -103,6 +103,7 @@ interface FileCapabilityReference {
|
|||||||
contextBridge.exposeInMainWorld('api', {
|
contextBridge.exposeInMainWorld('api', {
|
||||||
// Config
|
// Config
|
||||||
getConfig: () => ipcRenderer.invoke('get-config'),
|
getConfig: () => ipcRenderer.invoke('get-config'),
|
||||||
|
getDownloadPolicyStatus: () => ipcRenderer.invoke('get-download-policy-status'),
|
||||||
saveConfig: (config: any, fileCapability?: string) => ipcRenderer.invoke('save-config', config, fileCapability),
|
saveConfig: (config: any, fileCapability?: string) => ipcRenderer.invoke('save-config', config, fileCapability),
|
||||||
getSecretStatus: () => ipcRenderer.invoke('get-secret-status'),
|
getSecretStatus: () => ipcRenderer.invoke('get-secret-status'),
|
||||||
setClientSecret: (value: string) => ipcRenderer.invoke('set-client-secret', value),
|
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),
|
createMergeGroup: (itemIds: string[]) => ipcRenderer.invoke('create-merge-group', itemIds),
|
||||||
|
|
||||||
// Download
|
// Download
|
||||||
startDownload: () => ipcRenderer.invoke('start-download'),
|
startDownload: (manualOverride: boolean = true) => ipcRenderer.invoke('start-download', manualOverride),
|
||||||
pauseDownload: () => ipcRenderer.invoke('pause-download'),
|
pauseDownload: () => ipcRenderer.invoke('pause-download'),
|
||||||
cancelDownload: () => ipcRenderer.invoke('cancel-download'),
|
cancelDownload: () => ipcRenderer.invoke('cancel-download'),
|
||||||
isDownloading: () => ipcRenderer.invoke('is-downloading'),
|
isDownloading: () => ipcRenderer.invoke('is-downloading'),
|
||||||
@@ -249,6 +250,9 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
onDownloadFinished: (callback: () => void) => {
|
onDownloadFinished: (callback: () => void) => {
|
||||||
ipcRenderer.on('download-finished', () => callback());
|
ipcRenderer.on('download-finished', () => callback());
|
||||||
},
|
},
|
||||||
|
onDownloadPolicyStatus: (callback: (status: DownloadPolicyStatus) => void) => {
|
||||||
|
ipcRenderer.on('download-policy-status', (_, status) => callback(status));
|
||||||
|
},
|
||||||
onCutProgress: (callback: (percent: number) => void) => {
|
onCutProgress: (callback: (percent: number) => void) => {
|
||||||
ipcRenderer.on('cut-progress', (_, percent) => callback(percent));
|
ipcRenderer.on('cut-progress', (_, percent) => callback(percent));
|
||||||
},
|
},
|
||||||
|
|||||||
Vendored
+15
-1
@@ -22,6 +22,7 @@ interface AppConfig {
|
|||||||
streamlink_quality?: string;
|
streamlink_quality?: string;
|
||||||
notify_on_each_completion?: boolean;
|
notify_on_each_completion?: boolean;
|
||||||
streamlink_disable_ads?: boolean;
|
streamlink_disable_ads?: boolean;
|
||||||
|
download_policy?: DownloadPolicy;
|
||||||
auto_record_streamers?: string[];
|
auto_record_streamers?: string[];
|
||||||
auto_record_poll_seconds?: number;
|
auto_record_poll_seconds?: number;
|
||||||
download_chat_replay?: boolean;
|
download_chat_replay?: boolean;
|
||||||
@@ -172,6 +173,17 @@ interface VideoInfo {
|
|||||||
audioStreams: Array<{ index: number; codec: string; channels: number; language: string | null }>;
|
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 {
|
interface SecretStatus {
|
||||||
encryptionAvailable: boolean;
|
encryptionAvailable: boolean;
|
||||||
clientSecretConfigured: boolean;
|
clientSecretConfigured: boolean;
|
||||||
@@ -409,6 +421,7 @@ interface ArchiveStats {
|
|||||||
|
|
||||||
interface ApiBridge {
|
interface ApiBridge {
|
||||||
getConfig(): Promise<AppConfig>;
|
getConfig(): Promise<AppConfig>;
|
||||||
|
getDownloadPolicyStatus(): Promise<DownloadPolicyStatus>;
|
||||||
saveConfig(config: Partial<AppConfig>, fileCapability?: string): Promise<AppConfig>;
|
saveConfig(config: Partial<AppConfig>, fileCapability?: string): Promise<AppConfig>;
|
||||||
getSecretStatus(): Promise<SecretStatus>;
|
getSecretStatus(): Promise<SecretStatus>;
|
||||||
setClientSecret(value: string): Promise<SecretStatus>;
|
setClientSecret(value: string): Promise<SecretStatus>;
|
||||||
@@ -427,7 +440,7 @@ interface ApiBridge {
|
|||||||
retryFailedDownloads(): Promise<QueueItem[]>;
|
retryFailedDownloads(): Promise<QueueItem[]>;
|
||||||
retryQueueItem(id: string): Promise<QueueItem[]>;
|
retryQueueItem(id: string): Promise<QueueItem[]>;
|
||||||
createMergeGroup(itemIds: string[]): Promise<QueueItem[]>;
|
createMergeGroup(itemIds: string[]): Promise<QueueItem[]>;
|
||||||
startDownload(): Promise<boolean>;
|
startDownload(manualOverride?: boolean): Promise<boolean>;
|
||||||
pauseDownload(): Promise<boolean>;
|
pauseDownload(): Promise<boolean>;
|
||||||
cancelDownload(): Promise<boolean>;
|
cancelDownload(): Promise<boolean>;
|
||||||
isDownloading(): Promise<boolean>;
|
isDownloading(): Promise<boolean>;
|
||||||
@@ -504,6 +517,7 @@ interface ApiBridge {
|
|||||||
onDownloadStarted(callback: () => void): void;
|
onDownloadStarted(callback: () => void): void;
|
||||||
onDownloadPaused(callback: () => void): void;
|
onDownloadPaused(callback: () => void): void;
|
||||||
onDownloadFinished(callback: () => void): void;
|
onDownloadFinished(callback: () => void): void;
|
||||||
|
onDownloadPolicyStatus(callback: (status: DownloadPolicyStatus) => void): void;
|
||||||
onCutProgress(callback: (percent: number) => void): void;
|
onCutProgress(callback: (percent: number) => void): void;
|
||||||
onMergeProgress(callback: (percent: number) => void): void;
|
onMergeProgress(callback: (percent: number) => void): void;
|
||||||
onUpdateChecking(callback: () => void): void;
|
onUpdateChecking(callback: () => void): void;
|
||||||
|
|||||||
@@ -218,6 +218,16 @@ const UI_TEXT_DE = {
|
|||||||
streamlinkQualityBest: 'Best (Standard)',
|
streamlinkQualityBest: 'Best (Standard)',
|
||||||
streamlinkQualitySource: 'Source (Original)',
|
streamlinkQualitySource: 'Source (Original)',
|
||||||
streamlinkQualityAudio: 'Nur Audio',
|
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.',
|
downloadPathNotWritable: 'Download-Ordner ist nicht beschreibbar. Wähle einen anderen Ordner oder prüfe die Schreibrechte.',
|
||||||
streamerSectionTitle: 'Streamer',
|
streamerSectionTitle: 'Streamer',
|
||||||
streamerListFilterPlaceholder: 'Filtern...',
|
streamerListFilterPlaceholder: 'Filtern...',
|
||||||
|
|||||||
@@ -218,6 +218,16 @@ const UI_TEXT_EN = {
|
|||||||
streamlinkQualityBest: 'Best (default)',
|
streamlinkQualityBest: 'Best (default)',
|
||||||
streamlinkQualitySource: 'Source (original)',
|
streamlinkQualitySource: 'Source (original)',
|
||||||
streamlinkQualityAudio: 'Audio only',
|
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.',
|
downloadPathNotWritable: 'Download folder is not writable. Pick another folder or grant write permission.',
|
||||||
streamerSectionTitle: 'Streamer',
|
streamerSectionTitle: 'Streamer',
|
||||||
streamerListFilterPlaceholder: 'Filter...',
|
streamerListFilterPlaceholder: 'Filter...',
|
||||||
|
|||||||
@@ -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<string, unknown> = {};
|
||||||
|
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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -694,7 +694,75 @@ function syncPartMinutesFieldState(): void {
|
|||||||
label.classList.toggle('input-disabled', !isSplitMode);
|
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<string>();
|
||||||
|
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<HTMLElement>('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<HTMLElement>('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<void> {
|
||||||
|
renderDownloadPolicyStatus(await window.api.getDownloadPolicyStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startDownloadPolicyOverride(): Promise<void> {
|
||||||
|
await window.api.startDownload(true);
|
||||||
|
await refreshDownloadPolicyStatus();
|
||||||
|
}
|
||||||
|
|
||||||
function collectDownloadSettingsPayload(): Partial<AppConfig> {
|
function collectDownloadSettingsPayload(): Partial<AppConfig> {
|
||||||
|
const parsedPolicy = parseDownloadPolicyFormValue(
|
||||||
|
byId<HTMLInputElement>('downloadThrottleMiBps').value,
|
||||||
|
byId<HTMLTextAreaElement>('downloadWindows').value,
|
||||||
|
);
|
||||||
|
updateDownloadPolicyValidation(parsedPolicy.error);
|
||||||
return {
|
return {
|
||||||
sidebar_split_view: byId<HTMLInputElement>('sidebarSplitViewToggle').checked,
|
sidebar_split_view: byId<HTMLInputElement>('sidebarSplitViewToggle').checked,
|
||||||
download_mode: byId<HTMLSelectElement>('downloadMode').value as 'parts' | 'full',
|
download_mode: byId<HTMLSelectElement>('downloadMode').value as 'parts' | 'full',
|
||||||
@@ -724,7 +792,8 @@ function collectDownloadSettingsPayload(): Partial<AppConfig> {
|
|||||||
auto_cleanup_target: byId<HTMLSelectElement>('autoCleanupTarget').value === 'all' ? 'all' : 'live_only',
|
auto_cleanup_target: byId<HTMLSelectElement>('autoCleanupTarget').value === 'all' ? 'all' : 'live_only',
|
||||||
auto_cleanup_action: byId<HTMLSelectElement>('autoCleanupAction').value === 'delete' ? 'delete' : 'archive',
|
auto_cleanup_action: byId<HTMLSelectElement>('autoCleanupAction').value === 'delete' ? 'delete' : 'archive',
|
||||||
streamlink_quality: byId<HTMLSelectElement>('streamlinkQuality').value,
|
streamlink_quality: byId<HTMLSelectElement>('streamlinkQuality').value,
|
||||||
metadata_cache_minutes: parseInt(byId<HTMLInputElement>('metadataCacheMinutes').value, 10) || 10
|
metadata_cache_minutes: parseInt(byId<HTMLInputElement>('metadataCacheMinutes').value, 10) || 10,
|
||||||
|
download_policy: parsedPolicy.value ?? config.download_policy ?? { throttle: null, windows: [] }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -811,6 +880,9 @@ function syncSettingsFormFromConfig(syncSecrets = true): void {
|
|||||||
byId<HTMLInputElement>('autoResumeQueueToggle').checked = (config.auto_resume_queue_on_startup as boolean) === true;
|
byId<HTMLInputElement>('autoResumeQueueToggle').checked = (config.auto_resume_queue_on_startup as boolean) === true;
|
||||||
byId<HTMLInputElement>('notifyEachCompletionToggle').checked = (config.notify_on_each_completion as boolean) === true;
|
byId<HTMLInputElement>('notifyEachCompletionToggle').checked = (config.notify_on_each_completion as boolean) === true;
|
||||||
byId<HTMLInputElement>('streamlinkDisableAdsToggle').checked = (config.streamlink_disable_ads as boolean) !== false;
|
byId<HTMLInputElement>('streamlinkDisableAdsToggle').checked = (config.streamlink_disable_ads as boolean) !== false;
|
||||||
|
byId<HTMLInputElement>('downloadThrottleMiBps').value = formatDownloadThrottle(config.download_policy?.throttle?.maxBytesPerSecond);
|
||||||
|
byId<HTMLTextAreaElement>('downloadWindows').value = (config.download_policy?.windows ?? []).map((window) => `${window.start}-${window.end}`).join('\n');
|
||||||
|
updateDownloadPolicyValidation(null);
|
||||||
byId<HTMLInputElement>('downloadChatReplayToggle').checked = (config.download_chat_replay as boolean) === true;
|
byId<HTMLInputElement>('downloadChatReplayToggle').checked = (config.download_chat_replay as boolean) === true;
|
||||||
byId<HTMLInputElement>('captureLiveChatToggle').checked = (config.capture_live_chat as boolean) === true;
|
byId<HTMLInputElement>('captureLiveChatToggle').checked = (config.capture_live_chat as boolean) === true;
|
||||||
byId<HTMLInputElement>('logStreamEventsToggle').checked = (config.log_stream_events as boolean) !== false;
|
byId<HTMLInputElement>('logStreamEventsToggle').checked = (config.log_stream_events as boolean) !== false;
|
||||||
@@ -833,6 +905,7 @@ function syncSettingsFormFromConfig(syncSecrets = true): void {
|
|||||||
byId<HTMLInputElement>('partsFilenameTemplate').value = (config.filename_template_parts as string) || '{date}_Part{part_padded}.mp4';
|
byId<HTMLInputElement>('partsFilenameTemplate').value = (config.filename_template_parts as string) || '{date}_Part{part_padded}.mp4';
|
||||||
byId<HTMLInputElement>('defaultClipFilenameTemplate').value = (config.filename_template_clip as string) || '{date}_{part}.mp4';
|
byId<HTMLInputElement>('defaultClipFilenameTemplate').value = (config.filename_template_clip as string) || '{date}_{part}.mp4';
|
||||||
syncPartMinutesFieldState();
|
syncPartMinutesFieldState();
|
||||||
|
void refreshDownloadPolicyStatus();
|
||||||
validateFilenameTemplates();
|
validateFilenameTemplates();
|
||||||
lastPersistedSettingsFingerprint = getSettingsFingerprint({});
|
lastPersistedSettingsFingerprint = getSettingsFingerprint({});
|
||||||
}
|
}
|
||||||
@@ -938,6 +1011,7 @@ function initSettingsAutoSave(): void {
|
|||||||
|
|
||||||
settingsAutoSaveBound = true;
|
settingsAutoSaveBound = true;
|
||||||
syncSettingsFormFromConfig();
|
syncSettingsFormFromConfig();
|
||||||
|
window.api.onDownloadPolicyStatus(renderDownloadPolicyStatus);
|
||||||
|
|
||||||
const immediateSaveIds = [
|
const immediateSaveIds = [
|
||||||
'downloadMode',
|
'downloadMode',
|
||||||
@@ -969,7 +1043,9 @@ function initSettingsAutoSave(): void {
|
|||||||
'partsFilenameTemplate',
|
'partsFilenameTemplate',
|
||||||
'defaultClipFilenameTemplate',
|
'defaultClipFilenameTemplate',
|
||||||
'discordWebhookUrl',
|
'discordWebhookUrl',
|
||||||
'autoCleanupDays'
|
'autoCleanupDays',
|
||||||
|
'downloadThrottleMiBps',
|
||||||
|
'downloadWindows'
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const credentialIds = [
|
const credentialIds = [
|
||||||
|
|||||||
@@ -280,6 +280,12 @@ function applyLanguageToStaticUI(): void {
|
|||||||
setText('streamlinkQualityBest', UI_TEXT.static.streamlinkQualityBest);
|
setText('streamlinkQualityBest', UI_TEXT.static.streamlinkQualityBest);
|
||||||
setText('streamlinkQualitySource', UI_TEXT.static.streamlinkQualitySource);
|
setText('streamlinkQualitySource', UI_TEXT.static.streamlinkQualitySource);
|
||||||
setText('streamlinkQualityAudio', UI_TEXT.static.streamlinkQualityAudio);
|
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);
|
setText('streamerSectionTitleText', UI_TEXT.static.streamerSectionTitle);
|
||||||
setPlaceholder('streamerListFilter', UI_TEXT.static.streamerListFilterPlaceholder);
|
setPlaceholder('streamerListFilter', UI_TEXT.static.streamerListFilterPlaceholder);
|
||||||
setAriaLabel('streamerListFilter', UI_TEXT.static.streamerListFilterAria);
|
setAriaLabel('streamerListFilter', UI_TEXT.static.streamerListFilterAria);
|
||||||
|
|||||||
@@ -3598,6 +3598,18 @@ input[type="checkbox"].vod-select-checkbox:focus-visible {
|
|||||||
margin-top: 9px;
|
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 {
|
.sidebar-layout-setting {
|
||||||
margin-top: 18px;
|
margin-top: 18px;
|
||||||
padding-top: 16px;
|
padding-top: 16px;
|
||||||
|
|||||||
Reference in New Issue
Block a user