diff --git a/src/main/debrid.ts b/src/main/debrid.ts index 07785d3..00c5d8e 100644 --- a/src/main/debrid.ts +++ b/src/main/debrid.ts @@ -1897,7 +1897,7 @@ class MegaDebridClient { logger.info(`Mega-Debrid (API) unrestrict OK: ${apiResult.fileName}`); return apiResult; } - throw new Error("Mega-Debrid API: Login oder Unrestrict fehlgeschlagen"); + throw new Error("Mega-Debrid API: Linkgenerierung lieferte kein Ergebnis"); } catch (error) { const errorText = compactErrorText(error); if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) { @@ -2162,7 +2162,11 @@ class MegaDebridClient { return { fatal: true, cooldownMs: 0, message: errorText, category: "temporary" }; } - if (/login|password|auth|credentials|unauthorized|forbidden/i.test(errorText) || /connectUser/i.test(errorText)) { + if (/token.?error|please log.?in/i.test(errorText)) { + return { fatal: false, cooldownMs: 15_000, message: errorText, category: "temporary" }; + } + + if (/bad.?login|incorrect.?(login|password)|invalid.?(login|password|credentials)|wrong.?password|unauthorized|forbidden|connectUser/i.test(errorText)) { return { fatal: false, cooldownMs: MEGA_DEBRID_INVALID_ACCOUNT_COOLDOWN_MS, diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 304c267..a432331 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -7998,9 +7998,10 @@ export class DownloadManager extends EventEmitter { } private getSerializedValidatingLimit(provider: DebridProvider | null): number { - if (provider === "megadebrid-web") { + if (provider === "megadebrid-web" || provider === "megadebrid-api") { + const mode = provider === "megadebrid-web" ? "web" : "api"; const usableAccounts = getAvailableMegaDebridAccounts(this.settings) - .filter((account) => !getMegaDebridAccountCooldownState(`${account.id}:web`)) + .filter((account) => !getMegaDebridAccountCooldownState(`${account.id}:${mode}`)) .length; return Math.max(1, usableAccounts); } diff --git a/tests/debrid.test.ts b/tests/debrid.test.ts index 670b366..844b2ce 100644 --- a/tests/debrid.test.ts +++ b/tests/debrid.test.ts @@ -1339,6 +1339,84 @@ describe("debrid service", () => { 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 => { + 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 => { + 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("uses Mega Web only when it is configured as a separate fallback provider", async () => { const settings = { ...defaultSettings(), diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index e6290e7..d59c1f0 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -6721,6 +6721,160 @@ describe("download manager", () => { await new Promise((resolve) => setTimeout(resolve, 150)); }); + it("serializes Mega-Debrid API conversions to one per account (no single-token hammering)", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + let getLinkCalls = 0; + const pendingRejectors = new Set<(error: Error) => void>(); + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : (input as Request).url; + if (url.includes("action=connectUser")) { + return new Response(JSON.stringify({ response_code: "ok", token: `tok-${getLinkCalls}-${url.length}` }), { status: 200, headers: { "Content-Type": "application/json" } }); + } + if (url.includes("action=getLink")) { + getLinkCalls += 1; + const signal = init?.signal; + return await new Promise((_resolve, reject) => { + const rejector = (error: Error): void => { + signal?.removeEventListener("abort", onAbort); + pendingRejectors.delete(rejector); + reject(error); + }; + const onAbort = (): void => { rejector(new Error("aborted:test-api")); }; + if (signal?.aborted) { onAbort(); return; } + signal?.addEventListener("abort", onAbort, { once: true }); + pendingRejectors.add(rejector); + }); + } + return originalFetch(input, init); + }) as typeof fetch; + + const manager = new DownloadManager( + { + ...defaultSettings(), + megaCredentials: "mega-user-a:pass-a\nmega-user-b:pass-b", + megaDebridApiEnabled: true, + megaDebridWebEnabled: false, + megaDebridPreferApi: true, + providerOrder: [], + providerPrimary: "megadebrid", + providerSecondary: "none", + providerTertiary: "none", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false, + autoReconnect: false, + enableIntegrityCheck: false, + maxParallel: 6 + }, + emptySession(), + createStoragePaths(path.join(root, "state")), + {} + ); + + manager.addPackages([{ + name: "mega-api-serialized", + links: [ + "https://rapidgator.net/file/api-1.part1.rar.html", + "https://rapidgator.net/file/api-2.part2.rar.html", + "https://rapidgator.net/file/api-3.part3.rar.html" + ] + }]); + + await manager.start(); + await waitFor(() => getLinkCalls === 2, 10000); + await new Promise((resolve) => setTimeout(resolve, 250)); + + const items = Object.values(manager.getSnapshot().session.items); + expect(items.filter((item) => item.status === "validating")).toHaveLength(2); + expect(items.filter((item) => item.status === "queued")).toHaveLength(1); + expect(getLinkCalls).toBe(2); + + manager.stop(); + for (const reject of Array.from(pendingRejectors)) { + reject(new Error("aborted:test-api")); + } + await new Promise((resolve) => setTimeout(resolve, 150)); + }); + + it("limits Mega-Debrid API conversions to one at a time with a single account", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + let getLinkCalls = 0; + const pendingRejectors = new Set<(error: Error) => void>(); + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : (input as Request).url; + if (url.includes("action=connectUser")) { + return new Response(JSON.stringify({ response_code: "ok", token: "tok-single" }), { status: 200, headers: { "Content-Type": "application/json" } }); + } + if (url.includes("action=getLink")) { + getLinkCalls += 1; + const signal = init?.signal; + return await new Promise((_resolve, reject) => { + const rejector = (error: Error): void => { + signal?.removeEventListener("abort", onAbort); + pendingRejectors.delete(rejector); + reject(error); + }; + const onAbort = (): void => { rejector(new Error("aborted:test-api")); }; + if (signal?.aborted) { onAbort(); return; } + signal?.addEventListener("abort", onAbort, { once: true }); + pendingRejectors.add(rejector); + }); + } + return originalFetch(input, init); + }) as typeof fetch; + + const manager = new DownloadManager( + { + ...defaultSettings(), + megaCredentials: "mega-user:mega-pass", + megaDebridApiEnabled: true, + megaDebridWebEnabled: false, + megaDebridPreferApi: true, + providerOrder: [], + providerPrimary: "megadebrid", + providerSecondary: "none", + providerTertiary: "none", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false, + autoReconnect: false, + enableIntegrityCheck: false, + maxParallel: 6 + }, + emptySession(), + createStoragePaths(path.join(root, "state")), + {} + ); + + manager.addPackages([{ + name: "mega-api-single", + links: [ + "https://rapidgator.net/file/single-1.part1.rar.html", + "https://rapidgator.net/file/single-2.part2.rar.html", + "https://rapidgator.net/file/single-3.part3.rar.html" + ] + }]); + + await manager.start(); + await waitFor(() => getLinkCalls === 1, 10000); + await new Promise((resolve) => setTimeout(resolve, 250)); + + const items = Object.values(manager.getSnapshot().session.items); + expect(items.filter((item) => item.status === "validating")).toHaveLength(1); + expect(items.filter((item) => item.status === "queued")).toHaveLength(2); + expect(getLinkCalls).toBe(1); + + manager.stop(); + for (const reject of Array.from(pendingRejectors)) { + reject(new Error("aborted:test-api")); + } + await new Promise((resolve) => setTimeout(resolve, 150)); + }); + it("shows the same AllDebrid countdown for all immediately free slots", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); tempDirs.push(root);