Fix resume completion and rar fallback handling

This commit is contained in:
Sucukdeluxe 2026-03-08 04:49:13 +01:00
parent 4a27fd72c7
commit 2123a48bea
4 changed files with 332 additions and 63 deletions

View File

@ -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<Set<string>> {
const relevant = new Set<string>();
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

View File

@ -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)

View File

@ -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<Response> => {
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();

View File

@ -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");