Fix: Mega-Debrid-Rotation verteilt jetzt ueber ALLE Accounts (Round-Robin statt First-Wins)

Symptom (User): 4 Accounts hinterlegt, aber die Rotation nutzte nur die
Accounts 1-3 — der 4. wurde nie angefasst, ausser man deaktivierte die
anderen manuell.

Ursache (im Live-Log des Servers belegt, rd_downloader.log): Die
Account-Schleife in unrestrictWithAccounts startete bei JEDER
Link-Aufloesung bei Account 1 und nahm den ersten brauchbaren
(First-Usable-Wins-Failover). Ein spaeterer Account kam nur dran, wenn
ALLE davor am Tageslimit/Cooldown/deaktiviert waren. Live-Verteilung:
Account 1 = 749 OK + 1314x "Tageslimit — bis Neustart gesperrt",
Account 2 = 1733 OK + 448x Tageslimit-Sperre, Account 3 = 1603 OK ohne
ein einziges Limit — die Kette endete deshalb IMMER spaetestens bei
Account 3, und Account 4 tauchte im gesamten Log mit keinem einzigen
Event auf (nicht getestet, nicht geskippt). Accounts 1-2 liefen also
staendig ins Tageslimit, waehrend die Kapazitaet von Account 4 jeden
Tag verfiel.

Fix: Round-Robin-Cursor in unrestrictWithAccounts. Jede Aufloesung
startet beim Account NACH dem zuletzt getesteten (Modulo ueber die
Liste), alle bestehenden Checks (deaktiviert, lokales Tageslimit,
Cooldown, Park-bis-Neustart) bleiben unveraendert und werden in der
neuen Reihenfolge durchlaufen. Damit verteilen sich Links gleichmaessig
ueber alle Accounts, kein Account wird mehr stumpf bis ans Limit
gehaemmert, und Account 4 nimmt automatisch teil. Der Cursor ist
Modul-State (bei App-Neustart wieder Account 1), die "naechster
Account"-Vorschau im Fehlerpfad folgt der Modulo-Reihenfolge, und
resetMegaDebridRuntimeStateForTests setzt ihn fuer deterministische
Tests zurueck. Die Debrid-Link-Key-Rotation (separate Schleife) bleibt
unveraendert First-Wins — dort ist kein Account konfiguriert; bei
Bedarf gleiches Muster nachziehen.

Nebenfix: Das Support-Bundle exportiert jetzt auch
logs/account-rotation.log (+ .old) — genau dieses Log fehlte im Bundle
und haette die Diagnose sofort geliefert (die Datei kollidiert
namentlich mit dem gleichnamigen Log des Multi-Hoster-Uploaders, daher
war zunaechst das falsche Log in der Analyse).

Tests: 2 neue Regressionstests (5 Links auf 4 Accounts -> 1,2,3,4,1;
Cooldown-Account wird in der Reihe uebersprungen), Suite 806 gruen,
tsc=6 Baseline, self-check + build ok.
This commit is contained in:
Sucukdeluxe 2026-06-11 14:16:17 +02:00
parent 18f29a6a2c
commit cf5c498ee9
3 changed files with 102 additions and 3 deletions

View File

@ -296,6 +296,8 @@ function getMegaDebridAbortMinRunMs(): number {
const megaDebridEmptyResponseStreaks = new Map<string, number>();
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;

View File

@ -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");

View File

@ -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(),