Reduce extract lag and improve long-path auto-rename stability

This commit is contained in:
Sucukdeluxe 2026-03-01 03:47:18 +01:00
parent 6e50841387
commit 282c1ebf1d
2 changed files with 161 additions and 34 deletions

View File

@ -47,6 +47,8 @@ const DEFAULT_GLOBAL_STALL_WATCHDOG_TIMEOUT_MS = 90000;
const DEFAULT_POST_EXTRACT_TIMEOUT_MS = 4 * 60 * 60 * 1000; const DEFAULT_POST_EXTRACT_TIMEOUT_MS = 4 * 60 * 60 * 1000;
const EXTRACT_PROGRESS_EMIT_INTERVAL_MS = 260;
function getDownloadStallTimeoutMs(): number { function getDownloadStallTimeoutMs(): number {
const fromEnv = Number(process.env.RD_STALL_TIMEOUT_MS ?? NaN); const fromEnv = Number(process.env.RD_STALL_TIMEOUT_MS ?? NaN);
if (Number.isFinite(fromEnv) && fromEnv >= 2000 && fromEnv <= 600000) { if (Number.isFinite(fromEnv) && fromEnv >= 2000 && fromEnv <= 600000) {
@ -210,6 +212,23 @@ function isPathInsideDir(filePath: string, dirPath: string): boolean {
return file.startsWith(withSep); return file.startsWith(withSep);
} }
function toWindowsLongPathIfNeeded(filePath: string): string {
const absolute = path.resolve(String(filePath || ""));
if (process.platform !== "win32") {
return absolute;
}
if (!absolute || absolute.startsWith("\\\\?\\")) {
return absolute;
}
if (absolute.length < 248) {
return absolute;
}
if (absolute.startsWith("\\\\")) {
return `\\\\?\\UNC\\${absolute.slice(2)}`;
}
return `\\\\?\\${absolute}`;
}
const SCENE_RELEASE_FOLDER_RE = /-(?:4sf|4sj)$/i; const SCENE_RELEASE_FOLDER_RE = /-(?:4sf|4sj)$/i;
const SCENE_GROUP_SUFFIX_RE = /-(?=[A-Za-z0-9]{2,}$)(?=[A-Za-z0-9]*[A-Z])[A-Za-z0-9]+$/; const SCENE_GROUP_SUFFIX_RE = /-(?=[A-Za-z0-9]{2,}$)(?=[A-Za-z0-9]*[A-Z])[A-Za-z0-9]+$/;
const SCENE_EPISODE_RE = /(?:^|[._\-\s])s(\d{1,2})e(\d{1,3})(?:[._\-\s]|$)/i; const SCENE_EPISODE_RE = /(?:^|[._\-\s])s(\d{1,2})e(\d{1,3})(?:[._\-\s]|$)/i;
@ -1508,6 +1527,46 @@ export class DownloadManager extends EventEmitter {
return this.collectFilesByExtensions(rootDir, SAMPLE_VIDEO_EXTENSIONS); return this.collectFilesByExtensions(rootDir, SAMPLE_VIDEO_EXTENSIONS);
} }
private existsSyncSafe(filePath: string): boolean {
try {
return fs.existsSync(toWindowsLongPathIfNeeded(filePath));
} catch {
return false;
}
}
private renamePathWithExdevFallback(sourcePath: string, targetPath: string): void {
const sourceFsPath = toWindowsLongPathIfNeeded(sourcePath);
const targetFsPath = toWindowsLongPathIfNeeded(targetPath);
try {
fs.renameSync(sourceFsPath, targetFsPath);
return;
} catch (error) {
const code = error && typeof error === "object" && "code" in error
? String((error as NodeJS.ErrnoException).code || "")
: "";
if (code !== "EXDEV") {
throw error;
}
}
fs.copyFileSync(sourceFsPath, targetFsPath);
fs.rmSync(sourceFsPath, { force: true });
}
private isPathLengthRenameError(error: unknown): boolean {
const code = error && typeof error === "object" && "code" in error
? String((error as NodeJS.ErrnoException).code || "")
: "";
if (code === "ENAMETOOLONG") {
return true;
}
const text = String(error || "").toLowerCase();
return text.includes("path too long")
|| text.includes("name too long")
|| text.includes("filename or extension is too long");
}
private buildSafeAutoRenameTargetPath(sourcePath: string, targetBaseName: string, sourceExt: string): string | null { private buildSafeAutoRenameTargetPath(sourcePath: string, targetBaseName: string, sourceExt: string): string | null {
const dirPath = path.dirname(sourcePath); const dirPath = path.dirname(sourcePath);
const safeBaseName = sanitizeFilename(String(targetBaseName || "").trim()); const safeBaseName = sanitizeFilename(String(targetBaseName || "").trim());
@ -1526,11 +1585,7 @@ export class DownloadManager extends EventEmitter {
return null; return null;
} }
const maxWindowsPathLength = 259; return candidatePath;
if (candidatePath.length <= maxWindowsPathLength) {
return candidatePath;
}
return null;
} }
private buildShortPackageFallbackBaseName(folderCandidates: string[], sourceBaseName: string, targetBaseName: string): string | null { private buildShortPackageFallbackBaseName(folderCandidates: string[], sourceBaseName: string, targetBaseName: string): string | null {
@ -1658,15 +1713,43 @@ export class DownloadManager extends EventEmitter {
if (pathKey(targetPath) === pathKey(sourcePath)) { if (pathKey(targetPath) === pathKey(sourcePath)) {
continue; continue;
} }
if (fs.existsSync(targetPath)) { if (this.existsSyncSafe(targetPath)) {
logger.warn(`Auto-Rename übersprungen (Ziel existiert): ${targetPath}`); logger.warn(`Auto-Rename übersprungen (Ziel existiert): ${targetPath}`);
continue; continue;
} }
try { try {
fs.renameSync(sourcePath, targetPath); this.renamePathWithExdevFallback(sourcePath, targetPath);
renamed += 1; renamed += 1;
} catch (error) { } catch (error) {
if (this.isPathLengthRenameError(error)) {
const fallbackCandidates = [
this.buildShortPackageFallbackBaseName(folderCandidates, sourceBaseName, targetBaseName),
this.buildVeryShortPackageFallbackBaseName(folderCandidates, sourceBaseName, targetBaseName)
].filter((value): value is string => Boolean(value));
let fallbackRenamed = false;
for (const fallbackBaseName of fallbackCandidates) {
const fallbackPath = this.buildSafeAutoRenameTargetPath(sourcePath, fallbackBaseName, sourceExt);
if (!fallbackPath || pathKey(fallbackPath) === pathKey(sourcePath)) {
continue;
}
if (this.existsSyncSafe(fallbackPath)) {
continue;
}
try {
this.renamePathWithExdevFallback(sourcePath, fallbackPath);
logger.warn(`Auto-Rename Fallback wegen Pfadlänge: ${sourceName} -> ${path.basename(fallbackPath)}`);
renamed += 1;
fallbackRenamed = true;
break;
} catch {
// try next fallback candidate
}
}
if (fallbackRenamed) {
continue;
}
}
logger.warn(`Auto-Rename fehlgeschlagen (${sourceName}): ${compactErrorText(error)}`); logger.warn(`Auto-Rename fehlgeschlagen (${sourceName}): ${compactErrorText(error)}`);
} }
} }
@ -1678,20 +1761,7 @@ export class DownloadManager extends EventEmitter {
} }
private moveFileWithExdevFallback(sourcePath: string, targetPath: string): void { private moveFileWithExdevFallback(sourcePath: string, targetPath: string): void {
try { this.renamePathWithExdevFallback(sourcePath, targetPath);
fs.renameSync(sourcePath, targetPath);
return;
} catch (error) {
const code = error && typeof error === "object" && "code" in error
? String((error as NodeJS.ErrnoException).code || "")
: "";
if (code !== "EXDEV") {
throw error;
}
}
fs.copyFileSync(sourcePath, targetPath);
fs.rmSync(sourcePath, { force: true });
} }
private buildUniqueFlattenTargetPath(targetDir: string, sourcePath: string, reserved: Set<string>): string { private buildUniqueFlattenTargetPath(targetDir: string, sourcePath: string, reserved: Set<string>): string {
@ -4006,21 +4076,37 @@ export class DownloadManager extends EventEmitter {
); );
const updateExtractingStatus = (text: string): void => { const updateExtractingStatus = (text: string): void => {
const normalized = String(text || "");
if (hybridLastStatusText === normalized) {
return;
}
hybridLastStatusText = normalized;
const updatedAt = nowMs(); const updatedAt = nowMs();
for (const entry of hybridItems) { for (const entry of hybridItems) {
if (isExtractedLabel(entry.fullStatus)) { if (isExtractedLabel(entry.fullStatus)) {
continue; continue;
} }
if (entry.fullStatus === text) { if (entry.fullStatus === normalized) {
continue; continue;
} }
entry.fullStatus = text; entry.fullStatus = normalized;
entry.updatedAt = updatedAt; entry.updatedAt = updatedAt;
} }
}; };
updateExtractingStatus("Entpacken (hybrid) 0%"); let hybridLastStatusText = "";
this.emitState(); let hybridLastEmitAt = 0;
const emitHybridStatus = (text: string, force = false): void => {
updateExtractingStatus(text);
const now = nowMs();
if (!force && now - hybridLastEmitAt < EXTRACT_PROGRESS_EMIT_INTERVAL_MS) {
return;
}
hybridLastEmitAt = now;
this.emitState();
};
emitHybridStatus("Entpacken (hybrid) 0%", true);
try { try {
const result = await extractPackageArchives({ const result = await extractPackageArchives({
@ -4046,8 +4132,7 @@ export class DownloadManager extends EventEmitter {
const activeArchive = Number(progress.archivePercent ?? 0) > 0 ? 1 : 0; const activeArchive = Number(progress.archivePercent ?? 0) > 0 ? 1 : 0;
const currentDisplay = Math.max(0, Math.min(progress.total, progress.current + activeArchive)); const currentDisplay = Math.max(0, Math.min(progress.total, progress.current + activeArchive));
const label = `Entpacken (hybrid) ${progress.percent}% (${currentDisplay}/${progress.total})${archive}${elapsed}`; const label = `Entpacken (hybrid) ${progress.percent}% (${currentDisplay}/${progress.total})${archive}${elapsed}`;
updateExtractingStatus(label); emitHybridStatus(label);
this.emitState();
} }
}); });
@ -4123,21 +4208,37 @@ export class DownloadManager extends EventEmitter {
this.emitState(); this.emitState();
const updateExtractingStatus = (text: string): void => { const updateExtractingStatus = (text: string): void => {
const normalized = String(text || "");
if (lastExtractStatusText === normalized) {
return;
}
lastExtractStatusText = normalized;
const updatedAt = nowMs(); const updatedAt = nowMs();
for (const entry of completedItems) { for (const entry of completedItems) {
if (isExtractedLabel(entry.fullStatus)) { if (isExtractedLabel(entry.fullStatus)) {
continue; continue;
} }
if (entry.fullStatus === text) { if (entry.fullStatus === normalized) {
continue; continue;
} }
entry.fullStatus = text; entry.fullStatus = normalized;
entry.updatedAt = updatedAt; entry.updatedAt = updatedAt;
} }
}; };
updateExtractingStatus("Entpacken 0%"); let lastExtractStatusText = "";
this.emitState(); let lastExtractEmitAt = 0;
const emitExtractStatus = (text: string, force = false): void => {
updateExtractingStatus(text);
const now = nowMs();
if (!force && now - lastExtractEmitAt < EXTRACT_PROGRESS_EMIT_INTERVAL_MS) {
return;
}
lastExtractEmitAt = now;
this.emitState();
};
emitExtractStatus("Entpacken 0%", true);
const extractTimeoutMs = getPostExtractTimeoutMs(); const extractTimeoutMs = getPostExtractTimeoutMs();
const extractAbortController = new AbortController(); const extractAbortController = new AbortController();
@ -4188,8 +4289,7 @@ export class DownloadManager extends EventEmitter {
const currentDisplay = Math.max(0, Math.min(progress.total, progress.current + activeArchive)); const currentDisplay = Math.max(0, Math.min(progress.total, progress.current + activeArchive));
return `Entpacken ${progress.percent}% (${currentDisplay}/${progress.total})${archive}${elapsed}`; return `Entpacken ${progress.percent}% (${currentDisplay}/${progress.total})${archive}${elapsed}`;
})(); })();
updateExtractingStatus(label); emitExtractStatus(label);
this.emitState();
} }
}); });
logger.info(`Post-Processing Entpacken Ende: pkg=${pkg.name}, extracted=${result.extracted}, failed=${result.failed}, lastError=${result.lastError || ""}`); logger.info(`Post-Processing Entpacken Ende: pkg=${pkg.name}, extracted=${result.extracted}, failed=${result.failed}, lastError=${result.lastError || ""}`);

