Fix extraction retry loop on CRC/password failures

This commit is contained in:
Sucukdeluxe 2026-03-08 02:02:39 +01:00
parent 9eb28cee2e
commit e4b0f9001e
2 changed files with 179 additions and 19 deletions

View File

@ -102,6 +102,12 @@ const ALLDEBRID_HOST_INFO_TTL_MS = 60000;
const ALLDEBRID_START_STAGGER_MS = 2500;
const ARCHIVE_SETTLE_MIN_DELAY_MS = 1500;
const ARCHIVE_SETTLE_POLL_MS = 250;
const ARCHIVE_SETTLE_MAX_WAIT_MS = 5000;
const LARGE_BINARY_FILE_RE = /\.(?:part\d+\.rar|rar|r\d{2,3}|zip(?:\.\d+)?|7z(?:\.\d+)?|tar|gz|bz2|xz|iso|mkv|mp4|avi|mov|wmv|m4v|ts|m2ts|webm|mp3|flac|aac|wav)$/i;
function itemExpectedMinBytes(item: DownloadItem): number {
@ -445,7 +451,9 @@ function isExtractedLabel(statusText: string): boolean {
function isExtractErrorLabel(statusText: string): boolean {
const text = String(statusText || "").trim();
return /^entpacken\b/i.test(text) && /\berror\b/i.test(text);
return /^entpacken\b/i.test(text) && /\berror\b/i.test(text)
|| /^entpack-fehler\b/i.test(text)
|| /^entpacken\b.*\btimeout\b/i.test(text);
}
function shouldAutoRetryExtraction(statusText: string): boolean {
@ -3847,7 +3855,7 @@ export class DownloadManager extends EventEmitter {
if (item.status === "completed") {
const statusText = (item.fullStatus || "").trim();
// Preserve extraction-related statuses (Ausstehend, Warten auf Parts, etc.)
if (/^Entpacken\b/i.test(statusText) || isExtractedLabel(statusText) || /^Fertig\b/i.test(statusText)) {
if (/^Entpacken\b/i.test(statusText) || isExtractErrorLabel(statusText) || isExtractedLabel(statusText) || /^Fertig\b/i.test(statusText)) {
// keep as-is
} else {
item.fullStatus = this.settings.autoExtract
@ -4391,25 +4399,12 @@ export class DownloadManager extends EventEmitter {
}
if (hasValidSignature) {
// Valid signature + suggestRedownload means both JVM and legacy extractors failed
// (CRC/password error). Even with a valid header the content can be corrupt
// encrypted RAR5 produces "Checksum error" indistinguishable from wrong password.
// Force re-download ONCE; use autoRecoveredForRedownload Set for loop protection.
const redownloadKey = `${pkg.id}::${failure.archiveName}`;
if (this.autoRecoveredForRedownload.has(redownloadKey)) {
logger.warn(
`Auto-Recovery (${scope}): ${failure.archiveName} uebersprungen - ` +
`wurde bereits einmal per Re-Download versucht (Loop-Schutz)`
);
return 0;
}
this.autoRecoveredForRedownload.add(redownloadKey);
logger.warn(
`Auto-Recovery (${scope}): ${failure.archiveName} - Signatur gueltig, ` +
`beide Extraktoren fehlgeschlagen (suggestRedownload). ` +
`Erzwinge Re-Download aller ${archiveItems.length} Parts`
`Auto-Recovery (${scope}): ${failure.archiveName} uebersprungen - ` +
`Dateien haben korrekte Groesse und gueltige Archiv-Signatur, ` +
`wahrscheinlicher Passwort-/Extractor-Fall statt defektem Download`
);
corruptArchiveItems.push(...inspectedArchiveItems);
return 0;
}
logger.warn(
@ -4464,6 +4459,125 @@ export class DownloadManager extends EventEmitter {
return changed;
}
private async waitForCompletedArchiveFilesToSettle(
pkg: PackageEntry,
items: DownloadItem[],
signal: AbortSignal | undefined,
scope: "hybrid" | "full"
): Promise<void> {
const archiveItems = items.filter((item) =>
item.status === "completed" && isArchiveLikePath(item.targetPath || item.fileName || "")
);
if (archiveItems.length === 0) {
return;
}
const startedAt = nowMs();
const newestCompletionAt = archiveItems.reduce((maxTs, item) => Math.max(maxTs, Number(item.updatedAt || 0)), 0);
const minDelayMs = newestCompletionAt > 0
? Math.max(0, ARCHIVE_SETTLE_MIN_DELAY_MS - Math.max(0, startedAt - newestCompletionAt))
: 0;
if (minDelayMs > 0) {
logger.info(
`Extract-Settle (${scope}): warte ${minDelayMs}ms nach letztem Downloadabschluss ` +
`vor Entpacken: pkg=${pkg.name}, archiveItems=${archiveItems.length}`
);
this.logPackageForPackage(pkg, "INFO", "Archiv-Stabilisierung wartet", {
scope,
waitMs: minDelayMs,
archiveItems: archiveItems.length,
reason: "recent_completion"
});
pkg.postProcessLabel = "Archive stabilisieren...";
this.emitState();
let remainingMs = minDelayMs;
while (remainingMs > 0) {
if (signal?.aborted) {
return;
}
const sleepMs = Math.min(ARCHIVE_SETTLE_POLL_MS, remainingMs);
await sleep(sleepMs);
remainingMs -= sleepMs;
}
}
const deadlineAt = nowMs() + ARCHIVE_SETTLE_MAX_WAIT_MS;
const requiredStableRounds = minDelayMs > 0 ? 2 : 1;
let stableRounds = 0;
let lastSnapshot = "";
let pollCount = 0;
let lastPending = "";
while (stableRounds < requiredStableRounds && nowMs() < deadlineAt) {
if (signal?.aborted) {
return;
}
const snapshotParts: string[] = [];
const pending: string[] = [];
for (const item of archiveItems) {
const state = inspectPackageItemDiskState(pkg, item);
const label = item.fileName || item.id;
snapshotParts.push(`${item.id}:${state.reason}:${state.size}`);
if (state.reason !== "ok" || !state.diskPath) {
pending.push(`${label}:${state.reason}`);
continue;
}
try {
const fd = await fs.promises.open(state.diskPath, "r");
await fd.close();
} catch {
pending.push(`${label}:open_failed`);
}
}
pollCount += 1;
lastPending = pending.join(", ");
const snapshot = snapshotParts.join("|");
if (pending.length === 0) {
stableRounds = snapshot === lastSnapshot ? stableRounds + 1 : 1;
} else {
stableRounds = 0;
}
lastSnapshot = snapshot;
if (stableRounds >= requiredStableRounds) {
break;
}
await sleep(ARCHIVE_SETTLE_POLL_MS);
}
const settleMs = nowMs() - startedAt;
if (stableRounds >= requiredStableRounds) {
if (pollCount > 1 || minDelayMs > 0) {
logger.info(
`Extract-Settle (${scope}) abgeschlossen: pkg=${pkg.name}, archiveItems=${archiveItems.length}, ` +
`waitMs=${settleMs}, polls=${pollCount}`
);
this.logPackageForPackage(pkg, "INFO", "Archiv-Stabilisierung abgeschlossen", {
scope,
archiveItems: archiveItems.length,
waitMs: settleMs,
polls: pollCount
});
}
return;
}
logger.warn(
`Extract-Settle (${scope}) Timeout: pkg=${pkg.name}, archiveItems=${archiveItems.length}, ` +
`waitMs=${settleMs}, pending=${lastPending || "none"}`
);
this.logPackageForPackage(pkg, "WARN", "Archiv-Stabilisierung Timeout", {
scope,
archiveItems: archiveItems.length,
waitMs: settleMs,
pending: lastPending || "none"
});
}
/** 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 {
@ -7995,6 +8109,11 @@ export class DownloadManager extends EventEmitter {
}
try {
await this.waitForCompletedArchiveFilesToSettle(pkg, hybridItems, signal, "hybrid");
if (signal?.aborted) {
return 0;
}
const result = await extractPackageArchives({
packageDir: pkg.outputDir,
targetDir: pkg.extractDir,
@ -8446,6 +8565,16 @@ export class DownloadManager extends EventEmitter {
const fullStartTimes = new Map<string, number>();
let fullLastProgressCurrent: number | null = null;
await this.waitForCompletedArchiveFilesToSettle(
pkg,
completedItems,
extractAbortController.signal,
"full"
);
if (extractAbortController.signal.aborted) {
throw new Error(String(extractAbortController.signal.reason || "aborted:extract"));
}
const result = await extractPackageArchives({
packageDir: pkg.outputDir,
targetDir: pkg.extractDir,

View File

@ -2475,6 +2475,37 @@ describe("download manager", () => {
expect((manager as any).session.items[itemId].fullStatus).toBe("Entpacken - Error");
});
it("does not auto-reschedule extraction for completed items already marked as entpack-fehler", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root);
const {
session,
packageId,
itemId
} = createCompletedArchiveSession(root, "hybrid-entpack-fehler-hold", "episode.mkv");
session.items[itemId]!.fullStatus = "Entpack-Fehler: Checksum error in encrypted file";
session.packages[packageId]!.status = "queued";
const manager = new DownloadManager(
{
...defaultSettings(),
token: "rd-token",
outputDir: path.join(root, "downloads"),
extractDir: path.join(root, "extract"),
autoExtract: true,
hybridExtract: true
},
session,
createStoragePaths(path.join(root, "state"))
);
(manager as any).triggerPendingExtractions();
expect((manager as any).packagePostProcessTasks.has(packageId)).toBe(false);
expect((manager as any).session.items[itemId].fullStatus).toBe("Entpack-Fehler: Checksum error in encrypted file");
});
it("detects start conflicts when extract output already exists", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root);