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