feat(realdebrid): rotate accounts during unrestrict
This commit is contained in:
+277
-5
@@ -1,17 +1,19 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { defaultSettings, REQUEST_RETRIES } from "../src/main/constants";
|
||||
import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys";
|
||||
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
|
||||
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
|
||||
import { serializeRealDebridApiAccounts } from "../src/shared/real-debrid-accounts";
|
||||
import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits";
|
||||
import { isMegaDebridTransientResolveFailure } from "../src/shared/mega-debrid-errors";
|
||||
import { checkRapidgatorOnline, classifyMegaDebridAccountFailureForTests, clearMegaDebridEmptyResponseStreak, DebridService, extractRapidgatorFilenameFromHtml, fetchAllDebridHostInfo, fetchDebridLinkHostLimits, filenameFromRapidgatorUrlPath, getDebridLinkKeyCooldownStateForTests, getDebridLinkKeyRuntimeStateForTests, getMegaDebridAccountCooldownState, getProviderRuntimeSnapshot, leadProviderChainWith, MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART, MEGA_DEBRID_STICKY_LINKS, normalizeResolvedFilename, parseRapidgatorFileSize, primeMegaDebridRuntimeCooldownForTests, primeMegaDebridUntilRestartForTests, recordMegaDebridEmptyResponseStreak, resetDebridLinkRuntimeStateForTests, resetMegaDebridRuntimeStateForTests } from "../src/main/debrid";
|
||||
import { checkRapidgatorOnline, classifyMegaDebridAccountFailureForTests, clearMegaDebridEmptyResponseStreak, DebridService, extractRapidgatorFilenameFromHtml, fetchAllDebridHostInfo, fetchDebridLinkHostLimits, filenameFromRapidgatorUrlPath, getAvailableRealDebridAccounts, getDebridLinkKeyCooldownStateForTests, getDebridLinkKeyRuntimeStateForTests, getMegaDebridAccountCooldownState, getProviderRuntimeSnapshot, leadProviderChainWith, MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART, MEGA_DEBRID_STICKY_LINKS, normalizeResolvedFilename, parseRapidgatorFileSize, primeMegaDebridRuntimeCooldownForTests, primeMegaDebridUntilRestartForTests, recordMegaDebridEmptyResponseStreak, resetDebridLinkRuntimeStateForTests, resetMegaDebridRuntimeStateForTests, resetRealDebridRuntimeStateForTests } from "../src/main/debrid";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
resetDebridLinkRuntimeStateForTests();
|
||||
resetMegaDebridRuntimeStateForTests();
|
||||
resetDebridLinkRuntimeStateForTests();
|
||||
resetMegaDebridRuntimeStateForTests();
|
||||
resetRealDebridRuntimeStateForTests();
|
||||
delete process.env.RD_MEGA_ABORT_MIN_RUN_MS;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
@@ -2828,7 +2830,277 @@ describe("checkRapidgatorOnline", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("filenameFromRapidgatorUrlPath", () => {
|
||||
describe("Real-Debrid account rotation", () => {
|
||||
const accountSettings = (accounts: Array<{ id: string; token: string }>) => ({
|
||||
...defaultSettings(),
|
||||
token: "",
|
||||
realDebridUseWebLogin: false,
|
||||
realDebridApiTokens: serializeRealDebridApiAccounts(accounts),
|
||||
providerPrimary: "realdebrid" as const,
|
||||
providerSecondary: "none" as const,
|
||||
providerTertiary: "none" as const,
|
||||
providerOrder: ["realdebrid"] as const,
|
||||
autoProviderFallback: false
|
||||
});
|
||||
|
||||
const successResponse = (accountId: string) => new Response(JSON.stringify({
|
||||
download: `https://download.example/${accountId}.bin`,
|
||||
filename: `${accountId}.bin`,
|
||||
filesize: 1234
|
||||
}), { status: 200, headers: { "Content-Type": "application/json" } });
|
||||
|
||||
it("returns the concrete account identity when the first API account succeeds", async () => {
|
||||
globalThis.fetch = (async () => successResponse("rda_one")) as typeof fetch;
|
||||
const service = new DebridService(accountSettings([{ id: "rda_one", token: "token-one" }]));
|
||||
|
||||
const result = await service.unrestrictLink("https://hoster.example/first.bin");
|
||||
|
||||
expect(result.sourceAccountId).toBe("rda_one");
|
||||
expect(result.sourceAccountLabel).toBe("API-Token 1");
|
||||
});
|
||||
|
||||
it("fails over from a rejected API account to the next account in the same call", async () => {
|
||||
const usedTokens: string[] = [];
|
||||
globalThis.fetch = (async (_input, init) => {
|
||||
const token = String((init?.headers as Record<string, string>)?.Authorization || "").replace("Bearer ", "");
|
||||
usedTokens.push(token);
|
||||
return token === "token-one"
|
||||
? new Response(JSON.stringify({ error: "bad_token", error_code: 8 }), { status: 401, headers: { "Content-Type": "application/json" } })
|
||||
: successResponse("rda_two");
|
||||
}) as typeof fetch;
|
||||
const settings = accountSettings([
|
||||
{ id: "rda_one", token: "token-one" },
|
||||
{ id: "rda_two", token: "token-two" }
|
||||
]);
|
||||
const service = new DebridService(settings);
|
||||
|
||||
const result = await service.unrestrictLink("https://hoster.example/failover.bin");
|
||||
|
||||
expect(result.sourceAccountId).toBe("rda_two");
|
||||
expect(usedTokens).toEqual(["token-one", "token-two"]);
|
||||
expect(getAvailableRealDebridAccounts(settings, Date.now() + 3 * 60 * 1000).map((account) => account.id)).toEqual(["rda_two"]);
|
||||
});
|
||||
|
||||
it("cools down a rate-limited account and skips it on the next call", async () => {
|
||||
const usedTokens: string[] = [];
|
||||
globalThis.fetch = (async (_input, init) => {
|
||||
const token = String((init?.headers as Record<string, string>)?.Authorization || "").replace("Bearer ", "");
|
||||
usedTokens.push(token);
|
||||
return token === "token-one"
|
||||
? new Response(JSON.stringify({ error: "too_many_requests", error_code: 34 }), { status: 429, headers: { "Content-Type": "application/json" } })
|
||||
: successResponse("rda_two");
|
||||
}) as typeof fetch;
|
||||
const settings = accountSettings([
|
||||
{ id: "rda_one", token: "token-one" },
|
||||
{ id: "rda_two", token: "token-two" }
|
||||
]);
|
||||
const service = new DebridService(settings);
|
||||
|
||||
await service.unrestrictLink("https://hoster.example/limited-one.bin");
|
||||
const firstCallCount = usedTokens.length;
|
||||
await service.unrestrictLink("https://hoster.example/limited-two.bin");
|
||||
|
||||
expect(usedTokens.slice(firstCallCount)).toEqual(["token-two"]);
|
||||
expect(getAvailableRealDebridAccounts(settings, Date.now() + 3 * 60 * 1000).map((account) => account.id)).toEqual(["rda_two"]);
|
||||
});
|
||||
|
||||
it("rotates from a timed-out API account to an isolated web account", async () => {
|
||||
globalThis.fetch = (async () => { throw new Error("Timeout"); }) as typeof fetch;
|
||||
const webAccounts: string[] = [];
|
||||
const settings = {
|
||||
...accountSettings([{ id: "rda_one", token: "token-one" }]),
|
||||
realDebridWebAccountIds: ["rdw_two"]
|
||||
};
|
||||
const service = new DebridService(settings, {
|
||||
realDebridWebUnrestrict: async (accountId) => {
|
||||
webAccounts.push(accountId);
|
||||
return {
|
||||
fileName: "web.bin",
|
||||
directUrl: "https://download.example/web.bin",
|
||||
fileSize: 4321,
|
||||
retriesUsed: 0
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const result = await service.unrestrictLink("https://hoster.example/web-failover.bin");
|
||||
|
||||
expect(result.sourceAccountId).toBe("rdw_two");
|
||||
expect(webAccounts).toEqual(["rdw_two"]);
|
||||
});
|
||||
|
||||
it("treats a pool with only disabled accounts as not configured", async () => {
|
||||
const settings = {
|
||||
...accountSettings([
|
||||
{ id: "rda_one", token: "token-one" },
|
||||
{ id: "rda_two", token: "token-two" }
|
||||
]),
|
||||
realDebridDisabledAccountIds: ["rda_one", "rda_two"]
|
||||
};
|
||||
const service = new DebridService(settings);
|
||||
|
||||
await expect(service.unrestrictLink("https://hoster.example/disabled.bin")).rejects.toThrow(/nicht konfiguriert/i);
|
||||
});
|
||||
|
||||
it("reports an exhausted pool when every active account reached its own daily limit", async () => {
|
||||
const settings = {
|
||||
...accountSettings([
|
||||
{ id: "rda_one", token: "token-one" },
|
||||
{ id: "rda_two", token: "token-two" }
|
||||
]),
|
||||
providerDailyUsageDay: getProviderUsageDayKey(),
|
||||
realDebridAccountDailyLimitBytes: { rda_one: 100, rda_two: 200 },
|
||||
realDebridAccountDailyUsageBytes: { rda_one: 100, rda_two: 200 }
|
||||
};
|
||||
const service = new DebridService(settings);
|
||||
|
||||
await expect(service.unrestrictLink("https://hoster.example/daily-limit.bin")).rejects.toThrow(/Real-Debrid.*Accounts.*ausgesch/i);
|
||||
});
|
||||
|
||||
it("propagates a caller abort without trying another account", async () => {
|
||||
const controller = new AbortController();
|
||||
const attempted: string[] = [];
|
||||
const settings = {
|
||||
...accountSettings([]),
|
||||
realDebridWebAccountIds: ["rdw_one", "rdw_two"]
|
||||
};
|
||||
const service = new DebridService(settings, {
|
||||
realDebridWebUnrestrict: async (accountId) => {
|
||||
attempted.push(accountId);
|
||||
controller.abort("stop");
|
||||
throw new Error("aborted:stop");
|
||||
}
|
||||
});
|
||||
|
||||
await expect(service.unrestrictLink("https://hoster.example/abort.bin", controller.signal)).rejects.toThrow(/aborted/i);
|
||||
expect(attempted).toEqual(["rdw_one"]);
|
||||
});
|
||||
|
||||
it("shares sequential successes fairly across the available API accounts", async () => {
|
||||
const usedTokens: string[] = [];
|
||||
globalThis.fetch = (async (_input, init) => {
|
||||
const token = String((init?.headers as Record<string, string>)?.Authorization || "").replace("Bearer ", "");
|
||||
usedTokens.push(token);
|
||||
return successResponse(token === "token-one" ? "rda_one" : "rda_two");
|
||||
}) as typeof fetch;
|
||||
const service = new DebridService(accountSettings([
|
||||
{ id: "rda_one", token: "token-one" },
|
||||
{ id: "rda_two", token: "token-two" }
|
||||
]));
|
||||
|
||||
for (let index = 0; index < 8; index += 1) {
|
||||
await service.unrestrictLink(`https://hoster.example/fair-${index}.bin`);
|
||||
}
|
||||
|
||||
expect(usedTokens.filter((token) => token === "token-one")).toHaveLength(4);
|
||||
expect(usedTokens.filter((token) => token === "token-two")).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("keeps round-robin fair when the middle configured account is disabled", async () => {
|
||||
const usedTokens: string[] = [];
|
||||
globalThis.fetch = (async (_input, init) => {
|
||||
const token = String((init?.headers as Record<string, string>)?.Authorization || "").replace("Bearer ", "");
|
||||
usedTokens.push(token);
|
||||
return successResponse(token === "token-one" ? "rda_one" : "rda_three");
|
||||
}) as typeof fetch;
|
||||
const settings = {
|
||||
...accountSettings([
|
||||
{ id: "rda_one", token: "token-one" },
|
||||
{ id: "rda_two", token: "token-two" },
|
||||
{ id: "rda_three", token: "token-three" }
|
||||
]),
|
||||
realDebridDisabledAccountIds: ["rda_two"]
|
||||
};
|
||||
const service = new DebridService(settings);
|
||||
|
||||
for (let index = 0; index < 16; index += 1) {
|
||||
await service.unrestrictLink(`https://hoster.example/filtered-fair-${index}.bin`);
|
||||
}
|
||||
|
||||
expect(usedTokens.filter((token) => token === "token-one")).toHaveLength(8);
|
||||
expect(usedTokens.filter((token) => token === "token-three")).toHaveLength(8);
|
||||
expect(usedTokens).not.toContain("token-two");
|
||||
});
|
||||
|
||||
it("does not rotate or cool down an account for a permanent link error", async () => {
|
||||
const usedTokens: string[] = [];
|
||||
globalThis.fetch = (async (_input, init) => {
|
||||
const token = String((init?.headers as Record<string, string>)?.Authorization || "").replace("Bearer ", "");
|
||||
usedTokens.push(token);
|
||||
return new Response(JSON.stringify({ error: "file_unavailable", error_code: 22 }), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
}) as typeof fetch;
|
||||
const service = new DebridService(accountSettings([
|
||||
{ id: "rda_one", token: "token-one" },
|
||||
{ id: "rda_two", token: "token-two" }
|
||||
]));
|
||||
|
||||
await expect(service.unrestrictLink("https://hoster.example/missing.bin")).rejects.toThrow(/file_unavailable/i);
|
||||
|
||||
expect(usedTokens).toEqual(["token-one"]);
|
||||
expect(getProviderRuntimeSnapshot().realDebrid.cooldownCount).toBe(0);
|
||||
});
|
||||
|
||||
it("does not rotate or cool down accounts for a provider-wide hoster_unavailable response", async () => {
|
||||
const usedTokens: string[] = [];
|
||||
globalThis.fetch = (async (_input, init) => {
|
||||
const token = String((init?.headers as Record<string, string>)?.Authorization || "").replace("Bearer ", "");
|
||||
const link = String((init?.body as URLSearchParams)?.get("link") || "");
|
||||
usedTokens.push(token);
|
||||
if (link.includes("hoster-down")) {
|
||||
return new Response(JSON.stringify({ error: "hoster_unavailable", error_code: 19 }), {
|
||||
status: 503,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
}
|
||||
return successResponse("rda_one");
|
||||
}) as typeof fetch;
|
||||
const service = new DebridService(accountSettings([
|
||||
{ id: "rda_one", token: "token-one" },
|
||||
{ id: "rda_two", token: "token-two" }
|
||||
]));
|
||||
|
||||
await expect(service.unrestrictLink("https://hoster-down.example/file.bin")).rejects.toThrow(/hoster_unavailable/i);
|
||||
|
||||
expect(new Set(usedTokens)).toEqual(new Set(["token-one"]));
|
||||
expect(getProviderRuntimeSnapshot().realDebrid.cooldownCount).toBe(0);
|
||||
|
||||
usedTokens.length = 0;
|
||||
const result = await service.unrestrictLink("https://hoster-up.example/file.bin");
|
||||
expect(result.sourceAccountId).toBe("rda_one");
|
||||
expect(usedTokens).toEqual(["token-one"]);
|
||||
});
|
||||
|
||||
it("routes concurrent unrestrict calls to the least busy accounts", async () => {
|
||||
const usedTokens: string[] = [];
|
||||
let releaseResponses: (() => void) | null = null;
|
||||
const responseGate = new Promise<void>((resolve) => { releaseResponses = resolve; });
|
||||
globalThis.fetch = (async (_input, init) => {
|
||||
const token = String((init?.headers as Record<string, string>)?.Authorization || "").replace("Bearer ", "");
|
||||
usedTokens.push(token);
|
||||
if (usedTokens.length === 2) {
|
||||
releaseResponses?.();
|
||||
}
|
||||
await responseGate;
|
||||
return successResponse(token === "token-one" ? "rda_one" : "rda_two");
|
||||
}) as typeof fetch;
|
||||
const service = new DebridService(accountSettings([
|
||||
{ id: "rda_one", token: "token-one" },
|
||||
{ id: "rda_two", token: "token-two" }
|
||||
]));
|
||||
|
||||
await Promise.all([
|
||||
service.unrestrictLink("https://hoster.example/concurrent-one.bin"),
|
||||
service.unrestrictLink("https://hoster.example/concurrent-two.bin")
|
||||
]);
|
||||
|
||||
expect(usedTokens).toEqual(["token-one", "token-two"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filenameFromRapidgatorUrlPath", () => {
|
||||
it("extracts filename from standard rapidgator URL", () => {
|
||||
expect(filenameFromRapidgatorUrlPath("https://rapidgator.net/file/abc123/Show.S01E01.part01.rar.html"))
|
||||
.toBe("Show.S01E01.part01.rar");
|
||||
|
||||
@@ -6,7 +6,7 @@ import crypto from "node:crypto";
|
||||
import { EventEmitter, once } from "node:events";
|
||||
import AdmZip from "adm-zip";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { DownloadManager, buildAutoRenameBaseNameFromFoldersWithOptions, extractArchiveNameFromExtractorLogMessage, getAuthoritativeRealDebridTotal, getDiskWriteWaitReason, resolveArchiveItemsFromList, runWithLimitedConcurrency } from "../src/main/download-manager";
|
||||
import { DownloadManager, buildAutoRenameBaseNameFromFoldersWithOptions, extractArchiveNameFromExtractorLogMessage, getAuthoritativeRealDebridTotal, getDiskWriteWaitReason, resolveArchiveItemsFromList, resolveUnrestrictTimeoutBudgetMs, runWithLimitedConcurrency } from "../src/main/download-manager";
|
||||
import { planDownloadCompletion, validateDownloadedFileCompletion } from "../src/main/download-completion";
|
||||
import { DiskReservationCoordinator } from "../src/main/disk-space";
|
||||
import { defaultSettings } from "../src/main/constants";
|
||||
@@ -15,8 +15,9 @@ import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits";
|
||||
import { getItemLogPath, initItemLogs, shutdownItemLogs } from "../src/main/item-log";
|
||||
import { initPackageLogs, shutdownPackageLogs } from "../src/main/package-log";
|
||||
import { createStoragePaths, emptySession } from "../src/main/storage";
|
||||
import { primeDebridLinkRuntimeCooldownForTests, resetDebridLinkRuntimeStateForTests, primeMegaDebridRuntimeCooldownForTests, resetMegaDebridRuntimeStateForTests, primeMegaDebridInFlightForTests } from "../src/main/debrid";
|
||||
import { getProviderRuntimeSnapshot, primeDebridLinkRuntimeCooldownForTests, resetDebridLinkRuntimeStateForTests, primeMegaDebridRuntimeCooldownForTests, resetMegaDebridRuntimeStateForTests, primeMegaDebridInFlightForTests, primeRealDebridRuntimeCooldownForTests, resetRealDebridRuntimeStateForTests } from "../src/main/debrid";
|
||||
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
|
||||
import { serializeRealDebridApiAccounts } from "../src/shared/real-debrid-accounts";
|
||||
import { getRenameLogPath, initRenameLog, shutdownRenameLog } from "../src/main/rename-log";
|
||||
import { UnrestrictedLink } from "../src/main/realdebrid";
|
||||
import { resetVideoToolingCache } from "../src/main/video-processor";
|
||||
@@ -44,6 +45,48 @@ describe("runWithLimitedConcurrency", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveUnrestrictTimeoutBudgetMs", () => {
|
||||
it("covers the complete provider plan and each later Real-Debrid account attempt", () => {
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
debridLinkApiKeys: "dl-key",
|
||||
bestToken: "best-token",
|
||||
realDebridApiTokens: serializeRealDebridApiAccounts([
|
||||
{ id: "rda_one", token: "token-one" },
|
||||
{ id: "rda_two", token: "token-two" },
|
||||
{ id: "rda_three", token: "token-three" }
|
||||
]),
|
||||
realDebridDisabledAccountIds: ["rda_three"],
|
||||
providerOrder: ["debridlink", "realdebrid", "bestdebrid"] as const,
|
||||
autoProviderFallback: true
|
||||
};
|
||||
|
||||
expect(resolveUnrestrictTimeoutBudgetMs(5_000, "debridlink", settings, "https://hoster.example/file", 35_000)).toBe(80_000);
|
||||
});
|
||||
|
||||
it("includes a Real-Debrid pool selected only by hoster routing", () => {
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
debridLinkApiKeys: "dl-key",
|
||||
realDebridApiTokens: serializeRealDebridApiAccounts([
|
||||
{ id: "rda_one", token: "token-one" },
|
||||
{ id: "rda_two", token: "token-two" }
|
||||
]),
|
||||
providerOrder: ["debridlink"] as const,
|
||||
hosterRouting: { rapidgator: "realdebrid" as const },
|
||||
autoProviderFallback: true
|
||||
};
|
||||
|
||||
expect(resolveUnrestrictTimeoutBudgetMs(
|
||||
5_000,
|
||||
null,
|
||||
settings,
|
||||
"https://rapidgator.net/file/abc123/file.rar.html",
|
||||
35_000
|
||||
)).toBeGreaterThanOrEqual(70_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("disk write recovery", () => {
|
||||
it("classifies retryable disk write stalls without treating permission errors as temporary", () => {
|
||||
expect(getDiskWriteWaitReason(Object.assign(new Error("write ENOSPC"), { code: "ENOSPC" }))).toMatch(/Festplatte voll/);
|
||||
@@ -765,6 +808,7 @@ afterEach(async () => {
|
||||
resetVideoToolingCache();
|
||||
resetDebridLinkRuntimeStateForTests();
|
||||
resetMegaDebridRuntimeStateForTests();
|
||||
resetRealDebridRuntimeStateForTests();
|
||||
shutdownItemLogs();
|
||||
shutdownPackageLogs();
|
||||
shutdownRenameLog();
|
||||
@@ -13158,7 +13202,7 @@ describe("download manager", () => {
|
||||
expect((internal.settings.providerTotalUsageBytes as Record<string, number>).megadebrid).toBeUndefined();
|
||||
});
|
||||
|
||||
it("tracks daily usage on the actual Debrid-Link key without touching other keys", () => {
|
||||
it("tracks daily usage on the actual Debrid-Link key without touching other keys", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||
tempDirs.push(root);
|
||||
const [firstKey, secondKey] = parseDebridLinkApiKeys("dl-key-one\ndl-key-two");
|
||||
@@ -13189,9 +13233,77 @@ describe("download manager", () => {
|
||||
expect(internal.settings.debridLinkApiKeyDailyUsageBytes[firstKey.id]).toBe(1024);
|
||||
expect(internal.settings.debridLinkApiKeyDailyUsageBytes[secondKey.id]).toBe(512);
|
||||
expect(internal.settings.debridLinkApiKeyTotalUsageBytes[firstKey.id]).toBe(1024);
|
||||
expect(internal.settings.debridLinkApiKeyTotalUsageBytes[secondKey.id]).toBe(2048);
|
||||
});
|
||||
|
||||
expect(internal.settings.debridLinkApiKeyTotalUsageBytes[secondKey.id]).toBe(2048);
|
||||
});
|
||||
|
||||
it("tracks Real-Debrid traffic only on the account that produced the direct link", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||
tempDirs.push(root);
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
providerDailyUsageDay: getProviderUsageDayKey(),
|
||||
realDebridApiTokens: serializeRealDebridApiAccounts([
|
||||
{ id: "rda_one", token: "token-one" },
|
||||
{ id: "rda_two", token: "token-two" }
|
||||
]),
|
||||
realDebridAccountDailyUsageBytes: { rda_one: 100, rda_two: 200 },
|
||||
realDebridAccountTotalUsageBytes: { rda_one: 1000, rda_two: 2000 }
|
||||
};
|
||||
const manager = new DownloadManager(settings, emptySession(), createStoragePaths(path.join(root, "state")));
|
||||
const internal = manager as unknown as {
|
||||
recordProviderDownloadedBytes: (provider: "realdebrid", bytes: number, providerAccountId?: string) => void;
|
||||
settings: typeof settings;
|
||||
};
|
||||
|
||||
internal.recordProviderDownloadedBytes("realdebrid", 50, "rda_two");
|
||||
|
||||
expect(internal.settings.realDebridAccountDailyUsageBytes).toEqual({ rda_one: 100, rda_two: 250 });
|
||||
expect(internal.settings.realDebridAccountTotalUsageBytes).toEqual({ rda_one: 1000, rda_two: 2050 });
|
||||
});
|
||||
|
||||
it("does not recreate account usage when the source account was removed during the download", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||
tempDirs.push(root);
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
providerDailyUsageDay: getProviderUsageDayKey(),
|
||||
providerDailyUsageBytes: { realdebrid: 100 },
|
||||
providerTotalUsageBytes: { realdebrid: 1000 },
|
||||
realDebridApiTokens: serializeRealDebridApiAccounts([{ id: "rda_one", token: "token-one" }]),
|
||||
realDebridAccountDailyUsageBytes: {},
|
||||
realDebridAccountTotalUsageBytes: {}
|
||||
};
|
||||
const manager = new DownloadManager(settings, emptySession(), createStoragePaths(path.join(root, "state")));
|
||||
manager.setSettings({ ...settings, realDebridApiTokens: "" });
|
||||
const internal = manager as unknown as {
|
||||
recordProviderDownloadedBytes: (provider: "realdebrid", bytes: number, providerAccountId?: string) => void;
|
||||
settings: typeof settings;
|
||||
};
|
||||
|
||||
internal.recordProviderDownloadedBytes("realdebrid", 50, "rda_one");
|
||||
|
||||
expect(internal.settings.providerDailyUsageBytes.realdebrid).toBe(150);
|
||||
expect(internal.settings.providerTotalUsageBytes.realdebrid).toBe(1050);
|
||||
expect(internal.settings.realDebridAccountDailyUsageBytes).toEqual({});
|
||||
expect(internal.settings.realDebridAccountTotalUsageBytes).toEqual({});
|
||||
});
|
||||
|
||||
it("prunes removed Real-Debrid account runtime state on a live settings update", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||
tempDirs.push(root);
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
realDebridApiTokens: serializeRealDebridApiAccounts([{ id: "rda_one", token: "token-one" }])
|
||||
};
|
||||
const manager = new DownloadManager(settings, emptySession(), createStoragePaths(path.join(root, "state")));
|
||||
primeRealDebridRuntimeCooldownForTests("rda_one", 60_000);
|
||||
expect(getProviderRuntimeSnapshot().realDebrid.cooldownCount).toBe(1);
|
||||
|
||||
manager.setSettings({ ...settings, realDebridApiTokens: "" });
|
||||
|
||||
expect(getProviderRuntimeSnapshot().realDebrid.cooldownCount).toBe(0);
|
||||
});
|
||||
|
||||
it("does not hang when rapid stop is followed by disabling the last provider", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||
tempDirs.push(root);
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { defaultSettings } from "../src/main/constants";
|
||||
import {
|
||||
addRealDebridAccountDailyUsageBytes,
|
||||
addRealDebridAccountTotalUsageBytes,
|
||||
getProviderUsageDayKey,
|
||||
getRealDebridAccountDailyRemainingBytes,
|
||||
getRealDebridAccountDailyUsageBytes,
|
||||
getRealDebridAccountTotalUsageBytes,
|
||||
isRealDebridAccountDailyLimitReached,
|
||||
resetRealDebridAccountDailyUsage
|
||||
} from "../src/shared/provider-daily-limits";
|
||||
|
||||
describe("Real-Debrid account usage", () => {
|
||||
it("counts daily and lifetime traffic only for the selected account", () => {
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
providerDailyUsageDay: getProviderUsageDayKey(),
|
||||
realDebridAccountDailyUsageBytes: { rda_one: 100, rda_two: 200 },
|
||||
realDebridAccountTotalUsageBytes: { rda_one: 1000, rda_two: 2000 }
|
||||
};
|
||||
|
||||
const daily = addRealDebridAccountDailyUsageBytes(settings, "rda_two", 50);
|
||||
const total = addRealDebridAccountTotalUsageBytes(settings, "rda_two", 50);
|
||||
|
||||
expect(daily.realDebridAccountDailyUsageBytes).toEqual({ rda_one: 100, rda_two: 250 });
|
||||
expect(total.realDebridAccountTotalUsageBytes).toEqual({ rda_one: 1000, rda_two: 2050 });
|
||||
});
|
||||
|
||||
it("resets stale daily usage before adding new account traffic", () => {
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
providerDailyUsageDay: "2000-01-01",
|
||||
realDebridAccountDailyUsageBytes: { rda_one: 900 }
|
||||
};
|
||||
|
||||
const next = addRealDebridAccountDailyUsageBytes(settings, "rda_two", 75);
|
||||
|
||||
expect(next.providerDailyUsageDay).toBe(getProviderUsageDayKey());
|
||||
expect(next.realDebridAccountDailyUsageBytes).toEqual({ rda_two: 75 });
|
||||
expect(getRealDebridAccountDailyUsageBytes(settings, "rda_one")).toBe(0);
|
||||
});
|
||||
|
||||
it("marks only the account whose own daily limit is reached", () => {
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
providerDailyUsageDay: getProviderUsageDayKey(),
|
||||
realDebridAccountDailyLimitBytes: { rda_one: 100, rda_two: 500 },
|
||||
realDebridAccountDailyUsageBytes: { rda_one: 100, rda_two: 100 }
|
||||
};
|
||||
|
||||
expect(isRealDebridAccountDailyLimitReached(settings, "rda_one")).toBe(true);
|
||||
expect(isRealDebridAccountDailyLimitReached(settings, "rda_two")).toBe(false);
|
||||
expect(getRealDebridAccountDailyRemainingBytes(settings, "rda_two")).toBe(400);
|
||||
expect(getRealDebridAccountTotalUsageBytes({ ...settings, realDebridAccountTotalUsageBytes: { rda_two: 900 } }, "rda_two")).toBe(900);
|
||||
});
|
||||
|
||||
it("resets one Real-Debrid account without clearing the others", () => {
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
providerDailyUsageDay: getProviderUsageDayKey(),
|
||||
realDebridAccountDailyUsageBytes: { rda_one: 100, rda_two: 200 }
|
||||
};
|
||||
|
||||
const next = resetRealDebridAccountDailyUsage(settings, "rda_one");
|
||||
|
||||
expect(next.realDebridAccountDailyUsageBytes).toEqual({ rda_two: 200 });
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { RealDebridClient } from "../src/main/realdebrid";
|
||||
import { RealDebridApiError, RealDebridClient } from "../src/main/realdebrid";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
@@ -20,7 +20,7 @@ describe("realdebrid client", () => {
|
||||
await expect(client.unrestrictLink("https://hoster.example/file/html")).rejects.toThrow(/html/i);
|
||||
});
|
||||
|
||||
it("does not leak raw response body on JSON parse errors", async () => {
|
||||
it("does not leak raw response body on JSON parse errors", async () => {
|
||||
globalThis.fetch = (async (): Promise<Response> => {
|
||||
return new Response("<html>token=secret-should-not-leak</html>", {
|
||||
status: 200,
|
||||
@@ -37,6 +37,38 @@ describe("realdebrid client", () => {
|
||||
expect(text.toLowerCase()).toContain("json");
|
||||
expect(text.toLowerCase()).not.toContain("secret-should-not-leak");
|
||||
expect(text.toLowerCase()).not.toContain("<html>");
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves the HTTP status and structured bad_token error", async () => {
|
||||
globalThis.fetch = (async () => new Response(JSON.stringify({
|
||||
error: "bad_token",
|
||||
error_code: 8
|
||||
}), {
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
})) as typeof fetch;
|
||||
|
||||
const client = new RealDebridClient("rd-token");
|
||||
const error = await client.unrestrictLink("https://hoster.example/file/auth").then(() => null, (value) => value);
|
||||
|
||||
expect(error).toBeInstanceOf(RealDebridApiError);
|
||||
expect(error).toMatchObject({ status: 401, apiError: "bad_token", apiErrorCode: 8 });
|
||||
});
|
||||
|
||||
it("preserves too_many_requests after the client's retries", async () => {
|
||||
globalThis.fetch = (async () => new Response(JSON.stringify({
|
||||
error: "too_many_requests",
|
||||
error_code: 34
|
||||
}), {
|
||||
status: 429,
|
||||
headers: { "Content-Type": "application/json", "Retry-After": "0" }
|
||||
})) as typeof fetch;
|
||||
|
||||
const client = new RealDebridClient("rd-token");
|
||||
const error = await client.unrestrictLink("https://hoster.example/file/rate").then(() => null, (value) => value);
|
||||
|
||||
expect(error).toBeInstanceOf(RealDebridApiError);
|
||||
expect(error).toMatchObject({ status: 429, apiError: "too_many_requests", apiErrorCode: 34 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,8 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { defaultSettings } from "../src/main/constants";
|
||||
import { defaultSettings } from "../src/main/constants";
|
||||
import { serializeRealDebridApiAccounts } from "../src/shared/real-debrid-accounts";
|
||||
import { createStoragePaths } from "../src/main/storage";
|
||||
import { runStartupHealthCheck } from "../src/main/startup-health-check";
|
||||
|
||||
@@ -66,7 +67,7 @@ describe("runStartupHealthCheck", () => {
|
||||
expect(report.warnCount).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("reports configured providers when at least one credential is set", () => {
|
||||
it("reports configured providers when at least one credential is set", () => {
|
||||
const { outputDir, paths } = makeTempBase();
|
||||
fs.mkdirSync(paths.baseDir, { recursive: true });
|
||||
|
||||
@@ -82,7 +83,27 @@ describe("runStartupHealthCheck", () => {
|
||||
expect(providersFinding?.message).toContain("Real-Debrid");
|
||||
expect(providersFinding?.message).toContain("Debrid-Link");
|
||||
expect(providersFinding?.message).toContain("2 Keys");
|
||||
});
|
||||
});
|
||||
|
||||
it("recognizes a Real-Debrid account pool without legacy singleton fields", () => {
|
||||
const { outputDir, paths } = makeTempBase();
|
||||
fs.mkdirSync(paths.baseDir, { recursive: true });
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
token: "",
|
||||
realDebridUseWebLogin: false,
|
||||
outputDir,
|
||||
realDebridApiTokens: serializeRealDebridApiAccounts([
|
||||
{ id: "rda_one", token: "token-one" },
|
||||
{ id: "rda_two", token: "token-two" }
|
||||
])
|
||||
};
|
||||
|
||||
const report = runStartupHealthCheck(settings, paths);
|
||||
const providersFinding = report.findings.find((finding) => finding.code === "providers_configured");
|
||||
|
||||
expect(providersFinding?.message).toContain("Real-Debrid (2 Accounts)");
|
||||
});
|
||||
|
||||
it("flags large state files", () => {
|
||||
const { outputDir, paths } = makeTempBase();
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { defaultSettings } from "../src/main/constants";
|
||||
import { buildAccountSummary } from "../src/main/support-data";
|
||||
import { serializeRealDebridApiAccounts } from "../src/shared/real-debrid-accounts";
|
||||
|
||||
describe("Real-Debrid support summary", () => {
|
||||
it("reports pool counts without exposing account IDs or credentials", () => {
|
||||
const summary = buildAccountSummary({
|
||||
...defaultSettings(),
|
||||
realDebridApiTokens: serializeRealDebridApiAccounts([
|
||||
{ id: "rda_private_one", token: "secret-one" },
|
||||
{ id: "rda_private_two", token: "secret-two" }
|
||||
]),
|
||||
realDebridWebAccountIds: ["rdw_private_three"],
|
||||
realDebridDisabledAccountIds: ["rda_private_two"]
|
||||
});
|
||||
const realDebrid = summary.realDebrid as Record<string, unknown>;
|
||||
const serialized = JSON.stringify(realDebrid);
|
||||
|
||||
expect(realDebrid).toMatchObject({
|
||||
configured: true,
|
||||
accountCount: 3,
|
||||
enabledAccountCount: 2,
|
||||
disabledAccountCount: 1,
|
||||
apiAccountCount: 2,
|
||||
webAccountCount: 1
|
||||
});
|
||||
expect(serialized).not.toContain("rda_private");
|
||||
expect(serialized).not.toContain("rdw_private");
|
||||
expect(serialized).not.toContain("secret-");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user