Fix auto-recovery for stale archive parts
This commit is contained in:
parent
a322a16b7b
commit
fb036733e3
@ -42,7 +42,7 @@ function releaseTlsSkip(): void {
|
|||||||
}
|
}
|
||||||
import { cleanupCancelledPackageArtifactsAsync, removeDownloadLinkArtifacts, removeSampleArtifacts } from "./cleanup";
|
import { cleanupCancelledPackageArtifactsAsync, removeDownloadLinkArtifacts, removeSampleArtifacts } from "./cleanup";
|
||||||
import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, DebridService, MegaWebUnrestrictor, RealDebridWebUnrestrictor, checkRapidgatorOnline, fetchAllDebridHostInfo, getAvailableDebridLinkApiKeys } from "./debrid";
|
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 { validateFileAgainstManifest } from "./integrity";
|
||||||
import { logger } from "./logger";
|
import { logger } from "./logger";
|
||||||
import { StoragePaths, saveSession, saveSessionAsync, saveSettings, saveSettingsAsync } from "./storage";
|
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
|
/** 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 {
|
||||||
@ -7198,6 +7254,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
resolveArchiveItemsFromList(archiveName, items);
|
resolveArchiveItemsFromList(archiveName, items);
|
||||||
|
|
||||||
// Track archives for parallel hybrid extraction progress
|
// Track archives for parallel hybrid extraction progress
|
||||||
|
const autoRecoveredArchives = new Set<string>();
|
||||||
const hybridResolvedItems = new Map<string, DownloadItem[]>();
|
const hybridResolvedItems = new Map<string, DownloadItem[]>();
|
||||||
const hybridStartTimes = new Map<string, number>();
|
const hybridStartTimes = new Map<string, number>();
|
||||||
let hybridLastEmitAt = 0;
|
let hybridLastEmitAt = 0;
|
||||||
@ -7242,6 +7299,15 @@ export class DownloadManager extends EventEmitter {
|
|||||||
hybridMode: true,
|
hybridMode: true,
|
||||||
maxParallel: this.settings.maxParallelExtract || 2,
|
maxParallel: this.settings.maxParallelExtract || 2,
|
||||||
extractCpuPriority: "high",
|
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) => {
|
onProgress: (progress) => {
|
||||||
if (progress.phase === "preparing") {
|
if (progress.phase === "preparing") {
|
||||||
pkg.postProcessLabel = progress.archiveName || "Vorbereiten...";
|
pkg.postProcessLabel = progress.archiveName || "Vorbereiten...";
|
||||||
@ -7273,6 +7339,9 @@ export class DownloadManager extends EventEmitter {
|
|||||||
const initLabel = `Entpacken 0% · ${progress.archiveName}`;
|
const initLabel = `Entpacken 0% · ${progress.archiveName}`;
|
||||||
const initAt = nowMs();
|
const initAt = nowMs();
|
||||||
for (const entry of resolved) {
|
for (const entry of resolved) {
|
||||||
|
if (entry.status !== "completed" || isExtractedLabel(entry.fullStatus)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (!isExtractedLabel(entry.fullStatus)) {
|
if (!isExtractedLabel(entry.fullStatus)) {
|
||||||
entry.fullStatus = initLabel;
|
entry.fullStatus = initLabel;
|
||||||
entry.updatedAt = initAt;
|
entry.updatedAt = initAt;
|
||||||
@ -7293,10 +7362,9 @@ export class DownloadManager extends EventEmitter {
|
|||||||
? "Entpacken - Error"
|
? "Entpacken - Error"
|
||||||
: formatExtractDone(doneAt - startedAt);
|
: formatExtractDone(doneAt - startedAt);
|
||||||
for (const entry of archItems) {
|
for (const entry of archItems) {
|
||||||
if (!isExtractedLabel(entry.fullStatus)) {
|
if (entry.status !== "completed" || isExtractedLabel(entry.fullStatus)) continue;
|
||||||
entry.fullStatus = doneLabel;
|
entry.fullStatus = doneLabel;
|
||||||
entry.updatedAt = doneAt;
|
entry.updatedAt = doneAt;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
hybridResolvedItems.delete(progress.archiveName);
|
hybridResolvedItems.delete(progress.archiveName);
|
||||||
hybridStartTimes.delete(progress.archiveName);
|
hybridStartTimes.delete(progress.archiveName);
|
||||||
@ -7324,10 +7392,9 @@ export class DownloadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
const updatedAt = nowMs();
|
const updatedAt = nowMs();
|
||||||
for (const entry of archItems) {
|
for (const entry of archItems) {
|
||||||
if (!isExtractedLabel(entry.fullStatus) && entry.fullStatus !== label) {
|
if (entry.status !== "completed" || isExtractedLabel(entry.fullStatus) || entry.fullStatus === label) continue;
|
||||||
entry.fullStatus = label;
|
entry.fullStatus = label;
|
||||||
entry.updatedAt = updatedAt;
|
entry.updatedAt = updatedAt;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -7396,7 +7463,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
// downloading) as "Done".
|
// downloading) as "Done".
|
||||||
const updatedAt = nowMs();
|
const updatedAt = nowMs();
|
||||||
for (const entry of hybridItems) {
|
for (const entry of hybridItems) {
|
||||||
if (isExtractedLabel(entry.fullStatus)) {
|
if (entry.status !== "completed" || isExtractedLabel(entry.fullStatus)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const status = entry.fullStatus || "";
|
const status = entry.fullStatus || "";
|
||||||
@ -7417,7 +7484,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
logger.info(`Hybrid-Extract abgebrochen: pkg=${pkg.name}`);
|
logger.info(`Hybrid-Extract abgebrochen: pkg=${pkg.name}`);
|
||||||
const abortAt = nowMs();
|
const abortAt = nowMs();
|
||||||
for (const entry of hybridItems) {
|
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 || "")) {
|
if (/^Entpacken\b/i.test(entry.fullStatus || "") || /^Passwort\b/i.test(entry.fullStatus || "")) {
|
||||||
entry.fullStatus = "Entpacken abgebrochen (wird fortgesetzt)";
|
entry.fullStatus = "Entpacken abgebrochen (wird fortgesetzt)";
|
||||||
entry.updatedAt = abortAt;
|
entry.updatedAt = abortAt;
|
||||||
@ -7428,7 +7495,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
logger.warn(`Hybrid-Extract Fehler: pkg=${pkg.name}, reason=${compactErrorText(error)}`);
|
logger.warn(`Hybrid-Extract Fehler: pkg=${pkg.name}, reason=${compactErrorText(error)}`);
|
||||||
const errorAt = nowMs();
|
const errorAt = nowMs();
|
||||||
for (const entry of hybridItems) {
|
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 || "")) {
|
if (/^Entpacken\b/i.test(entry.fullStatus || "") || /^Passwort\b/i.test(entry.fullStatus || "")) {
|
||||||
entry.fullStatus = `Entpacken - Error`;
|
entry.fullStatus = `Entpacken - Error`;
|
||||||
entry.updatedAt = errorAt;
|
entry.updatedAt = errorAt;
|
||||||
@ -7609,6 +7676,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
}, extractTimeoutMs);
|
}, extractTimeoutMs);
|
||||||
try {
|
try {
|
||||||
// Track archives for parallel extraction progress
|
// Track archives for parallel extraction progress
|
||||||
|
const autoRecoveredArchives = new Set<string>();
|
||||||
const fullResolvedItems = new Map<string, DownloadItem[]>();
|
const fullResolvedItems = new Map<string, DownloadItem[]>();
|
||||||
const fullStartTimes = new Map<string, number>();
|
const fullStartTimes = new Map<string, number>();
|
||||||
let fullLastProgressCurrent: number | null = null;
|
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
|
// All downloads finished — use NORMAL OS priority so extraction runs at
|
||||||
// full speed (matching manual 7-Zip/WinRAR speed).
|
// full speed (matching manual 7-Zip/WinRAR speed).
|
||||||
extractCpuPriority: "high",
|
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) => {
|
onProgress: (progress) => {
|
||||||
if (progress.phase === "preparing") {
|
if (progress.phase === "preparing") {
|
||||||
pkg.postProcessLabel = progress.archiveName || "Vorbereiten...";
|
pkg.postProcessLabel = progress.archiveName || "Vorbereiten...";
|
||||||
@ -7660,10 +7737,9 @@ export class DownloadManager extends EventEmitter {
|
|||||||
const initLabel = `Entpacken 0% · ${progress.archiveName}`;
|
const initLabel = `Entpacken 0% · ${progress.archiveName}`;
|
||||||
const initAt = nowMs();
|
const initAt = nowMs();
|
||||||
for (const entry of resolved) {
|
for (const entry of resolved) {
|
||||||
if (!isExtractedLabel(entry.fullStatus)) {
|
if (entry.status !== "completed" || isExtractedLabel(entry.fullStatus)) continue;
|
||||||
entry.fullStatus = initLabel;
|
entry.fullStatus = initLabel;
|
||||||
entry.updatedAt = initAt;
|
entry.updatedAt = initAt;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
emitExtractStatus(`Entpacken ${progress.percent}% · ${progress.archiveName}`, true);
|
emitExtractStatus(`Entpacken ${progress.percent}% · ${progress.archiveName}`, true);
|
||||||
}
|
}
|
||||||
@ -7679,10 +7755,9 @@ export class DownloadManager extends EventEmitter {
|
|||||||
? "Entpacken - Error"
|
? "Entpacken - Error"
|
||||||
: formatExtractDone(doneAt - startedAt);
|
: formatExtractDone(doneAt - startedAt);
|
||||||
for (const entry of archiveItems) {
|
for (const entry of archiveItems) {
|
||||||
if (!isExtractedLabel(entry.fullStatus)) {
|
if (entry.status !== "completed" || isExtractedLabel(entry.fullStatus)) continue;
|
||||||
entry.fullStatus = doneLabel;
|
entry.fullStatus = doneLabel;
|
||||||
entry.updatedAt = doneAt;
|
entry.updatedAt = doneAt;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
fullResolvedItems.delete(progress.archiveName);
|
fullResolvedItems.delete(progress.archiveName);
|
||||||
fullStartTimes.delete(progress.archiveName);
|
fullStartTimes.delete(progress.archiveName);
|
||||||
@ -7709,10 +7784,9 @@ export class DownloadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
const updatedAt = nowMs();
|
const updatedAt = nowMs();
|
||||||
for (const entry of archiveItems) {
|
for (const entry of archiveItems) {
|
||||||
if (!isExtractedLabel(entry.fullStatus) && entry.fullStatus !== label) {
|
if (entry.status !== "completed" || isExtractedLabel(entry.fullStatus) || entry.fullStatus === label) continue;
|
||||||
entry.fullStatus = label;
|
entry.fullStatus = label;
|
||||||
entry.updatedAt = updatedAt;
|
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 || ""}`);
|
logger.info(`Post-Processing Entpacken Ende: pkg=${pkg.name}, extracted=${result.extracted}, failed=${result.failed}, lastError=${result.lastError || ""}`);
|
||||||
extractedCount = result.extracted;
|
extractedCount = result.extracted;
|
||||||
|
const autoRecoveredPending = completedItems.some((item) => item.status === "queued");
|
||||||
|
|
||||||
// Auto-rename wird in runDeferredPostExtraction ausgeführt (im Hintergrund),
|
// Auto-rename wird in runDeferredPostExtraction ausgeführt (im Hintergrund),
|
||||||
// damit der Slot sofort freigegeben wird.
|
// 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) {
|
if (result.failed > 0) {
|
||||||
const reason = compactErrorText(result.lastError || "Entpacken fehlgeschlagen");
|
const reason = compactErrorText(result.lastError || "Entpacken fehlgeschlagen");
|
||||||
const failAt = nowMs();
|
const failAt = nowMs();
|
||||||
for (const entry of completedItems) {
|
for (const entry of completedItems) {
|
||||||
// Preserve per-archive "Entpackt - Done (X.Xs)" labels for successfully extracted archives
|
// 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.fullStatus = `Entpack-Fehler: ${reason}`;
|
||||||
entry.updatedAt = failAt;
|
entry.updatedAt = failAt;
|
||||||
}
|
}
|
||||||
@ -7786,7 +7869,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
const timeoutReason = `Entpacken Timeout nach ${Math.ceil(extractTimeoutMs / 1000)}s`;
|
const timeoutReason = `Entpacken Timeout nach ${Math.ceil(extractTimeoutMs / 1000)}s`;
|
||||||
logger.error(`Post-Processing Entpacken Timeout: pkg=${pkg.name}`);
|
logger.error(`Post-Processing Entpacken Timeout: pkg=${pkg.name}`);
|
||||||
for (const entry of completedItems) {
|
for (const entry of completedItems) {
|
||||||
if (!isExtractedLabel(entry.fullStatus)) {
|
if (entry.status === "completed" && !isExtractedLabel(entry.fullStatus)) {
|
||||||
entry.fullStatus = `Entpack-Fehler: ${timeoutReason}`;
|
entry.fullStatus = `Entpack-Fehler: ${timeoutReason}`;
|
||||||
entry.updatedAt = nowMs();
|
entry.updatedAt = nowMs();
|
||||||
}
|
}
|
||||||
@ -7811,7 +7894,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
const reason = compactErrorText(error);
|
const reason = compactErrorText(error);
|
||||||
logger.error(`Post-Processing Entpacken Exception: pkg=${pkg.name}, reason=${reason}`);
|
logger.error(`Post-Processing Entpacken Exception: pkg=${pkg.name}, reason=${reason}`);
|
||||||
for (const entry of completedItems) {
|
for (const entry of completedItems) {
|
||||||
if (!isExtractedLabel(entry.fullStatus)) {
|
if (entry.status === "completed" && !isExtractedLabel(entry.fullStatus)) {
|
||||||
entry.fullStatus = `Entpack-Fehler: ${reason}`;
|
entry.fullStatus = `Entpack-Fehler: ${reason}`;
|
||||||
entry.updatedAt = nowMs();
|
entry.updatedAt = nowMs();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -110,6 +110,7 @@ export interface ExtractOptions {
|
|||||||
hybridMode?: boolean;
|
hybridMode?: boolean;
|
||||||
maxParallel?: number;
|
maxParallel?: number;
|
||||||
extractCpuPriority?: string;
|
extractCpuPriority?: string;
|
||||||
|
onArchiveFailure?: (failure: ExtractArchiveFailureInfo) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExtractProgressUpdate {
|
export interface ExtractProgressUpdate {
|
||||||
@ -127,6 +128,14 @@ export interface ExtractProgressUpdate {
|
|||||||
archiveSuccess?: boolean;
|
archiveSuccess?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ExtractArchiveFailureInfo {
|
||||||
|
archiveName: string;
|
||||||
|
errorText: string;
|
||||||
|
category: ExtractErrorCategory;
|
||||||
|
suggestRedownload: boolean;
|
||||||
|
jvmFailureReason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
const MAX_EXTRACT_OUTPUT_BUFFER = 48 * 1024;
|
const MAX_EXTRACT_OUTPUT_BUFFER = 48 * 1024;
|
||||||
const EXTRACT_PROGRESS_FILE = ".rd_extract_progress.json";
|
const EXTRACT_PROGRESS_FILE = ".rd_extract_progress.json";
|
||||||
const EXTRACT_BASE_TIMEOUT_MS = 6 * 60 * 1000;
|
const EXTRACT_BASE_TIMEOUT_MS = 6 * 60 * 1000;
|
||||||
@ -532,6 +541,26 @@ export type ExtractErrorCategory =
|
|||||||
| "no_extractor"
|
| "no_extractor"
|
||||||
| "unknown";
|
| "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 {
|
export function classifyExtractionError(errorText: string): ExtractErrorCategory {
|
||||||
const text = String(errorText || "").toLowerCase();
|
const text = String(errorText || "").toLowerCase();
|
||||||
if (text.includes("aborted:extract") || text.includes("extract_aborted")) return "aborted";
|
if (text.includes("aborted:extract") || text.includes("extract_aborted")) return "aborted";
|
||||||
@ -1146,6 +1175,20 @@ function finishDaemonRequest(result: JvmExtractResult): void {
|
|||||||
req.resolve(result);
|
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 {
|
function handleDaemonLine(line: string): void {
|
||||||
const trimmed = String(line || "").trim();
|
const trimmed = String(line || "").trim();
|
||||||
if (!trimmed) return;
|
if (!trimmed) return;
|
||||||
@ -1162,27 +1205,41 @@ function handleDaemonLine(line: string): void {
|
|||||||
const code = parseInt(trimmed.slice("RD_REQUEST_DONE ".length).trim(), 10);
|
const code = parseInt(trimmed.slice("RD_REQUEST_DONE ".length).trim(), 10);
|
||||||
const req = daemonCurrentRequest;
|
const req = daemonCurrentRequest;
|
||||||
if (!req) return;
|
if (!req) return;
|
||||||
const elapsedMs = Date.now() - req.startedAt;
|
const finalize = (): void => {
|
||||||
logger.info(
|
if (daemonCurrentRequest !== req) {
|
||||||
`JVM Daemon Request Ende: archive=${req.archiveName}, code=${code}, ms=${elapsedMs}, pwCandidates=${req.passwordCount}, ` +
|
return;
|
||||||
`bestPercent=${req.parseState.bestPercent}, backend=${req.parseState.backend || "unknown"}, usedPassword=${req.parseState.usedPassword ? "yes" : "no"}`
|
}
|
||||||
);
|
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}`;
|
const message = cleanErrorText(req.parseState.reportedError || daemonOutput) || `Exit Code ${code}`;
|
||||||
finishDaemonRequest({
|
finishDaemonRequest({
|
||||||
ok: false, missingCommand: false, missingRuntime: isJvmRuntimeMissingError(message),
|
ok: false, missingCommand: false, missingRuntime: isJvmRuntimeMissingError(message),
|
||||||
aborted: false, timedOut: false, errorText: message,
|
aborted: false, timedOut: false, errorText: message,
|
||||||
usedPassword: req.parseState.usedPassword, backend: req.parseState.backend
|
usedPassword: req.parseState.usedPassword, backend: req.parseState.backend
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (code !== 0 && !req.parseState.reportedError) {
|
||||||
|
setTimeout(finalize, 40);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
finalize();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1771,6 +1828,7 @@ async function runExternalExtract(
|
|||||||
const archiveName = path.basename(archivePath);
|
const archiveName = path.basename(archivePath);
|
||||||
const totalStartedAt = Date.now();
|
const totalStartedAt = Date.now();
|
||||||
let jvmFailureReason = "";
|
let jvmFailureReason = "";
|
||||||
|
let jvmCodecError = false;
|
||||||
let fallbackFromJvm = false;
|
let fallbackFromJvm = false;
|
||||||
logger.info(`Extract-Backend Start: archive=${archiveName}, mode=${backendMode}, pwCandidates=${passwordCandidates.length}, timeoutMs=${timeoutMs}, hybrid=${hybridMode}`);
|
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")
|
const isCodecError = jvmFailureLower.includes("registered codecs")
|
||||||
|| jvmFailureLower.includes("can not open")
|
|| jvmFailureLower.includes("can not open")
|
||||||
|| jvmFailureLower.includes("cannot open archive");
|
|| jvmFailureLower.includes("cannot open archive");
|
||||||
|
jvmCodecError = isCodecError;
|
||||||
const isWrongPassword = jvmFailureReason.includes("WRONG_PASSWORD")
|
const isWrongPassword = jvmFailureReason.includes("WRONG_PASSWORD")
|
||||||
|| jvmFailureLower.includes("wrong password");
|
|| jvmFailureLower.includes("wrong password");
|
||||||
const shouldFallbackToLegacy = isUnsupportedMethod || isCodecError || isWrongPassword;
|
const shouldFallbackToLegacy = isUnsupportedMethod || isCodecError || isWrongPassword;
|
||||||
@ -1854,52 +1913,61 @@ async function runExternalExtract(
|
|||||||
let password: string;
|
let password: string;
|
||||||
let usedCommand = command;
|
let usedCommand = command;
|
||||||
try {
|
try {
|
||||||
password = await runExternalExtractInner(
|
try {
|
||||||
command,
|
password = await runExternalExtractInner(
|
||||||
archivePath,
|
command,
|
||||||
effectiveTargetDir,
|
archivePath,
|
||||||
conflictMode,
|
effectiveTargetDir,
|
||||||
passwordCandidates,
|
conflictMode,
|
||||||
onArchiveProgress,
|
passwordCandidates,
|
||||||
signal,
|
onArchiveProgress,
|
||||||
timeoutMs,
|
signal,
|
||||||
hybridMode,
|
timeoutMs,
|
||||||
onPasswordAttempt,
|
hybridMode,
|
||||||
forceFlatMode,
|
onPasswordAttempt,
|
||||||
flatModeResult
|
forceFlatMode,
|
||||||
);
|
flatModeResult
|
||||||
} catch (primaryError) {
|
);
|
||||||
// If the primary extractor (typically 7-Zip) fails on a RAR archive,
|
} catch (primaryError) {
|
||||||
// try the alternative extractor (UnRAR/WinRAR) which handles RAR natively.
|
// If the primary extractor (typically 7-Zip) fails on a RAR archive,
|
||||||
const isRar = /\.rar$/i.test(archiveName) || /\.r\d{2,3}$/i.test(archiveName);
|
// try the alternative extractor (UnRAR/WinRAR) which handles RAR natively.
|
||||||
const errText = String((primaryError as Error)?.message || primaryError || "");
|
const isRar = /\.rar$/i.test(archiveName) || /\.r\d{2,3}$/i.test(archiveName);
|
||||||
const isPasswordOrCorrupt = /wrong.password|checksum error|corrupt/i.test(errText);
|
const errText = String((primaryError as Error)?.message || primaryError || "");
|
||||||
if (isRar && isPasswordOrCorrupt && !signal?.aborted) {
|
const isPasswordOrCorrupt = /wrong.password|checksum error|corrupt/i.test(errText);
|
||||||
const alt = await findAlternativeExtractor(command);
|
if (isRar && isPasswordOrCorrupt && !signal?.aborted) {
|
||||||
if (alt) {
|
const alt = await findAlternativeExtractor(command);
|
||||||
const altName = path.basename(alt).replace(/\.exe$/i, "");
|
if (alt) {
|
||||||
logger.info(`Legacy-Fallback: ${path.basename(command)} fehlgeschlagen bei RAR, versuche ${altName}: ${archiveName}`);
|
const altName = path.basename(alt).replace(/\.exe$/i, "");
|
||||||
usedCommand = alt;
|
logger.info(`Legacy-Fallback: ${path.basename(command)} fehlgeschlagen bei RAR, versuche ${altName}: ${archiveName}`);
|
||||||
password = await runExternalExtractInner(
|
usedCommand = alt;
|
||||||
alt,
|
password = await runExternalExtractInner(
|
||||||
archivePath,
|
alt,
|
||||||
effectiveTargetDir,
|
archivePath,
|
||||||
conflictMode,
|
effectiveTargetDir,
|
||||||
passwordCandidates,
|
conflictMode,
|
||||||
onArchiveProgress,
|
passwordCandidates,
|
||||||
signal,
|
onArchiveProgress,
|
||||||
timeoutMs,
|
signal,
|
||||||
hybridMode,
|
timeoutMs,
|
||||||
onPasswordAttempt,
|
hybridMode,
|
||||||
forceFlatMode,
|
onPasswordAttempt,
|
||||||
flatModeResult
|
forceFlatMode,
|
||||||
);
|
flatModeResult
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
throw primaryError;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
throw primaryError;
|
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 legacyMs = Date.now() - legacyStartedAt;
|
||||||
const extractorName = path.basename(usedCommand).replace(/\.exe$/i, "");
|
const extractorName = path.basename(usedCommand).replace(/\.exe$/i, "");
|
||||||
@ -2754,6 +2822,14 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|||||||
failed += 1;
|
failed += 1;
|
||||||
lastError = errorText;
|
lastError = errorText;
|
||||||
const errorCategory = classifyExtractionError(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}`);
|
logger.error(`Entpack-Fehler ${path.basename(archivePath)} [${errorCategory}]: ${errorText}`);
|
||||||
if (errorCategory === "wrong_password" && learnedPassword) {
|
if (errorCategory === "wrong_password" && learnedPassword) {
|
||||||
learnedPassword = "";
|
learnedPassword = "";
|
||||||
|
|||||||
@ -1950,6 +1950,98 @@ describe("download manager", () => {
|
|||||||
expect(snapshot.session.packages[packageId]?.status).toBe("queued");
|
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 () => {
|
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);
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import {
|
|||||||
buildExternalExtractArgs,
|
buildExternalExtractArgs,
|
||||||
collectArchiveCleanupTargets,
|
collectArchiveCleanupTargets,
|
||||||
extractPackageArchives,
|
extractPackageArchives,
|
||||||
|
type ExtractArchiveFailureInfo,
|
||||||
archiveFilenamePasswords,
|
archiveFilenamePasswords,
|
||||||
detectArchiveSignature,
|
detectArchiveSignature,
|
||||||
classifyExtractionError,
|
classifyExtractionError,
|
||||||
@ -999,6 +1000,36 @@ describe("extractor", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("password discovery", () => {
|
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 () => {
|
it("extracts first archive serially before parallel pool when multiple passwords", async () => {
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-pwdisc-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-pwdisc-"));
|
||||||
tempDirs.push(root);
|
tempDirs.push(root);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user