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:
@@ -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
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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",
|
||||
|
||||
+114
-1
@@ -6,10 +6,24 @@ 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 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;
|
||||
|
||||
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);
|
||||
return signal ? AbortSignal.any([signal, timeout]) : timeout;
|
||||
@@ -43,6 +57,101 @@ function formatRemaining(premiumUntilMs: number | null, now: number): string {
|
||||
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,
|
||||
signal?: AbortSignal,
|
||||
@@ -160,13 +269,17 @@ export async function checkDebridLinkKey(
|
||||
|
||||
export async function checkAllDebridAccounts(
|
||||
settings: AppSettings,
|
||||
signal?: AbortSignal
|
||||
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>> = [
|
||||
...(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))
|
||||
];
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
@@ -580,6 +602,22 @@ export class AppController {
|
||||
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");
|
||||
await this.allDebridWebFallback.openLoginWindow();
|
||||
@@ -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);
|
||||
|
||||
+6
-5
@@ -3964,8 +3964,8 @@ export class DebridService {
|
||||
}
|
||||
|
||||
let configuredFound = false;
|
||||
let limitReachedFound = false;
|
||||
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));
|
||||
unavailableProviders.push(this.formatProviderLimitMessage(settings, provider));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -4024,8 +4023,10 @@ 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(" | ")}`);
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -8,6 +8,7 @@ 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_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";
|
||||
@@ -16,6 +17,19 @@ 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");
|
||||
}
|
||||
@@ -118,8 +132,11 @@ export class RealDebridWebFallback {
|
||||
|
||||
private getRememberSession: () => boolean;
|
||||
|
||||
public constructor(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> {
|
||||
@@ -148,6 +165,60 @@ export class RealDebridWebFallback {
|
||||
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();
|
||||
this.cachedToken = "";
|
||||
@@ -248,8 +319,12 @@ export class RealDebridWebFallback {
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
+14
-4
@@ -269,9 +269,10 @@ function normalizeNamedByteMap(raw: unknown, allowedKeys: readonly string[]): Re
|
||||
function normalizeDebridAccountStatuses(
|
||||
value: unknown,
|
||||
megaIds: string[],
|
||||
debridLinkIds: string[]
|
||||
debridLinkIds: string[],
|
||||
realDebridConfigured: boolean
|
||||
): Record<string, DebridAccountStatus> {
|
||||
const allowed = new Set([...megaIds, ...debridLinkIds]);
|
||||
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)
|
||||
};
|
||||
|
||||
@@ -2360,6 +2360,7 @@ export function App(): ReactElement {
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const serviceAccountId = entry.service === "realdebrid" ? "svc-realdebrid" : null;
|
||||
rows.push({
|
||||
rowKey: `svc-${entry.service}`,
|
||||
entry,
|
||||
@@ -2367,8 +2368,8 @@ export function App(): ReactElement {
|
||||
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,
|
||||
@@ -2654,7 +2655,7 @@ export function App(): ReactElement {
|
||||
try {
|
||||
const statuses = await window.rd.checkDebridAccounts();
|
||||
if (!statuses || statuses.length === 0) {
|
||||
showToast("Keine Mega-Debrid-/Debrid-Link-Accounts zum Prüfen konfiguriert.", 3200);
|
||||
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;
|
||||
}
|
||||
|
||||
+2
-2
@@ -57,7 +57,7 @@ export interface DownloadStats {
|
||||
|
||||
export interface DebridAccountStatus {
|
||||
accountId: string;
|
||||
provider: "megadebrid" | "debridlink";
|
||||
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;
|
||||
|
||||
@@ -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";
|
||||
@@ -110,6 +110,33 @@ describe("checkDebridLinkKey", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const settings = { megaCredentials: "", megaPassword: "", debridLinkApiKeys: "" } as unknown as AppSettings;
|
||||
@@ -117,6 +144,26 @@ describe("checkAllDebridAccounts", () => {
|
||||
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;
|
||||
vi.stubGlobal("fetch", vi.fn(async (url: string) => {
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -105,6 +105,51 @@ describe("debrid service", () => {
|
||||
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[] = [];
|
||||
const settings = {
|
||||
|
||||
@@ -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"
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -162,4 +162,44 @@ describe("realdebrid-web", () => {
|
||||
expect(apiFetch.mock.calls[0]?.[0]).toBe("https://api.real-debrid.com/rest/1.0/unrestrict/link");
|
||||
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));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -651,6 +651,35 @@ describe("settings storage", () => {
|
||||
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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user