diff --git a/package.json b/package.json index 98fb704..1eafa25 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "real-debrid-downloader", - "version": "1.5.37", + "version": "1.5.38", "description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)", "main": "build/main/main/main.js", "author": "Sucukdeluxe", diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 5b02cda..7411cf2 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -5128,6 +5128,7 @@ export class DownloadManager extends EventEmitter { onlyArchives: readyArchives, skipPostCleanup: true, packageId, + hybridMode: true, onProgress: (progress) => { if (progress.phase === "done") { return; diff --git a/src/main/extractor.ts b/src/main/extractor.ts index b913763..6ea574f 100644 --- a/src/main/extractor.ts +++ b/src/main/extractor.ts @@ -75,6 +75,7 @@ export interface ExtractOptions { onlyArchives?: Set; skipPostCleanup?: boolean; packageId?: string; + hybridMode?: boolean; } export interface ExtractProgressUpdate { @@ -93,6 +94,50 @@ 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)$/i; + +async function estimateArchivesTotalBytes(candidates: string[]): Promise { + let total = 0; + for (const archivePath of candidates) { + const parts = collectArchiveCleanupTargets(archivePath); + for (const part of parts) { + try { + total += (await fs.promises.stat(part)).size; + } catch { /* missing part, ignore */ } + } + } + return total; +} + +function humanSizeGB(bytes: number): string { + if (bytes >= 1024 * 1024 * 1024) { + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; + } + return `${(bytes / (1024 * 1024)).toFixed(0)} MB`; +} + +async function checkDiskSpaceForExtraction(targetDir: string, candidates: string[]): Promise { + 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); @@ -424,7 +469,10 @@ function extractCpuBudgetPercent(): number { return DEFAULT_EXTRACT_CPU_BUDGET_PERCENT; } -function extractorThreadSwitch(): string { +function extractorThreadSwitch(hybridMode = false): string { + if (hybridMode) { + return "-mt1"; + } const envValue = Number(process.env.RD_EXTRACT_THREADS ?? NaN); if (Number.isFinite(envValue) && envValue >= 1 && envValue <= 32) { return `-mt${Math.floor(envValue)}`; @@ -436,6 +484,24 @@ function extractorThreadSwitch(): string { return `-mt${threadCount}`; } +function setWindowsBackgroundIO(pid: number): void { + if (process.platform !== "win32") { + return; + } + // NtSetInformationProcess: set I/O priority to Very Low (0) and Page priority to Very Low (1). + // IDLE_PRIORITY_CLASS (set by os.setPriority) only lowers CPU scheduling priority; + // it does NOT lower I/O priority on modern Windows (Vista+). This call does. + const script = `$c=@'\nusing System;using System.Runtime.InteropServices;\npublic class P{[DllImport("ntdll.dll")]static extern int NtSetInformationProcess(IntPtr h,int c,ref int d,int s);[DllImport("kernel32.dll")]static extern IntPtr OpenProcess(int a,bool i,int p);[DllImport("kernel32.dll")]static extern bool CloseHandle(IntPtr h);public static void S(int pid){IntPtr h=OpenProcess(0x0600,false,pid);if(h==IntPtr.Zero)return;int v=0;NtSetInformationProcess(h,0x21,ref v,4);v=1;NtSetInformationProcess(h,0x27,ref v,4);CloseHandle(h);}}\n'@\nAdd-Type -TypeDefinition $c;[P]::S(${pid})`; + try { + spawn("powershell.exe", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script], { + windowsHide: true, + stdio: "ignore" + }).unref(); + } catch { + // best-effort: powershell may not be available + } +} + function lowerExtractProcessPriority(childPid: number | undefined): void { if (process.platform !== "win32") { return; @@ -445,12 +511,13 @@ function lowerExtractProcessPriority(childPid: number | undefined): void { return; } try { - // PRIORITY_LOW = IDLE_PRIORITY_CLASS on Windows, which also lowers I/O priority - // so extraction doesn't starve downloads or UI from disk bandwidth + // IDLE_PRIORITY_CLASS: lowers CPU scheduling priority only os.setPriority(pid, os.constants.priority.PRIORITY_LOW); } catch { // ignore: priority lowering is best-effort } + // Also lower I/O + page priority via Windows API (fire-and-forget) + setWindowsBackgroundIO(pid); } type ExtractSpawnResult = { @@ -635,7 +702,8 @@ export function buildExternalExtractArgs( targetDir: string, conflictMode: ConflictMode, password = "", - usePerformanceFlags = true + usePerformanceFlags = true, + hybridMode = false ): string[] { const mode = effectiveConflictMode(conflictMode); const lower = command.toLowerCase(); @@ -647,7 +715,7 @@ export function buildExternalExtractArgs( // On Windows (the target platform) this is less of a concern than on shared Unix systems. const pass = password ? `-p${password}` : "-p-"; const perfArgs = usePerformanceFlags && shouldUseExtractorPerformanceFlags() - ? ["-idc", extractorThreadSwitch()] + ? ["-idc", extractorThreadSwitch(hybridMode)] : []; return ["x", overwrite, pass, "-y", ...perfArgs, archivePath, `${targetDir}${path.sep}`]; } @@ -717,7 +785,8 @@ async function runExternalExtract( conflictMode: ConflictMode, passwordCandidates: string[], onArchiveProgress?: (percent: number) => void, - signal?: AbortSignal + signal?: AbortSignal, + hybridMode = false ): Promise { const command = await resolveExtractorCommand(); const passwords = passwordCandidates; @@ -732,7 +801,7 @@ async function runExternalExtract( const effectiveTargetDir = subst ? `${subst.drive}:` : targetDir; try { - return await runExternalExtractInner(command, archivePath, effectiveTargetDir, conflictMode, passwordCandidates, onArchiveProgress, signal, timeoutMs); + return await runExternalExtractInner(command, archivePath, effectiveTargetDir, conflictMode, passwordCandidates, onArchiveProgress, signal, timeoutMs, hybridMode); } finally { if (subst) removeSubstMapping(subst); } @@ -746,7 +815,8 @@ async function runExternalExtractInner( passwordCandidates: string[], onArchiveProgress: ((percent: number) => void) | undefined, signal: AbortSignal | undefined, - timeoutMs: number + timeoutMs: number, + hybridMode = false ): Promise { const passwords = passwordCandidates; let lastError = ""; @@ -763,7 +833,7 @@ async function runExternalExtractInner( announcedStart = true; onArchiveProgress?.(0); } - let args = buildExternalExtractArgs(command, archivePath, targetDir, conflictMode, password, usePerformanceFlags); + let args = buildExternalExtractArgs(command, archivePath, targetDir, conflictMode, password, usePerformanceFlags, hybridMode); let result = await runExtractCommand(command, args, (chunk) => { const parsed = parseProgressPercent(chunk); if (parsed === null || parsed <= bestPercent) { @@ -777,7 +847,7 @@ async function runExternalExtractInner( usePerformanceFlags = false; externalExtractorSupportsPerfFlags = false; logger.warn(`Entpacker ohne Performance-Flags fortgesetzt: ${path.basename(archivePath)}`); - args = buildExternalExtractArgs(command, archivePath, targetDir, conflictMode, password, false); + args = buildExternalExtractArgs(command, archivePath, targetDir, conflictMode, password, false, hybridMode); result = await runExtractCommand(command, args, (chunk) => { const parsed = parseProgressPercent(chunk); if (parsed === null || parsed <= bestPercent) { @@ -1202,6 +1272,15 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ }) : 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}`); + + // Disk space pre-check + if (candidates.length > 0) { + try { + await fs.promises.mkdir(options.targetDir, { recursive: true }); + } catch { /* ignore */ } + await checkDiskSpaceForExtraction(options.targetDir, candidates); + } + if (candidates.length === 0) { if (!options.onlyArchives) { const existingResume = await readExtractResumeState(options.packageDir, options.packageId); @@ -1299,7 +1378,8 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ const pulseTimer = setInterval(() => { emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt); }, 1100); - logger.info(`Entpacke Archiv: ${path.basename(archivePath)} -> ${options.targetDir}`); + const hybrid = Boolean(options.hybridMode); + logger.info(`Entpacke Archiv: ${path.basename(archivePath)} -> ${options.targetDir}${hybrid ? " (hybrid, -mt1, low I/O)" : ""}`); try { const ext = path.extname(archivePath).toLowerCase(); if (ext === ".zip") { @@ -1309,7 +1389,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ const usedPassword = await runExternalExtract(archivePath, options.targetDir, options.conflictMode, passwordCandidates, (value) => { archivePercent = Math.max(archivePercent, value); emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt); - }, options.signal); + }, options.signal, hybrid); passwordCandidates = prioritizePassword(passwordCandidates, usedPassword); } catch (error) { if (isNoExtractorError(String(error))) { @@ -1330,7 +1410,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ const usedPassword = await runExternalExtract(archivePath, options.targetDir, options.conflictMode, passwordCandidates, (value) => { archivePercent = Math.max(archivePercent, value); emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt); - }, options.signal); + }, options.signal, hybrid); passwordCandidates = prioritizePassword(passwordCandidates, usedPassword); } catch (externalError) { if (isNoExtractorError(String(externalError)) || isUnsupportedArchiveFormatError(String(externalError))) { @@ -1344,7 +1424,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ const usedPassword = await runExternalExtract(archivePath, options.targetDir, options.conflictMode, passwordCandidates, (value) => { archivePercent = Math.max(archivePercent, value); emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt); - }, options.signal); + }, options.signal, hybrid); passwordCandidates = prioritizePassword(passwordCandidates, usedPassword); } extracted += 1; @@ -1376,6 +1456,82 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ } } + // ── Nested extraction: extract archives found inside the output (1 level) ── + if (extracted > 0 && failed === 0 && !options.skipPostCleanup && !options.onlyArchives) { + try { + const nestedCandidates = (await findArchiveCandidates(options.targetDir)) + .filter((p) => !NESTED_EXTRACT_BLACKLIST_RE.test(p)); + if (nestedCandidates.length > 0) { + logger.info(`Nested-Extraction: ${nestedCandidates.length} Archive im Output gefunden`); + 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(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") { + 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); + passwordCandidates = prioritizePassword(passwordCandidates, usedPw); + } + } else { + const usedPw = await runExternalExtract(nestedArchive, options.targetDir, options.conflictMode, passwordCandidates, (v) => { nestedPercent = Math.max(nestedPercent, v); }, options.signal, hybrid); + passwordCandidates = prioritizePassword(passwordCandidates, usedPw); + } + extracted += 1; + extractedArchives.add(nestedArchive); + resumeCompleted.add(nestedKey); + await writeExtractResumeState(options.packageDir, resumeCompleted, options.packageId); + logger.info(`Nested-Entpacken erfolgreich: ${nestedName}`); + if (options.cleanupMode === "delete") { + for (const part of collectArchiveCleanupTargets(nestedArchive)) { + try { await fs.promises.unlink(part); } catch { /* ignore */ } + } + } + } 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; + lastError = errText; + logger.error(`Nested-Entpack-Fehler ${nestedName}: ${errText}`); + } finally { + clearInterval(nestedPulse); + } + } + } + } 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 hasAnyEntries(options.targetDir); const hadResumeProgress = resumeCompletedAtStart > 0; diff --git a/tests/extractor.test.ts b/tests/extractor.test.ts index 662d6fd..537e4fe 100644 --- a/tests/extractor.test.ts +++ b/tests/extractor.test.ts @@ -618,4 +618,119 @@ describe("extractor", () => { expect(result.extracted).toBe(1); expect(result.failed).toBe(0); }); + + describe("disk space check", () => { + it("aborts extraction when disk space is insufficient", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-diskspace-")); + tempDirs.push(root); + const packageDir = path.join(root, "pkg"); + const targetDir = path.join(root, "out"); + fs.mkdirSync(packageDir, { recursive: true }); + fs.mkdirSync(targetDir, { recursive: true }); + + const zip = new AdmZip(); + zip.addFile("test.txt", Buffer.alloc(1024, 0x41)); + zip.writeZip(path.join(packageDir, "test.zip")); + + const originalStatfs = fs.promises.statfs; + (fs.promises as any).statfs = async () => ({ bfree: 1, bsize: 1 }); + + try { + await expect( + extractPackageArchives({ + packageDir, + targetDir, + cleanupMode: "none" as any, + conflictMode: "overwrite" as any, + removeLinks: false, + removeSamples: false, + }) + ).rejects.toThrow(/Nicht genug Speicherplatz/); + } finally { + (fs.promises as any).statfs = originalStatfs; + } + }); + + it("proceeds when disk space is sufficient", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-diskspace-ok-")); + tempDirs.push(root); + const packageDir = path.join(root, "pkg"); + const targetDir = path.join(root, "out"); + fs.mkdirSync(packageDir, { recursive: true }); + fs.mkdirSync(targetDir, { recursive: true }); + + const zip = new AdmZip(); + zip.addFile("test.txt", Buffer.alloc(1024, 0x41)); + zip.writeZip(path.join(packageDir, "test.zip")); + + const result = await extractPackageArchives({ + packageDir, + targetDir, + cleanupMode: "none" as any, + conflictMode: "overwrite" as any, + removeLinks: false, + removeSamples: false, + }); + expect(result.extracted).toBe(1); + expect(result.failed).toBe(0); + }); + }); + + describe("nested extraction", () => { + it("extracts archives found inside extracted output", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-nested-")); + tempDirs.push(root); + const packageDir = path.join(root, "pkg"); + const targetDir = path.join(root, "out"); + fs.mkdirSync(packageDir, { recursive: true }); + fs.mkdirSync(targetDir, { recursive: true }); + + const innerZip = new AdmZip(); + innerZip.addFile("deep.txt", Buffer.from("deep content")); + + const outerZip = new AdmZip(); + outerZip.addFile("inner.zip", innerZip.toBuffer()); + outerZip.writeZip(path.join(packageDir, "outer.zip")); + + const result = await extractPackageArchives({ + packageDir, + targetDir, + cleanupMode: "none" as any, + conflictMode: "overwrite" as any, + removeLinks: false, + removeSamples: false, + }); + + expect(result.extracted).toBe(2); + expect(result.failed).toBe(0); + expect(fs.existsSync(path.join(targetDir, "deep.txt"))).toBe(true); + }); + + it("does not extract blacklisted extensions like .iso", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-nested-bl-")); + tempDirs.push(root); + const packageDir = path.join(root, "pkg"); + const targetDir = path.join(root, "out"); + fs.mkdirSync(packageDir, { recursive: true }); + fs.mkdirSync(targetDir, { recursive: true }); + + const zip = new AdmZip(); + zip.addFile("disc.iso", Buffer.alloc(64, 0)); + zip.addFile("readme.txt", Buffer.from("hello")); + zip.writeZip(path.join(packageDir, "package.zip")); + + const result = await extractPackageArchives({ + packageDir, + targetDir, + cleanupMode: "none" as any, + conflictMode: "overwrite" as any, + removeLinks: false, + removeSamples: false, + }); + + expect(result.extracted).toBe(1); + expect(fs.existsSync(path.join(targetDir, "disc.iso"))).toBe(true); + expect(fs.existsSync(path.join(targetDir, "readme.txt"))).toBe(true); + }); + }); });