feat(policy): add download schedule decisions

Provide a pure persisted policy contract for optional app-side throughput limits and local download windows. Cover overnight timing, local Date DST behavior, invalid values, and manual window overrides without creating Streamlink arguments.
This commit is contained in:
Sucukdeluxe
2026-08-12 02:24:38 +02:00
parent c3faeddf17
commit b65d73b236
2 changed files with 195 additions and 0 deletions
+86
View File
@@ -0,0 +1,86 @@
import { describe, expect, test } from 'vitest';
import {
decideDownloadStart,
isWithinLocalDownloadWindow,
normalizeDownloadPolicy,
} from './download-policy';
describe('download policy normalization', () => {
test('keeps the persistent shape for a valid app-side throttle and local windows', () => {
const policy = normalizeDownloadPolicy({
throttle: { maxBytesPerSecond: 524_288 },
windows: [{ start: '22:00', end: '06:00' }, { start: '09:30', end: '12:00' }]
});
expect(policy).toEqual({
throttle: { maxBytesPerSecond: 524_288 },
windows: [{ start: '22:00', end: '06:00' }, { start: '09:30', end: '12:00' }]
});
expect(JSON.parse(JSON.stringify(policy))).toEqual(policy);
});
test('drops invalid throttle limits instead of inventing a process argument', () => {
for (const maxBytesPerSecond of [0, -1, 1.5, Number.POSITIVE_INFINITY, '500000']) {
expect(normalizeDownloadPolicy({ throttle: { maxBytesPerSecond } }).throttle).toBeNull();
}
});
test('drops invalid local window records from the persisted shape', () => {
expect(normalizeDownloadPolicy({
windows: [
{ start: '9:00', end: '12:00' },
{ start: '12:00', end: '12:00' },
{ start: '24:00', end: '01:00' },
{ start: '08:00', end: '10:30' }
]
}).windows).toEqual([{ start: '08:00', end: '10:30' }]);
});
});
describe('local download windows', () => {
test('accepts both sides of an overnight window', () => {
const window = { start: '22:00', end: '06:00' };
expect(isWithinLocalDownloadWindow(new Date(2026, 0, 12, 23, 30), window)).toBe(true);
expect(isWithinLocalDownloadWindow(new Date(2026, 0, 13, 5, 30), window)).toBe(true);
expect(isWithinLocalDownloadWindow(new Date(2026, 0, 13, 6, 0), window)).toBe(false);
});
test('waits for the next overnight start after the morning close', () => {
const decision = decideDownloadStart(
normalizeDownloadPolicy({ windows: [{ start: '22:00', end: '06:00' }] }),
new Date(2026, 0, 13, 7, 15)
);
expect(decision).toMatchObject({ allowed: false, reason: 'outside-window' });
expect(decision.nextStart).toEqual(new Date(2026, 0, 13, 22, 0));
});
test('uses local Date normalization when the next start falls in a DST gap', () => {
const now = new Date(2026, 2, 29, 1, 45);
const decision = decideDownloadStart(
normalizeDownloadPolicy({ windows: [{ start: '02:30', end: '04:00' }] }),
now
);
expect(decision).toMatchObject({ allowed: false, reason: 'outside-window' });
expect(decision.nextStart).toEqual(new Date(2026, 2, 29, 2, 30));
});
});
describe('manual download policy override', () => {
test('allows a manual start outside the configured window without removing the app-side throttle', () => {
const policy = normalizeDownloadPolicy({
throttle: { maxBytesPerSecond: 256_000 },
windows: [{ start: '22:00', end: '06:00' }]
});
const decision = decideDownloadStart(policy, new Date(2026, 0, 13, 13, 0), true);
expect(decision).toEqual({
allowed: true,
reason: 'manual-override',
maxBytesPerSecond: 256_000,
nextStart: null
});
});
});
+109
View File
@@ -0,0 +1,109 @@
export interface LocalDownloadWindow {
start: string;
end: string;
}
export interface DownloadThrottle {
maxBytesPerSecond: number;
}
export interface DownloadPolicy {
throttle: DownloadThrottle | null;
windows: LocalDownloadWindow[];
}
export interface DownloadStartDecision {
allowed: boolean;
reason: 'unrestricted' | 'within-window' | 'outside-window' | 'manual-override';
maxBytesPerSecond: number | null;
nextStart: Date | null;
}
interface ParsedLocalDownloadWindow extends LocalDownloadWindow {
startMinute: number;
endMinute: number;
}
function asRecord(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : null;
}
function parseLocalTime(value: unknown): number | null {
if (typeof value !== 'string') return null;
const match = /^(\d{2}):(\d{2})$/.exec(value);
if (!match) return null;
const hour = Number(match[1]);
const minute = Number(match[2]);
if (hour > 23 || minute > 59) return null;
return hour * 60 + minute;
}
function parseWindow(value: unknown): ParsedLocalDownloadWindow | null {
const record = asRecord(value);
if (!record) return null;
const startMinute = parseLocalTime(record.start);
const endMinute = parseLocalTime(record.end);
if (startMinute === null || endMinute === null || startMinute === endMinute) return null;
return { start: record.start as string, end: record.end as string, startMinute, endMinute };
}
function parseThrottle(value: unknown): DownloadThrottle | null {
const record = asRecord(value);
const maxBytesPerSecond = record?.maxBytesPerSecond;
if (typeof maxBytesPerSecond !== 'number' || !Number.isSafeInteger(maxBytesPerSecond) || maxBytesPerSecond <= 0) return null;
return { maxBytesPerSecond };
}
function toLocalDateAtMinute(reference: Date, minuteOfDay: number, dayOffset = 0): Date {
return new Date(reference.getFullYear(), reference.getMonth(), reference.getDate() + dayOffset, Math.floor(minuteOfDay / 60), minuteOfDay % 60, 0, 0);
}
function localMinuteOfDay(value: Date): number {
return value.getHours() * 60 + value.getMinutes();
}
function isWithinParsedWindow(nowMinute: number, window: ParsedLocalDownloadWindow): boolean {
if (window.startMinute < window.endMinute) return nowMinute >= window.startMinute && nowMinute < window.endMinute;
return nowMinute >= window.startMinute || nowMinute < window.endMinute;
}
function nextWindowStart(now: Date, windows: ParsedLocalDownloadWindow[]): Date {
return windows.reduce<Date | null>((earliest, window) => {
let candidate = toLocalDateAtMinute(now, window.startMinute);
if (candidate.getTime() <= now.getTime()) candidate = toLocalDateAtMinute(now, window.startMinute, 1);
return earliest === null || candidate.getTime() < earliest.getTime() ? candidate : earliest;
}, null) ?? now;
}
export function normalizeDownloadPolicy(value: unknown): DownloadPolicy {
const record = asRecord(value);
const seen = new Set<string>();
const windows: LocalDownloadWindow[] = [];
if (Array.isArray(record?.windows)) {
for (const candidate of record.windows) {
const parsed = parseWindow(candidate);
if (!parsed) continue;
const key = `${parsed.start}-${parsed.end}`;
if (seen.has(key)) continue;
seen.add(key);
windows.push({ start: parsed.start, end: parsed.end });
}
}
return { throttle: parseThrottle(record?.throttle), windows };
}
export function isWithinLocalDownloadWindow(now: Date, window: LocalDownloadWindow): boolean {
const parsed = parseWindow(window);
return parsed !== null && isWithinParsedWindow(localMinuteOfDay(now), parsed);
}
export function decideDownloadStart(policy: DownloadPolicy, now: Date, manualOverride = false): DownloadStartDecision {
const parsedWindows = policy.windows.map(parseWindow).filter((window): window is ParsedLocalDownloadWindow => window !== null);
const maxBytesPerSecond = policy.throttle?.maxBytesPerSecond ?? null;
if (parsedWindows.length === 0) return { allowed: true, reason: 'unrestricted', maxBytesPerSecond, nextStart: null };
if (manualOverride) return { allowed: true, reason: 'manual-override', maxBytesPerSecond, nextStart: null };
if (parsedWindows.some((window) => isWithinParsedWindow(localMinuteOfDay(now), window))) {
return { allowed: true, reason: 'within-window', maxBytesPerSecond, nextStart: null };
}
return { allowed: false, reason: 'outside-window', maxBytesPerSecond, nextStart: nextWindowStart(now, parsedWindows) };
}