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 { buildAccountSummary, buildNotificationSupportPayload, diffAccountSummary, type NotificationSupportPayload } from "./support-data";
import { buildSupportBundle, getSupportBundleDefaultFileName } from "./support-bundle"; 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 type { DebugSetupCheckResult, SupportTraceConfig } from "../shared/types";
import { createOnlineBackup, downloadOnlineBackup, uploadOnlineBackup } from "./online-backup"; import { createOnlineBackup, downloadOnlineBackup, uploadOnlineBackup } from "./online-backup";
import { overlayLiveUsageCounters } from "./settings-live-overlay"; import { overlayLiveUsageCounters } from "./settings-live-overlay";
@@ -126,6 +128,8 @@ export class AppController {
private storagePaths = createStoragePaths(path.join(app.getPath("userData"), "runtime")); private storagePaths = createStoragePaths(path.join(app.getPath("userData"), "runtime"));
private collectorStore = new CollectorStore(this.storagePaths.collectorFile);
private notificationOutbox: NotificationOutbox; private notificationOutbox: NotificationOutbox;
private downloadHealthMonitor: DownloadHealthMonitor; private downloadHealthMonitor: DownloadHealthMonitor;
@@ -997,6 +1001,15 @@ export class AppController {
return prepareCollectorText(request); 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> { public prepareCollectorContainers(filePaths: string[], addedAt: number): Promise<CollectorInspectionResult> {
return prepareCollectorContainers(filePaths, addedAt); return prepareCollectorContainers(filePaths, addedAt);
} }
@@ -1388,6 +1401,11 @@ export class AppController {
} }
stopDebugServer(); stopDebugServer();
abortActiveUpdateDownload(); abortActiveUpdateDownload();
try {
this.collectorStore?.flushSync();
} catch (error) {
logger.warn(`Linksammler konnte beim Beenden nicht gespeichert werden: ${String(error)}`);
}
cancelPendingAsyncSaves(); cancelPendingAsyncSaves();
this.manager.prepareForShutdown(); this.manager.prepareForShutdown();
if (this.downloadHealthEvaluation) { 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; 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[] { export function getAvailableRealDebridAccounts(settings: AppSettings, now = Date.now()): RealDebridAccountEntry[] {
return getConfiguredRealDebridAccounts(settings).filter((account) => account.enabled return getConfiguredRealDebridAccounts(settings).filter((account) => account.enabled
&& !isRealDebridAccountDailyLimitReached(settings, account.id, now) && !isRealDebridAccountDailyLimitReached(settings, account.id, now)
+6 -3
View File
@@ -40,7 +40,8 @@ import {
addRealDebridAccountDailyUsageBytes, addRealDebridAccountDailyUsageBytes,
addRealDebridAccountTotalUsageBytes, addRealDebridAccountTotalUsageBytes,
getProviderUsageDayKey, getProviderUsageDayKey,
isProviderDailyLimitReached isProviderDailyLimitReached,
isRealDebridAccountDailyLimitReached
} from "../shared/provider-daily-limits"; } 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 { 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"; import { parseCollectorInput } from "./link-parser";
@@ -61,7 +62,7 @@ function releaseTlsSkip(): void {
} }
import { cleanupCancelledPackageArtifactsAsync, removeDownloadLinkArtifacts, removeSampleArtifacts } from "./cleanup"; import { cleanupCancelledPackageArtifactsAsync, removeDownloadLinkArtifacts, removeSampleArtifacts } from "./cleanup";
import { planDownloadCompletion, reconcileFinalizedSize, validateDownloadedFileCompletion } from "./download-completion"; 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 { cleanupArchives, clearExtractResumeState, collectArchiveCleanupTargets, detectArchiveSignature, extractPackageArchives, findArchiveCandidates, hasAnyFilesRecursive, removeEmptyDirectoryTree, resetExtractorCachesForPasswordChange, type ExtractArchiveFailureInfo, type ExtractProgressUpdate } from "./extractor";
import { validateFileAgainstManifest } from "./integrity"; import { validateFileAgainstManifest } from "./integrity";
import { classifyDiskError } from "./fs-error"; import { classifyDiskError } from "./fs-error";
@@ -8870,7 +8871,9 @@ export class DownloadManager extends EventEmitter {
if (effectiveProvider === "realdebrid") { if (effectiveProvider === "realdebrid") {
const configuredAccounts = getRealDebridAccounts(this.settings); const configuredAccounts = getRealDebridAccounts(this.settings);
return configuredAccounts.length > 0 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()); : Boolean(this.settings.realDebridUseWebLogin || this.settings.token.trim());
} }
if (effectiveProvider === "megadebrid-api") { if (effectiveProvider === "megadebrid-api") {
+14
View File
@@ -29,6 +29,7 @@ import { DailyStartScheduler, hasDailyStartRulePatch, prepareDailyStartSettingsP
import { import {
validateCollectorContainerPreparationRequest, validateCollectorContainerPreparationRequest,
validateCollectorEnrichmentRequest, validateCollectorEnrichmentRequest,
validateCollectorPersistenceState,
validateCollectorTextPreparationRequest validateCollectorTextPreparationRequest
} from "../shared/collector"; } 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.GET_START_CONFLICTS, () => controller.getStartConflicts());
handleTrusted(IPC_CHANNELS.RESOLVE_START_CONFLICT, (_event: IpcMainInvokeEvent, packageId: string, policy: "keep" | "skip" | "overwrite") => { handleTrusted(IPC_CHANNELS.RESOLVE_START_CONFLICT, (_event: IpcMainInvokeEvent, packageId: string, policy: "keep" | "skip" | "overwrite") => {
validateString(packageId, "packageId"); validateString(packageId, "packageId");
+3 -1
View File
@@ -719,6 +719,7 @@ export interface StoragePaths {
statisticsFile: string; statisticsFile: string;
notificationOutboxFile: string; notificationOutboxFile: string;
notificationHealthFile: string; notificationHealthFile: string;
collectorFile: string;
} }
export function createStoragePaths(baseDir: string): StoragePaths { export function createStoragePaths(baseDir: string): StoragePaths {
@@ -729,7 +730,8 @@ export function createStoragePaths(baseDir: string): StoragePaths {
historyFile: path.join(baseDir, "rd_history.json"), historyFile: path.join(baseDir, "rd_history.json"),
statisticsFile: path.join(baseDir, "rd_statistics.json"), statisticsFile: path.join(baseDir, "rd_statistics.json"),
notificationOutboxFile: path.join(baseDir, "rd_notification_outbox.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, CollectorEnrichmentRequest,
CollectorEnrichmentProgress, CollectorEnrichmentProgress,
CollectorInspectionResult, CollectorInspectionResult,
CollectorPersistenceState,
CollectorTextPreparationRequest CollectorTextPreparationRequest
} from "../shared/collector"; } from "../shared/collector";
import { IPC_CHANNELS } from "../shared/ipc"; import { IPC_CHANNELS } from "../shared/ipc";
@@ -68,6 +69,9 @@ const api: ElectronApi = {
ipcRenderer.on(IPC_CHANNELS.COLLECTOR_ENRICHMENT_PROGRESS, listener); ipcRenderer.on(IPC_CHANNELS.COLLECTOR_ENRICHMENT_PROGRESS, listener);
return () => ipcRenderer.removeListener(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), getPathForDroppedFile: (file: File): string => webUtils.getPathForFile(file),
getStartConflicts: (): Promise<StartConflictEntry[]> => ipcRenderer.invoke(IPC_CHANNELS.GET_START_CONFLICTS), getStartConflicts: (): Promise<StartConflictEntry[]> => ipcRenderer.invoke(IPC_CHANNELS.GET_START_CONFLICTS),
resolveStartConflict: (packageId: string, policy: DuplicatePolicy): Promise<StartConflictResolutionResult> => resolveStartConflict: (packageId: string, policy: DuplicatePolicy): Promise<StartConflictResolutionResult> =>
+72 -22
View File
@@ -74,6 +74,7 @@ import {
selectCollectorPackageLinks, selectCollectorPackageLinks,
type CollectorWorkspaceFilter type CollectorWorkspaceFilter
} from "./views/collector/collector-model"; } from "./views/collector/collector-model";
import { createCollectorPersistenceCoordinator, restoreCollectorPersistenceState, type CollectorPersistenceCoordinator } from "./views/collector/collector-persistence";
import { import {
CollectorInputDialog, CollectorInputDialog,
MemoizedCollectorContent, MemoizedCollectorContent,
@@ -106,7 +107,7 @@ import {
StatisticsSidebarStatus, StatisticsSidebarStatus,
type StatisticsViewActions type StatisticsViewActions
} from "./views/statistics/StatisticsView"; } 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 { downloadColumnDefinitions, type DownloadSortColumn } from "./views/downloads/DownloadsTable";
import { DeleteConfirmationDialog } from "./views/downloads/DeleteConfirmationDialog"; 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"; 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 [collectorError, setCollectorError] = useState("");
const [collectorInput, setCollectorInput] = useState<CollectorInputState | null>(null); const [collectorInput, setCollectorInput] = useState<CollectorInputState | null>(null);
const collectorPackagesRef = useRef<CollectorPackage[]>(collectorPackages); 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 collectorEnrichmentGenerationsRef = useRef(new Map<string, number>());
const collectorEnrichmentRequestsRef = useRef(new Map<string, ReturnType<typeof beginCollectorEnrichment>>()); const collectorEnrichmentRequestsRef = useRef(new Map<string, ReturnType<typeof beginCollectorEnrichment>>());
const importCollectorTextRef = useRef<(rawText: string) => Promise<void>>(() => Promise.resolve()); const importCollectorTextRef = useRef<(rawText: string) => Promise<void>>(() => Promise.resolve());
@@ -1872,6 +1876,7 @@ export function App(): ReactElement {
); );
collectorPackagesRef.current = collectorPackages; collectorPackagesRef.current = collectorPackages;
collapsedCollectorPackageIdsRef.current = collapsedCollectorPackageIds;
useEffect(() => { useEffect(() => {
activeTabRef.current = tab; activeTabRef.current = tab;
@@ -3652,6 +3657,65 @@ export function App(): ReactElement {
}, true); }, 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> => { const importCollectorText = async (rawText: string): Promise<void> => {
if (!rawText.trim()) { if (!rawText.trim()) {
showToast("Keine Links eingegeben", 2200); showToast("Keine Links eingegeben", 2200);
@@ -4877,21 +4941,6 @@ export function App(): ReactElement {
return map; return map;
}, [snapshot.packageSpeedBps]); }, [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 sortDownloadsByColumn = useCallback((column: DownloadSortColumn): void => {
const nextDescending = downloadsSortColumn === column ? !downloadsSortDescending : false; const nextDescending = downloadsSortColumn === column ? !downloadsSortDescending : false;
setDownloadsSortColumn(column); setDownloadsSortColumn(column);
@@ -5018,8 +5067,9 @@ export function App(): ReactElement {
if (!snapshot.session.running || snapshot.session.paused) return; if (!snapshot.session.running || snapshot.session.paused) return;
speedHistoryRef.current = appendBandwidthSample(speedHistoryRef.current, liveDownloadSpeedBps); speedHistoryRef.current = appendBandwidthSample(speedHistoryRef.current, liveDownloadSpeedBps);
}, [liveDownloadSpeedBps, snapshot.packageSpeedBps, snapshot.session.paused, snapshot.session.running]); }, [liveDownloadSpeedBps, snapshot.packageSpeedBps, snapshot.session.paused, snapshot.session.running]);
const downloadQueueTotalBytes = useMemo(() => getDownloadQueueTotalBytes(Object.values(snapshot.session.items)), [snapshot.session.items]); const downloadQueueMetrics = useMemo(() => getDownloadQueueStatusMetrics(downloadsViewCore.eligibleItems), [downloadsViewCore.eligibleItems]);
const downloadRemaining = useMemo(() => getRemainingDownloadBytes(Object.values(snapshot.session.items)), [snapshot.session.items]); const downloadQueueTotalBytes = downloadQueueMetrics.totalBytes;
const downloadRemaining = downloadQueueMetrics.remaining;
const downloadsViewModel = useMemo<DownloadsViewModel>(() => ({ const downloadsViewModel = useMemo<DownloadsViewModel>(() => ({
...downloadsViewCore, ...downloadsViewCore,
running: snapshot.session.running, running: snapshot.session.running,
@@ -5051,8 +5101,8 @@ export function App(): ReactElement {
disclosureRevision: downloadDisclosureRevision, disclosureRevision: downloadDisclosureRevision,
animationsEnabled: snapshot.settings.animatePackageDisclosure, animationsEnabled: snapshot.settings.animatePackageDisclosure,
status: { status: {
packages: snapshot.stats.totalPackages, packages: downloadQueueMetrics.packageCount,
links: getPendingDownloadItemCount(Object.values(snapshot.session.items)), links: downloadQueueMetrics.pendingItemCount,
session: humanSize(snapshot.stats.totalDownloaded), session: humanSize(snapshot.stats.totalDownloaded),
sessionBytes: snapshot.stats.totalDownloaded, sessionBytes: snapshot.stats.totalDownloaded,
total: humanSize(downloadQueueTotalBytes), total: humanSize(downloadQueueTotalBytes),
@@ -5060,11 +5110,11 @@ export function App(): ReactElement {
remaining: formatRemainingDownloadBytes(downloadRemaining), remaining: formatRemainingDownloadBytes(downloadRemaining),
remainingBytes: downloadRemaining.bytes, remainingBytes: downloadRemaining.bytes,
remainingTooltip: formatRemainingDownloadTooltip(downloadRemaining), remainingTooltip: formatRemainingDownloadTooltip(downloadRemaining),
hosters: providerStats.length, hosters: downloadQueueMetrics.hosterCount,
speed: liveDownloadSpeedBps > 0 ? formatSpeedMbps(liveDownloadSpeedBps) : "0 B/s", speed: liveDownloadSpeedBps > 0 ? formatSpeedMbps(liveDownloadSpeedBps) : "0 B/s",
eta: snapshot.etaText 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 => { const resetColumnLayout = useCallback((): void => {
if (columnDragSettleTimerRef.current !== null) { 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 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"; import { DOWNLOAD_FILE_ROW_HEIGHT, DOWNLOAD_PACKAGE_ROW_HEIGHT, type DownloadVirtualRowInput } from "./download-virtualizer";
export type DownloadDisplayMode = "packages" | "files"; export type DownloadDisplayMode = "packages" | "files";
@@ -43,6 +43,8 @@ export interface DownloadsViewModelCore {
providerOptions: Array<{ id: string; label: string }>; providerOptions: Array<{ id: string; label: string }>;
query: string; query: string;
counts: DownloadFilterCounts; counts: DownloadFilterCounts;
eligibleItems: DownloadItem[];
eligiblePackageCount: number;
packageRows: DownloadPackageRow[]; packageRows: DownloadPackageRow[];
fileRows: DownloadItem[]; fileRows: DownloadItem[];
visibleItemIds: string[]; visibleItemIds: string[];
@@ -116,6 +118,22 @@ export function getRemainingDownloadBytes(items: Iterable<DownloadItem>): { byte
return { bytes, unknownItems }; 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 { export function formatRemainingDownloadBytes(summary: { bytes: number; unknownItems: number }): string {
const value = summary.bytes >= 1024 ** 4 const value = summary.bytes >= 1024 ** 4
? `${(summary.bytes / 1024 ** 4).toFixed(4)} TB` ? `${(summary.bytes / 1024 ** 4).toFixed(4)} TB`
@@ -174,9 +192,11 @@ export function buildDownloadsViewModel(input: DownloadsModelInput): DownloadsVi
.map((id) => input.packages[id]) .map((id) => input.packages[id])
.filter((entry): entry is PackageEntry => Boolean(entry)); .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 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>(); 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); 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 !== "" const visibleItems = packageMatchesQuery && query !== ""
? items.filter((item) => matchesFilter(item, input.filter) && matchesProvider(item, input.providerFilter)) ? items.filter((item) => matchesFilter(item, input.filter) && matchesProvider(item, input.providerFilter))
: matchingItems; : 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; 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")), providerOptions: [...providerMap].map(([id, label]) => ({ id, label })).sort((left, right) => left.label.localeCompare(right.label, "de")),
query: input.query, query: input.query,
counts, counts,
eligibleItems,
eligiblePackageCount,
packageRows: displayedPackages, packageRows: displayedPackages,
fileRows, fileRows,
visibleItemIds, visibleItemIds,
@@ -245,7 +267,7 @@ export function buildDownloadsViewModel(input: DownloadsModelInput): DownloadsVi
totalMainRowCount, totalMainRowCount,
paginationLabel: paginationLabel(mainRowCount, totalMainRowCount), paginationLabel: paginationLabel(mainRowCount, totalMainRowCount),
limited: false, limited: false,
empty: allItems.length === 0, empty: eligibleItems.length === 0,
filteredEmpty: allItems.length > 0 && mainRowCount === 0 filteredEmpty: eligibleItems.length > 0 && mainRowCount === 0
}; };
} }
+30
View File
@@ -47,6 +47,11 @@ export interface CollectorInspectionResult {
duplicateCount: number; duplicateCount: number;
} }
export interface CollectorPersistenceState {
packages: CollectorPackage[];
collapsedPackageIds: string[];
}
function validAddedAt(value: unknown): value is number { function validAddedAt(value: unknown): value is number {
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0; 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[] }; 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 { function collectorMarkerValue(value: string): string {
return String(value || "").replace(/[\r\n]+/g, " ").trim(); 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", PREPARE_COLLECTOR_CONTAINERS: "collector:prepare-containers",
ENRICH_COLLECTOR_PACKAGES: "collector:enrich-packages", ENRICH_COLLECTOR_PACKAGES: "collector:enrich-packages",
COLLECTOR_ENRICHMENT_PROGRESS: "collector:enrichment-progress", 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", GET_START_CONFLICTS: "queue:get-start-conflicts",
RESOLVE_START_CONFLICT: "queue:resolve-start-conflict", RESOLVE_START_CONFLICT: "queue:resolve-start-conflict",
CLEAR_ALL: "queue:clear-all", CLEAR_ALL: "queue:clear-all",
+4
View File
@@ -38,6 +38,7 @@ import type {
CollectorEnrichmentRequest, CollectorEnrichmentRequest,
CollectorEnrichmentProgress, CollectorEnrichmentProgress,
CollectorInspectionResult, CollectorInspectionResult,
CollectorPersistenceState,
CollectorTextPreparationRequest CollectorTextPreparationRequest
} from "./collector"; } from "./collector";
@@ -89,6 +90,9 @@ export interface ElectronApi {
prepareCollectorContainers: (filePaths: string[], addedAt: number) => Promise<CollectorInspectionResult>; prepareCollectorContainers: (filePaths: string[], addedAt: number) => Promise<CollectorInspectionResult>;
enrichCollectorPackages: (request: CollectorEnrichmentRequest) => Promise<CollectorInspectionResult>; enrichCollectorPackages: (request: CollectorEnrichmentRequest) => Promise<CollectorInspectionResult>;
onCollectorEnrichmentProgress: (callback: (progress: CollectorEnrichmentProgress) => void) => () => void; onCollectorEnrichmentProgress: (callback: (progress: CollectorEnrichmentProgress) => void) => () => void;
getCollectorState: () => Promise<CollectorPersistenceState>;
saveCollectorState: (state: CollectorPersistenceState) => Promise<CollectorPersistenceState>;
saveCollectorStateSync: (state: CollectorPersistenceState) => void;
getPathForDroppedFile: (file: File) => string; getPathForDroppedFile: (file: File) => string;
getStartConflicts: () => Promise<StartConflictEntry[]>; getStartConflicts: () => Promise<StartConflictEntry[]>;
resolveStartConflict: (packageId: string, policy: DuplicatePolicy) => Promise<StartConflictResolutionResult>; 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), invoke: vi.fn<(...args: unknown[]) => Promise<unknown>>(async () => undefined),
getPathForFile: vi.fn(() => "C:\\Imports\\dropped.dlc"), getPathForFile: vi.fn(() => "C:\\Imports\\dropped.dlc"),
on: vi.fn(), on: vi.fn(),
removeListener: vi.fn() removeListener: vi.fn(),
sendSync: vi.fn((_: unknown, state: unknown) => state)
})); }));
vi.mock("electron", () => ({ vi.mock("electron", () => ({
@@ -20,6 +21,7 @@ vi.mock("electron", () => ({
invoke: electron.invoke, invoke: electron.invoke,
on: electron.on, on: electron.on,
removeListener: electron.removeListener, removeListener: electron.removeListener,
sendSync: electron.sendSync,
send: vi.fn() send: vi.fn()
}, },
webUtils: { getPathForFile: electron.getPathForFile } webUtils: { getPathForFile: electron.getPathForFile }
@@ -32,6 +34,7 @@ describe("account preload contract", () => {
beforeEach(() => { beforeEach(() => {
electron.invoke.mockClear(); electron.invoke.mockClear();
electron.sendSync.mockClear();
}); });
it("forwards submitted secrets only in write-only account commands", async () => { 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", () => { it("resolves dropped files through Electron webUtils without IPC", () => {
const file = { name: "dropped.dlc" } as File; 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(); 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", () => { it("allows start when an active account is available", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-active-account-gate-")); const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-active-account-gate-"));
tempDirs.push(root); tempDirs.push(root);
+47
View File
@@ -12,6 +12,7 @@ import {
formatRemainingDownloadBytes, formatRemainingDownloadBytes,
formatRemainingDownloadTooltip, formatRemainingDownloadTooltip,
getDownloadQueueTotalBytes, getDownloadQueueTotalBytes,
getDownloadQueueStatusMetrics,
getRemainingDownloadBytes, getRemainingDownloadBytes,
getPendingDownloadItemCount, getPendingDownloadItemCount,
getDownloadSpeedBps, getDownloadSpeedBps,
@@ -897,6 +898,52 @@ describe("downloads model", () => {
expect(hiddenExtracted.visibleItemIds).not.toContain("done"); 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", () => { it("supports the genuine flat file mode without synthetic package rows", () => {
const model = buildDownloadsViewModel(createInput({ displayMode: "files" })); const model = buildDownloadsViewModel(createInput({ displayMode: "files" }));
+27
View File
@@ -73,6 +73,33 @@ afterEach(() => {
}); });
describe("main shutdown lifecycle", () => { 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 () => { it("AppController waits for the bounded outbox drain before disposing runtime owners", async () => {
const drain = deferred(); const drain = deferred();
const manager = { prepareForShutdown: vi.fn() }; const manager = { prepareForShutdown: vi.fn() };
+3
View File
@@ -63,6 +63,9 @@ export function createVisualElectronApi(
prepareCollectorContainers: async () => ({ packages: [], invalidCount: 0, duplicateCount: 0 }), prepareCollectorContainers: async () => ({ packages: [], invalidCount: 0, duplicateCount: 0 }),
enrichCollectorPackages: async (request) => ({ packages: clone(request.packages), invalidCount: 0, duplicateCount: 0 }), enrichCollectorPackages: async (request) => ({ packages: clone(request.packages), invalidCount: 0, duplicateCount: 0 }),
onCollectorEnrichmentProgress: () => () => {}, onCollectorEnrichmentProgress: () => () => {},
getCollectorState: async () => ({ packages: [], collapsedPackageIds: [] }),
saveCollectorState: async (state) => clone(state),
saveCollectorStateSync: () => {},
getPathForDroppedFile: () => "", getPathForDroppedFile: () => "",
getStartConflicts: async () => [], getStartConflicts: async () => [],
resolveStartConflict: async (_packageId, policy) => ({ resolveStartConflict: async (_packageId, policy) => ({