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:
Sucukdeluxe
2026-08-26 20:35:06 +02:00
parent bdcf9e3754
commit 2ea494cd5c
69 changed files with 7660 additions and 883 deletions
+51
View File
@@ -4,6 +4,57 @@ All notable changes to Multi-Debrid Downloader are documented in this file.
## [Unreleased]
## [2.0.74] - 2026-08-26
### Package-based link collector
- Restore the package-based collector with expandable packages, file metadata, exact sizes, hoster identity, progressive availability results, search, filtering, selection, removal, and explicit transfer to Downloads.
- Keep DLC files dropped on the Downloads tab on the direct import path while reserving collector analysis for imports made inside the collector.
- Persist packages, metadata, and collapsed state across restarts with primary/backup recovery, a synchronous shutdown handoff, and deterministic merging when the window closes before initial hydration finishes.
- Collapse newly imported packages by default while preserving manual disclosure state across duplicate imports, metadata regrouping, and restarts.
- Add visible select-all, mixed checkbox state, Ctrl+A, Escape, offline-link removal, queue-export JSON round trips, and complete German and English collector text.
- Validate cumulative package, link, field-length, and 64 MiB persistence limits before mutating the interface, including metadata enrichment and late hydration.
- Roll back the visible collector state after persistence failures, prune stale selection and disclosure state, and prevent rejected saves from being reported as successful.
- Virtualize large package lists and disclosure transitions, cache stable regions, and reduce 20,000-link persistence updates to link-level deltas without rebuilding or serializing the complete collector.
### Download queue and controls
- Keep hidden extracted entries out of sidebar and queue totals without changing their package download size, completion count, or progress aggregates.
- Distinguish a genuinely empty queue, a hidden-only queue, and an empty filter result, with matching guidance and correctly disabled disclosure actions.
- Calculate remaining volume and ETA from the current run only, excluding unselected or disabled queue items while retaining explicit unknown-size handling.
- Normalize stale provider filters, package lifecycle states, mixed extraction results, and locale-formatted sidebar counters.
- Make toolbar and context-menu starts obey the same account, cooldown, busy, and action gates and report blocked or rejected starts instead of silently doing nothing.
- Serialize pause and resume actions so rapid clicks produce one state transition and the renderer changes only after the main process confirms the result.
- Add monotonic snapshot revisions so delayed initial snapshots, retries, full updates, and deltas cannot restore deleted rows or stale account and quota gates.
- Split the header speed graph and numeric value into stable-width modules so changing one-, two-, or three-digit speeds no longer shifts the graph.
### History and statistics
- Paginate and lazily project history rows so a 100,000-entry history no longer rebuilds and formats the complete dataset every second.
- Preserve selections across history pages, limit select-all and deselect-all to the current page, and keep context-menu actions from discarding selections on other pages.
- Correct Last 7 Days to include all available data from today and the previous six calendar days without requiring a complete seven-day history.
- Report the actual visible page count and clear stale rows, selection, expansion, and context state after a failed history load before performing a complete retry.
- Recover readable history backups without failing startup, replace imported history atomically, and roll retention changes back if settings persistence fails.
- Persist the start of rolling minute coverage so partial 24-hour datasets are described accurately rather than shown as complete.
- Keep statistics resets consistent across ledger and settings writes, rebase manual provider-usage resets, and recover post-reset provider bytes without double counting.
- Mark byte-only recovery days explicitly so unavailable file, error, speed, minute, and account details are not displayed as misleading zero values.
### Settings, themes, and language
- Store Light, Dark, and System as an authoritative theme preference in settings and backups while resolving System against the current Windows color scheme.
- Prevent delayed initial loading from exposing editable default settings or empty statistics and add a visible retry state for snapshot failures.
- Capture settings save intent before queued account operations so later edits remain unsaved instead of being overwritten by an older completed save.
- Complete German and English localization for collector, download, history, statistics, theme, count, unit, duration, and mixed-status text.
- Define the shared panel surface token for Dark, Light, and System themes and align standalone and shell sidebars consistently.
### Persistence and recovery
- Make full and settings-only backup imports transactional across settings, session, statistics, history, log storage, and remote diagnostics before changing runtime state.
- Wait for remote diagnostics restart and restore the previous files, runtime settings, log configuration, and server state when restart fails.
- Add a persistence barrier for imports and make asynchronous settings and session commits perform their final generation check and atomic rename without yielding, preventing older writes from overwriting sync saves, updates, or shutdown state.
- Preserve the current live session during settings-only imports before releasing blocked persistence work.
- Retry transient Windows rename failures in the Discord notification outbox for synchronous and asynchronous writes without preventing application startup.
## [2.0.73] - 2026-08-25
### Settings workflow
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "multi-debrid-downloader",
"version": "2.0.73",
"version": "2.0.74",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "multi-debrid-downloader",
"version": "2.0.73",
"version": "2.0.74",
"license": "MIT",
"dependencies": {
"adm-zip": "0.6.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "multi-debrid-downloader",
"version": "2.0.73",
"version": "2.0.74",
"description": "Desktop downloader",
"main": "build/main/main/main.js",
"author": "Sucukdeluxe",
+323 -112
View File
@@ -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)}`);
}
}
+73
View File
@@ -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);
}
+34 -15
View File
@@ -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 -3
View File
@@ -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;
+1
View File
@@ -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
View File
@@ -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();
+6 -5
View File
@@ -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
View File
@@ -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.
+56 -5
View File
@@ -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 });
+3
View File
@@ -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();
}
+1
View File
@@ -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,
+128 -2
View File
@@ -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
View File
@@ -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 });
}
}
+2 -1
View File
@@ -70,8 +70,9 @@ const api: ElectronApi = {
return () => ipcRenderer.removeListener(IPC_CHANNELS.COLLECTOR_ENRICHMENT_PROGRESS, listener);
},
getCollectorState: (): Promise<CollectorPersistenceState> => ipcRenderer.invoke(IPC_CHANNELS.GET_COLLECTOR_STATE),
getCollectorStateSync: (): CollectorPersistenceState | null => ipcRenderer.sendSync(IPC_CHANNELS.GET_COLLECTOR_STATE_SYNC) as CollectorPersistenceState | null,
saveCollectorState: (state: CollectorPersistenceState): Promise<CollectorPersistenceState> => ipcRenderer.invoke(IPC_CHANNELS.SAVE_COLLECTOR_STATE, state),
saveCollectorStateSync: (state: CollectorPersistenceState): void => { ipcRenderer.sendSync(IPC_CHANNELS.SAVE_COLLECTOR_STATE_SYNC, state); },
saveCollectorStateSync: (state: CollectorPersistenceState): boolean => ipcRenderer.sendSync(IPC_CHANNELS.SAVE_COLLECTOR_STATE_SYNC, state) === true,
getPathForDroppedFile: (file: File): string => webUtils.getPathForFile(file),
getStartConflicts: (): Promise<StartConflictEntry[]> => ipcRenderer.invoke(IPC_CHANNELS.GET_START_CONFLICTS),
resolveStartConflict: (packageId: string, policy: DuplicatePolicy): Promise<StartConflictResolutionResult> =>
+610 -206
View File
File diff suppressed because it is too large Load Diff
+14
View File
@@ -20,6 +20,20 @@ export function beginCollectorEnrichment(
return snapshot;
}
export function pruneCollectorEnrichmentGenerations(
current: Map<string, number>,
packages: CollectorPackage[],
activeSnapshots: Iterable<ReadonlyMap<string, number>>
): void {
const retainedUrls = new Set(packages.flatMap((pkg) => pkg.links.map((link) => collectorUrlKey(link.url))));
for (const snapshot of activeSnapshots) {
for (const url of snapshot.keys()) retainedUrls.add(url);
}
for (const url of current.keys()) {
if (!retainedUrls.has(url)) current.delete(url);
}
}
export function filterCurrentCollectorEnrichment(
packages: CollectorPackage[],
requested: CollectorEnrichmentGenerationSnapshot,
+165 -20
View File
@@ -8,7 +8,7 @@ const pairs = [
["Entpacken", "Extraction"], ["Geschwindigkeit", "Speed"], ["Bereinigung", "Cleanup"], ["Updates", "Updates"],
["Einstellungen speichern", "Save settings"], ["Änderungen verwerfen", "Discard changes"], ["Stellt den letzten gespeicherten Stand wieder her.", "Restores the last saved settings."], ["Ungespeicherte Änderungen verworfen", "Unsaved changes discarded"], ["Zwischenstand gespeichert weitere Änderungen sind ungespeichert", "Progress saved additional changes remain unsaved"], ["Gespeichert", "Saved"], ["Ungespeicherte Änderungen", "Unsaved changes"], ["Wird gespeichert…", "Saving…"], ["Speichern fehlgeschlagen", "Save failed"],
["Sprache", "Language"], ["Speicherort", "Storage location"], ["Download-Verhalten", "Download behavior"], ["Oberfläche und Bedienung", "Interface and controls"], ["Discord-Benachrichtigungen", "Discord notifications"],
["Speicherort, Download-Verhalten, Verlauf, Oberfläche und Benachrichtigungen.", "Storage location, download behavior, history, interface and notifications."],
["Speicherort, Download-Verhalten, Verlauf, Oberfläche und Benachrichtigungen.", "Storage location, download behavior, history, interface and notifications."], ["Hell", "Light"], ["Dunkel", "Dark"],
["Download-Ordner", "Download folder"], ["Paketname (optional)", "Package name (optional)"], ["Max. gleichzeitige Downloads", "Max. concurrent downloads"], ["Automatische Wiederholungen", "Automatic retries"],
["Zielordner für heruntergeladene Dateien.", "Destination folder for downloaded files."],
["Beim Start automatisch fortsetzen", "Resume automatically on startup"], ["Zwischenablage überwachen", "Monitor clipboard"], ["Verlauf speichern", "Save history"], ["Nur aktuelle Session", "Current session only"], ["Nur letzte 100 Einträge", "Last 100 entries only"], ["Nur letzte 250 Einträge", "Last 250 entries only"], ["Dauerhaft", "Permanent"],
@@ -62,17 +62,31 @@ const pairs = [
["Verlaufstabelle", "History table"], ["Verlaufsseiten", "History pages"], ["Vorherige Verlaufsseite", "Previous history page"], ["Nächste Verlaufsseite", "Next history page"], ["Zurück", "Back"], ["Vor", "Next"], ["Verlauf wird geladen", "Loading history"], ["Die gespeicherten Einträge werden geladen.", "Saved entries are being loaded."], ["Verlauf wird geladen. Die gespeicherten Einträge werden geladen.", "History is loading. Saved entries are being loaded."], ["Noch kein Verlauf", "No history yet"], ["Keine passenden Einträge", "No matching entries"],
["Abgeschlossene und gelöschte Pakete erscheinen hier.", "Completed and deleted packages appear here."], ["Passe Filter oder Suche an.", "Adjust the filter or search."], ["Öffne die Ansicht erneut, um es noch einmal zu versuchen.", "Open the view again to retry."],
["Alle sichtbaren Einträge auswählen", "Select all visible entries"], ["Details anzeigen", "Show details"], ["Details ausblenden", "Hide details"],
["Download gestartet", "Download started"], ["Download beendet", "Download finished"], ["Nachbearbeitung gestartet", "Post-processing started"], ["Gesamtdauer", "Total duration"],
["Erfolgreich / Fehlgeschlagen / Abgebrochen", "Successful / Failed / Cancelled"], ["Archive / Parts / Ausgaben", "Archives / parts / outputs"], ["Fehlerphase", "Failure phase"], ["Fehlerkategorie", "Error category"],
["Download / Offline / Entpacken / Remux / Cleanup / Nachbearbeitung", "Download / Offline / Extraction / Remux / Cleanup / Post-processing"], ["Downloaddauer (Altbestand)", "Download duration (legacy)"],
["Archivvorgänge", "Archive operations"], ["Remuxvorgänge", "Remux operations"], ["Keine Archivvorgänge", "No archive operations"], ["Keine Remuxvorgänge", "No remux operations"],
["Sichtbar:", "Visible:"], ["pro Seite", "per page"],
["Verfügbarkeit", "Availability"], ["Hinzugefügt am", "Added on"], ["Ungeprüft", "Unchecked"], ["Paket gestoppt", "Package stopped"], ["Alle anzeigen", "Show all"], ["Planen", "Schedule"], ["Startzeit", "Start time"], ["Starttag", "Start day"], ["Ab heute", "Starting today"], ["Ab morgen", "Starting tomorrow"], ["Bitte eine gültige Startzeit auswählen.", "Select a valid start time."],
["Keine Downloads", "No downloads"], ["Keine passenden Downloads", "No matching downloads"], ["Füge Links hinzu, um Downloads vorzubereiten.", "Add links to prepare downloads."], ["Passe Filter oder Suche an.", "Adjust the filter or search."],
["Entpackte Downloads sind ausgeblendet", "Extracted downloads are hidden"], ["Deaktiviere „Entpackte Einträge ausblenden“, um sie wieder anzuzeigen.", "Disable “Hide extracted entries” to show them again."], ["Downloads können derzeit nicht gestartet werden", "Downloads cannot be started right now"],
["Keine Links gesammelt", "No links collected"], ["Keine passenden Links", "No matching links"], ["Füge Links oder Text ein, um sie zu sammeln.", "Paste links or text to collect them."], ["Links durchsuchen", "Search links"],
["Datenmenge", "Data volume"], ["Sitzungszähler", "Session counter"], ["Sieben Tage", "Seven days"], ["30 Tage", "30 days"], ["Zeitraum", "Period"], ["Erfolgreich", "Successful"],
["Letzte 24 Stunden", "Last 24 hours"], ["Letzte sieben Tage", "Last seven days"], ["Letzte 30 Tage", "Last 30 days"], ["Seit Beginn der Statistikaufzeichnung", "Since statistics tracking began"],
["Account-Traffic der vergangenen 24 Stunden.", "Account traffic over the past 24 hours."], ["Letzte 24 Stunden: Werte seit Beginn der Aufzeichnung.", "Last 24 hours: values since tracking began."],
["Heutige Werte stammen aus der lokalen Statistikaufzeichnung.", "Today's values come from local statistics tracking."], ["Datenmenge und Dateien stammen aus den Gesamtzählern; Ergebnisse und Durchschnitt seit Beginn der Statistikaufzeichnung.", "Data volume and files come from the total counters; results and averages cover the period since statistics tracking began."],
["Dateien werden nicht minutengenau nach Account erfasst", "File counts are not tracked per account at minute precision"], ["Ergebnisse werden nicht minutengenau nach Account erfasst", "Results are not tracked per account at minute precision"],
["Aktive Downloadzeit wird nur tagesweise erfasst", "Active download time is tracked by day only"], ["Fehler werden nicht minutengenau nach Account erfasst", "Errors are not tracked per account at minute precision"],
["Noch keine aktive Downloadzeit mit übertragenen Daten erfasst", "No active download time with transferred data has been recorded yet"], ["In den vergangenen 24 Stunden wurde noch kein Account-Traffic erfasst.", "No account traffic has been recorded in the past 24 hours."], ["In diesem Zeitraum wurden noch keine Providerwerte erfasst.", "No provider values have been recorded for this period."],
["Sitzungszähler und Ergebnisse der aktuellen Queue werden angezeigt.", "Session counters and results for the current queue are shown."], ["Sitzung zurücksetzen", "Reset session"], ["Gesamt zurücksetzen", "Reset total"], ["Fehler zurücksetzen", "Reset errors"],
["Bandbreitenverlauf", "Bandwidth history"], ["Bandbreitenverlauf der letzten 60 Sekunden", "Bandwidth history for the last 60 seconds"], ["Provider", "Provider"], ["Daten", "Data"], ["Ergebnisse", "Results"],
["Nie", "Never"], ["Sofort", "Immediately"], ["Beim App-Start", "On app startup"], ["Sobald Paket fertig ist", "When package completes"], ["Überschreiben", "Overwrite"], ["Überspringen", "Skip"], ["Nachfragen", "Ask"],
["Abbrechen", "Cancel"], ["Speichern", "Save"], ["Schließen", "Close"], ["Löschen", "Delete"], ["Suchen", "Search"], ["Zurücksetzen", "Reset"], ["Testen", "Test"], ["Öffnen", "Open"],
["Noch keine Downloads", "No downloads yet"], ["Füge Links hinzu, um den ersten Download zu starten.", "Add links to start the first download."], ["Keine passenden Downloads", "No matching downloads"], ["Alle anzeigen", "Show all"],
["Neue Sammlung", "New collection"], ["Linksammler-Aktionen", "Link collector actions"], ["Links erfassen", "Capture links"], ["DLC importieren", "Import DLC"], ["Datei importieren", "Import file"],
["Neue Sammlung", "New collection"], ["Linksammler-Aktionen", "Link collector actions"], ["Linksammler-Filter", "Link collector filters"], ["Links erfassen", "Capture links"], ["DLC importieren", "Import DLC"], ["Datei importieren", "Import file"],
["Pakete:", "Packages:"], ["Links:", "Links:"], ["Downloads übergeben", "Transfer downloads"], ["Analyse läuft im Hintergrund", "Analysis is running in the background"], ["Name, URL oder Hoster", "Name, URL, or host"], ["Teilweise online", "Partially online"],
["Suche und Paketdarstellung", "Search and package display"], ["Gesammelte Downloadpakete", "Collected download packages"], ["Hinzugefügt", "Added"], ["Passe Suche oder Statusfilter an.", "Adjust the search or status filter."],
["Die ersten Links erscheinen sofort nach dem Import.", "The first links appear immediately after import."], ["Links werden vorbereitet", "Links are being prepared"], ["Füge Links hinzu, um Pakete vor dem Download zu prüfen.", "Add links to check packages before downloading."], ["Links erscheinen sofort und werden anschließend im Hintergrund geprüft.", "Links appear immediately and are then checked in the background."], ["Eine URL pro Zeile", "One URL per line"], ["Keine übertragbaren Links ausgewählt", "No transferable links selected"], ["Alle sichtbaren Links auswählen", "Select all visible links"], ["Der Linksammler ist zu groß, um gespeichert zu werden.", "The link collector is too large to save."], ["Der Queue-Export ist ungültig.", "The queue export is invalid."], ["Der Queue-Export enthält ungültiges JSON.", "The queue export contains invalid JSON."], ["Linksammler-Payload ist ungültig", "Link collector payload is invalid"], ["Linksammler-Anreicherung ist ungültig", "Link collector enrichment is invalid"], ["Linksammler-Speicherzustand ist ungültig", "Link collector persistence state is invalid"],
["Sammlung verarbeiten", "Process collection"], ["Queue exportieren", "Export queue"], ["An Downloads übergeben", "Send to downloads"], ["Auswahl entfernen", "Remove selection"], ["Ausgewählte Links löschen", "Delete selected links"], ["Links löschen", "Delete links"], ["Die ausgewählten Links werden aus der Sammlung entfernt. Dieser Schritt kann nicht rückgängig gemacht werden.", "The selected links will be removed from the collection. This action cannot be undone."], ["Gesammelte Links", "Collected links"],
["Auswahl", "Selection"], ["Links werden verarbeitet", "Processing links"], ["Die laufende Aktion wird abgeschlossen.", "The current action is being completed."], ["Die lokale Sammlung bleibt unverändert.", "The local collection remains unchanged."],
["Passe die Suche an oder lösche den Filter.", "Adjust the search or clear the filter."], ["Füge Links hinzu oder importiere eine vorhandene Liste.", "Add links or import an existing list."], ["Keine passenden Links", "No matching links"], ["Noch keine Links", "No links yet"],
@@ -211,19 +225,104 @@ const prefixedPairs = [
["Sicherung laden fehlgeschlagen: ", "Loading backup failed: "], ["Support-Bundle fehlgeschlagen: ", "Support bundle failed: "], ["Support-Trace fehlgeschlagen: ", "Support trace failed: "],
["Debug-Setup-Check fehlgeschlagen: ", "Debug setup check failed: "], ["Fehler-Ansicht fehlgeschlagen: ", "Error view failed: "], ["Token-Rotation fehlgeschlagen: ", "Token rotation failed: "],
["Ferndiagnose-Status fehlgeschlagen: ", "Remote diagnostics status failed: "], ["Aktivieren fehlgeschlagen: ", "Enabling failed: "], ["Deaktivieren fehlgeschlagen: ", "Disabling failed: "],
["Session-Reset fehlgeschlagen: ", "Session reset failed: "], ["Download-Reset fehlgeschlagen: ", "Download reset failed: "], ["Zeitplan konnte nicht aktiviert werden: ", "Schedule could not be activated: "], ["Zeitplan konnte nicht abgebrochen werden: ", "Schedule could not be cancelled: "], ["Zeitplan konnte nicht abgeglichen werden: ", "Schedule could not be reconciled: "]
["Session-Reset fehlgeschlagen: ", "Session reset failed: "], ["Download-Reset fehlgeschlagen: ", "Download reset failed: "], ["Zeitplan konnte nicht aktiviert werden: ", "Schedule could not be activated: "], ["Zeitplan konnte nicht abgebrochen werden: ", "Schedule could not be cancelled: "], ["Zeitplan konnte nicht abgeglichen werden: ", "Schedule could not be reconciled: "],
["Metadatenprüfung fehlgeschlagen: ", "Metadata check failed: "], ["Linksammler konnte nicht gespeichert werden: ", "Link collector could not be saved: "], ["Linksammler konnte nicht wiederhergestellt werden: ", "Link collector could not be restored: "], ["Links konnten nicht vorbereitet werden: ", "Links could not be prepared: "], ["Übergabe fehlgeschlagen: ", "Transfer failed: "],
["Start fehlgeschlagen: ", "Start failed: "], ["Pause fehlgeschlagen: ", "Pause failed: "]
] as const;
export function normalizeLanguage(value: unknown): AppLanguage {
return value === "de" ? "de" : "en";
}
function germanNumberToEnglish(value: string): string {
const [integer, fraction] = value.split(",");
const grouped = integer.replace(/\./g, "").replace(/\B(?=(\d{3})+(?!\d))/g, ",");
return fraction === undefined ? grouped : `${grouped}.${fraction}`;
}
function englishNumberToGerman(value: string): string {
const [integer, fraction] = value.split(".");
const grouped = integer.replace(/,/g, "").replace(/\B(?=(\d{3})+(?!\d))/g, ".");
return fraction === undefined ? grouped : `${grouped},${fraction}`;
}
function formatGermanInteger(value: string): string {
return value.replace(/[.,]/g, "").replace(/\B(?=(\d{3})+(?!\d))/g, ".");
}
function translateDynamic(value: string, language: AppLanguage): string {
if (value.startsWith("Error: ")) return `Error: ${translateUiText(value.slice(7), language)}`;
if (language === "en") {
const statisticsStatus = value.match(/^(Daten|Dateien|Erfolg|Fehler|Provider|Accounts): ((?:\d{1,3}(?:\.\d{3})+|\d+)(?:,\d+)?(?: (?:B|KB|MB|GB|TB|PB)(?:\/s)?| %)?$)/);
if (statisticsStatus) {
const labels: Record<string, string> = { Daten: "Data", Dateien: "Files", Erfolg: "Success", Fehler: "Errors", Provider: "Providers", Accounts: "Accounts" };
return `${labels[statisticsStatus[1]]}: ${translateUiText(statisticsStatus[2], language)}`;
}
} else {
const statisticsStatus = value.match(/^(Data|Files|Success|Errors|Providers|Accounts): ((?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?(?: (?:B|KB|MB|GB|TB|PB)(?:\/s)?| %)?$)/);
if (statisticsStatus) {
const labels: Record<string, string> = { Data: "Daten", Files: "Dateien", Success: "Erfolg", Errors: "Fehler", Providers: "Provider", Accounts: "Accounts" };
return `${labels[statisticsStatus[1]]}: ${translateUiText(statisticsStatus[2], language)}`;
}
const germanCountAction = value.match(/^(Ausgewählte Downloads starten|Ausgewählte Pakete exportieren|Ausgewählte Dateien exportieren|Ausgewählte Dateien entfernen|Zurücksetzen|überspringen|Ausgewählte entfernen) \(([\d.,]+)\)$/);
if (germanCountAction) return `${germanCountAction[1]} (${formatGermanInteger(germanCountAction[2])})`;
const germanToggleAll = value.match(/^Alle ([\d.,]+) umschalten$/);
if (germanToggleAll) return `Alle ${formatGermanInteger(germanToggleAll[1])} umschalten`;
}
for (const [german, english] of prefixedPairs) {
const source = language === "en" ? german : english;
if (value.startsWith(source)) return `${language === "en" ? english : german}${value.slice(source.length)}`;
if (value.startsWith(source)) {
const suffix = value.slice(source.length);
return `${language === "en" ? english : german}${suffix ? translateUiText(suffix, language) : ""}`;
}
}
if (language === "en") {
const groupedInteger = value.match(/^\d{1,3}(?:\.\d{3})+$/);
if (groupedInteger) return value.replace(/\./g, ",");
const englishMeasurement = value.match(/^(?:\d{1,3}(?:,\d{3})+|\d{1,3})(?:\.\d{1,2})? (?:B|KB|MB|GB|TB|PB)(?:\/s)?$/);
if (englishMeasurement) return value;
const englishPercentage = value.match(/^(?:\d{1,3}(?:,\d{3})+|\d{1,3})(?:\.\d{1,2})? %$/);
if (englishPercentage) return value;
const measurement = value.match(/^((?:\d{1,3}(?:\.\d{3})+|\d+)(?:,\d+)?) (B|KB|MB|GB|TB|PB)(\/s)?$/);
if (measurement) return `${germanNumberToEnglish(measurement[1])} ${measurement[2]}${measurement[3] ?? ""}`;
const percentage = value.match(/^((?:\d{1,3}(?:\.\d{3})+|\d+)(?:,\d+)?) %$/);
if (percentage) return `${germanNumberToEnglish(percentage[1])} %`;
const recordedDays = value.match(/^(Letzte sieben Tage|Letzte 30 Tage): (\d+) (erfasster Tag wird|erfasste Tage werden) bis heute zusammengefasst\.$/);
if (recordedDays) return `${recordedDays[1] === "Letzte sieben Tage" ? "Last seven days" : "Last 30 days"}: ${recordedDays[2]} recorded ${recordedDays[3] === "erfasster Tag wird" ? "day is" : "days are"} summarized through today.`;
const statisticsOutcome = value.match(/^([\d.,]+) fertig · ([\d.,]+) Fehler$/);
if (statisticsOutcome) return `${germanNumberToEnglish(statisticsOutcome[1])} completed · ${germanNumberToEnglish(statisticsOutcome[2])} errors`;
const historyParts = value.match(/^([\d.,]+) Parts$/);
if (historyParts) return `${germanNumberToEnglish(historyParts[1])} parts`;
const selectedDownloadsStart = value.match(/^Ausgewählte Downloads starten \(([\d.,]+)\)$/);
if (selectedDownloadsStart) return `Start selected downloads (${germanNumberToEnglish(selectedDownloadsStart[1])})`;
const selectedFilesRemove = value.match(/^Ausgewählte Dateien entfernen \(([\d.,]+)\)$/);
if (selectedFilesRemove) return `Remove selected files (${germanNumberToEnglish(selectedFilesRemove[1])})`;
const resetCount = value.match(/^Zurücksetzen \(([\d.,]+)\)$/);
if (resetCount) return `Reset (${germanNumberToEnglish(resetCount[1])})`;
const skipCount = value.match(/^überspringen \(([\d.,]+)\)$/);
if (skipCount) return `skip (${germanNumberToEnglish(skipCount[1])})`;
const collectorSelected = value.match(/^Auswahl übergeben \(([\d.,]+)\)$/);
if (collectorSelected) return `Send selection (${collectorSelected[1].replace(/\./g, ",")})`;
const collectorAll = value.match(/^Alle übergeben \(([\d.,]+)\)$/);
if (collectorAll) return `Send all (${collectorAll[1].replace(/\./g, ",")})`;
const collectorPackage = value.match(/^Paket (.+) auswählen$/);
if (collectorPackage) return `Select package ${collectorPackage[1]}`;
const collectorFiles = value.match(/^([\d.,]+) Dateien$/);
if (collectorFiles) return `${collectorFiles[1].replace(/\./g, ",")} files`;
const collectorAvailability = value.match(/^([\d.,]+)\/([\d.,]+) online$/);
if (collectorAvailability) return `${collectorAvailability[1].replace(/\./g, ",")}/${collectorAvailability[2].replace(/\./g, ",")} online`;
const collectorCapacity = value.match(/^Der Linksammler kann höchstens ([\d.,]+) (Pakete|Links) enthalten\.$/);
if (collectorCapacity) return `The link collector can contain at most ${collectorCapacity[1].replace(/\./g, ",")} ${collectorCapacity[2] === "Pakete" ? "packages" : "links"}.`;
const collectorImportCapacity = value.match(/^Der Queue-Export überschreitet das Collector-Limit von ([\d.,]+) (Paketen|Links)\.$/);
if (collectorImportCapacity) return `The queue export exceeds the link collector limit of ${collectorImportCapacity[1].replace(/\./g, ",")} ${collectorImportCapacity[2] === "Paketen" ? "packages" : "links"}.`;
const collectorCollected = value.match(/^([\d.,]+) Paket\(e\), ([\d.,]+) Link\(s\) gesammelt$/);
if (collectorCollected) return `${collectorCollected[1].replace(/\./g, ",")} package(s), ${collectorCollected[2].replace(/\./g, ",")} link(s) collected`;
const collectorDlcCollected = value.match(/^DLC gesammelt: ([\d.,]+) Paket\(e\), ([\d.,]+) Link\(s\)$/);
if (collectorDlcCollected) return `DLC collected: ${collectorDlcCollected[1].replace(/\./g, ",")} package(s), ${collectorDlcCollected[2].replace(/\./g, ",")} link(s)`;
const collectorPartialTransfer = value.match(/^([\d.,]+) von ([\d.,]+) Link\(s\) übergeben; Sammlung bleibt erhalten$/);
if (collectorPartialTransfer) return `${collectorPartialTransfer[1].replace(/\./g, ",")} of ${collectorPartialTransfer[2].replace(/\./g, ",")} link(s) transferred; collection remains`;
const collectorTransferred = value.match(/^([\d.,]+) Paket\(e\), ([\d.,]+) Link\(s\) übergeben$/);
if (collectorTransferred) return `${collectorTransferred[1].replace(/\./g, ",")} package(s), ${collectorTransferred[2].replace(/\./g, ",")} link(s) transferred`;
const update = value.match(/^(.+) ist verfügbar\. Installierte Version: (.+)\.$/);
if (update) return `${update[1]} is available. Installed version: ${update[2]}.`;
const pagination = value.match(/^([\d.,]+\s*[-]\s*[\d.,]+) von ([\d.,]+)$/);
@@ -234,8 +333,8 @@ function translateDynamic(value: string, language: AppLanguage): string {
if (scheduled) return `Scheduled: ${scheduled[1].replace(/^Heute\b/, "Today")}`;
const cancelled = value.match(/^(\d+) abgebrochen$/);
if (cancelled) return `${cancelled[1]} cancelled`;
const labelledCount = value.match(/^(Einträge|Sichtbar|Ausgewählt): (\d+)$/);
if (labelledCount) return `${({ Einträge: "Entries", Sichtbar: "Visible", Ausgewählt: "Selected" } as const)[labelledCount[1] as "Einträge" | "Sichtbar" | "Ausgewählt"]}: ${labelledCount[2]}`;
const labelledCount = value.match(/^(Pakete|Links|Einträge|Sichtbar|Ausgewählt): ([\d.,]+)$/);
if (labelledCount) return `${({ Pakete: "Packages", Links: "Links", Einträge: "Entries", Sichtbar: "Visible", Ausgewählt: "Selected" } as Record<string, string>)[labelledCount[1]]}: ${labelledCount[2].replace(/\./g, ",")}`;
const perPage = value.match(/^(\d+) pro Seite$/);
if (perPage) return `${perPage[1]} per page`;
const pageStatus = value.match(/^Seite ([\d.,\s]+) von ([\d.,\s]+)$/);
@@ -364,12 +463,12 @@ function translateDynamic(value: string, language: AppLanguage): string {
if (packageCount) return `${packageCount[1]} package(s)`;
const linkCount = value.match(/^(\d+) Link\(s\)$/);
if (linkCount) return `${linkCount[1]} link(s)`;
const exportSelected = value.match(/^Ausgewählte (Pakete|Dateien) exportieren \((\d+)\)$/);
if (exportSelected) return `Export selected ${exportSelected[1] === "Pakete" ? "packages" : "files"} (${exportSelected[2]})`;
const toggleAll = value.match(/^Alle (.+) umschalten$/);
if (toggleAll) return `Toggle all ${toggleAll[1]}`;
const removeSelected = value.match(/^Ausgewählte entfernen \((\d+)\)$/);
if (removeSelected) return `Remove selected (${removeSelected[1]})`;
const exportSelected = value.match(/^Ausgewählte (Pakete|Dateien) exportieren \(([\d.,]+)\)$/);
if (exportSelected) return `Export selected ${exportSelected[1] === "Pakete" ? "packages" : "files"} (${germanNumberToEnglish(exportSelected[2])})`;
const toggleAll = value.match(/^Alle ([\d.,]+) umschalten$/);
if (toggleAll) return `Toggle all ${germanNumberToEnglish(toggleAll[1])}`;
const removeSelected = value.match(/^Ausgewählte entfernen \(([\d.,]+)\)$/);
if (removeSelected) return `Remove selected (${germanNumberToEnglish(removeSelected[1])})`;
const removeCollection = value.match(/^Soll die Sammlung (.+) mit (\d+) Link\(s\) wirklich entfernt werden\?$/);
if (removeCollection) return `Do you really want to remove collection ${removeCollection[1]} with ${removeCollection[2]} link(s)?`;
const removeEmptyCollection = value.match(/^Soll die leere Sammlung (.+) wirklich entfernt werden\?$/);
@@ -393,6 +492,52 @@ function translateDynamic(value: string, language: AppLanguage): string {
.replace(/Fehlgeschlagen nach (\d+) Versuchen/g, "Failed after $1 attempts")
.replace(/(\d+) Fehler/g, "$1 errors");
} else {
const groupedInteger = value.match(/^\d{1,3}(?:,\d{3})+$/);
if (groupedInteger) return value.replace(/,/g, ".");
const germanMeasurement = value.match(/^(?:\d{1,3}(?:\.\d{3})+|\d{1,3})(?:,\d{1,2})? (?:B|KB|MB|GB|TB|PB)(?:\/s)?$/);
if (germanMeasurement) return value;
const germanPercentage = value.match(/^(?:\d{1,3}(?:\.\d{3})+|\d{1,3})(?:,\d{1,2})? %$/);
if (germanPercentage) return value;
const measurement = value.match(/^((?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?) (B|KB|MB|GB|TB|PB)(\/s)?$/);
if (measurement) return `${englishNumberToGerman(measurement[1])} ${measurement[2]}${measurement[3] ?? ""}`;
const percentage = value.match(/^((?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?) %$/);
if (percentage) return `${englishNumberToGerman(percentage[1])} %`;
const recordedDays = value.match(/^(Last seven days|Last 30 days): (\d+) recorded (day is|days are) summarized through today\.$/);
if (recordedDays) return `${recordedDays[1] === "Last seven days" ? "Letzte sieben Tage" : "Letzte 30 Tage"}: ${recordedDays[2]} ${recordedDays[3] === "day is" ? "erfasster Tag wird" : "erfasste Tage werden"} bis heute zusammengefasst.`;
const statisticsOutcome = value.match(/^([\d.,]+) completed · ([\d.,]+) errors$/);
if (statisticsOutcome) return `${englishNumberToGerman(statisticsOutcome[1])} fertig · ${englishNumberToGerman(statisticsOutcome[2])} Fehler`;
const historyParts = value.match(/^([\d.,]+) parts$/);
if (historyParts) return `${englishNumberToGerman(historyParts[1])} Parts`;
const selectedDownloadsStart = value.match(/^Start selected downloads \(([\d.,]+)\)$/);
if (selectedDownloadsStart) return `Ausgewählte Downloads starten (${englishNumberToGerman(selectedDownloadsStart[1])})`;
const selectedFilesRemove = value.match(/^Remove selected files \(([\d.,]+)\)$/);
if (selectedFilesRemove) return `Ausgewählte Dateien entfernen (${englishNumberToGerman(selectedFilesRemove[1])})`;
const resetCount = value.match(/^Reset \(([\d.,]+)\)$/);
if (resetCount) return `Zurücksetzen (${englishNumberToGerman(resetCount[1])})`;
const skipCount = value.match(/^skip \(([\d.,]+)\)$/);
if (skipCount) return `überspringen (${englishNumberToGerman(skipCount[1])})`;
const collectorSelected = value.match(/^Send selection \(([\d.,]+)\)$/);
if (collectorSelected) return `Auswahl übergeben (${collectorSelected[1].replace(/,/g, ".")})`;
const collectorAll = value.match(/^Send all \(([\d.,]+)\)$/);
if (collectorAll) return `Alle übergeben (${collectorAll[1].replace(/,/g, ".")})`;
const collectorPackage = value.match(/^Select package (.+)$/);
if (collectorPackage) return `Paket ${collectorPackage[1]} auswählen`;
const collectorFiles = value.match(/^([\d.,]+) files$/);
if (collectorFiles) return `${collectorFiles[1].replace(/,/g, ".")} Dateien`;
const collectorAvailability = value.match(/^([\d.,]+)\/([\d.,]+) online$/);
if (collectorAvailability) return `${collectorAvailability[1].replace(/,/g, ".")}/${collectorAvailability[2].replace(/,/g, ".")} online`;
const collectorCapacity = value.match(/^The link collector can contain at most ([\d.,]+) (packages|links)\.$/);
if (collectorCapacity) return `Der Linksammler kann höchstens ${collectorCapacity[1].replace(/,/g, ".")} ${collectorCapacity[2] === "packages" ? "Pakete" : "Links"} enthalten.`;
const collectorImportCapacity = value.match(/^The queue export exceeds the link collector limit of ([\d.,]+) (packages|links)\.$/);
if (collectorImportCapacity) return `Der Queue-Export überschreitet das Collector-Limit von ${collectorImportCapacity[1].replace(/,/g, ".")} ${collectorImportCapacity[2] === "packages" ? "Paketen" : "Links"}.`;
const collectorCollected = value.match(/^([\d.,]+) package\(s\), ([\d.,]+) link\(s\) collected$/);
if (collectorCollected) return `${collectorCollected[1].replace(/,/g, ".")} Paket(e), ${collectorCollected[2].replace(/,/g, ".")} Link(s) gesammelt`;
const collectorDlcCollected = value.match(/^DLC collected: ([\d.,]+) package\(s\), ([\d.,]+) link\(s\)$/);
if (collectorDlcCollected) return `DLC gesammelt: ${collectorDlcCollected[1].replace(/,/g, ".")} Paket(e), ${collectorDlcCollected[2].replace(/,/g, ".")} Link(s)`;
const collectorPartialTransfer = value.match(/^([\d.,]+) of ([\d.,]+) link\(s\) transferred; collection remains$/);
if (collectorPartialTransfer) return `${collectorPartialTransfer[1].replace(/,/g, ".")} von ${collectorPartialTransfer[2].replace(/,/g, ".")} Link(s) übergeben; Sammlung bleibt erhalten`;
const collectorTransferred = value.match(/^([\d.,]+) package\(s\), ([\d.,]+) link\(s\) transferred$/);
if (collectorTransferred) return `${collectorTransferred[1].replace(/,/g, ".")} Paket(e), ${collectorTransferred[2].replace(/,/g, ".")} Link(s) übergeben`;
const update = value.match(/^(.+) is available\. Installed version: (.+)\.$/);
if (update) return `${update[1]} ist verfügbar. Installierte Version: ${update[2]}.`;
const pagination = value.match(/^([\d.,]+\s*[-]\s*[\d.,]+) of ([\d.,]+)$/);
@@ -403,8 +548,8 @@ function translateDynamic(value: string, language: AppLanguage): string {
if (scheduled) return `Geplant: ${scheduled[1].replace(/^Today\b/, "Heute")}`;
const cancelled = value.match(/^(\d+) cancelled$/);
if (cancelled) return `${cancelled[1]} abgebrochen`;
const labelledCount = value.match(/^(Entries|Visible|Selected): (\d+)$/);
if (labelledCount) return `${({ Entries: "Einträge", Visible: "Sichtbar", Selected: "Ausgewählt" } as const)[labelledCount[1] as "Entries" | "Visible" | "Selected"]}: ${labelledCount[2]}`;
const labelledCount = value.match(/^(Packages|Links|Entries|Visible|Selected): ([\d.,]+)$/);
if (labelledCount) return `${({ Packages: "Pakete", Links: "Links", Entries: "Einträge", Visible: "Sichtbar", Selected: "Ausgewählt" } as Record<string, string>)[labelledCount[1]]}: ${labelledCount[2].replace(/,/g, ".")}`;
const perPage = value.match(/^(\d+) per page$/);
if (perPage) return `${perPage[1]} pro Seite`;
const filter = value.match(/^(All|Active|Queued|Paused|Completed|Errors) (\d+)$/);
@@ -541,12 +686,12 @@ function translateDynamic(value: string, language: AppLanguage): string {
if (packageCount) return `${packageCount[1]} Paket(e)`;
const linkCount = value.match(/^(\d+) link\(s\)$/);
if (linkCount) return `${linkCount[1]} Link(s)`;
const exportSelected = value.match(/^Export selected (packages|files) \((\d+)\)$/);
if (exportSelected) return `Ausgewählte ${exportSelected[1] === "packages" ? "Pakete" : "Dateien"} exportieren (${exportSelected[2]})`;
const toggleAll = value.match(/^Toggle all (.+)$/);
if (toggleAll) return `Alle ${toggleAll[1]} umschalten`;
const removeSelected = value.match(/^Remove selected \((\d+)\)$/);
if (removeSelected) return `Ausgewählte entfernen (${removeSelected[1]})`;
const exportSelected = value.match(/^Export selected (packages|files) \(([\d.,]+)\)$/);
if (exportSelected) return `Ausgewählte ${exportSelected[1] === "packages" ? "Pakete" : "Dateien"} exportieren (${englishNumberToGerman(exportSelected[2])})`;
const toggleAll = value.match(/^Toggle all ([\d.,]+)$/);
if (toggleAll) return `Alle ${englishNumberToGerman(toggleAll[1])} umschalten`;
const removeSelected = value.match(/^Remove selected \(([\d.,]+)\)$/);
if (removeSelected) return `Ausgewählte entfernen (${englishNumberToGerman(removeSelected[1])})`;
const copied = value.match(/^(.+) copied$/);
if (copied) return `${copied[1]} kopiert`;
const suffixes: Array<[RegExp, string]> = [
+96 -10
View File
@@ -97,17 +97,98 @@
}
}
html,
body,
#root {
html,
body,
#root {
margin: 0;
width: 100%;
height: 100%;
background: radial-gradient(circle at 15% 10%, var(--bg-glow) 0, var(--surface) 45%, var(--bg) 100%);
color: var(--text);
}
.app-shell {
color: var(--text);
}
.snapshot-bootstrap-root {
display: grid;
width: 100%;
height: 100%;
place-items: center;
padding: 24px;
background: var(--ui-canvas);
color: var(--ui-text);
}
.snapshot-bootstrap-panel {
display: grid;
width: min(420px, 100%);
justify-items: center;
gap: 12px;
padding: 32px;
border: 1px solid var(--ui-border);
border-radius: 12px;
background: var(--ui-surface);
text-align: center;
}
.snapshot-bootstrap-panel h1,
.snapshot-bootstrap-panel p {
margin: 0;
}
.snapshot-bootstrap-panel h1 {
font-size: 18px;
}
.snapshot-bootstrap-panel p {
max-width: 360px;
color: var(--ui-text-muted);
}
.snapshot-bootstrap-panel.is-error h1 {
color: var(--ui-danger-text);
}
.snapshot-bootstrap-spinner {
width: 28px;
height: 28px;
border: 3px solid var(--ui-border);
border-top-color: var(--ui-primary);
border-radius: 50%;
animation: snapshot-bootstrap-spin 800ms linear infinite;
}
.snapshot-bootstrap-retry {
min-height: 34px;
padding: 6px 14px;
border: 1px solid var(--ui-primary);
border-radius: 7px;
background: var(--ui-primary);
color: var(--ui-primary-text);
cursor: pointer;
font-weight: 600;
}
.snapshot-bootstrap-retry:hover {
background: var(--ui-primary-hover);
}
.snapshot-bootstrap-retry:focus-visible {
outline: 2px solid var(--ui-focus);
outline-offset: 2px;
}
@keyframes snapshot-bootstrap-spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.snapshot-bootstrap-spinner {
animation: none;
}
}
.app-shell {
display: grid;
grid-template-rows: auto auto auto 1fr auto;
height: 100%;
@@ -3102,9 +3183,14 @@ td {
transition: background 0.1s;
}
.ctx-menu-item:hover {
background: var(--button-bg-hover);
}
.ctx-menu-item:disabled {
opacity: 0.5;
cursor: default;
}
.ctx-menu-item:hover:not(:disabled) {
background: var(--button-bg-hover);
}
.ctx-menu-item.ctx-danger {
color: var(--danger);
+4 -3
View File
@@ -1,8 +1,9 @@
:root,
:root[data-theme="dark"] {
--ui-canvas: #0F0F0F;
--ui-surface: #232323;
--ui-input: #2B2B2B;
--ui-canvas: #0F0F0F;
--ui-surface: #232323;
--ui-panel: var(--ui-surface);
--ui-input: #2B2B2B;
--ui-table-header: #313131;
--ui-active: #333436;
--ui-hover: #373535;
+40 -20
View File
@@ -14,6 +14,7 @@ import type {
import "./collector.css";
const useRendererLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
const integerFormatter = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 0 });
export interface CollectorViewActions {
onFilterChange: (filter: CollectorWorkspaceFilter) => void;
@@ -25,6 +26,7 @@ export interface CollectorViewActions {
onQueryChange: (value: string) => void;
onLinkSelectionChange: (linkId: string, selected: boolean) => void;
onPackageSelectionChange: (packageId: string, selected: boolean) => void;
onSetVisibleSelection: (ids: string[], selected: boolean) => void;
onPackageCollapseChange: (packageId: string) => void;
onToggleAllPackages: () => void;
onRemoveSelected: () => void;
@@ -55,9 +57,9 @@ function packageStatus(row: CollectorWorkspacePackageRow): string {
}
function packageAvailability(row: CollectorWorkspacePackageRow): string {
if (row.onlineCount === row.totalCount) return `${row.onlineCount}/${row.totalCount} online`;
if (row.onlineCount === row.totalCount) return `${integerFormatter.format(row.onlineCount)}/${integerFormatter.format(row.totalCount)} online`;
if (row.offlineCount === row.totalCount) return "Offline";
if (row.onlineCount > 0) return `${row.onlineCount}/${row.totalCount} online`;
if (row.onlineCount > 0) return `${integerFormatter.format(row.onlineCount)}/${integerFormatter.format(row.totalCount)} online`;
return "Ungeprüft";
}
@@ -195,7 +197,7 @@ function CollectorHosterLabel({ hoster }: { hoster: ReturnType<typeof formatHost
export function CollectorSidebar({ model, actions }: CollectorViewProps): ReactElement {
return (
<div aria-label="Linksammler-Filter" className="collector-sidebar" data-visual-region="collector-sidebar">
<div className="collector-sidebar-heading"><strong>Status</strong><span>{model.totalCount}</span></div>
<div className="collector-sidebar-heading"><strong>Status</strong><span>{integerFormatter.format(model.totalCount)}</span></div>
<SlidingSelection activeKey={model.filter} axis="vertical" className="collector-sidebar-list">
{model.filters.map((filter) => (
<button
@@ -207,7 +209,7 @@ export function CollectorSidebar({ model, actions }: CollectorViewProps): ReactE
onClick={() => actions.onFilterChange(filter.id)}
type="button"
>
<span>{filter.label}</span><span>{filter.count}</span>
<span>{filter.label}</span><span>{integerFormatter.format(filter.count)}</span>
</button>
))}
</SlidingSelection>
@@ -218,9 +220,9 @@ export function CollectorSidebar({ model, actions }: CollectorViewProps): ReactE
export function CollectorSidebarStatus({ model }: { model: CollectorWorkspaceViewModel }): ReactElement {
return (
<>
<span>Pakete: {model.packageCount}</span>
<span>Links: {model.totalCount}</span>
<span>Ausgewählt: {model.selectedCount}</span>
<span>Pakete: {integerFormatter.format(model.packageCount)}</span>
<span>Links: {integerFormatter.format(model.totalCount)}</span>
<span>Ausgewählt: {integerFormatter.format(model.selectedCount)}</span>
{model.analyzing ? (
<span aria-live="polite" className="collector-sidebar-analysis" role="status"><span aria-hidden="true" />Analyse läuft im Hintergrund</span>
) : null}
@@ -237,8 +239,8 @@ export function CollectorToolbar({ model, actions }: CollectorViewProps): ReactE
<button className="collector-action" onClick={actions.onImportFile} type="button">Datei importieren</button>
</ToolbarGroup>
<ToolbarGroup label="Downloads übergeben">
<button className="collector-action" disabled={model.selectedCount === 0} onClick={actions.onSubmitSelected} type="button">{`Auswahl übergeben (${model.selectedCount})`}</button>
<button className="collector-action" disabled={model.totalCount === 0} onClick={actions.onSubmitAll} type="button">{`Alle übergeben (${model.totalCount})`}</button>
<button className="collector-action" disabled={model.selectedTransferableIds.length === 0} onClick={actions.onSubmitSelected} type="button">{`Auswahl übergeben (${integerFormatter.format(model.selectedTransferableIds.length)})`}</button>
<button className="collector-action" disabled={model.visibleTransferableIds.length === 0} onClick={actions.onSubmitAll} type="button">{`Alle übergeben (${integerFormatter.format(model.visibleTransferableIds.length)})`}</button>
<button className="collector-action collector-action-danger" disabled={model.selectedCount === 0} onClick={actions.onRemoveSelected} type="button">Auswahl entfernen</button>
</ToolbarGroup>
<ToolbarGroup className="collector-toolbar-tail" label="Suche und Paketdarstellung">
@@ -249,6 +251,32 @@ export function CollectorToolbar({ model, actions }: CollectorViewProps): ReactE
);
}
export function CollectorTableHeader({ model, actions, scrollLeft }: Pick<CollectorViewProps, "model" | "actions"> & { scrollLeft: number }): ReactElement {
const allVisibleSelected = model.visibleIds.length > 0 && model.selectedCount === model.visibleIds.length;
const partiallyVisibleSelected = model.selectedCount > 0 && !allVisibleSelected;
return (
<div aria-rowindex={1} className="collector-table-header-row" role="row" style={collectorHeaderScrollStyle(scrollLeft)}>
<span className="collector-column-select" role="columnheader">
<input
aria-checked={partiallyVisibleSelected ? "mixed" : allVisibleSelected}
aria-label="Alle sichtbaren Links auswählen"
checked={allVisibleSelected}
disabled={model.visibleIds.length === 0}
onChange={(event) => actions.onSetVisibleSelection(model.visibleIds, event.target.checked)}
ref={(node) => { if (node) node.indeterminate = partiallyVisibleSelected; }}
type="checkbox"
/>
</span>
<span role="columnheader">Name</span>
<span role="columnheader">Größe</span>
<span role="columnheader">Hoster</span>
<span role="columnheader">Status</span>
<span role="columnheader">Verfügbarkeit</span>
<span role="columnheader">Hinzugefügt</span>
</div>
);
}
function CollectorPackageGroup({ row, model, actions, selected, focusIndexStart, rowIndexStart }: {
focusIndexStart: number;
row: CollectorWorkspacePackageRow;
@@ -257,7 +285,7 @@ function CollectorPackageGroup({ row, model, actions, selected, focusIndexStart,
selected: ReadonlySet<string>;
rowIndexStart: number;
}): ReactElement {
const allSelected = row.selectedCount === row.totalCount;
const allSelected = row.links.length > 0 && row.selectedCount === row.links.length;
const partiallySelected = row.selectedCount > 0 && !allSelected;
const animateItems = model.animationsEnabled && row.allLinks.length <= 64;
const [renderItems, setRenderItems] = useState(!row.collapsed);
@@ -314,7 +342,7 @@ function CollectorPackageGroup({ row, model, actions, selected, focusIndexStart,
type="button"
>{row.collapsed ? "+" : ""}</button>
<strong title={row.name}>{row.name}</strong>
<small>{row.totalCount} Dateien</small>
<small>{integerFormatter.format(row.totalCount)} Dateien</small>
</span>
<span className="collector-size-cell" role="cell">{packageSize(row)}</span>
<span className="collector-hoster-cell" role="cell">
@@ -426,15 +454,7 @@ export function CollectorContent({ model, actions }: CollectorViewProps): ReactE
<section className="collector-content" aria-label="Gesammelte Downloadpakete">
<DataTable aria-rowcount={logicalRowCount} className="collector-table" label="Gesammelte Downloadpakete">
<DataTableHeader className="collector-table-header">
<div aria-rowindex={1} className="collector-table-header-row" role="row" style={collectorHeaderScrollStyle(viewport.scrollLeft)}>
<span aria-label="Auswahl" className="collector-column-select" role="columnheader" />
<span role="columnheader">Name</span>
<span role="columnheader">Größe</span>
<span role="columnheader">Hoster</span>
<span role="columnheader">Status</span>
<span role="columnheader">Verfügbarkeit</span>
<span role="columnheader">Hinzugefügt</span>
</div>
<CollectorTableHeader actions={actions} model={model} scrollLeft={viewport.scrollLeft} />
</DataTableHeader>
<DataTableBody
className="collector-table-body"
@@ -0,0 +1,149 @@
import {
inspectCollectorCapacity,
inspectCollectorPersistenceSize,
validateCollectorPersistenceState,
type CollectorPersistenceState
} from "../../../shared/collector";
import {
mergeCollectorPackages,
reconcileCollectorCollapsedPackageIds
} from "./collector-model";
export type CollectorLateHydrationResult = {
ok: true;
state: CollectorPersistenceState;
} | {
ok: false;
message: string;
packageCount: number;
linkCount: number;
attemptedState: CollectorPersistenceState;
currentState: CollectorPersistenceState;
rollbackState: CollectorPersistenceState;
};
export interface CollectorBeforeUnloadPersistenceRequest {
hydrated: boolean;
currentState: CollectorPersistenceState;
getPersistedStateSync: () => unknown;
saveStateSync: (state: CollectorPersistenceState) => boolean;
maximumBytes?: number;
}
export interface CollectorBeforeUnloadEvent {
preventDefault: () => void;
returnValue: string;
}
function mergeCollapsedPackageIds(
persistedState: CollectorPersistenceState,
currentState: CollectorPersistenceState,
packages: CollectorPersistenceState["packages"]
): string[] {
const persistedCollapsed = reconcileCollectorCollapsedPackageIds(
new Set(persistedState.collapsedPackageIds),
persistedState.packages,
packages,
[],
false
);
const currentCollapsed = reconcileCollectorCollapsedPackageIds(
new Set(currentState.collapsedPackageIds),
currentState.packages,
packages,
[],
false
);
return [...new Set([...persistedCollapsed, ...currentCollapsed])];
}
export function resolveCollectorLateHydration(
persistedState: CollectorPersistenceState,
currentState: CollectorPersistenceState,
maximumBytes?: number
): CollectorLateHydrationResult {
let persisted = persistedState;
let current = currentState;
let attemptedState: CollectorPersistenceState = {
packages: [],
collapsedPackageIds: []
};
try {
persisted = validateCollectorPersistenceState(persistedState);
current = validateCollectorPersistenceState(currentState);
const packages = mergeCollectorPackages(persisted.packages, current.packages).packages;
attemptedState = {
packages,
collapsedPackageIds: mergeCollapsedPackageIds(persisted, current, packages)
};
const capacity = inspectCollectorCapacity(packages);
if (!capacity.ok) {
return {
...capacity,
attemptedState,
currentState: current,
rollbackState: persisted
};
}
const persistenceSize = inspectCollectorPersistenceSize(attemptedState, maximumBytes);
if (!persistenceSize.ok) {
return {
ok: false,
message: persistenceSize.message,
packageCount: capacity.packageCount,
linkCount: capacity.linkCount,
attemptedState,
currentState: current,
rollbackState: persisted
};
}
return {
ok: true,
state: validateCollectorPersistenceState(attemptedState)
};
} catch (error) {
const capacity = inspectCollectorCapacity(attemptedState.packages);
return {
ok: false,
message: error instanceof Error
? `Linksammler konnte nicht wiederhergestellt werden: ${error.message}`
: "Linksammler konnte nicht wiederhergestellt werden.",
packageCount: capacity.packageCount,
linkCount: capacity.linkCount,
attemptedState,
currentState: current,
rollbackState: persisted
};
}
}
export function persistCollectorStateBeforeUnload(
request: CollectorBeforeUnloadPersistenceRequest
): boolean {
try {
const current = validateCollectorPersistenceState(request.currentState);
if (request.hydrated) {
if (!inspectCollectorPersistenceSize(current, request.maximumBytes).ok) return false;
return request.saveStateSync(current) === true;
}
const hydration = resolveCollectorLateHydration(
validateCollectorPersistenceState(request.getPersistedStateSync()),
current,
request.maximumBytes
);
if (!hydration.ok) return false;
return request.saveStateSync(hydration.state) === true;
} catch {
return false;
}
}
export function guardCollectorBeforeUnload(
event: CollectorBeforeUnloadEvent,
request: CollectorBeforeUnloadPersistenceRequest
): boolean {
if (persistCollectorStateBeforeUnload(request)) return true;
event.preventDefault();
event.returnValue = "";
return false;
}
+159 -13
View File
@@ -1,4 +1,4 @@
import type { CollectorAvailability, CollectorLink, CollectorPackage } from "../../../shared/collector";
import { inspectCollectorCapacity, type CollectorAvailability, type CollectorLink, type CollectorPackage } from "../../../shared/collector";
export type { CollectorAvailability, CollectorLink, CollectorPackage } from "../../../shared/collector";
export type CollectorWorkspaceFilter = "all" | CollectorAvailability;
@@ -37,7 +37,10 @@ export interface CollectorWorkspaceViewModel {
empty: boolean;
totalCount: number;
selectedCount: number;
visibleIds: string[];
visibleTransferableIds: string[];
selectedIds: string[];
selectedTransferableIds: string[];
animationsEnabled: boolean;
}
@@ -46,8 +49,39 @@ export interface CollectorMergeResult {
addedLinks: number;
duplicateLinks: number;
enrichedLinks: number;
persistenceDelta: CollectorPersistenceDelta;
}
export interface CollectorPersistenceLinkDelta {
url: string;
previousPackageId: string | null;
previousLink: CollectorLink | null;
nextPackageId: string | null;
nextLink: CollectorLink | null;
}
export interface CollectorPersistencePackageDelta {
package: Pick<CollectorPackage, "id" | "name" | "nameSource" | "addedAt">;
linkCount: number;
}
export interface CollectorPersistenceDelta {
links: CollectorPersistenceLinkDelta[];
packages: CollectorPersistencePackageDelta[];
removedPackageIds: string[];
packageCount: number;
}
export type CollectorCapacityMergeResult = {
ok: true;
value: CollectorMergeResult;
} | {
ok: false;
packageCount: number;
linkCount: number;
message: string;
};
function collectorUrlKey(url: string): string {
return url.trim();
}
@@ -96,6 +130,8 @@ export function mergeCollectorPackages(current: CollectorPackage[], incoming: Co
const replacements = new Map<CollectorLink, CollectorLink>();
const movedLinks = new Set<CollectorLink>();
const appendedLinks = new Map<CollectorPackage, CollectorLink[]>();
const affectedPackages = new Set<CollectorPackage>();
const persistenceLinkDeltas = new Map<string, CollectorPersistenceLinkDelta>();
const appendToPackage = (pkg: CollectorPackage, link: CollectorLink): void => {
const links = appendedLinks.get(pkg);
if (links) links.push(link);
@@ -141,8 +177,12 @@ export function mergeCollectorPackages(current: CollectorPackage[], incoming: Co
};
packages.push(target);
packageByName.set(incomingPackageKey, target);
affectedPackages.add(target);
}
if (incomingPackage.nameSource === "explicit" && target.nameSource !== "explicit") {
target.nameSource = "explicit";
affectedPackages.add(target);
}
if (incomingPackage.nameSource === "explicit") target.nameSource = "explicit";
if (existing) {
const enriched = mergeCollectorLinkMetadata(existing.link, incomingLink);
@@ -150,30 +190,80 @@ export function mergeCollectorPackages(current: CollectorPackage[], incoming: Co
else {
movedLinks.add(existing.link);
appendToPackage(target, enriched);
affectedPackages.add(existing.pkg);
}
affectedPackages.add(target);
target.addedAt = Math.min(target.addedAt, enriched.addedAt);
existingByUrl.set(urlKey, { pkg: target, link: enriched });
persistenceLinkDeltas.set(urlKey, {
url: urlKey,
previousPackageId: existing.pkg.id,
previousLink: existing.link,
nextPackageId: target.id,
nextLink: enriched
});
enrichedLinks += 1;
continue;
}
const added = { ...incomingLink };
appendToPackage(target, added);
affectedPackages.add(target);
target.addedAt = Math.min(target.addedAt, added.addedAt);
existingByUrl.set(urlKey, { pkg: target, link: added });
persistenceLinkDeltas.set(urlKey, {
url: urlKey,
previousPackageId: null,
previousLink: null,
nextPackageId: target.id,
nextLink: added
});
addedLinks += 1;
}
}
const finalPackages: CollectorPackage[] = [];
const persistencePackages: CollectorPersistencePackageDelta[] = [];
const removedPackageIds: string[] = [];
for (const pkg of packages) {
const links = pkg.links.flatMap((link) => movedLinks.has(link) ? [] : [replacements.get(link) ?? link]);
links.push(...(appendedLinks.get(pkg) ?? []));
if (links.length === 0) {
if (affectedPackages.has(pkg)) removedPackageIds.push(pkg.id);
continue;
}
const finalPackage = { ...pkg, links };
finalPackages.push(finalPackage);
if (affectedPackages.has(pkg)) {
persistencePackages.push({
package: {
id: finalPackage.id,
name: finalPackage.name,
nameSource: finalPackage.nameSource,
addedAt: finalPackage.addedAt
},
linkCount: finalPackage.links.length
});
}
for (const link of links) {
const delta = persistenceLinkDeltas.get(collectorUrlKey(link.url));
if (!delta) continue;
delta.nextPackageId = finalPackage.id;
delta.nextLink = link;
}
}
return {
packages: packages.flatMap((pkg) => {
const links = pkg.links.flatMap((link) => movedLinks.has(link) ? [] : [replacements.get(link) ?? link]);
links.push(...(appendedLinks.get(pkg) ?? []));
return links.length > 0 ? [{ ...pkg, links }] : [];
}),
packages: finalPackages,
addedLinks,
duplicateLinks,
enrichedLinks
enrichedLinks,
persistenceDelta: {
links: [...persistenceLinkDeltas.values()],
packages: persistencePackages,
removedPackageIds,
packageCount: finalPackages.length
}
};
}
@@ -186,6 +276,23 @@ export function mergeCollectorEnrichment(current: CollectorPackage[], incoming:
return mergeCollectorPackages(current, retained);
}
export function mergeCollectorPackagesWithinCapacity(
current: CollectorPackage[],
incoming: CollectorPackage[],
enrichment = false
): CollectorCapacityMergeResult {
const value = enrichment
? mergeCollectorEnrichment(current, incoming)
: mergeCollectorPackages(current, incoming);
const capacity = inspectCollectorCapacity(value.packages);
return capacity.ok ? { ok: true, value } : capacity;
}
function retainAvailableCollectorIds(current: ReadonlySet<string>, availableIds: Iterable<string>): Set<string> {
const available = new Set(availableIds);
return new Set([...current].filter((id) => available.has(id)));
}
export function reconcileCollectorCollapsedPackageIds(
current: Set<string>,
previousPackages: CollectorPackage[],
@@ -217,7 +324,14 @@ export function reconcileCollectorCollapsedPackageIds(
return next;
}
export function selectCollectorPackageLinks(current: Set<string>, pkg: CollectorPackage, selected: boolean): Set<string> {
export function reconcileCollectorCollapsedPackageIdsWithPackages(
current: ReadonlySet<string>,
packages: readonly Pick<CollectorPackage, "id">[]
): Set<string> {
return retainAvailableCollectorIds(current, packages.map((pkg) => pkg.id));
}
export function selectCollectorPackageLinks(current: Set<string>, pkg: Pick<CollectorPackage, "links">, selected: boolean): Set<string> {
const next = new Set(current);
for (const link of pkg.links) {
if (selected) next.add(link.id);
@@ -226,6 +340,22 @@ export function selectCollectorPackageLinks(current: Set<string>, pkg: Collector
return next;
}
export function setCollectorVisibleSelection(current: ReadonlySet<string>, visibleIds: readonly string[], selected: boolean): Set<string> {
const next = new Set(current);
for (const id of visibleIds) {
if (selected) next.add(id);
else next.delete(id);
}
return next;
}
export function reconcileCollectorSelectionWithPackages(
current: ReadonlySet<string>,
packages: readonly Pick<CollectorPackage, "links">[]
): Set<string> {
return retainAvailableCollectorIds(current, packages.flatMap((pkg) => pkg.links.map((link) => link.id)));
}
export function buildCollectorTransferPackages(packages: CollectorPackage[], selectedIds: Set<string>): CollectorPackage[] {
return packages.flatMap((pkg) => {
const links = pkg.links.filter((link) => selectedIds.has(link.id));
@@ -240,6 +370,13 @@ export function removeCollectorLinks(packages: CollectorPackage[], removedIds: S
});
}
export function reconcileCollectorSelectionAfterRemoval(
selectedIds: ReadonlySet<string>,
removedIds: ReadonlySet<string>
): Set<string> {
return new Set([...selectedIds].filter((id) => !removedIds.has(id)));
}
function filterCollectorLink(link: CollectorLink, filter: CollectorWorkspaceFilter): boolean {
return filter === "all" || link.availability === filter;
}
@@ -277,7 +414,6 @@ export function buildCollectorWorkspaceViewModel(
let onlineCount = 0;
let offlineCount = 0;
let unknownCount = 0;
let selectedCount = 0;
const hosters = new Set<string>();
for (const link of pkg.links) {
if (link.fileSizeBytes === null) unknownSizeCount += 1;
@@ -285,9 +421,9 @@ export function buildCollectorWorkspaceViewModel(
if (link.availability === "online") onlineCount += 1;
else if (link.availability === "offline") offlineCount += 1;
else unknownCount += 1;
if (selected.has(link.id)) selectedCount += 1;
if (link.hoster) hosters.add(link.hoster);
}
const selectedCount = visibleLinks.reduce((count, link) => count + (selected.has(link.id) ? 1 : 0), 0);
rows.push({
id: pkg.id,
name: pkg.name,
@@ -306,6 +442,13 @@ export function buildCollectorWorkspaceViewModel(
});
}
const visibleIds = rows.flatMap((row) => row.links.map((link) => link.id));
const visibleSelectedIds = visibleIds.filter((id) => selected.has(id));
const visibleTransferableIds = rows.flatMap((row) => row.links
.filter((link) => link.availability !== "offline")
.map((link) => link.id));
const selectedTransferableIds = visibleTransferableIds.filter((id) => selected.has(id));
return {
packages: rows,
packageCount: packages.length,
@@ -321,8 +464,11 @@ export function buildCollectorWorkspaceViewModel(
error,
empty: rows.length === 0,
totalCount: allLinks.length,
selectedCount: allLinks.reduce((count, link) => count + (selected.has(link.id) ? 1 : 0), 0),
selectedIds: allLinks.filter((link) => selected.has(link.id)).map((link) => link.id),
selectedCount: visibleSelectedIds.length,
visibleIds,
visibleTransferableIds,
selectedIds: visibleSelectedIds,
selectedTransferableIds,
animationsEnabled
};
}
@@ -0,0 +1,261 @@
import {
COLLECTOR_MAX_PERSISTENCE_BYTES,
type CollectorLink,
type CollectorPackage,
type CollectorPersistenceState
} from "../../../shared/collector";
import type { CollectorPersistenceDelta } from "./collector-model";
const encoder = new TextEncoder();
const overflowMessage = "Der Linksammler ist zu groß, um gespeichert zu werden.";
export interface CollectorPersistenceBudget {
byteCount: number;
packageByteTotal: number;
packageCount: number;
packageByteCounts: ReadonlyMap<string, number>;
packageBaseByteCounts: ReadonlyMap<string, number>;
packageLinkByteTotals: ReadonlyMap<string, number>;
packageLinkCounts: ReadonlyMap<string, number>;
linkByteCounts: ReadonlyMap<string, number>;
linkPackageIds: ReadonlyMap<string, string>;
linkIds: ReadonlyMap<string, string>;
}
export type CollectorPersistenceBudgetResult = {
ok: true;
byteCount: number;
nextBudget: CollectorPersistenceBudget;
} | {
ok: false;
byteCount: number;
message: string;
};
interface PackageCandidate {
id: string;
existed: boolean;
oldByteCount: number;
baseByteCount: number;
linkByteTotal: number;
linkCount: number;
removed: boolean;
}
interface LinkCandidate {
byteCount: number;
id: string;
packageId: string;
}
type MutableCollectorPersistenceBudget = CollectorPersistenceBudget & {
packageByteCounts: Map<string, number>;
packageBaseByteCounts: Map<string, number>;
packageLinkByteTotals: Map<string, number>;
packageLinkCounts: Map<string, number>;
linkByteCounts: Map<string, number>;
linkPackageIds: Map<string, string>;
linkIds: Map<string, string>;
};
function utf8ByteCount(value: string): number {
return encoder.encode(value).byteLength;
}
function normalizedUrl(url: string): string {
return url.trim();
}
function linkByteCount(link: CollectorLink): number {
return utf8ByteCount(JSON.stringify(link));
}
function samePersistentLink(left: CollectorLink, right: CollectorLink): boolean {
return left.id === right.id
&& left.url === right.url
&& left.fileName === right.fileName
&& left.fileSizeBytes === right.fileSizeBytes
&& left.hoster === right.hoster
&& left.availability === right.availability
&& left.status === right.status
&& left.addedAt === right.addedAt;
}
function packageBaseByteCount(pkg: Pick<CollectorPackage, "id" | "name" | "nameSource" | "addedAt">): number {
return utf8ByteCount(JSON.stringify({
id: pkg.id,
name: pkg.name,
nameSource: pkg.nameSource,
links: [],
addedAt: pkg.addedAt
}));
}
function completePackageByteCount(baseByteCount: number, linkByteTotal: number, linkCount: number): number {
return baseByteCount + linkByteTotal + Math.max(0, linkCount - 1);
}
function envelopeByteCount(collapsedPackageIds: readonly string[]): number {
return utf8ByteCount(JSON.stringify({
version: 1,
packages: [],
collapsedPackageIds,
updatedAt: Number.MAX_SAFE_INTEGER
}));
}
function totalByteCount(packageByteTotal: number, packageCount: number, collapsedPackageIds: readonly string[]): number {
return envelopeByteCount(collapsedPackageIds) + packageByteTotal + Math.max(0, packageCount - 1);
}
export function createCollectorPersistenceBudget(
state: CollectorPersistenceState,
maximumBytes = COLLECTOR_MAX_PERSISTENCE_BYTES
): CollectorPersistenceBudgetResult {
const packageByteCounts = new Map<string, number>();
const packageBaseByteCounts = new Map<string, number>();
const packageLinkByteTotals = new Map<string, number>();
const packageLinkCounts = new Map<string, number>();
const linkByteCounts = new Map<string, number>();
const linkPackageIds = new Map<string, string>();
const linkIds = new Map<string, string>();
let packageByteTotal = 0;
for (const pkg of state.packages) {
const baseByteCount = packageBaseByteCount(pkg);
let linkByteTotal = 0;
for (const link of pkg.links) {
const url = normalizedUrl(link.url);
const byteCount = linkByteCount(link);
linkByteCounts.set(url, byteCount);
linkPackageIds.set(url, pkg.id);
linkIds.set(url, link.id);
linkByteTotal += byteCount;
}
const byteCount = completePackageByteCount(baseByteCount, linkByteTotal, pkg.links.length);
packageByteCounts.set(pkg.id, byteCount);
packageBaseByteCounts.set(pkg.id, baseByteCount);
packageLinkByteTotals.set(pkg.id, linkByteTotal);
packageLinkCounts.set(pkg.id, pkg.links.length);
packageByteTotal += byteCount;
}
const packageCount = state.packages.length;
const byteCount = totalByteCount(packageByteTotal, packageCount, state.collapsedPackageIds);
if (byteCount > maximumBytes) return { ok: false, byteCount, message: overflowMessage };
return {
ok: true,
byteCount,
nextBudget: {
byteCount,
packageByteTotal,
packageCount,
packageByteCounts,
packageBaseByteCounts,
packageLinkByteTotals,
packageLinkCounts,
linkByteCounts,
linkPackageIds,
linkIds
}
};
}
export function advanceCollectorPersistenceBudget(
budget: CollectorPersistenceBudget,
delta: CollectorPersistenceDelta,
collapsedPackageIds: readonly string[],
maximumBytes = COLLECTOR_MAX_PERSISTENCE_BYTES
): CollectorPersistenceBudgetResult {
const packageCandidates = new Map<string, PackageCandidate>();
const linkCandidates = new Map<string, LinkCandidate | null>();
const packageCandidate = (id: string): PackageCandidate => {
const existing = packageCandidates.get(id);
if (existing) return existing;
const existed = budget.packageByteCounts.has(id);
const candidate = {
id,
existed,
oldByteCount: budget.packageByteCounts.get(id) ?? 0,
baseByteCount: budget.packageBaseByteCounts.get(id) ?? 0,
linkByteTotal: budget.packageLinkByteTotals.get(id) ?? 0,
linkCount: budget.packageLinkCounts.get(id) ?? 0,
removed: false
};
packageCandidates.set(id, candidate);
return candidate;
};
for (const change of delta.links) {
const url = normalizedUrl(change.url);
const previousPackageId = budget.linkPackageIds.get(url) ?? change.previousPackageId;
const previousByteCount = budget.linkByteCounts.get(url);
if (previousPackageId && previousByteCount !== undefined) {
const previousPackage = packageCandidate(previousPackageId);
previousPackage.linkByteTotal -= previousByteCount;
previousPackage.linkCount -= 1;
}
if (change.nextPackageId && change.nextLink) {
const canReuse = previousByteCount !== undefined
&& change.previousLink !== null
&& samePersistentLink(change.previousLink, change.nextLink);
const byteCount = canReuse ? previousByteCount : linkByteCount(change.nextLink);
const nextPackage = packageCandidate(change.nextPackageId);
nextPackage.linkByteTotal += byteCount;
nextPackage.linkCount += 1;
linkCandidates.set(url, { byteCount, id: change.nextLink.id, packageId: change.nextPackageId });
} else {
linkCandidates.set(url, null);
}
}
const removedPackageIds = new Set(delta.removedPackageIds);
for (const change of delta.packages) {
const candidate = packageCandidate(change.package.id);
candidate.baseByteCount = packageBaseByteCount(change.package);
candidate.linkCount = change.linkCount;
candidate.removed = false;
removedPackageIds.delete(change.package.id);
}
for (const id of removedPackageIds) packageCandidate(id).removed = true;
let packageByteTotal = budget.packageByteTotal;
const nextPackageByteCounts = new Map<string, number>();
for (const candidate of packageCandidates.values()) {
if (candidate.existed) packageByteTotal -= candidate.oldByteCount;
if (candidate.removed) continue;
const byteCount = completePackageByteCount(candidate.baseByteCount, candidate.linkByteTotal, candidate.linkCount);
nextPackageByteCounts.set(candidate.id, byteCount);
packageByteTotal += byteCount;
}
const byteCount = totalByteCount(packageByteTotal, delta.packageCount, collapsedPackageIds);
if (byteCount > maximumBytes) return { ok: false, byteCount, message: overflowMessage };
const nextBudget = budget as MutableCollectorPersistenceBudget;
for (const candidate of packageCandidates.values()) {
if (candidate.removed) {
nextBudget.packageByteCounts.delete(candidate.id);
nextBudget.packageBaseByteCounts.delete(candidate.id);
nextBudget.packageLinkByteTotals.delete(candidate.id);
nextBudget.packageLinkCounts.delete(candidate.id);
continue;
}
nextBudget.packageByteCounts.set(candidate.id, nextPackageByteCounts.get(candidate.id) ?? 0);
nextBudget.packageBaseByteCounts.set(candidate.id, candidate.baseByteCount);
nextBudget.packageLinkByteTotals.set(candidate.id, candidate.linkByteTotal);
nextBudget.packageLinkCounts.set(candidate.id, candidate.linkCount);
}
for (const [url, candidate] of linkCandidates) {
if (!candidate) {
nextBudget.linkByteCounts.delete(url);
nextBudget.linkPackageIds.delete(url);
nextBudget.linkIds.delete(url);
continue;
}
nextBudget.linkByteCounts.set(url, candidate.byteCount);
nextBudget.linkPackageIds.set(url, candidate.packageId);
nextBudget.linkIds.set(url, candidate.id);
}
nextBudget.byteCount = byteCount;
nextBudget.packageByteTotal = packageByteTotal;
nextBudget.packageCount = delta.packageCount;
return { ok: true, byteCount, nextBudget };
}
@@ -36,26 +36,54 @@ export function restoreCollectorPersistenceState(
export interface CollectorPersistenceCoordinator {
schedule: (state: CollectorPersistenceState) => void;
setBaseline: (state: CollectorPersistenceState) => void;
flush: () => Promise<void>;
dispose: () => void;
}
export interface CollectorPersistenceFailure {
error: unknown;
attemptedState: CollectorPersistenceState;
rollbackState: CollectorPersistenceState | null;
}
export function createCollectorPersistenceCoordinator(
save: (state: CollectorPersistenceState) => Promise<CollectorPersistenceState>,
delayMs = 300
delayMs = 300,
onFailure?: (failure: CollectorPersistenceFailure) => void
): CollectorPersistenceCoordinator {
let timer: ReturnType<typeof setTimeout> | null = null;
let pending: CollectorPersistenceState | null = null;
let running: Promise<void> | null = null;
let rollbackState: CollectorPersistenceState | null = null;
let disposed = false;
const reportFailure = (error: unknown, attemptedState: CollectorPersistenceState): void => {
try {
onFailure?.({ error, attemptedState, rollbackState });
} catch {
}
};
const drain = (): Promise<void> => {
if (running) return running;
const task = (async () => {
while (pending && !disposed) {
const state = pending;
const attemptedState = pending;
pending = null;
await save(state);
let state: CollectorPersistenceState;
try {
state = validateCollectorPersistenceState(attemptedState);
} catch (error) {
reportFailure(error, attemptedState);
continue;
}
try {
await save(state);
rollbackState = state;
} catch (error) {
reportFailure(error, attemptedState);
}
}
})().finally(() => {
if (running === task) running = null;
@@ -67,19 +95,30 @@ export function createCollectorPersistenceCoordinator(
return {
schedule: (state) => {
if (disposed) return;
pending = validateCollectorPersistenceState(state);
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
pending = state;
try {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
void drain();
}, Math.max(0, delayMs));
} catch {
timer = null;
void drain().catch(() => {});
}, Math.max(0, delayMs));
void drain();
}
},
setBaseline: (state) => {
rollbackState = state;
},
flush: async () => {
if (timer) {
clearTimeout(timer);
timer = null;
}
await drain();
while (!disposed) {
await drain();
if (!pending) break;
}
},
dispose: () => {
disposed = true;
+100 -8
View File
@@ -79,8 +79,99 @@ export interface DownloadsViewActions extends DownloadsTableActions {
onToggleClipboardWatcher: () => void;
onClearAll: () => void;
onToggleAllPackages: () => void;
onShowAllPackages: () => void;
}
onShowAllPackages: () => void;
}
export interface DownloadContextStartActionsProps {
actionBusy: boolean;
canStart: boolean;
showSelected: boolean;
selectedLabel: string;
onStartSelected: () => void;
onStartAll: () => void;
}
export async function runDownloadStartAction(
canStart: boolean,
action: () => Promise<void>,
onBlocked: () => void,
onError: (error: unknown) => void
): Promise<boolean> {
if (!canStart) {
onBlocked();
return false;
}
try {
await action();
return true;
} catch (error) {
onError(error);
return false;
}
}
export function resumeDownloadSession(
canStart: boolean,
togglePause: () => Promise<boolean>,
applyPaused: (paused: boolean) => void,
onBlocked: () => void,
onError: (error: unknown) => void
): Promise<boolean> {
return runDownloadStartAction(canStart, async () => {
applyPaused(await togglePause());
}, onBlocked, onError);
}
export async function pauseDownloadSession(
togglePause: () => Promise<boolean>,
applyPaused: (paused: boolean) => void,
onError: (error: unknown) => void
): Promise<boolean> {
try {
applyPaused(await togglePause());
return true;
} catch (error) {
onError(error);
return false;
}
}
export function runResumeDownloadAction(
runSingleFlight: (action: () => Promise<unknown>) => Promise<void>,
canStart: boolean,
togglePause: () => Promise<boolean>,
applyPaused: (paused: boolean) => void,
onBlocked: () => void,
onError: (error: unknown) => void
): Promise<void> {
return runSingleFlight(() => resumeDownloadSession(canStart, togglePause, applyPaused, onBlocked, onError));
}
export function runPauseDownloadAction(
runSingleFlight: (action: () => Promise<unknown>) => Promise<void>,
togglePause: () => Promise<boolean>,
applyPaused: (paused: boolean) => void,
onError: (error: unknown) => void
): Promise<void> {
return runSingleFlight(() => pauseDownloadSession(togglePause, applyPaused, onError));
}
export function DownloadContextStartActions({
actionBusy,
canStart,
showSelected,
selectedLabel,
onStartSelected,
onStartAll
}: DownloadContextStartActionsProps): ReactElement {
const disabled = !canStart || actionBusy;
return (
<>
{showSelected ? <button className="ctx-menu-item" disabled={disabled} onClick={onStartSelected}>{selectedLabel}</button> : null}
<button className="ctx-menu-item" disabled={disabled} onClick={onStartAll}>Alle Downloads starten</button>
</>
);
}
const filters: Array<{ id: DownloadSidebarFilter; label: string }> = [
{ id: "all", label: "Alle" },
@@ -95,7 +186,7 @@ export function DownloadsSidebar({ actions, model }: { actions: DownloadsViewAct
return (
<aside className="downloads-sidebar" data-visual-region="downloads-sidebar">
<SlidingSelection activeKey={model.filter} aria-label="Downloadfilter" as="nav" axis="vertical" className="downloads-filter-group">
{filters.map((filter) => <button aria-current={model.filter === filter.id ? "page" : undefined} className={model.filter === filter.id ? "is-active" : ""} data-sliding-selection-active={model.filter === filter.id} data-sliding-selection-item="true" key={filter.id} onClick={() => actions.onFilterChange(filter.id)} type="button"><span>{filter.label}</span><b>{model.counts[filter.id]}</b></button>)}
{filters.map((filter) => <button aria-current={model.filter === filter.id ? "page" : undefined} className={model.filter === filter.id ? "is-active" : ""} data-sliding-selection-active={model.filter === filter.id} data-sliding-selection-item="true" key={filter.id} onClick={() => actions.onFilterChange(filter.id)} type="button"><span>{filter.label}</span><b>{integerFormatter.format(model.counts[filter.id])}</b></button>)}
</SlidingSelection>
<label className="downloads-provider-filter"><span>Service</span><select aria-label="Service filtern" disabled={model.providerOptions.length <= 1} onChange={(event) => actions.onProviderFilterChange(event.target.value)} value={model.providerFilter}><option value="all">Alle Services</option>{model.providerOptions.map((provider) => <option key={provider.id} value={provider.id}>{provider.label}</option>)}</select></label>
<label className="downloads-sidebar-search"><span>Downloads durchsuchen</span><input className="downloads-search-input" onChange={(event) => actions.onQueryChange(event.target.value)} placeholder="Paket, Datei oder Service" type="search" value={model.query} /></label>
@@ -133,7 +224,7 @@ export function DownloadsToolbar({ actions, model }: { actions: DownloadsViewAct
return (
<div className="downloads-toolbar" data-visual-region="downloads-toolbar">
<button disabled={model.actionBusy || !model.canStart} onClick={actions.onStartDownloads} type="button">Start</button>
<button disabled={!model.canPause || model.paused} onClick={actions.onPauseDownloads} type="button">Pause</button>
<button disabled={model.actionBusy || !model.canPause || model.paused} onClick={actions.onPauseDownloads} type="button">Pause</button>
<button disabled={!model.canStop || model.actionBusy} onClick={actions.onStopDownloads} type="button">Stop</button>
{!model.scheduleActive ? <button aria-expanded={model.scheduleOpen} onClick={actions.onToggleSchedule} type="button">Zeitplan</button> : null}
<span className={scheduleSlotClass}>
@@ -149,15 +240,16 @@ export function DownloadsToolbar({ actions, model }: { actions: DownloadsViewAct
<button disabled={!onePackage} onClick={actions.onRenameSelection} type="button">Umbenennen</button>
<button disabled={!hasSelection} onClick={actions.onRemoveSelection} type="button">Entfernen</button>
<span aria-label="Paketdarstellung" className="downloads-toolbar-tail" role="group">
<button className="downloads-toolbar-toggle-all" disabled={model.empty} onClick={actions.onToggleAllPackages} type="button">Alle ein-/ausklappen</button>
<button className="downloads-toolbar-toggle-all" disabled={model.presentationEmpty} onClick={actions.onToggleAllPackages} type="button">Alle ein-/ausklappen</button>
</span>
</div>
);
}
function tableState(model: DownloadsViewModel): ReactElement | null {
if (model.empty) return <div className="downloads-empty-state" data-visual-region="downloads-empty-state" role="row"><div role="cell"><strong>Noch keine Downloads</strong><span>Füge Links hinzu, um den ersten Download zu starten.</span></div></div>;
if (model.filteredEmpty) return <div className="downloads-table-message" role="row"><div role="cell"><strong>Keine passenden Downloads</strong><span>Passe Filter oder Suche an.</span></div></div>;
function tableState(model: DownloadsViewModel): ReactElement | null {
if (model.empty) return <div className="downloads-empty-state" data-visual-region="downloads-empty-state" role="row"><div role="cell"><strong>Noch keine Downloads</strong><span>Füge Links hinzu, um den ersten Download zu starten.</span></div></div>;
if (model.presentationEmpty) return <div className="downloads-table-message" role="row"><div role="cell"><strong>Entpackte Downloads sind ausgeblendet</strong><span>Deaktiviere Entpackte Einträge ausblenden, um sie wieder anzuzeigen.</span></div></div>;
if (model.filteredEmpty) return <div className="downloads-table-message" role="row"><div role="cell"><strong>Keine passenden Downloads</strong><span>Passe Filter oder Suche an.</span></div></div>;
return null;
}
+92 -26
View File
@@ -55,11 +55,18 @@ export interface DownloadsViewModelCore {
mainRowCount: number;
totalMainRowCount: number;
paginationLabel: string;
limited: boolean;
empty: boolean;
limited: boolean;
sourceEmpty: boolean;
presentationEmpty: boolean;
empty: boolean;
filteredEmpty: boolean;
}
export interface DownloadByteSummary {
bytes: number;
unknownItems: number;
}
export type DownloadLogicalRow =
| (DownloadVirtualRowInput & { type: "package"; packageId: string; packageRow: DownloadPackageRow })
| (DownloadVirtualRowInput & { type: "item"; packageId: string; item: DownloadItem });
@@ -84,12 +91,18 @@ export function buildDownloadSidebarCounts(items: Iterable<DownloadItem>): Downl
return counts;
}
export function getDownloadQueueTotalBytes(items: Iterable<DownloadItem>): number {
let total = 0;
export function getDownloadQueueTotalBytes(items: Iterable<DownloadItem>): DownloadByteSummary {
let bytes = 0;
let unknownItems = 0;
for (const item of items) {
total += item.totalBytes || item.downloadedBytes || 0;
if (item.totalBytes && item.totalBytes > 0) {
bytes += item.totalBytes;
} else {
bytes += Math.max(0, item.downloadedBytes || 0);
unknownItems += 1;
}
}
return total;
return { bytes, unknownItems };
}
function isPendingDownloadItem(item: DownloadItem): boolean {
@@ -121,14 +134,14 @@ export function getRemainingDownloadBytes(items: Iterable<DownloadItem>): { byte
export function getDownloadQueueStatusMetrics(items: readonly DownloadItem[]): {
packageCount: number;
pendingItemCount: number;
totalBytes: number;
total: DownloadByteSummary;
remaining: { bytes: number; unknownItems: number };
hosterCount: number;
} {
return {
packageCount: new Set(items.map((item) => item.packageId)).size,
pendingItemCount: getPendingDownloadItemCount(items),
totalBytes: getDownloadQueueTotalBytes(items),
total: getDownloadQueueTotalBytes(items),
remaining: getRemainingDownloadBytes(items),
hosterCount: new Set(items.map((item) => extractHoster(item.url)).filter(Boolean)).size
};
@@ -147,6 +160,23 @@ export function formatRemainingDownloadTooltip(summary: { bytes: number; unknown
return `Noch unbekannte Dateigrößen: ${summary.unknownItems}. Die tatsächliche Restmenge kann höher sein.`;
}
export function formatDownloadEta(
remaining: DownloadByteSummary,
speedBps: number,
running: boolean,
paused: boolean
): string {
if (!running || paused || speedBps <= 0 || remaining.unknownItems > 0 || remaining.bytes <= 0) return "--";
const totalSeconds = Math.ceil(remaining.bytes / speedBps);
const seconds = totalSeconds % 60;
const totalMinutes = Math.floor(totalSeconds / 60);
const minutes = totalMinutes % 60;
const hours = Math.floor(totalMinutes / 60);
return hours > 0
? `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`
: `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
}
export function getDownloadSpeedBps(packageSpeeds: Record<string, number>): number {
let total = 0;
for (const speed of Object.values(packageSpeeds)) {
@@ -159,13 +189,43 @@ function isExtracted(item: DownloadItem): boolean {
return item.fullStatus.trim().toLocaleLowerCase("de-DE").startsWith("entpackt");
}
function matchesQuery(value: string | undefined, query: string): boolean {
return Boolean(value?.toLocaleLowerCase("de-DE").includes(query));
}
function matchesFilter(item: DownloadItem, filter: DownloadSidebarFilter): boolean {
return filter === "all" || classifyDownloadStatus(item.status) === filter;
}
function matchesQuery(value: string | undefined, query: string): boolean {
return Boolean(value?.toLocaleLowerCase("de-DE").includes(query));
}
function isExtractFailure(item: DownloadItem): boolean {
const status = item.fullStatus.trim();
return /^(?:Entpack(?:-|\s*)Fehler\b|Entpacken\b.*(?:\bFehler\b|\bError\b|fehlgeschlagen)|Extraction\b.*(?:\bError\b|failed))/i.test(status);
}
function classifyDownloadItem(item: DownloadItem, pkg: PackageEntry): DownloadSidebarFilter {
if (item.status === "failed" || isExtractFailure(item)) return "failed";
if (item.status === "cancelled") return "all";
if (isExtracted(item)) return "completed";
if ((pkg.status === "extracting" || pkg.status === "integrity_check") && item.status === "completed") return "active";
if (item.status === "completed") return "completed";
if (pkg.status === "failed") return "failed";
if (pkg.status === "paused") return "paused";
return classifyDownloadStatus(item.status);
}
function buildDownloadLifecycleCounts(packages: readonly PackageEntry[], items: Record<string, DownloadItem>, hideExtractedItems: boolean): DownloadFilterCounts {
const counts: DownloadFilterCounts = { all: 0, active: 0, queued: 0, paused: 0, completed: 0, failed: 0 };
for (const pkg of packages) {
for (const itemId of pkg.itemIds) {
const item = items[itemId];
if (!item || (hideExtractedItems && isExtracted(item))) continue;
counts.all += 1;
const category = classifyDownloadItem(item, pkg);
if (category !== "all") counts[category] += 1;
}
}
return counts;
}
function matchesFilter(item: DownloadItem, pkg: PackageEntry, filter: DownloadSidebarFilter): boolean {
return filter === "all" || classifyDownloadItem(item, pkg) === filter;
}
function matchesProvider(item: DownloadItem, providerFilter: string): boolean {
return providerFilter === "all" || item.provider === providerFilter;
@@ -194,11 +254,12 @@ export function buildDownloadsViewModel(input: DownloadsModelInput): DownloadsVi
const allItems = allPackages.flatMap((entry) => entry.itemIds.map((id) => input.items[id]).filter((item): item is DownloadItem => Boolean(item)));
const eligibleItems = input.hideExtractedItems ? allItems.filter((item) => !isExtracted(item)) : allItems;
const eligiblePackageCount = new Set(eligibleItems.map((item) => item.packageId)).size;
const counts = buildDownloadSidebarCounts(eligibleItems);
const counts = buildDownloadLifecycleCounts(allPackages, input.items, input.hideExtractedItems);
const providerMap = new Map<string, string>();
for (const entry of eligibleItems) {
if (entry.provider) providerMap.set(entry.provider, entry.providerLabel?.trim() || entry.provider);
}
if (entry.provider) providerMap.set(entry.provider, entry.providerLabel?.trim() || entry.provider);
}
const providerFilter = input.providerFilter === "all" || providerMap.has(input.providerFilter) ? input.providerFilter : "all";
const query = input.query.trim().toLocaleLowerCase("de-DE");
const collapsed = new Set(input.collapsedPackageIds);
@@ -218,13 +279,13 @@ export function buildDownloadsViewModel(input: DownloadsModelInput): DownloadsVi
|| matchesQuery(item.providerAccountLabel, query)
|| matchesQuery(item.fullStatus, query)
|| matchesQuery(item.lastError, query);
return matchesFilter(item, input.filter) && matchesProvider(item, input.providerFilter) && (packageMatchesQuery || itemMatchesQuery);
});
return matchesFilter(item, entry, input.filter) && matchesProvider(item, providerFilter) && (packageMatchesQuery || itemMatchesQuery);
});
if (matchingItems.length === 0) return [];
const visibleItems = packageMatchesQuery && query !== ""
? items.filter((item) => matchesFilter(item, input.filter) && matchesProvider(item, input.providerFilter))
? items.filter((item) => matchesFilter(item, entry, input.filter) && matchesProvider(item, providerFilter))
: matchingItems;
return [{ package: entry, items: visibleItems, allItems: items, collapsed: collapsed.has(entry.id) }];
return [{ package: entry, items: visibleItems, allItems: allPackageItems, collapsed: collapsed.has(entry.id) }];
});
const totalPackageRows = packageRows.length;
@@ -247,10 +308,13 @@ export function buildDownloadsViewModel(input: DownloadsModelInput): DownloadsVi
? fileRows.length
: totalPackageRows;
return {
const sourceEmpty = allItems.length === 0;
const presentationEmpty = eligibleItems.length === 0;
return {
displayMode: input.displayMode,
filter: input.filter,
providerFilter: input.providerFilter,
providerFilter,
providerOptions: [...providerMap].map(([id, label]) => ({ id, label })).sort((left, right) => left.label.localeCompare(right.label, "de")),
query: input.query,
counts,
@@ -267,7 +331,9 @@ export function buildDownloadsViewModel(input: DownloadsModelInput): DownloadsVi
totalMainRowCount,
paginationLabel: paginationLabel(mainRowCount, totalMainRowCount),
limited: false,
empty: eligibleItems.length === 0,
filteredEmpty: eligibleItems.length > 0 && mainRowCount === 0
sourceEmpty,
presentationEmpty,
empty: sourceEmpty,
filteredEmpty: !sourceEmpty && mainRowCount === 0
};
}
+53 -28
View File
@@ -20,11 +20,12 @@ import { Toolbar, ToolbarGroup, ToolbarSearch } from "../../ui/Toolbar";
import { SlidingSelection } from "../../ui/SlidingSelection";
import {
createHistoryTableColumnWidths,
formatHistoryInteger,
formatHistoryDuration,
getHistoryPage,
getHistoryTableGridTemplate,
getHistoryTableMinWidth,
HISTORY_TABLE_COLUMN_IDS,
paginateHistoryRows,
resizeHistoryTableColumn,
type HistoryFilter,
type HistoryPage,
@@ -33,7 +34,7 @@ import {
type HistoryTableColumnWidths,
type HistoryViewModel
} from "./history-model";
import "./history.css";
import "./history.css";
export interface HistoryViewActions {
onFilterChange: (filter: HistoryFilter) => void;
@@ -45,9 +46,11 @@ export interface HistoryViewActions {
onReveal: (entryId: string) => void;
onRemove: (entryIds: string[]) => void;
onClearSelection: () => void;
onClearHistory: () => void;
onContextMenu: (entryId: string, x: number, y: number) => void;
}
onClearHistory: () => void;
onContextMenu: (entryId: string, x: number, y: number) => void;
onVisiblePageCountChange?: (count: number) => void;
onVisiblePageChange?: (ids: string[]) => void;
}
export interface HistoryViewProps {
model: HistoryViewModel;
@@ -191,7 +194,7 @@ function HistoryRowDetails({
<div className="history-detail-cell" role="cell">
<dl className="history-details-grid">
<div><dt>Provider</dt><dd>{row.providerLabel}</dd></div>
<div><dt>Dateien</dt><dd>{row.fileCount}</dd></div>
<div><dt>Dateien</dt><dd>{formatHistoryInteger(row.fileCount, row.language)}</dd></div>
{row.hasStructuredLifecycle ? (
<>
<div><dt>Download gestartet</dt><dd>{row.startedLabel}</dd></div>
@@ -204,8 +207,8 @@ function HistoryRowDetails({
<div><dt>Nachbearbeitungsdauer</dt><dd>{row.postProcessDurationLabel}</dd></div>
<div><dt>Gesamtdauer</dt><dd>{row.totalDurationLabel}</dd></div>
<div><dt>Status</dt><dd>{row.statusLabel}</dd></div>
<div><dt>Erfolgreich / Fehlgeschlagen / Abgebrochen</dt><dd>{row.successfulFiles ?? 0} / {row.failedFiles ?? 0} / {row.cancelledFiles ?? 0}</dd></div>
<div><dt>Archive / Parts / Ausgaben</dt><dd>{row.archiveCount ?? 0} / {row.partCount ?? 0} / {row.outputCount ?? 0}</dd></div>
<div><dt>Erfolgreich / Fehlgeschlagen / Abgebrochen</dt><dd>{formatHistoryInteger(row.successfulFiles ?? 0, row.language)} / {formatHistoryInteger(row.failedFiles ?? 0, row.language)} / {formatHistoryInteger(row.cancelledFiles ?? 0, row.language)}</dd></div>
<div><dt>Archive / Parts / Ausgaben</dt><dd>{formatHistoryInteger(row.archiveCount ?? 0, row.language)} / {formatHistoryInteger(row.partCount ?? 0, row.language)} / {formatHistoryInteger(row.outputCount ?? 0, row.language)}</dd></div>
<div><dt>Fehlerphase</dt><dd>{row.failurePhaseLabel}</dd></div>
<div><dt>Fehlerkategorie</dt><dd>{row.errorCategory || "—"}</dd></div>
<div><dt>Download / Offline / Entpacken / Remux / Cleanup / Nachbearbeitung</dt><dd>{row.failureCountsLabel}</dd></div>
@@ -226,7 +229,7 @@ function HistoryRowDetails({
{row.archiveOperations.map((operation) => (
<li key={operation.id}>
<strong>{operation.name}</strong>
<span>{operation.partCount} Parts · {formatHistoryDuration(operation.durationMs / 1000)} · {operationStatusLabel(operation.status)}{operation.errorCategory ? ` · ${operation.errorCategory}` : ""}</span>
<span>{formatHistoryInteger(operation.partCount, row.language)} Parts · {formatHistoryDuration(operation.durationMs / 1000)} · {operationStatusLabel(operation.status)}{operation.errorCategory ? ` · ${operation.errorCategory}` : ""}</span>
</li>
))}
</ul>
@@ -270,7 +273,7 @@ export function HistorySidebar({ model, actions }: HistoryViewProps): ReactEleme
type="button"
>
<span>{item.label}</span>
<span>{model.counts[item.id]}</span>
<span>{formatHistoryInteger(model.counts[item.id], model.language)}</span>
</button>
))}
</SlidingSelection>
@@ -284,14 +287,12 @@ export function HistorySidebar({ model, actions }: HistoryViewProps): ReactEleme
);
}
export function HistoryToolbar({ model, actions }: HistoryViewProps): ReactElement {
const selectedIds = model.selectedIds;
const selectedSet = new Set(selectedIds);
const restorable = model.rows.some((row) => selectedSet.has(row.id) && (row.urls?.length ?? 0) > 0);
return (
export function HistoryToolbar({ model, actions }: HistoryViewProps): ReactElement {
const selectedIds = model.selectedIds;
return (
<Toolbar className="history-workspace-toolbar" data-visual-region="history-toolbar" label="Verlaufsaktionen">
<ToolbarGroup label="Einträge">
<button className="history-action" disabled={selectedIds.length === 0 || !restorable} onClick={() => actions.onRestore(selectedIds)} type="button">Erneut hinzufügen</button>
<button className="history-action" disabled={selectedIds.length === 0 || !model.restorableSelected} onClick={() => actions.onRestore(selectedIds)} type="button">Erneut hinzufügen</button>
<button className="history-action" disabled={selectedIds.length !== 1} onClick={() => actions.onReveal(selectedIds[0])} type="button">Im Ordner zeigen</button>
<button className="history-action history-action-danger" disabled={selectedIds.length === 0} onClick={() => actions.onRemove(selectedIds)} type="button">Entfernen</button>
<button className="history-action" disabled={selectedIds.length === 0} onClick={actions.onClearSelection} type="button">Auswahl löschen</button>
@@ -308,7 +309,10 @@ export function HistoryToolbar({ model, actions }: HistoryViewProps): ReactEleme
}
export function historyPageStatusLabel(page: HistoryPage): string {
return `Seite ${page.page} von ${page.totalPages}`;
const language = page.language ?? "de";
return language === "en"
? `Page ${formatHistoryInteger(page.page, language)} of ${formatHistoryInteger(page.totalPages, language)}`
: `Seite ${formatHistoryInteger(page.page, language)} von ${formatHistoryInteger(page.totalPages, language)}`;
}
export function HistoryPagination({
@@ -317,10 +321,11 @@ export function HistoryPagination({
}: {
page: HistoryPage;
onPageChange: (page: number) => void;
}): ReactElement {
return (
<nav aria-label="Verlaufsseiten" className="history-pagination" data-visual-region="history-pagination">
<span className="history-pagination-size">{page.pageSize} pro Seite</span>
}): ReactElement {
const language = page.language ?? "de";
return (
<nav aria-label="Verlaufsseiten" className="history-pagination" data-visual-region="history-pagination">
<span className="history-pagination-size">{formatHistoryInteger(page.pageSize, language)} {language === "en" ? "per page" : "pro Seite"}</span>
<div className="history-pagination-controls">
<button
aria-label="Vorherige Verlaufsseite"
@@ -341,8 +346,24 @@ export function HistoryPagination({
</div>
</nav>
);
}
}
export function reportHistoryVisiblePageCount(
actions: Pick<HistoryViewActions, "onVisiblePageCountChange">,
page: HistoryPage
): void {
actions.onVisiblePageCountChange?.(page.rows.length);
}
export function reportHistoryVisiblePage(
actions: Pick<HistoryViewActions, "onVisiblePageChange" | "onVisiblePageCountChange">,
page: HistoryPage
): void {
const ids = page.rows.map((row) => row.id);
actions.onVisiblePageCountChange?.(ids.length);
actions.onVisiblePageChange?.(ids);
}
interface HistoryContentPageProps extends HistoryViewProps {
page: HistoryPage;
onPageChange: (page: number) => void;
@@ -514,11 +535,15 @@ export function HistoryContentPage({ model, actions, page, onPageChange }: Histo
);
}
function PaginatedHistoryContent({ model, actions }: HistoryViewProps): ReactElement {
const [requestedPage, setRequestedPage] = useState(1);
const page = paginateHistoryRows(model.rows, requestedPage);
useEffect(() => {
function PaginatedHistoryContent({ model, actions }: HistoryViewProps): ReactElement {
const [requestedPage, setRequestedPage] = useState(1);
const page = getHistoryPage(model, requestedPage);
useEffect(() => {
reportHistoryVisiblePage(actions, page);
}, [actions.onVisiblePageChange, actions.onVisiblePageCountChange, page]);
useEffect(() => {
setRequestedPage((current) => current === page.page ? current : page.page);
}, [page.page]);
+287 -94
View File
@@ -3,8 +3,10 @@ import type { DebridProvider, HistoryEntry } from "../../../shared/types";
export type HistoryFilter = "all" | "today" | "week" | "older" | "completed" | "partial" | "failed" | "cancelled" | "deleted";
export type HistoryViewStatus = HistoryEntry["status"] | "failed";
export type HistoryViewEntry = Omit<HistoryEntry, "status"> & { status: HistoryViewStatus };
export type HistoryLanguage = "de" | "en";
export interface HistoryRow extends HistoryViewEntry {
language: HistoryLanguage;
hoster: string;
providerLabel: string;
startAt: number;
@@ -39,7 +41,8 @@ export interface HistoryFilterCounts {
}
export interface HistoryViewModel {
rows: HistoryRow[];
rows: readonly HistoryViewEntry[];
visibleIds: readonly string[];
filter: HistoryFilter;
query: string;
selectedIds: string[];
@@ -49,6 +52,9 @@ export interface HistoryViewModel {
error: string;
totalCount: number;
animationsEnabled: boolean;
language: HistoryLanguage;
restorableSelected: boolean;
pageSource: HistoryPageSource;
}
export interface HistoryPage {
@@ -58,6 +64,12 @@ export interface HistoryPage {
totalItems: number;
totalPages: number;
rangeLabel: string;
language?: HistoryLanguage;
}
export interface HistoryPageSource {
entries: readonly HistoryViewEntry[];
language: HistoryLanguage;
}
export const HISTORY_PAGE_SIZE = 100;
@@ -127,28 +139,57 @@ const providerLabels: Record<DebridProvider, string> = {
linksnappy: "LinkSnappy"
};
const statusLabels: Record<HistoryViewStatus, string> = {
completed: "Abgeschlossen",
partial: "Teilweise",
cancelled: "Abgebrochen",
deleted: "Gelöscht",
failed: "Fehlgeschlagen"
const statusLabels: Record<HistoryLanguage, Record<HistoryViewStatus, string>> = {
de: {
completed: "Abgeschlossen",
partial: "Teilweise",
cancelled: "Abgebrochen",
deleted: "Gelöscht",
failed: "Fehlgeschlagen"
},
en: {
completed: "Completed",
partial: "Partial",
cancelled: "Cancelled",
deleted: "Deleted",
failed: "Failed"
}
};
const numberFormatter = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 1 });
const integerFormatter = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 0 });
const dateFormatter = new Intl.DateTimeFormat("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit"
});
const locales: Record<HistoryLanguage, string> = { de: "de-DE", en: "en-US" };
const numberFormatters: Record<HistoryLanguage, Intl.NumberFormat> = {
de: new Intl.NumberFormat(locales.de, { maximumFractionDigits: 1 }),
en: new Intl.NumberFormat(locales.en, { maximumFractionDigits: 1 })
};
const integerFormatters: Record<HistoryLanguage, Intl.NumberFormat> = {
de: new Intl.NumberFormat(locales.de, { maximumFractionDigits: 0 }),
en: new Intl.NumberFormat(locales.en, { maximumFractionDigits: 0 })
};
const dateFormatters: Record<HistoryLanguage, Intl.DateTimeFormat> = {
de: new Intl.DateTimeFormat(locales.de, {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit"
}),
en: new Intl.DateTimeFormat(locales.en, {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit"
})
};
function formatBytes(bytes: number): string {
export function formatHistoryInteger(value: number, language: HistoryLanguage = "de"): string {
return integerFormatters[language].format(value);
}
function formatBytes(bytes: number, language: HistoryLanguage): string {
const safe = Math.max(0, Number.isFinite(bytes) ? bytes : 0);
if (safe < 1024) {
return `${Math.round(safe)} B`;
return `${formatHistoryInteger(Math.round(safe), language)} B`;
}
const units = ["KB", "MB", "GB", "TB", "PB"];
let value = safe / 1024;
@@ -157,7 +198,7 @@ function formatBytes(bytes: number): string {
value /= 1024;
unitIndex += 1;
}
return `${numberFormatter.format(value)} ${units[unitIndex]}`;
return `${numberFormatters[language].format(value)} ${units[unitIndex]}`;
}
export function formatHistoryDuration(durationSeconds: number): string {
@@ -171,21 +212,21 @@ export function formatHistoryDuration(durationSeconds: number): string {
return `${minutes}:${String(seconds).padStart(2, "0")}`;
}
function formatTimestamp(timestamp: number | undefined): string {
function formatTimestamp(timestamp: number | undefined, language: HistoryLanguage): string {
const safe = Math.max(0, Number.isFinite(timestamp) ? Number(timestamp) : 0);
return safe > 0 ? dateFormatter.format(new Date(safe)) : "—";
return safe > 0 ? dateFormatters[language].format(new Date(safe)) : "—";
}
function failurePhaseLabel(entry: HistoryViewEntry): string {
function failurePhaseLabel(entry: HistoryViewEntry, language: HistoryLanguage): string {
if (entry.failurePhase === "download") return "Download";
if (entry.failurePhase === "extract") return "Entpacken";
if (entry.failurePhase === "extract") return language === "de" ? "Entpacken" : "Extraction";
if (entry.failurePhase === "remux") return "Remux";
if (entry.failurePhase === "cleanup") return "Aufräumen";
if (entry.failurePhase === "postprocess") return "Nachbearbeitung";
if (entry.failurePhase === "cleanup") return language === "de" ? "Aufräumen" : "Cleanup";
if (entry.failurePhase === "postprocess") return language === "de" ? "Nachbearbeitung" : "Post-processing";
return "—";
}
function failureCountsLabel(entry: HistoryViewEntry): string {
function failureCountsLabel(entry: HistoryViewEntry, language: HistoryLanguage): string {
const values = [
entry.downloadFailures,
entry.offlineFailures,
@@ -196,61 +237,103 @@ function failureCountsLabel(entry: HistoryViewEntry): string {
];
return values.every((value) => value === undefined)
? "—"
: values.map((value) => Math.max(0, Number(value) || 0)).join(" / ");
: values.map((value) => formatHistoryInteger(Math.max(0, Number(value) || 0), language)).join(" / ");
}
export function paginateHistoryRows(rows: HistoryRow[], requestedPage: number): HistoryPage {
const totalItems = rows.length;
function createHistoryPage(source: HistoryPageSource, requestedPage: number): HistoryPage {
const totalItems = source.entries.length;
const totalPages = Math.max(1, Math.ceil(totalItems / HISTORY_PAGE_SIZE));
const normalizedPage = Number.isFinite(requestedPage) ? Math.trunc(requestedPage) : 1;
const page = Math.min(totalPages, Math.max(1, normalizedPage));
const startIndex = (page - 1) * HISTORY_PAGE_SIZE;
const endIndex = Math.min(totalItems, startIndex + HISTORY_PAGE_SIZE);
const rangeLabel = totalItems === 0
? "0 von 0"
: `${integerFormatter.format(startIndex + 1)}${integerFormatter.format(endIndex)} von ${integerFormatter.format(totalItems)}`;
? source.language === "de" ? "0 von 0" : "0 of 0"
: source.language === "de"
? `${formatHistoryInteger(startIndex + 1, source.language)}${formatHistoryInteger(endIndex, source.language)} von ${formatHistoryInteger(totalItems, source.language)}`
: `${formatHistoryInteger(startIndex + 1, source.language)}${formatHistoryInteger(endIndex, source.language)} of ${formatHistoryInteger(totalItems, source.language)}`;
return {
rows: rows.slice(startIndex, endIndex),
rows: source.entries.slice(startIndex, endIndex).map((entry) => toHistoryRow(entry, source.language)),
page,
pageSize: HISTORY_PAGE_SIZE,
totalItems,
totalPages,
rangeLabel
rangeLabel,
language: source.language
};
}
const historyPageCaches = new WeakMap<HistoryPageSource, Map<number, HistoryPage>>();
export function getHistoryPage(model: Pick<HistoryViewModel, "pageSource">, requestedPage: number): HistoryPage {
const source = model.pageSource;
const totalPages = Math.max(1, Math.ceil(source.entries.length / HISTORY_PAGE_SIZE));
const normalizedRequest = Number.isFinite(requestedPage) ? Math.trunc(requestedPage) : 1;
const page = Math.min(totalPages, Math.max(1, normalizedRequest));
let pages = historyPageCaches.get(source);
if (!pages) {
pages = new Map();
historyPageCaches.set(source, pages);
}
const cached = pages.get(page);
if (cached) return cached;
const projected = createHistoryPage(source, page);
pages.set(page, projected);
return projected;
}
export function paginateHistoryRows(
rows: readonly HistoryViewEntry[],
requestedPage: number,
language: HistoryLanguage = "de"
): HistoryPage {
return createHistoryPage({ entries: rows, language }, requestedPage);
}
function localDayStart(timestamp: number): number {
const date = new Date(timestamp);
date.setHours(0, 0, 0, 0);
return date.getTime();
}
function matchesTemporalFilter(entry: HistoryViewEntry, filter: HistoryFilter, now: number): boolean {
interface HistoryTimeWindow {
todayStart: number;
tomorrowStart: number;
weekStart: number;
}
function createHistoryTimeWindow(now: number): HistoryTimeWindow {
const todayStart = localDayStart(now);
const tomorrowStartDate = new Date(todayStart);
tomorrowStartDate.setDate(tomorrowStartDate.getDate() + 1);
const weekStartDate = new Date(todayStart);
weekStartDate.setDate(weekStartDate.getDate() - 6);
return {
todayStart,
tomorrowStart: tomorrowStartDate.getTime(),
weekStart: weekStartDate.getTime()
};
}
function matchesTemporalFilter(entry: HistoryViewEntry, filter: HistoryFilter, window: HistoryTimeWindow): boolean {
if (filter === "completed" || filter === "partial" || filter === "failed" || filter === "cancelled" || filter === "deleted") {
return entry.status === filter;
}
if (filter === "all") {
return true;
}
const todayStart = localDayStart(now);
const tomorrowStartDate = new Date(todayStart);
tomorrowStartDate.setDate(tomorrowStartDate.getDate() + 1);
const tomorrowStart = tomorrowStartDate.getTime();
const weekStartDate = new Date(todayStart);
weekStartDate.setDate(weekStartDate.getDate() - 6);
const weekStart = weekStartDate.getTime();
if (filter === "today") {
return entry.completedAt >= todayStart && entry.completedAt < tomorrowStart;
return entry.completedAt >= window.todayStart && entry.completedAt < window.tomorrowStart;
}
if (filter === "week") {
return entry.completedAt >= weekStart && entry.completedAt < todayStart;
return entry.completedAt >= window.weekStart && entry.completedAt < window.tomorrowStart;
}
return entry.completedAt < weekStart;
return entry.completedAt < window.weekStart;
}
function normalizeSearch(value: string): string {
return value.trim().toLocaleLowerCase("de-DE");
function normalizeSearch(value: string, language: HistoryLanguage): string {
return value.trim().toLocaleLowerCase(locales[language]);
}
export function deriveHistoryHoster(urls: string[] | undefined): string {
@@ -285,7 +368,7 @@ export function deriveHistoryStartAt(entry: Pick<HistoryViewEntry, "completedAt"
return Math.max(0, completedAt - durationMs);
}
function toHistoryRow(entry: HistoryViewEntry): HistoryRow {
function toHistoryRow(entry: HistoryViewEntry, language: HistoryLanguage): HistoryRow {
const hoster = deriveHistoryHoster(entry.urls);
const providerLabel = entry.provider ? providerLabels[entry.provider] : "—";
const startAt = deriveHistoryStartAt(entry);
@@ -297,69 +380,153 @@ function toHistoryRow(entry: HistoryViewEntry): HistoryRow {
|| entry.totalDurationSeconds !== undefined;
return {
...entry,
language,
hoster,
providerLabel,
startAt,
sizeLabel: `${formatBytes(entry.downloadedBytes)} / ${formatBytes(entry.totalBytes)}`,
startedLabel: formatTimestamp(startAt),
completedLabel: formatTimestamp(entry.completedAt),
sizeLabel: `${formatBytes(entry.downloadedBytes, language)} / ${formatBytes(entry.totalBytes, language)}`,
startedLabel: formatTimestamp(startAt, language),
completedLabel: formatTimestamp(entry.completedAt, language),
durationLabel: formatHistoryDuration(downloadDurationSeconds),
averageSpeedLabel: downloadDurationSeconds > 0 ? `${formatBytes(averageBytesPerSecond)}/s` : "—",
statusLabel: statusLabels[entry.status],
averageSpeedLabel: downloadDurationSeconds > 0 ? `${formatBytes(averageBytesPerSecond, language)}/s` : "—",
statusLabel: statusLabels[language][entry.status],
hasStructuredLifecycle,
downloadEndedLabel: formatTimestamp(entry.downloadEndedAt),
postProcessStartedLabel: formatTimestamp(entry.postProcessStartedAt),
downloadEndedLabel: formatTimestamp(entry.downloadEndedAt, language),
postProcessStartedLabel: formatTimestamp(entry.postProcessStartedAt, language),
downloadDurationLabel: formatHistoryDuration(entry.downloadDurationSeconds ?? 0),
extractionDurationLabel: formatHistoryDuration(entry.extractionDurationSeconds ?? 0),
remuxDurationLabel: formatHistoryDuration(entry.remuxDurationSeconds ?? 0),
postProcessDurationLabel: formatHistoryDuration(entry.postProcessDurationSeconds ?? 0),
totalDurationLabel: formatHistoryDuration(entry.totalDurationSeconds ?? 0),
failurePhaseLabel: failurePhaseLabel(entry),
failureCountsLabel: failureCountsLabel(entry)
failurePhaseLabel: failurePhaseLabel(entry, language),
failureCountsLabel: failureCountsLabel(entry, language)
};
}
export function filterHistoryRows(
entries: HistoryViewEntry[],
entries: readonly HistoryViewEntry[],
filter: HistoryFilter,
query: string,
now = Date.now()
now = Date.now(),
language: HistoryLanguage = "de"
): HistoryRow[] {
const normalizedQuery = normalizeSearch(query);
return entries
.filter((entry) => matchesTemporalFilter(entry, filter, now))
.map(toHistoryRow)
.filter((row) => {
if (!normalizedQuery) {
return true;
}
const searchable = [
row.name,
row.outputDir,
row.hoster,
row.providerLabel,
...(row.urls ?? [])
].join("\n").toLocaleLowerCase("de-DE");
return searchable.includes(normalizedQuery);
});
return getHistoryAnalysis(entries, filter, query, now, language).match.entries.map((entry) => toHistoryRow(entry, language));
}
function countHistoryFilters(entries: HistoryViewEntry[], now: number): HistoryFilterCounts {
interface HistoryMatchAnalysis {
entries: HistoryViewEntry[];
ids: string[];
entriesById: Map<string, HistoryViewEntry>;
pageSource: HistoryPageSource;
}
interface HistorySourceAnalysis {
dayStart: number;
counts: HistoryFilterCounts;
searchTexts: string[];
matchKey: string;
match: HistoryMatchAnalysis;
}
const historySourceCache = new WeakMap<readonly HistoryViewEntry[], HistorySourceAnalysis>();
const emptyHistoryEntries: readonly HistoryViewEntry[] = [];
function createHistoryFilterCounts(): HistoryFilterCounts {
return {
all: entries.length,
today: entries.filter((entry) => matchesTemporalFilter(entry, "today", now)).length,
week: entries.filter((entry) => matchesTemporalFilter(entry, "week", now)).length,
older: entries.filter((entry) => matchesTemporalFilter(entry, "older", now)).length,
completed: entries.filter((entry) => entry.status === "completed").length,
partial: entries.filter((entry) => entry.status === "partial").length,
cancelled: entries.filter((entry) => entry.status === "cancelled").length,
deleted: entries.filter((entry) => entry.status === "deleted").length,
failed: entries.filter((entry) => entry.status === "failed").length
all: 0,
today: 0,
week: 0,
older: 0,
completed: 0,
partial: 0,
cancelled: 0,
deleted: 0,
failed: 0
};
}
function createHistorySearchText(entry: HistoryViewEntry, language: HistoryLanguage): string {
return [
entry.name,
entry.outputDir,
entry.provider ? providerLabels[entry.provider] : "",
...(entry.urls ?? [])
].join("\n").toLocaleLowerCase(locales[language]);
}
function createHistoryMatchAnalysis(
entries: readonly HistoryViewEntry[],
filter: HistoryFilter,
normalizedQuery: string,
window: HistoryTimeWindow,
language: HistoryLanguage,
searchTexts: string[],
counts?: HistoryFilterCounts,
populateSearchTexts = false
): HistoryMatchAnalysis {
const matchingEntries: HistoryViewEntry[] = [];
const ids: string[] = [];
const entriesById = new Map<string, HistoryViewEntry>();
for (let index = 0; index < entries.length; index += 1) {
const entry = entries[index];
const searchText = populateSearchTexts ? createHistorySearchText(entry, language) : searchTexts[index];
if (populateSearchTexts) searchTexts.push(searchText);
if (counts) {
counts.all += 1;
if (entry.completedAt >= window.todayStart && entry.completedAt < window.tomorrowStart) counts.today += 1;
if (entry.completedAt >= window.weekStart && entry.completedAt < window.tomorrowStart) counts.week += 1;
if (entry.completedAt < window.weekStart) counts.older += 1;
counts[entry.status] += 1;
}
if (!matchesTemporalFilter(entry, filter, window) || (normalizedQuery && !searchText.includes(normalizedQuery))) continue;
matchingEntries.push(entry);
ids.push(entry.id);
entriesById.set(entry.id, entry);
}
return {
entries: matchingEntries,
ids,
entriesById,
pageSource: { entries: matchingEntries, language }
};
}
function getHistoryAnalysis(
entries: readonly HistoryViewEntry[],
filter: HistoryFilter,
query: string,
now: number,
language: HistoryLanguage
): HistorySourceAnalysis {
const window = createHistoryTimeWindow(now);
const normalizedQuery = normalizeSearch(query, language);
const matchKey = `${filter}\u0000${language}\u0000${normalizedQuery}`;
const cached = historySourceCache.get(entries);
if (cached?.dayStart === window.todayStart) {
if (cached.matchKey === matchKey) return cached;
const next = {
...cached,
matchKey,
match: createHistoryMatchAnalysis(entries, filter, normalizedQuery, window, language, cached.searchTexts)
};
historySourceCache.set(entries, next);
return next;
}
const counts = createHistoryFilterCounts();
const searchTexts: string[] = [];
const analysis = {
dayStart: window.todayStart,
counts,
searchTexts,
matchKey,
match: createHistoryMatchAnalysis(entries, filter, normalizedQuery, window, language, searchTexts, counts, true)
};
historySourceCache.set(entries, analysis);
return analysis;
}
export function buildHistoryViewModel(
entries: HistoryViewEntry[],
entries: readonly HistoryViewEntry[],
filter: HistoryFilter,
query: string,
selectedIds: Iterable<string>,
@@ -367,21 +534,34 @@ export function buildHistoryViewModel(
loading: boolean,
error: string,
now = Date.now(),
animationsEnabled = true
animationsEnabled = true,
language: HistoryLanguage = "de"
): HistoryViewModel {
const rows = filterHistoryRows(entries, filter, query, now);
const visibleIds = new Set(rows.map((row) => row.id));
const availableEntries = loading || error ? emptyHistoryEntries : entries;
const analysis = getHistoryAnalysis(availableEntries, filter, query, now, language);
const selected: string[] = [];
let restorableSelected = false;
for (const id of selectedIds) {
const entry = analysis.match.entriesById.get(id);
if (!entry) continue;
selected.push(id);
if ((entry.urls?.length ?? 0) > 0) restorableSelected = true;
}
return {
rows,
rows: analysis.match.entries,
visibleIds: analysis.match.ids,
filter,
query,
selectedIds: [...selectedIds].filter((id) => visibleIds.has(id)),
selectedIds: selected,
expandedIds: [...expandedIds],
counts: countHistoryFilters(entries, now),
counts: analysis.counts,
loading,
error,
totalCount: entries.length,
animationsEnabled
totalCount: availableEntries.length,
animationsEnabled,
language,
restorableSelected,
pageSource: analysis.match.pageSource
};
}
@@ -402,3 +582,16 @@ export function pruneHistoryIds(current: Set<string>, availableIds: Iterable<str
export function selectVisibleHistoryIds(visibleIds: Iterable<string>): Set<string> {
return new Set(visibleIds);
}
export function toggleHistoryPageSelection(current: ReadonlySet<string>, visibleIds: readonly string[]): Set<string> {
const allVisibleSelected = visibleIds.length > 0 && visibleIds.every((id) => current.has(id));
const next = new Set(current);
for (const id of visibleIds) {
if (allVisibleSelected) {
next.delete(id);
} else {
next.add(id);
}
}
return next;
}
@@ -4,6 +4,7 @@ import { ACCOUNT_SERVICE_ICONS } from "../../account-service-icons";
export type SettingsSection = "allgemein" | "accounts" | "extract" | "speed" | "cleanup" | "updates";
export type SettingsSaveState = "clean" | "dirty" | "saving" | "saved" | "error";
export type SettingsThemeChoice = RendererSettings["themePreference"];
export const SETTINGS_SECTIONS: readonly { id: SettingsSection; label: string }[] = [
{ id: "allgemein", label: "Allgemein" },
@@ -234,8 +235,8 @@ export interface SettingsSwitchFieldViewModel extends SettingsFieldBase {
export interface SettingsThemeFieldViewModel extends SettingsFieldBase {
kind: "theme";
value: string;
options: readonly { value: string; label: string }[];
value: SettingsThemeChoice;
options: readonly { value: SettingsThemeChoice; label: string }[];
}
export interface SettingsActionFieldViewModel extends SettingsFieldBase {
@@ -268,7 +269,7 @@ export interface SettingsFormProjectionInput {
section: SettingsSection;
speedLimitInput: string;
scheduleSpeedInputs: Readonly<Record<string, string>>;
themeChoice?: "light" | "dark" | "system";
themeChoice?: SettingsThemeChoice;
}
const NOTIFICATION_NUMBER_LIMITS = {
@@ -292,7 +293,7 @@ export function buildSettingsFormViewModel({
section,
speedLimitInput,
scheduleSpeedInputs,
themeChoice = settings.theme
themeChoice = settings.themePreference
}: SettingsFormProjectionInput): SettingsFormViewModel {
if (section === "extract") {
return {
@@ -602,8 +603,8 @@ export function buildSettingsFormViewModel({
label: "Theme",
value: themeChoice,
options: [
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" },
{ value: "light", label: "Hell" },
{ value: "dark", label: "Dunkel" },
{ value: "system", label: "System" }
]
}
+4
View File
@@ -18,6 +18,10 @@
background: var(--ui-surface);
}
.md-shell-sidebar-scroll > .settings-sidebar {
border-right: 0;
}
.settings-sidebar-heading {
display: flex;
min-height: 32px;
@@ -151,7 +151,7 @@ export function StatisticsSidebarStatus({ model }: Pick<StatisticsViewProps, "mo
metrics.files.available ? `Dateien: ${formatMetric(metrics.files, "count")}` : null,
metrics.successRate.available ? `Erfolg: ${formatMetric(metrics.successRate, "percent")}` : null,
metrics.errors.available ? `Fehler: ${formatMetric(metrics.errors, "count")}` : null,
model.providerScope ? `${model.usageKind === "accounts" ? "Accounts" : "Provider"}: ${model.providers.length}` : null
model.providerScope ? `${model.usageKind === "accounts" ? "Accounts" : "Provider"}: ${numberFormatter.format(model.providers.length)}` : null
].filter((value): value is string => value !== null);
if (rows.length === 0) {
return null;
@@ -219,9 +219,9 @@ export function StatisticsContent({ model, actions, chart }: StatisticsViewProps
<span className="statistics-provider-name" role="cell">{provider.label}</span>
<span role="cell">{formatBytes(provider.bytes)}</span>
<span className={provider.failed && provider.failed > 0 ? "statistics-provider-errors" : undefined} role="cell">
{provider.completed === null || provider.failed === null
? ""
: `${provider.completed} fertig · ${provider.failed} Fehler`}
{provider.completed === null || provider.failed === null
? ""
: `${numberFormatter.format(provider.completed)} fertig · ${numberFormatter.format(provider.failed)} Fehler`}
</span>
</div>
)) : (
@@ -1,5 +1,6 @@
import { aggregateStatisticsRange, type StatisticsAggregate } from "../../../shared/statistics-aggregation";
import type { DebridProvider, DownloadItem, DownloadSummary, StatisticsProviderBucket, UiSnapshot } from "../../../shared/types";
import { getProviderUsageDayKey } from "../../../shared/provider-daily-limits";
import type { DebridProvider, DownloadItem, DownloadSummary, StatisticsLedger, StatisticsProviderBucket, UiSnapshot } from "../../../shared/types";
export type StatisticsRange = "session" | "today" | "last24" | "week" | "month" | "all";
export type StatisticsCoverage = "partial" | "unavailable";
@@ -156,7 +157,8 @@ function deriveQueueProviders(items: DownloadItem[]): StatisticsProviderRow[] {
function deriveUsageProviders(
usage: Partial<Record<DebridProvider, number>>,
outcomes: Partial<Record<DebridProvider, StatisticsProviderBucket>> = {}
outcomes: Partial<Record<DebridProvider, StatisticsProviderBucket>> = {},
outcomesAvailable = true
): StatisticsProviderRow[] {
const rows: StatisticsProviderRow[] = [];
const providerIds = new Set<DebridProvider>([
@@ -175,8 +177,8 @@ function deriveUsageProviders(
id,
label: providerLabels[id],
bytes,
completed,
failed
completed: outcomesAvailable ? completed : null,
failed: outcomesAvailable ? failed : null
});
}
return sortProviderRows(rows);
@@ -192,25 +194,56 @@ function deriveRollingAccounts(snapshot: UiSnapshot): StatisticsProviderRow[] {
})).sort((left, right) => right.bytes - left.bytes || left.label.localeCompare(right.label, "de-DE", { sensitivity: "base" }) || left.id.localeCompare(right.id));
}
function aggregateMetrics(aggregate: StatisticsAggregate, sourceLabel: string): StatisticsMetrics {
function aggregateMetrics(aggregate: StatisticsAggregate, sourceLabel: string, detailsAvailable = true): StatisticsMetrics {
return {
downloadedBytes: availableMetric(aggregate.downloadedBytes, sourceLabel),
files: availableMetric(aggregate.completedFiles, sourceLabel),
successRate: successRateMetric(aggregate.completedFiles, aggregate.failedFiles, sourceLabel),
averageSpeedBps: aggregate.averageSpeedBps === null
files: detailsAvailable ? availableMetric(aggregate.completedFiles, sourceLabel) : unavailableMetric("Nicht verfügbar"),
successRate: detailsAvailable
? successRateMetric(aggregate.completedFiles, aggregate.failedFiles, sourceLabel)
: unavailableMetric("Nicht verfügbar"),
averageSpeedBps: !detailsAvailable
? unavailableMetric("Nicht verfügbar")
: aggregate.averageSpeedBps === null
? unavailableMetric("Noch keine aktive Downloadzeit mit übertragenen Daten erfasst")
: availableMetric(aggregate.averageSpeedBps, sourceLabel),
errors: availableMetric(
errors: detailsAvailable ? availableMetric(
aggregate.failedFiles,
sourceLabel,
aggregate.failedFiles > 0 ? "danger" : undefined
)
) : unavailableMetric("Nicht verfügbar")
};
}
function statisticsWindowStart(nowMs: number, days: number | null): string {
if (days === null) {
return "0000-00-00";
}
const date = new Date(nowMs);
date.setHours(0, 0, 0, 0);
date.setDate(date.getDate() - Math.max(0, days - 1));
return getProviderUsageDayKey(date.getTime());
}
function hasProviderBytesOnlyDay(
ledger: StatisticsLedger | null | undefined,
days: number | null,
nowMs: number
): boolean {
const start = statisticsWindowStart(nowMs, days);
const end = getProviderUsageDayKey(nowMs);
return (ledger?.providerBytesOnlyDays ?? []).some((day) => day >= start && day <= end);
}
function hasProviderBytesOnlyRollingDay(ledger: StatisticsLedger | null | undefined, nowMs: number): boolean {
const start = getProviderUsageDayKey(nowMs - (24 * 60 * 60 * 1_000));
const end = getProviderUsageDayKey(nowMs);
return (ledger?.providerBytesOnlyDays ?? []).some((day) => day >= start && day <= end);
}
function recordedDaysMessage(label: string, coveredDays: number): string {
const days = coveredDays === 1 ? "1 erfasster Tag" : `${coveredDays} erfasste Tage`;
return `${label}: ${days} werden bis heute zusammengefasst.`;
return coveredDays === 1
? `${label}: 1 erfasster Tag wird bis heute zusammengefasst.`
: `${label}: ${coveredDays} erfasste Tage werden bis heute zusammengefasst.`;
}
export function buildStatisticsViewModel(
@@ -222,7 +255,12 @@ export function buildStatisticsViewModel(
if (range === "last24") {
const rolling = snapshot.stats.rolling24Hours;
const hasFullCoverage = nowMs - Math.max(0, snapshot.stats.statistics?.startedAt ?? nowMs) >= 24 * 60 * 60 * 1_000;
const minuteTrackingStartedAt = snapshot.stats.statistics?.minuteTrackingStartedAt;
const rollingAvailable = !hasProviderBytesOnlyRollingDay(snapshot.stats.statistics, nowMs);
const hasFullCoverage = typeof minuteTrackingStartedAt === "number"
&& Number.isFinite(minuteTrackingStartedAt)
&& minuteTrackingStartedAt > 0
&& nowMs - minuteTrackingStartedAt >= 24 * 60 * 60 * 1_000;
return {
range,
coverage: "partial",
@@ -231,7 +269,9 @@ export function buildStatisticsViewModel(
: "Letzte 24 Stunden: Werte seit Beginn der Aufzeichnung.",
sessionState,
metrics: {
downloadedBytes: availableMetric(rolling?.downloadedBytes ?? 0, "Letzte 24 Stunden"),
downloadedBytes: rollingAvailable
? availableMetric(rolling?.downloadedBytes ?? 0, "Letzte 24 Stunden")
: unavailableMetric("Nicht verfügbar"),
files: unavailableMetric("Dateien werden nicht minutengenau nach Account erfasst"),
successRate: unavailableMetric("Ergebnisse werden nicht minutengenau nach Account erfasst"),
averageSpeedBps: unavailableMetric("Aktive Downloadzeit wird nur tagesweise erfasst"),
@@ -239,7 +279,7 @@ export function buildStatisticsViewModel(
},
providerScope: "last24",
usageKind: "accounts",
providers: deriveRollingAccounts(snapshot),
providers: rollingAvailable ? deriveRollingAccounts(snapshot) : [],
errorResetAvailable: false
};
}
@@ -248,6 +288,7 @@ export function buildStatisticsViewModel(
const days = range === "today" ? 1 : range === "week" ? 7 : 30;
const aggregate = aggregateStatisticsRange(snapshot.stats.statistics, days, nowMs);
const label = range === "today" ? "Heute" : range === "week" ? "Letzte sieben Tage" : "Letzte 30 Tage";
const detailsAvailable = !hasProviderBytesOnlyDay(snapshot.stats.statistics, days, nowMs);
return {
range,
coverage: "partial",
@@ -255,12 +296,13 @@ export function buildStatisticsViewModel(
? "Heutige Werte stammen aus der lokalen Statistikaufzeichnung."
: recordedDaysMessage(label, aggregate.coveredDays),
sessionState,
metrics: aggregateMetrics(aggregate, label),
metrics: aggregateMetrics(aggregate, label, detailsAvailable),
providerScope: range,
usageKind: "providers",
providers: deriveUsageProviders(
Object.fromEntries(Object.entries(aggregate.providers).map(([provider, bucket]) => [provider, bucket?.bytes ?? 0])),
aggregate.providers
aggregate.providers,
detailsAvailable
),
errorResetAvailable: false
};
@@ -268,8 +310,9 @@ export function buildStatisticsViewModel(
if (range === "all") {
const aggregate = aggregateStatisticsRange(snapshot.stats.statistics, null, nowMs);
const providers = deriveUsageProviders(snapshot.settings.providerTotalUsageBytes, aggregate.providers);
const recordedMetrics = aggregateMetrics(aggregate, "Seit Beginn der Statistikaufzeichnung");
const detailsAvailable = !hasProviderBytesOnlyDay(snapshot.stats.statistics, null, nowMs);
const providers = deriveUsageProviders(snapshot.settings.providerTotalUsageBytes, aggregate.providers, detailsAvailable);
const recordedMetrics = aggregateMetrics(aggregate, "Seit Beginn der Statistikaufzeichnung", detailsAvailable);
return {
range,
coverage: "partial",
+70 -7
View File
@@ -2,6 +2,11 @@ export type CollectorAvailability = "online" | "offline" | "unknown";
export type CollectorLinkStatus = "ready" | "offline" | "unknown";
export type CollectorPackageNameSource = "explicit" | "inferred";
export const COLLECTOR_MAX_PACKAGES = 2_000;
export const COLLECTOR_MAX_LINKS = 20_000;
export const COLLECTOR_MAX_NAME_LENGTH = 1_024;
export const COLLECTOR_MAX_PERSISTENCE_BYTES = 64 * 1024 * 1024;
export interface CollectorLink {
id: string;
url: string;
@@ -52,6 +57,64 @@ export interface CollectorPersistenceState {
collapsedPackageIds: string[];
}
export type CollectorCapacityResult = {
ok: true;
packageCount: number;
linkCount: number;
} | {
ok: false;
packageCount: number;
linkCount: number;
message: string;
};
export type CollectorPersistenceSizeResult = {
ok: true;
byteCount: number;
} | {
ok: false;
byteCount: number;
message: string;
};
export function inspectCollectorCapacity(packages: readonly Pick<CollectorPackage, "links">[]): CollectorCapacityResult {
const packageCount = packages.length;
let linkCount = 0;
for (const pkg of packages) linkCount += pkg.links.length;
if (packageCount > COLLECTOR_MAX_PACKAGES) {
return {
ok: false,
packageCount,
linkCount,
message: `Der Linksammler kann höchstens ${COLLECTOR_MAX_PACKAGES.toLocaleString("de-DE")} Pakete enthalten.`
};
}
if (linkCount > COLLECTOR_MAX_LINKS) {
return {
ok: false,
packageCount,
linkCount,
message: `Der Linksammler kann höchstens ${COLLECTOR_MAX_LINKS.toLocaleString("de-DE")} Links enthalten.`
};
}
return { ok: true, packageCount, linkCount };
}
export function inspectCollectorPersistenceSize(
state: CollectorPersistenceState,
maximumBytes = COLLECTOR_MAX_PERSISTENCE_BYTES
): CollectorPersistenceSizeResult {
const byteCount = new TextEncoder().encode(JSON.stringify({
version: 1,
packages: state.packages,
collapsedPackageIds: state.collapsedPackageIds,
updatedAt: Number.MAX_SAFE_INTEGER
})).byteLength;
return byteCount <= maximumBytes
? { ok: true, byteCount }
: { ok: false, byteCount, message: "Der Linksammler ist zu groß, um gespeichert zu werden." };
}
function validAddedAt(value: unknown): value is number {
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
}
@@ -107,7 +170,7 @@ function validCollectorLink(value: unknown): value is CollectorLink {
&& raw.url.length <= 32767
&& /^https?:\/\/[^\s]+$/i.test(raw.url)
&& typeof raw.fileName === "string"
&& raw.fileName.length <= 1024
&& raw.fileName.length <= COLLECTOR_MAX_NAME_LENGTH
&& (raw.fileSizeBytes === null || (typeof raw.fileSizeBytes === "number" && Number.isSafeInteger(raw.fileSizeBytes) && raw.fileSizeBytes >= 0))
&& typeof raw.hoster === "string"
&& raw.hoster.length <= 255
@@ -125,7 +188,7 @@ function validCollectorPackage(value: unknown): value is CollectorPackage {
&& raw.id.length <= 160
&& typeof raw.name === "string"
&& raw.name.length > 0
&& raw.name.length <= 1024
&& raw.name.length <= COLLECTOR_MAX_NAME_LENGTH
&& (raw.nameSource === "explicit" || raw.nameSource === "inferred")
&& Array.isArray(raw.links)
&& raw.links.length > 0
@@ -144,9 +207,9 @@ export function validateCollectorEnrichmentRequest(value: unknown): CollectorEnr
|| raw.requestId.length > 160
|| !Array.isArray(raw.packages)
|| raw.packages.length === 0
|| raw.packages.length > 2_000
|| raw.packages.length > COLLECTOR_MAX_PACKAGES
|| raw.packages.some((entry) => !validCollectorPackage(entry))
|| raw.packages.reduce((sum, entry) => sum + (entry as CollectorPackage).links.length, 0) > 20_000) {
|| raw.packages.reduce((sum, entry) => sum + (entry as CollectorPackage).links.length, 0) > COLLECTOR_MAX_LINKS) {
throw new Error("Linksammler-Anreicherung ist ungültig");
}
return { requestId: raw.requestId, packages: structuredClone(raw.packages) as CollectorPackage[] };
@@ -161,11 +224,11 @@ export function validateCollectorPersistenceState(value: unknown): CollectorPers
const collapsedPackageIds = raw.collapsedPackageIds;
if (Object.keys(raw).some((key) => key !== "packages" && key !== "collapsedPackageIds")
|| !Array.isArray(packages)
|| packages.length > 2_000
|| packages.length > COLLECTOR_MAX_PACKAGES
|| packages.some((entry) => !validCollectorPackage(entry))
|| packages.reduce((sum, entry) => sum + (entry as CollectorPackage).links.length, 0) > 20_000
|| packages.reduce((sum, entry) => sum + (entry as CollectorPackage).links.length, 0) > COLLECTOR_MAX_LINKS
|| !Array.isArray(collapsedPackageIds)
|| collapsedPackageIds.length > 2_000
|| collapsedPackageIds.length > COLLECTOR_MAX_PACKAGES
|| collapsedPackageIds.some((entry) => typeof entry !== "string" || entry.length === 0 || entry.length > 160)) {
throw new Error("Linksammler-Speicherzustand ist ungültig");
}
+1
View File
@@ -19,6 +19,7 @@ export const IPC_CHANNELS = {
ENRICH_COLLECTOR_PACKAGES: "collector:enrich-packages",
COLLECTOR_ENRICHMENT_PROGRESS: "collector:enrichment-progress",
GET_COLLECTOR_STATE: "collector:get-state",
GET_COLLECTOR_STATE_SYNC: "collector:get-state-sync",
SAVE_COLLECTOR_STATE: "collector:save-state",
SAVE_COLLECTOR_STATE_SYNC: "collector:save-state-sync",
GET_START_CONFLICTS: "queue:get-start-conflicts",
+2 -1
View File
@@ -91,8 +91,9 @@ export interface ElectronApi {
enrichCollectorPackages: (request: CollectorEnrichmentRequest) => Promise<CollectorInspectionResult>;
onCollectorEnrichmentProgress: (callback: (progress: CollectorEnrichmentProgress) => void) => () => void;
getCollectorState: () => Promise<CollectorPersistenceState>;
getCollectorStateSync: () => CollectorPersistenceState | null;
saveCollectorState: (state: CollectorPersistenceState) => Promise<CollectorPersistenceState>;
saveCollectorStateSync: (state: CollectorPersistenceState) => void;
saveCollectorStateSync: (state: CollectorPersistenceState) => boolean;
getPathForDroppedFile: (file: File) => string;
getStartConflicts: () => Promise<StartConflictEntry[]>;
resolveStartConflict: (packageId: string, policy: DuplicatePolicy) => Promise<StartConflictResolutionResult>;
+11 -1
View File
@@ -28,6 +28,7 @@ export type DebridProvider =
| "linksnappy";
export type DebridFallbackProvider = DebridProvider | "none";
export type AppTheme = "dark" | "light";
export type ThemePreference = AppTheme | "system";
export type AppLanguage = "en" | "de";
export type PackagePriority = "high" | "normal" | "low";
export type ExtractCpuPriority = "high" | "middle" | "low";
@@ -87,6 +88,10 @@ export interface StatisticsRolling24Hours {
export interface StatisticsLedger {
version: 2;
startedAt: number;
minuteTrackingStartedAt?: number;
providerSeedSuppressedDay?: string;
providerSeedBaselineBytes?: Partial<Record<DebridProvider, number>>;
providerBytesOnlyDays?: string[];
days: StatisticsDayBucket[];
minutes: StatisticsMinuteBucket[];
}
@@ -202,6 +207,7 @@ export interface AppSettings extends DailyStartSettings {
clipboardWatch: boolean;
minimizeToTray: boolean;
theme: AppTheme;
themePreference: ThemePreference;
logStorageLocation: LogStorageLocation;
collapseNewPackages: boolean;
animatePackageDisclosure: boolean;
@@ -336,6 +342,7 @@ export interface RendererSettings extends DailyStartSettings {
clipboardWatch: boolean;
minimizeToTray: boolean;
theme: AppTheme;
themePreference: ThemePreference;
logStorageLocation: LogStorageLocation;
collapseNewPackages: boolean;
animatePackageDisclosure: boolean;
@@ -687,6 +694,8 @@ export interface UiSnapshot {
clipboardActive: boolean;
reconnectSeconds: number;
packageSpeedBps: Record<string, number>;
runRemainingBytes?: number;
runRemainingUnknownItems?: number;
diskWaitEvents?: Array<{
phase: "download" | "extract" | "remux";
ownerId: string;
@@ -698,8 +707,9 @@ export interface UiSnapshot {
deficitBytes: number;
retryAt: number;
}>;
snapshotRevision?: number;
payloadKind?: "full" | "delta";
removedItemIds?: string[];
removedItemIds?: string[];
removedPackageIds?: string[];
rotationEvents?: RotationEvent[];
accountRuntime?: AccountRuntimeEntry[];
+12 -2
View File
@@ -134,16 +134,26 @@ describe("account preload contract", () => {
it("loads and saves the persistent collector state through dedicated channels", async () => {
const state = { packages: [], collapsedPackageIds: [] };
electron.sendSync.mockReturnValueOnce(state);
electron.sendSync.mockReturnValueOnce(true);
await electron.api?.getCollectorState();
const syncState = (electron.api as ElectronApi & {
getCollectorStateSync: () => typeof state;
}).getCollectorStateSync();
await electron.api?.saveCollectorState(state);
electron.api?.saveCollectorStateSync(state);
const savedSync = electron.api?.saveCollectorStateSync(state);
expect(electron.invoke.mock.calls).toEqual([
[IPC_CHANNELS.GET_COLLECTOR_STATE],
[IPC_CHANNELS.SAVE_COLLECTOR_STATE, state]
]);
expect(electron.sendSync).toHaveBeenCalledWith(IPC_CHANNELS.SAVE_COLLECTOR_STATE_SYNC, state);
expect(syncState).toEqual(state);
expect(savedSync).toBe(true);
expect(electron.sendSync.mock.calls).toEqual([
[IPC_CHANNELS.GET_COLLECTOR_STATE_SYNC],
[IPC_CHANNELS.SAVE_COLLECTOR_STATE_SYNC, state]
]);
});
it("resolves dropped files through Electron webUtils without IPC", () => {
+556 -5
View File
@@ -6,13 +6,30 @@ import { AppController } from "../src/main/app-controller";
import { defaultSettings } from "../src/main/constants";
import { configureCredentialProtector } from "../src/main/credential-protection";
import { prepareDailyStartSettingsPatch } from "../src/main/daily-start-scheduler";
import { createStoragePaths, emptySession } from "../src/main/storage";
import { logger } from "../src/main/logger";
import { createStatisticsLedger, loadStatisticsLedger, saveStatisticsLedger } from "../src/main/statistics-ledger";
import { createStoragePaths, emptySession, loadHistory, loadSession, loadSettings, saveHistory, saveSession, saveSettings } from "../src/main/storage";
import type { CollectorPersistenceState } from "../src/shared/collector";
import type { AppSettings, SessionState } from "../src/shared/types";
const appState = vi.hoisted(() => ({ userDataDir: "C:\\MDD\\Test" }));
const bootStorage = vi.hoisted(() => ({
settings: null as unknown,
loadResult: null as unknown
loadResult: null as unknown,
historyResetError: null as Error | null,
historyClearError: null as Error | null,
settingsSaveError: null as Error | null,
sessionSaveError: null as Error | null,
rollbackCreateError: null as Error | null,
barrierAcquisitions: 0,
barrierReleases: [] as boolean[],
barrierReleaseHook: null as ((replayBlocked: boolean) => void) | null
}));
const statisticsStorage = vi.hoisted(() => ({ saveError: null as Error | null }));
const debugStorage = vi.hoisted(() => ({
configError: null as Error | null,
restartCalls: 0,
restartErrors: [] as Error[]
}));
vi.mock("electron", () => ({
@@ -34,7 +51,28 @@ vi.mock("../src/main/debug-server", async () => {
return {
...actual,
startDebugServer: vi.fn(),
stopDebugServer: vi.fn()
stopDebugServer: vi.fn(),
restartDebugServer: vi.fn(async () => {
debugStorage.restartCalls += 1;
const error = debugStorage.restartErrors.shift();
if (error) throw error;
return actual.getDebugServerRuntimeStatus();
}),
writeDebugServerConfig: (...args: Parameters<typeof actual.writeDebugServerConfig>) => {
if (debugStorage.configError) throw debugStorage.configError;
return actual.writeDebugServerConfig(...args);
}
};
});
vi.mock("../src/main/statistics-ledger", async () => {
const actual = await vi.importActual<typeof import("../src/main/statistics-ledger")>("../src/main/statistics-ledger");
return {
...actual,
saveStatisticsLedger: (...args: Parameters<typeof actual.saveStatisticsLedger>) => {
if (statisticsStorage.saveError) throw statisticsStorage.saveError;
return actual.saveStatisticsLedger(...args);
}
};
});
@@ -47,7 +85,36 @@ vi.mock("../src/main/storage", async () => {
: actual.loadSettings(...args),
loadSessionWithStatus: (...args: Parameters<typeof actual.loadSessionWithStatus>) => bootStorage.loadResult
? bootStorage.loadResult as ReturnType<typeof actual.loadSessionWithStatus>
: actual.loadSessionWithStatus(...args)
: actual.loadSessionWithStatus(...args),
resetHistoryForRetention: (...args: Parameters<typeof actual.resetHistoryForRetention>) => {
if (bootStorage.historyResetError) throw bootStorage.historyResetError;
return actual.resetHistoryForRetention(...args);
},
clearHistory: (...args: Parameters<typeof actual.clearHistory>) => {
if (bootStorage.historyClearError) throw bootStorage.historyClearError;
return actual.clearHistory(...args);
},
acquirePersistenceBarrier: async () => {
bootStorage.barrierAcquisitions += 1;
return {
release: async ({ replayBlocked }: { replayBlocked: boolean }) => {
bootStorage.barrierReleases.push(replayBlocked);
bootStorage.barrierReleaseHook?.(replayBlocked);
}
};
},
createFileRollback: (...args: Parameters<typeof actual.createFileRollback>) => {
if (bootStorage.rollbackCreateError) throw bootStorage.rollbackCreateError;
return actual.createFileRollback(...args);
},
saveSettings: (...args: Parameters<typeof actual.saveSettings>) => {
if (bootStorage.settingsSaveError) throw bootStorage.settingsSaveError;
return actual.saveSettings(...args);
},
saveSession: (...args: Parameters<typeof actual.saveSession>) => {
if (bootStorage.sessionSaveError) throw bootStorage.sessionSaveError;
return actual.saveSession(...args);
}
};
});
@@ -60,7 +127,7 @@ function createController(settings: AppSettings): AppController {
const controller = Object.create(AppController.prototype) as any;
controller.settings = settings;
controller.storagePaths = createStoragePaths(dir);
controller.manager = { setSettings: vi.fn() };
controller.manager = { setSettings: vi.fn(), persistNowSync: vi.fn() };
controller.audit = vi.fn();
controller.overlayLiveUsageCounters = vi.fn();
controller.pruneRealDebridWebFallbacks = vi.fn();
@@ -123,12 +190,496 @@ afterEach(async () => {
}
bootStorage.settings = null;
bootStorage.loadResult = null;
bootStorage.historyResetError = null;
bootStorage.historyClearError = null;
bootStorage.settingsSaveError = null;
bootStorage.sessionSaveError = null;
bootStorage.rollbackCreateError = null;
bootStorage.barrierAcquisitions = 0;
bootStorage.barrierReleases = [];
bootStorage.barrierReleaseHook = null;
statisticsStorage.saveError = null;
debugStorage.configError = null;
debugStorage.restartCalls = 0;
debugStorage.restartErrors = [];
vi.restoreAllMocks();
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
describe("AppController collector persistence", () => {
it("updates and synchronously flushes the latest collector state before returning", () => {
const state: CollectorPersistenceState = { packages: [], collapsedPackageIds: [] };
const operations: string[] = [];
const controller = Object.create(AppController.prototype) as any;
controller.collectorStore = {
update: (value: CollectorPersistenceState) => {
expect(value).toEqual(state);
operations.push("update");
},
flushSync: () => { operations.push("flush"); },
getState: () => {
operations.push("get");
return structuredClone(state);
}
};
expect(controller.saveCollectorStateSync(state)).toEqual(state);
expect(operations).toEqual(["update", "flush", "get"]);
});
});
describe("AppController full backup history restore", () => {
it("clears existing history when a full backup explicitly contains an empty history", async () => {
const settings = { ...defaultSettings(), backupIncludeDownloads: true, historyRetentionMode: "permanent" as const };
const controller = createController(settings) as any;
controller.manager = {
setSettings: vi.fn(),
stop: vi.fn(),
abortAllPostProcessing: vi.fn(),
clearPersistTimer: vi.fn(),
skipShutdownPersist: false,
blockAllPersistence: false
};
controller.restoreRemoteDiagnosticsFromBackup = vi.fn();
saveHistory(controller.storagePaths, [{ id: "existing", name: "Alt" } as never]);
const payload = Buffer.from(JSON.stringify({
version: 2,
kind: "full",
settings,
session: emptySession(),
history: []
}), "utf8");
expect((await controller.importBackup(payload)).restored).toBe(true);
expect(bootStorage.barrierAcquisitions).toBe(1);
expect(bootStorage.barrierReleases).toEqual([false]);
expect(loadHistory(controller.storagePaths)).toEqual([]);
expect(JSON.parse(fs.readFileSync(`${controller.storagePaths.historyFile}.bak`, "utf8"))).toEqual([]);
});
it("rejects a nonempty backup history when every entry is invalid", async () => {
const settings = { ...defaultSettings(), backupIncludeDownloads: true, historyRetentionMode: "permanent" as const };
const controller = createController(settings) as any;
controller.manager = { setSettings: vi.fn() };
saveHistory(controller.storagePaths, [{ id: "existing", name: "Alt" } as never]);
const payload = Buffer.from(JSON.stringify({
version: 2,
kind: "full",
settings,
session: emptySession(),
history: [null]
}), "utf8");
await expect(controller.importBackup(payload)).resolves.toMatchObject({ restored: false });
expect(loadHistory(controller.storagePaths).map((entry) => entry.id)).toEqual(["existing"]);
});
it("keeps settings, session ownership, and existing history untouched when authoritative history replacement fails", async () => {
const currentSettings = { ...defaultSettings(), outputDir: "C:\\Current", backupIncludeDownloads: true, historyRetentionMode: "permanent" as const };
const importedSettings = { ...currentSettings, outputDir: "C:\\Imported" };
const controller = createController(currentSettings) as any;
controller.manager = {
setSettings: vi.fn(),
stop: vi.fn(),
abortAllPostProcessing: vi.fn(),
clearPersistTimer: vi.fn(),
skipShutdownPersist: false,
blockAllPersistence: false
};
controller.restoreRemoteDiagnosticsFromBackup = vi.fn();
saveHistory(controller.storagePaths, [{ id: "existing", name: "Alt" } as never]);
const payload = Buffer.from(JSON.stringify({
version: 2,
kind: "full",
settings: importedSettings,
session: emptySession(),
history: [{ id: "imported", name: "Neu" }]
}), "utf8");
const renameFile = fs.renameSync.bind(fs);
let failed = false;
const rename = vi.spyOn(fs, "renameSync").mockImplementation((source, destination) => {
if (!failed && String(destination) === controller.storagePaths.historyFile) {
failed = true;
throw Object.assign(new Error("history locked"), { code: "EPERM" });
}
return renameFile(source, destination);
});
try {
await expect(controller.importBackup(payload)).rejects.toThrow("history locked");
} finally {
rename.mockRestore();
}
expect(controller.getSettings().outputDir).toBe("C:\\Current");
expect(controller.manager.setSettings).not.toHaveBeenCalled();
expect(controller.manager.stop).not.toHaveBeenCalled();
expect(loadHistory(controller.storagePaths).map((entry) => entry.id)).toEqual(["existing"]);
expect(bootStorage.barrierReleases).toEqual([true]);
});
it("restores existing history when a later full-import write fails", async () => {
const currentSettings = { ...defaultSettings(), outputDir: "C:\\Current", backupIncludeDownloads: true, historyRetentionMode: "permanent" as const };
const controller = createController(currentSettings) as any;
controller.manager = {
setSettings: vi.fn(),
stop: vi.fn(),
abortAllPostProcessing: vi.fn(),
clearPersistTimer: vi.fn(),
skipShutdownPersist: false,
blockAllPersistence: false
};
controller.restoreRemoteDiagnosticsFromBackup = vi.fn();
saveHistory(controller.storagePaths, [{ id: "existing", name: "Alt" } as never]);
bootStorage.settingsSaveError = new Error("settings locked");
const payload = Buffer.from(JSON.stringify({
version: 2,
kind: "full",
settings: { ...currentSettings, outputDir: "C:\\Imported" },
session: emptySession(),
history: [{ id: "imported", name: "Neu" }]
}), "utf8");
await expect(controller.importBackup(payload)).rejects.toThrow("settings locked");
expect(controller.getSettings().outputDir).toBe("C:\\Current");
expect(controller.manager.setSettings).not.toHaveBeenCalled();
expect(loadHistory(controller.storagePaths).map((entry) => entry.id)).toEqual(["existing"]);
});
it.each(["session", "statistics", "remote"] as const)("rolls back every persisted and runtime surface when %s persistence fails", async (failure) => {
const currentSettings = { ...defaultSettings(), outputDir: "C:\\Current", backupIncludeDownloads: true, historyRetentionMode: "permanent" as const };
const controller = createController(currentSettings) as any;
controller.manager = {
setSettings: vi.fn(),
stop: vi.fn(),
abortAllPostProcessing: vi.fn(),
clearPersistTimer: vi.fn(),
skipShutdownPersist: false,
blockAllPersistence: false
};
const currentSession = queuedSession();
saveSettings(controller.storagePaths, currentSettings);
saveSession(controller.storagePaths, currentSession);
saveHistory(controller.storagePaths, [{ id: "existing", name: "Alt" } as never]);
saveStatisticsLedger(controller.storagePaths.statisticsFile, createStatisticsLedger(1_000));
for (const [name, value] of [["debug_host.txt", "127.0.0.1\n"], ["debug_port.txt", "9868\n"], ["debug_allowlist.txt", "192.0.2.1\n"]]) {
fs.writeFileSync(path.join(controller.storagePaths.baseDir, name), value, "utf8");
}
if (failure === "session") bootStorage.sessionSaveError = new Error("session locked");
if (failure === "statistics") statisticsStorage.saveError = new Error("statistics locked");
if (failure === "remote") debugStorage.configError = new Error("remote locked");
const importedSession = emptySession();
importedSession.packageOrder = ["imported-package"];
const payload = Buffer.from(JSON.stringify({
version: 2,
kind: "full",
settings: { ...currentSettings, outputDir: "C:\\Imported" },
session: importedSession,
history: [{ id: "imported", name: "Neu" }],
statistics: createStatisticsLedger(2_000),
remoteDiagnostics: { allowlist: ["198.51.100.8"] }
}), "utf8");
await expect(controller.importBackup(payload)).rejects.toThrow(`${failure} locked`);
expect(controller.getSettings().outputDir).toBe("C:\\Current");
expect(controller.manager.setSettings).not.toHaveBeenCalled();
expect(controller.manager.stop).not.toHaveBeenCalled();
expect(controller.manager.abortAllPostProcessing).not.toHaveBeenCalled();
expect(loadSettings(controller.storagePaths).outputDir).toBe("C:\\Current");
expect(loadSession(controller.storagePaths).packageOrder).toEqual(["pkg"]);
expect(loadHistory(controller.storagePaths).map((entry) => entry.id)).toEqual(["existing"]);
expect(loadStatisticsLedger(controller.storagePaths.statisticsFile).startedAt).toBe(1_000);
expect(fs.readFileSync(path.join(controller.storagePaths.baseDir, "debug_allowlist.txt"), "utf8")).toBe("192.0.2.1\n");
});
it("restores runtime settings and persistence flags when full-import finalization fails", async () => {
const currentSettings = { ...defaultSettings(), outputDir: "C:\\Current", backupIncludeDownloads: true };
const importedSettings = { ...currentSettings, outputDir: "C:\\Imported" };
const controller = createController(currentSettings) as any;
controller.manager = {
setSettings: vi.fn(),
stop: vi.fn(() => { throw new Error("stop failed"); }),
abortAllPostProcessing: vi.fn(),
clearPersistTimer: vi.fn(),
skipShutdownPersist: false,
blockAllPersistence: false
};
saveSettings(controller.storagePaths, currentSettings);
saveSession(controller.storagePaths, queuedSession());
const payload = Buffer.from(JSON.stringify({
version: 2,
kind: "full",
settings: importedSettings,
session: emptySession(),
history: []
}), "utf8");
await expect(controller.importBackup(payload)).rejects.toThrow("stop failed");
expect(controller.getSettings().outputDir).toBe("C:\\Current");
expect(loadSettings(controller.storagePaths).outputDir).toBe("C:\\Current");
expect(controller.manager.setSettings).toHaveBeenNthCalledWith(1, expect.objectContaining({ outputDir: "C:\\Imported" }));
expect(controller.manager.setSettings).toHaveBeenNthCalledWith(2, currentSettings, { settingsOnlyImport: true });
expect(controller.manager.skipShutdownPersist).toBe(false);
expect(controller.manager.blockAllPersistence).toBe(false);
expect(bootStorage.barrierReleases).toEqual([true]);
expect(controller.audit).not.toHaveBeenCalled();
});
});
describe("AppController settings-only backup transactions", () => {
it("applies runtime settings before releasing the successful import barrier", async () => {
const currentSettings = { ...defaultSettings(), outputDir: "C:\\Current" };
const importedSettings = { ...currentSettings, outputDir: "C:\\Imported" };
const controller = createController(currentSettings) as any;
const events: string[] = [];
controller.manager.setSettings = vi.fn(() => { events.push("runtime"); });
bootStorage.barrierReleaseHook = (replayBlocked) => { events.push(`release:${replayBlocked}`); };
await controller.applySettingsOnlyBackup(importedSettings);
expect(controller.getSettings().outputDir).toBe("C:\\Imported");
expect(loadSettings(controller.storagePaths).outputDir).toBe("C:\\Imported");
expect(events).toEqual(["runtime", "release:false"]);
expect(bootStorage.barrierAcquisitions).toBe(1);
});
it("persists the current live session before discarding blocked saves after a settings-only import", async () => {
const currentSettings = { ...defaultSettings(), outputDir: "C:\\Current" };
const importedSettings = { ...currentSettings, outputDir: "C:\\Imported" };
const controller = createController(currentSettings) as any;
const staleSession = { ...emptySession(), summaryText: "before remote restart" };
const liveSession = { ...emptySession(), summaryText: "completed during remote restart" };
const events: string[] = [];
saveSession(controller.storagePaths, staleSession);
controller.manager = {
setSettings: vi.fn(() => { events.push("runtime"); }),
persistNowSync: vi.fn(() => {
events.push("session");
saveSession(controller.storagePaths, liveSession);
})
};
bootStorage.barrierReleaseHook = (replayBlocked) => { events.push(`release:${replayBlocked}`); };
await controller.applySettingsOnlyBackup(importedSettings);
expect(loadSession(controller.storagePaths).summaryText).toBe("completed during remote restart");
expect(events).toEqual(["runtime", "session", "release:false"]);
});
it("rolls back a settings-only import when the final live-session flush fails", async () => {
const currentSettings = { ...defaultSettings(), outputDir: "C:\\Current" };
const importedSettings = { ...currentSettings, outputDir: "C:\\Imported" };
const controller = createController(currentSettings) as any;
controller.manager = {
setSettings: vi.fn(),
persistNowSync: vi.fn(() => { throw new Error("live session flush failed"); })
};
saveSettings(controller.storagePaths, currentSettings);
await expect(controller.applySettingsOnlyBackup(importedSettings)).rejects.toThrow("live session flush failed");
expect(controller.getSettings().outputDir).toBe("C:\\Current");
expect(loadSettings(controller.storagePaths).outputDir).toBe("C:\\Current");
expect(controller.manager.setSettings).toHaveBeenNthCalledWith(1, expect.objectContaining({ outputDir: "C:\\Imported" }), { settingsOnlyImport: true });
expect(controller.manager.setSettings).toHaveBeenNthCalledWith(2, currentSettings, { settingsOnlyImport: true });
expect(bootStorage.barrierReleases).toEqual([true]);
expect(controller.audit).not.toHaveBeenCalled();
});
it("releases the persistence barrier when the rollback snapshot cannot be created", async () => {
const currentSettings = { ...defaultSettings(), outputDir: "C:\\Current" };
const controller = createController(currentSettings) as any;
bootStorage.rollbackCreateError = new Error("rollback snapshot unreadable");
await expect(controller.applySettingsOnlyBackup({ ...currentSettings, outputDir: "C:\\Imported" }))
.rejects.toThrow("rollback snapshot unreadable");
expect(controller.getSettings().outputDir).toBe("C:\\Current");
expect(bootStorage.barrierAcquisitions).toBe(1);
expect(bootStorage.barrierReleases).toEqual([true]);
});
it("does not reconfigure logs or runtime settings before settings persistence succeeds", async () => {
const currentSettings = { ...defaultSettings(), outputDir: "C:\\Current", logStorageLocation: "appdata" as const };
const controller = createController(currentSettings) as any;
controller.logDirectory = "C:\\CurrentLogs";
controller.reconfigureLogStorage = vi.fn(() => {
controller.logDirectory = "C:\\ChangedLogs";
return true;
});
saveSettings(controller.storagePaths, currentSettings);
bootStorage.settingsSaveError = new Error("settings-only locked");
await expect(controller.applySettingsOnlyBackup({ ...currentSettings, outputDir: "C:\\Imported", logStorageLocation: "desktop" })).rejects.toThrow("settings-only locked");
expect(controller.getSettings().outputDir).toBe("C:\\Current");
expect(controller.logDirectory).toBe("C:\\CurrentLogs");
expect(controller.reconfigureLogStorage).not.toHaveBeenCalled();
expect(controller.manager.setSettings).not.toHaveBeenCalled();
expect(loadSettings(controller.storagePaths).outputDir).toBe("C:\\Current");
});
it("rolls back settings when remote diagnostics persistence fails", async () => {
const currentSettings = { ...defaultSettings(), outputDir: "C:\\Current" };
const controller = createController(currentSettings) as any;
saveSettings(controller.storagePaths, currentSettings);
fs.writeFileSync(path.join(controller.storagePaths.baseDir, "debug_allowlist.txt"), "192.0.2.1\n", "utf8");
debugStorage.configError = new Error("remote settings-only locked");
await expect(controller.applySettingsOnlyBackup(
{ ...currentSettings, outputDir: "C:\\Imported" },
{ allowlist: ["198.51.100.8"] },
true
)).rejects.toThrow("remote settings-only locked");
expect(controller.getSettings().outputDir).toBe("C:\\Current");
expect(controller.manager.setSettings).not.toHaveBeenCalled();
expect(loadSettings(controller.storagePaths).outputDir).toBe("C:\\Current");
expect(fs.readFileSync(path.join(controller.storagePaths.baseDir, "debug_allowlist.txt"), "utf8")).toBe("192.0.2.1\n");
});
it("rejects and rolls back a settings-only import when the restored debug server cannot start", async () => {
const currentSettings = { ...defaultSettings(), outputDir: "C:\\Current" };
const importedSettings = { ...currentSettings, outputDir: "C:\\Imported" };
const controller = createController(currentSettings) as any;
controller.persistRemoteDiagnosticsFromBackup = vi.fn(() => ({
host: "0.0.0.0",
port: 9876,
allowlist: ["198.51.100.8"]
}));
saveSettings(controller.storagePaths, currentSettings);
debugStorage.restartErrors = [new Error("imported debug server failed")];
await expect(controller.applySettingsOnlyBackup(
importedSettings,
{ allowlist: ["198.51.100.8"] },
true
)).rejects.toThrow("imported debug server failed");
expect(controller.getSettings().outputDir).toBe("C:\\Current");
expect(loadSettings(controller.storagePaths).outputDir).toBe("C:\\Current");
expect(controller.manager.setSettings).not.toHaveBeenCalled();
expect(debugStorage.restartCalls).toBe(2);
expect(controller.audit).not.toHaveBeenCalled();
expect(bootStorage.barrierAcquisitions).toBe(1);
expect(bootStorage.barrierReleases).toEqual([true]);
});
});
describe("AppController history failure isolation", () => {
it("does not abort package finalization when primary and backup history are unreadable", () => {
const controller = createController({ ...defaultSettings(), historyRetentionMode: "permanent" }) as any;
fs.writeFileSync(controller.storagePaths.historyFile, "{broken", "utf8");
fs.writeFileSync(`${controller.storagePaths.historyFile}.bak`, "{also-broken", "utf8");
expect(() => controller.recordHistoryEntry({ id: "history-failure", name: "Paket" } as never)).not.toThrow();
});
});
describe("AppController history retention lifecycle", () => {
function prepareBoot(retentionMode: "permanent" | "session"): void {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mdd-controller-history-lifecycle-"));
tempDirs.push(root);
appState.userDataDir = root;
bootStorage.settings = { ...defaultSettings(), historyRetentionMode: retentionMode } satisfies AppSettings;
bootStorage.loadResult = { session: emptySession(), status: "ok", wasRunning: false };
}
it("logs and tolerates retention cleanup failures during startup", () => {
prepareBoot("session");
bootStorage.historyResetError = Object.assign(new Error("startup history busy"), { code: "EBUSY" });
const warning = vi.spyOn(logger, "warn").mockImplementation(() => {});
let controller: AppController | null = null;
expect(() => { controller = new AppController(); }).not.toThrow();
expect(warning).toHaveBeenCalledWith(expect.stringContaining("startup history busy"));
if (controller) liveControllers.push(controller);
});
it("logs and tolerates retention cleanup failures during shutdown", async () => {
prepareBoot("permanent");
const controller = new AppController();
(controller as any).settings.historyRetentionMode = "session";
bootStorage.historyClearError = Object.assign(new Error("shutdown history busy"), { code: "EBUSY" });
const warning = vi.spyOn(logger, "warn").mockImplementation(() => {});
await expect(controller.shutdown()).resolves.toBeUndefined();
expect(warning).toHaveBeenCalledWith(expect.stringContaining("shutdown history busy"));
});
});
describe("AppController history retention updates", () => {
it("keeps the previous controller state when retention cleanup fails", () => {
const controller = createController({ ...defaultSettings(), historyRetentionMode: "permanent" });
bootStorage.historyResetError = Object.assign(new Error("retention history busy"), { code: "EBUSY" });
expect(() => controller.updateSettings({ historyRetentionMode: "session" })).toThrow("retention history busy");
expect(controller.getSettings().historyRetentionMode).toBe("permanent");
expect((controller as any).manager.setSettings).not.toHaveBeenCalled();
});
it("restores history when settings persistence fails after retention cleanup", () => {
const controller = createController({ ...defaultSettings(), historyRetentionMode: "permanent" });
saveHistory((controller as any).storagePaths, [{ id: "kept", name: "Behalten" } as never]);
bootStorage.settingsSaveError = new Error("settings history locked");
expect(() => controller.updateSettings({ historyRetentionMode: "session" })).toThrow("settings history locked");
expect(controller.getSettings().historyRetentionMode).toBe("permanent");
expect(loadHistory((controller as any).storagePaths).map((entry) => entry.id)).toEqual(["kept"]);
});
});
describe("AppController provider quota reset statistics", () => {
it("persists quota settings before rebasing statistics and applying runtime settings", () => {
const settings = {
...defaultSettings(),
providerDailyUsageBytes: { realdebrid: 1_024 }
};
const controller = createController(settings) as any;
controller.manager = {
getSettings: vi.fn(() => settings),
rebaseStatisticsProviderDailyUsage: vi.fn(),
setSettings: vi.fn()
};
const result = controller.resetProviderDailyUsage("realdebrid");
expect(result.providerDailyUsageBytes.realdebrid ?? 0).toBe(0);
expect(controller.manager.rebaseStatisticsProviderDailyUsage).toHaveBeenCalledWith("realdebrid", 0);
expect(controller.manager.rebaseStatisticsProviderDailyUsage.mock.invocationCallOrder[0])
.toBeLessThan(controller.manager.setSettings.mock.invocationCallOrder[0]);
});
it("rolls back quota settings when statistics rebasing cannot be persisted", () => {
const settings = {
...defaultSettings(),
providerDailyUsageBytes: { realdebrid: 1_024 }
};
const controller = createController(settings) as any;
controller.manager = {
getSettings: vi.fn(() => settings),
rebaseStatisticsProviderDailyUsage: vi.fn(() => { throw new Error("rebase ledger locked"); }),
setSettings: vi.fn()
};
saveSettings(controller.storagePaths, settings);
expect(() => controller.resetProviderDailyUsage("realdebrid")).toThrow("rebase ledger locked");
expect(controller.getSettings().providerDailyUsageBytes.realdebrid).toBe(1_024);
expect(controller.manager.setSettings).not.toHaveBeenCalled();
expect(loadSettings(controller.storagePaths).providerDailyUsageBytes.realdebrid).toBe(1_024);
});
});
describe("AppController daily start settings", () => {
it("does not clear a legacy one-time schedule for an unprepared daily settings patch", () => {
configureCredentialProtector({
+193 -1
View File
@@ -3,11 +3,203 @@ import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { AvatarMenu, getAvatarMenuKeyboardAction } from "../src/renderer/shell/AvatarMenu";
import { AppHeader } from "../src/renderer/shell/AppHeader";
import { AppShell } from "../src/renderer/shell/AppShell";
import { AppShell } from "../src/renderer/shell/AppShell";
import { buildMainNavigation } from "../src/renderer/shell/shell-model";
import { getSnapshotRenderDelay } from "../src/renderer/App";
import * as appModule from "../src/renderer/App";
import { createVisualFixture } from "./visual/fixtures";
describe("desktop shell", () => {
it("keeps delta-only startup state behind the loading gate", () => {
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8");
const coordinator = appModule.createSnapshotBootstrapCoordinator() as ReturnType<typeof appModule.createSnapshotBootstrapCoordinator> & {
getStatus?: () => { phase: string; message: string };
};
const delta = structuredClone(createVisualFixture("dense").snapshot);
delta.payloadKind = "delta";
expect(coordinator.push(delta)).toBeNull();
expect(coordinator.getStatus).toBeTypeOf("function");
if (!coordinator.getStatus) return;
expect(coordinator.getStatus()).toEqual({ phase: "loading", message: "" });
expect(source).toContain('if (snapshotBootstrapStatus.phase !== "ready")');
expect(source).toContain("Anwendungsdaten werden geladen");
expect(source.indexOf('if (snapshotBootstrapStatus.phase !== "ready")')).toBeLessThan(source.indexOf("<AppShell"));
});
it("accepts an early full stream snapshot as authoritative startup state", () => {
const coordinator = appModule.createSnapshotBootstrapCoordinator() as ReturnType<typeof appModule.createSnapshotBootstrapCoordinator> & {
getStatus?: () => { phase: string; message: string };
};
const full = structuredClone(createVisualFixture("dense").snapshot);
full.payloadKind = "full";
expect(coordinator.push(full)).toEqual(full);
expect(coordinator.getStatus).toBeTypeOf("function");
if (!coordinator.getStatus) return;
expect(coordinator.getStatus()).toEqual({ phase: "ready", message: "" });
});
it("marks the initial snapshot as authoritative after replaying queued deltas", () => {
const coordinator = appModule.createSnapshotBootstrapCoordinator() as ReturnType<typeof appModule.createSnapshotBootstrapCoordinator> & {
getStatus?: () => { phase: string; message: string };
};
const initial = structuredClone(createVisualFixture("dense").snapshot);
const delta = structuredClone(initial);
delta.payloadKind = "delta";
delta.session.running = true;
expect(coordinator.push(delta)).toBeNull();
expect(coordinator.initialize(initial).session.running).toBe(true);
expect(coordinator.getStatus).toBeTypeOf("function");
if (!coordinator.getStatus) return;
expect(coordinator.getStatus()).toEqual({ phase: "ready", message: "" });
});
it("shows a retryable startup error without promoting placeholder data", () => {
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8");
const coordinator = appModule.createSnapshotBootstrapCoordinator() as ReturnType<typeof appModule.createSnapshotBootstrapCoordinator> & {
fail?: (message: string) => { phase: string; message: string };
getStatus?: () => { phase: string; message: string };
};
expect(coordinator.fail).toBeTypeOf("function");
if (!coordinator.fail || !coordinator.getStatus) return;
expect(coordinator.fail("Snapshot nicht erreichbar")).toEqual({ phase: "error", message: "Snapshot nicht erreichbar" });
expect(coordinator.getStatus()).toEqual({ phase: "error", message: "Snapshot nicht erreichbar" });
expect(source).toContain('snapshotBootstrapStatus.phase === "error"');
expect(source).toContain('onClick={() => retrySnapshotBootstrapRef.current()}');
expect(source).toContain("Erneut versuchen");
});
it("retries a failed snapshot load until authoritative state arrives", () => {
const coordinator = appModule.createSnapshotBootstrapCoordinator() as ReturnType<typeof appModule.createSnapshotBootstrapCoordinator> & {
fail?: (message: string) => { phase: string; message: string };
retry?: () => { phase: string; message: string };
getStatus?: () => { phase: string; message: string };
};
const full = structuredClone(createVisualFixture("dense").snapshot);
full.payloadKind = "full";
expect(coordinator.fail).toBeTypeOf("function");
expect(coordinator.retry).toBeTypeOf("function");
if (!coordinator.fail || !coordinator.retry || !coordinator.getStatus) return;
coordinator.fail("Snapshot nicht erreichbar");
expect(coordinator.retry()).toEqual({ phase: "loading", message: "" });
coordinator.push(full);
coordinator.fail("Verspäteter Fehler");
expect(coordinator.getStatus()).toEqual({ phase: "ready", message: "" });
});
it("applies queued live removals before accepting a delayed initial snapshot", () => {
const createSnapshotBootstrapCoordinator = (appModule as unknown as {
createSnapshotBootstrapCoordinator?: () => {
initialize: (snapshot: ReturnType<typeof createVisualFixture>["snapshot"]) => ReturnType<typeof createVisualFixture>["snapshot"];
push: (snapshot: ReturnType<typeof createVisualFixture>["snapshot"]) => ReturnType<typeof createVisualFixture>["snapshot"] | null;
};
}).createSnapshotBootstrapCoordinator;
expect(createSnapshotBootstrapCoordinator).toBeTypeOf("function");
if (!createSnapshotBootstrapCoordinator) return;
const initial = structuredClone(createVisualFixture("dense").snapshot);
const removedItemId = Object.keys(initial.session.items)[0];
const removedPackageId = initial.session.items[removedItemId].packageId;
const coordinator = createSnapshotBootstrapCoordinator();
const delta = structuredClone(initial);
delta.payloadKind = "delta";
delta.session.items = {};
delta.session.packages = {};
delta.removedItemIds = [removedItemId];
delta.removedPackageIds = [removedPackageId];
expect(coordinator.push(delta)).toBeNull();
const merged = coordinator.initialize(initial);
expect(merged.session.items[removedItemId]).toBeUndefined();
expect(merged.session.packages[removedPackageId]).toBeUndefined();
expect(coordinator.push({ ...merged, payloadKind: "full" })).toEqual(expect.objectContaining({ payloadKind: "full" }));
const fullCoordinator = createSnapshotBootstrapCoordinator();
const newerFull = structuredClone(initial);
newerFull.payloadKind = "full";
delete newerFull.session.items[removedItemId];
expect(fullCoordinator.push(newerFull)?.session.items[removedItemId]).toBeUndefined();
expect(fullCoordinator.initialize(initial).session.items[removedItemId]).toBeUndefined();
});
it("drops buffered snapshots that are not newer than a retry snapshot", () => {
const coordinator = appModule.createSnapshotBootstrapCoordinator();
const retrySnapshot = structuredClone(createVisualFixture("dense").snapshot) as ReturnType<typeof createVisualFixture>["snapshot"] & {
snapshotRevision?: number;
};
retrySnapshot.snapshotRevision = 20;
retrySnapshot.canStart = true;
const staleDelta = structuredClone(retrySnapshot);
staleDelta.snapshotRevision = 19;
staleDelta.payloadKind = "delta";
staleDelta.canStart = false;
staleDelta.session.packageOrder = [];
staleDelta.session.items = {};
staleDelta.session.packages = {};
expect(coordinator.push(staleDelta)).toBeNull();
coordinator.fail("Snapshot nicht erreichbar");
coordinator.retry();
const restored = coordinator.initialize(retrySnapshot);
expect(restored.canStart).toBe(true);
expect(restored.session.packageOrder).toEqual(retrySnapshot.session.packageOrder);
expect(restored.snapshotRevision).toBe(20);
});
it("ignores stale live snapshots and accepts a newer full snapshot", () => {
const coordinator = appModule.createSnapshotBootstrapCoordinator();
const current = structuredClone(createVisualFixture("dense").snapshot) as ReturnType<typeof createVisualFixture>["snapshot"] & {
snapshotRevision?: number;
};
current.snapshotRevision = 30;
current.payloadKind = "full";
current.canStart = true;
const stale = structuredClone(current);
stale.snapshotRevision = 29;
stale.canStart = false;
const newer = structuredClone(current);
newer.snapshotRevision = 31;
newer.canStart = false;
expect(coordinator.push(current)).toEqual(current);
expect(coordinator.push(stale)).toBeNull();
expect(coordinator.push(newer)).toEqual(newer);
});
it("lets a newer initial GET replace an earlier full stream snapshot", () => {
const coordinator = appModule.createSnapshotBootstrapCoordinator();
const early = structuredClone(createVisualFixture("dense").snapshot) as ReturnType<typeof createVisualFixture>["snapshot"] & {
snapshotRevision?: number;
};
early.snapshotRevision = 40;
early.payloadKind = "full";
early.canStart = false;
const initial = structuredClone(early);
initial.snapshotRevision = 41;
initial.canStart = true;
expect(coordinator.push(early)).toEqual(early);
expect(coordinator.initialize(initial)).toEqual(initial);
});
it("applies a resolved initial GET after an early full stream bootstrap", () => {
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8");
const start = source.indexOf("const state = snapshotCoordinator.initialize(initialState)");
const end = source.indexOf("}).catch((error) =>", start);
const resolution = source.slice(start, end);
expect(start).toBeGreaterThan(-1);
expect(resolution).toContain("authoritativeSnapshotApplied");
expect(resolution).toContain("applyStreamSnapshot(state)");
expect(resolution).toContain("applyAuthoritativeSnapshot(state");
});
it("uses keyboard-focusable controls for every copy target", () => {
const appSource = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8");
const dialogSource = readFileSync(new URL("../src/renderer/ui/LinkAddressesDialog.tsx", import.meta.url), "utf8");
+13
View File
@@ -19,6 +19,19 @@ const statistics: StatisticsLedger = { version: 2, startedAt: 1, days: [], minut
const baseInput = { appVersion: "1.7.183", exportedAt: "2026-06-07T00:00:00Z", session, history, statistics };
describe("buildBackupPayload — default is settings-only", () => {
it("keeps the semantic theme preference in exported settings", () => {
const payload = buildBackupPayload({
...baseInput,
settings: settings({ theme: "dark", themePreference: "system" })
});
expect(payload.settings).toEqual(expect.objectContaining({
theme: "dark",
themePreference: "system"
}));
expect((JSON.parse(JSON.stringify(payload)) as { settings: AppSettings }).settings.themePreference).toBe("system");
});
it("keeps the Deepbrid key only inside the encrypted backup payload", () => {
const key = "fixture-deepbrid-backup-key-4eF6";
const payload = buildBackupPayload({
+91
View File
@@ -0,0 +1,91 @@
import { describe, expect, it } from "vitest";
import * as collector from "../src/shared/collector";
import * as collectorModel from "../src/renderer/views/collector/collector-model";
import type { CollectorLink, CollectorPackage } from "../src/shared/collector";
function link(index: number): CollectorLink {
return {
id: `link-${index}`,
url: `https://example.test/${index}`,
fileName: `file-${index}.bin`,
fileSizeBytes: null,
hoster: "example",
availability: "unknown",
status: "unknown",
addedAt: 1
};
}
function packageEntry(index: number, links: CollectorLink[] = [link(index)]): CollectorPackage {
return {
id: `package-${index}`,
name: `Package ${index}`,
nameSource: "explicit",
links,
addedAt: 1
};
}
describe("collector capacity", () => {
it("reports package and link overflow without cloning the state", () => {
const inspect = (collector as unknown as {
inspectCollectorCapacity?: (packages: CollectorPackage[]) => unknown;
}).inspectCollectorCapacity;
expect(inspect).toBeTypeOf("function");
expect(inspect?.(Array.from({ length: 2_001 }, (_, index) => packageEntry(index)))).toEqual({
ok: false,
packageCount: 2_001,
linkCount: 2_001,
message: "Der Linksammler kann höchstens 2.000 Pakete enthalten."
});
expect(inspect?.([packageEntry(0, Array.from({ length: 20_001 }, (_, index) => link(index)))])).toEqual({
ok: false,
packageCount: 1,
linkCount: 20_001,
message: "Der Linksammler kann höchstens 20.000 Links enthalten."
});
});
it("reports the exact serialized persistence overflow independently of count limits", () => {
const inspectPersistence = (collector as unknown as {
inspectCollectorPersistenceSize?: (state: { packages: CollectorPackage[]; collapsedPackageIds: string[] }, maximumBytes?: number) => { ok: boolean; byteCount: number; message?: string };
}).inspectCollectorPersistenceSize;
const state = { packages: [packageEntry(1)], collapsedPackageIds: ["package-1"] };
expect(inspectPersistence).toBeTypeOf("function");
const result = inspectPersistence?.(state, 128);
expect(result?.ok).toBe(false);
expect(result?.byteCount).toBeGreaterThan(128);
expect(result?.message).toBe("Der Linksammler ist zu groß, um gespeichert zu werden.");
});
it("rejects only the cumulative post-merge result and leaves the current state untouched", () => {
const mergeWithinCapacity = (collectorModel as unknown as {
mergeCollectorPackagesWithinCapacity?: (current: CollectorPackage[], incoming: CollectorPackage[], enrichment?: boolean) => unknown;
}).mergeCollectorPackagesWithinCapacity;
const current = Array.from({ length: 1_999 }, (_, index) => packageEntry(index));
const incoming = [packageEntry(1_999), packageEntry(2_000)];
expect(mergeWithinCapacity).toBeTypeOf("function");
expect(mergeWithinCapacity?.(current, incoming)).toEqual({
ok: false,
packageCount: 2_001,
linkCount: 2_001,
message: "Der Linksammler kann höchstens 2.000 Pakete enthalten."
});
expect(current).toHaveLength(1_999);
});
it("accepts an at-limit merge when the incoming link is only a duplicate", () => {
const mergeWithinCapacity = (collectorModel as unknown as {
mergeCollectorPackagesWithinCapacity?: (current: CollectorPackage[], incoming: CollectorPackage[], enrichment?: boolean) => { ok: boolean; value?: { packages: CollectorPackage[] } };
}).mergeCollectorPackagesWithinCapacity;
const current = Array.from({ length: 2_000 }, (_, index) => packageEntry(index));
expect(mergeWithinCapacity).toBeTypeOf("function");
const result = mergeWithinCapacity?.(current, [{ ...current[0], links: [{ ...current[0].links[0] }] }]);
expect(result?.ok).toBe(true);
expect(result?.value?.packages).toHaveLength(2_000);
});
});
+60 -3
View File
@@ -1,9 +1,23 @@
import { describe, expect, it } from "vitest";
import {
import * as collectorEnrichment from "../src/renderer/collector-enrichment";
import type { CollectorPackage } from "../src/shared/collector";
const {
beginCollectorEnrichment,
filterCurrentCollectorEnrichment
} from "../src/renderer/collector-enrichment";
import type { CollectorPackage } from "../src/shared/collector";
} = collectorEnrichment;
type PruneCollectorEnrichmentGenerations = (
current: Map<string, number>,
packages: CollectorPackage[],
activeSnapshots: Iterable<ReadonlyMap<string, number>>
) => void;
function pruner(): PruneCollectorEnrichmentGenerations | undefined {
return (collectorEnrichment as unknown as {
pruneCollectorEnrichmentGenerations?: PruneCollectorEnrichmentGenerations;
}).pruneCollectorEnrichmentGenerations;
}
function collectorPackage(url: string, status: "ready" | "offline" | "unknown" = "unknown"): CollectorPackage {
return {
@@ -34,4 +48,47 @@ describe("collector enrichment generations", () => {
expect(filterCurrentCollectorEnrichment([collectorPackage("https://example.test/file", "offline")], first, current)).toEqual([]);
expect(filterCurrentCollectorEnrichment([collectorPackage("https://example.test/file", "ready")], second, current)).toHaveLength(1);
});
it("retains an in-flight removed URL so reimport cannot reuse its generation", () => {
const url = "https://example.test/reimported";
const current = new Map<string, number>();
const first = beginCollectorEnrichment([collectorPackage(url)], current);
const activeSnapshots = new Map([["request-1", first]]);
expect(pruner()).toBeTypeOf("function");
pruner()?.(current, [], activeSnapshots.values());
expect(current).toEqual(new Map([[url, 1]]));
const second = beginCollectorEnrichment([collectorPackage(url)], current);
expect(second.get(url)).toBe(2);
expect(filterCurrentCollectorEnrichment([collectorPackage(url, "offline")], first, current)).toEqual([]);
expect(filterCurrentCollectorEnrichment([collectorPackage(url, "ready")], second, current)).toHaveLength(1);
});
it("removes the last generation after its request ends and the collector stays empty", () => {
const url = "https://example.test/completed";
const current = new Map<string, number>();
const snapshot = beginCollectorEnrichment([collectorPackage(url)], current);
pruner()?.(current, [], [snapshot]);
expect(current.size).toBe(1);
pruner()?.(current, [], []);
expect(current.size).toBe(0);
});
it("does not grow across many unique import and removal cycles", () => {
const current = new Map<string, number>();
for (let index = 0; index < 5_000; index += 1) {
const pkg = collectorPackage(`https://example.test/cycle-${index}`);
beginCollectorEnrichment([pkg], current);
pruner()?.(current, [pkg], []);
expect(current.size).toBe(1);
pruner()?.(current, [], []);
expect(current.size).toBe(0);
}
});
});
+329
View File
@@ -0,0 +1,329 @@
import { describe, expect, it, vi } from "vitest";
import type { CollectorLink, CollectorPackage, CollectorPersistenceState } from "../src/shared/collector";
import * as collectorHydration from "../src/renderer/views/collector/collector-hydration";
function link(index: number): CollectorLink {
return {
id: `link-${index}`,
url: `https://example.test/${index}`,
fileName: `file-${index}.bin`,
fileSizeBytes: null,
hoster: "example",
availability: "unknown",
status: "unknown",
addedAt: index
};
}
function packageEntry(index: number, links: CollectorLink[] = [link(index)]): CollectorPackage {
return {
id: `package-${index}`,
name: `Package ${index}`,
nameSource: "explicit",
links,
addedAt: index
};
}
type ResolveCollectorLateHydration = (
persistedState: CollectorPersistenceState,
currentState: CollectorPersistenceState,
maximumBytes?: number
) => {
ok: boolean;
state?: CollectorPersistenceState;
message?: string;
packageCount?: number;
linkCount?: number;
attemptedState?: CollectorPersistenceState;
currentState?: CollectorPersistenceState;
rollbackState?: CollectorPersistenceState;
};
type PersistCollectorStateBeforeUnload = (request: {
hydrated: boolean;
currentState: CollectorPersistenceState;
getPersistedStateSync: () => unknown;
saveStateSync: (state: CollectorPersistenceState) => boolean;
maximumBytes?: number;
}) => boolean;
type GuardCollectorBeforeUnload = (
event: { preventDefault: () => void; returnValue: string },
request: Parameters<PersistCollectorStateBeforeUnload>[0]
) => boolean;
function resolver(): ResolveCollectorLateHydration | undefined {
return (collectorHydration as unknown as {
resolveCollectorLateHydration?: ResolveCollectorLateHydration;
}).resolveCollectorLateHydration;
}
function beforeUnloadPersistence(): PersistCollectorStateBeforeUnload | undefined {
return (collectorHydration as unknown as {
persistCollectorStateBeforeUnload?: PersistCollectorStateBeforeUnload;
}).persistCollectorStateBeforeUnload;
}
function beforeUnloadGuard(): GuardCollectorBeforeUnload | undefined {
return (collectorHydration as unknown as {
guardCollectorBeforeUnload?: GuardCollectorBeforeUnload;
}).guardCollectorBeforeUnload;
}
describe("collector late hydration", () => {
it.each([
["synchronous loading fails", {
hydrated: false,
currentState: { packages: [packageEntry(1)], collapsedPackageIds: [] },
getPersistedStateSync: () => { throw new Error("collector unavailable"); },
saveStateSync: (): boolean => true
}],
["synchronous saving fails", {
hydrated: true,
currentState: { packages: [packageEntry(1)], collapsedPackageIds: [] },
getPersistedStateSync: vi.fn(),
saveStateSync: (): boolean => false
}],
["the merged state exceeds the persistence limit", {
hydrated: false,
currentState: { packages: [packageEntry(2)], collapsedPackageIds: [] },
getPersistedStateSync: () => ({ packages: [packageEntry(1)], collapsedPackageIds: [] }),
saveStateSync: (): boolean => true,
maximumBytes: 128
}]
])("blocks unload when %s", (_name, request) => {
const event = { preventDefault: vi.fn(), returnValue: "unchanged" };
expect(beforeUnloadGuard()).toBeTypeOf("function");
const persisted = beforeUnloadGuard()?.(event, request);
expect(persisted).toBe(false);
expect(event.preventDefault).toHaveBeenCalledTimes(1);
expect(event.returnValue).toBe("");
});
it("allows unload without changing the event after confirmed persistence", () => {
const event = { preventDefault: vi.fn(), returnValue: "unchanged" };
const persisted = beforeUnloadGuard()?.(event, {
hydrated: true,
currentState: { packages: [packageEntry(1)], collapsedPackageIds: [] },
getPersistedStateSync: vi.fn(),
saveStateSync: () => true
});
expect(persisted).toBe(true);
expect(event.preventDefault).not.toHaveBeenCalled();
expect(event.returnValue).toBe("unchanged");
});
it("synchronously merges a later main-store snapshot while async hydration is still pending", async () => {
const persistedState = {
packages: [packageEntry(1)],
collapsedPackageIds: ["package-1"]
};
const currentState = {
packages: [packageEntry(2)],
collapsedPackageIds: ["package-2"]
};
let resolveAsyncHydration = (_state: CollectorPersistenceState): void => {};
let asyncHydrationSettled = false;
const asyncHydration = new Promise<CollectorPersistenceState>((resolve) => {
resolveAsyncHydration = resolve;
}).then((state) => {
asyncHydrationSettled = true;
return state;
});
const getPersistedStateSync = vi.fn(() => persistedState);
const saveStateSync = vi.fn(() => true);
expect(beforeUnloadPersistence()).toBeTypeOf("function");
const saved = beforeUnloadPersistence()?.({
hydrated: false,
currentState,
getPersistedStateSync,
saveStateSync
});
expect(asyncHydrationSettled).toBe(false);
expect(saved).toBe(true);
expect(getPersistedStateSync).toHaveBeenCalledTimes(1);
expect(saveStateSync).toHaveBeenCalledWith({
packages: [packageEntry(1), packageEntry(2)],
collapsedPackageIds: ["package-1", "package-2"]
});
expect(saveStateSync).not.toHaveBeenCalledWith(currentState);
resolveAsyncHydration(persistedState);
await asyncHydration;
});
it("saves the validated current renderer state without reloading after hydration", () => {
const currentState = {
packages: [packageEntry(1)],
collapsedPackageIds: ["package-1", "missing-package"]
};
const getPersistedStateSync = vi.fn();
const saveStateSync = vi.fn(() => true);
const saved = beforeUnloadPersistence()?.({
hydrated: true,
currentState,
getPersistedStateSync,
saveStateSync
});
expect(saved).toBe(true);
expect(getPersistedStateSync).not.toHaveBeenCalled();
expect(saveStateSync).toHaveBeenCalledWith({
packages: [packageEntry(1)],
collapsedPackageIds: ["package-1"]
});
});
it("leaves the persisted state untouched when the early merge exceeds a limit", () => {
const persistedState = {
packages: [packageEntry(1)],
collapsedPackageIds: []
};
const saveStateSync = vi.fn(() => true);
const saved = beforeUnloadPersistence()?.({
hydrated: false,
currentState: {
packages: [packageEntry(2)],
collapsedPackageIds: []
},
getPersistedStateSync: () => persistedState,
saveStateSync,
maximumBytes: 128
});
expect(saved).toBe(false);
expect(saveStateSync).not.toHaveBeenCalled();
});
it("leaves the persisted state untouched when synchronous loading fails", () => {
const saveStateSync = vi.fn(() => true);
expect(() => beforeUnloadPersistence()?.({
hydrated: false,
currentState: {
packages: [packageEntry(2)],
collapsedPackageIds: []
},
getPersistedStateSync: () => { throw new Error("collector unavailable"); },
saveStateSync
})).not.toThrow();
expect(saveStateSync).not.toHaveBeenCalled();
});
it("does not report success when synchronous persistence rejects the write", () => {
const saveStateSync = vi.fn(() => false);
const saved = beforeUnloadPersistence()?.({
hydrated: true,
currentState: {
packages: [packageEntry(1)],
collapsedPackageIds: []
},
getPersistedStateSync: vi.fn(),
saveStateSync
});
expect(saved).toBe(false);
expect(saveStateSync).toHaveBeenCalledTimes(1);
});
it("merges persisted and pre-load imports with collapse state from both sides", () => {
const persistedState = {
packages: [packageEntry(1)],
collapsedPackageIds: ["package-1"]
};
const duplicateCurrentPackage = {
...packageEntry(1),
id: "current-duplicate",
links: [{ ...link(1), id: "current-duplicate-link" }]
};
const currentState = {
packages: [duplicateCurrentPackage, packageEntry(2)],
collapsedPackageIds: ["current-duplicate", "package-2"]
};
expect(resolver()).toBeTypeOf("function");
expect(resolver()?.(persistedState, currentState)).toEqual({
ok: true,
state: {
packages: [packageEntry(1), packageEntry(2)],
collapsedPackageIds: ["package-1", "package-2"]
}
});
expect(persistedState.packages).toEqual([packageEntry(1)]);
expect(currentState.packages).toEqual([duplicateCurrentPackage, packageEntry(2)]);
});
it("returns the cumulative attempted state and persisted rollback when package capacity is exceeded", () => {
const persistedState = {
packages: Array.from({ length: 1_999 }, (_, index) => packageEntry(index)),
collapsedPackageIds: ["package-0"]
};
const currentState = {
packages: [packageEntry(1_999), packageEntry(2_000)],
collapsedPackageIds: ["package-2000"]
};
expect(resolver()).toBeTypeOf("function");
expect(() => resolver()?.(persistedState, currentState)).not.toThrow();
const result = resolver()?.(persistedState, currentState);
expect(result).toMatchObject({
ok: false,
message: "Der Linksammler kann höchstens 2.000 Pakete enthalten.",
packageCount: 2_001,
linkCount: 2_001,
currentState,
rollbackState: persistedState
});
expect(result?.attemptedState?.packages).toHaveLength(2_001);
expect(result?.attemptedState?.collapsedPackageIds).toEqual(["package-0", "package-2000"]);
});
it("returns an explicit failure when only cumulative link capacity is exceeded", () => {
const persistedState = {
packages: [packageEntry(1, Array.from({ length: 10_000 }, (_, index) => link(index)))],
collapsedPackageIds: ["package-1"]
};
const currentState = {
packages: [packageEntry(2, Array.from({ length: 10_001 }, (_, index) => link(index + 10_000)))],
collapsedPackageIds: ["package-2"]
};
expect(resolver()).toBeTypeOf("function");
const result = resolver()?.(persistedState, currentState);
expect(result).toMatchObject({
ok: false,
message: "Der Linksammler kann höchstens 20.000 Links enthalten.",
packageCount: 2,
linkCount: 20_001,
currentState,
rollbackState: persistedState
});
expect(result?.attemptedState?.packages.flatMap((pkg) => pkg.links)).toHaveLength(20_001);
});
it("returns the persisted rollback before a cumulatively oversized state reaches the UI", () => {
const persistedState = { packages: [packageEntry(1)], collapsedPackageIds: [] };
const currentState = { packages: [packageEntry(2)], collapsedPackageIds: [] };
const result = resolver()?.(persistedState, currentState, 128);
expect(result).toMatchObject({
ok: false,
message: "Der Linksammler ist zu groß, um gespeichert zu werden.",
currentState,
rollbackState: persistedState
});
expect(result?.attemptedState?.packages).toHaveLength(2);
});
});
+88
View File
@@ -0,0 +1,88 @@
import { describe, expect, it } from "vitest";
import { parseCollectorQueueExport } from "../src/main/collector-import";
describe("collector queue export import", () => {
it("leaves non-JSON text to the regular collector parser", () => {
expect(parseCollectorQueueExport([
"# package: Staffel 1",
"https://example.com/episode-1.mkv"
].join("\n"))).toBeNull();
});
it("preserves URL and file-name positions while normalizing duplicate packages and links", () => {
const result = parseCollectorQueueExport(JSON.stringify({
version: 1,
packages: [{
name: "Staffel? 1",
links: [
" https://example.com/episode-1.mkv ",
"https://example.com/episode-2.mkv",
"https://example.com/episode-2.mkv"
],
fileNames: ["Episode 1.mkv", "", "Episode 2.mkv"]
}, {
name: "Staffel 1",
links: [
"https://example.com/episode-2.mkv",
"https://example.com/episode-3.mkv"
],
fileNames: ["ignored.mkv", "Episode 3.mkv"]
}]
}));
expect(result).toEqual([{
name: "Staffel 1",
links: [
"https://example.com/episode-1.mkv",
"https://example.com/episode-2.mkv",
"https://example.com/episode-3.mkv"
],
fileNames: ["Episode 1.mkv", "Episode 2.mkv", "Episode 3.mkv"]
}]);
});
it("reports malformed JSON instead of treating it as regular text", () => {
expect(() => parseCollectorQueueExport('{"version":1,"packages":[')).toThrow(/JSON/i);
});
it("rejects JSON that does not match the version-one queue export schema", () => {
const unsuitable = [
[],
{ version: 2, packages: [] },
{ version: 1, packages: [], exportedAt: "2026-08-26" },
{ version: 1, packages: [{ name: "Paket", links: ["not-a-url"] }] },
{
version: 1,
packages: [{
name: "Paket",
links: ["https://example.com/file.bin"],
fileNames: ["file.bin", "orphan.bin"]
}]
}
];
for (const payload of unsuitable) {
expect(() => parseCollectorQueueExport(JSON.stringify(payload))).toThrow(/Queue-Export.*ungültig/i);
}
});
it("rejects queue exports above the collector package and link limits", () => {
const tooManyPackages = {
version: 1,
packages: Array.from({ length: 2_001 }, (_, index) => ({
name: `Paket ${index}`,
links: ["https://example.com/file.bin"]
}))
};
const tooManyLinks = {
version: 1,
packages: [{
name: "Paket",
links: Array.from({ length: 20_001 }, (_, index) => `https://example.com/${index}`)
}]
};
expect(() => parseCollectorQueueExport(JSON.stringify(tooManyPackages))).toThrow(/2\.000 Pakete/i);
expect(() => parseCollectorQueueExport(JSON.stringify(tooManyLinks))).toThrow(/20\.000 Links/i);
});
});
+102
View File
@@ -6,8 +6,10 @@ import {
prepareCollectorText
} from "../src/main/collector-inspection";
import {
COLLECTOR_MAX_NAME_LENGTH,
validateCollectorContainerPreparationRequest,
validateCollectorEnrichmentRequest,
validateCollectorPersistenceState,
validateCollectorTextPreparationRequest
} from "../src/shared/collector";
import type { CollectorInspectionResult } from "../src/shared/collector";
@@ -45,6 +47,30 @@ describe("collector preparation", () => {
fetchRequest.mockRestore();
});
it("prepares the app's queue export JSON as collector packages", () => {
const rawText = JSON.stringify({
version: 1,
packages: [{
name: "Roundtrip",
links: ["https://example.test/one", "https://example.test/two"],
fileNames: ["one.bin", "two.bin"]
}]
}, null, 2);
const result = prepareCollectorText({ rawText, addedAt: 4_000 });
expect(result.invalidCount).toBe(0);
expect(result.duplicateCount).toBe(0);
expect(result.packages).toEqual([expect.objectContaining({
name: "Roundtrip",
nameSource: "explicit",
links: [
expect.objectContaining({ url: "https://example.test/one", fileName: "one.bin" }),
expect.objectContaining({ url: "https://example.test/two", fileName: "two.bin" })
]
})]);
});
it("decrypts selected DLC files into a skeleton without metadata enrichment", async () => {
const importContainers = vi.fn(async () => [{
name: "DLC Paket",
@@ -70,6 +96,33 @@ describe("collector preparation", () => {
}));
});
it("normalizes package names from every preparation input to the persistence limit", async () => {
const oversizedName = "n".repeat(COLLECTOR_MAX_NAME_LENGTH + 1);
const textResult = prepareCollectorText({
rawText: [`# Package: ${oversizedName}`, "# File: text.bin", "https://example.com/text"].join("\n"),
addedAt: 3_100
});
const containerResult = await prepareCollectorContainers(
["C:\\Imports\\oversized.dlc"],
3_200,
{
importContainers: async () => [{
name: oversizedName,
links: ["https://example.com/container"],
fileNames: ["container.bin"]
}]
}
);
for (const result of [textResult, containerResult]) {
expect(result.packages[0].name).toBe("n".repeat(COLLECTOR_MAX_NAME_LENGTH));
expect(() => validateCollectorPersistenceState({
packages: result.packages,
collapsedPackageIds: []
})).not.toThrow();
}
});
it("rejects oversized text and invalid container paths", () => {
expect(() => validateCollectorTextPreparationRequest({ rawText: "x".repeat(2_000_001), addedAt: 1 })).toThrow(/ungültig/i);
expect(() => validateCollectorTextPreparationRequest({ rawText: "ä".repeat(1_100_000), addedAt: 1 })).toThrow(/ungültig/i);
@@ -181,6 +234,55 @@ describe("collector enrichment", () => {
await run;
});
it("normalizes every external enrichment file name before progress and persistence", async () => {
const urls = [
"https://1fichier.com/?abc123def456ghi789jk",
"https://rapidgator.net/file/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/opaque.html",
"https://ddownload.com/abcdefghij",
"https://example.com/"
];
const prepared = prepareCollectorText({
rawText: ["# Package: External", ...urls.slice(0, 3), "# File: download.bin", urls[3]].join("\n"),
addedAt: 8_000
});
const externalFileName = "x".repeat(1_025);
const progress: CollectorInspectionResult[] = [];
const result = await enrichCollectorPackages(
{ requestId: "request-external-file-names", packages: prepared.packages },
defaultSettings(),
{
checkOneFichier: async (links) => new Map(links.map((url) => [url, {
online: true,
fileName: externalFileName,
fileSizeBytes: 100,
accessRestricted: false
}])),
checkRapidgator: async () => ({ online: true, fileName: externalFileName, fileSizeBytes: 200 }),
checkDdownload: async () => ({ online: true, fileName: externalFileName, fileSizeBytes: 300 }),
resolveFilenames: async (links, onResolved) => {
for (const url of links) onResolved?.(url, externalFileName);
return new Map(links.map((url) => [url, externalFileName]));
}
},
(entry) => progress.push(entry)
);
const finalLinks = result.packages.flatMap((pkg) => pkg.links);
const progressLinks = progress.flatMap((entry) => entry.packages).flatMap((pkg) => pkg.links);
expect(finalLinks.map((link) => link.url).sort()).toEqual([...urls].sort());
expect([...new Set(progressLinks.map((link) => link.url))].sort()).toEqual([...urls].sort());
for (const link of [...finalLinks, ...progressLinks]) {
expect(link.fileName).toBe("x".repeat(1_024));
}
for (const entry of [...progress, result]) {
expect(() => validateCollectorPersistenceState({
packages: entry.packages,
collapsedPackageIds: []
})).not.toThrow();
}
});
it("rejects enrichment payloads that do not contain prepared absolute links", () => {
expect(() => validateCollectorEnrichmentRequest({ requestId: "request-empty", packages: [] })).toThrow(/ungültig/i);
expect(() => validateCollectorEnrichmentRequest({
+222
View File
@@ -0,0 +1,222 @@
import { describe, expect, it } from "vitest";
import {
inspectCollectorPersistenceSize,
type CollectorLink,
type CollectorPackage,
type CollectorPersistenceState
} from "../src/shared/collector";
import {
advanceCollectorPersistenceBudget,
createCollectorPersistenceBudget
} from "../src/renderer/views/collector/collector-persistence-budget";
import { mergeCollectorPackages } from "../src/renderer/views/collector/collector-model";
function link(id: string, url = `https://example.test/${id}`): CollectorLink {
return {
id,
url,
fileName: `${id}.bin`,
fileSizeBytes: null,
hoster: "example",
availability: "unknown",
status: "unknown",
addedAt: 1
};
}
function packageEntry(id: string, name: string, links: CollectorLink[]): CollectorPackage {
return { id, name, nameSource: "explicit", links, addedAt: 1 };
}
function requiredByteCount(state: CollectorPersistenceState): number {
const result = inspectCollectorPersistenceSize(state, Number.MAX_SAFE_INTEGER);
if (!result.ok) throw new Error(result.message);
return result.byteCount;
}
describe("collector persistence budget", () => {
it("creates exact UTF-8 package and envelope accounting with one serialization per link", () => {
const firstLink = link("link-ä");
const secondLink = link("link-two");
const plainFirst = packageEntry("package-ä", "Paket ä", [firstLink]);
const plainSecond = packageEntry("package-two", "Paket 二", [secondLink]);
const calls = new Map<string, number>();
const tracked = (entry: CollectorLink): CollectorLink => Object.assign({}, entry, {
toJSON: () => {
calls.set(entry.id, (calls.get(entry.id) ?? 0) + 1);
return entry;
}
});
const state = {
packages: [
{ ...plainFirst, links: [tracked(firstLink)] },
{ ...plainSecond, links: [tracked(secondLink)] }
],
collapsedPackageIds: ["package-ä"]
};
const expectedState = { packages: [plainFirst, plainSecond], collapsedPackageIds: ["package-ä"] };
const expectedBytes = requiredByteCount(expectedState);
const result = createCollectorPersistenceBudget(state, expectedBytes);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.byteCount).toBe(expectedBytes);
expect(result.nextBudget.byteCount).toBe(expectedBytes);
expect(result.nextBudget.packageByteCounts).toEqual(new Map([
[plainFirst.id, new TextEncoder().encode(JSON.stringify(plainFirst)).byteLength],
[plainSecond.id, new TextEncoder().encode(JSON.stringify(plainSecond)).byteLength]
]));
expect(result.nextBudget.packageBaseByteCounts).toBeInstanceOf(Map);
expect(result.nextBudget.linkByteCounts).toBeInstanceOf(Map);
expect(result.nextBudget.packageLinkCounts).toBeInstanceOf(Map);
expect(result.nextBudget.packageBaseByteCounts.size).toBe(2);
expect(result.nextBudget.linkByteCounts.size).toBe(2);
expect(result.nextBudget.packageLinkCounts).toEqual(new Map([[plainFirst.id, 1], [plainSecond.id, 1]]));
expect(calls).toEqual(new Map([[firstLink.id, 1], [secondLink.id, 1]]));
});
it("serializes only the changed link when advancing a 20,000-link package", () => {
const plainLinks = Array.from({ length: 20_000 }, (_, index) => link(`large-${index}`));
const calls = new Map<string, number>();
const tracked = (entry: CollectorLink): CollectorLink => Object.assign({}, entry, {
toJSON: () => {
calls.set(entry.id, (calls.get(entry.id) ?? 0) + 1);
return entry;
}
});
const currentLinks = plainLinks.map(tracked);
const currentPackage = packageEntry("large-package", "Großes Paket", currentLinks);
const baseline = createCollectorPersistenceBudget({ packages: [currentPackage], collapsedPackageIds: [] });
expect(baseline.ok).toBe(true);
if (!baseline.ok) return;
calls.clear();
const changedIndex = 12_345;
const changedPlainLink = { ...plainLinks[changedIndex], fileName: "angereichert.bin", availability: "online" as const, status: "ready" as const };
const nextLinks = [...currentLinks];
nextLinks[changedIndex] = tracked(changedPlainLink);
const nextPackage = packageEntry("large-package", "Großes Paket", nextLinks);
const expectedLinks = [...plainLinks];
expectedLinks[changedIndex] = changedPlainLink;
const expectedState = {
packages: [packageEntry("large-package", "Großes Paket", expectedLinks)],
collapsedPackageIds: []
};
const expectedBytes = requiredByteCount(expectedState);
let result: ReturnType<typeof advanceCollectorPersistenceBudget> | undefined;
let elapsedMs = Number.POSITIVE_INFINITY;
expect(() => {
const startedAt = performance.now();
result = advanceCollectorPersistenceBudget(
baseline.nextBudget,
{
links: [{
url: changedPlainLink.url,
previousPackageId: currentPackage.id,
previousLink: currentLinks[changedIndex],
nextPackageId: nextPackage.id,
nextLink: nextLinks[changedIndex]
}],
packages: [{
package: { id: nextPackage.id, name: nextPackage.name, nameSource: nextPackage.nameSource, addedAt: nextPackage.addedAt },
linkCount: nextPackage.links.length
}],
removedPackageIds: [],
packageCount: 1
},
[]
);
elapsedMs = performance.now() - startedAt;
}).not.toThrow();
if (!result) return;
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.byteCount).toBe(expectedBytes);
expect([...calls.values()].reduce((sum, count) => sum + count, 0)).toBe(1);
expect(calls).toEqual(new Map([[changedPlainLink.id, 1]]));
expect(elapsedMs).toBeLessThan(5);
});
it("updates moved links, package ids and comma bytes from the merge delta", () => {
const first = link("first", "https://example.test/first");
const second = link("second", "https://example.test/second");
const pending = packageEntry("pending", "Pending", [first, second]);
const untouched = packageEntry(
"untouched",
"Unberührt",
Array.from({ length: 512 }, (_, index) => link(`bulk-${index}`))
);
const resolvedFirst = packageEntry("resolved-first", "Erstes Paket", [{ ...first, fileName: "Erstes Paket.bin" }]);
const resolvedSecond = packageEntry("resolved-second", "Zweites Paket", [second]);
const baselineState = { packages: [pending, untouched], collapsedPackageIds: [] };
const baseline = createCollectorPersistenceBudget(baselineState);
expect(baseline.ok).toBe(true);
if (!baseline.ok) return;
const untouchedBytes = baseline.nextBudget.packageByteCounts.get(untouched.id);
const merged = mergeCollectorPackages(baselineState.packages, [resolvedFirst, resolvedSecond]);
const expectedState = {
packages: merged.packages,
collapsedPackageIds: ["resolved-second"]
};
const result = advanceCollectorPersistenceBudget(
baseline.nextBudget,
merged.persistenceDelta,
expectedState.collapsedPackageIds
);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.byteCount).toBe(requiredByteCount(expectedState));
expect([...result.nextBudget.packageByteCounts.keys()].sort()).toEqual(["resolved-first", "resolved-second", "untouched"]);
expect(result.nextBudget.packageByteCounts.get(untouched.id)).toBe(untouchedBytes);
});
it("atomically rejects enrichment that moves a skeleton over a configurable byte limit", () => {
const skeletonPackage = packageEntry("stable-package", "x", [link("stable-link")]);
const enrichedPackage = { ...skeletonPackage, name: "ä".repeat(1_024) };
const skeletonState = { packages: [skeletonPackage], collapsedPackageIds: [] };
const enrichedState = { packages: [enrichedPackage], collapsedPackageIds: [] };
const skeletonBytes = requiredByteCount(skeletonState);
const enrichedBytes = requiredByteCount(enrichedState);
const maximumBytes = skeletonBytes + 1;
const baseline = createCollectorPersistenceBudget(skeletonState, maximumBytes);
expect(skeletonBytes).toBeLessThan(maximumBytes);
expect(enrichedBytes).toBeGreaterThan(maximumBytes);
expect(baseline.ok).toBe(true);
if (!baseline.ok) return;
const originalPackageByteCounts = baseline.nextBudget.packageByteCounts;
const originalEntries = [...originalPackageByteCounts];
const result = advanceCollectorPersistenceBudget(
baseline.nextBudget,
{
links: [],
packages: [{
package: {
id: enrichedPackage.id,
name: enrichedPackage.name,
nameSource: enrichedPackage.nameSource,
addedAt: enrichedPackage.addedAt
},
linkCount: enrichedPackage.links.length
}],
removedPackageIds: [],
packageCount: 1
},
[],
maximumBytes
);
expect(result).toEqual({
ok: false,
byteCount: enrichedBytes,
message: "Der Linksammler ist zu groß, um gespeichert zu werden."
});
expect(baseline.nextBudget.byteCount).toBe(skeletonBytes);
expect(baseline.nextBudget.packageByteCounts).toBe(originalPackageByteCounts);
expect([...baseline.nextBudget.packageByteCounts]).toEqual(originalEntries);
});
});
+120 -2
View File
@@ -1,8 +1,9 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { validateCollectorPersistenceState } from "../src/shared/collector";
import {
createCollectorPersistenceCoordinator,
restoreCollectorPersistenceState
restoreCollectorPersistenceState,
type CollectorPersistenceFailure
} from "../src/renderer/views/collector/collector-persistence";
const packageEntry = {
@@ -45,6 +46,123 @@ describe("collector persistence payload", () => {
});
describe("collector persistence renderer flow", () => {
it("does not inspect or clone scheduled state", () => {
const coordinator = createCollectorPersistenceCoordinator(async (state) => state, 60_000);
const inaccessibleState = new Proxy({ packages: [], collapsedPackageIds: [] }, {
ownKeys: () => {
throw new Error("scheduled state was inspected");
}
});
expect(() => coordinator.schedule(inaccessibleState)).not.toThrow();
coordinator.dispose();
});
it("does not throw when timer scheduling fails", async () => {
const saved: string[] = [];
const coordinator = createCollectorPersistenceCoordinator(async (state) => {
saved.push(state.packages[0]?.id || "empty");
return state;
});
const timerSpy = vi.spyOn(globalThis, "setTimeout").mockImplementationOnce(() => {
throw new Error("timer failed");
});
try {
expect(() => coordinator.schedule({ packages: [packageEntry], collapsedPackageIds: [] })).not.toThrow();
} finally {
timerSpy.mockRestore();
}
await coordinator.flush();
expect(saved).toEqual(["package-one"]);
});
it("debounces twenty thousand progressive schedules before validating only the newest state", async () => {
const saved: string[] = [];
const coordinator = createCollectorPersistenceCoordinator(async (state) => {
saved.push(state.packages[0]?.id || "empty");
return state;
}, 60_000);
const cloneSpy = vi.spyOn(globalThis, "structuredClone");
try {
for (let index = 0; index < 20_000; index += 1) {
coordinator.schedule({
packages: [{ ...packageEntry, id: `progress-${index}` }],
collapsedPackageIds: []
});
}
expect(cloneSpy).not.toHaveBeenCalled();
await coordinator.flush();
expect(saved).toEqual(["progress-19999"]);
expect(cloneSpy).toHaveBeenCalledTimes(1);
} finally {
coordinator.dispose();
cloneSpy.mockRestore();
}
});
it("reports validation failures with the attempted state and configured rollback baseline", async () => {
const baseline = { packages: [packageEntry], collapsedPackageIds: ["package-one"] };
const attemptedState = {
packages: [{ ...packageEntry, name: "" }],
collapsedPackageIds: []
};
const failures: CollectorPersistenceFailure[] = [];
let saveCalls = 0;
const coordinator = createCollectorPersistenceCoordinator(async (state) => {
saveCalls += 1;
return state;
}, 60_000, (failure) => {
failures.push(failure);
});
coordinator.setBaseline(baseline);
expect(() => coordinator.schedule(attemptedState)).not.toThrow();
await expect(coordinator.flush()).resolves.toBeUndefined();
expect(saveCalls).toBe(0);
expect(failures).toHaveLength(1);
expect(failures[0]?.error).toBeInstanceOf(Error);
expect(failures[0]?.attemptedState).toBe(attemptedState);
expect(failures[0]?.rollbackState).toBe(baseline);
});
it("reports save failures without rejecting flush and rolls back to the last successful save", async () => {
const failures: CollectorPersistenceFailure[] = [];
const coordinator = createCollectorPersistenceCoordinator(async (state) => {
if (state.packages[0]?.id === "save-fails") throw new Error("save failed");
return state;
}, 60_000, (failure) => {
failures.push(failure);
});
const successfulState = {
packages: [{ ...packageEntry, id: "saved" }],
collapsedPackageIds: []
};
const failedState = {
packages: [{ ...packageEntry, id: "save-fails" }],
collapsedPackageIds: []
};
coordinator.schedule(successfulState);
await coordinator.flush();
coordinator.schedule(failedState);
await expect(coordinator.flush()).resolves.toBeUndefined();
expect(failures).toHaveLength(1);
expect(failures[0]?.error).toEqual(new Error("save failed"));
expect(failures[0]?.attemptedState).toBe(failedState);
expect(failures[0]?.rollbackState).toEqual(successfulState);
expect(failures[0]?.rollbackState).not.toBe(successfulState);
});
it("merges a late restore with current imports and keeps collapse state from both sides", () => {
const persisted = { packages: [packageEntry], collapsedPackageIds: ["package-one"] };
const currentPackage = {
+219 -2
View File
@@ -8,8 +8,12 @@ import {
mergeCollectorEnrichment,
mergeCollectorPackages,
removeCollectorLinks,
reconcileCollectorCollapsedPackageIdsWithPackages,
reconcileCollectorCollapsedPackageIds,
reconcileCollectorSelectionAfterRemoval,
reconcileCollectorSelectionWithPackages,
selectCollectorPackageLinks,
setCollectorVisibleSelection,
type CollectorPackage
} from "../src/renderer/views/collector/collector-model";
import {
@@ -18,6 +22,7 @@ import {
MemoizedCollectorContent,
CollectorSidebar,
CollectorSidebarStatus,
CollectorTableHeader,
CollectorToolbar,
CollectorView,
collectorFileInteractionAttributes,
@@ -69,6 +74,7 @@ function createActions(overrides: Partial<CollectorViewActions> = {}): Collector
onQueryChange: () => {},
onLinkSelectionChange: () => {},
onPackageSelectionChange: () => {},
onSetVisibleSelection: () => {},
onPackageCollapseChange: () => {},
onToggleAllPackages: () => {},
onRemoveSelected: () => {},
@@ -97,6 +103,31 @@ const packages: CollectorPackage[] = [{
}];
describe("collector workspace model", () => {
it("prunes rolled-back selection so an identical link reimport is not ghost-selected", () => {
const afterRollback = reconcileCollectorSelectionWithPackages(new Set(["link-1", "link-4"]), [packages[1]]);
const afterReimport = buildCollectorWorkspaceViewModel(packages, "all", "", false, [...afterRollback], [], "", true);
expect(afterRollback).toEqual(new Set(["link-4"]));
expect(afterReimport.selectedIds).toEqual(["link-4"]);
});
it("prunes removed collapsed package ids so an identical package reimport stays expanded", () => {
const afterRemoval = reconcileCollectorCollapsedPackageIdsWithPackages(new Set(["package-sbs", "package-mixed"]), [packages[1]]);
const afterReimport = buildCollectorWorkspaceViewModel(packages, "all", "", false, [], [...afterRemoval], "", true);
expect(afterRemoval).toEqual(new Set(["package-mixed"]));
expect(afterReimport.packages.map((pkg) => [pkg.id, pkg.collapsed])).toEqual([
["package-sbs", false],
["package-mixed", true]
]);
});
it("sets only visible link selection while preserving hidden ids", () => {
const selected = setCollectorVisibleSelection(new Set(["link-1", "hidden-link"]), ["link-1", "link-2", "link-4"], true);
expect(selected).toEqual(new Set(["link-1", "hidden-link", "link-2", "link-4"]));
expect(setCollectorVisibleSelection(selected, ["link-1", "link-2", "link-4"], false)).toEqual(new Set(["hidden-link"]));
});
it("merges late enrichment by URL without duplicates and moves the link into its resolved package", () => {
const initial: CollectorPackage[] = [{
id: "pending",
@@ -126,6 +157,21 @@ describe("collector workspace model", () => {
status: "ready",
addedAt: 1_000
})]);
expect(result.persistenceDelta).toEqual({
links: [{
url: "https://1fichier.com/?one11111",
previousPackageId: "pending",
previousLink: initial[0].links[0],
nextPackageId: "resolved",
nextLink: result.packages[0].links[0]
}],
packages: [{
package: { id: "resolved", name: "SBS14HD", nameSource: "inferred", addedAt: 1_000 },
linkCount: 1
}],
removedPackageIds: ["pending"],
packageCount: 1
});
});
it("deduplicates repeated incoming URLs while preserving distinct links", () => {
@@ -267,6 +313,15 @@ describe("collector workspace model", () => {
]);
});
it("preserves hidden selections when visible selected links are removed", () => {
const next = reconcileCollectorSelectionAfterRemoval(
new Set(["link-1", "link-4"]),
new Set(["link-1"])
);
expect(next).toEqual(new Set(["link-4"]));
});
it("derives aggregates, filters and search once for the view", () => {
const model = buildCollectorWorkspaceViewModel(packages, "online", "part02", false, ["link-2"], ["package-mixed"], "", true);
@@ -281,6 +336,43 @@ describe("collector workspace model", () => {
]);
});
it("limits selection and action scope to links visible through filter and search", () => {
const model = buildCollectorWorkspaceViewModel(
packages,
"online",
"part02",
false,
["link-1", "link-2", "link-3"],
[],
"",
true
);
expect(model.visibleIds).toEqual(["link-2"]);
expect(model.selectedIds).toEqual(["link-2"]);
expect(model.selectedCount).toBe(1);
expect(model.packages[0].selectedCount).toBe(1);
});
it("keeps offline links selectable and removable but excludes them from transfers", () => {
const model = buildCollectorWorkspaceViewModel(
packages,
"all",
"",
false,
["link-1", "link-2", "link-3", "link-4"],
[],
"",
true
);
expect(model.visibleIds).toEqual(["link-1", "link-2", "link-3", "link-4"]);
expect(model.selectedIds).toEqual(["link-1", "link-2", "link-3", "link-4"]);
expect(model.visibleTransferableIds).toEqual(["link-1", "link-2", "link-3"]);
expect(model.selectedTransferableIds).toEqual(["link-1", "link-2", "link-3"]);
expect(model.selectedCount).toBe(4);
});
it("keeps initially unknown links visible while background analysis runs", () => {
const model = buildCollectorWorkspaceViewModel([packages[1]], "all", "", true, [], [], "", true);
@@ -336,6 +428,111 @@ describe("collector disclosure virtualization", () => {
});
describe("CollectorView", () => {
it("derives header selection without iterating visible ids and forwards them unchanged", () => {
const visibleIds = new Proxy(["link-1", "link-2"], {
get(target, property, receiver) {
if (property === Symbol.iterator || property === "reduce") throw new Error("visible ids must not be iterated");
return Reflect.get(target, property, receiver);
}
});
const base = buildCollectorWorkspaceViewModel(packages, "all", "", false, [], [], "", true);
const model = { ...base, visibleIds, selectedIds: [], selectedCount: 1 };
const calls: Array<{ ids: string[]; selected: boolean }> = [];
let header: ReactElement | undefined;
expect(() => {
header = CollectorTableHeader({
actions: createActions({ onSetVisibleSelection: (ids, selected) => calls.push({ ids, selected }) }),
model,
scrollLeft: 0
});
}).not.toThrow();
if (!header) return;
const checkbox = findElement(header, (element) => element.type === "input");
checkbox.props.onChange({ target: { checked: true } });
expect(checkbox.props["aria-checked"]).toBe("mixed");
expect(checkbox.props.checked).toBe(false);
expect(calls).toHaveLength(1);
expect(calls[0].ids).toBe(visibleIds);
expect(calls[0].selected).toBe(true);
});
it("routes every visible link including offline through the header checkbox", () => {
const calls: unknown[] = [];
const model = buildCollectorWorkspaceViewModel(packages, "all", "", false, ["link-1", "link-4"], [], "", true);
const header = CollectorTableHeader({
actions: createActions({ onSetVisibleSelection: (ids, selected) => calls.push([ids, selected]) }),
model,
scrollLeft: 0
});
const checkbox = findElement(header, (element) => element.type === "input");
const input = { indeterminate: false };
(checkbox as unknown as { ref: (element: typeof input) => void }).ref(input);
checkbox.props.onChange({ target: { checked: true } });
expect(checkbox.props["aria-label"]).toBe("Alle sichtbaren Links auswählen");
expect(checkbox.props["aria-checked"]).toBe("mixed");
expect(checkbox.props.checked).toBe(false);
expect(checkbox.props.disabled).toBe(false);
expect(input.indeterminate).toBe(true);
expect(calls).toEqual([[['link-1', 'link-2', 'link-3', 'link-4'], true]]);
});
it("checks or disables the header checkbox from visible selection state", () => {
const selectedHeader = CollectorTableHeader({
actions: createActions(),
model: buildCollectorWorkspaceViewModel(packages, "offline", "", false, ["link-4"], [], "", true),
scrollLeft: 19
});
const selectedCheckbox = findElement(selectedHeader, (element) => element.type === "input");
const emptyHeader = CollectorTableHeader({
actions: createActions(),
model: buildCollectorWorkspaceViewModel(packages, "online", "missing", false, [], [], "", true),
scrollLeft: 0
});
const emptyCheckbox = findElement(emptyHeader, (element) => element.type === "input");
expect(selectedCheckbox.props["aria-checked"]).toBe(true);
expect(selectedCheckbox.props.checked).toBe(true);
expect(selectedCheckbox.props.disabled).toBe(false);
expect(selectedHeader.props.style).toEqual({ transform: "translateX(-19px)" });
expect(emptyCheckbox.props["aria-checked"]).toBe(false);
expect(emptyCheckbox.props.checked).toBe(false);
expect(emptyCheckbox.props.disabled).toBe(true);
});
it("uses visible link counts and selection state for filtered package actions", () => {
const model = buildCollectorWorkspaceViewModel(packages, "online", "part02", false, ["link-2"], [], "", true);
const toolbar = CollectorToolbar({ actions: createActions(), model });
const content = renderToStaticMarkup(<CollectorContent actions={createActions()} model={model} />);
expect(findButton(toolbar, "Alle übergeben (1)").props.disabled).toBe(false);
expect(content).toMatch(/aria-checked="true" aria-label="Paket SBS14HD auswählen"/);
expect(content).not.toContain('aria-checked="mixed" aria-label="Paket SBS14HD auswählen"');
});
it("disables transfer actions for an offline selection while keeping removal enabled", () => {
const model = buildCollectorWorkspaceViewModel(packages, "offline", "", false, ["link-4"], [], "", true);
const toolbar = CollectorToolbar({ actions: createActions(), model });
expect(findButton(toolbar, "Auswahl übergeben (0)").props.disabled).toBe(true);
expect(findButton(toolbar, "Alle übergeben (0)").props.disabled).toBe(true);
expect(findButton(toolbar, "Auswahl entfernen").props.disabled).toBe(false);
});
it("counts only transferable links in transfer actions", () => {
const model = buildCollectorWorkspaceViewModel(packages, "all", "", false, ["link-1", "link-2", "link-3", "link-4"], [], "", true);
const toolbar = CollectorToolbar({ actions: createActions(), model });
expect(findButton(toolbar, "Auswahl übergeben (3)").props.disabled).toBe(false);
expect(findButton(toolbar, "Alle übergeben (3)").props.disabled).toBe(false);
expect(findButton(toolbar, "Auswahl entfernen").props.disabled).toBe(false);
});
it("collapses newly imported packages while preserving existing disclosure state", () => {
const incoming = [{ ...packages[0], id: "temporary-import-package" }];
const collapsed = reconcileCollectorCollapsedPackageIds(new Set(["package-mixed"]), [packages[1]], packages, incoming, true);
@@ -431,10 +628,30 @@ describe("CollectorView", () => {
expect(sidebarStatus).toContain('role="status"');
expect(html).toContain("SBS14HD.part01.rar");
expect(findButton(toolbar, "Auswahl übergeben (1)").props.disabled).toBe(false);
expect(findButton(toolbar, "Alle übergeben (4)").props.disabled).toBe(false);
expect(findButton(toolbar, "Alle übergeben (3)").props.disabled).toBe(false);
expect(findButton(toolbar, "Auswahl entfernen").props.disabled).toBe(false);
});
it("formats collector sidebar and filter counters as German integers", () => {
const base = buildCollectorWorkspaceViewModel(packages, "all", "", false, [], [], "", true);
const model = {
...base,
packageCount: 1_250,
totalCount: 3_180,
selectedCount: 1_000,
filters: base.filters.map((filter, index) => ({ ...filter, count: [3_180, 1_250, 1_000, 930][index] ?? 0 }))
};
const sidebar = renderToStaticMarkup(<CollectorSidebar actions={createActions()} model={model} />);
const status = renderToStaticMarkup(<CollectorSidebarStatus model={model} />);
for (const count of ["3.180", "1.250", "1.000", "930"]) expect(sidebar).toContain(`>${count}<`);
expect(status).toContain("Pakete: 1.250");
expect(status).toContain("Links: 3.180");
expect(status).toContain("Ausgewählt: 1.000");
expect(sidebar).not.toContain(">3180<");
expect(status).not.toContain("Links: 3180");
});
it("derives status exclusively from availability instead of filename readiness", () => {
const unknownReady = [{
...packages[0],
@@ -505,7 +722,7 @@ describe("CollectorView", () => {
});
findButton(toolbar, "Auswahl übergeben (1)").props.onClick();
findButton(toolbar, "Alle übergeben (4)").props.onClick();
findButton(toolbar, "Alle übergeben (3)").props.onClick();
expect(selected).toBe(1);
expect(all).toBe(1);
});
+6
View File
@@ -117,4 +117,10 @@ describe("ContextMenu", () => {
expect(stylesSource).toMatch(/\.ctx-menu-sub-items\s*{[^}]*position:\s*fixed;/s);
expect(stylesSource).not.toMatch(/\.ctx-menu-sub-items\s*{[^}]*position:\s*absolute;/s);
});
it("dims disabled context actions without pointer or hover affordances", () => {
expect(stylesSource).toMatch(/\.ctx-menu-item:disabled\s*\{[^}]*opacity:\s*(?:0?\.\d+|\d+%);[^}]*cursor:\s*(?:default|not-allowed);/s);
expect(stylesSource).toMatch(/\.ctx-menu-item:hover:not\(:disabled\)\s*\{[^}]*background:\s*var\(--button-bg-hover\);/s);
expect(stylesSource).not.toMatch(/\.ctx-menu-item:hover\s*\{[^}]*background:/s);
});
});
+238 -4
View File
@@ -14,8 +14,8 @@ import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys";
import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits";
import { getItemLogPath, initItemLogs, shutdownItemLogs } from "../src/main/item-log";
import { initPackageLogs, shutdownPackageLogs } from "../src/main/package-log";
import { createStoragePaths, emptySession, loadSession, saveSession } from "../src/main/storage";
import { loadStatisticsLedger } from "../src/main/statistics-ledger";
import { createStoragePaths, emptySession, loadSession, loadSettings, saveSession } from "../src/main/storage";
import { loadStatisticsLedger, saveStatisticsLedger } from "../src/main/statistics-ledger";
import { getProviderRuntimeSnapshot, primeDebridLinkRuntimeCooldownForTests, resetDebridLinkRuntimeStateForTests, primeMegaDebridRuntimeCooldownForTests, resetMegaDebridRuntimeStateForTests, primeMegaDebridInFlightForTests, primeRealDebridRuntimeCooldownForTests, resetRealDebridRuntimeStateForTests } from "../src/main/debrid";
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
import { serializeRealDebridApiAccounts } from "../src/shared/real-debrid-accounts";
@@ -86,6 +86,22 @@ describe("download live update cadence", () => {
});
});
describe("snapshot revision", () => {
it("assigns a strictly increasing revision to every captured snapshot", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-snapshot-revision-"));
tempDirs.push(root);
const manager = new DownloadManager(defaultSettings(), emptySession(), createStoragePaths(path.join(root, "state")));
const first = manager.getSnapshot() as ReturnType<DownloadManager["getSnapshot"]> & { snapshotRevision?: number };
const second = manager.getSnapshot() as ReturnType<DownloadManager["getSnapshot"]> & { snapshotRevision?: number };
const emitted = manager.getSnapshotForEmit() as ReturnType<DownloadManager["getSnapshotForEmit"]> & { snapshotRevision?: number };
expect(first.snapshotRevision).toEqual(expect.any(Number));
expect(second.snapshotRevision).toBeGreaterThan(first.snapshotRevision ?? Number.MAX_SAFE_INTEGER);
expect(emitted.snapshotRevision).toBeGreaterThan(second.snapshotRevision ?? Number.MAX_SAFE_INTEGER);
});
});
describe("resolveUnrestrictTimeoutBudgetMs", () => {
it("covers the complete provider plan and each later Real-Debrid account attempt", () => {
const settings = {
@@ -1039,8 +1055,37 @@ describe("download start account gate", () => {
expect(manager.getSnapshot().session.paused).toBe(true);
});
});
describe("extractArchiveNameFromExtractorLogMessage", () => {
describe("run-scoped remaining volume", () => {
it("projects remaining bytes only for items in the current run", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-run-remaining-"));
tempDirs.push(root);
const manager = new DownloadManager(
{ ...defaultSettings(), token: "rd-token" },
emptySession(),
createStoragePaths(path.join(root, "state"))
);
manager.addPackages([
{ name: "selected", links: ["https://example.test/selected"] },
{ name: "excluded", links: ["https://example.test/excluded"] }
]);
const internal = manager as any;
const itemIds = Object.keys(internal.session.items);
internal.session.running = true;
internal.runItemIds = new Set([itemIds[0]]);
internal.session.items[itemIds[0]].totalBytes = 600 * 1024 * 1024;
internal.session.items[itemIds[0]].downloadedBytes = 0;
internal.session.items[itemIds[1]].totalBytes = 60 * 1024 * 1024 * 1024;
internal.session.items[itemIds[1]].downloadedBytes = 0;
const snapshot = manager.getSnapshot();
expect(snapshot.runRemainingBytes).toBe(600 * 1024 * 1024);
expect(snapshot.runRemainingUnknownItems).toBe(0);
expect("runItemIds" in snapshot).toBe(false);
});
});
describe("extractArchiveNameFromExtractorLogMessage", () => {
it("detects archive names from extractor log variants", () => {
expect(extractArchiveNameFromExtractorLogMessage("Extract-Backend Start: archive=scn-dhanbs7-S02E008.rar, mode=legacy")).toBe("scn-dhanbs7-S02E008.rar");
expect(extractArchiveNameFromExtractorLogMessage("Entpacke Archiv: scn-dhanbs7-S02E008.rar -> C:\\target")).toBe("scn-dhanbs7-S02E008.rar");
@@ -13820,6 +13865,195 @@ describe("download manager", () => {
expect(loadStatisticsLedger(createStoragePaths(path.join(root, "state")).statisticsFile).minutes).toEqual([]);
});
it("keeps today's statistics reset after restart without clearing provider quota usage", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-statistics-reset-restart-"));
tempDirs.push(root);
const paths = createStoragePaths(path.join(root, "state"));
const day = getProviderUsageDayKey();
const settings = {
...defaultSettings(),
providerDailyUsageDay: day,
providerDailyUsageBytes: { realdebrid: 4_096 }
};
const manager = new DownloadManager(settings, emptySession(), paths);
manager.resetDownloadStats();
const resetLedger = loadStatisticsLedger(paths.statisticsFile);
expect(settings.providerDailyUsageBytes).toEqual({ realdebrid: 4_096 });
expect(resetLedger.providerSeedSuppressedDay).toBe(day);
expect(resetLedger.providerSeedBaselineBytes).toEqual({ realdebrid: 4_096 });
expect(resetLedger.days.find((entry) => entry.day === day)).toBeUndefined();
const restored = new DownloadManager(settings, emptySession(), paths);
const restoredDay = restored.getStats().statistics?.days.find((entry) => entry.day === day);
expect(settings.providerDailyUsageBytes).toEqual({ realdebrid: 4_096 });
expect(restored.getStats().statistics?.providerSeedSuppressedDay).toBe(day);
expect(restoredDay).toBeUndefined();
});
it("recovers post-reset provider traffic from disk after a failed ledger save", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-statistics-reset-catchup-"));
tempDirs.push(root);
const paths = createStoragePaths(path.join(root, "state"));
const settings = {
...defaultSettings(),
providerDailyUsageDay: getProviderUsageDayKey(),
providerDailyUsageBytes: { realdebrid: 4_096 }
};
const manager = new DownloadManager(settings, emptySession(), paths);
const internal = manager as unknown as {
recordProviderDownloadedBytes: (provider: "realdebrid", bytes: number) => void;
persistNow: () => void;
};
manager.resetDownloadStats();
internal.recordProviderDownloadedBytes("realdebrid", 1_024);
const renameFile = fs.renameSync.bind(fs);
let failed = false;
const rename = vi.spyOn(fs, "renameSync").mockImplementation((source, target) => {
if (!failed && String(target) === paths.statisticsFile) {
failed = true;
throw Object.assign(new Error("ledger write failed"), { code: "EIO" });
}
return renameFile(source, target);
});
try {
internal.persistNow();
} finally {
rename.mockRestore();
}
const persistedSettings = loadSettings(paths);
const restored = new DownloadManager(persistedSettings, emptySession(), paths);
const day = restored.getStats().statistics?.days.find((entry) => entry.day === getProviderUsageDayKey());
expect(failed).toBe(true);
expect(persistedSettings.providerDailyUsageBytes.realdebrid).toBe(5_120);
expect(day?.downloadedBytes).toBe(1_024);
expect(day?.providers.realdebrid?.bytes).toBe(1_024);
});
it("captures the reset baseline only after refreshing stale daily provider usage", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-statistics-reset-baseline-day-"));
tempDirs.push(root);
const paths = createStoragePaths(path.join(root, "state"));
const settings = {
...defaultSettings(),
providerDailyUsageDay: "2000-01-01",
providerDailyUsageBytes: { realdebrid: 4_096 }
};
const manager = new DownloadManager(settings, emptySession(), paths);
manager.resetDownloadStats();
const resetLedger = loadStatisticsLedger(paths.statisticsFile);
expect(settings.providerDailyUsageDay).toBe(getProviderUsageDayKey());
expect(settings.providerDailyUsageBytes).toEqual({});
expect(resetLedger.providerSeedBaselineBytes).toEqual({});
});
it("persists a rebased provider quota baseline for manual daily usage resets", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-statistics-manual-quota-reset-"));
tempDirs.push(root);
const paths = createStoragePaths(path.join(root, "state"));
const settings = {
...defaultSettings(),
providerDailyUsageDay: getProviderUsageDayKey(),
providerDailyUsageBytes: { realdebrid: 500 }
};
const manager = new DownloadManager(settings, emptySession(), paths);
const internal = manager as unknown as {
recordProviderDownloadedBytes: (provider: "realdebrid", bytes: number) => void;
};
manager.resetDownloadStats();
internal.recordProviderDownloadedBytes("realdebrid", 200);
manager.rebaseStatisticsProviderDailyUsage("realdebrid", 0);
expect(loadStatisticsLedger(paths.statisticsFile).providerSeedBaselineBytes).toEqual({ realdebrid: -200 });
expect(manager.getStats().statistics?.days[0]?.providers.realdebrid?.bytes).toBe(200);
});
it("keeps runtime and disk statistics unchanged when the ledger reset write fails", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-statistics-reset-ledger-failure-"));
tempDirs.push(root);
const paths = createStoragePaths(path.join(root, "state"));
const settings = {
...defaultSettings(),
totalDownloadedAllTime: 8_192,
providerDailyUsageDay: getProviderUsageDayKey(),
providerDailyUsageBytes: { realdebrid: 1_024 }
};
const manager = new DownloadManager(settings, emptySession(), paths);
const internal = manager as unknown as {
recordProviderDownloadedBytes: (provider: "realdebrid", bytes: number) => void;
};
internal.recordProviderDownloadedBytes("realdebrid", 512);
manager.persistNowSync();
const beforeLedger = loadStatisticsLedger(paths.statisticsFile);
const renameFile = fs.renameSync.bind(fs);
let failed = false;
const rename = vi.spyOn(fs, "renameSync").mockImplementation((source, target) => {
if (!failed && String(target) === paths.statisticsFile) {
failed = true;
throw Object.assign(new Error("ledger write failed"), { code: "EIO" });
}
return renameFile(source, target);
});
try {
expect(() => manager.resetDownloadStats()).toThrow("ledger write failed");
} finally {
rename.mockRestore();
}
expect(settings.totalDownloadedAllTime).toBe(8_192);
expect(manager.getStats().statistics?.days).toEqual(beforeLedger.days);
expect(loadStatisticsLedger(paths.statisticsFile).days).toEqual(beforeLedger.days);
expect(loadSettings(paths).totalDownloadedAllTime).toBe(8_192);
});
it("rolls back the persisted ledger when the settings reset write fails", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-statistics-reset-settings-failure-"));
tempDirs.push(root);
const paths = createStoragePaths(path.join(root, "state"));
const settings = {
...defaultSettings(),
totalDownloadedAllTime: 16_384,
providerDailyUsageDay: getProviderUsageDayKey(),
providerDailyUsageBytes: { realdebrid: 2_048 }
};
const manager = new DownloadManager(settings, emptySession(), paths);
const internal = manager as unknown as {
recordProviderDownloadedBytes: (provider: "realdebrid", bytes: number) => void;
};
internal.recordProviderDownloadedBytes("realdebrid", 768);
manager.persistNowSync();
const beforeLedger = loadStatisticsLedger(paths.statisticsFile);
const renameFile = fs.renameSync.bind(fs);
let failed = false;
const rename = vi.spyOn(fs, "renameSync").mockImplementation((source, target) => {
if (!failed && String(target) === paths.configFile) {
failed = true;
throw Object.assign(new Error("settings write failed"), { code: "EIO" });
}
return renameFile(source, target);
});
try {
expect(() => manager.resetDownloadStats()).toThrow("settings write failed");
} finally {
rename.mockRestore();
}
expect(settings.totalDownloadedAllTime).toBe(16_384);
expect(manager.getStats().statistics?.days).toEqual(beforeLedger.days);
expect(loadStatisticsLedger(paths.statisticsFile).days).toEqual(beforeLedger.days);
expect(loadSettings(paths).totalDownloadedAllTime).toBe(16_384);
});
it("does not recreate account usage when the source account was removed during the download", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root);
+429 -10
View File
@@ -11,6 +11,7 @@ import {
classifyDownloadStatus,
formatRemainingDownloadBytes,
formatRemainingDownloadTooltip,
formatDownloadEta,
getDownloadQueueTotalBytes,
getDownloadQueueStatusMetrics,
getRemainingDownloadBytes,
@@ -46,6 +47,7 @@ import {
type DownloadsViewActions,
type DownloadsViewModel
} from "../src/renderer/views/downloads/DownloadsView";
import * as downloadsViewModule from "../src/renderer/views/downloads/DownloadsView";
import {
DownloadsTableHeader,
ItemRowContent,
@@ -206,13 +208,19 @@ function pkg(id: string, name: string, itemIds: string[]): PackageEntry {
}
describe("Download-Gesamtgröße", () => {
it("summiert bekannte Dateigrößen und verwendet geladene Bytes nur als Fallback", () => {
it("kennzeichnet fehlende Gesamtgrößen trotz geladener Untergrenze als unbekannt", () => {
const items = [
item("known", "package-a", "queued", { totalBytes: 4_000, downloadedBytes: 500 }),
item("fallback", "package-a", "queued", { totalBytes: null, downloadedBytes: 750 })
];
expect(getDownloadQueueTotalBytes(items)).toBe(4_750);
expect(getDownloadQueueTotalBytes(items)).toEqual({ bytes: 4_750, unknownItems: 1 });
});
it("zählt auch vollständig unbekannte Gesamtgrößen ohne falsche exakte Null", () => {
expect(getDownloadQueueTotalBytes([
item("unknown", "package-a", "queued", { totalBytes: null, downloadedBytes: 0 })
])).toEqual({ bytes: 0, unknownItems: 1 });
});
it("preserves completed package bytes and progress after immediate cleanup", () => {
@@ -233,6 +241,20 @@ describe("Download-Gesamtgröße", () => {
});
});
describe("Download-ETA", () => {
it("derives ETA from remaining bytes and current throughput before any file completes", () => {
expect(formatDownloadEta({ bytes: 600 * 1024 * 1024, unknownItems: 0 }, 10 * 1024 * 1024, true, false)).toBe("01:00");
});
it("keeps ETA unknown while paused, stopped, stalled, or missing file sizes", () => {
expect(formatDownloadEta({ bytes: 1_000, unknownItems: 0 }, 10, false, false)).toBe("--");
expect(formatDownloadEta({ bytes: 1_000, unknownItems: 0 }, 10, true, true)).toBe("--");
expect(formatDownloadEta({ bytes: 1_000, unknownItems: 0 }, 0, true, false)).toBe("--");
expect(formatDownloadEta({ bytes: 1_000, unknownItems: 1 }, 10, true, false)).toBe("--");
});
});
describe("verbleibendes Downloadvolumen", () => {
it("shows a fourth TB decimal so fast downloads stay visibly responsive", () => {
expect(formatRemainingDownloadBytes({ bytes: Math.round(1.125 * 1024 ** 4), unknownItems: 0 })).toBe("1.1250 TB");
@@ -881,6 +903,92 @@ describe("downloads model", () => {
expect(model.visibleItemIds).toEqual(["failed"]);
});
it("uses package lifecycle status for counts and filters without counting children twice", () => {
const extractingPackage = { ...pkg("extracting-package", "Entpacken", ["extracting-a", "extracting-b"]), status: "extracting" } as PackageEntry;
const failedPackage = { ...pkg("failed-package", "Entpackfehler", ["extract-failed"]), status: "failed" } as PackageEntry;
const pausedPackage = { ...pkg("paused-package", "Pausiert", ["paused-item"]), status: "paused" } as PackageEntry;
const input = createInput({
packageOrder: [extractingPackage.id, failedPackage.id, pausedPackage.id],
packages: {
[extractingPackage.id]: extractingPackage,
[failedPackage.id]: failedPackage,
[pausedPackage.id]: pausedPackage
},
items: {
"extracting-a": item("extracting-a", extractingPackage.id, "completed"),
"extracting-b": item("extracting-b", extractingPackage.id, "completed"),
"extract-failed": item("extract-failed", failedPackage.id, "completed", { fullStatus: "Entpack-Fehler: CRC" }),
"paused-item": item("paused-item", pausedPackage.id, "queued")
}
});
const all = buildDownloadsViewModel(input);
const active = buildDownloadsViewModel({ ...input, filter: "active" });
const failed = buildDownloadsViewModel({ ...input, filter: "failed" });
const paused = buildDownloadsViewModel({ ...input, filter: "paused" });
expect(all.counts).toEqual({ all: 4, active: 2, queued: 0, paused: 1, completed: 0, failed: 1 });
expect(all.counts.active + all.counts.queued + all.counts.paused + all.counts.completed + all.counts.failed).toBe(all.counts.all);
expect(active.visibleItemIds).toEqual(["extracting-a", "extracting-b"]);
expect(failed.visibleItemIds).toEqual(["extract-failed"]);
expect(paused.visibleItemIds).toEqual(["paused-item"]);
});
it("preserves terminal children while unfinished children inherit failed or paused package state", () => {
const failedPackage = { ...pkg("mixed-failed", "Gemischter Fehler", ["failed-complete", "failed-pending"]), status: "failed" } as PackageEntry;
const pausedPackage = { ...pkg("mixed-paused", "Gemischt pausiert", ["paused-complete", "paused-pending"]), status: "paused" } as PackageEntry;
const input = createInput({
packageOrder: [failedPackage.id, pausedPackage.id],
packages: { [failedPackage.id]: failedPackage, [pausedPackage.id]: pausedPackage },
items: {
"failed-complete": item("failed-complete", failedPackage.id, "completed"),
"failed-pending": item("failed-pending", failedPackage.id, "queued"),
"paused-complete": item("paused-complete", pausedPackage.id, "completed"),
"paused-pending": item("paused-pending", pausedPackage.id, "queued")
}
});
const model = buildDownloadsViewModel(input);
expect(model.counts).toEqual({ all: 4, active: 0, queued: 0, paused: 1, completed: 2, failed: 1 });
expect(buildDownloadsViewModel({ ...input, filter: "completed" }).visibleItemIds).toEqual(["failed-complete", "paused-complete"]);
expect(buildDownloadsViewModel({ ...input, filter: "failed" }).visibleItemIds).toEqual(["failed-pending"]);
expect(buildDownloadsViewModel({ ...input, filter: "paused" }).visibleItemIds).toEqual(["paused-pending"]);
});
it("classifies Entpacken Error status variants as failures", () => {
const packageEntry = { ...pkg("extract-error-package", "Entpackfehler", ["extract-error-item"]), status: "queued" } as PackageEntry;
const input = createInput({
packageOrder: [packageEntry.id],
packages: { [packageEntry.id]: packageEntry },
items: {
"extract-error-item": item("extract-error-item", packageEntry.id, "completed", { fullStatus: "Entpacken - Error: CRC" })
}
});
const model = buildDownloadsViewModel(input);
expect(model.counts).toEqual({ all: 1, active: 0, queued: 0, paused: 0, completed: 0, failed: 1 });
expect(buildDownloadsViewModel({ ...input, filter: "failed" }).visibleItemIds).toEqual(["extract-error-item"]);
});
it("keeps cancelled, extracted, and queued siblings out of an active extraction bucket", () => {
const packageEntry = { ...pkg("extracting-mixed", "Gemischtes Entpacken", ["extracting-ready", "extracting-cancelled", "extracting-done", "extracting-queued"]), status: "extracting" } as PackageEntry;
const input = createInput({
packageOrder: [packageEntry.id],
packages: { [packageEntry.id]: packageEntry },
items: {
"extracting-ready": item("extracting-ready", packageEntry.id, "completed", { fullStatus: "Entpacken - 50%" }),
"extracting-cancelled": item("extracting-cancelled", packageEntry.id, "cancelled"),
"extracting-done": item("extracting-done", packageEntry.id, "completed", { fullStatus: "Entpackt" }),
"extracting-queued": item("extracting-queued", packageEntry.id, "queued")
}
});
expect(buildDownloadsViewModel(input).counts).toEqual({ all: 4, active: 1, queued: 1, paused: 0, completed: 1, failed: 0 });
expect(buildDownloadsViewModel({ ...input, filter: "active" }).visibleItemIds).toEqual(["extracting-ready"]);
expect(buildDownloadsViewModel({ ...input, filter: "queued" }).visibleItemIds).toEqual(["extracting-queued"]);
expect(buildDownloadsViewModel({ ...input, filter: "completed" }).visibleItemIds).toEqual(["extracting-done"]);
});
it("filters by package name, file name, provider, status and extracted visibility", () => {
const byPackage = buildDownloadsViewModel(createInput({ query: "aktive serie" }));
const byFile = buildDownloadsViewModel(createInput({ query: "done.mkv" }));
@@ -898,7 +1006,7 @@ describe("downloads model", () => {
expect(hiddenExtracted.visibleItemIds).not.toContain("done");
});
it("removes hidden extracted downloads from every queue-wide model source", () => {
it("removes hidden extracted downloads from queue-wide visibility sources while preserving package aggregates", () => {
const model = buildDownloadsViewModel(createInput({
packageOrder: ["visible-package", "extracted-package"],
packages: {
@@ -921,29 +1029,89 @@ describe("downloads model", () => {
expect(model.filteredEmpty).toBe(false);
});
it("treats a queue containing only hidden extracted downloads as empty", () => {
it("keeps hidden extracted children in package progress, size, and total counts", () => {
const input = createInput({
packageOrder: ["mixed-package"],
packages: { "mixed-package": pkg("mixed-package", "Gemischtes Paket", ["extracted-item", "queued-item"]) },
items: {
"extracted-item": item("extracted-item", "mixed-package", "completed", { downloadedBytes: 100, totalBytes: 100, progressPercent: 100, fullStatus: "Entpackt" }),
"queued-item": item("queued-item", "mixed-package", "queued", { downloadedBytes: 0, totalBytes: 100, progressPercent: 0, fullStatus: "Wartet" })
},
hideExtractedItems: true
});
const model = buildDownloadsViewModel(input);
const row = model.packageRows[0];
expect(row.items.map((entry) => entry.id)).toEqual(["queued-item"]);
expect(row.allItems.map((entry) => entry.id)).toEqual(["extracted-item", "queued-item"]);
expect(getPackageProgress(row)).toEqual({ done: 1, failed: 0, cancelled: 0, total: 2, value: 50 });
expect(getPackageSizeProgress(row)).toEqual({ downloaded: 100, total: 200, value: 50 });
});
it("resets a stale service filter when its last eligible provider disappears", () => {
const model = buildDownloadsViewModel(createInput({
packageOrder: ["visible-package", "extracted-package"],
packages: {
"visible-package": pkg("visible-package", "Wartend", ["visible-item"]),
"extracted-package": pkg("extracted-package", "Entpackt", ["extracted-item"])
},
items: {
"visible-item": item("visible-item", "visible-package", "queued", { provider: "debridlink", providerLabel: "Debrid-Link" }),
"extracted-item": item("extracted-item", "extracted-package", "completed", { provider: "alldebrid", providerLabel: "AllDebrid", fullStatus: "Entpackt" })
},
providerFilter: "alldebrid",
hideExtractedItems: true
}));
expect(model.providerFilter).toBe("all");
expect(model.visibleItemIds).toEqual(["visible-item"]);
expect(model.filteredEmpty).toBe(false);
});
it("keeps a hidden-only source queue clearable while presenting no visible entries", () => {
const input = createInput({
packageOrder: ["extracted-package"],
packages: { "extracted-package": pkg("extracted-package", "Entpackt", ["extracted-item"]) },
items: { "extracted-item": item("extracted-item", "extracted-package", "completed", { fullStatus: "Entpackt" }) },
hideExtractedItems: true
}));
});
const model = buildDownloadsViewModel(input);
const runtimeModel = withRuntime(input);
const sidebar = renderToStaticMarkup(<DownloadsSidebar actions={createActions()} model={runtimeModel} />);
const content = renderToStaticMarkup(<DownloadsContent actions={createActions()} model={runtimeModel} />);
expect(model.eligibleItems).toEqual([]);
expect(model.eligiblePackageCount).toBe(0);
expect(model.counts.all).toBe(0);
expect(model.providerOptions).toEqual([]);
expect(model.empty).toBe(true);
expect(model.filteredEmpty).toBe(false);
expect(model.sourceEmpty).toBe(false);
expect(model.presentationEmpty).toBe(true);
expect(model.empty).toBe(false);
expect(model.filteredEmpty).toBe(true);
expect(sidebar).not.toMatch(/<button disabled="" type="button">Liste leeren<\/button>/);
expect(content).toContain("Entpackte Downloads sind ausgeblendet");
expect(content).toContain("Entpackte Einträge ausblenden");
expect(content).not.toContain("Passe Filter oder Suche an");
expect(content).not.toContain("Noch keine Downloads");
expect(getDownloadQueueStatusMetrics(model.eligibleItems)).toEqual({
packageCount: 0,
pendingItemCount: 0,
totalBytes: 0,
total: { bytes: 0, unknownItems: 0 },
remaining: { bytes: 0, unknownItems: 0 },
hosterCount: 0
});
});
it("distinguishes a genuinely empty source queue from presentation filtering", () => {
const model = buildDownloadsViewModel(createInput({ packageOrder: [], packages: {}, items: {} }));
expect(model.sourceEmpty).toBe(true);
expect(model.presentationEmpty).toBe(true);
expect(model.empty).toBe(true);
expect(model.filteredEmpty).toBe(false);
});
it("supports the genuine flat file mode without synthetic package rows", () => {
const model = buildDownloadsViewModel(createInput({ displayMode: "files" }));
@@ -1098,6 +1266,18 @@ describe("downloads view", () => {
expect(html.match(/aria-current="page"/g)).toHaveLength(1);
});
it("formats download filter counters as German integers", () => {
const model = withRuntime(createInput(), {
counts: { all: 3_180, active: 1_250, queued: 1_000, paused: 900, completed: 30, failed: 0 }
});
const html = renderToStaticMarkup(<DownloadsSidebar actions={createActions()} model={model} />);
for (const count of ["3.180", "1.250", "1.000", "900", "30", "0"]) {
expect(html).toContain(`<b>${count}</b>`);
}
expect(html).not.toContain("<b>3180</b>");
});
it("disables the service filter until more than one concrete service is available", () => {
const none = renderToStaticMarkup(<DownloadsSidebar actions={createActions()} model={withRuntime(createInput(), { providerOptions: [] })} />);
const one = renderToStaticMarkup(<DownloadsSidebar actions={createActions()} model={withRuntime(createInput(), { providerOptions: [{ id: "rapidgator", label: "RapidGator" }] })} />);
@@ -1156,6 +1336,231 @@ describe("downloads view", () => {
expect(findButton(toolbar, "Alle ein-/ausklappen").props.disabled).toBe(true);
});
it("disables the global package disclosure action when every source row is hidden", () => {
const model = withRuntime(createInput({
packageOrder: ["extracted-package"],
packages: { "extracted-package": pkg("extracted-package", "Entpackt", ["extracted-item"]) },
items: { "extracted-item": item("extracted-item", "extracted-package", "completed", { fullStatus: "Entpackt" }) },
hideExtractedItems: true
}), { running: false });
expect(model.empty).toBe(false);
expect(model.presentationEmpty).toBe(true);
expect(findButton(DownloadsToolbar({ actions: createActions(), model }), "Alle ein-/ausklappen").props.disabled).toBe(true);
});
it("gates context start actions and reports blocked or rejected starts", async () => {
const DownloadContextStartActions = (downloadsViewModule as unknown as {
DownloadContextStartActions?: (props: {
actionBusy: boolean;
canStart: boolean;
onStartAll: () => void;
onStartSelected: () => void;
selectedLabel: string;
showSelected: boolean;
}) => ReactElement;
}).DownloadContextStartActions;
const runDownloadStartAction = (downloadsViewModule as unknown as {
runDownloadStartAction?: (
canStart: boolean,
action: () => Promise<void>,
onBlocked: () => void,
onError: (error: unknown) => void
) => Promise<boolean>;
}).runDownloadStartAction;
const resumeDownloadSession = (downloadsViewModule as unknown as {
resumeDownloadSession?: (
canStart: boolean,
togglePause: () => Promise<boolean>,
applyPaused: (paused: boolean) => void,
onBlocked: () => void,
onError: (error: unknown) => void
) => Promise<boolean>;
}).resumeDownloadSession;
const pauseDownloadSession = (downloadsViewModule as unknown as {
pauseDownloadSession?: (
togglePause: () => Promise<boolean>,
applyPaused: (paused: boolean) => void,
onError: (error: unknown) => void
) => Promise<boolean>;
}).pauseDownloadSession;
expect(DownloadContextStartActions).toBeTypeOf("function");
expect(runDownloadStartAction).toBeTypeOf("function");
expect(resumeDownloadSession).toBeTypeOf("function");
expect(pauseDownloadSession).toBeTypeOf("function");
if (!DownloadContextStartActions || !runDownloadStartAction || !resumeDownloadSession || !pauseDownloadSession) return;
let allStarts = 0;
let selectedStarts = 0;
const blocked = DownloadContextStartActions({
actionBusy: false,
canStart: false,
onStartAll: () => { allStarts += 1; },
onStartSelected: () => { selectedStarts += 1; },
selectedLabel: "Ausgewählte Downloads starten (2)",
showSelected: true
});
const blockedButtons: ReactElement[] = [];
visitElements(blocked, (element) => { if (element.type === "button") blockedButtons.push(element); });
expect(blockedButtons).toHaveLength(2);
expect(blockedButtons.every((button) => button.props.disabled === true)).toBe(true);
const busy = DownloadContextStartActions({
actionBusy: true,
canStart: true,
onStartAll: () => { allStarts += 1; },
onStartSelected: () => { selectedStarts += 1; },
selectedLabel: "Ausgewählte Downloads starten (2)",
showSelected: true
});
const busyButtons: ReactElement[] = [];
visitElements(busy, (element) => { if (element.type === "button") busyButtons.push(element); });
expect(busyButtons.every((button) => button.props.disabled === true)).toBe(true);
const blockedNotice = vi.fn();
const rejectedNotice = vi.fn();
const action = vi.fn(async () => { throw new Error("Kein aktiver Download-Account verfügbar"); });
await expect(runDownloadStartAction(false, action, blockedNotice, rejectedNotice)).resolves.toBe(false);
expect(action).not.toHaveBeenCalled();
expect(blockedNotice).toHaveBeenCalledTimes(1);
expect(rejectedNotice).not.toHaveBeenCalled();
await expect(runDownloadStartAction(true, action, blockedNotice, rejectedNotice)).resolves.toBe(false);
expect(rejectedNotice).toHaveBeenCalledWith(expect.objectContaining({ message: "Kein aktiver Download-Account verfügbar" }));
const applyPaused = vi.fn();
await expect(resumeDownloadSession(
true,
async () => { throw new Error("Account fehlt"); },
applyPaused,
blockedNotice,
rejectedNotice
)).resolves.toBe(false);
expect(applyPaused).not.toHaveBeenCalled();
await expect(resumeDownloadSession(true, async () => false, applyPaused, blockedNotice, rejectedNotice)).resolves.toBe(true);
expect(applyPaused).toHaveBeenCalledWith(false);
applyPaused.mockClear();
await expect(pauseDownloadSession(
async () => { throw new Error("Pause fehlgeschlagen"); },
applyPaused,
rejectedNotice
)).resolves.toBe(false);
expect(applyPaused).not.toHaveBeenCalled();
await expect(pauseDownloadSession(async () => true, applyPaused, rejectedNotice)).resolves.toBe(true);
expect(applyPaused).toHaveBeenCalledWith(true);
const enabled = DownloadContextStartActions({
actionBusy: false,
canStart: true,
onStartAll: () => { allStarts += 1; },
onStartSelected: () => { selectedStarts += 1; },
selectedLabel: "Ausgewählte Downloads starten (2)",
showSelected: true
});
const enabledButtons: ReactElement[] = [];
visitElements(enabled, (element) => { if (element.type === "button") enabledButtons.push(element); });
enabledButtons[0].props.onClick();
enabledButtons[1].props.onClick();
expect(selectedStarts).toBe(1);
expect(allStarts).toBe(1);
});
it("runs resume through the shared single-flight gate", async () => {
const runResumeDownloadAction = (downloadsViewModule as unknown as {
runResumeDownloadAction?: (
runSingleFlight: (action: () => Promise<unknown>) => Promise<void>,
canStart: boolean,
togglePause: () => Promise<boolean>,
applyPaused: (paused: boolean) => void,
onBlocked: () => void,
onError: (error: unknown) => void
) => Promise<void>;
}).runResumeDownloadAction;
expect(runResumeDownloadAction).toBeTypeOf("function");
if (!runResumeDownloadAction) return;
let busy = false;
const runSingleFlight = async (action: () => Promise<unknown>): Promise<void> => {
if (busy) return;
busy = true;
try {
await action();
} finally {
busy = false;
}
};
let release = (): void => {};
const pending = new Promise<void>((resolve) => { release = resolve; });
let paused = true;
let toggleCalls = 0;
const applied: boolean[] = [];
const togglePause = async (): Promise<boolean> => {
toggleCalls += 1;
await pending;
paused = !paused;
return paused;
};
const first = runResumeDownloadAction(runSingleFlight, true, togglePause, (value) => applied.push(value), () => {}, () => {});
const second = runResumeDownloadAction(runSingleFlight, true, togglePause, (value) => applied.push(value), () => {}, () => {});
expect(toggleCalls).toBe(1);
release();
await Promise.all([first, second]);
expect(paused).toBe(false);
expect(applied).toEqual([false]);
});
it("runs pause through the shared single-flight gate", async () => {
const runPauseDownloadAction = (downloadsViewModule as unknown as {
runPauseDownloadAction?: (
runSingleFlight: (action: () => Promise<unknown>) => Promise<void>,
togglePause: () => Promise<boolean>,
applyPaused: (paused: boolean) => void,
onError: (error: unknown) => void
) => Promise<void>;
}).runPauseDownloadAction;
expect(runPauseDownloadAction).toBeTypeOf("function");
if (!runPauseDownloadAction) return;
let busy = false;
const runSingleFlight = async (action: () => Promise<unknown>): Promise<void> => {
if (busy) return;
busy = true;
try {
await action();
} finally {
busy = false;
}
};
let release = (): void => {};
const pending = new Promise<void>((resolve) => { release = resolve; });
let paused = false;
let toggleCalls = 0;
const applied: boolean[] = [];
const togglePause = async (): Promise<boolean> => {
toggleCalls += 1;
await pending;
paused = !paused;
return paused;
};
const first = runPauseDownloadAction(runSingleFlight, togglePause, (value) => applied.push(value), () => {});
const second = runPauseDownloadAction(runSingleFlight, togglePause, (value) => applied.push(value), () => {});
expect(toggleCalls).toBe(1);
release();
await Promise.all([first, second]);
expect(paused).toBe(true);
expect(applied).toEqual([true]);
});
it("renders the five dense markers exactly once and the empty marker only for a true empty queue", () => {
const occupied = renderToStaticMarkup(<DownloadsView actions={createActions()} model={withRuntime(createInput())} />);
const empty = renderToStaticMarkup(<DownloadsView actions={createActions()} model={withRuntime(createInput({ packageOrder: [], packages: {}, items: {} }), { running: false })} />);
@@ -1259,7 +1664,7 @@ describe("downloads view", () => {
expect(renderToStaticMarkup(toolbar)).not.toContain("Reconnect");
});
it("blocks resume without a usable account and keeps pause independent from unrelated action busy state", () => {
it("blocks resume without a usable account and pause while another action is busy", () => {
const pausedToolbar = DownloadsToolbar({
actions: createActions(),
model: withRuntime(createInput(), { paused: true, canStart: false, canPause: true })
@@ -1271,7 +1676,7 @@ describe("downloads view", () => {
expect(findButton(pausedToolbar, "Start").props.disabled).toBe(true);
expect(findButton(pausedToolbar, "Pause").props.disabled).toBe(true);
expect(findButton(busyToolbar, "Pause").props.disabled).toBe(false);
expect(findButton(busyToolbar, "Pause").props.disabled).toBe(true);
});
it("enables package movement only for a visible selected package row", () => {
@@ -1469,6 +1874,20 @@ describe("downloads view", () => {
});
describe("downloads App integration", () => {
it("keeps search input immediate while deferred text drives expensive filtering", () => {
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8").replaceAll("\r\n", "\n");
expect(source).toMatch(/buildDownloadsViewModel\(\{[\s\S]*?query: deferredDownloadSearch,/);
expect(source).toMatch(/const downloadsViewModel = useMemo<DownloadsViewModel>\(\(\) => \(\{\n\s*\.\.\.downloadsViewCore,\n\s*query: downloadSearch,/);
});
it("commits a normalized provider filter back to renderer state", () => {
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8").replaceAll("\r\n", "\n");
expect(source).toMatch(/if \(downloadsTabActive && downloadProviderFilter !== downloadsViewCore\.providerFilter\) \{\n\s*setDownloadProviderFilter\(downloadsViewCore\.providerFilter\);/);
expect(source).toContain("[downloadProviderFilter, downloadsTabActive, downloadsViewCore.providerFilter]");
});
it("keeps the priority menu open when the selected priority is already active", () => {
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8").replaceAll("\r\n", "\n");
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { paginateHistoryRows, type HistoryViewEntry } from "../src/renderer/views/history/history-model";
import * as historyViewModule from "../src/renderer/views/history/HistoryView";
function historyEntry(id: string): HistoryViewEntry {
return {
id,
name: id,
totalBytes: 1,
downloadedBytes: 1,
fileCount: 1,
provider: "realdebrid",
completedAt: 1,
durationSeconds: 1,
status: "completed",
outputDir: "",
urls: []
};
}
describe("history page reporting", () => {
it("reports the exact current page ids and count for App integration", () => {
const reportHistoryVisiblePage = (historyViewModule as unknown as {
reportHistoryVisiblePage?: (
actions: {
onVisiblePageChange?: (ids: string[]) => void;
onVisiblePageCountChange?: (count: number) => void;
},
page: ReturnType<typeof paginateHistoryRows>
) => void;
}).reportHistoryVisiblePage;
expect(reportHistoryVisiblePage).toBeTypeOf("function");
if (!reportHistoryVisiblePage) return;
const ids: string[][] = [];
const counts: number[] = [];
reportHistoryVisiblePage({
onVisiblePageChange: (value) => ids.push(value),
onVisiblePageCountChange: (value) => counts.push(value)
}, paginateHistoryRows([historyEntry("page-101"), historyEntry("page-102")], 1));
expect(ids).toEqual([["page-101", "page-102"]]);
expect(counts).toEqual([2]);
});
});
+72
View File
@@ -0,0 +1,72 @@
import { performance } from "node:perf_hooks";
import { describe, expect, it } from "vitest";
import * as historyModelModule from "../src/renderer/views/history/history-model";
import { buildHistoryViewModel, type HistoryViewEntry } from "../src/renderer/views/history/history-model";
function createLargeHistory(count: number, now: number): HistoryViewEntry[] {
return Array.from({ length: count }, (_, index) => ({
id: `history-${index}`,
name: `Package ${index}`,
totalBytes: 2_097_152,
downloadedBytes: 1_572_864,
fileCount: 1,
provider: "realdebrid",
completedAt: now - index,
durationSeconds: 60,
status: index % 5 === 0 ? "failed" : "completed",
outputDir: `C:\\Downloads\\Package ${index}`,
urls: [`https://rapidgator.net/file/${index}`]
}));
}
describe("history 100k performance contract", () => {
it("keeps an unchanged second tick and cached page below one frame", () => {
const getHistoryPage = (historyModelModule as unknown as {
getHistoryPage?: (
model: ReturnType<typeof buildHistoryViewModel>,
requestedPage: number
) => { rows: unknown[] };
}).getHistoryPage;
expect(getHistoryPage).toBeTypeOf("function");
if (!getHistoryPage) return;
const now = new Date(2026, 7, 10, 12, 0, 0, 0).getTime();
const entries = createLargeHistory(100_000, now);
const initial = buildHistoryViewModel(entries, "all", "", [], [], false, "", now);
const initialPage = getHistoryPage(initial, 500);
expect(initialPage.rows).toHaveLength(100);
const startedAt = performance.now();
const next = buildHistoryViewModel(entries, "all", "", [], [], false, "", now + 1_000);
const nextPage = getHistoryPage(next, 500);
const elapsedMs = performance.now() - startedAt;
expect(next.rows).toBe(initial.rows);
expect(nextPage).toBe(initialPage);
expect(elapsedMs).toBeLessThan(10);
});
it("keeps a warmed 100k search interaction below one frame", () => {
const getHistoryPage = (historyModelModule as unknown as {
getHistoryPage?: (
model: ReturnType<typeof buildHistoryViewModel>,
requestedPage: number
) => { rows: unknown[] };
}).getHistoryPage;
expect(getHistoryPage).toBeTypeOf("function");
if (!getHistoryPage) return;
const now = new Date(2026, 7, 10, 12, 0, 0, 0).getTime();
const entries = createLargeHistory(100_000, now);
buildHistoryViewModel(entries, "all", "", [], [], false, "", now);
const startedAt = performance.now();
const searched = buildHistoryViewModel(entries, "all", "99999", [], [], false, "", now);
const page = getHistoryPage(searched, 1);
const elapsedMs = performance.now() - startedAt;
expect(searched.rows).toHaveLength(1);
expect(page.rows).toHaveLength(1);
expect(elapsedMs).toBeLessThan(16);
});
});
+327 -23
View File
@@ -19,8 +19,9 @@ import {
selectVisibleHistoryIds,
type HistoryFilter,
type HistoryViewEntry
} from "../src/renderer/views/history/history-model";
import {
} from "../src/renderer/views/history/history-model";
import * as historyModelModule from "../src/renderer/views/history/history-model";
import {
HistoryContent,
HistoryContentPage,
HistoryPagination,
@@ -30,10 +31,13 @@ import {
historyPageStatusLabel,
type HistoryViewActions
} from "../src/renderer/views/history/HistoryView";
import { createVisualFixture } from "./visual/fixtures";
import { createVisualElectronApi } from "./visual/mock-electron-api";
import * as historyViewModule from "../src/renderer/views/history/HistoryView";
import { routeHistoryLiveEntry } from "../src/renderer/App";
import * as appModule from "../src/renderer/App";
import { createVisualFixture } from "./visual/fixtures";
import { createVisualElectronApi } from "./visual/mock-electron-api";
function visitElements(node: ReactNode, visit: (element: ReactElement) => void): void {
function visitElements(node: ReactNode, visit: (element: ReactElement) => void): void {
if (Array.isArray(node)) {
node.forEach((child) => visitElements(child, visit));
return;
@@ -43,7 +47,83 @@ function visitElements(node: ReactNode, visit: (element: ReactElement) => void):
}
visit(node);
visitElements(node.props.children, visit);
}
}
describe("history sidepanel counts", () => {
it("formats all three counters with the same German integer formatter", () => {
const formatHistorySidebarCounts = (appModule as unknown as {
formatHistorySidebarCounts?: (entries: number, visible: number, selected: number) => {
entries: string;
visible: string;
selected: string;
};
}).formatHistorySidebarCounts;
expect(formatHistorySidebarCounts).toBeTypeOf("function");
if (!formatHistorySidebarCounts) return;
expect(formatHistorySidebarCounts(1_234, 2_345, 3_456)).toEqual({
entries: "Einträge: 1.234",
visible: "Sichtbar: 2.345",
selected: "Ausgewählt: 3.456"
});
});
});
describe("history page selection", () => {
it("adds the current page without replacing selections from other pages", () => {
const toggleHistoryPageSelection = (historyModelModule as unknown as {
toggleHistoryPageSelection?: (current: ReadonlySet<string>, visibleIds: readonly string[]) => Set<string>;
}).toggleHistoryPageSelection;
expect(toggleHistoryPageSelection).toBeTypeOf("function");
if (!toggleHistoryPageSelection) return;
expect([...toggleHistoryPageSelection(new Set(["other-page"]), ["page-a", "page-b"])]).toEqual([
"other-page",
"page-a",
"page-b"
]);
});
it("removes only the current page when every visible row is selected", () => {
const toggleHistoryPageSelection = (historyModelModule as unknown as {
toggleHistoryPageSelection?: (current: ReadonlySet<string>, visibleIds: readonly string[]) => Set<string>;
}).toggleHistoryPageSelection;
expect(toggleHistoryPageSelection).toBeTypeOf("function");
if (!toggleHistoryPageSelection) return;
expect([...toggleHistoryPageSelection(new Set(["other-page", "page-a", "page-b"]), ["page-a", "page-b"])]).toEqual([
"other-page"
]);
});
it("keeps other pages selected when Ctrl+A toggles the current page", () => {
const selectHistoryPageFromShortcut = (appModule as unknown as {
selectHistoryPageFromShortcut?: (current: ReadonlySet<string>, visibleIds: readonly string[]) => Set<string>;
}).selectHistoryPageFromShortcut;
expect(selectHistoryPageFromShortcut).toBeTypeOf("function");
if (!selectHistoryPageFromShortcut) return;
expect([...selectHistoryPageFromShortcut(new Set(["other-page"]), ["page-a", "page-b"])]).toEqual([
"other-page",
"page-a",
"page-b"
]);
});
it("keeps other pages selected when opening context actions for a selected entry", () => {
const resolveHistoryContextSelection = (appModule as unknown as {
resolveHistoryContextSelection?: (current: ReadonlySet<string>, entryId: string) => Set<string>;
}).resolveHistoryContextSelection;
expect(resolveHistoryContextSelection).toBeTypeOf("function");
if (!resolveHistoryContextSelection) return;
expect([...resolveHistoryContextSelection(new Set(["other-page", "page-a"]), "page-a")]).toEqual([
"other-page",
"page-a"
]);
expect([...resolveHistoryContextSelection(new Set(["other-page"]), "page-a")]).toEqual(["page-a"]);
});
});
function findElement(node: ReactNode, predicate: (element: ReactElement) => boolean): ReactElement {
let result: ReactElement | null = null;
@@ -108,6 +188,87 @@ const entries: HistoryViewEntry[] = [
];
describe("history model", () => {
it("indexes matching history once per local day and projects only the requested page", () => {
const getHistoryPage = (historyModelModule as unknown as {
getHistoryPage?: (
model: ReturnType<typeof buildHistoryViewModel>,
requestedPage: number
) => ReturnType<typeof paginateHistoryRows>;
}).getHistoryPage;
expect(getHistoryPage).toBeTypeOf("function");
if (!getHistoryPage) return;
const source = Array.from({ length: 205 }, (_, index) => entry({
id: `indexed-${index + 1}`,
name: `Indexed ${index + 1}`,
completedAt: now - index
}));
const firstModel = buildHistoryViewModel(source, "all", "", ["indexed-101"], [], false, "", now);
const secondModel = buildHistoryViewModel(source, "all", "", ["indexed-101"], [], false, "", now + 1_000);
const firstPage = getHistoryPage(firstModel, 2);
const repeatedPage = getHistoryPage(secondModel, 2);
expect(firstModel.rows).toBe(secondModel.rows);
expect(firstModel.counts).toBe(secondModel.counts);
expect(firstModel.visibleIds).toBe(secondModel.visibleIds);
expect(repeatedPage).toBe(firstPage);
expect(firstPage.rows).toHaveLength(100);
expect(firstPage.rows[0]).toEqual(expect.objectContaining({
id: "indexed-101",
statusLabel: "Abgeschlossen"
}));
expect(firstPage.rows[99].id).toBe("indexed-200");
});
it("formats projected history rows and pagination with the selected locale", () => {
const getHistoryPage = (historyModelModule as unknown as {
getHistoryPage?: (
model: ReturnType<typeof buildHistoryViewModel>,
requestedPage: number
) => ReturnType<typeof paginateHistoryRows>;
}).getHistoryPage;
expect(getHistoryPage).toBeTypeOf("function");
if (!getHistoryPage) return;
const completedAt = new Date(2026, 7, 10, 14, 5, 0, 0).getTime();
const localized = entry({
id: "localized",
name: "Localized",
completedAt,
startedAt: completedAt - 60_000,
downloadedBytes: 1_572_864,
totalBytes: 2_097_152,
downloadFailures: 1_000,
offlineFailures: 2,
extractionFailures: 3,
remuxFailures: 4,
cleanupFailures: 5,
postProcessFailures: 6
});
const tiny = { ...localized, id: "tiny", downloadedBytes: 1_000, totalBytes: 1_000 };
const german = getHistoryPage(buildHistoryViewModel([localized], "all", "", [], [], false, "", now, true, "de"), 1);
const english = getHistoryPage(buildHistoryViewModel([localized], "all", "", [], [], false, "", now, true, "en"), 1);
const germanTiny = getHistoryPage(buildHistoryViewModel([tiny], "all", "", [], [], false, "", now, true, "de"), 1);
const englishTiny = getHistoryPage(buildHistoryViewModel([tiny], "all", "", [], [], false, "", now, true, "en"), 1);
expect(german).toEqual(expect.objectContaining({ rangeLabel: "11 von 1", language: "de" }));
expect(german.rows[0]).toEqual(expect.objectContaining({
sizeLabel: "1,5 MB / 2 MB",
completedLabel: "10.08.2026, 14:05",
failureCountsLabel: "1.000 / 2 / 3 / 4 / 5 / 6",
statusLabel: "Abgeschlossen"
}));
expect(english).toEqual(expect.objectContaining({ rangeLabel: "11 of 1", language: "en" }));
expect(english.rows[0]).toEqual(expect.objectContaining({
sizeLabel: "1.5 MB / 2 MB",
completedLabel: "08/10/2026, 02:05 PM",
failureCountsLabel: "1,000 / 2 / 3 / 4 / 5 / 6",
statusLabel: "Completed"
}));
expect(germanTiny.rows[0].sizeLabel).toBe("1.000 B / 1.000 B");
expect(englishTiny.rows[0].sizeLabel).toBe("1,000 B / 1,000 B");
});
it("prepends live entries without duplicates and applies retention limits", () => {
const incoming = entry({ id: "live", name: "Live", completedAt: now }) as HistoryEntry;
const existing: HistoryEntry[] = [
@@ -120,11 +281,11 @@ describe("history model", () => {
.toEqual(["live", "current"]);
});
it("separates today, previous six calendar days, older and status filters at exact boundaries", () => {
it("defines the last seven days as today plus the previous six calendar days", () => {
const expected: Record<HistoryFilter, string[]> = {
all: ["today", "week-edge", "week", "older"],
today: ["today"],
week: ["week-edge", "week"],
week: ["today", "week-edge", "week"],
older: ["older"],
completed: ["today", "week-edge"],
partial: [],
@@ -184,7 +345,7 @@ describe("history model", () => {
], "week", "", autumnNow).map((row) => row.id)).toEqual(["autumn-boundary"]);
});
it("bounds today to the exact local calendar day and excludes future timestamps", () => {
it("bounds today to the exact local calendar day and excludes future timestamps", () => {
const tomorrowStart = new Date(2026, 7, 11, 0, 0, 0, 0).getTime();
const temporalEntries = [
entry({ id: "today-last", name: "Today last", completedAt: tomorrowStart - 1 }),
@@ -192,10 +353,63 @@ describe("history model", () => {
entry({ id: "future", name: "Future", completedAt: tomorrowStart + 86_400_000 })
];
expect(filterHistoryRows(temporalEntries, "today", "", now).map((row) => row.id)).toEqual(["today-last"]);
});
it("searches name, path, hoster, provider and URLs without changing newest-first input order", () => {
expect(filterHistoryRows(temporalEntries, "today", "", now).map((row) => row.id)).toEqual(["today-last"]);
expect(filterHistoryRows(temporalEntries, "week", "", now).map((row) => row.id)).toEqual(["today-last"]);
});
it("suppresses stale history rows and counters while loading or after a load error", () => {
const loading = buildHistoryViewModel(entries, "all", "", ["today"], ["today"], true, "", now);
const failed = buildHistoryViewModel(entries, "all", "", ["today"], ["today"], false, "Verlauf konnte nicht geladen werden", now);
for (const model of [loading, failed]) {
expect(model.rows).toEqual([]);
expect(model.counts).toEqual({
all: 0,
today: 0,
week: 0,
older: 0,
completed: 0,
partial: 0,
cancelled: 0,
deleted: 0,
failed: 0
});
expect(model.totalCount).toBe(0);
expect(model.selectedIds).toEqual([]);
}
});
it("reloads the full history instead of accepting one live entry after a load failure", () => {
const transitions: string[] = [];
routeHistoryLiveEntry(
entry({ id: "live-after-error", name: "Live after error" }) as HistoryEntry,
true,
{
reload: () => transitions.push("reload"),
accept: () => transitions.push("accept")
}
);
expect(transitions).toEqual(["reload"]);
});
it("accepts live entries directly after a successful history load", () => {
const transitions: string[] = [];
routeHistoryLiveEntry(
entry({ id: "live-after-success", name: "Live after success" }) as HistoryEntry,
false,
{
reload: () => transitions.push("reload"),
accept: (value) => transitions.push(`accept:${value.id}`)
}
);
expect(transitions).toEqual(["accept:live-after-success"]);
});
it("searches name, path, hoster, provider and URLs without changing newest-first input order", () => {
const searchable = [
entry({ id: "new", name: "Neu", completedAt: now, provider: "debridlink", outputDir: "C:\\Filme\\Staffel", urls: ["https://rapidgator.net/file/needle"] }),
entry({ id: "old", name: "Älter", completedAt: now - 1, provider: "realdebrid", outputDir: "D:\\Archiv", urls: ["https://ddownload.com/archive"] })
@@ -326,16 +540,11 @@ describe("HistoryView", () => {
});
it("renders only one fixed-size page with accessible previous and next controls", () => {
const template = filterHistoryRows([entry({ id: "template", name: "Vorlage" })], "all", "", now)[0];
const model = {
...buildHistoryViewModel([], "all", "", [], [], false, "", now),
rows: Array.from({ length: 205 }, (_, index) => ({
...template,
id: `visible-${index + 1}`,
name: `Sichtbar ${index + 1}`
})),
totalCount: 205
};
const source = Array.from({ length: 205 }, (_, index) => entry({
id: `visible-${index + 1}`,
name: `Sichtbar ${index + 1}`
}));
const model = buildHistoryViewModel(source, "all", "", [], [], false, "", now);
const html = renderToStaticMarkup(<HistoryView actions={createActions()} model={model} />);
expect(html.match(/data-history-row-id=/g)).toHaveLength(100);
@@ -368,6 +577,29 @@ describe("HistoryView", () => {
expect(calls).toEqual([2, 1, 3]);
});
it("reports the number of rows actually shown on the current page", () => {
const template = filterHistoryRows([entry({ id: "template", name: "Vorlage" })], "all", "", now)[0];
const rows = Array.from({ length: 205 }, (_, index) => ({
...template,
id: `visible-${index + 1}`,
name: `Sichtbar ${index + 1}`
}));
const report = (historyViewModule as unknown as {
reportHistoryVisiblePageCount?: (
actions: { onVisiblePageCountChange?: (count: number) => void },
page: ReturnType<typeof paginateHistoryRows>
) => void;
}).reportHistoryVisiblePageCount;
const counts: number[] = [];
const actions = { onVisiblePageCountChange: (count: number) => counts.push(count) };
expect(report).toBeTypeOf("function");
report?.(actions, paginateHistoryRows(rows, 1));
report?.(actions, paginateHistoryRows(rows, 3));
expect(counts).toEqual([100, 5]);
});
it("keeps a visible page title in the main content when the filter sidebar is unavailable", () => {
const html = renderToStaticMarkup(
<HistoryContent actions={createActions()} model={buildHistoryViewModel(entries, "all", "", [], [], false, "", now)} />
@@ -406,6 +638,30 @@ describe("HistoryView", () => {
expect(html.match(/data-sliding-selection-active="true"/g)).toHaveLength(1);
});
it("formats history filter counters as German integers", () => {
const base = buildHistoryViewModel(entries, "all", "", [], [], false, "", now);
const model = {
...base,
counts: {
all: 3_180,
today: 1_250,
week: 1_000,
older: 900,
completed: 20,
partial: 5,
failed: 3,
cancelled: 2,
deleted: 0
}
};
const html = renderToStaticMarkup(<HistorySidebar actions={createActions()} model={model} />);
for (const count of ["3.180", "1.250", "1.000", "900", "20", "5", "3", "2", "0"]) {
expect(html).toContain(`<span>${count}</span>`);
}
expect(html).not.toContain("<span>3180</span>");
});
it("keeps one clipped header synchronized with the scrollable history rows", () => {
const html = renderToStaticMarkup(
<HistoryView
@@ -720,6 +976,41 @@ describe("HistoryView", () => {
expect(html).not.toContain("Downloaddauer (Altbestand)");
});
it("formats lifecycle and operation counts with the selected history locale", () => {
const structured = entry({
id: "localized-counts",
name: "Localized counts",
startedAt: todayStart,
successfulFiles: 1_000,
failedFiles: 2_000,
cancelledFiles: 3_000,
archiveCount: 4_000,
partCount: 5_000,
outputCount: 6_000,
archiveOperations: [{
id: "archive-localized",
name: "archive.rar",
itemIds: ["item-1"],
partCount: 7_000,
startedAt: todayStart,
completedAt: todayStart + 1_000,
durationMs: 1_000,
status: "completed",
errorCategory: ""
}]
});
const html = renderToStaticMarkup(
<HistoryView
actions={createActions()}
model={buildHistoryViewModel([structured], "all", "", [], [structured.id], false, "", now, true, "en")}
/>
);
expect(html).toContain("1,000 / 2,000 / 3,000");
expect(html).toContain("4,000 / 5,000 / 6,000");
expect(html).toContain("7,000 Parts");
});
it("labels durationSeconds honestly for legacy entries", () => {
const legacy = entry({ id: "legacy", name: "Altbestand" });
const html = renderToStaticMarkup(
@@ -786,6 +1077,19 @@ describe("visual history states", () => {
expect(listener).not.toContain("getHistory()");
});
it("reloads the complete history before accepting a live entry after a load failure", () => {
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8").replaceAll("\r\n", "\n");
const listenerStart = source.indexOf("window.rd.onHistoryEntryAdded");
const listenerEnd = source.indexOf("return unsubscribeHistoryEntryAdded", listenerStart);
const listener = source.slice(listenerStart, listenerEnd);
expect(source).toContain("historyLoadFailedRef.current = true");
expect(source).toContain("historyLoadFailedRef.current = false");
expect(listener).toContain("routeHistoryLiveEntry(");
expect(listener).toContain("historyLoadFailedRef.current");
expect(listener).toContain("loadHistoryEntries");
});
it("removes the selected history ids through one backend call", () => {
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8").replaceAll("\r\n", "\n");
const removalStart = source.indexOf("const removeHistoryEntries = useCallback");
+196
View File
@@ -94,6 +94,140 @@ describe("renderer localization", () => {
}
});
it.each([
["Download gestartet", "Download started"],
["Download beendet", "Download finished"],
["Nachbearbeitung gestartet", "Post-processing started"],
["Gesamtdauer", "Total duration"],
["Erfolgreich / Fehlgeschlagen / Abgebrochen", "Successful / Failed / Cancelled"],
["Archive / Parts / Ausgaben", "Archives / parts / outputs"],
["Fehlerphase", "Failure phase"],
["Fehlerkategorie", "Error category"],
["Download / Offline / Entpacken / Remux / Cleanup / Nachbearbeitung", "Download / Offline / Extraction / Remux / Cleanup / Post-processing"],
["Downloaddauer (Altbestand)", "Download duration (legacy)"],
["Archivvorgänge", "Archive operations"],
["Remuxvorgänge", "Remux operations"],
["Keine Archivvorgänge", "No archive operations"],
["Keine Remuxvorgänge", "No remux operations"]
])("translates history lifecycle text %s in both directions", (german, english) => {
expect(translateUiText(german, "en")).toBe(english);
expect(translateUiText(english, "de")).toBe(german);
});
it.each([
["2 Parts", "2 parts"],
["1.250 Parts", "1,250 parts"]
])("translates dynamic history operation text %s in both directions", (german, english) => {
expect(translateUiText(german, "en")).toBe(english);
expect(translateUiText(english, "de")).toBe(german);
});
it.each([
["Letzte 24 Stunden", "Last 24 hours"],
["Account-Traffic der vergangenen 24 Stunden.", "Account traffic over the past 24 hours."],
["Letzte 24 Stunden: Werte seit Beginn der Aufzeichnung.", "Last 24 hours: values since tracking began."],
["Heutige Werte stammen aus der lokalen Statistikaufzeichnung.", "Today's values come from local statistics tracking."],
["Datenmenge und Dateien stammen aus den Gesamtzählern; Ergebnisse und Durchschnitt seit Beginn der Statistikaufzeichnung.", "Data volume and files come from the total counters; results and averages cover the period since statistics tracking began."],
["Dateien werden nicht minutengenau nach Account erfasst", "File counts are not tracked per account at minute precision"],
["Ergebnisse werden nicht minutengenau nach Account erfasst", "Results are not tracked per account at minute precision"],
["Aktive Downloadzeit wird nur tagesweise erfasst", "Active download time is tracked by day only"],
["Fehler werden nicht minutengenau nach Account erfasst", "Errors are not tracked per account at minute precision"],
["Letzte sieben Tage", "Last seven days"],
["Letzte 30 Tage", "Last 30 days"],
["Seit Beginn der Statistikaufzeichnung", "Since statistics tracking began"],
["Noch keine aktive Downloadzeit mit übertragenen Daten erfasst", "No active download time with transferred data has been recorded yet"],
["In den vergangenen 24 Stunden wurde noch kein Account-Traffic erfasst.", "No account traffic has been recorded in the past 24 hours."],
["In diesem Zeitraum wurden noch keine Providerwerte erfasst.", "No provider values have been recorded for this period."]
])("translates statistics copy %s in both directions", (german, english) => {
expect(translateUiText(german, "en")).toBe(english);
expect(translateUiText(english, "de")).toBe(german);
});
it.each([
["Letzte sieben Tage: 1 erfasster Tag wird bis heute zusammengefasst.", "Last seven days: 1 recorded day is summarized through today."],
["Letzte 30 Tage: 12 erfasste Tage werden bis heute zusammengefasst.", "Last 30 days: 12 recorded days are summarized through today."],
["1,5 GB", "1.5 GB"],
["1.250 GB", "1,250 GB"],
["1,5 MB/s", "1.5 MB/s"],
["50,5 %", "50.5 %"],
["Daten: 1.250 GB", "Data: 1,250 GB"],
["Dateien: 1.250", "Files: 1,250"],
["Erfolg: 50,5 %", "Success: 50.5 %"],
["Fehler: 1.250", "Errors: 1,250"],
["Provider: 1.250", "Providers: 1,250"],
["Accounts: 1.250", "Accounts: 1,250"],
["5 fertig · 2 Fehler", "5 completed · 2 errors"],
["1.250 fertig · 2.500 Fehler", "1,250 completed · 2,500 errors"]
])("localizes dynamic statistics text %s in both directions", (german, english) => {
expect(translateUiText(german, "en")).toBe(english);
expect(translateUiText(english, "de")).toBe(german);
});
it.each([
["1.250 GB", "de", "1.250 GB"],
["1,5 GB", "de", "1,5 GB"],
["1,250 GB", "en", "1,250 GB"],
["1.5 GB", "en", "1.5 GB"],
["50,5 %", "de", "50,5 %"],
["50.5 %", "en", "50.5 %"]
])("preserves statistics numbers already formatted for %s", (value, language, expected) => {
expect(translateUiText(value, language as "de" | "en")).toBe(expected);
});
it.each([
["Hell", "Light"],
["Dunkel", "Dark"]
])("translates the German theme name %s in both directions", (german, english) => {
expect(translateUiText(german, "en")).toBe(english);
expect(translateUiText(english, "de")).toBe(german);
});
it.each([
["Entpackte Downloads sind ausgeblendet", "Extracted downloads are hidden"],
["Deaktiviere „Entpackte Einträge ausblenden“, um sie wieder anzuzeigen.", "Disable “Hide extracted entries” to show them again."],
["Downloads können derzeit nicht gestartet werden", "Downloads cannot be started right now"]
])("translates download state text %s in both directions", (german, english) => {
expect(translateUiText(german, "en")).toBe(english);
expect(translateUiText(english, "de")).toBe(german);
});
it.each([
["Start fehlgeschlagen: IPC_TIMEOUT", "Start failed: IPC_TIMEOUT"],
["Pause fehlgeschlagen: IPC_TIMEOUT", "Pause failed: IPC_TIMEOUT"]
])("translates download action failures %s in both directions", (german, english) => {
expect(translateUiText(german, "en")).toBe(english);
expect(translateUiText(english, "de")).toBe(german);
});
it.each([
["Ausgewählte Downloads starten (1.250)", "Start selected downloads (1,250)"],
["Ausgewählte Pakete exportieren (1.250)", "Export selected packages (1,250)"],
["Ausgewählte Dateien exportieren (1.250)", "Export selected files (1,250)"],
["Alle 1.250 umschalten", "Toggle all 1,250"],
["Ausgewählte Dateien entfernen (1.250)", "Remove selected files (1,250)"],
["Zurücksetzen (1.250)", "Reset (1,250)"],
["überspringen (1.250)", "skip (1,250)"],
["Ausgewählte entfernen (1.250)", "Remove selected (1,250)"]
])("localizes dynamic download action %s in both directions", (german, english) => {
expect(translateUiText(german, "en")).toBe(english);
expect(translateUiText(english, "de")).toBe(german);
});
it.each([
["Ausgewählte Downloads starten (1250)", "Start selected downloads (1,250)", "Ausgewählte Downloads starten (1.250)"],
["Ausgewählte Pakete exportieren (1250)", "Export selected packages (1,250)", "Ausgewählte Pakete exportieren (1.250)"],
["Ausgewählte Dateien exportieren (1250)", "Export selected files (1,250)", "Ausgewählte Dateien exportieren (1.250)"],
["Alle 1250 umschalten", "Toggle all 1,250", "Alle 1.250 umschalten"],
["Ausgewählte Dateien entfernen (1250)", "Remove selected files (1,250)", "Ausgewählte Dateien entfernen (1.250)"],
["Zurücksetzen (1250)", "Reset (1,250)", "Zurücksetzen (1.250)"],
["überspringen (1250)", "skip (1,250)", "überspringen (1.250)"],
["Ausgewählte entfernen (1250)", "Remove selected (1,250)", "Ausgewählte entfernen (1.250)"]
])("formats raw download action counts in both target locales", (germanRaw, english, germanLocalized) => {
expect(translateUiText(germanRaw, "en")).toBe(english);
expect(translateUiText(germanRaw, "de")).toBe(germanLocalized);
expect(translateUiText(english, "de")).toBe(germanLocalized);
});
it("translates dynamic queue and availability values", () => {
expect(translateUiText("26/26 online", "en")).toBe("26/26 online");
expect(translateUiText("3 abgebrochen", "en")).toBe("3 cancelled");
@@ -140,6 +274,68 @@ describe("renderer localization", () => {
expect(translateUiText("abc••••\nKlicken zum Kopieren", "en")).toBe("Click to copy abc••••");
});
it.each([
["Pakete:", "Packages:"],
["Links:", "Links:"],
["Downloads übergeben", "Transfer downloads"],
["Linksammler-Filter", "Link collector filters"],
["Suche und Paketdarstellung", "Search and package display"],
["Gesammelte Downloadpakete", "Collected download packages"],
["Analyse läuft im Hintergrund", "Analysis is running in the background"],
["Name, URL oder Hoster", "Name, URL, or host"],
["Teilweise online", "Partially online"],
["Hinzugefügt", "Added"],
["Passe Suche oder Statusfilter an.", "Adjust the search or status filter."],
["Die ersten Links erscheinen sofort nach dem Import.", "The first links appear immediately after import."],
["Links werden vorbereitet", "Links are being prepared"],
["Füge Links hinzu, um Pakete vor dem Download zu prüfen.", "Add links to check packages before downloading."],
["Links erscheinen sofort und werden anschließend im Hintergrund geprüft.", "Links appear immediately and are then checked in the background."],
["Eine URL pro Zeile", "One URL per line"],
["Keine übertragbaren Links ausgewählt", "No transferable links selected"],
["Alle sichtbaren Links auswählen", "Select all visible links"],
["Der Linksammler ist zu groß, um gespeichert zu werden.", "The link collector is too large to save."]
])("translates collector label %s in both directions", (german, english) => {
expect(translateUiText(german, "en")).toBe(english);
expect(translateUiText(english, "de")).toBe(german);
});
it.each([
["Auswahl übergeben (3)", "Send selection (3)"],
["Alle übergeben (20.000)", "Send all (20,000)"],
["Paket Staffel 1 auswählen", "Select package Staffel 1"],
["2.001 Dateien", "2,001 files"],
["20.000/20.000 online", "20,000/20,000 online"],
["Pakete: 2.001", "Packages: 2,001"],
["Links: 20.000", "Links: 20,000"],
["Ausgewählt: 1.250", "Selected: 1,250"],
["Der Linksammler kann höchstens 2.000 Pakete enthalten.", "The link collector can contain at most 2,000 packages."],
["Der Linksammler kann höchstens 20.000 Links enthalten.", "The link collector can contain at most 20,000 links."],
["Der Queue-Export überschreitet das Collector-Limit von 2.000 Paketen.", "The queue export exceeds the link collector limit of 2,000 packages."],
["Der Queue-Export überschreitet das Collector-Limit von 20.000 Links.", "The queue export exceeds the link collector limit of 20,000 links."],
["1.250", "1,250"]
])("translates dynamic collector text %s in both directions", (german, english) => {
expect(translateUiText(german, "en")).toBe(english);
expect(translateUiText(english, "de")).toBe(german);
});
it("translates nested collector persistence and import failures", () => {
expect(translateUiText("Links konnten nicht vorbereitet werden: Error: Der Queue-Export ist ungültig.", "en"))
.toBe("Links could not be prepared: Error: The queue export is invalid.");
expect(translateUiText("Linksammler konnte nicht gespeichert werden: Error: disk full", "en"))
.toBe("Link collector could not be saved: Error: disk full");
});
it.each([
["1 Paket(e), 2 Link(s) gesammelt", "1 package(s), 2 link(s) collected"],
["DLC gesammelt: 1 Paket(e), 2 Link(s)", "DLC collected: 1 package(s), 2 link(s)"],
["1 von 2 Link(s) übergeben; Sammlung bleibt erhalten", "1 of 2 link(s) transferred; collection remains"],
["1 Paket(e), 2 Link(s) übergeben", "1 package(s), 2 link(s) transferred"],
["Linksammler-Payload ist ungültig", "Link collector payload is invalid"]
])("translates collector runtime feedback %s in both directions", (german, english) => {
expect(translateUiText(german, "en")).toBe(english);
expect(translateUiText(english, "de")).toBe(german);
});
it.each([
["Verfügbarkeit", "Availability"],
["Hinzugefügt am", "Added on"],
+30
View File
@@ -44,6 +44,36 @@ describe("link-parser", () => {
expect(result[0]?.links).toEqual(["http://link1", "http://link2", "http://link3"]);
expect(result[0]?.fileNames).toEqual(["one.rar", "two.rar", "three.rar"]);
});
it("merges 20,000 unique links without quadratic duplicate scans", () => {
const prefix = "https://linear.example/";
const links = Array.from({ length: 20_000 }, (_, index) => `${prefix}${index}`);
const originalDescriptor = Object.getOwnPropertyDescriptor(Array.prototype, "includes");
const originalIncludes = Array.prototype.includes;
const maximumScannedSlots = 1_000_000;
let scannedSlots = 0;
let result: ReturnType<typeof mergePackageInputs> = [];
Object.defineProperty(Array.prototype, "includes", {
configurable: true,
writable: true,
value(this: unknown[], searchElement: unknown, fromIndex?: number): boolean {
if (typeof searchElement === "string" && searchElement.startsWith(prefix)) {
scannedSlots += this.length;
if (scannedSlots > maximumScannedSlots) throw new Error("quadratic duplicate scan limit exceeded");
}
return Reflect.apply(originalIncludes, this, [searchElement, fromIndex]) as boolean;
}
});
try {
result = mergePackageInputs([{ name: "Large package", links }]);
} finally {
if (originalDescriptor) Object.defineProperty(Array.prototype, "includes", originalDescriptor);
}
expect(result).toEqual([{ name: "Large package", links }]);
expect(scannedSlots).toBeLessThanOrEqual(maximumScannedSlots);
});
});
describe("parseCollectorInput", () => {
+56
View File
@@ -48,6 +48,7 @@ import {
type DownloadHealthSnapshot
} from "../src/main/download-health-monitor";
import { NotificationOutbox, type NotificationEvent } from "../src/main/notification-outbox";
import { logger } from "../src/main/logger";
function deferred(): { promise: Promise<void>; resolve: () => void } {
let resolve = () => {};
@@ -73,6 +74,61 @@ afterEach(() => {
});
describe("main shutdown lifecycle", () => {
it("returns a validated collector snapshot through synchronous IPC", async () => {
const main = await import("../src/main/main");
const state = { packages: [], collapsedPackageIds: [] };
const event = { returnValue: null as unknown };
main.getCollectorStateFromSyncIpc({ getCollectorState: () => state }, event);
expect(event.returnValue).toEqual(state);
expect(event.returnValue).not.toBe(state);
});
it("returns null instead of a malformed synchronous collector snapshot", async () => {
const main = await import("../src/main/main");
const warning = vi.spyOn(logger, "warn").mockImplementation(() => {});
const event = { returnValue: { packages: [], collapsedPackageIds: [] } as unknown };
try {
main.getCollectorStateFromSyncIpc({
getCollectorState: () => ({ packages: "invalid", collapsedPackageIds: [] }) as never
}, event);
expect(event.returnValue).toBeNull();
expect(warning).toHaveBeenCalledWith(expect.stringContaining("Synchrones Laden"));
} finally {
warning.mockRestore();
}
});
it("returns success only after the synchronous collector save completes", async () => {
const main = await import("../src/main/main");
const state = { packages: [], collapsedPackageIds: [] };
const saveCollectorStateSync = vi.fn(() => state);
const event = { returnValue: false };
main.saveCollectorStateFromSyncIpc({ saveCollectorStateSync }, event, state);
expect(saveCollectorStateSync).toHaveBeenCalledWith(state);
expect(event.returnValue).toBe(true);
});
it("returns false and logs a synchronous collector write failure", async () => {
const main = await import("../src/main/main");
const warning = vi.spyOn(logger, "warn").mockImplementation(() => {});
const event = { returnValue: true };
try {
expect(() => main.saveCollectorStateFromSyncIpc({
saveCollectorStateSync: () => { throw new Error("collector locked"); }
}, event, { packages: [], collapsedPackageIds: [] })).not.toThrow();
expect(event.returnValue).toBe(false);
expect(warning).toHaveBeenCalledWith(expect.stringContaining("collector locked"));
} finally {
warning.mockRestore();
}
});
it("continues the full shutdown when the collector state cannot be flushed", async () => {
const controller = Object.create(AppController.prototype) as any;
controller.runtimeStatsTimer = null;
+122
View File
@@ -7,6 +7,7 @@ import { NotificationEvent, NotificationOutbox } from "../src/main/notification-
import { buildPackageNotificationEvent } from "../src/main/notification-events";
import { buildNotifyRequest, sendNotification } from "../src/main/notify";
import { finalizePackageResult } from "../src/main/package-telemetry";
import { logger } from "../src/main/logger";
import type { DownloadItem, PackageEntry, PackageResult, PackageTelemetry, RemuxOperationMetric } from "../src/shared/types";
const tempDirs: string[] = [];
@@ -476,6 +477,30 @@ describe("NotificationOutbox", () => {
}
});
it.each(["EPERM", "EACCES", "EBUSY"])("retries a transient Windows rename error %s before succeeding", async (code) => {
const filePath = createOutboxFile();
const renameFile = fsp.rename.bind(fsp);
let renameAttempts = 0;
const rename = vi.spyOn(fsp, "rename").mockImplementation(async (oldPath, newPath) => {
renameAttempts += 1;
if (renameAttempts === 1) {
throw Object.assign(new Error(`temporary ${code}`), { code });
}
await renameFile(oldPath, newPath);
});
try {
const outbox = new NotificationOutbox({ filePath, send: async () => true, now: () => 1000 });
await outbox.enqueue(event(`transient-${code}`));
expect(renameAttempts).toBe(2);
expect(fs.existsSync(`${filePath}.tmp`)).toBe(false);
expect(persisted(filePath).events.map((queuedEvent) => queuedEvent.id)).toEqual([`transient-${code}`]);
} finally {
rename.mockRestore();
}
});
it("persists a cleaned empty legacy file atomically during load", () => {
const filePath = createOutboxFile();
const rename = vi.spyOn(fs, "renameSync");
@@ -508,6 +533,103 @@ describe("NotificationOutbox", () => {
rename.mockRestore();
});
it.each(["EPERM", "EACCES", "EBUSY"])("retries transient Windows sync rename error %s while loading", (code) => {
const filePath = createOutboxFile();
fs.writeFileSync(filePath, JSON.stringify({
version: 1,
events: [event(`sync-${code}`)],
lastSuccessAt: 0,
lastFailureAt: 0
}), "utf8");
const renameFile = fs.renameSync.bind(fs);
let attempts = 0;
const rename = vi.spyOn(fs, "renameSync").mockImplementation((oldPath, newPath) => {
attempts += 1;
if (attempts === 1) {
throw Object.assign(new Error(`temporary ${code}`), { code });
}
return renameFile(oldPath, newPath);
});
try {
const outbox = new NotificationOutbox({ filePath, send: async () => true, now: () => 1000 });
expect(attempts).toBe(2);
expect(outbox.getStatus().queued).toBe(1);
expect(persisted(filePath).events.map((queuedEvent) => queuedEvent.id)).toEqual([`sync-${code}`]);
expect(fs.existsSync(`${filePath}.tmp`)).toBe(false);
} finally {
rename.mockRestore();
}
});
it("continues loading and asynchronously recovers after bounded sync rename retries stay blocked", async () => {
vi.useFakeTimers();
const filePath = createOutboxFile();
const originalPayload = JSON.stringify({
version: 1,
events: [event("expired-sync-recovery", { expiresAt: 999 })],
lastSuccessAt: 0,
lastFailureAt: 0
});
fs.writeFileSync(filePath, originalPayload, "utf8");
let syncAttempts = 0;
const syncRename = vi.spyOn(fs, "renameSync").mockImplementation(() => {
syncAttempts += 1;
throw Object.assign(new Error("temporarily locked"), { code: "EBUSY" });
});
const renameFile = fsp.rename.bind(fsp);
let markRecovered = () => {};
const recovered = new Promise<void>((resolve) => { markRecovered = resolve; });
const asyncRename = vi.spyOn(fsp, "rename").mockImplementation(async (oldPath, newPath) => {
await renameFile(oldPath, newPath);
markRecovered();
});
try {
const outbox = new NotificationOutbox({ filePath, send: async () => true, now: () => 1000, autoDrain: true });
expect(syncAttempts).toBe(4);
expect(outbox.getStatus().queued).toBe(0);
expect(fs.readFileSync(filePath, "utf8")).toBe(originalPayload);
expect(fs.existsSync(`${filePath}.tmp`)).toBe(false);
syncRename.mockRestore();
await vi.advanceTimersByTimeAsync(1000);
await recovered;
expect(persisted(filePath).events).toEqual([]);
expect(fs.existsSync(`${filePath}.tmp`)).toBe(false);
} finally {
syncRename.mockRestore();
asyncRename.mockRestore();
}
});
it("reports a permanent sync persistence failure without replacing the loaded file", () => {
const filePath = createOutboxFile();
const originalPayload = JSON.stringify({
version: 1,
events: [event("permanent-sync-failure")],
lastSuccessAt: 0,
lastFailureAt: 0
});
fs.writeFileSync(filePath, originalPayload, "utf8");
const rename = vi.spyOn(fs, "renameSync").mockImplementation(() => {
throw Object.assign(new Error("disk unavailable"), { code: "ENOSPC" });
});
const errorLog = vi.spyOn(logger, "error").mockImplementation(() => {});
try {
const outbox = new NotificationOutbox({ filePath, send: async () => true, now: () => 1000 });
expect(outbox.getStatus().queued).toBe(1);
expect(fs.readFileSync(filePath, "utf8")).toBe(originalPayload);
expect(fs.existsSync(`${filePath}.tmp`)).toBe(false);
expect(errorLog).toHaveBeenCalledWith(expect.stringContaining("ENOSPC"));
} finally {
errorLog.mockRestore();
rename.mockRestore();
}
});
it("drops expired events before persisting or sending", async () => {
const filePath = createOutboxFile();
const send = vi.fn().mockResolvedValue(true);
+12
View File
@@ -4,6 +4,18 @@ import { createRendererSettings } from "../src/main/renderer-state";
import { validateRendererSettingsUpdate } from "../src/main/renderer-settings";
describe("renderer settings validation", () => {
it("projects and validates the semantic theme preference independently from the resolved palette", () => {
const current = { ...defaultSettings(), theme: "dark" as const, themePreference: "system" as const };
const projected = createRendererSettings(current);
expect(projected).toEqual(expect.objectContaining({ theme: "dark", themePreference: "system" }));
expect(validateRendererSettingsUpdate({ theme: "light", themePreference: "system" }, current)).toEqual({
theme: "light",
themePreference: "system"
});
expect(() => validateRendererSettingsUpdate({ themePreference: "automatic" }, current)).toThrow("Settings-Payload ist ungültig");
});
it("accepts every editable notification control while keeping the webhook write-only", () => {
const current = { ...defaultSettings(), notifyUrl: "https://notify.example.test/private-hook" };
const update = {
+82 -6
View File
@@ -4,7 +4,7 @@ import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { defaultSettings } from "../src/main/constants";
import { createRendererSettings, createRendererState } from "../src/main/renderer-state";
import { buildAccountAddFields, buildAccountCreateProviderOrderUpdate, buildProviderOrderEntry, createAccountDialogState, createDiscardedSettingsState, createSettingsDraft, resolveSettingsSaveCompletion } from "../src/renderer/App";
import { buildAccountAddFields, buildAccountCreateProviderOrderUpdate, buildProviderOrderEntry, captureSettingsSaveIntent, createAccountDialogState, createDiscardedSettingsState, createSettingsDraft, resolveSettingsSaveCompletion } from "../src/renderer/App";
import { buildAccountReplaceCommand, createAccountEditState, type AccountEditTarget } from "../src/renderer/account-edit";
import {
buildAccountTogglePatch,
@@ -66,6 +66,26 @@ const settingsCss = readFileSync(
"utf8"
);
describe("settings save intent", () => {
it("captures the clicked draft before waiting for queued account mutations", () => {
const clicked = { outputDir: "C:\\Clicked", maxParallel: 4 };
const intent = captureSettingsSaveIntent(7, clicked);
clicked.outputDir = "C:\\Edited later";
expect(intent).toEqual({ revision: 7, draft: { outputDir: "C:\\Clicked", maxParallel: 4 } });
});
it("keeps edits made after the save click dirty when the older request completes", () => {
const intent = captureSettingsSaveIntent(7, { outputDir: "C:\\Clicked" });
expect(resolveSettingsSaveCompletion(intent.revision, 8)).toEqual({
saveState: "dirty",
applyPersistedTheme: false,
toast: "Zwischenstand gespeichert weitere Änderungen sind ungespeichert"
});
});
});
function sourceBlock(source: string, start: string, end: string): string {
return source.slice(source.indexOf(start), source.indexOf(end, source.indexOf(start)));
}
@@ -268,8 +288,8 @@ function formModel(): SettingsFormViewModel {
label: "Theme",
value: "dark",
options: [
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" },
{ value: "light", label: "Hell" },
{ value: "dark", label: "Dunkel" },
{ value: "system", label: "System" }
]
},
@@ -840,8 +860,8 @@ describe("settings views", () => {
});
const html = renderToStaticMarkup(form);
expect(html).toContain("Light");
expect(html).toContain("Dark");
expect(html).toContain("Hell");
expect(html).toContain("Dunkel");
expect(html).toContain("System");
expect(html).toContain("role=\"switch\"");
const switchButton = findElement(form, (element) => element.props.role === "switch");
@@ -1412,6 +1432,28 @@ describe("settings App integration", () => {
});
});
it("uses the persisted semantic theme preference as the settings field value", () => {
const form = buildSettingsFormViewModel({
settings: {
...createRendererSettings({ ...defaultSettings(), theme: "dark", themePreference: "system" }),
archivePasswordList: "",
notifyUrl: ""
},
section: "allgemein",
speedLimitInput: "0",
scheduleSpeedInputs: {}
});
expect(form.groups.flatMap((group) => group.fields).find((field) => field.id === "theme")).toEqual(expect.objectContaining({
value: "system",
options: [
{ value: "light", label: "Hell" },
{ value: "dark", label: "Dunkel" },
{ value: "system", label: "System" }
]
}));
});
it("does not apply a stale save result over newer draft changes", () => {
expect(resolveSettingsSaveCompletion(4, 4)).toEqual({
saveState: "saved",
@@ -1432,7 +1474,7 @@ describe("settings App integration", () => {
expect(revealBlock).toContain("window.rd.getArchivePasswordList()");
expect(revealBlock).toContain('settingsSubTab !== "extract"');
expect(appSource).toContain("setSettingsDraft((current) => createSettingsDraft(state.settings, current))");
expect(appSource).toContain("setSettingsDraft((current) => createSettingsDraft({ ...state.settings, theme: resolvedTheme }, current))");
expect(appSource).toContain("setSettingsDraft((current) => createSettingsDraft(next.settings, current))");
expect(applyBlock).toContain("setSettingsDraft((current) => createSettingsDraft(result, preserveWriteOnlyValues ? current : undefined))");
expect(mainSource).toContain("handleTrusted(IPC_CHANNELS.GET_ARCHIVE_PASSWORD_LIST");
@@ -1529,6 +1571,40 @@ describe("settings App integration", () => {
expect(appSource).toContain("settingsThemeChoice");
expect(appSource).toContain("resolveSettingsThemeChoice");
});
it("restores and persists the semantic theme choice through backend settings", () => {
const startupBlock = sourceBlock(appSource, "void window.rd.getSnapshot()", "unsubscribe = window.rd.onStateUpdate");
const saveBlock = sourceBlock(appSource, "const persistDraftSettingsDirect", "const persistDraftSettings =");
expect(appSource).not.toContain("SETTINGS_THEME_CHOICE_STORAGE_KEY");
expect(appSource).not.toContain("window.localStorage");
expect(startupBlock).toContain("state.settings.themePreference");
expect(saveBlock).toContain("themePreference: themeChoiceAtStart");
});
it("tracks operating-system palette changes only while System is selected", () => {
expect(appSource).toContain('addEventListener("change", onSystemThemeChange)');
expect(appSource).toContain('removeEventListener("change", onSystemThemeChange)');
expect(appSource).toContain('settingsThemeChoiceRef.current !== "system"');
});
it("clears the live bandwidth chart only after a confirmed session reset succeeds", () => {
const resetBlock = sourceBlock(appSource, "onResetSession: () => {", "onResetAll: () => {");
const resetSuccessBlock = sourceBlock(resetBlock, "return window.rd.resetSessionStats().then(() => {", "}).catch((error) => {");
expect(resetSuccessBlock).toContain("speedHistoryRef.current = []");
expect(resetBlock.indexOf("window.rd.resetSessionStats()")).toBeLessThan(resetBlock.indexOf("speedHistoryRef.current = []"));
});
it("clears stale history state when loading the persisted history fails", () => {
const loadBlock = sourceBlock(appSource, "const loadHistoryEntries", "useEffect(() => {");
const catchBlock = sourceBlock(loadBlock, "} catch {", "} finally {");
expect(catchBlock).toContain("pendingLiveHistoryEntriesRef.current = []");
expect(catchBlock).toContain("applyHistoryEntries([])");
expect(catchBlock.indexOf("pendingLiveHistoryEntriesRef.current = []")).toBeLessThan(catchBlock.indexOf("setHistoryError"));
expect(catchBlock.indexOf("applyHistoryEntries([])")).toBeLessThan(catchBlock.indexOf("setHistoryError"));
});
});
describe("settings geometry", () => {
+18
View File
@@ -0,0 +1,18 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const themeCss = readFileSync(new URL("../src/renderer/theme.css", import.meta.url), "utf8");
const settingsCss = readFileSync(new URL("../src/renderer/views/settings/settings.css", import.meta.url), "utf8");
const historyCss = readFileSync(new URL("../src/renderer/views/history/history.css", import.meta.url), "utf8");
describe("sidepanel theme surfaces", () => {
it("defines the shared panel token used by account and history surfaces", () => {
expect(themeCss).toMatch(/--ui-panel:\s*var\(--ui-surface\);/);
expect(settingsCss).toContain("var(--ui-panel)");
expect(historyCss).toContain("var(--ui-panel)");
});
it("removes the standalone settings divider inside the shell sidebar", () => {
expect(settingsCss).toMatch(/\.md-shell-sidebar-scroll\s*>\s*\.settings-sidebar\s*\{[^}]*border-right:\s*0;/s);
});
});
+118 -1
View File
@@ -13,10 +13,13 @@ import {
loadStatisticsLedger,
normalizeStatisticsLedger,
projectStatisticsLedger,
rebaseStatisticsProviderSeedBaseline,
recordStatisticsActiveInterval,
recordStatisticsBytes,
recordStatisticsOutcome,
saveStatisticsLedger
saveStatisticsLedger,
seedStatisticsDayProviderBytes,
suppressStatisticsProviderSeedForDay
} from "../src/main/statistics-ledger";
const roots: string[] = [];
@@ -66,10 +69,21 @@ describe("statistics ledger", () => {
const migrated = normalizeStatisticsLedger(legacy, now);
expect(migrated.version).toBe(2);
expect(migrated.startedAt).toBe(legacy.startedAt);
expect(migrated.minuteTrackingStartedAt).toBe(now);
expect(migrated.days).toEqual(legacy.days);
expect(migrated.minutes).toEqual([]);
});
it("preserves the persisted minute tracking coverage start", () => {
const now = localTime(10);
const minuteTrackingStartedAt = now - (25 * 60 * 60 * 1_000);
const ledger = createStatisticsLedger(now);
expect(ledger.minuteTrackingStartedAt).toBe(now);
expect(normalizeStatisticsLedger({ ...ledger, minuteTrackingStartedAt }, now).minuteTrackingStartedAt).toBe(minuteTrackingStartedAt);
});
it("normalizes, merges, and bounds sparse account minute history", () => {
const now = localTime(10);
const currentMinute = Math.floor(now / 60_000) * 60_000;
@@ -199,6 +213,108 @@ describe("statistics ledger", () => {
expect(month).toMatchObject({ downloadedBytes: 107_000, activeDownloadMs: 3_000 });
});
it("reconciles provider usage unless the day carries an explicit reset marker", () => {
const previousDay = recordStatisticsBytes(createStatisticsLedger(localTime(9)), "debridlink", 200, localTime(9));
const migrated = seedStatisticsDayProviderBytes(previousDay, { realdebrid: 500 }, localTime(10));
expect(migrated.days).toEqual([
expect.objectContaining({ day: "2026-08-09", downloadedBytes: 200 }),
expect.objectContaining({ day: "2026-08-10", downloadedBytes: 500, providers: { realdebrid: expect.objectContaining({ bytes: 500 }) } })
]);
const existingToday = recordStatisticsBytes(createStatisticsLedger(localTime(10)), "realdebrid", 200, localTime(10));
expect(seedStatisticsDayProviderBytes(existingToday, { realdebrid: 500 }, localTime(10)).days[0]).toMatchObject({
downloadedBytes: 500,
providers: { realdebrid: expect.objectContaining({ bytes: 500 }) }
});
const resetDay = suppressStatisticsProviderSeedForDay(createStatisticsLedger(localTime(10)), localTime(10));
const preserved = seedStatisticsDayProviderBytes(resetDay, { realdebrid: 500 }, localTime(10));
expect(preserved.providerSeedSuppressedDay).toBe("2026-08-10");
expect(preserved.days).toEqual([]);
});
it("catches up only provider traffic recorded after a statistics reset baseline", () => {
const now = localTime(10);
const reset = suppressStatisticsProviderSeedForDay(
createStatisticsLedger(now),
now,
{ realdebrid: 500, debridlink: 200 }
);
const staleSavedLedger = recordStatisticsBytes(reset, "realdebrid", 100, now);
const caughtUp = seedStatisticsDayProviderBytes(
staleSavedLedger,
{ realdebrid: 650, debridlink: 200, deepbrid: 50 },
now
);
expect(caughtUp.providerSeedSuppressedDay).toBe("2026-08-10");
expect(caughtUp.providerSeedBaselineBytes).toEqual({ realdebrid: 500, debridlink: 200 });
expect(caughtUp.providerBytesOnlyDays).toEqual(["2026-08-10"]);
expect(caughtUp.days[0]).toMatchObject({
downloadedBytes: 200,
providers: {
realdebrid: { bytes: 150 },
deepbrid: { bytes: 50 }
}
});
expect(caughtUp.days[0].providers.debridlink).toBeUndefined();
});
it("normalizes reset baselines and preserves legacy marker suppression", () => {
const now = localTime(10);
const normalized = normalizeStatisticsLedger({
...createStatisticsLedger(now),
providerSeedSuppressedDay: "2026-08-10",
providerSeedBaselineBytes: { realdebrid: 100.9, deepbrid: -5, unsafe: 999 }
}, now);
expect(normalized.providerSeedBaselineBytes).toEqual({ realdebrid: 100, deepbrid: -5 });
const legacy = normalizeStatisticsLedger({
...createStatisticsLedger(now),
providerSeedSuppressedDay: "2026-08-10"
}, now);
expect(legacy.providerSeedBaselineBytes).toBeUndefined();
expect(seedStatisticsDayProviderBytes(legacy, { realdebrid: 500 }, now).days).toEqual([]);
const nextDay = seedStatisticsDayProviderBytes(normalized, { realdebrid: 25 }, localTime(11));
expect(nextDay.providerSeedSuppressedDay).toBeUndefined();
expect(nextDay.providerSeedBaselineBytes).toBeUndefined();
expect(nextDay.days[0]?.providers.realdebrid?.bytes).toBe(25);
});
it("rebases an active provider quota baseline without losing post-reset statistics", () => {
const now = localTime(10);
const reset = suppressStatisticsProviderSeedForDay(createStatisticsLedger(now), now, {
realdebrid: 500,
debridlink: 200
});
const withPostResetTraffic = recordStatisticsBytes(reset, "realdebrid", 200, now);
const rebased = rebaseStatisticsProviderSeedBaseline(withPostResetTraffic, "realdebrid", 0, now);
const afterMoreTraffic = seedStatisticsDayProviderBytes(rebased, { realdebrid: 100, debridlink: 200 }, now);
expect(rebased.providerSeedBaselineBytes).toEqual({ realdebrid: -200, debridlink: 200 });
expect(rebased.days[0]?.providers.realdebrid?.bytes).toBe(200);
expect(afterMoreTraffic.days[0]?.providers.realdebrid?.bytes).toBe(300);
expect(afterMoreTraffic.days[0]?.downloadedBytes).toBe(300);
});
it("establishes a catch-up baseline when quota usage is reset without a statistics reset", () => {
const now = localTime(10);
const existing = recordStatisticsBytes(createStatisticsLedger(now), "realdebrid", 500, now);
const rebased = rebaseStatisticsProviderSeedBaseline(existing, "realdebrid", 0, now);
const restored = seedStatisticsDayProviderBytes(rebased, { realdebrid: 100 }, now);
expect(rebased.providerSeedSuppressedDay).toBe("2026-08-10");
expect(rebased.providerSeedBaselineBytes).toEqual({ realdebrid: -500 });
expect(restored.days[0]?.providers.realdebrid?.bytes).toBe(600);
expect(restored.days[0]?.downloadedBytes).toBe(600);
});
it("splits active intervals across local calendar days", () => {
const start = new Date(2026, 7, 9, 23, 59, 59, 500).getTime();
const end = new Date(2026, 7, 10, 0, 0, 0, 500).getTime();
@@ -218,6 +334,7 @@ describe("statistics ledger", () => {
"completed",
localTime(10)
);
ledger.providerBytesOnlyDays = ["2026-08-10"];
saveStatisticsLedger(filePath, ledger);
expect(loadStatisticsLedger(filePath, localTime(10))).toEqual(ledger);
+115 -2
View File
@@ -22,6 +22,7 @@ import {
recordStatisticsBytes,
recordStatisticsOutcome
} from "../src/main/statistics-ledger";
import { translateUiText } from "../src/renderer/i18n";
const now = new Date(2026, 7, 10, 12, 0, 0, 0).getTime();
@@ -117,8 +118,34 @@ function expectUnavailable(metric: StatisticsMetric): void {
expect(metric.sourceLabel.trim()).not.toBe("");
}
describe("statistics model", () => {
it("uses real snapshot session fields and excludes active or waiting downloads from the success denominator", () => {
describe("statistics model", () => {
it("produces range messages that the shared localizer translates completely", () => {
const snapshot = createSnapshot();
snapshot.stats.statistics = {
version: 2,
startedAt: now,
days: [],
minutes: []
};
expect(translateUiText(buildStatisticsViewModel(snapshot, "last24", now).message, "en"))
.toBe("Last 24 hours: values since tracking began.");
expect(translateUiText(buildStatisticsViewModel(snapshot, "today", now).message, "en"))
.toBe("Today's values come from local statistics tracking.");
expect(translateUiText(buildStatisticsViewModel(snapshot, "all", now).message, "en"))
.toBe("Data volume and files come from the total counters; results and averages cover the period since statistics tracking began.");
});
it("uses singular grammar for one recorded statistics day in both languages", () => {
const snapshot = createSnapshot();
snapshot.stats.statistics = recordStatisticsBytes(createStatisticsLedger(now), "realdebrid", 1, now);
const message = buildStatisticsViewModel(snapshot, "week", now).message;
expect(message).toBe("Letzte sieben Tage: 1 erfasster Tag wird bis heute zusammengefasst.");
expect(translateUiText(message, "en")).toBe("Last seven days: 1 recorded day is summarized through today.");
});
it("uses real snapshot session fields and excludes active or waiting downloads from the success denominator", () => {
const snapshot = createSnapshot();
snapshot.stats.totalDownloaded = 600;
snapshot.stats.totalFilesSession = 3;
@@ -213,6 +240,25 @@ describe("statistics model", () => {
expect(model.providers.map((row) => [row.completed, row.failed])).toEqual([[0, 1], [1, 0]]);
});
it("keeps recovered provider bytes available while marking unrecoverable daily details unavailable", () => {
const snapshot = createSnapshot();
let ledger = createStatisticsLedger(now);
ledger = recordStatisticsBytes(ledger, "realdebrid", 500, now);
ledger = recordStatisticsOutcome(ledger, "realdebrid", "completed", now);
ledger = recordStatisticsActiveInterval(ledger, now - 500, now);
ledger.providerBytesOnlyDays = ["2026-08-10"];
snapshot.stats.statistics = ledger;
const model = buildStatisticsViewModel(snapshot, "today", now);
expect(model.metrics.downloadedBytes).toMatchObject({ value: 500, available: true });
expectUnavailable(model.metrics.files);
expectUnavailable(model.metrics.successRate);
expectUnavailable(model.metrics.averageSpeedBps);
expectUnavailable(model.metrics.errors);
expect(model.providers.map((row) => [row.completed, row.failed])).toEqual([[null, null]]);
});
it("shows rolling account traffic with unavailable day-scoped metrics", () => {
const snapshot = createSnapshot();
snapshot.stats.statistics = {
@@ -248,11 +294,43 @@ describe("statistics model", () => {
expect(model.message).toContain("seit Beginn der Aufzeichnung");
});
it("does not present incomplete rolling traffic as available after provider-only recovery", () => {
const snapshot = createSnapshot();
snapshot.stats.statistics = {
version: 2,
startedAt: now - (25 * 60 * 60 * 1_000),
minuteTrackingStartedAt: now - (25 * 60 * 60 * 1_000),
providerBytesOnlyDays: ["2026-08-10"],
days: [{
day: "2026-08-10",
downloadedBytes: 125,
measuredBytes: 0,
completedFiles: 0,
failedFiles: 0,
activeDownloadMs: 0,
providers: { realdebrid: { bytes: 125, completed: 0, failed: 0 } }
}],
minutes: []
};
snapshot.stats.rolling24Hours = {
from: now - (24 * 60 * 60 * 1_000),
to: now,
downloadedBytes: 0,
accounts: []
};
const model = buildStatisticsViewModel(snapshot, "last24", now);
expectUnavailable(model.metrics.downloadedBytes);
expect(model.providers).toEqual([]);
});
it("shows a complete rolling description after 24 hours of statistics coverage", () => {
const snapshot = createSnapshot();
snapshot.stats.statistics = {
version: 2,
startedAt: now - (25 * 60 * 60 * 1_000),
minuteTrackingStartedAt: now - (25 * 60 * 60 * 1_000),
days: [],
minutes: []
};
@@ -268,6 +346,21 @@ describe("statistics model", () => {
expect(model.message).toContain("vergangenen 24 Stunden");
expect(model.message).not.toContain("seit Beginn der Aufzeichnung");
});
it("does not infer minute coverage from an old ledger start during migration", () => {
const snapshot = createSnapshot();
snapshot.stats.statistics = {
version: 2,
startedAt: now - (25 * 60 * 60 * 1_000),
days: [],
minutes: []
};
const model = buildStatisticsViewModel(snapshot, "last24", now);
expect(model.message).toContain("seit Beginn der Aufzeichnung");
expect(model.message).not.toContain("vergangenen 24 Stunden");
});
it("treats a stale daily key as a genuine zero today without stale provider rows", () => {
const snapshot = createSnapshot();
@@ -392,6 +485,26 @@ describe("statistics model", () => {
});
describe("statistics view", () => {
it("formats provider outcome counts for the active locale pipeline", () => {
const model = {
...buildStatisticsViewModel(createSnapshot(), "today", now),
providers: [{ id: "realdebrid", label: "Real-Debrid", bytes: 0, completed: 1_250, failed: 2_500 }]
};
const html = renderToStaticMarkup(<StatisticsContent actions={createActions()} chart={<div />} model={model} />);
expect(html).toContain("1.250 fertig · 2.500 Fehler");
});
it("formats the sidebar provider count for the active locale pipeline", () => {
const model = {
...buildStatisticsViewModel(createSnapshot(), "today", now),
providers: Array.from({ length: 1_250 }, (_, index) => ({ id: String(index), label: String(index), bytes: 0, completed: 0, failed: 0 }))
};
const html = renderToStaticMarkup(<StatisticsSidebarStatus model={model} />);
expect(html).toContain("Provider: 1.250");
});
it("keeps large statistic data volumes precise in gigabytes instead of rounding to terabytes", () => {
const snapshot = createSnapshot();
let ledger = createStatisticsLedger(now);
+380 -27
View File
@@ -1,6 +1,6 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys";
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
@@ -9,7 +9,7 @@ import { parseRealDebridApiAccounts, serializeRealDebridApiAccounts } from "../s
import { AppSettings } from "../src/shared/types";
import { defaultSettings } from "../src/main/constants";
import { configureCredentialProtector } from "../src/main/credential-protection";
import { addHistoryEntryForRetention, createStoragePaths, emptySession, loadHistory, loadHistoryForRetention, loadSession, loadSessionWithStatus, loadSettings, normalizeHistoryEntry, normalizeLoadedSession, normalizeSettings, removeHistoryEntries, resetHistoryForRetention, saveHistory, saveSession, saveSessionAsync, saveSettings, saveSettingsAsync } from "../src/main/storage";
import { acquirePersistenceBarrier, addHistoryEntryForRetention, clearHistory, createStoragePaths, emptySession, loadHistory, loadHistoryForRetention, loadSession, loadSessionWithStatus, loadSettings, normalizeHistoryEntry, normalizeLoadedSession, normalizeSettings, removeHistoryEntries, replaceHistory, resetHistoryForRetention, saveHistory, saveSession, saveSessionAsync, saveSettings, saveSettingsAsync } from "../src/main/storage";
const tempDirs: string[] = [];
type SettingsSaveMode = "sync" | "async";
@@ -1472,12 +1472,12 @@ describe("settings storage", () => {
expect(loadHistoryForRetention(paths, "never")).toEqual([]);
});
it("clears persisted history for session retention mode", () => {
it("clears persisted history for session retention mode", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
saveHistory(paths, [{
saveHistory(paths, [{
id: "hist-2",
name: "kept",
totalBytes: 2048,
@@ -1488,15 +1488,166 @@ describe("settings storage", () => {
durationSeconds: 20,
status: "completed",
outputDir: path.join(dir, "out"),
urls: ["https://example.com/file2.rar"]
}]);
resetHistoryForRetention(paths, "session");
expect(loadHistory(paths)).toEqual([]);
});
it("caps persisted history to the configured maxEntries", () => {
urls: ["https://example.com/file2.rar"]
}]);
saveHistory(paths, [{
id: "hist-3",
name: "newer",
totalBytes: 4096,
downloadedBytes: 4096,
fileCount: 1,
provider: "realdebrid",
completedAt: Date.now(),
durationSeconds: 25,
status: "completed",
outputDir: path.join(dir, "out"),
urls: ["https://example.com/file3.rar"]
}]);
resetHistoryForRetention(paths, "session");
expect(loadHistory(paths)).toEqual([]);
expect(fs.existsSync(`${paths.historyFile}.bak`)).toBe(false);
});
it("recovers history from the last valid backup when the primary file is corrupted", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
const entry = {
id: "history-backup",
name: "Backup",
totalBytes: 1,
downloadedBytes: 1,
fileCount: 1,
provider: "realdebrid" as const,
completedAt: Date.now(),
durationSeconds: 1,
status: "completed" as const,
outputDir: path.join(dir, "out"),
urls: []
};
saveHistory(paths, [entry]);
saveHistory(paths, [{ ...entry, id: "newer", name: "Neuer" }]);
fs.writeFileSync(paths.historyFile, "{broken", "utf8");
expect(loadHistory(paths).map((item) => item.id)).toEqual(["history-backup"]);
expect(loadHistory(paths).map((item) => item.id)).toEqual(["history-backup"]);
});
it("reports an unreadable history instead of silently replacing it with an empty list", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
fs.mkdirSync(paths.baseDir, { recursive: true });
fs.writeFileSync(paths.historyFile, "{broken", "utf8");
fs.writeFileSync(`${paths.historyFile}.bak`, "{also-broken", "utf8");
expect(() => loadHistory(paths)).toThrow("Verlaufsspeicher ist beschädigt");
});
it("returns a valid history backup even when repairing the primary file fails", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
const entry = {
id: "readable-backup",
name: "Backup",
totalBytes: 1,
downloadedBytes: 1,
fileCount: 1,
provider: "realdebrid" as const,
completedAt: Date.now(),
durationSeconds: 1,
status: "completed" as const,
outputDir: path.join(dir, "out"),
urls: []
};
saveHistory(paths, [entry]);
saveHistory(paths, [{ ...entry, id: "newer" }]);
fs.writeFileSync(paths.historyFile, "{broken", "utf8");
const rename = vi.spyOn(fs, "renameSync").mockImplementation(() => {
throw Object.assign(new Error("locked"), { code: "EPERM" });
});
try {
expect(loadHistory(paths).map((item) => item.id)).toEqual(["readable-backup"]);
} finally {
rename.mockRestore();
}
});
it("authoritatively replaces both history copies without retaining previous entries", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
saveHistory(paths, [{ id: "old", name: "Alt" } as never]);
replaceHistory(paths, [{ id: "new", name: "Neu" } as never]);
expect(loadHistory(paths).map((entry) => entry.id)).toEqual(["new"]);
expect((JSON.parse(fs.readFileSync(`${paths.historyFile}.bak`, "utf8")) as Array<{ id: string }>).map((entry) => entry.id)).toEqual(["new"]);
});
it("rolls back the backup copy when authoritative primary replacement fails", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
saveHistory(paths, [{ id: "older", name: "Älter" } as never]);
saveHistory(paths, [{ id: "old", name: "Alt" } as never]);
const previousBackup = fs.readFileSync(`${paths.historyFile}.bak`, "utf8");
const renameFile = fs.renameSync.bind(fs);
let failed = false;
const rename = vi.spyOn(fs, "renameSync").mockImplementation((source, destination) => {
if (!failed && String(destination) === paths.historyFile) {
failed = true;
throw Object.assign(new Error("primary locked"), { code: "EPERM" });
}
return renameFile(source, destination);
});
try {
expect(() => replaceHistory(paths, [{ id: "new", name: "Neu" } as never])).toThrow("primary locked");
} finally {
rename.mockRestore();
}
expect(loadHistory(paths).map((entry) => entry.id)).toEqual(["old"]);
expect(fs.readFileSync(`${paths.historyFile}.bak`, "utf8")).toBe(previousBackup);
});
it("keeps the primary history intact when clearing its backup fails", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
const entry = {
id: "clear-protected",
name: "Geschützt",
totalBytes: 1,
downloadedBytes: 1,
fileCount: 1,
provider: "realdebrid" as const,
completedAt: Date.now(),
durationSeconds: 1,
status: "completed" as const,
outputDir: path.join(dir, "out"),
urls: []
};
saveHistory(paths, [entry]);
saveHistory(paths, [{ ...entry, id: "clear-current" }]);
const removeFile = fs.rmSync.bind(fs);
const remove = vi.spyOn(fs, "rmSync").mockImplementation((filePath, options) => {
if (String(filePath) === `${paths.historyFile}.bak`) throw Object.assign(new Error("busy"), { code: "EBUSY" });
return removeFile(filePath, options);
});
try {
expect(() => clearHistory(paths)).toThrow();
expect(fs.existsSync(paths.historyFile)).toBe(true);
} finally {
remove.mockRestore();
}
});
it("caps persisted history to the configured maxEntries", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
@@ -1734,7 +1885,8 @@ describe("settings storage", () => {
const updated = removeHistoryEntries(paths, ["hist-0", "hist-599"], limits);
expect(renameSpy).toHaveBeenCalledTimes(1);
expect(renameSpy).toHaveBeenCalledTimes(2);
expect(renameSpy.mock.calls.map((call) => call[1])).toEqual([`${paths.historyFile}.bak`, paths.historyFile]);
renameSpy.mockRestore();
expect(updated).toHaveLength(598);
expect(updated.some((entry) => entry.id === "hist-0" || entry.id === "hist-599")).toBe(false);
@@ -1952,7 +2104,7 @@ describe("settings storage", () => {
expect(loaded.items["item-outside"]?.targetPath).toBe("");
});
it("captures async session save payload before later mutations", async () => {
it("captures async session save payload before later mutations", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
@@ -1965,10 +2117,185 @@ describe("settings storage", () => {
await pending;
const persisted = JSON.parse(fs.readFileSync(paths.sessionFile, "utf8")) as { summaryText: string };
expect(persisted.summaryText).toBe("before-mutation");
});
it("creates session backup before sync and async session overwrites", async () => {
expect(persisted.summaryText).toBe("before-mutation");
});
it("keeps newer synchronous settings and session saves when older async commits resume", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-sync-wins-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
const oldSettings = { ...defaultSettings(), packageName: "old-async" };
const newSettings = { ...defaultSettings(), packageName: "new-sync" };
const oldSession = { ...emptySession(), summaryText: "old-async" };
const newSession = { ...emptySession(), summaryText: "new-sync" };
saveSettings(paths, defaultSettings());
saveSession(paths, emptySession());
let releaseSettingsCommit = () => {};
let releaseSessionCommit = () => {};
let markSettingsCommitStarted = () => {};
let markSessionCommitStarted = () => {};
const settingsCommitStarted = new Promise<void>((resolve) => { markSettingsCommitStarted = resolve; });
const sessionCommitStarted = new Promise<void>((resolve) => { markSessionCommitStarted = resolve; });
const settingsCommitRelease = new Promise<void>((resolve) => { releaseSettingsCommit = resolve; });
const sessionCommitRelease = new Promise<void>((resolve) => { releaseSessionCommit = resolve; });
const renameFile = fs.promises.rename.bind(fs.promises);
const rename = vi.spyOn(fs.promises, "rename").mockImplementation(async (source, destination) => {
const sourcePath = String(source);
if (sourcePath === `${paths.configFile}.settings.tmp`) {
markSettingsCommitStarted();
await settingsCommitRelease;
}
if (sourcePath === `${paths.sessionFile}.async.tmp`) {
markSessionCommitStarted();
await sessionCommitRelease;
}
await renameFile(source, destination);
});
try {
const oldSettingsSave = saveSettingsAsync(paths, oldSettings);
const oldSessionSave = saveSessionAsync(paths, oldSession);
const oldSaves = Promise.all([oldSettingsSave, oldSessionSave]);
const asyncCommitPaused = await Promise.race([
Promise.all([settingsCommitStarted, sessionCommitStarted]).then(() => true),
oldSaves.then(() => false)
]);
saveSettings(paths, newSettings);
saveSession(paths, newSession);
if (asyncCommitPaused) {
releaseSettingsCommit();
releaseSessionCommit();
}
await oldSaves;
expect(loadSettings(paths).packageName).toBe("new-sync");
expect(loadSession(paths).summaryText).toBe("new-sync");
} finally {
releaseSettingsCommit();
releaseSessionCommit();
rename.mockRestore();
}
});
it("persists the async session primary when replacing its backup is temporarily blocked", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-session-backup-blocked-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
saveSession(paths, { ...emptySession(), summaryText: "previous" });
const renameFile = fs.renameSync.bind(fs);
const rename = vi.spyOn(fs, "renameSync").mockImplementation((source, destination) => {
if (String(destination) === `${paths.sessionFile}.bak`) {
throw Object.assign(new Error("backup busy"), { code: "EBUSY" });
}
return renameFile(source, destination);
});
try {
await saveSessionAsync(paths, { ...emptySession(), summaryText: "latest" });
} finally {
rename.mockRestore();
}
expect(loadSession(paths).summaryText).toBe("latest");
});
it("waits for in-flight settings and session writes and discards saves blocked by an import barrier", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-barrier-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
const oldSettings = { ...defaultSettings(), packageName: "old-settings" };
const importedSettings = { ...defaultSettings(), packageName: "imported-settings" };
const oldSession = { ...emptySession(), summaryText: "old-session" };
const importedSession = { ...emptySession(), summaryText: "imported-session" };
saveSettings(paths, defaultSettings());
saveSession(paths, emptySession());
let releaseSettingsWrite = () => {};
let releaseSessionWrite = () => {};
let markSettingsWriteStarted = () => {};
let markSessionWriteStarted = () => {};
const settingsWriteStarted = new Promise<void>((resolve) => { markSettingsWriteStarted = resolve; });
const sessionWriteStarted = new Promise<void>((resolve) => { markSessionWriteStarted = resolve; });
const settingsWriteRelease = new Promise<void>((resolve) => { releaseSettingsWrite = resolve; });
const sessionWriteRelease = new Promise<void>((resolve) => { releaseSessionWrite = resolve; });
const writeFile = fs.promises.writeFile.bind(fs.promises);
const openFile = fs.promises.open.bind(fs.promises);
const write = vi.spyOn(fs.promises, "writeFile").mockImplementation(async (filePath, data, options) => {
await writeFile(filePath, data, options);
if (String(filePath) === `${paths.configFile}.settings.tmp`) {
markSettingsWriteStarted();
await settingsWriteRelease;
}
});
const open = vi.spyOn(fs.promises, "open").mockImplementation(async (filePath, flags, mode) => {
const handle = await openFile(filePath, flags, mode);
if (String(filePath) === `${paths.sessionFile}.async.tmp`) {
const originalWrite = handle.writeFile.bind(handle);
handle.writeFile = async (...args: Parameters<typeof handle.writeFile>) => {
const result = await originalWrite(...args);
markSessionWriteStarted();
await sessionWriteRelease;
return result;
};
}
return handle;
});
try {
const oldSettingsSave = saveSettingsAsync(paths, oldSettings);
const oldSessionSave = saveSessionAsync(paths, oldSession);
await Promise.all([settingsWriteStarted, sessionWriteStarted]);
let barrierAcquired = false;
const barrierPromise = acquirePersistenceBarrier().then((barrier) => {
barrierAcquired = true;
return barrier;
});
await Promise.resolve();
expect(barrierAcquired).toBe(false);
releaseSettingsWrite();
releaseSessionWrite();
const barrier = await barrierPromise;
await Promise.all([oldSettingsSave, oldSessionSave]);
const blockedSettingsSave = saveSettingsAsync(paths, oldSettings);
const blockedSessionSave = saveSessionAsync(paths, oldSession);
saveSettings(paths, importedSettings);
saveSession(paths, importedSession);
await barrier.release({ replayBlocked: false });
await Promise.all([blockedSettingsSave, blockedSessionSave]);
expect(loadSettings(paths).packageName).toBe("imported-settings");
expect(loadSession(paths).summaryText).toBe("imported-session");
} finally {
releaseSettingsWrite();
releaseSessionWrite();
write.mockRestore();
open.mockRestore();
}
});
it("replays the latest saves blocked by a failed import and releases the barrier", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-barrier-replay-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
const barrier = await acquirePersistenceBarrier();
const settingsSave = saveSettingsAsync(paths, { ...defaultSettings(), packageName: "replayed-settings" });
const sessionSave = saveSessionAsync(paths, { ...emptySession(), summaryText: "replayed-session" });
await barrier.release({ replayBlocked: true });
await Promise.all([settingsSave, sessionSave]);
expect(loadSettings(paths).packageName).toBe("replayed-settings");
expect(loadSession(paths).summaryText).toBe("replayed-session");
const nextBarrier = await acquirePersistenceBarrier();
await nextBarrier.release({ replayBlocked: false });
});
it("creates session backup before sync and async session overwrites", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
@@ -1994,7 +2321,7 @@ describe("settings storage", () => {
expect(primaryAfterAsync.summaryText).toBe("third");
});
it("applies defaults for missing fields when loading old config", () => {
it("applies defaults for missing fields when loading old config", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
@@ -2024,8 +2351,34 @@ describe("settings storage", () => {
expect(loaded.retryLimit).toBe(defaults.retryLimit);
expect(loaded.collectMkvToLibrary).toBe(defaults.collectMkvToLibrary);
expect(loaded.mkvLibraryDir).toBe(defaults.mkvLibraryDir);
expect(loaded.theme).toBe(defaults.theme);
expect(loaded.bandwidthSchedules).toEqual(defaults.bandwidthSchedules);
expect(loaded.updateRepo).toBe(defaults.updateRepo);
});
});
expect(loaded.theme).toBe(defaults.theme);
expect(loaded.themePreference).toBe(defaults.themePreference);
expect(loaded.bandwidthSchedules).toEqual(defaults.bandwidthSchedules);
expect(loaded.updateRepo).toBe(defaults.updateRepo);
});
it("persists the semantic theme preference and migrates legacy fixed themes", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
const legacy = { ...defaultSettings(), theme: "light" as const } as Partial<AppSettings>;
delete legacy.themePreference;
fs.mkdirSync(path.dirname(paths.configFile), { recursive: true });
fs.writeFileSync(paths.configFile, JSON.stringify(legacy), "utf8");
expect(loadSettings(paths).themePreference).toBe("light");
saveSettings(paths, { ...defaultSettings(), theme: "dark", themePreference: "system" });
expect(loadSettings(paths)).toEqual(expect.objectContaining({
theme: "dark",
themePreference: "system"
}));
expect(normalizeSettings({
...defaultSettings(),
theme: "light",
themePreference: "invalid" as AppSettings["themePreference"]
}).themePreference).toBe("light");
});
});
+1
View File
@@ -122,6 +122,7 @@ function createSettings(): AppSettings {
clipboardWatch: true,
minimizeToTray: false,
theme: "dark",
themePreference: "dark",
logStorageLocation: "appdata",
collapseNewPackages: false,
animatePackageDisclosure: true,
+2 -1
View File
@@ -64,8 +64,9 @@ export function createVisualElectronApi(
enrichCollectorPackages: async (request) => ({ packages: clone(request.packages), invalidCount: 0, duplicateCount: 0 }),
onCollectorEnrichmentProgress: () => () => {},
getCollectorState: async () => ({ packages: [], collapsedPackageIds: [] }),
getCollectorStateSync: () => ({ packages: [], collapsedPackageIds: [] }),
saveCollectorState: async (state) => clone(state),
saveCollectorStateSync: () => {},
saveCollectorStateSync: () => true,
getPathForDroppedFile: () => "",
getStartConflicts: async () => [],
resolveStartConflict: async (_packageId, policy) => ({