Compare commits

..

17 Commits

Author SHA1 Message Date
Sucukdeluxe
77c937888a Release v1.7.190 2026-06-08 23:05:25 +02:00
Sucukdeluxe
fbbc960d9d docs(tasks): Bug-Audit Batch 2 abgeschlossen — 5 Fixes (L/M,H,J/Q,P,B/I) + verifizierte Nicht-Bugs (G,N,D/E,E,O,F) 2026-06-08 23:04:28 +02:00
Sucukdeluxe
dc05b51083 Fix: Settings-only-Backup-Import wischte Live-Queue + Zaehler (B/I)
importBackup wendete die Settings fuer beide Pfade ueber setSettings an, das bei
nicht-"never"-CleanupPolicy applyRetroactiveCleanupPolicy ausloest. Beim reinen
Settings-Restore purgte das die LIVE-Queue (fertige Items), obwohl der Vertrag
"running queue stays untouched" lautet (Dateien blieben auf Platte). Zudem rollte
der Import die laufenden Usage-/Status-Zaehler auf den (aelteren) Backup-Stand
zurueck (anders als updateSettings).

- setSettings bekommt optionales { suppressRetroactiveCleanup }; der Settings-only
  Import setzt es. Die importierte Policy gilt weiter fuer KUENFTIGE Completions
  ueber den normalen Vorwaertspfad (immediate/package_done) — nur der retroaktive
  Sweep wird hier unterdrueckt.
- overlayLiveUsageCounters aus updateSettings extrahiert und im Settings-only Import
  wiederverwendet (inkl. Key-Filter der Debrid-Link-Per-Key-Usage auf existierende
  Keys). Nicht ueber updateSettings geroutet (vermeidet dessen resetHistoryForRetention).
2026-06-08 22:51:16 +02:00
Sucukdeluxe
61a830475b Fix: verschachtelte Entpack-Fortschritte wurden bei jedem Lauf verworfen
Der Resume-Prune validiert Eintraege gegen die Top-Level-Archiv-Kandidaten auf
der Platte. Nested-Archiv-Schluessel (nested:<name>) haben dort kein Gegenstueck,
also wurden sie bei JEDEM extractPackageArchives-Aufruf geloescht — verschachtelte
Archive wurden beim Resume erneut entpackt. nested:-Schluessel werden im Prune
jetzt uebersprungen (sie werden mit dem Rest geleert, wenn das Paket fertig ist).
2026-06-08 22:47:34 +02:00
Sucukdeluxe
3c33b988c3 Fix: Post-Process-Identity-Guard (J) + Remux-Temp nie ins Library sammeln (Q)
J: runPackagePostProcessing loescht im finally die Map-Eintraege fuer das Paket.
Hatte ein Abort den Handle schon entfernt und ein neuer Lauf einen frischen
Task+Controller gesetzt, riss das spaete finall des alten Tasks diesen neuen
Eintrag mit raus -> nicht abbrechbarer Waisen-Task + doppeltes paralleles
Post-Processing. Jetzt nur loeschen wenn Map noch auf DIESEN Task/Controller zeigt.

Q: collectFilesByExtensions filtert jetzt ~rd-Praefix (unsere Remux-Temp/Orphan-
Sidecars) aus, damit eine bei einem Crash mitten im Remux liegengebliebene
Teil-Datei nie in die MKV-Library gesammelt wird.

(dropItemContribution: Kommentar ergaenzt, dass das Nicht-Abziehen der
Session-Totals Absicht ist — kumulative Session-Zaehler, per Test abgesichert.)
2026-06-08 22:46:14 +02:00
Sucukdeluxe
4432fa25e8 Fix: Logger-Flush konnte ungeschriebene Zeilen verlieren (Race mit 1MB-Cap)
flushAsync nahm eine Kopie der pending-Zeilen und entfernte sie nach dem await
per Index-Zaehlung (slice(snapshot.length)). Feuerte waehrend des awaits ein
write() den 1MB-Buffer-Cap, der vorne Zeilen wegshiftet, war die Zaehlung
desynchron und verwarf neu hinzugekommene, noch nicht geschriebene Zeilen.
Jetzt: pending-Zeilen per Move uebernehmen (Buffer auf [] zuruecksetzen) statt
kopieren; await-Zeit-writes laufen in einen frischen Buffer. Bei Schreibfehler
werden die Zeilen wieder vorn eingereiht und der Cap erneut angewandt.
2026-06-08 22:33:11 +02:00
Sucukdeluxe
272a41a4a7 Fix: zu weite Deutsch-Erkennung konnte falsche Tonspur behalten
- isGermanStream: Titel-Fallback nur noch ganze Woerter (german/deutsch); die
  2-3-Buchstaben-Codes ger/deu sind im freien Titel-Text mehrdeutig und konnten
  die falsche Spur als "deutsch" picken (und damit die echte deutsche loeschen).
  Der Sprach-Tag-Check (ger/deu/de) bleibt unveraendert.
- looksLikeGermanRelease: 'dubbed' entfernt — ein nacktes "Dubbed" kann ein
  italienischer/franzoesischer Dub sein und darf den German-first-Fallback nicht
  ausloesen. Explizite german/deutsch-Tokens reichen.
- 2 Negativtests (3-Letter-Titel-Code, nicht-deutscher Dub).
2026-06-08 22:31:34 +02:00
Sucukdeluxe
b71866c3dc Release v1.7.189 2026-06-08 22:23:07 +02:00
Sucukdeluxe
b200b4e5b1 docs(tasks): Bug-Audit Sequenz — B verifiziert demoted (keine Platten-Loeschung), A allein v1.7.189 2026-06-08 22:19:07 +02:00
Sucukdeluxe
189af2242f Fix: Tonspur-Remux konnte bei Windows-Datei-Lock Original UND Remux verlieren
Der atomare Ersetzen-Schritt loeschte das Original bevor der Ersatz bestaetigt
war; schlug das anschliessende Rename fehl (z.B. AV/Indexer-Lock), raeumte der
aeussere catch zusaetzlich die Temp-Datei weg -> null Kopien auf der Platte.

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

- debrid.ts: handle the abort in the rotation catch before classifyAccountFailure
- rotation log event TIMEOUT_COOLDOWN (+ renderer label) replaces the misleading
  red "fataler Fehler" for this case
- RD_MEGA_ABORT_MIN_RUN_MS env override for the run-length threshold
- 2 regression tests (cooldown set -> next call rotates; quick abort -> no cooldown)
2026-06-08 13:33:49 +02:00
Sucukdeluxe
92a36e2e47 Release v1.7.186 2026-06-07 21:18:43 +02:00
Sucukdeluxe
77661389f3 Add "keep only German audio" post-extract step for .DL. files
- New video-processor.ts: ffmpeg/ffprobe remux that keeps only the German
  audio track (by language tag, with safe fallbacks) and strips the ".DL."
  marker from the filename
- Runs after extraction in both the deferred and hybrid post-process paths,
  inside the per-package file-op chain; abortable, disk-space checked,
  mtime-preserving, atomic temp->replace so the original is never lost
- System ffmpeg via PATH / RD_FFMPEG_BIN; toggle + track-mode select in settings
2026-06-07 21:17:26 +02:00
Sucukdeluxe
397e667af2 chore: tidy tasks/todo.md (archive completed work, keep open backlog) 2026-06-07 20:17:26 +02:00
16 changed files with 1630 additions and 174 deletions

View File

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

View File

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

View File

