Compare commits

..

7 Commits

Author SHA1 Message Date
Sucukdeluxe
b71866c3dc Release v1.7.189 2026-06-08 22:23:07 +02:00
Sucukdeluxe
b200b4e5b1 docs(tasks): Bug-Audit Sequenz — B verifiziert demoted (keine Platten-Loeschung), A allein v1.7.189 2026-06-08 22:19:07 +02:00
Sucukdeluxe
189af2242f Fix: Tonspur-Remux konnte bei Windows-Datei-Lock Original UND Remux verlieren
Der atomare Ersetzen-Schritt loeschte das Original bevor der Ersatz bestaetigt
war; schlug das anschliessende Rename fehl (z.B. AV/Indexer-Lock), raeumte der
aeussere catch zusaetzlich die Temp-Datei weg -> null Kopien auf der Platte.

- Atomares Replace-over (MoveFileEx REPLACE_EXISTING / rename(2)) statt
  rm-dann-rename: filePath haelt zu jedem Zeitpunkt entweder das volle Original
  oder den vollen Remux.
- renameWithRetry: transiente Locks (EBUSY/EACCES/EPERM/EEXIST) mit Backoff
  (200/500/1000ms) statt sofort abzubrechen.
- Eindeutiger Temp-Name (~rd<pid><rand>) statt fixem ~rdtmp -> keine Kollision
  zwischen parallelen Paketen/Retries.
- 3 neue Tests (Recovery bei Replace-Fehler, Retry-Pfad EBUSY/EXDEV).
2026-06-08 22:14:13 +02:00
Sucukdeluxe
92890f9649 Release v1.7.188 2026-06-08 14:51:22 +02:00
Sucukdeluxe
2b93f47d3a German-audio step: mislabeled-tag fallback, full logging, shorter temp
- tag mode: when no German-tagged audio track is found but the release name
  says German/Dubbed, fall back to the first track (the dub is mislabeled, e.g.
  German tagged "eng") instead of skipping; non-German names still skip safely
- comprehensive logging: per-package ffmpeg/ffprobe availability, plus per-file
  detected audio languages, decision + reason, remux/rename result and the exact
  error text when a file can't be processed
- shorter same-dir temp name so a long scene path + temp suffix cannot exceed
  Windows MAX_PATH and silently fail the remux
2026-06-08 14:50:34 +02:00
Sucukdeluxe
15edfbeb74 Release v1.7.187 2026-06-08 13:34:26 +02:00
Sucukdeluxe
aa65f56c28 Fix Mega-Web rotation skipping accounts on a timeout abort
When a Mega-Web account's unrestrict aborts because the shared unrestrict
timeout fired while it was running, give that account a 2-min cooldown
(only if it actually ran >=8s, so a quick user-cancel does not cool it
down). The download-manager retry then skips the cooled-down account and
rotates to the next one, instead of hammering the same account every 60s.

- debrid.ts: handle the abort in the rotation catch before classifyAccountFailure
- rotation log event TIMEOUT_COOLDOWN (+ renderer label) replaces the misleading
  red "fataler Fehler" for this case
- RD_MEGA_ABORT_MIN_RUN_MS env override for the run-length threshold
- 2 regression tests (cooldown set -> next call rotates; quick abort -> no cooldown)
2026-06-08 13:33:49 +02:00
9 changed files with 437 additions and 44 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "1.7.186", "version": "1.7.189",
"description": "Desktop downloader", "description": "Desktop downloader",
"main": "build/main/main/main.js", "main": "build/main/main/main.js",
"author": "Sucukdeluxe", "author": "Sucukdeluxe",

View File

