Fix resume completion and rar fallback handling
This commit is contained in:
parent
4a27fd72c7
commit
2123a48bea
@ -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 {
|
private clearHybridArchiveState(packageId: string, archiveKey?: string): void {
|
||||||
if (!archiveKey) {
|
if (!archiveKey) {
|
||||||
this.hybridExtractedPaths.delete(packageId);
|
this.hybridExtractedPaths.delete(packageId);
|
||||||
@ -4872,7 +4969,14 @@ export class DownloadManager extends EventEmitter {
|
|||||||
const success = items.filter((item) => item.status === "completed").length;
|
const success = items.filter((item) => item.status === "completed").length;
|
||||||
const failed = items.filter((item) => item.status === "failed").length;
|
const failed = items.filter((item) => item.status === "failed").length;
|
||||||
const cancelled = items.filter((item) => item.status === "cancelled").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
|
// Hybrid extraction recovery: not all items done, but some completed
|
||||||
// with pending extraction status → re-label and trigger post-processing
|
// 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 success = items.filter((item) => item.status === "completed").length;
|
||||||
const failed = items.filter((item) => item.status === "failed").length;
|
const failed = items.filter((item) => item.status === "failed").length;
|
||||||
const cancelled = items.filter((item) => item.status === "cancelled").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
|
// Full extraction: all items done, no failures
|
||||||
if (allDone && failed === 0 && success > 0) {
|
if (allDone && failed === 0 && success > 0) {
|
||||||
@ -6404,48 +6515,10 @@ export class DownloadManager extends EventEmitter {
|
|||||||
// even though the download finished successfully.
|
// even though the download finished successfully.
|
||||||
if (item.downloadedBytes > 0) {
|
if (item.downloadedBytes > 0) {
|
||||||
const targetFile = this.claimedTargetPathByItem.get(item.id) || "";
|
const targetFile = this.claimedTargetPathByItem.get(item.id) || "";
|
||||||
const expectedMin = itemExpectedMinBytes(item);
|
if (this.tryFinalizeItemFromDisk(pkg, item, "Stall-Recovery", stallErrorText)) {
|
||||||
let fileAlreadyComplete = false;
|
return;
|
||||||
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 */ }
|
|
||||||
}
|
}
|
||||||
// Reset partial download so next attempt uses a fresh link
|
if (targetFile) {
|
||||||
if (!fileAlreadyComplete && targetFile) {
|
|
||||||
try { fs.rmSync(targetFile, { force: true }); } catch { /* ignore */ }
|
try { fs.rmSync(targetFile, { force: true }); } catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
this.releaseTargetPath(item.id);
|
this.releaseTargetPath(item.id);
|
||||||
@ -6479,6 +6552,9 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.retryStateByItem.delete(item.id);
|
this.retryStateByItem.delete(item.id);
|
||||||
} else {
|
} else {
|
||||||
const errorText = compactErrorText(error);
|
const errorText = compactErrorText(error);
|
||||||
|
if (this.tryFinalizeItemFromDisk(pkg, item, "Error-Recovery", errorText)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.logPackageForItem(item, "WARN", "Download-Fehlerpfad erreicht", {
|
this.logPackageForItem(item, "WARN", "Download-Fehlerpfad erreicht", {
|
||||||
error: errorText,
|
error: errorText,
|
||||||
abortReason: reason || "none"
|
abortReason: reason || "none"
|
||||||
@ -8596,7 +8672,14 @@ export class DownloadManager extends EventEmitter {
|
|||||||
recoveryMs
|
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) {
|
if (!allDone && this.settings.hybridExtract && this.settings.autoExtract && failed === 0 && success > 0) {
|
||||||
pkg.postProcessLabel = "Entpacken vorbereiten...";
|
pkg.postProcessLabel = "Entpacken vorbereiten...";
|
||||||
@ -8713,6 +8796,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
throw new Error(String(extractAbortController.signal.reason || "aborted:extract"));
|
throw new Error(String(extractAbortController.signal.reason || "aborted:extract"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fullArchiveSet = await this.findFullExtractArchiveSet(pkg, completedItems);
|
||||||
const result = await extractPackageArchives({
|
const result = await extractPackageArchives({
|
||||||
packageDir: pkg.outputDir,
|
packageDir: pkg.outputDir,
|
||||||
targetDir: pkg.extractDir,
|
targetDir: pkg.extractDir,
|
||||||
@ -8723,6 +8807,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
passwordList: this.settings.archivePasswordList,
|
passwordList: this.settings.archivePasswordList,
|
||||||
signal: extractAbortController.signal,
|
signal: extractAbortController.signal,
|
||||||
packageId,
|
packageId,
|
||||||
|
onlyArchives: fullArchiveSet,
|
||||||
skipPostCleanup: true,
|
skipPostCleanup: true,
|
||||||
maxParallel: this.settings.maxParallelExtract || 2,
|
maxParallel: this.settings.maxParallelExtract || 2,
|
||||||
// All downloads finished — use NORMAL OS priority so extraction runs at
|
// All downloads finished — use NORMAL OS priority so extraction runs at
|
||||||
|
|||||||
@ -594,11 +594,18 @@ export type ExtractErrorCategory =
|
|||||||
type ExtractionErrorWithHints = Error & {
|
type ExtractionErrorWithHints = Error & {
|
||||||
suggestRedownload?: boolean;
|
suggestRedownload?: boolean;
|
||||||
jvmFailureReason?: string;
|
jvmFailureReason?: string;
|
||||||
|
legacyBestPercent?: number;
|
||||||
|
legacyExtractor?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function withExtractionErrorHints(
|
function withExtractionErrorHints(
|
||||||
error: unknown,
|
error: unknown,
|
||||||
hints: { suggestRedownload?: boolean; jvmFailureReason?: string }
|
hints: {
|
||||||
|
suggestRedownload?: boolean;
|
||||||
|
jvmFailureReason?: string;
|
||||||
|
legacyBestPercent?: number;
|
||||||
|
legacyExtractor?: string;
|
||||||
|
}
|
||||||
): Error {
|
): Error {
|
||||||
const base = error instanceof Error ? error : new Error(String(error || "Entpacken fehlgeschlagen"));
|
const base = error instanceof Error ? error : new Error(String(error || "Entpacken fehlgeschlagen"));
|
||||||
const enhanced = base as ExtractionErrorWithHints;
|
const enhanced = base as ExtractionErrorWithHints;
|
||||||
@ -608,6 +615,12 @@ function withExtractionErrorHints(
|
|||||||
if (hints.jvmFailureReason) {
|
if (hints.jvmFailureReason) {
|
||||||
enhanced.jvmFailureReason = 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;
|
return enhanced;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -624,6 +637,37 @@ export function classifyExtractionError(errorText: string): ExtractErrorCategory
|
|||||||
return "unknown";
|
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 {
|
function isExtractAbortError(errorText: string): boolean {
|
||||||
const text = String(errorText || "").toLowerCase();
|
const text = String(errorText || "").toLowerCase();
|
||||||
return text.includes("aborted:extract") || text.includes("extract_aborted") || text.includes("noextractor:skipped");
|
return text.includes("aborted:extract") || text.includes("extract_aborted") || text.includes("noextractor:skipped");
|
||||||
@ -2136,22 +2180,22 @@ async function runExternalExtract(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (legacyError) {
|
} catch (legacyError) {
|
||||||
const legacyText = String((legacyError as Error)?.message || legacyError || "");
|
const initialLegacyText = String((legacyError as Error)?.message || legacyError || "");
|
||||||
const legacyCategory = classifyExtractionError(legacyText);
|
const initialLegacyCategory = classifyExtractionError(initialLegacyText);
|
||||||
const isCrcOrWrongPw = legacyCategory === "crc_error" || legacyCategory === "wrong_password";
|
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 ──
|
// Retry once after a short delay to let Windows flush freshly completed archive parts.
|
||||||
// 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.
|
|
||||||
if (isCrcOrWrongPw && !signal?.aborted) {
|
if (isCrcOrWrongPw && !signal?.aborted) {
|
||||||
const retryDelayMs = 2500;
|
const retryDelayMs = 2500;
|
||||||
logger.warn(
|
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);
|
await extractRetryDelay(retryDelayMs);
|
||||||
if (!signal?.aborted) {
|
if (!signal?.aborted) {
|
||||||
try {
|
try {
|
||||||
@ -2175,27 +2219,86 @@ async function runExternalExtract(
|
|||||||
onLog?.("INFO", `Legacy-Retry erfolgreich: ${archiveName}`);
|
onLog?.("INFO", `Legacy-Retry erfolgreich: ${archiveName}`);
|
||||||
password = retryPassword;
|
password = retryPassword;
|
||||||
usedCommand = retryCmd;
|
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) {
|
} catch (retryError) {
|
||||||
const retryText = String((retryError as Error)?.message || retryError || "");
|
const retryText = String((retryError as Error)?.message || retryError || "");
|
||||||
const retryCategory = classifyExtractionError(retryText);
|
const retryCategory = classifyExtractionError(retryText);
|
||||||
logger.warn(`Legacy-Retry ebenfalls fehlgeschlagen (${retryCategory}): ${archiveName}`);
|
logger.warn(`Legacy-Retry ebenfalls fehlgeschlagen (${retryCategory}): ${archiveName}`);
|
||||||
onLog?.("WARN", `Legacy-Retry ebenfalls fehlgeschlagen (${retryCategory}): ${archiveName}`);
|
onLog?.("WARN", `Legacy-Retry ebenfalls fehlgeschlagen (${retryCategory}): ${archiveName}`);
|
||||||
const suggestRedownload = jvmCodecError && (retryCategory === "crc_error" || retryCategory === "wrong_password");
|
const suggestRedownload = jvmCodecError && (retryCategory === "crc_error" || retryCategory === "wrong_password");
|
||||||
throw withExtractionErrorHints(retryError, {
|
finalLegacyError = withExtractionErrorHints(retryError, {
|
||||||
suggestRedownload,
|
suggestRedownload,
|
||||||
jvmFailureReason: jvmFailureReason || undefined
|
jvmFailureReason: jvmFailureReason || undefined
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
throw legacyError;
|
finalLegacyError = withExtractionErrorHints(legacyError, {
|
||||||
|
jvmFailureReason: jvmFailureReason || undefined
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const suggestRedownload = jvmCodecError && isCrcOrWrongPw;
|
const suggestRedownload = jvmCodecError && isCrcOrWrongPw;
|
||||||
throw withExtractionErrorHints(legacyError, {
|
finalLegacyError = withExtractionErrorHints(legacyError, {
|
||||||
suggestRedownload,
|
suggestRedownload,
|
||||||
jvmFailureReason: jvmFailureReason || undefined
|
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 legacyMs = Date.now() - legacyStartedAt;
|
||||||
const extractorName = path.basename(usedCommand).replace(/\.exe$/i, "");
|
const extractorName = path.basename(usedCommand).replace(/\.exe$/i, "");
|
||||||
@ -2267,7 +2370,7 @@ async function runExternalExtractInner(
|
|||||||
if (result.timedOut || result.missingCommand) break;
|
if (result.timedOut || result.missingCommand) break;
|
||||||
lastError = result.errorText;
|
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) {
|
for (const password of passwords) {
|
||||||
@ -2356,7 +2459,7 @@ async function runExternalExtractInner(
|
|||||||
resolvedExtractorCommand = null;
|
resolvedExtractorCommand = null;
|
||||||
resolveFailureReason = NO_EXTRACTOR_MESSAGE;
|
resolveFailureReason = NO_EXTRACTOR_MESSAGE;
|
||||||
resolveFailureAt = Date.now();
|
resolveFailureAt = Date.now();
|
||||||
throw new Error(NO_EXTRACTOR_MESSAGE);
|
throw withExtractionErrorHints(new Error(NO_EXTRACTOR_MESSAGE), { legacyBestPercent: bestPercent, legacyExtractor: extractorName });
|
||||||
}
|
}
|
||||||
|
|
||||||
lastError = result.errorText;
|
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)
|
// Delay helper for extraction retries (allows file handles to be released on Windows)
|
||||||
|
|||||||
@ -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 () => {
|
it("restarts from zero after repeated resume underflow on fresh direct links", 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);
|
||||||
@ -762,7 +823,7 @@ describe("download manager", () => {
|
|||||||
|
|
||||||
const item = Object.values(manager.getSnapshot().session.items)[0];
|
const item = Object.values(manager.getSnapshot().session.items)[0];
|
||||||
expect(item?.status).toBe("failed");
|
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);
|
expect(item?.downloadedBytes).toBe(actual.length);
|
||||||
} finally {
|
} finally {
|
||||||
server.close();
|
server.close();
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import {
|
|||||||
orderExtractorCandidatesForArchive,
|
orderExtractorCandidatesForArchive,
|
||||||
resolveExtractorBackendModeForArchive,
|
resolveExtractorBackendModeForArchive,
|
||||||
resolveExtractorBackendMode,
|
resolveExtractorBackendMode,
|
||||||
|
shouldFallbackLegacyRarToJvm,
|
||||||
} from "../src/main/extractor";
|
} from "../src/main/extractor";
|
||||||
|
|
||||||
const tempDirs: string[] = [];
|
const tempDirs: string[] = [];
|
||||||
@ -1183,6 +1184,25 @@ describe("extractor", () => {
|
|||||||
expect(resolveExtractorBackendModeForArchive("C:\\Downloads\\episode.r00", undefined, false, "win32")).toBe("legacy");
|
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", () => {
|
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.zip", undefined, false, "win32")).toBe("auto");
|
||||||
expect(resolveExtractorBackendModeForArchive("C:\\Downloads\\episode.part01.rar", "jvm", false, "win32")).toBe("jvm");
|
expect(resolveExtractorBackendModeForArchive("C:\\Downloads\\episode.part01.rar", "jvm", false, "win32")).toBe("jvm");
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user