From fb036733e358191e120b56e9c9b84e791e2844a3 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Sat, 7 Mar 2026 21:27:03 +0100 Subject: [PATCH] Fix auto-recovery for stale archive parts --- src/main/download-manager.ts | 137 +++++++++++++++++++----- src/main/extractor.ts | 186 +++++++++++++++++++++++---------- tests/download-manager.test.ts | 92 ++++++++++++++++ tests/extractor.test.ts | 31 ++++++ 4 files changed, 364 insertions(+), 82 deletions(-) diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index f1b796a..641c37b 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -42,7 +42,7 @@ function releaseTlsSkip(): void { } import { cleanupCancelledPackageArtifactsAsync, removeDownloadLinkArtifacts, removeSampleArtifacts } from "./cleanup"; import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, DebridService, MegaWebUnrestrictor, RealDebridWebUnrestrictor, checkRapidgatorOnline, fetchAllDebridHostInfo, getAvailableDebridLinkApiKeys } from "./debrid"; -import { cleanupArchives, clearExtractResumeState, collectArchiveCleanupTargets, extractPackageArchives, findArchiveCandidates, hasAnyFilesRecursive, removeEmptyDirectoryTree } from "./extractor"; +import { cleanupArchives, clearExtractResumeState, collectArchiveCleanupTargets, extractPackageArchives, findArchiveCandidates, hasAnyFilesRecursive, removeEmptyDirectoryTree, type ExtractArchiveFailureInfo } from "./extractor"; import { validateFileAgainstManifest } from "./integrity"; import { logger } from "./logger"; import { StoragePaths, saveSession, saveSessionAsync, saveSettings, saveSettingsAsync } from "./storage"; @@ -4057,6 +4057,62 @@ export class DownloadManager extends EventEmitter { } } + private autoRecoverArchiveCrcFailure( + pkg: PackageEntry, + items: DownloadItem[], + failure: ExtractArchiveFailureInfo, + scope: "hybrid" | "full" + ): number { + if (!failure.suggestRedownload || failure.category !== "crc_error") { + return 0; + } + + const archiveItems = resolveArchiveItemsFromList(failure.archiveName, items) + .filter((item) => item.status === "completed"); + if (archiveItems.length === 0) { + logger.warn(`Auto-Recovery (${scope}): Keine completed Items für ${failure.archiveName} gefunden, überspringe`); + return 0; + } + + const queuedAt = nowMs(); + const reason = "Wartet (Auto-Recovery: Archiv beschädigt/unvollständig)"; + let changed = 0; + for (const item of archiveItems) { + const claimedTargetPath = String(item.targetPath || "").trim(); + if (claimedTargetPath) { + try { + fs.rmSync(claimedTargetPath, { force: true }); + } catch { + // ignore; claim is still released so a fresh path can be chosen if needed + } + } + this.releaseTargetPath(item.id); + this.dropItemContribution(item.id); + item.targetPath = ""; + item.status = "queued"; + item.attempts = 0; + item.downloadedBytes = 0; + item.progressPercent = 0; + item.speedBps = 0; + item.lastError = failure.errorText; + item.fullStatus = reason; + item.updatedAt = queuedAt; + changed += 1; + } + + if (changed > 0) { + pkg.status = (pkg.enabled && this.session.running && !this.session.paused) ? "downloading" : "queued"; + pkg.updatedAt = queuedAt; + logger.warn( + `Auto-Recovery (${scope}): ${failure.archiveName} auf queued gesetzt (${changed} Items), ` + + `reason=${compactErrorText(failure.jvmFailureReason || failure.errorText)}` + ); + this.persistSoon(); + this.emitState(); + } + return changed; + } + /** Detect items whose targetPath has a " (N)" suffix from a previous bug and rename * them back to the original filename if the original path is not claimed by another item. */ private fixDuplicateSuffixFiles(): void { @@ -7198,6 +7254,7 @@ export class DownloadManager extends EventEmitter { resolveArchiveItemsFromList(archiveName, items); // Track archives for parallel hybrid extraction progress + const autoRecoveredArchives = new Set(); const hybridResolvedItems = new Map(); const hybridStartTimes = new Map(); let hybridLastEmitAt = 0; @@ -7242,6 +7299,15 @@ export class DownloadManager extends EventEmitter { hybridMode: true, maxParallel: this.settings.maxParallelExtract || 2, extractCpuPriority: "high", + onArchiveFailure: (failure) => { + if (autoRecoveredArchives.has(failure.archiveName)) { + return; + } + const changed = this.autoRecoverArchiveCrcFailure(pkg, items, failure, "hybrid"); + if (changed > 0) { + autoRecoveredArchives.add(failure.archiveName); + } + }, onProgress: (progress) => { if (progress.phase === "preparing") { pkg.postProcessLabel = progress.archiveName || "Vorbereiten..."; @@ -7273,6 +7339,9 @@ export class DownloadManager extends EventEmitter { const initLabel = `Entpacken 0% · ${progress.archiveName}`; const initAt = nowMs(); for (const entry of resolved) { + if (entry.status !== "completed" || isExtractedLabel(entry.fullStatus)) { + continue; + } if (!isExtractedLabel(entry.fullStatus)) { entry.fullStatus = initLabel; entry.updatedAt = initAt; @@ -7293,10 +7362,9 @@ export class DownloadManager extends EventEmitter { ? "Entpacken - Error" : formatExtractDone(doneAt - startedAt); for (const entry of archItems) { - if (!isExtractedLabel(entry.fullStatus)) { - entry.fullStatus = doneLabel; - entry.updatedAt = doneAt; - } + if (entry.status !== "completed" || isExtractedLabel(entry.fullStatus)) continue; + entry.fullStatus = doneLabel; + entry.updatedAt = doneAt; } hybridResolvedItems.delete(progress.archiveName); hybridStartTimes.delete(progress.archiveName); @@ -7324,10 +7392,9 @@ export class DownloadManager extends EventEmitter { } const updatedAt = nowMs(); for (const entry of archItems) { - if (!isExtractedLabel(entry.fullStatus) && entry.fullStatus !== label) { - entry.fullStatus = label; - entry.updatedAt = updatedAt; - } + if (entry.status !== "completed" || isExtractedLabel(entry.fullStatus) || entry.fullStatus === label) continue; + entry.fullStatus = label; + entry.updatedAt = updatedAt; } } } @@ -7396,7 +7463,7 @@ export class DownloadManager extends EventEmitter { // downloading) as "Done". const updatedAt = nowMs(); for (const entry of hybridItems) { - if (isExtractedLabel(entry.fullStatus)) { + if (entry.status !== "completed" || isExtractedLabel(entry.fullStatus)) { continue; } const status = entry.fullStatus || ""; @@ -7417,7 +7484,7 @@ export class DownloadManager extends EventEmitter { logger.info(`Hybrid-Extract abgebrochen: pkg=${pkg.name}`); const abortAt = nowMs(); for (const entry of hybridItems) { - if (isExtractedLabel(entry.fullStatus || "")) continue; + if (entry.status !== "completed" || isExtractedLabel(entry.fullStatus || "")) continue; if (/^Entpacken\b/i.test(entry.fullStatus || "") || /^Passwort\b/i.test(entry.fullStatus || "")) { entry.fullStatus = "Entpacken abgebrochen (wird fortgesetzt)"; entry.updatedAt = abortAt; @@ -7428,7 +7495,7 @@ export class DownloadManager extends EventEmitter { logger.warn(`Hybrid-Extract Fehler: pkg=${pkg.name}, reason=${compactErrorText(error)}`); const errorAt = nowMs(); for (const entry of hybridItems) { - if (isExtractedLabel(entry.fullStatus || "")) continue; + if (entry.status !== "completed" || isExtractedLabel(entry.fullStatus || "")) continue; if (/^Entpacken\b/i.test(entry.fullStatus || "") || /^Passwort\b/i.test(entry.fullStatus || "")) { entry.fullStatus = `Entpacken - Error`; entry.updatedAt = errorAt; @@ -7609,6 +7676,7 @@ export class DownloadManager extends EventEmitter { }, extractTimeoutMs); try { // Track archives for parallel extraction progress + const autoRecoveredArchives = new Set(); const fullResolvedItems = new Map(); const fullStartTimes = new Map(); let fullLastProgressCurrent: number | null = null; @@ -7628,6 +7696,15 @@ export class DownloadManager extends EventEmitter { // All downloads finished — use NORMAL OS priority so extraction runs at // full speed (matching manual 7-Zip/WinRAR speed). extractCpuPriority: "high", + onArchiveFailure: (failure) => { + if (autoRecoveredArchives.has(failure.archiveName)) { + return; + } + const changed = this.autoRecoverArchiveCrcFailure(pkg, completedItems, failure, "full"); + if (changed > 0) { + autoRecoveredArchives.add(failure.archiveName); + } + }, onProgress: (progress) => { if (progress.phase === "preparing") { pkg.postProcessLabel = progress.archiveName || "Vorbereiten..."; @@ -7660,10 +7737,9 @@ export class DownloadManager extends EventEmitter { const initLabel = `Entpacken 0% · ${progress.archiveName}`; const initAt = nowMs(); for (const entry of resolved) { - if (!isExtractedLabel(entry.fullStatus)) { - entry.fullStatus = initLabel; - entry.updatedAt = initAt; - } + if (entry.status !== "completed" || isExtractedLabel(entry.fullStatus)) continue; + entry.fullStatus = initLabel; + entry.updatedAt = initAt; } emitExtractStatus(`Entpacken ${progress.percent}% · ${progress.archiveName}`, true); } @@ -7679,10 +7755,9 @@ export class DownloadManager extends EventEmitter { ? "Entpacken - Error" : formatExtractDone(doneAt - startedAt); for (const entry of archiveItems) { - if (!isExtractedLabel(entry.fullStatus)) { - entry.fullStatus = doneLabel; - entry.updatedAt = doneAt; - } + if (entry.status !== "completed" || isExtractedLabel(entry.fullStatus)) continue; + entry.fullStatus = doneLabel; + entry.updatedAt = doneAt; } fullResolvedItems.delete(progress.archiveName); fullStartTimes.delete(progress.archiveName); @@ -7709,10 +7784,9 @@ export class DownloadManager extends EventEmitter { } const updatedAt = nowMs(); for (const entry of archiveItems) { - if (!isExtractedLabel(entry.fullStatus) && entry.fullStatus !== label) { - entry.fullStatus = label; - entry.updatedAt = updatedAt; - } + if (entry.status !== "completed" || isExtractedLabel(entry.fullStatus) || entry.fullStatus === label) continue; + entry.fullStatus = label; + entry.updatedAt = updatedAt; } } } @@ -7738,16 +7812,25 @@ export class DownloadManager extends EventEmitter { }); logger.info(`Post-Processing Entpacken Ende: pkg=${pkg.name}, extracted=${result.extracted}, failed=${result.failed}, lastError=${result.lastError || ""}`); extractedCount = result.extracted; + const autoRecoveredPending = completedItems.some((item) => item.status === "queued"); // Auto-rename wird in runDeferredPostExtraction ausgeführt (im Hintergrund), // damit der Slot sofort freigegeben wird. + if (autoRecoveredPending) { + pkg.postProcessLabel = undefined; + pkg.status = (pkg.enabled && this.session.running && !this.session.paused) ? "downloading" : "queued"; + pkg.updatedAt = nowMs(); + logger.warn(`Post-Processing: pkg=${pkg.name}, Archivfehler automatisch auf Re-Download umgestellt`); + return; + } + if (result.failed > 0) { const reason = compactErrorText(result.lastError || "Entpacken fehlgeschlagen"); const failAt = nowMs(); for (const entry of completedItems) { // Preserve per-archive "Entpackt - Done (X.Xs)" labels for successfully extracted archives - if (!isExtractedLabel(entry.fullStatus)) { + if (entry.status === "completed" && !isExtractedLabel(entry.fullStatus)) { entry.fullStatus = `Entpack-Fehler: ${reason}`; entry.updatedAt = failAt; } @@ -7786,7 +7869,7 @@ export class DownloadManager extends EventEmitter { const timeoutReason = `Entpacken Timeout nach ${Math.ceil(extractTimeoutMs / 1000)}s`; logger.error(`Post-Processing Entpacken Timeout: pkg=${pkg.name}`); for (const entry of completedItems) { - if (!isExtractedLabel(entry.fullStatus)) { + if (entry.status === "completed" && !isExtractedLabel(entry.fullStatus)) { entry.fullStatus = `Entpack-Fehler: ${timeoutReason}`; entry.updatedAt = nowMs(); } @@ -7811,7 +7894,7 @@ export class DownloadManager extends EventEmitter { const reason = compactErrorText(error); logger.error(`Post-Processing Entpacken Exception: pkg=${pkg.name}, reason=${reason}`); for (const entry of completedItems) { - if (!isExtractedLabel(entry.fullStatus)) { + if (entry.status === "completed" && !isExtractedLabel(entry.fullStatus)) { entry.fullStatus = `Entpack-Fehler: ${reason}`; entry.updatedAt = nowMs(); } diff --git a/src/main/extractor.ts b/src/main/extractor.ts index eef64c5..3a32bd0 100644 --- a/src/main/extractor.ts +++ b/src/main/extractor.ts @@ -110,6 +110,7 @@ export interface ExtractOptions { hybridMode?: boolean; maxParallel?: number; extractCpuPriority?: string; + onArchiveFailure?: (failure: ExtractArchiveFailureInfo) => void; } export interface ExtractProgressUpdate { @@ -127,6 +128,14 @@ export interface ExtractProgressUpdate { archiveSuccess?: boolean; } +export interface ExtractArchiveFailureInfo { + archiveName: string; + errorText: string; + category: ExtractErrorCategory; + suggestRedownload: boolean; + jvmFailureReason?: string; +} + const MAX_EXTRACT_OUTPUT_BUFFER = 48 * 1024; const EXTRACT_PROGRESS_FILE = ".rd_extract_progress.json"; const EXTRACT_BASE_TIMEOUT_MS = 6 * 60 * 1000; @@ -532,6 +541,26 @@ export type ExtractErrorCategory = | "no_extractor" | "unknown"; +type ExtractionErrorWithHints = Error & { + suggestRedownload?: boolean; + jvmFailureReason?: string; +}; + +function withExtractionErrorHints( + error: unknown, + hints: { suggestRedownload?: boolean; jvmFailureReason?: string } +): Error { + const base = error instanceof Error ? error : new Error(String(error || "Entpacken fehlgeschlagen")); + const enhanced = base as ExtractionErrorWithHints; + if (hints.suggestRedownload) { + enhanced.suggestRedownload = true; + } + if (hints.jvmFailureReason) { + enhanced.jvmFailureReason = hints.jvmFailureReason; + } + return enhanced; +} + export function classifyExtractionError(errorText: string): ExtractErrorCategory { const text = String(errorText || "").toLowerCase(); if (text.includes("aborted:extract") || text.includes("extract_aborted")) return "aborted"; @@ -1146,6 +1175,20 @@ function finishDaemonRequest(result: JvmExtractResult): void { req.resolve(result); } +function flushDaemonParseBuffers(req: DaemonRequest | null): void { + if (!req) { + return; + } + if (daemonStdoutBuffer.trim()) { + parseJvmLine(daemonStdoutBuffer, req.onArchiveProgress, req.parseState); + daemonStdoutBuffer = ""; + } + if (daemonStderrBuffer.trim()) { + parseJvmLine(daemonStderrBuffer, req.onArchiveProgress, req.parseState); + daemonStderrBuffer = ""; + } +} + function handleDaemonLine(line: string): void { const trimmed = String(line || "").trim(); if (!trimmed) return; @@ -1162,27 +1205,41 @@ function handleDaemonLine(line: string): void { const code = parseInt(trimmed.slice("RD_REQUEST_DONE ".length).trim(), 10); const req = daemonCurrentRequest; if (!req) return; - const elapsedMs = Date.now() - req.startedAt; - logger.info( - `JVM Daemon Request Ende: archive=${req.archiveName}, code=${code}, ms=${elapsedMs}, pwCandidates=${req.passwordCount}, ` + - `bestPercent=${req.parseState.bestPercent}, backend=${req.parseState.backend || "unknown"}, usedPassword=${req.parseState.usedPassword ? "yes" : "no"}` - ); + const finalize = (): void => { + if (daemonCurrentRequest !== req) { + return; + } + flushDaemonParseBuffers(req); + const elapsedMs = Date.now() - req.startedAt; + logger.info( + `JVM Daemon Request Ende: archive=${req.archiveName}, code=${code}, ms=${elapsedMs}, pwCandidates=${req.passwordCount}, ` + + `bestPercent=${req.parseState.bestPercent}, backend=${req.parseState.backend || "unknown"}, usedPassword=${req.parseState.usedPassword ? "yes" : "no"}` + ); + + if (code === 0) { + req.onArchiveProgress?.(100); + finishDaemonRequest({ + ok: true, missingCommand: false, missingRuntime: false, + aborted: false, timedOut: false, errorText: "", + usedPassword: req.parseState.usedPassword, backend: req.parseState.backend + }); + return; + } - if (code === 0) { - req.onArchiveProgress?.(100); - finishDaemonRequest({ - ok: true, missingCommand: false, missingRuntime: false, - aborted: false, timedOut: false, errorText: "", - usedPassword: req.parseState.usedPassword, backend: req.parseState.backend - }); - } else { const message = cleanErrorText(req.parseState.reportedError || daemonOutput) || `Exit Code ${code}`; finishDaemonRequest({ ok: false, missingCommand: false, missingRuntime: isJvmRuntimeMissingError(message), aborted: false, timedOut: false, errorText: message, usedPassword: req.parseState.usedPassword, backend: req.parseState.backend }); + }; + + if (code !== 0 && !req.parseState.reportedError) { + setTimeout(finalize, 40); + return; } + + finalize(); return; } @@ -1771,6 +1828,7 @@ async function runExternalExtract( const archiveName = path.basename(archivePath); const totalStartedAt = Date.now(); let jvmFailureReason = ""; + let jvmCodecError = false; let fallbackFromJvm = false; logger.info(`Extract-Backend Start: archive=${archiveName}, mode=${backendMode}, pwCandidates=${passwordCandidates.length}, timeoutMs=${timeoutMs}, hybrid=${hybridMode}`); @@ -1827,6 +1885,7 @@ async function runExternalExtract( const isCodecError = jvmFailureLower.includes("registered codecs") || jvmFailureLower.includes("can not open") || jvmFailureLower.includes("cannot open archive"); + jvmCodecError = isCodecError; const isWrongPassword = jvmFailureReason.includes("WRONG_PASSWORD") || jvmFailureLower.includes("wrong password"); const shouldFallbackToLegacy = isUnsupportedMethod || isCodecError || isWrongPassword; @@ -1854,52 +1913,61 @@ async function runExternalExtract( let password: string; let usedCommand = command; try { - password = await runExternalExtractInner( - command, - archivePath, - effectiveTargetDir, - conflictMode, - passwordCandidates, - onArchiveProgress, - signal, - timeoutMs, - hybridMode, - onPasswordAttempt, - forceFlatMode, - flatModeResult - ); - } catch (primaryError) { - // If the primary extractor (typically 7-Zip) fails on a RAR archive, - // try the alternative extractor (UnRAR/WinRAR) which handles RAR natively. - const isRar = /\.rar$/i.test(archiveName) || /\.r\d{2,3}$/i.test(archiveName); - const errText = String((primaryError as Error)?.message || primaryError || ""); - const isPasswordOrCorrupt = /wrong.password|checksum error|corrupt/i.test(errText); - if (isRar && isPasswordOrCorrupt && !signal?.aborted) { - const alt = await findAlternativeExtractor(command); - if (alt) { - const altName = path.basename(alt).replace(/\.exe$/i, ""); - logger.info(`Legacy-Fallback: ${path.basename(command)} fehlgeschlagen bei RAR, versuche ${altName}: ${archiveName}`); - usedCommand = alt; - password = await runExternalExtractInner( - alt, - archivePath, - effectiveTargetDir, - conflictMode, - passwordCandidates, - onArchiveProgress, - signal, - timeoutMs, - hybridMode, - onPasswordAttempt, - forceFlatMode, - flatModeResult - ); + try { + password = await runExternalExtractInner( + command, + archivePath, + effectiveTargetDir, + conflictMode, + passwordCandidates, + onArchiveProgress, + signal, + timeoutMs, + hybridMode, + onPasswordAttempt, + forceFlatMode, + flatModeResult + ); + } catch (primaryError) { + // If the primary extractor (typically 7-Zip) fails on a RAR archive, + // try the alternative extractor (UnRAR/WinRAR) which handles RAR natively. + const isRar = /\.rar$/i.test(archiveName) || /\.r\d{2,3}$/i.test(archiveName); + const errText = String((primaryError as Error)?.message || primaryError || ""); + const isPasswordOrCorrupt = /wrong.password|checksum error|corrupt/i.test(errText); + if (isRar && isPasswordOrCorrupt && !signal?.aborted) { + const alt = await findAlternativeExtractor(command); + if (alt) { + const altName = path.basename(alt).replace(/\.exe$/i, ""); + logger.info(`Legacy-Fallback: ${path.basename(command)} fehlgeschlagen bei RAR, versuche ${altName}: ${archiveName}`); + usedCommand = alt; + password = await runExternalExtractInner( + alt, + archivePath, + effectiveTargetDir, + conflictMode, + passwordCandidates, + onArchiveProgress, + signal, + timeoutMs, + hybridMode, + onPasswordAttempt, + forceFlatMode, + flatModeResult + ); + } else { + throw primaryError; + } } else { throw primaryError; } - } else { - throw primaryError; } + } catch (legacyError) { + const legacyText = String((legacyError as Error)?.message || legacyError || ""); + const suggestRedownload = jvmCodecError && classifyExtractionError(legacyText) === "crc_error"; + throw withExtractionErrorHints(legacyError, { + suggestRedownload, + jvmFailureReason: jvmFailureReason || undefined + }); } const legacyMs = Date.now() - legacyStartedAt; const extractorName = path.basename(usedCommand).replace(/\.exe$/i, ""); @@ -2754,6 +2822,14 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ failed += 1; lastError = errorText; const errorCategory = classifyExtractionError(errorText); + const hintedError = error as ExtractionErrorWithHints; + options.onArchiveFailure?.({ + archiveName, + errorText, + category: errorCategory, + suggestRedownload: hintedError?.suggestRedownload === true, + jvmFailureReason: hintedError?.jvmFailureReason + }); logger.error(`Entpack-Fehler ${path.basename(archivePath)} [${errorCategory}]: ${errorText}`); if (errorCategory === "wrong_password" && learnedPassword) { learnedPassword = ""; diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index 9add2dd..be70715 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -1950,6 +1950,98 @@ describe("download manager", () => { expect(snapshot.session.packages[packageId]?.status).toBe("queued"); }); + it("requeues completed archive parts after auto-recovery extraction failures", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const session = emptySession(); + const packageId = "crc-pkg"; + const createdAt = Date.now() - 10_000; + const outputDir = path.join(root, "downloads", "crc"); + const extractDir = path.join(root, "extract", "crc"); + fs.mkdirSync(outputDir, { recursive: true }); + + const archiveNames = ["show.s01e01.part1.rar", "show.s01e01.part2.rar"]; + const itemIds = archiveNames.map((_, index) => `crc-item-${index}`); + + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "crc", + outputDir, + extractDir, + status: "extracting", + itemIds, + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + + for (const [index, archiveName] of archiveNames.entries()) { + const targetPath = path.join(outputDir, archiveName); + fs.writeFileSync(targetPath, Buffer.from(`part-${index}`)); + session.items[itemIds[index]!] = { + id: itemIds[index]!, + packageId, + url: `https://dummy/${archiveName}`, + provider: "realdebrid", + status: "completed", + retries: 0, + speedBps: 0, + downloadedBytes: 4096, + totalBytes: 4096, + progressPercent: 100, + fileName: archiveName, + targetPath, + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Entpacken - Ausstehend", + createdAt, + updatedAt: createdAt + }; + } + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: true + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + const changed = (manager as any).autoRecoverArchiveCrcFailure( + session.packages[packageId], + itemIds.map((itemId) => session.items[itemId]!), + { + archiveName: "show.s01e01.part1.rar", + errorText: "Checksum error in the encrypted file", + category: "crc_error", + suggestRedownload: true, + jvmFailureReason: "Can not open the file as archive" + }, + "hybrid" + ); + + expect(changed).toBe(2); + for (const itemId of itemIds) { + const item = session.items[itemId]!; + expect(item.status).toBe("queued"); + expect(item.targetPath).toBe(""); + expect(item.downloadedBytes).toBe(0); + 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"); + }); + it("detects start conflicts when extract output already exists", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); tempDirs.push(root); diff --git a/tests/extractor.test.ts b/tests/extractor.test.ts index ac6e049..572a795 100644 --- a/tests/extractor.test.ts +++ b/tests/extractor.test.ts @@ -7,6 +7,7 @@ import { buildExternalExtractArgs, collectArchiveCleanupTargets, extractPackageArchives, + type ExtractArchiveFailureInfo, archiveFilenamePasswords, detectArchiveSignature, classifyExtractionError, @@ -999,6 +1000,36 @@ describe("extractor", () => { }); describe("password discovery", () => { + it("reports per-archive failures through onArchiveFailure", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-failure-")); + tempDirs.push(root); + const packageDir = path.join(root, "pkg"); + const targetDir = path.join(root, "out"); + fs.mkdirSync(packageDir, { recursive: true }); + + fs.writeFileSync(path.join(packageDir, "broken.zip"), "not-a-zip", "utf8"); + const failures: ExtractArchiveFailureInfo[] = []; + + const result = await extractPackageArchives({ + packageDir, + targetDir, + cleanupMode: "none", + conflictMode: "overwrite", + removeLinks: false, + removeSamples: false, + onArchiveFailure: (failure) => { + failures.push(failure); + } + }); + + expect(result.extracted).toBe(0); + expect(result.failed).toBe(1); + expect(failures).toHaveLength(1); + expect(failures[0]?.archiveName).toBe("broken.zip"); + expect(failures[0]?.category).toBe("unsupported_format"); + expect(failures[0]?.suggestRedownload).toBe(false); + }); + it("extracts first archive serially before parallel pool when multiple passwords", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-pwdisc-")); tempDirs.push(root);