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:
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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) };
|
||||
}
|
||||
Reference in New Issue
Block a user