Compare commits
No commits in common. "main" and "v1.7.204" have entirely different histories.
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "1.7.232",
|
"version": "1.7.204",
|
||||||
"description": "Desktop downloader",
|
"description": "Desktop downloader",
|
||||||
"main": "build/main/main/main.js",
|
"main": "build/main/main/main.js",
|
||||||
"author": "Sucukdeluxe",
|
"author": "Sucukdeluxe",
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import os from "node:os";
|
|
||||||
import v8 from "node:v8";
|
import v8 from "node:v8";
|
||||||
import { app } from "electron";
|
import { app } from "electron";
|
||||||
import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys";
|
import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys";
|
||||||
@ -10,11 +9,9 @@ import {
|
|||||||
DebridAccountStatus,
|
DebridAccountStatus,
|
||||||
DebridProvider,
|
DebridProvider,
|
||||||
DuplicatePolicy,
|
DuplicatePolicy,
|
||||||
EnableRemoteDiagnosticsInput,
|
|
||||||
HistoryEntry,
|
HistoryEntry,
|
||||||
PackagePriority,
|
PackagePriority,
|
||||||
ParsedPackageInput,
|
ParsedPackageInput,
|
||||||
RemoteDiagnosticsInfo,
|
|
||||||
SessionStats,
|
SessionStats,
|
||||||
StartConflictEntry,
|
StartConflictEntry,
|
||||||
StartConflictResolutionResult,
|
StartConflictResolutionResult,
|
||||||
@ -39,16 +36,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, loadSessionWithStatus, loadSettings, normalizeHistoryEntry, normalizeLoadedSession, normalizeLoadedSessionTransientFields, normalizeSettings, removeHistoryEntry, resetHistoryForRetention, saveHistory, saveSession, saveSettings } from "./storage";
|
import { addHistoryEntry, addHistoryEntryForRetention, cancelPendingAsyncSaves, clearHistory, createStoragePaths, loadHistory, loadHistoryForRetention, loadSession, loadSettings, normalizeHistoryEntry, normalizeLoadedSession, normalizeLoadedSessionTransientFields, normalizeSettings, removeHistoryEntry, resetHistoryForRetention, saveHistory, saveSession, saveSettings } from "./storage";
|
||||||
import { abortActiveUpdateDownload, checkGitHubUpdate, installLatestUpdate } from "./update";
|
import { abortActiveUpdateDownload, checkGitHubUpdate, installLatestUpdate } from "./update";
|
||||||
import { runInstallWithResume } from "./update-install-flow";
|
import { rotateDebugToken, startDebugServer, stopDebugServer } from "./debug-server";
|
||||||
import { rotateDebugToken, startDebugServer, stopDebugServer, restartDebugServer, getDebugServerRuntimeStatus, getActiveDebugToken, getDebugAllowlist, writeDebugServerConfig, clearDebugToken } from "./debug-server";
|
|
||||||
import { encodeConnectionCode, loadRemoteMeta, saveRemoteMeta } from "./connection-code";
|
|
||||||
import { encryptBackup, decryptBackup } from "./backup-crypto";
|
import { encryptBackup, decryptBackup } from "./backup-crypto";
|
||||||
import { buildBackupPayload, planBackupImport, resolveMcpRemoteRestore, BackupMcpRemote } from "./backup-payload";
|
import { buildBackupPayload, planBackupImport } from "./backup-payload";
|
||||||
import { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "./audit-log";
|
import { 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 { runStartupHealthCheck } from "./startup-health-check";
|
import { runStartupHealthCheck } from "./startup-health-check";
|
||||||
import { getDebugSetupCheck } from "./debug-setup";
|
import { getDebugSetupCheck } from "./debug-setup";
|
||||||
import { buildLinkExportSelection, serializeLinkExportText } from "./link-export";
|
import { buildLinkExportSelection, serializeLinkExportText } from "./link-export";
|
||||||
@ -100,7 +94,6 @@ export class AppController {
|
|||||||
initItemLogs(this.storagePaths.baseDir);
|
initItemLogs(this.storagePaths.baseDir);
|
||||||
initAuditLog(this.storagePaths.baseDir);
|
initAuditLog(this.storagePaths.baseDir);
|
||||||
initAccountRotationLog(this.storagePaths.baseDir);
|
initAccountRotationLog(this.storagePaths.baseDir);
|
||||||
initConversionLog(this.storagePaths.baseDir);
|
|
||||||
initRenameLog(this.storagePaths.baseDir);
|
initRenameLog(this.storagePaths.baseDir);
|
||||||
let desktopDir: string | null = null;
|
let desktopDir: string | null = null;
|
||||||
try {
|
try {
|
||||||
@ -112,8 +105,7 @@ 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 loadResult = loadSessionWithStatus(this.storagePaths);
|
const session = loadSession(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
|
||||||
@ -127,7 +119,6 @@ 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());
|
||||||
}
|
}
|
||||||
@ -180,13 +171,21 @@ 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 && this.hasAnyProviderToken(this.settings)) {
|
if (hasPending) {
|
||||||
if (this.onStateHandler) {
|
void this.manager.getStartConflicts().then((conflicts) => {
|
||||||
this.beginAutoResume();
|
const hasConflicts = conflicts.length > 0;
|
||||||
} else {
|
if (this.hasAnyProviderToken(this.settings) && !hasConflicts) {
|
||||||
this.autoResumePending = true;
|
if (this.onStateHandler) {
|
||||||
logger.info("Auto-Resume beim Start vorgemerkt");
|
logger.info("Auto-Resume beim Start aktiviert (nach Konflikt-Check)");
|
||||||
}
|
void this.manager.start().catch((err) => logger.warn(`Auto-Resume Start Fehler: ${String(err)}`));
|
||||||
|
} else {
|
||||||
|
this.autoResumePending = true;
|
||||||
|
logger.info("Auto-Resume beim Start vorgemerkt");
|
||||||
|
}
|
||||||
|
} else if (hasConflicts) {
|
||||||
|
logger.info("Auto-Resume übersprungen: Start-Konflikte erkannt");
|
||||||
|
}
|
||||||
|
}).catch((err) => logger.warn(`getStartConflicts Fehler (constructor): ${String(err)}`));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -243,27 +242,14 @@ export class AppController {
|
|||||||
handler(this.manager.getSnapshot());
|
handler(this.manager.getSnapshot());
|
||||||
if (this.autoResumePending) {
|
if (this.autoResumePending) {
|
||||||
this.autoResumePending = false;
|
this.autoResumePending = false;
|
||||||
this.beginAutoResume();
|
void this.manager.start().catch((err) => logger.warn(`Auto-Resume Start Fehler: ${String(err)}`));
|
||||||
|
logger.info("Auto-Resume beim Start aktiviert");
|
||||||
} else {
|
} 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();
|
||||||
}
|
}
|
||||||
@ -302,100 +288,6 @@ export class AppController {
|
|||||||
return rotated;
|
return rotated;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getSuggestedRemoteHosts(): string[] {
|
|
||||||
const hosts: string[] = [];
|
|
||||||
try {
|
|
||||||
const interfaces = os.networkInterfaces();
|
|
||||||
for (const entry of Object.values(interfaces)) {
|
|
||||||
for (const net of entry || []) {
|
|
||||||
if (net.family === "IPv4" && !net.internal && net.address) {
|
|
||||||
hosts.push(net.address);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
return [...new Set(hosts)];
|
|
||||||
}
|
|
||||||
|
|
||||||
public getRemoteDiagnostics(): RemoteDiagnosticsInfo {
|
|
||||||
const status = getDebugServerRuntimeStatus();
|
|
||||||
const meta = loadRemoteMeta(this.storagePaths.baseDir);
|
|
||||||
const token = getActiveDebugToken();
|
|
||||||
const allowlist = getDebugAllowlist();
|
|
||||||
const suggestedHosts = this.getSuggestedRemoteHosts();
|
|
||||||
const host = meta.publicHost
|
|
||||||
|| (status.localOnly ? "127.0.0.1" : (suggestedHosts[0] || status.host));
|
|
||||||
const code = (status.hasToken && token && host)
|
|
||||||
? encodeConnectionCode({ host, port: status.port, token, name: meta.name || undefined })
|
|
||||||
: null;
|
|
||||||
return {
|
|
||||||
status,
|
|
||||||
code,
|
|
||||||
publicHost: meta.publicHost,
|
|
||||||
name: meta.name,
|
|
||||||
allowlist,
|
|
||||||
suggestedHosts
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public async enableRemoteDiagnostics(input: EnableRemoteDiagnosticsInput): Promise<RemoteDiagnosticsInfo> {
|
|
||||||
const baseDir = this.storagePaths.baseDir;
|
|
||||||
const port = input.port && Number.isInteger(input.port) && input.port >= 1024 && input.port <= 65535
|
|
||||||
? input.port
|
|
||||||
: 9868;
|
|
||||||
const bindHost = input.hostMode === "network" ? "0.0.0.0" : "127.0.0.1";
|
|
||||||
const allowlist = (input.allowlist || []).map((entry) => entry.trim()).filter((entry) => entry.length > 0);
|
|
||||||
if (input.hostMode === "network" && allowlist.length === 0) {
|
|
||||||
throw new Error("Netzwerk-Freigabe erfordert mindestens eine erlaubte IP oder CIDR in der Allowlist.");
|
|
||||||
}
|
|
||||||
let token = getActiveDebugToken();
|
|
||||||
if (!token || input.rotateToken) {
|
|
||||||
token = rotateDebugToken(baseDir).token;
|
|
||||||
}
|
|
||||||
writeDebugServerConfig({ host: bindHost, port, allowlist });
|
|
||||||
saveRemoteMeta(baseDir, { publicHost: (input.publicHost || "").trim(), name: (input.name || "").trim() });
|
|
||||||
await restartDebugServer();
|
|
||||||
this.audit("WARN", "Ferndiagnose aktiviert", {
|
|
||||||
host: bindHost,
|
|
||||||
port,
|
|
||||||
allowlistCount: allowlist.length,
|
|
||||||
localOnly: input.hostMode === "local"
|
|
||||||
});
|
|
||||||
return this.getRemoteDiagnostics();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async disableRemoteDiagnostics(): Promise<RemoteDiagnosticsInfo> {
|
|
||||||
clearDebugToken();
|
|
||||||
await restartDebugServer();
|
|
||||||
this.audit("WARN", "Ferndiagnose deaktiviert (Token entfernt)");
|
|
||||||
return this.getRemoteDiagnostics();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async rotateRemoteDiagnosticsToken(): Promise<RemoteDiagnosticsInfo> {
|
|
||||||
rotateDebugToken(this.storagePaths.baseDir);
|
|
||||||
await restartDebugServer();
|
|
||||||
this.audit("WARN", "Ferndiagnose-Token rotiert");
|
|
||||||
return this.getRemoteDiagnostics();
|
|
||||||
}
|
|
||||||
|
|
||||||
private restoreMcpRemoteFromBackup(section: unknown, restartNow: boolean): void {
|
|
||||||
const restore = resolveMcpRemoteRestore(section);
|
|
||||||
if (!restore) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
writeDebugServerConfig({ host: restore.host, port: restore.port, allowlist: restore.allowlist });
|
|
||||||
if (restartNow) {
|
|
||||||
void restartDebugServer().catch(() => {});
|
|
||||||
}
|
|
||||||
this.audit("INFO", "Ferndiagnose-Einstellungen aus Backup wiederhergestellt", {
|
|
||||||
port: restore.port ?? null,
|
|
||||||
allowlistCount: restore.allowlist?.length ?? 0,
|
|
||||||
host: restore.host ?? "unveraendert",
|
|
||||||
restartNow
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public getDebugSetupCheck(): DebugSetupCheckResult {
|
public getDebugSetupCheck(): DebugSetupCheckResult {
|
||||||
return getDebugSetupCheck(this.storagePaths.baseDir);
|
return getDebugSetupCheck(this.storagePaths.baseDir);
|
||||||
}
|
}
|
||||||
@ -565,14 +457,16 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async installUpdate(onProgress?: (progress: UpdateInstallProgress) => void): Promise<UpdateInstallResult> {
|
public async installUpdate(onProgress?: (progress: UpdateInstallProgress) => void): Promise<UpdateInstallResult> {
|
||||||
|
if (this.manager.isSessionRunning()) {
|
||||||
|
this.manager.stop({ parkForRestart: true });
|
||||||
|
}
|
||||||
|
this.manager.persistNowSync();
|
||||||
|
|
||||||
const cacheAgeMs = Date.now() - this.lastUpdateCheckAt;
|
const cacheAgeMs = Date.now() - this.lastUpdateCheckAt;
|
||||||
const cached = this.lastUpdateCheck && !this.lastUpdateCheck.error && cacheAgeMs <= 10 * 60 * 1000
|
const cached = this.lastUpdateCheck && !this.lastUpdateCheck.error && cacheAgeMs <= 10 * 60 * 1000
|
||||||
? this.lastUpdateCheck
|
? this.lastUpdateCheck
|
||||||
: undefined;
|
: undefined;
|
||||||
const result = await runInstallWithResume(
|
const result = await installLatestUpdate(this.settings.updateRepo, cached, onProgress);
|
||||||
this.manager,
|
|
||||||
() => installLatestUpdate(this.settings.updateRepo, cached, onProgress)
|
|
||||||
);
|
|
||||||
if (result.started) {
|
if (result.started) {
|
||||||
this.lastUpdateCheck = null;
|
this.lastUpdateCheck = null;
|
||||||
this.lastUpdateCheckAt = 0;
|
this.lastUpdateCheckAt = 0;
|
||||||
@ -748,22 +642,13 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public exportBackup(): Buffer {
|
public exportBackup(): Buffer {
|
||||||
let mcpRemote: BackupMcpRemote | undefined;
|
const includeDownloads = Boolean(this.settings.backupIncludeDownloads);
|
||||||
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,
|
||||||
@ -774,14 +659,14 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
|
|||||||
return encryptBackup(JSON.stringify(payloadObj));
|
return encryptBackup(JSON.stringify(payloadObj));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async exportSupportBundle(): Promise<{ buffer: Buffer; defaultFileName: string }> {
|
public exportSupportBundle(): { buffer: Buffer; defaultFileName: string } {
|
||||||
this.audit("INFO", "Support-Bundle exportiert");
|
this.audit("INFO", "Support-Bundle exportiert");
|
||||||
logTraceEvent("INFO", "support", "Support-Bundle erstellt", {
|
logTraceEvent("INFO", "support", "Support-Bundle erstellt", {
|
||||||
packageCount: Object.keys(this.manager.getSnapshot().session.packages).length,
|
packageCount: Object.keys(this.manager.getSnapshot().session.packages).length,
|
||||||
itemCount: Object.keys(this.manager.getSnapshot().session.items).length
|
itemCount: Object.keys(this.manager.getSnapshot().session.items).length
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
buffer: await buildSupportBundle(this.manager, this.storagePaths.baseDir, { hostDiagnosticsMode: "cached" }),
|
buffer: buildSupportBundle(this.manager, this.storagePaths.baseDir, { hostDiagnosticsMode: "cached" }),
|
||||||
defaultFileName: getSupportBundleDefaultFileName()
|
defaultFileName: getSupportBundleDefaultFileName()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -837,7 +722,6 @@ 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)
|
||||||
});
|
});
|
||||||
@ -874,8 +758,6 @@ 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");
|
||||||
@ -905,7 +787,6 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
|
|||||||
}
|
}
|
||||||
stopDebugServer();
|
stopDebugServer();
|
||||||
abortActiveUpdateDownload();
|
abortActiveUpdateDownload();
|
||||||
cancelPendingAsyncSaves();
|
|
||||||
this.manager.prepareForShutdown();
|
this.manager.prepareForShutdown();
|
||||||
this.megaWebFallback.dispose();
|
this.megaWebFallback.dispose();
|
||||||
this.realDebridWebFallback.dispose();
|
this.realDebridWebFallback.dispose();
|
||||||
@ -919,7 +800,6 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
|
|||||||
this.audit("INFO", "App beendet");
|
this.audit("INFO", "App beendet");
|
||||||
shutdownTraceLog();
|
shutdownTraceLog();
|
||||||
shutdownAccountRotationLog();
|
shutdownAccountRotationLog();
|
||||||
shutdownConversionLog();
|
|
||||||
shutdownAuditLog();
|
shutdownAuditLog();
|
||||||
if (this.settings.historyRetentionMode === "session") {
|
if (this.settings.historyRetentionMode === "session") {
|
||||||
clearHistory(this.storagePaths);
|
clearHistory(this.storagePaths);
|
||||||
|
|||||||
@ -2,12 +2,6 @@ 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;
|
||||||
@ -16,7 +10,6 @@ export interface BackupPayload {
|
|||||||
settings: AppSettings;
|
settings: AppSettings;
|
||||||
session?: SessionState;
|
session?: SessionState;
|
||||||
history?: HistoryEntry[];
|
history?: HistoryEntry[];
|
||||||
mcpRemote?: BackupMcpRemote;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BuildBackupInput {
|
export interface BuildBackupInput {
|
||||||
@ -26,7 +19,6 @@ 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -48,39 +40,9 @@ 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. */
|
||||||
|
|||||||
@ -1,59 +0,0 @@
|
|||||||
import fs from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
const PREFIX = "rddiag:v1:";
|
|
||||||
|
|
||||||
function base64urlEncode(value: string): string {
|
|
||||||
return Buffer.from(value, "utf8")
|
|
||||||
.toString("base64")
|
|
||||||
.replace(/\+/g, "-")
|
|
||||||
.replace(/\//g, "_")
|
|
||||||
.replace(/=+$/, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ConnectionCodeInput {
|
|
||||||
host: string;
|
|
||||||
port: number;
|
|
||||||
token: string;
|
|
||||||
name?: string;
|
|
||||||
scheme?: "http" | "https";
|
|
||||||
fingerprint?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function encodeConnectionCode(input: ConnectionCodeInput): string {
|
|
||||||
const host = String(input.host || "").trim();
|
|
||||||
if (!host) throw new Error("Host fehlt fuer Verbindungscode");
|
|
||||||
const port = Number(input.port);
|
|
||||||
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("Port ungueltig fuer Verbindungscode");
|
|
||||||
if (!input.token) throw new Error("Token fehlt fuer Verbindungscode");
|
|
||||||
const payload: Record<string, unknown> = { v: 1, h: host, p: port, t: input.token };
|
|
||||||
if (input.name) payload.n = String(input.name);
|
|
||||||
if (input.fingerprint) payload.fp = String(input.fingerprint);
|
|
||||||
if (input.scheme && input.scheme !== "http") payload.s = String(input.scheme);
|
|
||||||
return PREFIX + base64urlEncode(JSON.stringify(payload));
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RemoteMeta {
|
|
||||||
publicHost: string;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function remoteMetaPath(baseDir: string): string {
|
|
||||||
return path.join(baseDir, "debug_remote.json");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function loadRemoteMeta(baseDir: string): RemoteMeta {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(fs.readFileSync(remoteMetaPath(baseDir), "utf8"));
|
|
||||||
return {
|
|
||||||
publicHost: String(parsed.publicHost || ""),
|
|
||||||
name: String(parsed.name || "")
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
return { publicHost: "", name: "" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function saveRemoteMeta(baseDir: string, meta: RemoteMeta): void {
|
|
||||||
fs.writeFileSync(remoteMetaPath(baseDir), JSON.stringify({ publicHost: meta.publicHost, name: meta.name }, null, 2), "utf8");
|
|
||||||
}
|
|
||||||
@ -109,7 +109,6 @@ 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,
|
||||||
|
|||||||
@ -1,190 +0,0 @@
|
|||||||
import fs from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
import { AsyncLocalStorage } from "node:async_hooks";
|
|
||||||
import { logTimestamp } from "./log-timestamp";
|
|
||||||
|
|
||||||
export interface ConversionPhase {
|
|
||||||
atMs: number;
|
|
||||||
phase: string;
|
|
||||||
provider?: string;
|
|
||||||
account?: string;
|
|
||||||
tokenState?: string;
|
|
||||||
queueWaitMs?: number;
|
|
||||||
workMs?: number;
|
|
||||||
outcome?: string;
|
|
||||||
detail?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ConversionTrace {
|
|
||||||
startedAt: number;
|
|
||||||
itemId: string;
|
|
||||||
itemName: string;
|
|
||||||
link: string;
|
|
||||||
providerOrder: string;
|
|
||||||
notes: Record<string, string | number>;
|
|
||||||
phases: ConversionPhase[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const conversionContext = new AsyncLocalStorage<ConversionTrace>();
|
|
||||||
|
|
||||||
function shortLink(link: string): string {
|
|
||||||
const raw = String(link || "").trim();
|
|
||||||
return raw.length > 90 ? `${raw.slice(0, 90)}…` : raw;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function traceConversionPhase(phase: Omit<ConversionPhase, "atMs">): void {
|
|
||||||
const trace = conversionContext.getStore();
|
|
||||||
if (!trace) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
trace.phases.push({ ...phase, atMs: Date.now() - trace.startedAt });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function traceConversionNote(key: string, value: string | number): void {
|
|
||||||
const trace = conversionContext.getStore();
|
|
||||||
if (!trace) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
trace.notes[key] = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hasActiveConversionTrace(): boolean {
|
|
||||||
return conversionContext.getStore() !== undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatConversionBlock(
|
|
||||||
trace: ConversionTrace,
|
|
||||||
outcome: string,
|
|
||||||
detail: string,
|
|
||||||
totalMs: number
|
|
||||||
): string {
|
|
||||||
const noteParts = Object.entries(trace.notes)
|
|
||||||
.map(([key, value]) => `${key}=${value}`)
|
|
||||||
.join(" ");
|
|
||||||
const header = `${logTimestamp()} [CONV] item=${trace.itemName || trace.itemId} | order=${trace.providerOrder || "?"}`
|
|
||||||
+ ` | result=${outcome}${detail ? ` (${detail})` : ""} | total=${totalMs}ms${noteParts ? ` | ${noteParts}` : ""}`
|
|
||||||
+ ` | link=${shortLink(trace.link)}`;
|
|
||||||
const lines = trace.phases.map((p) => {
|
|
||||||
const parts: string[] = [];
|
|
||||||
if (p.provider) parts.push(`provider=${p.provider}`);
|
|
||||||
if (p.account) parts.push(`account=${p.account}`);
|
|
||||||
if (p.tokenState) parts.push(`token=${p.tokenState}`);
|
|
||||||
if (typeof p.queueWaitMs === "number") parts.push(`queueWaitMs=${p.queueWaitMs}`);
|
|
||||||
if (typeof p.workMs === "number") parts.push(`workMs=${p.workMs}`);
|
|
||||||
if (p.outcome) parts.push(`outcome=${p.outcome}`);
|
|
||||||
if (p.detail) parts.push(`detail=${String(p.detail).replace(/\r?\n/g, "\\n")}`);
|
|
||||||
return ` +${p.atMs}ms ${p.phase}${parts.length ? ` | ${parts.join(" | ")}` : ""}`;
|
|
||||||
});
|
|
||||||
return [header, ...lines].join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
const CONVERSION_LOG_MAX_FILE_BYTES = Number(process.env.RD_CONVERSION_LOG_MAX_BYTES || 5 * 1024 * 1024);
|
|
||||||
const CONVERSION_LOG_RETENTION_DAYS = Number(process.env.RD_CONVERSION_LOG_RETENTION_DAYS || 14);
|
|
||||||
|
|
||||||
let conversionLogPath: string | null = null;
|
|
||||||
|
|
||||||
function rotateIfNeeded(filePath: string): void {
|
|
||||||
try {
|
|
||||||
const stat = fs.statSync(filePath);
|
|
||||||
if (stat.size < CONVERSION_LOG_MAX_FILE_BYTES) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const backup = `${filePath}.old`;
|
|
||||||
try {
|
|
||||||
fs.rmSync(backup, { force: true });
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
fs.renameSync(filePath, backup);
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function cleanupOldBackup(filePath: string): void {
|
|
||||||
const backup = `${filePath}.old`;
|
|
||||||
try {
|
|
||||||
const stat = fs.statSync(backup);
|
|
||||||
const cutoff = Date.now() - CONVERSION_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;
|
|
||||||
if (stat.mtimeMs < cutoff) {
|
|
||||||
fs.rmSync(backup, { force: true });
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function initConversionLog(baseDir: string): void {
|
|
||||||
conversionLogPath = path.join(baseDir, "conversion.log");
|
|
||||||
try {
|
|
||||||
fs.mkdirSync(path.dirname(conversionLogPath), { recursive: true });
|
|
||||||
cleanupOldBackup(conversionLogPath);
|
|
||||||
if (!fs.existsSync(conversionLogPath)) {
|
|
||||||
fs.writeFileSync(conversionLogPath, "", "utf8");
|
|
||||||
}
|
|
||||||
rotateIfNeeded(conversionLogPath);
|
|
||||||
if (!fs.existsSync(conversionLogPath)) {
|
|
||||||
fs.writeFileSync(conversionLogPath, "", "utf8");
|
|
||||||
}
|
|
||||||
fs.appendFileSync(conversionLogPath, `=== Conversion Log Start: ${logTimestamp()} ===\n`, "utf8");
|
|
||||||
} catch {
|
|
||||||
conversionLogPath = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getConversionLogPath(): string | null {
|
|
||||||
if (!conversionLogPath) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return fs.existsSync(conversionLogPath) ? conversionLogPath : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function shutdownConversionLog(): void {
|
|
||||||
if (!conversionLogPath) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
fs.appendFileSync(conversionLogPath, `=== Conversion Log Ende: ${logTimestamp()} ===\n`, "utf8");
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
conversionLogPath = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeConversionBlock(block: string): void {
|
|
||||||
if (!conversionLogPath) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
rotateIfNeeded(conversionLogPath);
|
|
||||||
if (!fs.existsSync(conversionLogPath)) {
|
|
||||||
fs.writeFileSync(conversionLogPath, "", "utf8");
|
|
||||||
}
|
|
||||||
fs.appendFileSync(conversionLogPath, `${block}\n`, "utf8");
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function runWithConversionTrace<T>(
|
|
||||||
meta: { itemId: string; itemName: string; link: string; providerOrder: string },
|
|
||||||
fn: () => Promise<T>
|
|
||||||
): Promise<T> {
|
|
||||||
const trace: ConversionTrace = {
|
|
||||||
startedAt: Date.now(),
|
|
||||||
itemId: meta.itemId,
|
|
||||||
itemName: meta.itemName,
|
|
||||||
link: meta.link,
|
|
||||||
providerOrder: meta.providerOrder,
|
|
||||||
notes: {},
|
|
||||||
phases: []
|
|
||||||
};
|
|
||||||
let outcome = "OK";
|
|
||||||
let detail = "";
|
|
||||||
try {
|
|
||||||
const result = await conversionContext.run(trace, fn);
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
outcome = "FAIL";
|
|
||||||
detail = String((error as { message?: string })?.message || error || "").replace(/^Error:\s*/i, "").slice(0, 160);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
const totalMs = Date.now() - trace.startedAt;
|
|
||||||
writeConversionBlock(formatConversionBlock(trace, outcome, detail, totalMs));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -2,11 +2,9 @@ import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
|
|||||||
import { parseMegaDebridAccounts, type MegaDebridAccountEntry } from "../shared/mega-debrid-accounts";
|
import { parseMegaDebridAccounts, type MegaDebridAccountEntry } from "../shared/mega-debrid-accounts";
|
||||||
import { AllDebridHostInfo, AppSettings, DebridFallbackProvider, DebridLinkHostLimitInfo, DebridProvider } from "../shared/types";
|
import { AllDebridHostInfo, AppSettings, DebridFallbackProvider, DebridLinkHostLimitInfo, DebridProvider } from "../shared/types";
|
||||||
import { isDebridLinkApiKeyDailyLimitReached, isMegaDebridAccountDisabled, isMegaDebridAccountDailyLimitReached, isProviderDailyLimitReached } from "../shared/provider-daily-limits";
|
import { isDebridLinkApiKeyDailyLimitReached, isMegaDebridAccountDisabled, isMegaDebridAccountDailyLimitReached, isProviderDailyLimitReached } from "../shared/provider-daily-limits";
|
||||||
import { isMegaDebridResolveFailure, germanMegaDebridResolveReason } from "../shared/mega-debrid-errors";
|
|
||||||
import { APP_VERSION, REQUEST_RETRIES } from "./constants";
|
import { APP_VERSION, REQUEST_RETRIES } from "./constants";
|
||||||
import { logger } from "./logger";
|
import { logger } from "./logger";
|
||||||
import { logAccountRotation } from "./account-rotation-log";
|
import { logAccountRotation } from "./account-rotation-log";
|
||||||
import { traceConversionPhase } from "./conversion-trace";
|
|
||||||
import { RealDebridClient, UnrestrictedLink } from "./realdebrid";
|
import { RealDebridClient, UnrestrictedLink } from "./realdebrid";
|
||||||
import { MEGA_DEBRID_NO_SERVER_RE } from "./mega-web-fallback";
|
import { MEGA_DEBRID_NO_SERVER_RE } from "./mega-web-fallback";
|
||||||
import { isMegaFileUrl, resolveMegaFilename } from "./mega-public-api";
|
import { isMegaFileUrl, resolveMegaFilename } from "./mega-public-api";
|
||||||
@ -123,11 +121,6 @@ export function getDebridLinkKeyRuntimeStateForTests(keyId: string): DebridLinkR
|
|||||||
return status ? status.state : null;
|
return status ? status.state : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDebridLinkKeyCooldownStateForTests(keyId: string, now = Date.now()): { remainingMs: number; message: string } | null {
|
|
||||||
const state = getDebridLinkKeyCooldownState(keyId, now);
|
|
||||||
return state ? { remainingMs: state.remainingMs, message: state.message } : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearDebridLinkKeyCooldownState(keyId: string): void {
|
function clearDebridLinkKeyCooldownState(keyId: string): void {
|
||||||
debridLinkKeyCooldowns.delete(keyId);
|
debridLinkKeyCooldowns.delete(keyId);
|
||||||
debridLinkKeyCooldownDetails.delete(keyId);
|
debridLinkKeyCooldownDetails.delete(keyId);
|
||||||
@ -288,7 +281,6 @@ type MegaDebridCooldownCategory = "invalid" | "rate_limit" | "quota" | "temporar
|
|||||||
type MegaDebridCooldownDetail = { until: number; message: string; category: MegaDebridCooldownCategory; untilRestart?: boolean };
|
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
|
||||||
@ -302,24 +294,25 @@ function getMegaDebridAbortMinRunMs(): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const megaDebridEmptyResponseStreaks = new Map<string, number>();
|
const megaDebridEmptyResponseStreaks = new Map<string, number>();
|
||||||
export const MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART = 10;
|
export const MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART = 3;
|
||||||
|
|
||||||
let megaDebridRotationCursor = 0;
|
let megaDebridRotationCursor = 0;
|
||||||
let megaDebridStickyCount = 0;
|
|
||||||
// Mega-Web cacht Sessions pro Account (~20 Min). Wuerde jede Link-Aufloesung den
|
|
||||||
// Account wechseln (reines Round-Robin), zahlte JEDER Link einen kalten Login in
|
|
||||||
// die serielle Single-Flight-Queue → minutenlanger Vorlauf. Stattdessen bleibt die
|
|
||||||
// Rotation "klebrig": ein funktionierender Account wird fuer einen Schwung Links
|
|
||||||
// behalten (warm/schnell), erst danach (oder bei Limit/Cooldown/Fehler) auf den
|
|
||||||
// naechsten gewechselt. So bleibt es schnell UND ueber die Zeit kommen alle dran.
|
|
||||||
export const MEGA_DEBRID_STICKY_LINKS = 25;
|
|
||||||
|
|
||||||
// Wie viele Umwandlungen pro Account (key `${id}:${mode}`) GERADE laufen/anstehen. Gleichzeitige
|
const MEGA_DEBRID_SLOW_ACCOUNT_FLOOR_MS = 6000;
|
||||||
// Aufloesungen waehlen den am WENIGSTEN belegten Account → verschiedene Accounts wandeln parallel
|
const MEGA_DEBRID_SLOW_ACCOUNT_FACTOR = 3;
|
||||||
// um (je eigene Queue in MegaWebFallback), und auch bei mehr gleichzeitigen Links als Accounts
|
const megaDebridAccountLatencyEma = new Map<string, number>();
|
||||||
// verteilt es sich gleichmaessig statt sich hinter einem Account zu stauen. Tiefe (nicht nur
|
|
||||||
// belegt/frei), damit ein frueh fertiger Erst-Job einen noch laufenden Folge-Job nicht "frei" meldet.
|
function recordMegaDebridUnrestrictLatency(cooldownKey: string, elapsedMs: number): void {
|
||||||
const megaDebridInFlight = new Map<string, number>();
|
if (!Number.isFinite(elapsedMs) || elapsedMs < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const prev = megaDebridAccountLatencyEma.get(cooldownKey);
|
||||||
|
megaDebridAccountLatencyEma.set(cooldownKey, prev === undefined ? elapsedMs : prev * 0.7 + elapsedMs * 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function primeMegaDebridLatencyForTests(cooldownKey: string, emaMs: number): void {
|
||||||
|
megaDebridAccountLatencyEma.set(cooldownKey, emaMs);
|
||||||
|
}
|
||||||
|
|
||||||
export function recordMegaDebridEmptyResponseStreak(accountId: string): number {
|
export function recordMegaDebridEmptyResponseStreak(accountId: string): number {
|
||||||
const streak = (megaDebridEmptyResponseStreaks.get(accountId) || 0) + 1;
|
const streak = (megaDebridEmptyResponseStreaks.get(accountId) || 0) + 1;
|
||||||
@ -335,27 +328,7 @@ export function resetMegaDebridRuntimeStateForTests(): void {
|
|||||||
megaDebridAccountCooldowns.clear();
|
megaDebridAccountCooldowns.clear();
|
||||||
megaDebridEmptyResponseStreaks.clear();
|
megaDebridEmptyResponseStreaks.clear();
|
||||||
megaDebridRotationCursor = 0;
|
megaDebridRotationCursor = 0;
|
||||||
megaDebridStickyCount = 0;
|
megaDebridAccountLatencyEma.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 {
|
||||||
@ -374,26 +347,14 @@ export function primeMegaDebridRuntimeCooldownForTests(accountId: string, cooldo
|
|||||||
setMegaDebridAccountCooldownState(accountId, cooldownMs, message, "temporary");
|
setMegaDebridAccountCooldownState(accountId, cooldownMs, message, "temporary");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function primeMegaDebridUntilRestartForTests(accountId: string, message = "Tageslimit (Test) — bis zum Tagesreset gesperrt"): void {
|
export function primeMegaDebridUntilRestartForTests(accountId: string, message = "Tageslimit (Test) — bis Neustart gesperrt"): void {
|
||||||
setMegaDebridAccountCooldownState(accountId, 0, message, "quota", true);
|
setMegaDebridAccountCooldownState(accountId, 0, message, "quota", true);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function classifyMegaDebridAccountFailureForTests(
|
|
||||||
error: unknown
|
|
||||||
): { fatal: boolean; cooldownMs: number; message: string; category: MegaDebridCooldownCategory; limitSignal?: boolean } {
|
|
||||||
return MegaDebridClient.classifyAccountFailure(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearMegaDebridAccountCooldownState(accountId: string): void {
|
function clearMegaDebridAccountCooldownState(accountId: string): void {
|
||||||
megaDebridAccountCooldowns.delete(accountId);
|
megaDebridAccountCooldowns.delete(accountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function megaDebridDailyParkExpiry(now: number): number {
|
|
||||||
const midnight = new Date(now);
|
|
||||||
midnight.setHours(24, 0, 0, 0);
|
|
||||||
return Math.max(midnight.getTime(), now + MEGA_DEBRID_ACCOUNT_COOLDOWN_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
function setMegaDebridAccountCooldownState(
|
function setMegaDebridAccountCooldownState(
|
||||||
accountId: string,
|
accountId: string,
|
||||||
cooldownMs: number,
|
cooldownMs: number,
|
||||||
@ -403,7 +364,7 @@ function setMegaDebridAccountCooldownState(
|
|||||||
): void {
|
): void {
|
||||||
if (untilRestart) {
|
if (untilRestart) {
|
||||||
megaDebridAccountCooldowns.set(accountId, {
|
megaDebridAccountCooldowns.set(accountId, {
|
||||||
until: megaDebridDailyParkExpiry(Date.now()),
|
until: Number.MAX_SAFE_INTEGER,
|
||||||
message,
|
message,
|
||||||
category,
|
category,
|
||||||
untilRestart: true
|
untilRestart: true
|
||||||
@ -442,106 +403,6 @@ export function getMegaDebridAccountCooldownState(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProviderRuntimeCooldown {
|
|
||||||
untilMs: number;
|
|
||||||
remainingMs: number;
|
|
||||||
message: string;
|
|
||||||
category: string;
|
|
||||||
untilRestart?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProviderRuntimeSnapshot {
|
|
||||||
capturedAtMs: number;
|
|
||||||
megaDebrid: {
|
|
||||||
rotationCursor: number;
|
|
||||||
stickyCount: number;
|
|
||||||
accounts: Array<{
|
|
||||||
key: string;
|
|
||||||
cooldown: ProviderRuntimeCooldown | null;
|
|
||||||
inFlight: number;
|
|
||||||
emptyResponseStreak: number;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
debridLink: {
|
|
||||||
keys: Array<{
|
|
||||||
keyId: string;
|
|
||||||
cooldown: ProviderRuntimeCooldown | null;
|
|
||||||
runtimeStatus: { state: string; detail: string; updatedAt: number } | null;
|
|
||||||
}>;
|
|
||||||
hostCooldowns: Array<{ key: string; cooldown: ProviderRuntimeCooldown }>;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getProviderRuntimeSnapshot(now = Date.now()): ProviderRuntimeSnapshot {
|
|
||||||
const megaKeys = new Set<string>([
|
|
||||||
...megaDebridAccountCooldowns.keys(),
|
|
||||||
...megaDebridInFlight.keys(),
|
|
||||||
...megaDebridEmptyResponseStreaks.keys()
|
|
||||||
]);
|
|
||||||
const megaAccounts = [...megaKeys].sort().map((key) => {
|
|
||||||
const detail = megaDebridAccountCooldowns.get(key);
|
|
||||||
return {
|
|
||||||
key,
|
|
||||||
cooldown: detail
|
|
||||||
? {
|
|
||||||
untilMs: detail.until,
|
|
||||||
remainingMs: Math.max(0, detail.until - now),
|
|
||||||
message: detail.message,
|
|
||||||
category: detail.category,
|
|
||||||
untilRestart: detail.untilRestart === true
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
inFlight: megaDebridInFlight.get(key) ?? 0,
|
|
||||||
emptyResponseStreak: megaDebridEmptyResponseStreaks.get(key) ?? 0
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const dlKeyIds = new Set<string>([
|
|
||||||
...debridLinkKeyCooldowns.keys(),
|
|
||||||
...debridLinkKeyRuntimeStatuses.keys()
|
|
||||||
]);
|
|
||||||
const dlKeys = [...dlKeyIds].sort().map((keyId) => {
|
|
||||||
const until = Number(debridLinkKeyCooldowns.get(keyId) || 0);
|
|
||||||
const detail = debridLinkKeyCooldownDetails.get(keyId);
|
|
||||||
const status = debridLinkKeyRuntimeStatuses.get(keyId) || null;
|
|
||||||
return {
|
|
||||||
keyId,
|
|
||||||
cooldown: until > 0
|
|
||||||
? {
|
|
||||||
untilMs: until,
|
|
||||||
remainingMs: Math.max(0, until - now),
|
|
||||||
message: detail?.message ?? "",
|
|
||||||
category: detail?.category ?? "temporary"
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
runtimeStatus: status ? { state: status.state, detail: status.detail, updatedAt: status.updatedAt } : null
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const dlHostCooldowns = [...debridLinkKeyHostCooldowns].map(([key, until]) => {
|
|
||||||
const detail = debridLinkKeyHostCooldownDetails.get(key);
|
|
||||||
return {
|
|
||||||
key,
|
|
||||||
cooldown: {
|
|
||||||
untilMs: until,
|
|
||||||
remainingMs: Math.max(0, until - now),
|
|
||||||
message: detail?.message ?? "",
|
|
||||||
category: detail?.category ?? "temporary"
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
capturedAtMs: now,
|
|
||||||
megaDebrid: {
|
|
||||||
rotationCursor: megaDebridRotationCursor,
|
|
||||||
stickyCount: megaDebridStickyCount,
|
|
||||||
accounts: megaAccounts
|
|
||||||
},
|
|
||||||
debridLink: { keys: dlKeys, hostCooldowns: dlHostCooldowns }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const LINKSNAPPY_API_BASE = "https://linksnappy.com/api";
|
const LINKSNAPPY_API_BASE = "https://linksnappy.com/api";
|
||||||
|
|
||||||
const PROVIDER_LABELS: Record<DebridProvider, string> = {
|
const PROVIDER_LABELS: Record<DebridProvider, string> = {
|
||||||
@ -1446,13 +1307,6 @@ function toProviderOrder(primary: DebridProvider, secondary: DebridFallbackProvi
|
|||||||
return uniqueProviderOrder(order);
|
return uniqueProviderOrder(order);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function leadProviderChainWith(order: readonly DebridProvider[], preferred: DebridProvider | null | undefined): DebridProvider[] {
|
|
||||||
if (!preferred || !order.includes(preferred)) {
|
|
||||||
return [...order];
|
|
||||||
}
|
|
||||||
return [preferred, ...order.filter((provider) => provider !== preferred)];
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRapidgatorLink(link: string): boolean {
|
function isRapidgatorLink(link: string): boolean {
|
||||||
try {
|
try {
|
||||||
const hostname = new URL(link).hostname.toLowerCase();
|
const hostname = new URL(link).hostname.toLowerCase();
|
||||||
@ -1887,13 +1741,11 @@ class MegaDebridClient {
|
|||||||
const key = this.cacheKey;
|
const key = this.cacheKey;
|
||||||
const cached = MegaDebridClient.cachedApiTokens.get(key);
|
const cached = MegaDebridClient.cachedApiTokens.get(key);
|
||||||
if (cached && cached.token && Date.now() - cached.at < 20 * 60 * 1000) {
|
if (cached && cached.token && Date.now() - cached.at < 20 * 60 * 1000) {
|
||||||
traceConversionPhase({ phase: "token", provider: "megadebrid-api", tokenState: `cached(${Math.floor((Date.now() - cached.at) / 1000)}s)`, outcome: "ok" });
|
|
||||||
return cached.token;
|
return cached.token;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pending = MegaDebridClient.pendingConnects.get(key);
|
const pending = MegaDebridClient.pendingConnects.get(key);
|
||||||
if (pending) {
|
if (pending) {
|
||||||
traceConversionPhase({ phase: "token", provider: "megadebrid-api", tokenState: "pending-join", outcome: "ok" });
|
|
||||||
return pending;
|
return pending;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1909,7 +1761,6 @@ class MegaDebridClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async doConnectApi(signal?: AbortSignal): Promise<string | null> {
|
private async doConnectApi(signal?: AbortSignal): Promise<string | null> {
|
||||||
const connectStartedAt = Date.now();
|
|
||||||
const url = `${MEGA_DEBRID_API_BASE}?action=connectUser&login=${encodeURIComponent(this.login)}&password=${encodeURIComponent(this.password)}`;
|
const url = `${MEGA_DEBRID_API_BASE}?action=connectUser&login=${encodeURIComponent(this.login)}&password=${encodeURIComponent(this.password)}`;
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
headers: { "User-Agent": DEBRID_USER_AGENT },
|
headers: { "User-Agent": DEBRID_USER_AGENT },
|
||||||
@ -1920,7 +1771,6 @@ class MegaDebridClient {
|
|||||||
if (response.status === 401 || response.status === 403) {
|
if (response.status === 401 || response.status === 403) {
|
||||||
this.clearTokenCache();
|
this.clearTokenCache();
|
||||||
}
|
}
|
||||||
traceConversionPhase({ phase: "token", provider: "megadebrid-api", tokenState: "fresh-login", workMs: Date.now() - connectStartedAt, outcome: "error", detail: `HTTP ${response.status}` });
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const payload = parseJsonSafe(text);
|
const payload = parseJsonSafe(text);
|
||||||
@ -1928,16 +1778,13 @@ class MegaDebridClient {
|
|||||||
if (payload && String(payload.response_code || "").toLowerCase().includes("token")) {
|
if (payload && String(payload.response_code || "").toLowerCase().includes("token")) {
|
||||||
this.clearTokenCache();
|
this.clearTokenCache();
|
||||||
}
|
}
|
||||||
traceConversionPhase({ phase: "token", provider: "megadebrid-api", tokenState: "fresh-login", workMs: Date.now() - connectStartedAt, outcome: "error", detail: `response_code=${payload?.response_code || "?"} ${String(payload?.response_text || "").slice(0, 80)}`.trim() });
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const token = String(payload.token || "").trim();
|
const token = String(payload.token || "").trim();
|
||||||
if (!token) {
|
if (!token) {
|
||||||
traceConversionPhase({ phase: "token", provider: "megadebrid-api", tokenState: "fresh-login", workMs: Date.now() - connectStartedAt, outcome: "error", detail: "leeres Token" });
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
MegaDebridClient.cachedApiTokens.set(this.cacheKey, { token, at: Date.now() });
|
MegaDebridClient.cachedApiTokens.set(this.cacheKey, { token, at: Date.now() });
|
||||||
traceConversionPhase({ phase: "token", provider: "megadebrid-api", tokenState: "fresh-login", workMs: Date.now() - connectStartedAt, outcome: "ok" });
|
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1947,7 +1794,6 @@ class MegaDebridClient {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getLinkStartedAt = Date.now();
|
|
||||||
const url = `${MEGA_DEBRID_API_BASE}?action=getLink&token=${encodeURIComponent(token)}`;
|
const url = `${MEGA_DEBRID_API_BASE}?action=getLink&token=${encodeURIComponent(token)}`;
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@ -1963,17 +1809,14 @@ class MegaDebridClient {
|
|||||||
if (response.status === 401 || response.status === 403) {
|
if (response.status === 401 || response.status === 403) {
|
||||||
this.clearTokenCache();
|
this.clearTokenCache();
|
||||||
}
|
}
|
||||||
traceConversionPhase({ phase: "api-getlink", provider: "megadebrid-api", workMs: Date.now() - getLinkStartedAt, outcome: "error", detail: `HTTP ${response.status}` });
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const payload = parseJsonSafe(text);
|
const payload = parseJsonSafe(text);
|
||||||
if (!payload || payload.response_code !== "ok") {
|
if (!payload || payload.response_code !== "ok") {
|
||||||
const tokenInvalidated = Boolean(payload && String(payload.response_code || "").includes("token"));
|
if (payload && String(payload.response_code || "").includes("token")) {
|
||||||
if (tokenInvalidated) {
|
|
||||||
this.clearTokenCache();
|
this.clearTokenCache();
|
||||||
}
|
}
|
||||||
const errorText = String(payload?.response_text || "").trim();
|
const errorText = String(payload?.response_text || "").trim();
|
||||||
traceConversionPhase({ phase: "api-getlink", provider: "megadebrid-api", workMs: Date.now() - getLinkStartedAt, outcome: "error", detail: `response_code=${payload?.response_code || "?"}${tokenInvalidated ? " (token-cache-geleert)" : ""} ${errorText}`.trim() });
|
|
||||||
if (errorText) {
|
if (errorText) {
|
||||||
throw new Error(`Mega-Debrid API: ${errorText}`);
|
throw new Error(`Mega-Debrid API: ${errorText}`);
|
||||||
}
|
}
|
||||||
@ -1982,10 +1825,8 @@ class MegaDebridClient {
|
|||||||
|
|
||||||
const directUrl = String(payload.debridLink || "").trim();
|
const directUrl = String(payload.debridLink || "").trim();
|
||||||
if (!directUrl) {
|
if (!directUrl) {
|
||||||
traceConversionPhase({ phase: "api-getlink", provider: "megadebrid-api", workMs: Date.now() - getLinkStartedAt, outcome: "error", detail: "kein debridLink" });
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
traceConversionPhase({ phase: "api-getlink", provider: "megadebrid-api", workMs: Date.now() - getLinkStartedAt, outcome: "ok" });
|
|
||||||
const fileName = String(payload.filename || "").trim() || filenameFromUrl(directUrl) || filenameFromUrl(link);
|
const fileName = String(payload.filename || "").trim() || filenameFromUrl(directUrl) || filenameFromUrl(link);
|
||||||
return {
|
return {
|
||||||
directUrl,
|
directUrl,
|
||||||
@ -2010,9 +1851,6 @@ class MegaDebridClient {
|
|||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
if (signal?.aborted) {
|
if (signal?.aborted) {
|
||||||
if (/queue.?timeout/i.test(lastError)) {
|
|
||||||
throw new Error(lastError.replace(/^Error:\s*/i, ""));
|
|
||||||
}
|
|
||||||
throw new Error("aborted:debrid");
|
throw new Error("aborted:debrid");
|
||||||
}
|
}
|
||||||
if (web?.directUrl) {
|
if (web?.directUrl) {
|
||||||
@ -2044,7 +1882,7 @@ class MegaDebridClient {
|
|||||||
logger.info(`Mega-Debrid (API) unrestrict OK: ${apiResult.fileName}`);
|
logger.info(`Mega-Debrid (API) unrestrict OK: ${apiResult.fileName}`);
|
||||||
return apiResult;
|
return apiResult;
|
||||||
}
|
}
|
||||||
throw new Error("Mega-Debrid API: Linkgenerierung lieferte kein Ergebnis");
|
throw new Error("Mega-Debrid API: Login oder Unrestrict fehlgeschlagen");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorText = compactErrorText(error);
|
const errorText = compactErrorText(error);
|
||||||
if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) {
|
if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) {
|
||||||
@ -2087,28 +1925,50 @@ class MegaDebridClient {
|
|||||||
const providerName = `Mega-Debrid ${mode === "api" ? "API" : "Web"}`;
|
const providerName = `Mega-Debrid ${mode === "api" ? "API" : "Web"}`;
|
||||||
const linkShort = String(link || "").slice(0, 80);
|
const linkShort = String(link || "").slice(0, 80);
|
||||||
|
|
||||||
// Klebrige Rotation: gestartet wird beim Cursor (zuletzt erfolgreich genutzter,
|
// Round-Robin statt First-Wins: ohne den Cursor startete jede Link-Aufloesung
|
||||||
// also warmer Account), danach der Reihe nach als Failover. Alle Skip-/Cooldown-
|
// bei Account 1 und endete beim ersten brauchbaren — ein spaeterer Account kam
|
||||||
// Checks bleiben. Der Cursor wird erst im Erfolgszweig weitergesetzt — nach einem
|
// nur dran, wenn ALLE davor am Limit/Cooldown waren. Live-Folge: Account 1-2
|
||||||
// Schwung erfolgreicher Umwandlungen (MEGA_DEBRID_STICKY_LINKS) — sodass aufeinander
|
// liefen staendig ins Tageslimit, Account 3 trug den Rest allein und Account 4
|
||||||
// folgende Links auf demselben warmen Account laufen statt jeweils neu einzuloggen.
|
// wurde nie auch nur angeschaut. Der Cursor laesst jede Aufloesung beim Account
|
||||||
|
// NACH dem zuletzt getesteten starten, alle Skip-/Cooldown-Checks bleiben.
|
||||||
const startOffset = ((megaDebridRotationCursor % accounts.length) + accounts.length) % accounts.length;
|
const startOffset = ((megaDebridRotationCursor % accounts.length) + accounts.length) % accounts.length;
|
||||||
const cursorOrder: { account: MegaDebridAccountEntry; idx: number }[] = [];
|
const rotationOrder: { account: MegaDebridAccountEntry; idx: number }[] = [];
|
||||||
for (let step = 0; step < accounts.length; step += 1) {
|
for (let step = 0; step < accounts.length; step += 1) {
|
||||||
const idx = (startOffset + step) % accounts.length;
|
const idx = (startOffset + step) % accounts.length;
|
||||||
cursorOrder.push({ account: accounts[idx], idx });
|
rotationOrder.push({ account: accounts[idx], idx });
|
||||||
}
|
}
|
||||||
// Parallel ueber mehrere Accounts: nach AKTUELLER Auslastung (in-flight-Tiefe) sortieren —
|
|
||||||
// am wenigsten belegter Account zuerst, cursorOrder als stabiler Gleichstand-Tiebreak. So
|
// Latenz-Schutz fuer die Mega-Web-Single-Flight-Queue: dort laufen ALLE
|
||||||
// verteilen sich auch MEHR gleichzeitige Aufloesungen als Accounts gleichmaessig (statt sich
|
// Umwandlungen seriell, ein nachweislich langsamer Account bremst also
|
||||||
// alle hinter dem Cursor-Account zu stauen). Nichts in-flight (sequenziell) => alle Tiefe 0 =>
|
// jeden nachfolgenden Link aus. Accounts mit deutlich erhoehtem Erfolgs-EMA
|
||||||
// Reihenfolge == cursorOrder => bleibt klebrig beim warmen Account.
|
// wandern ans Ende der Reihenfolge (= Failover-Reserve) statt in der
|
||||||
const inFlightDepth = (entry: { account: MegaDebridAccountEntry }): number =>
|
// gleichmaessigen Rotation mitzulaufen; erholt sich ihr EMA, rotieren sie
|
||||||
megaDebridInFlight.get(`${entry.account.id}:${mode}`) ?? 0;
|
// automatisch wieder mit. Ungemessene Accounts gelten als gesund.
|
||||||
const orderedEntries = cursorOrder
|
let bestEma = Infinity;
|
||||||
.map((entry, position) => ({ entry, position }))
|
let anyUnmeasuredUsable = false;
|
||||||
.sort((a, b) => (inFlightDepth(a.entry) - inFlightDepth(b.entry)) || (a.position - b.position))
|
for (const entry of rotationOrder) {
|
||||||
.map((wrapped) => wrapped.entry);
|
if (isMegaDebridAccountDisabled(settings, entry.account.id)) continue;
|
||||||
|
if (isMegaDebridAccountDailyLimitReached(settings, entry.account.id)) continue;
|
||||||
|
if (getMegaDebridAccountCooldownState(`${entry.account.id}:${mode}`)) continue;
|
||||||
|
const ema = megaDebridAccountLatencyEma.get(`${entry.account.id}:${mode}`);
|
||||||
|
if (ema === undefined) {
|
||||||
|
anyUnmeasuredUsable = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ema < bestEma) bestEma = ema;
|
||||||
|
}
|
||||||
|
const slowThreshold = Math.max(
|
||||||
|
MEGA_DEBRID_SLOW_ACCOUNT_FLOOR_MS,
|
||||||
|
anyUnmeasuredUsable ? 0 : (Number.isFinite(bestEma) ? MEGA_DEBRID_SLOW_ACCOUNT_FACTOR * bestEma : Infinity)
|
||||||
|
);
|
||||||
|
const isSlowEntry = (entry: { account: MegaDebridAccountEntry }): boolean => {
|
||||||
|
const ema = megaDebridAccountLatencyEma.get(`${entry.account.id}:${mode}`);
|
||||||
|
return ema !== undefined && ema > slowThreshold;
|
||||||
|
};
|
||||||
|
const orderedEntries = [
|
||||||
|
...rotationOrder.filter((entry) => !isSlowEntry(entry)),
|
||||||
|
...rotationOrder.filter((entry) => isSlowEntry(entry))
|
||||||
|
];
|
||||||
|
|
||||||
for (let orderPos = 0; orderPos < orderedEntries.length; orderPos += 1) {
|
for (let orderPos = 0; orderPos < orderedEntries.length; orderPos += 1) {
|
||||||
const entry = orderedEntries[orderPos];
|
const entry = orderedEntries[orderPos];
|
||||||
@ -2134,7 +1994,7 @@ class MegaDebridClient {
|
|||||||
? "Neustart"
|
? "Neustart"
|
||||||
: new Date(accountCooldownState.until).toLocaleTimeString();
|
: new Date(accountCooldownState.until).toLocaleTimeString();
|
||||||
const reasonText = accountCooldownState.untilRestart
|
const reasonText = accountCooldownState.untilRestart
|
||||||
? "Tageslimit erreicht — bis zum Tagesreset gesperrt"
|
? "Tageslimit erreicht — bis Neustart gesperrt"
|
||||||
: `Cooldown bis ${untilStr}`;
|
: `Cooldown bis ${untilStr}`;
|
||||||
logger.info(`Mega-Debrid${accountLabel}: uebersprungen (${reasonText}), pruefe naechsten Account`);
|
logger.info(`Mega-Debrid${accountLabel}: uebersprungen (${reasonText}), pruefe naechsten Account`);
|
||||||
logAccountRotation("INFO", providerName, rotationLabel, "SKIP_COOLDOWN", {
|
logAccountRotation("INFO", providerName, rotationLabel, "SKIP_COOLDOWN", {
|
||||||
@ -2151,28 +2011,24 @@ class MegaDebridClient {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const emaForLog = megaDebridAccountLatencyEma.get(cooldownKey);
|
||||||
logger.info(`Mega-Debrid${accountLabel}: TESTE Account fuer Link-Generierung...`);
|
logger.info(`Mega-Debrid${accountLabel}: TESTE Account fuer Link-Generierung...`);
|
||||||
logAccountRotation("INFO", providerName, rotationLabel, "TEST", {
|
logAccountRotation("INFO", providerName, rotationLabel, "TEST", {
|
||||||
link: linkShort
|
link: linkShort,
|
||||||
|
emaMs: emaForLog !== undefined ? Math.round(emaForLog) : undefined,
|
||||||
|
slow: isSlowEntry(entry) ? true : undefined
|
||||||
});
|
});
|
||||||
const testStartedAt = Date.now();
|
const testStartedAt = Date.now();
|
||||||
|
|
||||||
usableAccountSeen = true;
|
usableAccountSeen = true;
|
||||||
megaDebridInFlight.set(cooldownKey, (megaDebridInFlight.get(cooldownKey) ?? 0) + 1);
|
megaDebridRotationCursor = idx + 1;
|
||||||
try {
|
try {
|
||||||
const client = new MegaDebridClient(account.login, account.password, mode, allowApiFallback, megaWebUnrestrict);
|
const client = new MegaDebridClient(account.login, account.password, mode, allowApiFallback, megaWebUnrestrict);
|
||||||
const result = await client.unrestrictLink(link, signal);
|
const result = await client.unrestrictLink(link, signal);
|
||||||
clearMegaDebridAccountCooldownState(cooldownKey);
|
clearMegaDebridAccountCooldownState(cooldownKey);
|
||||||
clearMegaDebridEmptyResponseStreak(cooldownKey);
|
clearMegaDebridEmptyResponseStreak(cooldownKey);
|
||||||
const elapsedMs = Date.now() - testStartedAt;
|
const elapsedMs = Date.now() - testStartedAt;
|
||||||
traceConversionPhase({ phase: "mega-account", provider: providerName.includes("API") ? "megadebrid-api" : "megadebrid-web", account: rotationLabel, workMs: elapsedMs, outcome: "ok" });
|
recordMegaDebridUnrestrictLatency(cooldownKey, elapsedMs);
|
||||||
megaDebridStickyCount += 1;
|
|
||||||
if (megaDebridStickyCount >= MEGA_DEBRID_STICKY_LINKS) {
|
|
||||||
megaDebridRotationCursor = idx + 1;
|
|
||||||
megaDebridStickyCount = 0;
|
|
||||||
} else {
|
|
||||||
megaDebridRotationCursor = idx;
|
|
||||||
}
|
|
||||||
logger.info(`Mega-Debrid${accountLabel}: Unrestrict OK nach ${elapsedMs}ms -> ${result.fileName || "?"}`);
|
logger.info(`Mega-Debrid${accountLabel}: Unrestrict OK nach ${elapsedMs}ms -> ${result.fileName || "?"}`);
|
||||||
logAccountRotation("INFO", providerName, rotationLabel, "OK", {
|
logAccountRotation("INFO", providerName, rotationLabel, "OK", {
|
||||||
elapsedMs,
|
elapsedMs,
|
||||||
@ -2188,64 +2044,27 @@ 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 timeout fired). The
|
// Timeout/abort on THIS account (the shared unrestrict signal fired). Cool
|
||||||
// account-wide cooldown exists ONLY to make the retry rotate to another
|
// the account down — if it actually ran, not a quick user-cancel — so the
|
||||||
// account — so it is set only when another usable account actually exists.
|
// download-manager's retry rotates to the NEXT account instead of hammering
|
||||||
// With no rotation target (single account / all others busy), cooling the
|
// this one. The shared signal is now aborted, so we stop this pass; the
|
||||||
// sole account would freeze EVERY queued item while the account is healthy;
|
// retry runs the rotation fresh with this account skipped. A genuine cancel
|
||||||
// a >60s timeout is a slow-LINK signal, not an unhealthy-account signal, so
|
// is not retried by the caller, so the cooldown is harmless there.
|
||||||
// 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();
|
||||||
const otherUsableAccounts = orderedEntries.reduce((count, candidate) => {
|
if (ranLongEnough) {
|
||||||
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({
|
|
||||||
phase: "mega-account",
|
|
||||||
provider: providerName.includes("API") ? "megadebrid-api" : "megadebrid-web",
|
|
||||||
account: rotationLabel,
|
|
||||||
workMs: elapsedMs,
|
|
||||||
outcome: "aborted",
|
|
||||||
detail: `${abortText}${rotateToAnotherAccount ? ` cd=${Math.ceil(MEGA_DEBRID_ACCOUNT_COOLDOWN_MS / 1000)}s` : ranLongEnough ? ` slowlink=${Math.ceil(MEGA_DEBRID_SLOW_LINK_RETRY_MS / 1000)}s` : ""}`
|
|
||||||
});
|
|
||||||
failures.push(`Mega-Debrid${accountLabel}: ${abortText}`);
|
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: rotateToAnotherAccount ? Math.ceil(MEGA_DEBRID_ACCOUNT_COOLDOWN_MS / 1000) : 0,
|
cooldownSec: ranLongEnough ? Math.ceil(MEGA_DEBRID_ACCOUNT_COOLDOWN_MS / 1000) : 0,
|
||||||
next: rotateToAnotherAccount ? "naechster Account beim Retry" : "Einzel-Retry (Account bleibt fuer andere Items frei)"
|
next: "naechster Account beim Retry"
|
||||||
});
|
});
|
||||||
if (ranLongEnough && !rotateToAnotherAccount) {
|
|
||||||
throw new Error(`mega_debrid_slow_link:${MEGA_DEBRID_SLOW_LINK_RETRY_MS}:Mega-Debrid${accountLabel}: ${abortText}`);
|
|
||||||
}
|
|
||||||
throw new Error(`Mega-Debrid${accountLabel}: ${abortText}`);
|
throw new Error(`Mega-Debrid${accountLabel}: ${abortText}`);
|
||||||
}
|
}
|
||||||
const failure = MegaDebridClient.classifyAccountFailure(error);
|
const failure = MegaDebridClient.classifyAccountFailure(error);
|
||||||
traceConversionPhase({
|
|
||||||
phase: "mega-account",
|
|
||||||
provider: providerName.includes("API") ? "megadebrid-api" : "megadebrid-web",
|
|
||||||
account: rotationLabel,
|
|
||||||
workMs: Date.now() - testStartedAt,
|
|
||||||
outcome: failure.fatal ? "fatal" : "failed",
|
|
||||||
detail: `${failure.message}${failure.cooldownMs > 0 ? ` cd=${Math.ceil(failure.cooldownMs / 1000)}s` : ""}`
|
|
||||||
});
|
|
||||||
failures.push(`Mega-Debrid${accountLabel}: ${failure.message}`);
|
failures.push(`Mega-Debrid${accountLabel}: ${failure.message}`);
|
||||||
|
|
||||||
let parkUntilRestart = false;
|
let parkUntilRestart = false;
|
||||||
@ -2254,7 +2073,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 leere Antwort in Folge) — bis zum Tagesreset gesperrt`;
|
parkMessage = `Tageslimit erreicht (${streak}x kein Server/leere Antwort) — bis Neustart gesperrt`;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
clearMegaDebridEmptyResponseStreak(cooldownKey);
|
clearMegaDebridEmptyResponseStreak(cooldownKey);
|
||||||
@ -2277,7 +2096,7 @@ class MegaDebridClient {
|
|||||||
throw new Error(`Mega-Debrid${accountLabel}: ${failure.message}`);
|
throw new Error(`Mega-Debrid${accountLabel}: ${failure.message}`);
|
||||||
}
|
}
|
||||||
const cooldownInfo = parkUntilRestart
|
const cooldownInfo = parkUntilRestart
|
||||||
? ", bis zum Tagesreset gesperrt"
|
? ", bis Neustart gesperrt"
|
||||||
: failure.cooldownMs > 0
|
: failure.cooldownMs > 0
|
||||||
? `, Cooldown ${Math.ceil(failure.cooldownMs / 1000)}s`
|
? `, Cooldown ${Math.ceil(failure.cooldownMs / 1000)}s`
|
||||||
: "";
|
: "";
|
||||||
@ -2298,13 +2117,6 @@ class MegaDebridClient {
|
|||||||
next: nextLabel,
|
next: nextLabel,
|
||||||
link: linkShort
|
link: linkShort
|
||||||
});
|
});
|
||||||
} finally {
|
|
||||||
const remainingInFlight = (megaDebridInFlight.get(cooldownKey) ?? 1) - 1;
|
|
||||||
if (remainingInFlight <= 0) {
|
|
||||||
megaDebridInFlight.delete(cooldownKey);
|
|
||||||
} else {
|
|
||||||
megaDebridInFlight.set(cooldownKey, remainingInFlight);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2314,15 +2126,14 @@ class MegaDebridClient {
|
|||||||
throw new Error(`mega_debrid_cooldown:${retryMs}:${cooldownFailures.join(" | ")}`);
|
throw new Error(`mega_debrid_cooldown:${retryMs}:${cooldownFailures.join(" | ")}`);
|
||||||
}
|
}
|
||||||
if (parkedUntilRestartSeen) {
|
if (parkedUntilRestartSeen) {
|
||||||
const resetParkMs = Math.max(1000, megaDebridDailyParkExpiry(Date.now()) - Date.now() + 1000);
|
throw new Error(`Mega-Debrid: Alle Accounts am Tageslimit (bis Neustart gesperrt)${cooldownFailures.length > 0 ? ` | ${cooldownFailures.join(" | ")}` : ""}`);
|
||||||
throw new Error(`mega_debrid_reset_park:${resetParkMs}:Mega-Debrid: Alle Accounts am Tageslimit (bis zum Tagesreset gesperrt)${cooldownFailures.length > 0 ? ` | ${cooldownFailures.join(" | ")}` : ""}`);
|
|
||||||
}
|
}
|
||||||
throw new Error("Mega-Debrid: Kein aktiver Account verfuegbar");
|
throw new Error("Mega-Debrid: Kein aktiver Account verfuegbar");
|
||||||
}
|
}
|
||||||
throw new Error(failures.join(" | ") || "Mega-Debrid: Kein aktiver Account verfuegbar");
|
throw new Error(failures.join(" | ") || "Mega-Debrid: Kein aktiver Account verfuegbar");
|
||||||
}
|
}
|
||||||
|
|
||||||
static classifyAccountFailure(
|
private static classifyAccountFailure(
|
||||||
error: unknown
|
error: unknown
|
||||||
): { fatal: boolean; cooldownMs: number; message: string; category: MegaDebridCooldownCategory; limitSignal?: boolean } {
|
): { fatal: boolean; cooldownMs: number; message: string; category: MegaDebridCooldownCategory; limitSignal?: boolean } {
|
||||||
const errorText = compactErrorText(error).replace(/^Error:\s*/i, "");
|
const errorText = compactErrorText(error).replace(/^Error:\s*/i, "");
|
||||||
@ -2331,11 +2142,7 @@ class MegaDebridClient {
|
|||||||
return { fatal: true, cooldownMs: 0, message: errorText, category: "temporary" };
|
return { fatal: true, cooldownMs: 0, message: errorText, category: "temporary" };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (/token.?error|please log.?in/i.test(errorText)) {
|
if (/login|password|auth|credentials|unauthorized|forbidden/i.test(errorText) || /connectUser/i.test(errorText)) {
|
||||||
return { fatal: false, cooldownMs: 15_000, message: errorText, category: "temporary" };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (/bad.?login|incorrect.?(login|password)|invalid.?(login|password|credentials)|wrong.?password|unauthorized|forbidden|connectUser/i.test(errorText)) {
|
|
||||||
return {
|
return {
|
||||||
fatal: false,
|
fatal: false,
|
||||||
cooldownMs: MEGA_DEBRID_INVALID_ACCOUNT_COOLDOWN_MS,
|
cooldownMs: MEGA_DEBRID_INVALID_ACCOUNT_COOLDOWN_MS,
|
||||||
@ -2344,27 +2151,10 @@ class MegaDebridClient {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isMegaDebridResolveFailure(errorText)) {
|
|
||||||
return { fatal: false, cooldownMs: 0, message: germanMegaDebridResolveReason(errorText), category: "temporary" };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (/lieferte kein ergebnis|linkgenerierung[^.]*kein ergebnis/i.test(errorText)) {
|
|
||||||
return { fatal: false, cooldownMs: 0, message: germanMegaDebridResolveReason(errorText), category: "temporary" };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (/permanent ungültig|hosternotavailable|file.?not.?found|file.?unavailable|link.?is.?dead/i.test(errorText)) {
|
if (/permanent ungültig|hosternotavailable|file.?not.?found|file.?unavailable|link.?is.?dead/i.test(errorText)) {
|
||||||
return { fatal: true, cooldownMs: 0, message: errorText, category: "skip" };
|
return { fatal: true, cooldownMs: 0, message: errorText, category: "skip" };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (/rate.?limit|too.?many|429/i.test(errorText)) {
|
|
||||||
return {
|
|
||||||
fatal: false,
|
|
||||||
cooldownMs: MEGA_DEBRID_ACCOUNT_COOLDOWN_MS,
|
|
||||||
message: `Rate-Limit (${errorText})`,
|
|
||||||
category: "rate_limit"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (/quota|limit|exceeded|bandwidth/i.test(errorText)) {
|
if (/quota|limit|exceeded|bandwidth/i.test(errorText)) {
|
||||||
return {
|
return {
|
||||||
fatal: false,
|
fatal: false,
|
||||||
@ -2379,7 +2169,17 @@ class MegaDebridClient {
|
|||||||
fatal: false,
|
fatal: false,
|
||||||
cooldownMs: MEGA_DEBRID_ACCOUNT_COOLDOWN_MS,
|
cooldownMs: MEGA_DEBRID_ACCOUNT_COOLDOWN_MS,
|
||||||
message: "Kein Server fuer diesen Hoster (Tageslimit/Hoster nicht verfuegbar)",
|
message: "Kein Server fuer diesen Hoster (Tageslimit/Hoster nicht verfuegbar)",
|
||||||
category: "quota"
|
category: "quota",
|
||||||
|
limitSignal: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/rate.?limit|too.?many|429/i.test(errorText)) {
|
||||||
|
return {
|
||||||
|
fatal: false,
|
||||||
|
cooldownMs: MEGA_DEBRID_ACCOUNT_COOLDOWN_MS,
|
||||||
|
message: `Rate-Limit (${errorText})`,
|
||||||
|
category: "rate_limit"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2393,13 +2193,6 @@ class MegaDebridClient {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (/queue.?timeout/i.test(errorText)) {
|
|
||||||
// Lokaler Stau (die Queue dieses Accounts war zu lange belegt) — KEIN Signal fuer einen
|
|
||||||
// ungesunden Account. Kein Cooldown, sonst wuerde der warme Account fuer Eigen-Stau bestraft;
|
|
||||||
// der Link wird einfach erneut versucht und rotiert dann natuerlich weiter.
|
|
||||||
return { fatal: false, cooldownMs: 0, message: errorText, category: "temporary" };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isRetryableErrorText(errorText) || /timeout|network|fetch|socket/i.test(errorText)) {
|
if (isRetryableErrorText(errorText) || /timeout|network|fetch|socket/i.test(errorText)) {
|
||||||
return {
|
return {
|
||||||
fatal: false,
|
fatal: false,
|
||||||
@ -2937,23 +2730,6 @@ class DebridLinkClient {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
const failure = await this.classifyKeyFailure(error, apiKey, link, signal);
|
const failure = await this.classifyKeyFailure(error, apiKey, link, signal);
|
||||||
const elapsedMs = Date.now() - testStartedAt;
|
const elapsedMs = Date.now() - testStartedAt;
|
||||||
const abortText = compactErrorText(error).replace(/^Error:\s*/i, "");
|
|
||||||
if (/aborted/i.test(abortText) && !/timeout/i.test(abortText)) {
|
|
||||||
const ranLongEnough = elapsedMs >= getMegaDebridAbortMinRunMs();
|
|
||||||
if (ranLongEnough) {
|
|
||||||
setDebridLinkKeyCooldownState(apiKey.id, DEBRID_LINK_KEY_COOLDOWN_MS, `Abbruch/Timeout nach ${Math.ceil(elapsedMs / 1000)}s`, "temporary");
|
|
||||||
} else {
|
|
||||||
clearDebridLinkKeyCooldownState(apiKey.id);
|
|
||||||
}
|
|
||||||
failures.push(`Debrid-Link${keyLabel}: ${abortText}`);
|
|
||||||
logAccountRotation("WARN", providerName, rotationLabel, "TIMEOUT_COOLDOWN", {
|
|
||||||
elapsedMs,
|
|
||||||
reason: abortText,
|
|
||||||
cooldownSec: ranLongEnough ? Math.ceil(DEBRID_LINK_KEY_COOLDOWN_MS / 1000) : 0,
|
|
||||||
next: "naechster Key beim Retry"
|
|
||||||
});
|
|
||||||
throw new Error(`Debrid-Link${keyLabel}: ${abortText}`);
|
|
||||||
}
|
|
||||||
attemptedKeyFailures.push({
|
attemptedKeyFailures.push({
|
||||||
message: `Debrid-Link${keyLabel}: ${failure.message}`,
|
message: `Debrid-Link${keyLabel}: ${failure.message}`,
|
||||||
cooldownMs: failure.cooldownMs,
|
cooldownMs: failure.cooldownMs,
|
||||||
@ -3878,7 +3654,7 @@ export class DebridService {
|
|||||||
return `${PROVIDER_LABELS[effectiveProvider]} Tageslimit erreicht`;
|
return `${PROVIDER_LABELS[effectiveProvider]} Tageslimit erreicht`;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async unrestrictLink(link: string, signal?: AbortSignal, settingsSnapshot?: AppSettings, preferredLeadProvider?: DebridProvider | null): Promise<ProviderUnrestrictedLink> {
|
public async unrestrictLink(link: string, signal?: AbortSignal, settingsSnapshot?: AppSettings): Promise<ProviderUnrestrictedLink> {
|
||||||
const settings = settingsSnapshot ? cloneSettings(settingsSnapshot) : cloneSettings(this.settings);
|
const settings = settingsSnapshot ? cloneSettings(settingsSnapshot) : cloneSettings(this.settings);
|
||||||
|
|
||||||
const routing = settings.hosterRouting || {};
|
const routing = settings.hosterRouting || {};
|
||||||
@ -3930,9 +3706,6 @@ export class DebridService {
|
|||||||
if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) {
|
if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
if (!settings.autoProviderFallback) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -3949,16 +3722,12 @@ export class DebridService {
|
|||||||
if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) {
|
if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
if (!settings.autoProviderFallback) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseOrder: DebridProvider[] = (settings.providerOrder && settings.providerOrder.length > 0)
|
const order: DebridProvider[] = (settings.providerOrder && settings.providerOrder.length > 0)
|
||||||
? uniqueProviderOrder(settings.providerOrder)
|
? uniqueProviderOrder(settings.providerOrder)
|
||||||
: toProviderOrder(settings.providerPrimary, settings.providerSecondary, settings.providerTertiary);
|
: toProviderOrder(settings.providerPrimary, settings.providerSecondary, settings.providerTertiary);
|
||||||
const order = leadProviderChainWith(baseOrder, preferredLeadProvider);
|
|
||||||
|
|
||||||
const primary = order[0];
|
const primary = order[0];
|
||||||
if (!settings.autoProviderFallback) {
|
if (!settings.autoProviderFallback) {
|
||||||
@ -4011,12 +3780,9 @@ export class DebridService {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const providerStartedAt = Date.now();
|
|
||||||
try {
|
try {
|
||||||
logger.info(`Provider-Kette: versuche ${PROVIDER_LABELS[provider]}`);
|
logger.info(`Provider-Kette: versuche ${PROVIDER_LABELS[provider]}`);
|
||||||
traceConversionPhase({ phase: "chain-try", provider });
|
|
||||||
const result = await this.unrestrictViaProvider(settings, provider, link, signal);
|
const result = await this.unrestrictViaProvider(settings, provider, link, signal);
|
||||||
traceConversionPhase({ phase: "chain-ok", provider, workMs: Date.now() - providerStartedAt, outcome: "ok" });
|
|
||||||
let fileName = result.fileName;
|
let fileName = result.fileName;
|
||||||
if (isRapidgatorLink(link) && looksLikeOpaqueFilename(fileName || filenameFromUrl(link))) {
|
if (isRapidgatorLink(link) && looksLikeOpaqueFilename(fileName || filenameFromUrl(link))) {
|
||||||
const fromPage = await resolveRapidgatorFilename(link, signal);
|
const fromPage = await resolveRapidgatorFilename(link, signal);
|
||||||
@ -4033,17 +3799,9 @@ export class DebridService {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorText = compactErrorText(error);
|
const errorText = compactErrorText(error);
|
||||||
if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) {
|
if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) {
|
||||||
traceConversionPhase({ phase: "chain-aborted", provider, workMs: Date.now() - providerStartedAt, outcome: "aborted", detail: errorText.slice(0, 120) });
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
const nextProvider = order.slice(order.indexOf(provider) + 1).find((candidate) => this.isProviderSelectableFor(settings, candidate));
|
const nextProvider = order.slice(order.indexOf(provider) + 1).find((candidate) => this.isProviderSelectableFor(settings, candidate));
|
||||||
traceConversionPhase({
|
|
||||||
phase: "chain-failed",
|
|
||||||
provider,
|
|
||||||
workMs: Date.now() - providerStartedAt,
|
|
||||||
outcome: nextProvider ? "failover" : "exhausted",
|
|
||||||
detail: `${errorText.slice(0, 120)}${nextProvider ? ` → ${nextProvider}` : ""}`
|
|
||||||
});
|
|
||||||
if (nextProvider) {
|
if (nextProvider) {
|
||||||
logger.warn(`Provider-Kette: ${PROVIDER_LABELS[provider]} fehlgeschlagen (${errorText}), Fallback auf ${PROVIDER_LABELS[nextProvider]}`);
|
logger.warn(`Provider-Kette: ${PROVIDER_LABELS[provider]} fehlgeschlagen (${errorText}), Fallback auf ${PROVIDER_LABELS[nextProvider]}`);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -15,8 +15,6 @@ 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";
|
||||||
@ -45,14 +43,12 @@ const DEBUG_ENDPOINTS: DebugEndpointDescriptor[] = [
|
|||||||
{ method: "GET", path: "/logs/rename", queryExample: "lines=100&grep=keyword", description: "Reads the dedicated rename and MKV move log." },
|
{ method: "GET", path: "/logs/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." },
|
||||||
@ -69,16 +65,6 @@ let authToken = "";
|
|||||||
let bindHost = DEFAULT_HOST;
|
let bindHost = DEFAULT_HOST;
|
||||||
let bindPort = DEFAULT_PORT;
|
let bindPort = DEFAULT_PORT;
|
||||||
let runtimeBaseDir = "";
|
let runtimeBaseDir = "";
|
||||||
let allowlist: string[] = [];
|
|
||||||
|
|
||||||
export interface DebugServerRuntimeStatus {
|
|
||||||
running: boolean;
|
|
||||||
host: string;
|
|
||||||
port: number;
|
|
||||||
hasToken: boolean;
|
|
||||||
localOnly: boolean;
|
|
||||||
allowlistCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStoragePaths() {
|
function getStoragePaths() {
|
||||||
return createStoragePaths(runtimeBaseDir);
|
return createStoragePaths(runtimeBaseDir);
|
||||||
@ -154,94 +140,6 @@ function getHost(baseDir: string): string {
|
|||||||
return DEFAULT_HOST;
|
return DEFAULT_HOST;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAllowlistPath(baseDir: string = runtimeBaseDir): string {
|
|
||||||
return path.join(baseDir, "debug_allowlist.txt");
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadAllowlist(baseDir: string): string[] {
|
|
||||||
try {
|
|
||||||
return fs.readFileSync(getAllowlistPath(baseDir), "utf8")
|
|
||||||
.split(/\r?\n/)
|
|
||||||
.map((line) => line.trim())
|
|
||||||
.filter((line) => line.length > 0 && !line.startsWith("#"));
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeIp(ip: string): string {
|
|
||||||
return String(ip || "").trim().replace(/^::ffff:/i, "").toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
function isLoopbackIp(ip: string): boolean {
|
|
||||||
const x = normalizeIp(ip);
|
|
||||||
return x === "::1" || x === "localhost" || x.startsWith("127.");
|
|
||||||
}
|
|
||||||
|
|
||||||
function ipv4ToInt(ip: string): number | null {
|
|
||||||
const parts = ip.split(".");
|
|
||||||
if (parts.length !== 4) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
let result = 0;
|
|
||||||
for (const part of parts) {
|
|
||||||
const value = Number(part);
|
|
||||||
if (!Number.isInteger(value) || value < 0 || value > 255) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
result = (result * 256) + value;
|
|
||||||
}
|
|
||||||
return result >>> 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function matchIpRule(clientIp: string, rule: string): boolean {
|
|
||||||
const client = normalizeIp(clientIp);
|
|
||||||
const r = rule.trim().toLowerCase();
|
|
||||||
if (!r) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (r === "*" || r === "0.0.0.0/0") {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (r === client) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const slash = r.indexOf("/");
|
|
||||||
if (slash > 0) {
|
|
||||||
const baseInt = ipv4ToInt(r.slice(0, slash));
|
|
||||||
const clientInt = ipv4ToInt(client);
|
|
||||||
const bits = Number(r.slice(slash + 1));
|
|
||||||
if (baseInt === null || clientInt === null || !Number.isInteger(bits) || bits < 0 || bits > 32) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (bits === 0) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const mask = bits === 32 ? 0xffffffff : (~((1 << (32 - bits)) - 1)) >>> 0;
|
|
||||||
return (clientInt & mask) === (baseInt & mask);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function evaluateClientAllowed(clientIp: string, rules: string[]): boolean {
|
|
||||||
const client = normalizeIp(clientIp);
|
|
||||||
if (isLoopbackIp(client) || client === "") {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (rules.length === 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return rules.some((rule) => matchIpRule(client, rule));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getPeerIp(req: http.IncomingMessage): string {
|
|
||||||
return normalizeIp(req.socket?.remoteAddress || "");
|
|
||||||
}
|
|
||||||
|
|
||||||
function isClientAllowed(clientIp: string): boolean {
|
|
||||||
return evaluateClientAllowed(clientIp, allowlist);
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkAuth(req: http.IncomingMessage): boolean {
|
function checkAuth(req: http.IncomingMessage): boolean {
|
||||||
if (!authToken) {
|
if (!authToken) {
|
||||||
return false;
|
return false;
|
||||||
@ -360,7 +258,6 @@ function buildAiManifest(baseDir: string): Record<string, unknown> {
|
|||||||
"Call /meta first to confirm the server is reachable and to re-read the endpoint list.",
|
"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: {
|
||||||
@ -564,19 +461,6 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const peerIp = getPeerIp(req);
|
|
||||||
if (!isClientAllowed(peerIp)) {
|
|
||||||
if (traceConfig.enabled && traceConfig.logDebugRequests) {
|
|
||||||
logTraceEvent("WARN", "debug-http", "Durch Allowlist blockiert", {
|
|
||||||
peerIp,
|
|
||||||
forwardedFor: extractDebugClientIp(req),
|
|
||||||
url: sanitizeRequestUrlForTrace(req.url || "/")
|
|
||||||
});
|
|
||||||
}
|
|
||||||
jsonResponse(res, 403, { error: "Forbidden", reason: "Client-IP nicht in Allowlist", clientIp: peerIp });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!checkAuth(req)) {
|
if (!checkAuth(req)) {
|
||||||
if (traceConfig.enabled && traceConfig.logDebugRequests) {
|
if (traceConfig.enabled && traceConfig.logDebugRequests) {
|
||||||
logTraceEvent("WARN", "debug-http", "Unauthorized request", {
|
logTraceEvent("WARN", "debug-http", "Unauthorized request", {
|
||||||
@ -712,25 +596,6 @@ 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"));
|
||||||
@ -972,17 +837,12 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const fileName = getSupportBundleDefaultFileName();
|
const fileName = getSupportBundleDefaultFileName();
|
||||||
buildSupportBundle(manager, runtimeBaseDir)
|
const body = buildSupportBundle(manager, runtimeBaseDir);
|
||||||
.then((body) => {
|
logTraceEvent("INFO", "support", "Support-Bundle über Debug-Server heruntergeladen", {
|
||||||
logTraceEvent("INFO", "support", "Support-Bundle über Debug-Server heruntergeladen", {
|
fileName,
|
||||||
fileName,
|
sizeBytes: body.length
|
||||||
sizeBytes: body.length
|
});
|
||||||
});
|
binaryResponse(res, 200, body, "application/zip", fileName);
|
||||||
binaryResponse(res, 200, body, "application/zip", fileName);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
jsonResponse(res, 500, { error: String((error as { message?: string })?.message || error) });
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1016,7 +876,6 @@ 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()
|
||||||
@ -1048,10 +907,6 @@ 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)
|
||||||
@ -1067,126 +922,32 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function openServerSocket(): Promise<void> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
authToken = loadToken(runtimeBaseDir);
|
|
||||||
bindPort = getPort(runtimeBaseDir);
|
|
||||||
bindHost = getHost(runtimeBaseDir);
|
|
||||||
allowlist = loadAllowlist(runtimeBaseDir);
|
|
||||||
writeAiManifest(runtimeBaseDir);
|
|
||||||
if (!authToken) {
|
|
||||||
logger.info("Debug-Server: Kein Token in debug_token.txt, Server wird nicht gestartet");
|
|
||||||
resolve();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (bindHost === "0.0.0.0" && allowlist.length === 0) {
|
|
||||||
logger.warn("Debug-Server: Netzwerk-Bind ohne Allowlist - nur Loopback-Clients werden akzeptiert (fail-closed)");
|
|
||||||
}
|
|
||||||
const srv = http.createServer(handleRequest);
|
|
||||||
let settled = false;
|
|
||||||
const settle = (): void => {
|
|
||||||
if (!settled) {
|
|
||||||
settled = true;
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
srv.on("error", (err: NodeJS.ErrnoException) => {
|
|
||||||
if (err.code === "EADDRINUSE") {
|
|
||||||
logger.warn(`Debug-Server: Port ${bindPort} belegt (EADDRINUSE) - Server nicht gestartet`);
|
|
||||||
} else {
|
|
||||||
logger.warn(`Debug-Server Fehler: ${String(err)}`);
|
|
||||||
}
|
|
||||||
if (server === srv) {
|
|
||||||
server = null;
|
|
||||||
}
|
|
||||||
settle();
|
|
||||||
});
|
|
||||||
srv.listen(bindPort, bindHost, () => {
|
|
||||||
logger.info(`Debug-Server gestartet auf ${bindHost}:${bindPort} (Allowlist: ${allowlist.length})`);
|
|
||||||
settle();
|
|
||||||
});
|
|
||||||
server = srv;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function startDebugServer(mgr: DownloadManager, baseDir: string): void {
|
export function startDebugServer(mgr: DownloadManager, baseDir: string): void {
|
||||||
runtimeBaseDir = baseDir;
|
runtimeBaseDir = baseDir;
|
||||||
|
authToken = loadToken(baseDir);
|
||||||
|
bindPort = getPort(baseDir);
|
||||||
|
bindHost = getHost(baseDir);
|
||||||
|
writeAiManifest(baseDir);
|
||||||
|
if (!authToken) {
|
||||||
|
logger.info("Debug-Server: Kein Token in debug_token.txt, Server wird nicht gestartet");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
manager = mgr;
|
manager = mgr;
|
||||||
void openServerSocket();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function restartDebugServer(): Promise<DebugServerRuntimeStatus> {
|
server = http.createServer(handleRequest);
|
||||||
const old = server;
|
server.listen(bindPort, bindHost, () => {
|
||||||
if (old) {
|
logger.info(`Debug-Server gestartet auf ${bindHost}:${bindPort}`);
|
||||||
|
});
|
||||||
|
server.on("error", (err) => {
|
||||||
|
logger.warn(`Debug-Server Fehler: ${String(err)}`);
|
||||||
server = null;
|
server = null;
|
||||||
await new Promise<void>((resolve) => {
|
});
|
||||||
let settled = false;
|
|
||||||
const done = (): void => {
|
|
||||||
if (!settled) {
|
|
||||||
settled = true;
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
old.close(() => done());
|
|
||||||
try {
|
|
||||||
old.closeAllConnections?.();
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
setTimeout(done, 1500);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
await openServerSocket();
|
|
||||||
return getDebugServerRuntimeStatus();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getDebugServerRuntimeStatus(): DebugServerRuntimeStatus {
|
|
||||||
return {
|
|
||||||
running: Boolean(server && server.listening),
|
|
||||||
host: bindHost,
|
|
||||||
port: bindPort,
|
|
||||||
hasToken: Boolean(authToken),
|
|
||||||
localOnly: isLoopbackIp(bindHost) || bindHost === "127.0.0.1",
|
|
||||||
allowlistCount: allowlist.length
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getActiveDebugToken(): string {
|
|
||||||
return authToken || loadToken(runtimeBaseDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getDebugAllowlist(): string[] {
|
|
||||||
return [...allowlist];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function writeDebugServerConfig(opts: { host?: string; port?: number; allowlist?: string[] }): void {
|
|
||||||
if (opts.host !== undefined) {
|
|
||||||
fs.writeFileSync(path.join(runtimeBaseDir, "debug_host.txt"), `${opts.host}\n`, "utf8");
|
|
||||||
}
|
|
||||||
if (opts.port !== undefined) {
|
|
||||||
fs.writeFileSync(path.join(runtimeBaseDir, "debug_port.txt"), `${opts.port}\n`, "utf8");
|
|
||||||
}
|
|
||||||
if (opts.allowlist !== undefined) {
|
|
||||||
const body = opts.allowlist.length > 0 ? opts.allowlist.join("\n") + "\n" : "";
|
|
||||||
fs.writeFileSync(getAllowlistPath(), body, "utf8");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clearDebugToken(): void {
|
|
||||||
try {
|
|
||||||
fs.unlinkSync(getDebugTokenPath());
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
authToken = "";
|
|
||||||
writeAiManifest(runtimeBaseDir);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stopDebugServer(): void {
|
export function stopDebugServer(): void {
|
||||||
if (server) {
|
if (server) {
|
||||||
server.close();
|
server.close();
|
||||||
try {
|
|
||||||
server.closeAllConnections?.();
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
server = null;
|
server = null;
|
||||||
logger.info("Debug-Server gestoppt");
|
logger.info("Debug-Server gestoppt");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -72,22 +72,6 @@ export function planDownloadCompletion(args: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function reconcileFinalizedSize(
|
|
||||||
streamedBytes: number,
|
|
||||||
statSize: number,
|
|
||||||
preAllocated: boolean
|
|
||||||
): number {
|
|
||||||
const streamed = Math.max(0, Math.floor(Number(streamedBytes) || 0));
|
|
||||||
if (!Number.isFinite(statSize) || statSize < 0) {
|
|
||||||
return streamed;
|
|
||||||
}
|
|
||||||
const onDisk = Math.floor(statSize);
|
|
||||||
if (preAllocated && onDisk > streamed) {
|
|
||||||
return streamed;
|
|
||||||
}
|
|
||||||
return onDisk;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function validateDownloadedFileCompletion(args: {
|
export function validateDownloadedFileCompletion(args: {
|
||||||
actualBytes: number;
|
actualBytes: number;
|
||||||
plan: DownloadCompletionPlan;
|
plan: DownloadCompletionPlan;
|
||||||
|
|||||||
@ -22,7 +22,6 @@ import {
|
|||||||
StartConflictResolutionResult,
|
StartConflictResolutionResult,
|
||||||
UiSnapshot, DebridAccountStatus } from "../shared/types";
|
UiSnapshot, DebridAccountStatus } from "../shared/types";
|
||||||
import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
|
import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
|
||||||
import { isMegaDebridTransientResolveFailure, germanMegaDebridResolveReason } from "../shared/mega-debrid-errors";
|
|
||||||
import {
|
import {
|
||||||
addDebridLinkApiKeyDailyUsageBytes,
|
addDebridLinkApiKeyDailyUsageBytes,
|
||||||
addDebridLinkApiKeyTotalUsageBytes,
|
addDebridLinkApiKeyTotalUsageBytes,
|
||||||
@ -51,8 +50,8 @@ function releaseTlsSkip(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
import { cleanupCancelledPackageArtifactsAsync, removeDownloadLinkArtifacts, removeSampleArtifacts } from "./cleanup";
|
import { cleanupCancelledPackageArtifactsAsync, removeDownloadLinkArtifacts, removeSampleArtifacts } from "./cleanup";
|
||||||
import { planDownloadCompletion, reconcileFinalizedSize, validateDownloadedFileCompletion } from "./download-completion";
|
import { planDownloadCompletion, validateDownloadedFileCompletion } from "./download-completion";
|
||||||
import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, DebridService, MegaWebUnrestrictor, RealDebridWebUnrestrictor, checkRapidgatorOnline, fetchAllDebridHostInfo, getAvailableDebridLinkApiKeys, getAvailableMegaDebridAccounts, getMegaDebridAccountCooldownState, getMegaDebridInFlightCountForMode, pruneExpiredDebridLinkRuntimeState, pruneExpiredMegaDebridRuntimeState } from "./debrid";
|
import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, DebridService, MegaWebUnrestrictor, RealDebridWebUnrestrictor, checkRapidgatorOnline, fetchAllDebridHostInfo, getAvailableDebridLinkApiKeys, 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";
|
||||||
@ -60,7 +59,6 @@ import { processVideoFile, resolveVideoTooling, stripDualLangMarker, hasDualLang
|
|||||||
import { sendNotification } from "./notify";
|
import { sendNotification } from "./notify";
|
||||||
import { logger } from "./logger";
|
import { logger } from "./logger";
|
||||||
import { getRecentRotationEvents, runWithRotationItemSink, setRotationEventListener } from "./account-rotation-log";
|
import { getRecentRotationEvents, runWithRotationItemSink, setRotationEventListener } from "./account-rotation-log";
|
||||||
import { runWithConversionTrace, traceConversionPhase, traceConversionNote } from "./conversion-trace";
|
|
||||||
import type { RotationEvent } from "../shared/types";
|
import type { RotationEvent } from "../shared/types";
|
||||||
import { ensureItemLog, getItemLogPath as getPersistedItemLogPath, logItemEvent as writeItemLogEvent } from "./item-log";
|
import { ensureItemLog, getItemLogPath as getPersistedItemLogPath, logItemEvent as writeItemLogEvent } from "./item-log";
|
||||||
import { ensurePackageLog, getPackageLogPath as getPersistedPackageLogPath, logPackageEvent as writePackageLogEvent } from "./package-log";
|
import { ensurePackageLog, getPackageLogPath as getPersistedPackageLogPath, logPackageEvent as writePackageLogEvent } from "./package-log";
|
||||||
@ -131,17 +129,6 @@ const ARCHIVE_SETTLE_MAX_WAIT_MS = 5000;
|
|||||||
|
|
||||||
const MAX_SAME_DIRECT_URL_ATTEMPTS = 3;
|
const MAX_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;
|
||||||
@ -374,7 +361,6 @@ type DownloadManagerOptions = {
|
|||||||
bestDebridWebUnrestrict?: BestDebridWebUnrestrictor;
|
bestDebridWebUnrestrict?: BestDebridWebUnrestrictor;
|
||||||
invalidateMegaSession?: () => void;
|
invalidateMegaSession?: () => void;
|
||||||
onHistoryEntry?: HistoryEntryCallback;
|
onHistoryEntry?: HistoryEntryCallback;
|
||||||
protectEmptyClobber?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function generateHistoryId(): string {
|
function generateHistoryId(): string {
|
||||||
@ -657,47 +643,6 @@ function parseDebridLinkCooldownRetry(errorText: string): { delayMs: number; det
|
|||||||
return { delayMs, detail };
|
return { delayMs, detail };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseMegaDebridCooldownRetry(errorText: string): { delayMs: number; detail: string } | null {
|
|
||||||
const text = String(errorText || "");
|
|
||||||
const matches = [...text.matchAll(/mega_debrid_cooldown:(\d+)/gi)];
|
|
||||||
if (matches.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const delays = matches.map((m) => Number(m[1])).filter((n) => Number.isFinite(n) && n > 0);
|
|
||||||
if (delays.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const delayMs = Math.max(1000, Math.min(15 * 60 * 1000, Math.min(...delays)));
|
|
||||||
return { delayMs, detail: text.replace(/mega_debrid_cooldown:\d+:/i, "").trim() };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseMegaDebridSlowLinkRetry(errorText: string): { delayMs: number; detail: string } | null {
|
|
||||||
const text = String(errorText || "");
|
|
||||||
const match = text.match(/mega_debrid_slow_link:(\d+)/i);
|
|
||||||
if (!match) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const raw = Number(match[1]);
|
|
||||||
if (!Number.isFinite(raw) || raw <= 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const delayMs = Math.max(1000, Math.min(15 * 60 * 1000, raw));
|
|
||||||
return { delayMs, detail: text.replace(/mega_debrid_slow_link:\d+:/i, "").trim() };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseMegaDebridResetPark(errorText: string): { delayMs: number; detail: string } | null {
|
|
||||||
const match = String(errorText || "").match(/mega_debrid_reset_park:(\d+):(.*)$/is);
|
|
||||||
if (!match) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const raw = Number(match[1]);
|
|
||||||
if (!Number.isFinite(raw) || raw <= 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const delayMs = Math.max(1000, Math.min(26 * 60 * 60 * 1000, raw));
|
|
||||||
return { delayMs, detail: String(match[2] || "").trim() };
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseDebridLinkTerminalFailure(errorText: string): { kind: "invalid_all" | "no_active_key"; detail: string } | null {
|
function parseDebridLinkTerminalFailure(errorText: string): { kind: "invalid_all" | "no_active_key"; detail: string } | null {
|
||||||
const raw = String(errorText || "");
|
const raw = String(errorText || "");
|
||||||
const match = raw.match(/debrid_link_(invalid_all|no_active_key):(.*)$/i);
|
const match = raw.match(/debrid_link_(invalid_all|no_active_key):(.*)$/i);
|
||||||
@ -756,12 +701,6 @@ function isTemporaryUnrestrictError(errorText: string): boolean {
|
|||||||
|| text.includes("worker error");
|
|| text.includes("worker error");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function transientResolveRetryDelayMs(retryCount: number): number {
|
|
||||||
const steps = [3000, 6000, 10000];
|
|
||||||
const n = Math.max(1, Math.floor(Number(retryCount) || 1));
|
|
||||||
return steps[Math.min(n - 1, steps.length - 1)];
|
|
||||||
}
|
|
||||||
|
|
||||||
function isFinishedStatus(status: DownloadStatus): boolean {
|
function isFinishedStatus(status: DownloadStatus): boolean {
|
||||||
return status === "completed" || status === "failed" || status === "cancelled";
|
return status === "completed" || status === "failed" || status === "cancelled";
|
||||||
}
|
}
|
||||||
@ -1714,10 +1653,6 @@ 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;
|
||||||
@ -1840,8 +1775,6 @@ 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 }>();
|
||||||
@ -1863,10 +1796,6 @@ 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,
|
||||||
@ -3024,14 +2953,6 @@ 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;
|
||||||
}
|
}
|
||||||
@ -5583,7 +5504,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async start(options?: { excludePackageIds?: ReadonlySet<string> }): Promise<void> {
|
public async start(): Promise<void> {
|
||||||
if (this.session.running) {
|
if (this.session.running) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -5624,9 +5545,6 @@ 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);
|
||||||
});
|
});
|
||||||
@ -5691,15 +5609,9 @@ 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;
|
||||||
@ -5874,9 +5786,7 @@ 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}`);
|
||||||
@ -6179,29 +6089,10 @@ 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;
|
||||||
@ -6215,9 +6106,7 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -6481,38 +6370,19 @@ export class DownloadManager extends EventEmitter {
|
|||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
} else if (duplicateExists && canonicalExists && !primaryWins && primaryItem.status !== "completed") {
|
} else if (duplicateExists && canonicalExists && !primaryWins && primaryItem.status !== "completed") {
|
||||||
let canonicalSize = -1;
|
try {
|
||||||
let duplicateSize = -1;
|
fs.rmSync(canonicalPath, { force: true });
|
||||||
try { canonicalSize = fs.statSync(canonicalPath).size; } catch { }
|
fs.renameSync(duplicateTargetPath, canonicalPath);
|
||||||
try { duplicateSize = fs.statSync(duplicateTargetPath).size; } catch { }
|
canonicalExists = true;
|
||||||
if (canonicalSize >= 0 && duplicateSize >= 0 && canonicalSize >= duplicateSize) {
|
this.logVerifiedRenameSync("startup-dedup (Austausch)", duplicateTargetPath, canonicalPath);
|
||||||
try {
|
logger.info(`startupDuplicateMerge: ersetze verwaisten Originalpfad ${canonicalBaseName} durch ${path.basename(duplicateTargetPath)}`);
|
||||||
fs.rmSync(duplicateTargetPath, { force: true });
|
} catch (err) {
|
||||||
} catch {
|
logDesktopRename("ERROR", "startup-dedup (Austausch): Rename fehlgeschlagen", {
|
||||||
}
|
source: path.basename(duplicateTargetPath),
|
||||||
logger.info(`startupDuplicateMerge: kanonische Datei behalten (${canonicalSize}B >= Duplikat ${duplicateSize}B), Duplikat verworfen: ${canonicalBaseName}`);
|
target: canonicalBaseName,
|
||||||
} else {
|
error: compactErrorText(err)
|
||||||
const dedupBackupPath = `${canonicalPath}.dedupbak`;
|
});
|
||||||
try {
|
logger.warn(`startupDuplicateMerge: Austausch fehlgeschlagen ${canonicalPath}: ${compactErrorText(err)}`);
|
||||||
fs.renameSync(canonicalPath, dedupBackupPath);
|
|
||||||
try {
|
|
||||||
fs.renameSync(duplicateTargetPath, canonicalPath);
|
|
||||||
try { fs.rmSync(dedupBackupPath, { force: true }); } catch { }
|
|
||||||
canonicalExists = true;
|
|
||||||
this.logVerifiedRenameSync("startup-dedup (Austausch)", duplicateTargetPath, canonicalPath);
|
|
||||||
logger.info(`startupDuplicateMerge: ersetze verwaisten Originalpfad ${canonicalBaseName} durch ${path.basename(duplicateTargetPath)}`);
|
|
||||||
} catch (swapErr) {
|
|
||||||
try { fs.renameSync(dedupBackupPath, canonicalPath); } catch { }
|
|
||||||
throw swapErr;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
logDesktopRename("ERROR", "startup-dedup (Austausch): Rename fehlgeschlagen", {
|
|
||||||
source: path.basename(duplicateTargetPath),
|
|
||||||
target: canonicalBaseName,
|
|
||||||
error: compactErrorText(err)
|
|
||||||
});
|
|
||||||
logger.warn(`startupDuplicateMerge: Austausch fehlgeschlagen ${canonicalPath}: ${compactErrorText(err)}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -7886,7 +7756,6 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.settings.providerDailyUsageDay = currentDay;
|
this.settings.providerDailyUsageDay = currentDay;
|
||||||
this.settings.providerDailyUsageBytes = {};
|
this.settings.providerDailyUsageBytes = {};
|
||||||
this.settings.debridLinkApiKeyDailyUsageBytes = {};
|
this.settings.debridLinkApiKeyDailyUsageBytes = {};
|
||||||
this.settings.megaDebridAccountDailyUsageBytes = {};
|
|
||||||
this.statsCache = null;
|
this.statsCache = null;
|
||||||
this.statsCacheAt = 0;
|
this.statsCacheAt = 0;
|
||||||
if (persist) {
|
if (persist) {
|
||||||
@ -8103,30 +7972,9 @@ export class DownloadManager extends EventEmitter {
|
|||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
private describeSlotOccupancy(): string {
|
|
||||||
let converting = 0;
|
|
||||||
let downloading = 0;
|
|
||||||
for (const active of this.activeTasks.values()) {
|
|
||||||
const activeItem = this.session.items[active.itemId];
|
|
||||||
if (!activeItem) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (activeItem.status === "validating") {
|
|
||||||
converting += 1;
|
|
||||||
} else if (activeItem.status === "downloading") {
|
|
||||||
downloading += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return `conv${converting}/dl${downloading}/active${this.activeTasks.size}/max${this.settings.maxParallel}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getSerializedValidatingLimit(provider: DebridProvider | null): number {
|
private getSerializedValidatingLimit(provider: DebridProvider | null): number {
|
||||||
if (provider === "megadebrid-web" || provider === "megadebrid-api") {
|
if (provider === "megadebrid-web") {
|
||||||
const mode = provider === "megadebrid-web" ? "web" : "api";
|
return 1;
|
||||||
const usableAccounts = getAvailableMegaDebridAccounts(this.settings)
|
|
||||||
.filter((account) => !getMegaDebridAccountCooldownState(`${account.id}:${mode}`))
|
|
||||||
.length;
|
|
||||||
return Math.max(1, usableAccounts);
|
|
||||||
}
|
}
|
||||||
return Number.MAX_SAFE_INTEGER;
|
return Number.MAX_SAFE_INTEGER;
|
||||||
}
|
}
|
||||||
@ -8226,13 +8074,7 @@ 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) {
|
||||||
const validating = this.getProviderValidatingTaskCount(provider, item.id);
|
return this.getProviderValidatingTaskCount(provider, item.id) >= serializedValidatingLimit;
|
||||||
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;
|
||||||
@ -8603,7 +8445,6 @@ export class DownloadManager extends EventEmitter {
|
|||||||
const retryAfter = this.retryAfterByItem.get(itemId) || 0;
|
const retryAfter = this.retryAfterByItem.get(itemId) || 0;
|
||||||
if (retryAfter > now) continue;
|
if (retryAfter > now) continue;
|
||||||
if (item.status !== "queued" && item.status !== "reconnect_wait") continue;
|
if (item.status !== "queued" && item.status !== "reconnect_wait") continue;
|
||||||
if (this.activeTasks.has(itemId)) continue;
|
|
||||||
if (this.delayPacedStartForItem(item, now)) continue;
|
if (this.delayPacedStartForItem(item, now)) continue;
|
||||||
if (this.shouldDelayStartForItem(item)) continue;
|
if (this.shouldDelayStartForItem(item)) continue;
|
||||||
|
|
||||||
@ -8741,53 +8582,6 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.queueRetry(item, active, delayMs, `HTTP 416 erkannt, Retry ${active.genericErrorRetries}/${retryDisplayLimit}`);
|
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];
|
||||||
@ -8913,7 +8707,6 @@ export class DownloadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
const cooldownProvider = this.getProviderFailureKeyForItem(item);
|
const cooldownProvider = this.getProviderFailureKeyForItem(item);
|
||||||
const cooldownMs = this.getProviderCooldownRemaining(cooldownProvider);
|
const cooldownMs = this.getProviderCooldownRemaining(cooldownProvider);
|
||||||
let preferredLeadProvider: DebridProvider | null = null;
|
|
||||||
if (cooldownMs > 0) {
|
if (cooldownMs > 0) {
|
||||||
if (this.settings.autoProviderFallback) {
|
if (this.settings.autoProviderFallback) {
|
||||||
const fallback = this.findFallbackProviderNotInCooldown(item);
|
const fallback = this.findFallbackProviderNotInCooldown(item);
|
||||||
@ -8925,7 +8718,6 @@ export class DownloadManager extends EventEmitter {
|
|||||||
fallback
|
fallback
|
||||||
});
|
});
|
||||||
item.provider = null;
|
item.provider = null;
|
||||||
preferredLeadProvider = fallback;
|
|
||||||
} else {
|
} else {
|
||||||
this.logPackageForItem(item, "WARN", "Provider-Cooldown blockiert Unrestrict", {
|
this.logPackageForItem(item, "WARN", "Provider-Cooldown blockiert Unrestrict", {
|
||||||
provider: cooldownProvider,
|
provider: cooldownProvider,
|
||||||
@ -8958,30 +8750,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
const unrestrictedSignal = AbortSignal.any([active.abortController.signal, unrestrictTimeoutSignal]);
|
const unrestrictedSignal = AbortSignal.any([active.abortController.signal, unrestrictTimeoutSignal]);
|
||||||
let unrestricted;
|
let unrestricted;
|
||||||
try {
|
try {
|
||||||
unrestricted = await runWithConversionTrace(
|
unrestricted = await this.debridService.unrestrictLink(item.url, unrestrictedSignal);
|
||||||
{
|
|
||||||
itemId: item.id,
|
|
||||||
itemName: item.fileName || item.id,
|
|
||||||
link: item.url,
|
|
||||||
providerOrder: (this.settings.providerOrder || []).join(",") || String(this.getExpectedProviderForItem(item) || "?")
|
|
||||||
},
|
|
||||||
async () => {
|
|
||||||
traceConversionNote("slots", this.describeSlotOccupancy());
|
|
||||||
traceConversionNote("retry", Number(active.unrestrictRetries || 0));
|
|
||||||
try {
|
|
||||||
return await this.debridService.unrestrictLink(item.url, unrestrictedSignal, undefined, preferredLeadProvider);
|
|
||||||
} catch (innerError) {
|
|
||||||
if (!active.abortController.signal.aborted && unrestrictTimeoutSignal.aborted) {
|
|
||||||
traceConversionPhase({
|
|
||||||
phase: "caller-timeout",
|
|
||||||
outcome: "timeout",
|
|
||||||
detail: `Caller-Budget ${Math.ceil(getUnrestrictTimeoutMs() / 1000)}s erschoepft (siehe letzte Phase fuer in-flight Provider/Account)`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
throw innerError;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
} catch (unrestrictError) {
|
} catch (unrestrictError) {
|
||||||
if (!active.abortController.signal.aborted && unrestrictTimeoutSignal.aborted) {
|
if (!active.abortController.signal.aborted && unrestrictTimeoutSignal.aborted) {
|
||||||
this.recordProviderFailure(cooldownProvider);
|
this.recordProviderFailure(cooldownProvider);
|
||||||
@ -9105,6 +8874,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
if (item.attempts < maxAttempts) {
|
if (item.attempts < maxAttempts) {
|
||||||
item.status = "integrity_check";
|
item.status = "integrity_check";
|
||||||
item.progressPercent = 0;
|
item.progressPercent = 0;
|
||||||
|
this.dropItemContribution(item.id);
|
||||||
item.downloadedBytes = 0;
|
item.downloadedBytes = 0;
|
||||||
item.totalBytes = unrestricted.fileSize;
|
item.totalBytes = unrestricted.fileSize;
|
||||||
this.emitState();
|
this.emitState();
|
||||||
@ -9145,6 +8915,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
this.releaseTargetPath(item.id);
|
this.releaseTargetPath(item.id);
|
||||||
|
this.dropItemContribution(item.id);
|
||||||
item.downloadedBytes = 0;
|
item.downloadedBytes = 0;
|
||||||
item.progressPercent = 0;
|
item.progressPercent = 0;
|
||||||
item.totalBytes = (item.totalBytes || 0) > 0 ? item.totalBytes : null;
|
item.totalBytes = (item.totalBytes || 0) > 0 ? item.totalBytes : null;
|
||||||
@ -9193,15 +8964,6 @@ export class DownloadManager extends EventEmitter {
|
|||||||
return;
|
return;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (this.session.items[item.id] !== item) {
|
if (this.session.items[item.id] !== item) {
|
||||||
if (active.abortReason === "cancel") {
|
|
||||||
const orphanClaimedPath = this.claimedTargetPathByItem.get(item.id) || item.targetPath || "";
|
|
||||||
if (orphanClaimedPath) {
|
|
||||||
try {
|
|
||||||
fs.rmSync(orphanClaimedPath, { force: true });
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const reason = active.abortReason;
|
const reason = active.abortReason;
|
||||||
@ -9292,20 +9054,6 @@ export class DownloadManager extends EventEmitter {
|
|||||||
const totalFailures = (active.stallRetries || 0) + (active.unrestrictRetries || 0) + (active.genericErrorRetries || 0);
|
const totalFailures = (active.stallRetries || 0) + (active.unrestrictRetries || 0) + (active.genericErrorRetries || 0);
|
||||||
if (totalFailures >= 15) {
|
if (totalFailures >= 15) {
|
||||||
item.retries += 1;
|
item.retries += 1;
|
||||||
if (configuredRetryLimit > 0 && item.retries >= configuredRetryLimit) {
|
|
||||||
item.status = "failed";
|
|
||||||
item.lastError = stallErrorText || "Wiederholt fehlgeschlagen";
|
|
||||||
item.fullStatus = `Fehler: ${item.lastError}`;
|
|
||||||
item.speedBps = 0;
|
|
||||||
item.updatedAt = nowMs();
|
|
||||||
this.recordRunOutcome(item.id, "failed");
|
|
||||||
this.retryStateByItem.delete(item.id);
|
|
||||||
const shelveFailPkgStall = this.session.packages[item.packageId];
|
|
||||||
if (shelveFailPkgStall) this.refreshPackageStatus(shelveFailPkgStall);
|
|
||||||
this.persistSoon();
|
|
||||||
this.emitState();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
active.stallRetries = Math.floor((active.stallRetries || 0) / 2);
|
active.stallRetries = Math.floor((active.stallRetries || 0) / 2);
|
||||||
active.unrestrictRetries = Math.floor((active.unrestrictRetries || 0) / 2);
|
active.unrestrictRetries = Math.floor((active.unrestrictRetries || 0) / 2);
|
||||||
active.genericErrorRetries = Math.floor((active.genericErrorRetries || 0) / 2);
|
active.genericErrorRetries = Math.floor((active.genericErrorRetries || 0) / 2);
|
||||||
@ -9378,14 +9126,10 @@ export class DownloadManager extends EventEmitter {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (isHttp416Text(exhaustedReason)) {
|
if (isHttp416Text(exhaustedReason) && active.genericErrorRetries < maxHttp416Retries) {
|
||||||
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;
|
|
||||||
}
|
|
||||||
this.escalateHttp416OrFail(item, active, claimedTargetPath, exhaustedReason);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isResumeHardResetReason(exhaustedReason) && !active.resumeHardResetUsed) {
|
if (isResumeHardResetReason(exhaustedReason) && !active.resumeHardResetUsed) {
|
||||||
@ -9442,7 +9186,17 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.emitState();
|
this.emitState();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.escalateHttp416OrFail(item, active, claimedTargetPath, errorText);
|
item.status = "failed";
|
||||||
|
this.recordRunOutcome(item.id, "failed");
|
||||||
|
item.lastError = errorText;
|
||||||
|
item.fullStatus = `Fehler: ${item.lastError}`;
|
||||||
|
item.speedBps = 0;
|
||||||
|
item.updatedAt = nowMs();
|
||||||
|
const failPkg416 = this.session.packages[item.packageId];
|
||||||
|
if (failPkg416) this.refreshPackageStatus(failPkg416);
|
||||||
|
this.persistSoon();
|
||||||
|
this.emitState();
|
||||||
|
this.retryStateByItem.delete(item.id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (shouldFreshRetry) {
|
if (shouldFreshRetry) {
|
||||||
@ -9486,20 +9240,6 @@ export class DownloadManager extends EventEmitter {
|
|||||||
const totalNonStallFailures = (active.stallRetries || 0) + (active.unrestrictRetries || 0) + (active.genericErrorRetries || 0);
|
const totalNonStallFailures = (active.stallRetries || 0) + (active.unrestrictRetries || 0) + (active.genericErrorRetries || 0);
|
||||||
if (totalNonStallFailures >= 15) {
|
if (totalNonStallFailures >= 15) {
|
||||||
item.retries += 1;
|
item.retries += 1;
|
||||||
if (configuredRetryLimit > 0 && item.retries >= configuredRetryLimit) {
|
|
||||||
item.status = "failed";
|
|
||||||
item.lastError = errorText || "Wiederholt fehlgeschlagen";
|
|
||||||
item.fullStatus = `Fehler: ${item.lastError}`;
|
|
||||||
item.speedBps = 0;
|
|
||||||
item.updatedAt = nowMs();
|
|
||||||
this.recordRunOutcome(item.id, "failed");
|
|
||||||
this.retryStateByItem.delete(item.id);
|
|
||||||
const shelveFailPkgErr = this.session.packages[item.packageId];
|
|
||||||
if (shelveFailPkgErr) this.refreshPackageStatus(shelveFailPkgErr);
|
|
||||||
this.persistSoon();
|
|
||||||
this.emitState();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
active.stallRetries = Math.floor((active.stallRetries || 0) / 2);
|
active.stallRetries = Math.floor((active.stallRetries || 0) / 2);
|
||||||
active.unrestrictRetries = Math.floor((active.unrestrictRetries || 0) / 2);
|
active.unrestrictRetries = Math.floor((active.unrestrictRetries || 0) / 2);
|
||||||
active.genericErrorRetries = Math.floor((active.genericErrorRetries || 0) / 2);
|
active.genericErrorRetries = Math.floor((active.genericErrorRetries || 0) / 2);
|
||||||
@ -9557,88 +9297,6 @@ export class DownloadManager extends EventEmitter {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const megaRawError = error instanceof Error ? String(error.message || "") : String(error || "");
|
|
||||||
const megaSlowLinkRetry = parseMegaDebridSlowLinkRetry(megaRawError);
|
|
||||||
if (megaSlowLinkRetry && active.unrestrictRetries < maxUnrestrictRetries) {
|
|
||||||
active.unrestrictRetries += 1;
|
|
||||||
item.retries += 1;
|
|
||||||
item.provider = null;
|
|
||||||
logger.warn(`Mega-Debrid Link langsam (Timeout): item=${item.fileName || item.id}, retry=${active.unrestrictRetries}/${retryDisplayLimit}, delay=${megaSlowLinkRetry.delayMs}ms, link=${item.url.slice(0, 80)}`);
|
|
||||||
this.queueRetry(
|
|
||||||
item,
|
|
||||||
active,
|
|
||||||
megaSlowLinkRetry.delayMs,
|
|
||||||
`Mega-Debrid: Link zu langsam, Einzel-Retry in ${Math.ceil(megaSlowLinkRetry.delayMs / 1000)}s`
|
|
||||||
);
|
|
||||||
item.lastError = megaSlowLinkRetry.detail || errorText;
|
|
||||||
this.persistSoon();
|
|
||||||
this.emitState();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const megaCooldownRetry = parseMegaDebridCooldownRetry(megaRawError);
|
|
||||||
if (megaCooldownRetry && active.unrestrictRetries < maxUnrestrictRetries) {
|
|
||||||
active.unrestrictRetries += 1;
|
|
||||||
item.retries += 1;
|
|
||||||
logger.warn(`Mega-Debrid Account-Cooldown: item=${item.fileName || item.id}, retry=${active.unrestrictRetries}/${retryDisplayLimit}, delay=${megaCooldownRetry.delayMs}ms, link=${item.url.slice(0, 80)}`);
|
|
||||||
this.queueRetry(
|
|
||||||
item,
|
|
||||||
active,
|
|
||||||
megaCooldownRetry.delayMs,
|
|
||||||
`Mega-Debrid Cooldown, neuer Versuch in ${Math.ceil(megaCooldownRetry.delayMs / 1000)}s`
|
|
||||||
);
|
|
||||||
item.lastError = megaCooldownRetry.detail || errorText;
|
|
||||||
this.persistSoon();
|
|
||||||
this.emitState();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const megaResetPark = parseMegaDebridResetPark(megaRawError);
|
|
||||||
if (megaResetPark && active.unrestrictRetries < maxUnrestrictRetries) {
|
|
||||||
active.unrestrictRetries += 1;
|
|
||||||
item.retries += 1;
|
|
||||||
logger.warn(`Mega-Debrid bis Tagesreset gesperrt: item=${item.fileName || item.id}, retry=${active.unrestrictRetries}/${retryDisplayLimit}, delay=${megaResetPark.delayMs}ms, link=${item.url.slice(0, 80)}`);
|
|
||||||
this.queueRetry(
|
|
||||||
item,
|
|
||||||
active,
|
|
||||||
megaResetPark.delayMs,
|
|
||||||
`Mega-Debrid bis Tagesreset gesperrt, Pause ${Math.ceil(megaResetPark.delayMs / 1000)}s`
|
|
||||||
);
|
|
||||||
item.lastError = megaResetPark.detail || errorText;
|
|
||||||
this.persistSoon();
|
|
||||||
this.emitState();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isMegaDebridTransientResolveFailure(errorText) && active.unrestrictRetries < maxUnrestrictRetries) {
|
|
||||||
active.unrestrictRetries += 1;
|
|
||||||
item.retries += 1;
|
|
||||||
const transientDelayMs = transientResolveRetryDelayMs(active.unrestrictRetries);
|
|
||||||
const transientReason = germanMegaDebridResolveReason(errorText);
|
|
||||||
logger.warn(`Transienter Mega-Debrid-Resolve-Fehler: item=${item.fileName || item.id}, retry=${active.unrestrictRetries}/${retryDisplayLimit}, delay=${transientDelayMs}ms, error=${errorText}, link=${item.url.slice(0, 80)}`);
|
|
||||||
if (item.downloadedBytes > 0) {
|
|
||||||
const targetFile = this.claimedTargetPathByItem.get(item.id) || "";
|
|
||||||
if (targetFile) {
|
|
||||||
try { fs.rmSync(targetFile, { force: true }); } catch { }
|
|
||||||
}
|
|
||||||
this.releaseTargetPath(item.id);
|
|
||||||
item.downloadedBytes = 0;
|
|
||||||
item.progressPercent = 0;
|
|
||||||
item.totalBytes = null;
|
|
||||||
this.dropItemContribution(item.id);
|
|
||||||
}
|
|
||||||
this.queueRetry(
|
|
||||||
item,
|
|
||||||
active,
|
|
||||||
transientDelayMs,
|
|
||||||
`${transientReason} — neuer Versuch ${active.unrestrictRetries}/${retryDisplayLimit} (${Math.ceil(transientDelayMs / 1000)}s)`
|
|
||||||
);
|
|
||||||
item.lastError = transientReason;
|
|
||||||
this.persistSoon();
|
|
||||||
this.emitState();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isUnrestrictFailure(errorText) && active.unrestrictRetries < maxUnrestrictRetries) {
|
if (isUnrestrictFailure(errorText) && active.unrestrictRetries < maxUnrestrictRetries) {
|
||||||
const debridLinkCooldown = parseDebridLinkCooldownRetry(errorText);
|
const debridLinkCooldown = parseDebridLinkCooldownRetry(errorText);
|
||||||
if (debridLinkCooldown) {
|
if (debridLinkCooldown) {
|
||||||
@ -9773,7 +9431,6 @@ export class DownloadManager extends EventEmitter {
|
|||||||
await fs.promises.truncate(effectiveTargetPath, resumeStart);
|
await fs.promises.truncate(effectiveTargetPath, resumeStart);
|
||||||
existingBytes = resumeStart;
|
existingBytes = resumeStart;
|
||||||
item.downloadedBytes = Math.min(item.downloadedBytes, existingBytes);
|
item.downloadedBytes = Math.min(item.downloadedBytes, existingBytes);
|
||||||
resumeRewindBytesNextAttempt = 0;
|
|
||||||
logAttemptEvent("WARN", "Resume-Schutz aktiv: Teil-Datei vor Retry zurueckgespult", {
|
logAttemptEvent("WARN", "Resume-Schutz aktiv: Teil-Datei vor Retry zurueckgespult", {
|
||||||
attempt,
|
attempt,
|
||||||
previousBytes,
|
previousBytes,
|
||||||
@ -9787,6 +9444,8 @@ export class DownloadManager extends EventEmitter {
|
|||||||
rewindBytes,
|
rewindBytes,
|
||||||
error: compactErrorText(rewindError)
|
error: compactErrorText(rewindError)
|
||||||
});
|
});
|
||||||
|
} finally {
|
||||||
|
resumeRewindBytesNextAttempt = 0;
|
||||||
}
|
}
|
||||||
} else if (resumeRewindBytesNextAttempt > 0) {
|
} else if (resumeRewindBytesNextAttempt > 0) {
|
||||||
resumeRewindBytesNextAttempt = 0;
|
resumeRewindBytesNextAttempt = 0;
|
||||||
@ -10132,7 +9791,6 @@ export class DownloadManager extends EventEmitter {
|
|||||||
if (previouslyContributed > 0) {
|
if (previouslyContributed > 0) {
|
||||||
this.session.totalDownloadedBytes = Math.max(0, this.session.totalDownloadedBytes - previouslyContributed);
|
this.session.totalDownloadedBytes = Math.max(0, this.session.totalDownloadedBytes - previouslyContributed);
|
||||||
this.sessionDownloadedBytes = Math.max(0, this.sessionDownloadedBytes - previouslyContributed);
|
this.sessionDownloadedBytes = Math.max(0, this.sessionDownloadedBytes - previouslyContributed);
|
||||||
this.settings.totalDownloadedAllTime = Math.max(0, Number(this.settings.totalDownloadedAllTime || 0) - previouslyContributed);
|
|
||||||
this.itemContributedBytes.set(active.itemId, 0);
|
this.itemContributedBytes.set(active.itemId, 0);
|
||||||
}
|
}
|
||||||
if (existingBytes > 0) {
|
if (existingBytes > 0) {
|
||||||
@ -10585,15 +10243,13 @@ export class DownloadManager extends EventEmitter {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const finalizedStat = await fs.promises.stat(effectiveTargetPath);
|
const finalizedStat = await fs.promises.stat(effectiveTargetPath);
|
||||||
const reconciledSize = reconcileFinalizedSize(written, finalizedStat.size, preAllocated);
|
if (Number.isFinite(finalizedStat.size) && finalizedStat.size >= 0 && finalizedStat.size !== written) {
|
||||||
if (reconciledSize !== written) {
|
|
||||||
logAttemptEvent("WARN", "Dateigroesse nach Stream-Abschluss korrigiert", {
|
logAttemptEvent("WARN", "Dateigroesse nach Stream-Abschluss korrigiert", {
|
||||||
attempt,
|
attempt,
|
||||||
previousWritten: written,
|
previousWritten: written,
|
||||||
statSize: finalizedStat.size,
|
statSize: finalizedStat.size
|
||||||
reconciledSize
|
|
||||||
});
|
});
|
||||||
written = reconciledSize;
|
written = finalizedStat.size;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
@ -10623,6 +10279,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
await fs.promises.rm(effectiveTargetPath, { force: true });
|
await fs.promises.rm(effectiveTargetPath, { force: true });
|
||||||
} catch { }
|
} catch { }
|
||||||
this.releaseTargetPath(active.itemId);
|
this.releaseTargetPath(active.itemId);
|
||||||
|
this.dropItemContribution(active.itemId);
|
||||||
item.downloadedBytes = 0;
|
item.downloadedBytes = 0;
|
||||||
item.progressPercent = 0;
|
item.progressPercent = 0;
|
||||||
throw new Error(`Download zu klein (${written} B) – Hoster-Fehlerseite?${snippet ? ` Inhalt: "${snippet}"` : ""}`);
|
throw new Error(`Download zu klein (${written} B) – Hoster-Fehlerseite?${snippet ? ` Inhalt: "${snippet}"` : ""}`);
|
||||||
@ -10732,34 +10389,6 @@ export class DownloadManager extends EventEmitter {
|
|||||||
await sleep(retryDelayWithJitter(attempt, 250));
|
await sleep(retryDelayWithJitter(attempt, 250));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (
|
|
||||||
item.totalBytes != null && item.totalBytes > 0
|
|
||||||
&& written > existingBytes
|
|
||||||
&& shouldRewindResumeTail(normalizedLastError)
|
|
||||||
) {
|
|
||||||
const rewindTarget = Math.max(0, written - RESUME_REWIND_BYTES);
|
|
||||||
item.downloadedBytes = Math.min(item.downloadedBytes, rewindTarget);
|
|
||||||
try {
|
|
||||||
await fs.promises.truncate(effectiveTargetPath, rewindTarget);
|
|
||||||
logAttemptEvent("WARN", "Resume-Schutz: letzter Versuch vor Linkerneuerung zurueckgespult", {
|
|
||||||
attempt,
|
|
||||||
written,
|
|
||||||
rewindTarget
|
|
||||||
});
|
|
||||||
} catch (rewindError) {
|
|
||||||
try {
|
|
||||||
fs.rmSync(effectiveTargetPath, { force: true });
|
|
||||||
item.downloadedBytes = 0;
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
logAttemptEvent("WARN", "Resume-Schutz: finales Rueckspulen fehlgeschlagen, Teil-Datei verworfen", {
|
|
||||||
attempt,
|
|
||||||
written,
|
|
||||||
rewindTarget,
|
|
||||||
error: compactErrorText(rewindError)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (maxAttemptsBySetting > maxAttempts) {
|
if (maxAttemptsBySetting > maxAttempts) {
|
||||||
const exhaustedError = existingBytes > 0 && normalizedLastError.startsWith("download_underflow:")
|
const exhaustedError = existingBytes > 0 && normalizedLastError.startsWith("download_underflow:")
|
||||||
? `resume_download_underflow:${normalizedLastError.slice("download_underflow:".length)}`
|
? `resume_download_underflow:${normalizedLastError.slice("download_underflow:".length)}`
|
||||||
|
|||||||
@ -2907,16 +2907,9 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|||||||
let learnedPassword = cachedPackagePassword;
|
let learnedPassword = cachedPackagePassword;
|
||||||
let packageNeedsFlatMode = false;
|
let packageNeedsFlatMode = false;
|
||||||
const extractedArchives = new Set<string>();
|
const extractedArchives = new Set<string>();
|
||||||
const skippedNonArchives = new Set<string>();
|
|
||||||
const failedArchiveCategories = new Map<string, ExtractErrorCategory>();
|
const failedArchiveCategories = new Map<string, ExtractErrorCategory>();
|
||||||
for (const archivePath of candidates) {
|
for (const archivePath of candidates) {
|
||||||
if (resumeCompleted.has(archiveNameKey(path.basename(archivePath)))) {
|
if (resumeCompleted.has(archiveNameKey(path.basename(archivePath)))) {
|
||||||
const resumedName = path.basename(archivePath);
|
|
||||||
const resumedIsGenericSplit = /\.\d{3}$/i.test(resumedName) && !/\.(zip|7z)\.\d{3}$/i.test(resumedName);
|
|
||||||
if (resumedIsGenericSplit && !(await detectArchiveSignature(archivePath))) {
|
|
||||||
skippedNonArchives.add(pathSetKey(archivePath));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
extractedArchives.add(archivePath);
|
extractedArchives.add(archivePath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -3033,7 +3026,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|||||||
logger.info(`Generische Split-Datei übersprungen (keine Archiv-Signatur): ${archiveName}`);
|
logger.info(`Generische Split-Datei übersprungen (keine Archiv-Signatur): ${archiveName}`);
|
||||||
extracted += 1;
|
extracted += 1;
|
||||||
resumeCompleted.add(archiveResumeKey);
|
resumeCompleted.add(archiveResumeKey);
|
||||||
skippedNonArchives.add(pathSetKey(archivePath));
|
extractedArchives.add(archivePath);
|
||||||
await writeExtractResumeState(options.packageDir, resumeCompleted, options.packageId);
|
await writeExtractResumeState(options.packageDir, resumeCompleted, options.packageId);
|
||||||
clearInterval(pulseTimer);
|
clearInterval(pulseTimer);
|
||||||
archiveOutcome = "skipped";
|
archiveOutcome = "skipped";
|
||||||
@ -3377,8 +3370,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|||||||
logger.error(`Entpacken ohne neue Ausgabe erkannt: ${options.targetDir}. Cleanup wird NICHT ausgeführt.`);
|
logger.error(`Entpacken ohne neue Ausgabe erkannt: ${options.targetDir}. Cleanup wird NICHT ausgeführt.`);
|
||||||
} else {
|
} else {
|
||||||
if (!options.skipPostCleanup) {
|
if (!options.skipPostCleanup) {
|
||||||
const cleanupSources = (failed === 0 ? candidates : Array.from(extractedArchives.values()))
|
const cleanupSources = failed === 0 ? candidates : Array.from(extractedArchives.values());
|
||||||
.filter((archivePath) => !skippedNonArchives.has(pathSetKey(archivePath)));
|
|
||||||
const sourceAndTargetEqual = pathSetKey(path.resolve(options.packageDir)) === pathSetKey(path.resolve(options.targetDir));
|
const sourceAndTargetEqual = pathSetKey(path.resolve(options.packageDir)) === pathSetKey(path.resolve(options.targetDir));
|
||||||
const removedArchives = sourceAndTargetEqual
|
const removedArchives = sourceAndTargetEqual
|
||||||
? 0
|
? 0
|
||||||
|
|||||||
@ -93,11 +93,15 @@ export function readHashManifest(packageDir: string): Map<string, ParsedHashEntr
|
|||||||
if (!parsed) {
|
if (!parsed) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
const normalized: ParsedHashEntry = {
|
||||||
|
...parsed,
|
||||||
|
algorithm: hit[1]
|
||||||
|
};
|
||||||
const key = normalizeManifestKey(parsed.fileName);
|
const key = normalizeManifestKey(parsed.fileName);
|
||||||
if (map.has(key)) {
|
if (map.has(key)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
map.set(key, parsed);
|
map.set(key, normalized);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
manifestCache.set(cacheKey, { at: Date.now(), entries: new Map(map) });
|
manifestCache.set(cacheKey, { at: Date.now(), entries: new Map(map) });
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { app, BrowserWindow, clipboard, dialog, ipcMain, IpcMainInvokeEvent, Menu, shell, Tray } from "electron";
|
import { app, BrowserWindow, clipboard, dialog, ipcMain, IpcMainInvokeEvent, Menu, shell, Tray } from "electron";
|
||||||
import { AddLinksPayload, AppSettings, DebridProvider, EnableRemoteDiagnosticsInput, UpdateInstallProgress } from "../shared/types";
|
import { AddLinksPayload, AppSettings, DebridProvider, UpdateInstallProgress } from "../shared/types";
|
||||||
import { AppController } from "./app-controller";
|
import { AppController } from "./app-controller";
|
||||||
import { IPC_CHANNELS } from "../shared/ipc";
|
import { IPC_CHANNELS } from "../shared/ipc";
|
||||||
import { getLogFilePath, logger } from "./logger";
|
import { getLogFilePath, logger } from "./logger";
|
||||||
@ -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: `${new Date().toISOString().slice(0, 10).split("-").reverse().join("-")}-mdd-backup.mdd`,
|
defaultPath: `mdd-backup-${new Date().toISOString().slice(0, 10)}.mdd`,
|
||||||
filters: [{ name: "MDD Backup", extensions: ["mdd"] }]
|
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);
|
||||||
@ -592,7 +592,7 @@ function registerIpcHandlers(): void {
|
|||||||
if (result.canceled || !result.filePath) {
|
if (result.canceled || !result.filePath) {
|
||||||
return { saved: false };
|
return { saved: false };
|
||||||
}
|
}
|
||||||
const exported = await controller.exportSupportBundle();
|
const exported = controller.exportSupportBundle();
|
||||||
await fs.promises.writeFile(result.filePath, exported.buffer);
|
await fs.promises.writeFile(result.filePath, exported.buffer);
|
||||||
return { saved: true, filePath: result.filePath };
|
return { saved: true, filePath: result.filePath };
|
||||||
});
|
});
|
||||||
@ -671,33 +671,6 @@ function registerIpcHandlers(): void {
|
|||||||
return { path: rotated.path };
|
return { path: rotated.path };
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle(IPC_CHANNELS.GET_REMOTE_DIAGNOSTICS, async () => {
|
|
||||||
return controller.getRemoteDiagnostics();
|
|
||||||
});
|
|
||||||
|
|
||||||
ipcMain.handle(IPC_CHANNELS.ENABLE_REMOTE_DIAGNOSTICS, async (_event: IpcMainInvokeEvent, input: EnableRemoteDiagnosticsInput) => {
|
|
||||||
if (!input || (input.hostMode !== "local" && input.hostMode !== "network")) {
|
|
||||||
throw new Error("hostMode muss 'local' oder 'network' sein");
|
|
||||||
}
|
|
||||||
const allowlist = Array.isArray(input.allowlist) ? input.allowlist.map((entry) => String(entry)) : [];
|
|
||||||
return controller.enableRemoteDiagnostics({
|
|
||||||
hostMode: input.hostMode,
|
|
||||||
publicHost: String(input.publicHost || ""),
|
|
||||||
port: input.port ? Number(input.port) : undefined,
|
|
||||||
allowlist,
|
|
||||||
name: input.name ? String(input.name) : undefined,
|
|
||||||
rotateToken: Boolean(input.rotateToken)
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
ipcMain.handle(IPC_CHANNELS.DISABLE_REMOTE_DIAGNOSTICS, async () => {
|
|
||||||
return controller.disableRemoteDiagnostics();
|
|
||||||
});
|
|
||||||
|
|
||||||
ipcMain.handle(IPC_CHANNELS.ROTATE_REMOTE_DIAGNOSTICS_TOKEN, async () => {
|
|
||||||
return controller.rotateRemoteDiagnosticsToken();
|
|
||||||
});
|
|
||||||
|
|
||||||
ipcMain.handle(IPC_CHANNELS.OPEN_ITEM_LOG, async (_event: IpcMainInvokeEvent, itemId: string) => {
|
ipcMain.handle(IPC_CHANNELS.OPEN_ITEM_LOG, async (_event: IpcMainInvokeEvent, itemId: string) => {
|
||||||
validateString(itemId, "itemId");
|
validateString(itemId, "itemId");
|
||||||
const logPath = controller.getItemLogPath(itemId);
|
const logPath = controller.getItemLogPath(itemId);
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import { UnrestrictedLink } from "./realdebrid";
|
import { UnrestrictedLink } from "./realdebrid";
|
||||||
import { compactErrorText, filenameFromUrl, sleep } from "./utils";
|
import { compactErrorText, filenameFromUrl, sleep } from "./utils";
|
||||||
import { traceConversionPhase } from "./conversion-trace";
|
|
||||||
|
|
||||||
type MegaCredentials = {
|
type MegaCredentials = {
|
||||||
login: string;
|
login: string;
|
||||||
@ -177,12 +176,12 @@ async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise<void>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function raceWithAbort<T>(promise: Promise<T>, signal?: AbortSignal, abortErrorFactory: () => Error = abortError): Promise<T> {
|
async function raceWithAbort<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
|
||||||
if (!signal) {
|
if (!signal) {
|
||||||
return promise;
|
return promise;
|
||||||
}
|
}
|
||||||
if (signal.aborted) {
|
if (signal.aborted) {
|
||||||
throw abortErrorFactory();
|
throw abortError();
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Promise<T>((resolve, reject) => {
|
return new Promise<T>((resolve, reject) => {
|
||||||
@ -194,7 +193,7 @@ async function raceWithAbort<T>(promise: Promise<T>, signal?: AbortSignal, abort
|
|||||||
}
|
}
|
||||||
settled = true;
|
settled = true;
|
||||||
signal.removeEventListener("abort", onAbort);
|
signal.removeEventListener("abort", onAbort);
|
||||||
reject(abortErrorFactory());
|
reject(abortError());
|
||||||
};
|
};
|
||||||
|
|
||||||
signal.addEventListener("abort", onAbort, { once: true });
|
signal.addEventListener("abort", onAbort, { once: true });
|
||||||
@ -218,11 +217,7 @@ async function raceWithAbort<T>(promise: Promise<T>, signal?: AbortSignal, abort
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class MegaWebFallback {
|
export class MegaWebFallback {
|
||||||
// Pro Account eine eigene Warteschlange: Umwandlungen auf DEMSELBEN Account laufen
|
private queue: Promise<unknown> = Promise.resolve();
|
||||||
// seriell (kein Doppel-Login, kein Hammern eines einzelnen Accounts), verschiedene
|
|
||||||
// Accounts laufen parallel. So koennen die Links eines Pakets ueber mehrere Accounts
|
|
||||||
// gleichzeitig umgewandelt werden statt global eine nach der anderen.
|
|
||||||
private queues = new Map<string, Promise<unknown>>();
|
|
||||||
|
|
||||||
private getCredentials: () => MegaCredentials;
|
private getCredentials: () => MegaCredentials;
|
||||||
|
|
||||||
@ -238,15 +233,15 @@ export class MegaWebFallback {
|
|||||||
account?: { login: string; password: string }
|
account?: { login: string; password: string }
|
||||||
): Promise<UnrestrictedLink | null> {
|
): Promise<UnrestrictedLink | null> {
|
||||||
const overallSignal = withTimeoutSignal(signal, 180000);
|
const overallSignal = withTimeoutSignal(signal, 180000);
|
||||||
const creds = (account && account.login.trim() && account.password.trim())
|
|
||||||
? account
|
|
||||||
: this.getCredentials();
|
|
||||||
if (!creds.login.trim() || !creds.password.trim()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const key = creds.login.trim().toLowerCase();
|
|
||||||
return this.runExclusive(async () => {
|
return this.runExclusive(async () => {
|
||||||
throwIfAborted(overallSignal);
|
throwIfAborted(overallSignal);
|
||||||
|
const creds = (account && account.login.trim() && account.password.trim())
|
||||||
|
? account
|
||||||
|
: this.getCredentials();
|
||||||
|
if (!creds.login.trim() || !creds.password.trim()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const key = creds.login.trim().toLowerCase();
|
||||||
let cookie = await this.ensureSession(key, creds.login, creds.password, overallSignal);
|
let cookie = await this.ensureSession(key, creds.login, creds.password, overallSignal);
|
||||||
|
|
||||||
let generated = await this.generate(link, cookie, overallSignal);
|
let generated = await this.generate(link, cookie, overallSignal);
|
||||||
@ -264,7 +259,7 @@ export class MegaWebFallback {
|
|||||||
fileSize: null,
|
fileSize: null,
|
||||||
retriesUsed: 0
|
retriesUsed: 0
|
||||||
};
|
};
|
||||||
}, key, overallSignal);
|
}, overallSignal);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async ensureSession(key: string, login: string, password: string, signal?: AbortSignal): Promise<string> {
|
private async ensureSession(key: string, login: string, password: string, signal?: AbortSignal): Promise<string> {
|
||||||
@ -281,36 +276,20 @@ export class MegaWebFallback {
|
|||||||
this.sessions.clear();
|
this.sessions.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async runExclusive<T>(job: () => Promise<T>, key: string, signal?: AbortSignal): Promise<T> {
|
private async runExclusive<T>(job: () => Promise<T>, signal?: AbortSignal): Promise<T> {
|
||||||
const queuedAt = Date.now();
|
const queuedAt = Date.now();
|
||||||
const QUEUE_WAIT_TIMEOUT_MS = 90000;
|
const QUEUE_WAIT_TIMEOUT_MS = 90000;
|
||||||
let workStarted = false;
|
|
||||||
const guardedJob = async (): Promise<T> => {
|
const guardedJob = async (): Promise<T> => {
|
||||||
throwIfAborted(signal);
|
throwIfAborted(signal);
|
||||||
const waited = Date.now() - queuedAt;
|
const waited = Date.now() - queuedAt;
|
||||||
if (waited > QUEUE_WAIT_TIMEOUT_MS) {
|
if (waited > QUEUE_WAIT_TIMEOUT_MS) {
|
||||||
traceConversionPhase({ phase: "web-queue", provider: "megadebrid-web", queueWaitMs: waited, outcome: "queue-timeout", detail: `${Math.floor(waited / 1000)}s in Web-Queue gewartet` });
|
|
||||||
throw new Error(`Mega-Web Queue-Timeout (${Math.floor(waited / 1000)}s gewartet)`);
|
throw new Error(`Mega-Web Queue-Timeout (${Math.floor(waited / 1000)}s gewartet)`);
|
||||||
}
|
}
|
||||||
workStarted = true;
|
return job();
|
||||||
const workStartedAt = Date.now();
|
|
||||||
try {
|
|
||||||
const result = await job();
|
|
||||||
traceConversionPhase({ phase: "web-queue", provider: "megadebrid-web", queueWaitMs: waited, workMs: Date.now() - workStartedAt, outcome: "ok" });
|
|
||||||
return result;
|
|
||||||
} catch (jobError) {
|
|
||||||
traceConversionPhase({ phase: "web-queue", provider: "megadebrid-web", queueWaitMs: waited, workMs: Date.now() - workStartedAt, outcome: "error", detail: compactErrorText(jobError).slice(0, 100) });
|
|
||||||
throw jobError;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
const prev = this.queues.get(key) ?? Promise.resolve();
|
const run = this.queue.then(guardedJob, guardedJob);
|
||||||
const run = prev.then(guardedJob, guardedJob);
|
this.queue = run.then(() => undefined, () => undefined);
|
||||||
this.queues.set(key, run.then(() => undefined, () => undefined));
|
return raceWithAbort(run, signal);
|
||||||
return raceWithAbort(run, signal, () =>
|
|
||||||
workStarted
|
|
||||||
? abortError()
|
|
||||||
: new Error(`Mega-Web Queue-Timeout (abgebrochen nach ${Math.floor((Date.now() - queuedAt) / 1000)}s Wartezeit, Account war belegt)`)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async login(login: string, password: string, signal?: AbortSignal): Promise<string> {
|
private async login(login: string, password: string, signal?: AbortSignal): Promise<string> {
|
||||||
|
|||||||
@ -461,7 +461,6 @@ 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,
|
||||||
@ -628,24 +627,12 @@ function normalizeAudioStripSummary(raw: unknown): AudioStripSummary | undefined
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function migrateLegacyMegaEnableFlags(parsed: AppSettings): AppSettings {
|
|
||||||
if (parsed.megaDebridApiEnabled !== undefined || parsed.megaDebridWebEnabled !== undefined) {
|
|
||||||
return parsed;
|
|
||||||
}
|
|
||||||
const hasMegaCreds = Boolean(asText(parsed.megaLogin) && asText(parsed.megaPassword));
|
|
||||||
if (!hasMegaCreds) {
|
|
||||||
return parsed;
|
|
||||||
}
|
|
||||||
const preferApi = parsed.megaDebridPreferApi !== undefined ? Boolean(parsed.megaDebridPreferApi) : true;
|
|
||||||
return { ...parsed, megaDebridApiEnabled: preferApi, megaDebridWebEnabled: !preferApi };
|
|
||||||
}
|
|
||||||
|
|
||||||
function readSettingsFile(filePath: string): AppSettings | null {
|
function readSettingsFile(filePath: string): AppSettings | null {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as AppSettings;
|
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as AppSettings;
|
||||||
const merged = normalizeSettings({
|
const merged = normalizeSettings({
|
||||||
...defaultSettings(),
|
...defaultSettings(),
|
||||||
...migrateLegacyMegaEnableFlags(parsed)
|
...parsed
|
||||||
});
|
});
|
||||||
return sanitizeCredentialPersistence(merged);
|
return sanitizeCredentialPersistence(merged);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -890,56 +877,26 @@ 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 {
|
|
||||||
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 {
|
try {
|
||||||
const parsed = JSON.parse(raw) as unknown;
|
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) 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) {
|
||||||
logger.error(`Session-Datei beschädigt (JSON ungültig): ${filePath}: ${String(error)}`);
|
const code = (error as NodeJS.ErrnoException)?.code || "";
|
||||||
|
if (code === "EACCES" || code === "EPERM") {
|
||||||
|
logger.error(`Session-Datei nicht zugreifbar (${code}): ${filePath} - pruefe Datei-/Ordner-Berechtigungen fuer Benutzer ${process.env.USERNAME || process.env.USER || "?"}`);
|
||||||
|
} else {
|
||||||
|
logger.error(`Session-Datei nicht lesbar: ${filePath}: ${String(error)}`);
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function saveSettings(paths: StoragePaths, settings: AppSettings): void {
|
export function saveSettings(paths: StoragePaths, settings: AppSettings): void {
|
||||||
syncSettingsSaveGeneration += 1;
|
|
||||||
ensureBaseDir(paths.baseDir);
|
ensureBaseDir(paths.baseDir);
|
||||||
if (fs.existsSync(paths.configFile)) {
|
if (fs.existsSync(paths.configFile)) {
|
||||||
try {
|
try {
|
||||||
@ -960,26 +917,17 @@ export function saveSettings(paths: StoragePaths, settings: AppSettings): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let asyncSettingsSaveRunning = false;
|
let asyncSettingsSaveRunning = false;
|
||||||
let asyncSettingsSaveQueued: { paths: StoragePaths; payload: string; generation: number } | null = null;
|
let asyncSettingsSaveQueued: { paths: StoragePaths; settings: AppSettings } | null = null;
|
||||||
let syncSettingsSaveGeneration = 0;
|
|
||||||
|
|
||||||
async function writeSettingsPayload(paths: StoragePaths, payload: string, generation: number): Promise<void> {
|
async function writeSettingsPayload(paths: StoragePaths, payload: string): Promise<void> {
|
||||||
await fs.promises.mkdir(paths.baseDir, { recursive: true });
|
await fs.promises.mkdir(paths.baseDir, { recursive: true });
|
||||||
await fsp.copyFile(paths.configFile, `${paths.configFile}.bak`).catch(() => {});
|
await fsp.copyFile(paths.configFile, `${paths.configFile}.bak`).catch(() => {});
|
||||||
const tempPath = `${paths.configFile}.settings.tmp`;
|
const tempPath = `${paths.configFile}.settings.tmp`;
|
||||||
await fsp.writeFile(tempPath, payload, "utf8");
|
await fsp.writeFile(tempPath, payload, "utf8");
|
||||||
if (generation < syncSettingsSaveGeneration) {
|
|
||||||
await fsp.rm(tempPath, { force: true }).catch(() => {});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
await fsp.rename(tempPath, paths.configFile);
|
await fsp.rename(tempPath, paths.configFile);
|
||||||
} catch (renameError: unknown) {
|
} catch (renameError: unknown) {
|
||||||
if (renameError && typeof renameError === "object" && "code" in renameError && (renameError as NodeJS.ErrnoException).code === "EXDEV") {
|
if (renameError && typeof renameError === "object" && "code" in renameError && (renameError as NodeJS.ErrnoException).code === "EXDEV") {
|
||||||
if (generation < syncSettingsSaveGeneration) {
|
|
||||||
await fsp.rm(tempPath, { force: true }).catch(() => {});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await fsp.copyFile(tempPath, paths.configFile);
|
await fsp.copyFile(tempPath, paths.configFile);
|
||||||
await fsp.rm(tempPath, { force: true }).catch(() => {});
|
await fsp.rm(tempPath, { force: true }).catch(() => {});
|
||||||
} else {
|
} else {
|
||||||
@ -989,14 +937,16 @@ async function writeSettingsPayload(paths: StoragePaths, payload: string, genera
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveSettingsPayloadAsync(paths: StoragePaths, payload: string, generation: number): Promise<void> {
|
export async function saveSettingsAsync(paths: StoragePaths, settings: AppSettings): Promise<void> {
|
||||||
|
const persisted = sanitizeCredentialPersistence(normalizeSettings(settings));
|
||||||
|
const payload = JSON.stringify(persisted, safeJsonReplacer, 2);
|
||||||
if (asyncSettingsSaveRunning) {
|
if (asyncSettingsSaveRunning) {
|
||||||
asyncSettingsSaveQueued = { paths, payload, generation };
|
asyncSettingsSaveQueued = { paths, settings };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
asyncSettingsSaveRunning = true;
|
asyncSettingsSaveRunning = true;
|
||||||
try {
|
try {
|
||||||
await writeSettingsPayload(paths, payload, generation);
|
await writeSettingsPayload(paths, payload);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Async Settings-Save fehlgeschlagen: ${String(error)}`);
|
logger.error(`Async Settings-Save fehlgeschlagen: ${String(error)}`);
|
||||||
} finally {
|
} finally {
|
||||||
@ -1004,18 +954,11 @@ async function saveSettingsPayloadAsync(paths: StoragePaths, payload: string, ge
|
|||||||
if (asyncSettingsSaveQueued) {
|
if (asyncSettingsSaveQueued) {
|
||||||
const queued = asyncSettingsSaveQueued;
|
const queued = asyncSettingsSaveQueued;
|
||||||
asyncSettingsSaveQueued = null;
|
asyncSettingsSaveQueued = null;
|
||||||
void saveSettingsPayloadAsync(queued.paths, queued.payload, queued.generation);
|
void saveSettingsAsync(queued.paths, queued.settings);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveSettingsAsync(paths: StoragePaths, settings: AppSettings): Promise<void> {
|
|
||||||
const generation = syncSettingsSaveGeneration;
|
|
||||||
const persisted = sanitizeCredentialPersistence(normalizeSettings(settings));
|
|
||||||
const payload = JSON.stringify(persisted, safeJsonReplacer, 2);
|
|
||||||
await saveSettingsPayloadAsync(paths, payload, generation);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function emptySession(): SessionState {
|
export function emptySession(): SessionState {
|
||||||
return {
|
return {
|
||||||
version: 2,
|
version: 2,
|
||||||
@ -1033,31 +976,17 @@ export function emptySession(): SessionState {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionLoadStatus =
|
export function loadSession(paths: StoragePaths): SessionState {
|
||||||
| "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) {
|
||||||
if (!backupExists && !anyTempExists) {
|
const hasRecoverable = fs.existsSync(backupFile)
|
||||||
|
|| fs.existsSync(sessionTempPath(paths.sessionFile, "sync"))
|
||||||
|
|| fs.existsSync(sessionTempPath(paths.sessionFile, "async"));
|
||||||
|
if (!hasRecoverable) {
|
||||||
logger.info("Keine Session-Datei vorhanden, starte mit leerer Session");
|
logger.info("Keine Session-Datei vorhanden, starte mit leerer Session");
|
||||||
return { session: emptySession(), status: "empty-fresh" };
|
return emptySession();
|
||||||
}
|
}
|
||||||
logger.warn("Session-Primaerdatei fehlt, aber Backup/Temp vorhanden — Wiederherstellung wird versucht");
|
logger.warn("Session-Primaerdatei fehlt, aber Backup/Temp vorhanden — Wiederherstellung wird versucht");
|
||||||
}
|
}
|
||||||
@ -1066,7 +995,7 @@ export function loadSessionWithStatus(paths: StoragePaths): SessionLoadResult {
|
|||||||
|
|
||||||
if (primary) {
|
if (primary) {
|
||||||
const primaryPkgCount = Object.keys(primary.packages).length;
|
const primaryPkgCount = Object.keys(primary.packages).length;
|
||||||
if (primaryPkgCount === 0 && backupExists) {
|
if (primaryPkgCount === 0 && fs.existsSync(backupFile)) {
|
||||||
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;
|
||||||
@ -1074,27 +1003,29 @@ export function loadSessionWithStatus(paths: StoragePaths): SessionLoadResult {
|
|||||||
logger.warn(`Session-Datei ist leer (0 Pakete), aber Backup hat ${backupPkgCount} Pakete — verwende Backup`);
|
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);
|
||||||
fs.writeFileSync(syncTempFile, payload, "utf8");
|
const tempPath = sessionTempPath(paths.sessionFile, "sync");
|
||||||
syncRenameWithExdevFallback(syncTempFile, paths.sessionFile);
|
fs.writeFileSync(tempPath, payload, "utf8");
|
||||||
|
syncRenameWithExdevFallback(tempPath, paths.sessionFile);
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
return { session: backup, status: "recovered-backup" };
|
return backup;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { session: primary, status: "ok" };
|
return primary;
|
||||||
}
|
}
|
||||||
|
|
||||||
const backup = backupExists ? readSessionFile(backupFile) : null;
|
const backup = fs.existsSync(backupFile) ? readSessionFile(backupFile) : null;
|
||||||
if (backup) {
|
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);
|
||||||
fs.writeFileSync(syncTempFile, payload, "utf8");
|
const tempPath = sessionTempPath(paths.sessionFile, "sync");
|
||||||
syncRenameWithExdevFallback(syncTempFile, paths.sessionFile);
|
fs.writeFileSync(tempPath, payload, "utf8");
|
||||||
|
syncRenameWithExdevFallback(tempPath, paths.sessionFile);
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
return { session: backup, status: "recovered-backup" };
|
return backup;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const kind of ["sync", "async"] as const) {
|
for (const kind of ["sync", "async"] as const) {
|
||||||
@ -1108,21 +1039,13 @@ export function loadSessionWithStatus(paths: StoragePaths): SessionLoadResult {
|
|||||||
fs.writeFileSync(paths.sessionFile, payload, "utf8");
|
fs.writeFileSync(paths.sessionFile, payload, "utf8");
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
return { session: tmpSession, status: "recovered-temp" };
|
return tmpSession;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (primaryExists || backupExists || anyTempExists) {
|
logger.error("Session konnte nicht geladen werden (Primary, Backup und Temp-Dateien fehlgeschlagen)");
|
||||||
logger.error("Session konnte nicht geladen werden (Primary, Backup und Temp-Dateien fehlgeschlagen) — Schutz gegen leeres Ueberschreiben aktiv");
|
return emptySession();
|
||||||
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 {
|
||||||
@ -1137,13 +1060,7 @@ 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 {
|
||||||
const fd = fs.openSync(tempPath, "w");
|
fs.writeFileSync(tempPath, payload, "utf8");
|
||||||
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 { }
|
||||||
@ -1159,13 +1076,7 @@ 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");
|
||||||
const handle = await fsp.open(tempPath, "w");
|
await fsp.writeFile(tempPath, payload, "utf8");
|
||||||
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;
|
||||||
@ -1211,7 +1122,6 @@ export function cancelPendingAsyncSaves(): void {
|
|||||||
asyncSaveQueued = null;
|
asyncSaveQueued = null;
|
||||||
asyncSettingsSaveQueued = null;
|
asyncSettingsSaveQueued = null;
|
||||||
syncSaveGeneration += 1;
|
syncSaveGeneration += 1;
|
||||||
syncSettingsSaveGeneration += 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveSessionAsync(paths: StoragePaths, session: SessionState): Promise<void> {
|
export async function saveSessionAsync(paths: StoragePaths, session: SessionState): Promise<void> {
|
||||||
|
|||||||
@ -1,9 +1,8 @@
|
|||||||
import { promises as fsp } from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import AdmZip from "adm-zip";
|
import AdmZip from "adm-zip";
|
||||||
import { APP_VERSION } from "./constants";
|
import { APP_VERSION } from "./constants";
|
||||||
import { getAccountRotationLogPath } from "./account-rotation-log";
|
import { getAccountRotationLogPath } from "./account-rotation-log";
|
||||||
import { getConversionLogPath } from "./conversion-trace";
|
|
||||||
import { getAuditLogPath } from "./audit-log";
|
import { getAuditLogPath } from "./audit-log";
|
||||||
import { getDebugSetupCheck } from "./debug-setup";
|
import { getDebugSetupCheck } from "./debug-setup";
|
||||||
import { getLogFilePath } from "./logger";
|
import { getLogFilePath } from "./logger";
|
||||||
@ -20,9 +19,9 @@ import type { DownloadManager } from "./download-manager";
|
|||||||
|
|
||||||
const AI_MANIFEST_FILE = "debug_ai_manifest.json";
|
const AI_MANIFEST_FILE = "debug_ai_manifest.json";
|
||||||
|
|
||||||
async function safeReadJson(filePath: string): Promise<unknown> {
|
function safeReadJson(filePath: string): unknown {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(await fsp.readFile(filePath, "utf8")) as unknown;
|
return JSON.parse(fs.readFileSync(filePath, "utf8")) as unknown;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -32,50 +31,42 @@ function addJson(zip: AdmZip, zipPath: string, value: unknown): void {
|
|||||||
zip.addFile(zipPath, Buffer.from(`${JSON.stringify(value, null, 2)}\n`, "utf8"));
|
zip.addFile(zipPath, Buffer.from(`${JSON.stringify(value, null, 2)}\n`, "utf8"));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addFileIfExists(zip: AdmZip, sourcePath: string | null, zipPath: string): Promise<void> {
|
function addFileIfExists(zip: AdmZip, sourcePath: string | null, zipPath: string): void {
|
||||||
if (!sourcePath) {
|
if (!sourcePath || !fs.existsSync(sourcePath)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
zip.addLocalFile(sourcePath, path.posix.dirname(zipPath), path.posix.basename(zipPath));
|
||||||
const buffer = await fsp.readFile(sourcePath);
|
|
||||||
zip.addFile(zipPath, buffer);
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addDirectoryIfExists(zip: AdmZip, dirPath: string, zipRoot: string): Promise<void> {
|
function addDirectoryIfExists(zip: AdmZip, dirPath: string, zipRoot: string): void {
|
||||||
let entries;
|
if (!fs.existsSync(dirPath)) {
|
||||||
try {
|
|
||||||
entries = await fsp.readdir(dirPath, { withFileTypes: true });
|
|
||||||
} catch {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const fullPath = path.join(dirPath, entry.name);
|
const fullPath = path.join(dirPath, entry.name);
|
||||||
const zipPath = path.posix.join(zipRoot, entry.name);
|
const zipPath = path.posix.join(zipRoot, entry.name);
|
||||||
if (entry.isDirectory()) {
|
if (entry.isDirectory()) {
|
||||||
await addDirectoryIfExists(zip, fullPath, zipPath);
|
addDirectoryIfExists(zip, fullPath, zipPath);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
await addFileIfExists(zip, fullPath, zipPath);
|
zip.addLocalFile(fullPath, path.posix.dirname(zipPath), path.posix.basename(zipPath));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addRecentDirectoryFiles(zip: AdmZip, dirPath: string, zipRoot: string, maxAgeMs: number): Promise<number> {
|
function addRecentDirectoryFiles(zip: AdmZip, dirPath: string, zipRoot: string, maxAgeMs: number): number {
|
||||||
let entries;
|
if (!fs.existsSync(dirPath)) {
|
||||||
try {
|
|
||||||
entries = await fsp.readdir(dirPath, { withFileTypes: true });
|
|
||||||
} catch {
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
const cutoff = Date.now() - maxAgeMs;
|
const cutoff = Date.now() - maxAgeMs;
|
||||||
let added = 0;
|
let added = 0;
|
||||||
|
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (!entry.isFile()) continue;
|
if (!entry.isFile()) continue;
|
||||||
const fullPath = path.join(dirPath, entry.name);
|
const fullPath = path.join(dirPath, entry.name);
|
||||||
try {
|
try {
|
||||||
if ((await fsp.stat(fullPath)).mtimeMs >= cutoff) {
|
if (fs.statSync(fullPath).mtimeMs >= cutoff) {
|
||||||
await addFileIfExists(zip, fullPath, path.posix.join(zipRoot, entry.name));
|
zip.addLocalFile(fullPath, zipRoot, entry.name);
|
||||||
added += 1;
|
added += 1;
|
||||||
}
|
}
|
||||||
} catch { }
|
} catch { }
|
||||||
@ -135,7 +126,7 @@ function resolveHostDiagnostics(mode: HostDiagnosticsMode): unknown {
|
|||||||
return getWindowsHostDiagnostics();
|
return getWindowsHostDiagnostics();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function buildSupportBundle(manager: DownloadManager, baseDir: string, options: BuildSupportBundleOptions = {}): Promise<Buffer> {
|
export function buildSupportBundle(manager: DownloadManager, baseDir: string, options: BuildSupportBundleOptions = {}): Buffer {
|
||||||
const zip = new AdmZip();
|
const zip = new AdmZip();
|
||||||
const hostDiagnosticsMode = options.hostDiagnosticsMode || "full";
|
const hostDiagnosticsMode = options.hostDiagnosticsMode || "full";
|
||||||
const storagePaths = createStoragePaths(baseDir);
|
const storagePaths = createStoragePaths(baseDir);
|
||||||
@ -183,39 +174,37 @@ export async function buildSupportBundle(manager: DownloadManager, baseDir: stri
|
|||||||
const recentErrors = getRecentErrors();
|
const recentErrors = getRecentErrors();
|
||||||
addJson(zip, "overview/recent-errors.json", { count: recentErrors.length, entries: recentErrors });
|
addJson(zip, "overview/recent-errors.json", { count: recentErrors.length, entries: recentErrors });
|
||||||
|
|
||||||
await addFileIfExists(zip, path.join(baseDir, AI_MANIFEST_FILE), `runtime/${AI_MANIFEST_FILE}`);
|
addFileIfExists(zip, path.join(baseDir, AI_MANIFEST_FILE), `runtime/${AI_MANIFEST_FILE}`);
|
||||||
await addFileIfExists(zip, path.join(baseDir, "debug_host.txt"), "runtime/debug_host.txt");
|
addFileIfExists(zip, path.join(baseDir, "debug_host.txt"), "runtime/debug_host.txt");
|
||||||
await addFileIfExists(zip, path.join(baseDir, "debug_port.txt"), "runtime/debug_port.txt");
|
addFileIfExists(zip, path.join(baseDir, "debug_port.txt"), "runtime/debug_port.txt");
|
||||||
await addFileIfExists(zip, getTraceConfigPath(), "runtime/trace_config.json");
|
addFileIfExists(zip, getTraceConfigPath(), "runtime/trace_config.json");
|
||||||
|
|
||||||
await addFileIfExists(zip, getLogFilePath(), "logs/rd_downloader.log");
|
addFileIfExists(zip, getLogFilePath(), "logs/rd_downloader.log");
|
||||||
await addFileIfExists(zip, `${getLogFilePath()}.old`, "logs/rd_downloader.log.old");
|
addFileIfExists(zip, `${getLogFilePath()}.old`, "logs/rd_downloader.log.old");
|
||||||
await addFileIfExists(zip, getAuditLogPath(), "logs/audit.log");
|
addFileIfExists(zip, getAuditLogPath(), "logs/audit.log");
|
||||||
await addFileIfExists(zip, getAuditLogPath() ? `${getAuditLogPath()}.old` : null, "logs/audit.log.old");
|
addFileIfExists(zip, getAuditLogPath() ? `${getAuditLogPath()}.old` : null, "logs/audit.log.old");
|
||||||
await addFileIfExists(zip, getRenameLogPath(), "logs/rename.log");
|
addFileIfExists(zip, getRenameLogPath(), "logs/rename.log");
|
||||||
await addFileIfExists(zip, getRenameLogPath() ? `${getRenameLogPath()}.old` : null, "logs/rename.log.old");
|
addFileIfExists(zip, getRenameLogPath() ? `${getRenameLogPath()}.old` : null, "logs/rename.log.old");
|
||||||
await addFileIfExists(zip, getDesktopRenameLogPath(), "logs/rename-session-desktop.txt");
|
addFileIfExists(zip, getDesktopRenameLogPath(), "logs/rename-session-desktop.txt");
|
||||||
await addFileIfExists(zip, getSessionLogPath(), "logs/session.log");
|
addFileIfExists(zip, getSessionLogPath(), "logs/session.log");
|
||||||
await addFileIfExists(zip, getTraceLogPath(), "logs/trace.log");
|
addFileIfExists(zip, getTraceLogPath(), "logs/trace.log");
|
||||||
await addFileIfExists(zip, getTraceLogPath() ? `${getTraceLogPath()}.old` : null, "logs/trace.log.old");
|
addFileIfExists(zip, getTraceLogPath() ? `${getTraceLogPath()}.old` : null, "logs/trace.log.old");
|
||||||
await addFileIfExists(zip, getAccountRotationLogPath(), "logs/account-rotation.log");
|
addFileIfExists(zip, getAccountRotationLogPath(), "logs/account-rotation.log");
|
||||||
await addFileIfExists(zip, getAccountRotationLogPath() ? `${getAccountRotationLogPath()}.old` : null, "logs/account-rotation.log.old");
|
addFileIfExists(zip, getAccountRotationLogPath() ? `${getAccountRotationLogPath()}.old` : null, "logs/account-rotation.log.old");
|
||||||
await addFileIfExists(zip, getConversionLogPath(), "logs/conversion.log");
|
|
||||||
await addFileIfExists(zip, getConversionLogPath() ? `${getConversionLogPath()}.old` : null, "logs/conversion.log.old");
|
|
||||||
|
|
||||||
const SUPPORT_BUNDLE_LOG_WINDOW_MS = 8 * 60 * 60 * 1000;
|
const SUPPORT_BUNDLE_LOG_WINDOW_MS = 8 * 60 * 60 * 1000;
|
||||||
await addDirectoryIfExists(zip, path.join(baseDir, "session-logs"), "logs/session-logs");
|
addDirectoryIfExists(zip, path.join(baseDir, "session-logs"), "logs/session-logs");
|
||||||
await addRecentDirectoryFiles(zip, path.join(baseDir, "package-logs"), "logs/package-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS);
|
addRecentDirectoryFiles(zip, path.join(baseDir, "package-logs"), "logs/package-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS);
|
||||||
await addRecentDirectoryFiles(zip, path.join(baseDir, "item-logs"), "logs/item-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS);
|
addRecentDirectoryFiles(zip, path.join(baseDir, "item-logs"), "logs/item-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS);
|
||||||
|
|
||||||
for (const packageId of packageIds) {
|
for (const packageId of packageIds) {
|
||||||
await addFileIfExists(zip, manager.getPackageLogPath(packageId) || getPackageLogPath(packageId), `logs/live/package-${packageId}.txt`);
|
addFileIfExists(zip, manager.getPackageLogPath(packageId) || getPackageLogPath(packageId), `logs/live/package-${packageId}.txt`);
|
||||||
}
|
}
|
||||||
for (const itemId of itemIds) {
|
for (const itemId of itemIds) {
|
||||||
await addFileIfExists(zip, manager.getItemLogPath(itemId), `logs/live/item-${itemId}.txt`);
|
addFileIfExists(zip, manager.getItemLogPath(itemId), `logs/live/item-${itemId}.txt`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const aiManifest = await safeReadJson(path.join(baseDir, AI_MANIFEST_FILE));
|
const aiManifest = safeReadJson(path.join(baseDir, AI_MANIFEST_FILE));
|
||||||
if (aiManifest) {
|
if (aiManifest) {
|
||||||
addJson(zip, "overview/ai-manifest.json", aiManifest);
|
addJson(zip, "overview/ai-manifest.json", aiManifest);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,34 +0,0 @@
|
|||||||
export interface InstallResumeManager {
|
|
||||||
isSessionRunning(): boolean;
|
|
||||||
stop(options: { parkForRestart: boolean }): void;
|
|
||||||
persistNowSync(): void;
|
|
||||||
start(): Promise<void> | void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function runInstallWithResume<T extends { started: boolean }>(
|
|
||||||
manager: InstallResumeManager,
|
|
||||||
doInstall: () => Promise<T>
|
|
||||||
): Promise<T> {
|
|
||||||
const wasRunning = manager.isSessionRunning();
|
|
||||||
if (wasRunning) {
|
|
||||||
manager.stop({ parkForRestart: true });
|
|
||||||
}
|
|
||||||
manager.persistNowSync();
|
|
||||||
|
|
||||||
const resumeIfParked = async (): Promise<void> => {
|
|
||||||
if (wasRunning && !manager.isSessionRunning()) {
|
|
||||||
await manager.start();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await doInstall();
|
|
||||||
if (!result.started) {
|
|
||||||
await resumeIfParked();
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
await resumeIfParked();
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -104,9 +104,6 @@ function isGermanStream(stream: ProbedAudioStream): boolean {
|
|||||||
// Free-text title fallback (used when the language tag is missing). Full words
|
// Free-text title fallback (used when the language tag is missing). Full words
|
||||||
// only — the 2-3 letter codes ger/deu are too ambiguous in a title and would
|
// only — the 2-3 letter codes ger/deu are too ambiguous in a title and would
|
||||||
// pick the wrong track to keep (which then deletes the real German one).
|
// pick the wrong track to keep (which then deletes the real German one).
|
||||||
if (lang) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const title = (stream.title || "").toLowerCase();
|
const title = (stream.title || "").toLowerCase();
|
||||||
return /\b(german|deutsch)\b/.test(title);
|
return /\b(german|deutsch)\b/.test(title);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,10 +7,8 @@ import {
|
|||||||
DebridLinkHostLimitInfo,
|
DebridLinkHostLimitInfo,
|
||||||
DebridProvider,
|
DebridProvider,
|
||||||
DuplicatePolicy,
|
DuplicatePolicy,
|
||||||
EnableRemoteDiagnosticsInput,
|
|
||||||
HistoryEntry,
|
HistoryEntry,
|
||||||
PackagePriority,
|
PackagePriority,
|
||||||
RemoteDiagnosticsInfo,
|
|
||||||
RendererErrorReport,
|
RendererErrorReport,
|
||||||
SessionStats,
|
SessionStats,
|
||||||
StartConflictEntry,
|
StartConflictEntry,
|
||||||
@ -76,10 +74,6 @@ const api: ElectronApi = {
|
|||||||
getTraceConfig: () => ipcRenderer.invoke(IPC_CHANNELS.GET_TRACE_CONFIG),
|
getTraceConfig: () => ipcRenderer.invoke(IPC_CHANNELS.GET_TRACE_CONFIG),
|
||||||
setTraceEnabled: (enabled: boolean, note?: string, durationMinutes?: number) => ipcRenderer.invoke(IPC_CHANNELS.SET_TRACE_ENABLED, enabled, note, durationMinutes),
|
setTraceEnabled: (enabled: boolean, note?: string, durationMinutes?: number) => ipcRenderer.invoke(IPC_CHANNELS.SET_TRACE_ENABLED, enabled, note, durationMinutes),
|
||||||
rotateDebugToken: (): Promise<{ path: string }> => ipcRenderer.invoke(IPC_CHANNELS.ROTATE_DEBUG_TOKEN),
|
rotateDebugToken: (): Promise<{ path: string }> => ipcRenderer.invoke(IPC_CHANNELS.ROTATE_DEBUG_TOKEN),
|
||||||
getRemoteDiagnostics: (): Promise<RemoteDiagnosticsInfo> => ipcRenderer.invoke(IPC_CHANNELS.GET_REMOTE_DIAGNOSTICS),
|
|
||||||
enableRemoteDiagnostics: (input: EnableRemoteDiagnosticsInput): Promise<RemoteDiagnosticsInfo> => ipcRenderer.invoke(IPC_CHANNELS.ENABLE_REMOTE_DIAGNOSTICS, input),
|
|
||||||
disableRemoteDiagnostics: (): Promise<RemoteDiagnosticsInfo> => ipcRenderer.invoke(IPC_CHANNELS.DISABLE_REMOTE_DIAGNOSTICS),
|
|
||||||
rotateRemoteDiagnosticsToken: (): Promise<RemoteDiagnosticsInfo> => ipcRenderer.invoke(IPC_CHANNELS.ROTATE_REMOTE_DIAGNOSTICS_TOKEN),
|
|
||||||
openRealDebridLogin: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_REALDEBRID_LOGIN),
|
openRealDebridLogin: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_REALDEBRID_LOGIN),
|
||||||
openAllDebridLogin: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_ALLDEBRID_LOGIN),
|
openAllDebridLogin: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_ALLDEBRID_LOGIN),
|
||||||
importBestDebridCookies: (): Promise<number> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BESTDEBRID_COOKIES),
|
importBestDebridCookies: (): Promise<number> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BESTDEBRID_COOKIES),
|
||||||
|
|||||||
@ -16,7 +16,6 @@ import type {
|
|||||||
DuplicatePolicy,
|
DuplicatePolicy,
|
||||||
HistoryEntry,
|
HistoryEntry,
|
||||||
PackageEntry,
|
PackageEntry,
|
||||||
RemoteDiagnosticsInfo,
|
|
||||||
StartConflictEntry,
|
StartConflictEntry,
|
||||||
UiSnapshot,
|
UiSnapshot,
|
||||||
UpdateCheckResult,
|
UpdateCheckResult,
|
||||||
@ -110,7 +109,7 @@ interface MegaDialogAccount {
|
|||||||
password: string;
|
password: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AccountDialogState {
|
interface AccountDialogState {
|
||||||
mode: "create" | "edit";
|
mode: "create" | "edit";
|
||||||
kind: AccountKind | null;
|
kind: AccountKind | null;
|
||||||
token: string;
|
token: string;
|
||||||
@ -648,7 +647,7 @@ function createAccountDialogState(mode: "create" | "edit", kind: AccountKind | n
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyAccountDialogToSettings(settings: AppSettings, dialog: AccountDialogState): AppSettings {
|
function applyAccountDialogToSettings(settings: AppSettings, dialog: AccountDialogState): AppSettings {
|
||||||
if (!dialog.kind) {
|
if (!dialog.kind) {
|
||||||
return settings;
|
return settings;
|
||||||
}
|
}
|
||||||
@ -683,7 +682,7 @@ export function applyAccountDialogToSettings(settings: AppSettings, dialog: Acco
|
|||||||
const firstPassword = megaParsed.length > 0 ? megaParsed[0].password : "";
|
const firstPassword = megaParsed.length > 0 ? megaParsed[0].password : "";
|
||||||
const validIds = new Set(megaParsed.map((a) => a.id));
|
const validIds = new Set(megaParsed.map((a) => a.id));
|
||||||
const megaDebridDisabledAccountIds = (dialog.megaDisabledIds || []).filter((id) => validIds.has(id));
|
const megaDebridDisabledAccountIds = (dialog.megaDisabledIds || []).filter((id) => validIds.has(id));
|
||||||
return { ...settings, megaCredentials: megaSerialized, megaLogin: firstLogin, megaPassword: firstPassword, megaDebridApiEnabled: true, megaDebridDisabledAccountIds, providerDailyLimitBytes: nextProviderDailyLimitBytes };
|
return { ...settings, megaCredentials: megaSerialized, megaLogin: firstLogin, megaPassword: firstPassword, megaDebridApiEnabled: true, megaDebridPreferApi: true, megaDebridDisabledAccountIds, providerDailyLimitBytes: nextProviderDailyLimitBytes };
|
||||||
}
|
}
|
||||||
case "megadebrid-web": {
|
case "megadebrid-web": {
|
||||||
const megaSerialized = serializeMegaDebridAccounts(dialog.megaAccounts);
|
const megaSerialized = serializeMegaDebridAccounts(dialog.megaAccounts);
|
||||||
@ -692,7 +691,7 @@ export function applyAccountDialogToSettings(settings: AppSettings, dialog: Acco
|
|||||||
const firstPassword = megaParsed.length > 0 ? megaParsed[0].password : "";
|
const firstPassword = megaParsed.length > 0 ? megaParsed[0].password : "";
|
||||||
const validIds = new Set(megaParsed.map((a) => a.id));
|
const validIds = new Set(megaParsed.map((a) => a.id));
|
||||||
const megaDebridDisabledAccountIds = (dialog.megaDisabledIds || []).filter((id) => validIds.has(id));
|
const megaDebridDisabledAccountIds = (dialog.megaDisabledIds || []).filter((id) => validIds.has(id));
|
||||||
return { ...settings, megaCredentials: megaSerialized, megaLogin: firstLogin, megaPassword: firstPassword, megaDebridWebEnabled: true, megaDebridDisabledAccountIds, providerDailyLimitBytes: nextProviderDailyLimitBytes };
|
return { ...settings, megaCredentials: megaSerialized, megaLogin: firstLogin, megaPassword: firstPassword, megaDebridWebEnabled: true, megaDebridPreferApi: false, megaDebridDisabledAccountIds, providerDailyLimitBytes: nextProviderDailyLimitBytes };
|
||||||
}
|
}
|
||||||
case "bestdebrid-api":
|
case "bestdebrid-api":
|
||||||
return { ...settings, bestToken: token, bestDebridUseWebLogin: false, providerDailyLimitBytes: nextProviderDailyLimitBytes };
|
return { ...settings, bestToken: token, bestDebridUseWebLogin: false, providerDailyLimitBytes: nextProviderDailyLimitBytes };
|
||||||
@ -856,7 +855,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, backupIncludeMcp: false,
|
theme: "dark", collapseNewPackages: true, historyRetentionMode: "permanent", historyMaxEntries: 500, historyMaxAgeDays: 0, autoSortPackagesByProgress: true, autoSkipExtracted: false, hideExtractedItems: true, confirmDeleteSelection: true, backupIncludeDownloads: false,
|
||||||
notifyUrl: "", notifyMention: "", notifyOnPackageCompleted: false, notifyOnPackageFailed: false, notifyOnRunFinished: false,
|
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,
|
||||||
@ -1160,13 +1159,13 @@ function formatCheckedAgo(checkedAt: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function rotationEventText(ev: { event: string; cooldownSec?: number; next?: string; reason?: string }): string {
|
function rotationEventText(ev: { event: string; cooldownSec?: number; next?: string; reason?: string }): string {
|
||||||
const untilRestart = /bis zum Tagesreset gesperrt/i.test(ev.reason || "");
|
const untilRestart = /bis Neustart gesperrt/i.test(ev.reason || "");
|
||||||
switch (ev.event) {
|
switch (ev.event) {
|
||||||
case "OK": return "erfolgreich";
|
case "OK": return "erfolgreich";
|
||||||
case "FAILED": {
|
case "FAILED": {
|
||||||
if (untilRestart) {
|
if (untilRestart) {
|
||||||
const nx = ev.next && ev.next !== "ENDE" ? ` → ${ev.next}` : "";
|
const nx = ev.next && ev.next !== "ENDE" ? ` → ${ev.next}` : "";
|
||||||
return `Tageslimit erreicht, bis zum Tagesreset gesperrt${nx}`;
|
return `Tageslimit erreicht, bis Neustart gesperrt${nx}`;
|
||||||
}
|
}
|
||||||
const cd = ev.cooldownSec ? `, Cooldown ${ev.cooldownSec}s` : "";
|
const cd = ev.cooldownSec ? `, Cooldown ${ev.cooldownSec}s` : "";
|
||||||
const nx = ev.next && ev.next !== "ENDE" ? ` → ${ev.next}` : "";
|
const nx = ev.next && ev.next !== "ENDE" ? ` → ${ev.next}` : "";
|
||||||
@ -1177,7 +1176,7 @@ function rotationEventText(ev: { event: string; cooldownSec?: number; next?: str
|
|||||||
const cd = ev.cooldownSec ? `, Cooldown ${ev.cooldownSec}s` : "";
|
const cd = ev.cooldownSec ? `, Cooldown ${ev.cooldownSec}s` : "";
|
||||||
return `Timeout/Abbruch${cd} → nächster Account beim Retry`;
|
return `Timeout/Abbruch${cd} → nächster Account beim Retry`;
|
||||||
}
|
}
|
||||||
case "SKIP_COOLDOWN": return untilRestart ? "übersprungen (bis zum Tagesreset gesperrt)" : "übersprungen (Cooldown aktiv)";
|
case "SKIP_COOLDOWN": return untilRestart ? "übersprungen (bis Neustart gesperrt)" : "übersprungen (Cooldown aktiv)";
|
||||||
case "SKIP_DISABLED": return "übersprungen (deaktiviert)";
|
case "SKIP_DISABLED": return "übersprungen (deaktiviert)";
|
||||||
case "SKIP_DAILY_LIMIT": return "übersprungen (Tageslimit erreicht)";
|
case "SKIP_DAILY_LIMIT": return "übersprungen (Tageslimit erreicht)";
|
||||||
case "SKIP_HOST_COOLDOWN": return "übersprungen (Host-Cooldown)";
|
case "SKIP_HOST_COOLDOWN": return "übersprungen (Host-Cooldown)";
|
||||||
@ -1762,14 +1761,6 @@ export function App(): ReactElement {
|
|||||||
const [startConflictPrompt, setStartConflictPrompt] = useState<StartConflictPromptState | null>(null);
|
const [startConflictPrompt, setStartConflictPrompt] = useState<StartConflictPromptState | null>(null);
|
||||||
const startConflictResolverRef = useRef<((result: { policy: Extract<DuplicatePolicy, "skip" | "overwrite">; applyToAll: boolean } | null) => void) | null>(null);
|
const startConflictResolverRef = useRef<((result: { policy: Extract<DuplicatePolicy, "skip" | "overwrite">; applyToAll: boolean } | null) => void) | null>(null);
|
||||||
const [confirmPrompt, setConfirmPrompt] = useState<ConfirmPromptState | null>(null);
|
const [confirmPrompt, setConfirmPrompt] = useState<ConfirmPromptState | null>(null);
|
||||||
const [remoteDiag, setRemoteDiag] = useState<RemoteDiagnosticsInfo | null>(null);
|
|
||||||
const [remoteDiagOpen, setRemoteDiagOpen] = useState(false);
|
|
||||||
const [remoteDiagBusy, setRemoteDiagBusy] = useState(false);
|
|
||||||
const [rdHostMode, setRdHostMode] = useState<"local" | "network">("local");
|
|
||||||
const [rdPublicHost, setRdPublicHost] = useState("");
|
|
||||||
const [rdPort, setRdPort] = useState("9868");
|
|
||||||
const [rdAllowlist, setRdAllowlist] = useState("");
|
|
||||||
const [rdName, setRdName] = useState("");
|
|
||||||
const confirmResolverRef = useRef<((confirmed: boolean) => void) | null>(null);
|
const confirmResolverRef = useRef<((confirmed: boolean) => void) | null>(null);
|
||||||
const confirmQueueRef = useRef<Array<{ prompt: ConfirmPromptState; resolve: (confirmed: boolean) => void }>>([]);
|
const confirmQueueRef = useRef<Array<{ prompt: ConfirmPromptState; resolve: (confirmed: boolean) => void }>>([]);
|
||||||
const importQueueFocusHandlerRef = useRef<(() => void) | null>(null);
|
const importQueueFocusHandlerRef = useRef<(() => void) | null>(null);
|
||||||
@ -2570,7 +2561,7 @@ export function App(): ReactElement {
|
|||||||
const used = getMegaDebridAccountDailyUsageBytes(snapshot.settings, acc.id);
|
const used = getMegaDebridAccountDailyUsageBytes(snapshot.settings, acc.id);
|
||||||
const limit = getMegaDebridAccountDailyLimitBytes(settingsDraft, acc.id);
|
const limit = getMegaDebridAccountDailyLimitBytes(settingsDraft, acc.id);
|
||||||
rows.push({
|
rows.push({
|
||||||
rowKey: `mega-${entry.kind}-${acc.id}`,
|
rowKey: `mega-${acc.id}`,
|
||||||
entry,
|
entry,
|
||||||
hosterLabel: entry.serviceLabel,
|
hosterLabel: entry.serviceLabel,
|
||||||
modeLabel: entry.modeLabel,
|
modeLabel: entry.modeLabel,
|
||||||
@ -2626,9 +2617,6 @@ export function App(): ReactElement {
|
|||||||
return rows;
|
return rows;
|
||||||
}, [configuredAccounts, settingsDraft, snapshot.settings]);
|
}, [configuredAccounts, settingsDraft, snapshot.settings]);
|
||||||
|
|
||||||
const [accountStatusSort, setAccountStatusSort] = useState<"none" | "desc" | "asc">("none");
|
|
||||||
const cycleAccountStatusSort = (): void => setAccountStatusSort((s) => (s === "none" ? "desc" : s === "desc" ? "asc" : "none"));
|
|
||||||
|
|
||||||
const groupedAccountRows = useMemo(() => {
|
const groupedAccountRows = useMemo(() => {
|
||||||
const groups: { service: string; serviceLabel: string; rows: typeof accountRows }[] = [];
|
const groups: { service: string; serviceLabel: string; rows: typeof accountRows }[] = [];
|
||||||
const byService = new Map<string, { service: string; serviceLabel: string; rows: typeof accountRows }>();
|
const byService = new Map<string, { service: string; serviceLabel: string; rows: typeof accountRows }>();
|
||||||
@ -2642,26 +2630,8 @@ export function App(): ReactElement {
|
|||||||
}
|
}
|
||||||
group.rows.push(row);
|
group.rows.push(row);
|
||||||
}
|
}
|
||||||
if (accountStatusSort !== "none") {
|
|
||||||
const statuses = snapshot.settings?.debridAccountStatuses || {};
|
|
||||||
const now = Date.now();
|
|
||||||
const remainingMs = (row: (typeof accountRows)[number]): number => {
|
|
||||||
const st = row.accountId ? statuses[row.accountId] : null;
|
|
||||||
return st && st.premiumUntilMs && st.premiumUntilMs > now ? st.premiumUntilMs - now : -1;
|
|
||||||
};
|
|
||||||
for (const group of groups) {
|
|
||||||
group.rows = [...group.rows].sort((a, b) => {
|
|
||||||
const ra = remainingMs(a);
|
|
||||||
const rb = remainingMs(b);
|
|
||||||
if (ra > 0 && rb > 0) return accountStatusSort === "desc" ? rb - ra : ra - rb;
|
|
||||||
if (ra > 0) return -1;
|
|
||||||
if (rb > 0) return 1;
|
|
||||||
return 0;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return groups;
|
return groups;
|
||||||
}, [accountRows, accountStatusSort, snapshot.settings]);
|
}, [accountRows]);
|
||||||
|
|
||||||
const [collapsedAccountGroups, setCollapsedAccountGroups] = useState<Set<string>>(() => {
|
const [collapsedAccountGroups, setCollapsedAccountGroups] = useState<Set<string>>(() => {
|
||||||
try {
|
try {
|
||||||
@ -2693,8 +2663,7 @@ export function App(): ReactElement {
|
|||||||
else if (!st.isPremium) { statusCls = "free"; statusText = "Free Account"; }
|
else if (!st.isPremium) { statusCls = "free"; statusText = "Free Account"; }
|
||||||
else { statusCls = "ok"; statusText = st.message || "Premium Account"; }
|
else { statusCls = "ok"; statusText = st.message || "Premium Account"; }
|
||||||
const isProblem = statusCls === "invalid";
|
const isProblem = statusCls === "invalid";
|
||||||
const username = row.username;
|
const username = st && st.email ? st.email : row.username;
|
||||||
const usernameTitle = st && st.email && st.email.toLowerCase() !== row.username.toLowerCase() ? `${row.username} (Mail: ${st.email})` : row.username;
|
|
||||||
const expiry = st && st.premiumUntilMs && st.premiumUntilMs > 0 ? new Date(st.premiumUntilMs).toLocaleDateString("de-DE") : "—";
|
const expiry = st && st.premiumUntilMs && st.premiumUntilMs > 0 ? new Date(st.premiumUntilMs).toLocaleDateString("de-DE") : "—";
|
||||||
const traffic = row.dailyLimitBytes > 0
|
const traffic = row.dailyLimitBytes > 0
|
||||||
? `${humanSize(row.dailyRemainingBytes)} von ${humanSize(row.dailyLimitBytes)} übrig`
|
? `${humanSize(row.dailyRemainingBytes)} von ${humanSize(row.dailyLimitBytes)} übrig`
|
||||||
@ -2724,7 +2693,7 @@ export function App(): ReactElement {
|
|||||||
? <span className="acct2-nostatus" title="Für diesen Anbieter gibt es keine Status-Prüfung">—</span>
|
? <span className="acct2-nostatus" title="Für diesen Anbieter gibt es keine Status-Prüfung">—</span>
|
||||||
: <span className={`account-validity-badge ${statusCls}`}>{statusText}</span>}
|
: <span className={`account-validity-badge ${statusCls}`}>{statusText}</span>}
|
||||||
</span>
|
</span>
|
||||||
<span className="acct2-user" title={usernameTitle}>{username}</span>
|
<span className="acct2-user" title={username}>{username}</span>
|
||||||
<span className="acct2-expiry">{expiry}</span>
|
<span className="acct2-expiry">{expiry}</span>
|
||||||
<span className="acct2-c-actions">
|
<span className="acct2-c-actions">
|
||||||
{row.toggleKind === "single" && getAccountQuickActionMeta(row.entry.kind) && (
|
{row.toggleKind === "single" && getAccountQuickActionMeta(row.entry.kind) && (
|
||||||
@ -2796,16 +2765,28 @@ export function App(): ReactElement {
|
|||||||
}
|
}
|
||||||
let changelogText = "";
|
let changelogText = "";
|
||||||
if (result.releaseNotes) {
|
if (result.releaseNotes) {
|
||||||
changelogText = result.releaseNotes
|
const lines = result.releaseNotes.split("\n");
|
||||||
.split("\n")
|
const compactLines: string[] = [];
|
||||||
.filter((line) => !/^#{1,6}\s/.test(line))
|
for (const line of lines) {
|
||||||
.map((line) => line
|
if (/^\s{2,}[-*]/.test(line)) continue;
|
||||||
|
if (/^#{1,6}\s/.test(line)) continue;
|
||||||
|
if (!line.trim()) continue;
|
||||||
|
let clean = line
|
||||||
.replace(/\*\*([^*]+)\*\*/g, "$1")
|
.replace(/\*\*([^*]+)\*\*/g, "$1")
|
||||||
.replace(/\*([^*]+)\*/g, "$1")
|
.replace(/\*([^*]+)\*/g, "$1")
|
||||||
.replace(/`([^`]+)`/g, "$1"))
|
.replace(/`([^`]+)`/g, "$1")
|
||||||
.join("\n")
|
.replace(/^\s*[-*]\s+/, "- ")
|
||||||
.replace(/\n{3,}/g, "\n\n")
|
.trim();
|
||||||
.trim();
|
const colonIdx = clean.indexOf(":");
|
||||||
|
if (colonIdx > 0 && colonIdx < clean.length - 1) {
|
||||||
|
const afterColon = clean.slice(colonIdx + 1).trim();
|
||||||
|
if (afterColon.length > 60) {
|
||||||
|
clean = clean.slice(0, colonIdx + 1).trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (clean) compactLines.push(clean);
|
||||||
|
}
|
||||||
|
changelogText = compactLines.join("\n");
|
||||||
}
|
}
|
||||||
const approved = await askConfirmPrompt({
|
const approved = await askConfirmPrompt({
|
||||||
title: "Update verfügbar",
|
title: "Update verfügbar",
|
||||||
@ -4337,89 +4318,6 @@ export function App(): ReactElement {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const applyRemoteDiagInfo = (info: RemoteDiagnosticsInfo): void => {
|
|
||||||
setRemoteDiag(info);
|
|
||||||
setRdHostMode(info.status.localOnly ? "local" : "network");
|
|
||||||
setRdPublicHost(info.publicHost || (info.status.localOnly ? "" : (info.suggestedHosts[0] || "")));
|
|
||||||
setRdPort(String(info.status.port || 9868));
|
|
||||||
setRdAllowlist(info.allowlist.join("\n"));
|
|
||||||
setRdName(info.name || "");
|
|
||||||
};
|
|
||||||
|
|
||||||
const onOpenRemoteDiagnostics = async (): Promise<void> => {
|
|
||||||
closeMenus();
|
|
||||||
try {
|
|
||||||
const info = await window.rd.getRemoteDiagnostics();
|
|
||||||
applyRemoteDiagInfo(info);
|
|
||||||
setRemoteDiagOpen(true);
|
|
||||||
} catch (error) {
|
|
||||||
showToast(`Ferndiagnose-Status fehlgeschlagen: ${String(error)}`, 3000);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onSubmitRemoteDiagnostics = async (): Promise<void> => {
|
|
||||||
const port = Number(rdPort) || 9868;
|
|
||||||
const allowlist = rdAllowlist.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0);
|
|
||||||
if (rdHostMode === "network" && allowlist.length === 0) {
|
|
||||||
showToast("Netzwerkmodus braucht mindestens eine IP oder CIDR in der Allowlist", 3600);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setRemoteDiagBusy(true);
|
|
||||||
try {
|
|
||||||
const info = await window.rd.enableRemoteDiagnostics({
|
|
||||||
hostMode: rdHostMode,
|
|
||||||
publicHost: rdPublicHost,
|
|
||||||
port,
|
|
||||||
allowlist,
|
|
||||||
name: rdName
|
|
||||||
});
|
|
||||||
applyRemoteDiagInfo(info);
|
|
||||||
showToast(info.status.running ? "Ferndiagnose aktiv" : "Ferndiagnose konfiguriert", 2600);
|
|
||||||
} catch (error) {
|
|
||||||
showToast(`Aktivieren fehlgeschlagen: ${String(error)}`, 3600);
|
|
||||||
} finally {
|
|
||||||
setRemoteDiagBusy(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDisableRemoteDiagnostics = async (): Promise<void> => {
|
|
||||||
setRemoteDiagBusy(true);
|
|
||||||
try {
|
|
||||||
const info = await window.rd.disableRemoteDiagnostics();
|
|
||||||
applyRemoteDiagInfo(info);
|
|
||||||
showToast("Ferndiagnose deaktiviert", 2400);
|
|
||||||
} catch (error) {
|
|
||||||
showToast(`Deaktivieren fehlgeschlagen: ${String(error)}`, 3000);
|
|
||||||
} finally {
|
|
||||||
setRemoteDiagBusy(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onRotateRemoteDiagnosticsToken = async (): Promise<void> => {
|
|
||||||
setRemoteDiagBusy(true);
|
|
||||||
try {
|
|
||||||
const info = await window.rd.rotateRemoteDiagnosticsToken();
|
|
||||||
applyRemoteDiagInfo(info);
|
|
||||||
showToast("Neues Token - alter Verbindungscode ist ungueltig", 3000);
|
|
||||||
} catch (error) {
|
|
||||||
showToast(`Token-Rotation fehlgeschlagen: ${String(error)}`, 3000);
|
|
||||||
} finally {
|
|
||||||
setRemoteDiagBusy(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onCopyRemoteDiagnosticsCode = async (): Promise<void> => {
|
|
||||||
if (!remoteDiag?.code) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(remoteDiag.code);
|
|
||||||
showToast("Verbindungscode kopiert", 2200);
|
|
||||||
} catch {
|
|
||||||
showToast("Kopieren fehlgeschlagen", 2200);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onMenuRestart = (): void => {
|
const onMenuRestart = (): void => {
|
||||||
closeMenus();
|
closeMenus();
|
||||||
void window.rd.restart();
|
void window.rd.restart();
|
||||||
@ -4810,7 +4708,6 @@ export function App(): ReactElement {
|
|||||||
<button className="menu-submenu-trigger">Remote-Support</button>
|
<button className="menu-submenu-trigger">Remote-Support</button>
|
||||||
{openSubmenu === "hilfe-remote" && (
|
{openSubmenu === "hilfe-remote" && (
|
||||||
<div className="menu-submenu-dropdown">
|
<div className="menu-submenu-dropdown">
|
||||||
<button className="menu-dropdown-item" onClick={() => { void onOpenRemoteDiagnostics(); }}><span>Ferndiagnose (MCP) …</span></button>
|
|
||||||
<button className="menu-dropdown-item" onClick={() => { void onExportSupportBundle(); }}><span>Support-Bundle exportieren</span></button>
|
<button className="menu-dropdown-item" onClick={() => { void onExportSupportBundle(); }}><span>Support-Bundle exportieren</span></button>
|
||||||
<button className="menu-dropdown-item" onClick={() => { void onToggleSupportTrace(); }}><span>{supportTraceEnabled ? "Support-Trace deaktivieren" : "Support-Trace aktivieren"}</span></button>
|
<button className="menu-dropdown-item" onClick={() => { void onToggleSupportTrace(); }}><span>{supportTraceEnabled ? "Support-Trace deaktivieren" : "Support-Trace aktivieren"}</span></button>
|
||||||
</div>
|
</div>
|
||||||
@ -5442,8 +5339,6 @@ 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;
|
||||||
@ -5507,12 +5402,6 @@ export function App(): ReactElement {
|
|||||||
<span className="account-inline-stat">{availableAccountOptions.length} weitere Typen verfügbar</span>
|
<span className="account-inline-stat">{availableAccountOptions.length} weitere Typen verfügbar</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{configuredAccountServices.has("megadebrid-api") && configuredAccountServices.has("megadebrid-web") && (
|
|
||||||
<div className="account-mode-note">
|
|
||||||
<strong>Mega-Debrid API + Web sind derselbe Account</strong>, nur zwei Zugriffsarten: API (schnell) wird zuerst versucht, Web dient als Fallback. Gleicher Login, kein zweites Konto — beide Zeilen teilen sich dasselbe Tageslimit und denselben Premium-Status.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{accountRows.length === 0 && (
|
{accountRows.length === 0 && (
|
||||||
<div className="account-empty-state">
|
<div className="account-empty-state">
|
||||||
<strong>Noch keine Accounts hinterlegt</strong>
|
<strong>Noch keine Accounts hinterlegt</strong>
|
||||||
@ -5526,7 +5415,7 @@ export function App(): ReactElement {
|
|||||||
<span className="acct2-c-check" />
|
<span className="acct2-c-check" />
|
||||||
<span>Hoster</span>
|
<span>Hoster</span>
|
||||||
<span>Download-Traffic</span>
|
<span>Download-Traffic</span>
|
||||||
<span className="acct2-sortable" title="Nach Premium-Restlaufzeit sortieren (längste/kürzeste)" onClick={cycleAccountStatusSort}>Status{accountStatusSort === "desc" ? " ▼" : accountStatusSort === "asc" ? " ▲" : ""}</span>
|
<span>Status</span>
|
||||||
<span>Benutzername</span>
|
<span>Benutzername</span>
|
||||||
<span>Verfallsdatum</span>
|
<span>Verfallsdatum</span>
|
||||||
<span className="acct2-c-actions">Aktion</span>
|
<span className="acct2-c-actions">Aktion</span>
|
||||||
@ -6029,97 +5918,6 @@ export function App(): ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{remoteDiagOpen && (
|
|
||||||
<div className="modal-backdrop" onClick={() => setRemoteDiagOpen(false)}>
|
|
||||||
<div className="modal-card" onClick={(event) => event.stopPropagation()}>
|
|
||||||
<h3>Ferndiagnose (MCP)</h3>
|
|
||||||
<p>Aktiviert einen abgesicherten Lesezugriff auf Status, Logs und Fehler dieses Servers. Den Verbindungscode dem Assistenten geben - er verbindet sich, sieht alles und behebt Probleme.</p>
|
|
||||||
<div className="rd-status-line">
|
|
||||||
<span className={`rd-dot${remoteDiag?.status.running ? " on" : ""}`} />
|
|
||||||
<span>
|
|
||||||
{remoteDiag?.status.running
|
|
||||||
? `Aktiv auf ${remoteDiag.status.host}:${remoteDiag.status.port}${remoteDiag.status.localOnly ? " (nur lokal)" : ` (Allowlist: ${remoteDiag.status.allowlistCount})`}`
|
|
||||||
: "Inaktiv"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rd-field">
|
|
||||||
<label>Sichtbarkeit</label>
|
|
||||||
<div className="rd-seg">
|
|
||||||
<button className={rdHostMode === "local" ? "active" : ""} onClick={() => setRdHostMode("local")}>Nur lokal</button>
|
|
||||||
<button className={rdHostMode === "network" ? "active" : ""} onClick={() => setRdHostMode("network")}>Im Netzwerk</button>
|
|
||||||
</div>
|
|
||||||
<span className="rd-hint">
|
|
||||||
{rdHostMode === "local"
|
|
||||||
? "Bindet nur an 127.0.0.1. Fernzugriff nur ueber einen Tunnel (z.B. Tailscale/SSH) - die sicherste Variante."
|
|
||||||
: "Bindet an 0.0.0.0. Erreichbar im Netzwerk, erfordert eine Allowlist. Nur in vertrauenswuerdigen Netzen/VPN nutzen."}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rd-field">
|
|
||||||
<label>Oeffentliche Adresse (fuer den Verbindungscode)</label>
|
|
||||||
<input
|
|
||||||
value={rdPublicHost}
|
|
||||||
placeholder={rdHostMode === "local" ? "127.0.0.1 oder Tunnel-Adresse" : "Server-IP oder DNS-Name"}
|
|
||||||
onChange={(event) => setRdPublicHost(event.target.value)}
|
|
||||||
/>
|
|
||||||
{remoteDiag && remoteDiag.suggestedHosts.length > 0 && (
|
|
||||||
<div className="rd-chips">
|
|
||||||
{remoteDiag.suggestedHosts.map((host) => (
|
|
||||||
<button key={host} className="rd-chip" onClick={() => setRdPublicHost(host)}>{host}</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rd-field rd-field-inline">
|
|
||||||
<div className="rd-field">
|
|
||||||
<label>Port</label>
|
|
||||||
<input value={rdPort} onChange={(event) => setRdPort(event.target.value.replace(/[^0-9]/g, ""))} />
|
|
||||||
</div>
|
|
||||||
<div className="rd-field">
|
|
||||||
<label>Name (optional)</label>
|
|
||||||
<input value={rdName} placeholder="z.B. Server-Berlin" onChange={(event) => setRdName(event.target.value)} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{rdHostMode === "network" && (
|
|
||||||
<div className="rd-field">
|
|
||||||
<label>Allowlist - erlaubte IPs/CIDR (eine pro Zeile)</label>
|
|
||||||
<textarea
|
|
||||||
value={rdAllowlist}
|
|
||||||
placeholder={"203.0.113.5\n10.0.0.0/24"}
|
|
||||||
onChange={(event) => setRdAllowlist(event.target.value)}
|
|
||||||
/>
|
|
||||||
<span className="rd-hint">Pflicht im Netzwerkmodus. Nur diese Quell-IPs duerfen verbinden (zusaetzlich zum Token). Loopback ist immer erlaubt.</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{remoteDiag?.status.running && remoteDiag.code && (
|
|
||||||
<div className="rd-field">
|
|
||||||
<label>Verbindungscode</label>
|
|
||||||
<div className="rd-code">{remoteDiag.code}</div>
|
|
||||||
<div className="rd-chips">
|
|
||||||
<button className="btn" onClick={() => { void onCopyRemoteDiagnosticsCode(); }}>Kopieren</button>
|
|
||||||
<button className="btn" onClick={() => { void onRotateRemoteDiagnosticsToken(); }} disabled={remoteDiagBusy}>Token neu (Code ungueltig machen)</button>
|
|
||||||
</div>
|
|
||||||
<span className="rd-hint">Enthaelt das Zugriffstoken - wie ein Passwort behandeln. Token neu = alter Code wird sofort ungueltig.</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="modal-actions">
|
|
||||||
<button className="btn" onClick={() => setRemoteDiagOpen(false)}>Schliessen</button>
|
|
||||||
{remoteDiag?.status.running && (
|
|
||||||
<button className="btn danger" onClick={() => { void onDisableRemoteDiagnostics(); }} disabled={remoteDiagBusy}>Deaktivieren</button>
|
|
||||||
)}
|
|
||||||
<button className="btn accent" onClick={() => { void onSubmitRemoteDiagnostics(); }} disabled={remoteDiagBusy}>
|
|
||||||
{remoteDiag?.status.running ? "Aktualisieren" : "Aktivieren"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{deleteConfirm && (() => {
|
{deleteConfirm && (() => {
|
||||||
const itemCount = [...deleteConfirm.ids].filter((id) => snapshot.session.items[id]).length;
|
const itemCount = [...deleteConfirm.ids].filter((id) => snapshot.session.items[id]).length;
|
||||||
const pkgCount = [...deleteConfirm.ids].filter((id) => snapshot.session.packages[id]).length;
|
const pkgCount = [...deleteConfirm.ids].filter((id) => snapshot.session.packages[id]).length;
|
||||||
@ -6971,13 +6769,7 @@ const ItemRow = memo(function ItemRow({ item, packageId, isSelected, sessionRunn
|
|||||||
}, [packageId, item.id, onContextMenu]);
|
}, [packageId, item.id, onContextMenu]);
|
||||||
const formattedCreatedAt = useMemo(() => formatDateTime(item.createdAt), [item.createdAt]);
|
const formattedCreatedAt = useMemo(() => formatDateTime(item.createdAt), [item.createdAt]);
|
||||||
const displayStatus = useMemo(() => computeDisplayedItemStatus(item, sessionRunning), [item, sessionRunning]);
|
const displayStatus = useMemo(() => computeDisplayedItemStatus(item, sessionRunning), [item, sessionRunning]);
|
||||||
const retrySuffix = item.retries > 0 ? ` (R${item.retries})` : "";
|
const statusTitle = displayStatus ? (item.retries > 0 ? `${displayStatus} ? R${item.retries}` : displayStatus) : "";
|
||||||
const lastErrorText = String(item.lastError || "").trim();
|
|
||||||
const statusTitle = displayStatus
|
|
||||||
? (lastErrorText && lastErrorText !== displayStatus && !displayStatus.includes(lastErrorText)
|
|
||||||
? `${displayStatus}${retrySuffix}\n${lastErrorText}`
|
|
||||||
: `${displayStatus}${retrySuffix}`)
|
|
||||||
: lastErrorText;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@ -2088,20 +2088,6 @@ body,
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.account-mode-note {
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-radius: 10px;
|
|
||||||
border: 1px solid color-mix(in srgb, var(--accent) 22%, transparent);
|
|
||||||
background: color-mix(in srgb, var(--accent) 7%, transparent);
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 12.5px;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.account-mode-note strong {
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.account-dl-key-limit-list {
|
.account-dl-key-limit-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@ -3333,10 +3319,6 @@ td {
|
|||||||
.acct2-mode { font-size: 11px; color: var(--muted); }
|
.acct2-mode { font-size: 11px; color: var(--muted); }
|
||||||
.acct2-traffic,
|
.acct2-traffic,
|
||||||
.acct2-expiry { font-variant-numeric: tabular-nums; color: var(--muted); }
|
.acct2-expiry { font-variant-numeric: tabular-nums; color: var(--muted); }
|
||||||
.acct2-head > span:nth-child(3),
|
|
||||||
.acct2-traffic { text-align: center; }
|
|
||||||
.acct2-sortable { cursor: pointer; user-select: none; }
|
|
||||||
.acct2-sortable:hover { color: var(--text); }
|
|
||||||
.acct2-user { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.acct2-user { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.acct2-status .account-validity-badge { margin-top: 0; }
|
.acct2-status .account-validity-badge { margin-top: 0; }
|
||||||
.acct2-nostatus { color: var(--muted); opacity: 0.6; font-variant-numeric: tabular-nums; }
|
.acct2-nostatus { color: var(--muted); opacity: 0.6; font-variant-numeric: tabular-nums; }
|
||||||
@ -3360,110 +3342,3 @@ td {
|
|||||||
.rotation-event .rotation-time { color: var(--muted, #a59c8e); font-variant-numeric: tabular-nums; }
|
.rotation-event .rotation-time { color: var(--muted, #a59c8e); font-variant-numeric: tabular-nums; }
|
||||||
.rotation-event .rotation-body strong { font-weight: 600; }
|
.rotation-event .rotation-body strong { font-weight: 600; }
|
||||||
.rotation-event .rotation-reason { color: var(--muted, #a59c8e); }
|
.rotation-event .rotation-reason { color: var(--muted, #a59c8e); }
|
||||||
|
|
||||||
.rd-status-line {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
.rd-dot {
|
|
||||||
width: 9px;
|
|
||||||
height: 9px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--muted);
|
|
||||||
flex: 0 0 auto;
|
|
||||||
}
|
|
||||||
.rd-dot.on {
|
|
||||||
background: #3fb950;
|
|
||||||
}
|
|
||||||
.rd-field {
|
|
||||||
display: grid;
|
|
||||||
gap: 5px;
|
|
||||||
}
|
|
||||||
.rd-field > label {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
.rd-field input,
|
|
||||||
.rd-field textarea {
|
|
||||||
width: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--text);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 8px 10px;
|
|
||||||
font-size: 13px;
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
.rd-field input:focus,
|
|
||||||
.rd-field textarea:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
.rd-field textarea {
|
|
||||||
resize: vertical;
|
|
||||||
min-height: 62px;
|
|
||||||
}
|
|
||||||
.rd-field-inline {
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
.rd-hint {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--muted);
|
|
||||||
line-height: 1.45;
|
|
||||||
}
|
|
||||||
.rd-seg {
|
|
||||||
display: flex;
|
|
||||||
width: fit-content;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.rd-seg button {
|
|
||||||
background: transparent;
|
|
||||||
color: var(--muted);
|
|
||||||
border: none;
|
|
||||||
padding: 7px 14px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.rd-seg button.active {
|
|
||||||
background: var(--accent);
|
|
||||||
color: #1a1206;
|
|
||||||
}
|
|
||||||
.rd-chips {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
.rd-chip {
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 3px 9px;
|
|
||||||
font-size: 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
.rd-chip:hover {
|
|
||||||
color: var(--text);
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
.rd-code {
|
|
||||||
font-family: ui-monospace, "Cascadia Code", monospace;
|
|
||||||
font-size: 12px;
|
|
||||||
word-break: break-all;
|
|
||||||
background: var(--bg);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 9px 10px;
|
|
||||||
max-height: 120px;
|
|
||||||
overflow-y: auto;
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -52,10 +52,6 @@ export const IPC_CHANNELS = {
|
|||||||
GET_TRACE_CONFIG: "app:get-trace-config",
|
GET_TRACE_CONFIG: "app:get-trace-config",
|
||||||
SET_TRACE_ENABLED: "app:set-trace-enabled",
|
SET_TRACE_ENABLED: "app:set-trace-enabled",
|
||||||
ROTATE_DEBUG_TOKEN: "app:rotate-debug-token",
|
ROTATE_DEBUG_TOKEN: "app:rotate-debug-token",
|
||||||
GET_REMOTE_DIAGNOSTICS: "app:get-remote-diagnostics",
|
|
||||||
ENABLE_REMOTE_DIAGNOSTICS: "app:enable-remote-diagnostics",
|
|
||||||
DISABLE_REMOTE_DIAGNOSTICS: "app:disable-remote-diagnostics",
|
|
||||||
ROTATE_REMOTE_DIAGNOSTICS_TOKEN: "app:rotate-remote-diagnostics-token",
|
|
||||||
OPEN_REALDEBRID_LOGIN: "app:open-realdebrid-login",
|
OPEN_REALDEBRID_LOGIN: "app:open-realdebrid-login",
|
||||||
OPEN_ALLDEBRID_LOGIN: "app:open-alldebrid-login",
|
OPEN_ALLDEBRID_LOGIN: "app:open-alldebrid-login",
|
||||||
IMPORT_BESTDEBRID_COOKIES: "app:import-bestdebrid-cookies",
|
IMPORT_BESTDEBRID_COOKIES: "app:import-bestdebrid-cookies",
|
||||||
|
|||||||
@ -1,24 +0,0 @@
|
|||||||
export function isMegaDebridResolveFailure(errorText: string): boolean {
|
|
||||||
const text = String(errorText || "").toLowerCase();
|
|
||||||
return /supprim/.test(text)
|
|
||||||
|| text.includes("introuvable")
|
|
||||||
|| text.includes("n'existe plus")
|
|
||||||
|| text.includes("n existe plus")
|
|
||||||
|| text.includes("fichier inexistant");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isMegaDebridTransientResolveFailure(errorText: string): boolean {
|
|
||||||
const text = String(errorText || "").toLowerCase();
|
|
||||||
return isMegaDebridResolveFailure(text)
|
|
||||||
|| text.includes("datei beim hoster gerade nicht abrufbar")
|
|
||||||
|| text.includes("datei beim hoster nicht gefunden");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function germanMegaDebridResolveReason(errorText: string): string {
|
|
||||||
const text = String(errorText || "").toLowerCase();
|
|
||||||
if (text.includes("datei beim hoster nicht gefunden")
|
|
||||||
|| text.includes("introuvable") || text.includes("fichier inexistant") || text.includes("n'existe plus") || text.includes("n existe plus")) {
|
|
||||||
return "Datei beim Hoster nicht gefunden";
|
|
||||||
}
|
|
||||||
return "Datei beim Hoster gerade nicht abrufbar";
|
|
||||||
}
|
|
||||||
@ -7,10 +7,8 @@ import type {
|
|||||||
DebridLinkHostLimitInfo,
|
DebridLinkHostLimitInfo,
|
||||||
DebridProvider,
|
DebridProvider,
|
||||||
DuplicatePolicy,
|
DuplicatePolicy,
|
||||||
EnableRemoteDiagnosticsInput,
|
|
||||||
HistoryEntry,
|
HistoryEntry,
|
||||||
PackagePriority,
|
PackagePriority,
|
||||||
RemoteDiagnosticsInfo,
|
|
||||||
RendererErrorReport,
|
RendererErrorReport,
|
||||||
SessionStats,
|
SessionStats,
|
||||||
StartConflictEntry,
|
StartConflictEntry,
|
||||||
@ -73,10 +71,6 @@ export interface ElectronApi {
|
|||||||
getTraceConfig: () => Promise<SupportTraceConfig>;
|
getTraceConfig: () => Promise<SupportTraceConfig>;
|
||||||
setTraceEnabled: (enabled: boolean, note?: string, durationMinutes?: number) => Promise<SupportTraceConfig>;
|
setTraceEnabled: (enabled: boolean, note?: string, durationMinutes?: number) => Promise<SupportTraceConfig>;
|
||||||
rotateDebugToken: () => Promise<{ path: string }>;
|
rotateDebugToken: () => Promise<{ path: string }>;
|
||||||
getRemoteDiagnostics: () => Promise<RemoteDiagnosticsInfo>;
|
|
||||||
enableRemoteDiagnostics: (input: EnableRemoteDiagnosticsInput) => Promise<RemoteDiagnosticsInfo>;
|
|
||||||
disableRemoteDiagnostics: () => Promise<RemoteDiagnosticsInfo>;
|
|
||||||
rotateRemoteDiagnosticsToken: () => Promise<RemoteDiagnosticsInfo>;
|
|
||||||
openRealDebridLogin: () => Promise<void>;
|
openRealDebridLogin: () => Promise<void>;
|
||||||
openAllDebridLogin: () => Promise<void>;
|
openAllDebridLogin: () => Promise<void>;
|
||||||
importBestDebridCookies: () => Promise<number>;
|
importBestDebridCookies: () => Promise<number>;
|
||||||
|
|||||||
@ -134,7 +134,6 @@ 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;
|
||||||
@ -538,30 +537,3 @@ export interface RendererErrorReport {
|
|||||||
column?: number;
|
column?: number;
|
||||||
componentStack?: string;
|
componentStack?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RemoteDiagnosticsStatus {
|
|
||||||
running: boolean;
|
|
||||||
host: string;
|
|
||||||
port: number;
|
|
||||||
hasToken: boolean;
|
|
||||||
localOnly: boolean;
|
|
||||||
allowlistCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RemoteDiagnosticsInfo {
|
|
||||||
status: RemoteDiagnosticsStatus;
|
|
||||||
code: string | null;
|
|
||||||
publicHost: string;
|
|
||||||
name: string;
|
|
||||||
allowlist: string[];
|
|
||||||
suggestedHosts: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EnableRemoteDiagnosticsInput {
|
|
||||||
hostMode: "local" | "network";
|
|
||||||
publicHost: string;
|
|
||||||
port?: number;
|
|
||||||
allowlist: string[];
|
|
||||||
name?: string;
|
|
||||||
rotateToken?: boolean;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,479 +0,0 @@
|
|||||||
# Autonomer Audit-Loop — Download/Fehler/Rotation (Goal 2026-06-17, 8h)
|
|
||||||
|
|
||||||
## GOAL-ANPASSUNG (Nutzer, nach Runde 8 + Synthese-Start): noch Runde 9 + 10, dann Goal BEENDEN.
|
|
||||||
Plan: Synthese-Pass (wrexz7mdf, Capstone R1-8) auswerten → Runde 9 (Provider-spezifische
|
|
||||||
Unrestrict-/Rotations-Pfade: Mega-Web-Fallback Session/Single-Flight, AllDebrid Host-Cooldown/
|
|
||||||
Rapidgator-Backoff, DebridLink-Key-Rotation, Passwort-Cache-Race) → Runde 10 (Concurrency/Locking +
|
|
||||||
Settings/Persistenz-Integritaet: Hybrid-Race, targetPath-Claim/Release-Races, Settings-Migration,
|
|
||||||
Backup/Restore, account-check) → Abschlussbericht + Ende. Fixes je rot-bewiesen, buendeln zu v1.7.220 falls HIGH/MED.
|
|
||||||
|
|
||||||
Disziplin: erst BELEGEN (Code-Zitat + konkretes Szenario), dann adversarisch verifizieren,
|
|
||||||
dann TDD-Fix. Kein Blind-Fix. Tests gruen + tsc=6 nach jeder Runde. Periodisch releasen.
|
|
||||||
|
|
||||||
## Runde 9 (Provider-spezifische Unrestrict-/Rotations-Pfade) — Mega-Web-Single-Flight + DebridLink-Key + Concurrency
|
|
||||||
Fokus: Mega-Web-Fallback Session/Single-Flight-Queue, DebridLink-Key-Rotation, In-Flight-Verteilung.
|
|
||||||
- **CONFIRMED HIGH (GEFIXT) MW-1 Selbst-Cooldown eines GESUNDEN, nur in der Queue wartenden Web-Accounts:**
|
|
||||||
Der Caller-Timeout (`DEFAULT_UNRESTRICT_TIMEOUT_MS`=60s) feuert, waehrend die zweite Umwandlung eines
|
|
||||||
Accounts noch SERIELL in der Mega-Web-Single-Flight-Queue (90s `QUEUE_WAIT_TIMEOUT_MS`) auf den laufenden
|
|
||||||
Vorgaenger wartet — also bevor ueberhaupt echte Arbeit begann. `raceWithAbort` warf bisher unbedingt
|
|
||||||
`aborted:mega-web`; `unrestrictViaWeb` (debrid.ts:1887) flachte JEDEN signal-aborted-Fall zu `aborted:debrid`
|
|
||||||
ab; die Rotation (debrid.ts:2072) wertete das via `/aborted/i && !/timeout/i` → `ranLongEnough`
|
|
||||||
(elapsedMs schliesst die Queue-Wartezeit ein, also >= 8s) → **120s Account-Cooldown auf einen voellig
|
|
||||||
gesunden Account**. Genau die vom Nutzer gemeldete „Tool sperrt sich selbst"-Klasse (Web-Variante).
|
|
||||||
Zweiteiliger Fix:
|
|
||||||
(1) `MegaWebFallback.runExclusive` (mega-web-fallback.ts) trackt `workStarted` und reicht eine
|
|
||||||
`abortErrorFactory` an `raceWithAbort`: abgebrochen-bevor-Arbeit-begann → `Mega-Web Queue-Timeout (…)`
|
|
||||||
(matcht `/queue.?timeout/i`), nur ein echter In-Arbeit-Abbruch bleibt `aborted:mega-web`.
|
|
||||||
(2) `unrestrictViaWeb` (debrid.ts:1887) bewahrt einen `/queue.?timeout/i`-klassifizierten lastError statt
|
|
||||||
ihn zu `aborted:debrid` zu plaetten → Rotation trifft die bestehende Queue-Timeout-Ausnahme
|
|
||||||
(classifyAccountFailure 2243 → cooldownMs 0) statt den Abbruch-Cooldown-Zweig.
|
|
||||||
Rot-bewiesen NICHT-vakuum (zwei Tests, beide per Temp-Revert rot verifiziert):
|
|
||||||
- Teil 1 (mega-web-fallback.test.ts): echtes `MegaWebFallback`, Queue mit langsamem Erst-Job belegt,
|
|
||||||
Zweit-Call WAEHREND in Queue abgebrochen → `rejects.toThrow(/queue.?timeout/i)` (ohne Fix: `aborted:mega-web`).
|
|
||||||
- Teil 2 (debrid.test.ts): durch `DebridService.unrestrictLink` mit `RD_MEGA_ABORT_MIN_RUN_MS=0` (besiegt die
|
|
||||||
Vakuum-Falle: der buggy Pfad WUERDE selbst bei Sofort-Abbruch cooldownen), Signal bricht WAEHREND des
|
|
||||||
Calls ab, megaWeb meldet Queue-Timeout → `getMegaDebridAccountCooldownState` bleibt null (ohne Fix: 120s
|
|
||||||
„Abbruch/Timeout nach 0s"). tsc=6, volle mega-web+debrid-Suite 103 gruen inkl. der bestehenden
|
|
||||||
Abbruch-Cooldown-Tests (echter langsamer Account cooled WEITERHIN — keine Regression).
|
|
||||||
- **Rotations-Verifikation (Advisor-Shippability-Diskriminator):** Nach dem No-Cooldown-Pfad retried der
|
|
||||||
Manager `unrestrictWithAccounts` frisch. Die Account-Wahl sortiert per `megaDebridInFlight`-TIEFE
|
|
||||||
(debrid.ts:1981-1986, least-busy zuerst). Invariante: jeder Belegt-Halter der MegaWebFallback-Queue
|
|
||||||
entspricht einem in-flight `client.unrestrictLink`, das `megaDebridInFlight` inkrementiert hat (2033,
|
|
||||||
Dekrement nur im finally 2152). Also sieht der Retry des queue-getimeouteten Links den saturierenden
|
|
||||||
Account bei Tiefe>=1 und rotiert auf einen freien — MW-1 ist NACHWEISLICH besser als das alte 120s-Lockout
|
|
||||||
(das einen nur-belegten gesunden Account sperrte). Kein Tight-Retry-Loop auf saturierter Queue, solange
|
|
||||||
irgendein Account frei ist; sind ALLE saturiert, queued der least-busy (unvermeidbar — keine freie Kapazitaet,
|
|
||||||
aber das alte Cooldown haette es via Faux-Park SCHLIMMER gemacht).
|
|
||||||
- **DOKUMENTIERT, nicht gefixt (LOW) DL-1 DebridLink-Key-Cooldown bei User-Cancel-Abbruch:** classifyKeyFailure
|
|
||||||
(debrid.ts:3118-3126) gibt fuer Text mit „aborted" (via isRetryableErrorText 597 + isTransport 3119)
|
|
||||||
`cooldownMs: 15_000` zurueck — auch bei einem SCHNELLEN User-Cancel (kein Min-Run-Gate wie bei Mega 2072).
|
|
||||||
Symptom: 15s Key-Cooldown nach Nutzer-Abbruch. Advisor (revidiert eigenen frueheren „clean fix"-Call):
|
|
||||||
classifyKeyFailure hat KEIN elapsedMs und DebridLink hat keinen Min-Run-Knopf-Aequivalent zu
|
|
||||||
`getMegaDebridAbortMinRunMs()`. Der treue Fix (Caller-Gate bei 2787 spiegeln) braucht entweder cross-Provider-
|
|
||||||
Wiederverwendung des Mega-Knopfes oder einen neuen DebridLink-Knopf = neue Oberflaeche im Live-Hot-Path fuer
|
|
||||||
einen LOW-Bug (Symptom: 15s nach User-Cancel). Klart die eigene „clean + null-Regression"-Schwelle NICHT →
|
|
||||||
dokumentiert, nicht gefixt.
|
|
||||||
- **DOKUMENTIERT, nicht gefixt (MED) DL-CONCURRENCY-PILEUP:** Bei gleichzeitigen Umwandlungen, die ALLE
|
|
||||||
Mega-Accounts saturieren, queued der least-busy-Account weitere Links seriell (per-Account-Single-Flight) →
|
|
||||||
Wartezeiten stapeln sich. MW-1 entfernt die FRUEHERE versehentliche Backpressure (der falsche 120s-Cooldown
|
|
||||||
wirkte als grobe Ratenbegrenzung), routet aber korrekt per In-Flight-Tiefe um belegte Accounts herum statt sie
|
|
||||||
faelschlich zu sperren — strikt besser. Echter Fix (In-Flight-Tiefen-Spread groesser ziehen / globale
|
|
||||||
Web-Parallelitaetsgrenze) = groessere Aenderung, 4.-Bug-Risiko auf Live-Server → deferred. MW-1 reduziert das
|
|
||||||
Pileup-in-Park-Risiko bereits (kein Faux-Lockout merely-busy Accounts).
|
|
||||||
|
|
||||||
## Runde 10 (Concurrency/Locking + Settings/Persistenz-Integritaet) — LETZTE RUNDE (Nutzer: „nach der Runde ist Schluss")
|
|
||||||
4 Finder (hybrid/targetPath-Races, Slot-Accounting, Settings-Migration/Persistenz, Backup-Restore/account-check)
|
|
||||||
→ adversarisch 3 Lenses → Synthese. Workflow wwyqsdrf9: 2 Finder (slot-accounting, settings-persistence) starben
|
|
||||||
an Stream-Idle-Timeout (Riesen-Dateien) → 0/7 confirmed war NUR fuer 2 von 4 Dimensionen ehrlich. KEIN stilles
|
|
||||||
Coverage-Loch akzeptiert → Re-Run wwyqsdrf9b (wfmu4pw2n) mit engerem Scope (Grep-dann-Region statt Ganzdatei).
|
|
||||||
- **Dim hybrid/targetPath + backup/account-check (wwyqsdrf9): 7 Kandidaten, ALLE refutiert (>=2/3), quell-reverifiziert.**
|
|
||||||
Kern-Invarianten halten: synchrones `claimTargetPath` (dl-mgr:6257) = nie zwei Items auf einem Pfad → alle drei
|
|
||||||
Hybrid-„Races" kollabieren zu Sub-Sekunden-Redundanzarbeit, kein Collision/Korruption; jeder Loss-Ausgang von
|
|
||||||
CRC/Extraction-Fail re-queued (`hybridExtractRequeue.add` 11739). Restore ist fail-closed (encrypted-or-reject,
|
|
||||||
Binaerheader „MDD1" wirft in JSON.parse vor normalizeSettings). account-check `valid:false` ist reiner
|
|
||||||
Renderer-Badge (kein src/main liest ihn als Gate; Disable laeuft ueber megaDebridDisabledAccountIds).
|
|
||||||
- **CONFIRMED MED (DOKUMENTIERT, nicht gefixt) BYTE-DROP-RETRY-1 (wfmu4pw2n):** Integrity-/too-small-/tiny-Retry
|
|
||||||
doppelzaehlt eine volle Datei in die Byte-Statistik. `dropItemContribution` (6247) loescht den
|
|
||||||
itemContributedBytes-Eintrag OHNE von session.totalDownloadedBytes abzuziehen (eigener Kommentar: „retry path
|
|
||||||
subtracts on its own"), aber die EINZIGE Subtraktion (9991-9997) liest genau diesen geloeschten Eintrag → 0 →
|
|
||||||
Subtraktion tot → Re-Download addiert N nochmal → (k+1)*fileSize nach k Integrity-Fails. enableIntegrityCheck
|
|
||||||
default true → organisch. Verdict isReal (CORRECTNESS+REPRO), aber BLAST-RADIUS = NUR Statistik: kein
|
|
||||||
Slot/Admission/Semaphore liest diese Counter (grep-belegt), Session-Counter self-healen bei jedem Neustart,
|
|
||||||
nur persistiertes totalDownloadedAllTime + avg-Speed bleiben dauerhaft inflationiert = kosmetisch. NICHT clean
|
|
||||||
(dropItemContribution ueber 26 Call-Sites ueberladen; ~10 im Retry-Bereich; ein 3-Site-Patch liefert
|
|
||||||
partielles/inkonsistentes Accounting = arguably schlimmer; Guard-Test dl-mgr.test.ts:6152 sperrt die
|
|
||||||
Completion-Removal-Semantik) → DOKUMENTIERT mit Rezept (dropItemContribution splitten in ForCompletion/ForRetry
|
|
||||||
nach Klassifikation aller Retry-Sites re-download-vs-terminal). Kosmetisch + nicht-clean → klart Live-Schwelle nicht.
|
|
||||||
- **CONFIRMED MED (GEFIXT) SET-MIG-01 (wfmu4pw2n):** Pre-v1.6.90-Config-Migration tot → Mega-Debrid wird beim
|
|
||||||
ersten Settings-Panel-Save still aus der Provider-Reihenfolge demotet. readSettingsFile (storage.ts:633) merged
|
|
||||||
`{...defaultSettings(), ...parsed}`; defaultSettings setzt megaDebridApiEnabled/WebEnabled:false (constants.ts:50-51)
|
|
||||||
BEVOR normalizeSettings laeuft → der `=== undefined`-Migrationszweig (355-360) ist tot auf dem Disk-Pfad (Git:
|
|
||||||
tot seit v1.6.90/0003d78). Renderer (App.tsx:447-494) gated Mega-Inklusion am false-Flag → persistDraftSettings
|
|
||||||
schreibt eine Mega-bereinigte providerOrder zurueck. Fix: reine `migrateLegacyMegaEnableFlags(parsed)` in
|
|
||||||
readSettingsFile — seedet apiEnabled=preferApi/webEnabled=!preferApi NUR wenn BEIDE Flags im RAW-parsed fehlen
|
|
||||||
UND Creds da sind (Creds-Erkennung wie normalizeSettings: asText(login)&&asText(password)). Lokalisiert, keine
|
|
||||||
normalizeSettings-Signatur-Aenderung, getypt (kein Cast). EHRLICHER Scope (Advisor): rettet NUR Legacy-Configs,
|
|
||||||
die seit dem Upgrade noch NICHT ueber das Settings-Panel neu gespeichert wurden (ein Post-Upgrade-Save schreibt
|
|
||||||
present-false → Trigger feuert nicht mehr) — schmales historisches Fenster, NICHT „rettet die Live-Mega dieses
|
|
||||||
Nutzers". HARTE Grenze (Advisor): Trigger bleibt absent-both; present-false NICHT anfassen (= bewusst-deaktiviert,
|
|
||||||
ununterscheidbar → das geparkte entscheidungen-offen #2-Migrationsrisiko). Rot-bewiesen NICHT-vakuum (Assertion NUR
|
|
||||||
auf den geladenen Flags — providerOrder demotet backend-seitig nicht = waere vakuum; via Temp-Revert rot bestaetigt:
|
|
||||||
apiEnabled true→false). Boundary-Test (bewusst-deaktiviert bleibt false + ohne Creds keine Migration) bleibt beim
|
|
||||||
Revert gruen = unabhaengig. Volle Suite 890 gruen, tsc=6.
|
|
||||||
- **REFUTED (wfmu4pw2n) SET-PERSIST-02:** Residual-Lost-Update nach R2-Generations-Guard — der einzige verlorene
|
|
||||||
Payload ist totalRuntimeAllTimeMs (`<=`-Ratchet, naechste Session re-derived) + contrived Sub-ms-Race der die
|
|
||||||
Prozess-Teardown gewinnen muss → self-healing kosmetisch, nicht erreichbar.
|
|
||||||
- **Coherence-Verdikt:** Concurrency-Accounting + Settings-Persistenz sind kohaerent. Byte-Counter sind
|
|
||||||
telemetry-only, voll entkoppelt von Admission/Slot (grep-belegt) → BYTE-DROP kann nicht stranden/freezen/
|
|
||||||
ueber-admiten. Persistenz kohaerent bis auf EINE benannte Inkohaerenz: Backend normalizeConfiguredProvider
|
|
||||||
(storage.ts:144) mapt „megadebrid"→konkret unabhaengig der Flags, Renderer gated auf dem Flag = Wurzel von
|
|
||||||
SET-MIG-01 (jetzt am Loader gefixt).
|
|
||||||
- **EHRLICHE Coverage-Luecke (vom Audit selbst aufgedeckt, R10-HYB-3):** Die in project_pending genannte
|
|
||||||
Passwort-Cache-Race liegt NICHT in download-manager.ts (dort kein passwordCache/resolvePassword), sondern in
|
|
||||||
src/main/extractor.ts — ausserhalb des Round-10-Scopes. Bewusst NICHT in dieser letzten Runde auditiert (Nutzer:
|
|
||||||
„nach der Runde ist Schluss"); als das eine identifizierte, un-auditierte Concurrency-Seam fuer eine etwaige
|
|
||||||
kuenftige Runde dokumentiert statt still fallengelassen.
|
|
||||||
|
|
||||||
## GOAL-ABSCHLUSS
|
|
||||||
Runde 9 + 10 erledigt (Nutzer-Anpassung). Release v1.7.220 buendelt: 85c8d6b (Synthese C1/C2/RANGE1-Haertung) +
|
|
||||||
MW-1 (HIGH, Web-Selbstcooldown) + SET-MIG-01 (MED, Legacy-Mega-Demotion). Gitea + GitHub-Mirror MIT .exe (4 Assets).
|
|
||||||
Dokumentiert-nicht-gefixt: DL-1, DL-CONCURRENCY-PILEUP, BYTE-DROP-RETRY-1, extractor.ts-Passwort-Cache-Seam.
|
|
||||||
Beim Nutzer (nicht autonom): 60s-Failover-Kappung + gespiegelter Mega-API/Web-Schalter (entscheidungen-offen.md).
|
|
||||||
|
|
||||||
## Nutzer-Nachforderung (nach Goal-Abschluss): die 3 dokumentierten Funde DOCH umsetzen → v1.7.221
|
|
||||||
Nutzer: "dann mach das beides erstmal" (BYTE-DROP-RETRY-1 + DL-1 + DL-CONCURRENCY-PILEUP). Nicht relitigiert OB,
|
|
||||||
nur WIE (Advisor-gefuehrt, je rot-bewiesen, je full-suite gruen + tsc=6 single-pass).
|
|
||||||
- **BYTE-DROP-RETRY-1 (MED) GEFIXT.** Scope-Disziplin (Advisor): NUR Session-Counter + totalDownloadedAllTime,
|
|
||||||
NICHT recordProviderDownloadedBytes/providerDailyUsageBytes (das gated isProviderDailyLimited = Verhalten,
|
|
||||||
und ist nicht provider-keyed → naive Subtraktion wuerde den falschen Provider-Bucket korrumpieren → bewusst
|
|
||||||
ausgeklammert). Mechanismus (a): an den 3 bestaetigten rm-dann-frisch-Sites (Integrity 8979, too-small 9020,
|
|
||||||
tiny 10486) den `dropItemContribution`-Aufruf ENTFERNT → der itemContributedBytes-Eintrag ueberlebt → die
|
|
||||||
bestehende writeMode-"w"-Reconciliation (9991) subtrahiert ihn korrekt (selbst-korrigierend nach writeMode,
|
|
||||||
Append undercounted nicht). Plus: am selben Punkt (9991) `totalDownloadedAllTime -= previouslyContributed`
|
|
||||||
ergaenzt (spiegelt den Add bei 10311; All-Time wurde NIE subtrahiert → doppelte bei JEDEM frischen Re-Download,
|
|
||||||
nicht nur den dropItemContribution-Pfaden). KEINE 23-Site-Reklassifikation (Advisor: Provider-Usage off-limits
|
|
||||||
→ jede Restfehlklassifikation ist bounded Telemetrie). Guard-Test dl-mgr.test.ts:6152 (Completion-Removal behaelt
|
|
||||||
Session-Total) bleibt gruen. Rot-bewiesen NICHT-vakuum, BEIDE Beine einzeln: Integration durch echten
|
|
||||||
Integrity-Fail-Retry (.md5-Manifest, lokaler HTTP-Server serviert wrong-dann-correct), Assert session==1x UND
|
|
||||||
allTime==1x; Bein 1 (All-Time-Zeile raus) → allTime rot (2x) session gruen; Bein 2 (dropItemContribution zurueck)
|
|
||||||
→ session rot (2x).
|
|
||||||
- **DL-1 (LOW) GEFIXT.** Advisor revidierte den frueheren "braucht neue Oberflaeche"-Call: am Rotations-Catch (2789)
|
|
||||||
ist elapsedMs bereits da → Mega-Gate (2072) gespiegelt. abort-ohne-timeout + elapsedMs < getMegaDebridAbortMinRunMs()
|
|
||||||
→ KEIN Key-Cooldown (User-Cancel bestraft den Key nicht); ran-long-enough → DEBRID_LINK_KEY_COOLDOWN_MS (120s)
|
|
||||||
damit der Retry rotiert; throw bailt die Rotation (verhindert auch die zuvor moegliche Transport-Kaskade ueber
|
|
||||||
mehrere Keys bei aborted-Signal). Neuer Test-Getter getDebridLinkKeyCooldownStateForTests. Rot-bewiesen
|
|
||||||
(quick-cancel → null; ohne Fix 15s gesetzt; long-abort → >60s, ohne Fix 15s).
|
|
||||||
- **DL-CONCURRENCY-PILEUP (MED): untersucht → BEREITS STRUKTURELL GELOEST, kein Eingriff (Nutzer-Entscheidung
|
|
||||||
"Akzeptieren").** getSerializedValidatingLimit("megadebrid-web") = Anzahl nutzbarer (nicht-gecoolter) Accounts
|
|
||||||
(dl-mgr 8047-8055); shouldDelayStartForItem erzwingt es in der Kandidatenwahl (8526) → Ueberschuss-Konvertierungen
|
|
||||||
warten als "queued" im Scheduler, NICHT in den per-Account-Single-Flight-Queues; Depth-Spread (debrid 1981-1986)
|
|
||||||
verteilt die erlaubten 1-pro-Account. MW-1 haelt usableAccounts (= das Limit) korrekt hoch. Ein weiterer
|
|
||||||
Scheduler-Eingriff = redundant ODER schaedlich (Ueber-Admission = echter Pileup) → Advisor-4.-Bug-Risiko. Dem
|
|
||||||
Nutzer vorgelegt (AskUserQuestion) → "Akzeptieren, kein Eingriff".
|
|
||||||
|
|
||||||
## Runde 1 (laeuft)
|
|
||||||
- Discover+Verify-Workflow ueber 7 Subsysteme (scheduler-slots, unrestrict-retry, mega-rotation,
|
|
||||||
classify-cooldown, mega-web-token, provider-chain-timeout, account-availability).
|
|
||||||
|
|
||||||
### Meine unabhaengigen Verdachtsfaelle (Cross-Check gegen Workflow)
|
|
||||||
1. **Web-Selbst-Cooldown (Analog zum API-214-Bug, HOCH):** Caller-Timeout = 60s
|
|
||||||
(`DEFAULT_UNRESTRICT_TIMEOUT_MS`, download-manager 114) umschliesst die GANZE Kette.
|
|
||||||
Mega-Web braucht legitim laenger (per-Account-Queue bis 90s + Login + Generate).
|
|
||||||
Feuert die 60s nach >=8s (`MEGA_DEBRID_ABORT_MIN_RUN_MS_DEFAULT`=8000), setzt die
|
|
||||||
Rotation `aborted:debrid` → 120s Account-Cooldown (debrid.ts ~2037-2048), obwohl der
|
|
||||||
Account GESUND ist — die App hat aufgegeben. → Kaskade ueber Accounts. Live im jf.zip
|
|
||||||
belegt: `Mega-Debrid Web | TIMEOUT_COOLDOWN | reason=aborted:debrid | cooldownSec=120`.
|
|
||||||
Fix-Kandidat: (a) per-Provider-Timeout statt globaler 60s; und/oder (b) Caller-Timeout-
|
|
||||||
Abort NICHT als Account-Cooldown werten (EMA-Demotion regelt langsame Accounts bereits),
|
|
||||||
oder nur sehr kurz.
|
|
||||||
2. **Globaler 60s-Timeout kappt Failover (Advisor-bewiesen, HOCH):** download-manager 8759
|
|
||||||
`AbortSignal.any([cancel, timeout])` → bei Provider1-Verbrauch des Budgets abortet das
|
|
||||||
Signal → debrid.ts 3805 `signal.aborted` → throw, kein nextProvider. Fix: per-Provider-
|
|
||||||
AbortSignal.timeout, Stop nur bei USER-Cancel.
|
|
||||||
3. **Exponential-Backoff bis 120s** (generic unrestrict retry) — Item sitzt bis 2 min.
|
|
||||||
Pruefen ob fuer haeufige transiente Faelle zu lang.
|
|
||||||
|
|
||||||
## Bestaetigte Bugs (Workflow R1: 14 confirmed / 11 refuted) — priorisiert
|
|
||||||
- [IN ARBEIT] #1 HIGH Scheduler-Freeze: findNextQueuedItem ohne activeTasks-Guard → synchroner
|
|
||||||
Admission-Loop dreht endlos wenn ein reset/overwrite-Item noch im activeTasks parkt (non-abort-
|
|
||||||
observing await, z.B. Integrity-Check). Fix: `if (this.activeTasks.has(itemId)) continue;`. TDD-Test
|
|
||||||
(Freeze-Repro mit non-abort Mock + resetItems) geschrieben.
|
|
||||||
- #2/#3 MED mega_debrid_cooldown:<ms> Delay verworfen — kein Parser (nur debrid_link_cooldown). Fix:
|
|
||||||
Parser fuer beide Praefixe, queueRetry mit echtem delayMs. (Erklaert Rapid-Retry-trotz-Cooldown im jf.zip.)
|
|
||||||
- #7/#8 MED Mega per-Account Daily-Usage wird am Tagesgrenze NIE resettet → Accounts faelschlich "am
|
|
||||||
Limit" → schrumpft MEIN neues serialized-limit. Fix: megaDebridAccountDailyUsageBytes in
|
|
||||||
ensureProviderDailyUsageFresh resetten.
|
|
||||||
- #4 MED transiente leere Web-Antwort → permanenter until-restart-Park (limitSignal vom generischen
|
|
||||||
"antwort leer"). Fix: limitSignal nur vom echten Daily-Limit (NO_SERVER_RE).
|
|
||||||
- #5 MED Web echte Bad-Credentials erreichen invalid-Branch nicht (werden ewig retried). Fix: echte
|
|
||||||
Web-Login-Fehlerphrasen in invalid-Branch.
|
|
||||||
- #6 MED onefichier/ddownload-Routing ignoriert autoProviderFallback=off. Fix: Guard in catch.
|
|
||||||
- #14 LOW Regex-Ordering classify: quota-Branch shadowt rate_limit. Fix: rate_limit vor quota.
|
|
||||||
- #9 LOW overwrite wipet frisch geclaimten targetPath via altem .finally.
|
|
||||||
- #10 LOW HTTP416 shared counter mit genericErrorRetries.
|
|
||||||
- #11 LOW fresh-retry preempt typed transient handlers.
|
|
||||||
- #12 LOW 15-failure-shelve + shared counters → mehr Retries als retryLimit.
|
|
||||||
- #13 LOW self-poison: queue-wait zaehlt zu elapsedMs → abort-cooldown (Analog zu meinem Web-Verdacht #1).
|
|
||||||
|
|
||||||
## Refutiert / Nicht-Bug (11) — nicht anfassen
|
|
||||||
providerStartReservations dead-state; debrid_link_cooldown cleanup; supprimé-fallthrough; mega-web 180s
|
|
||||||
aborts whole rotation; EMA-removed-premise; quota-no-park asymmetry; connectApi single-flight cancel-couple;
|
|
||||||
per-account queue chain-break (NON-BUG); mega-web slot-hold (NON-BUG); provider abort-vs-timeout heuristic;
|
|
||||||
daily-limit aggregate early-exit.
|
|
||||||
|
|
||||||
## Fixes (TDD, mit Test + Release)
|
|
||||||
### Batch 1 → v1.7.215 (Suite laeuft)
|
|
||||||
- [x] #1 HIGH Scheduler-Freeze: `findNextQueuedItem` activeTasks-Guard. Repro-Test (ohne Fix haengt der
|
|
||||||
Event-Loop so hart, dass nicht mal vitest-Timeout feuert = Freeze empirisch bewiesen). Mit Fix 288ms.
|
|
||||||
- [x] #2/#3 MED parseMegaDebridCooldownRetry (export) + Handler VOR transient/generic branch → Item wartet
|
|
||||||
den ECHTEN Cooldown (min ueber alle Accounts) statt 5s-Busy-Loop. 5 Parser-Tests.
|
|
||||||
- [x] #7/#8 MED megaDebridAccountDailyUsageBytes Reset in ensureProviderDailyUsageFresh (laeuft via
|
|
||||||
getSnapshot, also auch im Stall). Test: Tagesgrenze → leer.
|
|
||||||
- [x] #14 LOW rate_limit-Branch VOR quota (quota matchte "limit" in "rate limit"). Test: rate_limit-Kategorie.
|
|
||||||
- [deferred] #4 empty-response→until-restart-park: 3-consecutive-streak ist reale Mitigation gegen transiente
|
|
||||||
Blips; Mega-empty-Semantik nicht sicher verifizierbar → kein Blind-Change.
|
|
||||||
|
|
||||||
### Strategie-Update (LIVE-Server, Advisor-bestaetigt)
|
|
||||||
- LOW-Fix-Schwelle HOCH: nur fixen bei NULL plausibler Regression UND einem Test der OHNE Fix rot ist.
|
|
||||||
Sonst dokumentieren ("gefunden & charakterisiert" ist valides Audit-Ergebnis). Server laeuft live,
|
|
||||||
auto-update, ~1 TB/h → jede unnoetige Verhaltensaenderung = Risiko.
|
|
||||||
- Releases BUENDELN (alle 2-3 Runden / Roll-up), nicht pro Fix. Weniger Update-Churn auf dem Live-Server.
|
|
||||||
- #5 NICHT raten: conversion.log faengt den echten Web-Login-Fehler-String schon (web-queue-Phase-Detail).
|
|
||||||
Aus naechstem Bundle ernten, dann erst invalid-Phrasen ergaenzen. Kein Phrasen-Halluzinieren.
|
|
||||||
- #13 defer: Web ist seit v1.7.214 nur noch Fallback (API-first), Selbstcooldown trifft kaum mehr;
|
|
||||||
braucht workMs-Threading → groesserer Eingriff, nicht LOW-billig.
|
|
||||||
- Vor Runde 4-5: SYNTHESE-Pass — ist Retry/Cooldown/Rotation END-TO-END kohaerent selbstheilend?
|
|
||||||
|
|
||||||
## Runde 3 (Failover/Reconnect/Cooldown-Lifecycle/Scheduler/IPC-Toggle/Updater) — Workflow wcwztx7e9
|
|
||||||
7 confirmed / 1 refuted. provider-failover-Finder crashte (Socket) → diese Dimension via MEINER
|
|
||||||
unabhaengigen Code-Verifikation abgedeckt (60s-Timeout kappt Failover, debrid.ts 3845 + dl-mgr 8814).
|
|
||||||
|
|
||||||
### Batch 3 → v1.7.217 (GEFIXT, je rot-bewiesener Test)
|
|
||||||
- [x] #R3-1 HIGH (3/3) Self-Cooldown bis Neustart — DER vom Nutzer gemeldete „Tool sperrt sich selbst".
|
|
||||||
(a) limitSignal aus MEGA_DEBRID_NO_SERVER_RE-Zweig entfernt (Hoster-Problem != Account-Limit),
|
|
||||||
(b) until-restart-Park laeuft jetzt zum Tagesreset (lokale Mitternacht) ab statt MAX_SAFE_INTEGER →
|
|
||||||
heilt <=24h selbst. Texte „bis Neustart"→„bis zum Tagesreset". Commit 76b3f99.
|
|
||||||
- [x] #R3-5 HIGH (3/3) Fehlgeschlagenes Update → Queue-Stillstand bis Neustart. runInstallWithResume()
|
|
||||||
(neue reine Funktion) resumt bei started:false UND throw. Commit dfd1926.
|
|
||||||
- [x] #R3-3 MED (3/3) Account-Edit ueberschreibt megaDebridPreferApi. Hardcode entfernt → ...settings
|
|
||||||
reicht Nutzerwahl durch. Commit 1e04b7b.
|
|
||||||
|
|
||||||
### Dokumentiert / NICHT autonom gefixt (Advisor-Disziplin)
|
|
||||||
- #R3-2 HIGH (2/3, UMSTRITTEN) Mega API/Web-Account-Zeilen teilen EINE login-only Enable-Flag →
|
|
||||||
Toggle spiegelt sich (= Nutzer-Report „API aus → Web an"). KEIN Auto-Fix: Daten-Modell-Fix braucht
|
|
||||||
Settings-Migration (kann deaktivierte Accounts re-aktivieren), UI-Collapse = Layout-Redesign (Nutzer
|
|
||||||
UI-Geschmack-sensibel). → DEM NUTZER vorlegen: gemeinsamer Schalter vs. unabhaengige pro-Modus-Flags.
|
|
||||||
- 60s-Failover-Kappung (HIGH, mein Fund, Finder gecrasht) — debrid.ts 3845 wertet JEDEN combined-signal-
|
|
||||||
Abort (cancel ODER 60s-Timeout) als kein-Failover; langsamer Provider1 hungert Provider2 aus, auch ueber
|
|
||||||
Retries. Post-214 (API-first) groesstenteils latent. → eigene Runde: gecrashten Finder ERST neu laufen
|
|
||||||
lassen (unabhaengige Verifikation fehlt), dann per-Provider-Timeout-Design mit Advisor. NICHT in 217.
|
|
||||||
- #R3-6 MED (3/3) Update-Mirror-Failover feuert nie (nur Gitea). NICHT fixen: aendert den Update-Fetch-Pfad
|
|
||||||
= der Kanal, ueber den jeder Fix den Nutzer erreicht; faellt heute sicher aus (App behaelt alte Version).
|
|
||||||
- #R3-4 LOW (2/3) providerPrimary kann auf disabled Mega normalisieren — self-heilt zur Laufzeit. Belassen
|
|
||||||
(Refuter: Fix riskanter als Bug — schreibt persistierte Absicht um).
|
|
||||||
- #R3-7 LOW (3/3) Update-Integritaet hash-only, kein Authenticode — ehrliche Grenze, faellt sicher aus.
|
|
||||||
- REFUTIERT (0/3): all-accounts-parked wirft plain error ohne cooldown-retry-Token.
|
|
||||||
|
|
||||||
## Runde 4 (Failover-Routing-Slice + Entscheidungs-Doku)
|
|
||||||
Follow-on aus R3-Failover-Fund. Advisor-Disziplin: nur die SICHERE Scheibe autonom, der
|
|
||||||
Produkt-Tradeoff geht an den Nutzer (tasks/entscheidungen-offen.md).
|
|
||||||
|
|
||||||
### Autonom gefixt (TDD, rot-bewiesen)
|
|
||||||
- [x] MED Failover-Routing: Manager berechnete bei Provider-Cooldown (>=20 Fehler in Folge,
|
|
||||||
auto-Fallback an) einen Ersatz-Provider (`findFallbackProviderNotInCooldown`), WARF ihn aber
|
|
||||||
weg — `unrestrictLink(item.url, signal)` ohne Hint → debrid.ts baut `order` neu aus
|
|
||||||
providerOrder und fuehrt WIEDER mit dem ausgebremsten Provider1 an (wahrsch. 60s-Timeout
|
|
||||||
verschwendet). Fix: reine `leadProviderChainWith(order, preferred)` (debrid.ts, export) +
|
|
||||||
4. optionaler Param `preferredLeadProvider` an `unrestrictLink`; Manager reicht den Ersatz
|
|
||||||
durch (dl-mgr 8772/8828). REORDER nicht SKIP → ausgebremster Provider bleibt als letzter
|
|
||||||
Notnagel in der Kette, kein Stranding. All-cooled-Fall erreicht `unrestrictLink` gar nicht
|
|
||||||
(else-Zweig queueRetry'd). Tests: integration (control=realdebrid, preferred=debridlink) +
|
|
||||||
3 reine Helper-Tests (null→unchanged, in-order→leads+keeps-all, not-in-order→unchanged).
|
|
||||||
Suite 875 gruen, tsc=6. Commit folgt; HALTEN fuer Roll-up-Release (kein HIGH/dringend).
|
|
||||||
|
|
||||||
### An den Nutzer vorgelegt (NICHT autonom) → tasks/entscheidungen-offen.md
|
|
||||||
- 60s-Failover-Kappung (HIGH): A) pro-Provider-Timeout (Failover immer, aber bis 3×60s
|
|
||||||
Worst-Case, Drehregler ueber Pro-Provider-Wert) vs B) globales Budget mit Failover-Reserve
|
|
||||||
(langsamer Provider1 frueher abgeschnitten). Produkt-Tradeoff = Nutzerwahl. Post-214 weitgehend latent.
|
|
||||||
- Gespiegelter Mega API/Web-Schalter (HIGH, #R3-2): gemeinsamer Schalter vs unabhaengige
|
|
||||||
pro-Modus-Flags (Migration + UI-Redesign noetig, Nutzer UI-sensibel).
|
|
||||||
|
|
||||||
### SYNTHESE-Pass (Cross-Layer: Manager-Cooldown-Keys vs Debrid Account/Daily-Park) — Workflow wrfxpjudj
|
|
||||||
2 Kandidaten, 1 confirmed (3/3), 1 refuted (0/3). Advisor-Hypothese (Key-Mismatch) WIDERLEGT.
|
|
||||||
- **REFUTED (0/3) KSM-1 Key-Mismatch:** normalizeProviderOrder (storage.ts:144/411) speichert IMMER
|
|
||||||
den aufgeloesten 'megadebrid-api'/'-web', NIE den virtuellen 'megadebrid'. Also fallen
|
|
||||||
Cooldown-WRITE (recordProviderFailure), CHECK (getProviderFailureKeyForItem), CLEAR und READ
|
|
||||||
(findFallbackProviderNotInCooldown) auf denselben aufgeloesten Key → KEINE Divergenz. Der
|
|
||||||
986fbab-Routing-Fix ist fuer den Mega-Fall nachweislich sicher (No-Stranding haelt).
|
|
||||||
- **CONFIRMED (3/3) LOW MEGA-UNTILRESTART-MISCLASS (GEFIXT):** Der untilRestart-Park-Throw
|
|
||||||
(debrid.ts:2167) trug KEINEN Maschinen-Token → Manager-Catch klassifiziert ihn als generischen
|
|
||||||
Unrestrict-Fehler (isUnrestrictFailure matcht "mega_debrid") → Retry alle ~2min den ganzen Tag
|
|
||||||
+ recordProviderFailure (Circuit-Breaker-Verschmutzung), statt einmal bis Tagesreset zu parken.
|
|
||||||
Default-Config (retryLimit=0=∞) self-heilt um Mitternacht, KEIN Stranding. Genau die
|
|
||||||
Round-3-untilRestart-Park-Absicht, die hier unterlaufen wurde. Predates 986fbab.
|
|
||||||
Fix: debrid.ts emittiert jetzt `mega_debrid_reset_park:<msBisReset>:` (megaDebridDailyParkExpiry);
|
|
||||||
neue reine parseMegaDebridResetPark (kein 15min-Clamp, 26h-Cap) + Catch-Branch VOR der
|
|
||||||
Cooldown-Klassifikation queued bis Tagesreset OHNE recordProviderFailure. Rot-bewiesen
|
|
||||||
(Parser-Tests + debrid-Token-Assertion). tsc=6.
|
|
||||||
- **End-to-end-Verdikt:** Retry/Cooldown/Rotation/Failover ist unter Default-Config kohaerent
|
|
||||||
self-heilend (jeder Park hat Zeitgrenze + Auto-Clear). Einzige Rest-Inkohaerenz war die
|
|
||||||
Layering-Naht oben (in-memory untilRestart-Park unsichtbar fuer getAvailableMegaDebridAccounts
|
|
||||||
+ isProviderDailyLimited) — mit dem konservativen String-Klassifikations-Fix geschlossen.
|
|
||||||
Der breitere Fix (Selektierbarkeit cooldown-aware machen) bewusst NICHT gemacht (groesserer
|
|
||||||
Blast-Radius auf Live-Server).
|
|
||||||
|
|
||||||
## Boot-Verifikation (Advisor-Punkt: Suite gruen != App bootet)
|
|
||||||
Smoke-Test des gebauten 1.7.218-Binaries (release\win-unpacked, throwaway userData, leere Session):
|
|
||||||
laeuft >10s stabil, spawnt die normalen 4 Electron-Prozesse (main+GPU+renderer+utility), schreibt
|
|
||||||
userData/runtime → BOOTET. Boot-Pfad (main.ts-Boot, app-controller-Init, Window/IPC-Registrierung)
|
|
||||||
von diesem Audit NICHT angefasst; alle editierten Methoden sind Download-Zeit (von der 884er-Suite
|
|
||||||
importiert+konstruiert). Risiko Startup-Regression empirisch ausgeschlossen.
|
|
||||||
|
|
||||||
## Advisor-Leitlinie (Stand Runde 8)
|
|
||||||
- Nach Runde 8 KEINE weitere Discovery-Runde, sondern ein SYNTHESE/Regressions-Pass ueber den
|
|
||||||
KUMULATIVEN Diff (Interaktion der 6 gestagten/releasten Fixes) — Confirmed-Yield faellt (R5:0/R6:1/R7:1),
|
|
||||||
Risiko ist jetzt Fix-Interaktion, nicht unentdeckte Bugs.
|
|
||||||
- Advisor NICHT auf Kadenz pollen — nur bei echter Ship/Fix-Entscheidung mit neuer Info.
|
|
||||||
- Die 2 HIGH Produkt/UI-Entscheidungen bleiben beim Nutzer (nicht autonom shippen).
|
|
||||||
|
|
||||||
## Release-Status: v1.7.219 RELEASED (Gitea 6ae9f5d + GitHub-Mirror df3670a)
|
|
||||||
Roll-up Runde 7+8: be15419 MED VP-1 dt.-Tonspur + eacd0c9 HIGH RANGE-1 Silent-Corruption + REWIND-TRUNCATE.
|
|
||||||
HIGH rechtfertigt Release. Advisor explizit cleared (vor Implementierung konsultiert, Check A verifiziert).
|
|
||||||
Mirror kuratiert ohne CLAUDE.md/tasks/ (Leak-Check sauber). NAECHSTER SCHRITT (Advisor): Synthese/Regressions-
|
|
||||||
Pass ueber kumulativen Diff aller Session-Fixes (Interaktion), KEINE weitere Discovery-Runde.
|
|
||||||
|
|
||||||
## Release-Status: v1.7.218 RELEASED (Gitea bbb9355 + GitHub-Mirror 94d143b)
|
|
||||||
Roll-up Runde 4+5+6: 986fbab MED Provider-Cooldown-Routing + 03c908b No-Stranding-Test +
|
|
||||||
f1e35f5 LOW Mega-Tagesreset-Park + d2a1b83 HIGH Shelve-Loop-RetryLimit. HIGH rechtfertigt das
|
|
||||||
Release. Gitea (Live-Update-Quelle): .../releases/tag/v1.7.218. GitHub-Mirror (kuratierter
|
|
||||||
Single-Commit ohne CLAUDE.md/tasks/, Leak-Check sauber): Sucukdeluxe/multi-debrid-downloader v1.7.218.
|
|
||||||
Advisor war die GANZE Session ueberlastet → autonom released auf Basis: 3x rot-bewiesene Tests
|
|
||||||
(je per Temp-Revert verifiziert), volle Suite 882 gruen, tsc=6, unabhaengige Code-Verifikation +
|
|
||||||
Multi-Agent-adversarisch, Routing-Fix frueher advisor-gesegnet, Praezedenz 215/216/217 autonom.
|
|
||||||
Runde-5/6-Charakterisierungen (PP-SEM-1 benign, DISK-1 deferred, deferred-LOW-Cluster #10/#11/#13
|
|
||||||
benign) NICHT released — dokumentiert.
|
|
||||||
|
|
||||||
## SYNTHESE/Regressions-Pass (Fix-Interaktion ueber kumulativen Diff v1.7.212..HEAD) — Workflow wrexz7mdf
|
|
||||||
3 Reviewer (Catch-Cascade / Streaming / Provider-Chain) + adversarisch verifizieren. 3 confirmed (1 MED, 2 LOW),
|
|
||||||
2 refuted. Bestaetigt: Rest komponiert sauber; Risiko war (wie Advisor sagte) Fix-Interaktion, nicht neue Bugs.
|
|
||||||
- **CONFIRMED MED (GEFIXT) C1 reset_park maskiert kurzen Cooldown:** Bei BEIDEN Mega-Modi aktiv aggregiert die
|
|
||||||
Provider-Kette beide Token (`mega_debrid_reset_park:LONG` von API-Park + `mega_debrid_cooldown:30000` von
|
|
||||||
Web-Cooldown). Manager prueft reset_park VOR cooldown → Item ~24h geparkt obwohl Web in ~30s erholt. Fix:
|
|
||||||
Cooldown-Zweig VOR reset_park (kuerzerer Delay gewinnt). Rot-bewiesen (Single-Pass-processItem-Test: Aggregat
|
|
||||||
beider Token → retryAfter < 60s, fullStatus "Cooldown" nicht "Tagesreset"; ohne Reorder ~24h).
|
|
||||||
- **CONFIRMED LOW (GEFIXT) C2 Token-Truncation:** reset_park-Token konnte von compactErrorText (220-Zeichen-Cap)
|
|
||||||
abgeschnitten werden, wenn Mega nicht Lead + vorheriger Provider verbose → Park still uebersprungen. Fix: Mega-
|
|
||||||
Token aus der UNGEKUERZTEN error.message parsen (megaRawError). (Im selben Edit wie C1.)
|
|
||||||
- **CONFIRMED LOW (GEHAERTET + Doku korrigiert) RANGE1-TRUNCATEFAIL-FINALIZE:** Mein 219-Commit OVERCLAIMte —
|
|
||||||
bei FEHLGESCHLAGENEM finalem Rewind-truncate finalisiert tryFinalizeItemFromDisk(9238) die Garbage-Datei
|
|
||||||
(size==totalBytes) VOR dem Re-Entry. NET-NEUTRAL vs pre-audit (v1.7.212 hatte denselben Finalize), KEINE
|
|
||||||
Regression. Haertung: bei truncate-Fehler jetzt rmSync(Teil-Datei)+downloadedBytes=0 → Finalize lehnt ab →
|
|
||||||
sauberer Re-Download (best-effort; faellt der rm auch, bleibt es net-neutral). Macht den 219-Claim wahr.
|
|
||||||
- **Refuted (1/3 je):** reset_park von shelve-guard/unrestrictRetries-Exhaustion unter finite retryLimit
|
|
||||||
verdraengt — beide nicht bestaetigt (compound/nicht erreichbar).
|
|
||||||
- **Capstone-Verdikt:** Die 8-Runden-Fixes komponieren — inner-rewind success-only-reset und final-rewind sind
|
|
||||||
per-attempt mutually exclusive; prealloc-reconcile double-truncated nicht nach erfolgreichem Rewind; Routing
|
|
||||||
+ cooldown + park kohaerent NACH C1/C2-Fix. Diese 3 Synthese-Fixes buendeln zu v1.7.220.
|
|
||||||
|
|
||||||
## Runde 8 (Byte-Streaming downloadToFile: Range/Resume/Append/Truncation) — Workflow wwjz4srkq
|
|
||||||
3 Finder + adversarisch verifizieren. 3 confirmed (1 HIGH + 2 MED, alle Silent-Corruption-Familie), 1 refuted.
|
|
||||||
Advisor VOR Implementierung konsultiert (HIGH-Hot-Path) — Design + Check-A-Branch (totalBytes-null) bestaetigt.
|
|
||||||
- **CONFIRMED HIGH (GEFIXT, known-total) RANGE-1 Silent-Mid-File-Corruption:** Auf dem LETZTEN inneren Versuch
|
|
||||||
wird ein injizierter Garbage-Tail nicht zurueckgespult (`attempt < maxAttempts`-Guard greift nicht),
|
|
||||||
resumeRewindBytesNextAttempt ist funktions-lokal (ueberlebt downloadToFile-Re-Entry nicht), der Outer-Handler
|
|
||||||
nimmt fuer terminated-class den generic-retry-Zweig (kein File-Delete), und der Fresh-Link-Resume haengt
|
|
||||||
echte Bytes NACH dem Garbage an → exakt-laengen-Datei besteht die Length-only-Completion-Pruefung; bei
|
|
||||||
manifestlosen .mkv/.mp4 nie erkannt. Fix: Rewind-vor-Throw am Exhaustion-Punkt (truncate letzte
|
|
||||||
RESUME_REWIND_BYTES + downloadedBytes ZUERST setzen → binary-Re-Entry-prealloc-reconcile robust auch bei
|
|
||||||
truncate-Fehler), GEGATED auf `totalBytes != null && > 0`. Check A verifiziert: known-total → rewound
|
|
||||||
size < totalBytes=minBytes → tryFinalizeItemFromDisk(9238) REJECTET → Re-Entry ueberschreibt Garbage.
|
|
||||||
EHRLICHER Scope (Advisor): GEFIXT fuer known-total Medien (Debrid liefert fast immer fileSize);
|
|
||||||
**null-total behaelt die separate, vor-bestehende Silent-Corruption** unter dem dokumentierten
|
|
||||||
Size-only-Validation-Blindspot (durch Rewind NICHT fixbar — kein Laengensignal; nur Hard-Reset wuerde
|
|
||||||
helfen, groesserer Eingriff). Rot-bewiesen: Cross-Call-Test (final-attempt Garbage → Exhaustion →
|
|
||||||
queueRetry → 2. downloadToFile → CONTENT-Gleichheit); ohne Fix Length-Assert gruen + Content-Assert rot.
|
|
||||||
- **CONFIRMED MED (GEFIXT) REWIND-TRUNCATE-FAIL:** Das `finally` setzte resumeRewindBytesNextAttempt=0
|
|
||||||
UNBEDINGT, auch wenn die Rewind-truncate (9633) warf → transienter win32-EBUSY/AV-Lock-Fehler liess den
|
|
||||||
Garbage-Tail + cleart das Flag (nie retried). Fix: Reset NUR im Success-Branch → fehlgeschlagenes Rewind
|
|
||||||
wird naechsten Versuch erneut probiert. Defensive Haertung (Advisor "ship it"); Happy-Path von Test 1113
|
|
||||||
+ RANGE-1-Test abgedeckt.
|
|
||||||
- **DOKUMENTIERT, nicht gefixt (MED, schwaechste, Workload-immun) PREALLOC-ZEROS-ACCEPTED:** win32-Prealloc-
|
|
||||||
Nullen am Ende als komplett akzeptiert fuer NICHT-binaere Typen (1MB-Slack) wenn truncate skip/faellt.
|
|
||||||
Dominante Medien/Archive sind immun (threshold=0). Narrow Conjunction (non-binary >20MB, Gap<1MB, Crash/
|
|
||||||
truncate-fail). Fix bekannt (binary-strict footprint im 416-accept + recovery-finalize), aber deferred.
|
|
||||||
- **Refuted (1/3) TRUNC-1:** fsync auf resume-append fehlt — als Power-Loss-Edge eingestuft, nicht confirmed.
|
|
||||||
- **Cross-cutting (Advisor: NICHT jetzt anfassen):** Size-only-Completion-Validation ist der gemeinsame
|
|
||||||
Blindspot; kein billiger Content-Check fuer manifestlose Medien → validateDownloadedFileCompletion NICHT
|
|
||||||
umbauen (Risiko 4. Bug). Punkt-Fixes sind korrekt; Blindspot bleibt langfristiges Item.
|
|
||||||
|
|
||||||
## Runde 7 (Post-Download: Extraction + Video-Processor + Companion/Orchestrierung) — Workflow wcxk08n5i
|
|
||||||
3 Finder + adversarisch verifizieren. 2 confirmed, 0 refuted. Schliesst den deferred-Extraction-Cluster ab.
|
|
||||||
- **CONFIRMED MED (GEFIXT) VP-1 Falsche Tonspur:** isGermanStream (video-processor.ts:99-108) wertet die
|
|
||||||
Titel-Regex /\b(german|deutsch)\b/ fuer JEDEN nicht-deutsch-getaggten Stream — NICHT auf "Sprach-Tag fehlt"
|
|
||||||
gegated, obwohl der Kommentar (104-106) genau das als Absicht nennt. pickAudioTrack (124, EINZIGER Caller)
|
|
||||||
nimmt den ERSTEN Treffer → eine eng-Spur mit Titel "...German..." vor der echten ger-Spur GEWINNT → Remux
|
|
||||||
behaelt Englisch, verwirft die korrekte dt. Spur, ersetzt das Original atomar in-place (500) und strippt
|
|
||||||
.DL. → irreversibler Datenverlust + falsche Sprache, als Erfolg gemeldet. Single-Trigger (nicht compound).
|
|
||||||
Betrifft genau die vom Nutzer bestaetigte dt.-Tonspur-Funktion [[project_german_audio_feature]]. Fix:
|
|
||||||
`if (lang) return false;` VOR dem Titel-Fallback (deckt sich mit der Kommentar-Absicht). Rot-bewiesen
|
|
||||||
(2 Tests: eng-Titel-"German" vs echte ger → audioRelIndex 1; eng-Titel-"Deutsch entfernt" → skip). tsc=6.
|
|
||||||
- **DOKUMENTIERT, nicht gefixt (LOW compound) PP-1/#13 Companion-Overwrite:** renameCompanionFiles/
|
|
||||||
moveCompanionFiles (dl-mgr 3735/3791) ohne Uniqueness-Guard (anders als MKV via buildUniqueFlattenTargetPath);
|
|
||||||
cross-volume copyFile ohne COPYFILE_EXCL (3696) → ueberschreibt einen vorhandenen Orphan-Companion still
|
|
||||||
(nur .srt/.idx/.nfo, nie Archiv/Video). Compound (Orphan + Namenskollision), Sekundaerdateien, Integrations-
|
|
||||||
TDD noetig → dokumentiert. Fix bekannt (Uniqueness-Guard spiegeln).
|
|
||||||
- **Deferred-Extraction-Cluster re-klassifiziert (aus Runde 2):** #10 CRC-Kleindatei-Delete BENIGN (failed
|
|
||||||
Archive aus cleanupSources ausgeschlossen, nicht geloescht); #12 7z-Exit-1-als-Erfolg BENIGN (nur Erfolg
|
|
||||||
wenn KEINE Error-Marker — echte CRC/Korruption korrekt als Fehler); #11 resume-empty-output NOT-CONFIRMED
|
|
||||||
(compound, unbewiesene Hybrid-Collect-Ordering-Annahme); #13 = PP-1.
|
|
||||||
|
|
||||||
## Runde 6 (Retry/Backoff/Error-Klassifikations-State-Machine + Disk/IO) — Workflow wqurq83ma
|
|
||||||
3 Finder + adversarisch verifizieren. 1 confirmed (2/3), 3 refuted. Schliesst den deferred-LOW-Cluster ab.
|
|
||||||
- **CONFIRMED HIGH (GEFIXT) SHELVE-LOOP-RETRYLIMIT:** Bei FINITEM retryLimit>=5 sind alle drei Per-Klasse-
|
|
||||||
Caps = retryLimit; die 15-Failure-Shelve-Zweige (dl-mgr 9166 stall + 9352 error) feuern aber auf
|
|
||||||
hartkodiertem `sum>=15` OBERHALB der Per-Klasse-Terminal-Fails und HALBIEREN danach alle Counter →
|
|
||||||
Per-Klasse-Caps werden nie gleichzeitig ueberschritten → Item failt NIE, schleift ewig, item.retries
|
|
||||||
waechst ueber das konfigurierte Limit, Slot gestrandet, providerFailures.delete besiegt wiederholt den
|
|
||||||
Circuit-Breaker → Hoster-Hammering. resetStaleRetryState rettet nicht (<=90s-Re-Admit haelt updatedAt
|
|
||||||
frisch < 10min-Stale). Default retryLimit=0=∞ NICHT betroffen (Shelve ist dort der gewollte Park-Backstop).
|
|
||||||
Fix: in BEIDEN Shelve-Zweigen vor dem queueRetry `if (configuredRetryLimit > 0 && item.retries >=
|
|
||||||
configuredRetryLimit)` → terminal failen statt requeue (∞-Modus unberuehrt). Rot-bewiesen (Single-Pass-
|
|
||||||
Integrationstest: genericErrorRetries=15 vorgeseedet + injizierter Generic-Error → status "failed" statt
|
|
||||||
requeue). tsc=6.
|
|
||||||
- **Deferred-LOW-Cluster re-klassifiziert (aus Runden 1-2):** #10 HTTP416-shared-counter BENIGN (maxHttp416Retries
|
|
||||||
eigenes Budget, speist Shelve-Summe NICHT); #11 fresh-retry-preempt BENIGN (One-Shot-Booleans);
|
|
||||||
STALL-CAP-OFF-BY-ONE real aber +1 (Zutat von CONFIRMED-1, kein eigener Strand); #13 queue-wait→elapsedMs
|
|
||||||
BENIGN (queueRetry resettet attempts/updatedAt, Stall-Detektor misst nur aktiven Download). #12 = der
|
|
||||||
gefixte Bug. DISK-1 (ENOSPC/EACCES via Generic-Budget) kein eigener Bug (in finite vom Cap + diesem Fix
|
|
||||||
begrenzt; in ∞ by-design) → optionale Klassifikations-Erweiterung, deferred bis Nutzer Full-Disk-Hammering meldet.
|
|
||||||
|
|
||||||
## Runde 5 (unberuehrte Subsysteme: auto-reconnect, Scheduler-Fairness, Crash/Persistenz) — Workflow w7woztsxx
|
|
||||||
3 Finder (reconnect / scheduler / persistence) → adversarisch verifizieren (3 Lenses). 1 Kandidat, 0 confirmed.
|
|
||||||
**Sauberes „kein bestaetigter Bug"-Ergebnis.** Auto-reconnect-Resume, Slot-Accounting/Fairness und
|
|
||||||
Crash-Recovery/targetPath-Lifecycle sind solide (jeder Park/Slot hat Auto-Clear, stop/reset/abort drainen
|
|
||||||
Waiter + nullen Counter).
|
|
||||||
- **DOKUMENTIERT, nicht gefixt (LOW, benign): PP-SEM-1** Post-Process-Semaphore (acquire/releasePostProcessSlot,
|
|
||||||
~7269-7304). Mechanismus real: ein geweckter Waiter, dem ein Fast-Path-Queue-Jumper im Microtask-Gap den
|
|
||||||
Slot klaut, laeuft trotzdem (Re-Check nach next.resolve() nicht autoritativ) und released spaeter einen nie
|
|
||||||
inkrementierten Slot → packagePostProcessActive UNTER-zaehlt um 1/Vorkommen. Wirkung: **Ueber-Admission**
|
|
||||||
(mehr als maxParallelExtract parallele Extraktionen) = gebundene CPU/IO-Verschwendung, NIE Strand/Loss/
|
|
||||||
Korruption. Der einzige Strand-Pfad (`<=0`-Guard skippt Waiter-Wake) war NICHT organisch erreichbar (nur
|
|
||||||
durch manuelles Nullen des Counters). Self-heilt bei jedem stop/reset/drain. Fix bekannt (Re-Check
|
|
||||||
autoritativ machen ODER Increment in den Releaser ziehen) + rot-testbar, aber benign → kein Live-Server-
|
|
||||||
Hotfix wert (LOW-Fix-Schwelle: Wirkung ist Verschwendung, keine Korrektheit).
|
|
||||||
|
|
||||||
## Runde 2 (Download-Ausfuehrung: stream/resume/disk/integrity/extract/persist) — Workflow whspc8ddv
|
|
||||||
14 confirmed / 7 refuted (>=2/3 adversarisch). Alle HIGH/MED unten unabhaengig am echten Code
|
|
||||||
verifiziert (Zeilen zitiert) bevor gefixt. Jeder Fix mit rot-bewiesenem Test, tsc bleibt 6.
|
|
||||||
|
|
||||||
### Batch 2b → noch nicht released (buendeln, dann v1.7.216)
|
|
||||||
- [x] #R2-1/4 HIGH Pre-alloc-stat-Reconciliation blaeht `written` auf Padding-Groesse auf → stille
|
|
||||||
Null-Byte-Korruption auf win32. reconcileFinalizedSize() (download-completion.ts, rein+getestet),
|
|
||||||
nur noch ABWAERTS-Korrektur bei preAllocated. Commit 2646cba.
|
|
||||||
- [x] #R2-3 HIGH Nicht-Archiv-".001" ohne Signatur wurde als extrahiert gezaehlt → ganze .00x-Familie
|
|
||||||
beim Cleanup geloescht (Datenverlust). skippedNonArchives (pathSetKey) aus cleanupSources gefiltert,
|
|
||||||
frisch + resume. End-to-end-Test. Commit 56bae4a.
|
|
||||||
- [x] #R2-9 MED + #R2-14 LOW Settings-async-Writer ohne Generations-Schutz → Lost Update; shutdown rief
|
|
||||||
cancelPendingAsyncSaves nicht. Eigener syncSettingsSaveGeneration (Spiegel des Session-Pfads) +
|
|
||||||
cancel in shutdown. Commit 1a33fc2.
|
|
||||||
- [x] #R2-6 MED Teildatei verwaist beim Entfernen eines laufenden Downloads (catch-early-return vor
|
|
||||||
Cancel-Cleanup). rmSync im catch vor dem return (nach Stream-Close, kein Race). Commit 2b639b7.
|
|
||||||
- [x] #R2-8 MED Hash-Manifest: Pro-Zeile-Algorithmus von Dateiendung ueberschrieben → gute Datei
|
|
||||||
geloescht bei fehl-etikettiertem Manifest. parseHashLine-Algorithmus uebernehmen. Commit 4578991.
|
|
||||||
- [x] #R2-7 MED Startup-Dedup ersetzt gute kanonische Datei durch kleineres Duplikat (+ EXDEV-Loss-
|
|
||||||
Fenster). Size-Guard (kanonisch >= Duplikat → behalten) + rename-zu-.dedupbak-Reihenfolge mit
|
|
||||||
Restore. Commit folgt nach voller DM-Suite.
|
|
||||||
- [deferred/dokumentiert] #R2-5 stream-end akzeptiert truncated download (ohne Laengensignal nicht
|
|
||||||
entscheidbar; Web ist post-214 nur Fallback) — nur WARN-Log sinnvoll, kein sicherer Fix.
|
|
||||||
- [deferred/dokumentiert] #R2-10 CRC-verifizierte Kleindatei vom suspicious-small-Heuristik geloescht;
|
|
||||||
#R2-11 Resume-empty-output-Bypass; #R2-12 7z-Exit-1-Warnung als Erfolg; #R2-13 Companion-.srt/.nfo-
|
|
||||||
Overwrite. Je narrow/heuristisch → charakterisiert, nicht blind gefixt (LIVE-Server-Schwelle).
|
|
||||||
|
|
||||||
### Batch 2 (commit, noch nicht released — buendeln mit Runde-2-Findings)
|
|
||||||
- [x] #6 MED onefichier/ddownload-catch respektiert autoProviderFallback=off (Guard nach abort-rethrow).
|
|
||||||
Test: 1fichier KO + Fallback aus → reject, mega getLink NICHT aufgerufen (rot-ohne-Fix beweisbar).
|
|
||||||
ACHTUNG-Notiz: replace_all matchte faelschlich auch den getLinkInfos-Filename-catch (~3614) → revertet
|
|
||||||
(Filename-Aufloesung muss nicht-fatal bleiben). Nur die zwei Hoster-catch-Bloecke geaendert. Commit 21fb09b.
|
|
||||||
- [deferred LOW, dokumentiert statt blind-fix] #9 overwrite targetPath-wipe, #10 HTTP416 shared counter,
|
|
||||||
#11 fresh-retry preempt typed handlers, #12 shelve+shared counter, #13 queue-wait→elapsedMs, #5 Web-bad-creds.
|
|
||||||
→ je nur fixen wenn rot-ohne-Fix billig beweisbar + null Regression; sonst bleibt's charakterisiert.
|
|
||||||
@ -1,164 +0,0 @@
|
|||||||
# Offene Entscheidungen für dich (Audit-Loop 2026-06-17)
|
|
||||||
|
|
||||||
Diese zwei Punkte habe ich BEWUSST nicht autonom „gefixt", weil jede Lösung
|
|
||||||
einen Produkt-/Geschmacks-Kompromiss enthält, den du entscheiden solltest, nicht ich.
|
|
||||||
Beide sind verifiziert (Code-Zitat + Szenario), nur die Richtung ist deine Wahl.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Failover-Kappung durch globalen 60-Sekunden-Timeout (HIGH)
|
|
||||||
|
|
||||||
**Was passiert (belegt):**
|
|
||||||
`download-manager.ts` baut EINEN Timeout fürs gesamte Unrestrict:
|
|
||||||
```
|
|
||||||
const unrestrictTimeoutSignal = AbortSignal.timeout(getUnrestrictTimeoutMs()); // 60s
|
|
||||||
const unrestrictedSignal = AbortSignal.any([active.abortController.signal, unrestrictTimeoutSignal]);
|
|
||||||
```
|
|
||||||
Dieses EINE Signal geht an die komplette Provider-Kette in `debrid.ts`. Die 60s sind
|
|
||||||
also ein Budget für ALLE Provider zusammen, nicht pro Provider. Wenn Provider 1
|
|
||||||
(z.B. Mega-Web mit Account-Queue) das Budget verbraucht, dann sieht Provider 2 ein
|
|
||||||
bereits abgelaufenes Signal → `debrid.ts` wertet den Abbruch als „kein Failover" und
|
|
||||||
wirft, ohne Provider 2 echt zu versuchen.
|
|
||||||
|
|
||||||
**Aktuell weitgehend latent:** Seit v1.7.214 wird die API zuerst probiert (schneller Pfad),
|
|
||||||
Mega-Web ist nur noch Fallback. Das Szenario „langsamer Provider 1 hungert Provider 2 aus"
|
|
||||||
trifft in der Produktion derzeit selten. Deshalb dokumentiert statt dringend gefixt.
|
|
||||||
|
|
||||||
**Deine Entscheidung — zwei Richtungen (gleiche Spannung, andere Seite):**
|
|
||||||
|
|
||||||
- **A) Pro-Provider-Timeout:** Jeder Provider bekommt sein eigenes frisches Budget.
|
|
||||||
Failover bekommt IMMER einen echten Versuch.
|
|
||||||
*Kosten:* Worst-Case-Wartezeit pro Item steigt. Das Budget ist dabei ein DREHregler, kein
|
|
||||||
fixer Wert: 60s/Provider = bis 180s Worst-Case (3 Provider), 30s/Provider = bis 90s usw.
|
|
||||||
Du akzeptierst langsameres Worst-Case-pro-Item für vollständigeres Failover — und stellst
|
|
||||||
über den Pro-Provider-Wert ein, wie viel langsamer.
|
|
||||||
|
|
||||||
- **B) Globales Budget behalten, aber Slice für Failover reservieren:** Provider 1 wird auf
|
|
||||||
z.B. 35s gedeckelt, damit garantiert Zeit für Provider 2 bleibt.
|
|
||||||
*Kosten:* Ein legitim langsamer Provider 1 (Mega-Web-Account-Queue bis ~90s) wird früher
|
|
||||||
abgeschnitten → mehr Failover, auch wenn Provider 1 noch erfolgreich gewesen wäre.
|
|
||||||
|
|
||||||
Kernfrage, die nur du beantworten kannst: **Wie lange darf ein Item bei einem langsamen
|
|
||||||
aber funktionierenden Provider hängen, bevor wir ihn zugunsten des nächsten aufgeben?**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Gespiegelter Mega API/Web-Schalter (HIGH, in Runde 3 als 2/3 bestätigt)
|
|
||||||
|
|
||||||
**Was passiert (belegt):** Die Mega-Debrid API- und Web-Account-Zeilen teilen sich EINE
|
|
||||||
login-only Enable-Flag (`hasMegaDebridCredentials` gilt für beide; die Pro-Modus-Auswahl
|
|
||||||
läuft über `isMegaDebridModeEnabled(settings, "api"|"web")`). Dein Report: „API ausschalten
|
|
||||||
schaltet Web an" — weil der Schalter sich spiegelt.
|
|
||||||
|
|
||||||
**Warum ich es NICHT autonom geändert habe:**
|
|
||||||
- Daten-Modell-Fix (echte unabhängige Flags) braucht eine Settings-Migration, die
|
|
||||||
deaktivierte Accounts versehentlich re-aktivieren könnte.
|
|
||||||
- Die saubere UI-Variante (zwei echte unabhängige Schalter) ist ein Layout-Redesign — und
|
|
||||||
du bist UI-Geschmack-sensibel; das will ich nicht ungefragt umbauen.
|
|
||||||
|
|
||||||
**Deine Entscheidung:** Ein gemeinsamer Schalter (API+Web zusammen an/aus, klar beschriftet)
|
|
||||||
ODER zwei unabhängige Pro-Modus-Schalter (mehr Kontrolle, aber UI + Migration nötig)?
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Erledigt in dieser Runde (zur Info, kein Handlungsbedarf)
|
|
||||||
|
|
||||||
- **Download-Statistik zählt eine Datei nach einem Integritäts-/Zu-klein-Neuversuch nicht mehr doppelt:**
|
|
||||||
Schlug eine Datei die CRC-/Hash-Prüfung fehl (oder kam zu klein an) und wurde komplett neu geladen, wurde die
|
|
||||||
Dateigröße bisher pro Versuch erneut in die Statistik addiert — die Anzeige „insgesamt heruntergeladen" (Session
|
|
||||||
und Gesamt-Zähler) sowie die daraus berechnete Durchschnittsgeschwindigkeit waren dadurch bei flatterhaften Hostern
|
|
||||||
um die jeweilige Dateigröße aufgebläht (bei großen Archiven mit wiederholten CRC-Fehlern um mehrere GB). Jetzt zählt
|
|
||||||
jede gelieferte Datei genau einmal. Reine Anzeige-/Statistik-Korrektur — Slot-Vergabe, Tageslimits und der
|
|
||||||
Download-Ablauf waren nie betroffen (die Tageslimit-Zähler werden bewusst nicht angefasst, da sie die Provider-Auswahl
|
|
||||||
steuern und der echte Datenverkehr über die Leitung ging). Rot-bewiesener Test, beide Zähler einzeln geprüft.
|
|
||||||
|
|
||||||
- **Debrid-Link: ein abgebrochener Vorgang sperrt den Key nicht mehr unnötig:**
|
|
||||||
Wenn du einen Vorgang abgebrochen hast (oder der Gesamttimeout zuschlug), bevor echte Arbeit lief, bekam der
|
|
||||||
Debrid-Link-Key bisher trotzdem eine 15-Sekunden-Sperre — bei mehreren Abbrüchen in Folge konnte das sogar über
|
|
||||||
mehrere Keys kaskadieren und eine längere providerweite Sperre auslösen. Jetzt wird ein schneller Abbruch (vor der
|
|
||||||
Mindest-Laufzeit) nicht mehr als Key-Fehler gewertet: keine Sperre. Lief der Vorgang dagegen lange genug und brach
|
|
||||||
dann ab (echter langsamer/hängender Key), wird er weiterhin gesperrt, damit der nächste Versuch sauber auf den
|
|
||||||
nächsten Key rotiert. Spiegelt exakt das Verhalten, das es bei Mega-Debrid schon gibt. Rot-bewiesener Test.
|
|
||||||
|
|
||||||
- **Mega-Konvertierungs-Stau (geprüft, kein Eingriff nötig):** Die Zahl gleichzeitiger Mega-Umwandlungen ist bereits
|
|
||||||
auf die Anzahl nutzbarer Accounts gedeckelt — Überschuss wartet sauber im Scheduler statt sich in den Account-
|
|
||||||
Warteschlangen zu stapeln, und der oben beschriebene Mega-Web-Fix hält dieses Limit jetzt korrekt hoch. Ein
|
|
||||||
zusätzlicher Eingriff wäre überflüssig oder würde durch Über-Vergabe erst echten Stau erzeugen. Auf deine
|
|
||||||
Entscheidung hin daher bewusst NICHT verändert.
|
|
||||||
|
|
||||||
- **Alte Konfiguration: Mega-Debrid fällt nach einem Upgrade nicht mehr still aus der Provider-Reihenfolge:**
|
|
||||||
Eine Konfigurationsdatei, die noch von einer sehr alten Version (vor v1.6.90) stammt, kannte die getrennten
|
|
||||||
Mega-Debrid „API aktiv"/„Web aktiv"-Schalter noch nicht. Beim Laden wurden diese fehlenden Schalter still auf
|
|
||||||
„aus" gesetzt, obwohl Mega-Zugangsdaten vorhanden waren — und sobald man danach das erste Mal die Einstellungen
|
|
||||||
speicherte, wurde Mega-Debrid dadurch lautlos aus der Provider-Reihenfolge entfernt. Jetzt wird beim Laden einer
|
|
||||||
solchen alten Datei erkannt, dass die Schalter komplett fehlen, und Mega-Debrid passend zu deiner Bevorzugung
|
|
||||||
(API oder Web) aktiviert — genau die Migration, die ursprünglich gedacht war, aber durch einen Default-Vorrang
|
|
||||||
nie ausgelöst hatte. Ehrlicher Umfang: Das betrifft nur alte Dateien, die seit dem Upgrade noch NICHT über die
|
|
||||||
Einstellungen neu gespeichert wurden — wer seit dem Update schon einmal in den Einstellungen gespeichert hat, hat
|
|
||||||
die Schalter bereits als „aus" stehen und greift dort weiterhin manuell ein (das ist Absicht: ein bewusst auf
|
|
||||||
„aus" gestellter Schalter wird NICHT wieder angeschaltet). Rot-bewiesener Test; voller Testlauf grün.
|
|
||||||
|
|
||||||
- **Mega-Web: gesunder Account sperrt sich nicht mehr selbst, nur weil er gerade belegt war (deine „Tool sperrt sich selbst"-Klasse, Web-Variante):**
|
|
||||||
Wenn mehrere Links gleichzeitig über DENSELBEN Mega-Account umgewandelt wurden, laufen sie absichtlich nacheinander
|
|
||||||
(eine Warteschlange pro Account, damit nicht doppelt eingeloggt/gehämmert wird). Wartete ein Link in dieser Schlange
|
|
||||||
noch auf seinen Vorgänger und lief dabei der 60-Sekunden-Gesamttimeout ab, wurde der Abbruch fälschlich wie ein echter
|
|
||||||
Account-Fehler gewertet → der völlig gesunde Account bekam 120 Sekunden Sperre. Jetzt wird ein Abbruch, der NOCH IN DER
|
|
||||||
Warteschlange passiert (bevor echte Arbeit begann), als reiner Warteschlangen-Timeout erkannt: KEINE Account-Sperre,
|
|
||||||
der Link wird einfach erneut versucht und rotiert dann von selbst auf einen freien Account. Ein echter Abbruch MITTEN
|
|
||||||
in der Arbeit sperrt den Account weiterhin (damit langsame Accounts korrekt übersprungen werden) — das blieb unverändert.
|
|
||||||
Zwei rot-bewiesene Tests (jeder ohne Fix nachweislich rot; der zweite läuft komplett durch die echte Account-Rotation
|
|
||||||
und prüft, dass keine Sperre gesetzt wird). Die Rotation auf einen freien Account ist über die Auslastungs-Verteilung
|
|
||||||
(am-wenigsten-belegter-Account-zuerst) abgesichert — strikt besser als die alte 120s-Pauschalsperre.
|
|
||||||
|
|
||||||
- **Stille Datei-Beschädigung beim Resume nach Verbindungsabbruch behoben (für Dateien mit bekannter Größe):**
|
|
||||||
Wenn ein Debrid-Server beim Abbruch einen kleinen Fehler-Müll-Block mitten in den Datenstrom schreibt und
|
|
||||||
das genau im LETZTEN Wiederhol-Versuch passierte, blieb dieser Müll in der Datei und der anschließende
|
|
||||||
Neuversuch (mit frischem Link) hängte die echten Bytes DAHINTER an — die Datei hatte am Ende exakt die
|
|
||||||
richtige Größe und galt deshalb als fertig, obwohl mittendrin Müll steckte. Bei .mkv/.mp4 ohne Prüfsumme
|
|
||||||
fiel das nie auf. Jetzt wird der verdächtige Datei-Schwanz vor der Linkerneuerung zurückgespult, sodass der
|
|
||||||
Neuversuch ihn sauber überschreibt. Rot-bewiesener Test (Inhalt byte-genau geprüft, nicht nur die Länge).
|
|
||||||
Ehrlich eingeordnet: Das ist behoben für Dateien, bei denen der Anbieter die Größe meldet (fast immer der
|
|
||||||
Fall). Für die seltenen Fälle ganz ohne Größenangabe bleibt eine separate, schon vorher bestehende Lücke
|
|
||||||
(ohne Längensignal nicht über dieses Rückspulen lösbar) — dokumentiert als langfristiges Thema.
|
|
||||||
- Zusätzlich gehärtet: Ein fehlgeschlagenes Zurückspulen (z.B. Datei kurz von Virenscanner gesperrt) wird
|
|
||||||
jetzt im nächsten Versuch erneut probiert statt still übergangen.
|
|
||||||
|
|
||||||
|
|
||||||
- **Deutsche Tonspur: falsche Spur-Auswahl behoben (Datenverlust-Schutz):** Die Erkennung der
|
|
||||||
deutschen Tonspur hat den Titel-Text einer Spur („...German...") auch dann ausgewertet, wenn die
|
|
||||||
Spur bereits ein anderssprachiges Tag hatte (z.B. eine englische Spur mit „German" im Titel, wie
|
|
||||||
„German Commentary"). Lag so eine Spur VOR der korrekt mit „ger" getaggten Spur, wurde die falsche
|
|
||||||
(englische) behalten und die echte deutsche Spur beim Remux unwiderruflich verworfen — das Ergebnis
|
|
||||||
wurde als Erfolg gemeldet. Jetzt wird der Titel nur noch dann herangezogen, wenn gar kein Sprach-Tag
|
|
||||||
vorhanden ist (so war es ohnehin gemeint). Korrekt getaggte deutsche Spuren gewinnen jetzt immer.
|
|
||||||
Rot-bewiesener Test. (Noch nicht released — wird mit der nächsten Runde gebündelt.)
|
|
||||||
|
|
||||||
|
|
||||||
- **Endlos-Wiederholung bei festem Wiederholungslimit behoben:** Wenn du ein FESTES Retry-Limit
|
|
||||||
(z.B. 5) eingestellt hattest UND ein Link sprunghaft verschiedene Fehlerarten produzierte
|
|
||||||
(mal Umwandlungs-Timeout, mal Abbruch mitten im Download, mal allgemeiner Fehler), konnte ein
|
|
||||||
Eintrag in einer Endlosschleife hängen: eine interne „Viele-Fehler"-Pause halbierte die Zähler,
|
|
||||||
sodass das eingestellte Limit nie erreicht wurde — der Eintrag scheiterte nie, blockierte dauerhaft
|
|
||||||
einen Download-Slot und hämmerte den Anbieter (weil dabei auch die Anbieter-Sperre zurückgesetzt
|
|
||||||
wurde). Jetzt wird das von dir eingestellte Limit hart eingehalten: nach N Versuchen scheitert der
|
|
||||||
Eintrag sauber. Standard-Einstellung („unendlich", der Auslieferungs-Default) war nie betroffen.
|
|
||||||
Rot-bewiesener Test.
|
|
||||||
|
|
||||||
|
|
||||||
- **Mega „bis Tagesreset gesperrt" parkt jetzt wirklich (statt alle 2 min neu zu versuchen):**
|
|
||||||
Wenn ALLE Mega-Accounts wegen wiederholt leerer Antworten bis zum Tagesreset geparkt waren,
|
|
||||||
hat das Tool den Fehler bisher als normalen Umwandlungsfehler behandelt und den ganzen Tag
|
|
||||||
alle ~2 Minuten neu probiert (und dabei den Provider-Circuit-Breaker mit Fehlern vollgemüllt).
|
|
||||||
Jetzt erkennt es den Park und legt das Paket EINMAL bis zum Tagesreset schlafen — genau das,
|
|
||||||
was der „bis Tagesreset"-Park eigentlich erreichen sollte. Bei Standard-Einstellungen heilte
|
|
||||||
sich das vorher schon um Mitternacht selbst (kein Datenverlust), war aber unnötige Log-Flut
|
|
||||||
und Churn. Rot-bewiesener Test.
|
|
||||||
|
|
||||||
|
|
||||||
- **MED Failover-Routing:** Wenn ein Provider in den Manager-Cooldown läuft (≥20 Fehler in
|
|
||||||
Folge) und auto-Fallback an ist, hat der Manager bisher zwar einen Ersatz-Provider berechnet,
|
|
||||||
ihn aber WEGGEWORFEN — die Kette führte trotzdem wieder mit dem ausgebremsten Provider an.
|
|
||||||
Jetzt wird der Ersatz-Provider als „Lead" durchgereicht und die Kette führt mit ihm an, OHNE
|
|
||||||
einen Provider zu verlieren (der ausgebremste bleibt als letzter Notnagel in der Kette).
|
|
||||||
Greift nur, wenn der Provider bereits nachweislich degradiert ist → strikt-besser-wenn-aktiv,
|
|
||||||
kein Timeout/Cancel-Vertrag berührt. Rot-bewiesener Test. (Hält für Roll-up-Release bereit.)
|
|
||||||
@ -1,31 +1,5 @@
|
|||||||
# Lessons
|
# Lessons
|
||||||
|
|
||||||
## 2026-06-17 — "Permanent/tot" NIE annehmen ohne Transienz-Gegenprobe (supprimé war transient)
|
|
||||||
|
|
||||||
**Muster:** Mega-Debrid lieferte 479x "Fichier supprimé chez l'hébergeur". Ich nahm
|
|
||||||
"supprimé = gelöscht = toter Link" als permanent an, baute Fix (sofort scheitern, kein
|
|
||||||
Web-Fallback) + released v1.7.210. Der User fragte: "welcher Link soll tot sein, hast du
|
|
||||||
das hinterfragt?" Gegenprobe an den Logs: von 18 Links mit "supprimé" haben **4 Sekunden
|
|
||||||
später ein OK** geliefert (1x Web, 3x API-Retry 7–66s). Der Fehler war TRANSIENT. 210
|
|
||||||
hätte erholbare Links dauerhaft gekillt. Korrektur v1.7.211: temporär statt permanent.
|
|
||||||
|
|
||||||
**Regel:**
|
|
||||||
- Bevor ein Fehler als permanent/fatal/tot klassifiziert wird: an echten Daten prüfen, ob
|
|
||||||
derselbe Link/dieselbe Ressource mit demselben Fehler **jemals danach ein OK** bekam.
|
|
||||||
Intersection(failed-links, ok-links) ≠ ∅ → transient → permanent-Klassifizierung ist
|
|
||||||
falsch. Wortbedeutung ("supprimé"=gelöscht, "deleted") beweist KEINE Permanenz —
|
|
||||||
besonders bei flakigen Multihostern (Mega-Debrid), die per-Link kurzzeitig falsch melden.
|
|
||||||
- **Die Linse (Advisor):** bei jedem Fehlersignal fragen — ist das über den ACCOUNT
|
|
||||||
(→ ggf. Cooldown), über den LINK PERMANENT (→ Item scheitern), oder nur über DIESEN
|
|
||||||
VERSUCH (→ Retry)? Beide Bugs hier (Account-Cooldown-Vergiftung UND falsch-permanent)
|
|
||||||
waren derselbe Fehler: ein Per-Versuch-Signal als account-/link-globaler Zustand behandelt.
|
|
||||||
- Wieder die 05-31-Regel verletzt (empirisch bestätigen vor Release). Wenn der User
|
|
||||||
skeptisch nachfragt ("hast du das hinterfragt?"), ist das fast immer ein echter
|
|
||||||
ungeprüfter Sprung — sofort an Daten gegenprüfen, nicht verteidigen.
|
|
||||||
- Retry-Pacing verifizieren, nicht annehmen: cooldownMs:0 entfernt Account-Cooldown,
|
|
||||||
aber der Download-Manager bremst per 5s-Exponential-Backoff (unrestrictDelayMs) pro
|
|
||||||
Item — getraced, weit unter Mega-Debrid 50 req/s. Account-Cooldown ≠ Retry-Pacing.
|
|
||||||
|
|
||||||
## 2026-05-31 — Fix-Diagnose EMPIRISCH bestätigen, bevor man released (Timeout ≠ Account-Hänger)
|
## 2026-05-31 — Fix-Diagnose EMPIRISCH bestätigen, bevor man released (Timeout ≠ Account-Hänger)
|
||||||
|
|
||||||
**Muster:** "acc2/acc3 nie versucht" wurde als "acc1 hängt → Per-Account-Timeout +
|
**Muster:** "acc2/acc3 nie versucht" wurde als "acc1 hängt → Per-Account-Timeout +
|
||||||
|
|||||||
319
tasks/todo.md
319
tasks/todo.md
@ -1,42 +1,287 @@
|
|||||||
# MCP-Ferndiagnose (Goal, ultracode) — v1.7.223
|
# Real-Debrid-Downloader — Tasks (Stand 2026-06-10)
|
||||||
|
|
||||||
## Ziel (Nutzer)
|
**Status:** Webhook-Audit ABGESCHLOSSEN + released (v1.7.195). Davor: Bug-Audit (v1.7.189/190),
|
||||||
"Baue massive Diagnose-Funktionen ein (MCP), so dass ich MCP auf nem Windows-Server aktivieren kann und du
|
Sichtbarkeits-Paket (v1.7.191-194). Verbleibende QoL-Kandidaten weiter unten.
|
||||||
auf den Server zugreifst und WIRKLICH ALLES siehst (State, Fehler, Logs, Probleme). 5-6 Server, ueber
|
|
||||||
Verbindungscode. Du verbindest dich → liest alles → behebst Probleme direkt."
|
|
||||||
|
|
||||||
## Architektur (Advisor-bestaetigt v1)
|
---
|
||||||
- Standalone **stdio MCP-Bridge** auf MEINER (Claude-Code-)Maschine, proxyt zur bestehenden HTTP `debug-server.ts`
|
|
||||||
jedes Servers via **Verbindungscode**. Eine Bridge bedient alle 5-6 Server.
|
|
||||||
- KEIN eingebettetes MCP-over-HTTP (verworfen: hand-rolled Protokoll auf Internet-Oberflaeche = mehr Risiko).
|
|
||||||
- Bridge-Code identisch fuer Direkt-IP vs spaeterer Tunnel (proxyt host:port).
|
|
||||||
- Verbindungscode: `rddiag:v1:<base64url(JSON {v,h:host,p:port,t:token,n?:name,fp?:certFp})>`. Nutzer liefert
|
|
||||||
oeffentlichen Host (nicht auto-detecten).
|
|
||||||
|
|
||||||
## Sicherheitsmodell (Advisor, first-class)
|
## ✅ Webhook-Bug-Audit — RELEASED v1.7.195 (2026-06-10, Gitea + GitHub-Mirror 37430ba)
|
||||||
Plain HTTP + Bearer ueber Internet = sniffbares Token mit Lesezugriff auf sensible Logs. Mitigations:
|
|
||||||
- App-seitige IP-Allowlist (extractDebugClientIp existiert schon).
|
|
||||||
- `/trace/config` MUTIERT → "read-only"-Claim auditieren: Writes von Remote-Oberflaeche gaten oder umlabeln.
|
|
||||||
- Opt-in + sofort widerrufbar (Token-Rotation killt Zugang).
|
|
||||||
- debug_token.txt in userData bestaetigen (ueberlebt Auto-Update).
|
|
||||||
- Optional self-signed Cert + Fingerprint im Code gepinnt (NICHT v1-blockierend).
|
|
||||||
|
|
||||||
## Phasen
|
User-Goal: "intensiv prüfen obs Bugs gibt wodurch Webhooks nicht getriggert werden + drumrum".
|
||||||
- [x] **P0 Vertical Slice (Diskriminator) — ERLEDIGT, harness ALL PASS:** Bridge → debug-server via stdio JSON-RPC, echte Daten zurueck.
|
Workflow: 6 Finder → 29 adversarial bestätigte Findings (0 widerlegt, 65 Non-Findings),
|
||||||
- [x] MCP SDK API holen (context7) → @modelcontextprotocol/sdk 1.29.0, registerTool(name,{inputSchema:zodShape},cb)
|
dedupliziert auf 16 Themen in 6 Fix-Commits. Volle Gate: tsc=6, 801 Tests (+9), self-check, build.
|
||||||
- [x] Bridge in `tools/rd-diagnostics-mcp/` (eigenes package.json, NICHT in App-Bundle): code.mjs/http.mjs/bridge.mjs/gen-code.mjs
|
|
||||||
- [x] 14 Tools: rd_servers/rd_ping/rd_diagnostics/rd_status/rd_items/rd_packages/rd_errors/rd_logs/rd_history/rd_accounts/rd_host/rd_self_check/rd_get + Multi-Server (code|server|RDDIAG_CODE|RDDIAG_SERVERS)
|
|
||||||
- [x] Verbindungscode-Codec rddiag:v1:base64url({v,h,p,t,n?,fp?,s?})
|
|
||||||
- [x] Test-Harness (test/harness.mjs): fake debug-server (auth+routes+query-echo) + Bridge als stdio-Child → 19 Checks gruen (handshake, tools/list, ping, diagnostics+query-passthrough, logs-mapping, errors, escape-hatch, 401, missing-code, unreachable+hint)
|
|
||||||
- [x] **P1 Security-Hardening — ERLEDIGT:** IP-Allowlist (exakt+CIDR), erzwungen VOR Auth am ECHTEN Socket-Peer (req.socket.remoteAddress), NICHT X-Forwarded-For (Advisor: XFF faelschbar → Bypass; gefixt+Threat-Test). Fail-closed. /trace/config belassen (zeitbegrenzt). userData/runtime verifiziert. Log-Audit: keine Secrets in /logs/*.
|
|
||||||
- [x] **P2 One-Click-Enable + Code — ERLEDIGT:** restartDebugServer ('close'+closeAllConnections, EADDRINUSE). IPC get/enable/disable/rotate + Controller + Typen. Flache Modal-UI (Hilfe→Remote-Support→"Ferndiagnose (MCP)"): Status, lokal/netzwerk, Public-Host+Chips, Allowlist, Code+Copy+Token-Rotation+Deaktivieren.
|
|
||||||
- [x] **P3 — durch bestehende Endpunkte abgedeckt:** /accounts (Cooldown/Rotation), /errors, /status, /diagnostics via Bridge. Kein neuer Endpunkt noetig.
|
|
||||||
- [x] **P4 Verify — ERLEDIGT:** Suite 906 gruen, tsc=6, Harness gruen, Advisor (fing XFF-Bypass). Release v1.7.223 Gitea (6b52678, 4 Assets) + Mirror (24485be, 4 Assets, Claude-frei). Bridge `claude mcp add` (user, ✔ Connected).
|
|
||||||
- [ ] **P5 Reachability (NUTZER):** Ferndiagnose auf 1 Server an → Code an mich → ich verbinde. Entscheid: Tunnel (sicherste, "Nur lokal") vs Direkt-Bind 0.0.0.0+Allowlist (nur vertrauenswuerdiges Netz/VPN; Token reist plain HTTP).
|
|
||||||
|
|
||||||
## Review
|
### Gefixt (Commits f060d02, 99a2459, 8acb22d, 3fb9e85, 5f8b02e, 86d935e + Test-Commit)
|
||||||
Vertikaler Slice zuerst (Bridge gegen Fake-Debug-Server, JSON-RPC stdio), dann App-Seite load-bearing-first
|
1. **notify.ts Zustellung** (N5/RATELIMIT HIGH): Send-Queue seriell + 450ms-Gap (Discord
|
||||||
(Backend+Tests vor UI). Advisor fing einen releaseblockierenden Bug: Allowlist nutzte extractDebugClientIp
|
5/2s-Bucket), 429 mit retry_after (Sek→ms), 5xx/Netz-Backoff-Retries, Body-Konsum,
|
||||||
(X-Forwarded-For zuerst = angreiferkontrolliert) → Bypass per `X-Forwarded-For: 127.0.0.1`; Tests maskierten es
|
surrogat-sichere 2000er-Kappung, Invalid-URL-Warnung. 17 notify-Tests.
|
||||||
(injizierten die IP per genau dem Header). Fix: Enforcement am Socket-Peer, XFF nur fuers Log; Threat-Test
|
2. **Terminal-Abdeckung** (N1/DEDUP-2 HIGH, N2 HIGH, N4): refreshPackageStatus benachrichtigt
|
||||||
(socket 8.8.8.8 + XFF 127.0.0.1 → denied). Empfohlener Transport: Loopback+Tunnel; Direkt-Bind nur mit Allowlist.
|
JEDEN Failed-Übergang (success===0-Gate weg — Mixed-Paket mit letztem Fail = häufigste
|
||||||
|
Reihenfolge verlor den ❌) + History für Mixed; 3 Early-Return-Fehlerpfade (416/toter
|
||||||
|
Link/Debrid-Link) riefen refreshPackageStatus nie → Paket hing bis Neustart auf
|
||||||
|
downloading; skipItems finalisiert jetzt jedes terminal gewordene Paket.
|
||||||
|
3. **Dedup-Lifecycle** (DEDUP-1 HIGH, -3, -4): recoverRetryableItems + retryExtraction/extractNow
|
||||||
|
geben den Notify-Marker frei (Recovery-✅ kam sonst NIE); retryExtraction tritt Run unbedingt
|
||||||
|
bei; addTrailingPostProcessPackageIds erhält Run-Mitgliedschaft laufender Entpackungen an
|
||||||
|
allen 5 runPackageIds-Ersetz/Clear-Stellen; Marker-Freigabe bei endgültig gescheitertem Send.
|
||||||
|
4. **Run-Lifecycle** (RUN-1..4): stop() schickt "Durchlauf gestoppt"-Summary; Scheduler-Respawn
|
||||||
|
in finally heilt Stop→Start-Race (führerloser Run!); finishRun sagt "Downloads beendet +
|
||||||
|
Entpacken läuft noch" statt zu lügen; armScheduledStart beim Boot re-armt (geplanter Start
|
||||||
|
überlebte keinen Neustart; vergangene Zeit → log+clear statt Blind-Start).
|
||||||
|
5. **Settings/Security** (SET-2, SET-4): notifyUrl in importBackup-SENSITIVE_KEYS;
|
||||||
|
Support-Bundle notifications-Block (konfiguriert/plausibel/Toggles, ohne URL-Leak).
|
||||||
|
6. **Renderer** (R1, R2): Testen-Toast mahnt Speichern bei dirty Settings; Button disabled
|
||||||
|
während Quick-Action läuft (statt Klick still zu schlucken).
|
||||||
|
|
||||||
|
### Bewusst NICHT gefixt (verifiziert, Begründung)
|
||||||
|
- **SET-3** (rememberToken=false wipt notifyUrl nicht): Webhook als Credential zu wipen würde
|
||||||
|
Benachrichtigungen für rememberToken=false-Setups brechen; Konsistenz-Trade-off akzeptiert.
|
||||||
|
- **R3** (URL als Klartext-Feld statt password): Sichtbarkeit hilft beim Prüfen/Debuggen,
|
||||||
|
Einzel-Operator-Setup.
|
||||||
|
- **R4** (Boot-Window Draft-Race): generisches, vorbestehendes Settings-Hydration-Muster,
|
||||||
|
nicht notify-spezifisch.
|
||||||
|
- **DEDUP-5** (immediate-Cleanup schrumpft Zähler in der ✅-Meldung): kosmetisch, Policy-Kombi
|
||||||
|
beim User nicht aktiv. **RUN-5** (Quit mid-run still): nach Prozesstod nicht zustellbar.
|
||||||
|
**RUN-6** (Dauer-Fallback 1s): kosmetisch. **N6**: durch Marker-Freigabe + URL-Warnung subsumiert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ QoL Sichtbarkeits-Paket — RELEASED v1.7.191 (2026-06-09, Gitea + GitHub-Mirror)
|
||||||
|
1. **Push-Benachrichtigungen** (e753ea1, v1.7.192 auf Discord-Webhook-JSON umgebaut —
|
||||||
|
{username, content}, Emoji-Titel, 2000-Zeichen-Cap): Settings notifyUrl + 3 Toggles; 3 Hooks
|
||||||
|
(Post-Process-Ende, refreshPackageStatus all-failed-Lücke, finishRun-Summary); Dedup-Set
|
||||||
|
Lifecycle wie historyRecordedPackages; Guard running||runPackageIds (Recovery pusht nicht).
|
||||||
|
2. **audioStripSummary am Paket** (2a1a554): PackageEntry-Feld + Status-Spalten-Badge
|
||||||
|
("Tonspur: 5 OK · 1 ohne DE-Tag", Tooltip mit Datei-Details); storage-Whitelist + Delta-Hash.
|
||||||
|
3. **"Letzte Fehler anzeigen"** (be4d54a): Hilfe-Menü → Error-Ring-Snapshot im Dialog,
|
||||||
|
Bestätigen-Knopf = in Zwischenablage kopieren.
|
||||||
|
787 Tests, tsc=6, self-check+build grün; latest.yml-path verifiziert; Mirror f61fbc4 clean.
|
||||||
|
|
||||||
|
## 🟡 QoL-Backlog — code-verifiziert mit Hook-Punkten (aus Ideation 2026-06-09)
|
||||||
|
Volle Details (exakte Zeilennummern, Verifier-Gotchas) im Ideation-Workflow-Output; Kurzform:
|
||||||
|
1. **Mega-Web Per-Account-Timeout** (M, Score 9) — 20s eigenes Timeout pro Account via
|
||||||
|
AbortSignal.any + Signal-IDENTITÄT (nie Error-Text!) im Rotations-catch VOR Z.~1979;
|
||||||
|
äußeres 60s-Budget bleibt. GOTCHA: tests/debrid.test.ts:1376 asserted Signal-Objekt-Identität
|
||||||
|
→ Test lockern. Nur mode==='web'.
|
||||||
|
2. **ffmpeg-Pfad als Setting + Testen-Button** (M, Score 9) — ffmpegPath/ffprobePath, Setter +
|
||||||
|
resetVideoToolingCache(); GOTCHA: auch im KONSTRUKTOR setzen (setSettings reicht nicht nach
|
||||||
|
Neustart); IPC CHECK_VIDEO_TOOLING; UI unter keepGermanAudioOnly-Toggle.
|
||||||
|
3. **Bibliothek-Batch Tonspur für Bestand** (M, Score 9) — Button neben Toggle; Kandidaten-Filter
|
||||||
|
aus keepGermanAudioOnlyImpl als shared Predicate extrahieren (Overlap-Guard NICHT schwächen);
|
||||||
|
sequenziell + Single-In-Flight + AbortController + mtime-Age-Gate.
|
||||||
|
4. **Auto-Reset gefailter Items bei Tageswechsel** (S–M, Score 8) — Toggle, Default aus;
|
||||||
|
GOTCHA: NICHT synchron aus ensureProviderDailyUsageFresh (Re-Entranz: läuft in getSnapshot/
|
||||||
|
Scheduler) → über 60s-runtimeStatsTimer (app-controller ~164); resetItems löscht Teil-Downloads.
|
||||||
|
5. **POST /control am Debug-Server** (M, Score 8) — add-links/start/stop/pause; GOTCHA: über
|
||||||
|
AppController routen (Audit-Log), nicht manager direkt; Start-Konflikt-Guard beachten.
|
||||||
|
6. **Mobile Statusseite GET /ui + Remote-Toggle** (M, Score 8) — handgeschriebenes flaches HTML,
|
||||||
|
pollt /status+/errors; Toggle schreibt debug_host.txt um; GOTCHA: stop+start Race (EADDRINUSE
|
||||||
|
→ Server tot bis Neustart) → closeAllConnections/restartDebugServer.
|
||||||
|
7. **Mega-Cooldown-Status + aufheben** (S, Score 7) — listMegaDebridAccountCooldowns() export,
|
||||||
|
Snapshot-Feld, Badge + Button; GOTCHA: Keys sind `${id}:api|web` getrennt; lazy expiry.
|
||||||
|
8. **Status-Filter-Chips Downloads-Tab** (S, Score 7) — GOTCHAS: PackageCard-memo-Comparator
|
||||||
|
braucht neue Prop; Prädikat an 2 Stellen (6615 + visibleOrderIds 3546); VOR Rendering-Limit.
|
||||||
|
9. **Support-Bundle: Explorer zeigen + Desktop-Schnellweg** (S, Score 7) — filePath wird schon
|
||||||
|
returned, Toast ignoriert ihn nur; shell.showItemInFolder; preload-api.ts nicht vergessen.
|
||||||
|
Long-Tail: Low-Disk-Auto-Pause (S), Autostart/Tray (S).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ ERLEDIGT — Bug-Audit 2026-06-08 (Multi-Agent find→verify, 18 bestätigt)
|
||||||
|
|
||||||
|
Advisor-Triage: **A = einzige echte Daten-Verlust-Notlage** (zerstört echte Datei auf Platte)
|
||||||
|
→ zuerst, ALLEINE Release. **B verifiziert demoted:** applyRetroactiveCleanupPolicy/
|
||||||
|
removePackageFromSession löschen KEINE Platten-Dateien (nur Session/Queue-Einträge + ggf.
|
||||||
|
History-Eintrag) → Queue-Integrität, nicht Daten-Verlust → in v1.7.190-Batch.
|
||||||
|
Sequenz: Release 1 (v1.7.189) = **A allein**; Release 2 (v1.7.190) = B/I,C,D/E,F,G,H,J,L,M,N,O,P,Q.
|
||||||
|
Ein Commit pro Fix, jeder einzeln verifiziert. **K übersprungen** (auto-rename-Reorder,
|
||||||
|
schlechtestes Risiko/Nutzen, kann für diesen User gar nicht feuern).
|
||||||
|
|
||||||
|
### Release 1 — Daten-Verlust-Stopper (v1.7.189, A ALLEIN)
|
||||||
|
- [x] **A** `video-processor.ts` atomic-replace zerstörte bei Windows-Lock BEIDE Kopien
|
||||||
|
(rm(original) VOR bestätigtem Replace + outer-catch rm(temp) → 0 Kopien). **GEFIXT:**
|
||||||
|
atomic replace-over + `renameWithRetry` (EBUSY/EACCES/EPERM/EEXIST, Backoff 200/500/1000ms),
|
||||||
|
rm-first-Fallback entfernt, **unique** Temp-Name (`~rd<pid><rand>`, löst auch C-Kollision).
|
||||||
|
Advisor bestätigt Ansatz besser als bak-dance (kein Missing-File-Window). 3 neue Tests
|
||||||
|
(Recovery + Retry-Pfad), 41 video-processor-Tests grün, tsc=6 (Baseline). Commit 189af22.
|
||||||
|
|
||||||
|
### Release 2 — v1.7.190 (GEFIXT + verifiziert, ein Commit pro Fix)
|
||||||
|
- [x] **L+M** video-processor.ts zu weite Deutsch-Erkennung. isGermanStream Titel-Fallback nur
|
||||||
|
ganze Wörter (ger/deu raus → konnten falsche Spur picken + echte dt. löschen); looksLikeGerman
|
||||||
|
Release 'dubbed' raus (ital./franz. Dub triggerte German-first). 2 Negativtests. Commit 272a41a.
|
||||||
|
- [x] **H** logger.ts flushAsync slice-snapshot korrumpiert bei 1MB-Cap-Trim während await →
|
||||||
|
ungeschriebene Zeilen verloren. Move-snapshot (Buffer auf [] übernehmen) + Requeue bei
|
||||||
|
Schreibfehler. Commit 4432fa2.
|
||||||
|
- [x] **J+Q** download-manager. J: runPackagePostProcessing finally löschte Map-Eintrag ohne
|
||||||
|
Identity-Guard → Abort+Neustart-Race riss neuen Task raus (Waise + Doppel-Lauf); jetzt nur
|
||||||
|
löschen wenn Map noch auf DIESEN Task/Controller zeigt (handle-Objekt wegen TS2454). Q:
|
||||||
|
collectFilesByExtensions filtert `~rd`-Temp-Präfix (crash-verwaiste Teil-Remuxe nie ins
|
||||||
|
Library). Commit 3c33b98.
|
||||||
|
- [x] **P** extractor.ts nested-Resume-Keys (`nested:<name>`) bei jedem extractPackageArchives
|
||||||
|
gepurged → verschachtelte Archive beim Resume neu entpackt; `startsWith("nested:")` im Prune
|
||||||
|
übersprungen. Commit 61a8304.
|
||||||
|
- [x] **B/I** app-controller.ts importBackup settings-only purgte LIVE-Queue (Dateien blieben auf
|
||||||
|
Platte) + rollte Usage-Zähler zurück. Fix: setSettings({suppressRetroactiveCleanup}) +
|
||||||
|
overlayLiveUsageCounters (extrahiert+wiederverwendet, inkl. Key-Filter). Commit dc05b51.
|
||||||
|
|
||||||
|
### Verifiziert KEINE Bugs / bewusst NICHT angefasst (Advisor-Disziplin: erst belegen, dann ändern)
|
||||||
|
- **G** dropItemContribution "subtrahiert Session-Totals nicht" → **KEIN Bug**: Test "keeps
|
||||||
|
cumulative session totals when completed items are removed" kodifiziert die Absicht (Session-
|
||||||
|
Zähler kumulativ, divergieren bewusst von der Item-Map; Retry-Pfad zieht ab, weil neu geladen
|
||||||
|
wird). Fix-Versuch ließ den Test failen → revertiert, Klarstellungs-Kommentar gesetzt.
|
||||||
|
- **N** stripDualLangFromFileName "Kollision" → **bereits geguarded**: existsAsync-Skip verhindert
|
||||||
|
Überschreiben; Remux machte Inhalt eh deutsch-only; collect strippt `.DL.` downstream. Residual
|
||||||
|
= generischer Rename-TOCTOU (in JEDEM Rename-Pfad), kein spezifischer Bug hier.
|
||||||
|
- **D/E** abort-Klassifizierung über signal.reason statt Text → **deferred (Robustheit, kein
|
||||||
|
Live-Bug auf User-Pfad)**. BELEGT: mega-web-fallback normalisiert JEDEN Abort (Timeout UND
|
||||||
|
Cancel) zu `new Error("aborted:mega-web")` → aktueller Guard `/aborted/i && !/timeout/i` FEUERT
|
||||||
|
→ v1.7.187-Cooldown LÄUFT auf dem Web-Pfad (User-Pfad). Einzige Imperfektion: Cancel >8s wird
|
||||||
|
fälschlich gecooled (minor). Empirisch bestätigt: `AbortSignal.any([ac,timeout]).reason?.name===
|
||||||
|
'TimeoutError'` (timeout) vs string/AbortError (cancel) — falls je gebaut: signal.aborted-gaten,
|
||||||
|
reason.name nutzen, Text-Fallback behalten, reason-Test. Hoch-Risiko (kritischer Unrestrict-Pfad
|
||||||
|
JEDES Downloads) → nicht für Robustheit anfassen. API-Pfad-Abort-Text nicht erschöpfend geprüft.
|
||||||
|
- **E** "API 'cancel'-Pfad umgeht" → **nicht real**: kein `'cancel'`-throw im Code gefunden.
|
||||||
|
- **O** classifyAccountFailure abort-Branch tot → **stehen lassen**: tot NUR wegen aktueller
|
||||||
|
Text-Interception; ein signal.aborted-gated D/E würde ihn wiederbeleben. Kein Kosmetik-Churn.
|
||||||
|
- **F** Mega-Web empty-streak Concurrency → **N-shaped, deferred**: Streak wird bei Erfolg (1956)
|
||||||
|
+ Nicht-Limit-Fehler (2005) gecleart; "bis Neustart gesperrt" ist bewusste Tageslimit-Logik,
|
||||||
|
Restart-cleared; Mega-Web single-flight → Concurrency greift nicht. Keine fühlbare Schädigung
|
||||||
|
konstruierbar → keine Park-State-Maschinerie.
|
||||||
|
- **C** → in A subsumiert (unique Temp-Name). **K** übersprungen (auto-rename-Reorder, Risiko≫Nutzen).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🟢 OFFEN — Backlog (optional, nie begonnen)
|
||||||
|
|
||||||
|
### ✅ Mega-Web Account-Rotation überspringt Account 3 — GEFIXT 2026-06-08 (v1.7.187)
|
||||||
|
**Fix:** Ein Mega-Web-Account-Abbruch (geteiltes Timeout feuert während der Account lief)
|
||||||
|
setzt jetzt einen 2-min-Cooldown auf den Account (nur wenn er ≥8s lief, sonst = User-Cancel,
|
||||||
|
RD_MEGA_ABORT_MIN_RUN_MS env). Dadurch überspringt der download-manager-Retry diesen Account
|
||||||
|
und rotiert zum nächsten (debrid.ts, abort-Handling im Rotations-catch, vor classifyAccountFailure).
|
||||||
|
Log-Event `TIMEOUT_COOLDOWN` (gelb, "Timeout/Abbruch → nächster Account beim Retry") statt
|
||||||
|
rotem "fataler Fehler" (App.tsx:1141 Label). 2 Regressionstests (Cooldown gesetzt → Call 2
|
||||||
|
rotiert; Quick-Abbruch → kein Cooldown). EHRLICH: fixt Korrektheit, NICHT Latenz — Account 1
|
||||||
|
brennt weiter ~60s ins Timeout bevor der Retry auf Account 2 wechselt (instant-Failover bräuchte
|
||||||
|
per-Account-Timeout = größerer Eingriff, bewusst verschoben). Advisor-gegengeprüft.
|
||||||
|
|
||||||
|
**(Ursprüngliche Analyse — Symptom & Mechanismus, zur Doku belassen)**
|
||||||
|
**Symptom (User):** 3 Mega-Debrid-Web-Accounts aktiv, Rotation pendelt aber nur zwischen
|
||||||
|
Account 1 ↔ 2 (bzw. nur Account 1), Account 3 (Su****xe) wird NIE probiert.
|
||||||
|
|
||||||
|
**Verifizierter Mechanismus (Code):**
|
||||||
|
- Rotationsschleife `debrid.ts:1898`. Account 1 → "Mega-Web Antwort leer" → Cooldown 20s →
|
||||||
|
weiter zu Account 2. Account 2 → `aborted:debrid`.
|
||||||
|
- `classifyAccountFailure` (`debrid.ts:2036`) stuft JEDEN Abbruch als **fatal** ein →
|
||||||
|
`throw` (`debrid.ts:1991`) → Schleife bricht ab → **Account 3 nie erreicht.**
|
||||||
|
- Account 2 bekommt beim Fatal-Abbruch **keinen Cooldown** (cooldownMs:0). Beim
|
||||||
|
download-manager-Retry wird Account 1 (Cooldown) übersprungen, aber Account 2 (kein
|
||||||
|
Cooldown) ERNEUT vor Account 3 probiert → bricht wieder ab → ewiges 1↔2.
|
||||||
|
- Geteiltes 60s-Unrestrict-Timeout `download-manager.ts:8590` (`AbortSignal.any([taskAbort,
|
||||||
|
timeout(60s)])`) gilt für die GANZE Rotation, nicht pro Account. Mega-Web pollt intern bis
|
||||||
|
180s (`mega-web-fallback.ts:235` + Poll-Loop `:371`). Sobald das geteilte 60s feuert, bleibt
|
||||||
|
das kombinierte Signal aborted → KEIN späterer Account kriegt im selben Pass eine echte Chance.
|
||||||
|
|
||||||
|
**BESTÄTIGT 2026-06-08 (zweite Screenshots):** Account 1 läuft 10x rasch "erfolgreich"
|
||||||
|
(11:51:45–11:52:26), dann zwei "abgebrochen (aborted:debrid)" um 11:53:30 UND 11:54:30 —
|
||||||
|
**exakt 60s auseinander** = das geteilte 60s-Unrestrict-Timeout feuert (kein User-Stop, der
|
||||||
|
wiederholt sich nicht periodisch). Hier rotiert GAR NICHTS: Account 1 bricht ab → fatal →
|
||||||
|
Rotation stoppt sofort bei idx=0 → Account 2 und 3 werden NIE probiert. Bug eindeutig
|
||||||
|
bestätigt, elapsedMs nicht mehr nötig. Account 1 selbst ist gesund (10x ok) — Mega-Web hängt
|
||||||
|
nur sporadisch (no-server-Poll) bis ins 60s-Timeout.
|
||||||
|
|
||||||
|
**Fix-Design (wenn bestätigt):** Pro-Account-Timeout-Budget, abgekoppelt vom geteilten Cap.
|
||||||
|
debrid.ts braucht das **cancel-only** Signal getrennt vom Timeout (kombiniertes Signal kann
|
||||||
|
beides nicht unterscheiden). Minimal-invasiv: optionaler `opts`-Param an `unrestrictLink`
|
||||||
|
({cancelSignal, perAttemptTimeoutMs}) — nur die Mega-Rotation liest ihn, andere Provider
|
||||||
|
unberührt (kombiniertes Signal bleibt). Pro Account: `AbortSignal.any([cancelSignal,
|
||||||
|
AbortSignal.timeout(perAttemptMs)])`. Abbruch-Logik: cancelSignal aborted → echter Stop;
|
||||||
|
eigenes Account-Timer gefeuert → non-fatal, Cooldown, weiter zum nächsten Account (inkl. 3).
|
||||||
|
**Regressionstest ZUERST** (3 Accounts, 1+2 failen/aborten → assert Account 3 kriegt TEST).
|
||||||
|
**Advisor-Gate** vor Eingriff (kritischer Unrestrict-Pfad, betrifft jeden Download).
|
||||||
|
Hinweis: Grundursache der leeren Antworten = Mega-Debrid Server/IP-Thema — Fix macht Rotation
|
||||||
|
nur FAIRER (alle Accounts drankommen), bringt aber keinen busy Server zum Antworten.
|
||||||
|
|
||||||
|
### Features / UX (nach ROI)
|
||||||
|
App läuft headless auf Windows-Server → Nutzer sitzt nicht davor.
|
||||||
|
|
||||||
|
1. [ ] **Push-Benachrichtigungen** (Discord/Telegram/ntfy) — S–M. Paket fertig/Fehler/Quota/Provider-down aufs Handy. Neuer `notifier.ts`, Hooks an Completion-Punkten. **Höchster ROI.**
|
||||||
|
2. [ ] **Fernsteuerung über Debug-Server** (POST-Endpunkte) — S–M. Server hat HTTP + Token-Auth, aber nur GET. POST `/control/add-links`, `/start`, `/stop`.
|
||||||
|
3. [ ] **URL-Duplikat-Erkennung beim Hinzufügen** — S. History-`urls` existiert, wird nie geprüft → versehentliche Re-Downloads. Warnen: "3 Links bereits geladen".
|
||||||
|
4. [ ] **Pre-Flight-Check + Bulk-Skip toter Links** — M. Vor Start Größe/Name/Online für ganze Queue, "alle offline überspringen".
|
||||||
|
5. [ ] **Speicherplatz-Vorabprüfung vor Start** — S. Aktuell keine Free-Space-Prüfung für Downloads → Abbruch mitten drin bei voller Platte.
|
||||||
|
6. [ ] **Konsolidierte Fehler-Ansicht** — M. Alle fehlgeschlagenen Items flach + Fehlertext + "alle erneut versuchen". (Daten dafür liegen jetzt teils in der Error-Ring aus v1.7.185.)
|
||||||
|
7. [ ] **Per-Provider-Statistik** — M. Rohdaten (`providerTotalUsageBytes`) existieren, werden nicht dargestellt. Welches Abo lohnt sich?
|
||||||
|
8. [ ] **Auto-Retry fehlgeschlagener Pakete nach Wartezeit** — S–M. Quota/Cooldown-Fails am nächsten Tag automatisch neu.
|
||||||
|
9. [ ] **Plex/Jellyfin Library-Refresh nach MKV-Move** — S. Gleicher Hook wie #1.
|
||||||
|
10. [ ] **Watch-Folder für DLC/Link-Auto-Import** — M.
|
||||||
|
|
||||||
|
### Design-Richtung (Entscheidung steht aus)
|
||||||
|
4 Mockups in `design-mockups/` (index.html = Vergleich): **Aurora** (verfeinert dark, geringstes Risiko) · **Command** (Terminal/Ops, dicht) · **Vellum** (light editorial) · **Nebula** (neon).
|
||||||
|
→ Richtung wählen. Siehe Memory: design-taste (Anti-KI-Look) + design-direction (Ember-Wärme, flach/ehrlich).
|
||||||
|
|
||||||
|
### Alte Audit-Items (2026-04-04, Status ggf. veraltet — VOR Fix gegen aktuellen Code verifizieren)
|
||||||
|
- [ ] Debrid-Link `maxDataHost` kühlt ganzen Key ab statt nur den Host
|
||||||
|
- [ ] Debrid-Link `fileNotAvailable` setzt Key auf "error" statt temporär
|
||||||
|
- [ ] AllDebrid: kein per-host-Cooldown für erschöpfte Quotas
|
||||||
|
- [ ] LinkSnappy: keine Auth-Dedup (parallele Requests rufen beide authenticate())
|
||||||
|
- [ ] Extractor password-cache race (parallele Worker mutieren `packageLearnedPasswords`)
|
||||||
|
- [x] Hybrid race: 1 Datei/Staffel evtl. beim MKV-Move nicht umbenannt (NUR per-package fixen — Post-MKV-Move-Scan ist tabu, v1.7.107 revertiert) → GEFIXT v1.7.196
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Hybrid-Race .DL.+Doppeltonspur — v1.7.196 (2026-06-10)
|
||||||
|
|
||||||
|
User-Goal: Desperate Housewives S03E08/E11/E17 behielten .DL. + DE+EN-Tonspur trotz keepGermanAudioOnly.
|
||||||
|
Vermutung "langer Titel" widerlegt (gleich lange Namen). Support-Bundle: genau die 3 hatten 0× "Tonspur-Bereinigung OK".
|
||||||
|
|
||||||
|
Ursache: Hybrid-Extraktor laeuft SEPARAT von der per-Paket-Kette (autoRename→keepGermanAudio→collect).
|
||||||
|
Waehrend langsamer ~3s-Remux (1,5GB) lief, entpackte er frische .DL.-MKVs in extractDir — nach dem
|
||||||
|
keepGermanAudio-Datei-Scan, vor dem Ketten-collect. collectMkvFilesToLibrary (deferFreshFiles=true)
|
||||||
|
verschob sie mit .DL. in die Library, bevor ihr Audio-Strip lief; raus aus extractDir fand der finale
|
||||||
|
Deferred-keepGermanAudio sie nie. 2000ms-Frische-Gate griff nicht (Dateien ~2,3s alt).
|
||||||
|
|
||||||
|
Fix: collectMkvFilesToLibrary haelt bei deferFreshFiles && keepGermanAudioOnly remuxbare (.mkv/.mp4)
|
||||||
|
Dateien mit .DL.-Marker zurueck → bleiben in extractDir, spaetere Hybrid-Runde ODER finaler Deferred-Pass
|
||||||
|
bereinigt+sammelt. Praezise (eine .DL.avi wird nie remuxt → nicht zurueckhalten). Deferred-Pass
|
||||||
|
(deferFreshFiles=false) unberuehrt → legitim doppeltonige Datei ohne DE-Track wird weiter gesammelt.
|
||||||
|
Regressionstest tests/hybrid-collect-race.test.ts (3 Faelle). 804 Tests, tsc=6, self-check+build,
|
||||||
|
Gitea+Mirror 3664e78 verifiziert (beide draft=false, 4 Assets, latest.yml path==Asset).
|
||||||
|
|
||||||
|
OFFEN: Altfiles in entpackt2 werden NICHT retroaktiv bereinigt — nur neue Downloads. Remediation der
|
||||||
|
3 Episoden nur auf Admin-Account (ploet-Session sieht Administrator-Profil nicht) oder via geplantes
|
||||||
|
"Bibliothek-Batch Tonspur"-QoL.
|
||||||
|
|
||||||
|
## ℹ️ Webhook "0 von 5 getriggert" (2026-06-10) — KEIN Code-Bug
|
||||||
|
Test-Nachricht kam an (URL OK), aber alle 3 Notify-Toggles unchecked + "Einstellungen speichern" offen
|
||||||
|
(ungespeichert). Toggle-Default=aus. User muss Toggles aktivieren UND speichern.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ ERLEDIGT — Archiv (Details in git-History + Memory)
|
||||||
|
|
||||||
|
- **Erweitertes Logging** → released **v1.7.185** (Crash-Handler, Renderer-Fehler-IPC, RD_DEBUG-Level, Error-Ring + `/errors`, ENOSPC-Klassifizierung, Memory-Heartbeat). → Memory: extended-logging
|
||||||
|
- **Link-Prefetch** → untersucht (6-Agent) + **bewusst verworfen** (marginal bei maxParallel 8, Mega-Web single-flight). → Memory: link-prefetch-declined
|
||||||
|
- **Backup nur Settings** → v1.7.184 (`backupIncludeDownloads`-Toggle + 4 Selektions/Flicker-Fixes). → Memory: backup-settings-only
|
||||||
|
- **Account-Rotation-Overhaul** → v1.7.164–168 (Validity/Premium-Badges, Live-Panel, "Alle prüfen"). → Memory: account-rotation
|
||||||
|
- **Mega-Debrid-Account deaktivieren (UI)** → erledigt (Toggle im Edit-Dialog, im Code verifiziert 2026-06-07)
|
||||||
|
- **Bugs/Robustheit (Deferred-Pipeline H1/H2/H3/M1/M2/N1)** → v1.7.158/159; M3 bewusst übersprungen (Generation-Guard schützt Integrität bereits)
|
||||||
|
- **Deferred-Pfad Rename-Gap** → gefixt v1.7.162+ (finaler Deferred-Pass benennt frische Dateien vor Collect um; Repro-Test grün)
|
||||||
|
- **Repo-Privacy-Audit** → GitHub gelöscht+neu (saubere History), Gitea unberührt. → Memory: repo-privacy-audit
|
||||||
|
|
||||||
|
### Bewusst NICHT angefasst (Crash-Debris / alte Experimente)
|
||||||
|
- Gestashtes Crash-Debris `stash@{0}` (Revert von 08372f9/18eada9/98dc366 + log.old) — bei Bedarf recoverbar, sonst verwerfbar
|
||||||
|
- Untracked `*-postprocess/` + `fix-library-renames.mjs` — alte Experimente (Apr/Mai)
|
||||||
|
|
||||||
|
## ✅ Mega-Rotation Round-Robin — v1.7.197 (2026-06-11)
|
||||||
|
|
||||||
|
User-Goal: "4 Accounts, rotiert nur zwischen 1-3, der 4. wird nie angefasst."
|
||||||
|
ACHTUNG: Beide Apps (Downloader + Multi-Hoster-Uploader) schreiben "account-rotation.log" —
|
||||||
|
das Log im User-Ordner war das des Uploaders (dort eigener Befund, Fix auf User-Wunsch
|
||||||
|
revertiert + geparkt). Downloader-Beweis kam aus rd_downloader.log im Support-Bundle:
|
||||||
|
First-Usable-Wins ab Account 1 — Acc1/2 dauernd am Tageslimit, Acc3 trug alles (1603/1603 OK),
|
||||||
|
Acc4 hatte null Events. Fix: Round-Robin-Cursor in unrestrictWithAccounts (Start beim Account
|
||||||
|
nach dem zuletzt getesteten, alle Skip-Checks unverändert, Reset im Test-Hook). Nebenfix:
|
||||||
|
Support-Bundle exportiert jetzt account-rotation.log(+.old). 2 Regressionstests, 806 grün.
|
||||||
|
Releases Gitea + Mirror e104e72 verifiziert. Debrid-Link-Keys bewusst First-Wins gelassen:
|
||||||
|
- [ ] Kandidat: Round-Robin auch für Debrid-Link-Keys (gleiches Muster), falls User dort
|
||||||
|
mal mehrere Keys nutzt
|
||||||
|
|||||||
@ -1,35 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { applyAccountDialogToSettings, AccountDialogState } from "../src/renderer/App";
|
|
||||||
import { defaultSettings } from "../src/main/constants";
|
|
||||||
|
|
||||||
function megaDialog(kind: "megadebrid-api" | "megadebrid-web"): AccountDialogState {
|
|
||||||
return {
|
|
||||||
mode: "edit",
|
|
||||||
kind,
|
|
||||||
token: "",
|
|
||||||
login: "",
|
|
||||||
password: "",
|
|
||||||
dailyLimitGb: "",
|
|
||||||
keyDailyLimitGbById: {},
|
|
||||||
megaAccounts: [{ login: "user@x", password: "pw" }],
|
|
||||||
megaNewLogin: "",
|
|
||||||
megaNewPassword: "",
|
|
||||||
megaDisabledIds: []
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("applyAccountDialogToSettings — keeps the user's Mega preferApi choice", () => {
|
|
||||||
it("does not flip megaDebridPreferApi to true when editing the API account", () => {
|
|
||||||
const settings = { ...defaultSettings(), megaDebridApiEnabled: true, megaDebridWebEnabled: true, megaDebridPreferApi: false };
|
|
||||||
const next = applyAccountDialogToSettings(settings, megaDialog("megadebrid-api"));
|
|
||||||
expect(next.megaDebridApiEnabled).toBe(true);
|
|
||||||
expect(next.megaDebridPreferApi).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not flip megaDebridPreferApi to false when editing the Web account", () => {
|
|
||||||
const settings = { ...defaultSettings(), megaDebridApiEnabled: true, megaDebridWebEnabled: true, megaDebridPreferApi: true };
|
|
||||||
const next = applyAccountDialogToSettings(settings, megaDialog("megadebrid-web"));
|
|
||||||
expect(next.megaDebridWebEnabled).toBe(true);
|
|
||||||
expect(next.megaDebridPreferApi).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,232 +0,0 @@
|
|||||||
import fs from "node:fs";
|
|
||||||
import http from "node:http";
|
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
import { once } from "node:events";
|
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import { buildBackupPayload, resolveMcpRemoteRestore, BackupMcpRemote } from "../src/main/backup-payload";
|
|
||||||
import { defaultSettings } from "../src/main/constants";
|
|
||||||
import { normalizeSettings } from "../src/main/storage";
|
|
||||||
import {
|
|
||||||
startDebugServer,
|
|
||||||
stopDebugServer,
|
|
||||||
restartDebugServer,
|
|
||||||
writeDebugServerConfig,
|
|
||||||
getDebugAllowlist,
|
|
||||||
getDebugServerRuntimeStatus
|
|
||||||
} from "../src/main/debug-server";
|
|
||||||
import type { DownloadManager } from "../src/main/download-manager";
|
|
||||||
import type { AppSettings, SessionState } from "../src/shared/types";
|
|
||||||
|
|
||||||
const tempDirs: string[] = [];
|
|
||||||
|
|
||||||
function input(settingsOverride: Partial<AppSettings>, mcpRemote?: BackupMcpRemote) {
|
|
||||||
return {
|
|
||||||
settings: { ...defaultSettings(), ...settingsOverride } as AppSettings,
|
|
||||||
appVersion: "1.7.224",
|
|
||||||
exportedAt: "2026-06-19T00:00:00.000Z",
|
|
||||||
session: {} as unknown as SessionState,
|
|
||||||
history: [],
|
|
||||||
mcpRemote
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getFreePort(): Promise<number> {
|
|
||||||
const probe = http.createServer();
|
|
||||||
probe.listen(0, "127.0.0.1");
|
|
||||||
await once(probe, "listening");
|
|
||||||
const address = probe.address();
|
|
||||||
if (!address || typeof address === "string") {
|
|
||||||
throw new Error("port probe failed");
|
|
||||||
}
|
|
||||||
probe.close();
|
|
||||||
await once(probe, "close");
|
|
||||||
return address.port;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForReady(url: string): Promise<void> {
|
|
||||||
const deadline = Date.now() + 5000;
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
try {
|
|
||||||
const res = await fetch(url);
|
|
||||||
if (res.ok) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
|
||||||
}
|
|
||||||
throw new Error(`debug server not ready: ${url}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
stopDebugServer();
|
|
||||||
while (tempDirs.length > 0) {
|
|
||||||
const dir = tempDirs.pop();
|
|
||||||
if (!dir) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
fs.rmSync(dir, { recursive: true, force: true });
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("backup mcpRemote export gating", () => {
|
|
||||||
it("includes mcpRemote when backupIncludeMcp is on", () => {
|
|
||||||
const section: BackupMcpRemote = { allowlist: ["10.0.0.5", "192.168.1.0/24"], port: 9999, hostMode: "network" };
|
|
||||||
const payload = buildBackupPayload(input({ backupIncludeMcp: true }, section));
|
|
||||||
expect(payload.mcpRemote).toEqual(section);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("omits mcpRemote when the toggle is off even if a section is provided", () => {
|
|
||||||
const payload = buildBackupPayload(input({ backupIncludeMcp: false }, { allowlist: ["10.0.0.5"], port: 9868, hostMode: "network" }));
|
|
||||||
expect(payload.mcpRemote).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("omits mcpRemote when toggle on but no section gathered", () => {
|
|
||||||
const payload = buildBackupPayload(input({ backupIncludeMcp: true }, undefined));
|
|
||||||
expect(payload.mcpRemote).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("the mcpRemote section carries ONLY allowlist/port/hostMode (no token, publicHost, name)", () => {
|
|
||||||
const payload = buildBackupPayload(input({ backupIncludeMcp: true }, { allowlist: ["10.0.0.5"], port: 9868, hostMode: "network" }));
|
|
||||||
expect(payload.mcpRemote && Object.keys(payload.mcpRemote).sort()).toEqual(["allowlist", "hostMode", "port"]);
|
|
||||||
const sectionJson = JSON.stringify(payload.mcpRemote);
|
|
||||||
expect(sectionJson.toLowerCase()).not.toContain("token");
|
|
||||||
expect(sectionJson).not.toContain("publicHost");
|
|
||||||
expect(sectionJson.toLowerCase()).not.toContain("\"name\"");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("backupIncludeMcp settings persistence", () => {
|
|
||||||
it("normalizeSettings preserves backupIncludeMcp (the toggle survives save/load)", () => {
|
|
||||||
expect(normalizeSettings({ backupIncludeMcp: true } as unknown as AppSettings).backupIncludeMcp).toBe(true);
|
|
||||||
expect(normalizeSettings({ backupIncludeMcp: false } as unknown as AppSettings).backupIncludeMcp).toBe(false);
|
|
||||||
expect(normalizeSettings({} as unknown as AppSettings).backupIncludeMcp).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("resolveMcpRemoteRestore", () => {
|
|
||||||
it("maps network + non-empty allowlist to 0.0.0.0", () => {
|
|
||||||
expect(resolveMcpRemoteRestore({ allowlist: ["10.0.0.5"], port: 9868, hostMode: "network" }))
|
|
||||||
.toEqual({ host: "0.0.0.0", port: 9868, allowlist: ["10.0.0.5"] });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("SAFETY: network with EMPTY allowlist binds local, never 0.0.0.0", () => {
|
|
||||||
expect(resolveMcpRemoteRestore({ allowlist: [], port: 9868, hostMode: "network" })?.host).toBe("127.0.0.1");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("maps local to 127.0.0.1", () => {
|
|
||||||
expect(resolveMcpRemoteRestore({ allowlist: ["10.0.0.5"], port: 9868, hostMode: "local" })?.host).toBe("127.0.0.1");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects an out-of-range or non-integer port", () => {
|
|
||||||
expect(resolveMcpRemoteRestore({ allowlist: ["10.0.0.5"], port: 80, hostMode: "network" })?.port).toBeUndefined();
|
|
||||||
expect(resolveMcpRemoteRestore({ allowlist: ["10.0.0.5"], port: 70000, hostMode: "network" })?.port).toBeUndefined();
|
|
||||||
expect(resolveMcpRemoteRestore({ allowlist: ["10.0.0.5"], port: 9868.5, hostMode: "network" })?.port).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("filters non-string and blank allowlist entries and trims", () => {
|
|
||||||
const r = resolveMcpRemoteRestore({ allowlist: ["10.0.0.5", "", " ", 5, null, " 8.8.8.8 "], port: 9868, hostMode: "network" });
|
|
||||||
expect(r?.allowlist).toEqual(["10.0.0.5", "8.8.8.8"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null for missing or empty/invalid sections", () => {
|
|
||||||
expect(resolveMcpRemoteRestore(undefined)).toBeNull();
|
|
||||||
expect(resolveMcpRemoteRestore(null)).toBeNull();
|
|
||||||
expect(resolveMcpRemoteRestore("x")).toBeNull();
|
|
||||||
expect(resolveMcpRemoteRestore({})).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("backup mcpRemote live restore round-trip", () => {
|
|
||||||
it("export -> resolve -> apply is reflected in the running debug-server (proves restart fired)", async () => {
|
|
||||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bkmcp-"));
|
|
||||||
tempDirs.push(baseDir);
|
|
||||||
const startPort = await getFreePort();
|
|
||||||
const restorePort = await getFreePort();
|
|
||||||
fs.writeFileSync(path.join(baseDir, "debug_token.txt"), "rt-secret", "utf8");
|
|
||||||
fs.writeFileSync(path.join(baseDir, "debug_port.txt"), String(startPort), "utf8");
|
|
||||||
fs.writeFileSync(path.join(baseDir, "debug_host.txt"), "127.0.0.1", "utf8");
|
|
||||||
fs.writeFileSync(path.join(baseDir, "debug_allowlist.txt"), "", "utf8");
|
|
||||||
startDebugServer({} as unknown as DownloadManager, baseDir);
|
|
||||||
await waitForReady(`http://127.0.0.1:${startPort}/health?token=rt-secret`);
|
|
||||||
expect(getDebugAllowlist()).toEqual([]);
|
|
||||||
|
|
||||||
const payload = buildBackupPayload(input(
|
|
||||||
{ backupIncludeMcp: true },
|
|
||||||
{ allowlist: ["203.0.113.4", "10.0.0.0/24"], port: restorePort, hostMode: "network" }
|
|
||||||
));
|
|
||||||
|
|
||||||
const restore = resolveMcpRemoteRestore(payload.mcpRemote);
|
|
||||||
expect(restore).not.toBeNull();
|
|
||||||
writeDebugServerConfig({ host: restore!.host, port: restore!.port, allowlist: restore!.allowlist });
|
|
||||||
const status = await restartDebugServer();
|
|
||||||
|
|
||||||
expect(getDebugAllowlist()).toEqual(["203.0.113.4", "10.0.0.0/24"]);
|
|
||||||
expect(status.port).toBe(restorePort);
|
|
||||||
expect(status.host).toBe("0.0.0.0");
|
|
||||||
expect(status.allowlistCount).toBe(2);
|
|
||||||
|
|
||||||
expect(fs.readFileSync(path.join(baseDir, "debug_token.txt"), "utf8").trim()).toBe("rt-secret");
|
|
||||||
expect(fs.existsSync(path.join(baseDir, "debug_remote.json"))).toBe(false);
|
|
||||||
|
|
||||||
await waitForReady(`http://127.0.0.1:${restorePort}/health?token=rt-secret`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("full-backup path writes the debug_* files to disk without a restart (boot picks them up)", async () => {
|
|
||||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bkmcp2-"));
|
|
||||||
tempDirs.push(baseDir);
|
|
||||||
const startPort = await getFreePort();
|
|
||||||
fs.writeFileSync(path.join(baseDir, "debug_token.txt"), "rt2", "utf8");
|
|
||||||
fs.writeFileSync(path.join(baseDir, "debug_port.txt"), String(startPort), "utf8");
|
|
||||||
fs.writeFileSync(path.join(baseDir, "debug_host.txt"), "127.0.0.1", "utf8");
|
|
||||||
fs.writeFileSync(path.join(baseDir, "debug_allowlist.txt"), "", "utf8");
|
|
||||||
startDebugServer({} as unknown as DownloadManager, baseDir);
|
|
||||||
await waitForReady(`http://127.0.0.1:${startPort}/health?token=rt2`);
|
|
||||||
|
|
||||||
const restore = resolveMcpRemoteRestore({ allowlist: ["198.51.100.9"], port: 9100, hostMode: "network" });
|
|
||||||
writeDebugServerConfig({ host: restore!.host, port: restore!.port, allowlist: restore!.allowlist });
|
|
||||||
|
|
||||||
expect(fs.readFileSync(path.join(baseDir, "debug_host.txt"), "utf8").trim()).toBe("0.0.0.0");
|
|
||||||
expect(fs.readFileSync(path.join(baseDir, "debug_port.txt"), "utf8").trim()).toBe("9100");
|
|
||||||
expect(fs.readFileSync(path.join(baseDir, "debug_allowlist.txt"), "utf8")).toContain("198.51.100.9");
|
|
||||||
expect(getDebugServerRuntimeStatus().port).toBe(startPort);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("debug-server live diagnostics endpoints", () => {
|
|
||||||
it("serves /providers (live cooldown/runtime snapshot) and /logs/conversion over authenticated HTTP", async () => {
|
|
||||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-prov-"));
|
|
||||||
tempDirs.push(baseDir);
|
|
||||||
const port = await getFreePort();
|
|
||||||
fs.writeFileSync(path.join(baseDir, "debug_token.txt"), "prov-secret", "utf8");
|
|
||||||
fs.writeFileSync(path.join(baseDir, "debug_port.txt"), String(port), "utf8");
|
|
||||||
fs.writeFileSync(path.join(baseDir, "debug_host.txt"), "127.0.0.1", "utf8");
|
|
||||||
fs.writeFileSync(path.join(baseDir, "debug_allowlist.txt"), "", "utf8");
|
|
||||||
startDebugServer({} as unknown as DownloadManager, baseDir);
|
|
||||||
await waitForReady(`http://127.0.0.1:${port}/health?token=prov-secret`);
|
|
||||||
|
|
||||||
const provRes = await fetch(`http://127.0.0.1:${port}/providers?token=prov-secret`);
|
|
||||||
expect(provRes.status).toBe(200);
|
|
||||||
const prov = await provRes.json();
|
|
||||||
expect(typeof prov.capturedAtMs).toBe("number");
|
|
||||||
expect(prov.megaDebrid).toBeTruthy();
|
|
||||||
expect(Array.isArray(prov.megaDebrid.accounts)).toBe(true);
|
|
||||||
expect(typeof prov.megaDebrid.rotationCursor).toBe("number");
|
|
||||||
expect(prov.debridLink).toBeTruthy();
|
|
||||||
expect(Array.isArray(prov.debridLink.keys)).toBe(true);
|
|
||||||
|
|
||||||
const unauth = await fetch(`http://127.0.0.1:${port}/providers`);
|
|
||||||
expect(unauth.status).toBe(401);
|
|
||||||
|
|
||||||
const convRes = await fetch(`http://127.0.0.1:${port}/logs/conversion?token=prov-secret`);
|
|
||||||
expect(convRes.status).toBe(200);
|
|
||||||
const conv = await convRes.json();
|
|
||||||
expect(Array.isArray(conv.lines)).toBe(true);
|
|
||||||
expect(conv).toHaveProperty("available");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,42 +0,0 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { encodeConnectionCode } from "../src/main/connection-code";
|
|
||||||
import { decodeConnectionCode } from "../tools/rd-diagnostics-mcp/src/code.mjs";
|
|
||||||
|
|
||||||
describe("connection-code", () => {
|
|
||||||
it("round-trips through the bridge decoder", () => {
|
|
||||||
const code = encodeConnectionCode({ host: "203.0.113.5", port: 9868, token: "deadbeef", name: "server-1" });
|
|
||||||
expect(code.startsWith("rddiag:v1:")).toBe(true);
|
|
||||||
const decoded = decodeConnectionCode(code);
|
|
||||||
expect(decoded.host).toBe("203.0.113.5");
|
|
||||||
expect(decoded.port).toBe(9868);
|
|
||||||
expect(decoded.token).toBe("deadbeef");
|
|
||||||
expect(decoded.name).toBe("server-1");
|
|
||||||
expect(decoded.scheme).toBe("http");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("carries https scheme and fingerprint when set", () => {
|
|
||||||
const code = encodeConnectionCode({
|
|
||||||
host: "diag.example.com",
|
|
||||||
port: 8443,
|
|
||||||
token: "abc",
|
|
||||||
scheme: "https",
|
|
||||||
fingerprint: "AA:BB:CC"
|
|
||||||
});
|
|
||||||
const decoded = decodeConnectionCode(code);
|
|
||||||
expect(decoded.scheme).toBe("https");
|
|
||||||
expect(decoded.fingerprint).toBe("AA:BB:CC");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("omits scheme key for plain http (default)", () => {
|
|
||||||
const code = encodeConnectionCode({ host: "10.0.0.2", port: 9868, token: "t" });
|
|
||||||
const json = JSON.parse(Buffer.from(code.slice("rddiag:v1:".length).replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8"));
|
|
||||||
expect(json.s).toBeUndefined();
|
|
||||||
expect(json).toMatchObject({ v: 1, h: "10.0.0.2", p: 9868, t: "t" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects invalid input", () => {
|
|
||||||
expect(() => encodeConnectionCode({ host: "", port: 9868, token: "t" })).toThrow();
|
|
||||||
expect(() => encodeConnectionCode({ host: "h", port: 0, token: "t" })).toThrow();
|
|
||||||
expect(() => encodeConnectionCode({ host: "h", port: 9868, token: "" })).toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,71 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import {
|
|
||||||
formatConversionBlock,
|
|
||||||
hasActiveConversionTrace,
|
|
||||||
runWithConversionTrace,
|
|
||||||
traceConversionPhase,
|
|
||||||
type ConversionTrace
|
|
||||||
} from "../src/main/conversion-trace";
|
|
||||||
|
|
||||||
describe("formatConversionBlock", () => {
|
|
||||||
it("renders a header with verdict + total and one indented line per phase", () => {
|
|
||||||
const trace: ConversionTrace = {
|
|
||||||
startedAt: 1000,
|
|
||||||
itemId: "id1",
|
|
||||||
itemName: "tvs-foo.part5.rar",
|
|
||||||
link: "https://rapidgator.net/file/abc/tvs-foo.part5.rar.html",
|
|
||||||
providerOrder: "megadebrid-api,megadebrid-web",
|
|
||||||
notes: { slots: "conv2/dl6/max8" },
|
|
||||||
phases: [
|
|
||||||
{ atMs: 0, phase: "chain-try", provider: "megadebrid-api" },
|
|
||||||
{ atMs: 5, phase: "token", provider: "megadebrid-api", account: "2/2(e3)", tokenState: "fresh", workMs: 812, outcome: "ok" },
|
|
||||||
{ atMs: 820, phase: "api-getlink", provider: "megadebrid-api", account: "2/2(e3)", workMs: 634, outcome: "ok" }
|
|
||||||
]
|
|
||||||
};
|
|
||||||
const block = formatConversionBlock(trace, "OK", "", 1450);
|
|
||||||
|
|
||||||
const lines = block.split("\n");
|
|
||||||
expect(lines[0]).toContain("[CONV]");
|
|
||||||
expect(lines[0]).toContain("item=tvs-foo.part5.rar");
|
|
||||||
expect(lines[0]).toContain("result=OK");
|
|
||||||
expect(lines[0]).toContain("total=1450ms");
|
|
||||||
expect(lines[0]).toContain("slots=conv2/dl6/max8");
|
|
||||||
expect(lines).toHaveLength(4);
|
|
||||||
expect(lines[2]).toContain("+5ms token");
|
|
||||||
expect(lines[2]).toContain("token=fresh");
|
|
||||||
expect(lines[2]).toContain("workMs=812");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("includes the failure detail in the header verdict", () => {
|
|
||||||
const trace: ConversionTrace = {
|
|
||||||
startedAt: 0, itemId: "i", itemName: "x", link: "l", providerOrder: "megadebrid-web", notes: {},
|
|
||||||
phases: [{ atMs: 60000, phase: "caller-timeout", provider: "megadebrid-web", outcome: "timeout", detail: "Unrestrict Timeout nach 60s" }]
|
|
||||||
};
|
|
||||||
const block = formatConversionBlock(trace, "FAIL", "Unrestrict Timeout nach 60s", 60003);
|
|
||||||
expect(block.split("\n")[0]).toContain("result=FAIL (Unrestrict Timeout nach 60s)");
|
|
||||||
expect(block).toContain("caller-timeout");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("conversion trace context", () => {
|
|
||||||
it("traceConversionPhase is a no-op outside an active trace and does not throw", () => {
|
|
||||||
expect(hasActiveConversionTrace()).toBe(false);
|
|
||||||
expect(() => traceConversionPhase({ phase: "orphan" })).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("activates an ambient trace across awaits inside runWithConversionTrace", async () => {
|
|
||||||
expect(hasActiveConversionTrace()).toBe(false);
|
|
||||||
const seen = await runWithConversionTrace(
|
|
||||||
{ itemId: "i", itemName: "n", link: "l", providerOrder: "megadebrid-api" },
|
|
||||||
async () => {
|
|
||||||
const before = hasActiveConversionTrace();
|
|
||||||
traceConversionPhase({ phase: "chain-try", provider: "megadebrid-api" });
|
|
||||||
await Promise.resolve();
|
|
||||||
const afterAwait = hasActiveConversionTrace();
|
|
||||||
return before && afterAwait;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
expect(seen).toBe(true);
|
|
||||||
expect(hasActiveConversionTrace()).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -3,8 +3,7 @@ import { defaultSettings, REQUEST_RETRIES } from "../src/main/constants";
|
|||||||
import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys";
|
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 { clearMegaDebridEmptyResponseStreak, DebridService, extractRapidgatorFilenameFromHtml, fetchAllDebridHostInfo, fetchDebridLinkHostLimits, filenameFromRapidgatorUrlPath, getDebridLinkKeyRuntimeStateForTests, getMegaDebridAccountCooldownState, MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART, normalizeResolvedFilename, primeMegaDebridLatencyForTests, 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;
|
||||||
|
|
||||||
@ -16,21 +15,6 @@ afterEach(() => {
|
|||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("leadProviderChainWith", () => {
|
|
||||||
it("leaves the order unchanged when no preferred provider is given", () => {
|
|
||||||
expect(leadProviderChainWith(["realdebrid", "debridlink", "alldebrid"], null)).toEqual(["realdebrid", "debridlink", "alldebrid"]);
|
|
||||||
expect(leadProviderChainWith(["realdebrid", "debridlink"], undefined)).toEqual(["realdebrid", "debridlink"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leads with the preferred provider but keeps every other provider as a later fallback", () => {
|
|
||||||
expect(leadProviderChainWith(["realdebrid", "debridlink", "alldebrid"], "alldebrid")).toEqual(["alldebrid", "realdebrid", "debridlink"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not drop or strand any provider when the preferred one is not in the order", () => {
|
|
||||||
expect(leadProviderChainWith(["realdebrid", "debridlink"], "alldebrid")).toEqual(["realdebrid", "debridlink"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("debrid service", () => {
|
describe("debrid service", () => {
|
||||||
it("falls back to Mega web when Real-Debrid fails", async () => {
|
it("falls back to Mega web when Real-Debrid fails", async () => {
|
||||||
const settings = {
|
const settings = {
|
||||||
@ -150,44 +134,6 @@ describe("debrid service", () => {
|
|||||||
expect(calledUrls.some((url) => url.includes("api.real-debrid.com/rest/1.0/unrestrict/link"))).toBe(false);
|
expect(calledUrls.some((url) => url.includes("api.real-debrid.com/rest/1.0/unrestrict/link"))).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("leads the provider chain with the preferred (non-cooled) provider when one is hinted", async () => {
|
|
||||||
const settings = {
|
|
||||||
...defaultSettings(),
|
|
||||||
token: "rd-token",
|
|
||||||
debridLinkApiKeys: "dl-token",
|
|
||||||
providerOrder: ["realdebrid", "debridlink"] as const,
|
|
||||||
providerPrimary: "realdebrid" as const,
|
|
||||||
providerSecondary: "debridlink" as const,
|
|
||||||
providerTertiary: "none" as const,
|
|
||||||
autoProviderFallback: true
|
|
||||||
};
|
|
||||||
|
|
||||||
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
|
||||||
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
||||||
if (url.includes("debrid-link.com/api/v2/downloader/add")) {
|
|
||||||
return new Response(JSON.stringify({
|
|
||||||
success: true,
|
|
||||||
value: { downloadUrl: "https://debrid-link.example/file.bin", name: "file.bin", size: 1234 }
|
|
||||||
}), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
||||||
}
|
|
||||||
if (url.includes("api.real-debrid.com/rest/1.0/unrestrict/link")) {
|
|
||||||
return new Response(JSON.stringify({
|
|
||||||
download: "https://rd.example/file.bin",
|
|
||||||
filename: "file.bin",
|
|
||||||
filesize: 1234
|
|
||||||
}), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
||||||
}
|
|
||||||
return new Response("not-found", { status: 404 });
|
|
||||||
}) as typeof fetch;
|
|
||||||
|
|
||||||
const service = new DebridService(settings);
|
|
||||||
const control = await service.unrestrictLink("https://hoster.example/lead-control.bin");
|
|
||||||
expect(control.provider).toBe("realdebrid");
|
|
||||||
|
|
||||||
const preferred = await service.unrestrictLink("https://hoster.example/lead-pref.bin", undefined, undefined, "debridlink");
|
|
||||||
expect(preferred.provider).toBe("debridlink");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses the next Debrid-Link key when the first key hit its local daily limit", async () => {
|
it("uses the next Debrid-Link key when the first key hit its local daily limit", async () => {
|
||||||
const keys = parseDebridLinkApiKeys("dl-key-one\ndl-key-two");
|
const keys = parseDebridLinkApiKeys("dl-key-one\ndl-key-two");
|
||||||
let usedAuthHeader = "";
|
let usedAuthHeader = "";
|
||||||
@ -573,78 +519,6 @@ describe("debrid service", () => {
|
|||||||
expect(getDebridLinkKeyRuntimeStateForTests(key2Id)).toBe("ready");
|
expect(getDebridLinkKeyRuntimeStateForTests(key2Id)).toBe("ready");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does NOT cool down a Debrid-Link key on a quick user-cancel abort (below the min-run threshold)", async () => {
|
|
||||||
const settings = {
|
|
||||||
...defaultSettings(),
|
|
||||||
token: "",
|
|
||||||
bestToken: "",
|
|
||||||
allDebridToken: "",
|
|
||||||
megaLogin: "",
|
|
||||||
megaPassword: "",
|
|
||||||
megaCredentials: "",
|
|
||||||
debridLinkApiKeys: "dl-key-one",
|
|
||||||
providerOrder: ["debridlink"] as const,
|
|
||||||
providerPrimary: "debridlink" as const,
|
|
||||||
providerSecondary: "none" as const,
|
|
||||||
providerTertiary: "none" as const,
|
|
||||||
autoProviderFallback: false
|
|
||||||
};
|
|
||||||
const controller = new AbortController();
|
|
||||||
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
|
||||||
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
||||||
if (url.includes("/downloader/add")) {
|
|
||||||
controller.abort();
|
|
||||||
throw new Error("aborted");
|
|
||||||
}
|
|
||||||
return new Response("not-found", { status: 404 });
|
|
||||||
}) as typeof fetch;
|
|
||||||
|
|
||||||
const keyId = parseDebridLinkApiKeys("dl-key-one")[0].id;
|
|
||||||
const service = new DebridService(settings);
|
|
||||||
await expect(
|
|
||||||
service.unrestrictLink("https://rapidgator.net/file/dl-quick-cancel", controller.signal)
|
|
||||||
).rejects.toThrow();
|
|
||||||
|
|
||||||
expect(getDebridLinkKeyCooldownStateForTests(keyId)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("cools down a Debrid-Link key on an abort that ran long enough (retry rotates to the next key)", async () => {
|
|
||||||
process.env.RD_MEGA_ABORT_MIN_RUN_MS = "0";
|
|
||||||
const settings = {
|
|
||||||
...defaultSettings(),
|
|
||||||
token: "",
|
|
||||||
bestToken: "",
|
|
||||||
allDebridToken: "",
|
|
||||||
megaLogin: "",
|
|
||||||
megaPassword: "",
|
|
||||||
megaCredentials: "",
|
|
||||||
debridLinkApiKeys: "dl-key-one",
|
|
||||||
providerOrder: ["debridlink"] as const,
|
|
||||||
providerPrimary: "debridlink" as const,
|
|
||||||
providerSecondary: "none" as const,
|
|
||||||
providerTertiary: "none" as const,
|
|
||||||
autoProviderFallback: false
|
|
||||||
};
|
|
||||||
const controller = new AbortController();
|
|
||||||
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
|
||||||
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
||||||
if (url.includes("/downloader/add")) {
|
|
||||||
controller.abort();
|
|
||||||
throw new Error("aborted");
|
|
||||||
}
|
|
||||||
return new Response("not-found", { status: 404 });
|
|
||||||
}) as typeof fetch;
|
|
||||||
|
|
||||||
const keyId = parseDebridLinkApiKeys("dl-key-one")[0].id;
|
|
||||||
const service = new DebridService(settings);
|
|
||||||
await expect(
|
|
||||||
service.unrestrictLink("https://rapidgator.net/file/dl-long-abort", controller.signal)
|
|
||||||
).rejects.toThrow();
|
|
||||||
|
|
||||||
const cooldown = getDebridLinkKeyCooldownStateForTests(keyId);
|
|
||||||
expect(cooldown?.remainingMs ?? 0).toBeGreaterThan(60_000);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("treats bad Debrid-Link file passwords as fatal and does not rotate keys", async () => {
|
it("treats bad Debrid-Link file passwords as fatal and does not rotate keys", async () => {
|
||||||
const settings = {
|
const settings = {
|
||||||
...defaultSettings(),
|
...defaultSettings(),
|
||||||
@ -1429,198 +1303,6 @@ describe("debrid service", () => {
|
|||||||
expect(megaWeb).toHaveBeenCalledTimes(0);
|
expect(megaWeb).toHaveBeenCalledTimes(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("treats a Mega-Debrid 'Fichier supprimé' as transient: no account cooldown, German message, retryable", async () => {
|
|
||||||
const settings = {
|
|
||||||
...defaultSettings(),
|
|
||||||
token: "",
|
|
||||||
bestToken: "",
|
|
||||||
allDebridToken: "",
|
|
||||||
megaLogin: "user",
|
|
||||||
megaPassword: "pass",
|
|
||||||
megaCredentials: "user:pass",
|
|
||||||
megaDebridApiEnabled: true,
|
|
||||||
megaDebridWebEnabled: false,
|
|
||||||
providerPrimary: "megadebrid-api" as const,
|
|
||||||
providerSecondary: "none" as const,
|
|
||||||
providerTertiary: "none" as const,
|
|
||||||
autoProviderFallback: true
|
|
||||||
};
|
|
||||||
|
|
||||||
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
|
||||||
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
||||||
if (url.includes("action=connectUser")) {
|
|
||||||
return new Response(JSON.stringify({ response_code: "ok", token: "tok", vip_end: Math.floor(Date.now() / 1000) + 999999 }), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
||||||
}
|
|
||||||
if (url.includes("action=getLink")) {
|
|
||||||
return new Response(JSON.stringify({ response_code: "error", response_text: "Fichier supprimé chez l'hébergeur" }), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
||||||
}
|
|
||||||
return new Response("not-found", { status: 404 });
|
|
||||||
}) as typeof fetch;
|
|
||||||
|
|
||||||
const service = new DebridService(settings);
|
|
||||||
const err = await service.unrestrictLink("https://rapidgator.net/file/maybe-dead.rar.html").then(() => null, (e: unknown) => e);
|
|
||||||
expect(err).toBeTruthy();
|
|
||||||
expect(String(err)).toMatch(/nicht abrufbar/i);
|
|
||||||
expect(String(err)).not.toMatch(/supprim/i);
|
|
||||||
expect(getMegaDebridAccountCooldownState(`${getMegaDebridAccountId("user")}:api`)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does NOT slap a 60-minute 'invalid' cooldown on a working account when the API returns no result (transient)", async () => {
|
|
||||||
const settings = {
|
|
||||||
...defaultSettings(),
|
|
||||||
token: "",
|
|
||||||
bestToken: "",
|
|
||||||
allDebridToken: "",
|
|
||||||
megaLogin: "user",
|
|
||||||
megaPassword: "pass",
|
|
||||||
megaCredentials: "user:pass",
|
|
||||||
megaDebridApiEnabled: true,
|
|
||||||
megaDebridWebEnabled: false,
|
|
||||||
providerPrimary: "megadebrid-api" as const,
|
|
||||||
providerSecondary: "none" as const,
|
|
||||||
providerTertiary: "none" as const,
|
|
||||||
autoProviderFallback: true
|
|
||||||
};
|
|
||||||
|
|
||||||
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
|
||||||
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
||||||
if (url.includes("action=connectUser")) {
|
|
||||||
return new Response(JSON.stringify({ response_code: "ok", token: "tok", vip_end: Math.floor(Date.now() / 1000) + 999999 }), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
||||||
}
|
|
||||||
if (url.includes("action=getLink")) {
|
|
||||||
return new Response(JSON.stringify({ response_code: "ok" }), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
||||||
}
|
|
||||||
return new Response("not-found", { status: 404 });
|
|
||||||
}) as typeof fetch;
|
|
||||||
|
|
||||||
const service = new DebridService(settings);
|
|
||||||
const err = await service.unrestrictLink("https://rapidgator.net/file/no-result.rar.html").then(() => null, (e: unknown) => e);
|
|
||||||
expect(err).toBeTruthy();
|
|
||||||
expect(String(err)).not.toMatch(/Login oder Unrestrict/i);
|
|
||||||
|
|
||||||
const cooldown = getMegaDebridAccountCooldownState(`${getMegaDebridAccountId("user")}:api`);
|
|
||||||
if (cooldown) {
|
|
||||||
expect(cooldown.category).not.toBe("invalid");
|
|
||||||
expect(cooldown.remainingMs).toBeLessThan(5 * 60 * 1000);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("treats a Mega-Debrid API 'Token error, please log-in' as a short transient cooldown, not invalid", async () => {
|
|
||||||
const settings = {
|
|
||||||
...defaultSettings(),
|
|
||||||
token: "",
|
|
||||||
bestToken: "",
|
|
||||||
allDebridToken: "",
|
|
||||||
megaLogin: "user",
|
|
||||||
megaPassword: "pass",
|
|
||||||
megaCredentials: "user:pass",
|
|
||||||
megaDebridApiEnabled: true,
|
|
||||||
megaDebridWebEnabled: false,
|
|
||||||
providerPrimary: "megadebrid-api" as const,
|
|
||||||
providerSecondary: "none" as const,
|
|
||||||
providerTertiary: "none" as const,
|
|
||||||
autoProviderFallback: true
|
|
||||||
};
|
|
||||||
|
|
||||||
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
|
||||||
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
||||||
if (url.includes("action=connectUser")) {
|
|
||||||
return new Response(JSON.stringify({ response_code: "ok", token: "tok", vip_end: Math.floor(Date.now() / 1000) + 999999 }), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
||||||
}
|
|
||||||
if (url.includes("action=getLink")) {
|
|
||||||
return new Response(JSON.stringify({ response_code: "error_token", response_text: "Token error, please log-in" }), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
||||||
}
|
|
||||||
return new Response("not-found", { status: 404 });
|
|
||||||
}) as typeof fetch;
|
|
||||||
|
|
||||||
const service = new DebridService(settings);
|
|
||||||
await service.unrestrictLink("https://rapidgator.net/file/token-collision.rar.html").then(() => null, (e: unknown) => e);
|
|
||||||
|
|
||||||
const cooldown = getMegaDebridAccountCooldownState(`${getMegaDebridAccountId("user")}:api`);
|
|
||||||
if (cooldown) {
|
|
||||||
expect(cooldown.category).not.toBe("invalid");
|
|
||||||
expect(cooldown.remainingMs).toBeLessThan(60 * 1000);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("categorizes a Mega-Debrid 'rate limit' error as rate_limit, not quota (regex ordering)", async () => {
|
|
||||||
const settings = {
|
|
||||||
...defaultSettings(),
|
|
||||||
token: "",
|
|
||||||
bestToken: "",
|
|
||||||
allDebridToken: "",
|
|
||||||
megaLogin: "user",
|
|
||||||
megaPassword: "pass",
|
|
||||||
megaCredentials: "user:pass",
|
|
||||||
megaDebridApiEnabled: true,
|
|
||||||
megaDebridWebEnabled: false,
|
|
||||||
providerPrimary: "megadebrid-api" as const,
|
|
||||||
providerSecondary: "none" as const,
|
|
||||||
providerTertiary: "none" as const,
|
|
||||||
autoProviderFallback: true
|
|
||||||
};
|
|
||||||
|
|
||||||
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
|
||||||
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
||||||
if (url.includes("action=connectUser")) {
|
|
||||||
return new Response(JSON.stringify({ response_code: "ok", token: "tok", vip_end: Math.floor(Date.now() / 1000) + 999999 }), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
||||||
}
|
|
||||||
if (url.includes("action=getLink")) {
|
|
||||||
return new Response(JSON.stringify({ response_code: "error", response_text: "Rate limit exceeded, too many requests" }), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
||||||
}
|
|
||||||
return new Response("not-found", { status: 404 });
|
|
||||||
}) as typeof fetch;
|
|
||||||
|
|
||||||
const service = new DebridService(settings);
|
|
||||||
await service.unrestrictLink("https://rapidgator.net/file/rl.rar.html").then(() => null, (e: unknown) => e);
|
|
||||||
|
|
||||||
const cooldown = getMegaDebridAccountCooldownState(`${getMegaDebridAccountId("user")}:api`);
|
|
||||||
expect(cooldown).not.toBeNull();
|
|
||||||
expect(cooldown!.category).toBe("rate_limit");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not fall through a failed 1fichier link to the provider chain when autoProviderFallback is off", async () => {
|
|
||||||
let megaGetLinkCalled = false;
|
|
||||||
const settings = {
|
|
||||||
...defaultSettings(),
|
|
||||||
token: "",
|
|
||||||
bestToken: "",
|
|
||||||
allDebridToken: "",
|
|
||||||
oneFichierApiKey: "1f-key",
|
|
||||||
megaLogin: "user",
|
|
||||||
megaPassword: "pass",
|
|
||||||
megaCredentials: "user:pass",
|
|
||||||
megaDebridApiEnabled: true,
|
|
||||||
megaDebridWebEnabled: false,
|
|
||||||
megaDebridPreferApi: true,
|
|
||||||
providerPrimary: "megadebrid" as const,
|
|
||||||
providerSecondary: "none" as const,
|
|
||||||
providerTertiary: "none" as const,
|
|
||||||
autoProviderFallback: false
|
|
||||||
};
|
|
||||||
|
|
||||||
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
|
||||||
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
||||||
if (url.includes("api.1fichier.com")) {
|
|
||||||
return new Response(JSON.stringify({ status: "KO", message: "not available" }), { status: 500, headers: { "Content-Type": "application/json" } });
|
|
||||||
}
|
|
||||||
if (url.includes("action=connectUser")) {
|
|
||||||
return new Response(JSON.stringify({ response_code: "ok", token: "tok", vip_end: Math.floor(Date.now() / 1000) + 999999 }), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
||||||
}
|
|
||||||
if (url.includes("action=getLink")) {
|
|
||||||
megaGetLinkCalled = true;
|
|
||||||
return new Response(JSON.stringify({ response_code: "ok", debridLink: "https://mega-cdn.example/file.rar", filename: "file.rar" }), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
||||||
}
|
|
||||||
return new Response("not-found", { status: 404 });
|
|
||||||
}) as typeof fetch;
|
|
||||||
|
|
||||||
const service = new DebridService(settings);
|
|
||||||
const result = await service.unrestrictLink("https://1fichier.com/?abc12345xyz").then((r) => ({ ok: true, r }), (e: unknown) => ({ ok: false, e }));
|
|
||||||
|
|
||||||
expect(result.ok).toBe(false);
|
|
||||||
expect(megaGetLinkCalled).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses Mega Web only when it is configured as a separate fallback provider", async () => {
|
it("uses Mega Web only when it is configured as a separate fallback provider", async () => {
|
||||||
const settings = {
|
const settings = {
|
||||||
...defaultSettings(),
|
...defaultSettings(),
|
||||||
@ -1697,196 +1379,167 @@ describe("debrid service", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("bleibt klebrig bei einem funktionierenden Account (kein Account-Wechsel pro Link)", async () => {
|
it("verteilt Links per Round-Robin ueber ALLE Mega-Debrid-Accounts statt immer beim ersten zu starten", async () => {
|
||||||
const settings = {
|
const settings = {
|
||||||
...defaultSettings(),
|
...defaultSettings(),
|
||||||
token: "", bestToken: "", allDebridToken: "",
|
token: "",
|
||||||
megaLogin: "user1", megaPassword: "pass1",
|
bestToken: "",
|
||||||
|
allDebridToken: "",
|
||||||
|
megaLogin: "user1",
|
||||||
|
megaPassword: "pass1",
|
||||||
megaCredentials: "user1:pass1\nuser2:pass2\nuser3:pass3\nuser4:pass4",
|
megaCredentials: "user1:pass1\nuser2:pass2\nuser3:pass3\nuser4:pass4",
|
||||||
megaDebridPreferApi: false,
|
megaDebridPreferApi: false,
|
||||||
providerOrder: [] as const, providerPrimary: "megadebrid" as const,
|
providerOrder: [] as const,
|
||||||
providerSecondary: "none" as const, providerTertiary: "none" as const,
|
providerPrimary: "megadebrid" as const,
|
||||||
|
providerSecondary: "none" as const,
|
||||||
|
providerTertiary: "none" as const,
|
||||||
autoProviderFallback: false
|
autoProviderFallback: false
|
||||||
};
|
};
|
||||||
|
|
||||||
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
|
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
|
||||||
|
|
||||||
const megaWeb = vi.fn(async () => ({ fileName: "ok.rar", directUrl: "https://mega-web.example/ok.rar", fileSize: null, retriesUsed: 0 }));
|
const megaWeb = vi.fn(async () => ({
|
||||||
|
fileName: "ok.rar",
|
||||||
|
directUrl: "https://mega-web.example/ok.rar",
|
||||||
|
fileSize: null,
|
||||||
|
retriesUsed: 0
|
||||||
|
}));
|
||||||
|
|
||||||
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
||||||
const usedIds: (string | undefined)[] = [];
|
const usedIds: (string | undefined)[] = [];
|
||||||
for (let i = 0; i < 5; i += 1) {
|
for (let i = 0; i < 5; i += 1) {
|
||||||
const result = await service.unrestrictLink(`https://rapidgator.net/file/sticky-${i}`);
|
const result = await service.unrestrictLink(`https://rapidgator.net/file/rr-${i}`);
|
||||||
usedIds.push((result as { sourceAccountId?: string }).sourceAccountId);
|
usedIds.push((result as { sourceAccountId?: string }).sourceAccountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(usedIds).toEqual(new Array(5).fill(getMegaDebridAccountId("user1")));
|
expect(usedIds).toEqual([
|
||||||
|
getMegaDebridAccountId("user1"),
|
||||||
|
getMegaDebridAccountId("user2"),
|
||||||
|
getMegaDebridAccountId("user3"),
|
||||||
|
getMegaDebridAccountId("user4"),
|
||||||
|
getMegaDebridAccountId("user1")
|
||||||
|
]);
|
||||||
}, 30000);
|
}, 30000);
|
||||||
|
|
||||||
it("wechselt erst nach einem Schwung Links auf den naechsten Account", async () => {
|
it("Round-Robin respektiert Cooldowns: gesperrter Account wird in der Reihe uebersprungen", async () => {
|
||||||
const settings = {
|
const settings = {
|
||||||
...defaultSettings(),
|
...defaultSettings(),
|
||||||
token: "", bestToken: "", allDebridToken: "",
|
token: "",
|
||||||
megaLogin: "user1", megaPassword: "pass1",
|
bestToken: "",
|
||||||
megaCredentials: "user1:pass1\nuser2:pass2",
|
allDebridToken: "",
|
||||||
megaDebridPreferApi: false,
|
megaLogin: "user1",
|
||||||
providerOrder: [] as const, providerPrimary: "megadebrid" as const,
|
megaPassword: "pass1",
|
||||||
providerSecondary: "none" as const, providerTertiary: "none" as const,
|
|
||||||
autoProviderFallback: false
|
|
||||||
};
|
|
||||||
|
|
||||||
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
|
|
||||||
|
|
||||||
const megaWeb = vi.fn(async () => ({ fileName: "ok.rar", directUrl: "https://mega-web.example/ok.rar", fileSize: null, retriesUsed: 0 }));
|
|
||||||
|
|
||||||
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
|
||||||
const usedIds: (string | undefined)[] = [];
|
|
||||||
for (let i = 0; i < MEGA_DEBRID_STICKY_LINKS + 1; i += 1) {
|
|
||||||
const result = await service.unrestrictLink(`https://rapidgator.net/file/chunk-${i}`);
|
|
||||||
usedIds.push((result as { sourceAccountId?: string }).sourceAccountId);
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(usedIds.slice(0, MEGA_DEBRID_STICKY_LINKS)).toEqual(new Array(MEGA_DEBRID_STICKY_LINKS).fill(getMegaDebridAccountId("user1")));
|
|
||||||
expect(usedIds[MEGA_DEBRID_STICKY_LINKS]).toBe(getMegaDebridAccountId("user2"));
|
|
||||||
}, 30000);
|
|
||||||
|
|
||||||
it("ueberspringt einen gesperrten Account und bleibt dann klebrig beim naechsten", async () => {
|
|
||||||
const settings = {
|
|
||||||
...defaultSettings(),
|
|
||||||
token: "", bestToken: "", allDebridToken: "",
|
|
||||||
megaLogin: "user1", megaPassword: "pass1",
|
|
||||||
megaCredentials: "user1:pass1\nuser2:pass2\nuser3:pass3",
|
megaCredentials: "user1:pass1\nuser2:pass2\nuser3:pass3",
|
||||||
megaDebridPreferApi: false,
|
megaDebridPreferApi: false,
|
||||||
providerOrder: [] as const, providerPrimary: "megadebrid" as const,
|
providerOrder: [] as const,
|
||||||
providerSecondary: "none" as const, providerTertiary: "none" as const,
|
providerPrimary: "megadebrid" as const,
|
||||||
|
providerSecondary: "none" as const,
|
||||||
|
providerTertiary: "none" as const,
|
||||||
autoProviderFallback: false
|
autoProviderFallback: false
|
||||||
};
|
};
|
||||||
|
|
||||||
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
|
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
|
||||||
|
|
||||||
const megaWeb = vi.fn(async () => ({ fileName: "ok.rar", directUrl: "https://mega-web.example/ok.rar", fileSize: null, retriesUsed: 0 }));
|
const megaWeb = vi.fn(async () => ({
|
||||||
|
fileName: "ok.rar",
|
||||||
|
directUrl: "https://mega-web.example/ok.rar",
|
||||||
|
fileSize: null,
|
||||||
|
retriesUsed: 0
|
||||||
|
}));
|
||||||
|
|
||||||
primeMegaDebridUntilRestartForTests(`${getMegaDebridAccountId("user1")}:web`);
|
primeMegaDebridUntilRestartForTests(`${getMegaDebridAccountId("user2")}:web`);
|
||||||
|
|
||||||
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
||||||
const usedIds: (string | undefined)[] = [];
|
const usedIds: (string | undefined)[] = [];
|
||||||
for (let i = 0; i < 3; i += 1) {
|
for (let i = 0; i < 3; i += 1) {
|
||||||
const result = await service.unrestrictLink(`https://rapidgator.net/file/skip-${i}`);
|
const result = await service.unrestrictLink(`https://rapidgator.net/file/rr-skip-${i}`);
|
||||||
usedIds.push((result as { sourceAccountId?: string }).sourceAccountId);
|
usedIds.push((result as { sourceAccountId?: string }).sourceAccountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(usedIds).toEqual(new Array(3).fill(getMegaDebridAccountId("user2")));
|
expect(usedIds).toEqual([
|
||||||
|
getMegaDebridAccountId("user1"),
|
||||||
|
getMegaDebridAccountId("user3"),
|
||||||
|
getMegaDebridAccountId("user1")
|
||||||
|
]);
|
||||||
}, 30000);
|
}, 30000);
|
||||||
|
|
||||||
it("verteilt gleichzeitige Umwandlungen auf verschiedene Accounts (parallel, in-flight-Routing)", async () => {
|
it("depriorisiert einen nachweislich langsamen Account in der Rotation (Single-Flight-Schutz)", async () => {
|
||||||
const settings = {
|
const settings = {
|
||||||
...defaultSettings(),
|
...defaultSettings(),
|
||||||
token: "", bestToken: "", allDebridToken: "",
|
token: "",
|
||||||
megaLogin: "user1", megaPassword: "pass1",
|
bestToken: "",
|
||||||
megaCredentials: "user1:pass1\nuser2:pass2\nuser3:pass3\nuser4:pass4",
|
allDebridToken: "",
|
||||||
megaDebridPreferApi: false,
|
megaLogin: "user1",
|
||||||
providerOrder: [] as const, providerPrimary: "megadebrid" as const,
|
megaPassword: "pass1",
|
||||||
providerSecondary: "none" as const, providerTertiary: "none" as const,
|
|
||||||
autoProviderFallback: false
|
|
||||||
};
|
|
||||||
|
|
||||||
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
|
|
||||||
|
|
||||||
let active = 0;
|
|
||||||
let maxActive = 0;
|
|
||||||
const accountsSeen = new Set<string>();
|
|
||||||
const allInFlight = new Promise<void>((resolve) => {
|
|
||||||
const check = setInterval(() => { if (active >= 4) { clearInterval(check); resolve(); } }, 5);
|
|
||||||
});
|
|
||||||
const megaWeb = vi.fn(async (_link: string, _signal: AbortSignal | undefined, account?: { login: string; password: string }) => {
|
|
||||||
active += 1;
|
|
||||||
maxActive = Math.max(maxActive, active);
|
|
||||||
if (account) accountsSeen.add(account.login);
|
|
||||||
await allInFlight;
|
|
||||||
active -= 1;
|
|
||||||
return { fileName: "ok.rar", directUrl: "https://mega-web.example/ok.rar", fileSize: null, retriesUsed: 0 };
|
|
||||||
});
|
|
||||||
|
|
||||||
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
|
||||||
const results = await Promise.all([0, 1, 2, 3].map((i) => service.unrestrictLink(`https://rapidgator.net/file/conc-${i}`)));
|
|
||||||
|
|
||||||
expect(results.every((r) => Boolean((r as { directUrl?: string }).directUrl))).toBe(true);
|
|
||||||
expect(accountsSeen.size).toBe(4);
|
|
||||||
expect(maxActive).toBe(4);
|
|
||||||
}, 15000);
|
|
||||||
|
|
||||||
it("verteilt Ueberzahl-Umwandlungen (mehr gleichzeitig als Accounts) gleichmaessig statt sie zu stapeln", async () => {
|
|
||||||
const settings = {
|
|
||||||
...defaultSettings(),
|
|
||||||
token: "", bestToken: "", allDebridToken: "",
|
|
||||||
megaLogin: "user1", megaPassword: "pass1",
|
|
||||||
megaCredentials: "user1:pass1\nuser2:pass2",
|
megaCredentials: "user1:pass1\nuser2:pass2",
|
||||||
megaDebridPreferApi: false,
|
megaDebridPreferApi: false,
|
||||||
providerOrder: [] as const, providerPrimary: "megadebrid" as const,
|
providerOrder: [] as const,
|
||||||
providerSecondary: "none" as const, providerTertiary: "none" as const,
|
providerPrimary: "megadebrid" as const,
|
||||||
|
providerSecondary: "none" as const,
|
||||||
|
providerTertiary: "none" as const,
|
||||||
autoProviderFallback: false
|
autoProviderFallback: false
|
||||||
};
|
};
|
||||||
|
|
||||||
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
|
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
|
||||||
|
|
||||||
let active = 0;
|
const megaWeb = vi.fn(async () => ({
|
||||||
const callsPerAccount = new Map<string, number>();
|
fileName: "ok.rar",
|
||||||
const allInFlight = new Promise<void>((resolve) => {
|
directUrl: "https://mega-web.example/ok.rar",
|
||||||
const check = setInterval(() => { if (active >= 8) { clearInterval(check); resolve(); } }, 5);
|
fileSize: null,
|
||||||
});
|
retriesUsed: 0
|
||||||
const megaWeb = vi.fn(async (_link: string, _signal: AbortSignal | undefined, account?: { login: string; password: string }) => {
|
}));
|
||||||
active += 1;
|
|
||||||
if (account) callsPerAccount.set(account.login, (callsPerAccount.get(account.login) ?? 0) + 1);
|
primeMegaDebridLatencyForTests(`${getMegaDebridAccountId("user1")}:web`, 15000);
|
||||||
await allInFlight;
|
primeMegaDebridLatencyForTests(`${getMegaDebridAccountId("user2")}:web`, 800);
|
||||||
return { fileName: "ok.rar", directUrl: "https://mega-web.example/ok.rar", fileSize: null, retriesUsed: 0 };
|
|
||||||
});
|
|
||||||
|
|
||||||
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
||||||
await Promise.all(Array.from({ length: 8 }, (_unused, i) => service.unrestrictLink(`https://rapidgator.net/file/ov-${i}`)));
|
const usedIds: (string | undefined)[] = [];
|
||||||
|
for (let i = 0; i < 2; i += 1) {
|
||||||
|
const result = await service.unrestrictLink(`https://rapidgator.net/file/slow-${i}`);
|
||||||
|
usedIds.push((result as { sourceAccountId?: string }).sourceAccountId);
|
||||||
|
}
|
||||||
|
|
||||||
// 8 gleichzeitige Aufloesungen auf 2 Accounts → 4 je Account (statt 7/1 beim alten belegt/frei-Set).
|
expect(usedIds).toEqual([
|
||||||
expect(callsPerAccount.get("user1")).toBe(4);
|
getMegaDebridAccountId("user2"),
|
||||||
expect(callsPerAccount.get("user2")).toBe(4);
|
getMegaDebridAccountId("user2")
|
||||||
}, 15000);
|
]);
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
it("faellt ein Account in der Mitte aus (1,2,4 ok, 3 nicht): parallel nur ueber die funktionierenden, alle Links loesen auf", async () => {
|
it("nutzt den langsamen Account weiterhin, wenn die schnellen gesperrt sind (Failover-Reserve)", async () => {
|
||||||
const settings = {
|
const settings = {
|
||||||
...defaultSettings(),
|
...defaultSettings(),
|
||||||
token: "", bestToken: "", allDebridToken: "",
|
token: "",
|
||||||
megaLogin: "user1", megaPassword: "pass1",
|
bestToken: "",
|
||||||
megaCredentials: "user1:pass1\nuser2:pass2\nuser3:pass3\nuser4:pass4",
|
allDebridToken: "",
|
||||||
|
megaLogin: "user1",
|
||||||
|
megaPassword: "pass1",
|
||||||
|
megaCredentials: "user1:pass1\nuser2:pass2",
|
||||||
megaDebridPreferApi: false,
|
megaDebridPreferApi: false,
|
||||||
providerOrder: [] as const, providerPrimary: "megadebrid" as const,
|
providerOrder: [] as const,
|
||||||
providerSecondary: "none" as const, providerTertiary: "none" as const,
|
providerPrimary: "megadebrid" as const,
|
||||||
|
providerSecondary: "none" as const,
|
||||||
|
providerTertiary: "none" as const,
|
||||||
autoProviderFallback: false
|
autoProviderFallback: false
|
||||||
};
|
};
|
||||||
|
|
||||||
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
|
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
|
||||||
|
|
||||||
let active = 0;
|
const megaWeb = vi.fn(async () => ({
|
||||||
const used = new Set<string>();
|
fileName: "ok.rar",
|
||||||
const threeInFlight = new Promise<void>((resolve) => {
|
directUrl: "https://mega-web.example/ok.rar",
|
||||||
const check = setInterval(() => { if (active >= 3) { clearInterval(check); resolve(); } }, 5);
|
fileSize: null,
|
||||||
});
|
retriesUsed: 0
|
||||||
const megaWeb = vi.fn(async (_link: string, _signal: AbortSignal | undefined, account?: { login: string; password: string }) => {
|
}));
|
||||||
active += 1;
|
|
||||||
if (account) used.add(account.login);
|
|
||||||
await threeInFlight;
|
|
||||||
return { fileName: "ok.rar", directUrl: "https://mega-web.example/ok.rar", fileSize: null, retriesUsed: 0 };
|
|
||||||
});
|
|
||||||
|
|
||||||
// Account 3 ist ausgefallen (z.B. zuvor fehlgeschlagen -> gesperrt) und faellt aus der Rotation.
|
primeMegaDebridLatencyForTests(`${getMegaDebridAccountId("user1")}:web`, 15000);
|
||||||
primeMegaDebridUntilRestartForTests(`${getMegaDebridAccountId("user3")}:web`);
|
primeMegaDebridLatencyForTests(`${getMegaDebridAccountId("user2")}:web`, 800);
|
||||||
|
primeMegaDebridUntilRestartForTests(`${getMegaDebridAccountId("user2")}:web`);
|
||||||
|
|
||||||
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
||||||
const results = await Promise.all([0, 1, 2].map((i) => service.unrestrictLink(`https://rapidgator.net/file/dead3-${i}`)));
|
const result = await service.unrestrictLink("https://rapidgator.net/file/slow-fallback");
|
||||||
|
|
||||||
expect(results.every((r) => Boolean((r as { directUrl?: string }).directUrl))).toBe(true);
|
expect((result as { sourceAccountId?: string }).sourceAccountId).toBe(getMegaDebridAccountId("user1"));
|
||||||
expect(used.has("user3")).toBe(false);
|
}, 30000);
|
||||||
expect(used.has("user1")).toBe(true);
|
|
||||||
expect(used.has("user2")).toBe(true);
|
|
||||||
expect(used.has("user4")).toBe(true);
|
|
||||||
}, 15000);
|
|
||||||
|
|
||||||
it("rotates to the next Mega-Debrid account when one hits its daily limit (error-based)", async () => {
|
it("rotates to the next Mega-Debrid account when one hits its daily limit (error-based)", async () => {
|
||||||
const settings = {
|
const settings = {
|
||||||
@ -2034,113 +1687,9 @@ describe("debrid service", () => {
|
|||||||
expect(result.directUrl).toBe("https://mega-web.example/ok.rar");
|
expect(result.directUrl).toBe("https://mega-web.example/ok.rar");
|
||||||
}, 20000);
|
}, 20000);
|
||||||
|
|
||||||
it("setzt KEINEN Account-Cooldown, wenn der Mega-Web-Abbruch nur ein Queue-Timeout war (Account war belegt, nicht ungesund)", async () => {
|
|
||||||
process.env.RD_MEGA_ABORT_MIN_RUN_MS = "0";
|
|
||||||
const settings = {
|
|
||||||
...defaultSettings(),
|
|
||||||
token: "",
|
|
||||||
bestToken: "",
|
|
||||||
allDebridToken: "",
|
|
||||||
megaLogin: "user",
|
|
||||||
megaPassword: "pass",
|
|
||||||
megaCredentials: "user:pass",
|
|
||||||
megaDebridPreferApi: false,
|
|
||||||
providerOrder: [] as const,
|
|
||||||
providerPrimary: "megadebrid" as const,
|
|
||||||
providerSecondary: "none" as const,
|
|
||||||
providerTertiary: "none" as const,
|
|
||||||
autoProviderFallback: false
|
|
||||||
};
|
|
||||||
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
|
|
||||||
|
|
||||||
const controller = new AbortController();
|
|
||||||
let calls = 0;
|
|
||||||
const megaWeb = vi.fn(async () => {
|
|
||||||
calls += 1;
|
|
||||||
controller.abort("caller-timeout");
|
|
||||||
throw new Error("Mega-Web Queue-Timeout (abgebrochen nach 60s Wartezeit, Account war belegt)");
|
|
||||||
});
|
|
||||||
|
|
||||||
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
|
||||||
await expect(
|
|
||||||
service.unrestrictLink("https://rapidgator.net/file/queue-timeout-no-cooldown", controller.signal)
|
|
||||||
).rejects.toThrow();
|
|
||||||
|
|
||||||
const key = `${getMegaDebridAccountId("user")}:web`;
|
|
||||||
expect(getMegaDebridAccountCooldownState(key)).toBeNull();
|
|
||||||
expect(calls).toBeGreaterThanOrEqual(1);
|
|
||||||
}, 20000);
|
|
||||||
|
|
||||||
it("getProviderRuntimeSnapshot surfaces a live Mega-Debrid account cooldown (until/remaining/reason) for the diagnostics endpoint", () => {
|
|
||||||
const accId = getMegaDebridAccountId("user");
|
|
||||||
const key = `${accId}:web`;
|
|
||||||
expect(getProviderRuntimeSnapshot().megaDebrid.accounts.find((a) => a.key === key)?.cooldown ?? null).toBeNull();
|
|
||||||
|
|
||||||
primeMegaDebridRuntimeCooldownForTests(key, 90_000, "Abbruch/Timeout nach 60s");
|
|
||||||
|
|
||||||
const snap = getProviderRuntimeSnapshot();
|
|
||||||
expect(typeof snap.capturedAtMs).toBe("number");
|
|
||||||
const acc = snap.megaDebrid.accounts.find((a) => a.key === key);
|
|
||||||
expect(acc).toBeTruthy();
|
|
||||||
expect(acc!.cooldown).not.toBeNull();
|
|
||||||
expect(acc!.cooldown!.remainingMs).toBeGreaterThan(0);
|
|
||||||
expect(acc!.cooldown!.remainingMs).toBeLessThanOrEqual(90_000);
|
|
||||||
expect(acc!.cooldown!.untilMs).toBeGreaterThan(snap.capturedAtMs);
|
|
||||||
expect(acc!.cooldown!.message).toContain("Abbruch");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("single Mega-Debrid account: a long Web abort parks only the slow link and does NOT freeze the sole account", async () => {
|
|
||||||
process.env.RD_MEGA_ABORT_MIN_RUN_MS = "0";
|
|
||||||
const settings = {
|
|
||||||
...defaultSettings(),
|
|
||||||
token: "",
|
|
||||||
bestToken: "",
|
|
||||||
allDebridToken: "",
|
|
||||||
megaLogin: "user",
|
|
||||||
megaPassword: "pass",
|
|
||||||
megaCredentials: "user:pass",
|
|
||||||
megaDebridPreferApi: false,
|
|
||||||
providerOrder: [] as const,
|
|
||||||
providerPrimary: "megadebrid" as const,
|
|
||||||
providerSecondary: "none" as const,
|
|
||||||
providerTertiary: "none" as const,
|
|
||||||
autoProviderFallback: false
|
|
||||||
};
|
|
||||||
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
|
|
||||||
|
|
||||||
const controller = new AbortController();
|
|
||||||
let calls = 0;
|
|
||||||
const megaWeb = vi.fn((): Promise<{ fileName: string; directUrl: string; fileSize: number | null; retriesUsed: number }> => {
|
|
||||||
calls += 1;
|
|
||||||
if (calls === 1) {
|
|
||||||
controller.abort("simulated-60s-timeout");
|
|
||||||
return Promise.reject(new Error("aborted"));
|
|
||||||
}
|
|
||||||
return Promise.resolve({
|
|
||||||
fileName: "healthy.rar",
|
|
||||||
directUrl: "https://www11.unrestrict.link/download/file/ok/healthy.rar",
|
|
||||||
fileSize: null,
|
|
||||||
retriesUsed: 0
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
|
||||||
|
|
||||||
const err = await service.unrestrictLink("https://rapidgator.net/file/slow-link.rar.html", controller.signal).then(() => null, (e: unknown) => e);
|
|
||||||
expect(err).toBeTruthy();
|
|
||||||
expect(String(err)).toMatch(/mega_debrid_slow_link:\d+:/i);
|
|
||||||
|
|
||||||
const key = `${getMegaDebridAccountId("user")}:web`;
|
|
||||||
expect(getMegaDebridAccountCooldownState(key)).toBeNull();
|
|
||||||
|
|
||||||
const second = await service.unrestrictLink("https://rapidgator.net/file/healthy.rar.html");
|
|
||||||
expect(second.provider).toBe("megadebrid");
|
|
||||||
expect(calls).toBeGreaterThanOrEqual(2);
|
|
||||||
}, 20000);
|
|
||||||
|
|
||||||
it("escalates a Mega-Debrid account to 'until restart' after the empty-response streak threshold", () => {
|
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(10);
|
expect(MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART).toBe(3);
|
||||||
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);
|
||||||
@ -2148,32 +1697,13 @@ describe("debrid service", () => {
|
|||||||
expect(recordMegaDebridEmptyResponseStreak(key)).toBe(1);
|
expect(recordMegaDebridEmptyResponseStreak(key)).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("self-heals a daily-limit park after the daily reset instead of staying locked until process restart", () => {
|
it("keeps an 'until restart' park active forever (never expires until process restart)", () => {
|
||||||
const key = `${getMegaDebridAccountId("user1")}:api`;
|
const key = `${getMegaDebridAccountId("user1")}:api`;
|
||||||
primeMegaDebridUntilRestartForTests(key);
|
primeMegaDebridUntilRestartForTests(key);
|
||||||
const active = getMegaDebridAccountCooldownState(key);
|
const now = getMegaDebridAccountCooldownState(key);
|
||||||
expect(active?.untilRestart).toBe(true);
|
expect(now?.untilRestart).toBe(true);
|
||||||
expect(getMegaDebridAccountCooldownState(key, Date.now() + 60_000)?.untilRestart).toBe(true);
|
const farFuture = Date.now() + 100 * 24 * 60 * 60 * 1000;
|
||||||
const afterReset = Date.now() + 25 * 60 * 60 * 1000;
|
expect(getMegaDebridAccountCooldownState(key, farFuture)?.untilRestart).toBe(true);
|
||||||
expect(getMegaDebridAccountCooldownState(key, afterReset)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does NOT treat a per-hoster 'no server' failure as an account daily-limit signal (no until-restart park)", () => {
|
|
||||||
const noServer = classifyMegaDebridAccountFailureForTests(new Error("no server available for this host"));
|
|
||||||
expect(noServer.limitSignal).toBeFalsy();
|
|
||||||
expect(noServer.category).toBe("quota");
|
|
||||||
expect(noServer.cooldownMs).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
const genuineEmpty = classifyMegaDebridAccountFailureForTests(new Error("Antwort leer"));
|
|
||||||
expect(genuineEmpty.limitSignal).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("classifies an empty Mega-Debrid API result ('Linkgenerierung lieferte kein Ergebnis') as a fast transient, not a 30s cooldown", () => {
|
|
||||||
const result = classifyMegaDebridAccountFailureForTests(new Error("Mega-Debrid API: Linkgenerierung lieferte kein Ergebnis"));
|
|
||||||
expect(result.fatal).toBe(false);
|
|
||||||
expect(result.cooldownMs).toBe(0);
|
|
||||||
expect(result.limitSignal).toBeFalsy();
|
|
||||||
expect(isMegaDebridTransientResolveFailure(result.message)).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("skips a Mega-Debrid account parked until restart and rotates to the next, without re-testing it", async () => {
|
it("skips a Mega-Debrid account parked until restart and rotates to the next, without re-testing it", async () => {
|
||||||
@ -2212,7 +1742,7 @@ describe("debrid service", () => {
|
|||||||
expect((result as { sourceAccountId?: string }).sourceAccountId).toBe(getMegaDebridAccountId("user2"));
|
expect((result as { sourceAccountId?: string }).sourceAccountId).toBe(getMegaDebridAccountId("user2"));
|
||||||
}, 20000);
|
}, 20000);
|
||||||
|
|
||||||
it("emits an until-Tagesreset park token (so the manager parks, not 2min-retries) when ALL Mega-Debrid accounts are parked until restart", async () => {
|
it("fails terminally (no retry timer) when ALL Mega-Debrid accounts are parked until restart", async () => {
|
||||||
const settings = {
|
const settings = {
|
||||||
...defaultSettings(),
|
...defaultSettings(),
|
||||||
token: "",
|
token: "",
|
||||||
@ -2239,10 +1769,7 @@ describe("debrid service", () => {
|
|||||||
const megaWeb = vi.fn(async () => ({ fileName: "x.rar", directUrl: "https://mega-web.example/x.rar", fileSize: null, retriesUsed: 0 }));
|
const megaWeb = vi.fn(async () => ({ fileName: "x.rar", directUrl: "https://mega-web.example/x.rar", fileSize: null, retriesUsed: 0 }));
|
||||||
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
||||||
|
|
||||||
const err = await service.unrestrictLink("https://rapidgator.net/file/all-parked-test").then(() => null, (e: unknown) => e as Error);
|
await expect(service.unrestrictLink("https://rapidgator.net/file/all-parked-test")).rejects.toThrow(/bis Neustart gesperrt/i);
|
||||||
expect(err).toBeInstanceOf(Error);
|
|
||||||
expect(err!.message).toMatch(/bis zum Tagesreset gesperrt/i);
|
|
||||||
expect(err!.message).toMatch(/mega_debrid_reset_park:\d+:/);
|
|
||||||
expect(megaWeb).not.toHaveBeenCalled();
|
expect(megaWeb).not.toHaveBeenCalled();
|
||||||
}, 20000);
|
}, 20000);
|
||||||
|
|
||||||
@ -2265,9 +1792,8 @@ 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);
|
||||||
|
|||||||
@ -1,146 +0,0 @@
|
|||||||
import fs from "node:fs";
|
|
||||||
import http from "node:http";
|
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
import { once } from "node:events";
|
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import {
|
|
||||||
startDebugServer,
|
|
||||||
stopDebugServer,
|
|
||||||
restartDebugServer,
|
|
||||||
writeDebugServerConfig,
|
|
||||||
getDebugServerRuntimeStatus,
|
|
||||||
evaluateClientAllowed,
|
|
||||||
getPeerIp
|
|
||||||
} from "../src/main/debug-server";
|
|
||||||
import type { DownloadManager } from "../src/main/download-manager";
|
|
||||||
|
|
||||||
const tempDirs: string[] = [];
|
|
||||||
const TOKEN = "allowlist-secret";
|
|
||||||
|
|
||||||
async function getFreePort(): Promise<number> {
|
|
||||||
const probe = http.createServer();
|
|
||||||
probe.listen(0, "127.0.0.1");
|
|
||||||
await once(probe, "listening");
|
|
||||||
const address = probe.address();
|
|
||||||
if (!address || typeof address === "string") {
|
|
||||||
throw new Error("port probe failed");
|
|
||||||
}
|
|
||||||
probe.close();
|
|
||||||
await once(probe, "close");
|
|
||||||
return address.port;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForReady(url: string): Promise<void> {
|
|
||||||
const deadline = Date.now() + 5000;
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
try {
|
|
||||||
const res = await fetch(url);
|
|
||||||
if (res.ok) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
|
||||||
}
|
|
||||||
throw new Error(`debug server not ready: ${url}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startWithAllowlist(allowlist: string[], host = "0.0.0.0"): Promise<{ baseUrl: string }> {
|
|
||||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-allow-"));
|
|
||||||
tempDirs.push(baseDir);
|
|
||||||
const port = await getFreePort();
|
|
||||||
fs.writeFileSync(path.join(baseDir, "debug_token.txt"), TOKEN, "utf8");
|
|
||||||
fs.writeFileSync(path.join(baseDir, "debug_port.txt"), String(port), "utf8");
|
|
||||||
fs.writeFileSync(path.join(baseDir, "debug_host.txt"), host, "utf8");
|
|
||||||
fs.writeFileSync(path.join(baseDir, "debug_allowlist.txt"), allowlist.join("\n"), "utf8");
|
|
||||||
const manager = {} as unknown as DownloadManager;
|
|
||||||
startDebugServer(manager, baseDir);
|
|
||||||
const baseUrl = `http://127.0.0.1:${port}`;
|
|
||||||
await waitForReady(`${baseUrl}/health?token=${TOKEN}`);
|
|
||||||
return { baseUrl };
|
|
||||||
}
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
stopDebugServer();
|
|
||||||
while (tempDirs.length > 0) {
|
|
||||||
const dir = tempDirs.pop();
|
|
||||||
if (!dir) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
fs.rmSync(dir, { recursive: true, force: true });
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("debug-server allowlist matcher (pure)", () => {
|
|
||||||
it("always allows loopback regardless of rules", () => {
|
|
||||||
expect(evaluateClientAllowed("127.0.0.1", [])).toBe(true);
|
|
||||||
expect(evaluateClientAllowed("::1", [])).toBe(true);
|
|
||||||
expect(evaluateClientAllowed("::ffff:127.0.0.1", ["8.8.8.8"])).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("matches an exact allowlisted IP and rejects others", () => {
|
|
||||||
expect(evaluateClientAllowed("8.8.8.8", ["8.8.8.8"])).toBe(true);
|
|
||||||
expect(evaluateClientAllowed("9.9.9.9", ["8.8.8.8"])).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("matches inside a CIDR and rejects outside it", () => {
|
|
||||||
expect(evaluateClientAllowed("10.0.0.42", ["10.0.0.0/24"])).toBe(true);
|
|
||||||
expect(evaluateClientAllowed("10.0.1.42", ["10.0.0.0/24"])).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("fail-closed: empty rules reject every non-loopback client", () => {
|
|
||||||
expect(evaluateClientAllowed("203.0.113.7", [])).toBe(false);
|
|
||||||
expect(evaluateClientAllowed("8.8.8.8", [])).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("derives the client IP from the socket peer, never from X-Forwarded-For", () => {
|
|
||||||
const forgedLoopback = {
|
|
||||||
socket: { remoteAddress: "8.8.8.8" },
|
|
||||||
headers: { "x-forwarded-for": "127.0.0.1" }
|
|
||||||
} as unknown as http.IncomingMessage;
|
|
||||||
expect(getPeerIp(forgedLoopback)).toBe("8.8.8.8");
|
|
||||||
expect(evaluateClientAllowed(getPeerIp(forgedLoopback), [])).toBe(false);
|
|
||||||
expect(evaluateClientAllowed(getPeerIp(forgedLoopback), ["9.9.9.9"])).toBe(false);
|
|
||||||
expect(evaluateClientAllowed(getPeerIp(forgedLoopback), ["8.8.8.8"])).toBe(true);
|
|
||||||
|
|
||||||
const ipv6Mapped = {
|
|
||||||
socket: { remoteAddress: "::ffff:10.0.0.5" },
|
|
||||||
headers: {}
|
|
||||||
} as unknown as http.IncomingMessage;
|
|
||||||
expect(getPeerIp(ipv6Mapped)).toBe("10.0.0.5");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("debug-server allowlist enforcement (wired)", () => {
|
|
||||||
it("allows a loopback connection and ignores a spoofed X-Forwarded-For", async () => {
|
|
||||||
const { baseUrl } = await startWithAllowlist(["8.8.8.8"]);
|
|
||||||
const plain = await fetch(`${baseUrl}/health?token=${TOKEN}`);
|
|
||||||
expect(plain.status).toBe(200);
|
|
||||||
const spoofed = await fetch(`${baseUrl}/health?token=${TOKEN}`, {
|
|
||||||
headers: { "X-Forwarded-For": "203.0.113.9" }
|
|
||||||
});
|
|
||||||
expect(spoofed.status).toBe(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("still enforces the token for loopback clients", async () => {
|
|
||||||
const { baseUrl } = await startWithAllowlist(["8.8.8.8"]);
|
|
||||||
const res = await fetch(`${baseUrl}/health`);
|
|
||||||
expect(res.status).toBe(401);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reloads the allowlist live via restartDebugServer", async () => {
|
|
||||||
const { baseUrl } = await startWithAllowlist(["8.8.8.8"]);
|
|
||||||
expect(getDebugServerRuntimeStatus().allowlistCount).toBe(1);
|
|
||||||
writeDebugServerConfig({ allowlist: ["9.9.9.9", "10.0.0.0/24"] });
|
|
||||||
const status = await restartDebugServer();
|
|
||||||
expect(status.running).toBe(true);
|
|
||||||
expect(status.allowlistCount).toBe(2);
|
|
||||||
await waitForReady(`${baseUrl}/health?token=${TOKEN}`);
|
|
||||||
expect((await fetch(`${baseUrl}/health?token=${TOKEN}`)).status).toBe(200);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { planDownloadCompletion, reconcileFinalizedSize, validateDownloadedFileCompletion } from "../src/main/download-completion";
|
import { planDownloadCompletion, validateDownloadedFileCompletion } from "../src/main/download-completion";
|
||||||
|
|
||||||
describe("download-completion", () => {
|
describe("download-completion", () => {
|
||||||
describe("planDownloadCompletion", () => {
|
describe("planDownloadCompletion", () => {
|
||||||
@ -58,33 +58,4 @@ describe("download-completion", () => {
|
|||||||
expect(result.ok).toBe(false);
|
expect(result.ok).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("reconcileFinalizedSize", () => {
|
|
||||||
it("keeps the streamed count for a pre-allocated file whose on-disk size is the zero-padding (corruption guard)", () => {
|
|
||||||
expect(reconcileFinalizedSize(300_000_000, 1_000_000_000, true)).toBe(300_000_000);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shrinks to the on-disk size when a pre-allocated file is genuinely short (real partial write)", () => {
|
|
||||||
expect(reconcileFinalizedSize(500, 300, true)).toBe(300);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reconciles in both directions for a non-pre-allocated file (stat is authoritative)", () => {
|
|
||||||
expect(reconcileFinalizedSize(300, 1000, false)).toBe(1000);
|
|
||||||
expect(reconcileFinalizedSize(1000, 300, false)).toBe(300);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns the streamed count unchanged when the stat is invalid", () => {
|
|
||||||
expect(reconcileFinalizedSize(1234, Number.NaN, true)).toBe(1234);
|
|
||||||
expect(reconcileFinalizedSize(1234, -1, false)).toBe(1234);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is a no-op when on-disk size already equals the streamed count", () => {
|
|
||||||
expect(reconcileFinalizedSize(777, 777, true)).toBe(777);
|
|
||||||
expect(reconcileFinalizedSize(777, 777, false)).toBe(777);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not block legitimate overshoot on a pre-allocated file (server sent more than pre-alloc)", () => {
|
|
||||||
expect(reconcileFinalizedSize(900, 900, true)).toBe(900);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -858,40 +858,6 @@ describe("extractor", () => {
|
|||||||
expect(targets.has(p003)).toBe(true);
|
expect(targets.has(p003)).toBe(true);
|
||||||
expect(targets.has(other)).toBe(false);
|
expect(targets.has(other)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does NOT delete a non-archive .00x family that sits beside a real archive (no-signature data-loss guard)", async () => {
|
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-split-noarch-"));
|
|
||||||
tempDirs.push(root);
|
|
||||||
const packageDir = path.join(root, "pkg");
|
|
||||||
const targetDir = path.join(root, "out");
|
|
||||||
fs.mkdirSync(packageDir, { recursive: true });
|
|
||||||
|
|
||||||
const realZip = new AdmZip();
|
|
||||||
realZip.addFile("release.txt", Buffer.from("ok"));
|
|
||||||
realZip.writeZip(path.join(packageDir, "movie.zip"));
|
|
||||||
|
|
||||||
const d001 = path.join(packageDir, "mydata.001");
|
|
||||||
const d002 = path.join(packageDir, "mydata.002");
|
|
||||||
const d003 = path.join(packageDir, "mydata.003");
|
|
||||||
fs.writeFileSync(d001, "raw user split data, not an archive at all 0123456789", "utf8");
|
|
||||||
fs.writeFileSync(d002, "second raw chunk, also no archive magic bytes here", "utf8");
|
|
||||||
fs.writeFileSync(d003, "third raw chunk likewise plain content payload", "utf8");
|
|
||||||
|
|
||||||
const result = await extractPackageArchives({
|
|
||||||
packageDir,
|
|
||||||
targetDir,
|
|
||||||
cleanupMode: "delete",
|
|
||||||
conflictMode: "overwrite",
|
|
||||||
removeLinks: false,
|
|
||||||
removeSamples: false
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.failed).toBe(0);
|
|
||||||
expect(fs.existsSync(path.join(targetDir, "release.txt"))).toBe(true);
|
|
||||||
expect(fs.existsSync(d001)).toBe(true);
|
|
||||||
expect(fs.existsSync(d002)).toBe(true);
|
|
||||||
expect(fs.existsSync(d003)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("detectArchiveSignature", () => {
|
describe("detectArchiveSignature", () => {
|
||||||
|
|||||||
@ -66,21 +66,6 @@ describe("integrity", () => {
|
|||||||
expect(parseHashLine(" ")).toBeNull();
|
expect(parseHashLine(" ")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("trusts the per-line algorithm over the file extension for a mislabeled manifest (.sfv holding md5 lines)", async () => {
|
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-int-"));
|
|
||||||
tempDirs.push(dir);
|
|
||||||
const filePath = path.join(dir, "movie.bin");
|
|
||||||
fs.writeFileSync(filePath, Buffer.from("hello"));
|
|
||||||
fs.writeFileSync(path.join(dir, "checksums.sfv"), "5d41402abc4b2a76b9719d911017c592 movie.bin\n", "utf8");
|
|
||||||
|
|
||||||
const manifest = readHashManifest(dir);
|
|
||||||
expect(manifest.get("movie.bin")?.algorithm).toBe("md5");
|
|
||||||
|
|
||||||
const result = await validateFileAgainstManifest(filePath, dir);
|
|
||||||
expect(result.ok).toBe(true);
|
|
||||||
expect(result.message).toContain("MD5");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps first hash entry when duplicate filename appears across manifests", () => {
|
it("keeps first hash entry when duplicate filename appears across manifests", () => {
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-int-"));
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-int-"));
|
||||||
tempDirs.push(dir);
|
tempDirs.push(dir);
|
||||||
|
|||||||
@ -1,64 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { isMegaDebridResolveFailure, germanMegaDebridResolveReason, isMegaDebridTransientResolveFailure } from "../src/shared/mega-debrid-errors";
|
|
||||||
|
|
||||||
describe("isMegaDebridResolveFailure", () => {
|
|
||||||
it("detects the real Mega-Debrid French resolve-failure phrase", () => {
|
|
||||||
expect(isMegaDebridResolveFailure("Mega-Debrid API: Fichier supprimé chez l'hébergeur")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("matches inside the aggregated provider-chain error (api fail | web timeout)", () => {
|
|
||||||
const aggregated = "Unrestrict fehlgeschlagen: Mega-Debrid API: Mega-Debrid (API): Fichier supprimé chez l'hébergeur | Mega-Debrid Web: Mega-Debrid (Web): Abbruch/Timeout nach 60s";
|
|
||||||
expect(isMegaDebridResolveFailure(aggregated)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("matches the de-accented variant", () => {
|
|
||||||
expect(isMegaDebridResolveFailure("Fichier supprime chez l'hebergeur")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("matches other Mega-Debrid resolve phrases", () => {
|
|
||||||
expect(isMegaDebridResolveFailure("Fichier introuvable")).toBe(true);
|
|
||||||
expect(isMegaDebridResolveFailure("Le fichier n'existe plus")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does NOT match unrelated/transient text", () => {
|
|
||||||
expect(isMegaDebridResolveFailure("Abbruch/Timeout nach 60s")).toBe(false);
|
|
||||||
expect(isMegaDebridResolveFailure("Quota/Limit erreicht")).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("germanMegaDebridResolveReason (transient wording, NOT 'tot')", () => {
|
|
||||||
it("renders 'supprimé' as a transient, retryable German reason", () => {
|
|
||||||
const reason = germanMegaDebridResolveReason("Mega-Debrid API: Fichier supprimé chez l'hébergeur");
|
|
||||||
expect(reason).toBe("Datei beim Hoster gerade nicht abrufbar");
|
|
||||||
expect(reason.toLowerCase()).not.toContain("tot");
|
|
||||||
expect(reason.toLowerCase()).not.toContain("gelöscht");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders not-found phrases in German", () => {
|
|
||||||
expect(germanMegaDebridResolveReason("Fichier introuvable")).toBe("Datei beim Hoster nicht gefunden");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("isMegaDebridTransientResolveFailure (matches raw French AND rendered German)", () => {
|
|
||||||
it("matches the raw French phrase that may reach the download-manager", () => {
|
|
||||||
expect(isMegaDebridTransientResolveFailure("Mega-Debrid API: Fichier supprimé chez l'hébergeur")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("matches the German rendered reason that classifyAccountFailure produces", () => {
|
|
||||||
const aggregated = "Mega-Debrid (Account 1/4, ab***@x): Datei beim Hoster gerade nicht abrufbar | Mega-Debrid (Account 2/4, cd***@y): Datei beim Hoster gerade nicht abrufbar";
|
|
||||||
expect(isMegaDebridTransientResolveFailure(aggregated)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("matches the German not-found rendered reason", () => {
|
|
||||||
expect(isMegaDebridTransientResolveFailure("Mega-Debrid (Account 1/4): Datei beim Hoster nicht gefunden")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does NOT match a Mega-Debrid timeout/abort (that has its own account cooldown path)", () => {
|
|
||||||
expect(isMegaDebridTransientResolveFailure("Mega-Debrid (Account 1/4): Abbruch/Timeout nach 60s")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does NOT match unrelated provider errors", () => {
|
|
||||||
expect(isMegaDebridTransientResolveFailure("AllDebrid: zu viele aktive Downloads")).toBe(false);
|
|
||||||
expect(isMegaDebridTransientResolveFailure("Debrid-Link: badToken")).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -202,68 +202,6 @@ describe("mega-web-fallback", () => {
|
|||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("serialisiert gleichzeitige Umwandlungen auf DEMSELBEN Account (kein Doppel-Login)", async () => {
|
|
||||||
let loginCount = 0;
|
|
||||||
globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
|
|
||||||
const u = String(url);
|
|
||||||
if (u.includes("form=login")) {
|
|
||||||
loginCount += 1;
|
|
||||||
await new Promise((r) => setTimeout(r, 15));
|
|
||||||
const headers = new Headers();
|
|
||||||
headers.append("set-cookie", "session=c; path=/");
|
|
||||||
return new Response("", { headers, status: 200 });
|
|
||||||
}
|
|
||||||
if (u.includes("page=debrideur")) return new Response('<form id="debridForm"></form>', { status: 200 });
|
|
||||||
if (u.includes("form=debrid")) return new Response(`<div class="acp-box"><h3>Link: https://mega.debrid/l</h3><a href="javascript:processDebrid(1,'code',0)">d</a></div>`, { status: 200 });
|
|
||||||
if (u.includes("ajax=debrid")) return new Response(JSON.stringify({ link: "https://mega.direct/ok" }), { status: 200 });
|
|
||||||
return new Response("Not found", { status: 404 });
|
|
||||||
}) as unknown as typeof fetch;
|
|
||||||
|
|
||||||
const fallback = new MegaWebFallback(() => ({ login: "same", password: "pw" }));
|
|
||||||
const [r1, r2] = await Promise.all([
|
|
||||||
fallback.unrestrict("https://mega.debrid/a", undefined, { login: "same", password: "pw" }),
|
|
||||||
fallback.unrestrict("https://mega.debrid/b", undefined, { login: "same", password: "pw" })
|
|
||||||
]);
|
|
||||||
expect(r1?.directUrl).toBe("https://mega.direct/ok");
|
|
||||||
expect(r2?.directUrl).toBe("https://mega.direct/ok");
|
|
||||||
// Serialisiert auf demselben Account → der zweite nutzt die gecachte Session, kein zweiter Login.
|
|
||||||
expect(loginCount).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("wandelt auf VERSCHIEDENEN Accounts parallel um (Logins laufen gleichzeitig)", async () => {
|
|
||||||
let activeLogins = 0;
|
|
||||||
let maxActiveLogins = 0;
|
|
||||||
const bothInFlight = new Promise<void>((resolve) => {
|
|
||||||
const check = setInterval(() => { if (activeLogins >= 2) { clearInterval(check); resolve(); } }, 5);
|
|
||||||
});
|
|
||||||
globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
|
|
||||||
const u = String(url);
|
|
||||||
if (u.includes("form=login")) {
|
|
||||||
activeLogins += 1;
|
|
||||||
maxActiveLogins = Math.max(maxActiveLogins, activeLogins);
|
|
||||||
await bothInFlight;
|
|
||||||
activeLogins -= 1;
|
|
||||||
const headers = new Headers();
|
|
||||||
headers.append("set-cookie", "session=c; path=/");
|
|
||||||
return new Response("", { headers, status: 200 });
|
|
||||||
}
|
|
||||||
if (u.includes("page=debrideur")) return new Response('<form id="debridForm"></form>', { status: 200 });
|
|
||||||
if (u.includes("form=debrid")) return new Response(`<div class="acp-box"><h3>Link: https://mega.debrid/l</h3><a href="javascript:processDebrid(1,'code',0)">d</a></div>`, { status: 200 });
|
|
||||||
if (u.includes("ajax=debrid")) return new Response(JSON.stringify({ link: "https://mega.direct/ok" }), { status: 200 });
|
|
||||||
return new Response("Not found", { status: 404 });
|
|
||||||
}) as unknown as typeof fetch;
|
|
||||||
|
|
||||||
const fallback = new MegaWebFallback(() => ({ login: "d", password: "p" }));
|
|
||||||
const [r1, r2] = await Promise.all([
|
|
||||||
fallback.unrestrict("https://mega.debrid/a", undefined, { login: "acc1", password: "p" }),
|
|
||||||
fallback.unrestrict("https://mega.debrid/b", undefined, { login: "acc2", password: "p" })
|
|
||||||
]);
|
|
||||||
expect(r1?.directUrl).toBe("https://mega.direct/ok");
|
|
||||||
expect(r2?.directUrl).toBe("https://mega.direct/ok");
|
|
||||||
// Verschiedene Accounts → beide Logins gleichzeitig in-flight (sonst haengt es am bothInFlight-Barrier).
|
|
||||||
expect(maxActiveLogins).toBe(2);
|
|
||||||
}, 10000);
|
|
||||||
|
|
||||||
it("aborts pending Mega-Web polling when signal is cancelled", async () => {
|
it("aborts pending Mega-Web polling when signal is cancelled", async () => {
|
||||||
globalThis.fetch = vi.fn((url: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
globalThis.fetch = vi.fn((url: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
||||||
const urlStr = String(url);
|
const urlStr = String(url);
|
||||||
@ -314,41 +252,5 @@ describe("mega-web-fallback", () => {
|
|||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("klassifiziert einen Abbruch WAEHREND in der Queue als Queue-Timeout (nicht harter Abbruch), damit der belegte Account nicht bestraft wird", async () => {
|
|
||||||
let releaseLogin: () => void = () => {};
|
|
||||||
const loginGate = new Promise<void>((resolve) => { releaseLogin = resolve; });
|
|
||||||
globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
|
|
||||||
const u = String(url);
|
|
||||||
if (u.includes("form=login")) {
|
|
||||||
await loginGate;
|
|
||||||
const headers = new Headers();
|
|
||||||
headers.append("set-cookie", "session=c; path=/");
|
|
||||||
return new Response("", { headers, status: 200 });
|
|
||||||
}
|
|
||||||
if (u.includes("page=debrideur")) return new Response('<form id="debridForm"></form>', { status: 200 });
|
|
||||||
if (u.includes("form=debrid")) return new Response(`<div class="acp-box"><h3>Link: https://mega.debrid/l</h3><a href="javascript:processDebrid(1,'code',0)">d</a></div>`, { status: 200 });
|
|
||||||
if (u.includes("ajax=debrid")) return new Response(JSON.stringify({ link: "https://mega.direct/ok" }), { status: 200 });
|
|
||||||
return new Response("Not found", { status: 404 });
|
|
||||||
}) as unknown as typeof fetch;
|
|
||||||
|
|
||||||
const fallback = new MegaWebFallback(() => ({ login: "same", password: "pw" }));
|
|
||||||
const firstCtrl = new AbortController();
|
|
||||||
const first = fallback.unrestrict("https://mega.debrid/a", firstCtrl.signal, { login: "same", password: "pw" });
|
|
||||||
|
|
||||||
await new Promise((r) => setTimeout(r, 25));
|
|
||||||
|
|
||||||
const secondCtrl = new AbortController();
|
|
||||||
const second = fallback.unrestrict("https://mega.debrid/b", secondCtrl.signal, { login: "same", password: "pw" });
|
|
||||||
|
|
||||||
await new Promise((r) => setTimeout(r, 25));
|
|
||||||
secondCtrl.abort("caller-timeout");
|
|
||||||
|
|
||||||
await expect(second).rejects.toThrow(/queue.?timeout/i);
|
|
||||||
|
|
||||||
releaseLogin();
|
|
||||||
await first.catch(() => null);
|
|
||||||
await new Promise((r) => setTimeout(r, 20));
|
|
||||||
}, 10000);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Binary file not shown.
@ -180,67 +180,6 @@ describe("settings storage", () => {
|
|||||||
expect(webNormalized.hosterRouting.rapidgator).toBe("megadebrid-web");
|
expect(webNormalized.hosterRouting.rapidgator).toBe("megadebrid-web");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("migriert eine pre-v1.6.90-Config (Mega-Creds, beide Enable-Flags fehlen) zu aktiviertem Mega-Debrid statt es still auf false zu setzen", () => {
|
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
|
||||||
tempDirs.push(dir);
|
|
||||||
const paths = createStoragePaths(dir);
|
|
||||||
const legacyApi = {
|
|
||||||
megaLogin: "mega-user",
|
|
||||||
megaPassword: "mega-pass",
|
|
||||||
megaDebridPreferApi: true,
|
|
||||||
providerPrimary: "realdebrid",
|
|
||||||
providerSecondary: "megadebrid"
|
|
||||||
};
|
|
||||||
fs.writeFileSync(paths.configFile, JSON.stringify(legacyApi), "utf8");
|
|
||||||
const loadedApi = loadSettings(paths);
|
|
||||||
expect(loadedApi.megaDebridApiEnabled).toBe(true);
|
|
||||||
expect(loadedApi.megaDebridWebEnabled).toBe(false);
|
|
||||||
|
|
||||||
const dir2 = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
|
||||||
tempDirs.push(dir2);
|
|
||||||
const paths2 = createStoragePaths(dir2);
|
|
||||||
const legacyWeb = {
|
|
||||||
megaLogin: "mega-user",
|
|
||||||
megaPassword: "mega-pass",
|
|
||||||
megaDebridPreferApi: false,
|
|
||||||
providerPrimary: "realdebrid",
|
|
||||||
providerSecondary: "megadebrid"
|
|
||||||
};
|
|
||||||
fs.writeFileSync(paths2.configFile, JSON.stringify(legacyWeb), "utf8");
|
|
||||||
const loadedWeb = loadSettings(paths2);
|
|
||||||
expect(loadedWeb.megaDebridApiEnabled).toBe(false);
|
|
||||||
expect(loadedWeb.megaDebridWebEnabled).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("re-aktiviert KEINE bewusst deaktivierten Mega-Flags und migriert nicht ohne Mega-Creds", () => {
|
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
|
||||||
tempDirs.push(dir);
|
|
||||||
const paths = createStoragePaths(dir);
|
|
||||||
const deliberatelyDisabled = {
|
|
||||||
megaLogin: "mega-user",
|
|
||||||
megaPassword: "mega-pass",
|
|
||||||
megaDebridPreferApi: true,
|
|
||||||
megaDebridApiEnabled: false,
|
|
||||||
megaDebridWebEnabled: false
|
|
||||||
};
|
|
||||||
fs.writeFileSync(paths.configFile, JSON.stringify(deliberatelyDisabled), "utf8");
|
|
||||||
const loaded = loadSettings(paths);
|
|
||||||
expect(loaded.megaDebridApiEnabled).toBe(false);
|
|
||||||
expect(loaded.megaDebridWebEnabled).toBe(false);
|
|
||||||
|
|
||||||
const dir2 = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
|
||||||
tempDirs.push(dir2);
|
|
||||||
const paths2 = createStoragePaths(dir2);
|
|
||||||
const noCreds = {
|
|
||||||
megaDebridPreferApi: true,
|
|
||||||
providerPrimary: "realdebrid"
|
|
||||||
};
|
|
||||||
fs.writeFileSync(paths2.configFile, JSON.stringify(noCreds), "utf8");
|
|
||||||
const loadedNoCreds = loadSettings(paths2);
|
|
||||||
expect(loadedNoCreds.megaDebridApiEnabled).toBe(false);
|
|
||||||
expect(loadedNoCreds.megaDebridWebEnabled).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("normalizes provider daily limits and resets stale daily usage", () => {
|
it("normalizes provider daily limits and resets stale daily usage", () => {
|
||||||
const [debridLinkKey] = parseDebridLinkApiKeys("dl-key-one");
|
const [debridLinkKey] = parseDebridLinkApiKeys("dl-key-one");
|
||||||
const normalized = normalizeSettings({
|
const normalized = normalizeSettings({
|
||||||
|
|||||||
@ -1,67 +0,0 @@
|
|||||||
import fs from "node:fs";
|
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
import AdmZip from "adm-zip";
|
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
|
||||||
import { buildSupportBundle } from "../src/main/support-bundle";
|
|
||||||
import type { DownloadManager } from "../src/main/download-manager";
|
|
||||||
|
|
||||||
const tempDirs: string[] = [];
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
for (const dir of tempDirs.splice(0)) {
|
|
||||||
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function fakeManager(): DownloadManager {
|
|
||||||
const snapshot = {
|
|
||||||
stats: {},
|
|
||||||
session: { packages: {}, items: {}, packageOrder: [] },
|
|
||||||
speedText: "",
|
|
||||||
etaText: "",
|
|
||||||
canStart: false,
|
|
||||||
canStop: false,
|
|
||||||
canPause: false
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
getSnapshot: () => snapshot,
|
|
||||||
getPackageLogPath: () => null,
|
|
||||||
getItemLogPath: () => null
|
|
||||||
} as unknown as DownloadManager;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("buildSupportBundle (async, non-blocking)", () => {
|
|
||||||
it("returns a Promise and produces a valid zip with overview + a real on-disk file", async () => {
|
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-"));
|
|
||||||
tempDirs.push(root);
|
|
||||||
fs.writeFileSync(path.join(root, "debug_host.txt"), "host-info-test", "utf8");
|
|
||||||
|
|
||||||
const promise = buildSupportBundle(fakeManager(), root, { hostDiagnosticsMode: "none" });
|
|
||||||
expect(promise).toBeInstanceOf(Promise);
|
|
||||||
|
|
||||||
const buffer = await promise;
|
|
||||||
expect(Buffer.isBuffer(buffer)).toBe(true);
|
|
||||||
expect(buffer.length).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
const entries = new AdmZip(buffer).getEntries().map((e) => e.entryName);
|
|
||||||
expect(entries).toContain("overview/meta.json");
|
|
||||||
expect(entries).toContain("overview/settings.json");
|
|
||||||
expect(entries).toContain("runtime/debug_host.txt");
|
|
||||||
|
|
||||||
const hostEntry = new AdmZip(buffer).getEntry("runtime/debug_host.txt");
|
|
||||||
expect(hostEntry?.getData().toString("utf8")).toBe("host-info-test");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not block the event loop while building (a concurrent timer still fires)", async () => {
|
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-"));
|
|
||||||
tempDirs.push(root);
|
|
||||||
|
|
||||||
let timerFired = false;
|
|
||||||
const timer = setTimeout(() => { timerFired = true; }, 0);
|
|
||||||
await buildSupportBundle(fakeManager(), root, { hostDiagnosticsMode: "none" });
|
|
||||||
clearTimeout(timer);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
expect(timerFired).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,114 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { transientResolveRetryDelayMs, parseMegaDebridCooldownRetry, parseMegaDebridResetPark, parseMegaDebridSlowLinkRetry } from "../src/main/download-manager";
|
|
||||||
|
|
||||||
describe("transientResolveRetryDelayMs (fast, bounded retry for transient resolve failures)", () => {
|
|
||||||
it("starts fast (<= 3s) instead of the 5s..120s exponential", () => {
|
|
||||||
expect(transientResolveRetryDelayMs(1)).toBeLessThanOrEqual(3000);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ramps gently and caps at 10s", () => {
|
|
||||||
expect(transientResolveRetryDelayMs(2)).toBeLessThanOrEqual(7000);
|
|
||||||
expect(transientResolveRetryDelayMs(3)).toBeLessThanOrEqual(10000);
|
|
||||||
expect(transientResolveRetryDelayMs(10)).toBe(10000);
|
|
||||||
expect(transientResolveRetryDelayMs(100)).toBe(10000);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("never schedules anywhere near the 5s..120s exponential cap", () => {
|
|
||||||
for (let n = 1; n <= 50; n += 1) {
|
|
||||||
expect(transientResolveRetryDelayMs(n)).toBeLessThanOrEqual(10000);
|
|
||||||
expect(transientResolveRetryDelayMs(n)).toBeGreaterThanOrEqual(1000);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is monotonic non-decreasing", () => {
|
|
||||||
let prev = 0;
|
|
||||||
for (let n = 1; n <= 12; n += 1) {
|
|
||||||
const d = transientResolveRetryDelayMs(n);
|
|
||||||
expect(d).toBeGreaterThanOrEqual(prev);
|
|
||||||
prev = d;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("parseMegaDebridCooldownRetry (honor the encoded account-cooldown delay)", () => {
|
|
||||||
it("parses the encoded delay from a bare mega_debrid_cooldown error", () => {
|
|
||||||
const r = parseMegaDebridCooldownRetry("mega_debrid_cooldown:20330:Mega-Debrid (Account 2/2, Da******el): Token error");
|
|
||||||
expect(r).not.toBeNull();
|
|
||||||
expect(r!.delayMs).toBe(20330);
|
|
||||||
expect(r!.detail).toContain("Mega-Debrid");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("parses it when embedded in the aggregated provider-chain error", () => {
|
|
||||||
const aggregated = "Unrestrict fehlgeschlagen: Mega-Debrid API: mega_debrid_cooldown:20330:Mega-Debrid (Account 2/2): Token error";
|
|
||||||
expect(parseMegaDebridCooldownRetry(aggregated)!.delayMs).toBe(20330);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("takes the SOONEST (min) cooldown when several accounts are cooled", () => {
|
|
||||||
const both = "Mega-Debrid API: mega_debrid_cooldown:116285:web | Mega-Debrid API: mega_debrid_cooldown:20330:api";
|
|
||||||
expect(parseMegaDebridCooldownRetry(both)!.delayMs).toBe(20330);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("clamps to [1s, 15min]", () => {
|
|
||||||
expect(parseMegaDebridCooldownRetry("mega_debrid_cooldown:1:x")!.delayMs).toBe(1000);
|
|
||||||
expect(parseMegaDebridCooldownRetry("mega_debrid_cooldown:99999999:x")!.delayMs).toBe(15 * 60 * 1000);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null when there is no mega cooldown marker", () => {
|
|
||||||
expect(parseMegaDebridCooldownRetry("Datei beim Hoster gerade nicht abrufbar")).toBeNull();
|
|
||||||
expect(parseMegaDebridCooldownRetry("debrid_link_cooldown:5000:x")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does NOT swallow the until-Tagesreset park token", () => {
|
|
||||||
expect(parseMegaDebridCooldownRetry("mega_debrid_reset_park:43200000:Alle Accounts bis zum Tagesreset gesperrt")).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("parseMegaDebridSlowLinkRetry (park only the slow link, never the account)", () => {
|
|
||||||
it("parses the encoded delay from a slow-link error", () => {
|
|
||||||
const r = parseMegaDebridSlowLinkRetry("mega_debrid_slow_link:120000:Mega-Debrid (Account 1/1, Su******e3): aborted");
|
|
||||||
expect(r).not.toBeNull();
|
|
||||||
expect(r!.delayMs).toBe(120000);
|
|
||||||
expect(r!.detail).toContain("Mega-Debrid");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("parses it when embedded in the aggregated provider-chain error", () => {
|
|
||||||
const aggregated = "Provider-Kette: Mega-Debrid Web fehlgeschlagen (Error: mega_debrid_slow_link:90000:Mega-Debrid (Account 1/1): aborted)";
|
|
||||||
expect(parseMegaDebridSlowLinkRetry(aggregated)!.delayMs).toBe(90000);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("clamps to [1s, 15min]", () => {
|
|
||||||
expect(parseMegaDebridSlowLinkRetry("mega_debrid_slow_link:1:x")!.delayMs).toBe(1000);
|
|
||||||
expect(parseMegaDebridSlowLinkRetry("mega_debrid_slow_link:99999999:x")!.delayMs).toBe(15 * 60 * 1000);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not collide with the account-cooldown or reset-park tokens", () => {
|
|
||||||
expect(parseMegaDebridSlowLinkRetry("mega_debrid_cooldown:20330:x")).toBeNull();
|
|
||||||
expect(parseMegaDebridSlowLinkRetry("mega_debrid_reset_park:43200000:x")).toBeNull();
|
|
||||||
expect(parseMegaDebridCooldownRetry("mega_debrid_slow_link:120000:x")).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("parseMegaDebridResetPark (park the item until the Tagesreset, not a ~2min generic retry)", () => {
|
|
||||||
it("parses the encoded until-reset delay from the park token", () => {
|
|
||||||
const r = parseMegaDebridResetPark("mega_debrid_reset_park:43200000:Mega-Debrid: Alle Accounts am Tageslimit (bis zum Tagesreset gesperrt)");
|
|
||||||
expect(r).not.toBeNull();
|
|
||||||
expect(r!.delayMs).toBe(43200000);
|
|
||||||
expect(r!.detail).toContain("bis zum Tagesreset gesperrt");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("parses it when embedded in the aggregated provider-chain error", () => {
|
|
||||||
const aggregated = "Unrestrict fehlgeschlagen: Mega-Debrid API: mega_debrid_reset_park:7200000:Alle Accounts bis zum Tagesreset gesperrt";
|
|
||||||
expect(parseMegaDebridResetPark(aggregated)!.delayMs).toBe(7200000);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is NOT clamped to the 15min cooldown ceiling (can park multiple hours)", () => {
|
|
||||||
expect(parseMegaDebridResetPark("mega_debrid_reset_park:21600000:x")!.delayMs).toBe(21600000);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("clamps to a sane [1s, 26h] window and rejects junk", () => {
|
|
||||||
expect(parseMegaDebridResetPark("mega_debrid_reset_park:1:x")!.delayMs).toBe(1000);
|
|
||||||
expect(parseMegaDebridResetPark("mega_debrid_reset_park:999999999999:x")!.delayMs).toBe(26 * 60 * 60 * 1000);
|
|
||||||
expect(parseMegaDebridResetPark("mega_debrid_cooldown:20330:x")).toBeNull();
|
|
||||||
expect(parseMegaDebridResetPark("kein Token hier")).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,63 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { runInstallWithResume, InstallResumeManager } from "../src/main/update-install-flow";
|
|
||||||
|
|
||||||
function makeManager(running: boolean): InstallResumeManager & { startCalls: number; stopCalls: number; persistCalls: number; sessionRunning: boolean } {
|
|
||||||
return {
|
|
||||||
sessionRunning: running,
|
|
||||||
startCalls: 0,
|
|
||||||
stopCalls: 0,
|
|
||||||
persistCalls: 0,
|
|
||||||
isSessionRunning() {
|
|
||||||
return this.sessionRunning;
|
|
||||||
},
|
|
||||||
stop() {
|
|
||||||
this.stopCalls += 1;
|
|
||||||
this.sessionRunning = false;
|
|
||||||
},
|
|
||||||
persistNowSync() {
|
|
||||||
this.persistCalls += 1;
|
|
||||||
},
|
|
||||||
async start() {
|
|
||||||
this.startCalls += 1;
|
|
||||||
this.sessionRunning = true;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("runInstallWithResume", () => {
|
|
||||||
it("resumes a running session when the install returns started:false", async () => {
|
|
||||||
const m = makeManager(true);
|
|
||||||
const result = await runInstallWithResume(m, async () => ({ started: false }));
|
|
||||||
expect(result.started).toBe(false);
|
|
||||||
expect(m.stopCalls).toBe(1);
|
|
||||||
expect(m.startCalls).toBe(1);
|
|
||||||
expect(m.isSessionRunning()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("resumes a running session when the install THROWS, then rethrows", async () => {
|
|
||||||
const m = makeManager(true);
|
|
||||||
await expect(
|
|
||||||
runInstallWithResume(m, async () => {
|
|
||||||
throw new Error("network down");
|
|
||||||
})
|
|
||||||
).rejects.toThrow("network down");
|
|
||||||
expect(m.stopCalls).toBe(1);
|
|
||||||
expect(m.startCalls).toBe(1);
|
|
||||||
expect(m.isSessionRunning()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does NOT resume when the install succeeds (started:true) — the app is about to quit", async () => {
|
|
||||||
const m = makeManager(true);
|
|
||||||
const result = await runInstallWithResume(m, async () => ({ started: true }));
|
|
||||||
expect(result.started).toBe(true);
|
|
||||||
expect(m.startCalls).toBe(0);
|
|
||||||
expect(m.isSessionRunning()).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does NOT resume when no session was running before the install", async () => {
|
|
||||||
const m = makeManager(false);
|
|
||||||
await runInstallWithResume(m, async () => ({ started: false }));
|
|
||||||
expect(m.stopCalls).toBe(0);
|
|
||||||
expect(m.startCalls).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -92,17 +92,6 @@ describe("pickAudioTrack", () => {
|
|||||||
expect(d).toMatchObject({ action: "remux", audioRelIndex: 0, reason: "fallback-first-untagged" });
|
expect(d).toMatchObject({ action: "remux", audioRelIndex: 0, reason: "fallback-first-untagged" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("tag mode does NOT let an eng track titled 'German' beat a correctly-tagged German track", () => {
|
|
||||||
const engTitledGerman = { language: "eng", title: "German Commentary" };
|
|
||||||
const d = pickAudioTrack([engTitledGerman, ger], "tag");
|
|
||||||
expect(d).toMatchObject({ action: "remux", audioRelIndex: 1, reason: "german-tag" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("tag mode does NOT pick a non-German-tagged track just because its title contains 'Deutsch'", () => {
|
|
||||||
const d = pickAudioTrack([{ language: "eng", title: "Deutsch entfernt" }, { language: "fre", title: "" }], "tag");
|
|
||||||
expect(d).toMatchObject({ action: "skip", reason: "no-german-track" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("tag mode with single German -> single (no remux)", () => {
|
it("tag mode with single German -> single (no remux)", () => {
|
||||||
expect(pickAudioTrack([ger], "tag")).toMatchObject({ action: "single" });
|
expect(pickAudioTrack([ger], "tag")).toMatchObject({ action: "single" });
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,62 +0,0 @@
|
|||||||
# rd-diagnostics-mcp
|
|
||||||
|
|
||||||
Standalone **stdio MCP bridge** to the Real-Debrid-Downloader debug-server. It runs on the machine where the
|
|
||||||
MCP client runs, takes a **connection code** for a downloader server, and exposes that server's
|
|
||||||
read-only HTTP diagnostics API (`/diagnostics`, `/status`, `/errors`, `/logs/*`, `/accounts`, …) as MCP tools.
|
|
||||||
One bridge serves all 5–6 servers; you pass a `code` (or a configured `server` name) per call.
|
|
||||||
|
|
||||||
This bridge is **not** bundled into the Electron app and adds **no** dependencies to it.
|
|
||||||
|
|
||||||
## Setup
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd tools/rd-diagnostics-mcp
|
|
||||||
npm install
|
|
||||||
```
|
|
||||||
|
|
||||||
Register it with your MCP client as a stdio server that launches the bridge:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
node "<repo>/tools/rd-diagnostics-mcp/src/bridge.mjs"
|
|
||||||
```
|
|
||||||
|
|
||||||
Provide servers via environment variables (codes contain a token — treat like passwords):
|
|
||||||
|
|
||||||
- `RDDIAG_CODE` — a single default connection code (`rddiag:v1:...`)
|
|
||||||
- `RDDIAG_SERVERS` — JSON map of name → code, e.g. `{"berlin":"rddiag:v1:...","fra":"rddiag:v1:..."}`
|
|
||||||
|
|
||||||
Without env config, every tool simply takes a `code` argument.
|
|
||||||
|
|
||||||
## Tools
|
|
||||||
|
|
||||||
`rd_servers`, `rd_ping`, `rd_diagnostics`, `rd_status`, `rd_items`, `rd_packages`, `rd_errors`, `rd_logs`
|
|
||||||
(`main|audit|rename|trace|session|conversion|package|item`), `rd_history`, `rd_accounts`, `rd_providers`
|
|
||||||
(live per-account/key cooldown + in-flight + rotation state), `rd_host`, `rd_self_check`,
|
|
||||||
`rd_get` (raw escape-hatch, any read-only path).
|
|
||||||
|
|
||||||
Each tool accepts `code` or `server` to pick the target.
|
|
||||||
|
|
||||||
## Connection code
|
|
||||||
|
|
||||||
Format: `rddiag:v1:<base64url(JSON)>` with `{ v:1, h:host, p:port, t:token, n?:name, fp?:certFingerprint, s?:scheme }`.
|
|
||||||
Generated by the app (Hilfe → Remote-Support → Ferndiagnose (MCP)) or via `node src/gen-code.mjs --host H --port P --token T`.
|
|
||||||
|
|
||||||
## Security model (read before exposing a server)
|
|
||||||
|
|
||||||
- The debug surface is **read-only** for state/logs; the one control endpoint is `/trace/config` (toggles the
|
|
||||||
optional, time-bounded support trace). No persistent secrets are written into the logs it serves: passwords are
|
|
||||||
redacted, debrid API keys/tokens and resolved download URLs are never logged; `/settings` and `/accounts` are redacted.
|
|
||||||
- Auth is a bearer token (24 random bytes). Over plain HTTP on a public network the token is sniffable, so:
|
|
||||||
- **Preferred:** keep the server bound to `127.0.0.1` ("Nur lokal") and reach it through a private tunnel
|
|
||||||
(Tailscale / SSH / Cloudflare Tunnel). The tunnel encrypts and authenticates; no public exposure.
|
|
||||||
- **Direct network bind (`0.0.0.0`)** requires a non-empty **IP allowlist** (enforced fail-closed: with an empty
|
|
||||||
allowlist only loopback is accepted). Use only inside a trusted LAN/VPN.
|
|
||||||
- Revoke instantly from the app ("Token neu" or "Deaktivieren") — the old code stops working immediately.
|
|
||||||
- `fp` pins a self-signed cert fingerprint and is verified on `secureConnect` (before the token is sent). HTTPS is
|
|
||||||
not the v1 default; plain HTTP behind a tunnel is the recommended transport.
|
|
||||||
|
|
||||||
## Test
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm test # spins a fake debug-server, runs the bridge as a stdio child, asserts the full protocol path
|
|
||||||
```
|
|
||||||
1172
tools/rd-diagnostics-mcp/package-lock.json
generated
1172
tools/rd-diagnostics-mcp/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "rd-diagnostics-mcp",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"private": true,
|
|
||||||
"description": "Standalone stdio MCP bridge to the Real-Debrid-Downloader debug-server. Connects via connection code, proxies the read-only HTTP diagnostics API as MCP tools.",
|
|
||||||
"type": "module",
|
|
||||||
"bin": {
|
|
||||||
"rd-diagnostics-mcp": "src/bridge.mjs"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"start": "node src/bridge.mjs",
|
|
||||||
"test": "node test/harness.mjs"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
||||||
"zod": "^3.23.8"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,332 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
||||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { decodeConnectionCode } from "./code.mjs";
|
|
||||||
import { debugGet } from "./http.mjs";
|
|
||||||
|
|
||||||
function loadServerMap() {
|
|
||||||
const map = new Map();
|
|
||||||
const raw = process.env.RDDIAG_SERVERS;
|
|
||||||
if (raw) {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(raw);
|
|
||||||
for (const [name, code] of Object.entries(parsed || {})) {
|
|
||||||
map.set(String(name), String(code));
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
process.stderr.write("rd-diagnostics-mcp: RDDIAG_SERVERS ist kein gueltiges JSON, wird ignoriert\n");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SERVER_MAP = loadServerMap();
|
|
||||||
const DEFAULT_CODE = process.env.RDDIAG_CODE ? String(process.env.RDDIAG_CODE) : "";
|
|
||||||
|
|
||||||
function listAvailableServers() {
|
|
||||||
const names = [...SERVER_MAP.keys()];
|
|
||||||
if (DEFAULT_CODE) names.push("(RDDIAG_CODE-Default)");
|
|
||||||
return names;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveTarget(args) {
|
|
||||||
let code = "";
|
|
||||||
if (args && args.code) {
|
|
||||||
code = String(args.code);
|
|
||||||
} else if (args && args.server) {
|
|
||||||
const found = SERVER_MAP.get(String(args.server));
|
|
||||||
if (!found) {
|
|
||||||
throw new Error(
|
|
||||||
`Server "${args.server}" nicht konfiguriert. Bekannt: ${listAvailableServers().join(", ") || "(keine)"}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
code = found;
|
|
||||||
} else if (DEFAULT_CODE) {
|
|
||||||
code = DEFAULT_CODE;
|
|
||||||
} else if (SERVER_MAP.size === 1) {
|
|
||||||
code = [...SERVER_MAP.values()][0];
|
|
||||||
} else {
|
|
||||||
throw new Error(
|
|
||||||
`Kein Verbindungscode. Uebergib "code" oder "server", oder setze RDDIAG_CODE/RDDIAG_SERVERS. Bekannt: ${listAvailableServers().join(", ") || "(keine)"}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return decodeConnectionCode(code);
|
|
||||||
}
|
|
||||||
|
|
||||||
function targetLabel(target) {
|
|
||||||
return target.name ? `${target.name} (${target.host}:${target.port})` : `${target.host}:${target.port}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildQuery(params) {
|
|
||||||
const usable = Object.entries(params || {}).filter(
|
|
||||||
([, v]) => v !== undefined && v !== null && String(v).length > 0
|
|
||||||
);
|
|
||||||
if (usable.length === 0) return "";
|
|
||||||
const sp = new URLSearchParams();
|
|
||||||
for (const [k, v] of usable) sp.set(k, String(v));
|
|
||||||
return "?" + sp.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
function prettyBody(body) {
|
|
||||||
try {
|
|
||||||
return JSON.stringify(JSON.parse(body), null, 2);
|
|
||||||
} catch {
|
|
||||||
return body;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function connectionHint(err) {
|
|
||||||
const m = String((err && err.code) || err && err.message || "");
|
|
||||||
if (/ECONNREFUSED/.test(m)) return "Debug-Server nicht erreichbar — auf dem Server aktiviert? Port/Firewall offen?";
|
|
||||||
if (/ENOTFOUND|EAI_AGAIN/.test(m)) return "Host nicht aufloesbar — stimmt die Adresse im Verbindungscode?";
|
|
||||||
if (/ETIMEDOUT|Zeitueberschreitung/.test(m)) return "Zeitueberschreitung — Server/Netz langsam oder Port geblockt.";
|
|
||||||
if (/ECONNRESET|EPIPE/.test(m)) return "Verbindung abgebrochen — falscher Port/Scheme (http vs https)?";
|
|
||||||
if (/Fingerprint/.test(m)) return "TLS-Fingerprint passt nicht — Code stammt evtl. von einem anderen Server.";
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
async function requestTool(args, path, params, opts = {}) {
|
|
||||||
let target;
|
|
||||||
try {
|
|
||||||
target = resolveTarget(args);
|
|
||||||
} catch (err) {
|
|
||||||
return { content: [{ type: "text", text: `# Verbindungsfehler\n${err.message}` }], isError: true };
|
|
||||||
}
|
|
||||||
const fullPath = path + buildQuery(params);
|
|
||||||
const label = targetLabel(target);
|
|
||||||
try {
|
|
||||||
const res = await debugGet(target, fullPath, { timeoutMs: opts.timeoutMs || 20000 });
|
|
||||||
const isError = res.status < 200 || res.status >= 300;
|
|
||||||
let extra = "";
|
|
||||||
if (res.status === 401) extra = "\n(401 = Token im Verbindungscode ist abgelaufen/rotiert. Neuen Code anfordern.)";
|
|
||||||
if (res.status === 503) extra = "\n(503 = Download-Manager nicht bereit. App laeuft, aber noch nicht initialisiert?)";
|
|
||||||
const head = `# ${label} ${fullPath} → HTTP ${res.status}${extra}`;
|
|
||||||
return { content: [{ type: "text", text: head + "\n" + prettyBody(res.body) }], isError };
|
|
||||||
} catch (err) {
|
|
||||||
const hint = connectionHint(err);
|
|
||||||
const text = `# ${label} ${fullPath} → FEHLER\n${err.message}${hint ? "\n→ " + hint : ""}`;
|
|
||||||
return { content: [{ type: "text", text }], isError: true };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const CODE_FIELD = {
|
|
||||||
code: z.string().optional().describe("Verbindungscode (rddiag:v1:...). Optional, wenn server/RDDIAG_CODE gesetzt ist."),
|
|
||||||
server: z.string().optional().describe("Name eines via RDDIAG_SERVERS konfigurierten Servers statt eines vollen Codes.")
|
|
||||||
};
|
|
||||||
|
|
||||||
const server = new McpServer({ name: "rd-diagnostics-mcp", version: "1.0.0" });
|
|
||||||
|
|
||||||
server.registerTool(
|
|
||||||
"rd_servers",
|
|
||||||
{
|
|
||||||
title: "Konfigurierte Server",
|
|
||||||
description: "Listet die in dieser Bridge konfigurierten Server (RDDIAG_SERVERS / RDDIAG_CODE). Verbindet sich nicht.",
|
|
||||||
inputSchema: {}
|
|
||||||
},
|
|
||||||
async () => {
|
|
||||||
const names = [...SERVER_MAP.keys()];
|
|
||||||
const lines = [];
|
|
||||||
lines.push(`Konfigurierte Server: ${names.length}`);
|
|
||||||
for (const n of names) lines.push(`- ${n}`);
|
|
||||||
lines.push(`Default (RDDIAG_CODE): ${DEFAULT_CODE ? "gesetzt" : "nicht gesetzt"}`);
|
|
||||||
lines.push("");
|
|
||||||
lines.push("Tools akzeptieren entweder code:<rddiag:v1:...> oder server:<name>.");
|
|
||||||
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
server.registerTool(
|
|
||||||
"rd_ping",
|
|
||||||
{
|
|
||||||
title: "Erreichbarkeit pruefen",
|
|
||||||
description: "Schneller Health-Check (GET /health): App-Version, Uptime, Speicher. Zuerst aufrufen, um Erreichbarkeit + Token zu pruefen.",
|
|
||||||
inputSchema: { ...CODE_FIELD }
|
|
||||||
},
|
|
||||||
async (args) => requestTool(args, "/health", {}, { timeoutMs: 10000 })
|
|
||||||
);
|
|
||||||
|
|
||||||
server.registerTool(
|
|
||||||
"rd_diagnostics",
|
|
||||||
{
|
|
||||||
title: "Gesamtdiagnose",
|
|
||||||
description: "Aggregierter Zustand (GET /diagnostics): Meta, Status, Settings, Stats, Accounts, History, Host + die wichtigsten Logs. Der 'alles auf einen Blick'-Endpunkt.",
|
|
||||||
inputSchema: {
|
|
||||||
...CODE_FIELD,
|
|
||||||
lines: z.number().int().positive().optional().describe("Anzahl Log-Zeilen pro Log (Default 150)."),
|
|
||||||
grep: z.string().optional().describe("Filter fuer Log-Zeilen."),
|
|
||||||
package: z.string().optional().describe("Optional auf ein Paket fokussieren.")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async (args) => requestTool(args, "/diagnostics", { lines: args.lines, grep: args.grep, package: args.package }, { timeoutMs: 30000 })
|
|
||||||
);
|
|
||||||
|
|
||||||
server.registerTool(
|
|
||||||
"rd_status",
|
|
||||||
{
|
|
||||||
title: "Live-Status",
|
|
||||||
description: "Laufzeit-Status (GET /status): aktive Downloads, Queue, Provider-Zustand.",
|
|
||||||
inputSchema: { ...CODE_FIELD }
|
|
||||||
},
|
|
||||||
async (args) => requestTool(args, "/status", {})
|
|
||||||
);
|
|
||||||
|
|
||||||
server.registerTool(
|
|
||||||
"rd_items",
|
|
||||||
{
|
|
||||||
title: "Download-Items",
|
|
||||||
description: "Einzelne Download-Items (GET /items), optional gefiltert nach Status/Paket.",
|
|
||||||
inputSchema: {
|
|
||||||
...CODE_FIELD,
|
|
||||||
status: z.string().optional().describe("Status-Filter (z.B. downloading, error, done)."),
|
|
||||||
package: z.string().optional().describe("Paket-Filter.")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async (args) => requestTool(args, "/items", { status: args.status, package: args.package })
|
|
||||||
);
|
|
||||||
|
|
||||||
server.registerTool(
|
|
||||||
"rd_packages",
|
|
||||||
{
|
|
||||||
title: "Pakete",
|
|
||||||
description: "Pakete (GET /packages), optional mit enthaltenen Items.",
|
|
||||||
inputSchema: {
|
|
||||||
...CODE_FIELD,
|
|
||||||
package: z.string().optional().describe("Bestimmtes Paket."),
|
|
||||||
includeItems: z.boolean().optional().describe("Items mitliefern.")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async (args) => requestTool(args, "/packages", { package: args.package, includeItems: args.includeItems ? "1" : "" })
|
|
||||||
);
|
|
||||||
|
|
||||||
server.registerTool(
|
|
||||||
"rd_errors",
|
|
||||||
{
|
|
||||||
title: "Letzte Fehler",
|
|
||||||
description: "Fehler-Ring (GET /errors): die letzten Fehler mit Level/Quelle. 'Was ist schiefgelaufen'.",
|
|
||||||
inputSchema: {
|
|
||||||
...CODE_FIELD,
|
|
||||||
level: z.string().optional().describe("Level-Filter (ERROR, WARN, ...)."),
|
|
||||||
limit: z.number().int().positive().optional().describe("Anzahl (Default 100).")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async (args) => requestTool(args, "/errors", { level: args.level, limit: args.limit })
|
|
||||||
);
|
|
||||||
|
|
||||||
const LOG_PATHS = {
|
|
||||||
main: "/logs/main",
|
|
||||||
audit: "/logs/audit",
|
|
||||||
rename: "/logs/rename",
|
|
||||||
trace: "/logs/trace",
|
|
||||||
session: "/logs/session",
|
|
||||||
conversion: "/logs/conversion",
|
|
||||||
package: "/logs/package",
|
|
||||||
item: "/logs/item"
|
|
||||||
};
|
|
||||||
|
|
||||||
server.registerTool(
|
|
||||||
"rd_logs",
|
|
||||||
{
|
|
||||||
title: "Log lesen",
|
|
||||||
description: "Liest das Ende eines Logs (GET /logs/<name>). name: main|audit|rename|trace|session|conversion|package|item. conversion = Pro-Item Link-Aufloesungs-Lebenszyklus (Token, API, Web, Rotation, Abbrueche mit Zeiten). Fuer package/item zusaetzlich package/item angeben.",
|
|
||||||
inputSchema: {
|
|
||||||
...CODE_FIELD,
|
|
||||||
name: z.enum(["main", "audit", "rename", "trace", "session", "conversion", "package", "item"]).describe("Welches Log."),
|
|
||||||
lines: z.number().int().positive().optional().describe("Anzahl Zeilen vom Ende (Default 100)."),
|
|
||||||
grep: z.string().optional().describe("Filter."),
|
|
||||||
package: z.string().optional().describe("Nur fuer name=package."),
|
|
||||||
item: z.string().optional().describe("Nur fuer name=item.")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async (args) => {
|
|
||||||
const path = LOG_PATHS[args.name];
|
|
||||||
return requestTool(args, path, { lines: args.lines, grep: args.grep, package: args.package, item: args.item });
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
server.registerTool(
|
|
||||||
"rd_history",
|
|
||||||
{
|
|
||||||
title: "Verlauf",
|
|
||||||
description: "Abgeschlossener Verlauf (GET /history), optional nach Status/Suchbegriff.",
|
|
||||||
inputSchema: {
|
|
||||||
...CODE_FIELD,
|
|
||||||
limit: z.number().int().positive().optional().describe("Anzahl (Default 50)."),
|
|
||||||
status: z.string().optional().describe("Status-Filter."),
|
|
||||||
grep: z.string().optional().describe("Suchbegriff.")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async (args) => requestTool(args, "/history", { limit: args.limit, status: args.status, grep: args.grep })
|
|
||||||
);
|
|
||||||
|
|
||||||
server.registerTool(
|
|
||||||
"rd_accounts",
|
|
||||||
{
|
|
||||||
title: "Accounts",
|
|
||||||
description: "Debrid-Accounts (GET /accounts, Token redigiert): Gueltigkeit, Premium, Cooldown/Rotation.",
|
|
||||||
inputSchema: { ...CODE_FIELD }
|
|
||||||
},
|
|
||||||
async (args) => requestTool(args, "/accounts", {})
|
|
||||||
);
|
|
||||||
|
|
||||||
server.registerTool(
|
|
||||||
"rd_providers",
|
|
||||||
{
|
|
||||||
title: "Provider-Laufzeitzustand",
|
|
||||||
description: "Live Provider-Runtime (GET /providers): pro Mega-Account/Debrid-Link-Key der AKTIVE Cooldown (until/remainingMs/Grund/Kategorie), in-flight-Tiefe, Mega-Rotationscursor, Empty-Response-Streaks. Die 'warum kuehlt es JETZT ab'-Ansicht — beantwortet Cooldown-Fragen direkt statt aus Log-Arithmetik.",
|
|
||||||
inputSchema: { ...CODE_FIELD }
|
|
||||||
},
|
|
||||||
async (args) => requestTool(args, "/providers", {})
|
|
||||||
);
|
|
||||||
|
|
||||||
server.registerTool(
|
|
||||||
"rd_host",
|
|
||||||
{
|
|
||||||
title: "Host-Diagnose",
|
|
||||||
description: "Windows-Host-Diagnose (GET /host/diagnostics): Laufwerke, Speicher, Pfade.",
|
|
||||||
inputSchema: { ...CODE_FIELD }
|
|
||||||
},
|
|
||||||
async (args) => requestTool(args, "/host/diagnostics", {})
|
|
||||||
);
|
|
||||||
|
|
||||||
server.registerTool(
|
|
||||||
"rd_self_check",
|
|
||||||
{
|
|
||||||
title: "Self-Check",
|
|
||||||
description: "Setup/Self-Check (GET /self-check): erkennt Konfigurations-/Pfadprobleme.",
|
|
||||||
inputSchema: { ...CODE_FIELD }
|
|
||||||
},
|
|
||||||
async (args) => requestTool(args, "/self-check", {})
|
|
||||||
);
|
|
||||||
|
|
||||||
server.registerTool(
|
|
||||||
"rd_get",
|
|
||||||
{
|
|
||||||
title: "Roh-Endpunkt (Escape-Hatch)",
|
|
||||||
description: "Beliebigen Debug-Server-Pfad lesen (GET <path>), wenn kein spezialisiertes Tool passt. Pfad inkl. fuehrendem / und optionalem Query-String, z.B. /meta oder /stats.",
|
|
||||||
inputSchema: {
|
|
||||||
...CODE_FIELD,
|
|
||||||
path: z.string().describe("Pfad mit fuehrendem /, optional ?query. Nur GET, read-only.")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async (args) => {
|
|
||||||
const p = String(args.path || "");
|
|
||||||
if (!p.startsWith("/")) {
|
|
||||||
return { content: [{ type: "text", text: "# Fehler\npath muss mit / beginnen" }], isError: true };
|
|
||||||
}
|
|
||||||
return requestTool(args, p, {}, { timeoutMs: 30000 });
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const transport = new StdioServerTransport();
|
|
||||||
await server.connect(transport);
|
|
||||||
process.stderr.write(
|
|
||||||
`rd-diagnostics-mcp bereit. Server: ${listAvailableServers().join(", ") || "(keine vorkonfiguriert; code pro Aufruf uebergeben)"}\n`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((err) => {
|
|
||||||
process.stderr.write(`rd-diagnostics-mcp Startfehler: ${err && err.stack ? err.stack : err}\n`);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
export interface DecodedConnectionCode {
|
|
||||||
host: string;
|
|
||||||
port: number;
|
|
||||||
token: string;
|
|
||||||
scheme: string;
|
|
||||||
name: string;
|
|
||||||
fingerprint: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EncodeConnectionCodeInput {
|
|
||||||
host: string;
|
|
||||||
port: number;
|
|
||||||
token: string;
|
|
||||||
name?: string;
|
|
||||||
fingerprint?: string;
|
|
||||||
scheme?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function encodeConnectionCode(input: EncodeConnectionCodeInput): string;
|
|
||||||
export function decodeConnectionCode(code: string): DecodedConnectionCode;
|
|
||||||
@ -1,56 +0,0 @@
|
|||||||
const PREFIX = "rddiag:v1:";
|
|
||||||
|
|
||||||
function base64urlEncode(str) {
|
|
||||||
return Buffer.from(str, "utf8")
|
|
||||||
.toString("base64")
|
|
||||||
.replace(/\+/g, "-")
|
|
||||||
.replace(/\//g, "_")
|
|
||||||
.replace(/=+$/, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
function base64urlDecode(str) {
|
|
||||||
const pad = str.length % 4 === 0 ? "" : "=".repeat(4 - (str.length % 4));
|
|
||||||
const b64 = str.replace(/-/g, "+").replace(/_/g, "/") + pad;
|
|
||||||
return Buffer.from(b64, "base64").toString("utf8");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function encodeConnectionCode({ host, port, token, name, fingerprint, scheme }) {
|
|
||||||
if (!host || typeof host !== "string") throw new Error("host fehlt");
|
|
||||||
const p = Number(port);
|
|
||||||
if (!Number.isInteger(p) || p < 1 || p > 65535) throw new Error("port ungueltig");
|
|
||||||
if (!token || typeof token !== "string") throw new Error("token fehlt");
|
|
||||||
const payload = { v: 1, h: host, p, t: token };
|
|
||||||
if (name) payload.n = String(name);
|
|
||||||
if (fingerprint) payload.fp = String(fingerprint);
|
|
||||||
if (scheme && scheme !== "http") payload.s = String(scheme);
|
|
||||||
return PREFIX + base64urlEncode(JSON.stringify(payload));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function decodeConnectionCode(code) {
|
|
||||||
const raw = String(code || "").trim();
|
|
||||||
if (!raw.startsWith(PREFIX)) {
|
|
||||||
throw new Error(`Verbindungscode muss mit "${PREFIX}" beginnen`);
|
|
||||||
}
|
|
||||||
let json;
|
|
||||||
try {
|
|
||||||
json = JSON.parse(base64urlDecode(raw.slice(PREFIX.length)));
|
|
||||||
} catch {
|
|
||||||
throw new Error("Verbindungscode ist beschaedigt (kein gueltiges base64url/JSON)");
|
|
||||||
}
|
|
||||||
if (!json || typeof json !== "object") throw new Error("Verbindungscode-Inhalt ungueltig");
|
|
||||||
const host = String(json.h || "").trim();
|
|
||||||
const port = Number(json.p);
|
|
||||||
const token = String(json.t || "");
|
|
||||||
if (!host) throw new Error("Verbindungscode ohne Host");
|
|
||||||
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("Verbindungscode mit ungueltigem Port");
|
|
||||||
if (!token) throw new Error("Verbindungscode ohne Token");
|
|
||||||
const scheme = json.s === "https" ? "https" : "http";
|
|
||||||
return {
|
|
||||||
host,
|
|
||||||
port,
|
|
||||||
token,
|
|
||||||
scheme,
|
|
||||||
name: json.n ? String(json.n) : "",
|
|
||||||
fingerprint: json.fp ? String(json.fp) : ""
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
import { encodeConnectionCode } from "./code.mjs";
|
|
||||||
|
|
||||||
function arg(name, fallback) {
|
|
||||||
const i = process.argv.indexOf("--" + name);
|
|
||||||
if (i >= 0 && i + 1 < process.argv.length) return process.argv[i + 1];
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
const host = arg("host");
|
|
||||||
const port = arg("port");
|
|
||||||
const token = arg("token");
|
|
||||||
const name = arg("name");
|
|
||||||
const scheme = arg("scheme");
|
|
||||||
const fingerprint = arg("fp");
|
|
||||||
|
|
||||||
if (!host || !port || !token) {
|
|
||||||
process.stderr.write("Usage: node src/gen-code.mjs --host <h> --port <p> --token <t> [--name <n>] [--scheme https] [--fp <sha256>]\n");
|
|
||||||
process.exit(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
process.stdout.write(encodeConnectionCode({ host, port, token, name, scheme, fingerprint }) + "\n");
|
|
||||||
@ -1,63 +0,0 @@
|
|||||||
import http from "node:http";
|
|
||||||
import https from "node:https";
|
|
||||||
|
|
||||||
function normalizeFp(fp) {
|
|
||||||
return String(fp || "").replace(/:/g, "").toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function debugGet(target, path, { timeoutMs = 20000 } = {}) {
|
|
||||||
const scheme = target.scheme === "https" ? "https" : "http";
|
|
||||||
const lib = scheme === "https" ? https : http;
|
|
||||||
const rel = path.startsWith("/") ? path : "/" + path;
|
|
||||||
const url = new URL(rel, `${scheme}://${target.host}:${target.port}`);
|
|
||||||
const pinning = scheme === "https" && !!target.fingerprint;
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const options = {
|
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${target.token}`,
|
|
||||||
Accept: "application/json"
|
|
||||||
},
|
|
||||||
timeout: timeoutMs
|
|
||||||
};
|
|
||||||
if (scheme === "https") {
|
|
||||||
options.rejectUnauthorized = !target.fingerprint;
|
|
||||||
}
|
|
||||||
|
|
||||||
const req = lib.request(url, options, (res) => {
|
|
||||||
let data = "";
|
|
||||||
res.setEncoding("utf8");
|
|
||||||
res.on("data", (chunk) => {
|
|
||||||
data += chunk;
|
|
||||||
});
|
|
||||||
res.on("end", () => {
|
|
||||||
resolve({ status: res.statusCode || 0, body: data, headers: res.headers });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
req.on("timeout", () => {
|
|
||||||
req.destroy(new Error(`Zeitueberschreitung nach ${timeoutMs}ms`));
|
|
||||||
});
|
|
||||||
req.on("error", (err) => {
|
|
||||||
reject(err);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (pinning) {
|
|
||||||
req.on("socket", (socket) => {
|
|
||||||
socket.on("secureConnect", () => {
|
|
||||||
const cert = typeof socket.getPeerCertificate === "function" ? socket.getPeerCertificate() : null;
|
|
||||||
const got = normalizeFp(cert && cert.fingerprint256);
|
|
||||||
const want = normalizeFp(target.fingerprint);
|
|
||||||
if (!got || got !== want) {
|
|
||||||
req.destroy(new Error(`TLS-Fingerprint stimmt nicht (erwartet ${want || "?"}, erhalten ${got || "?"})`));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
req.end();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,175 +0,0 @@
|
|||||||
import http from "node:http";
|
|
||||||
import { spawn } from "node:child_process";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
import { dirname, join } from "node:path";
|
|
||||||
import { encodeConnectionCode } from "../src/code.mjs";
|
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
||||||
const BRIDGE = join(__dirname, "..", "src", "bridge.mjs");
|
|
||||||
const TOKEN = "test-token-abc123";
|
|
||||||
|
|
||||||
const failures = [];
|
|
||||||
function check(name, cond, detail) {
|
|
||||||
if (cond) {
|
|
||||||
process.stdout.write(` PASS ${name}\n`);
|
|
||||||
} else {
|
|
||||||
failures.push(name);
|
|
||||||
process.stdout.write(` FAIL ${name}${detail ? " — " + detail : ""}\n`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function startFakeServer() {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const server = http.createServer((req, res) => {
|
|
||||||
const url = new URL(req.url, "http://localhost");
|
|
||||||
const auth = req.headers.authorization || "";
|
|
||||||
const tokenOk = auth === `Bearer ${TOKEN}` || url.searchParams.get("token") === TOKEN;
|
|
||||||
if (!tokenOk) {
|
|
||||||
res.writeHead(401, { "content-type": "application/json" });
|
|
||||||
res.end(JSON.stringify({ error: "Unauthorized" }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const p = url.pathname;
|
|
||||||
const q = Object.fromEntries(url.searchParams.entries());
|
|
||||||
const send = (obj) => {
|
|
||||||
res.writeHead(200, { "content-type": "application/json" });
|
|
||||||
res.end(JSON.stringify(obj));
|
|
||||||
};
|
|
||||||
if (p === "/health") return send({ status: "ok", appVersion: "1.7.222", uptime: 42 });
|
|
||||||
if (p === "/diagnostics") return send({ meta: { appVersion: "1.7.222" }, status: { active: 1 }, query: q });
|
|
||||||
if (p === "/errors") return send({ errors: [{ level: "ERROR", message: "boom" }], query: q });
|
|
||||||
if (p === "/logs/main") return send({ lines: ["line1", "line2"], count: 2, query: q });
|
|
||||||
if (p === "/status") return send({ active: 1, queued: 3 });
|
|
||||||
if (p === "/items") return send({ items: [], query: q });
|
|
||||||
if (p === "/accounts") return send({ accounts: [{ name: "acc1", premium: true }] });
|
|
||||||
if (p === "/meta") return send({ appVersion: "1.7.222", endpoints: ["/health", "/diagnostics"] });
|
|
||||||
res.writeHead(404, { "content-type": "application/json" });
|
|
||||||
res.end(JSON.stringify({ error: "not found", path: p }));
|
|
||||||
});
|
|
||||||
server.listen(0, "127.0.0.1", () => resolve(server));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function startBridge() {
|
|
||||||
const child = spawn(process.execPath, [BRIDGE], { stdio: ["pipe", "pipe", "pipe"] });
|
|
||||||
child.stderr.on("data", (d) => process.stderr.write(`[bridge] ${d}`));
|
|
||||||
const pending = new Map();
|
|
||||||
let buf = "";
|
|
||||||
child.stdout.on("data", (chunk) => {
|
|
||||||
buf += chunk.toString("utf8");
|
|
||||||
let idx;
|
|
||||||
while ((idx = buf.indexOf("\n")) >= 0) {
|
|
||||||
const line = buf.slice(0, idx).trim();
|
|
||||||
buf = buf.slice(idx + 1);
|
|
||||||
if (!line) continue;
|
|
||||||
let msg;
|
|
||||||
try {
|
|
||||||
msg = JSON.parse(line);
|
|
||||||
} catch {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (msg.id !== undefined && pending.has(msg.id)) {
|
|
||||||
pending.get(msg.id)(msg);
|
|
||||||
pending.delete(msg.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
let nextId = 1;
|
|
||||||
function rpc(method, params) {
|
|
||||||
const id = nextId++;
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
pending.set(id, resolve);
|
|
||||||
child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
|
|
||||||
setTimeout(() => {
|
|
||||||
if (pending.has(id)) {
|
|
||||||
pending.delete(id);
|
|
||||||
reject(new Error(`RPC timeout: ${method}`));
|
|
||||||
}
|
|
||||||
}, 15000);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function notify(method, params) {
|
|
||||||
child.stdin.write(JSON.stringify({ jsonrpc: "2.0", method, params }) + "\n");
|
|
||||||
}
|
|
||||||
return { child, rpc, notify };
|
|
||||||
}
|
|
||||||
|
|
||||||
function textOf(callResult) {
|
|
||||||
const c = callResult && callResult.result && callResult.result.content;
|
|
||||||
if (!Array.isArray(c)) return "";
|
|
||||||
return c.map((x) => x.text || "").join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function run() {
|
|
||||||
const fake = await startFakeServer();
|
|
||||||
const port = fake.address().port;
|
|
||||||
const code = encodeConnectionCode({ host: "127.0.0.1", port, token: TOKEN, name: "testserver" });
|
|
||||||
const badCode = encodeConnectionCode({ host: "127.0.0.1", port, token: "WRONG", name: "testserver" });
|
|
||||||
|
|
||||||
const bridge = startBridge();
|
|
||||||
try {
|
|
||||||
const init = await bridge.rpc("initialize", {
|
|
||||||
protocolVersion: "2024-11-05",
|
|
||||||
capabilities: {},
|
|
||||||
clientInfo: { name: "harness", version: "1.0.0" }
|
|
||||||
});
|
|
||||||
check("initialize handshake", !!(init.result && init.result.serverInfo), JSON.stringify(init.error || {}));
|
|
||||||
check("server name reported", init.result && init.result.serverInfo && init.result.serverInfo.name === "rd-diagnostics-mcp");
|
|
||||||
bridge.notify("notifications/initialized", {});
|
|
||||||
|
|
||||||
const tools = await bridge.rpc("tools/list", {});
|
|
||||||
const names = (tools.result && tools.result.tools || []).map((t) => t.name);
|
|
||||||
check("tools/list returns tools", names.length >= 10, `got ${names.length}`);
|
|
||||||
for (const expected of ["rd_ping", "rd_diagnostics", "rd_errors", "rd_logs", "rd_get", "rd_servers"]) {
|
|
||||||
check(`tool present: ${expected}`, names.includes(expected));
|
|
||||||
}
|
|
||||||
|
|
||||||
const ping = await bridge.rpc("tools/call", { name: "rd_ping", arguments: { code } });
|
|
||||||
const pingText = textOf(ping);
|
|
||||||
check("rd_ping reaches server", /HTTP 200/.test(pingText) && /"status": "ok"/.test(pingText), pingText.slice(0, 200));
|
|
||||||
check("rd_ping shows server label", /testserver \(127\.0\.0\.1:/.test(pingText));
|
|
||||||
|
|
||||||
const diag = await bridge.rpc("tools/call", { name: "rd_diagnostics", arguments: { code, lines: 50, grep: "err" } });
|
|
||||||
const diagText = textOf(diag);
|
|
||||||
check("rd_diagnostics returns aggregate", /"appVersion": "1\.7\.222"/.test(diagText));
|
|
||||||
check("rd_diagnostics passes query params", /"lines": "50"/.test(diagText) && /"grep": "err"/.test(diagText), diagText.slice(0, 300));
|
|
||||||
|
|
||||||
const logs = await bridge.rpc("tools/call", { name: "rd_logs", arguments: { code, name: "main", lines: 5 } });
|
|
||||||
const logsText = textOf(logs);
|
|
||||||
check("rd_logs maps name→path + lines", /logs\/main\?lines=5/.test(logsText) && /"count": 2/.test(logsText), logsText.slice(0, 200));
|
|
||||||
|
|
||||||
const errs = await bridge.rpc("tools/call", { name: "rd_errors", arguments: { code, level: "ERROR" } });
|
|
||||||
check("rd_errors returns ring", /"message": "boom"/.test(textOf(errs)));
|
|
||||||
|
|
||||||
const raw = await bridge.rpc("tools/call", { name: "rd_get", arguments: { code, path: "/meta" } });
|
|
||||||
check("rd_get escape hatch hits arbitrary path", /"endpoints"/.test(textOf(raw)));
|
|
||||||
|
|
||||||
const unauthorized = await bridge.rpc("tools/call", { name: "rd_ping", arguments: { code: badCode } });
|
|
||||||
check("bad token → HTTP 401 + isError", /HTTP 401/.test(textOf(unauthorized)) && unauthorized.result.isError === true);
|
|
||||||
|
|
||||||
const noCode = await bridge.rpc("tools/call", { name: "rd_ping", arguments: {} });
|
|
||||||
check("missing code → graceful isError", noCode.result && noCode.result.isError === true && /Kein Verbindungscode/.test(textOf(noCode)));
|
|
||||||
|
|
||||||
const unreachable = await bridge.rpc("tools/call", {
|
|
||||||
name: "rd_ping",
|
|
||||||
arguments: { code: encodeConnectionCode({ host: "127.0.0.1", port: 1, token: TOKEN }) }
|
|
||||||
});
|
|
||||||
check("unreachable → isError + hint", unreachable.result.isError === true && /nicht erreichbar|abgebrochen|FEHLER/.test(textOf(unreachable)), textOf(unreachable).slice(0, 160));
|
|
||||||
} finally {
|
|
||||||
bridge.child.kill();
|
|
||||||
fake.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
process.stdout.write("\n");
|
|
||||||
if (failures.length) {
|
|
||||||
process.stdout.write(`RESULT: ${failures.length} FAIL\n`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
process.stdout.write("RESULT: ALL PASS\n");
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
run().catch((err) => {
|
|
||||||
process.stderr.write(`harness error: ${err && err.stack ? err.stack : err}\n`);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
Loading…
Reference in New Issue
Block a user