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(["dark", "light"]); const VALID_THEME_PREFERENCES = new Set(["dark", "light", "system"]); const VALID_EXTRACT_CPU_PRIORITIES = new Set(["high", "middle", "low"]); const VALID_HISTORY_RETENTION_MODES = new Set(["never", "session", "permanent"]); const VALID_LOG_STORAGE_LOCATIONS = new Set(["appdata", "desktop"]); const VALID_PACKAGE_PRIORITIES = new Set(["high", "normal", "low"]); const VALID_DOWNLOAD_STATUSES = new Set([ "queued", "validating", "downloading", "paused", "reconnect_wait", "extracting", "integrity_check", "completed", "failed", "cancelled" ]); const VALID_ITEM_PROVIDERS = new Set(["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; 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(); 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(); 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> { if (!raw || typeof raw !== "object" || Array.isArray(raw)) { return {}; } const result: Partial> = {}; for (const [key, value] of Object.entries(raw as Record)) { 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 { if (!raw || typeof raw !== "object" || Array.isArray(raw)) { return {}; } const allowed = new Set(allowedKeys); const result: Record = {}; for (const [key, value] of Object.entries(raw as Record)) { 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 { const allowed = new Set([...megaIds, ...debridLinkIds, ...realDebridIds, ...(deepbridConfigured ? ["svc-deepbrid"] : [])]); const result: Record = {}; if (value && typeof value === "object" && !Array.isArray(value)) { for (const [storedKey, raw] of Object.entries(value as Record)) { 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; 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(); 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 { if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {}; const result: Record = {}; for (const [key, value] of Object.entries(raw as Record)) { 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(); 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(["", "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), 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 | null { if (!value || typeof value !== "object" || Array.isArray(value)) { return null; } return value as Record; } 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, 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).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).megaDebridApiDisabledAccountIds; } if (!Object.prototype.hasOwnProperty.call(parsed, "megaDebridWebDisabledAccountIds")) { delete (mergedInput as Partial).megaDebridWebDisabledAccountIds; } if (!Object.prototype.hasOwnProperty.call(parsed, "realDebridApiTokens")) { delete (mergedInput as Partial).realDebridApiTokens; } if (!Object.prototype.hasOwnProperty.call(parsed, "realDebridWebAccountIds")) { delete (mergedInput as Partial).realDebridWebAccountIds; } if (!Object.prototype.hasOwnProperty.call(parsed, "notifyPackageSuccessMode")) { delete (mergedInput as Partial).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 = {}; 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, createdAt: clampNumber(item.createdAt, now, 0, Number.MAX_SAFE_INTEGER), updatedAt: clampNumber(item.updatedAt, now, 0, Number.MAX_SAFE_INTEGER) }; } const packagesById: Record = {}; 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(); 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 | null = null; async function writeSettingsPayload(paths: StoragePaths, settings: AppSettings, generation: number): Promise { 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; 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 { return new Promise((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 { 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 | null = null; async function writeSessionPayload(paths: StoragePaths, payload: string, generation: number): Promise { 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; 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 { return new Promise((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; resolveReleased: () => void; } export interface PersistenceBarrier { release: (options: { replayBlocked: boolean }) => Promise; } 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 { return new Promise((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 { return new Promise((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 { 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 { while (activePersistenceBarrier) { await activePersistenceBarrier.released; } let resolveReleased = () => {}; const released = new Promise((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 { 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 }); } }