@ -72,6 +72,8 @@ export function defaultSettings(): AppSettings {
packageName: "", packageName: "",
autoExtract: true, autoExtract: true,
autoRename4sf4sj: false, autoRename4sf4sj: false,
keepGermanAudioOnly: false,
germanAudioMode: "tag",
extractDir: path.join(baseDir, "_entpackt"), extractDir: path.join(baseDir, "_entpackt"),
collectMkvToLibrary: false, collectMkvToLibrary: false,
mkvLibraryDir: path.join(baseDir, "_mkv"), mkvLibraryDir: path.join(baseDir, "_mkv"),

View File

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

View File

@ -54,6 +54,7 @@ import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, DebridService, Meg
import { cleanupArchives, clearExtractResumeState, collectArchiveCleanupTargets, detectArchiveSignature, extractPackageArchives, findArchiveCandidates, hasAnyFilesRecursive, removeEmptyDirectoryTree, resetExtractorCachesForPasswordChange, type ExtractArchiveFailureInfo } from "./extractor"; import { cleanupArchives, clearExtractResumeState, collectArchiveCleanupTargets, detectArchiveSignature, extractPackageArchives, findArchiveCandidates, hasAnyFilesRecursive, removeEmptyDirectoryTree, resetExtractorCachesForPasswordChange, type ExtractArchiveFailureInfo } from "./extractor";
import { validateFileAgainstManifest } from "./integrity"; import { validateFileAgainstManifest } from "./integrity";
import { classifyDiskError } from "./fs-error"; import { classifyDiskError } from "./fs-error";
import { processVideoFile, resolveVideoTooling, stripDualLangMarker, hasDualLangMarker, isRemuxableVideoFile, type GermanAudioMode, type VideoProcessResult } from "./video-processor";
import { logger } from "./logger"; import { logger } from "./logger";
import { getRecentRotationEvents, runWithRotationItemSink, setRotationEventListener } from "./account-rotation-log"; import { getRecentRotationEvents, runWithRotationItemSink, setRotationEventListener } from "./account-rotation-log";
import type { RotationEvent } from "../shared/types"; import type { RotationEvent } from "../shared/types";
@ -2040,7 +2041,7 @@ export class DownloadManager extends EventEmitter {
private logRenameProcess( private logRenameProcess(
pkg: PackageEntry, pkg: PackageEntry,
level: "INFO" | "WARN" | "ERROR", level: "INFO" | "WARN" | "ERROR",
stage: "auto-rename" | "mkv-move", stage: "auto-rename" | "mkv-move" | "audio-strip",
message: string, message: string,
fields?: Record<string, unknown>, fields?: Record<string, unknown>,
item?: DownloadItem | null, item?: DownloadItem | null,
@ -2080,7 +2081,7 @@ export class DownloadManager extends EventEmitter {
this.emitState(); this.emitState();
} }
public setSettings(next: AppSettings): void { public setSettings(next: AppSettings, opts?: { suppressRetroactiveCleanup?: boolean }): void {
const previous = this.settings; const previous = this.settings;
next.totalDownloadedAllTime = Math.max(next.totalDownloadedAllTime || 0, this.settings.totalDownloadedAllTime || 0); next.totalDownloadedAllTime = Math.max(next.totalDownloadedAllTime || 0, this.settings.totalDownloadedAllTime || 0);
next.totalCompletedFilesAllTime = Math.max(next.totalCompletedFilesAllTime || 0, this.settings.totalCompletedFilesAllTime || 0); next.totalCompletedFilesAllTime = Math.max(next.totalCompletedFilesAllTime || 0, this.settings.totalCompletedFilesAllTime || 0);
@ -2144,7 +2145,7 @@ export class DownloadManager extends EventEmitter {
this.resolveExistingQueuedOpaqueFilenames(); this.resolveExistingQueuedOpaqueFilenames();
void this.cleanupExistingExtractedArchives().catch((err) => logger.warn(`cleanupExistingExtractedArchives Fehler (setSettings): ${compactErrorText(err)}`)); void this.cleanupExistingExtractedArchives().catch((err) => logger.warn(`cleanupExistingExtractedArchives Fehler (setSettings): ${compactErrorText(err)}`));
if (next.completedCleanupPolicy !== "never") { if (!opts?.suppressRetroactiveCleanup && next.completedCleanupPolicy !== "never") {
this.applyRetroactiveCleanupPolicy(); this.applyRetroactiveCleanupPolicy();
} }
this.emitState(); this.emitState();
@ -3545,6 +3546,11 @@ export class DownloadManager extends EventEmitter {
if (!entry.isFile()) { if (!entry.isFile()) {
continue; continue;
} }
// Never collect our own remux temp/orphan sidecars (~rd<token>.<ext>): a
// partial file left by a crash mid-remux must not be swept into the library.
if (entry.name.startsWith("~rd")) {
continue;
}
const extension = path.extname(entry.name).toLowerCase(); const extension = path.extname(entry.name).toLowerCase();
if (!normalizedExtensions.has(extension)) { if (!normalizedExtensions.has(extension)) {
continue; continue;
@ -3892,6 +3898,151 @@ export class DownloadManager extends EventEmitter {
); );
} }
private async keepGermanAudioOnly(
extractDir: string,
pkg?: PackageEntry,
shouldAbort?: () => boolean,
signal?: AbortSignal
): Promise<number> {
if (!pkg) {
return this.keepGermanAudioOnlyImpl(extractDir, undefined, shouldAbort, signal);
}
return this.chainPackageFileOp(pkg.id, () =>
this.keepGermanAudioOnlyImpl(extractDir, pkg, shouldAbort, signal)
);
}
// Post-extract step: for ".DL." (Dual-Language) MKV/MP4 files, keep only the
// German audio track and strip the ".DL." marker from the filename. Operates
// only inside pkg.extractDir, before MKV-collect. Best-effort per file; an
// error never fails the package. Original is never lost (see video-processor).
private async keepGermanAudioOnlyImpl(
extractDir: string,
pkg?: PackageEntry,
shouldAbort?: () => boolean,
signal?: AbortSignal
): Promise<number> {
if (!this.settings.keepGermanAudioOnly) {
return 0;
}
const mkvLibraryDir = String(this.settings.mkvLibraryDir || "").trim();
if (mkvLibraryDir && (isPathInsideDir(mkvLibraryDir, extractDir) || isPathInsideDir(extractDir, mkvLibraryDir))) {
logger.warn(`Tonspur-Bereinigung ABGEBROCHEN: extractDir=${extractDir} ueberlappt mit mkvLibraryDir=${mkvLibraryDir}`);
return 0;
}
const videoFiles = await this.collectVideoFiles(extractDir);
const sampleTokenRe = /(^|[._\-\s])sample([._\-\s]|$)/i;
const targets = videoFiles.filter((p) => {
const name = path.basename(p);
if (!isRemuxableVideoFile(name) || !hasDualLangMarker(name)) {
return false;
}
if (sampleTokenRe.test(name) || ["sample", "samples"].includes(path.basename(path.dirname(p)).toLowerCase())) {
return false;
}
return true;
});
if (targets.length === 0) {
return 0;
}
logger.info(`Tonspur-Bereinigung: ${targets.length} .DL.-Datei(en) in ${extractDir}, Modus=${this.settings.germanAudioMode}`);
if (pkg) {
this.logRenameProcess(pkg, "INFO", "audio-strip", "Tonspur-Bereinigung gestartet", { extractDir, candidates: targets.length, mode: this.settings.germanAudioMode });
}
// Resolve ffmpeg/ffprobe ONCE up front and log it loudly — a missing tool is
// the single most common reason the whole step silently does nothing.
const tooling = await resolveVideoTooling();
if (!tooling) {
logger.warn(`Tonspur-Bereinigung: ffmpeg/ffprobe NICHT gefunden — Schritt uebersprungen, ${targets.length} Datei(en) unangetastet. ffmpeg in den PATH legen oder RD_FFMPEG_BIN/RD_FFPROBE_BIN setzen.`);
if (pkg) {
this.logRenameProcess(pkg, "WARN", "audio-strip", "Tonspur-Bereinigung uebersprungen: ffmpeg/ffprobe nicht gefunden", { candidates: targets.length });
}
return 0;
}
logger.info(`Tonspur-Bereinigung: ffmpeg=${tooling.ffmpeg} ffprobe=${tooling.ffprobe}`);
const mode: GermanAudioMode = this.settings.germanAudioMode === "first" ? "first" : "tag";
let processed = 0;
let failed = 0;
for (const sourcePath of targets) {
if (shouldAbort?.() || signal?.aborted) {
return processed;
}
const sourceName = path.basename(sourcePath);
let result: VideoProcessResult;
try {
result = await processVideoFile(sourcePath, { mode, cpuPriority: this.settings.extractCpuPriority, signal });
} catch (error) {
result = { action: "error", reason: "exception", error: compactErrorText(error) };
}
if (result.action === "aborted") {
return processed;
}
const langs = (result.audioLanguages || []).join(",");
if (pkg) {
const level = result.action === "error" ? "WARN" : "INFO";
const resolved = this.inferItemForMediaLog(pkg, sourcePath, sourceName);
this.logRenameProcess(pkg, level, "audio-strip", `Tonspur: ${result.action} (${result.reason})`, {
sourceName,
keptTrack: result.keptTrackIndex,
audioTracks: result.totalAudioTracks,
languages: langs || undefined,
...(result.error ? { error: result.error } : {})
}, resolved.item, resolved.matchedBy);
}
// Per-file main-log lines so the cause of any unprocessed file is visible
// without opening the rename/item logs.
if (result.action === "error") {
failed += 1;
logger.warn(`Tonspur-Bereinigung FEHLER: ${sourceName}${result.reason}${result.error ? `${result.error}` : ""} (Spuren: ${langs || "?"}, ${result.totalAudioTracks ?? "?"} Audio)`);
} else if (result.action === "remuxed") {
processed += 1;
logger.info(`Tonspur-Bereinigung OK: ${sourceName} — Spur ${result.keptTrackIndex} behalten (${langs || "?"})`);
} else if (result.action === "skipped-no-german") {
logger.info(`Tonspur-Bereinigung uebersprungen (kein Deutsch-Tag, Spuren: ${langs || "?"}): ${sourceName}`);
} else if (result.action === "skipped-no-space") {
logger.warn(`Tonspur-Bereinigung uebersprungen (zu wenig Speicher): ${sourceName}`);
}
// Only strip ".DL." once the file is confirmed German-only (remuxed) or
// already single-track. Skips/errors leave the file fully untouched so the
// unprocessed state stays visible.
if (result.action === "remuxed" || result.action === "kept-single") {
await this.stripDualLangFromFileName(sourcePath, pkg);
}
}
logger.info(`Tonspur-Bereinigung fertig: ${processed} verarbeitet, ${failed} Fehler von ${targets.length} Kandidaten in ${extractDir}`);
if (pkg) {
this.logRenameProcess(pkg, failed > 0 ? "WARN" : "INFO", "audio-strip", "Tonspur-Bereinigung fertig", { processed, failed, candidates: targets.length });
}
return processed;
}
private async stripDualLangFromFileName(sourcePath: string, pkg?: PackageEntry): Promise<void> {
const dir = path.dirname(sourcePath);
const name = path.basename(sourcePath);
const newName = stripDualLangMarker(name);
if (newName === name) {
return;
}
const targetPath = path.join(dir, newName);
if (pathKey(targetPath) !== pathKey(sourcePath) && await this.existsAsync(targetPath)) {
logger.warn(`.DL.-Strip uebersprungen (Ziel existiert): ${newName}`);
return;
}
try {
await this.renamePathWithExdevFallback(sourcePath, targetPath, { label: "audio-strip" });
await this.renameCompanionFiles(sourcePath, targetPath, pkg);
if (pkg) {
const resolved = this.inferItemForMediaLog(pkg, targetPath, path.basename(targetPath));
this.logRenameProcess(pkg, "INFO", "audio-strip", ".DL. aus Dateiname entfernt", { sourcePath, targetPath }, resolved.item, resolved.matchedBy);
}
} catch (error) {
logger.warn(`.DL.-Strip fehlgeschlagen (${name}): ${compactErrorText(error)}`);
}
}
private async autoRenameExtractedVideoFilesImpl( private async autoRenameExtractedVideoFilesImpl(
extractDir: string, extractDir: string,
pkg?: PackageEntry, pkg?: PackageEntry,
@ -4511,7 +4662,12 @@ export class DownloadManager extends EventEmitter {
if (!cleanBase) { if (!cleanBase) {
return null; return null;
} }
const cleanFileName = `${cleanBase}${sourceExt}`; // Safety net: collect derives names from folder tokens that may still carry
// ".DL.". When German-audio-only is on, never reintroduce the marker the
// audio step already stripped from the file.
const cleanFileName = this.settings.keepGermanAudioOnly
? stripDualLangMarker(`${cleanBase}${sourceExt}`)
: `${cleanBase}${sourceExt}`;
if (cleanFileName.toLowerCase() === path.basename(sourcePath).toLowerCase()) { if (cleanFileName.toLowerCase() === path.basename(sourcePath).toLowerCase()) {
return null; return null;
} }
@ -5956,6 +6112,11 @@ export class DownloadManager extends EventEmitter {
} }
private dropItemContribution(itemId: string): void { private dropItemContribution(itemId: string): void {
// NOTE: deliberately does NOT subtract from session.totalDownloadedBytes /
// sessionDownloadedBytes. Those are cumulative-session counters and must stay
// put when a completed item is removed from the queue (see the test "keeps
// cumulative session totals when completed items are removed from the queue").
// The retry path subtracts on its own because those bytes get re-downloaded.
this.itemContributedBytes.delete(itemId); this.itemContributedBytes.delete(itemId);
this.invalidateStatsCache(); this.invalidateStatsCache();
} }
@ -7000,6 +7161,9 @@ export class DownloadManager extends EventEmitter {
const abortController = new AbortController(); const abortController = new AbortController();
this.packagePostProcessAbortControllers.set(packageId, abortController); this.packagePostProcessAbortControllers.set(packageId, abortController);
// Holder so the task's own finally can identity-check itself (the task Promise
// cannot reference its own const inside its initializer). Assigned right after.
const handle: { task?: Promise<void> } = {};
const task = (async () => { const task = (async () => {
const slotWaitStart = nowMs(); const slotWaitStart = nowMs();
await this.acquirePostProcessSlot(packageId); await this.acquirePostProcessSlot(packageId);
@ -7046,8 +7210,16 @@ export class DownloadManager extends EventEmitter {
} while (this.hybridExtractRequeue.has(packageId)); } while (this.hybridExtractRequeue.has(packageId));
} finally { } finally {
this.releasePostProcessSlot(); this.releasePostProcessSlot();
// Identity guard: only clear the map entries if they still point to THIS
// task/controller. After an abort deletes our handle a new run can install
// a fresh task+controller for the same packageId; a blind delete here would
// orphan that newer task (uncancellable) and allow a duplicate concurrent run.
if (this.packagePostProcessTasks.get(packageId) === handle.task) {
this.packagePostProcessTasks.delete(packageId); this.packagePostProcessTasks.delete(packageId);
}
if (this.packagePostProcessAbortControllers.get(packageId) === abortController) {
this.packagePostProcessAbortControllers.delete(packageId); this.packagePostProcessAbortControllers.delete(packageId);
}
this.persistSoon(); this.persistSoon();
this.emitState(); this.emitState();
if (this.hybridExtractRequeue.delete(packageId)) { if (this.hybridExtractRequeue.delete(packageId)) {
@ -7058,6 +7230,7 @@ export class DownloadManager extends EventEmitter {
} }
})(); })();
handle.task = task;
this.packagePostProcessTasks.set(packageId, task); this.packagePostProcessTasks.set(packageId, task);
return task; return task;
} }
@ -7076,6 +7249,7 @@ export class DownloadManager extends EventEmitter {
return false; return false;
} }
return this.settings.autoRename4sf4sj return this.settings.autoRename4sf4sj
|| this.settings.keepGermanAudioOnly
|| this.settings.collectMkvToLibrary || this.settings.collectMkvToLibrary
|| this.settings.removeLinkFilesAfterExtract || this.settings.removeLinkFilesAfterExtract
|| this.settings.removeSamplesAfterExtract || this.settings.removeSamplesAfterExtract
@ -10942,6 +11116,7 @@ export class DownloadManager extends EventEmitter {
try { try {
await this.chainPackageFileOp(pkg.id, async () => { await this.chainPackageFileOp(pkg.id, async () => {
await this.autoRenameExtractedVideoFilesImpl(pkg.extractDir, pkg, hybridShouldAbort); await this.autoRenameExtractedVideoFilesImpl(pkg.extractDir, pkg, hybridShouldAbort);
await this.keepGermanAudioOnlyImpl(pkg.extractDir, pkg, hybridShouldAbort, hybridController.signal);
await this.collectMkvFilesToLibrary(packageId, pkg, hybridShouldAbort, true); await this.collectMkvFilesToLibrary(packageId, pkg, hybridShouldAbort, true);
}); });
} catch (err) { } catch (err) {
@ -11612,6 +11787,12 @@ export class DownloadManager extends EventEmitter {
}); });
throwIfAborted(); throwIfAborted();
await this.autoRenameExtractedVideoFiles(pkg.extractDir, pkg, shouldAbort, true); await this.autoRenameExtractedVideoFiles(pkg.extractDir, pkg, shouldAbort, true);
if (this.settings.keepGermanAudioOnly) {
pkg.postProcessLabel = "Tonspur...";
this.emitState();
throwIfAborted();
await this.keepGermanAudioOnly(pkg.extractDir, pkg, shouldAbort, deferredController.signal);
}
} }
if ((extractedCount > 0 || alreadyMarkedExtracted) && failed === 0 && this.settings.cleanupMode !== "none") { if ((extractedCount > 0 || alreadyMarkedExtracted) && failed === 0 && this.settings.cleanupMode !== "none") {

View File

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

View File

@ -183,7 +183,14 @@ async function flushAsync(): Promise<void> {
} }
flushInFlight = true; flushInFlight = true;
const linesSnapshot = pendingLines.slice(); // 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 chunk = linesSnapshot.join(""); const chunk = linesSnapshot.join("");
try { try {
@ -200,9 +207,19 @@ async function flushAsync(): Promise<void> {
} else if (!primary.ok) { } else if (!primary.ok) {
writeStderr(`LOGGER write failed: ${primary.errorText}\n`); writeStderr(`LOGGER write failed: ${primary.errorText}\n`);
} }
if (wroteAny) { if (!wroteAny) {
pendingLines = pendingLines.slice(linesSnapshot.length); // Write failed: requeue the unwritten lines AHEAD of anything that arrived
pendingChars = Math.max(0, pendingChars - chunk.length); // 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);
}
} }
} finally { } finally {
flushInFlight = false; flushInFlight = false;

View File

@ -421,6 +421,8 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
packageName: asText(settings.packageName), packageName: asText(settings.packageName),
autoExtract: Boolean(settings.autoExtract), autoExtract: Boolean(settings.autoExtract),
autoRename4sf4sj: Boolean(settings.autoRename4sf4sj), autoRename4sf4sj: Boolean(settings.autoRename4sf4sj),
keepGermanAudioOnly: Boolean(settings.keepGermanAudioOnly),
germanAudioMode: settings.germanAudioMode === "first" ? "first" : "tag",
extractDir: normalizeAbsoluteDir(settings.extractDir, defaults.extractDir), extractDir: normalizeAbsoluteDir(settings.extractDir, defaults.extractDir),
collectMkvToLibrary: Boolean(settings.collectMkvToLibrary), collectMkvToLibrary: Boolean(settings.collectMkvToLibrary),
mkvLibraryDir: normalizeAbsoluteDir(settings.mkvLibraryDir, defaults.mkvLibraryDir), mkvLibraryDir: normalizeAbsoluteDir(settings.mkvLibraryDir, defaults.mkvLibraryDir),

510
src/main/video-processor.ts Normal file
View File

@ -0,0 +1,510 @@
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 };
}

View File

@ -844,7 +844,7 @@ const emptySnapshot = (): UiSnapshot => ({
archivePasswordList: "", archivePasswordList: "",
rememberToken: true, providerOrder: [], providerPrimary: "realdebrid", providerSecondary: "none", rememberToken: true, providerOrder: [], providerPrimary: "realdebrid", providerSecondary: "none",
providerTertiary: "none", autoProviderFallback: true, outputDir: "", packageName: "", providerTertiary: "none", autoProviderFallback: true, outputDir: "", packageName: "",
autoExtract: true, autoRename4sf4sj: false, extractDir: "", createExtractSubfolder: true, hybridExtract: true, autoExtract: true, autoRename4sf4sj: false, keepGermanAudioOnly: false, germanAudioMode: "tag", extractDir: "", createExtractSubfolder: true, hybridExtract: true,
collectMkvToLibrary: false, mkvLibraryDir: "", collectMkvToLibrary: false, mkvLibraryDir: "",
cleanupMode: "none", extractConflictMode: "overwrite", removeLinkFilesAfterExtract: false, cleanupMode: "none", extractConflictMode: "overwrite", removeLinkFilesAfterExtract: false,
removeSamplesAfterExtract: false, enableIntegrityCheck: true, autoResumeOnStart: true, removeSamplesAfterExtract: false, enableIntegrityCheck: true, autoResumeOnStart: true,
@ -1150,6 +1150,10 @@ function rotationEventText(ev: { event: string; cooldownSec?: number; next?: str
return `fehlgeschlagen${cd}${nx}`; return `fehlgeschlagen${cd}${nx}`;
} }
case "FATAL": return "abgebrochen (fataler Fehler)"; case "FATAL": return "abgebrochen (fataler Fehler)";
case "TIMEOUT_COOLDOWN": {
const cd = ev.cooldownSec ? `, Cooldown ${ev.cooldownSec}s` : "";
return `Timeout/Abbruch${cd} → nächster Account beim Retry`;
}
case "SKIP_COOLDOWN": return untilRestart ? "übersprungen (bis Neustart gesperrt)" : "übersprungen (Cooldown aktiv)"; case "SKIP_COOLDOWN": return untilRestart ? "übersprungen (bis Neustart gesperrt)" : "übersprungen (Cooldown aktiv)";
case "SKIP_DISABLED": return "übersprungen (deaktiviert)"; case "SKIP_DISABLED": return "übersprungen (deaktiviert)";
case "SKIP_DAILY_LIMIT": return "übersprungen (Tageslimit erreicht)"; case "SKIP_DAILY_LIMIT": return "übersprungen (Tageslimit erreicht)";
@ -5372,6 +5376,11 @@ export function App(): ReactElement {
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoSkipExtracted} onChange={(e) => setBool("autoSkipExtracted", e.target.checked)} /> Bereits Entpacktes beim Start überspringen</label> <label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoSkipExtracted} onChange={(e) => setBool("autoSkipExtracted", e.target.checked)} /> Bereits Entpacktes beim Start überspringen</label>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.hideExtractedItems} onChange={(e) => setBool("hideExtractedItems", e.target.checked)} /> Entpackte Items in Paketliste ausblenden</label> <label className="toggle-line"><input type="checkbox" checked={settingsDraft.hideExtractedItems} onChange={(e) => setBool("hideExtractedItems", e.target.checked)} /> Entpackte Items in Paketliste ausblenden</label>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoRename4sf4sj} onChange={(e) => setBool("autoRename4sf4sj", e.target.checked)} /> Auto-Rename (Beta)</label> <label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoRename4sf4sj} onChange={(e) => setBool("autoRename4sf4sj", e.target.checked)} /> Auto-Rename (Beta)</label>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.keepGermanAudioOnly} onChange={(e) => setBool("keepGermanAudioOnly", e.target.checked)} /> Nur deutsche Tonspur behalten (.DL.-Dateien, braucht ffmpeg)</label>
<div><label>Tonspur-Auswahl</label><select value={settingsDraft.germanAudioMode} disabled={!settingsDraft.keepGermanAudioOnly} onChange={(e) => setText("germanAudioMode", e.target.value)}>
<option value="tag">Deutsche Spur per Sprach-Tag (empfohlen)</option>
<option value="first">Immer erste Tonspur (wie Script)</option>
</select></div>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.createExtractSubfolder} onChange={(e) => setBool("createExtractSubfolder", e.target.checked)} /> Entpackte Dateien in Paket-Unterordner speichern</label> <label className="toggle-line"><input type="checkbox" checked={settingsDraft.createExtractSubfolder} onChange={(e) => setBool("createExtractSubfolder", e.target.checked)} /> Entpackte Dateien in Paket-Unterordner speichern</label>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.hybridExtract} onChange={(e) => setBool("hybridExtract", e.target.checked)} /> Hybrid-Extract</label> <label className="toggle-line"><input type="checkbox" checked={settingsDraft.hybridExtract} onChange={(e) => setBool("hybridExtract", e.target.checked)} /> Hybrid-Extract</label>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoExtractWhenStopped} onChange={(e) => setBool("autoExtractWhenStopped", e.target.checked)} /> Entpacken auch ohne laufende Session (bei Stopp / Programmstart)</label> <label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoExtractWhenStopped} onChange={(e) => setBool("autoExtractWhenStopped", e.target.checked)} /> Entpacken auch ohne laufende Session (bei Stopp / Programmstart)</label>

