diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index b4f8d3e..894faf3 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -43,6 +43,7 @@ import { encryptBackup, decryptBackup } from "./backup-crypto"; import { buildBackupPayload, planBackupImport } from "./backup-payload"; import { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "./audit-log"; import { initAccountRotationLog, shutdownAccountRotationLog } from "./account-rotation-log"; +import { initConversionLog, shutdownConversionLog } from "./conversion-trace"; import { runStartupHealthCheck } from "./startup-health-check"; import { getDebugSetupCheck } from "./debug-setup"; import { buildLinkExportSelection, serializeLinkExportText } from "./link-export"; @@ -94,6 +95,7 @@ export class AppController { initItemLogs(this.storagePaths.baseDir); initAuditLog(this.storagePaths.baseDir); initAccountRotationLog(this.storagePaths.baseDir); + initConversionLog(this.storagePaths.baseDir); initRenameLog(this.storagePaths.baseDir); let desktopDir: string | null = null; try { @@ -800,6 +802,7 @@ public async checkDebridAccounts(): Promise { this.audit("INFO", "App beendet"); shutdownTraceLog(); shutdownAccountRotationLog(); + shutdownConversionLog(); shutdownAuditLog(); if (this.settings.historyRetentionMode === "session") { clearHistory(this.storagePaths); diff --git a/src/main/conversion-trace.ts b/src/main/conversion-trace.ts new file mode 100644 index 0000000..1470c85 --- /dev/null +++ b/src/main/conversion-trace.ts @@ -0,0 +1,190 @@ +import fs from "node:fs"; +import path from "node:path"; +import { AsyncLocalStorage } from "node:async_hooks"; +import { logTimestamp } from "./log-timestamp"; + +export interface ConversionPhase { + atMs: number; + phase: string; + provider?: string; + account?: string; + tokenState?: string; + queueWaitMs?: number; + workMs?: number; + outcome?: string; + detail?: string; +} + +export interface ConversionTrace { + startedAt: number; + itemId: string; + itemName: string; + link: string; + providerOrder: string; + notes: Record; + phases: ConversionPhase[]; +} + +const conversionContext = new AsyncLocalStorage(); + +function shortLink(link: string): string { + const raw = String(link || "").trim(); + return raw.length > 90 ? `${raw.slice(0, 90)}…` : raw; +} + +export function traceConversionPhase(phase: Omit): void { + const trace = conversionContext.getStore(); + if (!trace) { + return; + } + trace.phases.push({ ...phase, atMs: Date.now() - trace.startedAt }); +} + +export function traceConversionNote(key: string, value: string | number): void { + const trace = conversionContext.getStore(); + if (!trace) { + return; + } + trace.notes[key] = value; +} + +export function hasActiveConversionTrace(): boolean { + return conversionContext.getStore() !== undefined; +} + +export function formatConversionBlock( + trace: ConversionTrace, + outcome: string, + detail: string, + totalMs: number +): string { + const noteParts = Object.entries(trace.notes) + .map(([key, value]) => `${key}=${value}`) + .join(" "); + const header = `${logTimestamp()} [CONV] item=${trace.itemName || trace.itemId} | order=${trace.providerOrder || "?"}` + + ` | result=${outcome}${detail ? ` (${detail})` : ""} | total=${totalMs}ms${noteParts ? ` | ${noteParts}` : ""}` + + ` | link=${shortLink(trace.link)}`; + const lines = trace.phases.map((p) => { + const parts: string[] = []; + if (p.provider) parts.push(`provider=${p.provider}`); + if (p.account) parts.push(`account=${p.account}`); + if (p.tokenState) parts.push(`token=${p.tokenState}`); + if (typeof p.queueWaitMs === "number") parts.push(`queueWaitMs=${p.queueWaitMs}`); + if (typeof p.workMs === "number") parts.push(`workMs=${p.workMs}`); + if (p.outcome) parts.push(`outcome=${p.outcome}`); + if (p.detail) parts.push(`detail=${String(p.detail).replace(/\r?\n/g, "\\n")}`); + return ` +${p.atMs}ms ${p.phase}${parts.length ? ` | ${parts.join(" | ")}` : ""}`; + }); + return [header, ...lines].join("\n"); +} + +const CONVERSION_LOG_MAX_FILE_BYTES = Number(process.env.RD_CONVERSION_LOG_MAX_BYTES || 5 * 1024 * 1024); +const CONVERSION_LOG_RETENTION_DAYS = Number(process.env.RD_CONVERSION_LOG_RETENTION_DAYS || 14); + +let conversionLogPath: string | null = null; + +function rotateIfNeeded(filePath: string): void { + try { + const stat = fs.statSync(filePath); + if (stat.size < CONVERSION_LOG_MAX_FILE_BYTES) { + return; + } + const backup = `${filePath}.old`; + try { + fs.rmSync(backup, { force: true }); + } catch { + } + fs.renameSync(filePath, backup); + } catch { + } +} + +function cleanupOldBackup(filePath: string): void { + const backup = `${filePath}.old`; + try { + const stat = fs.statSync(backup); + const cutoff = Date.now() - CONVERSION_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000; + if (stat.mtimeMs < cutoff) { + fs.rmSync(backup, { force: true }); + } + } catch { + } +} + +export function initConversionLog(baseDir: string): void { + conversionLogPath = path.join(baseDir, "conversion.log"); + try { + fs.mkdirSync(path.dirname(conversionLogPath), { recursive: true }); + cleanupOldBackup(conversionLogPath); + if (!fs.existsSync(conversionLogPath)) { + fs.writeFileSync(conversionLogPath, "", "utf8"); + } + rotateIfNeeded(conversionLogPath); + if (!fs.existsSync(conversionLogPath)) { + fs.writeFileSync(conversionLogPath, "", "utf8"); + } + fs.appendFileSync(conversionLogPath, `=== Conversion Log Start: ${logTimestamp()} ===\n`, "utf8"); + } catch { + conversionLogPath = null; + } +} + +export function getConversionLogPath(): string | null { + if (!conversionLogPath) { + return null; + } + return fs.existsSync(conversionLogPath) ? conversionLogPath : null; +} + +export function shutdownConversionLog(): void { + if (!conversionLogPath) { + return; + } + try { + fs.appendFileSync(conversionLogPath, `=== Conversion Log Ende: ${logTimestamp()} ===\n`, "utf8"); + } catch { + } + conversionLogPath = null; +} + +function writeConversionBlock(block: string): void { + if (!conversionLogPath) { + return; + } + try { + rotateIfNeeded(conversionLogPath); + if (!fs.existsSync(conversionLogPath)) { + fs.writeFileSync(conversionLogPath, "", "utf8"); + } + fs.appendFileSync(conversionLogPath, `${block}\n`, "utf8"); + } catch { + } +} + +export async function runWithConversionTrace( + meta: { itemId: string; itemName: string; link: string; providerOrder: string }, + fn: () => Promise +): Promise { + const trace: ConversionTrace = { + startedAt: Date.now(), + itemId: meta.itemId, + itemName: meta.itemName, + link: meta.link, + providerOrder: meta.providerOrder, + notes: {}, + phases: [] + }; + let outcome = "OK"; + let detail = ""; + try { + const result = await conversionContext.run(trace, fn); + return result; + } catch (error) { + outcome = "FAIL"; + detail = String((error as { message?: string })?.message || error || "").replace(/^Error:\s*/i, "").slice(0, 160); + throw error; + } finally { + const totalMs = Date.now() - trace.startedAt; + writeConversionBlock(formatConversionBlock(trace, outcome, detail, totalMs)); + } +} diff --git a/src/main/debrid.ts b/src/main/debrid.ts index 9f8e0c1..07785d3 100644 --- a/src/main/debrid.ts +++ b/src/main/debrid.ts @@ -6,6 +6,7 @@ import { isMegaDebridResolveFailure, germanMegaDebridResolveReason } from "../sh import { APP_VERSION, REQUEST_RETRIES } from "./constants"; import { logger } from "./logger"; import { logAccountRotation } from "./account-rotation-log"; +import { traceConversionPhase } from "./conversion-trace"; import { RealDebridClient, UnrestrictedLink } from "./realdebrid"; import { MEGA_DEBRID_NO_SERVER_RE } from "./mega-web-fallback"; import { isMegaFileUrl, resolveMegaFilename } from "./mega-public-api"; @@ -1742,11 +1743,13 @@ class MegaDebridClient { const key = this.cacheKey; const cached = MegaDebridClient.cachedApiTokens.get(key); if (cached && cached.token && Date.now() - cached.at < 20 * 60 * 1000) { + traceConversionPhase({ phase: "token", provider: "megadebrid-api", tokenState: `cached(${Math.floor((Date.now() - cached.at) / 1000)}s)`, outcome: "ok" }); return cached.token; } const pending = MegaDebridClient.pendingConnects.get(key); if (pending) { + traceConversionPhase({ phase: "token", provider: "megadebrid-api", tokenState: "pending-join", outcome: "ok" }); return pending; } @@ -1762,6 +1765,7 @@ class MegaDebridClient { } private async doConnectApi(signal?: AbortSignal): Promise { + const connectStartedAt = Date.now(); const url = `${MEGA_DEBRID_API_BASE}?action=connectUser&login=${encodeURIComponent(this.login)}&password=${encodeURIComponent(this.password)}`; const response = await fetch(url, { headers: { "User-Agent": DEBRID_USER_AGENT }, @@ -1772,6 +1776,7 @@ class MegaDebridClient { if (response.status === 401 || response.status === 403) { this.clearTokenCache(); } + traceConversionPhase({ phase: "token", provider: "megadebrid-api", tokenState: "fresh-login", workMs: Date.now() - connectStartedAt, outcome: "error", detail: `HTTP ${response.status}` }); return null; } const payload = parseJsonSafe(text); @@ -1779,13 +1784,16 @@ class MegaDebridClient { if (payload && String(payload.response_code || "").toLowerCase().includes("token")) { this.clearTokenCache(); } + traceConversionPhase({ phase: "token", provider: "megadebrid-api", tokenState: "fresh-login", workMs: Date.now() - connectStartedAt, outcome: "error", detail: `response_code=${payload?.response_code || "?"} ${String(payload?.response_text || "").slice(0, 80)}`.trim() }); return null; } const token = String(payload.token || "").trim(); if (!token) { + traceConversionPhase({ phase: "token", provider: "megadebrid-api", tokenState: "fresh-login", workMs: Date.now() - connectStartedAt, outcome: "error", detail: "leeres Token" }); return null; } MegaDebridClient.cachedApiTokens.set(this.cacheKey, { token, at: Date.now() }); + traceConversionPhase({ phase: "token", provider: "megadebrid-api", tokenState: "fresh-login", workMs: Date.now() - connectStartedAt, outcome: "ok" }); return token; } @@ -1795,6 +1803,7 @@ class MegaDebridClient { return null; } + const getLinkStartedAt = Date.now(); const url = `${MEGA_DEBRID_API_BASE}?action=getLink&token=${encodeURIComponent(token)}`; const response = await fetch(url, { method: "POST", @@ -1810,14 +1819,17 @@ class MegaDebridClient { if (response.status === 401 || response.status === 403) { this.clearTokenCache(); } + traceConversionPhase({ phase: "api-getlink", provider: "megadebrid-api", workMs: Date.now() - getLinkStartedAt, outcome: "error", detail: `HTTP ${response.status}` }); return null; } const payload = parseJsonSafe(text); if (!payload || payload.response_code !== "ok") { - if (payload && String(payload.response_code || "").includes("token")) { + const tokenInvalidated = Boolean(payload && String(payload.response_code || "").includes("token")); + if (tokenInvalidated) { this.clearTokenCache(); } const errorText = String(payload?.response_text || "").trim(); + traceConversionPhase({ phase: "api-getlink", provider: "megadebrid-api", workMs: Date.now() - getLinkStartedAt, outcome: "error", detail: `response_code=${payload?.response_code || "?"}${tokenInvalidated ? " (token-cache-geleert)" : ""} ${errorText}`.trim() }); if (errorText) { throw new Error(`Mega-Debrid API: ${errorText}`); } @@ -1826,8 +1838,10 @@ class MegaDebridClient { const directUrl = String(payload.debridLink || "").trim(); if (!directUrl) { + traceConversionPhase({ phase: "api-getlink", provider: "megadebrid-api", workMs: Date.now() - getLinkStartedAt, outcome: "error", detail: "kein debridLink" }); return null; } + traceConversionPhase({ phase: "api-getlink", provider: "megadebrid-api", workMs: Date.now() - getLinkStartedAt, outcome: "ok" }); const fileName = String(payload.filename || "").trim() || filenameFromUrl(directUrl) || filenameFromUrl(link); return { directUrl, @@ -2004,6 +2018,7 @@ class MegaDebridClient { clearMegaDebridAccountCooldownState(cooldownKey); clearMegaDebridEmptyResponseStreak(cooldownKey); const elapsedMs = Date.now() - testStartedAt; + traceConversionPhase({ phase: "mega-account", provider: providerName.includes("API") ? "megadebrid-api" : "megadebrid-web", account: rotationLabel, workMs: elapsedMs, outcome: "ok" }); megaDebridStickyCount += 1; if (megaDebridStickyCount >= MEGA_DEBRID_STICKY_LINKS) { megaDebridRotationCursor = idx + 1; @@ -2037,6 +2052,14 @@ class MegaDebridClient { if (ranLongEnough) { setMegaDebridAccountCooldownState(cooldownKey, MEGA_DEBRID_ACCOUNT_COOLDOWN_MS, `Abbruch/Timeout nach ${Math.ceil(elapsedMs / 1000)}s`, "temporary"); } + traceConversionPhase({ + phase: "mega-account", + provider: providerName.includes("API") ? "megadebrid-api" : "megadebrid-web", + account: rotationLabel, + workMs: elapsedMs, + outcome: "aborted", + detail: `${abortText}${ranLongEnough ? ` cd=${Math.ceil(MEGA_DEBRID_ACCOUNT_COOLDOWN_MS / 1000)}s` : ""}` + }); failures.push(`Mega-Debrid${accountLabel}: ${abortText}`); logAccountRotation("WARN", providerName, rotationLabel, "TIMEOUT_COOLDOWN", { elapsedMs, @@ -2047,6 +2070,14 @@ class MegaDebridClient { throw new Error(`Mega-Debrid${accountLabel}: ${abortText}`); } const failure = MegaDebridClient.classifyAccountFailure(error); + traceConversionPhase({ + phase: "mega-account", + provider: providerName.includes("API") ? "megadebrid-api" : "megadebrid-web", + account: rotationLabel, + workMs: Date.now() - testStartedAt, + outcome: failure.fatal ? "fatal" : "failed", + detail: `${failure.message}${failure.cooldownMs > 0 ? ` cd=${Math.ceil(failure.cooldownMs / 1000)}s` : ""}` + }); failures.push(`Mega-Debrid${accountLabel}: ${failure.message}`); let parkUntilRestart = false; @@ -3780,9 +3811,12 @@ export class DebridService { continue; } + const providerStartedAt = Date.now(); try { logger.info(`Provider-Kette: versuche ${PROVIDER_LABELS[provider]}`); + traceConversionPhase({ phase: "chain-try", provider }); const result = await this.unrestrictViaProvider(settings, provider, link, signal); + traceConversionPhase({ phase: "chain-ok", provider, workMs: Date.now() - providerStartedAt, outcome: "ok" }); let fileName = result.fileName; if (isRapidgatorLink(link) && looksLikeOpaqueFilename(fileName || filenameFromUrl(link))) { const fromPage = await resolveRapidgatorFilename(link, signal); @@ -3799,9 +3833,17 @@ export class DebridService { } catch (error) { const errorText = compactErrorText(error); if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) { + traceConversionPhase({ phase: "chain-aborted", provider, workMs: Date.now() - providerStartedAt, outcome: "aborted", detail: errorText.slice(0, 120) }); throw error; } const nextProvider = order.slice(order.indexOf(provider) + 1).find((candidate) => this.isProviderSelectableFor(settings, candidate)); + traceConversionPhase({ + phase: "chain-failed", + provider, + workMs: Date.now() - providerStartedAt, + outcome: nextProvider ? "failover" : "exhausted", + detail: `${errorText.slice(0, 120)}${nextProvider ? ` → ${nextProvider}` : ""}` + }); if (nextProvider) { logger.warn(`Provider-Kette: ${PROVIDER_LABELS[provider]} fehlgeschlagen (${errorText}), Fallback auf ${PROVIDER_LABELS[nextProvider]}`); } else { diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 6a219bd..304c267 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -60,6 +60,7 @@ import { processVideoFile, resolveVideoTooling, stripDualLangMarker, hasDualLang import { sendNotification } from "./notify"; import { logger } from "./logger"; import { getRecentRotationEvents, runWithRotationItemSink, setRotationEventListener } from "./account-rotation-log"; +import { runWithConversionTrace, traceConversionPhase, traceConversionNote } from "./conversion-trace"; import type { RotationEvent } from "../shared/types"; import { ensureItemLog, getItemLogPath as getPersistedItemLogPath, logItemEvent as writeItemLogEvent } from "./item-log"; import { ensurePackageLog, getPackageLogPath as getPersistedPackageLogPath, logPackageEvent as writePackageLogEvent } from "./package-log"; @@ -7979,6 +7980,23 @@ export class DownloadManager extends EventEmitter { return count; } + private describeSlotOccupancy(): string { + let converting = 0; + let downloading = 0; + for (const active of this.activeTasks.values()) { + const activeItem = this.session.items[active.itemId]; + if (!activeItem) { + continue; + } + if (activeItem.status === "validating") { + converting += 1; + } else if (activeItem.status === "downloading") { + downloading += 1; + } + } + return `conv${converting}/dl${downloading}/active${this.activeTasks.size}/max${this.settings.maxParallel}`; + } + private getSerializedValidatingLimit(provider: DebridProvider | null): number { if (provider === "megadebrid-web") { const usableAccounts = getAvailableMegaDebridAccounts(this.settings) @@ -8760,7 +8778,30 @@ export class DownloadManager extends EventEmitter { const unrestrictedSignal = AbortSignal.any([active.abortController.signal, unrestrictTimeoutSignal]); let unrestricted; try { - unrestricted = await this.debridService.unrestrictLink(item.url, unrestrictedSignal); + unrestricted = await runWithConversionTrace( + { + itemId: item.id, + itemName: item.fileName || item.id, + link: item.url, + providerOrder: (this.settings.providerOrder || []).join(",") || String(this.getExpectedProviderForItem(item) || "?") + }, + async () => { + traceConversionNote("slots", this.describeSlotOccupancy()); + traceConversionNote("retry", Number(active.unrestrictRetries || 0)); + try { + return await this.debridService.unrestrictLink(item.url, unrestrictedSignal); + } catch (innerError) { + if (!active.abortController.signal.aborted && unrestrictTimeoutSignal.aborted) { + traceConversionPhase({ + phase: "caller-timeout", + outcome: "timeout", + detail: `Caller-Budget ${Math.ceil(getUnrestrictTimeoutMs() / 1000)}s erschoepft (siehe letzte Phase fuer in-flight Provider/Account)` + }); + } + throw innerError; + } + } + ); } catch (unrestrictError) { if (!active.abortController.signal.aborted && unrestrictTimeoutSignal.aborted) { this.recordProviderFailure(cooldownProvider); diff --git a/src/main/mega-web-fallback.ts b/src/main/mega-web-fallback.ts index 1757ac5..a9b2683 100644 --- a/src/main/mega-web-fallback.ts +++ b/src/main/mega-web-fallback.ts @@ -1,5 +1,6 @@ import { UnrestrictedLink } from "./realdebrid"; import { compactErrorText, filenameFromUrl, sleep } from "./utils"; +import { traceConversionPhase } from "./conversion-trace"; type MegaCredentials = { login: string; @@ -287,9 +288,18 @@ export class MegaWebFallback { throwIfAborted(signal); const waited = Date.now() - queuedAt; if (waited > QUEUE_WAIT_TIMEOUT_MS) { + traceConversionPhase({ phase: "web-queue", provider: "megadebrid-web", queueWaitMs: waited, outcome: "queue-timeout", detail: `${Math.floor(waited / 1000)}s in Web-Queue gewartet` }); throw new Error(`Mega-Web Queue-Timeout (${Math.floor(waited / 1000)}s gewartet)`); } - return job(); + const workStartedAt = Date.now(); + try { + const result = await job(); + traceConversionPhase({ phase: "web-queue", provider: "megadebrid-web", queueWaitMs: waited, workMs: Date.now() - workStartedAt, outcome: "ok" }); + return result; + } catch (jobError) { + traceConversionPhase({ phase: "web-queue", provider: "megadebrid-web", queueWaitMs: waited, workMs: Date.now() - workStartedAt, outcome: "error", detail: compactErrorText(jobError).slice(0, 100) }); + throw jobError; + } }; const prev = this.queues.get(key) ?? Promise.resolve(); const run = prev.then(guardedJob, guardedJob); diff --git a/src/main/support-bundle.ts b/src/main/support-bundle.ts index 29c5b79..cf18f25 100644 --- a/src/main/support-bundle.ts +++ b/src/main/support-bundle.ts @@ -3,6 +3,7 @@ import path from "node:path"; import AdmZip from "adm-zip"; import { APP_VERSION } from "./constants"; import { getAccountRotationLogPath } from "./account-rotation-log"; +import { getConversionLogPath } from "./conversion-trace"; import { getAuditLogPath } from "./audit-log"; import { getDebugSetupCheck } from "./debug-setup"; import { getLogFilePath } from "./logger"; @@ -191,6 +192,8 @@ export function buildSupportBundle(manager: DownloadManager, baseDir: string, op addFileIfExists(zip, getTraceLogPath() ? `${getTraceLogPath()}.old` : null, "logs/trace.log.old"); addFileIfExists(zip, getAccountRotationLogPath(), "logs/account-rotation.log"); addFileIfExists(zip, getAccountRotationLogPath() ? `${getAccountRotationLogPath()}.old` : null, "logs/account-rotation.log.old"); + addFileIfExists(zip, getConversionLogPath(), "logs/conversion.log"); + addFileIfExists(zip, getConversionLogPath() ? `${getConversionLogPath()}.old` : null, "logs/conversion.log.old"); const SUPPORT_BUNDLE_LOG_WINDOW_MS = 8 * 60 * 60 * 1000; addDirectoryIfExists(zip, path.join(baseDir, "session-logs"), "logs/session-logs"); diff --git a/tasks/todo.md b/tasks/todo.md index a4b7417..703ab81 100644 --- a/tasks/todo.md +++ b/tasks/todo.md @@ -1,287 +1,37 @@ -# Real-Debrid-Downloader — Tasks (Stand 2026-06-10) +# Massives Conversion-Logging (v1.7.213) + Failover-Fix -**Status:** Webhook-Audit ABGESCHLOSSEN + released (v1.7.195). Davor: Bug-Audit (v1.7.189/190), -Sichtbarkeits-Paket (v1.7.191-194). Verbleibende QoL-Kandidaten weiter unten. +## Problem (live belegt, 2026-06-17) +- Links haengen mit "Unrestrict Timeout nach 60s", R53/R64, halten Download-Slot → Stop-and-Go. +- Web-first: 1 globaler 60s-Timeout um die GANZE Provider-Kette → Web frisst das Budget, + API-Failover wird NIE versucht (debrid.ts 3801 `signal.aborted` → throw). Retry startet wieder bei Web. +- API-first laeuft "um einiges fluessiger" (User bestaetigt: API resolved UND downloaded auf dem Server). +- ABER: API-Token-Fehler ("Token error, please log-in") + "Login oder Unrestrict fehlgeschlagen" + → beide Accounts kriegen Cooldown → Doom-Loop. connectApi single-flightet Logins schon (pendingConnects), + also ist die Token-Ursache NICHT trivial → braucht Token-Lifecycle-Logging. ---- +## Kern-Blindstelle +Bestehende Logs (account-rotation.log) zeigen `elapsedMs` aber NICHT: +- queue-wait vs aktive Arbeit (war der 60s-Timeout Warten in der Queue oder echtes Arbeiten?) +- Token-Lifecycle (cacheHit/freshLogin/invalidation) → woher "Token error"? +- Provider-Ketten-Entscheidung pro Item (welche Provider, welches Budget, warum Stopp) +- Was war in-flight als der Caller-60s-Timeout feuerte (Provider/Account/Phase) + Slot-Belegung -## ✅ Webhook-Bug-Audit — RELEASED v1.7.195 (2026-06-10, Gitea + GitHub-Mirror 37430ba) +## Plan (Release 213 = NUR Logging, bewusst KEINE Verhaltensaenderung — damit das naechste Bundle das ECHTE aktuelle Verhalten zeigt) +- [x] `src/main/conversion-trace.ts`: AsyncLocalStorage-Trace + dedizierte `conversion.log`. EIN strukturierter Block pro Unrestrict-Versuch. +- [x] Wiring: init in app-controller, shutdown, support-bundle. +- [x] Instrumentiert (nur tracePhase-Calls, additiv, no-op ohne aktiven Trace): + - download-manager unrestrict-Boundary: runWithConversionTrace + Caller-Timeout-Attribution + describeSlotOccupancy. + - debrid.ts Provider-Kette: chain-try/chain-ok/chain-failed/chain-aborted (zeigt ob Failover feuert). + - debrid.ts Mega-Rotation: mega-account workMs + outcome (ok/failed/fatal/aborted) + cooldown. + - MegaDebridClient connectApi/doConnectApi/unrestrictViaApi: token cached/pending-join/fresh-login + connectMs + getLink response_code/text (DAS klaert "Token error"). + - mega-web-fallback runExclusive: web-queue queueWaitMs + workMs (DAS klaert ob 60s = Warten oder Arbeit). +- [x] Test: conversion-trace.test.ts (Formatter + ALS-Kontext). tsc unveraendert 6. +- [ ] Build + Suite gruen. Release 213 (Gitea + Mirror). -User-Goal: "intensiv prüfen obs Bugs gibt wodurch Webhooks nicht getriggert werden + drumrum". -Workflow: 6 Finder → 29 adversarial bestätigte Findings (0 widerlegt, 65 Non-Findings), -dedupliziert auf 16 Themen in 6 Fix-Commits. Volle Gate: tsc=6, 801 Tests (+9), self-check, build. +## DEFERRED auf 214 (erst NACH Logs, kein Blind-Fix mehr) +- 60s global → per-Provider-Budget (Failover feuert) — proven, aber erst messen: tritt der 60s ueberhaupt bei API-first auf, und ist es Queue oder Arbeit? +- API Token-Error Doom-Loop ("Token error, please log-in") — Mechanismus per conversion.log verifizieren, DANN fixen (evtl. per-Account-Serialisierung fuer API wie bei Web). +- Config-Realitaet an User: 2. Account (xe) lief abgelaufen/deaktiviert → 212-Parallelitaet griff nicht; API-first ist der schnelle Pfad. -### Gefixt (Commits f060d02, 99a2459, 8acb22d, 3fb9e85, 5f8b02e, 86d935e + Test-Commit) -1. **notify.ts Zustellung** (N5/RATELIMIT HIGH): Send-Queue seriell + 450ms-Gap (Discord - 5/2s-Bucket), 429 mit retry_after (Sek→ms), 5xx/Netz-Backoff-Retries, Body-Konsum, - surrogat-sichere 2000er-Kappung, Invalid-URL-Warnung. 17 notify-Tests. -2. **Terminal-Abdeckung** (N1/DEDUP-2 HIGH, N2 HIGH, N4): refreshPackageStatus benachrichtigt - JEDEN Failed-Übergang (success===0-Gate weg — Mixed-Paket mit letztem Fail = häufigste - Reihenfolge verlor den ❌) + History für Mixed; 3 Early-Return-Fehlerpfade (416/toter - Link/Debrid-Link) riefen refreshPackageStatus nie → Paket hing bis Neustart auf - downloading; skipItems finalisiert jetzt jedes terminal gewordene Paket. -3. **Dedup-Lifecycle** (DEDUP-1 HIGH, -3, -4): recoverRetryableItems + retryExtraction/extractNow - geben den Notify-Marker frei (Recovery-✅ kam sonst NIE); retryExtraction tritt Run unbedingt - bei; addTrailingPostProcessPackageIds erhält Run-Mitgliedschaft laufender Entpackungen an - allen 5 runPackageIds-Ersetz/Clear-Stellen; Marker-Freigabe bei endgültig gescheitertem Send. -4. **Run-Lifecycle** (RUN-1..4): stop() schickt "Durchlauf gestoppt"-Summary; Scheduler-Respawn - in finally heilt Stop→Start-Race (führerloser Run!); finishRun sagt "Downloads beendet + - Entpacken läuft noch" statt zu lügen; armScheduledStart beim Boot re-armt (geplanter Start - überlebte keinen Neustart; vergangene Zeit → log+clear statt Blind-Start). -5. **Settings/Security** (SET-2, SET-4): notifyUrl in importBackup-SENSITIVE_KEYS; - Support-Bundle notifications-Block (konfiguriert/plausibel/Toggles, ohne URL-Leak). -6. **Renderer** (R1, R2): Testen-Toast mahnt Speichern bei dirty Settings; Button disabled - während Quick-Action läuft (statt Klick still zu schlucken). - -### Bewusst NICHT gefixt (verifiziert, Begründung) -- **SET-3** (rememberToken=false wipt notifyUrl nicht): Webhook als Credential zu wipen würde - Benachrichtigungen für rememberToken=false-Setups brechen; Konsistenz-Trade-off akzeptiert. -- **R3** (URL als Klartext-Feld statt password): Sichtbarkeit hilft beim Prüfen/Debuggen, - Einzel-Operator-Setup. -- **R4** (Boot-Window Draft-Race): generisches, vorbestehendes Settings-Hydration-Muster, - nicht notify-spezifisch. -- **DEDUP-5** (immediate-Cleanup schrumpft Zähler in der ✅-Meldung): kosmetisch, Policy-Kombi - beim User nicht aktiv. **RUN-5** (Quit mid-run still): nach Prozesstod nicht zustellbar. - **RUN-6** (Dauer-Fallback 1s): kosmetisch. **N6**: durch Marker-Freigabe + URL-Warnung subsumiert. - ---- - -## ✅ QoL Sichtbarkeits-Paket — RELEASED v1.7.191 (2026-06-09, Gitea + GitHub-Mirror) -1. **Push-Benachrichtigungen** (e753ea1, v1.7.192 auf Discord-Webhook-JSON umgebaut — - {username, content}, Emoji-Titel, 2000-Zeichen-Cap): Settings notifyUrl + 3 Toggles; 3 Hooks - (Post-Process-Ende, refreshPackageStatus all-failed-Lücke, finishRun-Summary); Dedup-Set - Lifecycle wie historyRecordedPackages; Guard running||runPackageIds (Recovery pusht nicht). -2. **audioStripSummary am Paket** (2a1a554): PackageEntry-Feld + Status-Spalten-Badge - ("Tonspur: 5 OK · 1 ohne DE-Tag", Tooltip mit Datei-Details); storage-Whitelist + Delta-Hash. -3. **"Letzte Fehler anzeigen"** (be4d54a): Hilfe-Menü → Error-Ring-Snapshot im Dialog, - Bestätigen-Knopf = in Zwischenablage kopieren. -787 Tests, tsc=6, self-check+build grün; latest.yml-path verifiziert; Mirror f61fbc4 clean. - -## 🟡 QoL-Backlog — code-verifiziert mit Hook-Punkten (aus Ideation 2026-06-09) -Volle Details (exakte Zeilennummern, Verifier-Gotchas) im Ideation-Workflow-Output; Kurzform: -1. **Mega-Web Per-Account-Timeout** (M, Score 9) — 20s eigenes Timeout pro Account via - AbortSignal.any + Signal-IDENTITÄT (nie Error-Text!) im Rotations-catch VOR Z.~1979; - äußeres 60s-Budget bleibt. GOTCHA: tests/debrid.test.ts:1376 asserted Signal-Objekt-Identität - → Test lockern. Nur mode==='web'. -2. **ffmpeg-Pfad als Setting + Testen-Button** (M, Score 9) — ffmpegPath/ffprobePath, Setter + - resetVideoToolingCache(); GOTCHA: auch im KONSTRUKTOR setzen (setSettings reicht nicht nach - Neustart); IPC CHECK_VIDEO_TOOLING; UI unter keepGermanAudioOnly-Toggle. -3. **Bibliothek-Batch Tonspur für Bestand** (M, Score 9) — Button neben Toggle; Kandidaten-Filter - aus keepGermanAudioOnlyImpl als shared Predicate extrahieren (Overlap-Guard NICHT schwächen); - sequenziell + Single-In-Flight + AbortController + mtime-Age-Gate. -4. **Auto-Reset gefailter Items bei Tageswechsel** (S–M, Score 8) — Toggle, Default aus; - GOTCHA: NICHT synchron aus ensureProviderDailyUsageFresh (Re-Entranz: läuft in getSnapshot/ - Scheduler) → über 60s-runtimeStatsTimer (app-controller ~164); resetItems löscht Teil-Downloads. -5. **POST /control am Debug-Server** (M, Score 8) — add-links/start/stop/pause; GOTCHA: über - AppController routen (Audit-Log), nicht manager direkt; Start-Konflikt-Guard beachten. -6. **Mobile Statusseite GET /ui + Remote-Toggle** (M, Score 8) — handgeschriebenes flaches HTML, - pollt /status+/errors; Toggle schreibt debug_host.txt um; GOTCHA: stop+start Race (EADDRINUSE - → Server tot bis Neustart) → closeAllConnections/restartDebugServer. -7. **Mega-Cooldown-Status + aufheben** (S, Score 7) — listMegaDebridAccountCooldowns() export, - Snapshot-Feld, Badge + Button; GOTCHA: Keys sind `${id}:api|web` getrennt; lazy expiry. -8. **Status-Filter-Chips Downloads-Tab** (S, Score 7) — GOTCHAS: PackageCard-memo-Comparator - braucht neue Prop; Prädikat an 2 Stellen (6615 + visibleOrderIds 3546); VOR Rendering-Limit. -9. **Support-Bundle: Explorer zeigen + Desktop-Schnellweg** (S, Score 7) — filePath wird schon - returned, Toast ignoriert ihn nur; shell.showItemInFolder; preload-api.ts nicht vergessen. -Long-Tail: Low-Disk-Auto-Pause (S), Autostart/Tray (S). - ---- - -## ✅ ERLEDIGT — Bug-Audit 2026-06-08 (Multi-Agent find→verify, 18 bestätigt) - -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`, 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:`) 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). - ---- - -## 🟢 OFFEN — Backlog (optional, nie begonnen) - -### ✅ Mega-Web Account-Rotation überspringt Account 3 — GEFIXT 2026-06-08 (v1.7.187) -**Fix:** Ein Mega-Web-Account-Abbruch (geteiltes Timeout feuert während der Account lief) -setzt jetzt einen 2-min-Cooldown auf den Account (nur wenn er ≥8s lief, sonst = User-Cancel, -RD_MEGA_ABORT_MIN_RUN_MS env). Dadurch überspringt der download-manager-Retry diesen Account -und rotiert zum nächsten (debrid.ts, abort-Handling im Rotations-catch, vor classifyAccountFailure). -Log-Event `TIMEOUT_COOLDOWN` (gelb, "Timeout/Abbruch → nächster Account beim Retry") statt -rotem "fataler Fehler" (App.tsx:1141 Label). 2 Regressionstests (Cooldown gesetzt → Call 2 -rotiert; Quick-Abbruch → kein Cooldown). EHRLICH: fixt Korrektheit, NICHT Latenz — Account 1 -brennt weiter ~60s ins Timeout bevor der Retry auf Account 2 wechselt (instant-Failover bräuchte -per-Account-Timeout = größerer Eingriff, bewusst verschoben). Advisor-gegengeprüft. - -**(Ursprüngliche Analyse — Symptom & Mechanismus, zur Doku belassen)** -**Symptom (User):** 3 Mega-Debrid-Web-Accounts aktiv, Rotation pendelt aber nur zwischen -Account 1 ↔ 2 (bzw. nur Account 1), Account 3 (Su****xe) wird NIE probiert. - -**Verifizierter Mechanismus (Code):** -- Rotationsschleife `debrid.ts:1898`. Account 1 → "Mega-Web Antwort leer" → Cooldown 20s → - weiter zu Account 2. Account 2 → `aborted:debrid`. -- `classifyAccountFailure` (`debrid.ts:2036`) stuft JEDEN Abbruch als **fatal** ein → - `throw` (`debrid.ts:1991`) → Schleife bricht ab → **Account 3 nie erreicht.** -- Account 2 bekommt beim Fatal-Abbruch **keinen Cooldown** (cooldownMs:0). Beim - download-manager-Retry wird Account 1 (Cooldown) übersprungen, aber Account 2 (kein - Cooldown) ERNEUT vor Account 3 probiert → bricht wieder ab → ewiges 1↔2. -- Geteiltes 60s-Unrestrict-Timeout `download-manager.ts:8590` (`AbortSignal.any([taskAbort, - timeout(60s)])`) gilt für die GANZE Rotation, nicht pro Account. Mega-Web pollt intern bis - 180s (`mega-web-fallback.ts:235` + Poll-Loop `:371`). Sobald das geteilte 60s feuert, bleibt - das kombinierte Signal aborted → KEIN späterer Account kriegt im selben Pass eine echte Chance. - -**BESTÄTIGT 2026-06-08 (zweite Screenshots):** Account 1 läuft 10x rasch "erfolgreich" -(11:51:45–11:52:26), dann zwei "abgebrochen (aborted:debrid)" um 11:53:30 UND 11:54:30 — -**exakt 60s auseinander** = das geteilte 60s-Unrestrict-Timeout feuert (kein User-Stop, der -wiederholt sich nicht periodisch). Hier rotiert GAR NICHTS: Account 1 bricht ab → fatal → -Rotation stoppt sofort bei idx=0 → Account 2 und 3 werden NIE probiert. Bug eindeutig -bestätigt, elapsedMs nicht mehr nötig. Account 1 selbst ist gesund (10x ok) — Mega-Web hängt -nur sporadisch (no-server-Poll) bis ins 60s-Timeout. - -**Fix-Design (wenn bestätigt):** Pro-Account-Timeout-Budget, abgekoppelt vom geteilten Cap. -debrid.ts braucht das **cancel-only** Signal getrennt vom Timeout (kombiniertes Signal kann -beides nicht unterscheiden). Minimal-invasiv: optionaler `opts`-Param an `unrestrictLink` -({cancelSignal, perAttemptTimeoutMs}) — nur die Mega-Rotation liest ihn, andere Provider -unberührt (kombiniertes Signal bleibt). Pro Account: `AbortSignal.any([cancelSignal, -AbortSignal.timeout(perAttemptMs)])`. Abbruch-Logik: cancelSignal aborted → echter Stop; -eigenes Account-Timer gefeuert → non-fatal, Cooldown, weiter zum nächsten Account (inkl. 3). -**Regressionstest ZUERST** (3 Accounts, 1+2 failen/aborten → assert Account 3 kriegt TEST). -**Advisor-Gate** vor Eingriff (kritischer Unrestrict-Pfad, betrifft jeden Download). -Hinweis: Grundursache der leeren Antworten = Mega-Debrid Server/IP-Thema — Fix macht Rotation -nur FAIRER (alle Accounts drankommen), bringt aber keinen busy Server zum Antworten. - -### Features / UX (nach ROI) -App läuft headless auf Windows-Server → Nutzer sitzt nicht davor. - -1. [ ] **Push-Benachrichtigungen** (Discord/Telegram/ntfy) — S–M. Paket fertig/Fehler/Quota/Provider-down aufs Handy. Neuer `notifier.ts`, Hooks an Completion-Punkten. **Höchster ROI.** -2. [ ] **Fernsteuerung über Debug-Server** (POST-Endpunkte) — S–M. Server hat HTTP + Token-Auth, aber nur GET. POST `/control/add-links`, `/start`, `/stop`. -3. [ ] **URL-Duplikat-Erkennung beim Hinzufügen** — S. History-`urls` existiert, wird nie geprüft → versehentliche Re-Downloads. Warnen: "3 Links bereits geladen". -4. [ ] **Pre-Flight-Check + Bulk-Skip toter Links** — M. Vor Start Größe/Name/Online für ganze Queue, "alle offline überspringen". -5. [ ] **Speicherplatz-Vorabprüfung vor Start** — S. Aktuell keine Free-Space-Prüfung für Downloads → Abbruch mitten drin bei voller Platte. -6. [ ] **Konsolidierte Fehler-Ansicht** — M. Alle fehlgeschlagenen Items flach + Fehlertext + "alle erneut versuchen". (Daten dafür liegen jetzt teils in der Error-Ring aus v1.7.185.) -7. [ ] **Per-Provider-Statistik** — M. Rohdaten (`providerTotalUsageBytes`) existieren, werden nicht dargestellt. Welches Abo lohnt sich? -8. [ ] **Auto-Retry fehlgeschlagener Pakete nach Wartezeit** — S–M. Quota/Cooldown-Fails am nächsten Tag automatisch neu. -9. [ ] **Plex/Jellyfin Library-Refresh nach MKV-Move** — S. Gleicher Hook wie #1. -10. [ ] **Watch-Folder für DLC/Link-Auto-Import** — M. - -### Design-Richtung (Entscheidung steht aus) -4 Mockups in `design-mockups/` (index.html = Vergleich): **Aurora** (verfeinert dark, geringstes Risiko) · **Command** (Terminal/Ops, dicht) · **Vellum** (light editorial) · **Nebula** (neon). -→ Richtung wählen. Siehe Memory: design-taste (Anti-KI-Look) + design-direction (Ember-Wärme, flach/ehrlich). - -### Alte Audit-Items (2026-04-04, Status ggf. veraltet — VOR Fix gegen aktuellen Code verifizieren) -- [ ] Debrid-Link `maxDataHost` kühlt ganzen Key ab statt nur den Host -- [ ] Debrid-Link `fileNotAvailable` setzt Key auf "error" statt temporär -- [ ] AllDebrid: kein per-host-Cooldown für erschöpfte Quotas -- [ ] LinkSnappy: keine Auth-Dedup (parallele Requests rufen beide authenticate()) -- [ ] Extractor password-cache race (parallele Worker mutieren `packageLearnedPasswords`) -- [x] 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) → GEFIXT v1.7.196 - ---- - -## ✅ Hybrid-Race .DL.+Doppeltonspur — v1.7.196 (2026-06-10) - -User-Goal: Desperate Housewives S03E08/E11/E17 behielten .DL. + DE+EN-Tonspur trotz keepGermanAudioOnly. -Vermutung "langer Titel" widerlegt (gleich lange Namen). Support-Bundle: genau die 3 hatten 0× "Tonspur-Bereinigung OK". - -Ursache: Hybrid-Extraktor laeuft SEPARAT von der per-Paket-Kette (autoRename→keepGermanAudio→collect). -Waehrend langsamer ~3s-Remux (1,5GB) lief, entpackte er frische .DL.-MKVs in extractDir — nach dem -keepGermanAudio-Datei-Scan, vor dem Ketten-collect. collectMkvFilesToLibrary (deferFreshFiles=true) -verschob sie mit .DL. in die Library, bevor ihr Audio-Strip lief; raus aus extractDir fand der finale -Deferred-keepGermanAudio sie nie. 2000ms-Frische-Gate griff nicht (Dateien ~2,3s alt). - -Fix: collectMkvFilesToLibrary haelt bei deferFreshFiles && keepGermanAudioOnly remuxbare (.mkv/.mp4) -Dateien mit .DL.-Marker zurueck → bleiben in extractDir, spaetere Hybrid-Runde ODER finaler Deferred-Pass -bereinigt+sammelt. Praezise (eine .DL.avi wird nie remuxt → nicht zurueckhalten). Deferred-Pass -(deferFreshFiles=false) unberuehrt → legitim doppeltonige Datei ohne DE-Track wird weiter gesammelt. -Regressionstest tests/hybrid-collect-race.test.ts (3 Faelle). 804 Tests, tsc=6, self-check+build, -Gitea+Mirror 3664e78 verifiziert (beide draft=false, 4 Assets, latest.yml path==Asset). - -OFFEN: Altfiles in entpackt2 werden NICHT retroaktiv bereinigt — nur neue Downloads. Remediation der -3 Episoden nur auf Admin-Account (ploet-Session sieht Administrator-Profil nicht) oder via geplantes -"Bibliothek-Batch Tonspur"-QoL. - -## ℹ️ Webhook "0 von 5 getriggert" (2026-06-10) — KEIN Code-Bug -Test-Nachricht kam an (URL OK), aber alle 3 Notify-Toggles unchecked + "Einstellungen speichern" offen -(ungespeichert). Toggle-Default=aus. User muss Toggles aktivieren UND speichern. - ---- - -## ✅ ERLEDIGT — Archiv (Details in git-History + Memory) - -- **Erweitertes Logging** → released **v1.7.185** (Crash-Handler, Renderer-Fehler-IPC, RD_DEBUG-Level, Error-Ring + `/errors`, ENOSPC-Klassifizierung, Memory-Heartbeat). → Memory: extended-logging -- **Link-Prefetch** → untersucht (6-Agent) + **bewusst verworfen** (marginal bei maxParallel 8, Mega-Web single-flight). → Memory: link-prefetch-declined -- **Backup nur Settings** → v1.7.184 (`backupIncludeDownloads`-Toggle + 4 Selektions/Flicker-Fixes). → Memory: backup-settings-only -- **Account-Rotation-Overhaul** → v1.7.164–168 (Validity/Premium-Badges, Live-Panel, "Alle prüfen"). → Memory: account-rotation -- **Mega-Debrid-Account deaktivieren (UI)** → erledigt (Toggle im Edit-Dialog, im Code verifiziert 2026-06-07) -- **Bugs/Robustheit (Deferred-Pipeline H1/H2/H3/M1/M2/N1)** → v1.7.158/159; M3 bewusst übersprungen (Generation-Guard schützt Integrität bereits) -- **Deferred-Pfad Rename-Gap** → gefixt v1.7.162+ (finaler Deferred-Pass benennt frische Dateien vor Collect um; Repro-Test grün) -- **Repo-Privacy-Audit** → GitHub gelöscht+neu (saubere History), Gitea unberührt. → Memory: repo-privacy-audit - -### Bewusst NICHT angefasst (Crash-Debris / alte Experimente) -- Gestashtes Crash-Debris `stash@{0}` (Revert von 08372f9/18eada9/98dc366 + log.old) — bei Bedarf recoverbar, sonst verwerfbar -- Untracked `*-postprocess/` + `fix-library-renames.mjs` — alte Experimente (Apr/Mai) - -## ✅ Mega-Rotation Round-Robin — v1.7.197 (2026-06-11) - -User-Goal: "4 Accounts, rotiert nur zwischen 1-3, der 4. wird nie angefasst." -ACHTUNG: Beide Apps (Downloader + Multi-Hoster-Uploader) schreiben "account-rotation.log" — -das Log im User-Ordner war das des Uploaders (dort eigener Befund, Fix auf User-Wunsch -revertiert + geparkt). Downloader-Beweis kam aus rd_downloader.log im Support-Bundle: -First-Usable-Wins ab Account 1 — Acc1/2 dauernd am Tageslimit, Acc3 trug alles (1603/1603 OK), -Acc4 hatte null Events. Fix: Round-Robin-Cursor in unrestrictWithAccounts (Start beim Account -nach dem zuletzt getesteten, alle Skip-Checks unverändert, Reset im Test-Hook). Nebenfix: -Support-Bundle exportiert jetzt account-rotation.log(+.old). 2 Regressionstests, 806 grün. -Releases Gitea + Mirror e104e72 verifiziert. Debrid-Link-Keys bewusst First-Wins gelassen: -- [ ] Kandidat: Round-Robin auch für Debrid-Link-Keys (gleiches Muster), falls User dort - mal mehrere Keys nutzt +## Review +Logging-Release: pure Diagnose, null Verhaltensrisiko. Naechster Schritt: User reproduziert, schickt Bundle, conversion.log zeigt Queue-vs-Arbeit + Token-Lifecycle eindeutig → praeziser Fix in 214. diff --git a/tests/conversion-trace.test.ts b/tests/conversion-trace.test.ts new file mode 100644 index 0000000..54b1bfe --- /dev/null +++ b/tests/conversion-trace.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import { + formatConversionBlock, + hasActiveConversionTrace, + runWithConversionTrace, + traceConversionPhase, + type ConversionTrace +} from "../src/main/conversion-trace"; + +describe("formatConversionBlock", () => { + it("renders a header with verdict + total and one indented line per phase", () => { + const trace: ConversionTrace = { + startedAt: 1000, + itemId: "id1", + itemName: "tvs-foo.part5.rar", + link: "https://rapidgator.net/file/abc/tvs-foo.part5.rar.html", + providerOrder: "megadebrid-api,megadebrid-web", + notes: { slots: "conv2/dl6/max8" }, + phases: [ + { atMs: 0, phase: "chain-try", provider: "megadebrid-api" }, + { atMs: 5, phase: "token", provider: "megadebrid-api", account: "2/2(e3)", tokenState: "fresh", workMs: 812, outcome: "ok" }, + { atMs: 820, phase: "api-getlink", provider: "megadebrid-api", account: "2/2(e3)", workMs: 634, outcome: "ok" } + ] + }; + const block = formatConversionBlock(trace, "OK", "", 1450); + + const lines = block.split("\n"); + expect(lines[0]).toContain("[CONV]"); + expect(lines[0]).toContain("item=tvs-foo.part5.rar"); + expect(lines[0]).toContain("result=OK"); + expect(lines[0]).toContain("total=1450ms"); + expect(lines[0]).toContain("slots=conv2/dl6/max8"); + expect(lines).toHaveLength(4); + expect(lines[2]).toContain("+5ms token"); + expect(lines[2]).toContain("token=fresh"); + expect(lines[2]).toContain("workMs=812"); + }); + + it("includes the failure detail in the header verdict", () => { + const trace: ConversionTrace = { + startedAt: 0, itemId: "i", itemName: "x", link: "l", providerOrder: "megadebrid-web", notes: {}, + phases: [{ atMs: 60000, phase: "caller-timeout", provider: "megadebrid-web", outcome: "timeout", detail: "Unrestrict Timeout nach 60s" }] + }; + const block = formatConversionBlock(trace, "FAIL", "Unrestrict Timeout nach 60s", 60003); + expect(block.split("\n")[0]).toContain("result=FAIL (Unrestrict Timeout nach 60s)"); + expect(block).toContain("caller-timeout"); + }); +}); + +describe("conversion trace context", () => { + it("traceConversionPhase is a no-op outside an active trace and does not throw", () => { + expect(hasActiveConversionTrace()).toBe(false); + expect(() => traceConversionPhase({ phase: "orphan" })).not.toThrow(); + }); + + it("activates an ambient trace across awaits inside runWithConversionTrace", async () => { + expect(hasActiveConversionTrace()).toBe(false); + const seen = await runWithConversionTrace( + { itemId: "i", itemName: "n", link: "l", providerOrder: "megadebrid-api" }, + async () => { + const before = hasActiveConversionTrace(); + traceConversionPhase({ phase: "chain-try", provider: "megadebrid-api" }); + await Promise.resolve(); + const afterAwait = hasActiveConversionTrace(); + return before && afterAwait; + } + ); + expect(seen).toBe(true); + expect(hasActiveConversionTrace()).toBe(false); + }); +});