fix(realdebrid): persist web status and report relevant provider errors

Check Real-Debrid API and browser sessions through the account status flow, retain the service status across settings updates, and refresh the account row when a browser login is detected. Exclude unavailable providers that were never attempted from conversion failures and prevent aggregated fallback text from being mislabeled as a Debrid-Link terminal error. Bump the development version to 2.0.37 and add focused regression coverage.
This commit is contained in:
Sucukdeluxe
2026-08-14 21:02:30 +02:00
parent c2d5dbaeb3
commit b80209a10f
20 changed files with 556 additions and 81 deletions
+12
View File
@@ -2,6 +2,18 @@
All notable changes to Multi-Debrid Downloader are documented in this file.
## [2.0.37] - 2026-08-14
### Account status
- Added persistent Real-Debrid API and browser-session account checks with premium status and account identity details.
- Updated the Real-Debrid account row automatically after a successful browser login and preserved the result across settings saves and restarts.
### Provider errors
- Limited failed conversion messages to providers that were actually attempted.
- Prevented aggregated fallback details from being mislabeled as a Debrid-Link failure.
## [2.0.36] - 2026-08-14
### Download performance
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "real-debrid-downloader",
"version": "2.0.36",
"version": "2.0.37",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "real-debrid-downloader",
"version": "2.0.36",
"version": "2.0.37",
"license": "MIT",
"dependencies": {
"adm-zip": "0.6.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "real-debrid-downloader",
"version": "2.0.36",
"version": "2.0.37",
"description": "Desktop downloader",
"main": "build/main/main/main.js",
"author": "Sucukdeluxe",
+122 -9
View File
@@ -5,10 +5,24 @@ import { logger } from "./logger";
import { compactErrorText } from "./utils";
const MEGA_DEBRID_API = "https://www.mega-debrid.eu/api.php";
const DEBRID_LINK_API = "https://debrid-link.com/api/v2";
const DEBRID_LINK_API = "https://debrid-link.com/api/v2";
const REAL_DEBRID_USER_API = "https://api.real-debrid.com/rest/1.0/user";
const CHECK_USER_AGENT =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36";
const CHECK_TIMEOUT_MS = 20000;
const CHECK_TIMEOUT_MS = 20000;
export const REAL_DEBRID_STATUS_ID = "svc-realdebrid";
export interface RealDebridSessionProbeResult {
valid: boolean;
isPremium?: boolean;
premiumUntilMs?: number | null;
username?: string;
email?: string;
message?: string;
}
export type RealDebridSessionProbe = (signal?: AbortSignal) => Promise<RealDebridSessionProbeResult>;
function timeoutSignal(signal: AbortSignal | undefined, ms: number): AbortSignal {
const timeout = AbortSignal.timeout(ms);
@@ -24,7 +38,7 @@ function parseJsonSafe(text: string): Record<string, unknown> | null {
}
}
function formatRemaining(premiumUntilMs: number | null, now: number): string {
function formatRemaining(premiumUntilMs: number | null, now: number): string {
if (premiumUntilMs == null) {
return "Premium-Status unbekannt";
}
@@ -41,7 +55,102 @@ function formatRemaining(premiumUntilMs: number | null, now: number): string {
}
const hours = Math.max(1, Math.floor(remainingMs / (60 * 60 * 1000)));
return `Premium noch ${hours} Std`;
}
}
function maskSecret(value: string): string {
const trimmed = value.trim();
if (!trimmed) {
return "";
}
if (trimmed.length <= 6) {
return "*".repeat(trimmed.length);
}
return `${trimmed.slice(0, 3)}${"*".repeat(Math.max(4, trimmed.length - 6))}${trimmed.slice(-3)}`;
}
export async function checkRealDebridAccount(
settings: AppSettings,
signal?: AbortSignal,
now = Date.now(),
probeWebSession?: RealDebridSessionProbe
): Promise<DebridAccountStatus> {
const token = String(settings.token || "").trim();
const useWebLogin = Boolean(settings.realDebridUseWebLogin);
const base: DebridAccountStatus = {
accountId: REAL_DEBRID_STATUS_ID,
provider: "realdebrid",
label: "Real-Debrid",
maskedLogin: useWebLogin ? "Browser-Login" : maskSecret(token),
valid: false,
isPremium: false,
premiumUntilMs: null,
message: "",
checkedAt: now
};
if (useWebLogin) {
if (!probeWebSession) {
return { ...base, message: "Browser-Sitzung ist nicht prüfbar" };
}
try {
const result = await probeWebSession(signal);
if (!result.valid) {
return { ...base, message: result.message || "Browser-Sitzung abgelaufen" };
}
const premiumUntilMs = typeof result.premiumUntilMs === "number" ? result.premiumUntilMs : null;
return {
...base,
valid: true,
isPremium: Boolean(result.isPremium),
premiumUntilMs,
email: String(result.email || result.username || "").trim() || undefined,
message: result.message || (result.isPremium ? formatRemaining(premiumUntilMs, now) : "Kein Premium (Free)")
};
} catch (error) {
const errText = compactErrorText(error);
const aborted = signal?.aborted || /aborted/i.test(errText);
return { ...base, message: aborted ? "Prüfung abgebrochen" : `Prüfung fehlgeschlagen: ${errText}` };
}
}
if (!token) {
return { ...base, message: "Kein API-Token hinterlegt" };
}
try {
const response = await fetch(REAL_DEBRID_USER_API, {
headers: {
Authorization: `Bearer ${token}`,
"User-Agent": CHECK_USER_AGENT
},
signal: timeoutSignal(signal, CHECK_TIMEOUT_MS)
});
const text = await response.text();
const payload = parseJsonSafe(text);
if (!response.ok || !payload) {
if (response.status === 401 || response.status === 403) {
return { ...base, message: "Ungültiger API-Token" };
}
return { ...base, message: `Prüfung fehlgeschlagen (HTTP ${response.status})` };
}
const expiration = Date.parse(String(payload.expiration || ""));
const premiumUntilMs = Number.isFinite(expiration) ? expiration : null;
const isPremium = String(payload.type || "").toLowerCase() === "premium"
&& (premiumUntilMs == null || premiumUntilMs > now);
return {
...base,
valid: true,
isPremium,
premiumUntilMs,
email: String(payload.email || payload.username || "").trim() || undefined,
message: isPremium ? formatRemaining(premiumUntilMs, now) : "Kein Premium (Free)"
};
} catch (error) {
const errText = compactErrorText(error);
const aborted = signal?.aborted || /aborted/i.test(errText);
return { ...base, message: aborted ? "Prüfung abgebrochen" : `Prüfung fehlgeschlagen: ${errText}` };
}
}
export async function checkMegaDebridAccount(
account: MegaDebridAccountEntry,
@@ -158,15 +267,19 @@ export async function checkDebridLinkKey(
}
}
export async function checkAllDebridAccounts(
settings: AppSettings,
signal?: AbortSignal
): Promise<DebridAccountStatus[]> {
export async function checkAllDebridAccounts(
settings: AppSettings,
signal?: AbortSignal,
probeRealDebridWebSession?: RealDebridSessionProbe
): Promise<DebridAccountStatus[]> {
const now = Date.now();
const megaAccounts = parseMegaDebridAccounts(settings.megaCredentials || "", settings.megaPassword || "");
const debridLinkKeys = parseDebridLinkApiKeys(settings.debridLinkApiKeys || "");
const taskFns: Array<() => Promise<DebridAccountStatus>> = [
const taskFns: Array<() => Promise<DebridAccountStatus>> = [
...(settings.realDebridUseWebLogin || String(settings.token || "").trim()
? [() => checkRealDebridAccount(settings, signal, now, probeRealDebridWebSession)]
: []),
...megaAccounts.map((account) => () => checkMegaDebridAccount(account, signal, now)),
...debridLinkKeys.map((key) => () => checkDebridLinkKey(key, signal, now))
];
+5 -1
View File
@@ -121,7 +121,11 @@ export function validateAccountCredentialCheckInput(value: unknown): AccountCred
if (!value || typeof value !== "object" || Array.isArray(value)) invalid();
const raw = value as Record<string, unknown>;
if (Object.keys(raw).some((key) => !new Set(["kind", "accountId", "identity", "secret"]).has(key))) invalid();
if (raw.kind !== "megadebrid-api" && raw.kind !== "megadebrid-web" && raw.kind !== "debridlink-api") invalid();
if (raw.kind !== "realdebrid-api"
&& raw.kind !== "realdebrid-web"
&& raw.kind !== "megadebrid-api"
&& raw.kind !== "megadebrid-web"
&& raw.kind !== "debridlink-api") invalid();
return {
kind: raw.kind,
accountId: optionalString(raw.accountId, 256),
+48 -6
View File
@@ -30,7 +30,7 @@ import { importDlcContainers } from "./container";
import { APP_VERSION, ONLINE_BACKUP_API_URL } from "./constants";
import { DownloadManager } from "./download-manager";
import { fetchAllDebridHostInfo, fetchDebridLinkHostLimits } from "./debrid";
import { checkAllDebridAccounts, checkDebridLinkKey, checkMegaDebridAccount } from "./account-check";
import { checkAllDebridAccounts, checkDebridLinkKey, checkMegaDebridAccount, checkRealDebridAccount } from "./account-check";
import { parseMegaDebridAccounts } from "../shared/mega-debrid-accounts";
import { getMegaDebridAccountsForMode } from "../shared/mega-debrid-accounts";
import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
@@ -133,7 +133,10 @@ export class AppController {
login: this.settings.megaLogin,
password: this.settings.megaPassword
}));
this.realDebridWebFallback = new RealDebridWebFallback(() => this.settings.rememberToken);
this.realDebridWebFallback = new RealDebridWebFallback(
() => this.settings.rememberToken,
() => { void this.refreshRealDebridWebStatus(); }
);
this.allDebridWebFallback = new AllDebridWebFallback(() => this.settings.rememberToken);
this.bestDebridWebFallback = new BestDebridWebFallback(() => this.settings.rememberToken);
this.manager = new DownloadManager(this.settings, session, this.storagePaths, {
@@ -532,6 +535,25 @@ export class AppController {
public async checkAccountCredentials(input: AccountCredentialCheckInput): Promise<DebridAccountStatus> {
const redactions = collectAccountStatusRedactionValues(this.settings, input);
if (input.kind === "realdebrid-api" || input.kind === "realdebrid-web") {
const useWebLogin = input.kind === "realdebrid-web";
const settings = input.secret?.trim()
? { ...this.settings, token: input.secret.trim(), realDebridUseWebLogin: useWebLogin }
: { ...this.settings, realDebridUseWebLogin: useWebLogin };
const status = sanitizeDebridAccountStatus(
await checkRealDebridAccount(
settings,
undefined,
Date.now(),
useWebLogin ? (signal) => this.realDebridWebFallback.probeLoginState(signal) : undefined
),
redactions
);
if (!input.secret && useWebLogin === this.settings.realDebridUseWebLogin) {
this.manager.applyDebridAccountStatuses([status]);
}
return status;
}
if (input.kind === "megadebrid-api" || input.kind === "megadebrid-web") {
const mode = input.kind === "megadebrid-web" ? "web" : "api";
const account = input.identity?.trim() && input.secret
@@ -575,10 +597,26 @@ export class AppController {
return this.settings;
}
public async openRealDebridLoginWindow(): Promise<void> {
public async openRealDebridLoginWindow(): Promise<void> {
this.audit("INFO", "Real-Debrid Login-Fenster geöffnet");
await this.realDebridWebFallback.openLoginWindow();
}
await this.realDebridWebFallback.openLoginWindow();
}
private async refreshRealDebridWebStatus(): Promise<void> {
if (!this.settings.realDebridUseWebLogin) {
return;
}
const status = sanitizeDebridAccountStatus(
await checkRealDebridAccount(
this.settings,
undefined,
Date.now(),
(signal) => this.realDebridWebFallback.probeLoginState(signal)
),
collectAccountStatusRedactionValues(this.settings)
);
this.manager.applyDebridAccountStatuses([status]);
}
public async openAllDebridLoginWindow(): Promise<void> {
this.audit("INFO", "AllDebrid Login-Fenster geöffnet");
@@ -611,7 +649,11 @@ export class AppController {
public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
const statuses = sanitizeDebridAccountStatuses(
await checkAllDebridAccounts(this.settings),
await checkAllDebridAccounts(
this.settings,
undefined,
(signal) => this.realDebridWebFallback.probeLoginState(signal)
),
collectAccountStatusRedactionValues(this.settings)
);
this.manager.applyDebridAccountStatuses(statuses);
+9 -8
View File
@@ -3964,8 +3964,8 @@ export class DebridService {
}
let configuredFound = false;
let limitReachedFound = false;
const attempts: string[] = [];
const attempts: string[] = [];
const unavailableProviders: string[] = [];
for (const provider of order) {
if (!this.isProviderConfiguredFor(settings, provider)) {
@@ -3973,9 +3973,8 @@ export class DebridService {
}
configuredFound = true;
if (this.isProviderDailyLimited(settings, provider)) {
limitReachedFound = true;
logger.info(`Provider-Kette: ${PROVIDER_LABELS[provider]} uebersprungen (${this.formatProviderLimitMessage(settings, provider)})`);
attempts.push(this.formatProviderLimitMessage(settings, provider));
logger.info(`Provider-Kette: ${PROVIDER_LABELS[provider]} uebersprungen (${this.formatProviderLimitMessage(settings, provider)})`);
unavailableProviders.push(this.formatProviderLimitMessage(settings, provider));
continue;
}
@@ -4024,9 +4023,11 @@ export class DebridService {
if (!configuredFound) {
throw new Error("Kein Debrid-Provider konfiguriert");
}
if (limitReachedFound && attempts.every((entry) => /Tageslimit erreicht$/i.test(entry))) {
throw new Error("Alle konfigurierten Provider haben ihr Tageslimit erreicht");
}
if (attempts.length === 0 && unavailableProviders.length > 0) {
throw new Error(unavailableProviders.length === 1
? unavailableProviders[0]
: `Alle konfigurierten Provider sind nicht verfügbar: ${unavailableProviders.join(" | ")}`);
}
throw new Error(`Unrestrict fehlgeschlagen: ${attempts.join(" | ")}`);
}
+2 -2
View File
@@ -696,11 +696,11 @@ export function parseMegaDebridResetPark(errorText: string): { delayMs: number;
return { delayMs, detail: String(match[2] || "").trim() };
}
function parseDebridLinkTerminalFailure(errorText: string): { kind: "invalid_all" | "no_active_key"; detail: string } | null {
export function parseDebridLinkTerminalFailure(errorText: string): { kind: "invalid_all" | "no_active_key"; detail: string } | null {
const raw = String(errorText || "");
const match = raw.match(/debrid_link_(invalid_all|no_active_key):(.*)$/i);
if (!match) {
if (/debrid-link.+(deaktiviert|ausgeschopft|kein aktiver api-key)/i.test(raw)) {
if (/^(?:Error:\s*)?Debrid-Link.+(?:deaktiviert|ausgeschopft|kein aktiver api-key)/i.test(raw)) {
return {
kind: "no_active_key",
detail: raw.trim()
+91 -16
View File
@@ -7,14 +7,28 @@ import { applyRemoteLoginSecurity, createRemoteLoginWebPreferences, REALDEBRID_L
const RD_BASE_URL = "https://real-debrid.com";
const RD_LOGIN_URL = RD_BASE_URL;
const RD_APITOKEN_URL = `${RD_BASE_URL}/apitoken`;
const RD_UNRESTRICT_API = `${API_BASE_URL}/unrestrict/link`;
const RD_UNRESTRICT_API = `${API_BASE_URL}/unrestrict/link`;
const RD_USER_API = `${API_BASE_URL}/user`;
const RD_PERSISTENT_PARTITION = "persist:realdebrid-web";
const RD_TRANSIENT_PARTITION = "realdebrid-web";
const RD_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36";
type GenerateOutcome =
| { kind: "success"; value: UnrestrictedLink }
| { kind: "login_required" };
type GenerateOutcome =
| { kind: "success"; value: UnrestrictedLink }
| { kind: "login_required" };
export interface RealDebridLoginState {
valid: boolean;
username: string;
email: string;
isPremium: boolean;
premiumUntilMs: number | null;
message: string;
}
function loginFailure(message: string): RealDebridLoginState {
return { valid: false, username: "", email: "", isPremium: false, premiumUntilMs: null, message };
}
function abortError(): Error {
return new Error("aborted:realdebrid-web");
@@ -116,10 +130,13 @@ export class RealDebridWebFallback {
private cachedTokenAt = 0;
private getRememberSession: () => boolean;
public constructor(getRememberSession: () => boolean) {
this.getRememberSession = getRememberSession;
private getRememberSession: () => boolean;
private onAuthenticated?: () => void;
public constructor(getRememberSession: () => boolean, onAuthenticated?: () => void) {
this.getRememberSession = getRememberSession;
this.onAuthenticated = onAuthenticated;
}
public async unrestrict(link: string, signal?: AbortSignal): Promise<UnrestrictedLink | null> {
@@ -138,15 +155,69 @@ export class RealDebridWebFallback {
}, overallSignal);
}
public async openLoginWindow(): Promise<void> {
public async openLoginWindow(): Promise<void> {
const window = await this.ensureLoginWindow();
if (window.isMinimized()) {
window.restore();
}
window.show();
window.focus();
void this.primeTokenFromWindow(window);
}
void this.primeTokenFromWindow(window);
}
public async probeLoginState(signal?: AbortSignal): Promise<RealDebridLoginState> {
let token: string | null = null;
try {
token = await this.extractApiToken(signal);
} catch (error) {
if (signal?.aborted) {
throw abortError();
}
return loginFailure(`Sitzung nicht prüfbar: ${String(error)}`);
}
if (!token) {
return loginFailure("Nicht angemeldet");
}
try {
const response = await fetch(RD_USER_API, {
headers: {
Authorization: `Bearer ${token}`,
"User-Agent": RD_USER_AGENT
},
signal: withTimeoutSignal(signal, 30_000)
});
const text = await response.text();
if (response.status === 401 || response.status === 403) {
this.cachedToken = "";
this.cachedTokenAt = 0;
return loginFailure("Sitzung abgelaufen");
}
if (!response.ok) {
return loginFailure(`Real-Debrid Web HTTP ${response.status}`);
}
const payload = parseJson(text);
if (!payload) {
return loginFailure("Ungültige Antwort von Real-Debrid");
}
const expiration = Date.parse(String(payload.expiration || ""));
const premiumUntilMs = Number.isFinite(expiration) ? expiration : null;
const isPremium = String(payload.type || "").toLowerCase() === "premium"
&& (premiumUntilMs == null || premiumUntilMs > Date.now());
return {
valid: true,
username: String(payload.username || "").trim(),
email: String(payload.email || "").trim(),
isPremium,
premiumUntilMs,
message: isPremium ? "Premium aktiv" : "Kein Premium (Free)"
};
} catch (error) {
if (signal?.aborted) {
throw abortError();
}
return loginFailure(`Sitzung nicht prüfbar: ${String(error)}`);
}
}
public async clearSessions(): Promise<void> {
this.disposeLoginWindow();
@@ -247,11 +318,15 @@ export class RealDebridWebFallback {
return window;
}
private rememberToken(token: string): string {
this.cachedToken = token;
this.cachedTokenAt = Date.now();
return token;
}
private rememberToken(token: string): string {
const changed = token !== this.cachedToken;
this.cachedToken = token;
this.cachedTokenAt = Date.now();
if (changed && this.onAuthenticated) {
void Promise.resolve().then(() => this.onAuthenticated?.());
}
return token;
}
private getActiveLoginWindow(): BrowserWindow | null {
const window = this.loginWindow;
+5 -1
View File
@@ -5,7 +5,11 @@ import type { AppSettings } from "../shared/types";
export function overlayLiveUsageCounters(target: AppSettings, liveSettings: AppSettings, liveTotalRuntimeMs: number): void {
const debridLinkKeyIds = new Set(getDebridLinkApiKeyIds(target.debridLinkApiKeys));
const megaAccountIds = new Set(getMegaDebridAccountIds(mergeMegaDebridCredentialPools(target.megaDebridApiCredentials || "", target.megaDebridWebCredentials || "") || target.megaCredentials || "", target.megaPassword || ""));
const validAccountIds = new Set([...debridLinkKeyIds, ...megaAccountIds]);
const validAccountIds = new Set([
...debridLinkKeyIds,
...megaAccountIds,
...(target.realDebridUseWebLogin || target.token.trim() ? ["svc-realdebrid"] : [])
]);
target.totalDownloadedAllTime = Math.max(target.totalDownloadedAllTime || 0, liveSettings.totalDownloadedAllTime || 0);
target.totalCompletedFilesAllTime = Math.max(target.totalCompletedFilesAllTime || 0, liveSettings.totalCompletedFilesAllTime || 0);
target.totalRuntimeAllTimeMs = Math.max(target.totalRuntimeAllTimeMs || 0, liveTotalRuntimeMs);
+18 -8
View File
@@ -266,12 +266,13 @@ function normalizeNamedByteMap(raw: unknown, allowedKeys: readonly string[]): Re
return result;
}
function normalizeDebridAccountStatuses(
value: unknown,
megaIds: string[],
debridLinkIds: string[]
): Record<string, DebridAccountStatus> {
const allowed = new Set([...megaIds, ...debridLinkIds]);
function normalizeDebridAccountStatuses(
value: unknown,
megaIds: string[],
debridLinkIds: string[],
realDebridConfigured: boolean
): Record<string, DebridAccountStatus> {
const allowed = new Set([...megaIds, ...debridLinkIds, ...(realDebridConfigured ? ["svc-realdebrid"] : [])]);
const result: Record<string, DebridAccountStatus> = {};
if (value && typeof value === "object" && !Array.isArray(value)) {
for (const [key, raw] of Object.entries(value as Record<string, unknown>)) {
@@ -284,7 +285,11 @@ function normalizeDebridAccountStatuses(
}
result[key] = {
accountId: entry.accountId,
provider: entry.provider === "debridlink" ? "debridlink" : "megadebrid",
provider: entry.provider === "debridlink"
? "debridlink"
: entry.provider === "realdebrid"
? "realdebrid"
: "megadebrid",
label: String(entry.label || ""),
maskedLogin: String(entry.maskedLogin || ""),
valid: Boolean(entry.valid),
@@ -570,7 +575,12 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
? normalizeNamedByteMap(settings.megaDebridAccountDailyUsageBytes, megaDebridAccountIds)
: {},
megaDebridAccountTotalUsageBytes: normalizeNamedByteMap(settings.megaDebridAccountTotalUsageBytes, megaDebridAccountIds),
debridAccountStatuses: normalizeDebridAccountStatuses(settings.debridAccountStatuses, megaDebridAccountIds, debridLinkApiKeyIds),
debridAccountStatuses: normalizeDebridAccountStatuses(
settings.debridAccountStatuses,
megaDebridAccountIds,
debridLinkApiKeyIds,
Boolean(settings.realDebridUseWebLogin || asText(settings.token))
),
providerDailyUsageDay: providerDailyUsageDay === currentUsageDay ? providerDailyUsageDay : currentUsageDay,
scheduledStartEpochMs: clampNumber(settings.scheduledStartEpochMs, defaults.scheduledStartEpochMs, 0, Number.MAX_SAFE_INTEGER)
};
+8 -7
View File
@@ -2359,16 +2359,17 @@ export function App(): ReactElement {
}
});
}
} else {
rows.push({
} else {
const serviceAccountId = entry.service === "realdebrid" ? "svc-realdebrid" : null;
rows.push({
rowKey: `svc-${entry.service}`,
entry,
hosterLabel: entry.serviceLabel,
modeLabel: entry.modeLabel,
username: getStoredAccountUsername(entry.kind, snapshot.accounts),
credentialLabel: getAccountCredentialLabel(entry.kind),
accountId: null,
checkable: false,
accountId: serviceAccountId,
checkable: serviceAccountId !== null,
disabled: entry.disabled,
dailyUsedBytes: entry.dailyUsedBytes,
dailyLimitBytes: entry.dailyLimitBytes,
@@ -2653,8 +2654,8 @@ export function App(): ReactElement {
setAccountCheckBusy(true);
try {
const statuses = await window.rd.checkDebridAccounts();
if (!statuses || statuses.length === 0) {
showToast("Keine Mega-Debrid-/Debrid-Link-Accounts zum Prüfen konfiguriert.", 3200);
if (!statuses || statuses.length === 0) {
showToast("Keine prüfbaren Accounts konfiguriert.", 3200);
} else {
const valid = statuses.filter((st) => st.valid).length;
const premium = statuses.filter((st) => st.isPremium).length;
@@ -2941,7 +2942,7 @@ export function App(): ReactElement {
const checkAccountTableRow = (row: AccountTableRow): void => {
setAccountContextMenu(null);
if (row.toggleKind === "mega" || row.toggleKind === "dl") {
if (row.checkable) {
void checkAllAccounts();
return;
}
+4 -4
View File
@@ -55,9 +55,9 @@ export interface DownloadStats {
runtimeMeasuredAt: number;
}
export interface DebridAccountStatus {
accountId: string;
provider: "megadebrid" | "debridlink";
export interface DebridAccountStatus {
accountId: string;
provider: DebridProvider;
label: string;
maskedLogin: string;
valid: boolean;
@@ -337,7 +337,7 @@ export interface AccountCommandResult {
}
export interface AccountCredentialCheckInput {
kind: "megadebrid-api" | "megadebrid-web" | "debridlink-api";
kind: "realdebrid-api" | "realdebrid-web" | "megadebrid-api" | "megadebrid-web" | "debridlink-api";
accountId?: string;
identity?: string;
secret?: string;
+52 -5
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { checkMegaDebridAccount, checkDebridLinkKey, checkAllDebridAccounts } from "../src/main/account-check";
import { checkMegaDebridAccount, checkDebridLinkKey, checkAllDebridAccounts, checkRealDebridAccount, REAL_DEBRID_STATUS_ID } from "../src/main/account-check";
import type { MegaDebridAccountEntry } from "../src/shared/mega-debrid-accounts";
import type { DebridLinkApiKeyEntry } from "../src/shared/debrid-link-keys";
import type { AppSettings } from "../src/shared/types";
@@ -78,7 +78,7 @@ describe("checkMegaDebridAccount", () => {
});
});
describe("checkDebridLinkKey", () => {
describe("checkDebridLinkKey", () => {
it("reports valid + premium from premiumLeft seconds", async () => {
const premiumLeft = 60 * 24 * 60 * 60;
mockFetchOnce(200, { success: true, value: { username: "u", accountType: 1, premiumLeft } });
@@ -108,14 +108,61 @@ describe("checkDebridLinkKey", () => {
const st = await checkDebridLinkKey(debridLinkKey(), undefined, NOW);
expect(st.valid).toBe(false);
});
});
});
describe("checkRealDebridAccount", () => {
it("uses the browser-session probe and returns a stable service status", async () => {
const premiumUntilMs = NOW + 30 * 24 * 60 * 60 * 1000;
const probe = vi.fn(async () => ({
valid: true,
isPremium: true,
premiumUntilMs,
username: "web-user"
}));
const status = await checkRealDebridAccount({
token: "",
realDebridUseWebLogin: true
} as AppSettings, undefined, NOW, probe);
expect(probe).toHaveBeenCalledTimes(1);
expect(status).toMatchObject({
accountId: REAL_DEBRID_STATUS_ID,
provider: "realdebrid",
valid: true,
isPremium: true,
premiumUntilMs,
email: "web-user"
});
});
});
describe("checkAllDebridAccounts", () => {
it("returns empty array when nothing configured", async () => {
it("returns empty array when nothing configured", async () => {
const settings = { megaCredentials: "", megaPassword: "", debridLinkApiKeys: "" } as unknown as AppSettings;
const result = await checkAllDebridAccounts(settings);
expect(result).toEqual([]);
});
});
it("includes Real-Debrid web login in the bulk account check", async () => {
const settings = {
token: "",
realDebridUseWebLogin: true,
megaCredentials: "",
megaPassword: "",
debridLinkApiKeys: ""
} as AppSettings;
const probe = vi.fn(async () => ({ valid: true, isPremium: true, username: "web-user" }));
const result = await checkAllDebridAccounts(settings, undefined, probe);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
accountId: REAL_DEBRID_STATUS_ID,
provider: "realdebrid",
valid: true
});
});
it("checks every configured mega account + debrid-link key", async () => {
const futureSec = Math.floor(Date.now() / 1000) + 1000;
+10 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { applyAccountCommand, validateAccountCommand } from "../src/main/account-commands";
import { applyAccountCommand, validateAccountCommand, validateAccountCredentialCheckInput } from "../src/main/account-commands";
import { defaultSettings } from "../src/main/constants";
import { getDebridLinkApiKeyId } from "../src/shared/debrid-link-keys";
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
@@ -42,6 +42,15 @@ const ACCOUNT_KINDS: RendererAccountKind[] = [
];
describe("write-only account commands", () => {
it.each(["realdebrid-api", "realdebrid-web"] as const)("accepts %s credential checks at the IPC boundary", (kind) => {
expect(validateAccountCredentialCheckInput({ kind, accountId: "svc-realdebrid" })).toEqual({
kind,
accountId: "svc-realdebrid",
identity: undefined,
secret: undefined
});
});
it.each([
["realdebrid-api", "", "fixture-rd-provider-secret-1fA4", "token"],
["realdebrid-web", "", "", "realDebridUseWebLogin"],
+48 -3
View File
@@ -72,7 +72,7 @@ describe("debrid service", () => {
expect(megaWeb).toHaveBeenCalledTimes(1);
});
it("does not fallback when auto fallback is disabled", async () => {
it("does not fallback when auto fallback is disabled", async () => {
const settings = {
...defaultSettings(),
token: "rd-token",
@@ -102,8 +102,53 @@ describe("debrid service", () => {
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
await expect(service.unrestrictLink("https://rapidgator.net/file/example.part2.rar.html")).rejects.toThrow();
expect(megaWeb).toHaveBeenCalledTimes(0);
});
expect(megaWeb).toHaveBeenCalledTimes(0);
});
it("reports only providers that were actually attempted", async () => {
const megaLogin = "disabled@example.test";
const debridLinkKeys = parseDebridLinkApiKeys("disabled-dl-key");
const settings = {
...defaultSettings(),
token: "rd-token",
megaDebridApiCredentials: `${megaLogin}:password`,
megaDebridApiEnabled: true,
megaDebridApiDisabledAccountIds: [getMegaDebridAccountId(megaLogin)],
debridLinkApiKeys: "disabled-dl-key",
debridLinkApiKeyDailyLimitBytes: { [debridLinkKeys[0].id]: 1 },
debridLinkApiKeyDailyUsageBytes: { [debridLinkKeys[0].id]: 1 },
providerDailyUsageDay: getProviderUsageDayKey(),
providerOrder: ["megadebrid-api", "debridlink", "realdebrid"] as const,
providerPrimary: "megadebrid-api" as const,
providerSecondary: "debridlink" as const,
providerTertiary: "realdebrid" as const,
autoProviderFallback: true
};
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("api.real-debrid.com/rest/1.0/unrestrict/link")) {
return new Response(JSON.stringify({ error: "traffic_exhausted" }), {
status: 429,
headers: { "Content-Type": "application/json" }
});
}
return new Response("not-found", { status: 404 });
}) as typeof fetch;
const service = new DebridService(settings);
let message = "";
try {
await service.unrestrictLink("https://hoster.example/realdebrid-limit.bin");
} catch (error) {
message = String(error);
}
expect(message).toContain("Real-Debrid");
expect(message).toContain("traffic_exhausted");
expect(message).not.toContain("Mega-Debrid nicht verfuegbar");
expect(message).not.toContain("Debrid-Link nicht verfuegbar");
});
it("skips a provider whose daily limit is already reached and uses the next provider", async () => {
const calledUrls: string[] = [];
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { parseDebridLinkTerminalFailure } from "../src/main/download-manager";
describe("provider error classification", () => {
it("does not relabel an aggregated Real-Debrid failure as Debrid-Link", () => {
const message = "Error: Unrestrict fehlgeschlagen: Mega-Debrid nicht verfuegbar (alle aktiven Accounts deaktiviert oder ausgeschopft) | Debrid-Link nicht verfuegbar (alle aktiven API-Keys deaktiviert oder ausgeschopft) | Real-Debrid: traffic_exhausted";
expect(parseDebridLinkTerminalFailure(message)).toBeNull();
});
it("still recognizes a direct Debrid-Link terminal failure", () => {
expect(parseDebridLinkTerminalFailure("Debrid-Link nicht verfuegbar: kein aktiver API-Key")).toMatchObject({
kind: "no_active_key"
});
});
});
+44 -4
View File
@@ -120,7 +120,7 @@ describe("realdebrid-web", () => {
.toBe("ghi789");
});
it("uses the already logged-in browser window to warm the token cache before unrestricting", async () => {
it("uses the already logged-in browser window to warm the token cache before unrestricting", async () => {
const apiFetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({
download: "https://cdn.real-debrid.example/file.bin",
filename: "file.bin",
@@ -160,6 +160,46 @@ describe("realdebrid-web", () => {
expect(mockSessionFetch).not.toHaveBeenCalled();
expect(apiFetch).toHaveBeenCalledTimes(1);
expect(apiFetch.mock.calls[0]?.[0]).toBe("https://api.real-debrid.com/rest/1.0/unrestrict/link");
expect(mockBrowserWindow.webContents.executeJavaScript).toHaveBeenCalled();
});
});
expect(mockBrowserWindow.webContents.executeJavaScript).toHaveBeenCalled();
});
it("checks the logged-in browser account without exposing its token", async () => {
mockExecuteJavaScript.mockResolvedValue("token-from-window");
const apiFetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({
username: "web-user",
email: "web-user@example.test",
type: "premium",
expiration: "2030-01-02T03:04:05.000Z"
}), { status: 200 }));
vi.stubGlobal("fetch", apiFetch);
const fallback = new RealDebridWebFallback(() => true);
await fallback.openLoginWindow();
const status = await fallback.probeLoginState();
expect(status).toEqual({
valid: true,
username: "web-user",
email: "web-user@example.test",
isPremium: true,
premiumUntilMs: Date.parse("2030-01-02T03:04:05.000Z"),
message: "Premium aktiv"
});
expect(JSON.stringify(status)).not.toContain("token-from-window");
expect(apiFetch).toHaveBeenCalledWith(
"https://api.real-debrid.com/rest/1.0/user",
expect.objectContaining({
headers: expect.objectContaining({ Authorization: "Bearer token-from-window" })
})
);
});
it("notifies the controller when a new browser token is detected", async () => {
mockExecuteJavaScript.mockResolvedValue("new-browser-token");
const onAuthenticated = vi.fn();
const fallback = new RealDebridWebFallback(() => true, onAuthenticated);
await fallback.openLoginWindow();
await vi.waitFor(() => expect(onAuthenticated).toHaveBeenCalledTimes(1));
});
});
+27
View File
@@ -44,4 +44,31 @@ describe("live settings overlay", () => {
expect(Object.keys(target.debridAccountStatuses).sort()).toEqual([keepKeyId, keepMegaId].sort());
expect(target.totalRuntimeAllTimeMs).toBe(9_000);
});
it("keeps the Real-Debrid service status while the browser account remains configured", () => {
const target = {
...defaultSettings(),
realDebridUseWebLogin: true
};
const live = {
...target,
debridAccountStatuses: {
"svc-realdebrid": {
accountId: "svc-realdebrid",
provider: "realdebrid" as const,
label: "Real-Debrid",
maskedLogin: "Browser-Login",
valid: true,
isPremium: true,
premiumUntilMs: null,
message: "Premium aktiv",
checkedAt: 1
}
}
};
overlayLiveUsageCounters(target, live, 9_000);
expect(target.debridAccountStatuses["svc-realdebrid"]).toEqual(live.debridAccountStatuses["svc-realdebrid"]);
});
});
+32 -3
View File
@@ -635,7 +635,7 @@ describe("settings storage", () => {
expect(normalized.archivePasswordList).toBe("one\ntwo\nthree");
});
it("defaults Real-Debrid web login to disabled and normalizes the flag", () => {
it("defaults Real-Debrid web login to disabled and normalizes the flag", () => {
expect(defaultSettings().realDebridUseWebLogin).toBe(false);
const normalizedEnabled = normalizeSettings({
@@ -648,8 +648,37 @@ describe("settings storage", () => {
...defaultSettings(),
realDebridUseWebLogin: 0 as unknown as boolean
});
expect(normalizedDisabled.realDebridUseWebLogin).toBe(false);
});
expect(normalizedDisabled.realDebridUseWebLogin).toBe(false);
});
it("keeps the Real-Debrid service status while web login remains configured", () => {
const checkedAt = Date.now();
const normalized = normalizeSettings({
...defaultSettings(),
realDebridUseWebLogin: true,
debridAccountStatuses: {
"svc-realdebrid": {
accountId: "svc-realdebrid",
provider: "realdebrid",
label: "Real-Debrid",
maskedLogin: "Browser-Login",
valid: true,
isPremium: true,
premiumUntilMs: checkedAt + 1000,
email: "web-user",
message: "Premium aktiv",
checkedAt
}
}
});
expect(normalized.debridAccountStatuses["svc-realdebrid"]).toMatchObject({
accountId: "svc-realdebrid",
provider: "realdebrid",
valid: true,
checkedAt
});
});
it("defaults AllDebrid web login to disabled and normalizes the flag", () => {
expect(defaultSettings().allDebridUseWebLogin).toBe(false);