Add "keep only German audio" post-extract step for .DL. files
- New video-processor.ts: ffmpeg/ffprobe remux that keeps only the German audio track (by language tag, with safe fallbacks) and strips the ".DL." marker from the filename - Runs after extraction in both the deferred and hybrid post-process paths, inside the per-package file-op chain; abortable, disk-space checked, mtime-preserving, atomic temp->replace so the original is never lost - System ffmpeg via PATH / RD_FFMPEG_BIN; toggle + track-mode select in settings
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Mock only processVideoFile (the ffmpeg boundary); keep the real pure helpers
|
||||
// (stripDualLangMarker / hasDualLangMarker / isRemuxableVideoFile) so the
|
||||
// download-manager's selection + .DL.-rename wiring is exercised for real.
|
||||
vi.mock("../src/main/video-processor", async (importActual) => {
|
||||
const actual = await importActual<typeof import("../src/main/video-processor")>();
|
||||
return { ...actual, processVideoFile: vi.fn() };
|
||||
});
|
||||
|
||||
import { DownloadManager } from "../src/main/download-manager";
|
||||
import { defaultSettings } from "../src/main/constants";
|
||||
import { createStoragePaths, emptySession } from "../src/main/storage";
|
||||
import { shutdownItemLogs } from "../src/main/item-log";
|
||||
import { shutdownPackageLogs } from "../src/main/package-log";
|
||||
import { shutdownRenameLog } from "../src/main/rename-log";
|
||||
import { processVideoFile, type VideoProcessResult } from "../src/main/video-processor";
|
||||
|
||||
const mockedProcess = processVideoFile as unknown as ReturnType<typeof vi.fn>;
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
mockedProcess.mockReset();
|
||||
shutdownItemLogs();
|
||||
shutdownPackageLogs();
|
||||
shutdownRenameLog();
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
}
|
||||
});
|
||||
|
||||
function setup(keepGermanAudioOnly: boolean): { extractDir: string; manager: DownloadManager; pkg: any } {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-ga-"));
|
||||
tempDirs.push(root);
|
||||
const extractDir = path.join(root, "extract");
|
||||
const stateDir = path.join(root, "state");
|
||||
fs.mkdirSync(extractDir, { recursive: true });
|
||||
fs.mkdirSync(stateDir, { recursive: true });
|
||||
const manager = new DownloadManager(
|
||||
{
|
||||
...defaultSettings(),
|
||||
token: "rd-token",
|
||||
keepGermanAudioOnly,
|
||||
germanAudioMode: "tag",
|
||||
autoRename4sf4sj: false,
|
||||
outputDir: path.join(root, "out"),
|
||||
extractDir,
|
||||
mkvLibraryDir: path.join(stateDir, "_mkv")
|
||||
},
|
||||
emptySession(),
|
||||
createStoragePaths(stateDir)
|
||||
);
|
||||
const pkg: any = {
|
||||
id: "ga-pkg-1",
|
||||
name: "Test.Show.S01.GERMAN.DL.720p",
|
||||
outputDir: path.join(root, "out", "Test.Show"),
|
||||
extractDir,
|
||||
status: "completed",
|
||||
itemIds: [],
|
||||
cancelled: false,
|
||||
enabled: true,
|
||||
priority: "normal",
|
||||
createdAt: 0,
|
||||
updatedAt: 0
|
||||
};
|
||||
return { extractDir, manager, pkg };
|
||||
}
|
||||
|
||||
const DL_MKV = "Show.S01E01.German.DL.720p.x264.mkv";
|
||||
const PLAIN_MKV = "Show.S01E02.German.1080p.x264.mkv";
|
||||
const SAMPLE_DL = "Show.sample.DL.mkv";
|
||||
const DL_AVI = "Show.S01E03.German.DL.avi";
|
||||
|
||||
function stage(extractDir: string): void {
|
||||
for (const f of [DL_MKV, PLAIN_MKV, SAMPLE_DL, DL_AVI]) {
|
||||
fs.writeFileSync(path.join(extractDir, f), "x");
|
||||
}
|
||||
}
|
||||
|
||||
describe("keepGermanAudioOnly integration", () => {
|
||||
it("processes only .DL. mkv/mp4 and strips .DL. after a successful remux", async () => {
|
||||
const { extractDir, manager, pkg } = setup(true);
|
||||
stage(extractDir);
|
||||
mockedProcess.mockResolvedValue({ action: "remuxed", reason: "german-tag", totalAudioTracks: 2, keptTrackIndex: 0 } as VideoProcessResult);
|
||||
|
||||
const n = await (manager as any).keepGermanAudioOnlyImpl(extractDir, pkg);
|
||||
|
||||
expect(mockedProcess).toHaveBeenCalledTimes(1);
|
||||
expect(mockedProcess.mock.calls[0][0]).toBe(path.join(extractDir, DL_MKV));
|
||||
expect(n).toBe(1);
|
||||
|
||||
const files = fs.readdirSync(extractDir);
|
||||
expect(files).toContain("Show.S01E01.German.720p.x264.mkv"); // .DL. stripped
|
||||
expect(files).not.toContain(DL_MKV);
|
||||
expect(files).toContain(PLAIN_MKV); // non-.DL. untouched
|
||||
expect(files).toContain(SAMPLE_DL); // sample skipped
|
||||
expect(files).toContain(DL_AVI); // avi not remuxable, skipped
|
||||
});
|
||||
|
||||
it("does nothing when the setting is off", async () => {
|
||||
const { extractDir, manager, pkg } = setup(false);
|
||||
stage(extractDir);
|
||||
const n = await (manager as any).keepGermanAudioOnlyImpl(extractDir, pkg);
|
||||
expect(n).toBe(0);
|
||||
expect(mockedProcess).not.toHaveBeenCalled();
|
||||
expect(fs.readdirSync(extractDir)).toContain(DL_MKV); // untouched
|
||||
});
|
||||
|
||||
it("leaves the file fully untouched (name included) when no German track is found", async () => {
|
||||
const { extractDir, manager, pkg } = setup(true);
|
||||
stage(extractDir);
|
||||
mockedProcess.mockResolvedValue({ action: "skipped-no-german", reason: "no-german-track", totalAudioTracks: 2 } as VideoProcessResult);
|
||||
|
||||
await (manager as any).keepGermanAudioOnlyImpl(extractDir, pkg);
|
||||
|
||||
expect(mockedProcess).toHaveBeenCalledTimes(1);
|
||||
expect(fs.readdirSync(extractDir)).toContain(DL_MKV); // NOT renamed -> stays visible as unprocessed
|
||||
});
|
||||
|
||||
it("still strips .DL. for a single-audio file (no remux needed)", async () => {
|
||||
const { extractDir, manager, pkg } = setup(true);
|
||||
stage(extractDir);
|
||||
mockedProcess.mockResolvedValue({ action: "kept-single", reason: "single-german", totalAudioTracks: 1, keptTrackIndex: 0 } as VideoProcessResult);
|
||||
|
||||
const n = await (manager as any).keepGermanAudioOnlyImpl(extractDir, pkg);
|
||||
|
||||
expect(n).toBe(0); // not counted as a remux
|
||||
expect(fs.readdirSync(extractDir)).toContain("Show.S01E01.German.720p.x264.mkv");
|
||||
});
|
||||
|
||||
it("stops the run and leaves files untouched when ffmpeg is missing", async () => {
|
||||
const { extractDir, manager, pkg } = setup(true);
|
||||
stage(extractDir);
|
||||
mockedProcess.mockResolvedValue({ action: "skipped-no-tool", reason: "ffmpeg/ffprobe nicht gefunden" } as VideoProcessResult);
|
||||
|
||||
const n = await (manager as any).keepGermanAudioOnlyImpl(extractDir, pkg);
|
||||
|
||||
expect(n).toBe(0);
|
||||
expect(fs.readdirSync(extractDir)).toContain(DL_MKV); // untouched
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,241 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
stripDualLangMarker,
|
||||
hasDualLangMarker,
|
||||
isRemuxableVideoFile,
|
||||
pickAudioTrack,
|
||||
parseFfprobeAudioStreams,
|
||||
buildFfprobeArgs,
|
||||
buildFfmpegRemuxArgs,
|
||||
computeRemuxTimeoutMs,
|
||||
processVideoFile,
|
||||
type VideoSpawnResult
|
||||
} from "../src/main/video-processor";
|
||||
|
||||
describe("stripDualLangMarker", () => {
|
||||
it("strips a mid-name .DL. token", () => {
|
||||
expect(stripDualLangMarker("Show.S01E01.German.DL.720p.WEB.x264.mkv")).toBe("Show.S01E01.German.720p.WEB.x264.mkv");
|
||||
});
|
||||
it("strips a .DL. directly before the extension", () => {
|
||||
expect(stripDualLangMarker("Movie.DL.mkv")).toBe("Movie.mkv");
|
||||
});
|
||||
it("strips a trailing .DL token before extension", () => {
|
||||
expect(stripDualLangMarker("Movie.German.DL.mp4")).toBe("Movie.German.mp4");
|
||||
});
|
||||
it("is case-insensitive", () => {
|
||||
expect(stripDualLangMarker("Show.dl.1080p.mkv")).toBe("Show.1080p.mkv");
|
||||
});
|
||||
it("leaves files without the marker unchanged", () => {
|
||||
expect(stripDualLangMarker("Show.S01E01.German.1080p.mkv")).toBe("Show.S01E01.German.1080p.mkv");
|
||||
});
|
||||
it("does not strip unrelated tokens containing DL", () => {
|
||||
expect(stripDualLangMarker("Show.HANDLES.1080p.mkv")).toBe("Show.HANDLES.1080p.mkv");
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasDualLangMarker", () => {
|
||||
it("detects the marker", () => {
|
||||
expect(hasDualLangMarker("X.German.DL.720p.mkv")).toBe(true);
|
||||
expect(hasDualLangMarker("X.DL.mkv")).toBe(true);
|
||||
});
|
||||
it("returns false without the marker", () => {
|
||||
expect(hasDualLangMarker("X.German.720p.mkv")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isRemuxableVideoFile", () => {
|
||||
it("accepts mkv/mp4 only", () => {
|
||||
expect(isRemuxableVideoFile("a.mkv")).toBe(true);
|
||||
expect(isRemuxableVideoFile("a.MP4")).toBe(true);
|
||||
expect(isRemuxableVideoFile("a.avi")).toBe(false);
|
||||
expect(isRemuxableVideoFile("a.srt")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pickAudioTrack", () => {
|
||||
const ger = { language: "ger", title: "" };
|
||||
const eng = { language: "eng", title: "" };
|
||||
const untagged = { language: "", title: "" };
|
||||
|
||||
it("no audio -> skip", () => {
|
||||
expect(pickAudioTrack([], "tag").action).toBe("skip");
|
||||
});
|
||||
|
||||
it("first mode keeps first of many", () => {
|
||||
const d = pickAudioTrack([eng, ger], "first");
|
||||
expect(d).toMatchObject({ action: "remux", audioRelIndex: 0 });
|
||||
});
|
||||
|
||||
it("first mode with single audio -> single (no remux)", () => {
|
||||
expect(pickAudioTrack([eng], "first")).toMatchObject({ action: "single" });
|
||||
});
|
||||
|
||||
it("tag mode picks the German track even if not first", () => {
|
||||
const d = pickAudioTrack([eng, ger], "tag");
|
||||
expect(d).toMatchObject({ action: "remux", audioRelIndex: 1, reason: "german-tag" });
|
||||
});
|
||||
|
||||
it("tag mode picks German via title when language untagged", () => {
|
||||
const d = pickAudioTrack([{ language: "", title: "Englisch" }, { language: "", title: "Deutsch" }], "tag");
|
||||
expect(d).toMatchObject({ action: "remux", audioRelIndex: 1 });
|
||||
});
|
||||
|
||||
it("tag mode with single German -> single (no remux)", () => {
|
||||
expect(pickAudioTrack([ger], "tag")).toMatchObject({ action: "single" });
|
||||
});
|
||||
|
||||
it("tag mode, fully untagged multi -> fallback to first", () => {
|
||||
const d = pickAudioTrack([untagged, untagged], "tag");
|
||||
expect(d).toMatchObject({ action: "remux", audioRelIndex: 0, reason: "fallback-first-untagged" });
|
||||
});
|
||||
|
||||
it("tag mode, tagged but no German -> SKIP (never delete the only usable audio)", () => {
|
||||
expect(pickAudioTrack([eng, { language: "fre", title: "" }], "tag")).toMatchObject({ action: "skip", reason: "no-german-track" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseFfprobeAudioStreams", () => {
|
||||
it("parses language/title tags", () => {
|
||||
const json = JSON.stringify({ streams: [{ index: 1, tags: { language: "ger", title: "Deutsch" } }, { index: 2, tags: { language: "eng" } }] });
|
||||
expect(parseFfprobeAudioStreams(json)).toEqual([{ language: "ger", title: "Deutsch" }, { language: "eng", title: "" }]);
|
||||
});
|
||||
it("returns [] on invalid json", () => {
|
||||
expect(parseFfprobeAudioStreams("not json")).toEqual([]);
|
||||
});
|
||||
it("returns [] when streams missing", () => {
|
||||
expect(parseFfprobeAudioStreams("{}")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildFfprobeArgs", () => {
|
||||
it("requests audio streams as json", () => {
|
||||
const args = buildFfprobeArgs("in.mkv");
|
||||
expect(args).toContain("-select_streams");
|
||||
expect(args).toContain("a");
|
||||
expect(args[args.length - 1]).toBe("in.mkv");
|
||||
expect(args).toContain("json");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildFfmpegRemuxArgs", () => {
|
||||
it("maps video + chosen audio, stream-copy, keeps metadata (language tag), no subs by default", () => {
|
||||
const args = buildFfmpegRemuxArgs({ input: "in.mkv", output: "out.mkv", audioRelIndex: 1 });
|
||||
expect(args).toEqual([
|
||||
"-i", "in.mkv", "-map", "0:v:0", "-map", "0:a:1",
|
||||
"-c", "copy", "-disposition:a:0", "default", "-y", "out.mkv"
|
||||
]);
|
||||
expect(args).not.toContain("-map_metadata"); // language tag of kept track must survive
|
||||
});
|
||||
it("adds optional German subtitle maps when keepSubs", () => {
|
||||
const args = buildFfmpegRemuxArgs({ input: "in.mkv", output: "out.mkv", audioRelIndex: 0, keepSubs: true });
|
||||
expect(args.join(" ")).toContain("0:s:m:language:ger?");
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeRemuxTimeoutMs", () => {
|
||||
it("has a floor", () => {
|
||||
expect(computeRemuxTimeoutMs(0)).toBe(120_000);
|
||||
});
|
||||
it("scales with size and caps at 60 min", () => {
|
||||
expect(computeRemuxTimeoutMs(50 * 1024 * 1024 * 1024)).toBe(60 * 60 * 1000);
|
||||
});
|
||||
});
|
||||
|
||||
// Exercises the REAL file-mutating body (temp -> replace -> utimes -> rm) with a
|
||||
// fake ffmpeg/ffprobe runner. This is the irreversible-overwrite path that the
|
||||
// download-manager integration test (which mocks processVideoFile wholesale)
|
||||
// cannot cover.
|
||||
describe("processVideoFile (real fs body, fake runner)", () => {
|
||||
const tempDirs: string[] = [];
|
||||
afterEach(() => {
|
||||
for (const d of tempDirs.splice(0)) {
|
||||
try { fs.rmSync(d, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
}
|
||||
});
|
||||
|
||||
function makeFile(content: string): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-vp-"));
|
||||
tempDirs.push(dir);
|
||||
const file = path.join(dir, "Show.S01E01.German.DL.720p.mkv");
|
||||
fs.writeFileSync(file, content);
|
||||
return file;
|
||||
}
|
||||
|
||||
function fakeRunner(opts: { probeJson: string; ffmpegOk?: boolean }): typeof import("../src/main/video-processor").runVideoProcess {
|
||||
return async (_command: string, args: string[]): Promise<VideoSpawnResult> => {
|
||||
const base = { aborted: false, timedOut: false, missing: false } as const;
|
||||
if (args.includes("-show_entries")) {
|
||||
return { ...base, ok: true, exitCode: 0, stdout: opts.probeJson, stderr: "" };
|
||||
}
|
||||
const output = args[args.length - 1];
|
||||
if (opts.ffmpegOk !== false) {
|
||||
fs.writeFileSync(output, "REMUXED-GERMAN-ONLY");
|
||||
return { ...base, ok: true, exitCode: 0, stdout: "", stderr: "" };
|
||||
}
|
||||
return { ...base, ok: false, exitCode: 1, stdout: "", stderr: "ffmpeg boom" };
|
||||
};
|
||||
}
|
||||
|
||||
const tooling = async (): Promise<{ ffmpeg: string; ffprobe: string }> => ({ ffmpeg: "ffmpeg", ffprobe: "ffprobe" });
|
||||
const twoTracksGerSecond = JSON.stringify({ streams: [{ tags: { language: "eng" } }, { tags: { language: "ger" } }] });
|
||||
|
||||
it("replaces the original in place and preserves mtime on success", async () => {
|
||||
const file = makeFile("ORIGINAL");
|
||||
const oldTime = new Date(Date.now() - 5 * 60 * 1000);
|
||||
fs.utimesSync(file, oldTime, oldTime);
|
||||
const beforeMtime = fs.statSync(file).mtimeMs;
|
||||
|
||||
const result = await processVideoFile(file, { mode: "tag" }, {
|
||||
resolveTooling: tooling,
|
||||
runProcess: fakeRunner({ probeJson: twoTracksGerSecond })
|
||||
});
|
||||
|
||||
expect(result.action).toBe("remuxed");
|
||||
expect(result.keptTrackIndex).toBe(1); // German was second
|
||||
expect(fs.readFileSync(file, "utf8")).toBe("REMUXED-GERMAN-ONLY"); // original overwritten
|
||||
expect(Math.abs(fs.statSync(file).mtimeMs - beforeMtime)).toBeLessThan(1500); // mtime preserved
|
||||
expect(fs.existsSync(`${file}.gertmp.mkv`)).toBe(false); // temp cleaned up
|
||||
});
|
||||
|
||||
it("leaves the original intact and removes temp when ffmpeg fails", async () => {
|
||||
const file = makeFile("ORIGINAL");
|
||||
const result = await processVideoFile(file, { mode: "tag" }, {
|
||||
resolveTooling: tooling,
|
||||
runProcess: fakeRunner({ probeJson: twoTracksGerSecond, ffmpegOk: false })
|
||||
});
|
||||
|
||||
expect(result.action).toBe("error");
|
||||
expect(fs.readFileSync(file, "utf8")).toBe("ORIGINAL"); // never lost
|
||||
expect(fs.existsSync(`${file}.gertmp.mkv`)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not touch a single-audio file (no remux)", async () => {
|
||||
const file = makeFile("ORIGINAL");
|
||||
const result = await processVideoFile(file, { mode: "tag" }, {
|
||||
resolveTooling: tooling,
|
||||
runProcess: fakeRunner({ probeJson: JSON.stringify({ streams: [{ tags: { language: "ger" } }] }) })
|
||||
});
|
||||
expect(result.action).toBe("kept-single");
|
||||
expect(fs.readFileSync(file, "utf8")).toBe("ORIGINAL");
|
||||
});
|
||||
|
||||
it("leaves the file untouched when tagged but no German track", async () => {
|
||||
const file = makeFile("ORIGINAL");
|
||||
const result = await processVideoFile(file, { mode: "tag" }, {
|
||||
resolveTooling: tooling,
|
||||
runProcess: fakeRunner({ probeJson: JSON.stringify({ streams: [{ tags: { language: "eng" } }, { tags: { language: "fre" } }] }) })
|
||||
});
|
||||
expect(result.action).toBe("skipped-no-german");
|
||||
expect(fs.readFileSync(file, "utf8")).toBe("ORIGINAL");
|
||||
});
|
||||
|
||||
it("returns skipped-no-tool when ffmpeg/ffprobe are absent", async () => {
|
||||
const file = makeFile("ORIGINAL");
|
||||
const result = await processVideoFile(file, { mode: "tag" }, { resolveTooling: async () => null });
|
||||
expect(result.action).toBe("skipped-no-tool");
|
||||
expect(fs.readFileSync(file, "utf8")).toBe("ORIGINAL");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user