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) => ({