From 7b5218ad98965570205392f80d1bc74bf38da0ee Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Fri, 27 Feb 2026 15:55:43 +0100 Subject: [PATCH] Remove empty download package dirs after archive cleanup in v1.3.11 --- package.json | 2 +- src/main/download-manager.ts | 46 +++++++++++++++++++++ src/main/extractor.ts | 73 ++++++++++++++++++++++++++++++++++ tests/download-manager.test.ts | 66 ++++++++++++++++++++++++++++++ tests/extractor.test.ts | 57 ++++++++++++++++++++++++++ 5 files changed, 243 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 8a2154e..877a156 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "real-debrid-downloader", - "version": "1.3.10", + "version": "1.3.11", "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 81a6a03..ff6b650 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -671,6 +671,12 @@ export class DownloadManager extends EventEmitter { if (removed > 0) { logger.info(`Nachtraegliches Archive-Cleanup fuer ${pkg.name}: ${removed} Datei(en) geloescht`); + if (!this.directoryHasAnyFiles(pkg.outputDir)) { + const removedDirs = this.removeEmptyDirectoryTree(pkg.outputDir); + if (removedDirs > 0) { + logger.info(`Nachtraegliches Cleanup entfernte leere Download-Ordner fuer ${pkg.name}: ${removedDirs}`); + } + } } else { logger.info(`Nachtraegliches Archive-Cleanup fuer ${pkg.name}: keine Dateien entfernt`); } @@ -707,6 +713,46 @@ export class DownloadManager extends EventEmitter { return false; } + private removeEmptyDirectoryTree(rootDir: string): number { + if (!rootDir || !fs.existsSync(rootDir)) { + return 0; + } + + const dirs = [rootDir]; + 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) { + 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 = fs.readdirSync(dirPath); + if (entries.length === 0) { + fs.rmdirSync(dirPath); + removed += 1; + } + } catch { + // ignore + } + } + return removed; + } + public cancelPackage(packageId: string): void { const pkg = this.session.packages[packageId]; if (!pkg) { diff --git a/src/main/extractor.ts b/src/main/extractor.ts index 4a1cc16..efb2345 100644 --- a/src/main/extractor.ts +++ b/src/main/extractor.ts @@ -379,6 +379,72 @@ function cleanupArchives(sourceFiles: string[], cleanupMode: CleanupMode): numbe return removed; } +function hasAnyFilesRecursive(rootDir: string): boolean { + if (!fs.existsSync(rootDir)) { + return false; + } + 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) { + if (entry.isFile()) { + return true; + } + if (entry.isDirectory()) { + stack.push(path.join(current, entry.name)); + } + } + } + return false; +} + +function removeEmptyDirectoryTree(rootDir: string): number { + if (!fs.existsSync(rootDir)) { + return 0; + } + + const dirs = [rootDir]; + 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) { + 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 = fs.readdirSync(dirPath); + if (entries.length === 0) { + fs.rmdirSync(dirPath); + removed += 1; + } + } catch { + // ignore + } + } + return removed; +} + export async function extractPackageArchives(options: ExtractOptions): Promise<{ extracted: number; failed: number; lastError: string }> { const candidates = findArchiveCandidates(options.packageDir); logger.info(`Entpacken gestartet: packageDir=${options.packageDir}, targetDir=${options.targetDir}, archives=${candidates.length}, cleanupMode=${options.cleanupMode}, conflictMode=${options.conflictMode}`); @@ -445,6 +511,13 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ const removedSamples = removeSampleArtifacts(options.targetDir); logger.info(`Sample-Cleanup: ${removedSamples.files} Datei(en), ${removedSamples.dirs} Ordner entfernt`); } + + if (options.cleanupMode === "delete" && !hasAnyFilesRecursive(options.packageDir)) { + const removedDirs = removeEmptyDirectoryTree(options.packageDir); + if (removedDirs > 0) { + logger.info(`Leere Download-Ordner entfernt: ${removedDirs} (root=${options.packageDir})`); + } + } } } else { try { diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index 35c2194..8e5e7df 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -1027,6 +1027,72 @@ describe("download manager", () => { expect(fs.existsSync(path.join(extractDir, "episode.mkv"))).toBe(true); }); + it("removes empty download package directory after startup cleanup backfill", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const packageDir = path.join(root, "downloads", "legacy-empty"); + fs.mkdirSync(packageDir, { recursive: true }); + const part1 = path.join(packageDir, "legacy.empty.part01.rar"); + const part2 = path.join(packageDir, "legacy.empty.part02.rar"); + fs.writeFileSync(part1, "part1", "utf8"); + fs.writeFileSync(part2, "part2", "utf8"); + + const session = emptySession(); + const packageId = "legacy-empty-pkg"; + const itemId = "legacy-empty-item"; + const createdAt = Date.now() - 20_000; + + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "legacy-empty", + outputDir: packageDir, + extractDir: path.join(root, "extract", "legacy-empty"), + status: "completed", + itemIds: [itemId], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + session.items[itemId] = { + id: itemId, + packageId, + url: "https://dummy/legacy-empty", + provider: "realdebrid", + status: "completed", + retries: 0, + speedBps: 0, + downloadedBytes: 123, + totalBytes: 123, + progressPercent: 100, + fileName: path.basename(part1), + targetPath: part1, + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Entpackt", + createdAt, + updatedAt: createdAt + }; + + new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false, + cleanupMode: "delete" + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + await waitFor(() => !fs.existsSync(packageDir), 5000); + }); + it("does not over-clean packages that share one extract directory", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); tempDirs.push(root); diff --git a/tests/extractor.test.ts b/tests/extractor.test.ts index 1c4f277..c422fd0 100644 --- a/tests/extractor.test.ts +++ b/tests/extractor.test.ts @@ -131,6 +131,63 @@ describe("extractor", () => { expect(fs.existsSync(path.join(targetDir, "episode.txt"))).toBe(true); }); + it("removes empty package directory after archive cleanup", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-")); + tempDirs.push(root); + const packageDir = path.join(root, "pkg"); + const targetDir = path.join(root, "out"); + fs.mkdirSync(packageDir, { recursive: true }); + + const zipPath = path.join(packageDir, "release.zip"); + const zip = new AdmZip(); + zip.addFile("video.mkv", Buffer.from("ok")); + zip.writeZip(zipPath); + + const result = await extractPackageArchives({ + packageDir, + targetDir, + cleanupMode: "delete", + conflictMode: "overwrite", + removeLinks: false, + removeSamples: false + }); + + expect(result.extracted).toBe(1); + expect(result.failed).toBe(0); + expect(fs.existsSync(packageDir)).toBe(false); + expect(fs.existsSync(path.join(targetDir, "video.mkv"))).toBe(true); + }); + + it("keeps package directory when non-archive files remain", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-")); + tempDirs.push(root); + const packageDir = path.join(root, "pkg"); + const targetDir = path.join(root, "out"); + fs.mkdirSync(packageDir, { recursive: true }); + + const zipPath = path.join(packageDir, "release.zip"); + const keepPath = path.join(packageDir, "notes.nfo"); + const zip = new AdmZip(); + zip.addFile("video.mkv", Buffer.from("ok")); + zip.writeZip(zipPath); + fs.writeFileSync(keepPath, "keep", "utf8"); + + const result = await extractPackageArchives({ + packageDir, + targetDir, + cleanupMode: "delete", + conflictMode: "overwrite", + removeLinks: false, + removeSamples: false + }); + + expect(result.extracted).toBe(1); + expect(result.failed).toBe(0); + expect(fs.existsSync(packageDir)).toBe(true); + expect(fs.existsSync(keepPath)).toBe(true); + expect(fs.existsSync(path.join(targetDir, "video.mkv"))).toBe(true); + }); + it("treats ask conflict mode as skip in zip extraction", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-")); tempDirs.push(root);