Prepare v2.0.74 sidepanel reliability release
Restore and persist the package-based link collector with progressive metadata, bounded high-volume updates, safe hydration, visible selection, and complete localization. Harden download controls, snapshot ordering, history pagination, statistics recovery, settings saves, backup imports, notification persistence, and Windows storage races with regression coverage.
This commit is contained in:
+323
-112
@@ -59,7 +59,7 @@ 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 { addHistoryEntry, addHistoryEntryForRetention, cancelPendingAsyncSaves, clearHistory, createStoragePaths, loadHistory, loadHistoryForRetention, loadSessionWithStatus, loadSettings, normalizeHistoryEntry, normalizeLoadedSession, normalizeLoadedSessionTransientFields, normalizeSettings, removeHistoryEntries, resetHistoryForRetention, saveHistory, saveSession, saveSettings } from "./storage";
|
||||
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";
|
||||
@@ -169,7 +169,7 @@ export class AppController {
|
||||
);
|
||||
}
|
||||
this.initializeLogStorage();
|
||||
resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode);
|
||||
this.runHistoryLifecycleCleanup("Start", () => resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode));
|
||||
const loadResult = loadSessionWithStatus(this.storagePaths);
|
||||
const session = loadResult.session;
|
||||
this.notificationOutbox = new NotificationOutbox({
|
||||
@@ -465,22 +465,41 @@ export class AppController {
|
||||
return this.getRemoteDiagnostics();
|
||||
}
|
||||
|
||||
private restoreRemoteDiagnosticsFromBackup(section: unknown, restartNow: boolean): void {
|
||||
private persistRemoteDiagnosticsFromBackup(section: unknown): ReturnType<typeof resolveRemoteDiagnosticsRestore> {
|
||||
const restore = resolveRemoteDiagnosticsRestore(section);
|
||||
if (!restore) {
|
||||
return;
|
||||
}
|
||||
writeDebugServerConfig({ host: restore.host, port: restore.port, allowlist: restore.allowlist });
|
||||
if (restartNow) {
|
||||
void restartDebugServer().catch(() => {});
|
||||
}
|
||||
this.audit("INFO", "Ferndiagnose-Einstellungen aus Backup wiederhergestellt", {
|
||||
port: restore.port ?? null,
|
||||
allowlistCount: restore.allowlist?.length ?? 0,
|
||||
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
|
||||
});
|
||||
}
|
||||
restartNow
|
||||
});
|
||||
}
|
||||
|
||||
public getDebugSetupCheck(): DebugSetupCheckResult {
|
||||
return getDebugSetupCheck(this.storagePaths.baseDir);
|
||||
@@ -510,22 +529,124 @@ export class AppController {
|
||||
this.onHistoryEntryAddedHandler = handler;
|
||||
}
|
||||
|
||||
private applySettingsOnlyBackup(importedSettings: AppSettings, remoteDiagnostics?: unknown, restoreRemoteDiagnostics = false): void {
|
||||
let restoredSettings = normalizeSettings(importedSettings);
|
||||
if (this.settings.logStorageLocation !== restoredSettings.logStorageLocation
|
||||
&& !this.reconfigureLogStorage(restoredSettings.logStorageLocation)) {
|
||||
restoredSettings = normalizeSettings({
|
||||
...restoredSettings,
|
||||
logStorageLocation: this.settings.logStorageLocation
|
||||
});
|
||||
private prepareImportedLogStorage(restoredSettings: AppSettings): AppSettings {
|
||||
if (this.settings.logStorageLocation === restoredSettings.logStorageLocation) {
|
||||
return restoredSettings;
|
||||
}
|
||||
this.overlayLiveUsageCounters(restoredSettings);
|
||||
this.settings = restoredSettings;
|
||||
saveSettings(this.storagePaths, this.settings);
|
||||
this.manager.setSettings(this.settings, { settingsOnlyImport: true });
|
||||
if (restoreRemoteDiagnostics) {
|
||||
this.restoreRemoteDiagnosticsFromBackup(remoteDiagnostics, true);
|
||||
const nextDirectory = resolveLogDirectory(
|
||||
this.storagePaths.baseDir,
|
||||
this.getDesktopDirectory(),
|
||||
restoredSettings.logStorageLocation
|
||||
);
|
||||
return prepareLogDirectory(nextDirectory)
|
||||
? restoredSettings
|
||||
: normalizeSettings({ ...restoredSettings, logStorageLocation: this.settings.logStorageLocation });
|
||||
}
|
||||
|
||||
private createBackupImportRollback(includeDownloads: boolean): () => 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")
|
||||
];
|
||||
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(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): Promise<void> {
|
||||
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;
|
||||
try {
|
||||
restoredSettings = this.prepareImportedLogStorage(normalizeSettings(importedSettings));
|
||||
this.overlayLiveUsageCounters(restoredSettings);
|
||||
rollback = this.createBackupImportRollback(false);
|
||||
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;
|
||||
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;
|
||||
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);
|
||||
}
|
||||
|
||||
public updateSettings(partial: Partial<AppSettings>): AppSettings {
|
||||
@@ -548,17 +669,26 @@ export class AppController {
|
||||
});
|
||||
}
|
||||
this.overlayLiveUsageCounters(nextSettings);
|
||||
const retentionChanged = previousSettings.historyRetentionMode !== nextSettings.historyRetentionMode;
|
||||
const historyLimitsChanged = previousSettings.historyMaxEntries !== nextSettings.historyMaxEntries
|
||||
|| previousSettings.historyMaxAgeDays !== nextSettings.historyMaxAgeDays;
|
||||
this.settings = nextSettings;
|
||||
if (retentionChanged) {
|
||||
resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode);
|
||||
} else if (historyLimitsChanged && this.settings.historyRetentionMode !== "never") {
|
||||
saveHistory(this.storagePaths, loadHistory(this.storagePaths), this.historyLimits());
|
||||
}
|
||||
saveSettings(this.storagePaths, this.settings);
|
||||
this.manager.setSettings(this.settings);
|
||||
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.manager.setSettings(this.settings);
|
||||
this.audit("INFO", "Einstellungen aktualisiert", {
|
||||
changedKeys: Object.keys(sanitizedPatch),
|
||||
accountChanges: diffAccountSummary(previousSettings, this.settings)
|
||||
@@ -692,15 +822,30 @@ export class AppController {
|
||||
return status;
|
||||
}
|
||||
|
||||
public resetProviderDailyUsage(provider: DebridProvider): AppSettings {
|
||||
const liveSettings = this.manager.getSettings();
|
||||
const nextSettings = normalizeSettings({
|
||||
...liveSettings,
|
||||
...resetProviderDailyUsage(liveSettings, provider)
|
||||
});
|
||||
this.settings = nextSettings;
|
||||
saveSettings(this.storagePaths, this.settings);
|
||||
this.manager.setSettings(this.settings);
|
||||
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;
|
||||
}
|
||||
@@ -1010,6 +1155,12 @@ export class AppController {
|
||||
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);
|
||||
}
|
||||
@@ -1209,7 +1360,7 @@ export class AppController {
|
||||
|
||||
public async importOnlineBackup(key: string): Promise<{ restored: boolean; relaunch: false; message: string }> {
|
||||
const payload = await downloadOnlineBackup(key, ONLINE_BACKUP_API_URL);
|
||||
this.applySettingsOnlyBackup(payload.settings);
|
||||
await this.applySettingsOnlyBackup(payload.settings);
|
||||
this.audit("INFO", "Online-Sicherung importiert", {
|
||||
kind: "settings-only",
|
||||
accountSummary: buildAccountSummary(this.settings)
|
||||
@@ -1243,7 +1394,7 @@ export class AppController {
|
||||
);
|
||||
}
|
||||
|
||||
public importBackup(data: Buffer, passphrase?: string): { restored: boolean; relaunch: boolean; message: string } {
|
||||
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);
|
||||
@@ -1259,10 +1410,19 @@ export class AppController {
|
||||
const plan = planBackupImport(parsed);
|
||||
if (!plan.valid) {
|
||||
return { restored: false, relaunch: false, message: plan.message };
|
||||
}
|
||||
const hasSession = plan.restoreDownloads;
|
||||
|
||||
const importedSettings = parsed.settings as AppSettings;
|
||||
}
|
||||
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)[] = [
|
||||
@@ -1286,7 +1446,7 @@ export class AppController {
|
||||
// policy still governs FUTURE completions through the normal path. Do NOT stop the
|
||||
// manager, wipe the session, block persistence or relaunch.
|
||||
if (!hasSession) {
|
||||
this.applySettingsOnlyBackup(restoredSettings, parsed.remoteDiagnostics, true);
|
||||
await this.applySettingsOnlyBackup(restoredSettings, parsed.remoteDiagnostics, true);
|
||||
this.audit("INFO", "Backup importiert (nur Einstellungen)", {
|
||||
accountSummary: buildAccountSummary(this.settings)
|
||||
});
|
||||
@@ -1294,57 +1454,85 @@ export class AppController {
|
||||
restored: true,
|
||||
relaunch: false,
|
||||
message: "Einstellungen wiederhergestellt"
|
||||
};
|
||||
}
|
||||
|
||||
if (this.settings.logStorageLocation !== restoredSettings.logStorageLocation
|
||||
&& !this.reconfigureLogStorage(restoredSettings.logStorageLocation)) {
|
||||
restoredSettings = normalizeSettings({
|
||||
...restoredSettings,
|
||||
logStorageLocation: this.settings.logStorageLocation
|
||||
});
|
||||
};
|
||||
}
|
||||
this.settings = restoredSettings;
|
||||
saveSettings(this.storagePaths, this.settings);
|
||||
this.manager.setSettings(this.settings);
|
||||
|
||||
this.manager.stop();
|
||||
this.manager.abortAllPostProcessing();
|
||||
this.manager.clearPersistTimer();
|
||||
cancelPendingAsyncSaves();
|
||||
|
||||
const restoredSession = normalizeLoadedSessionTransientFields(
|
||||
normalizeLoadedSession(parsed.session)
|
||||
);
|
||||
saveSession(this.storagePaths, restoredSession);
|
||||
|
||||
if (parsed.statistics) {
|
||||
saveStatisticsLedger(this.storagePaths.statisticsFile, normalizeStatisticsLedger(parsed.statistics));
|
||||
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;
|
||||
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;
|
||||
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 (Array.isArray(parsed.history) && parsed.history.length > 0) {
|
||||
const normalizedHistory = (parsed.history as unknown[])
|
||||
.map((raw, idx) => normalizeHistoryEntry(raw, idx))
|
||||
.filter((entry): entry is HistoryEntry => entry !== null);
|
||||
if (normalizedHistory.length > 0) {
|
||||
saveHistory(this.storagePaths, normalizedHistory);
|
||||
logger.info(`Backup: ${normalizedHistory.length} History-Einträge wiederhergestellt`);
|
||||
}
|
||||
}
|
||||
|
||||
resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode);
|
||||
|
||||
this.restoreRemoteDiagnosticsFromBackup(parsed.remoteDiagnostics, false);
|
||||
|
||||
this.manager.skipShutdownPersist = true;
|
||||
this.manager.blockAllPersistence = true;
|
||||
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…" };
|
||||
}
|
||||
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();
|
||||
@@ -1428,16 +1616,16 @@ export class AppController {
|
||||
this.realDebridWebFallbacks.clear();
|
||||
this.pendingRealDebridWebAccountIds.clear();
|
||||
this.allDebridWebFallback.dispose();
|
||||
this.bestDebridWebFallback.dispose();
|
||||
this.bestDebridWebFallback.dispose();
|
||||
if (this.settings.historyRetentionMode === "session") {
|
||||
this.runHistoryLifecycleCleanup("Beenden", () => clearHistory(this.storagePaths));
|
||||
}
|
||||
this.shutdownLogStorage();
|
||||
this.audit("INFO", "App beendet");
|
||||
shutdownTraceLog();
|
||||
shutdownAccountRotationLog();
|
||||
shutdownConversionLog();
|
||||
shutdownAuditLog();
|
||||
if (this.settings.historyRetentionMode === "session") {
|
||||
clearHistory(this.storagePaths);
|
||||
}
|
||||
logger.info("App beendet");
|
||||
}
|
||||
|
||||
@@ -1516,10 +1704,33 @@ export class AppController {
|
||||
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 {
|
||||
const entries = addHistoryEntryForRetention(this.storagePaths, this.settings.historyRetentionMode, entry, this.historyLimits());
|
||||
if (entries[0]?.id === entry.id) {
|
||||
this.onHistoryEntryAddedHandler?.(entry);
|
||||
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)}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { ParsedPackageInput } from "../shared/types";
|
||||
import { COLLECTOR_MAX_LINKS, COLLECTOR_MAX_NAME_LENGTH, COLLECTOR_MAX_PACKAGES } from "../shared/collector";
|
||||
import { mergePackageInputs } from "./link-parser";
|
||||
import { isHttpLink } from "./utils";
|
||||
|
||||
const invalidQueueExport = (): never => {
|
||||
throw new Error("Der Queue-Export ist ungültig.");
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
function hasOnlyKeys(value: Record<string, unknown>, allowedKeys: readonly string[]): boolean {
|
||||
return Object.keys(value).every((key) => allowedKeys.includes(key));
|
||||
}
|
||||
|
||||
export function parseCollectorQueueExport(rawText: string): ParsedPackageInput[] | null {
|
||||
const text = String(rawText || "").trim();
|
||||
if (!text) return null;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(text) as unknown;
|
||||
} catch {
|
||||
if (!text.startsWith("{") && !text.startsWith("[")) return null;
|
||||
throw new Error("Der Queue-Export enthält ungültiges JSON.");
|
||||
}
|
||||
if (!isRecord(parsed)
|
||||
|| !hasOnlyKeys(parsed, ["version", "packages"])
|
||||
|| parsed.version !== 1
|
||||
|| !Array.isArray(parsed.packages)) {
|
||||
return invalidQueueExport();
|
||||
}
|
||||
if (parsed.packages.length > COLLECTOR_MAX_PACKAGES) {
|
||||
throw new Error(`Der Queue-Export überschreitet das Collector-Limit von ${COLLECTOR_MAX_PACKAGES.toLocaleString("de-DE")} Paketen.`);
|
||||
}
|
||||
|
||||
let linkCount = 0;
|
||||
const packages: ParsedPackageInput[] = parsed.packages.map((value) => {
|
||||
if (!isRecord(value)
|
||||
|| !hasOnlyKeys(value, ["name", "links", "fileNames"])
|
||||
|| typeof value.name !== "string"
|
||||
|| value.name.trim().length === 0
|
||||
|| value.name.length > COLLECTOR_MAX_NAME_LENGTH
|
||||
|| !Array.isArray(value.links)
|
||||
|| (value.fileNames !== undefined && !Array.isArray(value.fileNames))) {
|
||||
return invalidQueueExport();
|
||||
}
|
||||
const fileNamesRaw = value.fileNames as unknown[] | undefined;
|
||||
if (fileNamesRaw && fileNamesRaw.length > value.links.length) return invalidQueueExport();
|
||||
linkCount += value.links.length;
|
||||
if (linkCount > COLLECTOR_MAX_LINKS) {
|
||||
throw new Error(`Der Queue-Export überschreitet das Collector-Limit von ${COLLECTOR_MAX_LINKS.toLocaleString("de-DE")} Links.`);
|
||||
}
|
||||
const links = value.links.map((entry) => {
|
||||
if (typeof entry !== "string" || entry.length > 32767 || !isHttpLink(entry)) return invalidQueueExport();
|
||||
return entry.trim();
|
||||
});
|
||||
const fileNames = links.map((_, index) => {
|
||||
const entry = fileNamesRaw?.[index];
|
||||
if (entry === undefined) return "";
|
||||
if (typeof entry !== "string" || entry.length > COLLECTOR_MAX_NAME_LENGTH) return invalidQueueExport();
|
||||
return entry.trim();
|
||||
});
|
||||
return {
|
||||
name: value.name,
|
||||
links,
|
||||
...(fileNames.some((fileName) => fileName.length > 0) ? { fileNames } : {})
|
||||
};
|
||||
});
|
||||
|
||||
return mergePackageInputs(packages).filter((pkg) => pkg.links.length > 0);
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import type {
|
||||
CollectorPackage,
|
||||
CollectorTextPreparationRequest
|
||||
} from "../shared/collector";
|
||||
import { serializeCollectorPackages } from "../shared/collector";
|
||||
import { COLLECTOR_MAX_NAME_LENGTH, serializeCollectorPackages } from "../shared/collector";
|
||||
import { extractHosterFromUrl } from "../shared/hoster";
|
||||
import type { AppSettings, ParsedPackageInput } from "../shared/types";
|
||||
import {
|
||||
@@ -20,9 +20,17 @@ import {
|
||||
type OneFichierCheckResult
|
||||
} from "./debrid";
|
||||
import { importDlcContainers } from "./container";
|
||||
import { parseCollectorQueueExport } from "./collector-import";
|
||||
import { parseCollectorInput } from "./link-parser";
|
||||
import { filenameFromUrl, isHttpLink, looksLikeOpaqueFilename, sanitizeFilename } from "./utils";
|
||||
|
||||
function normalizeCollectorName(value: string): string {
|
||||
const sanitized = sanitizeFilename(value);
|
||||
return sanitized.length <= COLLECTOR_MAX_NAME_LENGTH
|
||||
? sanitized
|
||||
: sanitizeFilename(sanitized.slice(0, COLLECTOR_MAX_NAME_LENGTH));
|
||||
}
|
||||
|
||||
export interface CollectorInspectionDependencies {
|
||||
checkDdownload?: typeof checkDdownloadOnline;
|
||||
checkOneFichier?: (links: string[]) => Promise<Map<string, OneFichierCheckResult>>;
|
||||
@@ -82,7 +90,7 @@ function readableHosterName(hoster: string): string {
|
||||
}
|
||||
|
||||
export function inferCollectorPackageName(fileName: string, hoster: string): string {
|
||||
const safeName = sanitizeFilename(fileName || "");
|
||||
const safeName = normalizeCollectorName(fileName || "");
|
||||
if (!safeName || looksLikeOpaqueFilename(safeName)) return readableHosterName(hoster);
|
||||
const patterns = [
|
||||
/^(.*)\.part\d+\.rar$/i,
|
||||
@@ -93,10 +101,10 @@ export function inferCollectorPackageName(fileName: string, hoster: string): str
|
||||
];
|
||||
for (const pattern of patterns) {
|
||||
const match = safeName.match(pattern);
|
||||
if (match?.[1]?.trim()) return sanitizeFilename(match[1]);
|
||||
if (match?.[1]?.trim()) return normalizeCollectorName(match[1]);
|
||||
}
|
||||
const stem = path.parse(safeName).name.trim();
|
||||
return sanitizeFilename(stem || readableHosterName(hoster));
|
||||
return normalizeCollectorName(stem || readableHosterName(hoster));
|
||||
}
|
||||
|
||||
function countInputLines(rawText: string): { invalidCount: number; duplicateCount: number } {
|
||||
@@ -129,8 +137,8 @@ function preparePackages(packages: ParsedPackageInput[], addedAt: number, nameSo
|
||||
const url = String(pkg.links[index] || "").trim();
|
||||
if (!url || seen.has(url)) continue;
|
||||
seen.add(url);
|
||||
const explicitFileName = sanitizeFilename(String(pkg.fileNames?.[index] || "").trim());
|
||||
const fileName = explicitFileName || filenameFromUrl(url);
|
||||
const explicitFileName = normalizeCollectorName(String(pkg.fileNames?.[index] || "").trim());
|
||||
const fileName = explicitFileName || normalizeCollectorName(filenameFromUrl(url));
|
||||
links.push({
|
||||
id: stableId("link", url),
|
||||
url,
|
||||
@@ -143,13 +151,21 @@ function preparePackages(packages: ParsedPackageInput[], addedAt: number, nameSo
|
||||
});
|
||||
}
|
||||
if (links.length === 0) continue;
|
||||
const name = sanitizeFilename(pkg.name || inferCollectorPackageName(links[0].fileName, links[0].hoster));
|
||||
const name = normalizeCollectorName(pkg.name || inferCollectorPackageName(links[0].fileName, links[0].hoster));
|
||||
prepared.push({ id: packageId(links), name, nameSource, links, addedAt });
|
||||
}
|
||||
return prepared;
|
||||
}
|
||||
|
||||
export function prepareCollectorText(request: CollectorTextPreparationRequest): CollectorInspectionResult {
|
||||
const queueExport = parseCollectorQueueExport(request.rawText);
|
||||
if (queueExport) {
|
||||
return {
|
||||
packages: preparePackages(queueExport, request.addedAt, "explicit"),
|
||||
invalidCount: 0,
|
||||
duplicateCount: 0
|
||||
};
|
||||
}
|
||||
const parsed = parseCollectorInput(request.rawText, "");
|
||||
const nameSource = /^#\s*package\s*:/im.test(request.rawText) ? "explicit" : "inferred";
|
||||
return {
|
||||
@@ -235,9 +251,10 @@ export async function enrichCollectorPackages(
|
||||
for (const [url, result] of results) {
|
||||
const link = linksByUrl.get(url);
|
||||
if (!link) continue;
|
||||
const fileName = result.fileName ? normalizeCollectorName(result.fileName) : "";
|
||||
link.availability = result.online ? "online" : "offline";
|
||||
link.status = result.online ? (result.fileName ? "ready" : "unknown") : "offline";
|
||||
if (result.fileName) link.fileName = sanitizeFilename(result.fileName);
|
||||
link.status = result.online ? (fileName ? "ready" : "unknown") : "offline";
|
||||
if (fileName) link.fileName = fileName;
|
||||
if (result.fileSizeBytes !== null && result.fileSizeBytes >= 0) link.fileSizeBytes = result.fileSizeBytes;
|
||||
progress.queue(url);
|
||||
}
|
||||
@@ -248,9 +265,10 @@ export async function enrichCollectorPackages(
|
||||
const result = await checkRapidgator(url).catch(() => null);
|
||||
const link = linksByUrl.get(url);
|
||||
if (!link || !result) return;
|
||||
const fileName = result.fileName ? normalizeCollectorName(result.fileName) : "";
|
||||
link.availability = result.online ? "online" : "offline";
|
||||
link.status = result.online ? (result.fileName ? "ready" : "unknown") : "offline";
|
||||
if (result.fileName) link.fileName = sanitizeFilename(result.fileName);
|
||||
link.status = result.online ? (fileName ? "ready" : "unknown") : "offline";
|
||||
if (fileName) link.fileName = fileName;
|
||||
if (result.fileSizeBytes !== null && result.fileSizeBytes >= 0) link.fileSizeBytes = result.fileSizeBytes;
|
||||
progress.queue(url);
|
||||
});
|
||||
@@ -258,9 +276,10 @@ export async function enrichCollectorPackages(
|
||||
const result = await checkDdownload(url).catch(() => null);
|
||||
const link = linksByUrl.get(url);
|
||||
if (!link || !result) return;
|
||||
const fileName = result.fileName ? normalizeCollectorName(result.fileName) : "";
|
||||
link.availability = result.online ? "online" : "offline";
|
||||
link.status = result.online ? (result.fileName ? "ready" : "unknown") : "offline";
|
||||
if (result.fileName) link.fileName = sanitizeFilename(result.fileName);
|
||||
link.status = result.online ? (fileName ? "ready" : "unknown") : "offline";
|
||||
if (fileName) link.fileName = fileName;
|
||||
if (result.fileSizeBytes !== null && result.fileSizeBytes >= 0) link.fileSizeBytes = result.fileSizeBytes;
|
||||
progress.queue(url);
|
||||
});
|
||||
@@ -268,7 +287,7 @@ export async function enrichCollectorPackages(
|
||||
? resolveFilenames(genericLinks, (url, fileName) => {
|
||||
const link = linksByUrl.get(url);
|
||||
if (!link || !fileName) return;
|
||||
link.fileName = sanitizeFilename(fileName);
|
||||
link.fileName = normalizeCollectorName(fileName);
|
||||
link.status = "ready";
|
||||
progress.queue(url);
|
||||
}).catch(() => new Map<string, string>())
|
||||
@@ -283,7 +302,7 @@ export async function enrichCollectorPackages(
|
||||
for (const [url, fileName] of genericResults) {
|
||||
const link = linksByUrl.get(url);
|
||||
if (!link || !fileName) continue;
|
||||
link.fileName = sanitizeFilename(fileName);
|
||||
link.fileName = normalizeCollectorName(fileName);
|
||||
link.status = "ready";
|
||||
progress.queue(url);
|
||||
}
|
||||
|
||||
@@ -3,12 +3,12 @@ import fsp from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
COLLECTOR_MAX_PERSISTENCE_BYTES,
|
||||
validateCollectorPersistenceState,
|
||||
type CollectorPersistenceState
|
||||
} from "../shared/collector";
|
||||
import { logger } from "./logger";
|
||||
|
||||
const maxPersistenceBytes = 64 * 1024 * 1024;
|
||||
const writeDelayMs = 300;
|
||||
const renameRetryDelaysMs = [15, 40, 90];
|
||||
|
||||
@@ -62,7 +62,7 @@ async function renameWithRetry(tempPath: string, filePath: string): Promise<void
|
||||
function parsePersistenceFile(filePath: string): CollectorPersistenceFile | null {
|
||||
try {
|
||||
const stat = fs.statSync(filePath);
|
||||
if (!stat.isFile() || stat.size > maxPersistenceBytes) return null;
|
||||
if (!stat.isFile() || stat.size > COLLECTOR_MAX_PERSISTENCE_BYTES) return null;
|
||||
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as Record<string, unknown>;
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
||||
if (Object.keys(parsed).some((key) => !["version", "packages", "collapsedPackageIds", "updatedAt"].includes(key))) return null;
|
||||
@@ -163,7 +163,7 @@ export class CollectorStore {
|
||||
public update(state: CollectorPersistenceState): void {
|
||||
const nextState = validateCollectorPersistenceState(state);
|
||||
const nextUpdatedAt = Math.max(Date.now(), this.updatedAt + 1);
|
||||
if (Buffer.byteLength(serializeState(nextState, nextUpdatedAt), "utf8") > maxPersistenceBytes) {
|
||||
if (Buffer.byteLength(serializeState(nextState, nextUpdatedAt), "utf8") > COLLECTOR_MAX_PERSISTENCE_BYTES) {
|
||||
throw new Error("Linksammler-Speicherzustand ist zu groß");
|
||||
}
|
||||
this.state = nextState;
|
||||
|
||||
@@ -111,6 +111,7 @@ export function defaultSettings(): AppSettings {
|
||||
clipboardWatch: false,
|
||||
minimizeToTray: false,
|
||||
theme: "dark" as const,
|
||||
themePreference: "dark" as const,
|
||||
logStorageLocation: "appdata",
|
||||
collapseNewPackages: true,
|
||||
animatePackageDisclosure: true,
|
||||
|
||||
+103
-25
@@ -90,8 +90,10 @@ import {
|
||||
loadStatisticsLedger,
|
||||
normalizeStatisticsLedger,
|
||||
projectStatisticsLedger,
|
||||
rebaseStatisticsProviderSeedBaseline,
|
||||
saveStatisticsLedger,
|
||||
seedStatisticsDayProviderBytes
|
||||
seedStatisticsDayProviderBytes,
|
||||
suppressStatisticsProviderSeedForDay
|
||||
} from "./statistics-ledger";
|
||||
import { finalizePackageResult, projectPackageFailureCategory } from "./package-telemetry";
|
||||
import type { NotificationEvent } from "./notification-outbox";
|
||||
@@ -1910,9 +1912,10 @@ export class DownloadManager extends EventEmitter {
|
||||
this.settingsSnapshotCacheAt = 0;
|
||||
}
|
||||
|
||||
private lastEmittedItemHashes = new Map<string, string>();
|
||||
private lastEmittedPackageHashes = new Map<string, string>();
|
||||
private firstEmitDone = false;
|
||||
private lastEmittedItemHashes = new Map<string, string>();
|
||||
private lastEmittedPackageHashes = new Map<string, string>();
|
||||
private snapshotRevision = 0;
|
||||
private firstEmitDone = false;
|
||||
private lastFullEmitAt = 0;
|
||||
private static readonly FULL_RESYNC_INTERVAL_MS = 30000;
|
||||
|
||||
@@ -2787,7 +2790,20 @@ export class DownloadManager extends EventEmitter {
|
||||
const elapsed = this.session.runStartedAt > 0 ? (now - this.session.runStartedAt) / 1000 : 0;
|
||||
const rate = doneItems > 0 && elapsed > 0 ? doneItems / elapsed : 0;
|
||||
const remaining = totalItems - doneItems;
|
||||
const eta = remaining > 0 && rate > 0 ? remaining / rate : -1;
|
||||
const eta = remaining > 0 && rate > 0 ? remaining / rate : -1;
|
||||
let runRemainingBytes = 0;
|
||||
let runRemainingUnknownItems = 0;
|
||||
if (this.session.running) {
|
||||
for (const itemId of this.runItemIds) {
|
||||
const item = this.session.items[itemId];
|
||||
if (!item || isFinishedStatus(item.status)) continue;
|
||||
if (item.totalBytes && item.totalBytes > 0) {
|
||||
runRemainingBytes += Math.max(0, item.totalBytes - Math.max(0, item.downloadedBytes));
|
||||
} else {
|
||||
runRemainingUnknownItems += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const reconnectMs = Math.max(0, this.session.reconnectUntil - now);
|
||||
|
||||
@@ -2805,6 +2821,7 @@ export class DownloadManager extends EventEmitter {
|
||||
: null;
|
||||
|
||||
return {
|
||||
snapshotRevision: ++this.snapshotRevision,
|
||||
rotationEvents: getRecentRotationEvents(40),
|
||||
accountRuntime: createAccountRuntimeEntries(rendererState.accounts, Object.values(snapshotSession.items), now),
|
||||
settings: rendererState.settings,
|
||||
@@ -2827,8 +2844,10 @@ export class DownloadManager extends EventEmitter {
|
||||
for (const [pid, bytes] of this.speedBytesPerPackage) {
|
||||
out[pid] = Math.floor(bytes / SPEED_WINDOW_SECONDS);
|
||||
}
|
||||
return out;
|
||||
})()
|
||||
return out;
|
||||
})(),
|
||||
runRemainingBytes,
|
||||
runRemainingUnknownItems
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3036,7 +3055,7 @@ export class DownloadManager extends EventEmitter {
|
||||
return Object.values(this.session.packages).some((pkg) => pathKey(pkg.outputDir) === key);
|
||||
}
|
||||
|
||||
public resetSessionStats(): void {
|
||||
public resetSessionStats(): void {
|
||||
const now = nowMs();
|
||||
this.session.totalDownloadedBytes = 0;
|
||||
this.sessionDownloadedBytes = 0;
|
||||
@@ -3053,24 +3072,77 @@ export class DownloadManager extends EventEmitter {
|
||||
this.summary = null;
|
||||
this.invalidateStatsCache();
|
||||
this.persistSoon();
|
||||
this.emitState(true);
|
||||
}
|
||||
|
||||
public resetDownloadStats(): void {
|
||||
this.settings.totalDownloadedAllTime = 0;
|
||||
this.settings.totalCompletedFilesAllTime = 0;
|
||||
this.settings.providerTotalUsageBytes = {};
|
||||
this.settings.debridLinkApiKeyTotalUsageBytes = {};
|
||||
this.statisticsLedger = createStatisticsLedger();
|
||||
this.rollingAccountStatistics.reset(this.statisticsLedger);
|
||||
this.emitState(true);
|
||||
}
|
||||
|
||||
public rebaseStatisticsProviderDailyUsage(provider: DebridProvider, providerUsageBytes: number): void {
|
||||
const rebasedAt = nowMs();
|
||||
const rebased = rebaseStatisticsProviderSeedBaseline(
|
||||
this.statisticsLedger,
|
||||
provider,
|
||||
providerUsageBytes,
|
||||
rebasedAt
|
||||
);
|
||||
saveStatisticsLedger(this.storagePaths.statisticsFile, rebased);
|
||||
this.statisticsLedger = rebased;
|
||||
this.rollingAccountStatistics.reset(rebased, rebasedAt);
|
||||
this.statisticsDirty = false;
|
||||
this.statisticsUrgent = false;
|
||||
saveStatisticsLedger(this.storagePaths.statisticsFile, this.statisticsLedger);
|
||||
this.lastSettingsPersistAt = nowMs();
|
||||
saveSettings(this.storagePaths, this.settings);
|
||||
this.invalidateStatsCache();
|
||||
this.emitState(true);
|
||||
}
|
||||
this.invalidateStatsCache();
|
||||
this.emitState(true);
|
||||
}
|
||||
|
||||
public resetDownloadStats(): void {
|
||||
const resetAt = nowMs();
|
||||
const previousSettings = JSON.parse(JSON.stringify(this.settings)) as AppSettings;
|
||||
const previousLedger = normalizeStatisticsLedger(this.statisticsLedger, resetAt);
|
||||
const nextSettings = JSON.parse(JSON.stringify(previousSettings)) as AppSettings;
|
||||
const currentUsageDay = getProviderUsageDayKey(resetAt);
|
||||
if (nextSettings.providerDailyUsageDay !== currentUsageDay) {
|
||||
nextSettings.providerDailyUsageDay = currentUsageDay;
|
||||
nextSettings.providerDailyUsageBytes = {};
|
||||
nextSettings.debridLinkApiKeyDailyUsageBytes = {};
|
||||
nextSettings.megaDebridAccountDailyUsageBytes = {};
|
||||
nextSettings.realDebridAccountDailyUsageBytes = {};
|
||||
}
|
||||
nextSettings.totalDownloadedAllTime = 0;
|
||||
nextSettings.totalCompletedFilesAllTime = 0;
|
||||
nextSettings.providerTotalUsageBytes = {};
|
||||
nextSettings.debridLinkApiKeyTotalUsageBytes = {};
|
||||
const nextLedger = suppressStatisticsProviderSeedForDay(
|
||||
createStatisticsLedger(resetAt),
|
||||
resetAt,
|
||||
nextSettings.providerDailyUsageBytes
|
||||
);
|
||||
saveStatisticsLedger(this.storagePaths.statisticsFile, nextLedger);
|
||||
try {
|
||||
saveSettings(this.storagePaths, nextSettings);
|
||||
} catch (error) {
|
||||
const rollbackErrors: string[] = [];
|
||||
try {
|
||||
saveStatisticsLedger(this.storagePaths.statisticsFile, previousLedger);
|
||||
} catch (rollbackError) {
|
||||
rollbackErrors.push(`Statistik: ${compactErrorText(rollbackError)}`);
|
||||
}
|
||||
try {
|
||||
saveSettings(this.storagePaths, previousSettings);
|
||||
} catch (rollbackError) {
|
||||
rollbackErrors.push(`Einstellungen: ${compactErrorText(rollbackError)}`);
|
||||
}
|
||||
if (rollbackErrors.length > 0) {
|
||||
logger.error(`Statistik-Reset-Rollback fehlgeschlagen: ${rollbackErrors.join(" | ")}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
Object.assign(this.settings, nextSettings);
|
||||
this.statisticsLedger = nextLedger;
|
||||
this.rollingAccountStatistics.reset(nextLedger, resetAt);
|
||||
this.statisticsDirty = false;
|
||||
this.statisticsUrgent = false;
|
||||
this.lastSettingsPersistAt = resetAt;
|
||||
this.invalidateStatsCache();
|
||||
this.emitState(true);
|
||||
}
|
||||
|
||||
public renamePackage(packageId: string, newName: string): void {
|
||||
const pkg = this.session.packages[packageId];
|
||||
@@ -7062,9 +7134,15 @@ export class DownloadManager extends EventEmitter {
|
||||
} catch (error) {
|
||||
this.statisticsDirty = true;
|
||||
logger.warn(`Statistik konnte nicht gespeichert werden: ${compactErrorText(error)}`);
|
||||
try {
|
||||
saveSettings(this.storagePaths, this.settings);
|
||||
this.lastSettingsPersistAt = now;
|
||||
} catch (settingsError) {
|
||||
logger.warn(`Statistik-Fallback konnte nicht gespeichert werden: ${compactErrorText(settingsError)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public persistNowSync(): void {
|
||||
this.clearPersistTimer();
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { ParsedPackageInput } from "../shared/types";
|
||||
import { inferPackageNameFromLinks, parsePackagesFromLinksText, sanitizeFilename, uniquePreserveOrder } from "./utils";
|
||||
import { inferPackageNameFromLinks, parsePackagesFromLinksText, sanitizeFilename } from "./utils";
|
||||
|
||||
export function mergePackageInputs(packages: ParsedPackageInput[]): ParsedPackageInput[] {
|
||||
const grouped = new Map<string, { links: string[]; fileNameByLink: Map<string, string> }>();
|
||||
const grouped = new Map<string, { links: string[]; linkSet: Set<string>; fileNameByLink: Map<string, string> }>();
|
||||
for (const pkg of packages) {
|
||||
const name = sanitizeFilename(pkg.name || inferPackageNameFromLinks(pkg.links));
|
||||
const current = grouped.get(name) ?? { links: [], fileNameByLink: new Map<string, string>() };
|
||||
const current = grouped.get(name) ?? { links: [], linkSet: new Set<string>(), fileNameByLink: new Map<string, string>() };
|
||||
for (let index = 0; index < pkg.links.length; index += 1) {
|
||||
const link = String(pkg.links[index] || "").trim();
|
||||
if (!link) {
|
||||
continue;
|
||||
}
|
||||
if (!current.links.includes(link)) {
|
||||
if (!current.linkSet.has(link)) {
|
||||
current.linkSet.add(link);
|
||||
current.links.push(link);
|
||||
}
|
||||
const rawFileName = String(pkg.fileNames?.[index] || "").trim();
|
||||
@@ -23,7 +24,7 @@ export function mergePackageInputs(packages: ParsedPackageInput[]): ParsedPackag
|
||||
grouped.set(name, current);
|
||||
}
|
||||
return Array.from(grouped.entries()).map(([name, entry]) => {
|
||||
const links = uniquePreserveOrder(entry.links);
|
||||
const links = entry.links;
|
||||
const fileNames = links.map((link) => entry.fileNameByLink.get(link) || "");
|
||||
return {
|
||||
name,
|
||||
|
||||
+31
-8
@@ -207,6 +207,32 @@ function onTrusted<TArgs extends unknown[]>(channel: string, listener: (event: I
|
||||
listener(event, ...(args as TArgs));
|
||||
});
|
||||
}
|
||||
|
||||
export function saveCollectorStateFromSyncIpc(
|
||||
target: Pick<AppController, "saveCollectorStateSync">,
|
||||
event: Pick<IpcMainEvent, "returnValue">,
|
||||
value: unknown
|
||||
): void {
|
||||
try {
|
||||
target.saveCollectorStateSync(validateCollectorPersistenceState(value));
|
||||
event.returnValue = true;
|
||||
} catch (error) {
|
||||
logger.warn(`Linksammler-Abschlussspeicherung fehlgeschlagen: ${String(error)}`);
|
||||
event.returnValue = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function getCollectorStateFromSyncIpc(
|
||||
target: Pick<AppController, "getCollectorState">,
|
||||
event: Pick<IpcMainEvent, "returnValue">
|
||||
): void {
|
||||
try {
|
||||
event.returnValue = validateCollectorPersistenceState(target.getCollectorState());
|
||||
} catch (error) {
|
||||
logger.warn(`Synchrones Laden des Linksammlers fehlgeschlagen: ${String(error)}`);
|
||||
event.returnValue = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Single owner of the scheduled-start timer. startOnPast: a past time entered
|
||||
// interactively starts right away; at boot a stale past time is cleared instead
|
||||
@@ -579,17 +605,14 @@ function registerIpcHandlers(): void {
|
||||
});
|
||||
});
|
||||
handleTrusted(IPC_CHANNELS.GET_COLLECTOR_STATE, () => controller.getCollectorState());
|
||||
onTrusted(IPC_CHANNELS.GET_COLLECTOR_STATE_SYNC, (event: IpcMainEvent) => {
|
||||
getCollectorStateFromSyncIpc(controller, event);
|
||||
});
|
||||
handleTrusted(IPC_CHANNELS.SAVE_COLLECTOR_STATE, (_event: IpcMainInvokeEvent, value: unknown) => {
|
||||
return controller.saveCollectorState(validateCollectorPersistenceState(value));
|
||||
});
|
||||
onTrusted(IPC_CHANNELS.SAVE_COLLECTOR_STATE_SYNC, (event: IpcMainEvent, value: unknown) => {
|
||||
try {
|
||||
controller.saveCollectorState(validateCollectorPersistenceState(value));
|
||||
event.returnValue = true;
|
||||
} catch (error) {
|
||||
logger.warn(`Linksammler-Abschlussspeicherung fehlgeschlagen: ${String(error)}`);
|
||||
event.returnValue = false;
|
||||
}
|
||||
saveCollectorStateFromSyncIpc(controller, event, value);
|
||||
});
|
||||
handleTrusted(IPC_CHANNELS.GET_START_CONFLICTS, () => controller.getStartConflicts());
|
||||
handleTrusted(IPC_CHANNELS.RESOLVE_START_CONFLICT, (_event: IpcMainInvokeEvent, packageId: string, policy: "keep" | "skip" | "overwrite") => {
|
||||
@@ -1022,7 +1045,7 @@ function registerIpcHandlers(): void {
|
||||
return { restored: false, relaunch: false, message: "Keine Backup-Datei ausgewählt" };
|
||||
}
|
||||
const passphrase = typeof rawPassphrase === "string" ? rawPassphrase : undefined;
|
||||
const importResult = controller.importBackup(data, passphrase);
|
||||
const importResult = await controller.importBackup(data, passphrase);
|
||||
// Only a full restore (queue swapped) needs the auto-relaunch. A settings-
|
||||
// only import applied live — relaunching would be pointless and would drop
|
||||
// the running queue.
|
||||
|
||||
@@ -4,6 +4,7 @@ import path from "node:path";
|
||||
import type { DiscordEmbedFieldPayload } from "./notify";
|
||||
import { projectPackageFailureCategory } from "./package-telemetry";
|
||||
import type { FailurePhase } from "../shared/types";
|
||||
import { logger } from "./logger";
|
||||
|
||||
export type NotificationEventType =
|
||||
| "package_completed"
|
||||
@@ -71,6 +72,7 @@ const EVENT_TYPES = new Set<NotificationEventType>([
|
||||
const MAX_EVENTS = 250;
|
||||
const MAX_RETRY_DELAY_MS = 10 * 60 * 1000;
|
||||
const DEFAULT_SHUTDOWN_TIMEOUT_MS = 3000;
|
||||
const RENAME_RETRY_DELAYS_MS = [15, 40, 90];
|
||||
const PACKAGE_FAILURE_EVENT_TYPES = new Set<NotificationEventType>([
|
||||
"package_partial",
|
||||
"package_failed",
|
||||
@@ -84,6 +86,40 @@ const PACKAGE_FAILURE_PHASES = new Map<string, FailurePhase>([
|
||||
["Nachbearbeitung", "postprocess"]
|
||||
]);
|
||||
|
||||
function renameErrorCode(error: unknown): string {
|
||||
return error && typeof error === "object" && "code" in error
|
||||
? String((error as NodeJS.ErrnoException).code || "")
|
||||
: "";
|
||||
}
|
||||
|
||||
function isTransientRenameError(error: unknown): boolean {
|
||||
return ["EPERM", "EACCES", "EBUSY"].includes(renameErrorCode(error));
|
||||
}
|
||||
|
||||
function renameFileSyncWithRetry(tempPath: string, filePath: string): void {
|
||||
for (let attempt = 0; ; attempt += 1) {
|
||||
try {
|
||||
fs.renameSync(tempPath, filePath);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (!isTransientRenameError(error) || attempt >= RENAME_RETRY_DELAYS_MS.length) throw error;
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, RENAME_RETRY_DELAYS_MS[attempt]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function renameFileWithRetry(tempPath: string, filePath: string): Promise<void> {
|
||||
for (let attempt = 0; ; attempt += 1) {
|
||||
try {
|
||||
await fsp.rename(tempPath, filePath);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (!isTransientRenameError(error) || attempt >= RENAME_RETRY_DELAYS_MS.length) throw error;
|
||||
await new Promise((resolve) => setTimeout(resolve, RENAME_RETRY_DELAYS_MS[attempt]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function finiteInteger(value: unknown, fallback = 0): number {
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric) ? Math.max(0, Math.floor(numeric)) : fallback;
|
||||
@@ -188,8 +224,12 @@ export class NotificationOutbox {
|
||||
this.clock = options.now || Date.now;
|
||||
this.autoDrain = Boolean(options.autoDrain);
|
||||
this.load();
|
||||
if (this.autoDrain && this.events.length > 0) {
|
||||
this.scheduleDrain(Math.max(0, this.events[0].nextAttemptAt - this.clock()));
|
||||
if (this.autoDrain) {
|
||||
if (this.persistenceRequired) {
|
||||
this.schedulePersistenceRetry(retryDelayMs(this.persistenceRetryAttempts));
|
||||
} else if (this.events.length > 0) {
|
||||
this.scheduleDrain(Math.max(0, this.events[0].nextAttemptAt - this.clock()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -381,7 +421,18 @@ export class NotificationOutbox {
|
||||
this.lastSuccessAt = 0;
|
||||
this.lastFailureAt = 0;
|
||||
}
|
||||
this.persistSync(this.clock());
|
||||
try {
|
||||
this.persistSync(this.clock());
|
||||
} catch (error) {
|
||||
this.persistenceRequired = true;
|
||||
this.persistenceRetryAttempts += 1;
|
||||
const code = renameErrorCode(error) || "UNKNOWN";
|
||||
if (isTransientRenameError(error)) {
|
||||
logger.warn(`Notification-Outbox beim Laden vorübergehend gesperrt (${code}); Persistenz wird erneut versucht`);
|
||||
} else {
|
||||
logger.error(`Notification-Outbox konnte beim Laden nicht gespeichert werden (${code}): ${String(error)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enforceLimits(now: number): void {
|
||||
@@ -411,7 +462,7 @@ export class NotificationOutbox {
|
||||
try {
|
||||
await fsp.mkdir(path.dirname(this.filePath), { recursive: true });
|
||||
await fsp.writeFile(tempPath, JSON.stringify(state), "utf8");
|
||||
await fsp.rename(tempPath, this.filePath);
|
||||
await renameFileWithRetry(tempPath, this.filePath);
|
||||
this.persistenceRequired = false;
|
||||
this.persistenceRetryAttempts = 0;
|
||||
} catch (error) {
|
||||
@@ -436,7 +487,7 @@ export class NotificationOutbox {
|
||||
};
|
||||
try {
|
||||
fs.writeFileSync(tempPath, JSON.stringify(state), "utf8");
|
||||
fs.renameSync(tempPath, this.filePath);
|
||||
renameFileSyncWithRetry(tempPath, this.filePath);
|
||||
} catch (error) {
|
||||
try {
|
||||
fs.rmSync(tempPath, { force: true });
|
||||
|
||||
@@ -94,6 +94,9 @@ export function validateRendererSettingsUpdate(value: unknown, current: AppSetti
|
||||
if (key === "notifyPackageSuccessMode" && entry !== "digest" && entry !== "individual") {
|
||||
invalid();
|
||||
}
|
||||
if (key === "themePreference" && entry !== "light" && entry !== "dark" && entry !== "system") {
|
||||
invalid();
|
||||
}
|
||||
if (key === "dailyStartMinuteOfDay" && (!Number.isInteger(entry) || (entry as number) < 0 || (entry as number) > 1_439)) {
|
||||
invalid();
|
||||
}
|
||||
|
||||
@@ -209,6 +209,7 @@ export function createRendererSettings(settings: AppSettings): RendererSettings
|
||||
clipboardWatch: settings.clipboardWatch,
|
||||
minimizeToTray: settings.minimizeToTray,
|
||||
theme: settings.theme,
|
||||
themePreference: settings.themePreference,
|
||||
logStorageLocation: settings.logStorageLocation,
|
||||
collapseNewPackages: settings.collapseNewPackages,
|
||||
animatePackageDisclosure: settings.animatePackageDisclosure,
|
||||
|
||||
@@ -54,6 +54,14 @@ function finiteNonNegative(value: unknown): number {
|
||||
return Number.isFinite(number) ? Math.max(0, Math.floor(number)) : 0;
|
||||
}
|
||||
|
||||
function finiteInteger(value: unknown): number {
|
||||
const number = Number(value);
|
||||
if (!Number.isFinite(number)) {
|
||||
return 0;
|
||||
}
|
||||
return Math.max(-Number.MAX_SAFE_INTEGER, Math.min(Number.MAX_SAFE_INTEGER, Math.trunc(number)));
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
@@ -176,6 +184,20 @@ function normalizeProviderBucket(value: unknown): StatisticsProviderBucket {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeProviderSeedBaseline(value: unknown): Partial<Record<DebridProvider, number>> | undefined {
|
||||
const record = asRecord(value);
|
||||
if (!record) {
|
||||
return undefined;
|
||||
}
|
||||
const baseline: Partial<Record<DebridProvider, number>> = {};
|
||||
for (const [provider, bytes] of Object.entries(record)) {
|
||||
if (providers.has(provider as DebridProvider)) {
|
||||
baseline[provider as DebridProvider] = finiteInteger(bytes);
|
||||
}
|
||||
}
|
||||
return baseline;
|
||||
}
|
||||
|
||||
function normalizeDay(value: unknown): StatisticsDayBucket | null {
|
||||
const record = asRecord(value);
|
||||
const day = String(record?.day || "");
|
||||
@@ -201,7 +223,7 @@ function normalizeDay(value: unknown): StatisticsDayBucket | null {
|
||||
}
|
||||
|
||||
export function createStatisticsLedger(now = Date.now()): StatisticsLedger {
|
||||
return { version: 2, startedAt: now, days: [], minutes: [] };
|
||||
return { version: 2, startedAt: now, minuteTrackingStartedAt: now, days: [], minutes: [] };
|
||||
}
|
||||
|
||||
export function normalizeStatisticsLedger(value: unknown, now = Date.now()): StatisticsLedger {
|
||||
@@ -216,9 +238,23 @@ export function normalizeStatisticsLedger(value: unknown, now = Date.now()): Sta
|
||||
byDay.set(day.day, day);
|
||||
}
|
||||
}
|
||||
const providerSeedSuppressedDay = /^\d{4}-\d{2}-\d{2}$/.test(String(record.providerSeedSuppressedDay || ""))
|
||||
? String(record.providerSeedSuppressedDay)
|
||||
: undefined;
|
||||
const providerBytesOnlyDays = [...new Set(
|
||||
(Array.isArray(record.providerBytesOnlyDays) ? record.providerBytesOnlyDays : [])
|
||||
.map((day) => String(day || ""))
|
||||
.filter((day) => /^\d{4}-\d{2}-\d{2}$/.test(day) && byDay.has(day))
|
||||
)].sort();
|
||||
return {
|
||||
version: 2,
|
||||
startedAt: finiteNonNegative(record.startedAt) || now,
|
||||
minuteTrackingStartedAt: Math.min(finiteNonNegative(record.minuteTrackingStartedAt) || now, now),
|
||||
providerSeedSuppressedDay,
|
||||
providerSeedBaselineBytes: providerSeedSuppressedDay
|
||||
? normalizeProviderSeedBaseline(record.providerSeedBaselineBytes)
|
||||
: undefined,
|
||||
providerBytesOnlyDays: providerBytesOnlyDays.length > 0 ? providerBytesOnlyDays : undefined,
|
||||
days: [...byDay.values()].sort((left, right) => left.day.localeCompare(right.day)),
|
||||
minutes: normalizeMinutes(record.minutes, now)
|
||||
};
|
||||
@@ -401,16 +437,58 @@ function mutableDay(ledger: StatisticsLedger, epochMs: number): StatisticsDayBuc
|
||||
return day;
|
||||
}
|
||||
|
||||
function markProviderBytesOnlyDay(ledger: StatisticsLedger, dayKey: string): StatisticsLedger {
|
||||
return {
|
||||
...ledger,
|
||||
providerBytesOnlyDays: [...new Set([...(ledger.providerBytesOnlyDays ?? []), dayKey])].sort()
|
||||
};
|
||||
}
|
||||
|
||||
export function seedStatisticsDayProviderBytes(
|
||||
ledger: StatisticsLedger,
|
||||
usage: Partial<Record<DebridProvider, number>>,
|
||||
epochMs = Date.now()
|
||||
): StatisticsLedger {
|
||||
return updateDay(ledger, epochMs, (day) => {
|
||||
const normalized = normalizeStatisticsLedger(ledger, epochMs);
|
||||
const dayKey = getProviderUsageDayKey(epochMs);
|
||||
if (normalized.providerSeedSuppressedDay === dayKey) {
|
||||
const baseline = normalized.providerSeedBaselineBytes;
|
||||
if (!baseline) {
|
||||
return normalized;
|
||||
}
|
||||
const deltas: Partial<Record<DebridProvider, number>> = {};
|
||||
for (const [provider, rawBytes] of Object.entries(usage) as Array<[DebridProvider, number | undefined]>) {
|
||||
if (!providers.has(provider)) continue;
|
||||
const delta = Math.max(0, finiteNonNegative(rawBytes) - finiteInteger(baseline[provider]));
|
||||
if (delta > 0) {
|
||||
deltas[provider] = delta;
|
||||
}
|
||||
}
|
||||
if (Object.keys(deltas).length === 0) {
|
||||
return normalized;
|
||||
}
|
||||
let recovered = false;
|
||||
const seeded = updateDay(normalized, epochMs, (day) => {
|
||||
for (const [provider, bytes] of Object.entries(deltas) as Array<[DebridProvider, number]>) {
|
||||
const existing = day.providers[provider] ?? emptyProviderBucket();
|
||||
recovered ||= bytes > existing.bytes;
|
||||
existing.bytes = Math.max(existing.bytes, bytes);
|
||||
day.providers[provider] = existing;
|
||||
}
|
||||
day.downloadedBytes = Math.max(
|
||||
day.downloadedBytes,
|
||||
Object.values(day.providers).reduce((total, bucket) => total + finiteNonNegative(bucket?.bytes), 0)
|
||||
);
|
||||
});
|
||||
return recovered ? markProviderBytesOnlyDay(seeded, dayKey) : seeded;
|
||||
}
|
||||
let recovered = false;
|
||||
const seeded = updateDay(normalized, epochMs, (day) => {
|
||||
for (const [provider, rawBytes] of Object.entries(usage) as Array<[DebridProvider, number | undefined]>) {
|
||||
if (!providers.has(provider)) continue;
|
||||
const bytes = finiteNonNegative(rawBytes);
|
||||
const existing = day.providers[provider] ?? emptyProviderBucket();
|
||||
recovered ||= bytes > existing.bytes;
|
||||
existing.bytes = Math.max(existing.bytes, bytes);
|
||||
day.providers[provider] = existing;
|
||||
}
|
||||
@@ -419,6 +497,54 @@ export function seedStatisticsDayProviderBytes(
|
||||
Object.values(day.providers).reduce((total, bucket) => total + finiteNonNegative(bucket?.bytes), 0)
|
||||
);
|
||||
});
|
||||
const current = normalized.providerSeedSuppressedDay && normalized.providerSeedSuppressedDay !== dayKey
|
||||
? { ...seeded, providerSeedSuppressedDay: undefined, providerSeedBaselineBytes: undefined }
|
||||
: seeded;
|
||||
return recovered ? markProviderBytesOnlyDay(current, dayKey) : current;
|
||||
}
|
||||
|
||||
export function rebaseStatisticsProviderSeedBaseline(
|
||||
ledger: StatisticsLedger,
|
||||
provider: DebridProvider,
|
||||
providerUsageBytes: number,
|
||||
epochMs = Date.now()
|
||||
): StatisticsLedger {
|
||||
const normalized = normalizeStatisticsLedger(ledger, epochMs);
|
||||
const dayKey = getProviderUsageDayKey(epochMs);
|
||||
if (!providers.has(provider)) {
|
||||
return normalized;
|
||||
}
|
||||
const activeBaseline = normalized.providerSeedSuppressedDay === dayKey
|
||||
? normalized.providerSeedBaselineBytes
|
||||
: {};
|
||||
if (normalized.providerSeedSuppressedDay === dayKey && activeBaseline === undefined) {
|
||||
return normalized;
|
||||
}
|
||||
const providerBytes = finiteNonNegative(
|
||||
normalized.days.find((day) => day.day === dayKey)?.providers[provider]?.bytes
|
||||
);
|
||||
return {
|
||||
...normalized,
|
||||
providerSeedSuppressedDay: dayKey,
|
||||
providerSeedBaselineBytes: {
|
||||
...activeBaseline,
|
||||
[provider]: finiteNonNegative(providerUsageBytes) - providerBytes
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function suppressStatisticsProviderSeedForDay(
|
||||
ledger: StatisticsLedger,
|
||||
epochMs = Date.now(),
|
||||
baselineUsage?: Partial<Record<DebridProvider, number>>
|
||||
): StatisticsLedger {
|
||||
return {
|
||||
...normalizeStatisticsLedger(ledger, epochMs),
|
||||
providerSeedSuppressedDay: getProviderUsageDayKey(epochMs),
|
||||
providerSeedBaselineBytes: baselineUsage === undefined
|
||||
? undefined
|
||||
: normalizeProviderSeedBaseline(baselineUsage) ?? {}
|
||||
};
|
||||
}
|
||||
|
||||
export function recordStatisticsBytes(
|
||||
|
||||
+349
-140
@@ -5,7 +5,7 @@ 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, ArchiveOperationMetric, AudioStripSummary, BandwidthScheduleEntry, DailyStartOutcome, DebridAccountStatus, DebridFallbackProvider, DebridProvider, DownloadItem, DownloadStatus, FailurePhase, HistoryEntry, HistoryRetentionMode, LogStorageLocation, PackageEntry, PackagePriority, RemuxOperationMetric, SessionState } from "../shared/types";
|
||||
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";
|
||||
@@ -34,7 +34,8 @@ 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_THEMES = new Set<AppTheme>(["dark", "light"]);
|
||||
const VALID_THEME_PREFERENCES = new Set<ThemePreference>(["dark", "light", "system"]);
|
||||
const VALID_EXTRACT_CPU_PRIORITIES = new Set(["high", "middle", "low"]);
|
||||
const VALID_HISTORY_RETENTION_MODES = new Set<HistoryRetentionMode>(["never", "session", "permanent"]);
|
||||
const VALID_LOG_STORAGE_LOCATIONS = new Set<LogStorageLocation>(["appdata", "desktop"]);
|
||||
@@ -424,6 +425,8 @@ function migrateUpdateRepo(raw: string, fallback: string): string {
|
||||
|
||||
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"
|
||||
@@ -633,7 +636,8 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
|
||||
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: VALID_THEMES.has(settings.theme) ? settings.theme : defaults.theme,
|
||||
theme,
|
||||
themePreference,
|
||||
bandwidthSchedules: normalizeBandwidthSchedules(settings.bandwidthSchedules),
|
||||
columnOrder: normalizeColumnOrder(settings.columnOrder, settings.columnOrderVersion),
|
||||
columnOrderVersion: 3,
|
||||
@@ -722,7 +726,7 @@ export interface StoragePaths {
|
||||
collectorFile: string;
|
||||
}
|
||||
|
||||
export function createStoragePaths(baseDir: string): StoragePaths {
|
||||
export function createStoragePaths(baseDir: string): StoragePaths {
|
||||
return {
|
||||
baseDir,
|
||||
configFile: path.join(baseDir, "rd_downloader_config.json"),
|
||||
@@ -734,8 +738,40 @@ export function createStoragePaths(baseDir: string): StoragePaths {
|
||||
collectorFile: path.join(baseDir, "rd_collector_state.json")
|
||||
};
|
||||
}
|
||||
|
||||
function ensureBaseDir(baseDir: string): void {
|
||||
|
||||
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) {
|
||||
@@ -890,6 +926,9 @@ function readSettingsFile(filePath: string): LoadedSettingsFile | null {
|
||||
...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")) {
|
||||
@@ -1295,6 +1334,7 @@ export function saveSettings(paths: StoragePaths, settings: AppSettings): void {
|
||||
let asyncSettingsSaveRunning = false;
|
||||
let asyncSettingsSaveQueued: { paths: StoragePaths; settings: AppSettings; generation: number } | null = null;
|
||||
let syncSettingsSaveGeneration = 0;
|
||||
let activeSettingsSave: Promise<void> | null = null;
|
||||
|
||||
async function writeSettingsPayload(paths: StoragePaths, settings: AppSettings, generation: number): Promise<void> {
|
||||
await fs.promises.mkdir(paths.baseDir, { recursive: true });
|
||||
@@ -1315,32 +1355,14 @@ async function writeSettingsPayload(paths: StoragePaths, settings: AppSettings,
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await fsp.rename(backupTempPath, `${paths.configFile}.bak`);
|
||||
} catch (renameError: unknown) {
|
||||
if (renameError && typeof renameError === "object" && "code" in renameError && (renameError as NodeJS.ErrnoException).code === "EXDEV") {
|
||||
await fsp.copyFile(backupTempPath, `${paths.configFile}.bak`);
|
||||
await fsp.rm(backupTempPath, { force: true }).catch(() => {});
|
||||
} else {
|
||||
await fsp.rm(backupTempPath, { force: true }).catch(() => {});
|
||||
throw renameError;
|
||||
}
|
||||
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;
|
||||
}
|
||||
try {
|
||||
await fsp.rename(tempPath, paths.configFile);
|
||||
} catch (renameError: unknown) {
|
||||
if (renameError && typeof renameError === "object" && "code" in renameError && (renameError as NodeJS.ErrnoException).code === "EXDEV") {
|
||||
if (generation < syncSettingsSaveGeneration) {
|
||||
await fsp.rm(tempPath, { force: true }).catch(() => {});
|
||||
return;
|
||||
}
|
||||
await fsp.copyFile(tempPath, paths.configFile);
|
||||
await fsp.rm(tempPath, { force: true }).catch(() => {});
|
||||
} else {
|
||||
await fsp.rm(tempPath, { force: true }).catch(() => {});
|
||||
throw renameError;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSettingsPayloadAsync(paths: StoragePaths, settings: AppSettings, generation: number): Promise<void> {
|
||||
if (asyncSettingsSaveRunning) {
|
||||
@@ -1348,23 +1370,30 @@ async function saveSettingsPayloadAsync(paths: StoragePaths, settings: AppSettin
|
||||
return;
|
||||
}
|
||||
asyncSettingsSaveRunning = true;
|
||||
try {
|
||||
await writeSettingsPayload(paths, settings, generation);
|
||||
} catch (error) {
|
||||
logger.error(`Async Settings-Save fehlgeschlagen: ${String(error)}`);
|
||||
} finally {
|
||||
asyncSettingsSaveRunning = false;
|
||||
if (asyncSettingsSaveQueued) {
|
||||
const operation = writeSettingsPayload(paths, settings, generation).catch((error) => {
|
||||
logger.error(`Async Settings-Save fehlgeschlagen: ${String(error)}`);
|
||||
}).finally(() => {
|
||||
asyncSettingsSaveRunning = false;
|
||||
if (activeSettingsSave === operation) {
|
||||
activeSettingsSave = null;
|
||||
}
|
||||
if (asyncSettingsSaveQueued) {
|
||||
const queued = asyncSettingsSaveQueued;
|
||||
asyncSettingsSaveQueued = null;
|
||||
void saveSettingsPayloadAsync(queued.paths, queued.settings, queued.generation);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
});
|
||||
activeSettingsSave = operation;
|
||||
await operation;
|
||||
}
|
||||
|
||||
export async function saveSettingsAsync(paths: StoragePaths, settings: AppSettings): Promise<void> {
|
||||
const captured = captureSettings(settings);
|
||||
if (activePersistenceBarrier) {
|
||||
return blockSettingsSave(activePersistenceBarrier, paths, captured);
|
||||
}
|
||||
const generation = syncSettingsSaveGeneration;
|
||||
await saveSettingsPayloadAsync(paths, captureSettings(settings), generation);
|
||||
await saveSettingsPayloadAsync(paths, captured, generation);
|
||||
}
|
||||
|
||||
export function emptySession(): SessionState {
|
||||
@@ -1503,61 +1532,177 @@ export function saveSession(paths: StoragePaths, session: SessionState): void {
|
||||
}
|
||||
}
|
||||
|
||||
let asyncSaveRunning = false;
|
||||
let asyncSaveQueued: { paths: StoragePaths; payload: string; generation: number } | null = null;
|
||||
let syncSaveGeneration = 0;
|
||||
let asyncSaveRunning = false;
|
||||
let asyncSaveQueued: { paths: StoragePaths; payload: string; generation: number } | null = null;
|
||||
let syncSaveGeneration = 0;
|
||||
let activeSessionSave: Promise<void> | null = null;
|
||||
|
||||
async function writeSessionPayload(paths: StoragePaths, payload: string, generation: number): Promise<void> {
|
||||
await fs.promises.mkdir(paths.baseDir, { recursive: true });
|
||||
await fsp.copyFile(paths.sessionFile, sessionBackupPath(paths.sessionFile)).catch(() => {});
|
||||
const tempPath = sessionTempPath(paths.sessionFile, "async");
|
||||
const handle = await fsp.open(tempPath, "w");
|
||||
async function writeSessionPayload(paths: StoragePaths, payload: string, generation: number): Promise<void> {
|
||||
await fs.promises.mkdir(paths.baseDir, { recursive: true });
|
||||
const tempPath = sessionTempPath(paths.sessionFile, "async");
|
||||
const backupTempPath = `${paths.sessionFile}.async.backup.tmp`;
|
||||
const handle = await fsp.open(tempPath, "w");
|
||||
try {
|
||||
await handle.writeFile(payload, "utf8");
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
if (generation < syncSaveGeneration) {
|
||||
await fsp.rm(tempPath, { force: true }).catch(() => {});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await fsp.rename(tempPath, paths.sessionFile);
|
||||
} catch (renameError: unknown) {
|
||||
if (renameError && typeof renameError === "object" && "code" in renameError && (renameError as NodeJS.ErrnoException).code === "EXDEV") {
|
||||
if (generation < syncSaveGeneration) {
|
||||
await fsp.rm(tempPath, { force: true }).catch(() => {});
|
||||
return;
|
||||
}
|
||||
await fsp.copyFile(tempPath, paths.sessionFile);
|
||||
await fsp.rm(tempPath, { force: true }).catch(() => {});
|
||||
} else {
|
||||
await fsp.rm(tempPath, { force: true }).catch(() => {});
|
||||
throw renameError;
|
||||
}
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSessionPayloadAsync(paths: StoragePaths, payload: string, generation: number): Promise<void> {
|
||||
if (asyncSaveRunning) {
|
||||
asyncSaveQueued = { paths, payload, generation };
|
||||
return;
|
||||
}
|
||||
asyncSaveRunning = true;
|
||||
try {
|
||||
await writeSessionPayload(paths, payload, generation);
|
||||
} catch (error) {
|
||||
logger.error(`Async Session-Save fehlgeschlagen: ${String(error)}`);
|
||||
} finally {
|
||||
asyncSaveRunning = false;
|
||||
if (asyncSaveQueued) {
|
||||
const queued = asyncSaveQueued;
|
||||
asyncSaveQueued = null;
|
||||
void saveSessionPayloadAsync(queued.paths, queued.payload, queued.generation);
|
||||
}
|
||||
}
|
||||
}
|
||||
async function saveSessionPayloadAsync(paths: StoragePaths, payload: string, generation: number): Promise<void> {
|
||||
if (asyncSaveRunning) {
|
||||
asyncSaveQueued = { paths, payload, generation };
|
||||
return;
|
||||
}
|
||||
asyncSaveRunning = true;
|
||||
const operation = writeSessionPayload(paths, payload, generation).catch((error) => {
|
||||
logger.error(`Async Session-Save fehlgeschlagen: ${String(error)}`);
|
||||
}).finally(() => {
|
||||
asyncSaveRunning = false;
|
||||
if (activeSessionSave === operation) {
|
||||
activeSessionSave = null;
|
||||
}
|
||||
if (asyncSaveQueued) {
|
||||
const queued = asyncSaveQueued;
|
||||
asyncSaveQueued = null;
|
||||
void saveSessionPayloadAsync(queued.paths, queued.payload, queued.generation);
|
||||
}
|
||||
});
|
||||
activeSessionSave = operation;
|
||||
await operation;
|
||||
}
|
||||
|
||||
interface BlockedSettingsSave {
|
||||
paths: StoragePaths;
|
||||
settings: AppSettings;
|
||||
waiters: Array<{ resolve: () => void; reject: (error: unknown) => void }>;
|
||||
}
|
||||
|
||||
interface BlockedSessionSave {
|
||||
paths: StoragePaths;
|
||||
payload: string;
|
||||
waiters: Array<{ resolve: () => void; reject: (error: unknown) => void }>;
|
||||
}
|
||||
|
||||
interface PersistenceBarrierState {
|
||||
blockedSettings: BlockedSettingsSave | null;
|
||||
blockedSession: BlockedSessionSave | null;
|
||||
released: Promise<void>;
|
||||
resolveReleased: () => void;
|
||||
}
|
||||
|
||||
export interface PersistenceBarrier {
|
||||
release: (options: { replayBlocked: boolean }) => Promise<void>;
|
||||
}
|
||||
|
||||
let activePersistenceBarrier: PersistenceBarrierState | null = null;
|
||||
|
||||
function blockSettingsSave(state: PersistenceBarrierState, paths: StoragePaths, settings: AppSettings): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const waiter = { resolve, reject };
|
||||
if (state.blockedSettings) {
|
||||
state.blockedSettings.paths = paths;
|
||||
state.blockedSettings.settings = settings;
|
||||
state.blockedSettings.waiters.push(waiter);
|
||||
} else {
|
||||
state.blockedSettings = { paths, settings, waiters: [waiter] };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function blockSessionSave(state: PersistenceBarrierState, paths: StoragePaths, payload: string): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const waiter = { resolve, reject };
|
||||
if (state.blockedSession) {
|
||||
state.blockedSession.paths = paths;
|
||||
state.blockedSession.payload = payload;
|
||||
state.blockedSession.waiters.push(waiter);
|
||||
} else {
|
||||
state.blockedSession = { paths, payload, waiters: [waiter] };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function replayBlockedSaves(state: PersistenceBarrierState): Promise<void> {
|
||||
while (state.blockedSettings || state.blockedSession) {
|
||||
const blockedSettings = state.blockedSettings;
|
||||
const blockedSession = state.blockedSession;
|
||||
state.blockedSettings = null;
|
||||
state.blockedSession = null;
|
||||
try {
|
||||
await Promise.all([
|
||||
blockedSettings
|
||||
? saveSettingsPayloadAsync(blockedSettings.paths, blockedSettings.settings, syncSettingsSaveGeneration)
|
||||
: Promise.resolve(),
|
||||
blockedSession
|
||||
? saveSessionPayloadAsync(blockedSession.paths, blockedSession.payload, syncSaveGeneration)
|
||||
: Promise.resolve()
|
||||
]);
|
||||
blockedSettings?.waiters.forEach((waiter) => waiter.resolve());
|
||||
blockedSession?.waiters.forEach((waiter) => waiter.resolve());
|
||||
} catch (error) {
|
||||
blockedSettings?.waiters.forEach((waiter) => waiter.reject(error));
|
||||
blockedSession?.waiters.forEach((waiter) => waiter.reject(error));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function acquirePersistenceBarrier(): Promise<PersistenceBarrier> {
|
||||
while (activePersistenceBarrier) {
|
||||
await activePersistenceBarrier.released;
|
||||
}
|
||||
let resolveReleased = () => {};
|
||||
const released = new Promise<void>((resolve) => { resolveReleased = resolve; });
|
||||
const state: PersistenceBarrierState = {
|
||||
blockedSettings: null,
|
||||
blockedSession: null,
|
||||
released,
|
||||
resolveReleased
|
||||
};
|
||||
activePersistenceBarrier = state;
|
||||
cancelPendingAsyncSaves();
|
||||
await Promise.all([activeSettingsSave ?? Promise.resolve(), activeSessionSave ?? Promise.resolve()]);
|
||||
let finished = false;
|
||||
return {
|
||||
release: async ({ replayBlocked }) => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
try {
|
||||
if (replayBlocked) {
|
||||
await replayBlockedSaves(state);
|
||||
} else {
|
||||
state.blockedSettings?.waiters.forEach((waiter) => waiter.resolve());
|
||||
state.blockedSession?.waiters.forEach((waiter) => waiter.resolve());
|
||||
}
|
||||
} finally {
|
||||
if (activePersistenceBarrier === state) {
|
||||
activePersistenceBarrier = null;
|
||||
}
|
||||
state.resolveReleased();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function cancelPendingAsyncSaves(): void {
|
||||
asyncSaveQueued = null;
|
||||
@@ -1566,11 +1711,14 @@ export function cancelPendingAsyncSaves(): void {
|
||||
syncSettingsSaveGeneration += 1;
|
||||
}
|
||||
|
||||
export async function saveSessionAsync(paths: StoragePaths, session: SessionState): Promise<void> {
|
||||
const generation = syncSaveGeneration;
|
||||
const payload = JSON.stringify({ ...session, updatedAt: Date.now() }, safeJsonReplacer);
|
||||
await saveSessionPayloadAsync(paths, payload, generation);
|
||||
}
|
||||
export async function saveSessionAsync(paths: StoragePaths, session: SessionState): Promise<void> {
|
||||
const payload = JSON.stringify({ ...session, updatedAt: Date.now() }, safeJsonReplacer);
|
||||
if (activePersistenceBarrier) {
|
||||
return blockSessionSave(activePersistenceBarrier, paths, payload);
|
||||
}
|
||||
const generation = syncSaveGeneration;
|
||||
await saveSessionPayloadAsync(paths, payload, generation);
|
||||
}
|
||||
|
||||
const MAX_HISTORY_ENTRIES = 500;
|
||||
const HISTORY_HARD_CAP = 100000;
|
||||
@@ -1647,42 +1795,106 @@ export function normalizeHistoryEntry(raw: unknown, index: number): HistoryEntry
|
||||
};
|
||||
}
|
||||
|
||||
export function loadHistory(paths: StoragePaths, limits?: HistoryLimits): HistoryEntry[] {
|
||||
ensureBaseDir(paths.baseDir);
|
||||
if (!fs.existsSync(paths.historyFile)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(paths.historyFile, "utf8")) as unknown;
|
||||
if (!Array.isArray(raw)) return [];
|
||||
|
||||
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 pruneHistoryEntries(entries, limits);
|
||||
} catch {
|
||||
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 tempPath = `${paths.historyFile}.tmp`;
|
||||
try {
|
||||
fs.writeFileSync(tempPath, payload, "utf8");
|
||||
syncRenameWithExdevFallback(tempPath, paths.historyFile);
|
||||
} catch (error) {
|
||||
try { fs.rmSync(tempPath, { force: true }); } catch { }
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function addHistoryEntry(paths: StoragePaths, entry: HistoryEntry, limits?: HistoryLimits): HistoryEntry[] {
|
||||
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);
|
||||
@@ -1724,12 +1936,9 @@ export function removeHistoryEntry(paths: StoragePaths, entryId: string, limits?
|
||||
return removeHistoryEntries(paths, [entryId], limits);
|
||||
}
|
||||
|
||||
export function clearHistory(paths: StoragePaths): void {
|
||||
ensureBaseDir(paths.baseDir);
|
||||
if (fs.existsSync(paths.historyFile)) {
|
||||
try {
|
||||
fs.unlinkSync(paths.historyFile);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
}
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user