Compare commits

..

No commits in common. "main" and "v1.7.187" have entirely different histories.

9 changed files with 73 additions and 400 deletions

View File

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

View File

@ -303,27 +303,6 @@ export class AppController {
return next; return next;
} }
// Carry the live, runtime-maintained usage/status counters onto a settings
// object about to be applied, so they are never rolled back to a stale snapshot.
// All-time totals take the max; daily/total usage and account statuses are taken
// live; per-key Debrid-Link usage is filtered to keys that still exist.
private overlayLiveUsageCounters(target: AppSettings): void {
const liveSettings = this.manager.getSettings();
target.totalDownloadedAllTime = Math.max(target.totalDownloadedAllTime || 0, liveSettings.totalDownloadedAllTime || 0);
target.totalCompletedFilesAllTime = Math.max(target.totalCompletedFilesAllTime || 0, liveSettings.totalCompletedFilesAllTime || 0);
target.totalRuntimeAllTimeMs = Math.max(target.totalRuntimeAllTimeMs || 0, this.manager.getLiveTotalRuntimeMs());
target.providerDailyUsageDay = liveSettings.providerDailyUsageDay;
target.providerDailyUsageBytes = { ...(liveSettings.providerDailyUsageBytes || {}) };
target.providerTotalUsageBytes = { ...(liveSettings.providerTotalUsageBytes || {}) };
target.debridLinkApiKeyDailyUsageBytes = Object.fromEntries(
Object.entries(liveSettings.debridLinkApiKeyDailyUsageBytes || {}).filter(([keyId]) => getDebridLinkApiKeyIds(target.debridLinkApiKeys).includes(keyId))
);
target.debridLinkApiKeyTotalUsageBytes = Object.fromEntries(
Object.entries(liveSettings.debridLinkApiKeyTotalUsageBytes || {}).filter(([keyId]) => getDebridLinkApiKeyIds(target.debridLinkApiKeys).includes(keyId))
);
target.debridAccountStatuses = { ...(liveSettings.debridAccountStatuses || {}) };
}
public updateSettings(partial: Partial<AppSettings>): AppSettings { public updateSettings(partial: Partial<AppSettings>): AppSettings {
const sanitizedPatch = sanitizeSettingsPatch(partial); const sanitizedPatch = sanitizeSettingsPatch(partial);
const previousSettings = this.settings; const previousSettings = this.settings;
@ -336,7 +315,20 @@ export class AppController {
return previousSettings; return previousSettings;
} }
this.overlayLiveUsageCounters(nextSettings); const liveSettings = this.manager.getSettings();
nextSettings.totalDownloadedAllTime = Math.max(nextSettings.totalDownloadedAllTime || 0, liveSettings.totalDownloadedAllTime || 0);
nextSettings.totalCompletedFilesAllTime = Math.max(nextSettings.totalCompletedFilesAllTime || 0, liveSettings.totalCompletedFilesAllTime || 0);
nextSettings.totalRuntimeAllTimeMs = Math.max(nextSettings.totalRuntimeAllTimeMs || 0, this.manager.getLiveTotalRuntimeMs());
nextSettings.providerDailyUsageDay = liveSettings.providerDailyUsageDay;
nextSettings.providerDailyUsageBytes = { ...(liveSettings.providerDailyUsageBytes || {}) };
nextSettings.providerTotalUsageBytes = { ...(liveSettings.providerTotalUsageBytes || {}) };
nextSettings.debridLinkApiKeyDailyUsageBytes = Object.fromEntries(
Object.entries(liveSettings.debridLinkApiKeyDailyUsageBytes || {}).filter(([keyId]) => getDebridLinkApiKeyIds(nextSettings.debridLinkApiKeys).includes(keyId))
);
nextSettings.debridLinkApiKeyTotalUsageBytes = Object.fromEntries(
Object.entries(liveSettings.debridLinkApiKeyTotalUsageBytes || {}).filter(([keyId]) => getDebridLinkApiKeyIds(nextSettings.debridLinkApiKeys).includes(keyId))
);
nextSettings.debridAccountStatuses = { ...(liveSettings.debridAccountStatuses || {}) };
const retentionChanged = previousSettings.historyRetentionMode !== nextSettings.historyRetentionMode; const retentionChanged = previousSettings.historyRetentionMode !== nextSettings.historyRetentionMode;
this.settings = nextSettings; this.settings = nextSettings;
if (retentionChanged) { if (retentionChanged) {
@ -705,18 +697,14 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
} }
} }
const restoredSettings = normalizeSettings(importedSettings); const restoredSettings = normalizeSettings(importedSettings);
// Settings-only backup: keep the running queue AND the live counters untouched.
// Overlay the live usage/status counters so they don't roll back to the backup's
// (older) snapshot (BUG I), and suppress the retroactive cleanup sweep so the
// backup's cleanup policy can't purge the live completed queue here (BUG B) — the
// policy still governs FUTURE completions through the normal path. Do NOT stop the
// manager, wipe the session, block persistence or relaunch.
if (!hasSession) {
this.overlayLiveUsageCounters(restoredSettings);
this.settings = restoredSettings; this.settings = restoredSettings;
saveSettings(this.storagePaths, this.settings); saveSettings(this.storagePaths, this.settings);
this.manager.setSettings(this.settings, { suppressRetroactiveCleanup: true }); this.manager.setSettings(this.settings);
// Settings-only backup: settings are already applied live (same path as the
// normal updateSettings flow). Do NOT stop the manager, wipe the session,
// block persistence or relaunch — the running queue stays untouched.
if (!hasSession) {
this.audit("INFO", "Backup importiert (nur Einstellungen)", { this.audit("INFO", "Backup importiert (nur Einstellungen)", {
accountSummary: buildAccountSummary(this.settings) accountSummary: buildAccountSummary(this.settings)
}); });
@ -727,10 +715,6 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
}; };
} }
this.settings = restoredSettings;
saveSettings(this.storagePaths, this.settings);
this.manager.setSettings(this.settings);
this.manager.stop(); this.manager.stop();
this.manager.abortAllPostProcessing(); this.manager.abortAllPostProcessing();
this.manager.clearPersistTimer(); this.manager.clearPersistTimer();

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, 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 { 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";
@ -2081,7 +2081,7 @@ export class DownloadManager extends EventEmitter {
this.emitState(); this.emitState();
} }
public setSettings(next: AppSettings, opts?: { suppressRetroactiveCleanup?: boolean }): void { public setSettings(next: AppSettings): void {
const previous = this.settings; const previous = this.settings;
next.totalDownloadedAllTime = Math.max(next.totalDownloadedAllTime || 0, this.settings.totalDownloadedAllTime || 0); next.totalDownloadedAllTime = Math.max(next.totalDownloadedAllTime || 0, this.settings.totalDownloadedAllTime || 0);
next.totalCompletedFilesAllTime = Math.max(next.totalCompletedFilesAllTime || 0, this.settings.totalCompletedFilesAllTime || 0); next.totalCompletedFilesAllTime = Math.max(next.totalCompletedFilesAllTime || 0, this.settings.totalCompletedFilesAllTime || 0);
@ -2145,7 +2145,7 @@ export class DownloadManager extends EventEmitter {
this.resolveExistingQueuedOpaqueFilenames(); this.resolveExistingQueuedOpaqueFilenames();
void this.cleanupExistingExtractedArchives().catch((err) => logger.warn(`cleanupExistingExtractedArchives Fehler (setSettings): ${compactErrorText(err)}`)); void this.cleanupExistingExtractedArchives().catch((err) => logger.warn(`cleanupExistingExtractedArchives Fehler (setSettings): ${compactErrorText(err)}`));
if (!opts?.suppressRetroactiveCleanup && next.completedCleanupPolicy !== "never") { if (next.completedCleanupPolicy !== "never") {
this.applyRetroactiveCleanupPolicy(); this.applyRetroactiveCleanupPolicy();
} }
this.emitState(); this.emitState();
@ -3546,11 +3546,6 @@ export class DownloadManager extends EventEmitter {
if (!entry.isFile()) { if (!entry.isFile()) {
continue; continue;
} }
// Never collect our own remux temp/orphan sidecars (~rd<token>.<ext>): a
// partial file left by a crash mid-remux must not be swept into the library.
if (entry.name.startsWith("~rd")) {
continue;
}
const extension = path.extname(entry.name).toLowerCase(); const extension = path.extname(entry.name).toLowerCase();
if (!normalizedExtensions.has(extension)) { if (!normalizedExtensions.has(extension)) {
continue; continue;
@ -3946,26 +3941,12 @@ 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, 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"; 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;
@ -3980,7 +3961,13 @@ export class DownloadManager extends EventEmitter {
if (result.action === "aborted") { if (result.action === "aborted") {
return processed; 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) { 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);
@ -3988,22 +3975,11 @@ 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);
} }
// Per-file main-log lines so the cause of any unprocessed file is visible if (result.action === "remuxed") {
// 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
@ -4012,10 +3988,6 @@ 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;
} }
@ -6112,11 +6084,6 @@ export class DownloadManager extends EventEmitter {
} }
private dropItemContribution(itemId: string): void { private dropItemContribution(itemId: string): void {
// NOTE: deliberately does NOT subtract from session.totalDownloadedBytes /
// sessionDownloadedBytes. Those are cumulative-session counters and must stay
// put when a completed item is removed from the queue (see the test "keeps
// cumulative session totals when completed items are removed from the queue").
// The retry path subtracts on its own because those bytes get re-downloaded.
this.itemContributedBytes.delete(itemId); this.itemContributedBytes.delete(itemId);
this.invalidateStatsCache(); this.invalidateStatsCache();
} }
@ -7161,9 +7128,6 @@ export class DownloadManager extends EventEmitter {
const abortController = new AbortController(); const abortController = new AbortController();
this.packagePostProcessAbortControllers.set(packageId, abortController); this.packagePostProcessAbortControllers.set(packageId, abortController);
// Holder so the task's own finally can identity-check itself (the task Promise
// cannot reference its own const inside its initializer). Assigned right after.
const handle: { task?: Promise<void> } = {};
const task = (async () => { const task = (async () => {
const slotWaitStart = nowMs(); const slotWaitStart = nowMs();
await this.acquirePostProcessSlot(packageId); await this.acquirePostProcessSlot(packageId);
@ -7210,16 +7174,8 @@ export class DownloadManager extends EventEmitter {
} while (this.hybridExtractRequeue.has(packageId)); } while (this.hybridExtractRequeue.has(packageId));
} finally { } finally {
this.releasePostProcessSlot(); this.releasePostProcessSlot();
// Identity guard: only clear the map entries if they still point to THIS
// task/controller. After an abort deletes our handle a new run can install
// a fresh task+controller for the same packageId; a blind delete here would
// orphan that newer task (uncancellable) and allow a duplicate concurrent run.
if (this.packagePostProcessTasks.get(packageId) === handle.task) {
this.packagePostProcessTasks.delete(packageId); this.packagePostProcessTasks.delete(packageId);
}
if (this.packagePostProcessAbortControllers.get(packageId) === abortController) {
this.packagePostProcessAbortControllers.delete(packageId); this.packagePostProcessAbortControllers.delete(packageId);
}
this.persistSoon(); this.persistSoon();
this.emitState(); this.emitState();
if (this.hybridExtractRequeue.delete(packageId)) { if (this.hybridExtractRequeue.delete(packageId)) {
@ -7230,7 +7186,6 @@ export class DownloadManager extends EventEmitter {
} }
})(); })();
handle.task = task;
this.packagePostProcessTasks.set(packageId, task); this.packagePostProcessTasks.set(packageId, task);
return task; return task;
} }

View File

@ -2883,13 +2883,6 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
const resumeCompletedAtStart = resumeCompleted.size; const resumeCompletedAtStart = resumeCompleted.size;
const allCandidateNames = new Set(allCandidates.map((archivePath) => archiveNameKey(path.basename(archivePath)))); const allCandidateNames = new Set(allCandidates.map((archivePath) => archiveNameKey(path.basename(archivePath))));
for (const archiveName of Array.from(resumeCompleted.values())) { for (const archiveName of Array.from(resumeCompleted.values())) {
// Nested-archive progress (keyed "nested:<name>") has no top-level candidate on
// disk to validate against, so it must NOT be pruned here — otherwise every
// extractPackageArchives call wiped it and nested archives were re-extracted on
// resume. It is cleared together with the rest once the package fully completes.
if (archiveName.startsWith("nested:")) {
continue;
}
if (!allCandidateNames.has(archiveName)) { if (!allCandidateNames.has(archiveName)) {
resumeCompleted.delete(archiveName); resumeCompleted.delete(archiveName);
} }

View File

@ -183,14 +183,7 @@ async function flushAsync(): Promise<void> {
} }
flushInFlight = true; flushInFlight = true;
// Move (not copy) the pending lines out and take ownership. A concurrent write() const linesSnapshot = pendingLines.slice();
// during the await below pushes new lines AND can trim the 1MB cap from the FRONT
// of pendingLines; the old count-based removal (pendingLines.slice(snapshot.length))
// then sliced off the wrong lines and dropped unwritten ones. Resetting the buffer
// here means await-time writes queue independently and nothing desyncs.
const linesSnapshot = pendingLines;
pendingLines = [];
pendingChars = 0;
const chunk = linesSnapshot.join(""); const chunk = linesSnapshot.join("");
try { try {
@ -207,19 +200,9 @@ async function flushAsync(): Promise<void> {
} else if (!primary.ok) { } else if (!primary.ok) {
writeStderr(`LOGGER write failed: ${primary.errorText}\n`); writeStderr(`LOGGER write failed: ${primary.errorText}\n`);
} }
if (!wroteAny) { if (wroteAny) {
// Write failed: requeue the unwritten lines AHEAD of anything that arrived pendingLines = pendingLines.slice(linesSnapshot.length);
// during the await (preserve order), then re-apply the buffer cap so a pendingChars = Math.max(0, pendingChars - chunk.length);
// persistent write failure cannot grow the buffer without bound.
pendingLines = linesSnapshot.concat(pendingLines);
pendingChars += chunk.length;
while (pendingChars > LOG_BUFFER_LIMIT_CHARS && pendingLines.length > 1) {
const removed = pendingLines.shift();
if (!removed) {
break;
}
pendingChars = Math.max(0, pendingChars - removed.length);
}
} }
} finally { } finally {
flushInFlight = false; flushInFlight = false;

View File

@ -1,7 +1,6 @@
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.
@ -40,7 +39,6 @@ export interface VideoProcessResult {
reason: string; reason: string;
keptTrackIndex?: number; keptTrackIndex?: number;
totalAudioTracks?: number; totalAudioTracks?: number;
audioLanguages?: string[];
error?: string; error?: string;
} }
@ -56,9 +54,6 @@ 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"]);
@ -86,31 +81,18 @@ 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 token — the
// ".DL." marker alone (present on every processed file) is not enough, and a bare
// "dubbed" can mean an Italian/French dub, so it must NOT flag a German release.
export function looksLikeGermanRelease(fileName: string): boolean {
return /(^|[._\s-])(german|deutsch)([._\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)) {
return true; return true;
} }
// Free-text title fallback (used when the language tag is missing). Full words
// only — the 2-3 letter codes ger/deu are too ambiguous in a title and would
// pick the wrong track to keep (which then deletes the real German one).
const title = (stream.title || "").toLowerCase(); const title = (stream.title || "").toLowerCase();
return /\b(german|deutsch)\b/.test(title); return /\b(german|deutsch|ger|deu)\b/.test(title);
} }
// 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, germanRelease = false): AudioTrackDecision { export function pickAudioTrack(streams: ProbedAudioStream[], mode: GermanAudioMode): 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" };
@ -134,15 +116,7 @@ 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" };
} }
if (germanRelease) { // Tagged, but no German track -> never guess-delete the only usable audio.
// 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" };
} }
@ -386,41 +360,6 @@ 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;
@ -441,18 +380,16 @@ export async function processVideoFile(filePath: string, opts: ProcessVideoOptio
} }
const streams = parseFfprobeAudioStreams(probe.stdout); const streams = parseFfprobeAudioStreams(probe.stdout);
const audioLanguages = streams.map((s) => (s.language || "").trim() || "und"); const decision = pickAudioTrack(streams, opts.mode);
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, audioLanguages, keptTrackIndex: 0 }; return { action: "kept-single", reason: decision.reason, totalAudioTracks: streams.length, keptTrackIndex: 0 };
} }
// remux path // remux path
@ -460,14 +397,15 @@ 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), audioLanguages }; return { action: "error", reason: "stat fehlgeschlagen", error: String(error) };
} }
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, audioLanguages }; return { action: "skipped-no-space", reason: "zu wenig freier Speicher fuer Remux", totalAudioTracks: streams.length };
} }
const tempPath = uniqueTempPath(filePath); const ext = path.extname(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(
@ -481,30 +419,27 @@ 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, 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); 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, audioLanguages }; return { action: "error", reason: "Remux ergab leere Datei", totalAudioTracks: streams.length };
} }
const renameOp = deps.rename || renameWithRetry;
try { try {
// Atomic replace-over: libuv maps fs.rename to MoveFileEx(REPLACE_EXISTING) on // libuv rename replaces an existing destination on Windows; fall back if not.
// Windows and rename(2) on POSIX, both atomic on the same volume, so filePath await fs.promises.rename(tempPath, filePath).catch(async () => {
// holds either the full original or the full remux at every instant. Retried await fs.promises.rm(filePath, { force: true });
// for transient locks. We must NEVER rm the original first (the old fallback await fs.promises.rename(tempPath, filePath);
// 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, 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 };
} }

