Compare commits

..

No commits in common. "main" and "v1.7.219" have entirely different histories.

38 changed files with 169 additions and 4687 deletions

View File

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

View File

@ -1,5 +1,4 @@
import path from "node:path";
import os from "node:os";
import v8 from "node:v8";
import { app } from "electron";
import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys";
@ -10,11 +9,9 @@ import {
DebridAccountStatus,
DebridProvider,
DuplicatePolicy,
EnableRemoteDiagnosticsInput,
HistoryEntry,
PackagePriority,
ParsedPackageInput,
RemoteDiagnosticsInfo,
SessionStats,
StartConflictEntry,
StartConflictResolutionResult,
@ -39,13 +36,12 @@ import { getItemLogPath, initItemLogs, shutdownItemLogs } from "./item-log";
import { getPackageLogPath, initPackageLogs, shutdownPackageLogs } from "./package-log";
import { initSessionLog, getSessionLogPath, shutdownSessionLog } from "./session-log";
import { MegaWebFallback } from "./mega-web-fallback";
import { addHistoryEntry, addHistoryEntryForRetention, cancelPendingAsyncSaves, clearHistory, createStoragePaths, loadHistory, loadHistoryForRetention, loadSessionWithStatus, loadSettings, normalizeHistoryEntry, normalizeLoadedSession, normalizeLoadedSessionTransientFields, normalizeSettings, removeHistoryEntry, resetHistoryForRetention, saveHistory, saveSession, saveSettings } from "./storage";
import { addHistoryEntry, addHistoryEntryForRetention, cancelPendingAsyncSaves, clearHistory, createStoragePaths, loadHistory, loadHistoryForRetention, loadSession, loadSettings, normalizeHistoryEntry, normalizeLoadedSession, normalizeLoadedSessionTransientFields, normalizeSettings, removeHistoryEntry, resetHistoryForRetention, saveHistory, saveSession, saveSettings } from "./storage";
import { abortActiveUpdateDownload, checkGitHubUpdate, installLatestUpdate } from "./update";
import { runInstallWithResume } from "./update-install-flow";
import { rotateDebugToken, startDebugServer, stopDebugServer, restartDebugServer, getDebugServerRuntimeStatus, getActiveDebugToken, getDebugAllowlist, writeDebugServerConfig, clearDebugToken } from "./debug-server";
import { encodeConnectionCode, loadRemoteMeta, saveRemoteMeta } from "./connection-code";
import { rotateDebugToken, startDebugServer, stopDebugServer } from "./debug-server";
import { encryptBackup, decryptBackup } from "./backup-crypto";
import { buildBackupPayload, planBackupImport, resolveMcpRemoteRestore, BackupMcpRemote } from "./backup-payload";
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";
@ -112,8 +108,7 @@ export class AppController {
initTraceLog(this.storagePaths.baseDir);
this.settings = loadSettings(this.storagePaths);
resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode);
const loadResult = loadSessionWithStatus(this.storagePaths);
const session = loadResult.session;
const session = loadSession(this.storagePaths);
this.megaWebFallback = new MegaWebFallback(() => ({
login: this.settings.megaLogin,
password: this.settings.megaPassword
@ -127,7 +122,6 @@ export class AppController {
realDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.realDebridWebFallback.unrestrict(link, signal),
bestDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.bestDebridWebFallback.unrestrict(link, signal),
invalidateMegaSession: () => this.megaWebFallback.invalidateSession(),
protectEmptyClobber: loadResult.status === "empty-unreadable",
onHistoryEntry: (entry: HistoryEntry) => {
addHistoryEntryForRetention(this.storagePaths, this.settings.historyRetentionMode, entry, this.historyLimits());
}
@ -180,13 +174,21 @@ export class AppController {
if (this.settings.autoResumeOnStart) {
const snapshot = this.manager.getSnapshot();
const hasPending = Object.values(snapshot.session.items).some((item) => item.status === "queued" || item.status === "reconnect_wait");
if (hasPending && this.hasAnyProviderToken(this.settings)) {
if (this.onStateHandler) {
this.beginAutoResume();
} else {
this.autoResumePending = true;
logger.info("Auto-Resume beim Start vorgemerkt");
}
if (hasPending) {
void this.manager.getStartConflicts().then((conflicts) => {
const hasConflicts = conflicts.length > 0;
if (this.hasAnyProviderToken(this.settings) && !hasConflicts) {
if (this.onStateHandler) {
logger.info("Auto-Resume beim Start aktiviert (nach Konflikt-Check)");
void this.manager.start().catch((err) => logger.warn(`Auto-Resume Start Fehler: ${String(err)}`));
} else {
this.autoResumePending = true;
logger.info("Auto-Resume beim Start vorgemerkt");
}
} else if (hasConflicts) {
logger.info("Auto-Resume übersprungen: Start-Konflikte erkannt");
}
}).catch((err) => logger.warn(`getStartConflicts Fehler (constructor): ${String(err)}`));
}
}
}
@ -243,27 +245,14 @@ export class AppController {
handler(this.manager.getSnapshot());
if (this.autoResumePending) {
this.autoResumePending = false;
this.beginAutoResume();
void this.manager.start().catch((err) => logger.warn(`Auto-Resume Start Fehler: ${String(err)}`));
logger.info("Auto-Resume beim Start aktiviert");
} else {
this.manager.triggerIdleExtractions();
}
}
}
private beginAutoResume(): void {
void this.manager.getStartConflicts().then((conflicts) => {
const excludePackageIds = new Set(conflicts.map((conflict) => conflict.packageId));
if (excludePackageIds.size > 0) {
const names = conflicts.map((conflict) => conflict.packageName).join(", ");
logger.info(`Auto-Resume: ${excludePackageIds.size} Paket(e) mit Start-Konflikt zurückgehalten (${names}); übrige Pakete starten`);
} else {
logger.info("Auto-Resume beim Start aktiviert (keine Start-Konflikte)");
}
void this.manager.start(excludePackageIds.size > 0 ? { excludePackageIds } : undefined)
.catch((err) => logger.warn(`Auto-Resume Start Fehler: ${String(err)}`));
}).catch((err) => logger.warn(`Auto-Resume Konflikt-Check Fehler: ${String(err)}`));
}
public getSnapshot(): UiSnapshot {
return this.manager.getSnapshot();
}
@ -302,100 +291,6 @@ export class AppController {
return rotated;
}
private getSuggestedRemoteHosts(): string[] {
const hosts: string[] = [];
try {
const interfaces = os.networkInterfaces();
for (const entry of Object.values(interfaces)) {
for (const net of entry || []) {
if (net.family === "IPv4" && !net.internal && net.address) {
hosts.push(net.address);
}
}
}
} catch {
}
return [...new Set(hosts)];
}
public getRemoteDiagnostics(): RemoteDiagnosticsInfo {
const status = getDebugServerRuntimeStatus();
const meta = loadRemoteMeta(this.storagePaths.baseDir);
const token = getActiveDebugToken();
const allowlist = getDebugAllowlist();
const suggestedHosts = this.getSuggestedRemoteHosts();
const host = meta.publicHost
|| (status.localOnly ? "127.0.0.1" : (suggestedHosts[0] || status.host));
const code = (status.hasToken && token && host)
? encodeConnectionCode({ host, port: status.port, token, name: meta.name || undefined })
: null;
return {
status,
code,
publicHost: meta.publicHost,
name: meta.name,
allowlist,
suggestedHosts
};
}
public async enableRemoteDiagnostics(input: EnableRemoteDiagnosticsInput): Promise<RemoteDiagnosticsInfo> {
const baseDir = this.storagePaths.baseDir;
const port = input.port && Number.isInteger(input.port) && input.port >= 1024 && input.port <= 65535
? input.port
: 9868;
const bindHost = input.hostMode === "network" ? "0.0.0.0" : "127.0.0.1";
const allowlist = (input.allowlist || []).map((entry) => entry.trim()).filter((entry) => entry.length > 0);
if (input.hostMode === "network" && allowlist.length === 0) {
throw new Error("Netzwerk-Freigabe erfordert mindestens eine erlaubte IP oder CIDR in der Allowlist.");
}
let token = getActiveDebugToken();
if (!token || input.rotateToken) {
token = rotateDebugToken(baseDir).token;
}
writeDebugServerConfig({ host: bindHost, port, allowlist });
saveRemoteMeta(baseDir, { publicHost: (input.publicHost || "").trim(), name: (input.name || "").trim() });
await restartDebugServer();
this.audit("WARN", "Ferndiagnose aktiviert", {
host: bindHost,
port,
allowlistCount: allowlist.length,
localOnly: input.hostMode === "local"
});
return this.getRemoteDiagnostics();
}
public async disableRemoteDiagnostics(): Promise<RemoteDiagnosticsInfo> {
clearDebugToken();
await restartDebugServer();
this.audit("WARN", "Ferndiagnose deaktiviert (Token entfernt)");
return this.getRemoteDiagnostics();
}
public async rotateRemoteDiagnosticsToken(): Promise<RemoteDiagnosticsInfo> {
rotateDebugToken(this.storagePaths.baseDir);
await restartDebugServer();
this.audit("WARN", "Ferndiagnose-Token rotiert");
return this.getRemoteDiagnostics();
}
private restoreMcpRemoteFromBackup(section: unknown, restartNow: boolean): void {
const restore = resolveMcpRemoteRestore(section);
if (!restore) {
return;
}
writeDebugServerConfig({ host: restore.host, port: restore.port, allowlist: restore.allowlist });
if (restartNow) {
void restartDebugServer().catch(() => {});
}
this.audit("INFO", "Ferndiagnose-Einstellungen aus Backup wiederhergestellt", {
port: restore.port ?? null,
allowlistCount: restore.allowlist?.length ?? 0,
host: restore.host ?? "unveraendert",
restartNow
});
}
public getDebugSetupCheck(): DebugSetupCheckResult {
return getDebugSetupCheck(this.storagePaths.baseDir);
}
@ -748,22 +643,13 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
}
public exportBackup(): Buffer {
let mcpRemote: BackupMcpRemote | undefined;
if (Boolean(this.settings.backupIncludeMcp)) {
const status = getDebugServerRuntimeStatus();
mcpRemote = {
allowlist: getDebugAllowlist(),
port: status.port,
hostMode: status.host === "0.0.0.0" ? "network" : "local"
};
}
const includeDownloads = Boolean(this.settings.backupIncludeDownloads);
const payloadObj = buildBackupPayload({
settings: { ...this.settings },
appVersion: APP_VERSION,
exportedAt: new Date().toISOString(),
session: this.manager.getSession(),
history: loadHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode, this.historyLimits()),
mcpRemote
history: loadHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode, this.historyLimits())
});
this.audit("INFO", "Backup exportiert", {
kind: payloadObj.kind,
@ -837,7 +723,6 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
this.settings = restoredSettings;
saveSettings(this.storagePaths, this.settings);
this.manager.setSettings(this.settings, { suppressRetroactiveCleanup: true });
this.restoreMcpRemoteFromBackup(parsed.mcpRemote, true);
this.audit("INFO", "Backup importiert (nur Einstellungen)", {
accountSummary: buildAccountSummary(this.settings)
});
@ -874,8 +759,6 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode);
this.restoreMcpRemoteFromBackup(parsed.mcpRemote, false);
this.manager.skipShutdownPersist = true;
this.manager.blockAllPersistence = true;
logger.info("Backup wiederhergestellt — App startet automatisch neu");

View File

@ -2,12 +2,6 @@ import type { AppSettings, SessionState, HistoryEntry } from "../shared/types";
export type BackupKind = "full" | "settings-only";
export interface BackupMcpRemote {
allowlist: string[];
port: number;
hostMode: "local" | "network";
}
export interface BackupPayload {
version: 2;
kind: BackupKind;
@ -16,7 +10,6 @@ export interface BackupPayload {
settings: AppSettings;
session?: SessionState;
history?: HistoryEntry[];
mcpRemote?: BackupMcpRemote;
}
export interface BuildBackupInput {
@ -26,7 +19,6 @@ export interface BuildBackupInput {
/** Only bundled when includeDownloads is true. */
session: SessionState;
history: HistoryEntry[];
mcpRemote?: BackupMcpRemote;
}
/**
@ -48,39 +40,9 @@ export function buildBackupPayload(input: BuildBackupInput): BackupPayload {
base.session = input.session;
base.history = input.history;
}
if (Boolean(input.settings.backupIncludeMcp) && input.mcpRemote) {
base.mcpRemote = input.mcpRemote;
}
return base;
}
export interface McpRemoteRestore {
host?: "127.0.0.1" | "0.0.0.0";
port?: number;
allowlist?: string[];
}
export function resolveMcpRemoteRestore(section: unknown): McpRemoteRestore | null {
if (!section || typeof section !== "object") {
return null;
}
const s = section as { allowlist?: unknown; port?: unknown; hostMode?: unknown };
const allowlist = Array.isArray(s.allowlist)
? s.allowlist.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0).map((entry) => entry.trim())
: undefined;
const port = (typeof s.port === "number" && Number.isInteger(s.port) && s.port >= 1024 && s.port <= 65535) ? s.port : undefined;
let host: "127.0.0.1" | "0.0.0.0" | undefined;
if (s.hostMode === "network") {
host = allowlist && allowlist.length > 0 ? "0.0.0.0" : "127.0.0.1";
} else if (s.hostMode === "local") {
host = "127.0.0.1";
}
if (host === undefined && port === undefined && allowlist === undefined) {
return null;
}
return { host, port, allowlist };
}
export interface ImportPlan {
valid: boolean;
/** Restore the download list (session + history) and relaunch. */

View File

@ -1,59 +0,0 @@
import fs from "node:fs";
import path from "node:path";
const PREFIX = "rddiag:v1:";
function base64urlEncode(value: string): string {
return Buffer.from(value, "utf8")
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
export interface ConnectionCodeInput {
host: string;
port: number;
token: string;
name?: string;
scheme?: "http" | "https";
fingerprint?: string;
}
export function encodeConnectionCode(input: ConnectionCodeInput): string {
const host = String(input.host || "").trim();
if (!host) throw new Error("Host fehlt fuer Verbindungscode");
const port = Number(input.port);
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("Port ungueltig fuer Verbindungscode");
if (!input.token) throw new Error("Token fehlt fuer Verbindungscode");
const payload: Record<string, unknown> = { v: 1, h: host, p: port, t: input.token };
if (input.name) payload.n = String(input.name);
if (input.fingerprint) payload.fp = String(input.fingerprint);
if (input.scheme && input.scheme !== "http") payload.s = String(input.scheme);
return PREFIX + base64urlEncode(JSON.stringify(payload));
}
export interface RemoteMeta {
publicHost: string;
name: string;
}
function remoteMetaPath(baseDir: string): string {
return path.join(baseDir, "debug_remote.json");
}
export function loadRemoteMeta(baseDir: string): RemoteMeta {
try {
const parsed = JSON.parse(fs.readFileSync(remoteMetaPath(baseDir), "utf8"));
return {
publicHost: String(parsed.publicHost || ""),
name: String(parsed.name || "")
};
} catch {
return { publicHost: "", name: "" };
}
}
export function saveRemoteMeta(baseDir: string, meta: RemoteMeta): void {
fs.writeFileSync(remoteMetaPath(baseDir), JSON.stringify({ publicHost: meta.publicHost, name: meta.name }, null, 2), "utf8");
}

View File

@ -109,7 +109,6 @@ export function defaultSettings(): AppSettings {
hideExtractedItems: true,
confirmDeleteSelection: true,
backupIncludeDownloads: false,
backupIncludeMcp: false,
notifyUrl: "",
notifyMention: "",
notifyOnPackageCompleted: false,

View File

@ -123,11 +123,6 @@ export function getDebridLinkKeyRuntimeStateForTests(keyId: string): DebridLinkR
return status ? status.state : null;
}
export function getDebridLinkKeyCooldownStateForTests(keyId: string, now = Date.now()): { remainingMs: number; message: string } | null {
const state = getDebridLinkKeyCooldownState(keyId, now);
return state ? { remainingMs: state.remainingMs, message: state.message } : null;
}
function clearDebridLinkKeyCooldownState(keyId: string): void {
debridLinkKeyCooldowns.delete(keyId);
debridLinkKeyCooldownDetails.delete(keyId);
@ -288,7 +283,6 @@ type MegaDebridCooldownCategory = "invalid" | "rate_limit" | "quota" | "temporar
type MegaDebridCooldownDetail = { until: number; message: string; category: MegaDebridCooldownCategory; untilRestart?: boolean };
const megaDebridAccountCooldowns = new Map<string, MegaDebridCooldownDetail>();
const MEGA_DEBRID_ACCOUNT_COOLDOWN_MS = 120_000;
const MEGA_DEBRID_SLOW_LINK_RETRY_MS = 120_000;
const MEGA_DEBRID_INVALID_ACCOUNT_COOLDOWN_MS = 60 * 60 * 1000;
// A Mega-Web account abort (the shared unrestrict timeout firing while this
@ -302,7 +296,7 @@ function getMegaDebridAbortMinRunMs(): number {
}
const megaDebridEmptyResponseStreaks = new Map<string, number>();
export const MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART = 10;
export const MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART = 3;
let megaDebridRotationCursor = 0;
let megaDebridStickyCount = 0;
@ -339,25 +333,6 @@ export function resetMegaDebridRuntimeStateForTests(): void {
megaDebridInFlight.clear();
}
export function getMegaDebridInFlightCountForMode(mode: "api" | "web"): number {
const suffix = `:${mode}`;
let total = 0;
for (const [key, count] of megaDebridInFlight) {
if (key.endsWith(suffix)) {
total += count;
}
}
return total;
}
export function primeMegaDebridInFlightForTests(key: string, count: number): void {
if (count <= 0) {
megaDebridInFlight.delete(key);
return;
}
megaDebridInFlight.set(key, count);
}
export function pruneExpiredMegaDebridRuntimeState(now = Date.now()): number {
let removed = 0;
const grace = 60 * 60 * 1000;
@ -442,106 +417,6 @@ 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<string>([
...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<string>([
...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<DebridProvider, string> = {
@ -2010,9 +1885,6 @@ class MegaDebridClient {
return null;
});
if (signal?.aborted) {
if (/queue.?timeout/i.test(lastError)) {
throw new Error(lastError.replace(/^Error:\s*/i, ""));
}
throw new Error("aborted:debrid");
}
if (web?.directUrl) {
@ -2188,33 +2060,15 @@ class MegaDebridClient {
} catch (error) {
const elapsedMs = Date.now() - testStartedAt;
const abortText = compactErrorText(error).replace(/^Error:\s*/i, "");
// Timeout/abort on THIS account (the shared unrestrict timeout fired). The
// account-wide cooldown exists ONLY to make the retry rotate to another
// account — so it is set only when another usable account actually exists.
// With no rotation target (single account / all others busy), cooling the
// sole account would freeze EVERY queued item while the account is healthy;
// a >60s timeout is a slow-LINK signal, not an unhealthy-account signal, so
// we park just this link (mega_debrid_slow_link) and leave the account free
// for other items. A quick user-cancel (below the min run) parks nothing.
// 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();
const otherUsableAccounts = orderedEntries.reduce((count, candidate) => {
if (candidate.account.id === account.id) {
return count;
}
if (isMegaDebridAccountDisabled(settings, candidate.account.id)) {
return count;
}
if (isMegaDebridAccountDailyLimitReached(settings, candidate.account.id)) {
return count;
}
if (getMegaDebridAccountCooldownState(`${candidate.account.id}:${mode}`)) {
return count;
}
return count + 1;
}, 0);
const rotateToAnotherAccount = ranLongEnough && otherUsableAccounts > 0;
if (rotateToAnotherAccount) {
if (ranLongEnough) {
setMegaDebridAccountCooldownState(cooldownKey, MEGA_DEBRID_ACCOUNT_COOLDOWN_MS, `Abbruch/Timeout nach ${Math.ceil(elapsedMs / 1000)}s`, "temporary");
}
traceConversionPhase({
@ -2223,18 +2077,15 @@ class MegaDebridClient {
account: rotationLabel,
workMs: elapsedMs,
outcome: "aborted",
detail: `${abortText}${rotateToAnotherAccount ? ` cd=${Math.ceil(MEGA_DEBRID_ACCOUNT_COOLDOWN_MS / 1000)}s` : ranLongEnough ? ` slowlink=${Math.ceil(MEGA_DEBRID_SLOW_LINK_RETRY_MS / 1000)}s` : ""}`
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,
reason: abortText,
cooldownSec: rotateToAnotherAccount ? Math.ceil(MEGA_DEBRID_ACCOUNT_COOLDOWN_MS / 1000) : 0,
next: rotateToAnotherAccount ? "naechster Account beim Retry" : "Einzel-Retry (Account bleibt fuer andere Items frei)"
cooldownSec: ranLongEnough ? Math.ceil(MEGA_DEBRID_ACCOUNT_COOLDOWN_MS / 1000) : 0,
next: "naechster Account beim Retry"
});
if (ranLongEnough && !rotateToAnotherAccount) {
throw new Error(`mega_debrid_slow_link:${MEGA_DEBRID_SLOW_LINK_RETRY_MS}:Mega-Debrid${accountLabel}: ${abortText}`);
}
throw new Error(`Mega-Debrid${accountLabel}: ${abortText}`);
}
const failure = MegaDebridClient.classifyAccountFailure(error);
@ -2254,7 +2105,7 @@ class MegaDebridClient {
const streak = recordMegaDebridEmptyResponseStreak(cooldownKey);
if (streak >= MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART) {
parkUntilRestart = true;
parkMessage = `Tageslimit erreicht (${streak}x leere Antwort in Folge) — bis zum Tagesreset gesperrt`;
parkMessage = `Tageslimit erreicht (${streak}x kein Server/leere Antwort) — bis zum Tagesreset gesperrt`;
}
} else {
clearMegaDebridEmptyResponseStreak(cooldownKey);
@ -2348,10 +2199,6 @@ class MegaDebridClient {
return { fatal: false, cooldownMs: 0, message: germanMegaDebridResolveReason(errorText), category: "temporary" };
}
if (/lieferte kein ergebnis|linkgenerierung[^.]*kein ergebnis/i.test(errorText)) {
return { fatal: false, cooldownMs: 0, message: germanMegaDebridResolveReason(errorText), category: "temporary" };
}
if (/permanent ungültig|hosternotavailable|file.?not.?found|file.?unavailable|link.?is.?dead/i.test(errorText)) {
return { fatal: true, cooldownMs: 0, message: errorText, category: "skip" };
}
@ -2937,23 +2784,6 @@ class DebridLinkClient {
} catch (error) {
const failure = await this.classifyKeyFailure(error, apiKey, link, signal);
const elapsedMs = Date.now() - testStartedAt;
const abortText = compactErrorText(error).replace(/^Error:\s*/i, "");
if (/aborted/i.test(abortText) && !/timeout/i.test(abortText)) {
const ranLongEnough = elapsedMs >= getMegaDebridAbortMinRunMs();
if (ranLongEnough) {
setDebridLinkKeyCooldownState(apiKey.id, DEBRID_LINK_KEY_COOLDOWN_MS, `Abbruch/Timeout nach ${Math.ceil(elapsedMs / 1000)}s`, "temporary");
} else {
clearDebridLinkKeyCooldownState(apiKey.id);
}
failures.push(`Debrid-Link${keyLabel}: ${abortText}`);
logAccountRotation("WARN", providerName, rotationLabel, "TIMEOUT_COOLDOWN", {
elapsedMs,
reason: abortText,
cooldownSec: ranLongEnough ? Math.ceil(DEBRID_LINK_KEY_COOLDOWN_MS / 1000) : 0,
next: "naechster Key beim Retry"
});
throw new Error(`Debrid-Link${keyLabel}: ${abortText}`);
}
attemptedKeyFailures.push({
message: `Debrid-Link${keyLabel}: ${failure.message}`,
cooldownMs: failure.cooldownMs,

View File

@ -15,8 +15,6 @@ 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";
@ -45,14 +43,12 @@ 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&note=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." },
@ -69,16 +65,6 @@ let authToken = "";
let bindHost = DEFAULT_HOST;
let bindPort = DEFAULT_PORT;
let runtimeBaseDir = "";
let allowlist: string[] = [];
export interface DebugServerRuntimeStatus {
running: boolean;
host: string;
port: number;
hasToken: boolean;
localOnly: boolean;
allowlistCount: number;
}
function getStoragePaths() {
return createStoragePaths(runtimeBaseDir);
@ -154,94 +140,6 @@ function getHost(baseDir: string): string {
return DEFAULT_HOST;
}
function getAllowlistPath(baseDir: string = runtimeBaseDir): string {
return path.join(baseDir, "debug_allowlist.txt");
}
function loadAllowlist(baseDir: string): string[] {
try {
return fs.readFileSync(getAllowlistPath(baseDir), "utf8")
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0 && !line.startsWith("#"));
} catch {
return [];
}
}
function normalizeIp(ip: string): string {
return String(ip || "").trim().replace(/^::ffff:/i, "").toLowerCase();
}
function isLoopbackIp(ip: string): boolean {
const x = normalizeIp(ip);
return x === "::1" || x === "localhost" || x.startsWith("127.");
}
function ipv4ToInt(ip: string): number | null {
const parts = ip.split(".");
if (parts.length !== 4) {
return null;
}
let result = 0;
for (const part of parts) {
const value = Number(part);
if (!Number.isInteger(value) || value < 0 || value > 255) {
return null;
}
result = (result * 256) + value;
}
return result >>> 0;
}
function matchIpRule(clientIp: string, rule: string): boolean {
const client = normalizeIp(clientIp);
const r = rule.trim().toLowerCase();
if (!r) {
return false;
}
if (r === "*" || r === "0.0.0.0/0") {
return true;
}
if (r === client) {
return true;
}
const slash = r.indexOf("/");
if (slash > 0) {
const baseInt = ipv4ToInt(r.slice(0, slash));
const clientInt = ipv4ToInt(client);
const bits = Number(r.slice(slash + 1));
if (baseInt === null || clientInt === null || !Number.isInteger(bits) || bits < 0 || bits > 32) {
return false;
}
if (bits === 0) {
return true;
}
const mask = bits === 32 ? 0xffffffff : (~((1 << (32 - bits)) - 1)) >>> 0;
return (clientInt & mask) === (baseInt & mask);
}
return false;
}
export function evaluateClientAllowed(clientIp: string, rules: string[]): boolean {
const client = normalizeIp(clientIp);
if (isLoopbackIp(client) || client === "") {
return true;
}
if (rules.length === 0) {
return false;
}
return rules.some((rule) => matchIpRule(client, rule));
}
export function getPeerIp(req: http.IncomingMessage): string {
return normalizeIp(req.socket?.remoteAddress || "");
}
function isClientAllowed(clientIp: string): boolean {
return evaluateClientAllowed(clientIp, allowlist);
}
function checkAuth(req: http.IncomingMessage): boolean {
if (!authToken) {
return false;
@ -360,7 +258,6 @@ function buildAiManifest(baseDir: string): Record<string, unknown> {
"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: {
@ -564,19 +461,6 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
return;
}
const peerIp = getPeerIp(req);
if (!isClientAllowed(peerIp)) {
if (traceConfig.enabled && traceConfig.logDebugRequests) {
logTraceEvent("WARN", "debug-http", "Durch Allowlist blockiert", {
peerIp,
forwardedFor: extractDebugClientIp(req),
url: sanitizeRequestUrlForTrace(req.url || "/")
});
}
jsonResponse(res, 403, { error: "Forbidden", reason: "Client-IP nicht in Allowlist", clientIp: peerIp });
return;
}
if (!checkAuth(req)) {
if (traceConfig.enabled && traceConfig.logDebugRequests) {
logTraceEvent("WARN", "debug-http", "Unauthorized request", {
@ -712,25 +596,6 @@ 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<string, unknown> = {};
const enabled = toBooleanQuery(url.searchParams.get("enable"));
@ -1016,7 +881,6 @@ 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()
@ -1048,10 +912,6 @@ 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)
@ -1067,126 +927,32 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
});
}
function openServerSocket(): Promise<void> {
return new Promise((resolve) => {
authToken = loadToken(runtimeBaseDir);
bindPort = getPort(runtimeBaseDir);
bindHost = getHost(runtimeBaseDir);
allowlist = loadAllowlist(runtimeBaseDir);
writeAiManifest(runtimeBaseDir);
if (!authToken) {
logger.info("Debug-Server: Kein Token in debug_token.txt, Server wird nicht gestartet");
resolve();
return;
}
if (bindHost === "0.0.0.0" && allowlist.length === 0) {
logger.warn("Debug-Server: Netzwerk-Bind ohne Allowlist - nur Loopback-Clients werden akzeptiert (fail-closed)");
}
const srv = http.createServer(handleRequest);
let settled = false;
const settle = (): void => {
if (!settled) {
settled = true;
resolve();
}
};
srv.on("error", (err: NodeJS.ErrnoException) => {
if (err.code === "EADDRINUSE") {
logger.warn(`Debug-Server: Port ${bindPort} belegt (EADDRINUSE) - Server nicht gestartet`);
} else {
logger.warn(`Debug-Server Fehler: ${String(err)}`);
}
if (server === srv) {
server = null;
}
settle();
});
srv.listen(bindPort, bindHost, () => {
logger.info(`Debug-Server gestartet auf ${bindHost}:${bindPort} (Allowlist: ${allowlist.length})`);
settle();
});
server = srv;
});
}
export function startDebugServer(mgr: DownloadManager, baseDir: string): void {
runtimeBaseDir = baseDir;
authToken = loadToken(baseDir);
bindPort = getPort(baseDir);
bindHost = getHost(baseDir);
writeAiManifest(baseDir);
if (!authToken) {
logger.info("Debug-Server: Kein Token in debug_token.txt, Server wird nicht gestartet");
return;
}
manager = mgr;
void openServerSocket();
}
export async function restartDebugServer(): Promise<DebugServerRuntimeStatus> {
const old = server;
if (old) {
server = http.createServer(handleRequest);
server.listen(bindPort, bindHost, () => {
logger.info(`Debug-Server gestartet auf ${bindHost}:${bindPort}`);
});
server.on("error", (err) => {
logger.warn(`Debug-Server Fehler: ${String(err)}`);
server = null;
await new Promise<void>((resolve) => {
let settled = false;
const done = (): void => {
if (!settled) {
settled = true;
resolve();
}
};
old.close(() => done());
try {
old.closeAllConnections?.();
} catch {
}
setTimeout(done, 1500);
});
}
await openServerSocket();
return getDebugServerRuntimeStatus();
}
export function getDebugServerRuntimeStatus(): DebugServerRuntimeStatus {
return {
running: Boolean(server && server.listening),
host: bindHost,
port: bindPort,
hasToken: Boolean(authToken),
localOnly: isLoopbackIp(bindHost) || bindHost === "127.0.0.1",
allowlistCount: allowlist.length
};
}
export function getActiveDebugToken(): string {
return authToken || loadToken(runtimeBaseDir);
}
export function getDebugAllowlist(): string[] {
return [...allowlist];
}
export function writeDebugServerConfig(opts: { host?: string; port?: number; allowlist?: string[] }): void {
if (opts.host !== undefined) {
fs.writeFileSync(path.join(runtimeBaseDir, "debug_host.txt"), `${opts.host}\n`, "utf8");
}
if (opts.port !== undefined) {
fs.writeFileSync(path.join(runtimeBaseDir, "debug_port.txt"), `${opts.port}\n`, "utf8");
}
if (opts.allowlist !== undefined) {
const body = opts.allowlist.length > 0 ? opts.allowlist.join("\n") + "\n" : "";
fs.writeFileSync(getAllowlistPath(), body, "utf8");
}
}
export function clearDebugToken(): void {
try {
fs.unlinkSync(getDebugTokenPath());
} catch {
}
authToken = "";
writeAiManifest(runtimeBaseDir);
});
}
export function stopDebugServer(): void {
if (server) {
server.close();
try {
server.closeAllConnections?.();
} catch {
}
server = null;
logger.info("Debug-Server gestoppt");
}

View File

@ -52,7 +52,7 @@ function releaseTlsSkip(): void {
}
import { cleanupCancelledPackageArtifactsAsync, removeDownloadLinkArtifacts, removeSampleArtifacts } from "./cleanup";
import { planDownloadCompletion, reconcileFinalizedSize, validateDownloadedFileCompletion } from "./download-completion";
import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, DebridService, MegaWebUnrestrictor, RealDebridWebUnrestrictor, checkRapidgatorOnline, fetchAllDebridHostInfo, getAvailableDebridLinkApiKeys, getAvailableMegaDebridAccounts, getMegaDebridAccountCooldownState, getMegaDebridInFlightCountForMode, pruneExpiredDebridLinkRuntimeState, pruneExpiredMegaDebridRuntimeState } from "./debrid";
import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, DebridService, MegaWebUnrestrictor, RealDebridWebUnrestrictor, checkRapidgatorOnline, fetchAllDebridHostInfo, getAvailableDebridLinkApiKeys, getAvailableMegaDebridAccounts, getMegaDebridAccountCooldownState, pruneExpiredDebridLinkRuntimeState, pruneExpiredMegaDebridRuntimeState } from "./debrid";
import { cleanupArchives, clearExtractResumeState, collectArchiveCleanupTargets, detectArchiveSignature, extractPackageArchives, findArchiveCandidates, hasAnyFilesRecursive, removeEmptyDirectoryTree, resetExtractorCachesForPasswordChange, type ExtractArchiveFailureInfo } from "./extractor";
import { validateFileAgainstManifest } from "./integrity";
import { classifyDiskError } from "./fs-error";
@ -131,17 +131,6 @@ const ARCHIVE_SETTLE_MAX_WAIT_MS = 5000;
const MAX_SAME_DIRECT_URL_ATTEMPTS = 3;
const MAX_HTTP416_FRESH_RESTARTS = 2;
const HTTP416_FRESH_RESTART_DELAY_MS = 8000;
function getHttp416FreshRestartDelayMs(): number {
const fromEnv = Number(process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS ?? NaN);
if (Number.isFinite(fromEnv) && fromEnv >= 0 && fromEnv <= 600000) {
return Math.floor(fromEnv);
}
return HTTP416_FRESH_RESTART_DELAY_MS;
}
const RESUME_REWIND_BYTES = 256 * 1024;
const REALDEBRID_TOTAL_MISMATCH_TOLERANCE_BYTES = 64 * 1024;
@ -374,7 +363,6 @@ type DownloadManagerOptions = {
bestDebridWebUnrestrict?: BestDebridWebUnrestrictor;
invalidateMegaSession?: () => void;
onHistoryEntry?: HistoryEntryCallback;
protectEmptyClobber?: boolean;
};
function generateHistoryId(): string {
@ -671,20 +659,6 @@ export function parseMegaDebridCooldownRetry(errorText: string): { delayMs: numb
return { delayMs, detail: text.replace(/mega_debrid_cooldown:\d+:/i, "").trim() };
}
export function parseMegaDebridSlowLinkRetry(errorText: string): { delayMs: number; detail: string } | null {
const text = String(errorText || "");
const match = text.match(/mega_debrid_slow_link:(\d+)/i);
if (!match) {
return null;
}
const raw = Number(match[1]);
if (!Number.isFinite(raw) || raw <= 0) {
return null;
}
const delayMs = Math.max(1000, Math.min(15 * 60 * 1000, raw));
return { delayMs, detail: text.replace(/mega_debrid_slow_link:\d+:/i, "").trim() };
}
export function parseMegaDebridResetPark(errorText: string): { delayMs: number; detail: string } | null {
const match = String(errorText || "").match(/mega_debrid_reset_park:(\d+):(.*)$/is);
if (!match) {
@ -1714,10 +1688,6 @@ export class DownloadManager extends EventEmitter {
public blockAllPersistence = false;
private protectAgainstEmptyClobber = false;
private emptyClobberProtectionLogged = false;
private debridService: DebridService;
private invalidateMegaSessionFn?: () => void;
@ -1840,8 +1810,6 @@ export class DownloadManager extends EventEmitter {
unrestrictRetries: number;
}>();
private http416FreshRestartByItem = new Map<string, number>();
private providerFailures = new Map<string, { count: number; lastFailAt: number; cooldownUntil: number }>();
private allDebridHostInfoCache = new Map<string, { info: AllDebridHostInfo; cachedAt: number }>();
@ -1863,10 +1831,6 @@ export class DownloadManager extends EventEmitter {
this.session = session;
this.itemCount = Object.keys(this.session.items).length;
this.storagePaths = storagePaths;
this.protectAgainstEmptyClobber = Boolean(options.protectEmptyClobber);
if (this.protectAgainstEmptyClobber) {
logger.warn("Session-Schutz aktiv: Start mit unlesbarer Session — leere Speicherungen blockiert, bis echte Daten vorliegen");
}
this.debridService = new DebridService(settings, {
megaWebUnrestrict: options.megaWebUnrestrict,
allDebridWebUnrestrict: options.allDebridWebUnrestrict,
@ -3024,14 +2988,6 @@ export class DownloadManager extends EventEmitter {
continue;
}
const hasOwnCompletedOutput = pkg.itemIds.some((itemId) => {
const item = this.session.items[itemId];
return Boolean(item && item.status === "completed");
});
if (hasOwnCompletedOutput) {
continue;
}
if (!this.isPackageSpecificExtractDir(pkg)) {
continue;
}
@ -5583,7 +5539,7 @@ export class DownloadManager extends EventEmitter {
});
}
public async start(options?: { excludePackageIds?: ReadonlySet<string> }): Promise<void> {
public async start(): Promise<void> {
if (this.session.running) {
return;
}
@ -5624,9 +5580,6 @@ export class DownloadManager extends EventEmitter {
if (item.status !== "queued" && item.status !== "reconnect_wait") {
return false;
}
if (options?.excludePackageIds?.has(item.packageId)) {
return false;
}
const pkg = this.session.packages[item.packageId];
return Boolean(pkg && !pkg.cancelled && pkg.enabled);
});
@ -5691,15 +5644,9 @@ export class DownloadManager extends EventEmitter {
this.providerStartReservations.clear();
this.pacedStartReservationByItem.clear();
this.retryStateByItem.clear();
this.http416FreshRestartByItem.clear();
this.itemContributedBytes.clear();
this.reservedTargetPaths.clear();
this.claimedTargetPathByItem.clear();
if (options?.excludePackageIds) {
for (const excluded of options.excludePackageIds) {
this.runPackageIds.delete(excluded);
}
}
this.session.running = true;
this.session.paused = false;
@ -5874,9 +5821,7 @@ export class DownloadManager extends EventEmitter {
const itemCount = Object.keys(this.session.items).length;
logger.info(`Shutdown-Save: ${pkgCount} Pakete, ${itemCount} Items`);
this.foldRuntimeIntoSettings(nowMs());
if (!this.guardBlocksSessionSave()) {
saveSession(this.storagePaths, this.session);
}
saveSession(this.storagePaths, this.session);
saveSettings(this.storagePaths, this.settings);
} else {
logger.info(`Shutdown-Save übersprungen: skipShutdownPersist=${this.skipShutdownPersist}, blockAllPersistence=${this.blockAllPersistence}`);
@ -6179,29 +6124,10 @@ export class DownloadManager extends EventEmitter {
}, delay);
}
private guardBlocksSessionSave(): boolean {
if (!this.protectAgainstEmptyClobber) {
return false;
}
const isEmpty = Object.keys(this.session.packages).length === 0 && Object.keys(this.session.items).length === 0;
if (isEmpty) {
if (!this.emptyClobberProtectionLogged) {
logger.warn("Leere Session-Speicherung uebersprungen (Schutz nach unlesbarem Start) — vorhandene Datei bleibt unangetastet");
this.emptyClobberProtectionLogged = true;
}
return true;
}
this.protectAgainstEmptyClobber = false;
logger.info("Session-Schutz aufgehoben: nicht-leere Session wird wieder normal gespeichert");
return false;
}
private persistNow(): void {
const now = nowMs();
this.lastPersistAt = now;
if (!this.guardBlocksSessionSave()) {
void saveSessionAsync(this.storagePaths, this.session).catch((err) => logger.warn(`saveSessionAsync Fehler: ${compactErrorText(err)}`));
}
void saveSessionAsync(this.storagePaths, this.session).catch((err) => logger.warn(`saveSessionAsync Fehler: ${compactErrorText(err)}`));
if (now - this.lastSettingsPersistAt >= 30000) {
this.foldRuntimeIntoSettings(now);
this.lastSettingsPersistAt = now;
@ -6215,9 +6141,7 @@ export class DownloadManager extends EventEmitter {
const itemCount = Object.keys(this.session.items).length;
logger.info(`Pre-Update Sync-Save: ${pkgCount} Pakete, ${itemCount} Items`);
this.foldRuntimeIntoSettings(nowMs());
if (!this.guardBlocksSessionSave()) {
saveSession(this.storagePaths, this.session);
}
saveSession(this.storagePaths, this.session);
saveSettings(this.storagePaths, this.settings);
}
@ -8226,13 +8150,7 @@ export class DownloadManager extends EventEmitter {
const provider = resolveMegaDebridProvider(this.settings, this.getExpectedProviderForItem(item));
const serializedValidatingLimit = this.getSerializedValidatingLimit(provider);
if (provider && Number.isFinite(serializedValidatingLimit) && serializedValidatingLimit < Number.MAX_SAFE_INTEGER) {
const validating = this.getProviderValidatingTaskCount(provider, item.id);
if (provider === "megadebrid-api") {
const webInFlight = getMegaDebridInFlightCountForMode("web");
const overlapAllowance = Math.min(serializedValidatingLimit, webInFlight);
return validating >= serializedValidatingLimit + overlapAllowance;
}
return validating >= serializedValidatingLimit;
return this.getProviderValidatingTaskCount(provider, item.id) >= serializedValidatingLimit;
}
if (provider !== "alldebrid") {
return false;
@ -8741,53 +8659,6 @@ export class DownloadManager extends EventEmitter {
this.queueRetry(item, active, delayMs, `HTTP 416 erkannt, Retry ${active.genericErrorRetries}/${retryDisplayLimit}`);
}
private escalateHttp416OrFail(item: DownloadItem, active: ActiveTask, claimedTargetPath: string, errorText: string): void {
const freshRestarts = this.http416FreshRestartByItem.get(item.id) || 0;
if (freshRestarts < MAX_HTTP416_FRESH_RESTARTS) {
this.http416FreshRestartByItem.set(item.id, freshRestarts + 1);
const resetTargetPath = claimedTargetPath || String(item.targetPath || "").trim();
if (resetTargetPath) {
try {
fs.rmSync(resetTargetPath, { force: true });
} catch {
}
}
this.releaseTargetPath(item.id);
this.dropItemContribution(item.id);
item.retries += 1;
item.downloadedBytes = 0;
item.totalBytes = null;
item.progressPercent = 0;
item.speedBps = 0;
item.lastError = "";
active.genericErrorRetries = 0;
active.freshRetryUsed = false;
active.resumeHardResetUsed = false;
logger.warn(
`HTTP 416 Budget erschöpft: item=${item.fileName || item.id}, ` +
`kompletter Neu-Download ${freshRestarts + 1}/${MAX_HTTP416_FRESH_RESTARTS} (Partial verworfen, kein Resume), provider=${item.provider || "?"}`
);
this.queueRetry(item, active, getHttp416FreshRestartDelayMs(), `Range-Konflikt (HTTP 416): Neu-Download ${freshRestarts + 1}/${MAX_HTTP416_FRESH_RESTARTS}`);
this.persistSoon();
this.emitState();
return;
}
this.http416FreshRestartByItem.delete(item.id);
item.status = "failed";
this.recordRunOutcome(item.id, "failed");
item.lastError = errorText;
item.fullStatus = `Fehler: ${item.lastError}`;
item.speedBps = 0;
item.updatedAt = nowMs();
const failPkg = this.session.packages[item.packageId];
if (failPkg) {
this.refreshPackageStatus(failPkg);
}
this.persistSoon();
this.emitState();
this.retryStateByItem.delete(item.id);
}
private startItem(packageId: string, itemId: string): void {
const item = this.session.items[itemId];
const pkg = this.session.packages[packageId];
@ -9105,6 +8976,7 @@ export class DownloadManager extends EventEmitter {
if (item.attempts < maxAttempts) {
item.status = "integrity_check";
item.progressPercent = 0;
this.dropItemContribution(item.id);
item.downloadedBytes = 0;
item.totalBytes = unrestricted.fileSize;
this.emitState();
@ -9145,6 +9017,7 @@ export class DownloadManager extends EventEmitter {
} catch {
}
this.releaseTargetPath(item.id);
this.dropItemContribution(item.id);
item.downloadedBytes = 0;
item.progressPercent = 0;
item.totalBytes = (item.totalBytes || 0) > 0 ? item.totalBytes : null;
@ -9378,14 +9251,10 @@ export class DownloadManager extends EventEmitter {
return;
}
}
if (isHttp416Text(exhaustedReason)) {
if (active.genericErrorRetries < maxHttp416Retries) {
this.scheduleHttp416Retry(item, active, retryDisplayLimit, exhaustedReason, claimedTargetPath);
this.persistSoon();
this.emitState();
return;
}
this.escalateHttp416OrFail(item, active, claimedTargetPath, exhaustedReason);
if (isHttp416Text(exhaustedReason) && active.genericErrorRetries < maxHttp416Retries) {
this.scheduleHttp416Retry(item, active, retryDisplayLimit, exhaustedReason, claimedTargetPath);
this.persistSoon();
this.emitState();
return;
}
if (isResumeHardResetReason(exhaustedReason) && !active.resumeHardResetUsed) {
@ -9442,7 +9311,17 @@ export class DownloadManager extends EventEmitter {
this.emitState();
return;
}
this.escalateHttp416OrFail(item, active, claimedTargetPath, errorText);
item.status = "failed";
this.recordRunOutcome(item.id, "failed");
item.lastError = errorText;
item.fullStatus = `Fehler: ${item.lastError}`;
item.speedBps = 0;
item.updatedAt = nowMs();
const failPkg416 = this.session.packages[item.packageId];
if (failPkg416) this.refreshPackageStatus(failPkg416);
this.persistSoon();
this.emitState();
this.retryStateByItem.delete(item.id);
return;
}
if (shouldFreshRetry) {
@ -9557,43 +9436,7 @@ export class DownloadManager extends EventEmitter {
return;
}
const megaRawError = error instanceof Error ? String(error.message || "") : String(error || "");
const megaSlowLinkRetry = parseMegaDebridSlowLinkRetry(megaRawError);
if (megaSlowLinkRetry && active.unrestrictRetries < maxUnrestrictRetries) {
active.unrestrictRetries += 1;
item.retries += 1;
item.provider = null;
logger.warn(`Mega-Debrid Link langsam (Timeout): item=${item.fileName || item.id}, retry=${active.unrestrictRetries}/${retryDisplayLimit}, delay=${megaSlowLinkRetry.delayMs}ms, link=${item.url.slice(0, 80)}`);
this.queueRetry(
item,
active,
megaSlowLinkRetry.delayMs,
`Mega-Debrid: Link zu langsam, Einzel-Retry in ${Math.ceil(megaSlowLinkRetry.delayMs / 1000)}s`
);
item.lastError = megaSlowLinkRetry.detail || errorText;
this.persistSoon();
this.emitState();
return;
}
const megaCooldownRetry = parseMegaDebridCooldownRetry(megaRawError);
if (megaCooldownRetry && active.unrestrictRetries < maxUnrestrictRetries) {
active.unrestrictRetries += 1;
item.retries += 1;
logger.warn(`Mega-Debrid Account-Cooldown: item=${item.fileName || item.id}, retry=${active.unrestrictRetries}/${retryDisplayLimit}, delay=${megaCooldownRetry.delayMs}ms, link=${item.url.slice(0, 80)}`);
this.queueRetry(
item,
active,
megaCooldownRetry.delayMs,
`Mega-Debrid Cooldown, neuer Versuch in ${Math.ceil(megaCooldownRetry.delayMs / 1000)}s`
);
item.lastError = megaCooldownRetry.detail || errorText;
this.persistSoon();
this.emitState();
return;
}
const megaResetPark = parseMegaDebridResetPark(megaRawError);
const megaResetPark = parseMegaDebridResetPark(errorText);
if (megaResetPark && active.unrestrictRetries < maxUnrestrictRetries) {
active.unrestrictRetries += 1;
item.retries += 1;
@ -9610,6 +9453,23 @@ export class DownloadManager extends EventEmitter {
return;
}
const megaCooldownRetry = parseMegaDebridCooldownRetry(errorText);
if (megaCooldownRetry && active.unrestrictRetries < maxUnrestrictRetries) {
active.unrestrictRetries += 1;
item.retries += 1;
logger.warn(`Mega-Debrid Account-Cooldown: item=${item.fileName || item.id}, retry=${active.unrestrictRetries}/${retryDisplayLimit}, delay=${megaCooldownRetry.delayMs}ms, link=${item.url.slice(0, 80)}`);
this.queueRetry(
item,
active,
megaCooldownRetry.delayMs,
`Mega-Debrid Cooldown, neuer Versuch in ${Math.ceil(megaCooldownRetry.delayMs / 1000)}s`
);
item.lastError = megaCooldownRetry.detail || errorText;
this.persistSoon();
this.emitState();
return;
}
if (isMegaDebridTransientResolveFailure(errorText) && active.unrestrictRetries < maxUnrestrictRetries) {
active.unrestrictRetries += 1;
item.retries += 1;
@ -10132,7 +9992,6 @@ export class DownloadManager extends EventEmitter {
if (previouslyContributed > 0) {
this.session.totalDownloadedBytes = Math.max(0, this.session.totalDownloadedBytes - previouslyContributed);
this.sessionDownloadedBytes = Math.max(0, this.sessionDownloadedBytes - previouslyContributed);
this.settings.totalDownloadedAllTime = Math.max(0, Number(this.settings.totalDownloadedAllTime || 0) - previouslyContributed);
this.itemContributedBytes.set(active.itemId, 0);
}
if (existingBytes > 0) {
@ -10623,6 +10482,7 @@ export class DownloadManager extends EventEmitter {
await fs.promises.rm(effectiveTargetPath, { force: true });
} catch { }
this.releaseTargetPath(active.itemId);
this.dropItemContribution(active.itemId);
item.downloadedBytes = 0;
item.progressPercent = 0;
throw new Error(`Download zu klein (${written} B) Hoster-Fehlerseite?${snippet ? ` Inhalt: "${snippet}"` : ""}`);
@ -10747,12 +10607,7 @@ export class DownloadManager extends EventEmitter {
rewindTarget
});
} catch (rewindError) {
try {
fs.rmSync(effectiveTargetPath, { force: true });
item.downloadedBytes = 0;
} catch {
}
logAttemptEvent("WARN", "Resume-Schutz: finales Rueckspulen fehlgeschlagen, Teil-Datei verworfen", {
logAttemptEvent("WARN", "Resume-Schutz: finales Rueckspulen fehlgeschlagen", {
attempt,
written,
rewindTarget,

View File

@ -1,7 +1,7 @@
import fs from "node:fs";
import path from "node:path";
import { app, BrowserWindow, clipboard, dialog, ipcMain, IpcMainInvokeEvent, Menu, shell, Tray } from "electron";
import { AddLinksPayload, AppSettings, DebridProvider, EnableRemoteDiagnosticsInput, UpdateInstallProgress } from "../shared/types";
import { AddLinksPayload, AppSettings, DebridProvider, UpdateInstallProgress } from "../shared/types";
import { AppController } from "./app-controller";
import { IPC_CHANNELS } from "../shared/ipc";
import { getLogFilePath, logger } from "./logger";
@ -571,7 +571,7 @@ function registerIpcHandlers(): void {
ipcMain.handle(IPC_CHANNELS.EXPORT_BACKUP, async () => {
const options = {
defaultPath: `${new Date().toISOString().slice(0, 10).split("-").reverse().join("-")}-mdd-backup.mdd`,
defaultPath: `mdd-backup-${new Date().toISOString().slice(0, 10)}.mdd`,
filters: [{ name: "MDD Backup", extensions: ["mdd"] }]
};
const result = mainWindow ? await dialog.showSaveDialog(mainWindow, options) : await dialog.showSaveDialog(options);
@ -671,33 +671,6 @@ function registerIpcHandlers(): void {
return { path: rotated.path };
});
ipcMain.handle(IPC_CHANNELS.GET_REMOTE_DIAGNOSTICS, async () => {
return controller.getRemoteDiagnostics();
});
ipcMain.handle(IPC_CHANNELS.ENABLE_REMOTE_DIAGNOSTICS, async (_event: IpcMainInvokeEvent, input: EnableRemoteDiagnosticsInput) => {
if (!input || (input.hostMode !== "local" && input.hostMode !== "network")) {
throw new Error("hostMode muss 'local' oder 'network' sein");
}
const allowlist = Array.isArray(input.allowlist) ? input.allowlist.map((entry) => String(entry)) : [];
return controller.enableRemoteDiagnostics({
hostMode: input.hostMode,
publicHost: String(input.publicHost || ""),
port: input.port ? Number(input.port) : undefined,
allowlist,
name: input.name ? String(input.name) : undefined,
rotateToken: Boolean(input.rotateToken)
});
});
ipcMain.handle(IPC_CHANNELS.DISABLE_REMOTE_DIAGNOSTICS, async () => {
return controller.disableRemoteDiagnostics();
});
ipcMain.handle(IPC_CHANNELS.ROTATE_REMOTE_DIAGNOSTICS_TOKEN, async () => {
return controller.rotateRemoteDiagnosticsToken();
});
ipcMain.handle(IPC_CHANNELS.OPEN_ITEM_LOG, async (_event: IpcMainInvokeEvent, itemId: string) => {
validateString(itemId, "itemId");
const logPath = controller.getItemLogPath(itemId);

View File

@ -177,12 +177,12 @@ async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise<void>
});
}
async function raceWithAbort<T>(promise: Promise<T>, signal?: AbortSignal, abortErrorFactory: () => Error = abortError): Promise<T> {
async function raceWithAbort<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
if (!signal) {
return promise;
}
if (signal.aborted) {
throw abortErrorFactory();
throw abortError();
}
return new Promise<T>((resolve, reject) => {
@ -194,7 +194,7 @@ async function raceWithAbort<T>(promise: Promise<T>, signal?: AbortSignal, abort
}
settled = true;
signal.removeEventListener("abort", onAbort);
reject(abortErrorFactory());
reject(abortError());
};
signal.addEventListener("abort", onAbort, { once: true });
@ -284,7 +284,6 @@ export class MegaWebFallback {
private async runExclusive<T>(job: () => Promise<T>, key: string, signal?: AbortSignal): Promise<T> {
const queuedAt = Date.now();
const QUEUE_WAIT_TIMEOUT_MS = 90000;
let workStarted = false;
const guardedJob = async (): Promise<T> => {
throwIfAborted(signal);
const waited = Date.now() - queuedAt;
@ -292,7 +291,6 @@ export class MegaWebFallback {
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)`);
}
workStarted = true;
const workStartedAt = Date.now();
try {
const result = await job();
@ -306,11 +304,7 @@ export class MegaWebFallback {
const prev = this.queues.get(key) ?? Promise.resolve();
const run = prev.then(guardedJob, guardedJob);
this.queues.set(key, run.then(() => undefined, () => undefined));
return raceWithAbort(run, signal, () =>
workStarted
? abortError()
: new Error(`Mega-Web Queue-Timeout (abgebrochen nach ${Math.floor((Date.now() - queuedAt) / 1000)}s Wartezeit, Account war belegt)`)
);
return raceWithAbort(run, signal);
}
private async login(login: string, password: string, signal?: AbortSignal): Promise<string> {

View File

@ -461,7 +461,6 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
hideExtractedItems: settings.hideExtractedItems !== undefined ? Boolean(settings.hideExtractedItems) : defaults.hideExtractedItems,
confirmDeleteSelection: settings.confirmDeleteSelection !== undefined ? Boolean(settings.confirmDeleteSelection) : defaults.confirmDeleteSelection,
backupIncludeDownloads: settings.backupIncludeDownloads !== undefined ? Boolean(settings.backupIncludeDownloads) : defaults.backupIncludeDownloads,
backupIncludeMcp: settings.backupIncludeMcp !== undefined ? Boolean(settings.backupIncludeMcp) : defaults.backupIncludeMcp,
notifyUrl: asText(settings.notifyUrl) || defaults.notifyUrl,
notifyMention: asText(settings.notifyMention) || defaults.notifyMention,
notifyOnPackageCompleted: settings.notifyOnPackageCompleted !== undefined ? Boolean(settings.notifyOnPackageCompleted) : defaults.notifyOnPackageCompleted,
@ -628,24 +627,12 @@ function normalizeAudioStripSummary(raw: unknown): AudioStripSummary | undefined
};
}
function migrateLegacyMegaEnableFlags(parsed: AppSettings): AppSettings {
if (parsed.megaDebridApiEnabled !== undefined || parsed.megaDebridWebEnabled !== undefined) {
return parsed;
}
const hasMegaCreds = Boolean(asText(parsed.megaLogin) && asText(parsed.megaPassword));
if (!hasMegaCreds) {
return parsed;
}
const preferApi = parsed.megaDebridPreferApi !== undefined ? Boolean(parsed.megaDebridPreferApi) : true;
return { ...parsed, megaDebridApiEnabled: preferApi, megaDebridWebEnabled: !preferApi };
}
function readSettingsFile(filePath: string): AppSettings | null {
try {
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as AppSettings;
const merged = normalizeSettings({
...defaultSettings(),
...migrateLegacyMegaEnableFlags(parsed)
...parsed
});
return sanitizeCredentialPersistence(merged);
} catch (error) {
@ -890,50 +877,21 @@ export function normalizeLoadedSessionTransientFields(session: SessionState): Se
return session;
}
const TRANSIENT_READ_CODES = new Set(["EBUSY", "EPERM", "EAGAIN"]);
function sleepSyncMs(ms: number): void {
if (ms <= 0) {
return;
}
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
}
function readSessionFile(filePath: string): SessionState | null {
let raw: string | null = null;
const maxAttempts = 5;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
raw = fs.readFileSync(filePath, "utf8");
break;
} catch (error) {
const code = (error as NodeJS.ErrnoException)?.code || "";
if (TRANSIENT_READ_CODES.has(code) && attempt < maxAttempts) {
const backoffMs = 100 * 2 ** (attempt - 1);
logger.warn(`Session-Datei vorübergehend gesperrt (${code}), Versuch ${attempt}/${maxAttempts}, warte ${backoffMs}ms: ${filePath}`);
sleepSyncMs(backoffMs);
continue;
}
if (code === "EACCES" || code === "EPERM") {
logger.error(`Session-Datei nicht zugreifbar (${code}): ${filePath} - pruefe Datei-/Ordner-Berechtigungen fuer Benutzer ${process.env.USERNAME || process.env.USER || "?"}`);
} else {
logger.error(`Session-Datei nicht lesbar (${code || "?"}): ${filePath}: ${String(error)}`);
}
return null;
}
}
if (raw === null) {
return null;
}
try {
const parsed = JSON.parse(raw) as unknown;
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as unknown;
const session = normalizeLoadedSessionTransientFields(normalizeLoadedSession(parsed));
const pkgCount = Object.keys(session.packages).length;
const itemCount = Object.keys(session.items).length;
logger.info(`Session geladen: ${filePath} (${pkgCount} Pakete, ${itemCount} Items)`);
return session;
} catch (error) {
logger.error(`Session-Datei beschädigt (JSON ungültig): ${filePath}: ${String(error)}`);
const code = (error as NodeJS.ErrnoException)?.code || "";
if (code === "EACCES" || code === "EPERM") {
logger.error(`Session-Datei nicht zugreifbar (${code}): ${filePath} - pruefe Datei-/Ordner-Berechtigungen fuer Benutzer ${process.env.USERNAME || process.env.USER || "?"}`);
} else {
logger.error(`Session-Datei nicht lesbar: ${filePath}: ${String(error)}`);
}
return null;
}
}
@ -1033,31 +991,17 @@ export function emptySession(): SessionState {
};
}
export type SessionLoadStatus =
| "ok"
| "recovered-backup"
| "recovered-temp"
| "empty-fresh"
| "empty-unreadable";
export interface SessionLoadResult {
session: SessionState;
status: SessionLoadStatus;
}
export function loadSessionWithStatus(paths: StoragePaths): SessionLoadResult {
export function loadSession(paths: StoragePaths): SessionState {
ensureBaseDir(paths.baseDir);
const backupFile = sessionBackupPath(paths.sessionFile);
const syncTempFile = sessionTempPath(paths.sessionFile, "sync");
const asyncTempFile = sessionTempPath(paths.sessionFile, "async");
const primaryExists = fs.existsSync(paths.sessionFile);
const backupExists = fs.existsSync(backupFile);
const anyTempExists = fs.existsSync(syncTempFile) || fs.existsSync(asyncTempFile);
if (!primaryExists) {
if (!backupExists && !anyTempExists) {
const hasRecoverable = fs.existsSync(backupFile)
|| fs.existsSync(sessionTempPath(paths.sessionFile, "sync"))
|| fs.existsSync(sessionTempPath(paths.sessionFile, "async"));
if (!hasRecoverable) {
logger.info("Keine Session-Datei vorhanden, starte mit leerer Session");
return { session: emptySession(), status: "empty-fresh" };
return emptySession();
}
logger.warn("Session-Primaerdatei fehlt, aber Backup/Temp vorhanden — Wiederherstellung wird versucht");
}
@ -1066,7 +1010,7 @@ export function loadSessionWithStatus(paths: StoragePaths): SessionLoadResult {
if (primary) {
const primaryPkgCount = Object.keys(primary.packages).length;
if (primaryPkgCount === 0 && backupExists) {
if (primaryPkgCount === 0 && fs.existsSync(backupFile)) {
const backup = readSessionFile(backupFile);
if (backup) {
const backupPkgCount = Object.keys(backup.packages).length;
@ -1074,27 +1018,29 @@ export function loadSessionWithStatus(paths: StoragePaths): SessionLoadResult {
logger.warn(`Session-Datei ist leer (0 Pakete), aber Backup hat ${backupPkgCount} Pakete — verwende Backup`);
try {
const payload = JSON.stringify({ ...backup, updatedAt: Date.now() }, safeJsonReplacer);
fs.writeFileSync(syncTempFile, payload, "utf8");
syncRenameWithExdevFallback(syncTempFile, paths.sessionFile);
const tempPath = sessionTempPath(paths.sessionFile, "sync");
fs.writeFileSync(tempPath, payload, "utf8");
syncRenameWithExdevFallback(tempPath, paths.sessionFile);
} catch {
}
return { session: backup, status: "recovered-backup" };
return backup;
}
}
}
return { session: primary, status: "ok" };
return primary;
}
const backup = backupExists ? readSessionFile(backupFile) : null;
const backup = fs.existsSync(backupFile) ? readSessionFile(backupFile) : null;
if (backup) {
logger.warn("Session defekt, Backup-Datei wird verwendet");
try {
const payload = JSON.stringify({ ...backup, updatedAt: Date.now() }, safeJsonReplacer);
fs.writeFileSync(syncTempFile, payload, "utf8");
syncRenameWithExdevFallback(syncTempFile, paths.sessionFile);
const tempPath = sessionTempPath(paths.sessionFile, "sync");
fs.writeFileSync(tempPath, payload, "utf8");
syncRenameWithExdevFallback(tempPath, paths.sessionFile);
} catch {
}
return { session: backup, status: "recovered-backup" };
return backup;
}
for (const kind of ["sync", "async"] as const) {
@ -1108,21 +1054,13 @@ export function loadSessionWithStatus(paths: StoragePaths): SessionLoadResult {
fs.writeFileSync(paths.sessionFile, payload, "utf8");
} catch {
}
return { session: tmpSession, status: "recovered-temp" };
return tmpSession;
}
}
}
if (primaryExists || backupExists || anyTempExists) {
logger.error("Session konnte nicht geladen werden (Primary, Backup und Temp-Dateien fehlgeschlagen) — Schutz gegen leeres Ueberschreiben aktiv");
return { session: emptySession(), status: "empty-unreadable" };
}
return { session: emptySession(), status: "empty-fresh" };
}
export function loadSession(paths: StoragePaths): SessionState {
return loadSessionWithStatus(paths).session;
logger.error("Session konnte nicht geladen werden (Primary, Backup und Temp-Dateien fehlgeschlagen)");
return emptySession();
}
export function saveSession(paths: StoragePaths, session: SessionState): void {
@ -1137,13 +1075,7 @@ export function saveSession(paths: StoragePaths, session: SessionState): void {
const payload = JSON.stringify({ ...session, updatedAt: Date.now() }, safeJsonReplacer);
const tempPath = sessionTempPath(paths.sessionFile, "sync");
try {
const fd = fs.openSync(tempPath, "w");
try {
fs.writeSync(fd, payload);
fs.fsyncSync(fd);
} finally {
fs.closeSync(fd);
}
fs.writeFileSync(tempPath, payload, "utf8");
syncRenameWithExdevFallback(tempPath, paths.sessionFile);
} catch (error) {
try { fs.rmSync(tempPath, { force: true }); } catch { }
@ -1159,13 +1091,7 @@ async function writeSessionPayload(paths: StoragePaths, payload: string, generat
await fs.promises.mkdir(paths.baseDir, { recursive: true });
await fsp.copyFile(paths.sessionFile, sessionBackupPath(paths.sessionFile)).catch(() => {});
const tempPath = sessionTempPath(paths.sessionFile, "async");
const handle = await fsp.open(tempPath, "w");
try {
await handle.writeFile(payload, "utf8");
await handle.sync();
} finally {
await handle.close();
}
await fsp.writeFile(tempPath, payload, "utf8");
if (generation < syncSaveGeneration) {
await fsp.rm(tempPath, { force: true }).catch(() => {});
return;

View File

@ -7,10 +7,8 @@ import {
DebridLinkHostLimitInfo,
DebridProvider,
DuplicatePolicy,
EnableRemoteDiagnosticsInput,
HistoryEntry,
PackagePriority,
RemoteDiagnosticsInfo,
RendererErrorReport,
SessionStats,
StartConflictEntry,
@ -76,10 +74,6 @@ const api: ElectronApi = {
getTraceConfig: () => ipcRenderer.invoke(IPC_CHANNELS.GET_TRACE_CONFIG),
setTraceEnabled: (enabled: boolean, note?: string, durationMinutes?: number) => ipcRenderer.invoke(IPC_CHANNELS.SET_TRACE_ENABLED, enabled, note, durationMinutes),
rotateDebugToken: (): Promise<{ path: string }> => ipcRenderer.invoke(IPC_CHANNELS.ROTATE_DEBUG_TOKEN),
getRemoteDiagnostics: (): Promise<RemoteDiagnosticsInfo> => ipcRenderer.invoke(IPC_CHANNELS.GET_REMOTE_DIAGNOSTICS),
enableRemoteDiagnostics: (input: EnableRemoteDiagnosticsInput): Promise<RemoteDiagnosticsInfo> => ipcRenderer.invoke(IPC_CHANNELS.ENABLE_REMOTE_DIAGNOSTICS, input),
disableRemoteDiagnostics: (): Promise<RemoteDiagnosticsInfo> => ipcRenderer.invoke(IPC_CHANNELS.DISABLE_REMOTE_DIAGNOSTICS),
rotateRemoteDiagnosticsToken: (): Promise<RemoteDiagnosticsInfo> => ipcRenderer.invoke(IPC_CHANNELS.ROTATE_REMOTE_DIAGNOSTICS_TOKEN),
openRealDebridLogin: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_REALDEBRID_LOGIN),
openAllDebridLogin: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_ALLDEBRID_LOGIN),
importBestDebridCookies: (): Promise<number> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BESTDEBRID_COOKIES),

View File

@ -16,7 +16,6 @@ import type {
DuplicatePolicy,
HistoryEntry,
PackageEntry,
RemoteDiagnosticsInfo,
StartConflictEntry,
UiSnapshot,
UpdateCheckResult,
@ -856,7 +855,7 @@ const emptySnapshot = (): UiSnapshot => ({
autoReconnect: false, reconnectWaitSeconds: 45, completedCleanupPolicy: "never",
maxParallel: 4, maxParallelExtract: 2, extractCpuPriority: "high", retryLimit: 0, speedLimitEnabled: false, speedLimitKbps: 0, speedLimitMode: "global",
updateRepo: "", autoUpdateCheck: true, clipboardWatch: false, minimizeToTray: false,
theme: "dark", collapseNewPackages: true, historyRetentionMode: "permanent", historyMaxEntries: 500, historyMaxAgeDays: 0, autoSortPackagesByProgress: true, autoSkipExtracted: false, hideExtractedItems: true, confirmDeleteSelection: true, backupIncludeDownloads: false, backupIncludeMcp: false,
theme: "dark", collapseNewPackages: true, historyRetentionMode: "permanent", historyMaxEntries: 500, historyMaxAgeDays: 0, autoSortPackagesByProgress: true, autoSkipExtracted: false, hideExtractedItems: true, confirmDeleteSelection: true, backupIncludeDownloads: false,
notifyUrl: "", notifyMention: "", notifyOnPackageCompleted: false, notifyOnPackageFailed: false, notifyOnRunFinished: false,
accountListShowDetailedDebridLinkKeys: false,
bandwidthSchedules: [], totalDownloadedAllTime: 0, totalCompletedFilesAllTime: 0, totalRuntimeAllTimeMs: 0,
@ -1762,14 +1761,6 @@ export function App(): ReactElement {
const [startConflictPrompt, setStartConflictPrompt] = useState<StartConflictPromptState | null>(null);
const startConflictResolverRef = useRef<((result: { policy: Extract<DuplicatePolicy, "skip" | "overwrite">; applyToAll: boolean } | null) => void) | null>(null);
const [confirmPrompt, setConfirmPrompt] = useState<ConfirmPromptState | null>(null);
const [remoteDiag, setRemoteDiag] = useState<RemoteDiagnosticsInfo | null>(null);
const [remoteDiagOpen, setRemoteDiagOpen] = useState(false);
const [remoteDiagBusy, setRemoteDiagBusy] = useState(false);
const [rdHostMode, setRdHostMode] = useState<"local" | "network">("local");
const [rdPublicHost, setRdPublicHost] = useState("");
const [rdPort, setRdPort] = useState("9868");
const [rdAllowlist, setRdAllowlist] = useState("");
const [rdName, setRdName] = useState("");
const confirmResolverRef = useRef<((confirmed: boolean) => void) | null>(null);
const confirmQueueRef = useRef<Array<{ prompt: ConfirmPromptState; resolve: (confirmed: boolean) => void }>>([]);
const importQueueFocusHandlerRef = useRef<(() => void) | null>(null);
@ -4337,89 +4328,6 @@ export function App(): ReactElement {
});
};
const applyRemoteDiagInfo = (info: RemoteDiagnosticsInfo): void => {
setRemoteDiag(info);
setRdHostMode(info.status.localOnly ? "local" : "network");
setRdPublicHost(info.publicHost || (info.status.localOnly ? "" : (info.suggestedHosts[0] || "")));
setRdPort(String(info.status.port || 9868));
setRdAllowlist(info.allowlist.join("\n"));
setRdName(info.name || "");
};
const onOpenRemoteDiagnostics = async (): Promise<void> => {
closeMenus();
try {
const info = await window.rd.getRemoteDiagnostics();
applyRemoteDiagInfo(info);
setRemoteDiagOpen(true);
} catch (error) {
showToast(`Ferndiagnose-Status fehlgeschlagen: ${String(error)}`, 3000);
}
};
const onSubmitRemoteDiagnostics = async (): Promise<void> => {
const port = Number(rdPort) || 9868;
const allowlist = rdAllowlist.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0);
if (rdHostMode === "network" && allowlist.length === 0) {
showToast("Netzwerkmodus braucht mindestens eine IP oder CIDR in der Allowlist", 3600);
return;
}
setRemoteDiagBusy(true);
try {
const info = await window.rd.enableRemoteDiagnostics({
hostMode: rdHostMode,
publicHost: rdPublicHost,
port,
allowlist,
name: rdName
});
applyRemoteDiagInfo(info);
showToast(info.status.running ? "Ferndiagnose aktiv" : "Ferndiagnose konfiguriert", 2600);
} catch (error) {
showToast(`Aktivieren fehlgeschlagen: ${String(error)}`, 3600);
} finally {
setRemoteDiagBusy(false);
}
};
const onDisableRemoteDiagnostics = async (): Promise<void> => {
setRemoteDiagBusy(true);
try {
const info = await window.rd.disableRemoteDiagnostics();
applyRemoteDiagInfo(info);
showToast("Ferndiagnose deaktiviert", 2400);
} catch (error) {
showToast(`Deaktivieren fehlgeschlagen: ${String(error)}`, 3000);
} finally {
setRemoteDiagBusy(false);
}
};
const onRotateRemoteDiagnosticsToken = async (): Promise<void> => {
setRemoteDiagBusy(true);
try {
const info = await window.rd.rotateRemoteDiagnosticsToken();
applyRemoteDiagInfo(info);
showToast("Neues Token - alter Verbindungscode ist ungueltig", 3000);
} catch (error) {
showToast(`Token-Rotation fehlgeschlagen: ${String(error)}`, 3000);
} finally {
setRemoteDiagBusy(false);
}
};
const onCopyRemoteDiagnosticsCode = async (): Promise<void> => {
if (!remoteDiag?.code) {
return;
}
try {
await navigator.clipboard.writeText(remoteDiag.code);
showToast("Verbindungscode kopiert", 2200);
} catch {
showToast("Kopieren fehlgeschlagen", 2200);
}
};
const onMenuRestart = (): void => {
closeMenus();
void window.rd.restart();
@ -4810,7 +4718,6 @@ export function App(): ReactElement {
<button className="menu-submenu-trigger">Remote-Support</button>
{openSubmenu === "hilfe-remote" && (
<div className="menu-submenu-dropdown">
<button className="menu-dropdown-item" onClick={() => { void onOpenRemoteDiagnostics(); }}><span>Ferndiagnose (MCP) </span></button>
<button className="menu-dropdown-item" onClick={() => { void onExportSupportBundle(); }}><span>Support-Bundle exportieren</span></button>
<button className="menu-dropdown-item" onClick={() => { void onToggleSupportTrace(); }}><span>{supportTraceEnabled ? "Support-Trace deaktivieren" : "Support-Trace aktivieren"}</span></button>
</div>
@ -5442,8 +5349,6 @@ export function App(): ReactElement {
<div className="setting-hint">Sicherheitsabfrage vor dem Entfernen ausgewählter Einträge.</div>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.backupIncludeDownloads} onChange={(e) => setBool("backupIncludeDownloads", e.target.checked)} /> Download-Liste mitsichern</label>
<div className="setting-hint">Sicherung enthält auch die Download-Liste; Standard: nur Einstellungen.</div>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.backupIncludeMcp} onChange={(e) => setBool("backupIncludeMcp", e.target.checked)} /> Ferndiagnose-Einstellungen mitsichern</label>
<div className="setting-hint">Allowlist, Port und Freigabemodus (lokal/Netzwerk) reisen mit. Verbindungs-Token und eigene Adresse bleiben pro Server nach dem Import einmal Aktivieren" drücken.</div>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.theme === "light"} onChange={(e) => {
const next = e.target.checked ? "light" : "dark";
settingsDraftRevisionRef.current += 1;
@ -6029,97 +5934,6 @@ export function App(): ReactElement {
</div>
)}
{remoteDiagOpen && (
<div className="modal-backdrop" onClick={() => setRemoteDiagOpen(false)}>
<div className="modal-card" onClick={(event) => event.stopPropagation()}>
<h3>Ferndiagnose (MCP)</h3>
<p>Aktiviert einen abgesicherten Lesezugriff auf Status, Logs und Fehler dieses Servers. Den Verbindungscode dem Assistenten geben - er verbindet sich, sieht alles und behebt Probleme.</p>
<div className="rd-status-line">
<span className={`rd-dot${remoteDiag?.status.running ? " on" : ""}`} />
<span>
{remoteDiag?.status.running
? `Aktiv auf ${remoteDiag.status.host}:${remoteDiag.status.port}${remoteDiag.status.localOnly ? " (nur lokal)" : ` (Allowlist: ${remoteDiag.status.allowlistCount})`}`
: "Inaktiv"}
</span>
</div>
<div className="rd-field">
<label>Sichtbarkeit</label>
<div className="rd-seg">
<button className={rdHostMode === "local" ? "active" : ""} onClick={() => setRdHostMode("local")}>Nur lokal</button>
<button className={rdHostMode === "network" ? "active" : ""} onClick={() => setRdHostMode("network")}>Im Netzwerk</button>
</div>
<span className="rd-hint">
{rdHostMode === "local"
? "Bindet nur an 127.0.0.1. Fernzugriff nur ueber einen Tunnel (z.B. Tailscale/SSH) - die sicherste Variante."
: "Bindet an 0.0.0.0. Erreichbar im Netzwerk, erfordert eine Allowlist. Nur in vertrauenswuerdigen Netzen/VPN nutzen."}
</span>
</div>
<div className="rd-field">
<label>Oeffentliche Adresse (fuer den Verbindungscode)</label>
<input
value={rdPublicHost}
placeholder={rdHostMode === "local" ? "127.0.0.1 oder Tunnel-Adresse" : "Server-IP oder DNS-Name"}
onChange={(event) => setRdPublicHost(event.target.value)}
/>
{remoteDiag && remoteDiag.suggestedHosts.length > 0 && (
<div className="rd-chips">
{remoteDiag.suggestedHosts.map((host) => (
<button key={host} className="rd-chip" onClick={() => setRdPublicHost(host)}>{host}</button>
))}
</div>
)}
</div>
<div className="rd-field rd-field-inline">
<div className="rd-field">
<label>Port</label>
<input value={rdPort} onChange={(event) => setRdPort(event.target.value.replace(/[^0-9]/g, ""))} />
</div>
<div className="rd-field">
<label>Name (optional)</label>
<input value={rdName} placeholder="z.B. Server-Berlin" onChange={(event) => setRdName(event.target.value)} />
</div>
</div>
{rdHostMode === "network" && (
<div className="rd-field">
<label>Allowlist - erlaubte IPs/CIDR (eine pro Zeile)</label>
<textarea
value={rdAllowlist}
placeholder={"203.0.113.5\n10.0.0.0/24"}
onChange={(event) => setRdAllowlist(event.target.value)}
/>
<span className="rd-hint">Pflicht im Netzwerkmodus. Nur diese Quell-IPs duerfen verbinden (zusaetzlich zum Token). Loopback ist immer erlaubt.</span>
</div>
)}
{remoteDiag?.status.running && remoteDiag.code && (
<div className="rd-field">
<label>Verbindungscode</label>
<div className="rd-code">{remoteDiag.code}</div>
<div className="rd-chips">
<button className="btn" onClick={() => { void onCopyRemoteDiagnosticsCode(); }}>Kopieren</button>
<button className="btn" onClick={() => { void onRotateRemoteDiagnosticsToken(); }} disabled={remoteDiagBusy}>Token neu (Code ungueltig machen)</button>
</div>
<span className="rd-hint">Enthaelt das Zugriffstoken - wie ein Passwort behandeln. Token neu = alter Code wird sofort ungueltig.</span>
</div>
)}
<div className="modal-actions">
<button className="btn" onClick={() => setRemoteDiagOpen(false)}>Schliessen</button>
{remoteDiag?.status.running && (
<button className="btn danger" onClick={() => { void onDisableRemoteDiagnostics(); }} disabled={remoteDiagBusy}>Deaktivieren</button>
)}
<button className="btn accent" onClick={() => { void onSubmitRemoteDiagnostics(); }} disabled={remoteDiagBusy}>
{remoteDiag?.status.running ? "Aktualisieren" : "Aktivieren"}
</button>
</div>
</div>
</div>
)}
{deleteConfirm && (() => {
const itemCount = [...deleteConfirm.ids].filter((id) => snapshot.session.items[id]).length;
const pkgCount = [...deleteConfirm.ids].filter((id) => snapshot.session.packages[id]).length;

View File

@ -3360,110 +3360,3 @@ td {
.rotation-event .rotation-time { color: var(--muted, #a59c8e); font-variant-numeric: tabular-nums; }
.rotation-event .rotation-body strong { font-weight: 600; }
.rotation-event .rotation-reason { color: var(--muted, #a59c8e); }
.rd-status-line {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--text);
}
.rd-dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--muted);
flex: 0 0 auto;
}
.rd-dot.on {
background: #3fb950;
}
.rd-field {
display: grid;
gap: 5px;
}
.rd-field > label {
font-size: 13px;
font-weight: 600;
color: var(--text);
}
.rd-field input,
.rd-field textarea {
width: 100%;
box-sizing: border-box;
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px 10px;
font-size: 13px;
font-family: inherit;
}
.rd-field input:focus,
.rd-field textarea:focus {
outline: none;
border-color: var(--accent);
}
.rd-field textarea {
resize: vertical;
min-height: 62px;
}
.rd-field-inline {
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.rd-hint {
font-size: 12px;
color: var(--muted);
line-height: 1.45;
}
.rd-seg {
display: flex;
width: fit-content;
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
}
.rd-seg button {
background: transparent;
color: var(--muted);
border: none;
padding: 7px 14px;
cursor: pointer;
font-size: 13px;
font-weight: 600;
}
.rd-seg button.active {
background: var(--accent);
color: #1a1206;
}
.rd-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.rd-chip {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
padding: 3px 9px;
font-size: 12px;
cursor: pointer;
color: var(--muted);
}
.rd-chip:hover {
color: var(--text);
border-color: var(--accent);
}
.rd-code {
font-family: ui-monospace, "Cascadia Code", monospace;
font-size: 12px;
word-break: break-all;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 9px 10px;
max-height: 120px;
overflow-y: auto;
color: var(--text);
}

View File

@ -52,10 +52,6 @@ export const IPC_CHANNELS = {
GET_TRACE_CONFIG: "app:get-trace-config",
SET_TRACE_ENABLED: "app:set-trace-enabled",
ROTATE_DEBUG_TOKEN: "app:rotate-debug-token",
GET_REMOTE_DIAGNOSTICS: "app:get-remote-diagnostics",
ENABLE_REMOTE_DIAGNOSTICS: "app:enable-remote-diagnostics",
DISABLE_REMOTE_DIAGNOSTICS: "app:disable-remote-diagnostics",
ROTATE_REMOTE_DIAGNOSTICS_TOKEN: "app:rotate-remote-diagnostics-token",
OPEN_REALDEBRID_LOGIN: "app:open-realdebrid-login",
OPEN_ALLDEBRID_LOGIN: "app:open-alldebrid-login",
IMPORT_BESTDEBRID_COOKIES: "app:import-bestdebrid-cookies",

View File

@ -7,10 +7,8 @@ import type {
DebridLinkHostLimitInfo,
DebridProvider,
DuplicatePolicy,
EnableRemoteDiagnosticsInput,
HistoryEntry,
PackagePriority,
RemoteDiagnosticsInfo,
RendererErrorReport,
SessionStats,
StartConflictEntry,
@ -73,10 +71,6 @@ export interface ElectronApi {
getTraceConfig: () => Promise<SupportTraceConfig>;
setTraceEnabled: (enabled: boolean, note?: string, durationMinutes?: number) => Promise<SupportTraceConfig>;
rotateDebugToken: () => Promise<{ path: string }>;
getRemoteDiagnostics: () => Promise<RemoteDiagnosticsInfo>;
enableRemoteDiagnostics: (input: EnableRemoteDiagnosticsInput) => Promise<RemoteDiagnosticsInfo>;
disableRemoteDiagnostics: () => Promise<RemoteDiagnosticsInfo>;
rotateRemoteDiagnosticsToken: () => Promise<RemoteDiagnosticsInfo>;
openRealDebridLogin: () => Promise<void>;
openAllDebridLogin: () => Promise<void>;
importBestDebridCookies: () => Promise<number>;

View File

@ -134,7 +134,6 @@ export interface AppSettings {
hideExtractedItems: boolean;
confirmDeleteSelection: boolean;
backupIncludeDownloads: boolean;
backupIncludeMcp: boolean;
notifyUrl: string;
notifyMention: string;
notifyOnPackageCompleted: boolean;
@ -538,30 +537,3 @@ export interface RendererErrorReport {
column?: number;
componentStack?: string;
}
export interface RemoteDiagnosticsStatus {
running: boolean;
host: string;
port: number;
hasToken: boolean;
localOnly: boolean;
allowlistCount: number;
}
export interface RemoteDiagnosticsInfo {
status: RemoteDiagnosticsStatus;
code: string | null;
publicHost: string;
name: string;
allowlist: string[];
suggestedHosts: string[];
}
export interface EnableRemoteDiagnosticsInput {
hostMode: "local" | "network";
publicHost: string;
port?: number;
allowlist: string[];
name?: string;
rotateToken?: boolean;
}

View File

@ -1,156 +1,8 @@
# Autonomer Audit-Loop — Download/Fehler/Rotation (Goal 2026-06-17, 8h)
## GOAL-ANPASSUNG (Nutzer, nach Runde 8 + Synthese-Start): noch Runde 9 + 10, dann Goal BEENDEN.
Plan: Synthese-Pass (wrexz7mdf, Capstone R1-8) auswerten → Runde 9 (Provider-spezifische
Unrestrict-/Rotations-Pfade: Mega-Web-Fallback Session/Single-Flight, AllDebrid Host-Cooldown/
Rapidgator-Backoff, DebridLink-Key-Rotation, Passwort-Cache-Race) → Runde 10 (Concurrency/Locking +
Settings/Persistenz-Integritaet: Hybrid-Race, targetPath-Claim/Release-Races, Settings-Migration,
Backup/Restore, account-check) → Abschlussbericht + Ende. Fixes je rot-bewiesen, buendeln zu v1.7.220 falls HIGH/MED.
Disziplin: erst BELEGEN (Code-Zitat + konkretes Szenario), dann adversarisch verifizieren,
dann TDD-Fix. Kein Blind-Fix. Tests gruen + tsc=6 nach jeder Runde. Periodisch releasen.
## Runde 9 (Provider-spezifische Unrestrict-/Rotations-Pfade) — Mega-Web-Single-Flight + DebridLink-Key + Concurrency
Fokus: Mega-Web-Fallback Session/Single-Flight-Queue, DebridLink-Key-Rotation, In-Flight-Verteilung.
- **CONFIRMED HIGH (GEFIXT) MW-1 Selbst-Cooldown eines GESUNDEN, nur in der Queue wartenden Web-Accounts:**
Der Caller-Timeout (`DEFAULT_UNRESTRICT_TIMEOUT_MS`=60s) feuert, waehrend die zweite Umwandlung eines
Accounts noch SERIELL in der Mega-Web-Single-Flight-Queue (90s `QUEUE_WAIT_TIMEOUT_MS`) auf den laufenden
Vorgaenger wartet — also bevor ueberhaupt echte Arbeit begann. `raceWithAbort` warf bisher unbedingt
`aborted:mega-web`; `unrestrictViaWeb` (debrid.ts:1887) flachte JEDEN signal-aborted-Fall zu `aborted:debrid`
ab; die Rotation (debrid.ts:2072) wertete das via `/aborted/i && !/timeout/i``ranLongEnough`
(elapsedMs schliesst die Queue-Wartezeit ein, also >= 8s) → **120s Account-Cooldown auf einen voellig
gesunden Account**. Genau die vom Nutzer gemeldete „Tool sperrt sich selbst"-Klasse (Web-Variante).
Zweiteiliger Fix:
(1) `MegaWebFallback.runExclusive` (mega-web-fallback.ts) trackt `workStarted` und reicht eine
`abortErrorFactory` an `raceWithAbort`: abgebrochen-bevor-Arbeit-begann → `Mega-Web Queue-Timeout (…)`
(matcht `/queue.?timeout/i`), nur ein echter In-Arbeit-Abbruch bleibt `aborted:mega-web`.
(2) `unrestrictViaWeb` (debrid.ts:1887) bewahrt einen `/queue.?timeout/i`-klassifizierten lastError statt
ihn zu `aborted:debrid` zu plaetten → Rotation trifft die bestehende Queue-Timeout-Ausnahme
(classifyAccountFailure 2243 → cooldownMs 0) statt den Abbruch-Cooldown-Zweig.
Rot-bewiesen NICHT-vakuum (zwei Tests, beide per Temp-Revert rot verifiziert):
- Teil 1 (mega-web-fallback.test.ts): echtes `MegaWebFallback`, Queue mit langsamem Erst-Job belegt,
Zweit-Call WAEHREND in Queue abgebrochen → `rejects.toThrow(/queue.?timeout/i)` (ohne Fix: `aborted:mega-web`).
- Teil 2 (debrid.test.ts): durch `DebridService.unrestrictLink` mit `RD_MEGA_ABORT_MIN_RUN_MS=0` (besiegt die
Vakuum-Falle: der buggy Pfad WUERDE selbst bei Sofort-Abbruch cooldownen), Signal bricht WAEHREND des
Calls ab, megaWeb meldet Queue-Timeout → `getMegaDebridAccountCooldownState` bleibt null (ohne Fix: 120s
„Abbruch/Timeout nach 0s"). tsc=6, volle mega-web+debrid-Suite 103 gruen inkl. der bestehenden
Abbruch-Cooldown-Tests (echter langsamer Account cooled WEITERHIN — keine Regression).
- **Rotations-Verifikation (Advisor-Shippability-Diskriminator):** Nach dem No-Cooldown-Pfad retried der
Manager `unrestrictWithAccounts` frisch. Die Account-Wahl sortiert per `megaDebridInFlight`-TIEFE
(debrid.ts:1981-1986, least-busy zuerst). Invariante: jeder Belegt-Halter der MegaWebFallback-Queue
entspricht einem in-flight `client.unrestrictLink`, das `megaDebridInFlight` inkrementiert hat (2033,
Dekrement nur im finally 2152). Also sieht der Retry des queue-getimeouteten Links den saturierenden
Account bei Tiefe>=1 und rotiert auf einen freien — MW-1 ist NACHWEISLICH besser als das alte 120s-Lockout
(das einen nur-belegten gesunden Account sperrte). Kein Tight-Retry-Loop auf saturierter Queue, solange
irgendein Account frei ist; sind ALLE saturiert, queued der least-busy (unvermeidbar — keine freie Kapazitaet,
aber das alte Cooldown haette es via Faux-Park SCHLIMMER gemacht).
- **DOKUMENTIERT, nicht gefixt (LOW) DL-1 DebridLink-Key-Cooldown bei User-Cancel-Abbruch:** classifyKeyFailure
(debrid.ts:3118-3126) gibt fuer Text mit „aborted" (via isRetryableErrorText 597 + isTransport 3119)
`cooldownMs: 15_000` zurueck — auch bei einem SCHNELLEN User-Cancel (kein Min-Run-Gate wie bei Mega 2072).
Symptom: 15s Key-Cooldown nach Nutzer-Abbruch. Advisor (revidiert eigenen frueheren „clean fix"-Call):
classifyKeyFailure hat KEIN elapsedMs und DebridLink hat keinen Min-Run-Knopf-Aequivalent zu
`getMegaDebridAbortMinRunMs()`. Der treue Fix (Caller-Gate bei 2787 spiegeln) braucht entweder cross-Provider-
Wiederverwendung des Mega-Knopfes oder einen neuen DebridLink-Knopf = neue Oberflaeche im Live-Hot-Path fuer
einen LOW-Bug (Symptom: 15s nach User-Cancel). Klart die eigene „clean + null-Regression"-Schwelle NICHT →
dokumentiert, nicht gefixt.
- **DOKUMENTIERT, nicht gefixt (MED) DL-CONCURRENCY-PILEUP:** Bei gleichzeitigen Umwandlungen, die ALLE
Mega-Accounts saturieren, queued der least-busy-Account weitere Links seriell (per-Account-Single-Flight) →
Wartezeiten stapeln sich. MW-1 entfernt die FRUEHERE versehentliche Backpressure (der falsche 120s-Cooldown
wirkte als grobe Ratenbegrenzung), routet aber korrekt per In-Flight-Tiefe um belegte Accounts herum statt sie
faelschlich zu sperren — strikt besser. Echter Fix (In-Flight-Tiefen-Spread groesser ziehen / globale
Web-Parallelitaetsgrenze) = groessere Aenderung, 4.-Bug-Risiko auf Live-Server → deferred. MW-1 reduziert das
Pileup-in-Park-Risiko bereits (kein Faux-Lockout merely-busy Accounts).
## Runde 10 (Concurrency/Locking + Settings/Persistenz-Integritaet) — LETZTE RUNDE (Nutzer: „nach der Runde ist Schluss")
4 Finder (hybrid/targetPath-Races, Slot-Accounting, Settings-Migration/Persistenz, Backup-Restore/account-check)
→ adversarisch 3 Lenses → Synthese. Workflow wwyqsdrf9: 2 Finder (slot-accounting, settings-persistence) starben
an Stream-Idle-Timeout (Riesen-Dateien) → 0/7 confirmed war NUR fuer 2 von 4 Dimensionen ehrlich. KEIN stilles
Coverage-Loch akzeptiert → Re-Run wwyqsdrf9b (wfmu4pw2n) mit engerem Scope (Grep-dann-Region statt Ganzdatei).
- **Dim hybrid/targetPath + backup/account-check (wwyqsdrf9): 7 Kandidaten, ALLE refutiert (>=2/3), quell-reverifiziert.**
Kern-Invarianten halten: synchrones `claimTargetPath` (dl-mgr:6257) = nie zwei Items auf einem Pfad → alle drei
Hybrid-„Races" kollabieren zu Sub-Sekunden-Redundanzarbeit, kein Collision/Korruption; jeder Loss-Ausgang von
CRC/Extraction-Fail re-queued (`hybridExtractRequeue.add` 11739). Restore ist fail-closed (encrypted-or-reject,
Binaerheader „MDD1" wirft in JSON.parse vor normalizeSettings). account-check `valid:false` ist reiner
Renderer-Badge (kein src/main liest ihn als Gate; Disable laeuft ueber megaDebridDisabledAccountIds).
- **CONFIRMED MED (DOKUMENTIERT, nicht gefixt) BYTE-DROP-RETRY-1 (wfmu4pw2n):** Integrity-/too-small-/tiny-Retry
doppelzaehlt eine volle Datei in die Byte-Statistik. `dropItemContribution` (6247) loescht den
itemContributedBytes-Eintrag OHNE von session.totalDownloadedBytes abzuziehen (eigener Kommentar: „retry path
subtracts on its own"), aber die EINZIGE Subtraktion (9991-9997) liest genau diesen geloeschten Eintrag → 0 →
Subtraktion tot → Re-Download addiert N nochmal → (k+1)*fileSize nach k Integrity-Fails. enableIntegrityCheck
default true → organisch. Verdict isReal (CORRECTNESS+REPRO), aber BLAST-RADIUS = NUR Statistik: kein
Slot/Admission/Semaphore liest diese Counter (grep-belegt), Session-Counter self-healen bei jedem Neustart,
nur persistiertes totalDownloadedAllTime + avg-Speed bleiben dauerhaft inflationiert = kosmetisch. NICHT clean
(dropItemContribution ueber 26 Call-Sites ueberladen; ~10 im Retry-Bereich; ein 3-Site-Patch liefert
partielles/inkonsistentes Accounting = arguably schlimmer; Guard-Test dl-mgr.test.ts:6152 sperrt die
Completion-Removal-Semantik) → DOKUMENTIERT mit Rezept (dropItemContribution splitten in ForCompletion/ForRetry
nach Klassifikation aller Retry-Sites re-download-vs-terminal). Kosmetisch + nicht-clean → klart Live-Schwelle nicht.
- **CONFIRMED MED (GEFIXT) SET-MIG-01 (wfmu4pw2n):** Pre-v1.6.90-Config-Migration tot → Mega-Debrid wird beim
ersten Settings-Panel-Save still aus der Provider-Reihenfolge demotet. readSettingsFile (storage.ts:633) merged
`{...defaultSettings(), ...parsed}`; defaultSettings setzt megaDebridApiEnabled/WebEnabled:false (constants.ts:50-51)
BEVOR normalizeSettings laeuft → der `=== undefined`-Migrationszweig (355-360) ist tot auf dem Disk-Pfad (Git:
tot seit v1.6.90/0003d78). Renderer (App.tsx:447-494) gated Mega-Inklusion am false-Flag → persistDraftSettings
schreibt eine Mega-bereinigte providerOrder zurueck. Fix: reine `migrateLegacyMegaEnableFlags(parsed)` in
readSettingsFile — seedet apiEnabled=preferApi/webEnabled=!preferApi NUR wenn BEIDE Flags im RAW-parsed fehlen
UND Creds da sind (Creds-Erkennung wie normalizeSettings: asText(login)&&asText(password)). Lokalisiert, keine
normalizeSettings-Signatur-Aenderung, getypt (kein Cast). EHRLICHER Scope (Advisor): rettet NUR Legacy-Configs,
die seit dem Upgrade noch NICHT ueber das Settings-Panel neu gespeichert wurden (ein Post-Upgrade-Save schreibt
present-false → Trigger feuert nicht mehr) — schmales historisches Fenster, NICHT „rettet die Live-Mega dieses
Nutzers". HARTE Grenze (Advisor): Trigger bleibt absent-both; present-false NICHT anfassen (= bewusst-deaktiviert,
ununterscheidbar → das geparkte entscheidungen-offen #2-Migrationsrisiko). Rot-bewiesen NICHT-vakuum (Assertion NUR
auf den geladenen Flags — providerOrder demotet backend-seitig nicht = waere vakuum; via Temp-Revert rot bestaetigt:
apiEnabled true→false). Boundary-Test (bewusst-deaktiviert bleibt false + ohne Creds keine Migration) bleibt beim
Revert gruen = unabhaengig. Volle Suite 890 gruen, tsc=6.
- **REFUTED (wfmu4pw2n) SET-PERSIST-02:** Residual-Lost-Update nach R2-Generations-Guard — der einzige verlorene
Payload ist totalRuntimeAllTimeMs (`<=`-Ratchet, naechste Session re-derived) + contrived Sub-ms-Race der die
Prozess-Teardown gewinnen muss → self-healing kosmetisch, nicht erreichbar.
- **Coherence-Verdikt:** Concurrency-Accounting + Settings-Persistenz sind kohaerent. Byte-Counter sind
telemetry-only, voll entkoppelt von Admission/Slot (grep-belegt) → BYTE-DROP kann nicht stranden/freezen/
ueber-admiten. Persistenz kohaerent bis auf EINE benannte Inkohaerenz: Backend normalizeConfiguredProvider
(storage.ts:144) mapt „megadebrid"→konkret unabhaengig der Flags, Renderer gated auf dem Flag = Wurzel von
SET-MIG-01 (jetzt am Loader gefixt).
- **EHRLICHE Coverage-Luecke (vom Audit selbst aufgedeckt, R10-HYB-3):** Die in project_pending genannte
Passwort-Cache-Race liegt NICHT in download-manager.ts (dort kein passwordCache/resolvePassword), sondern in
src/main/extractor.ts — ausserhalb des Round-10-Scopes. Bewusst NICHT in dieser letzten Runde auditiert (Nutzer:
„nach der Runde ist Schluss"); als das eine identifizierte, un-auditierte Concurrency-Seam fuer eine etwaige
kuenftige Runde dokumentiert statt still fallengelassen.
## GOAL-ABSCHLUSS
Runde 9 + 10 erledigt (Nutzer-Anpassung). Release v1.7.220 buendelt: 85c8d6b (Synthese C1/C2/RANGE1-Haertung) +
MW-1 (HIGH, Web-Selbstcooldown) + SET-MIG-01 (MED, Legacy-Mega-Demotion). Gitea + GitHub-Mirror MIT .exe (4 Assets).
Dokumentiert-nicht-gefixt: DL-1, DL-CONCURRENCY-PILEUP, BYTE-DROP-RETRY-1, extractor.ts-Passwort-Cache-Seam.
Beim Nutzer (nicht autonom): 60s-Failover-Kappung + gespiegelter Mega-API/Web-Schalter (entscheidungen-offen.md).
## Nutzer-Nachforderung (nach Goal-Abschluss): die 3 dokumentierten Funde DOCH umsetzen → v1.7.221
Nutzer: "dann mach das beides erstmal" (BYTE-DROP-RETRY-1 + DL-1 + DL-CONCURRENCY-PILEUP). Nicht relitigiert OB,
nur WIE (Advisor-gefuehrt, je rot-bewiesen, je full-suite gruen + tsc=6 single-pass).
- **BYTE-DROP-RETRY-1 (MED) GEFIXT.** Scope-Disziplin (Advisor): NUR Session-Counter + totalDownloadedAllTime,
NICHT recordProviderDownloadedBytes/providerDailyUsageBytes (das gated isProviderDailyLimited = Verhalten,
und ist nicht provider-keyed → naive Subtraktion wuerde den falschen Provider-Bucket korrumpieren → bewusst
ausgeklammert). Mechanismus (a): an den 3 bestaetigten rm-dann-frisch-Sites (Integrity 8979, too-small 9020,
tiny 10486) den `dropItemContribution`-Aufruf ENTFERNT → der itemContributedBytes-Eintrag ueberlebt → die
bestehende writeMode-"w"-Reconciliation (9991) subtrahiert ihn korrekt (selbst-korrigierend nach writeMode,
Append undercounted nicht). Plus: am selben Punkt (9991) `totalDownloadedAllTime -= previouslyContributed`
ergaenzt (spiegelt den Add bei 10311; All-Time wurde NIE subtrahiert → doppelte bei JEDEM frischen Re-Download,
nicht nur den dropItemContribution-Pfaden). KEINE 23-Site-Reklassifikation (Advisor: Provider-Usage off-limits
→ jede Restfehlklassifikation ist bounded Telemetrie). Guard-Test dl-mgr.test.ts:6152 (Completion-Removal behaelt
Session-Total) bleibt gruen. Rot-bewiesen NICHT-vakuum, BEIDE Beine einzeln: Integration durch echten
Integrity-Fail-Retry (.md5-Manifest, lokaler HTTP-Server serviert wrong-dann-correct), Assert session==1x UND
allTime==1x; Bein 1 (All-Time-Zeile raus) → allTime rot (2x) session gruen; Bein 2 (dropItemContribution zurueck)
→ session rot (2x).
- **DL-1 (LOW) GEFIXT.** Advisor revidierte den frueheren "braucht neue Oberflaeche"-Call: am Rotations-Catch (2789)
ist elapsedMs bereits da → Mega-Gate (2072) gespiegelt. abort-ohne-timeout + elapsedMs < getMegaDebridAbortMinRunMs()
→ KEIN Key-Cooldown (User-Cancel bestraft den Key nicht); ran-long-enough → DEBRID_LINK_KEY_COOLDOWN_MS (120s)
damit der Retry rotiert; throw bailt die Rotation (verhindert auch die zuvor moegliche Transport-Kaskade ueber
mehrere Keys bei aborted-Signal). Neuer Test-Getter getDebridLinkKeyCooldownStateForTests. Rot-bewiesen
(quick-cancel → null; ohne Fix 15s gesetzt; long-abort → >60s, ohne Fix 15s).
- **DL-CONCURRENCY-PILEUP (MED): untersucht → BEREITS STRUKTURELL GELOEST, kein Eingriff (Nutzer-Entscheidung
"Akzeptieren").** getSerializedValidatingLimit("megadebrid-web") = Anzahl nutzbarer (nicht-gecoolter) Accounts
(dl-mgr 8047-8055); shouldDelayStartForItem erzwingt es in der Kandidatenwahl (8526) → Ueberschuss-Konvertierungen
warten als "queued" im Scheduler, NICHT in den per-Account-Single-Flight-Queues; Depth-Spread (debrid 1981-1986)
verteilt die erlaubten 1-pro-Account. MW-1 haelt usableAccounts (= das Limit) korrekt hoch. Ein weiterer
Scheduler-Eingriff = redundant ODER schaedlich (Ueber-Admission = echter Pileup) → Advisor-4.-Bug-Risiko. Dem
Nutzer vorgelegt (AskUserQuestion) → "Akzeptieren, kein Eingriff".
## Runde 1 (laeuft)
- Discover+Verify-Workflow ueber 7 Subsysteme (scheduler-slots, unrestrict-retry, mega-rotation,
classify-cooldown, mega-web-token, provider-chain-timeout, account-availability).
@ -316,12 +168,6 @@ importiert+konstruiert). Risiko Startup-Regression empirisch ausgeschlossen.
- Advisor NICHT auf Kadenz pollen — nur bei echter Ship/Fix-Entscheidung mit neuer Info.
- Die 2 HIGH Produkt/UI-Entscheidungen bleiben beim Nutzer (nicht autonom shippen).
## Release-Status: v1.7.219 RELEASED (Gitea 6ae9f5d + GitHub-Mirror df3670a)
Roll-up Runde 7+8: be15419 MED VP-1 dt.-Tonspur + eacd0c9 HIGH RANGE-1 Silent-Corruption + REWIND-TRUNCATE.
HIGH rechtfertigt Release. Advisor explizit cleared (vor Implementierung konsultiert, Check A verifiziert).
Mirror kuratiert ohne CLAUDE.md/tasks/ (Leak-Check sauber). NAECHSTER SCHRITT (Advisor): Synthese/Regressions-
Pass ueber kumulativen Diff aller Session-Fixes (Interaktion), KEINE weitere Discovery-Runde.
## Release-Status: v1.7.218 RELEASED (Gitea bbb9355 + GitHub-Mirror 94d143b)
Roll-up Runde 4+5+6: 986fbab MED Provider-Cooldown-Routing + 03c908b No-Stranding-Test +
f1e35f5 LOW Mega-Tagesreset-Park + d2a1b83 HIGH Shelve-Loop-RetryLimit. HIGH rechtfertigt das
@ -333,28 +179,6 @@ Multi-Agent-adversarisch, Routing-Fix frueher advisor-gesegnet, Praezedenz 215/2
Runde-5/6-Charakterisierungen (PP-SEM-1 benign, DISK-1 deferred, deferred-LOW-Cluster #10/#11/#13
benign) NICHT released — dokumentiert.
## SYNTHESE/Regressions-Pass (Fix-Interaktion ueber kumulativen Diff v1.7.212..HEAD) — Workflow wrexz7mdf
3 Reviewer (Catch-Cascade / Streaming / Provider-Chain) + adversarisch verifizieren. 3 confirmed (1 MED, 2 LOW),
2 refuted. Bestaetigt: Rest komponiert sauber; Risiko war (wie Advisor sagte) Fix-Interaktion, nicht neue Bugs.
- **CONFIRMED MED (GEFIXT) C1 reset_park maskiert kurzen Cooldown:** Bei BEIDEN Mega-Modi aktiv aggregiert die
Provider-Kette beide Token (`mega_debrid_reset_park:LONG` von API-Park + `mega_debrid_cooldown:30000` von
Web-Cooldown). Manager prueft reset_park VOR cooldown → Item ~24h geparkt obwohl Web in ~30s erholt. Fix:
Cooldown-Zweig VOR reset_park (kuerzerer Delay gewinnt). Rot-bewiesen (Single-Pass-processItem-Test: Aggregat
beider Token → retryAfter < 60s, fullStatus "Cooldown" nicht "Tagesreset"; ohne Reorder ~24h).
- **CONFIRMED LOW (GEFIXT) C2 Token-Truncation:** reset_park-Token konnte von compactErrorText (220-Zeichen-Cap)
abgeschnitten werden, wenn Mega nicht Lead + vorheriger Provider verbose → Park still uebersprungen. Fix: Mega-
Token aus der UNGEKUERZTEN error.message parsen (megaRawError). (Im selben Edit wie C1.)
- **CONFIRMED LOW (GEHAERTET + Doku korrigiert) RANGE1-TRUNCATEFAIL-FINALIZE:** Mein 219-Commit OVERCLAIMte —
bei FEHLGESCHLAGENEM finalem Rewind-truncate finalisiert tryFinalizeItemFromDisk(9238) die Garbage-Datei
(size==totalBytes) VOR dem Re-Entry. NET-NEUTRAL vs pre-audit (v1.7.212 hatte denselben Finalize), KEINE
Regression. Haertung: bei truncate-Fehler jetzt rmSync(Teil-Datei)+downloadedBytes=0 → Finalize lehnt ab →
sauberer Re-Download (best-effort; faellt der rm auch, bleibt es net-neutral). Macht den 219-Claim wahr.
- **Refuted (1/3 je):** reset_park von shelve-guard/unrestrictRetries-Exhaustion unter finite retryLimit
verdraengt — beide nicht bestaetigt (compound/nicht erreichbar).
- **Capstone-Verdikt:** Die 8-Runden-Fixes komponieren — inner-rewind success-only-reset und final-rewind sind
per-attempt mutually exclusive; prealloc-reconcile double-truncated nicht nach erfolgreichem Rewind; Routing
+ cooldown + park kohaerent NACH C1/C2-Fix. Diese 3 Synthese-Fixes buendeln zu v1.7.220.
## Runde 8 (Byte-Streaming downloadToFile: Range/Resume/Append/Truncation) — Workflow wwjz4srkq
3 Finder + adversarisch verifizieren. 3 confirmed (1 HIGH + 2 MED, alle Silent-Corruption-Familie), 1 refuted.
Advisor VOR Implementierung konsultiert (HIGH-Hot-Path) — Design + Check-A-Branch (totalBytes-null) bestaetigt.

View File

@ -63,53 +63,6 @@ ODER zwei unabhängige Pro-Modus-Schalter (mehr Kontrolle, aber UI + Migration n
## Erledigt in dieser Runde (zur Info, kein Handlungsbedarf)
- **Download-Statistik zählt eine Datei nach einem Integritäts-/Zu-klein-Neuversuch nicht mehr doppelt:**
Schlug eine Datei die CRC-/Hash-Prüfung fehl (oder kam zu klein an) und wurde komplett neu geladen, wurde die
Dateigröße bisher pro Versuch erneut in die Statistik addiert — die Anzeige „insgesamt heruntergeladen" (Session
und Gesamt-Zähler) sowie die daraus berechnete Durchschnittsgeschwindigkeit waren dadurch bei flatterhaften Hostern
um die jeweilige Dateigröße aufgebläht (bei großen Archiven mit wiederholten CRC-Fehlern um mehrere GB). Jetzt zählt
jede gelieferte Datei genau einmal. Reine Anzeige-/Statistik-Korrektur — Slot-Vergabe, Tageslimits und der
Download-Ablauf waren nie betroffen (die Tageslimit-Zähler werden bewusst nicht angefasst, da sie die Provider-Auswahl
steuern und der echte Datenverkehr über die Leitung ging). Rot-bewiesener Test, beide Zähler einzeln geprüft.
- **Debrid-Link: ein abgebrochener Vorgang sperrt den Key nicht mehr unnötig:**
Wenn du einen Vorgang abgebrochen hast (oder der Gesamttimeout zuschlug), bevor echte Arbeit lief, bekam der
Debrid-Link-Key bisher trotzdem eine 15-Sekunden-Sperre — bei mehreren Abbrüchen in Folge konnte das sogar über
mehrere Keys kaskadieren und eine längere providerweite Sperre auslösen. Jetzt wird ein schneller Abbruch (vor der
Mindest-Laufzeit) nicht mehr als Key-Fehler gewertet: keine Sperre. Lief der Vorgang dagegen lange genug und brach
dann ab (echter langsamer/hängender Key), wird er weiterhin gesperrt, damit der nächste Versuch sauber auf den
nächsten Key rotiert. Spiegelt exakt das Verhalten, das es bei Mega-Debrid schon gibt. Rot-bewiesener Test.
- **Mega-Konvertierungs-Stau (geprüft, kein Eingriff nötig):** Die Zahl gleichzeitiger Mega-Umwandlungen ist bereits
auf die Anzahl nutzbarer Accounts gedeckelt — Überschuss wartet sauber im Scheduler statt sich in den Account-
Warteschlangen zu stapeln, und der oben beschriebene Mega-Web-Fix hält dieses Limit jetzt korrekt hoch. Ein
zusätzlicher Eingriff wäre überflüssig oder würde durch Über-Vergabe erst echten Stau erzeugen. Auf deine
Entscheidung hin daher bewusst NICHT verändert.
- **Alte Konfiguration: Mega-Debrid fällt nach einem Upgrade nicht mehr still aus der Provider-Reihenfolge:**
Eine Konfigurationsdatei, die noch von einer sehr alten Version (vor v1.6.90) stammt, kannte die getrennten
Mega-Debrid „API aktiv"/„Web aktiv"-Schalter noch nicht. Beim Laden wurden diese fehlenden Schalter still auf
„aus" gesetzt, obwohl Mega-Zugangsdaten vorhanden waren — und sobald man danach das erste Mal die Einstellungen
speicherte, wurde Mega-Debrid dadurch lautlos aus der Provider-Reihenfolge entfernt. Jetzt wird beim Laden einer
solchen alten Datei erkannt, dass die Schalter komplett fehlen, und Mega-Debrid passend zu deiner Bevorzugung
(API oder Web) aktiviert — genau die Migration, die ursprünglich gedacht war, aber durch einen Default-Vorrang
nie ausgelöst hatte. Ehrlicher Umfang: Das betrifft nur alte Dateien, die seit dem Upgrade noch NICHT über die
Einstellungen neu gespeichert wurden — wer seit dem Update schon einmal in den Einstellungen gespeichert hat, hat
die Schalter bereits als „aus" stehen und greift dort weiterhin manuell ein (das ist Absicht: ein bewusst auf
„aus" gestellter Schalter wird NICHT wieder angeschaltet). Rot-bewiesener Test; voller Testlauf grün.
- **Mega-Web: gesunder Account sperrt sich nicht mehr selbst, nur weil er gerade belegt war (deine „Tool sperrt sich selbst"-Klasse, Web-Variante):**
Wenn mehrere Links gleichzeitig über DENSELBEN Mega-Account umgewandelt wurden, laufen sie absichtlich nacheinander
(eine Warteschlange pro Account, damit nicht doppelt eingeloggt/gehämmert wird). Wartete ein Link in dieser Schlange
noch auf seinen Vorgänger und lief dabei der 60-Sekunden-Gesamttimeout ab, wurde der Abbruch fälschlich wie ein echter
Account-Fehler gewertet → der völlig gesunde Account bekam 120 Sekunden Sperre. Jetzt wird ein Abbruch, der NOCH IN DER
Warteschlange passiert (bevor echte Arbeit begann), als reiner Warteschlangen-Timeout erkannt: KEINE Account-Sperre,
der Link wird einfach erneut versucht und rotiert dann von selbst auf einen freien Account. Ein echter Abbruch MITTEN
in der Arbeit sperrt den Account weiterhin (damit langsame Accounts korrekt übersprungen werden) — das blieb unverändert.
Zwei rot-bewiesene Tests (jeder ohne Fix nachweislich rot; der zweite läuft komplett durch die echte Account-Rotation
und prüft, dass keine Sperre gesetzt wird). Die Rotation auf einen freien Account ist über die Auslastungs-Verteilung
(am-wenigsten-belegter-Account-zuerst) abgesichert — strikt besser als die alte 120s-Pauschalsperre.
- **Stille Datei-Beschädigung beim Resume nach Verbindungsabbruch behoben (für Dateien mit bekannter Größe):**
Wenn ein Debrid-Server beim Abbruch einen kleinen Fehler-Müll-Block mitten in den Datenstrom schreibt und
das genau im LETZTEN Wiederhol-Versuch passierte, blieb dieser Müll in der Datei und der anschließende

View File

@ -1,42 +1,37 @@
# MCP-Ferndiagnose (Goal, ultracode) — v1.7.223
# Massives Conversion-Logging (v1.7.213) + Failover-Fix
## Ziel (Nutzer)
"Baue massive Diagnose-Funktionen ein (MCP), so dass ich MCP auf nem Windows-Server aktivieren kann und du
auf den Server zugreifst und WIRKLICH ALLES siehst (State, Fehler, Logs, Probleme). 5-6 Server, ueber
Verbindungscode. Du verbindest dich → liest alles → behebst Probleme direkt."
## 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.
## Architektur (Advisor-bestaetigt v1)
- Standalone **stdio MCP-Bridge** auf MEINER (Claude-Code-)Maschine, proxyt zur bestehenden HTTP `debug-server.ts`
jedes Servers via **Verbindungscode**. Eine Bridge bedient alle 5-6 Server.
- KEIN eingebettetes MCP-over-HTTP (verworfen: hand-rolled Protokoll auf Internet-Oberflaeche = mehr Risiko).
- Bridge-Code identisch fuer Direkt-IP vs spaeterer Tunnel (proxyt host:port).
- Verbindungscode: `rddiag:v1:<base64url(JSON {v,h:host,p:port,t:token,n?:name,fp?:certFp})>`. Nutzer liefert
oeffentlichen Host (nicht auto-detecten).
## 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
## Sicherheitsmodell (Advisor, first-class)
Plain HTTP + Bearer ueber Internet = sniffbares Token mit Lesezugriff auf sensible Logs. Mitigations:
- App-seitige IP-Allowlist (extractDebugClientIp existiert schon).
- `/trace/config` MUTIERT → "read-only"-Claim auditieren: Writes von Remote-Oberflaeche gaten oder umlabeln.
- Opt-in + sofort widerrufbar (Token-Rotation killt Zugang).
- debug_token.txt in userData bestaetigen (ueberlebt Auto-Update).
- Optional self-signed Cert + Fingerprint im Code gepinnt (NICHT v1-blockierend).
## 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).
## Phasen
- [x] **P0 Vertical Slice (Diskriminator) — ERLEDIGT, harness ALL PASS:** Bridge → debug-server via stdio JSON-RPC, echte Daten zurueck.
- [x] MCP SDK API holen (context7) → @modelcontextprotocol/sdk 1.29.0, registerTool(name,{inputSchema:zodShape},cb)
- [x] Bridge in `tools/rd-diagnostics-mcp/` (eigenes package.json, NICHT in App-Bundle): code.mjs/http.mjs/bridge.mjs/gen-code.mjs
- [x] 14 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 + Multi-Server (code|server|RDDIAG_CODE|RDDIAG_SERVERS)
- [x] Verbindungscode-Codec rddiag:v1:base64url({v,h,p,t,n?,fp?,s?})
- [x] Test-Harness (test/harness.mjs): fake debug-server (auth+routes+query-echo) + Bridge als stdio-Child → 19 Checks gruen (handshake, tools/list, ping, diagnostics+query-passthrough, logs-mapping, errors, escape-hatch, 401, missing-code, unreachable+hint)
- [x] **P1 Security-Hardening — ERLEDIGT:** IP-Allowlist (exakt+CIDR), erzwungen VOR Auth am ECHTEN Socket-Peer (req.socket.remoteAddress), NICHT X-Forwarded-For (Advisor: XFF faelschbar → Bypass; gefixt+Threat-Test). Fail-closed. /trace/config belassen (zeitbegrenzt). userData/runtime verifiziert. Log-Audit: keine Secrets in /logs/*.
- [x] **P2 One-Click-Enable + Code — ERLEDIGT:** restartDebugServer ('close'+closeAllConnections, EADDRINUSE). IPC get/enable/disable/rotate + Controller + Typen. Flache Modal-UI (Hilfe→Remote-Support→"Ferndiagnose (MCP)"): Status, lokal/netzwerk, Public-Host+Chips, Allowlist, Code+Copy+Token-Rotation+Deaktivieren.
- [x] **P3 — durch bestehende Endpunkte abgedeckt:** /accounts (Cooldown/Rotation), /errors, /status, /diagnostics via Bridge. Kein neuer Endpunkt noetig.
- [x] **P4 Verify — ERLEDIGT:** Suite 906 gruen, tsc=6, Harness gruen, Advisor (fing XFF-Bypass). Release v1.7.223 Gitea (6b52678, 4 Assets) + Mirror (24485be, 4 Assets, Claude-frei). Bridge `claude mcp add` (user, ✔ Connected).
- [ ] **P5 Reachability (NUTZER):** Ferndiagnose auf 1 Server an → Code an mich → ich verbinde. Entscheid: Tunnel (sicherste, "Nur lokal") vs Direkt-Bind 0.0.0.0+Allowlist (nur vertrauenswuerdiges Netz/VPN; Token reist plain HTTP).
## 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.
## Review
Vertikaler Slice zuerst (Bridge gegen Fake-Debug-Server, JSON-RPC stdio), dann App-Seite load-bearing-first
(Backend+Tests vor UI). Advisor fing einen releaseblockierenden Bug: Allowlist nutzte extractDebugClientIp
(X-Forwarded-For zuerst = angreiferkontrolliert) → Bypass per `X-Forwarded-For: 127.0.0.1`; Tests maskierten es
(injizierten die IP per genau dem Header). Fix: Enforcement am Socket-Peer, XFF nur fuers Log; Threat-Test
(socket 8.8.8.8 + XFF 127.0.0.1 → denied). Empfohlener Transport: Loopback+Tunnel; Direkt-Bind nur mit Allowlist.
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.

View File

@ -1,232 +0,0 @@
import fs from "node:fs";
import http from "node:http";
import os from "node:os";
import path from "node:path";
import { once } from "node:events";
import { afterEach, describe, expect, it } from "vitest";
import { buildBackupPayload, resolveMcpRemoteRestore, BackupMcpRemote } from "../src/main/backup-payload";
import { defaultSettings } from "../src/main/constants";
import { normalizeSettings } from "../src/main/storage";
import {
startDebugServer,
stopDebugServer,
restartDebugServer,
writeDebugServerConfig,
getDebugAllowlist,
getDebugServerRuntimeStatus
} from "../src/main/debug-server";
import type { DownloadManager } from "../src/main/download-manager";
import type { AppSettings, SessionState } from "../src/shared/types";
const tempDirs: string[] = [];
function input(settingsOverride: Partial<AppSettings>, mcpRemote?: BackupMcpRemote) {
return {
settings: { ...defaultSettings(), ...settingsOverride } as AppSettings,
appVersion: "1.7.224",
exportedAt: "2026-06-19T00:00:00.000Z",
session: {} as unknown as SessionState,
history: [],
mcpRemote
};
}
async function getFreePort(): Promise<number> {
const probe = http.createServer();
probe.listen(0, "127.0.0.1");
await once(probe, "listening");
const address = probe.address();
if (!address || typeof address === "string") {
throw new Error("port probe failed");
}
probe.close();
await once(probe, "close");
return address.port;
}
async function waitForReady(url: string): Promise<void> {
const deadline = Date.now() + 5000;
while (Date.now() < deadline) {
try {
const res = await fetch(url);
if (res.ok) {
return;
}
} catch {
}
await new Promise((resolve) => setTimeout(resolve, 40));
}
throw new Error(`debug server not ready: ${url}`);
}
afterEach(() => {
stopDebugServer();
while (tempDirs.length > 0) {
const dir = tempDirs.pop();
if (!dir) {
continue;
}
try {
fs.rmSync(dir, { recursive: true, force: true });
} catch {
}
}
});
describe("backup mcpRemote export gating", () => {
it("includes mcpRemote when backupIncludeMcp is on", () => {
const section: BackupMcpRemote = { allowlist: ["10.0.0.5", "192.168.1.0/24"], port: 9999, hostMode: "network" };
const payload = buildBackupPayload(input({ backupIncludeMcp: true }, section));
expect(payload.mcpRemote).toEqual(section);
});
it("omits mcpRemote when the toggle is off even if a section is provided", () => {
const payload = buildBackupPayload(input({ backupIncludeMcp: false }, { allowlist: ["10.0.0.5"], port: 9868, hostMode: "network" }));
expect(payload.mcpRemote).toBeUndefined();
});
it("omits mcpRemote when toggle on but no section gathered", () => {
const payload = buildBackupPayload(input({ backupIncludeMcp: true }, undefined));
expect(payload.mcpRemote).toBeUndefined();
});
it("the mcpRemote section carries ONLY allowlist/port/hostMode (no token, publicHost, name)", () => {
const payload = buildBackupPayload(input({ backupIncludeMcp: true }, { allowlist: ["10.0.0.5"], port: 9868, hostMode: "network" }));
expect(payload.mcpRemote && Object.keys(payload.mcpRemote).sort()).toEqual(["allowlist", "hostMode", "port"]);
const sectionJson = JSON.stringify(payload.mcpRemote);
expect(sectionJson.toLowerCase()).not.toContain("token");
expect(sectionJson).not.toContain("publicHost");
expect(sectionJson.toLowerCase()).not.toContain("\"name\"");
});
});
describe("backupIncludeMcp settings persistence", () => {
it("normalizeSettings preserves backupIncludeMcp (the toggle survives save/load)", () => {
expect(normalizeSettings({ backupIncludeMcp: true } as unknown as AppSettings).backupIncludeMcp).toBe(true);
expect(normalizeSettings({ backupIncludeMcp: false } as unknown as AppSettings).backupIncludeMcp).toBe(false);
expect(normalizeSettings({} as unknown as AppSettings).backupIncludeMcp).toBe(false);
});
});
describe("resolveMcpRemoteRestore", () => {
it("maps network + non-empty allowlist to 0.0.0.0", () => {
expect(resolveMcpRemoteRestore({ allowlist: ["10.0.0.5"], port: 9868, hostMode: "network" }))
.toEqual({ host: "0.0.0.0", port: 9868, allowlist: ["10.0.0.5"] });
});
it("SAFETY: network with EMPTY allowlist binds local, never 0.0.0.0", () => {
expect(resolveMcpRemoteRestore({ allowlist: [], port: 9868, hostMode: "network" })?.host).toBe("127.0.0.1");
});
it("maps local to 127.0.0.1", () => {
expect(resolveMcpRemoteRestore({ allowlist: ["10.0.0.5"], port: 9868, hostMode: "local" })?.host).toBe("127.0.0.1");
});
it("rejects an out-of-range or non-integer port", () => {
expect(resolveMcpRemoteRestore({ allowlist: ["10.0.0.5"], port: 80, hostMode: "network" })?.port).toBeUndefined();
expect(resolveMcpRemoteRestore({ allowlist: ["10.0.0.5"], port: 70000, hostMode: "network" })?.port).toBeUndefined();
expect(resolveMcpRemoteRestore({ allowlist: ["10.0.0.5"], port: 9868.5, hostMode: "network" })?.port).toBeUndefined();
});
it("filters non-string and blank allowlist entries and trims", () => {
const r = resolveMcpRemoteRestore({ allowlist: ["10.0.0.5", "", " ", 5, null, " 8.8.8.8 "], port: 9868, hostMode: "network" });
expect(r?.allowlist).toEqual(["10.0.0.5", "8.8.8.8"]);
});
it("returns null for missing or empty/invalid sections", () => {
expect(resolveMcpRemoteRestore(undefined)).toBeNull();
expect(resolveMcpRemoteRestore(null)).toBeNull();
expect(resolveMcpRemoteRestore("x")).toBeNull();
expect(resolveMcpRemoteRestore({})).toBeNull();
});
});
describe("backup mcpRemote live restore round-trip", () => {
it("export -> resolve -> apply is reflected in the running debug-server (proves restart fired)", async () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bkmcp-"));
tempDirs.push(baseDir);
const startPort = await getFreePort();
const restorePort = await getFreePort();
fs.writeFileSync(path.join(baseDir, "debug_token.txt"), "rt-secret", "utf8");
fs.writeFileSync(path.join(baseDir, "debug_port.txt"), String(startPort), "utf8");
fs.writeFileSync(path.join(baseDir, "debug_host.txt"), "127.0.0.1", "utf8");
fs.writeFileSync(path.join(baseDir, "debug_allowlist.txt"), "", "utf8");
startDebugServer({} as unknown as DownloadManager, baseDir);
await waitForReady(`http://127.0.0.1:${startPort}/health?token=rt-secret`);
expect(getDebugAllowlist()).toEqual([]);
const payload = buildBackupPayload(input(
{ backupIncludeMcp: true },
{ allowlist: ["203.0.113.4", "10.0.0.0/24"], port: restorePort, hostMode: "network" }
));
const restore = resolveMcpRemoteRestore(payload.mcpRemote);
expect(restore).not.toBeNull();
writeDebugServerConfig({ host: restore!.host, port: restore!.port, allowlist: restore!.allowlist });
const status = await restartDebugServer();
expect(getDebugAllowlist()).toEqual(["203.0.113.4", "10.0.0.0/24"]);
expect(status.port).toBe(restorePort);
expect(status.host).toBe("0.0.0.0");
expect(status.allowlistCount).toBe(2);
expect(fs.readFileSync(path.join(baseDir, "debug_token.txt"), "utf8").trim()).toBe("rt-secret");
expect(fs.existsSync(path.join(baseDir, "debug_remote.json"))).toBe(false);
await waitForReady(`http://127.0.0.1:${restorePort}/health?token=rt-secret`);
});
it("full-backup path writes the debug_* files to disk without a restart (boot picks them up)", async () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bkmcp2-"));
tempDirs.push(baseDir);
const startPort = await getFreePort();
fs.writeFileSync(path.join(baseDir, "debug_token.txt"), "rt2", "utf8");
fs.writeFileSync(path.join(baseDir, "debug_port.txt"), String(startPort), "utf8");
fs.writeFileSync(path.join(baseDir, "debug_host.txt"), "127.0.0.1", "utf8");
fs.writeFileSync(path.join(baseDir, "debug_allowlist.txt"), "", "utf8");
startDebugServer({} as unknown as DownloadManager, baseDir);
await waitForReady(`http://127.0.0.1:${startPort}/health?token=rt2`);
const restore = resolveMcpRemoteRestore({ allowlist: ["198.51.100.9"], port: 9100, hostMode: "network" });
writeDebugServerConfig({ host: restore!.host, port: restore!.port, allowlist: restore!.allowlist });
expect(fs.readFileSync(path.join(baseDir, "debug_host.txt"), "utf8").trim()).toBe("0.0.0.0");
expect(fs.readFileSync(path.join(baseDir, "debug_port.txt"), "utf8").trim()).toBe("9100");
expect(fs.readFileSync(path.join(baseDir, "debug_allowlist.txt"), "utf8")).toContain("198.51.100.9");
expect(getDebugServerRuntimeStatus().port).toBe(startPort);
});
});
describe("debug-server live diagnostics endpoints", () => {
it("serves /providers (live cooldown/runtime snapshot) and /logs/conversion over authenticated HTTP", async () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-prov-"));
tempDirs.push(baseDir);
const port = await getFreePort();
fs.writeFileSync(path.join(baseDir, "debug_token.txt"), "prov-secret", "utf8");
fs.writeFileSync(path.join(baseDir, "debug_port.txt"), String(port), "utf8");
fs.writeFileSync(path.join(baseDir, "debug_host.txt"), "127.0.0.1", "utf8");
fs.writeFileSync(path.join(baseDir, "debug_allowlist.txt"), "", "utf8");
startDebugServer({} as unknown as DownloadManager, baseDir);
await waitForReady(`http://127.0.0.1:${port}/health?token=prov-secret`);
const provRes = await fetch(`http://127.0.0.1:${port}/providers?token=prov-secret`);
expect(provRes.status).toBe(200);
const prov = await provRes.json();
expect(typeof prov.capturedAtMs).toBe("number");
expect(prov.megaDebrid).toBeTruthy();
expect(Array.isArray(prov.megaDebrid.accounts)).toBe(true);
expect(typeof prov.megaDebrid.rotationCursor).toBe("number");
expect(prov.debridLink).toBeTruthy();
expect(Array.isArray(prov.debridLink.keys)).toBe(true);
const unauth = await fetch(`http://127.0.0.1:${port}/providers`);
expect(unauth.status).toBe(401);
const convRes = await fetch(`http://127.0.0.1:${port}/logs/conversion?token=prov-secret`);
expect(convRes.status).toBe(200);
const conv = await convRes.json();
expect(Array.isArray(conv.lines)).toBe(true);
expect(conv).toHaveProperty("available");
});
});

View File

@ -1,42 +0,0 @@
import { describe, it, expect } from "vitest";
import { encodeConnectionCode } from "../src/main/connection-code";
import { decodeConnectionCode } from "../tools/rd-diagnostics-mcp/src/code.mjs";
describe("connection-code", () => {
it("round-trips through the bridge decoder", () => {
const code = encodeConnectionCode({ host: "203.0.113.5", port: 9868, token: "deadbeef", name: "server-1" });
expect(code.startsWith("rddiag:v1:")).toBe(true);
const decoded = decodeConnectionCode(code);
expect(decoded.host).toBe("203.0.113.5");
expect(decoded.port).toBe(9868);
expect(decoded.token).toBe("deadbeef");
expect(decoded.name).toBe("server-1");
expect(decoded.scheme).toBe("http");
});
it("carries https scheme and fingerprint when set", () => {
const code = encodeConnectionCode({
host: "diag.example.com",
port: 8443,
token: "abc",
scheme: "https",
fingerprint: "AA:BB:CC"
});
const decoded = decodeConnectionCode(code);
expect(decoded.scheme).toBe("https");
expect(decoded.fingerprint).toBe("AA:BB:CC");
});
it("omits scheme key for plain http (default)", () => {
const code = encodeConnectionCode({ host: "10.0.0.2", port: 9868, token: "t" });
const json = JSON.parse(Buffer.from(code.slice("rddiag:v1:".length).replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8"));
expect(json.s).toBeUndefined();
expect(json).toMatchObject({ v: 1, h: "10.0.0.2", p: 9868, t: "t" });
});
it("rejects invalid input", () => {
expect(() => encodeConnectionCode({ host: "", port: 9868, token: "t" })).toThrow();
expect(() => encodeConnectionCode({ host: "h", port: 0, token: "t" })).toThrow();
expect(() => encodeConnectionCode({ host: "h", port: 9868, token: "" })).toThrow();
});
});

View File

@ -3,8 +3,7 @@ import { defaultSettings, REQUEST_RETRIES } from "../src/main/constants";
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, getProviderRuntimeSnapshot, leadProviderChainWith, MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART, MEGA_DEBRID_STICKY_LINKS, normalizeResolvedFilename, primeMegaDebridRuntimeCooldownForTests, primeMegaDebridUntilRestartForTests, recordMegaDebridEmptyResponseStreak, resetDebridLinkRuntimeStateForTests, resetMegaDebridRuntimeStateForTests } from "../src/main/debrid";
import { classifyMegaDebridAccountFailureForTests, clearMegaDebridEmptyResponseStreak, DebridService, extractRapidgatorFilenameFromHtml, fetchAllDebridHostInfo, fetchDebridLinkHostLimits, filenameFromRapidgatorUrlPath, getDebridLinkKeyRuntimeStateForTests, getMegaDebridAccountCooldownState, leadProviderChainWith, MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART, MEGA_DEBRID_STICKY_LINKS, normalizeResolvedFilename, primeMegaDebridUntilRestartForTests, recordMegaDebridEmptyResponseStreak, resetDebridLinkRuntimeStateForTests, resetMegaDebridRuntimeStateForTests } from "../src/main/debrid";
const originalFetch = globalThis.fetch;
@ -573,78 +572,6 @@ describe("debrid service", () => {
expect(getDebridLinkKeyRuntimeStateForTests(key2Id)).toBe("ready");
});
it("does NOT cool down a Debrid-Link key on a quick user-cancel abort (below the min-run threshold)", async () => {
const settings = {
...defaultSettings(),
token: "",
bestToken: "",
allDebridToken: "",
megaLogin: "",
megaPassword: "",
megaCredentials: "",
debridLinkApiKeys: "dl-key-one",
providerOrder: ["debridlink"] as const,
providerPrimary: "debridlink" as const,
providerSecondary: "none" as const,
providerTertiary: "none" as const,
autoProviderFallback: false
};
const controller = new AbortController();
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("/downloader/add")) {
controller.abort();
throw new Error("aborted");
}
return new Response("not-found", { status: 404 });
}) as typeof fetch;
const keyId = parseDebridLinkApiKeys("dl-key-one")[0].id;
const service = new DebridService(settings);
await expect(
service.unrestrictLink("https://rapidgator.net/file/dl-quick-cancel", controller.signal)
).rejects.toThrow();
expect(getDebridLinkKeyCooldownStateForTests(keyId)).toBeNull();
});
it("cools down a Debrid-Link key on an abort that ran long enough (retry rotates to the next key)", async () => {
process.env.RD_MEGA_ABORT_MIN_RUN_MS = "0";
const settings = {
...defaultSettings(),
token: "",
bestToken: "",
allDebridToken: "",
megaLogin: "",
megaPassword: "",
megaCredentials: "",
debridLinkApiKeys: "dl-key-one",
providerOrder: ["debridlink"] as const,
providerPrimary: "debridlink" as const,
providerSecondary: "none" as const,
providerTertiary: "none" as const,
autoProviderFallback: false
};
const controller = new AbortController();
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("/downloader/add")) {
controller.abort();
throw new Error("aborted");
}
return new Response("not-found", { status: 404 });
}) as typeof fetch;
const keyId = parseDebridLinkApiKeys("dl-key-one")[0].id;
const service = new DebridService(settings);
await expect(
service.unrestrictLink("https://rapidgator.net/file/dl-long-abort", controller.signal)
).rejects.toThrow();
const cooldown = getDebridLinkKeyCooldownStateForTests(keyId);
expect(cooldown?.remainingMs ?? 0).toBeGreaterThan(60_000);
});
it("treats bad Debrid-Link file passwords as fatal and does not rotate keys", async () => {
const settings = {
...defaultSettings(),
@ -2034,113 +1961,9 @@ describe("debrid service", () => {
expect(result.directUrl).toBe("https://mega-web.example/ok.rar");
}, 20000);
it("setzt KEINEN Account-Cooldown, wenn der Mega-Web-Abbruch nur ein Queue-Timeout war (Account war belegt, nicht ungesund)", async () => {
process.env.RD_MEGA_ABORT_MIN_RUN_MS = "0";
const settings = {
...defaultSettings(),
token: "",
bestToken: "",
allDebridToken: "",
megaLogin: "user",
megaPassword: "pass",
megaCredentials: "user:pass",
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 controller = new AbortController();
let calls = 0;
const megaWeb = vi.fn(async () => {
calls += 1;
controller.abort("caller-timeout");
throw new Error("Mega-Web Queue-Timeout (abgebrochen nach 60s Wartezeit, Account war belegt)");
});
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
await expect(
service.unrestrictLink("https://rapidgator.net/file/queue-timeout-no-cooldown", controller.signal)
).rejects.toThrow();
const key = `${getMegaDebridAccountId("user")}:web`;
expect(getMegaDebridAccountCooldownState(key)).toBeNull();
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 = {
...defaultSettings(),
token: "",
bestToken: "",
allDebridToken: "",
megaLogin: "user",
megaPassword: "pass",
megaCredentials: "user:pass",
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 controller = new AbortController();
let calls = 0;
const megaWeb = vi.fn((): Promise<{ fileName: string; directUrl: string; fileSize: number | null; retriesUsed: number }> => {
calls += 1;
if (calls === 1) {
controller.abort("simulated-60s-timeout");
return Promise.reject(new Error("aborted"));
}
return Promise.resolve({
fileName: "healthy.rar",
directUrl: "https://www11.unrestrict.link/download/file/ok/healthy.rar",
fileSize: null,
retriesUsed: 0
});
});
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
const err = await service.unrestrictLink("https://rapidgator.net/file/slow-link.rar.html", controller.signal).then(() => null, (e: unknown) => e);
expect(err).toBeTruthy();
expect(String(err)).toMatch(/mega_debrid_slow_link:\d+:/i);
const key = `${getMegaDebridAccountId("user")}:web`;
expect(getMegaDebridAccountCooldownState(key)).toBeNull();
const second = await service.unrestrictLink("https://rapidgator.net/file/healthy.rar.html");
expect(second.provider).toBe("megadebrid");
expect(calls).toBeGreaterThanOrEqual(2);
}, 20000);
it("escalates a Mega-Debrid account to 'until restart' after the empty-response streak threshold", () => {
const key = `${getMegaDebridAccountId("user1")}:web`;
expect(MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART).toBe(10);
expect(MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART).toBe(3);
expect(recordMegaDebridEmptyResponseStreak(key)).toBe(1);
expect(recordMegaDebridEmptyResponseStreak(key)).toBe(2);
expect(recordMegaDebridEmptyResponseStreak(key)).toBe(3);
@ -2168,14 +1991,6 @@ describe("debrid service", () => {
expect(genuineEmpty.limitSignal).toBe(true);
});
it("classifies an empty Mega-Debrid API result ('Linkgenerierung lieferte kein Ergebnis') as a fast transient, not a 30s cooldown", () => {
const result = classifyMegaDebridAccountFailureForTests(new Error("Mega-Debrid API: Linkgenerierung lieferte kein Ergebnis"));
expect(result.fatal).toBe(false);
expect(result.cooldownMs).toBe(0);
expect(result.limitSignal).toBeFalsy();
expect(isMegaDebridTransientResolveFailure(result.message)).toBe(true);
});
it("skips a Mega-Debrid account parked until restart and rotates to the next, without re-testing it", async () => {
const settings = {
...defaultSettings(),
@ -2265,9 +2080,8 @@ describe("debrid service", () => {
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
const key = `${getMegaDebridAccountId("user1")}:web`;
for (let i = 0; i < MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART - 1; i += 1) {
recordMegaDebridEmptyResponseStreak(key);
}
recordMegaDebridEmptyResponseStreak(key);
recordMegaDebridEmptyResponseStreak(key);
expect(getMegaDebridAccountCooldownState(key)?.untilRestart ?? false).toBe(false);
const megaWeb = vi.fn(async () => null);

View File

@ -1,146 +0,0 @@
import fs from "node:fs";
import http from "node:http";
import os from "node:os";
import path from "node:path";
import { once } from "node:events";
import { afterEach, describe, expect, it } from "vitest";
import {
startDebugServer,
stopDebugServer,
restartDebugServer,
writeDebugServerConfig,
getDebugServerRuntimeStatus,
evaluateClientAllowed,
getPeerIp
} from "../src/main/debug-server";
import type { DownloadManager } from "../src/main/download-manager";
const tempDirs: string[] = [];
const TOKEN = "allowlist-secret";
async function getFreePort(): Promise<number> {
const probe = http.createServer();
probe.listen(0, "127.0.0.1");
await once(probe, "listening");
const address = probe.address();
if (!address || typeof address === "string") {
throw new Error("port probe failed");
}
probe.close();
await once(probe, "close");
return address.port;
}
async function waitForReady(url: string): Promise<void> {
const deadline = Date.now() + 5000;
while (Date.now() < deadline) {
try {
const res = await fetch(url);
if (res.ok) {
return;
}
} catch {
}
await new Promise((resolve) => setTimeout(resolve, 40));
}
throw new Error(`debug server not ready: ${url}`);
}
async function startWithAllowlist(allowlist: string[], host = "0.0.0.0"): Promise<{ baseUrl: string }> {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-allow-"));
tempDirs.push(baseDir);
const port = await getFreePort();
fs.writeFileSync(path.join(baseDir, "debug_token.txt"), TOKEN, "utf8");
fs.writeFileSync(path.join(baseDir, "debug_port.txt"), String(port), "utf8");
fs.writeFileSync(path.join(baseDir, "debug_host.txt"), host, "utf8");
fs.writeFileSync(path.join(baseDir, "debug_allowlist.txt"), allowlist.join("\n"), "utf8");
const manager = {} as unknown as DownloadManager;
startDebugServer(manager, baseDir);
const baseUrl = `http://127.0.0.1:${port}`;
await waitForReady(`${baseUrl}/health?token=${TOKEN}`);
return { baseUrl };
}
afterEach(() => {
stopDebugServer();
while (tempDirs.length > 0) {
const dir = tempDirs.pop();
if (!dir) {
continue;
}
try {
fs.rmSync(dir, { recursive: true, force: true });
} catch {
}
}
});
describe("debug-server allowlist matcher (pure)", () => {
it("always allows loopback regardless of rules", () => {
expect(evaluateClientAllowed("127.0.0.1", [])).toBe(true);
expect(evaluateClientAllowed("::1", [])).toBe(true);
expect(evaluateClientAllowed("::ffff:127.0.0.1", ["8.8.8.8"])).toBe(true);
});
it("matches an exact allowlisted IP and rejects others", () => {
expect(evaluateClientAllowed("8.8.8.8", ["8.8.8.8"])).toBe(true);
expect(evaluateClientAllowed("9.9.9.9", ["8.8.8.8"])).toBe(false);
});
it("matches inside a CIDR and rejects outside it", () => {
expect(evaluateClientAllowed("10.0.0.42", ["10.0.0.0/24"])).toBe(true);
expect(evaluateClientAllowed("10.0.1.42", ["10.0.0.0/24"])).toBe(false);
});
it("fail-closed: empty rules reject every non-loopback client", () => {
expect(evaluateClientAllowed("203.0.113.7", [])).toBe(false);
expect(evaluateClientAllowed("8.8.8.8", [])).toBe(false);
});
it("derives the client IP from the socket peer, never from X-Forwarded-For", () => {
const forgedLoopback = {
socket: { remoteAddress: "8.8.8.8" },
headers: { "x-forwarded-for": "127.0.0.1" }
} as unknown as http.IncomingMessage;
expect(getPeerIp(forgedLoopback)).toBe("8.8.8.8");
expect(evaluateClientAllowed(getPeerIp(forgedLoopback), [])).toBe(false);
expect(evaluateClientAllowed(getPeerIp(forgedLoopback), ["9.9.9.9"])).toBe(false);
expect(evaluateClientAllowed(getPeerIp(forgedLoopback), ["8.8.8.8"])).toBe(true);
const ipv6Mapped = {
socket: { remoteAddress: "::ffff:10.0.0.5" },
headers: {}
} as unknown as http.IncomingMessage;
expect(getPeerIp(ipv6Mapped)).toBe("10.0.0.5");
});
});
describe("debug-server allowlist enforcement (wired)", () => {
it("allows a loopback connection and ignores a spoofed X-Forwarded-For", async () => {
const { baseUrl } = await startWithAllowlist(["8.8.8.8"]);
const plain = await fetch(`${baseUrl}/health?token=${TOKEN}`);
expect(plain.status).toBe(200);
const spoofed = await fetch(`${baseUrl}/health?token=${TOKEN}`, {
headers: { "X-Forwarded-For": "203.0.113.9" }
});
expect(spoofed.status).toBe(200);
});
it("still enforces the token for loopback clients", async () => {
const { baseUrl } = await startWithAllowlist(["8.8.8.8"]);
const res = await fetch(`${baseUrl}/health`);
expect(res.status).toBe(401);
});
it("reloads the allowlist live via restartDebugServer", async () => {
const { baseUrl } = await startWithAllowlist(["8.8.8.8"]);
expect(getDebugServerRuntimeStatus().allowlistCount).toBe(1);
writeDebugServerConfig({ allowlist: ["9.9.9.9", "10.0.0.0/24"] });
const status = await restartDebugServer();
expect(status.running).toBe(true);
expect(status.allowlistCount).toBe(2);
await waitForReady(`${baseUrl}/health?token=${TOKEN}`);
expect((await fetch(`${baseUrl}/health?token=${TOKEN}`)).status).toBe(200);
});
});

View File

@ -14,7 +14,7 @@ import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits";
import { getItemLogPath, initItemLogs, shutdownItemLogs } from "../src/main/item-log";
import { initPackageLogs, shutdownPackageLogs } from "../src/main/package-log";
import { createStoragePaths, emptySession } from "../src/main/storage";
import { primeDebridLinkRuntimeCooldownForTests, resetDebridLinkRuntimeStateForTests, primeMegaDebridRuntimeCooldownForTests, resetMegaDebridRuntimeStateForTests, primeMegaDebridInFlightForTests } from "../src/main/debrid";
import { primeDebridLinkRuntimeCooldownForTests, resetDebridLinkRuntimeStateForTests, primeMegaDebridRuntimeCooldownForTests, resetMegaDebridRuntimeStateForTests } from "../src/main/debrid";
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
import { getRenameLogPath, initRenameLog, shutdownRenameLog } from "../src/main/rename-log";
import { UnrestrictedLink } from "../src/main/realdebrid";
@ -301,57 +301,6 @@ describe("download manager", () => {
expect(session.items[itemId].retries).toBeLessThan(20);
});
it("prefers the short Mega cooldown over the until-reset park when an aggregate error carries both tokens", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-mega-both-"));
tempDirs.push(root);
const storagePaths = createStoragePaths(path.join(root, "state"));
initPackageLogs(storagePaths.baseDir);
initItemLogs(storagePaths.baseDir);
const session = emptySession();
const packageId = "mega-both-pkg";
const itemId = "mega-both-item";
const outputDir = path.join(root, "downloads", "MegaBoth");
const extractDir = path.join(root, "extract", "MegaBoth");
fs.mkdirSync(outputDir, { recursive: true });
fs.mkdirSync(extractDir, { recursive: true });
session.packageOrder = [packageId];
session.packages[packageId] = {
id: packageId, name: "MegaBoth", outputDir, extractDir,
status: "downloading", itemIds: [itemId], cancelled: false, enabled: true,
createdAt: Date.now(), updatedAt: Date.now()
} as any;
session.items[itemId] = {
id: itemId, packageId, url: "https://rapidgator.net/file/mega-both.rar", provider: "megadebrid-api",
status: "downloading", retries: 0, speedBps: 0, downloadedBytes: 0, totalBytes: null,
progressPercent: 0, fileName: "mega-both.rar", targetPath: "", resumable: true,
attempts: 0, lastError: "", fullStatus: "", createdAt: Date.now(), updatedAt: Date.now()
} as any;
const manager = new DownloadManager(
{ ...defaultSettings(), megaLogin: "u", megaPassword: "p", megaCredentials: "u:p", megaDebridApiEnabled: true, megaDebridWebEnabled: true, outputDir: path.join(root, "downloads"), extractDir: path.join(root, "extract") },
session,
storagePaths
);
(manager as any).retryStateByItem.set(itemId, { freshRetryUsed: true, resumeHardResetUsed: true, stallRetries: 0, genericErrorRetries: 0, unrestrictRetries: 0 });
(manager as any).debridService.unrestrictLink = async () => {
throw new Error("Unrestrict fehlgeschlagen: Mega-Debrid API: mega_debrid_reset_park:86400000:Alle Accounts bis zum Tagesreset gesperrt | Mega-Debrid Web: mega_debrid_cooldown:30000:rate limit");
};
const active = { itemId, packageId, abortController: new AbortController(), abortReason: "none", resumable: true, nonResumableCounted: false, blockedOnDiskWrite: false, blockedOnDiskSince: 0 };
(manager as any).activeTasks.set(itemId, active);
const before = Date.now();
await (manager as any).processItem(active);
const retryAfter = (manager as any).retryAfterByItem.get(itemId) as number;
expect(retryAfter).toBeGreaterThan(0);
const delayMs = retryAfter - before;
expect(delayMs).toBeLessThan(60000);
expect(session.items[itemId].fullStatus).toMatch(/Cooldown/i);
expect(session.items[itemId].fullStatus).not.toMatch(/Tagesreset/i);
});
it("keeps the quick post-process requeue once the final package items are finished", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-postprocess-final-"));
tempDirs.push(root);
@ -1373,100 +1322,6 @@ describe("download manager", () => {
}
});
it("counts a file once in session and all-time totals across an integrity-fail retry (no telemetry double-count)", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-byteacct-"));
tempDirs.push(root);
const size = 512 * 1024;
const correct = Buffer.alloc(size, 0);
for (let i = 0; i < size; i += 1) {
correct[i] = (i * 17 + 3) & 0xff;
}
const wrong = Buffer.alloc(size, 0);
for (let i = 0; i < size; i += 1) {
wrong[i] = (i * 17 + 99) & 0xff;
}
const md5 = crypto.createHash("md5").update(correct).digest("hex");
const pkgDir = path.join(root, "downloads", "byteacct");
fs.mkdirSync(pkgDir, { recursive: true });
fs.writeFileSync(path.join(pkgDir, "byteacct.md5"), `${md5} *movie.mkv\n`, "utf8");
let fullServes = 0;
const server = http.createServer((req, res) => {
const range = String(req.headers.range || "");
const m = range.match(/bytes=(\d+)-/i);
const start = m ? Number(m[1]) : 0;
if (start === 0) {
fullServes += 1;
}
const body = fullServes <= 1 ? wrong : correct;
const chunk = body.subarray(start);
if (start > 0) {
res.statusCode = 206;
res.setHeader("Content-Range", `bytes ${start}-${body.length - 1}/${body.length}`);
} else {
res.statusCode = 200;
}
res.setHeader("Accept-Ranges", "bytes");
res.setHeader("Content-Length", String(chunk.length));
res.end(chunk);
});
server.listen(0, "127.0.0.1");
await once(server, "listening");
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("server address unavailable");
}
const base = `http://127.0.0.1:${address.port}`;
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("/unrestrict/link")) {
return new Response(
JSON.stringify({ download: `${base}/file`, filename: "movie.mkv", filesize: size }),
{ status: 200, headers: { "Content-Type": "application/json" } }
);
}
return originalFetch(input, init);
};
try {
const manager = new DownloadManager(
{
...defaultSettings(),
token: "rd-token",
outputDir: path.join(root, "downloads"),
extractDir: path.join(root, "extract"),
autoExtract: false,
autoReconnect: false,
enableIntegrityCheck: true,
totalDownloadedAllTime: 0
},
emptySession(),
createStoragePaths(path.join(root, "state"))
);
manager.addPackages([{ name: "byteacct", links: ["https://dummy/byteacct"] }]);
await manager.start();
await waitFor(() => !manager.getSnapshot().session.running, 30000);
const item = Object.values(manager.getSnapshot().session.items)[0];
expect(item?.status).toBe("completed");
expect(fullServes).toBeGreaterThanOrEqual(2);
const internal = manager as unknown as {
session: { totalDownloadedBytes: number };
settings: { totalDownloadedAllTime: number };
};
expect(internal.session.totalDownloadedBytes).toBe(size);
expect(internal.settings.totalDownloadedAllTime).toBe(size);
} finally {
server.close();
await once(server, "close");
}
}, 40000);
it("requests a fresh direct link after repeated same-link download failures", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root);
@ -2209,98 +2064,6 @@ describe("download manager", () => {
expect(fs.statSync(item.targetPath).size).toBe(binary.length);
});
it("recovers an HTTP 416 item with a clean fresh restart after the in-budget retries are exhausted", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-416-fresh-"));
tempDirs.push(root);
const binary = Buffer.alloc(160 * 1024, 19);
const prevDelay = process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS;
process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS = "0";
let downloadCalls = 0;
globalThis.fetch = async (input: RequestInfo | URL): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("/unrestrict/link")) {
return new Response(JSON.stringify({ download: "https://dummy/direct-416-recover", filename: "fresh-416.mkv", filesize: binary.length }), { status: 200, headers: { "Content-Type": "application/json" } });
}
throw new Error(`unexpected fetch ${url}`);
};
try {
const manager = new DownloadManager(
{ ...defaultSettings(), token: "rd-token", outputDir: path.join(root, "downloads"), extractDir: path.join(root, "extract"), retryLimit: 2, autoExtract: false, autoReconnect: false },
emptySession(),
createStoragePaths(path.join(root, "state"))
);
(manager as any).downloadToFile = async (_active: unknown, _directUrl: string, targetPath: string) => {
downloadCalls += 1;
if (downloadCalls <= 3) {
throw new Error("HTTP 416");
}
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
fs.writeFileSync(targetPath, binary);
const item = Object.values((manager as any).session.items)[0] as { downloadedBytes: number; totalBytes: number; progressPercent: number } | undefined;
if (item) {
item.downloadedBytes = binary.length;
item.totalBytes = binary.length;
item.progressPercent = 100;
}
return { resumable: true };
};
manager.addPackages([{ name: "fresh-416", links: ["https://dummy/fresh-416"] }]);
await manager.start();
await waitFor(() => !manager.getSnapshot().session.running, 20000);
const item = Object.values(manager.getSnapshot().session.items)[0];
expect(item?.status).toBe("completed");
expect(downloadCalls).toBeGreaterThan(3);
} finally {
if (prevDelay === undefined) { delete process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS; } else { process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS = prevDelay; }
}
}, 25000);
it("bounds HTTP 416 clean restarts and finally fails instead of looping forever or stalling permanently", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-416-cap-"));
tempDirs.push(root);
const prevDelay = process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS;
process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS = "0";
let downloadCalls = 0;
globalThis.fetch = async (input: RequestInfo | URL): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("/unrestrict/link")) {
return new Response(JSON.stringify({ download: "https://dummy/direct-416-forever", filename: "always-416.mkv", filesize: 1024 }), { status: 200, headers: { "Content-Type": "application/json" } });
}
throw new Error(`unexpected fetch ${url}`);
};
try {
const manager = new DownloadManager(
{ ...defaultSettings(), token: "rd-token", outputDir: path.join(root, "downloads"), extractDir: path.join(root, "extract"), retryLimit: 0, autoExtract: false, autoReconnect: false },
emptySession(),
createStoragePaths(path.join(root, "state"))
);
(manager as any).downloadToFile = async () => {
downloadCalls += 1;
throw new Error("direct_link_retry_exhausted:HTTP 416");
};
manager.addPackages([{ name: "always-416", links: ["https://dummy/always-416"] }]);
await manager.start();
await waitFor(() => !manager.getSnapshot().session.running, 20000);
const item = Object.values(manager.getSnapshot().session.items)[0];
expect(item?.status).toBe("failed");
expect(downloadCalls).toBeGreaterThan(4);
expect(downloadCalls).toBeLessThan(30);
expect((manager as any).http416FreshRestartByItem.get(item.id)).toBeUndefined();
} finally {
if (prevDelay === undefined) { delete process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS; } else { process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS = prevDelay; }
}
}, 25000);
it("retries HTTP 416 in-session when using Debrid-Link API and then completes", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root);
@ -12336,200 +12099,3 @@ describe("download manager", () => {
}
});
});
describe("start conflict guard + selective resume", () => {
function makeItem(id: string, packageId: string, status: string, fileName: string): any {
return {
id, packageId, url: `https://hoster.example/${id}`, provider: "realdebrid",
status, retries: 0, speedBps: 0, downloadedBytes: status === "completed" ? 100 : 0,
totalBytes: status === "completed" ? 100 : null, progressPercent: status === "completed" ? 100 : 0,
fileName, targetPath: "", resumable: true, attempts: 0, lastError: "", fullStatus: "",
createdAt: Date.now(), updatedAt: Date.now()
};
}
it("does not flag a partially-downloaded package whose extract dir holds its own completed output", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-startconflict-own-"));
tempDirs.push(root);
const storagePaths = createStoragePaths(path.join(root, "state"));
initPackageLogs(storagePaths.baseDir);
initItemLogs(storagePaths.baseDir);
const session = emptySession();
const packageId = "pkg-own-output";
const extractDir = path.join(root, "extract", "OwnOutput");
fs.mkdirSync(extractDir, { recursive: true });
fs.writeFileSync(path.join(extractDir, "episode01.mkv"), Buffer.alloc(64, 7));
session.packageOrder = [packageId];
session.packages[packageId] = {
id: packageId, name: "OwnOutput",
outputDir: path.join(root, "downloads", "OwnOutput"), extractDir,
status: "queued", itemIds: ["own-done", "own-pending"], cancelled: false, enabled: true,
createdAt: Date.now(), updatedAt: Date.now()
} as any;
session.items["own-done"] = makeItem("own-done", packageId, "completed", "done.rar");
session.items["own-pending"] = makeItem("own-pending", packageId, "queued", "pending.rar");
const manager = new DownloadManager(
{ ...defaultSettings(), token: "rd-token", outputDir: path.join(root, "downloads"), extractDir: path.join(root, "extract") },
session, storagePaths
);
const conflicts = await manager.getStartConflicts();
expect(conflicts.map((c) => c.packageId)).not.toContain(packageId);
});
it("flags a fresh package when its package-specific extract dir already holds files and it has no completed items", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-startconflict-fresh-"));
tempDirs.push(root);
const storagePaths = createStoragePaths(path.join(root, "state"));
initPackageLogs(storagePaths.baseDir);
initItemLogs(storagePaths.baseDir);
const session = emptySession();
const packageId = "pkg-fresh-conflict";
const extractDir = path.join(root, "extract", "FreshConflict");
fs.mkdirSync(extractDir, { recursive: true });
fs.writeFileSync(path.join(extractDir, "old-from-previous-run.mkv"), Buffer.alloc(64, 9));
session.packageOrder = [packageId];
session.packages[packageId] = {
id: packageId, name: "FreshConflict",
outputDir: path.join(root, "downloads", "FreshConflict"), extractDir,
status: "queued", itemIds: ["fresh-pending"], cancelled: false, enabled: true,
createdAt: Date.now(), updatedAt: Date.now()
} as any;
session.items["fresh-pending"] = makeItem("fresh-pending", packageId, "queued", "fresh.rar");
const manager = new DownloadManager(
{ ...defaultSettings(), token: "rd-token", outputDir: path.join(root, "downloads"), extractDir: path.join(root, "extract") },
session, storagePaths
);
const conflicts = await manager.getStartConflicts();
expect(conflicts.map((c) => c.packageId)).toContain(packageId);
});
it("start() holds excluded packages out of the run set and runs the rest", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-selective-resume-"));
tempDirs.push(root);
const storagePaths = createStoragePaths(path.join(root, "state"));
initPackageLogs(storagePaths.baseDir);
initItemLogs(storagePaths.baseDir);
const session = emptySession();
const runId = "pkg-run";
const holdId = "pkg-hold";
fs.mkdirSync(path.join(root, "downloads", "RunMe"), { recursive: true });
fs.mkdirSync(path.join(root, "downloads", "HoldMe"), { recursive: true });
session.packageOrder = [runId, holdId];
session.packages[runId] = {
id: runId, name: "RunMe",
outputDir: path.join(root, "downloads", "RunMe"), extractDir: path.join(root, "extract", "RunMe"),
status: "queued", itemIds: ["run-item"], cancelled: false, enabled: true,
createdAt: Date.now(), updatedAt: Date.now()
} as any;
session.packages[holdId] = {
id: holdId, name: "HoldMe",
outputDir: path.join(root, "downloads", "HoldMe"), extractDir: path.join(root, "extract", "HoldMe"),
status: "queued", itemIds: ["hold-item"], cancelled: false, enabled: true,
createdAt: Date.now(), updatedAt: Date.now()
} as any;
session.items["run-item"] = makeItem("run-item", runId, "queued", "run.rar");
session.items["hold-item"] = makeItem("hold-item", holdId, "queued", "hold.rar");
const manager = new DownloadManager(
{ ...defaultSettings(), token: "rd-token", maxParallel: 2, outputDir: path.join(root, "downloads"), extractDir: path.join(root, "extract") },
session, storagePaths
);
(manager as any).debridService.unrestrictLink = () => new Promise(() => {});
await manager.start({ excludePackageIds: new Set([holdId]) });
expect((manager as any).runPackageIds.has(runId)).toBe(true);
expect((manager as any).runPackageIds.has(holdId)).toBe(false);
expect((manager as any).runItemIds.has("run-item")).toBe(true);
expect((manager as any).runItemIds.has("hold-item")).toBe(false);
expect(session.items["hold-item"].status).toBe("queued");
manager.stop();
});
});
describe("mega-debrid api/web resolution overlap gate", () => {
function megaApiSettings(root: string): any {
return {
...defaultSettings(),
megaLogin: "u", megaPassword: "p", megaCredentials: "u:p",
megaDebridApiEnabled: true, megaDebridWebEnabled: true, megaDebridPreferApi: true,
outputDir: path.join(root, "downloads"), extractDir: path.join(root, "extract")
};
}
function megaItem(id: string, status: string): any {
return {
id, packageId: "pkg", url: `https://rapidgator.net/file/${id}`, provider: "megadebrid-api",
status, retries: 0, speedBps: 0, downloadedBytes: 0, totalBytes: null, progressPercent: 0,
fileName: `${id}.rar`, targetPath: "", resumable: true, attempts: 0, lastError: "", fullStatus: "",
createdAt: Date.now(), updatedAt: Date.now()
};
}
function addValidating(manager: DownloadManager, session: any, ids: string[]): void {
for (const id of ids) {
session.items[id] = megaItem(id, "validating");
(manager as any).activeTasks.set(id, {
itemId: id, packageId: "pkg", abortController: new AbortController(), abortReason: "none",
resumable: true, nonResumableCounted: false, blockedOnDiskWrite: false, blockedOnDiskSince: 0
});
}
}
function buildManager(root: string, session: any): DownloadManager {
return new DownloadManager(megaApiSettings(root), session, createStoragePaths(path.join(root, "state")));
}
it("lets the first mega resolve start when nothing is in flight", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-overlap-0-"));
tempDirs.push(root);
const session = emptySession();
const candidate = megaItem("cand", "queued");
session.items["cand"] = candidate;
const manager = buildManager(root, session);
expect((manager as any).shouldDelayStartForItem(candidate)).toBe(false);
});
it("serializes a second API resolve while the first is still in its API phase (no concurrent API)", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-overlap-1-"));
tempDirs.push(root);
const session = emptySession();
const candidate = megaItem("cand", "queued");
session.items["cand"] = candidate;
const manager = buildManager(root, session);
addValidating(manager, session, ["a"]);
expect((manager as any).shouldDelayStartForItem(candidate)).toBe(true);
});
it("allows one API resolve to overlap once the first has moved to its web phase", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-overlap-2-"));
tempDirs.push(root);
const session = emptySession();
const candidate = megaItem("cand", "queued");
session.items["cand"] = candidate;
const manager = buildManager(root, session);
addValidating(manager, session, ["a"]);
primeMegaDebridInFlightForTests("acc:web", 1);
expect((manager as any).shouldDelayStartForItem(candidate)).toBe(false);
});
it("caps the overlap at one API plus one web (no third concurrent resolve)", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-overlap-3-"));
tempDirs.push(root);
const session = emptySession();
const candidate = megaItem("cand", "queued");
session.items["cand"] = candidate;
const manager = buildManager(root, session);
addValidating(manager, session, ["a", "b"]);
primeMegaDebridInFlightForTests("acc:web", 1);
expect((manager as any).shouldDelayStartForItem(candidate)).toBe(true);
});
});

View File

@ -314,41 +314,5 @@ describe("mega-web-fallback", () => {
clearTimeout(timer);
}
});
it("klassifiziert einen Abbruch WAEHREND in der Queue als Queue-Timeout (nicht harter Abbruch), damit der belegte Account nicht bestraft wird", async () => {
let releaseLogin: () => void = () => {};
const loginGate = new Promise<void>((resolve) => { releaseLogin = resolve; });
globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
const u = String(url);
if (u.includes("form=login")) {
await loginGate;
const headers = new Headers();
headers.append("set-cookie", "session=c; path=/");
return new Response("", { headers, status: 200 });
}
if (u.includes("page=debrideur")) return new Response('<form id="debridForm"></form>', { status: 200 });
if (u.includes("form=debrid")) return new Response(`<div class="acp-box"><h3>Link: https://mega.debrid/l</h3><a href="javascript:processDebrid(1,'code',0)">d</a></div>`, { status: 200 });
if (u.includes("ajax=debrid")) return new Response(JSON.stringify({ link: "https://mega.direct/ok" }), { status: 200 });
return new Response("Not found", { status: 404 });
}) as unknown as typeof fetch;
const fallback = new MegaWebFallback(() => ({ login: "same", password: "pw" }));
const firstCtrl = new AbortController();
const first = fallback.unrestrict("https://mega.debrid/a", firstCtrl.signal, { login: "same", password: "pw" });
await new Promise((r) => setTimeout(r, 25));
const secondCtrl = new AbortController();
const second = fallback.unrestrict("https://mega.debrid/b", secondCtrl.signal, { login: "same", password: "pw" });
await new Promise((r) => setTimeout(r, 25));
secondCtrl.abort("caller-timeout");
await expect(second).rejects.toThrow(/queue.?timeout/i);
releaseLogin();
await first.catch(() => null);
await new Promise((r) => setTimeout(r, 20));
}, 10000);
});
});

Binary file not shown.

View File

@ -180,67 +180,6 @@ describe("settings storage", () => {
expect(webNormalized.hosterRouting.rapidgator).toBe("megadebrid-web");
});
it("migriert eine pre-v1.6.90-Config (Mega-Creds, beide Enable-Flags fehlen) zu aktiviertem Mega-Debrid statt es still auf false zu setzen", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
const legacyApi = {
megaLogin: "mega-user",
megaPassword: "mega-pass",
megaDebridPreferApi: true,
providerPrimary: "realdebrid",
providerSecondary: "megadebrid"
};
fs.writeFileSync(paths.configFile, JSON.stringify(legacyApi), "utf8");
const loadedApi = loadSettings(paths);
expect(loadedApi.megaDebridApiEnabled).toBe(true);
expect(loadedApi.megaDebridWebEnabled).toBe(false);
const dir2 = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir2);
const paths2 = createStoragePaths(dir2);
const legacyWeb = {
megaLogin: "mega-user",
megaPassword: "mega-pass",
megaDebridPreferApi: false,
providerPrimary: "realdebrid",
providerSecondary: "megadebrid"
};
fs.writeFileSync(paths2.configFile, JSON.stringify(legacyWeb), "utf8");
const loadedWeb = loadSettings(paths2);
expect(loadedWeb.megaDebridApiEnabled).toBe(false);
expect(loadedWeb.megaDebridWebEnabled).toBe(true);
});
it("re-aktiviert KEINE bewusst deaktivierten Mega-Flags und migriert nicht ohne Mega-Creds", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
const deliberatelyDisabled = {
megaLogin: "mega-user",
megaPassword: "mega-pass",
megaDebridPreferApi: true,
megaDebridApiEnabled: false,
megaDebridWebEnabled: false
};
fs.writeFileSync(paths.configFile, JSON.stringify(deliberatelyDisabled), "utf8");
const loaded = loadSettings(paths);
expect(loaded.megaDebridApiEnabled).toBe(false);
expect(loaded.megaDebridWebEnabled).toBe(false);
const dir2 = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir2);
const paths2 = createStoragePaths(dir2);
const noCreds = {
megaDebridPreferApi: true,
providerPrimary: "realdebrid"
};
fs.writeFileSync(paths2.configFile, JSON.stringify(noCreds), "utf8");
const loadedNoCreds = loadSettings(paths2);
expect(loadedNoCreds.megaDebridApiEnabled).toBe(false);
expect(loadedNoCreds.megaDebridWebEnabled).toBe(false);
});
it("normalizes provider daily limits and resets stale daily usage", () => {
const [debridLinkKey] = parseDebridLinkApiKeys("dl-key-one");
const normalized = normalizeSettings({

View File

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { transientResolveRetryDelayMs, parseMegaDebridCooldownRetry, parseMegaDebridResetPark, parseMegaDebridSlowLinkRetry } from "../src/main/download-manager";
import { transientResolveRetryDelayMs, parseMegaDebridCooldownRetry, parseMegaDebridResetPark } from "../src/main/download-manager";
describe("transientResolveRetryDelayMs (fast, bounded retry for transient resolve failures)", () => {
it("starts fast (<= 3s) instead of the 5s..120s exponential", () => {
@ -63,31 +63,6 @@ describe("parseMegaDebridCooldownRetry (honor the encoded account-cooldown delay
});
});
describe("parseMegaDebridSlowLinkRetry (park only the slow link, never the account)", () => {
it("parses the encoded delay from a slow-link error", () => {
const r = parseMegaDebridSlowLinkRetry("mega_debrid_slow_link:120000:Mega-Debrid (Account 1/1, Su******e3): aborted");
expect(r).not.toBeNull();
expect(r!.delayMs).toBe(120000);
expect(r!.detail).toContain("Mega-Debrid");
});
it("parses it when embedded in the aggregated provider-chain error", () => {
const aggregated = "Provider-Kette: Mega-Debrid Web fehlgeschlagen (Error: mega_debrid_slow_link:90000:Mega-Debrid (Account 1/1): aborted)";
expect(parseMegaDebridSlowLinkRetry(aggregated)!.delayMs).toBe(90000);
});
it("clamps to [1s, 15min]", () => {
expect(parseMegaDebridSlowLinkRetry("mega_debrid_slow_link:1:x")!.delayMs).toBe(1000);
expect(parseMegaDebridSlowLinkRetry("mega_debrid_slow_link:99999999:x")!.delayMs).toBe(15 * 60 * 1000);
});
it("does not collide with the account-cooldown or reset-park tokens", () => {
expect(parseMegaDebridSlowLinkRetry("mega_debrid_cooldown:20330:x")).toBeNull();
expect(parseMegaDebridSlowLinkRetry("mega_debrid_reset_park:43200000:x")).toBeNull();
expect(parseMegaDebridCooldownRetry("mega_debrid_slow_link:120000:x")).toBeNull();
});
});
describe("parseMegaDebridResetPark (park the item until the Tagesreset, not a ~2min generic retry)", () => {
it("parses the encoded until-reset delay from the park token", () => {
const r = parseMegaDebridResetPark("mega_debrid_reset_park:43200000:Mega-Debrid: Alle Accounts am Tageslimit (bis zum Tagesreset gesperrt)");

View File

@ -1,62 +0,0 @@
# rd-diagnostics-mcp
Standalone **stdio MCP bridge** to the Real-Debrid-Downloader debug-server. It runs on the machine where the
MCP client runs, takes a **connection code** for a downloader server, and exposes that server's
read-only HTTP diagnostics API (`/diagnostics`, `/status`, `/errors`, `/logs/*`, `/accounts`, …) as MCP tools.
One bridge serves all 56 servers; you pass a `code` (or a configured `server` name) per call.
This bridge is **not** bundled into the Electron app and adds **no** dependencies to it.
## Setup
```bash
cd tools/rd-diagnostics-mcp
npm install
```
Register it with your MCP client as a stdio server that launches the bridge:
```bash
node "<repo>/tools/rd-diagnostics-mcp/src/bridge.mjs"
```
Provide servers via environment variables (codes contain a token — treat like passwords):
- `RDDIAG_CODE` — a single default connection code (`rddiag:v1:...`)
- `RDDIAG_SERVERS` — JSON map of name → code, e.g. `{"berlin":"rddiag:v1:...","fra":"rddiag:v1:..."}`
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`
(`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.
## Connection code
Format: `rddiag:v1:<base64url(JSON)>` with `{ v:1, h:host, p:port, t:token, n?:name, fp?:certFingerprint, s?:scheme }`.
Generated by the app (Hilfe → Remote-Support → Ferndiagnose (MCP)) or via `node src/gen-code.mjs --host H --port P --token T`.
## Security model (read before exposing a server)
- The debug surface is **read-only** for state/logs; the one control endpoint is `/trace/config` (toggles the
optional, time-bounded support trace). No persistent secrets are written into the logs it serves: passwords are
redacted, debrid API keys/tokens and resolved download URLs are never logged; `/settings` and `/accounts` are redacted.
- Auth is a bearer token (24 random bytes). Over plain HTTP on a public network the token is sniffable, so:
- **Preferred:** keep the server bound to `127.0.0.1` ("Nur lokal") and reach it through a private tunnel
(Tailscale / SSH / Cloudflare Tunnel). The tunnel encrypts and authenticates; no public exposure.
- **Direct network bind (`0.0.0.0`)** requires a non-empty **IP allowlist** (enforced fail-closed: with an empty
allowlist only loopback is accepted). Use only inside a trusted LAN/VPN.
- Revoke instantly from the app ("Token neu" or "Deaktivieren") — the old code stops working immediately.
- `fp` pins a self-signed cert fingerprint and is verified on `secureConnect` (before the token is sent). HTTPS is
not the v1 default; plain HTTP behind a tunnel is the recommended transport.
## Test
```bash
npm test # spins a fake debug-server, runs the bridge as a stdio child, asserts the full protocol path
```

File diff suppressed because it is too large Load Diff

View File

@ -1,18 +0,0 @@
{
"name": "rd-diagnostics-mcp",
"version": "1.0.0",
"private": true,
"description": "Standalone stdio MCP bridge to the Real-Debrid-Downloader debug-server. Connects via connection code, proxies the read-only HTTP diagnostics API as MCP tools.",
"type": "module",
"bin": {
"rd-diagnostics-mcp": "src/bridge.mjs"
},
"scripts": {
"start": "node src/bridge.mjs",
"test": "node test/harness.mjs"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.12.0",
"zod": "^3.23.8"
}
}

View File

@ -1,332 +0,0 @@
#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { decodeConnectionCode } from "./code.mjs";
import { debugGet } from "./http.mjs";
function loadServerMap() {
const map = new Map();
const raw = process.env.RDDIAG_SERVERS;
if (raw) {
try {
const parsed = JSON.parse(raw);
for (const [name, code] of Object.entries(parsed || {})) {
map.set(String(name), String(code));
}
} catch {
process.stderr.write("rd-diagnostics-mcp: RDDIAG_SERVERS ist kein gueltiges JSON, wird ignoriert\n");
}
}
return map;
}
const SERVER_MAP = loadServerMap();
const DEFAULT_CODE = process.env.RDDIAG_CODE ? String(process.env.RDDIAG_CODE) : "";
function listAvailableServers() {
const names = [...SERVER_MAP.keys()];
if (DEFAULT_CODE) names.push("(RDDIAG_CODE-Default)");
return names;
}
function resolveTarget(args) {
let code = "";
if (args && args.code) {
code = String(args.code);
} else if (args && args.server) {
const found = SERVER_MAP.get(String(args.server));
if (!found) {
throw new Error(
`Server "${args.server}" nicht konfiguriert. Bekannt: ${listAvailableServers().join(", ") || "(keine)"}`
);
}
code = found;
} else if (DEFAULT_CODE) {
code = DEFAULT_CODE;
} else if (SERVER_MAP.size === 1) {
code = [...SERVER_MAP.values()][0];
} else {
throw new Error(
`Kein Verbindungscode. Uebergib "code" oder "server", oder setze RDDIAG_CODE/RDDIAG_SERVERS. Bekannt: ${listAvailableServers().join(", ") || "(keine)"}`
);
}
return decodeConnectionCode(code);
}
function targetLabel(target) {
return target.name ? `${target.name} (${target.host}:${target.port})` : `${target.host}:${target.port}`;
}
function buildQuery(params) {
const usable = Object.entries(params || {}).filter(
([, v]) => v !== undefined && v !== null && String(v).length > 0
);
if (usable.length === 0) return "";
const sp = new URLSearchParams();
for (const [k, v] of usable) sp.set(k, String(v));
return "?" + sp.toString();
}
function prettyBody(body) {
try {
return JSON.stringify(JSON.parse(body), null, 2);
} catch {
return body;
}
}
function connectionHint(err) {
const m = String((err && err.code) || err && err.message || "");
if (/ECONNREFUSED/.test(m)) return "Debug-Server nicht erreichbar — auf dem Server aktiviert? Port/Firewall offen?";
if (/ENOTFOUND|EAI_AGAIN/.test(m)) return "Host nicht aufloesbar — stimmt die Adresse im Verbindungscode?";
if (/ETIMEDOUT|Zeitueberschreitung/.test(m)) return "Zeitueberschreitung — Server/Netz langsam oder Port geblockt.";
if (/ECONNRESET|EPIPE/.test(m)) return "Verbindung abgebrochen — falscher Port/Scheme (http vs https)?";
if (/Fingerprint/.test(m)) return "TLS-Fingerprint passt nicht — Code stammt evtl. von einem anderen Server.";
return "";
}
async function requestTool(args, path, params, opts = {}) {
let target;
try {
target = resolveTarget(args);
} catch (err) {
return { content: [{ type: "text", text: `# Verbindungsfehler\n${err.message}` }], isError: true };
}
const fullPath = path + buildQuery(params);
const label = targetLabel(target);
try {
const res = await debugGet(target, fullPath, { timeoutMs: opts.timeoutMs || 20000 });
const isError = res.status < 200 || res.status >= 300;
let extra = "";
if (res.status === 401) extra = "\n(401 = Token im Verbindungscode ist abgelaufen/rotiert. Neuen Code anfordern.)";
if (res.status === 503) extra = "\n(503 = Download-Manager nicht bereit. App laeuft, aber noch nicht initialisiert?)";
const head = `# ${label} ${fullPath} → HTTP ${res.status}${extra}`;
return { content: [{ type: "text", text: head + "\n" + prettyBody(res.body) }], isError };
} catch (err) {
const hint = connectionHint(err);
const text = `# ${label} ${fullPath} → FEHLER\n${err.message}${hint ? "\n→ " + hint : ""}`;
return { content: [{ type: "text", text }], isError: true };
}
}
const CODE_FIELD = {
code: z.string().optional().describe("Verbindungscode (rddiag:v1:...). Optional, wenn server/RDDIAG_CODE gesetzt ist."),
server: z.string().optional().describe("Name eines via RDDIAG_SERVERS konfigurierten Servers statt eines vollen Codes.")
};
const server = new McpServer({ name: "rd-diagnostics-mcp", version: "1.0.0" });
server.registerTool(
"rd_servers",
{
title: "Konfigurierte Server",
description: "Listet die in dieser Bridge konfigurierten Server (RDDIAG_SERVERS / RDDIAG_CODE). Verbindet sich nicht.",
inputSchema: {}
},
async () => {
const names = [...SERVER_MAP.keys()];
const lines = [];
lines.push(`Konfigurierte Server: ${names.length}`);
for (const n of names) lines.push(`- ${n}`);
lines.push(`Default (RDDIAG_CODE): ${DEFAULT_CODE ? "gesetzt" : "nicht gesetzt"}`);
lines.push("");
lines.push("Tools akzeptieren entweder code:<rddiag:v1:...> oder server:<name>.");
return { content: [{ type: "text", text: lines.join("\n") }] };
}
);
server.registerTool(
"rd_ping",
{
title: "Erreichbarkeit pruefen",
description: "Schneller Health-Check (GET /health): App-Version, Uptime, Speicher. Zuerst aufrufen, um Erreichbarkeit + Token zu pruefen.",
inputSchema: { ...CODE_FIELD }
},
async (args) => requestTool(args, "/health", {}, { timeoutMs: 10000 })
);
server.registerTool(
"rd_diagnostics",
{
title: "Gesamtdiagnose",
description: "Aggregierter Zustand (GET /diagnostics): Meta, Status, Settings, Stats, Accounts, History, Host + die wichtigsten Logs. Der 'alles auf einen Blick'-Endpunkt.",
inputSchema: {
...CODE_FIELD,
lines: z.number().int().positive().optional().describe("Anzahl Log-Zeilen pro Log (Default 150)."),
grep: z.string().optional().describe("Filter fuer Log-Zeilen."),
package: z.string().optional().describe("Optional auf ein Paket fokussieren.")
}
},
async (args) => requestTool(args, "/diagnostics", { lines: args.lines, grep: args.grep, package: args.package }, { timeoutMs: 30000 })
);
server.registerTool(
"rd_status",
{
title: "Live-Status",
description: "Laufzeit-Status (GET /status): aktive Downloads, Queue, Provider-Zustand.",
inputSchema: { ...CODE_FIELD }
},
async (args) => requestTool(args, "/status", {})
);
server.registerTool(
"rd_items",
{
title: "Download-Items",
description: "Einzelne Download-Items (GET /items), optional gefiltert nach Status/Paket.",
inputSchema: {
...CODE_FIELD,
status: z.string().optional().describe("Status-Filter (z.B. downloading, error, done)."),
package: z.string().optional().describe("Paket-Filter.")
}
},
async (args) => requestTool(args, "/items", { status: args.status, package: args.package })
);
server.registerTool(
"rd_packages",
{
title: "Pakete",
description: "Pakete (GET /packages), optional mit enthaltenen Items.",
inputSchema: {
...CODE_FIELD,
package: z.string().optional().describe("Bestimmtes Paket."),
includeItems: z.boolean().optional().describe("Items mitliefern.")
}
},
async (args) => requestTool(args, "/packages", { package: args.package, includeItems: args.includeItems ? "1" : "" })
);
server.registerTool(
"rd_errors",
{
title: "Letzte Fehler",
description: "Fehler-Ring (GET /errors): die letzten Fehler mit Level/Quelle. 'Was ist schiefgelaufen'.",
inputSchema: {
...CODE_FIELD,
level: z.string().optional().describe("Level-Filter (ERROR, WARN, ...)."),
limit: z.number().int().positive().optional().describe("Anzahl (Default 100).")
}
},
async (args) => requestTool(args, "/errors", { level: args.level, limit: args.limit })
);
const LOG_PATHS = {
main: "/logs/main",
audit: "/logs/audit",
rename: "/logs/rename",
trace: "/logs/trace",
session: "/logs/session",
conversion: "/logs/conversion",
package: "/logs/package",
item: "/logs/item"
};
server.registerTool(
"rd_logs",
{
title: "Log lesen",
description: "Liest das Ende eines Logs (GET /logs/<name>). 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", "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."),
item: z.string().optional().describe("Nur fuer name=item.")
}
},
async (args) => {
const path = LOG_PATHS[args.name];
return requestTool(args, path, { lines: args.lines, grep: args.grep, package: args.package, item: args.item });
}
);
server.registerTool(
"rd_history",
{
title: "Verlauf",
description: "Abgeschlossener Verlauf (GET /history), optional nach Status/Suchbegriff.",
inputSchema: {
...CODE_FIELD,
limit: z.number().int().positive().optional().describe("Anzahl (Default 50)."),
status: z.string().optional().describe("Status-Filter."),
grep: z.string().optional().describe("Suchbegriff.")
}
},
async (args) => requestTool(args, "/history", { limit: args.limit, status: args.status, grep: args.grep })
);
server.registerTool(
"rd_accounts",
{
title: "Accounts",
description: "Debrid-Accounts (GET /accounts, Token redigiert): Gueltigkeit, Premium, Cooldown/Rotation.",
inputSchema: { ...CODE_FIELD }
},
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",
{
title: "Host-Diagnose",
description: "Windows-Host-Diagnose (GET /host/diagnostics): Laufwerke, Speicher, Pfade.",
inputSchema: { ...CODE_FIELD }
},
async (args) => requestTool(args, "/host/diagnostics", {})
);
server.registerTool(
"rd_self_check",
{
title: "Self-Check",
description: "Setup/Self-Check (GET /self-check): erkennt Konfigurations-/Pfadprobleme.",
inputSchema: { ...CODE_FIELD }
},
async (args) => requestTool(args, "/self-check", {})
);
server.registerTool(
"rd_get",
{
title: "Roh-Endpunkt (Escape-Hatch)",
description: "Beliebigen Debug-Server-Pfad lesen (GET <path>), wenn kein spezialisiertes Tool passt. Pfad inkl. fuehrendem / und optionalem Query-String, z.B. /meta oder /stats.",
inputSchema: {
...CODE_FIELD,
path: z.string().describe("Pfad mit fuehrendem /, optional ?query. Nur GET, read-only.")
}
},
async (args) => {
const p = String(args.path || "");
if (!p.startsWith("/")) {
return { content: [{ type: "text", text: "# Fehler\npath muss mit / beginnen" }], isError: true };
}
return requestTool(args, p, {}, { timeoutMs: 30000 });
}
);
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
process.stderr.write(
`rd-diagnostics-mcp bereit. Server: ${listAvailableServers().join(", ") || "(keine vorkonfiguriert; code pro Aufruf uebergeben)"}\n`
);
}
main().catch((err) => {
process.stderr.write(`rd-diagnostics-mcp Startfehler: ${err && err.stack ? err.stack : err}\n`);
process.exit(1);
});

View File

@ -1,20 +0,0 @@
export interface DecodedConnectionCode {
host: string;
port: number;
token: string;
scheme: string;
name: string;
fingerprint: string;
}
export interface EncodeConnectionCodeInput {
host: string;
port: number;
token: string;
name?: string;
fingerprint?: string;
scheme?: string;
}
export function encodeConnectionCode(input: EncodeConnectionCodeInput): string;
export function decodeConnectionCode(code: string): DecodedConnectionCode;

View File

@ -1,56 +0,0 @@
const PREFIX = "rddiag:v1:";
function base64urlEncode(str) {
return Buffer.from(str, "utf8")
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
function base64urlDecode(str) {
const pad = str.length % 4 === 0 ? "" : "=".repeat(4 - (str.length % 4));
const b64 = str.replace(/-/g, "+").replace(/_/g, "/") + pad;
return Buffer.from(b64, "base64").toString("utf8");
}
export function encodeConnectionCode({ host, port, token, name, fingerprint, scheme }) {
if (!host || typeof host !== "string") throw new Error("host fehlt");
const p = Number(port);
if (!Number.isInteger(p) || p < 1 || p > 65535) throw new Error("port ungueltig");
if (!token || typeof token !== "string") throw new Error("token fehlt");
const payload = { v: 1, h: host, p, t: token };
if (name) payload.n = String(name);
if (fingerprint) payload.fp = String(fingerprint);
if (scheme && scheme !== "http") payload.s = String(scheme);
return PREFIX + base64urlEncode(JSON.stringify(payload));
}
export function decodeConnectionCode(code) {
const raw = String(code || "").trim();
if (!raw.startsWith(PREFIX)) {
throw new Error(`Verbindungscode muss mit "${PREFIX}" beginnen`);
}
let json;
try {
json = JSON.parse(base64urlDecode(raw.slice(PREFIX.length)));
} catch {
throw new Error("Verbindungscode ist beschaedigt (kein gueltiges base64url/JSON)");
}
if (!json || typeof json !== "object") throw new Error("Verbindungscode-Inhalt ungueltig");
const host = String(json.h || "").trim();
const port = Number(json.p);
const token = String(json.t || "");
if (!host) throw new Error("Verbindungscode ohne Host");
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("Verbindungscode mit ungueltigem Port");
if (!token) throw new Error("Verbindungscode ohne Token");
const scheme = json.s === "https" ? "https" : "http";
return {
host,
port,
token,
scheme,
name: json.n ? String(json.n) : "",
fingerprint: json.fp ? String(json.fp) : ""
};
}

View File

@ -1,22 +0,0 @@
#!/usr/bin/env node
import { encodeConnectionCode } from "./code.mjs";
function arg(name, fallback) {
const i = process.argv.indexOf("--" + name);
if (i >= 0 && i + 1 < process.argv.length) return process.argv[i + 1];
return fallback;
}
const host = arg("host");
const port = arg("port");
const token = arg("token");
const name = arg("name");
const scheme = arg("scheme");
const fingerprint = arg("fp");
if (!host || !port || !token) {
process.stderr.write("Usage: node src/gen-code.mjs --host <h> --port <p> --token <t> [--name <n>] [--scheme https] [--fp <sha256>]\n");
process.exit(2);
}
process.stdout.write(encodeConnectionCode({ host, port, token, name, scheme, fingerprint }) + "\n");

View File

@ -1,63 +0,0 @@
import http from "node:http";
import https from "node:https";
function normalizeFp(fp) {
return String(fp || "").replace(/:/g, "").toLowerCase();
}
export function debugGet(target, path, { timeoutMs = 20000 } = {}) {
const scheme = target.scheme === "https" ? "https" : "http";
const lib = scheme === "https" ? https : http;
const rel = path.startsWith("/") ? path : "/" + path;
const url = new URL(rel, `${scheme}://${target.host}:${target.port}`);
const pinning = scheme === "https" && !!target.fingerprint;
return new Promise((resolve, reject) => {
const options = {
method: "GET",
headers: {
Authorization: `Bearer ${target.token}`,
Accept: "application/json"
},
timeout: timeoutMs
};
if (scheme === "https") {
options.rejectUnauthorized = !target.fingerprint;
}
const req = lib.request(url, options, (res) => {
let data = "";
res.setEncoding("utf8");
res.on("data", (chunk) => {
data += chunk;
});
res.on("end", () => {
resolve({ status: res.statusCode || 0, body: data, headers: res.headers });
});
});
req.on("timeout", () => {
req.destroy(new Error(`Zeitueberschreitung nach ${timeoutMs}ms`));
});
req.on("error", (err) => {
reject(err);
});
if (pinning) {
req.on("socket", (socket) => {
socket.on("secureConnect", () => {
const cert = typeof socket.getPeerCertificate === "function" ? socket.getPeerCertificate() : null;
const got = normalizeFp(cert && cert.fingerprint256);
const want = normalizeFp(target.fingerprint);
if (!got || got !== want) {
req.destroy(new Error(`TLS-Fingerprint stimmt nicht (erwartet ${want || "?"}, erhalten ${got || "?"})`));
return;
}
req.end();
});
});
} else {
req.end();
}
});
}

View File

@ -1,175 +0,0 @@
import http from "node:http";
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { encodeConnectionCode } from "../src/code.mjs";
const __dirname = dirname(fileURLToPath(import.meta.url));
const BRIDGE = join(__dirname, "..", "src", "bridge.mjs");
const TOKEN = "test-token-abc123";
const failures = [];
function check(name, cond, detail) {
if (cond) {
process.stdout.write(` PASS ${name}\n`);
} else {
failures.push(name);
process.stdout.write(` FAIL ${name}${detail ? " — " + detail : ""}\n`);
}
}
function startFakeServer() {
return new Promise((resolve) => {
const server = http.createServer((req, res) => {
const url = new URL(req.url, "http://localhost");
const auth = req.headers.authorization || "";
const tokenOk = auth === `Bearer ${TOKEN}` || url.searchParams.get("token") === TOKEN;
if (!tokenOk) {
res.writeHead(401, { "content-type": "application/json" });
res.end(JSON.stringify({ error: "Unauthorized" }));
return;
}
const p = url.pathname;
const q = Object.fromEntries(url.searchParams.entries());
const send = (obj) => {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify(obj));
};
if (p === "/health") return send({ status: "ok", appVersion: "1.7.222", uptime: 42 });
if (p === "/diagnostics") return send({ meta: { appVersion: "1.7.222" }, status: { active: 1 }, query: q });
if (p === "/errors") return send({ errors: [{ level: "ERROR", message: "boom" }], query: q });
if (p === "/logs/main") return send({ lines: ["line1", "line2"], count: 2, query: q });
if (p === "/status") return send({ active: 1, queued: 3 });
if (p === "/items") return send({ items: [], query: q });
if (p === "/accounts") return send({ accounts: [{ name: "acc1", premium: true }] });
if (p === "/meta") return send({ appVersion: "1.7.222", endpoints: ["/health", "/diagnostics"] });
res.writeHead(404, { "content-type": "application/json" });
res.end(JSON.stringify({ error: "not found", path: p }));
});
server.listen(0, "127.0.0.1", () => resolve(server));
});
}
function startBridge() {
const child = spawn(process.execPath, [BRIDGE], { stdio: ["pipe", "pipe", "pipe"] });
child.stderr.on("data", (d) => process.stderr.write(`[bridge] ${d}`));
const pending = new Map();
let buf = "";
child.stdout.on("data", (chunk) => {
buf += chunk.toString("utf8");
let idx;
while ((idx = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, idx).trim();
buf = buf.slice(idx + 1);
if (!line) continue;
let msg;
try {
msg = JSON.parse(line);
} catch {
continue;
}
if (msg.id !== undefined && pending.has(msg.id)) {
pending.get(msg.id)(msg);
pending.delete(msg.id);
}
}
});
let nextId = 1;
function rpc(method, params) {
const id = nextId++;
return new Promise((resolve, reject) => {
pending.set(id, resolve);
child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
setTimeout(() => {
if (pending.has(id)) {
pending.delete(id);
reject(new Error(`RPC timeout: ${method}`));
}
}, 15000);
});
}
function notify(method, params) {
child.stdin.write(JSON.stringify({ jsonrpc: "2.0", method, params }) + "\n");
}
return { child, rpc, notify };
}
function textOf(callResult) {
const c = callResult && callResult.result && callResult.result.content;
if (!Array.isArray(c)) return "";
return c.map((x) => x.text || "").join("\n");
}
async function run() {
const fake = await startFakeServer();
const port = fake.address().port;
const code = encodeConnectionCode({ host: "127.0.0.1", port, token: TOKEN, name: "testserver" });
const badCode = encodeConnectionCode({ host: "127.0.0.1", port, token: "WRONG", name: "testserver" });
const bridge = startBridge();
try {
const init = await bridge.rpc("initialize", {
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: { name: "harness", version: "1.0.0" }
});
check("initialize handshake", !!(init.result && init.result.serverInfo), JSON.stringify(init.error || {}));
check("server name reported", init.result && init.result.serverInfo && init.result.serverInfo.name === "rd-diagnostics-mcp");
bridge.notify("notifications/initialized", {});
const tools = await bridge.rpc("tools/list", {});
const names = (tools.result && tools.result.tools || []).map((t) => t.name);
check("tools/list returns tools", names.length >= 10, `got ${names.length}`);
for (const expected of ["rd_ping", "rd_diagnostics", "rd_errors", "rd_logs", "rd_get", "rd_servers"]) {
check(`tool present: ${expected}`, names.includes(expected));
}
const ping = await bridge.rpc("tools/call", { name: "rd_ping", arguments: { code } });
const pingText = textOf(ping);
check("rd_ping reaches server", /HTTP 200/.test(pingText) && /"status": "ok"/.test(pingText), pingText.slice(0, 200));
check("rd_ping shows server label", /testserver \(127\.0\.0\.1:/.test(pingText));
const diag = await bridge.rpc("tools/call", { name: "rd_diagnostics", arguments: { code, lines: 50, grep: "err" } });
const diagText = textOf(diag);
check("rd_diagnostics returns aggregate", /"appVersion": "1\.7\.222"/.test(diagText));
check("rd_diagnostics passes query params", /"lines": "50"/.test(diagText) && /"grep": "err"/.test(diagText), diagText.slice(0, 300));
const logs = await bridge.rpc("tools/call", { name: "rd_logs", arguments: { code, name: "main", lines: 5 } });
const logsText = textOf(logs);
check("rd_logs maps name→path + lines", /logs\/main\?lines=5/.test(logsText) && /"count": 2/.test(logsText), logsText.slice(0, 200));
const errs = await bridge.rpc("tools/call", { name: "rd_errors", arguments: { code, level: "ERROR" } });
check("rd_errors returns ring", /"message": "boom"/.test(textOf(errs)));
const raw = await bridge.rpc("tools/call", { name: "rd_get", arguments: { code, path: "/meta" } });
check("rd_get escape hatch hits arbitrary path", /"endpoints"/.test(textOf(raw)));
const unauthorized = await bridge.rpc("tools/call", { name: "rd_ping", arguments: { code: badCode } });
check("bad token → HTTP 401 + isError", /HTTP 401/.test(textOf(unauthorized)) && unauthorized.result.isError === true);
const noCode = await bridge.rpc("tools/call", { name: "rd_ping", arguments: {} });
check("missing code → graceful isError", noCode.result && noCode.result.isError === true && /Kein Verbindungscode/.test(textOf(noCode)));
const unreachable = await bridge.rpc("tools/call", {
name: "rd_ping",
arguments: { code: encodeConnectionCode({ host: "127.0.0.1", port: 1, token: TOKEN }) }
});
check("unreachable → isError + hint", unreachable.result.isError === true && /nicht erreichbar|abgebrochen|FEHLER/.test(textOf(unreachable)), textOf(unreachable).slice(0, 160));
} finally {
bridge.child.kill();
fake.close();
}
process.stdout.write("\n");
if (failures.length) {
process.stdout.write(`RESULT: ${failures.length} FAIL\n`);
process.exit(1);
}
process.stdout.write("RESULT: ALL PASS\n");
process.exit(0);
}
run().catch((err) => {
process.stderr.write(`harness error: ${err && err.stack ? err.stack : err}\n`);
process.exit(1);
});