Compare commits

..

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

56 changed files with 727 additions and 7728 deletions

View File

@ -1,6 +1,6 @@
{
"name": "real-debrid-downloader",
"version": "1.7.232",
"version": "1.7.204",
"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,16 +36,13 @@ 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";
import { runStartupHealthCheck } from "./startup-health-check";
import { getDebugSetupCheck } from "./debug-setup";
import { buildLinkExportSelection, serializeLinkExportText } from "./link-export";
@ -100,7 +94,6 @@ export class AppController {
initItemLogs(this.storagePaths.baseDir);
initAuditLog(this.storagePaths.baseDir);
initAccountRotationLog(this.storagePaths.baseDir);
initConversionLog(this.storagePaths.baseDir);
initRenameLog(this.storagePaths.baseDir);
let desktopDir: string | null = null;
try {
@ -112,8 +105,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 +119,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 +171,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 +242,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 +288,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);
}
@ -565,14 +457,16 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
}
public async installUpdate(onProgress?: (progress: UpdateInstallProgress) => void): Promise<UpdateInstallResult> {
if (this.manager.isSessionRunning()) {
this.manager.stop({ parkForRestart: true });
}
this.manager.persistNowSync();
const cacheAgeMs = Date.now() - this.lastUpdateCheckAt;
const cached = this.lastUpdateCheck && !this.lastUpdateCheck.error && cacheAgeMs <= 10 * 60 * 1000
? this.lastUpdateCheck
: undefined;
const result = await runInstallWithResume(
this.manager,
() => installLatestUpdate(this.settings.updateRepo, cached, onProgress)
);
const result = await installLatestUpdate(this.settings.updateRepo, cached, onProgress);
if (result.started) {
this.lastUpdateCheck = null;
this.lastUpdateCheckAt = 0;
@ -748,22 +642,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,
@ -774,14 +659,14 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
return encryptBackup(JSON.stringify(payloadObj));
}
public async exportSupportBundle(): Promise<{ buffer: Buffer; defaultFileName: string }> {
public exportSupportBundle(): { buffer: Buffer; defaultFileName: string } {
this.audit("INFO", "Support-Bundle exportiert");
logTraceEvent("INFO", "support", "Support-Bundle erstellt", {
packageCount: Object.keys(this.manager.getSnapshot().session.packages).length,
itemCount: Object.keys(this.manager.getSnapshot().session.items).length
});
return {
buffer: await buildSupportBundle(this.manager, this.storagePaths.baseDir, { hostDiagnosticsMode: "cached" }),
buffer: buildSupportBundle(this.manager, this.storagePaths.baseDir, { hostDiagnosticsMode: "cached" }),
defaultFileName: getSupportBundleDefaultFileName()
};
}
@ -837,7 +722,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 +758,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");
@ -905,7 +787,6 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
}
stopDebugServer();
abortActiveUpdateDownload();
cancelPendingAsyncSaves();
this.manager.prepareForShutdown();
this.megaWebFallback.dispose();
this.realDebridWebFallback.dispose();
@ -919,7 +800,6 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
this.audit("INFO", "App beendet");
shutdownTraceLog();
shutdownAccountRotationLog();
shutdownConversionLog();
shutdownAuditLog();
if (this.settings.historyRetentionMode === "session") {
clearHistory(this.storagePaths);

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

@ -1,190 +0,0 @@
import fs from "node:fs";
import path from "node:path";
import { AsyncLocalStorage } from "node:async_hooks";
import { logTimestamp } from "./log-timestamp";
export interface ConversionPhase {
atMs: number;
phase: string;
provider?: string;
account?: string;
tokenState?: string;
queueWaitMs?: number;
workMs?: number;
outcome?: string;
detail?: string;
}
export interface ConversionTrace {
startedAt: number;
itemId: string;
itemName: string;
link: string;
providerOrder: string;
notes: Record<string, string | number>;
phases: ConversionPhase[];
}
const conversionContext = new AsyncLocalStorage<ConversionTrace>();
function shortLink(link: string): string {
const raw = String(link || "").trim();
return raw.length > 90 ? `${raw.slice(0, 90)}` : raw;
}
export function traceConversionPhase(phase: Omit<ConversionPhase, "atMs">): void {
const trace = conversionContext.getStore();
if (!trace) {
return;
}
trace.phases.push({ ...phase, atMs: Date.now() - trace.startedAt });
}
export function traceConversionNote(key: string, value: string | number): void {
const trace = conversionContext.getStore();
if (!trace) {
return;
}
trace.notes[key] = value;
}
export function hasActiveConversionTrace(): boolean {
return conversionContext.getStore() !== undefined;
}
export function formatConversionBlock(
trace: ConversionTrace,
outcome: string,
detail: string,
totalMs: number
): string {
const noteParts = Object.entries(trace.notes)
.map(([key, value]) => `${key}=${value}`)
.join(" ");
const header = `${logTimestamp()} [CONV] item=${trace.itemName || trace.itemId} | order=${trace.providerOrder || "?"}`
+ ` | result=${outcome}${detail ? ` (${detail})` : ""} | total=${totalMs}ms${noteParts ? ` | ${noteParts}` : ""}`
+ ` | link=${shortLink(trace.link)}`;
const lines = trace.phases.map((p) => {
const parts: string[] = [];
if (p.provider) parts.push(`provider=${p.provider}`);
if (p.account) parts.push(`account=${p.account}`);
if (p.tokenState) parts.push(`token=${p.tokenState}`);
if (typeof p.queueWaitMs === "number") parts.push(`queueWaitMs=${p.queueWaitMs}`);
if (typeof p.workMs === "number") parts.push(`workMs=${p.workMs}`);
if (p.outcome) parts.push(`outcome=${p.outcome}`);
if (p.detail) parts.push(`detail=${String(p.detail).replace(/\r?\n/g, "\\n")}`);
return ` +${p.atMs}ms ${p.phase}${parts.length ? ` | ${parts.join(" | ")}` : ""}`;
});
return [header, ...lines].join("\n");
}
const CONVERSION_LOG_MAX_FILE_BYTES = Number(process.env.RD_CONVERSION_LOG_MAX_BYTES || 5 * 1024 * 1024);
const CONVERSION_LOG_RETENTION_DAYS = Number(process.env.RD_CONVERSION_LOG_RETENTION_DAYS || 14);
let conversionLogPath: string | null = null;
function rotateIfNeeded(filePath: string): void {
try {
const stat = fs.statSync(filePath);
if (stat.size < CONVERSION_LOG_MAX_FILE_BYTES) {
return;
}
const backup = `${filePath}.old`;
try {
fs.rmSync(backup, { force: true });
} catch {
}
fs.renameSync(filePath, backup);
} catch {
}
}
function cleanupOldBackup(filePath: string): void {
const backup = `${filePath}.old`;
try {
const stat = fs.statSync(backup);
const cutoff = Date.now() - CONVERSION_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;
if (stat.mtimeMs < cutoff) {
fs.rmSync(backup, { force: true });
}
} catch {
}
}
export function initConversionLog(baseDir: string): void {
conversionLogPath = path.join(baseDir, "conversion.log");
try {
fs.mkdirSync(path.dirname(conversionLogPath), { recursive: true });
cleanupOldBackup(conversionLogPath);
if (!fs.existsSync(conversionLogPath)) {
fs.writeFileSync(conversionLogPath, "", "utf8");
}
rotateIfNeeded(conversionLogPath);
if (!fs.existsSync(conversionLogPath)) {
fs.writeFileSync(conversionLogPath, "", "utf8");
}
fs.appendFileSync(conversionLogPath, `=== Conversion Log Start: ${logTimestamp()} ===\n`, "utf8");
} catch {
conversionLogPath = null;
}
}
export function getConversionLogPath(): string | null {
if (!conversionLogPath) {
return null;
}
return fs.existsSync(conversionLogPath) ? conversionLogPath : null;
}
export function shutdownConversionLog(): void {
if (!conversionLogPath) {
return;
}
try {
fs.appendFileSync(conversionLogPath, `=== Conversion Log Ende: ${logTimestamp()} ===\n`, "utf8");
} catch {
}
conversionLogPath = null;
}
function writeConversionBlock(block: string): void {
if (!conversionLogPath) {
return;
}
try {
rotateIfNeeded(conversionLogPath);
if (!fs.existsSync(conversionLogPath)) {
fs.writeFileSync(conversionLogPath, "", "utf8");
}
fs.appendFileSync(conversionLogPath, `${block}\n`, "utf8");
} catch {
}
}
export async function runWithConversionTrace<T>(
meta: { itemId: string; itemName: string; link: string; providerOrder: string },
fn: () => Promise<T>
): Promise<T> {
const trace: ConversionTrace = {
startedAt: Date.now(),
itemId: meta.itemId,
itemName: meta.itemName,
link: meta.link,
providerOrder: meta.providerOrder,
notes: {},
phases: []
};
let outcome = "OK";
let detail = "";
try {
const result = await conversionContext.run(trace, fn);
return result;
} catch (error) {
outcome = "FAIL";
detail = String((error as { message?: string })?.message || error || "").replace(/^Error:\s*/i, "").slice(0, 160);
throw error;
} finally {
const totalMs = Date.now() - trace.startedAt;
writeConversionBlock(formatConversionBlock(trace, outcome, detail, totalMs));
}
}

View File

@ -2,11 +2,9 @@ import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
import { parseMegaDebridAccounts, type MegaDebridAccountEntry } from "../shared/mega-debrid-accounts";
import { AllDebridHostInfo, AppSettings, DebridFallbackProvider, DebridLinkHostLimitInfo, DebridProvider } from "../shared/types";
import { isDebridLinkApiKeyDailyLimitReached, isMegaDebridAccountDisabled, isMegaDebridAccountDailyLimitReached, isProviderDailyLimitReached } from "../shared/provider-daily-limits";
import { isMegaDebridResolveFailure, germanMegaDebridResolveReason } from "../shared/mega-debrid-errors";
import { APP_VERSION, REQUEST_RETRIES } from "./constants";
import { logger } from "./logger";
import { logAccountRotation } from "./account-rotation-log";
import { traceConversionPhase } from "./conversion-trace";
import { RealDebridClient, UnrestrictedLink } from "./realdebrid";
import { MEGA_DEBRID_NO_SERVER_RE } from "./mega-web-fallback";
import { isMegaFileUrl, resolveMegaFilename } from "./mega-public-api";
@ -123,11 +121,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 +281,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,24 +294,25 @@ 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;
// Mega-Web cacht Sessions pro Account (~20 Min). Wuerde jede Link-Aufloesung den
// Account wechseln (reines Round-Robin), zahlte JEDER Link einen kalten Login in
// die serielle Single-Flight-Queue → minutenlanger Vorlauf. Stattdessen bleibt die
// Rotation "klebrig": ein funktionierender Account wird fuer einen Schwung Links
// behalten (warm/schnell), erst danach (oder bei Limit/Cooldown/Fehler) auf den
// naechsten gewechselt. So bleibt es schnell UND ueber die Zeit kommen alle dran.
export const MEGA_DEBRID_STICKY_LINKS = 25;
// Wie viele Umwandlungen pro Account (key `${id}:${mode}`) GERADE laufen/anstehen. Gleichzeitige
// Aufloesungen waehlen den am WENIGSTEN belegten Account → verschiedene Accounts wandeln parallel
// um (je eigene Queue in MegaWebFallback), und auch bei mehr gleichzeitigen Links als Accounts
// verteilt es sich gleichmaessig statt sich hinter einem Account zu stauen. Tiefe (nicht nur
// belegt/frei), damit ein frueh fertiger Erst-Job einen noch laufenden Folge-Job nicht "frei" meldet.
const megaDebridInFlight = new Map<string, number>();
const MEGA_DEBRID_SLOW_ACCOUNT_FLOOR_MS = 6000;
const MEGA_DEBRID_SLOW_ACCOUNT_FACTOR = 3;
const megaDebridAccountLatencyEma = new Map<string, number>();
function recordMegaDebridUnrestrictLatency(cooldownKey: string, elapsedMs: number): void {
if (!Number.isFinite(elapsedMs) || elapsedMs < 0) {
return;
}
const prev = megaDebridAccountLatencyEma.get(cooldownKey);
megaDebridAccountLatencyEma.set(cooldownKey, prev === undefined ? elapsedMs : prev * 0.7 + elapsedMs * 0.3);
}
export function primeMegaDebridLatencyForTests(cooldownKey: string, emaMs: number): void {
megaDebridAccountLatencyEma.set(cooldownKey, emaMs);
}
export function recordMegaDebridEmptyResponseStreak(accountId: string): number {
const streak = (megaDebridEmptyResponseStreaks.get(accountId) || 0) + 1;
@ -335,27 +328,7 @@ export function resetMegaDebridRuntimeStateForTests(): void {
megaDebridAccountCooldowns.clear();
megaDebridEmptyResponseStreaks.clear();
megaDebridRotationCursor = 0;
megaDebridStickyCount = 0;
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);
megaDebridAccountLatencyEma.clear();
}
export function pruneExpiredMegaDebridRuntimeState(now = Date.now()): number {
@ -374,26 +347,14 @@ export function primeMegaDebridRuntimeCooldownForTests(accountId: string, cooldo
setMegaDebridAccountCooldownState(accountId, cooldownMs, message, "temporary");
}
export function primeMegaDebridUntilRestartForTests(accountId: string, message = "Tageslimit (Test) — bis zum Tagesreset gesperrt"): void {
export function primeMegaDebridUntilRestartForTests(accountId: string, message = "Tageslimit (Test) — bis Neustart gesperrt"): void {
setMegaDebridAccountCooldownState(accountId, 0, message, "quota", true);
}
export function classifyMegaDebridAccountFailureForTests(
error: unknown
): { fatal: boolean; cooldownMs: number; message: string; category: MegaDebridCooldownCategory; limitSignal?: boolean } {
return MegaDebridClient.classifyAccountFailure(error);
}
function clearMegaDebridAccountCooldownState(accountId: string): void {
megaDebridAccountCooldowns.delete(accountId);
}
function megaDebridDailyParkExpiry(now: number): number {
const midnight = new Date(now);
midnight.setHours(24, 0, 0, 0);
return Math.max(midnight.getTime(), now + MEGA_DEBRID_ACCOUNT_COOLDOWN_MS);
}
function setMegaDebridAccountCooldownState(
accountId: string,
cooldownMs: number,
@ -403,7 +364,7 @@ function setMegaDebridAccountCooldownState(
): void {
if (untilRestart) {
megaDebridAccountCooldowns.set(accountId, {
until: megaDebridDailyParkExpiry(Date.now()),
until: Number.MAX_SAFE_INTEGER,
message,
category,
untilRestart: true
@ -442,106 +403,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> = {
@ -1446,13 +1307,6 @@ function toProviderOrder(primary: DebridProvider, secondary: DebridFallbackProvi
return uniqueProviderOrder(order);
}
export function leadProviderChainWith(order: readonly DebridProvider[], preferred: DebridProvider | null | undefined): DebridProvider[] {
if (!preferred || !order.includes(preferred)) {
return [...order];
}
return [preferred, ...order.filter((provider) => provider !== preferred)];
}
function isRapidgatorLink(link: string): boolean {
try {
const hostname = new URL(link).hostname.toLowerCase();
@ -1887,13 +1741,11 @@ class MegaDebridClient {
const key = this.cacheKey;
const cached = MegaDebridClient.cachedApiTokens.get(key);
if (cached && cached.token && Date.now() - cached.at < 20 * 60 * 1000) {
traceConversionPhase({ phase: "token", provider: "megadebrid-api", tokenState: `cached(${Math.floor((Date.now() - cached.at) / 1000)}s)`, outcome: "ok" });
return cached.token;
}
const pending = MegaDebridClient.pendingConnects.get(key);
if (pending) {
traceConversionPhase({ phase: "token", provider: "megadebrid-api", tokenState: "pending-join", outcome: "ok" });
return pending;
}
@ -1909,7 +1761,6 @@ class MegaDebridClient {
}
private async doConnectApi(signal?: AbortSignal): Promise<string | null> {
const connectStartedAt = Date.now();
const url = `${MEGA_DEBRID_API_BASE}?action=connectUser&login=${encodeURIComponent(this.login)}&password=${encodeURIComponent(this.password)}`;
const response = await fetch(url, {
headers: { "User-Agent": DEBRID_USER_AGENT },
@ -1920,7 +1771,6 @@ class MegaDebridClient {
if (response.status === 401 || response.status === 403) {
this.clearTokenCache();
}
traceConversionPhase({ phase: "token", provider: "megadebrid-api", tokenState: "fresh-login", workMs: Date.now() - connectStartedAt, outcome: "error", detail: `HTTP ${response.status}` });
return null;
}
const payload = parseJsonSafe(text);
@ -1928,16 +1778,13 @@ class MegaDebridClient {
if (payload && String(payload.response_code || "").toLowerCase().includes("token")) {
this.clearTokenCache();
}
traceConversionPhase({ phase: "token", provider: "megadebrid-api", tokenState: "fresh-login", workMs: Date.now() - connectStartedAt, outcome: "error", detail: `response_code=${payload?.response_code || "?"} ${String(payload?.response_text || "").slice(0, 80)}`.trim() });
return null;
}
const token = String(payload.token || "").trim();
if (!token) {
traceConversionPhase({ phase: "token", provider: "megadebrid-api", tokenState: "fresh-login", workMs: Date.now() - connectStartedAt, outcome: "error", detail: "leeres Token" });
return null;
}
MegaDebridClient.cachedApiTokens.set(this.cacheKey, { token, at: Date.now() });
traceConversionPhase({ phase: "token", provider: "megadebrid-api", tokenState: "fresh-login", workMs: Date.now() - connectStartedAt, outcome: "ok" });
return token;
}
@ -1947,7 +1794,6 @@ class MegaDebridClient {
return null;
}
const getLinkStartedAt = Date.now();
const url = `${MEGA_DEBRID_API_BASE}?action=getLink&token=${encodeURIComponent(token)}`;
const response = await fetch(url, {
method: "POST",
@ -1963,17 +1809,14 @@ class MegaDebridClient {
if (response.status === 401 || response.status === 403) {
this.clearTokenCache();
}
traceConversionPhase({ phase: "api-getlink", provider: "megadebrid-api", workMs: Date.now() - getLinkStartedAt, outcome: "error", detail: `HTTP ${response.status}` });
return null;
}
const payload = parseJsonSafe(text);
if (!payload || payload.response_code !== "ok") {
const tokenInvalidated = Boolean(payload && String(payload.response_code || "").includes("token"));
if (tokenInvalidated) {
if (payload && String(payload.response_code || "").includes("token")) {
this.clearTokenCache();
}
const errorText = String(payload?.response_text || "").trim();
traceConversionPhase({ phase: "api-getlink", provider: "megadebrid-api", workMs: Date.now() - getLinkStartedAt, outcome: "error", detail: `response_code=${payload?.response_code || "?"}${tokenInvalidated ? " (token-cache-geleert)" : ""} ${errorText}`.trim() });
if (errorText) {
throw new Error(`Mega-Debrid API: ${errorText}`);
}
@ -1982,10 +1825,8 @@ class MegaDebridClient {
const directUrl = String(payload.debridLink || "").trim();
if (!directUrl) {
traceConversionPhase({ phase: "api-getlink", provider: "megadebrid-api", workMs: Date.now() - getLinkStartedAt, outcome: "error", detail: "kein debridLink" });
return null;
}
traceConversionPhase({ phase: "api-getlink", provider: "megadebrid-api", workMs: Date.now() - getLinkStartedAt, outcome: "ok" });
const fileName = String(payload.filename || "").trim() || filenameFromUrl(directUrl) || filenameFromUrl(link);
return {
directUrl,
@ -2010,9 +1851,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) {
@ -2044,7 +1882,7 @@ class MegaDebridClient {
logger.info(`Mega-Debrid (API) unrestrict OK: ${apiResult.fileName}`);
return apiResult;
}
throw new Error("Mega-Debrid API: Linkgenerierung lieferte kein Ergebnis");
throw new Error("Mega-Debrid API: Login oder Unrestrict fehlgeschlagen");
} catch (error) {
const errorText = compactErrorText(error);
if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) {
@ -2087,28 +1925,50 @@ class MegaDebridClient {
const providerName = `Mega-Debrid ${mode === "api" ? "API" : "Web"}`;
const linkShort = String(link || "").slice(0, 80);
// Klebrige Rotation: gestartet wird beim Cursor (zuletzt erfolgreich genutzter,
// also warmer Account), danach der Reihe nach als Failover. Alle Skip-/Cooldown-
// Checks bleiben. Der Cursor wird erst im Erfolgszweig weitergesetzt — nach einem
// Schwung erfolgreicher Umwandlungen (MEGA_DEBRID_STICKY_LINKS) — sodass aufeinander
// folgende Links auf demselben warmen Account laufen statt jeweils neu einzuloggen.
// Round-Robin statt First-Wins: ohne den Cursor startete jede Link-Aufloesung
// bei Account 1 und endete beim ersten brauchbaren — ein spaeterer Account kam
// nur dran, wenn ALLE davor am Limit/Cooldown waren. Live-Folge: Account 1-2
// liefen staendig ins Tageslimit, Account 3 trug den Rest allein und Account 4
// wurde nie auch nur angeschaut. Der Cursor laesst jede Aufloesung beim Account
// NACH dem zuletzt getesteten starten, alle Skip-/Cooldown-Checks bleiben.
const startOffset = ((megaDebridRotationCursor % accounts.length) + accounts.length) % accounts.length;
const cursorOrder: { account: MegaDebridAccountEntry; idx: number }[] = [];
const rotationOrder: { account: MegaDebridAccountEntry; idx: number }[] = [];
for (let step = 0; step < accounts.length; step += 1) {
const idx = (startOffset + step) % accounts.length;
cursorOrder.push({ account: accounts[idx], idx });
rotationOrder.push({ account: accounts[idx], idx });
}
// Parallel ueber mehrere Accounts: nach AKTUELLER Auslastung (in-flight-Tiefe) sortieren —
// am wenigsten belegter Account zuerst, cursorOrder als stabiler Gleichstand-Tiebreak. So
// verteilen sich auch MEHR gleichzeitige Aufloesungen als Accounts gleichmaessig (statt sich
// alle hinter dem Cursor-Account zu stauen). Nichts in-flight (sequenziell) => alle Tiefe 0 =>
// Reihenfolge == cursorOrder => bleibt klebrig beim warmen Account.
const inFlightDepth = (entry: { account: MegaDebridAccountEntry }): number =>
megaDebridInFlight.get(`${entry.account.id}:${mode}`) ?? 0;
const orderedEntries = cursorOrder
.map((entry, position) => ({ entry, position }))
.sort((a, b) => (inFlightDepth(a.entry) - inFlightDepth(b.entry)) || (a.position - b.position))
.map((wrapped) => wrapped.entry);
// Latenz-Schutz fuer die Mega-Web-Single-Flight-Queue: dort laufen ALLE
// Umwandlungen seriell, ein nachweislich langsamer Account bremst also
// jeden nachfolgenden Link aus. Accounts mit deutlich erhoehtem Erfolgs-EMA
// wandern ans Ende der Reihenfolge (= Failover-Reserve) statt in der
// gleichmaessigen Rotation mitzulaufen; erholt sich ihr EMA, rotieren sie
// automatisch wieder mit. Ungemessene Accounts gelten als gesund.
let bestEma = Infinity;
let anyUnmeasuredUsable = false;
for (const entry of rotationOrder) {
if (isMegaDebridAccountDisabled(settings, entry.account.id)) continue;
if (isMegaDebridAccountDailyLimitReached(settings, entry.account.id)) continue;
if (getMegaDebridAccountCooldownState(`${entry.account.id}:${mode}`)) continue;
const ema = megaDebridAccountLatencyEma.get(`${entry.account.id}:${mode}`);
if (ema === undefined) {
anyUnmeasuredUsable = true;
continue;
}
if (ema < bestEma) bestEma = ema;
}
const slowThreshold = Math.max(
MEGA_DEBRID_SLOW_ACCOUNT_FLOOR_MS,
anyUnmeasuredUsable ? 0 : (Number.isFinite(bestEma) ? MEGA_DEBRID_SLOW_ACCOUNT_FACTOR * bestEma : Infinity)
);
const isSlowEntry = (entry: { account: MegaDebridAccountEntry }): boolean => {
const ema = megaDebridAccountLatencyEma.get(`${entry.account.id}:${mode}`);
return ema !== undefined && ema > slowThreshold;
};
const orderedEntries = [
...rotationOrder.filter((entry) => !isSlowEntry(entry)),
...rotationOrder.filter((entry) => isSlowEntry(entry))
];
for (let orderPos = 0; orderPos < orderedEntries.length; orderPos += 1) {
const entry = orderedEntries[orderPos];
@ -2134,7 +1994,7 @@ class MegaDebridClient {
? "Neustart"
: new Date(accountCooldownState.until).toLocaleTimeString();
const reasonText = accountCooldownState.untilRestart
? "Tageslimit erreicht — bis zum Tagesreset gesperrt"
? "Tageslimit erreicht — bis Neustart gesperrt"
: `Cooldown bis ${untilStr}`;
logger.info(`Mega-Debrid${accountLabel}: uebersprungen (${reasonText}), pruefe naechsten Account`);
logAccountRotation("INFO", providerName, rotationLabel, "SKIP_COOLDOWN", {
@ -2151,28 +2011,24 @@ class MegaDebridClient {
continue;
}
const emaForLog = megaDebridAccountLatencyEma.get(cooldownKey);
logger.info(`Mega-Debrid${accountLabel}: TESTE Account fuer Link-Generierung...`);
logAccountRotation("INFO", providerName, rotationLabel, "TEST", {
link: linkShort
link: linkShort,
emaMs: emaForLog !== undefined ? Math.round(emaForLog) : undefined,
slow: isSlowEntry(entry) ? true : undefined
});
const testStartedAt = Date.now();
usableAccountSeen = true;
megaDebridInFlight.set(cooldownKey, (megaDebridInFlight.get(cooldownKey) ?? 0) + 1);
megaDebridRotationCursor = idx + 1;
try {
const client = new MegaDebridClient(account.login, account.password, mode, allowApiFallback, megaWebUnrestrict);
const result = await client.unrestrictLink(link, signal);
clearMegaDebridAccountCooldownState(cooldownKey);
clearMegaDebridEmptyResponseStreak(cooldownKey);
const elapsedMs = Date.now() - testStartedAt;
traceConversionPhase({ phase: "mega-account", provider: providerName.includes("API") ? "megadebrid-api" : "megadebrid-web", account: rotationLabel, workMs: elapsedMs, outcome: "ok" });
megaDebridStickyCount += 1;
if (megaDebridStickyCount >= MEGA_DEBRID_STICKY_LINKS) {
megaDebridRotationCursor = idx + 1;
megaDebridStickyCount = 0;
} else {
megaDebridRotationCursor = idx;
}
recordMegaDebridUnrestrictLatency(cooldownKey, elapsedMs);
logger.info(`Mega-Debrid${accountLabel}: Unrestrict OK nach ${elapsedMs}ms -> ${result.fileName || "?"}`);
logAccountRotation("INFO", providerName, rotationLabel, "OK", {
elapsedMs,
@ -2188,64 +2044,27 @@ 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({
phase: "mega-account",
provider: providerName.includes("API") ? "megadebrid-api" : "megadebrid-web",
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` : ""}`
});
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);
traceConversionPhase({
phase: "mega-account",
provider: providerName.includes("API") ? "megadebrid-api" : "megadebrid-web",
account: rotationLabel,
workMs: Date.now() - testStartedAt,
outcome: failure.fatal ? "fatal" : "failed",
detail: `${failure.message}${failure.cooldownMs > 0 ? ` cd=${Math.ceil(failure.cooldownMs / 1000)}s` : ""}`
});
failures.push(`Mega-Debrid${accountLabel}: ${failure.message}`);
let parkUntilRestart = false;
@ -2254,7 +2073,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 Neustart gesperrt`;
}
} else {
clearMegaDebridEmptyResponseStreak(cooldownKey);
@ -2277,7 +2096,7 @@ class MegaDebridClient {
throw new Error(`Mega-Debrid${accountLabel}: ${failure.message}`);
}
const cooldownInfo = parkUntilRestart
? ", bis zum Tagesreset gesperrt"
? ", bis Neustart gesperrt"
: failure.cooldownMs > 0
? `, Cooldown ${Math.ceil(failure.cooldownMs / 1000)}s`
: "";
@ -2298,13 +2117,6 @@ class MegaDebridClient {
next: nextLabel,
link: linkShort
});
} finally {
const remainingInFlight = (megaDebridInFlight.get(cooldownKey) ?? 1) - 1;
if (remainingInFlight <= 0) {
megaDebridInFlight.delete(cooldownKey);
} else {
megaDebridInFlight.set(cooldownKey, remainingInFlight);
}
}
}
@ -2314,15 +2126,14 @@ class MegaDebridClient {
throw new Error(`mega_debrid_cooldown:${retryMs}:${cooldownFailures.join(" | ")}`);
}
if (parkedUntilRestartSeen) {
const resetParkMs = Math.max(1000, megaDebridDailyParkExpiry(Date.now()) - Date.now() + 1000);
throw new Error(`mega_debrid_reset_park:${resetParkMs}:Mega-Debrid: Alle Accounts am Tageslimit (bis zum Tagesreset gesperrt)${cooldownFailures.length > 0 ? ` | ${cooldownFailures.join(" | ")}` : ""}`);
throw new Error(`Mega-Debrid: Alle Accounts am Tageslimit (bis Neustart gesperrt)${cooldownFailures.length > 0 ? ` | ${cooldownFailures.join(" | ")}` : ""}`);
}
throw new Error("Mega-Debrid: Kein aktiver Account verfuegbar");
}
throw new Error(failures.join(" | ") || "Mega-Debrid: Kein aktiver Account verfuegbar");
}
static classifyAccountFailure(
private static classifyAccountFailure(
error: unknown
): { fatal: boolean; cooldownMs: number; message: string; category: MegaDebridCooldownCategory; limitSignal?: boolean } {
const errorText = compactErrorText(error).replace(/^Error:\s*/i, "");
@ -2331,11 +2142,7 @@ class MegaDebridClient {
return { fatal: true, cooldownMs: 0, message: errorText, category: "temporary" };
}
if (/token.?error|please log.?in/i.test(errorText)) {
return { fatal: false, cooldownMs: 15_000, message: errorText, category: "temporary" };
}
if (/bad.?login|incorrect.?(login|password)|invalid.?(login|password|credentials)|wrong.?password|unauthorized|forbidden|connectUser/i.test(errorText)) {
if (/login|password|auth|credentials|unauthorized|forbidden/i.test(errorText) || /connectUser/i.test(errorText)) {
return {
fatal: false,
cooldownMs: MEGA_DEBRID_INVALID_ACCOUNT_COOLDOWN_MS,
@ -2344,27 +2151,10 @@ class MegaDebridClient {
};
}
if (isMegaDebridResolveFailure(errorText)) {
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" };
}
if (/rate.?limit|too.?many|429/i.test(errorText)) {
return {
fatal: false,
cooldownMs: MEGA_DEBRID_ACCOUNT_COOLDOWN_MS,
message: `Rate-Limit (${errorText})`,
category: "rate_limit"
};
}
if (/quota|limit|exceeded|bandwidth/i.test(errorText)) {
return {
fatal: false,
@ -2379,7 +2169,17 @@ class MegaDebridClient {
fatal: false,
cooldownMs: MEGA_DEBRID_ACCOUNT_COOLDOWN_MS,
message: "Kein Server fuer diesen Hoster (Tageslimit/Hoster nicht verfuegbar)",
category: "quota"
category: "quota",
limitSignal: true
};
}
if (/rate.?limit|too.?many|429/i.test(errorText)) {
return {
fatal: false,
cooldownMs: MEGA_DEBRID_ACCOUNT_COOLDOWN_MS,
message: `Rate-Limit (${errorText})`,
category: "rate_limit"
};
}
@ -2393,13 +2193,6 @@ class MegaDebridClient {
};
}
if (/queue.?timeout/i.test(errorText)) {
// Lokaler Stau (die Queue dieses Accounts war zu lange belegt) — KEIN Signal fuer einen
// ungesunden Account. Kein Cooldown, sonst wuerde der warme Account fuer Eigen-Stau bestraft;
// der Link wird einfach erneut versucht und rotiert dann natuerlich weiter.
return { fatal: false, cooldownMs: 0, message: errorText, category: "temporary" };
}
if (isRetryableErrorText(errorText) || /timeout|network|fetch|socket/i.test(errorText)) {
return {
fatal: false,
@ -2937,23 +2730,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,
@ -3878,7 +3654,7 @@ export class DebridService {
return `${PROVIDER_LABELS[effectiveProvider]} Tageslimit erreicht`;
}
public async unrestrictLink(link: string, signal?: AbortSignal, settingsSnapshot?: AppSettings, preferredLeadProvider?: DebridProvider | null): Promise<ProviderUnrestrictedLink> {
public async unrestrictLink(link: string, signal?: AbortSignal, settingsSnapshot?: AppSettings): Promise<ProviderUnrestrictedLink> {
const settings = settingsSnapshot ? cloneSettings(settingsSnapshot) : cloneSettings(this.settings);
const routing = settings.hosterRouting || {};
@ -3930,9 +3706,6 @@ export class DebridService {
if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) {
throw error;
}
if (!settings.autoProviderFallback) {
throw error;
}
}
}
@ -3949,16 +3722,12 @@ export class DebridService {
if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) {
throw error;
}
if (!settings.autoProviderFallback) {
throw error;
}
}
}
const baseOrder: DebridProvider[] = (settings.providerOrder && settings.providerOrder.length > 0)
const order: DebridProvider[] = (settings.providerOrder && settings.providerOrder.length > 0)
? uniqueProviderOrder(settings.providerOrder)
: toProviderOrder(settings.providerPrimary, settings.providerSecondary, settings.providerTertiary);
const order = leadProviderChainWith(baseOrder, preferredLeadProvider);
const primary = order[0];
if (!settings.autoProviderFallback) {
@ -4011,12 +3780,9 @@ export class DebridService {
continue;
}
const providerStartedAt = Date.now();
try {
logger.info(`Provider-Kette: versuche ${PROVIDER_LABELS[provider]}`);
traceConversionPhase({ phase: "chain-try", provider });
const result = await this.unrestrictViaProvider(settings, provider, link, signal);
traceConversionPhase({ phase: "chain-ok", provider, workMs: Date.now() - providerStartedAt, outcome: "ok" });
let fileName = result.fileName;
if (isRapidgatorLink(link) && looksLikeOpaqueFilename(fileName || filenameFromUrl(link))) {
const fromPage = await resolveRapidgatorFilename(link, signal);
@ -4033,17 +3799,9 @@ export class DebridService {
} catch (error) {
const errorText = compactErrorText(error);
if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) {
traceConversionPhase({ phase: "chain-aborted", provider, workMs: Date.now() - providerStartedAt, outcome: "aborted", detail: errorText.slice(0, 120) });
throw error;
}
const nextProvider = order.slice(order.indexOf(provider) + 1).find((candidate) => this.isProviderSelectableFor(settings, candidate));
traceConversionPhase({
phase: "chain-failed",
provider,
workMs: Date.now() - providerStartedAt,
outcome: nextProvider ? "failover" : "exhausted",
detail: `${errorText.slice(0, 120)}${nextProvider ? `${nextProvider}` : ""}`
});
if (nextProvider) {
logger.warn(`Provider-Kette: ${PROVIDER_LABELS[provider]} fehlgeschlagen (${errorText}), Fallback auf ${PROVIDER_LABELS[nextProvider]}`);
} else {

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"));
@ -972,17 +837,12 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
return;
}
const fileName = getSupportBundleDefaultFileName();
buildSupportBundle(manager, runtimeBaseDir)
.then((body) => {
logTraceEvent("INFO", "support", "Support-Bundle über Debug-Server heruntergeladen", {
fileName,
sizeBytes: body.length
});
binaryResponse(res, 200, body, "application/zip", fileName);
})
.catch((error) => {
jsonResponse(res, 500, { error: String((error as { message?: string })?.message || error) });
});
const body = buildSupportBundle(manager, runtimeBaseDir);
logTraceEvent("INFO", "support", "Support-Bundle über Debug-Server heruntergeladen", {
fileName,
sizeBytes: body.length
});
binaryResponse(res, 200, body, "application/zip", fileName);
return;
}
@ -1016,7 +876,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 +907,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 +922,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

@ -72,22 +72,6 @@ export function planDownloadCompletion(args: {
};
}
export function reconcileFinalizedSize(
streamedBytes: number,
statSize: number,
preAllocated: boolean
): number {
const streamed = Math.max(0, Math.floor(Number(streamedBytes) || 0));
if (!Number.isFinite(statSize) || statSize < 0) {
return streamed;
}
const onDisk = Math.floor(statSize);
if (preAllocated && onDisk > streamed) {
return streamed;
}
return onDisk;
}
export function validateDownloadedFileCompletion(args: {
actualBytes: number;
plan: DownloadCompletionPlan;

View File

@ -22,7 +22,6 @@ import {
StartConflictResolutionResult,
UiSnapshot, DebridAccountStatus } from "../shared/types";
import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
import { isMegaDebridTransientResolveFailure, germanMegaDebridResolveReason } from "../shared/mega-debrid-errors";
import {
addDebridLinkApiKeyDailyUsageBytes,
addDebridLinkApiKeyTotalUsageBytes,
@ -51,8 +50,8 @@ 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 { planDownloadCompletion, validateDownloadedFileCompletion } from "./download-completion";
import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, DebridService, MegaWebUnrestrictor, RealDebridWebUnrestrictor, checkRapidgatorOnline, fetchAllDebridHostInfo, getAvailableDebridLinkApiKeys, 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";
@ -60,7 +59,6 @@ import { processVideoFile, resolveVideoTooling, stripDualLangMarker, hasDualLang
import { sendNotification } from "./notify";
import { logger } from "./logger";
import { getRecentRotationEvents, runWithRotationItemSink, setRotationEventListener } from "./account-rotation-log";
import { runWithConversionTrace, traceConversionPhase, traceConversionNote } from "./conversion-trace";
import type { RotationEvent } from "../shared/types";
import { ensureItemLog, getItemLogPath as getPersistedItemLogPath, logItemEvent as writeItemLogEvent } from "./item-log";
import { ensurePackageLog, getPackageLogPath as getPersistedPackageLogPath, logPackageEvent as writePackageLogEvent } from "./package-log";
@ -131,17 +129,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 +361,6 @@ type DownloadManagerOptions = {
bestDebridWebUnrestrict?: BestDebridWebUnrestrictor;
invalidateMegaSession?: () => void;
onHistoryEntry?: HistoryEntryCallback;
protectEmptyClobber?: boolean;
};
function generateHistoryId(): string {
@ -657,47 +643,6 @@ function parseDebridLinkCooldownRetry(errorText: string): { delayMs: number; det
return { delayMs, detail };
}
export function parseMegaDebridCooldownRetry(errorText: string): { delayMs: number; detail: string } | null {
const text = String(errorText || "");
const matches = [...text.matchAll(/mega_debrid_cooldown:(\d+)/gi)];
if (matches.length === 0) {
return null;
}
const delays = matches.map((m) => Number(m[1])).filter((n) => Number.isFinite(n) && n > 0);
if (delays.length === 0) {
return null;
}
const delayMs = Math.max(1000, Math.min(15 * 60 * 1000, Math.min(...delays)));
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) {
return null;
}
const raw = Number(match[1]);
if (!Number.isFinite(raw) || raw <= 0) {
return null;
}
const delayMs = Math.max(1000, Math.min(26 * 60 * 60 * 1000, raw));
return { delayMs, detail: String(match[2] || "").trim() };
}
function parseDebridLinkTerminalFailure(errorText: string): { kind: "invalid_all" | "no_active_key"; detail: string } | null {
const raw = String(errorText || "");
const match = raw.match(/debrid_link_(invalid_all|no_active_key):(.*)$/i);
@ -756,12 +701,6 @@ function isTemporaryUnrestrictError(errorText: string): boolean {
|| text.includes("worker error");
}
export function transientResolveRetryDelayMs(retryCount: number): number {
const steps = [3000, 6000, 10000];
const n = Math.max(1, Math.floor(Number(retryCount) || 1));
return steps[Math.min(n - 1, steps.length - 1)];
}
function isFinishedStatus(status: DownloadStatus): boolean {
return status === "completed" || status === "failed" || status === "cancelled";
}
@ -1714,10 +1653,6 @@ export class DownloadManager extends EventEmitter {
public blockAllPersistence = false;
private protectAgainstEmptyClobber = false;
private emptyClobberProtectionLogged = false;
private debridService: DebridService;
private invalidateMegaSessionFn?: () => void;
@ -1840,8 +1775,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 +1796,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 +2953,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 +5504,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 +5545,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 +5609,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 +5786,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 +6089,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 +6106,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);
}
@ -6481,38 +6370,19 @@ export class DownloadManager extends EventEmitter {
} catch {
}
} else if (duplicateExists && canonicalExists && !primaryWins && primaryItem.status !== "completed") {
let canonicalSize = -1;
let duplicateSize = -1;
try { canonicalSize = fs.statSync(canonicalPath).size; } catch { }
try { duplicateSize = fs.statSync(duplicateTargetPath).size; } catch { }
if (canonicalSize >= 0 && duplicateSize >= 0 && canonicalSize >= duplicateSize) {
try {
fs.rmSync(duplicateTargetPath, { force: true });
} catch {
}
logger.info(`startupDuplicateMerge: kanonische Datei behalten (${canonicalSize}B >= Duplikat ${duplicateSize}B), Duplikat verworfen: ${canonicalBaseName}`);
} else {
const dedupBackupPath = `${canonicalPath}.dedupbak`;
try {
fs.renameSync(canonicalPath, dedupBackupPath);
try {
fs.renameSync(duplicateTargetPath, canonicalPath);
try { fs.rmSync(dedupBackupPath, { force: true }); } catch { }
canonicalExists = true;
this.logVerifiedRenameSync("startup-dedup (Austausch)", duplicateTargetPath, canonicalPath);
logger.info(`startupDuplicateMerge: ersetze verwaisten Originalpfad ${canonicalBaseName} durch ${path.basename(duplicateTargetPath)}`);
} catch (swapErr) {
try { fs.renameSync(dedupBackupPath, canonicalPath); } catch { }
throw swapErr;
}
} catch (err) {
logDesktopRename("ERROR", "startup-dedup (Austausch): Rename fehlgeschlagen", {
source: path.basename(duplicateTargetPath),
target: canonicalBaseName,
error: compactErrorText(err)
});
logger.warn(`startupDuplicateMerge: Austausch fehlgeschlagen ${canonicalPath}: ${compactErrorText(err)}`);
}
try {
fs.rmSync(canonicalPath, { force: true });
fs.renameSync(duplicateTargetPath, canonicalPath);
canonicalExists = true;
this.logVerifiedRenameSync("startup-dedup (Austausch)", duplicateTargetPath, canonicalPath);
logger.info(`startupDuplicateMerge: ersetze verwaisten Originalpfad ${canonicalBaseName} durch ${path.basename(duplicateTargetPath)}`);
} catch (err) {
logDesktopRename("ERROR", "startup-dedup (Austausch): Rename fehlgeschlagen", {
source: path.basename(duplicateTargetPath),
target: canonicalBaseName,
error: compactErrorText(err)
});
logger.warn(`startupDuplicateMerge: Austausch fehlgeschlagen ${canonicalPath}: ${compactErrorText(err)}`);
}
}
@ -7886,7 +7756,6 @@ export class DownloadManager extends EventEmitter {
this.settings.providerDailyUsageDay = currentDay;
this.settings.providerDailyUsageBytes = {};
this.settings.debridLinkApiKeyDailyUsageBytes = {};
this.settings.megaDebridAccountDailyUsageBytes = {};
this.statsCache = null;
this.statsCacheAt = 0;
if (persist) {
@ -8103,30 +7972,9 @@ export class DownloadManager extends EventEmitter {
return count;
}
private describeSlotOccupancy(): string {
let converting = 0;
let downloading = 0;
for (const active of this.activeTasks.values()) {
const activeItem = this.session.items[active.itemId];
if (!activeItem) {
continue;
}
if (activeItem.status === "validating") {
converting += 1;
} else if (activeItem.status === "downloading") {
downloading += 1;
}
}
return `conv${converting}/dl${downloading}/active${this.activeTasks.size}/max${this.settings.maxParallel}`;
}
private getSerializedValidatingLimit(provider: DebridProvider | null): number {
if (provider === "megadebrid-web" || provider === "megadebrid-api") {
const mode = provider === "megadebrid-web" ? "web" : "api";
const usableAccounts = getAvailableMegaDebridAccounts(this.settings)
.filter((account) => !getMegaDebridAccountCooldownState(`${account.id}:${mode}`))
.length;
return Math.max(1, usableAccounts);
if (provider === "megadebrid-web") {
return 1;
}
return Number.MAX_SAFE_INTEGER;
}
@ -8226,13 +8074,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;
@ -8603,7 +8445,6 @@ export class DownloadManager extends EventEmitter {
const retryAfter = this.retryAfterByItem.get(itemId) || 0;
if (retryAfter > now) continue;
if (item.status !== "queued" && item.status !== "reconnect_wait") continue;
if (this.activeTasks.has(itemId)) continue;
if (this.delayPacedStartForItem(item, now)) continue;
if (this.shouldDelayStartForItem(item)) continue;
@ -8741,53 +8582,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];
@ -8913,7 +8707,6 @@ export class DownloadManager extends EventEmitter {
}
const cooldownProvider = this.getProviderFailureKeyForItem(item);
const cooldownMs = this.getProviderCooldownRemaining(cooldownProvider);
let preferredLeadProvider: DebridProvider | null = null;
if (cooldownMs > 0) {
if (this.settings.autoProviderFallback) {
const fallback = this.findFallbackProviderNotInCooldown(item);
@ -8925,7 +8718,6 @@ export class DownloadManager extends EventEmitter {
fallback
});
item.provider = null;
preferredLeadProvider = fallback;
} else {
this.logPackageForItem(item, "WARN", "Provider-Cooldown blockiert Unrestrict", {
provider: cooldownProvider,
@ -8958,30 +8750,7 @@ export class DownloadManager extends EventEmitter {
const unrestrictedSignal = AbortSignal.any([active.abortController.signal, unrestrictTimeoutSignal]);
let unrestricted;
try {
unrestricted = await runWithConversionTrace(
{
itemId: item.id,
itemName: item.fileName || item.id,
link: item.url,
providerOrder: (this.settings.providerOrder || []).join(",") || String(this.getExpectedProviderForItem(item) || "?")
},
async () => {
traceConversionNote("slots", this.describeSlotOccupancy());
traceConversionNote("retry", Number(active.unrestrictRetries || 0));
try {
return await this.debridService.unrestrictLink(item.url, unrestrictedSignal, undefined, preferredLeadProvider);
} catch (innerError) {
if (!active.abortController.signal.aborted && unrestrictTimeoutSignal.aborted) {
traceConversionPhase({
phase: "caller-timeout",
outcome: "timeout",
detail: `Caller-Budget ${Math.ceil(getUnrestrictTimeoutMs() / 1000)}s erschoepft (siehe letzte Phase fuer in-flight Provider/Account)`
});
}
throw innerError;
}
}
);
unrestricted = await this.debridService.unrestrictLink(item.url, unrestrictedSignal);
} catch (unrestrictError) {
if (!active.abortController.signal.aborted && unrestrictTimeoutSignal.aborted) {
this.recordProviderFailure(cooldownProvider);
@ -9105,6 +8874,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 +8915,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;
@ -9193,15 +8964,6 @@ export class DownloadManager extends EventEmitter {
return;
} catch (error) {
if (this.session.items[item.id] !== item) {
if (active.abortReason === "cancel") {
const orphanClaimedPath = this.claimedTargetPathByItem.get(item.id) || item.targetPath || "";
if (orphanClaimedPath) {
try {
fs.rmSync(orphanClaimedPath, { force: true });
} catch {
}
}
}
return;
}
const reason = active.abortReason;
@ -9292,20 +9054,6 @@ export class DownloadManager extends EventEmitter {
const totalFailures = (active.stallRetries || 0) + (active.unrestrictRetries || 0) + (active.genericErrorRetries || 0);
if (totalFailures >= 15) {
item.retries += 1;
if (configuredRetryLimit > 0 && item.retries >= configuredRetryLimit) {
item.status = "failed";
item.lastError = stallErrorText || "Wiederholt fehlgeschlagen";
item.fullStatus = `Fehler: ${item.lastError}`;
item.speedBps = 0;
item.updatedAt = nowMs();
this.recordRunOutcome(item.id, "failed");
this.retryStateByItem.delete(item.id);
const shelveFailPkgStall = this.session.packages[item.packageId];
if (shelveFailPkgStall) this.refreshPackageStatus(shelveFailPkgStall);
this.persistSoon();
this.emitState();
return;
}
active.stallRetries = Math.floor((active.stallRetries || 0) / 2);
active.unrestrictRetries = Math.floor((active.unrestrictRetries || 0) / 2);
active.genericErrorRetries = Math.floor((active.genericErrorRetries || 0) / 2);
@ -9378,14 +9126,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 +9186,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) {
@ -9486,20 +9240,6 @@ export class DownloadManager extends EventEmitter {
const totalNonStallFailures = (active.stallRetries || 0) + (active.unrestrictRetries || 0) + (active.genericErrorRetries || 0);
if (totalNonStallFailures >= 15) {
item.retries += 1;
if (configuredRetryLimit > 0 && item.retries >= configuredRetryLimit) {
item.status = "failed";
item.lastError = errorText || "Wiederholt fehlgeschlagen";
item.fullStatus = `Fehler: ${item.lastError}`;
item.speedBps = 0;
item.updatedAt = nowMs();
this.recordRunOutcome(item.id, "failed");
this.retryStateByItem.delete(item.id);
const shelveFailPkgErr = this.session.packages[item.packageId];
if (shelveFailPkgErr) this.refreshPackageStatus(shelveFailPkgErr);
this.persistSoon();
this.emitState();
return;
}
active.stallRetries = Math.floor((active.stallRetries || 0) / 2);
active.unrestrictRetries = Math.floor((active.unrestrictRetries || 0) / 2);
active.genericErrorRetries = Math.floor((active.genericErrorRetries || 0) / 2);
@ -9557,88 +9297,6 @@ 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);
if (megaResetPark && active.unrestrictRetries < maxUnrestrictRetries) {
active.unrestrictRetries += 1;
item.retries += 1;
logger.warn(`Mega-Debrid bis Tagesreset gesperrt: item=${item.fileName || item.id}, retry=${active.unrestrictRetries}/${retryDisplayLimit}, delay=${megaResetPark.delayMs}ms, link=${item.url.slice(0, 80)}`);
this.queueRetry(
item,
active,
megaResetPark.delayMs,
`Mega-Debrid bis Tagesreset gesperrt, Pause ${Math.ceil(megaResetPark.delayMs / 1000)}s`
);
item.lastError = megaResetPark.detail || errorText;
this.persistSoon();
this.emitState();
return;
}
if (isMegaDebridTransientResolveFailure(errorText) && active.unrestrictRetries < maxUnrestrictRetries) {
active.unrestrictRetries += 1;
item.retries += 1;
const transientDelayMs = transientResolveRetryDelayMs(active.unrestrictRetries);
const transientReason = germanMegaDebridResolveReason(errorText);
logger.warn(`Transienter Mega-Debrid-Resolve-Fehler: item=${item.fileName || item.id}, retry=${active.unrestrictRetries}/${retryDisplayLimit}, delay=${transientDelayMs}ms, error=${errorText}, link=${item.url.slice(0, 80)}`);
if (item.downloadedBytes > 0) {
const targetFile = this.claimedTargetPathByItem.get(item.id) || "";
if (targetFile) {
try { fs.rmSync(targetFile, { force: true }); } catch { }
}
this.releaseTargetPath(item.id);
item.downloadedBytes = 0;
item.progressPercent = 0;
item.totalBytes = null;
this.dropItemContribution(item.id);
}
this.queueRetry(
item,
active,
transientDelayMs,
`${transientReason} — neuer Versuch ${active.unrestrictRetries}/${retryDisplayLimit} (${Math.ceil(transientDelayMs / 1000)}s)`
);
item.lastError = transientReason;
this.persistSoon();
this.emitState();
return;
}
if (isUnrestrictFailure(errorText) && active.unrestrictRetries < maxUnrestrictRetries) {
const debridLinkCooldown = parseDebridLinkCooldownRetry(errorText);
if (debridLinkCooldown) {
@ -9773,7 +9431,6 @@ export class DownloadManager extends EventEmitter {
await fs.promises.truncate(effectiveTargetPath, resumeStart);
existingBytes = resumeStart;
item.downloadedBytes = Math.min(item.downloadedBytes, existingBytes);
resumeRewindBytesNextAttempt = 0;
logAttemptEvent("WARN", "Resume-Schutz aktiv: Teil-Datei vor Retry zurueckgespult", {
attempt,
previousBytes,
@ -9787,6 +9444,8 @@ export class DownloadManager extends EventEmitter {
rewindBytes,
error: compactErrorText(rewindError)
});
} finally {
resumeRewindBytesNextAttempt = 0;
}
} else if (resumeRewindBytesNextAttempt > 0) {
resumeRewindBytesNextAttempt = 0;
@ -10132,7 +9791,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) {
@ -10585,15 +10243,13 @@ export class DownloadManager extends EventEmitter {
try {
const finalizedStat = await fs.promises.stat(effectiveTargetPath);
const reconciledSize = reconcileFinalizedSize(written, finalizedStat.size, preAllocated);
if (reconciledSize !== written) {
if (Number.isFinite(finalizedStat.size) && finalizedStat.size >= 0 && finalizedStat.size !== written) {
logAttemptEvent("WARN", "Dateigroesse nach Stream-Abschluss korrigiert", {
attempt,
previousWritten: written,
statSize: finalizedStat.size,
reconciledSize
statSize: finalizedStat.size
});
written = reconciledSize;
written = finalizedStat.size;
}
} catch {
}
@ -10623,6 +10279,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}"` : ""}`);
@ -10732,34 +10389,6 @@ export class DownloadManager extends EventEmitter {
await sleep(retryDelayWithJitter(attempt, 250));
continue;
}
if (
item.totalBytes != null && item.totalBytes > 0
&& written > existingBytes
&& shouldRewindResumeTail(normalizedLastError)
) {
const rewindTarget = Math.max(0, written - RESUME_REWIND_BYTES);
item.downloadedBytes = Math.min(item.downloadedBytes, rewindTarget);
try {
await fs.promises.truncate(effectiveTargetPath, rewindTarget);
logAttemptEvent("WARN", "Resume-Schutz: letzter Versuch vor Linkerneuerung zurueckgespult", {
attempt,
written,
rewindTarget
});
} catch (rewindError) {
try {
fs.rmSync(effectiveTargetPath, { force: true });
item.downloadedBytes = 0;
} catch {
}
logAttemptEvent("WARN", "Resume-Schutz: finales Rueckspulen fehlgeschlagen, Teil-Datei verworfen", {
attempt,
written,
rewindTarget,
error: compactErrorText(rewindError)
});
}
}
if (maxAttemptsBySetting > maxAttempts) {
const exhaustedError = existingBytes > 0 && normalizedLastError.startsWith("download_underflow:")
? `resume_download_underflow:${normalizedLastError.slice("download_underflow:".length)}`

View File

@ -2907,16 +2907,9 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
let learnedPassword = cachedPackagePassword;
let packageNeedsFlatMode = false;
const extractedArchives = new Set<string>();
const skippedNonArchives = new Set<string>();
const failedArchiveCategories = new Map<string, ExtractErrorCategory>();
for (const archivePath of candidates) {
if (resumeCompleted.has(archiveNameKey(path.basename(archivePath)))) {
const resumedName = path.basename(archivePath);
const resumedIsGenericSplit = /\.\d{3}$/i.test(resumedName) && !/\.(zip|7z)\.\d{3}$/i.test(resumedName);
if (resumedIsGenericSplit && !(await detectArchiveSignature(archivePath))) {
skippedNonArchives.add(pathSetKey(archivePath));
continue;
}
extractedArchives.add(archivePath);
}
}
@ -3033,7 +3026,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
logger.info(`Generische Split-Datei übersprungen (keine Archiv-Signatur): ${archiveName}`);
extracted += 1;
resumeCompleted.add(archiveResumeKey);
skippedNonArchives.add(pathSetKey(archivePath));
extractedArchives.add(archivePath);
await writeExtractResumeState(options.packageDir, resumeCompleted, options.packageId);
clearInterval(pulseTimer);
archiveOutcome = "skipped";
@ -3377,8 +3370,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
logger.error(`Entpacken ohne neue Ausgabe erkannt: ${options.targetDir}. Cleanup wird NICHT ausgeführt.`);
} else {
if (!options.skipPostCleanup) {
const cleanupSources = (failed === 0 ? candidates : Array.from(extractedArchives.values()))
.filter((archivePath) => !skippedNonArchives.has(pathSetKey(archivePath)));
const cleanupSources = failed === 0 ? candidates : Array.from(extractedArchives.values());
const sourceAndTargetEqual = pathSetKey(path.resolve(options.packageDir)) === pathSetKey(path.resolve(options.targetDir));
const removedArchives = sourceAndTargetEqual
? 0

View File

@ -93,11 +93,15 @@ export function readHashManifest(packageDir: string): Map<string, ParsedHashEntr
if (!parsed) {
continue;
}
const normalized: ParsedHashEntry = {
...parsed,
algorithm: hit[1]
};
const key = normalizeManifestKey(parsed.fileName);
if (map.has(key)) {
continue;
}
map.set(key, parsed);
map.set(key, normalized);
}
}
manifestCache.set(cacheKey, { at: Date.now(), entries: new Map(map) });

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);
@ -592,7 +592,7 @@ function registerIpcHandlers(): void {
if (result.canceled || !result.filePath) {
return { saved: false };
}
const exported = await controller.exportSupportBundle();
const exported = controller.exportSupportBundle();
await fs.promises.writeFile(result.filePath, exported.buffer);
return { saved: true, filePath: result.filePath };
});
@ -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

@ -1,6 +1,5 @@
import { UnrestrictedLink } from "./realdebrid";
import { compactErrorText, filenameFromUrl, sleep } from "./utils";
import { traceConversionPhase } from "./conversion-trace";
type MegaCredentials = {
login: string;
@ -177,12 +176,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 +193,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 });
@ -218,11 +217,7 @@ async function raceWithAbort<T>(promise: Promise<T>, signal?: AbortSignal, abort
}
export class MegaWebFallback {
// Pro Account eine eigene Warteschlange: Umwandlungen auf DEMSELBEN Account laufen
// seriell (kein Doppel-Login, kein Hammern eines einzelnen Accounts), verschiedene
// Accounts laufen parallel. So koennen die Links eines Pakets ueber mehrere Accounts
// gleichzeitig umgewandelt werden statt global eine nach der anderen.
private queues = new Map<string, Promise<unknown>>();
private queue: Promise<unknown> = Promise.resolve();
private getCredentials: () => MegaCredentials;
@ -238,15 +233,15 @@ export class MegaWebFallback {
account?: { login: string; password: string }
): Promise<UnrestrictedLink | null> {
const overallSignal = withTimeoutSignal(signal, 180000);
const creds = (account && account.login.trim() && account.password.trim())
? account
: this.getCredentials();
if (!creds.login.trim() || !creds.password.trim()) {
return null;
}
const key = creds.login.trim().toLowerCase();
return this.runExclusive(async () => {
throwIfAborted(overallSignal);
const creds = (account && account.login.trim() && account.password.trim())
? account
: this.getCredentials();
if (!creds.login.trim() || !creds.password.trim()) {
return null;
}
const key = creds.login.trim().toLowerCase();
let cookie = await this.ensureSession(key, creds.login, creds.password, overallSignal);
let generated = await this.generate(link, cookie, overallSignal);
@ -264,7 +259,7 @@ export class MegaWebFallback {
fileSize: null,
retriesUsed: 0
};
}, key, overallSignal);
}, overallSignal);
}
private async ensureSession(key: string, login: string, password: string, signal?: AbortSignal): Promise<string> {
@ -281,36 +276,20 @@ export class MegaWebFallback {
this.sessions.clear();
}
private async runExclusive<T>(job: () => Promise<T>, key: string, signal?: AbortSignal): Promise<T> {
private async runExclusive<T>(job: () => Promise<T>, 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;
if (waited > QUEUE_WAIT_TIMEOUT_MS) {
traceConversionPhase({ phase: "web-queue", provider: "megadebrid-web", queueWaitMs: waited, outcome: "queue-timeout", detail: `${Math.floor(waited / 1000)}s in Web-Queue gewartet` });
throw new Error(`Mega-Web Queue-Timeout (${Math.floor(waited / 1000)}s gewartet)`);
}
workStarted = true;
const workStartedAt = Date.now();
try {
const result = await job();
traceConversionPhase({ phase: "web-queue", provider: "megadebrid-web", queueWaitMs: waited, workMs: Date.now() - workStartedAt, outcome: "ok" });
return result;
} catch (jobError) {
traceConversionPhase({ phase: "web-queue", provider: "megadebrid-web", queueWaitMs: waited, workMs: Date.now() - workStartedAt, outcome: "error", detail: compactErrorText(jobError).slice(0, 100) });
throw jobError;
}
return job();
};
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)`)
);
const run = this.queue.then(guardedJob, guardedJob);
this.queue = run.then(() => undefined, () => undefined);
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,56 +877,26 @@ 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;
}
}
export function saveSettings(paths: StoragePaths, settings: AppSettings): void {
syncSettingsSaveGeneration += 1;
ensureBaseDir(paths.baseDir);
if (fs.existsSync(paths.configFile)) {
try {
@ -960,26 +917,17 @@ export function saveSettings(paths: StoragePaths, settings: AppSettings): void {
}
let asyncSettingsSaveRunning = false;
let asyncSettingsSaveQueued: { paths: StoragePaths; payload: string; generation: number } | null = null;
let syncSettingsSaveGeneration = 0;
let asyncSettingsSaveQueued: { paths: StoragePaths; settings: AppSettings } | null = null;
async function writeSettingsPayload(paths: StoragePaths, payload: string, generation: number): Promise<void> {
async function writeSettingsPayload(paths: StoragePaths, payload: string): Promise<void> {
await fs.promises.mkdir(paths.baseDir, { recursive: true });
await fsp.copyFile(paths.configFile, `${paths.configFile}.bak`).catch(() => {});
const tempPath = `${paths.configFile}.settings.tmp`;
await fsp.writeFile(tempPath, payload, "utf8");
if (generation < syncSettingsSaveGeneration) {
await fsp.rm(tempPath, { force: true }).catch(() => {});
return;
}
try {
await fsp.rename(tempPath, paths.configFile);
} catch (renameError: unknown) {
if (renameError && typeof renameError === "object" && "code" in renameError && (renameError as NodeJS.ErrnoException).code === "EXDEV") {
if (generation < syncSettingsSaveGeneration) {
await fsp.rm(tempPath, { force: true }).catch(() => {});
return;
}
await fsp.copyFile(tempPath, paths.configFile);
await fsp.rm(tempPath, { force: true }).catch(() => {});
} else {
@ -989,14 +937,16 @@ async function writeSettingsPayload(paths: StoragePaths, payload: string, genera
}
}
async function saveSettingsPayloadAsync(paths: StoragePaths, payload: string, generation: number): Promise<void> {
export async function saveSettingsAsync(paths: StoragePaths, settings: AppSettings): Promise<void> {
const persisted = sanitizeCredentialPersistence(normalizeSettings(settings));
const payload = JSON.stringify(persisted, safeJsonReplacer, 2);
if (asyncSettingsSaveRunning) {
asyncSettingsSaveQueued = { paths, payload, generation };
asyncSettingsSaveQueued = { paths, settings };
return;
}
asyncSettingsSaveRunning = true;
try {
await writeSettingsPayload(paths, payload, generation);
await writeSettingsPayload(paths, payload);
} catch (error) {
logger.error(`Async Settings-Save fehlgeschlagen: ${String(error)}`);
} finally {
@ -1004,18 +954,11 @@ async function saveSettingsPayloadAsync(paths: StoragePaths, payload: string, ge
if (asyncSettingsSaveQueued) {
const queued = asyncSettingsSaveQueued;
asyncSettingsSaveQueued = null;
void saveSettingsPayloadAsync(queued.paths, queued.payload, queued.generation);
void saveSettingsAsync(queued.paths, queued.settings);
}
}
}
export async function saveSettingsAsync(paths: StoragePaths, settings: AppSettings): Promise<void> {
const generation = syncSettingsSaveGeneration;
const persisted = sanitizeCredentialPersistence(normalizeSettings(settings));
const payload = JSON.stringify(persisted, safeJsonReplacer, 2);
await saveSettingsPayloadAsync(paths, payload, generation);
}
export function emptySession(): SessionState {
return {
version: 2,
@ -1033,31 +976,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 +995,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 +1003,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 +1039,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 +1060,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 +1076,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;
@ -1211,7 +1122,6 @@ export function cancelPendingAsyncSaves(): void {
asyncSaveQueued = null;
asyncSettingsSaveQueued = null;
syncSaveGeneration += 1;
syncSettingsSaveGeneration += 1;
}
export async function saveSessionAsync(paths: StoragePaths, session: SessionState): Promise<void> {

View File

@ -1,9 +1,8 @@
import { promises as fsp } from "node:fs";
import fs from "node:fs";
import path from "node:path";
import AdmZip from "adm-zip";
import { APP_VERSION } from "./constants";
import { getAccountRotationLogPath } from "./account-rotation-log";
import { getConversionLogPath } from "./conversion-trace";
import { getAuditLogPath } from "./audit-log";
import { getDebugSetupCheck } from "./debug-setup";
import { getLogFilePath } from "./logger";
@ -20,9 +19,9 @@ import type { DownloadManager } from "./download-manager";
const AI_MANIFEST_FILE = "debug_ai_manifest.json";
async function safeReadJson(filePath: string): Promise<unknown> {
function safeReadJson(filePath: string): unknown {
try {
return JSON.parse(await fsp.readFile(filePath, "utf8")) as unknown;
return JSON.parse(fs.readFileSync(filePath, "utf8")) as unknown;
} catch {
return null;
}
@ -32,50 +31,42 @@ function addJson(zip: AdmZip, zipPath: string, value: unknown): void {
zip.addFile(zipPath, Buffer.from(`${JSON.stringify(value, null, 2)}\n`, "utf8"));
}
async function addFileIfExists(zip: AdmZip, sourcePath: string | null, zipPath: string): Promise<void> {
if (!sourcePath) {
function addFileIfExists(zip: AdmZip, sourcePath: string | null, zipPath: string): void {
if (!sourcePath || !fs.existsSync(sourcePath)) {
return;
}
try {
const buffer = await fsp.readFile(sourcePath);
zip.addFile(zipPath, buffer);
} catch {
}
zip.addLocalFile(sourcePath, path.posix.dirname(zipPath), path.posix.basename(zipPath));
}
async function addDirectoryIfExists(zip: AdmZip, dirPath: string, zipRoot: string): Promise<void> {
let entries;
try {
entries = await fsp.readdir(dirPath, { withFileTypes: true });
} catch {
function addDirectoryIfExists(zip: AdmZip, dirPath: string, zipRoot: string): void {
if (!fs.existsSync(dirPath)) {
return;
}
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name);
const zipPath = path.posix.join(zipRoot, entry.name);
if (entry.isDirectory()) {
await addDirectoryIfExists(zip, fullPath, zipPath);
addDirectoryIfExists(zip, fullPath, zipPath);
continue;
}
await addFileIfExists(zip, fullPath, zipPath);
zip.addLocalFile(fullPath, path.posix.dirname(zipPath), path.posix.basename(zipPath));
}
}
async function addRecentDirectoryFiles(zip: AdmZip, dirPath: string, zipRoot: string, maxAgeMs: number): Promise<number> {
let entries;
try {
entries = await fsp.readdir(dirPath, { withFileTypes: true });
} catch {
function addRecentDirectoryFiles(zip: AdmZip, dirPath: string, zipRoot: string, maxAgeMs: number): number {
if (!fs.existsSync(dirPath)) {
return 0;
}
const cutoff = Date.now() - maxAgeMs;
let added = 0;
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isFile()) continue;
const fullPath = path.join(dirPath, entry.name);
try {
if ((await fsp.stat(fullPath)).mtimeMs >= cutoff) {
await addFileIfExists(zip, fullPath, path.posix.join(zipRoot, entry.name));
if (fs.statSync(fullPath).mtimeMs >= cutoff) {
zip.addLocalFile(fullPath, zipRoot, entry.name);
added += 1;
}
} catch { }
@ -135,7 +126,7 @@ function resolveHostDiagnostics(mode: HostDiagnosticsMode): unknown {
return getWindowsHostDiagnostics();
}
export async function buildSupportBundle(manager: DownloadManager, baseDir: string, options: BuildSupportBundleOptions = {}): Promise<Buffer> {
export function buildSupportBundle(manager: DownloadManager, baseDir: string, options: BuildSupportBundleOptions = {}): Buffer {
const zip = new AdmZip();
const hostDiagnosticsMode = options.hostDiagnosticsMode || "full";
const storagePaths = createStoragePaths(baseDir);
@ -183,39 +174,37 @@ export async function buildSupportBundle(manager: DownloadManager, baseDir: stri
const recentErrors = getRecentErrors();
addJson(zip, "overview/recent-errors.json", { count: recentErrors.length, entries: recentErrors });
await addFileIfExists(zip, path.join(baseDir, AI_MANIFEST_FILE), `runtime/${AI_MANIFEST_FILE}`);
await addFileIfExists(zip, path.join(baseDir, "debug_host.txt"), "runtime/debug_host.txt");
await addFileIfExists(zip, path.join(baseDir, "debug_port.txt"), "runtime/debug_port.txt");
await addFileIfExists(zip, getTraceConfigPath(), "runtime/trace_config.json");
addFileIfExists(zip, path.join(baseDir, AI_MANIFEST_FILE), `runtime/${AI_MANIFEST_FILE}`);
addFileIfExists(zip, path.join(baseDir, "debug_host.txt"), "runtime/debug_host.txt");
addFileIfExists(zip, path.join(baseDir, "debug_port.txt"), "runtime/debug_port.txt");
addFileIfExists(zip, getTraceConfigPath(), "runtime/trace_config.json");
await addFileIfExists(zip, getLogFilePath(), "logs/rd_downloader.log");
await addFileIfExists(zip, `${getLogFilePath()}.old`, "logs/rd_downloader.log.old");
await addFileIfExists(zip, getAuditLogPath(), "logs/audit.log");
await addFileIfExists(zip, getAuditLogPath() ? `${getAuditLogPath()}.old` : null, "logs/audit.log.old");
await addFileIfExists(zip, getRenameLogPath(), "logs/rename.log");
await addFileIfExists(zip, getRenameLogPath() ? `${getRenameLogPath()}.old` : null, "logs/rename.log.old");
await addFileIfExists(zip, getDesktopRenameLogPath(), "logs/rename-session-desktop.txt");
await addFileIfExists(zip, getSessionLogPath(), "logs/session.log");
await addFileIfExists(zip, getTraceLogPath(), "logs/trace.log");
await addFileIfExists(zip, getTraceLogPath() ? `${getTraceLogPath()}.old` : null, "logs/trace.log.old");
await addFileIfExists(zip, getAccountRotationLogPath(), "logs/account-rotation.log");
await addFileIfExists(zip, getAccountRotationLogPath() ? `${getAccountRotationLogPath()}.old` : null, "logs/account-rotation.log.old");
await addFileIfExists(zip, getConversionLogPath(), "logs/conversion.log");
await addFileIfExists(zip, getConversionLogPath() ? `${getConversionLogPath()}.old` : null, "logs/conversion.log.old");
addFileIfExists(zip, getLogFilePath(), "logs/rd_downloader.log");
addFileIfExists(zip, `${getLogFilePath()}.old`, "logs/rd_downloader.log.old");
addFileIfExists(zip, getAuditLogPath(), "logs/audit.log");
addFileIfExists(zip, getAuditLogPath() ? `${getAuditLogPath()}.old` : null, "logs/audit.log.old");
addFileIfExists(zip, getRenameLogPath(), "logs/rename.log");
addFileIfExists(zip, getRenameLogPath() ? `${getRenameLogPath()}.old` : null, "logs/rename.log.old");
addFileIfExists(zip, getDesktopRenameLogPath(), "logs/rename-session-desktop.txt");
addFileIfExists(zip, getSessionLogPath(), "logs/session.log");
addFileIfExists(zip, getTraceLogPath(), "logs/trace.log");
addFileIfExists(zip, getTraceLogPath() ? `${getTraceLogPath()}.old` : null, "logs/trace.log.old");
addFileIfExists(zip, getAccountRotationLogPath(), "logs/account-rotation.log");
addFileIfExists(zip, getAccountRotationLogPath() ? `${getAccountRotationLogPath()}.old` : null, "logs/account-rotation.log.old");
const SUPPORT_BUNDLE_LOG_WINDOW_MS = 8 * 60 * 60 * 1000;
await addDirectoryIfExists(zip, path.join(baseDir, "session-logs"), "logs/session-logs");
await addRecentDirectoryFiles(zip, path.join(baseDir, "package-logs"), "logs/package-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS);
await addRecentDirectoryFiles(zip, path.join(baseDir, "item-logs"), "logs/item-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS);
addDirectoryIfExists(zip, path.join(baseDir, "session-logs"), "logs/session-logs");
addRecentDirectoryFiles(zip, path.join(baseDir, "package-logs"), "logs/package-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS);
addRecentDirectoryFiles(zip, path.join(baseDir, "item-logs"), "logs/item-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS);
for (const packageId of packageIds) {
await addFileIfExists(zip, manager.getPackageLogPath(packageId) || getPackageLogPath(packageId), `logs/live/package-${packageId}.txt`);
addFileIfExists(zip, manager.getPackageLogPath(packageId) || getPackageLogPath(packageId), `logs/live/package-${packageId}.txt`);
}
for (const itemId of itemIds) {
await addFileIfExists(zip, manager.getItemLogPath(itemId), `logs/live/item-${itemId}.txt`);
addFileIfExists(zip, manager.getItemLogPath(itemId), `logs/live/item-${itemId}.txt`);
}
const aiManifest = await safeReadJson(path.join(baseDir, AI_MANIFEST_FILE));
const aiManifest = safeReadJson(path.join(baseDir, AI_MANIFEST_FILE));
if (aiManifest) {
addJson(zip, "overview/ai-manifest.json", aiManifest);
}

View File

@ -1,34 +0,0 @@
export interface InstallResumeManager {
isSessionRunning(): boolean;
stop(options: { parkForRestart: boolean }): void;
persistNowSync(): void;
start(): Promise<void> | void;
}
export async function runInstallWithResume<T extends { started: boolean }>(
manager: InstallResumeManager,
doInstall: () => Promise<T>
): Promise<T> {
const wasRunning = manager.isSessionRunning();
if (wasRunning) {
manager.stop({ parkForRestart: true });
}
manager.persistNowSync();
const resumeIfParked = async (): Promise<void> => {
if (wasRunning && !manager.isSessionRunning()) {
await manager.start();
}
};
try {
const result = await doInstall();
if (!result.started) {
await resumeIfParked();
}
return result;
} catch (error) {
await resumeIfParked();
throw error;
}
}

View File

@ -104,9 +104,6 @@ function isGermanStream(stream: ProbedAudioStream): boolean {
// Free-text title fallback (used when the language tag is missing). Full words
// only — the 2-3 letter codes ger/deu are too ambiguous in a title and would
// pick the wrong track to keep (which then deletes the real German one).
if (lang) {
return false;
}
const title = (stream.title || "").toLowerCase();
return /\b(german|deutsch)\b/.test(title);
}

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,
@ -110,7 +109,7 @@ interface MegaDialogAccount {
password: string;
}
export interface AccountDialogState {
interface AccountDialogState {
mode: "create" | "edit";
kind: AccountKind | null;
token: string;
@ -648,7 +647,7 @@ function createAccountDialogState(mode: "create" | "edit", kind: AccountKind | n
}
}
export function applyAccountDialogToSettings(settings: AppSettings, dialog: AccountDialogState): AppSettings {
function applyAccountDialogToSettings(settings: AppSettings, dialog: AccountDialogState): AppSettings {
if (!dialog.kind) {
return settings;
}
@ -683,7 +682,7 @@ export function applyAccountDialogToSettings(settings: AppSettings, dialog: Acco
const firstPassword = megaParsed.length > 0 ? megaParsed[0].password : "";
const validIds = new Set(megaParsed.map((a) => a.id));
const megaDebridDisabledAccountIds = (dialog.megaDisabledIds || []).filter((id) => validIds.has(id));
return { ...settings, megaCredentials: megaSerialized, megaLogin: firstLogin, megaPassword: firstPassword, megaDebridApiEnabled: true, megaDebridDisabledAccountIds, providerDailyLimitBytes: nextProviderDailyLimitBytes };
return { ...settings, megaCredentials: megaSerialized, megaLogin: firstLogin, megaPassword: firstPassword, megaDebridApiEnabled: true, megaDebridPreferApi: true, megaDebridDisabledAccountIds, providerDailyLimitBytes: nextProviderDailyLimitBytes };
}
case "megadebrid-web": {
const megaSerialized = serializeMegaDebridAccounts(dialog.megaAccounts);
@ -692,7 +691,7 @@ export function applyAccountDialogToSettings(settings: AppSettings, dialog: Acco
const firstPassword = megaParsed.length > 0 ? megaParsed[0].password : "";
const validIds = new Set(megaParsed.map((a) => a.id));
const megaDebridDisabledAccountIds = (dialog.megaDisabledIds || []).filter((id) => validIds.has(id));
return { ...settings, megaCredentials: megaSerialized, megaLogin: firstLogin, megaPassword: firstPassword, megaDebridWebEnabled: true, megaDebridDisabledAccountIds, providerDailyLimitBytes: nextProviderDailyLimitBytes };
return { ...settings, megaCredentials: megaSerialized, megaLogin: firstLogin, megaPassword: firstPassword, megaDebridWebEnabled: true, megaDebridPreferApi: false, megaDebridDisabledAccountIds, providerDailyLimitBytes: nextProviderDailyLimitBytes };
}
case "bestdebrid-api":
return { ...settings, bestToken: token, bestDebridUseWebLogin: false, providerDailyLimitBytes: nextProviderDailyLimitBytes };
@ -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,
@ -1160,13 +1159,13 @@ function formatCheckedAgo(checkedAt: number): string {
}
function rotationEventText(ev: { event: string; cooldownSec?: number; next?: string; reason?: string }): string {
const untilRestart = /bis zum Tagesreset gesperrt/i.test(ev.reason || "");
const untilRestart = /bis Neustart gesperrt/i.test(ev.reason || "");
switch (ev.event) {
case "OK": return "erfolgreich";
case "FAILED": {
if (untilRestart) {
const nx = ev.next && ev.next !== "ENDE" ? `${ev.next}` : "";
return `Tageslimit erreicht, bis zum Tagesreset gesperrt${nx}`;
return `Tageslimit erreicht, bis Neustart gesperrt${nx}`;
}
const cd = ev.cooldownSec ? `, Cooldown ${ev.cooldownSec}s` : "";
const nx = ev.next && ev.next !== "ENDE" ? `${ev.next}` : "";
@ -1177,7 +1176,7 @@ function rotationEventText(ev: { event: string; cooldownSec?: number; next?: str
const cd = ev.cooldownSec ? `, Cooldown ${ev.cooldownSec}s` : "";
return `Timeout/Abbruch${cd} → nächster Account beim Retry`;
}
case "SKIP_COOLDOWN": return untilRestart ? "übersprungen (bis zum Tagesreset gesperrt)" : "übersprungen (Cooldown aktiv)";
case "SKIP_COOLDOWN": return untilRestart ? "übersprungen (bis Neustart gesperrt)" : "übersprungen (Cooldown aktiv)";
case "SKIP_DISABLED": return "übersprungen (deaktiviert)";
case "SKIP_DAILY_LIMIT": return "übersprungen (Tageslimit erreicht)";
case "SKIP_HOST_COOLDOWN": return "übersprungen (Host-Cooldown)";
@ -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);
@ -2570,7 +2561,7 @@ export function App(): ReactElement {
const used = getMegaDebridAccountDailyUsageBytes(snapshot.settings, acc.id);
const limit = getMegaDebridAccountDailyLimitBytes(settingsDraft, acc.id);
rows.push({
rowKey: `mega-${entry.kind}-${acc.id}`,
rowKey: `mega-${acc.id}`,
entry,
hosterLabel: entry.serviceLabel,
modeLabel: entry.modeLabel,
@ -2626,9 +2617,6 @@ export function App(): ReactElement {
return rows;
}, [configuredAccounts, settingsDraft, snapshot.settings]);
const [accountStatusSort, setAccountStatusSort] = useState<"none" | "desc" | "asc">("none");
const cycleAccountStatusSort = (): void => setAccountStatusSort((s) => (s === "none" ? "desc" : s === "desc" ? "asc" : "none"));
const groupedAccountRows = useMemo(() => {
const groups: { service: string; serviceLabel: string; rows: typeof accountRows }[] = [];
const byService = new Map<string, { service: string; serviceLabel: string; rows: typeof accountRows }>();
@ -2642,26 +2630,8 @@ export function App(): ReactElement {
}
group.rows.push(row);
}
if (accountStatusSort !== "none") {
const statuses = snapshot.settings?.debridAccountStatuses || {};
const now = Date.now();
const remainingMs = (row: (typeof accountRows)[number]): number => {
const st = row.accountId ? statuses[row.accountId] : null;
return st && st.premiumUntilMs && st.premiumUntilMs > now ? st.premiumUntilMs - now : -1;
};
for (const group of groups) {
group.rows = [...group.rows].sort((a, b) => {
const ra = remainingMs(a);
const rb = remainingMs(b);
if (ra > 0 && rb > 0) return accountStatusSort === "desc" ? rb - ra : ra - rb;
if (ra > 0) return -1;
if (rb > 0) return 1;
return 0;
});
}
}
return groups;
}, [accountRows, accountStatusSort, snapshot.settings]);
}, [accountRows]);
const [collapsedAccountGroups, setCollapsedAccountGroups] = useState<Set<string>>(() => {
try {
@ -2693,8 +2663,7 @@ export function App(): ReactElement {
else if (!st.isPremium) { statusCls = "free"; statusText = "Free Account"; }
else { statusCls = "ok"; statusText = st.message || "Premium Account"; }
const isProblem = statusCls === "invalid";
const username = row.username;
const usernameTitle = st && st.email && st.email.toLowerCase() !== row.username.toLowerCase() ? `${row.username} (Mail: ${st.email})` : row.username;
const username = st && st.email ? st.email : row.username;
const expiry = st && st.premiumUntilMs && st.premiumUntilMs > 0 ? new Date(st.premiumUntilMs).toLocaleDateString("de-DE") : "—";
const traffic = row.dailyLimitBytes > 0
? `${humanSize(row.dailyRemainingBytes)} von ${humanSize(row.dailyLimitBytes)} übrig`
@ -2724,7 +2693,7 @@ export function App(): ReactElement {
? <span className="acct2-nostatus" title="Für diesen Anbieter gibt es keine Status-Prüfung"></span>
: <span className={`account-validity-badge ${statusCls}`}>{statusText}</span>}
</span>
<span className="acct2-user" title={usernameTitle}>{username}</span>
<span className="acct2-user" title={username}>{username}</span>
<span className="acct2-expiry">{expiry}</span>
<span className="acct2-c-actions">
{row.toggleKind === "single" && getAccountQuickActionMeta(row.entry.kind) && (
@ -2796,16 +2765,28 @@ export function App(): ReactElement {
}
let changelogText = "";
if (result.releaseNotes) {
changelogText = result.releaseNotes
.split("\n")
.filter((line) => !/^#{1,6}\s/.test(line))
.map((line) => line
const lines = result.releaseNotes.split("\n");
const compactLines: string[] = [];
for (const line of lines) {
if (/^\s{2,}[-*]/.test(line)) continue;
if (/^#{1,6}\s/.test(line)) continue;
if (!line.trim()) continue;
let clean = line
.replace(/\*\*([^*]+)\*\*/g, "$1")
.replace(/\*([^*]+)\*/g, "$1")
.replace(/`([^`]+)`/g, "$1"))
.join("\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
.replace(/`([^`]+)`/g, "$1")
.replace(/^\s*[-*]\s+/, "- ")
.trim();
const colonIdx = clean.indexOf(":");
if (colonIdx > 0 && colonIdx < clean.length - 1) {
const afterColon = clean.slice(colonIdx + 1).trim();
if (afterColon.length > 60) {
clean = clean.slice(0, colonIdx + 1).trim();
}
}
if (clean) compactLines.push(clean);
}
changelogText = compactLines.join("\n");
}
const approved = await askConfirmPrompt({
title: "Update verfügbar",
@ -4337,89 +4318,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 +4708,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 +5339,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;
@ -5507,12 +5402,6 @@ export function App(): ReactElement {
<span className="account-inline-stat">{availableAccountOptions.length} weitere Typen verfügbar</span>
</div>
{configuredAccountServices.has("megadebrid-api") && configuredAccountServices.has("megadebrid-web") && (
<div className="account-mode-note">
<strong>Mega-Debrid API + Web sind derselbe Account</strong>, nur zwei Zugriffsarten: API (schnell) wird zuerst versucht, Web dient als Fallback. Gleicher Login, kein zweites Konto beide Zeilen teilen sich dasselbe Tageslimit und denselben Premium-Status.
</div>
)}
{accountRows.length === 0 && (
<div className="account-empty-state">
<strong>Noch keine Accounts hinterlegt</strong>
@ -5526,7 +5415,7 @@ export function App(): ReactElement {
<span className="acct2-c-check" />
<span>Hoster</span>
<span>Download-Traffic</span>
<span className="acct2-sortable" title="Nach Premium-Restlaufzeit sortieren (längste/kürzeste)" onClick={cycleAccountStatusSort}>Status{accountStatusSort === "desc" ? " ▼" : accountStatusSort === "asc" ? " ▲" : ""}</span>
<span>Status</span>
<span>Benutzername</span>
<span>Verfallsdatum</span>
<span className="acct2-c-actions">Aktion</span>
@ -6029,97 +5918,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;
@ -6971,13 +6769,7 @@ const ItemRow = memo(function ItemRow({ item, packageId, isSelected, sessionRunn
}, [packageId, item.id, onContextMenu]);
const formattedCreatedAt = useMemo(() => formatDateTime(item.createdAt), [item.createdAt]);
const displayStatus = useMemo(() => computeDisplayedItemStatus(item, sessionRunning), [item, sessionRunning]);
const retrySuffix = item.retries > 0 ? ` (R${item.retries})` : "";
const lastErrorText = String(item.lastError || "").trim();
const statusTitle = displayStatus
? (lastErrorText && lastErrorText !== displayStatus && !displayStatus.includes(lastErrorText)
? `${displayStatus}${retrySuffix}\n${lastErrorText}`
: `${displayStatus}${retrySuffix}`)
: lastErrorText;
const statusTitle = displayStatus ? (item.retries > 0 ? `${displayStatus} ? R${item.retries}` : displayStatus) : "";
return (
<div

View File

@ -2088,20 +2088,6 @@ body,
line-height: 1.5;
}
.account-mode-note {
padding: 10px 12px;
border-radius: 10px;
border: 1px solid color-mix(in srgb, var(--accent) 22%, transparent);
background: color-mix(in srgb, var(--accent) 7%, transparent);
color: var(--muted);
font-size: 12.5px;
line-height: 1.5;
}
.account-mode-note strong {
color: var(--text);
}
.account-dl-key-limit-list {
display: grid;
gap: 8px;
@ -3333,10 +3319,6 @@ td {
.acct2-mode { font-size: 11px; color: var(--muted); }
.acct2-traffic,
.acct2-expiry { font-variant-numeric: tabular-nums; color: var(--muted); }
.acct2-head > span:nth-child(3),
.acct2-traffic { text-align: center; }
.acct2-sortable { cursor: pointer; user-select: none; }
.acct2-sortable:hover { color: var(--text); }
.acct2-user { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.acct2-status .account-validity-badge { margin-top: 0; }
.acct2-nostatus { color: var(--muted); opacity: 0.6; font-variant-numeric: tabular-nums; }
@ -3360,110 +3342,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

@ -1,24 +0,0 @@
export function isMegaDebridResolveFailure(errorText: string): boolean {
const text = String(errorText || "").toLowerCase();
return /supprim/.test(text)
|| text.includes("introuvable")
|| text.includes("n'existe plus")
|| text.includes("n existe plus")
|| text.includes("fichier inexistant");
}
export function isMegaDebridTransientResolveFailure(errorText: string): boolean {
const text = String(errorText || "").toLowerCase();
return isMegaDebridResolveFailure(text)
|| text.includes("datei beim hoster gerade nicht abrufbar")
|| text.includes("datei beim hoster nicht gefunden");
}
export function germanMegaDebridResolveReason(errorText: string): string {
const text = String(errorText || "").toLowerCase();
if (text.includes("datei beim hoster nicht gefunden")
|| text.includes("introuvable") || text.includes("fichier inexistant") || text.includes("n'existe plus") || text.includes("n existe plus")) {
return "Datei beim Hoster nicht gefunden";
}
return "Datei beim Hoster gerade nicht abrufbar";
}

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,479 +0,0 @@
# 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).
### Meine unabhaengigen Verdachtsfaelle (Cross-Check gegen Workflow)
1. **Web-Selbst-Cooldown (Analog zum API-214-Bug, HOCH):** Caller-Timeout = 60s
(`DEFAULT_UNRESTRICT_TIMEOUT_MS`, download-manager 114) umschliesst die GANZE Kette.
Mega-Web braucht legitim laenger (per-Account-Queue bis 90s + Login + Generate).
Feuert die 60s nach >=8s (`MEGA_DEBRID_ABORT_MIN_RUN_MS_DEFAULT`=8000), setzt die
Rotation `aborted:debrid` → 120s Account-Cooldown (debrid.ts ~2037-2048), obwohl der
Account GESUND ist — die App hat aufgegeben. → Kaskade ueber Accounts. Live im jf.zip
belegt: `Mega-Debrid Web | TIMEOUT_COOLDOWN | reason=aborted:debrid | cooldownSec=120`.
Fix-Kandidat: (a) per-Provider-Timeout statt globaler 60s; und/oder (b) Caller-Timeout-
Abort NICHT als Account-Cooldown werten (EMA-Demotion regelt langsame Accounts bereits),
oder nur sehr kurz.
2. **Globaler 60s-Timeout kappt Failover (Advisor-bewiesen, HOCH):** download-manager 8759
`AbortSignal.any([cancel, timeout])` → bei Provider1-Verbrauch des Budgets abortet das
Signal → debrid.ts 3805 `signal.aborted` → throw, kein nextProvider. Fix: per-Provider-
AbortSignal.timeout, Stop nur bei USER-Cancel.
3. **Exponential-Backoff bis 120s** (generic unrestrict retry) — Item sitzt bis 2 min.
Pruefen ob fuer haeufige transiente Faelle zu lang.
## Bestaetigte Bugs (Workflow R1: 14 confirmed / 11 refuted) — priorisiert
- [IN ARBEIT] #1 HIGH Scheduler-Freeze: findNextQueuedItem ohne activeTasks-Guard → synchroner
Admission-Loop dreht endlos wenn ein reset/overwrite-Item noch im activeTasks parkt (non-abort-
observing await, z.B. Integrity-Check). Fix: `if (this.activeTasks.has(itemId)) continue;`. TDD-Test
(Freeze-Repro mit non-abort Mock + resetItems) geschrieben.
- #2/#3 MED mega_debrid_cooldown:<ms> Delay verworfen — kein Parser (nur debrid_link_cooldown). Fix:
Parser fuer beide Praefixe, queueRetry mit echtem delayMs. (Erklaert Rapid-Retry-trotz-Cooldown im jf.zip.)
- #7/#8 MED Mega per-Account Daily-Usage wird am Tagesgrenze NIE resettet → Accounts faelschlich "am
Limit" → schrumpft MEIN neues serialized-limit. Fix: megaDebridAccountDailyUsageBytes in
ensureProviderDailyUsageFresh resetten.
- #4 MED transiente leere Web-Antwort → permanenter until-restart-Park (limitSignal vom generischen
"antwort leer"). Fix: limitSignal nur vom echten Daily-Limit (NO_SERVER_RE).
- #5 MED Web echte Bad-Credentials erreichen invalid-Branch nicht (werden ewig retried). Fix: echte
Web-Login-Fehlerphrasen in invalid-Branch.
- #6 MED onefichier/ddownload-Routing ignoriert autoProviderFallback=off. Fix: Guard in catch.
- #14 LOW Regex-Ordering classify: quota-Branch shadowt rate_limit. Fix: rate_limit vor quota.
- #9 LOW overwrite wipet frisch geclaimten targetPath via altem .finally.
- #10 LOW HTTP416 shared counter mit genericErrorRetries.
- #11 LOW fresh-retry preempt typed transient handlers.
- #12 LOW 15-failure-shelve + shared counters → mehr Retries als retryLimit.
- #13 LOW self-poison: queue-wait zaehlt zu elapsedMs → abort-cooldown (Analog zu meinem Web-Verdacht #1).
## Refutiert / Nicht-Bug (11) — nicht anfassen
providerStartReservations dead-state; debrid_link_cooldown cleanup; supprimé-fallthrough; mega-web 180s
aborts whole rotation; EMA-removed-premise; quota-no-park asymmetry; connectApi single-flight cancel-couple;
per-account queue chain-break (NON-BUG); mega-web slot-hold (NON-BUG); provider abort-vs-timeout heuristic;
daily-limit aggregate early-exit.
## Fixes (TDD, mit Test + Release)
### Batch 1 → v1.7.215 (Suite laeuft)
- [x] #1 HIGH Scheduler-Freeze: `findNextQueuedItem` activeTasks-Guard. Repro-Test (ohne Fix haengt der
Event-Loop so hart, dass nicht mal vitest-Timeout feuert = Freeze empirisch bewiesen). Mit Fix 288ms.
- [x] #2/#3 MED parseMegaDebridCooldownRetry (export) + Handler VOR transient/generic branch → Item wartet
den ECHTEN Cooldown (min ueber alle Accounts) statt 5s-Busy-Loop. 5 Parser-Tests.
- [x] #7/#8 MED megaDebridAccountDailyUsageBytes Reset in ensureProviderDailyUsageFresh (laeuft via
getSnapshot, also auch im Stall). Test: Tagesgrenze → leer.
- [x] #14 LOW rate_limit-Branch VOR quota (quota matchte "limit" in "rate limit"). Test: rate_limit-Kategorie.
- [deferred] #4 empty-response→until-restart-park: 3-consecutive-streak ist reale Mitigation gegen transiente
Blips; Mega-empty-Semantik nicht sicher verifizierbar → kein Blind-Change.
### Strategie-Update (LIVE-Server, Advisor-bestaetigt)
- LOW-Fix-Schwelle HOCH: nur fixen bei NULL plausibler Regression UND einem Test der OHNE Fix rot ist.
Sonst dokumentieren ("gefunden & charakterisiert" ist valides Audit-Ergebnis). Server laeuft live,
auto-update, ~1 TB/h → jede unnoetige Verhaltensaenderung = Risiko.
- Releases BUENDELN (alle 2-3 Runden / Roll-up), nicht pro Fix. Weniger Update-Churn auf dem Live-Server.
- #5 NICHT raten: conversion.log faengt den echten Web-Login-Fehler-String schon (web-queue-Phase-Detail).
Aus naechstem Bundle ernten, dann erst invalid-Phrasen ergaenzen. Kein Phrasen-Halluzinieren.
- #13 defer: Web ist seit v1.7.214 nur noch Fallback (API-first), Selbstcooldown trifft kaum mehr;
braucht workMs-Threading → groesserer Eingriff, nicht LOW-billig.
- Vor Runde 4-5: SYNTHESE-Pass — ist Retry/Cooldown/Rotation END-TO-END kohaerent selbstheilend?
## Runde 3 (Failover/Reconnect/Cooldown-Lifecycle/Scheduler/IPC-Toggle/Updater) — Workflow wcwztx7e9
7 confirmed / 1 refuted. provider-failover-Finder crashte (Socket) → diese Dimension via MEINER
unabhaengigen Code-Verifikation abgedeckt (60s-Timeout kappt Failover, debrid.ts 3845 + dl-mgr 8814).
### Batch 3 → v1.7.217 (GEFIXT, je rot-bewiesener Test)
- [x] #R3-1 HIGH (3/3) Self-Cooldown bis Neustart — DER vom Nutzer gemeldete „Tool sperrt sich selbst".
(a) limitSignal aus MEGA_DEBRID_NO_SERVER_RE-Zweig entfernt (Hoster-Problem != Account-Limit),
(b) until-restart-Park laeuft jetzt zum Tagesreset (lokale Mitternacht) ab statt MAX_SAFE_INTEGER →
heilt <=24h selbst. Texte „bis Neustart"→„bis zum Tagesreset". Commit 76b3f99.
- [x] #R3-5 HIGH (3/3) Fehlgeschlagenes Update → Queue-Stillstand bis Neustart. runInstallWithResume()
(neue reine Funktion) resumt bei started:false UND throw. Commit dfd1926.
- [x] #R3-3 MED (3/3) Account-Edit ueberschreibt megaDebridPreferApi. Hardcode entfernt → ...settings
reicht Nutzerwahl durch. Commit 1e04b7b.
### Dokumentiert / NICHT autonom gefixt (Advisor-Disziplin)
- #R3-2 HIGH (2/3, UMSTRITTEN) Mega API/Web-Account-Zeilen teilen EINE login-only Enable-Flag →
Toggle spiegelt sich (= Nutzer-Report „API aus → Web an"). KEIN Auto-Fix: Daten-Modell-Fix braucht
Settings-Migration (kann deaktivierte Accounts re-aktivieren), UI-Collapse = Layout-Redesign (Nutzer
UI-Geschmack-sensibel). → DEM NUTZER vorlegen: gemeinsamer Schalter vs. unabhaengige pro-Modus-Flags.
- 60s-Failover-Kappung (HIGH, mein Fund, Finder gecrasht) — debrid.ts 3845 wertet JEDEN combined-signal-
Abort (cancel ODER 60s-Timeout) als kein-Failover; langsamer Provider1 hungert Provider2 aus, auch ueber
Retries. Post-214 (API-first) groesstenteils latent. → eigene Runde: gecrashten Finder ERST neu laufen
lassen (unabhaengige Verifikation fehlt), dann per-Provider-Timeout-Design mit Advisor. NICHT in 217.
- #R3-6 MED (3/3) Update-Mirror-Failover feuert nie (nur Gitea). NICHT fixen: aendert den Update-Fetch-Pfad
= der Kanal, ueber den jeder Fix den Nutzer erreicht; faellt heute sicher aus (App behaelt alte Version).
- #R3-4 LOW (2/3) providerPrimary kann auf disabled Mega normalisieren — self-heilt zur Laufzeit. Belassen
(Refuter: Fix riskanter als Bug — schreibt persistierte Absicht um).
- #R3-7 LOW (3/3) Update-Integritaet hash-only, kein Authenticode — ehrliche Grenze, faellt sicher aus.
- REFUTIERT (0/3): all-accounts-parked wirft plain error ohne cooldown-retry-Token.
## Runde 4 (Failover-Routing-Slice + Entscheidungs-Doku)
Follow-on aus R3-Failover-Fund. Advisor-Disziplin: nur die SICHERE Scheibe autonom, der
Produkt-Tradeoff geht an den Nutzer (tasks/entscheidungen-offen.md).
### Autonom gefixt (TDD, rot-bewiesen)
- [x] MED Failover-Routing: Manager berechnete bei Provider-Cooldown (>=20 Fehler in Folge,
auto-Fallback an) einen Ersatz-Provider (`findFallbackProviderNotInCooldown`), WARF ihn aber
weg — `unrestrictLink(item.url, signal)` ohne Hint → debrid.ts baut `order` neu aus
providerOrder und fuehrt WIEDER mit dem ausgebremsten Provider1 an (wahrsch. 60s-Timeout
verschwendet). Fix: reine `leadProviderChainWith(order, preferred)` (debrid.ts, export) +
4. optionaler Param `preferredLeadProvider` an `unrestrictLink`; Manager reicht den Ersatz
durch (dl-mgr 8772/8828). REORDER nicht SKIP → ausgebremster Provider bleibt als letzter
Notnagel in der Kette, kein Stranding. All-cooled-Fall erreicht `unrestrictLink` gar nicht
(else-Zweig queueRetry'd). Tests: integration (control=realdebrid, preferred=debridlink) +
3 reine Helper-Tests (null→unchanged, in-order→leads+keeps-all, not-in-order→unchanged).
Suite 875 gruen, tsc=6. Commit folgt; HALTEN fuer Roll-up-Release (kein HIGH/dringend).
### An den Nutzer vorgelegt (NICHT autonom) → tasks/entscheidungen-offen.md
- 60s-Failover-Kappung (HIGH): A) pro-Provider-Timeout (Failover immer, aber bis 3×60s
Worst-Case, Drehregler ueber Pro-Provider-Wert) vs B) globales Budget mit Failover-Reserve
(langsamer Provider1 frueher abgeschnitten). Produkt-Tradeoff = Nutzerwahl. Post-214 weitgehend latent.
- Gespiegelter Mega API/Web-Schalter (HIGH, #R3-2): gemeinsamer Schalter vs unabhaengige
pro-Modus-Flags (Migration + UI-Redesign noetig, Nutzer UI-sensibel).
### SYNTHESE-Pass (Cross-Layer: Manager-Cooldown-Keys vs Debrid Account/Daily-Park) — Workflow wrfxpjudj
2 Kandidaten, 1 confirmed (3/3), 1 refuted (0/3). Advisor-Hypothese (Key-Mismatch) WIDERLEGT.
- **REFUTED (0/3) KSM-1 Key-Mismatch:** normalizeProviderOrder (storage.ts:144/411) speichert IMMER
den aufgeloesten 'megadebrid-api'/'-web', NIE den virtuellen 'megadebrid'. Also fallen
Cooldown-WRITE (recordProviderFailure), CHECK (getProviderFailureKeyForItem), CLEAR und READ
(findFallbackProviderNotInCooldown) auf denselben aufgeloesten Key → KEINE Divergenz. Der
986fbab-Routing-Fix ist fuer den Mega-Fall nachweislich sicher (No-Stranding haelt).
- **CONFIRMED (3/3) LOW MEGA-UNTILRESTART-MISCLASS (GEFIXT):** Der untilRestart-Park-Throw
(debrid.ts:2167) trug KEINEN Maschinen-Token → Manager-Catch klassifiziert ihn als generischen
Unrestrict-Fehler (isUnrestrictFailure matcht "mega_debrid") → Retry alle ~2min den ganzen Tag
+ recordProviderFailure (Circuit-Breaker-Verschmutzung), statt einmal bis Tagesreset zu parken.
Default-Config (retryLimit=0=∞) self-heilt um Mitternacht, KEIN Stranding. Genau die
Round-3-untilRestart-Park-Absicht, die hier unterlaufen wurde. Predates 986fbab.
Fix: debrid.ts emittiert jetzt `mega_debrid_reset_park:<msBisReset>:` (megaDebridDailyParkExpiry);
neue reine parseMegaDebridResetPark (kein 15min-Clamp, 26h-Cap) + Catch-Branch VOR der
Cooldown-Klassifikation queued bis Tagesreset OHNE recordProviderFailure. Rot-bewiesen
(Parser-Tests + debrid-Token-Assertion). tsc=6.
- **End-to-end-Verdikt:** Retry/Cooldown/Rotation/Failover ist unter Default-Config kohaerent
self-heilend (jeder Park hat Zeitgrenze + Auto-Clear). Einzige Rest-Inkohaerenz war die
Layering-Naht oben (in-memory untilRestart-Park unsichtbar fuer getAvailableMegaDebridAccounts
+ isProviderDailyLimited) — mit dem konservativen String-Klassifikations-Fix geschlossen.
Der breitere Fix (Selektierbarkeit cooldown-aware machen) bewusst NICHT gemacht (groesserer
Blast-Radius auf Live-Server).
## Boot-Verifikation (Advisor-Punkt: Suite gruen != App bootet)
Smoke-Test des gebauten 1.7.218-Binaries (release\win-unpacked, throwaway userData, leere Session):
laeuft >10s stabil, spawnt die normalen 4 Electron-Prozesse (main+GPU+renderer+utility), schreibt
userData/runtime → BOOTET. Boot-Pfad (main.ts-Boot, app-controller-Init, Window/IPC-Registrierung)
von diesem Audit NICHT angefasst; alle editierten Methoden sind Download-Zeit (von der 884er-Suite
importiert+konstruiert). Risiko Startup-Regression empirisch ausgeschlossen.
## Advisor-Leitlinie (Stand Runde 8)
- Nach Runde 8 KEINE weitere Discovery-Runde, sondern ein SYNTHESE/Regressions-Pass ueber den
KUMULATIVEN Diff (Interaktion der 6 gestagten/releasten Fixes) — Confirmed-Yield faellt (R5:0/R6:1/R7:1),
Risiko ist jetzt Fix-Interaktion, nicht unentdeckte Bugs.
- 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
Release. Gitea (Live-Update-Quelle): .../releases/tag/v1.7.218. GitHub-Mirror (kuratierter
Single-Commit ohne CLAUDE.md/tasks/, Leak-Check sauber): Sucukdeluxe/multi-debrid-downloader v1.7.218.
Advisor war die GANZE Session ueberlastet → autonom released auf Basis: 3x rot-bewiesene Tests
(je per Temp-Revert verifiziert), volle Suite 882 gruen, tsc=6, unabhaengige Code-Verifikation +
Multi-Agent-adversarisch, Routing-Fix frueher advisor-gesegnet, Praezedenz 215/216/217 autonom.
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.
- **CONFIRMED HIGH (GEFIXT, known-total) RANGE-1 Silent-Mid-File-Corruption:** Auf dem LETZTEN inneren Versuch
wird ein injizierter Garbage-Tail nicht zurueckgespult (`attempt < maxAttempts`-Guard greift nicht),
resumeRewindBytesNextAttempt ist funktions-lokal (ueberlebt downloadToFile-Re-Entry nicht), der Outer-Handler
nimmt fuer terminated-class den generic-retry-Zweig (kein File-Delete), und der Fresh-Link-Resume haengt
echte Bytes NACH dem Garbage an → exakt-laengen-Datei besteht die Length-only-Completion-Pruefung; bei
manifestlosen .mkv/.mp4 nie erkannt. Fix: Rewind-vor-Throw am Exhaustion-Punkt (truncate letzte
RESUME_REWIND_BYTES + downloadedBytes ZUERST setzen → binary-Re-Entry-prealloc-reconcile robust auch bei
truncate-Fehler), GEGATED auf `totalBytes != null && > 0`. Check A verifiziert: known-total → rewound
size < totalBytes=minBytes tryFinalizeItemFromDisk(9238) REJECTET Re-Entry ueberschreibt Garbage.
EHRLICHER Scope (Advisor): GEFIXT fuer known-total Medien (Debrid liefert fast immer fileSize);
**null-total behaelt die separate, vor-bestehende Silent-Corruption** unter dem dokumentierten
Size-only-Validation-Blindspot (durch Rewind NICHT fixbar — kein Laengensignal; nur Hard-Reset wuerde
helfen, groesserer Eingriff). Rot-bewiesen: Cross-Call-Test (final-attempt Garbage → Exhaustion →
queueRetry → 2. downloadToFile → CONTENT-Gleichheit); ohne Fix Length-Assert gruen + Content-Assert rot.
- **CONFIRMED MED (GEFIXT) REWIND-TRUNCATE-FAIL:** Das `finally` setzte resumeRewindBytesNextAttempt=0
UNBEDINGT, auch wenn die Rewind-truncate (9633) warf → transienter win32-EBUSY/AV-Lock-Fehler liess den
Garbage-Tail + cleart das Flag (nie retried). Fix: Reset NUR im Success-Branch → fehlgeschlagenes Rewind
wird naechsten Versuch erneut probiert. Defensive Haertung (Advisor "ship it"); Happy-Path von Test 1113
+ RANGE-1-Test abgedeckt.
- **DOKUMENTIERT, nicht gefixt (MED, schwaechste, Workload-immun) PREALLOC-ZEROS-ACCEPTED:** win32-Prealloc-
Nullen am Ende als komplett akzeptiert fuer NICHT-binaere Typen (1MB-Slack) wenn truncate skip/faellt.
Dominante Medien/Archive sind immun (threshold=0). Narrow Conjunction (non-binary >20MB, Gap<1MB, Crash/
truncate-fail). Fix bekannt (binary-strict footprint im 416-accept + recovery-finalize), aber deferred.
- **Refuted (1/3) TRUNC-1:** fsync auf resume-append fehlt — als Power-Loss-Edge eingestuft, nicht confirmed.
- **Cross-cutting (Advisor: NICHT jetzt anfassen):** Size-only-Completion-Validation ist der gemeinsame
Blindspot; kein billiger Content-Check fuer manifestlose Medien → validateDownloadedFileCompletion NICHT
umbauen (Risiko 4. Bug). Punkt-Fixes sind korrekt; Blindspot bleibt langfristiges Item.
## Runde 7 (Post-Download: Extraction + Video-Processor + Companion/Orchestrierung) — Workflow wcxk08n5i
3 Finder + adversarisch verifizieren. 2 confirmed, 0 refuted. Schliesst den deferred-Extraction-Cluster ab.
- **CONFIRMED MED (GEFIXT) VP-1 Falsche Tonspur:** isGermanStream (video-processor.ts:99-108) wertet die
Titel-Regex /\b(german|deutsch)\b/ fuer JEDEN nicht-deutsch-getaggten Stream — NICHT auf "Sprach-Tag fehlt"
gegated, obwohl der Kommentar (104-106) genau das als Absicht nennt. pickAudioTrack (124, EINZIGER Caller)
nimmt den ERSTEN Treffer → eine eng-Spur mit Titel "...German..." vor der echten ger-Spur GEWINNT → Remux
behaelt Englisch, verwirft die korrekte dt. Spur, ersetzt das Original atomar in-place (500) und strippt
.DL. → irreversibler Datenverlust + falsche Sprache, als Erfolg gemeldet. Single-Trigger (nicht compound).
Betrifft genau die vom Nutzer bestaetigte dt.-Tonspur-Funktion [[project_german_audio_feature]]. Fix:
`if (lang) return false;` VOR dem Titel-Fallback (deckt sich mit der Kommentar-Absicht). Rot-bewiesen
(2 Tests: eng-Titel-"German" vs echte ger → audioRelIndex 1; eng-Titel-"Deutsch entfernt" → skip). tsc=6.
- **DOKUMENTIERT, nicht gefixt (LOW compound) PP-1/#13 Companion-Overwrite:** renameCompanionFiles/
moveCompanionFiles (dl-mgr 3735/3791) ohne Uniqueness-Guard (anders als MKV via buildUniqueFlattenTargetPath);
cross-volume copyFile ohne COPYFILE_EXCL (3696) → ueberschreibt einen vorhandenen Orphan-Companion still
(nur .srt/.idx/.nfo, nie Archiv/Video). Compound (Orphan + Namenskollision), Sekundaerdateien, Integrations-
TDD noetig → dokumentiert. Fix bekannt (Uniqueness-Guard spiegeln).
- **Deferred-Extraction-Cluster re-klassifiziert (aus Runde 2):** #10 CRC-Kleindatei-Delete BENIGN (failed
Archive aus cleanupSources ausgeschlossen, nicht geloescht); #12 7z-Exit-1-als-Erfolg BENIGN (nur Erfolg
wenn KEINE Error-Marker — echte CRC/Korruption korrekt als Fehler); #11 resume-empty-output NOT-CONFIRMED
(compound, unbewiesene Hybrid-Collect-Ordering-Annahme); #13 = PP-1.
## Runde 6 (Retry/Backoff/Error-Klassifikations-State-Machine + Disk/IO) — Workflow wqurq83ma
3 Finder + adversarisch verifizieren. 1 confirmed (2/3), 3 refuted. Schliesst den deferred-LOW-Cluster ab.
- **CONFIRMED HIGH (GEFIXT) SHELVE-LOOP-RETRYLIMIT:** Bei FINITEM retryLimit>=5 sind alle drei Per-Klasse-
Caps = retryLimit; die 15-Failure-Shelve-Zweige (dl-mgr 9166 stall + 9352 error) feuern aber auf
hartkodiertem `sum>=15` OBERHALB der Per-Klasse-Terminal-Fails und HALBIEREN danach alle Counter →
Per-Klasse-Caps werden nie gleichzeitig ueberschritten → Item failt NIE, schleift ewig, item.retries
waechst ueber das konfigurierte Limit, Slot gestrandet, providerFailures.delete besiegt wiederholt den
Circuit-Breaker → Hoster-Hammering. resetStaleRetryState rettet nicht (<=90s-Re-Admit haelt updatedAt
frisch < 10min-Stale). Default retryLimit=0=∞ NICHT betroffen (Shelve ist dort der gewollte Park-Backstop).
Fix: in BEIDEN Shelve-Zweigen vor dem queueRetry `if (configuredRetryLimit > 0 && item.retries >=
configuredRetryLimit)` → terminal failen statt requeue (∞-Modus unberuehrt). Rot-bewiesen (Single-Pass-
Integrationstest: genericErrorRetries=15 vorgeseedet + injizierter Generic-Error → status "failed" statt
requeue). tsc=6.
- **Deferred-LOW-Cluster re-klassifiziert (aus Runden 1-2):** #10 HTTP416-shared-counter BENIGN (maxHttp416Retries
eigenes Budget, speist Shelve-Summe NICHT); #11 fresh-retry-preempt BENIGN (One-Shot-Booleans);
STALL-CAP-OFF-BY-ONE real aber +1 (Zutat von CONFIRMED-1, kein eigener Strand); #13 queue-wait→elapsedMs
BENIGN (queueRetry resettet attempts/updatedAt, Stall-Detektor misst nur aktiven Download). #12 = der
gefixte Bug. DISK-1 (ENOSPC/EACCES via Generic-Budget) kein eigener Bug (in finite vom Cap + diesem Fix
begrenzt; in ∞ by-design) → optionale Klassifikations-Erweiterung, deferred bis Nutzer Full-Disk-Hammering meldet.
## Runde 5 (unberuehrte Subsysteme: auto-reconnect, Scheduler-Fairness, Crash/Persistenz) — Workflow w7woztsxx
3 Finder (reconnect / scheduler / persistence) → adversarisch verifizieren (3 Lenses). 1 Kandidat, 0 confirmed.
**Sauberes „kein bestaetigter Bug"-Ergebnis.** Auto-reconnect-Resume, Slot-Accounting/Fairness und
Crash-Recovery/targetPath-Lifecycle sind solide (jeder Park/Slot hat Auto-Clear, stop/reset/abort drainen
Waiter + nullen Counter).
- **DOKUMENTIERT, nicht gefixt (LOW, benign): PP-SEM-1** Post-Process-Semaphore (acquire/releasePostProcessSlot,
~7269-7304). Mechanismus real: ein geweckter Waiter, dem ein Fast-Path-Queue-Jumper im Microtask-Gap den
Slot klaut, laeuft trotzdem (Re-Check nach next.resolve() nicht autoritativ) und released spaeter einen nie
inkrementierten Slot → packagePostProcessActive UNTER-zaehlt um 1/Vorkommen. Wirkung: **Ueber-Admission**
(mehr als maxParallelExtract parallele Extraktionen) = gebundene CPU/IO-Verschwendung, NIE Strand/Loss/
Korruption. Der einzige Strand-Pfad (`<=0`-Guard skippt Waiter-Wake) war NICHT organisch erreichbar (nur
durch manuelles Nullen des Counters). Self-heilt bei jedem stop/reset/drain. Fix bekannt (Re-Check
autoritativ machen ODER Increment in den Releaser ziehen) + rot-testbar, aber benign → kein Live-Server-
Hotfix wert (LOW-Fix-Schwelle: Wirkung ist Verschwendung, keine Korrektheit).
## Runde 2 (Download-Ausfuehrung: stream/resume/disk/integrity/extract/persist) — Workflow whspc8ddv
14 confirmed / 7 refuted (>=2/3 adversarisch). Alle HIGH/MED unten unabhaengig am echten Code
verifiziert (Zeilen zitiert) bevor gefixt. Jeder Fix mit rot-bewiesenem Test, tsc bleibt 6.
### Batch 2b → noch nicht released (buendeln, dann v1.7.216)
- [x] #R2-1/4 HIGH Pre-alloc-stat-Reconciliation blaeht `written` auf Padding-Groesse auf → stille
Null-Byte-Korruption auf win32. reconcileFinalizedSize() (download-completion.ts, rein+getestet),
nur noch ABWAERTS-Korrektur bei preAllocated. Commit 2646cba.
- [x] #R2-3 HIGH Nicht-Archiv-".001" ohne Signatur wurde als extrahiert gezaehlt → ganze .00x-Familie
beim Cleanup geloescht (Datenverlust). skippedNonArchives (pathSetKey) aus cleanupSources gefiltert,
frisch + resume. End-to-end-Test. Commit 56bae4a.
- [x] #R2-9 MED + #R2-14 LOW Settings-async-Writer ohne Generations-Schutz → Lost Update; shutdown rief
cancelPendingAsyncSaves nicht. Eigener syncSettingsSaveGeneration (Spiegel des Session-Pfads) +
cancel in shutdown. Commit 1a33fc2.
- [x] #R2-6 MED Teildatei verwaist beim Entfernen eines laufenden Downloads (catch-early-return vor
Cancel-Cleanup). rmSync im catch vor dem return (nach Stream-Close, kein Race). Commit 2b639b7.
- [x] #R2-8 MED Hash-Manifest: Pro-Zeile-Algorithmus von Dateiendung ueberschrieben → gute Datei
geloescht bei fehl-etikettiertem Manifest. parseHashLine-Algorithmus uebernehmen. Commit 4578991.
- [x] #R2-7 MED Startup-Dedup ersetzt gute kanonische Datei durch kleineres Duplikat (+ EXDEV-Loss-
Fenster). Size-Guard (kanonisch >= Duplikat → behalten) + rename-zu-.dedupbak-Reihenfolge mit
Restore. Commit folgt nach voller DM-Suite.
- [deferred/dokumentiert] #R2-5 stream-end akzeptiert truncated download (ohne Laengensignal nicht
entscheidbar; Web ist post-214 nur Fallback) — nur WARN-Log sinnvoll, kein sicherer Fix.
- [deferred/dokumentiert] #R2-10 CRC-verifizierte Kleindatei vom suspicious-small-Heuristik geloescht;
#R2-11 Resume-empty-output-Bypass; #R2-12 7z-Exit-1-Warnung als Erfolg; #R2-13 Companion-.srt/.nfo-
Overwrite. Je narrow/heuristisch → charakterisiert, nicht blind gefixt (LIVE-Server-Schwelle).
### Batch 2 (commit, noch nicht released — buendeln mit Runde-2-Findings)
- [x] #6 MED onefichier/ddownload-catch respektiert autoProviderFallback=off (Guard nach abort-rethrow).
Test: 1fichier KO + Fallback aus → reject, mega getLink NICHT aufgerufen (rot-ohne-Fix beweisbar).
ACHTUNG-Notiz: replace_all matchte faelschlich auch den getLinkInfos-Filename-catch (~3614) → revertet
(Filename-Aufloesung muss nicht-fatal bleiben). Nur die zwei Hoster-catch-Bloecke geaendert. Commit 21fb09b.
- [deferred LOW, dokumentiert statt blind-fix] #9 overwrite targetPath-wipe, #10 HTTP416 shared counter,
#11 fresh-retry preempt typed handlers, #12 shelve+shared counter, #13 queue-wait→elapsedMs, #5 Web-bad-creds.
→ je nur fixen wenn rot-ohne-Fix billig beweisbar + null Regression; sonst bleibt's charakterisiert.

View File

@ -1,164 +0,0 @@
# Offene Entscheidungen für dich (Audit-Loop 2026-06-17)
Diese zwei Punkte habe ich BEWUSST nicht autonom „gefixt", weil jede Lösung
einen Produkt-/Geschmacks-Kompromiss enthält, den du entscheiden solltest, nicht ich.
Beide sind verifiziert (Code-Zitat + Szenario), nur die Richtung ist deine Wahl.
---
## 1. Failover-Kappung durch globalen 60-Sekunden-Timeout (HIGH)
**Was passiert (belegt):**
`download-manager.ts` baut EINEN Timeout fürs gesamte Unrestrict:
```
const unrestrictTimeoutSignal = AbortSignal.timeout(getUnrestrictTimeoutMs()); // 60s
const unrestrictedSignal = AbortSignal.any([active.abortController.signal, unrestrictTimeoutSignal]);
```
Dieses EINE Signal geht an die komplette Provider-Kette in `debrid.ts`. Die 60s sind
also ein Budget für ALLE Provider zusammen, nicht pro Provider. Wenn Provider 1
(z.B. Mega-Web mit Account-Queue) das Budget verbraucht, dann sieht Provider 2 ein
bereits abgelaufenes Signal → `debrid.ts` wertet den Abbruch als „kein Failover" und
wirft, ohne Provider 2 echt zu versuchen.
**Aktuell weitgehend latent:** Seit v1.7.214 wird die API zuerst probiert (schneller Pfad),
Mega-Web ist nur noch Fallback. Das Szenario „langsamer Provider 1 hungert Provider 2 aus"
trifft in der Produktion derzeit selten. Deshalb dokumentiert statt dringend gefixt.
**Deine Entscheidung — zwei Richtungen (gleiche Spannung, andere Seite):**
- **A) Pro-Provider-Timeout:** Jeder Provider bekommt sein eigenes frisches Budget.
Failover bekommt IMMER einen echten Versuch.
*Kosten:* Worst-Case-Wartezeit pro Item steigt. Das Budget ist dabei ein DREHregler, kein
fixer Wert: 60s/Provider = bis 180s Worst-Case (3 Provider), 30s/Provider = bis 90s usw.
Du akzeptierst langsameres Worst-Case-pro-Item für vollständigeres Failover — und stellst
über den Pro-Provider-Wert ein, wie viel langsamer.
- **B) Globales Budget behalten, aber Slice für Failover reservieren:** Provider 1 wird auf
z.B. 35s gedeckelt, damit garantiert Zeit für Provider 2 bleibt.
*Kosten:* Ein legitim langsamer Provider 1 (Mega-Web-Account-Queue bis ~90s) wird früher
abgeschnitten → mehr Failover, auch wenn Provider 1 noch erfolgreich gewesen wäre.
Kernfrage, die nur du beantworten kannst: **Wie lange darf ein Item bei einem langsamen
aber funktionierenden Provider hängen, bevor wir ihn zugunsten des nächsten aufgeben?**
---
## 2. Gespiegelter Mega API/Web-Schalter (HIGH, in Runde 3 als 2/3 bestätigt)
**Was passiert (belegt):** Die Mega-Debrid API- und Web-Account-Zeilen teilen sich EINE
login-only Enable-Flag (`hasMegaDebridCredentials` gilt für beide; die Pro-Modus-Auswahl
läuft über `isMegaDebridModeEnabled(settings, "api"|"web")`). Dein Report: „API ausschalten
schaltet Web an" — weil der Schalter sich spiegelt.
**Warum ich es NICHT autonom geändert habe:**
- Daten-Modell-Fix (echte unabhängige Flags) braucht eine Settings-Migration, die
deaktivierte Accounts versehentlich re-aktivieren könnte.
- Die saubere UI-Variante (zwei echte unabhängige Schalter) ist ein Layout-Redesign — und
du bist UI-Geschmack-sensibel; das will ich nicht ungefragt umbauen.
**Deine Entscheidung:** Ein gemeinsamer Schalter (API+Web zusammen an/aus, klar beschriftet)
ODER zwei unabhängige Pro-Modus-Schalter (mehr Kontrolle, aber UI + Migration nötig)?
---
## 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
Neuversuch (mit frischem Link) hängte die echten Bytes DAHINTER an — die Datei hatte am Ende exakt die
richtige Größe und galt deshalb als fertig, obwohl mittendrin Müll steckte. Bei .mkv/.mp4 ohne Prüfsumme
fiel das nie auf. Jetzt wird der verdächtige Datei-Schwanz vor der Linkerneuerung zurückgespult, sodass der
Neuversuch ihn sauber überschreibt. Rot-bewiesener Test (Inhalt byte-genau geprüft, nicht nur die Länge).
Ehrlich eingeordnet: Das ist behoben für Dateien, bei denen der Anbieter die Größe meldet (fast immer der
Fall). Für die seltenen Fälle ganz ohne Größenangabe bleibt eine separate, schon vorher bestehende Lücke
(ohne Längensignal nicht über dieses Rückspulen lösbar) — dokumentiert als langfristiges Thema.
- Zusätzlich gehärtet: Ein fehlgeschlagenes Zurückspulen (z.B. Datei kurz von Virenscanner gesperrt) wird
jetzt im nächsten Versuch erneut probiert statt still übergangen.
- **Deutsche Tonspur: falsche Spur-Auswahl behoben (Datenverlust-Schutz):** Die Erkennung der
deutschen Tonspur hat den Titel-Text einer Spur („...German...") auch dann ausgewertet, wenn die
Spur bereits ein anderssprachiges Tag hatte (z.B. eine englische Spur mit „German" im Titel, wie
„German Commentary"). Lag so eine Spur VOR der korrekt mit „ger" getaggten Spur, wurde die falsche
(englische) behalten und die echte deutsche Spur beim Remux unwiderruflich verworfen — das Ergebnis
wurde als Erfolg gemeldet. Jetzt wird der Titel nur noch dann herangezogen, wenn gar kein Sprach-Tag
vorhanden ist (so war es ohnehin gemeint). Korrekt getaggte deutsche Spuren gewinnen jetzt immer.
Rot-bewiesener Test. (Noch nicht released — wird mit der nächsten Runde gebündelt.)
- **Endlos-Wiederholung bei festem Wiederholungslimit behoben:** Wenn du ein FESTES Retry-Limit
(z.B. 5) eingestellt hattest UND ein Link sprunghaft verschiedene Fehlerarten produzierte
(mal Umwandlungs-Timeout, mal Abbruch mitten im Download, mal allgemeiner Fehler), konnte ein
Eintrag in einer Endlosschleife hängen: eine interne „Viele-Fehler"-Pause halbierte die Zähler,
sodass das eingestellte Limit nie erreicht wurde — der Eintrag scheiterte nie, blockierte dauerhaft
einen Download-Slot und hämmerte den Anbieter (weil dabei auch die Anbieter-Sperre zurückgesetzt
wurde). Jetzt wird das von dir eingestellte Limit hart eingehalten: nach N Versuchen scheitert der
Eintrag sauber. Standard-Einstellung („unendlich", der Auslieferungs-Default) war nie betroffen.
Rot-bewiesener Test.
- **Mega „bis Tagesreset gesperrt" parkt jetzt wirklich (statt alle 2 min neu zu versuchen):**
Wenn ALLE Mega-Accounts wegen wiederholt leerer Antworten bis zum Tagesreset geparkt waren,
hat das Tool den Fehler bisher als normalen Umwandlungsfehler behandelt und den ganzen Tag
alle ~2 Minuten neu probiert (und dabei den Provider-Circuit-Breaker mit Fehlern vollgemüllt).
Jetzt erkennt es den Park und legt das Paket EINMAL bis zum Tagesreset schlafen — genau das,
was der „bis Tagesreset"-Park eigentlich erreichen sollte. Bei Standard-Einstellungen heilte
sich das vorher schon um Mitternacht selbst (kein Datenverlust), war aber unnötige Log-Flut
und Churn. Rot-bewiesener Test.
- **MED Failover-Routing:** Wenn ein Provider in den Manager-Cooldown läuft (≥20 Fehler in
Folge) und auto-Fallback an ist, hat der Manager bisher zwar einen Ersatz-Provider berechnet,
ihn aber WEGGEWORFEN — die Kette führte trotzdem wieder mit dem ausgebremsten Provider an.
Jetzt wird der Ersatz-Provider als „Lead" durchgereicht und die Kette führt mit ihm an, OHNE
einen Provider zu verlieren (der ausgebremste bleibt als letzter Notnagel in der Kette).
Greift nur, wenn der Provider bereits nachweislich degradiert ist → strikt-besser-wenn-aktiv,
kein Timeout/Cancel-Vertrag berührt. Rot-bewiesener Test. (Hält für Roll-up-Release bereit.)

View File

@ -1,31 +1,5 @@
# Lessons
## 2026-06-17 — "Permanent/tot" NIE annehmen ohne Transienz-Gegenprobe (supprimé war transient)
**Muster:** Mega-Debrid lieferte 479x "Fichier supprimé chez l'hébergeur". Ich nahm
"supprimé = gelöscht = toter Link" als permanent an, baute Fix (sofort scheitern, kein
Web-Fallback) + released v1.7.210. Der User fragte: "welcher Link soll tot sein, hast du
das hinterfragt?" Gegenprobe an den Logs: von 18 Links mit "supprimé" haben **4 Sekunden
später ein OK** geliefert (1x Web, 3x API-Retry 766s). Der Fehler war TRANSIENT. 210
hätte erholbare Links dauerhaft gekillt. Korrektur v1.7.211: temporär statt permanent.
**Regel:**
- Bevor ein Fehler als permanent/fatal/tot klassifiziert wird: an echten Daten prüfen, ob
derselbe Link/dieselbe Ressource mit demselben Fehler **jemals danach ein OK** bekam.
Intersection(failed-links, ok-links) ≠ ∅ → transient → permanent-Klassifizierung ist
falsch. Wortbedeutung ("supprimé"=gelöscht, "deleted") beweist KEINE Permanenz —
besonders bei flakigen Multihostern (Mega-Debrid), die per-Link kurzzeitig falsch melden.
- **Die Linse (Advisor):** bei jedem Fehlersignal fragen — ist das über den ACCOUNT
(→ ggf. Cooldown), über den LINK PERMANENT (→ Item scheitern), oder nur über DIESEN
VERSUCH (→ Retry)? Beide Bugs hier (Account-Cooldown-Vergiftung UND falsch-permanent)
waren derselbe Fehler: ein Per-Versuch-Signal als account-/link-globaler Zustand behandelt.
- Wieder die 05-31-Regel verletzt (empirisch bestätigen vor Release). Wenn der User
skeptisch nachfragt ("hast du das hinterfragt?"), ist das fast immer ein echter
ungeprüfter Sprung — sofort an Daten gegenprüfen, nicht verteidigen.
- Retry-Pacing verifizieren, nicht annehmen: cooldownMs:0 entfernt Account-Cooldown,
aber der Download-Manager bremst per 5s-Exponential-Backoff (unrestrictDelayMs) pro
Item — getraced, weit unter Mega-Debrid 50 req/s. Account-Cooldown ≠ Retry-Pacing.
## 2026-05-31 — Fix-Diagnose EMPIRISCH bestätigen, bevor man released (Timeout ≠ Account-Hänger)
**Muster:** "acc2/acc3 nie versucht" wurde als "acc1 hängt → Per-Account-Timeout +

View File

@ -1,42 +1,287 @@
# MCP-Ferndiagnose (Goal, ultracode) — v1.7.223
# Real-Debrid-Downloader — Tasks (Stand 2026-06-10)
## 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."
**Status:** Webhook-Audit ABGESCHLOSSEN + released (v1.7.195). Davor: Bug-Audit (v1.7.189/190),
Sichtbarkeits-Paket (v1.7.191-194). Verbleibende QoL-Kandidaten weiter unten.
## 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).
---
## 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).
## ✅ Webhook-Bug-Audit — RELEASED v1.7.195 (2026-06-10, Gitea + GitHub-Mirror 37430ba)
## 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).
User-Goal: "intensiv prüfen obs Bugs gibt wodurch Webhooks nicht getriggert werden + drumrum".
Workflow: 6 Finder → 29 adversarial bestätigte Findings (0 widerlegt, 65 Non-Findings),
dedupliziert auf 16 Themen in 6 Fix-Commits. Volle Gate: tsc=6, 801 Tests (+9), self-check, build.
## 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.
### Gefixt (Commits f060d02, 99a2459, 8acb22d, 3fb9e85, 5f8b02e, 86d935e + Test-Commit)
1. **notify.ts Zustellung** (N5/RATELIMIT HIGH): Send-Queue seriell + 450ms-Gap (Discord
5/2s-Bucket), 429 mit retry_after (Sek→ms), 5xx/Netz-Backoff-Retries, Body-Konsum,
surrogat-sichere 2000er-Kappung, Invalid-URL-Warnung. 17 notify-Tests.
2. **Terminal-Abdeckung** (N1/DEDUP-2 HIGH, N2 HIGH, N4): refreshPackageStatus benachrichtigt
JEDEN Failed-Übergang (success===0-Gate weg — Mixed-Paket mit letztem Fail = häufigste
Reihenfolge verlor den ❌) + History für Mixed; 3 Early-Return-Fehlerpfade (416/toter
Link/Debrid-Link) riefen refreshPackageStatus nie → Paket hing bis Neustart auf
downloading; skipItems finalisiert jetzt jedes terminal gewordene Paket.
3. **Dedup-Lifecycle** (DEDUP-1 HIGH, -3, -4): recoverRetryableItems + retryExtraction/extractNow
geben den Notify-Marker frei (Recovery-✅ kam sonst NIE); retryExtraction tritt Run unbedingt
bei; addTrailingPostProcessPackageIds erhält Run-Mitgliedschaft laufender Entpackungen an
allen 5 runPackageIds-Ersetz/Clear-Stellen; Marker-Freigabe bei endgültig gescheitertem Send.
4. **Run-Lifecycle** (RUN-1..4): stop() schickt "Durchlauf gestoppt"-Summary; Scheduler-Respawn
in finally heilt Stop→Start-Race (führerloser Run!); finishRun sagt "Downloads beendet +
Entpacken läuft noch" statt zu lügen; armScheduledStart beim Boot re-armt (geplanter Start
überlebte keinen Neustart; vergangene Zeit → log+clear statt Blind-Start).
5. **Settings/Security** (SET-2, SET-4): notifyUrl in importBackup-SENSITIVE_KEYS;
Support-Bundle notifications-Block (konfiguriert/plausibel/Toggles, ohne URL-Leak).
6. **Renderer** (R1, R2): Testen-Toast mahnt Speichern bei dirty Settings; Button disabled
während Quick-Action läuft (statt Klick still zu schlucken).
### Bewusst NICHT gefixt (verifiziert, Begründung)
- **SET-3** (rememberToken=false wipt notifyUrl nicht): Webhook als Credential zu wipen würde
Benachrichtigungen für rememberToken=false-Setups brechen; Konsistenz-Trade-off akzeptiert.
- **R3** (URL als Klartext-Feld statt password): Sichtbarkeit hilft beim Prüfen/Debuggen,
Einzel-Operator-Setup.
- **R4** (Boot-Window Draft-Race): generisches, vorbestehendes Settings-Hydration-Muster,
nicht notify-spezifisch.
- **DEDUP-5** (immediate-Cleanup schrumpft Zähler in der ✅-Meldung): kosmetisch, Policy-Kombi
beim User nicht aktiv. **RUN-5** (Quit mid-run still): nach Prozesstod nicht zustellbar.
**RUN-6** (Dauer-Fallback 1s): kosmetisch. **N6**: durch Marker-Freigabe + URL-Warnung subsumiert.
---
## ✅ QoL Sichtbarkeits-Paket — RELEASED v1.7.191 (2026-06-09, Gitea + GitHub-Mirror)
1. **Push-Benachrichtigungen** (e753ea1, v1.7.192 auf Discord-Webhook-JSON umgebaut —
{username, content}, Emoji-Titel, 2000-Zeichen-Cap): Settings notifyUrl + 3 Toggles; 3 Hooks
(Post-Process-Ende, refreshPackageStatus all-failed-Lücke, finishRun-Summary); Dedup-Set
Lifecycle wie historyRecordedPackages; Guard running||runPackageIds (Recovery pusht nicht).
2. **audioStripSummary am Paket** (2a1a554): PackageEntry-Feld + Status-Spalten-Badge
("Tonspur: 5 OK · 1 ohne DE-Tag", Tooltip mit Datei-Details); storage-Whitelist + Delta-Hash.
3. **"Letzte Fehler anzeigen"** (be4d54a): Hilfe-Menü → Error-Ring-Snapshot im Dialog,
Bestätigen-Knopf = in Zwischenablage kopieren.
787 Tests, tsc=6, self-check+build grün; latest.yml-path verifiziert; Mirror f61fbc4 clean.
## 🟡 QoL-Backlog — code-verifiziert mit Hook-Punkten (aus Ideation 2026-06-09)
Volle Details (exakte Zeilennummern, Verifier-Gotchas) im Ideation-Workflow-Output; Kurzform:
1. **Mega-Web Per-Account-Timeout** (M, Score 9) — 20s eigenes Timeout pro Account via
AbortSignal.any + Signal-IDENTITÄT (nie Error-Text!) im Rotations-catch VOR Z.~1979;
äußeres 60s-Budget bleibt. GOTCHA: tests/debrid.test.ts:1376 asserted Signal-Objekt-Identität
→ Test lockern. Nur mode==='web'.
2. **ffmpeg-Pfad als Setting + Testen-Button** (M, Score 9) — ffmpegPath/ffprobePath, Setter +
resetVideoToolingCache(); GOTCHA: auch im KONSTRUKTOR setzen (setSettings reicht nicht nach
Neustart); IPC CHECK_VIDEO_TOOLING; UI unter keepGermanAudioOnly-Toggle.
3. **Bibliothek-Batch Tonspur für Bestand** (M, Score 9) — Button neben Toggle; Kandidaten-Filter
aus keepGermanAudioOnlyImpl als shared Predicate extrahieren (Overlap-Guard NICHT schwächen);
sequenziell + Single-In-Flight + AbortController + mtime-Age-Gate.
4. **Auto-Reset gefailter Items bei Tageswechsel** (SM, Score 8) — Toggle, Default aus;
GOTCHA: NICHT synchron aus ensureProviderDailyUsageFresh (Re-Entranz: läuft in getSnapshot/
Scheduler) → über 60s-runtimeStatsTimer (app-controller ~164); resetItems löscht Teil-Downloads.
5. **POST /control am Debug-Server** (M, Score 8) — add-links/start/stop/pause; GOTCHA: über
AppController routen (Audit-Log), nicht manager direkt; Start-Konflikt-Guard beachten.
6. **Mobile Statusseite GET /ui + Remote-Toggle** (M, Score 8) — handgeschriebenes flaches HTML,
pollt /status+/errors; Toggle schreibt debug_host.txt um; GOTCHA: stop+start Race (EADDRINUSE
→ Server tot bis Neustart) → closeAllConnections/restartDebugServer.
7. **Mega-Cooldown-Status + aufheben** (S, Score 7) — listMegaDebridAccountCooldowns() export,
Snapshot-Feld, Badge + Button; GOTCHA: Keys sind `${id}:api|web` getrennt; lazy expiry.
8. **Status-Filter-Chips Downloads-Tab** (S, Score 7) — GOTCHAS: PackageCard-memo-Comparator
braucht neue Prop; Prädikat an 2 Stellen (6615 + visibleOrderIds 3546); VOR Rendering-Limit.
9. **Support-Bundle: Explorer zeigen + Desktop-Schnellweg** (S, Score 7) — filePath wird schon
returned, Toast ignoriert ihn nur; shell.showItemInFolder; preload-api.ts nicht vergessen.
Long-Tail: Low-Disk-Auto-Pause (S), Autostart/Tray (S).
---
## ✅ ERLEDIGT — Bug-Audit 2026-06-08 (Multi-Agent find→verify, 18 bestätigt)
Advisor-Triage: **A = einzige echte Daten-Verlust-Notlage** (zerstört echte Datei auf Platte)
→ zuerst, ALLEINE Release. **B verifiziert demoted:** applyRetroactiveCleanupPolicy/
removePackageFromSession löschen KEINE Platten-Dateien (nur Session/Queue-Einträge + ggf.
History-Eintrag) → Queue-Integrität, nicht Daten-Verlust → in v1.7.190-Batch.
Sequenz: Release 1 (v1.7.189) = **A allein**; Release 2 (v1.7.190) = B/I,C,D/E,F,G,H,J,L,M,N,O,P,Q.
Ein Commit pro Fix, jeder einzeln verifiziert. **K übersprungen** (auto-rename-Reorder,
schlechtestes Risiko/Nutzen, kann für diesen User gar nicht feuern).
### Release 1 — Daten-Verlust-Stopper (v1.7.189, A ALLEIN)
- [x] **A** `video-processor.ts` atomic-replace zerstörte bei Windows-Lock BEIDE Kopien
(rm(original) VOR bestätigtem Replace + outer-catch rm(temp) → 0 Kopien). **GEFIXT:**
atomic replace-over + `renameWithRetry` (EBUSY/EACCES/EPERM/EEXIST, Backoff 200/500/1000ms),
rm-first-Fallback entfernt, **unique** Temp-Name (`~rd<pid><rand>`, löst auch C-Kollision).
Advisor bestätigt Ansatz besser als bak-dance (kein Missing-File-Window). 3 neue Tests
(Recovery + Retry-Pfad), 41 video-processor-Tests grün, tsc=6 (Baseline). Commit 189af22.
### Release 2 — v1.7.190 (GEFIXT + verifiziert, ein Commit pro Fix)
- [x] **L+M** video-processor.ts zu weite Deutsch-Erkennung. isGermanStream Titel-Fallback nur
ganze Wörter (ger/deu raus → konnten falsche Spur picken + echte dt. löschen); looksLikeGerman
Release 'dubbed' raus (ital./franz. Dub triggerte German-first). 2 Negativtests. Commit 272a41a.
- [x] **H** logger.ts flushAsync slice-snapshot korrumpiert bei 1MB-Cap-Trim während await →
ungeschriebene Zeilen verloren. Move-snapshot (Buffer auf [] übernehmen) + Requeue bei
Schreibfehler. Commit 4432fa2.
- [x] **J+Q** download-manager. J: runPackagePostProcessing finally löschte Map-Eintrag ohne
Identity-Guard → Abort+Neustart-Race riss neuen Task raus (Waise + Doppel-Lauf); jetzt nur
löschen wenn Map noch auf DIESEN Task/Controller zeigt (handle-Objekt wegen TS2454). Q:
collectFilesByExtensions filtert `~rd`-Temp-Präfix (crash-verwaiste Teil-Remuxe nie ins
Library). Commit 3c33b98.
- [x] **P** extractor.ts nested-Resume-Keys (`nested:<name>`) bei jedem extractPackageArchives
gepurged → verschachtelte Archive beim Resume neu entpackt; `startsWith("nested:")` im Prune
übersprungen. Commit 61a8304.
- [x] **B/I** app-controller.ts importBackup settings-only purgte LIVE-Queue (Dateien blieben auf
Platte) + rollte Usage-Zähler zurück. Fix: setSettings({suppressRetroactiveCleanup}) +
overlayLiveUsageCounters (extrahiert+wiederverwendet, inkl. Key-Filter). Commit dc05b51.
### Verifiziert KEINE Bugs / bewusst NICHT angefasst (Advisor-Disziplin: erst belegen, dann ändern)
- **G** dropItemContribution "subtrahiert Session-Totals nicht" → **KEIN Bug**: Test "keeps
cumulative session totals when completed items are removed" kodifiziert die Absicht (Session-
Zähler kumulativ, divergieren bewusst von der Item-Map; Retry-Pfad zieht ab, weil neu geladen
wird). Fix-Versuch ließ den Test failen → revertiert, Klarstellungs-Kommentar gesetzt.
- **N** stripDualLangFromFileName "Kollision" → **bereits geguarded**: existsAsync-Skip verhindert
Überschreiben; Remux machte Inhalt eh deutsch-only; collect strippt `.DL.` downstream. Residual
= generischer Rename-TOCTOU (in JEDEM Rename-Pfad), kein spezifischer Bug hier.
- **D/E** abort-Klassifizierung über signal.reason statt Text → **deferred (Robustheit, kein
Live-Bug auf User-Pfad)**. BELEGT: mega-web-fallback normalisiert JEDEN Abort (Timeout UND
Cancel) zu `new Error("aborted:mega-web")` → aktueller Guard `/aborted/i && !/timeout/i` FEUERT
→ v1.7.187-Cooldown LÄUFT auf dem Web-Pfad (User-Pfad). Einzige Imperfektion: Cancel >8s wird
fälschlich gecooled (minor). Empirisch bestätigt: `AbortSignal.any([ac,timeout]).reason?.name===
'TimeoutError'` (timeout) vs string/AbortError (cancel) — falls je gebaut: signal.aborted-gaten,
reason.name nutzen, Text-Fallback behalten, reason-Test. Hoch-Risiko (kritischer Unrestrict-Pfad
JEDES Downloads) → nicht für Robustheit anfassen. API-Pfad-Abort-Text nicht erschöpfend geprüft.
- **E** "API 'cancel'-Pfad umgeht" → **nicht real**: kein `'cancel'`-throw im Code gefunden.
- **O** classifyAccountFailure abort-Branch tot → **stehen lassen**: tot NUR wegen aktueller
Text-Interception; ein signal.aborted-gated D/E würde ihn wiederbeleben. Kein Kosmetik-Churn.
- **F** Mega-Web empty-streak Concurrency → **N-shaped, deferred**: Streak wird bei Erfolg (1956)
+ Nicht-Limit-Fehler (2005) gecleart; "bis Neustart gesperrt" ist bewusste Tageslimit-Logik,
Restart-cleared; Mega-Web single-flight → Concurrency greift nicht. Keine fühlbare Schädigung
konstruierbar → keine Park-State-Maschinerie.
- **C** → in A subsumiert (unique Temp-Name). **K** übersprungen (auto-rename-Reorder, Risiko≫Nutzen).
---
## 🟢 OFFEN — Backlog (optional, nie begonnen)
### ✅ Mega-Web Account-Rotation überspringt Account 3 — GEFIXT 2026-06-08 (v1.7.187)
**Fix:** Ein Mega-Web-Account-Abbruch (geteiltes Timeout feuert während der Account lief)
setzt jetzt einen 2-min-Cooldown auf den Account (nur wenn er ≥8s lief, sonst = User-Cancel,
RD_MEGA_ABORT_MIN_RUN_MS env). Dadurch überspringt der download-manager-Retry diesen Account
und rotiert zum nächsten (debrid.ts, abort-Handling im Rotations-catch, vor classifyAccountFailure).
Log-Event `TIMEOUT_COOLDOWN` (gelb, "Timeout/Abbruch → nächster Account beim Retry") statt
rotem "fataler Fehler" (App.tsx:1141 Label). 2 Regressionstests (Cooldown gesetzt → Call 2
rotiert; Quick-Abbruch → kein Cooldown). EHRLICH: fixt Korrektheit, NICHT Latenz — Account 1
brennt weiter ~60s ins Timeout bevor der Retry auf Account 2 wechselt (instant-Failover bräuchte
per-Account-Timeout = größerer Eingriff, bewusst verschoben). Advisor-gegengeprüft.
**(Ursprüngliche Analyse — Symptom & Mechanismus, zur Doku belassen)**
**Symptom (User):** 3 Mega-Debrid-Web-Accounts aktiv, Rotation pendelt aber nur zwischen
Account 1 ↔ 2 (bzw. nur Account 1), Account 3 (Su****xe) wird NIE probiert.
**Verifizierter Mechanismus (Code):**
- Rotationsschleife `debrid.ts:1898`. Account 1 → "Mega-Web Antwort leer" → Cooldown 20s →
weiter zu Account 2. Account 2 → `aborted:debrid`.
- `classifyAccountFailure` (`debrid.ts:2036`) stuft JEDEN Abbruch als **fatal** ein →
`throw` (`debrid.ts:1991`) → Schleife bricht ab → **Account 3 nie erreicht.**
- Account 2 bekommt beim Fatal-Abbruch **keinen Cooldown** (cooldownMs:0). Beim
download-manager-Retry wird Account 1 (Cooldown) übersprungen, aber Account 2 (kein
Cooldown) ERNEUT vor Account 3 probiert → bricht wieder ab → ewiges 1↔2.
- Geteiltes 60s-Unrestrict-Timeout `download-manager.ts:8590` (`AbortSignal.any([taskAbort,
timeout(60s)])`) gilt für die GANZE Rotation, nicht pro Account. Mega-Web pollt intern bis
180s (`mega-web-fallback.ts:235` + Poll-Loop `:371`). Sobald das geteilte 60s feuert, bleibt
das kombinierte Signal aborted → KEIN späterer Account kriegt im selben Pass eine echte Chance.
**BESTÄTIGT 2026-06-08 (zweite Screenshots):** Account 1 läuft 10x rasch "erfolgreich"
(11:51:4511:52:26), dann zwei "abgebrochen (aborted:debrid)" um 11:53:30 UND 11:54:30 —
**exakt 60s auseinander** = das geteilte 60s-Unrestrict-Timeout feuert (kein User-Stop, der
wiederholt sich nicht periodisch). Hier rotiert GAR NICHTS: Account 1 bricht ab → fatal →
Rotation stoppt sofort bei idx=0 → Account 2 und 3 werden NIE probiert. Bug eindeutig
bestätigt, elapsedMs nicht mehr nötig. Account 1 selbst ist gesund (10x ok) — Mega-Web hängt
nur sporadisch (no-server-Poll) bis ins 60s-Timeout.
**Fix-Design (wenn bestätigt):** Pro-Account-Timeout-Budget, abgekoppelt vom geteilten Cap.
debrid.ts braucht das **cancel-only** Signal getrennt vom Timeout (kombiniertes Signal kann
beides nicht unterscheiden). Minimal-invasiv: optionaler `opts`-Param an `unrestrictLink`
({cancelSignal, perAttemptTimeoutMs}) — nur die Mega-Rotation liest ihn, andere Provider
unberührt (kombiniertes Signal bleibt). Pro Account: `AbortSignal.any([cancelSignal,
AbortSignal.timeout(perAttemptMs)])`. Abbruch-Logik: cancelSignal aborted → echter Stop;
eigenes Account-Timer gefeuert → non-fatal, Cooldown, weiter zum nächsten Account (inkl. 3).
**Regressionstest ZUERST** (3 Accounts, 1+2 failen/aborten → assert Account 3 kriegt TEST).
**Advisor-Gate** vor Eingriff (kritischer Unrestrict-Pfad, betrifft jeden Download).
Hinweis: Grundursache der leeren Antworten = Mega-Debrid Server/IP-Thema — Fix macht Rotation
nur FAIRER (alle Accounts drankommen), bringt aber keinen busy Server zum Antworten.
### Features / UX (nach ROI)
App läuft headless auf Windows-Server → Nutzer sitzt nicht davor.
1. [ ] **Push-Benachrichtigungen** (Discord/Telegram/ntfy) — SM. Paket fertig/Fehler/Quota/Provider-down aufs Handy. Neuer `notifier.ts`, Hooks an Completion-Punkten. **Höchster ROI.**
2. [ ] **Fernsteuerung über Debug-Server** (POST-Endpunkte) — SM. Server hat HTTP + Token-Auth, aber nur GET. POST `/control/add-links`, `/start`, `/stop`.
3. [ ] **URL-Duplikat-Erkennung beim Hinzufügen** — S. History-`urls` existiert, wird nie geprüft → versehentliche Re-Downloads. Warnen: "3 Links bereits geladen".
4. [ ] **Pre-Flight-Check + Bulk-Skip toter Links** — M. Vor Start Größe/Name/Online für ganze Queue, "alle offline überspringen".
5. [ ] **Speicherplatz-Vorabprüfung vor Start** — S. Aktuell keine Free-Space-Prüfung für Downloads → Abbruch mitten drin bei voller Platte.
6. [ ] **Konsolidierte Fehler-Ansicht** — M. Alle fehlgeschlagenen Items flach + Fehlertext + "alle erneut versuchen". (Daten dafür liegen jetzt teils in der Error-Ring aus v1.7.185.)
7. [ ] **Per-Provider-Statistik** — M. Rohdaten (`providerTotalUsageBytes`) existieren, werden nicht dargestellt. Welches Abo lohnt sich?
8. [ ] **Auto-Retry fehlgeschlagener Pakete nach Wartezeit** — SM. Quota/Cooldown-Fails am nächsten Tag automatisch neu.
9. [ ] **Plex/Jellyfin Library-Refresh nach MKV-Move** — S. Gleicher Hook wie #1.
10. [ ] **Watch-Folder für DLC/Link-Auto-Import** — M.
### Design-Richtung (Entscheidung steht aus)
4 Mockups in `design-mockups/` (index.html = Vergleich): **Aurora** (verfeinert dark, geringstes Risiko) · **Command** (Terminal/Ops, dicht) · **Vellum** (light editorial) · **Nebula** (neon).
→ Richtung wählen. Siehe Memory: design-taste (Anti-KI-Look) + design-direction (Ember-Wärme, flach/ehrlich).
### Alte Audit-Items (2026-04-04, Status ggf. veraltet — VOR Fix gegen aktuellen Code verifizieren)
- [ ] Debrid-Link `maxDataHost` kühlt ganzen Key ab statt nur den Host
- [ ] Debrid-Link `fileNotAvailable` setzt Key auf "error" statt temporär
- [ ] AllDebrid: kein per-host-Cooldown für erschöpfte Quotas
- [ ] LinkSnappy: keine Auth-Dedup (parallele Requests rufen beide authenticate())
- [ ] Extractor password-cache race (parallele Worker mutieren `packageLearnedPasswords`)
- [x] Hybrid race: 1 Datei/Staffel evtl. beim MKV-Move nicht umbenannt (NUR per-package fixen — Post-MKV-Move-Scan ist tabu, v1.7.107 revertiert) → GEFIXT v1.7.196
---
## ✅ Hybrid-Race .DL.+Doppeltonspur — v1.7.196 (2026-06-10)
User-Goal: Desperate Housewives S03E08/E11/E17 behielten .DL. + DE+EN-Tonspur trotz keepGermanAudioOnly.
Vermutung "langer Titel" widerlegt (gleich lange Namen). Support-Bundle: genau die 3 hatten 0× "Tonspur-Bereinigung OK".
Ursache: Hybrid-Extraktor laeuft SEPARAT von der per-Paket-Kette (autoRename→keepGermanAudio→collect).
Waehrend langsamer ~3s-Remux (1,5GB) lief, entpackte er frische .DL.-MKVs in extractDir — nach dem
keepGermanAudio-Datei-Scan, vor dem Ketten-collect. collectMkvFilesToLibrary (deferFreshFiles=true)
verschob sie mit .DL. in die Library, bevor ihr Audio-Strip lief; raus aus extractDir fand der finale
Deferred-keepGermanAudio sie nie. 2000ms-Frische-Gate griff nicht (Dateien ~2,3s alt).
Fix: collectMkvFilesToLibrary haelt bei deferFreshFiles && keepGermanAudioOnly remuxbare (.mkv/.mp4)
Dateien mit .DL.-Marker zurueck → bleiben in extractDir, spaetere Hybrid-Runde ODER finaler Deferred-Pass
bereinigt+sammelt. Praezise (eine .DL.avi wird nie remuxt → nicht zurueckhalten). Deferred-Pass
(deferFreshFiles=false) unberuehrt → legitim doppeltonige Datei ohne DE-Track wird weiter gesammelt.
Regressionstest tests/hybrid-collect-race.test.ts (3 Faelle). 804 Tests, tsc=6, self-check+build,
Gitea+Mirror 3664e78 verifiziert (beide draft=false, 4 Assets, latest.yml path==Asset).
OFFEN: Altfiles in entpackt2 werden NICHT retroaktiv bereinigt — nur neue Downloads. Remediation der
3 Episoden nur auf Admin-Account (ploet-Session sieht Administrator-Profil nicht) oder via geplantes
"Bibliothek-Batch Tonspur"-QoL.
## Webhook "0 von 5 getriggert" (2026-06-10) — KEIN Code-Bug
Test-Nachricht kam an (URL OK), aber alle 3 Notify-Toggles unchecked + "Einstellungen speichern" offen
(ungespeichert). Toggle-Default=aus. User muss Toggles aktivieren UND speichern.
---
## ✅ ERLEDIGT — Archiv (Details in git-History + Memory)
- **Erweitertes Logging** → released **v1.7.185** (Crash-Handler, Renderer-Fehler-IPC, RD_DEBUG-Level, Error-Ring + `/errors`, ENOSPC-Klassifizierung, Memory-Heartbeat). → Memory: extended-logging
- **Link-Prefetch** → untersucht (6-Agent) + **bewusst verworfen** (marginal bei maxParallel 8, Mega-Web single-flight). → Memory: link-prefetch-declined
- **Backup nur Settings** → v1.7.184 (`backupIncludeDownloads`-Toggle + 4 Selektions/Flicker-Fixes). → Memory: backup-settings-only
- **Account-Rotation-Overhaul** → v1.7.164168 (Validity/Premium-Badges, Live-Panel, "Alle prüfen"). → Memory: account-rotation
- **Mega-Debrid-Account deaktivieren (UI)** → erledigt (Toggle im Edit-Dialog, im Code verifiziert 2026-06-07)
- **Bugs/Robustheit (Deferred-Pipeline H1/H2/H3/M1/M2/N1)** → v1.7.158/159; M3 bewusst übersprungen (Generation-Guard schützt Integrität bereits)
- **Deferred-Pfad Rename-Gap** → gefixt v1.7.162+ (finaler Deferred-Pass benennt frische Dateien vor Collect um; Repro-Test grün)
- **Repo-Privacy-Audit** → GitHub gelöscht+neu (saubere History), Gitea unberührt. → Memory: repo-privacy-audit
### Bewusst NICHT angefasst (Crash-Debris / alte Experimente)
- Gestashtes Crash-Debris `stash@{0}` (Revert von 08372f9/18eada9/98dc366 + log.old) — bei Bedarf recoverbar, sonst verwerfbar
- Untracked `*-postprocess/` + `fix-library-renames.mjs` — alte Experimente (Apr/Mai)
## ✅ Mega-Rotation Round-Robin — v1.7.197 (2026-06-11)
User-Goal: "4 Accounts, rotiert nur zwischen 1-3, der 4. wird nie angefasst."
ACHTUNG: Beide Apps (Downloader + Multi-Hoster-Uploader) schreiben "account-rotation.log" —
das Log im User-Ordner war das des Uploaders (dort eigener Befund, Fix auf User-Wunsch
revertiert + geparkt). Downloader-Beweis kam aus rd_downloader.log im Support-Bundle:
First-Usable-Wins ab Account 1 — Acc1/2 dauernd am Tageslimit, Acc3 trug alles (1603/1603 OK),
Acc4 hatte null Events. Fix: Round-Robin-Cursor in unrestrictWithAccounts (Start beim Account
nach dem zuletzt getesteten, alle Skip-Checks unverändert, Reset im Test-Hook). Nebenfix:
Support-Bundle exportiert jetzt account-rotation.log(+.old). 2 Regressionstests, 806 grün.
Releases Gitea + Mirror e104e72 verifiziert. Debrid-Link-Keys bewusst First-Wins gelassen:
- [ ] Kandidat: Round-Robin auch für Debrid-Link-Keys (gleiches Muster), falls User dort
mal mehrere Keys nutzt

View File

@ -1,35 +0,0 @@
import { describe, expect, it } from "vitest";
import { applyAccountDialogToSettings, AccountDialogState } from "../src/renderer/App";
import { defaultSettings } from "../src/main/constants";
function megaDialog(kind: "megadebrid-api" | "megadebrid-web"): AccountDialogState {
return {
mode: "edit",
kind,
token: "",
login: "",
password: "",
dailyLimitGb: "",
keyDailyLimitGbById: {},
megaAccounts: [{ login: "user@x", password: "pw" }],
megaNewLogin: "",
megaNewPassword: "",
megaDisabledIds: []
};
}
describe("applyAccountDialogToSettings — keeps the user's Mega preferApi choice", () => {
it("does not flip megaDebridPreferApi to true when editing the API account", () => {
const settings = { ...defaultSettings(), megaDebridApiEnabled: true, megaDebridWebEnabled: true, megaDebridPreferApi: false };
const next = applyAccountDialogToSettings(settings, megaDialog("megadebrid-api"));
expect(next.megaDebridApiEnabled).toBe(true);
expect(next.megaDebridPreferApi).toBe(false);
});
it("does not flip megaDebridPreferApi to false when editing the Web account", () => {
const settings = { ...defaultSettings(), megaDebridApiEnabled: true, megaDebridWebEnabled: true, megaDebridPreferApi: true };
const next = applyAccountDialogToSettings(settings, megaDialog("megadebrid-web"));
expect(next.megaDebridWebEnabled).toBe(true);
expect(next.megaDebridPreferApi).toBe(true);
});
});

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

@ -1,71 +0,0 @@
import { describe, expect, it } from "vitest";
import {
formatConversionBlock,
hasActiveConversionTrace,
runWithConversionTrace,
traceConversionPhase,
type ConversionTrace
} from "../src/main/conversion-trace";
describe("formatConversionBlock", () => {
it("renders a header with verdict + total and one indented line per phase", () => {
const trace: ConversionTrace = {
startedAt: 1000,
itemId: "id1",
itemName: "tvs-foo.part5.rar",
link: "https://rapidgator.net/file/abc/tvs-foo.part5.rar.html",
providerOrder: "megadebrid-api,megadebrid-web",
notes: { slots: "conv2/dl6/max8" },
phases: [
{ atMs: 0, phase: "chain-try", provider: "megadebrid-api" },
{ atMs: 5, phase: "token", provider: "megadebrid-api", account: "2/2(e3)", tokenState: "fresh", workMs: 812, outcome: "ok" },
{ atMs: 820, phase: "api-getlink", provider: "megadebrid-api", account: "2/2(e3)", workMs: 634, outcome: "ok" }
]
};
const block = formatConversionBlock(trace, "OK", "", 1450);
const lines = block.split("\n");
expect(lines[0]).toContain("[CONV]");
expect(lines[0]).toContain("item=tvs-foo.part5.rar");
expect(lines[0]).toContain("result=OK");
expect(lines[0]).toContain("total=1450ms");
expect(lines[0]).toContain("slots=conv2/dl6/max8");
expect(lines).toHaveLength(4);
expect(lines[2]).toContain("+5ms token");
expect(lines[2]).toContain("token=fresh");
expect(lines[2]).toContain("workMs=812");
});
it("includes the failure detail in the header verdict", () => {
const trace: ConversionTrace = {
startedAt: 0, itemId: "i", itemName: "x", link: "l", providerOrder: "megadebrid-web", notes: {},
phases: [{ atMs: 60000, phase: "caller-timeout", provider: "megadebrid-web", outcome: "timeout", detail: "Unrestrict Timeout nach 60s" }]
};
const block = formatConversionBlock(trace, "FAIL", "Unrestrict Timeout nach 60s", 60003);
expect(block.split("\n")[0]).toContain("result=FAIL (Unrestrict Timeout nach 60s)");
expect(block).toContain("caller-timeout");
});
});
describe("conversion trace context", () => {
it("traceConversionPhase is a no-op outside an active trace and does not throw", () => {
expect(hasActiveConversionTrace()).toBe(false);
expect(() => traceConversionPhase({ phase: "orphan" })).not.toThrow();
});
it("activates an ambient trace across awaits inside runWithConversionTrace", async () => {
expect(hasActiveConversionTrace()).toBe(false);
const seen = await runWithConversionTrace(
{ itemId: "i", itemName: "n", link: "l", providerOrder: "megadebrid-api" },
async () => {
const before = hasActiveConversionTrace();
traceConversionPhase({ phase: "chain-try", provider: "megadebrid-api" });
await Promise.resolve();
const afterAwait = hasActiveConversionTrace();
return before && afterAwait;
}
);
expect(seen).toBe(true);
expect(hasActiveConversionTrace()).toBe(false);
});
});

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 { clearMegaDebridEmptyResponseStreak, DebridService, extractRapidgatorFilenameFromHtml, fetchAllDebridHostInfo, fetchDebridLinkHostLimits, filenameFromRapidgatorUrlPath, getDebridLinkKeyRuntimeStateForTests, getMegaDebridAccountCooldownState, MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART, normalizeResolvedFilename, primeMegaDebridLatencyForTests, primeMegaDebridUntilRestartForTests, recordMegaDebridEmptyResponseStreak, resetDebridLinkRuntimeStateForTests, resetMegaDebridRuntimeStateForTests } from "../src/main/debrid";
const originalFetch = globalThis.fetch;
@ -16,21 +15,6 @@ afterEach(() => {
vi.restoreAllMocks();
});
describe("leadProviderChainWith", () => {
it("leaves the order unchanged when no preferred provider is given", () => {
expect(leadProviderChainWith(["realdebrid", "debridlink", "alldebrid"], null)).toEqual(["realdebrid", "debridlink", "alldebrid"]);
expect(leadProviderChainWith(["realdebrid", "debridlink"], undefined)).toEqual(["realdebrid", "debridlink"]);
});
it("leads with the preferred provider but keeps every other provider as a later fallback", () => {
expect(leadProviderChainWith(["realdebrid", "debridlink", "alldebrid"], "alldebrid")).toEqual(["alldebrid", "realdebrid", "debridlink"]);
});
it("does not drop or strand any provider when the preferred one is not in the order", () => {
expect(leadProviderChainWith(["realdebrid", "debridlink"], "alldebrid")).toEqual(["realdebrid", "debridlink"]);
});
});
describe("debrid service", () => {
it("falls back to Mega web when Real-Debrid fails", async () => {
const settings = {
@ -150,44 +134,6 @@ describe("debrid service", () => {
expect(calledUrls.some((url) => url.includes("api.real-debrid.com/rest/1.0/unrestrict/link"))).toBe(false);
});
it("leads the provider chain with the preferred (non-cooled) provider when one is hinted", async () => {
const settings = {
...defaultSettings(),
token: "rd-token",
debridLinkApiKeys: "dl-token",
providerOrder: ["realdebrid", "debridlink"] as const,
providerPrimary: "realdebrid" as const,
providerSecondary: "debridlink" as const,
providerTertiary: "none" as const,
autoProviderFallback: true
};
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("debrid-link.com/api/v2/downloader/add")) {
return new Response(JSON.stringify({
success: true,
value: { downloadUrl: "https://debrid-link.example/file.bin", name: "file.bin", size: 1234 }
}), { status: 200, headers: { "Content-Type": "application/json" } });
}
if (url.includes("api.real-debrid.com/rest/1.0/unrestrict/link")) {
return new Response(JSON.stringify({
download: "https://rd.example/file.bin",
filename: "file.bin",
filesize: 1234
}), { status: 200, headers: { "Content-Type": "application/json" } });
}
return new Response("not-found", { status: 404 });
}) as typeof fetch;
const service = new DebridService(settings);
const control = await service.unrestrictLink("https://hoster.example/lead-control.bin");
expect(control.provider).toBe("realdebrid");
const preferred = await service.unrestrictLink("https://hoster.example/lead-pref.bin", undefined, undefined, "debridlink");
expect(preferred.provider).toBe("debridlink");
});
it("uses the next Debrid-Link key when the first key hit its local daily limit", async () => {
const keys = parseDebridLinkApiKeys("dl-key-one\ndl-key-two");
let usedAuthHeader = "";
@ -573,78 +519,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(),
@ -1429,198 +1303,6 @@ describe("debrid service", () => {
expect(megaWeb).toHaveBeenCalledTimes(0);
});
it("treats a Mega-Debrid 'Fichier supprimé' as transient: no account cooldown, German message, retryable", async () => {
const settings = {
...defaultSettings(),
token: "",
bestToken: "",
allDebridToken: "",
megaLogin: "user",
megaPassword: "pass",
megaCredentials: "user:pass",
megaDebridApiEnabled: true,
megaDebridWebEnabled: false,
providerPrimary: "megadebrid-api" as const,
providerSecondary: "none" as const,
providerTertiary: "none" as const,
autoProviderFallback: true
};
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("action=connectUser")) {
return new Response(JSON.stringify({ response_code: "ok", token: "tok", vip_end: Math.floor(Date.now() / 1000) + 999999 }), { status: 200, headers: { "Content-Type": "application/json" } });
}
if (url.includes("action=getLink")) {
return new Response(JSON.stringify({ response_code: "error", response_text: "Fichier supprimé chez l'hébergeur" }), { status: 200, headers: { "Content-Type": "application/json" } });
}
return new Response("not-found", { status: 404 });
}) as typeof fetch;
const service = new DebridService(settings);
const err = await service.unrestrictLink("https://rapidgator.net/file/maybe-dead.rar.html").then(() => null, (e: unknown) => e);
expect(err).toBeTruthy();
expect(String(err)).toMatch(/nicht abrufbar/i);
expect(String(err)).not.toMatch(/supprim/i);
expect(getMegaDebridAccountCooldownState(`${getMegaDebridAccountId("user")}:api`)).toBeNull();
});
it("does NOT slap a 60-minute 'invalid' cooldown on a working account when the API returns no result (transient)", async () => {
const settings = {
...defaultSettings(),
token: "",
bestToken: "",
allDebridToken: "",
megaLogin: "user",
megaPassword: "pass",
megaCredentials: "user:pass",
megaDebridApiEnabled: true,
megaDebridWebEnabled: false,
providerPrimary: "megadebrid-api" as const,
providerSecondary: "none" as const,
providerTertiary: "none" as const,
autoProviderFallback: true
};
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("action=connectUser")) {
return new Response(JSON.stringify({ response_code: "ok", token: "tok", vip_end: Math.floor(Date.now() / 1000) + 999999 }), { status: 200, headers: { "Content-Type": "application/json" } });
}
if (url.includes("action=getLink")) {
return new Response(JSON.stringify({ response_code: "ok" }), { status: 200, headers: { "Content-Type": "application/json" } });
}
return new Response("not-found", { status: 404 });
}) as typeof fetch;
const service = new DebridService(settings);
const err = await service.unrestrictLink("https://rapidgator.net/file/no-result.rar.html").then(() => null, (e: unknown) => e);
expect(err).toBeTruthy();
expect(String(err)).not.toMatch(/Login oder Unrestrict/i);
const cooldown = getMegaDebridAccountCooldownState(`${getMegaDebridAccountId("user")}:api`);
if (cooldown) {
expect(cooldown.category).not.toBe("invalid");
expect(cooldown.remainingMs).toBeLessThan(5 * 60 * 1000);
}
});
it("treats a Mega-Debrid API 'Token error, please log-in' as a short transient cooldown, not invalid", async () => {
const settings = {
...defaultSettings(),
token: "",
bestToken: "",
allDebridToken: "",
megaLogin: "user",
megaPassword: "pass",
megaCredentials: "user:pass",
megaDebridApiEnabled: true,
megaDebridWebEnabled: false,
providerPrimary: "megadebrid-api" as const,
providerSecondary: "none" as const,
providerTertiary: "none" as const,
autoProviderFallback: true
};
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("action=connectUser")) {
return new Response(JSON.stringify({ response_code: "ok", token: "tok", vip_end: Math.floor(Date.now() / 1000) + 999999 }), { status: 200, headers: { "Content-Type": "application/json" } });
}
if (url.includes("action=getLink")) {
return new Response(JSON.stringify({ response_code: "error_token", response_text: "Token error, please log-in" }), { status: 200, headers: { "Content-Type": "application/json" } });
}
return new Response("not-found", { status: 404 });
}) as typeof fetch;
const service = new DebridService(settings);
await service.unrestrictLink("https://rapidgator.net/file/token-collision.rar.html").then(() => null, (e: unknown) => e);
const cooldown = getMegaDebridAccountCooldownState(`${getMegaDebridAccountId("user")}:api`);
if (cooldown) {
expect(cooldown.category).not.toBe("invalid");
expect(cooldown.remainingMs).toBeLessThan(60 * 1000);
}
});
it("categorizes a Mega-Debrid 'rate limit' error as rate_limit, not quota (regex ordering)", async () => {
const settings = {
...defaultSettings(),
token: "",
bestToken: "",
allDebridToken: "",
megaLogin: "user",
megaPassword: "pass",
megaCredentials: "user:pass",
megaDebridApiEnabled: true,
megaDebridWebEnabled: false,
providerPrimary: "megadebrid-api" as const,
providerSecondary: "none" as const,
providerTertiary: "none" as const,
autoProviderFallback: true
};
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("action=connectUser")) {
return new Response(JSON.stringify({ response_code: "ok", token: "tok", vip_end: Math.floor(Date.now() / 1000) + 999999 }), { status: 200, headers: { "Content-Type": "application/json" } });
}
if (url.includes("action=getLink")) {
return new Response(JSON.stringify({ response_code: "error", response_text: "Rate limit exceeded, too many requests" }), { status: 200, headers: { "Content-Type": "application/json" } });
}
return new Response("not-found", { status: 404 });
}) as typeof fetch;
const service = new DebridService(settings);
await service.unrestrictLink("https://rapidgator.net/file/rl.rar.html").then(() => null, (e: unknown) => e);
const cooldown = getMegaDebridAccountCooldownState(`${getMegaDebridAccountId("user")}:api`);
expect(cooldown).not.toBeNull();
expect(cooldown!.category).toBe("rate_limit");
});
it("does not fall through a failed 1fichier link to the provider chain when autoProviderFallback is off", async () => {
let megaGetLinkCalled = false;
const settings = {
...defaultSettings(),
token: "",
bestToken: "",
allDebridToken: "",
oneFichierApiKey: "1f-key",
megaLogin: "user",
megaPassword: "pass",
megaCredentials: "user:pass",
megaDebridApiEnabled: true,
megaDebridWebEnabled: false,
megaDebridPreferApi: true,
providerPrimary: "megadebrid" as const,
providerSecondary: "none" as const,
providerTertiary: "none" as const,
autoProviderFallback: false
};
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("api.1fichier.com")) {
return new Response(JSON.stringify({ status: "KO", message: "not available" }), { status: 500, headers: { "Content-Type": "application/json" } });
}
if (url.includes("action=connectUser")) {
return new Response(JSON.stringify({ response_code: "ok", token: "tok", vip_end: Math.floor(Date.now() / 1000) + 999999 }), { status: 200, headers: { "Content-Type": "application/json" } });
}
if (url.includes("action=getLink")) {
megaGetLinkCalled = true;
return new Response(JSON.stringify({ response_code: "ok", debridLink: "https://mega-cdn.example/file.rar", filename: "file.rar" }), { status: 200, headers: { "Content-Type": "application/json" } });
}
return new Response("not-found", { status: 404 });
}) as typeof fetch;
const service = new DebridService(settings);
const result = await service.unrestrictLink("https://1fichier.com/?abc12345xyz").then((r) => ({ ok: true, r }), (e: unknown) => ({ ok: false, e }));
expect(result.ok).toBe(false);
expect(megaGetLinkCalled).toBe(false);
});
it("uses Mega Web only when it is configured as a separate fallback provider", async () => {
const settings = {
...defaultSettings(),
@ -1697,196 +1379,167 @@ describe("debrid service", () => {
}
});
it("bleibt klebrig bei einem funktionierenden Account (kein Account-Wechsel pro Link)", async () => {
it("verteilt Links per Round-Robin ueber ALLE Mega-Debrid-Accounts statt immer beim ersten zu starten", async () => {
const settings = {
...defaultSettings(),
token: "", bestToken: "", allDebridToken: "",
megaLogin: "user1", megaPassword: "pass1",
token: "",
bestToken: "",
allDebridToken: "",
megaLogin: "user1",
megaPassword: "pass1",
megaCredentials: "user1:pass1\nuser2:pass2\nuser3:pass3\nuser4:pass4",
megaDebridPreferApi: false,
providerOrder: [] as const, providerPrimary: "megadebrid" as const,
providerSecondary: "none" as const, providerTertiary: "none" as const,
providerOrder: [] as const,
providerPrimary: "megadebrid" as const,
providerSecondary: "none" as const,
providerTertiary: "none" as const,
autoProviderFallback: false
};
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
const megaWeb = vi.fn(async () => ({ fileName: "ok.rar", directUrl: "https://mega-web.example/ok.rar", fileSize: null, retriesUsed: 0 }));
const megaWeb = vi.fn(async () => ({
fileName: "ok.rar",
directUrl: "https://mega-web.example/ok.rar",
fileSize: null,
retriesUsed: 0
}));
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
const usedIds: (string | undefined)[] = [];
for (let i = 0; i < 5; i += 1) {
const result = await service.unrestrictLink(`https://rapidgator.net/file/sticky-${i}`);
const result = await service.unrestrictLink(`https://rapidgator.net/file/rr-${i}`);
usedIds.push((result as { sourceAccountId?: string }).sourceAccountId);
}
expect(usedIds).toEqual(new Array(5).fill(getMegaDebridAccountId("user1")));
expect(usedIds).toEqual([
getMegaDebridAccountId("user1"),
getMegaDebridAccountId("user2"),
getMegaDebridAccountId("user3"),
getMegaDebridAccountId("user4"),
getMegaDebridAccountId("user1")
]);
}, 30000);
it("wechselt erst nach einem Schwung Links auf den naechsten Account", async () => {
it("Round-Robin respektiert Cooldowns: gesperrter Account wird in der Reihe uebersprungen", async () => {
const settings = {
...defaultSettings(),
token: "", bestToken: "", allDebridToken: "",
megaLogin: "user1", megaPassword: "pass1",
megaCredentials: "user1:pass1\nuser2:pass2",
megaDebridPreferApi: false,
providerOrder: [] as const, providerPrimary: "megadebrid" as const,
providerSecondary: "none" as const, providerTertiary: "none" as const,
autoProviderFallback: false
};
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
const megaWeb = vi.fn(async () => ({ fileName: "ok.rar", directUrl: "https://mega-web.example/ok.rar", fileSize: null, retriesUsed: 0 }));
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
const usedIds: (string | undefined)[] = [];
for (let i = 0; i < MEGA_DEBRID_STICKY_LINKS + 1; i += 1) {
const result = await service.unrestrictLink(`https://rapidgator.net/file/chunk-${i}`);
usedIds.push((result as { sourceAccountId?: string }).sourceAccountId);
}
expect(usedIds.slice(0, MEGA_DEBRID_STICKY_LINKS)).toEqual(new Array(MEGA_DEBRID_STICKY_LINKS).fill(getMegaDebridAccountId("user1")));
expect(usedIds[MEGA_DEBRID_STICKY_LINKS]).toBe(getMegaDebridAccountId("user2"));
}, 30000);
it("ueberspringt einen gesperrten Account und bleibt dann klebrig beim naechsten", async () => {
const settings = {
...defaultSettings(),
token: "", bestToken: "", allDebridToken: "",
megaLogin: "user1", megaPassword: "pass1",
token: "",
bestToken: "",
allDebridToken: "",
megaLogin: "user1",
megaPassword: "pass1",
megaCredentials: "user1:pass1\nuser2:pass2\nuser3:pass3",
megaDebridPreferApi: false,
providerOrder: [] as const, providerPrimary: "megadebrid" as const,
providerSecondary: "none" as const, providerTertiary: "none" as const,
providerOrder: [] as const,
providerPrimary: "megadebrid" as const,
providerSecondary: "none" as const,
providerTertiary: "none" as const,
autoProviderFallback: false
};
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
const megaWeb = vi.fn(async () => ({ fileName: "ok.rar", directUrl: "https://mega-web.example/ok.rar", fileSize: null, retriesUsed: 0 }));
const megaWeb = vi.fn(async () => ({
fileName: "ok.rar",
directUrl: "https://mega-web.example/ok.rar",
fileSize: null,
retriesUsed: 0
}));
primeMegaDebridUntilRestartForTests(`${getMegaDebridAccountId("user1")}:web`);
primeMegaDebridUntilRestartForTests(`${getMegaDebridAccountId("user2")}:web`);
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
const usedIds: (string | undefined)[] = [];
for (let i = 0; i < 3; i += 1) {
const result = await service.unrestrictLink(`https://rapidgator.net/file/skip-${i}`);
const result = await service.unrestrictLink(`https://rapidgator.net/file/rr-skip-${i}`);
usedIds.push((result as { sourceAccountId?: string }).sourceAccountId);
}
expect(usedIds).toEqual(new Array(3).fill(getMegaDebridAccountId("user2")));
expect(usedIds).toEqual([
getMegaDebridAccountId("user1"),
getMegaDebridAccountId("user3"),
getMegaDebridAccountId("user1")
]);
}, 30000);
it("verteilt gleichzeitige Umwandlungen auf verschiedene Accounts (parallel, in-flight-Routing)", async () => {
it("depriorisiert einen nachweislich langsamen Account in der Rotation (Single-Flight-Schutz)", async () => {
const settings = {
...defaultSettings(),
token: "", bestToken: "", allDebridToken: "",
megaLogin: "user1", megaPassword: "pass1",
megaCredentials: "user1:pass1\nuser2:pass2\nuser3:pass3\nuser4:pass4",
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;
let active = 0;
let maxActive = 0;
const accountsSeen = new Set<string>();
const allInFlight = new Promise<void>((resolve) => {
const check = setInterval(() => { if (active >= 4) { clearInterval(check); resolve(); } }, 5);
});
const megaWeb = vi.fn(async (_link: string, _signal: AbortSignal | undefined, account?: { login: string; password: string }) => {
active += 1;
maxActive = Math.max(maxActive, active);
if (account) accountsSeen.add(account.login);
await allInFlight;
active -= 1;
return { fileName: "ok.rar", directUrl: "https://mega-web.example/ok.rar", fileSize: null, retriesUsed: 0 };
});
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
const results = await Promise.all([0, 1, 2, 3].map((i) => service.unrestrictLink(`https://rapidgator.net/file/conc-${i}`)));
expect(results.every((r) => Boolean((r as { directUrl?: string }).directUrl))).toBe(true);
expect(accountsSeen.size).toBe(4);
expect(maxActive).toBe(4);
}, 15000);
it("verteilt Ueberzahl-Umwandlungen (mehr gleichzeitig als Accounts) gleichmaessig statt sie zu stapeln", async () => {
const settings = {
...defaultSettings(),
token: "", bestToken: "", allDebridToken: "",
megaLogin: "user1", megaPassword: "pass1",
token: "",
bestToken: "",
allDebridToken: "",
megaLogin: "user1",
megaPassword: "pass1",
megaCredentials: "user1:pass1\nuser2:pass2",
megaDebridPreferApi: false,
providerOrder: [] as const, providerPrimary: "megadebrid" as const,
providerSecondary: "none" as const, providerTertiary: "none" as const,
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;
let active = 0;
const callsPerAccount = new Map<string, number>();
const allInFlight = new Promise<void>((resolve) => {
const check = setInterval(() => { if (active >= 8) { clearInterval(check); resolve(); } }, 5);
});
const megaWeb = vi.fn(async (_link: string, _signal: AbortSignal | undefined, account?: { login: string; password: string }) => {
active += 1;
if (account) callsPerAccount.set(account.login, (callsPerAccount.get(account.login) ?? 0) + 1);
await allInFlight;
return { fileName: "ok.rar", directUrl: "https://mega-web.example/ok.rar", fileSize: null, retriesUsed: 0 };
});
const megaWeb = vi.fn(async () => ({
fileName: "ok.rar",
directUrl: "https://mega-web.example/ok.rar",
fileSize: null,
retriesUsed: 0
}));
primeMegaDebridLatencyForTests(`${getMegaDebridAccountId("user1")}:web`, 15000);
primeMegaDebridLatencyForTests(`${getMegaDebridAccountId("user2")}:web`, 800);
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
await Promise.all(Array.from({ length: 8 }, (_unused, i) => service.unrestrictLink(`https://rapidgator.net/file/ov-${i}`)));
const usedIds: (string | undefined)[] = [];
for (let i = 0; i < 2; i += 1) {
const result = await service.unrestrictLink(`https://rapidgator.net/file/slow-${i}`);
usedIds.push((result as { sourceAccountId?: string }).sourceAccountId);
}
// 8 gleichzeitige Aufloesungen auf 2 Accounts → 4 je Account (statt 7/1 beim alten belegt/frei-Set).
expect(callsPerAccount.get("user1")).toBe(4);
expect(callsPerAccount.get("user2")).toBe(4);
}, 15000);
expect(usedIds).toEqual([
getMegaDebridAccountId("user2"),
getMegaDebridAccountId("user2")
]);
}, 30000);
it("faellt ein Account in der Mitte aus (1,2,4 ok, 3 nicht): parallel nur ueber die funktionierenden, alle Links loesen auf", async () => {
it("nutzt den langsamen Account weiterhin, wenn die schnellen gesperrt sind (Failover-Reserve)", async () => {
const settings = {
...defaultSettings(),
token: "", bestToken: "", allDebridToken: "",
megaLogin: "user1", megaPassword: "pass1",
megaCredentials: "user1:pass1\nuser2:pass2\nuser3:pass3\nuser4:pass4",
token: "",
bestToken: "",
allDebridToken: "",
megaLogin: "user1",
megaPassword: "pass1",
megaCredentials: "user1:pass1\nuser2:pass2",
megaDebridPreferApi: false,
providerOrder: [] as const, providerPrimary: "megadebrid" as const,
providerSecondary: "none" as const, providerTertiary: "none" as const,
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;
let active = 0;
const used = new Set<string>();
const threeInFlight = new Promise<void>((resolve) => {
const check = setInterval(() => { if (active >= 3) { clearInterval(check); resolve(); } }, 5);
});
const megaWeb = vi.fn(async (_link: string, _signal: AbortSignal | undefined, account?: { login: string; password: string }) => {
active += 1;
if (account) used.add(account.login);
await threeInFlight;
return { fileName: "ok.rar", directUrl: "https://mega-web.example/ok.rar", fileSize: null, retriesUsed: 0 };
});
const megaWeb = vi.fn(async () => ({
fileName: "ok.rar",
directUrl: "https://mega-web.example/ok.rar",
fileSize: null,
retriesUsed: 0
}));
// Account 3 ist ausgefallen (z.B. zuvor fehlgeschlagen -> gesperrt) und faellt aus der Rotation.
primeMegaDebridUntilRestartForTests(`${getMegaDebridAccountId("user3")}:web`);
primeMegaDebridLatencyForTests(`${getMegaDebridAccountId("user1")}:web`, 15000);
primeMegaDebridLatencyForTests(`${getMegaDebridAccountId("user2")}:web`, 800);
primeMegaDebridUntilRestartForTests(`${getMegaDebridAccountId("user2")}:web`);
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
const results = await Promise.all([0, 1, 2].map((i) => service.unrestrictLink(`https://rapidgator.net/file/dead3-${i}`)));
const result = await service.unrestrictLink("https://rapidgator.net/file/slow-fallback");
expect(results.every((r) => Boolean((r as { directUrl?: string }).directUrl))).toBe(true);
expect(used.has("user3")).toBe(false);
expect(used.has("user1")).toBe(true);
expect(used.has("user2")).toBe(true);
expect(used.has("user4")).toBe(true);
}, 15000);
expect((result as { sourceAccountId?: string }).sourceAccountId).toBe(getMegaDebridAccountId("user1"));
}, 30000);
it("rotates to the next Mega-Debrid account when one hits its daily limit (error-based)", async () => {
const settings = {
@ -2034,113 +1687,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);
@ -2148,32 +1697,13 @@ describe("debrid service", () => {
expect(recordMegaDebridEmptyResponseStreak(key)).toBe(1);
});
it("self-heals a daily-limit park after the daily reset instead of staying locked until process restart", () => {
it("keeps an 'until restart' park active forever (never expires until process restart)", () => {
const key = `${getMegaDebridAccountId("user1")}:api`;
primeMegaDebridUntilRestartForTests(key);
const active = getMegaDebridAccountCooldownState(key);
expect(active?.untilRestart).toBe(true);
expect(getMegaDebridAccountCooldownState(key, Date.now() + 60_000)?.untilRestart).toBe(true);
const afterReset = Date.now() + 25 * 60 * 60 * 1000;
expect(getMegaDebridAccountCooldownState(key, afterReset)).toBeNull();
});
it("does NOT treat a per-hoster 'no server' failure as an account daily-limit signal (no until-restart park)", () => {
const noServer = classifyMegaDebridAccountFailureForTests(new Error("no server available for this host"));
expect(noServer.limitSignal).toBeFalsy();
expect(noServer.category).toBe("quota");
expect(noServer.cooldownMs).toBeGreaterThan(0);
const genuineEmpty = classifyMegaDebridAccountFailureForTests(new Error("Antwort leer"));
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);
const now = getMegaDebridAccountCooldownState(key);
expect(now?.untilRestart).toBe(true);
const farFuture = Date.now() + 100 * 24 * 60 * 60 * 1000;
expect(getMegaDebridAccountCooldownState(key, farFuture)?.untilRestart).toBe(true);
});
it("skips a Mega-Debrid account parked until restart and rotates to the next, without re-testing it", async () => {
@ -2212,7 +1742,7 @@ describe("debrid service", () => {
expect((result as { sourceAccountId?: string }).sourceAccountId).toBe(getMegaDebridAccountId("user2"));
}, 20000);
it("emits an until-Tagesreset park token (so the manager parks, not 2min-retries) when ALL Mega-Debrid accounts are parked until restart", async () => {
it("fails terminally (no retry timer) when ALL Mega-Debrid accounts are parked until restart", async () => {
const settings = {
...defaultSettings(),
token: "",
@ -2239,10 +1769,7 @@ describe("debrid service", () => {
const megaWeb = vi.fn(async () => ({ fileName: "x.rar", directUrl: "https://mega-web.example/x.rar", fileSize: null, retriesUsed: 0 }));
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
const err = await service.unrestrictLink("https://rapidgator.net/file/all-parked-test").then(() => null, (e: unknown) => e as Error);
expect(err).toBeInstanceOf(Error);
expect(err!.message).toMatch(/bis zum Tagesreset gesperrt/i);
expect(err!.message).toMatch(/mega_debrid_reset_park:\d+:/);
await expect(service.unrestrictLink("https://rapidgator.net/file/all-parked-test")).rejects.toThrow(/bis Neustart gesperrt/i);
expect(megaWeb).not.toHaveBeenCalled();
}, 20000);
@ -2265,9 +1792,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

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { planDownloadCompletion, reconcileFinalizedSize, validateDownloadedFileCompletion } from "../src/main/download-completion";
import { planDownloadCompletion, validateDownloadedFileCompletion } from "../src/main/download-completion";
describe("download-completion", () => {
describe("planDownloadCompletion", () => {
@ -58,33 +58,4 @@ describe("download-completion", () => {
expect(result.ok).toBe(false);
});
});
describe("reconcileFinalizedSize", () => {
it("keeps the streamed count for a pre-allocated file whose on-disk size is the zero-padding (corruption guard)", () => {
expect(reconcileFinalizedSize(300_000_000, 1_000_000_000, true)).toBe(300_000_000);
});
it("shrinks to the on-disk size when a pre-allocated file is genuinely short (real partial write)", () => {
expect(reconcileFinalizedSize(500, 300, true)).toBe(300);
});
it("reconciles in both directions for a non-pre-allocated file (stat is authoritative)", () => {
expect(reconcileFinalizedSize(300, 1000, false)).toBe(1000);
expect(reconcileFinalizedSize(1000, 300, false)).toBe(300);
});
it("returns the streamed count unchanged when the stat is invalid", () => {
expect(reconcileFinalizedSize(1234, Number.NaN, true)).toBe(1234);
expect(reconcileFinalizedSize(1234, -1, false)).toBe(1234);
});
it("is a no-op when on-disk size already equals the streamed count", () => {
expect(reconcileFinalizedSize(777, 777, true)).toBe(777);
expect(reconcileFinalizedSize(777, 777, false)).toBe(777);
});
it("does not block legitimate overshoot on a pre-allocated file (server sent more than pre-alloc)", () => {
expect(reconcileFinalizedSize(900, 900, true)).toBe(900);
});
});
});

File diff suppressed because it is too large Load Diff

View File

@ -858,40 +858,6 @@ describe("extractor", () => {
expect(targets.has(p003)).toBe(true);
expect(targets.has(other)).toBe(false);
});
it("does NOT delete a non-archive .00x family that sits beside a real archive (no-signature data-loss guard)", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-split-noarch-"));
tempDirs.push(root);
const packageDir = path.join(root, "pkg");
const targetDir = path.join(root, "out");
fs.mkdirSync(packageDir, { recursive: true });
const realZip = new AdmZip();
realZip.addFile("release.txt", Buffer.from("ok"));
realZip.writeZip(path.join(packageDir, "movie.zip"));
const d001 = path.join(packageDir, "mydata.001");
const d002 = path.join(packageDir, "mydata.002");
const d003 = path.join(packageDir, "mydata.003");
fs.writeFileSync(d001, "raw user split data, not an archive at all 0123456789", "utf8");
fs.writeFileSync(d002, "second raw chunk, also no archive magic bytes here", "utf8");
fs.writeFileSync(d003, "third raw chunk likewise plain content payload", "utf8");
const result = await extractPackageArchives({
packageDir,
targetDir,
cleanupMode: "delete",
conflictMode: "overwrite",
removeLinks: false,
removeSamples: false
});
expect(result.failed).toBe(0);
expect(fs.existsSync(path.join(targetDir, "release.txt"))).toBe(true);
expect(fs.existsSync(d001)).toBe(true);
expect(fs.existsSync(d002)).toBe(true);
expect(fs.existsSync(d003)).toBe(true);
});
});
describe("detectArchiveSignature", () => {

View File

@ -66,21 +66,6 @@ describe("integrity", () => {
expect(parseHashLine(" ")).toBeNull();
});
it("trusts the per-line algorithm over the file extension for a mislabeled manifest (.sfv holding md5 lines)", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-int-"));
tempDirs.push(dir);
const filePath = path.join(dir, "movie.bin");
fs.writeFileSync(filePath, Buffer.from("hello"));
fs.writeFileSync(path.join(dir, "checksums.sfv"), "5d41402abc4b2a76b9719d911017c592 movie.bin\n", "utf8");
const manifest = readHashManifest(dir);
expect(manifest.get("movie.bin")?.algorithm).toBe("md5");
const result = await validateFileAgainstManifest(filePath, dir);
expect(result.ok).toBe(true);
expect(result.message).toContain("MD5");
});
it("keeps first hash entry when duplicate filename appears across manifests", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-int-"));
tempDirs.push(dir);

View File

@ -1,64 +0,0 @@
import { describe, expect, it } from "vitest";
import { isMegaDebridResolveFailure, germanMegaDebridResolveReason, isMegaDebridTransientResolveFailure } from "../src/shared/mega-debrid-errors";
describe("isMegaDebridResolveFailure", () => {
it("detects the real Mega-Debrid French resolve-failure phrase", () => {
expect(isMegaDebridResolveFailure("Mega-Debrid API: Fichier supprimé chez l'hébergeur")).toBe(true);
});
it("matches inside the aggregated provider-chain error (api fail | web timeout)", () => {
const aggregated = "Unrestrict fehlgeschlagen: Mega-Debrid API: Mega-Debrid (API): Fichier supprimé chez l'hébergeur | Mega-Debrid Web: Mega-Debrid (Web): Abbruch/Timeout nach 60s";
expect(isMegaDebridResolveFailure(aggregated)).toBe(true);
});
it("matches the de-accented variant", () => {
expect(isMegaDebridResolveFailure("Fichier supprime chez l'hebergeur")).toBe(true);
});
it("matches other Mega-Debrid resolve phrases", () => {
expect(isMegaDebridResolveFailure("Fichier introuvable")).toBe(true);
expect(isMegaDebridResolveFailure("Le fichier n'existe plus")).toBe(true);
});
it("does NOT match unrelated/transient text", () => {
expect(isMegaDebridResolveFailure("Abbruch/Timeout nach 60s")).toBe(false);
expect(isMegaDebridResolveFailure("Quota/Limit erreicht")).toBe(false);
});
});
describe("germanMegaDebridResolveReason (transient wording, NOT 'tot')", () => {
it("renders 'supprimé' as a transient, retryable German reason", () => {
const reason = germanMegaDebridResolveReason("Mega-Debrid API: Fichier supprimé chez l'hébergeur");
expect(reason).toBe("Datei beim Hoster gerade nicht abrufbar");
expect(reason.toLowerCase()).not.toContain("tot");
expect(reason.toLowerCase()).not.toContain("gelöscht");
});
it("renders not-found phrases in German", () => {
expect(germanMegaDebridResolveReason("Fichier introuvable")).toBe("Datei beim Hoster nicht gefunden");
});
});
describe("isMegaDebridTransientResolveFailure (matches raw French AND rendered German)", () => {
it("matches the raw French phrase that may reach the download-manager", () => {
expect(isMegaDebridTransientResolveFailure("Mega-Debrid API: Fichier supprimé chez l'hébergeur")).toBe(true);
});
it("matches the German rendered reason that classifyAccountFailure produces", () => {
const aggregated = "Mega-Debrid (Account 1/4, ab***@x): Datei beim Hoster gerade nicht abrufbar | Mega-Debrid (Account 2/4, cd***@y): Datei beim Hoster gerade nicht abrufbar";
expect(isMegaDebridTransientResolveFailure(aggregated)).toBe(true);
});
it("matches the German not-found rendered reason", () => {
expect(isMegaDebridTransientResolveFailure("Mega-Debrid (Account 1/4): Datei beim Hoster nicht gefunden")).toBe(true);
});
it("does NOT match a Mega-Debrid timeout/abort (that has its own account cooldown path)", () => {
expect(isMegaDebridTransientResolveFailure("Mega-Debrid (Account 1/4): Abbruch/Timeout nach 60s")).toBe(false);
});
it("does NOT match unrelated provider errors", () => {
expect(isMegaDebridTransientResolveFailure("AllDebrid: zu viele aktive Downloads")).toBe(false);
expect(isMegaDebridTransientResolveFailure("Debrid-Link: badToken")).toBe(false);
});
});

View File

@ -202,68 +202,6 @@ describe("mega-web-fallback", () => {
expect(result).toBeNull();
});
it("serialisiert gleichzeitige Umwandlungen auf DEMSELBEN Account (kein Doppel-Login)", async () => {
let loginCount = 0;
globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
const u = String(url);
if (u.includes("form=login")) {
loginCount += 1;
await new Promise((r) => setTimeout(r, 15));
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 [r1, r2] = await Promise.all([
fallback.unrestrict("https://mega.debrid/a", undefined, { login: "same", password: "pw" }),
fallback.unrestrict("https://mega.debrid/b", undefined, { login: "same", password: "pw" })
]);
expect(r1?.directUrl).toBe("https://mega.direct/ok");
expect(r2?.directUrl).toBe("https://mega.direct/ok");
// Serialisiert auf demselben Account → der zweite nutzt die gecachte Session, kein zweiter Login.
expect(loginCount).toBe(1);
});
it("wandelt auf VERSCHIEDENEN Accounts parallel um (Logins laufen gleichzeitig)", async () => {
let activeLogins = 0;
let maxActiveLogins = 0;
const bothInFlight = new Promise<void>((resolve) => {
const check = setInterval(() => { if (activeLogins >= 2) { clearInterval(check); resolve(); } }, 5);
});
globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
const u = String(url);
if (u.includes("form=login")) {
activeLogins += 1;
maxActiveLogins = Math.max(maxActiveLogins, activeLogins);
await bothInFlight;
activeLogins -= 1;
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: "d", password: "p" }));
const [r1, r2] = await Promise.all([
fallback.unrestrict("https://mega.debrid/a", undefined, { login: "acc1", password: "p" }),
fallback.unrestrict("https://mega.debrid/b", undefined, { login: "acc2", password: "p" })
]);
expect(r1?.directUrl).toBe("https://mega.direct/ok");
expect(r2?.directUrl).toBe("https://mega.direct/ok");
// Verschiedene Accounts → beide Logins gleichzeitig in-flight (sonst haengt es am bothInFlight-Barrier).
expect(maxActiveLogins).toBe(2);
}, 10000);
it("aborts pending Mega-Web polling when signal is cancelled", async () => {
globalThis.fetch = vi.fn((url: string | URL | Request, init?: RequestInit): Promise<Response> => {
const urlStr = String(url);
@ -314,41 +252,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,67 +0,0 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import AdmZip from "adm-zip";
import { afterEach, describe, expect, it } from "vitest";
import { buildSupportBundle } from "../src/main/support-bundle";
import type { DownloadManager } from "../src/main/download-manager";
const tempDirs: string[] = [];
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { }
}
});
function fakeManager(): DownloadManager {
const snapshot = {
stats: {},
session: { packages: {}, items: {}, packageOrder: [] },
speedText: "",
etaText: "",
canStart: false,
canStop: false,
canPause: false
};
return {
getSnapshot: () => snapshot,
getPackageLogPath: () => null,
getItemLogPath: () => null
} as unknown as DownloadManager;
}
describe("buildSupportBundle (async, non-blocking)", () => {
it("returns a Promise and produces a valid zip with overview + a real on-disk file", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-"));
tempDirs.push(root);
fs.writeFileSync(path.join(root, "debug_host.txt"), "host-info-test", "utf8");
const promise = buildSupportBundle(fakeManager(), root, { hostDiagnosticsMode: "none" });
expect(promise).toBeInstanceOf(Promise);
const buffer = await promise;
expect(Buffer.isBuffer(buffer)).toBe(true);
expect(buffer.length).toBeGreaterThan(0);
const entries = new AdmZip(buffer).getEntries().map((e) => e.entryName);
expect(entries).toContain("overview/meta.json");
expect(entries).toContain("overview/settings.json");
expect(entries).toContain("runtime/debug_host.txt");
const hostEntry = new AdmZip(buffer).getEntry("runtime/debug_host.txt");
expect(hostEntry?.getData().toString("utf8")).toBe("host-info-test");
});
it("does not block the event loop while building (a concurrent timer still fires)", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-"));
tempDirs.push(root);
let timerFired = false;
const timer = setTimeout(() => { timerFired = true; }, 0);
await buildSupportBundle(fakeManager(), root, { hostDiagnosticsMode: "none" });
clearTimeout(timer);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(timerFired).toBe(true);
});
});

View File

@ -1,114 +0,0 @@
import { describe, expect, it } from "vitest";
import { transientResolveRetryDelayMs, parseMegaDebridCooldownRetry, parseMegaDebridResetPark, parseMegaDebridSlowLinkRetry } from "../src/main/download-manager";
describe("transientResolveRetryDelayMs (fast, bounded retry for transient resolve failures)", () => {
it("starts fast (<= 3s) instead of the 5s..120s exponential", () => {
expect(transientResolveRetryDelayMs(1)).toBeLessThanOrEqual(3000);
});
it("ramps gently and caps at 10s", () => {
expect(transientResolveRetryDelayMs(2)).toBeLessThanOrEqual(7000);
expect(transientResolveRetryDelayMs(3)).toBeLessThanOrEqual(10000);
expect(transientResolveRetryDelayMs(10)).toBe(10000);
expect(transientResolveRetryDelayMs(100)).toBe(10000);
});
it("never schedules anywhere near the 5s..120s exponential cap", () => {
for (let n = 1; n <= 50; n += 1) {
expect(transientResolveRetryDelayMs(n)).toBeLessThanOrEqual(10000);
expect(transientResolveRetryDelayMs(n)).toBeGreaterThanOrEqual(1000);
}
});
it("is monotonic non-decreasing", () => {
let prev = 0;
for (let n = 1; n <= 12; n += 1) {
const d = transientResolveRetryDelayMs(n);
expect(d).toBeGreaterThanOrEqual(prev);
prev = d;
}
});
});
describe("parseMegaDebridCooldownRetry (honor the encoded account-cooldown delay)", () => {
it("parses the encoded delay from a bare mega_debrid_cooldown error", () => {
const r = parseMegaDebridCooldownRetry("mega_debrid_cooldown:20330:Mega-Debrid (Account 2/2, Da******el): Token error");
expect(r).not.toBeNull();
expect(r!.delayMs).toBe(20330);
expect(r!.detail).toContain("Mega-Debrid");
});
it("parses it when embedded in the aggregated provider-chain error", () => {
const aggregated = "Unrestrict fehlgeschlagen: Mega-Debrid API: mega_debrid_cooldown:20330:Mega-Debrid (Account 2/2): Token error";
expect(parseMegaDebridCooldownRetry(aggregated)!.delayMs).toBe(20330);
});
it("takes the SOONEST (min) cooldown when several accounts are cooled", () => {
const both = "Mega-Debrid API: mega_debrid_cooldown:116285:web | Mega-Debrid API: mega_debrid_cooldown:20330:api";
expect(parseMegaDebridCooldownRetry(both)!.delayMs).toBe(20330);
});
it("clamps to [1s, 15min]", () => {
expect(parseMegaDebridCooldownRetry("mega_debrid_cooldown:1:x")!.delayMs).toBe(1000);
expect(parseMegaDebridCooldownRetry("mega_debrid_cooldown:99999999:x")!.delayMs).toBe(15 * 60 * 1000);
});
it("returns null when there is no mega cooldown marker", () => {
expect(parseMegaDebridCooldownRetry("Datei beim Hoster gerade nicht abrufbar")).toBeNull();
expect(parseMegaDebridCooldownRetry("debrid_link_cooldown:5000:x")).toBeNull();
});
it("does NOT swallow the until-Tagesreset park token", () => {
expect(parseMegaDebridCooldownRetry("mega_debrid_reset_park:43200000:Alle Accounts bis zum Tagesreset gesperrt")).toBeNull();
});
});
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)");
expect(r).not.toBeNull();
expect(r!.delayMs).toBe(43200000);
expect(r!.detail).toContain("bis zum Tagesreset gesperrt");
});
it("parses it when embedded in the aggregated provider-chain error", () => {
const aggregated = "Unrestrict fehlgeschlagen: Mega-Debrid API: mega_debrid_reset_park:7200000:Alle Accounts bis zum Tagesreset gesperrt";
expect(parseMegaDebridResetPark(aggregated)!.delayMs).toBe(7200000);
});
it("is NOT clamped to the 15min cooldown ceiling (can park multiple hours)", () => {
expect(parseMegaDebridResetPark("mega_debrid_reset_park:21600000:x")!.delayMs).toBe(21600000);
});
it("clamps to a sane [1s, 26h] window and rejects junk", () => {
expect(parseMegaDebridResetPark("mega_debrid_reset_park:1:x")!.delayMs).toBe(1000);
expect(parseMegaDebridResetPark("mega_debrid_reset_park:999999999999:x")!.delayMs).toBe(26 * 60 * 60 * 1000);
expect(parseMegaDebridResetPark("mega_debrid_cooldown:20330:x")).toBeNull();
expect(parseMegaDebridResetPark("kein Token hier")).toBeNull();
});
});

View File

@ -1,63 +0,0 @@
import { describe, expect, it } from "vitest";
import { runInstallWithResume, InstallResumeManager } from "../src/main/update-install-flow";
function makeManager(running: boolean): InstallResumeManager & { startCalls: number; stopCalls: number; persistCalls: number; sessionRunning: boolean } {
return {
sessionRunning: running,
startCalls: 0,
stopCalls: 0,
persistCalls: 0,
isSessionRunning() {
return this.sessionRunning;
},
stop() {
this.stopCalls += 1;
this.sessionRunning = false;
},
persistNowSync() {
this.persistCalls += 1;
},
async start() {
this.startCalls += 1;
this.sessionRunning = true;
}
};
}
describe("runInstallWithResume", () => {
it("resumes a running session when the install returns started:false", async () => {
const m = makeManager(true);
const result = await runInstallWithResume(m, async () => ({ started: false }));
expect(result.started).toBe(false);
expect(m.stopCalls).toBe(1);
expect(m.startCalls).toBe(1);
expect(m.isSessionRunning()).toBe(true);
});
it("resumes a running session when the install THROWS, then rethrows", async () => {
const m = makeManager(true);
await expect(
runInstallWithResume(m, async () => {
throw new Error("network down");
})
).rejects.toThrow("network down");
expect(m.stopCalls).toBe(1);
expect(m.startCalls).toBe(1);
expect(m.isSessionRunning()).toBe(true);
});
it("does NOT resume when the install succeeds (started:true) — the app is about to quit", async () => {
const m = makeManager(true);
const result = await runInstallWithResume(m, async () => ({ started: true }));
expect(result.started).toBe(true);
expect(m.startCalls).toBe(0);
expect(m.isSessionRunning()).toBe(false);
});
it("does NOT resume when no session was running before the install", async () => {
const m = makeManager(false);
await runInstallWithResume(m, async () => ({ started: false }));
expect(m.stopCalls).toBe(0);
expect(m.startCalls).toBe(0);
});
});

View File

@ -92,17 +92,6 @@ describe("pickAudioTrack", () => {
expect(d).toMatchObject({ action: "remux", audioRelIndex: 0, reason: "fallback-first-untagged" });
});
it("tag mode does NOT let an eng track titled 'German' beat a correctly-tagged German track", () => {
const engTitledGerman = { language: "eng", title: "German Commentary" };
const d = pickAudioTrack([engTitledGerman, ger], "tag");
expect(d).toMatchObject({ action: "remux", audioRelIndex: 1, reason: "german-tag" });
});
it("tag mode does NOT pick a non-German-tagged track just because its title contains 'Deutsch'", () => {
const d = pickAudioTrack([{ language: "eng", title: "Deutsch entfernt" }, { language: "fre", title: "" }], "tag");
expect(d).toMatchObject({ action: "skip", reason: "no-german-track" });
});
it("tag mode with single German -> single (no remux)", () => {
expect(pickAudioTrack([ger], "tag")).toMatchObject({ action: "single" });
});

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);
});