Add optional flat MKV library collection per package
This commit is contained in:
parent
71aa9204f4
commit
809aec69c2
@ -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",
|
||||
|
||||
@ -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>): string[] {
|
||||
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 [];
|
||||
}
|
||||
|
||||
@ -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>): 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 {
|
||||
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);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 {
|
||||
<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.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>
|
||||
<textarea
|
||||
className="password-list"
|
||||
|
||||
@ -50,6 +50,8 @@ export interface AppSettings {
|
||||
autoExtract: boolean;
|
||||
autoRename4sf4sj: boolean;
|
||||
extractDir: string;
|
||||
collectMkvToLibrary: boolean;
|
||||
mkvLibraryDir: string;
|
||||
createExtractSubfolder: boolean;
|
||||
hybridExtract: boolean;
|
||||
cleanupMode: CleanupMode;
|
||||
|
||||
@ -3998,6 +3998,78 @@ describe("download manager", () => {
|
||||
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", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||
tempDirs.push(root);
|
||||
|
||||
@ -84,6 +84,7 @@ describe("settings storage", () => {
|
||||
speedLimitKbps: -1,
|
||||
outputDir: " ",
|
||||
extractDir: " ",
|
||||
mkvLibraryDir: " ",
|
||||
updateRepo: " "
|
||||
});
|
||||
|
||||
@ -99,6 +100,7 @@ describe("settings storage", () => {
|
||||
expect(normalized.speedLimitKbps).toBe(0);
|
||||
expect(normalized.outputDir).toBe(defaultSettings().outputDir);
|
||||
expect(normalized.extractDir).toBe(defaultSettings().extractDir);
|
||||
expect(normalized.mkvLibraryDir).toBe(defaultSettings().mkvLibraryDir);
|
||||
expect(normalized.updateRepo).toBe(defaultSettings().updateRepo);
|
||||
});
|
||||
|
||||
@ -418,6 +420,8 @@ describe("settings storage", () => {
|
||||
expect(loaded.speedLimitMode).toBe(defaults.speedLimitMode);
|
||||
expect(loaded.clipboardWatch).toBe(defaults.clipboardWatch);
|
||||
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.bandwidthSchedules).toEqual(defaults.bandwidthSchedules);
|
||||
expect(loaded.updateRepo).toBe(defaults.updateRepo);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user