diff --git a/src/main/debrid.ts b/src/main/debrid.ts index 1bc233a..6138584 100644 --- a/src/main/debrid.ts +++ b/src/main/debrid.ts @@ -423,6 +423,106 @@ export function getMegaDebridAccountCooldownState( }; } +export interface ProviderRuntimeCooldown { + untilMs: number; + remainingMs: number; + message: string; + category: string; + untilRestart?: boolean; +} + +export interface ProviderRuntimeSnapshot { + capturedAtMs: number; + megaDebrid: { + rotationCursor: number; + stickyCount: number; + accounts: Array<{ + key: string; + cooldown: ProviderRuntimeCooldown | null; + inFlight: number; + emptyResponseStreak: number; + }>; + }; + debridLink: { + keys: Array<{ + keyId: string; + cooldown: ProviderRuntimeCooldown | null; + runtimeStatus: { state: string; detail: string; updatedAt: number } | null; + }>; + hostCooldowns: Array<{ key: string; cooldown: ProviderRuntimeCooldown }>; + }; +} + +export function getProviderRuntimeSnapshot(now = Date.now()): ProviderRuntimeSnapshot { + const megaKeys = new Set([ + ...megaDebridAccountCooldowns.keys(), + ...megaDebridInFlight.keys(), + ...megaDebridEmptyResponseStreaks.keys() + ]); + const megaAccounts = [...megaKeys].sort().map((key) => { + const detail = megaDebridAccountCooldowns.get(key); + return { + key, + cooldown: detail + ? { + untilMs: detail.until, + remainingMs: Math.max(0, detail.until - now), + message: detail.message, + category: detail.category, + untilRestart: detail.untilRestart === true + } + : null, + inFlight: megaDebridInFlight.get(key) ?? 0, + emptyResponseStreak: megaDebridEmptyResponseStreaks.get(key) ?? 0 + }; + }); + + const dlKeyIds = new Set([ + ...debridLinkKeyCooldowns.keys(), + ...debridLinkKeyRuntimeStatuses.keys() + ]); + const dlKeys = [...dlKeyIds].sort().map((keyId) => { + const until = Number(debridLinkKeyCooldowns.get(keyId) || 0); + const detail = debridLinkKeyCooldownDetails.get(keyId); + const status = debridLinkKeyRuntimeStatuses.get(keyId) || null; + return { + keyId, + cooldown: until > 0 + ? { + untilMs: until, + remainingMs: Math.max(0, until - now), + message: detail?.message ?? "", + category: detail?.category ?? "temporary" + } + : null, + runtimeStatus: status ? { state: status.state, detail: status.detail, updatedAt: status.updatedAt } : null + }; + }); + + const dlHostCooldowns = [...debridLinkKeyHostCooldowns].map(([key, until]) => { + const detail = debridLinkKeyHostCooldownDetails.get(key); + return { + key, + cooldown: { + untilMs: until, + remainingMs: Math.max(0, until - now), + message: detail?.message ?? "", + category: detail?.category ?? "temporary" + } + }; + }); + + return { + capturedAtMs: now, + megaDebrid: { + rotationCursor: megaDebridRotationCursor, + stickyCount: megaDebridStickyCount, + accounts: megaAccounts + }, + debridLink: { keys: dlKeys, hostCooldowns: dlHostCooldowns } + }; +} + const LINKSNAPPY_API_BASE = "https://linksnappy.com/api"; const PROVIDER_LABELS: Record = { diff --git a/src/main/debug-server.ts b/src/main/debug-server.ts index cba58a1..10a74de 100644 --- a/src/main/debug-server.ts +++ b/src/main/debug-server.ts @@ -15,6 +15,8 @@ import { createStoragePaths, loadHistory, loadSettings } from "./storage"; import { buildAccountSummary, buildRedactedSettingsPayload, buildStatsPayload, summarizeHistoryEntry } from "./support-data"; import { buildSupportBundle, getSupportBundleDefaultFileName } from "./support-bundle"; import { getTraceConfig, getTraceConfigPath, getTraceLogPath, logTraceEvent, setTraceEnabled, updateTraceConfig } from "./trace-log"; +import { getConversionLogPath } from "./conversion-trace"; +import { getProviderRuntimeSnapshot } from "./debrid"; import { getWindowsHostDiagnostics } from "./windows-host-diagnostics"; import type { DownloadManager } from "./download-manager"; import type { DownloadItem, PackageEntry, UiSnapshot } from "../shared/types"; @@ -43,12 +45,14 @@ const DEBUG_ENDPOINTS: DebugEndpointDescriptor[] = [ { method: "GET", path: "/logs/rename", queryExample: "lines=100&grep=keyword", description: "Reads the dedicated rename and MKV move log." }, { method: "GET", path: "/logs/trace", queryExample: "lines=100&grep=keyword", description: "Reads the optional support trace log." }, { method: "GET", path: "/logs/session", queryExample: "lines=100&grep=keyword", description: "Reads the session log tail." }, + { method: "GET", path: "/logs/conversion", queryExample: "lines=100&grep=keyword", description: "Reads the per-item link conversion/unrestrict lifecycle log (token, API getLink, web, account rotation, aborts with timings)." }, { method: "GET", path: "/logs/package", queryExample: "package=Release&lines=100&grep=keyword", description: "Reads the package log for a specific package name or id." }, { method: "GET", path: "/logs/item", queryExample: "item=episode.part2.rar&lines=100&grep=keyword", description: "Reads the item log for a specific file name or item id." }, { method: "GET", path: "/errors", queryExample: "level=ERROR&limit=100", description: "Returns the in-memory ring of the most recent WARN/ERROR log lines." }, { method: "GET", path: "/trace/config", queryExample: "enable=1¬e=support&durationMinutes=120", description: "Reads or updates the support trace configuration." }, { method: "GET", path: "/settings", description: "Returns a redacted settings snapshot without raw secrets." }, { method: "GET", path: "/accounts", description: "Returns a redacted account/provider configuration summary." }, + { method: "GET", path: "/providers", description: "Live provider runtime state: per-account/key cooldowns (until/remaining/reason/category), in-flight depth, Mega rotation cursor, empty-response streaks. The 'why is it cooling down right now' view." }, { method: "GET", path: "/stats", description: "Returns live session stats plus persisted all-time totals." }, { method: "GET", path: "/history", queryExample: "limit=50&status=completed", description: "Returns history entries with optional filters." }, { method: "GET", path: "/status", description: "Returns a live high-level status overview." }, @@ -356,6 +360,7 @@ function buildAiManifest(baseDir: string): Record { "Call /meta first to confirm the server is reachable and to re-read the endpoint list.", "Use /self-check or /debug/setup to quickly verify whether token, host, manifest, trace, disk space, and log sizes are in a good support state.", "Use /diagnostics for an overview, then drill into /logs/item, /logs/package, /logs/rename, /status, /packages, /items, /settings, /accounts, /stats, /history, or /logs/trace.", + "For provider stalls/cooldowns, call /providers for the live cooldown state (until/remaining/reason per account/key) and /logs/conversion for the per-item resolve lifecycle (token, API, web, rotation, aborts with timings).", "If a full handoff is needed, download /support/bundle as a ZIP." ], auth: { @@ -707,6 +712,25 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi return; } + if (pathname === "/logs/conversion") { + const count = normalizeLinesParam(url.searchParams.get("lines"), 100); + const grep = url.searchParams.get("grep") || ""; + const logPath = getConversionLogPath(); + const lines = logPath ? filterLines(readLogTailFromFile(logPath, count), grep) : []; + jsonResponse(res, 200, { + path: logPath, + available: Boolean(logPath), + lines, + count: lines.length + }); + return; + } + + if (pathname === "/providers") { + jsonResponse(res, 200, getProviderRuntimeSnapshot()); + return; + } + if (pathname === "/trace/config") { const patch: Record = {}; const enabled = toBooleanQuery(url.searchParams.get("enable")); @@ -992,6 +1016,7 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi settings: buildRedactedSettingsPayload(readSupportSettings()), stats: buildStatsPayload(snapshot), accounts: buildAccountSummary(readSupportSettings()), + providers: getProviderRuntimeSnapshot(), history: { total: readSupportHistory().length, recent: readSupportHistory() @@ -1023,6 +1048,10 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi path: sessionLogPath, lines: filterLines(readLogTailFromFile(sessionLogPath, lineCount), grep) }, + conversion: { + path: getConversionLogPath(), + lines: getConversionLogPath() ? filterLines(readLogTailFromFile(getConversionLogPath() as string, lineCount), grep) : [] + }, package: selectedPackage ? { path: packageLogPath, lines: filterLines(readLogTailFromFile(packageLogPath, lineCount), grep) diff --git a/tests/debrid.test.ts b/tests/debrid.test.ts index 042b00d..6a2495d 100644 --- a/tests/debrid.test.ts +++ b/tests/debrid.test.ts @@ -4,7 +4,7 @@ import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys"; import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts"; import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits"; import { isMegaDebridTransientResolveFailure } from "../src/shared/mega-debrid-errors"; -import { classifyMegaDebridAccountFailureForTests, clearMegaDebridEmptyResponseStreak, DebridService, extractRapidgatorFilenameFromHtml, fetchAllDebridHostInfo, fetchDebridLinkHostLimits, filenameFromRapidgatorUrlPath, getDebridLinkKeyCooldownStateForTests, getDebridLinkKeyRuntimeStateForTests, getMegaDebridAccountCooldownState, leadProviderChainWith, MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART, MEGA_DEBRID_STICKY_LINKS, normalizeResolvedFilename, primeMegaDebridUntilRestartForTests, recordMegaDebridEmptyResponseStreak, resetDebridLinkRuntimeStateForTests, resetMegaDebridRuntimeStateForTests } from "../src/main/debrid"; +import { classifyMegaDebridAccountFailureForTests, clearMegaDebridEmptyResponseStreak, DebridService, extractRapidgatorFilenameFromHtml, fetchAllDebridHostInfo, fetchDebridLinkHostLimits, filenameFromRapidgatorUrlPath, getDebridLinkKeyCooldownStateForTests, getDebridLinkKeyRuntimeStateForTests, getMegaDebridAccountCooldownState, getProviderRuntimeSnapshot, leadProviderChainWith, MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART, MEGA_DEBRID_STICKY_LINKS, normalizeResolvedFilename, primeMegaDebridRuntimeCooldownForTests, primeMegaDebridUntilRestartForTests, recordMegaDebridEmptyResponseStreak, resetDebridLinkRuntimeStateForTests, resetMegaDebridRuntimeStateForTests } from "../src/main/debrid"; const originalFetch = globalThis.fetch; @@ -2071,6 +2071,24 @@ describe("debrid service", () => { expect(calls).toBeGreaterThanOrEqual(1); }, 20000); + it("getProviderRuntimeSnapshot surfaces a live Mega-Debrid account cooldown (until/remaining/reason) for the diagnostics endpoint", () => { + const accId = getMegaDebridAccountId("user"); + const key = `${accId}:web`; + expect(getProviderRuntimeSnapshot().megaDebrid.accounts.find((a) => a.key === key)?.cooldown ?? null).toBeNull(); + + primeMegaDebridRuntimeCooldownForTests(key, 90_000, "Abbruch/Timeout nach 60s"); + + const snap = getProviderRuntimeSnapshot(); + expect(typeof snap.capturedAtMs).toBe("number"); + const acc = snap.megaDebrid.accounts.find((a) => a.key === key); + expect(acc).toBeTruthy(); + expect(acc!.cooldown).not.toBeNull(); + expect(acc!.cooldown!.remainingMs).toBeGreaterThan(0); + expect(acc!.cooldown!.remainingMs).toBeLessThanOrEqual(90_000); + expect(acc!.cooldown!.untilMs).toBeGreaterThan(snap.capturedAtMs); + expect(acc!.cooldown!.message).toContain("Abbruch"); + }); + it("single Mega-Debrid account: a long Web abort parks only the slow link and does NOT freeze the sole account", async () => { process.env.RD_MEGA_ABORT_MIN_RUN_MS = "0"; const settings = { diff --git a/tools/rd-diagnostics-mcp/README.md b/tools/rd-diagnostics-mcp/README.md index 987f9d6..80779b4 100644 --- a/tools/rd-diagnostics-mcp/README.md +++ b/tools/rd-diagnostics-mcp/README.md @@ -29,8 +29,10 @@ Without env config, every tool simply takes a `code` argument. ## Tools -`rd_servers`, `rd_ping`, `rd_diagnostics`, `rd_status`, `rd_items`, `rd_packages`, `rd_errors`, `rd_logs`, -`rd_history`, `rd_accounts`, `rd_host`, `rd_self_check`, `rd_get` (raw escape-hatch, any read-only path). +`rd_servers`, `rd_ping`, `rd_diagnostics`, `rd_status`, `rd_items`, `rd_packages`, `rd_errors`, `rd_logs` +(`main|audit|rename|trace|session|conversion|package|item`), `rd_history`, `rd_accounts`, `rd_providers` +(live per-account/key cooldown + in-flight + rotation state), `rd_host`, `rd_self_check`, +`rd_get` (raw escape-hatch, any read-only path). Each tool accepts `code` or `server` to pick the target. diff --git a/tools/rd-diagnostics-mcp/src/bridge.mjs b/tools/rd-diagnostics-mcp/src/bridge.mjs index 3aef241..a605c72 100644 --- a/tools/rd-diagnostics-mcp/src/bridge.mjs +++ b/tools/rd-diagnostics-mcp/src/bridge.mjs @@ -219,6 +219,7 @@ const LOG_PATHS = { rename: "/logs/rename", trace: "/logs/trace", session: "/logs/session", + conversion: "/logs/conversion", package: "/logs/package", item: "/logs/item" }; @@ -227,10 +228,10 @@ server.registerTool( "rd_logs", { title: "Log lesen", - description: "Liest das Ende eines Logs (GET /logs/). name: main|audit|rename|trace|session|package|item. Fuer package/item zusaetzlich package/item angeben.", + description: "Liest das Ende eines Logs (GET /logs/). name: main|audit|rename|trace|session|conversion|package|item. conversion = Pro-Item Link-Aufloesungs-Lebenszyklus (Token, API, Web, Rotation, Abbrueche mit Zeiten). Fuer package/item zusaetzlich package/item angeben.", inputSchema: { ...CODE_FIELD, - name: z.enum(["main", "audit", "rename", "trace", "session", "package", "item"]).describe("Welches Log."), + name: z.enum(["main", "audit", "rename", "trace", "session", "conversion", "package", "item"]).describe("Welches Log."), lines: z.number().int().positive().optional().describe("Anzahl Zeilen vom Ende (Default 100)."), grep: z.string().optional().describe("Filter."), package: z.string().optional().describe("Nur fuer name=package."), @@ -268,6 +269,16 @@ server.registerTool( async (args) => requestTool(args, "/accounts", {}) ); +server.registerTool( + "rd_providers", + { + title: "Provider-Laufzeitzustand", + description: "Live Provider-Runtime (GET /providers): pro Mega-Account/Debrid-Link-Key der AKTIVE Cooldown (until/remainingMs/Grund/Kategorie), in-flight-Tiefe, Mega-Rotationscursor, Empty-Response-Streaks. Die 'warum kuehlt es JETZT ab'-Ansicht — beantwortet Cooldown-Fragen direkt statt aus Log-Arithmetik.", + inputSchema: { ...CODE_FIELD } + }, + async (args) => requestTool(args, "/providers", {}) +); + server.registerTool( "rd_host", {