diff --git a/src/main/constants.ts b/src/main/constants.ts index 12dcf4e..9e84362 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -50,6 +50,8 @@ export function defaultSettings(): AppSettings { autoExtract: true, autoRename4sf4sj: false, extractDir: path.join(baseDir, "_entpackt"), + collectMkvToLibrary: false, + mkvLibraryDir: path.join(baseDir, "_mkv"), createExtractSubfolder: true, hybridExtract: true, cleanupMode: "none", diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 2bf3be5..865b782 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -1457,8 +1457,19 @@ export class DownloadManager extends EventEmitter { return removed; } - private collectVideoFiles(rootDir: string): string[] { - if (!rootDir || !fs.existsSync(rootDir)) { + private collectFilesByExtensions(rootDir: string, extensions: Set): string[] { + if (!rootDir || !fs.existsSync(rootDir) || extensions.size === 0) { + return []; + } + + const normalizedExtensions = new Set(); + for (const extension of extensions) { + const normalized = String(extension || "").trim().toLowerCase(); + if (normalized) { + normalizedExtensions.add(normalized); + } + } + if (normalizedExtensions.size === 0) { return []; } @@ -1483,7 +1494,7 @@ export class DownloadManager extends EventEmitter { continue; } const extension = path.extname(entry.name).toLowerCase(); - if (!SAMPLE_VIDEO_EXTENSIONS.has(extension)) { + if (!normalizedExtensions.has(extension)) { continue; } files.push(fullPath); @@ -1493,6 +1504,10 @@ export class DownloadManager extends EventEmitter { return files; } + private collectVideoFiles(rootDir: string): string[] { + return this.collectFilesByExtensions(rootDir, SAMPLE_VIDEO_EXTENSIONS); + } + private buildSafeAutoRenameTargetPath(sourcePath: string, targetBaseName: string, sourceExt: string): string | null { const dirPath = path.dirname(sourcePath); const safeBaseName = sanitizeFilename(String(targetBaseName || "").trim()); @@ -1662,6 +1677,112 @@ export class DownloadManager extends EventEmitter { return renamed; } + private moveFileWithExdevFallback(sourcePath: string, targetPath: string): void { + try { + 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 { + const parsed = path.parse(path.basename(sourcePath)); + const extension = parsed.ext || ".mkv"; + const baseName = sanitizeFilename(parsed.name || "video"); + + let index = 1; + while (true) { + const candidateName = index <= 1 + ? `${baseName}${extension}` + : `${baseName} (${index})${extension}`; + const candidatePath = path.join(targetDir, candidateName); + const candidateKey = pathKey(candidatePath); + if (reserved.has(candidateKey)) { + index += 1; + continue; + } + if (!fs.existsSync(candidatePath)) { + reserved.add(candidateKey); + return candidatePath; + } + index += 1; + } + } + + private collectMkvFilesToLibrary(packageId: string, pkg: PackageEntry): void { + if (!this.settings.collectMkvToLibrary) { + return; + } + + const sourceDir = this.settings.autoExtract ? pkg.extractDir : pkg.outputDir; + const targetDirRaw = String(this.settings.mkvLibraryDir || "").trim(); + if (!sourceDir || !targetDirRaw) { + logger.warn(`MKV-Sammelordner übersprungen: pkg=${pkg.name}, ungültiger Pfad`); + return; + } + const targetDir = path.resolve(targetDirRaw); + if (!fs.existsSync(sourceDir)) { + logger.info(`MKV-Sammelordner: pkg=${pkg.name}, Quelle fehlt (${sourceDir})`); + return; + } + + try { + fs.mkdirSync(targetDir, { recursive: true }); + } catch (error) { + logger.warn(`MKV-Sammelordner konnte nicht erstellt werden: pkg=${pkg.name}, dir=${targetDir}, reason=${compactErrorText(error)}`); + return; + } + + const mkvFiles = this.collectFilesByExtensions(sourceDir, new Set([".mkv"])); + if (mkvFiles.length === 0) { + logger.info(`MKV-Sammelordner: pkg=${pkg.name}, keine MKV gefunden`); + return; + } + + const reservedTargets = new Set(); + let moved = 0; + let skipped = 0; + let failed = 0; + + for (const sourcePath of mkvFiles) { + if (isPathInsideDir(sourcePath, targetDir)) { + skipped += 1; + continue; + } + const targetPath = this.buildUniqueFlattenTargetPath(targetDir, sourcePath, reservedTargets); + if (pathKey(sourcePath) === pathKey(targetPath)) { + skipped += 1; + continue; + } + + try { + this.moveFileWithExdevFallback(sourcePath, targetPath); + moved += 1; + } catch (error) { + failed += 1; + logger.warn(`MKV verschieben fehlgeschlagen: ${sourcePath} -> ${targetPath} (${compactErrorText(error)})`); + } + } + + if (moved > 0 && fs.existsSync(sourceDir)) { + const removedDirs = this.removeEmptyDirectoryTree(sourceDir); + if (removedDirs > 0) { + logger.info(`MKV-Sammelordner entfernte leere Ordner: pkg=${pkg.name}, entfernt=${removedDirs}`); + } + } + + logger.info(`MKV-Sammelordner: pkg=${pkg.name}, packageId=${packageId}, moved=${moved}, skipped=${skipped}, failed=${failed}, target=${targetDir}`); + } + public cancelPackage(packageId: string): void { const pkg = this.session.packages[packageId]; if (!pkg) { @@ -4152,6 +4273,9 @@ export class DownloadManager extends EventEmitter { } else { pkg.status = "completed"; } + if (pkg.status === "completed") { + this.collectMkvFilesToLibrary(packageId, pkg); + } if (this.runPackageIds.has(packageId)) { if (pkg.status === "completed") { this.runCompletedPackages.add(packageId); diff --git a/src/main/storage.ts b/src/main/storage.ts index d3e9b3d..7142ddf 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -84,6 +84,8 @@ export function normalizeSettings(settings: AppSettings): AppSettings { autoExtract: Boolean(settings.autoExtract), autoRename4sf4sj: Boolean(settings.autoRename4sf4sj), extractDir: normalizeAbsoluteDir(settings.extractDir, defaults.extractDir), + collectMkvToLibrary: Boolean(settings.collectMkvToLibrary), + mkvLibraryDir: normalizeAbsoluteDir(settings.mkvLibraryDir, defaults.mkvLibraryDir), createExtractSubfolder: Boolean(settings.createExtractSubfolder), hybridExtract: Boolean(settings.hybridExtract), cleanupMode: settings.cleanupMode, diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 6cce6d6..1db39aa 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -48,6 +48,7 @@ const emptySnapshot = (): UiSnapshot => ({ rememberToken: true, providerPrimary: "realdebrid", providerSecondary: "megadebrid", providerTertiary: "bestdebrid", autoProviderFallback: true, outputDir: "", packageName: "", autoExtract: true, autoRename4sf4sj: false, extractDir: "", createExtractSubfolder: true, hybridExtract: true, + collectMkvToLibrary: false, mkvLibraryDir: "", cleanupMode: "none", extractConflictMode: "overwrite", removeLinkFilesAfterExtract: false, removeSamplesAfterExtract: false, enableIntegrityCheck: true, autoResumeOnStart: true, autoReconnect: false, reconnectWaitSeconds: 45, completedCleanupPolicy: "never", @@ -1412,6 +1413,23 @@ export function App(): ReactElement { + + +
+ setText("mkvLibraryDir", e.target.value)} disabled={!settingsDraft.collectMkvToLibrary} /> + +