From b80209a10ff7effe07704a0ee7254f656ee7f02e Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Fri, 14 Aug 2026 21:02:30 +0200 Subject: [PATCH] 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. --- CHANGELOG.md | 12 ++ package-lock.json | 4 +- package.json | 2 +- src/main/account-check.ts | 131 ++++++++++++++++++-- src/main/account-commands.ts | 6 +- src/main/app-controller.ts | 54 +++++++- src/main/debrid.ts | 17 +-- src/main/download-manager.ts | 4 +- src/main/realdebrid-web.ts | 107 +++++++++++++--- src/main/settings-live-overlay.ts | 6 +- src/main/storage.ts | 26 ++-- src/renderer/App.tsx | 15 +-- src/shared/types.ts | 8 +- tests/account-check.test.ts | 57 ++++++++- tests/account-commands.test.ts | 11 +- tests/debrid.test.ts | 51 +++++++- tests/provider-error-classification.test.ts | 16 +++ tests/realdebrid-web.test.ts | 48 ++++++- tests/settings-live-overlay.test.ts | 27 ++++ tests/storage.test.ts | 35 +++++- 20 files changed, 556 insertions(+), 81 deletions(-) create mode 100644 tests/provider-error-classification.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d540fd3..31e5a76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/package-lock.json b/package-lock.json index 55310e8..8a1bbce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index c3da90d..9d125a5 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/main/account-check.ts b/src/main/account-check.ts index f26dff0..392eeed 100644 --- a/src/main/account-check.ts +++ b/src/main/account-check.ts @@ -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; function timeoutSignal(signal: AbortSignal | undefined, ms: number): AbortSignal { const timeout = AbortSignal.timeout(ms); @@ -24,7 +38,7 @@ function parseJsonSafe(text: string): Record | 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 { + 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 { +export async function checkAllDebridAccounts( + settings: AppSettings, + signal?: AbortSignal, + probeRealDebridWebSession?: RealDebridSessionProbe +): Promise { const now = Date.now(); const megaAccounts = parseMegaDebridAccounts(settings.megaCredentials || "", settings.megaPassword || ""); const debridLinkKeys = parseDebridLinkApiKeys(settings.debridLinkApiKeys || ""); - const taskFns: Array<() => Promise> = [ + const taskFns: Array<() => Promise> = [ + ...(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)) ]; diff --git a/src/main/account-commands.ts b/src/main/account-commands.ts index 68e8140..a05a762 100644 --- a/src/main/account-commands.ts +++ b/src/main/account-commands.ts @@ -121,7 +121,11 @@ export function validateAccountCredentialCheckInput(value: unknown): AccountCred if (!value || typeof value !== "object" || Array.isArray(value)) invalid(); const raw = value as Record; 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), diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index 45d8ba9..72d934d 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -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 { 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 { + public async openRealDebridLoginWindow(): Promise { this.audit("INFO", "Real-Debrid Login-Fenster geöffnet"); - await this.realDebridWebFallback.openLoginWindow(); - } + await this.realDebridWebFallback.openLoginWindow(); + } + + private async refreshRealDebridWebStatus(): Promise { + 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 { this.audit("INFO", "AllDebrid Login-Fenster geöffnet"); @@ -611,7 +649,11 @@ export class AppController { public async checkDebridAccounts(): Promise { const statuses = sanitizeDebridAccountStatuses( - await checkAllDebridAccounts(this.settings), + await checkAllDebridAccounts( + this.settings, + undefined, + (signal) => this.realDebridWebFallback.probeLoginState(signal) + ), collectAccountStatusRedactionValues(this.settings) ); this.manager.applyDebridAccountStatuses(statuses); diff --git a/src/main/debrid.ts b/src/main/debrid.ts index 57ee98d..2304959 100644 --- a/src/main/debrid.ts +++ b/src/main/debrid.ts @@ -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(" | ")}`); } diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 4b2b755..5ab9fd1 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -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() diff --git a/src/main/realdebrid-web.ts b/src/main/realdebrid-web.ts index 7732246..4cfc7a4 100644 --- a/src/main/realdebrid-web.ts +++ b/src/main/realdebrid-web.ts @@ -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 { @@ -138,15 +155,69 @@ export class RealDebridWebFallback { }, overallSignal); } - public async openLoginWindow(): Promise { + public async openLoginWindow(): Promise { 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 { + 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 { 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; diff --git a/src/main/settings-live-overlay.ts b/src/main/settings-live-overlay.ts index 8658294..995f6b1 100644 --- a/src/main/settings-live-overlay.ts +++ b/src/main/settings-live-overlay.ts @@ -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); diff --git a/src/main/storage.ts b/src/main/storage.ts index 42bfc23..7f62366 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -266,12 +266,13 @@ function normalizeNamedByteMap(raw: unknown, allowedKeys: readonly string[]): Re return result; } -function normalizeDebridAccountStatuses( - value: unknown, - megaIds: string[], - debridLinkIds: string[] -): Record { - const allowed = new Set([...megaIds, ...debridLinkIds]); +function normalizeDebridAccountStatuses( + value: unknown, + megaIds: string[], + debridLinkIds: string[], + realDebridConfigured: boolean +): Record { + const allowed = new Set([...megaIds, ...debridLinkIds, ...(realDebridConfigured ? ["svc-realdebrid"] : [])]); const result: Record = {}; if (value && typeof value === "object" && !Array.isArray(value)) { for (const [key, raw] of Object.entries(value as Record)) { @@ -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) }; diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 4e65e4c..dce63d8 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -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; } diff --git a/src/shared/types.ts b/src/shared/types.ts index 6da3f02..6376d2e 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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; diff --git a/tests/account-check.test.ts b/tests/account-check.test.ts index 441a51c..e3ec3df 100644 --- a/tests/account-check.test.ts +++ b/tests/account-check.test.ts @@ -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; diff --git a/tests/account-commands.test.ts b/tests/account-commands.test.ts index c68102c..c67dfed 100644 --- a/tests/account-commands.test.ts +++ b/tests/account-commands.test.ts @@ -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"], diff --git a/tests/debrid.test.ts b/tests/debrid.test.ts index 48a10e6..f65d4a9 100644 --- a/tests/debrid.test.ts +++ b/tests/debrid.test.ts @@ -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 => { + 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[] = []; diff --git a/tests/provider-error-classification.test.ts b/tests/provider-error-classification.test.ts new file mode 100644 index 0000000..05c5aba --- /dev/null +++ b/tests/provider-error-classification.test.ts @@ -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" + }); + }); +}); diff --git a/tests/realdebrid-web.test.ts b/tests/realdebrid-web.test.ts index 46d2b46..f1089ea 100644 --- a/tests/realdebrid-web.test.ts +++ b/tests/realdebrid-web.test.ts @@ -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)); + }); +}); diff --git a/tests/settings-live-overlay.test.ts b/tests/settings-live-overlay.test.ts index 9143f48..7895cc0 100644 --- a/tests/settings-live-overlay.test.ts +++ b/tests/settings-live-overlay.test.ts @@ -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"]); + }); }); diff --git a/tests/storage.test.ts b/tests/storage.test.ts index da74c44..65272b7 100644 --- a/tests/storage.test.ts +++ b/tests/storage.test.ts @@ -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);