View File

@ -19,6 +19,7 @@ let resolveExtractorCommandInFlight: Promise<string> | null = null;
const EXTRACTOR_RETRY_AFTER_MS = 30_000; const EXTRACTOR_RETRY_AFTER_MS = 30_000;
const DEFAULT_ZIP_ENTRY_MEMORY_LIMIT_MB = 256; const DEFAULT_ZIP_ENTRY_MEMORY_LIMIT_MB = 256;
const EXTRACTOR_PROBE_TIMEOUT_MS = 8_000; const EXTRACTOR_PROBE_TIMEOUT_MS = 8_000;
const DEFAULT_EXTRACT_CPU_BUDGET_PERCENT = 80;
export interface ExtractOptions { export interface ExtractOptions {
packageDir: string; packageDir: string;
@ -365,16 +366,41 @@ function shouldUseExtractorPerformanceFlags(): boolean {
return raw !== "0" && raw !== "false" && raw !== "off" && raw !== "no"; return raw !== "0" && raw !== "false" && raw !== "off" && raw !== "no";
} }
function extractCpuBudgetPercent(): 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 DEFAULT_EXTRACT_CPU_BUDGET_PERCENT;
}
function extractorThreadSwitch(): string { function extractorThreadSwitch(): string {
const envValue = Number(process.env.RD_EXTRACT_THREADS ?? NaN); const envValue = Number(process.env.RD_EXTRACT_THREADS ?? NaN);
if (Number.isFinite(envValue) && envValue >= 1 && envValue <= 32) { if (Number.isFinite(envValue) && envValue >= 1 && envValue <= 32) {
return `-mt${Math.floor(envValue)}`; return `-mt${Math.floor(envValue)}`;
} }
const cpuCount = Math.max(1, os.cpus().length || 1); const cpuCount = Math.max(1, os.cpus().length || 1);
const threadCount = Math.max(1, Math.min(16, cpuCount)); const budgetPercent = extractCpuBudgetPercent();
const budgetedThreads = Math.floor((cpuCount * budgetPercent) / 100);
const threadCount = Math.max(1, Math.min(16, Math.max(1, budgetedThreads)));
return `-mt${threadCount}`; return `-mt${threadCount}`;
} }
function lowerExtractProcessPriority(childPid: number | undefined): void {
if (process.platform !== "win32") {
return;
}
const pid = Number(childPid || 0);
if (!Number.isFinite(pid) || pid <= 0) {
return;
}
try {
os.setPriority(pid, os.constants.priority.PRIORITY_BELOW_NORMAL);
} catch {
// ignore: priority lowering is best-effort
}
}
type ExtractSpawnResult = { type ExtractSpawnResult = {
ok: boolean; ok: boolean;
missingCommand: boolean; missingCommand: boolean;
@ -439,6 +465,7 @@ function runExtractCommand(
let settled = false; let settled = false;
let output = ""; let output = "";
const child = spawn(command, args, { windowsHide: true }); const child = spawn(command, args, { windowsHide: true });
lowerExtractProcessPriority(child.pid);
let timeoutId: NodeJS.Timeout | null = null; let timeoutId: NodeJS.Timeout | null = null;
let timedOutByWatchdog = false; let timedOutByWatchdog = false;
let abortedBySignal = false; let abortedBySignal = false;