From b65d73b236be1eba6bc9d6889e22b49cdce0985d Mon Sep 17 00:00:00 2001 From: Sucukdeluxe <259325684+Sucukdeluxe@users.noreply.github.com> Date: Wed, 12 Aug 2026 02:24:38 +0200 Subject: [PATCH] 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. --- src/main/domain/download-policy.test.ts | 86 +++++++++++++++++++ src/main/domain/download-policy.ts | 109 ++++++++++++++++++++++++ 2 files changed, 195 insertions(+) create mode 100644 src/main/domain/download-policy.test.ts create mode 100644 src/main/domain/download-policy.ts diff --git a/src/main/domain/download-policy.test.ts b/src/main/domain/download-policy.test.ts new file mode 100644 index 0000000..5631b66 --- /dev/null +++ b/src/main/domain/download-policy.test.ts @@ -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 + }); + }); +}); diff --git a/src/main/domain/download-policy.ts b/src/main/domain/download-policy.ts new file mode 100644 index 0000000..a66126f --- /dev/null +++ b/src/main/domain/download-policy.ts @@ -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 | null { + return typeof value === 'object' && value !== null && !Array.isArray(value) ? value as Record : 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((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(); + 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) }; +}