diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index c4b9b6c..1ba98fd 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -103,7 +103,7 @@ export class AppController { this.allDebridWebFallback = new AllDebridWebFallback(() => this.settings.rememberToken); this.bestDebridWebFallback = new BestDebridWebFallback(() => this.settings.rememberToken); this.manager = new DownloadManager(this.settings, session, this.storagePaths, { - megaWebUnrestrict: (link: string, signal?: AbortSignal) => this.megaWebFallback.unrestrict(link, signal), + megaWebUnrestrict: (link: string, signal?: AbortSignal, account?: { login: string; password: string }) => this.megaWebFallback.unrestrict(link, signal, account), allDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.allDebridWebFallback.unrestrict(link, signal), realDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.realDebridWebFallback.unrestrict(link, signal), bestDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.bestDebridWebFallback.unrestrict(link, signal), diff --git a/src/main/debrid.ts b/src/main/debrid.ts index 19f370c..3d76499 100644 --- a/src/main/debrid.ts +++ b/src/main/debrid.ts @@ -412,7 +412,7 @@ interface ProviderUnrestrictedLink extends UnrestrictedLink { providerLabel: string; } -export type MegaWebUnrestrictor = (link: string, signal?: AbortSignal) => Promise; +export type MegaWebUnrestrictor = (link: string, signal?: AbortSignal, account?: { login: string; password: string }) => Promise; export type AllDebridWebUnrestrictor = (link: string, signal?: AbortSignal) => Promise; export type RealDebridWebUnrestrictor = (link: string, signal?: AbortSignal) => Promise; export type BestDebridWebUnrestrictor = (link: string, signal?: AbortSignal) => Promise; @@ -1849,7 +1849,7 @@ class MegaDebridClient { if (signal?.aborted) { throw new Error("aborted:debrid"); } - const web = await this.megaWebUnrestrict(link, signal).catch((error) => { + const web = await this.megaWebUnrestrict(link, signal, { login: this.login, password: this.password }).catch((error) => { lastError = compactErrorText(error); return null; }); diff --git a/src/main/mega-web-fallback.ts b/src/main/mega-web-fallback.ts index 5d5273d..bb67546 100644 --- a/src/main/mega-web-fallback.ts +++ b/src/main/mega-web-fallback.ts @@ -228,43 +228,45 @@ export class MegaWebFallback { private getCredentials: () => MegaCredentials; - private cookie = ""; - - private cookieSetAt = 0; + // Per-Login Session-Cache: login(lowercase) → { cookie, setAt }. Multi-Account- + // Rotation: jeder Account nutzt SEINE eigene Session. Frueher gab es nur EINE + // geteilte Cookie-Session → der Web-Unrestrict lief fuer JEDEN rotierten Account mit + // den Creds des ersten/Legacy-Accounts (settings.megaLogin); der naechste Account + // wurde nie wirklich verwendet (Rotation war wirkungslos). + private sessions = new Map(); public constructor(getCredentials: () => MegaCredentials) { this.getCredentials = getCredentials; } - public async unrestrict(link: string, signal?: AbortSignal): Promise { + public async unrestrict( + link: string, + signal?: AbortSignal, + account?: { login: string; password: string } + ): Promise { const overallSignal = withTimeoutSignal(signal, 180000); return this.runExclusive(async () => { throwIfAborted(overallSignal); - const creds = this.getCredentials(); + // Per-Account-Creds aus der Rotation bevorzugen; sonst Legacy-Default. + const creds = (account && account.login.trim() && account.password.trim()) + ? account + : this.getCredentials(); if (!creds.login.trim() || !creds.password.trim()) { return null; } + const key = creds.login.trim().toLowerCase(); + let cookie = await this.ensureSession(key, creds.login, creds.password, overallSignal); - if (!this.cookie || Date.now() - this.cookieSetAt > 20 * 60 * 1000) { - await this.login(creds.login, creds.password, overallSignal); - } - - const generated = await this.generate(link, overallSignal); + let generated = await this.generate(link, cookie, overallSignal); if (!generated) { - this.cookie = ""; - await this.login(creds.login, creds.password, overallSignal); - const retry = await this.generate(link, overallSignal); - if (!retry) { + // Session evtl. abgelaufen → fuer DIESEN Login neu einloggen + einmal erneut. + this.sessions.delete(key); + cookie = await this.ensureSession(key, creds.login, creds.password, overallSignal); + generated = await this.generate(link, cookie, overallSignal); + if (!generated) { return null; } - return { - directUrl: retry.directUrl, - fileName: retry.fileName || filenameFromUrl(link), - fileSize: null, - retriesUsed: 0 - }; } - return { directUrl: generated.directUrl, fileName: generated.fileName || filenameFromUrl(link), @@ -274,9 +276,20 @@ export class MegaWebFallback { }, overallSignal); } + /** Liefert ein gueltiges Session-Cookie fuer den gegebenen Login (aus Cache oder + * via frischem Login). Cache-TTL 20 min. */ + private async ensureSession(key: string, login: string, password: string, signal?: AbortSignal): Promise { + const existing = this.sessions.get(key); + if (existing && existing.cookie && Date.now() - existing.setAt <= 20 * 60 * 1000) { + return existing.cookie; + } + const cookie = await this.login(login, password, signal); + this.sessions.set(key, { cookie, setAt: Date.now() }); + return cookie; + } + public invalidateSession(): void { - this.cookie = ""; - this.cookieSetAt = 0; + this.sessions.clear(); } private async runExclusive(job: () => Promise, signal?: AbortSignal): Promise { @@ -295,7 +308,7 @@ export class MegaWebFallback { return raceWithAbort(run, signal); } - private async login(login: string, password: string, signal?: AbortSignal): Promise { + private async login(login: string, password: string, signal?: AbortSignal): Promise { throwIfAborted(signal); const response = await fetch(LOGIN_URL, { method: "POST", @@ -332,18 +345,17 @@ export class MegaWebFallback { throw new Error("Mega-Web Login ungültig oder Session blockiert"); } - this.cookie = cookie; - this.cookieSetAt = Date.now(); + return cookie; } - private async generate(link: string, signal?: AbortSignal): Promise<{ directUrl: string; fileName: string } | null> { + private async generate(link: string, cookie: string, signal?: AbortSignal): Promise<{ directUrl: string; fileName: string } | null> { throwIfAborted(signal); const page = await fetch(DEBRID_URL, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", "User-Agent": "Mozilla/5.0", - Cookie: this.cookie, + Cookie: cookie, Referer: DEBRID_REFERER }, body: new URLSearchParams({ @@ -375,7 +387,7 @@ export class MegaWebFallback { headers: { "Content-Type": "application/x-www-form-urlencoded", "User-Agent": "Mozilla/5.0", - Cookie: this.cookie, + Cookie: cookie, Referer: DEBRID_REFERER }, body: new URLSearchParams({ @@ -433,7 +445,7 @@ export class MegaWebFallback { } public dispose(): void { - this.cookie = ""; + this.sessions.clear(); } } diff --git a/tests/debrid.test.ts b/tests/debrid.test.ts index d49599d..4942247 100644 --- a/tests/debrid.test.ts +++ b/tests/debrid.test.ts @@ -1522,6 +1522,50 @@ describe("debrid service", () => { expect(calls).toBe(2); }, 20000); + it("passes each account's OWN credentials to the Mega web unrestrict during rotation", async () => { + // Echter Root-Cause (Support-Bundle): der Web-Pfad nutzte fuer JEDEN rotierten + // Account die Creds des ersten/Legacy-Accounts → "Account 2" lief in Wahrheit mit + // Account-1-Login → der zweite (funktionierende) Account wurde nie verwendet. + // Jetzt muss jeder Account-Versuch SEINE eigenen Creds an den Web-Unrestrict reichen. + 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 = []; + const megaWeb = vi.fn(async (_link: string, _signal: AbortSignal | undefined, account?: { login: string; password: string }) => { + accountsSeen.push(account?.login); + if (account?.login === "user1") { + // Account 1 am Tageslimit. + throw new Error("Mega-Web: Kein Server für diesen Hoster verfügbar. Bitte versuchen Sie es später noch einmal."); + } + // Account 2 (eigene Creds) loest auf. + 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"); + + // Jeder Account wurde mit SEINEM eigenen Login angesprochen (nicht 2x user1). + expect(accountsSeen).toContain("user1"); + expect(accountsSeen).toContain("user2"); + // Und der funktionierende Account 2 loest auf. + expect((result as { sourceAccountId?: string }).sourceAccountId).toBe(getMegaDebridAccountId("user2")); + expect(result.directUrl).toBe("https://mega-web.example/ok.rar"); + }, 20000); + it("respects provider selection and does not append hidden providers", async () => { const settings = { ...defaultSettings(), diff --git a/tests/mega-web-fallback.test.ts b/tests/mega-web-fallback.test.ts index 405a1a1..12e28a2 100644 --- a/tests/mega-web-fallback.test.ts +++ b/tests/mega-web-fallback.test.ts @@ -89,6 +89,38 @@ describe("mega-web-fallback", () => { expect(ajaxCalls).toBe(1); }); + it("logs in with the per-account credentials passed to unrestrict, not the default", async () => { + const loginsUsed: string[] = []; + globalThis.fetch = vi.fn(async (url: string | URL | Request, opts?: { body?: unknown }) => { + const urlStr = String(url); + if (urlStr.includes("form=login")) { + const params = new URLSearchParams(String(opts?.body ?? "")); + loginsUsed.push(params.get("login") || ""); + const headers = new Headers(); + headers.append("set-cookie", "session=goodcookie; path=/"); + return new Response("", { headers, status: 200 }); + } + if (urlStr.includes("page=debrideur")) { + return new Response('
', { status: 200 }); + } + if (urlStr.includes("form=debrid")) { + return new Response(`

Link: https://mega.debrid/l1

d
`, { status: 200 }); + } + if (urlStr.includes("ajax=debrid")) { + return new Response(JSON.stringify({ link: "https://mega.direct/ok" }), { status: 200 }); + } + return new Response("Not found", { status: 404 }); + }) as unknown as typeof fetch; + + // getCredentials liefert den DEFAULT/Legacy-Account ... + const fallback = new MegaWebFallback(() => ({ login: "defaultacc", password: "defpw" })); + // ... aber die Rotation übergibt explizit Account 2 — DESSEN Login MUSS verwendet werden. + const result = await fallback.unrestrict("https://mega.debrid/l1", undefined, { login: "account2", password: "pw2" }); + expect(result?.directUrl).toBe("https://mega.direct/ok"); + expect(loginsUsed).toContain("account2"); + expect(loginsUsed).not.toContain("defaultacc"); + }); + it("throws if login fails to set cookie", async () => { globalThis.fetch = vi.fn(async (url: string | URL | Request) => { const urlStr = String(url);