Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92890f9649 | ||
|
|
2b93f47d3a | ||
|
|
15edfbeb74 | ||
|
|
aa65f56c28 | ||
|
|
92a36e2e47 | ||
|
|
77661389f3 | ||
|
|
397e667af2 | ||
|
|
20c803302d | ||
|
|
468df99142 |
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "1.7.184",
|
"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",
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import v8 from "node:v8";
|
||||||
import { app } from "electron";
|
import { app } from "electron";
|
||||||
import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys";
|
import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys";
|
||||||
import {
|
import {
|
||||||
@ -84,6 +85,7 @@ export class AppController {
|
|||||||
|
|
||||||
private autoResumePending = false;
|
private autoResumePending = false;
|
||||||
private runtimeStatsTimer: NodeJS.Timeout | null = null;
|
private runtimeStatsTimer: NodeJS.Timeout | null = null;
|
||||||
|
private lastMemoryWarnAt = 0;
|
||||||
|
|
||||||
public constructor() {
|
public constructor() {
|
||||||
configureLogger(this.storagePaths.baseDir);
|
configureLogger(this.storagePaths.baseDir);
|
||||||
@ -162,6 +164,7 @@ export class AppController {
|
|||||||
this.runtimeStatsTimer = setInterval(() => {
|
this.runtimeStatsTimer = setInterval(() => {
|
||||||
this.manager.persistRuntimeStats();
|
this.manager.persistRuntimeStats();
|
||||||
this.settings = this.manager.getSettings();
|
this.settings = this.manager.getSettings();
|
||||||
|
this.checkMemoryPressure();
|
||||||
}, 60_000);
|
}, 60_000);
|
||||||
this.runtimeStatsTimer.unref?.();
|
this.runtimeStatsTimer.unref?.();
|
||||||
|
|
||||||
@ -187,6 +190,34 @@ export class AppController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Early-warning for OOM on a long-running process. Measured against the V8
|
||||||
|
// heap_size_limit (the real ceiling at which the process is killed), NOT against
|
||||||
|
// heapTotal: V8 routinely runs near-full of its current heapTotal just before it
|
||||||
|
// grows it, so a heapUsed/heapTotal ratio would cry wolf and — since every WARN
|
||||||
|
// now feeds the error ring — crowd real failures out. Throttled to 1 warning per
|
||||||
|
// 5 min so a genuine sustained-pressure run does not spam the log/ring.
|
||||||
|
private checkMemoryPressure(): void {
|
||||||
|
try {
|
||||||
|
const mem = process.memoryUsage();
|
||||||
|
const heapLimit = v8.getHeapStatistics().heap_size_limit;
|
||||||
|
const ratio = heapLimit > 0 ? mem.heapUsed / heapLimit : 0;
|
||||||
|
if (ratio < 0.9) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - this.lastMemoryWarnAt < 5 * 60_000) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.lastMemoryWarnAt = now;
|
||||||
|
const mb = (bytes: number): number => Math.round(bytes / 1048576);
|
||||||
|
logger.warn(
|
||||||
|
`Speicherdruck: heapUsed=${mb(mem.heapUsed)}MB von Limit ${mb(heapLimit)}MB ` +
|
||||||
|
`(${Math.round(ratio * 100)}%), heapTotal=${mb(mem.heapTotal)}MB, rss=${mb(mem.rss)}MB, external=${mb(mem.external)}MB`
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private hasAnyProviderToken(settings: AppSettings): boolean {
|
private hasAnyProviderToken(settings: AppSettings): boolean {
|
||||||
return Boolean(
|
return Boolean(
|
||||||
settings.token.trim()
|
settings.token.trim()
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { APP_VERSION } from "./constants";
|
|||||||
import { getAuditLogPath } from "./audit-log";
|
import { getAuditLogPath } from "./audit-log";
|
||||||
import { getDebugSetupCheck } from "./debug-setup";
|
import { getDebugSetupCheck } from "./debug-setup";
|
||||||
import { logger, getLogFilePath } from "./logger";
|
import { logger, getLogFilePath } from "./logger";
|
||||||
|
import { getRecentErrors } from "./error-ring";
|
||||||
import { getItemLogPath as getPersistedItemLogPath } from "./item-log";
|
import { getItemLogPath as getPersistedItemLogPath } from "./item-log";
|
||||||
import { getSessionLogPath } from "./session-log";
|
import { getSessionLogPath } from "./session-log";
|
||||||
import { getPackageLogPath as getPersistedPackageLogPath } from "./package-log";
|
import { getPackageLogPath as getPersistedPackageLogPath } from "./package-log";
|
||||||
@ -44,6 +45,7 @@ const DEBUG_ENDPOINTS: DebugEndpointDescriptor[] = [
|
|||||||
{ method: "GET", path: "/logs/session", queryExample: "lines=100&grep=keyword", description: "Reads the session log tail." },
|
{ method: "GET", path: "/logs/session", queryExample: "lines=100&grep=keyword", description: "Reads the session log tail." },
|
||||||
{ method: "GET", path: "/logs/package", queryExample: "package=Release&lines=100&grep=keyword", description: "Reads the package log for a specific package name or id." },
|
{ method: "GET", path: "/logs/package", queryExample: "package=Release&lines=100&grep=keyword", description: "Reads the package log for a specific package name or id." },
|
||||||
{ method: "GET", path: "/logs/item", queryExample: "item=episode.part2.rar&lines=100&grep=keyword", description: "Reads the item log for a specific file name or item id." },
|
{ method: "GET", path: "/logs/item", queryExample: "item=episode.part2.rar&lines=100&grep=keyword", description: "Reads the item log for a specific file name or item id." },
|
||||||
|
{ method: "GET", path: "/errors", queryExample: "level=ERROR&limit=100", description: "Returns the in-memory ring of the most recent WARN/ERROR log lines." },
|
||||||
{ method: "GET", path: "/trace/config", queryExample: "enable=1¬e=support&durationMinutes=120", description: "Reads or updates the support trace configuration." },
|
{ method: "GET", path: "/trace/config", queryExample: "enable=1¬e=support&durationMinutes=120", description: "Reads or updates the support trace configuration." },
|
||||||
{ method: "GET", path: "/settings", description: "Returns a redacted settings snapshot without raw secrets." },
|
{ method: "GET", path: "/settings", description: "Returns a redacted settings snapshot without raw secrets." },
|
||||||
{ method: "GET", path: "/accounts", description: "Returns a redacted account/provider configuration summary." },
|
{ method: "GET", path: "/accounts", description: "Returns a redacted account/provider configuration summary." },
|
||||||
@ -528,6 +530,18 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pathname === "/errors") {
|
||||||
|
const levelFilter = (url.searchParams.get("level") || "").toUpperCase();
|
||||||
|
const limit = normalizeLinesParam(url.searchParams.get("limit"), 100);
|
||||||
|
let entries = getRecentErrors();
|
||||||
|
if (levelFilter === "ERROR" || levelFilter === "WARN") {
|
||||||
|
entries = entries.filter((entry) => entry.level === levelFilter);
|
||||||
|
}
|
||||||
|
const limited = entries.slice(-limit);
|
||||||
|
jsonResponse(res, 200, { count: limited.length, total: entries.length, entries: limited });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (pathname === "/logs/audit") {
|
if (pathname === "/logs/audit") {
|
||||||
const count = normalizeLinesParam(url.searchParams.get("lines"), 100);
|
const count = normalizeLinesParam(url.searchParams.get("lines"), 100);
|
||||||
const grep = url.searchParams.get("grep") || "";
|
const grep = url.searchParams.get("grep") || "";
|
||||||
|
|||||||
@ -53,6 +53,8 @@ import { planDownloadCompletion, validateDownloadedFileCompletion } from "./down
|
|||||||
import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, DebridService, MegaWebUnrestrictor, RealDebridWebUnrestrictor, checkRapidgatorOnline, fetchAllDebridHostInfo, getAvailableDebridLinkApiKeys, pruneExpiredDebridLinkRuntimeState, pruneExpiredMegaDebridRuntimeState } from "./debrid";
|
import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, DebridService, MegaWebUnrestrictor, RealDebridWebUnrestrictor, checkRapidgatorOnline, fetchAllDebridHostInfo, getAvailableDebridLinkApiKeys, pruneExpiredDebridLinkRuntimeState, pruneExpiredMegaDebridRuntimeState } from "./debrid";
|
||||||
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 { 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";
|
||||||
@ -2039,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,
|
||||||
@ -3891,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,
|
||||||
@ -4510,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;
|
||||||
}
|
}
|
||||||
@ -7075,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
|
||||||
@ -8563,16 +8716,24 @@ export class DownloadManager extends EventEmitter {
|
|||||||
item.updatedAt = nowMs();
|
item.updatedAt = nowMs();
|
||||||
this.emitState();
|
this.emitState();
|
||||||
|
|
||||||
|
const integrityStartedAt = nowMs();
|
||||||
const validation = await validateFileAgainstManifest(item.targetPath, pkg.outputDir);
|
const validation = await validateFileAgainstManifest(item.targetPath, pkg.outputDir);
|
||||||
if (active.abortController.signal.aborted) {
|
if (active.abortController.signal.aborted) {
|
||||||
throw new Error(`aborted:${active.abortReason}`);
|
throw new Error(`aborted:${active.abortReason}`);
|
||||||
}
|
}
|
||||||
|
const integrityElapsedMs = nowMs() - integrityStartedAt;
|
||||||
if (!validation.ok) {
|
if (!validation.ok) {
|
||||||
item.lastError = validation.message;
|
item.lastError = validation.message;
|
||||||
item.fullStatus = `${validation.message}, Neuversuch`;
|
item.fullStatus = `${validation.message}, Neuversuch`;
|
||||||
|
this.logPackageForItem(item, "WARN", "Integritätsprüfung fehlgeschlagen", {
|
||||||
|
result: validation.message,
|
||||||
|
elapsedMs: integrityElapsedMs,
|
||||||
|
willRetry: item.attempts < maxAttempts
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
fs.rmSync(item.targetPath, { force: true });
|
fs.rmSync(item.targetPath, { force: true });
|
||||||
} catch {
|
} catch (rmErr) {
|
||||||
|
logger.debug(`Integrity-Cleanup rm fehlgeschlagen (${item.fileName}): ${String(rmErr)}`);
|
||||||
}
|
}
|
||||||
if (item.attempts < maxAttempts) {
|
if (item.attempts < maxAttempts) {
|
||||||
item.status = "integrity_check";
|
item.status = "integrity_check";
|
||||||
@ -8586,6 +8747,11 @@ export class DownloadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
throw new Error(`Integritätsprüfung fehlgeschlagen (${validation.message})`);
|
throw new Error(`Integritätsprüfung fehlgeschlagen (${validation.message})`);
|
||||||
}
|
}
|
||||||
|
// Symmetry: a passed check was previously silent in the item log, so a
|
||||||
|
// reader could not tell whether integrity ran and passed vs was skipped.
|
||||||
|
this.logPackageForItem(item, "INFO", "Integritätsprüfung bestanden", {
|
||||||
|
elapsedMs: integrityElapsedMs
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (active.abortController.signal.aborted) {
|
if (active.abortController.signal.aborted) {
|
||||||
@ -10045,11 +10211,18 @@ export class DownloadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
lastError = compactErrorText(error);
|
lastError = compactErrorText(error);
|
||||||
const normalizedLastError = lastError.replace(/^Error:\s*/i, "");
|
const normalizedLastError = lastError.replace(/^Error:\s*/i, "");
|
||||||
|
const diskCause = classifyDiskError(error);
|
||||||
logAttemptEvent("WARN", "HTTP-Download-Versuch fehlgeschlagen", {
|
logAttemptEvent("WARN", "HTTP-Download-Versuch fehlgeschlagen", {
|
||||||
attempt,
|
attempt,
|
||||||
error: lastError,
|
error: lastError,
|
||||||
targetPath: effectiveTargetPath
|
targetPath: effectiveTargetPath,
|
||||||
|
...(diskCause ? { diskCause } : {})
|
||||||
});
|
});
|
||||||
|
if (diskCause) {
|
||||||
|
// Surface the concrete OS cause (disk full, permission, ...) prominently
|
||||||
|
// instead of leaving only a generic write/stream error in the log.
|
||||||
|
logger.error(`Schreibfehler beim Download: ${diskCause} - ${item.fileName} (ziel=${effectiveTargetPath})`);
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
normalizedLastError.startsWith("range_ignored_on_resume:")
|
normalizedLastError.startsWith("range_ignored_on_resume:")
|
||||||
|| normalizedLastError.startsWith("range_mismatch_on_resume:")
|
|| normalizedLastError.startsWith("range_mismatch_on_resume:")
|
||||||
@ -10921,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) {
|
||||||
@ -11591,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") {
|
||||||
|
|||||||
45
src/main/error-ring.ts
Normal file
45
src/main/error-ring.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
export interface ErrorRingEntry {
|
||||||
|
ts: string;
|
||||||
|
level: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ErrorRing {
|
||||||
|
push: (entry: ErrorRingEntry) => void;
|
||||||
|
snapshot: () => ErrorRingEntry[];
|
||||||
|
clear: () => void;
|
||||||
|
size: () => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createErrorRing(capacity: number): ErrorRing {
|
||||||
|
const limit = Math.max(1, Math.floor(capacity));
|
||||||
|
const buffer: ErrorRingEntry[] = [];
|
||||||
|
return {
|
||||||
|
push(entry: ErrorRingEntry): void {
|
||||||
|
buffer.push(entry);
|
||||||
|
while (buffer.length > limit) {
|
||||||
|
buffer.shift();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
snapshot(): ErrorRingEntry[] {
|
||||||
|
return buffer.slice();
|
||||||
|
},
|
||||||
|
clear(): void {
|
||||||
|
buffer.length = 0;
|
||||||
|
},
|
||||||
|
size(): number {
|
||||||
|
return buffer.length;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const RECENT_ERROR_CAPACITY = 200;
|
||||||
|
const recentErrors = createErrorRing(RECENT_ERROR_CAPACITY);
|
||||||
|
|
||||||
|
export function recordRecentError(level: string, message: string, ts: string): void {
|
||||||
|
recentErrors.push({ level, message, ts });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRecentErrors(): ErrorRingEntry[] {
|
||||||
|
return recentErrors.snapshot();
|
||||||
|
}
|
||||||
56
src/main/fs-error.ts
Normal file
56
src/main/fs-error.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
// Maps low-level filesystem/OS error codes to a human-readable cause so that a
|
||||||
|
// generic "write failed" or "timeout" can be reported as the specific root cause
|
||||||
|
// (disk full, permission denied, ...). Pure + side-effect-free for testing.
|
||||||
|
|
||||||
|
const DISK_ERROR_REASONS: Record<string, string> = {
|
||||||
|
ENOSPC: "Festplatte voll (ENOSPC)",
|
||||||
|
EDQUOT: "Speicher-Kontingent erschöpft (EDQUOT)",
|
||||||
|
EROFS: "Laufwerk schreibgeschützt (EROFS)",
|
||||||
|
EACCES: "Zugriff verweigert (EACCES)",
|
||||||
|
EPERM: "Operation nicht erlaubt (EPERM)",
|
||||||
|
EMFILE: "Zu viele offene Dateien (EMFILE)",
|
||||||
|
ENFILE: "System-Limit offener Dateien erreicht (ENFILE)",
|
||||||
|
EBUSY: "Datei/Laufwerk belegt (EBUSY)",
|
||||||
|
ENODEV: "Gerät nicht vorhanden (ENODEV)",
|
||||||
|
ENXIO: "Gerät getrennt (ENXIO)",
|
||||||
|
EIO: "Ein-/Ausgabefehler des Datenträgers (EIO)"
|
||||||
|
};
|
||||||
|
|
||||||
|
export function classifyDiskError(err: unknown): string | null {
|
||||||
|
const code = extractErrorCode(err);
|
||||||
|
if (code && DISK_ERROR_REASONS[code]) {
|
||||||
|
return DISK_ERROR_REASONS[code];
|
||||||
|
}
|
||||||
|
// Some errors arrive as plain strings/messages without a `.code`; fall back to
|
||||||
|
// scanning the text for a known code token.
|
||||||
|
const text = errorText(err);
|
||||||
|
for (const knownCode of Object.keys(DISK_ERROR_REASONS)) {
|
||||||
|
if (text.includes(knownCode)) {
|
||||||
|
return DISK_ERROR_REASONS[knownCode];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractErrorCode(err: unknown): string {
|
||||||
|
if (err && typeof err === "object") {
|
||||||
|
const code = (err as { code?: unknown }).code;
|
||||||
|
if (typeof code === "string") {
|
||||||
|
return code.toUpperCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorText(err: unknown): string {
|
||||||
|
if (typeof err === "string") {
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
if (err && typeof err === "object") {
|
||||||
|
const message = (err as { message?: unknown }).message;
|
||||||
|
if (typeof message === "string") {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return String(err ?? "");
|
||||||
|
}
|
||||||
@ -1,7 +1,24 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import { logTimestamp } from "./log-timestamp";
|
import { logTimestamp } from "./log-timestamp";
|
||||||
|
import { recordRecentError } from "./error-ring";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
|
export function isDebugFlagEnabled(value: string | undefined): boolean {
|
||||||
|
if (!value) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return /^(1|true|yes|on)$/i.test(value.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read once at startup. Enabling verbose DEBUG logging on the (unattended) server
|
||||||
|
// is a deliberate support action that requires a restart — the runtime-toggleable
|
||||||
|
// channel is the trace log, not this.
|
||||||
|
const DEBUG_ENABLED = isDebugFlagEnabled(process.env.RD_DEBUG);
|
||||||
|
|
||||||
|
export function isDebugLoggingEnabled(): boolean {
|
||||||
|
return DEBUG_ENABLED;
|
||||||
|
}
|
||||||
|
|
||||||
let logFilePath = path.resolve(process.cwd(), "rd_downloader.log");
|
let logFilePath = path.resolve(process.cwd(), "rd_downloader.log");
|
||||||
let fallbackLogFilePath: string | null = null;
|
let fallbackLogFilePath: string | null = null;
|
||||||
const LOG_FLUSH_INTERVAL_MS = 120;
|
const LOG_FLUSH_INTERVAL_MS = 120;
|
||||||
@ -204,12 +221,19 @@ function ensureExitHook(): void {
|
|||||||
process.once("exit", flushSyncPending);
|
process.once("exit", flushSyncPending);
|
||||||
}
|
}
|
||||||
|
|
||||||
function write(level: "INFO" | "WARN" | "ERROR", message: string): void {
|
function write(level: "DEBUG" | "INFO" | "WARN" | "ERROR", message: string): void {
|
||||||
ensureExitHook();
|
ensureExitHook();
|
||||||
const line = `${logTimestamp()} [${level}] ${message}\n`;
|
const ts = logTimestamp();
|
||||||
|
const line = `${ts} [${level}] ${message}\n`;
|
||||||
pendingLines.push(line);
|
pendingLines.push(line);
|
||||||
pendingChars += line.length;
|
pendingChars += line.length;
|
||||||
|
|
||||||
|
// Single chokepoint: every WARN/ERROR also lands in the in-memory ring so
|
||||||
|
// "what failed recently" is answerable even after the file rotates.
|
||||||
|
if (level === "ERROR" || level === "WARN") {
|
||||||
|
recordRecentError(level, message, ts);
|
||||||
|
}
|
||||||
|
|
||||||
for (const listener of logListeners) {
|
for (const listener of logListeners) {
|
||||||
try { listener(line); } catch { }
|
try { listener(line); } catch { }
|
||||||
}
|
}
|
||||||
@ -230,6 +254,9 @@ function write(level: "INFO" | "WARN" | "ERROR", message: string): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const logger = {
|
export const logger = {
|
||||||
|
// Gated to a no-op when RD_DEBUG is unset so verbose call sites cost nothing
|
||||||
|
// (no formatting, no allocation) in the normal/production path.
|
||||||
|
debug: DEBUG_ENABLED ? (msg: string): void => write("DEBUG", msg) : (_msg: string): void => {},
|
||||||
info: (msg: string): void => write("INFO", msg),
|
info: (msg: string): void => write("INFO", msg),
|
||||||
warn: (msg: string): void => write("WARN", msg),
|
warn: (msg: string): void => write("WARN", msg),
|
||||||
error: (msg: string): void => write("ERROR", msg)
|
error: (msg: string): void => write("ERROR", msg)
|
||||||
|
|||||||
@ -53,7 +53,13 @@ process.on("uncaughtException", (error) => {
|
|||||||
logger.error(`Uncaught Exception: ${String(error?.stack || error)}`);
|
logger.error(`Uncaught Exception: ${String(error?.stack || error)}`);
|
||||||
});
|
});
|
||||||
process.on("unhandledRejection", (reason) => {
|
process.on("unhandledRejection", (reason) => {
|
||||||
logger.error(`Unhandled Rejection: ${String(reason)}`);
|
const detail = reason instanceof Error ? (reason.stack || reason.message) : String(reason);
|
||||||
|
logger.error(`Unhandled Rejection: ${detail}`);
|
||||||
|
});
|
||||||
|
// Node-Warnungen (z.B. MaxListenersExceeded, DeprecationWarning) sind ein
|
||||||
|
// Frühindikator für Leaks/Fehlnutzung in einem langlaufenden Server-Prozess.
|
||||||
|
process.on("warning", (warning) => {
|
||||||
|
logger.warn(`Node-Warnung: ${warning.name}: ${warning.message}${warning.stack ? ` | ${warning.stack.replace(/\s*\n\s*/g, " ⏎ ")}` : ""}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
let mainWindow: BrowserWindow | null = null;
|
let mainWindow: BrowserWindow | null = null;
|
||||||
@ -110,6 +116,23 @@ function createWindow(): BrowserWindow {
|
|||||||
return window;
|
return window;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let rendererReloadTimes: number[] = [];
|
||||||
|
const RENDERER_RELOAD_WINDOW_MS = 5 * 60 * 1000;
|
||||||
|
const RENDERER_RELOAD_MAX = 3;
|
||||||
|
|
||||||
|
// Circuit breaker: recover from a one-off renderer crash by reloading, but stop
|
||||||
|
// after a few crashes in a short window so a reproducible crash can't spin into a
|
||||||
|
// reload loop that pegs an unattended server.
|
||||||
|
function allowRendererReload(): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
rendererReloadTimes = rendererReloadTimes.filter((t) => now - t < RENDERER_RELOAD_WINDOW_MS);
|
||||||
|
if (rendererReloadTimes.length >= RENDERER_RELOAD_MAX) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
rendererReloadTimes.push(now);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function bindMainWindowLifecycle(window: BrowserWindow): void {
|
function bindMainWindowLifecycle(window: BrowserWindow): void {
|
||||||
window.on("close", (event) => {
|
window.on("close", (event) => {
|
||||||
const settings = controller.getSettings();
|
const settings = controller.getSettings();
|
||||||
@ -124,6 +147,33 @@ function bindMainWindowLifecycle(window: BrowserWindow): void {
|
|||||||
mainWindow = null;
|
mainWindow = null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
window.webContents.on("render-process-gone", (_event, details) => {
|
||||||
|
logger.error(`Renderer-Prozess beendet: reason=${details.reason} exitCode=${details.exitCode ?? "?"}`);
|
||||||
|
if (details.reason === "clean-exit" || window.isDestroyed()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (allowRendererReload()) {
|
||||||
|
logger.warn("Renderer wird automatisch neu geladen (Wiederherstellung nach Absturz)");
|
||||||
|
try {
|
||||||
|
window.webContents.reload();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Renderer-Reload fehlgeschlagen: ${String(error)}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.error(`Renderer-Absturz: Auto-Reload gestoppt (mehr als ${RENDERER_RELOAD_MAX} Abstürze in ${RENDERER_RELOAD_WINDOW_MS / 60000} Min) - manueller Neustart nötig`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Nur protokollieren, niemals killen/neu laden: "unresponsive" feuert auch
|
||||||
|
// während legitimer langer Sync-Arbeit (große JSON-Serialisierung) und erholt
|
||||||
|
// sich meist von selbst. Eingreifen würde einen Schluckauf zum Ausfall machen.
|
||||||
|
window.webContents.on("unresponsive", () => {
|
||||||
|
logger.warn("Renderer reagiert nicht (unresponsive) - evtl. langer Sync-Task, warte auf Erholung");
|
||||||
|
});
|
||||||
|
window.webContents.on("responsive", () => {
|
||||||
|
logger.info("Renderer wieder reaktionsfähig (responsive)");
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function createTray(): void {
|
function createTray(): void {
|
||||||
@ -676,6 +726,14 @@ function registerIpcHandlers(): void {
|
|||||||
return importResult;
|
return importResult;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.on(IPC_CHANNELS.LOG_RENDERER_ERROR, (_event, rawReport: unknown) => {
|
||||||
|
try {
|
||||||
|
logger.error(formatRendererErrorReport(rawReport));
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`[Renderer] Fehlerbericht konnte nicht verarbeitet werden: ${String(error)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
controller.onState = (snapshot) => {
|
controller.onState = (snapshot) => {
|
||||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||||
return;
|
return;
|
||||||
@ -684,6 +742,41 @@ function registerIpcHandlers(): void {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatRendererErrorReport(rawReport: unknown): string {
|
||||||
|
const report = (rawReport && typeof rawReport === "object" ? rawReport : {}) as Record<string, unknown>;
|
||||||
|
const str = (value: unknown): string => (typeof value === "string" ? value : "");
|
||||||
|
const num = (value: unknown): string => (typeof value === "number" && Number.isFinite(value) ? String(value) : "");
|
||||||
|
const kind = str(report.kind) || "error";
|
||||||
|
const message = (str(report.message) || "(ohne Nachricht)").slice(0, 2000);
|
||||||
|
const source = str(report.source);
|
||||||
|
const line = num(report.line);
|
||||||
|
const column = num(report.column);
|
||||||
|
const stack = str(report.stack).slice(0, 4000);
|
||||||
|
const componentStack = str(report.componentStack).slice(0, 4000);
|
||||||
|
|
||||||
|
const parts: string[] = [`[Renderer:${kind}] ${message}`];
|
||||||
|
if (source) {
|
||||||
|
parts.push(`@ ${source}${line ? `:${line}${column ? `:${column}` : ""}` : ""}`);
|
||||||
|
}
|
||||||
|
if (stack) {
|
||||||
|
parts.push(`| stack: ${stack.replace(/\s*\n\s*/g, " ⏎ ")}`);
|
||||||
|
}
|
||||||
|
if (componentStack) {
|
||||||
|
parts.push(`| react: ${componentStack.replace(/\s*\n\s*/g, " ⏎ ")}`);
|
||||||
|
}
|
||||||
|
return parts.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
app.on("child-process-gone", (_event, details) => {
|
||||||
|
const killed = details.reason !== "clean-exit" && details.reason !== "killed";
|
||||||
|
const line = `Subprozess beendet: type=${details.type} reason=${details.reason} exitCode=${details.exitCode ?? "?"}${details.name ? ` name=${details.name}` : ""}${details.serviceName ? ` service=${details.serviceName}` : ""}`;
|
||||||
|
if (killed) {
|
||||||
|
logger.error(line);
|
||||||
|
} else {
|
||||||
|
logger.warn(line);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.on("second-instance", () => {
|
app.on("second-instance", () => {
|
||||||
if (mainWindow) {
|
if (mainWindow) {
|
||||||
if (mainWindow.isMinimized()) {
|
if (mainWindow.isMinimized()) {
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { APP_VERSION } from "./constants";
|
|||||||
import { getAuditLogPath } from "./audit-log";
|
import { getAuditLogPath } from "./audit-log";
|
||||||
import { getDebugSetupCheck } from "./debug-setup";
|
import { getDebugSetupCheck } from "./debug-setup";
|
||||||
import { getLogFilePath } from "./logger";
|
import { getLogFilePath } from "./logger";
|
||||||
|
import { getRecentErrors } from "./error-ring";
|
||||||
import { getPackageLogPath } from "./package-log";
|
import { getPackageLogPath } from "./package-log";
|
||||||
import { getRenameLogPath } from "./rename-log";
|
import { getRenameLogPath } from "./rename-log";
|
||||||
import { getDesktopRenameLogPath } from "./desktop-rename-log";
|
import { getDesktopRenameLogPath } from "./desktop-rename-log";
|
||||||
@ -169,6 +170,8 @@ export function buildSupportBundle(manager: DownloadManager, baseDir: string, op
|
|||||||
});
|
});
|
||||||
addJson(zip, "overview/host-diagnostics.json", resolveHostDiagnostics(hostDiagnosticsMode));
|
addJson(zip, "overview/host-diagnostics.json", resolveHostDiagnostics(hostDiagnosticsMode));
|
||||||
addJson(zip, "overview/trace-config.json", getTraceConfig());
|
addJson(zip, "overview/trace-config.json", getTraceConfig());
|
||||||
|
const recentErrors = getRecentErrors();
|
||||||
|
addJson(zip, "overview/recent-errors.json", { count: recentErrors.length, entries: recentErrors });
|
||||||
|
|
||||||
addFileIfExists(zip, path.join(baseDir, AI_MANIFEST_FILE), `runtime/${AI_MANIFEST_FILE}`);
|
addFileIfExists(zip, path.join(baseDir, AI_MANIFEST_FILE), `runtime/${AI_MANIFEST_FILE}`);
|
||||||
addFileIfExists(zip, path.join(baseDir, "debug_host.txt"), "runtime/debug_host.txt");
|
addFileIfExists(zip, path.join(baseDir, "debug_host.txt"), "runtime/debug_host.txt");
|
||||||
|
|||||||
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 };
|
||||||
|
}
|
||||||
@ -9,6 +9,7 @@ import {
|
|||||||
DuplicatePolicy,
|
DuplicatePolicy,
|
||||||
HistoryEntry,
|
HistoryEntry,
|
||||||
PackagePriority,
|
PackagePriority,
|
||||||
|
RendererErrorReport,
|
||||||
SessionStats,
|
SessionStats,
|
||||||
StartConflictEntry,
|
StartConflictEntry,
|
||||||
StartConflictResolutionResult,
|
StartConflictResolutionResult,
|
||||||
@ -88,6 +89,7 @@ const api: ElectronApi = {
|
|||||||
skipItems: (itemIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.SKIP_ITEMS, itemIds),
|
skipItems: (itemIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.SKIP_ITEMS, itemIds),
|
||||||
resetItems: (itemIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_ITEMS, itemIds),
|
resetItems: (itemIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_ITEMS, itemIds),
|
||||||
startItems: (itemIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.START_ITEMS, itemIds),
|
startItems: (itemIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.START_ITEMS, itemIds),
|
||||||
|
reportRendererError: (report: RendererErrorReport): void => ipcRenderer.send(IPC_CHANNELS.LOG_RENDERER_ERROR, report),
|
||||||
onStateUpdate: (callback: (snapshot: UiSnapshot) => void): (() => void) => {
|
onStateUpdate: (callback: (snapshot: UiSnapshot) => void): (() => void) => {
|
||||||
const listener = (_event: unknown, snapshot: UiSnapshot): void => callback(snapshot);
|
const listener = (_event: unknown, snapshot: UiSnapshot): void => callback(snapshot);
|
||||||
ipcRenderer.on(IPC_CHANNELS.STATE_UPDATE, listener);
|
ipcRenderer.on(IPC_CHANNELS.STATE_UPDATE, listener);
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
94
src/renderer/error-boundary.tsx
Normal file
94
src/renderer/error-boundary.tsx
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
interface ErrorBoundaryProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ErrorBoundaryState {
|
||||||
|
hasError: boolean;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Catches render-time errors in the component tree so a crash shows a minimal
|
||||||
|
// recovery surface instead of a silent white screen, and forwards the error to
|
||||||
|
// the main process log. Kept deliberately dead-simple and state-independent: an
|
||||||
|
// error inside the error path is how you get a second white screen or a loop.
|
||||||
|
export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||||
|
constructor(props: ErrorBoundaryProps) {
|
||||||
|
super(props);
|
||||||
|
this.state = { hasError: false, message: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: unknown): ErrorBoundaryState {
|
||||||
|
return { hasError: true, message: error instanceof Error ? error.message : String(error) };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: unknown, info: React.ErrorInfo): void {
|
||||||
|
try {
|
||||||
|
window.rd?.reportRendererError({
|
||||||
|
kind: "react",
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
componentStack: info?.componentStack || undefined
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleReload = (): void => {
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
render(): React.ReactNode {
|
||||||
|
if (!this.state.hasError) {
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
const overlay: React.CSSProperties = {
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: 16,
|
||||||
|
padding: 32,
|
||||||
|
background: "#070b14",
|
||||||
|
color: "#e6edf6",
|
||||||
|
fontFamily: "Segoe UI, system-ui, sans-serif",
|
||||||
|
textAlign: "center"
|
||||||
|
};
|
||||||
|
const pre: React.CSSProperties = {
|
||||||
|
maxWidth: 640,
|
||||||
|
maxHeight: 200,
|
||||||
|
overflow: "auto",
|
||||||
|
padding: 12,
|
||||||
|
background: "#0d1422",
|
||||||
|
border: "1px solid #243049",
|
||||||
|
borderRadius: 6,
|
||||||
|
color: "#ff9a8c",
|
||||||
|
fontSize: 12,
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
textAlign: "left"
|
||||||
|
};
|
||||||
|
const button: React.CSSProperties = {
|
||||||
|
padding: "8px 20px",
|
||||||
|
background: "#2d5cff",
|
||||||
|
color: "#fff",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: 6,
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: 14
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div style={overlay}>
|
||||||
|
<h1 style={{ margin: 0, fontSize: 20 }}>Die Oberfläche hat einen Fehler ausgelöst</h1>
|
||||||
|
<p style={{ margin: 0, maxWidth: 560, color: "#9aa7bd" }}>
|
||||||
|
Die Anzeige wurde gestoppt, um Datenverlust zu vermeiden. Die laufenden Downloads im
|
||||||
|
Hintergrund sind nicht betroffen. Der Fehler wurde ins Log geschrieben.
|
||||||
|
</p>
|
||||||
|
<pre style={pre}>{this.state.message}</pre>
|
||||||
|
<button type="button" style={button} onClick={this.handleReload}>Oberfläche neu laden</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,8 +1,39 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { App } from "./App";
|
import { App } from "./App";
|
||||||
|
import { ErrorBoundary } from "./error-boundary";
|
||||||
import "./styles.css";
|
import "./styles.css";
|
||||||
|
|
||||||
|
// Forward otherwise-silent renderer failures (uncaught errors, unhandled promise
|
||||||
|
// rejections) to the main process log. Without this, a renderer crash leaves no
|
||||||
|
// trace anywhere on an unattended server.
|
||||||
|
function reportRendererError(report: Parameters<typeof window.rd.reportRendererError>[0]): void {
|
||||||
|
try {
|
||||||
|
window.rd?.reportRendererError(report);
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("error", (event) => {
|
||||||
|
reportRendererError({
|
||||||
|
kind: "error",
|
||||||
|
message: event.message || String(event.error || "Unbekannter Fehler"),
|
||||||
|
stack: event.error instanceof Error ? event.error.stack : undefined,
|
||||||
|
source: event.filename || undefined,
|
||||||
|
line: typeof event.lineno === "number" ? event.lineno : undefined,
|
||||||
|
column: typeof event.colno === "number" ? event.colno : undefined
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("unhandledrejection", (event) => {
|
||||||
|
const reason = event.reason;
|
||||||
|
reportRendererError({
|
||||||
|
kind: "unhandledrejection",
|
||||||
|
message: reason instanceof Error ? reason.message : String(reason),
|
||||||
|
stack: reason instanceof Error ? reason.stack : undefined
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const rootElement = document.getElementById("root");
|
const rootElement = document.getElementById("root");
|
||||||
if (!rootElement) {
|
if (!rootElement) {
|
||||||
throw new Error("Root element fehlt");
|
throw new Error("Root element fehlt");
|
||||||
@ -10,6 +41,8 @@ if (!rootElement) {
|
|||||||
|
|
||||||
createRoot(rootElement).render(
|
createRoot(rootElement).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<ErrorBoundary>
|
||||||
|
<App />
|
||||||
|
</ErrorBoundary>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -66,5 +66,6 @@ export const IPC_CHANNELS = {
|
|||||||
SET_PACKAGE_PRIORITY: "queue:set-package-priority",
|
SET_PACKAGE_PRIORITY: "queue:set-package-priority",
|
||||||
SKIP_ITEMS: "queue:skip-items",
|
SKIP_ITEMS: "queue:skip-items",
|
||||||
RESET_ITEMS: "queue:reset-items",
|
RESET_ITEMS: "queue:reset-items",
|
||||||
START_ITEMS: "queue:start-items"
|
START_ITEMS: "queue:start-items",
|
||||||
|
LOG_RENDERER_ERROR: "log:renderer-error"
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import type {
|
|||||||
DuplicatePolicy,
|
DuplicatePolicy,
|
||||||
HistoryEntry,
|
HistoryEntry,
|
||||||
PackagePriority,
|
PackagePriority,
|
||||||
|
RendererErrorReport,
|
||||||
SessionStats,
|
SessionStats,
|
||||||
StartConflictEntry,
|
StartConflictEntry,
|
||||||
StartConflictResolutionResult,
|
StartConflictResolutionResult,
|
||||||
@ -85,6 +86,7 @@ export interface ElectronApi {
|
|||||||
skipItems: (itemIds: string[]) => Promise<void>;
|
skipItems: (itemIds: string[]) => Promise<void>;
|
||||||
resetItems: (itemIds: string[]) => Promise<void>;
|
resetItems: (itemIds: string[]) => Promise<void>;
|
||||||
startItems: (itemIds: string[]) => Promise<void>;
|
startItems: (itemIds: string[]) => Promise<void>;
|
||||||
|
reportRendererError: (report: RendererErrorReport) => void;
|
||||||
onStateUpdate: (callback: (snapshot: UiSnapshot) => void) => () => void;
|
onStateUpdate: (callback: (snapshot: UiSnapshot) => void) => () => void;
|
||||||
onClipboardDetected: (callback: (links: string[]) => void) => () => void;
|
onClipboardDetected: (callback: (links: string[]) => void) => () => void;
|
||||||
onUpdateInstallProgress: (callback: (progress: UpdateInstallProgress) => void) => () => void;
|
onUpdateInstallProgress: (callback: (progress: UpdateInstallProgress) => void) => () => void;
|
||||||
|
|||||||
@ -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;
|
||||||
@ -499,3 +501,13 @@ export interface HistoryState {
|
|||||||
entries: HistoryEntry[];
|
entries: HistoryEntry[];
|
||||||
maxEntries: number;
|
maxEntries: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RendererErrorReport {
|
||||||
|
kind: "error" | "unhandledrejection" | "react";
|
||||||
|
message: string;
|
||||||
|
stack?: string;
|
||||||
|
source?: string;
|
||||||
|
line?: number;
|
||||||
|
column?: number;
|
||||||
|
componentStack?: 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.
|
||||||
181
tasks/todo.md
181
tasks/todo.md
@ -1,109 +1,100 @@
|
|||||||
# Real-Debrid-Downloader — Analyse & Verbesserungen (2026-05-23)
|
# Real-Debrid-Downloader — Tasks (Stand 2026-06-07)
|
||||||
|
|
||||||
Tiefe Analyse via 3 parallele Subagents (Bugs / Features / UI) + 4 Design-Mockups.
|
**Status:** Alle zugesagten Features erledigt+released (Archiv unten). EIN Bug analysiert
|
||||||
|
+ geparkt (Mega-Web Account-3-Rotation, siehe direkt unten — wartet auf 1 Log-Zahl vom User).
|
||||||
|
Rest ist freiwilliger Backlog.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## A. BUGS / ROBUSTHEIT (verifiziert gegen Quellcode)
|
## 🟢 OFFEN — Backlog (optional, nie begonnen)
|
||||||
|
|
||||||
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.
|
### ✅ 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.
|
||||||
|
|
||||||
### HOCH
|
**(Ursprüngliche Analyse — Symptom & Mechanismus, zur Doku belassen)**
|
||||||
- [ ] **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).
|
**Symptom (User):** 3 Mega-Debrid-Web-Accounts aktiv, Rotation pendelt aber nur zwischen
|
||||||
- [ ] **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.
|
Account 1 ↔ 2 (bzw. nur Account 1), Account 3 (Su****xe) wird NIE probiert.
|
||||||
- [ ] **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
|
**Verifizierter Mechanismus (Code):**
|
||||||
- [ ] **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.
|
- Rotationsschleife `debrid.ts:1898`. Account 1 → "Mega-Web Antwort leer" → Cooldown 20s →
|
||||||
- [ ] **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.
|
weiter zu Account 2. Account 2 → `aborted:debrid`.
|
||||||
- [ ] **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).
|
- `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.
|
||||||
|
|
||||||
### NIEDRIG
|
**BESTÄTIGT 2026-06-08 (zweite Screenshots):** Account 1 läuft 10x rasch "erfolgreich"
|
||||||
- [ ] **N1 — Toter Code in `findReadyArchiveSets`** (download-manager.ts:10847). Unbedingtes `ready.add+continue` macht strengeren Disk-Fallback-Block (untracked-pending-Schutz) unerreichbar.
|
(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.
|
||||||
|
|
||||||
**Empfehlung:** H1 + H2 + M1 zusammen fixen (eine kohärente Härtung der Deferred-Pipeline). H3 ist klein & unabhängig. M2 trivial.
|
**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)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## B. FEATURES / UX-GAPS (nach Mehrwert/Aufwand)
|
## ✅ ERLEDIGT — Archiv (Details in git-History + Memory)
|
||||||
|
|
||||||
App läuft headless auf Windows-Server → Nutzer sitzt nicht davor. Größte Lücke: keine Benachrichtigungen.
|
- **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
|
||||||
|
|
||||||
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.**
|
### Bewusst NICHT angefasst (Crash-Debris / alte Experimente)
|
||||||
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.
|
- Gestashtes Crash-Debris `stash@{0}` (Revert von 08372f9/18eada9/98dc366 + log.old) — bei Bedarf recoverbar, sonst verwerfbar
|
||||||
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".
|
- Untracked `*-postprocess/` + `fix-library-renames.mjs` — alte Experimente (Apr/Mai)
|
||||||
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(),
|
||||||
|
|||||||
44
tests/error-ring.test.ts
Normal file
44
tests/error-ring.test.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { createErrorRing } from "../src/main/error-ring";
|
||||||
|
|
||||||
|
describe("createErrorRing", () => {
|
||||||
|
it("keeps entries in insertion order", () => {
|
||||||
|
const ring = createErrorRing(10);
|
||||||
|
ring.push({ ts: "t1", level: "ERROR", message: "a" });
|
||||||
|
ring.push({ ts: "t2", level: "WARN", message: "b" });
|
||||||
|
expect(ring.snapshot().map((e) => e.message)).toEqual(["a", "b"]);
|
||||||
|
expect(ring.size()).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps at capacity by dropping the oldest", () => {
|
||||||
|
const ring = createErrorRing(3);
|
||||||
|
for (const m of ["a", "b", "c", "d", "e"]) {
|
||||||
|
ring.push({ ts: m, level: "ERROR", message: m });
|
||||||
|
}
|
||||||
|
expect(ring.snapshot().map((e) => e.message)).toEqual(["c", "d", "e"]);
|
||||||
|
expect(ring.size()).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("snapshot returns a copy, not the live buffer", () => {
|
||||||
|
const ring = createErrorRing(5);
|
||||||
|
ring.push({ ts: "t", level: "WARN", message: "x" });
|
||||||
|
const snap = ring.snapshot();
|
||||||
|
snap.push({ ts: "t2", level: "ERROR", message: "injected" });
|
||||||
|
expect(ring.snapshot().map((e) => e.message)).toEqual(["x"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clear empties the ring", () => {
|
||||||
|
const ring = createErrorRing(5);
|
||||||
|
ring.push({ ts: "t", level: "ERROR", message: "x" });
|
||||||
|
ring.clear();
|
||||||
|
expect(ring.snapshot()).toEqual([]);
|
||||||
|
expect(ring.size()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("coerces a non-positive capacity to at least 1", () => {
|
||||||
|
const ring = createErrorRing(0);
|
||||||
|
ring.push({ ts: "t1", level: "ERROR", message: "a" });
|
||||||
|
ring.push({ ts: "t2", level: "ERROR", message: "b" });
|
||||||
|
expect(ring.snapshot().map((e) => e.message)).toEqual(["b"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
49
tests/fs-error.test.ts
Normal file
49
tests/fs-error.test.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { classifyDiskError } from "../src/main/fs-error";
|
||||||
|
import { isDebugFlagEnabled } from "../src/main/logger";
|
||||||
|
|
||||||
|
describe("classifyDiskError", () => {
|
||||||
|
it("maps ENOSPC from an error code to a disk-full reason", () => {
|
||||||
|
const err = Object.assign(new Error("write ENOSPC"), { code: "ENOSPC" });
|
||||||
|
expect(classifyDiskError(err)).toMatch(/Festplatte voll/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps EACCES from a code to a permission reason", () => {
|
||||||
|
const err = Object.assign(new Error("nope"), { code: "EACCES" });
|
||||||
|
expect(classifyDiskError(err)).toMatch(/Zugriff verweigert/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lower-case codes are normalized", () => {
|
||||||
|
const err = Object.assign(new Error("x"), { code: "enospc" });
|
||||||
|
expect(classifyDiskError(err)).toMatch(/ENOSPC/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to scanning the message text when no code is present", () => {
|
||||||
|
expect(classifyDiskError(new Error("operation failed: ENOSPC on volume"))).toMatch(/Festplatte voll/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles a plain string error", () => {
|
||||||
|
expect(classifyDiskError("EROFS: read-only file system")).toMatch(/schreibgeschützt/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for an unrelated error", () => {
|
||||||
|
expect(classifyDiskError(new Error("write_drain_timeout"))).toBeNull();
|
||||||
|
expect(classifyDiskError(new Error("premature close"))).toBeNull();
|
||||||
|
expect(classifyDiskError(null)).toBeNull();
|
||||||
|
expect(classifyDiskError(undefined)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isDebugFlagEnabled", () => {
|
||||||
|
it("is true for affirmative values", () => {
|
||||||
|
for (const v of ["1", "true", "TRUE", "yes", "on", " on "]) {
|
||||||
|
expect(isDebugFlagEnabled(v)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is false for empty/negative/garbage values", () => {
|
||||||
|
for (const v of [undefined, "", "0", "false", "off", "no", "maybe"]) {
|
||||||
|
expect(isDebugFlagEnabled(v)).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
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