diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index 69e5a89..068028a 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -1034,14 +1034,14 @@ export class AppController { return paused; } - public retryExtraction(packageId: string): void { - this.audit("INFO", "Extraktion manuell wiederholt", { packageId }); - this.manager.retryExtraction(packageId); - } + public async retryExtraction(packageId: string): Promise { + this.audit("INFO", "Extraktion manuell wiederholt", { packageId }); + await this.manager.retryExtraction(packageId); + } - public extractNow(request: ExtractNowRequest): void { + public async extractNow(request: ExtractNowRequest): Promise { this.audit("INFO", "Jetzt entpacken ausgelöst", { packageIds: request.packageIds, itemIds: request.itemIds }); - this.manager.extractNow(request); + await this.manager.extractNow(request); } public resetPackage(packageId: string): void { diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 8d21ebf..883475b 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -380,13 +380,50 @@ function getGlobalStallWatchdogTimeoutMs(): number { return DEFAULT_GLOBAL_STALL_WATCHDOG_TIMEOUT_MS; } -function getPostExtractTimeoutMs(): number { +function getPostExtractTimeoutMs(): number { const fromEnv = Number(process.env.RD_POST_EXTRACT_TIMEOUT_MS ?? NaN); if (Number.isFinite(fromEnv) && fromEnv >= 2000 && fromEnv <= 24 * 60 * 60 * 1000) { return Math.floor(fromEnv); } - return DEFAULT_POST_EXTRACT_TIMEOUT_MS; -} + return DEFAULT_POST_EXTRACT_TIMEOUT_MS; +} + +export function formatExtractionProgressLabels(progress: Pick): { itemLabel: string; packageLabel: string } { + const archivePercent = Math.max(0, Math.min(100, Math.floor(Number(progress.archivePercent ?? 0)))); + const overallPercent = Math.max(0, Math.min(100, Math.floor(Number(progress.percent ?? 0)))); + const total = Math.max(1, Math.floor(Number(progress.total) || 1)); + const current = Math.max(0, Math.min(total, Math.floor(Number(progress.current) || 0))); + const archive = progress.archiveName ? ` · ${progress.archiveName}` : ""; + const elapsed = progress.elapsedMs && progress.elapsedMs >= 1000 + ? ` · ${Math.floor(progress.elapsedMs / 1000)}s` + : ""; + if (progress.passwordFound) { + return { + itemLabel: `Passwort gefunden${archive}`, + packageLabel: "Passwort gefunden" + }; + } + if (progress.passwordAttempt && progress.passwordTotal && progress.passwordTotal > 1) { + const passwordPercent = Math.round((progress.passwordAttempt / progress.passwordTotal) * 100); + return { + itemLabel: `Passwort knacken: ${passwordPercent}% (${progress.passwordAttempt}/${progress.passwordTotal})${archive}`, + packageLabel: `Passwort knacken: ${passwordPercent}% (${progress.passwordAttempt}/${progress.passwordTotal})` + }; + } + if (archivePercent >= 99 && progress.archiveDone !== true) { + return { + itemLabel: `Finalisieren - ${archivePercent}%${archive}${elapsed}`, + packageLabel: `Finalisieren - ${overallPercent}% (${current}/${total})${archive}${elapsed}` + }; + } + return { + itemLabel: `Entpacken ${archivePercent}%${archive}${elapsed}`, + packageLabel: `Entpacken ${overallPercent}% (${current}/${total})${archive}${elapsed}` + }; +} function getUnrestrictTimeoutMs(): number { const fromEnv = Number(process.env.RD_UNRESTRICT_TIMEOUT_MS ?? NaN); @@ -3024,8 +3061,7 @@ export class DownloadManager extends EventEmitter { } private abortPackagePostProcessing(packageId: string, reason: string, invalidateDeferred = true): Promise[] { - const tasks: Promise[] = []; - void this.extractionCoordinator.cancelPackage(packageId, reason); + const tasks: Promise[] = [this.extractionCoordinator.cancelPackage(packageId, reason)]; if (invalidateDeferred) { this.bumpPackagePostProcessVersion(packageId); } @@ -6849,8 +6885,7 @@ export class DownloadManager extends EventEmitter { return; } - this.triggerPendingExtractions(); - const runItems = Object.values(this.session.items) + const runItems = Object.values(this.session.items) .filter((item) => { if (!targetSet.has(item.packageId)) return false; if (item.status !== "queued" && item.status !== "reconnect_wait") return false; @@ -6858,6 +6893,7 @@ export class DownloadManager extends EventEmitter { return Boolean(pkg && !pkg.cancelled && pkg.enabled); }); if (runItems.length === 0) { + this.triggerPendingExtractions(); this.lifecyclePhase = "idle"; this.lifecycleReason = "Bereit"; this.persistSoon(); @@ -6881,6 +6917,7 @@ export class DownloadManager extends EventEmitter { this.lifecycleReason = "Downloads laufen"; this.session.runStartedAt = nowMs(); this.beginActiveRunContext(this.runPackageIds, this.session.runStartedAt); + this.triggerPendingExtractions(); this.session.totalDownloadedBytes = 0; this.sessionCompletedFiles = 0; this.session.summaryText = ""; @@ -6963,8 +7000,7 @@ export class DownloadManager extends EventEmitter { return; } - this.triggerPendingExtractions(); - const runItems = [...targetSet] + const runItems = [...targetSet] .map((id) => this.session.items[id]) .filter((item) => { if (!item) return false; @@ -6973,6 +7009,7 @@ export class DownloadManager extends EventEmitter { return Boolean(pkg && !pkg.cancelled && pkg.enabled); }); if (runItems.length === 0) { + this.triggerPendingExtractions(); this.lifecyclePhase = "idle"; this.lifecycleReason = "Bereit"; this.persistSoon(); @@ -6996,6 +7033,7 @@ export class DownloadManager extends EventEmitter { this.lifecycleReason = "Downloads laufen"; this.session.runStartedAt = nowMs(); this.beginActiveRunContext(this.runPackageIds, this.session.runStartedAt); + this.triggerPendingExtractions(); this.session.totalDownloadedBytes = 0; this.sessionCompletedFiles = 0; this.session.summaryText = ""; @@ -7189,6 +7227,7 @@ export class DownloadManager extends EventEmitter { public stop(options?: { parkForRestart?: boolean }): void { const parkForRestart = options?.parkForRestart === true; + const previousLifecyclePhase = this.lifecyclePhase; const wasStopping = this.lifecyclePhase === "stopping"; this.lifecycleGeneration += 1; this.lifecyclePhase = "stopping"; @@ -7200,40 +7239,72 @@ export class DownloadManager extends EventEmitter { this.healthShuttingDown = parkForRestart; const abortReason: "stop" | "shutdown" = parkForRestart ? "shutdown" : "stop"; const wasRunning = this.session.running; + const stoppedItemIds = new Set(this.runItemIds); + const stoppedPackageIds = new Set(this.runPackageIds); + const hasScopedRun = wasRunning && stoppedItemIds.size > 0; + const stopsStandalonePostProcessing = wasRunning && !hasScopedRun && previousLifecyclePhase === "postprocessing"; const stoppedRunContext = wasRunning ? this.stopActiveRunContext(this.runPackageIds, this.session.runStartedAt) : null; - this.suppressStandalonePackageResults(); + if (!stoppedRunContext || stopsStandalonePostProcessing) { + this.suppressStandalonePackageResults(); + } this.schedulerGeneration += 1; this.session.running = false; this.session.paused = false; this.session.reconnectUntil = 0; this.session.reconnectReason = ""; - this.retryAfterByItem.clear(); - this.providerStartReservations.clear(); - this.pacedStartReservationByItem.clear(); - this.retryStateByItem.clear(); + if (hasScopedRun) { + const paceKeys = new Set(); + for (const itemId of stoppedItemIds) { + const item = this.session.items[itemId]; + const paceKey = item ? this.getPacedStartKeyForItem(item) : ""; + if (paceKey) paceKeys.add(paceKey); + this.retryAfterByItem.delete(itemId); + this.pacedStartReservationByItem.delete(itemId); + this.retryStateByItem.delete(itemId); + } + for (const paceKey of paceKeys) { + if (this.countFuturePacedStarts(paceKey, nowMs()) <= 0) { + this.providerStartReservations.delete(paceKey); + } + } + } else { + this.retryAfterByItem.clear(); + this.providerStartReservations.clear(); + this.pacedStartReservationByItem.clear(); + this.retryStateByItem.clear(); + } this.lastGlobalProgressBytes = this.session.totalDownloadedBytes; this.lastGlobalProgressAt = nowMs(); this.speedEvents = []; this.speedBytesLastWindow = 0; this.speedBytesPerPackage.clear(); this.speedEventsHead = 0; - this.abortPostProcessing("stop", stoppedRunContext?.id); - for (const active of this.activeTasks.values()) { - active.abortReason = abortReason; - active.abortController.abort(abortReason); - } - for (const item of Object.values(this.session.items)) { - if (!isFinishedStatus(item.status)) { + this.abortPostProcessing("stop", stopsStandalonePostProcessing ? undefined : stoppedRunContext?.id); + for (const active of this.activeTasks.values()) { + if (hasScopedRun && !stoppedItemIds.has(active.itemId)) { + continue; + } + active.abortReason = abortReason; + active.abortController.abort(abortReason); + } + for (const item of Object.values(this.session.items)) { + if (hasScopedRun && !stoppedItemIds.has(item.id)) { + continue; + } + if (!isFinishedStatus(item.status)) { item.status = "queued"; item.speedBps = 0; const pkg = this.session.packages[item.packageId]; item.fullStatus = pkg && !pkg.enabled ? "Paket gestoppt" : "Wartet"; item.updatedAt = nowMs(); } - } - for (const pkg of Object.values(this.session.packages)) { + } + for (const pkg of Object.values(this.session.packages)) { + if (hasScopedRun && !stoppedPackageIds.has(pkg.id)) { + continue; + } if (pkg.status === "downloading" || pkg.status === "validating" || pkg.status === "extracting" || pkg.status === "integrity_check" || pkg.status === "paused" || pkg.status === "reconnect_wait") { @@ -8417,8 +8488,16 @@ export class DownloadManager extends EventEmitter { changed += 1; } - if (changed > 0) { - this.clearHybridArchiveState(pkg.id); + if (changed > 0) { + if (this.session.running) { + for (const { item } of corruptArchiveItems) { + this.runItemIds.add(item.id); + this.runOutcomes.delete(item.id); + } + this.runPackageIds.add(pkg.id); + this.trackActiveRunPackage(pkg.id); + } + this.clearHybridArchiveState(pkg.id); pkg.status = (pkg.enabled && this.session.running && !this.session.paused) ? "downloading" : "queued"; pkg.updatedAt = queuedAt; const evidence = corruptArchiveItems @@ -8823,10 +8902,10 @@ export class DownloadManager extends EventEmitter { if (pkg.status === "extracting" || pkg.status === "integrity_check") { pkg.status = (pkg.enabled && !this.session.paused) ? "queued" : "paused"; pkg.updatedAt = nowMs(); - } - - for (const itemId of pkg.itemIds) { - const item = this.session.items[itemId]; + } + + for (const itemId of pkg.itemIds) { + const item = this.session.items[itemId]; if (!item || item.status !== "completed") { continue; } @@ -9153,56 +9232,52 @@ export class DownloadManager extends EventEmitter { } } - public retryExtraction(packageId: string): void { - const pkg = this.session.packages[packageId]; - if (!pkg) return; - if (this.packagePostProcessTasks.has(packageId)) return; - this.clearHybridArchiveState(packageId); - const items = pkg.itemIds.map((id) => this.session.items[id]).filter(Boolean) as DownloadItem[]; - const completedItems = items.filter((item) => item.status === "completed"); - const targetItems = completedItems.filter((item) => !isExtractedLabel(item.fullStatus)); - if (targetItems.length === 0) return; - pkg.status = "queued"; - pkg.updatedAt = nowMs(); - for (const item of targetItems) { - if (!isExtractedLabel(item.fullStatus)) { - item.fullStatus = "Entpacken - Ausstehend"; - item.updatedAt = nowMs(); - } - } - logger.info(`Extraktion manuell wiederholt: pkg=${pkg.name}`); - this.logPackageForPackage(pkg, "INFO", "Extraktion manuell wiederholt", { - completedItems: completedItems.length, - targetedItems: targetItems.length - }); - this.beginPackageResultGeneration(packageId, false, true); - this.reactivateStandalonePackageResult(packageId); - this.manualExtractPackages.add(packageId); - this.persistSoon(); - this.emitState(true); - void this.runPackagePostProcessing(packageId).catch((err) => logger.warn(`runPackagePostProcessing Fehler (retryExtraction): ${compactErrorText(err)}`)); - } - - private armExtractNowPackage( + public async retryExtraction(packageId: string): Promise { + if (!(await this.armExtractNowPackage(packageId))) { + throw new Error("Kein entpackbarer Archivsatz ausgewählt"); + } + } + + private async armExtractNowPackage( packageId: string, selectedItemIds?: ReadonlySet, archiveFilter?: ReadonlySet - ): boolean { - const pkg = this.session.packages[packageId]; + ): Promise { + let pkg = this.session.packages[packageId]; if (!pkg || pkg.cancelled) return false; - if (this.packagePostProcessTasks.has(packageId)) return false; - this.clearHybridArchiveState(packageId); - if (!pkg.enabled) { - pkg.enabled = true; - } - const items = pkg.itemIds.map((id) => this.session.items[id]).filter(Boolean) as DownloadItem[]; - const completedItems = items.filter((item) => item.status === "completed"); - const targetItems = completedItems.filter((item) => !isExtractedLabel(item.fullStatus) && (!selectedItemIds || selectedItemIds.has(item.id))); + let items = pkg.itemIds.map((id) => this.session.items[id]).filter(Boolean) as DownloadItem[]; + let completedItems = items.filter((item) => item.status === "completed"); + let targetItems = completedItems.filter((item) => !isExtractedLabel(item.fullStatus) && (!selectedItemIds || selectedItemIds.has(item.id))); if (targetItems.length === 0) { this.manualExtractArchiveFilters.delete(packageId); this.manualExtractPackages.delete(packageId); return false; } + const initialTargetIds = new Set(targetItems.map((item) => item.id)); + if (this.packagePostProcessTasks.has(packageId) || this.hasDeferredPostProcessPending(packageId)) { + pkg.postProcessLabel = "Entpacken wird neu gestartet..."; + pkg.updatedAt = nowMs(); + this.emitState(true); + await Promise.allSettled(this.abortPackagePostProcessing(packageId, "manual_extract_restart")); + pkg = this.session.packages[packageId]; + if (!pkg || pkg.cancelled) return false; + items = pkg.itemIds.map((id) => this.session.items[id]).filter(Boolean) as DownloadItem[]; + completedItems = items.filter((item) => item.status === "completed"); + targetItems = completedItems.filter((item) => !isExtractedLabel(item.fullStatus) && (!selectedItemIds || selectedItemIds.has(item.id))); + if (targetItems.length === 0) { + pkg.postProcessLabel = undefined; + pkg.updatedAt = nowMs(); + this.emitState(true); + return [...initialTargetIds].every((itemId) => { + const item = this.session.items[itemId]; + return Boolean(item && item.status === "completed" && isExtractedLabel(item.fullStatus)); + }); + } + } + this.clearHybridArchiveState(packageId); + if (!pkg.enabled) { + pkg.enabled = true; + } if (archiveFilter) this.manualExtractArchiveFilters.set(packageId, new Set(archiveFilter)); else this.manualExtractArchiveFilters.delete(packageId); this.manualExtractPackages.add(packageId); @@ -9225,8 +9300,9 @@ export class DownloadManager extends EventEmitter { return true; } - private async extractNowItems(itemIds: readonly string[], excludedPackageIds: ReadonlySet): Promise { + private async extractNowItems(itemIds: readonly string[], excludedPackageIds: ReadonlySet): Promise { const selectedByPackage = new Map>(); + let armed = 0; for (const itemId of itemIds) { const item = this.session.items[itemId]; if (!item || excludedPackageIds.has(item.packageId)) { @@ -9238,7 +9314,7 @@ export class DownloadManager extends EventEmitter { } for (const [packageId, selectedItemIds] of selectedByPackage) { const pkg = this.session.packages[packageId]; - if (!pkg || pkg.cancelled || this.packagePostProcessTasks.has(packageId)) { + if (!pkg || pkg.cancelled) { continue; } const completedItems = pkg.itemIds @@ -9250,27 +9326,36 @@ export class DownloadManager extends EventEmitter { logger.warn(`Jetzt entpacken: Kein vollständiger Archivsatz für ${selectedItemIds.size} ausgewählte Datei(en) in pkg=${pkg.name}`); continue; } - this.armExtractNowPackage( + if (await this.armExtractNowPackage( packageId, selection.itemIds, new Set([...selection.archivePaths].map((archivePath) => pathKey(archivePath))) - ); + )) { + armed += 1; + } } + return armed; } - public extractNow(target: string | ExtractNowRequest): void { + public async extractNow(target: string | ExtractNowRequest): Promise { if (typeof target === "string") { - this.armExtractNowPackage(target); + if (!(await this.armExtractNowPackage(target))) { + throw new Error("Kein entpackbarer Archivsatz ausgewählt"); + } return; } const packageIds = [...new Set(target.packageIds)]; const packageSet = new Set(packageIds); + let armed = 0; for (const packageId of packageIds) { - this.armExtractNowPackage(packageId); + if (await this.armExtractNowPackage(packageId)) { + armed += 1; + } + } + armed += await this.extractNowItems(target.itemIds, packageSet); + if (armed === 0) { + throw new Error("Kein vollständiger entpackbarer Archivsatz ausgewählt"); } - void this.extractNowItems(target.itemIds, packageSet).catch((error) => { - logger.warn(`Jetzt entpacken für Dateiauswahl fehlgeschlagen: ${compactErrorText(error)}`); - }); } private notePackageDownloadStarted(pkg: PackageEntry, startedAt = nowMs()): void { @@ -10253,8 +10338,9 @@ export class DownloadManager extends EventEmitter { if (normalCandidate && pkgPrio === "low") continue; if (normalCandidate && pkgPrio === "normal") continue; - for (const itemId of pkg.itemIds) { - const item = this.session.items[itemId]; + for (const itemId of pkg.itemIds) { + if (this.runItemIds.size > 0 && !this.runItemIds.has(itemId)) continue; + const item = this.session.items[itemId]; if (!item) continue; const retryAfter = this.retryAfterByItem.get(itemId) || 0; if (retryAfter > now) continue; @@ -10292,8 +10378,11 @@ export class DownloadManager extends EventEmitter { const pkg = this.session.packages[packageId]; if (!pkg || pkg.cancelled || !pkg.enabled) continue; if (this.runPackageIds.size > 0 && !this.runPackageIds.has(packageId)) continue; - for (const itemId of pkg.itemIds) { - const item = this.session.items[itemId]; + for (const itemId of pkg.itemIds) { + if (this.runItemIds.size > 0 && !this.runItemIds.has(itemId)) { + continue; + } + const item = this.session.items[itemId]; if (!item) continue; if (item.status !== "queued" && item.status !== "reconnect_wait") continue; const retryAfter = this.retryAfterByItem.get(itemId) || 0; @@ -10325,9 +10414,12 @@ export class DownloadManager extends EventEmitter { } if (this.runPackageIds.size > 0 && !this.runPackageIds.has(packageId)) { continue; - } - for (const itemId of pkg.itemIds) { - const item = this.session.items[itemId]; + } + for (const itemId of pkg.itemIds) { + if (this.runItemIds.size > 0 && !this.runItemIds.has(itemId)) { + continue; + } + const item = this.session.items[itemId]; if (!item) { continue; } @@ -13585,13 +13677,22 @@ export class DownloadManager extends EventEmitter { const completedItems = items.filter((item) => item.status === "completed"); - const alreadyTried = this.hybridExtractedPaths.get(packageId); - if (alreadyTried) { - for (const key of [...readyArchives]) { - if (alreadyTried.has(key)) { - readyArchives.delete(key); - } - } + const alreadyTried = this.hybridExtractedPaths.get(packageId); + if (alreadyTried) { + for (const key of [...readyArchives]) { + if (!alreadyTried.has(key)) { + continue; + } + const archiveItems = resolveArchiveItemsFromList(path.basename(key), completedItems, key); + if (archiveItems.length === 0 || archiveItems.every((item) => isExtractedLabel(item.fullStatus))) { + readyArchives.delete(key); + } else { + alreadyTried.delete(key); + } + } + if (alreadyTried.size === 0) { + this.hybridExtractedPaths.delete(packageId); + } } const failedArchiveStates = this.hybridFailedArchives.get(packageId); @@ -13668,6 +13769,7 @@ export class DownloadManager extends EventEmitter { const autoRecoveredArchives = new Set(); const failedArchiveErrors = new Map(); const failedArchiveCategories = new Map(); + const successfulArchiveKeys = new Set(); const hybridResolvedItems = new Map(); const hybridStartTimes = new Map(); let hybridLastEmitAt = 0; @@ -13783,6 +13885,7 @@ export class DownloadManager extends EventEmitter { : formatExtractDone(doneAt - startedAt); const archiveKey = readyArchives.has(progressKey) ? progressKey : undefined; if (archiveKey && progress.archiveSuccess !== false) { + successfulArchiveKeys.add(archiveKey); this.clearHybridArchiveState(packageId, archiveKey); } this.recordArchiveOperation( @@ -13803,25 +13906,9 @@ export class DownloadManager extends EventEmitter { pkg.postProcessLabel = `Entpacken (${done}/${progress.total}) - Nächstes Archiv...`; this.emitState(); } - } else { - const archiveLabel = ` · ${progress.archiveName}`; - const elapsed = progress.elapsedMs && progress.elapsedMs >= 1000 - ? ` · ${Math.floor(progress.elapsedMs / 1000)}s` - : ""; - const archivePct = Math.max(0, Math.min(100, Math.floor(Number(progress.archivePercent ?? 0)))); - const isFinalizing = archivePct >= 99; - let label: string; - if (progress.passwordFound) { - label = `Passwort gefunden · ${progress.archiveName}`; - } else if (progress.passwordAttempt && progress.passwordTotal && progress.passwordTotal > 1) { - const pwPct = Math.round((progress.passwordAttempt / progress.passwordTotal) * 100); - label = `Passwort knacken: ${pwPct}% (${progress.passwordAttempt}/${progress.passwordTotal}) · ${progress.archiveName}`; - } else if (isFinalizing) { - label = `Finalisieren${archiveLabel}${elapsed}`; - } else { - label = `Entpacken ${archivePct}%${archiveLabel}${elapsed}`; - } - const updatedAt = nowMs(); + } else { + const label = formatExtractionProgressLabels(progress).itemLabel; + const updatedAt = nowMs(); for (const entry of archItems) { if (entry.status !== "completed" || isExtractedLabel(entry.fullStatus) || entry.fullStatus === label) continue; entry.fullStatus = label; @@ -13830,22 +13917,7 @@ export class DownloadManager extends EventEmitter { } } - const activeArchive = !archiveFinished && Number(progress.archivePercent ?? 0) > 0 ? 1 : 0; - const currentDisplay = Math.max(0, Math.min(progress.total, progress.current + activeArchive)); - if (progress.passwordFound) { - pkg.postProcessLabel = "Passwort gefunden"; - } else if (progress.passwordAttempt && progress.passwordTotal && progress.passwordTotal > 1) { - const pwPct = Math.round((progress.passwordAttempt / progress.passwordTotal) * 100); - pkg.postProcessLabel = `Passwort knacken: ${pwPct}%`; - } else if (Number(progress.archivePercent ?? 0) >= 99) { - const archive = progress.archiveName ? ` · ${progress.archiveName}` : ""; - const elapsed = progress.elapsedMs && progress.elapsedMs >= 1000 - ? ` · ${Math.floor(progress.elapsedMs / 1000)}s` - : ""; - pkg.postProcessLabel = `Finalisieren (${currentDisplay}/${progress.total})${archive}${elapsed}`; - } else { - pkg.postProcessLabel = `Entpacken ${progress.percent}% (${currentDisplay}/${progress.total})`; - } + pkg.postProcessLabel = formatExtractionProgressLabels(progress).packageLabel; const now = nowMs(); if (now - hybridLastEmitAt >= EXTRACT_PROGRESS_EMIT_INTERVAL_MS) { @@ -13861,11 +13933,12 @@ export class DownloadManager extends EventEmitter { extracted: result.extracted, failed: result.failed }); - { - let tried = this.hybridExtractedPaths.get(packageId); - if (!tried) { tried = new Set(); this.hybridExtractedPaths.set(packageId, tried); } - for (const key of readyArchives) { tried.add(key); } - } + { + let tried = this.hybridExtractedPaths.get(packageId); + if (!tried) { tried = new Set(); this.hybridExtractedPaths.set(packageId, tried); } + for (const key of successfulArchiveKeys) { tried.add(key); } + if (tried.size === 0) this.hybridExtractedPaths.delete(packageId); + } if (failedArchiveErrors.size > 0) { let failed = this.hybridFailedArchives.get(packageId); if (!failed) { @@ -14119,7 +14192,7 @@ export class DownloadManager extends EventEmitter { ); } - if (!allDone && this.settings.hybridExtract && shouldExtract && failed === 0 && success > 0) { + if (!manualExtraction && !allDone && this.settings.hybridExtract && shouldExtract && failed === 0 && success > 0) { pkg.postProcessLabel = "Entpacken vorbereiten..."; this.emitState(); const hybridExtracted = await this.runHybridExtraction(packageId, pkg, items, signal); @@ -14147,7 +14220,7 @@ export class DownloadManager extends EventEmitter { return; } - if (!allDone) { + if (!manualExtraction && !allDone) { pkg.postProcessLabel = undefined; pkg.status = (pkg.enabled && this.session.running && !this.session.paused) ? "downloading" : "queued"; logger.info(`Post-Processing verschoben: pkg=${pkg.name}, noch offene items`); @@ -14173,14 +14246,14 @@ export class DownloadManager extends EventEmitter { resolveArchiveItemsFromList(archiveName, completedItems, archivePath); let lastExtractEmitAt = 0; - const emitExtractStatus = (text: string, force = false): void => { + const emitExtractStatus = (text: string, force = false): void => { const now = nowMs(); if (!force && now - lastExtractEmitAt < EXTRACT_PROGRESS_EMIT_INTERVAL_MS) { return; } - lastExtractEmitAt = now; - pkg.postProcessLabel = text || "Entpacken..."; - this.emitState(); + lastExtractEmitAt = now; + pkg.postProcessLabel = text || "Entpacken..."; + this.emitState(force); }; const extractTimeoutMs = getPostExtractTimeoutMs(); @@ -14361,25 +14434,9 @@ export class DownloadManager extends EventEmitter { if (done < progress.total) { emitExtractStatus(`Entpacken (${done}/${progress.total}) - Nächstes Archiv...`, true); } - } else { - const archiveTag = progress.archiveName ? ` · ${progress.archiveName}` : ""; - const elapsed = progress.elapsedMs && progress.elapsedMs >= 1000 - ? ` · ${Math.floor(progress.elapsedMs / 1000)}s` - : ""; - const archivePct = Math.max(0, Math.min(100, Math.floor(Number(progress.archivePercent ?? 0)))); - const isFinalizing = archivePct >= 99; - let label: string; - if (progress.passwordFound) { - label = `Passwort gefunden · ${progress.archiveName}`; - } else if (progress.passwordAttempt && progress.passwordTotal && progress.passwordTotal > 1) { - const pwPct = Math.round((progress.passwordAttempt / progress.passwordTotal) * 100); - label = `Passwort knacken: ${pwPct}% (${progress.passwordAttempt}/${progress.passwordTotal}) · ${progress.archiveName}`; - } else if (isFinalizing) { - label = `Finalisieren${archiveTag}${elapsed}`; - } else { - label = `Entpacken ${archivePct}%${archiveTag}${elapsed}`; - } - const updatedAt = nowMs(); + } else { + const label = formatExtractionProgressLabels(progress).itemLabel; + const updatedAt = nowMs(); for (const entry of archiveItems) { if (entry.status !== "completed" || isExtractedLabel(entry.fullStatus) || entry.fullStatus === label) continue; entry.fullStatus = label; @@ -14388,24 +14445,7 @@ export class DownloadManager extends EventEmitter { } } - const archive = progress.archiveName ? ` · ${progress.archiveName}` : ""; - const elapsed = progress.elapsedMs && progress.elapsedMs >= 1000 - ? ` · ${Math.floor(progress.elapsedMs / 1000)}s` - : ""; - const activeArchive = !archiveFinished && Number(progress.archivePercent ?? 0) > 0 ? 1 : 0; - const currentDisplay = Math.max(0, Math.min(progress.total, progress.current + activeArchive)); - let overallLabel: string; - if (progress.passwordFound) { - overallLabel = `Passwort gefunden · ${progress.archiveName || ""}`; - } else if (progress.passwordAttempt && progress.passwordTotal && progress.passwordTotal > 1) { - const pwPct = Math.round((progress.passwordAttempt / progress.passwordTotal) * 100); - overallLabel = `Passwort knacken: ${pwPct}% (${progress.passwordAttempt}/${progress.passwordTotal}) · ${progress.archiveName || ""}`; - } else if (Number(progress.archivePercent ?? 0) >= 99) { - overallLabel = `Finalisieren (${currentDisplay}/${progress.total})${archive}${elapsed}`; - } else { - overallLabel = `Entpacken ${progress.percent}% (${currentDisplay}/${progress.total})${archive}${elapsed}`; - } - emitExtractStatus(overallLabel); + emitExtractStatus(formatExtractionProgressLabels(progress).packageLabel); } })); } catch (error) { @@ -14490,7 +14530,14 @@ export class DownloadManager extends EventEmitter { entry.updatedAt = finalAt; } } - if (manualArchiveFilter) { + if (manualExtraction && !allDone) { + const hasRemainingExtractError = completedItems.some((entry) => isExtractErrorLabel(entry.fullStatus || "")); + pkg.status = hasRemainingExtractError + ? "failed" + : this.session.paused + ? "paused" + : (pkg.enabled && this.session.running ? "downloading" : "queued"); + } else if (manualArchiveFilter) { const hasRemainingExtractError = completedItems.some((entry) => isExtractErrorLabel(entry.fullStatus || "")); const hasRemainingExtractWork = completedItems.some((entry) => !isExtractedLabel(entry.fullStatus || "") && /^Entpack/i.test(entry.fullStatus || "")); pkg.status = hasRemainingExtractError ? "failed" : hasRemainingExtractWork ? "queued" : "completed"; diff --git a/src/main/extractor.ts b/src/main/extractor.ts index 7b060f4..cf40a20 100644 --- a/src/main/extractor.ts +++ b/src/main/extractor.ts @@ -1452,11 +1452,23 @@ function parseProgressPercent(chunk: string): number | null { return latest; } -function nextArchivePercent(previous: number, incoming: number): number { +function nextArchivePercent(previous: number, incoming: number): number { const prev = Math.max(0, Math.min(100, Math.floor(Number(previous) || 0))); const next = Math.max(0, Math.min(100, Math.floor(Number(incoming) || 0))); - return next >= prev ? next : prev; -} + return next >= prev ? next : prev; +} + +type ExtractPasswordProgress = Pick; + +export function mergeExtractPasswordProgress( + current: ExtractPasswordProgress | undefined, + update: ExtractPasswordProgress | undefined +): ExtractPasswordProgress | undefined { + if (update?.passwordFound || (update?.passwordAttempt && update?.passwordTotal)) { + return { ...update }; + } + return current; +} function runExtractCommand( command: string, @@ -4088,12 +4100,13 @@ export async function extractPackageArchives(options: ExtractOptions): Promise { - emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt, undefined, undefined, archivePath); + const pulseTimer = setInterval(() => { + emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt, activePasswordProgress, undefined, archivePath); }, 1100); const hybrid = Boolean(options.hybridMode); const filenamePasswords = archiveFilenamePasswords(archiveName); @@ -4110,7 +4123,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise { - emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt, { passwordAttempt: attempt, passwordTotal: total }, undefined, archivePath); + ? (attempt: number, total: number) => { + activePasswordProgress = mergeExtractPasswordProgress(activePasswordProgress, { passwordAttempt: attempt, passwordTotal: total }); + emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt, activePasswordProgress, undefined, archivePath); options.onLog?.("INFO", `Passwort-Versuch ${attempt}/${total}: archive=${archiveName}, password=`); } : undefined; diff --git a/src/renderer/i18n.ts b/src/renderer/i18n.ts index f21f8af..6dd5153 100644 --- a/src/renderer/i18n.ts +++ b/src/renderer/i18n.ts @@ -175,6 +175,7 @@ const pairs = [ ["Token neu (Code ungueltig machen)", "New token (invalidate code)"], ["Enthaelt das Zugriffstoken - wie ein Passwort behandeln. Token neu = alter Code wird sofort ungueltig.", "Contains the access token - treat it like a password. New token = old code becomes invalid immediately."], ["Möchtest Du wirklich diese Aufräumaktion(en) durchführen?", "Do you really want to perform these cleanup action(s)?"], ["Ausgewählte Links löschen", "Delete selected links"], ["Nicht mehr anzeigen", "Do not show again"], ["Paket bereits entpackt", "Package already extracted"], ["ist im Ziel bereits vorhanden.", "already exists at the destination."], ["Für alle weiteren Pakete dieselbe Auswahl verwenden", "Use the same selection for all remaining packages"], + ["Link-Umwandlung erneut", "Retrying link conversion"], ["Entpacktes überspringen", "Skip extracted content"], ["Links, .dlc oder Export-Dateien hier ablegen", "Drop links, .dlc or export files here"], ["Account prüfen", "Check account"], ["Account aktivieren", "Enable account"], ["Account deaktivieren", "Disable account"], ["Ausgewählte Downloads starten", "Start selected downloads"], ["Alle Downloads starten", "Start all downloads"], ["Linkadressen anzeigen", "Show link addresses"], ["Paket exportieren", "Export package"], ["Log öffnen", "Open log"], ["Item-Log öffnen", "Open item log"], ["Jetzt entpacken", "Extract now"], @@ -227,6 +228,8 @@ function translatePackageStatusParts(value: string, language: AppLanguage): stri if (parts.length < 2) return null; const translated = parts.map((part): string | null => { if (language === "en") { + const exact = deToEn.get(part); + if (exact) return exact; const extractionError = part.match(/^(\d+) Entpackfehler$/); if (extractionError) return `${extractionError[1]} extraction error${extractionError[1] === "1" ? "" : "s"}`; const retry = part.match(/^(\d+) Wiederholung(?:en)?$/); @@ -237,6 +240,8 @@ function translatePackageStatusParts(value: string, language: AppLanguage): stri if (cancelled) return `${cancelled[1]} cancelled`; return null; } + const exact = enToDe.get(part); + if (exact) return exact; const extractionError = part.match(/^(\d+) extraction errors?$/); if (extractionError) return `${extractionError[1]} Entpackfehler`; const retry = part.match(/^(\d+) retr(?:y|ies)$/); diff --git a/src/renderer/views/downloads/DownloadsTable.tsx b/src/renderer/views/downloads/DownloadsTable.tsx index 98760f3..10cddd7 100644 --- a/src/renderer/views/downloads/DownloadsTable.tsx +++ b/src/renderer/views/downloads/DownloadsTable.tsx @@ -175,6 +175,8 @@ export function compactDownloadStatus(value: string): string { if (extractingEnglish) return `Extracting - ${extractingEnglish[1]}%`; const finalizing = status.match(/^(Finalisieren|Finalizing)\b/i); if (finalizing) { + const percentage = status.match(/-\s*(-?\d+(?:\.\d+)?)%/); + if (percentage) return `${finalizing[1]} - ${progress(Number(percentage[1]))}%`; const fraction = status.match(/\(([^)]*)\)/); if (fraction) { const values = fraction[1].split("/"); @@ -184,8 +186,6 @@ export function compactDownloadStatus(value: string): string { if (Number.isFinite(current) && Number.isFinite(total) && total > 0) return `${finalizing[1]} - ${progress((current / total) * 100)}%`; return finalizing[1]; } - const percentage = status.match(/-\s*(-?\d+(?:\.\d+)?)%/); - if (percentage) return `${finalizing[1]} - ${progress(Number(percentage[1]))}%`; return finalizing[1]; } return status; @@ -205,7 +205,7 @@ function DownloadMeter({ value, text }: { value: number; text: string }): ReactE function DownloadStatusCell({ status, title }: { status: string; title?: string }): ReactElement { const visibleStatus = compactDownloadStatus(status); - const statusTitle = /^(Finalisieren|Finalizing)\b/i.test(status) ? visibleStatus : title || status; + const statusTitle = title || status; return ( @@ -392,7 +392,8 @@ function packageCell(row: DownloadPackageRow, column: string, packageSpeedBps: n const postProcessLabel = entry.status === "extracting" && compactPostProcessLabel === rawPostProcessLabel && /(?:^|[\\/])[^\\/]+\.(?:rar|zip|7z|tar|gz|bz2|xz)(?:\.\d+)?$/i.test(rawPostProcessLabel) ? "Entpacken - Ausstehend" : compactPostProcessLabel; - const details = `${presentation.details}${postProcessLabel ? ` · ${postProcessLabel}` : ""}${audio ? ` · ${audio.text}` : ""}`; + const detailPostProcessLabel = rawPostProcessLabel || postProcessLabel; + const details = `${presentation.details}${detailPostProcessLabel ? ` · ${detailPostProcessLabel}` : ""}${audio ? ` · ${audio.text}` : ""}`; const status = presentation.extractFailureCount === 0 && presentation.retryCount === 0 && postProcessLabel diff --git a/src/renderer/views/downloads/package-presentation.ts b/src/renderer/views/downloads/package-presentation.ts index 4c6cf34..5fea16d 100644 --- a/src/renderer/views/downloads/package-presentation.ts +++ b/src/renderer/views/downloads/package-presentation.ts @@ -21,7 +21,7 @@ export interface PackagePresentation { } function extractionPercent(fullStatus: string): number { - const match = fullStatus.match(/^Entpacken\s+(\d+)%/i); + const match = fullStatus.match(/^(?:Entpacken\s+|Finalisieren\s*-\s*)(\d+)%/i); return match ? Math.max(0, Math.min(100, Number(match[1]))) / 100 : 0; } @@ -30,7 +30,7 @@ function isExtractFailure(fullStatus: string): boolean { } function isExtractionLifecycle(fullStatus: string): boolean { - return /^(?:Entpack|Passwort)/i.test(fullStatus); + return /^(?:Entpack|Passwort|Finalisieren)/i.test(fullStatus); } function isArchiveItem(item: DownloadItem): boolean { @@ -42,6 +42,10 @@ function isRetrying(item: DownloadItem): boolean { || (item.retries > 0 && (item.status === "queued" || item.status === "validating" || item.status === "reconnect_wait")); } +function isLinkConversionRetry(item: DownloadItem): boolean { + return /(?:Link-Umwandlung erneut|Retrying link conversion)/i.test(item.fullStatus || ""); +} + function downloadFraction(item: DownloadItem): number { if (item.status === "completed") { return 1; @@ -65,6 +69,7 @@ export function buildPackagePresentation(row: DownloadPackageRow): PackagePresen || (row.allItems.some(isArchiveItem) && !row.allItems.every((item) => /^Fertig\b/i.test(item.fullStatus || ""))); let extracting = 0; let retrying = 0; + let linkConversionRetrying = 0; let waitsForDisk = 0; const extractFailures: DownloadItem[] = []; @@ -79,7 +84,7 @@ export function buildPackagePresentation(row: DownloadPackageRow): PackagePresen extractionLifecycle = true; } else { const progress = extractionPercent(fullStatus); - if (progress > 0 || /^Entpacken\b/i.test(fullStatus)) { + if (progress > 0 || /^(?:Entpacken|Finalisieren)\b/i.test(fullStatus)) { extracting += 1; extractionUnits += progress; } @@ -90,7 +95,10 @@ export function buildPackagePresentation(row: DownloadPackageRow): PackagePresen extractionLifecycle = true; } } - if (isRetrying(item)) retrying += 1; + if (isRetrying(item)) { + retrying += 1; + if (isLinkConversionRetry(item)) linkConversionRetrying += 1; + } if (/Warte auf Festplatte/i.test(fullStatus)) waitsForDisk += 1; } @@ -101,8 +109,11 @@ export function buildPackagePresentation(row: DownloadPackageRow): PackagePresen const value = allExtracted ? 100 : Math.min(extractionLifecycle ? 99 : 100, downloadValue + extractionValue); const parts: string[] = []; + const retryLabel = linkConversionRetrying > 0 + ? "Link-Umwandlung erneut" + : `${retrying} Wiederholung${retrying === 1 ? "" : "en"}`; if (extractFailures.length > 0) parts.push(`${extractFailures.length} Entpackfehler`); - if (retrying > 0) parts.push(`${retrying} Wiederholung${retrying === 1 ? "" : "en"}`); + if (retrying > 0) parts.push(retryLabel); if (failed > 0) parts.push(`${failed} Fehler`); if (cancelled > 0) parts.push(`${cancelled} abgebrochen`); const details = parts.length > 0 ? parts.join(" · ") : done >= total ? "Fertig" : `${done}/${total} fertig`; @@ -114,7 +125,7 @@ export function buildPackagePresentation(row: DownloadPackageRow): PackagePresen let status = allExtracted ? "Entpackt" : details; if (extractFailures.length > 0 && retrying > 0) { - status = `${extractFailures.length} Entpackfehler · ${retrying} Wiederholung${retrying === 1 ? "" : "en"}`; + status = `${extractFailures.length} Entpackfehler · ${retryLabel}`; } else if (extractFailures.length > 0) { status = downloadsComplete ? `Download fertig · ${extractFailures.length} Entpackfehler` : `${extractFailures.length} Entpackfehler`; } else if (waitsForDisk > 0) { @@ -122,7 +133,7 @@ export function buildPackagePresentation(row: DownloadPackageRow): PackagePresen } else if (extracting > 0 || row.package.status === "extracting") { status = packageExtractLabel || "Entpacken"; } else if (retrying > 0) { - status = `${retrying} Wiederholung${retrying === 1 ? "" : "en"}`; + status = retryLabel; } else if (downloading) { status = "Download läuft"; } diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index ae598f2..4e93479 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -63,6 +63,95 @@ describe("runWithLimitedConcurrency", () => { }); }); +describe("selected item run scope", () => { + function createSelectedItemManager(root: string): { manager: DownloadManager; packageId: string; itemIds: string[] } { + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + autoExtract: true, + hybridExtract: true, + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract") + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + manager.addPackages([{ name: "selected-items", links: ["https://dummy/first", "https://dummy/second"] }]); + const snapshot = manager.getSnapshot().session; + const packageId = snapshot.packageOrder[0]; + return { manager, packageId, itemIds: snapshot.packages[packageId].itemIds }; + } + + it("creates the active run context before triggering pending hybrid extraction", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-selected-owner-")); + tempDirs.push(root); + const { manager, itemIds } = createSelectedItemManager(root); + const internal = manager as any; + const owners: Array = []; + internal.ensureScheduler = async () => {}; + internal.triggerPendingExtractions = () => owners.push(internal.activeRunContextId); + + await internal.startItemsNow([itemIds[1]]); + + expect(owners).toHaveLength(1); + expect(owners[0]).toBeTypeOf("string"); + expect(owners[0]).toBe(internal.activeRunContextId); + }); + + it("never schedules an unselected queued sibling from the same package", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-selected-scope-")); + tempDirs.push(root); + const { manager, itemIds } = createSelectedItemManager(root); + const internal = manager as any; + internal.ensureScheduler = async () => {}; + internal.triggerPendingExtractions = () => {}; + + await internal.startItemsNow([itemIds[0]]); + expect(internal.findNextQueuedItem()).toEqual(expect.objectContaining({ itemId: itemIds[0] })); + internal.session.items[itemIds[0]].status = "downloading"; + + expect(internal.findNextQueuedItem()).toBeNull(); + expect(internal.getQueuePresence()).toEqual({ hasImmediate: false, hasDelayed: false }); + }); + + it("stops only selected run items without erasing sibling wait state", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-selected-stop-")); + tempDirs.push(root); + const { manager, packageId, itemIds } = createSelectedItemManager(root); + const internal = manager as any; + internal.ensureScheduler = async () => {}; + internal.triggerPendingExtractions = () => {}; + + await internal.startItemsNow([itemIds[0]]); + internal.session.items[itemIds[0]].status = "downloading"; + internal.session.items[itemIds[0]].fullStatus = "Download läuft"; + internal.session.items[itemIds[1]].status = "reconnect_wait"; + internal.session.items[itemIds[1]].fullStatus = "Unselektierter Backoff"; + internal.session.packages[packageId].status = "downloading"; + internal.retryAfterByItem.set(itemIds[0], 100); + internal.retryAfterByItem.set(itemIds[1], 200); + internal.retryStateByItem.set(itemIds[0], { freshRetryUsed: true, resumeHardResetUsed: false }); + internal.retryStateByItem.set(itemIds[1], { freshRetryUsed: false, resumeHardResetUsed: true }); + internal.pacedStartReservationByItem.set(itemIds[0], 100); + internal.pacedStartReservationByItem.set(itemIds[1], 200); + internal.standalonePackageResults.add("foreign-package:1"); + + manager.stop(); + + expect(internal.session.items[itemIds[0]]).toEqual(expect.objectContaining({ status: "queued", fullStatus: "Wartet" })); + expect(internal.session.items[itemIds[1]]).toEqual(expect.objectContaining({ status: "reconnect_wait", fullStatus: "Unselektierter Backoff" })); + expect(internal.retryAfterByItem.has(itemIds[0])).toBe(false); + expect(internal.retryAfterByItem.get(itemIds[1])).toBe(200); + expect(internal.retryStateByItem.has(itemIds[0])).toBe(false); + expect(internal.retryStateByItem.has(itemIds[1])).toBe(true); + expect(internal.pacedStartReservationByItem.has(itemIds[0])).toBe(false); + expect(internal.pacedStartReservationByItem.get(itemIds[1])).toBe(200); + expect(internal.standalonePackageResults.has("foreign-package:1")).toBe(true); + expect(internal.suppressedPackageResults.has("foreign-package:1")).toBe(false); + }); +}); + describe("download live update cadence", () => { it.each([69, 661, 2_470])("emits a running queue snapshot no sooner than 750 ms for %i items", async (itemCount) => { vi.useFakeTimers(); @@ -2311,7 +2400,7 @@ describe("download manager", () => { expect((manager as any).shouldCollapseQuickPostProcessRequeue(packageId)).toBe(false); }); - it("extractNow only re-arms completed items that are not already extracted", () => { + it("extractNow only re-arms completed items that are not already extracted", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-now-")); tempDirs.push(root); @@ -2377,13 +2466,21 @@ describe("download manager", () => { autoExtract: true, hybridExtract: true }, - session, - createStoragePaths(path.join(root, "state")) - ); - - manager.extractNow(packageId); - - expect((manager as any).session.items["extract-now-item-1"].fullStatus).toBe("Entpackt - Done (<1s)"); + session, + createStoragePaths(path.join(root, "state")) + ); + const staleController = new AbortController(); + const restartPostProcessing = vi.fn(() => Promise.resolve()); + (manager as any).packagePostProcessTasks.set(packageId, Promise.resolve()); + (manager as any).packagePostProcessAbortControllers.set(packageId, staleController); + (manager as any).runPackagePostProcessing = restartPostProcessing; + session.packages[packageId].status = "paused"; + + await manager.extractNow(packageId); + + expect(staleController.signal.aborted).toBe(true); + expect(restartPostProcessing).toHaveBeenCalledTimes(1); + expect((manager as any).session.items["extract-now-item-1"].fullStatus).toBe("Entpackt - Done (<1s)"); expect((manager as any).session.items["extract-now-item-2"].fullStatus).toBe("Entpackt - Done (1.2s)"); expect((manager as any).session.items["extract-now-item-3"].fullStatus).toBe("Entpacken - Ausstehend"); expect((manager as any).session.packages[packageId].status).toBe("queued"); @@ -2543,6 +2640,111 @@ describe("download manager", () => { expect(fs.existsSync(path.join(extractDir, "Episode.E02.mkv"))).toBe(false); }, 15_000); + it.each([ + ["package", false], + ["package", true], + ["item", false], + ["item", true] + ] as const)("extractNow %s runs with an open sibling while session paused=%s", async (scope, paused) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), `rd-extract-open-${scope}-${paused}-`)); + tempDirs.push(root); + const outputDir = path.join(root, "downloads", "Open sibling"); + const extractDir = path.join(root, "extract", "Open sibling"); + fs.mkdirSync(outputDir, { recursive: true }); + const archivePath = path.join(outputDir, "Episode.E01.zip"); + const zip = new AdmZip(); + zip.addFile("Episode.E01.mkv", Buffer.from("episode-one")); + zip.writeZip(archivePath); + const archiveSize = fs.statSync(archivePath).size; + const session = emptySession(); + const packageId = `open-${scope}-${paused}`; + const archiveItemId = `${packageId}-archive`; + const queuedItemId = `${packageId}-queued`; + const createdAt = Date.now() - 1000; + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "Open sibling", + outputDir, + extractDir, + status: paused ? "paused" : "queued", + itemIds: [archiveItemId, queuedItemId], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + session.items[archiveItemId] = { + id: archiveItemId, + packageId, + url: "https://dummy/Episode.E01.zip", + provider: "realdebrid", + status: "completed", + retries: 0, + speedBps: 0, + downloadedBytes: archiveSize, + totalBytes: archiveSize, + progressPercent: 100, + fileName: "Episode.E01.zip", + targetPath: archivePath, + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Entpacken - Ausstehend", + createdAt, + updatedAt: createdAt + }; + session.items[queuedItemId] = { + id: queuedItemId, + packageId, + url: "https://dummy/Episode.E02.zip", + provider: "realdebrid", + status: "queued", + retries: 0, + speedBps: 0, + downloadedBytes: 0, + totalBytes: null, + progressPercent: 0, + fileName: "Episode.E02.zip", + targetPath: "", + resumable: true, + attempts: 0, + lastError: "", + fullStatus: "Wartet", + createdAt, + updatedAt: createdAt + }; + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir, + extractDir, + autoExtract: false, + hybridExtract: false, + cleanupMode: "none", + removeLinkFilesAfterExtract: false, + removeSamplesAfterExtract: false, + autoRename4sf4sj: false, + keepGermanAudioOnly: false + }, + session, + createStoragePaths(path.join(root, "state")) + ); + session.running = paused; + session.paused = paused; + + manager.extractNow(scope === "package" + ? { packageIds: [packageId], itemIds: [] } + : { packageIds: [], itemIds: [archiveItemId] }); + + await waitFor(() => fs.existsSync(path.join(extractDir, "Episode.E01.mkv")), 10_000); + await waitFor(() => !(manager as any).packagePostProcessTasks.has(packageId), 10_000); + expect(session.items[archiveItemId].fullStatus).toMatch(/^Entpackt/); + expect(session.items[queuedItemId]).toEqual(expect.objectContaining({ status: "queued", fullStatus: "Wartet" })); + expect(session.packages[packageId].status).toBe(paused ? "paused" : "queued"); + }, 15_000); + it("assigns same-named archive failures only to the matching directory", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-failure-scope-")); tempDirs.push(root); @@ -7010,7 +7212,14 @@ describe("download manager", () => { createStoragePaths(path.join(root, "state")) ); - const changed = (manager as any).autoRecoverArchiveCrcFailure( + session.running = true; + (manager as any).runItemIds.add("selected-item"); + (manager as any).runPackageIds.add(packageId); + for (const itemId of itemIds) { + (manager as any).runOutcomes.set(itemId, "completed"); + } + + const changed = (manager as any).autoRecoverArchiveCrcFailure( session.packages[packageId], itemIds.map((itemId) => session.items[itemId]!), { @@ -7033,10 +7242,14 @@ describe("download manager", () => { expect(item.attempts).toBe(0); expect(item.fullStatus).toContain("Auto-Recovery"); } - expect(fs.existsSync(path.join(outputDir, archiveNames[0]!))).toBe(false); - expect(fs.existsSync(path.join(outputDir, archiveNames[1]!))).toBe(false); - expect(session.packages[packageId]?.status).toBe("queued"); - }); + expect(fs.existsSync(path.join(outputDir, archiveNames[0]!))).toBe(false); + expect(fs.existsSync(path.join(outputDir, archiveNames[1]!))).toBe(false); + expect(session.packages[packageId]?.status).toBe("downloading"); + for (const itemId of itemIds) { + expect((manager as any).runItemIds.has(itemId)).toBe(true); + expect((manager as any).runOutcomes.has(itemId)).toBe(false); + } + }); it("requeues archive parts on CRC error when file has invalid archive signature (corrupt content)", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); @@ -7393,7 +7606,79 @@ describe("download manager", () => { expect(Array.from(ready)).toEqual([part1Path.toLowerCase()]); }); - it("skips unchanged hybrid archives after a previous extraction failure", async () => { + it("retries a complete archive that was marked attempted without a terminal extraction result", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-hybrid-stale-attempt-")); + tempDirs.push(root); + const outputDir = path.join(root, "downloads", "stale-attempt"); + const extractDir = path.join(root, "extract", "stale-attempt"); + fs.mkdirSync(outputDir, { recursive: true }); + const archivePath = path.join(outputDir, "Episode.E01.zip"); + const zip = new AdmZip(); + zip.addFile("Episode.E01.mkv", Buffer.from("episode")); + zip.writeZip(archivePath); + const archiveSize = fs.statSync(archivePath).size; + const session = emptySession(); + const packageId = "stale-attempt-pkg"; + const itemId = "stale-attempt-item"; + const createdAt = Date.now() - 1000; + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "stale-attempt", + outputDir, + extractDir, + status: "queued", + itemIds: [itemId], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + session.items[itemId] = { + id: itemId, + packageId, + url: "https://dummy/Episode.E01.zip", + provider: "realdebrid", + status: "completed", + retries: 0, + speedBps: 0, + downloadedBytes: archiveSize, + totalBytes: archiveSize, + progressPercent: 100, + fileName: "Episode.E01.zip", + targetPath: archivePath, + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Entpacken - Warten auf Parts", + createdAt, + updatedAt: createdAt + }; + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir, + extractDir, + autoExtract: true, + hybridExtract: true, + cleanupMode: "none", + autoRename4sf4sj: false, + keepGermanAudioOnly: false + }, + session, + createStoragePaths(path.join(root, "state")) + ); + (manager as any).hybridExtractedPaths.set(packageId, new Set([archivePath.toLowerCase()])); + + const extracted = await (manager as any).runHybridExtraction(packageId, session.packages[packageId], [session.items[itemId]]); + + expect(extracted).toBe(1); + expect(fs.existsSync(path.join(extractDir, "Episode.E01.mkv"))).toBe(true); + expect(session.items[itemId].fullStatus).toMatch(/^Entpackt/); + }, 10_000); + + it("skips unchanged hybrid archives after a previous extraction failure", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); tempDirs.push(root); diff --git a/tests/downloads-view.test.tsx b/tests/downloads-view.test.tsx index e9212bc..218b4b8 100644 --- a/tests/downloads-view.test.tsx +++ b/tests/downloads-view.test.tsx @@ -1691,7 +1691,7 @@ describe("download table row contracts", () => { ["package", "Finalizing - 50% * release.part1.rar", "Finalizing - 50%"], ["item", "Finalizing (3/4) · release.part1.rar", "Finalizing - 75%"], ["item", "Finalisieren - 50% * release.part1.rar", "Finalisieren - 50%"] - ])("shows %s finalization status without archive details", (target, rawStatus, expectedStatus) => { + ])("shows compact %s finalization text while retaining archive details in the tooltip", (target, rawStatus, expectedStatus) => { const html = target === "package" ? renderToStaticMarkup(PackageCardContent({ actions: createActions(), @@ -1720,10 +1720,11 @@ describe("download table row contracts", () => { expect(html).toContain(`aria-label="${expectedStatus}"`); expect(html.match(new RegExp(`>${expectedStatus}`, "g"))).toHaveLength(2); expect(html).not.toContain(">release.part1.rar"); - if (target === "item") expect(html).not.toMatch(/title="[^"]*release\.part1\.rar/); + expect(html).toMatch(/title="[^"]*release\.part1\.rar/); }); it.each([ + ["Finalisieren - 99% (0/1) · release.part1.rar", "Finalisieren - 99%"], ["Finalisieren (3/2) · release.part1.rar", "Finalisieren - 100%"], ["Finalizing (-1/2) · release.part1.rar", "Finalizing - 0%"], ["Finalisieren (/2) · release.part1.rar", "Finalisieren"], @@ -2210,7 +2211,7 @@ describe("download table row contracts", () => { selectedVersion: 0 })); - expect(html).toMatch(/title="0\/1 fertig · Entpacken - 1% · Tonspur: 1 OK[^\"]*episode\.mkv: remuxed \(German kept\)"/s); + expect(html).toMatch(/title="0\/1 fertig · Entpacken 1% · Tonspur: 1 OK[^\"]*episode\.mkv: remuxed \(German kept\)"/s); }); it("shows only a compact extraction error while retaining diagnostics in the tooltip", () => { diff --git a/tests/extraction-progress-label.test.ts b/tests/extraction-progress-label.test.ts new file mode 100644 index 0000000..2b14635 --- /dev/null +++ b/tests/extraction-progress-label.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import * as downloadManagerModule from "../src/main/download-manager"; + +describe("extraction progress labels", () => { + it("keeps a finalizing archive at its real 99 percent instead of counting it as complete", () => { + const format = (downloadManagerModule as Record).formatExtractionProgressLabels as ((progress: Record) => { + itemLabel: string; + packageLabel: string; + }) | undefined; + expect(format).toBeTypeOf("function"); + if (!format) return; + + expect(format({ + current: 0, + total: 1, + percent: 99, + archiveName: "release.part01.rar", + archivePercent: 99, + elapsedMs: 17_000 + })).toEqual({ + itemLabel: "Finalisieren - 99% · release.part01.rar · 17s", + packageLabel: "Finalisieren - 99% (0/1) · release.part01.rar · 17s" + }); + }); + + it("keeps password attempts more important than a stale 99 percent archive value", () => { + const format = (downloadManagerModule as Record).formatExtractionProgressLabels as ((progress: Record) => { + itemLabel: string; + packageLabel: string; + }) | undefined; + expect(format).toBeTypeOf("function"); + if (!format) return; + + expect(format({ + current: 0, + total: 1, + percent: 99, + archiveName: "release.part01.rar", + archivePercent: 99, + passwordAttempt: 7, + passwordTotal: 7 + })).toEqual({ + itemLabel: "Passwort knacken: 100% (7/7) · release.part01.rar", + packageLabel: "Passwort knacken: 100% (7/7)" + }); + }); +}); diff --git a/tests/extractor-password-progress.test.ts b/tests/extractor-password-progress.test.ts new file mode 100644 index 0000000..ce6f906 --- /dev/null +++ b/tests/extractor-password-progress.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import * as extractorModule from "../src/main/extractor"; + +describe("extractor password progress", () => { + it("retains the active password attempt across pulse and percentage updates", () => { + const merge = (extractorModule as Record).mergeExtractPasswordProgress as (( + current: Record | undefined, + update: Record | undefined + ) => Record | undefined) | undefined; + expect(merge).toBeTypeOf("function"); + if (!merge) return; + + const secondAttempt = { passwordAttempt: 2, passwordTotal: 7 }; + expect(merge(undefined, secondAttempt)).toEqual(secondAttempt); + expect(merge(secondAttempt, undefined)).toEqual(secondAttempt); + expect(merge(secondAttempt, { passwordAttempt: 3, passwordTotal: 7 })).toEqual({ + passwordAttempt: 3, + passwordTotal: 7 + }); + }); +}); diff --git a/tests/i18n.test.ts b/tests/i18n.test.ts index f9c9aab..a70a782 100644 --- a/tests/i18n.test.ts +++ b/tests/i18n.test.ts @@ -172,6 +172,8 @@ describe("renderer localization", () => { ["Tonspur: 2 OK · 1 ohne DE-Tag · ffmpeg fehlt · 3 Fehler", "Audio track: 2 OK · 1 without DE tag · ffmpeg missing · 3 errors"], ["4/8 fertig · 2 Fehler", "4/8 completed · 2 errors"], ["7 Entpackfehler · 1 Wiederholung", "7 extraction errors · 1 retry"], + ["Link-Umwandlung erneut", "Retrying link conversion"], + ["7 Entpackfehler · Link-Umwandlung erneut", "7 extraction errors · Retrying link conversion"], ["Download fertig · 1 Entpackfehler", "Download complete · 1 extraction error"], ["Jetzt entpacken (2)", "Extract now (2)"], ["1 Entpackfehler", "1 extraction error"], diff --git a/tests/package-presentation.test.ts b/tests/package-presentation.test.ts index fd2a775..10efcd4 100644 --- a/tests/package-presentation.test.ts +++ b/tests/package-presentation.test.ts @@ -85,6 +85,15 @@ describe("download package presentation", () => { expect(after.progress.value).toBe(90); }); + it("includes finalization progress in the reserved extraction range", () => { + const presentation = buildPackagePresentation(row([ + item("archive", "Finalisieren - 99% · release.part01.rar") + ], { status: "extracting", postProcessLabel: "Finalisieren - 99% (0/1) · release.part01.rar" })); + + expect(presentation.progress.value).toBe(99); + expect(presentation.status).toBe("Finalisieren - 99% (0/1) · release.part01.rar"); + }); + it("summarizes mixed extraction errors and a live retry instead of showing a fraction", () => { const items = [ ...Array.from({ length: 7 }, (_, index) => item(`failed-${index}`, "Entpack-Fehler: Keine entpackten Dateien erkannt")), @@ -97,9 +106,23 @@ describe("download package presentation", () => { ]; const presentation = buildPackagePresentation(row(items, { status: "queued" })); - expect(presentation.status).toBe("7 Entpackfehler · 1 Wiederholung"); + expect(presentation.status).toBe("7 Entpackfehler · Link-Umwandlung erneut"); expect(presentation.details).toContain("7 Entpackfehler"); - expect(presentation.details).toContain("1 Wiederholung"); + expect(presentation.details).toContain("Link-Umwandlung erneut"); + }); + + it("describes parallel link conversion retries without presenting the item count as attempts", () => { + const retries = Array.from({ length: 20 }, (_, index) => item(`retry-${index}`, "Link-Umwandlung erneut, Versuch 2/...", { + status: "validating", + retries: 2, + downloadedBytes: 0, + progressPercent: 0 + })); + + const presentation = buildPackagePresentation(row(retries, { status: "queued" })); + + expect(presentation.status).toBe("Link-Umwandlung erneut"); + expect(presentation.status).not.toContain("20"); }); it("keeps a single normal active download compact", () => {