diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 18ef12d..09ab8e1 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -7,6 +7,7 @@ import { AllDebridHostInfo, AppSettings, DebridProvider, + AudioStripSummary, DownloadItem, DownloadStats, DownloadSummary, @@ -2189,7 +2190,7 @@ export class DownloadManager extends EventEmitter { } private buildPackageHash(pkg: PackageEntry): string { - return `${pkg.updatedAt}|${pkg.status}|${pkg.name}|${pkg.enabled ? 1 : 0}|${pkg.cancelled ? 1 : 0}|${pkg.priority || ""}|${pkg.itemIds.length}|${pkg.postProcessLabel || ""}`; + return `${pkg.updatedAt}|${pkg.status}|${pkg.name}|${pkg.enabled ? 1 : 0}|${pkg.cancelled ? 1 : 0}|${pkg.priority || ""}|${pkg.itemIds.length}|${pkg.postProcessLabel || ""}|${pkg.audioStripSummary?.at || 0}`; } public getSnapshotForEmit(forceFull = false): UiSnapshot { @@ -3951,6 +3952,28 @@ export class DownloadManager extends EventEmitter { this.logRenameProcess(pkg, "INFO", "audio-strip", "Tonspur-Bereinigung gestartet", { extractDir, candidates: targets.length, mode: this.settings.germanAudioMode }); } + const summary: AudioStripSummary = { + at: nowMs(), + candidates: targets.length, + remuxed: 0, + keptSingle: 0, + skippedNoGerman: 0, + skippedNoTool: 0, + failed: 0, + files: [] + }; + const recordFile = (name: string, action: string, reason: string, languages?: string): void => { + if (summary.files.length < 100) { + summary.files.push({ name, action, reason, ...(languages ? { languages } : {}) }); + } + }; + const writeSummary = (): void => { + if (pkg) { + pkg.audioStripSummary = summary; + pkg.updatedAt = nowMs(); + } + }; + // 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(); @@ -3959,6 +3982,11 @@ export class DownloadManager extends EventEmitter { if (pkg) { this.logRenameProcess(pkg, "WARN", "audio-strip", "Tonspur-Bereinigung uebersprungen: ffmpeg/ffprobe nicht gefunden", { candidates: targets.length }); } + summary.skippedNoTool = targets.length; + for (const p of targets) { + recordFile(path.basename(p), "skipped-no-tool", "ffmpeg/ffprobe nicht gefunden"); + } + writeSummary(); return 0; } logger.info(`Tonspur-Bereinigung: ffmpeg=${tooling.ffmpeg} ffprobe=${tooling.ffprobe}`); @@ -3992,18 +4020,29 @@ export class DownloadManager extends EventEmitter { ...(result.error ? { error: result.error } : {}) }, resolved.item, resolved.matchedBy); } + recordFile(sourceName, result.action, result.error ? `${result.reason} — ${result.error}` : result.reason, langs || undefined); // 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; + summary.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; + summary.remuxed += 1; logger.info(`Tonspur-Bereinigung OK: ${sourceName} — Spur ${result.keptTrackIndex} behalten (${langs || "?"})`); + } else if (result.action === "kept-single") { + summary.keptSingle += 1; } else if (result.action === "skipped-no-german") { + summary.skippedNoGerman += 1; logger.info(`Tonspur-Bereinigung uebersprungen (kein Deutsch-Tag, Spuren: ${langs || "?"}): ${sourceName}`); } else if (result.action === "skipped-no-space") { + summary.failed += 1; logger.warn(`Tonspur-Bereinigung uebersprungen (zu wenig Speicher): ${sourceName}`); + } else if (result.action === "skipped-no-tool") { + summary.skippedNoTool += 1; + } else { + summary.failed += 1; } // Only strip ".DL." once the file is confirmed German-only (remuxed) or // already single-track. Skips/errors leave the file fully untouched so the @@ -4012,6 +4051,7 @@ export class DownloadManager extends EventEmitter { await this.stripDualLangFromFileName(sourcePath, pkg); } } + writeSummary(); 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 }); diff --git a/src/main/storage.ts b/src/main/storage.ts index c3a59d1..647ef54 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -3,7 +3,7 @@ import fsp from "node:fs/promises"; import path from "node:path"; import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys"; import { getMegaDebridAccountIds } from "../shared/mega-debrid-accounts"; -import { AppSettings, BandwidthScheduleEntry, DebridAccountStatus, DebridFallbackProvider, DebridProvider, DownloadItem, DownloadStatus, HistoryEntry, HistoryRetentionMode, PackageEntry, PackagePriority, SessionState } from "../shared/types"; +import { AppSettings, AudioStripSummary, BandwidthScheduleEntry, DebridAccountStatus, DebridFallbackProvider, DebridProvider, DownloadItem, DownloadStatus, HistoryEntry, HistoryRetentionMode, PackageEntry, PackagePriority, SessionState } from "../shared/types"; import { getProviderUsageDayKey } from "../shared/provider-daily-limits"; import { defaultSettings } from "./constants"; import { logger } from "./logger"; @@ -589,6 +589,37 @@ function asRecord(value: unknown): Record | null { return value as Record; } +function normalizeAudioStripSummary(raw: unknown): AudioStripSummary | undefined { + const parsed = asRecord(raw); + if (!parsed) { + return undefined; + } + const files = Array.isArray(parsed.files) + ? parsed.files.slice(0, 100).flatMap((entry) => { + const file = asRecord(entry); + if (!file) { + return []; + } + const name = asText(file.name); + if (!name) { + return []; + } + const languages = asText(file.languages); + return [{ name, action: asText(file.action), reason: asText(file.reason), ...(languages ? { languages } : {}) }]; + }) + : []; + return { + at: clampNumber(parsed.at, 0, 0, Number.MAX_SAFE_INTEGER), + candidates: clampNumber(parsed.candidates, 0, 0, 1_000_000), + remuxed: clampNumber(parsed.remuxed, 0, 0, 1_000_000), + keptSingle: clampNumber(parsed.keptSingle, 0, 0, 1_000_000), + skippedNoGerman: clampNumber(parsed.skippedNoGerman, 0, 0, 1_000_000), + skippedNoTool: clampNumber(parsed.skippedNoTool, 0, 0, 1_000_000), + failed: clampNumber(parsed.failed, 0, 0, 1_000_000), + files + }; +} + function readSettingsFile(filePath: string): AppSettings | null { try { const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as AppSettings; @@ -689,6 +720,7 @@ export function normalizeLoadedSession(raw: unknown): SessionState { cancelled: Boolean(pkg.cancelled), enabled: pkg.enabled === undefined ? true : Boolean(pkg.enabled), priority: VALID_PACKAGE_PRIORITIES.has(asText(pkg.priority)) ? asText(pkg.priority) as PackagePriority : "normal", + audioStripSummary: normalizeAudioStripSummary(pkg.audioStripSummary), downloadStartedAt: clampNumber(pkg.downloadStartedAt, 0, 0, Number.MAX_SAFE_INTEGER), downloadCompletedAt: clampNumber(pkg.downloadCompletedAt, 0, 0, Number.MAX_SAFE_INTEGER), createdAt: clampNumber(pkg.createdAt, now, 0, Number.MAX_SAFE_INTEGER), diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 0f44695..45e5082 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -5,6 +5,7 @@ import type { AllDebridHostInfo, AppSettings, AppTheme, + AudioStripSummary, BandwidthScheduleEntry, DebugSetupCheckResult, DebridFallbackProvider, @@ -984,6 +985,23 @@ function extractHoster(url: string): string { } catch { return ""; } } +function formatAudioStripSummary(summary: AudioStripSummary): { text: string; tooltip: string; attention: boolean } { + const parts: string[] = []; + const ok = summary.remuxed + summary.keptSingle; + if (ok > 0) parts.push(`${ok} OK`); + if (summary.skippedNoGerman > 0) parts.push(`${summary.skippedNoGerman} ohne DE-Tag`); + if (summary.skippedNoTool > 0) parts.push("ffmpeg fehlt"); + if (summary.failed > 0) parts.push(`${summary.failed} Fehler`); + const tooltip = summary.files + .map((f) => `${f.name}: ${f.action} (${f.reason}${f.languages ? `, Spuren: ${f.languages}` : ""})`) + .join("\n"); + return { + text: `Tonspur: ${parts.join(" · ") || "—"}`, + tooltip, + attention: summary.skippedNoGerman > 0 || summary.skippedNoTool > 0 || summary.failed > 0 + }; +} + const settingsSubTabs: { key: SettingsSubTab; label: string }[] = [ { key: "allgemein", label: "Allgemein" }, { key: "accounts", label: "Accounts" }, @@ -6594,9 +6612,12 @@ const PackageCard = memo(function PackageCard({ pkg, items, packageSpeed, stripe case "prio": return ( {pkg.priority === "high" ? "Hoch" : pkg.priority === "low" ? "Niedrig" : ""} ); - case "status": return ( - [{done}/{total}{done === total && total > 0 ? " - Done" : ""}{failed > 0 ? ` | ${failed} Fehler` : ""}{cancelled > 0 ? ` | ${cancelled} abgebr.` : ""}]{pkg.postProcessLabel ? ` - ${pkg.postProcessLabel}` : ""} - ); + case "status": { + const audioStrip = pkg.audioStripSummary ? formatAudioStripSummary(pkg.audioStripSummary) : null; + return ( + [{done}/{total}{done === total && total > 0 ? " - Done" : ""}{failed > 0 ? ` | ${failed} Fehler` : ""}{cancelled > 0 ? ` | ${cancelled} abgebr.` : ""}]{pkg.postProcessLabel ? ` - ${pkg.postProcessLabel}` : ""}{audioStrip ? {` · ${audioStrip.text}`} : null} + ); + } case "speed": return ( {packageSpeed > 0 ? formatSpeedMbps(packageSpeed) : ""} ); diff --git a/src/renderer/styles.css b/src/renderer/styles.css index 8d9d5a5..ed764d9 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -736,6 +736,14 @@ body, padding-right: 12px; } +.pkg-audio-strip { + color: var(--muted); +} + +.pkg-audio-strip-warn { + color: var(--danger); +} + .progress-size { display: block; position: relative; diff --git a/src/shared/types.ts b/src/shared/types.ts index afaae60..93e31c2 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -181,6 +181,24 @@ export interface DownloadItem { onlineStatus?: "online" | "offline" | "checking"; } +export interface AudioStripFileResult { + name: string; + action: string; + reason: string; + languages?: string; +} + +export interface AudioStripSummary { + at: number; + candidates: number; + remuxed: number; + keptSingle: number; + skippedNoGerman: number; + skippedNoTool: number; + failed: number; + files: AudioStripFileResult[]; +} + export interface PackageEntry { id: string; name: string; @@ -192,6 +210,7 @@ export interface PackageEntry { enabled: boolean; priority?: PackagePriority; postProcessLabel?: string; + audioStripSummary?: AudioStripSummary; downloadStartedAt?: number; downloadCompletedAt?: number; createdAt: number; diff --git a/tests/german-audio-integration.test.ts b/tests/german-audio-integration.test.ts index f5c1679..bc58f0f 100644 --- a/tests/german-audio-integration.test.ts +++ b/tests/german-audio-integration.test.ts @@ -146,5 +146,20 @@ describe("keepGermanAudioOnly integration", () => { expect(n).toBe(0); expect(mockedProcess).not.toHaveBeenCalled(); // bailed before touching any file expect(fs.readdirSync(extractDir)).toContain(DL_MKV); // untouched + expect(pkg.audioStripSummary).toMatchObject({ candidates: 1, skippedNoTool: 1, remuxed: 0 }); + expect(pkg.audioStripSummary.files[0]).toMatchObject({ name: DL_MKV, action: "skipped-no-tool" }); + }); + + it("stores a per-package summary with counts and file details", async () => { + const { extractDir, manager, pkg } = setup(true); + stage(extractDir); + mockedProcess.mockResolvedValue({ action: "skipped-no-german", reason: "no-german-track", totalAudioTracks: 2, audioLanguages: ["eng", "fre"] } as VideoProcessResult); + + await (manager as any).keepGermanAudioOnlyImpl(extractDir, pkg); + + expect(pkg.audioStripSummary).toMatchObject({ candidates: 1, skippedNoGerman: 1, remuxed: 0, failed: 0 }); + expect(pkg.audioStripSummary.files).toHaveLength(1); + expect(pkg.audioStripSummary.files[0]).toMatchObject({ name: DL_MKV, action: "skipped-no-german", reason: "no-german-track", languages: "eng,fre" }); + expect(pkg.updatedAt).toBeGreaterThan(0); // bumped so the snapshot delta picks it up }); });