diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index 917f51f..def2ec6 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -317,6 +317,9 @@ export class AppController { // Prevent prepareForShutdown from overwriting the restored session file // with the old in-memory session when the app quits after backup restore. this.manager.skipShutdownPersist = true; + // Block all persistence (including persistSoon from any IPC operations + // the user might trigger before restarting) to protect the restored backup. + this.manager.blockAllPersistence = true; return { restored: true, message: "Backup wiederhergestellt. Bitte App neustarten." }; } diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index f519599..5955b50 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -803,6 +803,10 @@ export class DownloadManager extends EventEmitter { public skipShutdownPersist = false; + /** Block ALL persistence (persistSoon + shutdown). Set after importBackup to prevent + * the old in-memory session from overwriting the restored backup on disk. */ + public blockAllPersistence = false; + private debridService: DebridService; private invalidateMegaSessionFn?: () => void; @@ -1083,7 +1087,7 @@ export class DownloadManager extends EventEmitter { seen.add(id); return true; }); - const remaining = this.session.packageOrder.filter((id) => !valid.includes(id)); + const remaining = this.session.packageOrder.filter((id) => !seen.has(id)); this.session.packageOrder = [...valid, ...remaining]; this.persistSoon(); this.emitState(true); @@ -3056,7 +3060,7 @@ export class DownloadManager extends EventEmitter { this.session.reconnectReason = ""; this.speedEvents = []; this.speedBytesLastWindow = 0; - this.speedBytesPerPackage.clear(); + this.speedBytesPerPackage.clear(); this.speedEventsHead = 0; this.lastGlobalProgressBytes = 0; this.lastGlobalProgressAt = nowMs(); @@ -3116,6 +3120,7 @@ export class DownloadManager extends EventEmitter { this.session.reconnectUntil = 0; this.session.reconnectReason = ""; this.retryAfterByItem.clear(); + this.retryStateByItem.clear(); this.lastGlobalProgressBytes = this.session.totalDownloadedBytes; this.lastGlobalProgressAt = nowMs(); this.speedEvents = []; @@ -3132,12 +3137,13 @@ export class DownloadManager extends EventEmitter { active.abortReason = "stop"; active.abortController.abort("stop"); } - // Reset all non-finished items to clean "Wartet" state + // Reset all non-finished items to clean "Wartet" / "Paket gestoppt" state for (const item of Object.values(this.session.items)) { if (!isFinishedStatus(item.status)) { item.status = "queued"; item.speedBps = 0; - item.fullStatus = "Wartet"; + const pkg = this.session.packages[item.packageId]; + item.fullStatus = pkg && !pkg.enabled ? "Paket gestoppt" : "Wartet"; item.updatedAt = nowMs(); } } @@ -3229,7 +3235,7 @@ export class DownloadManager extends EventEmitter { this.session.summaryText = ""; // Persist synchronously on shutdown to guarantee data is written before process exits // Skip if a backup was just imported — the restored session on disk must not be overwritten - if (!this.skipShutdownPersist) { + if (!this.skipShutdownPersist && !this.blockAllPersistence) { saveSession(this.storagePaths, this.session); saveSettings(this.storagePaths, this.settings); } @@ -3494,7 +3500,7 @@ export class DownloadManager extends EventEmitter { } private persistSoon(): void { - if (this.persistTimer) { + if (this.persistTimer || this.blockAllPersistence) { return; } @@ -6343,18 +6349,9 @@ export class DownloadManager extends EventEmitter { extractCpuPriority: this.settings.extractCpuPriority, onProgress: (progress) => { if (progress.phase === "done") { - // Mark all remaining active archives as done - for (const [archName, archItems] of activeHybridArchiveMap) { - const doneAt = nowMs(); - const startedAt = hybridArchiveStartTimes.get(archName) || doneAt; - const doneLabel = formatExtractDone(doneAt - startedAt); - for (const entry of archItems) { - if (!isExtractedLabel(entry.fullStatus)) { - entry.fullStatus = doneLabel; - entry.updatedAt = doneAt; - } - } - } + // Do NOT mark remaining archives as "Done" here — some may have + // failed. The post-extraction code (result.failed check) will + // assign the correct label. Only clear the tracking maps. activeHybridArchiveMap.clear(); hybridArchiveStartTimes.clear(); return; @@ -6639,18 +6636,9 @@ export class DownloadManager extends EventEmitter { extractCpuPriority: this.settings.extractCpuPriority, onProgress: (progress) => { if (progress.phase === "done") { - // Mark all remaining active archives as done - for (const [archName, items] of activeArchiveItemsMap) { - const doneAt = nowMs(); - const startedAt = archiveStartTimes.get(archName) || doneAt; - const doneLabel = formatExtractDone(doneAt - startedAt); - for (const entry of items) { - if (!isExtractedLabel(entry.fullStatus)) { - entry.fullStatus = doneLabel; - entry.updatedAt = doneAt; - } - } - } + // Do NOT mark remaining archives as "Done" here — some may have + // failed. The post-extraction code (result.failed check) will + // assign the correct label. Only clear the tracking maps. activeArchiveItemsMap.clear(); archiveStartTimes.clear(); emitExtractStatus("Entpacken 100%", true); @@ -6786,8 +6774,8 @@ export class DownloadManager extends EventEmitter { for (const entry of completedItems) { if (/^Entpacken/i.test(entry.fullStatus || "") || /^Passwort/i.test(entry.fullStatus || "")) { entry.fullStatus = "Entpacken abgebrochen (wird fortgesetzt)"; + entry.updatedAt = nowMs(); } - entry.updatedAt = nowMs(); } pkg.status = (pkg.enabled && !this.session.paused) ? "queued" : "paused"; pkg.updatedAt = nowMs(); @@ -6801,8 +6789,8 @@ export class DownloadManager extends EventEmitter { for (const entry of completedItems) { if (!isExtractedLabel(entry.fullStatus)) { entry.fullStatus = `Entpack-Fehler: ${reason}`; + entry.updatedAt = nowMs(); } - entry.updatedAt = nowMs(); } pkg.status = "failed"; } diff --git a/src/main/extractor.ts b/src/main/extractor.ts index ffbc292..45ab299 100644 --- a/src/main/extractor.ts +++ b/src/main/extractor.ts @@ -2195,7 +2195,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ for (const nestedArchive of nestedCandidates) { if (options.signal?.aborted) throw new Error("aborted:extract"); const nestedName = path.basename(nestedArchive); - const nestedKey = archiveNameKey(nestedName); + const nestedKey = archiveNameKey(`nested:${nestedName}`); if (resumeCompleted.has(nestedKey)) { logger.info(`Nested-Extraction übersprungen (bereits entpackt): ${nestedName}`); continue; @@ -2258,7 +2258,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ } if (extracted > 0) { - const hasOutputAfter = await hasAnyEntries(options.targetDir); + const hasOutputAfter = await hasAnyFilesRecursive(options.targetDir); const hadResumeProgress = resumeCompletedAtStart > 0; if (!hasOutputAfter && conflictMode !== "skip" && !hadResumeProgress) { lastError = "Keine entpackten Dateien erkannt"; diff --git a/src/main/realdebrid.ts b/src/main/realdebrid.ts index 4fcd099..d07831f 100644 --- a/src/main/realdebrid.ts +++ b/src/main/realdebrid.ts @@ -78,6 +78,11 @@ async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise await sleep(ms); return; } + // Check before entering the Promise constructor to avoid a race where the timer + // resolves before the aborted check runs (especially when ms=0). + if (signal.aborted) { + throw new Error("aborted"); + } await new Promise((resolve, reject) => { let timer: NodeJS.Timeout | null = setTimeout(() => { timer = null; @@ -94,10 +99,6 @@ async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise reject(new Error("aborted")); }; - if (signal.aborted) { - onAbort(); - return; - } signal.addEventListener("abort", onAbort, { once: true }); }); } diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 4aa2a7e..30d5619 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -3331,24 +3331,14 @@ const PackageCard = memo(function PackageCard({ pkg, items, packageSpeed, isFirs ); - case "hoster": return ( - { - const hosters = [...new Set(items.map((item) => extractHoster(item.url)).filter(Boolean))]; - return hosters.join(", "); - })()}>{(() => { - const hosters = [...new Set(items.map((item) => extractHoster(item.url)).filter(Boolean))]; - return hosters.length > 0 ? hosters.join(", ") : ""; - })()} - ); - case "account": return ( - { - const providers = [...new Set(items.map((item) => item.provider).filter(Boolean))]; - return providers.map((p) => providerLabels[p!] || p).join(", "); - })()}>{(() => { - const providers = [...new Set(items.map((item) => item.provider).filter(Boolean))]; - return providers.length > 0 ? providers.map((p) => providerLabels[p!] || p).join(", ") : ""; - })()} - ); + case "hoster": { + const hosterText = [...new Set(items.map((item) => extractHoster(item.url)).filter(Boolean))].join(", "); + return {hosterText}; + } + case "account": { + const accountText = [...new Set(items.map((item) => item.provider).filter(Boolean))].map((p) => providerLabels[p!] || p).join(", "); + return {accountText}; + } case "prio": return ( {pkg.priority === "high" ? "Hoch" : pkg.priority === "low" ? "Niedrig" : ""} );