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

Check Real-Debrid API and browser sessions through the account status flow, retain the service status across settings updates, and refresh the account row when a browser login is detected. Exclude unavailable providers that were never attempted from conversion failures and prevent aggregated fallback text from being mislabeled as a Debrid-Link terminal error. Bump the development version to 2.0.37 and add focused regression coverage.
This commit is contained in:
Sucukdeluxe
2026-08-14 21:02:30 +02:00
parent c2d5dbaeb3
commit b80209a10f
20 changed files with 556 additions and 81 deletions
+52 -5
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { checkMegaDebridAccount, checkDebridLinkKey, checkAllDebridAccounts } from "../src/main/account-check";
import { checkMegaDebridAccount, checkDebridLinkKey, checkAllDebridAccounts, checkRealDebridAccount, REAL_DEBRID_STATUS_ID } from "../src/main/account-check";
import type { MegaDebridAccountEntry } from "../src/shared/mega-debrid-accounts";
import type { DebridLinkApiKeyEntry } from "../src/shared/debrid-link-keys";
import type { AppSettings } from "../src/shared/types";
@@ -78,7 +78,7 @@ describe("checkMegaDebridAccount", () => {
});
});
describe("checkDebridLinkKey", () => {
describe("checkDebridLinkKey", () => {
it("reports valid + premium from premiumLeft seconds", async () => {
const premiumLeft = 60 * 24 * 60 * 60;
mockFetchOnce(200, { success: true, value: { username: "u", accountType: 1, premiumLeft } });
@@ -108,14 +108,61 @@ describe("checkDebridLinkKey", () => {
const st = await checkDebridLinkKey(debridLinkKey(), undefined, NOW);
expect(st.valid).toBe(false);
});
});
});
describe("checkRealDebridAccount", () => {
it("uses the browser-session probe and returns a stable service status", async () => {
const premiumUntilMs = NOW + 30 * 24 * 60 * 60 * 1000;
const probe = vi.fn(async () => ({
valid: true,
isPremium: true,
premiumUntilMs,
username: "web-user"
}));
const status = await checkRealDebridAccount({
token: "",
realDebridUseWebLogin: true
} as AppSettings, undefined, NOW, probe);
expect(probe).toHaveBeenCalledTimes(1);
expect(status).toMatchObject({
accountId: REAL_DEBRID_STATUS_ID,
provider: "realdebrid",
valid: true,
isPremium: true,
premiumUntilMs,
email: "web-user"
});
});
});
describe("checkAllDebridAccounts", () => {
it("returns empty array when nothing configured", async () => {
it("returns empty array when nothing configured", async () => {
const settings = { megaCredentials: "", megaPassword: "", debridLinkApiKeys: "" } as unknown as AppSettings;
const result = await checkAllDebridAccounts(settings);
expect(result).toEqual([]);
});
});
it("includes Real-Debrid web login in the bulk account check", async () => {
const settings = {
token: "",
realDebridUseWebLogin: true,
megaCredentials: "",
megaPassword: "",
debridLinkApiKeys: ""
} as AppSettings;
const probe = vi.fn(async () => ({ valid: true, isPremium: true, username: "web-user" }));
const result = await checkAllDebridAccounts(settings, undefined, probe);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
accountId: REAL_DEBRID_STATUS_ID,
provider: "realdebrid",
valid: true
});
});
it("checks every configured mega account + debrid-link key", async () => {
const futureSec = Math.floor(Date.now() / 1000) + 1000;
+10 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { applyAccountCommand, validateAccountCommand } from "../src/main/account-commands";
import { applyAccountCommand, validateAccountCommand, validateAccountCredentialCheckInput } from "../src/main/account-commands";
import { defaultSettings } from "../src/main/constants";
import { getDebridLinkApiKeyId } from "../src/shared/debrid-link-keys";
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
@@ -42,6 +42,15 @@ const ACCOUNT_KINDS: RendererAccountKind[] = [
];
describe("write-only account commands", () => {
it.each(["realdebrid-api", "realdebrid-web"] as const)("accepts %s credential checks at the IPC boundary", (kind) => {
expect(validateAccountCredentialCheckInput({ kind, accountId: "svc-realdebrid" })).toEqual({
kind,
accountId: "svc-realdebrid",
identity: undefined,
secret: undefined
});
});
it.each([
["realdebrid-api", "", "fixture-rd-provider-secret-1fA4", "token"],
["realdebrid-web", "", "", "realDebridUseWebLogin"],
+48 -3
View File
@@ -72,7 +72,7 @@ describe("debrid service", () => {
expect(megaWeb).toHaveBeenCalledTimes(1);
});
it("does not fallback when auto fallback is disabled", async () => {
it("does not fallback when auto fallback is disabled", async () => {
const settings = {
...defaultSettings(),
token: "rd-token",
@@ -102,8 +102,53 @@ describe("debrid service", () => {
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
await expect(service.unrestrictLink("https://rapidgator.net/file/example.part2.rar.html")).rejects.toThrow();
expect(megaWeb).toHaveBeenCalledTimes(0);
});
expect(megaWeb).toHaveBeenCalledTimes(0);
});
it("reports only providers that were actually attempted", async () => {
const megaLogin = "disabled@example.test";
const debridLinkKeys = parseDebridLinkApiKeys("disabled-dl-key");
const settings = {
...defaultSettings(),
token: "rd-token",
megaDebridApiCredentials: `${megaLogin}:password`,
megaDebridApiEnabled: true,
megaDebridApiDisabledAccountIds: [getMegaDebridAccountId(megaLogin)],
debridLinkApiKeys: "disabled-dl-key",
debridLinkApiKeyDailyLimitBytes: { [debridLinkKeys[0].id]: 1 },
debridLinkApiKeyDailyUsageBytes: { [debridLinkKeys[0].id]: 1 },
providerDailyUsageDay: getProviderUsageDayKey(),
providerOrder: ["megadebrid-api", "debridlink", "realdebrid"] as const,
providerPrimary: "megadebrid-api" as const,
providerSecondary: "debridlink" as const,
providerTertiary: "realdebrid" as const,
autoProviderFallback: true
};
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("api.real-debrid.com/rest/1.0/unrestrict/link")) {
return new Response(JSON.stringify({ error: "traffic_exhausted" }), {
status: 429,
headers: { "Content-Type": "application/json" }
});
}
return new Response("not-found", { status: 404 });
}) as typeof fetch;
const service = new DebridService(settings);
let message = "";
try {
await service.unrestrictLink("https://hoster.example/realdebrid-limit.bin");
} catch (error) {
message = String(error);
}
expect(message).toContain("Real-Debrid");
expect(message).toContain("traffic_exhausted");
expect(message).not.toContain("Mega-Debrid nicht verfuegbar");
expect(message).not.toContain("Debrid-Link nicht verfuegbar");
});
it("skips a provider whose daily limit is already reached and uses the next provider", async () => {
const calledUrls: string[] = [];
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { parseDebridLinkTerminalFailure } from "../src/main/download-manager";
describe("provider error classification", () => {
it("does not relabel an aggregated Real-Debrid failure as Debrid-Link", () => {
const message = "Error: Unrestrict fehlgeschlagen: Mega-Debrid nicht verfuegbar (alle aktiven Accounts deaktiviert oder ausgeschopft) | Debrid-Link nicht verfuegbar (alle aktiven API-Keys deaktiviert oder ausgeschopft) | Real-Debrid: traffic_exhausted";
expect(parseDebridLinkTerminalFailure(message)).toBeNull();
});
it("still recognizes a direct Debrid-Link terminal failure", () => {
expect(parseDebridLinkTerminalFailure("Debrid-Link nicht verfuegbar: kein aktiver API-Key")).toMatchObject({
kind: "no_active_key"
});
});
});
+44 -4
View File
@@ -120,7 +120,7 @@ describe("realdebrid-web", () => {
.toBe("ghi789");
});
it("uses the already logged-in browser window to warm the token cache before unrestricting", async () => {
it("uses the already logged-in browser window to warm the token cache before unrestricting", async () => {
const apiFetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({
download: "https://cdn.real-debrid.example/file.bin",
filename: "file.bin",
@@ -160,6 +160,46 @@ describe("realdebrid-web", () => {
expect(mockSessionFetch).not.toHaveBeenCalled();
expect(apiFetch).toHaveBeenCalledTimes(1);
expect(apiFetch.mock.calls[0]?.[0]).toBe("https://api.real-debrid.com/rest/1.0/unrestrict/link");
expect(mockBrowserWindow.webContents.executeJavaScript).toHaveBeenCalled();
});
});
expect(mockBrowserWindow.webContents.executeJavaScript).toHaveBeenCalled();
});
it("checks the logged-in browser account without exposing its token", async () => {
mockExecuteJavaScript.mockResolvedValue("token-from-window");
const apiFetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({
username: "web-user",
email: "web-user@example.test",
type: "premium",
expiration: "2030-01-02T03:04:05.000Z"
}), { status: 200 }));
vi.stubGlobal("fetch", apiFetch);
const fallback = new RealDebridWebFallback(() => true);
await fallback.openLoginWindow();
const status = await fallback.probeLoginState();
expect(status).toEqual({
valid: true,
username: "web-user",
email: "web-user@example.test",
isPremium: true,
premiumUntilMs: Date.parse("2030-01-02T03:04:05.000Z"),
message: "Premium aktiv"
});
expect(JSON.stringify(status)).not.toContain("token-from-window");
expect(apiFetch).toHaveBeenCalledWith(
"https://api.real-debrid.com/rest/1.0/user",
expect.objectContaining({
headers: expect.objectContaining({ Authorization: "Bearer token-from-window" })
})
);
});
it("notifies the controller when a new browser token is detected", async () => {
mockExecuteJavaScript.mockResolvedValue("new-browser-token");
const onAuthenticated = vi.fn();
const fallback = new RealDebridWebFallback(() => true, onAuthenticated);
await fallback.openLoginWindow();
await vi.waitFor(() => expect(onAuthenticated).toHaveBeenCalledTimes(1));
});
});
+27
View File
@@ -44,4 +44,31 @@ describe("live settings overlay", () => {
expect(Object.keys(target.debridAccountStatuses).sort()).toEqual([keepKeyId, keepMegaId].sort());
expect(target.totalRuntimeAllTimeMs).toBe(9_000);
});
it("keeps the Real-Debrid service status while the browser account remains configured", () => {
const target = {
...defaultSettings(),
realDebridUseWebLogin: true
};
const live = {
...target,
debridAccountStatuses: {
"svc-realdebrid": {
accountId: "svc-realdebrid",
provider: "realdebrid" as const,
label: "Real-Debrid",
maskedLogin: "Browser-Login",
valid: true,
isPremium: true,
premiumUntilMs: null,
message: "Premium aktiv",
checkedAt: 1
}
}
};
overlayLiveUsageCounters(target, live, 9_000);
expect(target.debridAccountStatuses["svc-realdebrid"]).toEqual(live.debridAccountStatuses["svc-realdebrid"]);
});
});
+32 -3
View File
@@ -635,7 +635,7 @@ describe("settings storage", () => {
expect(normalized.archivePasswordList).toBe("one\ntwo\nthree");
});
it("defaults Real-Debrid web login to disabled and normalizes the flag", () => {
it("defaults Real-Debrid web login to disabled and normalizes the flag", () => {
expect(defaultSettings().realDebridUseWebLogin).toBe(false);
const normalizedEnabled = normalizeSettings({
@@ -648,8 +648,37 @@ describe("settings storage", () => {
...defaultSettings(),
realDebridUseWebLogin: 0 as unknown as boolean
});
expect(normalizedDisabled.realDebridUseWebLogin).toBe(false);
});
expect(normalizedDisabled.realDebridUseWebLogin).toBe(false);
});
it("keeps the Real-Debrid service status while web login remains configured", () => {
const checkedAt = Date.now();
const normalized = normalizeSettings({
...defaultSettings(),
realDebridUseWebLogin: true,
debridAccountStatuses: {
"svc-realdebrid": {
accountId: "svc-realdebrid",
provider: "realdebrid",
label: "Real-Debrid",
maskedLogin: "Browser-Login",
valid: true,
isPremium: true,
premiumUntilMs: checkedAt + 1000,
email: "web-user",
message: "Premium aktiv",
checkedAt
}
}
});
expect(normalized.debridAccountStatuses["svc-realdebrid"]).toMatchObject({
accountId: "svc-realdebrid",
provider: "realdebrid",
valid: true,
checkedAt
});
});
it("defaults AllDebrid web login to disabled and normalizes the flag", () => {
expect(defaultSettings().allDebridUseWebLogin).toBe(false);