View File

@ -97,6 +97,8 @@ export interface AppSettings {
packageName: string; packageName: string;
autoExtract: boolean; autoExtract: boolean;
autoRename4sf4sj: boolean; autoRename4sf4sj: boolean;
keepGermanAudioOnly: boolean;
germanAudioMode: "tag" | "first";
extractDir: string; extractDir: string;
collectMkvToLibrary: boolean; collectMkvToLibrary: boolean;
mkvLibraryDir: string; mkvLibraryDir: string;

View File

@ -0,0 +1,104 @@
# Plan: „Nur deutsche Tonspur behalten" (.DL.) als Tool-Funktion
Quelle der Idee: User-Script `Remove Non German Audio.py` (ffmpeg `-map 0:v:0 -map 0:a:0
-c copy -map_metadata -1`, + `.DL.`→`.` Rename). Soll als **toggle­barer 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.

View File

@ -1,159 +1,164 @@
# Erweitertes Logging (2026-06-07) — Goal: Tool besser kontrollieren bei Problemen/Fehlern # Real-Debrid-Downloader — Tasks (Stand 2026-06-08)
Recon (4-Agent-Workflow) fand 40+ Lücken. Bewusste Scope-Disziplin: NICHT alle 40 **Status:** Alle zugesagten Features erledigt+released (Archiv unten). Aktuell läuft ein
instrumentieren (die meisten leeren catches sind legitimes Cleanup → würden das Log **intensiver Bug-Audit** (User-Goal 2026-06-08, "schaue intensiv nach weiteren Bugs") —
fluten). Fokus: strukturelle Sichtbarkeit für unbeaufsichtigten Windows-Server, wo Fortschritt direkt unten.
"Crash/Hang ohne Log" der schlimmste Fall ist. Advisor-gegengeprüft.
## Tier 1 — Prozess-/Renderer-Crash-Sichtbarkeit (höchster Wert)
- [ ] main.ts: `render-process-gone` (+ Auto-Reload mit Circuit-Breaker max 3/5min)
- [ ] main.ts: `app.on("child-process-gone")` (GPU/Utility/Renderer-Subprozesse)
- [ ] main.ts: `webContents.on("unresponsive"/"responsive")` — NUR loggen, nie killen
- [ ] main.ts: `process.on("warning")` (Node-Warnungen, z.B. MaxListenersExceeded)
- [ ] Renderer-Fehler-Capture: window.onerror + unhandledrejection + React ErrorBoundary
→ über neuen Einweg-IPC `LOG_RENDERER_ERROR` ins Main-Log (kein stilles White-Screen)
- [ ] Memory-Heartbeat: im vorhandenen runtimeStatsTimer (60s), warn bei heapUsed/heapTotal > 0.9
## Tier 2 — Logging-Infrastruktur
- [ ] logger.ts: DEBUG-Level, gated über `RD_DEBUG` env (no-op wenn aus → keine Format-Kosten)
- [ ] error-ring.ts (NEU, pure+getestet): letzte N WARN/ERROR im RAM, gefüttert im write()-Chokepoint
- [ ] debug-server.ts: `/errors` Endpoint
- [ ] support-bundle.ts: overview/recent-errors.json
## Tier 3 — Gezielte High-Value-Catches
- [ ] fs-error.ts (NEU, pure+getestet): classifyDiskError(err) — ENOSPC/EACCES/EROFS/EMFILE
- [ ] download-manager.ts: ENOSPC-Klassifizierung am vorhandenen Attempt-catch (reine Log-Anreicherung)
- [ ] download-manager.ts: Integrity-Check PASS ins item-log (Symmetrie: bisher nur Fail geloggt)
## Verifikation — ERLEDIGT 2026-06-07
- [x] Alle Tier 1/2/3 Punkte umgesetzt (siehe unten)
- [x] tsc = 6 (Baseline unverändert — eine versehentlich eingeführte `pkg`-Referenz sofort gefixt)
- [x] 728 Tests grün (715 Baseline + 13 neu: error-ring 5, fs-error/debug-gate 8), 41 Dateien
- [x] `npm run build` grün (tsup main 1.04MB + vite renderer 33 Module)
- [x] Grep-Konsistenz LOG_RENDERER_ERROR/reportRendererError über alle 5 Dateien (Kette lückenlos:
ipc.ts → preload-api.ts → preload.ts(send) → main.ts(ipcMain.on, einweg) → main.tsx + error-boundary.tsx)
- [x] Renderer-tsc-Abdeckung BEWIESEN (nicht angenommen): absichtlicher Typfehler in error-boundary.tsx
wurde von `tsc --noEmit` geflaggt → die neuen Renderer-Dateien sind echt typgeprüft (vite build prüft NICHT)
- [x] Advisor-Feinschliff: Memory-Heartbeat misst gegen `v8.getHeapStatistics().heap_size_limit`
(echte OOM-Decke) statt heapUsed/heapTotal — sonst Fehlalarm, der die Error-Ring zumüllt
### Geänderte/neue Dateien
NEU: error-ring.ts, fs-error.ts, renderer/error-boundary.tsx, tests/error-ring.test.ts, tests/fs-error.test.ts
GEÄNDERT: logger.ts (DEBUG-Level+Gate+Ring-Feed), main.ts (4 Crash-Handler + Renderer-IPC),
app-controller.ts (Memory-Heartbeat), debug-server.ts (/errors), support-bundle.ts (recent-errors.json),
download-manager.ts (ENOSPC-Klassifizierung + Integrity-PASS-Log), shared: ipc.ts/types.ts/preload-api.ts, preload.ts, renderer/main.tsx
### NOCH OFFEN
- [ ] Release (Gitea + GitHub-Mirror) — wartet auf User-Go ("jo mach releasen")
--- ---
# Real-Debrid-Downloader — Analyse & Verbesserungen (2026-05-23) ## 🔴 LAUFEND — Bug-Audit 2026-06-08 (Multi-Agent find→verify, 18 bestätigt)
Tiefe Analyse via 3 parallele Subagents (Bugs / Features / UI) + 4 Design-Mockups. Advisor-Triage: **A = einzige echte Daten-Verlust-Notlage** (zerstört echte Datei auf Platte)
→ zuerst, ALLEINE Release. **B verifiziert demoted:** applyRetroactiveCleanupPolicy/
removePackageFromSession löschen KEINE Platten-Dateien (nur Session/Queue-Einträge + ggf.
History-Eintrag) → Queue-Integrität, nicht Daten-Verlust → in v1.7.190-Batch.
Sequenz: Release 1 (v1.7.189) = **A allein**; Release 2 (v1.7.190) = B/I,C,D/E,F,G,H,J,L,M,N,O,P,Q.
Ein Commit pro Fix, jeder einzeln verifiziert. **K übersprungen** (auto-rename-Reorder,
schlechtestes Risiko/Nutzen, kann für diesen User gar nicht feuern).
### Release 1 — Daten-Verlust-Stopper (v1.7.189, A ALLEIN)
- [x] **A** `video-processor.ts` atomic-replace zerstörte bei Windows-Lock BEIDE Kopien
(rm(original) VOR bestätigtem Replace + outer-catch rm(temp) → 0 Kopien). **GEFIXT:**
atomic replace-over + `renameWithRetry` (EBUSY/EACCES/EPERM/EEXIST, Backoff 200/500/1000ms),
rm-first-Fallback entfernt, **unique** Temp-Name (`~rd<pid><rand>`, löst auch C-Kollision).
Advisor bestätigt Ansatz besser als bak-dance (kein Missing-File-Window). 3 neue Tests
(Recovery + Retry-Pfad), 41 video-processor-Tests grün, tsc=6 (Baseline). Commit 189af22.
### Release 2 — v1.7.190 (GEFIXT + verifiziert, ein Commit pro Fix)
- [x] **L+M** video-processor.ts zu weite Deutsch-Erkennung. isGermanStream Titel-Fallback nur
ganze Wörter (ger/deu raus → konnten falsche Spur picken + echte dt. löschen); looksLikeGerman
Release 'dubbed' raus (ital./franz. Dub triggerte German-first). 2 Negativtests. Commit 272a41a.
- [x] **H** logger.ts flushAsync slice-snapshot korrumpiert bei 1MB-Cap-Trim während await →
ungeschriebene Zeilen verloren. Move-snapshot (Buffer auf [] übernehmen) + Requeue bei
Schreibfehler. Commit 4432fa2.
- [x] **J+Q** download-manager. J: runPackagePostProcessing finally löschte Map-Eintrag ohne
Identity-Guard → Abort+Neustart-Race riss neuen Task raus (Waise + Doppel-Lauf); jetzt nur
löschen wenn Map noch auf DIESEN Task/Controller zeigt (handle-Objekt wegen TS2454). Q:
collectFilesByExtensions filtert `~rd`-Temp-Präfix (crash-verwaiste Teil-Remuxe nie ins
Library). Commit 3c33b98.
- [x] **P** extractor.ts nested-Resume-Keys (`nested:<name>`) bei jedem extractPackageArchives
gepurged → verschachtelte Archive beim Resume neu entpackt; `startsWith("nested:")` im Prune
übersprungen. Commit 61a8304.
- [x] **B/I** app-controller.ts importBackup settings-only purgte LIVE-Queue (Dateien blieben auf
Platte) + rollte Usage-Zähler zurück. Fix: setSettings({suppressRetroactiveCleanup}) +
overlayLiveUsageCounters (extrahiert+wiederverwendet, inkl. Key-Filter). Commit dc05b51.
### Verifiziert KEINE Bugs / bewusst NICHT angefasst (Advisor-Disziplin: erst belegen, dann ändern)
- **G** dropItemContribution "subtrahiert Session-Totals nicht" → **KEIN Bug**: Test "keeps
cumulative session totals when completed items are removed" kodifiziert die Absicht (Session-
Zähler kumulativ, divergieren bewusst von der Item-Map; Retry-Pfad zieht ab, weil neu geladen
wird). Fix-Versuch ließ den Test failen → revertiert, Klarstellungs-Kommentar gesetzt.
- **N** stripDualLangFromFileName "Kollision" → **bereits geguarded**: existsAsync-Skip verhindert
Überschreiben; Remux machte Inhalt eh deutsch-only; collect strippt `.DL.` downstream. Residual
= generischer Rename-TOCTOU (in JEDEM Rename-Pfad), kein spezifischer Bug hier.
- **D/E** abort-Klassifizierung über signal.reason statt Text → **deferred (Robustheit, kein
Live-Bug auf User-Pfad)**. BELEGT: mega-web-fallback normalisiert JEDEN Abort (Timeout UND
Cancel) zu `new Error("aborted:mega-web")` → aktueller Guard `/aborted/i && !/timeout/i` FEUERT
→ v1.7.187-Cooldown LÄUFT auf dem Web-Pfad (User-Pfad). Einzige Imperfektion: Cancel >8s wird
fälschlich gecooled (minor). Empirisch bestätigt: `AbortSignal.any([ac,timeout]).reason?.name===
'TimeoutError'` (timeout) vs string/AbortError (cancel) — falls je gebaut: signal.aborted-gaten,
reason.name nutzen, Text-Fallback behalten, reason-Test. Hoch-Risiko (kritischer Unrestrict-Pfad
JEDES Downloads) → nicht für Robustheit anfassen. API-Pfad-Abort-Text nicht erschöpfend geprüft.
- **E** "API 'cancel'-Pfad umgeht" → **nicht real**: kein `'cancel'`-throw im Code gefunden.
- **O** classifyAccountFailure abort-Branch tot → **stehen lassen**: tot NUR wegen aktueller
Text-Interception; ein signal.aborted-gated D/E würde ihn wiederbeleben. Kein Kosmetik-Churn.
- **F** Mega-Web empty-streak Concurrency → **N-shaped, deferred**: Streak wird bei Erfolg (1956)
+ Nicht-Limit-Fehler (2005) gecleart; "bis Neustart gesperrt" ist bewusste Tageslimit-Logik,
Restart-cleared; Mega-Web single-flight → Concurrency greift nicht. Keine fühlbare Schädigung
konstruierbar → keine Park-State-Maschinerie.
- **C** → in A subsumiert (unique Temp-Name). **K** übersprungen (auto-rename-Reorder, Risiko≫Nutzen).
--- ---
## A. BUGS / ROBUSTHEIT (verifiziert gegen Quellcode) ## 🟢 OFFEN — Backlog (optional, nie begonnen)
Roter Faden: Die **Deferred-Post-Processing-Pipeline** (eingeführt um den Extract-Slot schnell freizugeben) ist nur halb ins Abbruch-/Lifecycle-Management integriert. Genau der Bereich des v1.7.156-Fixes. ### ✅ Mega-Web Account-Rotation überspringt Account 3 — GEFIXT 2026-06-08 (v1.7.187)
**Fix:** Ein Mega-Web-Account-Abbruch (geteiltes Timeout feuert während der Account lief)
setzt jetzt einen 2-min-Cooldown auf den Account (nur wenn er ≥8s lief, sonst = User-Cancel,
RD_MEGA_ABORT_MIN_RUN_MS env). Dadurch überspringt der download-manager-Retry diesen Account
und rotiert zum nächsten (debrid.ts, abort-Handling im Rotations-catch, vor classifyAccountFailure).
Log-Event `TIMEOUT_COOLDOWN` (gelb, "Timeout/Abbruch → nächster Account beim Retry") statt
rotem "fataler Fehler" (App.tsx:1141 Label). 2 Regressionstests (Cooldown gesetzt → Call 2
rotiert; Quick-Abbruch → kein Cooldown). EHRLICH: fixt Korrektheit, NICHT Latenz — Account 1
brennt weiter ~60s ins Timeout bevor der Retry auf Account 2 wechselt (instant-Failover bräuchte
per-Account-Timeout = größerer Eingriff, bewusst verschoben). Advisor-gegengeprüft.
### HOCH **(Ursprüngliche Analyse — Symptom & Mechanismus, zur Doku belassen)**
- [ ] **H1 — Globaler Stop/Shutdown bricht Deferred-Post-Processing nicht ab.** `abortPostProcessing` (download-manager.ts:7053) iteriert nur über `packagePostProcessAbortControllers`, nie über `packageDeferredPostProcessAbortControllers`. Bei Stop/Shutdown/clearAll laufen MKV-Move/Archiv-Cleanup/Rename weiter, während synchron persistiert wird → FS-Zustand ≠ Session-State (halb verschobene Datei, halb gelöschtes Archiv). **Symptom (User):** 3 Mega-Debrid-Web-Accounts aktiv, Rotation pendelt aber nur zwischen
- [ ] **H2 — Hybrid-Post-Extract feuert MKV-Collection/Rename als losgelöstes Promise** (download-manager.ts:11334). In keiner Tracking-Map, kein `shouldAbort`. Cancel/Reset während Hybrid-Collect → Dateien werden trotzdem verschoben/gelöscht. Account 1 ↔ 2 (bzw. nur Account 1), Account 3 (Su****xe) wird NIE probiert.
- [ ] **H3 — 0-Byte-Datei wird als vollständig akzeptiert** wenn keine Größeninfo (download-completion.ts:129, source "stream-end"). Hoster antwortet HTTP 200 ohne Content-Length + schließt sofort → Item "Fertig" mit leerer Datei, kein Auto-Redownload.
### MITTEL **Verifizierter Mechanismus (Code):**
- [ ] **M1 — Deferred-Post-Extraction nicht in `packagePostProcessTasks`** (download-manager.ts:11974). Scheduler-Abschluss (8154) + finishRun (12310) sehen Deferred-Tasks nicht → Run-Ende/Summary feuert während noch Dateien verschoben werden, State-Reset mitten in FS-Arbeit. - Rotationsschleife `debrid.ts:1898`. Account 1 → "Mega-Web Antwort leer" → Cooldown 20s →
- [ ] **M2 — `blockAllPersistence` wird nach Backup-Import nie zurückgesetzt** (app-controller.ts:678). Weiterarbeiten ohne Neustart → `persistSoon` ist dauerhaft No-Op → bei hartem Crash alle Änderungen weg. weiter zu Account 2. Account 2 → `aborted:debrid`.
- [ ] **M3 — `cancelPendingAsyncSaves` wartet nicht auf laufenden Async-Save** (storage.ts:1064). I/O-Overlap beim Import (Datenintegrität durch Generation-Guard geschützt, nur Robustheit). - `classifyAccountFailure` (`debrid.ts:2036`) stuft JEDEN Abbruch als **fatal** ein →
`throw` (`debrid.ts:1991`) → Schleife bricht ab → **Account 3 nie erreicht.**
- Account 2 bekommt beim Fatal-Abbruch **keinen Cooldown** (cooldownMs:0). Beim
download-manager-Retry wird Account 1 (Cooldown) übersprungen, aber Account 2 (kein
Cooldown) ERNEUT vor Account 3 probiert → bricht wieder ab → ewiges 1↔2.
- Geteiltes 60s-Unrestrict-Timeout `download-manager.ts:8590` (`AbortSignal.any([taskAbort,
timeout(60s)])`) gilt für die GANZE Rotation, nicht pro Account. Mega-Web pollt intern bis
180s (`mega-web-fallback.ts:235` + Poll-Loop `:371`). Sobald das geteilte 60s feuert, bleibt
das kombinierte Signal aborted → KEIN späterer Account kriegt im selben Pass eine echte Chance.
### NIEDRIG **BESTÄTIGT 2026-06-08 (zweite Screenshots):** Account 1 läuft 10x rasch "erfolgreich"
- [ ] **N1 — Toter Code in `findReadyArchiveSets`** (download-manager.ts:10847). Unbedingtes `ready.add+continue` macht strengeren Disk-Fallback-Block (untracked-pending-Schutz) unerreichbar. (11:51:4511:52:26), dann zwei "abgebrochen (aborted:debrid)" um 11:53:30 UND 11:54:30 —
**exakt 60s auseinander** = das geteilte 60s-Unrestrict-Timeout feuert (kein User-Stop, der
wiederholt sich nicht periodisch). Hier rotiert GAR NICHTS: Account 1 bricht ab → fatal →
Rotation stoppt sofort bei idx=0 → Account 2 und 3 werden NIE probiert. Bug eindeutig
bestätigt, elapsedMs nicht mehr nötig. Account 1 selbst ist gesund (10x ok) — Mega-Web hängt
nur sporadisch (no-server-Poll) bis ins 60s-Timeout.
**Empfehlung:** H1 + H2 + M1 zusammen fixen (eine kohärente Härtung der Deferred-Pipeline). H3 ist klein & unabhängig. M2 trivial. **Fix-Design (wenn bestätigt):** Pro-Account-Timeout-Budget, abgekoppelt vom geteilten Cap.
debrid.ts braucht das **cancel-only** Signal getrennt vom Timeout (kombiniertes Signal kann
beides nicht unterscheiden). Minimal-invasiv: optionaler `opts`-Param an `unrestrictLink`
({cancelSignal, perAttemptTimeoutMs}) — nur die Mega-Rotation liest ihn, andere Provider
unberührt (kombiniertes Signal bleibt). Pro Account: `AbortSignal.any([cancelSignal,
AbortSignal.timeout(perAttemptMs)])`. Abbruch-Logik: cancelSignal aborted → echter Stop;
eigenes Account-Timer gefeuert → non-fatal, Cooldown, weiter zum nächsten Account (inkl. 3).
**Regressionstest ZUERST** (3 Accounts, 1+2 failen/aborten → assert Account 3 kriegt TEST).
**Advisor-Gate** vor Eingriff (kritischer Unrestrict-Pfad, betrifft jeden Download).
Hinweis: Grundursache der leeren Antworten = Mega-Debrid Server/IP-Thema — Fix macht Rotation
nur FAIRER (alle Accounts drankommen), bringt aber keinen busy Server zum Antworten.
### Features / UX (nach ROI)
App läuft headless auf Windows-Server → Nutzer sitzt nicht davor.
1. [ ] **Push-Benachrichtigungen** (Discord/Telegram/ntfy) — SM. 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) — SM. 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** — SM. Quota/Cooldown-Fails am nächsten Tag automatisch neu.
9. [ ] **Plex/Jellyfin Library-Refresh nach MKV-Move** — S. Gleicher Hook wie #1.
10. [ ] **Watch-Folder für DLC/Link-Auto-Import** — M.
### Design-Richtung (Entscheidung steht aus)
4 Mockups in `design-mockups/` (index.html = Vergleich): **Aurora** (verfeinert dark, geringstes Risiko) · **Command** (Terminal/Ops, dicht) · **Vellum** (light editorial) · **Nebula** (neon).
→ Richtung wählen. Siehe Memory: design-taste (Anti-KI-Look) + design-direction (Ember-Wärme, flach/ehrlich).
### Alte Audit-Items (2026-04-04, Status ggf. veraltet — VOR Fix gegen aktuellen Code verifizieren)
- [ ] Debrid-Link `maxDataHost` kühlt ganzen Key ab statt nur den Host
- [ ] Debrid-Link `fileNotAvailable` setzt Key auf "error" statt temporär
- [ ] AllDebrid: kein per-host-Cooldown für erschöpfte Quotas
- [ ] LinkSnappy: keine Auth-Dedup (parallele Requests rufen beide authenticate())
- [ ] Extractor password-cache race (parallele Worker mutieren `packageLearnedPasswords`)
- [ ] Hybrid race: 1 Datei/Staffel evtl. beim MKV-Move nicht umbenannt (NUR per-package fixen — Post-MKV-Move-Scan ist tabu, v1.7.107 revertiert)
--- ---
## B. FEATURES / UX-GAPS (nach Mehrwert/Aufwand) ## ✅ ERLEDIGT — Archiv (Details in git-History + Memory)
App läuft headless auf Windows-Server → Nutzer sitzt nicht davor. Größte Lücke: keine Benachrichtigungen. - **Erweitertes Logging** → released **v1.7.185** (Crash-Handler, Renderer-Fehler-IPC, RD_DEBUG-Level, Error-Ring + `/errors`, ENOSPC-Klassifizierung, Memory-Heartbeat). → Memory: extended-logging
- **Link-Prefetch** → untersucht (6-Agent) + **bewusst verworfen** (marginal bei maxParallel 8, Mega-Web single-flight). → Memory: link-prefetch-declined
- **Backup nur Settings** → v1.7.184 (`backupIncludeDownloads`-Toggle + 4 Selektions/Flicker-Fixes). → Memory: backup-settings-only
- **Account-Rotation-Overhaul** → v1.7.164168 (Validity/Premium-Badges, Live-Panel, "Alle prüfen"). → Memory: account-rotation
- **Mega-Debrid-Account deaktivieren (UI)** → erledigt (Toggle im Edit-Dialog, im Code verifiziert 2026-06-07)
- **Bugs/Robustheit (Deferred-Pipeline H1/H2/H3/M1/M2/N1)** → v1.7.158/159; M3 bewusst übersprungen (Generation-Guard schützt Integrität bereits)
- **Deferred-Pfad Rename-Gap** → gefixt v1.7.162+ (finaler Deferred-Pass benennt frische Dateien vor Collect um; Repro-Test grün)
- **Repo-Privacy-Audit** → GitHub gelöscht+neu (saubere History), Gitea unberührt. → Memory: repo-privacy-audit
1. [ ] **Webhook/Push-Benachrichtigungen** (Discord/Telegram/ntfy) — SM. Bei Paket fertig/Fehler/Quota/Provider-down aufs Handy. Neuer `notifier.ts`, Hooks an Completion-Punkten. **Höchster ROI.** ### Bewusst NICHT angefasst (Crash-Debris / alte Experimente)
2. [ ] **Fernsteuerung über bestehenden Debug-Server** (POST-Endpunkte) — SM. Server hat schon HTTP + Token-Auth, aber nur GET. POST `/control/add-links`, `/start`, `/stop` → vom Handy steuern. - Gestashtes Crash-Debris `stash@{0}` (Revert von 08372f9/18eada9/98dc366 + log.old) — bei Bedarf recoverbar, sonst verwerfbar
3. [ ] **URL-Duplikat-Erkennung beim Hinzufügen** — S. History-`urls` existiert, wird aber nie geprüft → versehentliche Re-Downloads verschwenden Quota. Warnen: "3 Links bereits geladen". - Untracked `*-postprocess/` + `fix-library-renames.mjs` — alte Experimente (Apr/Mai)
4. [ ] **Vereinheitlichter Pre-Flight-Check + Bulk-Skip toter Links** — M. Vor Start Größe/Name/Online für ganze Queue, "alle offline überspringen"-Button.
5. [ ] **Speicherplatz-Vorabprüfung vor Start** — S. Aktuell keine Free-Space-Prüfung → Abbruch mitten im Download bei voller Platte.
6. [ ] **Konsolidierte Fehler-Ansicht** — M. Alle fehlgeschlagenen Items flach + Fehlertext + "alle erneut versuchen".
7. [ ] **Per-Provider-Statistik** — M. Rohdaten (`providerTotalUsageBytes`) existieren, werden nur nicht dargestellt. Welches Abo lohnt sich?
8. [ ] **Auto-Retry fehlgeschlagener Pakete nach Wartezeit** — SM. Quota/Cooldown-Fails am nächsten Tag automatisch neu versuchen.
9. [ ] **Plex/Jellyfin Library-Refresh nach MKV-Move** — S. Neue Folgen sofort sichtbar. Gleicher Hook wie #1.
10. [ ] **Watch-Folder für DLC/Link-Auto-Import** — M. Ordner überwachen → automatisch importieren+starten.
---
## C. DESIGN-MOCKUPS
4 Varianten in `design-mockups/` (index.html = Vergleich):
1. **Aurora** — verfeinerte Dark-Evolution (premium, vertraut, geringstes Risiko)
2. **Command** — Terminal/Ops-Dashboard (max. Dichte, Monospace, Status-LEDs)
3. **Vellum** — Light Editorial (warmes Papier, Serif, mutige helle Alternative)
4. **Nebula** — Neon/Synthwave (Magenta-Cyan-Glow, auffällig)
→ Nutzer wählt Richtung (oder Mischung).
---
## REVIEW / ERGEBNISSE (2026-05-23)
**Umgesetzt (v1.7.158):**
- ✅ **H1**`abortPostProcessing` aborted jetzt auch alle Deferred- + Hybrid-Controller (globaler Stop/Shutdown/clearAll/external). Keine FS-Race gegen Shutdown-Save mehr.
- ✅ **H2** — Hybrid-Post-Extract läuft über neue `packageHybridPostProcessControllers`-Map (Set pro Package), Controller SYNCHRON vor dem detached Promise registriert, `shouldAbort` an Rename + MKV-Collect durchgereicht. `abortPackagePostProcessing` + `clearAll` räumen die Map. Cancel/Reset stoppt jetzt laufende Hybrid-Arbeit.
- ✅ **M1** — neuer `hasAnyDeferredPostProcessPending()`; Scheduler-Abschluss + `finishRun`-Clear gaten darauf. `hasDeferredPostProcessPending` (per-Package, für package_done-Cleanup) prüft jetzt auch Hybrid. Run endet erst wenn Background-FS-Arbeit fertig.
- ✅ **H3**`validateDownloadedFileCompletion`: 0-Byte bei `stream-end``ok:false` (download_underflow), routet in den bestehenden Retry-Pfad. Regressionstest in `tests/download-completion.test.ts` (8 Tests).
- ✅ **N1** — toter Disk-Fallback-Block in `findReadyArchiveSets` + verwaiste `pendingItemStatus`-Map entfernt (verhaltensneutral).
**Bewusst NICHT umgesetzt (mit Begründung):**
- ✅ **M2** (`blockAllPersistence` nie zurückgesetzt) — GELÖST in v1.7.159 via **Auto-Relaunch**. In-Memory-Reload wäre unsicher (Task-finally-Blöcke settlen async gegen `this.session.items[id]` → Race beim Session-Swap, bräuchte async-Refactor). Stattdessen: nach erfolgreichem Import startet die App automatisch neu (main-getrieben in main.ts, nicht Renderer — robust gegen Renderer-Fehler). Der frische Prozess lädt die restored Session sauber via Standard-Startup-Pfad. `skipShutdownPersist`/`blockAllPersistence` schützen das ~1.5s-Fenster + den Quit (verifiziert: prepareForShutdown:5680 überspringt Persistenz sauber). Footgun eliminiert — User kann nicht mehr im blockierten Zustand weiterarbeiten.
- ⏭️ **M3** (`cancelPendingAsyncSaves` wartet nicht auf laufenden Save) — Report stuft selbst als reines I/O-Overlap ein; die Generation-Guard (storage.ts:1022) schützt die Datenintegrität bereits (stale Write wird verworfen). Kein Korrektheitsgewinn, daher kein Eingriff.
**Verifikation:** 30 Test-Dateien, 621 Tests grün. Build sauber. Advisor-Review vor Implementierung (fing H2-Falle: Hybrid-Controller nicht in die Deferred-Map legen, sonst killt `runDeferredPostExtraction` sie selbst).
---
## D. DEFERRED-PFAD RENAME-GAP (2026-05-28, Opus-Verifikation von 18eada9)
**Kontext:** Eine abgestürzte Session (API 400 thinking-blocks) hinterließ ein uncommittetes Working-Tree, das **drei** releaste Commits revertierte (08372f9 Passwort + 18eada9 Hybrid-Rename + 98dc366 Support-Bundle, zurück auf v1.7.159). Kein dokumentierter Intent → als Crash-Debris bewertet, non-destruktiv **gestasht** (`git stash` — recoverable), HEAD/v1.7.162 wiederhergestellt.
**Verifizierter Fund (Folge-Bug zu 18eada9):**
- 18eada9 schloss den "frische Datei landet unbenannt"-Bug nur für den **Hybrid-Pfad** (`deferFreshFiles=true` + Mehrfach-Pässe).
- Der **finale Deferred-Pass** (`runDeferredPostExtraction`) macht Rename (12125) → Collect (12156, `deferFreshFiles=false`). Ist eine Datei beim Deferred-Rename noch frisch (< `fileStabilizeMinAgeMs`, prod=2000ms) — v.a. eine eben per **Nested-Extraction** (12045, unmittelbar davor) geschriebene Datei — überspringt der Frische-Gate sie, und der Collect moved sie mit **Original-Scene-Namen** in die Library. `collectMkvFilesToLibrary` benennt selbst nicht um (Move-Body: `buildUniqueFlattenTargetPath`, nur Flatten).
- Pre-existierender Gap (Frische-Skip-Block älter als 18eada9); auch HEAD/v1.7.162 betroffen.
**Gate (TDD, vor Fix):** neuer Regressionstest "deferred final pass renames fresh files before collecting them" → reproduzierte den Bug zuverlässig gegen HEAD (Datei landete unbenannt).
**Fix (minimal, Root-Cause):** `treatFilesAsStable`-Param durch `autoRenameExtractedVideoFiles(Impl)`. Im Deferred-**Final**-Pass (kein concurrent Extractor-Write mehr, Extraktion awaited) wird der Frische-Gate umgangen → alle Dateien werden umbenannt, bevor der Collect sie sammelt. Hybrid-Pfad unangetastet (nutzt `...Impl` mit Default `false` → Frische-Skip bleibt aktiv, schützt weiter vor Rename mitten in concurrent Write).
**Verifikation:** neuer Test grün, Hybrid-Test grün (kein Regress), **623 Tests grün** (31 Dateien), tsc unverändert (9 pre-existing). Advisor-Gate vor Fix (verlangte Repro-Test statt Timing-Argument).
**Offen / bewusst nicht angefasst:**
- Gestashtes Crash-Debris (`stash@{0}`): enthält Revert von 08372f9/18eada9/98dc366 + log.old. Bei Bedarf inspizierbar/recoverbar; sonst irgendwann verwerfbar.
- 08372f9 (Passwort-Daemon-Reset) bewusst nicht neu aufgerollt (außerhalb dieses Goals, kein Hinweis auf Defekt).
- Untracked `*-postprocess/` + `fix-library-renames.mjs`: alte Experimente (Apr/Mai), unverändert gelassen.
---
## E. Mega-Debrid Account temporär deaktivieren (UI) — 2026-05-31
**Goal:** Einzelne Mega-Debrid-Accounts deaktivieren (statt löschen) → Rotation überspringt sie, nutzt die anderen.
**Befund:** Backend KOMPLETT vorhanden — `megaDebridDisabledAccountIds` (Typ/Defaults/Storage-Normalisierung) + Rotation-Skip (debrid.ts:1944) + Verfügbarkeits-Checks. Es fehlt NUR das UI-Toggle (Debrid-Link hat es bereits via keyStatsPopup). ID-Seam verifiziert: `getMegaDebridAccountId(login)` (trim+lowercase) ist auf beiden Seiten identisch → Test grün.
**Ansatz (B-kohärent):** Toggle in die bestehende Mega-Account-Liste im Bearbeiten-Dialog falten (draft-then-Save, KEIN Live-Persist → kohärent, minimal). Schritte:
1. [x] TDD: Test „skips a manually disabled Mega-Debrid account" (acc1 disabled → acc2) — grün gegen Backend.
2. [ ] `AccountDialogState`: Feld `megaDisabledIds: string[]`.
3. [ ] Dialog-Init (createAccountDialogState): aus `settings.megaDebridDisabledAccountIds`.
4. [ ] JSX Mega-Account-Liste: Aktivieren/Deaktivieren-Button + disabled-Styling pro Account.
5. [ ] Entfernen-Handler: ID auch aus megaDisabledIds entfernen.
6. [ ] `buildSettingsFromDialog` (megadebrid-api/web): `megaDebridDisabledAccountIds` aus Draft (gefiltert auf vorhandene Accounts) übernehmen.
7. [ ] Verifizieren: tsc unverändert, volle Suite grün, Toggle-Test grün.

