Files
Multi-Debrid-Downloader/src/main/daily-start-scheduler.ts
T
Sucukdeluxe 4eb95c92b9 feat(scheduler): persist recurring daily starts
Add a local-calendar daily start scheduler with pending receipts, per-day deduplication, DST-safe target calculation, missed-day recovery, and retryable account/start failure outcomes.

Persist and validate the daily rule, expose its next target to the renderer, preserve legacy one-time schedules until a daily rule is saved, and gate boot auto-resume on recorded active-run evidence.

Wire boot, settings, account, suspend, resume, interval, and shutdown lifecycle handling with focused RED/GREEN coverage for calendar, persistence, renderer, and controller boundaries.
2026-08-22 09:36:45 +02:00

216 lines
6.9 KiB
TypeScript

import type { DailyStartOutcome, DailyStartSettings } from "../shared/types";
interface DailyStartSnapshot {
settings: DailyStartSettings;
session: {
running: boolean;
paused: boolean;
items: Record<string, { status: string }>;
};
canStart: boolean;
}
interface DailyStartController {
getSnapshot(): DailyStartSnapshot;
updateSettings(partial: Partial<DailyStartSettings>): unknown;
start(): Promise<void>;
}
interface LocalDateParts {
year: number;
month: number;
day: number;
}
function parseLocalDate(value: string): LocalDateParts | null {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
if (!match) {
return null;
}
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
const date = new Date(year, month - 1, day, 12, 0, 0, 0);
if (year < 1000 || date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) {
return null;
}
return { year, month, day };
}
function formatLocalDate(date: Date): string {
return `${date.getFullYear().toString().padStart(4, "0")}-${(date.getMonth() + 1).toString().padStart(2, "0")}-${date.getDate().toString().padStart(2, "0")}`;
}
function addLocalDays(value: string, days: number): string {
const parts = parseLocalDate(value);
if (!parts) {
return "";
}
return formatLocalDate(new Date(parts.year, parts.month - 1, parts.day + days, 12, 0, 0, 0));
}
function localTargetEpochMs(value: string, minuteOfDay: number): number {
const parts = parseLocalDate(value);
if (!parts) {
return 0;
}
const minute = Math.max(0, Math.min(1_439, Math.floor(minuteOfDay)));
return new Date(parts.year, parts.month - 1, parts.day, Math.floor(minute / 60), minute % 60, 0, 0).getTime();
}
export function isValidLocalDate(value: string): boolean {
return parseLocalDate(value) !== null;
}
export function nextDailyStartEpochMs(settings: DailyStartSettings, nowEpochMs = Date.now()): number {
if (!settings.dailyStartEnabled || !Number.isFinite(settings.dailyStartMinuteOfDay)) {
return 0;
}
const firstDate = parseLocalDate(settings.dailyStartFirstLocalDate);
if (!firstDate) {
return 0;
}
const now = new Date(nowEpochMs);
const today = formatLocalDate(now);
let candidate = settings.dailyStartFirstLocalDate > today ? settings.dailyStartFirstLocalDate : today;
const handled = isValidLocalDate(settings.dailyStartLastHandledLocalDate)
? settings.dailyStartLastHandledLocalDate
: "";
if (handled && candidate <= handled) {
candidate = addLocalDays(handled, 1);
}
return localTargetEpochMs(candidate, settings.dailyStartMinuteOfDay);
}
export function hasDailyStartRulePatch(partial: object): boolean {
const value = partial as Record<string, unknown>;
return ["dailyStartEnabled", "dailyStartMinuteOfDay", "dailyStartFirstLocalDate"]
.some((key) => Object.prototype.hasOwnProperty.call(value, key));
}
export function prepareDailyStartSettingsPatch<T extends object>(partial: T): T & { scheduledStartEpochMs?: number } {
return hasDailyStartRulePatch(partial)
? { ...partial, scheduledStartEpochMs: 0 }
: { ...partial };
}
export function shouldDeferAutoResumeToDailyStart(
settings: DailyStartSettings,
wasRunning: boolean,
nowEpochMs = Date.now()
): boolean {
return !wasRunning && nextDailyStartEpochMs(settings, nowEpochMs) > nowEpochMs;
}
export class DailyStartScheduler {
private reconcileInFlight: Promise<DailyStartOutcome | null> | null = null;
private reconcileTimer: ReturnType<typeof setInterval> | null = null;
public constructor(
private readonly controller: DailyStartController,
private readonly now: () => number = Date.now
) {}
public reconcile(): Promise<DailyStartOutcome | null> {
if (this.reconcileInFlight) {
return this.reconcileInFlight;
}
const operation = this.reconcileOnce();
this.reconcileInFlight = operation;
void operation.finally(() => {
if (this.reconcileInFlight === operation) {
this.reconcileInFlight = null;
}
}).catch(() => {});
return operation;
}
public begin(onError: (error: unknown) => void = () => {}): void {
this.end();
void this.reconcile().catch(onError);
this.reconcileTimer = setInterval(() => {
void this.reconcile().catch(onError);
}, 60_000);
this.reconcileTimer.unref?.();
}
public end(): void {
if (this.reconcileTimer !== null) {
clearInterval(this.reconcileTimer);
this.reconcileTimer = null;
}
}
private finish(localDate: string, outcome: DailyStartOutcome): DailyStartOutcome {
this.controller.updateSettings({
dailyStartLastHandledLocalDate: localDate,
dailyStartPendingLocalDate: "",
dailyStartLastOutcome: outcome
});
return outcome;
}
private async reconcileOnce(): Promise<DailyStartOutcome | null> {
const nowEpochMs = this.now();
let settings = this.controller.getSnapshot().settings;
if (!settings.dailyStartEnabled || !isValidLocalDate(settings.dailyStartFirstLocalDate)) {
return null;
}
const today = formatLocalDate(new Date(nowEpochMs));
let missed: DailyStartOutcome | null = null;
if (isValidLocalDate(settings.dailyStartPendingLocalDate) && settings.dailyStartPendingLocalDate < today) {
const pendingDate = settings.dailyStartPendingLocalDate;
this.controller.updateSettings({
dailyStartLastHandledLocalDate: pendingDate,
dailyStartPendingLocalDate: "",
dailyStartLastOutcome: "missed"
});
settings = {
...settings,
dailyStartLastHandledLocalDate: pendingDate,
dailyStartPendingLocalDate: "",
dailyStartLastOutcome: "missed"
};
missed = "missed";
}
if (today < settings.dailyStartFirstLocalDate || settings.dailyStartLastHandledLocalDate === today) {
return missed;
}
const targetEpochMs = localTargetEpochMs(today, settings.dailyStartMinuteOfDay);
if (!targetEpochMs || targetEpochMs > nowEpochMs) {
return missed;
}
if (settings.dailyStartPendingLocalDate !== today) {
this.controller.updateSettings({
dailyStartPendingLocalDate: today,
dailyStartLastOutcome: ""
});
}
const snapshot = this.controller.getSnapshot();
if (snapshot.session.running || snapshot.session.paused) {
return this.finish(today, "already_active");
}
const hasQueuedItems = Object.values(snapshot.session.items)
.some((item) => item.status === "queued" || item.status === "reconnect_wait");
if (!hasQueuedItems) {
return this.finish(today, "empty_queue");
}
if (!snapshot.canStart) {
this.controller.updateSettings({ dailyStartLastOutcome: "missing_account" });
return "missing_account";
}
try {
await this.controller.start();
} catch (error) {
this.controller.updateSettings({ dailyStartLastOutcome: "start_failed" });
throw error;
}
return this.finish(today, "started");
}
}