release: prepare v2.0.43
Add sanitized provider and account runtime diagnostics, complete the Multi-Debrid-Downloader product rename, preserve existing application data during migration, update public release verification, and publish the accompanying English documentation and regression coverage.
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
import type {
|
||||
AccountRuntimeEntry,
|
||||
DebridProvider,
|
||||
DownloadItem,
|
||||
RendererAccount
|
||||
} from "../shared/types";
|
||||
import { getAccountRuntimeSessionStats } from "./account-runtime";
|
||||
import { getProviderRuntimeSnapshot, type ProviderRuntimeCooldown } from "./debrid";
|
||||
|
||||
const ACTIVE_DOWNLOAD_STATUSES = new Set(["validating", "downloading", "paused", "reconnect_wait"]);
|
||||
|
||||
function providerFamily(provider: DebridProvider | null): string {
|
||||
if (provider === "megadebrid" || provider === "megadebrid-api" || provider === "megadebrid-web") return "megadebrid";
|
||||
return provider || "";
|
||||
}
|
||||
|
||||
function accountRuntimeKey(provider: DebridProvider, accountId: string): string {
|
||||
return `${provider}:${accountId}`;
|
||||
}
|
||||
|
||||
function sanitizedCooldownReason(category: string): string {
|
||||
if (category === "invalid") return "Anmeldung ungültig";
|
||||
if (category === "rate_limit") return "Rate-Limit aktiv";
|
||||
if (category === "quota") return "Traffic- oder Kontolimit erreicht";
|
||||
if (category === "provider_or_link") return "Provider oder Link vorübergehend nicht verfügbar";
|
||||
return "Vorübergehender Cooldown";
|
||||
}
|
||||
|
||||
function accountCooldown(
|
||||
account: RendererAccount,
|
||||
runtime: ReturnType<typeof getProviderRuntimeSnapshot>
|
||||
): { cooldown: ProviderRuntimeCooldown | null; inFlight: number } {
|
||||
if (account.provider === "realdebrid") {
|
||||
const entry = runtime.realDebrid.accounts.find((candidate) => candidate.accountId === account.accountId);
|
||||
return { cooldown: entry?.cooldown ?? null, inFlight: entry?.inFlight ?? 0 };
|
||||
}
|
||||
if (account.provider === "megadebrid-api" || account.provider === "megadebrid-web") {
|
||||
const mode = account.provider === "megadebrid-api" ? "api" : "web";
|
||||
const entry = runtime.megaDebrid.accounts.find((candidate) => candidate.key === `${account.accountId}:${mode}`);
|
||||
return { cooldown: entry?.cooldown ?? null, inFlight: entry?.inFlight ?? 0 };
|
||||
}
|
||||
if (account.provider === "debridlink") {
|
||||
const entry = runtime.debridLink.keys.find((candidate) => candidate.keyId === account.accountId);
|
||||
return { cooldown: entry?.cooldown ?? null, inFlight: 0 };
|
||||
}
|
||||
return { cooldown: null, inFlight: 0 };
|
||||
}
|
||||
|
||||
function attributedAccount(item: DownloadItem, accounts: readonly RendererAccount[]): RendererAccount | null {
|
||||
if (item.providerAccountId) {
|
||||
const exact = accounts.filter((account) => account.accountId === item.providerAccountId && account.provider === item.provider);
|
||||
if (exact.length === 1) return exact[0];
|
||||
const sameFamily = accounts.filter((account) => account.accountId === item.providerAccountId && providerFamily(account.provider) === providerFamily(item.provider));
|
||||
if (sameFamily.length === 1) return sameFamily[0];
|
||||
}
|
||||
const family = providerFamily(item.provider);
|
||||
const candidates = accounts.filter((account) => providerFamily(account.provider) === family);
|
||||
return candidates.length === 1 ? candidates[0] : null;
|
||||
}
|
||||
|
||||
export function createAccountRuntimeEntries(
|
||||
accounts: readonly RendererAccount[],
|
||||
items: readonly DownloadItem[],
|
||||
now = Date.now()
|
||||
): AccountRuntimeEntry[] {
|
||||
const providerRuntime = getProviderRuntimeSnapshot(now);
|
||||
const activeByAccount = new Map<string, { count: number; lastUsedAt: number }>();
|
||||
for (const item of items) {
|
||||
if (!ACTIVE_DOWNLOAD_STATUSES.has(item.status)) continue;
|
||||
const account = attributedAccount(item, accounts);
|
||||
if (!account) continue;
|
||||
const key = accountRuntimeKey(account.provider, account.accountId);
|
||||
const current = activeByAccount.get(key) ?? { count: 0, lastUsedAt: 0 };
|
||||
activeByAccount.set(key, {
|
||||
count: current.count + 1,
|
||||
lastUsedAt: Math.max(current.lastUsedAt, item.updatedAt || item.createdAt || now)
|
||||
});
|
||||
}
|
||||
|
||||
return accounts.map((account) => {
|
||||
const stats = getAccountRuntimeSessionStats(account.provider, account.accountId);
|
||||
const active = activeByAccount.get(accountRuntimeKey(account.provider, account.accountId)) ?? { count: 0, lastUsedAt: 0 };
|
||||
const { cooldown, inFlight } = accountCooldown(account, providerRuntime);
|
||||
const dailyLimitReached = account.dailyLimitBytes > 0 && account.dailyUsageBytes >= account.dailyLimitBytes;
|
||||
const invalid = account.status?.valid === false;
|
||||
let state: AccountRuntimeEntry["state"] = "ready";
|
||||
let reason = "Bereit";
|
||||
if (!account.enabled) {
|
||||
state = "disabled";
|
||||
reason = "Account deaktiviert";
|
||||
} else if (dailyLimitReached) {
|
||||
state = "daily_limit";
|
||||
reason = "Tageslimit erreicht";
|
||||
} else if (cooldown && cooldown.remainingMs > 0) {
|
||||
state = "cooldown";
|
||||
reason = sanitizedCooldownReason(cooldown.category);
|
||||
} else if (active.count > 0) {
|
||||
state = "active";
|
||||
reason = active.count === 1 ? "1 aktiver Download" : `${active.count} aktive Downloads`;
|
||||
} else if (inFlight > 0) {
|
||||
state = "checking";
|
||||
reason = "Link wird geprüft";
|
||||
} else if (invalid) {
|
||||
state = "invalid";
|
||||
reason = "Accountprüfung fehlgeschlagen";
|
||||
}
|
||||
return {
|
||||
accountId: account.accountId,
|
||||
provider: account.provider,
|
||||
state,
|
||||
reason,
|
||||
activeDownloads: active.count,
|
||||
inFlight,
|
||||
attempts: stats.attempts,
|
||||
successes: stats.successes,
|
||||
failures: stats.failures,
|
||||
lastUsedAt: Math.max(stats.lastUsedAt ?? 0, active.lastUsedAt) || null,
|
||||
cooldownUntil: cooldown && cooldown.remainingMs > 0 ? cooldown.untilMs : null,
|
||||
dailyUsageBytes: account.dailyUsageBytes
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { DebridProvider } from "../shared/types";
|
||||
|
||||
export interface AccountRuntimeSessionStats {
|
||||
attempts: number;
|
||||
successes: number;
|
||||
failures: number;
|
||||
lastUsedAt: number | null;
|
||||
}
|
||||
|
||||
const accountRuntimeSession = new Map<string, AccountRuntimeSessionStats>();
|
||||
|
||||
function runtimeKey(provider: DebridProvider, accountId: string): string {
|
||||
return `${provider}:${accountId}`;
|
||||
}
|
||||
|
||||
function updateAccountRuntimeSession(
|
||||
provider: DebridProvider,
|
||||
accountId: string,
|
||||
update: (current: AccountRuntimeSessionStats) => AccountRuntimeSessionStats
|
||||
): void {
|
||||
if (!accountId) return;
|
||||
const key = runtimeKey(provider, accountId);
|
||||
const current = accountRuntimeSession.get(key) ?? { attempts: 0, successes: 0, failures: 0, lastUsedAt: null };
|
||||
accountRuntimeSession.set(key, update(current));
|
||||
}
|
||||
|
||||
export function recordAccountRuntimeAttempt(provider: DebridProvider, accountId: string, at = Date.now()): void {
|
||||
updateAccountRuntimeSession(provider, accountId, (current) => ({
|
||||
...current,
|
||||
attempts: current.attempts + 1,
|
||||
lastUsedAt: at
|
||||
}));
|
||||
}
|
||||
|
||||
export function recordAccountRuntimeSuccess(provider: DebridProvider, accountId: string, at = Date.now()): void {
|
||||
updateAccountRuntimeSession(provider, accountId, (current) => ({
|
||||
...current,
|
||||
successes: current.successes + 1,
|
||||
lastUsedAt: at
|
||||
}));
|
||||
}
|
||||
|
||||
export function recordAccountRuntimeFailure(provider: DebridProvider, accountId: string, at = Date.now()): void {
|
||||
updateAccountRuntimeSession(provider, accountId, (current) => ({
|
||||
...current,
|
||||
failures: current.failures + 1,
|
||||
lastUsedAt: at
|
||||
}));
|
||||
}
|
||||
|
||||
export function getAccountRuntimeSessionStats(provider: DebridProvider, accountId: string): AccountRuntimeSessionStats {
|
||||
const current = accountRuntimeSession.get(runtimeKey(provider, accountId));
|
||||
return current ? { ...current } : { attempts: 0, successes: 0, failures: 0, lastUsedAt: null };
|
||||
}
|
||||
|
||||
export function pruneAccountRuntimeSession(validKeys: ReadonlySet<string>): void {
|
||||
for (const key of accountRuntimeSession.keys()) {
|
||||
if (!validKeys.has(key)) accountRuntimeSession.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
export function resetAccountRuntimeSessionForProvider(provider: DebridProvider): void {
|
||||
const prefix = `${provider}:`;
|
||||
for (const key of accountRuntimeSession.keys()) {
|
||||
if (key.startsWith(prefix)) accountRuntimeSession.delete(key);
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@ export const MAX_LINK_ARTIFACT_BYTES = 256 * 1024;
|
||||
export const SPEED_WINDOW_SECONDS = 1;
|
||||
export const CLIPBOARD_POLL_INTERVAL_MS = 2000;
|
||||
|
||||
export const DEFAULT_UPDATE_REPO = "Sucukdeluxe/multi-debrid-downloader";
|
||||
export const DEFAULT_UPDATE_REPO = "Sucukdeluxe/Multi-Debrid-Downloader";
|
||||
export const ONLINE_BACKUP_API_URL = "https://downloader.24-music.de/backup-api";
|
||||
|
||||
export function defaultSettings(): AppSettings {
|
||||
|
||||
+77
-29
@@ -5,7 +5,8 @@ import { extractHosterFromUrl } from "../shared/hoster";
|
||||
import { AllDebridHostInfo, AppSettings, DebridFallbackProvider, DebridLinkHostLimitInfo, DebridProvider } from "../shared/types";
|
||||
import { isDebridLinkApiKeyDailyLimitReached, isMegaDebridAccountDisabled, isMegaDebridAccountDailyLimitReached, isProviderDailyLimitReached, isRealDebridAccountDailyLimitReached } 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 { pruneAccountRuntimeSession, recordAccountRuntimeAttempt, recordAccountRuntimeFailure, recordAccountRuntimeSuccess, resetAccountRuntimeSessionForProvider } from "./account-runtime";
|
||||
import { logger } from "./logger";
|
||||
import { logAccountRotation } from "./account-rotation-log";
|
||||
import { traceConversionPhase } from "./conversion-trace";
|
||||
@@ -15,7 +16,7 @@ import { isMegaFileUrl, resolveMegaFilename } from "./mega-public-api";
|
||||
import { compactErrorText, filenameFromUrl, looksLikeOpaqueFilename, sleep } from "./utils";
|
||||
|
||||
const API_TIMEOUT_MS = 30000;
|
||||
const DEBRID_USER_AGENT = `RD-Node-Downloader/${APP_VERSION}`;
|
||||
const DEBRID_USER_AGENT = `MDD-Node-Downloader/${APP_VERSION}`;
|
||||
const RAPIDGATOR_SCAN_MAX_BYTES = 512 * 1024;
|
||||
|
||||
const BEST_DEBRID_API_BASE = "https://bestdebrid.com/api/v1";
|
||||
@@ -60,12 +61,13 @@ const DEBRID_LINK_KEY_COOLDOWN_MS = 120_000;
|
||||
const DEBRID_LINK_INVALID_KEY_COOLDOWN_MS = 60 * 60 * 1000;
|
||||
const DEBRID_LINK_RATE_LIMIT_COOLDOWN_MS = 60 * 60 * 1000;
|
||||
|
||||
export function resetDebridLinkRuntimeStateForTests(): void {
|
||||
export function resetDebridLinkRuntimeStateForTests(): void {
|
||||
debridLinkKeyCooldowns.clear();
|
||||
debridLinkKeyCooldownDetails.clear();
|
||||
debridLinkKeyRuntimeStatuses.clear();
|
||||
debridLinkKeyHostCooldowns.clear();
|
||||
debridLinkKeyHostCooldownDetails.clear();
|
||||
debridLinkKeyHostCooldownDetails.clear();
|
||||
resetAccountRuntimeSessionForProvider("debridlink");
|
||||
}
|
||||
|
||||
export function pruneDebridLinkRuntimeStateForKeys(activeKeyIds: Set<string>): void {
|
||||
@@ -333,11 +335,13 @@ export function clearMegaDebridEmptyResponseStreak(accountId: string): void {
|
||||
megaDebridEmptyResponseStreaks.delete(accountId);
|
||||
}
|
||||
|
||||
export function resetMegaDebridRuntimeStateForTests(): void {
|
||||
export function resetMegaDebridRuntimeStateForTests(): void {
|
||||
megaDebridAccountCooldowns.clear();
|
||||
megaDebridEmptyResponseStreaks.clear();
|
||||
megaDebridRotationCursor = 0;
|
||||
megaDebridStickyCount = 0;
|
||||
megaDebridStickyCount = 0;
|
||||
resetAccountRuntimeSessionForProvider("megadebrid-api");
|
||||
resetAccountRuntimeSessionForProvider("megadebrid-web");
|
||||
megaDebridInFlight.clear();
|
||||
}
|
||||
|
||||
@@ -459,6 +463,11 @@ export interface ProviderRuntimeSnapshot {
|
||||
stickyCount: number;
|
||||
cooldownCount: number;
|
||||
inFlightCount: number;
|
||||
accounts: Array<{
|
||||
accountId: string;
|
||||
cooldown: ProviderRuntimeCooldown | null;
|
||||
inFlight: number;
|
||||
}>;
|
||||
};
|
||||
megaDebrid: {
|
||||
rotationCursor: number;
|
||||
@@ -480,7 +489,26 @@ export interface ProviderRuntimeSnapshot {
|
||||
};
|
||||
}
|
||||
|
||||
export function getProviderRuntimeSnapshot(now = Date.now()): ProviderRuntimeSnapshot {
|
||||
export function getProviderRuntimeSnapshot(now = Date.now()): ProviderRuntimeSnapshot {
|
||||
const realDebridAccountIds = new Set<string>([
|
||||
...realDebridAccountCooldowns.keys(),
|
||||
...realDebridInFlight.keys()
|
||||
]);
|
||||
const realDebridAccounts = [...realDebridAccountIds].sort().map((accountId) => {
|
||||
const detail = realDebridAccountCooldowns.get(accountId);
|
||||
return {
|
||||
accountId,
|
||||
cooldown: detail
|
||||
? {
|
||||
untilMs: detail.until,
|
||||
remainingMs: Math.max(0, detail.until - now),
|
||||
message: detail.message,
|
||||
category: detail.category
|
||||
}
|
||||
: null,
|
||||
inFlight: realDebridInFlight.get(accountId) ?? 0
|
||||
};
|
||||
});
|
||||
const megaKeys = new Set<string>([
|
||||
...megaDebridAccountCooldowns.keys(),
|
||||
...megaDebridInFlight.keys(),
|
||||
@@ -545,7 +573,8 @@ export function getProviderRuntimeSnapshot(now = Date.now()): ProviderRuntimeSna
|
||||
rotationCursor: realDebridRotationCursor,
|
||||
stickyCount: realDebridStickyCount,
|
||||
cooldownCount: [...realDebridAccountCooldowns.values()].filter((entry) => entry.until > now).length,
|
||||
inFlightCount: [...realDebridInFlight.values()].reduce((total, count) => total + count, 0)
|
||||
inFlightCount: [...realDebridInFlight.values()].reduce((total, count) => total + count, 0),
|
||||
accounts: realDebridAccounts
|
||||
},
|
||||
megaDebrid: {
|
||||
rotationCursor: megaDebridRotationCursor,
|
||||
@@ -660,6 +689,7 @@ export function resetRealDebridRuntimeStateForTests(): void {
|
||||
realDebridRotationCursor = 0;
|
||||
realDebridStickyAccountId = "";
|
||||
realDebridStickyCount = 0;
|
||||
resetAccountRuntimeSessionForProvider("realdebrid");
|
||||
}
|
||||
|
||||
export function pruneRealDebridRuntimeStateForAccounts(activeAccountIds: Set<string>): void {
|
||||
@@ -698,9 +728,10 @@ export function pruneExpiredRealDebridRuntimeState(now = Date.now()): number {
|
||||
export function primeRealDebridRuntimeCooldownForTests(
|
||||
accountId: string,
|
||||
cooldownMs: number,
|
||||
message = "Real-Debrid Account im Cooldown"
|
||||
message = "Real-Debrid Account im Cooldown",
|
||||
category: RealDebridCooldownCategory = "temporary"
|
||||
): void {
|
||||
setRealDebridAccountCooldown(accountId, cooldownMs, message, "temporary");
|
||||
setRealDebridAccountCooldown(accountId, cooldownMs, message, category);
|
||||
}
|
||||
|
||||
function setRealDebridAccountCooldown(
|
||||
@@ -2268,11 +2299,13 @@ class MegaDebridClient {
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.info(`Mega-Debrid${accountLabel}: TESTE Account fuer Link-Generierung...`);
|
||||
logAccountRotation("INFO", providerName, rotationLabel, "TEST", {
|
||||
link: linkShort
|
||||
});
|
||||
const testStartedAt = Date.now();
|
||||
logger.info(`Mega-Debrid${accountLabel}: TESTE Account fuer Link-Generierung...`);
|
||||
logAccountRotation("INFO", providerName, rotationLabel, "TEST", {
|
||||
link: linkShort
|
||||
});
|
||||
const runtimeProvider: DebridProvider = mode === "api" ? "megadebrid-api" : "megadebrid-web";
|
||||
recordAccountRuntimeAttempt(runtimeProvider, account.id);
|
||||
const testStartedAt = Date.now();
|
||||
|
||||
usableAccountSeen = true;
|
||||
megaDebridInFlight.set(cooldownKey, (megaDebridInFlight.get(cooldownKey) ?? 0) + 1);
|
||||
@@ -2291,11 +2324,12 @@ class MegaDebridClient {
|
||||
megaDebridRotationCursor = idx;
|
||||
}
|
||||
logger.info(`Mega-Debrid${accountLabel}: Unrestrict OK nach ${elapsedMs}ms -> ${result.fileName || "?"}`);
|
||||
logAccountRotation("INFO", providerName, rotationLabel, "OK", {
|
||||
logAccountRotation("INFO", providerName, rotationLabel, "OK", {
|
||||
elapsedMs,
|
||||
fileName: result.fileName || "",
|
||||
link: linkShort
|
||||
});
|
||||
link: linkShort
|
||||
});
|
||||
recordAccountRuntimeSuccess(runtimeProvider, account.id);
|
||||
return {
|
||||
...result,
|
||||
sourceLabel: `${result.sourceLabel ? `${result.sourceLabel} ` : ""}${account.label}`,
|
||||
@@ -2354,7 +2388,8 @@ class MegaDebridClient {
|
||||
}
|
||||
throw new Error(`Mega-Debrid${accountLabel}: ${abortText}`);
|
||||
}
|
||||
const failure = MegaDebridClient.classifyAccountFailure(error);
|
||||
const failure = MegaDebridClient.classifyAccountFailure(error);
|
||||
recordAccountRuntimeFailure(runtimeProvider, account.id);
|
||||
traceConversionPhase({
|
||||
phase: "mega-account",
|
||||
provider: providerName.includes("API") ? "megadebrid-api" : "megadebrid-web",
|
||||
@@ -3029,9 +3064,10 @@ class DebridLinkClient {
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.info(`Debrid-Link${keyLabel}: TESTE Key fuer Link-Generierung...`);
|
||||
logAccountRotation("INFO", providerName, rotationLabel, "TEST", { link: linkShort });
|
||||
const testStartedAt = Date.now();
|
||||
logger.info(`Debrid-Link${keyLabel}: TESTE Key fuer Link-Generierung...`);
|
||||
logAccountRotation("INFO", providerName, rotationLabel, "TEST", { link: linkShort });
|
||||
recordAccountRuntimeAttempt("debridlink", apiKey.id);
|
||||
const testStartedAt = Date.now();
|
||||
|
||||
usableKeySeen = true;
|
||||
try {
|
||||
@@ -3040,11 +3076,12 @@ class DebridLinkClient {
|
||||
setDebridLinkKeyRuntimeStatus(apiKey.id, "ready", "Unrestrict erfolgreich");
|
||||
const elapsedMs = Date.now() - testStartedAt;
|
||||
logger.info(`Debrid-Link${keyLabel}: Unrestrict OK nach ${elapsedMs}ms -> ${result.fileName || "?"}`);
|
||||
logAccountRotation("INFO", providerName, rotationLabel, "OK", {
|
||||
logAccountRotation("INFO", providerName, rotationLabel, "OK", {
|
||||
elapsedMs,
|
||||
fileName: result.fileName || "",
|
||||
link: linkShort
|
||||
});
|
||||
link: linkShort
|
||||
});
|
||||
recordAccountRuntimeSuccess("debridlink", apiKey.id);
|
||||
return {
|
||||
...result,
|
||||
sourceLabel: apiKey.label,
|
||||
@@ -3055,7 +3092,7 @@ class DebridLinkClient {
|
||||
const failure = await this.classifyKeyFailure(error, apiKey, link, signal);
|
||||
const elapsedMs = Date.now() - testStartedAt;
|
||||
const abortText = compactErrorText(error).replace(/^Error:\s*/i, "");
|
||||
if (/aborted/i.test(abortText) && !/timeout/i.test(abortText)) {
|
||||
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");
|
||||
@@ -3069,8 +3106,9 @@ class DebridLinkClient {
|
||||
cooldownSec: ranLongEnough ? Math.ceil(DEBRID_LINK_KEY_COOLDOWN_MS / 1000) : 0,
|
||||
next: "naechster Key beim Retry"
|
||||
});
|
||||
throw new Error(`Debrid-Link${keyLabel}: ${abortText}`);
|
||||
}
|
||||
throw new Error(`Debrid-Link${keyLabel}: ${abortText}`);
|
||||
}
|
||||
recordAccountRuntimeFailure("debridlink", apiKey.id);
|
||||
attemptedKeyFailures.push({
|
||||
message: `Debrid-Link${keyLabel}: ${failure.message}`,
|
||||
cooldownMs: failure.cooldownMs,
|
||||
@@ -3854,7 +3892,14 @@ export class DebridService {
|
||||
}
|
||||
const nextDebridLinkKeyIds = new Set<string>(parseDebridLinkApiKeys(next.debridLinkApiKeys || "").map((entry) => entry.id));
|
||||
pruneDebridLinkRuntimeStateForKeys(nextDebridLinkKeyIds);
|
||||
pruneRealDebridRuntimeStateForAccounts(new Set(this.getConfiguredRealDebridAccounts(next).map((account) => account.id)));
|
||||
const nextRealDebridAccountIds = new Set(this.getConfiguredRealDebridAccounts(next).map((account) => account.id));
|
||||
pruneRealDebridRuntimeStateForAccounts(nextRealDebridAccountIds);
|
||||
const validRuntimeKeys = new Set<string>();
|
||||
for (const accountId of nextRealDebridAccountIds) validRuntimeKeys.add(`realdebrid:${accountId}`);
|
||||
for (const account of getMegaDebridAccountsForMode(next, "api")) validRuntimeKeys.add(`megadebrid-api:${account.id}`);
|
||||
for (const account of getMegaDebridAccountsForMode(next, "web")) validRuntimeKeys.add(`megadebrid-web:${account.id}`);
|
||||
for (const keyId of nextDebridLinkKeyIds) validRuntimeKeys.add(`debridlink:${keyId}`);
|
||||
pruneAccountRuntimeSession(validRuntimeKeys);
|
||||
}
|
||||
|
||||
private getDebridLinkClient(apiKeysRaw: string): DebridLinkClient {
|
||||
@@ -4292,6 +4337,7 @@ export class DebridService {
|
||||
const account = this.selectRealDebridAccount(available);
|
||||
attempted.add(account.id);
|
||||
realDebridInFlight.set(account.id, (realDebridInFlight.get(account.id) || 0) + 1);
|
||||
recordAccountRuntimeAttempt("realdebrid", account.id);
|
||||
const timeoutMs = getRealDebridAccountAttemptTimeoutMs();
|
||||
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
||||
const accountSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
|
||||
@@ -4313,6 +4359,7 @@ export class DebridService {
|
||||
if (realDebridStickyCount >= REAL_DEBRID_STICKY_LINKS && accountIndex >= 0) {
|
||||
realDebridRotationCursor = (accountIndex + 1) % available.length;
|
||||
}
|
||||
recordAccountRuntimeSuccess("realdebrid", account.id);
|
||||
return {
|
||||
...result,
|
||||
sourceLabel: account.kind === "api" ? "API" : "Web",
|
||||
@@ -4324,6 +4371,7 @@ export class DebridService {
|
||||
throw error;
|
||||
}
|
||||
const failure = this.classifyRealDebridFailure(error);
|
||||
recordAccountRuntimeFailure("realdebrid", account.id);
|
||||
if (!failure.rotateAccount) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -65,7 +65,8 @@ import { classifyDiskError } from "./fs-error";
|
||||
import { processVideoFile, resolveVideoTooling, stripDualLangMarker, hasDualLangMarker, isRemuxableVideoFile, type GermanAudioMode, type VideoProcessResult } from "./video-processor";
|
||||
import { sendNotification } from "./notify";
|
||||
import { logger } from "./logger";
|
||||
import { getRecentRotationEvents, runWithRotationItemSink, setRotationEventListener } from "./account-rotation-log";
|
||||
import { getRecentRotationEvents, runWithRotationItemSink, setRotationEventListener } from "./account-rotation-log";
|
||||
import { createAccountRuntimeEntries } from "./account-runtime-snapshot";
|
||||
import { runWithConversionTrace, traceConversionPhase, traceConversionNote } from "./conversion-trace";
|
||||
import type { RotationEvent } from "../shared/types";
|
||||
import { ensureItemLog, getItemLogPath as getPersistedItemLogPath, logItemEvent as writeItemLogEvent } from "./item-log";
|
||||
@@ -2576,8 +2577,9 @@ export class DownloadManager extends EventEmitter {
|
||||
? { ...this.summary }
|
||||
: null;
|
||||
|
||||
return {
|
||||
rotationEvents: getRecentRotationEvents(40),
|
||||
return {
|
||||
rotationEvents: getRecentRotationEvents(40),
|
||||
accountRuntime: createAccountRuntimeEntries(rendererState.accounts, Object.values(snapshotSession.items), now),
|
||||
settings: rendererState.settings,
|
||||
accounts: rendererState.accounts,
|
||||
session: snapshotSession,
|
||||
|
||||
@@ -214,7 +214,7 @@ export function cleanupStaleSubstDrives(): void {
|
||||
if (!match) continue;
|
||||
const drive = match[1].toUpperCase();
|
||||
const target = match[2].trim();
|
||||
if (/\\rd-extract-|\\Real-Debrid-Downloader/i.test(target)) {
|
||||
if (/\\rd-extract-|\\(?:Real|Multi)-Debrid-Downloader/i.test(target)) {
|
||||
spawnSync("subst", [`${drive}:`, "/d"], { stdio: "pipe", timeout: 5000 });
|
||||
logger.info(`Stale subst ${drive}: entfernt (${target})`);
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ export function buildLinkExportSelection(snapshot: UiSnapshot, packageIds: strin
|
||||
export function serializeLinkExportText(packages: ParsedPackageInput[]): string {
|
||||
const lines: string[] = [
|
||||
"# rd-link-export: 1",
|
||||
"# Re-import in Real-Debrid-Downloader keeps package names and optional file names.",
|
||||
"# Re-import in Multi-Debrid-Downloader keeps package names and optional file names.",
|
||||
""
|
||||
];
|
||||
|
||||
|
||||
+9
-3
@@ -22,6 +22,7 @@ import { validateRendererSettingsUpdate } from "./renderer-settings";
|
||||
import { applyMainWindowSecurity, createMainWindowWebPreferences, MAIN_WINDOW_EXTERNAL_HOSTS, openAllowedExternalUrl } from "./browser-security";
|
||||
import { assertTrustedIpcSender, type TrustedIpcOptions } from "./ipc-security";
|
||||
import { validateRealDebridLoginRequest } from "../shared/preload-api";
|
||||
import { migrateProductUserDataDirectory } from "./storage";
|
||||
|
||||
function validateString(value: unknown, name: string): string {
|
||||
if (typeof value !== "string") {
|
||||
@@ -40,7 +41,7 @@ function validatePlainObject(value: unknown, name: string): Record<string, unkno
|
||||
const IMPORT_QUEUE_MAX_BYTES = 10 * 1024 * 1024;
|
||||
const CLIPBOARD_WRITE_MAX_BYTES = 4096;
|
||||
const RENAME_PACKAGE_MAX_CHARS = 240;
|
||||
const RESETTABLE_PROVIDER_KEYS = new Set<DebridProvider>([
|
||||
const RESETTABLE_PROVIDER_KEYS = new Set<DebridProvider>([
|
||||
"realdebrid",
|
||||
"megadebrid-api",
|
||||
"megadebrid-web",
|
||||
@@ -50,8 +51,13 @@ const RESETTABLE_PROVIDER_KEYS = new Set<DebridProvider>([
|
||||
"onefichier",
|
||||
"debridlink",
|
||||
"linksnappy"
|
||||
]);
|
||||
function validateStringArray(value: unknown, name: string): string[] {
|
||||
]);
|
||||
|
||||
if (app.isPackaged && !process.argv.some((arg) => arg === "--user-data-dir" || arg.startsWith("--user-data-dir="))) {
|
||||
app.setPath("userData", migrateProductUserDataDirectory(app.getPath("appData")));
|
||||
}
|
||||
|
||||
function validateStringArray(value: unknown, name: string): string[] {
|
||||
if (!Array.isArray(value) || !value.every(v => typeof v === "string")) {
|
||||
throw new Error(`${name} muss ein String-Array sein`);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { API_BASE_URL, APP_VERSION, REQUEST_RETRIES } from "./constants";
|
||||
import { compactErrorText, sleep } from "./utils";
|
||||
|
||||
const DEBRID_USER_AGENT = `RD-Node-Downloader/${APP_VERSION}`;
|
||||
const DEBRID_USER_AGENT = `MDD-Node-Downloader/${APP_VERSION}`;
|
||||
|
||||
export interface UnrestrictedLink {
|
||||
fileName: string;
|
||||
|
||||
@@ -188,7 +188,7 @@ export function runStartupHealthCheck(settings: AppSettings, storagePaths: Stora
|
||||
severity: "ERROR",
|
||||
code: "baseDir_not_writable",
|
||||
message: `Runtime-Verzeichnis ist NICHT beschreibbar: ${storagePaths.baseDir}`,
|
||||
hint: "Rechte auf das Runtime-Verzeichnis pruefen (%APPDATA%/Real-Debrid-Downloader/runtime)."
|
||||
hint: "Rechte auf das Runtime-Verzeichnis pruefen (%APPDATA%/Multi-Debrid-Downloader/runtime)."
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+17
-3
@@ -11,8 +11,22 @@ import { getRealDebridAccountIds, normalizeRealDebridWebAccountIds, parseRealDeb
|
||||
import { defaultSettings } from "./constants";
|
||||
import { needsPersistedSettingsRewrite, protectPersistedSettings, restorePersistedSettings } from "./credential-protection";
|
||||
import { logger } from "./logger";
|
||||
|
||||
const VALID_PRIMARY_PROVIDERS = new Set(["realdebrid", "megadebrid-api", "megadebrid-web", "bestdebrid", "alldebrid", "ddownload", "onefichier", "debridlink", "linksnappy"]);
|
||||
|
||||
export function migrateProductUserDataDirectory(appDataPath: string): string {
|
||||
const legacyPath = path.join(appDataPath, "Real-Debrid-Downloader");
|
||||
const targetPath = path.join(appDataPath, "Multi-Debrid-Downloader");
|
||||
if (fs.existsSync(targetPath) || !fs.existsSync(legacyPath)) {
|
||||
return targetPath;
|
||||
}
|
||||
try {
|
||||
fs.renameSync(legacyPath, targetPath);
|
||||
return targetPath;
|
||||
} catch {
|
||||
return legacyPath;
|
||||
}
|
||||
}
|
||||
|
||||
const VALID_PRIMARY_PROVIDERS = new Set(["realdebrid", "megadebrid-api", "megadebrid-web", "bestdebrid", "alldebrid", "ddownload", "onefichier", "debridlink", "linksnappy"]);
|
||||
const VALID_FALLBACK_PROVIDERS = new Set(["none", "realdebrid", "megadebrid-api", "megadebrid-web", "bestdebrid", "alldebrid", "ddownload", "onefichier", "debridlink", "linksnappy"]);
|
||||
const VALID_CLEANUP_MODES = new Set(["none", "trash", "delete"]);
|
||||
const VALID_CONFLICT_MODES = new Set(["overwrite", "skip", "rename", "ask"]);
|
||||
@@ -393,7 +407,7 @@ const DEPRECATED_UPDATE_REPO_NAMES = new Set([
|
||||
function migrateUpdateRepo(raw: string, fallback: string): string {
|
||||
const trimmed = raw.trim();
|
||||
const repoName = trimmed.split("/").filter(Boolean).pop()?.replace(/\.git$/i, "").toLowerCase() || "";
|
||||
if (!trimmed || (DEPRECATED_UPDATE_REPO_NAMES.has(repoName) && trimmed.toLowerCase() !== fallback.toLowerCase())) {
|
||||
if (!trimmed || (DEPRECATED_UPDATE_REPO_NAMES.has(repoName) && trimmed !== fallback)) {
|
||||
return fallback;
|
||||
}
|
||||
return trimmed;
|
||||
|
||||
@@ -94,7 +94,7 @@ function formatTimestampForFileName(date: Date): string {
|
||||
}
|
||||
|
||||
export function getSupportBundleDefaultFileName(): string {
|
||||
return `rd-support-bundle-${formatTimestampForFileName(new Date())}.zip`;
|
||||
return `mdd-support-bundle-${formatTimestampForFileName(new Date())}.zip`;
|
||||
}
|
||||
|
||||
type HostDiagnosticsMode = "full" | "cached" | "none";
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ const DOWNLOAD_BODY_IDLE_TIMEOUT_MS = 45_000;
|
||||
const RETRIES_PER_CANDIDATE = 3;
|
||||
const RETRY_DELAY_MS = 1_500;
|
||||
const MAX_DOWNLOAD_PASSES = 3;
|
||||
const USER_AGENT = `RD-Node-Downloader/${APP_VERSION}`;
|
||||
const USER_AGENT = `MDD-Node-Downloader/${APP_VERSION}`;
|
||||
|
||||
type UpdateSource = {
|
||||
name: string;
|
||||
|
||||
@@ -221,7 +221,7 @@ $appCrashes = @(
|
||||
Get-WinEvent -FilterHashtable @{ LogName = "Application"; StartTime = $startTime } -MaxEvents 100 |
|
||||
Where-Object {
|
||||
($_.ProviderName -eq "Application Error" -or $_.ProviderName -eq "Windows Error Reporting") -and
|
||||
($_.Message -match "Real-Debrid-Downloader|electron|node\.exe|main\.js")
|
||||
($_.Message -match "(?:Real|Multi)-Debrid-Downloader|electron|node\.exe|main\.js")
|
||||
} |
|
||||
Select-Object -First 10 |
|
||||
ForEach-Object { Convert-EventRecord $_ }
|
||||
|
||||
Reference in New Issue
Block a user