Compare commits
No commits in common. "main" and "v1.7.184" have entirely different histories.
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "real-debrid-downloader",
|
||||
"version": "1.7.190",
|
||||
"version": "1.7.184",
|
||||
"description": "Desktop downloader",
|
||||
"main": "build/main/main/main.js",
|
||||
"author": "Sucukdeluxe",
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import path from "node:path";
|
||||
import v8 from "node:v8";
|
||||
import { app } from "electron";
|
||||
import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys";
|
||||
import {
|
||||
@ -85,7 +84,6 @@ export class AppController {
|
||||
|
||||
private autoResumePending = false;
|
||||
private runtimeStatsTimer: NodeJS.Timeout | null = null;
|
||||
private lastMemoryWarnAt = 0;
|
||||
|
||||
public constructor() {
|
||||
configureLogger(this.storagePaths.baseDir);
|
||||
@ -164,7 +162,6 @@ export class AppController {
|
||||
this.runtimeStatsTimer = setInterval(() => {
|
||||
this.manager.persistRuntimeStats();
|
||||
this.settings = this.manager.getSettings();
|
||||
this.checkMemoryPressure();
|
||||
}, 60_000);
|
||||
this.runtimeStatsTimer.unref?.();
|
||||
|
||||
@ -190,34 +187,6 @@ 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 {
|
||||
return Boolean(
|
||||
settings.token.trim()
|
||||
@ -303,27 +272,6 @@ export class AppController {
|
||||
return next;
|
||||
}
|
||||
|
||||
// Carry the live, runtime-maintained usage/status counters onto a settings
|
||||
// object about to be applied, so they are never rolled back to a stale snapshot.
|
||||
// All-time totals take the max; daily/total usage and account statuses are taken
|
||||
// live; per-key Debrid-Link usage is filtered to keys that still exist.
|
||||
private overlayLiveUsageCounters(target: AppSettings): void {
|
||||
const liveSettings = this.manager.getSettings();
|
||||
target.totalDownloadedAllTime = Math.max(target.totalDownloadedAllTime || 0, liveSettings.totalDownloadedAllTime || 0);
|
||||
target.totalCompletedFilesAllTime = Math.max(target.totalCompletedFilesAllTime || 0, liveSettings.totalCompletedFilesAllTime || 0);
|
||||
target.totalRuntimeAllTimeMs = Math.max(target.totalRuntimeAllTimeMs || 0, this.manager.getLiveTotalRuntimeMs());
|
||||
target.providerDailyUsageDay = liveSettings.providerDailyUsageDay;
|
||||
target.providerDailyUsageBytes = { ...(liveSettings.providerDailyUsageBytes || {}) };
|
||||
target.providerTotalUsageBytes = { ...(liveSettings.providerTotalUsageBytes || {}) };
|
||||
target.debridLinkApiKeyDailyUsageBytes = Object.fromEntries(
|
||||
Object.entries(liveSettings.debridLinkApiKeyDailyUsageBytes || {}).filter(([keyId]) => getDebridLinkApiKeyIds(target.debridLinkApiKeys).includes(keyId))
|
||||
);
|
||||
target.debridLinkApiKeyTotalUsageBytes = Object.fromEntries(
|
||||
Object.entries(liveSettings.debridLinkApiKeyTotalUsageBytes || {}).filter(([keyId]) => getDebridLinkApiKeyIds(target.debridLinkApiKeys).includes(keyId))
|
||||
);
|
||||
target.debridAccountStatuses = { ...(liveSettings.debridAccountStatuses || {}) };
|
||||
}
|
||||
|
||||
public updateSettings(partial: Partial<AppSettings>): AppSettings {
|
||||
const sanitizedPatch = sanitizeSettingsPatch(partial);
|
||||
const previousSettings = this.settings;
|
||||
@ -336,7 +284,20 @@ export class AppController {
|
||||
return previousSettings;
|
||||
}
|
||||
|
||||
this.overlayLiveUsageCounters(nextSettings);
|
||||
const liveSettings = this.manager.getSettings();
|
||||
nextSettings.totalDownloadedAllTime = Math.max(nextSettings.totalDownloadedAllTime || 0, liveSettings.totalDownloadedAllTime || 0);
|
||||
nextSettings.totalCompletedFilesAllTime = Math.max(nextSettings.totalCompletedFilesAllTime || 0, liveSettings.totalCompletedFilesAllTime || 0);
|
||||
nextSettings.totalRuntimeAllTimeMs = Math.max(nextSettings.totalRuntimeAllTimeMs || 0, this.manager.getLiveTotalRuntimeMs());
|
||||
nextSettings.providerDailyUsageDay = liveSettings.providerDailyUsageDay;
|
||||
nextSettings.providerDailyUsageBytes = { ...(liveSettings.providerDailyUsageBytes || {}) };
|
||||
nextSettings.providerTotalUsageBytes = { ...(liveSettings.providerTotalUsageBytes || {}) };
|
||||
nextSettings.debridLinkApiKeyDailyUsageBytes = Object.fromEntries(
|
||||
Object.entries(liveSettings.debridLinkApiKeyDailyUsageBytes || {}).filter(([keyId]) => getDebridLinkApiKeyIds(nextSettings.debridLinkApiKeys).includes(keyId))
|
||||
);
|
||||
nextSettings.debridLinkApiKeyTotalUsageBytes = Object.fromEntries(
|
||||
Object.entries(liveSettings.debridLinkApiKeyTotalUsageBytes || {}).filter(([keyId]) => getDebridLinkApiKeyIds(nextSettings.debridLinkApiKeys).includes(keyId))
|
||||
);
|
||||
nextSettings.debridAccountStatuses = { ...(liveSettings.debridAccountStatuses || {}) };
|
||||
const retentionChanged = previousSettings.historyRetentionMode !== nextSettings.historyRetentionMode;
|
||||
this.settings = nextSettings;
|
||||
if (retentionChanged) {
|
||||
@ -705,18 +666,14 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
|
||||
}
|
||||
}
|
||||
const restoredSettings = normalizeSettings(importedSettings);
|
||||
this.settings = restoredSettings;
|
||||
saveSettings(this.storagePaths, this.settings);
|
||||
this.manager.setSettings(this.settings);
|
||||
|
||||
// Settings-only backup: keep the running queue AND the live counters untouched.
|
||||
// Overlay the live usage/status counters so they don't roll back to the backup's
|
||||
// (older) snapshot (BUG I), and suppress the retroactive cleanup sweep so the
|
||||
// backup's cleanup policy can't purge the live completed queue here (BUG B) — the
|
||||
// policy still governs FUTURE completions through the normal path. Do NOT stop the
|
||||
// manager, wipe the session, block persistence or relaunch.
|
||||
// Settings-only backup: settings are already applied live (same path as the
|
||||
// normal updateSettings flow). Do NOT stop the manager, wipe the session,
|
||||
// block persistence or relaunch — the running queue stays untouched.
|
||||
if (!hasSession) {
|
||||
this.overlayLiveUsageCounters(restoredSettings);
|
||||
this.settings = restoredSettings;
|
||||
saveSettings(this.storagePaths, this.settings);
|
||||
this.manager.setSettings(this.settings, { suppressRetroactiveCleanup: true });
|
||||
this.audit("INFO", "Backup importiert (nur Einstellungen)", {
|
||||
accountSummary: buildAccountSummary(this.settings)
|
||||
});
|
||||
@ -727,10 +684,6 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
|
||||
};
|
||||
}
|
||||
|
||||
this.settings = restoredSettings;
|
||||
saveSettings(this.storagePaths, this.settings);
|
||||
this.manager.setSettings(this.settings);
|
||||
|
||||
this.manager.stop();
|
||||
this.manager.abortAllPostProcessing();
|
||||
this.manager.clearPersistTimer();
|
||||
|
||||
@ -72,8 +72,6 @@ export function defaultSettings(): AppSettings {
|
||||
packageName: "",
|
||||
autoExtract: true,
|
||||
autoRename4sf4sj: false,
|
||||
keepGermanAudioOnly: false,
|
||||
germanAudioMode: "tag",
|
||||
extractDir: path.join(baseDir, "_entpackt"),
|
||||
collectMkvToLibrary: false,
|
||||
mkvLibraryDir: path.join(baseDir, "_mkv"),
|
||||
|
||||
@ -283,16 +283,6 @@ const megaDebridAccountCooldowns = new Map<string, MegaDebridCooldownDetail>();
|
||||
const MEGA_DEBRID_ACCOUNT_COOLDOWN_MS = 120_000;
|
||||
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>();
|
||||
export const MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART = 3;
|
||||
|
||||
@ -1968,29 +1958,8 @@ class MegaDebridClient {
|
||||
sourceAccountLabel: account.label
|
||||
};
|
||||
} catch (error) {
|
||||
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);
|
||||
const elapsedMs = Date.now() - testStartedAt;
|
||||
failures.push(`Mega-Debrid${accountLabel}: ${failure.message}`);
|
||||
|
||||
let parkUntilRestart = false;
|
||||
|
||||
@ -6,7 +6,6 @@ import { APP_VERSION } from "./constants";
|
||||
import { getAuditLogPath } from "./audit-log";
|
||||
import { getDebugSetupCheck } from "./debug-setup";
|
||||
import { logger, getLogFilePath } from "./logger";
|
||||
import { getRecentErrors } from "./error-ring";
|
||||
import { getItemLogPath as getPersistedItemLogPath } from "./item-log";
|
||||
import { getSessionLogPath } from "./session-log";
|
||||
import { getPackageLogPath as getPersistedPackageLogPath } from "./package-log";
|
||||
@ -45,7 +44,6 @@ const DEBUG_ENDPOINTS: DebugEndpointDescriptor[] = [
|
||||
{ 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/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: "/settings", description: "Returns a redacted settings snapshot without raw secrets." },
|
||||
{ method: "GET", path: "/accounts", description: "Returns a redacted account/provider configuration summary." },
|
||||
@ -530,18 +528,6 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
|
||||
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") {
|
||||
const count = normalizeLinesParam(url.searchParams.get("lines"), 100);
|
||||
const grep = url.searchParams.get("grep") || "";
|
||||
|
||||
@ -53,8 +53,6 @@ import { planDownloadCompletion, validateDownloadedFileCompletion } from "./down
|
||||
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 { 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 { getRecentRotationEvents, runWithRotationItemSink, setRotationEventListener } from "./account-rotation-log";
|
||||
import type { RotationEvent } from "../shared/types";
|
||||
@ -2041,7 +2039,7 @@ export class DownloadManager extends EventEmitter {
|
||||
private logRenameProcess(
|
||||
pkg: PackageEntry,
|
||||
level: "INFO" | "WARN" | "ERROR",
|
||||
stage: "auto-rename" | "mkv-move" | "audio-strip",
|
||||
stage: "auto-rename" | "mkv-move",
|
||||
message: string,
|
||||
fields?: Record<string, unknown>,
|
||||
item?: DownloadItem | null,
|
||||
@ -2081,7 +2079,7 @@ export class DownloadManager extends EventEmitter {
|
||||
this.emitState();
|
||||
}
|
||||
|
||||
public setSettings(next: AppSettings, opts?: { suppressRetroactiveCleanup?: boolean }): void {
|
||||
public setSettings(next: AppSettings): void {
|
||||
const previous = this.settings;
|
||||
next.totalDownloadedAllTime = Math.max(next.totalDownloadedAllTime || 0, this.settings.totalDownloadedAllTime || 0);
|
||||
next.totalCompletedFilesAllTime = Math.max(next.totalCompletedFilesAllTime || 0, this.settings.totalCompletedFilesAllTime || 0);
|
||||
@ -2145,7 +2143,7 @@ export class DownloadManager extends EventEmitter {
|
||||
|
||||
this.resolveExistingQueuedOpaqueFilenames();
|
||||
void this.cleanupExistingExtractedArchives().catch((err) => logger.warn(`cleanupExistingExtractedArchives Fehler (setSettings): ${compactErrorText(err)}`));
|
||||
if (!opts?.suppressRetroactiveCleanup && next.completedCleanupPolicy !== "never") {
|
||||
if (next.completedCleanupPolicy !== "never") {
|
||||
this.applyRetroactiveCleanupPolicy();
|
||||
}
|
||||
this.emitState();
|
||||
@ -3546,11 +3544,6 @@ export class DownloadManager extends EventEmitter {
|
||||
if (!entry.isFile()) {
|
||||
continue;
|
||||
}
|
||||
// Never collect our own remux temp/orphan sidecars (~rd<token>.<ext>): a
|
||||
// partial file left by a crash mid-remux must not be swept into the library.
|
||||
if (entry.name.startsWith("~rd")) {
|
||||
continue;
|
||||
}
|
||||
const extension = path.extname(entry.name).toLowerCase();
|
||||
if (!normalizedExtensions.has(extension)) {
|
||||
continue;
|
||||
@ -3898,151 +3891,6 @@ 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(
|
||||
extractDir: string,
|
||||
pkg?: PackageEntry,
|
||||
@ -4662,12 +4510,7 @@ export class DownloadManager extends EventEmitter {
|
||||
if (!cleanBase) {
|
||||
return null;
|
||||
}
|
||||
// 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}`;
|
||||
const cleanFileName = `${cleanBase}${sourceExt}`;
|
||||
if (cleanFileName.toLowerCase() === path.basename(sourcePath).toLowerCase()) {
|
||||
return null;
|
||||
}
|
||||
@ -6112,11 +5955,6 @@ export class DownloadManager extends EventEmitter {
|
||||
}
|
||||
|
||||
private dropItemContribution(itemId: string): void {
|
||||
// NOTE: deliberately does NOT subtract from session.totalDownloadedBytes /
|
||||
// sessionDownloadedBytes. Those are cumulative-session counters and must stay
|
||||
// put when a completed item is removed from the queue (see the test "keeps
|
||||
// cumulative session totals when completed items are removed from the queue").
|
||||
// The retry path subtracts on its own because those bytes get re-downloaded.
|
||||
this.itemContributedBytes.delete(itemId);
|
||||
this.invalidateStatsCache();
|
||||
}
|
||||
@ -7161,9 +6999,6 @@ export class DownloadManager extends EventEmitter {
|
||||
const abortController = new AbortController();
|
||||
this.packagePostProcessAbortControllers.set(packageId, abortController);
|
||||
|
||||
// Holder so the task's own finally can identity-check itself (the task Promise
|
||||
// cannot reference its own const inside its initializer). Assigned right after.
|
||||
const handle: { task?: Promise<void> } = {};
|
||||
const task = (async () => {
|
||||
const slotWaitStart = nowMs();
|
||||
await this.acquirePostProcessSlot(packageId);
|
||||
@ -7210,16 +7045,8 @@ export class DownloadManager extends EventEmitter {
|
||||
} while (this.hybridExtractRequeue.has(packageId));
|
||||
} finally {
|
||||
this.releasePostProcessSlot();
|
||||
// Identity guard: only clear the map entries if they still point to THIS
|
||||
// task/controller. After an abort deletes our handle a new run can install
|
||||
// a fresh task+controller for the same packageId; a blind delete here would
|
||||
// orphan that newer task (uncancellable) and allow a duplicate concurrent run.
|
||||
if (this.packagePostProcessTasks.get(packageId) === handle.task) {
|
||||
this.packagePostProcessTasks.delete(packageId);
|
||||
}
|
||||
if (this.packagePostProcessAbortControllers.get(packageId) === abortController) {
|
||||
this.packagePostProcessAbortControllers.delete(packageId);
|
||||
}
|
||||
this.packagePostProcessTasks.delete(packageId);
|
||||
this.packagePostProcessAbortControllers.delete(packageId);
|
||||
this.persistSoon();
|
||||
this.emitState();
|
||||
if (this.hybridExtractRequeue.delete(packageId)) {
|
||||
@ -7230,7 +7057,6 @@ export class DownloadManager extends EventEmitter {
|
||||
}
|
||||
})();
|
||||
|
||||
handle.task = task;
|
||||
this.packagePostProcessTasks.set(packageId, task);
|
||||
return task;
|
||||
}
|
||||
@ -7249,7 +7075,6 @@ export class DownloadManager extends EventEmitter {
|
||||
return false;
|
||||
}
|
||||
return this.settings.autoRename4sf4sj
|
||||
|| this.settings.keepGermanAudioOnly
|
||||
|| this.settings.collectMkvToLibrary
|
||||
|| this.settings.removeLinkFilesAfterExtract
|
||||
|| this.settings.removeSamplesAfterExtract
|
||||
@ -8738,24 +8563,16 @@ export class DownloadManager extends EventEmitter {
|
||||
item.updatedAt = nowMs();
|
||||
this.emitState();
|
||||
|
||||
const integrityStartedAt = nowMs();
|
||||
const validation = await validateFileAgainstManifest(item.targetPath, pkg.outputDir);
|
||||
if (active.abortController.signal.aborted) {
|
||||
throw new Error(`aborted:${active.abortReason}`);
|
||||
}
|
||||
const integrityElapsedMs = nowMs() - integrityStartedAt;
|
||||
if (!validation.ok) {
|
||||
item.lastError = validation.message;
|
||||
item.fullStatus = `${validation.message}, Neuversuch`;
|
||||
this.logPackageForItem(item, "WARN", "Integritätsprüfung fehlgeschlagen", {
|
||||
result: validation.message,
|
||||
elapsedMs: integrityElapsedMs,
|
||||
willRetry: item.attempts < maxAttempts
|
||||
});
|
||||
try {
|
||||
fs.rmSync(item.targetPath, { force: true });
|
||||
} catch (rmErr) {
|
||||
logger.debug(`Integrity-Cleanup rm fehlgeschlagen (${item.fileName}): ${String(rmErr)}`);
|
||||
} catch {
|
||||
}
|
||||
if (item.attempts < maxAttempts) {
|
||||
item.status = "integrity_check";
|
||||
@ -8769,11 +8586,6 @@ export class DownloadManager extends EventEmitter {
|
||||
}
|
||||
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) {
|
||||
@ -10233,18 +10045,11 @@ export class DownloadManager extends EventEmitter {
|
||||
}
|
||||
lastError = compactErrorText(error);
|
||||
const normalizedLastError = lastError.replace(/^Error:\s*/i, "");
|
||||
const diskCause = classifyDiskError(error);
|
||||
logAttemptEvent("WARN", "HTTP-Download-Versuch fehlgeschlagen", {
|
||||
attempt,
|
||||
error: lastError,
|
||||
targetPath: effectiveTargetPath,
|
||||
...(diskCause ? { diskCause } : {})
|
||||
targetPath: effectiveTargetPath
|
||||
});
|
||||
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 (
|
||||
normalizedLastError.startsWith("range_ignored_on_resume:")
|
||||
|| normalizedLastError.startsWith("range_mismatch_on_resume:")
|
||||
@ -11116,7 +10921,6 @@ export class DownloadManager extends EventEmitter {
|
||||
try {
|
||||
await this.chainPackageFileOp(pkg.id, async () => {
|
||||
await this.autoRenameExtractedVideoFilesImpl(pkg.extractDir, pkg, hybridShouldAbort);
|
||||
await this.keepGermanAudioOnlyImpl(pkg.extractDir, pkg, hybridShouldAbort, hybridController.signal);
|
||||
await this.collectMkvFilesToLibrary(packageId, pkg, hybridShouldAbort, true);
|
||||
});
|
||||
} catch (err) {
|
||||
@ -11787,12 +11591,6 @@ export class DownloadManager extends EventEmitter {
|
||||
});
|
||||
throwIfAborted();
|
||||
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") {
|
||||
|
||||
@ -1,45 +0,0 @@
|
||||
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();
|
||||
}
|
||||
@ -2883,13 +2883,6 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
||||
const resumeCompletedAtStart = resumeCompleted.size;
|
||||
const allCandidateNames = new Set(allCandidates.map((archivePath) => archiveNameKey(path.basename(archivePath))));
|
||||
for (const archiveName of Array.from(resumeCompleted.values())) {
|
||||
// Nested-archive progress (keyed "nested:<name>") has no top-level candidate on
|
||||
// disk to validate against, so it must NOT be pruned here — otherwise every
|
||||
// extractPackageArchives call wiped it and nested archives were re-extracted on
|
||||
// resume. It is cleared together with the rest once the package fully completes.
|
||||
if (archiveName.startsWith("nested:")) {
|
||||
continue;
|
||||
}
|
||||
if (!allCandidateNames.has(archiveName)) {
|
||||
resumeCompleted.delete(archiveName);
|
||||
}
|
||||
|
||||
@ -1,56 +0,0 @@
|
||||
// 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,24 +1,7 @@
|
||||
import fs from "node:fs";
|
||||
import { logTimestamp } from "./log-timestamp";
|
||||
import { recordRecentError } from "./error-ring";
|
||||
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 fallbackLogFilePath: string | null = null;
|
||||
const LOG_FLUSH_INTERVAL_MS = 120;
|
||||
@ -183,14 +166,7 @@ async function flushAsync(): Promise<void> {
|
||||
}
|
||||
|
||||
flushInFlight = true;
|
||||
// Move (not copy) the pending lines out and take ownership. A concurrent write()
|
||||
// during the await below pushes new lines AND can trim the 1MB cap from the FRONT
|
||||
// of pendingLines; the old count-based removal (pendingLines.slice(snapshot.length))
|
||||
// then sliced off the wrong lines and dropped unwritten ones. Resetting the buffer
|
||||
// here means await-time writes queue independently and nothing desyncs.
|
||||
const linesSnapshot = pendingLines;
|
||||
pendingLines = [];
|
||||
pendingChars = 0;
|
||||
const linesSnapshot = pendingLines.slice();
|
||||
const chunk = linesSnapshot.join("");
|
||||
|
||||
try {
|
||||
@ -207,19 +183,9 @@ async function flushAsync(): Promise<void> {
|
||||
} else if (!primary.ok) {
|
||||
writeStderr(`LOGGER write failed: ${primary.errorText}\n`);
|
||||
}
|
||||
if (!wroteAny) {
|
||||
// Write failed: requeue the unwritten lines AHEAD of anything that arrived
|
||||
// during the await (preserve order), then re-apply the buffer cap so a
|
||||
// persistent write failure cannot grow the buffer without bound.
|
||||
pendingLines = linesSnapshot.concat(pendingLines);
|
||||
pendingChars += chunk.length;
|
||||
while (pendingChars > LOG_BUFFER_LIMIT_CHARS && pendingLines.length > 1) {
|
||||
const removed = pendingLines.shift();
|
||||
if (!removed) {
|
||||
break;
|
||||
}
|
||||
pendingChars = Math.max(0, pendingChars - removed.length);
|
||||
}
|
||||
if (wroteAny) {
|
||||
pendingLines = pendingLines.slice(linesSnapshot.length);
|
||||
pendingChars = Math.max(0, pendingChars - chunk.length);
|
||||
}
|
||||
} finally {
|
||||
flushInFlight = false;
|
||||
@ -238,19 +204,12 @@ function ensureExitHook(): void {
|
||||
process.once("exit", flushSyncPending);
|
||||
}
|
||||
|
||||
function write(level: "DEBUG" | "INFO" | "WARN" | "ERROR", message: string): void {
|
||||
function write(level: "INFO" | "WARN" | "ERROR", message: string): void {
|
||||
ensureExitHook();
|
||||
const ts = logTimestamp();
|
||||
const line = `${ts} [${level}] ${message}\n`;
|
||||
const line = `${logTimestamp()} [${level}] ${message}\n`;
|
||||
pendingLines.push(line);
|
||||
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) {
|
||||
try { listener(line); } catch { }
|
||||
}
|
||||
@ -271,9 +230,6 @@ function write(level: "DEBUG" | "INFO" | "WARN" | "ERROR", message: string): voi
|
||||
}
|
||||
|
||||
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),
|
||||
warn: (msg: string): void => write("WARN", msg),
|
||||
error: (msg: string): void => write("ERROR", msg)
|
||||
|
||||
@ -53,13 +53,7 @@ process.on("uncaughtException", (error) => {
|
||||
logger.error(`Uncaught Exception: ${String(error?.stack || error)}`);
|
||||
});
|
||||
process.on("unhandledRejection", (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, " ⏎ ")}` : ""}`);
|
||||
logger.error(`Unhandled Rejection: ${String(reason)}`);
|
||||
});
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
@ -116,23 +110,6 @@ function createWindow(): BrowserWindow {
|
||||
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 {
|
||||
window.on("close", (event) => {
|
||||
const settings = controller.getSettings();
|
||||
@ -147,33 +124,6 @@ function bindMainWindowLifecycle(window: BrowserWindow): void {
|
||||
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 {
|
||||
@ -726,14 +676,6 @@ function registerIpcHandlers(): void {
|
||||
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) => {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
return;
|
||||
@ -742,41 +684,6 @@ 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", () => {
|
||||
if (mainWindow) {
|
||||
if (mainWindow.isMinimized()) {
|
||||
|
||||
@ -421,8 +421,6 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
|
||||
packageName: asText(settings.packageName),
|
||||
autoExtract: Boolean(settings.autoExtract),
|
||||
autoRename4sf4sj: Boolean(settings.autoRename4sf4sj),
|
||||
keepGermanAudioOnly: Boolean(settings.keepGermanAudioOnly),
|
||||
germanAudioMode: settings.germanAudioMode === "first" ? "first" : "tag",
|
||||
extractDir: normalizeAbsoluteDir(settings.extractDir, defaults.extractDir),
|
||||
collectMkvToLibrary: Boolean(settings.collectMkvToLibrary),
|
||||
mkvLibraryDir: normalizeAbsoluteDir(settings.mkvLibraryDir, defaults.mkvLibraryDir),
|
||||
|
||||
@ -5,7 +5,6 @@ import { APP_VERSION } from "./constants";
|
||||
import { getAuditLogPath } from "./audit-log";
|
||||
import { getDebugSetupCheck } from "./debug-setup";
|
||||
import { getLogFilePath } from "./logger";
|
||||
import { getRecentErrors } from "./error-ring";
|
||||
import { getPackageLogPath } from "./package-log";
|
||||
import { getRenameLogPath } from "./rename-log";
|
||||
import { getDesktopRenameLogPath } from "./desktop-rename-log";
|
||||
@ -170,8 +169,6 @@ export function buildSupportBundle(manager: DownloadManager, baseDir: string, op
|
||||
});
|
||||
addJson(zip, "overview/host-diagnostics.json", resolveHostDiagnostics(hostDiagnosticsMode));
|
||||
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, "debug_host.txt"), "runtime/debug_host.txt");
|
||||
|
||||
@ -1,510 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
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;
|
||||
// Seam for the atomic-replace rename so its failure/recovery path is testable
|
||||
// without provoking a real OS file lock. Production uses renameWithRetry.
|
||||
rename?: (from: string, to: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const VIDEO_REMUX_EXTENSIONS = new Set([".mkv", ".mp4"]);
|
||||
const 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 token — the
|
||||
// ".DL." marker alone (present on every processed file) is not enough, and a bare
|
||||
// "dubbed" can mean an Italian/French dub, so it must NOT flag a German release.
|
||||
export function looksLikeGermanRelease(fileName: string): boolean {
|
||||
return /(^|[._\s-])(german|deutsch)([._\s-]|$)/i.test(fileName);
|
||||
}
|
||||
|
||||
function isGermanStream(stream: ProbedAudioStream): boolean {
|
||||
const lang = (stream.language || "").toLowerCase().trim();
|
||||
if (["ger", "deu", "de", "german", "deutsch"].includes(lang)) {
|
||||
return true;
|
||||
}
|
||||
// Free-text title fallback (used when the language tag is missing). Full words
|
||||
// only — the 2-3 letter codes ger/deu are too ambiguous in a title and would
|
||||
// pick the wrong track to keep (which then deletes the real German one).
|
||||
const title = (stream.title || "").toLowerCase();
|
||||
return /\b(german|deutsch)\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;
|
||||
}
|
||||
}
|
||||
|
||||
const RENAME_RETRY_DELAYS_MS = [200, 500, 1000];
|
||||
const RENAME_RETRYABLE_CODES = new Set(["EBUSY", "EACCES", "EPERM", "EEXIST"]);
|
||||
|
||||
function delayMs(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
// Windows file locks from antivirus, the search indexer, or a media scanner are
|
||||
// transient: a rename that hits EBUSY/EACCES/EPERM/EEXIST often succeeds a moment
|
||||
// later. Retry with backoff before giving up so a momentary lock doesn't abort
|
||||
// the atomic replace and leave the file unprocessed.
|
||||
export async function renameWithRetry(from: string, to: string): Promise<void> {
|
||||
for (let attempt = 0; ; attempt += 1) {
|
||||
try {
|
||||
await fs.promises.rename(from, to);
|
||||
return;
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException)?.code;
|
||||
if (!code || !RENAME_RETRYABLE_CODES.has(code) || attempt >= RENAME_RETRY_DELAYS_MS.length) {
|
||||
throw error;
|
||||
}
|
||||
await delayMs(RENAME_RETRY_DELAYS_MS[attempt]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Short, unique, same-directory sidecar name (never longer than the original file
|
||||
// name) so concurrent packages / retries never collide on a fixed temp name and a
|
||||
// long scene filename + suffix cannot push the path past Windows MAX_PATH.
|
||||
function uniqueTempPath(filePath: string): string {
|
||||
const ext = path.extname(filePath);
|
||||
const token = `${process.pid.toString(36)}${crypto.randomBytes(3).toString("hex")}`;
|
||||
return path.join(path.dirname(filePath), `~rd${token}${ext}`);
|
||||
}
|
||||
|
||||
export async function processVideoFile(filePath: string, opts: ProcessVideoOptions, deps: ProcessVideoDeps = {}): Promise<VideoProcessResult> {
|
||||
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 tempPath = uniqueTempPath(filePath);
|
||||
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 };
|
||||
}
|
||||
|
||||
const renameOp = deps.rename || renameWithRetry;
|
||||
try {
|
||||
// Atomic replace-over: libuv maps fs.rename to MoveFileEx(REPLACE_EXISTING) on
|
||||
// Windows and rename(2) on POSIX, both atomic on the same volume, so filePath
|
||||
// holds either the full original or the full remux at every instant. Retried
|
||||
// for transient locks. We must NEVER rm the original first (the old fallback
|
||||
// did): an rm-then-failed-rename left zero copies of the file on disk.
|
||||
await renameOp(tempPath, filePath);
|
||||
// Preserve original mtime so freshness gates (hybrid collect) don't skip it.
|
||||
await fs.promises.utimes(filePath, originalStat.atime, originalStat.mtime).catch(() => {});
|
||||
} catch (error) {
|
||||
// Replace failed -> the original is untouched at filePath. Drop the temp only.
|
||||
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,7 +9,6 @@ import {
|
||||
DuplicatePolicy,
|
||||
HistoryEntry,
|
||||
PackagePriority,
|
||||
RendererErrorReport,
|
||||
SessionStats,
|
||||
StartConflictEntry,
|
||||
StartConflictResolutionResult,
|
||||
@ -89,7 +88,6 @@ const api: ElectronApi = {
|
||||
skipItems: (itemIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.SKIP_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),
|
||||
reportRendererError: (report: RendererErrorReport): void => ipcRenderer.send(IPC_CHANNELS.LOG_RENDERER_ERROR, report),
|
||||
onStateUpdate: (callback: (snapshot: UiSnapshot) => void): (() => void) => {
|
||||
const listener = (_event: unknown, snapshot: UiSnapshot): void => callback(snapshot);
|
||||
ipcRenderer.on(IPC_CHANNELS.STATE_UPDATE, listener);
|
||||
|
||||
@ -844,7 +844,7 @@ const emptySnapshot = (): UiSnapshot => ({
|
||||
archivePasswordList: "",
|
||||
rememberToken: true, providerOrder: [], providerPrimary: "realdebrid", providerSecondary: "none",
|
||||
providerTertiary: "none", autoProviderFallback: true, outputDir: "", packageName: "",
|
||||
autoExtract: true, autoRename4sf4sj: false, keepGermanAudioOnly: false, germanAudioMode: "tag", extractDir: "", createExtractSubfolder: true, hybridExtract: true,
|
||||
autoExtract: true, autoRename4sf4sj: false, extractDir: "", createExtractSubfolder: true, hybridExtract: true,
|
||||
collectMkvToLibrary: false, mkvLibraryDir: "",
|
||||
cleanupMode: "none", extractConflictMode: "overwrite", removeLinkFilesAfterExtract: false,
|
||||
removeSamplesAfterExtract: false, enableIntegrityCheck: true, autoResumeOnStart: true,
|
||||
@ -1150,10 +1150,6 @@ function rotationEventText(ev: { event: string; cooldownSec?: number; next?: str
|
||||
return `fehlgeschlagen${cd}${nx}`;
|
||||
}
|
||||
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_DISABLED": return "übersprungen (deaktiviert)";
|
||||
case "SKIP_DAILY_LIMIT": return "übersprungen (Tageslimit erreicht)";
|
||||
@ -5376,11 +5372,6 @@ 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.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.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.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>
|
||||
|
||||
@ -1,94 +0,0 @@
|
||||
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,39 +1,8 @@
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
import { ErrorBoundary } from "./error-boundary";
|
||||
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");
|
||||
if (!rootElement) {
|
||||
throw new Error("Root element fehlt");
|
||||
@ -41,8 +10,6 @@ if (!rootElement) {
|
||||
|
||||
createRoot(rootElement).render(
|
||||
<React.StrictMode>
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
@ -66,6 +66,5 @@ export const IPC_CHANNELS = {
|
||||
SET_PACKAGE_PRIORITY: "queue:set-package-priority",
|
||||
SKIP_ITEMS: "queue:skip-items",
|
||||
RESET_ITEMS: "queue:reset-items",
|
||||
START_ITEMS: "queue:start-items",
|
||||
LOG_RENDERER_ERROR: "log:renderer-error"
|
||||
START_ITEMS: "queue:start-items"
|
||||
} as const;
|
||||
|
||||
@ -9,7 +9,6 @@ import type {
|
||||
DuplicatePolicy,
|
||||
HistoryEntry,
|
||||
PackagePriority,
|
||||
RendererErrorReport,
|
||||
SessionStats,
|
||||
StartConflictEntry,
|
||||
StartConflictResolutionResult,
|
||||
@ -86,7 +85,6 @@ export interface ElectronApi {
|
||||
skipItems: (itemIds: string[]) => Promise<void>;
|
||||
resetItems: (itemIds: string[]) => Promise<void>;
|
||||
startItems: (itemIds: string[]) => Promise<void>;
|
||||
reportRendererError: (report: RendererErrorReport) => void;
|
||||
onStateUpdate: (callback: (snapshot: UiSnapshot) => void) => () => void;
|
||||
onClipboardDetected: (callback: (links: string[]) => void) => () => void;
|
||||
onUpdateInstallProgress: (callback: (progress: UpdateInstallProgress) => void) => () => void;
|
||||
|
||||
@ -97,8 +97,6 @@ export interface AppSettings {
|
||||
packageName: string;
|
||||
autoExtract: boolean;
|
||||
autoRename4sf4sj: boolean;
|
||||
keepGermanAudioOnly: boolean;
|
||||
germanAudioMode: "tag" | "first";
|
||||
extractDir: string;
|
||||
collectMkvToLibrary: boolean;
|
||||
mkvLibraryDir: string;
|
||||
@ -501,13 +499,3 @@ export interface HistoryState {
|
||||
entries: HistoryEntry[];
|
||||
maxEntries: number;
|
||||
}
|
||||
|
||||
export interface RendererErrorReport {
|
||||
kind: "error" | "unhandledrejection" | "react";
|
||||
message: string;
|
||||
stack?: string;
|
||||
source?: string;
|
||||
line?: number;
|
||||
column?: number;
|
||||
componentStack?: string;
|
||||
}
|
||||
|
||||
@ -1,104 +0,0 @@
|
||||
# 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.
|
||||
237
tasks/todo.md
237
tasks/todo.md
@ -1,164 +1,109 @@
|
||||
# Real-Debrid-Downloader — Tasks (Stand 2026-06-08)
|
||||
# Real-Debrid-Downloader — Analyse & Verbesserungen (2026-05-23)
|
||||
|
||||
**Status:** Alle zugesagten Features erledigt+released (Archiv unten). Aktuell läuft ein
|
||||
**intensiver Bug-Audit** (User-Goal 2026-06-08, "schaue intensiv nach weiteren Bugs") —
|
||||
Fortschritt direkt unten.
|
||||
Tiefe Analyse via 3 parallele Subagents (Bugs / Features / UI) + 4 Design-Mockups.
|
||||
|
||||
---
|
||||
|
||||
## 🔴 LAUFEND — Bug-Audit 2026-06-08 (Multi-Agent find→verify, 18 bestätigt)
|
||||
## A. BUGS / ROBUSTHEIT (verifiziert gegen Quellcode)
|
||||
|
||||
Advisor-Triage: **A = einzige echte Daten-Verlust-Notlage** (zerstört echte Datei auf Platte)
|
||||
→ zuerst, ALLEINE Release. **B verifiziert demoted:** applyRetroactiveCleanupPolicy/
|
||||
removePackageFromSession löschen KEINE Platten-Dateien (nur Session/Queue-Einträge + ggf.
|
||||
History-Eintrag) → Queue-Integrität, nicht Daten-Verlust → in v1.7.190-Batch.
|
||||
Sequenz: Release 1 (v1.7.189) = **A allein**; Release 2 (v1.7.190) = B/I,C,D/E,F,G,H,J,L,M,N,O,P,Q.
|
||||
Ein Commit pro Fix, jeder einzeln verifiziert. **K übersprungen** (auto-rename-Reorder,
|
||||
schlechtestes Risiko/Nutzen, kann für diesen User gar nicht feuern).
|
||||
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.
|
||||
|
||||
### Release 1 — Daten-Verlust-Stopper (v1.7.189, A ALLEIN)
|
||||
- [x] **A** `video-processor.ts` atomic-replace zerstörte bei Windows-Lock BEIDE Kopien
|
||||
(rm(original) VOR bestätigtem Replace + outer-catch rm(temp) → 0 Kopien). **GEFIXT:**
|
||||
atomic replace-over + `renameWithRetry` (EBUSY/EACCES/EPERM/EEXIST, Backoff 200/500/1000ms),
|
||||
rm-first-Fallback entfernt, **unique** Temp-Name (`~rd<pid><rand>`, löst auch C-Kollision).
|
||||
Advisor bestätigt Ansatz besser als bak-dance (kein Missing-File-Window). 3 neue Tests
|
||||
(Recovery + Retry-Pfad), 41 video-processor-Tests grün, tsc=6 (Baseline). Commit 189af22.
|
||||
### HOCH
|
||||
- [ ] **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).
|
||||
- [ ] **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.
|
||||
- [ ] **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.
|
||||
|
||||
### Release 2 — v1.7.190 (GEFIXT + verifiziert, ein Commit pro Fix)
|
||||
- [x] **L+M** video-processor.ts zu weite Deutsch-Erkennung. isGermanStream Titel-Fallback nur
|
||||
ganze Wörter (ger/deu raus → konnten falsche Spur picken + echte dt. löschen); looksLikeGerman
|
||||
Release 'dubbed' raus (ital./franz. Dub triggerte German-first). 2 Negativtests. Commit 272a41a.
|
||||
- [x] **H** logger.ts flushAsync slice-snapshot korrumpiert bei 1MB-Cap-Trim während await →
|
||||
ungeschriebene Zeilen verloren. Move-snapshot (Buffer auf [] übernehmen) + Requeue bei
|
||||
Schreibfehler. Commit 4432fa2.
|
||||
- [x] **J+Q** download-manager. J: runPackagePostProcessing finally löschte Map-Eintrag ohne
|
||||
Identity-Guard → Abort+Neustart-Race riss neuen Task raus (Waise + Doppel-Lauf); jetzt nur
|
||||
löschen wenn Map noch auf DIESEN Task/Controller zeigt (handle-Objekt wegen TS2454). Q:
|
||||
collectFilesByExtensions filtert `~rd`-Temp-Präfix (crash-verwaiste Teil-Remuxe nie ins
|
||||
Library). Commit 3c33b98.
|
||||
- [x] **P** extractor.ts nested-Resume-Keys (`nested:<name>`) bei jedem extractPackageArchives
|
||||
gepurged → verschachtelte Archive beim Resume neu entpackt; `startsWith("nested:")` im Prune
|
||||
übersprungen. Commit 61a8304.
|
||||
- [x] **B/I** app-controller.ts importBackup settings-only purgte LIVE-Queue (Dateien blieben auf
|
||||
Platte) + rollte Usage-Zähler zurück. Fix: setSettings({suppressRetroactiveCleanup}) +
|
||||
overlayLiveUsageCounters (extrahiert+wiederverwendet, inkl. Key-Filter). Commit dc05b51.
|
||||
### MITTEL
|
||||
- [ ] **M1 — Deferred-Post-Extraction nicht in `packagePostProcessTasks`** (download-manager.ts:11974). Scheduler-Abschluss (8154) + finishRun (12310) sehen Deferred-Tasks nicht → Run-Ende/Summary feuert während noch Dateien verschoben werden, State-Reset mitten in FS-Arbeit.
|
||||
- [ ] **M2 — `blockAllPersistence` wird nach Backup-Import nie zurückgesetzt** (app-controller.ts:678). Weiterarbeiten ohne Neustart → `persistSoon` ist dauerhaft No-Op → bei hartem Crash alle Änderungen weg.
|
||||
- [ ] **M3 — `cancelPendingAsyncSaves` wartet nicht auf laufenden Async-Save** (storage.ts:1064). I/O-Overlap beim Import (Datenintegrität durch Generation-Guard geschützt, nur Robustheit).
|
||||
|
||||
### Verifiziert KEINE Bugs / bewusst NICHT angefasst (Advisor-Disziplin: erst belegen, dann ändern)
|
||||
- **G** dropItemContribution "subtrahiert Session-Totals nicht" → **KEIN Bug**: Test "keeps
|
||||
cumulative session totals when completed items are removed" kodifiziert die Absicht (Session-
|
||||
Zähler kumulativ, divergieren bewusst von der Item-Map; Retry-Pfad zieht ab, weil neu geladen
|
||||
wird). Fix-Versuch ließ den Test failen → revertiert, Klarstellungs-Kommentar gesetzt.
|
||||
- **N** stripDualLangFromFileName "Kollision" → **bereits geguarded**: existsAsync-Skip verhindert
|
||||
Überschreiben; Remux machte Inhalt eh deutsch-only; collect strippt `.DL.` downstream. Residual
|
||||
= generischer Rename-TOCTOU (in JEDEM Rename-Pfad), kein spezifischer Bug hier.
|
||||
- **D/E** abort-Klassifizierung über signal.reason statt Text → **deferred (Robustheit, kein
|
||||
Live-Bug auf User-Pfad)**. BELEGT: mega-web-fallback normalisiert JEDEN Abort (Timeout UND
|
||||
Cancel) zu `new Error("aborted:mega-web")` → aktueller Guard `/aborted/i && !/timeout/i` FEUERT
|
||||
→ v1.7.187-Cooldown LÄUFT auf dem Web-Pfad (User-Pfad). Einzige Imperfektion: Cancel >8s wird
|
||||
fälschlich gecooled (minor). Empirisch bestätigt: `AbortSignal.any([ac,timeout]).reason?.name===
|
||||
'TimeoutError'` (timeout) vs string/AbortError (cancel) — falls je gebaut: signal.aborted-gaten,
|
||||
reason.name nutzen, Text-Fallback behalten, reason-Test. Hoch-Risiko (kritischer Unrestrict-Pfad
|
||||
JEDES Downloads) → nicht für Robustheit anfassen. API-Pfad-Abort-Text nicht erschöpfend geprüft.
|
||||
- **E** "API 'cancel'-Pfad umgeht" → **nicht real**: kein `'cancel'`-throw im Code gefunden.
|
||||
- **O** classifyAccountFailure abort-Branch tot → **stehen lassen**: tot NUR wegen aktueller
|
||||
Text-Interception; ein signal.aborted-gated D/E würde ihn wiederbeleben. Kein Kosmetik-Churn.
|
||||
- **F** Mega-Web empty-streak Concurrency → **N-shaped, deferred**: Streak wird bei Erfolg (1956)
|
||||
+ Nicht-Limit-Fehler (2005) gecleart; "bis Neustart gesperrt" ist bewusste Tageslimit-Logik,
|
||||
Restart-cleared; Mega-Web single-flight → Concurrency greift nicht. Keine fühlbare Schädigung
|
||||
konstruierbar → keine Park-State-Maschinerie.
|
||||
- **C** → in A subsumiert (unique Temp-Name). **K** übersprungen (auto-rename-Reorder, Risiko≫Nutzen).
|
||||
### NIEDRIG
|
||||
- [ ] **N1 — Toter Code in `findReadyArchiveSets`** (download-manager.ts:10847). Unbedingtes `ready.add+continue` macht strengeren Disk-Fallback-Block (untracked-pending-Schutz) unerreichbar.
|
||||
|
||||
**Empfehlung:** H1 + H2 + M1 zusammen fixen (eine kohärente Härtung der Deferred-Pipeline). H3 ist klein & unabhängig. M2 trivial.
|
||||
|
||||
---
|
||||
|
||||
## 🟢 OFFEN — Backlog (optional, nie begonnen)
|
||||
## B. FEATURES / UX-GAPS (nach Mehrwert/Aufwand)
|
||||
|
||||
### ✅ 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.
|
||||
App läuft headless auf Windows-Server → Nutzer sitzt nicht davor. Größte Lücke: keine Benachrichtigungen.
|
||||
|
||||
**(Ursprüngliche Analyse — Symptom & Mechanismus, zur Doku belassen)**
|
||||
**Symptom (User):** 3 Mega-Debrid-Web-Accounts aktiv, Rotation pendelt aber nur zwischen
|
||||
Account 1 ↔ 2 (bzw. nur Account 1), Account 3 (Su****xe) wird NIE probiert.
|
||||
|
||||
**Verifizierter Mechanismus (Code):**
|
||||
- Rotationsschleife `debrid.ts:1898`. Account 1 → "Mega-Web Antwort leer" → Cooldown 20s →
|
||||
weiter zu Account 2. Account 2 → `aborted:debrid`.
|
||||
- `classifyAccountFailure` (`debrid.ts:2036`) stuft JEDEN Abbruch als **fatal** ein →
|
||||
`throw` (`debrid.ts:1991`) → Schleife bricht ab → **Account 3 nie erreicht.**
|
||||
- Account 2 bekommt beim Fatal-Abbruch **keinen Cooldown** (cooldownMs:0). Beim
|
||||
download-manager-Retry wird Account 1 (Cooldown) übersprungen, aber Account 2 (kein
|
||||
Cooldown) ERNEUT vor Account 3 probiert → bricht wieder ab → ewiges 1↔2.
|
||||
- Geteiltes 60s-Unrestrict-Timeout `download-manager.ts:8590` (`AbortSignal.any([taskAbort,
|
||||
timeout(60s)])`) gilt für die GANZE Rotation, nicht pro Account. Mega-Web pollt intern bis
|
||||
180s (`mega-web-fallback.ts:235` + Poll-Loop `:371`). Sobald das geteilte 60s feuert, bleibt
|
||||
das kombinierte Signal aborted → KEIN späterer Account kriegt im selben Pass eine echte Chance.
|
||||
|
||||
**BESTÄTIGT 2026-06-08 (zweite Screenshots):** Account 1 läuft 10x rasch "erfolgreich"
|
||||
(11:51:45–11:52:26), dann zwei "abgebrochen (aborted:debrid)" um 11:53:30 UND 11:54:30 —
|
||||
**exakt 60s auseinander** = das geteilte 60s-Unrestrict-Timeout feuert (kein User-Stop, der
|
||||
wiederholt sich nicht periodisch). Hier rotiert GAR NICHTS: Account 1 bricht ab → fatal →
|
||||
Rotation stoppt sofort bei idx=0 → Account 2 und 3 werden NIE probiert. Bug eindeutig
|
||||
bestätigt, elapsedMs nicht mehr nötig. Account 1 selbst ist gesund (10x ok) — Mega-Web hängt
|
||||
nur sporadisch (no-server-Poll) bis ins 60s-Timeout.
|
||||
|
||||
**Fix-Design (wenn bestätigt):** Pro-Account-Timeout-Budget, abgekoppelt vom geteilten Cap.
|
||||
debrid.ts braucht das **cancel-only** Signal getrennt vom Timeout (kombiniertes Signal kann
|
||||
beides nicht unterscheiden). Minimal-invasiv: optionaler `opts`-Param an `unrestrictLink`
|
||||
({cancelSignal, perAttemptTimeoutMs}) — nur die Mega-Rotation liest ihn, andere Provider
|
||||
unberührt (kombiniertes Signal bleibt). Pro Account: `AbortSignal.any([cancelSignal,
|
||||
AbortSignal.timeout(perAttemptMs)])`. Abbruch-Logik: cancelSignal aborted → echter Stop;
|
||||
eigenes Account-Timer gefeuert → non-fatal, Cooldown, weiter zum nächsten Account (inkl. 3).
|
||||
**Regressionstest ZUERST** (3 Accounts, 1+2 failen/aborten → assert Account 3 kriegt TEST).
|
||||
**Advisor-Gate** vor Eingriff (kritischer Unrestrict-Pfad, betrifft jeden Download).
|
||||
Hinweis: Grundursache der leeren Antworten = Mega-Debrid Server/IP-Thema — Fix macht Rotation
|
||||
nur FAIRER (alle Accounts drankommen), bringt aber keinen busy Server zum Antworten.
|
||||
|
||||
### Features / UX (nach ROI)
|
||||
App läuft headless auf Windows-Server → Nutzer sitzt nicht davor.
|
||||
|
||||
1. [ ] **Push-Benachrichtigungen** (Discord/Telegram/ntfy) — S–M. Paket fertig/Fehler/Quota/Provider-down aufs Handy. Neuer `notifier.ts`, Hooks an Completion-Punkten. **Höchster ROI.**
|
||||
2. [ ] **Fernsteuerung über Debug-Server** (POST-Endpunkte) — S–M. Server hat HTTP + Token-Auth, aber nur GET. POST `/control/add-links`, `/start`, `/stop`.
|
||||
3. [ ] **URL-Duplikat-Erkennung beim Hinzufügen** — S. History-`urls` existiert, wird nie geprüft → versehentliche Re-Downloads. Warnen: "3 Links bereits geladen".
|
||||
4. [ ] **Pre-Flight-Check + Bulk-Skip toter Links** — M. Vor Start Größe/Name/Online für ganze Queue, "alle offline überspringen".
|
||||
5. [ ] **Speicherplatz-Vorabprüfung vor Start** — S. Aktuell keine Free-Space-Prüfung für Downloads → Abbruch mitten drin bei voller Platte.
|
||||
6. [ ] **Konsolidierte Fehler-Ansicht** — M. Alle fehlgeschlagenen Items flach + Fehlertext + "alle erneut versuchen". (Daten dafür liegen jetzt teils in der Error-Ring aus v1.7.185.)
|
||||
7. [ ] **Per-Provider-Statistik** — M. Rohdaten (`providerTotalUsageBytes`) existieren, werden nicht dargestellt. Welches Abo lohnt sich?
|
||||
8. [ ] **Auto-Retry fehlgeschlagener Pakete nach Wartezeit** — S–M. Quota/Cooldown-Fails am nächsten Tag automatisch neu.
|
||||
9. [ ] **Plex/Jellyfin Library-Refresh nach MKV-Move** — S. Gleicher Hook wie #1.
|
||||
10. [ ] **Watch-Folder für DLC/Link-Auto-Import** — M.
|
||||
|
||||
### Design-Richtung (Entscheidung steht aus)
|
||||
4 Mockups in `design-mockups/` (index.html = Vergleich): **Aurora** (verfeinert dark, geringstes Risiko) · **Command** (Terminal/Ops, dicht) · **Vellum** (light editorial) · **Nebula** (neon).
|
||||
→ Richtung wählen. Siehe Memory: design-taste (Anti-KI-Look) + design-direction (Ember-Wärme, flach/ehrlich).
|
||||
|
||||
### Alte Audit-Items (2026-04-04, Status ggf. veraltet — VOR Fix gegen aktuellen Code verifizieren)
|
||||
- [ ] Debrid-Link `maxDataHost` kühlt ganzen Key ab statt nur den Host
|
||||
- [ ] Debrid-Link `fileNotAvailable` setzt Key auf "error" statt temporär
|
||||
- [ ] AllDebrid: kein per-host-Cooldown für erschöpfte Quotas
|
||||
- [ ] LinkSnappy: keine Auth-Dedup (parallele Requests rufen beide authenticate())
|
||||
- [ ] Extractor password-cache race (parallele Worker mutieren `packageLearnedPasswords`)
|
||||
- [ ] Hybrid race: 1 Datei/Staffel evtl. beim MKV-Move nicht umbenannt (NUR per-package fixen — Post-MKV-Move-Scan ist tabu, v1.7.107 revertiert)
|
||||
1. [ ] **Webhook/Push-Benachrichtigungen** (Discord/Telegram/ntfy) — S–M. Bei Paket fertig/Fehler/Quota/Provider-down aufs Handy. Neuer `notifier.ts`, Hooks an Completion-Punkten. **Höchster ROI.**
|
||||
2. [ ] **Fernsteuerung über bestehenden Debug-Server** (POST-Endpunkte) — S–M. Server hat schon HTTP + Token-Auth, aber nur GET. POST `/control/add-links`, `/start`, `/stop` → vom Handy steuern.
|
||||
3. [ ] **URL-Duplikat-Erkennung beim Hinzufügen** — S. History-`urls` existiert, wird aber nie geprüft → versehentliche Re-Downloads verschwenden Quota. Warnen: "3 Links bereits geladen".
|
||||
4. [ ] **Vereinheitlichter Pre-Flight-Check + Bulk-Skip toter Links** — M. Vor Start Größe/Name/Online für ganze Queue, "alle offline überspringen"-Button.
|
||||
5. [ ] **Speicherplatz-Vorabprüfung vor Start** — S. Aktuell keine Free-Space-Prüfung → Abbruch mitten im Download bei voller Platte.
|
||||
6. [ ] **Konsolidierte Fehler-Ansicht** — M. Alle fehlgeschlagenen Items flach + Fehlertext + "alle erneut versuchen".
|
||||
7. [ ] **Per-Provider-Statistik** — M. Rohdaten (`providerTotalUsageBytes`) existieren, werden nur nicht dargestellt. Welches Abo lohnt sich?
|
||||
8. [ ] **Auto-Retry fehlgeschlagener Pakete nach Wartezeit** — S–M. Quota/Cooldown-Fails am nächsten Tag automatisch neu versuchen.
|
||||
9. [ ] **Plex/Jellyfin Library-Refresh nach MKV-Move** — S. Neue Folgen sofort sichtbar. Gleicher Hook wie #1.
|
||||
10. [ ] **Watch-Folder für DLC/Link-Auto-Import** — M. Ordner überwachen → automatisch importieren+starten.
|
||||
|
||||
---
|
||||
|
||||
## ✅ ERLEDIGT — Archiv (Details in git-History + Memory)
|
||||
## C. DESIGN-MOCKUPS
|
||||
|
||||
- **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
|
||||
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)
|
||||
|
||||
### Bewusst NICHT angefasst (Crash-Debris / alte Experimente)
|
||||
- Gestashtes Crash-Debris `stash@{0}` (Revert von 08372f9/18eada9/98dc366 + log.old) — bei Bedarf recoverbar, sonst verwerfbar
|
||||
- Untracked `*-postprocess/` + `fix-library-renames.mjs` — alte Experimente (Apr/Mai)
|
||||
→ 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,7 +11,6 @@ afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
resetDebridLinkRuntimeStateForTests();
|
||||
resetMegaDebridRuntimeStateForTests();
|
||||
delete process.env.RD_MEGA_ABORT_MIN_RUN_MS;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@ -1642,77 +1641,6 @@ describe("debrid service", () => {
|
||||
expect(getMegaDebridAccountCooldownState(key)?.untilRestart).toBe(true);
|
||||
}, 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 () => {
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
|
||||
@ -1,44 +0,0 @@
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
@ -1,49 +0,0 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -1,150 +0,0 @@
|
||||
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
|
||||
});
|
||||
});
|
||||
@ -1,348 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
stripDualLangMarker,
|
||||
hasDualLangMarker,
|
||||
isRemuxableVideoFile,
|
||||
looksLikeGermanRelease,
|
||||
pickAudioTrack,
|
||||
parseFfprobeAudioStreams,
|
||||
buildFfprobeArgs,
|
||||
buildFfmpegRemuxArgs,
|
||||
computeRemuxTimeoutMs,
|
||||
processVideoFile,
|
||||
renameWithRetry,
|
||||
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 does NOT treat an ambiguous 3-letter title code as German (no false-positive pick)", () => {
|
||||
// Two untagged tracks whose titles are only "Ger"/"Deu" must not be mistaken
|
||||
// for a German track; with no real German signal this falls back to first.
|
||||
const d = pickAudioTrack([{ language: "", title: "Ger" }, { language: "", title: "Deu" }], "tag");
|
||||
expect(d).toMatchObject({ action: "remux", audioRelIndex: 0, reason: "fallback-first-untagged" });
|
||||
});
|
||||
|
||||
it("tag mode with single German -> single (no remux)", () => {
|
||||
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);
|
||||
});
|
||||
it("does not flag a non-German dub as a German release (bare 'Dubbed' is ambiguous)", () => {
|
||||
expect(looksLikeGermanRelease("Movie.2020.ITALIAN.Dubbed.DL.1080p.mkv")).toBe(false);
|
||||
expect(looksLikeGermanRelease("Movie.2020.FRENCH.DUBBED.DL.720p.mkv")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseFfprobeAudioStreams", () => {
|
||||
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" };
|
||||
};
|
||||
}
|
||||
|
||||
// Any sidecar the replace machinery may leave behind (unique "~rd…" temp names).
|
||||
function leftoverTemps(file: string): string[] {
|
||||
return fs.readdirSync(path.dirname(file)).filter((n) => n.startsWith("~rd"));
|
||||
}
|
||||
|
||||
const tooling = async (): Promise<{ ffmpeg: string; ffprobe: string }> => ({ ffmpeg: "ffmpeg", ffprobe: "ffprobe" });
|
||||
const 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(leftoverTemps(file)).toEqual([]); // unique 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(leftoverTemps(file)).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps the original intact and cleans the temp when the atomic replace rename fails (no zero-copy window)", async () => {
|
||||
// Simulate a Windows file lock that defeats the replace even after retries.
|
||||
// The original must survive: the old rm-then-rename fallback could leave the
|
||||
// file with NEITHER the original nor the remux on disk.
|
||||
const file = makeFile("ORIGINAL");
|
||||
const result = await processVideoFile(file, { mode: "tag" }, {
|
||||
resolveTooling: tooling,
|
||||
runProcess: fakeRunner({ probeJson: twoTracksGerSecond }),
|
||||
rename: async () => { throw Object.assign(new Error("locked"), { code: "EBUSY" }); }
|
||||
});
|
||||
|
||||
expect(result.action).toBe("error");
|
||||
expect(fs.readFileSync(file, "utf8")).toBe("ORIGINAL"); // original never destroyed
|
||||
expect(leftoverTemps(file)).toEqual([]); // remux temp removed
|
||||
});
|
||||
|
||||
it("does not touch a single-audio file (no remux)", async () => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
describe("renameWithRetry", () => {
|
||||
afterEach(() => { vi.restoreAllMocks(); });
|
||||
const busy = (): NodeJS.ErrnoException => Object.assign(new Error("locked"), { code: "EBUSY" });
|
||||
|
||||
it("retries a transient EBUSY and then succeeds", async () => {
|
||||
let calls = 0;
|
||||
vi.spyOn(fs.promises, "rename").mockImplementation(async () => {
|
||||
calls += 1;
|
||||
if (calls <= 2) { throw busy(); }
|
||||
});
|
||||
await expect(renameWithRetry("a", "b")).resolves.toBeUndefined();
|
||||
expect(calls).toBe(3); // failed twice, succeeded on the third attempt
|
||||
});
|
||||
|
||||
it("gives up after exhausting retries on a persistent lock", async () => {
|
||||
let calls = 0;
|
||||
vi.spyOn(fs.promises, "rename").mockImplementation(async () => { calls += 1; throw busy(); });
|
||||
await expect(renameWithRetry("a", "b")).rejects.toThrow("locked");
|
||||
expect(calls).toBe(4); // initial attempt + 3 backoff retries
|
||||
});
|
||||
|
||||
it("does not retry a non-retryable error (e.g. EXDEV) — fails fast", async () => {
|
||||
let calls = 0;
|
||||
vi.spyOn(fs.promises, "rename").mockImplementation(async () => {
|
||||
calls += 1;
|
||||
throw Object.assign(new Error("cross-device"), { code: "EXDEV" });
|
||||
});
|
||||
await expect(renameWithRetry("a", "b")).rejects.toThrow("cross-device");
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user