diff --git a/src/main/debrid.ts b/src/main/debrid.ts index 76a28aa..d07be98 100644 --- a/src/main/debrid.ts +++ b/src/main/debrid.ts @@ -296,6 +296,8 @@ function getMegaDebridAbortMinRunMs(): number { const megaDebridEmptyResponseStreaks = new Map(); export const MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART = 3; +let megaDebridRotationCursor = 0; + export function recordMegaDebridEmptyResponseStreak(accountId: string): number { const streak = (megaDebridEmptyResponseStreaks.get(accountId) || 0) + 1; megaDebridEmptyResponseStreaks.set(accountId, streak); @@ -309,6 +311,7 @@ export function clearMegaDebridEmptyResponseStreak(accountId: string): void { export function resetMegaDebridRuntimeStateForTests(): void { megaDebridAccountCooldowns.clear(); megaDebridEmptyResponseStreaks.clear(); + megaDebridRotationCursor = 0; } export function pruneExpiredMegaDebridRuntimeState(now = Date.now()): number { @@ -1905,7 +1908,15 @@ class MegaDebridClient { const providerName = `Mega-Debrid ${mode === "api" ? "API" : "Web"}`; const linkShort = String(link || "").slice(0, 80); - for (let idx = 0; idx < accounts.length; idx += 1) { + // Round-Robin statt First-Wins: ohne den Cursor startete jede Link-Aufloesung + // bei Account 1 und endete beim ersten brauchbaren — ein spaeterer Account kam + // nur dran, wenn ALLE davor am Limit/Cooldown waren. Live-Folge: Account 1-2 + // liefen staendig ins Tageslimit, Account 3 trug den Rest allein und Account 4 + // wurde nie auch nur angeschaut. Der Cursor laesst jede Aufloesung beim Account + // NACH dem zuletzt getesteten starten, alle Skip-/Cooldown-Checks bleiben. + const startOffset = ((megaDebridRotationCursor % accounts.length) + accounts.length) % accounts.length; + for (let step = 0; step < accounts.length; step += 1) { + const idx = (startOffset + step) % accounts.length; const account = accounts[idx]; const accountLabel = ` (${account.label}/${totalAccounts}, ${account.maskedLogin})`; const rotationLabel = `${account.label}/${totalAccounts} (${account.maskedLogin})`; @@ -1949,6 +1960,7 @@ class MegaDebridClient { const testStartedAt = Date.now(); usableAccountSeen = true; + megaDebridRotationCursor = idx + 1; try { const client = new MegaDebridClient(account.login, account.password, mode, allowApiFallback, megaWebUnrestrict); const result = await client.unrestrictLink(link, signal); @@ -2027,8 +2039,8 @@ class MegaDebridClient { ? `, Cooldown ${Math.ceil(failure.cooldownMs / 1000)}s` : ""; let nextLabel = "ENDE"; - for (let nextIdx = idx + 1; nextIdx < accounts.length; nextIdx += 1) { - const nextAcc = accounts[nextIdx]; + for (let nextStep = step + 1; nextStep < accounts.length; nextStep += 1) { + const nextAcc = accounts[(startOffset + nextStep) % accounts.length]; if (!isMegaDebridAccountDisabled(settings, nextAcc.id) && !isMegaDebridAccountDailyLimitReached(settings, nextAcc.id) && !getMegaDebridAccountCooldownState(`${nextAcc.id}:${mode}`)) { nextLabel = `${nextAcc.label}/${totalAccounts} (${nextAcc.maskedLogin})`; break; diff --git a/src/main/support-bundle.ts b/src/main/support-bundle.ts index aa56976..29c5b79 100644 --- a/src/main/support-bundle.ts +++ b/src/main/support-bundle.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import AdmZip from "adm-zip"; import { APP_VERSION } from "./constants"; +import { getAccountRotationLogPath } from "./account-rotation-log"; import { getAuditLogPath } from "./audit-log"; import { getDebugSetupCheck } from "./debug-setup"; import { getLogFilePath } from "./logger"; @@ -188,6 +189,8 @@ export function buildSupportBundle(manager: DownloadManager, baseDir: string, op addFileIfExists(zip, getSessionLogPath(), "logs/session.log"); addFileIfExists(zip, getTraceLogPath(), "logs/trace.log"); addFileIfExists(zip, getTraceLogPath() ? `${getTraceLogPath()}.old` : null, "logs/trace.log.old"); + addFileIfExists(zip, getAccountRotationLogPath(), "logs/account-rotation.log"); + addFileIfExists(zip, getAccountRotationLogPath() ? `${getAccountRotationLogPath()}.old` : null, "logs/account-rotation.log.old"); const SUPPORT_BUNDLE_LOG_WINDOW_MS = 8 * 60 * 60 * 1000; addDirectoryIfExists(zip, path.join(baseDir, "session-logs"), "logs/session-logs"); diff --git a/tests/debrid.test.ts b/tests/debrid.test.ts index 30a59bb..8c2cda6 100644 --- a/tests/debrid.test.ts +++ b/tests/debrid.test.ts @@ -1379,6 +1379,90 @@ describe("debrid service", () => { } }); + it("verteilt Links per Round-Robin ueber ALLE Mega-Debrid-Accounts statt immer beim ersten zu starten", 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/rr-${i}`); + usedIds.push((result as { sourceAccountId?: string }).sourceAccountId); + } + + expect(usedIds).toEqual([ + getMegaDebridAccountId("user1"), + getMegaDebridAccountId("user2"), + getMegaDebridAccountId("user3"), + getMegaDebridAccountId("user4"), + getMegaDebridAccountId("user1") + ]); + }, 30000); + + it("Round-Robin respektiert Cooldowns: gesperrter Account wird in der Reihe uebersprungen", 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("user2")}: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/rr-skip-${i}`); + usedIds.push((result as { sourceAccountId?: string }).sourceAccountId); + } + + expect(usedIds).toEqual([ + getMegaDebridAccountId("user1"), + getMegaDebridAccountId("user3"), + getMegaDebridAccountId("user1") + ]); + }, 30000); + it("rotates to the next Mega-Debrid account when one hits its daily limit (error-based)", async () => { const settings = { ...defaultSettings(),