From 2123a48beaf1228b4efe89a90506894075ce1ecd Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Sun, 8 Mar 2026 04:49:13 +0100 Subject: [PATCH] Fix resume completion and rar fallback handling --- src/main/download-manager.ts | 173 ++++++++++++++++++++++++--------- src/main/extractor.ts | 139 ++++++++++++++++++++++---- tests/download-manager.test.ts | 63 +++++++++++- tests/extractor.test.ts | 20 ++++ 4 files changed, 332 insertions(+), 63 deletions(-) diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index b22019f..102c2a5 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -4339,6 +4339,103 @@ export class DownloadManager extends EventEmitter { } } + private tryFinalizeItemFromDisk( + pkg: PackageEntry, + item: DownloadItem, + source: string, + errorText = "" + ): boolean { + const diskState = inspectPackageItemDiskState(pkg, item); + const normalizedError = compactErrorText(errorText).replace(/^Error:\s*/i, ""); + const knownShortfall = item.totalBytes != null && item.totalBytes > 0 + ? Math.max(0, item.totalBytes - diskState.size) + : 0; + const underflowIndicated = normalizedError.includes("download_underflow") + || normalizedError.includes("resume_download_underflow"); + const archiveLikeTarget = String(item.fileName || diskState.diskPath || "").toLowerCase(); + const archiveLike = /(?:\.part\d+\.rar|\.rar|\.r\d{2,3}|\.zip(?:\.\d+)?|\.7z(?:\.\d+)?|\.(?:tar(?:\.(?:gz|bz2|xz))?|tgz|tbz2|txz)|\.\d{3})$/i.test(archiveLikeTarget); + const looksComplete = diskState.exists + && diskState.fullOnDisk + && ( + diskState.reason === "ok" + || item.progressPercent >= 100 + || item.downloadedBytes >= diskState.minBytes + || (item.totalBytes != null && item.totalBytes > 0 && diskState.size >= item.totalBytes - ALLOCATION_UNIT_SIZE) + ); + if (!looksComplete || (knownShortfall > 0 && (underflowIndicated || archiveLike))) { + return false; + } + + logger.info( + `${source}: ${item.fileName || item.id} ist bereits vollstaendig auf Disk ` + + `(${humanSize(diskState.size)}, erwartet mind. ${humanSize(diskState.minBytes)})` + ); + this.logPackageForItem(item, "INFO", `${source}: Datei bereits vollstaendig`, { + fileSize: diskState.size, + expectedMin: diskState.minBytes, + diskReason: diskState.reason, + error: errorText || undefined + }); + + item.status = "completed"; + item.fullStatus = this.settings.autoExtract + ? "Entpacken - Ausstehend" + : `Fertig (${humanSize(diskState.size)})`; + item.downloadedBytes = diskState.size; + if (!item.totalBytes || item.totalBytes < diskState.size) { + item.totalBytes = diskState.size; + } + item.progressPercent = 100; + item.speedBps = 0; + item.updatedAt = nowMs(); + pkg.updatedAt = nowMs(); + this.recordRunOutcome(item.id, "completed"); + + if (this.session.running) { + void this.runPackagePostProcessing(pkg.id).catch((err) => { + logger.warn(`runPackagePostProcessing Fehler (${source}): ${compactErrorText(err)}`); + }).finally(() => { + this.applyCompletedCleanupPolicy(pkg.id, item.id); + this.persistSoon(); + this.emitState(); + }); + } + + this.persistSoon(); + this.emitState(); + this.retryStateByItem.delete(item.id); + return true; + } + + private areAllPackageItemRefsFinished(pkg: PackageEntry): boolean { + return pkg.itemIds.every((itemId) => { + const item = this.session.items[itemId]; + return item != null && isFinishedStatus(item.status); + }); + } + + private async findFullExtractArchiveSet(pkg: PackageEntry, completedItems: DownloadItem[]): Promise> { + const relevant = new Set(); + if (!pkg.outputDir || completedItems.length === 0) { + return relevant; + } + + const candidates = await findArchiveCandidates(pkg.outputDir); + for (const candidate of candidates) { + const archiveItems = resolveArchiveItemsFromList(path.basename(candidate), completedItems); + if (archiveItems.length === 0) { + continue; + } + const hasPendingExtract = archiveItems.some((item) => !isExtractedLabel(item.fullStatus || "")); + if (!hasPendingExtract) { + continue; + } + relevant.add(pathKey(candidate)); + } + + return relevant; + } + private clearHybridArchiveState(packageId: string, archiveKey?: string): void { if (!archiveKey) { this.hybridExtractedPaths.delete(packageId); @@ -4872,7 +4969,14 @@ export class DownloadManager extends EventEmitter { const success = items.filter((item) => item.status === "completed").length; const failed = items.filter((item) => item.status === "failed").length; const cancelled = items.filter((item) => item.status === "cancelled").length; - const allDone = success + failed + cancelled >= items.length; + const allDone = this.areAllPackageItemRefsFinished(pkg); + if (!allDone && success + failed + cancelled >= items.length) { + logger.warn( + `Post-Processing wartet trotz gefiltert fertiger Items: ` + + `pkg=${pkg.name}, tracked=${pkg.itemIds.length}, resolved=${items.length}, ` + + `success=${success}, failed=${failed}, cancelled=${cancelled}` + ); + } // Hybrid extraction recovery: not all items done, but some completed // with pending extraction status → re-label and trigger post-processing @@ -4956,7 +5060,14 @@ export class DownloadManager extends EventEmitter { const success = items.filter((item) => item.status === "completed").length; const failed = items.filter((item) => item.status === "failed").length; const cancelled = items.filter((item) => item.status === "cancelled").length; - const allDone = success + failed + cancelled >= items.length; + const allDone = this.areAllPackageItemRefsFinished(pkg); + if (!allDone && success + failed + cancelled >= items.length) { + logger.warn( + `Post-Processing wartet trotz gefiltert fertiger Items: ` + + `pkg=${pkg.name}, tracked=${pkg.itemIds.length}, resolved=${items.length}, ` + + `success=${success}, failed=${failed}, cancelled=${cancelled}` + ); + } // Full extraction: all items done, no failures if (allDone && failed === 0 && success > 0) { @@ -6404,48 +6515,10 @@ export class DownloadManager extends EventEmitter { // even though the download finished successfully. if (item.downloadedBytes > 0) { const targetFile = this.claimedTargetPathByItem.get(item.id) || ""; - const expectedMin = itemExpectedMinBytes(item); - let fileAlreadyComplete = false; - if (targetFile && expectedMin > 10240) { - try { - const stallStat = fs.statSync(targetFile); - if (stallStat.size >= expectedMin) { - fileAlreadyComplete = true; - logger.info(`Stall-Recovery: ${item.fileName} ist bereits vollständig auf Disk (${humanSize(stallStat.size)}, erwartet mind. ${humanSize(expectedMin)}), überspringe Retry`); - this.logPackageForItem(item, "INFO", "Stall-Recovery: Datei bereits vollständig", { - fileSize: stallStat.size, - expectedMin - }); - item.status = "completed"; - item.fullStatus = this.settings.autoExtract - ? "Entpacken - Ausstehend" - : `Fertig (${humanSize(stallStat.size)})`; - item.downloadedBytes = stallStat.size; - if (item.totalBytes && item.totalBytes > 0) { - item.progressPercent = 100; - } - item.speedBps = 0; - item.updatedAt = nowMs(); - pkg.updatedAt = nowMs(); - this.recordRunOutcome(item.id, "completed"); - if (this.session.running && !active.abortController.signal.aborted) { - void this.runPackagePostProcessing(pkg.id).catch((err) => { - logger.warn(`runPackagePostProcessing Fehler (stallRecovery): ${compactErrorText(err)}`); - }).finally(() => { - this.applyCompletedCleanupPolicy(pkg.id, item.id); - this.persistSoon(); - this.emitState(); - }); - } - this.persistSoon(); - this.emitState(); - this.retryStateByItem.delete(item.id); - return; - } - } catch { /* file doesn't exist or not accessible */ } + if (this.tryFinalizeItemFromDisk(pkg, item, "Stall-Recovery", stallErrorText)) { + return; } - // Reset partial download so next attempt uses a fresh link - if (!fileAlreadyComplete && targetFile) { + if (targetFile) { try { fs.rmSync(targetFile, { force: true }); } catch { /* ignore */ } } this.releaseTargetPath(item.id); @@ -6479,6 +6552,9 @@ export class DownloadManager extends EventEmitter { this.retryStateByItem.delete(item.id); } else { const errorText = compactErrorText(error); + if (this.tryFinalizeItemFromDisk(pkg, item, "Error-Recovery", errorText)) { + return; + } this.logPackageForItem(item, "WARN", "Download-Fehlerpfad erreicht", { error: errorText, abortReason: reason || "none" @@ -8596,7 +8672,14 @@ export class DownloadManager extends EventEmitter { recoveryMs }); - const allDone = success + failed + cancelled >= items.length; + const allDone = this.areAllPackageItemRefsFinished(pkg); + if (!allDone && success + failed + cancelled >= items.length) { + logger.warn( + `Post-Processing wartet trotz gefiltert fertiger Items: ` + + `pkg=${pkg.name}, tracked=${pkg.itemIds.length}, resolved=${items.length}, ` + + `success=${success}, failed=${failed}, cancelled=${cancelled}` + ); + } if (!allDone && this.settings.hybridExtract && this.settings.autoExtract && failed === 0 && success > 0) { pkg.postProcessLabel = "Entpacken vorbereiten..."; @@ -8713,6 +8796,7 @@ export class DownloadManager extends EventEmitter { throw new Error(String(extractAbortController.signal.reason || "aborted:extract")); } + const fullArchiveSet = await this.findFullExtractArchiveSet(pkg, completedItems); const result = await extractPackageArchives({ packageDir: pkg.outputDir, targetDir: pkg.extractDir, @@ -8723,6 +8807,7 @@ export class DownloadManager extends EventEmitter { passwordList: this.settings.archivePasswordList, signal: extractAbortController.signal, packageId, + onlyArchives: fullArchiveSet, skipPostCleanup: true, maxParallel: this.settings.maxParallelExtract || 2, // All downloads finished — use NORMAL OS priority so extraction runs at diff --git a/src/main/extractor.ts b/src/main/extractor.ts index de7ed03..6853a45 100644 --- a/src/main/extractor.ts +++ b/src/main/extractor.ts @@ -594,11 +594,18 @@ export type ExtractErrorCategory = type ExtractionErrorWithHints = Error & { suggestRedownload?: boolean; jvmFailureReason?: string; + legacyBestPercent?: number; + legacyExtractor?: string; }; function withExtractionErrorHints( error: unknown, - hints: { suggestRedownload?: boolean; jvmFailureReason?: string } + hints: { + suggestRedownload?: boolean; + jvmFailureReason?: string; + legacyBestPercent?: number; + legacyExtractor?: string; + } ): Error { const base = error instanceof Error ? error : new Error(String(error || "Entpacken fehlgeschlagen")); const enhanced = base as ExtractionErrorWithHints; @@ -608,6 +615,12 @@ function withExtractionErrorHints( if (hints.jvmFailureReason) { enhanced.jvmFailureReason = hints.jvmFailureReason; } + if (Number.isFinite(hints.legacyBestPercent)) { + enhanced.legacyBestPercent = Math.max(Number(enhanced.legacyBestPercent || 0), Number(hints.legacyBestPercent || 0)); + } + if (hints.legacyExtractor) { + enhanced.legacyExtractor = hints.legacyExtractor; + } return enhanced; } @@ -624,6 +637,37 @@ export function classifyExtractionError(errorText: string): ExtractErrorCategory return "unknown"; } +export function shouldFallbackLegacyRarToJvm( + archivePath: string, + configuredMode: ExtractBackendMode, + backendMode: ExtractBackendMode, + errorText: string, + bestPercent = 0, + platform = process.platform +): boolean { + if (configuredMode !== "auto" || backendMode !== "legacy") { + return false; + } + if (String(platform || "").toLowerCase() !== "win32") { + return false; + } + if (!isRarArchivePath(archivePath)) { + return false; + } + + const category = classifyExtractionError(errorText); + if (category === "aborted" || category === "timeout" || category === "no_extractor" || category === "missing_parts" || category === "disk_full") { + return false; + } + + const text = String(errorText || "").toLowerCase(); + if (text.includes("cannot create")) { + return false; + } + + return bestPercent > 0 || category === "unknown"; +} + function isExtractAbortError(errorText: string): boolean { const text = String(errorText || "").toLowerCase(); return text.includes("aborted:extract") || text.includes("extract_aborted") || text.includes("noextractor:skipped"); @@ -2136,22 +2180,22 @@ async function runExternalExtract( } } } catch (legacyError) { - const legacyText = String((legacyError as Error)?.message || legacyError || ""); - const legacyCategory = classifyExtractionError(legacyText); - const isCrcOrWrongPw = legacyCategory === "crc_error" || legacyCategory === "wrong_password"; + const initialLegacyText = String((legacyError as Error)?.message || legacyError || ""); + const initialLegacyCategory = classifyExtractionError(initialLegacyText); + const initialLegacyHints = legacyError as ExtractionErrorWithHints; + const initialLegacyBestPercent = Number.isFinite(initialLegacyHints.legacyBestPercent) + ? Number(initialLegacyHints.legacyBestPercent || 0) + : 0; + const isCrcOrWrongPw = initialLegacyCategory === "crc_error" || initialLegacyCategory === "wrong_password"; + let finalLegacyError: Error; - // ── Retry once after 2s delay ── - // On Windows, freshly completed downloads may still have file handles not - // fully released by the OS. Encrypted RAR5 headers are especially sensitive: - // even a single unreadable byte causes "Checksum error in the encrypted file" - // at bestPercent=0, indistinguishable from a wrong password. - // A short delay allows the OS to finalise all handles and flush caches. + // Retry once after a short delay to let Windows flush freshly completed archive parts. if (isCrcOrWrongPw && !signal?.aborted) { const retryDelayMs = 2500; logger.warn( - `Legacy-Extraktion fehlgeschlagen (${legacyCategory}), Retry nach ${retryDelayMs}ms Delay: ${archiveName}` + `Legacy-Extraktion fehlgeschlagen (${initialLegacyCategory}), Retry nach ${retryDelayMs}ms Delay: ${archiveName}` ); - onLog?.("WARN", `Legacy-Extraktion fehlgeschlagen (${legacyCategory}), Retry nach ${retryDelayMs}ms Delay: ${archiveName}`); + onLog?.("WARN", `Legacy-Extraktion fehlgeschlagen (${initialLegacyCategory}), Retry nach ${retryDelayMs}ms Delay: ${archiveName}`); await extractRetryDelay(retryDelayMs); if (!signal?.aborted) { try { @@ -2175,27 +2219,86 @@ async function runExternalExtract( onLog?.("INFO", `Legacy-Retry erfolgreich: ${archiveName}`); password = retryPassword; usedCommand = retryCmd; + const retryExtractorName = path.basename(retryCmd).replace(/\.exe$/i, ""); + const retryLegacyMs = Date.now() - legacyStartedAt; + if (jvmFailureReason) { + logger.info(`Entpackt via legacy/${retryExtractorName} (nach JVM-Fehler): ${archiveName}`); + } else { + logger.info(`Entpackt via legacy/${retryExtractorName} (nach Legacy-Retry): ${archiveName}`); + } + logger.info(`Extract-Backend Ende: archive=${archiveName}, backend=legacy/${retryExtractorName}, mode=${backendMode}, ms=${Date.now() - totalStartedAt}, legacyMs=${retryLegacyMs}, fallbackFromJvm=${fallbackFromJvm}, usedPassword=${password ? "yes" : "no"}`); + onLog?.("INFO", `Extract-Backend Ende: archive=${archiveName}, backend=legacy/${retryExtractorName}, mode=${backendMode}, ms=${Date.now() - totalStartedAt}, legacyMs=${retryLegacyMs}, fallbackFromJvm=${fallbackFromJvm}, usedPassword=${password ? "yes" : "no"}`); + return password; } catch (retryError) { const retryText = String((retryError as Error)?.message || retryError || ""); const retryCategory = classifyExtractionError(retryText); logger.warn(`Legacy-Retry ebenfalls fehlgeschlagen (${retryCategory}): ${archiveName}`); onLog?.("WARN", `Legacy-Retry ebenfalls fehlgeschlagen (${retryCategory}): ${archiveName}`); const suggestRedownload = jvmCodecError && (retryCategory === "crc_error" || retryCategory === "wrong_password"); - throw withExtractionErrorHints(retryError, { + finalLegacyError = withExtractionErrorHints(retryError, { suggestRedownload, jvmFailureReason: jvmFailureReason || undefined }); } } else { - throw legacyError; + finalLegacyError = withExtractionErrorHints(legacyError, { + jvmFailureReason: jvmFailureReason || undefined + }); } } else { const suggestRedownload = jvmCodecError && isCrcOrWrongPw; - throw withExtractionErrorHints(legacyError, { + finalLegacyError = withExtractionErrorHints(legacyError, { suggestRedownload, jvmFailureReason: jvmFailureReason || undefined }); } + + const finalLegacyHints = finalLegacyError as ExtractionErrorWithHints; + const finalLegacyText = String(finalLegacyError?.message || finalLegacyError || ""); + const finalLegacyBestPercent = Number.isFinite(finalLegacyHints.legacyBestPercent) + ? Number(finalLegacyHints.legacyBestPercent || 0) + : initialLegacyBestPercent; + + if (!signal?.aborted && shouldFallbackLegacyRarToJvm(archivePath, configuredBackendMode, backendMode, finalLegacyText, finalLegacyBestPercent)) { + const layout = resolveJvmExtractorLayout(); + if (layout) { + logger.warn(`Legacy->JVM-Fallback: archive=${archiveName}, bestPercent=${finalLegacyBestPercent}, reason=${cleanErrorText(finalLegacyText)}`); + onLog?.("WARN", `Legacy->JVM-Fallback: archive=${archiveName}, bestPercent=${finalLegacyBestPercent}, reason=${cleanErrorText(finalLegacyText)}`); + const jvmStartedAt = Date.now(); + const jvmResult = await runJvmExtractCommand( + layout, + archivePath, + targetDir, + conflictMode, + passwordCandidates, + onArchiveProgress, + signal, + timeoutMs + ); + const jvmMs = Date.now() - jvmStartedAt; + logger.info(`JVM-Extractor Ergebnis (nach Legacy-Fallback): archive=${archiveName}, ok=${jvmResult.ok}, ms=${jvmMs}, timedOut=${jvmResult.timedOut}, aborted=${jvmResult.aborted}, backend=${jvmResult.backend || "unknown"}, usedPassword=${jvmResult.usedPassword ? "yes" : "no"}`); + onLog?.("INFO", `JVM-Extractor Ergebnis (nach Legacy-Fallback): archive=${archiveName}, ok=${jvmResult.ok}, ms=${jvmMs}, timedOut=${jvmResult.timedOut}, aborted=${jvmResult.aborted}, backend=${jvmResult.backend || "unknown"}, usedPassword=${jvmResult.usedPassword ? "yes" : "no"}`); + if (jvmResult.ok) { + logger.info(`Entpackt via ${jvmResult.backend || "jvm"} (nach Legacy-Fallback): ${archiveName}`); + logger.info(`Extract-Backend Ende: archive=${archiveName}, backend=${jvmResult.backend || "jvm"}, mode=${backendMode}, ms=${Date.now() - totalStartedAt}, fallbackFromJvm=${fallbackFromJvm}, fallbackFromLegacy=true, usedPassword=${jvmResult.usedPassword ? "yes" : "no"}`); + onLog?.("INFO", `Extract-Backend Ende: archive=${archiveName}, backend=${jvmResult.backend || "jvm"}, mode=${backendMode}, ms=${Date.now() - totalStartedAt}, fallbackFromJvm=${fallbackFromJvm}, fallbackFromLegacy=true, usedPassword=${jvmResult.usedPassword ? "yes" : "no"}`); + return jvmResult.usedPassword; + } + if (jvmResult.aborted) { + throw new Error("aborted:extract"); + } + finalLegacyError = withExtractionErrorHints(finalLegacyError, { + jvmFailureReason: jvmResult.errorText || "JVM-Extractor fehlgeschlagen" + }); + logger.warn(`Legacy->JVM-Fallback ebenfalls fehlgeschlagen: ${archiveName} (${cleanErrorText(jvmResult.errorText || "JVM-Extractor fehlgeschlagen")})`); + onLog?.("WARN", `Legacy->JVM-Fallback ebenfalls fehlgeschlagen: archive=${archiveName}, error=${cleanErrorText(jvmResult.errorText || "JVM-Extractor fehlgeschlagen")}`); + } else { + logger.warn(`Legacy->JVM-Fallback uebersprungen: JVM-Extractor nicht verfuegbar fuer ${archiveName}`); + onLog?.("WARN", `Legacy->JVM-Fallback uebersprungen: archive=${archiveName}, reason=no_jvm_extractor`); + } + } + + throw finalLegacyError; } const legacyMs = Date.now() - legacyStartedAt; const extractorName = path.basename(usedCommand).replace(/\.exe$/i, ""); @@ -2267,7 +2370,7 @@ async function runExternalExtractInner( if (result.timedOut || result.missingCommand) break; lastError = result.errorText; } - throw new Error(lastError || "Entpacken fehlgeschlagen (flat-mode)"); + throw withExtractionErrorHints(new Error(lastError || "Entpacken fehlgeschlagen (flat-mode)"), { legacyBestPercent: bestPercent, legacyExtractor: extractorName }); } for (const password of passwords) { @@ -2356,7 +2459,7 @@ async function runExternalExtractInner( resolvedExtractorCommand = null; resolveFailureReason = NO_EXTRACTOR_MESSAGE; resolveFailureAt = Date.now(); - throw new Error(NO_EXTRACTOR_MESSAGE); + throw withExtractionErrorHints(new Error(NO_EXTRACTOR_MESSAGE), { legacyBestPercent: bestPercent, legacyExtractor: extractorName }); } lastError = result.errorText; @@ -2397,7 +2500,7 @@ async function runExternalExtractInner( } } - throw new Error(lastError || "Entpacken fehlgeschlagen"); + throw withExtractionErrorHints(new Error(lastError || "Entpacken fehlgeschlagen"), { legacyBestPercent: bestPercent, legacyExtractor: extractorName }); } // Delay helper for extraction retries (allows file handles to be released on Windows) diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index 1d4e42a..24d9953 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -473,6 +473,67 @@ describe("download manager", () => { } }); + it("does not renew direct links when the file is already complete on disk", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const binary = Buffer.alloc(256 * 1024, 31); + let unrestrictCalls = 0; + let downloadCalls = 0; + + globalThis.fetch = async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/unrestrict/link")) { + unrestrictCalls += 1; + return new Response( + JSON.stringify({ + download: "https://dummy/direct-complete", + filename: "direct-complete.mkv", + filesize: binary.length + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + ); + } + throw new Error(`unexpected fetch ${url}`); + }; + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + retryLimit: 1, + autoExtract: false, + autoReconnect: false + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + (manager as any).downloadToFile = async (_active: unknown, _directUrl: string, targetPath: string) => { + downloadCalls += 1; + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + fs.writeFileSync(targetPath, binary); + throw new Error(`direct_link_retry_exhausted:range_ignored_on_resume:${binary.length}/${binary.length}`); + }; + + manager.addPackages([{ name: "direct-complete", links: ["https://dummy/direct-complete"] }]); + await manager.start(); + await waitFor(() => !manager.getSnapshot().session.running, 12000); + + const item = Object.values(manager.getSnapshot().session.items)[0]; + expect(item?.status).toBe("completed"); + expect(item?.progressPercent).toBe(100); + expect(item?.downloadedBytes).toBe(binary.length); + expect(unrestrictCalls).toBe(1); + expect(downloadCalls).toBe(1); + expect(fs.existsSync(item.targetPath)).toBe(true); + expect(fs.statSync(item.targetPath).size).toBe(binary.length); + }); + it("restarts from zero after repeated resume underflow on fresh direct links", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); tempDirs.push(root); @@ -762,7 +823,7 @@ describe("download manager", () => { const item = Object.values(manager.getSnapshot().session.items)[0]; expect(item?.status).toBe("failed"); - expect(item?.fullStatus || item?.lastError || "").toContain("download_underflow"); + expect(item?.fullStatus || item?.lastError || "").toMatch(/download_underflow|range_ignored_on_resume/); expect(item?.downloadedBytes).toBe(actual.length); } finally { server.close(); diff --git a/tests/extractor.test.ts b/tests/extractor.test.ts index e87915e..2c29f49 100644 --- a/tests/extractor.test.ts +++ b/tests/extractor.test.ts @@ -15,6 +15,7 @@ import { orderExtractorCandidatesForArchive, resolveExtractorBackendModeForArchive, resolveExtractorBackendMode, + shouldFallbackLegacyRarToJvm, } from "../src/main/extractor"; const tempDirs: string[] = []; @@ -1183,6 +1184,25 @@ describe("extractor", () => { expect(resolveExtractorBackendModeForArchive("C:\\Downloads\\episode.r00", undefined, false, "win32")).toBe("legacy"); }); + it("falls back from legacy rar to jvm after partial-progress failure in auto mode on Windows", () => { + expect( + shouldFallbackLegacyRarToJvm( + "C:\\Downloads\\episode.part01.rar", + "auto", + "legacy", + "Error: Extracting from C:\\Downloads\\episode.part01.rar", + 38, + "win32" + ) + ).toBe(true); + }); + + it("skips legacy rar to jvm fallback for explicit legacy mode and non-rar cases", () => { + expect(shouldFallbackLegacyRarToJvm("C:\\Downloads\\episode.part01.rar", "legacy", "legacy", "checksum error", 38, "win32")).toBe(false); + expect(shouldFallbackLegacyRarToJvm("C:\\Downloads\\episode.zip", "auto", "legacy", "unknown failure", 38, "win32")).toBe(false); + expect(shouldFallbackLegacyRarToJvm("C:\\Downloads\\episode.part01.rar", "auto", "legacy", "timeout", 38, "win32")).toBe(false); + }); + it("keeps auto for non-rar archives and respects explicit overrides", () => { expect(resolveExtractorBackendModeForArchive("C:\\Downloads\\episode.zip", undefined, false, "win32")).toBe("auto"); expect(resolveExtractorBackendModeForArchive("C:\\Downloads\\episode.part01.rar", "jvm", false, "win32")).toBe("jvm");