Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8c4dcc69b | ||
|
|
93a85a0255 | ||
|
|
22926d63e3 | ||
|
|
88993bccaa | ||
|
|
72b081d749 | ||
|
|
c541f1cc92 | ||
|
|
3acaf178ed | ||
|
|
8e0ae77aee | ||
|
|
ce573fe7b6 | ||
|
|
39fbc54818 | ||
|
|
d73b446d68 | ||
|
|
5bb04cb920 | ||
|
|
4543ac3c1a | ||
|
|
471e40b87f | ||
|
|
26df55f7ba | ||
|
|
d594c5082b | ||
|
|
6cde08dac3 | ||
|
|
8f3681b160 | ||
|
|
d9657a5459 | ||
|
|
a35ddf68b2 | ||
|
|
c79a031be8 | ||
|
|
6b52678e7e |
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "1.7.223",
|
"version": "1.7.232",
|
||||||
"description": "Desktop downloader",
|
"description": "Desktop downloader",
|
||||||
"main": "build/main/main/main.js",
|
"main": "build/main/main/main.js",
|
||||||
"author": "Sucukdeluxe",
|
"author": "Sucukdeluxe",
|
||||||
|
|||||||
@ -39,13 +39,13 @@ import { getItemLogPath, initItemLogs, shutdownItemLogs } from "./item-log";
|
|||||||
import { getPackageLogPath, initPackageLogs, shutdownPackageLogs } from "./package-log";
|
import { getPackageLogPath, initPackageLogs, shutdownPackageLogs } from "./package-log";
|
||||||
import { initSessionLog, getSessionLogPath, shutdownSessionLog } from "./session-log";
|
import { initSessionLog, getSessionLogPath, shutdownSessionLog } from "./session-log";
|
||||||
import { MegaWebFallback } from "./mega-web-fallback";
|
import { MegaWebFallback } from "./mega-web-fallback";
|
||||||
import { addHistoryEntry, addHistoryEntryForRetention, cancelPendingAsyncSaves, clearHistory, createStoragePaths, loadHistory, loadHistoryForRetention, loadSession, loadSettings, normalizeHistoryEntry, normalizeLoadedSession, normalizeLoadedSessionTransientFields, normalizeSettings, removeHistoryEntry, resetHistoryForRetention, saveHistory, saveSession, saveSettings } from "./storage";
|
import { addHistoryEntry, addHistoryEntryForRetention, cancelPendingAsyncSaves, clearHistory, createStoragePaths, loadHistory, loadHistoryForRetention, loadSessionWithStatus, loadSettings, normalizeHistoryEntry, normalizeLoadedSession, normalizeLoadedSessionTransientFields, normalizeSettings, removeHistoryEntry, resetHistoryForRetention, saveHistory, saveSession, saveSettings } from "./storage";
|
||||||
import { abortActiveUpdateDownload, checkGitHubUpdate, installLatestUpdate } from "./update";
|
import { abortActiveUpdateDownload, checkGitHubUpdate, installLatestUpdate } from "./update";
|
||||||
import { runInstallWithResume } from "./update-install-flow";
|
import { runInstallWithResume } from "./update-install-flow";
|
||||||
import { rotateDebugToken, startDebugServer, stopDebugServer, restartDebugServer, getDebugServerRuntimeStatus, getActiveDebugToken, getDebugAllowlist, writeDebugServerConfig, clearDebugToken } from "./debug-server";
|
import { rotateDebugToken, startDebugServer, stopDebugServer, restartDebugServer, getDebugServerRuntimeStatus, getActiveDebugToken, getDebugAllowlist, writeDebugServerConfig, clearDebugToken } from "./debug-server";
|
||||||
import { encodeConnectionCode, loadRemoteMeta, saveRemoteMeta } from "./connection-code";
|
import { encodeConnectionCode, loadRemoteMeta, saveRemoteMeta } from "./connection-code";
|
||||||
import { encryptBackup, decryptBackup } from "./backup-crypto";
|
import { encryptBackup, decryptBackup } from "./backup-crypto";
|
||||||
import { buildBackupPayload, planBackupImport } from "./backup-payload";
|
import { buildBackupPayload, planBackupImport, resolveMcpRemoteRestore, BackupMcpRemote } from "./backup-payload";
|
||||||
import { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "./audit-log";
|
import { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "./audit-log";
|
||||||
import { initAccountRotationLog, shutdownAccountRotationLog } from "./account-rotation-log";
|
import { initAccountRotationLog, shutdownAccountRotationLog } from "./account-rotation-log";
|
||||||
import { initConversionLog, shutdownConversionLog } from "./conversion-trace";
|
import { initConversionLog, shutdownConversionLog } from "./conversion-trace";
|
||||||
@ -112,7 +112,8 @@ export class AppController {
|
|||||||
initTraceLog(this.storagePaths.baseDir);
|
initTraceLog(this.storagePaths.baseDir);
|
||||||
this.settings = loadSettings(this.storagePaths);
|
this.settings = loadSettings(this.storagePaths);
|
||||||
resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode);
|
resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode);
|
||||||
const session = loadSession(this.storagePaths);
|
const loadResult = loadSessionWithStatus(this.storagePaths);
|
||||||
|
const session = loadResult.session;
|
||||||
this.megaWebFallback = new MegaWebFallback(() => ({
|
this.megaWebFallback = new MegaWebFallback(() => ({
|
||||||
login: this.settings.megaLogin,
|
login: this.settings.megaLogin,
|
||||||
password: this.settings.megaPassword
|
password: this.settings.megaPassword
|
||||||
@ -126,6 +127,7 @@ export class AppController {
|
|||||||
realDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.realDebridWebFallback.unrestrict(link, signal),
|
realDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.realDebridWebFallback.unrestrict(link, signal),
|
||||||
bestDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.bestDebridWebFallback.unrestrict(link, signal),
|
bestDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.bestDebridWebFallback.unrestrict(link, signal),
|
||||||
invalidateMegaSession: () => this.megaWebFallback.invalidateSession(),
|
invalidateMegaSession: () => this.megaWebFallback.invalidateSession(),
|
||||||
|
protectEmptyClobber: loadResult.status === "empty-unreadable",
|
||||||
onHistoryEntry: (entry: HistoryEntry) => {
|
onHistoryEntry: (entry: HistoryEntry) => {
|
||||||
addHistoryEntryForRetention(this.storagePaths, this.settings.historyRetentionMode, entry, this.historyLimits());
|
addHistoryEntryForRetention(this.storagePaths, this.settings.historyRetentionMode, entry, this.historyLimits());
|
||||||
}
|
}
|
||||||
@ -178,21 +180,13 @@ export class AppController {
|
|||||||
if (this.settings.autoResumeOnStart) {
|
if (this.settings.autoResumeOnStart) {
|
||||||
const snapshot = this.manager.getSnapshot();
|
const snapshot = this.manager.getSnapshot();
|
||||||
const hasPending = Object.values(snapshot.session.items).some((item) => item.status === "queued" || item.status === "reconnect_wait");
|
const hasPending = Object.values(snapshot.session.items).some((item) => item.status === "queued" || item.status === "reconnect_wait");
|
||||||
if (hasPending) {
|
if (hasPending && this.hasAnyProviderToken(this.settings)) {
|
||||||
void this.manager.getStartConflicts().then((conflicts) => {
|
|
||||||
const hasConflicts = conflicts.length > 0;
|
|
||||||
if (this.hasAnyProviderToken(this.settings) && !hasConflicts) {
|
|
||||||
if (this.onStateHandler) {
|
if (this.onStateHandler) {
|
||||||
logger.info("Auto-Resume beim Start aktiviert (nach Konflikt-Check)");
|
this.beginAutoResume();
|
||||||
void this.manager.start().catch((err) => logger.warn(`Auto-Resume Start Fehler: ${String(err)}`));
|
|
||||||
} else {
|
} else {
|
||||||
this.autoResumePending = true;
|
this.autoResumePending = true;
|
||||||
logger.info("Auto-Resume beim Start vorgemerkt");
|
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)}`));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -249,14 +243,27 @@ export class AppController {
|
|||||||
handler(this.manager.getSnapshot());
|
handler(this.manager.getSnapshot());
|
||||||
if (this.autoResumePending) {
|
if (this.autoResumePending) {
|
||||||
this.autoResumePending = false;
|
this.autoResumePending = false;
|
||||||
void this.manager.start().catch((err) => logger.warn(`Auto-Resume Start Fehler: ${String(err)}`));
|
this.beginAutoResume();
|
||||||
logger.info("Auto-Resume beim Start aktiviert");
|
|
||||||
} else {
|
} else {
|
||||||
this.manager.triggerIdleExtractions();
|
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 {
|
public getSnapshot(): UiSnapshot {
|
||||||
return this.manager.getSnapshot();
|
return this.manager.getSnapshot();
|
||||||
}
|
}
|
||||||
@ -372,6 +379,23 @@ export class AppController {
|
|||||||
return this.getRemoteDiagnostics();
|
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 {
|
public getDebugSetupCheck(): DebugSetupCheckResult {
|
||||||
return getDebugSetupCheck(this.storagePaths.baseDir);
|
return getDebugSetupCheck(this.storagePaths.baseDir);
|
||||||
}
|
}
|
||||||
@ -724,13 +748,22 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public exportBackup(): Buffer {
|
public exportBackup(): Buffer {
|
||||||
const includeDownloads = Boolean(this.settings.backupIncludeDownloads);
|
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 payloadObj = buildBackupPayload({
|
const payloadObj = buildBackupPayload({
|
||||||
settings: { ...this.settings },
|
settings: { ...this.settings },
|
||||||
appVersion: APP_VERSION,
|
appVersion: APP_VERSION,
|
||||||
exportedAt: new Date().toISOString(),
|
exportedAt: new Date().toISOString(),
|
||||||
session: this.manager.getSession(),
|
session: this.manager.getSession(),
|
||||||
history: loadHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode, this.historyLimits())
|
history: loadHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode, this.historyLimits()),
|
||||||
|
mcpRemote
|
||||||
});
|
});
|
||||||
this.audit("INFO", "Backup exportiert", {
|
this.audit("INFO", "Backup exportiert", {
|
||||||
kind: payloadObj.kind,
|
kind: payloadObj.kind,
|
||||||
@ -804,6 +837,7 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
|
|||||||
this.settings = restoredSettings;
|
this.settings = restoredSettings;
|
||||||
saveSettings(this.storagePaths, this.settings);
|
saveSettings(this.storagePaths, this.settings);
|
||||||
this.manager.setSettings(this.settings, { suppressRetroactiveCleanup: true });
|
this.manager.setSettings(this.settings, { suppressRetroactiveCleanup: true });
|
||||||
|
this.restoreMcpRemoteFromBackup(parsed.mcpRemote, true);
|
||||||
this.audit("INFO", "Backup importiert (nur Einstellungen)", {
|
this.audit("INFO", "Backup importiert (nur Einstellungen)", {
|
||||||
accountSummary: buildAccountSummary(this.settings)
|
accountSummary: buildAccountSummary(this.settings)
|
||||||
});
|
});
|
||||||
@ -840,6 +874,8 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
|
|||||||
|
|
||||||
resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode);
|
resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode);
|
||||||
|
|
||||||
|
this.restoreMcpRemoteFromBackup(parsed.mcpRemote, false);
|
||||||
|
|
||||||
this.manager.skipShutdownPersist = true;
|
this.manager.skipShutdownPersist = true;
|
||||||
this.manager.blockAllPersistence = true;
|
this.manager.blockAllPersistence = true;
|
||||||
logger.info("Backup wiederhergestellt — App startet automatisch neu");
|
logger.info("Backup wiederhergestellt — App startet automatisch neu");
|
||||||
|
|||||||
@ -2,6 +2,12 @@ import type { AppSettings, SessionState, HistoryEntry } from "../shared/types";
|
|||||||
|
|
||||||
export type BackupKind = "full" | "settings-only";
|
export type BackupKind = "full" | "settings-only";
|
||||||
|
|
||||||
|
export interface BackupMcpRemote {
|
||||||
|
allowlist: string[];
|
||||||
|
port: number;
|
||||||
|
hostMode: "local" | "network";
|
||||||
|
}
|
||||||
|
|
||||||
export interface BackupPayload {
|
export interface BackupPayload {
|
||||||
version: 2;
|
version: 2;
|
||||||
kind: BackupKind;
|
kind: BackupKind;
|
||||||
@ -10,6 +16,7 @@ export interface BackupPayload {
|
|||||||
settings: AppSettings;
|
settings: AppSettings;
|
||||||
session?: SessionState;
|
session?: SessionState;
|
||||||
history?: HistoryEntry[];
|
history?: HistoryEntry[];
|
||||||
|
mcpRemote?: BackupMcpRemote;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BuildBackupInput {
|
export interface BuildBackupInput {
|
||||||
@ -19,6 +26,7 @@ export interface BuildBackupInput {
|
|||||||
/** Only bundled when includeDownloads is true. */
|
/** Only bundled when includeDownloads is true. */
|
||||||
session: SessionState;
|
session: SessionState;
|
||||||
history: HistoryEntry[];
|
history: HistoryEntry[];
|
||||||
|
mcpRemote?: BackupMcpRemote;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -40,9 +48,39 @@ export function buildBackupPayload(input: BuildBackupInput): BackupPayload {
|
|||||||
base.session = input.session;
|
base.session = input.session;
|
||||||
base.history = input.history;
|
base.history = input.history;
|
||||||
}
|
}
|
||||||
|
if (Boolean(input.settings.backupIncludeMcp) && input.mcpRemote) {
|
||||||
|
base.mcpRemote = input.mcpRemote;
|
||||||
|
}
|
||||||
return base;
|
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 {
|
export interface ImportPlan {
|
||||||
valid: boolean;
|
valid: boolean;
|
||||||
/** Restore the download list (session + history) and relaunch. */
|
/** Restore the download list (session + history) and relaunch. */
|
||||||
|
|||||||
@ -109,6 +109,7 @@ export function defaultSettings(): AppSettings {
|
|||||||
hideExtractedItems: true,
|
hideExtractedItems: true,
|
||||||
confirmDeleteSelection: true,
|
confirmDeleteSelection: true,
|
||||||
backupIncludeDownloads: false,
|
backupIncludeDownloads: false,
|
||||||
|
backupIncludeMcp: false,
|
||||||
notifyUrl: "",
|
notifyUrl: "",
|
||||||
notifyMention: "",
|
notifyMention: "",
|
||||||
notifyOnPackageCompleted: false,
|
notifyOnPackageCompleted: false,
|
||||||
|
|||||||
@ -288,6 +288,7 @@ type MegaDebridCooldownCategory = "invalid" | "rate_limit" | "quota" | "temporar
|
|||||||
type MegaDebridCooldownDetail = { until: number; message: string; category: MegaDebridCooldownCategory; untilRestart?: boolean };
|
type MegaDebridCooldownDetail = { until: number; message: string; category: MegaDebridCooldownCategory; untilRestart?: boolean };
|
||||||
const megaDebridAccountCooldowns = new Map<string, MegaDebridCooldownDetail>();
|
const megaDebridAccountCooldowns = new Map<string, MegaDebridCooldownDetail>();
|
||||||
const MEGA_DEBRID_ACCOUNT_COOLDOWN_MS = 120_000;
|
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;
|
const MEGA_DEBRID_INVALID_ACCOUNT_COOLDOWN_MS = 60 * 60 * 1000;
|
||||||
|
|
||||||
// A Mega-Web account abort (the shared unrestrict timeout firing while this
|
// A Mega-Web account abort (the shared unrestrict timeout firing while this
|
||||||
@ -301,7 +302,7 @@ function getMegaDebridAbortMinRunMs(): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const megaDebridEmptyResponseStreaks = new Map<string, number>();
|
const megaDebridEmptyResponseStreaks = new Map<string, number>();
|
||||||
export const MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART = 3;
|
export const MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART = 10;
|
||||||
|
|
||||||
let megaDebridRotationCursor = 0;
|
let megaDebridRotationCursor = 0;
|
||||||
let megaDebridStickyCount = 0;
|
let megaDebridStickyCount = 0;
|
||||||
@ -338,6 +339,25 @@ export function resetMegaDebridRuntimeStateForTests(): void {
|
|||||||
megaDebridInFlight.clear();
|
megaDebridInFlight.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getMegaDebridInFlightCountForMode(mode: "api" | "web"): number {
|
||||||
|
const suffix = `:${mode}`;
|
||||||
|
let total = 0;
|
||||||
|
for (const [key, count] of megaDebridInFlight) {
|
||||||
|
if (key.endsWith(suffix)) {
|
||||||
|
total += count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function primeMegaDebridInFlightForTests(key: string, count: number): void {
|
||||||
|
if (count <= 0) {
|
||||||
|
megaDebridInFlight.delete(key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
megaDebridInFlight.set(key, count);
|
||||||
|
}
|
||||||
|
|
||||||
export function pruneExpiredMegaDebridRuntimeState(now = Date.now()): number {
|
export function pruneExpiredMegaDebridRuntimeState(now = Date.now()): number {
|
||||||
let removed = 0;
|
let removed = 0;
|
||||||
const grace = 60 * 60 * 1000;
|
const grace = 60 * 60 * 1000;
|
||||||
@ -422,6 +442,106 @@ export function getMegaDebridAccountCooldownState(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProviderRuntimeCooldown {
|
||||||
|
untilMs: number;
|
||||||
|
remainingMs: number;
|
||||||
|
message: string;
|
||||||
|
category: string;
|
||||||
|
untilRestart?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProviderRuntimeSnapshot {
|
||||||
|
capturedAtMs: number;
|
||||||
|
megaDebrid: {
|
||||||
|
rotationCursor: number;
|
||||||
|
stickyCount: number;
|
||||||
|
accounts: Array<{
|
||||||
|
key: string;
|
||||||
|
cooldown: ProviderRuntimeCooldown | null;
|
||||||
|
inFlight: number;
|
||||||
|
emptyResponseStreak: number;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
debridLink: {
|
||||||
|
keys: Array<{
|
||||||
|
keyId: string;
|
||||||
|
cooldown: ProviderRuntimeCooldown | null;
|
||||||
|
runtimeStatus: { state: string; detail: string; updatedAt: number } | null;
|
||||||
|
}>;
|
||||||
|
hostCooldowns: Array<{ key: string; cooldown: ProviderRuntimeCooldown }>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProviderRuntimeSnapshot(now = Date.now()): ProviderRuntimeSnapshot {
|
||||||
|
const megaKeys = new Set<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 LINKSNAPPY_API_BASE = "https://linksnappy.com/api";
|
||||||
|
|
||||||
const PROVIDER_LABELS: Record<DebridProvider, string> = {
|
const PROVIDER_LABELS: Record<DebridProvider, string> = {
|
||||||
@ -2068,15 +2188,33 @@ class MegaDebridClient {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
const elapsedMs = Date.now() - testStartedAt;
|
const elapsedMs = Date.now() - testStartedAt;
|
||||||
const abortText = compactErrorText(error).replace(/^Error:\s*/i, "");
|
const abortText = compactErrorText(error).replace(/^Error:\s*/i, "");
|
||||||
// Timeout/abort on THIS account (the shared unrestrict signal fired). Cool
|
// Timeout/abort on THIS account (the shared unrestrict timeout fired). The
|
||||||
// the account down — if it actually ran, not a quick user-cancel — so the
|
// account-wide cooldown exists ONLY to make the retry rotate to another
|
||||||
// download-manager's retry rotates to the NEXT account instead of hammering
|
// account — so it is set only when another usable account actually exists.
|
||||||
// this one. The shared signal is now aborted, so we stop this pass; the
|
// With no rotation target (single account / all others busy), cooling the
|
||||||
// retry runs the rotation fresh with this account skipped. A genuine cancel
|
// sole account would freeze EVERY queued item while the account is healthy;
|
||||||
// is not retried by the caller, so the cooldown is harmless there.
|
// 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.
|
||||||
if (/aborted/i.test(abortText) && !/timeout/i.test(abortText)) {
|
if (/aborted/i.test(abortText) && !/timeout/i.test(abortText)) {
|
||||||
const ranLongEnough = elapsedMs >= getMegaDebridAbortMinRunMs();
|
const ranLongEnough = elapsedMs >= getMegaDebridAbortMinRunMs();
|
||||||
if (ranLongEnough) {
|
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) {
|
||||||
setMegaDebridAccountCooldownState(cooldownKey, MEGA_DEBRID_ACCOUNT_COOLDOWN_MS, `Abbruch/Timeout nach ${Math.ceil(elapsedMs / 1000)}s`, "temporary");
|
setMegaDebridAccountCooldownState(cooldownKey, MEGA_DEBRID_ACCOUNT_COOLDOWN_MS, `Abbruch/Timeout nach ${Math.ceil(elapsedMs / 1000)}s`, "temporary");
|
||||||
}
|
}
|
||||||
traceConversionPhase({
|
traceConversionPhase({
|
||||||
@ -2085,15 +2223,18 @@ class MegaDebridClient {
|
|||||||
account: rotationLabel,
|
account: rotationLabel,
|
||||||
workMs: elapsedMs,
|
workMs: elapsedMs,
|
||||||
outcome: "aborted",
|
outcome: "aborted",
|
||||||
detail: `${abortText}${ranLongEnough ? ` cd=${Math.ceil(MEGA_DEBRID_ACCOUNT_COOLDOWN_MS / 1000)}s` : ""}`
|
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}`);
|
failures.push(`Mega-Debrid${accountLabel}: ${abortText}`);
|
||||||
logAccountRotation("WARN", providerName, rotationLabel, "TIMEOUT_COOLDOWN", {
|
logAccountRotation("WARN", providerName, rotationLabel, "TIMEOUT_COOLDOWN", {
|
||||||
elapsedMs,
|
elapsedMs,
|
||||||
reason: abortText,
|
reason: abortText,
|
||||||
cooldownSec: ranLongEnough ? Math.ceil(MEGA_DEBRID_ACCOUNT_COOLDOWN_MS / 1000) : 0,
|
cooldownSec: rotateToAnotherAccount ? Math.ceil(MEGA_DEBRID_ACCOUNT_COOLDOWN_MS / 1000) : 0,
|
||||||
next: "naechster Account beim Retry"
|
next: rotateToAnotherAccount ? "naechster Account beim Retry" : "Einzel-Retry (Account bleibt fuer andere Items frei)"
|
||||||
});
|
});
|
||||||
|
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}`);
|
throw new Error(`Mega-Debrid${accountLabel}: ${abortText}`);
|
||||||
}
|
}
|
||||||
const failure = MegaDebridClient.classifyAccountFailure(error);
|
const failure = MegaDebridClient.classifyAccountFailure(error);
|
||||||
@ -2113,7 +2254,7 @@ class MegaDebridClient {
|
|||||||
const streak = recordMegaDebridEmptyResponseStreak(cooldownKey);
|
const streak = recordMegaDebridEmptyResponseStreak(cooldownKey);
|
||||||
if (streak >= MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART) {
|
if (streak >= MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART) {
|
||||||
parkUntilRestart = true;
|
parkUntilRestart = true;
|
||||||
parkMessage = `Tageslimit erreicht (${streak}x kein Server/leere Antwort) — bis zum Tagesreset gesperrt`;
|
parkMessage = `Tageslimit erreicht (${streak}x leere Antwort in Folge) — bis zum Tagesreset gesperrt`;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
clearMegaDebridEmptyResponseStreak(cooldownKey);
|
clearMegaDebridEmptyResponseStreak(cooldownKey);
|
||||||
|
|||||||
@ -15,6 +15,8 @@ import { createStoragePaths, loadHistory, loadSettings } from "./storage";
|
|||||||
import { buildAccountSummary, buildRedactedSettingsPayload, buildStatsPayload, summarizeHistoryEntry } from "./support-data";
|
import { buildAccountSummary, buildRedactedSettingsPayload, buildStatsPayload, summarizeHistoryEntry } from "./support-data";
|
||||||
import { buildSupportBundle, getSupportBundleDefaultFileName } from "./support-bundle";
|
import { buildSupportBundle, getSupportBundleDefaultFileName } from "./support-bundle";
|
||||||
import { getTraceConfig, getTraceConfigPath, getTraceLogPath, logTraceEvent, setTraceEnabled, updateTraceConfig } from "./trace-log";
|
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 { getWindowsHostDiagnostics } from "./windows-host-diagnostics";
|
||||||
import type { DownloadManager } from "./download-manager";
|
import type { DownloadManager } from "./download-manager";
|
||||||
import type { DownloadItem, PackageEntry, UiSnapshot } from "../shared/types";
|
import type { DownloadItem, PackageEntry, UiSnapshot } from "../shared/types";
|
||||||
@ -43,12 +45,14 @@ const DEBUG_ENDPOINTS: DebugEndpointDescriptor[] = [
|
|||||||
{ method: "GET", path: "/logs/rename", queryExample: "lines=100&grep=keyword", description: "Reads the dedicated rename and MKV move log." },
|
{ method: "GET", path: "/logs/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/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/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/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: "/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: "/errors", queryExample: "level=ERROR&limit=100", description: "Returns the in-memory ring of the most recent WARN/ERROR log lines." },
|
||||||
{ method: "GET", path: "/trace/config", queryExample: "enable=1¬e=support&durationMinutes=120", description: "Reads or updates the support trace configuration." },
|
{ method: "GET", path: "/trace/config", queryExample: "enable=1¬e=support&durationMinutes=120", description: "Reads or updates the support trace configuration." },
|
||||||
{ method: "GET", path: "/settings", description: "Returns a redacted settings snapshot without raw secrets." },
|
{ method: "GET", path: "/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: "/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: "/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: "/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." },
|
{ method: "GET", path: "/status", description: "Returns a live high-level status overview." },
|
||||||
@ -356,6 +360,7 @@ function buildAiManifest(baseDir: string): Record<string, unknown> {
|
|||||||
"Call /meta first to confirm the server is reachable and to re-read the endpoint list.",
|
"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 /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.",
|
"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."
|
"If a full handoff is needed, download /support/bundle as a ZIP."
|
||||||
],
|
],
|
||||||
auth: {
|
auth: {
|
||||||
@ -707,6 +712,25 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
|
|||||||
return;
|
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") {
|
if (pathname === "/trace/config") {
|
||||||
const patch: Record<string, unknown> = {};
|
const patch: Record<string, unknown> = {};
|
||||||
const enabled = toBooleanQuery(url.searchParams.get("enable"));
|
const enabled = toBooleanQuery(url.searchParams.get("enable"));
|
||||||
@ -992,6 +1016,7 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
|
|||||||
settings: buildRedactedSettingsPayload(readSupportSettings()),
|
settings: buildRedactedSettingsPayload(readSupportSettings()),
|
||||||
stats: buildStatsPayload(snapshot),
|
stats: buildStatsPayload(snapshot),
|
||||||
accounts: buildAccountSummary(readSupportSettings()),
|
accounts: buildAccountSummary(readSupportSettings()),
|
||||||
|
providers: getProviderRuntimeSnapshot(),
|
||||||
history: {
|
history: {
|
||||||
total: readSupportHistory().length,
|
total: readSupportHistory().length,
|
||||||
recent: readSupportHistory()
|
recent: readSupportHistory()
|
||||||
@ -1023,6 +1048,10 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
|
|||||||
path: sessionLogPath,
|
path: sessionLogPath,
|
||||||
lines: filterLines(readLogTailFromFile(sessionLogPath, lineCount), grep)
|
lines: filterLines(readLogTailFromFile(sessionLogPath, lineCount), grep)
|
||||||
},
|
},
|
||||||
|
conversion: {
|
||||||
|
path: getConversionLogPath(),
|
||||||
|
lines: getConversionLogPath() ? filterLines(readLogTailFromFile(getConversionLogPath() as string, lineCount), grep) : []
|
||||||
|
},
|
||||||
package: selectedPackage ? {
|
package: selectedPackage ? {
|
||||||
path: packageLogPath,
|
path: packageLogPath,
|
||||||
lines: filterLines(readLogTailFromFile(packageLogPath, lineCount), grep)
|
lines: filterLines(readLogTailFromFile(packageLogPath, lineCount), grep)
|
||||||
|
|||||||
@ -52,7 +52,7 @@ function releaseTlsSkip(): void {
|
|||||||
}
|
}
|
||||||
import { cleanupCancelledPackageArtifactsAsync, removeDownloadLinkArtifacts, removeSampleArtifacts } from "./cleanup";
|
import { cleanupCancelledPackageArtifactsAsync, removeDownloadLinkArtifacts, removeSampleArtifacts } from "./cleanup";
|
||||||
import { planDownloadCompletion, reconcileFinalizedSize, validateDownloadedFileCompletion } from "./download-completion";
|
import { planDownloadCompletion, reconcileFinalizedSize, validateDownloadedFileCompletion } from "./download-completion";
|
||||||
import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, DebridService, MegaWebUnrestrictor, RealDebridWebUnrestrictor, checkRapidgatorOnline, fetchAllDebridHostInfo, getAvailableDebridLinkApiKeys, getAvailableMegaDebridAccounts, getMegaDebridAccountCooldownState, pruneExpiredDebridLinkRuntimeState, pruneExpiredMegaDebridRuntimeState } from "./debrid";
|
import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, DebridService, MegaWebUnrestrictor, RealDebridWebUnrestrictor, checkRapidgatorOnline, fetchAllDebridHostInfo, getAvailableDebridLinkApiKeys, getAvailableMegaDebridAccounts, getMegaDebridAccountCooldownState, getMegaDebridInFlightCountForMode, pruneExpiredDebridLinkRuntimeState, pruneExpiredMegaDebridRuntimeState } from "./debrid";
|
||||||
import { cleanupArchives, clearExtractResumeState, collectArchiveCleanupTargets, detectArchiveSignature, extractPackageArchives, findArchiveCandidates, hasAnyFilesRecursive, removeEmptyDirectoryTree, resetExtractorCachesForPasswordChange, type ExtractArchiveFailureInfo } from "./extractor";
|
import { cleanupArchives, clearExtractResumeState, collectArchiveCleanupTargets, detectArchiveSignature, extractPackageArchives, findArchiveCandidates, hasAnyFilesRecursive, removeEmptyDirectoryTree, resetExtractorCachesForPasswordChange, type ExtractArchiveFailureInfo } from "./extractor";
|
||||||
import { validateFileAgainstManifest } from "./integrity";
|
import { validateFileAgainstManifest } from "./integrity";
|
||||||
import { classifyDiskError } from "./fs-error";
|
import { classifyDiskError } from "./fs-error";
|
||||||
@ -131,6 +131,17 @@ const ARCHIVE_SETTLE_MAX_WAIT_MS = 5000;
|
|||||||
|
|
||||||
const MAX_SAME_DIRECT_URL_ATTEMPTS = 3;
|
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 RESUME_REWIND_BYTES = 256 * 1024;
|
||||||
|
|
||||||
const REALDEBRID_TOTAL_MISMATCH_TOLERANCE_BYTES = 64 * 1024;
|
const REALDEBRID_TOTAL_MISMATCH_TOLERANCE_BYTES = 64 * 1024;
|
||||||
@ -363,6 +374,7 @@ type DownloadManagerOptions = {
|
|||||||
bestDebridWebUnrestrict?: BestDebridWebUnrestrictor;
|
bestDebridWebUnrestrict?: BestDebridWebUnrestrictor;
|
||||||
invalidateMegaSession?: () => void;
|
invalidateMegaSession?: () => void;
|
||||||
onHistoryEntry?: HistoryEntryCallback;
|
onHistoryEntry?: HistoryEntryCallback;
|
||||||
|
protectEmptyClobber?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function generateHistoryId(): string {
|
function generateHistoryId(): string {
|
||||||
@ -659,6 +671,20 @@ export function parseMegaDebridCooldownRetry(errorText: string): { delayMs: numb
|
|||||||
return { delayMs, detail: text.replace(/mega_debrid_cooldown:\d+:/i, "").trim() };
|
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 {
|
export function parseMegaDebridResetPark(errorText: string): { delayMs: number; detail: string } | null {
|
||||||
const match = String(errorText || "").match(/mega_debrid_reset_park:(\d+):(.*)$/is);
|
const match = String(errorText || "").match(/mega_debrid_reset_park:(\d+):(.*)$/is);
|
||||||
if (!match) {
|
if (!match) {
|
||||||
@ -1688,6 +1714,10 @@ export class DownloadManager extends EventEmitter {
|
|||||||
|
|
||||||
public blockAllPersistence = false;
|
public blockAllPersistence = false;
|
||||||
|
|
||||||
|
private protectAgainstEmptyClobber = false;
|
||||||
|
|
||||||
|
private emptyClobberProtectionLogged = false;
|
||||||
|
|
||||||
private debridService: DebridService;
|
private debridService: DebridService;
|
||||||
|
|
||||||
private invalidateMegaSessionFn?: () => void;
|
private invalidateMegaSessionFn?: () => void;
|
||||||
@ -1810,6 +1840,8 @@ export class DownloadManager extends EventEmitter {
|
|||||||
unrestrictRetries: number;
|
unrestrictRetries: number;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
private http416FreshRestartByItem = new Map<string, number>();
|
||||||
|
|
||||||
private providerFailures = new Map<string, { count: number; lastFailAt: number; cooldownUntil: number }>();
|
private providerFailures = new Map<string, { count: number; lastFailAt: number; cooldownUntil: number }>();
|
||||||
|
|
||||||
private allDebridHostInfoCache = new Map<string, { info: AllDebridHostInfo; cachedAt: number }>();
|
private allDebridHostInfoCache = new Map<string, { info: AllDebridHostInfo; cachedAt: number }>();
|
||||||
@ -1831,6 +1863,10 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.session = session;
|
this.session = session;
|
||||||
this.itemCount = Object.keys(this.session.items).length;
|
this.itemCount = Object.keys(this.session.items).length;
|
||||||
this.storagePaths = storagePaths;
|
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, {
|
this.debridService = new DebridService(settings, {
|
||||||
megaWebUnrestrict: options.megaWebUnrestrict,
|
megaWebUnrestrict: options.megaWebUnrestrict,
|
||||||
allDebridWebUnrestrict: options.allDebridWebUnrestrict,
|
allDebridWebUnrestrict: options.allDebridWebUnrestrict,
|
||||||
@ -2988,6 +3024,14 @@ export class DownloadManager extends EventEmitter {
|
|||||||
continue;
|
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)) {
|
if (!this.isPackageSpecificExtractDir(pkg)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -5539,7 +5583,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async start(): Promise<void> {
|
public async start(options?: { excludePackageIds?: ReadonlySet<string> }): Promise<void> {
|
||||||
if (this.session.running) {
|
if (this.session.running) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -5580,6 +5624,9 @@ export class DownloadManager extends EventEmitter {
|
|||||||
if (item.status !== "queued" && item.status !== "reconnect_wait") {
|
if (item.status !== "queued" && item.status !== "reconnect_wait") {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (options?.excludePackageIds?.has(item.packageId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
const pkg = this.session.packages[item.packageId];
|
const pkg = this.session.packages[item.packageId];
|
||||||
return Boolean(pkg && !pkg.cancelled && pkg.enabled);
|
return Boolean(pkg && !pkg.cancelled && pkg.enabled);
|
||||||
});
|
});
|
||||||
@ -5644,9 +5691,15 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.providerStartReservations.clear();
|
this.providerStartReservations.clear();
|
||||||
this.pacedStartReservationByItem.clear();
|
this.pacedStartReservationByItem.clear();
|
||||||
this.retryStateByItem.clear();
|
this.retryStateByItem.clear();
|
||||||
|
this.http416FreshRestartByItem.clear();
|
||||||
this.itemContributedBytes.clear();
|
this.itemContributedBytes.clear();
|
||||||
this.reservedTargetPaths.clear();
|
this.reservedTargetPaths.clear();
|
||||||
this.claimedTargetPathByItem.clear();
|
this.claimedTargetPathByItem.clear();
|
||||||
|
if (options?.excludePackageIds) {
|
||||||
|
for (const excluded of options.excludePackageIds) {
|
||||||
|
this.runPackageIds.delete(excluded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.session.running = true;
|
this.session.running = true;
|
||||||
this.session.paused = false;
|
this.session.paused = false;
|
||||||
@ -5821,7 +5874,9 @@ export class DownloadManager extends EventEmitter {
|
|||||||
const itemCount = Object.keys(this.session.items).length;
|
const itemCount = Object.keys(this.session.items).length;
|
||||||
logger.info(`Shutdown-Save: ${pkgCount} Pakete, ${itemCount} Items`);
|
logger.info(`Shutdown-Save: ${pkgCount} Pakete, ${itemCount} Items`);
|
||||||
this.foldRuntimeIntoSettings(nowMs());
|
this.foldRuntimeIntoSettings(nowMs());
|
||||||
|
if (!this.guardBlocksSessionSave()) {
|
||||||
saveSession(this.storagePaths, this.session);
|
saveSession(this.storagePaths, this.session);
|
||||||
|
}
|
||||||
saveSettings(this.storagePaths, this.settings);
|
saveSettings(this.storagePaths, this.settings);
|
||||||
} else {
|
} else {
|
||||||
logger.info(`Shutdown-Save übersprungen: skipShutdownPersist=${this.skipShutdownPersist}, blockAllPersistence=${this.blockAllPersistence}`);
|
logger.info(`Shutdown-Save übersprungen: skipShutdownPersist=${this.skipShutdownPersist}, blockAllPersistence=${this.blockAllPersistence}`);
|
||||||
@ -6124,10 +6179,29 @@ export class DownloadManager extends EventEmitter {
|
|||||||
}, delay);
|
}, 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 {
|
private persistNow(): void {
|
||||||
const now = nowMs();
|
const now = nowMs();
|
||||||
this.lastPersistAt = now;
|
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) {
|
if (now - this.lastSettingsPersistAt >= 30000) {
|
||||||
this.foldRuntimeIntoSettings(now);
|
this.foldRuntimeIntoSettings(now);
|
||||||
this.lastSettingsPersistAt = now;
|
this.lastSettingsPersistAt = now;
|
||||||
@ -6141,7 +6215,9 @@ export class DownloadManager extends EventEmitter {
|
|||||||
const itemCount = Object.keys(this.session.items).length;
|
const itemCount = Object.keys(this.session.items).length;
|
||||||
logger.info(`Pre-Update Sync-Save: ${pkgCount} Pakete, ${itemCount} Items`);
|
logger.info(`Pre-Update Sync-Save: ${pkgCount} Pakete, ${itemCount} Items`);
|
||||||
this.foldRuntimeIntoSettings(nowMs());
|
this.foldRuntimeIntoSettings(nowMs());
|
||||||
|
if (!this.guardBlocksSessionSave()) {
|
||||||
saveSession(this.storagePaths, this.session);
|
saveSession(this.storagePaths, this.session);
|
||||||
|
}
|
||||||
saveSettings(this.storagePaths, this.settings);
|
saveSettings(this.storagePaths, this.settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -8150,7 +8226,13 @@ export class DownloadManager extends EventEmitter {
|
|||||||
const provider = resolveMegaDebridProvider(this.settings, this.getExpectedProviderForItem(item));
|
const provider = resolveMegaDebridProvider(this.settings, this.getExpectedProviderForItem(item));
|
||||||
const serializedValidatingLimit = this.getSerializedValidatingLimit(provider);
|
const serializedValidatingLimit = this.getSerializedValidatingLimit(provider);
|
||||||
if (provider && Number.isFinite(serializedValidatingLimit) && serializedValidatingLimit < Number.MAX_SAFE_INTEGER) {
|
if (provider && Number.isFinite(serializedValidatingLimit) && serializedValidatingLimit < Number.MAX_SAFE_INTEGER) {
|
||||||
return this.getProviderValidatingTaskCount(provider, item.id) >= serializedValidatingLimit;
|
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;
|
||||||
}
|
}
|
||||||
if (provider !== "alldebrid") {
|
if (provider !== "alldebrid") {
|
||||||
return false;
|
return false;
|
||||||
@ -8659,6 +8741,53 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.queueRetry(item, active, delayMs, `HTTP 416 erkannt, Retry ${active.genericErrorRetries}/${retryDisplayLimit}`);
|
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 {
|
private startItem(packageId: string, itemId: string): void {
|
||||||
const item = this.session.items[itemId];
|
const item = this.session.items[itemId];
|
||||||
const pkg = this.session.packages[packageId];
|
const pkg = this.session.packages[packageId];
|
||||||
@ -9249,12 +9378,16 @@ export class DownloadManager extends EventEmitter {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (isHttp416Text(exhaustedReason) && active.genericErrorRetries < maxHttp416Retries) {
|
if (isHttp416Text(exhaustedReason)) {
|
||||||
|
if (active.genericErrorRetries < maxHttp416Retries) {
|
||||||
this.scheduleHttp416Retry(item, active, retryDisplayLimit, exhaustedReason, claimedTargetPath);
|
this.scheduleHttp416Retry(item, active, retryDisplayLimit, exhaustedReason, claimedTargetPath);
|
||||||
this.persistSoon();
|
this.persistSoon();
|
||||||
this.emitState();
|
this.emitState();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
this.escalateHttp416OrFail(item, active, claimedTargetPath, exhaustedReason);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (isResumeHardResetReason(exhaustedReason) && !active.resumeHardResetUsed) {
|
if (isResumeHardResetReason(exhaustedReason) && !active.resumeHardResetUsed) {
|
||||||
active.resumeHardResetUsed = true;
|
active.resumeHardResetUsed = true;
|
||||||
item.retries += 1;
|
item.retries += 1;
|
||||||
@ -9309,17 +9442,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.emitState();
|
this.emitState();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
item.status = "failed";
|
this.escalateHttp416OrFail(item, active, claimedTargetPath, errorText);
|
||||||
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;
|
return;
|
||||||
}
|
}
|
||||||
if (shouldFreshRetry) {
|
if (shouldFreshRetry) {
|
||||||
@ -9435,6 +9558,24 @@ export class DownloadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const megaRawError = error instanceof Error ? String(error.message || "") : String(error || "");
|
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);
|
const megaCooldownRetry = parseMegaDebridCooldownRetry(megaRawError);
|
||||||
if (megaCooldownRetry && active.unrestrictRetries < maxUnrestrictRetries) {
|
if (megaCooldownRetry && active.unrestrictRetries < maxUnrestrictRetries) {
|
||||||
active.unrestrictRetries += 1;
|
active.unrestrictRetries += 1;
|
||||||
|
|||||||
@ -571,7 +571,7 @@ function registerIpcHandlers(): void {
|
|||||||
|
|
||||||
ipcMain.handle(IPC_CHANNELS.EXPORT_BACKUP, async () => {
|
ipcMain.handle(IPC_CHANNELS.EXPORT_BACKUP, async () => {
|
||||||
const options = {
|
const options = {
|
||||||
defaultPath: `mdd-backup-${new Date().toISOString().slice(0, 10)}.mdd`,
|
defaultPath: `${new Date().toISOString().slice(0, 10).split("-").reverse().join("-")}-mdd-backup.mdd`,
|
||||||
filters: [{ name: "MDD Backup", extensions: ["mdd"] }]
|
filters: [{ name: "MDD Backup", extensions: ["mdd"] }]
|
||||||
};
|
};
|
||||||
const result = mainWindow ? await dialog.showSaveDialog(mainWindow, options) : await dialog.showSaveDialog(options);
|
const result = mainWindow ? await dialog.showSaveDialog(mainWindow, options) : await dialog.showSaveDialog(options);
|
||||||
|
|||||||
@ -461,6 +461,7 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
|
|||||||
hideExtractedItems: settings.hideExtractedItems !== undefined ? Boolean(settings.hideExtractedItems) : defaults.hideExtractedItems,
|
hideExtractedItems: settings.hideExtractedItems !== undefined ? Boolean(settings.hideExtractedItems) : defaults.hideExtractedItems,
|
||||||
confirmDeleteSelection: settings.confirmDeleteSelection !== undefined ? Boolean(settings.confirmDeleteSelection) : defaults.confirmDeleteSelection,
|
confirmDeleteSelection: settings.confirmDeleteSelection !== undefined ? Boolean(settings.confirmDeleteSelection) : defaults.confirmDeleteSelection,
|
||||||
backupIncludeDownloads: settings.backupIncludeDownloads !== undefined ? Boolean(settings.backupIncludeDownloads) : defaults.backupIncludeDownloads,
|
backupIncludeDownloads: settings.backupIncludeDownloads !== undefined ? Boolean(settings.backupIncludeDownloads) : defaults.backupIncludeDownloads,
|
||||||
|
backupIncludeMcp: settings.backupIncludeMcp !== undefined ? Boolean(settings.backupIncludeMcp) : defaults.backupIncludeMcp,
|
||||||
notifyUrl: asText(settings.notifyUrl) || defaults.notifyUrl,
|
notifyUrl: asText(settings.notifyUrl) || defaults.notifyUrl,
|
||||||
notifyMention: asText(settings.notifyMention) || defaults.notifyMention,
|
notifyMention: asText(settings.notifyMention) || defaults.notifyMention,
|
||||||
notifyOnPackageCompleted: settings.notifyOnPackageCompleted !== undefined ? Boolean(settings.notifyOnPackageCompleted) : defaults.notifyOnPackageCompleted,
|
notifyOnPackageCompleted: settings.notifyOnPackageCompleted !== undefined ? Boolean(settings.notifyOnPackageCompleted) : defaults.notifyOnPackageCompleted,
|
||||||
@ -889,21 +890,50 @@ export function normalizeLoadedSessionTransientFields(session: SessionState): Se
|
|||||||
return session;
|
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 {
|
function readSessionFile(filePath: string): SessionState | null {
|
||||||
|
let raw: string | null = null;
|
||||||
|
const maxAttempts = 5;
|
||||||
|
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as unknown;
|
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 session = normalizeLoadedSessionTransientFields(normalizeLoadedSession(parsed));
|
const session = normalizeLoadedSessionTransientFields(normalizeLoadedSession(parsed));
|
||||||
const pkgCount = Object.keys(session.packages).length;
|
const pkgCount = Object.keys(session.packages).length;
|
||||||
const itemCount = Object.keys(session.items).length;
|
const itemCount = Object.keys(session.items).length;
|
||||||
logger.info(`Session geladen: ${filePath} (${pkgCount} Pakete, ${itemCount} Items)`);
|
logger.info(`Session geladen: ${filePath} (${pkgCount} Pakete, ${itemCount} Items)`);
|
||||||
return session;
|
return session;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const code = (error as NodeJS.ErrnoException)?.code || "";
|
logger.error(`Session-Datei beschädigt (JSON ungültig): ${filePath}: ${String(error)}`);
|
||||||
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;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1003,17 +1033,31 @@ export function emptySession(): SessionState {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadSession(paths: StoragePaths): 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 {
|
||||||
ensureBaseDir(paths.baseDir);
|
ensureBaseDir(paths.baseDir);
|
||||||
const backupFile = sessionBackupPath(paths.sessionFile);
|
const backupFile = sessionBackupPath(paths.sessionFile);
|
||||||
|
const syncTempFile = sessionTempPath(paths.sessionFile, "sync");
|
||||||
|
const asyncTempFile = sessionTempPath(paths.sessionFile, "async");
|
||||||
const primaryExists = fs.existsSync(paths.sessionFile);
|
const primaryExists = fs.existsSync(paths.sessionFile);
|
||||||
|
const backupExists = fs.existsSync(backupFile);
|
||||||
|
const anyTempExists = fs.existsSync(syncTempFile) || fs.existsSync(asyncTempFile);
|
||||||
|
|
||||||
if (!primaryExists) {
|
if (!primaryExists) {
|
||||||
const hasRecoverable = fs.existsSync(backupFile)
|
if (!backupExists && !anyTempExists) {
|
||||||
|| fs.existsSync(sessionTempPath(paths.sessionFile, "sync"))
|
|
||||||
|| fs.existsSync(sessionTempPath(paths.sessionFile, "async"));
|
|
||||||
if (!hasRecoverable) {
|
|
||||||
logger.info("Keine Session-Datei vorhanden, starte mit leerer Session");
|
logger.info("Keine Session-Datei vorhanden, starte mit leerer Session");
|
||||||
return emptySession();
|
return { session: emptySession(), status: "empty-fresh" };
|
||||||
}
|
}
|
||||||
logger.warn("Session-Primaerdatei fehlt, aber Backup/Temp vorhanden — Wiederherstellung wird versucht");
|
logger.warn("Session-Primaerdatei fehlt, aber Backup/Temp vorhanden — Wiederherstellung wird versucht");
|
||||||
}
|
}
|
||||||
@ -1022,7 +1066,7 @@ export function loadSession(paths: StoragePaths): SessionState {
|
|||||||
|
|
||||||
if (primary) {
|
if (primary) {
|
||||||
const primaryPkgCount = Object.keys(primary.packages).length;
|
const primaryPkgCount = Object.keys(primary.packages).length;
|
||||||
if (primaryPkgCount === 0 && fs.existsSync(backupFile)) {
|
if (primaryPkgCount === 0 && backupExists) {
|
||||||
const backup = readSessionFile(backupFile);
|
const backup = readSessionFile(backupFile);
|
||||||
if (backup) {
|
if (backup) {
|
||||||
const backupPkgCount = Object.keys(backup.packages).length;
|
const backupPkgCount = Object.keys(backup.packages).length;
|
||||||
@ -1030,29 +1074,27 @@ export function loadSession(paths: StoragePaths): SessionState {
|
|||||||
logger.warn(`Session-Datei ist leer (0 Pakete), aber Backup hat ${backupPkgCount} Pakete — verwende Backup`);
|
logger.warn(`Session-Datei ist leer (0 Pakete), aber Backup hat ${backupPkgCount} Pakete — verwende Backup`);
|
||||||
try {
|
try {
|
||||||
const payload = JSON.stringify({ ...backup, updatedAt: Date.now() }, safeJsonReplacer);
|
const payload = JSON.stringify({ ...backup, updatedAt: Date.now() }, safeJsonReplacer);
|
||||||
const tempPath = sessionTempPath(paths.sessionFile, "sync");
|
fs.writeFileSync(syncTempFile, payload, "utf8");
|
||||||
fs.writeFileSync(tempPath, payload, "utf8");
|
syncRenameWithExdevFallback(syncTempFile, paths.sessionFile);
|
||||||
syncRenameWithExdevFallback(tempPath, paths.sessionFile);
|
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
return backup;
|
return { session: backup, status: "recovered-backup" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return primary;
|
return { session: primary, status: "ok" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const backup = fs.existsSync(backupFile) ? readSessionFile(backupFile) : null;
|
const backup = backupExists ? readSessionFile(backupFile) : null;
|
||||||
if (backup) {
|
if (backup) {
|
||||||
logger.warn("Session defekt, Backup-Datei wird verwendet");
|
logger.warn("Session defekt, Backup-Datei wird verwendet");
|
||||||
try {
|
try {
|
||||||
const payload = JSON.stringify({ ...backup, updatedAt: Date.now() }, safeJsonReplacer);
|
const payload = JSON.stringify({ ...backup, updatedAt: Date.now() }, safeJsonReplacer);
|
||||||
const tempPath = sessionTempPath(paths.sessionFile, "sync");
|
fs.writeFileSync(syncTempFile, payload, "utf8");
|
||||||
fs.writeFileSync(tempPath, payload, "utf8");
|
syncRenameWithExdevFallback(syncTempFile, paths.sessionFile);
|
||||||
syncRenameWithExdevFallback(tempPath, paths.sessionFile);
|
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
return backup;
|
return { session: backup, status: "recovered-backup" };
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const kind of ["sync", "async"] as const) {
|
for (const kind of ["sync", "async"] as const) {
|
||||||
@ -1066,13 +1108,21 @@ export function loadSession(paths: StoragePaths): SessionState {
|
|||||||
fs.writeFileSync(paths.sessionFile, payload, "utf8");
|
fs.writeFileSync(paths.sessionFile, payload, "utf8");
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
return tmpSession;
|
return { session: tmpSession, status: "recovered-temp" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.error("Session konnte nicht geladen werden (Primary, Backup und Temp-Dateien fehlgeschlagen)");
|
if (primaryExists || backupExists || anyTempExists) {
|
||||||
return emptySession();
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function saveSession(paths: StoragePaths, session: SessionState): void {
|
export function saveSession(paths: StoragePaths, session: SessionState): void {
|
||||||
@ -1087,7 +1137,13 @@ export function saveSession(paths: StoragePaths, session: SessionState): void {
|
|||||||
const payload = JSON.stringify({ ...session, updatedAt: Date.now() }, safeJsonReplacer);
|
const payload = JSON.stringify({ ...session, updatedAt: Date.now() }, safeJsonReplacer);
|
||||||
const tempPath = sessionTempPath(paths.sessionFile, "sync");
|
const tempPath = sessionTempPath(paths.sessionFile, "sync");
|
||||||
try {
|
try {
|
||||||
fs.writeFileSync(tempPath, payload, "utf8");
|
const fd = fs.openSync(tempPath, "w");
|
||||||
|
try {
|
||||||
|
fs.writeSync(fd, payload);
|
||||||
|
fs.fsyncSync(fd);
|
||||||
|
} finally {
|
||||||
|
fs.closeSync(fd);
|
||||||
|
}
|
||||||
syncRenameWithExdevFallback(tempPath, paths.sessionFile);
|
syncRenameWithExdevFallback(tempPath, paths.sessionFile);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
try { fs.rmSync(tempPath, { force: true }); } catch { }
|
try { fs.rmSync(tempPath, { force: true }); } catch { }
|
||||||
@ -1103,7 +1159,13 @@ async function writeSessionPayload(paths: StoragePaths, payload: string, generat
|
|||||||
await fs.promises.mkdir(paths.baseDir, { recursive: true });
|
await fs.promises.mkdir(paths.baseDir, { recursive: true });
|
||||||
await fsp.copyFile(paths.sessionFile, sessionBackupPath(paths.sessionFile)).catch(() => {});
|
await fsp.copyFile(paths.sessionFile, sessionBackupPath(paths.sessionFile)).catch(() => {});
|
||||||
const tempPath = sessionTempPath(paths.sessionFile, "async");
|
const tempPath = sessionTempPath(paths.sessionFile, "async");
|
||||||
await fsp.writeFile(tempPath, payload, "utf8");
|
const handle = await fsp.open(tempPath, "w");
|
||||||
|
try {
|
||||||
|
await handle.writeFile(payload, "utf8");
|
||||||
|
await handle.sync();
|
||||||
|
} finally {
|
||||||
|
await handle.close();
|
||||||
|
}
|
||||||
if (generation < syncSaveGeneration) {
|
if (generation < syncSaveGeneration) {
|
||||||
await fsp.rm(tempPath, { force: true }).catch(() => {});
|
await fsp.rm(tempPath, { force: true }).catch(() => {});
|
||||||
return;
|
return;
|
||||||
|
|||||||
@ -856,7 +856,7 @@ const emptySnapshot = (): UiSnapshot => ({
|
|||||||
autoReconnect: false, reconnectWaitSeconds: 45, completedCleanupPolicy: "never",
|
autoReconnect: false, reconnectWaitSeconds: 45, completedCleanupPolicy: "never",
|
||||||
maxParallel: 4, maxParallelExtract: 2, extractCpuPriority: "high", retryLimit: 0, speedLimitEnabled: false, speedLimitKbps: 0, speedLimitMode: "global",
|
maxParallel: 4, maxParallelExtract: 2, extractCpuPriority: "high", retryLimit: 0, speedLimitEnabled: false, speedLimitKbps: 0, speedLimitMode: "global",
|
||||||
updateRepo: "", autoUpdateCheck: true, clipboardWatch: false, minimizeToTray: false,
|
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,
|
theme: "dark", collapseNewPackages: true, historyRetentionMode: "permanent", historyMaxEntries: 500, historyMaxAgeDays: 0, autoSortPackagesByProgress: true, autoSkipExtracted: false, hideExtractedItems: true, confirmDeleteSelection: true, backupIncludeDownloads: false, backupIncludeMcp: false,
|
||||||
notifyUrl: "", notifyMention: "", notifyOnPackageCompleted: false, notifyOnPackageFailed: false, notifyOnRunFinished: false,
|
notifyUrl: "", notifyMention: "", notifyOnPackageCompleted: false, notifyOnPackageFailed: false, notifyOnRunFinished: false,
|
||||||
accountListShowDetailedDebridLinkKeys: false,
|
accountListShowDetailedDebridLinkKeys: false,
|
||||||
bandwidthSchedules: [], totalDownloadedAllTime: 0, totalCompletedFilesAllTime: 0, totalRuntimeAllTimeMs: 0,
|
bandwidthSchedules: [], totalDownloadedAllTime: 0, totalCompletedFilesAllTime: 0, totalRuntimeAllTimeMs: 0,
|
||||||
@ -5442,6 +5442,8 @@ export function App(): ReactElement {
|
|||||||
<div className="setting-hint">Sicherheitsabfrage vor dem Entfernen ausgewählter Einträge.</div>
|
<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>
|
<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>
|
<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) => {
|
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.theme === "light"} onChange={(e) => {
|
||||||
const next = e.target.checked ? "light" : "dark";
|
const next = e.target.checked ? "light" : "dark";
|
||||||
settingsDraftRevisionRef.current += 1;
|
settingsDraftRevisionRef.current += 1;
|
||||||
|
|||||||
@ -134,6 +134,7 @@ export interface AppSettings {
|
|||||||
hideExtractedItems: boolean;
|
hideExtractedItems: boolean;
|
||||||
confirmDeleteSelection: boolean;
|
confirmDeleteSelection: boolean;
|
||||||
backupIncludeDownloads: boolean;
|
backupIncludeDownloads: boolean;
|
||||||
|
backupIncludeMcp: boolean;
|
||||||
notifyUrl: string;
|
notifyUrl: string;
|
||||||
notifyMention: string;
|
notifyMention: string;
|
||||||
notifyOnPackageCompleted: boolean;
|
notifyOnPackageCompleted: boolean;
|
||||||
|
|||||||
@ -28,11 +28,15 @@ Plain HTTP + Bearer ueber Internet = sniffbares Token mit Lesezugriff auf sensib
|
|||||||
- [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] 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] 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] 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)
|
||||||
- [ ] **P1 Security-Hardening debug-server:** IP-Allowlist app-seitig; /trace/config-Mutation gaten/umlabeln; opt-in+revoke; userData-Pfad verifizieren.
|
- [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/*.
|
||||||
- [ ] **P2 One-Click-Enable + Code (App):** debug-server live (re)startbar ohne App-Neustart; IPC + flache UI (Anti-KI-Taste); Verbindungscode mit Copy + Revoke.
|
- [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.
|
||||||
- [ ] **P3 Diagnose-Luecken:** Provider-Cooldown/Rotation Live-State, "Was ist JETZT kaputt"-Triage, evtl. Live-Log-Tail.
|
- [x] **P3 — durch bestehende Endpunkte abgedeckt:** /accounts (Cooldown/Rotation), /errors, /status, /diagnostics via Bridge. Kein neuer Endpunkt noetig.
|
||||||
- [ ] **P4 Verify:** Tests gruen + tsc=6, Bridge end-to-end, Security-Review, Release v1.7.223 (Gitea+Mirror, 4 .exe). Bridge zu Claude Code (claude mcp add).
|
- [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-Acceptance (Nutzer):** debug-server auf 1 Server an → curl von Claude-Maschine. Direkt-IP vs Tunnel-Entscheid.
|
- [ ] **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).
|
||||||
|
|
||||||
## Review
|
## Review
|
||||||
(folgt)
|
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.
|
||||||
|
|||||||
232
tests/backup-mcp.test.ts
Normal file
232
tests/backup-mcp.test.ts
Normal file
@ -0,0 +1,232 @@
|
|||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -4,7 +4,7 @@ import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys";
|
|||||||
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
|
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
|
||||||
import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits";
|
import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits";
|
||||||
import { isMegaDebridTransientResolveFailure } from "../src/shared/mega-debrid-errors";
|
import { isMegaDebridTransientResolveFailure } from "../src/shared/mega-debrid-errors";
|
||||||
import { classifyMegaDebridAccountFailureForTests, clearMegaDebridEmptyResponseStreak, DebridService, extractRapidgatorFilenameFromHtml, fetchAllDebridHostInfo, fetchDebridLinkHostLimits, filenameFromRapidgatorUrlPath, getDebridLinkKeyCooldownStateForTests, getDebridLinkKeyRuntimeStateForTests, getMegaDebridAccountCooldownState, leadProviderChainWith, MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART, MEGA_DEBRID_STICKY_LINKS, normalizeResolvedFilename, primeMegaDebridUntilRestartForTests, recordMegaDebridEmptyResponseStreak, resetDebridLinkRuntimeStateForTests, resetMegaDebridRuntimeStateForTests } from "../src/main/debrid";
|
import { classifyMegaDebridAccountFailureForTests, clearMegaDebridEmptyResponseStreak, DebridService, extractRapidgatorFilenameFromHtml, fetchAllDebridHostInfo, fetchDebridLinkHostLimits, filenameFromRapidgatorUrlPath, getDebridLinkKeyCooldownStateForTests, getDebridLinkKeyRuntimeStateForTests, getMegaDebridAccountCooldownState, getProviderRuntimeSnapshot, leadProviderChainWith, MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART, MEGA_DEBRID_STICKY_LINKS, normalizeResolvedFilename, primeMegaDebridRuntimeCooldownForTests, primeMegaDebridUntilRestartForTests, recordMegaDebridEmptyResponseStreak, resetDebridLinkRuntimeStateForTests, resetMegaDebridRuntimeStateForTests } from "../src/main/debrid";
|
||||||
|
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
|
|
||||||
@ -2071,9 +2071,76 @@ describe("debrid service", () => {
|
|||||||
expect(calls).toBeGreaterThanOrEqual(1);
|
expect(calls).toBeGreaterThanOrEqual(1);
|
||||||
}, 20000);
|
}, 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", () => {
|
it("escalates a Mega-Debrid account to 'until restart' after the empty-response streak threshold", () => {
|
||||||
const key = `${getMegaDebridAccountId("user1")}:web`;
|
const key = `${getMegaDebridAccountId("user1")}:web`;
|
||||||
expect(MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART).toBe(3);
|
expect(MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART).toBe(10);
|
||||||
expect(recordMegaDebridEmptyResponseStreak(key)).toBe(1);
|
expect(recordMegaDebridEmptyResponseStreak(key)).toBe(1);
|
||||||
expect(recordMegaDebridEmptyResponseStreak(key)).toBe(2);
|
expect(recordMegaDebridEmptyResponseStreak(key)).toBe(2);
|
||||||
expect(recordMegaDebridEmptyResponseStreak(key)).toBe(3);
|
expect(recordMegaDebridEmptyResponseStreak(key)).toBe(3);
|
||||||
@ -2198,8 +2265,9 @@ describe("debrid service", () => {
|
|||||||
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
|
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
|
||||||
|
|
||||||
const key = `${getMegaDebridAccountId("user1")}:web`;
|
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);
|
expect(getMegaDebridAccountCooldownState(key)?.untilRestart ?? false).toBe(false);
|
||||||
|
|
||||||
const megaWeb = vi.fn(async () => null);
|
const megaWeb = vi.fn(async () => null);
|
||||||
|
|||||||
@ -14,7 +14,7 @@ import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits";
|
|||||||
import { getItemLogPath, initItemLogs, shutdownItemLogs } from "../src/main/item-log";
|
import { getItemLogPath, initItemLogs, shutdownItemLogs } from "../src/main/item-log";
|
||||||
import { initPackageLogs, shutdownPackageLogs } from "../src/main/package-log";
|
import { initPackageLogs, shutdownPackageLogs } from "../src/main/package-log";
|
||||||
import { createStoragePaths, emptySession } from "../src/main/storage";
|
import { createStoragePaths, emptySession } from "../src/main/storage";
|
||||||
import { primeDebridLinkRuntimeCooldownForTests, resetDebridLinkRuntimeStateForTests, primeMegaDebridRuntimeCooldownForTests, resetMegaDebridRuntimeStateForTests } from "../src/main/debrid";
|
import { primeDebridLinkRuntimeCooldownForTests, resetDebridLinkRuntimeStateForTests, primeMegaDebridRuntimeCooldownForTests, resetMegaDebridRuntimeStateForTests, primeMegaDebridInFlightForTests } from "../src/main/debrid";
|
||||||
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
|
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
|
||||||
import { getRenameLogPath, initRenameLog, shutdownRenameLog } from "../src/main/rename-log";
|
import { getRenameLogPath, initRenameLog, shutdownRenameLog } from "../src/main/rename-log";
|
||||||
import { UnrestrictedLink } from "../src/main/realdebrid";
|
import { UnrestrictedLink } from "../src/main/realdebrid";
|
||||||
@ -2209,6 +2209,98 @@ describe("download manager", () => {
|
|||||||
expect(fs.statSync(item.targetPath).size).toBe(binary.length);
|
expect(fs.statSync(item.targetPath).size).toBe(binary.length);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("recovers an HTTP 416 item with a clean fresh restart after the in-budget retries are exhausted", async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-416-fresh-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const binary = Buffer.alloc(160 * 1024, 19);
|
||||||
|
const prevDelay = process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS;
|
||||||
|
process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS = "0";
|
||||||
|
let downloadCalls = 0;
|
||||||
|
|
||||||
|
globalThis.fetch = async (input: RequestInfo | URL): Promise<Response> => {
|
||||||
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||||
|
if (url.includes("/unrestrict/link")) {
|
||||||
|
return new Response(JSON.stringify({ download: "https://dummy/direct-416-recover", filename: "fresh-416.mkv", filesize: binary.length }), { status: 200, headers: { "Content-Type": "application/json" } });
|
||||||
|
}
|
||||||
|
throw new Error(`unexpected fetch ${url}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const manager = new DownloadManager(
|
||||||
|
{ ...defaultSettings(), token: "rd-token", outputDir: path.join(root, "downloads"), extractDir: path.join(root, "extract"), retryLimit: 2, autoExtract: false, autoReconnect: false },
|
||||||
|
emptySession(),
|
||||||
|
createStoragePaths(path.join(root, "state"))
|
||||||
|
);
|
||||||
|
|
||||||
|
(manager as any).downloadToFile = async (_active: unknown, _directUrl: string, targetPath: string) => {
|
||||||
|
downloadCalls += 1;
|
||||||
|
if (downloadCalls <= 3) {
|
||||||
|
throw new Error("HTTP 416");
|
||||||
|
}
|
||||||
|
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
||||||
|
fs.writeFileSync(targetPath, binary);
|
||||||
|
const item = Object.values((manager as any).session.items)[0] as { downloadedBytes: number; totalBytes: number; progressPercent: number } | undefined;
|
||||||
|
if (item) {
|
||||||
|
item.downloadedBytes = binary.length;
|
||||||
|
item.totalBytes = binary.length;
|
||||||
|
item.progressPercent = 100;
|
||||||
|
}
|
||||||
|
return { resumable: true };
|
||||||
|
};
|
||||||
|
|
||||||
|
manager.addPackages([{ name: "fresh-416", links: ["https://dummy/fresh-416"] }]);
|
||||||
|
await manager.start();
|
||||||
|
await waitFor(() => !manager.getSnapshot().session.running, 20000);
|
||||||
|
|
||||||
|
const item = Object.values(manager.getSnapshot().session.items)[0];
|
||||||
|
expect(item?.status).toBe("completed");
|
||||||
|
expect(downloadCalls).toBeGreaterThan(3);
|
||||||
|
} finally {
|
||||||
|
if (prevDelay === undefined) { delete process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS; } else { process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS = prevDelay; }
|
||||||
|
}
|
||||||
|
}, 25000);
|
||||||
|
|
||||||
|
it("bounds HTTP 416 clean restarts and finally fails instead of looping forever or stalling permanently", async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-416-cap-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const prevDelay = process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS;
|
||||||
|
process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS = "0";
|
||||||
|
let downloadCalls = 0;
|
||||||
|
|
||||||
|
globalThis.fetch = async (input: RequestInfo | URL): Promise<Response> => {
|
||||||
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||||
|
if (url.includes("/unrestrict/link")) {
|
||||||
|
return new Response(JSON.stringify({ download: "https://dummy/direct-416-forever", filename: "always-416.mkv", filesize: 1024 }), { status: 200, headers: { "Content-Type": "application/json" } });
|
||||||
|
}
|
||||||
|
throw new Error(`unexpected fetch ${url}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const manager = new DownloadManager(
|
||||||
|
{ ...defaultSettings(), token: "rd-token", outputDir: path.join(root, "downloads"), extractDir: path.join(root, "extract"), retryLimit: 0, autoExtract: false, autoReconnect: false },
|
||||||
|
emptySession(),
|
||||||
|
createStoragePaths(path.join(root, "state"))
|
||||||
|
);
|
||||||
|
|
||||||
|
(manager as any).downloadToFile = async () => {
|
||||||
|
downloadCalls += 1;
|
||||||
|
throw new Error("direct_link_retry_exhausted:HTTP 416");
|
||||||
|
};
|
||||||
|
|
||||||
|
manager.addPackages([{ name: "always-416", links: ["https://dummy/always-416"] }]);
|
||||||
|
await manager.start();
|
||||||
|
await waitFor(() => !manager.getSnapshot().session.running, 20000);
|
||||||
|
|
||||||
|
const item = Object.values(manager.getSnapshot().session.items)[0];
|
||||||
|
expect(item?.status).toBe("failed");
|
||||||
|
expect(downloadCalls).toBeGreaterThan(4);
|
||||||
|
expect(downloadCalls).toBeLessThan(30);
|
||||||
|
expect((manager as any).http416FreshRestartByItem.get(item.id)).toBeUndefined();
|
||||||
|
} finally {
|
||||||
|
if (prevDelay === undefined) { delete process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS; } else { process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS = prevDelay; }
|
||||||
|
}
|
||||||
|
}, 25000);
|
||||||
|
|
||||||
it("retries HTTP 416 in-session when using Debrid-Link API and then completes", async () => {
|
it("retries HTTP 416 in-session when using Debrid-Link API and then completes", async () => {
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||||
tempDirs.push(root);
|
tempDirs.push(root);
|
||||||
@ -12244,3 +12336,200 @@ describe("download manager", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("start conflict guard + selective resume", () => {
|
||||||
|
function makeItem(id: string, packageId: string, status: string, fileName: string): any {
|
||||||
|
return {
|
||||||
|
id, packageId, url: `https://hoster.example/${id}`, provider: "realdebrid",
|
||||||
|
status, retries: 0, speedBps: 0, downloadedBytes: status === "completed" ? 100 : 0,
|
||||||
|
totalBytes: status === "completed" ? 100 : null, progressPercent: status === "completed" ? 100 : 0,
|
||||||
|
fileName, targetPath: "", resumable: true, attempts: 0, lastError: "", fullStatus: "",
|
||||||
|
createdAt: Date.now(), updatedAt: Date.now()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it("does not flag a partially-downloaded package whose extract dir holds its own completed output", async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-startconflict-own-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const storagePaths = createStoragePaths(path.join(root, "state"));
|
||||||
|
initPackageLogs(storagePaths.baseDir);
|
||||||
|
initItemLogs(storagePaths.baseDir);
|
||||||
|
|
||||||
|
const session = emptySession();
|
||||||
|
const packageId = "pkg-own-output";
|
||||||
|
const extractDir = path.join(root, "extract", "OwnOutput");
|
||||||
|
fs.mkdirSync(extractDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(extractDir, "episode01.mkv"), Buffer.alloc(64, 7));
|
||||||
|
session.packageOrder = [packageId];
|
||||||
|
session.packages[packageId] = {
|
||||||
|
id: packageId, name: "OwnOutput",
|
||||||
|
outputDir: path.join(root, "downloads", "OwnOutput"), extractDir,
|
||||||
|
status: "queued", itemIds: ["own-done", "own-pending"], cancelled: false, enabled: true,
|
||||||
|
createdAt: Date.now(), updatedAt: Date.now()
|
||||||
|
} as any;
|
||||||
|
session.items["own-done"] = makeItem("own-done", packageId, "completed", "done.rar");
|
||||||
|
session.items["own-pending"] = makeItem("own-pending", packageId, "queued", "pending.rar");
|
||||||
|
|
||||||
|
const manager = new DownloadManager(
|
||||||
|
{ ...defaultSettings(), token: "rd-token", outputDir: path.join(root, "downloads"), extractDir: path.join(root, "extract") },
|
||||||
|
session, storagePaths
|
||||||
|
);
|
||||||
|
|
||||||
|
const conflicts = await manager.getStartConflicts();
|
||||||
|
expect(conflicts.map((c) => c.packageId)).not.toContain(packageId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags a fresh package when its package-specific extract dir already holds files and it has no completed items", async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-startconflict-fresh-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const storagePaths = createStoragePaths(path.join(root, "state"));
|
||||||
|
initPackageLogs(storagePaths.baseDir);
|
||||||
|
initItemLogs(storagePaths.baseDir);
|
||||||
|
|
||||||
|
const session = emptySession();
|
||||||
|
const packageId = "pkg-fresh-conflict";
|
||||||
|
const extractDir = path.join(root, "extract", "FreshConflict");
|
||||||
|
fs.mkdirSync(extractDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(extractDir, "old-from-previous-run.mkv"), Buffer.alloc(64, 9));
|
||||||
|
session.packageOrder = [packageId];
|
||||||
|
session.packages[packageId] = {
|
||||||
|
id: packageId, name: "FreshConflict",
|
||||||
|
outputDir: path.join(root, "downloads", "FreshConflict"), extractDir,
|
||||||
|
status: "queued", itemIds: ["fresh-pending"], cancelled: false, enabled: true,
|
||||||
|
createdAt: Date.now(), updatedAt: Date.now()
|
||||||
|
} as any;
|
||||||
|
session.items["fresh-pending"] = makeItem("fresh-pending", packageId, "queued", "fresh.rar");
|
||||||
|
|
||||||
|
const manager = new DownloadManager(
|
||||||
|
{ ...defaultSettings(), token: "rd-token", outputDir: path.join(root, "downloads"), extractDir: path.join(root, "extract") },
|
||||||
|
session, storagePaths
|
||||||
|
);
|
||||||
|
|
||||||
|
const conflicts = await manager.getStartConflicts();
|
||||||
|
expect(conflicts.map((c) => c.packageId)).toContain(packageId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("start() holds excluded packages out of the run set and runs the rest", async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-selective-resume-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const storagePaths = createStoragePaths(path.join(root, "state"));
|
||||||
|
initPackageLogs(storagePaths.baseDir);
|
||||||
|
initItemLogs(storagePaths.baseDir);
|
||||||
|
|
||||||
|
const session = emptySession();
|
||||||
|
const runId = "pkg-run";
|
||||||
|
const holdId = "pkg-hold";
|
||||||
|
fs.mkdirSync(path.join(root, "downloads", "RunMe"), { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(root, "downloads", "HoldMe"), { recursive: true });
|
||||||
|
session.packageOrder = [runId, holdId];
|
||||||
|
session.packages[runId] = {
|
||||||
|
id: runId, name: "RunMe",
|
||||||
|
outputDir: path.join(root, "downloads", "RunMe"), extractDir: path.join(root, "extract", "RunMe"),
|
||||||
|
status: "queued", itemIds: ["run-item"], cancelled: false, enabled: true,
|
||||||
|
createdAt: Date.now(), updatedAt: Date.now()
|
||||||
|
} as any;
|
||||||
|
session.packages[holdId] = {
|
||||||
|
id: holdId, name: "HoldMe",
|
||||||
|
outputDir: path.join(root, "downloads", "HoldMe"), extractDir: path.join(root, "extract", "HoldMe"),
|
||||||
|
status: "queued", itemIds: ["hold-item"], cancelled: false, enabled: true,
|
||||||
|
createdAt: Date.now(), updatedAt: Date.now()
|
||||||
|
} as any;
|
||||||
|
session.items["run-item"] = makeItem("run-item", runId, "queued", "run.rar");
|
||||||
|
session.items["hold-item"] = makeItem("hold-item", holdId, "queued", "hold.rar");
|
||||||
|
|
||||||
|
const manager = new DownloadManager(
|
||||||
|
{ ...defaultSettings(), token: "rd-token", maxParallel: 2, outputDir: path.join(root, "downloads"), extractDir: path.join(root, "extract") },
|
||||||
|
session, storagePaths
|
||||||
|
);
|
||||||
|
(manager as any).debridService.unrestrictLink = () => new Promise(() => {});
|
||||||
|
|
||||||
|
await manager.start({ excludePackageIds: new Set([holdId]) });
|
||||||
|
|
||||||
|
expect((manager as any).runPackageIds.has(runId)).toBe(true);
|
||||||
|
expect((manager as any).runPackageIds.has(holdId)).toBe(false);
|
||||||
|
expect((manager as any).runItemIds.has("run-item")).toBe(true);
|
||||||
|
expect((manager as any).runItemIds.has("hold-item")).toBe(false);
|
||||||
|
expect(session.items["hold-item"].status).toBe("queued");
|
||||||
|
|
||||||
|
manager.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("mega-debrid api/web resolution overlap gate", () => {
|
||||||
|
function megaApiSettings(root: string): any {
|
||||||
|
return {
|
||||||
|
...defaultSettings(),
|
||||||
|
megaLogin: "u", megaPassword: "p", megaCredentials: "u:p",
|
||||||
|
megaDebridApiEnabled: true, megaDebridWebEnabled: true, megaDebridPreferApi: true,
|
||||||
|
outputDir: path.join(root, "downloads"), extractDir: path.join(root, "extract")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function megaItem(id: string, status: string): any {
|
||||||
|
return {
|
||||||
|
id, packageId: "pkg", url: `https://rapidgator.net/file/${id}`, provider: "megadebrid-api",
|
||||||
|
status, retries: 0, speedBps: 0, downloadedBytes: 0, totalBytes: null, progressPercent: 0,
|
||||||
|
fileName: `${id}.rar`, targetPath: "", resumable: true, attempts: 0, lastError: "", fullStatus: "",
|
||||||
|
createdAt: Date.now(), updatedAt: Date.now()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function addValidating(manager: DownloadManager, session: any, ids: string[]): void {
|
||||||
|
for (const id of ids) {
|
||||||
|
session.items[id] = megaItem(id, "validating");
|
||||||
|
(manager as any).activeTasks.set(id, {
|
||||||
|
itemId: id, packageId: "pkg", abortController: new AbortController(), abortReason: "none",
|
||||||
|
resumable: true, nonResumableCounted: false, blockedOnDiskWrite: false, blockedOnDiskSince: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildManager(root: string, session: any): DownloadManager {
|
||||||
|
return new DownloadManager(megaApiSettings(root), session, createStoragePaths(path.join(root, "state")));
|
||||||
|
}
|
||||||
|
|
||||||
|
it("lets the first mega resolve start when nothing is in flight", () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-overlap-0-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const session = emptySession();
|
||||||
|
const candidate = megaItem("cand", "queued");
|
||||||
|
session.items["cand"] = candidate;
|
||||||
|
const manager = buildManager(root, session);
|
||||||
|
expect((manager as any).shouldDelayStartForItem(candidate)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serializes a second API resolve while the first is still in its API phase (no concurrent API)", () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-overlap-1-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const session = emptySession();
|
||||||
|
const candidate = megaItem("cand", "queued");
|
||||||
|
session.items["cand"] = candidate;
|
||||||
|
const manager = buildManager(root, session);
|
||||||
|
addValidating(manager, session, ["a"]);
|
||||||
|
expect((manager as any).shouldDelayStartForItem(candidate)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows one API resolve to overlap once the first has moved to its web phase", () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-overlap-2-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const session = emptySession();
|
||||||
|
const candidate = megaItem("cand", "queued");
|
||||||
|
session.items["cand"] = candidate;
|
||||||
|
const manager = buildManager(root, session);
|
||||||
|
addValidating(manager, session, ["a"]);
|
||||||
|
primeMegaDebridInFlightForTests("acc:web", 1);
|
||||||
|
expect((manager as any).shouldDelayStartForItem(candidate)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps the overlap at one API plus one web (no third concurrent resolve)", () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-overlap-3-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const session = emptySession();
|
||||||
|
const candidate = megaItem("cand", "queued");
|
||||||
|
session.items["cand"] = candidate;
|
||||||
|
const manager = buildManager(root, session);
|
||||||
|
addValidating(manager, session, ["a", "b"]);
|
||||||
|
primeMegaDebridInFlightForTests("acc:web", 1);
|
||||||
|
expect((manager as any).shouldDelayStartForItem(candidate)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Binary file not shown.
@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { transientResolveRetryDelayMs, parseMegaDebridCooldownRetry, parseMegaDebridResetPark } from "../src/main/download-manager";
|
import { transientResolveRetryDelayMs, parseMegaDebridCooldownRetry, parseMegaDebridResetPark, parseMegaDebridSlowLinkRetry } from "../src/main/download-manager";
|
||||||
|
|
||||||
describe("transientResolveRetryDelayMs (fast, bounded retry for transient resolve failures)", () => {
|
describe("transientResolveRetryDelayMs (fast, bounded retry for transient resolve failures)", () => {
|
||||||
it("starts fast (<= 3s) instead of the 5s..120s exponential", () => {
|
it("starts fast (<= 3s) instead of the 5s..120s exponential", () => {
|
||||||
@ -63,6 +63,31 @@ describe("parseMegaDebridCooldownRetry (honor the encoded account-cooldown delay
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("parseMegaDebridSlowLinkRetry (park only the slow link, never the account)", () => {
|
||||||
|
it("parses the encoded delay from a slow-link error", () => {
|
||||||
|
const r = parseMegaDebridSlowLinkRetry("mega_debrid_slow_link:120000:Mega-Debrid (Account 1/1, Su******e3): aborted");
|
||||||
|
expect(r).not.toBeNull();
|
||||||
|
expect(r!.delayMs).toBe(120000);
|
||||||
|
expect(r!.detail).toContain("Mega-Debrid");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses it when embedded in the aggregated provider-chain error", () => {
|
||||||
|
const aggregated = "Provider-Kette: Mega-Debrid Web fehlgeschlagen (Error: mega_debrid_slow_link:90000:Mega-Debrid (Account 1/1): aborted)";
|
||||||
|
expect(parseMegaDebridSlowLinkRetry(aggregated)!.delayMs).toBe(90000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps to [1s, 15min]", () => {
|
||||||
|
expect(parseMegaDebridSlowLinkRetry("mega_debrid_slow_link:1:x")!.delayMs).toBe(1000);
|
||||||
|
expect(parseMegaDebridSlowLinkRetry("mega_debrid_slow_link:99999999:x")!.delayMs).toBe(15 * 60 * 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not collide with the account-cooldown or reset-park tokens", () => {
|
||||||
|
expect(parseMegaDebridSlowLinkRetry("mega_debrid_cooldown:20330:x")).toBeNull();
|
||||||
|
expect(parseMegaDebridSlowLinkRetry("mega_debrid_reset_park:43200000:x")).toBeNull();
|
||||||
|
expect(parseMegaDebridCooldownRetry("mega_debrid_slow_link:120000:x")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("parseMegaDebridResetPark (park the item until the Tagesreset, not a ~2min generic retry)", () => {
|
describe("parseMegaDebridResetPark (park the item until the Tagesreset, not a ~2min generic retry)", () => {
|
||||||
it("parses the encoded until-reset delay from the park token", () => {
|
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)");
|
const r = parseMegaDebridResetPark("mega_debrid_reset_park:43200000:Mega-Debrid: Alle Accounts am Tageslimit (bis zum Tagesreset gesperrt)");
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
# rd-diagnostics-mcp
|
# rd-diagnostics-mcp
|
||||||
|
|
||||||
Standalone **stdio MCP bridge** to the Real-Debrid-Downloader debug-server. It runs on the machine where the
|
Standalone **stdio MCP bridge** to the Real-Debrid-Downloader debug-server. It runs on the machine where the
|
||||||
AI assistant (Claude Code) runs, takes a **connection code** for a downloader server, and exposes that server's
|
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.
|
read-only HTTP diagnostics API (`/diagnostics`, `/status`, `/errors`, `/logs/*`, `/accounts`, …) as MCP tools.
|
||||||
One bridge serves all 5–6 servers; you pass a `code` (or a configured `server` name) per call.
|
One bridge serves all 5–6 servers; you pass a `code` (or a configured `server` name) per call.
|
||||||
|
|
||||||
@ -14,10 +14,10 @@ cd tools/rd-diagnostics-mcp
|
|||||||
npm install
|
npm install
|
||||||
```
|
```
|
||||||
|
|
||||||
Register it with Claude Code (single default server):
|
Register it with your MCP client as a stdio server that launches the bridge:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
claude mcp add rd-diag -- node "<repo>/tools/rd-diagnostics-mcp/src/bridge.mjs"
|
node "<repo>/tools/rd-diagnostics-mcp/src/bridge.mjs"
|
||||||
```
|
```
|
||||||
|
|
||||||
Provide servers via environment variables (codes contain a token — treat like passwords):
|
Provide servers via environment variables (codes contain a token — treat like passwords):
|
||||||
@ -29,8 +29,10 @@ Without env config, every tool simply takes a `code` argument.
|
|||||||
|
|
||||||
## Tools
|
## Tools
|
||||||
|
|
||||||
`rd_servers`, `rd_ping`, `rd_diagnostics`, `rd_status`, `rd_items`, `rd_packages`, `rd_errors`, `rd_logs`,
|
`rd_servers`, `rd_ping`, `rd_diagnostics`, `rd_status`, `rd_items`, `rd_packages`, `rd_errors`, `rd_logs`
|
||||||
`rd_history`, `rd_accounts`, `rd_host`, `rd_self_check`, `rd_get` (raw escape-hatch, any read-only path).
|
(`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.
|
Each tool accepts `code` or `server` to pick the target.
|
||||||
|
|
||||||
|
|||||||
@ -219,6 +219,7 @@ const LOG_PATHS = {
|
|||||||
rename: "/logs/rename",
|
rename: "/logs/rename",
|
||||||
trace: "/logs/trace",
|
trace: "/logs/trace",
|
||||||
session: "/logs/session",
|
session: "/logs/session",
|
||||||
|
conversion: "/logs/conversion",
|
||||||
package: "/logs/package",
|
package: "/logs/package",
|
||||||
item: "/logs/item"
|
item: "/logs/item"
|
||||||
};
|
};
|
||||||
@ -227,10 +228,10 @@ server.registerTool(
|
|||||||
"rd_logs",
|
"rd_logs",
|
||||||
{
|
{
|
||||||
title: "Log lesen",
|
title: "Log lesen",
|
||||||
description: "Liest das Ende eines Logs (GET /logs/<name>). name: main|audit|rename|trace|session|package|item. Fuer package/item zusaetzlich package/item angeben.",
|
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: {
|
inputSchema: {
|
||||||
...CODE_FIELD,
|
...CODE_FIELD,
|
||||||
name: z.enum(["main", "audit", "rename", "trace", "session", "package", "item"]).describe("Welches Log."),
|
name: z.enum(["main", "audit", "rename", "trace", "session", "conversion", "package", "item"]).describe("Welches Log."),
|
||||||
lines: z.number().int().positive().optional().describe("Anzahl Zeilen vom Ende (Default 100)."),
|
lines: z.number().int().positive().optional().describe("Anzahl Zeilen vom Ende (Default 100)."),
|
||||||
grep: z.string().optional().describe("Filter."),
|
grep: z.string().optional().describe("Filter."),
|
||||||
package: z.string().optional().describe("Nur fuer name=package."),
|
package: z.string().optional().describe("Nur fuer name=package."),
|
||||||
@ -268,6 +269,16 @@ server.registerTool(
|
|||||||
async (args) => requestTool(args, "/accounts", {})
|
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(
|
server.registerTool(
|
||||||
"rd_host",
|
"rd_host",
|
||||||
{
|
{
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user