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.
This commit is contained in:
Sucukdeluxe
2026-08-26 17:24:07 +02:00
parent 5644ba25da
commit bdcf9e3754
20 changed files with 933 additions and 48 deletions
+18
View File
@@ -77,6 +77,8 @@ import { getDesktopRenameLogPath, initDesktopRenameLogAt, shutdownDesktopRenameL
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 { 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<CollectorInspectionResult> {
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) {
+232
View File
@@ -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<void> {
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<string, unknown>;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
if (Object.keys(parsed).some((key) => !["version", "packages", "collapsedPackageIds", "updatedAt"].includes(key))) return null;
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<boolean> {
await fsp.mkdir(path.dirname(filePath), { recursive: true });
const tempPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
let handle: Awaited<ReturnType<typeof fsp.open>> | 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<typeof setTimeout> | null = null;
private activeWrite: Promise<void> | 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);
}
}
+5
View File
@@ -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)
+6 -3
View File
@@ -40,7 +40,8 @@ import {
addRealDebridAccountDailyUsageBytes,
addRealDebridAccountTotalUsageBytes,
getProviderUsageDayKey,
isProviderDailyLimitReached
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") {
+14
View File
@@ -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");
+3 -1
View File
@@ -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")
};
}
+4
View File
@@ -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<CollectorPersistenceState> => ipcRenderer.invoke(IPC_CHANNELS.GET_COLLECTOR_STATE),
saveCollectorState: (state: CollectorPersistenceState): Promise<CollectorPersistenceState> => ipcRenderer.invoke(IPC_CHANNELS.SAVE_COLLECTOR_STATE, state),
saveCollectorStateSync: (state: CollectorPersistenceState): void => { ipcRenderer.sendSync(IPC_CHANNELS.SAVE_COLLECTOR_STATE_SYNC, state); },
getPathForDroppedFile: (file: File): string => webUtils.getPathForFile(file),
getStartConflicts: (): Promise<StartConflictEntry[]> => ipcRenderer.invoke(IPC_CHANNELS.GET_START_CONFLICTS),
resolveStartConflict: (packageId: string, policy: DuplicatePolicy): Promise<StartConflictResolutionResult> =>
+72 -22
View File
@@ -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<CollectorInputState | null>(null);
const collectorPackagesRef = useRef<CollectorPackage[]>(collectorPackages);
const collapsedCollectorPackageIdsRef = useRef<Set<string>>(collapsedCollectorPackageIds);
const collectorPersistenceHydratedRef = useRef(false);
const collectorPersistenceCoordinatorRef = useRef<CollectorPersistenceCoordinator | null>(null);
const collectorEnrichmentGenerationsRef = useRef(new Map<string, number>());
const collectorEnrichmentRequestsRef = useRef(new Map<string, ReturnType<typeof beginCollectorEnrichment>>());
const importCollectorTextRef = useRef<(rawText: string) => Promise<void>>(() => 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<void> => {
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<string, { total: number; completed: number; failed: number; bytes: number }> = {};
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<DownloadsViewModel>(() => ({
...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) {
@@ -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<string>
): 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<void>;
dispose: () => void;
}
export function createCollectorPersistenceCoordinator(
save: (state: CollectorPersistenceState) => Promise<CollectorPersistenceState>,
delayMs = 300
): CollectorPersistenceCoordinator {
let timer: ReturnType<typeof setTimeout> | null = null;
let pending: CollectorPersistenceState | null = null;
let running: Promise<void> | null = null;
let disposed = false;
const drain = (): Promise<void> => {
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;
}
};
}
@@ -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";
@@ -43,6 +43,8 @@ export interface DownloadsViewModelCore {
providerOptions: Array<{ id: string; label: string }>;
query: string;
counts: DownloadFilterCounts;
eligibleItems: DownloadItem[];
eligiblePackageCount: number;
packageRows: DownloadPackageRow[];
fileRows: DownloadItem[];
visibleItemIds: string[];
@@ -116,6 +118,22 @@ export function getRemainingDownloadBytes(items: Iterable<DownloadItem>): { 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`
@@ -174,9 +192,11 @@ export function buildDownloadsViewModel(input: DownloadsModelInput): DownloadsVi
.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 counts = buildDownloadSidebarCounts(allItems);
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<string, string>();
for (const entry of allItems) {
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;
@@ -234,6 +254,8 @@ export function buildDownloadsViewModel(input: DownloadsModelInput): DownloadsVi
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,
@@ -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
};
}
+30
View File
@@ -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<string, unknown>;
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();
}
+3
View File
@@ -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",
+4
View File
@@ -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<CollectorInspectionResult>;
enrichCollectorPackages: (request: CollectorEnrichmentRequest) => Promise<CollectorInspectionResult>;
onCollectorEnrichmentProgress: (callback: (progress: CollectorEnrichmentProgress) => void) => () => void;
getCollectorState: () => Promise<CollectorPersistenceState>;
saveCollectorState: (state: CollectorPersistenceState) => Promise<CollectorPersistenceState>;
saveCollectorStateSync: (state: CollectorPersistenceState) => void;
getPathForDroppedFile: (file: File) => string;
getStartConflicts: () => Promise<StartConflictEntry[]>;
resolveStartConflict: (packageId: string, policy: DuplicatePolicy) => Promise<StartConflictResolutionResult>;
+18 -1
View File
@@ -7,7 +7,8 @@ const electron = vi.hoisted(() => ({
invoke: vi.fn<(...args: unknown[]) => Promise<unknown>>(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;
+82
View File
@@ -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<void>((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"]);
});
});
+187
View File
@@ -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ß");
});
});
+44
View File
@@ -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);
+47
View File
@@ -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" }));
+27
View File
@@ -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() };
+3
View File
@@ -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) => ({