Fix extraction retry loop on CRC/password failures
This commit is contained in:
parent
9eb28cee2e
commit
e4b0f9001e
@ -102,6 +102,12 @@ const ALLDEBRID_HOST_INFO_TTL_MS = 60000;
|
|||||||
|
|
||||||
const ALLDEBRID_START_STAGGER_MS = 2500;
|
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;
|
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 {
|
function itemExpectedMinBytes(item: DownloadItem): number {
|
||||||
@ -445,7 +451,9 @@ function isExtractedLabel(statusText: string): boolean {
|
|||||||
|
|
||||||
function isExtractErrorLabel(statusText: string): boolean {
|
function isExtractErrorLabel(statusText: string): boolean {
|
||||||
const text = String(statusText || "").trim();
|
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 {
|
function shouldAutoRetryExtraction(statusText: string): boolean {
|
||||||
@ -3847,7 +3855,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
if (item.status === "completed") {
|
if (item.status === "completed") {
|
||||||
const statusText = (item.fullStatus || "").trim();
|
const statusText = (item.fullStatus || "").trim();
|
||||||
// Preserve extraction-related statuses (Ausstehend, Warten auf Parts, etc.)
|
// 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
|
// keep as-is
|
||||||
} else {
|
} else {
|
||||||
item.fullStatus = this.settings.autoExtract
|
item.fullStatus = this.settings.autoExtract
|
||||||
@ -4391,25 +4399,12 @@ export class DownloadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (hasValidSignature) {
|
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(
|
logger.warn(
|
||||||
`Auto-Recovery (${scope}): ${failure.archiveName} - Signatur gueltig, ` +
|
`Auto-Recovery (${scope}): ${failure.archiveName} uebersprungen - ` +
|
||||||
`beide Extraktoren fehlgeschlagen (suggestRedownload). ` +
|
`Dateien haben korrekte Groesse und gueltige Archiv-Signatur, ` +
|
||||||
`Erzwinge Re-Download aller ${archiveItems.length} Parts`
|
`wahrscheinlicher Passwort-/Extractor-Fall statt defektem Download`
|
||||||
);
|
);
|
||||||
corruptArchiveItems.push(...inspectedArchiveItems);
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.warn(
|
logger.warn(
|
||||||
@ -4464,6 +4459,125 @@ export class DownloadManager extends EventEmitter {
|
|||||||
return changed;
|
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
|
/** 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. */
|
* them back to the original filename if the original path is not claimed by another item. */
|
||||||
private fixDuplicateSuffixFiles(): void {
|
private fixDuplicateSuffixFiles(): void {
|
||||||
@ -7995,6 +8109,11 @@ export class DownloadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
await this.waitForCompletedArchiveFilesToSettle(pkg, hybridItems, signal, "hybrid");
|
||||||
|
if (signal?.aborted) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
const result = await extractPackageArchives({
|
const result = await extractPackageArchives({
|
||||||
packageDir: pkg.outputDir,
|
packageDir: pkg.outputDir,
|
||||||
targetDir: pkg.extractDir,
|
targetDir: pkg.extractDir,
|
||||||
@ -8446,6 +8565,16 @@ export class DownloadManager extends EventEmitter {
|
|||||||
const fullStartTimes = new Map<string, number>();
|
const fullStartTimes = new Map<string, number>();
|
||||||
let fullLastProgressCurrent: number | null = null;
|
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({
|
const result = await extractPackageArchives({
|
||||||
packageDir: pkg.outputDir,
|
packageDir: pkg.outputDir,
|
||||||
targetDir: pkg.extractDir,
|
targetDir: pkg.extractDir,
|
||||||
|
|||||||
@ -2475,6 +2475,37 @@ describe("download manager", () => {
|
|||||||
expect((manager as any).session.items[itemId].fullStatus).toBe("Entpacken - Error");
|
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 () => {
|
it("detects start conflicts when extract output already exists", async () => {
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||||
tempDirs.push(root);
|
tempDirs.push(root);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user