User-Report: ZIP scheitert mit wrong_password -> Passwort zur Liste hinzufügen + Settings speichern -> "Jetzt entpacken" scheitert WIEDER -> erst nach App-Neustart klappt "Jetzt entpacken". Analyse (eigenständig + zweiter Analyse-Agent, gesamte TS+Java-Kette): Renderer-Save -> updateSettings (fingerprint enthält archivePasswordList) -> setSettings (this.settings aktualisiert) -> extractNow -> runPackagePostProcessing -> extractPackageArchives (passwordList: this.settings.archivePasswordList) -> archivePasswordCandidates (enthält neues PW) -> Daemon bekommt Passwörter PRO REQUEST. Java-Daemon probiert alle PW frisch, kein Per-Archiv-Cache; zip4j/ sevenzipjbinding ohne relevanten static State. Alle Pfade propagieren die neue Liste korrekt — statisch ist KEIN Bug auffindbar. Verifikation per Ausschluss: die EINZIGE zustandsbehaftete Komponente, die ein App-Neustart zurücksetzt und ein Settings-Save NICHT, ist der langlebige JVM-Daemon-Prozess (+ der In-Memory Learned-Password-Cache). Der User bestätigt empirisch, dass ein Neustart (= frischer Daemon) es fixt. Fix: neue resetExtractorCachesForPasswordChange() repliziert den Neustart-Effekt am Extractor-Subsystem — bei Änderung der Passwortliste in setSettings wird der Learned-Password-Cache geleert und der idle JVM-Daemon heruntergefahren, sodass die nächste Extraktion frisch mit der neuen Liste startet. Beschäftigter Daemon wird nicht abgebrochen (laufende Extraktion bleibt unangetastet). Diagnostik: setSettings loggt jetzt PW-Anzahl + Reset-Resultat. Zusammen mit den bestehenden "Archiv-Passwortliste: passwordCount=N"-Logs lässt sich bei erneutem Auftreten in 30s unterscheiden, ob das PW beim Extractor ankommt (H2, Fix greift) oder nicht (H1, dann TS-upstream). Best-bet per Ausschluss + Logs zur finalen Bestätigung beim nächsten Repro. 621 Tests grün, tsc-Fehlerzahl unverändert (9 pre-existing). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
3580 lines
144 KiB
TypeScript
3580 lines
144 KiB
TypeScript
// ════════════════════════════════════════════════════════════════════════════
|
||
// Sektion 1 — Imports & Konstanten
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
|
||
import fs from "node:fs";
|
||
import path from "node:path";
|
||
import os from "node:os";
|
||
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
|
||
import AdmZip from "adm-zip";
|
||
import { CleanupMode, ConflictMode } from "../shared/types";
|
||
import { logger } from "./logger";
|
||
import { removeDownloadLinkArtifacts, removeSampleArtifacts } from "./cleanup";
|
||
|
||
import crypto from "node:crypto";
|
||
|
||
const DEFAULT_ARCHIVE_PASSWORDS = ["", "serienfans.org", "serienjunkies.org"];
|
||
const NO_EXTRACTOR_MESSAGE = "Kein nativer Entpacker gefunden (7-Zip/WinRAR). Bitte 7-Zip oder WinRAR installieren.";
|
||
const NO_JVM_EXTRACTOR_MESSAGE = "7-Zip-JBinding Runtime nicht gefunden. Bitte resources/extractor-jvm prüfen.";
|
||
const JVM_EXTRACTOR_MAIN_CLASS = "com.sucukdeluxe.extractor.JBindExtractorMain";
|
||
const JVM_EXTRACTOR_CLASSES_SUBDIR = "classes";
|
||
const JVM_EXTRACTOR_LIB_SUBDIR = "lib";
|
||
const JVM_EXTRACTOR_REQUIRED_LIBS = [
|
||
"sevenzipjbinding.jar",
|
||
"sevenzipjbinding-all-platforms.jar",
|
||
"zip4j.jar"
|
||
];
|
||
|
||
let resolvedExtractorCommand: string | null = null;
|
||
let resolveFailureReason = "";
|
||
let resolveFailureAt = 0;
|
||
let externalExtractorSupportsPerfFlags = true;
|
||
let resolveExtractorCommandInFlight: Promise<string> | null = null;
|
||
|
||
const EXTRACTOR_RETRY_AFTER_MS = 30_000;
|
||
const DEFAULT_ZIP_ENTRY_MEMORY_LIMIT_MB = 256;
|
||
const MAX_EXTRACT_OUTPUT_BUFFER = 48 * 1024;
|
||
const EXTRACT_PROGRESS_FILE = ".rd_extract_progress.json";
|
||
const EXTRACT_BASE_TIMEOUT_MS = 6 * 60 * 1000;
|
||
const EXTRACT_PER_GIB_TIMEOUT_MS = 4 * 60 * 1000;
|
||
const EXTRACT_MAX_TIMEOUT_MS = 120 * 60 * 1000;
|
||
const ARCHIVE_SORT_COLLATOR = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
|
||
const DISK_SPACE_SAFETY_FACTOR = 1.1;
|
||
const NESTED_EXTRACT_BLACKLIST_RE = /\.(iso|img|bin|dmg|vhd|vhdx|vmdk|wim)$/i;
|
||
const PACKAGE_PASSWORD_CACHE_LIMIT = 256;
|
||
const packageLearnedPasswords = new Map<string, string>();
|
||
const EXTRACTOR_PROBE_TIMEOUT_MS = 8_000;
|
||
const DEFAULT_EXTRACT_CPU_BUDGET_PERCENT = 80;
|
||
let currentExtractCpuPriority: string | undefined;
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// Sektion 2 — Types & Interfaces
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
|
||
export interface ExtractOptions {
|
||
packageDir: string;
|
||
targetDir: string;
|
||
cleanupMode: CleanupMode;
|
||
conflictMode: ConflictMode;
|
||
removeLinks: boolean;
|
||
removeSamples: boolean;
|
||
passwordList?: string;
|
||
signal?: AbortSignal;
|
||
onProgress?: (update: ExtractProgressUpdate) => void;
|
||
onlyArchives?: Set<string>;
|
||
skipPostCleanup?: boolean;
|
||
packageId?: string;
|
||
hybridMode?: boolean;
|
||
maxParallel?: number;
|
||
extractCpuPriority?: string;
|
||
onArchiveFailure?: (failure: ExtractArchiveFailureInfo) => void;
|
||
onLog?: (level: "INFO" | "WARN" | "ERROR", message: string) => void;
|
||
}
|
||
|
||
export interface ExtractProgressUpdate {
|
||
current: number;
|
||
total: number;
|
||
percent: number;
|
||
archiveName: string;
|
||
archivePercent?: number;
|
||
elapsedMs?: number;
|
||
phase: "extracting" | "done" | "preparing";
|
||
passwordAttempt?: number;
|
||
passwordTotal?: number;
|
||
passwordFound?: boolean;
|
||
archiveDone?: boolean;
|
||
archiveSuccess?: boolean;
|
||
}
|
||
|
||
export interface ExtractArchiveFailureInfo {
|
||
archiveName: string;
|
||
errorText: string;
|
||
category: ExtractErrorCategory;
|
||
suggestRedownload: boolean;
|
||
jvmFailureReason?: string;
|
||
}
|
||
|
||
export type ArchiveSignature = "rar" | "7z" | "zip" | "gzip" | "bzip2" | "xz" | null;
|
||
|
||
const ARCHIVE_SIGNATURES: { prefix: string; type: ArchiveSignature }[] = [
|
||
{ prefix: "526172211a07", type: "rar" },
|
||
{ prefix: "377abcaf271c", type: "7z" },
|
||
{ prefix: "504b0304", type: "zip" },
|
||
{ prefix: "1f8b08", type: "gzip" },
|
||
{ prefix: "425a68", type: "bzip2" },
|
||
{ prefix: "fd377a585a00", type: "xz" },
|
||
];
|
||
|
||
export type ExtractErrorCategory =
|
||
| "crc_error"
|
||
| "wrong_password"
|
||
| "missing_parts"
|
||
| "unsupported_format"
|
||
| "disk_full"
|
||
| "timeout"
|
||
| "aborted"
|
||
| "no_extractor"
|
||
| "unknown";
|
||
|
||
type ExtractionErrorWithHints = Error & {
|
||
suggestRedownload?: boolean;
|
||
jvmFailureReason?: string;
|
||
legacyBestPercent?: number;
|
||
legacyExtractor?: string;
|
||
};
|
||
|
||
type ExtractBackendMode = "auto" | "jvm" | "legacy";
|
||
|
||
type JvmExtractorLayout = {
|
||
javaCommand: string;
|
||
classPath: string;
|
||
rootDir: string;
|
||
};
|
||
|
||
type JvmExtractResult = {
|
||
ok: boolean;
|
||
missingCommand: boolean;
|
||
missingRuntime: boolean;
|
||
aborted: boolean;
|
||
timedOut: boolean;
|
||
errorText: string;
|
||
usedPassword: string;
|
||
backend: string;
|
||
};
|
||
|
||
type ExtractSpawnResult = {
|
||
ok: boolean;
|
||
missingCommand: boolean;
|
||
aborted: boolean;
|
||
timedOut: boolean;
|
||
errorText: string;
|
||
};
|
||
|
||
type ExtractResumeState = {
|
||
completedArchives: string[];
|
||
};
|
||
|
||
type ExtractorCommandKind = "rar_native" | "seven_zip" | "other";
|
||
|
||
interface SubstMapping { drive: string; original: string; }
|
||
|
||
interface DaemonRequest {
|
||
resolve: (result: JvmExtractResult) => void;
|
||
onArchiveProgress?: (percent: number) => void;
|
||
signal?: AbortSignal;
|
||
timeoutMs?: number;
|
||
parseState: { bestPercent: number; usedPassword: string; backend: string; reportedError: string };
|
||
archiveName: string;
|
||
startedAt: number;
|
||
passwordCount: number;
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// Sektion 3 — Subst Drive Mapping (Windows long-path workaround)
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
|
||
const activeSubstDrives = new Set<string>();
|
||
|
||
function findFreeSubstDrive(): string | null {
|
||
if (process.platform !== "win32") return null;
|
||
for (let code = 90; code >= 71; code--) { // Z to G
|
||
const letter = String.fromCharCode(code);
|
||
if (activeSubstDrives.has(letter)) continue;
|
||
try {
|
||
fs.accessSync(`${letter}:\\`);
|
||
// Drive exists, skip
|
||
} catch {
|
||
return letter;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function createSubstMapping(targetDir: string): SubstMapping | null {
|
||
if (process.platform !== "win32" || !path.isAbsolute(targetDir)) return null;
|
||
const drive = findFreeSubstDrive();
|
||
if (!drive) return null;
|
||
const result = spawnSync("subst", [`${drive}:`, targetDir], { stdio: "pipe", timeout: 5000 });
|
||
if (result.status !== 0) {
|
||
logger.warn(`subst ${drive}: fehlgeschlagen: ${String(result.stderr || "").trim()}`);
|
||
return null;
|
||
}
|
||
activeSubstDrives.add(drive);
|
||
logger.info(`subst ${drive}: -> ${targetDir}`);
|
||
return { drive, original: targetDir };
|
||
}
|
||
|
||
function removeSubstMapping(mapping: SubstMapping): void {
|
||
spawnSync("subst", [`${mapping.drive}:`, "/d"], { stdio: "pipe", timeout: 5000 });
|
||
activeSubstDrives.delete(mapping.drive);
|
||
logger.info(`subst ${mapping.drive}: entfernt`);
|
||
}
|
||
|
||
export function cleanupStaleSubstDrives(): void {
|
||
if (process.platform !== "win32") return;
|
||
try {
|
||
const result = spawnSync("subst", [], { stdio: "pipe", timeout: 5000 });
|
||
const output = String(result.stdout || "");
|
||
for (const line of output.split("\n")) {
|
||
const match = line.match(/^([A-Z]):\\: => (.+)/i);
|
||
if (!match) continue;
|
||
const drive = match[1].toUpperCase();
|
||
const target = match[2].trim();
|
||
if (/\\rd-extract-|\\Real-Debrid-Downloader/i.test(target)) {
|
||
spawnSync("subst", [`${drive}:`, "/d"], { stdio: "pipe", timeout: 5000 });
|
||
logger.info(`Stale subst ${drive}: entfernt (${target})`);
|
||
}
|
||
}
|
||
} catch {
|
||
// ignore — subst cleanup is best-effort
|
||
}
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// Sektion 4 — Archiv-Erkennung & Kandidaten
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
|
||
export async function detectArchiveSignature(filePath: string): Promise<ArchiveSignature> {
|
||
let fd: fs.promises.FileHandle | null = null;
|
||
try {
|
||
fd = await fs.promises.open(filePath, "r");
|
||
const buf = Buffer.alloc(8);
|
||
const { bytesRead } = await fd.read(buf, 0, 8, 0);
|
||
if (bytesRead < 3) return null;
|
||
const hex = buf.subarray(0, bytesRead).toString("hex");
|
||
for (const sig of ARCHIVE_SIGNATURES) {
|
||
if (hex.startsWith(sig.prefix)) return sig.type;
|
||
}
|
||
return null;
|
||
} catch {
|
||
return null;
|
||
} finally {
|
||
await fd?.close();
|
||
}
|
||
}
|
||
|
||
export function pathSetKey(filePath: string): string {
|
||
return process.platform === "win32" ? filePath.toLowerCase() : filePath;
|
||
}
|
||
|
||
function archiveNameKey(fileName: string): string {
|
||
return process.platform === "win32" ? String(fileName || "").toLowerCase() : String(fileName || "");
|
||
}
|
||
|
||
function stripDuplicateSuffixBeforeExtension(fileName: string): string {
|
||
return String(fileName || "").replace(/ \(\d+\)(?=\.[^.]+$)/, "");
|
||
}
|
||
|
||
function hasDuplicateSuffixBeforeExtension(fileName: string): boolean {
|
||
return stripDuplicateSuffixBeforeExtension(fileName) !== String(fileName || "");
|
||
}
|
||
|
||
function archiveDetectionName(fileName: string): string {
|
||
return stripDuplicateSuffixBeforeExtension(path.basename(String(fileName || "")));
|
||
}
|
||
|
||
function archiveCandidateIdentity(filePath: string): string {
|
||
const normalizedPath = path.join(path.dirname(filePath), archiveDetectionName(filePath));
|
||
return pathSetKey(normalizedPath);
|
||
}
|
||
|
||
function prefersArchiveCandidate(nextCandidate: string, currentCandidate: string): boolean {
|
||
const nextName = path.basename(nextCandidate);
|
||
const currentName = path.basename(currentCandidate);
|
||
const nextHasDuplicateSuffix = hasDuplicateSuffixBeforeExtension(nextName);
|
||
const currentHasDuplicateSuffix = hasDuplicateSuffixBeforeExtension(currentName);
|
||
if (nextHasDuplicateSuffix !== currentHasDuplicateSuffix) {
|
||
return !nextHasDuplicateSuffix;
|
||
}
|
||
return ARCHIVE_SORT_COLLATOR.compare(nextName, currentName) < 0;
|
||
}
|
||
|
||
function archiveSortKey(filePath: string): string {
|
||
const fileName = archiveDetectionName(filePath).toLowerCase();
|
||
return fileName
|
||
.replace(/\.part0*1\.rar$/i, "")
|
||
.replace(/\.zip\.\d{3}$/i, "")
|
||
.replace(/\.7z\.\d{3}$/i, "")
|
||
.replace(/\.\d{3}$/i, "")
|
||
.replace(/\.(?:tar\.(?:gz|bz2|xz)|tgz|tbz2|txz)$/i, "")
|
||
.replace(/\.rar$/i, "")
|
||
.replace(/\.zip$/i, "")
|
||
.replace(/\.7z$/i, "")
|
||
.replace(/[._\-\s]+$/g, "");
|
||
}
|
||
|
||
function archiveTypeRank(filePath: string): number {
|
||
const fileName = archiveDetectionName(filePath).toLowerCase();
|
||
if (/\.part0*1\.rar$/i.test(fileName)) {
|
||
return 0;
|
||
}
|
||
if (/\.rar$/i.test(fileName)) {
|
||
return 1;
|
||
}
|
||
if (/\.zip(?:\.\d{3})?$/i.test(fileName)) {
|
||
return 2;
|
||
}
|
||
if (/\.7z(?:\.\d{3})?$/i.test(fileName)) {
|
||
return 3;
|
||
}
|
||
if (/\.(?:tar\.(?:gz|bz2|xz)|tgz|tbz2|txz)$/i.test(fileName)) {
|
||
return 4;
|
||
}
|
||
if (/\.\d{3}$/i.test(fileName)) {
|
||
return 5;
|
||
}
|
||
return 9;
|
||
}
|
||
|
||
export async function findArchiveCandidates(packageDir: string): Promise<string[]> {
|
||
if (!packageDir) {
|
||
return [];
|
||
}
|
||
try {
|
||
await fs.promises.access(packageDir);
|
||
} catch {
|
||
return [];
|
||
}
|
||
|
||
let files: string[] = [];
|
||
try {
|
||
files = (await fs.promises.readdir(packageDir, { withFileTypes: true }))
|
||
.filter((entry) => entry.isFile())
|
||
.map((entry) => path.join(packageDir, entry.name));
|
||
} catch {
|
||
return [];
|
||
}
|
||
|
||
const fileNamesLower = new Set(files.map((filePath) => archiveDetectionName(filePath).toLowerCase()));
|
||
const multipartRar = files.filter((filePath) => /\.part0*1\.rar$/i.test(archiveDetectionName(filePath)));
|
||
const singleRar = files.filter((filePath) => {
|
||
const fileName = archiveDetectionName(filePath);
|
||
return /\.rar$/i.test(fileName) && !/\.part\d+\.rar$/i.test(fileName);
|
||
});
|
||
const zipSplit = files.filter((filePath) => /\.zip\.001$/i.test(archiveDetectionName(filePath)));
|
||
const zip = files.filter((filePath) => {
|
||
const fileName = archiveDetectionName(filePath);
|
||
if (!/\.zip$/i.test(fileName)) {
|
||
return false;
|
||
}
|
||
return !fileNamesLower.has(`${fileName}.001`.toLowerCase());
|
||
});
|
||
const sevenSplit = files.filter((filePath) => /\.7z\.001$/i.test(archiveDetectionName(filePath)));
|
||
const seven = files.filter((filePath) => {
|
||
const fileName = archiveDetectionName(filePath);
|
||
if (!/\.7z$/i.test(fileName)) {
|
||
return false;
|
||
}
|
||
return !fileNamesLower.has(`${fileName}.001`.toLowerCase());
|
||
});
|
||
const tarCompressed = files.filter((filePath) => /\.(?:tar\.(?:gz|bz2|xz)|tgz|tbz2|txz)$/i.test(filePath));
|
||
// Generic .001 splits (HJSplit etc.) — exclude already-recognized .zip.001 and .7z.001
|
||
const genericSplit = files.filter((filePath) => {
|
||
const fileName = archiveDetectionName(filePath).toLowerCase();
|
||
if (!/\.001$/.test(fileName)) return false;
|
||
if (/\.zip\.001$/.test(fileName) || /\.7z\.001$/.test(fileName)) return false;
|
||
return true;
|
||
});
|
||
|
||
const unique: string[] = [];
|
||
const seen = new Map<string, number>();
|
||
for (const candidate of [...multipartRar, ...singleRar, ...zipSplit, ...zip, ...sevenSplit, ...seven, ...tarCompressed, ...genericSplit]) {
|
||
const key = archiveCandidateIdentity(candidate);
|
||
const existingIndex = seen.get(key);
|
||
if (existingIndex !== undefined) {
|
||
if (prefersArchiveCandidate(candidate, unique[existingIndex])) {
|
||
unique[existingIndex] = candidate;
|
||
}
|
||
continue;
|
||
}
|
||
seen.set(key, unique.length);
|
||
unique.push(candidate);
|
||
}
|
||
|
||
unique.sort((left, right) => {
|
||
const keyCmp = ARCHIVE_SORT_COLLATOR.compare(archiveSortKey(left), archiveSortKey(right));
|
||
if (keyCmp !== 0) {
|
||
return keyCmp;
|
||
}
|
||
const rankCmp = archiveTypeRank(left) - archiveTypeRank(right);
|
||
if (rankCmp !== 0) {
|
||
return rankCmp;
|
||
}
|
||
return ARCHIVE_SORT_COLLATOR.compare(path.basename(left), path.basename(right));
|
||
});
|
||
|
||
return unique;
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// Sektion 5 — Cleanup & Dateisystem
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
|
||
function escapeRegex(value: string): string {
|
||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||
}
|
||
|
||
export function collectArchiveCleanupTargets(sourceArchivePath: string, directoryFiles?: string[]): string[] {
|
||
const targets = new Set<string>([sourceArchivePath]);
|
||
const dir = path.dirname(sourceArchivePath);
|
||
const fileName = path.basename(sourceArchivePath);
|
||
|
||
let filesInDir: string[] = directoryFiles ?? [];
|
||
if (!directoryFiles) {
|
||
try {
|
||
filesInDir = fs.readdirSync(dir, { withFileTypes: true })
|
||
.filter((entry) => entry.isFile())
|
||
.map((entry) => entry.name);
|
||
} catch {
|
||
return Array.from(targets);
|
||
}
|
||
}
|
||
|
||
const addMatching = (pattern: RegExp): void => {
|
||
for (const candidate of filesInDir) {
|
||
if (pattern.test(candidate)) {
|
||
targets.add(path.join(dir, candidate));
|
||
}
|
||
}
|
||
};
|
||
|
||
// Companion metadata files (.sfv, .nfo, .md5, etc.) share the same base stem
|
||
// as the archive and should be cleaned up together with the archive parts.
|
||
const COMPANION_EXTS_RE = /\.(?:sfv|nfo|nzb|md5|sha1|sha256|crc|srr)$/i;
|
||
const addCompanions = (stemRe: string): void => {
|
||
for (const candidate of filesInDir) {
|
||
if (!COMPANION_EXTS_RE.test(candidate)) continue;
|
||
const candidateStem = candidate.replace(/\.[^.]+$/, "");
|
||
if (new RegExp(`^${stemRe}$`, "i").test(candidateStem)) {
|
||
targets.add(path.join(dir, candidate));
|
||
}
|
||
}
|
||
};
|
||
|
||
const multipartRar = fileName.match(/^(.*)\.part\d+\.rar$/i);
|
||
if (multipartRar) {
|
||
const prefix = escapeRegex(multipartRar[1]);
|
||
addMatching(new RegExp(`^${prefix}\\.part\\d+\\.rar$`, "i"));
|
||
addMatching(new RegExp(`^${prefix}\\.part\\d+\\.rev$`, "i"));
|
||
addMatching(new RegExp(`^${prefix}\\.rev$`, "i"));
|
||
addCompanions(prefix);
|
||
return Array.from(targets);
|
||
}
|
||
|
||
if (/\.rar$/i.test(fileName)) {
|
||
const stem = escapeRegex(fileName.replace(/\.rar$/i, ""));
|
||
addMatching(new RegExp(`^${stem}\\.rar$`, "i"));
|
||
addMatching(new RegExp(`^${stem}\\.r\\d{2,3}$`, "i"));
|
||
addMatching(new RegExp(`^${stem}\\.rev$`, "i"));
|
||
addCompanions(stem);
|
||
return Array.from(targets);
|
||
}
|
||
|
||
if (/\.zip$/i.test(fileName)) {
|
||
const stem = escapeRegex(fileName.replace(/\.zip$/i, ""));
|
||
addMatching(new RegExp(`^${stem}\\.zip$`, "i"));
|
||
addMatching(new RegExp(`^${stem}\\.z\\d{2,3}$`, "i"));
|
||
addCompanions(stem);
|
||
return Array.from(targets);
|
||
}
|
||
|
||
const splitZip = fileName.match(/^(.*)\.zip\.\d{3}$/i);
|
||
if (splitZip) {
|
||
const stem = escapeRegex(splitZip[1]);
|
||
addMatching(new RegExp(`^${stem}\\.zip$`, "i"));
|
||
addMatching(new RegExp(`^${stem}\\.zip\\.\\d{3}$`, "i"));
|
||
addCompanions(stem);
|
||
return Array.from(targets);
|
||
}
|
||
|
||
if (/\.7z$/i.test(fileName)) {
|
||
const stem = escapeRegex(fileName.replace(/\.7z$/i, ""));
|
||
addMatching(new RegExp(`^${stem}\\.7z$`, "i"));
|
||
addMatching(new RegExp(`^${stem}\\.7z\\.\\d{3}$`, "i"));
|
||
addCompanions(stem);
|
||
return Array.from(targets);
|
||
}
|
||
|
||
const splitSeven = fileName.match(/^(.*)\.7z\.\d{3}$/i);
|
||
if (splitSeven) {
|
||
const stem = escapeRegex(splitSeven[1]);
|
||
addMatching(new RegExp(`^${stem}\\.7z$`, "i"));
|
||
addMatching(new RegExp(`^${stem}\\.7z\\.\\d{3}$`, "i"));
|
||
addCompanions(stem);
|
||
return Array.from(targets);
|
||
}
|
||
|
||
// Tar compound archives (.tar.gz, .tar.bz2, .tar.xz, .tgz, .tbz2, .txz)
|
||
if (/\.(?:tar\.(?:gz|bz2|xz)|tgz|tbz2|txz)$/i.test(fileName)) {
|
||
return Array.from(targets);
|
||
}
|
||
|
||
// Generic .NNN split files (HJSplit etc.)
|
||
const genericSplit = fileName.match(/^(.*)\.(\d{3})$/i);
|
||
if (genericSplit) {
|
||
const stem = escapeRegex(genericSplit[1]);
|
||
addMatching(new RegExp(`^${stem}\\.\\d{3}$`, "i"));
|
||
return Array.from(targets);
|
||
}
|
||
|
||
return Array.from(targets);
|
||
}
|
||
|
||
export async function cleanupArchives(
|
||
sourceFiles: string[],
|
||
cleanupMode: CleanupMode,
|
||
options: { shouldAbort?: () => boolean } = {}
|
||
): Promise<number> {
|
||
if (cleanupMode === "none") {
|
||
return 0;
|
||
}
|
||
|
||
const targets = new Set<string>();
|
||
const dirFilesCache = new Map<string, string[]>();
|
||
for (const sourceFile of sourceFiles) {
|
||
if (options.shouldAbort?.()) {
|
||
return 0;
|
||
}
|
||
const dir = path.dirname(sourceFile);
|
||
let filesInDir = dirFilesCache.get(dir);
|
||
if (!filesInDir) {
|
||
try {
|
||
filesInDir = (await fs.promises.readdir(dir, { withFileTypes: true }))
|
||
.filter((entry) => entry.isFile())
|
||
.map((entry) => entry.name);
|
||
} catch {
|
||
filesInDir = [];
|
||
}
|
||
dirFilesCache.set(dir, filesInDir);
|
||
}
|
||
|
||
for (const target of collectArchiveCleanupTargets(sourceFile, filesInDir)) {
|
||
targets.add(target);
|
||
}
|
||
}
|
||
|
||
let removed = 0;
|
||
|
||
const moveToTrashLike = async (filePath: string): Promise<boolean> => {
|
||
try {
|
||
const parsed = path.parse(filePath);
|
||
const trashDir = path.join(parsed.dir, ".rd-trash");
|
||
await fs.promises.mkdir(trashDir, { recursive: true });
|
||
let index = 0;
|
||
while (index <= 10000) {
|
||
const suffix = index === 0 ? "" : `-${index}`;
|
||
const candidate = path.join(trashDir, `${parsed.base}.${Date.now()}${suffix}`);
|
||
const candidateExists = await fs.promises.access(candidate).then(() => true, () => false);
|
||
if (!candidateExists) {
|
||
await fs.promises.rename(filePath, candidate);
|
||
return true;
|
||
}
|
||
index += 1;
|
||
}
|
||
} catch {
|
||
// ignore
|
||
}
|
||
return false;
|
||
};
|
||
|
||
for (const filePath of targets) {
|
||
if (options.shouldAbort?.()) {
|
||
return removed;
|
||
}
|
||
try {
|
||
const fileExists = await fs.promises.access(filePath).then(() => true, () => false);
|
||
if (!fileExists) {
|
||
continue;
|
||
}
|
||
if (cleanupMode === "trash") {
|
||
if (await moveToTrashLike(filePath)) {
|
||
removed += 1;
|
||
}
|
||
continue;
|
||
}
|
||
await fs.promises.rm(filePath, { force: true });
|
||
removed += 1;
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}
|
||
return removed;
|
||
}
|
||
|
||
export async function hasAnyFilesRecursive(rootDir: string): Promise<boolean> {
|
||
const rootExists = await fs.promises.access(rootDir).then(() => true, () => false);
|
||
if (!rootExists) {
|
||
return false;
|
||
}
|
||
const deadline = Date.now() + 220;
|
||
let inspectedDirs = 0;
|
||
const stack = [rootDir];
|
||
while (stack.length > 0) {
|
||
inspectedDirs += 1;
|
||
if (inspectedDirs > 8000 || Date.now() > deadline) {
|
||
return hasAnyEntries(rootDir);
|
||
}
|
||
const current = stack.pop() as string;
|
||
let entries: fs.Dirent[] = [];
|
||
try {
|
||
entries = await fs.promises.readdir(current, { withFileTypes: true });
|
||
} catch {
|
||
continue;
|
||
}
|
||
|
||
for (const entry of entries) {
|
||
if (entry.isFile()) {
|
||
return true;
|
||
}
|
||
if (entry.isDirectory()) {
|
||
stack.push(path.join(current, entry.name));
|
||
}
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
async function hasAnyEntries(rootDir: string): Promise<boolean> {
|
||
if (!rootDir) {
|
||
return false;
|
||
}
|
||
const rootExists = await fs.promises.access(rootDir).then(() => true, () => false);
|
||
if (!rootExists) {
|
||
return false;
|
||
}
|
||
try {
|
||
return (await fs.promises.readdir(rootDir)).length > 0;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
export async function removeEmptyDirectoryTree(rootDir: string): Promise<number> {
|
||
const rootExists = await fs.promises.access(rootDir).then(() => true, () => false);
|
||
if (!rootExists) {
|
||
return 0;
|
||
}
|
||
|
||
const dirs = [rootDir];
|
||
const stack = [rootDir];
|
||
while (stack.length > 0) {
|
||
const current = stack.pop() as string;
|
||
let entries: fs.Dirent[] = [];
|
||
try {
|
||
entries = await fs.promises.readdir(current, { withFileTypes: true });
|
||
} catch {
|
||
continue;
|
||
}
|
||
for (const entry of entries) {
|
||
if (entry.isDirectory()) {
|
||
const full = path.join(current, entry.name);
|
||
dirs.push(full);
|
||
stack.push(full);
|
||
}
|
||
}
|
||
}
|
||
|
||
dirs.sort((a, b) => b.length - a.length);
|
||
let removed = 0;
|
||
for (const dirPath of dirs) {
|
||
try {
|
||
const entries = await fs.promises.readdir(dirPath);
|
||
if (entries.length === 0) {
|
||
await fs.promises.rmdir(dirPath);
|
||
removed += 1;
|
||
}
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}
|
||
return removed;
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// Sektion 6 — Passwort-Management (LRU-Cache & Kandidaten)
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
|
||
function packagePasswordCacheKey(packageDir: string, packageId?: string): string {
|
||
const normalizedPackageId = String(packageId || "").trim();
|
||
if (normalizedPackageId) {
|
||
return `pkg:${normalizedPackageId}`;
|
||
}
|
||
return `dir:${pathSetKey(path.resolve(packageDir))}`;
|
||
}
|
||
|
||
function packagePasswordCacheLabel(packageDir: string, packageId?: string): string {
|
||
const normalizedPackageId = String(packageId || "").trim();
|
||
if (normalizedPackageId) {
|
||
return `packageId=${normalizedPackageId.slice(0, 8)}`;
|
||
}
|
||
return `packageDir=${path.basename(path.resolve(packageDir))}`;
|
||
}
|
||
|
||
function readCachedPackagePassword(cacheKey: string): string {
|
||
const cached = packageLearnedPasswords.get(cacheKey);
|
||
if (!cached) {
|
||
return "";
|
||
}
|
||
// Refresh insertion order to keep recently used package caches alive.
|
||
packageLearnedPasswords.delete(cacheKey);
|
||
packageLearnedPasswords.set(cacheKey, cached);
|
||
return cached;
|
||
}
|
||
|
||
function writeCachedPackagePassword(cacheKey: string, password: string): void {
|
||
const normalized = String(password || "").trim();
|
||
if (!normalized) {
|
||
return;
|
||
}
|
||
if (packageLearnedPasswords.has(cacheKey)) {
|
||
packageLearnedPasswords.delete(cacheKey);
|
||
}
|
||
packageLearnedPasswords.set(cacheKey, normalized);
|
||
if (packageLearnedPasswords.size > PACKAGE_PASSWORD_CACHE_LIMIT) {
|
||
const oldestKey = packageLearnedPasswords.keys().next().value as string | undefined;
|
||
if (oldestKey) {
|
||
packageLearnedPasswords.delete(oldestKey);
|
||
}
|
||
}
|
||
}
|
||
|
||
function clearCachedPackagePassword(cacheKey: string): void {
|
||
packageLearnedPasswords.delete(cacheKey);
|
||
}
|
||
|
||
/**
|
||
* Setzt den Extractor-Zustand zurück, wenn der User die Archiv-Passwortliste
|
||
* ändert. Repliziert, was ein App-Neustart am Extractor-Subsystem tut:
|
||
* - leert den In-Memory Learned-Password-Cache (gelernte Passwörter aller Pakete)
|
||
* - fährt den langlebigen JVM-Daemon herunter (sofern nicht gerade beschäftigt),
|
||
* damit die nächste Extraktion mit einem frischen Prozess + frischen Passwörtern
|
||
* startet.
|
||
*
|
||
* Hintergrund: User-Report — ein neu hinzugefügtes Passwort griff bei "Jetzt
|
||
* entpacken" erst NACH App-Neustart. Die gesamte TS/Java-Kette propagiert die
|
||
* Liste pro Request korrekt; die einzige zustandsbehaftete Komponente, die ein
|
||
* Neustart zurücksetzt (und dieser Aufruf ebenfalls), ist der Daemon-Prozess.
|
||
*
|
||
* Bewusst KEIN Shutdown eines beschäftigten Daemons: läuft gerade eine Extraktion
|
||
* (z.B. weil Settings während des Entpackens gespeichert werden), bleibt sie
|
||
* unangetastet — der nächste Lauf bekommt dann ggf. noch den alten Daemon, aber
|
||
* der häufige Fall (Liste im Leerlauf ändern) wird sauber abgedeckt.
|
||
*/
|
||
export function resetExtractorCachesForPasswordChange(): { learnedCleared: number; daemonRestarted: boolean } {
|
||
const learnedCleared = packageLearnedPasswords.size;
|
||
packageLearnedPasswords.clear();
|
||
let daemonRestarted = false;
|
||
if (daemonProcess && !daemonBusy) {
|
||
shutdownDaemon();
|
||
daemonRestarted = true;
|
||
}
|
||
return { learnedCleared, daemonRestarted };
|
||
}
|
||
|
||
export function archiveFilenamePasswords(archiveName: string): string[] {
|
||
const name = String(archiveName || "");
|
||
if (!name) return [];
|
||
const stem = name
|
||
.replace(/\.part\d+\.rar$/i, "")
|
||
.replace(/\.zip\.\d{3}$/i, "")
|
||
.replace(/\.7z\.\d{3}$/i, "")
|
||
.replace(/\.\d{3}$/i, "")
|
||
.replace(/\.(?:tar\.(?:gz|bz2|xz)|tgz|tbz2|txz)$/i, "")
|
||
.replace(/\.(rar|zip|7z|tar|gz|bz2|xz)$/i, "");
|
||
if (!stem) return [];
|
||
const candidates = [stem];
|
||
const withSpaces = stem.replace(/[._]/g, " ");
|
||
if (withSpaces !== stem) candidates.push(withSpaces);
|
||
return candidates;
|
||
}
|
||
|
||
function archivePasswords(listInput: string): string[] {
|
||
const custom = String(listInput || "")
|
||
.split(/\r?\n/g)
|
||
.map((part) => part.trim())
|
||
.filter(Boolean);
|
||
|
||
const fromEnv = String(process.env.RD_ARCHIVE_PASSWORDS || "")
|
||
.split(/[;,\n]/g)
|
||
.map((part) => part.trim())
|
||
.filter(Boolean);
|
||
|
||
return Array.from(new Set(["", ...custom, ...fromEnv, ...DEFAULT_ARCHIVE_PASSWORDS]));
|
||
}
|
||
|
||
function prioritizePassword(passwords: string[], successful: string): string[] {
|
||
const target = String(successful || "");
|
||
if (!target || passwords.length <= 1) {
|
||
return passwords;
|
||
}
|
||
const index = passwords.findIndex((candidate) => candidate === target);
|
||
if (index === 0) {
|
||
return passwords;
|
||
}
|
||
if (index < 0) {
|
||
return [target, ...passwords.filter((candidate) => candidate !== target)];
|
||
}
|
||
const next = [...passwords];
|
||
const [value] = next.splice(index, 1);
|
||
next.unshift(value);
|
||
return next;
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// Sektion 7 — Fehler-Klassifizierung
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
|
||
export function cleanErrorText(text: string): string {
|
||
const normalized = String(text || "").replace(/\s+/g, " ").trim();
|
||
if (normalized.length <= 500) {
|
||
return normalized;
|
||
}
|
||
const head = normalized.slice(0, 240).trimEnd();
|
||
const tail = normalized.slice(-240).trimStart();
|
||
return `${head} ... ${tail}`;
|
||
}
|
||
|
||
function appendLimited(base: string, chunk: string, maxLen = MAX_EXTRACT_OUTPUT_BUFFER): string {
|
||
const next = `${base}${chunk}`;
|
||
if (next.length <= maxLen) {
|
||
return next;
|
||
}
|
||
return next.slice(next.length - maxLen);
|
||
}
|
||
|
||
function withExtractionErrorHints(
|
||
error: unknown,
|
||
hints: {
|
||
suggestRedownload?: boolean;
|
||
jvmFailureReason?: string;
|
||
legacyBestPercent?: number;
|
||
legacyExtractor?: 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;
|
||
}
|
||
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;
|
||
}
|
||
|
||
export function classifyExtractionError(errorText: string): ExtractErrorCategory {
|
||
const text = String(errorText || "").toLowerCase();
|
||
if (text.includes("aborted:extract") || text.includes("extract_aborted")) return "aborted";
|
||
if (text.includes("timeout")) return "timeout";
|
||
if (text.includes("crc failed") || text.includes("checksum error") || text.includes("crc error")) return "crc_error";
|
||
if (text.includes("wrong password") || text.includes("falsches passwort") || text.includes("incorrect password")) return "wrong_password";
|
||
if (text.includes("missing volume") || text.includes("next volume") || text.includes("unexpected end of archive") || text.includes("missing parts")) return "missing_parts";
|
||
if (text.includes("nicht gefunden") || text.includes("not found") || text.includes("no extractor")) return "no_extractor";
|
||
if (text.includes("kein rar-archiv") || text.includes("not a rar archive") || text.includes("unsupported") || text.includes("unsupportedmethod")) return "unsupported_format";
|
||
if (text.includes("disk full") || text.includes("speicherplatz") || text.includes("no space left") || text.includes("not enough space")) return "disk_full";
|
||
return "unknown";
|
||
}
|
||
|
||
export function shouldSerialRetryParallelFailures(
|
||
extractedCount: number,
|
||
failedCategories: ExtractErrorCategory[]
|
||
): boolean {
|
||
if (failedCategories.length === 0) {
|
||
return false;
|
||
}
|
||
if (extractedCount > 0) {
|
||
return true;
|
||
}
|
||
return failedCategories.every((category) =>
|
||
category === "crc_error"
|
||
|| category === "wrong_password"
|
||
|| category === "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 {
|
||
const text = String(errorText || "").toLowerCase();
|
||
return text.includes("aborted:extract") || text.includes("extract_aborted") || text.includes("noextractor:skipped");
|
||
}
|
||
|
||
function isNoExtractorError(errorText: string): boolean {
|
||
return String(errorText || "").toLowerCase().includes("nicht gefunden");
|
||
}
|
||
|
||
function isUnsupportedArchiveFormatError(errorText: string): boolean {
|
||
const text = String(errorText || "").toLowerCase();
|
||
return text.includes("kein rar-archiv")
|
||
|| text.includes("not a rar archive")
|
||
|| text.includes("is not a rar archive");
|
||
}
|
||
|
||
function isUnsupportedExtractorSwitchError(errorText: string): boolean {
|
||
const text = String(errorText || "").toLowerCase();
|
||
return text.includes("unknown switch")
|
||
|| text.includes("unknown option")
|
||
|| text.includes("invalid switch")
|
||
|| text.includes("unsupported option")
|
||
|| text.includes("unbekannter schalter")
|
||
|| text.includes("falscher parameter");
|
||
}
|
||
|
||
function isJvmRuntimeMissingError(errorText: string): boolean {
|
||
const text = String(errorText || "").toLowerCase();
|
||
return text.includes("could not find or load main class")
|
||
|| text.includes("classnotfoundexception")
|
||
|| text.includes("noclassdeffounderror")
|
||
|| text.includes("unsatisfiedlinkerror")
|
||
|| text.includes("enoent");
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// Sektion 8 — Backend-Modus (auto / jvm / legacy)
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
|
||
export function resolveExtractorBackendMode(
|
||
rawValue?: string | null,
|
||
isVitestEnv = Boolean(process.env.VITEST)
|
||
): ExtractBackendMode {
|
||
const defaultMode: ExtractBackendMode = isVitestEnv ? "legacy" : "auto";
|
||
const raw = String(rawValue ?? defaultMode).trim().toLowerCase();
|
||
if (raw === "legacy") {
|
||
return "legacy";
|
||
}
|
||
if (raw === "jvm" || raw === "jbind" || raw === "7zjbinding") {
|
||
return "jvm";
|
||
}
|
||
return "auto";
|
||
}
|
||
|
||
export function resolveExtractorBackendModeForArchive(
|
||
archivePath: string,
|
||
rawValue?: string | null,
|
||
isVitestEnv = Boolean(process.env.VITEST),
|
||
platform = process.platform
|
||
): ExtractBackendMode {
|
||
const requestedMode = resolveExtractorBackendMode(rawValue, isVitestEnv);
|
||
if (requestedMode !== "auto") {
|
||
return requestedMode;
|
||
}
|
||
// On Windows, multipart RAR extraction feels significantly snappier with the
|
||
// native CLI path than with the JVM backend, and we already harden that path
|
||
// with subst + flat-mode fallback.
|
||
if (String(platform || "").toLowerCase() === "win32" && isRarArchivePath(archivePath)) {
|
||
return "legacy";
|
||
}
|
||
return requestedMode;
|
||
}
|
||
|
||
function extractorBackendMode(): ExtractBackendMode {
|
||
return resolveExtractorBackendMode(process.env.RD_EXTRACT_BACKEND);
|
||
}
|
||
|
||
function extractorBackendModeForArchive(archivePath: string): ExtractBackendMode {
|
||
return resolveExtractorBackendModeForArchive(archivePath, process.env.RD_EXTRACT_BACKEND);
|
||
}
|
||
|
||
function isRarArchivePath(filePath: string): boolean {
|
||
return /\.(?:rar|r\d{2,3})$/i.test(String(filePath || ""));
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// Sektion 9 — Native Extractor Resolution (7-Zip / WinRAR)
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
|
||
function is7zCommand(command: string): boolean {
|
||
const lower = command.toLowerCase();
|
||
return lower.includes("7z") && !lower.includes("unrar") && !lower.includes("winrar");
|
||
}
|
||
|
||
function isRarNativeCommand(command: string): boolean {
|
||
const base = path.basename(String(command || "")).toLowerCase();
|
||
return base === "unrar.exe"
|
||
|| base === "unrar"
|
||
|| base === "winrar.exe"
|
||
|| base === "rar.exe"
|
||
|| base === "rar";
|
||
}
|
||
|
||
function extractorCommandKind(command: string): ExtractorCommandKind {
|
||
if (isRarNativeCommand(command)) {
|
||
return "rar_native";
|
||
}
|
||
if (is7zCommand(command)) {
|
||
return "seven_zip";
|
||
}
|
||
return "other";
|
||
}
|
||
|
||
function isAbsoluteCommand(command: string): boolean {
|
||
return path.isAbsolute(command)
|
||
|| command.includes("\\")
|
||
|| command.includes("/");
|
||
}
|
||
|
||
function cachedExtractorFitsArchive(command: string, archivePath: string): boolean {
|
||
if (!archivePath) {
|
||
return true;
|
||
}
|
||
const kind = extractorCommandKind(command);
|
||
if (isRarArchivePath(archivePath)) {
|
||
return kind === "rar_native";
|
||
}
|
||
return kind === "seven_zip";
|
||
}
|
||
|
||
export function orderExtractorCandidatesForArchive(
|
||
candidates: string[],
|
||
archivePath: string,
|
||
preferredCommand = ""
|
||
): string[] {
|
||
const unique = Array.from(new Set(candidates.filter(Boolean)));
|
||
const preferRarNative = isRarArchivePath(archivePath);
|
||
const rank = (command: string): number => {
|
||
const kind = extractorCommandKind(command);
|
||
if (preferRarNative) {
|
||
if (kind === "rar_native") return 0;
|
||
if (kind === "seven_zip") return 1;
|
||
return 2;
|
||
}
|
||
if (kind === "seven_zip") return 0;
|
||
if (kind === "rar_native") return 1;
|
||
return 2;
|
||
};
|
||
|
||
return unique
|
||
.map((command, index) => ({ command, index }))
|
||
.sort((left, right) => {
|
||
const rankDiff = rank(left.command) - rank(right.command);
|
||
if (rankDiff !== 0) {
|
||
return rankDiff;
|
||
}
|
||
const leftPreferred = preferredCommand && left.command === preferredCommand;
|
||
const rightPreferred = preferredCommand && right.command === preferredCommand;
|
||
if (leftPreferred !== rightPreferred) {
|
||
return leftPreferred ? -1 : 1;
|
||
}
|
||
return left.index - right.index;
|
||
})
|
||
.map((entry) => entry.command);
|
||
}
|
||
|
||
function nativeExtractorCandidates(archivePath = ""): string[] {
|
||
const programFiles = process.env.ProgramFiles || "C:\\Program Files";
|
||
const programFilesX86 = process.env["ProgramFiles(x86)"] || "C:\\Program Files (x86)";
|
||
const localAppData = process.env.LOCALAPPDATA || "";
|
||
|
||
const sevenZipInstalled = [
|
||
process.env.RD_7Z_BIN || "",
|
||
path.join(programFiles, "7-Zip", "7z.exe"),
|
||
path.join(programFilesX86, "7-Zip", "7z.exe")
|
||
];
|
||
if (localAppData) {
|
||
sevenZipInstalled.push(path.join(localAppData, "Programs", "7-Zip", "7z.exe"));
|
||
}
|
||
|
||
const winRarInstalled = [
|
||
path.join(programFiles, "WinRAR", "Rar.exe"),
|
||
path.join(programFilesX86, "WinRAR", "Rar.exe"),
|
||
path.join(programFiles, "WinRAR", "UnRAR.exe"),
|
||
path.join(programFilesX86, "WinRAR", "UnRAR.exe")
|
||
];
|
||
|
||
if (localAppData) {
|
||
winRarInstalled.push(path.join(localAppData, "Programs", "WinRAR", "Rar.exe"));
|
||
winRarInstalled.push(path.join(localAppData, "Programs", "WinRAR", "UnRAR.exe"));
|
||
}
|
||
|
||
const ordered = resolvedExtractorCommand
|
||
? [
|
||
resolvedExtractorCommand,
|
||
...sevenZipInstalled,
|
||
"7z.exe",
|
||
"7z",
|
||
"7za.exe",
|
||
"7za",
|
||
...winRarInstalled,
|
||
"Rar.exe",
|
||
"rar",
|
||
"UnRAR.exe",
|
||
"unrar"
|
||
]
|
||
: [
|
||
...sevenZipInstalled,
|
||
"7z.exe",
|
||
"7z",
|
||
"7za.exe",
|
||
"7za",
|
||
...winRarInstalled,
|
||
"Rar.exe",
|
||
"rar",
|
||
"UnRAR.exe",
|
||
"unrar"
|
||
];
|
||
return orderExtractorCandidatesForArchive(ordered, archivePath, resolvedExtractorCommand || "");
|
||
}
|
||
|
||
function extractorProbeArgs(command: string): string[] {
|
||
return isRarNativeCommand(command) ? ["-?"] : ["?"];
|
||
}
|
||
|
||
async function resolveExtractorCommandInternal(archivePath = ""): Promise<string> {
|
||
if (resolvedExtractorCommand) {
|
||
return resolvedExtractorCommand;
|
||
}
|
||
if (resolveFailureReason) {
|
||
const age = Date.now() - resolveFailureAt;
|
||
if (age < EXTRACTOR_RETRY_AFTER_MS) {
|
||
throw new Error(resolveFailureReason);
|
||
}
|
||
resolveFailureReason = "";
|
||
resolveFailureAt = 0;
|
||
}
|
||
|
||
const candidates = nativeExtractorCandidates(archivePath);
|
||
for (const command of candidates) {
|
||
if (isAbsoluteCommand(command) && !fs.existsSync(command)) {
|
||
continue;
|
||
}
|
||
const probeArgs = extractorProbeArgs(command);
|
||
const probe = await runExtractCommand(command, probeArgs, undefined, undefined, EXTRACTOR_PROBE_TIMEOUT_MS);
|
||
if (probe.ok || (!probe.missingCommand && !probe.timedOut)) {
|
||
resolvedExtractorCommand = command;
|
||
resolveFailureReason = "";
|
||
resolveFailureAt = 0;
|
||
logger.info(`Entpacker erkannt: ${command}`);
|
||
return command;
|
||
}
|
||
}
|
||
|
||
resolveFailureReason = NO_EXTRACTOR_MESSAGE;
|
||
resolveFailureAt = Date.now();
|
||
throw new Error(resolveFailureReason);
|
||
}
|
||
|
||
async function resolveExtractorCommand(archivePath = ""): Promise<string> {
|
||
if (resolvedExtractorCommand && cachedExtractorFitsArchive(resolvedExtractorCommand, archivePath)) {
|
||
return resolvedExtractorCommand;
|
||
}
|
||
if (resolveExtractorCommandInFlight) {
|
||
return resolveExtractorCommandInFlight;
|
||
}
|
||
|
||
const pending = resolveExtractorCommandInternal(archivePath);
|
||
resolveExtractorCommandInFlight = pending;
|
||
try {
|
||
return await pending;
|
||
} finally {
|
||
if (resolveExtractorCommandInFlight === pending) {
|
||
resolveExtractorCommandInFlight = null;
|
||
}
|
||
}
|
||
}
|
||
|
||
async function findAlternativeExtractor(currentCommand: string, archivePath = ""): Promise<string | null> {
|
||
const candidates = nativeExtractorCandidates(archivePath);
|
||
const currentKind = extractorCommandKind(currentCommand);
|
||
const preferredKinds: ExtractorCommandKind[] = currentKind === "seven_zip"
|
||
? ["rar_native"]
|
||
: isRarArchivePath(archivePath)
|
||
? ["rar_native", "seven_zip"]
|
||
: ["seven_zip", "rar_native"];
|
||
for (const kind of preferredKinds) {
|
||
for (const candidate of candidates) {
|
||
if (candidate === currentCommand) continue;
|
||
if (extractorCommandKind(candidate) !== kind) continue;
|
||
if (isAbsoluteCommand(candidate) && !fs.existsSync(candidate)) continue;
|
||
const probe = await runExtractCommand(candidate, extractorProbeArgs(candidate), undefined, undefined, EXTRACTOR_PROBE_TIMEOUT_MS);
|
||
if (probe.ok || (!probe.missingCommand && !probe.timedOut)) {
|
||
return candidate;
|
||
}
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// Sektion 10 — CPU / Thread / Priority
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
|
||
/** Compute a safe JVM -Xmx value based on available physical RAM.
|
||
* Reserves 4 GB for Windows + Electron + other processes, caps at 16 GB. */
|
||
function jvmMaxHeapArg(): string {
|
||
const totalGb = os.totalmem() / (1024 ** 3);
|
||
const heapGb = Math.max(1, Math.min(Math.floor(totalGb - 4), 16));
|
||
return `-Xmx${heapGb}g`;
|
||
}
|
||
|
||
function shouldUseExtractorPerformanceFlags(): boolean {
|
||
const raw = String(process.env.RD_EXTRACT_PERF_FLAGS || "").trim().toLowerCase();
|
||
return raw !== "0" && raw !== "false" && raw !== "off" && raw !== "no";
|
||
}
|
||
|
||
function extractCpuBudgetFromPriority(priority?: string): number {
|
||
switch (priority) {
|
||
case "low": return 25;
|
||
case "middle": return 50;
|
||
default: return 80;
|
||
}
|
||
}
|
||
|
||
function extractOsPriority(priority?: string): number {
|
||
switch (priority) {
|
||
case "high": return os.constants.priority.PRIORITY_NORMAL;
|
||
default: return os.constants.priority.PRIORITY_BELOW_NORMAL;
|
||
}
|
||
}
|
||
|
||
function extractCpuBudgetPercent(priority?: string): number {
|
||
const envValue = Number(process.env.RD_EXTRACT_CPU_BUDGET_PERCENT ?? NaN);
|
||
if (Number.isFinite(envValue) && envValue >= 40 && envValue <= 95) {
|
||
return Math.floor(envValue);
|
||
}
|
||
return extractCpuBudgetFromPriority(priority);
|
||
}
|
||
|
||
function extractorThreadSwitch(hybridMode = false, priority?: string): string {
|
||
if (hybridMode) {
|
||
const envValue = Number(process.env.RD_EXTRACT_THREADS ?? NaN);
|
||
if (Number.isFinite(envValue) && envValue >= 1 && envValue <= 32) {
|
||
return `-mt${Math.floor(envValue)}`;
|
||
}
|
||
const cpuCount = Math.max(1, os.cpus().length || 1);
|
||
const hybridThreads = Math.max(2, Math.min(12, Math.ceil(cpuCount * 0.75)));
|
||
return `-mt${hybridThreads}`;
|
||
}
|
||
const envValue = Number(process.env.RD_EXTRACT_THREADS ?? NaN);
|
||
if (Number.isFinite(envValue) && envValue >= 1 && envValue <= 32) {
|
||
return `-mt${Math.floor(envValue)}`;
|
||
}
|
||
const cpuCount = Math.max(1, os.cpus().length || 1);
|
||
const budgetPercent = extractCpuBudgetPercent(priority);
|
||
const budgetedThreads = Math.floor((cpuCount * budgetPercent) / 100);
|
||
const threadCount = Math.max(1, Math.min(16, Math.max(1, budgetedThreads)));
|
||
return `-mt${threadCount}`;
|
||
}
|
||
|
||
function lowerExtractProcessPriority(childPid: number | undefined, cpuPriority?: string): void {
|
||
if (process.platform !== "win32") {
|
||
return;
|
||
}
|
||
const pid = Number(childPid || 0);
|
||
if (!Number.isFinite(pid) || pid <= 0) {
|
||
return;
|
||
}
|
||
try {
|
||
os.setPriority(pid, extractOsPriority(cpuPriority));
|
||
} catch {
|
||
// ignore: priority lowering is best-effort
|
||
}
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// Sektion 11 — Prozess-Ausführung (spawn, kill, progress parsing)
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
|
||
function killProcessTree(child: { pid?: number; kill: () => void }): void {
|
||
const pid = Number(child.pid || 0);
|
||
if (!Number.isFinite(pid) || pid <= 0) {
|
||
try {
|
||
child.kill();
|
||
} catch {
|
||
// ignore
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (process.platform === "win32") {
|
||
try {
|
||
const killer = spawn("taskkill", ["/PID", String(pid), "/T", "/F"], {
|
||
windowsHide: true,
|
||
stdio: "ignore"
|
||
});
|
||
killer.on("error", () => {
|
||
try {
|
||
child.kill();
|
||
} catch {
|
||
// ignore
|
||
}
|
||
});
|
||
} catch {
|
||
try {
|
||
child.kill();
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
|
||
try {
|
||
child.kill();
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}
|
||
|
||
function parseProgressPercent(chunk: string): number | null {
|
||
const text = String(chunk || "");
|
||
const matches = text.match(/(?:^|\D)(\d{1,3})%/g);
|
||
if (!matches) {
|
||
return null;
|
||
}
|
||
let latest: number | null = null;
|
||
for (const raw of matches) {
|
||
const digits = raw.match(/(\d{1,3})%/);
|
||
if (!digits) {
|
||
continue;
|
||
}
|
||
const value = Number(digits[1]);
|
||
if (Number.isFinite(value) && value >= 0 && value <= 100) {
|
||
latest = value;
|
||
}
|
||
}
|
||
return latest;
|
||
}
|
||
|
||
function nextArchivePercent(previous: number, incoming: number): number {
|
||
const prev = Math.max(0, Math.min(100, Math.floor(Number(previous) || 0)));
|
||
const next = Math.max(0, Math.min(100, Math.floor(Number(incoming) || 0)));
|
||
return next >= prev ? next : prev;
|
||
}
|
||
|
||
function runExtractCommand(
|
||
command: string,
|
||
args: string[],
|
||
onChunk?: (chunk: string) => void,
|
||
signal?: AbortSignal,
|
||
timeoutMs?: number
|
||
): Promise<ExtractSpawnResult> {
|
||
if (signal?.aborted) {
|
||
return Promise.resolve({ ok: false, missingCommand: false, aborted: true, timedOut: false, errorText: "aborted:extract" });
|
||
}
|
||
|
||
return new Promise((resolve) => {
|
||
let settled = false;
|
||
let output = "";
|
||
const child = spawn(command, args, { windowsHide: true });
|
||
lowerExtractProcessPriority(child.pid, currentExtractCpuPriority);
|
||
let timeoutId: NodeJS.Timeout | null = null;
|
||
let timedOutByWatchdog = false;
|
||
let abortedBySignal = false;
|
||
|
||
const finish = (result: ExtractSpawnResult): void => {
|
||
if (settled) {
|
||
return;
|
||
}
|
||
settled = true;
|
||
if (timeoutId) {
|
||
clearTimeout(timeoutId);
|
||
timeoutId = null;
|
||
}
|
||
if (signal && onAbort) {
|
||
signal.removeEventListener("abort", onAbort);
|
||
}
|
||
resolve(result);
|
||
};
|
||
|
||
if (timeoutMs && timeoutMs > 0) {
|
||
timeoutId = setTimeout(() => {
|
||
timedOutByWatchdog = true;
|
||
killProcessTree(child);
|
||
finish({
|
||
ok: false,
|
||
missingCommand: false,
|
||
aborted: false,
|
||
timedOut: true,
|
||
errorText: `Entpacken Timeout nach ${Math.ceil(timeoutMs / 1000)}s`
|
||
});
|
||
}, timeoutMs);
|
||
}
|
||
|
||
const onAbort = signal
|
||
? (): void => {
|
||
abortedBySignal = true;
|
||
killProcessTree(child);
|
||
finish({ ok: false, missingCommand: false, aborted: true, timedOut: false, errorText: "aborted:extract" });
|
||
}
|
||
: null;
|
||
if (signal && onAbort) {
|
||
signal.addEventListener("abort", onAbort, { once: true });
|
||
}
|
||
|
||
child.stdout.on("data", (chunk) => {
|
||
const text = String(chunk || "");
|
||
output = appendLimited(output, text);
|
||
onChunk?.(text);
|
||
});
|
||
child.stderr.on("data", (chunk) => {
|
||
const text = String(chunk || "");
|
||
output = appendLimited(output, text);
|
||
onChunk?.(text);
|
||
});
|
||
|
||
child.on("error", (error) => {
|
||
const text = cleanErrorText(String(error));
|
||
finish({
|
||
ok: false,
|
||
missingCommand: text.toLowerCase().includes("enoent"),
|
||
aborted: false,
|
||
timedOut: false,
|
||
errorText: text
|
||
});
|
||
});
|
||
|
||
child.on("close", (code) => {
|
||
if (abortedBySignal) {
|
||
finish({ ok: false, missingCommand: false, aborted: true, timedOut: false, errorText: "aborted:extract" });
|
||
return;
|
||
}
|
||
if (timedOutByWatchdog) {
|
||
finish({
|
||
ok: false,
|
||
missingCommand: false,
|
||
aborted: false,
|
||
timedOut: true,
|
||
errorText: `Entpacken Timeout nach ${Math.ceil((timeoutMs || 0) / 1000)}s`
|
||
});
|
||
return;
|
||
}
|
||
if (code === 0) {
|
||
finish({ ok: true, missingCommand: false, aborted: false, timedOut: false, errorText: "" });
|
||
return;
|
||
}
|
||
if (code === 1) {
|
||
const lowered = output.toLowerCase();
|
||
const warningOnly = !lowered.includes("crc failed")
|
||
&& !lowered.includes("checksum error")
|
||
&& !lowered.includes("wrong password")
|
||
&& !lowered.includes("cannot open")
|
||
&& !lowered.includes("fatal error")
|
||
&& !lowered.includes("unexpected end of archive")
|
||
&& !lowered.includes("error:");
|
||
if (warningOnly) {
|
||
finish({ ok: true, missingCommand: false, aborted: false, timedOut: false, errorText: "" });
|
||
return;
|
||
}
|
||
}
|
||
const cleaned = cleanErrorText(output);
|
||
finish({
|
||
ok: false,
|
||
missingCommand: false,
|
||
aborted: false,
|
||
timedOut: false,
|
||
errorText: cleaned || `Exit Code ${String(code ?? "?")}`
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// Sektion 12 — JVM Backend & Daemon
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
|
||
let cachedJvmLayout: JvmExtractorLayout | null | undefined;
|
||
let cachedJvmLayoutNullSince = 0;
|
||
const JVM_LAYOUT_NULL_TTL_MS = 5 * 60 * 1000;
|
||
|
||
function resolveJavaCommandCandidates(): string[] {
|
||
const programFiles = process.env.ProgramFiles || "C:\\Program Files";
|
||
const programFilesX86 = process.env["ProgramFiles(x86)"] || "C:\\Program Files (x86)";
|
||
const localAppData = process.env.LOCALAPPDATA || "";
|
||
|
||
const candidates = [
|
||
process.env.RD_JAVA_BIN || "",
|
||
path.join(programFiles, "JDownloader", "jre", "bin", "java.exe"),
|
||
path.join(programFilesX86, "JDownloader", "jre", "bin", "java.exe"),
|
||
localAppData ? path.join(localAppData, "JDownloader", "jre", "bin", "java.exe") : "",
|
||
"java"
|
||
].filter(Boolean);
|
||
|
||
return Array.from(new Set(candidates));
|
||
}
|
||
|
||
function resolveJvmExtractorRootCandidates(): string[] {
|
||
const fromEnv = String(process.env.RD_EXTRACTOR_JVM_DIR || "").trim();
|
||
const electronResourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath || "";
|
||
const candidates = [
|
||
fromEnv,
|
||
electronResourcesPath ? path.join(electronResourcesPath, "app.asar.unpacked", "resources", "extractor-jvm") : "",
|
||
path.join(process.cwd(), "resources", "extractor-jvm"),
|
||
path.join(process.cwd(), "build", "resources", "extractor-jvm"),
|
||
path.join(__dirname, "..", "..", "..", "resources", "extractor-jvm"),
|
||
electronResourcesPath ? path.join(electronResourcesPath, "extractor-jvm") : ""
|
||
].filter(Boolean);
|
||
return Array.from(new Set(candidates));
|
||
}
|
||
|
||
function resolveJvmExtractorLayout(): JvmExtractorLayout | null {
|
||
if (cachedJvmLayout !== undefined) {
|
||
if (cachedJvmLayout === null && Date.now() - cachedJvmLayoutNullSince > JVM_LAYOUT_NULL_TTL_MS) {
|
||
cachedJvmLayout = undefined;
|
||
} else {
|
||
return cachedJvmLayout;
|
||
}
|
||
}
|
||
const javaCandidates = resolveJavaCommandCandidates();
|
||
const javaCommand = javaCandidates.find((candidate) => {
|
||
if (!candidate) {
|
||
return false;
|
||
}
|
||
if (!isAbsoluteCommand(candidate)) {
|
||
return true;
|
||
}
|
||
return fs.existsSync(candidate);
|
||
}) || "";
|
||
|
||
if (!javaCommand) {
|
||
cachedJvmLayout = null;
|
||
cachedJvmLayoutNullSince = Date.now();
|
||
return null;
|
||
}
|
||
|
||
for (const rootDir of resolveJvmExtractorRootCandidates()) {
|
||
const classesDir = path.join(rootDir, JVM_EXTRACTOR_CLASSES_SUBDIR);
|
||
if (!fs.existsSync(classesDir)) {
|
||
continue;
|
||
}
|
||
const libs = JVM_EXTRACTOR_REQUIRED_LIBS.map((name) => path.join(rootDir, JVM_EXTRACTOR_LIB_SUBDIR, name));
|
||
if (libs.some((filePath) => !fs.existsSync(filePath))) {
|
||
continue;
|
||
}
|
||
const classPath = [classesDir, ...libs].join(path.delimiter);
|
||
const layout = { javaCommand, classPath, rootDir };
|
||
cachedJvmLayout = layout;
|
||
return layout;
|
||
}
|
||
|
||
cachedJvmLayout = null;
|
||
cachedJvmLayoutNullSince = Date.now();
|
||
return null;
|
||
}
|
||
|
||
function parseJvmLine(
|
||
line: string,
|
||
onArchiveProgress: ((percent: number) => void) | undefined,
|
||
state: { bestPercent: number; usedPassword: string; backend: string; reportedError: string }
|
||
): void {
|
||
const trimmed = String(line || "").trim();
|
||
if (!trimmed) {
|
||
return;
|
||
}
|
||
|
||
if (trimmed.startsWith("RD_PROGRESS ")) {
|
||
const parsed = parseProgressPercent(trimmed);
|
||
if (parsed !== null) {
|
||
const next = nextArchivePercent(state.bestPercent, parsed);
|
||
if (next !== state.bestPercent) {
|
||
state.bestPercent = next;
|
||
onArchiveProgress?.(next);
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (trimmed.startsWith("RD_PASSWORD ")) {
|
||
const encoded = trimmed.slice("RD_PASSWORD ".length).trim();
|
||
try {
|
||
state.usedPassword = Buffer.from(encoded, "base64").toString("utf8");
|
||
} catch {
|
||
state.usedPassword = "";
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (trimmed.startsWith("RD_BACKEND ")) {
|
||
state.backend = trimmed.slice("RD_BACKEND ".length).trim();
|
||
return;
|
||
}
|
||
|
||
if (trimmed.startsWith("RD_ERROR ")) {
|
||
state.reportedError = trimmed.slice("RD_ERROR ".length).trim();
|
||
}
|
||
}
|
||
|
||
// ── Persistent JVM Daemon ──
|
||
// Keeps a single JVM process alive across multiple extraction requests,
|
||
// eliminating the ~5s JVM boot overhead per archive.
|
||
|
||
let daemonProcess: ChildProcess | null = null;
|
||
let daemonReady = false;
|
||
let daemonBusy = false;
|
||
let daemonCurrentRequest: DaemonRequest | null = null;
|
||
let daemonStdoutBuffer = "";
|
||
let daemonStderrBuffer = "";
|
||
let daemonOutput = "";
|
||
let daemonTimeoutId: NodeJS.Timeout | null = null;
|
||
let daemonAbortHandler: (() => void) | null = null;
|
||
let daemonLayout: JvmExtractorLayout | null = null;
|
||
|
||
export function shutdownDaemon(): void {
|
||
if (daemonProcess) {
|
||
try { daemonProcess.stdin?.end(); } catch { /* ignore */ }
|
||
try { killProcessTree(daemonProcess); } catch { /* ignore */ }
|
||
daemonProcess = null;
|
||
}
|
||
daemonReady = false;
|
||
daemonBusy = false;
|
||
daemonCurrentRequest = null;
|
||
daemonStdoutBuffer = "";
|
||
daemonStderrBuffer = "";
|
||
daemonOutput = "";
|
||
if (daemonTimeoutId) { clearTimeout(daemonTimeoutId); daemonTimeoutId = null; }
|
||
if (daemonAbortHandler) { daemonAbortHandler = null; }
|
||
daemonLayout = null;
|
||
}
|
||
|
||
function finishDaemonRequest(result: JvmExtractResult): void {
|
||
const req = daemonCurrentRequest;
|
||
if (!req) return;
|
||
daemonCurrentRequest = null;
|
||
daemonBusy = false;
|
||
daemonStdoutBuffer = "";
|
||
daemonStderrBuffer = "";
|
||
daemonOutput = "";
|
||
if (daemonTimeoutId) { clearTimeout(daemonTimeoutId); daemonTimeoutId = null; }
|
||
if (req.signal && daemonAbortHandler) {
|
||
req.signal.removeEventListener("abort", daemonAbortHandler);
|
||
daemonAbortHandler = null;
|
||
}
|
||
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 {
|
||
const trimmed = String(line || "").trim();
|
||
if (!trimmed) return;
|
||
|
||
if (trimmed === "RD_DAEMON_READY") {
|
||
daemonReady = true;
|
||
logger.info("JVM Daemon bereit (persistent)");
|
||
return;
|
||
}
|
||
|
||
if (trimmed.startsWith("RD_REQUEST_DONE ")) {
|
||
const code = parseInt(trimmed.slice("RD_REQUEST_DONE ".length).trim(), 10);
|
||
const req = daemonCurrentRequest;
|
||
if (!req) return;
|
||
const finalize = (): void => {
|
||
if (daemonCurrentRequest !== req) {
|
||
return;
|
||
}
|
||
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;
|
||
}
|
||
|
||
const message = cleanErrorText(req.parseState.reportedError || daemonOutput) || `Exit Code ${code}`;
|
||
finishDaemonRequest({
|
||
ok: false, missingCommand: false, missingRuntime: isJvmRuntimeMissingError(message),
|
||
aborted: false, timedOut: false, errorText: message,
|
||
usedPassword: req.parseState.usedPassword, backend: req.parseState.backend
|
||
});
|
||
};
|
||
|
||
if (code !== 0 && !req.parseState.reportedError) {
|
||
setTimeout(finalize, 40);
|
||
return;
|
||
}
|
||
|
||
finalize();
|
||
return;
|
||
}
|
||
|
||
if (daemonCurrentRequest) {
|
||
parseJvmLine(trimmed, daemonCurrentRequest.onArchiveProgress, daemonCurrentRequest.parseState);
|
||
}
|
||
}
|
||
|
||
function startDaemon(layout: JvmExtractorLayout): boolean {
|
||
if (daemonProcess && daemonReady) return true;
|
||
if (daemonProcess) return false;
|
||
shutdownDaemon();
|
||
|
||
const jvmTmpDir = path.join(os.tmpdir(), `rd-extract-daemon-${crypto.randomUUID()}`);
|
||
fs.mkdirSync(jvmTmpDir, { recursive: true });
|
||
|
||
const args = [
|
||
"-Dfile.encoding=UTF-8",
|
||
`-Djava.io.tmpdir=${jvmTmpDir}`,
|
||
"-Xms1g",
|
||
jvmMaxHeapArg(),
|
||
"-XX:+UseG1GC",
|
||
"-XX:MaxGCPauseMillis=50",
|
||
"-cp",
|
||
layout.classPath,
|
||
JVM_EXTRACTOR_MAIN_CLASS,
|
||
"--daemon"
|
||
];
|
||
|
||
try {
|
||
const child = spawn(layout.javaCommand, args, {
|
||
windowsHide: true,
|
||
stdio: ["pipe", "pipe", "pipe"]
|
||
});
|
||
lowerExtractProcessPriority(child.pid, currentExtractCpuPriority);
|
||
daemonProcess = child;
|
||
daemonLayout = layout;
|
||
|
||
child.stdout!.on("data", (chunk) => {
|
||
const raw = String(chunk || "");
|
||
daemonOutput = appendLimited(daemonOutput, raw);
|
||
daemonStdoutBuffer += raw;
|
||
const lines = daemonStdoutBuffer.split(/\r?\n/);
|
||
daemonStdoutBuffer = lines.pop() || "";
|
||
for (const line of lines) {
|
||
handleDaemonLine(line);
|
||
}
|
||
});
|
||
|
||
child.stderr!.on("data", (chunk) => {
|
||
const raw = String(chunk || "");
|
||
daemonOutput = appendLimited(daemonOutput, raw);
|
||
daemonStderrBuffer += raw;
|
||
const lines = daemonStderrBuffer.split(/\r?\n/);
|
||
daemonStderrBuffer = lines.pop() || "";
|
||
for (const line of lines) {
|
||
if (daemonCurrentRequest) {
|
||
parseJvmLine(line, daemonCurrentRequest.onArchiveProgress, daemonCurrentRequest.parseState);
|
||
}
|
||
}
|
||
});
|
||
|
||
child.on("error", () => {
|
||
if (daemonCurrentRequest) {
|
||
finishDaemonRequest({
|
||
ok: false, missingCommand: true, missingRuntime: true,
|
||
aborted: false, timedOut: false, errorText: "Daemon process error",
|
||
usedPassword: "", backend: ""
|
||
});
|
||
}
|
||
shutdownDaemon();
|
||
});
|
||
|
||
child.on("close", () => {
|
||
if (daemonCurrentRequest) {
|
||
const req = daemonCurrentRequest;
|
||
finishDaemonRequest({
|
||
ok: false, missingCommand: false, missingRuntime: false,
|
||
aborted: false, timedOut: false,
|
||
errorText: cleanErrorText(req.parseState.reportedError || daemonOutput) || "Daemon process exited unexpectedly",
|
||
usedPassword: req.parseState.usedPassword, backend: req.parseState.backend
|
||
});
|
||
}
|
||
// Clean up tmp dir
|
||
fs.rm(jvmTmpDir, { recursive: true, force: true }, () => {});
|
||
daemonProcess = null;
|
||
daemonReady = false;
|
||
daemonBusy = false;
|
||
daemonLayout = null;
|
||
});
|
||
|
||
logger.info(`JVM Daemon gestartet (PID ${child.pid})`);
|
||
return true;
|
||
} catch (error) {
|
||
logger.warn(`JVM Daemon Start fehlgeschlagen: ${String(error)}`);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function isDaemonAvailable(layout: JvmExtractorLayout): boolean {
|
||
if (!daemonProcess || !daemonReady) {
|
||
startDaemon(layout);
|
||
}
|
||
return Boolean(daemonProcess && daemonReady && !daemonBusy);
|
||
}
|
||
|
||
/** Wait for the daemon to become ready (boot phase) or free (busy phase), with timeout. */
|
||
function waitForDaemonReady(maxWaitMs: number, signal?: AbortSignal): Promise<boolean> {
|
||
return new Promise((resolve) => {
|
||
const start = Date.now();
|
||
const check = () => {
|
||
if (signal?.aborted) { resolve(false); return; }
|
||
if (daemonProcess && daemonReady && !daemonBusy) { resolve(true); return; }
|
||
if (!daemonProcess) { resolve(false); return; }
|
||
if (Date.now() - start >= maxWaitMs) { resolve(false); return; }
|
||
setTimeout(check, 50);
|
||
};
|
||
check();
|
||
});
|
||
}
|
||
|
||
function sendDaemonRequest(
|
||
archivePath: string,
|
||
targetDir: string,
|
||
conflictMode: ConflictMode,
|
||
passwordCandidates: string[],
|
||
onArchiveProgress?: (percent: number) => void,
|
||
signal?: AbortSignal,
|
||
timeoutMs?: number
|
||
): Promise<JvmExtractResult> {
|
||
return new Promise((resolve) => {
|
||
const mode = effectiveConflictMode(conflictMode);
|
||
const parseState = { bestPercent: 0, usedPassword: "", backend: "", reportedError: "" };
|
||
const archiveName = path.basename(archivePath);
|
||
|
||
daemonBusy = true;
|
||
daemonOutput = "";
|
||
daemonCurrentRequest = {
|
||
resolve,
|
||
onArchiveProgress,
|
||
signal,
|
||
timeoutMs,
|
||
parseState,
|
||
archiveName,
|
||
startedAt: Date.now(),
|
||
passwordCount: passwordCandidates.length
|
||
};
|
||
logger.info(`JVM Daemon Request Start: archive=${archiveName}, pwCandidates=${passwordCandidates.length}, timeoutMs=${timeoutMs || 0}, conflict=${mode}`);
|
||
|
||
if (timeoutMs && timeoutMs > 0) {
|
||
daemonTimeoutId = setTimeout(() => {
|
||
const req = daemonCurrentRequest;
|
||
if (req) {
|
||
finishDaemonRequest({
|
||
ok: false, missingCommand: false, missingRuntime: false,
|
||
aborted: false, timedOut: true,
|
||
errorText: `Entpacken Timeout nach ${Math.ceil(timeoutMs / 1000)}s`,
|
||
usedPassword: parseState.usedPassword, backend: parseState.backend
|
||
});
|
||
}
|
||
shutdownDaemon();
|
||
}, timeoutMs);
|
||
}
|
||
|
||
if (signal) {
|
||
daemonAbortHandler = () => {
|
||
const req = daemonCurrentRequest;
|
||
if (req) {
|
||
finishDaemonRequest({
|
||
ok: false, missingCommand: false, missingRuntime: false,
|
||
aborted: true, timedOut: false, errorText: "aborted:extract",
|
||
usedPassword: parseState.usedPassword, backend: parseState.backend
|
||
});
|
||
}
|
||
shutdownDaemon();
|
||
};
|
||
signal.addEventListener("abort", daemonAbortHandler, { once: true });
|
||
}
|
||
|
||
const jsonRequest = JSON.stringify({
|
||
archive: archivePath,
|
||
target: targetDir,
|
||
conflict: mode,
|
||
backend: "auto",
|
||
passwords: passwordCandidates
|
||
});
|
||
|
||
try {
|
||
daemonProcess!.stdin!.write(jsonRequest + "\n");
|
||
} catch (error) {
|
||
finishDaemonRequest({
|
||
ok: false, missingCommand: false, missingRuntime: false,
|
||
aborted: false, timedOut: false,
|
||
errorText: `Daemon stdin write failed: ${String(error)}`,
|
||
usedPassword: "", backend: ""
|
||
});
|
||
shutdownDaemon();
|
||
}
|
||
});
|
||
}
|
||
|
||
async function runJvmExtractCommand(
|
||
layout: JvmExtractorLayout,
|
||
archivePath: string,
|
||
targetDir: string,
|
||
conflictMode: ConflictMode,
|
||
passwordCandidates: string[],
|
||
onArchiveProgress?: (percent: number) => void,
|
||
signal?: AbortSignal,
|
||
timeoutMs?: number
|
||
): Promise<JvmExtractResult> {
|
||
if (signal?.aborted) {
|
||
return Promise.resolve({
|
||
ok: false, missingCommand: false, missingRuntime: false,
|
||
aborted: true, timedOut: false, errorText: "aborted:extract",
|
||
usedPassword: "", backend: ""
|
||
});
|
||
}
|
||
|
||
// Try persistent daemon first — saves ~5s JVM boot per archive
|
||
if (isDaemonAvailable(layout)) {
|
||
lowerExtractProcessPriority(daemonProcess?.pid, currentExtractCpuPriority);
|
||
logger.info(`JVM Daemon: Sofort verfügbar, sende Request für ${path.basename(archivePath)} (pwCandidates=${passwordCandidates.length})`);
|
||
return sendDaemonRequest(archivePath, targetDir, conflictMode, passwordCandidates, onArchiveProgress, signal, timeoutMs);
|
||
}
|
||
|
||
// Daemon exists but is still booting or busy — wait up to 15s for it
|
||
if (daemonProcess) {
|
||
const reason = !daemonReady ? "booting" : "busy";
|
||
const waitStartedAt = Date.now();
|
||
logger.info(`JVM Daemon: Warte auf ${reason} Daemon für ${path.basename(archivePath)}...`);
|
||
const ready = await waitForDaemonReady(15_000, signal);
|
||
const waitedMs = Date.now() - waitStartedAt;
|
||
if (ready) {
|
||
lowerExtractProcessPriority(daemonProcess?.pid, currentExtractCpuPriority);
|
||
logger.info(`JVM Daemon: Bereit nach ${waitedMs}ms — sende Request für ${path.basename(archivePath)}`);
|
||
return sendDaemonRequest(archivePath, targetDir, conflictMode, passwordCandidates, onArchiveProgress, signal, timeoutMs);
|
||
}
|
||
logger.warn(`JVM Daemon: Timeout nach ${waitedMs}ms beim Warten — Fallback auf neuen Prozess für ${path.basename(archivePath)}`);
|
||
}
|
||
|
||
// Fallback: spawn a new JVM process (daemon not available after waiting)
|
||
logger.info(`JVM Spawn: Neuer Prozess für ${path.basename(archivePath)}`);
|
||
|
||
const mode = effectiveConflictMode(conflictMode);
|
||
const jvmTmpDir = path.join(os.tmpdir(), `rd-extract-${crypto.randomUUID()}`);
|
||
fs.mkdirSync(jvmTmpDir, { recursive: true });
|
||
const args = [
|
||
"-Dfile.encoding=UTF-8",
|
||
`-Djava.io.tmpdir=${jvmTmpDir}`,
|
||
"-Xms1g",
|
||
jvmMaxHeapArg(),
|
||
"-XX:+UseG1GC",
|
||
"-XX:MaxGCPauseMillis=50",
|
||
"-cp",
|
||
layout.classPath,
|
||
JVM_EXTRACTOR_MAIN_CLASS,
|
||
"--archive",
|
||
archivePath,
|
||
"--target",
|
||
targetDir,
|
||
"--conflict",
|
||
mode,
|
||
"--backend",
|
||
"auto"
|
||
];
|
||
for (const password of passwordCandidates) {
|
||
args.push("--password", password);
|
||
}
|
||
|
||
return new Promise((resolve) => {
|
||
let settled = false;
|
||
let output = "";
|
||
let timeoutId: NodeJS.Timeout | null = null;
|
||
let timedOutByWatchdog = false;
|
||
let abortedBySignal = false;
|
||
let onAbort: (() => void) | null = null;
|
||
const parseState = { bestPercent: 0, usedPassword: "", backend: "", reportedError: "" };
|
||
let stdoutBuffer = "";
|
||
let stderrBuffer = "";
|
||
|
||
const child = spawn(layout.javaCommand, args, { windowsHide: true });
|
||
lowerExtractProcessPriority(child.pid, currentExtractCpuPriority);
|
||
|
||
const flushLines = (rawChunk: string, fromStdErr = false): void => {
|
||
if (!rawChunk) {
|
||
return;
|
||
}
|
||
output = appendLimited(output, rawChunk);
|
||
const nextBuffer = `${fromStdErr ? stderrBuffer : stdoutBuffer}${rawChunk}`;
|
||
const lines = nextBuffer.split(/\r?\n/);
|
||
const keep = lines.pop() || "";
|
||
for (const line of lines) {
|
||
parseJvmLine(line, onArchiveProgress, parseState);
|
||
}
|
||
if (fromStdErr) {
|
||
stderrBuffer = keep;
|
||
} else {
|
||
stdoutBuffer = keep;
|
||
}
|
||
};
|
||
|
||
const cleanupTmpDir = (): void => {
|
||
fs.rm(jvmTmpDir, { recursive: true, force: true }, () => {});
|
||
};
|
||
|
||
const finish = (result: JvmExtractResult): void => {
|
||
if (settled) {
|
||
return;
|
||
}
|
||
settled = true;
|
||
if (timeoutId) {
|
||
clearTimeout(timeoutId);
|
||
timeoutId = null;
|
||
}
|
||
if (signal && onAbort) {
|
||
signal.removeEventListener("abort", onAbort);
|
||
}
|
||
cleanupTmpDir();
|
||
resolve(result);
|
||
};
|
||
|
||
if (timeoutMs && timeoutMs > 0) {
|
||
timeoutId = setTimeout(() => {
|
||
timedOutByWatchdog = true;
|
||
killProcessTree(child);
|
||
finish({
|
||
ok: false, missingCommand: false, missingRuntime: false,
|
||
aborted: false, timedOut: true,
|
||
errorText: `Entpacken Timeout nach ${Math.ceil(timeoutMs / 1000)}s`,
|
||
usedPassword: parseState.usedPassword, backend: parseState.backend
|
||
});
|
||
}, timeoutMs);
|
||
}
|
||
|
||
onAbort = signal
|
||
? (): void => {
|
||
abortedBySignal = true;
|
||
killProcessTree(child);
|
||
finish({
|
||
ok: false, missingCommand: false, missingRuntime: false,
|
||
aborted: true, timedOut: false, errorText: "aborted:extract",
|
||
usedPassword: parseState.usedPassword, backend: parseState.backend
|
||
});
|
||
}
|
||
: null;
|
||
|
||
if (signal && onAbort) {
|
||
signal.addEventListener("abort", onAbort, { once: true });
|
||
}
|
||
|
||
child.stdout.on("data", (chunk) => {
|
||
flushLines(String(chunk || ""), false);
|
||
});
|
||
child.stderr.on("data", (chunk) => {
|
||
flushLines(String(chunk || ""), true);
|
||
});
|
||
|
||
child.on("error", (error) => {
|
||
const text = cleanErrorText(String(error));
|
||
finish({
|
||
ok: false, missingCommand: text.toLowerCase().includes("enoent"),
|
||
missingRuntime: true, aborted: false, timedOut: false,
|
||
errorText: text, usedPassword: parseState.usedPassword, backend: parseState.backend
|
||
});
|
||
});
|
||
|
||
child.on("close", (code) => {
|
||
parseJvmLine(stdoutBuffer, onArchiveProgress, parseState);
|
||
parseJvmLine(stderrBuffer, onArchiveProgress, parseState);
|
||
|
||
if (abortedBySignal) {
|
||
finish({
|
||
ok: false, missingCommand: false, missingRuntime: false,
|
||
aborted: true, timedOut: false, errorText: "aborted:extract",
|
||
usedPassword: parseState.usedPassword, backend: parseState.backend
|
||
});
|
||
return;
|
||
}
|
||
if (timedOutByWatchdog) {
|
||
finish({
|
||
ok: false, missingCommand: false, missingRuntime: false,
|
||
aborted: false, timedOut: true,
|
||
errorText: `Entpacken Timeout nach ${Math.ceil((timeoutMs || 0) / 1000)}s`,
|
||
usedPassword: parseState.usedPassword, backend: parseState.backend
|
||
});
|
||
return;
|
||
}
|
||
|
||
const message = cleanErrorText(parseState.reportedError || output) || `Exit Code ${String(code ?? "?")}`;
|
||
if (code === 0) {
|
||
onArchiveProgress?.(100);
|
||
finish({
|
||
ok: true, missingCommand: false, missingRuntime: false,
|
||
aborted: false, timedOut: false, errorText: "",
|
||
usedPassword: parseState.usedPassword, backend: parseState.backend
|
||
});
|
||
return;
|
||
}
|
||
|
||
finish({
|
||
ok: false, missingCommand: false,
|
||
missingRuntime: isJvmRuntimeMissingError(message),
|
||
aborted: false, timedOut: false, errorText: message,
|
||
usedPassword: parseState.usedPassword, backend: parseState.backend
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
// Sektion 13 — Legacy Extraction (buildExternalExtractArgs, runExternalExtract*)
|
||
// ════════════════════════════════════════════════════════════════════════════
|
||
|
||
export function buildExternalExtractArgs(
|
||
command: string,
|
||
archivePath: string,
|
||
targetDir: string,
|
||
conflictMode: ConflictMode,
|
||
password = "",
|
||
usePerformanceFlags = true,
|
||
hybridMode = false,
|
||
flatMode = false
|
||
): string[] {
|
||
const mode = effectiveConflictMode(conflictMode);
|
||
if (isRarNativeCommand(command)) {
|
||
const extractCmd = flatMode ? "e" : "x";
|
||
const overwrite = mode === "overwrite" ? "-o+" : mode === "rename" ? "-or" : "-o-";
|
||
const pass = password ? `-p${password}` : "-p-";
|
||
const perfArgs = usePerformanceFlags && shouldUseExtractorPerformanceFlags()
|
||
? ["-idc", extractorThreadSwitch(hybridMode, currentExtractCpuPriority)]
|
||
: [];
|
||
return [extractCmd, overwrite, pass, "-y", ...perfArgs, archivePath, `${targetDir}${path.sep}`];
|
||
}
|
||
|
||
const overwrite = mode === "overwrite" ? "-aoa" : mode === "rename" ? "-aou" : "-aos";
|
||
const pass = password ? `-p${password}` : "-p";
|
||
return ["x", "-y", overwrite, pass, archivePath, `-o${targetDir}`];
|
||
}
|
||
|
||
// Delay helper for extraction retries
|
||
const extractRetryDelay = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
|
||
|
||
async function runExternalExtractInner(
|
||
command: string,
|
||
archivePath: string,
|
||
targetDir: string,
|
||
conflictMode: ConflictMode,
|
||
passwordCandidates: string[],
|
||
onArchiveProgress: ((percent: number) => void) | undefined,
|
||
signal: AbortSignal | undefined,
|
||
timeoutMs: number,
|
||
hybridMode = false,
|
||
onPasswordAttempt?: (attempt: number, total: number) => void,
|
||
forceFlatMode = false,
|
||
flatModeResult?: { needed: boolean },
|
||
onLog?: ExtractOptions["onLog"]
|
||
): Promise<string> {
|
||
const passwords = passwordCandidates;
|
||
let lastError = "";
|
||
const extractorName = path.basename(command).replace(/\.exe$/i, "") || command;
|
||
|
||
const emptyPasswordCount = passwords.filter((candidate) => candidate === "").length;
|
||
onLog?.("INFO", `Legacy-Extractor Start: archive=${path.basename(archivePath)}, extractor=${extractorName}, passwordCount=${passwords.length}, forceFlatMode=${forceFlatMode}, targetDir=${targetDir}`);
|
||
logger.info(`Legacy-Extractor (${extractorName}): ${path.basename(archivePath)}, passwordCount=${passwords.length}, redacted=true, emptyCandidates=${emptyPasswordCount}${forceFlatMode ? " (flat-mode cached)" : ""}`);
|
||
|
||
let announcedStart = false;
|
||
let bestPercent = 0;
|
||
let passwordAttempt = 0;
|
||
let usePerformanceFlags = externalExtractorSupportsPerfFlags && shouldUseExtractorPerformanceFlags();
|
||
const summarizeResultError = (errorText: string): string => cleanErrorText(errorText);
|
||
let createErrorText = "";
|
||
let createErrorPassword = "";
|
||
|
||
// Skip normal extraction loop if flat mode is already known to be needed for this package
|
||
if (forceFlatMode) {
|
||
logger.info(`Flat-Modus direkt (gespeichert vom vorherigen Archiv): ${path.basename(archivePath)}`);
|
||
onLog?.("INFO", `Flat-Modus direkt (gespeichert vom vorherigen Archiv): ${path.basename(archivePath)}`);
|
||
for (const password of passwords) {
|
||
if (signal?.aborted) throw new Error("aborted:extract");
|
||
passwordAttempt += 1;
|
||
onLog?.("INFO", `Flach-Extraktion Versuch ${passwordAttempt}/${passwords.length}: archive=${path.basename(archivePath)}, password=<redacted>`);
|
||
logger.info(`Flach-Extraktion Versuch ${passwordAttempt}/${passwords.length} für ${path.basename(archivePath)} (password=<redacted>)`);
|
||
const args = buildExternalExtractArgs(command, archivePath, targetDir, conflictMode, password, usePerformanceFlags, hybridMode, true);
|
||
const result = await runExtractCommand(command, args, (chunk) => {
|
||
const parsed = parseProgressPercent(chunk);
|
||
if (parsed === null) return;
|
||
const next = nextArchivePercent(bestPercent, parsed);
|
||
if (next !== bestPercent) { bestPercent = next; onArchiveProgress?.(bestPercent); }
|
||
}, signal, timeoutMs);
|
||
logger.info(`Flach-Extraktion Versuch ${passwordAttempt}/${passwords.length}: ok=${result.ok}, bestPercent=${bestPercent}`);
|
||
onLog?.("INFO", `Flach-Extraktion Ergebnis ${passwordAttempt}/${passwords.length}: archive=${path.basename(archivePath)}, ok=${result.ok}, timedOut=${result.timedOut}, missingCommand=${result.missingCommand}, bestPercent=${bestPercent}`);
|
||
if (result.ok) { if (flatModeResult) flatModeResult.needed = true; onArchiveProgress?.(100); return password; }
|
||
if (result.aborted) throw new Error("aborted:extract");
|
||
if (result.timedOut || result.missingCommand) break;
|
||
lastError = result.errorText;
|
||
}
|
||
throw withExtractionErrorHints(new Error(lastError || "Entpacken fehlgeschlagen (flat-mode)"), { legacyBestPercent: bestPercent, legacyExtractor: extractorName });
|
||
}
|
||
|
||
for (const password of passwords) {
|
||
if (signal?.aborted) {
|
||
throw new Error("aborted:extract");
|
||
}
|
||
if (!announcedStart) {
|
||
announcedStart = true;
|
||
onArchiveProgress?.(0);
|
||
}
|
||
passwordAttempt += 1;
|
||
const attemptStartedAt = Date.now();
|
||
onLog?.("INFO", `Legacy-Passwort-Versuch ${passwordAttempt}/${passwords.length}: archive=${path.basename(archivePath)}, password=<redacted>`);
|
||
logger.info(`Legacy-Passwort-Versuch ${passwordAttempt}/${passwords.length} für ${path.basename(archivePath)} (password=<redacted>)`);
|
||
if (passwords.length > 1) {
|
||
onPasswordAttempt?.(passwordAttempt, passwords.length);
|
||
}
|
||
let args = buildExternalExtractArgs(command, archivePath, targetDir, conflictMode, password, usePerformanceFlags, hybridMode);
|
||
let result = await runExtractCommand(command, args, (chunk) => {
|
||
const parsed = parseProgressPercent(chunk);
|
||
if (parsed === null) {
|
||
return;
|
||
}
|
||
const next = nextArchivePercent(bestPercent, parsed);
|
||
if (next !== bestPercent) {
|
||
bestPercent = next;
|
||
onArchiveProgress?.(bestPercent);
|
||
}
|
||
}, signal, timeoutMs);
|
||
|
||
if (!result.ok && usePerformanceFlags && isUnsupportedExtractorSwitchError(result.errorText)) {
|
||
usePerformanceFlags = false;
|
||
externalExtractorSupportsPerfFlags = false;
|
||
onLog?.("WARN", `Entpacker ohne Performance-Flags fortgesetzt: ${path.basename(archivePath)}`);
|
||
logger.warn(`Entpacker ohne Performance-Flags fortgesetzt: ${path.basename(archivePath)}`);
|
||
args = buildExternalExtractArgs(command, archivePath, targetDir, conflictMode, password, false, hybridMode);
|
||
result = await runExtractCommand(command, args, (chunk) => {
|
||
const parsed = parseProgressPercent(chunk);
|
||
if (parsed === null) {
|
||
return;
|
||
}
|
||
const next = nextArchivePercent(bestPercent, parsed);
|
||
if (next !== bestPercent) {
|
||
bestPercent = next;
|
||
onArchiveProgress?.(bestPercent);
|
||
}
|
||
}, signal, timeoutMs);
|
||
}
|
||
|
||
logger.info(
|
||
`Legacy-Passwort-Versuch Ergebnis: archive=${path.basename(archivePath)}, attempt=${passwordAttempt}/${passwords.length}, ` +
|
||
`ms=${Date.now() - attemptStartedAt}, ok=${result.ok}, timedOut=${result.timedOut}, missingCommand=${result.missingCommand}, bestPercent=${bestPercent}`
|
||
);
|
||
onLog?.("INFO", `Legacy-Passwort-Versuch Ergebnis: archive=${path.basename(archivePath)}, attempt=${passwordAttempt}/${passwords.length}, ms=${Date.now() - attemptStartedAt}, ok=${result.ok}, timedOut=${result.timedOut}, missingCommand=${result.missingCommand}, bestPercent=${bestPercent}`);
|
||
if (!result.ok) {
|
||
const errorSummary = summarizeResultError(result.errorText);
|
||
if (errorSummary) {
|
||
logger.info(`Legacy-Passwort-Versuch Fehlertext: archive=${path.basename(archivePath)}, attempt=${passwordAttempt}/${passwords.length}, extractor=${extractorName}, error=${errorSummary}`);
|
||
onLog?.("INFO", `Legacy-Passwort-Versuch Fehlertext: archive=${path.basename(archivePath)}, attempt=${passwordAttempt}/${passwords.length}, extractor=${extractorName}, error=${errorSummary}`);
|
||
}
|
||
}
|
||
|
||
if (result.ok) {
|
||
onArchiveProgress?.(100);
|
||
return password;
|
||
}
|
||
|
||
if (!createErrorText && result.errorText.includes("Cannot create")) {
|
||
createErrorText = result.errorText;
|
||
createErrorPassword = password;
|
||
logger.warn(`Entpack-Pfadfehler gemerkt: archive=${path.basename(archivePath)}, attempt=${passwordAttempt}/${passwords.length}, extractor=${extractorName}, password=<redacted>`);
|
||
onLog?.("WARN", `Entpack-Pfadfehler gemerkt: archive=${path.basename(archivePath)}, attempt=${passwordAttempt}/${passwords.length}, extractor=${extractorName}, password=<redacted>`);
|
||
}
|
||
|
||
if (result.aborted) {
|
||
throw new Error("aborted:extract");
|
||
}
|
||
|
||
if (result.timedOut) {
|
||
lastError = result.errorText;
|
||
break;
|
||
}
|
||
|
||
if (result.missingCommand) {
|
||
resolvedExtractorCommand = null;
|
||
resolveFailureReason = NO_EXTRACTOR_MESSAGE;
|
||
resolveFailureAt = Date.now();
|
||
throw withExtractionErrorHints(new Error(NO_EXTRACTOR_MESSAGE), { legacyBestPercent: bestPercent, legacyExtractor: extractorName });
|
||
}
|
||
|
||
lastError = result.errorText;
|
||
}
|
||
|
||
// Some archives store internal paths with a leading \, causing invalid \\ paths.
|
||
// Retry in flat mode ("e" instead of "x") which strips all archive paths.
|
||
const pathCreateError = createErrorText || (lastError.includes("Cannot create") ? lastError : "");
|
||
if (pathCreateError) {
|
||
const flatPasswords = createErrorPassword
|
||
? prioritizePassword(passwords, createErrorPassword)
|
||
: passwords;
|
||
logger.warn(`Entpack-Pfadfehler: Wiederholung mit flachem Modus: ${path.basename(archivePath)}`);
|
||
onLog?.("WARN", `Entpack-Pfadfehler: Wiederholung mit flachem Modus: ${path.basename(archivePath)}`);
|
||
bestPercent = 0;
|
||
passwordAttempt = 0;
|
||
lastError = pathCreateError;
|
||
for (const password of flatPasswords) {
|
||
if (signal?.aborted) throw new Error("aborted:extract");
|
||
passwordAttempt += 1;
|
||
logger.info(`Flach-Extraktion Versuch ${passwordAttempt}/${passwords.length} für ${path.basename(archivePath)} (password=<redacted>)`);
|
||
onLog?.("INFO", `Flach-Extraktion Versuch ${passwordAttempt}/${flatPasswords.length}: archive=${path.basename(archivePath)}, password=<redacted>`);
|
||
const args = buildExternalExtractArgs(command, archivePath, targetDir, conflictMode, password, usePerformanceFlags, hybridMode, true);
|
||
const result = await runExtractCommand(command, args, (chunk) => {
|
||
const parsed = parseProgressPercent(chunk);
|
||
if (parsed === null) return;
|
||
const next = nextArchivePercent(bestPercent, parsed);
|
||
if (next !== bestPercent) { bestPercent = next; onArchiveProgress?.(bestPercent); }
|
||
}, signal, timeoutMs);
|
||
logger.info(`Flach-Extraktion Versuch ${passwordAttempt}/${passwords.length}: ok=${result.ok}, bestPercent=${bestPercent}`);
|
||
onLog?.("INFO", `Flach-Extraktion Ergebnis ${passwordAttempt}/${flatPasswords.length}: archive=${path.basename(archivePath)}, ok=${result.ok}, timedOut=${result.timedOut}, missingCommand=${result.missingCommand}, bestPercent=${bestPercent}`);
|
||
if (result.ok) { if (flatModeResult) flatModeResult.needed = true; onArchiveProgress?.(100); return password; }
|
||
if (result.aborted) throw new Error("aborted:extract");
|
||
if (result.timedOut || result.missingCommand) break;
|
||
lastError = result.errorText;
|
||
}
|
||
}
|
||
|
||
throw withExtractionErrorHints(new Error(lastError || "Entpacken fehlgeschlagen"), { legacyBestPercent: bestPercent, legacyExtractor: extractorName });
|
||
}
|
||
|
||
async function runExternalExtract(
|
||
archivePath: string,
|
||
targetDir: string,
|
||
conflictMode: ConflictMode,
|
||
passwordCandidates: string[],
|
||
onArchiveProgress?: (percent: number) => void,
|
||
signal?: AbortSignal,
|
||
hybridMode = false,
|
||
onPasswordAttempt?: (attempt: number, total: number) => void,
|
||
forceFlatMode = false,
|
||
flatModeResult?: { needed: boolean },
|
||
onLog?: ExtractOptions["onLog"]
|
||
): Promise<string> {
|
||
const timeoutMs = await computeExtractTimeoutMs(archivePath);
|
||
const configuredBackendMode = extractorBackendMode();
|
||
const backendMode = extractorBackendModeForArchive(archivePath);
|
||
const archiveName = path.basename(archivePath);
|
||
const totalStartedAt = Date.now();
|
||
let jvmFailureReason = "";
|
||
let jvmCodecError = false;
|
||
let fallbackFromJvm = false;
|
||
logger.info(`Extract-Backend Start: archive=${archiveName}, mode=${backendMode}, configuredMode=${configuredBackendMode}, pwCandidates=${passwordCandidates.length}, timeoutMs=${timeoutMs}, hybrid=${hybridMode}`);
|
||
onLog?.("INFO", `Extract-Backend Start: archive=${archiveName}, mode=${backendMode}, configuredMode=${configuredBackendMode}, pwCandidates=${passwordCandidates.length}, timeoutMs=${timeoutMs}, hybrid=${hybridMode}`);
|
||
|
||
await fs.promises.mkdir(targetDir, { recursive: true });
|
||
|
||
let subst: SubstMapping | null = null;
|
||
|
||
try {
|
||
if (backendMode !== "legacy") {
|
||
const layout = resolveJvmExtractorLayout();
|
||
if (!layout) {
|
||
jvmFailureReason = NO_JVM_EXTRACTOR_MESSAGE;
|
||
if (backendMode === "jvm") {
|
||
throw new Error(NO_JVM_EXTRACTOR_MESSAGE);
|
||
}
|
||
logger.warn(`JVM-Extractor nicht verfügbar, nutze Legacy-Extractor: ${path.basename(archivePath)}`);
|
||
} else {
|
||
const emptyCount = passwordCandidates.filter((candidate) => candidate === "").length;
|
||
logger.info(`JVM-Extractor aktiv (${layout.rootDir}): ${archiveName}, passwordCount=${passwordCandidates.length}, redacted=true, emptyCandidates=${emptyCount}`);
|
||
const jvmStartedAt = Date.now();
|
||
onLog?.("INFO", `JVM-Extractor vorbereitet: archive=${archiveName}, passwordCandidates=${passwordCandidates.length}, layout=${layout.rootDir}`);
|
||
const jvmResult = await runJvmExtractCommand(
|
||
layout, archivePath, targetDir, conflictMode, passwordCandidates,
|
||
onArchiveProgress, signal, timeoutMs
|
||
);
|
||
const jvmMs = Date.now() - jvmStartedAt;
|
||
onLog?.("INFO", `JVM-Extractor Ergebnis: archive=${archiveName}, ok=${jvmResult.ok}, ms=${jvmMs}, timedOut=${jvmResult.timedOut}, aborted=${jvmResult.aborted}, backend=${jvmResult.backend || "unknown"}, usedPassword=${jvmResult.usedPassword ? "yes" : "no"}`);
|
||
logger.info(`JVM-Extractor Ergebnis: 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"}: ${archiveName}`);
|
||
logger.info(`Extract-Backend Ende: archive=${archiveName}, backend=${jvmResult.backend || "jvm"}, mode=${backendMode}, ms=${Date.now() - totalStartedAt}, fallbackFromJvm=false, usedPassword=${jvmResult.usedPassword ? "yes" : "no"}`);
|
||
return jvmResult.usedPassword;
|
||
}
|
||
if (jvmResult.aborted) {
|
||
throw new Error("aborted:extract");
|
||
}
|
||
if (jvmResult.timedOut) {
|
||
throw new Error(jvmResult.errorText || `Entpacken Timeout nach ${Math.ceil(timeoutMs / 1000)}s`);
|
||
}
|
||
|
||
jvmFailureReason = jvmResult.errorText || "JVM-Extractor fehlgeschlagen";
|
||
fallbackFromJvm = true;
|
||
const jvmFailureLower = jvmFailureReason.toLowerCase();
|
||
const isUnsupportedMethod = jvmFailureReason.includes("UNSUPPORTEDMETHOD");
|
||
const isCodecError = jvmFailureLower.includes("registered codecs")
|
||
|| jvmFailureLower.includes("can not open")
|
||
|| jvmFailureLower.includes("cannot open archive");
|
||
jvmCodecError = isCodecError;
|
||
const isWrongPassword = jvmFailureReason.includes("WRONG_PASSWORD")
|
||
|| jvmFailureLower.includes("wrong password");
|
||
const shouldFallbackToLegacy = isUnsupportedMethod || isCodecError || isWrongPassword;
|
||
onLog?.("WARN", `JVM-Extractor Fallback-Analyse: archive=${archiveName}, unsupportedMethod=${isUnsupportedMethod}, codecError=${isCodecError}, wrongPassword=${isWrongPassword}, backendMode=${backendMode}`);
|
||
if (backendMode === "jvm" && !shouldFallbackToLegacy) {
|
||
throw new Error(jvmFailureReason);
|
||
}
|
||
if (isUnsupportedMethod) {
|
||
logger.warn(`JVM-Extractor: Komprimierungsmethode nicht unterstützt, fallback auf Legacy: ${path.basename(archivePath)}`);
|
||
} else if (isCodecError) {
|
||
logger.warn(`JVM-Extractor: Archiv-Format nicht erkannt, fallback auf Legacy: ${path.basename(archivePath)}`);
|
||
} else if (isWrongPassword) {
|
||
logger.warn(`JVM-Extractor: Kein Passwort hat funktioniert, fallback auf Legacy: ${path.basename(archivePath)}`);
|
||
} else {
|
||
logger.warn(`JVM-Extractor Fehler, fallback auf Legacy: ${jvmFailureReason}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Use a short drive mapping for legacy native extractors on Windows.
|
||
subst = createSubstMapping(targetDir);
|
||
const effectiveTargetDir = subst ? `${subst.drive}:\\` : targetDir;
|
||
if (subst) {
|
||
onLog?.("INFO", `Legacy-Zielpfad verkuerzt via subst: archive=${archiveName}, originalTargetDir=${targetDir}, effectiveTargetDir=${effectiveTargetDir}`);
|
||
} else {
|
||
onLog?.("INFO", `Legacy-Zielpfad unveraendert: archive=${archiveName}, effectiveTargetDir=${effectiveTargetDir}`);
|
||
}
|
||
|
||
const command = await resolveExtractorCommand(archivePath);
|
||
const legacyStartedAt = Date.now();
|
||
let password: string;
|
||
let usedCommand = command;
|
||
try {
|
||
try {
|
||
password = await runExternalExtractInner(
|
||
command, archivePath, effectiveTargetDir, conflictMode, passwordCandidates,
|
||
onArchiveProgress, signal, timeoutMs, hybridMode, onPasswordAttempt,
|
||
forceFlatMode, flatModeResult, onLog
|
||
);
|
||
} catch (primaryError) {
|
||
const isRar = /\.rar$/i.test(archiveName) || /\.r\d{2,3}$/i.test(archiveName);
|
||
const errText = String((primaryError as Error)?.message || primaryError || "");
|
||
const isPasswordOrCorrupt = /wrong.password|checksum error|corrupt/i.test(errText);
|
||
if (isRar && isPasswordOrCorrupt && !signal?.aborted) {
|
||
const alt = await findAlternativeExtractor(command, archivePath);
|
||
if (alt) {
|
||
const altName = path.basename(alt).replace(/\.exe$/i, "");
|
||
onLog?.("INFO", `Legacy-Fallback: primary=${path.basename(command)}, alternative=${altName}, archive=${archiveName}`);
|
||
logger.info(`Legacy-Fallback: ${path.basename(command)} fehlgeschlagen bei RAR, versuche ${altName}: ${archiveName}`);
|
||
usedCommand = alt;
|
||
password = await runExternalExtractInner(
|
||
alt, archivePath, effectiveTargetDir, conflictMode, passwordCandidates,
|
||
onArchiveProgress, signal, timeoutMs, hybridMode, onPasswordAttempt,
|
||
forceFlatMode, flatModeResult, onLog
|
||
);
|
||
} else {
|
||
throw primaryError;
|
||
}
|
||
} else {
|
||
throw primaryError;
|
||
}
|
||
}
|
||
} catch (legacyError) {
|
||
const initialLegacyText = String((legacyError as Error)?.message || legacyError || "");
|
||
const initialLegacyCategory = classifyExtractionError(initialLegacyText);
|
||
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 a short delay to let Windows flush freshly completed archive parts.
|
||
if (isCrcOrWrongPw && !signal?.aborted) {
|
||
const retryDelayMs = 2500;
|
||
logger.warn(
|
||
`Legacy-Extraktion fehlgeschlagen (${initialLegacyCategory}), Retry nach ${retryDelayMs}ms Delay: ${archiveName}`
|
||
);
|
||
onLog?.("WARN", `Legacy-Extraktion fehlgeschlagen (${initialLegacyCategory}), Retry nach ${retryDelayMs}ms Delay: ${archiveName}`);
|
||
await extractRetryDelay(retryDelayMs);
|
||
if (!signal?.aborted) {
|
||
try {
|
||
const retryCmd = usedCommand;
|
||
const retryPassword = await runExternalExtractInner(
|
||
retryCmd,
|
||
archivePath,
|
||
effectiveTargetDir,
|
||
conflictMode,
|
||
passwordCandidates,
|
||
onArchiveProgress,
|
||
signal,
|
||
timeoutMs,
|
||
hybridMode,
|
||
onPasswordAttempt,
|
||
forceFlatMode,
|
||
flatModeResult,
|
||
onLog
|
||
);
|
||
logger.info(`Legacy-Retry erfolgreich: ${archiveName}`);
|
||
onLog?.("INFO", `Legacy-Retry erfolgreich: ${archiveName}`);
|
||
password = retryPassword;
|
||
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) {
|
||
const retryText = String((retryError as Error)?.message || retryError || "");
|
||
const retryCategory = classifyExtractionError(retryText);
|
||
logger.warn(`Legacy-Retry ebenfalls fehlgeschlagen (${retryCategory}): ${archiveName}`);
|
||
onLog?.("WARN", `Legacy-Retry ebenfalls fehlgeschlagen (${retryCategory}): ${archiveName}`);
|
||
const suggestRedownload = jvmCodecError && (retryCategory === "crc_error" || retryCategory === "wrong_password");
|
||
finalLegacyError = withExtractionErrorHints(retryError, {
|
||
suggestRedownload,
|
||
jvmFailureReason: jvmFailureReason || undefined
|
||
});
|
||
}
|
||
} else {
|
||
finalLegacyError = withExtractionErrorHints(legacyError, {
|
||
jvmFailureReason: jvmFailureReason || undefined
|
||
});
|
||
}
|
||
} else {
|
||
const suggestRedownload = jvmCodecError && isCrcOrWrongPw;
|
||
finalLegacyError = withExtractionErrorHints(legacyError, {
|
||
suggestRedownload,
|
||
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 extractorName = path.basename(usedCommand).replace(/\.exe$/i, "");
|
||
if (jvmFailureReason) {
|
||
logger.info(`Entpackt via legacy/${extractorName} (nach JVM-Fehler): ${archiveName}`);
|
||
} else {
|
||
logger.info(`Entpackt via legacy/${extractorName}: ${archiveName}`);
|
||
}
|
||
logger.info(`Extract-Backend Ende: archive=${archiveName}, backend=legacy/${extractorName}, mode=${backendMode}, ms=${Date.now() - totalStartedAt}, legacyMs=${legacyMs}, fallbackFromJvm=${fallbackFromJvm}, usedPassword=${password ? "yes" : "no"}`);
|
||
onLog?.("INFO", `Extract-Backend Ende: archive=${archiveName}, backend=legacy/${extractorName}, mode=${backendMode}, ms=${Date.now() - totalStartedAt}, legacyMs=${legacyMs}, fallbackFromJvm=${fallbackFromJvm}, usedPassword=${password ? "yes" : "no"}`);
|
||
return password;
|
||
} finally {
|
||
if (subst) removeSubstMapping(subst);
|
||
}
|
||
}
|
||
|
||
// ══════════════════════════════════════════════════════════════════════════════
|
||
// Sektion 14 – ZIP Extraction (AdmZip)
|
||
// ══════════════════════════════════════════════════════════════════════════════
|
||
|
||
function isZipSafetyGuardError(error: unknown): boolean {
|
||
const text = String(error || "").toLowerCase();
|
||
return text.includes("path traversal")
|
||
|| text.includes("zip-eintrag verdächtig groß")
|
||
|| text.includes("zip-eintrag verdaechtig gross");
|
||
}
|
||
|
||
function isZipInternalLimitError(error: unknown): boolean {
|
||
const text = String(error || "").toLowerCase();
|
||
return text.includes("zip-eintrag zu groß")
|
||
|| text.includes("zip-eintrag komprimiert zu groß")
|
||
|| text.includes("zip-eintrag ohne sichere groessenangabe");
|
||
}
|
||
|
||
function shouldFallbackToExternalZip(error: unknown): boolean {
|
||
if (isZipSafetyGuardError(error)) {
|
||
return false;
|
||
}
|
||
if (isZipInternalLimitError(error)) {
|
||
return true;
|
||
}
|
||
const text = String(error || "").toLowerCase();
|
||
if (text.includes("aborted:extract") || text.includes("extract_aborted")) {
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
async function extractZipArchive(archivePath: string, targetDir: string, conflictMode: ConflictMode, signal?: AbortSignal): Promise<void> {
|
||
const mode = effectiveConflictMode(conflictMode);
|
||
const memoryLimitBytes = zipEntryMemoryLimitBytes();
|
||
const zip = new AdmZip(archivePath);
|
||
const entries = zip.getEntries();
|
||
const resolvedTarget = path.resolve(targetDir);
|
||
const usedOutputs = new Set<string>();
|
||
const renameCounters = new Map<string, number>();
|
||
|
||
for (const entry of entries) {
|
||
if (signal?.aborted) {
|
||
throw new Error("aborted:extract");
|
||
}
|
||
const baseOutputPath = path.resolve(targetDir, entry.entryName);
|
||
if (!baseOutputPath.startsWith(resolvedTarget + path.sep) && baseOutputPath !== resolvedTarget) {
|
||
logger.warn(`ZIP-Eintrag übersprungen (Path Traversal): ${entry.entryName}`);
|
||
continue;
|
||
}
|
||
if (entry.isDirectory) {
|
||
await fs.promises.mkdir(baseOutputPath, { recursive: true });
|
||
continue;
|
||
}
|
||
|
||
const header = (entry as unknown as {
|
||
header?: {
|
||
size?: number;
|
||
compressedSize?: number;
|
||
crc?: number;
|
||
dataHeader?: {
|
||
size?: number;
|
||
compressedSize?: number;
|
||
crc?: number;
|
||
};
|
||
};
|
||
}).header;
|
||
const uncompressedSize = Number(header?.size ?? header?.dataHeader?.size ?? NaN);
|
||
const compressedSize = Number(header?.compressedSize ?? header?.dataHeader?.compressedSize ?? NaN);
|
||
|
||
if (!Number.isFinite(uncompressedSize) || uncompressedSize < 0) {
|
||
throw new Error("ZIP-Eintrag ohne sichere Groessenangabe fur internen Entpacker");
|
||
}
|
||
if (!Number.isFinite(compressedSize) || compressedSize < 0) {
|
||
throw new Error("ZIP-Eintrag ohne sichere Groessenangabe fur internen Entpacker");
|
||
}
|
||
|
||
if (uncompressedSize > memoryLimitBytes) {
|
||
const entryMb = Math.ceil(uncompressedSize / (1024 * 1024));
|
||
const limitMb = Math.ceil(memoryLimitBytes / (1024 * 1024));
|
||
throw new Error(`ZIP-Eintrag zu groß für internen Entpacker (${entryMb} MB > ${limitMb} MB)`);
|
||
}
|
||
if (compressedSize > memoryLimitBytes) {
|
||
const entryMb = Math.ceil(compressedSize / (1024 * 1024));
|
||
const limitMb = Math.ceil(memoryLimitBytes / (1024 * 1024));
|
||
throw new Error(`ZIP-Eintrag komprimiert zu groß für internen Entpacker (${entryMb} MB > ${limitMb} MB)`);
|
||
}
|
||
|
||
let outputPath = baseOutputPath;
|
||
let outputKey = pathSetKey(outputPath);
|
||
|
||
await fs.promises.mkdir(path.dirname(outputPath), { recursive: true });
|
||
// TOCTOU note: There is a small race between access and writeFile below.
|
||
// This is acceptable here because zip extraction is single-threaded and we need
|
||
// the exists check to implement skip/rename conflict resolution semantics.
|
||
const outputExists = usedOutputs.has(outputKey) || await fs.promises.access(outputPath).then(() => true, () => false);
|
||
if (outputExists) {
|
||
if (mode === "skip") {
|
||
continue;
|
||
}
|
||
if (mode === "rename") {
|
||
const parsed = path.parse(baseOutputPath);
|
||
const counterKey = pathSetKey(baseOutputPath);
|
||
let n = renameCounters.get(counterKey) || 1;
|
||
let candidate = baseOutputPath;
|
||
let candidateKey = outputKey;
|
||
while (n <= 10000) {
|
||
candidate = path.join(parsed.dir, `${parsed.name} (${n})${parsed.ext}`);
|
||
candidateKey = pathSetKey(candidate);
|
||
if (!usedOutputs.has(candidateKey) && !(await fs.promises.access(candidate).then(() => true, () => false))) {
|
||
break;
|
||
}
|
||
n += 1;
|
||
}
|
||
if (n > 10000) {
|
||
throw new Error(`ZIP-Rename-Limit erreicht für ${entry.entryName}`);
|
||
}
|
||
renameCounters.set(counterKey, n + 1);
|
||
if (signal?.aborted) {
|
||
throw new Error("aborted:extract");
|
||
}
|
||
outputPath = candidate;
|
||
outputKey = candidateKey;
|
||
}
|
||
}
|
||
|
||
if (signal?.aborted) {
|
||
throw new Error("aborted:extract");
|
||
}
|
||
const data = entry.getData();
|
||
if (data.length > memoryLimitBytes) {
|
||
const entryMb = Math.ceil(data.length / (1024 * 1024));
|
||
const limitMb = Math.ceil(memoryLimitBytes / (1024 * 1024));
|
||
throw new Error(`ZIP-Eintrag zu groß für internen Entpacker (${entryMb} MB > ${limitMb} MB)`);
|
||
}
|
||
const maxDeclaredSize = Math.max(uncompressedSize, compressedSize);
|
||
if (maxDeclaredSize > 0 && data.length > maxDeclaredSize * 20) {
|
||
throw new Error(`ZIP-Eintrag verdächtig groß nach Entpacken (${entry.entryName})`);
|
||
}
|
||
await fs.promises.writeFile(outputPath, data);
|
||
usedOutputs.add(outputKey);
|
||
}
|
||
}
|
||
|
||
// ══════════════════════════════════════════════════════════════════════════════
|
||
// Sektion 15 – Disk Space, Timeout & Memory Limits
|
||
// ══════════════════════════════════════════════════════════════════════════════
|
||
|
||
async function estimateArchivesTotalBytes(candidates: string[]): Promise<number> {
|
||
let total = 0;
|
||
for (const archivePath of candidates) {
|
||
const parts = collectArchiveCleanupTargets(archivePath);
|
||
for (const part of parts) {
|
||
try {
|
||
total += (await fs.promises.stat(part)).size;
|
||
} catch { /* missing part, ignore */ }
|
||
}
|
||
}
|
||
return total;
|
||
}
|
||
|
||
function humanSizeGB(bytes: number): string {
|
||
if (bytes >= 1024 * 1024 * 1024) {
|
||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
||
}
|
||
return `${(bytes / (1024 * 1024)).toFixed(0)} MB`;
|
||
}
|
||
|
||
async function checkDiskSpaceForExtraction(targetDir: string, candidates: string[]): Promise<void> {
|
||
if (candidates.length === 0) return;
|
||
const archiveBytes = await estimateArchivesTotalBytes(candidates);
|
||
if (archiveBytes <= 0) return;
|
||
const requiredBytes = Math.ceil(archiveBytes * DISK_SPACE_SAFETY_FACTOR);
|
||
|
||
let freeBytes: number;
|
||
try {
|
||
const stats = await fs.promises.statfs(targetDir);
|
||
freeBytes = stats.bfree * stats.bsize;
|
||
} catch {
|
||
return;
|
||
}
|
||
|
||
if (freeBytes < requiredBytes) {
|
||
const msg = `Nicht genug Speicherplatz: ${humanSizeGB(requiredBytes)} benötigt, ${humanSizeGB(freeBytes)} frei`;
|
||
logger.error(`Disk-Space-Check: ${msg} (target=${targetDir})`);
|
||
throw new Error(msg);
|
||
}
|
||
logger.info(`Disk-Space-Check OK: ${humanSizeGB(freeBytes)} frei, ${humanSizeGB(requiredBytes)} benötigt (target=${targetDir})`);
|
||
}
|
||
|
||
function zipEntryMemoryLimitBytes(): number {
|
||
const fromEnvMb = Number(process.env.RD_ZIP_ENTRY_MEMORY_LIMIT_MB ?? NaN);
|
||
if (Number.isFinite(fromEnvMb) && fromEnvMb >= 8 && fromEnvMb <= 4096) {
|
||
return Math.floor(fromEnvMb * 1024 * 1024);
|
||
}
|
||
return DEFAULT_ZIP_ENTRY_MEMORY_LIMIT_MB * 1024 * 1024;
|
||
}
|
||
|
||
async function shouldPreferExternalZip(archivePath: string): Promise<boolean> {
|
||
if (extractorBackendMode() !== "legacy") {
|
||
return true;
|
||
}
|
||
try {
|
||
const stat = await fs.promises.stat(archivePath);
|
||
return stat.size >= 64 * 1024 * 1024;
|
||
} catch {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
async function computeExtractTimeoutMs(archivePath: string): Promise<number> {
|
||
try {
|
||
const relatedFiles = collectArchiveCleanupTargets(archivePath);
|
||
let totalBytes = 0;
|
||
for (const filePath of relatedFiles) {
|
||
try {
|
||
totalBytes += (await fs.promises.stat(filePath)).size;
|
||
} catch {
|
||
// ignore missing parts
|
||
}
|
||
}
|
||
if (totalBytes <= 0) {
|
||
totalBytes = (await fs.promises.stat(archivePath)).size;
|
||
}
|
||
const gib = totalBytes / (1024 * 1024 * 1024);
|
||
const dynamicMs = EXTRACT_BASE_TIMEOUT_MS + Math.floor(gib * EXTRACT_PER_GIB_TIMEOUT_MS);
|
||
return Math.max(EXTRACT_BASE_TIMEOUT_MS, Math.min(EXTRACT_MAX_TIMEOUT_MS, dynamicMs));
|
||
} catch {
|
||
return EXTRACT_BASE_TIMEOUT_MS;
|
||
}
|
||
}
|
||
|
||
// ══════════════════════════════════════════════════════════════════════════════
|
||
// Sektion 16 – Resume State
|
||
// ══════════════════════════════════════════════════════════════════════════════
|
||
|
||
function extractProgressFilePath(packageDir: string, packageId?: string): string {
|
||
if (packageId) {
|
||
return path.join(packageDir, `.rd_extract_progress_${packageId}.json`);
|
||
}
|
||
return path.join(packageDir, EXTRACT_PROGRESS_FILE);
|
||
}
|
||
|
||
async function readExtractResumeState(packageDir: string, packageId?: string): Promise<Set<string>> {
|
||
const progressPath = extractProgressFilePath(packageDir, packageId);
|
||
try {
|
||
await fs.promises.access(progressPath);
|
||
} catch {
|
||
return new Set<string>();
|
||
}
|
||
try {
|
||
const payload = JSON.parse(await fs.promises.readFile(progressPath, "utf8")) as Partial<ExtractResumeState>;
|
||
const names = Array.isArray(payload.completedArchives) ? payload.completedArchives : [];
|
||
return new Set(names.map((value) => archiveNameKey(String(value || "").trim())).filter(Boolean));
|
||
} catch {
|
||
return new Set<string>();
|
||
}
|
||
}
|
||
|
||
async function writeExtractResumeState(packageDir: string, completedArchives: Set<string>, packageId?: string): Promise<void> {
|
||
try {
|
||
await fs.promises.mkdir(packageDir, { recursive: true });
|
||
const progressPath = extractProgressFilePath(packageDir, packageId);
|
||
const payload: ExtractResumeState = {
|
||
completedArchives: Array.from(completedArchives)
|
||
.map((name) => archiveNameKey(name))
|
||
.sort((a, b) => a.localeCompare(b))
|
||
};
|
||
const tmpPath = progressPath + "." + Date.now() + "." + Math.random().toString(36).slice(2, 8) + ".tmp";
|
||
await fs.promises.writeFile(tmpPath, JSON.stringify(payload, null, 2), "utf8");
|
||
await fs.promises.rename(tmpPath, progressPath).catch(async () => {
|
||
// rename may fail if another writer renamed tmpPath first (parallel workers)
|
||
await fs.promises.rm(tmpPath, { force: true }).catch(() => {});
|
||
});
|
||
} catch (error) {
|
||
logger.warn(`ExtractResumeState schreiben fehlgeschlagen: ${String(error)}`);
|
||
}
|
||
}
|
||
|
||
export async function clearExtractResumeState(packageDir: string, packageId?: string): Promise<void> {
|
||
try {
|
||
await fs.promises.rm(extractProgressFilePath(packageDir, packageId), { force: true });
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}
|
||
|
||
// ══════════════════════════════════════════════════════════════════════════════
|
||
// Sektion 17 – Progress & Conflict Helpers
|
||
// ══════════════════════════════════════════════════════════════════════════════
|
||
|
||
function emitExtractLog(
|
||
onLog: ExtractOptions["onLog"] | undefined,
|
||
level: "INFO" | "WARN" | "ERROR",
|
||
message: string
|
||
): void {
|
||
if (level === "INFO") {
|
||
logger.info(message);
|
||
} else if (level === "WARN") {
|
||
logger.warn(message);
|
||
} else {
|
||
logger.error(message);
|
||
}
|
||
onLog?.(level, message);
|
||
}
|
||
|
||
function effectiveConflictMode(conflictMode: ConflictMode): "overwrite" | "skip" | "rename" {
|
||
if (conflictMode === "rename") {
|
||
return "rename";
|
||
}
|
||
if (conflictMode === "overwrite") {
|
||
return "overwrite";
|
||
}
|
||
return "skip";
|
||
}
|
||
|
||
// ══════════════════════════════════════════════════════════════════════════════
|
||
// Sektion 18 – extractPackageArchives (Orchestrierung)
|
||
// ══════════════════════════════════════════════════════════════════════════════
|
||
|
||
export async function extractPackageArchives(options: ExtractOptions): Promise<{ extracted: number; failed: number; lastError: string }> {
|
||
if (options.signal?.aborted) {
|
||
throw new Error("aborted:extract");
|
||
}
|
||
options.onProgress?.({ current: 0, total: 0, percent: 0, archiveName: "Archive scannen...", phase: "preparing" });
|
||
const allCandidates = await findArchiveCandidates(options.packageDir);
|
||
const candidates = options.onlyArchives
|
||
? allCandidates.filter((archivePath) => {
|
||
const key = process.platform === "win32" ? path.resolve(archivePath).toLowerCase() : path.resolve(archivePath);
|
||
return options.onlyArchives!.has(key);
|
||
})
|
||
: allCandidates;
|
||
logger.info(`Entpacken gestartet: packageDir=${options.packageDir}, targetDir=${options.targetDir}, archives=${candidates.length}${options.onlyArchives ? ` (hybrid, gesamt=${allCandidates.length})` : ""}, cleanupMode=${options.cleanupMode}, conflictMode=${options.conflictMode}`);
|
||
options.onLog?.("INFO", `Entpacken gestartet: packageDir=${options.packageDir}, targetDir=${options.targetDir}, archives=${candidates.length}${options.onlyArchives ? ` (hybrid, gesamt=${allCandidates.length})` : ""}, cleanupMode=${options.cleanupMode}, conflictMode=${options.conflictMode}`);
|
||
|
||
// Disk space pre-check
|
||
if (candidates.length > 0) {
|
||
options.onProgress?.({ current: 0, total: candidates.length, percent: 0, archiveName: "Speicherplatz prüfen...", phase: "preparing" });
|
||
try {
|
||
await fs.promises.mkdir(options.targetDir, { recursive: true });
|
||
} catch { /* ignore */ }
|
||
await checkDiskSpaceForExtraction(options.targetDir, candidates);
|
||
}
|
||
|
||
if (candidates.length === 0) {
|
||
if (!options.onlyArchives) {
|
||
const existingResume = await readExtractResumeState(options.packageDir, options.packageId);
|
||
if (existingResume.size > 0 && await hasAnyEntries(options.targetDir)) {
|
||
await clearExtractResumeState(options.packageDir, options.packageId);
|
||
logger.info(`Entpacken übersprungen (Archive bereinigt, Ziel hat Dateien): ${options.packageDir}`);
|
||
options.onProgress?.({
|
||
current: existingResume.size,
|
||
total: existingResume.size,
|
||
percent: 100,
|
||
archiveName: "",
|
||
phase: "done"
|
||
});
|
||
return { extracted: existingResume.size, failed: 0, lastError: "" };
|
||
}
|
||
await clearExtractResumeState(options.packageDir, options.packageId);
|
||
}
|
||
logger.info(`Entpacken übersprungen (keine Archive gefunden): ${options.packageDir}`);
|
||
return { extracted: 0, failed: 0, lastError: "" };
|
||
}
|
||
|
||
const conflictMode = effectiveConflictMode(options.conflictMode);
|
||
if (options.conflictMode === "ask") {
|
||
logger.warn("Extract-ConflictMode 'ask' wird ohne Prompt als 'skip' behandelt");
|
||
}
|
||
const passwordCacheKey = packagePasswordCacheKey(options.packageDir, options.packageId);
|
||
const passwordCacheLabel = packagePasswordCacheLabel(options.packageDir, options.packageId);
|
||
let passwordCandidates = archivePasswords(options.passwordList || "");
|
||
const cachedPackagePassword = readCachedPackagePassword(passwordCacheKey);
|
||
if (cachedPackagePassword) {
|
||
passwordCandidates = prioritizePassword(passwordCandidates, cachedPackagePassword);
|
||
logger.info(`Passwort-Cache Treffer: ${passwordCacheLabel}, bekanntes Passwort wird zuerst getestet`);
|
||
options.onLog?.("INFO", `Passwort-Cache Treffer: ${passwordCacheLabel}, bekanntes Passwort wird zuerst getestet`);
|
||
}
|
||
const resumeCompleted = await readExtractResumeState(options.packageDir, options.packageId);
|
||
const resumeCompletedAtStart = resumeCompleted.size;
|
||
const allCandidateNames = new Set(allCandidates.map((archivePath) => archiveNameKey(path.basename(archivePath))));
|
||
for (const archiveName of Array.from(resumeCompleted.values())) {
|
||
if (!allCandidateNames.has(archiveName)) {
|
||
resumeCompleted.delete(archiveName);
|
||
}
|
||
}
|
||
if (resumeCompleted.size > 0) {
|
||
await writeExtractResumeState(options.packageDir, resumeCompleted, options.packageId);
|
||
} else {
|
||
await clearExtractResumeState(options.packageDir, options.packageId);
|
||
}
|
||
|
||
const pendingCandidates = candidates.filter((archivePath) => !resumeCompleted.has(archiveNameKey(path.basename(archivePath))));
|
||
let extracted = candidates.length - pendingCandidates.length;
|
||
let failed = 0;
|
||
let lastError = "";
|
||
let learnedPassword = cachedPackagePassword;
|
||
let packageNeedsFlatMode = false;
|
||
const extractedArchives = new Set<string>();
|
||
const failedArchiveCategories = new Map<string, ExtractErrorCategory>();
|
||
for (const archivePath of candidates) {
|
||
if (resumeCompleted.has(archiveNameKey(path.basename(archivePath)))) {
|
||
extractedArchives.add(archivePath);
|
||
}
|
||
}
|
||
|
||
const rememberLearnedPassword = (password: string): void => {
|
||
const normalized = String(password || "").trim();
|
||
if (!normalized) {
|
||
return;
|
||
}
|
||
const changed = normalized !== learnedPassword;
|
||
learnedPassword = normalized;
|
||
passwordCandidates = prioritizePassword(passwordCandidates, normalized);
|
||
writeCachedPackagePassword(passwordCacheKey, normalized);
|
||
if (changed) {
|
||
logger.info(`Passwort-Cache Update: ${passwordCacheLabel}, neues Passwort gelernt`);
|
||
options.onLog?.("INFO", `Passwort-Cache Update: ${passwordCacheLabel}, neues Passwort gelernt`);
|
||
}
|
||
};
|
||
|
||
const emitProgress = (
|
||
current: number,
|
||
archiveName: string,
|
||
phase: "extracting" | "done",
|
||
archivePercent?: number,
|
||
elapsedMs?: number,
|
||
pwInfo?: { passwordAttempt?: number; passwordTotal?: number; passwordFound?: boolean },
|
||
archiveInfo?: { archiveDone?: boolean; archiveSuccess?: boolean }
|
||
): void => {
|
||
if (!options.onProgress) {
|
||
return;
|
||
}
|
||
const total = Math.max(1, candidates.length);
|
||
let percent = Math.max(0, Math.min(100, Math.floor((current / total) * 100)));
|
||
let normalizedArchivePercent = Math.max(0, Math.min(100, Number(archivePercent ?? 0)));
|
||
if (phase !== "done") {
|
||
const boundedCurrent = Math.max(0, Math.min(total, current));
|
||
if (archiveInfo?.archiveDone !== true && normalizedArchivePercent >= 100) {
|
||
normalizedArchivePercent = 99;
|
||
}
|
||
percent = Math.max(0, Math.min(100, Math.floor(((boundedCurrent + (normalizedArchivePercent / 100)) / total) * 100)));
|
||
}
|
||
try {
|
||
options.onProgress({
|
||
current,
|
||
total,
|
||
percent,
|
||
archiveName,
|
||
archivePercent: normalizedArchivePercent,
|
||
elapsedMs,
|
||
phase,
|
||
...(archiveInfo || {}),
|
||
...(pwInfo || {})
|
||
});
|
||
} catch (error) {
|
||
logger.warn(`onProgress callback Fehler unterdrückt: ${cleanErrorText(String(error))}`);
|
||
}
|
||
};
|
||
|
||
emitProgress(extracted, "", "extracting");
|
||
|
||
// Emit "done" progress for archives already completed via resume state
|
||
// so the caller's onProgress handler can mark their items as "Done" immediately
|
||
// rather than leaving them as "Entpacken - Ausstehend" until all extraction finishes.
|
||
for (const archivePath of candidates) {
|
||
if (resumeCompleted.has(archiveNameKey(path.basename(archivePath)))) {
|
||
emitProgress(extracted, path.basename(archivePath), "extracting", 100, 0, undefined, { archiveDone: true, archiveSuccess: true });
|
||
}
|
||
}
|
||
|
||
const maxParallel = Math.max(1, options.maxParallel || 1);
|
||
let noExtractorEncountered = false;
|
||
let lastArchiveFinishedAt: number | null = null;
|
||
|
||
const extractSingleArchive = async (archivePath: string): Promise<void> => {
|
||
if (options.signal?.aborted) {
|
||
throw new Error("aborted:extract");
|
||
}
|
||
if (noExtractorEncountered) {
|
||
throw new Error("noextractor:skipped");
|
||
}
|
||
const archiveName = path.basename(archivePath);
|
||
const archiveResumeKey = archiveNameKey(archiveName);
|
||
const archiveStartedAt = Date.now();
|
||
const startedCurrent = extracted + failed;
|
||
if (lastArchiveFinishedAt !== null) {
|
||
logger.info(`Extract-Trace Gap: before=${archiveName}, prevDoneToStartMs=${archiveStartedAt - lastArchiveFinishedAt}, progress=${startedCurrent}/${candidates.length}`);
|
||
}
|
||
let archivePercent = 0;
|
||
let reached99At: number | null = null;
|
||
let archiveOutcome: "success" | "failed" | "skipped" = "failed";
|
||
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, 0);
|
||
const pulseTimer = setInterval(() => {
|
||
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt);
|
||
}, 1100);
|
||
const hybrid = Boolean(options.hybridMode);
|
||
// Before the first successful extraction, filename-derived candidates are useful.
|
||
// After a known password is learned, try that first to avoid per-archive delays.
|
||
const filenamePasswords = archiveFilenamePasswords(archiveName);
|
||
const nonEmptyBasePasswords = passwordCandidates.filter((p) => p !== "");
|
||
const orderedNonEmpty = learnedPassword
|
||
? [learnedPassword, ...nonEmptyBasePasswords.filter((p) => p !== learnedPassword), ...filenamePasswords]
|
||
: [...filenamePasswords, ...nonEmptyBasePasswords];
|
||
const archivePasswordCandidates = learnedPassword
|
||
? Array.from(new Set([...orderedNonEmpty, ""]))
|
||
: Array.from(new Set(["", ...orderedNonEmpty]));
|
||
const reportArchiveProgress = (value: number): void => {
|
||
archivePercent = nextArchivePercent(archivePercent, value);
|
||
if (reached99At === null && archivePercent >= 99) {
|
||
reached99At = Date.now();
|
||
logger.info(`Extract-Trace 99%: archive=${archiveName}, elapsedMs=${reached99At - archiveStartedAt}`);
|
||
}
|
||
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt);
|
||
};
|
||
|
||
// Validate generic .001 splits via file signature before attempting extraction
|
||
const isGenericSplit = /\.\d{3}$/i.test(archiveName) && !/\.(zip|7z)\.\d{3}$/i.test(archiveName);
|
||
if (isGenericSplit) {
|
||
const sig = await detectArchiveSignature(archivePath);
|
||
if (!sig) {
|
||
logger.info(`Generische Split-Datei übersprungen (keine Archiv-Signatur): ${archiveName}`);
|
||
extracted += 1;
|
||
resumeCompleted.add(archiveResumeKey);
|
||
extractedArchives.add(archivePath);
|
||
await writeExtractResumeState(options.packageDir, resumeCompleted, options.packageId);
|
||
clearInterval(pulseTimer);
|
||
archiveOutcome = "skipped";
|
||
const skippedAt = Date.now();
|
||
lastArchiveFinishedAt = skippedAt;
|
||
logger.info(`Extract-Trace Archiv Übersprungen: archive=${archiveName}, ms=${skippedAt - archiveStartedAt}, reason=no-signature`);
|
||
return;
|
||
}
|
||
logger.info(`Generische Split-Datei verifiziert (Signatur: ${sig}): ${archiveName}`);
|
||
}
|
||
|
||
logger.info(`Entpacke Archiv: ${path.basename(archivePath)} -> ${options.targetDir}${hybrid ? " (hybrid, reduced threads, low I/O)" : ""}`);
|
||
options.onLog?.("INFO", `Entpacke Archiv: ${path.basename(archivePath)} -> ${options.targetDir}${hybrid ? " (hybrid, reduced threads, low I/O)" : ""}`);
|
||
const emptyArchivePasswordCount = archivePasswordCandidates.filter((candidate) => candidate === "").length;
|
||
options.onLog?.("INFO", `Archiv-Passwortliste: archive=${archiveName}, passwordCount=${archivePasswordCandidates.length}, redacted=true, emptyCandidates=${emptyArchivePasswordCount}`);
|
||
const hasManyPasswords = archivePasswordCandidates.length > 1;
|
||
if (hasManyPasswords) {
|
||
emitProgress(extracted + failed, archiveName, "extracting", 0, 0, { passwordAttempt: 0, passwordTotal: archivePasswordCandidates.length });
|
||
}
|
||
const onPwAttempt = hasManyPasswords
|
||
? (attempt: number, total: number) => {
|
||
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt, { passwordAttempt: attempt, passwordTotal: total });
|
||
options.onLog?.("INFO", `Passwort-Versuch ${attempt}/${total}: archive=${archiveName}, password=<redacted>`);
|
||
}
|
||
: undefined;
|
||
try {
|
||
// Set module-level priority before each extract call (race-safe: spawn is synchronous)
|
||
currentExtractCpuPriority = options.extractCpuPriority;
|
||
const ext = path.extname(archivePath).toLowerCase();
|
||
if (ext === ".zip") {
|
||
const preferExternal = await shouldPreferExternalZip(archivePath);
|
||
if (preferExternal) {
|
||
try {
|
||
const usedPassword = await runExternalExtract(archivePath, options.targetDir, options.conflictMode, archivePasswordCandidates, (value) => {
|
||
reportArchiveProgress(value);
|
||
}, options.signal, hybrid, onPwAttempt, false, undefined, options.onLog);
|
||
rememberLearnedPassword(usedPassword);
|
||
} catch (error) {
|
||
if (isNoExtractorError(String(error))) {
|
||
await extractZipArchive(archivePath, options.targetDir, options.conflictMode, options.signal);
|
||
} else {
|
||
throw error;
|
||
}
|
||
}
|
||
} else {
|
||
try {
|
||
await extractZipArchive(archivePath, options.targetDir, options.conflictMode, options.signal);
|
||
archivePercent = 100;
|
||
} catch (error) {
|
||
if (!shouldFallbackToExternalZip(error)) {
|
||
throw error;
|
||
}
|
||
try {
|
||
const usedPassword = await runExternalExtract(archivePath, options.targetDir, options.conflictMode, archivePasswordCandidates, (value) => {
|
||
reportArchiveProgress(value);
|
||
}, options.signal, hybrid, onPwAttempt, false, undefined, options.onLog);
|
||
rememberLearnedPassword(usedPassword);
|
||
} catch (externalError) {
|
||
if (isNoExtractorError(String(externalError)) || isUnsupportedArchiveFormatError(String(externalError))) {
|
||
throw error;
|
||
}
|
||
throw externalError;
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
const flatResult = { needed: false };
|
||
const usedPassword = await runExternalExtract(archivePath, options.targetDir, options.conflictMode, archivePasswordCandidates, (value) => {
|
||
reportArchiveProgress(value);
|
||
}, options.signal, hybrid, onPwAttempt, packageNeedsFlatMode, flatResult, options.onLog);
|
||
rememberLearnedPassword(usedPassword);
|
||
if (flatResult.needed) packageNeedsFlatMode = true;
|
||
}
|
||
extracted += 1;
|
||
extractedArchives.add(archivePath);
|
||
failedArchiveCategories.delete(archivePath);
|
||
resumeCompleted.add(archiveResumeKey);
|
||
await writeExtractResumeState(options.packageDir, resumeCompleted, options.packageId);
|
||
logger.info(`Entpacken erfolgreich: ${path.basename(archivePath)}`);
|
||
options.onLog?.("INFO", `Entpacken erfolgreich: ${path.basename(archivePath)}`);
|
||
archiveOutcome = "success";
|
||
const successAt = Date.now();
|
||
const tailAfter99Ms = reached99At ? (successAt - reached99At) : -1;
|
||
logger.info(`Extract-Trace Archiv Erfolg: archive=${archiveName}, totalMs=${successAt - archiveStartedAt}, tailAfter99Ms=${tailAfter99Ms >= 0 ? tailAfter99Ms : "n/a"}, pwCandidates=${archivePasswordCandidates.length}`);
|
||
lastArchiveFinishedAt = successAt;
|
||
archivePercent = 100;
|
||
if (hasManyPasswords) {
|
||
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt, { passwordFound: true }, { archiveDone: true, archiveSuccess: true });
|
||
} else {
|
||
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt, undefined, { archiveDone: true, archiveSuccess: true });
|
||
}
|
||
} catch (error) {
|
||
const errorText = String(error);
|
||
if (isExtractAbortError(errorText)) {
|
||
throw error;
|
||
}
|
||
failed += 1;
|
||
lastError = errorText;
|
||
const errorCategory = classifyExtractionError(errorText);
|
||
failedArchiveCategories.set(archivePath, errorCategory);
|
||
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}`);
|
||
options.onLog?.("ERROR", `Entpack-Fehler ${path.basename(archivePath)} [${errorCategory}]: ${errorText}`);
|
||
if (errorCategory === "wrong_password" && learnedPassword) {
|
||
learnedPassword = "";
|
||
clearCachedPackagePassword(passwordCacheKey);
|
||
logger.warn(`Passwort-Cache verworfen: ${passwordCacheLabel} (wrong_password)`);
|
||
options.onLog?.("WARN", `Passwort-Cache verworfen: ${passwordCacheLabel} (wrong_password)`);
|
||
}
|
||
const failedAt = Date.now();
|
||
const tailAfter99Ms = reached99At ? (failedAt - reached99At) : -1;
|
||
logger.warn(`Extract-Trace Archiv Fehler: archive=${archiveName}, totalMs=${failedAt - archiveStartedAt}, tailAfter99Ms=${tailAfter99Ms >= 0 ? tailAfter99Ms : "n/a"}, category=${errorCategory}`);
|
||
lastArchiveFinishedAt = failedAt;
|
||
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt, undefined, { archiveDone: true, archiveSuccess: false });
|
||
if (isNoExtractorError(errorText)) {
|
||
noExtractorEncountered = true;
|
||
}
|
||
} finally {
|
||
clearInterval(pulseTimer);
|
||
if (lastArchiveFinishedAt === null || lastArchiveFinishedAt < archiveStartedAt) {
|
||
lastArchiveFinishedAt = Date.now();
|
||
}
|
||
logger.info(`Extract-Trace Archiv Ende: archive=${archiveName}, outcome=${archiveOutcome}, elapsedMs=${lastArchiveFinishedAt - archiveStartedAt}, percent=${archivePercent}`);
|
||
}
|
||
};
|
||
|
||
if (maxParallel <= 1) {
|
||
for (const archivePath of pendingCandidates) {
|
||
if (options.signal?.aborted || noExtractorEncountered) break;
|
||
await extractSingleArchive(archivePath);
|
||
}
|
||
// Count remaining archives as failed when no extractor was found
|
||
if (noExtractorEncountered) {
|
||
const remaining = candidates.length - (extracted + failed);
|
||
if (remaining > 0) {
|
||
failed += remaining;
|
||
emitProgress(candidates.length, "", "extracting", 0, 0);
|
||
}
|
||
}
|
||
} else {
|
||
// Password discovery: extract first archive serially to find the correct password,
|
||
// then run remaining archives in parallel with the promoted password order.
|
||
let parallelQueue = pendingCandidates;
|
||
if (passwordCandidates.length > 1 && pendingCandidates.length > 1) {
|
||
logger.info(`Passwort-Discovery: Extrahiere erstes Archiv seriell (${passwordCandidates.length} Passwort-Kandidaten)...`);
|
||
options.onLog?.("INFO", `Passwort-Discovery: Extrahiere erstes Archiv seriell (${passwordCandidates.length} Passwort-Kandidaten)...`);
|
||
const first = pendingCandidates[0];
|
||
try {
|
||
await extractSingleArchive(first);
|
||
} catch (err) {
|
||
const errText = String(err);
|
||
if (/aborted:extract/i.test(errText)) throw err;
|
||
// noextractor:skipped — handled by noExtractorEncountered flag below
|
||
}
|
||
parallelQueue = pendingCandidates.slice(1);
|
||
if (parallelQueue.length > 0) {
|
||
logger.info(`Passwort-Discovery abgeschlossen, starte parallele Extraktion für ${parallelQueue.length} verbleibende Archive`);
|
||
}
|
||
}
|
||
|
||
if (parallelQueue.length > 0 && !options.signal?.aborted && !noExtractorEncountered) {
|
||
// Parallel extraction pool: N workers pull from a shared queue
|
||
const queue = [...parallelQueue];
|
||
let nextIdx = 0;
|
||
let abortError: Error | null = null;
|
||
|
||
const worker = async (): Promise<void> => {
|
||
while (nextIdx < queue.length && !abortError && !noExtractorEncountered) {
|
||
if (options.signal?.aborted) break;
|
||
const idx = nextIdx;
|
||
nextIdx += 1;
|
||
try {
|
||
await extractSingleArchive(queue[idx]);
|
||
} catch (error) {
|
||
const errText = String(error);
|
||
if (errText.includes("noextractor:skipped")) {
|
||
break; // handled by noExtractorEncountered flag after the pool
|
||
}
|
||
if (isExtractAbortError(errText)) {
|
||
abortError = error instanceof Error ? error : new Error(errText);
|
||
break;
|
||
}
|
||
// Non-abort errors are already handled inside extractSingleArchive
|
||
}
|
||
}
|
||
};
|
||
|
||
const workerCount = Math.min(maxParallel, parallelQueue.length);
|
||
logger.info(`Parallele Extraktion: ${workerCount} gleichzeitige Worker für ${parallelQueue.length} Archive`);
|
||
// Snapshot passwordCandidates before parallel extraction to avoid concurrent mutation.
|
||
// Each worker reads the same promoted order from the serial password-discovery pass.
|
||
const frozenPasswords = [...passwordCandidates];
|
||
await Promise.all(Array.from({ length: workerCount }, () => worker()));
|
||
// Restore passwordCandidates from frozen snapshot (parallel mutations are discarded).
|
||
passwordCandidates = frozenPasswords;
|
||
|
||
if (abortError) throw new Error("aborted:extract");
|
||
|
||
if (failed > 0 && extracted === 0) {
|
||
const failedArchives = parallelQueue.filter((ap) => !extractedArchives.has(ap) && !resumeCompleted.has(archiveNameKey(path.basename(ap))));
|
||
const failedCategories = failedArchives.map((archivePath) => failedArchiveCategories.get(archivePath) || "unknown");
|
||
if (failedArchives.length > 0 && shouldSerialRetryParallelFailures(extracted, failedCategories)) {
|
||
const categorySummary = [...new Set(failedCategories)].join(",");
|
||
logger.info(
|
||
`Serielle Wiederholung nach Parallel-Fehlstart: ${failedArchives.length} Archive werden einzeln wiederholt ` +
|
||
`(categories=${categorySummary || "unknown"})`
|
||
);
|
||
let retryRecovered = 0;
|
||
for (const archivePath of failedArchives) {
|
||
if (options.signal?.aborted || noExtractorEncountered) break;
|
||
try {
|
||
failed -= 1;
|
||
await extractSingleArchive(archivePath);
|
||
retryRecovered += 1;
|
||
} catch (retryError) {
|
||
const errText = String(retryError);
|
||
if (isExtractAbortError(errText)) throw retryError;
|
||
}
|
||
}
|
||
if (retryRecovered > 0) {
|
||
logger.info(`Serielle Wiederholung nach Parallel-Fehlstart: ${retryRecovered}/${failedArchives.length} Archive erfolgreich entpackt`);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Retry failed wrong_password archives serially ──
|
||
// Parallel UnRAR processes writing to the same target directory can cause
|
||
// CRC mismatches that are misreported as "Incorrect password".
|
||
// If any archive succeeded (i.e. the password is known), retry the failed
|
||
// ones one-at-a-time to eliminate false positives from I/O contention.
|
||
if (failed > 0 && extracted > 0) {
|
||
const failedArchives = parallelQueue.filter((ap) => !extractedArchives.has(ap) && !resumeCompleted.has(archiveNameKey(path.basename(ap))));
|
||
if (failedArchives.length > 0) {
|
||
logger.info(`Serielle Wiederholung: ${failedArchives.length} fehlgeschlagene Archive werden einzeln wiederholt (mögliche Parallelitäts-Kollision)`);
|
||
let retryRecovered = 0;
|
||
for (const archivePath of failedArchives) {
|
||
if (options.signal?.aborted || noExtractorEncountered) break;
|
||
try {
|
||
// Reset failed count for this archive before retry
|
||
failed -= 1;
|
||
await extractSingleArchive(archivePath);
|
||
retryRecovered += 1;
|
||
} catch (retryError) {
|
||
const errText = String(retryError);
|
||
if (isExtractAbortError(errText)) throw retryError;
|
||
// extractSingleArchive already incremented failed and logged the error
|
||
}
|
||
}
|
||
if (retryRecovered > 0) {
|
||
logger.info(`Serielle Wiederholung: ${retryRecovered}/${failedArchives.length} Archive erfolgreich entpackt`);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (noExtractorEncountered) {
|
||
const remaining = candidates.length - (extracted + failed);
|
||
if (remaining > 0) {
|
||
failed += remaining;
|
||
emitProgress(candidates.length, "", "extracting", 0, 0);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Nested extraction: extract archives found inside the output (1 level) ──
|
||
if (extracted > 0 && failed === 0 && !options.skipPostCleanup && !options.onlyArchives) {
|
||
try {
|
||
const nestedCandidates = (await findArchiveCandidates(options.targetDir))
|
||
.filter((p) => !NESTED_EXTRACT_BLACKLIST_RE.test(p));
|
||
if (nestedCandidates.length > 0) {
|
||
logger.info(`Nested-Extraction: ${nestedCandidates.length} Archive im Output gefunden`);
|
||
let nestedExtracted = 0;
|
||
let nestedFailed = 0;
|
||
try {
|
||
await checkDiskSpaceForExtraction(options.targetDir, nestedCandidates);
|
||
} catch (spaceError) {
|
||
logger.warn(`Nested-Extraction Disk-Space-Check fehlgeschlagen: ${String(spaceError)}`);
|
||
nestedCandidates.length = 0;
|
||
}
|
||
for (const nestedArchive of nestedCandidates) {
|
||
if (options.signal?.aborted) throw new Error("aborted:extract");
|
||
const nestedName = path.basename(nestedArchive);
|
||
const nestedKey = archiveNameKey(`nested:${nestedName}`);
|
||
if (resumeCompleted.has(nestedKey)) {
|
||
logger.info(`Nested-Extraction übersprungen (bereits entpackt): ${nestedName}`);
|
||
continue;
|
||
}
|
||
const nestedStartedAt = Date.now();
|
||
let nestedPercent = 0;
|
||
emitProgress(extracted + failed, `nested: ${nestedName}`, "extracting", nestedPercent, 0);
|
||
const nestedPulse = setInterval(() => {
|
||
emitProgress(extracted + failed, `nested: ${nestedName}`, "extracting", nestedPercent, Date.now() - nestedStartedAt);
|
||
}, 1100);
|
||
const hybrid = Boolean(options.hybridMode);
|
||
logger.info(`Nested-Entpacke: ${nestedName} -> ${options.targetDir}${hybrid ? " (hybrid)" : ""}`);
|
||
try {
|
||
const ext = path.extname(nestedArchive).toLowerCase();
|
||
if (ext === ".zip" && !(await shouldPreferExternalZip(nestedArchive))) {
|
||
try {
|
||
await extractZipArchive(nestedArchive, options.targetDir, options.conflictMode, options.signal);
|
||
nestedPercent = 100;
|
||
} catch (zipErr) {
|
||
if (!shouldFallbackToExternalZip(zipErr)) throw zipErr;
|
||
const usedPw = await runExternalExtract(nestedArchive, options.targetDir, options.conflictMode, passwordCandidates, (v) => { nestedPercent = Math.max(nestedPercent, v); }, options.signal, hybrid, undefined, false, undefined, options.onLog);
|
||
rememberLearnedPassword(usedPw);
|
||
}
|
||
} else {
|
||
const usedPw = await runExternalExtract(nestedArchive, options.targetDir, options.conflictMode, passwordCandidates, (v) => { nestedPercent = Math.max(nestedPercent, v); }, options.signal, hybrid, undefined, false, undefined, options.onLog);
|
||
rememberLearnedPassword(usedPw);
|
||
}
|
||
extracted += 1;
|
||
nestedExtracted += 1;
|
||
extractedArchives.add(nestedArchive);
|
||
resumeCompleted.add(nestedKey);
|
||
await writeExtractResumeState(options.packageDir, resumeCompleted, options.packageId);
|
||
logger.info(`Nested-Entpacken erfolgreich: ${nestedName}`);
|
||
if (options.cleanupMode !== "none") {
|
||
await cleanupArchives([nestedArchive], options.cleanupMode);
|
||
}
|
||
} catch (nestedErr) {
|
||
const errText = String(nestedErr);
|
||
if (isExtractAbortError(errText)) throw new Error("aborted:extract");
|
||
if (isNoExtractorError(errText)) {
|
||
logger.warn(`Nested-Extraction: Kein Extractor, überspringe restliche`);
|
||
break;
|
||
}
|
||
failed += 1;
|
||
nestedFailed += 1;
|
||
lastError = errText;
|
||
const nestedCategory = classifyExtractionError(errText);
|
||
logger.error(`Nested-Entpack-Fehler ${nestedName} [${nestedCategory}]: ${errText}`);
|
||
} finally {
|
||
clearInterval(nestedPulse);
|
||
}
|
||
}
|
||
logger.info(`Nested-Extraction abgeschlossen: ${nestedExtracted} entpackt, ${nestedFailed} fehlgeschlagen von ${nestedCandidates.length} Kandidaten`);
|
||
}
|
||
} catch (nestedError) {
|
||
const errText = String(nestedError);
|
||
if (isExtractAbortError(errText)) throw new Error("aborted:extract");
|
||
logger.warn(`Nested-Extraction Fehler: ${cleanErrorText(errText)}`);
|
||
}
|
||
}
|
||
|
||
if (extracted > 0) {
|
||
const hasOutputAfter = await hasAnyFilesRecursive(options.targetDir);
|
||
const hadResumeProgress = resumeCompletedAtStart > 0;
|
||
if (!hasOutputAfter && conflictMode !== "skip" && !hadResumeProgress) {
|
||
lastError = "Keine entpackten Dateien erkannt";
|
||
failed += extracted;
|
||
extracted = 0;
|
||
logger.error(`Entpacken ohne neue Ausgabe erkannt: ${options.targetDir}. Cleanup wird NICHT ausgeführt.`);
|
||
} else {
|
||
if (!options.skipPostCleanup) {
|
||
const cleanupSources = failed === 0 ? candidates : Array.from(extractedArchives.values());
|
||
const sourceAndTargetEqual = pathSetKey(path.resolve(options.packageDir)) === pathSetKey(path.resolve(options.targetDir));
|
||
const removedArchives = sourceAndTargetEqual
|
||
? 0
|
||
: await cleanupArchives(cleanupSources, options.cleanupMode);
|
||
if (sourceAndTargetEqual && options.cleanupMode !== "none") {
|
||
logger.warn(`Archive-Cleanup übersprungen (Quelle=Ziel): ${options.packageDir}`);
|
||
}
|
||
if (options.cleanupMode !== "none") {
|
||
logger.info(`Archive-Cleanup abgeschlossen: ${removedArchives} Datei(en) entfernt`);
|
||
}
|
||
if (options.removeLinks) {
|
||
const removedLinks = await removeDownloadLinkArtifacts(options.targetDir);
|
||
logger.info(`Link-Artefakt-Cleanup: ${removedLinks} Datei(en) entfernt`);
|
||
}
|
||
if (options.removeSamples) {
|
||
const removedSamples = await removeSampleArtifacts(options.targetDir);
|
||
logger.info(`Sample-Cleanup: ${removedSamples.files} Datei(en), ${removedSamples.dirs} Ordner entfernt`);
|
||
}
|
||
}
|
||
|
||
if (failed === 0 && resumeCompleted.size >= allCandidates.length && !options.skipPostCleanup) {
|
||
await clearExtractResumeState(options.packageDir, options.packageId);
|
||
}
|
||
|
||
if (!options.skipPostCleanup && options.cleanupMode === "delete" && !(await hasAnyFilesRecursive(options.packageDir))) {
|
||
const removedDirs = await removeEmptyDirectoryTree(options.packageDir);
|
||
if (removedDirs > 0) {
|
||
logger.info(`Leere Download-Ordner entfernt: ${removedDirs} (root=${options.packageDir})`);
|
||
}
|
||
}
|
||
}
|
||
} else if (!options.skipPostCleanup) {
|
||
try {
|
||
const targetExists = await fs.promises.access(options.targetDir).then(() => true, () => false);
|
||
if (targetExists && (await fs.promises.readdir(options.targetDir)).length === 0) {
|
||
await fs.promises.rm(options.targetDir, { recursive: true, force: true });
|
||
}
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}
|
||
|
||
if (failed > 0) {
|
||
if (resumeCompleted.size > 0) {
|
||
await writeExtractResumeState(options.packageDir, resumeCompleted, options.packageId);
|
||
} else {
|
||
await clearExtractResumeState(options.packageDir, options.packageId);
|
||
}
|
||
}
|
||
|
||
emitProgress(extracted, "", "done");
|
||
|
||
logger.info(`Entpacken beendet: extracted=${extracted}, failed=${failed}, targetDir=${options.targetDir}`);
|
||
|
||
return { extracted, failed, lastError };
|
||
}
|