diff --git a/src/main/debrid.ts b/src/main/debrid.ts index 6138584..c898230 100644 --- a/src/main/debrid.ts +++ b/src/main/debrid.ts @@ -339,6 +339,25 @@ export function resetMegaDebridRuntimeStateForTests(): void { megaDebridInFlight.clear(); } +export function getMegaDebridInFlightCountForMode(mode: "api" | "web"): number { + const suffix = `:${mode}`; + let total = 0; + for (const [key, count] of megaDebridInFlight) { + if (key.endsWith(suffix)) { + total += count; + } + } + return total; +} + +export function primeMegaDebridInFlightForTests(key: string, count: number): void { + if (count <= 0) { + megaDebridInFlight.delete(key); + return; + } + megaDebridInFlight.set(key, count); +} + export function pruneExpiredMegaDebridRuntimeState(now = Date.now()): number { let removed = 0; const grace = 60 * 60 * 1000; diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 81ce33b..d782876 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -52,7 +52,7 @@ function releaseTlsSkip(): void { } import { cleanupCancelledPackageArtifactsAsync, removeDownloadLinkArtifacts, removeSampleArtifacts } from "./cleanup"; import { planDownloadCompletion, reconcileFinalizedSize, validateDownloadedFileCompletion } from "./download-completion"; -import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, DebridService, MegaWebUnrestrictor, RealDebridWebUnrestrictor, checkRapidgatorOnline, fetchAllDebridHostInfo, getAvailableDebridLinkApiKeys, getAvailableMegaDebridAccounts, getMegaDebridAccountCooldownState, pruneExpiredDebridLinkRuntimeState, pruneExpiredMegaDebridRuntimeState } from "./debrid"; +import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, DebridService, MegaWebUnrestrictor, RealDebridWebUnrestrictor, checkRapidgatorOnline, fetchAllDebridHostInfo, getAvailableDebridLinkApiKeys, getAvailableMegaDebridAccounts, getMegaDebridAccountCooldownState, getMegaDebridInFlightCountForMode, pruneExpiredDebridLinkRuntimeState, pruneExpiredMegaDebridRuntimeState } from "./debrid"; import { cleanupArchives, clearExtractResumeState, collectArchiveCleanupTargets, detectArchiveSignature, extractPackageArchives, findArchiveCandidates, hasAnyFilesRecursive, removeEmptyDirectoryTree, resetExtractorCachesForPasswordChange, type ExtractArchiveFailureInfo } from "./extractor"; import { validateFileAgainstManifest } from "./integrity"; import { classifyDiskError } from "./fs-error"; @@ -8212,7 +8212,13 @@ export class DownloadManager extends EventEmitter { const provider = resolveMegaDebridProvider(this.settings, this.getExpectedProviderForItem(item)); const serializedValidatingLimit = this.getSerializedValidatingLimit(provider); if (provider && Number.isFinite(serializedValidatingLimit) && serializedValidatingLimit < Number.MAX_SAFE_INTEGER) { - return this.getProviderValidatingTaskCount(provider, item.id) >= serializedValidatingLimit; + const validating = this.getProviderValidatingTaskCount(provider, item.id); + if (provider === "megadebrid-api") { + const webInFlight = getMegaDebridInFlightCountForMode("web"); + const overlapAllowance = Math.min(serializedValidatingLimit, webInFlight); + return validating >= serializedValidatingLimit + overlapAllowance; + } + return validating >= serializedValidatingLimit; } if (provider !== "alldebrid") { return false; diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index 8b6927a..3999473 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -14,7 +14,7 @@ import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits"; import { getItemLogPath, initItemLogs, shutdownItemLogs } from "../src/main/item-log"; import { initPackageLogs, shutdownPackageLogs } from "../src/main/package-log"; import { createStoragePaths, emptySession } from "../src/main/storage"; -import { primeDebridLinkRuntimeCooldownForTests, resetDebridLinkRuntimeStateForTests, primeMegaDebridRuntimeCooldownForTests, resetMegaDebridRuntimeStateForTests } from "../src/main/debrid"; +import { primeDebridLinkRuntimeCooldownForTests, resetDebridLinkRuntimeStateForTests, primeMegaDebridRuntimeCooldownForTests, resetMegaDebridRuntimeStateForTests, primeMegaDebridInFlightForTests } from "../src/main/debrid"; import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts"; import { getRenameLogPath, initRenameLog, shutdownRenameLog } from "../src/main/rename-log"; import { UnrestrictedLink } from "../src/main/realdebrid"; @@ -12362,3 +12362,82 @@ describe("start conflict guard + selective resume", () => { manager.stop(); }); }); + +describe("mega-debrid api/web resolution overlap gate", () => { + function megaApiSettings(root: string): any { + return { + ...defaultSettings(), + megaLogin: "u", megaPassword: "p", megaCredentials: "u:p", + megaDebridApiEnabled: true, megaDebridWebEnabled: true, megaDebridPreferApi: true, + outputDir: path.join(root, "downloads"), extractDir: path.join(root, "extract") + }; + } + + function megaItem(id: string, status: string): any { + return { + id, packageId: "pkg", url: `https://rapidgator.net/file/${id}`, provider: "megadebrid-api", + status, retries: 0, speedBps: 0, downloadedBytes: 0, totalBytes: null, progressPercent: 0, + fileName: `${id}.rar`, targetPath: "", resumable: true, attempts: 0, lastError: "", fullStatus: "", + createdAt: Date.now(), updatedAt: Date.now() + }; + } + + function addValidating(manager: DownloadManager, session: any, ids: string[]): void { + for (const id of ids) { + session.items[id] = megaItem(id, "validating"); + (manager as any).activeTasks.set(id, { + itemId: id, packageId: "pkg", abortController: new AbortController(), abortReason: "none", + resumable: true, nonResumableCounted: false, blockedOnDiskWrite: false, blockedOnDiskSince: 0 + }); + } + } + + function buildManager(root: string, session: any): DownloadManager { + return new DownloadManager(megaApiSettings(root), session, createStoragePaths(path.join(root, "state"))); + } + + it("lets the first mega resolve start when nothing is in flight", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-overlap-0-")); + tempDirs.push(root); + const session = emptySession(); + const candidate = megaItem("cand", "queued"); + session.items["cand"] = candidate; + const manager = buildManager(root, session); + expect((manager as any).shouldDelayStartForItem(candidate)).toBe(false); + }); + + it("serializes a second API resolve while the first is still in its API phase (no concurrent API)", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-overlap-1-")); + tempDirs.push(root); + const session = emptySession(); + const candidate = megaItem("cand", "queued"); + session.items["cand"] = candidate; + const manager = buildManager(root, session); + addValidating(manager, session, ["a"]); + expect((manager as any).shouldDelayStartForItem(candidate)).toBe(true); + }); + + it("allows one API resolve to overlap once the first has moved to its web phase", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-overlap-2-")); + tempDirs.push(root); + const session = emptySession(); + const candidate = megaItem("cand", "queued"); + session.items["cand"] = candidate; + const manager = buildManager(root, session); + addValidating(manager, session, ["a"]); + primeMegaDebridInFlightForTests("acc:web", 1); + expect((manager as any).shouldDelayStartForItem(candidate)).toBe(false); + }); + + it("caps the overlap at one API plus one web (no third concurrent resolve)", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-overlap-3-")); + tempDirs.push(root); + const session = emptySession(); + const candidate = megaItem("cand", "queued"); + session.items["cand"] = candidate; + const manager = buildManager(root, session); + addValidating(manager, session, ["a", "b"]); + primeMegaDebridInFlightForTests("acc:web", 1); + expect((manager as any).shouldDelayStartForItem(candidate)).toBe(true); + }); +});