@ -283,6 +283,16 @@ const megaDebridAccountCooldowns = new Map<string, MegaDebridCooldownDetail>();
const MEGA_DEBRID_ACCOUNT_COOLDOWN_MS = 120_000; const MEGA_DEBRID_ACCOUNT_COOLDOWN_MS = 120_000;
const MEGA_DEBRID_INVALID_ACCOUNT_COOLDOWN_MS = 60 * 60 * 1000; const MEGA_DEBRID_INVALID_ACCOUNT_COOLDOWN_MS = 60 * 60 * 1000;
// A Mega-Web account abort (the shared unrestrict timeout firing while this
// account ran) only cools the account down — so the next attempt rotates on —
// if it actually ran this long. Below this, it's treated as a quick user-cancel
// (no cooldown). Env-overridable for tests.
const MEGA_DEBRID_ABORT_MIN_RUN_MS_DEFAULT = 8000;
function getMegaDebridAbortMinRunMs(): number {
const fromEnv = Number(process.env.RD_MEGA_ABORT_MIN_RUN_MS ?? NaN);
return Number.isFinite(fromEnv) && fromEnv >= 0 ? Math.floor(fromEnv) : MEGA_DEBRID_ABORT_MIN_RUN_MS_DEFAULT;
}
const megaDebridEmptyResponseStreaks = new Map<string, number>(); const megaDebridEmptyResponseStreaks = new Map<string, number>();
export const MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART = 3; export const MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART = 3;
@ -1958,8 +1968,29 @@ class MegaDebridClient {
sourceAccountLabel: account.label sourceAccountLabel: account.label
}; };
} catch (error) { } catch (error) {
const failure = MegaDebridClient.classifyAccountFailure(error);
const elapsedMs = Date.now() - testStartedAt; const elapsedMs = Date.now() - testStartedAt;
const abortText = compactErrorText(error).replace(/^Error:\s*/i, "");
// Timeout/abort on THIS account (the shared unrestrict signal fired). Cool
// the account down — if it actually ran, not a quick user-cancel — so the
// download-manager's retry rotates to the NEXT account instead of hammering
// this one. The shared signal is now aborted, so we stop this pass; the
// retry runs the rotation fresh with this account skipped. A genuine cancel
// is not retried by the caller, so the cooldown is harmless there.
if (/aborted/i.test(abortText) && !/timeout/i.test(abortText)) {
const ranLongEnough = elapsedMs >= getMegaDebridAbortMinRunMs();
if (ranLongEnough) {
setMegaDebridAccountCooldownState(cooldownKey, MEGA_DEBRID_ACCOUNT_COOLDOWN_MS, `Abbruch/Timeout nach ${Math.ceil(elapsedMs / 1000)}s`, "temporary");
}
failures.push(`Mega-Debrid${accountLabel}: ${abortText}`);
logAccountRotation("WARN", providerName, rotationLabel, "TIMEOUT_COOLDOWN", {
elapsedMs,
reason: abortText,
cooldownSec: ranLongEnough ? Math.ceil(MEGA_DEBRID_ACCOUNT_COOLDOWN_MS / 1000) : 0,
next: "naechster Account beim Retry"
});
throw new Error(`Mega-Debrid${accountLabel}: ${abortText}`);
}
const failure = MegaDebridClient.classifyAccountFailure(error);
failures.push(`Mega-Debrid${accountLabel}: ${failure.message}`); failures.push(`Mega-Debrid${accountLabel}: ${failure.message}`);
let parkUntilRestart = false; let parkUntilRestart = false;

View File

@ -54,7 +54,7 @@ import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, DebridService, Meg
import { cleanupArchives, clearExtractResumeState, collectArchiveCleanupTargets, detectArchiveSignature, extractPackageArchives, findArchiveCandidates, hasAnyFilesRecursive, removeEmptyDirectoryTree, resetExtractorCachesForPasswordChange, type ExtractArchiveFailureInfo } from "./extractor"; import { cleanupArchives, clearExtractResumeState, collectArchiveCleanupTargets, detectArchiveSignature, extractPackageArchives, findArchiveCandidates, hasAnyFilesRecursive, removeEmptyDirectoryTree, resetExtractorCachesForPasswordChange, type ExtractArchiveFailureInfo } from "./extractor";
import { validateFileAgainstManifest } from "./integrity"; import { validateFileAgainstManifest } from "./integrity";
import { classifyDiskError } from "./fs-error"; 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 { logger } from "./logger";
import { getRecentRotationEvents, runWithRotationItemSink, setRotationEventListener } from "./account-rotation-log"; import { getRecentRotationEvents, runWithRotationItemSink, setRotationEventListener } from "./account-rotation-log";
import type { RotationEvent } from "../shared/types"; import type { RotationEvent } from "../shared/types";
@ -3941,12 +3941,26 @@ export class DownloadManager extends EventEmitter {
if (targets.length === 0) { if (targets.length === 0) {
return 0; return 0;
} }
logger.info(`Tonspur-Bereinigung: ${targets.length} .DL.-Datei(en) in ${extractDir}, Modus=${this.settings.germanAudioMode}`);
if (pkg) { 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"; const mode: GermanAudioMode = this.settings.germanAudioMode === "first" ? "first" : "tag";
let processed = 0; let processed = 0;
let failed = 0;
for (const sourcePath of targets) { for (const sourcePath of targets) {
if (shouldAbort?.() || signal?.aborted) { if (shouldAbort?.() || signal?.aborted) {
return processed; return processed;
@ -3961,13 +3975,7 @@ export class DownloadManager extends EventEmitter {
if (result.action === "aborted") { if (result.action === "aborted") {
return processed; return processed;
} }
if (result.action === "skipped-no-tool") { const langs = (result.audioLanguages || []).join(",");
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) { if (pkg) {
const level = result.action === "error" ? "WARN" : "INFO"; const level = result.action === "error" ? "WARN" : "INFO";
const resolved = this.inferItemForMediaLog(pkg, sourcePath, sourceName); const resolved = this.inferItemForMediaLog(pkg, sourcePath, sourceName);
@ -3975,11 +3983,22 @@ export class DownloadManager extends EventEmitter {
sourceName, sourceName,
keptTrack: result.keptTrackIndex, keptTrack: result.keptTrackIndex,
audioTracks: result.totalAudioTracks, audioTracks: result.totalAudioTracks,
languages: langs || undefined,
...(result.error ? { error: result.error } : {}) ...(result.error ? { error: result.error } : {})
}, resolved.item, resolved.matchedBy); }, 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; 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 // Only strip ".DL." once the file is confirmed German-only (remuxed) or
// already single-track. Skips/errors leave the file fully untouched so the // 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); 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; return processed;
} }

View File

@ -1,6 +1,7 @@
import fs from "node:fs"; import fs from "node:fs";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import crypto from "node:crypto";
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
// Removes only-German audio handling for "Dual Language" (.DL.) scene releases. // Removes only-German audio handling for "Dual Language" (.DL.) scene releases.
@ -39,6 +40,7 @@ export interface VideoProcessResult {
reason: string; reason: string;
keptTrackIndex?: number; keptTrackIndex?: number;
totalAudioTracks?: number; totalAudioTracks?: number;
audioLanguages?: string[];
error?: string; error?: string;
} }
@ -54,6 +56,9 @@ export interface ProcessVideoOptions {
export interface ProcessVideoDeps { export interface ProcessVideoDeps {
resolveTooling?: () => Promise<{ ffmpeg: string; ffprobe: string } | null>; resolveTooling?: () => Promise<{ ffmpeg: string; ffprobe: string } | null>;
runProcess?: typeof runVideoProcess; runProcess?: typeof runVideoProcess;
// Seam for the atomic-replace rename so its failure/recovery path is testable
// without provoking a real OS file lock. Production uses renameWithRetry.
rename?: (from: string, to: string) => Promise<void>;
} }
const VIDEO_REMUX_EXTENSIONS = new Set([".mkv", ".mp4"]); const VIDEO_REMUX_EXTENSIONS = new Set([".mkv", ".mp4"]);
@ -81,6 +86,15 @@ export function isRemuxableVideoFile(fileName: string): boolean {
return VIDEO_REMUX_EXTENSIONS.has(path.extname(fileName).toLowerCase()); 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 { function isGermanStream(stream: ProbedAudioStream): boolean {
const lang = (stream.language || "").toLowerCase().trim(); const lang = (stream.language || "").toLowerCase().trim();
if (["ger", "deu", "de", "german", "deutsch"].includes(lang)) { if (["ger", "deu", "de", "german", "deutsch"].includes(lang)) {
@ -92,7 +106,7 @@ function isGermanStream(stream: ProbedAudioStream): boolean {
// Decide which audio track to keep. Safety invariant: only ever choose to remux // Decide which audio track to keep. Safety invariant: only ever choose to remux
// (which destroys the original) when we are confident; otherwise skip untouched. // (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; const total = streams.length;
if (total === 0) { if (total === 0) {
return { action: "skip", reason: "no-audio" }; return { action: "skip", reason: "no-audio" };
@ -116,7 +130,15 @@ export function pickAudioTrack(streams: ProbedAudioStream[], mode: GermanAudioMo
? { action: "single", audioRelIndex: 0, reason: "single-untagged" } ? { action: "single", audioRelIndex: 0, reason: "single-untagged" }
: { action: "remux", audioRelIndex: 0, reason: "fallback-first-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" }; return { action: "skip", reason: "no-german-track" };
} }
@ -360,6 +382,41 @@ async function getFreeSpaceBytes(dir: string): Promise<number | null> {
} }
} }
const RENAME_RETRY_DELAYS_MS = [200, 500, 1000];
const RENAME_RETRYABLE_CODES = new Set(["EBUSY", "EACCES", "EPERM", "EEXIST"]);
function delayMs(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// Windows file locks from antivirus, the search indexer, or a media scanner are
// transient: a rename that hits EBUSY/EACCES/EPERM/EEXIST often succeeds a moment
// later. Retry with backoff before giving up so a momentary lock doesn't abort
// the atomic replace and leave the file unprocessed.
export async function renameWithRetry(from: string, to: string): Promise<void> {
for (let attempt = 0; ; attempt += 1) {
try {
await fs.promises.rename(from, to);
return;
} catch (error) {
const code = (error as NodeJS.ErrnoException)?.code;
if (!code || !RENAME_RETRYABLE_CODES.has(code) || attempt >= RENAME_RETRY_DELAYS_MS.length) {
throw error;
}
await delayMs(RENAME_RETRY_DELAYS_MS[attempt]);
}
}
}
// Short, unique, same-directory sidecar name (never longer than the original file
// name) so concurrent packages / retries never collide on a fixed temp name and a
// long scene filename + suffix cannot push the path past Windows MAX_PATH.
function uniqueTempPath(filePath: string): string {
const ext = path.extname(filePath);
const token = `${process.pid.toString(36)}${crypto.randomBytes(3).toString("hex")}`;
return path.join(path.dirname(filePath), `~rd${token}${ext}`);
}
export async function processVideoFile(filePath: string, opts: ProcessVideoOptions, deps: ProcessVideoDeps = {}): Promise<VideoProcessResult> { export async function processVideoFile(filePath: string, opts: ProcessVideoOptions, deps: ProcessVideoDeps = {}): Promise<VideoProcessResult> {
const resolveTool = deps.resolveTooling || resolveVideoTooling; const resolveTool = deps.resolveTooling || resolveVideoTooling;
const run = deps.runProcess || runVideoProcess; const run = deps.runProcess || runVideoProcess;
@ -380,16 +437,18 @@ export async function processVideoFile(filePath: string, opts: ProcessVideoOptio
} }
const streams = parseFfprobeAudioStreams(probe.stdout); 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") { if (decision.action === "skip") {
return { return {
action: decision.reason === "no-german-track" ? "skipped-no-german" : "skipped-no-audio", action: decision.reason === "no-german-track" ? "skipped-no-german" : "skipped-no-audio",
reason: decision.reason, reason: decision.reason,
totalAudioTracks: streams.length totalAudioTracks: streams.length,
audioLanguages
}; };
} }
if (decision.action === "single") { 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 // remux path
@ -397,15 +456,14 @@ export async function processVideoFile(filePath: string, opts: ProcessVideoOptio
try { try {
originalStat = await fs.promises.stat(filePath); originalStat = await fs.promises.stat(filePath);
} catch (error) { } 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)); const free = await getFreeSpaceBytes(path.dirname(filePath));
if (free !== null && free < Math.ceil(originalStat.size * 1.05)) { 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 = uniqueTempPath(filePath);
const tempPath = `${filePath}.gertmp${ext}`;
await fs.promises.rm(tempPath, { force: true }).catch(() => {}); await fs.promises.rm(tempPath, { force: true }).catch(() => {});
const remux = await run( const remux = await run(
@ -419,27 +477,30 @@ export async function processVideoFile(filePath: string, opts: ProcessVideoOptio
} }
if (!remux.ok) { if (!remux.ok) {
await fs.promises.rm(tempPath, { force: true }).catch(() => {}); 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); const tempStat = await fs.promises.stat(tempPath).catch(() => null);
if (!tempStat || tempStat.size <= 0) { if (!tempStat || tempStat.size <= 0) {
await fs.promises.rm(tempPath, { force: true }).catch(() => {}); 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 };
} }
const renameOp = deps.rename || renameWithRetry;
try { try {
// libuv rename replaces an existing destination on Windows; fall back if not. // Atomic replace-over: libuv maps fs.rename to MoveFileEx(REPLACE_EXISTING) on
await fs.promises.rename(tempPath, filePath).catch(async () => { // Windows and rename(2) on POSIX, both atomic on the same volume, so filePath
await fs.promises.rm(filePath, { force: true }); // holds either the full original or the full remux at every instant. Retried
await fs.promises.rename(tempPath, filePath); // for transient locks. We must NEVER rm the original first (the old fallback
}); // did): an rm-then-failed-rename left zero copies of the file on disk.
await renameOp(tempPath, filePath);
// Preserve original mtime so freshness gates (hybrid collect) don't skip it. // Preserve original mtime so freshness gates (hybrid collect) don't skip it.
await fs.promises.utimes(filePath, originalStat.atime, originalStat.mtime).catch(() => {}); await fs.promises.utimes(filePath, originalStat.atime, originalStat.mtime).catch(() => {});
} catch (error) { } catch (error) {
// Replace failed -> the original is untouched at filePath. Drop the temp only.
await fs.promises.rm(tempPath, { force: true }).catch(() => {}); 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 };
} }

View File

@ -1150,6 +1150,10 @@ function rotationEventText(ev: { event: string; cooldownSec?: number; next?: str
return `fehlgeschlagen${cd}${nx}`; return `fehlgeschlagen${cd}${nx}`;
} }
case "FATAL": return "abgebrochen (fataler Fehler)"; case "FATAL": return "abgebrochen (fataler Fehler)";
case "TIMEOUT_COOLDOWN": {
const cd = ev.cooldownSec ? `, Cooldown ${ev.cooldownSec}s` : "";
return `Timeout/Abbruch${cd} → nächster Account beim Retry`;
}
case "SKIP_COOLDOWN": return untilRestart ? "übersprungen (bis Neustart gesperrt)" : "übersprungen (Cooldown aktiv)"; case "SKIP_COOLDOWN": return untilRestart ? "übersprungen (bis Neustart gesperrt)" : "übersprungen (Cooldown aktiv)";
case "SKIP_DISABLED": return "übersprungen (deaktiviert)"; case "SKIP_DISABLED": return "übersprungen (deaktiviert)";
case "SKIP_DAILY_LIMIT": return "übersprungen (Tageslimit erreicht)"; case "SKIP_DAILY_LIMIT": return "übersprungen (Tageslimit erreicht)";

View File

@ -1,12 +1,112 @@
# Real-Debrid-Downloader — Tasks (Stand 2026-06-07) # Real-Debrid-Downloader — Tasks (Stand 2026-06-08)
**Status: nichts hängt, nichts ist halbfertig.** Alle zugesagten Tasks sind erledigt **Status:** Alle zugesagten Features erledigt+released (Archiv unten). Aktuell läuft ein
(siehe Archiv unten). Was hier offen steht, ist freiwilliger Backlog. **intensiver Bug-Audit** (User-Goal 2026-06-08, "schaue intensiv nach weiteren Bugs") —
Fortschritt direkt unten.
---
## 🔴 LAUFEND — Bug-Audit 2026-06-08 (Multi-Agent find→verify, 18 bestätigt)
Advisor-Triage: **A = einzige echte Daten-Verlust-Notlage** (zerstört echte Datei auf Platte)
→ zuerst, ALLEINE Release. **B verifiziert demoted:** applyRetroactiveCleanupPolicy/
removePackageFromSession löschen KEINE Platten-Dateien (nur Session/Queue-Einträge + ggf.
History-Eintrag) → Queue-Integrität, nicht Daten-Verlust → in v1.7.190-Batch.
Sequenz: Release 1 (v1.7.189) = **A allein**; Release 2 (v1.7.190) = B/I,C,D/E,F,G,H,J,L,M,N,O,P,Q.
Ein Commit pro Fix, jeder einzeln verifiziert. **K übersprungen** (auto-rename-Reorder,
schlechtestes Risiko/Nutzen, kann für diesen User gar nicht feuern).
### Release 1 — Daten-Verlust-Stopper (v1.7.189, A ALLEIN)
- [x] **A** `video-processor.ts` atomic-replace zerstörte bei Windows-Lock BEIDE Kopien
(rm(original) VOR bestätigtem Replace + outer-catch rm(temp) → 0 Kopien). **GEFIXT:**
atomic replace-over + `renameWithRetry` (EBUSY/EACCES/EPERM/EEXIST, Backoff 200/500/1000ms),
rm-first-Fallback entfernt, **unique** Temp-Name (`~rd<pid><rand>`, löst auch C-Kollision).
Advisor bestätigt Ansatz besser als bak-dance (kein Missing-File-Window). 3 neue Tests
(Recovery + Retry-Pfad), 41 video-processor-Tests grün, tsc=6 (Baseline). Commit 189af22.
### Release 2 — Medium/Low (v1.7.190), ein Commit pro Fix
- [ ] **B/I** `app-controller.ts` importBackup settings-only: setSettings → applyRetroactive
CleanupPolicy purged die LIVE-Queue (Vertragsbruch "running queue stays untouched"; Dateien
bleiben aber auf Platte). **Fix (Advisor):** (b) retroaktiven Sweep NUR für diesen Import
unterdrücken (importierte Policy gilt weiter für künftige Completions über normalen Pfad) —
NICHT über updateSettings routen (zweite Landmine resetHistoryForRetention). **I:** die 5
Live-Usage/Status-Felder overlayen wie updateSettings 322-331 INKL. Key-Filterung der
debridLinkApiKey*UsageBytes auf keyIds in restored debridLinkApiKeys (3 All-Time-Totals deckt
setSettings-Math.max schon ab). Vorher 1 grep: forward-Anwendungsstelle der Policy bestätigen.
- [ ] **C** ~~fixe Temp-Name-Kollision~~ → bereits in A subsumiert (unique Name).
- [ ] **D/E** debrid.ts Rotation: abort-Klassifizierung über `signal.reason` (TimeoutError vs
cancel) statt Text/elapsedMs; API-Pfad 'cancel' umgeht. **VORHER empirisch bestätigen:**
`AbortSignal.any([ac.signal, AbortSignal.timeout(x)]).reason?.name==='TimeoutError'` in DIESEM
Electron-Build; konservativen Fallback behalten, alte Guard nicht blind löschen.
- [ ] **F** Mega-Web empty-streak Concurrency (streak permanent-park unreachable-to-clear vorher
re-verifizieren bevor Maschinerie).
- [ ] **G** download-manager `dropItemContribution` subtrahiert Session-Totals nicht.
- [ ] **H** logger.ts `flushAsync` snapshot-by-slice korrumpiert bei 1MB-Cap-Trim während await
→ move-snapshot (`linesSnapshot = pendingLines; pendingLines = []`).
- [ ] **I** → mit B zusammen (app-controller live-usage-Counter).
- [ ] **J** download-manager `abortPackagePostProcessing` löscht Task-Handle ohne Identity-Guard.
- [ ] **L** `isGermanStream` Title-Regex False-Positive.
- [ ] **M** `looksLikeGermanRelease` 'dubbed' zu breit.
- [ ] **N** `stripDualLangFromFileName` Kollision.
- [ ] **O** classifyAccountFailure abort-Branch jetzt tot (nach v1.7.187-Fix).
- [ ] **P** extractor.ts nested-Resume-Keys (`nested:<name>`) bei jedem extractPackageArchives
gepurged (prune-Whitelist nur top-level) → `startsWith("nested:")` in prune skippen.
- [ ] **Q** (NEU, aus A-Review) `collectFilesByExtensions` filtert `~rd`-Temp-Präfix NICHT →
crash-verwaiste Teil-Remuxe könnten in Library gesammelt werden. Vorbestehend (alter fixer
`~rdtmp` wurde überschrieben, neuer unique akkumuliert) → `~`-Präfix in collect skippen.
--- ---
## 🟢 OFFEN — Backlog (optional, nie begonnen) ## 🟢 OFFEN — Backlog (optional, nie begonnen)
### ✅ Mega-Web Account-Rotation überspringt Account 3 — GEFIXT 2026-06-08 (v1.7.187)
**Fix:** Ein Mega-Web-Account-Abbruch (geteiltes Timeout feuert während der Account lief)
setzt jetzt einen 2-min-Cooldown auf den Account (nur wenn er ≥8s lief, sonst = User-Cancel,
RD_MEGA_ABORT_MIN_RUN_MS env). Dadurch überspringt der download-manager-Retry diesen Account
und rotiert zum nächsten (debrid.ts, abort-Handling im Rotations-catch, vor classifyAccountFailure).
Log-Event `TIMEOUT_COOLDOWN` (gelb, "Timeout/Abbruch → nächster Account beim Retry") statt
rotem "fataler Fehler" (App.tsx:1141 Label). 2 Regressionstests (Cooldown gesetzt → Call 2
rotiert; Quick-Abbruch → kein Cooldown). EHRLICH: fixt Korrektheit, NICHT Latenz — Account 1
brennt weiter ~60s ins Timeout bevor der Retry auf Account 2 wechselt (instant-Failover bräuchte
per-Account-Timeout = größerer Eingriff, bewusst verschoben). Advisor-gegengeprüft.
**(Ursprüngliche Analyse — Symptom & Mechanismus, zur Doku belassen)**
**Symptom (User):** 3 Mega-Debrid-Web-Accounts aktiv, Rotation pendelt aber nur zwischen
Account 1 ↔ 2 (bzw. nur Account 1), Account 3 (Su****xe) wird NIE probiert.
**Verifizierter Mechanismus (Code):**
- Rotationsschleife `debrid.ts:1898`. Account 1 → "Mega-Web Antwort leer" → Cooldown 20s →
weiter zu Account 2. Account 2 → `aborted:debrid`.
- `classifyAccountFailure` (`debrid.ts:2036`) stuft JEDEN Abbruch als **fatal** ein →
`throw` (`debrid.ts:1991`) → Schleife bricht ab → **Account 3 nie erreicht.**
- Account 2 bekommt beim Fatal-Abbruch **keinen Cooldown** (cooldownMs:0). Beim
download-manager-Retry wird Account 1 (Cooldown) übersprungen, aber Account 2 (kein
Cooldown) ERNEUT vor Account 3 probiert → bricht wieder ab → ewiges 1↔2.
- Geteiltes 60s-Unrestrict-Timeout `download-manager.ts:8590` (`AbortSignal.any([taskAbort,
timeout(60s)])`) gilt für die GANZE Rotation, nicht pro Account. Mega-Web pollt intern bis
180s (`mega-web-fallback.ts:235` + Poll-Loop `:371`). Sobald das geteilte 60s feuert, bleibt
das kombinierte Signal aborted → KEIN späterer Account kriegt im selben Pass eine echte Chance.
**BESTÄTIGT 2026-06-08 (zweite Screenshots):** Account 1 läuft 10x rasch "erfolgreich"
(11:51:4511:52:26), dann zwei "abgebrochen (aborted:debrid)" um 11:53:30 UND 11:54:30 —
**exakt 60s auseinander** = das geteilte 60s-Unrestrict-Timeout feuert (kein User-Stop, der
wiederholt sich nicht periodisch). Hier rotiert GAR NICHTS: Account 1 bricht ab → fatal →
Rotation stoppt sofort bei idx=0 → Account 2 und 3 werden NIE probiert. Bug eindeutig
bestätigt, elapsedMs nicht mehr nötig. Account 1 selbst ist gesund (10x ok) — Mega-Web hängt
nur sporadisch (no-server-Poll) bis ins 60s-Timeout.
**Fix-Design (wenn bestätigt):** Pro-Account-Timeout-Budget, abgekoppelt vom geteilten Cap.
debrid.ts braucht das **cancel-only** Signal getrennt vom Timeout (kombiniertes Signal kann
beides nicht unterscheiden). Minimal-invasiv: optionaler `opts`-Param an `unrestrictLink`
({cancelSignal, perAttemptTimeoutMs}) — nur die Mega-Rotation liest ihn, andere Provider
unberührt (kombiniertes Signal bleibt). Pro Account: `AbortSignal.any([cancelSignal,
AbortSignal.timeout(perAttemptMs)])`. Abbruch-Logik: cancelSignal aborted → echter Stop;
eigenes Account-Timer gefeuert → non-fatal, Cooldown, weiter zum nächsten Account (inkl. 3).
**Regressionstest ZUERST** (3 Accounts, 1+2 failen/aborten → assert Account 3 kriegt TEST).
**Advisor-Gate** vor Eingriff (kritischer Unrestrict-Pfad, betrifft jeden Download).
Hinweis: Grundursache der leeren Antworten = Mega-Debrid Server/IP-Thema — Fix macht Rotation
nur FAIRER (alle Accounts drankommen), bringt aber keinen busy Server zum Antworten.
### Features / UX (nach ROI) ### Features / UX (nach ROI)
App läuft headless auf Windows-Server → Nutzer sitzt nicht davor. App läuft headless auf Windows-Server → Nutzer sitzt nicht davor.

View File

@ -11,6 +11,7 @@ afterEach(() => {
globalThis.fetch = originalFetch; globalThis.fetch = originalFetch;
resetDebridLinkRuntimeStateForTests(); resetDebridLinkRuntimeStateForTests();
resetMegaDebridRuntimeStateForTests(); resetMegaDebridRuntimeStateForTests();
delete process.env.RD_MEGA_ABORT_MIN_RUN_MS;
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
@ -1641,6 +1642,77 @@ describe("debrid service", () => {
expect(getMegaDebridAccountCooldownState(key)?.untilRestart).toBe(true); expect(getMegaDebridAccountCooldownState(key)?.untilRestart).toBe(true);
}, 20000); }, 20000);
it("cools down a Mega-Web account that aborts (timeout) so the NEXT unrestrict rotates to the next account", async () => {
process.env.RD_MEGA_ABORT_MIN_RUN_MS = "0"; // treat the instant mock abort as a real timeout
const settings = {
...defaultSettings(),
token: "",
bestToken: "",
allDebridToken: "",
megaLogin: "user1",
megaPassword: "pass1",
megaCredentials: "user1:pass1\nuser2:pass2",
megaDebridPreferApi: false,
providerOrder: [] as const,
providerPrimary: "megadebrid" as const,
providerSecondary: "none" as const,
providerTertiary: "none" as const,
autoProviderFallback: false
};
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
const loginsSeen: Array<string | undefined> = [];
const megaWeb = vi.fn(async (_link: string, _signal: AbortSignal | undefined, account?: { login: string; password: string }) => {
loginsSeen.push(account?.login);
if (account?.login === "user1") {
throw new Error("aborted:debrid");
}
return { fileName: "acc2.rar", directUrl: "https://mega-web.example/acc2.rar", fileSize: null, retriesUsed: 0 };
});
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
const user1Key = `${getMegaDebridAccountId("user1")}:web`;
// Call 1: account 1 aborts -> rotation stops this pass, account 2 NOT tried, but account 1 is cooled down.
await expect(service.unrestrictLink("https://rapidgator.net/file/abort-call-1")).rejects.toThrow();
expect(loginsSeen).toContain("user1");
expect(loginsSeen).not.toContain("user2");
expect(getMegaDebridAccountCooldownState(user1Key)).not.toBeNull();
// Call 2 (the retry, same state): account 1 is on cooldown -> skipped -> account 2 served.
loginsSeen.length = 0;
const result = await service.unrestrictLink("https://rapidgator.net/file/abort-call-2");
expect(loginsSeen).not.toContain("user1");
expect(loginsSeen).toContain("user2");
expect((result as { sourceAccountId?: string }).sourceAccountId).toBe(getMegaDebridAccountId("user2"));
}, 20000);
it("does NOT cool down a Mega-Web account on a quick abort (below the min-run threshold = user cancel)", async () => {
process.env.RD_MEGA_ABORT_MIN_RUN_MS = "99999"; // any realistic elapsed stays below -> no cooldown
const settings = {
...defaultSettings(),
token: "",
bestToken: "",
allDebridToken: "",
megaLogin: "user1",
megaPassword: "pass1",
megaCredentials: "user1:pass1\nuser2:pass2",
megaDebridPreferApi: false,
providerOrder: [] as const,
providerPrimary: "megadebrid" as const,
providerSecondary: "none" as const,
providerTertiary: "none" as const,
autoProviderFallback: false
};
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
const megaWeb = vi.fn(async () => { throw new Error("aborted:debrid"); });
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
const user1Key = `${getMegaDebridAccountId("user1")}:web`;
await expect(service.unrestrictLink("https://rapidgator.net/file/quick-cancel")).rejects.toThrow();
expect(getMegaDebridAccountCooldownState(user1Key)).toBeNull();
}, 20000);
it("respects provider selection and does not append hidden providers", async () => { it("respects provider selection and does not append hidden providers", async () => {
const settings = { const settings = {
...defaultSettings(), ...defaultSettings(),

View File

@ -8,7 +8,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
// download-manager's selection + .DL.-rename wiring is exercised for real. // download-manager's selection + .DL.-rename wiring is exercised for real.
vi.mock("../src/main/video-processor", async (importActual) => { vi.mock("../src/main/video-processor", async (importActual) => {
const actual = await importActual<typeof import("../src/main/video-processor")>(); const actual = await importActual<typeof import("../src/main/video-processor")>();
return { ...actual, processVideoFile: vi.fn() }; return { ...actual, processVideoFile: vi.fn(), resolveVideoTooling: vi.fn() };
}); });
import { DownloadManager } from "../src/main/download-manager"; 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 { shutdownItemLogs } from "../src/main/item-log";
import { shutdownPackageLogs } from "../src/main/package-log"; import { shutdownPackageLogs } from "../src/main/package-log";
import { shutdownRenameLog } from "../src/main/rename-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<typeof vi.fn>; const mockedProcess = processVideoFile as unknown as ReturnType<typeof vi.fn>;
const mockedTooling = resolveVideoTooling as unknown as ReturnType<typeof vi.fn>;
const tempDirs: string[] = []; const tempDirs: string[] = [];
afterEach(() => { afterEach(() => {
mockedProcess.mockReset(); mockedProcess.mockReset();
mockedTooling.mockReset();
shutdownItemLogs(); shutdownItemLogs();
shutdownPackageLogs(); shutdownPackageLogs();
shutdownRenameLog(); shutdownRenameLog();
@ -66,6 +68,9 @@ function setup(keepGermanAudioOnly: boolean): { extractDir: string; manager: Dow
createdAt: 0, createdAt: 0,
updatedAt: 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 }; return { extractDir, manager, pkg };
} }
@ -131,14 +136,15 @@ describe("keepGermanAudioOnly integration", () => {
expect(fs.readdirSync(extractDir)).toContain("Show.S01E01.German.720p.x264.mkv"); 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); const { extractDir, manager, pkg } = setup(true);
stage(extractDir); 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); const n = await (manager as any).keepGermanAudioOnlyImpl(extractDir, pkg);
expect(n).toBe(0); expect(n).toBe(0);
expect(mockedProcess).not.toHaveBeenCalled(); // bailed before touching any file
expect(fs.readdirSync(extractDir)).toContain(DL_MKV); // untouched expect(fs.readdirSync(extractDir)).toContain(DL_MKV); // untouched
}); });
}); });

View File

@ -1,17 +1,19 @@
import fs from "node:fs"; import fs from "node:fs";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { import {
stripDualLangMarker, stripDualLangMarker,
hasDualLangMarker, hasDualLangMarker,
isRemuxableVideoFile, isRemuxableVideoFile,
looksLikeGermanRelease,
pickAudioTrack, pickAudioTrack,
parseFfprobeAudioStreams, parseFfprobeAudioStreams,
buildFfprobeArgs, buildFfprobeArgs,
buildFfmpegRemuxArgs, buildFfmpegRemuxArgs,
computeRemuxTimeoutMs, computeRemuxTimeoutMs,
processVideoFile, processVideoFile,
renameWithRetry,
type VideoSpawnResult type VideoSpawnResult
} from "../src/main/video-processor"; } from "../src/main/video-processor";
@ -95,6 +97,34 @@ describe("pickAudioTrack", () => {
it("tag mode, tagged but no German -> SKIP (never delete the only usable audio)", () => { 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" }); 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", () => { describe("parseFfprobeAudioStreams", () => {
@ -156,10 +186,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-")); const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-vp-"));
tempDirs.push(dir); tempDirs.push(dir);
const file = path.join(dir, "Show.S01E01.German.DL.720p.mkv"); const file = path.join(dir, name);
fs.writeFileSync(file, content); fs.writeFileSync(file, content);
return file; return file;
} }
@ -179,6 +209,11 @@ describe("processVideoFile (real fs body, fake runner)", () => {
}; };
} }
// Any sidecar the replace machinery may leave behind (unique "~rd…" temp names).
function leftoverTemps(file: string): string[] {
return fs.readdirSync(path.dirname(file)).filter((n) => n.startsWith("~rd"));
}
const tooling = async (): Promise<{ ffmpeg: string; ffprobe: string }> => ({ ffmpeg: "ffmpeg", ffprobe: "ffprobe" }); const tooling = async (): Promise<{ ffmpeg: string; ffprobe: string }> => ({ ffmpeg: "ffmpeg", ffprobe: "ffprobe" });
const twoTracksGerSecond = JSON.stringify({ streams: [{ tags: { language: "eng" } }, { tags: { language: "ger" } }] }); const twoTracksGerSecond = JSON.stringify({ streams: [{ tags: { language: "eng" } }, { tags: { language: "ger" } }] });
@ -197,7 +232,7 @@ describe("processVideoFile (real fs body, fake runner)", () => {
expect(result.keptTrackIndex).toBe(1); // German was second expect(result.keptTrackIndex).toBe(1); // German was second
expect(fs.readFileSync(file, "utf8")).toBe("REMUXED-GERMAN-ONLY"); // original overwritten expect(fs.readFileSync(file, "utf8")).toBe("REMUXED-GERMAN-ONLY"); // original overwritten
expect(Math.abs(fs.statSync(file).mtimeMs - beforeMtime)).toBeLessThan(1500); // mtime preserved expect(Math.abs(fs.statSync(file).mtimeMs - beforeMtime)).toBeLessThan(1500); // mtime preserved
expect(fs.existsSync(`${file}.gertmp.mkv`)).toBe(false); // temp cleaned up expect(leftoverTemps(file)).toEqual([]); // unique temp cleaned up
}); });
it("leaves the original intact and removes temp when ffmpeg fails", async () => { it("leaves the original intact and removes temp when ffmpeg fails", async () => {
@ -209,7 +244,23 @@ describe("processVideoFile (real fs body, fake runner)", () => {
expect(result.action).toBe("error"); expect(result.action).toBe("error");
expect(fs.readFileSync(file, "utf8")).toBe("ORIGINAL"); // never lost expect(fs.readFileSync(file, "utf8")).toBe("ORIGINAL"); // never lost
expect(fs.existsSync(`${file}.gertmp.mkv`)).toBe(false); expect(leftoverTemps(file)).toEqual([]);
});
it("keeps the original intact and cleans the temp when the atomic replace rename fails (no zero-copy window)", async () => {
// Simulate a Windows file lock that defeats the replace even after retries.
// The original must survive: the old rm-then-rename fallback could leave the
// file with NEITHER the original nor the remux on disk.
const file = makeFile("ORIGINAL");
const result = await processVideoFile(file, { mode: "tag" }, {
resolveTooling: tooling,
runProcess: fakeRunner({ probeJson: twoTracksGerSecond }),
rename: async () => { throw Object.assign(new Error("locked"), { code: "EBUSY" }); }
});
expect(result.action).toBe("error");
expect(fs.readFileSync(file, "utf8")).toBe("ORIGINAL"); // original never destroyed
expect(leftoverTemps(file)).toEqual([]); // remux temp removed
}); });
it("does not touch a single-audio file (no remux)", async () => { it("does not touch a single-audio file (no remux)", async () => {
@ -222,8 +273,21 @@ describe("processVideoFile (real fs body, fake runner)", () => {
expect(fs.readFileSync(file, "utf8")).toBe("ORIGINAL"); expect(fs.readFileSync(file, "utf8")).toBe("ORIGINAL");
}); });
it("leaves the file untouched when tagged but no German track", async () => { it("remuxes a German-named release with MISLABELED audio tags (fallback to first track)", async () => {
const file = makeFile("ORIGINAL"); // 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" }, { const result = await processVideoFile(file, { mode: "tag" }, {
resolveTooling: tooling, resolveTooling: tooling,
runProcess: fakeRunner({ probeJson: JSON.stringify({ streams: [{ tags: { language: "eng" } }, { tags: { language: "fre" } }] }) }) runProcess: fakeRunner({ probeJson: JSON.stringify({ streams: [{ tags: { language: "eng" } }, { tags: { language: "fre" } }] }) })
@ -239,3 +303,35 @@ describe("processVideoFile (real fs body, fake runner)", () => {
expect(fs.readFileSync(file, "utf8")).toBe("ORIGINAL"); expect(fs.readFileSync(file, "utf8")).toBe("ORIGINAL");
}); });
}); });
describe("renameWithRetry", () => {
afterEach(() => { vi.restoreAllMocks(); });
const busy = (): NodeJS.ErrnoException => Object.assign(new Error("locked"), { code: "EBUSY" });
it("retries a transient EBUSY and then succeeds", async () => {
let calls = 0;
vi.spyOn(fs.promises, "rename").mockImplementation(async () => {
calls += 1;
if (calls <= 2) { throw busy(); }
});
await expect(renameWithRetry("a", "b")).resolves.toBeUndefined();
expect(calls).toBe(3); // failed twice, succeeded on the third attempt
});
it("gives up after exhausting retries on a persistent lock", async () => {
let calls = 0;
vi.spyOn(fs.promises, "rename").mockImplementation(async () => { calls += 1; throw busy(); });
await expect(renameWithRetry("a", "b")).rejects.toThrow("locked");
expect(calls).toBe(4); // initial attempt + 3 backoff retries
});
it("does not retry a non-retryable error (e.g. EXDEV) — fails fast", async () => {
let calls = 0;
vi.spyOn(fs.promises, "rename").mockImplementation(async () => {
calls += 1;
throw Object.assign(new Error("cross-device"), { code: "EXDEV" });
});
await expect(renameWithRetry("a", "b")).rejects.toThrow("cross-device");
expect(calls).toBe(1);
});
});