View File

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

View File

@ -0,0 +1,150 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
// Mock only processVideoFile (the ffmpeg boundary); keep the real pure helpers
// (stripDualLangMarker / hasDualLangMarker / isRemuxableVideoFile) so the
// download-manager's selection + .DL.-rename wiring is exercised for real.
vi.mock("../src/main/video-processor", async (importActual) => {
const actual = await importActual<typeof import("../src/main/video-processor")>();
return { ...actual, processVideoFile: vi.fn(), resolveVideoTooling: vi.fn() };
});
import { DownloadManager } from "../src/main/download-manager";
import { defaultSettings } from "../src/main/constants";
import { createStoragePaths, emptySession } from "../src/main/storage";
import { shutdownItemLogs } from "../src/main/item-log";
import { shutdownPackageLogs } from "../src/main/package-log";
import { shutdownRenameLog } from "../src/main/rename-log";
import { processVideoFile, resolveVideoTooling, type VideoProcessResult } from "../src/main/video-processor";
const mockedProcess = processVideoFile as unknown as ReturnType<typeof vi.fn>;
const mockedTooling = resolveVideoTooling as unknown as ReturnType<typeof vi.fn>;
const tempDirs: string[] = [];
afterEach(() => {
mockedProcess.mockReset();
mockedTooling.mockReset();
shutdownItemLogs();
shutdownPackageLogs();
shutdownRenameLog();
for (const dir of tempDirs.splice(0)) {
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
}
});
function setup(keepGermanAudioOnly: boolean): { extractDir: string; manager: DownloadManager; pkg: any } {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-ga-"));
tempDirs.push(root);
const extractDir = path.join(root, "extract");
const stateDir = path.join(root, "state");
fs.mkdirSync(extractDir, { recursive: true });
fs.mkdirSync(stateDir, { recursive: true });
const manager = new DownloadManager(
{
...defaultSettings(),
token: "rd-token",
keepGermanAudioOnly,
germanAudioMode: "tag",
autoRename4sf4sj: false,
outputDir: path.join(root, "out"),
extractDir,
mkvLibraryDir: path.join(stateDir, "_mkv")
},
emptySession(),
createStoragePaths(stateDir)
);
const pkg: any = {
id: "ga-pkg-1",
name: "Test.Show.S01.GERMAN.DL.720p",
outputDir: path.join(root, "out", "Test.Show"),
extractDir,
status: "completed",
itemIds: [],
cancelled: false,
enabled: true,
priority: "normal",
createdAt: 0,
updatedAt: 0
};
// Default: ffmpeg/ffprobe "available" so the step proceeds to the (mocked)
// processVideoFile. Tests that need the no-tool path override this.
mockedTooling.mockResolvedValue({ ffmpeg: "ffmpeg", ffprobe: "ffprobe" });
return { extractDir, manager, pkg };
}
const DL_MKV = "Show.S01E01.German.DL.720p.x264.mkv";
const PLAIN_MKV = "Show.S01E02.German.1080p.x264.mkv";
const SAMPLE_DL = "Show.sample.DL.mkv";
const DL_AVI = "Show.S01E03.German.DL.avi";
function stage(extractDir: string): void {
for (const f of [DL_MKV, PLAIN_MKV, SAMPLE_DL, DL_AVI]) {
fs.writeFileSync(path.join(extractDir, f), "x");
}
}
describe("keepGermanAudioOnly integration", () => {
it("processes only .DL. mkv/mp4 and strips .DL. after a successful remux", async () => {
const { extractDir, manager, pkg } = setup(true);
stage(extractDir);
mockedProcess.mockResolvedValue({ action: "remuxed", reason: "german-tag", totalAudioTracks: 2, keptTrackIndex: 0 } as VideoProcessResult);
const n = await (manager as any).keepGermanAudioOnlyImpl(extractDir, pkg);
expect(mockedProcess).toHaveBeenCalledTimes(1);
expect(mockedProcess.mock.calls[0][0]).toBe(path.join(extractDir, DL_MKV));
expect(n).toBe(1);
const files = fs.readdirSync(extractDir);
expect(files).toContain("Show.S01E01.German.720p.x264.mkv"); // .DL. stripped
expect(files).not.toContain(DL_MKV);
expect(files).toContain(PLAIN_MKV); // non-.DL. untouched
expect(files).toContain(SAMPLE_DL); // sample skipped
expect(files).toContain(DL_AVI); // avi not remuxable, skipped
});
it("does nothing when the setting is off", async () => {
const { extractDir, manager, pkg } = setup(false);
stage(extractDir);
const n = await (manager as any).keepGermanAudioOnlyImpl(extractDir, pkg);
expect(n).toBe(0);
expect(mockedProcess).not.toHaveBeenCalled();
expect(fs.readdirSync(extractDir)).toContain(DL_MKV); // untouched
});
it("leaves the file fully untouched (name included) when no German track is found", async () => {
const { extractDir, manager, pkg } = setup(true);
stage(extractDir);
mockedProcess.mockResolvedValue({ action: "skipped-no-german", reason: "no-german-track", totalAudioTracks: 2 } as VideoProcessResult);
await (manager as any).keepGermanAudioOnlyImpl(extractDir, pkg);
expect(mockedProcess).toHaveBeenCalledTimes(1);
expect(fs.readdirSync(extractDir)).toContain(DL_MKV); // NOT renamed -> stays visible as unprocessed
});
it("still strips .DL. for a single-audio file (no remux needed)", async () => {
const { extractDir, manager, pkg } = setup(true);
stage(extractDir);
mockedProcess.mockResolvedValue({ action: "kept-single", reason: "single-german", totalAudioTracks: 1, keptTrackIndex: 0 } as VideoProcessResult);
const n = await (manager as any).keepGermanAudioOnlyImpl(extractDir, pkg);
expect(n).toBe(0); // not counted as a remux
expect(fs.readdirSync(extractDir)).toContain("Show.S01E01.German.720p.x264.mkv");
});
it("skips up front (no processVideoFile calls) and leaves files untouched when ffmpeg is missing", async () => {
const { extractDir, manager, pkg } = setup(true);
stage(extractDir);
mockedTooling.mockResolvedValue(null); // ffmpeg/ffprobe not found
const n = await (manager as any).keepGermanAudioOnlyImpl(extractDir, pkg);
expect(n).toBe(0);
expect(mockedProcess).not.toHaveBeenCalled(); // bailed before touching any file
expect(fs.readdirSync(extractDir)).toContain(DL_MKV); // untouched
});
});

View File

@ -0,0 +1,348 @@
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);
});
});