1865 lines
77 KiB
TypeScript
1865 lines
77 KiB
TypeScript
import path from "node:path";
|
||
import os from "node:os";
|
||
import v8 from "node:v8";
|
||
import { randomUUID } from "node:crypto";
|
||
import { app } from "electron";
|
||
import {
|
||
AddLinksPayload,
|
||
AccountCheckScope,
|
||
AllDebridHostInfo,
|
||
AppSettings,
|
||
AccountCommand,
|
||
AccountCommandResult,
|
||
AccountCredentialCheckInput,
|
||
AccountSecretRequest,
|
||
AccountSecretResult,
|
||
ArchivePasswordListResult,
|
||
DebridAccountStatus,
|
||
DebridProvider,
|
||
DuplicatePolicy,
|
||
EnableRemoteDiagnosticsInput,
|
||
HistoryEntry,
|
||
PackagePriority,
|
||
ParsedPackageInput,
|
||
RemoteDiagnosticsInfo,
|
||
SessionStats,
|
||
StartConflictEntry,
|
||
StartConflictResolutionResult,
|
||
UiSnapshot,
|
||
UpdateCheckResult,
|
||
UpdateInstallProgress,
|
||
UpdateInstallResult
|
||
} from "../shared/types";
|
||
import { resetDebridLinkApiKeyDailyUsage, resetProviderDailyUsage } from "../shared/provider-daily-limits";
|
||
import { importDlcContainers } from "./container";
|
||
import { APP_VERSION, ONLINE_BACKUP_API_URL } from "./constants";
|
||
import { DownloadManager } from "./download-manager";
|
||
import { fetchAllDebridHostInfo, fetchDebridLinkHostLimits } from "./debrid";
|
||
import { checkAllDebridAccounts, checkDebridLinkKey, checkDeepbridAccount, checkMegaDebridAccount, checkRealDebridAccount, retainConfiguredRealDebridStatuses } from "./account-check";
|
||
import { parseMegaDebridAccounts } from "../shared/mega-debrid-accounts";
|
||
import { getMegaDebridAccountsForMode } from "../shared/mega-debrid-accounts";
|
||
import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
|
||
import { getRealDebridAccounts, isRealDebridWebAccountId, type RealDebridWebAccountEntry } from "../shared/real-debrid-accounts";
|
||
import type { RealDebridLoginRequest } from "../shared/preload-api";
|
||
import { applyAccountCommand, resolveStoredAccountSecret } from "./account-commands";
|
||
import { collectAccountStatusRedactionValues, sanitizeDebridAccountStatus, sanitizeDebridAccountStatuses } from "./account-status-sanitizer";
|
||
import { createRendererState } from "./renderer-state";
|
||
import { parseCollectorInput } from "./link-parser";
|
||
import { enrichCollectorPackages, prepareCollectorContainers, prepareCollectorText } from "./collector-inspection";
|
||
import type {
|
||
CollectorEnrichmentRequest,
|
||
CollectorInspectionResult,
|
||
CollectorTextPreparationRequest
|
||
} from "../shared/collector";
|
||
import { configureLogger, flushLoggerSync, getLogFilePath, logger } from "./logger";
|
||
import { AllDebridWebFallback } from "./all-debrid-web";
|
||
import { BestDebridWebFallback } from "./bestdebrid-web";
|
||
import { RealDebridWebFallback } from "./realdebrid-web";
|
||
import { getItemLogPath, initItemLogs, shutdownItemLogs } from "./item-log";
|
||
import { getPackageLogPath, initPackageLogs, shutdownPackageLogs } from "./package-log";
|
||
import { initSessionLog, getSessionLogPath, shutdownSessionLog } from "./session-log";
|
||
import { MegaWebFallback } from "./mega-web-fallback";
|
||
import { acquirePersistenceBarrier, addHistoryEntry, addHistoryEntryForRetention, cancelPendingAsyncSaves, clearHistory, createFileRollback, createHistoryRollback, createStoragePaths, loadHistory, loadHistoryForRetention, loadSessionWithStatus, loadSettings, normalizeHistoryEntry, normalizeLoadedSession, normalizeLoadedSessionTransientFields, normalizeSettings, removeHistoryEntries, replaceHistory, resetHistoryForRetention, saveHistory, saveSession, saveSettings } from "./storage";
|
||
import { abortActiveUpdateDownload, checkGitHubUpdate, installLatestUpdate } from "./update";
|
||
import { runInstallWithResume } from "./update-install-flow";
|
||
import { rotateDebugToken, startDebugServer, stopDebugServer, restartDebugServer, getDebugServerRuntimeStatus, getActiveDebugToken, getDebugAllowlist, writeDebugServerConfig, clearDebugToken } from "./debug-server";
|
||
import { encodeConnectionCode, loadRemoteMeta, saveRemoteMeta } from "./connection-code";
|
||
import { encryptBackup, decryptBackup } from "./backup-crypto";
|
||
import { buildBackupPayload, planBackupImport, resolveRemoteDiagnosticsRestore, BackupRemoteDiagnostics } from "./backup-payload";
|
||
import { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "./audit-log";
|
||
import { initAccountRotationLog, shutdownAccountRotationLog } from "./account-rotation-log";
|
||
import { initConversionLog, shutdownConversionLog } from "./conversion-trace";
|
||
import { runStartupHealthCheck } from "./startup-health-check";
|
||
import { getDebugSetupCheck } from "./debug-setup";
|
||
import { buildLinkExportSelection, serializeLinkExportText } from "./link-export";
|
||
import { getRenameLogPath, initRenameLog, shutdownRenameLog } from "./rename-log";
|
||
import { getDesktopRenameLogPath, initDesktopRenameLogAt, shutdownDesktopRenameLog } from "./desktop-rename-log";
|
||
import { buildAccountSummary, buildNotificationSupportPayload, diffAccountSummary, type NotificationSupportPayload } from "./support-data";
|
||
import { buildSupportBundle, getSupportBundleDefaultFileName } from "./support-bundle";
|
||
import { getTraceConfig, getTraceLogPath, initTraceLog, logTraceEvent, setTraceEnabled, shutdownTraceLog } from "./trace-log";
|
||
import { CollectorStore } from "./collector-store";
|
||
import type { CollectorPersistenceState } from "../shared/collector";
|
||
import type { DebugSetupCheckResult, SupportTraceConfig } from "../shared/types";
|
||
import { createOnlineBackup, downloadOnlineBackup, uploadOnlineBackup } from "./online-backup";
|
||
import { captureOnlineProxyList, getManagedOnlineProxyListPath, writeImportedOnlineProxyList } from "./online-proxy-list";
|
||
import { overlayLiveUsageCounters } from "./settings-live-overlay";
|
||
import { getLegacyDesktopLogDirectory, migrateLogDirectories, prepareLogDirectory, resolveLogDirectory } from "./log-storage";
|
||
import { normalizeStatisticsLedger, saveStatisticsLedger } from "./statistics-ledger";
|
||
import { NotificationOutbox } from "./notification-outbox";
|
||
import { sendNotification } from "./notify";
|
||
import { DownloadHealthMonitor } from "./download-health-monitor";
|
||
import { shouldDeferAutoResumeToDailyStart } from "./daily-start-scheduler";
|
||
import { configureNetworkProxy, getNetworkProxyState, shutdownNetworkProxy } from "./network-proxy";
|
||
import { createProxyOnlyAccountError, resolveProxyOnlyAccountErrorCode } from "./proxy-account-errors";
|
||
|
||
function sanitizeSettingsPatch(partial: Partial<AppSettings>): Partial<AppSettings> {
|
||
const entries = Object.entries(partial || {}).filter(([, value]) => value !== undefined);
|
||
return Object.fromEntries(entries) as Partial<AppSettings>;
|
||
}
|
||
|
||
function settingsFingerprint(settings: AppSettings): string {
|
||
return JSON.stringify(normalizeSettings(settings));
|
||
}
|
||
|
||
type PendingRealDebridWebAccount = {
|
||
generation: number;
|
||
dailyLimitBytes: number;
|
||
};
|
||
|
||
export class AppController {
|
||
private settings: AppSettings;
|
||
|
||
private manager: DownloadManager;
|
||
|
||
private megaWebFallback: MegaWebFallback;
|
||
|
||
private realDebridWebFallbacks = new Map<string, RealDebridWebFallback>();
|
||
|
||
private pendingRealDebridWebAccountIds = new Map<string, PendingRealDebridWebAccount>();
|
||
|
||
private realDebridWebGenerations = new Map<string, number>();
|
||
|
||
private realDebridWebAuthenticationTasks = new Map<string, Promise<void>>();
|
||
|
||
private allDebridWebFallback: AllDebridWebFallback;
|
||
|
||
private bestDebridWebFallback: BestDebridWebFallback;
|
||
|
||
private lastUpdateCheck: UpdateCheckResult | null = null;
|
||
|
||
private lastUpdateCheckAt = 0;
|
||
|
||
private storagePaths = createStoragePaths(path.join(app.getPath("userData"), "runtime"));
|
||
|
||
private collectorStore = new CollectorStore(this.storagePaths.collectorFile);
|
||
|
||
private notificationOutbox: NotificationOutbox;
|
||
|
||
private downloadHealthMonitor: DownloadHealthMonitor;
|
||
|
||
private downloadHealthTimer: NodeJS.Timeout | null = null;
|
||
|
||
private downloadHealthEvaluation: Promise<void> | null = null;
|
||
|
||
private logDirectory = this.storagePaths.baseDir;
|
||
|
||
private onStateHandler: ((snapshot: UiSnapshot) => void) | null = null;
|
||
|
||
private onHistoryEntryAddedHandler: ((entry: HistoryEntry) => void) | null = null;
|
||
|
||
private autoResumePending = false;
|
||
private runtimeStatsTimer: NodeJS.Timeout | null = null;
|
||
private lastMemoryWarnAt = 0;
|
||
|
||
public constructor() {
|
||
configureLogger(this.storagePaths.baseDir);
|
||
this.settings = loadSettings(this.storagePaths);
|
||
flushLoggerSync();
|
||
const desktopDir = this.getDesktopDirectory();
|
||
const requestedLogDirectory = resolveLogDirectory(this.storagePaths.baseDir, desktopDir, this.settings.logStorageLocation);
|
||
if (!prepareLogDirectory(requestedLogDirectory)) {
|
||
this.settings = normalizeSettings({ ...this.settings, logStorageLocation: "appdata" });
|
||
saveSettings(this.storagePaths, this.settings);
|
||
this.logDirectory = this.storagePaths.baseDir;
|
||
} else {
|
||
this.logDirectory = requestedLogDirectory;
|
||
const legacyDirectory = this.settings.logStorageLocation === "desktop"
|
||
? getLegacyDesktopLogDirectory(desktopDir)
|
||
: null;
|
||
migrateLogDirectories(
|
||
[this.storagePaths.baseDir, ...(legacyDirectory ? [legacyDirectory] : [])],
|
||
this.logDirectory
|
||
);
|
||
}
|
||
this.applyNetworkProxyConfiguration();
|
||
this.initializeLogStorage();
|
||
this.runHistoryLifecycleCleanup("Start", () => resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode));
|
||
const loadResult = loadSessionWithStatus(this.storagePaths);
|
||
const session = loadResult.session;
|
||
this.notificationOutbox = new NotificationOutbox({
|
||
filePath: this.storagePaths.notificationOutboxFile,
|
||
autoDrain: true,
|
||
send: (event) => sendNotification(this.settings.notifyUrl, {
|
||
title: event.payload.title,
|
||
message: event.payload.description || "",
|
||
mention: this.settings.notifyMention,
|
||
color: event.payload.color ?? (event.priority === "error" ? 0xe74c3c : 0x2ecc71),
|
||
fields: event.payload.fields,
|
||
timestamp: event.createdAt
|
||
}),
|
||
onDelivered: (event, deliveredAt) => {
|
||
return this.downloadHealthMonitor?.acknowledgeDelivery(
|
||
event,
|
||
deliveredAt,
|
||
this.settings.notifyStallCooldownMinutes * 60_000
|
||
);
|
||
}
|
||
});
|
||
this.downloadHealthMonitor = new DownloadHealthMonitor(this.storagePaths.notificationHealthFile);
|
||
void this.notificationOutbox.drain().catch((error) => {
|
||
logger.warn(`Notification-Outbox konnte nicht gestartet werden: ${String(error)}`);
|
||
});
|
||
this.megaWebFallback = new MegaWebFallback(() => ({
|
||
login: this.settings.megaLogin,
|
||
password: this.settings.megaPassword
|
||
}));
|
||
this.allDebridWebFallback = new AllDebridWebFallback(() => this.settings.rememberToken);
|
||
this.bestDebridWebFallback = new BestDebridWebFallback(() => this.settings.rememberToken);
|
||
this.manager = new DownloadManager(this.settings, session, this.storagePaths, {
|
||
megaWebUnrestrict: (link: string, signal?: AbortSignal, account?: { login: string; password: string }) => this.megaWebFallback.unrestrict(link, signal, account),
|
||
allDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.allDebridWebFallback.unrestrict(link, signal),
|
||
realDebridWebUnrestrict: (accountId: string, link: string, signal?: AbortSignal) => this.unrestrictRealDebridWebAccount(accountId, link, signal),
|
||
bestDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.bestDebridWebFallback.unrestrict(link, signal),
|
||
invalidateMegaSession: () => this.megaWebFallback.invalidateSession(),
|
||
protectEmptyClobber: loadResult.status === "empty-unreadable",
|
||
enqueueNotification: (event) => this.notificationOutbox.enqueue(event),
|
||
onHistoryEntry: (entry: HistoryEntry) => {
|
||
this.recordHistoryEntry(entry);
|
||
}
|
||
});
|
||
this.manager.on("state", (snapshot: UiSnapshot) => {
|
||
this.onStateHandler?.(snapshot);
|
||
});
|
||
void this.evaluateDownloadHealth();
|
||
this.downloadHealthTimer = setInterval(() => {
|
||
void this.evaluateDownloadHealth();
|
||
}, 15_000);
|
||
this.downloadHealthTimer.unref?.();
|
||
logger.info(`App gestartet v${APP_VERSION}`);
|
||
logger.info(`Log-Datei: ${getLogFilePath()}`);
|
||
logAuditEvent("INFO", "App gestartet", {
|
||
appVersion: APP_VERSION,
|
||
runtimeDir: this.storagePaths.baseDir
|
||
});
|
||
try {
|
||
const report = runStartupHealthCheck(this.settings, this.storagePaths);
|
||
if (report.errorCount > 0 || report.warnCount > 0) {
|
||
logger.warn(`Health-Check: ${report.errorCount} Fehler, ${report.warnCount} Warnungen, ${report.infoCount} Info`);
|
||
} else {
|
||
logger.info(`Health-Check: alles OK (${report.infoCount} Info)`);
|
||
}
|
||
for (const finding of report.findings) {
|
||
const line = finding.hint
|
||
? `Health-Check [${finding.code}]: ${finding.message} — ${finding.hint}`
|
||
: `Health-Check [${finding.code}]: ${finding.message}`;
|
||
if (finding.severity === "ERROR") {
|
||
logger.error(line);
|
||
} else if (finding.severity === "WARN") {
|
||
logger.warn(line);
|
||
} else {
|
||
logger.info(line);
|
||
}
|
||
if (finding.severity !== "INFO") {
|
||
logAuditEvent(finding.severity, `Health-Check: ${finding.code}`, {
|
||
message: finding.message,
|
||
hint: finding.hint || ""
|
||
});
|
||
}
|
||
}
|
||
} catch (err) {
|
||
logger.warn(`Health-Check uebersprungen (Fehler): ${String((err as Error).message || err)}`);
|
||
}
|
||
startDebugServer(this.manager, this.storagePaths.baseDir, () => this.getNotificationSupportPayload());
|
||
this.runtimeStatsTimer = setInterval(() => {
|
||
this.manager.persistRuntimeStats();
|
||
this.settings = this.manager.getSettings();
|
||
this.checkMemoryPressure();
|
||
}, 60_000);
|
||
this.runtimeStatsTimer.unref?.();
|
||
|
||
if (this.settings.autoResumeOnStart && !shouldDeferAutoResumeToDailyStart(this.settings, loadResult.wasRunning)) {
|
||
const snapshot = this.manager.getSnapshot();
|
||
const hasPending = Object.values(snapshot.session.items).some((item) => item.status === "queued" || item.status === "reconnect_wait");
|
||
if (hasPending && this.hasAnyProviderToken(this.settings)) {
|
||
if (this.onStateHandler) {
|
||
this.beginAutoResume();
|
||
} else {
|
||
this.autoResumePending = true;
|
||
logger.info("Auto-Resume beim Start vorgemerkt");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Early-warning for OOM on a long-running process. Measured against the V8
|
||
// heap_size_limit (the real ceiling at which the process is killed), NOT against
|
||
// heapTotal: V8 routinely runs near-full of its current heapTotal just before it
|
||
// grows it, so a heapUsed/heapTotal ratio would cry wolf and — since every WARN
|
||
// now feeds the error ring — crowd real failures out. Throttled to 1 warning per
|
||
// 5 min so a genuine sustained-pressure run does not spam the log/ring.
|
||
private checkMemoryPressure(): void {
|
||
try {
|
||
const mem = process.memoryUsage();
|
||
const heapLimit = v8.getHeapStatistics().heap_size_limit;
|
||
const ratio = heapLimit > 0 ? mem.heapUsed / heapLimit : 0;
|
||
if (ratio < 0.9) {
|
||
return;
|
||
}
|
||
const now = Date.now();
|
||
if (now - this.lastMemoryWarnAt < 5 * 60_000) {
|
||
return;
|
||
}
|
||
this.lastMemoryWarnAt = now;
|
||
const mb = (bytes: number): number => Math.round(bytes / 1048576);
|
||
logger.warn(
|
||
`Speicherdruck: heapUsed=${mb(mem.heapUsed)}MB von Limit ${mb(heapLimit)}MB ` +
|
||
`(${Math.round(ratio * 100)}%), heapTotal=${mb(mem.heapTotal)}MB, rss=${mb(mem.rss)}MB, external=${mb(mem.external)}MB`
|
||
);
|
||
} catch {
|
||
}
|
||
}
|
||
|
||
private hasAnyProviderToken(settings: AppSettings): boolean {
|
||
return Boolean(
|
||
settings.token.trim()
|
||
|| settings.realDebridUseWebLogin
|
||
|| (settings.megaLogin.trim() && settings.megaPassword.trim())
|
||
|| settings.bestToken.trim()
|
||
|| settings.bestDebridUseWebLogin
|
||
|| settings.allDebridUseWebLogin
|
||
|| settings.allDebridToken.trim()
|
||
|| (settings.ddownloadLogin.trim() && settings.ddownloadPassword.trim())
|
||
|| settings.oneFichierApiKey.trim()
|
||
);
|
||
}
|
||
|
||
public get onState(): ((snapshot: UiSnapshot) => void) | null {
|
||
return this.onStateHandler;
|
||
}
|
||
|
||
public set onState(handler: ((snapshot: UiSnapshot) => void) | null) {
|
||
this.onStateHandler = handler;
|
||
if (handler) {
|
||
handler(this.manager.getSnapshot());
|
||
if (this.autoResumePending) {
|
||
this.autoResumePending = false;
|
||
this.beginAutoResume();
|
||
} else {
|
||
this.manager.triggerIdleExtractions();
|
||
}
|
||
}
|
||
}
|
||
|
||
private beginAutoResume(): void {
|
||
void this.manager.getStartConflicts().then((conflicts) => {
|
||
const excludePackageIds = new Set(conflicts.map((conflict) => conflict.packageId));
|
||
if (excludePackageIds.size > 0) {
|
||
const names = conflicts.map((conflict) => conflict.packageName).join(", ");
|
||
logger.info(`Auto-Resume: ${excludePackageIds.size} Paket(e) mit Start-Konflikt zurückgehalten (${names}); übrige Pakete starten`);
|
||
} else {
|
||
logger.info("Auto-Resume beim Start aktiviert (keine Start-Konflikte)");
|
||
}
|
||
void this.manager.start(excludePackageIds.size > 0 ? { excludePackageIds } : undefined)
|
||
.catch((err) => logger.warn(`Auto-Resume Start Fehler: ${String(err)}`));
|
||
}).catch((err) => logger.warn(`Auto-Resume Konflikt-Check Fehler: ${String(err)}`));
|
||
}
|
||
|
||
public getSnapshot(): UiSnapshot {
|
||
return this.manager.getSnapshot();
|
||
}
|
||
|
||
public getVersion(): string {
|
||
return APP_VERSION;
|
||
}
|
||
|
||
public getSettings(): AppSettings {
|
||
return this.settings;
|
||
}
|
||
|
||
public getAuditLogPath(): string | null {
|
||
return getAuditLogPath();
|
||
}
|
||
|
||
public getRenameLogPath(): string | null {
|
||
return getRenameLogPath();
|
||
}
|
||
|
||
public getDesktopRenameLogPath(): string | null {
|
||
return getDesktopRenameLogPath();
|
||
}
|
||
|
||
public getTraceLogPath(): string | null {
|
||
return getTraceLogPath();
|
||
}
|
||
|
||
public getTraceConfig(): SupportTraceConfig {
|
||
return getTraceConfig();
|
||
}
|
||
|
||
public rotateDebugToken(): { path: string; token: string } {
|
||
const rotated = rotateDebugToken(this.storagePaths.baseDir);
|
||
this.audit("WARN", "Debug-Token rotiert", { path: rotated.path });
|
||
return rotated;
|
||
}
|
||
|
||
private getSuggestedRemoteHosts(): string[] {
|
||
const hosts: string[] = [];
|
||
try {
|
||
const interfaces = os.networkInterfaces();
|
||
for (const entry of Object.values(interfaces)) {
|
||
for (const net of entry || []) {
|
||
if (net.family === "IPv4" && !net.internal && net.address) {
|
||
hosts.push(net.address);
|
||
}
|
||
}
|
||
}
|
||
} catch {
|
||
}
|
||
return [...new Set(hosts)];
|
||
}
|
||
|
||
public getRemoteDiagnostics(): RemoteDiagnosticsInfo {
|
||
const status = getDebugServerRuntimeStatus();
|
||
const meta = loadRemoteMeta(this.storagePaths.baseDir);
|
||
const token = getActiveDebugToken();
|
||
const allowlist = getDebugAllowlist();
|
||
const suggestedHosts = this.getSuggestedRemoteHosts();
|
||
const host = meta.publicHost
|
||
|| (status.localOnly ? "127.0.0.1" : (suggestedHosts[0] || status.host));
|
||
const code = (status.hasToken && token && host)
|
||
? encodeConnectionCode({ host, port: status.port, token, name: meta.name || undefined })
|
||
: null;
|
||
return {
|
||
status,
|
||
code,
|
||
publicHost: meta.publicHost,
|
||
name: meta.name,
|
||
allowlist,
|
||
suggestedHosts
|
||
};
|
||
}
|
||
|
||
public async enableRemoteDiagnostics(input: EnableRemoteDiagnosticsInput): Promise<RemoteDiagnosticsInfo> {
|
||
const baseDir = this.storagePaths.baseDir;
|
||
const port = input.port && Number.isInteger(input.port) && input.port >= 1024 && input.port <= 65535
|
||
? input.port
|
||
: 9868;
|
||
const bindHost = input.hostMode === "network" ? "0.0.0.0" : "127.0.0.1";
|
||
const allowlist = (input.allowlist || []).map((entry) => entry.trim()).filter((entry) => entry.length > 0);
|
||
if (input.hostMode === "network" && allowlist.length === 0) {
|
||
throw new Error("Netzwerk-Freigabe erfordert mindestens eine erlaubte IP oder CIDR in der Allowlist.");
|
||
}
|
||
let token = getActiveDebugToken();
|
||
if (!token || input.rotateToken) {
|
||
token = rotateDebugToken(baseDir).token;
|
||
}
|
||
writeDebugServerConfig({ host: bindHost, port, allowlist });
|
||
saveRemoteMeta(baseDir, { publicHost: (input.publicHost || "").trim(), name: (input.name || "").trim() });
|
||
await restartDebugServer();
|
||
this.audit("WARN", "Ferndiagnose aktiviert", {
|
||
host: bindHost,
|
||
port,
|
||
allowlistCount: allowlist.length,
|
||
localOnly: input.hostMode === "local"
|
||
});
|
||
return this.getRemoteDiagnostics();
|
||
}
|
||
|
||
public async disableRemoteDiagnostics(): Promise<RemoteDiagnosticsInfo> {
|
||
clearDebugToken();
|
||
await restartDebugServer();
|
||
this.audit("WARN", "Ferndiagnose deaktiviert (Token entfernt)");
|
||
return this.getRemoteDiagnostics();
|
||
}
|
||
|
||
public async rotateRemoteDiagnosticsToken(): Promise<RemoteDiagnosticsInfo> {
|
||
rotateDebugToken(this.storagePaths.baseDir);
|
||
await restartDebugServer();
|
||
this.audit("WARN", "Ferndiagnose-Token rotiert");
|
||
return this.getRemoteDiagnostics();
|
||
}
|
||
|
||
private persistRemoteDiagnosticsFromBackup(section: unknown): ReturnType<typeof resolveRemoteDiagnosticsRestore> {
|
||
const restore = resolveRemoteDiagnosticsRestore(section);
|
||
if (!restore) {
|
||
return null;
|
||
}
|
||
writeDebugServerConfig({ host: restore.host, port: restore.port, allowlist: restore.allowlist });
|
||
return restore;
|
||
}
|
||
|
||
private async restartRemoteDiagnosticsRestore(
|
||
restore: ReturnType<typeof resolveRemoteDiagnosticsRestore>,
|
||
restartNow: boolean
|
||
): Promise<void> {
|
||
if (!restore) {
|
||
return;
|
||
}
|
||
if (restartNow) {
|
||
await restartDebugServer();
|
||
}
|
||
}
|
||
|
||
private auditRemoteDiagnosticsRestore(
|
||
restore: ReturnType<typeof resolveRemoteDiagnosticsRestore>,
|
||
restartNow: boolean
|
||
): void {
|
||
if (!restore) {
|
||
return;
|
||
}
|
||
this.audit("INFO", "Ferndiagnose-Einstellungen aus Backup wiederhergestellt", {
|
||
port: restore.port ?? null,
|
||
allowlistCount: restore.allowlist?.length ?? 0,
|
||
host: restore.host ?? "unveraendert",
|
||
restartNow
|
||
});
|
||
}
|
||
|
||
public getDebugSetupCheck(): DebugSetupCheckResult {
|
||
return getDebugSetupCheck(this.storagePaths.baseDir);
|
||
}
|
||
|
||
private audit(level: "INFO" | "WARN" | "ERROR", message: string, fields?: Record<string, unknown>): void {
|
||
logAuditEvent(level, message, fields);
|
||
logTraceEvent(level, "audit", message, fields);
|
||
}
|
||
|
||
private assertProxyOnlyAccountSetup(): void {
|
||
const code = resolveProxyOnlyAccountErrorCode(this.settings, getNetworkProxyState());
|
||
if (code) throw createProxyOnlyAccountError(code);
|
||
}
|
||
|
||
private throwProxyOnlyAccountFailure(failureText: string): void {
|
||
const code = resolveProxyOnlyAccountErrorCode(this.settings, getNetworkProxyState(), failureText);
|
||
if (code) throw createProxyOnlyAccountError(code);
|
||
}
|
||
|
||
public setTraceEnabled(enabled: boolean, note = "", durationMs?: number): SupportTraceConfig {
|
||
const next = setTraceEnabled(enabled, note, durationMs);
|
||
this.audit("INFO", enabled ? "Support-Trace aktiviert" : "Support-Trace deaktiviert", { note });
|
||
return next;
|
||
}
|
||
|
||
// Carry the live, runtime-maintained usage/status counters onto a settings
|
||
// object about to be applied, so they are never rolled back to a stale snapshot.
|
||
// All-time totals take the max; daily/total usage and account statuses are taken
|
||
// live; per-key Debrid-Link usage is filtered to keys that still exist.
|
||
private overlayLiveUsageCounters(target: AppSettings): void {
|
||
const liveSettings = this.manager.getSettings();
|
||
overlayLiveUsageCounters(target, liveSettings, this.manager.getLiveTotalRuntimeMs());
|
||
}
|
||
|
||
public set onHistoryEntryAdded(handler: ((entry: HistoryEntry) => void) | null) {
|
||
this.onHistoryEntryAddedHandler = handler;
|
||
}
|
||
|
||
private prepareImportedLogStorage(restoredSettings: AppSettings): AppSettings {
|
||
if (this.settings.logStorageLocation === restoredSettings.logStorageLocation) {
|
||
return restoredSettings;
|
||
}
|
||
const nextDirectory = resolveLogDirectory(
|
||
this.storagePaths.baseDir,
|
||
this.getDesktopDirectory(),
|
||
restoredSettings.logStorageLocation
|
||
);
|
||
return prepareLogDirectory(nextDirectory)
|
||
? restoredSettings
|
||
: normalizeSettings({ ...restoredSettings, logStorageLocation: this.settings.logStorageLocation });
|
||
}
|
||
|
||
private createBackupImportRollback(includeDownloads: boolean, additionalFiles: readonly string[] = []): () => void {
|
||
const baseDir = this.storagePaths.baseDir;
|
||
const files = [
|
||
this.storagePaths.configFile,
|
||
`${this.storagePaths.configFile}.bak`,
|
||
`${this.storagePaths.configFile}.tmp`,
|
||
`${this.storagePaths.configFile}.bak.tmp`,
|
||
path.join(baseDir, "debug_host.txt"),
|
||
path.join(baseDir, "debug_port.txt"),
|
||
path.join(baseDir, "debug_allowlist.txt"),
|
||
...additionalFiles
|
||
];
|
||
if (includeDownloads) {
|
||
files.push(
|
||
this.storagePaths.sessionFile,
|
||
`${this.storagePaths.sessionFile}.bak`,
|
||
`${this.storagePaths.sessionFile}.sync.tmp`,
|
||
`${this.storagePaths.sessionFile}.async.tmp`,
|
||
this.storagePaths.historyFile,
|
||
`${this.storagePaths.historyFile}.bak`,
|
||
`${this.storagePaths.historyFile}.tmp`,
|
||
`${this.storagePaths.historyFile}.bak.tmp`,
|
||
this.storagePaths.statisticsFile,
|
||
`${this.storagePaths.statisticsFile}.tmp`
|
||
);
|
||
}
|
||
return createFileRollback([...new Set(files)]);
|
||
}
|
||
|
||
private rollbackImportPersistence(rollback: () => void, context: string): void {
|
||
try {
|
||
rollback();
|
||
} catch (error) {
|
||
logger.error(`Backup-Import konnte nach ${context} nicht zurückgerollt werden: ${String(error)}`);
|
||
}
|
||
}
|
||
|
||
private restoreLogStorageAfterFailedImport(location: AppSettings["logStorageLocation"]): void {
|
||
try {
|
||
this.reconfigureLogStorage(location);
|
||
} catch (error) {
|
||
logger.error(`Log-Speicherort konnte nach fehlgeschlagenem Backup-Import nicht wiederhergestellt werden: ${String(error)}`);
|
||
}
|
||
}
|
||
|
||
private async applySettingsOnlyBackup(
|
||
importedSettings: AppSettings,
|
||
remoteDiagnostics?: unknown,
|
||
restoreRemoteDiagnostics = false,
|
||
onlineProxyListContent?: string | null
|
||
): Promise<{ proxyListRestored: boolean; proxyOnlyDisabled: boolean }> {
|
||
const barrier = await acquirePersistenceBarrier();
|
||
const previousSettings = this.settings;
|
||
let restoredSettings: AppSettings | null = null;
|
||
let rollback: (() => void) | null = null;
|
||
let remoteRestore: ReturnType<typeof resolveRemoteDiagnosticsRestore> = null;
|
||
let logStorageChanged = false;
|
||
let runtimeApplied = false;
|
||
let proxyListRestored = false;
|
||
let proxyOnlyDisabled = false;
|
||
try {
|
||
restoredSettings = this.prepareImportedLogStorage(normalizeSettings(importedSettings));
|
||
if (typeof onlineProxyListContent === "string") {
|
||
restoredSettings = normalizeSettings({
|
||
...restoredSettings,
|
||
proxyListPath: getManagedOnlineProxyListPath(this.storagePaths.baseDir)
|
||
});
|
||
} else if (onlineProxyListContent === null) {
|
||
proxyOnlyDisabled = restoredSettings.proxyDownloadEnabled || Boolean(restoredSettings.proxyListPath.trim());
|
||
restoredSettings = normalizeSettings({
|
||
...restoredSettings,
|
||
proxyDownloadEnabled: false,
|
||
proxyListPath: ""
|
||
});
|
||
}
|
||
this.overlayLiveUsageCounters(restoredSettings);
|
||
const managedProxyListPath = typeof onlineProxyListContent === "string"
|
||
? getManagedOnlineProxyListPath(this.storagePaths.baseDir)
|
||
: null;
|
||
rollback = this.createBackupImportRollback(false, managedProxyListPath ? [managedProxyListPath] : []);
|
||
if (typeof onlineProxyListContent === "string") {
|
||
writeImportedOnlineProxyList(this.storagePaths.baseDir, onlineProxyListContent);
|
||
proxyListRestored = true;
|
||
}
|
||
saveSettings(this.storagePaths, restoredSettings);
|
||
remoteRestore = restoreRemoteDiagnostics
|
||
? this.persistRemoteDiagnosticsFromBackup(remoteDiagnostics)
|
||
: null;
|
||
if (previousSettings.logStorageLocation !== restoredSettings.logStorageLocation) {
|
||
if (!this.reconfigureLogStorage(restoredSettings.logStorageLocation)) {
|
||
throw new Error("Log-Speicherort konnte nicht wiederhergestellt werden");
|
||
}
|
||
logStorageChanged = true;
|
||
}
|
||
await this.restartRemoteDiagnosticsRestore(remoteRestore, restoreRemoteDiagnostics);
|
||
this.overlayLiveUsageCounters(restoredSettings);
|
||
saveSettings(this.storagePaths, restoredSettings);
|
||
this.settings = restoredSettings;
|
||
this.applyNetworkProxyConfiguration();
|
||
runtimeApplied = true;
|
||
this.manager.setSettings(this.settings, { settingsOnlyImport: true });
|
||
this.manager.persistNowSync();
|
||
await barrier.release({ replayBlocked: false });
|
||
} catch (error) {
|
||
if (rollback) {
|
||
this.rollbackImportPersistence(rollback, "fehlgeschlagenem Settings-Import");
|
||
}
|
||
this.settings = previousSettings;
|
||
this.applyNetworkProxyConfiguration();
|
||
if (runtimeApplied) {
|
||
try {
|
||
this.manager.setSettings(previousSettings, { settingsOnlyImport: true });
|
||
} catch (runtimeError) {
|
||
logger.error(`Backup-Import konnte die vorherigen Laufzeiteinstellungen nicht wiederherstellen: ${String(runtimeError)}`);
|
||
}
|
||
}
|
||
if (logStorageChanged) {
|
||
this.restoreLogStorageAfterFailedImport(previousSettings.logStorageLocation);
|
||
}
|
||
if (remoteRestore && restoreRemoteDiagnostics) {
|
||
try {
|
||
await restartDebugServer();
|
||
} catch (runtimeError) {
|
||
logger.error(`Ferndiagnose konnte nach fehlgeschlagenem Backup-Import nicht wiederhergestellt werden: ${String(runtimeError)}`);
|
||
}
|
||
}
|
||
try {
|
||
await barrier.release({ replayBlocked: true });
|
||
} catch (releaseError) {
|
||
logger.error(`Blockierte Persistenz konnte nach fehlgeschlagenem Backup-Import nicht fortgesetzt werden: ${String(releaseError)}`);
|
||
}
|
||
throw error;
|
||
}
|
||
this.auditRemoteDiagnosticsRestore(remoteRestore, restoreRemoteDiagnostics);
|
||
return { proxyListRestored, proxyOnlyDisabled };
|
||
}
|
||
|
||
public updateSettings(partial: Partial<AppSettings>): AppSettings {
|
||
const sanitizedPatch = sanitizeSettingsPatch(partial);
|
||
const previousSettings = this.settings;
|
||
let nextSettings = normalizeSettings({
|
||
...previousSettings,
|
||
...sanitizedPatch
|
||
});
|
||
|
||
if (settingsFingerprint(nextSettings) === settingsFingerprint(previousSettings)) {
|
||
return previousSettings;
|
||
}
|
||
|
||
if (previousSettings.logStorageLocation !== nextSettings.logStorageLocation
|
||
&& !this.reconfigureLogStorage(nextSettings.logStorageLocation)) {
|
||
nextSettings = normalizeSettings({
|
||
...nextSettings,
|
||
logStorageLocation: previousSettings.logStorageLocation
|
||
});
|
||
}
|
||
this.overlayLiveUsageCounters(nextSettings);
|
||
const retentionChanged = previousSettings.historyRetentionMode !== nextSettings.historyRetentionMode;
|
||
const historyLimitsChanged = previousSettings.historyMaxEntries !== nextSettings.historyMaxEntries
|
||
|| previousSettings.historyMaxAgeDays !== nextSettings.historyMaxAgeDays;
|
||
const rollbackHistory = retentionChanged || historyLimitsChanged ? createHistoryRollback(this.storagePaths) : null;
|
||
try {
|
||
if (retentionChanged) {
|
||
resetHistoryForRetention(this.storagePaths, nextSettings.historyRetentionMode);
|
||
} else if (historyLimitsChanged && nextSettings.historyRetentionMode !== "never") {
|
||
saveHistory(this.storagePaths, loadHistory(this.storagePaths), {
|
||
maxEntries: nextSettings.historyMaxEntries,
|
||
maxAgeDays: nextSettings.historyMaxAgeDays
|
||
});
|
||
}
|
||
saveSettings(this.storagePaths, nextSettings);
|
||
} catch (error) {
|
||
this.rollbackHistoryAfterFailure(rollbackHistory, "fehlgeschlagener Einstellungsänderung");
|
||
throw error;
|
||
}
|
||
this.settings = nextSettings;
|
||
this.applyNetworkProxyConfiguration();
|
||
this.manager.setSettings(this.settings);
|
||
this.audit("INFO", "Einstellungen aktualisiert", {
|
||
changedKeys: Object.keys(sanitizedPatch),
|
||
accountChanges: diffAccountSummary(previousSettings, this.settings)
|
||
});
|
||
this.pruneRealDebridWebFallbacks(previousSettings, this.settings);
|
||
if (previousSettings.rememberToken && !this.settings.rememberToken) {
|
||
const accountIds = new Set([...this.settings.realDebridWebAccountIds, ...this.realDebridWebFallbacks.keys()]);
|
||
for (const accountId of accountIds) {
|
||
void this.cleanupRealDebridWebAccount(accountId, true).catch((error) => {
|
||
logger.warn(`Real-Debrid Web-Session konnte nicht gelöscht werden (${accountId}): ${String(error)}`);
|
||
});
|
||
}
|
||
void this.allDebridWebFallback.clearSessions().catch((error) => {
|
||
logger.warn(`AllDebrid Web-Session konnte nicht gelöscht werden: ${String(error)}`);
|
||
});
|
||
void this.bestDebridWebFallback.clearSessions().catch((error) => {
|
||
logger.warn(`BestDebrid Web-Session konnte nicht gelöscht werden: ${String(error)}`);
|
||
});
|
||
}
|
||
return this.settings;
|
||
}
|
||
|
||
public async executeAccountCommand(command: AccountCommand): Promise<AccountCommandResult> {
|
||
const applied = applyAccountCommand(this.settings, command);
|
||
let checkedStatus: DebridAccountStatus | null = null;
|
||
const redactions = collectAccountStatusRedactionValues(applied.settings, command);
|
||
if (command.action !== "delete" && ["megadebrid-api", "megadebrid-web", "debridlink-api", "realdebrid-api", "deepbrid-api"].includes(command.kind)) {
|
||
this.assertProxyOnlyAccountSetup();
|
||
}
|
||
if (command.action !== "delete" && applied.response.accountId && (command.kind === "megadebrid-api" || command.kind === "megadebrid-web")) {
|
||
const mode = command.kind === "megadebrid-web" ? "web" : "api";
|
||
const account = getMegaDebridAccountsForMode(applied.settings, mode).find((entry) => entry.id === applied.response.accountId);
|
||
if (!account) throw new Error("Account-Payload ist ungültig");
|
||
checkedStatus = await checkMegaDebridAccount(account);
|
||
}
|
||
if (command.action !== "delete" && applied.response.accountId && command.kind === "debridlink-api") {
|
||
const key = parseDebridLinkApiKeys(applied.settings.debridLinkApiKeys).find((entry) => entry.id === applied.response.accountId);
|
||
if (!key) throw new Error("Account-Payload ist ungültig");
|
||
checkedStatus = await checkDebridLinkKey(key);
|
||
}
|
||
if (command.action !== "delete" && applied.response.accountId && command.kind === "realdebrid-api") {
|
||
const account = getRealDebridAccounts(applied.settings).find((entry) => entry.id === applied.response.accountId && entry.kind === "api");
|
||
if (!account) throw new Error("Account-Payload ist ungültig");
|
||
checkedStatus = await checkRealDebridAccount(account);
|
||
}
|
||
if (command.action !== "delete" && applied.response.accountId && command.kind === "deepbrid-api") {
|
||
checkedStatus = await checkDeepbridAccount(applied.settings.deepbridApiKey);
|
||
}
|
||
if (checkedStatus) {
|
||
checkedStatus = sanitizeDebridAccountStatus(checkedStatus, redactions);
|
||
}
|
||
if (checkedStatus && !checkedStatus.valid) {
|
||
this.throwProxyOnlyAccountFailure(checkedStatus.message || "");
|
||
throw new Error(checkedStatus.message || "Zugangsdaten ungültig");
|
||
}
|
||
this.updateSettings(applied.settings);
|
||
if (checkedStatus) this.manager.applyDebridAccountStatuses([checkedStatus]);
|
||
const state = createRendererState(this.settings);
|
||
this.audit("INFO", "Account aktualisiert", { action: command.action, kind: command.kind, accountId: applied.response.accountId });
|
||
return { ...applied.response, ...state };
|
||
}
|
||
|
||
public revealAccountSecret(request: AccountSecretRequest): AccountSecretResult {
|
||
const secret = resolveStoredAccountSecret(this.settings, request);
|
||
this.audit("INFO", "Gespeicherter Account-Zugang explizit angezeigt", { kind: request.kind });
|
||
return { secret };
|
||
}
|
||
|
||
public getArchivePasswordList(): ArchivePasswordListResult {
|
||
const passwords = this.settings.archivePasswordList;
|
||
const entryCount = passwords.split(/\r?\n/).filter((entry) => entry.trim().length > 0).length;
|
||
this.audit("INFO", "Archiv-Passwortliste explizit angezeigt", { entryCount });
|
||
return { passwords };
|
||
}
|
||
|
||
public async checkAccountCredentials(input: AccountCredentialCheckInput): Promise<DebridAccountStatus> {
|
||
this.assertProxyOnlyAccountSetup();
|
||
const redactions = collectAccountStatusRedactionValues(this.settings, input);
|
||
if (input.kind === "deepbrid-api") {
|
||
const key = input.secret?.trim() || this.settings.deepbridApiKey.trim();
|
||
if (!key) throw new Error("Account-Payload ist ungültig");
|
||
const status = sanitizeDebridAccountStatus(await checkDeepbridAccount(key), redactions);
|
||
if (!input.secret && input.accountId === "svc-deepbrid" && this.settings.deepbridApiKey.trim()) {
|
||
this.manager.applyDebridAccountStatuses([status]);
|
||
}
|
||
if (!status.valid) this.throwProxyOnlyAccountFailure(status.message || "");
|
||
return status;
|
||
}
|
||
if (input.kind === "realdebrid-api" || input.kind === "realdebrid-web") {
|
||
const useWebLogin = input.kind === "realdebrid-web";
|
||
const account = input.secret?.trim() && !useWebLogin
|
||
? {
|
||
id: `rda_${randomUUID().replace(/-/g, "")}`,
|
||
kind: "api" as const,
|
||
index: 0,
|
||
label: "Real-Debrid",
|
||
maskedLogin: "Geschützter API-Token",
|
||
enabled: true,
|
||
token: input.secret.trim()
|
||
}
|
||
: getRealDebridAccounts(this.settings).find((entry) => entry.id === input.accountId && entry.kind === (useWebLogin ? "web" : "api"));
|
||
if (!account) throw new Error("Account-Payload ist ungültig");
|
||
const status = sanitizeDebridAccountStatus(
|
||
await checkRealDebridAccount(
|
||
account,
|
||
undefined,
|
||
Date.now(),
|
||
useWebLogin ? (signal) => this.getRealDebridWebFallback(account.id).probeLoginState(signal) : undefined
|
||
),
|
||
redactions
|
||
);
|
||
if (!input.secret && getRealDebridAccounts(this.settings).some((entry) => entry.id === status.accountId)) {
|
||
this.manager.applyDebridAccountStatuses([status]);
|
||
}
|
||
if (!status.valid) this.throwProxyOnlyAccountFailure(status.message || "");
|
||
return status;
|
||
}
|
||
if (input.kind === "megadebrid-api" || input.kind === "megadebrid-web") {
|
||
const mode = input.kind === "megadebrid-web" ? "web" : "api";
|
||
const account = input.identity?.trim() && input.secret
|
||
? parseMegaDebridAccounts(`${input.identity.trim()}:${input.secret}`)[0]
|
||
: getMegaDebridAccountsForMode(this.settings, mode).find((entry) => entry.id === input.accountId);
|
||
if (!account) throw new Error("Account-Payload ist ungültig");
|
||
const status = sanitizeDebridAccountStatus(await checkMegaDebridAccount(account), redactions);
|
||
if (!input.secret && getMegaDebridAccountsForMode(this.settings, mode).some((entry) => entry.id === status.accountId)) {
|
||
this.manager.applyDebridAccountStatuses([status]);
|
||
}
|
||
if (!status.valid) this.throwProxyOnlyAccountFailure(status.message || "");
|
||
return status;
|
||
}
|
||
const key = input.secret?.trim()
|
||
? parseDebridLinkApiKeys(input.secret)[0]
|
||
: parseDebridLinkApiKeys(this.settings.debridLinkApiKeys).find((entry) => entry.id === input.accountId);
|
||
if (!key) throw new Error("Account-Payload ist ungültig");
|
||
const status = sanitizeDebridAccountStatus(await checkDebridLinkKey(key), redactions);
|
||
if (!input.secret && parseDebridLinkApiKeys(this.settings.debridLinkApiKeys).some((entry) => entry.id === status.accountId)) {
|
||
this.manager.applyDebridAccountStatuses([status]);
|
||
}
|
||
if (!status.valid) this.throwProxyOnlyAccountFailure(status.message || "");
|
||
return status;
|
||
}
|
||
|
||
public resetProviderDailyUsage(provider: DebridProvider): AppSettings {
|
||
const liveSettings = this.manager.getSettings();
|
||
const nextSettings = normalizeSettings({
|
||
...liveSettings,
|
||
...resetProviderDailyUsage(liveSettings, provider)
|
||
});
|
||
const rollback = createFileRollback([
|
||
this.storagePaths.configFile,
|
||
`${this.storagePaths.configFile}.bak`,
|
||
this.storagePaths.statisticsFile,
|
||
`${this.storagePaths.statisticsFile}.tmp`
|
||
]);
|
||
try {
|
||
saveSettings(this.storagePaths, nextSettings);
|
||
this.manager.rebaseStatisticsProviderDailyUsage(
|
||
provider,
|
||
nextSettings.providerDailyUsageBytes[provider] ?? 0
|
||
);
|
||
} catch (error) {
|
||
this.rollbackImportPersistence(rollback, "fehlgeschlagener Provider-Nutzungsrücksetzung");
|
||
throw error;
|
||
}
|
||
this.settings = nextSettings;
|
||
this.manager.setSettings(this.settings);
|
||
this.audit("INFO", "Provider-Tagesnutzung zurückgesetzt", { provider });
|
||
return this.settings;
|
||
}
|
||
|
||
public resetDebridLinkApiKeyDailyUsage(keyId: string): AppSettings {
|
||
const liveSettings = this.manager.getSettings();
|
||
const nextSettings = normalizeSettings({
|
||
...liveSettings,
|
||
...resetDebridLinkApiKeyDailyUsage(liveSettings, keyId)
|
||
});
|
||
this.settings = nextSettings;
|
||
saveSettings(this.storagePaths, this.settings);
|
||
this.manager.setSettings(this.settings);
|
||
this.audit("INFO", "Debrid-Link-Key-Tagesnutzung zurückgesetzt", { keyId });
|
||
return this.settings;
|
||
}
|
||
|
||
private getRealDebridWebPartition(accountId: string): string {
|
||
return accountId === "rdw_legacy"
|
||
? "persist:realdebrid-web"
|
||
: `persist:realdebrid-web-${accountId}`;
|
||
}
|
||
|
||
private getRealDebridWebFallback(accountId: string): RealDebridWebFallback {
|
||
if (!isRealDebridWebAccountId(accountId)) {
|
||
throw new Error("Account-Payload ist ungültig");
|
||
}
|
||
const existing = this.realDebridWebFallbacks.get(accountId);
|
||
if (existing) {
|
||
return existing;
|
||
}
|
||
const fallback = new RealDebridWebFallback(
|
||
this.getRealDebridWebPartition(accountId),
|
||
() => this.settings.rememberToken,
|
||
() => this.queueRealDebridWebAuthentication(accountId),
|
||
() => this.handleRealDebridWebWindowClosed(accountId)
|
||
);
|
||
this.realDebridWebFallbacks.set(accountId, fallback);
|
||
return fallback;
|
||
}
|
||
|
||
private queueRealDebridWebAuthentication(accountId: string): void {
|
||
if (this.realDebridWebAuthenticationTasks.has(accountId)) {
|
||
return;
|
||
}
|
||
const generation = this.realDebridWebGenerations.get(accountId) || 0;
|
||
const task = this.refreshRealDebridWebStatus(accountId, generation)
|
||
.catch((error) => logger.warn(`Real-Debrid Web-Status konnte nicht aktualisiert werden (${accountId}): ${String(error)}`))
|
||
.finally(() => {
|
||
if (this.realDebridWebAuthenticationTasks.get(accountId) === task) {
|
||
this.realDebridWebAuthenticationTasks.delete(accountId);
|
||
}
|
||
});
|
||
this.realDebridWebAuthenticationTasks.set(accountId, task);
|
||
}
|
||
|
||
private handleRealDebridWebWindowClosed(accountId: string): void {
|
||
if (!this.pendingRealDebridWebAccountIds.has(accountId)) {
|
||
return;
|
||
}
|
||
void Promise.resolve().then(async () => {
|
||
const authenticationTask = this.realDebridWebAuthenticationTasks.get(accountId);
|
||
if (authenticationTask) {
|
||
await authenticationTask;
|
||
}
|
||
if (!this.pendingRealDebridWebAccountIds.has(accountId)
|
||
|| this.settings.realDebridWebAccountIds.includes(accountId)) {
|
||
return;
|
||
}
|
||
await this.cleanupRealDebridWebAccount(accountId, true);
|
||
}).catch((error) => {
|
||
logger.warn(`Abgebrochener Real-Debrid Web-Login konnte nicht bereinigt werden (${accountId}): ${String(error)}`);
|
||
});
|
||
}
|
||
|
||
private async cleanupRealDebridWebAccount(accountId: string, clearStorage: boolean): Promise<void> {
|
||
const nextGeneration = (this.realDebridWebGenerations.get(accountId) || 0) + 1;
|
||
this.realDebridWebGenerations.set(accountId, nextGeneration);
|
||
this.pendingRealDebridWebAccountIds.delete(accountId);
|
||
const existing = this.realDebridWebFallbacks.get(accountId);
|
||
this.realDebridWebFallbacks.delete(accountId);
|
||
const fallback = existing || new RealDebridWebFallback(
|
||
this.getRealDebridWebPartition(accountId),
|
||
() => this.settings.rememberToken
|
||
);
|
||
if (clearStorage) {
|
||
await fallback.clearSessions();
|
||
} else {
|
||
fallback.dispose();
|
||
}
|
||
}
|
||
|
||
private pruneRealDebridWebFallbacks(previous: AppSettings, current: AppSettings): void {
|
||
const currentIds = new Set(current.realDebridWebAccountIds);
|
||
const previouslyDisabled = new Set(previous.realDebridDisabledAccountIds || []);
|
||
const currentlyDisabled = new Set(current.realDebridDisabledAccountIds || []);
|
||
for (const accountId of previous.realDebridWebAccountIds) {
|
||
if (currentIds.has(accountId)) {
|
||
if (!previouslyDisabled.has(accountId) && currentlyDisabled.has(accountId)) {
|
||
const existing = this.realDebridWebFallbacks.get(accountId);
|
||
if (existing) {
|
||
this.realDebridWebFallbacks.delete(accountId);
|
||
existing.dispose();
|
||
}
|
||
}
|
||
continue;
|
||
}
|
||
void this.cleanupRealDebridWebAccount(accountId, true).catch((error) => {
|
||
logger.warn(`Real-Debrid Web-Session konnte nicht gelöscht werden (${accountId}): ${String(error)}`);
|
||
});
|
||
}
|
||
}
|
||
|
||
public async openRealDebridLoginWindow(request: RealDebridLoginRequest): Promise<void> {
|
||
this.assertProxyOnlyAccountSetup();
|
||
const accountId = String(request.accountId || "").trim();
|
||
if (!isRealDebridWebAccountId(accountId)) {
|
||
throw new Error("Account-Payload ist ungültig");
|
||
}
|
||
const existing = getRealDebridAccounts(this.settings).find((entry) => entry.id === accountId && entry.kind === "web");
|
||
if (request.create && existing) {
|
||
throw new Error("Account-Payload ist ungültig");
|
||
}
|
||
if (!request.create && !existing && accountId !== "rdw_legacy") {
|
||
throw new Error("Account wurde nicht gefunden");
|
||
}
|
||
if (!existing) {
|
||
const generation = (this.realDebridWebGenerations.get(accountId) || 0) + 1;
|
||
this.realDebridWebGenerations.set(accountId, generation);
|
||
this.pendingRealDebridWebAccountIds.set(accountId, {
|
||
generation,
|
||
dailyLimitBytes: request.create ? Math.floor(request.dailyLimitBytes || 0) : 0
|
||
});
|
||
}
|
||
this.audit("INFO", "Real-Debrid Login-Fenster geöffnet", { accountId, create: !existing });
|
||
try {
|
||
await this.getRealDebridWebFallback(accountId).openLoginWindow();
|
||
} catch (error) {
|
||
if (!existing) {
|
||
await this.cleanupRealDebridWebAccount(accountId, true);
|
||
}
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
public probeRealDebridWebAccount(accountId: string, signal?: AbortSignal) {
|
||
return this.getRealDebridWebFallback(accountId).probeLoginState(signal);
|
||
}
|
||
|
||
public unrestrictRealDebridWebAccount(accountId: string, link: string, signal?: AbortSignal) {
|
||
return this.getRealDebridWebFallback(accountId).unrestrict(link, signal);
|
||
}
|
||
|
||
public async clearRealDebridWebAccount(accountId: string): Promise<void> {
|
||
await this.cleanupRealDebridWebAccount(accountId, true);
|
||
}
|
||
|
||
private async refreshRealDebridWebStatus(accountId: string, generation = this.realDebridWebGenerations.get(accountId) || 0): Promise<void> {
|
||
const account = getRealDebridAccounts(this.settings)
|
||
.filter((entry): entry is RealDebridWebAccountEntry => entry.kind === "web")
|
||
.find((entry) => entry.id === accountId);
|
||
const checkedAccount: RealDebridWebAccountEntry = account || {
|
||
id: accountId,
|
||
kind: "web",
|
||
index: this.settings.realDebridWebAccountIds.length,
|
||
label: `Browser-Login ${this.settings.realDebridWebAccountIds.length + 1}`,
|
||
maskedLogin: "Geschützter Browser-Login",
|
||
enabled: true
|
||
};
|
||
const status = sanitizeDebridAccountStatus(
|
||
await checkRealDebridAccount(
|
||
checkedAccount,
|
||
undefined,
|
||
Date.now(),
|
||
(signal) => this.getRealDebridWebFallback(accountId).probeLoginState(signal)
|
||
),
|
||
collectAccountStatusRedactionValues(this.settings)
|
||
);
|
||
if (!status.valid) {
|
||
return;
|
||
}
|
||
if ((this.realDebridWebGenerations.get(accountId) || 0) !== generation) {
|
||
return;
|
||
}
|
||
const currentAccount = getRealDebridAccounts(this.settings).find((entry) => entry.id === accountId && entry.kind === "web");
|
||
if (account && !currentAccount) {
|
||
return;
|
||
}
|
||
if (!account) {
|
||
const pending = this.pendingRealDebridWebAccountIds.get(accountId);
|
||
if (!pending || pending.generation !== generation) {
|
||
return;
|
||
}
|
||
const applied = applyAccountCommand(this.settings, {
|
||
action: "create",
|
||
kind: "realdebrid-web",
|
||
identity: accountId,
|
||
secret: "",
|
||
dailyLimitBytes: pending.dailyLimitBytes
|
||
});
|
||
this.pendingRealDebridWebAccountIds.delete(accountId);
|
||
this.updateSettings(applied.settings);
|
||
}
|
||
this.manager.applyDebridAccountStatuses([status]);
|
||
const fallback = this.realDebridWebFallbacks.get(accountId);
|
||
if (fallback) {
|
||
fallback.closeLoginWindow();
|
||
}
|
||
}
|
||
|
||
public async openAllDebridLoginWindow(): Promise<void> {
|
||
this.assertProxyOnlyAccountSetup();
|
||
this.audit("INFO", "AllDebrid Login-Fenster geöffnet");
|
||
await this.allDebridWebFallback.openLoginWindow();
|
||
}
|
||
|
||
public async importBestDebridCookies(filePath: string): Promise<number> {
|
||
const imported = await this.bestDebridWebFallback.importCookiesFromFile(filePath);
|
||
this.audit("INFO", "BestDebrid Cookies importiert", {
|
||
filePath,
|
||
imported
|
||
});
|
||
return imported;
|
||
}
|
||
|
||
public async getAllDebridHostInfo(host = "rapidgator"): Promise<AllDebridHostInfo> {
|
||
if (this.settings.allDebridUseWebLogin) {
|
||
return this.allDebridWebFallback.getHostInfo(host);
|
||
}
|
||
const token = this.settings.allDebridToken.trim();
|
||
if (!token) {
|
||
throw new Error("AllDebrid ist nicht konfiguriert");
|
||
}
|
||
return fetchAllDebridHostInfo(token, host);
|
||
}
|
||
|
||
public async getDebridLinkHostLimits(host = "rapidgator") {
|
||
return fetchDebridLinkHostLimits(this.settings.debridLinkApiKeys, host);
|
||
}
|
||
|
||
public async checkDebridAccounts(scope: AccountCheckScope = "active"): Promise<DebridAccountStatus[]> {
|
||
this.assertProxyOnlyAccountSetup();
|
||
const checkedStatuses = sanitizeDebridAccountStatuses(
|
||
await checkAllDebridAccounts(
|
||
this.settings,
|
||
undefined,
|
||
(accountId, signal) => this.getRealDebridWebFallback(accountId).probeLoginState(signal),
|
||
scope
|
||
),
|
||
collectAccountStatusRedactionValues(this.settings)
|
||
);
|
||
const statuses = retainConfiguredRealDebridStatuses(this.settings, checkedStatuses);
|
||
const proxyFailure = statuses.find((status) => !status.valid && resolveProxyOnlyAccountErrorCode(
|
||
this.settings,
|
||
getNetworkProxyState(),
|
||
status.message || ""
|
||
));
|
||
if (proxyFailure) this.throwProxyOnlyAccountFailure(proxyFailure.message || "");
|
||
this.manager.applyDebridAccountStatuses(statuses);
|
||
this.audit("INFO", "Debrid-Accounts geprueft", {
|
||
total: statuses.length,
|
||
scope,
|
||
valid: statuses.filter((s) => s.valid).length,
|
||
premium: statuses.filter((s) => s.isPremium).length
|
||
});
|
||
return statuses;
|
||
}
|
||
public async checkUpdates(): Promise<UpdateCheckResult> {
|
||
const result = await checkGitHubUpdate(this.settings.updateRepo);
|
||
if (!result.error) {
|
||
this.lastUpdateCheck = result;
|
||
this.lastUpdateCheckAt = Date.now();
|
||
}
|
||
return result;
|
||
}
|
||
|
||
public async installUpdate(onProgress?: (progress: UpdateInstallProgress) => void): Promise<UpdateInstallResult> {
|
||
const result = await runInstallWithResume(
|
||
this.manager,
|
||
() => installLatestUpdate(this.settings.updateRepo, undefined, onProgress)
|
||
);
|
||
if (result.started) {
|
||
this.lastUpdateCheck = null;
|
||
this.lastUpdateCheckAt = 0;
|
||
}
|
||
return result;
|
||
}
|
||
|
||
public addLinks(payload: AddLinksPayload): { addedPackages: number; addedLinks: number; invalidCount: number } {
|
||
const parsed = parseCollectorInput(payload.rawText, payload.packageName || this.settings.packageName);
|
||
if (parsed.length === 0) {
|
||
this.audit("WARN", "Links hinzufügen ohne gültigen Inhalt", {
|
||
hasPackageName: Boolean(payload.packageName)
|
||
});
|
||
return { addedPackages: 0, addedLinks: 0, invalidCount: 1 };
|
||
}
|
||
const result = this.manager.addPackages(parsed);
|
||
this.audit("INFO", "Links hinzugefügt", {
|
||
addedPackages: result.addedPackages,
|
||
addedLinks: result.addedLinks,
|
||
requestedPackages: parsed.length
|
||
});
|
||
return { ...result, invalidCount: 0 };
|
||
}
|
||
|
||
public prepareCollectorText(request: CollectorTextPreparationRequest): CollectorInspectionResult {
|
||
return prepareCollectorText(request);
|
||
}
|
||
|
||
public getCollectorState(): CollectorPersistenceState {
|
||
return this.collectorStore.getState();
|
||
}
|
||
|
||
public saveCollectorState(state: CollectorPersistenceState): CollectorPersistenceState {
|
||
this.collectorStore.update(state);
|
||
return this.collectorStore.getState();
|
||
}
|
||
|
||
public saveCollectorStateSync(state: CollectorPersistenceState): CollectorPersistenceState {
|
||
this.collectorStore.update(state);
|
||
this.collectorStore.flushSync();
|
||
return this.collectorStore.getState();
|
||
}
|
||
|
||
public prepareCollectorContainers(filePaths: string[], addedAt: number): Promise<CollectorInspectionResult> {
|
||
return prepareCollectorContainers(filePaths, addedAt);
|
||
}
|
||
|
||
public enrichCollectorPackages(
|
||
request: CollectorEnrichmentRequest,
|
||
onProgress?: (result: CollectorInspectionResult) => void
|
||
): Promise<CollectorInspectionResult> {
|
||
return enrichCollectorPackages(request, this.settings, {}, onProgress);
|
||
}
|
||
|
||
public async addContainers(filePaths: string[]): Promise<{ addedPackages: number; addedLinks: number }> {
|
||
const packages = await importDlcContainers(filePaths);
|
||
const merged: ParsedPackageInput[] = packages.map((pkg) => ({
|
||
name: pkg.name,
|
||
links: pkg.links,
|
||
...(pkg.fileNames ? { fileNames: pkg.fileNames } : {})
|
||
}));
|
||
const result = this.manager.addPackages(merged);
|
||
this.audit("INFO", "Container importiert", {
|
||
files: filePaths.length,
|
||
addedPackages: result.addedPackages,
|
||
addedLinks: result.addedLinks
|
||
});
|
||
return result;
|
||
}
|
||
|
||
public async getStartConflicts(): Promise<StartConflictEntry[]> {
|
||
return this.manager.getStartConflicts();
|
||
}
|
||
|
||
public async resolveStartConflict(packageId: string, policy: DuplicatePolicy): Promise<StartConflictResolutionResult> {
|
||
return this.manager.resolveStartConflict(packageId, policy);
|
||
}
|
||
|
||
public clearAll(): void {
|
||
this.audit("WARN", "Queue komplett geleert");
|
||
this.manager.clearAll();
|
||
}
|
||
|
||
public async start(): Promise<void> {
|
||
this.audit("INFO", "Session-Start ausgelöst");
|
||
await this.manager.start();
|
||
}
|
||
|
||
public async startPackages(packageIds: string[]): Promise<void> {
|
||
this.audit("INFO", "Paket-Start ausgelöst", { packageIds });
|
||
await this.manager.startPackages(packageIds);
|
||
}
|
||
|
||
public async startItems(itemIds: string[]): Promise<void> {
|
||
this.audit("INFO", "Item-Start ausgelöst", { itemIds });
|
||
await this.manager.startItems(itemIds);
|
||
}
|
||
|
||
public stop(): void {
|
||
this.audit("INFO", "Session-Stopp ausgelöst");
|
||
this.manager.stop();
|
||
}
|
||
|
||
public togglePause(): boolean {
|
||
const paused = this.manager.togglePause();
|
||
this.audit("INFO", "Pause umgeschaltet", { paused });
|
||
return paused;
|
||
}
|
||
|
||
public retryExtraction(packageId: string): void {
|
||
this.audit("INFO", "Extraktion manuell wiederholt", { packageId });
|
||
this.manager.retryExtraction(packageId);
|
||
}
|
||
|
||
public extractNow(packageId: string): void {
|
||
this.audit("INFO", "Jetzt entpacken ausgelöst", { packageId });
|
||
this.manager.extractNow(packageId);
|
||
}
|
||
|
||
public resetPackage(packageId: string): void {
|
||
this.audit("INFO", "Paket zurückgesetzt", { packageId });
|
||
this.manager.resetPackage(packageId);
|
||
}
|
||
|
||
public cancelPackage(packageId: string): void {
|
||
this.audit("WARN", "Paket abgebrochen", { packageId });
|
||
this.manager.cancelPackage(packageId);
|
||
}
|
||
|
||
public renamePackage(packageId: string, newName: string): void {
|
||
this.audit("INFO", "Paket umbenannt", { packageId, newName });
|
||
this.manager.renamePackage(packageId, newName);
|
||
}
|
||
|
||
public reorderPackages(packageIds: string[]): void {
|
||
this.audit("INFO", "Paketreihenfolge geändert", { packageIds });
|
||
this.manager.reorderPackages(packageIds);
|
||
}
|
||
|
||
public removeItem(itemId: string): void {
|
||
this.audit("WARN", "Item entfernt", { itemId });
|
||
this.manager.removeItem(itemId);
|
||
}
|
||
|
||
public togglePackage(packageId: string): void {
|
||
this.audit("INFO", "Paket aktiviert/deaktiviert", { packageId });
|
||
this.manager.togglePackage(packageId);
|
||
}
|
||
|
||
public exportPackageSelection(packageIds: string[]): { text: string; defaultFileName: string; packageCount: number; linkCount: number } {
|
||
const selection = buildLinkExportSelection(this.manager.getSnapshot(), packageIds, []);
|
||
this.audit("INFO", "Paket-Auswahl exportiert", {
|
||
packageCount: selection.packageCount,
|
||
linkCount: selection.linkCount,
|
||
packageIds
|
||
});
|
||
return {
|
||
text: serializeLinkExportText(selection.packages),
|
||
defaultFileName: selection.defaultFileName,
|
||
packageCount: selection.packageCount,
|
||
linkCount: selection.linkCount
|
||
};
|
||
}
|
||
|
||
public exportItemSelection(itemIds: string[]): { text: string; defaultFileName: string; packageCount: number; linkCount: number } {
|
||
const selection = buildLinkExportSelection(this.manager.getSnapshot(), [], itemIds);
|
||
this.audit("INFO", "Item-Auswahl exportiert", {
|
||
packageCount: selection.packageCount,
|
||
linkCount: selection.linkCount,
|
||
itemIds
|
||
});
|
||
return {
|
||
text: serializeLinkExportText(selection.packages),
|
||
defaultFileName: selection.defaultFileName,
|
||
packageCount: selection.packageCount,
|
||
linkCount: selection.linkCount
|
||
};
|
||
}
|
||
|
||
public exportQueue(): string {
|
||
return this.manager.exportQueue();
|
||
}
|
||
|
||
public importQueue(json: string): { addedPackages: number; addedLinks: number } {
|
||
const result = this.manager.importQueue(json);
|
||
this.audit("INFO", "Import-Datei verarbeitet", result);
|
||
return result;
|
||
}
|
||
|
||
public getSessionStats(): SessionStats {
|
||
return this.manager.getSessionStats();
|
||
}
|
||
|
||
public resetSessionStats(): void {
|
||
this.audit("INFO", "Session-Statistik zurückgesetzt");
|
||
this.manager.resetSessionStats();
|
||
}
|
||
|
||
public resetDownloadStats(): void {
|
||
this.manager.resetDownloadStats();
|
||
this.settings = this.manager.getSettings();
|
||
this.audit("INFO", "Download-Statistik zurückgesetzt");
|
||
}
|
||
|
||
public exportBackup(passphrase: string): Buffer {
|
||
let remoteDiagnostics: BackupRemoteDiagnostics | undefined;
|
||
if (Boolean(this.settings.backupIncludeRemoteDiagnostics)) {
|
||
const status = getDebugServerRuntimeStatus();
|
||
remoteDiagnostics = {
|
||
allowlist: getDebugAllowlist(),
|
||
port: status.port,
|
||
hostMode: status.host === "0.0.0.0" ? "network" : "local"
|
||
};
|
||
}
|
||
const payloadObj = buildBackupPayload({
|
||
settings: { ...this.settings },
|
||
appVersion: APP_VERSION,
|
||
exportedAt: new Date().toISOString(),
|
||
session: this.manager.getSession(),
|
||
history: loadHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode, this.historyLimits()),
|
||
statistics: this.manager.getStatisticsLedgerForBackup(),
|
||
remoteDiagnostics
|
||
});
|
||
const encrypted = encryptBackup(JSON.stringify(payloadObj), passphrase);
|
||
this.audit("INFO", "Backup exportiert", {
|
||
kind: payloadObj.kind,
|
||
historyEntries: payloadObj.history ? payloadObj.history.length : 0,
|
||
sessionItems: payloadObj.session ? Object.keys(payloadObj.session.items).length : 0,
|
||
sessionPackages: payloadObj.session ? Object.keys(payloadObj.session.packages).length : 0
|
||
});
|
||
return encrypted;
|
||
}
|
||
|
||
public async exportOnlineBackup(): Promise<{ key: string }> {
|
||
const proxyListContent = captureOnlineProxyList(this.settings);
|
||
const created = createOnlineBackup({ ...this.settings }, APP_VERSION, undefined, proxyListContent);
|
||
await uploadOnlineBackup(created.record, ONLINE_BACKUP_API_URL);
|
||
this.audit("INFO", "Online-Sicherung erstellt", { kind: "settings-only", proxyListIncluded: proxyListContent !== undefined });
|
||
return { key: created.key };
|
||
}
|
||
|
||
public async importOnlineBackup(key: string): Promise<{ restored: boolean; relaunch: false; message: string }> {
|
||
const payload = await downloadOnlineBackup(key, ONLINE_BACKUP_API_URL);
|
||
const result = await this.applySettingsOnlyBackup(payload.settings, undefined, false, payload.proxyList?.content ?? null);
|
||
this.audit("INFO", "Online-Sicherung importiert", {
|
||
kind: "settings-only",
|
||
proxyListRestored: result.proxyListRestored,
|
||
proxyOnlyDisabled: result.proxyOnlyDisabled,
|
||
accountSummary: buildAccountSummary(this.settings)
|
||
});
|
||
const message = result.proxyListRestored
|
||
? "Einstellungen und Proxy-Liste aus Online-Sicherung wiederhergestellt"
|
||
: result.proxyOnlyDisabled
|
||
? "Einstellungen wiederhergestellt; Proxy-only wurde deaktiviert, weil die Online-Sicherung keine Proxy-Liste enthält"
|
||
: "Einstellungen aus Online-Sicherung wiederhergestellt";
|
||
return { restored: true, relaunch: false, message };
|
||
}
|
||
|
||
public async exportSupportBundle(): Promise<{ buffer: Buffer; defaultFileName: string }> {
|
||
this.audit("INFO", "Support-Bundle exportiert");
|
||
logTraceEvent("INFO", "support", "Support-Bundle erstellt", {
|
||
packageCount: Object.keys(this.manager.getSnapshot().session.packages).length,
|
||
itemCount: Object.keys(this.manager.getSnapshot().session.items).length
|
||
});
|
||
return {
|
||
buffer: await buildSupportBundle(this.manager, this.storagePaths.baseDir, {
|
||
hostDiagnosticsMode: "cached",
|
||
notificationStatus: this.getNotificationSupportPayload()
|
||
}),
|
||
defaultFileName: getSupportBundleDefaultFileName()
|
||
};
|
||
}
|
||
|
||
public getSupportBundleDefaultFileName(): string {
|
||
return getSupportBundleDefaultFileName();
|
||
}
|
||
|
||
public getNotificationSupportPayload(): NotificationSupportPayload {
|
||
return buildNotificationSupportPayload(
|
||
this.notificationOutbox.getStatus(),
|
||
this.downloadHealthMonitor.getState()
|
||
);
|
||
}
|
||
|
||
public async importBackup(data: Buffer, passphrase?: string): Promise<{ restored: boolean; relaunch: boolean; message: string }> {
|
||
let parsed: Record<string, unknown>;
|
||
try {
|
||
const json = decryptBackup(data, passphrase);
|
||
parsed = JSON.parse(json) as Record<string, unknown>;
|
||
} catch {
|
||
try {
|
||
const json = data.toString("utf8");
|
||
parsed = JSON.parse(json) as Record<string, unknown>;
|
||
} catch {
|
||
return { restored: false, relaunch: false, message: "Backup-Datei konnte nicht entschlüsselt werden" };
|
||
}
|
||
}
|
||
const plan = planBackupImport(parsed);
|
||
if (!plan.valid) {
|
||
return { restored: false, relaunch: false, message: plan.message };
|
||
}
|
||
const hasSession = plan.restoreDownloads;
|
||
let restoredHistory: HistoryEntry[] | undefined;
|
||
if (Array.isArray(parsed.history)) {
|
||
restoredHistory = (parsed.history as unknown[])
|
||
.map((raw, idx) => normalizeHistoryEntry(raw, idx))
|
||
.filter((entry): entry is HistoryEntry => entry !== null);
|
||
if (parsed.history.length > 0 && restoredHistory.length === 0) {
|
||
return { restored: false, relaunch: false, message: "Backup-Verlauf enthält keine gültigen Einträge" };
|
||
}
|
||
}
|
||
|
||
const importedSettings = parsed.settings as AppSettings;
|
||
const importedSettingsRecord = importedSettings as unknown as Record<string, unknown>;
|
||
const currentSettingsRecord = this.settings as unknown as Record<string, unknown>;
|
||
const SENSITIVE_KEYS: (keyof AppSettings)[] = [
|
||
"token", "megaLogin", "megaPassword", "bestToken", "allDebridToken",
|
||
"deepbridApiKey", "ddownloadLogin", "ddownloadPassword", "oneFichierApiKey",
|
||
"debridLinkApiKeys", "linkSnappyLogin", "linkSnappyPassword",
|
||
"notifyUrl"
|
||
];
|
||
for (const key of SENSITIVE_KEYS) {
|
||
const val = importedSettingsRecord[key];
|
||
if (typeof val === "string" && val.startsWith("***")) {
|
||
importedSettingsRecord[key] = currentSettingsRecord[key];
|
||
}
|
||
}
|
||
let restoredSettings = normalizeSettings(importedSettings);
|
||
|
||
// Settings-only backup: keep the running queue AND the live counters untouched.
|
||
// Overlay the live usage/status counters so they don't roll back to the backup's
|
||
// (older) snapshot (BUG I), and suppress the retroactive cleanup sweep so the
|
||
// backup's cleanup policy can't purge the live completed queue here (BUG B) — the
|
||
// policy still governs FUTURE completions through the normal path. Do NOT stop the
|
||
// manager, wipe the session, block persistence or relaunch.
|
||
if (!hasSession) {
|
||
await this.applySettingsOnlyBackup(restoredSettings, parsed.remoteDiagnostics, true);
|
||
this.audit("INFO", "Backup importiert (nur Einstellungen)", {
|
||
accountSummary: buildAccountSummary(this.settings)
|
||
});
|
||
return {
|
||
restored: true,
|
||
relaunch: false,
|
||
message: "Einstellungen wiederhergestellt"
|
||
};
|
||
}
|
||
|
||
restoredSettings = this.prepareImportedLogStorage(restoredSettings);
|
||
const barrier = await acquirePersistenceBarrier();
|
||
const previousSettings = this.settings;
|
||
const previousLogStorageLocation = this.settings.logStorageLocation;
|
||
const previousSkipShutdownPersist = this.manager.skipShutdownPersist;
|
||
const previousBlockAllPersistence = this.manager.blockAllPersistence;
|
||
let rollbackPersistence: (() => void) | null = null;
|
||
let remoteRestore: ReturnType<typeof resolveRemoteDiagnosticsRestore> = null;
|
||
let logStorageChanged = false;
|
||
let runtimeApplied = false;
|
||
try {
|
||
rollbackPersistence = this.createBackupImportRollback(true);
|
||
if (restoredHistory) {
|
||
replaceHistory(this.storagePaths, restoredHistory);
|
||
}
|
||
saveSettings(this.storagePaths, restoredSettings);
|
||
const restoredSession = normalizeLoadedSessionTransientFields(
|
||
normalizeLoadedSession(parsed.session)
|
||
);
|
||
saveSession(this.storagePaths, restoredSession);
|
||
|
||
if (parsed.statistics) {
|
||
saveStatisticsLedger(this.storagePaths.statisticsFile, normalizeStatisticsLedger(parsed.statistics));
|
||
}
|
||
resetHistoryForRetention(this.storagePaths, restoredSettings.historyRetentionMode);
|
||
remoteRestore = this.persistRemoteDiagnosticsFromBackup(parsed.remoteDiagnostics);
|
||
if (previousLogStorageLocation !== restoredSettings.logStorageLocation) {
|
||
if (!this.reconfigureLogStorage(restoredSettings.logStorageLocation)) {
|
||
throw new Error("Log-Speicherort konnte nicht wiederhergestellt werden");
|
||
}
|
||
logStorageChanged = true;
|
||
}
|
||
this.manager.skipShutdownPersist = true;
|
||
this.manager.blockAllPersistence = true;
|
||
this.settings = restoredSettings;
|
||
this.applyNetworkProxyConfiguration();
|
||
runtimeApplied = true;
|
||
this.manager.setSettings(this.settings);
|
||
this.manager.stop();
|
||
this.manager.abortAllPostProcessing();
|
||
this.manager.clearPersistTimer();
|
||
await barrier.release({ replayBlocked: false });
|
||
} catch (error) {
|
||
if (rollbackPersistence) {
|
||
this.rollbackImportPersistence(rollbackPersistence, "fehlgeschlagenem Full-Backup-Import");
|
||
}
|
||
this.manager.skipShutdownPersist = previousSkipShutdownPersist;
|
||
this.manager.blockAllPersistence = previousBlockAllPersistence;
|
||
this.settings = previousSettings;
|
||
this.applyNetworkProxyConfiguration();
|
||
if (runtimeApplied) {
|
||
try {
|
||
this.manager.setSettings(previousSettings, { settingsOnlyImport: true });
|
||
} catch (runtimeError) {
|
||
logger.error(`Full-Backup-Import konnte die vorherigen Laufzeiteinstellungen nicht wiederherstellen: ${String(runtimeError)}`);
|
||
}
|
||
}
|
||
if (logStorageChanged) {
|
||
this.restoreLogStorageAfterFailedImport(previousLogStorageLocation);
|
||
}
|
||
try {
|
||
await barrier.release({ replayBlocked: true });
|
||
} catch (releaseError) {
|
||
logger.error(`Blockierte Persistenz konnte nach fehlgeschlagenem Full-Backup-Import nicht fortgesetzt werden: ${String(releaseError)}`);
|
||
}
|
||
throw error;
|
||
}
|
||
if (restoredHistory) {
|
||
logger.info(`Backup: ${restoredHistory.length} History-Einträge wiederhergestellt`);
|
||
}
|
||
this.auditRemoteDiagnosticsRestore(remoteRestore, false);
|
||
logger.info("Backup wiederhergestellt — App startet automatisch neu");
|
||
this.audit("WARN", "Backup importiert", {
|
||
historyEntries: Array.isArray(parsed.history) ? parsed.history.length : 0,
|
||
accountSummary: buildAccountSummary(this.settings)
|
||
});
|
||
return { restored: true, relaunch: true, message: "Backup wiederhergestellt – App startet automatisch neu…" };
|
||
}
|
||
|
||
public getSessionLogPath(): string | null {
|
||
return getSessionLogPath();
|
||
}
|
||
|
||
public getLogDirectory(): string {
|
||
return this.logDirectory;
|
||
}
|
||
|
||
public getPackageLogPath(packageId: string): string | null {
|
||
return this.manager.getPackageLogPath(packageId) || getPackageLogPath(packageId);
|
||
}
|
||
|
||
public getItemLogPath(itemId: string): string | null {
|
||
return this.manager.getItemLogPath(itemId) || getItemLogPath(itemId);
|
||
}
|
||
|
||
private evaluateDownloadHealth(): Promise<void> {
|
||
if (this.downloadHealthEvaluation) {
|
||
return this.downloadHealthEvaluation;
|
||
}
|
||
const now = Date.now();
|
||
const task = this.downloadHealthMonitor.sample(
|
||
this.manager.getDownloadHealthSnapshot(now),
|
||
now,
|
||
{
|
||
stallAfterMs: this.settings.notifyStallAfterSeconds * 1000,
|
||
cooldownMs: this.settings.notifyStallCooldownMinutes * 60_000,
|
||
notifyOnStall: this.settings.notifyOnDownloadStall && Boolean(String(this.settings.notifyUrl || "").trim()),
|
||
notifyOnRecovery: this.settings.notifyOnDownloadRecovery && Boolean(String(this.settings.notifyUrl || "").trim())
|
||
},
|
||
(event) => this.notificationOutbox.enqueue(event)
|
||
).then(() => undefined).catch((error) => {
|
||
logger.warn(`Download-Health-Monitor konnte nicht ausgewertet werden: ${String(error)}`);
|
||
}).finally(() => {
|
||
if (this.downloadHealthEvaluation === task) {
|
||
this.downloadHealthEvaluation = null;
|
||
}
|
||
});
|
||
this.downloadHealthEvaluation = task;
|
||
return task;
|
||
}
|
||
|
||
public async shutdown(): Promise<void> {
|
||
const deadlineAt = Date.now() + 3000;
|
||
if (this.downloadHealthTimer) {
|
||
clearInterval(this.downloadHealthTimer);
|
||
this.downloadHealthTimer = null;
|
||
}
|
||
this.manager.suspendDownloadHealthMonitoring?.();
|
||
if (this.runtimeStatsTimer) {
|
||
clearInterval(this.runtimeStatsTimer);
|
||
this.runtimeStatsTimer = null;
|
||
}
|
||
stopDebugServer();
|
||
abortActiveUpdateDownload();
|
||
try {
|
||
this.collectorStore?.flushSync();
|
||
} catch (error) {
|
||
logger.warn(`Linksammler konnte beim Beenden nicht gespeichert werden: ${String(error)}`);
|
||
}
|
||
cancelPendingAsyncSaves();
|
||
this.manager.prepareForShutdown();
|
||
if (this.downloadHealthEvaluation) {
|
||
await this.waitForShutdownTask(this.downloadHealthEvaluation, deadlineAt);
|
||
}
|
||
if (this.downloadHealthMonitor && Date.now() < deadlineAt) {
|
||
await this.waitForShutdownTask(this.evaluateDownloadHealth(), deadlineAt);
|
||
}
|
||
const notificationFlush = this.manager.flushNotificationsForShutdown?.();
|
||
if (notificationFlush && Date.now() < deadlineAt) {
|
||
await this.waitForShutdownTask(notificationFlush, deadlineAt);
|
||
}
|
||
await this.notificationOutbox.drainForShutdown(Math.max(0, deadlineAt - Date.now())).catch((error) => {
|
||
logger.warn(`Notification-Outbox konnte beim Beenden nicht geleert werden: ${String(error)}`);
|
||
});
|
||
this.megaWebFallback.dispose();
|
||
for (const fallback of this.realDebridWebFallbacks.values()) {
|
||
fallback.dispose();
|
||
}
|
||
this.realDebridWebFallbacks.clear();
|
||
this.pendingRealDebridWebAccountIds.clear();
|
||
this.allDebridWebFallback.dispose();
|
||
this.bestDebridWebFallback.dispose();
|
||
await shutdownNetworkProxy();
|
||
if (this.settings.historyRetentionMode === "session") {
|
||
this.runHistoryLifecycleCleanup("Beenden", () => clearHistory(this.storagePaths));
|
||
}
|
||
this.shutdownLogStorage();
|
||
this.audit("INFO", "App beendet");
|
||
shutdownTraceLog();
|
||
shutdownAccountRotationLog();
|
||
shutdownConversionLog();
|
||
shutdownAuditLog();
|
||
logger.info("App beendet");
|
||
}
|
||
|
||
private applyNetworkProxyConfiguration(): void {
|
||
const state = configureNetworkProxy(this.settings);
|
||
if (state.status === "active") {
|
||
logger.info(`Proxy-only aktiv: fester API-Proxy ${state.selectedIndex}/${state.proxyCount}`);
|
||
} else if (state.status === "blocked") {
|
||
logger.warn(`Proxy-only blockiert Netzwerkanfragen: ${state.reason}`);
|
||
}
|
||
}
|
||
|
||
private async waitForShutdownTask(task: Promise<unknown>, deadlineAt: number): Promise<void> {
|
||
const remainingMs = Math.max(0, deadlineAt - Date.now());
|
||
if (remainingMs <= 0) {
|
||
return;
|
||
}
|
||
let timer: NodeJS.Timeout | null = null;
|
||
const timeout = new Promise<void>((resolve) => {
|
||
timer = setTimeout(resolve, remainingMs);
|
||
});
|
||
await Promise.race([task.then(() => undefined, () => undefined), timeout]);
|
||
if (timer) {
|
||
clearTimeout(timer);
|
||
}
|
||
}
|
||
|
||
private getDesktopDirectory(): string | null {
|
||
try {
|
||
return app.getPath("desktop");
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
private initializeLogStorage(): void {
|
||
configureLogger(this.logDirectory);
|
||
initSessionLog(this.logDirectory);
|
||
initPackageLogs(this.logDirectory);
|
||
initItemLogs(this.logDirectory);
|
||
initAuditLog(this.logDirectory);
|
||
initAccountRotationLog(this.logDirectory);
|
||
initConversionLog(this.logDirectory);
|
||
initRenameLog(this.logDirectory);
|
||
initDesktopRenameLogAt(this.logDirectory);
|
||
initTraceLog(this.logDirectory);
|
||
}
|
||
|
||
private shutdownLogStorage(): void {
|
||
flushLoggerSync();
|
||
shutdownSessionLog();
|
||
shutdownPackageLogs();
|
||
shutdownItemLogs();
|
||
shutdownRenameLog();
|
||
shutdownDesktopRenameLog();
|
||
}
|
||
|
||
private reconfigureLogStorage(location: AppSettings["logStorageLocation"]): boolean {
|
||
const nextDirectory = resolveLogDirectory(this.storagePaths.baseDir, this.getDesktopDirectory(), location);
|
||
if (nextDirectory === this.logDirectory) {
|
||
return true;
|
||
}
|
||
if (!prepareLogDirectory(nextDirectory)) {
|
||
return false;
|
||
}
|
||
this.shutdownLogStorage();
|
||
shutdownTraceLog();
|
||
shutdownAccountRotationLog();
|
||
shutdownConversionLog();
|
||
shutdownAuditLog();
|
||
const legacyDirectory = location === "desktop"
|
||
? getLegacyDesktopLogDirectory(this.getDesktopDirectory())
|
||
: null;
|
||
migrateLogDirectories(
|
||
[this.logDirectory, ...(legacyDirectory ? [legacyDirectory] : [])],
|
||
nextDirectory
|
||
);
|
||
this.logDirectory = nextDirectory;
|
||
this.initializeLogStorage();
|
||
logger.info(`Log-Speicherort geändert: ${this.logDirectory}`);
|
||
return true;
|
||
}
|
||
|
||
private historyLimits(): { maxEntries: number; maxAgeDays: number } {
|
||
return { maxEntries: this.settings.historyMaxEntries, maxAgeDays: this.settings.historyMaxAgeDays };
|
||
}
|
||
|
||
private runHistoryLifecycleCleanup(phase: "Start" | "Beenden", cleanup: () => void): void {
|
||
try {
|
||
cleanup();
|
||
} catch (error) {
|
||
logger.warn(`Verlauf konnte beim ${phase} nicht bereinigt werden: ${String(error)}`);
|
||
}
|
||
}
|
||
|
||
private rollbackHistoryAfterFailure(rollback: (() => void) | null, context: string): void {
|
||
if (!rollback) {
|
||
return;
|
||
}
|
||
try {
|
||
rollback();
|
||
} catch (error) {
|
||
logger.error(`Verlauf konnte nach ${context} nicht zurückgerollt werden: ${String(error)}`);
|
||
}
|
||
}
|
||
|
||
private recordHistoryEntry(entry: HistoryEntry): void {
|
||
try {
|
||
const entries = addHistoryEntryForRetention(this.storagePaths, this.settings.historyRetentionMode, entry, this.historyLimits());
|
||
if (entries[0]?.id === entry.id) {
|
||
this.onHistoryEntryAddedHandler?.(entry);
|
||
}
|
||
} catch (error) {
|
||
logger.error(`Verlaufseintrag konnte nicht gespeichert werden: ${String(error)}`);
|
||
}
|
||
}
|
||
|
||
public getHistory(): HistoryEntry[] {
|
||
return loadHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode, this.historyLimits());
|
||
}
|
||
|
||
public clearHistory(): void {
|
||
this.audit("WARN", "Verlauf geleert");
|
||
clearHistory(this.storagePaths);
|
||
}
|
||
|
||
public setPackagePriority(packageId: string, priority: PackagePriority): void {
|
||
this.audit("INFO", "Paket-Priorität geändert", { packageId, priority });
|
||
this.manager.setPackagePriority(packageId, priority);
|
||
}
|
||
|
||
public skipItems(itemIds: string[]): void {
|
||
this.audit("INFO", "Items übersprungen", { itemIds });
|
||
this.manager.skipItems(itemIds);
|
||
}
|
||
|
||
public resetItems(itemIds: string[]): void {
|
||
this.audit("INFO", "Items zurückgesetzt", { itemIds });
|
||
this.manager.resetItems(itemIds);
|
||
}
|
||
|
||
public removeHistoryEntry(entryId: string): void {
|
||
this.removeHistoryEntries([entryId]);
|
||
}
|
||
|
||
public removeHistoryEntries(entryIds: string[]): void {
|
||
this.audit("INFO", "Verlaufseinträge entfernt", { count: entryIds.length });
|
||
removeHistoryEntries(this.storagePaths, entryIds, this.historyLimits());
|
||
}
|
||
|
||
public addToHistory(entry: HistoryEntry): void {
|
||
this.audit("INFO", "Verlaufseintrag hinzugefügt", {
|
||
id: entry.id,
|
||
name: entry.name,
|
||
status: entry.status,
|
||
provider: entry.provider,
|
||
fileCount: entry.fileCount
|
||
});
|
||
addHistoryEntry(this.storagePaths, entry);
|
||
}
|
||
}
|