From 3b9c4a4e8801159ccc9ecf29635b9751f1919baa Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Fri, 27 Feb 2026 19:12:40 +0100 Subject: [PATCH] Release v1.4.7 with ENOENT extraction recovery and lag optimizations --- package-lock.json | 4 +- package.json | 2 +- src/main/download-manager.ts | 52 ++++++++++--- src/main/extractor.ts | 71 ++++-------------- src/main/logger.ts | 129 ++++++++++++++++++++++++++++---- src/main/storage.ts | 2 +- src/renderer/App.tsx | 35 +++++++-- tests/download-manager.test.ts | 132 +++++++++++++++++++++++++++++++++ tests/extractor.test.ts | 21 ++++++ 9 files changed, 358 insertions(+), 90 deletions(-) diff --git a/package-lock.json b/package-lock.json index 966d425..8274a20 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "real-debrid-downloader", - "version": "1.4.6", + "version": "1.4.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "real-debrid-downloader", - "version": "1.4.6", + "version": "1.4.7", "license": "MIT", "dependencies": { "adm-zip": "^0.5.16", diff --git a/package.json b/package.json index 94d536f..fc0d652 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "real-debrid-downloader", - "version": "1.4.6", + "version": "1.4.7", "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 e2d7d44..a82d2fd 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -113,6 +113,10 @@ function isFinishedStatus(status: DownloadStatus): boolean { return status === "completed" || status === "failed" || status === "cancelled"; } +function isExtractedLabel(statusText: string): boolean { + return /^entpackt\b/i.test(String(statusText || "").trim()); +} + function providerLabel(provider: DownloadItem["provider"]): string { if (provider === "realdebrid") { return "Real-Debrid"; @@ -169,6 +173,8 @@ export class DownloadManager extends EventEmitter { private speedBytesLastWindow = 0; + private lastPersistAt = 0; + private cleanupQueue: Promise = Promise.resolve(); private packagePostProcessQueue: Promise = Promise.resolve(); @@ -1203,13 +1209,28 @@ export class DownloadManager extends EventEmitter { if (this.persistTimer) { return; } + + const itemCount = Object.keys(this.session.items).length; + const minGapMs = this.session.running + ? itemCount >= 1500 + ? 1300 + : itemCount >= 700 + ? 950 + : itemCount >= 250 + ? 700 + : 450 + : 250; + const sinceLastPersist = nowMs() - this.lastPersistAt; + const delay = Math.max(120, minGapMs - sinceLastPersist); + this.persistTimer = setTimeout(() => { this.persistTimer = null; this.persistNow(); - }, 250); + }, delay); } private persistNow(): void { + this.lastPersistAt = nowMs(); saveSession(this.storagePaths, this.session); } @@ -1397,7 +1418,7 @@ export class DownloadManager extends EventEmitter { if (this.settings.autoExtract && failed === 0 && success > 0) { const needsPostProcess = pkg.status !== "completed" - || items.some((item) => item.status === "completed" && item.fullStatus !== "Entpackt"); + || items.some((item) => item.status === "completed" && !isExtractedLabel(item.fullStatus)); if (needsPostProcess) { void this.runPackagePostProcessing(packageId); } else if (pkg.status !== "completed") { @@ -1475,7 +1496,9 @@ export class DownloadManager extends EventEmitter { break; } - await sleep(120); + const maxParallel = Math.max(1, this.settings.maxParallel); + const schedulerSleepMs = this.activeTasks.size >= maxParallel ? 170 : 120; + await sleep(schedulerSleepMs); } } finally { this.scheduleRunning = false; @@ -2381,7 +2404,7 @@ export class DownloadManager extends EventEmitter { } const completedItems = items.filter((item) => item.status === "completed"); - const alreadyMarkedExtracted = completedItems.length > 0 && completedItems.every((item) => item.fullStatus === "Entpackt"); + const alreadyMarkedExtracted = completedItems.length > 0 && completedItems.every((item) => isExtractedLabel(item.fullStatus)); if (this.settings.autoExtract && failed === 0 && success > 0 && !alreadyMarkedExtracted) { pkg.status = "extracting"; @@ -2436,11 +2459,22 @@ export class DownloadManager extends EventEmitter { } pkg.status = "failed"; } else { - if (result.extracted > 0) { - for (const entry of completedItems) { - entry.fullStatus = "Entpackt"; - entry.updatedAt = nowMs(); - } + const hasExtractedOutput = this.directoryHasAnyFiles(pkg.extractDir); + const sourceExists = fs.existsSync(pkg.outputDir); + let finalStatusText = ""; + + if (result.extracted > 0 || hasExtractedOutput) { + finalStatusText = "Entpackt"; + } else if (!sourceExists) { + finalStatusText = "Entpackt (Quelle fehlt)"; + logger.warn(`Post-Processing ohne Quellordner: pkg=${pkg.name}, outputDir fehlt`); + } else { + finalStatusText = "Entpackt (keine Archive)"; + } + + for (const entry of completedItems) { + entry.fullStatus = finalStatusText; + entry.updatedAt = nowMs(); } pkg.status = "completed"; } diff --git a/src/main/extractor.ts b/src/main/extractor.ts index afc73a3..0e2111b 100644 --- a/src/main/extractor.ts +++ b/src/main/extractor.ts @@ -42,9 +42,18 @@ type ExtractResumeState = { }; function findArchiveCandidates(packageDir: string): string[] { - const files = fs.readdirSync(packageDir, { withFileTypes: true }) - .filter((entry) => entry.isFile()) - .map((entry) => path.join(packageDir, entry.name)); + if (!packageDir || !fs.existsSync(packageDir)) { + return []; + } + + let files: string[] = []; + try { + files = fs.readdirSync(packageDir, { withFileTypes: true }) + .filter((entry) => entry.isFile()) + .map((entry) => path.join(packageDir, entry.name)); + } catch { + return []; + } const preferred = files.filter((file) => /\.part0*1\.rar$/i.test(file)); const zip = files.filter((file) => /\.zip$/i.test(file)); @@ -408,56 +417,6 @@ function escapeRegex(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } -function captureDirFingerprint(rootDir: string): Map { - const fingerprint = new Map(); - if (!fs.existsSync(rootDir)) { - return fingerprint; - } - - const stack = [rootDir]; - while (stack.length > 0) { - const current = stack.pop() as string; - let entries: fs.Dirent[] = []; - try { - entries = fs.readdirSync(current, { withFileTypes: true }); - } catch { - continue; - } - - for (const entry of entries) { - const full = path.join(current, entry.name); - if (entry.isDirectory()) { - stack.push(full); - continue; - } - if (!entry.isFile()) { - continue; - } - try { - const stat = fs.statSync(full); - const relative = path.relative(rootDir, full).toLowerCase(); - fingerprint.set(relative, `${stat.size}:${stat.mtimeMs}`); - } catch { - // ignore - } - } - } - - return fingerprint; -} - -function hasDirChanges(before: Map, after: Map): boolean { - if (after.size > before.size) { - return true; - } - for (const [relative, meta] of after.entries()) { - if (before.get(relative) !== meta) { - return true; - } - } - return false; -} - export function collectArchiveCleanupTargets(sourceArchivePath: string): string[] { const targets = new Set([sourceArchivePath]); const dir = path.dirname(sourceArchivePath); @@ -619,7 +578,6 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ const conflictMode = effectiveConflictMode(options.conflictMode); const passwordCandidates = archivePasswords(options.passwordList || ""); - const beforeFingerprint = captureDirFingerprint(options.targetDir); const resumeCompleted = readExtractResumeState(options.packageDir); const resumeCompletedAtStart = resumeCompleted.size; const candidateNames = new Set(candidates.map((archivePath) => path.basename(archivePath))); @@ -751,10 +709,9 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ } if (extracted > 0) { - const afterFingerprint = captureDirFingerprint(options.targetDir); - const changedOutput = hasDirChanges(beforeFingerprint, afterFingerprint); + const hasOutputAfter = hasAnyFilesRecursive(options.targetDir); const hadResumeProgress = resumeCompletedAtStart > 0; - if (!changedOutput && conflictMode !== "skip" && !hadResumeProgress) { + if (!hasOutputAfter && conflictMode !== "skip" && !hadResumeProgress) { lastError = "Keine entpackten Dateien erkannt"; failed += extracted; extracted = 0; diff --git a/src/main/logger.ts b/src/main/logger.ts index 2817251..fe51947 100644 --- a/src/main/logger.ts +++ b/src/main/logger.ts @@ -3,6 +3,14 @@ import path from "node:path"; let logFilePath = path.resolve(process.cwd(), "rd_downloader.log"); let fallbackLogFilePath: string | null = null; +const LOG_FLUSH_INTERVAL_MS = 120; +const LOG_BUFFER_LIMIT_CHARS = 1_000_000; + +let pendingLines: string[] = []; +let pendingChars = 0; +let flushTimer: NodeJS.Timeout | null = null; +let flushInFlight = false; +let exitHookAttached = false; export function configureLogger(baseDir: string): void { logFilePath = path.join(baseDir, "rd_downloader.log"); @@ -20,31 +28,126 @@ function appendLine(filePath: string, line: string): { ok: boolean; errorText: s } } -function write(level: "INFO" | "WARN" | "ERROR", message: string): void { - const line = `${new Date().toISOString()} [${level}] ${message}\n`; - const primary = appendLine(logFilePath, line); +async function appendChunk(filePath: string, chunk: string): Promise<{ ok: boolean; errorText: string }> { + try { + await fs.promises.mkdir(path.dirname(filePath), { recursive: true }); + await fs.promises.appendFile(filePath, chunk, "utf8"); + return { ok: true, errorText: "" }; + } catch (error) { + return { ok: false, errorText: String(error) }; + } +} +function writeStderr(text: string): void { + try { + process.stderr.write(text); + } catch { + // ignore stderr failures + } +} + +function flushSyncPending(): void { + if (pendingLines.length === 0) { + return; + } + + const chunk = pendingLines.join(""); + pendingLines = []; + pendingChars = 0; + + const primary = appendLine(logFilePath, chunk); if (fallbackLogFilePath) { - const fallback = appendLine(fallbackLogFilePath, line); + const fallback = appendLine(fallbackLogFilePath, chunk); if (!primary.ok && !fallback.ok) { - try { - process.stderr.write(`LOGGER write failed (primary+fallback): ${primary.errorText} | ${fallback.errorText}\n`); - } catch { - // ignore stderr failures - } + writeStderr(`LOGGER write failed (primary+fallback): ${primary.errorText} | ${fallback.errorText}\n`); } return; } if (!primary.ok) { - try { - process.stderr.write(`LOGGER write failed: ${primary.errorText}\n`); - } catch { - // ignore stderr failures + writeStderr(`LOGGER write failed: ${primary.errorText}\n`); + } +} + +function scheduleFlush(immediate = false): void { + if (flushInFlight) { + return; + } + if (immediate) { + if (flushTimer) { + clearTimeout(flushTimer); + flushTimer = null; + } + void flushAsync(); + return; + } + if (flushTimer) { + return; + } + flushTimer = setTimeout(() => { + flushTimer = null; + void flushAsync(); + }, LOG_FLUSH_INTERVAL_MS); +} + +async function flushAsync(): Promise { + if (flushInFlight || pendingLines.length === 0) { + return; + } + + flushInFlight = true; + const chunk = pendingLines.join(""); + pendingLines = []; + pendingChars = 0; + + try { + const primary = await appendChunk(logFilePath, chunk); + if (fallbackLogFilePath) { + const fallback = await appendChunk(fallbackLogFilePath, chunk); + if (!primary.ok && !fallback.ok) { + writeStderr(`LOGGER write failed (primary+fallback): ${primary.errorText} | ${fallback.errorText}\n`); + } + } else if (!primary.ok) { + writeStderr(`LOGGER write failed: ${primary.errorText}\n`); + } + } finally { + flushInFlight = false; + if (pendingLines.length > 0) { + scheduleFlush(true); } } } +function ensureExitHook(): void { + if (exitHookAttached) { + return; + } + exitHookAttached = true; + process.once("beforeExit", flushSyncPending); + process.once("exit", flushSyncPending); +} + +function write(level: "INFO" | "WARN" | "ERROR", message: string): void { + ensureExitHook(); + const line = `${new Date().toISOString()} [${level}] ${message}\n`; + pendingLines.push(line); + pendingChars += line.length; + + while (pendingChars > LOG_BUFFER_LIMIT_CHARS && pendingLines.length > 1) { + const removed = pendingLines.shift(); + if (!removed) { + break; + } + pendingChars = Math.max(0, pendingChars - removed.length); + } + + if (level === "ERROR") { + scheduleFlush(true); + return; + } + scheduleFlush(); +} + export const logger = { info: (msg: string): void => write("INFO", msg), warn: (msg: string): void => write("WARN", msg), diff --git a/src/main/storage.ts b/src/main/storage.ts index 94b67a9..364b4d8 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -205,7 +205,7 @@ export function loadSession(paths: StoragePaths): SessionState { export function saveSession(paths: StoragePaths, session: SessionState): void { ensureBaseDir(paths.baseDir); - const payload = JSON.stringify({ ...session, updatedAt: Date.now() }, null, 2); + const payload = JSON.stringify({ ...session, updatedAt: Date.now() }); const tempPath = `${paths.sessionFile}.tmp`; fs.writeFileSync(tempPath, payload, "utf8"); fs.renameSync(tempPath, paths.sessionFile); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index d563a13..013f5e0 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -201,21 +201,39 @@ export function App(): ReactElement { }; }, []); - const packages = useMemo(() => snapshot.session.packageOrder - .map((id: string) => snapshot.session.packages[id]) - .filter(Boolean), [snapshot.session.packageOrder, snapshot.session.packages]); + const downloadsTabActive = tab === "downloads"; - const packageOrderKey = useMemo(() => snapshot.session.packageOrder.join("|"), [snapshot.session.packageOrder]); + const packages = useMemo(() => { + if (!downloadsTabActive) { + return [] as PackageEntry[]; + } + return snapshot.session.packageOrder + .map((id: string) => snapshot.session.packages[id]) + .filter(Boolean); + }, [downloadsTabActive, snapshot.session.packageOrder, snapshot.session.packages]); + + const packageOrderKey = useMemo(() => { + if (!downloadsTabActive) { + return ""; + } + return snapshot.session.packageOrder.join("|"); + }, [downloadsTabActive, snapshot.session.packageOrder]); const packagePosition = useMemo(() => { + if (!downloadsTabActive) { + return new Map(); + } const map = new Map(); snapshot.session.packageOrder.forEach((id, index) => { map.set(id, index); }); return map; - }, [packageOrderKey]); + }, [downloadsTabActive, packageOrderKey, snapshot.session.packageOrder]); const itemsByPackage = useMemo(() => { + if (!downloadsTabActive) { + return new Map(); + } const map = new Map(); for (const packageId of snapshot.session.packageOrder) { const pkg = snapshot.session.packages[packageId]; @@ -228,9 +246,12 @@ export function App(): ReactElement { map.set(packageId, items); } return map; - }, [packageOrderKey, snapshot.session.items, snapshot.session.packages]); + }, [downloadsTabActive, packageOrderKey, snapshot.session.items, snapshot.session.packages, snapshot.session.packageOrder]); useEffect(() => { + if (!downloadsTabActive) { + return; + } setCollapsedPackages((prev) => { const next: Record = {}; const defaultCollapsed = snapshot.session.packageOrder.length >= 24; @@ -239,7 +260,7 @@ export function App(): ReactElement { } return next; }); - }, [packageOrderKey, snapshot.session.packageOrder.length]); + }, [downloadsTabActive, packageOrderKey, snapshot.session.packageOrder.length]); const deferredDownloadSearch = useDeferredValue(downloadSearch); diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index ed3a0d9..f07c509 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -2941,4 +2941,136 @@ describe("download manager", () => { expect(snapshot.session.packages[packageId]?.status).toBe("completed"); expect(snapshot.session.items[itemId]?.fullStatus).toBe("Entpackt"); }); + + it("does not fail startup post-processing when source package dir is missing but extract output exists", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const outputDir = path.join(root, "downloads", "missing-source-ok"); + const extractDir = path.join(root, "extract", "missing-source-ok"); + fs.mkdirSync(extractDir, { recursive: true }); + fs.writeFileSync(path.join(extractDir, "episode.mkv"), "ok", "utf8"); + + const session = emptySession(); + const packageId = "missing-source-ok-pkg"; + const itemId = "missing-source-ok-item"; + const createdAt = Date.now() - 20_000; + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "missing-source-ok", + outputDir, + extractDir, + status: "downloading", + itemIds: [itemId], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + session.items[itemId] = { + id: itemId, + packageId, + url: "https://dummy/missing-source-ok", + provider: "megadebrid", + status: "completed", + retries: 0, + speedBps: 0, + downloadedBytes: 123, + totalBytes: 123, + progressPercent: 100, + fileName: "missing-source-ok.part01.rar", + targetPath: path.join(outputDir, "missing-source-ok.part01.rar"), + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Fertig (123 B)", + createdAt, + updatedAt: createdAt + }; + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: true, + enableIntegrityCheck: false, + cleanupMode: "none" + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + await waitFor(() => manager.getSnapshot().session.items[itemId]?.fullStatus.startsWith("Entpackt"), 12000); + const snapshot = manager.getSnapshot(); + expect(snapshot.session.packages[packageId]?.status).toBe("completed"); + expect(snapshot.session.items[itemId]?.fullStatus).toBe("Entpackt"); + }); + + it("marks missing source package dir as extracted instead of failed", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const outputDir = path.join(root, "downloads", "missing-source-empty"); + const extractDir = path.join(root, "extract", "missing-source-empty"); + + const session = emptySession(); + const packageId = "missing-source-empty-pkg"; + const itemId = "missing-source-empty-item"; + const createdAt = Date.now() - 20_000; + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "missing-source-empty", + outputDir, + extractDir, + status: "downloading", + itemIds: [itemId], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + session.items[itemId] = { + id: itemId, + packageId, + url: "https://dummy/missing-source-empty", + provider: "megadebrid", + status: "completed", + retries: 0, + speedBps: 0, + downloadedBytes: 123, + totalBytes: 123, + progressPercent: 100, + fileName: "missing-source-empty.part01.rar", + targetPath: path.join(outputDir, "missing-source-empty.part01.rar"), + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Fertig (123 B)", + createdAt, + updatedAt: createdAt + }; + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: true, + enableIntegrityCheck: false, + cleanupMode: "none" + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + await waitFor(() => manager.getSnapshot().session.items[itemId]?.fullStatus.startsWith("Entpackt"), 12000); + const snapshot = manager.getSnapshot(); + expect(snapshot.session.packages[packageId]?.status).toBe("completed"); + expect(snapshot.session.items[itemId]?.fullStatus).toBe("Entpackt (Quelle fehlt)"); + }); }); diff --git a/tests/extractor.test.ts b/tests/extractor.test.ts index 3a1375c..7ff6a00 100644 --- a/tests/extractor.test.ts +++ b/tests/extractor.test.ts @@ -331,4 +331,25 @@ describe("extractor", () => { signal: controller.signal })).rejects.toThrow("aborted:extract"); }); + + it("handles missing package source directory without throwing", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-")); + tempDirs.push(root); + const packageDir = path.join(root, "pkg-missing"); + const targetDir = path.join(root, "out"); + fs.mkdirSync(targetDir, { recursive: true }); + fs.writeFileSync(path.join(targetDir, "video.mkv"), "ok", "utf8"); + + const result = await extractPackageArchives({ + packageDir, + targetDir, + cleanupMode: "none", + conflictMode: "overwrite", + removeLinks: false, + removeSamples: false + }); + + expect(result.failed).toBe(0); + expect(result.extracted).toBe(0); + }); });