2168 lines
91 KiB
TypeScript
2168 lines
91 KiB
TypeScript
import fs from "node:fs";
|
|
import fsp from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { randomUUID } from "node:crypto";
|
|
import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys";
|
|
import { getMegaDebridAccountIds, mergeMegaDebridCredentialPools, parseMegaDebridAccounts } from "../shared/mega-debrid-accounts";
|
|
import { AppSettings, AppTheme, ArchiveOperationMetric, AudioStripSummary, BandwidthScheduleEntry, DailyStartOutcome, DebridAccountStatus, DebridFallbackProvider, DebridProvider, DownloadItem, DownloadStatus, FailurePhase, HistoryEntry, HistoryRetentionMode, LogStorageLocation, PackageEntry, PackagePriority, RemuxOperationMetric, SessionState, ThemePreference } from "../shared/types";
|
|
import { getProviderUsageDayKey } from "../shared/provider-daily-limits";
|
|
import { getRealDebridAccountIds, normalizeRealDebridWebAccountIds, parseRealDebridApiAccounts, serializeRealDebridApiAccounts } from "../shared/real-debrid-accounts";
|
|
import { defaultSettings } from "./constants";
|
|
import { needsPersistedSettingsRewrite, protectPersistedSettings, restorePersistedSettings } from "./credential-protection";
|
|
import { logger } from "./logger";
|
|
import { isValidLocalDate } from "./daily-start-scheduler";
|
|
import { projectPackageFailureCategory } from "./package-telemetry";
|
|
|
|
export function migrateProductUserDataDirectory(appDataPath: string): string {
|
|
const legacyPath = path.join(appDataPath, "Real-Debrid-Downloader");
|
|
const targetPath = path.join(appDataPath, "Multi-Debrid-Downloader");
|
|
if (fs.existsSync(targetPath) || !fs.existsSync(legacyPath)) {
|
|
return targetPath;
|
|
}
|
|
try {
|
|
fs.renameSync(legacyPath, targetPath);
|
|
return targetPath;
|
|
} catch {
|
|
return legacyPath;
|
|
}
|
|
}
|
|
|
|
const VALID_PRIMARY_PROVIDERS = new Set(["realdebrid", "megadebrid-api", "megadebrid-web", "bestdebrid", "alldebrid", "deepbrid", "ddownload", "onefichier", "debridlink", "linksnappy"]);
|
|
const VALID_FALLBACK_PROVIDERS = new Set(["none", "realdebrid", "megadebrid-api", "megadebrid-web", "bestdebrid", "alldebrid", "deepbrid", "ddownload", "onefichier", "debridlink", "linksnappy"]);
|
|
const VALID_CLEANUP_MODES = new Set(["none", "trash", "delete"]);
|
|
const VALID_CONFLICT_MODES = new Set(["overwrite", "skip", "rename", "ask"]);
|
|
const VALID_FINISHED_POLICIES = new Set(["never", "immediate", "on_start", "package_done"]);
|
|
const VALID_SPEED_MODES = new Set(["global", "per_download"]);
|
|
const VALID_THEMES = new Set<AppTheme>(["dark", "light"]);
|
|
const VALID_THEME_PREFERENCES = new Set<ThemePreference>(["dark", "light", "system"]);
|
|
const VALID_EXTRACT_CPU_PRIORITIES = new Set(["high", "middle", "low"]);
|
|
const VALID_HISTORY_RETENTION_MODES = new Set<HistoryRetentionMode>(["never", "session", "permanent"]);
|
|
const VALID_LOG_STORAGE_LOCATIONS = new Set<LogStorageLocation>(["appdata", "desktop"]);
|
|
const VALID_PACKAGE_PRIORITIES = new Set<string>(["high", "normal", "low"]);
|
|
const VALID_DOWNLOAD_STATUSES = new Set<DownloadStatus>([
|
|
"queued", "validating", "downloading", "paused", "reconnect_wait", "extracting", "integrity_check", "completed", "failed", "cancelled"
|
|
]);
|
|
const VALID_ITEM_PROVIDERS = new Set<DebridProvider>(["realdebrid", "megadebrid", "megadebrid-api", "megadebrid-web", "bestdebrid", "alldebrid", "deepbrid", "ddownload", "onefichier", "debridlink"]);
|
|
const VALID_ONLINE_STATUSES = new Set(["online", "offline", "checking"]);
|
|
const VALID_OPERATION_STATUSES = new Set(["completed", "failed", "cancelled"]);
|
|
const VALID_HISTORY_STATUSES = new Set(["completed", "partial", "failed", "cancelled", "deleted"]);
|
|
const VALID_FAILURE_PHASES = new Set(["download", "extract", "remux", "cleanup", "postprocess"]);
|
|
const SAFE_SESSION_ID_RE = /^[A-Za-z0-9._-]{1,128}$/;
|
|
const SAFE_OUTPUT_SIGNATURE_RE = /^[a-f0-9]{64}$/;
|
|
|
|
function asText(value: unknown): string {
|
|
return String(value ?? "").trim();
|
|
}
|
|
|
|
function normalizeSessionId(value: unknown): string {
|
|
const text = asText(value);
|
|
if (!text || !SAFE_SESSION_ID_RE.test(text)) {
|
|
return "";
|
|
}
|
|
return text;
|
|
}
|
|
|
|
function isPathInsideDir(filePath: string, dirPath: string): boolean {
|
|
try {
|
|
const resolvedFile = path.resolve(filePath);
|
|
const resolvedDir = path.resolve(dirPath);
|
|
const normalizedFile = process.platform === "win32" ? resolvedFile.toLowerCase() : resolvedFile;
|
|
const normalizedDir = process.platform === "win32" ? resolvedDir.toLowerCase() : resolvedDir;
|
|
return normalizedFile === normalizedDir || normalizedFile.startsWith(`${normalizedDir}${path.sep}`);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function normalizeSessionTargetPath(value: unknown, packageOutputDir: string): string {
|
|
const targetPath = asText(value);
|
|
if (!targetPath || !packageOutputDir || !path.isAbsolute(targetPath)) {
|
|
return "";
|
|
}
|
|
if (!isPathInsideDir(targetPath, packageOutputDir)) {
|
|
return "";
|
|
}
|
|
return path.resolve(targetPath);
|
|
}
|
|
|
|
function normalizeSessionMetadataRenameTargetPath(value: unknown, packageOutputDir: string): string {
|
|
if (!path.isAbsolute(packageOutputDir)) {
|
|
return "";
|
|
}
|
|
const targetPath = normalizeSessionTargetPath(value, packageOutputDir);
|
|
if (!targetPath) {
|
|
return "";
|
|
}
|
|
const resolvedOutputDir = path.resolve(packageOutputDir);
|
|
const normalizedTargetPath = process.platform === "win32" ? targetPath.toLowerCase() : targetPath;
|
|
const normalizedOutputDir = process.platform === "win32" ? resolvedOutputDir.toLowerCase() : resolvedOutputDir;
|
|
return normalizedTargetPath === normalizedOutputDir ? "" : targetPath;
|
|
}
|
|
|
|
function clampNumber(value: unknown, fallback: number, min: number, max: number): number {
|
|
const num = Number(value);
|
|
if (!Number.isFinite(num)) {
|
|
return fallback;
|
|
}
|
|
return Math.max(min, Math.min(max, Math.floor(num)));
|
|
}
|
|
|
|
function createScheduleId(index: number): string {
|
|
return `sched-${Date.now().toString(36)}-${index.toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
}
|
|
|
|
function normalizeBandwidthSchedules(raw: unknown): BandwidthScheduleEntry[] {
|
|
if (!Array.isArray(raw)) {
|
|
return [];
|
|
}
|
|
|
|
const normalized: BandwidthScheduleEntry[] = [];
|
|
for (let index = 0; index < raw.length; index += 1) {
|
|
const entry = raw[index];
|
|
if (!entry || typeof entry !== "object") {
|
|
continue;
|
|
}
|
|
const value = entry as Partial<BandwidthScheduleEntry>;
|
|
const rawId = typeof value.id === "string" ? value.id.trim() : "";
|
|
normalized.push({
|
|
id: rawId || createScheduleId(index),
|
|
startHour: clampNumber(value.startHour, 0, 0, 23),
|
|
endHour: clampNumber(value.endHour, 8, 0, 23),
|
|
speedLimitKbps: clampNumber(value.speedLimitKbps, 0, 0, 500000),
|
|
enabled: value.enabled === undefined ? true : Boolean(value.enabled)
|
|
});
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
function normalizeAbsoluteDir(value: unknown, fallback: string): string {
|
|
const text = asText(value);
|
|
if (!text || !path.isAbsolute(text)) {
|
|
return path.resolve(fallback);
|
|
}
|
|
return path.resolve(text);
|
|
}
|
|
|
|
function samePath(left: unknown, right: string): boolean {
|
|
const leftText = asText(left);
|
|
if (!leftText) {
|
|
return false;
|
|
}
|
|
const leftResolved = path.resolve(leftText);
|
|
const rightResolved = path.resolve(right);
|
|
return process.platform === "win32"
|
|
? leftResolved.toLowerCase() === rightResolved.toLowerCase()
|
|
: leftResolved === rightResolved;
|
|
}
|
|
|
|
function migrateLegacyDefaultDirectories(settings: AppSettings, defaults: AppSettings): AppSettings {
|
|
const legacyBaseDir = path.join(os.homedir(), "Downloads", "RealDebrid");
|
|
const legacyExtractDir = path.join(legacyBaseDir, "_entpackt");
|
|
const legacyMkvLibraryDir = path.join(legacyBaseDir, "_mkv");
|
|
const isUntouchedLegacyConfiguration = samePath(settings.outputDir, legacyBaseDir)
|
|
&& samePath(settings.extractDir, legacyExtractDir)
|
|
&& samePath(settings.mkvLibraryDir, legacyMkvLibraryDir);
|
|
if (!isUntouchedLegacyConfiguration) {
|
|
return settings;
|
|
}
|
|
return {
|
|
...settings,
|
|
outputDir: defaults.outputDir,
|
|
extractDir: defaults.extractDir,
|
|
mkvLibraryDir: defaults.mkvLibraryDir
|
|
};
|
|
}
|
|
|
|
const DEFAULT_COLUMN_ORDER = ["name", "size", "progress", "hoster", "account", "prio", "status", "speed", "availability"];
|
|
const ALL_VALID_COLUMNS = new Set([...DEFAULT_COLUMN_ORDER, "added"]);
|
|
|
|
function normalizeColumnOrder(raw: unknown, version: unknown): string[] {
|
|
if (!Array.isArray(raw) || raw.length === 0) {
|
|
return [...DEFAULT_COLUMN_ORDER];
|
|
}
|
|
const valid = ALL_VALID_COLUMNS;
|
|
const seen = new Set<string>();
|
|
const result: string[] = [];
|
|
for (const col of raw) {
|
|
if (typeof col === "string" && valid.has(col) && !seen.has(col)) {
|
|
seen.add(col);
|
|
result.push(col);
|
|
}
|
|
}
|
|
if (!seen.has("name")) {
|
|
result.unshift("name");
|
|
}
|
|
if (version !== 3 && !seen.has("availability")) {
|
|
const speedIndex = result.indexOf("speed");
|
|
result.splice(speedIndex >= 0 ? speedIndex + 1 : result.length, 0, "availability");
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function getPreferredMegaDebridProvider(megaDebridPreferApi: boolean, megaDebridApiEnabled: boolean, megaDebridWebEnabled: boolean): DebridProvider {
|
|
if (megaDebridApiEnabled && !megaDebridWebEnabled) {
|
|
return "megadebrid-api";
|
|
}
|
|
if (megaDebridWebEnabled && !megaDebridApiEnabled) {
|
|
return "megadebrid-web";
|
|
}
|
|
return megaDebridPreferApi ? "megadebrid-api" : "megadebrid-web";
|
|
}
|
|
|
|
function normalizeConfiguredProvider(raw: unknown, megaDebridPreferApi: boolean, megaDebridApiEnabled: boolean, megaDebridWebEnabled: boolean): DebridProvider | null {
|
|
const provider = String(raw ?? "").trim();
|
|
if (!provider) {
|
|
return null;
|
|
}
|
|
if (provider === "megadebrid") {
|
|
return getPreferredMegaDebridProvider(megaDebridPreferApi, megaDebridApiEnabled, megaDebridWebEnabled);
|
|
}
|
|
return VALID_PRIMARY_PROVIDERS.has(provider) ? provider as DebridProvider : null;
|
|
}
|
|
|
|
function normalizeFallbackProvider(raw: unknown, megaDebridPreferApi: boolean, megaDebridApiEnabled: boolean, megaDebridWebEnabled: boolean): DebridFallbackProvider {
|
|
const provider = String(raw ?? "").trim();
|
|
if (!provider || provider === "none") {
|
|
return "none";
|
|
}
|
|
const normalized = normalizeConfiguredProvider(provider, megaDebridPreferApi, megaDebridApiEnabled, megaDebridWebEnabled);
|
|
return normalized || "none";
|
|
}
|
|
|
|
function normalizeDisabledProviders(raw: unknown): DebridProvider[] {
|
|
if (!Array.isArray(raw)) {
|
|
return [];
|
|
}
|
|
const seen = new Set<DebridProvider>();
|
|
const result: DebridProvider[] = [];
|
|
for (const entry of raw) {
|
|
const provider = String(entry ?? "").trim();
|
|
const candidates: DebridProvider[] = provider === "megadebrid"
|
|
? ["megadebrid-api", "megadebrid-web"]
|
|
: (VALID_PRIMARY_PROVIDERS.has(provider) ? [provider as DebridProvider] : []);
|
|
for (const candidate of candidates) {
|
|
if (seen.has(candidate)) {
|
|
continue;
|
|
}
|
|
seen.add(candidate);
|
|
result.push(candidate);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function normalizeProviderByteMap(
|
|
raw: unknown,
|
|
megaDebridPreferApi: boolean,
|
|
megaDebridApiEnabled: boolean,
|
|
megaDebridWebEnabled: boolean,
|
|
mergeMode: "max" | "sum"
|
|
): Partial<Record<DebridProvider, number>> {
|
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
return {};
|
|
}
|
|
|
|
const result: Partial<Record<DebridProvider, number>> = {};
|
|
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
|
|
const provider = normalizeConfiguredProvider(key, megaDebridPreferApi, megaDebridApiEnabled, megaDebridWebEnabled);
|
|
if (!provider) {
|
|
continue;
|
|
}
|
|
const bytes = clampNumber(value, 0, 0, Number.MAX_SAFE_INTEGER);
|
|
if (bytes <= 0) {
|
|
continue;
|
|
}
|
|
if (mergeMode === "sum") {
|
|
result[provider] = (result[provider] || 0) + bytes;
|
|
} else {
|
|
result[provider] = Math.max(result[provider] || 0, bytes);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function normalizeNamedByteMap(raw: unknown, allowedKeys: readonly string[]): Record<string, number> {
|
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
return {};
|
|
}
|
|
|
|
const allowed = new Set(allowedKeys);
|
|
const result: Record<string, number> = {};
|
|
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
|
|
const normalizedKey = String(key || "").trim();
|
|
if (!normalizedKey || !allowed.has(normalizedKey)) {
|
|
continue;
|
|
}
|
|
const bytes = clampNumber(value, 0, 0, Number.MAX_SAFE_INTEGER);
|
|
if (bytes <= 0) {
|
|
continue;
|
|
}
|
|
result[normalizedKey] = bytes;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function normalizeDebridAccountStatuses(
|
|
value: unknown,
|
|
megaIds: string[],
|
|
debridLinkIds: string[],
|
|
realDebridIds: string[],
|
|
legacyRealDebridTargetId: string | null,
|
|
deepbridConfigured: boolean
|
|
): Record<string, DebridAccountStatus> {
|
|
const allowed = new Set([...megaIds, ...debridLinkIds, ...realDebridIds, ...(deepbridConfigured ? ["svc-deepbrid"] : [])]);
|
|
const result: Record<string, DebridAccountStatus> = {};
|
|
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
for (const [storedKey, raw] of Object.entries(value as Record<string, unknown>)) {
|
|
const key = storedKey === "svc-realdebrid" ? legacyRealDebridTargetId : storedKey;
|
|
if (!key || !allowed.has(key) || !raw || typeof raw !== "object") {
|
|
continue;
|
|
}
|
|
if (storedKey === "svc-realdebrid" && result[key]) {
|
|
continue;
|
|
}
|
|
const entry = raw as Partial<DebridAccountStatus>;
|
|
if (typeof entry.accountId !== "string" || typeof entry.checkedAt !== "number") {
|
|
continue;
|
|
}
|
|
const provider = key === "svc-deepbrid"
|
|
? "deepbrid"
|
|
: entry.provider === "debridlink"
|
|
? "debridlink"
|
|
: entry.provider === "realdebrid"
|
|
? "realdebrid"
|
|
: "megadebrid";
|
|
let username = typeof entry.username === "string" ? entry.username : undefined;
|
|
let email = typeof entry.email === "string" ? entry.email : undefined;
|
|
if (provider === "debridlink" && !username && email && !email.includes("@")) {
|
|
username = email;
|
|
email = undefined;
|
|
}
|
|
result[key] = {
|
|
accountId: key,
|
|
provider,
|
|
label: String(entry.label || ""),
|
|
maskedLogin: String(entry.maskedLogin || ""),
|
|
valid: Boolean(entry.valid),
|
|
isPremium: Boolean(entry.isPremium),
|
|
premiumUntilMs: typeof entry.premiumUntilMs === "number" ? entry.premiumUntilMs : null,
|
|
username,
|
|
email,
|
|
message: String(entry.message || ""),
|
|
checkedAt: entry.checkedAt
|
|
};
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function normalizeStringList(raw: unknown, allowedKeys: readonly string[]): string[] {
|
|
if (!Array.isArray(raw)) {
|
|
return [];
|
|
}
|
|
|
|
const allowed = new Set(allowedKeys);
|
|
const seen = new Set<string>();
|
|
const result: string[] = [];
|
|
for (const entry of raw) {
|
|
const normalized = String(entry || "").trim();
|
|
if (!normalized || !allowed.has(normalized) || seen.has(normalized)) {
|
|
continue;
|
|
}
|
|
seen.add(normalized);
|
|
result.push(normalized);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function normalizeHosterRouting(raw: unknown, megaDebridPreferApi: boolean, megaDebridApiEnabled: boolean, megaDebridWebEnabled: boolean): Record<string, DebridProvider> {
|
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
|
const result: Record<string, DebridProvider> = {};
|
|
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
|
|
const hoster = String(key).trim().toLowerCase();
|
|
const provider = normalizeConfiguredProvider(value, megaDebridPreferApi, megaDebridApiEnabled, megaDebridWebEnabled);
|
|
if (hoster && provider) {
|
|
result[hoster] = provider;
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function normalizeProviderOrder(
|
|
raw: unknown,
|
|
megaDebridPreferApi: boolean,
|
|
megaDebridApiEnabled: boolean,
|
|
megaDebridWebEnabled: boolean,
|
|
legacyPrimary: unknown,
|
|
legacySecondary: unknown,
|
|
legacyTertiary: unknown
|
|
): DebridProvider[] {
|
|
let list: unknown[] = [];
|
|
|
|
if (Array.isArray(raw) && raw.length > 0) {
|
|
list = raw;
|
|
} else {
|
|
const candidates = [legacyPrimary, legacySecondary, legacyTertiary].filter(
|
|
(v) => v && String(v).trim() && String(v).trim() !== "none"
|
|
);
|
|
if (candidates.length > 0) {
|
|
list = candidates;
|
|
}
|
|
}
|
|
|
|
const seen = new Set<DebridProvider>();
|
|
const result: DebridProvider[] = [];
|
|
for (const entry of list) {
|
|
const provider = normalizeConfiguredProvider(entry, megaDebridPreferApi, megaDebridApiEnabled, megaDebridWebEnabled);
|
|
if (provider && !seen.has(provider)) {
|
|
seen.add(provider);
|
|
result.push(provider);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
const DEPRECATED_UPDATE_REPO_NAMES = new Set([
|
|
"real-debrid-downloader",
|
|
"multi-debrid-downloader"
|
|
]);
|
|
|
|
function migrateUpdateRepo(raw: string, fallback: string): string {
|
|
const trimmed = raw.trim();
|
|
const repoName = trimmed.split("/").filter(Boolean).pop()?.replace(/\.git$/i, "").toLowerCase() || "";
|
|
if (!trimmed || (DEPRECATED_UPDATE_REPO_NAMES.has(repoName) && trimmed !== fallback)) {
|
|
return fallback;
|
|
}
|
|
return trimmed;
|
|
}
|
|
|
|
export function normalizeSettings(settings: AppSettings): AppSettings {
|
|
const defaults = defaultSettings();
|
|
const theme = VALID_THEMES.has(settings.theme) ? settings.theme : defaults.theme;
|
|
const themePreference = VALID_THEME_PREFERENCES.has(settings.themePreference) ? settings.themePreference : theme;
|
|
const directorySettings = migrateLegacyDefaultDirectories(settings, defaults);
|
|
const legacySuccessMode = settings.notifyOnPackageCompleted === true ? "individual" : "digest";
|
|
const notifyPackageSuccessMode = settings.notifyPackageSuccessMode === "individual" || settings.notifyPackageSuccessMode === "digest"
|
|
? settings.notifyPackageSuccessMode
|
|
: legacySuccessMode;
|
|
const currentUsageDay = getProviderUsageDayKey();
|
|
const legacyMegaLogin = asText(settings.megaLogin);
|
|
const legacyMegaPassword = asText(settings.megaPassword);
|
|
let legacyMegaCredentials = String(settings.megaCredentials ?? "").replace(/\r\n|\r/g, "\n").trim();
|
|
if (!legacyMegaCredentials && legacyMegaLogin && legacyMegaPassword) {
|
|
legacyMegaCredentials = `${legacyMegaLogin}:${legacyMegaPassword}`;
|
|
}
|
|
const megaDebridPreferApi = settings.megaDebridPreferApi !== undefined ? Boolean(settings.megaDebridPreferApi) : true;
|
|
const hasLegacyMegaCredentials = getMegaDebridAccountIds(legacyMegaCredentials).length > 0;
|
|
const requestedMegaDebridApiEnabled = settings.megaDebridApiEnabled !== undefined
|
|
? Boolean(settings.megaDebridApiEnabled)
|
|
: (hasLegacyMegaCredentials ? megaDebridPreferApi : defaults.megaDebridApiEnabled);
|
|
const requestedMegaDebridWebEnabled = settings.megaDebridWebEnabled !== undefined
|
|
? Boolean(settings.megaDebridWebEnabled)
|
|
: (hasLegacyMegaCredentials ? !megaDebridPreferApi : defaults.megaDebridWebEnabled);
|
|
const legacyMegaMode = requestedMegaDebridApiEnabled !== requestedMegaDebridWebEnabled
|
|
? requestedMegaDebridApiEnabled ? "api" : "web"
|
|
: megaDebridPreferApi ? "api" : "web";
|
|
const rawMegaDebridApiCredentials = String(settings.megaDebridApiCredentials || "").replace(/\r\n|\r/g, "\n").trim();
|
|
const rawMegaDebridWebCredentials = String(settings.megaDebridWebCredentials || "").replace(/\r\n|\r/g, "\n").trim();
|
|
const hasDedicatedMegaPools = Boolean(rawMegaDebridApiCredentials || rawMegaDebridWebCredentials) || !legacyMegaCredentials;
|
|
const megaDebridApiCredentials = hasDedicatedMegaPools
|
|
? rawMegaDebridApiCredentials
|
|
: legacyMegaMode === "api" ? legacyMegaCredentials : "";
|
|
const megaDebridWebCredentials = hasDedicatedMegaPools
|
|
? rawMegaDebridWebCredentials
|
|
: legacyMegaMode === "web" ? legacyMegaCredentials : "";
|
|
const megaDebridApiAccountIds = getMegaDebridAccountIds(megaDebridApiCredentials);
|
|
const megaDebridWebAccountIds = getMegaDebridAccountIds(megaDebridWebCredentials);
|
|
const megaCredentials = mergeMegaDebridCredentialPools(megaDebridApiCredentials, megaDebridWebCredentials);
|
|
const megaDebridAccountIds = getMegaDebridAccountIds(megaCredentials);
|
|
const primaryMegaAccount = parseMegaDebridAccounts(megaCredentials)[0];
|
|
const megaLogin = primaryMegaAccount?.login || "";
|
|
const megaPassword = primaryMegaAccount?.password || "";
|
|
const megaDebridApiEnabled = requestedMegaDebridApiEnabled && megaDebridApiAccountIds.length > 0;
|
|
const megaDebridWebEnabled = requestedMegaDebridWebEnabled && megaDebridWebAccountIds.length > 0;
|
|
const legacyMegaDisabledAccountIds = normalizeStringList(settings.megaDebridDisabledAccountIds, megaDebridAccountIds);
|
|
const megaDebridApiDisabledAccountIds = Array.isArray(settings.megaDebridApiDisabledAccountIds)
|
|
? normalizeStringList(settings.megaDebridApiDisabledAccountIds, megaDebridApiAccountIds)
|
|
: legacyMegaDisabledAccountIds.filter((id) => megaDebridApiAccountIds.includes(id));
|
|
const megaDebridWebDisabledAccountIds = Array.isArray(settings.megaDebridWebDisabledAccountIds)
|
|
? normalizeStringList(settings.megaDebridWebDisabledAccountIds, megaDebridWebAccountIds)
|
|
: legacyMegaDisabledAccountIds.filter((id) => megaDebridWebAccountIds.includes(id));
|
|
const megaDebridDisabledAccountIds = [...new Set([...megaDebridApiDisabledAccountIds, ...megaDebridWebDisabledAccountIds])];
|
|
const legacyRealDebridToken = asText(settings.token);
|
|
const hasRealDebridApiPool = Object.prototype.hasOwnProperty.call(settings, "realDebridApiTokens");
|
|
const rawRealDebridApiAccounts = hasRealDebridApiPool
|
|
? parseRealDebridApiAccounts(String(settings.realDebridApiTokens ?? ""))
|
|
: legacyRealDebridToken ? parseRealDebridApiAccounts(legacyRealDebridToken) : [];
|
|
const realDebridApiTokens = serializeRealDebridApiAccounts(rawRealDebridApiAccounts.map((account) => ({
|
|
id: account.id.startsWith("rda_legacy_") ? `rda_${randomUUID().replace(/-/g, "")}` : account.id,
|
|
token: account.token
|
|
})));
|
|
const hasRealDebridWebPool = Object.prototype.hasOwnProperty.call(settings, "realDebridWebAccountIds");
|
|
let realDebridWebAccountIds = normalizeRealDebridWebAccountIds(settings.realDebridWebAccountIds);
|
|
if (!hasRealDebridWebPool && Boolean(settings.realDebridUseWebLogin)) {
|
|
realDebridWebAccountIds = ["rdw_legacy"];
|
|
}
|
|
const realDebridAccountIds = getRealDebridAccountIds({
|
|
realDebridApiTokens,
|
|
realDebridWebAccountIds,
|
|
realDebridDisabledAccountIds: []
|
|
});
|
|
const realDebridDisabledAccountIds = normalizeStringList(settings.realDebridDisabledAccountIds, realDebridAccountIds);
|
|
const legacyRealDebridTargetId = Boolean(settings.realDebridUseWebLogin) && realDebridWebAccountIds.includes("rdw_legacy")
|
|
? "rdw_legacy"
|
|
: (parseRealDebridApiAccounts(realDebridApiTokens)[0]?.id || realDebridWebAccountIds[0] || null);
|
|
const providerDailyUsageDayRaw = asText(settings.providerDailyUsageDay);
|
|
const providerDailyUsageDay = /^\d{4}-\d{2}-\d{2}$/.test(providerDailyUsageDayRaw)
|
|
? providerDailyUsageDayRaw
|
|
: currentUsageDay;
|
|
const debridLinkApiKeyIds = getDebridLinkApiKeyIds(String(settings.debridLinkApiKeys ?? ""));
|
|
const deepbridApiKey = asText(settings.deepbridApiKey);
|
|
const providerDailyUsageBytes = normalizeProviderByteMap(
|
|
settings.providerDailyUsageBytes,
|
|
megaDebridPreferApi, megaDebridApiEnabled, megaDebridWebEnabled,
|
|
"sum"
|
|
);
|
|
const providerTotalUsageBytes = normalizeProviderByteMap(
|
|
settings.providerTotalUsageBytes,
|
|
megaDebridPreferApi, megaDebridApiEnabled, megaDebridWebEnabled,
|
|
"sum"
|
|
);
|
|
const debridLinkApiKeyDailyLimitBytes = normalizeNamedByteMap(
|
|
settings.debridLinkApiKeyDailyLimitBytes,
|
|
debridLinkApiKeyIds
|
|
);
|
|
const debridLinkApiKeyDailyUsageBytes = normalizeNamedByteMap(
|
|
settings.debridLinkApiKeyDailyUsageBytes,
|
|
debridLinkApiKeyIds
|
|
);
|
|
const debridLinkApiKeyTotalUsageBytes = normalizeNamedByteMap(
|
|
settings.debridLinkApiKeyTotalUsageBytes,
|
|
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 = {
|
|
language: settings.language === "de" ? "de" : "en",
|
|
token: asText(settings.token),
|
|
realDebridUseWebLogin: Boolean(settings.realDebridUseWebLogin),
|
|
realDebridApiTokens,
|
|
realDebridWebAccountIds,
|
|
realDebridDisabledAccountIds,
|
|
realDebridAccountDailyLimitBytes: normalizeNamedByteMap(settings.realDebridAccountDailyLimitBytes, realDebridAccountIds),
|
|
realDebridAccountDailyUsageBytes: providerDailyUsageDay === currentUsageDay
|
|
? normalizeNamedByteMap(settings.realDebridAccountDailyUsageBytes, realDebridAccountIds)
|
|
: {},
|
|
realDebridAccountTotalUsageBytes: normalizeNamedByteMap(settings.realDebridAccountTotalUsageBytes, realDebridAccountIds),
|
|
megaLogin,
|
|
megaPassword,
|
|
megaCredentials,
|
|
megaDebridApiCredentials,
|
|
megaDebridWebCredentials,
|
|
megaDebridApiEnabled,
|
|
megaDebridWebEnabled,
|
|
megaDebridPreferApi,
|
|
bestToken: asText(settings.bestToken),
|
|
bestDebridUseWebLogin: Boolean(settings.bestDebridUseWebLogin),
|
|
allDebridToken: asText(settings.allDebridToken),
|
|
allDebridUseWebLogin: Boolean(settings.allDebridUseWebLogin),
|
|
deepbridApiKey,
|
|
ddownloadLogin: asText(settings.ddownloadLogin),
|
|
ddownloadPassword: asText(settings.ddownloadPassword),
|
|
oneFichierApiKey: asText(settings.oneFichierApiKey),
|
|
debridLinkApiKeys: String(settings.debridLinkApiKeys ?? "").replace(/\r\n|\r/g, "\n").trim(),
|
|
debridLinkDisabledKeyIds,
|
|
linkSnappyLogin: asText(settings.linkSnappyLogin),
|
|
linkSnappyPassword: asText(settings.linkSnappyPassword),
|
|
archivePasswordList: String(settings.archivePasswordList ?? "").replace(/\r\n|\r/g, "\n"),
|
|
rememberToken: Boolean(settings.rememberToken),
|
|
providerOrder: normalizeProviderOrder(
|
|
settings.providerOrder,
|
|
megaDebridPreferApi, megaDebridApiEnabled, megaDebridWebEnabled,
|
|
settings.providerPrimary, settings.providerSecondary, settings.providerTertiary
|
|
),
|
|
providerPrimary: normalizeConfiguredProvider(settings.providerPrimary, megaDebridPreferApi, megaDebridApiEnabled, megaDebridWebEnabled) || defaults.providerPrimary,
|
|
providerSecondary: normalizeFallbackProvider(settings.providerSecondary, megaDebridPreferApi, megaDebridApiEnabled, megaDebridWebEnabled),
|
|
providerTertiary: normalizeFallbackProvider(settings.providerTertiary, megaDebridPreferApi, megaDebridApiEnabled, megaDebridWebEnabled),
|
|
autoProviderFallback: Boolean(settings.autoProviderFallback),
|
|
outputDir: normalizeAbsoluteDir(directorySettings.outputDir, defaults.outputDir),
|
|
createWorkDirectoriesOnStartup: settings.createWorkDirectoriesOnStartup !== undefined
|
|
? Boolean(settings.createWorkDirectoriesOnStartup)
|
|
: defaults.createWorkDirectoriesOnStartup,
|
|
packageName: asText(settings.packageName),
|
|
autoExtract: Boolean(settings.autoExtract),
|
|
autoRename4sf4sj: Boolean(settings.autoRename4sf4sj),
|
|
keepGermanAudioOnly: Boolean(settings.keepGermanAudioOnly),
|
|
germanAudioMode: settings.germanAudioMode === "first" ? "first" : "tag",
|
|
extractDir: normalizeAbsoluteDir(directorySettings.extractDir, defaults.extractDir),
|
|
collectMkvToLibrary: Boolean(settings.collectMkvToLibrary),
|
|
mkvLibraryDir: normalizeAbsoluteDir(directorySettings.mkvLibraryDir, defaults.mkvLibraryDir),
|
|
createExtractSubfolder: Boolean(settings.createExtractSubfolder),
|
|
hybridExtract: Boolean(settings.hybridExtract),
|
|
cleanupMode: settings.cleanupMode,
|
|
extractConflictMode: settings.extractConflictMode,
|
|
removeLinkFilesAfterExtract: Boolean(settings.removeLinkFilesAfterExtract),
|
|
removeSamplesAfterExtract: Boolean(settings.removeSamplesAfterExtract),
|
|
enableIntegrityCheck: Boolean(settings.enableIntegrityCheck),
|
|
autoResumeOnStart: Boolean(settings.autoResumeOnStart),
|
|
autoReconnect: Boolean(settings.autoReconnect),
|
|
maxParallel: clampNumber(settings.maxParallel, defaults.maxParallel, 1, 50),
|
|
maxParallelExtract: clampNumber(settings.maxParallelExtract, defaults.maxParallelExtract, 1, 8),
|
|
retryLimit: clampNumber(settings.retryLimit, defaults.retryLimit, 0, 99),
|
|
offlineSkipScope: settings.offlineSkipScope === "package" ? "package" : "archive",
|
|
reconnectWaitSeconds: clampNumber(settings.reconnectWaitSeconds, defaults.reconnectWaitSeconds, 10, 600),
|
|
completedCleanupPolicy: settings.completedCleanupPolicy,
|
|
speedLimitEnabled: Boolean(settings.speedLimitEnabled),
|
|
speedLimitKbps: clampNumber(settings.speedLimitKbps, defaults.speedLimitKbps, 0, 500000),
|
|
speedLimitMode: settings.speedLimitMode,
|
|
proxyDownloadEnabled: Boolean(settings.proxyDownloadEnabled),
|
|
proxyListPath: asText(settings.proxyListPath).slice(0, 4096),
|
|
proxyApiProxyIndex: clampNumber(settings.proxyApiProxyIndex, defaults.proxyApiProxyIndex, 1, 100000),
|
|
proxyConnectionsPerDownload: clampNumber(settings.proxyConnectionsPerDownload, defaults.proxyConnectionsPerDownload, 2, 80),
|
|
autoUpdateCheck: Boolean(settings.autoUpdateCheck),
|
|
updateRepo: migrateUpdateRepo(asText(settings.updateRepo), defaults.updateRepo),
|
|
clipboardWatch: Boolean(settings.clipboardWatch),
|
|
minimizeToTray: Boolean(settings.minimizeToTray),
|
|
logStorageLocation: VALID_LOG_STORAGE_LOCATIONS.has(settings.logStorageLocation)
|
|
? settings.logStorageLocation
|
|
: defaults.logStorageLocation,
|
|
collapseNewPackages: settings.collapseNewPackages !== undefined ? Boolean(settings.collapseNewPackages) : defaults.collapseNewPackages,
|
|
animatePackageDisclosure: settings.animatePackageDisclosure !== undefined ? Boolean(settings.animatePackageDisclosure) : defaults.animatePackageDisclosure,
|
|
historyRetentionMode: VALID_HISTORY_RETENTION_MODES.has(settings.historyRetentionMode)
|
|
? settings.historyRetentionMode
|
|
: defaults.historyRetentionMode,
|
|
historyMaxEntries: clampNumber(settings.historyMaxEntries, defaults.historyMaxEntries, 50, 100000),
|
|
historyMaxAgeDays: clampNumber(settings.historyMaxAgeDays, defaults.historyMaxAgeDays, 0, 3650),
|
|
accountListShowDetailedDebridLinkKeys: settings.accountListShowDetailedDebridLinkKeys !== undefined
|
|
? Boolean(settings.accountListShowDetailedDebridLinkKeys)
|
|
: defaults.accountListShowDetailedDebridLinkKeys,
|
|
autoSortPackagesByProgress: settings.autoSortPackagesByProgress !== undefined ? Boolean(settings.autoSortPackagesByProgress) : defaults.autoSortPackagesByProgress,
|
|
autoSkipExtracted: settings.autoSkipExtracted !== undefined ? Boolean(settings.autoSkipExtracted) : defaults.autoSkipExtracted,
|
|
hideExtractedItems: settings.hideExtractedItems !== undefined ? Boolean(settings.hideExtractedItems) : defaults.hideExtractedItems,
|
|
confirmDeleteSelection: settings.confirmDeleteSelection !== undefined ? Boolean(settings.confirmDeleteSelection) : defaults.confirmDeleteSelection,
|
|
backupIncludeDownloads: settings.backupIncludeDownloads !== undefined ? Boolean(settings.backupIncludeDownloads) : defaults.backupIncludeDownloads,
|
|
backupIncludeRemoteDiagnostics: settings.backupIncludeRemoteDiagnostics !== undefined ? Boolean(settings.backupIncludeRemoteDiagnostics) : defaults.backupIncludeRemoteDiagnostics,
|
|
notifyUrl: asText(settings.notifyUrl) || defaults.notifyUrl,
|
|
notifyMention: asText(settings.notifyMention) || defaults.notifyMention,
|
|
notifyOnPackageCompleted: settings.notifyOnPackageCompleted !== undefined ? Boolean(settings.notifyOnPackageCompleted) : defaults.notifyOnPackageCompleted,
|
|
notifyOnPackageFailed: settings.notifyOnPackageFailed !== undefined ? Boolean(settings.notifyOnPackageFailed) : defaults.notifyOnPackageFailed,
|
|
notifyOnRunFinished: settings.notifyOnRunFinished !== undefined ? Boolean(settings.notifyOnRunFinished) : defaults.notifyOnRunFinished,
|
|
notifyPackageSuccessMode,
|
|
notifyOnRemainingBelow: settings.notifyOnRemainingBelow !== undefined ? Boolean(settings.notifyOnRemainingBelow) : defaults.notifyOnRemainingBelow,
|
|
notifyRemainingThresholdGb: clampNumber(settings.notifyRemainingThresholdGb, 50, 1, 100000),
|
|
notifyOnDownloadStall: settings.notifyOnDownloadStall !== undefined ? Boolean(settings.notifyOnDownloadStall) : defaults.notifyOnDownloadStall,
|
|
notifyStallAfterSeconds: clampNumber(settings.notifyStallAfterSeconds, 90, 60, 3600),
|
|
notifyStallCooldownMinutes: clampNumber(settings.notifyStallCooldownMinutes, 10, 5, 1440),
|
|
notifyOnDownloadRecovery: settings.notifyOnDownloadRecovery !== undefined ? Boolean(settings.notifyOnDownloadRecovery) : defaults.notifyOnDownloadRecovery,
|
|
totalDownloadedAllTime: typeof settings.totalDownloadedAllTime === "number" && settings.totalDownloadedAllTime >= 0 ? settings.totalDownloadedAllTime : defaults.totalDownloadedAllTime,
|
|
totalCompletedFilesAllTime: typeof settings.totalCompletedFilesAllTime === "number" && settings.totalCompletedFilesAllTime >= 0 ? settings.totalCompletedFilesAllTime : defaults.totalCompletedFilesAllTime,
|
|
totalRuntimeAllTimeMs: typeof settings.totalRuntimeAllTimeMs === "number" && settings.totalRuntimeAllTimeMs >= 0 ? settings.totalRuntimeAllTimeMs : defaults.totalRuntimeAllTimeMs,
|
|
theme,
|
|
themePreference,
|
|
bandwidthSchedules: normalizeBandwidthSchedules(settings.bandwidthSchedules),
|
|
columnOrder: normalizeColumnOrder(settings.columnOrder, settings.columnOrderVersion),
|
|
columnOrderVersion: 3,
|
|
extractCpuPriority: settings.extractCpuPriority,
|
|
autoExtractWhenStopped: settings.autoExtractWhenStopped !== undefined ? Boolean(settings.autoExtractWhenStopped) : defaults.autoExtractWhenStopped,
|
|
disabledProviders: normalizeDisabledProviders(settings.disabledProviders),
|
|
hosterRouting: normalizeHosterRouting(settings.hosterRouting, megaDebridPreferApi, megaDebridApiEnabled, megaDebridWebEnabled),
|
|
providerDailyLimitBytes: normalizeProviderByteMap(
|
|
settings.providerDailyLimitBytes,
|
|
megaDebridPreferApi, megaDebridApiEnabled, megaDebridWebEnabled,
|
|
"max"
|
|
),
|
|
providerDailyUsageBytes: providerDailyUsageDay === currentUsageDay ? providerDailyUsageBytes : {},
|
|
providerTotalUsageBytes,
|
|
debridLinkApiKeyDailyLimitBytes,
|
|
debridLinkApiKeyDailyUsageBytes: providerDailyUsageDay === currentUsageDay ? debridLinkApiKeyDailyUsageBytes : {},
|
|
debridLinkApiKeyTotalUsageBytes,
|
|
megaDebridDisabledAccountIds,
|
|
megaDebridApiDisabledAccountIds,
|
|
megaDebridWebDisabledAccountIds,
|
|
megaDebridAccountDailyLimitBytes: normalizeNamedByteMap(settings.megaDebridAccountDailyLimitBytes, megaDebridAccountIds),
|
|
megaDebridAccountDailyUsageBytes: providerDailyUsageDay === currentUsageDay
|
|
? normalizeNamedByteMap(settings.megaDebridAccountDailyUsageBytes, megaDebridAccountIds)
|
|
: {},
|
|
megaDebridAccountTotalUsageBytes: normalizeNamedByteMap(settings.megaDebridAccountTotalUsageBytes, megaDebridAccountIds),
|
|
debridAccountStatuses: normalizeDebridAccountStatuses(
|
|
settings.debridAccountStatuses,
|
|
megaDebridAccountIds,
|
|
debridLinkApiKeyIds,
|
|
realDebridAccountIds,
|
|
legacyRealDebridTargetId,
|
|
Boolean(deepbridApiKey)
|
|
),
|
|
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)
|
|
};
|
|
|
|
if (!VALID_PRIMARY_PROVIDERS.has(normalized.providerPrimary)) {
|
|
normalized.providerPrimary = defaults.providerPrimary;
|
|
}
|
|
if (!VALID_FALLBACK_PROVIDERS.has(normalized.providerSecondary)) {
|
|
normalized.providerSecondary = "none";
|
|
}
|
|
if (!VALID_FALLBACK_PROVIDERS.has(normalized.providerTertiary)) {
|
|
normalized.providerTertiary = "none";
|
|
}
|
|
if (normalized.providerSecondary === normalized.providerPrimary) {
|
|
normalized.providerSecondary = "none";
|
|
}
|
|
if (normalized.providerTertiary === normalized.providerPrimary || normalized.providerTertiary === normalized.providerSecondary) {
|
|
normalized.providerTertiary = "none";
|
|
}
|
|
if (!VALID_CLEANUP_MODES.has(normalized.cleanupMode)) {
|
|
normalized.cleanupMode = defaults.cleanupMode;
|
|
}
|
|
if (!VALID_CONFLICT_MODES.has(normalized.extractConflictMode)) {
|
|
normalized.extractConflictMode = defaults.extractConflictMode;
|
|
}
|
|
if (!VALID_FINISHED_POLICIES.has(normalized.completedCleanupPolicy)) {
|
|
normalized.completedCleanupPolicy = defaults.completedCleanupPolicy;
|
|
}
|
|
if (!VALID_SPEED_MODES.has(normalized.speedLimitMode)) {
|
|
normalized.speedLimitMode = defaults.speedLimitMode;
|
|
}
|
|
if (!VALID_EXTRACT_CPU_PRIORITIES.has(normalized.extractCpuPriority)) {
|
|
normalized.extractCpuPriority = defaults.extractCpuPriority;
|
|
}
|
|
|
|
return normalized;
|
|
}
|
|
|
|
export interface StoragePaths {
|
|
baseDir: string;
|
|
configFile: string;
|
|
sessionFile: string;
|
|
historyFile: string;
|
|
statisticsFile: string;
|
|
notificationOutboxFile: string;
|
|
notificationHealthFile: string;
|
|
collectorFile: string;
|
|
}
|
|
|
|
export function createStoragePaths(baseDir: string): StoragePaths {
|
|
return {
|
|
baseDir,
|
|
configFile: path.join(baseDir, "rd_downloader_config.json"),
|
|
sessionFile: path.join(baseDir, "rd_session_state.json"),
|
|
historyFile: path.join(baseDir, "rd_history.json"),
|
|
statisticsFile: path.join(baseDir, "rd_statistics.json"),
|
|
notificationOutboxFile: path.join(baseDir, "rd_notification_outbox.json"),
|
|
notificationHealthFile: path.join(baseDir, "rd_notification_health.json"),
|
|
collectorFile: path.join(baseDir, "rd_collector_state.json")
|
|
};
|
|
}
|
|
|
|
export function createFileRollback(filePaths: readonly string[]): () => void {
|
|
const snapshots = [...new Set(filePaths)].map((filePath) => ({
|
|
filePath,
|
|
payload: fs.existsSync(filePath) ? fs.readFileSync(filePath) : null
|
|
}));
|
|
return () => {
|
|
let firstError: unknown = null;
|
|
for (const snapshot of snapshots) {
|
|
try {
|
|
if (snapshot.payload === null) {
|
|
fs.rmSync(snapshot.filePath, { force: true });
|
|
continue;
|
|
}
|
|
fs.mkdirSync(path.dirname(snapshot.filePath), { recursive: true });
|
|
const tempPath = `${snapshot.filePath}.${process.pid}.${randomUUID()}.rollback.tmp`;
|
|
try {
|
|
fs.writeFileSync(tempPath, snapshot.payload);
|
|
syncRenameWithExdevFallback(tempPath, snapshot.filePath);
|
|
} catch (error) {
|
|
try { fs.rmSync(tempPath, { force: true }); } catch { }
|
|
throw error;
|
|
}
|
|
} catch (error) {
|
|
firstError ??= error;
|
|
}
|
|
}
|
|
if (firstError) {
|
|
throw firstError;
|
|
}
|
|
};
|
|
}
|
|
|
|
function ensureBaseDir(baseDir: string): void {
|
|
try {
|
|
fs.mkdirSync(baseDir, { recursive: true });
|
|
} catch (error) {
|
|
const code = (error as NodeJS.ErrnoException)?.code || "";
|
|
if (code === "EACCES" || code === "EPERM") {
|
|
logger.error(`AppData-Ordner kann nicht erstellt werden (${code}): ${baseDir} - pruefe Schreibrechte fuer Benutzer ${process.env.USERNAME || process.env.USER || "?"}`);
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function safeJsonReplacer(_key: string, value: unknown): unknown {
|
|
if (typeof value === "number" && !Number.isFinite(value)) {
|
|
return null;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
return null;
|
|
}
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
function normalizeAudioStripSummary(raw: unknown): AudioStripSummary | undefined {
|
|
const parsed = asRecord(raw);
|
|
if (!parsed) {
|
|
return undefined;
|
|
}
|
|
const files = Array.isArray(parsed.files)
|
|
? parsed.files.slice(0, 100).flatMap((entry) => {
|
|
const file = asRecord(entry);
|
|
if (!file) {
|
|
return [];
|
|
}
|
|
const name = asText(file.name);
|
|
if (!name) {
|
|
return [];
|
|
}
|
|
const languages = asText(file.languages);
|
|
return [{ name, action: asText(file.action), reason: asText(file.reason), ...(languages ? { languages } : {}) }];
|
|
})
|
|
: [];
|
|
return {
|
|
at: clampNumber(parsed.at, 0, 0, Number.MAX_SAFE_INTEGER),
|
|
candidates: clampNumber(parsed.candidates, 0, 0, 1_000_000),
|
|
remuxed: clampNumber(parsed.remuxed, 0, 0, 1_000_000),
|
|
keptSingle: clampNumber(parsed.keptSingle, 0, 0, 1_000_000),
|
|
skippedNoGerman: clampNumber(parsed.skippedNoGerman, 0, 0, 1_000_000),
|
|
skippedNoTool: clampNumber(parsed.skippedNoTool, 0, 0, 1_000_000),
|
|
failed: clampNumber(parsed.failed, 0, 0, 1_000_000),
|
|
files
|
|
};
|
|
}
|
|
|
|
function normalizeFailureCategory(phase: FailurePhase, detail: unknown): string {
|
|
const value = asText(detail);
|
|
return value ? projectPackageFailureCategory(phase, value) : "";
|
|
}
|
|
|
|
function normalizeArchiveOperations(raw: unknown): ArchiveOperationMetric[] {
|
|
if (!Array.isArray(raw)) {
|
|
return [];
|
|
}
|
|
return raw.slice(0, 10_000).flatMap((value) => {
|
|
const operation = asRecord(value);
|
|
if (!operation) {
|
|
return [];
|
|
}
|
|
const id = normalizeSessionId(operation.id);
|
|
const status = asText(operation.status);
|
|
if (!id || !VALID_OPERATION_STATUSES.has(status)) {
|
|
return [];
|
|
}
|
|
return [{
|
|
id,
|
|
name: asText(operation.name),
|
|
itemIds: Array.isArray(operation.itemIds)
|
|
? operation.itemIds.map(normalizeSessionId).filter(Boolean).slice(0, 100_000)
|
|
: [],
|
|
partCount: clampNumber(operation.partCount, 0, 0, 100_000),
|
|
startedAt: clampNumber(operation.startedAt, 0, 0, Number.MAX_SAFE_INTEGER),
|
|
completedAt: clampNumber(operation.completedAt, 0, 0, Number.MAX_SAFE_INTEGER),
|
|
durationMs: clampNumber(operation.durationMs, 0, 0, Number.MAX_SAFE_INTEGER),
|
|
status: status as ArchiveOperationMetric["status"],
|
|
errorCategory: normalizeFailureCategory("extract", operation.errorCategory)
|
|
}];
|
|
});
|
|
}
|
|
|
|
function normalizeRemuxOperations(raw: unknown): RemuxOperationMetric[] {
|
|
if (!Array.isArray(raw)) {
|
|
return [];
|
|
}
|
|
return raw.slice(0, 10_000).flatMap((value) => {
|
|
const operation = asRecord(value);
|
|
if (!operation) {
|
|
return [];
|
|
}
|
|
const id = normalizeSessionId(operation.id);
|
|
const status = asText(operation.status);
|
|
if (!id || !VALID_OPERATION_STATUSES.has(status)) {
|
|
return [];
|
|
}
|
|
return [{
|
|
id,
|
|
fileName: asText(operation.fileName),
|
|
startedAt: clampNumber(operation.startedAt, 0, 0, Number.MAX_SAFE_INTEGER),
|
|
completedAt: clampNumber(operation.completedAt, 0, 0, Number.MAX_SAFE_INTEGER),
|
|
durationMs: clampNumber(operation.durationMs, 0, 0, Number.MAX_SAFE_INTEGER),
|
|
status: status as RemuxOperationMetric["status"],
|
|
errorCategory: normalizeFailureCategory("remux", operation.errorCategory)
|
|
}];
|
|
});
|
|
}
|
|
|
|
function optionalClampedNumber(record: Record<string, unknown>, key: string, max = Number.MAX_SAFE_INTEGER): number | undefined {
|
|
return Object.prototype.hasOwnProperty.call(record, key)
|
|
? clampNumber(record[key], 0, 0, max)
|
|
: undefined;
|
|
}
|
|
|
|
function migrateLegacyMegaEnableFlags(parsed: AppSettings): AppSettings {
|
|
if (parsed.megaDebridApiEnabled !== undefined || parsed.megaDebridWebEnabled !== undefined) {
|
|
return parsed;
|
|
}
|
|
const hasMegaCreds = Boolean(
|
|
getMegaDebridAccountIds(asText(parsed.megaCredentials), asText(parsed.megaPassword)).length
|
|
|| (asText(parsed.megaLogin) && asText(parsed.megaPassword))
|
|
);
|
|
if (!hasMegaCreds) {
|
|
return parsed;
|
|
}
|
|
const preferApi = parsed.megaDebridPreferApi !== undefined ? Boolean(parsed.megaDebridPreferApi) : true;
|
|
return { ...parsed, megaDebridApiEnabled: preferApi, megaDebridWebEnabled: !preferApi };
|
|
}
|
|
|
|
const PERSISTED_SETTINGS_KEYS = new Set(Object.keys(defaultSettings()));
|
|
|
|
function isPersistedSettingsEnvelope(value: unknown): boolean {
|
|
const parsed = asRecord(value);
|
|
return Boolean(parsed && Object.keys(parsed).some((key) => PERSISTED_SETTINGS_KEYS.has(key)));
|
|
}
|
|
|
|
interface LoadedSettingsFile {
|
|
settings: AppSettings;
|
|
needsCredentialRewrite: boolean;
|
|
}
|
|
|
|
function readSettingsFile(filePath: string): LoadedSettingsFile | null {
|
|
try {
|
|
const raw = JSON.parse(fs.readFileSync(filePath, "utf8")) as unknown;
|
|
if (!isPersistedSettingsEnvelope(raw)) {
|
|
logger.error(`Settings-Datei beschädigt (Struktur ungültig): ${filePath}`);
|
|
return null;
|
|
}
|
|
const parsed = raw as AppSettings;
|
|
const needsCredentialRewrite = needsPersistedSettingsRewrite(parsed);
|
|
const restored = restorePersistedSettings(parsed);
|
|
const migratedLanguage = (restored as Partial<AppSettings>).language === undefined ? "de" : restored.language;
|
|
const migrated = migrateLegacyMegaEnableFlags(restored);
|
|
const mergedInput = {
|
|
...defaultSettings(),
|
|
...migrated,
|
|
language: migratedLanguage,
|
|
themePreference: Object.prototype.hasOwnProperty.call(parsed, "themePreference")
|
|
? migrated.themePreference
|
|
: VALID_THEMES.has(migrated.theme) ? migrated.theme : defaultSettings().themePreference,
|
|
columnOrderVersion: parsed.columnOrderVersion
|
|
} as AppSettings;
|
|
if (!Object.prototype.hasOwnProperty.call(parsed, "megaDebridApiDisabledAccountIds")) {
|
|
delete (mergedInput as Partial<AppSettings>).megaDebridApiDisabledAccountIds;
|
|
}
|
|
if (!Object.prototype.hasOwnProperty.call(parsed, "megaDebridWebDisabledAccountIds")) {
|
|
delete (mergedInput as Partial<AppSettings>).megaDebridWebDisabledAccountIds;
|
|
}
|
|
if (!Object.prototype.hasOwnProperty.call(parsed, "realDebridApiTokens")) {
|
|
delete (mergedInput as Partial<AppSettings>).realDebridApiTokens;
|
|
}
|
|
if (!Object.prototype.hasOwnProperty.call(parsed, "realDebridWebAccountIds")) {
|
|
delete (mergedInput as Partial<AppSettings>).realDebridWebAccountIds;
|
|
}
|
|
if (!Object.prototype.hasOwnProperty.call(parsed, "notifyPackageSuccessMode")) {
|
|
delete (mergedInput as Partial<AppSettings>).notifyPackageSuccessMode;
|
|
}
|
|
const merged = normalizeSettings(mergedInput);
|
|
return { settings: merged, needsCredentialRewrite };
|
|
} catch (error) {
|
|
const code = (error as NodeJS.ErrnoException)?.code || "";
|
|
if (code === "ENOENT") {
|
|
} else if (code === "EACCES" || code === "EPERM") {
|
|
logger.error(`Settings-Datei nicht zugreifbar (${code}): ${filePath} - pruefe Datei-/Ordner-Berechtigungen fuer Benutzer ${process.env.USERNAME || process.env.USER || "?"}`);
|
|
} else {
|
|
logger.warn(`Settings-Datei nicht lesbar: ${filePath}: ${String(error)}`);
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function normalizeLoadedSession(raw: unknown): SessionState {
|
|
const fallback = emptySession();
|
|
const parsed = asRecord(raw);
|
|
if (!parsed) {
|
|
return fallback;
|
|
}
|
|
|
|
const now = Date.now();
|
|
const itemsById: Record<string, DownloadItem> = {};
|
|
const rawItems = asRecord(parsed.items) ?? {};
|
|
for (const [entryId, rawItem] of Object.entries(rawItems)) {
|
|
const item = asRecord(rawItem);
|
|
if (!item) {
|
|
continue;
|
|
}
|
|
const id = normalizeSessionId(item.id) || normalizeSessionId(entryId);
|
|
const packageId = normalizeSessionId(item.packageId);
|
|
const url = asText(item.url);
|
|
if (!id || !packageId || !url) {
|
|
continue;
|
|
}
|
|
|
|
const statusRaw = asText(item.status) as DownloadStatus;
|
|
const status: DownloadStatus = VALID_DOWNLOAD_STATUSES.has(statusRaw) ? statusRaw : "queued";
|
|
const providerRaw = asText(item.provider) as DebridProvider;
|
|
|
|
const onlineStatusRaw = asText(item.onlineStatus);
|
|
|
|
itemsById[id] = {
|
|
id,
|
|
packageId,
|
|
url,
|
|
provider: VALID_ITEM_PROVIDERS.has(providerRaw) ? providerRaw : null,
|
|
providerLabel: asText(item.providerLabel) || undefined,
|
|
providerAccountId: asText(item.providerAccountId) || undefined,
|
|
providerAccountLabel: asText(item.providerAccountLabel) || undefined,
|
|
status,
|
|
retries: clampNumber(item.retries, 0, 0, 1_000_000),
|
|
speedBps: clampNumber(item.speedBps, 0, 0, 10_000_000_000),
|
|
downloadedBytes: clampNumber(item.downloadedBytes, 0, 0, 10_000_000_000_000),
|
|
totalBytes: item.totalBytes == null ? null : clampNumber(item.totalBytes, 0, 0, 10_000_000_000_000),
|
|
progressPercent: clampNumber(item.progressPercent, 0, 0, 100),
|
|
fileName: asText(item.fileName) || "download.bin",
|
|
targetPath: asText(item.targetPath),
|
|
metadataRenameTargetPath: asText(item.metadataRenameTargetPath) || undefined,
|
|
resumable: item.resumable === undefined ? true : Boolean(item.resumable),
|
|
attempts: clampNumber(item.attempts, 0, 0, 10_000),
|
|
archiveRecoveryRedownloads: item.archiveRecoveryRedownloads === undefined
|
|
? undefined
|
|
: clampNumber(item.archiveRecoveryRedownloads, 0, 0, 1),
|
|
lastError: asText(item.lastError),
|
|
fullStatus: asText(item.fullStatus),
|
|
onlineStatus: VALID_ONLINE_STATUSES.has(onlineStatusRaw) ? onlineStatusRaw as "online" | "offline" | "checking" : undefined,
|
|
onlineCheckedAt: item.onlineCheckedAt === undefined
|
|
? undefined
|
|
: clampNumber(item.onlineCheckedAt, 0, 0, now),
|
|
createdAt: clampNumber(item.createdAt, now, 0, Number.MAX_SAFE_INTEGER),
|
|
updatedAt: clampNumber(item.updatedAt, now, 0, Number.MAX_SAFE_INTEGER)
|
|
};
|
|
}
|
|
|
|
const packagesById: Record<string, PackageEntry> = {};
|
|
const rawPackages = asRecord(parsed.packages) ?? {};
|
|
for (const [entryId, rawPkg] of Object.entries(rawPackages)) {
|
|
const pkg = asRecord(rawPkg);
|
|
if (!pkg) {
|
|
continue;
|
|
}
|
|
const id = normalizeSessionId(pkg.id) || normalizeSessionId(entryId);
|
|
if (!id) {
|
|
continue;
|
|
}
|
|
const statusRaw = asText(pkg.status) as DownloadStatus;
|
|
const status: DownloadStatus = VALID_DOWNLOAD_STATUSES.has(statusRaw) ? statusRaw : "queued";
|
|
const rawItemIds = Array.isArray(pkg.itemIds) ? pkg.itemIds : [];
|
|
packagesById[id] = {
|
|
id,
|
|
name: asText(pkg.name) || "Paket",
|
|
outputDir: asText(pkg.outputDir),
|
|
extractDir: asText(pkg.extractDir),
|
|
status,
|
|
itemIds: rawItemIds
|
|
.map((value) => normalizeSessionId(value))
|
|
.filter((value) => value.length > 0),
|
|
cancelled: Boolean(pkg.cancelled),
|
|
enabled: pkg.enabled === undefined ? true : Boolean(pkg.enabled),
|
|
priority: VALID_PACKAGE_PRIORITIES.has(asText(pkg.priority)) ? asText(pkg.priority) as PackagePriority : "normal",
|
|
audioStripSummary: normalizeAudioStripSummary(pkg.audioStripSummary),
|
|
cleanedCompletedItemCount: clampNumber(pkg.cleanedCompletedItemCount, 0, 0, 1_000_000),
|
|
cleanedExtractedItemCount: clampNumber(pkg.cleanedExtractedItemCount, 0, 0, 1_000_000),
|
|
cleanedDownloadedBytes: clampNumber(pkg.cleanedDownloadedBytes, 0, 0, 10_000_000_000_000),
|
|
cleanedTotalBytes: clampNumber(pkg.cleanedTotalBytes, 0, 0, 10_000_000_000_000),
|
|
cleanedUrls: Array.isArray(pkg.cleanedUrls)
|
|
? [...new Set(pkg.cleanedUrls.map((value) => asText(value)).filter(Boolean))].slice(0, 1_000_000)
|
|
: [],
|
|
cleanedProviders: Array.isArray(pkg.cleanedProviders)
|
|
? [...new Set(pkg.cleanedProviders.map((value) => asText(value) as DebridProvider).filter((value) => VALID_ITEM_PROVIDERS.has(value)))]
|
|
: [],
|
|
downloadStartedAt: clampNumber(pkg.downloadStartedAt, 0, 0, Number.MAX_SAFE_INTEGER),
|
|
downloadCompletedAt: clampNumber(pkg.downloadCompletedAt, 0, 0, Number.MAX_SAFE_INTEGER),
|
|
downloadEndedAt: clampNumber(pkg.downloadEndedAt, 0, 0, Number.MAX_SAFE_INTEGER),
|
|
postProcessQueuedAt: clampNumber(pkg.postProcessQueuedAt, 0, 0, Number.MAX_SAFE_INTEGER),
|
|
postProcessStartedAt: clampNumber(pkg.postProcessStartedAt, 0, 0, Number.MAX_SAFE_INTEGER),
|
|
postProcessCompletedAt: clampNumber(pkg.postProcessCompletedAt, 0, 0, Number.MAX_SAFE_INTEGER),
|
|
terminalAt: clampNumber(pkg.terminalAt, 0, 0, Number.MAX_SAFE_INTEGER),
|
|
archiveOperations: normalizeArchiveOperations(pkg.archiveOperations),
|
|
remuxOperations: normalizeRemuxOperations(pkg.remuxOperations),
|
|
outputCount: clampNumber(pkg.outputCount, 0, 0, 1_000_000),
|
|
outputBaselineSignatures: Array.isArray(pkg.outputBaselineSignatures)
|
|
? [...new Set(pkg.outputBaselineSignatures.map((value) => asText(value).toLowerCase()).filter((value) => SAFE_OUTPUT_SIGNATURE_RE.test(value)))].slice(0, 1_000_000)
|
|
: undefined,
|
|
cleanupErrorCategory: normalizeFailureCategory("cleanup", pkg.cleanupErrorCategory),
|
|
postProcessErrorCategory: normalizeFailureCategory("postprocess", pkg.postProcessErrorCategory),
|
|
resultGeneration: clampNumber(pkg.resultGeneration, 1, 1, Number.MAX_SAFE_INTEGER),
|
|
createdAt: clampNumber(pkg.createdAt, now, 0, Number.MAX_SAFE_INTEGER),
|
|
updatedAt: clampNumber(pkg.updatedAt, now, 0, Number.MAX_SAFE_INTEGER)
|
|
};
|
|
}
|
|
|
|
let orphanedItemCount = 0;
|
|
for (const [itemId, item] of Object.entries(itemsById)) {
|
|
if (!packagesById[item.packageId]) {
|
|
orphanedItemCount += 1;
|
|
delete itemsById[itemId];
|
|
}
|
|
}
|
|
if (orphanedItemCount > 0) {
|
|
logger.warn(`normalizeLoadedSession: ${orphanedItemCount} verwaiste Items entfernt (fehlende Pakete)`);
|
|
}
|
|
|
|
let droppedUnsafeTargetPathCount = 0;
|
|
let droppedUnsafeMetadataRenameTargetPathCount = 0;
|
|
for (const item of Object.values(itemsById)) {
|
|
const pkg = packagesById[item.packageId];
|
|
if (!pkg) {
|
|
continue;
|
|
}
|
|
const safeTargetPath = normalizeSessionTargetPath(item.targetPath, pkg.outputDir);
|
|
if (!safeTargetPath && asText(item.targetPath)) {
|
|
droppedUnsafeTargetPathCount += 1;
|
|
}
|
|
item.targetPath = safeTargetPath;
|
|
const metadataRenameTargetPath = asText(item.metadataRenameTargetPath);
|
|
const safeMetadataRenameTargetPath = normalizeSessionMetadataRenameTargetPath(metadataRenameTargetPath, pkg.outputDir);
|
|
if (metadataRenameTargetPath && !safeMetadataRenameTargetPath) {
|
|
droppedUnsafeMetadataRenameTargetPathCount += 1;
|
|
}
|
|
if (safeMetadataRenameTargetPath) {
|
|
item.metadataRenameTargetPath = safeMetadataRenameTargetPath;
|
|
} else {
|
|
delete item.metadataRenameTargetPath;
|
|
}
|
|
}
|
|
if (droppedUnsafeTargetPathCount > 0) {
|
|
logger.warn(`normalizeLoadedSession: ${droppedUnsafeTargetPathCount} unsichere targetPath-Eintraege verworfen`);
|
|
}
|
|
if (droppedUnsafeMetadataRenameTargetPathCount > 0) {
|
|
logger.warn(`normalizeLoadedSession: ${droppedUnsafeMetadataRenameTargetPathCount} unsichere metadataRenameTargetPath-Einträge verworfen`);
|
|
}
|
|
|
|
for (const pkg of Object.values(packagesById)) {
|
|
pkg.itemIds = pkg.itemIds.filter((itemId) => {
|
|
const item = itemsById[itemId];
|
|
return Boolean(item) && item.packageId === pkg.id;
|
|
});
|
|
}
|
|
|
|
const rawOrder = Array.isArray(parsed.packageOrder) ? parsed.packageOrder : [];
|
|
const seenOrder = new Set<string>();
|
|
const packageOrder = rawOrder
|
|
.map((entry) => normalizeSessionId(entry))
|
|
.filter((id) => {
|
|
if (!(id in packagesById) || seenOrder.has(id)) {
|
|
return false;
|
|
}
|
|
seenOrder.add(id);
|
|
return true;
|
|
});
|
|
for (const packageId of Object.keys(packagesById)) {
|
|
if (!seenOrder.has(packageId)) {
|
|
seenOrder.add(packageId);
|
|
packageOrder.push(packageId);
|
|
}
|
|
}
|
|
|
|
return {
|
|
...fallback,
|
|
version: clampNumber(parsed.version, fallback.version, 1, 10),
|
|
packageOrder,
|
|
packages: packagesById,
|
|
items: itemsById,
|
|
runStartedAt: clampNumber(parsed.runStartedAt, 0, 0, Number.MAX_SAFE_INTEGER),
|
|
totalDownloadedBytes: clampNumber(parsed.totalDownloadedBytes, 0, 0, Number.MAX_SAFE_INTEGER),
|
|
summaryText: asText(parsed.summaryText),
|
|
reconnectUntil: clampNumber(parsed.reconnectUntil, 0, 0, Number.MAX_SAFE_INTEGER),
|
|
reconnectReason: asText(parsed.reconnectReason),
|
|
paused: Boolean(parsed.paused),
|
|
running: Boolean(parsed.running),
|
|
updatedAt: clampNumber(parsed.updatedAt, now, 0, Number.MAX_SAFE_INTEGER)
|
|
};
|
|
}
|
|
|
|
export function loadSettings(paths: StoragePaths): AppSettings {
|
|
ensureBaseDir(paths.baseDir);
|
|
const primaryExists = fs.existsSync(paths.configFile);
|
|
const backupFile = `${paths.configFile}.bak`;
|
|
const backupExists = fs.existsSync(backupFile);
|
|
const loaded = primaryExists ? readSettingsFile(paths.configFile) : null;
|
|
if (loaded) {
|
|
const backupNeedsCredentialRewrite = backupExists
|
|
? needsSettingsFileCredentialRewrite(backupFile)
|
|
: false;
|
|
if (loaded.needsCredentialRewrite || backupNeedsCredentialRewrite) {
|
|
rewriteProtectedSettings(paths, loaded.settings);
|
|
}
|
|
return loaded.settings;
|
|
}
|
|
|
|
const backupLoaded = backupExists ? readSettingsFile(backupFile) : null;
|
|
if (backupLoaded) {
|
|
logger.warn(primaryExists
|
|
? "Konfiguration defekt, Backup-Datei wird verwendet"
|
|
: "Konfiguration fehlt, Backup-Datei wird verwendet");
|
|
rewriteProtectedSettings(paths, backupLoaded.settings);
|
|
return backupLoaded.settings;
|
|
}
|
|
|
|
if (primaryExists || backupExists) {
|
|
logger.error("Konfiguration konnte nicht geladen werden (auch Backup fehlgeschlagen)");
|
|
}
|
|
return defaultSettings();
|
|
}
|
|
|
|
function syncRenameWithExdevFallback(tempPath: string, targetPath: string): void {
|
|
try {
|
|
fs.renameSync(tempPath, targetPath);
|
|
} catch (renameError: unknown) {
|
|
if (renameError && typeof renameError === "object" && "code" in renameError && (renameError as NodeJS.ErrnoException).code === "EXDEV") {
|
|
fs.copyFileSync(tempPath, targetPath);
|
|
try { fs.rmSync(tempPath, { force: true }); } catch {}
|
|
} else {
|
|
throw renameError;
|
|
}
|
|
}
|
|
}
|
|
|
|
function settingsPayload(settings: AppSettings): string {
|
|
return JSON.stringify(protectPersistedSettings(normalizeSettings(settings)), safeJsonReplacer, 2);
|
|
}
|
|
|
|
interface SettingsSavePayloads {
|
|
primary: string;
|
|
backup: string;
|
|
}
|
|
|
|
function credentialFreeSettingsPayload(settings: AppSettings): string {
|
|
return settingsPayload({ ...settings, rememberToken: false });
|
|
}
|
|
|
|
function createSettingsSavePayloads(paths: StoragePaths, settings: AppSettings): SettingsSavePayloads {
|
|
const previous = readSettingsFile(paths.configFile);
|
|
const backup = previous
|
|
? settingsPayload({ ...previous.settings, rememberToken: settings.rememberToken })
|
|
: credentialFreeSettingsPayload(settings);
|
|
return {
|
|
primary: settingsPayload(settings),
|
|
backup
|
|
};
|
|
}
|
|
|
|
function captureSettings(settings: AppSettings): AppSettings {
|
|
return JSON.parse(JSON.stringify(normalizeSettings(settings))) as AppSettings;
|
|
}
|
|
|
|
function writeSettingsFileAtomically(filePath: string, payload: string): void {
|
|
const tempPath = `${filePath}.tmp`;
|
|
try {
|
|
fs.writeFileSync(tempPath, payload, "utf8");
|
|
syncRenameWithExdevFallback(tempPath, filePath);
|
|
} catch (error) {
|
|
try { fs.rmSync(tempPath, { force: true }); } catch { }
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function rewriteProtectedSettings(paths: StoragePaths, settings: AppSettings): void {
|
|
const payload = settingsPayload(settings);
|
|
writeSettingsFileAtomically(`${paths.configFile}.bak`, payload);
|
|
writeSettingsFileAtomically(paths.configFile, payload);
|
|
}
|
|
|
|
function needsSettingsFileCredentialRewrite(filePath: string): boolean {
|
|
try {
|
|
return needsPersistedSettingsRewrite(JSON.parse(fs.readFileSync(filePath, "utf8")) as AppSettings);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function sessionTempPath(sessionFile: string, kind: "sync" | "async"): string {
|
|
return `${sessionFile}.${kind}.tmp`;
|
|
}
|
|
|
|
function sessionBackupPath(sessionFile: string): string {
|
|
return `${sessionFile}.bak`;
|
|
}
|
|
|
|
export function normalizeLoadedSessionTransientFields(session: SessionState): SessionState {
|
|
const ACTIVE_STATUSES = new Set(["downloading", "validating", "extracting", "integrity_check", "paused", "reconnect_wait"]);
|
|
for (const item of Object.values(session.items)) {
|
|
if (ACTIVE_STATUSES.has(item.status)) {
|
|
item.status = "queued";
|
|
item.lastError = "";
|
|
}
|
|
item.speedBps = 0;
|
|
}
|
|
|
|
const ACTIVE_PKG_STATUSES = new Set(["downloading", "validating", "extracting", "integrity_check", "paused", "reconnect_wait"]);
|
|
for (const pkg of Object.values(session.packages)) {
|
|
if (ACTIVE_PKG_STATUSES.has(pkg.status)) {
|
|
pkg.status = "queued";
|
|
}
|
|
pkg.postProcessLabel = undefined;
|
|
}
|
|
|
|
session.running = false;
|
|
session.paused = false;
|
|
|
|
return session;
|
|
}
|
|
|
|
const TRANSIENT_READ_CODES = new Set(["EBUSY", "EPERM", "EAGAIN"]);
|
|
|
|
function sleepSyncMs(ms: number): void {
|
|
if (ms <= 0) {
|
|
return;
|
|
}
|
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
}
|
|
|
|
interface LoadedSessionFile {
|
|
session: SessionState;
|
|
wasRunning: boolean;
|
|
}
|
|
|
|
function isPersistedSessionEnvelope(value: unknown): boolean {
|
|
const parsed = asRecord(value);
|
|
return Boolean(
|
|
parsed
|
|
&& Array.isArray(parsed.packageOrder)
|
|
&& asRecord(parsed.packages)
|
|
&& asRecord(parsed.items)
|
|
);
|
|
}
|
|
|
|
function readSessionFile(filePath: string): LoadedSessionFile | null {
|
|
let raw: string | null = null;
|
|
const maxAttempts = 5;
|
|
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
try {
|
|
raw = fs.readFileSync(filePath, "utf8");
|
|
break;
|
|
} catch (error) {
|
|
const code = (error as NodeJS.ErrnoException)?.code || "";
|
|
if (TRANSIENT_READ_CODES.has(code) && attempt < maxAttempts) {
|
|
const backoffMs = 100 * 2 ** (attempt - 1);
|
|
logger.warn(`Session-Datei vorübergehend gesperrt (${code}), Versuch ${attempt}/${maxAttempts}, warte ${backoffMs}ms: ${filePath}`);
|
|
sleepSyncMs(backoffMs);
|
|
continue;
|
|
}
|
|
if (code === "EACCES" || code === "EPERM") {
|
|
logger.error(`Session-Datei nicht zugreifbar (${code}): ${filePath} - pruefe Datei-/Ordner-Berechtigungen fuer Benutzer ${process.env.USERNAME || process.env.USER || "?"}`);
|
|
} else {
|
|
logger.error(`Session-Datei nicht lesbar (${code || "?"}): ${filePath}: ${String(error)}`);
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
if (raw === null) {
|
|
return null;
|
|
}
|
|
try {
|
|
const parsed = JSON.parse(raw) as unknown;
|
|
if (!isPersistedSessionEnvelope(parsed)) {
|
|
logger.error(`Session-Datei beschädigt (Struktur ungültig): ${filePath}`);
|
|
return null;
|
|
}
|
|
const normalized = normalizeLoadedSession(parsed);
|
|
const wasRunning = normalized.running;
|
|
const session = normalizeLoadedSessionTransientFields(normalized);
|
|
const pkgCount = Object.keys(session.packages).length;
|
|
const itemCount = Object.keys(session.items).length;
|
|
logger.info(`Session geladen: ${filePath} (${pkgCount} Pakete, ${itemCount} Items)`);
|
|
return { session, wasRunning };
|
|
} catch (error) {
|
|
logger.error(`Session-Datei beschädigt (JSON ungültig): ${filePath}: ${String(error)}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function saveSettings(paths: StoragePaths, settings: AppSettings): void {
|
|
syncSettingsSaveGeneration += 1;
|
|
ensureBaseDir(paths.baseDir);
|
|
const payloads = createSettingsSavePayloads(paths, settings);
|
|
writeSettingsFileAtomically(`${paths.configFile}.bak`, payloads.backup);
|
|
writeSettingsFileAtomically(paths.configFile, payloads.primary);
|
|
}
|
|
|
|
interface AsyncSaveWaiter {
|
|
resolve: () => void;
|
|
reject: (error: unknown) => void;
|
|
}
|
|
|
|
interface QueuedSettingsSave {
|
|
paths: StoragePaths;
|
|
settings: AppSettings;
|
|
generation: number;
|
|
waiters: AsyncSaveWaiter[];
|
|
}
|
|
|
|
let asyncSettingsSaveQueued: QueuedSettingsSave | null = null;
|
|
let syncSettingsSaveGeneration = 0;
|
|
let activeSettingsSave: Promise<void> | null = null;
|
|
|
|
async function writeSettingsPayload(paths: StoragePaths, settings: AppSettings, generation: number): Promise<void> {
|
|
await fs.promises.mkdir(paths.baseDir, { recursive: true });
|
|
const payloads = createSettingsSavePayloads(paths, settings);
|
|
const tempPath = `${paths.configFile}.settings.tmp`;
|
|
await fsp.writeFile(tempPath, payloads.primary, "utf8");
|
|
if (generation < syncSettingsSaveGeneration) {
|
|
await fsp.rm(tempPath, { force: true }).catch(() => {});
|
|
return;
|
|
}
|
|
const backupTempPath = `${paths.configFile}.bak.settings.tmp`;
|
|
await fsp.writeFile(backupTempPath, payloads.backup, "utf8");
|
|
if (generation < syncSettingsSaveGeneration) {
|
|
await Promise.all([
|
|
fsp.rm(tempPath, { force: true }).catch(() => {}),
|
|
fsp.rm(backupTempPath, { force: true }).catch(() => {})
|
|
]);
|
|
return;
|
|
}
|
|
try {
|
|
syncRenameWithExdevFallback(backupTempPath, `${paths.configFile}.bak`);
|
|
syncRenameWithExdevFallback(tempPath, paths.configFile);
|
|
} catch (error) {
|
|
try { fs.rmSync(backupTempPath, { force: true }); } catch { }
|
|
try { fs.rmSync(tempPath, { force: true }); } catch { }
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function startSettingsSaveDrain(): void {
|
|
if (activeSettingsSave) {
|
|
return;
|
|
}
|
|
let operation: Promise<void>;
|
|
operation = (async () => {
|
|
while (asyncSettingsSaveQueued) {
|
|
const current = asyncSettingsSaveQueued;
|
|
asyncSettingsSaveQueued = null;
|
|
try {
|
|
await writeSettingsPayload(current.paths, current.settings, current.generation);
|
|
current.waiters.forEach((waiter) => waiter.resolve());
|
|
} catch (error) {
|
|
current.waiters.forEach((waiter) => waiter.reject(error));
|
|
const pending = asyncSettingsSaveQueued as QueuedSettingsSave | null;
|
|
asyncSettingsSaveQueued = null;
|
|
pending?.waiters.forEach((waiter) => waiter.reject(error));
|
|
logger.error(`Async Settings-Save fehlgeschlagen: ${String(error)}`);
|
|
throw error;
|
|
}
|
|
}
|
|
})().finally(() => {
|
|
if (activeSettingsSave === operation) {
|
|
activeSettingsSave = null;
|
|
}
|
|
if (asyncSettingsSaveQueued) {
|
|
startSettingsSaveDrain();
|
|
}
|
|
});
|
|
activeSettingsSave = operation;
|
|
void operation.catch(() => {});
|
|
}
|
|
|
|
function saveSettingsPayloadAsync(paths: StoragePaths, settings: AppSettings, generation: number): Promise<void> {
|
|
return new Promise<void>((resolve, reject) => {
|
|
const waiter = { resolve, reject };
|
|
if (asyncSettingsSaveQueued) {
|
|
asyncSettingsSaveQueued.paths = paths;
|
|
asyncSettingsSaveQueued.settings = settings;
|
|
asyncSettingsSaveQueued.generation = generation;
|
|
asyncSettingsSaveQueued.waiters.push(waiter);
|
|
} else {
|
|
asyncSettingsSaveQueued = { paths, settings, generation, waiters: [waiter] };
|
|
}
|
|
startSettingsSaveDrain();
|
|
});
|
|
}
|
|
|
|
export async function saveSettingsAsync(paths: StoragePaths, settings: AppSettings): Promise<void> {
|
|
const captured = captureSettings(settings);
|
|
if (activePersistenceBarrier) {
|
|
return blockSettingsSave(activePersistenceBarrier, paths, captured);
|
|
}
|
|
const generation = syncSettingsSaveGeneration;
|
|
await saveSettingsPayloadAsync(paths, captured, generation);
|
|
}
|
|
|
|
export function emptySession(): SessionState {
|
|
return {
|
|
version: 2,
|
|
packageOrder: [],
|
|
packages: {},
|
|
items: {},
|
|
runStartedAt: 0,
|
|
totalDownloadedBytes: 0,
|
|
summaryText: "",
|
|
reconnectUntil: 0,
|
|
reconnectReason: "",
|
|
paused: false,
|
|
running: false,
|
|
updatedAt: Date.now()
|
|
};
|
|
}
|
|
|
|
export type SessionLoadStatus =
|
|
| "ok"
|
|
| "recovered-backup"
|
|
| "recovered-temp"
|
|
| "empty-fresh"
|
|
| "empty-unreadable";
|
|
|
|
export interface SessionLoadResult {
|
|
session: SessionState;
|
|
status: SessionLoadStatus;
|
|
wasRunning: boolean;
|
|
}
|
|
|
|
export function loadSessionWithStatus(paths: StoragePaths): SessionLoadResult {
|
|
ensureBaseDir(paths.baseDir);
|
|
const backupFile = sessionBackupPath(paths.sessionFile);
|
|
const syncTempFile = sessionTempPath(paths.sessionFile, "sync");
|
|
const asyncTempFile = sessionTempPath(paths.sessionFile, "async");
|
|
const primaryExists = fs.existsSync(paths.sessionFile);
|
|
const backupExists = fs.existsSync(backupFile);
|
|
const anyTempExists = fs.existsSync(syncTempFile) || fs.existsSync(asyncTempFile);
|
|
|
|
if (!primaryExists) {
|
|
if (!backupExists && !anyTempExists) {
|
|
logger.info("Keine Session-Datei vorhanden, starte mit leerer Session");
|
|
return { session: emptySession(), status: "empty-fresh", wasRunning: false };
|
|
}
|
|
logger.warn("Session-Primaerdatei fehlt, aber Backup/Temp vorhanden — Wiederherstellung wird versucht");
|
|
}
|
|
|
|
const primary = primaryExists ? readSessionFile(paths.sessionFile) : null;
|
|
|
|
if (primary) {
|
|
return { session: primary.session, status: "ok", wasRunning: primary.wasRunning };
|
|
}
|
|
|
|
const backup = backupExists ? readSessionFile(backupFile) : null;
|
|
if (backup) {
|
|
logger.warn("Session defekt, Backup-Datei wird verwendet");
|
|
try {
|
|
const payload = JSON.stringify({ ...backup.session, updatedAt: Date.now() }, safeJsonReplacer);
|
|
fs.writeFileSync(syncTempFile, payload, "utf8");
|
|
syncRenameWithExdevFallback(syncTempFile, paths.sessionFile);
|
|
} catch {
|
|
}
|
|
return { session: backup.session, status: "recovered-backup", wasRunning: backup.wasRunning };
|
|
}
|
|
|
|
for (const kind of ["sync", "async"] as const) {
|
|
const tmpPath = sessionTempPath(paths.sessionFile, kind);
|
|
if (fs.existsSync(tmpPath)) {
|
|
const tmpSession = readSessionFile(tmpPath);
|
|
if (tmpSession && Object.keys(tmpSession.session.packages).length > 0) {
|
|
logger.warn(`Session aus temporaerer Datei wiederhergestellt: ${tmpPath} (${Object.keys(tmpSession.session.packages).length} Pakete)`);
|
|
try {
|
|
const payload = JSON.stringify({ ...tmpSession.session, updatedAt: Date.now() }, safeJsonReplacer);
|
|
fs.writeFileSync(paths.sessionFile, payload, "utf8");
|
|
} catch {
|
|
}
|
|
return { session: tmpSession.session, status: "recovered-temp", wasRunning: tmpSession.wasRunning };
|
|
}
|
|
}
|
|
}
|
|
|
|
if (primaryExists || backupExists || anyTempExists) {
|
|
logger.error("Session konnte nicht geladen werden (Primary, Backup und Temp-Dateien fehlgeschlagen) — Schutz gegen leeres Ueberschreiben aktiv");
|
|
return { session: emptySession(), status: "empty-unreadable", wasRunning: false };
|
|
}
|
|
|
|
return { session: emptySession(), status: "empty-fresh", wasRunning: false };
|
|
}
|
|
|
|
export function loadSession(paths: StoragePaths): SessionState {
|
|
return loadSessionWithStatus(paths).session;
|
|
}
|
|
|
|
export function saveSession(paths: StoragePaths, session: SessionState): void {
|
|
syncSaveGeneration += 1;
|
|
ensureBaseDir(paths.baseDir);
|
|
if (fs.existsSync(paths.sessionFile)) {
|
|
try {
|
|
fs.copyFileSync(paths.sessionFile, sessionBackupPath(paths.sessionFile));
|
|
} catch {
|
|
}
|
|
}
|
|
const payload = JSON.stringify({ ...session, updatedAt: Date.now() }, safeJsonReplacer);
|
|
const tempPath = sessionTempPath(paths.sessionFile, "sync");
|
|
try {
|
|
const fd = fs.openSync(tempPath, "w");
|
|
try {
|
|
fs.writeSync(fd, payload);
|
|
fs.fsyncSync(fd);
|
|
} finally {
|
|
fs.closeSync(fd);
|
|
}
|
|
syncRenameWithExdevFallback(tempPath, paths.sessionFile);
|
|
} catch (error) {
|
|
try { fs.rmSync(tempPath, { force: true }); } catch { }
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
interface QueuedSessionSave {
|
|
paths: StoragePaths;
|
|
payload: string;
|
|
generation: number;
|
|
waiters: AsyncSaveWaiter[];
|
|
}
|
|
|
|
let asyncSaveQueued: QueuedSessionSave | null = null;
|
|
let syncSaveGeneration = 0;
|
|
let activeSessionSave: Promise<void> | null = null;
|
|
|
|
async function writeSessionPayload(paths: StoragePaths, payload: string, generation: number): Promise<void> {
|
|
await fs.promises.mkdir(paths.baseDir, { recursive: true });
|
|
const tempPath = sessionTempPath(paths.sessionFile, "async");
|
|
const backupTempPath = `${paths.sessionFile}.async.backup.tmp`;
|
|
const handle = await fsp.open(tempPath, "w");
|
|
try {
|
|
await handle.writeFile(payload, "utf8");
|
|
await handle.sync();
|
|
} finally {
|
|
await handle.close();
|
|
}
|
|
if (generation < syncSaveGeneration) {
|
|
await fsp.rm(tempPath, { force: true }).catch(() => {});
|
|
return;
|
|
}
|
|
if (fs.existsSync(paths.sessionFile)) {
|
|
try {
|
|
fs.copyFileSync(paths.sessionFile, backupTempPath);
|
|
syncRenameWithExdevFallback(backupTempPath, sessionBackupPath(paths.sessionFile));
|
|
} catch {
|
|
try { fs.rmSync(backupTempPath, { force: true }); } catch { }
|
|
}
|
|
}
|
|
try {
|
|
syncRenameWithExdevFallback(tempPath, paths.sessionFile);
|
|
} catch (error) {
|
|
try { fs.rmSync(tempPath, { force: true }); } catch { }
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function startSessionSaveDrain(): void {
|
|
if (activeSessionSave) {
|
|
return;
|
|
}
|
|
let operation: Promise<void>;
|
|
operation = (async () => {
|
|
while (asyncSaveQueued) {
|
|
const current = asyncSaveQueued;
|
|
asyncSaveQueued = null;
|
|
try {
|
|
await writeSessionPayload(current.paths, current.payload, current.generation);
|
|
current.waiters.forEach((waiter) => waiter.resolve());
|
|
} catch (error) {
|
|
current.waiters.forEach((waiter) => waiter.reject(error));
|
|
const pending = asyncSaveQueued as QueuedSessionSave | null;
|
|
asyncSaveQueued = null;
|
|
pending?.waiters.forEach((waiter) => waiter.reject(error));
|
|
logger.error(`Async Session-Save fehlgeschlagen: ${String(error)}`);
|
|
throw error;
|
|
}
|
|
}
|
|
})().finally(() => {
|
|
if (activeSessionSave === operation) {
|
|
activeSessionSave = null;
|
|
}
|
|
if (asyncSaveQueued) {
|
|
startSessionSaveDrain();
|
|
}
|
|
});
|
|
activeSessionSave = operation;
|
|
void operation.catch(() => {});
|
|
}
|
|
|
|
function saveSessionPayloadAsync(paths: StoragePaths, payload: string, generation: number): Promise<void> {
|
|
return new Promise<void>((resolve, reject) => {
|
|
const waiter = { resolve, reject };
|
|
if (asyncSaveQueued) {
|
|
asyncSaveQueued.paths = paths;
|
|
asyncSaveQueued.payload = payload;
|
|
asyncSaveQueued.generation = generation;
|
|
asyncSaveQueued.waiters.push(waiter);
|
|
} else {
|
|
asyncSaveQueued = { paths, payload, generation, waiters: [waiter] };
|
|
}
|
|
startSessionSaveDrain();
|
|
});
|
|
}
|
|
|
|
interface BlockedSettingsSave {
|
|
paths: StoragePaths;
|
|
settings: AppSettings;
|
|
waiters: Array<{ resolve: () => void; reject: (error: unknown) => void }>;
|
|
}
|
|
|
|
interface BlockedSessionSave {
|
|
paths: StoragePaths;
|
|
payload: string;
|
|
waiters: Array<{ resolve: () => void; reject: (error: unknown) => void }>;
|
|
}
|
|
|
|
interface PersistenceBarrierState {
|
|
blockedSettings: BlockedSettingsSave | null;
|
|
blockedSession: BlockedSessionSave | null;
|
|
released: Promise<void>;
|
|
resolveReleased: () => void;
|
|
}
|
|
|
|
export interface PersistenceBarrier {
|
|
release: (options: { replayBlocked: boolean }) => Promise<void>;
|
|
}
|
|
|
|
let activePersistenceBarrier: PersistenceBarrierState | null = null;
|
|
|
|
interface DetachedAsyncSaves {
|
|
settings: QueuedSettingsSave | null;
|
|
session: QueuedSessionSave | null;
|
|
}
|
|
|
|
function detachPendingAsyncSaves(): DetachedAsyncSaves {
|
|
const detached = {
|
|
settings: asyncSettingsSaveQueued,
|
|
session: asyncSaveQueued
|
|
};
|
|
asyncSettingsSaveQueued = null;
|
|
asyncSaveQueued = null;
|
|
return detached;
|
|
}
|
|
|
|
function rejectDetachedAsyncSaves(detached: DetachedAsyncSaves, error: unknown): void {
|
|
detached.settings?.waiters.forEach((waiter) => waiter.reject(error));
|
|
detached.session?.waiters.forEach((waiter) => waiter.reject(error));
|
|
}
|
|
|
|
function requeueDetachedAsyncSaves(detached: DetachedAsyncSaves): void {
|
|
if (detached.settings) {
|
|
detached.settings.generation = syncSettingsSaveGeneration;
|
|
if (asyncSettingsSaveQueued) {
|
|
asyncSettingsSaveQueued.waiters.unshift(...detached.settings.waiters);
|
|
} else {
|
|
asyncSettingsSaveQueued = detached.settings;
|
|
}
|
|
startSettingsSaveDrain();
|
|
}
|
|
if (detached.session) {
|
|
detached.session.generation = syncSaveGeneration;
|
|
if (asyncSaveQueued) {
|
|
asyncSaveQueued.waiters.unshift(...detached.session.waiters);
|
|
} else {
|
|
asyncSaveQueued = detached.session;
|
|
}
|
|
startSessionSaveDrain();
|
|
}
|
|
}
|
|
|
|
function blockSettingsSave(state: PersistenceBarrierState, paths: StoragePaths, settings: AppSettings): Promise<void> {
|
|
return new Promise<void>((resolve, reject) => {
|
|
const waiter = { resolve, reject };
|
|
if (state.blockedSettings) {
|
|
state.blockedSettings.paths = paths;
|
|
state.blockedSettings.settings = settings;
|
|
state.blockedSettings.waiters.push(waiter);
|
|
} else {
|
|
state.blockedSettings = { paths, settings, waiters: [waiter] };
|
|
}
|
|
});
|
|
}
|
|
|
|
function blockSessionSave(state: PersistenceBarrierState, paths: StoragePaths, payload: string): Promise<void> {
|
|
return new Promise<void>((resolve, reject) => {
|
|
const waiter = { resolve, reject };
|
|
if (state.blockedSession) {
|
|
state.blockedSession.paths = paths;
|
|
state.blockedSession.payload = payload;
|
|
state.blockedSession.waiters.push(waiter);
|
|
} else {
|
|
state.blockedSession = { paths, payload, waiters: [waiter] };
|
|
}
|
|
});
|
|
}
|
|
|
|
async function replayBlockedSaves(state: PersistenceBarrierState): Promise<void> {
|
|
let firstError: unknown;
|
|
let failed = false;
|
|
while (state.blockedSettings || state.blockedSession) {
|
|
const blockedSettings = state.blockedSettings;
|
|
const blockedSession = state.blockedSession;
|
|
state.blockedSettings = null;
|
|
state.blockedSession = null;
|
|
const [settingsResult, sessionResult] = await Promise.allSettled([
|
|
blockedSettings
|
|
? saveSettingsPayloadAsync(blockedSettings.paths, blockedSettings.settings, syncSettingsSaveGeneration)
|
|
: Promise.resolve(),
|
|
blockedSession
|
|
? saveSessionPayloadAsync(blockedSession.paths, blockedSession.payload, syncSaveGeneration)
|
|
: Promise.resolve()
|
|
]);
|
|
if (blockedSettings) {
|
|
if (settingsResult.status === "fulfilled") {
|
|
blockedSettings.waiters.forEach((waiter) => waiter.resolve());
|
|
} else {
|
|
blockedSettings.waiters.forEach((waiter) => waiter.reject(settingsResult.reason));
|
|
if (!failed) {
|
|
firstError = settingsResult.reason;
|
|
failed = true;
|
|
}
|
|
}
|
|
}
|
|
if (blockedSession) {
|
|
if (sessionResult.status === "fulfilled") {
|
|
blockedSession.waiters.forEach((waiter) => waiter.resolve());
|
|
} else {
|
|
blockedSession.waiters.forEach((waiter) => waiter.reject(sessionResult.reason));
|
|
if (!failed) {
|
|
firstError = sessionResult.reason;
|
|
failed = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (failed) {
|
|
throw firstError;
|
|
}
|
|
}
|
|
|
|
function rejectBlockedSaves(state: PersistenceBarrierState, error: unknown): void {
|
|
state.blockedSettings?.waiters.forEach((waiter) => waiter.reject(error));
|
|
state.blockedSession?.waiters.forEach((waiter) => waiter.reject(error));
|
|
state.blockedSettings = null;
|
|
state.blockedSession = null;
|
|
}
|
|
|
|
function resolveBlockedSaves(state: PersistenceBarrierState): void {
|
|
state.blockedSettings?.waiters.forEach((waiter) => waiter.resolve());
|
|
state.blockedSession?.waiters.forEach((waiter) => waiter.resolve());
|
|
state.blockedSettings = null;
|
|
state.blockedSession = null;
|
|
}
|
|
|
|
export async function acquirePersistenceBarrier(): Promise<PersistenceBarrier> {
|
|
while (activePersistenceBarrier) {
|
|
await activePersistenceBarrier.released;
|
|
}
|
|
let resolveReleased = () => {};
|
|
const released = new Promise<void>((resolve) => { resolveReleased = resolve; });
|
|
const state: PersistenceBarrierState = {
|
|
blockedSettings: null,
|
|
blockedSession: null,
|
|
released,
|
|
resolveReleased
|
|
};
|
|
activePersistenceBarrier = state;
|
|
const detachedSaves = detachPendingAsyncSaves();
|
|
const activeWriteResults = await Promise.allSettled([
|
|
activeSettingsSave ?? Promise.resolve(),
|
|
activeSessionSave ?? Promise.resolve()
|
|
]);
|
|
const activeWriteFailure = activeWriteResults.find((result) => result.status === "rejected");
|
|
if (activeWriteFailure?.status === "rejected") {
|
|
rejectBlockedSaves(state, activeWriteFailure.reason);
|
|
if (activePersistenceBarrier === state) {
|
|
activePersistenceBarrier = null;
|
|
}
|
|
requeueDetachedAsyncSaves(detachedSaves);
|
|
state.resolveReleased();
|
|
throw activeWriteFailure.reason;
|
|
}
|
|
rejectDetachedAsyncSaves(detachedSaves, new Error("Persistenzbarriere hat ausstehenden Save verworfen"));
|
|
let finished = false;
|
|
return {
|
|
release: async ({ replayBlocked }) => {
|
|
if (finished) return;
|
|
finished = true;
|
|
let replayError: unknown;
|
|
let replayFailed = false;
|
|
try {
|
|
if (replayBlocked) {
|
|
do {
|
|
try {
|
|
await replayBlockedSaves(state);
|
|
} catch (error) {
|
|
if (!replayFailed) {
|
|
replayError = error;
|
|
replayFailed = true;
|
|
}
|
|
}
|
|
} while (state.blockedSettings || state.blockedSession);
|
|
} else {
|
|
resolveBlockedSaves(state);
|
|
}
|
|
if (replayFailed) {
|
|
throw replayError;
|
|
}
|
|
} finally {
|
|
if (activePersistenceBarrier === state) {
|
|
activePersistenceBarrier = null;
|
|
}
|
|
state.resolveReleased();
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
export function cancelPendingAsyncSaves(): void {
|
|
syncSettingsSaveGeneration += 1;
|
|
syncSaveGeneration += 1;
|
|
const detached = detachPendingAsyncSaves();
|
|
detached.session?.waiters.forEach((waiter) => waiter.resolve());
|
|
detached.settings?.waiters.forEach((waiter) => waiter.resolve());
|
|
}
|
|
|
|
export async function saveSessionAsync(paths: StoragePaths, session: SessionState): Promise<void> {
|
|
const payload = JSON.stringify({ ...session, updatedAt: Date.now() }, safeJsonReplacer);
|
|
if (activePersistenceBarrier) {
|
|
return blockSessionSave(activePersistenceBarrier, paths, payload);
|
|
}
|
|
const generation = syncSaveGeneration;
|
|
await saveSessionPayloadAsync(paths, payload, generation);
|
|
}
|
|
|
|
const MAX_HISTORY_ENTRIES = 500;
|
|
const HISTORY_HARD_CAP = 100000;
|
|
|
|
export interface HistoryLimits {
|
|
maxEntries: number;
|
|
maxAgeDays: number;
|
|
}
|
|
|
|
function pruneHistoryEntries(entries: HistoryEntry[], limits?: HistoryLimits, now = Date.now()): HistoryEntry[] {
|
|
const maxEntries = limits && limits.maxEntries > 0 ? Math.min(limits.maxEntries, HISTORY_HARD_CAP) : MAX_HISTORY_ENTRIES;
|
|
const maxAgeDays = limits && limits.maxAgeDays > 0 ? limits.maxAgeDays : 0;
|
|
let result = entries;
|
|
if (maxAgeDays > 0) {
|
|
const cutoff = now - maxAgeDays * 24 * 60 * 60 * 1000;
|
|
result = result.filter((entry) => entry.completedAt >= cutoff);
|
|
}
|
|
return result.length > maxEntries ? result.slice(0, maxEntries) : result;
|
|
}
|
|
|
|
export function normalizeHistoryEntry(raw: unknown, index: number): HistoryEntry | null {
|
|
const entry = asRecord(raw);
|
|
if (!entry) return null;
|
|
|
|
const id = asText(entry.id) || `hist-${Date.now().toString(36)}-${index}`;
|
|
const name = asText(entry.name) || "Unbenannt";
|
|
const providerRaw = asText(entry.provider);
|
|
const statusRaw = asText(entry.status);
|
|
const failurePhaseRaw = entry.failurePhase === null ? null : asText(entry.failurePhase);
|
|
const optionalFields = {
|
|
startedAt: optionalClampedNumber(entry, "startedAt"),
|
|
downloadEndedAt: optionalClampedNumber(entry, "downloadEndedAt"),
|
|
postProcessStartedAt: optionalClampedNumber(entry, "postProcessStartedAt"),
|
|
downloadDurationSeconds: optionalClampedNumber(entry, "downloadDurationSeconds"),
|
|
extractionDurationSeconds: optionalClampedNumber(entry, "extractionDurationSeconds"),
|
|
remuxDurationSeconds: optionalClampedNumber(entry, "remuxDurationSeconds"),
|
|
postProcessDurationSeconds: optionalClampedNumber(entry, "postProcessDurationSeconds"),
|
|
totalDurationSeconds: optionalClampedNumber(entry, "totalDurationSeconds"),
|
|
successfulFiles: optionalClampedNumber(entry, "successfulFiles", 1_000_000),
|
|
failedFiles: optionalClampedNumber(entry, "failedFiles", 1_000_000),
|
|
cancelledFiles: optionalClampedNumber(entry, "cancelledFiles", 1_000_000),
|
|
archiveCount: optionalClampedNumber(entry, "archiveCount", 100_000),
|
|
partCount: optionalClampedNumber(entry, "partCount", 1_000_000),
|
|
outputCount: optionalClampedNumber(entry, "outputCount", 1_000_000),
|
|
downloadFailures: optionalClampedNumber(entry, "downloadFailures", 1_000_000),
|
|
offlineFailures: optionalClampedNumber(entry, "offlineFailures", 1_000_000),
|
|
extractionFailures: optionalClampedNumber(entry, "extractionFailures", 1_000_000),
|
|
remuxFailures: optionalClampedNumber(entry, "remuxFailures", 1_000_000),
|
|
cleanupFailures: optionalClampedNumber(entry, "cleanupFailures", 1_000_000),
|
|
postProcessFailures: optionalClampedNumber(entry, "postProcessFailures", 1_000_000)
|
|
};
|
|
|
|
return {
|
|
id,
|
|
name,
|
|
totalBytes: clampNumber(entry.totalBytes, 0, 0, Number.MAX_SAFE_INTEGER),
|
|
downloadedBytes: clampNumber(entry.downloadedBytes, 0, 0, Number.MAX_SAFE_INTEGER),
|
|
fileCount: clampNumber(entry.fileCount, 0, 0, 100000),
|
|
provider: VALID_ITEM_PROVIDERS.has(providerRaw as DebridProvider) ? providerRaw as DebridProvider : null,
|
|
completedAt: clampNumber(entry.completedAt, Date.now(), 0, Number.MAX_SAFE_INTEGER),
|
|
durationSeconds: clampNumber(entry.durationSeconds, 0, 0, Number.MAX_SAFE_INTEGER),
|
|
status: VALID_HISTORY_STATUSES.has(statusRaw) ? statusRaw as HistoryEntry["status"] : "completed",
|
|
outputDir: asText(entry.outputDir),
|
|
urls: Array.isArray(entry.urls) ? (entry.urls as unknown[]).map(String).filter(Boolean) : undefined,
|
|
...Object.fromEntries(Object.entries(optionalFields).filter(([, value]) => value !== undefined)),
|
|
...(failurePhaseRaw === null || VALID_FAILURE_PHASES.has(failurePhaseRaw)
|
|
? { failurePhase: failurePhaseRaw as FailurePhase }
|
|
: {}),
|
|
...(asText(entry.errorCategory)
|
|
? { errorCategory: normalizeFailureCategory(failurePhaseRaw !== null && VALID_FAILURE_PHASES.has(failurePhaseRaw) ? failurePhaseRaw as FailurePhase : null, entry.errorCategory) }
|
|
: {}),
|
|
...(Array.isArray(entry.archiveOperations) ? { archiveOperations: normalizeArchiveOperations(entry.archiveOperations) } : {}),
|
|
...(Array.isArray(entry.remuxOperations) ? { remuxOperations: normalizeRemuxOperations(entry.remuxOperations) } : {})
|
|
};
|
|
}
|
|
|
|
function readHistoryFile(filePath: string): HistoryEntry[] | null {
|
|
try {
|
|
const raw = JSON.parse(fs.readFileSync(filePath, "utf8")) as unknown;
|
|
if (!Array.isArray(raw)) return null;
|
|
|
|
const entries: HistoryEntry[] = [];
|
|
for (let i = 0; i < raw.length && entries.length < HISTORY_HARD_CAP; i++) {
|
|
const normalized = normalizeHistoryEntry(raw[i], i);
|
|
if (normalized) entries.push(normalized);
|
|
}
|
|
return entries;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function writeHistoryPayload(filePath: string, payload: string): void {
|
|
const tempPath = `${filePath}.tmp`;
|
|
try {
|
|
fs.writeFileSync(tempPath, payload, "utf8");
|
|
syncRenameWithExdevFallback(tempPath, filePath);
|
|
} catch (error) {
|
|
try { fs.rmSync(tempPath, { force: true }); } catch { }
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export function loadHistory(paths: StoragePaths, limits?: HistoryLimits): HistoryEntry[] {
|
|
ensureBaseDir(paths.baseDir);
|
|
const backupFile = `${paths.historyFile}.bak`;
|
|
const primaryExists = fs.existsSync(paths.historyFile);
|
|
const backupExists = fs.existsSync(backupFile);
|
|
const primary = primaryExists ? readHistoryFile(paths.historyFile) : null;
|
|
if (primary) return pruneHistoryEntries(primary, limits);
|
|
const backup = backupExists ? readHistoryFile(backupFile) : null;
|
|
if (backup) {
|
|
try {
|
|
writeHistoryPayload(paths.historyFile, JSON.stringify(backup, safeJsonReplacer, 2));
|
|
} catch (error) {
|
|
logger.warn(`Verlauf konnte aus dem Backup gelesen, aber nicht repariert werden: ${String(error)}`);
|
|
}
|
|
return pruneHistoryEntries(backup, limits);
|
|
}
|
|
if (primaryExists || backupExists) {
|
|
throw new Error("Verlaufsspeicher ist beschädigt");
|
|
}
|
|
return [];
|
|
}
|
|
|
|
export function saveHistory(paths: StoragePaths, entries: HistoryEntry[], limits?: HistoryLimits): void {
|
|
ensureBaseDir(paths.baseDir);
|
|
const trimmed = pruneHistoryEntries(entries, limits);
|
|
const payload = JSON.stringify(trimmed, safeJsonReplacer, 2);
|
|
const previous = fs.existsSync(paths.historyFile) ? readHistoryFile(paths.historyFile) : null;
|
|
if (previous) {
|
|
writeHistoryPayload(`${paths.historyFile}.bak`, JSON.stringify(previous, safeJsonReplacer, 2));
|
|
}
|
|
writeHistoryPayload(paths.historyFile, payload);
|
|
}
|
|
|
|
function restoreHistoryPayload(filePath: string, payload: string | null): void {
|
|
if (payload === null) {
|
|
fs.rmSync(`${filePath}.tmp`, { force: true });
|
|
fs.rmSync(filePath, { force: true });
|
|
return;
|
|
}
|
|
writeHistoryPayload(filePath, payload);
|
|
}
|
|
|
|
export function createHistoryRollback(paths: StoragePaths): () => void {
|
|
ensureBaseDir(paths.baseDir);
|
|
const backupFile = `${paths.historyFile}.bak`;
|
|
const previousPrimary = fs.existsSync(paths.historyFile) ? fs.readFileSync(paths.historyFile, "utf8") : null;
|
|
const previousBackup = fs.existsSync(backupFile) ? fs.readFileSync(backupFile, "utf8") : null;
|
|
return () => {
|
|
restoreHistoryPayload(backupFile, previousBackup);
|
|
restoreHistoryPayload(paths.historyFile, previousPrimary);
|
|
};
|
|
}
|
|
|
|
export function replaceHistory(paths: StoragePaths, entries: HistoryEntry[], limits?: HistoryLimits): () => void {
|
|
ensureBaseDir(paths.baseDir);
|
|
const payload = JSON.stringify(pruneHistoryEntries(entries, limits), safeJsonReplacer, 2);
|
|
const backupFile = `${paths.historyFile}.bak`;
|
|
const rollback = createHistoryRollback(paths);
|
|
writeHistoryPayload(backupFile, payload);
|
|
try {
|
|
writeHistoryPayload(paths.historyFile, payload);
|
|
} catch (error) {
|
|
try {
|
|
rollback();
|
|
} catch (rollbackError) {
|
|
logger.error(`Verlauf-Backup konnte nach fehlgeschlagener Ersetzung nicht zurückgerollt werden: ${String(rollbackError)}`);
|
|
}
|
|
throw error;
|
|
}
|
|
return rollback;
|
|
}
|
|
|
|
export function addHistoryEntry(paths: StoragePaths, entry: HistoryEntry, limits?: HistoryLimits): HistoryEntry[] {
|
|
const existing = loadHistory(paths, limits);
|
|
const updated = pruneHistoryEntries([entry, ...existing], limits);
|
|
saveHistory(paths, updated, limits);
|
|
return updated;
|
|
}
|
|
|
|
export function loadHistoryForRetention(paths: StoragePaths, retentionMode: HistoryRetentionMode, limits?: HistoryLimits): HistoryEntry[] {
|
|
return retentionMode === "never" ? [] : loadHistory(paths, limits);
|
|
}
|
|
|
|
export function addHistoryEntryForRetention(paths: StoragePaths, retentionMode: HistoryRetentionMode, entry: HistoryEntry, limits?: HistoryLimits): HistoryEntry[] {
|
|
if (retentionMode === "never") {
|
|
return [];
|
|
}
|
|
return addHistoryEntry(paths, entry, limits);
|
|
}
|
|
|
|
export function resetHistoryForRetention(paths: StoragePaths, retentionMode: HistoryRetentionMode): void {
|
|
if (retentionMode === "permanent") {
|
|
return;
|
|
}
|
|
clearHistory(paths);
|
|
}
|
|
|
|
export function removeHistoryEntries(paths: StoragePaths, entryIds: readonly string[], limits?: HistoryLimits): HistoryEntry[] {
|
|
const existing = loadHistory(paths, limits);
|
|
const removedIds = new Set(entryIds);
|
|
if (removedIds.size === 0) {
|
|
return existing;
|
|
}
|
|
const updated = existing.filter((entry) => !removedIds.has(entry.id));
|
|
if (updated.length !== existing.length) {
|
|
saveHistory(paths, updated, limits);
|
|
}
|
|
return updated;
|
|
}
|
|
|
|
export function removeHistoryEntry(paths: StoragePaths, entryId: string, limits?: HistoryLimits): HistoryEntry[] {
|
|
return removeHistoryEntries(paths, [entryId], limits);
|
|
}
|
|
|
|
export function clearHistory(paths: StoragePaths): void {
|
|
ensureBaseDir(paths.baseDir);
|
|
for (const filePath of [`${paths.historyFile}.bak.tmp`, `${paths.historyFile}.tmp`, `${paths.historyFile}.bak`, paths.historyFile]) {
|
|
fs.rmSync(filePath, { force: true });
|
|
}
|
|
}
|