Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92890f9649 | ||
|
|
2b93f47d3a | ||
|
|
15edfbeb74 | ||
|
|
aa65f56c28 | ||
|
|
92a36e2e47 | ||
|
|
77661389f3 | ||
|
|
397e667af2 |
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "1.7.185",
|
"version": "1.7.188",
|
||||||
"description": "Desktop downloader",
|
"description": "Desktop downloader",
|
||||||
"main": "build/main/main/main.js",
|
"main": "build/main/main/main.js",
|
||||||
"author": "Sucukdeluxe",
|
"author": "Sucukdeluxe",
|
||||||
|
|||||||
@ -72,6 +72,8 @@ export function defaultSettings(): AppSettings {
|
|||||||
packageName: "",
|
packageName: "",
|
||||||
autoExtract: true,
|
autoExtract: true,
|
||||||
autoRename4sf4sj: false,
|
autoRename4sf4sj: false,
|
||||||
|
keepGermanAudioOnly: false,
|
||||||
|
germanAudioMode: "tag",
|
||||||
extractDir: path.join(baseDir, "_entpackt"),
|
extractDir: path.join(baseDir, "_entpackt"),
|
||||||
collectMkvToLibrary: false,
|
collectMkvToLibrary: false,
|
||||||
mkvLibraryDir: path.join(baseDir, "_mkv"),
|
mkvLibraryDir: path.join(baseDir, "_mkv"),
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -54,6 +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 { 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";
|
||||||
@ -2040,7 +2041,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
private logRenameProcess(
|
private logRenameProcess(
|
||||||
pkg: PackageEntry,
|
pkg: PackageEntry,
|
||||||
level: "INFO" | "WARN" | "ERROR",
|
level: "INFO" | "WARN" | "ERROR",
|
||||||
stage: "auto-rename" | "mkv-move",
|
stage: "auto-rename" | "mkv-move" | "audio-strip",
|
||||||
message: string,
|
message: string,
|
||||||
fields?: Record<string, unknown>,
|
fields?: Record<string, unknown>,
|
||||||
item?: DownloadItem | null,
|
item?: DownloadItem | null,
|
||||||
@ -3892,6 +3893,151 @@ export class DownloadManager extends EventEmitter {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async keepGermanAudioOnly(
|
||||||
|
extractDir: string,
|
||||||
|
pkg?: PackageEntry,
|
||||||
|
shouldAbort?: () => boolean,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<number> {
|
||||||
|
if (!pkg) {
|
||||||
|
return this.keepGermanAudioOnlyImpl(extractDir, undefined, shouldAbort, signal);
|
||||||
|
}
|
||||||
|
return this.chainPackageFileOp(pkg.id, () =>
|
||||||
|
this.keepGermanAudioOnlyImpl(extractDir, pkg, shouldAbort, signal)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post-extract step: for ".DL." (Dual-Language) MKV/MP4 files, keep only the
|
||||||
|
// German audio track and strip the ".DL." marker from the filename. Operates
|
||||||
|
// only inside pkg.extractDir, before MKV-collect. Best-effort per file; an
|
||||||
|
// error never fails the package. Original is never lost (see video-processor).
|
||||||
|
private async keepGermanAudioOnlyImpl(
|
||||||
|
extractDir: string,
|
||||||
|
pkg?: PackageEntry,
|
||||||
|
shouldAbort?: () => boolean,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<number> {
|
||||||
|
if (!this.settings.keepGermanAudioOnly) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const mkvLibraryDir = String(this.settings.mkvLibraryDir || "").trim();
|
||||||
|
if (mkvLibraryDir && (isPathInsideDir(mkvLibraryDir, extractDir) || isPathInsideDir(extractDir, mkvLibraryDir))) {
|
||||||
|
logger.warn(`Tonspur-Bereinigung ABGEBROCHEN: extractDir=${extractDir} ueberlappt mit mkvLibraryDir=${mkvLibraryDir}`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const videoFiles = await this.collectVideoFiles(extractDir);
|
||||||
|
const sampleTokenRe = /(^|[._\-\s])sample([._\-\s]|$)/i;
|
||||||
|
const targets = videoFiles.filter((p) => {
|
||||||
|
const name = path.basename(p);
|
||||||
|
if (!isRemuxableVideoFile(name) || !hasDualLangMarker(name)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (sampleTokenRe.test(name) || ["sample", "samples"].includes(path.basename(path.dirname(p)).toLowerCase())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
if (targets.length === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
logger.info(`Tonspur-Bereinigung: ${targets.length} .DL.-Datei(en) in ${extractDir}, Modus=${this.settings.germanAudioMode}`);
|
||||||
|
if (pkg) {
|
||||||
|
this.logRenameProcess(pkg, "INFO", "audio-strip", "Tonspur-Bereinigung gestartet", { extractDir, candidates: targets.length, mode: this.settings.germanAudioMode });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve ffmpeg/ffprobe ONCE up front and log it loudly — a missing tool is
|
||||||
|
// the single most common reason the whole step silently does nothing.
|
||||||
|
const tooling = await resolveVideoTooling();
|
||||||
|
if (!tooling) {
|
||||||
|
logger.warn(`Tonspur-Bereinigung: ffmpeg/ffprobe NICHT gefunden — Schritt uebersprungen, ${targets.length} Datei(en) unangetastet. ffmpeg in den PATH legen oder RD_FFMPEG_BIN/RD_FFPROBE_BIN setzen.`);
|
||||||
|
if (pkg) {
|
||||||
|
this.logRenameProcess(pkg, "WARN", "audio-strip", "Tonspur-Bereinigung uebersprungen: ffmpeg/ffprobe nicht gefunden", { candidates: targets.length });
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
logger.info(`Tonspur-Bereinigung: ffmpeg=${tooling.ffmpeg} ffprobe=${tooling.ffprobe}`);
|
||||||
|
|
||||||
|
const mode: GermanAudioMode = this.settings.germanAudioMode === "first" ? "first" : "tag";
|
||||||
|
let processed = 0;
|
||||||
|
let failed = 0;
|
||||||
|
for (const sourcePath of targets) {
|
||||||
|
if (shouldAbort?.() || signal?.aborted) {
|
||||||
|
return processed;
|
||||||
|
}
|
||||||
|
const sourceName = path.basename(sourcePath);
|
||||||
|
let result: VideoProcessResult;
|
||||||
|
try {
|
||||||
|
result = await processVideoFile(sourcePath, { mode, cpuPriority: this.settings.extractCpuPriority, signal });
|
||||||
|
} catch (error) {
|
||||||
|
result = { action: "error", reason: "exception", error: compactErrorText(error) };
|
||||||
|
}
|
||||||
|
if (result.action === "aborted") {
|
||||||
|
return processed;
|
||||||
|
}
|
||||||
|
const langs = (result.audioLanguages || []).join(",");
|
||||||
|
if (pkg) {
|
||||||
|
const level = result.action === "error" ? "WARN" : "INFO";
|
||||||
|
const resolved = this.inferItemForMediaLog(pkg, sourcePath, sourceName);
|
||||||
|
this.logRenameProcess(pkg, level, "audio-strip", `Tonspur: ${result.action} (${result.reason})`, {
|
||||||
|
sourceName,
|
||||||
|
keptTrack: result.keptTrackIndex,
|
||||||
|
audioTracks: result.totalAudioTracks,
|
||||||
|
languages: langs || undefined,
|
||||||
|
...(result.error ? { error: result.error } : {})
|
||||||
|
}, resolved.item, resolved.matchedBy);
|
||||||
|
}
|
||||||
|
// Per-file main-log lines so the cause of any unprocessed file is visible
|
||||||
|
// without opening the rename/item logs.
|
||||||
|
if (result.action === "error") {
|
||||||
|
failed += 1;
|
||||||
|
logger.warn(`Tonspur-Bereinigung FEHLER: ${sourceName} — ${result.reason}${result.error ? ` — ${result.error}` : ""} (Spuren: ${langs || "?"}, ${result.totalAudioTracks ?? "?"} Audio)`);
|
||||||
|
} else if (result.action === "remuxed") {
|
||||||
|
processed += 1;
|
||||||
|
logger.info(`Tonspur-Bereinigung OK: ${sourceName} — Spur ${result.keptTrackIndex} behalten (${langs || "?"})`);
|
||||||
|
} else if (result.action === "skipped-no-german") {
|
||||||
|
logger.info(`Tonspur-Bereinigung uebersprungen (kein Deutsch-Tag, Spuren: ${langs || "?"}): ${sourceName}`);
|
||||||
|
} else if (result.action === "skipped-no-space") {
|
||||||
|
logger.warn(`Tonspur-Bereinigung uebersprungen (zu wenig Speicher): ${sourceName}`);
|
||||||
|
}
|
||||||
|
// Only strip ".DL." once the file is confirmed German-only (remuxed) or
|
||||||
|
// already single-track. Skips/errors leave the file fully untouched so the
|
||||||
|
// unprocessed state stays visible.
|
||||||
|
if (result.action === "remuxed" || result.action === "kept-single") {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async stripDualLangFromFileName(sourcePath: string, pkg?: PackageEntry): Promise<void> {
|
||||||
|
const dir = path.dirname(sourcePath);
|
||||||
|
const name = path.basename(sourcePath);
|
||||||
|
const newName = stripDualLangMarker(name);
|
||||||
|
if (newName === name) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const targetPath = path.join(dir, newName);
|
||||||
|
if (pathKey(targetPath) !== pathKey(sourcePath) && await this.existsAsync(targetPath)) {
|
||||||
|
logger.warn(`.DL.-Strip uebersprungen (Ziel existiert): ${newName}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await this.renamePathWithExdevFallback(sourcePath, targetPath, { label: "audio-strip" });
|
||||||
|
await this.renameCompanionFiles(sourcePath, targetPath, pkg);
|
||||||
|
if (pkg) {
|
||||||
|
const resolved = this.inferItemForMediaLog(pkg, targetPath, path.basename(targetPath));
|
||||||
|
this.logRenameProcess(pkg, "INFO", "audio-strip", ".DL. aus Dateiname entfernt", { sourcePath, targetPath }, resolved.item, resolved.matchedBy);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`.DL.-Strip fehlgeschlagen (${name}): ${compactErrorText(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async autoRenameExtractedVideoFilesImpl(
|
private async autoRenameExtractedVideoFilesImpl(
|
||||||
extractDir: string,
|
extractDir: string,
|
||||||
pkg?: PackageEntry,
|
pkg?: PackageEntry,
|
||||||
@ -4511,7 +4657,12 @@ export class DownloadManager extends EventEmitter {
|
|||||||
if (!cleanBase) {
|
if (!cleanBase) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const cleanFileName = `${cleanBase}${sourceExt}`;
|
// Safety net: collect derives names from folder tokens that may still carry
|
||||||
|
// ".DL.". When German-audio-only is on, never reintroduce the marker the
|
||||||
|
// audio step already stripped from the file.
|
||||||
|
const cleanFileName = this.settings.keepGermanAudioOnly
|
||||||
|
? stripDualLangMarker(`${cleanBase}${sourceExt}`)
|
||||||
|
: `${cleanBase}${sourceExt}`;
|
||||||
if (cleanFileName.toLowerCase() === path.basename(sourcePath).toLowerCase()) {
|
if (cleanFileName.toLowerCase() === path.basename(sourcePath).toLowerCase()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -7076,6 +7227,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return this.settings.autoRename4sf4sj
|
return this.settings.autoRename4sf4sj
|
||||||
|
|| this.settings.keepGermanAudioOnly
|
||||||
|| this.settings.collectMkvToLibrary
|
|| this.settings.collectMkvToLibrary
|
||||||
|| this.settings.removeLinkFilesAfterExtract
|
|| this.settings.removeLinkFilesAfterExtract
|
||||||
|| this.settings.removeSamplesAfterExtract
|
|| this.settings.removeSamplesAfterExtract
|
||||||
@ -10942,6 +11094,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
try {
|
try {
|
||||||
await this.chainPackageFileOp(pkg.id, async () => {
|
await this.chainPackageFileOp(pkg.id, async () => {
|
||||||
await this.autoRenameExtractedVideoFilesImpl(pkg.extractDir, pkg, hybridShouldAbort);
|
await this.autoRenameExtractedVideoFilesImpl(pkg.extractDir, pkg, hybridShouldAbort);
|
||||||
|
await this.keepGermanAudioOnlyImpl(pkg.extractDir, pkg, hybridShouldAbort, hybridController.signal);
|
||||||
await this.collectMkvFilesToLibrary(packageId, pkg, hybridShouldAbort, true);
|
await this.collectMkvFilesToLibrary(packageId, pkg, hybridShouldAbort, true);
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -11612,6 +11765,12 @@ export class DownloadManager extends EventEmitter {
|
|||||||
});
|
});
|
||||||
throwIfAborted();
|
throwIfAborted();
|
||||||
await this.autoRenameExtractedVideoFiles(pkg.extractDir, pkg, shouldAbort, true);
|
await this.autoRenameExtractedVideoFiles(pkg.extractDir, pkg, shouldAbort, true);
|
||||||
|
if (this.settings.keepGermanAudioOnly) {
|
||||||
|
pkg.postProcessLabel = "Tonspur...";
|
||||||
|
this.emitState();
|
||||||
|
throwIfAborted();
|
||||||
|
await this.keepGermanAudioOnly(pkg.extractDir, pkg, shouldAbort, deferredController.signal);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((extractedCount > 0 || alreadyMarkedExtracted) && failed === 0 && this.settings.cleanupMode !== "none") {
|
if ((extractedCount > 0 || alreadyMarkedExtracted) && failed === 0 && this.settings.cleanupMode !== "none") {
|
||||||
|
|||||||
@ -421,6 +421,8 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
|
|||||||
packageName: asText(settings.packageName),
|
packageName: asText(settings.packageName),
|
||||||
autoExtract: Boolean(settings.autoExtract),
|
autoExtract: Boolean(settings.autoExtract),
|
||||||
autoRename4sf4sj: Boolean(settings.autoRename4sf4sj),
|
autoRename4sf4sj: Boolean(settings.autoRename4sf4sj),
|
||||||
|
keepGermanAudioOnly: Boolean(settings.keepGermanAudioOnly),
|
||||||
|
germanAudioMode: settings.germanAudioMode === "first" ? "first" : "tag",
|
||||||
extractDir: normalizeAbsoluteDir(settings.extractDir, defaults.extractDir),
|
extractDir: normalizeAbsoluteDir(settings.extractDir, defaults.extractDir),
|
||||||
collectMkvToLibrary: Boolean(settings.collectMkvToLibrary),
|
collectMkvToLibrary: Boolean(settings.collectMkvToLibrary),
|
||||||
mkvLibraryDir: normalizeAbsoluteDir(settings.mkvLibraryDir, defaults.mkvLibraryDir),
|
mkvLibraryDir: normalizeAbsoluteDir(settings.mkvLibraryDir, defaults.mkvLibraryDir),
|
||||||
|
|||||||
468
src/main/video-processor.ts
Normal file
468
src/main/video-processor.ts
Normal file
@ -0,0 +1,468 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
|
||||||
|
// Removes only-German audio handling for "Dual Language" (.DL.) scene releases.
|
||||||
|
// Mirrors the user's ffmpeg script but adds: language-tag detection (with safe
|
||||||
|
// fallbacks), disk-space pre-check, atomic temp->replace, mtime preservation,
|
||||||
|
// abort-into-child, and "never destroy the only usable audio" safety.
|
||||||
|
//
|
||||||
|
// The ffmpeg/ffprobe-specific logic lives here so it is mockable in isolation;
|
||||||
|
// the per-package iteration + filename/.DL. rename + logging stays in
|
||||||
|
// download-manager.ts (its existing domain).
|
||||||
|
|
||||||
|
export type GermanAudioMode = "tag" | "first";
|
||||||
|
|
||||||
|
export interface ProbedAudioStream {
|
||||||
|
language: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AudioTrackDecision =
|
||||||
|
| { action: "remux"; audioRelIndex: number; reason: string }
|
||||||
|
| { action: "single"; audioRelIndex: 0; reason: string }
|
||||||
|
| { action: "skip"; reason: string };
|
||||||
|
|
||||||
|
export type VideoProcessAction =
|
||||||
|
| "remuxed"
|
||||||
|
| "kept-single"
|
||||||
|
| "skipped-no-german"
|
||||||
|
| "skipped-no-audio"
|
||||||
|
| "skipped-no-space"
|
||||||
|
| "skipped-no-tool"
|
||||||
|
| "error"
|
||||||
|
| "aborted";
|
||||||
|
|
||||||
|
export interface VideoProcessResult {
|
||||||
|
action: VideoProcessAction;
|
||||||
|
reason: string;
|
||||||
|
keptTrackIndex?: number;
|
||||||
|
totalAudioTracks?: number;
|
||||||
|
audioLanguages?: string[];
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProcessVideoOptions {
|
||||||
|
mode: GermanAudioMode;
|
||||||
|
cpuPriority?: string;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Injection seam so the irreversible file-mutating body (temp -> replace ->
|
||||||
|
// utimes -> rm-on-failure) can be exercised in tests with a fake ffmpeg/ffprobe
|
||||||
|
// runner, without spawning real processes. Production passes nothing.
|
||||||
|
export interface ProcessVideoDeps {
|
||||||
|
resolveTooling?: () => Promise<{ ffmpeg: string; ffprobe: string } | null>;
|
||||||
|
runProcess?: typeof runVideoProcess;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VIDEO_REMUX_EXTENSIONS = new Set([".mkv", ".mp4"]);
|
||||||
|
const PROBE_TIMEOUT_MS = 60_000;
|
||||||
|
const STDOUT_CAP = 2 * 1024 * 1024;
|
||||||
|
const STDERR_CAP = 64 * 1024;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Pure helpers (no fs / no process) — unit-tested in isolation.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// "X.German.DL.720p.mkv" -> "X.German.720p.mkv"; "X.DL.mkv" -> "X.mkv".
|
||||||
|
export function stripDualLangMarker(fileName: string): string {
|
||||||
|
const ext = path.extname(fileName);
|
||||||
|
const base = ext ? fileName.slice(0, -ext.length) : fileName;
|
||||||
|
const stripped = base.replace(/\.DL\./gi, ".").replace(/\.DL$/i, "");
|
||||||
|
return stripped + ext;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasDualLangMarker(fileName: string): boolean {
|
||||||
|
return stripDualLangMarker(fileName) !== fileName;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRemuxableVideoFile(fileName: string): boolean {
|
||||||
|
return VIDEO_REMUX_EXTENSIONS.has(path.extname(fileName).toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
// True when the release name explicitly marks it as a German release. Used in
|
||||||
|
// tag mode to fall back to the first audio track (German-first scene convention)
|
||||||
|
// when the audio language tags are wrong (a German dub mislabeled "eng"), instead
|
||||||
|
// of skipping. Deliberately requires an explicit german/deutsch/dubbed token —
|
||||||
|
// the ".DL." marker alone (present on every processed file) is not enough.
|
||||||
|
export function looksLikeGermanRelease(fileName: string): boolean {
|
||||||
|
return /(^|[._\s-])(german|deutsch|dubbed)([._\s-]|$)/i.test(fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGermanStream(stream: ProbedAudioStream): boolean {
|
||||||
|
const lang = (stream.language || "").toLowerCase().trim();
|
||||||
|
if (["ger", "deu", "de", "german", "deutsch"].includes(lang)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const title = (stream.title || "").toLowerCase();
|
||||||
|
return /\b(german|deutsch|ger|deu)\b/.test(title);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decide which audio track to keep. Safety invariant: only ever choose to remux
|
||||||
|
// (which destroys the original) when we are confident; otherwise skip untouched.
|
||||||
|
export function pickAudioTrack(streams: ProbedAudioStream[], mode: GermanAudioMode, germanRelease = false): AudioTrackDecision {
|
||||||
|
const total = streams.length;
|
||||||
|
if (total === 0) {
|
||||||
|
return { action: "skip", reason: "no-audio" };
|
||||||
|
}
|
||||||
|
if (mode === "first") {
|
||||||
|
return total === 1
|
||||||
|
? { action: "single", audioRelIndex: 0, reason: "single-audio" }
|
||||||
|
: { action: "remux", audioRelIndex: 0, reason: "first-audio" };
|
||||||
|
}
|
||||||
|
// tag mode
|
||||||
|
const germanPos = streams.findIndex(isGermanStream);
|
||||||
|
if (germanPos >= 0) {
|
||||||
|
return total === 1
|
||||||
|
? { action: "single", audioRelIndex: 0, reason: "single-german" }
|
||||||
|
: { action: "remux", audioRelIndex: germanPos, reason: "german-tag" };
|
||||||
|
}
|
||||||
|
const anyTagged = streams.some((s) => (s.language || "").trim().length > 0);
|
||||||
|
if (!anyTagged) {
|
||||||
|
// No language metadata at all -> fall back to the script's behavior.
|
||||||
|
return total === 1
|
||||||
|
? { action: "single", audioRelIndex: 0, reason: "single-untagged" }
|
||||||
|
: { action: "remux", audioRelIndex: 0, reason: "fallback-first-untagged" };
|
||||||
|
}
|
||||||
|
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" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseFfprobeAudioStreams(jsonText: string): ProbedAudioStream[] {
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(jsonText);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const streams = (parsed as { streams?: unknown }).streams;
|
||||||
|
if (!Array.isArray(streams)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return streams.map((raw) => {
|
||||||
|
const tags = (raw && typeof raw === "object" ? (raw as { tags?: unknown }).tags : undefined) as
|
||||||
|
| { language?: unknown; title?: unknown }
|
||||||
|
| undefined;
|
||||||
|
return {
|
||||||
|
language: typeof tags?.language === "string" ? tags.language : "",
|
||||||
|
title: typeof tags?.title === "string" ? tags.title : ""
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildFfprobeArgs(input: string): string[] {
|
||||||
|
return [
|
||||||
|
"-v", "error",
|
||||||
|
"-select_streams", "a",
|
||||||
|
"-show_entries", "stream=index:stream_tags=language,title",
|
||||||
|
"-of", "json",
|
||||||
|
input
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildFfmpegRemuxArgs(opts: { input: string; output: string; audioRelIndex: number; keepSubs?: boolean }): string[] {
|
||||||
|
const args = ["-i", opts.input, "-map", "0:v:0", "-map", `0:a:${opts.audioRelIndex}`];
|
||||||
|
if (opts.keepSubs) {
|
||||||
|
// Optional (not enabled by current settings): keep German subtitle tracks only.
|
||||||
|
args.push("-map", "0:s:m:language:ger?", "-map", "0:s:m:language:deu?");
|
||||||
|
}
|
||||||
|
// Stream-copy and keep metadata (so the kept track's language tag survives;
|
||||||
|
// unlike the original script's -map_metadata -1 which dropped it).
|
||||||
|
args.push("-c", "copy", "-disposition:a:0", "default", "-y", opts.output);
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream-copy remux is disk-bound; generous budget scaled by size, clamped.
|
||||||
|
export function computeRemuxTimeoutMs(bytes: number): number {
|
||||||
|
const perBytes = Math.ceil((Number(bytes) || 0) / (10 * 1024 * 1024)) * 1000;
|
||||||
|
return Math.max(120_000, Math.min(60 * 60 * 1000, 120_000 + perBytes));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tooling discovery (system PATH + RD_FFMPEG_BIN/RD_FFPROBE_BIN env override).
|
||||||
|
// Lazy probe + cache, mirroring the extractor's 7z/Java resolution convention.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface VideoTooling {
|
||||||
|
ffmpeg: string;
|
||||||
|
ffprobe: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cachedTooling: VideoTooling | null | undefined;
|
||||||
|
let cachedToolingNullSince = 0;
|
||||||
|
const TOOLING_NULL_TTL_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
function ffmpegCandidate(): string {
|
||||||
|
return String(process.env.RD_FFMPEG_BIN || "").trim() || "ffmpeg";
|
||||||
|
}
|
||||||
|
|
||||||
|
function ffprobeCandidate(): string {
|
||||||
|
return String(process.env.RD_FFPROBE_BIN || "").trim() || "ffprobe";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probeVersion(command: string): Promise<boolean> {
|
||||||
|
const result = await runVideoProcess(command, ["-version"], { timeoutMs: 10_000 });
|
||||||
|
return result.ok && !result.missing;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveVideoTooling(): Promise<VideoTooling | null> {
|
||||||
|
if (cachedTooling) {
|
||||||
|
return cachedTooling;
|
||||||
|
}
|
||||||
|
if (cachedTooling === null && Date.now() - cachedToolingNullSince < TOOLING_NULL_TTL_MS) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const ffmpeg = ffmpegCandidate();
|
||||||
|
const ffprobe = ffprobeCandidate();
|
||||||
|
const [ffmpegOk, ffprobeOk] = await Promise.all([probeVersion(ffmpeg), probeVersion(ffprobe)]);
|
||||||
|
if (ffmpegOk && ffprobeOk) {
|
||||||
|
cachedTooling = { ffmpeg, ffprobe };
|
||||||
|
return cachedTooling;
|
||||||
|
}
|
||||||
|
cachedTooling = null;
|
||||||
|
cachedToolingNullSince = Date.now();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetVideoToolingCache(): void {
|
||||||
|
cachedTooling = undefined;
|
||||||
|
cachedToolingNullSince = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Process spawning (ffmpeg/ffprobe). ffmpeg/ffprobe exit conventions: 0 = ok,
|
||||||
|
// anything else = real failure (NOT 7-Zip's "exit 1 = warning" semantics).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface VideoSpawnResult {
|
||||||
|
ok: boolean;
|
||||||
|
aborted: boolean;
|
||||||
|
timedOut: boolean;
|
||||||
|
missing: boolean;
|
||||||
|
exitCode: number | null;
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendCapped(buffer: string, text: string, cap: number): string {
|
||||||
|
const next = buffer + text;
|
||||||
|
return next.length > cap ? next.slice(next.length - cap) : next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyChildPriority(pid: number | undefined, cpuPriority?: string): void {
|
||||||
|
if (process.platform !== "win32") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const numeric = Number(pid || 0);
|
||||||
|
if (!Number.isFinite(numeric) || numeric <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const level = cpuPriority === "high" ? os.constants.priority.PRIORITY_NORMAL : os.constants.priority.PRIORITY_BELOW_NORMAL;
|
||||||
|
os.setPriority(numeric, level);
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function killChildTree(child: { pid?: number; kill: () => void }): void {
|
||||||
|
const pid = Number(child.pid || 0);
|
||||||
|
if (process.platform === "win32" && Number.isFinite(pid) && pid > 0) {
|
||||||
|
try {
|
||||||
|
const killer = spawn("taskkill", ["/PID", String(pid), "/T", "/F"], { windowsHide: true, stdio: "ignore" });
|
||||||
|
killer.on("error", () => { try { child.kill(); } catch {} });
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
child.kill();
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runVideoProcess(
|
||||||
|
command: string,
|
||||||
|
args: string[],
|
||||||
|
opts: { signal?: AbortSignal; timeoutMs?: number; cpuPriority?: string } = {}
|
||||||
|
): Promise<VideoSpawnResult> {
|
||||||
|
const { signal, timeoutMs, cpuPriority } = opts;
|
||||||
|
if (signal?.aborted) {
|
||||||
|
return Promise.resolve({ ok: false, aborted: true, timedOut: false, missing: false, exitCode: null, stdout: "", stderr: "" });
|
||||||
|
}
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let settled = false;
|
||||||
|
let stdout = "";
|
||||||
|
let stderr = "";
|
||||||
|
let timedOut = false;
|
||||||
|
let aborted = false;
|
||||||
|
let timeoutId: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
const child = spawn(command, args, { windowsHide: true });
|
||||||
|
applyChildPriority(child.pid, cpuPriority);
|
||||||
|
|
||||||
|
const onAbort = (): void => {
|
||||||
|
aborted = true;
|
||||||
|
killChildTree(child);
|
||||||
|
};
|
||||||
|
|
||||||
|
const finish = (result: VideoSpawnResult): void => {
|
||||||
|
if (settled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settled = true;
|
||||||
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
timeoutId = null;
|
||||||
|
}
|
||||||
|
if (signal) {
|
||||||
|
signal.removeEventListener("abort", onAbort);
|
||||||
|
}
|
||||||
|
resolve(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (timeoutMs && timeoutMs > 0) {
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
timedOut = true;
|
||||||
|
killChildTree(child);
|
||||||
|
finish({ ok: false, aborted: false, timedOut: true, missing: false, exitCode: null, stdout, stderr });
|
||||||
|
}, timeoutMs);
|
||||||
|
}
|
||||||
|
if (signal) {
|
||||||
|
signal.addEventListener("abort", onAbort, { once: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
child.stdout?.on("data", (chunk) => { stdout = appendCapped(stdout, String(chunk || ""), STDOUT_CAP); });
|
||||||
|
child.stderr?.on("data", (chunk) => { stderr = appendCapped(stderr, String(chunk || ""), STDERR_CAP); });
|
||||||
|
|
||||||
|
child.on("error", (error) => {
|
||||||
|
const text = String(error || "");
|
||||||
|
finish({ ok: false, aborted: false, timedOut: false, missing: text.toLowerCase().includes("enoent"), exitCode: null, stdout, stderr: stderr || text });
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on("close", (code) => {
|
||||||
|
if (aborted) {
|
||||||
|
finish({ ok: false, aborted: true, timedOut: false, missing: false, exitCode: code, stdout, stderr });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (timedOut) {
|
||||||
|
finish({ ok: false, aborted: false, timedOut: true, missing: false, exitCode: code, stdout, stderr });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
finish({ ok: code === 0, aborted: false, timedOut: false, missing: false, exitCode: code, stdout, stderr });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Per-file orchestration: probe -> decide -> (disk check) -> remux -> atomic
|
||||||
|
// replace -> preserve mtime. Operates IN PLACE (same filename); the .DL. rename
|
||||||
|
// + companion handling + logging is done by the caller (download-manager).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function getFreeSpaceBytes(dir: string): Promise<number | null> {
|
||||||
|
try {
|
||||||
|
const stat = await fs.promises.statfs(dir);
|
||||||
|
return Number(stat.bavail) * Number(stat.bsize);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function processVideoFile(filePath: string, opts: ProcessVideoOptions, deps: ProcessVideoDeps = {}): Promise<VideoProcessResult> {
|
||||||
|
const resolveTool = deps.resolveTooling || resolveVideoTooling;
|
||||||
|
const run = deps.runProcess || runVideoProcess;
|
||||||
|
if (opts.signal?.aborted) {
|
||||||
|
return { action: "aborted", reason: "aborted" };
|
||||||
|
}
|
||||||
|
const tooling = await resolveTool();
|
||||||
|
if (!tooling) {
|
||||||
|
return { action: "skipped-no-tool", reason: "ffmpeg/ffprobe nicht gefunden (PATH oder RD_FFMPEG_BIN)" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const probe = await run(tooling.ffprobe, buildFfprobeArgs(filePath), { signal: opts.signal, timeoutMs: PROBE_TIMEOUT_MS });
|
||||||
|
if (probe.aborted) {
|
||||||
|
return { action: "aborted", reason: "aborted" };
|
||||||
|
}
|
||||||
|
if (!probe.ok) {
|
||||||
|
return { action: "error", reason: "ffprobe fehlgeschlagen", error: probe.stderr || `exit ${String(probe.exitCode)}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const streams = parseFfprobeAudioStreams(probe.stdout);
|
||||||
|
const audioLanguages = streams.map((s) => (s.language || "").trim() || "und");
|
||||||
|
const decision = pickAudioTrack(streams, opts.mode, looksLikeGermanRelease(path.basename(filePath)));
|
||||||
|
if (decision.action === "skip") {
|
||||||
|
return {
|
||||||
|
action: decision.reason === "no-german-track" ? "skipped-no-german" : "skipped-no-audio",
|
||||||
|
reason: decision.reason,
|
||||||
|
totalAudioTracks: streams.length,
|
||||||
|
audioLanguages
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (decision.action === "single") {
|
||||||
|
return { action: "kept-single", reason: decision.reason, totalAudioTracks: streams.length, audioLanguages, keptTrackIndex: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// remux path
|
||||||
|
let originalStat: fs.Stats;
|
||||||
|
try {
|
||||||
|
originalStat = await fs.promises.stat(filePath);
|
||||||
|
} catch (error) {
|
||||||
|
return { action: "error", reason: "stat fehlgeschlagen", error: String(error), audioLanguages };
|
||||||
|
}
|
||||||
|
const free = await getFreeSpaceBytes(path.dirname(filePath));
|
||||||
|
if (free !== null && free < Math.ceil(originalStat.size * 1.05)) {
|
||||||
|
return { action: "skipped-no-space", reason: "zu wenig freier Speicher fuer Remux", totalAudioTracks: streams.length, audioLanguages };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = path.extname(filePath);
|
||||||
|
// Short, same-directory temp name (never longer than the original file name) so
|
||||||
|
// a long scene filename + temp suffix cannot push the temp path past Windows
|
||||||
|
// MAX_PATH and make ffmpeg fail (which would leave the file unprocessed).
|
||||||
|
const tempPath = path.join(path.dirname(filePath), `~rdtmp${ext}`);
|
||||||
|
await fs.promises.rm(tempPath, { force: true }).catch(() => {});
|
||||||
|
|
||||||
|
const remux = await run(
|
||||||
|
tooling.ffmpeg,
|
||||||
|
buildFfmpegRemuxArgs({ input: filePath, output: tempPath, audioRelIndex: decision.audioRelIndex, keepSubs: false }),
|
||||||
|
{ signal: opts.signal, timeoutMs: computeRemuxTimeoutMs(originalStat.size), cpuPriority: opts.cpuPriority }
|
||||||
|
);
|
||||||
|
if (remux.aborted) {
|
||||||
|
await fs.promises.rm(tempPath, { force: true }).catch(() => {});
|
||||||
|
return { action: "aborted", reason: "aborted" };
|
||||||
|
}
|
||||||
|
if (!remux.ok) {
|
||||||
|
await fs.promises.rm(tempPath, { force: true }).catch(() => {});
|
||||||
|
return { action: "error", reason: "ffmpeg remux fehlgeschlagen", error: remux.stderr || `exit ${String(remux.exitCode)}`, totalAudioTracks: streams.length, audioLanguages, keptTrackIndex: decision.audioRelIndex };
|
||||||
|
}
|
||||||
|
|
||||||
|
const tempStat = await fs.promises.stat(tempPath).catch(() => null);
|
||||||
|
if (!tempStat || tempStat.size <= 0) {
|
||||||
|
await fs.promises.rm(tempPath, { force: true }).catch(() => {});
|
||||||
|
return { action: "error", reason: "Remux ergab leere Datei", totalAudioTracks: streams.length, audioLanguages };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// libuv rename replaces an existing destination on Windows; fall back if not.
|
||||||
|
await fs.promises.rename(tempPath, filePath).catch(async () => {
|
||||||
|
await fs.promises.rm(filePath, { force: true });
|
||||||
|
await fs.promises.rename(tempPath, filePath);
|
||||||
|
});
|
||||||
|
// Preserve original mtime so freshness gates (hybrid collect) don't skip it.
|
||||||
|
await fs.promises.utimes(filePath, originalStat.atime, originalStat.mtime).catch(() => {});
|
||||||
|
} catch (error) {
|
||||||
|
await fs.promises.rm(tempPath, { force: true }).catch(() => {});
|
||||||
|
return { action: "error", reason: "Ersetzen der Datei fehlgeschlagen", error: String(error), totalAudioTracks: streams.length, audioLanguages };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { action: "remuxed", reason: decision.reason, keptTrackIndex: decision.audioRelIndex, totalAudioTracks: streams.length, audioLanguages };
|
||||||
|
}
|
||||||
@ -844,7 +844,7 @@ const emptySnapshot = (): UiSnapshot => ({
|
|||||||
archivePasswordList: "",
|
archivePasswordList: "",
|
||||||
rememberToken: true, providerOrder: [], providerPrimary: "realdebrid", providerSecondary: "none",
|
rememberToken: true, providerOrder: [], providerPrimary: "realdebrid", providerSecondary: "none",
|
||||||
providerTertiary: "none", autoProviderFallback: true, outputDir: "", packageName: "",
|
providerTertiary: "none", autoProviderFallback: true, outputDir: "", packageName: "",
|
||||||
autoExtract: true, autoRename4sf4sj: false, extractDir: "", createExtractSubfolder: true, hybridExtract: true,
|
autoExtract: true, autoRename4sf4sj: false, keepGermanAudioOnly: false, germanAudioMode: "tag", extractDir: "", createExtractSubfolder: true, hybridExtract: true,
|
||||||
collectMkvToLibrary: false, mkvLibraryDir: "",
|
collectMkvToLibrary: false, mkvLibraryDir: "",
|
||||||
cleanupMode: "none", extractConflictMode: "overwrite", removeLinkFilesAfterExtract: false,
|
cleanupMode: "none", extractConflictMode: "overwrite", removeLinkFilesAfterExtract: false,
|
||||||
removeSamplesAfterExtract: false, enableIntegrityCheck: true, autoResumeOnStart: true,
|
removeSamplesAfterExtract: false, enableIntegrityCheck: true, autoResumeOnStart: true,
|
||||||
@ -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)";
|
||||||
@ -5372,6 +5376,11 @@ export function App(): ReactElement {
|
|||||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoSkipExtracted} onChange={(e) => setBool("autoSkipExtracted", e.target.checked)} /> Bereits Entpacktes beim Start überspringen</label>
|
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoSkipExtracted} onChange={(e) => setBool("autoSkipExtracted", e.target.checked)} /> Bereits Entpacktes beim Start überspringen</label>
|
||||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.hideExtractedItems} onChange={(e) => setBool("hideExtractedItems", e.target.checked)} /> Entpackte Items in Paketliste ausblenden</label>
|
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.hideExtractedItems} onChange={(e) => setBool("hideExtractedItems", e.target.checked)} /> Entpackte Items in Paketliste ausblenden</label>
|
||||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoRename4sf4sj} onChange={(e) => setBool("autoRename4sf4sj", e.target.checked)} /> Auto-Rename (Beta)</label>
|
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoRename4sf4sj} onChange={(e) => setBool("autoRename4sf4sj", e.target.checked)} /> Auto-Rename (Beta)</label>
|
||||||
|
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.keepGermanAudioOnly} onChange={(e) => setBool("keepGermanAudioOnly", e.target.checked)} /> Nur deutsche Tonspur behalten (.DL.-Dateien, braucht ffmpeg)</label>
|
||||||
|
<div><label>Tonspur-Auswahl</label><select value={settingsDraft.germanAudioMode} disabled={!settingsDraft.keepGermanAudioOnly} onChange={(e) => setText("germanAudioMode", e.target.value)}>
|
||||||
|
<option value="tag">Deutsche Spur per Sprach-Tag (empfohlen)</option>
|
||||||
|
<option value="first">Immer erste Tonspur (wie Script)</option>
|
||||||
|
</select></div>
|
||||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.createExtractSubfolder} onChange={(e) => setBool("createExtractSubfolder", e.target.checked)} /> Entpackte Dateien in Paket-Unterordner speichern</label>
|
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.createExtractSubfolder} onChange={(e) => setBool("createExtractSubfolder", e.target.checked)} /> Entpackte Dateien in Paket-Unterordner speichern</label>
|
||||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.hybridExtract} onChange={(e) => setBool("hybridExtract", e.target.checked)} /> Hybrid-Extract</label>
|
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.hybridExtract} onChange={(e) => setBool("hybridExtract", e.target.checked)} /> Hybrid-Extract</label>
|
||||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoExtractWhenStopped} onChange={(e) => setBool("autoExtractWhenStopped", e.target.checked)} /> Entpacken auch ohne laufende Session (bei Stopp / Programmstart)</label>
|
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoExtractWhenStopped} onChange={(e) => setBool("autoExtractWhenStopped", e.target.checked)} /> Entpacken auch ohne laufende Session (bei Stopp / Programmstart)</label>
|
||||||
|
|||||||
@ -97,6 +97,8 @@ export interface AppSettings {
|
|||||||
packageName: string;
|
packageName: string;
|
||||||
autoExtract: boolean;
|
autoExtract: boolean;
|
||||||
autoRename4sf4sj: boolean;
|
autoRename4sf4sj: boolean;
|
||||||
|
keepGermanAudioOnly: boolean;
|
||||||
|
germanAudioMode: "tag" | "first";
|
||||||
extractDir: string;
|
extractDir: string;
|
||||||
collectMkvToLibrary: boolean;
|
collectMkvToLibrary: boolean;
|
||||||
mkvLibraryDir: string;
|
mkvLibraryDir: string;
|
||||||
|
|||||||
104
tasks/plan-german-audio-track.md
Normal file
104
tasks/plan-german-audio-track.md
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
# Plan: „Nur deutsche Tonspur behalten" (.DL.) als Tool-Funktion
|
||||||
|
|
||||||
|
Quelle der Idee: User-Script `Remove Non German Audio.py` (ffmpeg `-map 0:v:0 -map 0:a:0
|
||||||
|
-c copy -map_metadata -1`, + `.DL.`→`.` Rename). Soll als **togglebarer Post-Extract-Schritt**
|
||||||
|
nach jedem Entpacken laufen, nur für **MKV/MP4 mit `.DL.` im Namen** (Dual-Language),
|
||||||
|
und nur die **deutsche** Spur behalten. Fundiert per 6-Agent-Analyse + Advisor.
|
||||||
|
|
||||||
|
## 1. Verhalten (Soll)
|
||||||
|
- Läuft automatisch nach dem Entpacken eines Pakets (wenn Toggle an), bevor MKV-Collect.
|
||||||
|
- Pro extrahierter Video-Datei mit `.DL.` im Namen (case-insensitive, nur .mkv/.mp4):
|
||||||
|
1. Audiospuren prüfen → deutsche/erste Spur bestimmen (Modus = User-Entscheidung, s.u.).
|
||||||
|
2. Wenn >1 Audiospur: remux (stream-copy, kein Re-Encode) → behält Video + 1 Audio
|
||||||
|
(+ optional dt. Untertitel) → Temp-Datei → atomar ersetzen.
|
||||||
|
3. `.DL.` aus dem Dateinamen strippen (`.DL.`→`.`, `.DL`→``), Companion-Dateien (Untertitel/.nfo) mitziehen.
|
||||||
|
4. Wenn nur 1 Audiospur: **kein** Remux (spart Neuschreiben großer Dateien), ABER `.DL.`-Strip trotzdem.
|
||||||
|
- Status pro Item sichtbar (z.B. „Tonspur wird bereinigt" / „Deutsche Spur behalten").
|
||||||
|
|
||||||
|
## 2. Architektur
|
||||||
|
- **NEUES Modul `src/main/video-processor.ts`** (spiegelt `extractor.ts`: exportierte async-Funktion
|
||||||
|
+ Options-Bag, KEINE DI-Klasse — es gibt keinen Constructor-Seam). Enthält:
|
||||||
|
- ffmpeg/ffprobe-Spawn nach dem `runExtractCommand`-Muster (extractor.ts:1296): `spawn(cmd,args,{windowsHide:true})`,
|
||||||
|
Promise-Wrapper, Timeout-Watchdog → `killProcessTree` (taskkill /T /F), **AbortSignal IN den Child** geben.
|
||||||
|
- **Pure exportierte Helfer** für Unit-Tests: `pickGermanAudioTrack(probeJson, mode)`, `stripDualLangMarker(name)`,
|
||||||
|
`buildFfmpegRemuxArgs(...)`, `computeRemuxTimeoutMs(bytes)`.
|
||||||
|
- ffmpeg-Exit-Codes ≠ 7-Zip (NICHT die „exit 1 = ok"-Logik kopieren — nur das Spawn/Await/Kill-Gerüst).
|
||||||
|
- ffprobe-JSON auf stdout NICHT durch den 48KB-Tail-Cap (`appendLimited`) — stdout separat voll puffern.
|
||||||
|
- **ffmpeg-Discovery (Option a, empfohlen):** System-PATH + `RD_FFMPEG_BIN` env + lazy `ffmpeg -version`-Probe
|
||||||
|
gecacht (spiegelt `RD_7Z_BIN`, extractor.ts:1030-1083). **Nicht bündeln** (~80-150MB → triggert den
|
||||||
|
eigenen 150MB-Large-Bundle-Selfcheck debug-setup.ts:22 + GPL-Lizenzpflicht). Wenn ffmpeg fehlt → Schritt
|
||||||
|
überspringen + WARN loggen + (optional) in Health-Check/Errors surfacen. NIE Downloads blockieren.
|
||||||
|
- **CPU-Priorität:** `lowerExtractProcessPriority(pid, priority)` + `extractOsPriority` wiederverwenden,
|
||||||
|
Priorität als **expliziten Param** (nicht das Modul-Global `currentExtractCpuPriority` — Cross-Talk-Gefahr).
|
||||||
|
Honoriert `settings.extractCpuPriority`.
|
||||||
|
|
||||||
|
## 3. Einhängepunkte (BEIDE Pfade — kritisch!)
|
||||||
|
Post-Processing ist **pro Paket**, zwei Pfade; Hybrid-Pakete durchlaufen NIE den Deferred-Pass:
|
||||||
|
- **Deferred** (download-manager.ts ~11614): nach `autoRenameExtractedVideoFiles`, VOR archive-cleanup/collect.
|
||||||
|
- **Hybrid** (download-manager.ts ~10944): zwischen Rename und Collect im detached Block.
|
||||||
|
- Beide: **innerhalb `chainPackageFileOp(pkg.id, ...)`** (serialisiert Datei-Ops pro Paket), nur auf
|
||||||
|
`pkg.extractDir` operieren — NIE im geteilten `mkvLibraryDir` (= der v1.7.107-revertierte Cross-Package-Crash;
|
||||||
|
autoRename bricht bei Overlap ab, 3905-3919).
|
||||||
|
- **Gate:** neuen Flag in den Post-Process-Aggregator OR-en (~7078-7084), sonst läuft der Schritt nie
|
||||||
|
standalone. Hängt inhärent an `autoExtract` (braucht entpackte Dateien).
|
||||||
|
- Datei-Enumeration: `collectVideoFiles(rootDir)` (rekursiv, SAMPLE_VIDEO_EXTENSIONS, constants.ts:28) — nur
|
||||||
|
.mkv/.mp4 verarbeiten; Sample/Bonus-Dateien per vorhandenem Skip-Prädikat auslassen.
|
||||||
|
|
||||||
|
## 4. Der .DL.-Knoten (LÖST den „Feature no-op"-Fehler)
|
||||||
|
- Selektion = „Datei hat `.DL.`"; der Schritt strippt `.DL.`. → KEIN früherer Schritt darf den Marker entfernen.
|
||||||
|
- **autoRename NICHT ändern** (behält `.DL.` verbatim) → Marker überlebt bis zum Video-Schritt.
|
||||||
|
- Video-Schritt läuft **nach** autoRename → sieht `.DL.` → remuxt + strippt `.DL.` atomar pro Datei.
|
||||||
|
- **NUR `collectMkvFilesToLibrary.deriveCleanCollectFileName`** bekommt den `.DL.`-Strip als Post-Transform
|
||||||
|
(läuft NACH dem Video-Schritt → kann den Selektor nicht brechen, verhindert nur Re-Einführung aus dem
|
||||||
|
Ordner-Token). Companion-Files via `renameCompanionFiles`/`moveCompanionFiles` mitziehen.
|
||||||
|
|
||||||
|
## 5. Sicherheitsmodell (Original NIE verlieren)
|
||||||
|
- Remux → Temp-Datei → Größe > 0 (idealerweise ~plausibel) prüfen → erst dann atomar ersetzen/umbenennen
|
||||||
|
(`renamePathWithExdevFallback` + `verifyRenameAsync`). ffmpeg-Fehler/Abbruch → Temp löschen, Original bleibt.
|
||||||
|
- **Disk-Space-Pre-Check**: vor Remux freien Platz ≥ Dateigröße (+Marge) prüfen, sonst skip+log
|
||||||
|
(Temp verdoppelt transient den Platz auf einer Platte, die grad entpackt hat / parallel lädt).
|
||||||
|
- **AbortSignal in den ffmpeg-Child** (Deferred-/Hybrid-Controller) → Stop/Cancel/Reset killt laufenden Remux.
|
||||||
|
- **mtime erhalten** (`fs.utimes` nach Remux) → sonst überspringt Hybrid-Collect (deferFreshFiles=true) die
|
||||||
|
frisch angefasste Datei.
|
||||||
|
- **Sicherheits-Invariante (BEIDE Modi):** Original nur ersetzen, wenn die behaltene Spur sicher die richtige
|
||||||
|
ist. Bei Unsicherheit (keine Tags / kein Deutsch gefunden) → Datei UNANGETASTET lassen + loggen, statt
|
||||||
|
versehentlich die einzige brauchbare Spur zu löschen.
|
||||||
|
- Dispositions-Flag der behaltenen Spur auf „default" setzen.
|
||||||
|
- Best-effort pro Datei: ein Fehler markiert NICHT das Paket als failed und blockiert nicht den Collect anderer Dateien.
|
||||||
|
|
||||||
|
## 6. ffmpeg/ffprobe-Aufrufe (Stream-Copy, schnell)
|
||||||
|
- Probe (nur im Tag-Modus): `ffprobe -v error -select_streams a -show_entries stream=index:stream_tags=language,title -of json INPUT`
|
||||||
|
- Remux erste Spur (Script-Parität): `ffmpeg -i INPUT -map 0:v:0 -map 0:a:0 [-map 0:s? je nach Untertitel-Option] -c copy -map_metadata -1 -disposition:a:0 default -y TEMP`
|
||||||
|
- Remux deutsche Spur (Tag-Modus): `-map 0:v:0 -map 0:a:<dt-Index> ...` (Index aus ffprobe).
|
||||||
|
|
||||||
|
## 7. Settings/UI-Wiring (5 Pflicht-Stellen, +1 optional)
|
||||||
|
1. `src/shared/types.ts` AppSettings: `keepGermanAudioOnly: boolean` (+ ggf. `germanAudioMode`, `keepGermanSubs`, `ffmpegPath`).
|
||||||
|
2. `src/main/constants.ts` defaultSettings: `keepGermanAudioOnly: false` etc.
|
||||||
|
3. `src/main/storage.ts` normalizeSettings: `Boolean(...)` (Pfad: `asText`, NICHT normalizeAbsoluteDir → leer = System-ffmpeg).
|
||||||
|
4. `src/renderer/App.tsx` Settings-Tab „entpacken" neben collectMkvToLibrary: Toggle + eingerückte Sub-Optionen (disabled wenn aus).
|
||||||
|
5. `src/renderer/App.tsx` **emptySnapshot()-Literal** (~840-859) — sonst tsc-Fehler (Feld non-optional).
|
||||||
|
6. (optional) `src/main/support-data.ts` ~95: Flag in Diagnose-Export spiegeln.
|
||||||
|
|
||||||
|
## 8. Tests + Verifikations-Gate
|
||||||
|
- ffmpeg in Tests **gemockt** (kein echter ffmpeg-Lauf): neues Modul via `vi.mock` in download-manager.test.ts
|
||||||
|
(assert: korrekt aufgerufen + Sequenz nach autoRename / vor collect, Deferred + Hybrid). KEIN blankes
|
||||||
|
`vi.mock("node:child_process")` in download-manager.test.ts (bricht echte Extractor-ZIP-Tests).
|
||||||
|
- Separate `video-processor.test.ts`: `node:child_process` mocken → ffmpeg/ffprobe-ARGS asserten (Track-Wahl, Untertitel-Option).
|
||||||
|
- Pure Helfer fs-frei testen (wie tests/auto-rename.test.ts): `pickGermanAudioTrack`, `stripDualLangMarker`.
|
||||||
|
- Negativ-Test: Toggle aus → keine Verarbeitung. Edge: 1-Audio-`.DL.` → nur Rename, kein Remux. Kein-Deutsch → unangetastet.
|
||||||
|
- **Gate:** tsc-Baseline = 6 vorbestehende Fehler (NICHT clean) → „keine NEUEN tsc-Fehler" + vitest 728→728+N grün + `npm run self-check` grün.
|
||||||
|
|
||||||
|
## 9. OFFENE ENTSCHEIDUNGEN (vor Bau — per AskUserQuestion)
|
||||||
|
- **A. Spurauswahl:** Script-Parität (immer erste Audiospur, kein ffprobe, validiertes Verhalten) vs.
|
||||||
|
Smart (deutsche Spur per Sprach-Tag, Fallback erste Spur, skip wenn kein Deutsch).
|
||||||
|
- **B. Untertitel:** weglassen (wie Script) vs. deutsche Untertitel behalten.
|
||||||
|
- **C. ffmpeg-Quelle:** nur System-PATH + `RD_FFMPEG_BIN` env vs. zusätzlich Settings-Pfad-Feld im UI.
|
||||||
|
|
||||||
|
## 10. Umsetzungsreihenfolge (nach Entscheidungen)
|
||||||
|
1. `video-processor.ts` + pure Helfer + deren Unit-Tests (TDD).
|
||||||
|
2. ffmpeg/ffprobe-Discovery (probe+cache).
|
||||||
|
3. Settings-Wiring (5 Stellen) + UI-Toggle.
|
||||||
|
4. Einhängen in Deferred + Hybrid (in chainPackageFileOp), Gate OR-en.
|
||||||
|
5. collect deriveCleanCollectFileName: `.DL.`-Strip-Safety-Net.
|
||||||
|
6. Logging (logRenameProcess, neuer Stage 'audio-strip').
|
||||||
|
7. Tests (download-manager mock + video-processor args + negativ/edge). Gate prüfen.
|
||||||
239
tasks/todo.md
239
tasks/todo.md
@ -1,159 +1,100 @@
|
|||||||
# Erweitertes Logging (2026-06-07) — Goal: Tool besser kontrollieren bei Problemen/Fehlern
|
# Real-Debrid-Downloader — Tasks (Stand 2026-06-07)
|
||||||
|
|
||||||
Recon (4-Agent-Workflow) fand 40+ Lücken. Bewusste Scope-Disziplin: NICHT alle 40
|
**Status:** Alle zugesagten Features erledigt+released (Archiv unten). EIN Bug analysiert
|
||||||
instrumentieren (die meisten leeren catches sind legitimes Cleanup → würden das Log
|
+ geparkt (Mega-Web Account-3-Rotation, siehe direkt unten — wartet auf 1 Log-Zahl vom User).
|
||||||
fluten). Fokus: strukturelle Sichtbarkeit für unbeaufsichtigten Windows-Server, wo
|
Rest ist freiwilliger Backlog.
|
||||||
"Crash/Hang ohne Log" der schlimmste Fall ist. Advisor-gegengeprüft.
|
|
||||||
|
|
||||||
## Tier 1 — Prozess-/Renderer-Crash-Sichtbarkeit (höchster Wert)
|
|
||||||
- [ ] main.ts: `render-process-gone` (+ Auto-Reload mit Circuit-Breaker max 3/5min)
|
|
||||||
- [ ] main.ts: `app.on("child-process-gone")` (GPU/Utility/Renderer-Subprozesse)
|
|
||||||
- [ ] main.ts: `webContents.on("unresponsive"/"responsive")` — NUR loggen, nie killen
|
|
||||||
- [ ] main.ts: `process.on("warning")` (Node-Warnungen, z.B. MaxListenersExceeded)
|
|
||||||
- [ ] Renderer-Fehler-Capture: window.onerror + unhandledrejection + React ErrorBoundary
|
|
||||||
→ über neuen Einweg-IPC `LOG_RENDERER_ERROR` ins Main-Log (kein stilles White-Screen)
|
|
||||||
- [ ] Memory-Heartbeat: im vorhandenen runtimeStatsTimer (60s), warn bei heapUsed/heapTotal > 0.9
|
|
||||||
|
|
||||||
## Tier 2 — Logging-Infrastruktur
|
|
||||||
- [ ] logger.ts: DEBUG-Level, gated über `RD_DEBUG` env (no-op wenn aus → keine Format-Kosten)
|
|
||||||
- [ ] error-ring.ts (NEU, pure+getestet): letzte N WARN/ERROR im RAM, gefüttert im write()-Chokepoint
|
|
||||||
- [ ] debug-server.ts: `/errors` Endpoint
|
|
||||||
- [ ] support-bundle.ts: overview/recent-errors.json
|
|
||||||
|
|
||||||
## Tier 3 — Gezielte High-Value-Catches
|
|
||||||
- [ ] fs-error.ts (NEU, pure+getestet): classifyDiskError(err) — ENOSPC/EACCES/EROFS/EMFILE
|
|
||||||
- [ ] download-manager.ts: ENOSPC-Klassifizierung am vorhandenen Attempt-catch (reine Log-Anreicherung)
|
|
||||||
- [ ] download-manager.ts: Integrity-Check PASS ins item-log (Symmetrie: bisher nur Fail geloggt)
|
|
||||||
|
|
||||||
## Verifikation — ERLEDIGT 2026-06-07
|
|
||||||
- [x] Alle Tier 1/2/3 Punkte umgesetzt (siehe unten)
|
|
||||||
- [x] tsc = 6 (Baseline unverändert — eine versehentlich eingeführte `pkg`-Referenz sofort gefixt)
|
|
||||||
- [x] 728 Tests grün (715 Baseline + 13 neu: error-ring 5, fs-error/debug-gate 8), 41 Dateien
|
|
||||||
- [x] `npm run build` grün (tsup main 1.04MB + vite renderer 33 Module)
|
|
||||||
- [x] Grep-Konsistenz LOG_RENDERER_ERROR/reportRendererError über alle 5 Dateien (Kette lückenlos:
|
|
||||||
ipc.ts → preload-api.ts → preload.ts(send) → main.ts(ipcMain.on, einweg) → main.tsx + error-boundary.tsx)
|
|
||||||
- [x] Renderer-tsc-Abdeckung BEWIESEN (nicht angenommen): absichtlicher Typfehler in error-boundary.tsx
|
|
||||||
wurde von `tsc --noEmit` geflaggt → die neuen Renderer-Dateien sind echt typgeprüft (vite build prüft NICHT)
|
|
||||||
- [x] Advisor-Feinschliff: Memory-Heartbeat misst gegen `v8.getHeapStatistics().heap_size_limit`
|
|
||||||
(echte OOM-Decke) statt heapUsed/heapTotal — sonst Fehlalarm, der die Error-Ring zumüllt
|
|
||||||
|
|
||||||
### Geänderte/neue Dateien
|
|
||||||
NEU: error-ring.ts, fs-error.ts, renderer/error-boundary.tsx, tests/error-ring.test.ts, tests/fs-error.test.ts
|
|
||||||
GEÄNDERT: logger.ts (DEBUG-Level+Gate+Ring-Feed), main.ts (4 Crash-Handler + Renderer-IPC),
|
|
||||||
app-controller.ts (Memory-Heartbeat), debug-server.ts (/errors), support-bundle.ts (recent-errors.json),
|
|
||||||
download-manager.ts (ENOSPC-Klassifizierung + Integrity-PASS-Log), shared: ipc.ts/types.ts/preload-api.ts, preload.ts, renderer/main.tsx
|
|
||||||
|
|
||||||
### NOCH OFFEN
|
|
||||||
- [ ] Release (Gitea + GitHub-Mirror) — wartet auf User-Go ("jo mach releasen")
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Real-Debrid-Downloader — Analyse & Verbesserungen (2026-05-23)
|
## 🟢 OFFEN — Backlog (optional, nie begonnen)
|
||||||
|
|
||||||
Tiefe Analyse via 3 parallele Subagents (Bugs / Features / UI) + 4 Design-Mockups.
|
### ✅ 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:45–11: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)
|
||||||
|
App läuft headless auf Windows-Server → Nutzer sitzt nicht davor.
|
||||||
|
|
||||||
|
1. [ ] **Push-Benachrichtigungen** (Discord/Telegram/ntfy) — S–M. Paket fertig/Fehler/Quota/Provider-down aufs Handy. Neuer `notifier.ts`, Hooks an Completion-Punkten. **Höchster ROI.**
|
||||||
|
2. [ ] **Fernsteuerung über Debug-Server** (POST-Endpunkte) — S–M. Server hat HTTP + Token-Auth, aber nur GET. POST `/control/add-links`, `/start`, `/stop`.
|
||||||
|
3. [ ] **URL-Duplikat-Erkennung beim Hinzufügen** — S. History-`urls` existiert, wird nie geprüft → versehentliche Re-Downloads. Warnen: "3 Links bereits geladen".
|
||||||
|
4. [ ] **Pre-Flight-Check + Bulk-Skip toter Links** — M. Vor Start Größe/Name/Online für ganze Queue, "alle offline überspringen".
|
||||||
|
5. [ ] **Speicherplatz-Vorabprüfung vor Start** — S. Aktuell keine Free-Space-Prüfung für Downloads → Abbruch mitten drin bei voller Platte.
|
||||||
|
6. [ ] **Konsolidierte Fehler-Ansicht** — M. Alle fehlgeschlagenen Items flach + Fehlertext + "alle erneut versuchen". (Daten dafür liegen jetzt teils in der Error-Ring aus v1.7.185.)
|
||||||
|
7. [ ] **Per-Provider-Statistik** — M. Rohdaten (`providerTotalUsageBytes`) existieren, werden nicht dargestellt. Welches Abo lohnt sich?
|
||||||
|
8. [ ] **Auto-Retry fehlgeschlagener Pakete nach Wartezeit** — S–M. Quota/Cooldown-Fails am nächsten Tag automatisch neu.
|
||||||
|
9. [ ] **Plex/Jellyfin Library-Refresh nach MKV-Move** — S. Gleicher Hook wie #1.
|
||||||
|
10. [ ] **Watch-Folder für DLC/Link-Auto-Import** — M.
|
||||||
|
|
||||||
|
### Design-Richtung (Entscheidung steht aus)
|
||||||
|
4 Mockups in `design-mockups/` (index.html = Vergleich): **Aurora** (verfeinert dark, geringstes Risiko) · **Command** (Terminal/Ops, dicht) · **Vellum** (light editorial) · **Nebula** (neon).
|
||||||
|
→ Richtung wählen. Siehe Memory: design-taste (Anti-KI-Look) + design-direction (Ember-Wärme, flach/ehrlich).
|
||||||
|
|
||||||
|
### Alte Audit-Items (2026-04-04, Status ggf. veraltet — VOR Fix gegen aktuellen Code verifizieren)
|
||||||
|
- [ ] Debrid-Link `maxDataHost` kühlt ganzen Key ab statt nur den Host
|
||||||
|
- [ ] Debrid-Link `fileNotAvailable` setzt Key auf "error" statt temporär
|
||||||
|
- [ ] AllDebrid: kein per-host-Cooldown für erschöpfte Quotas
|
||||||
|
- [ ] LinkSnappy: keine Auth-Dedup (parallele Requests rufen beide authenticate())
|
||||||
|
- [ ] Extractor password-cache race (parallele Worker mutieren `packageLearnedPasswords`)
|
||||||
|
- [ ] Hybrid race: 1 Datei/Staffel evtl. beim MKV-Move nicht umbenannt (NUR per-package fixen — Post-MKV-Move-Scan ist tabu, v1.7.107 revertiert)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## A. BUGS / ROBUSTHEIT (verifiziert gegen Quellcode)
|
## ✅ ERLEDIGT — Archiv (Details in git-History + Memory)
|
||||||
|
|
||||||
Roter Faden: Die **Deferred-Post-Processing-Pipeline** (eingeführt um den Extract-Slot schnell freizugeben) ist nur halb ins Abbruch-/Lifecycle-Management integriert. Genau der Bereich des v1.7.156-Fixes.
|
- **Erweitertes Logging** → released **v1.7.185** (Crash-Handler, Renderer-Fehler-IPC, RD_DEBUG-Level, Error-Ring + `/errors`, ENOSPC-Klassifizierung, Memory-Heartbeat). → Memory: extended-logging
|
||||||
|
- **Link-Prefetch** → untersucht (6-Agent) + **bewusst verworfen** (marginal bei maxParallel 8, Mega-Web single-flight). → Memory: link-prefetch-declined
|
||||||
|
- **Backup nur Settings** → v1.7.184 (`backupIncludeDownloads`-Toggle + 4 Selektions/Flicker-Fixes). → Memory: backup-settings-only
|
||||||
|
- **Account-Rotation-Overhaul** → v1.7.164–168 (Validity/Premium-Badges, Live-Panel, "Alle prüfen"). → Memory: account-rotation
|
||||||
|
- **Mega-Debrid-Account deaktivieren (UI)** → erledigt (Toggle im Edit-Dialog, im Code verifiziert 2026-06-07)
|
||||||
|
- **Bugs/Robustheit (Deferred-Pipeline H1/H2/H3/M1/M2/N1)** → v1.7.158/159; M3 bewusst übersprungen (Generation-Guard schützt Integrität bereits)
|
||||||
|
- **Deferred-Pfad Rename-Gap** → gefixt v1.7.162+ (finaler Deferred-Pass benennt frische Dateien vor Collect um; Repro-Test grün)
|
||||||
|
- **Repo-Privacy-Audit** → GitHub gelöscht+neu (saubere History), Gitea unberührt. → Memory: repo-privacy-audit
|
||||||
|
|
||||||
### HOCH
|
### Bewusst NICHT angefasst (Crash-Debris / alte Experimente)
|
||||||
- [ ] **H1 — Globaler Stop/Shutdown bricht Deferred-Post-Processing nicht ab.** `abortPostProcessing` (download-manager.ts:7053) iteriert nur über `packagePostProcessAbortControllers`, nie über `packageDeferredPostProcessAbortControllers`. Bei Stop/Shutdown/clearAll laufen MKV-Move/Archiv-Cleanup/Rename weiter, während synchron persistiert wird → FS-Zustand ≠ Session-State (halb verschobene Datei, halb gelöschtes Archiv).
|
- Gestashtes Crash-Debris `stash@{0}` (Revert von 08372f9/18eada9/98dc366 + log.old) — bei Bedarf recoverbar, sonst verwerfbar
|
||||||
- [ ] **H2 — Hybrid-Post-Extract feuert MKV-Collection/Rename als losgelöstes Promise** (download-manager.ts:11334). In keiner Tracking-Map, kein `shouldAbort`. Cancel/Reset während Hybrid-Collect → Dateien werden trotzdem verschoben/gelöscht.
|
- Untracked `*-postprocess/` + `fix-library-renames.mjs` — alte Experimente (Apr/Mai)
|
||||||
- [ ] **H3 — 0-Byte-Datei wird als vollständig akzeptiert** wenn keine Größeninfo (download-completion.ts:129, source "stream-end"). Hoster antwortet HTTP 200 ohne Content-Length + schließt sofort → Item "Fertig" mit leerer Datei, kein Auto-Redownload.
|
|
||||||
|
|
||||||
### MITTEL
|
|
||||||
- [ ] **M1 — Deferred-Post-Extraction nicht in `packagePostProcessTasks`** (download-manager.ts:11974). Scheduler-Abschluss (8154) + finishRun (12310) sehen Deferred-Tasks nicht → Run-Ende/Summary feuert während noch Dateien verschoben werden, State-Reset mitten in FS-Arbeit.
|
|
||||||
- [ ] **M2 — `blockAllPersistence` wird nach Backup-Import nie zurückgesetzt** (app-controller.ts:678). Weiterarbeiten ohne Neustart → `persistSoon` ist dauerhaft No-Op → bei hartem Crash alle Änderungen weg.
|
|
||||||
- [ ] **M3 — `cancelPendingAsyncSaves` wartet nicht auf laufenden Async-Save** (storage.ts:1064). I/O-Overlap beim Import (Datenintegrität durch Generation-Guard geschützt, nur Robustheit).
|
|
||||||
|
|
||||||
### NIEDRIG
|
|
||||||
- [ ] **N1 — Toter Code in `findReadyArchiveSets`** (download-manager.ts:10847). Unbedingtes `ready.add+continue` macht strengeren Disk-Fallback-Block (untracked-pending-Schutz) unerreichbar.
|
|
||||||
|
|
||||||
**Empfehlung:** H1 + H2 + M1 zusammen fixen (eine kohärente Härtung der Deferred-Pipeline). H3 ist klein & unabhängig. M2 trivial.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## B. FEATURES / UX-GAPS (nach Mehrwert/Aufwand)
|
|
||||||
|
|
||||||
App läuft headless auf Windows-Server → Nutzer sitzt nicht davor. Größte Lücke: keine Benachrichtigungen.
|
|
||||||
|
|
||||||
1. [ ] **Webhook/Push-Benachrichtigungen** (Discord/Telegram/ntfy) — S–M. Bei Paket fertig/Fehler/Quota/Provider-down aufs Handy. Neuer `notifier.ts`, Hooks an Completion-Punkten. **Höchster ROI.**
|
|
||||||
2. [ ] **Fernsteuerung über bestehenden Debug-Server** (POST-Endpunkte) — S–M. Server hat schon HTTP + Token-Auth, aber nur GET. POST `/control/add-links`, `/start`, `/stop` → vom Handy steuern.
|
|
||||||
3. [ ] **URL-Duplikat-Erkennung beim Hinzufügen** — S. History-`urls` existiert, wird aber nie geprüft → versehentliche Re-Downloads verschwenden Quota. Warnen: "3 Links bereits geladen".
|
|
||||||
4. [ ] **Vereinheitlichter Pre-Flight-Check + Bulk-Skip toter Links** — M. Vor Start Größe/Name/Online für ganze Queue, "alle offline überspringen"-Button.
|
|
||||||
5. [ ] **Speicherplatz-Vorabprüfung vor Start** — S. Aktuell keine Free-Space-Prüfung → Abbruch mitten im Download bei voller Platte.
|
|
||||||
6. [ ] **Konsolidierte Fehler-Ansicht** — M. Alle fehlgeschlagenen Items flach + Fehlertext + "alle erneut versuchen".
|
|
||||||
7. [ ] **Per-Provider-Statistik** — M. Rohdaten (`providerTotalUsageBytes`) existieren, werden nur nicht dargestellt. Welches Abo lohnt sich?
|
|
||||||
8. [ ] **Auto-Retry fehlgeschlagener Pakete nach Wartezeit** — S–M. Quota/Cooldown-Fails am nächsten Tag automatisch neu versuchen.
|
|
||||||
9. [ ] **Plex/Jellyfin Library-Refresh nach MKV-Move** — S. Neue Folgen sofort sichtbar. Gleicher Hook wie #1.
|
|
||||||
10. [ ] **Watch-Folder für DLC/Link-Auto-Import** — M. Ordner überwachen → automatisch importieren+starten.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## C. DESIGN-MOCKUPS
|
|
||||||
|
|
||||||
4 Varianten in `design-mockups/` (index.html = Vergleich):
|
|
||||||
1. **Aurora** — verfeinerte Dark-Evolution (premium, vertraut, geringstes Risiko)
|
|
||||||
2. **Command** — Terminal/Ops-Dashboard (max. Dichte, Monospace, Status-LEDs)
|
|
||||||
3. **Vellum** — Light Editorial (warmes Papier, Serif, mutige helle Alternative)
|
|
||||||
4. **Nebula** — Neon/Synthwave (Magenta-Cyan-Glow, auffällig)
|
|
||||||
|
|
||||||
→ Nutzer wählt Richtung (oder Mischung).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## REVIEW / ERGEBNISSE (2026-05-23)
|
|
||||||
|
|
||||||
**Umgesetzt (v1.7.158):**
|
|
||||||
- ✅ **H1** — `abortPostProcessing` aborted jetzt auch alle Deferred- + Hybrid-Controller (globaler Stop/Shutdown/clearAll/external). Keine FS-Race gegen Shutdown-Save mehr.
|
|
||||||
- ✅ **H2** — Hybrid-Post-Extract läuft über neue `packageHybridPostProcessControllers`-Map (Set pro Package), Controller SYNCHRON vor dem detached Promise registriert, `shouldAbort` an Rename + MKV-Collect durchgereicht. `abortPackagePostProcessing` + `clearAll` räumen die Map. Cancel/Reset stoppt jetzt laufende Hybrid-Arbeit.
|
|
||||||
- ✅ **M1** — neuer `hasAnyDeferredPostProcessPending()`; Scheduler-Abschluss + `finishRun`-Clear gaten darauf. `hasDeferredPostProcessPending` (per-Package, für package_done-Cleanup) prüft jetzt auch Hybrid. Run endet erst wenn Background-FS-Arbeit fertig.
|
|
||||||
- ✅ **H3** — `validateDownloadedFileCompletion`: 0-Byte bei `stream-end` → `ok:false` (download_underflow), routet in den bestehenden Retry-Pfad. Regressionstest in `tests/download-completion.test.ts` (8 Tests).
|
|
||||||
- ✅ **N1** — toter Disk-Fallback-Block in `findReadyArchiveSets` + verwaiste `pendingItemStatus`-Map entfernt (verhaltensneutral).
|
|
||||||
|
|
||||||
**Bewusst NICHT umgesetzt (mit Begründung):**
|
|
||||||
- ✅ **M2** (`blockAllPersistence` nie zurückgesetzt) — GELÖST in v1.7.159 via **Auto-Relaunch**. In-Memory-Reload wäre unsicher (Task-finally-Blöcke settlen async gegen `this.session.items[id]` → Race beim Session-Swap, bräuchte async-Refactor). Stattdessen: nach erfolgreichem Import startet die App automatisch neu (main-getrieben in main.ts, nicht Renderer — robust gegen Renderer-Fehler). Der frische Prozess lädt die restored Session sauber via Standard-Startup-Pfad. `skipShutdownPersist`/`blockAllPersistence` schützen das ~1.5s-Fenster + den Quit (verifiziert: prepareForShutdown:5680 überspringt Persistenz sauber). Footgun eliminiert — User kann nicht mehr im blockierten Zustand weiterarbeiten.
|
|
||||||
- ⏭️ **M3** (`cancelPendingAsyncSaves` wartet nicht auf laufenden Save) — Report stuft selbst als reines I/O-Overlap ein; die Generation-Guard (storage.ts:1022) schützt die Datenintegrität bereits (stale Write wird verworfen). Kein Korrektheitsgewinn, daher kein Eingriff.
|
|
||||||
|
|
||||||
**Verifikation:** 30 Test-Dateien, 621 Tests grün. Build sauber. Advisor-Review vor Implementierung (fing H2-Falle: Hybrid-Controller nicht in die Deferred-Map legen, sonst killt `runDeferredPostExtraction` sie selbst).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## D. DEFERRED-PFAD RENAME-GAP (2026-05-28, Opus-Verifikation von 18eada9)
|
|
||||||
|
|
||||||
**Kontext:** Eine abgestürzte Session (API 400 thinking-blocks) hinterließ ein uncommittetes Working-Tree, das **drei** releaste Commits revertierte (08372f9 Passwort + 18eada9 Hybrid-Rename + 98dc366 Support-Bundle, zurück auf v1.7.159). Kein dokumentierter Intent → als Crash-Debris bewertet, non-destruktiv **gestasht** (`git stash` — recoverable), HEAD/v1.7.162 wiederhergestellt.
|
|
||||||
|
|
||||||
**Verifizierter Fund (Folge-Bug zu 18eada9):**
|
|
||||||
- 18eada9 schloss den "frische Datei landet unbenannt"-Bug nur für den **Hybrid-Pfad** (`deferFreshFiles=true` + Mehrfach-Pässe).
|
|
||||||
- Der **finale Deferred-Pass** (`runDeferredPostExtraction`) macht Rename (12125) → Collect (12156, `deferFreshFiles=false`). Ist eine Datei beim Deferred-Rename noch frisch (< `fileStabilizeMinAgeMs`, prod=2000ms) — v.a. eine eben per **Nested-Extraction** (12045, unmittelbar davor) geschriebene Datei — überspringt der Frische-Gate sie, und der Collect moved sie mit **Original-Scene-Namen** in die Library. `collectMkvFilesToLibrary` benennt selbst nicht um (Move-Body: `buildUniqueFlattenTargetPath`, nur Flatten).
|
|
||||||
- Pre-existierender Gap (Frische-Skip-Block älter als 18eada9); auch HEAD/v1.7.162 betroffen.
|
|
||||||
|
|
||||||
**Gate (TDD, vor Fix):** neuer Regressionstest "deferred final pass renames fresh files before collecting them" → reproduzierte den Bug zuverlässig gegen HEAD (Datei landete unbenannt).
|
|
||||||
|
|
||||||
**Fix (minimal, Root-Cause):** `treatFilesAsStable`-Param durch `autoRenameExtractedVideoFiles(Impl)`. Im Deferred-**Final**-Pass (kein concurrent Extractor-Write mehr, Extraktion awaited) wird der Frische-Gate umgangen → alle Dateien werden umbenannt, bevor der Collect sie sammelt. Hybrid-Pfad unangetastet (nutzt `...Impl` mit Default `false` → Frische-Skip bleibt aktiv, schützt weiter vor Rename mitten in concurrent Write).
|
|
||||||
|
|
||||||
**Verifikation:** neuer Test grün, Hybrid-Test grün (kein Regress), **623 Tests grün** (31 Dateien), tsc unverändert (9 pre-existing). Advisor-Gate vor Fix (verlangte Repro-Test statt Timing-Argument).
|
|
||||||
|
|
||||||
**Offen / bewusst nicht angefasst:**
|
|
||||||
- Gestashtes Crash-Debris (`stash@{0}`): enthält Revert von 08372f9/18eada9/98dc366 + log.old. Bei Bedarf inspizierbar/recoverbar; sonst irgendwann verwerfbar.
|
|
||||||
- 08372f9 (Passwort-Daemon-Reset) bewusst nicht neu aufgerollt (außerhalb dieses Goals, kein Hinweis auf Defekt).
|
|
||||||
- Untracked `*-postprocess/` + `fix-library-renames.mjs`: alte Experimente (Apr/Mai), unverändert gelassen.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## E. Mega-Debrid Account temporär deaktivieren (UI) — 2026-05-31
|
|
||||||
|
|
||||||
**Goal:** Einzelne Mega-Debrid-Accounts deaktivieren (statt löschen) → Rotation überspringt sie, nutzt die anderen.
|
|
||||||
|
|
||||||
**Befund:** Backend KOMPLETT vorhanden — `megaDebridDisabledAccountIds` (Typ/Defaults/Storage-Normalisierung) + Rotation-Skip (debrid.ts:1944) + Verfügbarkeits-Checks. Es fehlt NUR das UI-Toggle (Debrid-Link hat es bereits via keyStatsPopup). ID-Seam verifiziert: `getMegaDebridAccountId(login)` (trim+lowercase) ist auf beiden Seiten identisch → Test grün.
|
|
||||||
|
|
||||||
**Ansatz (B-kohärent):** Toggle in die bestehende Mega-Account-Liste im Bearbeiten-Dialog falten (draft-then-Save, KEIN Live-Persist → kohärent, minimal). Schritte:
|
|
||||||
1. [x] TDD: Test „skips a manually disabled Mega-Debrid account" (acc1 disabled → acc2) — grün gegen Backend.
|
|
||||||
2. [ ] `AccountDialogState`: Feld `megaDisabledIds: string[]`.
|
|
||||||
3. [ ] Dialog-Init (createAccountDialogState): aus `settings.megaDebridDisabledAccountIds`.
|
|
||||||
4. [ ] JSX Mega-Account-Liste: Aktivieren/Deaktivieren-Button + disabled-Styling pro Account.
|
|
||||||
5. [ ] Entfernen-Handler: ID auch aus megaDisabledIds entfernen.
|
|
||||||
6. [ ] `buildSettingsFromDialog` (megadebrid-api/web): `megaDebridDisabledAccountIds` aus Draft (gefiltert auf vorhandene Accounts) übernehmen.
|
|
||||||
7. [ ] Verifizieren: tsc unverändert, volle Suite grün, Toggle-Test grün.
|
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
150
tests/german-audio-integration.test.ts
Normal file
150
tests/german-audio-integration.test.ts
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
// Mock only processVideoFile (the ffmpeg boundary); keep the real pure helpers
|
||||||
|
// (stripDualLangMarker / hasDualLangMarker / isRemuxableVideoFile) so the
|
||||||
|
// download-manager's selection + .DL.-rename wiring is exercised for real.
|
||||||
|
vi.mock("../src/main/video-processor", async (importActual) => {
|
||||||
|
const actual = await importActual<typeof import("../src/main/video-processor")>();
|
||||||
|
return { ...actual, processVideoFile: vi.fn(), resolveVideoTooling: vi.fn() };
|
||||||
|
});
|
||||||
|
|
||||||
|
import { DownloadManager } from "../src/main/download-manager";
|
||||||
|
import { defaultSettings } from "../src/main/constants";
|
||||||
|
import { createStoragePaths, emptySession } from "../src/main/storage";
|
||||||
|
import { shutdownItemLogs } from "../src/main/item-log";
|
||||||
|
import { shutdownPackageLogs } from "../src/main/package-log";
|
||||||
|
import { shutdownRenameLog } from "../src/main/rename-log";
|
||||||
|
import { processVideoFile, resolveVideoTooling, type VideoProcessResult } from "../src/main/video-processor";
|
||||||
|
|
||||||
|
const mockedProcess = processVideoFile as unknown as ReturnType<typeof vi.fn>;
|
||||||
|
const mockedTooling = resolveVideoTooling as unknown as ReturnType<typeof vi.fn>;
|
||||||
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mockedProcess.mockReset();
|
||||||
|
mockedTooling.mockReset();
|
||||||
|
shutdownItemLogs();
|
||||||
|
shutdownPackageLogs();
|
||||||
|
shutdownRenameLog();
|
||||||
|
for (const dir of tempDirs.splice(0)) {
|
||||||
|
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function setup(keepGermanAudioOnly: boolean): { extractDir: string; manager: DownloadManager; pkg: any } {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-ga-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const extractDir = path.join(root, "extract");
|
||||||
|
const stateDir = path.join(root, "state");
|
||||||
|
fs.mkdirSync(extractDir, { recursive: true });
|
||||||
|
fs.mkdirSync(stateDir, { recursive: true });
|
||||||
|
const manager = new DownloadManager(
|
||||||
|
{
|
||||||
|
...defaultSettings(),
|
||||||
|
token: "rd-token",
|
||||||
|
keepGermanAudioOnly,
|
||||||
|
germanAudioMode: "tag",
|
||||||
|
autoRename4sf4sj: false,
|
||||||
|
outputDir: path.join(root, "out"),
|
||||||
|
extractDir,
|
||||||
|
mkvLibraryDir: path.join(stateDir, "_mkv")
|
||||||
|
},
|
||||||
|
emptySession(),
|
||||||
|
createStoragePaths(stateDir)
|
||||||
|
);
|
||||||
|
const pkg: any = {
|
||||||
|
id: "ga-pkg-1",
|
||||||
|
name: "Test.Show.S01.GERMAN.DL.720p",
|
||||||
|
outputDir: path.join(root, "out", "Test.Show"),
|
||||||
|
extractDir,
|
||||||
|
status: "completed",
|
||||||
|
itemIds: [],
|
||||||
|
cancelled: false,
|
||||||
|
enabled: true,
|
||||||
|
priority: "normal",
|
||||||
|
createdAt: 0,
|
||||||
|
updatedAt: 0
|
||||||
|
};
|
||||||
|
// 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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const DL_MKV = "Show.S01E01.German.DL.720p.x264.mkv";
|
||||||
|
const PLAIN_MKV = "Show.S01E02.German.1080p.x264.mkv";
|
||||||
|
const SAMPLE_DL = "Show.sample.DL.mkv";
|
||||||
|
const DL_AVI = "Show.S01E03.German.DL.avi";
|
||||||
|
|
||||||
|
function stage(extractDir: string): void {
|
||||||
|
for (const f of [DL_MKV, PLAIN_MKV, SAMPLE_DL, DL_AVI]) {
|
||||||
|
fs.writeFileSync(path.join(extractDir, f), "x");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("keepGermanAudioOnly integration", () => {
|
||||||
|
it("processes only .DL. mkv/mp4 and strips .DL. after a successful remux", async () => {
|
||||||
|
const { extractDir, manager, pkg } = setup(true);
|
||||||
|
stage(extractDir);
|
||||||
|
mockedProcess.mockResolvedValue({ action: "remuxed", reason: "german-tag", totalAudioTracks: 2, keptTrackIndex: 0 } as VideoProcessResult);
|
||||||
|
|
||||||
|
const n = await (manager as any).keepGermanAudioOnlyImpl(extractDir, pkg);
|
||||||
|
|
||||||
|
expect(mockedProcess).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockedProcess.mock.calls[0][0]).toBe(path.join(extractDir, DL_MKV));
|
||||||
|
expect(n).toBe(1);
|
||||||
|
|
||||||
|
const files = fs.readdirSync(extractDir);
|
||||||
|
expect(files).toContain("Show.S01E01.German.720p.x264.mkv"); // .DL. stripped
|
||||||
|
expect(files).not.toContain(DL_MKV);
|
||||||
|
expect(files).toContain(PLAIN_MKV); // non-.DL. untouched
|
||||||
|
expect(files).toContain(SAMPLE_DL); // sample skipped
|
||||||
|
expect(files).toContain(DL_AVI); // avi not remuxable, skipped
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing when the setting is off", async () => {
|
||||||
|
const { extractDir, manager, pkg } = setup(false);
|
||||||
|
stage(extractDir);
|
||||||
|
const n = await (manager as any).keepGermanAudioOnlyImpl(extractDir, pkg);
|
||||||
|
expect(n).toBe(0);
|
||||||
|
expect(mockedProcess).not.toHaveBeenCalled();
|
||||||
|
expect(fs.readdirSync(extractDir)).toContain(DL_MKV); // untouched
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the file fully untouched (name included) when no German track is found", async () => {
|
||||||
|
const { extractDir, manager, pkg } = setup(true);
|
||||||
|
stage(extractDir);
|
||||||
|
mockedProcess.mockResolvedValue({ action: "skipped-no-german", reason: "no-german-track", totalAudioTracks: 2 } as VideoProcessResult);
|
||||||
|
|
||||||
|
await (manager as any).keepGermanAudioOnlyImpl(extractDir, pkg);
|
||||||
|
|
||||||
|
expect(mockedProcess).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fs.readdirSync(extractDir)).toContain(DL_MKV); // NOT renamed -> stays visible as unprocessed
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still strips .DL. for a single-audio file (no remux needed)", async () => {
|
||||||
|
const { extractDir, manager, pkg } = setup(true);
|
||||||
|
stage(extractDir);
|
||||||
|
mockedProcess.mockResolvedValue({ action: "kept-single", reason: "single-german", totalAudioTracks: 1, keptTrackIndex: 0 } as VideoProcessResult);
|
||||||
|
|
||||||
|
const n = await (manager as any).keepGermanAudioOnlyImpl(extractDir, pkg);
|
||||||
|
|
||||||
|
expect(n).toBe(0); // not counted as a remux
|
||||||
|
expect(fs.readdirSync(extractDir)).toContain("Show.S01E01.German.720p.x264.mkv");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips up front (no processVideoFile calls) and leaves files untouched when ffmpeg is missing", async () => {
|
||||||
|
const { extractDir, manager, pkg } = setup(true);
|
||||||
|
stage(extractDir);
|
||||||
|
mockedTooling.mockResolvedValue(null); // ffmpeg/ffprobe not found
|
||||||
|
|
||||||
|
const n = await (manager as any).keepGermanAudioOnlyImpl(extractDir, pkg);
|
||||||
|
|
||||||
|
expect(n).toBe(0);
|
||||||
|
expect(mockedProcess).not.toHaveBeenCalled(); // bailed before touching any file
|
||||||
|
expect(fs.readdirSync(extractDir)).toContain(DL_MKV); // untouched
|
||||||
|
});
|
||||||
|
});
|
||||||
283
tests/video-processor.test.ts
Normal file
283
tests/video-processor.test.ts
Normal file
@ -0,0 +1,283 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
stripDualLangMarker,
|
||||||
|
hasDualLangMarker,
|
||||||
|
isRemuxableVideoFile,
|
||||||
|
looksLikeGermanRelease,
|
||||||
|
pickAudioTrack,
|
||||||
|
parseFfprobeAudioStreams,
|
||||||
|
buildFfprobeArgs,
|
||||||
|
buildFfmpegRemuxArgs,
|
||||||
|
computeRemuxTimeoutMs,
|
||||||
|
processVideoFile,
|
||||||
|
type VideoSpawnResult
|
||||||
|
} from "../src/main/video-processor";
|
||||||
|
|
||||||
|
describe("stripDualLangMarker", () => {
|
||||||
|
it("strips a mid-name .DL. token", () => {
|
||||||
|
expect(stripDualLangMarker("Show.S01E01.German.DL.720p.WEB.x264.mkv")).toBe("Show.S01E01.German.720p.WEB.x264.mkv");
|
||||||
|
});
|
||||||
|
it("strips a .DL. directly before the extension", () => {
|
||||||
|
expect(stripDualLangMarker("Movie.DL.mkv")).toBe("Movie.mkv");
|
||||||
|
});
|
||||||
|
it("strips a trailing .DL token before extension", () => {
|
||||||
|
expect(stripDualLangMarker("Movie.German.DL.mp4")).toBe("Movie.German.mp4");
|
||||||
|
});
|
||||||
|
it("is case-insensitive", () => {
|
||||||
|
expect(stripDualLangMarker("Show.dl.1080p.mkv")).toBe("Show.1080p.mkv");
|
||||||
|
});
|
||||||
|
it("leaves files without the marker unchanged", () => {
|
||||||
|
expect(stripDualLangMarker("Show.S01E01.German.1080p.mkv")).toBe("Show.S01E01.German.1080p.mkv");
|
||||||
|
});
|
||||||
|
it("does not strip unrelated tokens containing DL", () => {
|
||||||
|
expect(stripDualLangMarker("Show.HANDLES.1080p.mkv")).toBe("Show.HANDLES.1080p.mkv");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("hasDualLangMarker", () => {
|
||||||
|
it("detects the marker", () => {
|
||||||
|
expect(hasDualLangMarker("X.German.DL.720p.mkv")).toBe(true);
|
||||||
|
expect(hasDualLangMarker("X.DL.mkv")).toBe(true);
|
||||||
|
});
|
||||||
|
it("returns false without the marker", () => {
|
||||||
|
expect(hasDualLangMarker("X.German.720p.mkv")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isRemuxableVideoFile", () => {
|
||||||
|
it("accepts mkv/mp4 only", () => {
|
||||||
|
expect(isRemuxableVideoFile("a.mkv")).toBe(true);
|
||||||
|
expect(isRemuxableVideoFile("a.MP4")).toBe(true);
|
||||||
|
expect(isRemuxableVideoFile("a.avi")).toBe(false);
|
||||||
|
expect(isRemuxableVideoFile("a.srt")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("pickAudioTrack", () => {
|
||||||
|
const ger = { language: "ger", title: "" };
|
||||||
|
const eng = { language: "eng", title: "" };
|
||||||
|
const untagged = { language: "", title: "" };
|
||||||
|
|
||||||
|
it("no audio -> skip", () => {
|
||||||
|
expect(pickAudioTrack([], "tag").action).toBe("skip");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("first mode keeps first of many", () => {
|
||||||
|
const d = pickAudioTrack([eng, ger], "first");
|
||||||
|
expect(d).toMatchObject({ action: "remux", audioRelIndex: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("first mode with single audio -> single (no remux)", () => {
|
||||||
|
expect(pickAudioTrack([eng], "first")).toMatchObject({ action: "single" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tag mode picks the German track even if not first", () => {
|
||||||
|
const d = pickAudioTrack([eng, ger], "tag");
|
||||||
|
expect(d).toMatchObject({ action: "remux", audioRelIndex: 1, reason: "german-tag" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tag mode picks German via title when language untagged", () => {
|
||||||
|
const d = pickAudioTrack([{ language: "", title: "Englisch" }, { language: "", title: "Deutsch" }], "tag");
|
||||||
|
expect(d).toMatchObject({ action: "remux", audioRelIndex: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tag mode with single German -> single (no remux)", () => {
|
||||||
|
expect(pickAudioTrack([ger], "tag")).toMatchObject({ action: "single" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tag mode, fully untagged multi -> fallback to first", () => {
|
||||||
|
const d = pickAudioTrack([untagged, untagged], "tag");
|
||||||
|
expect(d).toMatchObject({ action: "remux", audioRelIndex: 0, reason: "fallback-first-untagged" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tag mode, tagged but no German -> SKIP (never delete the only usable audio)", () => {
|
||||||
|
expect(pickAudioTrack([eng, { language: "fre", title: "" }], "tag")).toMatchObject({ action: "skip", reason: "no-german-track" });
|
||||||
|
});
|
||||||
|
|
||||||
|
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", () => {
|
||||||
|
it("parses language/title tags", () => {
|
||||||
|
const json = JSON.stringify({ streams: [{ index: 1, tags: { language: "ger", title: "Deutsch" } }, { index: 2, tags: { language: "eng" } }] });
|
||||||
|
expect(parseFfprobeAudioStreams(json)).toEqual([{ language: "ger", title: "Deutsch" }, { language: "eng", title: "" }]);
|
||||||
|
});
|
||||||
|
it("returns [] on invalid json", () => {
|
||||||
|
expect(parseFfprobeAudioStreams("not json")).toEqual([]);
|
||||||
|
});
|
||||||
|
it("returns [] when streams missing", () => {
|
||||||
|
expect(parseFfprobeAudioStreams("{}")).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildFfprobeArgs", () => {
|
||||||
|
it("requests audio streams as json", () => {
|
||||||
|
const args = buildFfprobeArgs("in.mkv");
|
||||||
|
expect(args).toContain("-select_streams");
|
||||||
|
expect(args).toContain("a");
|
||||||
|
expect(args[args.length - 1]).toBe("in.mkv");
|
||||||
|
expect(args).toContain("json");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildFfmpegRemuxArgs", () => {
|
||||||
|
it("maps video + chosen audio, stream-copy, keeps metadata (language tag), no subs by default", () => {
|
||||||
|
const args = buildFfmpegRemuxArgs({ input: "in.mkv", output: "out.mkv", audioRelIndex: 1 });
|
||||||
|
expect(args).toEqual([
|
||||||
|
"-i", "in.mkv", "-map", "0:v:0", "-map", "0:a:1",
|
||||||
|
"-c", "copy", "-disposition:a:0", "default", "-y", "out.mkv"
|
||||||
|
]);
|
||||||
|
expect(args).not.toContain("-map_metadata"); // language tag of kept track must survive
|
||||||
|
});
|
||||||
|
it("adds optional German subtitle maps when keepSubs", () => {
|
||||||
|
const args = buildFfmpegRemuxArgs({ input: "in.mkv", output: "out.mkv", audioRelIndex: 0, keepSubs: true });
|
||||||
|
expect(args.join(" ")).toContain("0:s:m:language:ger?");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("computeRemuxTimeoutMs", () => {
|
||||||
|
it("has a floor", () => {
|
||||||
|
expect(computeRemuxTimeoutMs(0)).toBe(120_000);
|
||||||
|
});
|
||||||
|
it("scales with size and caps at 60 min", () => {
|
||||||
|
expect(computeRemuxTimeoutMs(50 * 1024 * 1024 * 1024)).toBe(60 * 60 * 1000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Exercises the REAL file-mutating body (temp -> replace -> utimes -> rm) with a
|
||||||
|
// fake ffmpeg/ffprobe runner. This is the irreversible-overwrite path that the
|
||||||
|
// download-manager integration test (which mocks processVideoFile wholesale)
|
||||||
|
// cannot cover.
|
||||||
|
describe("processVideoFile (real fs body, fake runner)", () => {
|
||||||
|
const tempDirs: string[] = [];
|
||||||
|
afterEach(() => {
|
||||||
|
for (const d of tempDirs.splice(0)) {
|
||||||
|
try { fs.rmSync(d, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeFile(content: string, name = "Show.S01E01.German.DL.720p.mkv"): string {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-vp-"));
|
||||||
|
tempDirs.push(dir);
|
||||||
|
const file = path.join(dir, name);
|
||||||
|
fs.writeFileSync(file, content);
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeRunner(opts: { probeJson: string; ffmpegOk?: boolean }): typeof import("../src/main/video-processor").runVideoProcess {
|
||||||
|
return async (_command: string, args: string[]): Promise<VideoSpawnResult> => {
|
||||||
|
const base = { aborted: false, timedOut: false, missing: false } as const;
|
||||||
|
if (args.includes("-show_entries")) {
|
||||||
|
return { ...base, ok: true, exitCode: 0, stdout: opts.probeJson, stderr: "" };
|
||||||
|
}
|
||||||
|
const output = args[args.length - 1];
|
||||||
|
if (opts.ffmpegOk !== false) {
|
||||||
|
fs.writeFileSync(output, "REMUXED-GERMAN-ONLY");
|
||||||
|
return { ...base, ok: true, exitCode: 0, stdout: "", stderr: "" };
|
||||||
|
}
|
||||||
|
return { ...base, ok: false, exitCode: 1, stdout: "", stderr: "ffmpeg boom" };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const tooling = async (): Promise<{ ffmpeg: string; ffprobe: string }> => ({ ffmpeg: "ffmpeg", ffprobe: "ffprobe" });
|
||||||
|
const twoTracksGerSecond = JSON.stringify({ streams: [{ tags: { language: "eng" } }, { tags: { language: "ger" } }] });
|
||||||
|
|
||||||
|
it("replaces the original in place and preserves mtime on success", async () => {
|
||||||
|
const file = makeFile("ORIGINAL");
|
||||||
|
const oldTime = new Date(Date.now() - 5 * 60 * 1000);
|
||||||
|
fs.utimesSync(file, oldTime, oldTime);
|
||||||
|
const beforeMtime = fs.statSync(file).mtimeMs;
|
||||||
|
|
||||||
|
const result = await processVideoFile(file, { mode: "tag" }, {
|
||||||
|
resolveTooling: tooling,
|
||||||
|
runProcess: fakeRunner({ probeJson: twoTracksGerSecond })
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.action).toBe("remuxed");
|
||||||
|
expect(result.keptTrackIndex).toBe(1); // German was second
|
||||||
|
expect(fs.readFileSync(file, "utf8")).toBe("REMUXED-GERMAN-ONLY"); // original overwritten
|
||||||
|
expect(Math.abs(fs.statSync(file).mtimeMs - beforeMtime)).toBeLessThan(1500); // mtime preserved
|
||||||
|
expect(fs.existsSync(`${file}.gertmp.mkv`)).toBe(false); // temp cleaned up
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the original intact and removes temp when ffmpeg fails", async () => {
|
||||||
|
const file = makeFile("ORIGINAL");
|
||||||
|
const result = await processVideoFile(file, { mode: "tag" }, {
|
||||||
|
resolveTooling: tooling,
|
||||||
|
runProcess: fakeRunner({ probeJson: twoTracksGerSecond, ffmpegOk: false })
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.action).toBe("error");
|
||||||
|
expect(fs.readFileSync(file, "utf8")).toBe("ORIGINAL"); // never lost
|
||||||
|
expect(fs.existsSync(`${file}.gertmp.mkv`)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not touch a single-audio file (no remux)", async () => {
|
||||||
|
const file = makeFile("ORIGINAL");
|
||||||
|
const result = await processVideoFile(file, { mode: "tag" }, {
|
||||||
|
resolveTooling: tooling,
|
||||||
|
runProcess: fakeRunner({ probeJson: JSON.stringify({ streams: [{ tags: { language: "ger" } }] }) })
|
||||||
|
});
|
||||||
|
expect(result.action).toBe("kept-single");
|
||||||
|
expect(fs.readFileSync(file, "utf8")).toBe("ORIGINAL");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("remuxes a German-named release with MISLABELED audio tags (fallback to first track)", async () => {
|
||||||
|
// Name says German, but both audio tracks are tagged eng/fre (the dub is
|
||||||
|
// mislabeled). The fallback keeps the first track instead of skipping.
|
||||||
|
const file = makeFile("ORIGINAL"); // name contains "German"
|
||||||
|
const result = await processVideoFile(file, { mode: "tag" }, {
|
||||||
|
resolveTooling: tooling,
|
||||||
|
runProcess: fakeRunner({ probeJson: JSON.stringify({ streams: [{ tags: { language: "eng" } }, { tags: { language: "fre" } }] }) })
|
||||||
|
});
|
||||||
|
expect(result.action).toBe("remuxed");
|
||||||
|
expect(result.keptTrackIndex).toBe(0);
|
||||||
|
expect(fs.readFileSync(file, "utf8")).toBe("REMUXED-GERMAN-ONLY");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a NON-German-named file untouched when tagged but no German track (safety preserved)", async () => {
|
||||||
|
const file = makeFile("ORIGINAL", "Show.S01E01.MULTi.DL.720p.mkv");
|
||||||
|
const result = await processVideoFile(file, { mode: "tag" }, {
|
||||||
|
resolveTooling: tooling,
|
||||||
|
runProcess: fakeRunner({ probeJson: JSON.stringify({ streams: [{ tags: { language: "eng" } }, { tags: { language: "fre" } }] }) })
|
||||||
|
});
|
||||||
|
expect(result.action).toBe("skipped-no-german");
|
||||||
|
expect(fs.readFileSync(file, "utf8")).toBe("ORIGINAL");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns skipped-no-tool when ffmpeg/ffprobe are absent", async () => {
|
||||||
|
const file = makeFile("ORIGINAL");
|
||||||
|
const result = await processVideoFile(file, { mode: "tag" }, { resolveTooling: async () => null });
|
||||||
|
expect(result.action).toBe("skipped-no-tool");
|
||||||
|
expect(fs.readFileSync(file, "utf8")).toBe("ORIGINAL");
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user