Add optional flat MKV library collection per package

This commit is contained in:
Sucukdeluxe 2026-03-01 03:23:26 +01:00
parent 71aa9204f4
commit 809aec69c2
7 changed files with 227 additions and 3 deletions

View File

@ -50,6 +50,8 @@ export function defaultSettings(): AppSettings {
autoExtract: true, autoExtract: true,
autoRename4sf4sj: false, autoRename4sf4sj: false,
extractDir: path.join(baseDir, "_entpackt"), extractDir: path.join(baseDir, "_entpackt"),
collectMkvToLibrary: false,
mkvLibraryDir: path.join(baseDir, "_mkv"),
createExtractSubfolder: true, createExtractSubfolder: true,
hybridExtract: true, hybridExtract: true,
cleanupMode: "none", cleanupMode: "none",

View File

@ -1457,8 +1457,19 @@ export class DownloadManager extends EventEmitter {
return removed; return removed;
} }
private collectVideoFiles(rootDir: string): string[] { private collectFilesByExtensions(rootDir: string, extensions: Set<string>): string[] {
if (!rootDir || !fs.existsSync(rootDir)) { if (!rootDir || !fs.existsSync(rootDir) || extensions.size === 0) {
return [];
}
const normalizedExtensions = new Set<string>();
for (const extension of extensions) {
const normalized = String(extension || "").trim().toLowerCase();
if (normalized) {
normalizedExtensions.add(normalized);
}
}
if (normalizedExtensions.size === 0) {
return []; return [];
} }
@ -1483,7 +1494,7 @@ export class DownloadManager extends EventEmitter {
continue; continue;
} }
const extension = path.extname(entry.name).toLowerCase(); const extension = path.extname(entry.name).toLowerCase();
if (!SAMPLE_VIDEO_EXTENSIONS.has(extension)) { if (!normalizedExtensions.has(extension)) {
continue; continue;
} }
files.push(fullPath); files.push(fullPath);
@ -1493,6 +1504,10 @@ export class DownloadManager extends EventEmitter {
return files; return files;
} }
private collectVideoFiles(rootDir: string): string[] {
return this.collectFilesByExtensions(rootDir, SAMPLE_VIDEO_EXTENSIONS);
}
private buildSafeAutoRenameTargetPath(sourcePath: string, targetBaseName: string, sourceExt: string): string | null { private buildSafeAutoRenameTargetPath(sourcePath: string, targetBaseName: string, sourceExt: string): string | null {
const dirPath = path.dirname(sourcePath); const dirPath = path.dirname(sourcePath);
const safeBaseName = sanitizeFilename(String(targetBaseName || "").trim()); const safeBaseName = sanitizeFilename(String(targetBaseName || "").trim());
@ -1662,6 +1677,112 @@ export class DownloadManager extends EventEmitter {
return renamed; 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>): 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<string>();
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 { public cancelPackage(packageId: string): void {
const pkg = this.session.packages[packageId]; const pkg = this.session.packages[packageId];
if (!pkg) { if (!pkg) {
@ -4152,6 +4273,9 @@ export class DownloadManager extends EventEmitter {
} else { } else {
pkg.status = "completed"; pkg.status = "completed";
} }
if (pkg.status === "completed") {
this.collectMkvFilesToLibrary(packageId, pkg);
}
if (this.runPackageIds.has(packageId)) { if (this.runPackageIds.has(packageId)) {
if (pkg.status === "completed") { if (pkg.status === "completed") {
this.runCompletedPackages.add(packageId); this.runCompletedPackages.add(packageId);

View File

@ -84,6 +84,8 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
autoExtract: Boolean(settings.autoExtract), autoExtract: Boolean(settings.autoExtract),
autoRename4sf4sj: Boolean(settings.autoRename4sf4sj), autoRename4sf4sj: Boolean(settings.autoRename4sf4sj),
extractDir: normalizeAbsoluteDir(settings.extractDir, defaults.extractDir), extractDir: normalizeAbsoluteDir(settings.extractDir, defaults.extractDir),
collectMkvToLibrary: Boolean(settings.collectMkvToLibrary),
mkvLibraryDir: normalizeAbsoluteDir(settings.mkvLibraryDir, defaults.mkvLibraryDir),
createExtractSubfolder: Boolean(settings.createExtractSubfolder), createExtractSubfolder: Boolean(settings.createExtractSubfolder),
hybridExtract: Boolean(settings.hybridExtract), hybridExtract: Boolean(settings.hybridExtract),
cleanupMode: settings.cleanupMode, cleanupMode: settings.cleanupMode,

View File

@ -48,6 +48,7 @@ const emptySnapshot = (): UiSnapshot => ({
rememberToken: true, providerPrimary: "realdebrid", providerSecondary: "megadebrid", rememberToken: true, providerPrimary: "realdebrid", providerSecondary: "megadebrid",
providerTertiary: "bestdebrid", autoProviderFallback: true, outputDir: "", packageName: "", providerTertiary: "bestdebrid", autoProviderFallback: true, outputDir: "", packageName: "",
autoExtract: true, autoRename4sf4sj: false, extractDir: "", createExtractSubfolder: true, hybridExtract: true, autoExtract: true, autoRename4sf4sj: false, extractDir: "", createExtractSubfolder: true, hybridExtract: true,
collectMkvToLibrary: false, mkvLibraryDir: "",
cleanupMode: "none", extractConflictMode: "overwrite", removeLinkFilesAfterExtract: false, cleanupMode: "none", extractConflictMode: "overwrite", removeLinkFilesAfterExtract: false,
removeSamplesAfterExtract: false, enableIntegrityCheck: true, autoResumeOnStart: true, removeSamplesAfterExtract: false, enableIntegrityCheck: true, autoResumeOnStart: true,
autoReconnect: false, reconnectWaitSeconds: 45, completedCleanupPolicy: "never", autoReconnect: false, reconnectWaitSeconds: 45, completedCleanupPolicy: "never",
@ -1412,6 +1413,23 @@ export function App(): ReactElement {
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoRename4sf4sj} onChange={(e) => setBool("autoRename4sf4sj", e.target.checked)} /> Auto-Rename (4SF/4SJ)</label> <label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoRename4sf4sj} onChange={(e) => setBool("autoRename4sf4sj", e.target.checked)} /> Auto-Rename (4SF/4SJ)</label>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.createExtractSubfolder} onChange={(e) => setBool("createExtractSubfolder", e.target.checked)} /> Entpackte Dateien in Paket-Unterordner speichern</label> <label className="toggle-line"><input type="checkbox" checked={settingsDraft.createExtractSubfolder} onChange={(e) => setBool("createExtractSubfolder", e.target.checked)} /> Entpackte Dateien in Paket-Unterordner speichern</label>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.hybridExtract} onChange={(e) => setBool("hybridExtract", e.target.checked)} /> Hybrid-Extract</label> <label className="toggle-line"><input type="checkbox" checked={settingsDraft.hybridExtract} onChange={(e) => setBool("hybridExtract", e.target.checked)} /> Hybrid-Extract</label>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.collectMkvToLibrary} onChange={(e) => setBool("collectMkvToLibrary", e.target.checked)} /> MKV nach Paketabschluss in Sammelordner verschieben (flach)</label>
<label>MKV-Sammelordner</label>
<div className="input-row">
<input value={settingsDraft.mkvLibraryDir} onChange={(e) => setText("mkvLibraryDir", e.target.value)} disabled={!settingsDraft.collectMkvToLibrary} />
<button
className="btn"
disabled={!settingsDraft.collectMkvToLibrary}
onClick={() => {
void performQuickAction(async () => {
const s = await window.rd.pickFolder();
if (s) { setText("mkvLibraryDir", s); }
});
}}
>
Wählen
</button>
</div>
<label>Passwortliste (eine Zeile pro Passwort)</label> <label>Passwortliste (eine Zeile pro Passwort)</label>
<textarea <textarea
className="password-list" className="password-list"

View File

@ -50,6 +50,8 @@ export interface AppSettings {
autoExtract: boolean; autoExtract: boolean;
autoRename4sf4sj: boolean; autoRename4sf4sj: boolean;
extractDir: string; extractDir: string;
collectMkvToLibrary: boolean;
mkvLibraryDir: string;
createExtractSubfolder: boolean; createExtractSubfolder: boolean;
hybridExtract: boolean; hybridExtract: boolean;
cleanupMode: CleanupMode; cleanupMode: CleanupMode;

View File

@ -3998,6 +3998,78 @@ describe("download manager", () => {
expect(fs.existsSync(path.join(extractDir, unexpectedName))).toBe(false); expect(fs.existsSync(path.join(extractDir, unexpectedName))).toBe(false);
}); });
it("moves extracted MKV files into a flat library folder per completed package", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root);
const packageName = "Flat-Pack";
const sourceFileName = "Season 1/Episode01.mkv";
const { session, packageId, itemId, originalExtractedPath } = createCompletedArchiveSession(root, packageName, sourceFileName);
const mkvLibraryDir = path.join(root, "mkv-library");
const manager = new DownloadManager(
{
...defaultSettings(),
token: "rd-token",
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 flattenedPath = path.join(mkvLibraryDir, "Episode01.mkv");
await waitFor(() => fs.existsSync(flattenedPath), 12000);
expect(manager.getSnapshot().session.packages[packageId]?.status).toBe("completed");
expect(manager.getSnapshot().session.items[itemId]?.fullStatus).toBe("Entpackt");
expect(fs.existsSync(flattenedPath)).toBe(true);
expect(fs.existsSync(originalExtractedPath)).toBe(false);
});
it("keeps existing MKV names and appends a suffix while flattening", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root);
const packageName = "Flat-Collision";
const sourceFileName = "Season 1/Episode01.mkv";
const { session } = createCompletedArchiveSession(root, packageName, sourceFileName);
const mkvLibraryDir = path.join(root, "mkv-library");
fs.mkdirSync(mkvLibraryDir, { recursive: true });
const existingPath = path.join(mkvLibraryDir, "Episode01.mkv");
fs.writeFileSync(existingPath, "already-here", "utf8");
new DownloadManager(
{
...defaultSettings(),
token: "rd-token",
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 suffixedPath = path.join(mkvLibraryDir, "Episode01 (2).mkv");
await waitFor(() => fs.existsSync(suffixedPath), 12000);
expect(fs.existsSync(existingPath)).toBe(true);
expect(fs.readFileSync(existingPath, "utf8")).toBe("already-here");
expect(fs.existsSync(suffixedPath)).toBe(true);
});
it("throws a controlled error for invalid queue import JSON", () => { it("throws a controlled error for invalid queue import JSON", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root); tempDirs.push(root);

View File

@ -84,6 +84,7 @@ describe("settings storage", () => {
speedLimitKbps: -1, speedLimitKbps: -1,
outputDir: " ", outputDir: " ",
extractDir: " ", extractDir: " ",
mkvLibraryDir: " ",
updateRepo: " " updateRepo: " "
}); });
@ -99,6 +100,7 @@ describe("settings storage", () => {
expect(normalized.speedLimitKbps).toBe(0); expect(normalized.speedLimitKbps).toBe(0);
expect(normalized.outputDir).toBe(defaultSettings().outputDir); expect(normalized.outputDir).toBe(defaultSettings().outputDir);
expect(normalized.extractDir).toBe(defaultSettings().extractDir); expect(normalized.extractDir).toBe(defaultSettings().extractDir);
expect(normalized.mkvLibraryDir).toBe(defaultSettings().mkvLibraryDir);
expect(normalized.updateRepo).toBe(defaultSettings().updateRepo); expect(normalized.updateRepo).toBe(defaultSettings().updateRepo);
}); });
@ -418,6 +420,8 @@ describe("settings storage", () => {
expect(loaded.speedLimitMode).toBe(defaults.speedLimitMode); expect(loaded.speedLimitMode).toBe(defaults.speedLimitMode);
expect(loaded.clipboardWatch).toBe(defaults.clipboardWatch); expect(loaded.clipboardWatch).toBe(defaults.clipboardWatch);
expect(loaded.minimizeToTray).toBe(defaults.minimizeToTray); expect(loaded.minimizeToTray).toBe(defaults.minimizeToTray);
expect(loaded.collectMkvToLibrary).toBe(defaults.collectMkvToLibrary);
expect(loaded.mkvLibraryDir).toBe(defaults.mkvLibraryDir);
expect(loaded.theme).toBe(defaults.theme); expect(loaded.theme).toBe(defaults.theme);
expect(loaded.bandwidthSchedules).toEqual(defaults.bandwidthSchedules); expect(loaded.bandwidthSchedules).toEqual(defaults.bandwidthSchedules);
expect(loaded.updateRepo).toBe(defaults.updateRepo); expect(loaded.updateRepo).toBe(defaults.updateRepo);