Compare commits
No commits in common. "main" and "v1.7.187" have entirely different histories.
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "real-debrid-downloader",
|
||||
"version": "1.7.188",
|
||||
"version": "1.7.187",
|
||||
"description": "Desktop downloader",
|
||||
"main": "build/main/main/main.js",
|
||||
"author": "Sucukdeluxe",
|
||||
|
||||
@ -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, resolveVideoTooling, stripDualLangMarker, hasDualLangMarker, isRemuxableVideoFile, type GermanAudioMode, type VideoProcessResult } from "./video-processor";
|
||||
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";
|
||||
@ -3941,26 +3941,12 @@ 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, mode: this.settings.germanAudioMode });
|
||||
this.logRenameProcess(pkg, "INFO", "audio-strip", "Tonspur-Bereinigung gestartet", { extractDir, candidates: targets.length });
|
||||
}
|
||||
|
||||
// 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;
|
||||
@ -3975,7 +3961,13 @@ export class DownloadManager extends EventEmitter {
|
||||
if (result.action === "aborted") {
|
||||
return processed;
|
||||
}
|
||||
const langs = (result.audioLanguages || []).join(",");
|
||||
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);
|
||||
@ -3983,22 +3975,11 @@ export class DownloadManager extends EventEmitter {
|
||||
sourceName,
|
||||
keptTrack: result.keptTrackIndex,
|
||||
audioTracks: result.totalAudioTracks,
|
||||
languages: langs || undefined,
|
||||
...(result.error ? { error: result.error } : {})
|
||||
}, resolved.item, resolved.matchedBy);
|
||||
}
|
||||
// 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") {
|
||||
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
|
||||
@ -4007,10 +3988,6 @@ 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;
|
||||
}
|
||||
|
||||
|
||||
@ -39,7 +39,6 @@ export interface VideoProcessResult {
|
||||
reason: string;
|
||||
keptTrackIndex?: number;
|
||||
totalAudioTracks?: number;
|
||||
audioLanguages?: string[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@ -82,15 +81,6 @@ 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)) {
|
||||
@ -102,7 +92,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, germanRelease = false): AudioTrackDecision {
|
||||
export function pickAudioTrack(streams: ProbedAudioStream[], mode: GermanAudioMode): AudioTrackDecision {
|
||||
const total = streams.length;
|
||||
if (total === 0) {
|
||||
return { action: "skip", reason: "no-audio" };
|
||||
@ -126,15 +116,7 @@ export function pickAudioTrack(streams: ProbedAudioStream[], mode: GermanAudioMo
|
||||
? { action: "single", audioRelIndex: 0, reason: "single-untagged" }
|
||||
: { action: "remux", audioRelIndex: 0, reason: "fallback-first-untagged" };
|
||||
}
|
||||
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.
|
||||
// Tagged, but no German track -> never guess-delete the only usable audio.
|
||||
return { action: "skip", reason: "no-german-track" };
|
||||
}
|
||||
|
||||
@ -398,18 +380,16 @@ export async function processVideoFile(filePath: string, opts: ProcessVideoOptio
|
||||
}
|
||||
|
||||
const streams = parseFfprobeAudioStreams(probe.stdout);
|
||||
const audioLanguages = streams.map((s) => (s.language || "").trim() || "und");
|
||||
const decision = pickAudioTrack(streams, opts.mode, looksLikeGermanRelease(path.basename(filePath)));
|
||||
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,
|
||||
audioLanguages
|
||||
totalAudioTracks: streams.length
|
||||
};
|
||||
}
|
||||
if (decision.action === "single") {
|
||||
return { action: "kept-single", reason: decision.reason, totalAudioTracks: streams.length, audioLanguages, keptTrackIndex: 0 };
|
||||
return { action: "kept-single", reason: decision.reason, totalAudioTracks: streams.length, keptTrackIndex: 0 };
|
||||
}
|
||||
|
||||
// remux path
|
||||
@ -417,18 +397,15 @@ 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), audioLanguages };
|
||||
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, audioLanguages };
|
||||
return { action: "skipped-no-space", reason: "zu wenig freier Speicher fuer Remux", totalAudioTracks: streams.length };
|
||||
}
|
||||
|
||||
const ext = path.extname(filePath);
|
||||
// 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}`);
|
||||
const tempPath = `${filePath}.gertmp${ext}`;
|
||||
await fs.promises.rm(tempPath, { force: true }).catch(() => {});
|
||||
|
||||
const remux = await run(
|
||||
@ -442,13 +419,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, audioLanguages, keptTrackIndex: decision.audioRelIndex };
|
||||
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, audioLanguages };
|
||||
return { action: "error", reason: "Remux ergab leere Datei", totalAudioTracks: streams.length };
|
||||
}
|
||||
|
||||
try {
|
||||
@ -461,8 +438,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, audioLanguages };
|
||||
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, audioLanguages };
|
||||
return { action: "remuxed", reason: decision.reason, keptTrackIndex: decision.audioRelIndex, totalAudioTracks: streams.length };
|
||||
}
|
||||
|
||||
@ -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<typeof import("../src/main/video-processor")>();
|
||||
return { ...actual, processVideoFile: vi.fn(), resolveVideoTooling: vi.fn() };
|
||||
return { ...actual, processVideoFile: vi.fn() };
|
||||
});
|
||||
|
||||
import { DownloadManager } from "../src/main/download-manager";
|
||||
@ -17,15 +17,13 @@ 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, resolveVideoTooling, type VideoProcessResult } from "../src/main/video-processor";
|
||||
import { processVideoFile, type VideoProcessResult } from "../src/main/video-processor";
|
||||
|
||||
const mockedProcess = processVideoFile as unknown as ReturnType<typeof vi.fn>;
|
||||
const mockedTooling = resolveVideoTooling as unknown as ReturnType<typeof vi.fn>;
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
mockedProcess.mockReset();
|
||||
mockedTooling.mockReset();
|
||||
shutdownItemLogs();
|
||||
shutdownPackageLogs();
|
||||
shutdownRenameLog();
|
||||
@ -68,9 +66,6 @@ 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 };
|
||||
}
|
||||
|
||||
@ -136,15 +131,14 @@ describe("keepGermanAudioOnly integration", () => {
|
||||
expect(fs.readdirSync(extractDir)).toContain("Show.S01E01.German.720p.x264.mkv");
|
||||
});
|
||||
|
||||
it("skips up front (no processVideoFile calls) and leaves files untouched when ffmpeg is missing", async () => {
|
||||
it("stops the run and leaves files untouched when ffmpeg is missing", async () => {
|
||||
const { extractDir, manager, pkg } = setup(true);
|
||||
stage(extractDir);
|
||||
mockedTooling.mockResolvedValue(null); // ffmpeg/ffprobe not found
|
||||
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(mockedProcess).not.toHaveBeenCalled(); // bailed before touching any file
|
||||
expect(fs.readdirSync(extractDir)).toContain(DL_MKV); // untouched
|
||||
});
|
||||
});
|
||||
|
||||
@ -6,7 +6,6 @@ import {
|
||||
stripDualLangMarker,
|
||||
hasDualLangMarker,
|
||||
isRemuxableVideoFile,
|
||||
looksLikeGermanRelease,
|
||||
pickAudioTrack,
|
||||
parseFfprobeAudioStreams,
|
||||
buildFfprobeArgs,
|
||||
@ -96,34 +95,6 @@ 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", () => {
|
||||
@ -185,10 +156,10 @@ describe("processVideoFile (real fs body, fake runner)", () => {
|
||||
}
|
||||
});
|
||||
|
||||
function makeFile(content: string, name = "Show.S01E01.German.DL.720p.mkv"): string {
|
||||
function makeFile(content: string): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-vp-"));
|
||||
tempDirs.push(dir);
|
||||
const file = path.join(dir, name);
|
||||
const file = path.join(dir, "Show.S01E01.German.DL.720p.mkv");
|
||||
fs.writeFileSync(file, content);
|
||||
return file;
|
||||
}
|
||||
@ -251,21 +222,8 @@ describe("processVideoFile (real fs body, fake runner)", () => {
|
||||
expect(fs.readFileSync(file, "utf8")).toBe("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");
|
||||
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" } }] }) })
|
||||
|
||||
Loading…
Reference in New Issue
Block a user