release: rebuild AllDebrid integration for v2.0.66

Replace the legacy cookie and reCAPTCHA scraper with AllDebrid's official browser PIN authorization and API-key flow. Persist verified account identity and premium status, include AllDebrid in direct and bulk checks, and migrate stale web-only configurations to reauthorization.

Remove the unconditional three-second scheduler delay while retaining provider-reported slot limits. Preserve official error codes and terminate unavailable-link, unsupported-link, authentication, IP, and server restriction failures without retries or provider cooldowns.
This commit is contained in:
Sucukdeluxe
2026-08-23 16:03:09 +02:00
parent 6f922c458d
commit c46f9a1d7a
22 changed files with 1267 additions and 964 deletions
+71 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { checkMegaDebridAccount, checkDebridLinkKey, checkAllDebridAccounts, checkRealDebridAccount, REAL_DEBRID_STATUS_ID, retainConfiguredRealDebridStatuses } from "../src/main/account-check";
import { ALL_DEBRID_STATUS_ID, checkAllDebridAccount, checkMegaDebridAccount, checkDebridLinkKey, checkAllDebridAccounts, checkRealDebridAccount, REAL_DEBRID_STATUS_ID, retainConfiguredRealDebridStatuses } from "../src/main/account-check";
import type { MegaDebridAccountEntry } from "../src/shared/mega-debrid-accounts";
import { getDebridLinkApiKeyId, type DebridLinkApiKeyEntry } from "../src/shared/debrid-link-keys";
import type { AppSettings } from "../src/shared/types";
@@ -179,8 +179,78 @@ describe("checkRealDebridAccount", () => {
});
});
});
describe("checkAllDebridAccount", () => {
it("reads identity and premium expiry from the official user endpoint", async () => {
const premiumUntilSec = Math.floor(NOW / 1000) + 30 * 24 * 60 * 60;
mockFetchOnce(200, {
status: "success",
data: {
user: {
username: "all-user",
email: "all-user@example.test",
isPremium: true,
premiumUntil: premiumUntilSec
}
}
});
const status = await checkAllDebridAccount("all-api-key", undefined, NOW);
expect(status).toMatchObject({
accountId: ALL_DEBRID_STATUS_ID,
provider: "alldebrid",
valid: true,
isPremium: true,
premiumUntilMs: premiumUntilSec * 1000,
username: "all-user",
email: "all-user@example.test"
});
expect(fetch).toHaveBeenCalledWith("https://api.alldebrid.com/v4/user", expect.objectContaining({
headers: expect.objectContaining({ Authorization: "Bearer all-api-key" })
}));
});
it("reports authentication errors without accepting the API key", async () => {
mockFetchOnce(401, {
status: "error",
error: { code: "AUTH_BAD_APIKEY", message: "The auth apikey is invalid" }
});
const status = await checkAllDebridAccount("bad-key", undefined, NOW);
expect(status).toMatchObject({
accountId: ALL_DEBRID_STATUS_ID,
provider: "alldebrid",
valid: false,
isPremium: false,
message: "Ungültiger API-Key"
});
});
});
describe("checkAllDebridAccounts", () => {
it("checks configured AllDebrid in all scope and only when enabled in active scope", async () => {
const settings = {
...defaultSettings(),
allDebridToken: "all-api-key",
disabledProviders: ["alldebrid" as const]
};
vi.stubGlobal("fetch", vi.fn(async () => ({
ok: true,
status: 200,
text: async () => JSON.stringify({
status: "success",
data: { user: { username: "all-user", email: "all@example.test", isPremium: true, premiumUntil: 4_102_444_800 } }
})
})) as unknown as typeof fetch);
const active = await checkAllDebridAccounts(settings, undefined, undefined, "active");
const all = await checkAllDebridAccounts(settings, undefined, undefined, "all");
expect(active).toEqual([]);
expect(all).toEqual([expect.objectContaining({ accountId: ALL_DEBRID_STATUS_ID, provider: "alldebrid", valid: true })]);
});
it("discards a late Real-Debrid result after its account was removed", () => {
const removedId = "rda_removedAfterCheck";
const lateStatus = { accountId: removedId, provider: "realdebrid" as const, label: "API-Token 1", maskedLogin: "Geschützt", valid: true, isPremium: true, premiumUntilMs: null, message: "Premium aktiv", checkedAt: NOW };
+28 -3
View File
@@ -141,10 +141,15 @@ describe("write-only account commands", () => {
expect(() => api.validateAccountSecretRequest?.({ kind: "realdebrid-api", accountId: "svc-realdebrid", secret: "not-allowed" })).toThrow(/ungültig/i);
});
it.each(["realdebrid-api", "realdebrid-web"] as const)("accepts %s credential checks at the IPC boundary", (kind) => {
expect(validateAccountCredentialCheckInput({ kind, accountId: "svc-realdebrid" })).toEqual({
it.each([
["realdebrid-api", "svc-realdebrid"],
["realdebrid-web", "svc-realdebrid"],
["alldebrid-api", "svc-alldebrid"],
["alldebrid-web", "svc-alldebrid"]
] as const)("accepts %s credential checks at the IPC boundary", (kind, accountId) => {
expect(validateAccountCredentialCheckInput({ kind, accountId })).toEqual({
kind,
accountId: "svc-realdebrid",
accountId,
identity: undefined,
secret: undefined
});
@@ -265,6 +270,26 @@ describe("write-only account commands", () => {
expect(JSON.stringify(replaced.response)).not.toContain(secret);
});
it("keeps the PIN-issued AllDebrid API key when editing the browser-authorized account", () => {
const apiKey = "fixture-all-pin-api-key";
const settings = {
...defaultSettings(),
allDebridUseWebLogin: true,
allDebridToken: apiKey
};
const replaced = applyAccountCommand(settings, validateAccountCommand({
action: "replace",
kind: "alldebrid-web",
accountId: "svc-alldebrid",
secret: "",
dailyLimitBytes: 2 * GIB
}));
expect(replaced.settings.allDebridUseWebLogin).toBe(true);
expect(replaced.settings.allDebridToken).toBe(apiKey);
});
it("replaces a Mega-Debrid account while preserving sibling accounts and mode-specific state", () => {
const firstId = getMegaDebridAccountId("first@example.test");
const oldId = getMegaDebridAccountId("second@example.test");
+146 -126
View File
@@ -3,7 +3,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const {
mockFromPartition,
mockSession,
mockFetch,
mockBrowserWindowCtor,
mockLoadURL,
mockShow,
@@ -12,7 +11,6 @@ const {
mockSetWindowOpenHandler,
mockSetPermissionRequestHandler
} = vi.hoisted(() => {
const fetch = vi.fn();
const clearStorageData = vi.fn();
const clearCache = vi.fn();
const fromPartition = vi.fn();
@@ -31,6 +29,9 @@ const {
show,
focus,
close: vi.fn(() => {
if (destroyed) {
return;
}
destroyed = true;
windowEvents.closed?.();
}),
@@ -57,11 +58,9 @@ const {
return {
mockFromPartition: fromPartition,
mockSession: {
fetch,
clearStorageData,
clearCache
},
mockFetch: fetch,
mockBrowserWindowCtor: BrowserWindowCtor,
mockLoadURL: loadURL,
mockShow: show,
@@ -84,21 +83,59 @@ vi.mock("electron", () => ({
import { AllDebridWebFallback } from "../src/main/all-debrid-web";
describe("alldebrid-web", () => {
function pinResponse(expiresIn = 600): Response {
return new Response(JSON.stringify({
status: "success",
data: {
pin: "ABCD",
check: "check-token",
expires_in: expiresIn,
user_url: "https://alldebrid.com/pin/?pin=ABCD",
base_url: "https://alldebrid.com/pin/"
}
}), { status: 200 });
}
function checkResponse(activated: boolean, expiresIn: number, apiKey?: string): Response {
return new Response(JSON.stringify({
status: "success",
data: {
activated,
expires_in: expiresIn,
...(apiKey ? { apikey: apiKey } : {})
}
}), { status: 200 });
}
describe("alldebrid PIN auth", () => {
beforeEach(() => {
mockFromPartition.mockReturnValue(mockSession);
vi.stubGlobal("fetch", vi.fn());
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
vi.clearAllMocks();
mockFromPartition.mockReturnValue(mockSession);
});
it("opens the AllDebrid login window with the shared restrictive browser boundary", async () => {
const fallback = new AllDebridWebFallback(() => true);
it("opens the official PIN URL and reports the API key after activation", async () => {
vi.useFakeTimers();
const fetchMock = vi.mocked(fetch);
fetchMock
.mockResolvedValueOnce(pinResponse())
.mockResolvedValueOnce(checkResponse(false, 595))
.mockResolvedValueOnce(checkResponse(true, 590, "all-debrid-api-key"));
const authenticated = vi.fn();
const fallback = new AllDebridWebFallback(() => true, authenticated);
await fallback.openLoginWindow();
expect(fetchMock.mock.calls[0]).toEqual([
"https://api.alldebrid.com/v4.1/pin/get",
expect.objectContaining({ method: "GET" })
]);
expect(mockBrowserWindowCtor).toHaveBeenCalledTimes(1);
expect(mockBrowserWindowCtor.mock.calls[0]?.[0]).toEqual(expect.objectContaining({
webPreferences: {
@@ -112,128 +149,111 @@ describe("alldebrid-web", () => {
}));
expect(mockSetWindowOpenHandler).toHaveBeenCalledTimes(1);
expect(mockSetPermissionRequestHandler).toHaveBeenCalledTimes(1);
expect(mockLoadURL).toHaveBeenCalledWith("https://alldebrid.com/register/?from=de");
expect(mockShow).toHaveBeenCalled();
expect(mockFocus).toHaveBeenCalled();
});
expect(mockLoadURL).toHaveBeenCalledWith("https://alldebrid.com/pin/?pin=ABCD");
expect(mockShow).toHaveBeenCalledTimes(1);
expect(mockFocus).toHaveBeenCalledTimes(1);
expect(authenticated).not.toHaveBeenCalled();
it("uses an existing AllDebrid Web session to unrestrict without opening a login window", async () => {
mockFetch.mockResolvedValueOnce(new Response(JSON.stringify({
link: "https://alldebrid.direct/session-file.bin",
filename: "session-file.bin",
filesize: 9876
}), { status: 200 }));
const fallback = new AllDebridWebFallback(() => true);
await vi.advanceTimersByTimeAsync(5_000);
await vi.waitFor(() => expect(authenticated).toHaveBeenCalledWith({ apiKey: "all-debrid-api-key" }));
const result = await fallback.unrestrict("https://rapidgator.net/file/session");
expect(result).toEqual({
directUrl: "https://alldebrid.direct/session-file.bin",
fileName: "session-file.bin",
fileSize: 9876,
retriesUsed: 0
});
expect(mockBrowserWindowCtor).not.toHaveBeenCalled();
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(mockFetch.mock.calls[0]?.[0]).toBe("https://alldebrid.com/service.php");
expect(mockFetch.mock.calls[0]?.[1]).toEqual(expect.objectContaining({
method: "POST",
body: "link=https%3A%2F%2Frapidgator.net%2Ffile%2Fsession&nb=0&json=true&pw="
}));
});
it("releases an aborted caller while the active web request ignores its signal", async () => {
let rejectRequest!: (error: Error) => void;
mockFetch
.mockReturnValueOnce(new Promise<Response>((_resolve, reject) => {
rejectRequest = reject;
}))
.mockResolvedValueOnce(new Response(JSON.stringify({
link: "https://alldebrid.direct/second.bin",
filename: "second.bin",
filesize: 333
}), { status: 200 }));
const fallback = new AllDebridWebFallback(() => true);
const controller = new AbortController();
const running = fallback.unrestrict("https://rapidgator.net/file/abort-race", controller.signal)
.then(() => "resolved" as const, (error) => String(error));
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1));
controller.abort("test-stop");
const outcome = await Promise.race([
running,
new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 200))
expect(fetchMock.mock.calls[1]).toEqual([
"https://api.alldebrid.com/v4/pin/check",
expect.objectContaining({
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8" },
body: "check=check-token&pin=ABCD"
})
]);
expect(outcome).toContain("aborted:alldebrid-web");
const secondOutcome = await Promise.race([
fallback.unrestrict("https://rapidgator.net/file/second"),
new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 200))
]);
expect(secondOutcome).toEqual({
directUrl: "https://alldebrid.direct/second.bin",
fileName: "second.bin",
fileSize: 333,
retriesUsed: 0
});
expect(mockFetch).toHaveBeenCalledTimes(2);
rejectRequest(new Error("late alldebrid rejection"));
await Promise.resolve();
});
it("observes the raw rejection when the signal is already aborted and keeps the queue usable", async () => {
mockFetch.mockResolvedValue(new Response(JSON.stringify({
link: "https://alldebrid.direct/next.bin",
filename: "next.bin",
filesize: 666
}), { status: 200 }));
const fallback = new AllDebridWebFallback(() => true);
const controller = new AbortController();
controller.abort("before-queue");
const unhandled: unknown[] = [];
const onUnhandled = (reason: unknown) => unhandled.push(reason);
process.on("unhandledRejection", onUnhandled);
try {
await expect(fallback.unrestrict("https://rapidgator.net/file/pre-aborted", controller.signal))
.rejects.toThrow("aborted:alldebrid-web");
await new Promise((resolve) => setImmediate(resolve));
await expect(fallback.unrestrict("https://rapidgator.net/file/next")).resolves.toMatchObject({
directUrl: "https://alldebrid.direct/next.bin",
fileName: "next.bin"
});
expect(unhandled).toEqual([]);
expect(mockFetch).toHaveBeenCalledTimes(1);
} finally {
process.off("unhandledRejection", onUnhandled);
}
});
it("opens the login window after login_required and retries generation with the same session partition", async () => {
mockFetch
.mockResolvedValueOnce(new Response("login", { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({
link: "https://alldebrid.direct/retry-file.bin",
filename: "retry-file.bin",
filesize: 12345
}), { status: 200 }));
const fallback = new AllDebridWebFallback(() => true);
const result = await fallback.unrestrict("https://rapidgator.net/file/retry");
expect(result).toEqual({
directUrl: "https://alldebrid.direct/retry-file.bin",
fileName: "retry-file.bin",
fileSize: 12345,
retriesUsed: 0
});
expect(mockBrowserWindowCtor).toHaveBeenCalledTimes(1);
expect(mockLoadURL).toHaveBeenCalledWith("https://alldebrid.com/register/?from=de");
expect(mockShow).toHaveBeenCalled();
expect(mockFocus).toHaveBeenCalled();
expect(mockSetWindowOpenHandler).toHaveBeenCalledTimes(1);
expect(mockSetPermissionRequestHandler).toHaveBeenCalledTimes(1);
expect(authenticated).toHaveBeenCalledTimes(1);
expect(mockClose).toHaveBeenCalledTimes(1);
expect(mockFromPartition).toHaveBeenCalledWith("persist:alldebrid-web");
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it("returns after opening the window instead of waiting for PIN activation", async () => {
const fetchMock = vi.mocked(fetch);
fetchMock
.mockResolvedValueOnce(pinResponse())
.mockReturnValueOnce(new Promise<Response>(() => {}));
const fallback = new AllDebridWebFallback(() => false, vi.fn());
await expect(fallback.openLoginWindow()).resolves.toBeUndefined();
expect(mockLoadURL).toHaveBeenCalledWith("https://alldebrid.com/pin/?pin=ABCD");
expect(mockShow).toHaveBeenCalledTimes(1);
});
it("reuses the active PIN window without creating another flow", async () => {
const fetchMock = vi.mocked(fetch);
fetchMock
.mockResolvedValueOnce(pinResponse())
.mockReturnValueOnce(new Promise<Response>(() => {}));
const fallback = new AllDebridWebFallback(() => true, vi.fn());
await fallback.openLoginWindow();
await fallback.openLoginWindow();
expect(mockBrowserWindowCtor).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls.filter(([url]) => url === "https://api.alldebrid.com/v4.1/pin/get")).toHaveLength(1);
expect(mockShow).toHaveBeenCalledTimes(2);
expect(mockFocus).toHaveBeenCalledTimes(2);
});
it("stops polling after the user closes the window and never reopens it", async () => {
vi.useFakeTimers();
const fetchMock = vi.mocked(fetch);
fetchMock
.mockResolvedValueOnce(pinResponse())
.mockResolvedValue(checkResponse(false, 595));
const authenticated = vi.fn();
const fallback = new AllDebridWebFallback(() => true, authenticated);
await fallback.openLoginWindow();
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
mockClose();
await vi.advanceTimersByTimeAsync(30_000);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(mockBrowserWindowCtor).toHaveBeenCalledTimes(1);
expect(authenticated).not.toHaveBeenCalled();
});
it("closes the window and stops polling when the caller aborts", async () => {
vi.useFakeTimers();
const fetchMock = vi.mocked(fetch);
fetchMock
.mockResolvedValueOnce(pinResponse())
.mockResolvedValue(checkResponse(false, 595));
const authenticated = vi.fn();
const fallback = new AllDebridWebFallback(() => true, authenticated);
const controller = new AbortController();
await fallback.openLoginWindow(controller.signal);
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
controller.abort();
await vi.advanceTimersByTimeAsync(30_000);
expect(mockClose).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(authenticated).not.toHaveBeenCalled();
});
it("times out from the server expiry without reopening the login window", async () => {
vi.useFakeTimers();
const fetchMock = vi.mocked(fetch);
fetchMock
.mockResolvedValueOnce(pinResponse(1))
.mockResolvedValueOnce(checkResponse(false, 1));
const authenticated = vi.fn();
const failed = vi.fn();
const fallback = new AllDebridWebFallback(() => true, authenticated, failed);
await fallback.openLoginWindow();
await vi.advanceTimersByTimeAsync(5_000);
await vi.waitFor(() => expect(failed).toHaveBeenCalledTimes(1));
expect(failed.mock.calls[0]?.[0]).toEqual(expect.objectContaining({ message: "AllDebrid PIN-Login Timeout" }));
expect(authenticated).not.toHaveBeenCalled();
expect(mockClose).toHaveBeenCalledTimes(1);
expect(mockBrowserWindowCtor).toHaveBeenCalledTimes(1);
});
});
+79 -1
View File
@@ -29,7 +29,7 @@ function createController(settings: AppSettings): AppController {
const controller = Object.create(AppController.prototype) as any;
controller.settings = settings;
controller.storagePaths = createStoragePaths(dir);
controller.manager = { setSettings: vi.fn() };
controller.manager = { setSettings: vi.fn(), applyDebridAccountStatuses: vi.fn() };
controller.audit = vi.fn();
controller.overlayLiveUsageCounters = vi.fn();
controller.pruneRealDebridWebFallbacks = vi.fn();
@@ -93,3 +93,81 @@ describe("AppController daily start settings", () => {
expect(controller.getSettings().scheduledStartEpochMs).toBe(scheduledStartEpochMs);
});
});
describe("AppController AllDebrid account checks", () => {
it("routes a stored AllDebrid API account through the AllDebrid user endpoint and persists its status", async () => {
const controller = createController({
...defaultSettings(),
allDebridToken: "fixture-all-api-key"
}) as any;
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify({
status: "success",
data: {
user: {
username: "all-user",
email: "all@example.test",
isPremium: true,
premiumUntil: "1800000000"
}
}
}), { status: 200, headers: { "Content-Type": "application/json" } }));
const status = await controller.checkAccountCredentials({
kind: "alldebrid-api",
accountId: "svc-alldebrid"
});
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(String(fetchMock.mock.calls[0][0])).toBe("https://api.alldebrid.com/v4/user");
expect(status).toMatchObject({
accountId: "svc-alldebrid",
provider: "alldebrid",
valid: true,
isPremium: true,
username: "all-user",
email: "all@example.test"
});
expect(controller.manager.applyDebridAccountStatuses).toHaveBeenCalledWith([status]);
});
it("stores a PIN-issued API key, enables AllDebrid and applies the checked account status", async () => {
configureCredentialProtector({
isEncryptionAvailable: () => false,
encryptString: (value) => Buffer.from(value, "utf8"),
decryptString: (value) => Buffer.from(value).toString("utf8")
});
const controller = createController({
...defaultSettings(),
allDebridToken: "",
allDebridUseWebLogin: false,
disabledProviders: ["alldebrid"]
}) as any;
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify({
status: "success",
data: {
user: {
username: "pin-user",
email: "pin@example.test",
isPremium: true,
premiumUntil: "1800000000"
}
}
}), { status: 200, headers: { "Content-Type": "application/json" } }));
await controller.completeAllDebridLogin("fixture-pin-api-key");
expect(controller.getSettings()).toMatchObject({
allDebridToken: "fixture-pin-api-key",
allDebridUseWebLogin: true
});
expect(controller.getSettings().disabledProviders).not.toContain("alldebrid");
expect(controller.manager.applyDebridAccountStatuses).toHaveBeenCalledWith([
expect.objectContaining({
accountId: "svc-alldebrid",
provider: "alldebrid",
valid: true,
username: "pin-user"
})
]);
});
});
+13
View File
@@ -75,6 +75,19 @@ describe("desktop shell", () => {
expect(menu).toContain("openCollectorInput()");
});
it("uses the AllDebrid PIN flow before creating an account and exposes direct account checks", () => {
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8");
const createFlow = source.slice(source.indexOf("const onSaveAccountDialog"), source.indexOf("const onResetAccountDailyUsage"));
const rowFlow = source.slice(source.indexOf("const accountRows"), source.indexOf("const [accountStatusSort"));
const checkFlow = source.slice(source.indexOf("const checkAccountTableRow"), source.indexOf("const onCheckUpdates"));
expect(createFlow).toContain('dialogSnapshot.kind === "alldebrid-web"');
expect(createFlow.indexOf("openAllDebridLogin")).toBeLessThan(createFlow.indexOf("buildAccountCreateCommand"));
expect(rowFlow).toContain('entry.service === "alldebrid" ? "svc-alldebrid" : null');
expect(checkFlow).toContain('row.entry.kind === "alldebrid-api"');
expect(checkFlow).toContain('row.entry.kind === "alldebrid-web"');
});
it("places the delete confirmation opt-out below the right-aligned actions", () => {
const source = readFileSync(new URL("../src/renderer/views/downloads/DeleteConfirmationDialog.tsx", import.meta.url), "utf8");
const css = readFileSync(new URL("../src/renderer/styles.css", import.meta.url), "utf8");
+54 -24
View File
@@ -1045,7 +1045,7 @@ describe("debrid service", () => {
expect(calls).toBe(1);
});
it("does not retry AllDebrid auth failures (403)", async () => {
it("does not retry AllDebrid auth failures (403)", async () => {
const settings = {
...defaultSettings(),
allDebridToken: "ad-token",
@@ -1071,8 +1071,32 @@ describe("debrid service", () => {
const service = new DebridService(settings);
await expect(service.unrestrictLink("https://hoster.example/file/no-retry-ad")).rejects.toThrow();
expect(calls).toBe(1);
});
expect(calls).toBe(1);
});
it("preserves the official AllDebrid error code alongside its message", async () => {
const settings = {
...defaultSettings(),
allDebridToken: "ad-token",
providerOrder: [] as const,
providerPrimary: "alldebrid" as const,
providerSecondary: "none" as const,
providerTertiary: "none" as const,
autoProviderFallback: false
};
globalThis.fetch = (async () => new Response(JSON.stringify({
status: "error",
error: {
code: "NO_SERVER",
message: "Servers are not allowed on this endpoint"
}
}), { status: 200, headers: { "Content-Type": "application/json" } })) as typeof fetch;
const service = new DebridService(settings);
await expect(service.unrestrictLink("https://rapidgator.net/file/no-server"))
.rejects.toThrow(/NO_SERVER: Servers are not allowed/);
});
it("supports AllDebrid unlock", async () => {
const settings = {
@@ -1321,7 +1345,7 @@ describe("debrid service", () => {
expect(info[0].hostStateLabel).toBe("Offline");
});
it("uses AllDebrid web path when enabled", async () => {
it("uses the official AllDebrid API after browser authorization", async () => {
const settings = {
...defaultSettings(),
allDebridToken: "ad-token",
@@ -1333,26 +1357,32 @@ describe("debrid service", () => {
autoProviderFallback: false
};
const fetchSpy = vi.fn(async () => new Response("not-found", { status: 404 }));
globalThis.fetch = fetchSpy as unknown as typeof fetch;
const allDebridWeb = vi.fn(async () => ({
fileName: "from-web.rar",
directUrl: "https://df4ea4.debrid.it/dl/example/from-web.rar",
fileSize: 1234,
retriesUsed: 0
}));
const service = new DebridService(settings, { allDebridWebUnrestrict: allDebridWeb });
const result = await service.unrestrictLink("https://rapidgator.net/file/example.part4.rar.html");
expect(result.provider).toBe("alldebrid");
expect(result.directUrl).toContain("debrid.it/dl/");
expect(result.fileSize).toBe(1234);
expect(allDebridWeb).toHaveBeenCalledTimes(1);
expect(fetchSpy).toHaveBeenCalledTimes(0);
});
it("treats AllDebrid web mode as not configured when callback is unavailable", async () => {
const fetchSpy = vi.fn(async (input: RequestInfo | URL) => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("api.alldebrid.com/v4/link/unlock")) {
return new Response(JSON.stringify({
status: "success",
data: {
link: "https://df4ea4.debrid.it/dl/example/from-api.rar",
filename: "from-api.rar",
filesize: 1234
}
}), { status: 200, headers: { "Content-Type": "application/json" } });
}
return new Response("not-found", { status: 404 });
});
globalThis.fetch = fetchSpy as unknown as typeof fetch;
const service = new DebridService(settings);
const result = await service.unrestrictLink("https://rapidgator.net/file/example.part4.rar.html");
expect(result.provider).toBe("alldebrid");
expect(result.directUrl).toContain("debrid.it/dl/");
expect(result.fileSize).toBe(1234);
expect(result.sourceLabel).toBe("API");
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
it("treats AllDebrid browser authorization without an API key as not configured", async () => {
const settings = {
...defaultSettings(),
allDebridToken: "",
+178 -72
View File
@@ -140,8 +140,6 @@ describe("selected item run scope", () => {
internal.retryAfterByItem.set(itemIds[1], 200);
internal.retryStateByItem.set(itemIds[0], { freshRetryUsed: true, resumeHardResetUsed: false });
internal.retryStateByItem.set(itemIds[1], { freshRetryUsed: false, resumeHardResetUsed: true });
internal.pacedStartReservationByItem.set(itemIds[0], 100);
internal.pacedStartReservationByItem.set(itemIds[1], 200);
internal.standalonePackageResults.add("foreign-package:1");
manager.stop();
@@ -152,8 +150,6 @@ describe("selected item run scope", () => {
expect(internal.retryAfterByItem.get(itemIds[1])).toBe(200);
expect(internal.retryStateByItem.has(itemIds[0])).toBe(false);
expect(internal.retryStateByItem.has(itemIds[1])).toBe(true);
expect(internal.pacedStartReservationByItem.has(itemIds[0])).toBe(false);
expect(internal.pacedStartReservationByItem.get(itemIds[1])).toBe(200);
expect(internal.standalonePackageResults.has("foreign-package:1")).toBe(true);
expect(internal.suppressedPackageResults.has("foreign-package:1")).toBe(false);
});
@@ -181,7 +177,7 @@ describe("selected item run scope", () => {
expect(internal.retryAfterByItem.has(itemIds[1])).toBe(true);
});
it("resuming preserves item, disk and provider cooldown state", async () => {
it("resuming preserves item and provider cooldown state", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-resume-cooldowns-"));
tempDirs.push(root);
const { manager, itemIds } = createSelectedItemManager(root);
@@ -192,15 +188,11 @@ describe("selected item run scope", () => {
internal.session.paused = true;
const now = Date.now();
internal.retryAfterByItem.set(itemIds[0], now + 11_000);
internal.providerStartReservations.set("provider-key", now + 12_000);
internal.pacedStartReservationByItem.set(itemIds[0], now + 13_000);
internal.providerFailures.set("provider-key", { count: 1, lastFailAt: now, cooldownUntil: now + 14_000 });
manager.togglePause();
expect(internal.retryAfterByItem.get(itemIds[0])).toBe(now + 11_000);
expect(internal.providerStartReservations.get("provider-key")).toBe(now + 12_000);
expect(internal.pacedStartReservationByItem.get(itemIds[0])).toBe(now + 13_000);
expect(internal.providerFailures.get("provider-key")?.cooldownUntil).toBe(now + 14_000);
expect(internal.findNextQueuedItem()).toBeNull();
});
@@ -1589,6 +1581,31 @@ async function waitFor(predicate: () => boolean, timeoutMs = 15000): Promise<voi
}
}
function mockAllDebridApi(
resolveLink: (link: string) => { directUrl: string; fileName: string; fileSize: number },
limitSimuDl: number
): void {
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("/user/hosts")) {
return new Response(JSON.stringify({
status: "success",
data: { hosts: { rapidgator: { name: "Rapidgator", status: true, limitSimuDl } } }
}), { status: 200, headers: { "Content-Type": "application/json" } });
}
if (url.includes("/link/unlock")) {
const bodyText = init?.body instanceof URLSearchParams ? init.body.toString() : String(init?.body || "");
const originalLink = new URLSearchParams(bodyText).get("link") || "";
const resolved = resolveLink(originalLink);
return new Response(JSON.stringify({
status: "success",
data: { link: resolved.directUrl, filename: resolved.fileName, filesize: resolved.fileSize }
}), { status: 200, headers: { "Content-Type": "application/json" } });
}
return originalFetch(input, init);
};
}
async function removeDirWithRetries(dir: string): Promise<void> {
let lastError: unknown = null;
for (let attempt = 1; attempt <= 5; attempt += 1) {
@@ -9298,7 +9315,118 @@ describe("download manager", () => {
}
});
it("limits AllDebrid rapidgator starts to one active task by default", async () => {
it("starts AllDebrid items immediately without paced-start reservations", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root);
let unrestrictCalls = 0;
globalThis.fetch = async (input: RequestInfo | URL): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("/user/hosts")) {
return new Response(JSON.stringify({
status: "success",
data: { hosts: { rapidgator: { name: "Rapidgator", status: true, limitSimuDl: 2 } } }
}), { status: 200, headers: { "Content-Type": "application/json" } });
}
if (url.includes("/link/unlock")) {
unrestrictCalls += 1;
await new Promise(() => {});
}
return originalFetch(input);
};
const manager = new DownloadManager(
{
...defaultSettings(),
allDebridToken: "ad-token",
providerOrder: [],
providerPrimary: "alldebrid",
providerSecondary: "none",
providerTertiary: "none",
outputDir: path.join(root, "downloads"),
extractDir: path.join(root, "extract"),
autoExtract: false,
autoReconnect: false,
enableIntegrityCheck: false,
maxParallel: 2
},
emptySession(),
createStoragePaths(path.join(root, "state"))
);
manager.addPackages([{ name: "ad-immediate", links: ["https://rapidgator.net/file/ad-immediate/sample.rar.html"] }]);
await manager.start();
await waitFor(() => unrestrictCalls === 1, 1000);
const internal = manager as any;
expect(internal.pacedStartReservationByItem).toBeUndefined();
expect(internal.providerStartReservations).toBeUndefined();
manager.stop();
await waitFor(() => !manager.getSnapshot().session.running, 5000);
});
it.each([
["LINK_DOWN", "This link is not available on the file hoster website", "Link ungültig"],
["BAD_LINK", "The link format is invalid", "Link ungültig"],
["LINK_HOST_NOT_SUPPORTED", "This host is not supported", "Link ungültig"],
["LINK_NOT_SUPPORTED", "This link is not supported", "Link ungültig"],
["AUTH_MISSING_APIKEY", "The auth apikey was not sent", "AllDebrid-Anmeldung fehlgeschlagen"],
["AUTH_BAD_APIKEY", "The API key is invalid", "AllDebrid-Anmeldung fehlgeschlagen"],
["NO_SERVER", "Servers are not allowed on this endpoint", "AllDebrid-Anmeldung fehlgeschlagen"]
])("fails terminal AllDebrid errors without retry or host cooldown: %s", async (code, message, expectedStatus) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root);
let unrestrictCalls = 0;
globalThis.fetch = async (input: RequestInfo | URL): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("/user/hosts")) {
return new Response(JSON.stringify({
status: "success",
data: { hosts: { rapidgator: { name: "Rapidgator", status: true, limitSimuDl: 1 } } }
}), { status: 200, headers: { "Content-Type": "application/json" } });
}
if (url.includes("/link/unlock")) {
unrestrictCalls += 1;
return new Response(JSON.stringify({ status: "error", error: { code, message } }), {
status: 200,
headers: { "Content-Type": "application/json" }
});
}
return originalFetch(input);
};
const manager = new DownloadManager(
{
...defaultSettings(),
allDebridToken: "ad-token",
providerOrder: [],
providerPrimary: "alldebrid",
providerSecondary: "none",
providerTertiary: "none",
outputDir: path.join(root, "downloads"),
extractDir: path.join(root, "extract"),
autoExtract: false,
autoReconnect: false,
enableIntegrityCheck: false,
retryLimit: 0,
maxParallel: 1
},
emptySession(),
createStoragePaths(path.join(root, "state"))
);
manager.addPackages([{ name: "ad-terminal", links: ["https://rapidgator.net/file/ad-terminal/sample.rar.html"] }]);
await manager.start();
await waitFor(() => Object.values(manager.getSnapshot().session.items)[0]?.status === "failed", 5000);
const item = Object.values(manager.getSnapshot().session.items)[0];
const internal = manager as any;
expect(unrestrictCalls).toBe(1);
expect(item.retries).toBe(0);
expect(item.fullStatus).toContain(expectedStatus);
expect(internal.retryAfterByItem.has(item.id)).toBe(false);
expect(internal.providerFailures.has("alldebrid:rapidgator")).toBe(false);
});
it("respects the one-slot AllDebrid Rapidgator limit returned by the API", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root);
const binary = Buffer.alloc(2 * 1024 * 1024, 6);
@@ -9447,7 +9575,7 @@ describe("download manager", () => {
}
}, 35000);
it("allows concurrent AllDebrid Web Rapidgator starts up to configured parallelism", async () => {
it("allows concurrent AllDebrid Rapidgator starts up to the reported slot limit", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root);
const chunk = Buffer.alloc(256 * 1024, 9);
@@ -9493,11 +9621,15 @@ describe("download manager", () => {
const directUrl3 = `http://127.0.0.1:${address.port}/ad-web-3`;
try {
mockAllDebridApi((link) => ({
directUrl: link === link2 ? directUrl2 : link === link3 ? directUrl3 : directUrl1,
fileName: link === link2 ? "ad-web-2.bin" : link === link3 ? "ad-web-3.bin" : "ad-web-1.bin",
fileSize: chunk.length * 10
}), 3);
const manager = new DownloadManager(
{
...defaultSettings(),
allDebridToken: "ad-token",
allDebridUseWebLogin: true,
providerOrder: [],
providerPrimary: "alldebrid",
providerSecondary: "none",
@@ -9510,15 +9642,7 @@ describe("download manager", () => {
maxParallel: 3
},
emptySession(),
createStoragePaths(path.join(root, "state")),
{
allDebridWebUnrestrict: async (link) => ({
fileName: link === link2 ? "ad-web-2.bin" : link === link3 ? "ad-web-3.bin" : "ad-web-1.bin",
directUrl: link === link2 ? directUrl2 : link === link3 ? directUrl3 : directUrl1,
fileSize: chunk.length * 10,
retriesUsed: 0
})
}
createStoragePaths(path.join(root, "state"))
);
manager.addPackages([{ name: "ad-web-parallel", links: [link1, link2, link3] }]);
@@ -10285,7 +10409,7 @@ describe("download manager", () => {
await new Promise((resolve) => setTimeout(resolve, 150));
});
it("shows the same AllDebrid countdown for all immediately free slots", async () => {
it("starts all immediately free AllDebrid slots without a countdown", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root);
const chunk = Buffer.alloc(256 * 1024, 9);
@@ -10327,11 +10451,19 @@ describe("download manager", () => {
const links = Array.from({ length: totalLinks }, (_, index) => `https://rapidgator.net/file/web-${index + 1}/sample.part${index + 1}.rar.html`);
try {
mockAllDebridApi((link) => {
const match = link.match(/web-(\d+)/);
const slot = Number(match?.[1] || 1);
return {
fileName: `ad-web-${slot}.bin`,
directUrl: `http://127.0.0.1:${address.port}/ad-web-${slot}`,
fileSize: chunk.length * 10
};
}, 5);
const manager = new DownloadManager(
{
...defaultSettings(),
allDebridToken: "ad-token",
allDebridUseWebLogin: true,
providerOrder: [],
providerPrimary: "alldebrid",
providerSecondary: "none",
@@ -10344,19 +10476,7 @@ describe("download manager", () => {
maxParallel: 5
},
emptySession(),
createStoragePaths(path.join(root, "state")),
{
allDebridWebUnrestrict: async (link) => {
const match = link.match(/web-(\d+)/);
const slot = Number(match?.[1] || 1);
return {
fileName: `ad-web-${slot}.bin`,
directUrl: `http://127.0.0.1:${address.port}/ad-web-${slot}`,
fileSize: chunk.length * 10,
retriesUsed: 0
};
}
}
createStoragePaths(path.join(root, "state"))
);
manager.addPackages([{ name: "ad-web-visibility", links }]);
@@ -10364,18 +10484,15 @@ describe("download manager", () => {
await waitFor(() => {
const items = Object.values(manager.getSnapshot().session.items);
const countdownItems = items.filter((item) => /^AllDebrid Start in \d+s$/.test(item.fullStatus || ""));
return countdownItems.length === 5;
return items.filter((item) => item.status === "downloading" || item.status === "validating").length === 5;
}, 10000);
const items = Object.values(manager.getSnapshot().session.items);
const activeCount = items.filter((item) => item.status === "downloading" || item.status === "validating").length;
const countdownItems = items.filter((item) => /^AllDebrid Start in \d+s$/.test(item.fullStatus || ""));
const uniqueCountdowns = new Set(countdownItems.map((item) => item.fullStatus || ""));
expect(activeCount).toBe(0);
expect(countdownItems.length).toBe(5);
expect(uniqueCountdowns.size).toBe(1);
expect(activeCount).toBe(5);
expect(countdownItems).toHaveLength(0);
manager.stop();
await waitFor(() => !manager.getSnapshot().session.running, 15000);
@@ -10385,7 +10502,7 @@ describe("download manager", () => {
}
}, 20000);
it("starts immediately free AllDebrid slots after the same 3 second delay", async () => {
it("starts immediately free AllDebrid API slots without a fixed delay", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root);
const binary = Buffer.alloc(512 * 1024, 5);
@@ -10469,19 +10586,11 @@ describe("download manager", () => {
manager.addPackages([{ name: "ad-paced", links: [link1, link2, link3] }]);
await manager.start();
const managerInternals = manager as unknown as {
retryAfterByItem: Map<string, number>;
};
await waitFor(() => managerInternals.retryAfterByItem.size >= 3, 5000);
const now = Date.now();
const readyTimes = [...managerInternals.retryAfterByItem.values()].sort((a, b) => a - b);
expect(readyTimes.length).toBe(3);
const firstDelay = readyTimes[0] - now;
const lastDelay = readyTimes[readyTimes.length - 1] - now;
expect(firstDelay).toBeGreaterThan(2000);
expect(firstDelay).toBeLessThan(4500);
expect(lastDelay - firstDelay).toBeLessThan(500);
await waitFor(() => {
const items = Object.values(manager.getSnapshot().session.items);
return items.filter((item) => item.status === "downloading" || item.status === "validating").length === 3;
}, 1000);
expect(Object.values(manager.getSnapshot().session.items).some((item) => /^AllDebrid Start in \d+s$/.test(item.fullStatus || ""))).toBe(false);
manager.stop();
await waitFor(() => !manager.getSnapshot().session.running, 15000);
@@ -10491,7 +10600,7 @@ describe("download manager", () => {
}
}, 20000);
it("tops up newly freed AllDebrid slots with a fresh 3 second countdown", async () => {
it("tops up newly freed AllDebrid slots immediately", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root);
const shortBinary = Buffer.alloc(64 * 1024, 7);
@@ -10537,11 +10646,18 @@ describe("download manager", () => {
];
try {
mockAllDebridApi((link) => {
const slot = links.indexOf(link) + 1;
return {
fileName: `ad-topup-${slot}.bin`,
directUrl: `http://127.0.0.1:${address.port}/ad-${slot}`,
fileSize: slot === 1 ? shortBinary.length : longBinary.length
};
}, 3);
const manager = new DownloadManager(
{
...defaultSettings(),
allDebridToken: "ad-token",
allDebridUseWebLogin: true,
providerOrder: [],
providerPrimary: "alldebrid",
providerSecondary: "none",
@@ -10554,18 +10670,7 @@ describe("download manager", () => {
maxParallel: 3
},
emptySession(),
createStoragePaths(path.join(root, "state")),
{
allDebridWebUnrestrict: async (link) => {
const slot = links.indexOf(link) + 1;
return {
fileName: `ad-topup-${slot}.bin`,
directUrl: `http://127.0.0.1:${address.port}/ad-${slot}`,
fileSize: slot === 1 ? shortBinary.length : longBinary.length,
retriesUsed: 0
};
}
}
createStoragePaths(path.join(root, "state"))
);
manager.addPackages([{ name: "ad-topup", links }]);
@@ -10579,9 +10684,10 @@ describe("download manager", () => {
await waitFor(() => {
const items = Object.values(manager.getSnapshot().session.items);
const completedCount = items.filter((item) => item.status === "completed").length;
const countdownItems = items.filter((item) => /^AllDebrid Start in [123]s$/.test(item.fullStatus || ""));
return completedCount >= 1 && countdownItems.length === 1;
const downloadingCount = items.filter((item) => item.status === "downloading").length;
return completedCount >= 1 && downloadingCount === 3;
}, 12000);
expect(Object.values(manager.getSnapshot().session.items).some((item) => /^AllDebrid Start in \d+s$/.test(item.fullStatus || ""))).toBe(false);
manager.stop();
await waitFor(() => !manager.getSnapshot().session.running, 15000);
+32
View File
@@ -7,6 +7,8 @@ import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
import { serializeRealDebridApiAccounts } from "../src/shared/real-debrid-accounts";
import type { AppSettings, RendererAccountKind } from "../src/shared/types";
const NOW = 1_700_000_000_000;
const SECRETS = {
token: "fixture-rd-token-7vQ2",
megaPassword: "fixture-mega-password-8kM3",
@@ -81,6 +83,36 @@ describe("renderer state serialization", () => {
expect(JSON.stringify(state)).not.toContain(secondToken);
expect(realDebridRows[0].accountId).not.toBe(`rda_${crypto.createHash("sha256").update(firstToken).digest("hex").slice(0, 32)}`);
});
it("projects the persisted AllDebrid service status", () => {
const settings = {
...defaultSettings(),
allDebridToken: "fixture-all-status-secret",
debridAccountStatuses: {
"svc-alldebrid": {
accountId: "svc-alldebrid",
provider: "alldebrid" as const,
label: "AllDebrid",
maskedLogin: "Geschützter API-Key",
valid: true,
isPremium: true,
premiumUntilMs: NOW,
username: "all-user",
email: "all@example.test",
message: "Premium aktiv",
checkedAt: 1
}
}
};
const account = createRendererState(settings).accounts.find((entry) => entry.accountId === "svc-alldebrid");
expect(account).toMatchObject({
kind: "alldebrid-api",
provider: "alldebrid",
identity: "all-user",
status: expect.objectContaining({ valid: true, username: "all-user", email: "all@example.test" })
});
});
it.each(ACCOUNT_FIXTURES)("serializes $kind without its representative secret", ({ kind, secret, settings }) => {
const state = createRendererState({ ...defaultSettings(), ...settings });
+46 -10
View File
@@ -1007,16 +1007,23 @@ describe("settings storage", () => {
expect(normalized.debridAccountStatuses[key.id].email).toBeUndefined();
});
it("defaults AllDebrid web login to disabled and normalizes the flag", () => {
expect(defaultSettings().allDebridUseWebLogin).toBe(false);
const normalizedEnabled = normalizeSettings({
...defaultSettings(),
allDebridUseWebLogin: 1 as unknown as boolean
});
expect(normalizedEnabled.allDebridUseWebLogin).toBe(true);
const normalizedDisabled = normalizeSettings({
it("migrates legacy AllDebrid web login without a PIN-issued API key to reauthorization", () => {
expect(defaultSettings().allDebridUseWebLogin).toBe(false);
const legacyWebOnly = normalizeSettings({
...defaultSettings(),
allDebridUseWebLogin: 1 as unknown as boolean
});
expect(legacyWebOnly.allDebridUseWebLogin).toBe(false);
const authorizedWeb = normalizeSettings({
...defaultSettings(),
allDebridToken: "fixture-pin-issued-key",
allDebridUseWebLogin: 1 as unknown as boolean
});
expect(authorizedWeb.allDebridUseWebLogin).toBe(true);
const normalizedDisabled = normalizeSettings({
...defaultSettings(),
allDebridUseWebLogin: 0 as unknown as boolean
});
@@ -1034,6 +1041,35 @@ describe("settings storage", () => {
expect(normalized.historyRetentionMode).toBe("permanent");
});
it("keeps the AllDebrid service status only while AllDebrid is configured", () => {
const status = {
accountId: "svc-alldebrid",
provider: "alldebrid" as const,
label: "AllDebrid",
maskedLogin: "Geschützter API-Key",
valid: true,
isPremium: true,
premiumUntilMs: Date.now() + 1000,
username: "all-user",
email: "all@example.test",
message: "Premium aktiv",
checkedAt: Date.now()
};
const configured = normalizeSettings({
...defaultSettings(),
allDebridToken: "all-api-key",
debridAccountStatuses: { "svc-alldebrid": status }
});
const removed = normalizeSettings({
...defaultSettings(),
debridAccountStatuses: { "svc-alldebrid": status }
});
expect(configured.debridAccountStatuses["svc-alldebrid"]).toEqual(status);
expect(removed.debridAccountStatuses["svc-alldebrid"]).toBeUndefined();
});
it("loads legacy history without inventing structured durations", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);