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.
This commit is contained in:
@@ -81,6 +81,7 @@ import { normalizeStatisticsLedger, saveStatisticsLedger } from "./statistics-le
|
|||||||
import { NotificationOutbox } from "./notification-outbox";
|
import { NotificationOutbox } from "./notification-outbox";
|
||||||
import { sendNotification } from "./notify";
|
import { sendNotification } from "./notify";
|
||||||
import { DownloadHealthMonitor } from "./download-health-monitor";
|
import { DownloadHealthMonitor } from "./download-health-monitor";
|
||||||
|
import { prepareDailyStartSettingsPatch, shouldDeferAutoResumeToDailyStart } from "./daily-start-scheduler";
|
||||||
|
|
||||||
function sanitizeSettingsPatch(partial: Partial<AppSettings>): Partial<AppSettings> {
|
function sanitizeSettingsPatch(partial: Partial<AppSettings>): Partial<AppSettings> {
|
||||||
const entries = Object.entries(partial || {}).filter(([, value]) => value !== undefined);
|
const entries = Object.entries(partial || {}).filter(([, value]) => value !== undefined);
|
||||||
@@ -254,7 +255,7 @@ export class AppController {
|
|||||||
}, 60_000);
|
}, 60_000);
|
||||||
this.runtimeStatsTimer.unref?.();
|
this.runtimeStatsTimer.unref?.();
|
||||||
|
|
||||||
if (this.settings.autoResumeOnStart) {
|
if (this.settings.autoResumeOnStart && !shouldDeferAutoResumeToDailyStart(this.settings, loadResult.wasRunning)) {
|
||||||
const snapshot = this.manager.getSnapshot();
|
const snapshot = this.manager.getSnapshot();
|
||||||
const hasPending = Object.values(snapshot.session.items).some((item) => item.status === "queued" || item.status === "reconnect_wait");
|
const hasPending = Object.values(snapshot.session.items).some((item) => item.status === "queued" || item.status === "reconnect_wait");
|
||||||
if (hasPending && this.hasAnyProviderToken(this.settings)) {
|
if (hasPending && this.hasAnyProviderToken(this.settings)) {
|
||||||
@@ -520,7 +521,7 @@ export class AppController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public updateSettings(partial: Partial<AppSettings>): AppSettings {
|
public updateSettings(partial: Partial<AppSettings>): AppSettings {
|
||||||
const sanitizedPatch = sanitizeSettingsPatch(partial);
|
const sanitizedPatch = prepareDailyStartSettingsPatch(sanitizeSettingsPatch(partial)) as Partial<AppSettings>;
|
||||||
const previousSettings = this.settings;
|
const previousSettings = this.settings;
|
||||||
let nextSettings = normalizeSettings({
|
let nextSettings = normalizeSettings({
|
||||||
...previousSettings,
|
...previousSettings,
|
||||||
|
|||||||
@@ -159,6 +159,12 @@ export function defaultSettings(): AppSettings {
|
|||||||
megaDebridAccountTotalUsageBytes: {},
|
megaDebridAccountTotalUsageBytes: {},
|
||||||
debridAccountStatuses: {},
|
debridAccountStatuses: {},
|
||||||
providerDailyUsageDay: getProviderUsageDayKey(),
|
providerDailyUsageDay: getProviderUsageDayKey(),
|
||||||
|
dailyStartEnabled: false,
|
||||||
|
dailyStartMinuteOfDay: 0,
|
||||||
|
dailyStartFirstLocalDate: "",
|
||||||
|
dailyStartLastHandledLocalDate: "",
|
||||||
|
dailyStartPendingLocalDate: "",
|
||||||
|
dailyStartLastOutcome: "",
|
||||||
scheduledStartEpochMs: 0
|
scheduledStartEpochMs: 0
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
+50
-11
@@ -1,7 +1,7 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
import { app, BrowserWindow, clipboard, dialog, ipcMain, Menu, safeStorage, shell, Tray, type IpcMainEvent, type IpcMainInvokeEvent } from "electron";
|
import { app, BrowserWindow, clipboard, dialog, ipcMain, Menu, powerMonitor, safeStorage, shell, Tray, type IpcMainEvent, type IpcMainInvokeEvent } from "electron";
|
||||||
import { AddLinksPayload, AppSettings, DebridProvider, EnableRemoteDiagnosticsInput, RendererSettingsUpdate, UpdateInstallProgress } from "../shared/types";
|
import { AddLinksPayload, AppSettings, DebridProvider, EnableRemoteDiagnosticsInput, RendererSettingsUpdate, UpdateInstallProgress } from "../shared/types";
|
||||||
import { AppController } from "./app-controller";
|
import { AppController } from "./app-controller";
|
||||||
import { IPC_CHANNELS } from "../shared/ipc";
|
import { IPC_CHANNELS } from "../shared/ipc";
|
||||||
@@ -24,6 +24,7 @@ import { assertTrustedIpcSender, type TrustedIpcOptions } from "./ipc-security";
|
|||||||
import { validateRealDebridLoginRequest } from "../shared/preload-api";
|
import { validateRealDebridLoginRequest } from "../shared/preload-api";
|
||||||
import { migrateProductUserDataDirectory } from "./storage";
|
import { migrateProductUserDataDirectory } from "./storage";
|
||||||
import { validateCollectorContainerInspectionRequest, validateCollectorInspectionRequest } from "../shared/collector";
|
import { validateCollectorContainerInspectionRequest, validateCollectorInspectionRequest } from "../shared/collector";
|
||||||
|
import { DailyStartScheduler, hasDailyStartRulePatch } from "./daily-start-scheduler";
|
||||||
|
|
||||||
function validateString(value: unknown, name: string): string {
|
function validateString(value: unknown, name: string): string {
|
||||||
if (typeof value !== "string") {
|
if (typeof value !== "string") {
|
||||||
@@ -89,11 +90,26 @@ let tray: Tray | null = null;
|
|||||||
let clipboardTimer: ReturnType<typeof setInterval> | null = null;
|
let clipboardTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
let updateQuitTimer: ReturnType<typeof setTimeout> | null = null;
|
let updateQuitTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let scheduledStartTimer: ReturnType<typeof setTimeout> | null = null;
|
let scheduledStartTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let dailyStartScheduler: DailyStartScheduler | null = null;
|
||||||
let lastClipboardText = "";
|
let lastClipboardText = "";
|
||||||
let controller: AppController;
|
let controller: AppController;
|
||||||
let pendingBackupImport: Buffer | null = null;
|
let pendingBackupImport: Buffer | null = null;
|
||||||
const CLIPBOARD_MAX_TEXT_CHARS = 50_000;
|
const CLIPBOARD_MAX_TEXT_CHARS = 50_000;
|
||||||
|
|
||||||
|
function reconcileDailyStart(source: string): void {
|
||||||
|
void dailyStartScheduler?.reconcile().catch((error) => {
|
||||||
|
logger.warn(`Täglicher Start konnte nach ${source} nicht abgeglichen werden: ${String(error)}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePowerSuspend(): void {
|
||||||
|
reconcileDailyStart("Suspend");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePowerResume(): void {
|
||||||
|
reconcileDailyStart("Resume");
|
||||||
|
}
|
||||||
|
|
||||||
export interface BeforeQuitHandlerOptions {
|
export interface BeforeQuitHandlerOptions {
|
||||||
cleanup: () => void;
|
cleanup: () => void;
|
||||||
shutdown: () => Promise<void>;
|
shutdown: () => Promise<void>;
|
||||||
@@ -446,13 +462,18 @@ function registerIpcHandlers(): void {
|
|||||||
handleTrusted(IPC_CHANNELS.OPEN_EXTERNAL, async (_event: IpcMainInvokeEvent, rawUrl: string) => {
|
handleTrusted(IPC_CHANNELS.OPEN_EXTERNAL, async (_event: IpcMainInvokeEvent, rawUrl: string) => {
|
||||||
return openAllowedExternalUrl(String(rawUrl || "").trim(), MAIN_WINDOW_EXTERNAL_HOSTS);
|
return openAllowedExternalUrl(String(rawUrl || "").trim(), MAIN_WINDOW_EXTERNAL_HOSTS);
|
||||||
});
|
});
|
||||||
handleTrusted(IPC_CHANNELS.UPDATE_SETTINGS, (_event: IpcMainInvokeEvent, partial: RendererSettingsUpdate) => {
|
handleTrusted(IPC_CHANNELS.UPDATE_SETTINGS, async (_event: IpcMainInvokeEvent, partial: RendererSettingsUpdate) => {
|
||||||
const validated = validateRendererSettingsUpdate(partial ?? {}, controller.getSettings());
|
const validated = validateRendererSettingsUpdate(partial ?? {}, controller.getSettings());
|
||||||
const result = controller.updateSettings(validated as Partial<AppSettings>);
|
const result = controller.updateSettings(validated as Partial<AppSettings>);
|
||||||
updateClipboardWatcher();
|
updateClipboardWatcher();
|
||||||
updateTray();
|
updateTray();
|
||||||
armScheduledStart(result.scheduledStartEpochMs || 0, { startOnPast: true });
|
armScheduledStart(result.scheduledStartEpochMs || 0, { startOnPast: true });
|
||||||
return createRendererSettings(result);
|
if (hasDailyStartRulePatch(validated)) {
|
||||||
|
await dailyStartScheduler?.reconcile();
|
||||||
|
} else {
|
||||||
|
reconcileDailyStart("Einstellungsänderung");
|
||||||
|
}
|
||||||
|
return createRendererSettings(controller.getSettings());
|
||||||
});
|
});
|
||||||
handleTrusted(IPC_CHANNELS.RESET_PROVIDER_DAILY_USAGE, (_event: IpcMainInvokeEvent, provider: string) => {
|
handleTrusted(IPC_CHANNELS.RESET_PROVIDER_DAILY_USAGE, (_event: IpcMainInvokeEvent, provider: string) => {
|
||||||
const validatedProvider = validateString(provider, "provider") as DebridProvider;
|
const validatedProvider = validateString(provider, "provider") as DebridProvider;
|
||||||
@@ -469,28 +490,36 @@ function registerIpcHandlers(): void {
|
|||||||
return createRendererSettings(controller.resetDebridLinkApiKeyDailyUsage(validatedKeyId));
|
return createRendererSettings(controller.resetDebridLinkApiKeyDailyUsage(validatedKeyId));
|
||||||
});
|
});
|
||||||
|
|
||||||
handleTrusted(IPC_CHANNELS.CREATE_ACCOUNT, (_event: IpcMainInvokeEvent, rawCommand: unknown) => {
|
handleTrusted(IPC_CHANNELS.CREATE_ACCOUNT, async (_event: IpcMainInvokeEvent, rawCommand: unknown) => {
|
||||||
const command = validateAccountCommand(rawCommand);
|
const command = validateAccountCommand(rawCommand);
|
||||||
if (command.action !== "create") throw new Error("Account-Payload ist ungültig");
|
if (command.action !== "create") throw new Error("Account-Payload ist ungültig");
|
||||||
return controller.executeAccountCommand(command);
|
const result = await controller.executeAccountCommand(command);
|
||||||
|
reconcileDailyStart("Accountänderung");
|
||||||
|
return result;
|
||||||
});
|
});
|
||||||
|
|
||||||
handleTrusted(IPC_CHANNELS.REPLACE_ACCOUNT, (_event: IpcMainInvokeEvent, rawCommand: unknown) => {
|
handleTrusted(IPC_CHANNELS.REPLACE_ACCOUNT, async (_event: IpcMainInvokeEvent, rawCommand: unknown) => {
|
||||||
const command = validateAccountCommand(rawCommand);
|
const command = validateAccountCommand(rawCommand);
|
||||||
if (command.action !== "replace") throw new Error("Account-Payload ist ungültig");
|
if (command.action !== "replace") throw new Error("Account-Payload ist ungültig");
|
||||||
return controller.executeAccountCommand(command);
|
const result = await controller.executeAccountCommand(command);
|
||||||
|
reconcileDailyStart("Accountänderung");
|
||||||
|
return result;
|
||||||
});
|
});
|
||||||
|
|
||||||
handleTrusted(IPC_CHANNELS.UPDATE_ACCOUNT_SECRET, (_event: IpcMainInvokeEvent, rawCommand: unknown) => {
|
handleTrusted(IPC_CHANNELS.UPDATE_ACCOUNT_SECRET, async (_event: IpcMainInvokeEvent, rawCommand: unknown) => {
|
||||||
const command = validateAccountCommand(rawCommand);
|
const command = validateAccountCommand(rawCommand);
|
||||||
if (command.action !== "update-secret") throw new Error("Account-Payload ist ungültig");
|
if (command.action !== "update-secret") throw new Error("Account-Payload ist ungültig");
|
||||||
return controller.executeAccountCommand(command);
|
const result = await controller.executeAccountCommand(command);
|
||||||
|
reconcileDailyStart("Accountänderung");
|
||||||
|
return result;
|
||||||
});
|
});
|
||||||
|
|
||||||
handleTrusted(IPC_CHANNELS.DELETE_ACCOUNT, (_event: IpcMainInvokeEvent, rawCommand: unknown) => {
|
handleTrusted(IPC_CHANNELS.DELETE_ACCOUNT, async (_event: IpcMainInvokeEvent, rawCommand: unknown) => {
|
||||||
const command = validateAccountCommand(rawCommand);
|
const command = validateAccountCommand(rawCommand);
|
||||||
if (command.action !== "delete") throw new Error("Account-Payload ist ungültig");
|
if (command.action !== "delete") throw new Error("Account-Payload ist ungültig");
|
||||||
return controller.executeAccountCommand(command);
|
const result = await controller.executeAccountCommand(command);
|
||||||
|
reconcileDailyStart("Accountänderung");
|
||||||
|
return result;
|
||||||
});
|
});
|
||||||
handleTrusted(IPC_CHANNELS.REVEAL_ACCOUNT_SECRET, (_event: IpcMainInvokeEvent, rawRequest: unknown) => {
|
handleTrusted(IPC_CHANNELS.REVEAL_ACCOUNT_SECRET, (_event: IpcMainInvokeEvent, rawRequest: unknown) => {
|
||||||
return controller.revealAccountSecret(validateAccountSecretRequest(rawRequest));
|
return controller.revealAccountSecret(validateAccountSecretRequest(rawRequest));
|
||||||
@@ -1043,6 +1072,7 @@ app.on("second-instance", () => {
|
|||||||
app.whenReady().then(() => {
|
app.whenReady().then(() => {
|
||||||
configureCredentialProtector(safeStorage);
|
configureCredentialProtector(safeStorage);
|
||||||
controller = new AppController();
|
controller = new AppController();
|
||||||
|
dailyStartScheduler = new DailyStartScheduler(controller);
|
||||||
cleanupStaleSubstDrives();
|
cleanupStaleSubstDrives();
|
||||||
registerIpcHandlers();
|
registerIpcHandlers();
|
||||||
mainWindow = createWindow();
|
mainWindow = createWindow();
|
||||||
@@ -1053,6 +1083,11 @@ app.whenReady().then(() => {
|
|||||||
// process — without re-arming it here, any restart (auto-update, reboot,
|
// process — without re-arming it here, any restart (auto-update, reboot,
|
||||||
// crash) silently swallowed the planned run.
|
// crash) silently swallowed the planned run.
|
||||||
armScheduledStart(controller.getSettings().scheduledStartEpochMs || 0, { startOnPast: false });
|
armScheduledStart(controller.getSettings().scheduledStartEpochMs || 0, { startOnPast: false });
|
||||||
|
dailyStartScheduler.begin((error) => {
|
||||||
|
logger.warn(`Täglicher Start konnte nicht abgeglichen werden: ${String(error)}`);
|
||||||
|
});
|
||||||
|
powerMonitor.on("suspend", handlePowerSuspend);
|
||||||
|
powerMonitor.on("resume", handlePowerResume);
|
||||||
|
|
||||||
app.on("activate", () => {
|
app.on("activate", () => {
|
||||||
if (BrowserWindow.getAllWindows().length === 0) {
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||||||
@@ -1076,6 +1111,10 @@ app.on("window-all-closed", () => {
|
|||||||
app.on("before-quit", createBeforeQuitHandler({
|
app.on("before-quit", createBeforeQuitHandler({
|
||||||
cleanup: () => {
|
cleanup: () => {
|
||||||
if (updateQuitTimer) { clearTimeout(updateQuitTimer); updateQuitTimer = null; }
|
if (updateQuitTimer) { clearTimeout(updateQuitTimer); updateQuitTimer = null; }
|
||||||
|
if (scheduledStartTimer) { clearTimeout(scheduledStartTimer); scheduledStartTimer = null; }
|
||||||
|
dailyStartScheduler?.end();
|
||||||
|
powerMonitor.removeListener("suspend", handlePowerSuspend);
|
||||||
|
powerMonitor.removeListener("resume", handlePowerResume);
|
||||||
stopClipboardWatcher();
|
stopClipboardWatcher();
|
||||||
destroyTray();
|
destroyTray();
|
||||||
shutdownDaemon();
|
shutdownDaemon();
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
import type { AppSettings, RendererSettingsUpdate } from "../shared/types";
|
import type { AppSettings, RendererSettingsUpdate } from "../shared/types";
|
||||||
import { createRendererSettings } from "./renderer-state";
|
import { createRendererSettings } from "./renderer-state";
|
||||||
|
import { isValidLocalDate } from "./daily-start-scheduler";
|
||||||
|
|
||||||
const DERIVED_KEYS = new Set(["archivePasswordListConfigured", "notifyUrlConfigured", "configuredProviders"]);
|
const DERIVED_KEYS = new Set([
|
||||||
|
"archivePasswordListConfigured",
|
||||||
|
"notifyUrlConfigured",
|
||||||
|
"configuredProviders",
|
||||||
|
"dailyStartLastHandledLocalDate",
|
||||||
|
"dailyStartPendingLocalDate",
|
||||||
|
"dailyStartLastOutcome",
|
||||||
|
"nextDailyStartEpochMs"
|
||||||
|
]);
|
||||||
const WRITE_ONLY_KEYS = new Set(["archivePasswordList", "notifyUrl"]);
|
const WRITE_ONLY_KEYS = new Set(["archivePasswordList", "notifyUrl"]);
|
||||||
const MAX_SETTINGS_PAYLOAD_BYTES = 1_000_000;
|
const MAX_SETTINGS_PAYLOAD_BYTES = 1_000_000;
|
||||||
|
|
||||||
@@ -85,6 +94,12 @@ export function validateRendererSettingsUpdate(value: unknown, current: AppSetti
|
|||||||
if (key === "notifyPackageSuccessMode" && entry !== "digest" && entry !== "individual") {
|
if (key === "notifyPackageSuccessMode" && entry !== "digest" && entry !== "individual") {
|
||||||
invalid();
|
invalid();
|
||||||
}
|
}
|
||||||
|
if (key === "dailyStartMinuteOfDay" && (!Number.isInteger(entry) || (entry as number) < 0 || (entry as number) > 1_439)) {
|
||||||
|
invalid();
|
||||||
|
}
|
||||||
|
if (key === "dailyStartFirstLocalDate" && entry !== "" && (typeof entry !== "string" || !isValidLocalDate(entry))) {
|
||||||
|
invalid();
|
||||||
|
}
|
||||||
validateTopLevelType(entry, safe[key]);
|
validateTopLevelType(entry, safe[key]);
|
||||||
validateJsonValue(entry);
|
validateJsonValue(entry);
|
||||||
output[key] = entry;
|
output[key] = entry;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { getMegaDebridAccountsForMode, getMegaDebridDisabledAccountIdsForMode }
|
|||||||
import { getRealDebridAccounts } from "../shared/real-debrid-accounts";
|
import { getRealDebridAccounts } from "../shared/real-debrid-accounts";
|
||||||
import type { AppSettings, DebridAccountStatus, DebridProvider, RendererAccount, RendererAccountKind, RendererSettings } from "../shared/types";
|
import type { AppSettings, DebridAccountStatus, DebridProvider, RendererAccount, RendererAccountKind, RendererSettings } from "../shared/types";
|
||||||
import { collectAccountStatusRedactionValues, sanitizeDebridAccountStatus } from "./account-status-sanitizer";
|
import { collectAccountStatusRedactionValues, sanitizeDebridAccountStatus } from "./account-status-sanitizer";
|
||||||
|
import { nextDailyStartEpochMs } from "./daily-start-scheduler";
|
||||||
|
|
||||||
function maskValue(value: string, keepStart = 3, keepEnd = 3): string {
|
function maskValue(value: string, keepStart = 3, keepEnd = 3): string {
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
@@ -251,7 +252,14 @@ export function createRendererSettings(settings: AppSettings): RendererSettings
|
|||||||
megaDebridAccountTotalUsageBytes: { ...settings.megaDebridAccountTotalUsageBytes },
|
megaDebridAccountTotalUsageBytes: { ...settings.megaDebridAccountTotalUsageBytes },
|
||||||
debridAccountStatuses: Object.fromEntries(Object.entries(settings.debridAccountStatuses).map(([id, status]) => [id, safeStatus(status, redactions)])),
|
debridAccountStatuses: Object.fromEntries(Object.entries(settings.debridAccountStatuses).map(([id, status]) => [id, safeStatus(status, redactions)])),
|
||||||
providerDailyUsageDay: settings.providerDailyUsageDay,
|
providerDailyUsageDay: settings.providerDailyUsageDay,
|
||||||
scheduledStartEpochMs: settings.scheduledStartEpochMs
|
dailyStartEnabled: settings.dailyStartEnabled,
|
||||||
|
dailyStartMinuteOfDay: settings.dailyStartMinuteOfDay,
|
||||||
|
dailyStartFirstLocalDate: settings.dailyStartFirstLocalDate,
|
||||||
|
dailyStartLastHandledLocalDate: settings.dailyStartLastHandledLocalDate,
|
||||||
|
dailyStartPendingLocalDate: settings.dailyStartPendingLocalDate,
|
||||||
|
dailyStartLastOutcome: settings.dailyStartLastOutcome,
|
||||||
|
scheduledStartEpochMs: settings.scheduledStartEpochMs,
|
||||||
|
nextDailyStartEpochMs: nextDailyStartEpochMs(settings)
|
||||||
} as RendererSettings;
|
} as RendererSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+34
-18
@@ -5,12 +5,13 @@ import path from "node:path";
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys";
|
import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys";
|
||||||
import { getMegaDebridAccountIds, mergeMegaDebridCredentialPools, parseMegaDebridAccounts } from "../shared/mega-debrid-accounts";
|
import { getMegaDebridAccountIds, mergeMegaDebridCredentialPools, parseMegaDebridAccounts } from "../shared/mega-debrid-accounts";
|
||||||
import { AppSettings, ArchiveOperationMetric, AudioStripSummary, BandwidthScheduleEntry, DebridAccountStatus, DebridFallbackProvider, DebridProvider, DownloadItem, DownloadStatus, FailurePhase, HistoryEntry, HistoryRetentionMode, LogStorageLocation, PackageEntry, PackagePriority, RemuxOperationMetric, SessionState } from "../shared/types";
|
import { AppSettings, ArchiveOperationMetric, AudioStripSummary, BandwidthScheduleEntry, DailyStartOutcome, DebridAccountStatus, DebridFallbackProvider, DebridProvider, DownloadItem, DownloadStatus, FailurePhase, HistoryEntry, HistoryRetentionMode, LogStorageLocation, PackageEntry, PackagePriority, RemuxOperationMetric, SessionState } from "../shared/types";
|
||||||
import { getProviderUsageDayKey } from "../shared/provider-daily-limits";
|
import { getProviderUsageDayKey } from "../shared/provider-daily-limits";
|
||||||
import { getRealDebridAccountIds, normalizeRealDebridWebAccountIds, parseRealDebridApiAccounts, serializeRealDebridApiAccounts } from "../shared/real-debrid-accounts";
|
import { getRealDebridAccountIds, normalizeRealDebridWebAccountIds, parseRealDebridApiAccounts, serializeRealDebridApiAccounts } from "../shared/real-debrid-accounts";
|
||||||
import { defaultSettings } from "./constants";
|
import { defaultSettings } from "./constants";
|
||||||
import { needsPersistedSettingsRewrite, protectPersistedSettings, restorePersistedSettings } from "./credential-protection";
|
import { needsPersistedSettingsRewrite, protectPersistedSettings, restorePersistedSettings } from "./credential-protection";
|
||||||
import { logger } from "./logger";
|
import { logger } from "./logger";
|
||||||
|
import { isValidLocalDate } from "./daily-start-scheduler";
|
||||||
|
|
||||||
export function migrateProductUserDataDirectory(appDataPath: string): string {
|
export function migrateProductUserDataDirectory(appDataPath: string): string {
|
||||||
const legacyPath = path.join(appDataPath, "Real-Debrid-Downloader");
|
const legacyPath = path.join(appDataPath, "Real-Debrid-Downloader");
|
||||||
@@ -518,6 +519,7 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
|
|||||||
debridLinkApiKeyIds
|
debridLinkApiKeyIds
|
||||||
);
|
);
|
||||||
const debridLinkDisabledKeyIds = normalizeStringList(settings.debridLinkDisabledKeyIds, debridLinkApiKeyIds);
|
const debridLinkDisabledKeyIds = normalizeStringList(settings.debridLinkDisabledKeyIds, debridLinkApiKeyIds);
|
||||||
|
const validDailyStartOutcomes = new Set<DailyStartOutcome>(["", "started", "already_active", "empty_queue", "missing_account", "start_failed", "missed"]);
|
||||||
const normalized: AppSettings = {
|
const normalized: AppSettings = {
|
||||||
language: settings.language === "de" ? "de" : "en",
|
language: settings.language === "de" ? "de" : "en",
|
||||||
token: asText(settings.token),
|
token: asText(settings.token),
|
||||||
@@ -658,6 +660,12 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
|
|||||||
legacyRealDebridTargetId
|
legacyRealDebridTargetId
|
||||||
),
|
),
|
||||||
providerDailyUsageDay: providerDailyUsageDay === currentUsageDay ? providerDailyUsageDay : currentUsageDay,
|
providerDailyUsageDay: providerDailyUsageDay === currentUsageDay ? providerDailyUsageDay : currentUsageDay,
|
||||||
|
dailyStartEnabled: settings.dailyStartEnabled !== undefined ? Boolean(settings.dailyStartEnabled) : defaults.dailyStartEnabled,
|
||||||
|
dailyStartMinuteOfDay: clampNumber(settings.dailyStartMinuteOfDay, defaults.dailyStartMinuteOfDay, 0, 1_439),
|
||||||
|
dailyStartFirstLocalDate: isValidLocalDate(asText(settings.dailyStartFirstLocalDate)) ? asText(settings.dailyStartFirstLocalDate) : "",
|
||||||
|
dailyStartLastHandledLocalDate: isValidLocalDate(asText(settings.dailyStartLastHandledLocalDate)) ? asText(settings.dailyStartLastHandledLocalDate) : "",
|
||||||
|
dailyStartPendingLocalDate: isValidLocalDate(asText(settings.dailyStartPendingLocalDate)) ? asText(settings.dailyStartPendingLocalDate) : "",
|
||||||
|
dailyStartLastOutcome: validDailyStartOutcomes.has(settings.dailyStartLastOutcome) ? settings.dailyStartLastOutcome : "",
|
||||||
scheduledStartEpochMs: clampNumber(settings.scheduledStartEpochMs, defaults.scheduledStartEpochMs, 0, Number.MAX_SAFE_INTEGER)
|
scheduledStartEpochMs: clampNumber(settings.scheduledStartEpochMs, defaults.scheduledStartEpochMs, 0, Number.MAX_SAFE_INTEGER)
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1211,7 +1219,12 @@ function sleepSyncMs(ms: number): void {
|
|||||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
||||||
}
|
}
|
||||||
|
|
||||||
function readSessionFile(filePath: string): SessionState | null {
|
interface LoadedSessionFile {
|
||||||
|
session: SessionState;
|
||||||
|
wasRunning: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSessionFile(filePath: string): LoadedSessionFile | null {
|
||||||
let raw: string | null = null;
|
let raw: string | null = null;
|
||||||
const maxAttempts = 5;
|
const maxAttempts = 5;
|
||||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||||
@@ -1239,11 +1252,13 @@ function readSessionFile(filePath: string): SessionState | null {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(raw) as unknown;
|
const parsed = JSON.parse(raw) as unknown;
|
||||||
const session = normalizeLoadedSessionTransientFields(normalizeLoadedSession(parsed));
|
const normalized = normalizeLoadedSession(parsed);
|
||||||
|
const wasRunning = normalized.running;
|
||||||
|
const session = normalizeLoadedSessionTransientFields(normalized);
|
||||||
const pkgCount = Object.keys(session.packages).length;
|
const pkgCount = Object.keys(session.packages).length;
|
||||||
const itemCount = Object.keys(session.items).length;
|
const itemCount = Object.keys(session.items).length;
|
||||||
logger.info(`Session geladen: ${filePath} (${pkgCount} Pakete, ${itemCount} Items)`);
|
logger.info(`Session geladen: ${filePath} (${pkgCount} Pakete, ${itemCount} Items)`);
|
||||||
return session;
|
return { session, wasRunning };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Session-Datei beschädigt (JSON ungültig): ${filePath}: ${String(error)}`);
|
logger.error(`Session-Datei beschädigt (JSON ungültig): ${filePath}: ${String(error)}`);
|
||||||
return null;
|
return null;
|
||||||
@@ -1360,6 +1375,7 @@ export type SessionLoadStatus =
|
|||||||
export interface SessionLoadResult {
|
export interface SessionLoadResult {
|
||||||
session: SessionState;
|
session: SessionState;
|
||||||
status: SessionLoadStatus;
|
status: SessionLoadStatus;
|
||||||
|
wasRunning: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadSessionWithStatus(paths: StoragePaths): SessionLoadResult {
|
export function loadSessionWithStatus(paths: StoragePaths): SessionLoadResult {
|
||||||
@@ -1374,7 +1390,7 @@ export function loadSessionWithStatus(paths: StoragePaths): SessionLoadResult {
|
|||||||
if (!primaryExists) {
|
if (!primaryExists) {
|
||||||
if (!backupExists && !anyTempExists) {
|
if (!backupExists && !anyTempExists) {
|
||||||
logger.info("Keine Session-Datei vorhanden, starte mit leerer Session");
|
logger.info("Keine Session-Datei vorhanden, starte mit leerer Session");
|
||||||
return { session: emptySession(), status: "empty-fresh" };
|
return { session: emptySession(), status: "empty-fresh", wasRunning: false };
|
||||||
}
|
}
|
||||||
logger.warn("Session-Primaerdatei fehlt, aber Backup/Temp vorhanden — Wiederherstellung wird versucht");
|
logger.warn("Session-Primaerdatei fehlt, aber Backup/Temp vorhanden — Wiederherstellung wird versucht");
|
||||||
}
|
}
|
||||||
@@ -1382,60 +1398,60 @@ export function loadSessionWithStatus(paths: StoragePaths): SessionLoadResult {
|
|||||||
const primary = primaryExists ? readSessionFile(paths.sessionFile) : null;
|
const primary = primaryExists ? readSessionFile(paths.sessionFile) : null;
|
||||||
|
|
||||||
if (primary) {
|
if (primary) {
|
||||||
const primaryPkgCount = Object.keys(primary.packages).length;
|
const primaryPkgCount = Object.keys(primary.session.packages).length;
|
||||||
if (primaryPkgCount === 0 && backupExists) {
|
if (primaryPkgCount === 0 && backupExists) {
|
||||||
const backup = readSessionFile(backupFile);
|
const backup = readSessionFile(backupFile);
|
||||||
if (backup) {
|
if (backup) {
|
||||||
const backupPkgCount = Object.keys(backup.packages).length;
|
const backupPkgCount = Object.keys(backup.session.packages).length;
|
||||||
if (backupPkgCount > 0) {
|
if (backupPkgCount > 0) {
|
||||||
logger.warn(`Session-Datei ist leer (0 Pakete), aber Backup hat ${backupPkgCount} Pakete — verwende Backup`);
|
logger.warn(`Session-Datei ist leer (0 Pakete), aber Backup hat ${backupPkgCount} Pakete — verwende Backup`);
|
||||||
try {
|
try {
|
||||||
const payload = JSON.stringify({ ...backup, updatedAt: Date.now() }, safeJsonReplacer);
|
const payload = JSON.stringify({ ...backup.session, updatedAt: Date.now() }, safeJsonReplacer);
|
||||||
fs.writeFileSync(syncTempFile, payload, "utf8");
|
fs.writeFileSync(syncTempFile, payload, "utf8");
|
||||||
syncRenameWithExdevFallback(syncTempFile, paths.sessionFile);
|
syncRenameWithExdevFallback(syncTempFile, paths.sessionFile);
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
return { session: backup, status: "recovered-backup" };
|
return { session: backup.session, status: "recovered-backup", wasRunning: backup.wasRunning };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { session: primary, status: "ok" };
|
return { session: primary.session, status: "ok", wasRunning: primary.wasRunning };
|
||||||
}
|
}
|
||||||
|
|
||||||
const backup = backupExists ? readSessionFile(backupFile) : null;
|
const backup = backupExists ? readSessionFile(backupFile) : null;
|
||||||
if (backup) {
|
if (backup) {
|
||||||
logger.warn("Session defekt, Backup-Datei wird verwendet");
|
logger.warn("Session defekt, Backup-Datei wird verwendet");
|
||||||
try {
|
try {
|
||||||
const payload = JSON.stringify({ ...backup, updatedAt: Date.now() }, safeJsonReplacer);
|
const payload = JSON.stringify({ ...backup.session, updatedAt: Date.now() }, safeJsonReplacer);
|
||||||
fs.writeFileSync(syncTempFile, payload, "utf8");
|
fs.writeFileSync(syncTempFile, payload, "utf8");
|
||||||
syncRenameWithExdevFallback(syncTempFile, paths.sessionFile);
|
syncRenameWithExdevFallback(syncTempFile, paths.sessionFile);
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
return { session: backup, status: "recovered-backup" };
|
return { session: backup.session, status: "recovered-backup", wasRunning: backup.wasRunning };
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const kind of ["sync", "async"] as const) {
|
for (const kind of ["sync", "async"] as const) {
|
||||||
const tmpPath = sessionTempPath(paths.sessionFile, kind);
|
const tmpPath = sessionTempPath(paths.sessionFile, kind);
|
||||||
if (fs.existsSync(tmpPath)) {
|
if (fs.existsSync(tmpPath)) {
|
||||||
const tmpSession = readSessionFile(tmpPath);
|
const tmpSession = readSessionFile(tmpPath);
|
||||||
if (tmpSession && Object.keys(tmpSession.packages).length > 0) {
|
if (tmpSession && Object.keys(tmpSession.session.packages).length > 0) {
|
||||||
logger.warn(`Session aus temporaerer Datei wiederhergestellt: ${tmpPath} (${Object.keys(tmpSession.packages).length} Pakete)`);
|
logger.warn(`Session aus temporaerer Datei wiederhergestellt: ${tmpPath} (${Object.keys(tmpSession.session.packages).length} Pakete)`);
|
||||||
try {
|
try {
|
||||||
const payload = JSON.stringify({ ...tmpSession, updatedAt: Date.now() }, safeJsonReplacer);
|
const payload = JSON.stringify({ ...tmpSession.session, updatedAt: Date.now() }, safeJsonReplacer);
|
||||||
fs.writeFileSync(paths.sessionFile, payload, "utf8");
|
fs.writeFileSync(paths.sessionFile, payload, "utf8");
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
return { session: tmpSession, status: "recovered-temp" };
|
return { session: tmpSession.session, status: "recovered-temp", wasRunning: tmpSession.wasRunning };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (primaryExists || backupExists || anyTempExists) {
|
if (primaryExists || backupExists || anyTempExists) {
|
||||||
logger.error("Session konnte nicht geladen werden (Primary, Backup und Temp-Dateien fehlgeschlagen) — Schutz gegen leeres Ueberschreiben aktiv");
|
logger.error("Session konnte nicht geladen werden (Primary, Backup und Temp-Dateien fehlgeschlagen) — Schutz gegen leeres Ueberschreiben aktiv");
|
||||||
return { session: emptySession(), status: "empty-unreadable" };
|
return { session: emptySession(), status: "empty-unreadable", wasRunning: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { session: emptySession(), status: "empty-fresh" };
|
return { session: emptySession(), status: "empty-fresh", wasRunning: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadSession(paths: StoragePaths): SessionState {
|
export function loadSession(paths: StoragePaths): SessionState {
|
||||||
|
|||||||
@@ -810,7 +810,14 @@ const emptySnapshot = (): UiSnapshot => ({
|
|||||||
megaDebridAccountTotalUsageBytes: {},
|
megaDebridAccountTotalUsageBytes: {},
|
||||||
debridAccountStatuses: {},
|
debridAccountStatuses: {},
|
||||||
providerDailyUsageDay: getProviderUsageDayKey(),
|
providerDailyUsageDay: getProviderUsageDayKey(),
|
||||||
scheduledStartEpochMs: 0
|
dailyStartEnabled: false,
|
||||||
|
dailyStartMinuteOfDay: 0,
|
||||||
|
dailyStartFirstLocalDate: "",
|
||||||
|
dailyStartLastHandledLocalDate: "",
|
||||||
|
dailyStartPendingLocalDate: "",
|
||||||
|
dailyStartLastOutcome: "",
|
||||||
|
scheduledStartEpochMs: 0,
|
||||||
|
nextDailyStartEpochMs: 0
|
||||||
},
|
},
|
||||||
accounts: [],
|
accounts: [],
|
||||||
session: {
|
session: {
|
||||||
|
|||||||
+14
-2
@@ -122,7 +122,18 @@ export interface DebridAccountStatus {
|
|||||||
|
|
||||||
export type NotifyPackageSuccessMode = "digest" | "individual";
|
export type NotifyPackageSuccessMode = "digest" | "individual";
|
||||||
|
|
||||||
export interface AppSettings {
|
export type DailyStartOutcome = "" | "started" | "already_active" | "empty_queue" | "missing_account" | "start_failed" | "missed";
|
||||||
|
|
||||||
|
export interface DailyStartSettings {
|
||||||
|
dailyStartEnabled: boolean;
|
||||||
|
dailyStartMinuteOfDay: number;
|
||||||
|
dailyStartFirstLocalDate: string;
|
||||||
|
dailyStartLastHandledLocalDate: string;
|
||||||
|
dailyStartPendingLocalDate: string;
|
||||||
|
dailyStartLastOutcome: DailyStartOutcome;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppSettings extends DailyStartSettings {
|
||||||
language: AppLanguage;
|
language: AppLanguage;
|
||||||
token: string;
|
token: string;
|
||||||
realDebridUseWebLogin: boolean;
|
realDebridUseWebLogin: boolean;
|
||||||
@@ -271,7 +282,7 @@ export interface RendererAccount {
|
|||||||
status: DebridAccountStatus | null;
|
status: DebridAccountStatus | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RendererSettings {
|
export interface RendererSettings extends DailyStartSettings {
|
||||||
language: AppLanguage;
|
language: AppLanguage;
|
||||||
realDebridUseWebLogin: boolean;
|
realDebridUseWebLogin: boolean;
|
||||||
realDebridDisabledAccountIds: string[];
|
realDebridDisabledAccountIds: string[];
|
||||||
@@ -373,6 +384,7 @@ export interface RendererSettings {
|
|||||||
debridAccountStatuses: Record<string, DebridAccountStatus>;
|
debridAccountStatuses: Record<string, DebridAccountStatus>;
|
||||||
providerDailyUsageDay: string;
|
providerDailyUsageDay: string;
|
||||||
scheduledStartEpochMs: number;
|
scheduledStartEpochMs: number;
|
||||||
|
nextDailyStartEpochMs: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RendererSettingsUpdate = Partial<RendererSettings> & {
|
export type RendererSettingsUpdate = Partial<RendererSettings> & {
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { AppController } from "../src/main/app-controller";
|
||||||
|
import { defaultSettings } from "../src/main/constants";
|
||||||
|
import { configureCredentialProtector } from "../src/main/credential-protection";
|
||||||
|
import { createStoragePaths } from "../src/main/storage";
|
||||||
|
import type { AppSettings } from "../src/shared/types";
|
||||||
|
|
||||||
|
vi.mock("electron", () => ({
|
||||||
|
app: { getPath: () => "C:\\MDD\\Test" },
|
||||||
|
BrowserWindow: class {},
|
||||||
|
clipboard: {},
|
||||||
|
dialog: {},
|
||||||
|
ipcMain: { handle: vi.fn(), on: vi.fn() },
|
||||||
|
Menu: { buildFromTemplate: vi.fn(), setApplicationMenu: vi.fn() },
|
||||||
|
safeStorage: { isEncryptionAvailable: () => false, encryptString: vi.fn(), decryptString: vi.fn() },
|
||||||
|
shell: {},
|
||||||
|
Tray: class {}
|
||||||
|
}));
|
||||||
|
|
||||||
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
|
function createController(settings: AppSettings): AppController {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "mdd-controller-"));
|
||||||
|
tempDirs.push(dir);
|
||||||
|
const controller = Object.create(AppController.prototype) as any;
|
||||||
|
controller.settings = settings;
|
||||||
|
controller.storagePaths = createStoragePaths(dir);
|
||||||
|
controller.manager = { setSettings: vi.fn() };
|
||||||
|
controller.audit = vi.fn();
|
||||||
|
controller.overlayLiveUsageCounters = vi.fn();
|
||||||
|
controller.pruneRealDebridWebFallbacks = vi.fn();
|
||||||
|
controller.realDebridWebFallbacks = new Map();
|
||||||
|
return controller as AppController;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
for (const dir of tempDirs.splice(0)) {
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("AppController daily start settings", () => {
|
||||||
|
it("clears a legacy one-time schedule when a new daily rule is saved", () => {
|
||||||
|
configureCredentialProtector({
|
||||||
|
isEncryptionAvailable: () => false,
|
||||||
|
encryptString: (value) => Buffer.from(value, "utf8"),
|
||||||
|
decryptString: (value) => Buffer.from(value).toString("utf8")
|
||||||
|
});
|
||||||
|
const controller = createController({
|
||||||
|
...defaultSettings(),
|
||||||
|
scheduledStartEpochMs: 1_800_000_000_000
|
||||||
|
});
|
||||||
|
|
||||||
|
const updated = controller.updateSettings({
|
||||||
|
dailyStartEnabled: true,
|
||||||
|
dailyStartMinuteOfDay: 18 * 60 + 45,
|
||||||
|
dailyStartFirstLocalDate: "2026-08-23"
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(updated.scheduledStartEpochMs).toBe(0);
|
||||||
|
expect(updated).toMatchObject({
|
||||||
|
dailyStartEnabled: true,
|
||||||
|
dailyStartMinuteOfDay: 18 * 60 + 45,
|
||||||
|
dailyStartFirstLocalDate: "2026-08-23"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
import { afterAll, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { DailyStartSettings } from "../src/shared/types";
|
||||||
|
import { DailyStartScheduler, nextDailyStartEpochMs, prepareDailyStartSettingsPatch, shouldDeferAutoResumeToDailyStart } from "../src/main/daily-start-scheduler";
|
||||||
|
|
||||||
|
const originalTimezone = process.env.TZ;
|
||||||
|
process.env.TZ = "Europe/Berlin";
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
if (originalTimezone === undefined) {
|
||||||
|
delete process.env.TZ;
|
||||||
|
} else {
|
||||||
|
process.env.TZ = originalTimezone;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function settings(overrides: Partial<DailyStartSettings> = {}): DailyStartSettings {
|
||||||
|
return {
|
||||||
|
dailyStartEnabled: true,
|
||||||
|
dailyStartMinuteOfDay: 10 * 60,
|
||||||
|
dailyStartFirstLocalDate: "2026-08-22",
|
||||||
|
dailyStartLastHandledLocalDate: "",
|
||||||
|
dailyStartPendingLocalDate: "",
|
||||||
|
dailyStartLastOutcome: "",
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeDailyStartController {
|
||||||
|
public settings: DailyStartSettings;
|
||||||
|
public running = false;
|
||||||
|
public paused = false;
|
||||||
|
public canStart = true;
|
||||||
|
public items: Record<string, { status: string }> = { queued: { status: "queued" } };
|
||||||
|
public events: string[] = [];
|
||||||
|
public start = vi.fn(async () => {
|
||||||
|
this.events.push("start");
|
||||||
|
});
|
||||||
|
|
||||||
|
public constructor(value: DailyStartSettings) {
|
||||||
|
this.settings = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getSnapshot() {
|
||||||
|
return {
|
||||||
|
settings: this.settings,
|
||||||
|
session: {
|
||||||
|
running: this.running,
|
||||||
|
paused: this.paused,
|
||||||
|
items: this.items
|
||||||
|
},
|
||||||
|
canStart: this.canStart
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public updateSettings(partial: Partial<DailyStartSettings>): DailyStartSettings {
|
||||||
|
Object.assign(this.settings, partial);
|
||||||
|
if (partial.dailyStartPendingLocalDate) {
|
||||||
|
this.events.push(`pending:${partial.dailyStartPendingLocalDate}`);
|
||||||
|
}
|
||||||
|
if (partial.dailyStartLastHandledLocalDate) {
|
||||||
|
this.events.push(`handled:${partial.dailyStartLastHandledLocalDate}`);
|
||||||
|
}
|
||||||
|
return this.settings;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("daily start scheduler", () => {
|
||||||
|
it("keeps today's future and past targets on the local calendar date", () => {
|
||||||
|
const futureNow = new Date(2026, 7, 22, 9, 15).getTime();
|
||||||
|
const pastNow = new Date(2026, 7, 22, 12, 30).getTime();
|
||||||
|
const expected = new Date(2026, 7, 22, 10, 0, 0, 0).getTime();
|
||||||
|
|
||||||
|
expect(nextDailyStartEpochMs(settings(), futureNow)).toBe(expected);
|
||||||
|
expect(nextDailyStartEpochMs(settings(), pastNow)).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses tomorrow as the first eligible local date", () => {
|
||||||
|
const now = new Date(2026, 7, 22, 8, 0).getTime();
|
||||||
|
|
||||||
|
expect(nextDailyStartEpochMs(settings({ dailyStartFirstLocalDate: "2026-08-23" }), now))
|
||||||
|
.toBe(new Date(2026, 7, 23, 10, 0, 0, 0).getTime());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dispatches once on each of five eligible local days", async () => {
|
||||||
|
let now = new Date(2026, 7, 22, 10, 5).getTime();
|
||||||
|
const controller = new FakeDailyStartController(settings());
|
||||||
|
const scheduler = new DailyStartScheduler(controller, () => now);
|
||||||
|
|
||||||
|
for (let day = 22; day <= 26; day += 1) {
|
||||||
|
now = new Date(2026, 7, day, 10, 5).getTime();
|
||||||
|
await scheduler.reconcile();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(controller.start).toHaveBeenCalledTimes(5);
|
||||||
|
expect(controller.settings.dailyStartLastHandledLocalDate).toBe("2026-08-26");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists pending before dispatch and coalesces simultaneous reconciles", async () => {
|
||||||
|
const now = new Date(2026, 7, 22, 10, 5).getTime();
|
||||||
|
const controller = new FakeDailyStartController(settings());
|
||||||
|
const scheduler = new DailyStartScheduler(controller, () => now);
|
||||||
|
|
||||||
|
await Promise.all([scheduler.reconcile(), scheduler.reconcile()]);
|
||||||
|
|
||||||
|
expect(controller.start).toHaveBeenCalledTimes(1);
|
||||||
|
expect(controller.events).toEqual([
|
||||||
|
"pending:2026-08-22",
|
||||||
|
"start",
|
||||||
|
"handled:2026-08-22"
|
||||||
|
]);
|
||||||
|
expect(controller.settings.dailyStartPendingLocalDate).toBe("");
|
||||||
|
expect(controller.settings.dailyStartLastOutcome).toBe("started");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("catches an overdue target after a clock jump without dispatching twice", async () => {
|
||||||
|
let now = new Date(2026, 7, 22, 9, 55).getTime();
|
||||||
|
const controller = new FakeDailyStartController(settings());
|
||||||
|
const scheduler = new DailyStartScheduler(controller, () => now);
|
||||||
|
|
||||||
|
expect(await scheduler.reconcile()).toBeNull();
|
||||||
|
now = new Date(2026, 7, 22, 11, 10).getTime();
|
||||||
|
expect(await scheduler.reconcile()).toBe("started");
|
||||||
|
expect(await scheduler.reconcile()).toBeNull();
|
||||||
|
|
||||||
|
expect(controller.start).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats repeated suspend and resume reconciles as one daily dispatch", async () => {
|
||||||
|
const now = new Date(2026, 7, 22, 10, 5).getTime();
|
||||||
|
const controller = new FakeDailyStartController(settings());
|
||||||
|
const scheduler = new DailyStartScheduler(controller, () => now);
|
||||||
|
|
||||||
|
await scheduler.reconcile();
|
||||||
|
await scheduler.reconcile();
|
||||||
|
await scheduler.reconcile();
|
||||||
|
|
||||||
|
expect(controller.start).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recovers a persisted pending receipt after restart and then deduplicates it", async () => {
|
||||||
|
const now = new Date(2026, 7, 22, 10, 5).getTime();
|
||||||
|
const controller = new FakeDailyStartController(settings({ dailyStartPendingLocalDate: "2026-08-22" }));
|
||||||
|
|
||||||
|
await new DailyStartScheduler(controller, () => now).reconcile();
|
||||||
|
await new DailyStartScheduler(controller, () => now).reconcile();
|
||||||
|
|
||||||
|
expect(controller.start).toHaveBeenCalledTimes(1);
|
||||||
|
expect(controller.settings.dailyStartLastHandledLocalDate).toBe("2026-08-22");
|
||||||
|
expect(controller.settings.dailyStartPendingLocalDate).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks an empty queue handled without calling start", async () => {
|
||||||
|
const now = new Date(2026, 7, 22, 10, 5).getTime();
|
||||||
|
const controller = new FakeDailyStartController(settings());
|
||||||
|
controller.items = {};
|
||||||
|
|
||||||
|
expect(await new DailyStartScheduler(controller, () => now).reconcile()).toBe("empty_queue");
|
||||||
|
expect(controller.start).not.toHaveBeenCalled();
|
||||||
|
expect(controller.settings.dailyStartLastHandledLocalDate).toBe("2026-08-22");
|
||||||
|
expect(controller.settings.dailyStartLastOutcome).toBe("empty_queue");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a missing-account occurrence pending and retries after account recovery", async () => {
|
||||||
|
const now = new Date(2026, 7, 22, 10, 5).getTime();
|
||||||
|
const controller = new FakeDailyStartController(settings());
|
||||||
|
controller.canStart = false;
|
||||||
|
const scheduler = new DailyStartScheduler(controller, () => now);
|
||||||
|
|
||||||
|
expect(await scheduler.reconcile()).toBe("missing_account");
|
||||||
|
expect(controller.start).not.toHaveBeenCalled();
|
||||||
|
expect(controller.settings.dailyStartPendingLocalDate).toBe("2026-08-22");
|
||||||
|
expect(controller.settings.dailyStartLastHandledLocalDate).toBe("");
|
||||||
|
|
||||||
|
controller.canStart = true;
|
||||||
|
expect(await scheduler.reconcile()).toBe("started");
|
||||||
|
expect(controller.start).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats running and paused sessions as already active", async () => {
|
||||||
|
let now = new Date(2026, 7, 22, 10, 5).getTime();
|
||||||
|
const controller = new FakeDailyStartController(settings());
|
||||||
|
controller.running = true;
|
||||||
|
const scheduler = new DailyStartScheduler(controller, () => now);
|
||||||
|
|
||||||
|
expect(await scheduler.reconcile()).toBe("already_active");
|
||||||
|
controller.running = false;
|
||||||
|
controller.paused = true;
|
||||||
|
now = new Date(2026, 7, 23, 10, 5).getTime();
|
||||||
|
expect(await scheduler.reconcile()).toBe("already_active");
|
||||||
|
|
||||||
|
expect(controller.start).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records a stale pending day as missed without disabling future days", async () => {
|
||||||
|
const now = new Date(2026, 7, 23, 9, 0).getTime();
|
||||||
|
const controller = new FakeDailyStartController(settings({ dailyStartPendingLocalDate: "2026-08-22" }));
|
||||||
|
const scheduler = new DailyStartScheduler(controller, () => now);
|
||||||
|
|
||||||
|
expect(await scheduler.reconcile()).toBe("missed");
|
||||||
|
expect(controller.settings.dailyStartEnabled).toBe(true);
|
||||||
|
expect(controller.settings.dailyStartLastHandledLocalDate).toBe("2026-08-22");
|
||||||
|
expect(controller.settings.dailyStartLastOutcome).toBe("missed");
|
||||||
|
expect(nextDailyStartEpochMs(controller.settings, now)).toBe(new Date(2026, 7, 23, 10, 0).getTime());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps start failures pending for a later retry", async () => {
|
||||||
|
const now = new Date(2026, 7, 22, 10, 5).getTime();
|
||||||
|
const controller = new FakeDailyStartController(settings());
|
||||||
|
controller.start.mockRejectedValueOnce(new Error("start rejected"));
|
||||||
|
const scheduler = new DailyStartScheduler(controller, () => now);
|
||||||
|
|
||||||
|
await expect(scheduler.reconcile()).rejects.toThrow("start rejected");
|
||||||
|
expect(controller.settings.dailyStartPendingLocalDate).toBe("2026-08-22");
|
||||||
|
expect(controller.settings.dailyStartLastHandledLocalDate).toBe("");
|
||||||
|
expect(controller.settings.dailyStartLastOutcome).toBe("start_failed");
|
||||||
|
|
||||||
|
await scheduler.reconcile();
|
||||||
|
expect(controller.start).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("constructs each DST target from local calendar fields instead of adding 24 hours", () => {
|
||||||
|
const beforeDst = settings({
|
||||||
|
dailyStartMinuteOfDay: 2 * 60 + 30,
|
||||||
|
dailyStartFirstLocalDate: "2026-03-29"
|
||||||
|
});
|
||||||
|
const firstNow = new Date(2026, 2, 28, 12, 0).getTime();
|
||||||
|
const firstTarget = nextDailyStartEpochMs(beforeDst, firstNow);
|
||||||
|
const nextTarget = nextDailyStartEpochMs({
|
||||||
|
...beforeDst,
|
||||||
|
dailyStartLastHandledLocalDate: "2026-03-29"
|
||||||
|
}, new Date(2026, 2, 29, 12, 0).getTime());
|
||||||
|
|
||||||
|
expect(firstTarget).toBe(new Date(2026, 2, 29, 2, 30, 0, 0).getTime());
|
||||||
|
expect(nextTarget).toBe(new Date(2026, 2, 30, 2, 30, 0, 0).getTime());
|
||||||
|
expect(nextTarget - firstTarget).toBe(23 * 60 * 60 * 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears a legacy one-time target only when a new daily rule is saved", () => {
|
||||||
|
expect(prepareDailyStartSettingsPatch({
|
||||||
|
dailyStartEnabled: true,
|
||||||
|
dailyStartMinuteOfDay: 600,
|
||||||
|
dailyStartFirstLocalDate: "2026-08-22"
|
||||||
|
})).toEqual({
|
||||||
|
dailyStartEnabled: true,
|
||||||
|
dailyStartMinuteOfDay: 600,
|
||||||
|
dailyStartFirstLocalDate: "2026-08-22",
|
||||||
|
scheduledStartEpochMs: 0
|
||||||
|
});
|
||||||
|
expect(prepareDailyStartSettingsPatch({ dailyStartPendingLocalDate: "2026-08-22" }))
|
||||||
|
.toEqual({ dailyStartPendingLocalDate: "2026-08-22" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defers boot auto-resume for a future daily target unless the saved run was active", () => {
|
||||||
|
const now = new Date(2026, 7, 22, 9, 0).getTime();
|
||||||
|
const future = settings({ dailyStartMinuteOfDay: 10 * 60 });
|
||||||
|
|
||||||
|
expect(shouldDeferAutoResumeToDailyStart(future, false, now)).toBe(true);
|
||||||
|
expect(shouldDeferAutoResumeToDailyStart(future, true, now)).toBe(false);
|
||||||
|
expect(shouldDeferAutoResumeToDailyStart({ ...future, dailyStartEnabled: false }, false, now)).toBe(false);
|
||||||
|
expect(shouldDeferAutoResumeToDailyStart({ ...future, dailyStartMinuteOfDay: 8 * 60 }, false, now)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reconciles at boot and at most sixty seconds after a target becomes due", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date(2026, 7, 22, 9, 59, 30));
|
||||||
|
try {
|
||||||
|
const controller = new FakeDailyStartController(settings());
|
||||||
|
const scheduler = new DailyStartScheduler(controller);
|
||||||
|
|
||||||
|
scheduler.begin();
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
expect(controller.start).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(60_000);
|
||||||
|
expect(controller.start).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
scheduler.end();
|
||||||
|
await vi.advanceTimersByTimeAsync(120_000);
|
||||||
|
expect(controller.start).toHaveBeenCalledTimes(1);
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { defaultSettings } from "../src/main/constants";
|
import { defaultSettings } from "../src/main/constants";
|
||||||
import { createRendererSettings } from "../src/main/renderer-state";
|
import { createRendererSettings } from "../src/main/renderer-state";
|
||||||
import { validateRendererSettingsUpdate } from "../src/main/renderer-settings";
|
import { validateRendererSettingsUpdate } from "../src/main/renderer-settings";
|
||||||
@@ -73,4 +73,54 @@ describe("renderer settings validation", () => {
|
|||||||
expect(projected.archivePasswordListConfigured).toBe(true);
|
expect(projected.archivePasswordListConfigured).toBe(true);
|
||||||
expect(JSON.stringify(projected)).not.toContain(password);
|
expect(JSON.stringify(projected)).not.toContain(password);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("projects daily start state and accepts only editable calendar controls", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date(2026, 7, 22, 9, 0));
|
||||||
|
try {
|
||||||
|
const current = {
|
||||||
|
...defaultSettings(),
|
||||||
|
dailyStartEnabled: true,
|
||||||
|
dailyStartMinuteOfDay: 18 * 60 + 45,
|
||||||
|
dailyStartFirstLocalDate: "2026-08-23",
|
||||||
|
dailyStartLastHandledLocalDate: "2026-08-22",
|
||||||
|
dailyStartPendingLocalDate: "",
|
||||||
|
dailyStartLastOutcome: "started" as const
|
||||||
|
};
|
||||||
|
const projected = createRendererSettings(current);
|
||||||
|
|
||||||
|
expect(projected).toMatchObject({
|
||||||
|
dailyStartEnabled: true,
|
||||||
|
dailyStartMinuteOfDay: 18 * 60 + 45,
|
||||||
|
dailyStartFirstLocalDate: "2026-08-23",
|
||||||
|
dailyStartLastHandledLocalDate: "2026-08-22",
|
||||||
|
dailyStartPendingLocalDate: "",
|
||||||
|
dailyStartLastOutcome: "started"
|
||||||
|
});
|
||||||
|
expect(projected.nextDailyStartEpochMs).toBe(new Date(2026, 7, 23, 18, 45, 0, 0).getTime());
|
||||||
|
expect(validateRendererSettingsUpdate({
|
||||||
|
dailyStartEnabled: true,
|
||||||
|
dailyStartMinuteOfDay: 7 * 60 + 30,
|
||||||
|
dailyStartFirstLocalDate: "2026-08-24",
|
||||||
|
dailyStartLastHandledLocalDate: "2026-08-23",
|
||||||
|
dailyStartPendingLocalDate: "2026-08-24",
|
||||||
|
dailyStartLastOutcome: "missed",
|
||||||
|
nextDailyStartEpochMs: 123
|
||||||
|
}, current)).toEqual({
|
||||||
|
dailyStartEnabled: true,
|
||||||
|
dailyStartMinuteOfDay: 7 * 60 + 30,
|
||||||
|
dailyStartFirstLocalDate: "2026-08-24"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid editable daily calendar values at the renderer boundary", () => {
|
||||||
|
const current = defaultSettings();
|
||||||
|
|
||||||
|
expect(() => validateRendererSettingsUpdate({ dailyStartMinuteOfDay: 1_440 }, current)).toThrow("Settings-Payload ist ungültig");
|
||||||
|
expect(() => validateRendererSettingsUpdate({ dailyStartMinuteOfDay: 12.5 }, current)).toThrow("Settings-Payload ist ungültig");
|
||||||
|
expect(() => validateRendererSettingsUpdate({ dailyStartFirstLocalDate: "2026-02-29" }, current)).toThrow("Settings-Payload ist ungültig");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+43
-1
@@ -9,7 +9,7 @@ import { parseRealDebridApiAccounts, serializeRealDebridApiAccounts } from "../s
|
|||||||
import { AppSettings } from "../src/shared/types";
|
import { AppSettings } from "../src/shared/types";
|
||||||
import { defaultSettings } from "../src/main/constants";
|
import { defaultSettings } from "../src/main/constants";
|
||||||
import { configureCredentialProtector } from "../src/main/credential-protection";
|
import { configureCredentialProtector } from "../src/main/credential-protection";
|
||||||
import { addHistoryEntryForRetention, createStoragePaths, emptySession, loadHistory, loadHistoryForRetention, loadSession, loadSettings, normalizeLoadedSession, normalizeSettings, removeHistoryEntries, resetHistoryForRetention, saveHistory, saveSession, saveSessionAsync, saveSettings, saveSettingsAsync } from "../src/main/storage";
|
import { addHistoryEntryForRetention, createStoragePaths, emptySession, loadHistory, loadHistoryForRetention, loadSession, loadSessionWithStatus, loadSettings, normalizeLoadedSession, normalizeSettings, removeHistoryEntries, resetHistoryForRetention, saveHistory, saveSession, saveSessionAsync, saveSettings, saveSettingsAsync } from "../src/main/storage";
|
||||||
|
|
||||||
const tempDirs: string[] = [];
|
const tempDirs: string[] = [];
|
||||||
type SettingsSaveMode = "sync" | "async";
|
type SettingsSaveMode = "sync" | "async";
|
||||||
@@ -46,6 +46,48 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("settings storage", () => {
|
describe("settings storage", () => {
|
||||||
|
it("defaults and normalizes persistent daily start calendar fields", () => {
|
||||||
|
const defaults = defaultSettings();
|
||||||
|
|
||||||
|
expect(defaults.dailyStartEnabled).toBe(false);
|
||||||
|
expect(defaults.dailyStartMinuteOfDay).toBe(0);
|
||||||
|
expect(defaults.dailyStartFirstLocalDate).toBe("");
|
||||||
|
expect(defaults.dailyStartLastHandledLocalDate).toBe("");
|
||||||
|
expect(defaults.dailyStartPendingLocalDate).toBe("");
|
||||||
|
expect(defaults.dailyStartLastOutcome).toBe("");
|
||||||
|
|
||||||
|
expect(normalizeSettings({
|
||||||
|
...defaults,
|
||||||
|
dailyStartEnabled: true,
|
||||||
|
dailyStartMinuteOfDay: 1_500,
|
||||||
|
dailyStartFirstLocalDate: "2026-02-29",
|
||||||
|
dailyStartLastHandledLocalDate: "2026-08-21",
|
||||||
|
dailyStartPendingLocalDate: "not-a-day",
|
||||||
|
dailyStartLastOutcome: "unsupported"
|
||||||
|
} as unknown as AppSettings)).toMatchObject({
|
||||||
|
dailyStartEnabled: true,
|
||||||
|
dailyStartMinuteOfDay: 1_439,
|
||||||
|
dailyStartFirstLocalDate: "",
|
||||||
|
dailyStartLastHandledLocalDate: "2026-08-21",
|
||||||
|
dailyStartPendingLocalDate: "",
|
||||||
|
dailyStartLastOutcome: ""
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports whether a loaded session was active before transient normalization", () => {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||||
|
tempDirs.push(dir);
|
||||||
|
const paths = createStoragePaths(dir);
|
||||||
|
const session = emptySession();
|
||||||
|
session.running = true;
|
||||||
|
saveSession(paths, session);
|
||||||
|
|
||||||
|
const loaded = loadSessionWithStatus(paths);
|
||||||
|
|
||||||
|
expect(loaded.wasRunning).toBe(true);
|
||||||
|
expect(loaded.session.running).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("migrates legacy package success notifications without changing their delivery frequency", () => {
|
it("migrates legacy package success notifications without changing their delivery frequency", () => {
|
||||||
expect(loadSettingsFrom({ notifyOnPackageCompleted: true }).notifyPackageSuccessMode).toBe("individual");
|
expect(loadSettingsFrom({ notifyOnPackageCompleted: true }).notifyPackageSuccessMode).toBe("individual");
|
||||||
expect(loadSettingsFrom({}).notifyPackageSuccessMode).toBe("digest");
|
expect(loadSettingsFrom({}).notifyPackageSuccessMode).toBe("digest");
|
||||||
|
|||||||
@@ -264,6 +264,12 @@ function createSettings(): AppSettings {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
providerDailyUsageDay: "2026-08-10",
|
providerDailyUsageDay: "2026-08-10",
|
||||||
|
dailyStartEnabled: false,
|
||||||
|
dailyStartMinuteOfDay: 0,
|
||||||
|
dailyStartFirstLocalDate: "",
|
||||||
|
dailyStartLastHandledLocalDate: "",
|
||||||
|
dailyStartPendingLocalDate: "",
|
||||||
|
dailyStartLastOutcome: "",
|
||||||
scheduledStartEpochMs: 0
|
scheduledStartEpochMs: 0
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user