feat(downloads): apply bandwidth and schedule policy

This commit is contained in:
Sucukdeluxe
2026-08-12 02:43:06 +02:00
parent 661d7d9858
commit c44314ea2c
15 changed files with 510 additions and 17 deletions
+16
View File
@@ -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
View File
@@ -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);
});
});
+20
View File
@@ -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);
});
}); });
+9 -6
View File
@@ -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');
});
});
+58
View File
@@ -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
View File
@@ -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));
}, },
+15 -1
View File
@@ -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;
+10
View File
@@ -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...',
+10
View File
@@ -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' });
});
});
+78 -2
View File
@@ -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 = [
+6
View File
@@ -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);
+12
View File
@@ -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;