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
+129 -44
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