From bdcf9e3754f495a97e441b734f68012d1264f232 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Wed, 26 Aug 2026 17:24:07 +0200 Subject: [PATCH] Persist collector state and stabilize download controls Persist collector packages and disclosure state through validated atomic AppData storage with backup recovery and shutdown synchronization. Align download sidebar metrics with the hidden-extracted presentation scope and keep Start available through temporary Real-Debrid cooldowns while preserving hard account blocks. --- src/main/app-controller.ts | 20 +- src/main/collector-store.ts | 232 ++++++++++++++++++ src/main/debrid.ts | 5 + src/main/download-manager.ts | 11 +- src/main/main.ts | 14 ++ src/main/storage.ts | 4 +- src/preload/preload.ts | 4 + src/renderer/App.tsx | 94 +++++-- .../views/collector/collector-persistence.ts | 91 +++++++ .../views/downloads/downloads-model.ts | 60 +++-- src/shared/collector.ts | 30 +++ src/shared/ipc.ts | 3 + src/shared/preload-api.ts | 4 + tests/account-preload.test.ts | 19 +- tests/collector-persistence.test.ts | 82 +++++++ tests/collector-store.test.ts | 187 ++++++++++++++ tests/download-manager.test.ts | 44 ++++ tests/downloads-view.test.tsx | 47 ++++ tests/main-shutdown-lifecycle.test.ts | 27 ++ tests/visual/mock-electron-api.ts | 3 + 20 files changed, 933 insertions(+), 48 deletions(-) create mode 100644 src/main/collector-store.ts create mode 100644 src/renderer/views/collector/collector-persistence.ts create mode 100644 tests/collector-persistence.test.ts create mode 100644 tests/collector-store.test.ts diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index 0b5da0f..96052e7 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -76,7 +76,9 @@ import { getRenameLogPath, initRenameLog, shutdownRenameLog } from "./rename-log import { getDesktopRenameLogPath, initDesktopRenameLogAt, shutdownDesktopRenameLog } from "./desktop-rename-log"; import { buildAccountSummary, buildNotificationSupportPayload, diffAccountSummary, type NotificationSupportPayload } from "./support-data"; import { buildSupportBundle, getSupportBundleDefaultFileName } from "./support-bundle"; -import { getTraceConfig, getTraceLogPath, initTraceLog, logTraceEvent, setTraceEnabled, shutdownTraceLog } from "./trace-log"; +import { getTraceConfig, getTraceLogPath, initTraceLog, logTraceEvent, setTraceEnabled, shutdownTraceLog } from "./trace-log"; +import { CollectorStore } from "./collector-store"; +import type { CollectorPersistenceState } from "../shared/collector"; import type { DebugSetupCheckResult, SupportTraceConfig } from "../shared/types"; import { createOnlineBackup, downloadOnlineBackup, uploadOnlineBackup } from "./online-backup"; import { overlayLiveUsageCounters } from "./settings-live-overlay"; @@ -126,6 +128,8 @@ export class AppController { private storagePaths = createStoragePaths(path.join(app.getPath("userData"), "runtime")); + private collectorStore = new CollectorStore(this.storagePaths.collectorFile); + private notificationOutbox: NotificationOutbox; private downloadHealthMonitor: DownloadHealthMonitor; @@ -997,6 +1001,15 @@ export class AppController { return prepareCollectorText(request); } + public getCollectorState(): CollectorPersistenceState { + return this.collectorStore.getState(); + } + + public saveCollectorState(state: CollectorPersistenceState): CollectorPersistenceState { + this.collectorStore.update(state); + return this.collectorStore.getState(); + } + public prepareCollectorContainers(filePaths: string[], addedAt: number): Promise { return prepareCollectorContainers(filePaths, addedAt); } @@ -1388,6 +1401,11 @@ export class AppController { } stopDebugServer(); abortActiveUpdateDownload(); + try { + this.collectorStore?.flushSync(); + } catch (error) { + logger.warn(`Linksammler konnte beim Beenden nicht gespeichert werden: ${String(error)}`); + } cancelPendingAsyncSaves(); this.manager.prepareForShutdown(); if (this.downloadHealthEvaluation) { diff --git a/src/main/collector-store.ts b/src/main/collector-store.ts new file mode 100644 index 0000000..9bd6047 --- /dev/null +++ b/src/main/collector-store.ts @@ -0,0 +1,232 @@ +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; +import { + validateCollectorPersistenceState, + type CollectorPersistenceState +} from "../shared/collector"; +import { logger } from "./logger"; + +const maxPersistenceBytes = 64 * 1024 * 1024; +const writeDelayMs = 300; +const renameRetryDelaysMs = [15, 40, 90]; + +interface CollectorPersistenceFile extends CollectorPersistenceState { + version: 1; + updatedAt: number; +} + +function emptyState(): CollectorPersistenceState { + return { packages: [], collapsedPackageIds: [] }; +} + +function cloneState(state: CollectorPersistenceState): CollectorPersistenceState { + return structuredClone(state); +} + +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 renameSyncWithRetry(tempPath: string, filePath: string): void { + for (let attempt = 0; ; attempt += 1) { + try { + fs.renameSync(tempPath, filePath); + return; + } catch (error) { + if (!isTransientRenameError(error) || attempt >= renameRetryDelaysMs.length) throw error; + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, renameRetryDelaysMs[attempt]); + } + } +} + +async function renameWithRetry(tempPath: string, filePath: string): Promise { + for (let attempt = 0; ; attempt += 1) { + try { + await fsp.rename(tempPath, filePath); + return; + } catch (error) { + if (!isTransientRenameError(error) || attempt >= renameRetryDelaysMs.length) throw error; + await new Promise((resolve) => setTimeout(resolve, renameRetryDelaysMs[attempt])); + } + } +} + +function parsePersistenceFile(filePath: string): CollectorPersistenceFile | null { + try { + const stat = fs.statSync(filePath); + if (!stat.isFile() || stat.size > maxPersistenceBytes) return null; + const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as Record; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null; + if (Object.keys(parsed).some((key) => !["version", "packages", "collapsedPackageIds", "updatedAt"].includes(key))) return null; + if (parsed.version !== 1 || typeof parsed.updatedAt !== "number" || !Number.isSafeInteger(parsed.updatedAt) || parsed.updatedAt < 0) return null; + const state = validateCollectorPersistenceState({ + packages: parsed.packages, + collapsedPackageIds: parsed.collapsedPackageIds + }); + return { version: 1, updatedAt: parsed.updatedAt, ...state }; + } catch { + return null; + } +} + +function serializeState(state: CollectorPersistenceState, updatedAt: number): string { + const payload: CollectorPersistenceFile = { + version: 1, + packages: state.packages, + collapsedPackageIds: state.collapsedPackageIds, + updatedAt + }; + return JSON.stringify(payload); +} + +function atomicWriteSync(filePath: string, payload: string): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + const tempPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`; + let descriptor: number | null = null; + try { + descriptor = fs.openSync(tempPath, "w"); + fs.writeFileSync(descriptor, payload, "utf8"); + fs.fsyncSync(descriptor); + fs.closeSync(descriptor); + descriptor = null; + renameSyncWithRetry(tempPath, filePath); + } catch (error) { + if (descriptor !== null) { + try { fs.closeSync(descriptor); } catch {} + } + try { fs.rmSync(tempPath, { force: true }); } catch {} + throw error; + } +} + +async function atomicWrite(filePath: string, payload: string, isCurrent: () => boolean): Promise { + await fsp.mkdir(path.dirname(filePath), { recursive: true }); + const tempPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`; + let handle: Awaited> | null = null; + try { + handle = await fsp.open(tempPath, "w"); + await handle.writeFile(payload, "utf8"); + await handle.sync(); + await handle.close(); + handle = null; + if (!isCurrent()) { + await fsp.rm(tempPath, { force: true }); + return false; + } + await renameWithRetry(tempPath, filePath); + return isCurrent(); + } catch (error) { + if (handle) await handle.close().catch(() => {}); + await fsp.rm(tempPath, { force: true }).catch(() => {}); + throw error; + } +} + +export class CollectorStore { + private state: CollectorPersistenceState; + private updatedAt = 0; + private revision = 0; + private timer: ReturnType | null = null; + private activeWrite: Promise | null = null; + private retryAttempt = 0; + + public constructor( + private readonly filePath: string, + private readonly writeFile: typeof atomicWrite = atomicWrite + ) { + const primary = parsePersistenceFile(filePath); + const backup = parsePersistenceFile(`${filePath}.bak`); + const loaded = primary && (!backup || primary.updatedAt >= backup.updatedAt) ? primary : backup; + this.state = loaded + ? cloneState({ packages: loaded.packages, collapsedPackageIds: loaded.collapsedPackageIds }) + : emptyState(); + this.updatedAt = loaded?.updatedAt || 0; + if (loaded === backup && backup && (!primary || backup.updatedAt > primary.updatedAt)) { + try { + this.writeSync(); + } catch {} + } + } + + public getState(): CollectorPersistenceState { + return cloneState(this.state); + } + + 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) { + throw new Error("Linksammler-Speicherzustand ist zu groß"); + } + this.state = nextState; + this.revision += 1; + this.updatedAt = nextUpdatedAt; + this.scheduleWrite(this.retryAttempt > 0 ? this.retryDelayMs() : writeDelayMs); + } + + public flushSync(): void { + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + this.writeSync(); + } + + private retryDelayMs(): number { + return Math.min(60_000, 1_000 * 2 ** Math.max(0, this.retryAttempt - 1)); + } + + private scheduleWrite(delayMs = writeDelayMs): void { + if (this.timer) clearTimeout(this.timer); + this.timer = setTimeout(() => { + this.timer = null; + this.enqueueWrite(); + }, delayMs); + this.timer.unref?.(); + } + + private enqueueWrite(): void { + const revision = this.revision; + const state = cloneState(this.state); + const updatedAt = this.updatedAt; + const preceding = this.activeWrite || Promise.resolve(); + const write = preceding.catch(() => {}).then(async () => { + const payload = serializeState(state, updatedAt); + const isCurrent = () => revision === this.revision; + const backupCurrent = await this.writeFile(`${this.filePath}.bak`, payload, isCurrent); + if (!backupCurrent) return; + const primaryCurrent = await this.writeFile(this.filePath, payload, isCurrent); + if (!primaryCurrent) this.writeSync(); + else this.retryAttempt = 0; + }).catch((error) => { + if (revision === this.revision) { + this.retryAttempt += 1; + const retryMs = this.retryDelayMs(); + logger.warn(`Linksammler-Speicherung fehlgeschlagen, neuer Versuch in ${retryMs} ms: ${String(error)}`); + this.scheduleWrite(retryMs); + } + }).finally(() => { + if (this.activeWrite === write) this.activeWrite = null; + if (revision !== this.revision && !this.timer) { + try { + this.writeSync(); + } catch {} + } + }); + this.activeWrite = write; + } + + private writeSync(): void { + const payload = serializeState(this.state, this.updatedAt || Date.now()); + atomicWriteSync(`${this.filePath}.bak`, payload); + atomicWriteSync(this.filePath, payload); + } +} diff --git a/src/main/debrid.ts b/src/main/debrid.ts index b032c2d..a77c0ec 100644 --- a/src/main/debrid.ts +++ b/src/main/debrid.ts @@ -773,6 +773,11 @@ function getRealDebridAccountCooldown(accountId: string, now = Date.now()): Real return detail; } +export function isRealDebridAccountBlockedForStart(accountId: string, now = Date.now()): boolean { + const cooldown = getRealDebridAccountCooldown(accountId, now); + return Boolean(cooldown && cooldown.category !== "temporary"); +} + export function getAvailableRealDebridAccounts(settings: AppSettings, now = Date.now()): RealDebridAccountEntry[] { return getConfiguredRealDebridAccounts(settings).filter((account) => account.enabled && !isRealDebridAccountDailyLimitReached(settings, account.id, now) diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 822ddcc..61768a0 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -39,8 +39,9 @@ import { addProviderTotalUsageBytes, addRealDebridAccountDailyUsageBytes, addRealDebridAccountTotalUsageBytes, - getProviderUsageDayKey, - isProviderDailyLimitReached + getProviderUsageDayKey, + isProviderDailyLimitReached, + isRealDebridAccountDailyLimitReached } from "../shared/provider-daily-limits"; import { REQUEST_RETRIES, SAMPLE_VIDEO_EXTENSIONS, SPEED_WINDOW_SECONDS, WRITE_BUFFER_SIZE, WRITE_FLUSH_TIMEOUT_MS, ALLOCATION_UNIT_SIZE, STREAM_HIGH_WATER_MARK, DISK_BUSY_THRESHOLD_MS, DISK_BUSY_STATUS_THRESHOLD_MS } from "./constants"; import { parseCollectorInput } from "./link-parser"; @@ -61,7 +62,7 @@ function releaseTlsSkip(): void { } import { cleanupCancelledPackageArtifactsAsync, removeDownloadLinkArtifacts, removeSampleArtifacts } from "./cleanup"; import { planDownloadCompletion, reconcileFinalizedSize, validateDownloadedFileCompletion } from "./download-completion"; -import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, DebridService, MegaWebUnrestrictor, RealDebridWebUnrestrictor, checkDdownloadOnline, checkOneFichierLinks, checkRapidgatorOnline, fetchAllDebridHostInfo, filenameFromDdownloadUrlPath, getAvailableDebridLinkApiKeys, getAvailableMegaDebridAccounts, getAvailableRealDebridAccounts, getMegaDebridAccountCooldownState, getMegaDebridInFlightCountForMode, getRealDebridAccountAttemptTimeoutMs, isDdownloadLink, isOneFichierLink, pruneExpiredDebridLinkRuntimeState, pruneExpiredMegaDebridRuntimeState, pruneExpiredRealDebridRuntimeState, releaseRealDebridAccountCooldown, type DdownloadCheckResult, type OneFichierCheckResult } from "./debrid"; +import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, DebridService, MegaWebUnrestrictor, RealDebridWebUnrestrictor, checkDdownloadOnline, checkOneFichierLinks, checkRapidgatorOnline, fetchAllDebridHostInfo, filenameFromDdownloadUrlPath, getAvailableDebridLinkApiKeys, getAvailableMegaDebridAccounts, getAvailableRealDebridAccounts, getMegaDebridAccountCooldownState, getMegaDebridInFlightCountForMode, getRealDebridAccountAttemptTimeoutMs, isDdownloadLink, isOneFichierLink, isRealDebridAccountBlockedForStart, pruneExpiredDebridLinkRuntimeState, pruneExpiredMegaDebridRuntimeState, pruneExpiredRealDebridRuntimeState, releaseRealDebridAccountCooldown, type DdownloadCheckResult, type OneFichierCheckResult } from "./debrid"; import { cleanupArchives, clearExtractResumeState, collectArchiveCleanupTargets, detectArchiveSignature, extractPackageArchives, findArchiveCandidates, hasAnyFilesRecursive, removeEmptyDirectoryTree, resetExtractorCachesForPasswordChange, type ExtractArchiveFailureInfo, type ExtractProgressUpdate } from "./extractor"; import { validateFileAgainstManifest } from "./integrity"; import { classifyDiskError } from "./fs-error"; @@ -8870,7 +8871,9 @@ export class DownloadManager extends EventEmitter { if (effectiveProvider === "realdebrid") { const configuredAccounts = getRealDebridAccounts(this.settings); return configuredAccounts.length > 0 - ? getAvailableRealDebridAccounts(this.settings).length > 0 + ? configuredAccounts.some((account) => account.enabled + && !isRealDebridAccountDailyLimitReached(this.settings, account.id) + && !isRealDebridAccountBlockedForStart(account.id)) : Boolean(this.settings.realDebridUseWebLogin || this.settings.token.trim()); } if (effectiveProvider === "megadebrid-api") { diff --git a/src/main/main.ts b/src/main/main.ts index 432a594..2d95ab5 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -29,6 +29,7 @@ import { DailyStartScheduler, hasDailyStartRulePatch, prepareDailyStartSettingsP import { validateCollectorContainerPreparationRequest, validateCollectorEnrichmentRequest, + validateCollectorPersistenceState, validateCollectorTextPreparationRequest } from "../shared/collector"; @@ -577,6 +578,19 @@ function registerIpcHandlers(): void { } }); }); + handleTrusted(IPC_CHANNELS.GET_COLLECTOR_STATE, () => controller.getCollectorState()); + 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; + } + }); handleTrusted(IPC_CHANNELS.GET_START_CONFLICTS, () => controller.getStartConflicts()); handleTrusted(IPC_CHANNELS.RESOLVE_START_CONFLICT, (_event: IpcMainInvokeEvent, packageId: string, policy: "keep" | "skip" | "overwrite") => { validateString(packageId, "packageId"); diff --git a/src/main/storage.ts b/src/main/storage.ts index 41069e7..4a39591 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -719,6 +719,7 @@ export interface StoragePaths { statisticsFile: string; notificationOutboxFile: string; notificationHealthFile: string; + collectorFile: string; } export function createStoragePaths(baseDir: string): StoragePaths { @@ -729,7 +730,8 @@ export function createStoragePaths(baseDir: string): StoragePaths { historyFile: path.join(baseDir, "rd_history.json"), statisticsFile: path.join(baseDir, "rd_statistics.json"), notificationOutboxFile: path.join(baseDir, "rd_notification_outbox.json"), - notificationHealthFile: path.join(baseDir, "rd_notification_health.json") + notificationHealthFile: path.join(baseDir, "rd_notification_health.json"), + collectorFile: path.join(baseDir, "rd_collector_state.json") }; } diff --git a/src/preload/preload.ts b/src/preload/preload.ts index b7c18b7..d3ada9b 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -35,6 +35,7 @@ import type { CollectorEnrichmentRequest, CollectorEnrichmentProgress, CollectorInspectionResult, + CollectorPersistenceState, CollectorTextPreparationRequest } from "../shared/collector"; import { IPC_CHANNELS } from "../shared/ipc"; @@ -68,6 +69,9 @@ const api: ElectronApi = { ipcRenderer.on(IPC_CHANNELS.COLLECTOR_ENRICHMENT_PROGRESS, listener); return () => ipcRenderer.removeListener(IPC_CHANNELS.COLLECTOR_ENRICHMENT_PROGRESS, listener); }, + getCollectorState: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.GET_COLLECTOR_STATE), + saveCollectorState: (state: CollectorPersistenceState): Promise => ipcRenderer.invoke(IPC_CHANNELS.SAVE_COLLECTOR_STATE, state), + saveCollectorStateSync: (state: CollectorPersistenceState): void => { ipcRenderer.sendSync(IPC_CHANNELS.SAVE_COLLECTOR_STATE_SYNC, state); }, getPathForDroppedFile: (file: File): string => webUtils.getPathForFile(file), getStartConflicts: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.GET_START_CONFLICTS), resolveStartConflict: (packageId: string, policy: DuplicatePolicy): Promise => diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index ab073ff..45bbd8a 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -74,6 +74,7 @@ import { selectCollectorPackageLinks, type CollectorWorkspaceFilter } from "./views/collector/collector-model"; +import { createCollectorPersistenceCoordinator, restoreCollectorPersistenceState, type CollectorPersistenceCoordinator } from "./views/collector/collector-persistence"; import { CollectorInputDialog, MemoizedCollectorContent, @@ -106,7 +107,7 @@ import { StatisticsSidebarStatus, type StatisticsViewActions } from "./views/statistics/StatisticsView"; -import { buildDownloadsViewModel, formatRemainingDownloadBytes, formatRemainingDownloadTooltip, getDownloadQueueTotalBytes, getDownloadSpeedBps, getPendingDownloadItemCount, getRemainingDownloadBytes, type DownloadDisplayMode, type DownloadSidebarFilter } from "./views/downloads/downloads-model"; +import { buildDownloadsViewModel, formatRemainingDownloadBytes, formatRemainingDownloadTooltip, getDownloadQueueStatusMetrics, getDownloadSpeedBps, type DownloadDisplayMode, type DownloadSidebarFilter } from "./views/downloads/downloads-model"; import { downloadColumnDefinitions, type DownloadSortColumn } from "./views/downloads/DownloadsTable"; import { DeleteConfirmationDialog } from "./views/downloads/DeleteConfirmationDialog"; import { beginDownloadColumnDrag, clearDownloadColumnDrag, commitDownloadColumnDrag, createDownloadColumnOrderPersistence, DOWNLOAD_COLUMN_MOVE_DURATION_MS, updateDownloadColumnDrag, type DownloadColumnDragSession, type DownloadColumnOrderPersistence } from "./views/downloads/column-drag"; @@ -1721,6 +1722,9 @@ export function App(): ReactElement { const [collectorError, setCollectorError] = useState(""); const [collectorInput, setCollectorInput] = useState(null); const collectorPackagesRef = useRef(collectorPackages); + const collapsedCollectorPackageIdsRef = useRef>(collapsedCollectorPackageIds); + const collectorPersistenceHydratedRef = useRef(false); + const collectorPersistenceCoordinatorRef = useRef(null); const collectorEnrichmentGenerationsRef = useRef(new Map()); const collectorEnrichmentRequestsRef = useRef(new Map>()); const importCollectorTextRef = useRef<(rawText: string) => Promise>(() => Promise.resolve()); @@ -1872,6 +1876,7 @@ export function App(): ReactElement { ); collectorPackagesRef.current = collectorPackages; + collapsedCollectorPackageIdsRef.current = collapsedCollectorPackageIds; useEffect(() => { activeTabRef.current = tab; @@ -3652,6 +3657,65 @@ export function App(): ReactElement { }, true); }), []); + useEffect(() => { + let cancelled = false; + const coordinator = createCollectorPersistenceCoordinator((state) => window.rd.saveCollectorState(state)); + collectorPersistenceCoordinatorRef.current = coordinator; + const saveBeforeUnload = (): void => { + if (!collectorPersistenceHydratedRef.current) return; + window.rd.saveCollectorStateSync({ + packages: collectorPackagesRef.current, + collapsedPackageIds: [...collapsedCollectorPackageIdsRef.current] + }); + }; + window.addEventListener("beforeunload", saveBeforeUnload); + void window.rd.getCollectorState().then((persisted) => { + if (cancelled) return; + const restored = restoreCollectorPersistenceState( + persisted, + collectorPackagesRef.current, + collapsedCollectorPackageIdsRef.current + ); + collectorPackagesRef.current = restored.packages; + collapsedCollectorPackageIdsRef.current = new Set(restored.collapsedPackageIds); + setCollectorPackages(restored.packages); + setCollapsedCollectorPackageIds(new Set(restored.collapsedPackageIds)); + collectorPersistenceHydratedRef.current = true; + coordinator.schedule(restored); + if (restored.packages.length > 0) enrichCollectorResult(restored.packages); + }).catch((error) => { + if (cancelled) return; + collectorPersistenceHydratedRef.current = true; + setCollectorError(`Linksammler konnte nicht wiederhergestellt werden: ${String(error)}`); + coordinator.schedule({ + packages: collectorPackagesRef.current, + collapsedPackageIds: [...collapsedCollectorPackageIdsRef.current] + }); + }); + return () => { + cancelled = true; + window.removeEventListener("beforeunload", saveBeforeUnload); + if (!collectorPersistenceHydratedRef.current) { + coordinator.dispose(); + return; + } + coordinator.schedule({ + packages: collectorPackagesRef.current, + collapsedPackageIds: [...collapsedCollectorPackageIdsRef.current] + }); + void coordinator.flush().finally(() => coordinator.dispose()); + }; + }, []); + + useEffect(() => { + collapsedCollectorPackageIdsRef.current = collapsedCollectorPackageIds; + if (!collectorPersistenceHydratedRef.current) return; + collectorPersistenceCoordinatorRef.current?.schedule({ + packages: collectorPackages, + collapsedPackageIds: [...collapsedCollectorPackageIds] + }); + }, [collapsedCollectorPackageIds, collectorPackages]); + const importCollectorText = async (rawText: string): Promise => { if (!rawText.trim()) { showToast("Keine Links eingegeben", 2200); @@ -4877,21 +4941,6 @@ export function App(): ReactElement { return map; }, [snapshot.packageSpeedBps]); - const providerStats = useMemo(() => { - const stats: Record = {}; - for (const item of Object.values(snapshot.session.items)) { - const hoster = extractHoster(item.url) || "unknown"; - if (!stats[hoster]) { - stats[hoster] = { total: 0, completed: 0, failed: 0, bytes: 0 }; - } - stats[hoster].total += 1; - if (item.status === "completed") stats[hoster].completed += 1; - if (item.status === "failed") stats[hoster].failed += 1; - stats[hoster].bytes += item.downloadedBytes; - } - return Object.entries(stats); - }, [snapshot.session.items]); - const sortDownloadsByColumn = useCallback((column: DownloadSortColumn): void => { const nextDescending = downloadsSortColumn === column ? !downloadsSortDescending : false; setDownloadsSortColumn(column); @@ -5018,8 +5067,9 @@ export function App(): ReactElement { if (!snapshot.session.running || snapshot.session.paused) return; speedHistoryRef.current = appendBandwidthSample(speedHistoryRef.current, liveDownloadSpeedBps); }, [liveDownloadSpeedBps, snapshot.packageSpeedBps, snapshot.session.paused, snapshot.session.running]); - const downloadQueueTotalBytes = useMemo(() => getDownloadQueueTotalBytes(Object.values(snapshot.session.items)), [snapshot.session.items]); - const downloadRemaining = useMemo(() => getRemainingDownloadBytes(Object.values(snapshot.session.items)), [snapshot.session.items]); + const downloadQueueMetrics = useMemo(() => getDownloadQueueStatusMetrics(downloadsViewCore.eligibleItems), [downloadsViewCore.eligibleItems]); + const downloadQueueTotalBytes = downloadQueueMetrics.totalBytes; + const downloadRemaining = downloadQueueMetrics.remaining; const downloadsViewModel = useMemo(() => ({ ...downloadsViewCore, running: snapshot.session.running, @@ -5051,8 +5101,8 @@ export function App(): ReactElement { disclosureRevision: downloadDisclosureRevision, animationsEnabled: snapshot.settings.animatePackageDisclosure, status: { - packages: snapshot.stats.totalPackages, - links: getPendingDownloadItemCount(Object.values(snapshot.session.items)), + packages: downloadQueueMetrics.packageCount, + links: downloadQueueMetrics.pendingItemCount, session: humanSize(snapshot.stats.totalDownloaded), sessionBytes: snapshot.stats.totalDownloaded, total: humanSize(downloadQueueTotalBytes), @@ -5060,11 +5110,11 @@ export function App(): ReactElement { remaining: formatRemainingDownloadBytes(downloadRemaining), remainingBytes: downloadRemaining.bytes, remainingTooltip: formatRemainingDownloadTooltip(downloadRemaining), - hosters: providerStats.length, + hosters: downloadQueueMetrics.hosterCount, speed: liveDownloadSpeedBps > 0 ? formatSpeedMbps(liveDownloadSpeedBps) : "0 B/s", eta: snapshot.etaText } - }), [actionBusy, columnOrder, downloadDisclosureRevision, downloadPackageSpeeds, downloadQueueTotalBytes, downloadRemaining, downloadsSortColumn, downloadsSortDescending, downloadsViewCore, editingName, editingPackageId, gridTemplate, liveDownloadSpeedBps, providerStats.length, scheduleCountdown, schedulePickerOpen, scheduleStartDay, scheduleTimeInput, snapshot.canPause, snapshot.canStart, snapshot.canStop, snapshot.clipboardActive, snapshot.etaText, snapshot.reconnectSeconds, snapshot.session.items, snapshot.session.paused, snapshot.session.reconnectReason, snapshot.session.running, snapshot.settings.animatePackageDisclosure, snapshot.settings.dailyStartEnabled, snapshot.settings.dailyStartMinuteOfDay, snapshot.settings.scheduledStartEpochMs, snapshot.stats.totalDownloaded, snapshot.stats.totalPackages]); + }), [actionBusy, columnOrder, downloadDisclosureRevision, downloadPackageSpeeds, downloadQueueMetrics.hosterCount, downloadQueueMetrics.packageCount, downloadQueueMetrics.pendingItemCount, downloadQueueTotalBytes, downloadRemaining, downloadsSortColumn, downloadsSortDescending, downloadsViewCore, editingName, editingPackageId, gridTemplate, liveDownloadSpeedBps, scheduleCountdown, schedulePickerOpen, scheduleStartDay, scheduleTimeInput, snapshot.canPause, snapshot.canStart, snapshot.canStop, snapshot.clipboardActive, snapshot.etaText, snapshot.reconnectSeconds, snapshot.session.paused, snapshot.session.reconnectReason, snapshot.session.running, snapshot.settings.animatePackageDisclosure, snapshot.settings.dailyStartEnabled, snapshot.settings.dailyStartMinuteOfDay, snapshot.settings.scheduledStartEpochMs, snapshot.stats.totalDownloaded]); const resetColumnLayout = useCallback((): void => { if (columnDragSettleTimerRef.current !== null) { diff --git a/src/renderer/views/collector/collector-persistence.ts b/src/renderer/views/collector/collector-persistence.ts new file mode 100644 index 0000000..c2ce436 --- /dev/null +++ b/src/renderer/views/collector/collector-persistence.ts @@ -0,0 +1,91 @@ +import { + validateCollectorPersistenceState, + type CollectorPackage, + type CollectorPersistenceState +} from "../../../shared/collector"; +import { + mergeCollectorPackages, + reconcileCollectorCollapsedPackageIds +} from "./collector-model"; + +export function restoreCollectorPersistenceState( + persisted: CollectorPersistenceState, + currentPackages: CollectorPackage[], + currentCollapsedPackageIds: Set +): CollectorPersistenceState { + const merged = mergeCollectorPackages(persisted.packages, currentPackages).packages; + const persistedCollapsed = reconcileCollectorCollapsedPackageIds( + new Set(persisted.collapsedPackageIds), + persisted.packages, + merged, + [], + false + ); + const currentCollapsed = reconcileCollectorCollapsedPackageIds( + currentCollapsedPackageIds, + currentPackages, + merged, + [], + false + ); + return validateCollectorPersistenceState({ + packages: merged, + collapsedPackageIds: [...new Set([...persistedCollapsed, ...currentCollapsed])] + }); +} + +export interface CollectorPersistenceCoordinator { + schedule: (state: CollectorPersistenceState) => void; + flush: () => Promise; + dispose: () => void; +} + +export function createCollectorPersistenceCoordinator( + save: (state: CollectorPersistenceState) => Promise, + delayMs = 300 +): CollectorPersistenceCoordinator { + let timer: ReturnType | null = null; + let pending: CollectorPersistenceState | null = null; + let running: Promise | null = null; + let disposed = false; + + const drain = (): Promise => { + if (running) return running; + const task = (async () => { + while (pending && !disposed) { + const state = pending; + pending = null; + await save(state); + } + })().finally(() => { + if (running === task) running = null; + }); + running = task; + return task; + }; + + return { + schedule: (state) => { + if (disposed) return; + pending = validateCollectorPersistenceState(state); + if (timer) clearTimeout(timer); + timer = setTimeout(() => { + timer = null; + void drain().catch(() => {}); + }, Math.max(0, delayMs)); + }, + flush: async () => { + if (timer) { + clearTimeout(timer); + timer = null; + } + await drain(); + }, + dispose: () => { + disposed = true; + if (timer) clearTimeout(timer); + timer = null; + pending = null; + } + }; +} diff --git a/src/renderer/views/downloads/downloads-model.ts b/src/renderer/views/downloads/downloads-model.ts index 32421fe..0394eb8 100644 --- a/src/renderer/views/downloads/downloads-model.ts +++ b/src/renderer/views/downloads/downloads-model.ts @@ -1,5 +1,5 @@ import type { DownloadItem, DownloadStatus, PackageEntry } from "../../../shared/types"; -import { humanSize } from "../../download-format"; +import { extractHoster, humanSize } from "../../download-format"; import { DOWNLOAD_FILE_ROW_HEIGHT, DOWNLOAD_PACKAGE_ROW_HEIGHT, type DownloadVirtualRowInput } from "./download-virtualizer"; export type DownloadDisplayMode = "packages" | "files"; @@ -40,10 +40,12 @@ export interface DownloadsViewModelCore { displayMode: DownloadDisplayMode; filter: DownloadSidebarFilter; providerFilter: string; - providerOptions: Array<{ id: string; label: string }>; - query: string; - counts: DownloadFilterCounts; - packageRows: DownloadPackageRow[]; + providerOptions: Array<{ id: string; label: string }>; + query: string; + counts: DownloadFilterCounts; + eligibleItems: DownloadItem[]; + eligiblePackageCount: number; + packageRows: DownloadPackageRow[]; fileRows: DownloadItem[]; visibleItemIds: string[]; visibleRowIds: string[]; @@ -116,6 +118,22 @@ export function getRemainingDownloadBytes(items: Iterable): { byte return { bytes, unknownItems }; } +export function getDownloadQueueStatusMetrics(items: readonly DownloadItem[]): { + packageCount: number; + pendingItemCount: number; + totalBytes: number; + remaining: { bytes: number; unknownItems: number }; + hosterCount: number; +} { + return { + packageCount: new Set(items.map((item) => item.packageId)).size, + pendingItemCount: getPendingDownloadItemCount(items), + totalBytes: getDownloadQueueTotalBytes(items), + remaining: getRemainingDownloadBytes(items), + hosterCount: new Set(items.map((item) => extractHoster(item.url)).filter(Boolean)).size + }; +} + export function formatRemainingDownloadBytes(summary: { bytes: number; unknownItems: number }): string { const value = summary.bytes >= 1024 ** 4 ? `${(summary.bytes / 1024 ** 4).toFixed(4)} TB` @@ -170,13 +188,15 @@ export function buildDownloadLogicalRows(model: Pick input.packages[id]) - .filter((entry): entry is PackageEntry => Boolean(entry)); - const allItems = allPackages.flatMap((entry) => entry.itemIds.map((id) => input.items[id]).filter((item): item is DownloadItem => Boolean(item))); - const counts = buildDownloadSidebarCounts(allItems); - const providerMap = new Map(); - for (const entry of allItems) { + const allPackages = input.packageOrder + .map((id) => input.packages[id]) + .filter((entry): entry is PackageEntry => Boolean(entry)); + 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 providerMap = new Map(); + for (const entry of eligibleItems) { if (entry.provider) providerMap.set(entry.provider, entry.providerLabel?.trim() || entry.provider); } @@ -204,7 +224,7 @@ export function buildDownloadsViewModel(input: DownloadsModelInput): DownloadsVi const visibleItems = packageMatchesQuery && query !== "" ? items.filter((item) => matchesFilter(item, input.filter) && matchesProvider(item, input.providerFilter)) : matchingItems; - return [{ package: entry, items: visibleItems, allItems: allPackageItems, collapsed: collapsed.has(entry.id) }]; + return [{ package: entry, items: visibleItems, allItems: items, collapsed: collapsed.has(entry.id) }]; }); const totalPackageRows = packageRows.length; @@ -231,10 +251,12 @@ export function buildDownloadsViewModel(input: DownloadsModelInput): DownloadsVi displayMode: input.displayMode, filter: input.filter, providerFilter: input.providerFilter, - providerOptions: [...providerMap].map(([id, label]) => ({ id, label })).sort((left, right) => left.label.localeCompare(right.label, "de")), - query: input.query, - counts, - packageRows: displayedPackages, + providerOptions: [...providerMap].map(([id, label]) => ({ id, label })).sort((left, right) => left.label.localeCompare(right.label, "de")), + query: input.query, + counts, + eligibleItems, + eligiblePackageCount, + packageRows: displayedPackages, fileRows, visibleItemIds, visibleRowIds, @@ -245,7 +267,7 @@ export function buildDownloadsViewModel(input: DownloadsModelInput): DownloadsVi totalMainRowCount, paginationLabel: paginationLabel(mainRowCount, totalMainRowCount), limited: false, - empty: allItems.length === 0, - filteredEmpty: allItems.length > 0 && mainRowCount === 0 + empty: eligibleItems.length === 0, + filteredEmpty: eligibleItems.length > 0 && mainRowCount === 0 }; } diff --git a/src/shared/collector.ts b/src/shared/collector.ts index be90930..b600f4d 100644 --- a/src/shared/collector.ts +++ b/src/shared/collector.ts @@ -47,6 +47,11 @@ export interface CollectorInspectionResult { duplicateCount: number; } +export interface CollectorPersistenceState { + packages: CollectorPackage[]; + collapsedPackageIds: string[]; +} + function validAddedAt(value: unknown): value is number { return typeof value === "number" && Number.isSafeInteger(value) && value >= 0; } @@ -147,6 +152,31 @@ export function validateCollectorEnrichmentRequest(value: unknown): CollectorEnr return { requestId: raw.requestId, packages: structuredClone(raw.packages) as CollectorPackage[] }; } +export function validateCollectorPersistenceState(value: unknown): CollectorPersistenceState { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("Linksammler-Speicherzustand ist ungültig"); + } + const raw = value as Record; + const packages = raw.packages; + const collapsedPackageIds = raw.collapsedPackageIds; + if (Object.keys(raw).some((key) => key !== "packages" && key !== "collapsedPackageIds") + || !Array.isArray(packages) + || packages.length > 2_000 + || packages.some((entry) => !validCollectorPackage(entry)) + || packages.reduce((sum, entry) => sum + (entry as CollectorPackage).links.length, 0) > 20_000 + || !Array.isArray(collapsedPackageIds) + || collapsedPackageIds.length > 2_000 + || collapsedPackageIds.some((entry) => typeof entry !== "string" || entry.length === 0 || entry.length > 160)) { + throw new Error("Linksammler-Speicherzustand ist ungültig"); + } + const packageIds = new Set(packages.map((pkg) => (pkg as CollectorPackage).id)); + const collapsed = [...new Set(collapsedPackageIds)].filter((id) => packageIds.has(id)); + return { + packages: structuredClone(packages) as CollectorPackage[], + collapsedPackageIds: collapsed + }; +} + function collectorMarkerValue(value: string): string { return String(value || "").replace(/[\r\n]+/g, " ").trim(); } diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 11c16e0..4b1ec81 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -18,6 +18,9 @@ export const IPC_CHANNELS = { PREPARE_COLLECTOR_CONTAINERS: "collector:prepare-containers", ENRICH_COLLECTOR_PACKAGES: "collector:enrich-packages", COLLECTOR_ENRICHMENT_PROGRESS: "collector:enrichment-progress", + GET_COLLECTOR_STATE: "collector:get-state", + SAVE_COLLECTOR_STATE: "collector:save-state", + SAVE_COLLECTOR_STATE_SYNC: "collector:save-state-sync", GET_START_CONFLICTS: "queue:get-start-conflicts", RESOLVE_START_CONFLICT: "queue:resolve-start-conflict", CLEAR_ALL: "queue:clear-all", diff --git a/src/shared/preload-api.ts b/src/shared/preload-api.ts index 29dd8ce..ee8bee5 100644 --- a/src/shared/preload-api.ts +++ b/src/shared/preload-api.ts @@ -38,6 +38,7 @@ import type { CollectorEnrichmentRequest, CollectorEnrichmentProgress, CollectorInspectionResult, + CollectorPersistenceState, CollectorTextPreparationRequest } from "./collector"; @@ -89,6 +90,9 @@ export interface ElectronApi { prepareCollectorContainers: (filePaths: string[], addedAt: number) => Promise; enrichCollectorPackages: (request: CollectorEnrichmentRequest) => Promise; onCollectorEnrichmentProgress: (callback: (progress: CollectorEnrichmentProgress) => void) => () => void; + getCollectorState: () => Promise; + saveCollectorState: (state: CollectorPersistenceState) => Promise; + saveCollectorStateSync: (state: CollectorPersistenceState) => void; getPathForDroppedFile: (file: File) => string; getStartConflicts: () => Promise; resolveStartConflict: (packageId: string, policy: DuplicatePolicy) => Promise; diff --git a/tests/account-preload.test.ts b/tests/account-preload.test.ts index 48e54d3..4bbdb4e 100644 --- a/tests/account-preload.test.ts +++ b/tests/account-preload.test.ts @@ -7,7 +7,8 @@ const electron = vi.hoisted(() => ({ invoke: vi.fn<(...args: unknown[]) => Promise>(async () => undefined), getPathForFile: vi.fn(() => "C:\\Imports\\dropped.dlc"), on: vi.fn(), - removeListener: vi.fn() + removeListener: vi.fn(), + sendSync: vi.fn((_: unknown, state: unknown) => state) })); vi.mock("electron", () => ({ @@ -20,6 +21,7 @@ vi.mock("electron", () => ({ invoke: electron.invoke, on: electron.on, removeListener: electron.removeListener, + sendSync: electron.sendSync, send: vi.fn() }, webUtils: { getPathForFile: electron.getPathForFile } @@ -32,6 +34,7 @@ describe("account preload contract", () => { beforeEach(() => { electron.invoke.mockClear(); + electron.sendSync.mockClear(); }); it("forwards submitted secrets only in write-only account commands", async () => { @@ -129,6 +132,20 @@ describe("account preload contract", () => { ]); }); + it("loads and saves the persistent collector state through dedicated channels", async () => { + const state = { packages: [], collapsedPackageIds: [] }; + + await electron.api?.getCollectorState(); + await electron.api?.saveCollectorState(state); + electron.api?.saveCollectorStateSync(state); + + expect(electron.invoke.mock.calls).toEqual([ + [IPC_CHANNELS.GET_COLLECTOR_STATE], + [IPC_CHANNELS.SAVE_COLLECTOR_STATE, state] + ]); + expect(electron.sendSync).toHaveBeenCalledWith(IPC_CHANNELS.SAVE_COLLECTOR_STATE_SYNC, state); + }); + it("resolves dropped files through Electron webUtils without IPC", () => { const file = { name: "dropped.dlc" } as File; diff --git a/tests/collector-persistence.test.ts b/tests/collector-persistence.test.ts new file mode 100644 index 0000000..e54222d --- /dev/null +++ b/tests/collector-persistence.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import { validateCollectorPersistenceState } from "../src/shared/collector"; +import { + createCollectorPersistenceCoordinator, + restoreCollectorPersistenceState +} from "../src/renderer/views/collector/collector-persistence"; + +const packageEntry = { + id: "package-one", + name: "Staffel Eins", + nameSource: "explicit" as const, + links: [{ + id: "link-one", + url: "https://1fichier.com/?example", + fileName: "episode.part01.rar", + fileSizeBytes: 471_859_200, + hoster: "1fichier", + availability: "online" as const, + status: "ready" as const, + addedAt: 1_000 + }], + addedAt: 1_000 +}; + +describe("collector persistence payload", () => { + it("accepts a complete state and prunes unknown collapsed package ids", () => { + const input = { packages: [packageEntry], collapsedPackageIds: ["package-one", "missing", "package-one"] }; + + expect(validateCollectorPersistenceState(input)).toEqual({ + packages: [packageEntry], + collapsedPackageIds: ["package-one"] + }); + expect(validateCollectorPersistenceState(input)).not.toBe(input); + }); + + it("accepts an empty collector without loosening the payload shape", () => { + expect(validateCollectorPersistenceState({ packages: [], collapsedPackageIds: [] })).toEqual({ packages: [], collapsedPackageIds: [] }); + expect(() => validateCollectorPersistenceState({ packages: [], collapsedPackageIds: [], selectedLinkIds: [] })).toThrow("Linksammler-Speicherzustand ist ungültig"); + }); + + it("rejects invalid packages and excessive queue sizes", () => { + expect(() => validateCollectorPersistenceState({ packages: [{ ...packageEntry, name: "" }], collapsedPackageIds: [] })).toThrow("Linksammler-Speicherzustand ist ungültig"); + expect(() => validateCollectorPersistenceState({ packages: Array.from({ length: 2_001 }, () => packageEntry), collapsedPackageIds: [] })).toThrow("Linksammler-Speicherzustand ist ungültig"); + }); +}); + +describe("collector persistence renderer flow", () => { + it("merges a late restore with current imports and keeps collapse state from both sides", () => { + const persisted = { packages: [packageEntry], collapsedPackageIds: ["package-one"] }; + const currentPackage = { + ...packageEntry, + id: "package-current", + name: "Aktueller Import", + links: [{ ...packageEntry.links[0], id: "link-current", url: "https://example.com/current" }] + }; + + expect(restoreCollectorPersistenceState(persisted, [currentPackage], new Set(["package-current"]))).toEqual({ + packages: [packageEntry, currentPackage], + collapsedPackageIds: ["package-one", "package-current"] + }); + }); + + it("serializes saves and lets the newest queued state win", async () => { + const saved: string[] = []; + let releaseFirst: () => void = () => {}; + const coordinator = createCollectorPersistenceCoordinator(async (state) => { + saved.push(state.packages[0]?.id || "empty"); + if (saved.length === 1) await new Promise((resolve) => { releaseFirst = resolve; }); + return state; + }, 0); + + coordinator.schedule({ packages: [{ ...packageEntry, id: "first" }], collapsedPackageIds: [] }); + const firstFlush = coordinator.flush(); + await Promise.resolve(); + coordinator.schedule({ packages: [{ ...packageEntry, id: "latest" }], collapsedPackageIds: [] }); + releaseFirst(); + await firstFlush; + await coordinator.flush(); + + expect(saved).toEqual(["first", "latest"]); + }); +}); diff --git a/tests/collector-store.test.ts b/tests/collector-store.test.ts new file mode 100644 index 0000000..ca4b473 --- /dev/null +++ b/tests/collector-store.test.ts @@ -0,0 +1,187 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { CollectorStore } from "../src/main/collector-store"; +import type { CollectorPersistenceState } from "../src/shared/collector"; + +const roots: string[] = []; + +function createFilePath(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "mdd-collector-store-")); + roots.push(root); + return path.join(root, "collector.json"); +} + +function state(id: string, collapsed = true): CollectorPersistenceState { + return { + packages: [{ + id: `package-${id}`, + name: `Package ${id}`, + nameSource: "explicit", + addedAt: 1_700_000_000_000, + links: [{ + id: `link-${id}`, + url: `https://example.com/${id}`, + fileName: `${id}.rar`, + fileSizeBytes: 1024, + hoster: "example.com", + availability: "online", + status: "ready", + addedAt: 1_700_000_000_000 + }] + }], + collapsedPackageIds: collapsed ? [`package-${id}`] : [] + }; +} + +afterEach(() => { + vi.useRealTimers(); + for (const root of roots.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +describe("CollectorStore", () => { + it("starts empty when no persistence file exists", () => { + const store = new CollectorStore(createFilePath()); + + expect(store.getState()).toEqual({ packages: [], collapsedPackageIds: [] }); + }); + + it("persists a versioned state and loads an independent clone", () => { + const filePath = createFilePath(); + const store = new CollectorStore(filePath); + const expected = state("roundtrip"); + + store.update(expected); + store.flushSync(); + + const payload = JSON.parse(fs.readFileSync(filePath, "utf8")); + expect(payload).toMatchObject({ + version: 1, + packages: expected.packages, + collapsedPackageIds: expected.collapsedPackageIds + }); + expect(payload.updatedAt).toEqual(expect.any(Number)); + + const loaded = new CollectorStore(filePath); + const first = loaded.getState(); + first.packages[0].name = "Changed outside"; + expect(loaded.getState()).toEqual(expected); + }); + + it("recovers a valid backup when the primary file is corrupted", () => { + const filePath = createFilePath(); + const expected = state("backup"); + const store = new CollectorStore(filePath); + store.update(expected); + store.flushSync(); + fs.writeFileSync(filePath, "{broken", "utf8"); + + const recovered = new CollectorStore(filePath); + + expect(recovered.getState()).toEqual(expected); + expect(JSON.parse(fs.readFileSync(filePath, "utf8"))).toMatchObject({ + version: 1, + packages: expected.packages + }); + }); + + it("prefers a newer valid backup after an interrupted primary replacement", () => { + vi.useFakeTimers(); + vi.setSystemTime(1_700_000_000_000); + const filePath = createFilePath(); + const store = new CollectorStore(filePath); + store.update(state("old")); + store.flushSync(); + const oldPrimary = fs.readFileSync(filePath, "utf8"); + store.update(state("latest")); + store.flushSync(); + fs.writeFileSync(filePath, oldPrimary, "utf8"); + + expect(new CollectorStore(filePath).getState()).toEqual(state("latest")); + }); + + it("keeps the latest update when a scheduled write and flush overlap", async () => { + vi.useFakeTimers(); + const filePath = createFilePath(); + const store = new CollectorStore(filePath); + store.update(state("old")); + await vi.advanceTimersByTimeAsync(300); + + const latest = state("latest", false); + store.update(latest); + store.flushSync(); + await vi.runAllTimersAsync(); + await Promise.resolve(); + + expect(store.getState()).toEqual(latest); + expect(new CollectorStore(filePath).getState()).toEqual(latest); + }); + + it("coalesces pending updates into one delayed persistence", async () => { + vi.useFakeTimers(); + const filePath = createFilePath(); + const store = new CollectorStore(filePath); + + store.update(state("first")); + store.update(state("second")); + expect(fs.existsSync(filePath)).toBe(false); + await vi.advanceTimersByTimeAsync(299); + expect(fs.existsSync(filePath)).toBe(false); + await vi.advanceTimersByTimeAsync(1); + vi.useRealTimers(); + for (let attempt = 0; attempt < 50 && !fs.existsSync(filePath); attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + + expect(new CollectorStore(filePath).getState()).toEqual(state("second")); + }); + + it("backs off after a persistent write failure instead of retrying every 300 ms", async () => { + vi.useFakeTimers(); + let attempts = 0; + const store = new CollectorStore(createFilePath(), async () => { + attempts += 1; + throw Object.assign(new Error("locked"), { code: "EACCES" }); + }); + + store.update(state("blocked")); + await vi.advanceTimersByTimeAsync(300); + expect(attempts).toBe(1); + await vi.advanceTimersByTimeAsync(999); + expect(attempts).toBe(1); + await vi.advanceTimersByTimeAsync(1); + expect(attempts).toBe(2); + }); + + it("ignores invalid and oversized persistence files", () => { + const invalidPath = createFilePath(); + fs.writeFileSync(invalidPath, JSON.stringify({ version: 1, packages: "wrong", collapsedPackageIds: [], updatedAt: 1 }), "utf8"); + const oversizedPath = createFilePath(); + fs.writeFileSync(oversizedPath, "", "utf8"); + fs.truncateSync(oversizedPath, 64 * 1024 * 1024 + 1); + + expect(new CollectorStore(invalidPath).getState()).toEqual({ packages: [], collapsedPackageIds: [] }); + expect(new CollectorStore(oversizedPath).getState()).toEqual({ packages: [], collapsedPackageIds: [] }); + }); + + it("rejects a state that would be larger than the load limit", () => { + const store = new CollectorStore(createFilePath()); + const longUrl = `https://example.com/${"a".repeat(32_740)}`; + const longName = "n".repeat(1_024); + const packages = Array.from({ length: 2_000 }, (_, index) => ({ + ...state(String(index)).packages[0], + id: `package-${index}`, + name: longName, + links: [{ + ...state(String(index)).packages[0].links[0], + id: `link-${index}`, + url: longUrl + }] + })); + + expect(() => store.update({ packages, collapsedPackageIds: [] })).toThrow("Linksammler-Speicherzustand ist zu groß"); + }); +}); diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index 7186e20..f201ad0 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -962,6 +962,50 @@ describe("download start account gate", () => { expect(getProviderRuntimeSnapshot().realDebrid.accounts.find((entry) => entry.accountId === accountId)?.cooldown ?? null).toBeNull(); }); + it("keeps start available after stop when the last enabled Real-Debrid account has a transient cooldown", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-survivor-cooldown-gate-")); + tempDirs.push(root); + const accountIds = ["rdw_first", "rdw_second", "rdw_third", "rdw_survivor"]; + const session = emptySession(); + session.running = true; + const manager = new DownloadManager( + { + ...defaultSettings(), + realDebridUseWebLogin: true, + realDebridWebAccountIds: accountIds, + realDebridDisabledAccountIds: accountIds.slice(0, 3), + providerOrder: ["realdebrid"] + }, + session, + createStoragePaths(path.join(root, "state")) + ); + primeRealDebridRuntimeCooldownForTests("rdw_survivor", 60_000, "Temporärer Providerfehler"); + + await manager.stop(); + + expect(manager.getSnapshot().session.running).toBe(false); + expect(manager.getSnapshot().canStart).toBe(true); + }); + + it.each(["invalid", "rate_limit", "quota"] as const)("keeps start blocked for a Real-Debrid %s cooldown", (category) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), `rd-${category}-cooldown-gate-`)); + tempDirs.push(root); + const accountId = `rdw_${category}`; + const manager = new DownloadManager( + { + ...defaultSettings(), + realDebridUseWebLogin: true, + realDebridWebAccountIds: [accountId], + providerOrder: ["realdebrid"] + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + primeRealDebridRuntimeCooldownForTests(accountId, 60_000, "Account nicht nutzbar", category); + + expect(manager.getSnapshot().canStart).toBe(false); + }); + it("allows start when an active account is available", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-active-account-gate-")); tempDirs.push(root); diff --git a/tests/downloads-view.test.tsx b/tests/downloads-view.test.tsx index 081847d..b3a4e66 100644 --- a/tests/downloads-view.test.tsx +++ b/tests/downloads-view.test.tsx @@ -12,6 +12,7 @@ import { formatRemainingDownloadBytes, formatRemainingDownloadTooltip, getDownloadQueueTotalBytes, + getDownloadQueueStatusMetrics, getRemainingDownloadBytes, getPendingDownloadItemCount, getDownloadSpeedBps, @@ -897,6 +898,52 @@ describe("downloads model", () => { expect(hiddenExtracted.visibleItemIds).not.toContain("done"); }); + it("removes hidden extracted downloads from every queue-wide model source", () => { + const model = buildDownloadsViewModel(createInput({ + packageOrder: ["visible-package", "extracted-package"], + packages: { + "visible-package": pkg("visible-package", "Wartend", ["visible-item"]), + "extracted-package": pkg("extracted-package", "Entpackt", ["extracted-item"]) + }, + items: { + "visible-item": item("visible-item", "visible-package", "queued", { provider: "debridlink", providerLabel: "Debrid-Link" }), + "extracted-item": item("extracted-item", "extracted-package", "completed", { provider: "alldebrid", providerLabel: "AllDebrid", fullStatus: "Entpackt" }) + }, + hideExtractedItems: true + })); + + expect(model.counts).toEqual({ all: 1, active: 0, queued: 1, paused: 0, completed: 0, failed: 0 }); + expect(model.providerOptions).toEqual([{ id: "debridlink", label: "Debrid-Link" }]); + expect(model.eligibleItems.map((entry) => entry.id)).toEqual(["visible-item"]); + expect(model.eligiblePackageCount).toBe(1); + expect(model.packageRows[0].allItems.map((entry) => entry.id)).toEqual(["visible-item"]); + expect(model.empty).toBe(false); + expect(model.filteredEmpty).toBe(false); + }); + + it("treats a queue containing only hidden extracted downloads as empty", () => { + const model = buildDownloadsViewModel(createInput({ + packageOrder: ["extracted-package"], + packages: { "extracted-package": pkg("extracted-package", "Entpackt", ["extracted-item"]) }, + items: { "extracted-item": item("extracted-item", "extracted-package", "completed", { fullStatus: "Entpackt" }) }, + hideExtractedItems: true + })); + + expect(model.eligibleItems).toEqual([]); + expect(model.eligiblePackageCount).toBe(0); + expect(model.counts.all).toBe(0); + expect(model.providerOptions).toEqual([]); + expect(model.empty).toBe(true); + expect(model.filteredEmpty).toBe(false); + expect(getDownloadQueueStatusMetrics(model.eligibleItems)).toEqual({ + packageCount: 0, + pendingItemCount: 0, + totalBytes: 0, + remaining: { bytes: 0, unknownItems: 0 }, + hosterCount: 0 + }); + }); + it("supports the genuine flat file mode without synthetic package rows", () => { const model = buildDownloadsViewModel(createInput({ displayMode: "files" })); diff --git a/tests/main-shutdown-lifecycle.test.ts b/tests/main-shutdown-lifecycle.test.ts index 3ca1cee..6eda52b 100644 --- a/tests/main-shutdown-lifecycle.test.ts +++ b/tests/main-shutdown-lifecycle.test.ts @@ -73,6 +73,33 @@ afterEach(() => { }); describe("main shutdown lifecycle", () => { + it("continues the full shutdown when the collector state cannot be flushed", async () => { + const controller = Object.create(AppController.prototype) as any; + controller.runtimeStatsTimer = null; + controller.collectorStore = { flushSync: vi.fn(() => { throw new Error("collector locked"); }) }; + controller.notificationOutbox = { drainForShutdown: vi.fn(async () => undefined) }; + controller.manager = { + suspendDownloadHealthMonitoring: vi.fn(), + prepareForShutdown: vi.fn(), + flushNotificationsForShutdown: vi.fn(async () => undefined) + }; + controller.downloadHealthTimer = null; + controller.downloadHealthEvaluation = null; + controller.downloadHealthMonitor = null; + controller.megaWebFallback = { dispose: vi.fn() }; + controller.realDebridWebFallbacks = new Map(); + controller.pendingRealDebridWebAccountIds = new Map(); + controller.allDebridWebFallback = { dispose: vi.fn() }; + controller.bestDebridWebFallback = { dispose: vi.fn() }; + controller.shutdownLogStorage = vi.fn(); + controller.audit = vi.fn(); + controller.settings = { historyRetentionMode: "never" }; + + await expect(controller.shutdown()).resolves.toBeUndefined(); + expect(controller.manager.prepareForShutdown).toHaveBeenCalledTimes(1); + expect(controller.shutdownLogStorage).toHaveBeenCalledTimes(1); + }); + it("AppController waits for the bounded outbox drain before disposing runtime owners", async () => { const drain = deferred(); const manager = { prepareForShutdown: vi.fn() }; diff --git a/tests/visual/mock-electron-api.ts b/tests/visual/mock-electron-api.ts index ea1d7f3..1b1a6a1 100644 --- a/tests/visual/mock-electron-api.ts +++ b/tests/visual/mock-electron-api.ts @@ -63,6 +63,9 @@ export function createVisualElectronApi( prepareCollectorContainers: async () => ({ packages: [], invalidCount: 0, duplicateCount: 0 }), enrichCollectorPackages: async (request) => ({ packages: clone(request.packages), invalidCount: 0, duplicateCount: 0 }), onCollectorEnrichmentProgress: () => () => {}, + getCollectorState: async () => ({ packages: [], collapsedPackageIds: [] }), + saveCollectorState: async (state) => clone(state), + saveCollectorStateSync: () => {}, getPathForDroppedFile: () => "", getStartConflicts: async () => [], resolveStartConflict: async (_packageId, policy) => ({