Der Resume-Prune validiert Eintraege gegen die Top-Level-Archiv-Kandidaten auf der Platte. Nested-Archiv-Schluessel (nested:<name>) haben dort kein Gegenstueck, also wurden sie bei JEDEM extractPackageArchives-Aufruf geloescht — verschachtelte Archive wurden beim Resume erneut entpackt. nested:-Schluessel werden im Prune jetzt uebersprungen (sie werden mit dem Rest geleert, wenn das Paket fertig ist).
3429 lines
130 KiB
TypeScript
3429 lines
130 KiB
TypeScript
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;
|
|
|
|
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;
|
|
}
|
|
|
|
const activeSubstDrives = new Set<string>();
|
|
|
|
function findFreeSubstDrive(): string | null {
|
|
if (process.platform !== "win32") return null;
|
|
for (let code = 90; code >= 71; code--) {
|
|
const letter = String.fromCharCode(code);
|
|
if (activeSubstDrives.has(letter)) continue;
|
|
try {
|
|
fs.accessSync(`${letter}:\\`);
|
|
} 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 {
|
|
}
|
|
}
|
|
|
|
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));
|
|
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;
|
|
}
|
|
|
|
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));
|
|
}
|
|
}
|
|
};
|
|
|
|
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);
|
|
}
|
|
|
|
if (/\.(?:tar\.(?:gz|bz2|xz)|tgz|tbz2|txz)$/i.test(fileName)) {
|
|
return Array.from(targets);
|
|
}
|
|
|
|
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 {
|
|
}
|
|
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 {
|
|
}
|
|
}
|
|
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 {
|
|
}
|
|
}
|
|
return removed;
|
|
}
|
|
|
|
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 "";
|
|
}
|
|
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);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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");
|
|
}
|
|
|
|
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;
|
|
}
|
|
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 || ""));
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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 {
|
|
}
|
|
}
|
|
|
|
function killProcessTree(child: { pid?: number; kill: () => void }): void {
|
|
const pid = Number(child.pid || 0);
|
|
if (!Number.isFinite(pid) || pid <= 0) {
|
|
try {
|
|
child.kill();
|
|
} catch {
|
|
}
|
|
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 {
|
|
}
|
|
});
|
|
} catch {
|
|
try {
|
|
child.kill();
|
|
} catch {
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
try {
|
|
child.kill();
|
|
} catch {
|
|
}
|
|
}
|
|
|
|
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 ?? "?")}`
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|
|
|
|
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 { }
|
|
try { killProcessTree(daemonProcess); } catch { }
|
|
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
|
|
});
|
|
}
|
|
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);
|
|
}
|
|
|
|
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: ""
|
|
});
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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)}`);
|
|
}
|
|
|
|
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
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
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}`];
|
|
}
|
|
|
|
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 = "";
|
|
|
|
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;
|
|
}
|
|
|
|
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}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
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;
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
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 });
|
|
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);
|
|
}
|
|
}
|
|
|
|
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 { }
|
|
}
|
|
}
|
|
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 {
|
|
}
|
|
}
|
|
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;
|
|
}
|
|
}
|
|
|
|
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 () => {
|
|
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 {
|
|
}
|
|
}
|
|
|
|
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";
|
|
}
|
|
|
|
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}`);
|
|
|
|
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 { }
|
|
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())) {
|
|
// Nested-archive progress (keyed "nested:<name>") has no top-level candidate on
|
|
// disk to validate against, so it must NOT be pruned here — otherwise every
|
|
// extractPackageArchives call wiped it and nested archives were re-extracted on
|
|
// resume. It is cleared together with the rest once the package fully completes.
|
|
if (archiveName.startsWith("nested:")) {
|
|
continue;
|
|
}
|
|
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");
|
|
|
|
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);
|
|
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);
|
|
};
|
|
|
|
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 {
|
|
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);
|
|
}
|
|
if (noExtractorEncountered) {
|
|
const remaining = candidates.length - (extracted + failed);
|
|
if (remaining > 0) {
|
|
failed += remaining;
|
|
emitProgress(candidates.length, "", "extracting", 0, 0);
|
|
}
|
|
}
|
|
} else {
|
|
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;
|
|
}
|
|
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) {
|
|
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;
|
|
}
|
|
if (isExtractAbortError(errText)) {
|
|
abortError = error instanceof Error ? error : new Error(errText);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
const workerCount = Math.min(maxParallel, parallelQueue.length);
|
|
logger.info(`Parallele Extraktion: ${workerCount} gleichzeitige Worker für ${parallelQueue.length} Archive`);
|
|
const frozenPasswords = [...passwordCandidates];
|
|
await Promise.all(Array.from({ length: workerCount }, () => worker()));
|
|
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`);
|
|
}
|
|
}
|
|
}
|
|
|
|
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 {
|
|
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: ${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);
|
|
}
|
|
}
|
|
}
|
|
|
|
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 {
|
|
}
|
|
}
|
|
|
|
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 };
|
|
}
|