Compare commits
No commits in common. "main" and "v1.7.224" have entirely different histories.
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "real-debrid-downloader",
|
||||
"version": "1.7.232",
|
||||
"version": "1.7.224",
|
||||
"description": "Desktop downloader",
|
||||
"main": "build/main/main/main.js",
|
||||
"author": "Sucukdeluxe",
|
||||
|
||||
@ -39,7 +39,7 @@ import { getItemLogPath, initItemLogs, shutdownItemLogs } from "./item-log";
|
||||
import { getPackageLogPath, initPackageLogs, shutdownPackageLogs } from "./package-log";
|
||||
import { initSessionLog, getSessionLogPath, shutdownSessionLog } from "./session-log";
|
||||
import { MegaWebFallback } from "./mega-web-fallback";
|
||||
import { addHistoryEntry, addHistoryEntryForRetention, cancelPendingAsyncSaves, clearHistory, createStoragePaths, loadHistory, loadHistoryForRetention, loadSessionWithStatus, loadSettings, normalizeHistoryEntry, normalizeLoadedSession, normalizeLoadedSessionTransientFields, normalizeSettings, removeHistoryEntry, resetHistoryForRetention, saveHistory, saveSession, saveSettings } from "./storage";
|
||||
import { addHistoryEntry, addHistoryEntryForRetention, cancelPendingAsyncSaves, clearHistory, createStoragePaths, loadHistory, loadHistoryForRetention, loadSession, loadSettings, normalizeHistoryEntry, normalizeLoadedSession, normalizeLoadedSessionTransientFields, normalizeSettings, removeHistoryEntry, resetHistoryForRetention, saveHistory, saveSession, saveSettings } from "./storage";
|
||||
import { abortActiveUpdateDownload, checkGitHubUpdate, installLatestUpdate } from "./update";
|
||||
import { runInstallWithResume } from "./update-install-flow";
|
||||
import { rotateDebugToken, startDebugServer, stopDebugServer, restartDebugServer, getDebugServerRuntimeStatus, getActiveDebugToken, getDebugAllowlist, writeDebugServerConfig, clearDebugToken } from "./debug-server";
|
||||
@ -112,8 +112,7 @@ export class AppController {
|
||||
initTraceLog(this.storagePaths.baseDir);
|
||||
this.settings = loadSettings(this.storagePaths);
|
||||
resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode);
|
||||
const loadResult = loadSessionWithStatus(this.storagePaths);
|
||||
const session = loadResult.session;
|
||||
const session = loadSession(this.storagePaths);
|
||||
this.megaWebFallback = new MegaWebFallback(() => ({
|
||||
login: this.settings.megaLogin,
|
||||
password: this.settings.megaPassword
|
||||
@ -127,7 +126,6 @@ export class AppController {
|
||||
realDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.realDebridWebFallback.unrestrict(link, signal),
|
||||
bestDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.bestDebridWebFallback.unrestrict(link, signal),
|
||||
invalidateMegaSession: () => this.megaWebFallback.invalidateSession(),
|
||||
protectEmptyClobber: loadResult.status === "empty-unreadable",
|
||||
onHistoryEntry: (entry: HistoryEntry) => {
|
||||
addHistoryEntryForRetention(this.storagePaths, this.settings.historyRetentionMode, entry, this.historyLimits());
|
||||
}
|
||||
@ -180,13 +178,21 @@ export class AppController {
|
||||
if (this.settings.autoResumeOnStart) {
|
||||
const snapshot = this.manager.getSnapshot();
|
||||
const hasPending = Object.values(snapshot.session.items).some((item) => item.status === "queued" || item.status === "reconnect_wait");
|
||||
if (hasPending && this.hasAnyProviderToken(this.settings)) {
|
||||
if (this.onStateHandler) {
|
||||
this.beginAutoResume();
|
||||
} else {
|
||||
this.autoResumePending = true;
|
||||
logger.info("Auto-Resume beim Start vorgemerkt");
|
||||
}
|
||||
if (hasPending) {
|
||||
void this.manager.getStartConflicts().then((conflicts) => {
|
||||
const hasConflicts = conflicts.length > 0;
|
||||
if (this.hasAnyProviderToken(this.settings) && !hasConflicts) {
|
||||
if (this.onStateHandler) {
|
||||
logger.info("Auto-Resume beim Start aktiviert (nach Konflikt-Check)");
|
||||
void this.manager.start().catch((err) => logger.warn(`Auto-Resume Start Fehler: ${String(err)}`));
|
||||
} else {
|
||||
this.autoResumePending = true;
|
||||
logger.info("Auto-Resume beim Start vorgemerkt");
|
||||
}
|
||||
} else if (hasConflicts) {
|
||||
logger.info("Auto-Resume übersprungen: Start-Konflikte erkannt");
|
||||
}
|
||||
}).catch((err) => logger.warn(`getStartConflicts Fehler (constructor): ${String(err)}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -243,27 +249,14 @@ export class AppController {
|
||||
handler(this.manager.getSnapshot());
|
||||
if (this.autoResumePending) {
|
||||
this.autoResumePending = false;
|
||||
this.beginAutoResume();
|
||||
void this.manager.start().catch((err) => logger.warn(`Auto-Resume Start Fehler: ${String(err)}`));
|
||||
logger.info("Auto-Resume beim Start aktiviert");
|
||||
} else {
|
||||
this.manager.triggerIdleExtractions();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private beginAutoResume(): void {
|
||||
void this.manager.getStartConflicts().then((conflicts) => {
|
||||
const excludePackageIds = new Set(conflicts.map((conflict) => conflict.packageId));
|
||||
if (excludePackageIds.size > 0) {
|
||||
const names = conflicts.map((conflict) => conflict.packageName).join(", ");
|
||||
logger.info(`Auto-Resume: ${excludePackageIds.size} Paket(e) mit Start-Konflikt zurückgehalten (${names}); übrige Pakete starten`);
|
||||
} else {
|
||||
logger.info("Auto-Resume beim Start aktiviert (keine Start-Konflikte)");
|
||||
}
|
||||
void this.manager.start(excludePackageIds.size > 0 ? { excludePackageIds } : undefined)
|
||||
.catch((err) => logger.warn(`Auto-Resume Start Fehler: ${String(err)}`));
|
||||
}).catch((err) => logger.warn(`Auto-Resume Konflikt-Check Fehler: ${String(err)}`));
|
||||
}
|
||||
|
||||
public getSnapshot(): UiSnapshot {
|
||||
return this.manager.getSnapshot();
|
||||
}
|
||||
|
||||
@ -288,7 +288,6 @@ type MegaDebridCooldownCategory = "invalid" | "rate_limit" | "quota" | "temporar
|
||||
type MegaDebridCooldownDetail = { until: number; message: string; category: MegaDebridCooldownCategory; untilRestart?: boolean };
|
||||
const megaDebridAccountCooldowns = new Map<string, MegaDebridCooldownDetail>();
|
||||
const MEGA_DEBRID_ACCOUNT_COOLDOWN_MS = 120_000;
|
||||
const MEGA_DEBRID_SLOW_LINK_RETRY_MS = 120_000;
|
||||
const MEGA_DEBRID_INVALID_ACCOUNT_COOLDOWN_MS = 60 * 60 * 1000;
|
||||
|
||||
// A Mega-Web account abort (the shared unrestrict timeout firing while this
|
||||
@ -302,7 +301,7 @@ function getMegaDebridAbortMinRunMs(): number {
|
||||
}
|
||||
|
||||
const megaDebridEmptyResponseStreaks = new Map<string, number>();
|
||||
export const MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART = 10;
|
||||
export const MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART = 3;
|
||||
|
||||
let megaDebridRotationCursor = 0;
|
||||
let megaDebridStickyCount = 0;
|
||||
@ -339,25 +338,6 @@ export function resetMegaDebridRuntimeStateForTests(): void {
|
||||
megaDebridInFlight.clear();
|
||||
}
|
||||
|
||||
export function getMegaDebridInFlightCountForMode(mode: "api" | "web"): number {
|
||||
const suffix = `:${mode}`;
|
||||
let total = 0;
|
||||
for (const [key, count] of megaDebridInFlight) {
|
||||
if (key.endsWith(suffix)) {
|
||||
total += count;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
export function primeMegaDebridInFlightForTests(key: string, count: number): void {
|
||||
if (count <= 0) {
|
||||
megaDebridInFlight.delete(key);
|
||||
return;
|
||||
}
|
||||
megaDebridInFlight.set(key, count);
|
||||
}
|
||||
|
||||
export function pruneExpiredMegaDebridRuntimeState(now = Date.now()): number {
|
||||
let removed = 0;
|
||||
const grace = 60 * 60 * 1000;
|
||||
@ -442,106 +422,6 @@ export function getMegaDebridAccountCooldownState(
|
||||
};
|
||||
}
|
||||
|
||||
export interface ProviderRuntimeCooldown {
|
||||
untilMs: number;
|
||||
remainingMs: number;
|
||||
message: string;
|
||||
category: string;
|
||||
untilRestart?: boolean;
|
||||
}
|
||||
|
||||
export interface ProviderRuntimeSnapshot {
|
||||
capturedAtMs: number;
|
||||
megaDebrid: {
|
||||
rotationCursor: number;
|
||||
stickyCount: number;
|
||||
accounts: Array<{
|
||||
key: string;
|
||||
cooldown: ProviderRuntimeCooldown | null;
|
||||
inFlight: number;
|
||||
emptyResponseStreak: number;
|
||||
}>;
|
||||
};
|
||||
debridLink: {
|
||||
keys: Array<{
|
||||
keyId: string;
|
||||
cooldown: ProviderRuntimeCooldown | null;
|
||||
runtimeStatus: { state: string; detail: string; updatedAt: number } | null;
|
||||
}>;
|
||||
hostCooldowns: Array<{ key: string; cooldown: ProviderRuntimeCooldown }>;
|
||||
};
|
||||
}
|
||||
|
||||
export function getProviderRuntimeSnapshot(now = Date.now()): ProviderRuntimeSnapshot {
|
||||
const megaKeys = new Set<string>([
|
||||
...megaDebridAccountCooldowns.keys(),
|
||||
...megaDebridInFlight.keys(),
|
||||
...megaDebridEmptyResponseStreaks.keys()
|
||||
]);
|
||||
const megaAccounts = [...megaKeys].sort().map((key) => {
|
||||
const detail = megaDebridAccountCooldowns.get(key);
|
||||
return {
|
||||
key,
|
||||
cooldown: detail
|
||||
? {
|
||||
untilMs: detail.until,
|
||||
remainingMs: Math.max(0, detail.until - now),
|
||||
message: detail.message,
|
||||
category: detail.category,
|
||||
untilRestart: detail.untilRestart === true
|
||||
}
|
||||
: null,
|
||||
inFlight: megaDebridInFlight.get(key) ?? 0,
|
||||
emptyResponseStreak: megaDebridEmptyResponseStreaks.get(key) ?? 0
|
||||
};
|
||||
});
|
||||
|
||||
const dlKeyIds = new Set<string>([
|
||||
...debridLinkKeyCooldowns.keys(),
|
||||
...debridLinkKeyRuntimeStatuses.keys()
|
||||
]);
|
||||
const dlKeys = [...dlKeyIds].sort().map((keyId) => {
|
||||
const until = Number(debridLinkKeyCooldowns.get(keyId) || 0);
|
||||
const detail = debridLinkKeyCooldownDetails.get(keyId);
|
||||
const status = debridLinkKeyRuntimeStatuses.get(keyId) || null;
|
||||
return {
|
||||
keyId,
|
||||
cooldown: until > 0
|
||||
? {
|
||||
untilMs: until,
|
||||
remainingMs: Math.max(0, until - now),
|
||||
message: detail?.message ?? "",
|
||||
category: detail?.category ?? "temporary"
|
||||
}
|
||||
: null,
|
||||
runtimeStatus: status ? { state: status.state, detail: status.detail, updatedAt: status.updatedAt } : null
|
||||
};
|
||||
});
|
||||
|
||||
const dlHostCooldowns = [...debridLinkKeyHostCooldowns].map(([key, until]) => {
|
||||
const detail = debridLinkKeyHostCooldownDetails.get(key);
|
||||
return {
|
||||
key,
|
||||
cooldown: {
|
||||
untilMs: until,
|
||||
remainingMs: Math.max(0, until - now),
|
||||
message: detail?.message ?? "",
|
||||
category: detail?.category ?? "temporary"
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
capturedAtMs: now,
|
||||
megaDebrid: {
|
||||
rotationCursor: megaDebridRotationCursor,
|
||||
stickyCount: megaDebridStickyCount,
|
||||
accounts: megaAccounts
|
||||
},
|
||||
debridLink: { keys: dlKeys, hostCooldowns: dlHostCooldowns }
|
||||
};
|
||||
}
|
||||
|
||||
const LINKSNAPPY_API_BASE = "https://linksnappy.com/api";
|
||||
|
||||
const PROVIDER_LABELS: Record<DebridProvider, string> = {
|
||||
@ -2188,33 +2068,15 @@ class MegaDebridClient {
|
||||
} catch (error) {
|
||||
const elapsedMs = Date.now() - testStartedAt;
|
||||
const abortText = compactErrorText(error).replace(/^Error:\s*/i, "");
|
||||
// Timeout/abort on THIS account (the shared unrestrict timeout fired). The
|
||||
// account-wide cooldown exists ONLY to make the retry rotate to another
|
||||
// account — so it is set only when another usable account actually exists.
|
||||
// With no rotation target (single account / all others busy), cooling the
|
||||
// sole account would freeze EVERY queued item while the account is healthy;
|
||||
// a >60s timeout is a slow-LINK signal, not an unhealthy-account signal, so
|
||||
// we park just this link (mega_debrid_slow_link) and leave the account free
|
||||
// for other items. A quick user-cancel (below the min run) parks nothing.
|
||||
// Timeout/abort on THIS account (the shared unrestrict signal fired). Cool
|
||||
// the account down — if it actually ran, not a quick user-cancel — so the
|
||||
// download-manager's retry rotates to the NEXT account instead of hammering
|
||||
// this one. The shared signal is now aborted, so we stop this pass; the
|
||||
// retry runs the rotation fresh with this account skipped. A genuine cancel
|
||||
// is not retried by the caller, so the cooldown is harmless there.
|
||||
if (/aborted/i.test(abortText) && !/timeout/i.test(abortText)) {
|
||||
const ranLongEnough = elapsedMs >= getMegaDebridAbortMinRunMs();
|
||||
const otherUsableAccounts = orderedEntries.reduce((count, candidate) => {
|
||||
if (candidate.account.id === account.id) {
|
||||
return count;
|
||||
}
|
||||
if (isMegaDebridAccountDisabled(settings, candidate.account.id)) {
|
||||
return count;
|
||||
}
|
||||
if (isMegaDebridAccountDailyLimitReached(settings, candidate.account.id)) {
|
||||
return count;
|
||||
}
|
||||
if (getMegaDebridAccountCooldownState(`${candidate.account.id}:${mode}`)) {
|
||||
return count;
|
||||
}
|
||||
return count + 1;
|
||||
}, 0);
|
||||
const rotateToAnotherAccount = ranLongEnough && otherUsableAccounts > 0;
|
||||
if (rotateToAnotherAccount) {
|
||||
if (ranLongEnough) {
|
||||
setMegaDebridAccountCooldownState(cooldownKey, MEGA_DEBRID_ACCOUNT_COOLDOWN_MS, `Abbruch/Timeout nach ${Math.ceil(elapsedMs / 1000)}s`, "temporary");
|
||||
}
|
||||
traceConversionPhase({
|
||||
@ -2223,18 +2085,15 @@ class MegaDebridClient {
|
||||
account: rotationLabel,
|
||||
workMs: elapsedMs,
|
||||
outcome: "aborted",
|
||||
detail: `${abortText}${rotateToAnotherAccount ? ` cd=${Math.ceil(MEGA_DEBRID_ACCOUNT_COOLDOWN_MS / 1000)}s` : ranLongEnough ? ` slowlink=${Math.ceil(MEGA_DEBRID_SLOW_LINK_RETRY_MS / 1000)}s` : ""}`
|
||||
detail: `${abortText}${ranLongEnough ? ` cd=${Math.ceil(MEGA_DEBRID_ACCOUNT_COOLDOWN_MS / 1000)}s` : ""}`
|
||||
});
|
||||
failures.push(`Mega-Debrid${accountLabel}: ${abortText}`);
|
||||
logAccountRotation("WARN", providerName, rotationLabel, "TIMEOUT_COOLDOWN", {
|
||||
elapsedMs,
|
||||
reason: abortText,
|
||||
cooldownSec: rotateToAnotherAccount ? Math.ceil(MEGA_DEBRID_ACCOUNT_COOLDOWN_MS / 1000) : 0,
|
||||
next: rotateToAnotherAccount ? "naechster Account beim Retry" : "Einzel-Retry (Account bleibt fuer andere Items frei)"
|
||||
cooldownSec: ranLongEnough ? Math.ceil(MEGA_DEBRID_ACCOUNT_COOLDOWN_MS / 1000) : 0,
|
||||
next: "naechster Account beim Retry"
|
||||
});
|
||||
if (ranLongEnough && !rotateToAnotherAccount) {
|
||||
throw new Error(`mega_debrid_slow_link:${MEGA_DEBRID_SLOW_LINK_RETRY_MS}:Mega-Debrid${accountLabel}: ${abortText}`);
|
||||
}
|
||||
throw new Error(`Mega-Debrid${accountLabel}: ${abortText}`);
|
||||
}
|
||||
const failure = MegaDebridClient.classifyAccountFailure(error);
|
||||
@ -2254,7 +2113,7 @@ class MegaDebridClient {
|
||||
const streak = recordMegaDebridEmptyResponseStreak(cooldownKey);
|
||||
if (streak >= MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART) {
|
||||
parkUntilRestart = true;
|
||||
parkMessage = `Tageslimit erreicht (${streak}x leere Antwort in Folge) — bis zum Tagesreset gesperrt`;
|
||||
parkMessage = `Tageslimit erreicht (${streak}x kein Server/leere Antwort) — bis zum Tagesreset gesperrt`;
|
||||
}
|
||||
} else {
|
||||
clearMegaDebridEmptyResponseStreak(cooldownKey);
|
||||
|
||||
@ -15,8 +15,6 @@ import { createStoragePaths, loadHistory, loadSettings } from "./storage";
|
||||
import { buildAccountSummary, buildRedactedSettingsPayload, buildStatsPayload, summarizeHistoryEntry } from "./support-data";
|
||||
import { buildSupportBundle, getSupportBundleDefaultFileName } from "./support-bundle";
|
||||
import { getTraceConfig, getTraceConfigPath, getTraceLogPath, logTraceEvent, setTraceEnabled, updateTraceConfig } from "./trace-log";
|
||||
import { getConversionLogPath } from "./conversion-trace";
|
||||
import { getProviderRuntimeSnapshot } from "./debrid";
|
||||
import { getWindowsHostDiagnostics } from "./windows-host-diagnostics";
|
||||
import type { DownloadManager } from "./download-manager";
|
||||
import type { DownloadItem, PackageEntry, UiSnapshot } from "../shared/types";
|
||||
@ -45,14 +43,12 @@ const DEBUG_ENDPOINTS: DebugEndpointDescriptor[] = [
|
||||
{ method: "GET", path: "/logs/rename", queryExample: "lines=100&grep=keyword", description: "Reads the dedicated rename and MKV move log." },
|
||||
{ method: "GET", path: "/logs/trace", queryExample: "lines=100&grep=keyword", description: "Reads the optional support trace log." },
|
||||
{ method: "GET", path: "/logs/session", queryExample: "lines=100&grep=keyword", description: "Reads the session log tail." },
|
||||
{ method: "GET", path: "/logs/conversion", queryExample: "lines=100&grep=keyword", description: "Reads the per-item link conversion/unrestrict lifecycle log (token, API getLink, web, account rotation, aborts with timings)." },
|
||||
{ method: "GET", path: "/logs/package", queryExample: "package=Release&lines=100&grep=keyword", description: "Reads the package log for a specific package name or id." },
|
||||
{ method: "GET", path: "/logs/item", queryExample: "item=episode.part2.rar&lines=100&grep=keyword", description: "Reads the item log for a specific file name or item id." },
|
||||
{ method: "GET", path: "/errors", queryExample: "level=ERROR&limit=100", description: "Returns the in-memory ring of the most recent WARN/ERROR log lines." },
|
||||
{ method: "GET", path: "/trace/config", queryExample: "enable=1¬e=support&durationMinutes=120", description: "Reads or updates the support trace configuration." },
|
||||
{ method: "GET", path: "/settings", description: "Returns a redacted settings snapshot without raw secrets." },
|
||||
{ method: "GET", path: "/accounts", description: "Returns a redacted account/provider configuration summary." },
|
||||
{ method: "GET", path: "/providers", description: "Live provider runtime state: per-account/key cooldowns (until/remaining/reason/category), in-flight depth, Mega rotation cursor, empty-response streaks. The 'why is it cooling down right now' view." },
|
||||
{ method: "GET", path: "/stats", description: "Returns live session stats plus persisted all-time totals." },
|
||||
{ method: "GET", path: "/history", queryExample: "limit=50&status=completed", description: "Returns history entries with optional filters." },
|
||||
{ method: "GET", path: "/status", description: "Returns a live high-level status overview." },
|
||||
@ -360,7 +356,6 @@ function buildAiManifest(baseDir: string): Record<string, unknown> {
|
||||
"Call /meta first to confirm the server is reachable and to re-read the endpoint list.",
|
||||
"Use /self-check or /debug/setup to quickly verify whether token, host, manifest, trace, disk space, and log sizes are in a good support state.",
|
||||
"Use /diagnostics for an overview, then drill into /logs/item, /logs/package, /logs/rename, /status, /packages, /items, /settings, /accounts, /stats, /history, or /logs/trace.",
|
||||
"For provider stalls/cooldowns, call /providers for the live cooldown state (until/remaining/reason per account/key) and /logs/conversion for the per-item resolve lifecycle (token, API, web, rotation, aborts with timings).",
|
||||
"If a full handoff is needed, download /support/bundle as a ZIP."
|
||||
],
|
||||
auth: {
|
||||
@ -712,25 +707,6 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname === "/logs/conversion") {
|
||||
const count = normalizeLinesParam(url.searchParams.get("lines"), 100);
|
||||
const grep = url.searchParams.get("grep") || "";
|
||||
const logPath = getConversionLogPath();
|
||||
const lines = logPath ? filterLines(readLogTailFromFile(logPath, count), grep) : [];
|
||||
jsonResponse(res, 200, {
|
||||
path: logPath,
|
||||
available: Boolean(logPath),
|
||||
lines,
|
||||
count: lines.length
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname === "/providers") {
|
||||
jsonResponse(res, 200, getProviderRuntimeSnapshot());
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname === "/trace/config") {
|
||||
const patch: Record<string, unknown> = {};
|
||||
const enabled = toBooleanQuery(url.searchParams.get("enable"));
|
||||
@ -1016,7 +992,6 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
|
||||
settings: buildRedactedSettingsPayload(readSupportSettings()),
|
||||
stats: buildStatsPayload(snapshot),
|
||||
accounts: buildAccountSummary(readSupportSettings()),
|
||||
providers: getProviderRuntimeSnapshot(),
|
||||
history: {
|
||||
total: readSupportHistory().length,
|
||||
recent: readSupportHistory()
|
||||
@ -1048,10 +1023,6 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
|
||||
path: sessionLogPath,
|
||||
lines: filterLines(readLogTailFromFile(sessionLogPath, lineCount), grep)
|
||||
},
|
||||
conversion: {
|
||||
path: getConversionLogPath(),
|
||||
lines: getConversionLogPath() ? filterLines(readLogTailFromFile(getConversionLogPath() as string, lineCount), grep) : []
|
||||
},
|
||||
package: selectedPackage ? {
|
||||
path: packageLogPath,
|
||||
lines: filterLines(readLogTailFromFile(packageLogPath, lineCount), grep)
|
||||
|
||||
@ -52,7 +52,7 @@ function releaseTlsSkip(): void {
|
||||
}
|
||||
import { cleanupCancelledPackageArtifactsAsync, removeDownloadLinkArtifacts, removeSampleArtifacts } from "./cleanup";
|
||||
import { planDownloadCompletion, reconcileFinalizedSize, validateDownloadedFileCompletion } from "./download-completion";
|
||||
import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, DebridService, MegaWebUnrestrictor, RealDebridWebUnrestrictor, checkRapidgatorOnline, fetchAllDebridHostInfo, getAvailableDebridLinkApiKeys, getAvailableMegaDebridAccounts, getMegaDebridAccountCooldownState, getMegaDebridInFlightCountForMode, pruneExpiredDebridLinkRuntimeState, pruneExpiredMegaDebridRuntimeState } from "./debrid";
|
||||
import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, DebridService, MegaWebUnrestrictor, RealDebridWebUnrestrictor, checkRapidgatorOnline, fetchAllDebridHostInfo, getAvailableDebridLinkApiKeys, getAvailableMegaDebridAccounts, getMegaDebridAccountCooldownState, pruneExpiredDebridLinkRuntimeState, pruneExpiredMegaDebridRuntimeState } from "./debrid";
|
||||
import { cleanupArchives, clearExtractResumeState, collectArchiveCleanupTargets, detectArchiveSignature, extractPackageArchives, findArchiveCandidates, hasAnyFilesRecursive, removeEmptyDirectoryTree, resetExtractorCachesForPasswordChange, type ExtractArchiveFailureInfo } from "./extractor";
|
||||
import { validateFileAgainstManifest } from "./integrity";
|
||||
import { classifyDiskError } from "./fs-error";
|
||||
@ -131,17 +131,6 @@ const ARCHIVE_SETTLE_MAX_WAIT_MS = 5000;
|
||||
|
||||
const MAX_SAME_DIRECT_URL_ATTEMPTS = 3;
|
||||
|
||||
const MAX_HTTP416_FRESH_RESTARTS = 2;
|
||||
const HTTP416_FRESH_RESTART_DELAY_MS = 8000;
|
||||
|
||||
function getHttp416FreshRestartDelayMs(): number {
|
||||
const fromEnv = Number(process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS ?? NaN);
|
||||
if (Number.isFinite(fromEnv) && fromEnv >= 0 && fromEnv <= 600000) {
|
||||
return Math.floor(fromEnv);
|
||||
}
|
||||
return HTTP416_FRESH_RESTART_DELAY_MS;
|
||||
}
|
||||
|
||||
const RESUME_REWIND_BYTES = 256 * 1024;
|
||||
|
||||
const REALDEBRID_TOTAL_MISMATCH_TOLERANCE_BYTES = 64 * 1024;
|
||||
@ -374,7 +363,6 @@ type DownloadManagerOptions = {
|
||||
bestDebridWebUnrestrict?: BestDebridWebUnrestrictor;
|
||||
invalidateMegaSession?: () => void;
|
||||
onHistoryEntry?: HistoryEntryCallback;
|
||||
protectEmptyClobber?: boolean;
|
||||
};
|
||||
|
||||
function generateHistoryId(): string {
|
||||
@ -671,20 +659,6 @@ export function parseMegaDebridCooldownRetry(errorText: string): { delayMs: numb
|
||||
return { delayMs, detail: text.replace(/mega_debrid_cooldown:\d+:/i, "").trim() };
|
||||
}
|
||||
|
||||
export function parseMegaDebridSlowLinkRetry(errorText: string): { delayMs: number; detail: string } | null {
|
||||
const text = String(errorText || "");
|
||||
const match = text.match(/mega_debrid_slow_link:(\d+)/i);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const raw = Number(match[1]);
|
||||
if (!Number.isFinite(raw) || raw <= 0) {
|
||||
return null;
|
||||
}
|
||||
const delayMs = Math.max(1000, Math.min(15 * 60 * 1000, raw));
|
||||
return { delayMs, detail: text.replace(/mega_debrid_slow_link:\d+:/i, "").trim() };
|
||||
}
|
||||
|
||||
export function parseMegaDebridResetPark(errorText: string): { delayMs: number; detail: string } | null {
|
||||
const match = String(errorText || "").match(/mega_debrid_reset_park:(\d+):(.*)$/is);
|
||||
if (!match) {
|
||||
@ -1714,10 +1688,6 @@ export class DownloadManager extends EventEmitter {
|
||||
|
||||
public blockAllPersistence = false;
|
||||
|
||||
private protectAgainstEmptyClobber = false;
|
||||
|
||||
private emptyClobberProtectionLogged = false;
|
||||
|
||||
private debridService: DebridService;
|
||||
|
||||
private invalidateMegaSessionFn?: () => void;
|
||||
@ -1840,8 +1810,6 @@ export class DownloadManager extends EventEmitter {
|
||||
unrestrictRetries: number;
|
||||
}>();
|
||||
|
||||
private http416FreshRestartByItem = new Map<string, number>();
|
||||
|
||||
private providerFailures = new Map<string, { count: number; lastFailAt: number; cooldownUntil: number }>();
|
||||
|
||||
private allDebridHostInfoCache = new Map<string, { info: AllDebridHostInfo; cachedAt: number }>();
|
||||
@ -1863,10 +1831,6 @@ export class DownloadManager extends EventEmitter {
|
||||
this.session = session;
|
||||
this.itemCount = Object.keys(this.session.items).length;
|
||||
this.storagePaths = storagePaths;
|
||||
this.protectAgainstEmptyClobber = Boolean(options.protectEmptyClobber);
|
||||
if (this.protectAgainstEmptyClobber) {
|
||||
logger.warn("Session-Schutz aktiv: Start mit unlesbarer Session — leere Speicherungen blockiert, bis echte Daten vorliegen");
|
||||
}
|
||||
this.debridService = new DebridService(settings, {
|
||||
megaWebUnrestrict: options.megaWebUnrestrict,
|
||||
allDebridWebUnrestrict: options.allDebridWebUnrestrict,
|
||||
@ -3024,14 +2988,6 @@ export class DownloadManager extends EventEmitter {
|
||||
continue;
|
||||
}
|
||||
|
||||
const hasOwnCompletedOutput = pkg.itemIds.some((itemId) => {
|
||||
const item = this.session.items[itemId];
|
||||
return Boolean(item && item.status === "completed");
|
||||
});
|
||||
if (hasOwnCompletedOutput) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!this.isPackageSpecificExtractDir(pkg)) {
|
||||
continue;
|
||||
}
|
||||
@ -5583,7 +5539,7 @@ export class DownloadManager extends EventEmitter {
|
||||
});
|
||||
}
|
||||
|
||||
public async start(options?: { excludePackageIds?: ReadonlySet<string> }): Promise<void> {
|
||||
public async start(): Promise<void> {
|
||||
if (this.session.running) {
|
||||
return;
|
||||
}
|
||||
@ -5624,9 +5580,6 @@ export class DownloadManager extends EventEmitter {
|
||||
if (item.status !== "queued" && item.status !== "reconnect_wait") {
|
||||
return false;
|
||||
}
|
||||
if (options?.excludePackageIds?.has(item.packageId)) {
|
||||
return false;
|
||||
}
|
||||
const pkg = this.session.packages[item.packageId];
|
||||
return Boolean(pkg && !pkg.cancelled && pkg.enabled);
|
||||
});
|
||||
@ -5691,15 +5644,9 @@ export class DownloadManager extends EventEmitter {
|
||||
this.providerStartReservations.clear();
|
||||
this.pacedStartReservationByItem.clear();
|
||||
this.retryStateByItem.clear();
|
||||
this.http416FreshRestartByItem.clear();
|
||||
this.itemContributedBytes.clear();
|
||||
this.reservedTargetPaths.clear();
|
||||
this.claimedTargetPathByItem.clear();
|
||||
if (options?.excludePackageIds) {
|
||||
for (const excluded of options.excludePackageIds) {
|
||||
this.runPackageIds.delete(excluded);
|
||||
}
|
||||
}
|
||||
|
||||
this.session.running = true;
|
||||
this.session.paused = false;
|
||||
@ -5874,9 +5821,7 @@ export class DownloadManager extends EventEmitter {
|
||||
const itemCount = Object.keys(this.session.items).length;
|
||||
logger.info(`Shutdown-Save: ${pkgCount} Pakete, ${itemCount} Items`);
|
||||
this.foldRuntimeIntoSettings(nowMs());
|
||||
if (!this.guardBlocksSessionSave()) {
|
||||
saveSession(this.storagePaths, this.session);
|
||||
}
|
||||
saveSession(this.storagePaths, this.session);
|
||||
saveSettings(this.storagePaths, this.settings);
|
||||
} else {
|
||||
logger.info(`Shutdown-Save übersprungen: skipShutdownPersist=${this.skipShutdownPersist}, blockAllPersistence=${this.blockAllPersistence}`);
|
||||
@ -6179,29 +6124,10 @@ export class DownloadManager extends EventEmitter {
|
||||
}, delay);
|
||||
}
|
||||
|
||||
private guardBlocksSessionSave(): boolean {
|
||||
if (!this.protectAgainstEmptyClobber) {
|
||||
return false;
|
||||
}
|
||||
const isEmpty = Object.keys(this.session.packages).length === 0 && Object.keys(this.session.items).length === 0;
|
||||
if (isEmpty) {
|
||||
if (!this.emptyClobberProtectionLogged) {
|
||||
logger.warn("Leere Session-Speicherung uebersprungen (Schutz nach unlesbarem Start) — vorhandene Datei bleibt unangetastet");
|
||||
this.emptyClobberProtectionLogged = true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
this.protectAgainstEmptyClobber = false;
|
||||
logger.info("Session-Schutz aufgehoben: nicht-leere Session wird wieder normal gespeichert");
|
||||
return false;
|
||||
}
|
||||
|
||||
private persistNow(): void {
|
||||
const now = nowMs();
|
||||
this.lastPersistAt = now;
|
||||
if (!this.guardBlocksSessionSave()) {
|
||||
void saveSessionAsync(this.storagePaths, this.session).catch((err) => logger.warn(`saveSessionAsync Fehler: ${compactErrorText(err)}`));
|
||||
}
|
||||
void saveSessionAsync(this.storagePaths, this.session).catch((err) => logger.warn(`saveSessionAsync Fehler: ${compactErrorText(err)}`));
|
||||
if (now - this.lastSettingsPersistAt >= 30000) {
|
||||
this.foldRuntimeIntoSettings(now);
|
||||
this.lastSettingsPersistAt = now;
|
||||
@ -6215,9 +6141,7 @@ export class DownloadManager extends EventEmitter {
|
||||
const itemCount = Object.keys(this.session.items).length;
|
||||
logger.info(`Pre-Update Sync-Save: ${pkgCount} Pakete, ${itemCount} Items`);
|
||||
this.foldRuntimeIntoSettings(nowMs());
|
||||
if (!this.guardBlocksSessionSave()) {
|
||||
saveSession(this.storagePaths, this.session);
|
||||
}
|
||||
saveSession(this.storagePaths, this.session);
|
||||
saveSettings(this.storagePaths, this.settings);
|
||||
}
|
||||
|
||||
@ -8226,13 +8150,7 @@ export class DownloadManager extends EventEmitter {
|
||||
const provider = resolveMegaDebridProvider(this.settings, this.getExpectedProviderForItem(item));
|
||||
const serializedValidatingLimit = this.getSerializedValidatingLimit(provider);
|
||||
if (provider && Number.isFinite(serializedValidatingLimit) && serializedValidatingLimit < Number.MAX_SAFE_INTEGER) {
|
||||
const validating = this.getProviderValidatingTaskCount(provider, item.id);
|
||||
if (provider === "megadebrid-api") {
|
||||
const webInFlight = getMegaDebridInFlightCountForMode("web");
|
||||
const overlapAllowance = Math.min(serializedValidatingLimit, webInFlight);
|
||||
return validating >= serializedValidatingLimit + overlapAllowance;
|
||||
}
|
||||
return validating >= serializedValidatingLimit;
|
||||
return this.getProviderValidatingTaskCount(provider, item.id) >= serializedValidatingLimit;
|
||||
}
|
||||
if (provider !== "alldebrid") {
|
||||
return false;
|
||||
@ -8741,53 +8659,6 @@ export class DownloadManager extends EventEmitter {
|
||||
this.queueRetry(item, active, delayMs, `HTTP 416 erkannt, Retry ${active.genericErrorRetries}/${retryDisplayLimit}`);
|
||||
}
|
||||
|
||||
private escalateHttp416OrFail(item: DownloadItem, active: ActiveTask, claimedTargetPath: string, errorText: string): void {
|
||||
const freshRestarts = this.http416FreshRestartByItem.get(item.id) || 0;
|
||||
if (freshRestarts < MAX_HTTP416_FRESH_RESTARTS) {
|
||||
this.http416FreshRestartByItem.set(item.id, freshRestarts + 1);
|
||||
const resetTargetPath = claimedTargetPath || String(item.targetPath || "").trim();
|
||||
if (resetTargetPath) {
|
||||
try {
|
||||
fs.rmSync(resetTargetPath, { force: true });
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
this.releaseTargetPath(item.id);
|
||||
this.dropItemContribution(item.id);
|
||||
item.retries += 1;
|
||||
item.downloadedBytes = 0;
|
||||
item.totalBytes = null;
|
||||
item.progressPercent = 0;
|
||||
item.speedBps = 0;
|
||||
item.lastError = "";
|
||||
active.genericErrorRetries = 0;
|
||||
active.freshRetryUsed = false;
|
||||
active.resumeHardResetUsed = false;
|
||||
logger.warn(
|
||||
`HTTP 416 Budget erschöpft: item=${item.fileName || item.id}, ` +
|
||||
`kompletter Neu-Download ${freshRestarts + 1}/${MAX_HTTP416_FRESH_RESTARTS} (Partial verworfen, kein Resume), provider=${item.provider || "?"}`
|
||||
);
|
||||
this.queueRetry(item, active, getHttp416FreshRestartDelayMs(), `Range-Konflikt (HTTP 416): Neu-Download ${freshRestarts + 1}/${MAX_HTTP416_FRESH_RESTARTS}`);
|
||||
this.persistSoon();
|
||||
this.emitState();
|
||||
return;
|
||||
}
|
||||
this.http416FreshRestartByItem.delete(item.id);
|
||||
item.status = "failed";
|
||||
this.recordRunOutcome(item.id, "failed");
|
||||
item.lastError = errorText;
|
||||
item.fullStatus = `Fehler: ${item.lastError}`;
|
||||
item.speedBps = 0;
|
||||
item.updatedAt = nowMs();
|
||||
const failPkg = this.session.packages[item.packageId];
|
||||
if (failPkg) {
|
||||
this.refreshPackageStatus(failPkg);
|
||||
}
|
||||
this.persistSoon();
|
||||
this.emitState();
|
||||
this.retryStateByItem.delete(item.id);
|
||||
}
|
||||
|
||||
private startItem(packageId: string, itemId: string): void {
|
||||
const item = this.session.items[itemId];
|
||||
const pkg = this.session.packages[packageId];
|
||||
@ -9378,14 +9249,10 @@ export class DownloadManager extends EventEmitter {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (isHttp416Text(exhaustedReason)) {
|
||||
if (active.genericErrorRetries < maxHttp416Retries) {
|
||||
this.scheduleHttp416Retry(item, active, retryDisplayLimit, exhaustedReason, claimedTargetPath);
|
||||
this.persistSoon();
|
||||
this.emitState();
|
||||
return;
|
||||
}
|
||||
this.escalateHttp416OrFail(item, active, claimedTargetPath, exhaustedReason);
|
||||
if (isHttp416Text(exhaustedReason) && active.genericErrorRetries < maxHttp416Retries) {
|
||||
this.scheduleHttp416Retry(item, active, retryDisplayLimit, exhaustedReason, claimedTargetPath);
|
||||
this.persistSoon();
|
||||
this.emitState();
|
||||
return;
|
||||
}
|
||||
if (isResumeHardResetReason(exhaustedReason) && !active.resumeHardResetUsed) {
|
||||
@ -9442,7 +9309,17 @@ export class DownloadManager extends EventEmitter {
|
||||
this.emitState();
|
||||
return;
|
||||
}
|
||||
this.escalateHttp416OrFail(item, active, claimedTargetPath, errorText);
|
||||
item.status = "failed";
|
||||
this.recordRunOutcome(item.id, "failed");
|
||||
item.lastError = errorText;
|
||||
item.fullStatus = `Fehler: ${item.lastError}`;
|
||||
item.speedBps = 0;
|
||||
item.updatedAt = nowMs();
|
||||
const failPkg416 = this.session.packages[item.packageId];
|
||||
if (failPkg416) this.refreshPackageStatus(failPkg416);
|
||||
this.persistSoon();
|
||||
this.emitState();
|
||||
this.retryStateByItem.delete(item.id);
|
||||
return;
|
||||
}
|
||||
if (shouldFreshRetry) {
|
||||
@ -9558,24 +9435,6 @@ export class DownloadManager extends EventEmitter {
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@ -571,7 +571,7 @@ function registerIpcHandlers(): void {
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.EXPORT_BACKUP, async () => {
|
||||
const options = {
|
||||
defaultPath: `${new Date().toISOString().slice(0, 10).split("-").reverse().join("-")}-mdd-backup.mdd`,
|
||||
defaultPath: `mdd-backup-${new Date().toISOString().slice(0, 10)}.mdd`,
|
||||
filters: [{ name: "MDD Backup", extensions: ["mdd"] }]
|
||||
};
|
||||
const result = mainWindow ? await dialog.showSaveDialog(mainWindow, options) : await dialog.showSaveDialog(options);
|
||||
|
||||
@ -890,50 +890,21 @@ export function normalizeLoadedSessionTransientFields(session: SessionState): Se
|
||||
return session;
|
||||
}
|
||||
|
||||
const TRANSIENT_READ_CODES = new Set(["EBUSY", "EPERM", "EAGAIN"]);
|
||||
|
||||
function sleepSyncMs(ms: number): void {
|
||||
if (ms <= 0) {
|
||||
return;
|
||||
}
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
||||
}
|
||||
|
||||
function readSessionFile(filePath: string): SessionState | null {
|
||||
let raw: string | null = null;
|
||||
const maxAttempts = 5;
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||
try {
|
||||
raw = fs.readFileSync(filePath, "utf8");
|
||||
break;
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException)?.code || "";
|
||||
if (TRANSIENT_READ_CODES.has(code) && attempt < maxAttempts) {
|
||||
const backoffMs = 100 * 2 ** (attempt - 1);
|
||||
logger.warn(`Session-Datei vorübergehend gesperrt (${code}), Versuch ${attempt}/${maxAttempts}, warte ${backoffMs}ms: ${filePath}`);
|
||||
sleepSyncMs(backoffMs);
|
||||
continue;
|
||||
}
|
||||
if (code === "EACCES" || code === "EPERM") {
|
||||
logger.error(`Session-Datei nicht zugreifbar (${code}): ${filePath} - pruefe Datei-/Ordner-Berechtigungen fuer Benutzer ${process.env.USERNAME || process.env.USER || "?"}`);
|
||||
} else {
|
||||
logger.error(`Session-Datei nicht lesbar (${code || "?"}): ${filePath}: ${String(error)}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (raw === null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as unknown;
|
||||
const session = normalizeLoadedSessionTransientFields(normalizeLoadedSession(parsed));
|
||||
const pkgCount = Object.keys(session.packages).length;
|
||||
const itemCount = Object.keys(session.items).length;
|
||||
logger.info(`Session geladen: ${filePath} (${pkgCount} Pakete, ${itemCount} Items)`);
|
||||
return session;
|
||||
} catch (error) {
|
||||
logger.error(`Session-Datei beschädigt (JSON ungültig): ${filePath}: ${String(error)}`);
|
||||
const code = (error as NodeJS.ErrnoException)?.code || "";
|
||||
if (code === "EACCES" || code === "EPERM") {
|
||||
logger.error(`Session-Datei nicht zugreifbar (${code}): ${filePath} - pruefe Datei-/Ordner-Berechtigungen fuer Benutzer ${process.env.USERNAME || process.env.USER || "?"}`);
|
||||
} else {
|
||||
logger.error(`Session-Datei nicht lesbar: ${filePath}: ${String(error)}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -1033,31 +1004,17 @@ export function emptySession(): SessionState {
|
||||
};
|
||||
}
|
||||
|
||||
export type SessionLoadStatus =
|
||||
| "ok"
|
||||
| "recovered-backup"
|
||||
| "recovered-temp"
|
||||
| "empty-fresh"
|
||||
| "empty-unreadable";
|
||||
|
||||
export interface SessionLoadResult {
|
||||
session: SessionState;
|
||||
status: SessionLoadStatus;
|
||||
}
|
||||
|
||||
export function loadSessionWithStatus(paths: StoragePaths): SessionLoadResult {
|
||||
export function loadSession(paths: StoragePaths): SessionState {
|
||||
ensureBaseDir(paths.baseDir);
|
||||
const backupFile = sessionBackupPath(paths.sessionFile);
|
||||
const syncTempFile = sessionTempPath(paths.sessionFile, "sync");
|
||||
const asyncTempFile = sessionTempPath(paths.sessionFile, "async");
|
||||
const primaryExists = fs.existsSync(paths.sessionFile);
|
||||
const backupExists = fs.existsSync(backupFile);
|
||||
const anyTempExists = fs.existsSync(syncTempFile) || fs.existsSync(asyncTempFile);
|
||||
|
||||
if (!primaryExists) {
|
||||
if (!backupExists && !anyTempExists) {
|
||||
const hasRecoverable = fs.existsSync(backupFile)
|
||||
|| fs.existsSync(sessionTempPath(paths.sessionFile, "sync"))
|
||||
|| fs.existsSync(sessionTempPath(paths.sessionFile, "async"));
|
||||
if (!hasRecoverable) {
|
||||
logger.info("Keine Session-Datei vorhanden, starte mit leerer Session");
|
||||
return { session: emptySession(), status: "empty-fresh" };
|
||||
return emptySession();
|
||||
}
|
||||
logger.warn("Session-Primaerdatei fehlt, aber Backup/Temp vorhanden — Wiederherstellung wird versucht");
|
||||
}
|
||||
@ -1066,7 +1023,7 @@ export function loadSessionWithStatus(paths: StoragePaths): SessionLoadResult {
|
||||
|
||||
if (primary) {
|
||||
const primaryPkgCount = Object.keys(primary.packages).length;
|
||||
if (primaryPkgCount === 0 && backupExists) {
|
||||
if (primaryPkgCount === 0 && fs.existsSync(backupFile)) {
|
||||
const backup = readSessionFile(backupFile);
|
||||
if (backup) {
|
||||
const backupPkgCount = Object.keys(backup.packages).length;
|
||||
@ -1074,27 +1031,29 @@ export function loadSessionWithStatus(paths: StoragePaths): SessionLoadResult {
|
||||
logger.warn(`Session-Datei ist leer (0 Pakete), aber Backup hat ${backupPkgCount} Pakete — verwende Backup`);
|
||||
try {
|
||||
const payload = JSON.stringify({ ...backup, updatedAt: Date.now() }, safeJsonReplacer);
|
||||
fs.writeFileSync(syncTempFile, payload, "utf8");
|
||||
syncRenameWithExdevFallback(syncTempFile, paths.sessionFile);
|
||||
const tempPath = sessionTempPath(paths.sessionFile, "sync");
|
||||
fs.writeFileSync(tempPath, payload, "utf8");
|
||||
syncRenameWithExdevFallback(tempPath, paths.sessionFile);
|
||||
} catch {
|
||||
}
|
||||
return { session: backup, status: "recovered-backup" };
|
||||
return backup;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { session: primary, status: "ok" };
|
||||
return primary;
|
||||
}
|
||||
|
||||
const backup = backupExists ? readSessionFile(backupFile) : null;
|
||||
const backup = fs.existsSync(backupFile) ? readSessionFile(backupFile) : null;
|
||||
if (backup) {
|
||||
logger.warn("Session defekt, Backup-Datei wird verwendet");
|
||||
try {
|
||||
const payload = JSON.stringify({ ...backup, updatedAt: Date.now() }, safeJsonReplacer);
|
||||
fs.writeFileSync(syncTempFile, payload, "utf8");
|
||||
syncRenameWithExdevFallback(syncTempFile, paths.sessionFile);
|
||||
const tempPath = sessionTempPath(paths.sessionFile, "sync");
|
||||
fs.writeFileSync(tempPath, payload, "utf8");
|
||||
syncRenameWithExdevFallback(tempPath, paths.sessionFile);
|
||||
} catch {
|
||||
}
|
||||
return { session: backup, status: "recovered-backup" };
|
||||
return backup;
|
||||
}
|
||||
|
||||
for (const kind of ["sync", "async"] as const) {
|
||||
@ -1108,21 +1067,13 @@ export function loadSessionWithStatus(paths: StoragePaths): SessionLoadResult {
|
||||
fs.writeFileSync(paths.sessionFile, payload, "utf8");
|
||||
} catch {
|
||||
}
|
||||
return { session: tmpSession, status: "recovered-temp" };
|
||||
return tmpSession;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (primaryExists || backupExists || anyTempExists) {
|
||||
logger.error("Session konnte nicht geladen werden (Primary, Backup und Temp-Dateien fehlgeschlagen) — Schutz gegen leeres Ueberschreiben aktiv");
|
||||
return { session: emptySession(), status: "empty-unreadable" };
|
||||
}
|
||||
|
||||
return { session: emptySession(), status: "empty-fresh" };
|
||||
}
|
||||
|
||||
export function loadSession(paths: StoragePaths): SessionState {
|
||||
return loadSessionWithStatus(paths).session;
|
||||
logger.error("Session konnte nicht geladen werden (Primary, Backup und Temp-Dateien fehlgeschlagen)");
|
||||
return emptySession();
|
||||
}
|
||||
|
||||
export function saveSession(paths: StoragePaths, session: SessionState): void {
|
||||
@ -1137,13 +1088,7 @@ export function saveSession(paths: StoragePaths, session: SessionState): void {
|
||||
const payload = JSON.stringify({ ...session, updatedAt: Date.now() }, safeJsonReplacer);
|
||||
const tempPath = sessionTempPath(paths.sessionFile, "sync");
|
||||
try {
|
||||
const fd = fs.openSync(tempPath, "w");
|
||||
try {
|
||||
fs.writeSync(fd, payload);
|
||||
fs.fsyncSync(fd);
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
fs.writeFileSync(tempPath, payload, "utf8");
|
||||
syncRenameWithExdevFallback(tempPath, paths.sessionFile);
|
||||
} catch (error) {
|
||||
try { fs.rmSync(tempPath, { force: true }); } catch { }
|
||||
@ -1159,13 +1104,7 @@ async function writeSessionPayload(paths: StoragePaths, payload: string, generat
|
||||
await fs.promises.mkdir(paths.baseDir, { recursive: true });
|
||||
await fsp.copyFile(paths.sessionFile, sessionBackupPath(paths.sessionFile)).catch(() => {});
|
||||
const tempPath = sessionTempPath(paths.sessionFile, "async");
|
||||
const handle = await fsp.open(tempPath, "w");
|
||||
try {
|
||||
await handle.writeFile(payload, "utf8");
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
await fsp.writeFile(tempPath, payload, "utf8");
|
||||
if (generation < syncSaveGeneration) {
|
||||
await fsp.rm(tempPath, { force: true }).catch(() => {});
|
||||
return;
|
||||
|
||||
@ -197,36 +197,3 @@ describe("backup mcpRemote live restore round-trip", () => {
|
||||
expect(getDebugServerRuntimeStatus().port).toBe(startPort);
|
||||
});
|
||||
});
|
||||
|
||||
describe("debug-server live diagnostics endpoints", () => {
|
||||
it("serves /providers (live cooldown/runtime snapshot) and /logs/conversion over authenticated HTTP", async () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-prov-"));
|
||||
tempDirs.push(baseDir);
|
||||
const port = await getFreePort();
|
||||
fs.writeFileSync(path.join(baseDir, "debug_token.txt"), "prov-secret", "utf8");
|
||||
fs.writeFileSync(path.join(baseDir, "debug_port.txt"), String(port), "utf8");
|
||||
fs.writeFileSync(path.join(baseDir, "debug_host.txt"), "127.0.0.1", "utf8");
|
||||
fs.writeFileSync(path.join(baseDir, "debug_allowlist.txt"), "", "utf8");
|
||||
startDebugServer({} as unknown as DownloadManager, baseDir);
|
||||
await waitForReady(`http://127.0.0.1:${port}/health?token=prov-secret`);
|
||||
|
||||
const provRes = await fetch(`http://127.0.0.1:${port}/providers?token=prov-secret`);
|
||||
expect(provRes.status).toBe(200);
|
||||
const prov = await provRes.json();
|
||||
expect(typeof prov.capturedAtMs).toBe("number");
|
||||
expect(prov.megaDebrid).toBeTruthy();
|
||||
expect(Array.isArray(prov.megaDebrid.accounts)).toBe(true);
|
||||
expect(typeof prov.megaDebrid.rotationCursor).toBe("number");
|
||||
expect(prov.debridLink).toBeTruthy();
|
||||
expect(Array.isArray(prov.debridLink.keys)).toBe(true);
|
||||
|
||||
const unauth = await fetch(`http://127.0.0.1:${port}/providers`);
|
||||
expect(unauth.status).toBe(401);
|
||||
|
||||
const convRes = await fetch(`http://127.0.0.1:${port}/logs/conversion?token=prov-secret`);
|
||||
expect(convRes.status).toBe(200);
|
||||
const conv = await convRes.json();
|
||||
expect(Array.isArray(conv.lines)).toBe(true);
|
||||
expect(conv).toHaveProperty("available");
|
||||
});
|
||||
});
|
||||
|
||||
@ -4,7 +4,7 @@ import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys";
|
||||
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
|
||||
import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits";
|
||||
import { isMegaDebridTransientResolveFailure } from "../src/shared/mega-debrid-errors";
|
||||
import { classifyMegaDebridAccountFailureForTests, clearMegaDebridEmptyResponseStreak, DebridService, extractRapidgatorFilenameFromHtml, fetchAllDebridHostInfo, fetchDebridLinkHostLimits, filenameFromRapidgatorUrlPath, getDebridLinkKeyCooldownStateForTests, getDebridLinkKeyRuntimeStateForTests, getMegaDebridAccountCooldownState, getProviderRuntimeSnapshot, leadProviderChainWith, MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART, MEGA_DEBRID_STICKY_LINKS, normalizeResolvedFilename, primeMegaDebridRuntimeCooldownForTests, primeMegaDebridUntilRestartForTests, recordMegaDebridEmptyResponseStreak, resetDebridLinkRuntimeStateForTests, resetMegaDebridRuntimeStateForTests } from "../src/main/debrid";
|
||||
import { classifyMegaDebridAccountFailureForTests, clearMegaDebridEmptyResponseStreak, DebridService, extractRapidgatorFilenameFromHtml, fetchAllDebridHostInfo, fetchDebridLinkHostLimits, filenameFromRapidgatorUrlPath, getDebridLinkKeyCooldownStateForTests, getDebridLinkKeyRuntimeStateForTests, getMegaDebridAccountCooldownState, leadProviderChainWith, MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART, MEGA_DEBRID_STICKY_LINKS, normalizeResolvedFilename, primeMegaDebridUntilRestartForTests, recordMegaDebridEmptyResponseStreak, resetDebridLinkRuntimeStateForTests, resetMegaDebridRuntimeStateForTests } from "../src/main/debrid";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
@ -2071,76 +2071,9 @@ describe("debrid service", () => {
|
||||
expect(calls).toBeGreaterThanOrEqual(1);
|
||||
}, 20000);
|
||||
|
||||
it("getProviderRuntimeSnapshot surfaces a live Mega-Debrid account cooldown (until/remaining/reason) for the diagnostics endpoint", () => {
|
||||
const accId = getMegaDebridAccountId("user");
|
||||
const key = `${accId}:web`;
|
||||
expect(getProviderRuntimeSnapshot().megaDebrid.accounts.find((a) => a.key === key)?.cooldown ?? null).toBeNull();
|
||||
|
||||
primeMegaDebridRuntimeCooldownForTests(key, 90_000, "Abbruch/Timeout nach 60s");
|
||||
|
||||
const snap = getProviderRuntimeSnapshot();
|
||||
expect(typeof snap.capturedAtMs).toBe("number");
|
||||
const acc = snap.megaDebrid.accounts.find((a) => a.key === key);
|
||||
expect(acc).toBeTruthy();
|
||||
expect(acc!.cooldown).not.toBeNull();
|
||||
expect(acc!.cooldown!.remainingMs).toBeGreaterThan(0);
|
||||
expect(acc!.cooldown!.remainingMs).toBeLessThanOrEqual(90_000);
|
||||
expect(acc!.cooldown!.untilMs).toBeGreaterThan(snap.capturedAtMs);
|
||||
expect(acc!.cooldown!.message).toContain("Abbruch");
|
||||
});
|
||||
|
||||
it("single Mega-Debrid account: a long Web abort parks only the slow link and does NOT freeze the sole account", async () => {
|
||||
process.env.RD_MEGA_ABORT_MIN_RUN_MS = "0";
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
token: "",
|
||||
bestToken: "",
|
||||
allDebridToken: "",
|
||||
megaLogin: "user",
|
||||
megaPassword: "pass",
|
||||
megaCredentials: "user:pass",
|
||||
megaDebridPreferApi: false,
|
||||
providerOrder: [] as const,
|
||||
providerPrimary: "megadebrid" as const,
|
||||
providerSecondary: "none" as const,
|
||||
providerTertiary: "none" as const,
|
||||
autoProviderFallback: false
|
||||
};
|
||||
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
|
||||
|
||||
const controller = new AbortController();
|
||||
let calls = 0;
|
||||
const megaWeb = vi.fn((): Promise<{ fileName: string; directUrl: string; fileSize: number | null; retriesUsed: number }> => {
|
||||
calls += 1;
|
||||
if (calls === 1) {
|
||||
controller.abort("simulated-60s-timeout");
|
||||
return Promise.reject(new Error("aborted"));
|
||||
}
|
||||
return Promise.resolve({
|
||||
fileName: "healthy.rar",
|
||||
directUrl: "https://www11.unrestrict.link/download/file/ok/healthy.rar",
|
||||
fileSize: null,
|
||||
retriesUsed: 0
|
||||
});
|
||||
});
|
||||
|
||||
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
||||
|
||||
const err = await service.unrestrictLink("https://rapidgator.net/file/slow-link.rar.html", controller.signal).then(() => null, (e: unknown) => e);
|
||||
expect(err).toBeTruthy();
|
||||
expect(String(err)).toMatch(/mega_debrid_slow_link:\d+:/i);
|
||||
|
||||
const key = `${getMegaDebridAccountId("user")}:web`;
|
||||
expect(getMegaDebridAccountCooldownState(key)).toBeNull();
|
||||
|
||||
const second = await service.unrestrictLink("https://rapidgator.net/file/healthy.rar.html");
|
||||
expect(second.provider).toBe("megadebrid");
|
||||
expect(calls).toBeGreaterThanOrEqual(2);
|
||||
}, 20000);
|
||||
|
||||
it("escalates a Mega-Debrid account to 'until restart' after the empty-response streak threshold", () => {
|
||||
const key = `${getMegaDebridAccountId("user1")}:web`;
|
||||
expect(MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART).toBe(10);
|
||||
expect(MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART).toBe(3);
|
||||
expect(recordMegaDebridEmptyResponseStreak(key)).toBe(1);
|
||||
expect(recordMegaDebridEmptyResponseStreak(key)).toBe(2);
|
||||
expect(recordMegaDebridEmptyResponseStreak(key)).toBe(3);
|
||||
@ -2265,9 +2198,8 @@ describe("debrid service", () => {
|
||||
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
|
||||
|
||||
const key = `${getMegaDebridAccountId("user1")}:web`;
|
||||
for (let i = 0; i < MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART - 1; i += 1) {
|
||||
recordMegaDebridEmptyResponseStreak(key);
|
||||
}
|
||||
recordMegaDebridEmptyResponseStreak(key);
|
||||
recordMegaDebridEmptyResponseStreak(key);
|
||||
expect(getMegaDebridAccountCooldownState(key)?.untilRestart ?? false).toBe(false);
|
||||
|
||||
const megaWeb = vi.fn(async () => null);
|
||||
|
||||
@ -14,7 +14,7 @@ import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits";
|
||||
import { getItemLogPath, initItemLogs, shutdownItemLogs } from "../src/main/item-log";
|
||||
import { initPackageLogs, shutdownPackageLogs } from "../src/main/package-log";
|
||||
import { createStoragePaths, emptySession } from "../src/main/storage";
|
||||
import { primeDebridLinkRuntimeCooldownForTests, resetDebridLinkRuntimeStateForTests, primeMegaDebridRuntimeCooldownForTests, resetMegaDebridRuntimeStateForTests, primeMegaDebridInFlightForTests } from "../src/main/debrid";
|
||||
import { primeDebridLinkRuntimeCooldownForTests, resetDebridLinkRuntimeStateForTests, primeMegaDebridRuntimeCooldownForTests, resetMegaDebridRuntimeStateForTests } from "../src/main/debrid";
|
||||
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
|
||||
import { getRenameLogPath, initRenameLog, shutdownRenameLog } from "../src/main/rename-log";
|
||||
import { UnrestrictedLink } from "../src/main/realdebrid";
|
||||
@ -2209,98 +2209,6 @@ describe("download manager", () => {
|
||||
expect(fs.statSync(item.targetPath).size).toBe(binary.length);
|
||||
});
|
||||
|
||||
it("recovers an HTTP 416 item with a clean fresh restart after the in-budget retries are exhausted", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-416-fresh-"));
|
||||
tempDirs.push(root);
|
||||
const binary = Buffer.alloc(160 * 1024, 19);
|
||||
const prevDelay = process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS;
|
||||
process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS = "0";
|
||||
let downloadCalls = 0;
|
||||
|
||||
globalThis.fetch = async (input: RequestInfo | URL): Promise<Response> => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
if (url.includes("/unrestrict/link")) {
|
||||
return new Response(JSON.stringify({ download: "https://dummy/direct-416-recover", filename: "fresh-416.mkv", filesize: binary.length }), { status: 200, headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
throw new Error(`unexpected fetch ${url}`);
|
||||
};
|
||||
|
||||
try {
|
||||
const manager = new DownloadManager(
|
||||
{ ...defaultSettings(), token: "rd-token", outputDir: path.join(root, "downloads"), extractDir: path.join(root, "extract"), retryLimit: 2, autoExtract: false, autoReconnect: false },
|
||||
emptySession(),
|
||||
createStoragePaths(path.join(root, "state"))
|
||||
);
|
||||
|
||||
(manager as any).downloadToFile = async (_active: unknown, _directUrl: string, targetPath: string) => {
|
||||
downloadCalls += 1;
|
||||
if (downloadCalls <= 3) {
|
||||
throw new Error("HTTP 416");
|
||||
}
|
||||
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
||||
fs.writeFileSync(targetPath, binary);
|
||||
const item = Object.values((manager as any).session.items)[0] as { downloadedBytes: number; totalBytes: number; progressPercent: number } | undefined;
|
||||
if (item) {
|
||||
item.downloadedBytes = binary.length;
|
||||
item.totalBytes = binary.length;
|
||||
item.progressPercent = 100;
|
||||
}
|
||||
return { resumable: true };
|
||||
};
|
||||
|
||||
manager.addPackages([{ name: "fresh-416", links: ["https://dummy/fresh-416"] }]);
|
||||
await manager.start();
|
||||
await waitFor(() => !manager.getSnapshot().session.running, 20000);
|
||||
|
||||
const item = Object.values(manager.getSnapshot().session.items)[0];
|
||||
expect(item?.status).toBe("completed");
|
||||
expect(downloadCalls).toBeGreaterThan(3);
|
||||
} finally {
|
||||
if (prevDelay === undefined) { delete process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS; } else { process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS = prevDelay; }
|
||||
}
|
||||
}, 25000);
|
||||
|
||||
it("bounds HTTP 416 clean restarts and finally fails instead of looping forever or stalling permanently", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-416-cap-"));
|
||||
tempDirs.push(root);
|
||||
const prevDelay = process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS;
|
||||
process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS = "0";
|
||||
let downloadCalls = 0;
|
||||
|
||||
globalThis.fetch = async (input: RequestInfo | URL): Promise<Response> => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
if (url.includes("/unrestrict/link")) {
|
||||
return new Response(JSON.stringify({ download: "https://dummy/direct-416-forever", filename: "always-416.mkv", filesize: 1024 }), { status: 200, headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
throw new Error(`unexpected fetch ${url}`);
|
||||
};
|
||||
|
||||
try {
|
||||
const manager = new DownloadManager(
|
||||
{ ...defaultSettings(), token: "rd-token", outputDir: path.join(root, "downloads"), extractDir: path.join(root, "extract"), retryLimit: 0, autoExtract: false, autoReconnect: false },
|
||||
emptySession(),
|
||||
createStoragePaths(path.join(root, "state"))
|
||||
);
|
||||
|
||||
(manager as any).downloadToFile = async () => {
|
||||
downloadCalls += 1;
|
||||
throw new Error("direct_link_retry_exhausted:HTTP 416");
|
||||
};
|
||||
|
||||
manager.addPackages([{ name: "always-416", links: ["https://dummy/always-416"] }]);
|
||||
await manager.start();
|
||||
await waitFor(() => !manager.getSnapshot().session.running, 20000);
|
||||
|
||||
const item = Object.values(manager.getSnapshot().session.items)[0];
|
||||
expect(item?.status).toBe("failed");
|
||||
expect(downloadCalls).toBeGreaterThan(4);
|
||||
expect(downloadCalls).toBeLessThan(30);
|
||||
expect((manager as any).http416FreshRestartByItem.get(item.id)).toBeUndefined();
|
||||
} finally {
|
||||
if (prevDelay === undefined) { delete process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS; } else { process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS = prevDelay; }
|
||||
}
|
||||
}, 25000);
|
||||
|
||||
it("retries HTTP 416 in-session when using Debrid-Link API and then completes", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||
tempDirs.push(root);
|
||||
@ -12336,200 +12244,3 @@ describe("download manager", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("start conflict guard + selective resume", () => {
|
||||
function makeItem(id: string, packageId: string, status: string, fileName: string): any {
|
||||
return {
|
||||
id, packageId, url: `https://hoster.example/${id}`, provider: "realdebrid",
|
||||
status, retries: 0, speedBps: 0, downloadedBytes: status === "completed" ? 100 : 0,
|
||||
totalBytes: status === "completed" ? 100 : null, progressPercent: status === "completed" ? 100 : 0,
|
||||
fileName, targetPath: "", resumable: true, attempts: 0, lastError: "", fullStatus: "",
|
||||
createdAt: Date.now(), updatedAt: Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
it("does not flag a partially-downloaded package whose extract dir holds its own completed output", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-startconflict-own-"));
|
||||
tempDirs.push(root);
|
||||
const storagePaths = createStoragePaths(path.join(root, "state"));
|
||||
initPackageLogs(storagePaths.baseDir);
|
||||
initItemLogs(storagePaths.baseDir);
|
||||
|
||||
const session = emptySession();
|
||||
const packageId = "pkg-own-output";
|
||||
const extractDir = path.join(root, "extract", "OwnOutput");
|
||||
fs.mkdirSync(extractDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(extractDir, "episode01.mkv"), Buffer.alloc(64, 7));
|
||||
session.packageOrder = [packageId];
|
||||
session.packages[packageId] = {
|
||||
id: packageId, name: "OwnOutput",
|
||||
outputDir: path.join(root, "downloads", "OwnOutput"), extractDir,
|
||||
status: "queued", itemIds: ["own-done", "own-pending"], cancelled: false, enabled: true,
|
||||
createdAt: Date.now(), updatedAt: Date.now()
|
||||
} as any;
|
||||
session.items["own-done"] = makeItem("own-done", packageId, "completed", "done.rar");
|
||||
session.items["own-pending"] = makeItem("own-pending", packageId, "queued", "pending.rar");
|
||||
|
||||
const manager = new DownloadManager(
|
||||
{ ...defaultSettings(), token: "rd-token", outputDir: path.join(root, "downloads"), extractDir: path.join(root, "extract") },
|
||||
session, storagePaths
|
||||
);
|
||||
|
||||
const conflicts = await manager.getStartConflicts();
|
||||
expect(conflicts.map((c) => c.packageId)).not.toContain(packageId);
|
||||
});
|
||||
|
||||
it("flags a fresh package when its package-specific extract dir already holds files and it has no completed items", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-startconflict-fresh-"));
|
||||
tempDirs.push(root);
|
||||
const storagePaths = createStoragePaths(path.join(root, "state"));
|
||||
initPackageLogs(storagePaths.baseDir);
|
||||
initItemLogs(storagePaths.baseDir);
|
||||
|
||||
const session = emptySession();
|
||||
const packageId = "pkg-fresh-conflict";
|
||||
const extractDir = path.join(root, "extract", "FreshConflict");
|
||||
fs.mkdirSync(extractDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(extractDir, "old-from-previous-run.mkv"), Buffer.alloc(64, 9));
|
||||
session.packageOrder = [packageId];
|
||||
session.packages[packageId] = {
|
||||
id: packageId, name: "FreshConflict",
|
||||
outputDir: path.join(root, "downloads", "FreshConflict"), extractDir,
|
||||
status: "queued", itemIds: ["fresh-pending"], cancelled: false, enabled: true,
|
||||
createdAt: Date.now(), updatedAt: Date.now()
|
||||
} as any;
|
||||
session.items["fresh-pending"] = makeItem("fresh-pending", packageId, "queued", "fresh.rar");
|
||||
|
||||
const manager = new DownloadManager(
|
||||
{ ...defaultSettings(), token: "rd-token", outputDir: path.join(root, "downloads"), extractDir: path.join(root, "extract") },
|
||||
session, storagePaths
|
||||
);
|
||||
|
||||
const conflicts = await manager.getStartConflicts();
|
||||
expect(conflicts.map((c) => c.packageId)).toContain(packageId);
|
||||
});
|
||||
|
||||
it("start() holds excluded packages out of the run set and runs the rest", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-selective-resume-"));
|
||||
tempDirs.push(root);
|
||||
const storagePaths = createStoragePaths(path.join(root, "state"));
|
||||
initPackageLogs(storagePaths.baseDir);
|
||||
initItemLogs(storagePaths.baseDir);
|
||||
|
||||
const session = emptySession();
|
||||
const runId = "pkg-run";
|
||||
const holdId = "pkg-hold";
|
||||
fs.mkdirSync(path.join(root, "downloads", "RunMe"), { recursive: true });
|
||||
fs.mkdirSync(path.join(root, "downloads", "HoldMe"), { recursive: true });
|
||||
session.packageOrder = [runId, holdId];
|
||||
session.packages[runId] = {
|
||||
id: runId, name: "RunMe",
|
||||
outputDir: path.join(root, "downloads", "RunMe"), extractDir: path.join(root, "extract", "RunMe"),
|
||||
status: "queued", itemIds: ["run-item"], cancelled: false, enabled: true,
|
||||
createdAt: Date.now(), updatedAt: Date.now()
|
||||
} as any;
|
||||
session.packages[holdId] = {
|
||||
id: holdId, name: "HoldMe",
|
||||
outputDir: path.join(root, "downloads", "HoldMe"), extractDir: path.join(root, "extract", "HoldMe"),
|
||||
status: "queued", itemIds: ["hold-item"], cancelled: false, enabled: true,
|
||||
createdAt: Date.now(), updatedAt: Date.now()
|
||||
} as any;
|
||||
session.items["run-item"] = makeItem("run-item", runId, "queued", "run.rar");
|
||||
session.items["hold-item"] = makeItem("hold-item", holdId, "queued", "hold.rar");
|
||||
|
||||
const manager = new DownloadManager(
|
||||
{ ...defaultSettings(), token: "rd-token", maxParallel: 2, outputDir: path.join(root, "downloads"), extractDir: path.join(root, "extract") },
|
||||
session, storagePaths
|
||||
);
|
||||
(manager as any).debridService.unrestrictLink = () => new Promise(() => {});
|
||||
|
||||
await manager.start({ excludePackageIds: new Set([holdId]) });
|
||||
|
||||
expect((manager as any).runPackageIds.has(runId)).toBe(true);
|
||||
expect((manager as any).runPackageIds.has(holdId)).toBe(false);
|
||||
expect((manager as any).runItemIds.has("run-item")).toBe(true);
|
||||
expect((manager as any).runItemIds.has("hold-item")).toBe(false);
|
||||
expect(session.items["hold-item"].status).toBe("queued");
|
||||
|
||||
manager.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe("mega-debrid api/web resolution overlap gate", () => {
|
||||
function megaApiSettings(root: string): any {
|
||||
return {
|
||||
...defaultSettings(),
|
||||
megaLogin: "u", megaPassword: "p", megaCredentials: "u:p",
|
||||
megaDebridApiEnabled: true, megaDebridWebEnabled: true, megaDebridPreferApi: true,
|
||||
outputDir: path.join(root, "downloads"), extractDir: path.join(root, "extract")
|
||||
};
|
||||
}
|
||||
|
||||
function megaItem(id: string, status: string): any {
|
||||
return {
|
||||
id, packageId: "pkg", url: `https://rapidgator.net/file/${id}`, provider: "megadebrid-api",
|
||||
status, retries: 0, speedBps: 0, downloadedBytes: 0, totalBytes: null, progressPercent: 0,
|
||||
fileName: `${id}.rar`, targetPath: "", resumable: true, attempts: 0, lastError: "", fullStatus: "",
|
||||
createdAt: Date.now(), updatedAt: Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
function addValidating(manager: DownloadManager, session: any, ids: string[]): void {
|
||||
for (const id of ids) {
|
||||
session.items[id] = megaItem(id, "validating");
|
||||
(manager as any).activeTasks.set(id, {
|
||||
itemId: id, packageId: "pkg", abortController: new AbortController(), abortReason: "none",
|
||||
resumable: true, nonResumableCounted: false, blockedOnDiskWrite: false, blockedOnDiskSince: 0
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function buildManager(root: string, session: any): DownloadManager {
|
||||
return new DownloadManager(megaApiSettings(root), session, createStoragePaths(path.join(root, "state")));
|
||||
}
|
||||
|
||||
it("lets the first mega resolve start when nothing is in flight", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-overlap-0-"));
|
||||
tempDirs.push(root);
|
||||
const session = emptySession();
|
||||
const candidate = megaItem("cand", "queued");
|
||||
session.items["cand"] = candidate;
|
||||
const manager = buildManager(root, session);
|
||||
expect((manager as any).shouldDelayStartForItem(candidate)).toBe(false);
|
||||
});
|
||||
|
||||
it("serializes a second API resolve while the first is still in its API phase (no concurrent API)", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-overlap-1-"));
|
||||
tempDirs.push(root);
|
||||
const session = emptySession();
|
||||
const candidate = megaItem("cand", "queued");
|
||||
session.items["cand"] = candidate;
|
||||
const manager = buildManager(root, session);
|
||||
addValidating(manager, session, ["a"]);
|
||||
expect((manager as any).shouldDelayStartForItem(candidate)).toBe(true);
|
||||
});
|
||||
|
||||
it("allows one API resolve to overlap once the first has moved to its web phase", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-overlap-2-"));
|
||||
tempDirs.push(root);
|
||||
const session = emptySession();
|
||||
const candidate = megaItem("cand", "queued");
|
||||
session.items["cand"] = candidate;
|
||||
const manager = buildManager(root, session);
|
||||
addValidating(manager, session, ["a"]);
|
||||
primeMegaDebridInFlightForTests("acc:web", 1);
|
||||
expect((manager as any).shouldDelayStartForItem(candidate)).toBe(false);
|
||||
});
|
||||
|
||||
it("caps the overlap at one API plus one web (no third concurrent resolve)", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-overlap-3-"));
|
||||
tempDirs.push(root);
|
||||
const session = emptySession();
|
||||
const candidate = megaItem("cand", "queued");
|
||||
session.items["cand"] = candidate;
|
||||
const manager = buildManager(root, session);
|
||||
addValidating(manager, session, ["a", "b"]);
|
||||
primeMegaDebridInFlightForTests("acc:web", 1);
|
||||
expect((manager as any).shouldDelayStartForItem(candidate)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
Binary file not shown.
@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { transientResolveRetryDelayMs, parseMegaDebridCooldownRetry, parseMegaDebridResetPark, parseMegaDebridSlowLinkRetry } from "../src/main/download-manager";
|
||||
import { transientResolveRetryDelayMs, parseMegaDebridCooldownRetry, parseMegaDebridResetPark } from "../src/main/download-manager";
|
||||
|
||||
describe("transientResolveRetryDelayMs (fast, bounded retry for transient resolve failures)", () => {
|
||||
it("starts fast (<= 3s) instead of the 5s..120s exponential", () => {
|
||||
@ -63,31 +63,6 @@ describe("parseMegaDebridCooldownRetry (honor the encoded account-cooldown delay
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseMegaDebridSlowLinkRetry (park only the slow link, never the account)", () => {
|
||||
it("parses the encoded delay from a slow-link error", () => {
|
||||
const r = parseMegaDebridSlowLinkRetry("mega_debrid_slow_link:120000:Mega-Debrid (Account 1/1, Su******e3): aborted");
|
||||
expect(r).not.toBeNull();
|
||||
expect(r!.delayMs).toBe(120000);
|
||||
expect(r!.detail).toContain("Mega-Debrid");
|
||||
});
|
||||
|
||||
it("parses it when embedded in the aggregated provider-chain error", () => {
|
||||
const aggregated = "Provider-Kette: Mega-Debrid Web fehlgeschlagen (Error: mega_debrid_slow_link:90000:Mega-Debrid (Account 1/1): aborted)";
|
||||
expect(parseMegaDebridSlowLinkRetry(aggregated)!.delayMs).toBe(90000);
|
||||
});
|
||||
|
||||
it("clamps to [1s, 15min]", () => {
|
||||
expect(parseMegaDebridSlowLinkRetry("mega_debrid_slow_link:1:x")!.delayMs).toBe(1000);
|
||||
expect(parseMegaDebridSlowLinkRetry("mega_debrid_slow_link:99999999:x")!.delayMs).toBe(15 * 60 * 1000);
|
||||
});
|
||||
|
||||
it("does not collide with the account-cooldown or reset-park tokens", () => {
|
||||
expect(parseMegaDebridSlowLinkRetry("mega_debrid_cooldown:20330:x")).toBeNull();
|
||||
expect(parseMegaDebridSlowLinkRetry("mega_debrid_reset_park:43200000:x")).toBeNull();
|
||||
expect(parseMegaDebridCooldownRetry("mega_debrid_slow_link:120000:x")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseMegaDebridResetPark (park the item until the Tagesreset, not a ~2min generic retry)", () => {
|
||||
it("parses the encoded until-reset delay from the park token", () => {
|
||||
const r = parseMegaDebridResetPark("mega_debrid_reset_park:43200000:Mega-Debrid: Alle Accounts am Tageslimit (bis zum Tagesreset gesperrt)");
|
||||
|
||||
@ -29,10 +29,8 @@ 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).
|
||||
`rd_servers`, `rd_ping`, `rd_diagnostics`, `rd_status`, `rd_items`, `rd_packages`, `rd_errors`, `rd_logs`,
|
||||
`rd_history`, `rd_accounts`, `rd_host`, `rd_self_check`, `rd_get` (raw escape-hatch, any read-only path).
|
||||
|
||||
Each tool accepts `code` or `server` to pick the target.
|
||||
|
||||
|
||||
@ -219,7 +219,6 @@ const LOG_PATHS = {
|
||||
rename: "/logs/rename",
|
||||
trace: "/logs/trace",
|
||||
session: "/logs/session",
|
||||
conversion: "/logs/conversion",
|
||||
package: "/logs/package",
|
||||
item: "/logs/item"
|
||||
};
|
||||
@ -228,10 +227,10 @@ 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.",
|
||||
description: "Liest das Ende eines Logs (GET /logs/<name>). name: main|audit|rename|trace|session|package|item. Fuer package/item zusaetzlich package/item angeben.",
|
||||
inputSchema: {
|
||||
...CODE_FIELD,
|
||||
name: z.enum(["main", "audit", "rename", "trace", "session", "conversion", "package", "item"]).describe("Welches Log."),
|
||||
name: z.enum(["main", "audit", "rename", "trace", "session", "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."),
|
||||
@ -269,16 +268,6 @@ server.registerTool(
|
||||
async (args) => requestTool(args, "/accounts", {})
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"rd_providers",
|
||||
{
|
||||
title: "Provider-Laufzeitzustand",
|
||||
description: "Live Provider-Runtime (GET /providers): pro Mega-Account/Debrid-Link-Key der AKTIVE Cooldown (until/remainingMs/Grund/Kategorie), in-flight-Tiefe, Mega-Rotationscursor, Empty-Response-Streaks. Die 'warum kuehlt es JETZT ab'-Ansicht — beantwortet Cooldown-Fragen direkt statt aus Log-Arithmetik.",
|
||||
inputSchema: { ...CODE_FIELD }
|
||||
},
|
||||
async (args) => requestTool(args, "/providers", {})
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"rd_host",
|
||||
{
|
||||
|
||||
Loading…
Reference in New Issue
Block a user