View File

@ -1,72 +1,8 @@
# Real-Debrid-Downloader — Tasks (Stand 2026-06-08) # Real-Debrid-Downloader — Tasks (Stand 2026-06-07)
**Status:** Alle zugesagten Features erledigt+released (Archiv unten). Aktuell läuft ein **Status:** Alle zugesagten Features erledigt+released (Archiv unten). EIN Bug analysiert
**intensiver Bug-Audit** (User-Goal 2026-06-08, "schaue intensiv nach weiteren Bugs") — + geparkt (Mega-Web Account-3-Rotation, siehe direkt unten — wartet auf 1 Log-Zahl vom User).
Fortschritt direkt unten. Rest ist freiwilliger Backlog.
---
## 🔴 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 — v1.7.190 (GEFIXT + verifiziert, ein Commit pro Fix)
- [x] **L+M** video-processor.ts zu weite Deutsch-Erkennung. isGermanStream Titel-Fallback nur
ganze Wörter (ger/deu raus → konnten falsche Spur picken + echte dt. löschen); looksLikeGerman
Release 'dubbed' raus (ital./franz. Dub triggerte German-first). 2 Negativtests. Commit 272a41a.
- [x] **H** logger.ts flushAsync slice-snapshot korrumpiert bei 1MB-Cap-Trim während await →
ungeschriebene Zeilen verloren. Move-snapshot (Buffer auf [] übernehmen) + Requeue bei
Schreibfehler. Commit 4432fa2.
- [x] **J+Q** download-manager. J: runPackagePostProcessing finally löschte Map-Eintrag ohne
Identity-Guard → Abort+Neustart-Race riss neuen Task raus (Waise + Doppel-Lauf); jetzt nur
löschen wenn Map noch auf DIESEN Task/Controller zeigt (handle-Objekt wegen TS2454). Q:
collectFilesByExtensions filtert `~rd`-Temp-Präfix (crash-verwaiste Teil-Remuxe nie ins
Library). Commit 3c33b98.
- [x] **P** extractor.ts nested-Resume-Keys (`nested:<name>`) bei jedem extractPackageArchives
gepurged → verschachtelte Archive beim Resume neu entpackt; `startsWith("nested:")` im Prune
übersprungen. Commit 61a8304.
- [x] **B/I** app-controller.ts importBackup settings-only purgte LIVE-Queue (Dateien blieben auf
Platte) + rollte Usage-Zähler zurück. Fix: setSettings({suppressRetroactiveCleanup}) +
overlayLiveUsageCounters (extrahiert+wiederverwendet, inkl. Key-Filter). Commit dc05b51.
### Verifiziert KEINE Bugs / bewusst NICHT angefasst (Advisor-Disziplin: erst belegen, dann ändern)
- **G** dropItemContribution "subtrahiert Session-Totals nicht" → **KEIN Bug**: Test "keeps
cumulative session totals when completed items are removed" kodifiziert die Absicht (Session-
Zähler kumulativ, divergieren bewusst von der Item-Map; Retry-Pfad zieht ab, weil neu geladen
wird). Fix-Versuch ließ den Test failen → revertiert, Klarstellungs-Kommentar gesetzt.
- **N** stripDualLangFromFileName "Kollision" → **bereits geguarded**: existsAsync-Skip verhindert
Überschreiben; Remux machte Inhalt eh deutsch-only; collect strippt `.DL.` downstream. Residual
= generischer Rename-TOCTOU (in JEDEM Rename-Pfad), kein spezifischer Bug hier.
- **D/E** abort-Klassifizierung über signal.reason statt Text → **deferred (Robustheit, kein
Live-Bug auf User-Pfad)**. BELEGT: mega-web-fallback normalisiert JEDEN Abort (Timeout UND
Cancel) zu `new Error("aborted:mega-web")` → aktueller Guard `/aborted/i && !/timeout/i` FEUERT
→ v1.7.187-Cooldown LÄUFT auf dem Web-Pfad (User-Pfad). Einzige Imperfektion: Cancel >8s wird
fälschlich gecooled (minor). Empirisch bestätigt: `AbortSignal.any([ac,timeout]).reason?.name===
'TimeoutError'` (timeout) vs string/AbortError (cancel) — falls je gebaut: signal.aborted-gaten,
reason.name nutzen, Text-Fallback behalten, reason-Test. Hoch-Risiko (kritischer Unrestrict-Pfad
JEDES Downloads) → nicht für Robustheit anfassen. API-Pfad-Abort-Text nicht erschöpfend geprüft.
- **E** "API 'cancel'-Pfad umgeht" → **nicht real**: kein `'cancel'`-throw im Code gefunden.
- **O** classifyAccountFailure abort-Branch tot → **stehen lassen**: tot NUR wegen aktueller
Text-Interception; ein signal.aborted-gated D/E würde ihn wiederbeleben. Kein Kosmetik-Churn.
- **F** Mega-Web empty-streak Concurrency → **N-shaped, deferred**: Streak wird bei Erfolg (1956)
+ Nicht-Limit-Fehler (2005) gecleart; "bis Neustart gesperrt" ist bewusste Tageslimit-Logik,
Restart-cleared; Mega-Web single-flight → Concurrency greift nicht. Keine fühlbare Schädigung
konstruierbar → keine Park-State-Maschinerie.
- **C** → in A subsumiert (unique Temp-Name). **K** übersprungen (auto-rename-Reorder, Risiko≫Nutzen).
--- ---

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(), resolveVideoTooling: vi.fn() }; return { ...actual, processVideoFile: vi.fn() };
}); });
import { DownloadManager } from "../src/main/download-manager"; 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 { 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, 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 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();
@ -68,9 +66,6 @@ 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 };
} }
@ -136,15 +131,14 @@ 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("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); const { extractDir, manager, pkg } = setup(true);
stage(extractDir); 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); 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,19 +1,17 @@
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, vi } from "vitest"; import { afterEach, describe, expect, it } 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";
@ -85,13 +83,6 @@ describe("pickAudioTrack", () => {
expect(d).toMatchObject({ action: "remux", audioRelIndex: 1 }); expect(d).toMatchObject({ action: "remux", audioRelIndex: 1 });
}); });
it("tag mode does NOT treat an ambiguous 3-letter title code as German (no false-positive pick)", () => {
// Two untagged tracks whose titles are only "Ger"/"Deu" must not be mistaken
// for a German track; with no real German signal this falls back to first.
const d = pickAudioTrack([{ language: "", title: "Ger" }, { language: "", title: "Deu" }], "tag");
expect(d).toMatchObject({ action: "remux", audioRelIndex: 0, reason: "fallback-first-untagged" });
});
it("tag mode with single German -> single (no remux)", () => { it("tag mode with single German -> single (no remux)", () => {
expect(pickAudioTrack([ger], "tag")).toMatchObject({ action: "single" }); expect(pickAudioTrack([ger], "tag")).toMatchObject({ action: "single" });
}); });
@ -104,38 +95,6 @@ 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);
});
it("does not flag a non-German dub as a German release (bare 'Dubbed' is ambiguous)", () => {
expect(looksLikeGermanRelease("Movie.2020.ITALIAN.Dubbed.DL.1080p.mkv")).toBe(false);
expect(looksLikeGermanRelease("Movie.2020.FRENCH.DUBBED.DL.720p.mkv")).toBe(false);
});
}); });
describe("parseFfprobeAudioStreams", () => { describe("parseFfprobeAudioStreams", () => {
@ -197,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-")); const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-vp-"));
tempDirs.push(dir); tempDirs.push(dir);
const file = path.join(dir, name); const file = path.join(dir, "Show.S01E01.German.DL.720p.mkv");
fs.writeFileSync(file, content); fs.writeFileSync(file, content);
return file; return file;
} }
@ -220,11 +179,6 @@ 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" } }] });
@ -243,7 +197,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(leftoverTemps(file)).toEqual([]); // unique temp cleaned up expect(fs.existsSync(`${file}.gertmp.mkv`)).toBe(false); // 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 () => {
@ -255,23 +209,7 @@ 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(leftoverTemps(file)).toEqual([]); expect(fs.existsSync(`${file}.gertmp.mkv`)).toBe(false);
});
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 () => {
@ -284,21 +222,8 @@ describe("processVideoFile (real fs body, fake runner)", () => {
expect(fs.readFileSync(file, "utf8")).toBe("ORIGINAL"); expect(fs.readFileSync(file, "utf8")).toBe("ORIGINAL");
}); });
it("remuxes a German-named release with MISLABELED audio tags (fallback to first track)", async () => { it("leaves the file untouched when tagged but no German track", async () => {
// Name says German, but both audio tracks are tagged eng/fre (the dub is const file = makeFile("ORIGINAL");
// 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" } }] }) })
@ -314,35 +239,3 @@ 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);
});
});