Inspect public ddownload.com and ddl.to links before download startup to resolve canonical filenames, exact sizes and availability without consuming a debrid account. Keep removed files offline, protected pages retryable, restored queues recheckable and previously resolved names safe from later download.bin responses. Reuse the metadata path in the link collector, queue import and direct DDownload login flow.
3329 lines
137 KiB
TypeScript
3329 lines
137 KiB
TypeScript
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 { serializeRealDebridApiAccounts } from "../src/shared/real-debrid-accounts";
|
|
import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits";
|
|
import { isMegaDebridTransientResolveFailure } from "../src/shared/mega-debrid-errors";
|
|
import { checkDdownloadOnline, checkOneFichierLinks, checkRapidgatorOnline, classifyMegaDebridAccountFailureForTests, clearMegaDebridEmptyResponseStreak, DebridService, extractDdownloadFilenameFromHtml, extractRapidgatorFilenameFromHtml, fetchAllDebridHostInfo, fetchDebridLinkHostLimits, filenameFromDdownloadUrlPath, filenameFromRapidgatorUrlPath, getAvailableRealDebridAccounts, getDebridLinkKeyCooldownStateForTests, getDebridLinkKeyRuntimeStateForTests, getMegaDebridAccountCooldownState, getProviderRuntimeSnapshot, isDdownloadLink, isOneFichierLink, leadProviderChainWith, MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART, MEGA_DEBRID_STICKY_LINKS, normalizeResolvedFilename, parseDdownloadFileSize, parseRapidgatorFileSize, primeMegaDebridRuntimeCooldownForTests, primeMegaDebridUntilRestartForTests, recordMegaDebridEmptyResponseStreak, resetDebridLinkRuntimeStateForTests, resetMegaDebridRuntimeStateForTests, resetRealDebridRuntimeStateForTests } from "../src/main/debrid";
|
|
import { getAccountRuntimeSessionStats } from "../src/main/account-runtime";
|
|
|
|
const originalFetch = globalThis.fetch;
|
|
|
|
afterEach(() => {
|
|
globalThis.fetch = originalFetch;
|
|
resetDebridLinkRuntimeStateForTests();
|
|
resetMegaDebridRuntimeStateForTests();
|
|
resetRealDebridRuntimeStateForTests();
|
|
delete process.env.RD_MEGA_ABORT_MIN_RUN_MS;
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe("leadProviderChainWith", () => {
|
|
it("leaves the order unchanged when no preferred provider is given", () => {
|
|
expect(leadProviderChainWith(["realdebrid", "debridlink", "alldebrid"], null)).toEqual(["realdebrid", "debridlink", "alldebrid"]);
|
|
expect(leadProviderChainWith(["realdebrid", "debridlink"], undefined)).toEqual(["realdebrid", "debridlink"]);
|
|
});
|
|
|
|
it("leads with the preferred provider but keeps every other provider as a later fallback", () => {
|
|
expect(leadProviderChainWith(["realdebrid", "debridlink", "alldebrid"], "alldebrid")).toEqual(["alldebrid", "realdebrid", "debridlink"]);
|
|
});
|
|
|
|
it("does not drop or strand any provider when the preferred one is not in the order", () => {
|
|
expect(leadProviderChainWith(["realdebrid", "debridlink"], "alldebrid")).toEqual(["realdebrid", "debridlink"]);
|
|
});
|
|
});
|
|
|
|
describe("debrid service", () => {
|
|
it("falls back to Mega web when Real-Debrid fails", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
megaLogin: "user",
|
|
megaPassword: "pass",
|
|
megaCredentials: "user:pass",
|
|
bestToken: "",
|
|
providerOrder: [] as const,
|
|
providerPrimary: "realdebrid" as const,
|
|
providerSecondary: "megadebrid" as const,
|
|
providerTertiary: "bestdebrid" 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_limit" }), {
|
|
status: 403,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}
|
|
return new Response("not-found", { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
const megaWeb = vi.fn(async () => ({
|
|
fileName: "file.bin",
|
|
directUrl: "https://mega-web.example/file.bin",
|
|
fileSize: null,
|
|
retriesUsed: 0
|
|
}));
|
|
|
|
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
|
const result = await service.unrestrictLink("https://rapidgator.net/file/example.part1.rar.html");
|
|
expect(result.provider).toBe("megadebrid");
|
|
expect(result.directUrl).toBe("https://mega-web.example/file.bin");
|
|
expect(megaWeb).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("does not fallback when auto fallback is disabled", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
megaLogin: "user",
|
|
megaPassword: "pass",
|
|
megaCredentials: "user:pass",
|
|
providerPrimary: "realdebrid" as const,
|
|
providerSecondary: "megadebrid" as const,
|
|
providerTertiary: "bestdebrid" as const,
|
|
autoProviderFallback: false
|
|
};
|
|
|
|
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("traffic exhausted", { status: 429 });
|
|
}
|
|
return new Response("not-found", { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
const megaWeb = vi.fn(async () => ({
|
|
fileName: "unused.bin",
|
|
directUrl: "https://unused",
|
|
fileSize: null,
|
|
retriesUsed: 0
|
|
}));
|
|
|
|
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);
|
|
});
|
|
|
|
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[] = [];
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
debridLinkApiKeys: "dl-token",
|
|
providerOrder: ["realdebrid", "debridlink"] as const,
|
|
providerPrimary: "realdebrid" as const,
|
|
providerSecondary: "debridlink" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: true,
|
|
providerDailyLimitBytes: { realdebrid: 100 },
|
|
providerDailyUsageBytes: { realdebrid: 100 },
|
|
providerDailyUsageDay: getProviderUsageDayKey()
|
|
};
|
|
|
|
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
calledUrls.push(url);
|
|
if (url.includes("debrid-link.com/api/v2/downloader/add")) {
|
|
return new Response(JSON.stringify({
|
|
success: true,
|
|
value: {
|
|
downloadUrl: "https://debrid-link.example/file.bin",
|
|
name: "file.bin",
|
|
size: 1234
|
|
}
|
|
}), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}
|
|
if (url.includes("api.real-debrid.com/rest/1.0/unrestrict/link")) {
|
|
throw new Error("Real-Debrid should have been skipped due to daily limit");
|
|
}
|
|
return new Response("not-found", { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
const service = new DebridService(settings);
|
|
const result = await service.unrestrictLink("https://hoster.example/file.bin");
|
|
expect(result.provider).toBe("debridlink");
|
|
expect(result.directUrl).toBe("https://debrid-link.example/file.bin");
|
|
expect(calledUrls.some((url) => url.includes("api.real-debrid.com/rest/1.0/unrestrict/link"))).toBe(false);
|
|
});
|
|
|
|
it("leads the provider chain with the preferred (non-cooled) provider when one is hinted", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
debridLinkApiKeys: "dl-token",
|
|
providerOrder: ["realdebrid", "debridlink"] as const,
|
|
providerPrimary: "realdebrid" as const,
|
|
providerSecondary: "debridlink" as const,
|
|
providerTertiary: "none" 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("debrid-link.com/api/v2/downloader/add")) {
|
|
return new Response(JSON.stringify({
|
|
success: true,
|
|
value: { downloadUrl: "https://debrid-link.example/file.bin", name: "file.bin", size: 1234 }
|
|
}), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
}
|
|
if (url.includes("api.real-debrid.com/rest/1.0/unrestrict/link")) {
|
|
return new Response(JSON.stringify({
|
|
download: "https://rd.example/file.bin",
|
|
filename: "file.bin",
|
|
filesize: 1234
|
|
}), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
}
|
|
return new Response("not-found", { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
const service = new DebridService(settings);
|
|
const control = await service.unrestrictLink("https://hoster.example/lead-control.bin");
|
|
expect(control.provider).toBe("realdebrid");
|
|
|
|
const preferred = await service.unrestrictLink("https://hoster.example/lead-pref.bin", undefined, undefined, "debridlink");
|
|
expect(preferred.provider).toBe("debridlink");
|
|
});
|
|
|
|
it("uses the next Debrid-Link key when the first key hit its local daily limit", async () => {
|
|
const keys = parseDebridLinkApiKeys("dl-key-one\ndl-key-two");
|
|
let usedAuthHeader = "";
|
|
const settings = {
|
|
...defaultSettings(),
|
|
debridLinkApiKeys: "dl-key-one\ndl-key-two",
|
|
providerOrder: ["debridlink"] as const,
|
|
providerPrimary: "debridlink" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
debridLinkApiKeyDailyLimitBytes: {
|
|
[keys[0].id]: 100
|
|
},
|
|
debridLinkApiKeyDailyUsageBytes: {
|
|
[keys[0].id]: 100
|
|
},
|
|
providerDailyUsageDay: getProviderUsageDayKey()
|
|
};
|
|
|
|
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
const headers = init?.headers;
|
|
if (headers instanceof Headers) {
|
|
usedAuthHeader = headers.get("Authorization") || "";
|
|
} else if (Array.isArray(headers)) {
|
|
usedAuthHeader = headers.find(([key]) => key.toLowerCase() === "authorization")?.[1] || "";
|
|
} else {
|
|
usedAuthHeader = String((headers as Record<string, unknown> | undefined)?.Authorization || "");
|
|
}
|
|
return new Response(JSON.stringify({
|
|
success: true,
|
|
value: {
|
|
downloadUrl: "https://debrid-link.example/file.bin",
|
|
name: "file.bin",
|
|
size: 1234
|
|
}
|
|
}), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}) as typeof fetch;
|
|
|
|
const service = new DebridService(settings);
|
|
const result = await service.unrestrictLink("https://hoster.example/file.bin");
|
|
|
|
expect(usedAuthHeader).toBe("Bearer dl-key-two");
|
|
expect(result.provider).toBe("debridlink");
|
|
expect(result.providerLabel).toContain("Key 2");
|
|
});
|
|
|
|
it("uses JSON add payload and refreshes missing Debrid-Link downloadUrl via downloader/list", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
debridLinkApiKeys: "dl-key-one",
|
|
providerOrder: ["debridlink"] as const,
|
|
providerPrimary: "debridlink" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: true
|
|
};
|
|
|
|
let addBody = "";
|
|
let addContentType = "";
|
|
let addAccept = "";
|
|
const calledUrls: string[] = [];
|
|
|
|
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
calledUrls.push(url);
|
|
if (url.includes("debrid-link.com/api/v2/downloader/add")) {
|
|
const headers = init?.headers;
|
|
if (headers instanceof Headers) {
|
|
addContentType = headers.get("Content-Type") || "";
|
|
addAccept = headers.get("Accept") || "";
|
|
} else if (Array.isArray(headers)) {
|
|
addContentType = headers.find(([key]) => key.toLowerCase() === "content-type")?.[1] || "";
|
|
addAccept = headers.find(([key]) => key.toLowerCase() === "accept")?.[1] || "";
|
|
} else {
|
|
addContentType = String((headers as Record<string, unknown> | undefined)?.["Content-Type"] || "");
|
|
addAccept = String((headers as Record<string, unknown> | undefined)?.Accept || "");
|
|
}
|
|
addBody = String(init?.body || "");
|
|
return new Response(JSON.stringify({
|
|
success: true,
|
|
value: {
|
|
id: "dl-link-1",
|
|
url: "https://hoster.example/file.bin",
|
|
name: "file.bin",
|
|
expired: true
|
|
}
|
|
}), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}
|
|
if (url.includes("debrid-link.com/api/v2/downloader/list?ids=dl-link-1")) {
|
|
return new Response(JSON.stringify({
|
|
success: true,
|
|
value: [
|
|
{
|
|
id: "dl-link-1",
|
|
url: "https://hoster.example/file.bin",
|
|
name: "file.bin",
|
|
downloadUrl: "https://debrid-link.example/file.bin",
|
|
size: 1234,
|
|
expired: false
|
|
}
|
|
]
|
|
}), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}
|
|
return new Response("not-found", { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
const service = new DebridService(settings);
|
|
const result = await service.unrestrictLink("https://hoster.example/file.bin");
|
|
|
|
expect(addContentType).toBe("application/json");
|
|
expect(addAccept).toBe("application/json");
|
|
expect(addBody).toBe(JSON.stringify({ url: "https://hoster.example/file.bin" }));
|
|
expect(result.provider).toBe("debridlink");
|
|
expect(result.directUrl).toBe("https://debrid-link.example/file.bin");
|
|
expect(calledUrls.some((url) => url.includes("debrid-link.com/api/v2/downloader/list?ids=dl-link-1"))).toBe(true);
|
|
});
|
|
|
|
it("rotates to the next Debrid-Link key when the first key is invalid", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
debridLinkApiKeys: "dl-key-one\ndl-key-two",
|
|
providerOrder: ["debridlink"] as const,
|
|
providerPrimary: "debridlink" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: true
|
|
};
|
|
|
|
const authHeaders: string[] = [];
|
|
|
|
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
const headers = init?.headers;
|
|
let authHeader = "";
|
|
if (headers instanceof Headers) {
|
|
authHeader = headers.get("Authorization") || "";
|
|
} else if (Array.isArray(headers)) {
|
|
authHeader = headers.find(([key]) => key.toLowerCase() === "authorization")?.[1] || "";
|
|
} else {
|
|
authHeader = String((headers as Record<string, unknown> | undefined)?.Authorization || "");
|
|
}
|
|
authHeaders.push(authHeader);
|
|
if (authHeader === "Bearer dl-key-one") {
|
|
return new Response(JSON.stringify({
|
|
success: false,
|
|
error: "badToken",
|
|
error_description: "token expired"
|
|
}), {
|
|
status: 401,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}
|
|
return new Response(JSON.stringify({
|
|
success: true,
|
|
value: {
|
|
downloadUrl: "https://debrid-link.example/valid.bin",
|
|
name: "valid.bin",
|
|
size: 2048
|
|
}
|
|
}), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}) as typeof fetch;
|
|
|
|
const service = new DebridService(settings);
|
|
const result = await service.unrestrictLink("https://hoster.example/needs-rotation.bin");
|
|
|
|
expect(authHeaders).toEqual(["Bearer dl-key-one", "Bearer dl-key-two"]);
|
|
expect(result.provider).toBe("debridlink");
|
|
expect(result.providerLabel).toContain("Key 2");
|
|
expect(result.directUrl).toBe("https://debrid-link.example/valid.bin");
|
|
});
|
|
|
|
it("looks up limits and rotates keys when Debrid-Link host quota is reached", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
debridLinkApiKeys: "dl-key-one\ndl-key-two",
|
|
providerOrder: ["debridlink"] as const,
|
|
providerPrimary: "debridlink" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: true
|
|
};
|
|
|
|
let limitCalls = 0;
|
|
const authHeaders: string[] = [];
|
|
|
|
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
const headers = init?.headers;
|
|
let authHeader = "";
|
|
if (headers instanceof Headers) {
|
|
authHeader = headers.get("Authorization") || "";
|
|
} else if (Array.isArray(headers)) {
|
|
authHeader = headers.find(([key]) => key.toLowerCase() === "authorization")?.[1] || "";
|
|
} else {
|
|
authHeader = String((headers as Record<string, unknown> | undefined)?.Authorization || "");
|
|
}
|
|
|
|
if (url.includes("debrid-link.com/api/v2/downloader/limits")) {
|
|
limitCalls += 1;
|
|
return new Response(JSON.stringify({
|
|
success: true,
|
|
value: {
|
|
nextResetSeconds: { value: 900 }
|
|
}
|
|
}), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}
|
|
|
|
authHeaders.push(authHeader);
|
|
if (authHeader === "Bearer dl-key-one") {
|
|
return new Response(JSON.stringify({
|
|
success: false,
|
|
error: "maxDataHost",
|
|
error_description: "host quota reached"
|
|
}), {
|
|
status: 403,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}
|
|
|
|
return new Response(JSON.stringify({
|
|
success: true,
|
|
value: {
|
|
downloadUrl: "https://debrid-link.example/quota-ok.bin",
|
|
name: "quota-ok.bin",
|
|
size: 4096
|
|
}
|
|
}), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}) as typeof fetch;
|
|
|
|
const service = new DebridService(settings);
|
|
const result = await service.unrestrictLink("https://rapidgator.net/file/quota-test");
|
|
|
|
expect(limitCalls).toBe(1);
|
|
expect(authHeaders).toEqual(["Bearer dl-key-one", "Bearer dl-key-two"]);
|
|
expect(result.provider).toBe("debridlink");
|
|
expect(result.providerLabel).toContain("Key 2");
|
|
expect(result.directUrl).toBe("https://debrid-link.example/quota-ok.bin");
|
|
});
|
|
|
|
it("scopes Debrid-Link maxDataHost cooldown to the (key, host) pair so the key stays usable for other hosters", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
debridLinkApiKeys: "dl-key-one\ndl-key-two",
|
|
providerOrder: ["debridlink"] as const,
|
|
providerPrimary: "debridlink" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: true
|
|
};
|
|
|
|
const unrestrictAuthHeaders: string[] = [];
|
|
|
|
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
const headers = init?.headers;
|
|
let authHeader = "";
|
|
if (headers instanceof Headers) {
|
|
authHeader = headers.get("Authorization") || "";
|
|
} else if (Array.isArray(headers)) {
|
|
authHeader = headers.find(([key]) => key.toLowerCase() === "authorization")?.[1] || "";
|
|
} else {
|
|
authHeader = String((headers as Record<string, unknown> | undefined)?.Authorization || "");
|
|
}
|
|
|
|
if (url.includes("debrid-link.com/api/v2/downloader/limits")) {
|
|
return new Response(JSON.stringify({
|
|
success: true,
|
|
value: { nextResetSeconds: { value: 900 } }
|
|
}), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}
|
|
|
|
if (url.includes("/downloader/add")) {
|
|
unrestrictAuthHeaders.push(authHeader);
|
|
const bodyText = init?.body ? String(init.body) : "";
|
|
const isRapidgator = /rapidgator/i.test(bodyText);
|
|
if (authHeader === "Bearer dl-key-one" && isRapidgator) {
|
|
return new Response(JSON.stringify({
|
|
success: false,
|
|
error: "maxDataHost",
|
|
error_description: "host quota reached"
|
|
}), { status: 403, headers: { "Content-Type": "application/json" } });
|
|
}
|
|
return new Response(JSON.stringify({
|
|
success: true,
|
|
value: {
|
|
downloadUrl: `https://debrid-link.example/${authHeader.slice(-3)}-${isRapidgator ? "rg" : "ot"}.bin`,
|
|
name: "ok.bin",
|
|
size: 1024
|
|
}
|
|
}), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
}
|
|
|
|
return new Response("not-found", { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
const service = new DebridService(settings);
|
|
|
|
const r1 = await service.unrestrictLink("https://rapidgator.net/file/first");
|
|
expect(r1.providerLabel).toContain("Key 2");
|
|
|
|
unrestrictAuthHeaders.length = 0;
|
|
const r2 = await service.unrestrictLink("https://rapidgator.net/file/second");
|
|
expect(unrestrictAuthHeaders).toEqual(["Bearer dl-key-two"]);
|
|
expect(r2.providerLabel).toContain("Key 2");
|
|
|
|
unrestrictAuthHeaders.length = 0;
|
|
const r3 = await service.unrestrictLink("https://uploaded.net/file/third");
|
|
expect(unrestrictAuthHeaders).toEqual(["Bearer dl-key-one"]);
|
|
expect(r3.providerLabel).toContain("Key 1");
|
|
});
|
|
|
|
it("does not mark Debrid-Link key as errored when the API returns fileNotAvailable (link-level, not key-level)", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
debridLinkApiKeys: "dl-key-one\ndl-key-two",
|
|
providerOrder: ["debridlink"] as const,
|
|
providerPrimary: "debridlink" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: true
|
|
};
|
|
|
|
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
const headers = init?.headers;
|
|
let authHeader = "";
|
|
if (headers instanceof Headers) {
|
|
authHeader = headers.get("Authorization") || "";
|
|
} else if (Array.isArray(headers)) {
|
|
authHeader = headers.find(([key]) => key.toLowerCase() === "authorization")?.[1] || "";
|
|
} else {
|
|
authHeader = String((headers as Record<string, unknown> | undefined)?.Authorization || "");
|
|
}
|
|
|
|
if (!url.includes("/downloader/add")) {
|
|
return new Response("not-found", { status: 404 });
|
|
}
|
|
if (authHeader === "Bearer dl-key-one") {
|
|
return new Response(JSON.stringify({
|
|
success: false,
|
|
error: "fileNotAvailable",
|
|
error_description: "link is currently not available"
|
|
}), { status: 403, headers: { "Content-Type": "application/json" } });
|
|
}
|
|
return new Response(JSON.stringify({
|
|
success: true,
|
|
value: {
|
|
downloadUrl: "https://debrid-link.example/ok.bin",
|
|
name: "ok.bin",
|
|
size: 1024
|
|
}
|
|
}), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
}) as typeof fetch;
|
|
|
|
const key1Id = parseDebridLinkApiKeys("dl-key-one")[0].id;
|
|
const key2Id = parseDebridLinkApiKeys("dl-key-two")[0].id;
|
|
|
|
const service = new DebridService(settings);
|
|
const result = await service.unrestrictLink("https://rapidgator.net/file/example");
|
|
expect(result.providerLabel).toContain("Key 2");
|
|
|
|
expect(getDebridLinkKeyRuntimeStateForTests(key1Id)).not.toBe("error");
|
|
expect(getDebridLinkKeyRuntimeStateForTests(key2Id)).toBe("ready");
|
|
});
|
|
|
|
it("does NOT cool down a Debrid-Link key on a quick user-cancel abort (below the min-run threshold)", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "",
|
|
bestToken: "",
|
|
allDebridToken: "",
|
|
megaLogin: "",
|
|
megaPassword: "",
|
|
megaCredentials: "",
|
|
debridLinkApiKeys: "dl-key-one",
|
|
providerOrder: ["debridlink"] as const,
|
|
providerPrimary: "debridlink" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: false
|
|
};
|
|
const controller = new AbortController();
|
|
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
if (url.includes("/downloader/add")) {
|
|
controller.abort();
|
|
throw new Error("aborted");
|
|
}
|
|
return new Response("not-found", { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
const keyId = parseDebridLinkApiKeys("dl-key-one")[0].id;
|
|
const service = new DebridService(settings);
|
|
await expect(
|
|
service.unrestrictLink("https://rapidgator.net/file/dl-quick-cancel", controller.signal)
|
|
).rejects.toThrow();
|
|
|
|
expect(getDebridLinkKeyCooldownStateForTests(keyId)).toBeNull();
|
|
});
|
|
|
|
it("cools down a Debrid-Link key on an abort that ran long enough (retry rotates to the next key)", async () => {
|
|
process.env.RD_MEGA_ABORT_MIN_RUN_MS = "0";
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "",
|
|
bestToken: "",
|
|
allDebridToken: "",
|
|
megaLogin: "",
|
|
megaPassword: "",
|
|
megaCredentials: "",
|
|
debridLinkApiKeys: "dl-key-one",
|
|
providerOrder: ["debridlink"] as const,
|
|
providerPrimary: "debridlink" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: false
|
|
};
|
|
const controller = new AbortController();
|
|
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
if (url.includes("/downloader/add")) {
|
|
controller.abort();
|
|
throw new Error("aborted");
|
|
}
|
|
return new Response("not-found", { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
const keyId = parseDebridLinkApiKeys("dl-key-one")[0].id;
|
|
const service = new DebridService(settings);
|
|
await expect(
|
|
service.unrestrictLink("https://rapidgator.net/file/dl-long-abort", controller.signal)
|
|
).rejects.toThrow();
|
|
|
|
const cooldown = getDebridLinkKeyCooldownStateForTests(keyId);
|
|
expect(cooldown?.remainingMs ?? 0).toBeGreaterThan(60_000);
|
|
});
|
|
|
|
it("treats bad Debrid-Link file passwords as fatal and does not rotate keys", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
debridLinkApiKeys: "dl-key-one\ndl-key-two",
|
|
providerOrder: ["debridlink"] as const,
|
|
providerPrimary: "debridlink" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: true
|
|
};
|
|
|
|
const authHeaders: string[] = [];
|
|
|
|
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
const headers = init?.headers;
|
|
let authHeader = "";
|
|
if (headers instanceof Headers) {
|
|
authHeader = headers.get("Authorization") || "";
|
|
} else if (Array.isArray(headers)) {
|
|
authHeader = headers.find(([key]) => key.toLowerCase() === "authorization")?.[1] || "";
|
|
} else {
|
|
authHeader = String((headers as Record<string, unknown> | undefined)?.Authorization || "");
|
|
}
|
|
authHeaders.push(authHeader);
|
|
return new Response(JSON.stringify({
|
|
success: false,
|
|
error: "badFilePassword",
|
|
error_description: "wrong password"
|
|
}), {
|
|
status: 400,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}) as typeof fetch;
|
|
|
|
const service = new DebridService(settings);
|
|
await expect(service.unrestrictLink("https://hoster.example/protected.bin")).rejects.toThrow("wrong password");
|
|
expect(authHeaders).toEqual(["Bearer dl-key-one"]);
|
|
});
|
|
|
|
it("returns a cooldown marker when all Debrid-Link keys are temporarily cooling down", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
debridLinkApiKeys: "dl-key-one\ndl-key-two",
|
|
providerOrder: ["debridlink"] as const,
|
|
providerPrimary: "debridlink" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: true
|
|
};
|
|
|
|
let addCalls = 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("debrid-link.com/api/v2/downloader/add")) {
|
|
return new Response("not-found", { status: 404 });
|
|
}
|
|
addCalls += 1;
|
|
return new Response(JSON.stringify({
|
|
success: false,
|
|
error: "floodDetected",
|
|
error_description: "too many requests"
|
|
}), {
|
|
status: 403,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}) as typeof fetch;
|
|
|
|
const service = new DebridService(settings);
|
|
await expect(service.unrestrictLink("https://hoster.example/cooldown.bin")).rejects.toThrow("API-Rate-Limit erreicht");
|
|
await expect(service.unrestrictLink("https://hoster.example/cooldown.bin")).rejects.toThrow(/debrid_link_cooldown:\d+:/i);
|
|
expect(addCalls).toBe(2);
|
|
});
|
|
|
|
it("returns an invalid-all marker when all Debrid-Link keys are invalid", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
debridLinkApiKeys: "dl-key-one\ndl-key-two",
|
|
providerOrder: ["debridlink"] as const,
|
|
providerPrimary: "debridlink" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: true
|
|
};
|
|
|
|
const authHeaders: string[] = [];
|
|
|
|
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
const headers = init?.headers;
|
|
let authHeader = "";
|
|
if (headers instanceof Headers) {
|
|
authHeader = headers.get("Authorization") || "";
|
|
} else if (Array.isArray(headers)) {
|
|
authHeader = headers.find(([key]) => key.toLowerCase() === "authorization")?.[1] || "";
|
|
} else {
|
|
authHeader = String((headers as Record<string, unknown> | undefined)?.Authorization || "");
|
|
}
|
|
authHeaders.push(authHeader);
|
|
return new Response(JSON.stringify({
|
|
success: false,
|
|
error: "badToken",
|
|
error_description: "token expired"
|
|
}), {
|
|
status: 401,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}) as typeof fetch;
|
|
|
|
const service = new DebridService(settings);
|
|
await expect(service.unrestrictLink("https://hoster.example/all-invalid.bin")).rejects.toThrow(/debrid_link_invalid_all:/i);
|
|
expect(authHeaders).toEqual(["Bearer dl-key-one", "Bearer dl-key-two"]);
|
|
});
|
|
|
|
it("returns a clear error when all Debrid-Link keys are locally exhausted", async () => {
|
|
const keys = parseDebridLinkApiKeys("dl-key-one\ndl-key-two");
|
|
const settings = {
|
|
...defaultSettings(),
|
|
debridLinkApiKeys: "dl-key-one\ndl-key-two",
|
|
providerOrder: ["debridlink"] as const,
|
|
providerPrimary: "debridlink" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
debridLinkApiKeyDailyLimitBytes: {
|
|
[keys[0].id]: 100,
|
|
[keys[1].id]: 100
|
|
},
|
|
debridLinkApiKeyDailyUsageBytes: {
|
|
[keys[0].id]: 100,
|
|
[keys[1].id]: 100
|
|
},
|
|
providerDailyUsageDay: getProviderUsageDayKey()
|
|
};
|
|
|
|
const service = new DebridService(settings);
|
|
await expect(service.unrestrictLink("https://hoster.example/no-key-left.bin")).rejects.toThrow(/debrid-link nicht verfuegbar|kein aktiver api-key/i);
|
|
});
|
|
|
|
it("stops rotation immediately on Debrid-Link notDebrid (provider-wide) — does NOT burn remaining keys", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
debridLinkApiKeys: "dl-key-one\ndl-key-two",
|
|
providerOrder: ["debridlink"] as const,
|
|
providerPrimary: "debridlink" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: true
|
|
};
|
|
|
|
const authHeaders: string[] = [];
|
|
|
|
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
authHeaders.push(String((init?.headers as Record<string, string> | undefined)?.Authorization || ""));
|
|
return new Response(JSON.stringify({
|
|
success: false,
|
|
error: "notDebrid",
|
|
error_description: "notDebrid"
|
|
}), {
|
|
status: 403,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}) as typeof fetch;
|
|
|
|
const service = new DebridService(settings);
|
|
await expect(service.unrestrictLink("https://hoster.example/not-debrid.bin")).rejects.toThrow(/debrid_link_cooldown.*notDebrid/);
|
|
expect(authHeaders).toEqual(["Bearer dl-key-one"]);
|
|
});
|
|
|
|
it("continues to the next Debrid-Link key for non-provider-wide skip errors without caching a cooldown", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
debridLinkApiKeys: "dl-key-one\ndl-key-two",
|
|
providerOrder: ["debridlink"] as const,
|
|
providerPrimary: "debridlink" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: true
|
|
};
|
|
|
|
const authHeaders: string[] = [];
|
|
|
|
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
const authHeader = String((init?.headers as Record<string, string> | undefined)?.Authorization || "");
|
|
authHeaders.push(authHeader);
|
|
if (authHeader === "Bearer dl-key-one") {
|
|
return new Response(JSON.stringify({
|
|
success: false,
|
|
error: "noServerHost",
|
|
error_description: "host temporarily unavailable"
|
|
}), {
|
|
status: 403,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}
|
|
return new Response(JSON.stringify({
|
|
success: true,
|
|
value: {
|
|
downloadUrl: "https://debrid-link.example/second-key.bin",
|
|
name: "second-key.bin",
|
|
size: 4096
|
|
}
|
|
}), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}) as typeof fetch;
|
|
|
|
const service = new DebridService(settings);
|
|
const result = await service.unrestrictLink("https://hoster.example/skip-key.bin");
|
|
expect(result.directUrl).toBe("https://debrid-link.example/second-key.bin");
|
|
expect(result.sourceAccountLabel).toBe("Key 2");
|
|
expect(authHeaders).toEqual(["Bearer dl-key-one", "Bearer dl-key-two"]);
|
|
});
|
|
|
|
it("uses BestDebrid auth header without token query fallback", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "",
|
|
bestToken: "best-token",
|
|
providerPrimary: "bestdebrid" as const,
|
|
providerSecondary: "realdebrid" as const,
|
|
providerTertiary: "megadebrid" as const,
|
|
autoProviderFallback: true
|
|
};
|
|
|
|
const calledUrls: string[] = [];
|
|
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
calledUrls.push(url);
|
|
if (url.includes("/api/v1/generateLink?link=")) {
|
|
return new Response(JSON.stringify({ download: "https://best.example/file.bin", filename: "file.bin", filesize: 2048 }), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}
|
|
return new Response("not-found", { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
const service = new DebridService(settings);
|
|
const result = await service.unrestrictLink("https://rapidgator.net/file/example.part3.rar.html");
|
|
expect(result.provider).toBe("bestdebrid");
|
|
expect(result.fileSize).toBe(2048);
|
|
expect(calledUrls.some((url) => url.includes("auth="))).toBe(false);
|
|
});
|
|
|
|
it("sends Bearer auth header to BestDebrid", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "",
|
|
bestToken: "best-token",
|
|
providerPrimary: "bestdebrid" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: true
|
|
};
|
|
|
|
let authHeader = "";
|
|
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("/api/v1/generateLink?link=")) {
|
|
const headers = init?.headers;
|
|
if (headers instanceof Headers) {
|
|
authHeader = headers.get("Authorization") || "";
|
|
} else if (Array.isArray(headers)) {
|
|
const tuple = headers.find(([key]) => key.toLowerCase() === "authorization");
|
|
authHeader = tuple?.[1] || "";
|
|
} else {
|
|
authHeader = String((headers as Record<string, unknown> | undefined)?.Authorization || "");
|
|
}
|
|
return new Response(JSON.stringify({ download: "https://best.example/file.bin", filename: "file.bin", filesize: 42 }), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}
|
|
return new Response("not-found", { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
const service = new DebridService(settings);
|
|
const result = await service.unrestrictLink("https://hoster.example/file/abc");
|
|
expect(result.provider).toBe("bestdebrid");
|
|
expect(authHeader).toBe("Bearer best-token");
|
|
});
|
|
|
|
it("does not retry BestDebrid auth failures (401)", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "",
|
|
bestToken: "best-token",
|
|
providerPrimary: "bestdebrid" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: true
|
|
};
|
|
|
|
let calls = 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("/api/v1/generateLink?link=")) {
|
|
calls += 1;
|
|
return new Response(JSON.stringify({ message: "Unauthorized" }), {
|
|
status: 401,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}
|
|
return new Response("not-found", { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
const service = new DebridService(settings);
|
|
await expect(service.unrestrictLink("https://hoster.example/file/no-retry")).rejects.toThrow();
|
|
expect(calls).toBe(1);
|
|
});
|
|
|
|
it("does not retry AllDebrid auth failures (403)", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
allDebridToken: "ad-token",
|
|
providerOrder: [] as const,
|
|
providerPrimary: "alldebrid" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: true
|
|
};
|
|
|
|
let calls = 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("api.alldebrid.com/v4/link/unlock")) {
|
|
calls += 1;
|
|
return new Response(JSON.stringify({ status: "error", error: { message: "forbidden" } }), {
|
|
status: 403,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}
|
|
return new Response("not-found", { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
const service = new DebridService(settings);
|
|
await expect(service.unrestrictLink("https://hoster.example/file/no-retry-ad")).rejects.toThrow();
|
|
expect(calls).toBe(1);
|
|
});
|
|
|
|
it("supports AllDebrid unlock", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "",
|
|
bestToken: "",
|
|
allDebridToken: "ad-token",
|
|
providerOrder: [] as const,
|
|
providerPrimary: "alldebrid" as const,
|
|
providerSecondary: "realdebrid" as const,
|
|
providerTertiary: "megadebrid" 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.alldebrid.com/v4/link/unlock")) {
|
|
return new Response(JSON.stringify({
|
|
status: "success",
|
|
data: {
|
|
link: "https://alldebrid.example/file.bin",
|
|
filename: "file.bin",
|
|
filesize: 4096
|
|
}
|
|
}), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}
|
|
return new Response("not-found", { status: 404 });
|
|
}) 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).toBe("https://alldebrid.example/file.bin");
|
|
expect(result.fileSize).toBe(4096);
|
|
});
|
|
|
|
it("loads AllDebrid host info via api", async () => {
|
|
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.alldebrid.com/v4.1/user/hosts")) {
|
|
return new Response(JSON.stringify({
|
|
status: "success",
|
|
data: {
|
|
hosts: {
|
|
rapidgator: {
|
|
name: "rapidgator",
|
|
status: false,
|
|
quota: 1200,
|
|
quotaMax: 2400,
|
|
quotaType: "traffic",
|
|
limitSimuDl: 2
|
|
}
|
|
}
|
|
}
|
|
}), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}
|
|
return new Response("not-found", { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
const info = await fetchAllDebridHostInfo("ad-token", "rapidgator");
|
|
expect(info.source).toBe("api");
|
|
expect(info.host).toBe("rapidgator");
|
|
expect(info.state).toBe("down");
|
|
expect(info.statusLabel).toBe("Unverfügbar");
|
|
expect(info.quota).toBe(1200);
|
|
expect(info.quotaMax).toBe(2400);
|
|
expect(info.quotaType).toBe("traffic");
|
|
expect(info.limitSimuDl).toBe(2);
|
|
});
|
|
|
|
it("loads Debrid-Link rapidgator limits per api key", async () => {
|
|
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
if (url.includes("debrid-link.com/api/v2/downloader/limits/all")) {
|
|
return new Response(JSON.stringify({
|
|
success: true,
|
|
value: {
|
|
hosters: [
|
|
{
|
|
name: "rapidgator",
|
|
daySize: { current: 0, value: 150323855360 },
|
|
dayCount: { current: 0, value: 500 }
|
|
}
|
|
]
|
|
}
|
|
}), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}
|
|
return new Response("not-found", { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
const info = await fetchDebridLinkHostLimits("key-a", "rapidgator");
|
|
expect(info).toHaveLength(1);
|
|
expect(info[0].keyLabel).toBe("Key 1");
|
|
expect(info[0].host).toBe("rapidgator");
|
|
expect(info[0].trafficCurrentBytes).toBe(0);
|
|
expect(info[0].trafficMaxBytes).toBe(150323855360);
|
|
expect(info[0].linksCurrent).toBe(0);
|
|
expect(info[0].linksMax).toBe(500);
|
|
});
|
|
|
|
it("falls back from Debrid-Link limits/all to limits when the host is only present in limits", async () => {
|
|
const calledUrls: string[] = [];
|
|
|
|
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
calledUrls.push(url);
|
|
if (url.includes("debrid-link.com/api/v2/downloader/limits/all")) {
|
|
return new Response(JSON.stringify({
|
|
success: true,
|
|
value: {
|
|
hosters: [
|
|
{
|
|
name: "uploaded",
|
|
daySize: { current: 1, value: 2 },
|
|
dayCount: { current: 3, value: 4 }
|
|
}
|
|
]
|
|
}
|
|
}), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}
|
|
if (url.includes("debrid-link.com/api/v2/downloader/limits")) {
|
|
return new Response(JSON.stringify({
|
|
success: true,
|
|
value: {
|
|
hosters: [
|
|
{
|
|
name: "rapidgator",
|
|
displayName: "Rapidgator",
|
|
daySize: { current: 2147483648, value: 150323855360 },
|
|
dayCount: { current: 42, value: 500 }
|
|
}
|
|
]
|
|
}
|
|
}), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}
|
|
return new Response("not-found", { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
const info = await fetchDebridLinkHostLimits("key-a", "rapidgator");
|
|
expect(info).toHaveLength(1);
|
|
expect(info[0].host).toBe("rapidgator");
|
|
expect(info[0].trafficCurrentBytes).toBe(2147483648);
|
|
expect(info[0].trafficMaxBytes).toBe(150323855360);
|
|
expect(info[0].linksCurrent).toBe(42);
|
|
expect(info[0].linksMax).toBe(500);
|
|
expect(calledUrls.some((url) => url.includes("/limits/all"))).toBe(true);
|
|
expect(calledUrls.some((url) => url.includes("/limits"))).toBe(true);
|
|
});
|
|
|
|
it("includes Debrid-Link host and key state diagnostics in host limits", async () => {
|
|
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
if (url.includes("debrid-link.com/api/v2/downloader/hosts")) {
|
|
return new Response(JSON.stringify({
|
|
success: true,
|
|
value: [
|
|
{
|
|
name: "rapidgator",
|
|
status: 1,
|
|
domains: ["rapidgator.net", "rg.to"]
|
|
}
|
|
]
|
|
}), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}
|
|
if (url.includes("debrid-link.com/api/v2/downloader/limits/all")) {
|
|
return new Response(JSON.stringify({
|
|
success: true,
|
|
value: {
|
|
hosters: [
|
|
{
|
|
name: "rapidgator",
|
|
daySize: { current: 1024, value: 2048 },
|
|
dayCount: { current: 2, value: 5 }
|
|
}
|
|
]
|
|
}
|
|
}), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}
|
|
return new Response("not-found", { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
const info = await fetchDebridLinkHostLimits("key-a", "rapidgator");
|
|
expect(info[0].state).toBe("ready");
|
|
expect(info[0].stateLabel).toBe("Bereit");
|
|
expect(info[0].hostState).toBe("up");
|
|
expect(info[0].hostStateLabel).toBe("Online");
|
|
});
|
|
|
|
it("returns invalid Debrid-Link key diagnostics instead of failing the whole popup request", async () => {
|
|
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
if (url.includes("debrid-link.com/api/v2/downloader/hosts")) {
|
|
return new Response(JSON.stringify({
|
|
success: true,
|
|
value: [
|
|
{
|
|
name: "rapidgator",
|
|
status: 0,
|
|
domains: ["rapidgator.net"]
|
|
}
|
|
]
|
|
}), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}
|
|
if (url.includes("debrid-link.com/api/v2/downloader/limits/all")) {
|
|
return new Response(JSON.stringify({
|
|
success: false,
|
|
error: "badToken",
|
|
error_description: "token expired"
|
|
}), {
|
|
status: 401,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}
|
|
return new Response("not-found", { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
const info = await fetchDebridLinkHostLimits("key-a", "rapidgator");
|
|
expect(info).toHaveLength(1);
|
|
expect(info[0].state).toBe("invalid");
|
|
expect(info[0].cooldownRemainingMs).toBeGreaterThan(0);
|
|
expect(info[0].hostState).toBe("down");
|
|
expect(info[0].hostStateLabel).toBe("Offline");
|
|
});
|
|
|
|
it("uses AllDebrid web path when enabled", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
allDebridToken: "ad-token",
|
|
allDebridUseWebLogin: true,
|
|
providerOrder: [] as const,
|
|
providerPrimary: "alldebrid" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
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 settings = {
|
|
...defaultSettings(),
|
|
allDebridToken: "",
|
|
allDebridUseWebLogin: true,
|
|
providerPrimary: "alldebrid" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: false
|
|
};
|
|
|
|
const service = new DebridService(settings);
|
|
await expect(service.unrestrictLink("https://rapidgator.net/file/missing-alldebrid-web")).rejects.toThrow(/nicht konfiguriert/i);
|
|
});
|
|
|
|
it("uses Real-Debrid web path when enabled", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
realDebridUseWebLogin: true,
|
|
providerPrimary: "realdebrid" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: false
|
|
};
|
|
|
|
const fetchSpy = vi.fn(async () => new Response("not-found", { status: 404 }));
|
|
globalThis.fetch = fetchSpy as unknown as typeof fetch;
|
|
|
|
const realDebridWeb = vi.fn(async () => ({
|
|
fileName: "from-rd-web.rar",
|
|
directUrl: "https://download.real-debrid.com/d/example/from-rd-web.rar",
|
|
fileSize: 5678,
|
|
retriesUsed: 0
|
|
}));
|
|
|
|
const service = new DebridService(settings, { realDebridWebUnrestrict: realDebridWeb });
|
|
const result = await service.unrestrictLink("https://rapidgator.net/file/example.part5.rar.html");
|
|
expect(result.provider).toBe("realdebrid");
|
|
expect(result.directUrl).toContain("real-debrid.com/d/");
|
|
expect(result.fileSize).toBe(5678);
|
|
expect(realDebridWeb).toHaveBeenCalledTimes(1);
|
|
expect(fetchSpy).toHaveBeenCalledTimes(0);
|
|
});
|
|
|
|
it("treats Real-Debrid web mode as not configured when callback is unavailable and no token", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "",
|
|
realDebridUseWebLogin: true,
|
|
providerPrimary: "realdebrid" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: false
|
|
};
|
|
|
|
const service = new DebridService(settings);
|
|
await expect(service.unrestrictLink("https://rapidgator.net/file/missing-rd-web")).rejects.toThrow(/nicht konfiguriert/i);
|
|
});
|
|
|
|
it("falls back to API token when Real-Debrid web login is disabled", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
realDebridUseWebLogin: false,
|
|
providerPrimary: "realdebrid" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: false
|
|
};
|
|
|
|
globalThis.fetch = (async () => new Response(JSON.stringify({
|
|
download: "https://download.real-debrid.com/d/test/file.rar",
|
|
filename: "file.rar",
|
|
filesize: 9999
|
|
}), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
})) as typeof fetch;
|
|
|
|
const realDebridWeb = vi.fn(async () => null);
|
|
const service = new DebridService(settings, { realDebridWebUnrestrict: realDebridWeb });
|
|
const result = await service.unrestrictLink("https://rapidgator.net/file/test.rar.html");
|
|
expect(result.provider).toBe("realdebrid");
|
|
expect(realDebridWeb).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("treats MegaDebrid as not configured when no credentials are set", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
megaLogin: "",
|
|
megaPassword: "",
|
|
providerPrimary: "megadebrid" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: false
|
|
};
|
|
|
|
const service = new DebridService(settings);
|
|
await expect(service.unrestrictLink("https://rapidgator.net/file/missing-mega-web")).rejects.toThrow(/nicht konfiguriert/i);
|
|
});
|
|
|
|
it("uses Mega web fallback when API fails", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "",
|
|
bestToken: "",
|
|
allDebridToken: "",
|
|
megaLogin: "user",
|
|
megaPassword: "pass",
|
|
megaCredentials: "user:pass",
|
|
providerOrder: [] as const,
|
|
providerPrimary: "megadebrid" as const,
|
|
providerSecondary: "megadebrid" as const,
|
|
providerTertiary: "megadebrid" as const,
|
|
autoProviderFallback: true
|
|
};
|
|
|
|
const fetchSpy = vi.fn(async () => new Response("not-found", { status: 404 }));
|
|
globalThis.fetch = fetchSpy as unknown as typeof fetch;
|
|
|
|
const megaWeb = vi.fn(async () => ({
|
|
fileName: "from-web.rar",
|
|
directUrl: "https://www11.unrestrict.link/download/file/abc/from-web.rar",
|
|
fileSize: null,
|
|
retriesUsed: 0
|
|
}));
|
|
|
|
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
|
const result = await service.unrestrictLink("https://rapidgator.net/file/abc/from-web.rar.html");
|
|
expect(result.provider).toBe("megadebrid");
|
|
expect(result.directUrl).toContain("unrestrict.link/download/file/");
|
|
expect(megaWeb).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("does not fallback from Mega API to Mega Web unless Mega Web is a separate provider in the order", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "",
|
|
bestToken: "",
|
|
allDebridToken: "",
|
|
megaLogin: "user",
|
|
megaPassword: "pass",
|
|
megaCredentials: "user:pass",
|
|
megaDebridApiEnabled: true,
|
|
megaDebridWebEnabled: true,
|
|
providerPrimary: "megadebrid-api" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: true
|
|
};
|
|
|
|
globalThis.fetch = (async () => new Response("not-found", { status: 404 })) as typeof fetch;
|
|
|
|
const megaWeb = vi.fn(async () => ({
|
|
fileName: "should-not-run.rar",
|
|
directUrl: "https://unused",
|
|
fileSize: null,
|
|
retriesUsed: 0
|
|
}));
|
|
|
|
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
|
await expect(service.unrestrictLink("https://rapidgator.net/file/mega-api-only.rar.html")).rejects.toThrow(/mega-debrid api/i);
|
|
expect(megaWeb).toHaveBeenCalledTimes(0);
|
|
});
|
|
|
|
it("treats a Mega-Debrid 'Fichier supprimé' as transient: no account cooldown, German message, retryable", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "",
|
|
bestToken: "",
|
|
allDebridToken: "",
|
|
megaLogin: "user",
|
|
megaPassword: "pass",
|
|
megaCredentials: "user:pass",
|
|
megaDebridApiEnabled: true,
|
|
megaDebridWebEnabled: false,
|
|
providerPrimary: "megadebrid-api" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" 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("action=connectUser")) {
|
|
return new Response(JSON.stringify({ response_code: "ok", token: "tok", vip_end: Math.floor(Date.now() / 1000) + 999999 }), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
}
|
|
if (url.includes("action=getLink")) {
|
|
return new Response(JSON.stringify({ response_code: "error", response_text: "Fichier supprimé chez l'hébergeur" }), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
}
|
|
return new Response("not-found", { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
const service = new DebridService(settings);
|
|
const err = await service.unrestrictLink("https://rapidgator.net/file/maybe-dead.rar.html").then(() => null, (e: unknown) => e);
|
|
expect(err).toBeTruthy();
|
|
expect(String(err)).toMatch(/nicht abrufbar/i);
|
|
expect(String(err)).not.toMatch(/supprim/i);
|
|
expect(getMegaDebridAccountCooldownState(`${getMegaDebridAccountId("user")}:api`)).toBeNull();
|
|
});
|
|
|
|
it("does NOT slap a 60-minute 'invalid' cooldown on a working account when the API returns no result (transient)", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "",
|
|
bestToken: "",
|
|
allDebridToken: "",
|
|
megaLogin: "user",
|
|
megaPassword: "pass",
|
|
megaCredentials: "user:pass",
|
|
megaDebridApiEnabled: true,
|
|
megaDebridWebEnabled: false,
|
|
providerPrimary: "megadebrid-api" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" 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("action=connectUser")) {
|
|
return new Response(JSON.stringify({ response_code: "ok", token: "tok", vip_end: Math.floor(Date.now() / 1000) + 999999 }), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
}
|
|
if (url.includes("action=getLink")) {
|
|
return new Response(JSON.stringify({ response_code: "ok" }), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
}
|
|
return new Response("not-found", { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
const service = new DebridService(settings);
|
|
const err = await service.unrestrictLink("https://rapidgator.net/file/no-result.rar.html").then(() => null, (e: unknown) => e);
|
|
expect(err).toBeTruthy();
|
|
expect(String(err)).not.toMatch(/Login oder Unrestrict/i);
|
|
|
|
const cooldown = getMegaDebridAccountCooldownState(`${getMegaDebridAccountId("user")}:api`);
|
|
if (cooldown) {
|
|
expect(cooldown.category).not.toBe("invalid");
|
|
expect(cooldown.remainingMs).toBeLessThan(5 * 60 * 1000);
|
|
}
|
|
});
|
|
|
|
it("treats a Mega-Debrid API 'Token error, please log-in' as a short transient cooldown, not invalid", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "",
|
|
bestToken: "",
|
|
allDebridToken: "",
|
|
megaLogin: "user",
|
|
megaPassword: "pass",
|
|
megaCredentials: "user:pass",
|
|
megaDebridApiEnabled: true,
|
|
megaDebridWebEnabled: false,
|
|
providerPrimary: "megadebrid-api" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" 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("action=connectUser")) {
|
|
return new Response(JSON.stringify({ response_code: "ok", token: "tok", vip_end: Math.floor(Date.now() / 1000) + 999999 }), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
}
|
|
if (url.includes("action=getLink")) {
|
|
return new Response(JSON.stringify({ response_code: "error_token", response_text: "Token error, please log-in" }), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
}
|
|
return new Response("not-found", { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
const service = new DebridService(settings);
|
|
await service.unrestrictLink("https://rapidgator.net/file/token-collision.rar.html").then(() => null, (e: unknown) => e);
|
|
|
|
const cooldown = getMegaDebridAccountCooldownState(`${getMegaDebridAccountId("user")}:api`);
|
|
if (cooldown) {
|
|
expect(cooldown.category).not.toBe("invalid");
|
|
expect(cooldown.remainingMs).toBeLessThan(60 * 1000);
|
|
}
|
|
});
|
|
|
|
it("categorizes a Mega-Debrid 'rate limit' error as rate_limit, not quota (regex ordering)", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "",
|
|
bestToken: "",
|
|
allDebridToken: "",
|
|
megaLogin: "user",
|
|
megaPassword: "pass",
|
|
megaCredentials: "user:pass",
|
|
megaDebridApiEnabled: true,
|
|
megaDebridWebEnabled: false,
|
|
providerPrimary: "megadebrid-api" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" 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("action=connectUser")) {
|
|
return new Response(JSON.stringify({ response_code: "ok", token: "tok", vip_end: Math.floor(Date.now() / 1000) + 999999 }), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
}
|
|
if (url.includes("action=getLink")) {
|
|
return new Response(JSON.stringify({ response_code: "error", response_text: "Rate limit exceeded, too many requests" }), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
}
|
|
return new Response("not-found", { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
const service = new DebridService(settings);
|
|
await service.unrestrictLink("https://rapidgator.net/file/rl.rar.html").then(() => null, (e: unknown) => e);
|
|
|
|
const cooldown = getMegaDebridAccountCooldownState(`${getMegaDebridAccountId("user")}:api`);
|
|
expect(cooldown).not.toBeNull();
|
|
expect(cooldown!.category).toBe("rate_limit");
|
|
});
|
|
|
|
it("does not fall through a failed 1fichier link to the provider chain when autoProviderFallback is off", async () => {
|
|
let megaGetLinkCalled = false;
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "",
|
|
bestToken: "",
|
|
allDebridToken: "",
|
|
oneFichierApiKey: "1f-key",
|
|
megaLogin: "user",
|
|
megaPassword: "pass",
|
|
megaCredentials: "user:pass",
|
|
megaDebridApiEnabled: true,
|
|
megaDebridWebEnabled: false,
|
|
megaDebridPreferApi: true,
|
|
providerPrimary: "megadebrid" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: false
|
|
};
|
|
|
|
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.1fichier.com")) {
|
|
return new Response(JSON.stringify({ status: "KO", message: "not available" }), { status: 500, headers: { "Content-Type": "application/json" } });
|
|
}
|
|
if (url.includes("action=connectUser")) {
|
|
return new Response(JSON.stringify({ response_code: "ok", token: "tok", vip_end: Math.floor(Date.now() / 1000) + 999999 }), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
}
|
|
if (url.includes("action=getLink")) {
|
|
megaGetLinkCalled = true;
|
|
return new Response(JSON.stringify({ response_code: "ok", debridLink: "https://mega-cdn.example/file.rar", filename: "file.rar" }), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
}
|
|
return new Response("not-found", { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
const service = new DebridService(settings);
|
|
const result = await service.unrestrictLink("https://1fichier.com/?abc12345xyz").then((r) => ({ ok: true, r }), (e: unknown) => ({ ok: false, e }));
|
|
|
|
expect(result.ok).toBe(false);
|
|
expect(megaGetLinkCalled).toBe(false);
|
|
});
|
|
|
|
it("uses Mega Web only when it is configured as a separate fallback provider", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "",
|
|
bestToken: "",
|
|
allDebridToken: "",
|
|
megaLogin: "user",
|
|
megaPassword: "pass",
|
|
megaCredentials: "user:pass",
|
|
megaDebridApiCredentials: "user:pass",
|
|
megaDebridWebCredentials: "user:pass",
|
|
megaDebridApiEnabled: true,
|
|
megaDebridWebEnabled: true,
|
|
providerOrder: [] as const,
|
|
providerPrimary: "megadebrid-api" as const,
|
|
providerSecondary: "megadebrid-web" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: true
|
|
};
|
|
|
|
globalThis.fetch = (async () => new Response("not-found", { status: 404 })) as typeof fetch;
|
|
|
|
const megaWeb = vi.fn(async () => ({
|
|
fileName: "from-separate-web.rar",
|
|
directUrl: "https://mega-web.example/from-separate-web.rar",
|
|
fileSize: null,
|
|
retriesUsed: 0
|
|
}));
|
|
|
|
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
|
const result = await service.unrestrictLink("https://rapidgator.net/file/from-separate-web.rar.html");
|
|
expect(result.provider).toBe("megadebrid-web");
|
|
expect(result.directUrl).toBe("https://mega-web.example/from-separate-web.rar");
|
|
expect(megaWeb).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("aborts Mega web unrestrict when caller signal is cancelled", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "",
|
|
bestToken: "",
|
|
allDebridToken: "",
|
|
megaLogin: "user",
|
|
megaPassword: "pass",
|
|
megaCredentials: "user:pass",
|
|
providerOrder: [] as const,
|
|
providerPrimary: "megadebrid" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: false
|
|
};
|
|
|
|
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
|
|
|
|
const megaWeb = vi.fn((_link: string, signal?: AbortSignal): Promise<never> => new Promise((_, reject) => {
|
|
const onAbort = (): void => reject(new Error("aborted:mega-web-test"));
|
|
if (signal?.aborted) {
|
|
onAbort();
|
|
return;
|
|
}
|
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
}));
|
|
|
|
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
|
const controller = new AbortController();
|
|
const abortTimer = setTimeout(() => {
|
|
controller.abort("test");
|
|
}, 200);
|
|
|
|
try {
|
|
await expect(service.unrestrictLink("https://rapidgator.net/file/abort-mega-web", controller.signal)).rejects.toThrow(/aborted/i);
|
|
expect(megaWeb).toHaveBeenCalledTimes(1);
|
|
expect(megaWeb.mock.calls[0]?.[1]).toBe(controller.signal);
|
|
} finally {
|
|
clearTimeout(abortTimer);
|
|
}
|
|
});
|
|
|
|
it("bleibt klebrig bei einem funktionierenden Account (kein Account-Wechsel pro Link)", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "", bestToken: "", allDebridToken: "",
|
|
megaLogin: "user1", megaPassword: "pass1",
|
|
megaCredentials: "user1:pass1\nuser2:pass2\nuser3:pass3\nuser4:pass4",
|
|
megaDebridPreferApi: false,
|
|
providerOrder: [] as const, providerPrimary: "megadebrid" as const,
|
|
providerSecondary: "none" as const, providerTertiary: "none" as const,
|
|
autoProviderFallback: false
|
|
};
|
|
|
|
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
|
|
|
|
const megaWeb = vi.fn(async () => ({ fileName: "ok.rar", directUrl: "https://mega-web.example/ok.rar", fileSize: null, retriesUsed: 0 }));
|
|
|
|
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
|
const usedIds: (string | undefined)[] = [];
|
|
for (let i = 0; i < 5; i += 1) {
|
|
const result = await service.unrestrictLink(`https://rapidgator.net/file/sticky-${i}`);
|
|
usedIds.push((result as { sourceAccountId?: string }).sourceAccountId);
|
|
}
|
|
|
|
expect(usedIds).toEqual(new Array(5).fill(getMegaDebridAccountId("user1")));
|
|
}, 30000);
|
|
|
|
it("wechselt erst nach einem Schwung Links auf den naechsten Account", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "", bestToken: "", allDebridToken: "",
|
|
megaLogin: "user1", megaPassword: "pass1",
|
|
megaCredentials: "user1:pass1\nuser2:pass2",
|
|
megaDebridPreferApi: false,
|
|
providerOrder: [] as const, providerPrimary: "megadebrid" as const,
|
|
providerSecondary: "none" as const, providerTertiary: "none" as const,
|
|
autoProviderFallback: false
|
|
};
|
|
|
|
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
|
|
|
|
const megaWeb = vi.fn(async () => ({ fileName: "ok.rar", directUrl: "https://mega-web.example/ok.rar", fileSize: null, retriesUsed: 0 }));
|
|
|
|
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
|
const usedIds: (string | undefined)[] = [];
|
|
for (let i = 0; i < MEGA_DEBRID_STICKY_LINKS + 1; i += 1) {
|
|
const result = await service.unrestrictLink(`https://rapidgator.net/file/chunk-${i}`);
|
|
usedIds.push((result as { sourceAccountId?: string }).sourceAccountId);
|
|
}
|
|
|
|
expect(usedIds.slice(0, MEGA_DEBRID_STICKY_LINKS)).toEqual(new Array(MEGA_DEBRID_STICKY_LINKS).fill(getMegaDebridAccountId("user1")));
|
|
expect(usedIds[MEGA_DEBRID_STICKY_LINKS]).toBe(getMegaDebridAccountId("user2"));
|
|
}, 30000);
|
|
|
|
it("ueberspringt einen gesperrten Account und bleibt dann klebrig beim naechsten", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "", bestToken: "", allDebridToken: "",
|
|
megaLogin: "user1", megaPassword: "pass1",
|
|
megaCredentials: "user1:pass1\nuser2:pass2\nuser3:pass3",
|
|
megaDebridPreferApi: false,
|
|
providerOrder: [] as const, providerPrimary: "megadebrid" as const,
|
|
providerSecondary: "none" as const, providerTertiary: "none" as const,
|
|
autoProviderFallback: false
|
|
};
|
|
|
|
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
|
|
|
|
const megaWeb = vi.fn(async () => ({ fileName: "ok.rar", directUrl: "https://mega-web.example/ok.rar", fileSize: null, retriesUsed: 0 }));
|
|
|
|
primeMegaDebridUntilRestartForTests(`${getMegaDebridAccountId("user1")}:web`);
|
|
|
|
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
|
const usedIds: (string | undefined)[] = [];
|
|
for (let i = 0; i < 3; i += 1) {
|
|
const result = await service.unrestrictLink(`https://rapidgator.net/file/skip-${i}`);
|
|
usedIds.push((result as { sourceAccountId?: string }).sourceAccountId);
|
|
}
|
|
|
|
expect(usedIds).toEqual(new Array(3).fill(getMegaDebridAccountId("user2")));
|
|
}, 30000);
|
|
|
|
it("verteilt gleichzeitige Umwandlungen auf verschiedene Accounts (parallel, in-flight-Routing)", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "", bestToken: "", allDebridToken: "",
|
|
megaLogin: "user1", megaPassword: "pass1",
|
|
megaCredentials: "user1:pass1\nuser2:pass2\nuser3:pass3\nuser4:pass4",
|
|
megaDebridPreferApi: false,
|
|
providerOrder: [] as const, providerPrimary: "megadebrid" as const,
|
|
providerSecondary: "none" as const, providerTertiary: "none" as const,
|
|
autoProviderFallback: false
|
|
};
|
|
|
|
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
|
|
|
|
let active = 0;
|
|
let maxActive = 0;
|
|
const accountsSeen = new Set<string>();
|
|
const allInFlight = new Promise<void>((resolve) => {
|
|
const check = setInterval(() => { if (active >= 4) { clearInterval(check); resolve(); } }, 5);
|
|
});
|
|
const megaWeb = vi.fn(async (_link: string, _signal: AbortSignal | undefined, account?: { login: string; password: string }) => {
|
|
active += 1;
|
|
maxActive = Math.max(maxActive, active);
|
|
if (account) accountsSeen.add(account.login);
|
|
await allInFlight;
|
|
active -= 1;
|
|
return { fileName: "ok.rar", directUrl: "https://mega-web.example/ok.rar", fileSize: null, retriesUsed: 0 };
|
|
});
|
|
|
|
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
|
const results = await Promise.all([0, 1, 2, 3].map((i) => service.unrestrictLink(`https://rapidgator.net/file/conc-${i}`)));
|
|
|
|
expect(results.every((r) => Boolean((r as { directUrl?: string }).directUrl))).toBe(true);
|
|
expect(accountsSeen.size).toBe(4);
|
|
expect(maxActive).toBe(4);
|
|
}, 15000);
|
|
|
|
it("verteilt Ueberzahl-Umwandlungen (mehr gleichzeitig als Accounts) gleichmaessig statt sie zu stapeln", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "", bestToken: "", allDebridToken: "",
|
|
megaLogin: "user1", megaPassword: "pass1",
|
|
megaCredentials: "user1:pass1\nuser2:pass2",
|
|
megaDebridPreferApi: false,
|
|
providerOrder: [] as const, providerPrimary: "megadebrid" as const,
|
|
providerSecondary: "none" as const, providerTertiary: "none" as const,
|
|
autoProviderFallback: false
|
|
};
|
|
|
|
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
|
|
|
|
let active = 0;
|
|
const callsPerAccount = new Map<string, number>();
|
|
const allInFlight = new Promise<void>((resolve) => {
|
|
const check = setInterval(() => { if (active >= 8) { clearInterval(check); resolve(); } }, 5);
|
|
});
|
|
const megaWeb = vi.fn(async (_link: string, _signal: AbortSignal | undefined, account?: { login: string; password: string }) => {
|
|
active += 1;
|
|
if (account) callsPerAccount.set(account.login, (callsPerAccount.get(account.login) ?? 0) + 1);
|
|
await allInFlight;
|
|
return { fileName: "ok.rar", directUrl: "https://mega-web.example/ok.rar", fileSize: null, retriesUsed: 0 };
|
|
});
|
|
|
|
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
|
await Promise.all(Array.from({ length: 8 }, (_unused, i) => service.unrestrictLink(`https://rapidgator.net/file/ov-${i}`)));
|
|
|
|
// 8 gleichzeitige Aufloesungen auf 2 Accounts → 4 je Account (statt 7/1 beim alten belegt/frei-Set).
|
|
expect(callsPerAccount.get("user1")).toBe(4);
|
|
expect(callsPerAccount.get("user2")).toBe(4);
|
|
}, 15000);
|
|
|
|
it("faellt ein Account in der Mitte aus (1,2,4 ok, 3 nicht): parallel nur ueber die funktionierenden, alle Links loesen auf", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "", bestToken: "", allDebridToken: "",
|
|
megaLogin: "user1", megaPassword: "pass1",
|
|
megaCredentials: "user1:pass1\nuser2:pass2\nuser3:pass3\nuser4:pass4",
|
|
megaDebridPreferApi: false,
|
|
providerOrder: [] as const, providerPrimary: "megadebrid" as const,
|
|
providerSecondary: "none" as const, providerTertiary: "none" as const,
|
|
autoProviderFallback: false
|
|
};
|
|
|
|
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
|
|
|
|
let active = 0;
|
|
const used = new Set<string>();
|
|
const threeInFlight = new Promise<void>((resolve) => {
|
|
const check = setInterval(() => { if (active >= 3) { clearInterval(check); resolve(); } }, 5);
|
|
});
|
|
const megaWeb = vi.fn(async (_link: string, _signal: AbortSignal | undefined, account?: { login: string; password: string }) => {
|
|
active += 1;
|
|
if (account) used.add(account.login);
|
|
await threeInFlight;
|
|
return { fileName: "ok.rar", directUrl: "https://mega-web.example/ok.rar", fileSize: null, retriesUsed: 0 };
|
|
});
|
|
|
|
// Account 3 ist ausgefallen (z.B. zuvor fehlgeschlagen -> gesperrt) und faellt aus der Rotation.
|
|
primeMegaDebridUntilRestartForTests(`${getMegaDebridAccountId("user3")}:web`);
|
|
|
|
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
|
const results = await Promise.all([0, 1, 2].map((i) => service.unrestrictLink(`https://rapidgator.net/file/dead3-${i}`)));
|
|
|
|
expect(results.every((r) => Boolean((r as { directUrl?: string }).directUrl))).toBe(true);
|
|
expect(used.has("user3")).toBe(false);
|
|
expect(used.has("user1")).toBe(true);
|
|
expect(used.has("user2")).toBe(true);
|
|
expect(used.has("user4")).toBe(true);
|
|
}, 15000);
|
|
|
|
it("rotates to the next Mega-Debrid account when one hits its daily limit (error-based)", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "",
|
|
bestToken: "",
|
|
allDebridToken: "",
|
|
megaLogin: "user1",
|
|
megaPassword: "pass1",
|
|
megaCredentials: "user1:pass1\nuser2:pass2",
|
|
megaDebridPreferApi: false,
|
|
providerOrder: [] as const,
|
|
providerPrimary: "megadebrid" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: false
|
|
};
|
|
|
|
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
|
|
|
|
let webCalls = 0;
|
|
const megaWeb = vi.fn(async (_link: string, _signal?: AbortSignal) => {
|
|
webCalls += 1;
|
|
if (webCalls <= 3) {
|
|
throw new Error("Mega-Web: daily limit reached (Tageslimit erreicht)");
|
|
}
|
|
return {
|
|
fileName: "rotated-to-acc2.rar",
|
|
directUrl: "https://mega-web.example/rotated-to-acc2.rar",
|
|
fileSize: null,
|
|
retriesUsed: 0
|
|
};
|
|
});
|
|
|
|
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
|
const result = await service.unrestrictLink("https://rapidgator.net/file/limit-rotation-test");
|
|
|
|
expect(result.directUrl).toBe("https://mega-web.example/rotated-to-acc2.rar");
|
|
expect(webCalls).toBeGreaterThanOrEqual(4);
|
|
}, 30000);
|
|
|
|
it("skips a manually disabled Mega-Debrid account and uses the next one", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "",
|
|
bestToken: "",
|
|
allDebridToken: "",
|
|
megaLogin: "user1",
|
|
megaPassword: "pass1",
|
|
megaCredentials: "user1:pass1\nuser2:pass2",
|
|
megaDebridDisabledAccountIds: [getMegaDebridAccountId("user1")],
|
|
megaDebridPreferApi: false,
|
|
providerOrder: [] as const,
|
|
providerPrimary: "megadebrid" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: false
|
|
};
|
|
|
|
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
|
|
|
|
const megaWeb = vi.fn(async () => ({
|
|
fileName: "from-acc2.rar",
|
|
directUrl: "https://mega-web.example/from-acc2.rar",
|
|
fileSize: null,
|
|
retriesUsed: 0
|
|
}));
|
|
|
|
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
|
const result = await service.unrestrictLink("https://rapidgator.net/file/disabled-acc-test");
|
|
|
|
expect((result as { sourceAccountId?: string }).sourceAccountId).toBe(getMegaDebridAccountId("user2"));
|
|
expect(result.directUrl).toBe("https://mega-web.example/from-acc2.rar");
|
|
expect(megaWeb).toHaveBeenCalledTimes(1);
|
|
}, 20000);
|
|
|
|
it("fails fast on Mega-Debrid hoster quota ('Kein Server') and rotates to the next account", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "",
|
|
bestToken: "",
|
|
allDebridToken: "",
|
|
megaLogin: "user1",
|
|
megaPassword: "pass1",
|
|
megaCredentials: "user1:pass1\nuser2:pass2",
|
|
megaDebridPreferApi: false,
|
|
providerOrder: [] as const,
|
|
providerPrimary: "megadebrid" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: false
|
|
};
|
|
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
|
|
|
|
let calls = 0;
|
|
const megaWeb = vi.fn(async () => {
|
|
calls += 1;
|
|
if (calls === 1) {
|
|
throw new Error("Mega-Web: Kein Server für diesen Hoster verfügbar. Bitte versuchen Sie es später noch einmal.");
|
|
}
|
|
return { fileName: "acc2.rar", directUrl: "https://mega-web.example/acc2.rar", fileSize: null, retriesUsed: 0 };
|
|
});
|
|
|
|
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
|
const result = await service.unrestrictLink("https://rapidgator.net/file/quota-rotate-test");
|
|
|
|
expect((result as { sourceAccountId?: string }).sourceAccountId).toBe(getMegaDebridAccountId("user2"));
|
|
expect(result.directUrl).toBe("https://mega-web.example/acc2.rar");
|
|
expect(calls).toBe(2);
|
|
}, 20000);
|
|
|
|
it("passes each account's OWN credentials to the Mega web unrestrict during rotation", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "",
|
|
bestToken: "",
|
|
allDebridToken: "",
|
|
megaLogin: "user1",
|
|
megaPassword: "pass1",
|
|
megaCredentials: "user1:pass1\nuser2:pass2",
|
|
megaDebridPreferApi: false,
|
|
providerOrder: [] as const,
|
|
providerPrimary: "megadebrid" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: false
|
|
};
|
|
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
|
|
|
|
const accountsSeen: Array<string | undefined> = [];
|
|
const megaWeb = vi.fn(async (_link: string, _signal: AbortSignal | undefined, account?: { login: string; password: string }) => {
|
|
accountsSeen.push(account?.login);
|
|
if (account?.login === "user1") {
|
|
throw new Error("Mega-Web: Kein Server für diesen Hoster verfügbar. Bitte versuchen Sie es später noch einmal.");
|
|
}
|
|
return { fileName: "ok.rar", directUrl: "https://mega-web.example/ok.rar", fileSize: null, retriesUsed: 0 };
|
|
});
|
|
|
|
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
|
const result = await service.unrestrictLink("https://rapidgator.net/file/per-account-creds");
|
|
|
|
expect(accountsSeen).toContain("user1");
|
|
expect(accountsSeen).toContain("user2");
|
|
expect((result as { sourceAccountId?: string }).sourceAccountId).toBe(getMegaDebridAccountId("user2"));
|
|
expect(result.directUrl).toBe("https://mega-web.example/ok.rar");
|
|
}, 20000);
|
|
|
|
it("setzt KEINEN Account-Cooldown, wenn der Mega-Web-Abbruch nur ein Queue-Timeout war (Account war belegt, nicht ungesund)", async () => {
|
|
process.env.RD_MEGA_ABORT_MIN_RUN_MS = "0";
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "",
|
|
bestToken: "",
|
|
allDebridToken: "",
|
|
megaLogin: "user",
|
|
megaPassword: "pass",
|
|
megaCredentials: "user:pass",
|
|
megaDebridPreferApi: false,
|
|
providerOrder: [] as const,
|
|
providerPrimary: "megadebrid" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: false
|
|
};
|
|
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
|
|
|
|
const controller = new AbortController();
|
|
let calls = 0;
|
|
const megaWeb = vi.fn(async () => {
|
|
calls += 1;
|
|
controller.abort("caller-timeout");
|
|
throw new Error("Mega-Web Queue-Timeout (abgebrochen nach 60s Wartezeit, Account war belegt)");
|
|
});
|
|
|
|
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
|
await expect(
|
|
service.unrestrictLink("https://rapidgator.net/file/queue-timeout-no-cooldown", controller.signal)
|
|
).rejects.toThrow();
|
|
|
|
const key = `${getMegaDebridAccountId("user")}:web`;
|
|
expect(getMegaDebridAccountCooldownState(key)).toBeNull();
|
|
expect(calls).toBeGreaterThanOrEqual(1);
|
|
}, 20000);
|
|
|
|
it("getProviderRuntimeSnapshot surfaces a live Mega-Debrid account cooldown (until/remaining/reason) for the diagnostics endpoint", () => {
|
|
const accId = getMegaDebridAccountId("user");
|
|
const key = `${accId}:web`;
|
|
expect(getProviderRuntimeSnapshot().megaDebrid.accounts.find((a) => a.key === key)?.cooldown ?? null).toBeNull();
|
|
|
|
primeMegaDebridRuntimeCooldownForTests(key, 90_000, "Abbruch/Timeout nach 60s");
|
|
|
|
const snap = getProviderRuntimeSnapshot();
|
|
expect(typeof snap.capturedAtMs).toBe("number");
|
|
const acc = snap.megaDebrid.accounts.find((a) => a.key === key);
|
|
expect(acc).toBeTruthy();
|
|
expect(acc!.cooldown).not.toBeNull();
|
|
expect(acc!.cooldown!.remainingMs).toBeGreaterThan(0);
|
|
expect(acc!.cooldown!.remainingMs).toBeLessThanOrEqual(90_000);
|
|
expect(acc!.cooldown!.untilMs).toBeGreaterThan(snap.capturedAtMs);
|
|
expect(acc!.cooldown!.message).toContain("Abbruch");
|
|
});
|
|
|
|
it("single Mega-Debrid account: a long Web abort parks only the slow link and does NOT freeze the sole account", async () => {
|
|
process.env.RD_MEGA_ABORT_MIN_RUN_MS = "0";
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "",
|
|
bestToken: "",
|
|
allDebridToken: "",
|
|
megaLogin: "user",
|
|
megaPassword: "pass",
|
|
megaCredentials: "user:pass",
|
|
megaDebridPreferApi: false,
|
|
providerOrder: [] as const,
|
|
providerPrimary: "megadebrid" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: false
|
|
};
|
|
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
|
|
|
|
const controller = new AbortController();
|
|
let calls = 0;
|
|
const megaWeb = vi.fn((): Promise<{ fileName: string; directUrl: string; fileSize: number | null; retriesUsed: number }> => {
|
|
calls += 1;
|
|
if (calls === 1) {
|
|
controller.abort("simulated-60s-timeout");
|
|
return Promise.reject(new Error("aborted"));
|
|
}
|
|
return Promise.resolve({
|
|
fileName: "healthy.rar",
|
|
directUrl: "https://www11.unrestrict.link/download/file/ok/healthy.rar",
|
|
fileSize: null,
|
|
retriesUsed: 0
|
|
});
|
|
});
|
|
|
|
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
|
|
|
const err = await service.unrestrictLink("https://rapidgator.net/file/slow-link.rar.html", controller.signal).then(() => null, (e: unknown) => e);
|
|
expect(err).toBeTruthy();
|
|
expect(String(err)).toMatch(/mega_debrid_slow_link:\d+:/i);
|
|
|
|
const key = `${getMegaDebridAccountId("user")}:web`;
|
|
expect(getMegaDebridAccountCooldownState(key)).toBeNull();
|
|
|
|
const second = await service.unrestrictLink("https://rapidgator.net/file/healthy.rar.html");
|
|
expect(second.provider).toBe("megadebrid");
|
|
expect(calls).toBeGreaterThanOrEqual(2);
|
|
}, 20000);
|
|
|
|
it("escalates a Mega-Debrid account to 'until restart' after the empty-response streak threshold", () => {
|
|
const key = `${getMegaDebridAccountId("user1")}:web`;
|
|
expect(MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART).toBe(10);
|
|
expect(recordMegaDebridEmptyResponseStreak(key)).toBe(1);
|
|
expect(recordMegaDebridEmptyResponseStreak(key)).toBe(2);
|
|
expect(recordMegaDebridEmptyResponseStreak(key)).toBe(3);
|
|
clearMegaDebridEmptyResponseStreak(key);
|
|
expect(recordMegaDebridEmptyResponseStreak(key)).toBe(1);
|
|
});
|
|
|
|
it("self-heals a daily-limit park after the daily reset instead of staying locked until process restart", () => {
|
|
const key = `${getMegaDebridAccountId("user1")}:api`;
|
|
primeMegaDebridUntilRestartForTests(key);
|
|
const active = getMegaDebridAccountCooldownState(key);
|
|
expect(active?.untilRestart).toBe(true);
|
|
expect(getMegaDebridAccountCooldownState(key, Date.now() + 60_000)?.untilRestart).toBe(true);
|
|
const afterReset = Date.now() + 25 * 60 * 60 * 1000;
|
|
expect(getMegaDebridAccountCooldownState(key, afterReset)).toBeNull();
|
|
});
|
|
|
|
it("does NOT treat a per-hoster 'no server' failure as an account daily-limit signal (no until-restart park)", () => {
|
|
const noServer = classifyMegaDebridAccountFailureForTests(new Error("no server available for this host"));
|
|
expect(noServer.limitSignal).toBeFalsy();
|
|
expect(noServer.category).toBe("quota");
|
|
expect(noServer.cooldownMs).toBeGreaterThan(0);
|
|
|
|
const genuineEmpty = classifyMegaDebridAccountFailureForTests(new Error("Antwort leer"));
|
|
expect(genuineEmpty.limitSignal).toBe(true);
|
|
});
|
|
|
|
it("classifies an empty Mega-Debrid API result ('Linkgenerierung lieferte kein Ergebnis') as a fast transient, not a 30s cooldown", () => {
|
|
const result = classifyMegaDebridAccountFailureForTests(new Error("Mega-Debrid API: Linkgenerierung lieferte kein Ergebnis"));
|
|
expect(result.fatal).toBe(false);
|
|
expect(result.cooldownMs).toBe(0);
|
|
expect(result.limitSignal).toBeFalsy();
|
|
expect(isMegaDebridTransientResolveFailure(result.message)).toBe(true);
|
|
});
|
|
|
|
it("skips a Mega-Debrid account parked until restart and rotates to the next, without re-testing it", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "",
|
|
bestToken: "",
|
|
allDebridToken: "",
|
|
megaLogin: "user1",
|
|
megaPassword: "pass1",
|
|
megaCredentials: "user1:pass1\nuser2:pass2",
|
|
megaDebridPreferApi: false,
|
|
providerOrder: [] as const,
|
|
providerPrimary: "megadebrid" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: false
|
|
};
|
|
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
|
|
|
|
const user1 = getMegaDebridAccountId("user1");
|
|
primeMegaDebridUntilRestartForTests(`${user1}:api`);
|
|
primeMegaDebridUntilRestartForTests(`${user1}:web`);
|
|
|
|
const loginsSeen: Array<string | undefined> = [];
|
|
const megaWeb = vi.fn(async (_link: string, _signal: AbortSignal | undefined, account?: { login: string; password: string }) => {
|
|
loginsSeen.push(account?.login);
|
|
return { fileName: "acc2.rar", directUrl: "https://mega-web.example/acc2.rar", fileSize: null, retriesUsed: 0 };
|
|
});
|
|
|
|
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
|
const result = await service.unrestrictLink("https://rapidgator.net/file/parked-skip-test");
|
|
|
|
expect(loginsSeen).not.toContain("user1");
|
|
expect(loginsSeen).toContain("user2");
|
|
expect((result as { sourceAccountId?: string }).sourceAccountId).toBe(getMegaDebridAccountId("user2"));
|
|
}, 20000);
|
|
|
|
it("emits an until-Tagesreset park token (so the manager parks, not 2min-retries) when ALL Mega-Debrid accounts are parked until restart", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "",
|
|
bestToken: "",
|
|
allDebridToken: "",
|
|
megaLogin: "user1",
|
|
megaPassword: "pass1",
|
|
megaCredentials: "user1:pass1\nuser2:pass2",
|
|
megaDebridPreferApi: false,
|
|
providerOrder: [] as const,
|
|
providerPrimary: "megadebrid" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: false
|
|
};
|
|
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
|
|
|
|
for (const login of ["user1", "user2"]) {
|
|
const id = getMegaDebridAccountId(login);
|
|
primeMegaDebridUntilRestartForTests(`${id}:api`);
|
|
primeMegaDebridUntilRestartForTests(`${id}:web`);
|
|
}
|
|
|
|
const megaWeb = vi.fn(async () => ({ fileName: "x.rar", directUrl: "https://mega-web.example/x.rar", fileSize: null, retriesUsed: 0 }));
|
|
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
|
|
|
const err = await service.unrestrictLink("https://rapidgator.net/file/all-parked-test").then(() => null, (e: unknown) => e as Error);
|
|
expect(err).toBeInstanceOf(Error);
|
|
expect(err!.message).toMatch(/bis zum Tagesreset gesperrt/i);
|
|
expect(err!.message).toMatch(/mega_debrid_reset_park:\d+:/);
|
|
expect(megaWeb).not.toHaveBeenCalled();
|
|
}, 20000);
|
|
|
|
it("drives a real empty response through the full rotation into an until-restart park (wiring test)", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "",
|
|
bestToken: "",
|
|
allDebridToken: "",
|
|
megaLogin: "user1",
|
|
megaPassword: "pass1",
|
|
megaCredentials: "user1:pass1",
|
|
megaDebridPreferApi: false,
|
|
providerOrder: [] as const,
|
|
providerPrimary: "megadebrid" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: false
|
|
};
|
|
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
|
|
|
|
const key = `${getMegaDebridAccountId("user1")}:web`;
|
|
for (let i = 0; i < MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART - 1; i += 1) {
|
|
recordMegaDebridEmptyResponseStreak(key);
|
|
}
|
|
expect(getMegaDebridAccountCooldownState(key)?.untilRestart ?? false).toBe(false);
|
|
|
|
const megaWeb = vi.fn(async () => null);
|
|
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
|
await service.unrestrictLink("https://rapidgator.net/file/wiring").catch(() => undefined);
|
|
|
|
expect(megaWeb).toHaveBeenCalled();
|
|
expect(getMegaDebridAccountCooldownState(key)?.untilRestart).toBe(true);
|
|
}, 20000);
|
|
|
|
it("cools down a Mega-Web account that aborts (timeout) so the NEXT unrestrict rotates to the next account", async () => {
|
|
process.env.RD_MEGA_ABORT_MIN_RUN_MS = "0"; // treat the instant mock abort as a real timeout
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "",
|
|
bestToken: "",
|
|
allDebridToken: "",
|
|
megaLogin: "user1",
|
|
megaPassword: "pass1",
|
|
megaCredentials: "user1:pass1\nuser2:pass2",
|
|
megaDebridPreferApi: false,
|
|
providerOrder: [] as const,
|
|
providerPrimary: "megadebrid" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: false
|
|
};
|
|
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
|
|
|
|
const loginsSeen: Array<string | undefined> = [];
|
|
const megaWeb = vi.fn(async (_link: string, _signal: AbortSignal | undefined, account?: { login: string; password: string }) => {
|
|
loginsSeen.push(account?.login);
|
|
if (account?.login === "user1") {
|
|
throw new Error("aborted:debrid");
|
|
}
|
|
return { fileName: "acc2.rar", directUrl: "https://mega-web.example/acc2.rar", fileSize: null, retriesUsed: 0 };
|
|
});
|
|
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
|
const user1Key = `${getMegaDebridAccountId("user1")}:web`;
|
|
|
|
// Call 1: account 1 aborts -> rotation stops this pass, account 2 NOT tried, but account 1 is cooled down.
|
|
await expect(service.unrestrictLink("https://rapidgator.net/file/abort-call-1")).rejects.toThrow();
|
|
expect(loginsSeen).toContain("user1");
|
|
expect(loginsSeen).not.toContain("user2");
|
|
expect(getMegaDebridAccountCooldownState(user1Key)).not.toBeNull();
|
|
|
|
// Call 2 (the retry, same state): account 1 is on cooldown -> skipped -> account 2 served.
|
|
loginsSeen.length = 0;
|
|
const result = await service.unrestrictLink("https://rapidgator.net/file/abort-call-2");
|
|
expect(loginsSeen).not.toContain("user1");
|
|
expect(loginsSeen).toContain("user2");
|
|
expect((result as { sourceAccountId?: string }).sourceAccountId).toBe(getMegaDebridAccountId("user2"));
|
|
}, 20000);
|
|
|
|
it("does NOT cool down a Mega-Web account on a quick abort (below the min-run threshold = user cancel)", async () => {
|
|
process.env.RD_MEGA_ABORT_MIN_RUN_MS = "99999"; // any realistic elapsed stays below -> no cooldown
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "",
|
|
bestToken: "",
|
|
allDebridToken: "",
|
|
megaLogin: "user1",
|
|
megaPassword: "pass1",
|
|
megaCredentials: "user1:pass1\nuser2:pass2",
|
|
megaDebridPreferApi: false,
|
|
providerOrder: [] as const,
|
|
providerPrimary: "megadebrid" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: false
|
|
};
|
|
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
|
|
|
|
const megaWeb = vi.fn(async () => { throw new Error("aborted:debrid"); });
|
|
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
|
const user1Key = `${getMegaDebridAccountId("user1")}:web`;
|
|
|
|
await expect(service.unrestrictLink("https://rapidgator.net/file/quick-cancel")).rejects.toThrow();
|
|
expect(getMegaDebridAccountCooldownState(user1Key)).toBeNull();
|
|
}, 20000);
|
|
|
|
it("respects provider selection and does not append hidden providers", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "",
|
|
bestToken: "",
|
|
allDebridToken: "ad-token",
|
|
megaLogin: "user",
|
|
megaPassword: "pass",
|
|
megaCredentials: "user:pass",
|
|
providerPrimary: "megadebrid" as const,
|
|
providerSecondary: "megadebrid" as const,
|
|
providerTertiary: "megadebrid" as const,
|
|
autoProviderFallback: true
|
|
};
|
|
|
|
let allDebridCalls = 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("api.alldebrid.com/v4/link/unlock")) {
|
|
allDebridCalls += 1;
|
|
return new Response(JSON.stringify({ status: "success", data: { link: "https://alldebrid.example/file.bin" } }), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}
|
|
return new Response("not-found", { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
const megaWeb = vi.fn(async () => null);
|
|
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
|
await expect(service.unrestrictLink("https://rapidgator.net/file/example.part5.rar.html")).rejects.toThrow();
|
|
expect(allDebridCalls).toBe(0);
|
|
});
|
|
|
|
it("does not use secondary provider when fallback is disabled and primary is missing", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "",
|
|
megaLogin: "user",
|
|
megaPassword: "pass",
|
|
megaCredentials: "user:pass",
|
|
providerPrimary: "realdebrid" as const,
|
|
providerSecondary: "megadebrid" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: false
|
|
};
|
|
|
|
const megaWeb = vi.fn(async () => ({
|
|
fileName: "should-not-run.bin",
|
|
directUrl: "https://unused",
|
|
fileSize: null,
|
|
retriesUsed: 0
|
|
}));
|
|
|
|
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
|
await expect(service.unrestrictLink("https://rapidgator.net/file/example.part5.rar.html")).rejects.toThrow(/nicht konfiguriert/i);
|
|
expect(megaWeb).toHaveBeenCalledTimes(0);
|
|
});
|
|
|
|
it("allows disabling secondary and tertiary providers", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
megaLogin: "user",
|
|
megaPassword: "pass",
|
|
megaCredentials: "user:pass",
|
|
providerPrimary: "realdebrid" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" 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_limit" }), {
|
|
status: 403,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}
|
|
return new Response("not-found", { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
const megaWeb = vi.fn(async () => ({
|
|
fileName: "unused.bin",
|
|
directUrl: "https://unused",
|
|
fileSize: null,
|
|
retriesUsed: 0
|
|
}));
|
|
|
|
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
|
await expect(service.unrestrictLink("https://rapidgator.net/file/example.part6.rar.html")).rejects.toThrow();
|
|
expect(megaWeb).toHaveBeenCalledTimes(0);
|
|
});
|
|
|
|
it("resolves rapidgator filename from page when provider returns hash", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
providerPrimary: "realdebrid" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" 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({
|
|
download: "https://cdn.example/file.bin",
|
|
filename: "6f09df2984fe01378537c7cd8d7fa7ce",
|
|
filesize: 2048
|
|
}), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}
|
|
if (url.includes("rapidgator.net/file/6f09df2984fe01378537c7cd8d7fa7ce")) {
|
|
return new Response("<html><head><title>download file Banshee.S04E01.German.DL.720p.part01.rar - Rapidgator</title></head></html>", {
|
|
status: 200,
|
|
headers: { "Content-Type": "text/html" }
|
|
});
|
|
}
|
|
return new Response("not-found", { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
const service = new DebridService(settings);
|
|
const result = await service.unrestrictLink("https://rapidgator.net/file/6f09df2984fe01378537c7cd8d7fa7ce");
|
|
expect(result.provider).toBe("realdebrid");
|
|
expect(result.fileName).toBe("Banshee.S04E01.German.DL.720p.part01.rar");
|
|
});
|
|
|
|
it("resolves filenames for rg.to links", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
allDebridToken: ""
|
|
};
|
|
|
|
const link = "https://rg.to/file/685cec6dcc1837dc725755fc9c726dd9";
|
|
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
if (url === link) {
|
|
return new Response("<html><head><title>Download file Bulletproof.S01E01.German.DL.DD20.Synced.720p.AmazonHD.h264-GDR.part01.rar</title></head></html>", {
|
|
status: 200,
|
|
headers: { "Content-Type": "text/html" }
|
|
});
|
|
}
|
|
return new Response("not-found", { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
const service = new DebridService(settings);
|
|
const resolved = await service.resolveFilenames([link]);
|
|
expect(resolved.get(link)).toBe("Bulletproof.S01E01.German.DL.DD20.Synced.720p.AmazonHD.h264-GDR.part01.rar");
|
|
});
|
|
|
|
it("does not unrestrict non-rapidgator links during filename scan", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
providerPrimary: "realdebrid" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: true,
|
|
allDebridToken: ""
|
|
};
|
|
|
|
const linkFromPage = "https://rapidgator.net/file/11111111111111111111111111111111";
|
|
const linkFromProvider = "https://hoster.example/file/22222222222222222222222222222222";
|
|
let unrestrictCalls = 0;
|
|
|
|
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 === linkFromPage) {
|
|
return new Response("<html><head><title>Download file from-page.part1.rar</title></head></html>", {
|
|
status: 200,
|
|
headers: { "Content-Type": "text/html" }
|
|
});
|
|
}
|
|
|
|
if (url.includes("api.real-debrid.com/rest/1.0/unrestrict/link")) {
|
|
unrestrictCalls += 1;
|
|
const body = init?.body;
|
|
const bodyText = body instanceof URLSearchParams ? body.toString() : String(body || "");
|
|
const linkValue = new URLSearchParams(bodyText).get("link") || "";
|
|
if (linkValue === linkFromProvider) {
|
|
return new Response(JSON.stringify({
|
|
download: "https://cdn.example/from-provider",
|
|
filename: "from-provider.part2.rar",
|
|
filesize: 1024
|
|
}), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}
|
|
}
|
|
|
|
return new Response("not-found", { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
const service = new DebridService(settings);
|
|
const events: Array<{ link: string; fileName: string }> = [];
|
|
const resolved = await service.resolveFilenames([linkFromPage, linkFromProvider], (link, fileName) => {
|
|
events.push({ link, fileName });
|
|
});
|
|
|
|
expect(resolved.get(linkFromPage)).toBe("from-page.part1.rar");
|
|
expect(resolved.has(linkFromProvider)).toBe(false);
|
|
expect(unrestrictCalls).toBe(0);
|
|
expect(events).toEqual(expect.arrayContaining([
|
|
{ link: linkFromPage, fileName: "from-page.part1.rar" }
|
|
]));
|
|
});
|
|
|
|
it("does not unrestrict rapidgator links during filename scan after page lookup miss", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "rd-token",
|
|
providerPrimary: "realdebrid" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
allDebridToken: ""
|
|
};
|
|
|
|
const link = "https://rapidgator.net/file/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
|
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("api.real-debrid.com/rest/1.0/unrestrict/link")) {
|
|
unrestrictCalls += 1;
|
|
return new Response(JSON.stringify({ error: "should-not-be-called" }), {
|
|
status: 500,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}
|
|
if (url === link) {
|
|
return new Response("not found", { status: 404 });
|
|
}
|
|
return new Response("not-found", { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
const service = new DebridService(settings);
|
|
const resolved = await service.resolveFilenames([link]);
|
|
expect(resolved.size).toBe(0);
|
|
expect(unrestrictCalls).toBe(0);
|
|
});
|
|
|
|
it("maps AllDebrid filename infos by index when response link is missing", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
token: "",
|
|
bestToken: "",
|
|
allDebridToken: "ad-token",
|
|
providerPrimary: "realdebrid" as const,
|
|
providerSecondary: "none" as const,
|
|
providerTertiary: "none" as const,
|
|
autoProviderFallback: true
|
|
};
|
|
|
|
const linkA = "https://rapidgator.net/file/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
|
const linkB = "https://rapidgator.net/file/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
|
|
|
|
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.alldebrid.com/v4/link/infos")) {
|
|
return new Response(JSON.stringify({
|
|
status: "success",
|
|
data: {
|
|
infos: [
|
|
{ filename: "wrong-a.mkv" },
|
|
{ filename: "wrong-b.mkv" }
|
|
]
|
|
}
|
|
}), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}
|
|
if (url === linkA || url === linkB) {
|
|
return new Response("no title", { status: 404 });
|
|
}
|
|
return new Response("not-found", { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
const service = new DebridService(settings);
|
|
const resolved = await service.resolveFilenames([linkA, linkB]);
|
|
expect(resolved.get(linkA)).toBe("wrong-a.mkv");
|
|
expect(resolved.get(linkB)).toBe("wrong-b.mkv");
|
|
expect(resolved.size).toBe(2);
|
|
});
|
|
|
|
it("retries AllDebrid filename infos after transient server error", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
allDebridToken: "ad-token"
|
|
};
|
|
|
|
const link = "https://rapidgator.net/file/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
|
let infoCalls = 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("api.alldebrid.com/v4/link/infos")) {
|
|
infoCalls += 1;
|
|
if (infoCalls === 1) {
|
|
return new Response("temporary error", { status: 500 });
|
|
}
|
|
return new Response(JSON.stringify({
|
|
status: "success",
|
|
data: {
|
|
infos: [
|
|
{ link, filename: "resolved-from-infos.mkv" }
|
|
]
|
|
}
|
|
}), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
}
|
|
return new Response("not-found", { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
const service = new DebridService(settings);
|
|
const resolved = await service.resolveFilenames([link]);
|
|
expect(resolved.get(link)).toBe("resolved-from-infos.mkv");
|
|
expect(infoCalls).toBe(2);
|
|
});
|
|
|
|
it("retries AllDebrid filename infos when HTML challenge is returned", async () => {
|
|
const settings = {
|
|
...defaultSettings(),
|
|
allDebridToken: "ad-token"
|
|
};
|
|
|
|
const link = "https://rapidgator.net/file/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
|
|
let infoCalls = 0;
|
|
let pageCalls = 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("api.alldebrid.com/v4/link/infos")) {
|
|
infoCalls += 1;
|
|
return new Response("<html><title>cf challenge</title></html>", {
|
|
status: 200,
|
|
headers: { "Content-Type": "text/html" }
|
|
});
|
|
}
|
|
if (url === link) {
|
|
pageCalls += 1;
|
|
}
|
|
return new Response("not-found", { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
const service = new DebridService(settings);
|
|
const resolved = await service.resolveFilenames([link]);
|
|
expect(resolved.size).toBe(0);
|
|
expect(infoCalls).toBe(REQUEST_RETRIES);
|
|
expect(pageCalls).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe("normalizeResolvedFilename", () => {
|
|
it("strips HTML entities", () => {
|
|
expect(normalizeResolvedFilename("Show.S01E01.German.DL.720p.part01.rar")).toBe("Show.S01E01.German.DL.720p.part01.rar");
|
|
expect(normalizeResolvedFilename("File&Name.part1.rar")).toBe("File&Name.part1.rar");
|
|
expect(normalizeResolvedFilename("File"Name".part1.rar")).toBe('File"Name".part1.rar');
|
|
});
|
|
|
|
it("strips HTML tags and collapses whitespace", () => {
|
|
const result = normalizeResolvedFilename("<b>Show.S01E01</b>.part01.rar");
|
|
expect(result).toBe("Show.S01E01 .part01.rar");
|
|
|
|
const entityTagResult = normalizeResolvedFilename("File<Tag>.part1.rar");
|
|
expect(entityTagResult).toBe("File .part1.rar");
|
|
});
|
|
|
|
it("strips 'download file' prefix", () => {
|
|
expect(normalizeResolvedFilename("Download file Show.S01E01.part01.rar")).toBe("Show.S01E01.part01.rar");
|
|
expect(normalizeResolvedFilename("download file Movie.2024.mkv")).toBe("Movie.2024.mkv");
|
|
});
|
|
|
|
it("strips Rapidgator suffix", () => {
|
|
expect(normalizeResolvedFilename("Show.S01E01.part01.rar - Rapidgator")).toBe("Show.S01E01.part01.rar");
|
|
expect(normalizeResolvedFilename("Movie.mkv | Rapidgator.net")).toBe("Movie.mkv");
|
|
});
|
|
|
|
it("returns empty for opaque or non-filename values", () => {
|
|
expect(normalizeResolvedFilename("")).toBe("");
|
|
expect(normalizeResolvedFilename("just some text")).toBe("");
|
|
expect(normalizeResolvedFilename("e51f6809bb6ca615601f5ac5db433737")).toBe("");
|
|
expect(normalizeResolvedFilename("download.bin")).toBe("");
|
|
});
|
|
|
|
it("handles combined transforms", () => {
|
|
expect(normalizeResolvedFilename("Download file Show.S01E01.part01.rar - Rapidgator"))
|
|
.toBe("Show.S01E01.part01.rar");
|
|
});
|
|
});
|
|
|
|
describe("parseRapidgatorFileSize", () => {
|
|
it("converts hoster size labels to bytes", () => {
|
|
expect(parseRapidgatorFileSize("1.50 GB")).toBe(1_610_612_736);
|
|
expect(parseRapidgatorFileSize("658,25 MB")).toBe(690_225_152);
|
|
expect(parseRapidgatorFileSize("1024 B")).toBe(1024);
|
|
});
|
|
|
|
it("rejects missing and malformed values", () => {
|
|
expect(parseRapidgatorFileSize(null)).toBeNull();
|
|
expect(parseRapidgatorFileSize("unknown")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("checkRapidgatorOnline", () => {
|
|
it("loads metadata directly without waiting for a separate HEAD request", async () => {
|
|
const methods: string[] = [];
|
|
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
methods.push(String(init?.method || "GET"));
|
|
return new Response('<html><title>episode.part01.rar</title><div>File size: <strong>1.50 GB</strong></div></html>', { status: 200 });
|
|
}) as typeof fetch;
|
|
|
|
const result = await checkRapidgatorOnline("https://rapidgator.net/file/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
|
|
|
|
expect(methods).toEqual(["GET"]);
|
|
expect(result).toEqual({
|
|
online: true,
|
|
fileName: "episode.part01.rar",
|
|
fileSizeBytes: 1_610_612_736
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("DDownload public metadata", () => {
|
|
it("recognizes canonical and short domains without treating unrelated pages as files", () => {
|
|
expect(isDdownloadLink("https://ddownload.com/ntwscdw62gyb")).toBe(true);
|
|
expect(isDdownloadLink("https://ddl.to/ntwscdw62gyb/Archive.part02.rar")).toBe(true);
|
|
expect(isDdownloadLink("https://ddownload.com/login.html")).toBe(false);
|
|
expect(isDdownloadLink("https://example.com/ntwscdw62gyb")).toBe(false);
|
|
});
|
|
|
|
it("uses a real filename from the URL but rejects a bare file code", () => {
|
|
expect(filenameFromDdownloadUrlPath("https://ddownload.com/ntwscdw62gyb/Archive.part02.rar")).toBe("Archive.part02.rar");
|
|
expect(filenameFromDdownloadUrlPath("https://ddownload.com/ntwscdw62gyb")).toBe("");
|
|
});
|
|
|
|
it("parses the current public page name and binary size", () => {
|
|
const html = [
|
|
'<div class="dk-dl-icon" data-fn="Show.S01E02.German.DL.part02.rar">',
|
|
'<h2 class="dk-dl-name">Show.S01E02.German.DL.part02.rar</h2>',
|
|
'<p class="dk-dl-size">502.00 MB</p>'
|
|
].join("");
|
|
|
|
expect(extractDdownloadFilenameFromHtml(html)).toBe("Show.S01E02.German.DL.part02.rar");
|
|
expect(parseDdownloadFileSize("502.00 MB")).toBe(526_385_152);
|
|
});
|
|
|
|
it("returns exact metadata for an online file and explicit offline state for a removed file", async () => {
|
|
const responses = [
|
|
new Response('<div class="dk-dl-icon" data-fn="Show.S01E02.mkv"><h2 class="dk-dl-name">Show.S01E02.mkv</h2><p class="dk-dl-size">840.02 MB</p>', { status: 200, headers: { "Content-Type": "text/html" } }),
|
|
new Response('<main><h1>File Not Found</h1></main>', { status: 200, headers: { "Content-Type": "text/html" } })
|
|
];
|
|
globalThis.fetch = vi.fn(async () => responses.shift() || new Response("", { status: 500 })) as typeof fetch;
|
|
|
|
await expect(checkDdownloadOnline("https://ddownload.com/3nq8wruijuh4")).resolves.toEqual({
|
|
online: true,
|
|
fileName: "Show.S01E02.mkv",
|
|
fileSizeBytes: 880_824_812
|
|
});
|
|
await expect(checkDdownloadOnline("https://ddownload.com/missing1234")).resolves.toEqual({
|
|
online: false,
|
|
fileName: "",
|
|
fileSizeBytes: null
|
|
});
|
|
});
|
|
|
|
it("keeps challenge and malformed pages unknown instead of marking them online", async () => {
|
|
globalThis.fetch = vi.fn(async () => new Response("<html><title>Just a moment...</title></html>", {
|
|
status: 200,
|
|
headers: { "Content-Type": "text/html" }
|
|
})) as typeof fetch;
|
|
|
|
await expect(checkDdownloadOnline("https://ddownload.com/unknown1234")).resolves.toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("checkOneFichierLinks", () => {
|
|
it("checks at most 100 links per request and maps exact metadata", async () => {
|
|
const links = Array.from({ length: 101 }, (_unused, index) => `https://1fichier.com/?id${String(index).padStart(5, "0")}`);
|
|
const batchSizes: number[] = [];
|
|
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
const body = init?.body instanceof URLSearchParams ? init.body : new URLSearchParams(String(init?.body || ""));
|
|
const requested = body.getAll("links[]");
|
|
batchSizes.push(requested.length);
|
|
return new Response(requested.map((link, index) => `${link};Archive.${batchSizes.length}.${index}.7z;${1000 + index}`).join("\r\n"), {
|
|
status: 200,
|
|
headers: { "Content-Type": "text/plain; charset=utf-8" }
|
|
});
|
|
}) as typeof fetch;
|
|
|
|
const results = await checkOneFichierLinks(links);
|
|
|
|
expect(batchSizes).toEqual([100, 1]);
|
|
expect(results.size).toBe(101);
|
|
expect(results.get(links[0])).toEqual({
|
|
online: true,
|
|
fileName: "Archive.1.0.7z",
|
|
fileSizeBytes: 1000,
|
|
accessRestricted: false
|
|
});
|
|
expect(results.get(links[100])).toEqual({
|
|
online: true,
|
|
fileName: "Archive.2.0.7z",
|
|
fileSizeBytes: 1000,
|
|
accessRestricted: false
|
|
});
|
|
});
|
|
|
|
it("paces consecutive 1Fichier batches to avoid hoster request blocks", async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const links = Array.from({ length: 101 }, (_unused, index) => `https://1fichier.com/?pc${String(index).padStart(5, "0")}`);
|
|
let calls = 0;
|
|
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
calls += 1;
|
|
const body = init?.body instanceof URLSearchParams ? init.body : new URLSearchParams(String(init?.body || ""));
|
|
return new Response(body.getAll("links[]").map((link) => `${link};Paced.${calls}.rar;1024`).join("\n"), { status: 200 });
|
|
}) as typeof fetch;
|
|
|
|
const pending = checkOneFichierLinks(links);
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
expect(calls).toBe(1);
|
|
|
|
await vi.advanceTimersByTimeAsync(999);
|
|
expect(calls).toBe(1);
|
|
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
await pending;
|
|
expect(calls).toBe(2);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("maps offline, private and HTML-encoded file responses without inventing metadata", async () => {
|
|
const online = "https://1fichier.com/?online123";
|
|
const offline = "https://1fichier.com/?gone12345";
|
|
const privateLink = "https://1fichier.com/?priv12345";
|
|
globalThis.fetch = (async (): Promise<Response> => new Response([
|
|
`${online};Movie&Bonus.mkv;734003200`,
|
|
`${offline};;;NOT FOUND`,
|
|
`${privateLink};;;PRIVATE`
|
|
].join("\n"), { status: 200 })) as typeof fetch;
|
|
|
|
const results = await checkOneFichierLinks([online, offline, privateLink]);
|
|
|
|
expect(results.get(online)).toEqual({
|
|
online: true,
|
|
fileName: "Movie&Bonus.mkv",
|
|
fileSizeBytes: 734003200,
|
|
accessRestricted: false
|
|
});
|
|
expect(results.get(offline)).toEqual({
|
|
online: false,
|
|
fileName: "",
|
|
fileSizeBytes: null,
|
|
accessRestricted: false
|
|
});
|
|
expect(results.get(privateLink)).toEqual({
|
|
online: true,
|
|
fileName: "",
|
|
fileSizeBytes: null,
|
|
accessRestricted: true
|
|
});
|
|
});
|
|
|
|
it("recognizes current aliases and ignores unrelated links without a request", async () => {
|
|
const fetchSpy = vi.fn(async (): Promise<Response> => new Response("", { status: 200 }));
|
|
globalThis.fetch = fetchSpy as typeof fetch;
|
|
|
|
expect(isOneFichierLink("https://desfichiers.net/?abc12345")).toBe(true);
|
|
expect(isOneFichierLink("https://piecejointe.net/?abc12345")).toBe(true);
|
|
expect(isOneFichierLink("https://example.com/?abc12345")).toBe(false);
|
|
expect(await checkOneFichierLinks(["https://example.com/?abc12345"])).toEqual(new Map());
|
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
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");
|
|
expect(getAccountRuntimeSessionStats("realdebrid", "rda_one")).toEqual(expect.objectContaining({
|
|
attempts: 1,
|
|
successes: 1,
|
|
failures: 0,
|
|
lastUsedAt: expect.any(Number)
|
|
}));
|
|
});
|
|
|
|
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(getAccountRuntimeSessionStats("realdebrid", "rda_one")).toEqual(expect.objectContaining({ attempts: 1, successes: 0, failures: 1 }));
|
|
expect(getAccountRuntimeSessionStats("realdebrid", "rda_two")).toEqual(expect.objectContaining({ attempts: 1, successes: 1, failures: 0 }));
|
|
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");
|
|
});
|
|
|
|
it("extracts filename without .html suffix", () => {
|
|
expect(filenameFromRapidgatorUrlPath("https://rapidgator.net/file/abc123/Movie.2024.mkv"))
|
|
.toBe("Movie.2024.mkv");
|
|
});
|
|
|
|
it("returns empty for hash-only URL paths", () => {
|
|
expect(filenameFromRapidgatorUrlPath("https://rapidgator.net/file/e51f6809bb6ca615601f5ac5db433737"))
|
|
.toBe("");
|
|
});
|
|
|
|
it("returns empty for invalid URLs", () => {
|
|
expect(filenameFromRapidgatorUrlPath("not-a-url")).toBe("");
|
|
expect(filenameFromRapidgatorUrlPath("")).toBe("");
|
|
});
|
|
|
|
it("handles URL-encoded path segments", () => {
|
|
expect(filenameFromRapidgatorUrlPath("https://rapidgator.net/file/id/Show%20Name.S01E01.part01.rar.html"))
|
|
.toBe("Show Name.S01E01.part01.rar");
|
|
});
|
|
});
|
|
|
|
describe("extractRapidgatorFilenameFromHtml", () => {
|
|
it("extracts filename from title tag", () => {
|
|
const html = "<html><head><title>Download file Show.S01E01.German.DL.720p.part01.rar - Rapidgator</title></head></html>";
|
|
expect(extractRapidgatorFilenameFromHtml(html)).toBe("Show.S01E01.German.DL.720p.part01.rar");
|
|
});
|
|
|
|
it("extracts filename from og:title meta tag", () => {
|
|
const html = '<html><head><meta property="og:title" content="Movie.2024.German.DL.1080p.mkv"></head></html>';
|
|
expect(extractRapidgatorFilenameFromHtml(html)).toBe("Movie.2024.German.DL.1080p.mkv");
|
|
});
|
|
|
|
it("extracts filename from reversed og:title attribute order", () => {
|
|
const html = '<html><head><meta content="Movie.2024.German.DL.1080p.mkv" property="og:title"></head></html>';
|
|
expect(extractRapidgatorFilenameFromHtml(html)).toBe("Movie.2024.German.DL.1080p.mkv");
|
|
});
|
|
|
|
it("returns empty for HTML without recognizable filenames", () => {
|
|
const html = "<html><head><title>Rapidgator: Fast, Pair and Unlimited</title></head><body>No file here</body></html>";
|
|
expect(extractRapidgatorFilenameFromHtml(html)).toBe("");
|
|
});
|
|
|
|
it("returns empty for empty HTML", () => {
|
|
expect(extractRapidgatorFilenameFromHtml("")).toBe("");
|
|
});
|
|
|
|
it("ignores broad body text that is not a labeled filename", () => {
|
|
const html = "<html><body>Please download file now from mirror.mkv</body></html>";
|
|
expect(extractRapidgatorFilenameFromHtml(html)).toBe("");
|
|
});
|
|
|
|
it("extracts from File name label in page body", () => {
|
|
const html = '<html><body>File name: <b>Show.S02E03.720p.part01.rar</b></body></html>';
|
|
expect(extractRapidgatorFilenameFromHtml(html)).toBe("Show.S02E03.720p.part01.rar");
|
|
});
|
|
});
|