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,
|
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",
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user