diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 9dfb22c..b05905e 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -54,7 +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 { processVideoFile, resolveVideoTooling, 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"; @@ -3941,12 +3941,26 @@ export class DownloadManager extends EventEmitter { if (targets.length === 0) { return 0; } + logger.info(`Tonspur-Bereinigung: ${targets.length} .DL.-Datei(en) in ${extractDir}, Modus=${this.settings.germanAudioMode}`); if (pkg) { - this.logRenameProcess(pkg, "INFO", "audio-strip", "Tonspur-Bereinigung gestartet", { extractDir, candidates: targets.length }); + this.logRenameProcess(pkg, "INFO", "audio-strip", "Tonspur-Bereinigung gestartet", { extractDir, candidates: targets.length, mode: this.settings.germanAudioMode }); } + // Resolve ffmpeg/ffprobe ONCE up front and log it loudly — a missing tool is + // the single most common reason the whole step silently does nothing. + const tooling = await resolveVideoTooling(); + if (!tooling) { + logger.warn(`Tonspur-Bereinigung: ffmpeg/ffprobe NICHT gefunden — Schritt uebersprungen, ${targets.length} Datei(en) unangetastet. ffmpeg in den PATH legen oder RD_FFMPEG_BIN/RD_FFPROBE_BIN setzen.`); + if (pkg) { + this.logRenameProcess(pkg, "WARN", "audio-strip", "Tonspur-Bereinigung uebersprungen: ffmpeg/ffprobe nicht gefunden", { candidates: targets.length }); + } + return 0; + } + logger.info(`Tonspur-Bereinigung: ffmpeg=${tooling.ffmpeg} ffprobe=${tooling.ffprobe}`); + const mode: GermanAudioMode = this.settings.germanAudioMode === "first" ? "first" : "tag"; let processed = 0; + let failed = 0; for (const sourcePath of targets) { if (shouldAbort?.() || signal?.aborted) { return processed; @@ -3961,13 +3975,7 @@ export class DownloadManager extends EventEmitter { 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; - } + const langs = (result.audioLanguages || []).join(","); if (pkg) { const level = result.action === "error" ? "WARN" : "INFO"; const resolved = this.inferItemForMediaLog(pkg, sourcePath, sourceName); @@ -3975,11 +3983,22 @@ export class DownloadManager extends EventEmitter { sourceName, keptTrack: result.keptTrackIndex, audioTracks: result.totalAudioTracks, + languages: langs || undefined, ...(result.error ? { error: result.error } : {}) }, resolved.item, resolved.matchedBy); } - if (result.action === "remuxed") { + // Per-file main-log lines so the cause of any unprocessed file is visible + // without opening the rename/item logs. + if (result.action === "error") { + failed += 1; + logger.warn(`Tonspur-Bereinigung FEHLER: ${sourceName} — ${result.reason}${result.error ? ` — ${result.error}` : ""} (Spuren: ${langs || "?"}, ${result.totalAudioTracks ?? "?"} Audio)`); + } else if (result.action === "remuxed") { processed += 1; + logger.info(`Tonspur-Bereinigung OK: ${sourceName} — Spur ${result.keptTrackIndex} behalten (${langs || "?"})`); + } else if (result.action === "skipped-no-german") { + logger.info(`Tonspur-Bereinigung uebersprungen (kein Deutsch-Tag, Spuren: ${langs || "?"}): ${sourceName}`); + } else if (result.action === "skipped-no-space") { + logger.warn(`Tonspur-Bereinigung uebersprungen (zu wenig Speicher): ${sourceName}`); } // Only strip ".DL." once the file is confirmed German-only (remuxed) or // already single-track. Skips/errors leave the file fully untouched so the @@ -3988,6 +4007,10 @@ export class DownloadManager extends EventEmitter { await this.stripDualLangFromFileName(sourcePath, pkg); } } + logger.info(`Tonspur-Bereinigung fertig: ${processed} verarbeitet, ${failed} Fehler von ${targets.length} Kandidaten in ${extractDir}`); + if (pkg) { + this.logRenameProcess(pkg, failed > 0 ? "WARN" : "INFO", "audio-strip", "Tonspur-Bereinigung fertig", { processed, failed, candidates: targets.length }); + } return processed; } diff --git a/src/main/video-processor.ts b/src/main/video-processor.ts index 523ccab..9f320c2 100644 --- a/src/main/video-processor.ts +++ b/src/main/video-processor.ts @@ -39,6 +39,7 @@ export interface VideoProcessResult { reason: string; keptTrackIndex?: number; totalAudioTracks?: number; + audioLanguages?: string[]; error?: string; } @@ -81,6 +82,15 @@ export function isRemuxableVideoFile(fileName: string): boolean { return VIDEO_REMUX_EXTENSIONS.has(path.extname(fileName).toLowerCase()); } +// True when the release name explicitly marks it as a German release. Used in +// tag mode to fall back to the first audio track (German-first scene convention) +// when the audio language tags are wrong (a German dub mislabeled "eng"), instead +// of skipping. Deliberately requires an explicit german/deutsch/dubbed token — +// the ".DL." marker alone (present on every processed file) is not enough. +export function looksLikeGermanRelease(fileName: string): boolean { + return /(^|[._\s-])(german|deutsch|dubbed)([._\s-]|$)/i.test(fileName); +} + function isGermanStream(stream: ProbedAudioStream): boolean { const lang = (stream.language || "").toLowerCase().trim(); if (["ger", "deu", "de", "german", "deutsch"].includes(lang)) { @@ -92,7 +102,7 @@ function isGermanStream(stream: ProbedAudioStream): boolean { // 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 { +export function pickAudioTrack(streams: ProbedAudioStream[], mode: GermanAudioMode, germanRelease = false): AudioTrackDecision { const total = streams.length; if (total === 0) { return { action: "skip", reason: "no-audio" }; @@ -116,7 +126,15 @@ export function pickAudioTrack(streams: ProbedAudioStream[], mode: GermanAudioMo ? { 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. + if (germanRelease) { + // Tagged, no German track found, but the release name explicitly says German + // -> the dub is mislabeled (German audio tagged "eng"). Trust the German-first + // scene convention rather than skipping. + return total === 1 + ? { action: "single", audioRelIndex: 0, reason: "single-german-mislabeled" } + : { action: "remux", audioRelIndex: 0, reason: "fallback-first-german-release" }; + } + // Tagged, no German track, and nothing says German -> never guess-delete. return { action: "skip", reason: "no-german-track" }; } @@ -380,16 +398,18 @@ export async function processVideoFile(filePath: string, opts: ProcessVideoOptio } const streams = parseFfprobeAudioStreams(probe.stdout); - const decision = pickAudioTrack(streams, opts.mode); + const audioLanguages = streams.map((s) => (s.language || "").trim() || "und"); + const decision = pickAudioTrack(streams, opts.mode, looksLikeGermanRelease(path.basename(filePath))); if (decision.action === "skip") { return { action: decision.reason === "no-german-track" ? "skipped-no-german" : "skipped-no-audio", reason: decision.reason, - totalAudioTracks: streams.length + totalAudioTracks: streams.length, + audioLanguages }; } if (decision.action === "single") { - return { action: "kept-single", reason: decision.reason, totalAudioTracks: streams.length, keptTrackIndex: 0 }; + return { action: "kept-single", reason: decision.reason, totalAudioTracks: streams.length, audioLanguages, keptTrackIndex: 0 }; } // remux path @@ -397,15 +417,18 @@ export async function processVideoFile(filePath: string, opts: ProcessVideoOptio try { originalStat = await fs.promises.stat(filePath); } catch (error) { - return { action: "error", reason: "stat fehlgeschlagen", error: String(error) }; + return { action: "error", reason: "stat fehlgeschlagen", error: String(error), audioLanguages }; } 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 }; + return { action: "skipped-no-space", reason: "zu wenig freier Speicher fuer Remux", totalAudioTracks: streams.length, audioLanguages }; } const ext = path.extname(filePath); - const tempPath = `${filePath}.gertmp${ext}`; + // Short, same-directory temp name (never longer than the original file name) so + // a long scene filename + temp suffix cannot push the temp path past Windows + // MAX_PATH and make ffmpeg fail (which would leave the file unprocessed). + const tempPath = path.join(path.dirname(filePath), `~rdtmp${ext}`); await fs.promises.rm(tempPath, { force: true }).catch(() => {}); const remux = await run( @@ -419,13 +442,13 @@ export async function processVideoFile(filePath: string, opts: ProcessVideoOptio } 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 }; + return { action: "error", reason: "ffmpeg remux fehlgeschlagen", error: remux.stderr || `exit ${String(remux.exitCode)}`, totalAudioTracks: streams.length, audioLanguages, keptTrackIndex: decision.audioRelIndex }; } 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 }; + return { action: "error", reason: "Remux ergab leere Datei", totalAudioTracks: streams.length, audioLanguages }; } try { @@ -438,8 +461,8 @@ export async function processVideoFile(filePath: string, opts: ProcessVideoOptio 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: "error", reason: "Ersetzen der Datei fehlgeschlagen", error: String(error), totalAudioTracks: streams.length, audioLanguages }; } - return { action: "remuxed", reason: decision.reason, keptTrackIndex: decision.audioRelIndex, totalAudioTracks: streams.length }; + return { action: "remuxed", reason: decision.reason, keptTrackIndex: decision.audioRelIndex, totalAudioTracks: streams.length, audioLanguages }; } diff --git a/tests/german-audio-integration.test.ts b/tests/german-audio-integration.test.ts index cd8f028..f5c1679 100644 --- a/tests/german-audio-integration.test.ts +++ b/tests/german-audio-integration.test.ts @@ -8,7 +8,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; // 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() }; + return { ...actual, processVideoFile: vi.fn(), resolveVideoTooling: vi.fn() }; }); import { DownloadManager } from "../src/main/download-manager"; @@ -17,13 +17,15 @@ 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"; +import { processVideoFile, resolveVideoTooling, type VideoProcessResult } from "../src/main/video-processor"; const mockedProcess = processVideoFile as unknown as ReturnType; +const mockedTooling = resolveVideoTooling as unknown as ReturnType; const tempDirs: string[] = []; afterEach(() => { mockedProcess.mockReset(); + mockedTooling.mockReset(); shutdownItemLogs(); shutdownPackageLogs(); shutdownRenameLog(); @@ -66,6 +68,9 @@ function setup(keepGermanAudioOnly: boolean): { extractDir: string; manager: Dow createdAt: 0, updatedAt: 0 }; + // Default: ffmpeg/ffprobe "available" so the step proceeds to the (mocked) + // processVideoFile. Tests that need the no-tool path override this. + mockedTooling.mockResolvedValue({ ffmpeg: "ffmpeg", ffprobe: "ffprobe" }); return { extractDir, manager, pkg }; } @@ -131,14 +136,15 @@ describe("keepGermanAudioOnly integration", () => { expect(fs.readdirSync(extractDir)).toContain("Show.S01E01.German.720p.x264.mkv"); }); - it("stops the run and leaves files untouched when ffmpeg is missing", async () => { + it("skips up front (no processVideoFile calls) 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); + mockedTooling.mockResolvedValue(null); // ffmpeg/ffprobe not found const n = await (manager as any).keepGermanAudioOnlyImpl(extractDir, pkg); expect(n).toBe(0); + expect(mockedProcess).not.toHaveBeenCalled(); // bailed before touching any file expect(fs.readdirSync(extractDir)).toContain(DL_MKV); // untouched }); }); diff --git a/tests/video-processor.test.ts b/tests/video-processor.test.ts index 6d5ce96..53cc6a5 100644 --- a/tests/video-processor.test.ts +++ b/tests/video-processor.test.ts @@ -6,6 +6,7 @@ import { stripDualLangMarker, hasDualLangMarker, isRemuxableVideoFile, + looksLikeGermanRelease, pickAudioTrack, parseFfprobeAudioStreams, buildFfprobeArgs, @@ -95,6 +96,34 @@ describe("pickAudioTrack", () => { 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" }); }); + + it("tag mode, no German tag but GERMAN release -> fall back to first track (mislabeled dub)", () => { + expect(pickAudioTrack([eng, eng], "tag", true)).toMatchObject({ action: "remux", audioRelIndex: 0, reason: "fallback-first-german-release" }); + }); + + it("tag mode, single mislabeled track on a German release -> keep it (no remux)", () => { + expect(pickAudioTrack([eng], "tag", true)).toMatchObject({ action: "single", reason: "single-german-mislabeled" }); + }); + + it("tag mode, no German tag and NOT flagged German -> still SKIP (safety preserved)", () => { + expect(pickAudioTrack([eng, eng], "tag", false)).toMatchObject({ action: "skip", reason: "no-german-track" }); + }); + + it("correctly tagged German still wins even on a German release (fallback not needed)", () => { + expect(pickAudioTrack([eng, ger], "tag", true)).toMatchObject({ action: "remux", audioRelIndex: 1, reason: "german-tag" }); + }); +}); + +describe("looksLikeGermanRelease", () => { + it("detects German/Dubbed release names", () => { + expect(looksLikeGermanRelease("Desperate.Housewives.S02E01.German.DD51.Dubbed.DL.720p.WEB-DL.x264.mkv")).toBe(true); + expect(looksLikeGermanRelease("1899.S01E01.German.DL.720p.WEB-x264-WvF.mkv")).toBe(true); + expect(looksLikeGermanRelease("Show.S01E01.Deutsch.1080p.mkv")).toBe(true); + }); + it("does not flag a bare .DL. name without an explicit German token", () => { + expect(looksLikeGermanRelease("Show.S01E01.DL.720p.x264.mkv")).toBe(false); + expect(looksLikeGermanRelease("Show.S01E01.MULTi.1080p.mkv")).toBe(false); + }); }); describe("parseFfprobeAudioStreams", () => { @@ -156,10 +185,10 @@ describe("processVideoFile (real fs body, fake runner)", () => { } }); - function makeFile(content: string): string { + function makeFile(content: string, name = "Show.S01E01.German.DL.720p.mkv"): 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"); + const file = path.join(dir, name); fs.writeFileSync(file, content); return file; } @@ -222,8 +251,21 @@ describe("processVideoFile (real fs body, fake runner)", () => { expect(fs.readFileSync(file, "utf8")).toBe("ORIGINAL"); }); - it("leaves the file untouched when tagged but no German track", async () => { - const file = makeFile("ORIGINAL"); + it("remuxes a German-named release with MISLABELED audio tags (fallback to first track)", async () => { + // Name says German, but both audio tracks are tagged eng/fre (the dub is + // mislabeled). The fallback keeps the first track instead of skipping. + const file = makeFile("ORIGINAL"); // name contains "German" + 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("remuxed"); + expect(result.keptTrackIndex).toBe(0); + expect(fs.readFileSync(file, "utf8")).toBe("REMUXED-GERMAN-ONLY"); + }); + + it("leaves a NON-German-named file untouched when tagged but no German track (safety preserved)", async () => { + const file = makeFile("ORIGINAL", "Show.S01E01.MULTi.DL.720p.mkv"); const result = await processVideoFile(file, { mode: "tag" }, { resolveTooling: tooling, runProcess: fakeRunner({ probeJson: JSON.stringify({ streams: [{ tags: { language: "eng" } }, { tags: { language: "fre" } }] }) })