diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 37f40f2..025c1d8 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -108,7 +108,7 @@ const MINI_DOWNLOAD_RETRY_THRESHOLD_BYTES = 5 * 1024; const ALLDEBRID_HOST_INFO_TTL_MS = 60000; -const ALLDEBRID_START_STAGGER_MS = 2500; +const ALLDEBRID_START_STAGGER_MS = 3000; const ARCHIVE_SETTLE_MIN_DELAY_MS = 1500; @@ -5541,7 +5541,7 @@ export class DownloadManager extends EventEmitter { return provider; } - private countVisiblePacedStarts(paceKey: string, now: number, excludeItemId?: string): number { + private countFuturePacedStarts(paceKey: string, now: number, excludeItemId?: string): number { let count = 0; for (const [itemId, reservedAt] of this.pacedStartReservationByItem.entries()) { if (excludeItemId && itemId === excludeItemId) { @@ -5565,11 +5565,6 @@ export class DownloadManager extends EventEmitter { return count; } - private getVisiblePacedStartBudget(): number { - const maxParallel = Math.max(1, Number(this.settings.maxParallel) || 1); - return this.activeTasks.size < maxParallel ? 1 : 0; - } - private delayPacedStartForItem(item: DownloadItem, now: number): boolean { const paceKey = this.getPacedStartKeyForItem(item); if (!paceKey) { @@ -5592,15 +5587,17 @@ export class DownloadManager extends EventEmitter { return true; } - const nextAllowedAt = this.providerStartReservations.get(paceKey) || 0; - if (nextAllowedAt <= now) { - this.pacedStartReservationByItem.delete(item.id); - return false; - } - - const visibleBudget = this.getVisiblePacedStartBudget(); - const visibleReservations = this.countVisiblePacedStarts(paceKey, now, item.id); - if (visibleBudget <= 0 || visibleReservations >= visibleBudget) { + const failureKey = this.getProviderFailureKeyForItem(item, "alldebrid"); + const startLimit = this.getAllDebridStartLimit(extractHosterKey(item.url)); + const activeProviderTasks = this.activeTasks.size; + const activeHosterTasks = this.getActiveTaskCountForFailureKey(failureKey); + const futureReservations = this.countFuturePacedStarts(paceKey, now, item.id); + const remainingGlobalSlots = Math.max(0, Math.max(1, Number(this.settings.maxParallel) || 1) - activeProviderTasks - futureReservations); + const remainingHosterSlots = Number.isFinite(startLimit) + ? Math.max(0, startLimit - activeHosterTasks - futureReservations) + : Number.MAX_SAFE_INTEGER; + const availableReservationSlots = Math.min(remainingGlobalSlots, remainingHosterSlots); + if (availableReservationSlots <= 0) { this.pacedStartReservationByItem.delete(item.id); if ((item.fullStatus || "").startsWith("AllDebrid Start in ")) { item.fullStatus = "Wartet"; @@ -5609,14 +5606,13 @@ export class DownloadManager extends EventEmitter { return true; } - const scheduledAt = Math.max(existingReadyAt, nextAllowedAt); + const scheduledAt = Math.max(existingReadyAt, now + ALLDEBRID_START_STAGGER_MS); if (scheduledAt <= now) { this.pacedStartReservationByItem.delete(item.id); return false; } this.retryAfterByItem.set(item.id, scheduledAt); this.pacedStartReservationByItem.set(item.id, scheduledAt); - this.providerStartReservations.set(paceKey, scheduledAt + ALLDEBRID_START_STAGGER_MS); item.status = "queued"; item.speedBps = 0; item.fullStatus = `AllDebrid Start in ${Math.max(1, Math.ceil((scheduledAt - now) / 1000))}s`; @@ -5633,17 +5629,10 @@ export class DownloadManager extends EventEmitter { const reservedAt = this.pacedStartReservationByItem.get(item.id) || 0; if (reservedAt > 0) { this.pacedStartReservationByItem.delete(item.id); - return; } - - if (this.getProviderActiveTaskCount("alldebrid") <= 0) { + if (this.countFuturePacedStarts(paceKey, now) <= 0) { this.providerStartReservations.delete(paceKey); - return; } - - const existingReservation = this.providerStartReservations.get(paceKey) || 0; - const baseReservation = Math.max(now, existingReservation); - this.providerStartReservations.set(paceKey, baseReservation + ALLDEBRID_START_STAGGER_MS); } private getConfiguredAllDebridStartLimit(): number { diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index 08fd737..400ae9c 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -4838,7 +4838,7 @@ describe("download manager", () => { } }, 20000); - it("shows only the next AllDebrid item with a visible countdown", async () => { + 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); const chunk = Buffer.alloc(256 * 1024, 9); @@ -4917,18 +4917,18 @@ describe("download manager", () => { await waitFor(() => { const items = Object.values(manager.getSnapshot().session.items); - return items.some((item) => item.status === "downloading"); + const countdownItems = items.filter((item) => /^AllDebrid Start in \d+s$/.test(item.fullStatus || "")); + return countdownItems.length === 5; }, 10000); - await new Promise((resolve) => setTimeout(resolve, 500)); const items = Object.values(manager.getSnapshot().session.items); const activeCount = items.filter((item) => item.status === "downloading" || item.status === "validating").length; const countdownItems = items.filter((item) => /^AllDebrid Start in \d+s$/.test(item.fullStatus || "")); - const plainQueuedItems = items.filter((item) => (item.status === "queued" || item.status === "reconnect_wait") && item.fullStatus === "Wartet"); + const uniqueCountdowns = new Set(countdownItems.map((item) => item.fullStatus || "")); - expect(activeCount).toBeGreaterThan(0); - expect(countdownItems.length).toBeLessThanOrEqual(1); - expect(plainQueuedItems.length).toBeGreaterThan(0); + expect(activeCount).toBe(0); + expect(countdownItems.length).toBe(5); + expect(uniqueCountdowns.size).toBe(1); manager.stop(); await waitFor(() => !manager.getSnapshot().session.running, 15000); @@ -4938,7 +4938,7 @@ describe("download manager", () => { } }, 20000); - it("staggeres AllDebrid starts by 2.5 seconds per active download", async () => { + it("starts immediately free AllDebrid slots after the same 3 second delay", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); tempDirs.push(root); const binary = Buffer.alloc(512 * 1024, 5); @@ -5024,20 +5024,17 @@ describe("download manager", () => { const managerInternals = manager as unknown as { retryAfterByItem: Map; - providerStartReservations: Map; }; - await waitFor(() => managerInternals.retryAfterByItem.size >= 1 && managerInternals.providerStartReservations.size >= 1, 5000); + await waitFor(() => managerInternals.retryAfterByItem.size >= 3, 5000); const now = Date.now(); const readyTimes = [...managerInternals.retryAfterByItem.values()].sort((a, b) => a - b); - expect(readyTimes.length).toBeGreaterThanOrEqual(1); + expect(readyTimes.length).toBe(3); const firstDelay = readyTimes[0] - now; - const nextReservation = managerInternals.providerStartReservations.get("alldebrid") || 0; - const secondDelay = nextReservation - now; - expect(firstDelay).toBeGreaterThan(1500); - expect(firstDelay).toBeLessThan(6500); - expect(secondDelay).toBeGreaterThan(firstDelay + 1200); - expect(secondDelay).toBeLessThan(firstDelay + 4500); + const lastDelay = readyTimes[readyTimes.length - 1] - now; + expect(firstDelay).toBeGreaterThan(2000); + expect(firstDelay).toBeLessThan(4500); + expect(lastDelay - firstDelay).toBeLessThan(500); manager.stop(); await waitFor(() => !manager.getSnapshot().session.running, 15000); @@ -5047,6 +5044,106 @@ describe("download manager", () => { } }, 20000); + it("tops up newly freed AllDebrid slots with a fresh 3 second countdown", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const shortBinary = Buffer.alloc(64 * 1024, 7); + const longBinary = Buffer.alloc(512 * 1024, 8); + + const server = http.createServer((req, res) => { + const route = req.url || ""; + if (route === "/ad-1") { + setTimeout(() => { + res.statusCode = 200; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(shortBinary.length)); + res.end(shortBinary); + }, 150); + return; + } + if (route === "/ad-2" || route === "/ad-3" || route === "/ad-4") { + setTimeout(() => { + res.statusCode = 200; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(longBinary.length)); + res.end(longBinary); + }, 6000); + return; + } + res.statusCode = 404; + res.end("not-found"); + }); + + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("server address unavailable"); + } + + const links = [ + "https://rapidgator.net/file/ad-topup-1", + "https://rapidgator.net/file/ad-topup-2", + "https://rapidgator.net/file/ad-topup-3", + "https://rapidgator.net/file/ad-topup-4" + ]; + + try { + const manager = new DownloadManager( + { + ...defaultSettings(), + allDebridToken: "ad-token", + allDebridUseWebLogin: true, + providerOrder: [], + providerPrimary: "alldebrid", + providerSecondary: "none", + providerTertiary: "none", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false, + autoReconnect: false, + enableIntegrityCheck: false, + maxParallel: 3 + }, + emptySession(), + createStoragePaths(path.join(root, "state")), + { + allDebridWebUnrestrict: async (link) => { + const slot = links.indexOf(link) + 1; + return { + fileName: `ad-topup-${slot}.bin`, + directUrl: `http://127.0.0.1:${address.port}/ad-${slot}`, + fileSize: slot === 1 ? shortBinary.length : longBinary.length, + retriesUsed: 0 + }; + } + } + ); + + manager.addPackages([{ name: "ad-topup", links }]); + await manager.start(); + + await waitFor(() => { + const items = Object.values(manager.getSnapshot().session.items); + return items.filter((item) => item.status === "downloading").length === 3; + }, 12000); + + await waitFor(() => { + const items = Object.values(manager.getSnapshot().session.items); + const completedCount = items.filter((item) => item.status === "completed").length; + const countdownItems = items.filter((item) => /^AllDebrid Start in [123]s$/.test(item.fullStatus || "")); + return completedCount >= 1 && countdownItems.length === 1; + }, 12000); + + manager.stop(); + await waitFor(() => !manager.getSnapshot().session.running, 15000); + } finally { + server.close(); + await once(server, "close"); + } + }, 25000); + it("creates extract directory only at extraction and marks items as Entpackt", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); tempDirs.push(root);