diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 17a6b4e..3169971 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -4600,27 +4600,57 @@ export class DownloadManager extends EventEmitter { return; } - const sourceDir = this.settings.autoExtract ? pkg.extractDir : pkg.outputDir; + // SOURCE DIRECTORIES: + // - Wenn autoExtract aktiv: extractDir ist primäre Quelle (entpackte Videos). + // - IMMER zusätzlich outputDir: Provider wie Mega-Debrid liefern direkte + // .mkv (kein Archiv), die sonst im outputDir liegen bleiben und nie in + // der Library landen würden. + const sourceDirCandidates: string[] = []; + if (this.settings.autoExtract && pkg.extractDir) { + sourceDirCandidates.push(pkg.extractDir); + } + if (pkg.outputDir) { + sourceDirCandidates.push(pkg.outputDir); + } + // Dedupe nach resolved Pfad (extractDir kann == outputDir sein). + const sourceDirSeen = new Set(); + const sourceDirsAll: string[] = []; + for (const dir of sourceDirCandidates) { + const resolved = path.resolve(dir).toLowerCase(); + if (sourceDirSeen.has(resolved)) continue; + sourceDirSeen.add(resolved); + sourceDirsAll.push(dir); + } + const targetDirRaw = String(this.settings.mkvLibraryDir || "").trim(); - if (!sourceDir || !targetDirRaw) { + if (sourceDirsAll.length === 0 || !targetDirRaw) { logger.warn(`MKV-Sammelordner übersprungen: pkg=${pkg.name}, ungültiger Pfad`); return; } const targetDir = path.resolve(targetDirRaw); + // SAFETY: never move files WITHIN the library tree, and never treat the // library itself as a source. sourceDir == targetDir would scan the // library, match files collected from OTHER packages via the same rename // heuristics, and move them around — a cross-package corruption vector. - if (isPathInsideDir(sourceDir, targetDir) || isPathInsideDir(targetDir, sourceDir)) { - logger.warn(`MKV-Sammelordner ABGEBROCHEN: pkg=${pkg.name}, sourceDir=${sourceDir} ueberlappt mit mkvLibraryDir=${targetDir}`); - this.logPackageForPackage(pkg, "ERROR", "MKV-Sammelordner abgebrochen: sourceDir ueberlappt mit MKV-Bibliothek", { - sourceDir, - targetDir - }); - return; + // Pro Source-Dir prüfen — einer kann safe sein, der andere nicht. + const sourceDirs: string[] = []; + for (const dir of sourceDirsAll) { + if (isPathInsideDir(dir, targetDir) || isPathInsideDir(targetDir, dir)) { + logger.warn(`MKV-Sammelordner: Source uebersprungen (ueberlappt mit mkvLibraryDir): pkg=${pkg.name}, dir=${dir}, target=${targetDir}`); + this.logPackageForPackage(pkg, "WARN", "MKV-Sammelordner: Source uebersprungen (ueberlappt mit MKV-Bibliothek)", { + sourceDir: dir, + targetDir + }); + continue; + } + if (!await this.existsAsync(dir)) { + continue; + } + sourceDirs.push(dir); } - if (!await this.existsAsync(sourceDir)) { - logger.info(`MKV-Sammelordner: pkg=${pkg.name}, Quelle fehlt (${sourceDir})`); + if (sourceDirs.length === 0) { + logger.info(`MKV-Sammelordner: pkg=${pkg.name}, keine nutzbare Quelle (alle Source-Dirs fehlen oder ueberlappen mit Library)`); return; } @@ -4631,8 +4661,21 @@ export class DownloadManager extends EventEmitter { return; } - const allMkvFiles = await this.collectFilesByExtensions(sourceDir, SAMPLE_VIDEO_EXTENSIONS); - if (allMkvFiles.length === 0) { + // Sammle aus ALLEN safe source dirs. Dedupe nach basename (lowercase) — + // extractDir wird zuerst gescannt und gewinnt bei Kollision (entpackte + // Datei hat Vorrang vor evtl. noch liegengebliebenem Quell-File). + const seenBasenames = new Set(); + const collected: { filePath: string; sourceRoot: string }[] = []; + for (const dir of sourceDirs) { + const filesInDir = await this.collectFilesByExtensions(dir, SAMPLE_VIDEO_EXTENSIONS); + for (const filePath of filesInDir) { + const baseLower = path.basename(filePath).toLowerCase(); + if (seenBasenames.has(baseLower)) continue; + seenBasenames.add(baseLower); + collected.push({ filePath, sourceRoot: dir }); + } + } + if (collected.length === 0) { logger.info(`MKV-Sammelordner: pkg=${pkg.name}, keine MKV gefunden`); return; } @@ -4646,7 +4689,7 @@ export class DownloadManager extends EventEmitter { const mkvFiles: string[] = []; let sampleSkipped = 0; let bonusSkipped = 0; - for (const filePath of allMkvFiles) { + for (const { filePath, sourceRoot } of collected) { if (shouldAbort?.()) { return; } @@ -4656,9 +4699,9 @@ export class DownloadManager extends EventEmitter { sampleSkipped += 1; continue; } - if (isInsideBonusDir(filePath, sourceDir) || BONUS_FILENAME_RE.test(stem)) { + if (isInsideBonusDir(filePath, sourceRoot) || BONUS_FILENAME_RE.test(stem)) { bonusSkipped += 1; - logger.info(`MKV-Sammelordner: Bonus-Datei uebersprungen: ${path.basename(filePath)} (Pfad: ${path.relative(sourceDir, filePath)})`); + logger.info(`MKV-Sammelordner: Bonus-Datei uebersprungen: ${path.basename(filePath)} (Pfad: ${path.relative(sourceRoot, filePath)})`); continue; } mkvFiles.push(filePath); @@ -4675,7 +4718,7 @@ export class DownloadManager extends EventEmitter { } this.logRenameProcess(pkg, "INFO", "mkv-move", "MKV-Sammelordner Scan gestartet", { - sourceDir, + sourceDirs: sourceDirs.join(" | "), targetDir, mkvFiles: mkvFiles.length }); @@ -4787,20 +4830,25 @@ export class DownloadManager extends EventEmitter { } } - if ((sourceArtifactsChanged || sourceCleanupRelevant) && await this.existsAsync(sourceDir)) { - const removedResidual = await this.cleanupNonMkvResidualFiles(sourceDir, targetDir); - if (removedResidual > 0) { - logger.info(`MKV-Sammelordner entfernte Restdateien: pkg=${pkg.name}, entfernt=${removedResidual}`); - } - const removedDirs = await this.removeEmptyDirectoryTree(sourceDir); - if (removedDirs > 0) { - logger.info(`MKV-Sammelordner entfernte leere Ordner: pkg=${pkg.name}, entfernt=${removedDirs}`); + if (sourceArtifactsChanged || sourceCleanupRelevant) { + // Cleanup pro Source-Dir — beide können Restdateien hinterlassen haben + // (Mega-Direct .mkv weg aus outputDir, oder extracted .mkv weg aus extractDir). + for (const dir of sourceDirs) { + if (!await this.existsAsync(dir)) continue; + const removedResidual = await this.cleanupNonMkvResidualFiles(dir, targetDir); + if (removedResidual > 0) { + logger.info(`MKV-Sammelordner entfernte Restdateien: pkg=${pkg.name}, dir=${dir}, entfernt=${removedResidual}`); + } + const removedDirs = await this.removeEmptyDirectoryTree(dir); + if (removedDirs > 0) { + logger.info(`MKV-Sammelordner entfernte leere Ordner: pkg=${pkg.name}, dir=${dir}, entfernt=${removedDirs}`); + } } } logger.info(`MKV-Sammelordner: pkg=${pkg.name}, packageId=${packageId}, moved=${moved}, skipped=${skipped}, failed=${failed}, target=${targetDir}`); this.logRenameProcess(pkg, "INFO", "mkv-move", "MKV-Sammelordner abgeschlossen", { - sourceDir, + sourceDirs: sourceDirs.join(" | "), targetDir, moved, skipped, diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index efcbcd4..bc8262f 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -9358,6 +9358,89 @@ describe("download manager", () => { expect(fs.existsSync(originalExtractedPath)).toBe(false); }, 20000); + it("moves direct MKV download from outputDir to library when no archive present (Mega-Debrid flow)", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const packageName = "Mega-Direct-Pack"; + const outputDir = path.join(root, "downloads", packageName); + const extractDir = path.join(root, "extract", packageName); + fs.mkdirSync(outputDir, { recursive: true }); + + // Direct .mkv download (no archive) — wie es Mega-Debrid bei mega.nz liefert. + const directMkvName = "Direct.Show.S01E01.German.1080p.WEB.x264-DIRECT.mkv"; + const directMkvPath = path.join(outputDir, directMkvName); + fs.writeFileSync(directMkvPath, Buffer.alloc(2048, 1)); + const directMkvSize = fs.statSync(directMkvPath).size; + + const session = emptySession(); + const packageId = `${packageName}-pkg`; + const itemId = `${packageName}-item`; + const createdAt = Date.now() - 20_000; + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: packageName, + outputDir, + extractDir, + status: "completed", + itemIds: [itemId], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + session.items[itemId] = { + id: itemId, + packageId, + url: `https://mega.nz/file/${packageName}`, + provider: "megadebrid-api", + status: "completed", + retries: 0, + speedBps: 0, + downloadedBytes: directMkvSize, + totalBytes: directMkvSize, + progressPercent: 100, + fileName: directMkvName, + targetPath: directMkvPath, + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Fertig", + createdAt, + updatedAt: createdAt + }; + + const mkvLibraryDir = path.join(root, "mkv-library"); + + const manager = new DownloadManager( + { + ...defaultSettings(), + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: true, + autoRename4sf4sj: false, + collectMkvToLibrary: true, + mkvLibraryDir, + enableIntegrityCheck: false, + cleanupMode: "none" + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + const libraryPath = path.join(mkvLibraryDir, directMkvName); + await waitFor(() => fs.existsSync(libraryPath), 12000); + + expect(fs.existsSync(libraryPath)).toBe(true); + // Filename darf NICHT umbenannt werden (Mega-Files sind oft schon korrekt benannt). + expect(fs.readFileSync(libraryPath).length).toBe(directMkvSize); + // Quelle ist weg (verschoben). + expect(fs.existsSync(directMkvPath)).toBe(false); + + void manager; + }, 20000); + it("does NOT move bonus files from Extras subdirectory to flat library", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); tempDirs.push(root);