From 77661389f3657f1c0aa4fd6b9567a85af0758a54 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Sun, 7 Jun 2026 21:17:26 +0200 Subject: [PATCH] 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 --- src/main/constants.ts | 2 + src/main/download-manager.ts | 140 +++++++- src/main/storage.ts | 2 + src/main/video-processor.ts | 445 +++++++++++++++++++++++++ src/renderer/App.tsx | 7 +- src/shared/types.ts | 2 + tasks/plan-german-audio-track.md | 104 ++++++ tests/german-audio-integration.test.ts | 144 ++++++++ tests/video-processor.test.ts | 241 +++++++++++++ 9 files changed, 1084 insertions(+), 3 deletions(-) create mode 100644 src/main/video-processor.ts create mode 100644 tasks/plan-german-audio-track.md create mode 100644 tests/german-audio-integration.test.ts create mode 100644 tests/video-processor.test.ts diff --git a/src/main/constants.ts b/src/main/constants.ts index f37af15..615ae0f 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -72,6 +72,8 @@ export function defaultSettings(): AppSettings { packageName: "", autoExtract: true, autoRename4sf4sj: false, + keepGermanAudioOnly: false, + germanAudioMode: "tag", extractDir: path.join(baseDir, "_entpackt"), collectMkvToLibrary: false, mkvLibraryDir: path.join(baseDir, "_mkv"), diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 3e26c31..9dfb22c 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -54,6 +54,7 @@ import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, DebridService, Meg import { cleanupArchives, clearExtractResumeState, collectArchiveCleanupTargets, detectArchiveSignature, extractPackageArchives, findArchiveCandidates, hasAnyFilesRecursive, removeEmptyDirectoryTree, resetExtractorCachesForPasswordChange, type ExtractArchiveFailureInfo } from "./extractor"; import { validateFileAgainstManifest } from "./integrity"; import { classifyDiskError } from "./fs-error"; +import { processVideoFile, stripDualLangMarker, hasDualLangMarker, isRemuxableVideoFile, type GermanAudioMode, type VideoProcessResult } from "./video-processor"; import { logger } from "./logger"; import { getRecentRotationEvents, runWithRotationItemSink, setRotationEventListener } from "./account-rotation-log"; import type { RotationEvent } from "../shared/types"; @@ -2040,7 +2041,7 @@ export class DownloadManager extends EventEmitter { private logRenameProcess( pkg: PackageEntry, level: "INFO" | "WARN" | "ERROR", - stage: "auto-rename" | "mkv-move", + stage: "auto-rename" | "mkv-move" | "audio-strip", message: string, fields?: Record, item?: DownloadItem | null, @@ -3892,6 +3893,128 @@ export class DownloadManager extends EventEmitter { ); } + private async keepGermanAudioOnly( + extractDir: string, + pkg?: PackageEntry, + shouldAbort?: () => boolean, + signal?: AbortSignal + ): Promise { + if (!pkg) { + return this.keepGermanAudioOnlyImpl(extractDir, undefined, shouldAbort, signal); + } + return this.chainPackageFileOp(pkg.id, () => + this.keepGermanAudioOnlyImpl(extractDir, pkg, shouldAbort, signal) + ); + } + + // Post-extract step: for ".DL." (Dual-Language) MKV/MP4 files, keep only the + // German audio track and strip the ".DL." marker from the filename. Operates + // only inside pkg.extractDir, before MKV-collect. Best-effort per file; an + // error never fails the package. Original is never lost (see video-processor). + private async keepGermanAudioOnlyImpl( + extractDir: string, + pkg?: PackageEntry, + shouldAbort?: () => boolean, + signal?: AbortSignal + ): Promise { + if (!this.settings.keepGermanAudioOnly) { + return 0; + } + const mkvLibraryDir = String(this.settings.mkvLibraryDir || "").trim(); + if (mkvLibraryDir && (isPathInsideDir(mkvLibraryDir, extractDir) || isPathInsideDir(extractDir, mkvLibraryDir))) { + logger.warn(`Tonspur-Bereinigung ABGEBROCHEN: extractDir=${extractDir} ueberlappt mit mkvLibraryDir=${mkvLibraryDir}`); + return 0; + } + + const videoFiles = await this.collectVideoFiles(extractDir); + const sampleTokenRe = /(^|[._\-\s])sample([._\-\s]|$)/i; + const targets = videoFiles.filter((p) => { + const name = path.basename(p); + if (!isRemuxableVideoFile(name) || !hasDualLangMarker(name)) { + return false; + } + if (sampleTokenRe.test(name) || ["sample", "samples"].includes(path.basename(path.dirname(p)).toLowerCase())) { + return false; + } + return true; + }); + if (targets.length === 0) { + return 0; + } + if (pkg) { + this.logRenameProcess(pkg, "INFO", "audio-strip", "Tonspur-Bereinigung gestartet", { extractDir, candidates: targets.length }); + } + + const mode: GermanAudioMode = this.settings.germanAudioMode === "first" ? "first" : "tag"; + let processed = 0; + for (const sourcePath of targets) { + if (shouldAbort?.() || signal?.aborted) { + return processed; + } + const sourceName = path.basename(sourcePath); + let result: VideoProcessResult; + try { + result = await processVideoFile(sourcePath, { mode, cpuPriority: this.settings.extractCpuPriority, signal }); + } catch (error) { + result = { action: "error", reason: "exception", error: compactErrorText(error) }; + } + if (result.action === "aborted") { + return processed; + } + if (result.action === "skipped-no-tool") { + logger.warn("Tonspur-Bereinigung: ffmpeg/ffprobe nicht gefunden — Schritt uebersprungen (PATH oder RD_FFMPEG_BIN setzen)"); + if (pkg) { + this.logRenameProcess(pkg, "WARN", "audio-strip", "Tonspur-Bereinigung uebersprungen: ffmpeg/ffprobe fehlt", { sourceName }); + } + return processed; + } + if (pkg) { + const level = result.action === "error" ? "WARN" : "INFO"; + const resolved = this.inferItemForMediaLog(pkg, sourcePath, sourceName); + this.logRenameProcess(pkg, level, "audio-strip", `Tonspur: ${result.action} (${result.reason})`, { + sourceName, + keptTrack: result.keptTrackIndex, + audioTracks: result.totalAudioTracks, + ...(result.error ? { error: result.error } : {}) + }, resolved.item, resolved.matchedBy); + } + if (result.action === "remuxed") { + processed += 1; + } + // Only strip ".DL." once the file is confirmed German-only (remuxed) or + // already single-track. Skips/errors leave the file fully untouched so the + // unprocessed state stays visible. + if (result.action === "remuxed" || result.action === "kept-single") { + await this.stripDualLangFromFileName(sourcePath, pkg); + } + } + return processed; + } + + private async stripDualLangFromFileName(sourcePath: string, pkg?: PackageEntry): Promise { + const dir = path.dirname(sourcePath); + const name = path.basename(sourcePath); + const newName = stripDualLangMarker(name); + if (newName === name) { + return; + } + const targetPath = path.join(dir, newName); + if (pathKey(targetPath) !== pathKey(sourcePath) && await this.existsAsync(targetPath)) { + logger.warn(`.DL.-Strip uebersprungen (Ziel existiert): ${newName}`); + return; + } + try { + await this.renamePathWithExdevFallback(sourcePath, targetPath, { label: "audio-strip" }); + await this.renameCompanionFiles(sourcePath, targetPath, pkg); + if (pkg) { + const resolved = this.inferItemForMediaLog(pkg, targetPath, path.basename(targetPath)); + this.logRenameProcess(pkg, "INFO", "audio-strip", ".DL. aus Dateiname entfernt", { sourcePath, targetPath }, resolved.item, resolved.matchedBy); + } + } catch (error) { + logger.warn(`.DL.-Strip fehlgeschlagen (${name}): ${compactErrorText(error)}`); + } + } + private async autoRenameExtractedVideoFilesImpl( extractDir: string, pkg?: PackageEntry, @@ -4511,7 +4634,12 @@ export class DownloadManager extends EventEmitter { if (!cleanBase) { return null; } - const cleanFileName = `${cleanBase}${sourceExt}`; + // Safety net: collect derives names from folder tokens that may still carry + // ".DL.". When German-audio-only is on, never reintroduce the marker the + // audio step already stripped from the file. + const cleanFileName = this.settings.keepGermanAudioOnly + ? stripDualLangMarker(`${cleanBase}${sourceExt}`) + : `${cleanBase}${sourceExt}`; if (cleanFileName.toLowerCase() === path.basename(sourcePath).toLowerCase()) { return null; } @@ -7076,6 +7204,7 @@ export class DownloadManager extends EventEmitter { return false; } return this.settings.autoRename4sf4sj + || this.settings.keepGermanAudioOnly || this.settings.collectMkvToLibrary || this.settings.removeLinkFilesAfterExtract || this.settings.removeSamplesAfterExtract @@ -10942,6 +11071,7 @@ export class DownloadManager extends EventEmitter { try { await this.chainPackageFileOp(pkg.id, async () => { await this.autoRenameExtractedVideoFilesImpl(pkg.extractDir, pkg, hybridShouldAbort); + await this.keepGermanAudioOnlyImpl(pkg.extractDir, pkg, hybridShouldAbort, hybridController.signal); await this.collectMkvFilesToLibrary(packageId, pkg, hybridShouldAbort, true); }); } catch (err) { @@ -11612,6 +11742,12 @@ export class DownloadManager extends EventEmitter { }); throwIfAborted(); await this.autoRenameExtractedVideoFiles(pkg.extractDir, pkg, shouldAbort, true); + if (this.settings.keepGermanAudioOnly) { + pkg.postProcessLabel = "Tonspur..."; + this.emitState(); + throwIfAborted(); + await this.keepGermanAudioOnly(pkg.extractDir, pkg, shouldAbort, deferredController.signal); + } } if ((extractedCount > 0 || alreadyMarkedExtracted) && failed === 0 && this.settings.cleanupMode !== "none") { diff --git a/src/main/storage.ts b/src/main/storage.ts index 6c9135a..c3a59d1 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -421,6 +421,8 @@ export function normalizeSettings(settings: AppSettings): AppSettings { packageName: asText(settings.packageName), autoExtract: Boolean(settings.autoExtract), autoRename4sf4sj: Boolean(settings.autoRename4sf4sj), + keepGermanAudioOnly: Boolean(settings.keepGermanAudioOnly), + germanAudioMode: settings.germanAudioMode === "first" ? "first" : "tag", extractDir: normalizeAbsoluteDir(settings.extractDir, defaults.extractDir), collectMkvToLibrary: Boolean(settings.collectMkvToLibrary), mkvLibraryDir: normalizeAbsoluteDir(settings.mkvLibraryDir, defaults.mkvLibraryDir), diff --git a/src/main/video-processor.ts b/src/main/video-processor.ts new file mode 100644 index 0000000..523ccab --- /dev/null +++ b/src/main/video-processor.ts @@ -0,0 +1,445 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawn } from "node:child_process"; + +// Removes only-German audio handling for "Dual Language" (.DL.) scene releases. +// Mirrors the user's ffmpeg script but adds: language-tag detection (with safe +// fallbacks), disk-space pre-check, atomic temp->replace, mtime preservation, +// abort-into-child, and "never destroy the only usable audio" safety. +// +// The ffmpeg/ffprobe-specific logic lives here so it is mockable in isolation; +// the per-package iteration + filename/.DL. rename + logging stays in +// download-manager.ts (its existing domain). + +export type GermanAudioMode = "tag" | "first"; + +export interface ProbedAudioStream { + language: string; + title: string; +} + +export type AudioTrackDecision = + | { action: "remux"; audioRelIndex: number; reason: string } + | { action: "single"; audioRelIndex: 0; reason: string } + | { action: "skip"; reason: string }; + +export type VideoProcessAction = + | "remuxed" + | "kept-single" + | "skipped-no-german" + | "skipped-no-audio" + | "skipped-no-space" + | "skipped-no-tool" + | "error" + | "aborted"; + +export interface VideoProcessResult { + action: VideoProcessAction; + reason: string; + keptTrackIndex?: number; + totalAudioTracks?: number; + error?: string; +} + +export interface ProcessVideoOptions { + mode: GermanAudioMode; + cpuPriority?: string; + signal?: AbortSignal; +} + +// Injection seam so the irreversible file-mutating body (temp -> replace -> +// utimes -> rm-on-failure) can be exercised in tests with a fake ffmpeg/ffprobe +// runner, without spawning real processes. Production passes nothing. +export interface ProcessVideoDeps { + resolveTooling?: () => Promise<{ ffmpeg: string; ffprobe: string } | null>; + runProcess?: typeof runVideoProcess; +} + +const VIDEO_REMUX_EXTENSIONS = new Set([".mkv", ".mp4"]); +const PROBE_TIMEOUT_MS = 60_000; +const STDOUT_CAP = 2 * 1024 * 1024; +const STDERR_CAP = 64 * 1024; + +// --------------------------------------------------------------------------- +// Pure helpers (no fs / no process) — unit-tested in isolation. +// --------------------------------------------------------------------------- + +// "X.German.DL.720p.mkv" -> "X.German.720p.mkv"; "X.DL.mkv" -> "X.mkv". +export function stripDualLangMarker(fileName: string): string { + const ext = path.extname(fileName); + const base = ext ? fileName.slice(0, -ext.length) : fileName; + const stripped = base.replace(/\.DL\./gi, ".").replace(/\.DL$/i, ""); + return stripped + ext; +} + +export function hasDualLangMarker(fileName: string): boolean { + return stripDualLangMarker(fileName) !== fileName; +} + +export function isRemuxableVideoFile(fileName: string): boolean { + return VIDEO_REMUX_EXTENSIONS.has(path.extname(fileName).toLowerCase()); +} + +function isGermanStream(stream: ProbedAudioStream): boolean { + const lang = (stream.language || "").toLowerCase().trim(); + if (["ger", "deu", "de", "german", "deutsch"].includes(lang)) { + return true; + } + const title = (stream.title || "").toLowerCase(); + return /\b(german|deutsch|ger|deu)\b/.test(title); +} + +// Decide which audio track to keep. Safety invariant: only ever choose to remux +// (which destroys the original) when we are confident; otherwise skip untouched. +export function pickAudioTrack(streams: ProbedAudioStream[], mode: GermanAudioMode): AudioTrackDecision { + const total = streams.length; + if (total === 0) { + return { action: "skip", reason: "no-audio" }; + } + if (mode === "first") { + return total === 1 + ? { action: "single", audioRelIndex: 0, reason: "single-audio" } + : { action: "remux", audioRelIndex: 0, reason: "first-audio" }; + } + // tag mode + const germanPos = streams.findIndex(isGermanStream); + if (germanPos >= 0) { + return total === 1 + ? { action: "single", audioRelIndex: 0, reason: "single-german" } + : { action: "remux", audioRelIndex: germanPos, reason: "german-tag" }; + } + const anyTagged = streams.some((s) => (s.language || "").trim().length > 0); + if (!anyTagged) { + // No language metadata at all -> fall back to the script's behavior. + return total === 1 + ? { action: "single", audioRelIndex: 0, reason: "single-untagged" } + : { action: "remux", audioRelIndex: 0, reason: "fallback-first-untagged" }; + } + // Tagged, but no German track -> never guess-delete the only usable audio. + return { action: "skip", reason: "no-german-track" }; +} + +export function parseFfprobeAudioStreams(jsonText: string): ProbedAudioStream[] { + let parsed: unknown; + try { + parsed = JSON.parse(jsonText); + } catch { + return []; + } + const streams = (parsed as { streams?: unknown }).streams; + if (!Array.isArray(streams)) { + return []; + } + return streams.map((raw) => { + const tags = (raw && typeof raw === "object" ? (raw as { tags?: unknown }).tags : undefined) as + | { language?: unknown; title?: unknown } + | undefined; + return { + language: typeof tags?.language === "string" ? tags.language : "", + title: typeof tags?.title === "string" ? tags.title : "" + }; + }); +} + +export function buildFfprobeArgs(input: string): string[] { + return [ + "-v", "error", + "-select_streams", "a", + "-show_entries", "stream=index:stream_tags=language,title", + "-of", "json", + input + ]; +} + +export function buildFfmpegRemuxArgs(opts: { input: string; output: string; audioRelIndex: number; keepSubs?: boolean }): string[] { + const args = ["-i", opts.input, "-map", "0:v:0", "-map", `0:a:${opts.audioRelIndex}`]; + if (opts.keepSubs) { + // Optional (not enabled by current settings): keep German subtitle tracks only. + args.push("-map", "0:s:m:language:ger?", "-map", "0:s:m:language:deu?"); + } + // Stream-copy and keep metadata (so the kept track's language tag survives; + // unlike the original script's -map_metadata -1 which dropped it). + args.push("-c", "copy", "-disposition:a:0", "default", "-y", opts.output); + return args; +} + +// Stream-copy remux is disk-bound; generous budget scaled by size, clamped. +export function computeRemuxTimeoutMs(bytes: number): number { + const perBytes = Math.ceil((Number(bytes) || 0) / (10 * 1024 * 1024)) * 1000; + return Math.max(120_000, Math.min(60 * 60 * 1000, 120_000 + perBytes)); +} + +// --------------------------------------------------------------------------- +// Tooling discovery (system PATH + RD_FFMPEG_BIN/RD_FFPROBE_BIN env override). +// Lazy probe + cache, mirroring the extractor's 7z/Java resolution convention. +// --------------------------------------------------------------------------- + +interface VideoTooling { + ffmpeg: string; + ffprobe: string; +} + +let cachedTooling: VideoTooling | null | undefined; +let cachedToolingNullSince = 0; +const TOOLING_NULL_TTL_MS = 5 * 60 * 1000; + +function ffmpegCandidate(): string { + return String(process.env.RD_FFMPEG_BIN || "").trim() || "ffmpeg"; +} + +function ffprobeCandidate(): string { + return String(process.env.RD_FFPROBE_BIN || "").trim() || "ffprobe"; +} + +async function probeVersion(command: string): Promise { + const result = await runVideoProcess(command, ["-version"], { timeoutMs: 10_000 }); + return result.ok && !result.missing; +} + +export async function resolveVideoTooling(): Promise { + if (cachedTooling) { + return cachedTooling; + } + if (cachedTooling === null && Date.now() - cachedToolingNullSince < TOOLING_NULL_TTL_MS) { + return null; + } + const ffmpeg = ffmpegCandidate(); + const ffprobe = ffprobeCandidate(); + const [ffmpegOk, ffprobeOk] = await Promise.all([probeVersion(ffmpeg), probeVersion(ffprobe)]); + if (ffmpegOk && ffprobeOk) { + cachedTooling = { ffmpeg, ffprobe }; + return cachedTooling; + } + cachedTooling = null; + cachedToolingNullSince = Date.now(); + return null; +} + +export function resetVideoToolingCache(): void { + cachedTooling = undefined; + cachedToolingNullSince = 0; +} + +// --------------------------------------------------------------------------- +// Process spawning (ffmpeg/ffprobe). ffmpeg/ffprobe exit conventions: 0 = ok, +// anything else = real failure (NOT 7-Zip's "exit 1 = warning" semantics). +// --------------------------------------------------------------------------- + +export interface VideoSpawnResult { + ok: boolean; + aborted: boolean; + timedOut: boolean; + missing: boolean; + exitCode: number | null; + stdout: string; + stderr: string; +} + +function appendCapped(buffer: string, text: string, cap: number): string { + const next = buffer + text; + return next.length > cap ? next.slice(next.length - cap) : next; +} + +function applyChildPriority(pid: number | undefined, cpuPriority?: string): void { + if (process.platform !== "win32") { + return; + } + const numeric = Number(pid || 0); + if (!Number.isFinite(numeric) || numeric <= 0) { + return; + } + try { + const level = cpuPriority === "high" ? os.constants.priority.PRIORITY_NORMAL : os.constants.priority.PRIORITY_BELOW_NORMAL; + os.setPriority(numeric, level); + } catch { + } +} + +function killChildTree(child: { pid?: number; kill: () => void }): void { + const pid = Number(child.pid || 0); + if (process.platform === "win32" && Number.isFinite(pid) && pid > 0) { + try { + const killer = spawn("taskkill", ["/PID", String(pid), "/T", "/F"], { windowsHide: true, stdio: "ignore" }); + killer.on("error", () => { try { child.kill(); } catch {} }); + return; + } catch { + } + } + try { + child.kill(); + } catch { + } +} + +export function runVideoProcess( + command: string, + args: string[], + opts: { signal?: AbortSignal; timeoutMs?: number; cpuPriority?: string } = {} +): Promise { + const { signal, timeoutMs, cpuPriority } = opts; + if (signal?.aborted) { + return Promise.resolve({ ok: false, aborted: true, timedOut: false, missing: false, exitCode: null, stdout: "", stderr: "" }); + } + return new Promise((resolve) => { + let settled = false; + let stdout = ""; + let stderr = ""; + let timedOut = false; + let aborted = false; + let timeoutId: NodeJS.Timeout | null = null; + + const child = spawn(command, args, { windowsHide: true }); + applyChildPriority(child.pid, cpuPriority); + + const onAbort = (): void => { + aborted = true; + killChildTree(child); + }; + + const finish = (result: VideoSpawnResult): void => { + if (settled) { + return; + } + settled = true; + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = null; + } + if (signal) { + signal.removeEventListener("abort", onAbort); + } + resolve(result); + }; + + if (timeoutMs && timeoutMs > 0) { + timeoutId = setTimeout(() => { + timedOut = true; + killChildTree(child); + finish({ ok: false, aborted: false, timedOut: true, missing: false, exitCode: null, stdout, stderr }); + }, timeoutMs); + } + if (signal) { + signal.addEventListener("abort", onAbort, { once: true }); + } + + child.stdout?.on("data", (chunk) => { stdout = appendCapped(stdout, String(chunk || ""), STDOUT_CAP); }); + child.stderr?.on("data", (chunk) => { stderr = appendCapped(stderr, String(chunk || ""), STDERR_CAP); }); + + child.on("error", (error) => { + const text = String(error || ""); + finish({ ok: false, aborted: false, timedOut: false, missing: text.toLowerCase().includes("enoent"), exitCode: null, stdout, stderr: stderr || text }); + }); + + child.on("close", (code) => { + if (aborted) { + finish({ ok: false, aborted: true, timedOut: false, missing: false, exitCode: code, stdout, stderr }); + return; + } + if (timedOut) { + finish({ ok: false, aborted: false, timedOut: true, missing: false, exitCode: code, stdout, stderr }); + return; + } + finish({ ok: code === 0, aborted: false, timedOut: false, missing: false, exitCode: code, stdout, stderr }); + }); + }); +} + +// --------------------------------------------------------------------------- +// Per-file orchestration: probe -> decide -> (disk check) -> remux -> atomic +// replace -> preserve mtime. Operates IN PLACE (same filename); the .DL. rename +// + companion handling + logging is done by the caller (download-manager). +// --------------------------------------------------------------------------- + +async function getFreeSpaceBytes(dir: string): Promise { + try { + const stat = await fs.promises.statfs(dir); + return Number(stat.bavail) * Number(stat.bsize); + } catch { + return null; + } +} + +export async function processVideoFile(filePath: string, opts: ProcessVideoOptions, deps: ProcessVideoDeps = {}): Promise { + const resolveTool = deps.resolveTooling || resolveVideoTooling; + const run = deps.runProcess || runVideoProcess; + if (opts.signal?.aborted) { + return { action: "aborted", reason: "aborted" }; + } + const tooling = await resolveTool(); + if (!tooling) { + return { action: "skipped-no-tool", reason: "ffmpeg/ffprobe nicht gefunden (PATH oder RD_FFMPEG_BIN)" }; + } + + const probe = await run(tooling.ffprobe, buildFfprobeArgs(filePath), { signal: opts.signal, timeoutMs: PROBE_TIMEOUT_MS }); + if (probe.aborted) { + return { action: "aborted", reason: "aborted" }; + } + if (!probe.ok) { + return { action: "error", reason: "ffprobe fehlgeschlagen", error: probe.stderr || `exit ${String(probe.exitCode)}` }; + } + + const streams = parseFfprobeAudioStreams(probe.stdout); + const decision = pickAudioTrack(streams, opts.mode); + if (decision.action === "skip") { + return { + action: decision.reason === "no-german-track" ? "skipped-no-german" : "skipped-no-audio", + reason: decision.reason, + totalAudioTracks: streams.length + }; + } + if (decision.action === "single") { + return { action: "kept-single", reason: decision.reason, totalAudioTracks: streams.length, keptTrackIndex: 0 }; + } + + // remux path + let originalStat: fs.Stats; + try { + originalStat = await fs.promises.stat(filePath); + } catch (error) { + return { action: "error", reason: "stat fehlgeschlagen", error: String(error) }; + } + const free = await getFreeSpaceBytes(path.dirname(filePath)); + if (free !== null && free < Math.ceil(originalStat.size * 1.05)) { + return { action: "skipped-no-space", reason: "zu wenig freier Speicher fuer Remux", totalAudioTracks: streams.length }; + } + + const ext = path.extname(filePath); + const tempPath = `${filePath}.gertmp${ext}`; + await fs.promises.rm(tempPath, { force: true }).catch(() => {}); + + const remux = await run( + tooling.ffmpeg, + buildFfmpegRemuxArgs({ input: filePath, output: tempPath, audioRelIndex: decision.audioRelIndex, keepSubs: false }), + { signal: opts.signal, timeoutMs: computeRemuxTimeoutMs(originalStat.size), cpuPriority: opts.cpuPriority } + ); + if (remux.aborted) { + await fs.promises.rm(tempPath, { force: true }).catch(() => {}); + return { action: "aborted", reason: "aborted" }; + } + if (!remux.ok) { + await fs.promises.rm(tempPath, { force: true }).catch(() => {}); + return { action: "error", reason: "ffmpeg remux fehlgeschlagen", error: remux.stderr || `exit ${String(remux.exitCode)}`, totalAudioTracks: streams.length }; + } + + const tempStat = await fs.promises.stat(tempPath).catch(() => null); + if (!tempStat || tempStat.size <= 0) { + await fs.promises.rm(tempPath, { force: true }).catch(() => {}); + return { action: "error", reason: "Remux ergab leere Datei", totalAudioTracks: streams.length }; + } + + try { + // libuv rename replaces an existing destination on Windows; fall back if not. + await fs.promises.rename(tempPath, filePath).catch(async () => { + await fs.promises.rm(filePath, { force: true }); + await fs.promises.rename(tempPath, filePath); + }); + // Preserve original mtime so freshness gates (hybrid collect) don't skip it. + await fs.promises.utimes(filePath, originalStat.atime, originalStat.mtime).catch(() => {}); + } catch (error) { + await fs.promises.rm(tempPath, { force: true }).catch(() => {}); + return { action: "error", reason: "Ersetzen der Datei fehlgeschlagen", error: String(error), totalAudioTracks: streams.length }; + } + + return { action: "remuxed", reason: decision.reason, keptTrackIndex: decision.audioRelIndex, totalAudioTracks: streams.length }; +} diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 8004a11..32d04a8 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -844,7 +844,7 @@ const emptySnapshot = (): UiSnapshot => ({ archivePasswordList: "", rememberToken: true, providerOrder: [], providerPrimary: "realdebrid", providerSecondary: "none", providerTertiary: "none", autoProviderFallback: true, outputDir: "", packageName: "", - autoExtract: true, autoRename4sf4sj: false, extractDir: "", createExtractSubfolder: true, hybridExtract: true, + autoExtract: true, autoRename4sf4sj: false, keepGermanAudioOnly: false, germanAudioMode: "tag", extractDir: "", createExtractSubfolder: true, hybridExtract: true, collectMkvToLibrary: false, mkvLibraryDir: "", cleanupMode: "none", extractConflictMode: "overwrite", removeLinkFilesAfterExtract: false, removeSamplesAfterExtract: false, enableIntegrityCheck: true, autoResumeOnStart: true, @@ -5372,6 +5372,11 @@ export function App(): ReactElement { + +
diff --git a/src/shared/types.ts b/src/shared/types.ts index 371e881..afaae60 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -97,6 +97,8 @@ export interface AppSettings { packageName: string; autoExtract: boolean; autoRename4sf4sj: boolean; + keepGermanAudioOnly: boolean; + germanAudioMode: "tag" | "first"; extractDir: string; collectMkvToLibrary: boolean; mkvLibraryDir: string; diff --git a/tasks/plan-german-audio-track.md b/tasks/plan-german-audio-track.md new file mode 100644 index 0000000..f506888 --- /dev/null +++ b/tasks/plan-german-audio-track.md @@ -0,0 +1,104 @@ +# Plan: „Nur deutsche Tonspur behalten" (.DL.) als Tool-Funktion + +Quelle der Idee: User-Script `Remove Non German Audio.py` (ffmpeg `-map 0:v:0 -map 0:a:0 +-c copy -map_metadata -1`, + `.DL.`→`.` Rename). Soll als **toggle­barer Post-Extract-Schritt** +nach jedem Entpacken laufen, nur für **MKV/MP4 mit `.DL.` im Namen** (Dual-Language), +und nur die **deutsche** Spur behalten. Fundiert per 6-Agent-Analyse + Advisor. + +## 1. Verhalten (Soll) +- Läuft automatisch nach dem Entpacken eines Pakets (wenn Toggle an), bevor MKV-Collect. +- Pro extrahierter Video-Datei mit `.DL.` im Namen (case-insensitive, nur .mkv/.mp4): + 1. Audiospuren prüfen → deutsche/erste Spur bestimmen (Modus = User-Entscheidung, s.u.). + 2. Wenn >1 Audiospur: remux (stream-copy, kein Re-Encode) → behält Video + 1 Audio + (+ optional dt. Untertitel) → Temp-Datei → atomar ersetzen. + 3. `.DL.` aus dem Dateinamen strippen (`.DL.`→`.`, `.DL`→``), Companion-Dateien (Untertitel/.nfo) mitziehen. + 4. Wenn nur 1 Audiospur: **kein** Remux (spart Neuschreiben großer Dateien), ABER `.DL.`-Strip trotzdem. +- Status pro Item sichtbar (z.B. „Tonspur wird bereinigt" / „Deutsche Spur behalten"). + +## 2. Architektur +- **NEUES Modul `src/main/video-processor.ts`** (spiegelt `extractor.ts`: exportierte async-Funktion + + Options-Bag, KEINE DI-Klasse — es gibt keinen Constructor-Seam). Enthält: + - ffmpeg/ffprobe-Spawn nach dem `runExtractCommand`-Muster (extractor.ts:1296): `spawn(cmd,args,{windowsHide:true})`, + Promise-Wrapper, Timeout-Watchdog → `killProcessTree` (taskkill /T /F), **AbortSignal IN den Child** geben. + - **Pure exportierte Helfer** für Unit-Tests: `pickGermanAudioTrack(probeJson, mode)`, `stripDualLangMarker(name)`, + `buildFfmpegRemuxArgs(...)`, `computeRemuxTimeoutMs(bytes)`. + - ffmpeg-Exit-Codes ≠ 7-Zip (NICHT die „exit 1 = ok"-Logik kopieren — nur das Spawn/Await/Kill-Gerüst). + - ffprobe-JSON auf stdout NICHT durch den 48KB-Tail-Cap (`appendLimited`) — stdout separat voll puffern. +- **ffmpeg-Discovery (Option a, empfohlen):** System-PATH + `RD_FFMPEG_BIN` env + lazy `ffmpeg -version`-Probe + gecacht (spiegelt `RD_7Z_BIN`, extractor.ts:1030-1083). **Nicht bündeln** (~80-150MB → triggert den + eigenen 150MB-Large-Bundle-Selfcheck debug-setup.ts:22 + GPL-Lizenzpflicht). Wenn ffmpeg fehlt → Schritt + überspringen + WARN loggen + (optional) in Health-Check/Errors surfacen. NIE Downloads blockieren. +- **CPU-Priorität:** `lowerExtractProcessPriority(pid, priority)` + `extractOsPriority` wiederverwenden, + Priorität als **expliziten Param** (nicht das Modul-Global `currentExtractCpuPriority` — Cross-Talk-Gefahr). + Honoriert `settings.extractCpuPriority`. + +## 3. Einhängepunkte (BEIDE Pfade — kritisch!) +Post-Processing ist **pro Paket**, zwei Pfade; Hybrid-Pakete durchlaufen NIE den Deferred-Pass: +- **Deferred** (download-manager.ts ~11614): nach `autoRenameExtractedVideoFiles`, VOR archive-cleanup/collect. +- **Hybrid** (download-manager.ts ~10944): zwischen Rename und Collect im detached Block. +- Beide: **innerhalb `chainPackageFileOp(pkg.id, ...)`** (serialisiert Datei-Ops pro Paket), nur auf + `pkg.extractDir` operieren — NIE im geteilten `mkvLibraryDir` (= der v1.7.107-revertierte Cross-Package-Crash; + autoRename bricht bei Overlap ab, 3905-3919). +- **Gate:** neuen Flag in den Post-Process-Aggregator OR-en (~7078-7084), sonst läuft der Schritt nie + standalone. Hängt inhärent an `autoExtract` (braucht entpackte Dateien). +- Datei-Enumeration: `collectVideoFiles(rootDir)` (rekursiv, SAMPLE_VIDEO_EXTENSIONS, constants.ts:28) — nur + .mkv/.mp4 verarbeiten; Sample/Bonus-Dateien per vorhandenem Skip-Prädikat auslassen. + +## 4. Der .DL.-Knoten (LÖST den „Feature no-op"-Fehler) +- Selektion = „Datei hat `.DL.`"; der Schritt strippt `.DL.`. → KEIN früherer Schritt darf den Marker entfernen. +- **autoRename NICHT ändern** (behält `.DL.` verbatim) → Marker überlebt bis zum Video-Schritt. +- Video-Schritt läuft **nach** autoRename → sieht `.DL.` → remuxt + strippt `.DL.` atomar pro Datei. +- **NUR `collectMkvFilesToLibrary.deriveCleanCollectFileName`** bekommt den `.DL.`-Strip als Post-Transform + (läuft NACH dem Video-Schritt → kann den Selektor nicht brechen, verhindert nur Re-Einführung aus dem + Ordner-Token). Companion-Files via `renameCompanionFiles`/`moveCompanionFiles` mitziehen. + +## 5. Sicherheitsmodell (Original NIE verlieren) +- Remux → Temp-Datei → Größe > 0 (idealerweise ~plausibel) prüfen → erst dann atomar ersetzen/umbenennen + (`renamePathWithExdevFallback` + `verifyRenameAsync`). ffmpeg-Fehler/Abbruch → Temp löschen, Original bleibt. +- **Disk-Space-Pre-Check**: vor Remux freien Platz ≥ Dateigröße (+Marge) prüfen, sonst skip+log + (Temp verdoppelt transient den Platz auf einer Platte, die grad entpackt hat / parallel lädt). +- **AbortSignal in den ffmpeg-Child** (Deferred-/Hybrid-Controller) → Stop/Cancel/Reset killt laufenden Remux. +- **mtime erhalten** (`fs.utimes` nach Remux) → sonst überspringt Hybrid-Collect (deferFreshFiles=true) die + frisch angefasste Datei. +- **Sicherheits-Invariante (BEIDE Modi):** Original nur ersetzen, wenn die behaltene Spur sicher die richtige + ist. Bei Unsicherheit (keine Tags / kein Deutsch gefunden) → Datei UNANGETASTET lassen + loggen, statt + versehentlich die einzige brauchbare Spur zu löschen. +- Dispositions-Flag der behaltenen Spur auf „default" setzen. +- Best-effort pro Datei: ein Fehler markiert NICHT das Paket als failed und blockiert nicht den Collect anderer Dateien. + +## 6. ffmpeg/ffprobe-Aufrufe (Stream-Copy, schnell) +- Probe (nur im Tag-Modus): `ffprobe -v error -select_streams a -show_entries stream=index:stream_tags=language,title -of json INPUT` +- Remux erste Spur (Script-Parität): `ffmpeg -i INPUT -map 0:v:0 -map 0:a:0 [-map 0:s? je nach Untertitel-Option] -c copy -map_metadata -1 -disposition:a:0 default -y TEMP` +- Remux deutsche Spur (Tag-Modus): `-map 0:v:0 -map 0:a: ...` (Index aus ffprobe). + +## 7. Settings/UI-Wiring (5 Pflicht-Stellen, +1 optional) +1. `src/shared/types.ts` AppSettings: `keepGermanAudioOnly: boolean` (+ ggf. `germanAudioMode`, `keepGermanSubs`, `ffmpegPath`). +2. `src/main/constants.ts` defaultSettings: `keepGermanAudioOnly: false` etc. +3. `src/main/storage.ts` normalizeSettings: `Boolean(...)` (Pfad: `asText`, NICHT normalizeAbsoluteDir → leer = System-ffmpeg). +4. `src/renderer/App.tsx` Settings-Tab „entpacken" neben collectMkvToLibrary: Toggle + eingerückte Sub-Optionen (disabled wenn aus). +5. `src/renderer/App.tsx` **emptySnapshot()-Literal** (~840-859) — sonst tsc-Fehler (Feld non-optional). +6. (optional) `src/main/support-data.ts` ~95: Flag in Diagnose-Export spiegeln. + +## 8. Tests + Verifikations-Gate +- ffmpeg in Tests **gemockt** (kein echter ffmpeg-Lauf): neues Modul via `vi.mock` in download-manager.test.ts + (assert: korrekt aufgerufen + Sequenz nach autoRename / vor collect, Deferred + Hybrid). KEIN blankes + `vi.mock("node:child_process")` in download-manager.test.ts (bricht echte Extractor-ZIP-Tests). +- Separate `video-processor.test.ts`: `node:child_process` mocken → ffmpeg/ffprobe-ARGS asserten (Track-Wahl, Untertitel-Option). +- Pure Helfer fs-frei testen (wie tests/auto-rename.test.ts): `pickGermanAudioTrack`, `stripDualLangMarker`. +- Negativ-Test: Toggle aus → keine Verarbeitung. Edge: 1-Audio-`.DL.` → nur Rename, kein Remux. Kein-Deutsch → unangetastet. +- **Gate:** tsc-Baseline = 6 vorbestehende Fehler (NICHT clean) → „keine NEUEN tsc-Fehler" + vitest 728→728+N grün + `npm run self-check` grün. + +## 9. OFFENE ENTSCHEIDUNGEN (vor Bau — per AskUserQuestion) +- **A. Spurauswahl:** Script-Parität (immer erste Audiospur, kein ffprobe, validiertes Verhalten) vs. + Smart (deutsche Spur per Sprach-Tag, Fallback erste Spur, skip wenn kein Deutsch). +- **B. Untertitel:** weglassen (wie Script) vs. deutsche Untertitel behalten. +- **C. ffmpeg-Quelle:** nur System-PATH + `RD_FFMPEG_BIN` env vs. zusätzlich Settings-Pfad-Feld im UI. + +## 10. Umsetzungsreihenfolge (nach Entscheidungen) +1. `video-processor.ts` + pure Helfer + deren Unit-Tests (TDD). +2. ffmpeg/ffprobe-Discovery (probe+cache). +3. Settings-Wiring (5 Stellen) + UI-Toggle. +4. Einhängen in Deferred + Hybrid (in chainPackageFileOp), Gate OR-en. +5. collect deriveCleanCollectFileName: `.DL.`-Strip-Safety-Net. +6. Logging (logRenameProcess, neuer Stage 'audio-strip'). +7. Tests (download-manager mock + video-processor args + negativ/edge). Gate prüfen. diff --git a/tests/german-audio-integration.test.ts b/tests/german-audio-integration.test.ts new file mode 100644 index 0000000..cd8f028 --- /dev/null +++ b/tests/german-audio-integration.test.ts @@ -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(); + 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; +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 + }); +}); diff --git a/tests/video-processor.test.ts b/tests/video-processor.test.ts new file mode 100644 index 0000000..6d5ce96 --- /dev/null +++ b/tests/video-processor.test.ts @@ -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 => { + 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"); + }); +});