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.
|
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
|
## [2.0.36] - 2026-08-14
|
||||||
|
|
||||||
### Download performance
|
### Download performance
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "2.0.36",
|
"version": "2.0.37",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "2.0.36",
|
"version": "2.0.37",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"adm-zip": "0.6.0",
|
"adm-zip": "0.6.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "2.0.36",
|
"version": "2.0.37",
|
||||||
"description": "Desktop downloader",
|
"description": "Desktop downloader",
|
||||||
"main": "build/main/main/main.js",
|
"main": "build/main/main/main.js",
|
||||||
"author": "Sucukdeluxe",
|
"author": "Sucukdeluxe",
|
||||||
|
|||||||
+114
-1
@@ -6,10 +6,24 @@ import { compactErrorText } from "./utils";
|
|||||||
|
|
||||||
const MEGA_DEBRID_API = "https://www.mega-debrid.eu/api.php";
|
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 =
|
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";
|
"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 {
|
function timeoutSignal(signal: AbortSignal | undefined, ms: number): AbortSignal {
|
||||||
const timeout = AbortSignal.timeout(ms);
|
const timeout = AbortSignal.timeout(ms);
|
||||||
return signal ? AbortSignal.any([signal, timeout]) : timeout;
|
return signal ? AbortSignal.any([signal, timeout]) : timeout;
|
||||||
@@ -43,6 +57,101 @@ function formatRemaining(premiumUntilMs: number | null, now: number): string {
|
|||||||
return `Premium noch ${hours} Std`;
|
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(
|
export async function checkMegaDebridAccount(
|
||||||
account: MegaDebridAccountEntry,
|
account: MegaDebridAccountEntry,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
@@ -160,13 +269,17 @@ export async function checkDebridLinkKey(
|
|||||||
|
|
||||||
export async function checkAllDebridAccounts(
|
export async function checkAllDebridAccounts(
|
||||||
settings: AppSettings,
|
settings: AppSettings,
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal,
|
||||||
|
probeRealDebridWebSession?: RealDebridSessionProbe
|
||||||
): Promise<DebridAccountStatus[]> {
|
): Promise<DebridAccountStatus[]> {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const megaAccounts = parseMegaDebridAccounts(settings.megaCredentials || "", settings.megaPassword || "");
|
const megaAccounts = parseMegaDebridAccounts(settings.megaCredentials || "", settings.megaPassword || "");
|
||||||
const debridLinkKeys = parseDebridLinkApiKeys(settings.debridLinkApiKeys || "");
|
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)),
|
...megaAccounts.map((account) => () => checkMegaDebridAccount(account, signal, now)),
|
||||||
...debridLinkKeys.map((key) => () => checkDebridLinkKey(key, 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();
|
if (!value || typeof value !== "object" || Array.isArray(value)) invalid();
|
||||||
const raw = value as Record<string, unknown>;
|
const raw = value as Record<string, unknown>;
|
||||||
if (Object.keys(raw).some((key) => !new Set(["kind", "accountId", "identity", "secret"]).has(key))) invalid();
|
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 {
|
return {
|
||||||
kind: raw.kind,
|
kind: raw.kind,
|
||||||
accountId: optionalString(raw.accountId, 256),
|
accountId: optionalString(raw.accountId, 256),
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import { importDlcContainers } from "./container";
|
|||||||
import { APP_VERSION, ONLINE_BACKUP_API_URL } from "./constants";
|
import { APP_VERSION, ONLINE_BACKUP_API_URL } from "./constants";
|
||||||
import { DownloadManager } from "./download-manager";
|
import { DownloadManager } from "./download-manager";
|
||||||
import { fetchAllDebridHostInfo, fetchDebridLinkHostLimits } from "./debrid";
|
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 { parseMegaDebridAccounts } from "../shared/mega-debrid-accounts";
|
||||||
import { getMegaDebridAccountsForMode } from "../shared/mega-debrid-accounts";
|
import { getMegaDebridAccountsForMode } from "../shared/mega-debrid-accounts";
|
||||||
import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
|
import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
|
||||||
@@ -133,7 +133,10 @@ export class AppController {
|
|||||||
login: this.settings.megaLogin,
|
login: this.settings.megaLogin,
|
||||||
password: this.settings.megaPassword
|
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.allDebridWebFallback = new AllDebridWebFallback(() => this.settings.rememberToken);
|
||||||
this.bestDebridWebFallback = new BestDebridWebFallback(() => this.settings.rememberToken);
|
this.bestDebridWebFallback = new BestDebridWebFallback(() => this.settings.rememberToken);
|
||||||
this.manager = new DownloadManager(this.settings, session, this.storagePaths, {
|
this.manager = new DownloadManager(this.settings, session, this.storagePaths, {
|
||||||
@@ -532,6 +535,25 @@ export class AppController {
|
|||||||
|
|
||||||
public async checkAccountCredentials(input: AccountCredentialCheckInput): Promise<DebridAccountStatus> {
|
public async checkAccountCredentials(input: AccountCredentialCheckInput): Promise<DebridAccountStatus> {
|
||||||
const redactions = collectAccountStatusRedactionValues(this.settings, input);
|
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") {
|
if (input.kind === "megadebrid-api" || input.kind === "megadebrid-web") {
|
||||||
const mode = input.kind === "megadebrid-web" ? "web" : "api";
|
const mode = input.kind === "megadebrid-web" ? "web" : "api";
|
||||||
const account = input.identity?.trim() && input.secret
|
const account = input.identity?.trim() && input.secret
|
||||||
@@ -580,6 +602,22 @@ export class AppController {
|
|||||||
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> {
|
public async openAllDebridLoginWindow(): Promise<void> {
|
||||||
this.audit("INFO", "AllDebrid Login-Fenster geöffnet");
|
this.audit("INFO", "AllDebrid Login-Fenster geöffnet");
|
||||||
await this.allDebridWebFallback.openLoginWindow();
|
await this.allDebridWebFallback.openLoginWindow();
|
||||||
@@ -611,7 +649,11 @@ export class AppController {
|
|||||||
|
|
||||||
public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
|
public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
|
||||||
const statuses = sanitizeDebridAccountStatuses(
|
const statuses = sanitizeDebridAccountStatuses(
|
||||||
await checkAllDebridAccounts(this.settings),
|
await checkAllDebridAccounts(
|
||||||
|
this.settings,
|
||||||
|
undefined,
|
||||||
|
(signal) => this.realDebridWebFallback.probeLoginState(signal)
|
||||||
|
),
|
||||||
collectAccountStatusRedactionValues(this.settings)
|
collectAccountStatusRedactionValues(this.settings)
|
||||||
);
|
);
|
||||||
this.manager.applyDebridAccountStatuses(statuses);
|
this.manager.applyDebridAccountStatuses(statuses);
|
||||||
|
|||||||
+6
-5
@@ -3964,8 +3964,8 @@ export class DebridService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let configuredFound = false;
|
let configuredFound = false;
|
||||||
let limitReachedFound = false;
|
|
||||||
const attempts: string[] = [];
|
const attempts: string[] = [];
|
||||||
|
const unavailableProviders: string[] = [];
|
||||||
|
|
||||||
for (const provider of order) {
|
for (const provider of order) {
|
||||||
if (!this.isProviderConfiguredFor(settings, provider)) {
|
if (!this.isProviderConfiguredFor(settings, provider)) {
|
||||||
@@ -3973,9 +3973,8 @@ export class DebridService {
|
|||||||
}
|
}
|
||||||
configuredFound = true;
|
configuredFound = true;
|
||||||
if (this.isProviderDailyLimited(settings, provider)) {
|
if (this.isProviderDailyLimited(settings, provider)) {
|
||||||
limitReachedFound = true;
|
|
||||||
logger.info(`Provider-Kette: ${PROVIDER_LABELS[provider]} uebersprungen (${this.formatProviderLimitMessage(settings, provider)})`);
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4024,8 +4023,10 @@ export class DebridService {
|
|||||||
if (!configuredFound) {
|
if (!configuredFound) {
|
||||||
throw new Error("Kein Debrid-Provider konfiguriert");
|
throw new Error("Kein Debrid-Provider konfiguriert");
|
||||||
}
|
}
|
||||||
if (limitReachedFound && attempts.every((entry) => /Tageslimit erreicht$/i.test(entry))) {
|
if (attempts.length === 0 && unavailableProviders.length > 0) {
|
||||||
throw new Error("Alle konfigurierten Provider haben ihr Tageslimit erreicht");
|
throw new Error(unavailableProviders.length === 1
|
||||||
|
? unavailableProviders[0]
|
||||||
|
: `Alle konfigurierten Provider sind nicht verfügbar: ${unavailableProviders.join(" | ")}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(`Unrestrict fehlgeschlagen: ${attempts.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() };
|
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 raw = String(errorText || "");
|
||||||
const match = raw.match(/debrid_link_(invalid_all|no_active_key):(.*)$/i);
|
const match = raw.match(/debrid_link_(invalid_all|no_active_key):(.*)$/i);
|
||||||
if (!match) {
|
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 {
|
return {
|
||||||
kind: "no_active_key",
|
kind: "no_active_key",
|
||||||
detail: raw.trim()
|
detail: raw.trim()
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const RD_BASE_URL = "https://real-debrid.com";
|
|||||||
const RD_LOGIN_URL = RD_BASE_URL;
|
const RD_LOGIN_URL = RD_BASE_URL;
|
||||||
const RD_APITOKEN_URL = `${RD_BASE_URL}/apitoken`;
|
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_PERSISTENT_PARTITION = "persist:realdebrid-web";
|
||||||
const RD_TRANSIENT_PARTITION = "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";
|
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: "success"; value: UnrestrictedLink }
|
||||||
| { kind: "login_required" };
|
| { 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 {
|
function abortError(): Error {
|
||||||
return new Error("aborted:realdebrid-web");
|
return new Error("aborted:realdebrid-web");
|
||||||
}
|
}
|
||||||
@@ -118,8 +132,11 @@ export class RealDebridWebFallback {
|
|||||||
|
|
||||||
private getRememberSession: () => boolean;
|
private getRememberSession: () => boolean;
|
||||||
|
|
||||||
public constructor(getRememberSession: () => boolean) {
|
private onAuthenticated?: () => void;
|
||||||
|
|
||||||
|
public constructor(getRememberSession: () => boolean, onAuthenticated?: () => void) {
|
||||||
this.getRememberSession = getRememberSession;
|
this.getRememberSession = getRememberSession;
|
||||||
|
this.onAuthenticated = onAuthenticated;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async unrestrict(link: string, signal?: AbortSignal): Promise<UnrestrictedLink | null> {
|
public async unrestrict(link: string, signal?: AbortSignal): Promise<UnrestrictedLink | null> {
|
||||||
@@ -148,6 +165,60 @@ export class RealDebridWebFallback {
|
|||||||
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> {
|
public async clearSessions(): Promise<void> {
|
||||||
this.disposeLoginWindow();
|
this.disposeLoginWindow();
|
||||||
this.cachedToken = "";
|
this.cachedToken = "";
|
||||||
@@ -248,8 +319,12 @@ export class RealDebridWebFallback {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private rememberToken(token: string): string {
|
private rememberToken(token: string): string {
|
||||||
|
const changed = token !== this.cachedToken;
|
||||||
this.cachedToken = token;
|
this.cachedToken = token;
|
||||||
this.cachedTokenAt = Date.now();
|
this.cachedTokenAt = Date.now();
|
||||||
|
if (changed && this.onAuthenticated) {
|
||||||
|
void Promise.resolve().then(() => this.onAuthenticated?.());
|
||||||
|
}
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,11 @@ import type { AppSettings } from "../shared/types";
|
|||||||
export function overlayLiveUsageCounters(target: AppSettings, liveSettings: AppSettings, liveTotalRuntimeMs: number): void {
|
export function overlayLiveUsageCounters(target: AppSettings, liveSettings: AppSettings, liveTotalRuntimeMs: number): void {
|
||||||
const debridLinkKeyIds = new Set(getDebridLinkApiKeyIds(target.debridLinkApiKeys));
|
const debridLinkKeyIds = new Set(getDebridLinkApiKeyIds(target.debridLinkApiKeys));
|
||||||
const megaAccountIds = new Set(getMegaDebridAccountIds(mergeMegaDebridCredentialPools(target.megaDebridApiCredentials || "", target.megaDebridWebCredentials || "") || target.megaCredentials || "", target.megaPassword || ""));
|
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.totalDownloadedAllTime = Math.max(target.totalDownloadedAllTime || 0, liveSettings.totalDownloadedAllTime || 0);
|
||||||
target.totalCompletedFilesAllTime = Math.max(target.totalCompletedFilesAllTime || 0, liveSettings.totalCompletedFilesAllTime || 0);
|
target.totalCompletedFilesAllTime = Math.max(target.totalCompletedFilesAllTime || 0, liveSettings.totalCompletedFilesAllTime || 0);
|
||||||
target.totalRuntimeAllTimeMs = Math.max(target.totalRuntimeAllTimeMs || 0, liveTotalRuntimeMs);
|
target.totalRuntimeAllTimeMs = Math.max(target.totalRuntimeAllTimeMs || 0, liveTotalRuntimeMs);
|
||||||
|
|||||||
+14
-4
@@ -269,9 +269,10 @@ function normalizeNamedByteMap(raw: unknown, allowedKeys: readonly string[]): Re
|
|||||||
function normalizeDebridAccountStatuses(
|
function normalizeDebridAccountStatuses(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
megaIds: string[],
|
megaIds: string[],
|
||||||
debridLinkIds: string[]
|
debridLinkIds: string[],
|
||||||
|
realDebridConfigured: boolean
|
||||||
): Record<string, DebridAccountStatus> {
|
): Record<string, DebridAccountStatus> {
|
||||||
const allowed = new Set([...megaIds, ...debridLinkIds]);
|
const allowed = new Set([...megaIds, ...debridLinkIds, ...(realDebridConfigured ? ["svc-realdebrid"] : [])]);
|
||||||
const result: Record<string, DebridAccountStatus> = {};
|
const result: Record<string, DebridAccountStatus> = {};
|
||||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||||
for (const [key, raw] of Object.entries(value as Record<string, unknown>)) {
|
for (const [key, raw] of Object.entries(value as Record<string, unknown>)) {
|
||||||
@@ -284,7 +285,11 @@ function normalizeDebridAccountStatuses(
|
|||||||
}
|
}
|
||||||
result[key] = {
|
result[key] = {
|
||||||
accountId: entry.accountId,
|
accountId: entry.accountId,
|
||||||
provider: entry.provider === "debridlink" ? "debridlink" : "megadebrid",
|
provider: entry.provider === "debridlink"
|
||||||
|
? "debridlink"
|
||||||
|
: entry.provider === "realdebrid"
|
||||||
|
? "realdebrid"
|
||||||
|
: "megadebrid",
|
||||||
label: String(entry.label || ""),
|
label: String(entry.label || ""),
|
||||||
maskedLogin: String(entry.maskedLogin || ""),
|
maskedLogin: String(entry.maskedLogin || ""),
|
||||||
valid: Boolean(entry.valid),
|
valid: Boolean(entry.valid),
|
||||||
@@ -570,7 +575,12 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
|
|||||||
? normalizeNamedByteMap(settings.megaDebridAccountDailyUsageBytes, megaDebridAccountIds)
|
? normalizeNamedByteMap(settings.megaDebridAccountDailyUsageBytes, megaDebridAccountIds)
|
||||||
: {},
|
: {},
|
||||||
megaDebridAccountTotalUsageBytes: normalizeNamedByteMap(settings.megaDebridAccountTotalUsageBytes, 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,
|
providerDailyUsageDay: providerDailyUsageDay === currentUsageDay ? providerDailyUsageDay : currentUsageDay,
|
||||||
scheduledStartEpochMs: clampNumber(settings.scheduledStartEpochMs, defaults.scheduledStartEpochMs, 0, Number.MAX_SAFE_INTEGER)
|
scheduledStartEpochMs: clampNumber(settings.scheduledStartEpochMs, defaults.scheduledStartEpochMs, 0, Number.MAX_SAFE_INTEGER)
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2360,6 +2360,7 @@ export function App(): ReactElement {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
const serviceAccountId = entry.service === "realdebrid" ? "svc-realdebrid" : null;
|
||||||
rows.push({
|
rows.push({
|
||||||
rowKey: `svc-${entry.service}`,
|
rowKey: `svc-${entry.service}`,
|
||||||
entry,
|
entry,
|
||||||
@@ -2367,8 +2368,8 @@ export function App(): ReactElement {
|
|||||||
modeLabel: entry.modeLabel,
|
modeLabel: entry.modeLabel,
|
||||||
username: getStoredAccountUsername(entry.kind, snapshot.accounts),
|
username: getStoredAccountUsername(entry.kind, snapshot.accounts),
|
||||||
credentialLabel: getAccountCredentialLabel(entry.kind),
|
credentialLabel: getAccountCredentialLabel(entry.kind),
|
||||||
accountId: null,
|
accountId: serviceAccountId,
|
||||||
checkable: false,
|
checkable: serviceAccountId !== null,
|
||||||
disabled: entry.disabled,
|
disabled: entry.disabled,
|
||||||
dailyUsedBytes: entry.dailyUsedBytes,
|
dailyUsedBytes: entry.dailyUsedBytes,
|
||||||
dailyLimitBytes: entry.dailyLimitBytes,
|
dailyLimitBytes: entry.dailyLimitBytes,
|
||||||
@@ -2654,7 +2655,7 @@ export function App(): ReactElement {
|
|||||||
try {
|
try {
|
||||||
const statuses = await window.rd.checkDebridAccounts();
|
const statuses = await window.rd.checkDebridAccounts();
|
||||||
if (!statuses || statuses.length === 0) {
|
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 {
|
} else {
|
||||||
const valid = statuses.filter((st) => st.valid).length;
|
const valid = statuses.filter((st) => st.valid).length;
|
||||||
const premium = statuses.filter((st) => st.isPremium).length;
|
const premium = statuses.filter((st) => st.isPremium).length;
|
||||||
@@ -2941,7 +2942,7 @@ export function App(): ReactElement {
|
|||||||
|
|
||||||
const checkAccountTableRow = (row: AccountTableRow): void => {
|
const checkAccountTableRow = (row: AccountTableRow): void => {
|
||||||
setAccountContextMenu(null);
|
setAccountContextMenu(null);
|
||||||
if (row.toggleKind === "mega" || row.toggleKind === "dl") {
|
if (row.checkable) {
|
||||||
void checkAllAccounts();
|
void checkAllAccounts();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -57,7 +57,7 @@ export interface DownloadStats {
|
|||||||
|
|
||||||
export interface DebridAccountStatus {
|
export interface DebridAccountStatus {
|
||||||
accountId: string;
|
accountId: string;
|
||||||
provider: "megadebrid" | "debridlink";
|
provider: DebridProvider;
|
||||||
label: string;
|
label: string;
|
||||||
maskedLogin: string;
|
maskedLogin: string;
|
||||||
valid: boolean;
|
valid: boolean;
|
||||||
@@ -337,7 +337,7 @@ export interface AccountCommandResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface AccountCredentialCheckInput {
|
export interface AccountCredentialCheckInput {
|
||||||
kind: "megadebrid-api" | "megadebrid-web" | "debridlink-api";
|
kind: "realdebrid-api" | "realdebrid-web" | "megadebrid-api" | "megadebrid-web" | "debridlink-api";
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
identity?: string;
|
identity?: string;
|
||||||
secret?: string;
|
secret?: string;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
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 { MegaDebridAccountEntry } from "../src/shared/mega-debrid-accounts";
|
||||||
import type { DebridLinkApiKeyEntry } from "../src/shared/debrid-link-keys";
|
import type { DebridLinkApiKeyEntry } from "../src/shared/debrid-link-keys";
|
||||||
import type { AppSettings } from "../src/shared/types";
|
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", () => {
|
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 settings = { megaCredentials: "", megaPassword: "", debridLinkApiKeys: "" } as unknown as AppSettings;
|
||||||
@@ -117,6 +144,26 @@ describe("checkAllDebridAccounts", () => {
|
|||||||
expect(result).toEqual([]);
|
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 () => {
|
it("checks every configured mega account + debrid-link key", async () => {
|
||||||
const futureSec = Math.floor(Date.now() / 1000) + 1000;
|
const futureSec = Math.floor(Date.now() / 1000) + 1000;
|
||||||
vi.stubGlobal("fetch", vi.fn(async (url: string) => {
|
vi.stubGlobal("fetch", vi.fn(async (url: string) => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
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 { defaultSettings } from "../src/main/constants";
|
||||||
import { getDebridLinkApiKeyId } from "../src/shared/debrid-link-keys";
|
import { getDebridLinkApiKeyId } from "../src/shared/debrid-link-keys";
|
||||||
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
|
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
|
||||||
@@ -42,6 +42,15 @@ const ACCOUNT_KINDS: RendererAccountKind[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
describe("write-only account commands", () => {
|
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([
|
it.each([
|
||||||
["realdebrid-api", "", "fixture-rd-provider-secret-1fA4", "token"],
|
["realdebrid-api", "", "fixture-rd-provider-secret-1fA4", "token"],
|
||||||
["realdebrid-web", "", "", "realDebridUseWebLogin"],
|
["realdebrid-web", "", "", "realDebridUseWebLogin"],
|
||||||
|
|||||||
@@ -105,6 +105,51 @@ describe("debrid service", () => {
|
|||||||
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 () => {
|
it("skips a provider whose daily limit is already reached and uses the next provider", async () => {
|
||||||
const calledUrls: string[] = [];
|
const calledUrls: string[] = [];
|
||||||
const settings = {
|
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(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));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -44,4 +44,31 @@ describe("live settings overlay", () => {
|
|||||||
expect(Object.keys(target.debridAccountStatuses).sort()).toEqual([keepKeyId, keepMegaId].sort());
|
expect(Object.keys(target.debridAccountStatuses).sort()).toEqual([keepKeyId, keepMegaId].sort());
|
||||||
expect(target.totalRuntimeAllTimeMs).toBe(9_000);
|
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);
|
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", () => {
|
it("defaults AllDebrid web login to disabled and normalizes the flag", () => {
|
||||||
expect(defaultSettings().allDebridUseWebLogin).toBe(false);
|
expect(defaultSettings().allDebridUseWebLogin).toBe(false);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user