diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 264ef66..94a4196 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -1215,6 +1215,7 @@ export class DownloadManager extends EventEmitter { private allDebridHostInfoCache = new Map(); private providerStartReservations = new Map(); + private pacedStartReservationByItem = new Map(); private lastStaleResetAt = 0; @@ -1715,6 +1716,7 @@ export class DownloadManager extends EventEmitter { this.historyRecordedPackages.clear(); this.retryAfterByItem.clear(); this.providerStartReservations.clear(); + this.pacedStartReservationByItem.clear(); this.retryStateByItem.clear(); this.reservedTargetPaths.clear(); this.claimedTargetPathByItem.clear(); @@ -3397,6 +3399,7 @@ export class DownloadManager extends EventEmitter { this.runCompletedPackages.clear(); this.retryAfterByItem.clear(); this.providerStartReservations.clear(); + this.pacedStartReservationByItem.clear(); this.retryStateByItem.clear(); this.itemContributedBytes.clear(); this.reservedTargetPaths.clear(); @@ -3505,6 +3508,7 @@ export class DownloadManager extends EventEmitter { this.runCompletedPackages.clear(); this.retryAfterByItem.clear(); this.providerStartReservations.clear(); + this.pacedStartReservationByItem.clear(); this.retryStateByItem.clear(); this.itemContributedBytes.clear(); this.reservedTargetPaths.clear(); @@ -3617,6 +3621,7 @@ export class DownloadManager extends EventEmitter { this.runCompletedPackages.clear(); this.retryAfterByItem.clear(); this.providerStartReservations.clear(); + this.pacedStartReservationByItem.clear(); this.retryStateByItem.clear(); this.reservedTargetPaths.clear(); this.claimedTargetPathByItem.clear(); @@ -3645,6 +3650,8 @@ export class DownloadManager extends EventEmitter { this.runOutcomes.clear(); this.runCompletedPackages.clear(); this.retryAfterByItem.clear(); + this.providerStartReservations.clear(); + this.pacedStartReservationByItem.clear(); this.retryStateByItem.clear(); this.itemContributedBytes.clear(); this.reservedTargetPaths.clear(); @@ -3692,6 +3699,7 @@ export class DownloadManager extends EventEmitter { this.session.reconnectReason = ""; this.retryAfterByItem.clear(); this.providerStartReservations.clear(); + this.pacedStartReservationByItem.clear(); this.retryStateByItem.clear(); this.lastGlobalProgressBytes = this.session.totalDownloadedBytes; this.lastGlobalProgressAt = nowMs(); @@ -3804,6 +3812,7 @@ export class DownloadManager extends EventEmitter { this.runCompletedPackages.clear(); this.retryAfterByItem.clear(); this.providerStartReservations.clear(); + this.pacedStartReservationByItem.clear(); this.nonResumableActive = 0; this.session.summaryText = ""; // Persist synchronously on shutdown to guarantee data is written before process exits @@ -3842,6 +3851,7 @@ export class DownloadManager extends EventEmitter { if (wasPaused && !this.session.paused) { this.retryAfterByItem.clear(); this.providerStartReservations.clear(); + this.pacedStartReservationByItem.clear(); // Reset provider circuit breaker so items don't sit in cooldown after unpause this.providerFailures.clear(); @@ -5537,17 +5547,36 @@ export class DownloadManager extends EventEmitter { return false; } + const existingReadyAt = this.retryAfterByItem.get(item.id) || 0; + const existingPacedAt = this.pacedStartReservationByItem.get(item.id) || 0; + if (existingPacedAt > 0 && existingPacedAt <= now) { + this.pacedStartReservationByItem.delete(item.id); + return false; + } + if (existingPacedAt > now) { + const scheduledAt = Math.max(existingReadyAt, existingPacedAt); + this.retryAfterByItem.set(item.id, scheduledAt); + item.status = "queued"; + item.speedBps = 0; + item.fullStatus = `AllDebrid Start in ${Math.max(1, Math.ceil((scheduledAt - now) / 1000))}s`; + item.updatedAt = now; + return true; + } + const nextAllowedAt = this.providerStartReservations.get(paceKey) || 0; if (nextAllowedAt <= now) { + this.pacedStartReservationByItem.delete(item.id); return false; } - const existingReadyAt = this.retryAfterByItem.get(item.id) || 0; const scheduledAt = Math.max(existingReadyAt, nextAllowedAt); 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`; @@ -5561,6 +5590,12 @@ export class DownloadManager extends EventEmitter { return; } + const reservedAt = this.pacedStartReservationByItem.get(item.id) || 0; + if (reservedAt > 0) { + this.pacedStartReservationByItem.delete(item.id); + return; + } + if (this.getProviderActiveTaskCount("alldebrid") <= 0) { this.providerStartReservations.delete(paceKey); return; @@ -9438,6 +9473,7 @@ export class DownloadManager extends EventEmitter { delete this.session.items[itemId]; this.itemCount = Math.max(0, this.itemCount - 1); this.retryAfterByItem.delete(itemId); + this.pacedStartReservationByItem.delete(itemId); this.retryStateByItem.delete(itemId); if (pkg.itemIds.length === 0) { this.removePackageFromSession(packageId, []); @@ -9502,6 +9538,7 @@ export class DownloadManager extends EventEmitter { } this.retryAfterByItem.clear(); this.providerStartReservations.clear(); + this.pacedStartReservationByItem.clear(); this.retryStateByItem.clear(); this.reservedTargetPaths.clear(); this.claimedTargetPathByItem.clear(); diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index 21275f0..1b61edc 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -4709,6 +4709,7 @@ describe("download manager", () => { { ...defaultSettings(), allDebridToken: "ad-token", + providerOrder: [], providerPrimary: "alldebrid", providerSecondary: "none", providerTertiary: "none", @@ -4854,7 +4855,7 @@ describe("download manager", () => { res.setHeader("Accept-Ranges", "bytes"); res.setHeader("Content-Length", String(binary.length)); res.end(binary); - }, 1800); + }, 6500); }); server.listen(0, "127.0.0.1"); @@ -4903,6 +4904,7 @@ describe("download manager", () => { { ...defaultSettings(), allDebridToken: "ad-token", + providerOrder: [], providerPrimary: "alldebrid", providerSecondary: "none", providerTertiary: "none", @@ -4930,9 +4932,8 @@ describe("download manager", () => { const secondDelay = readyTimes[1] - now; expect(firstDelay).toBeGreaterThan(1500); expect(firstDelay).toBeLessThan(6500); - expect(secondDelay).toBeGreaterThan(1500); - expect(secondDelay).toBeLessThan(6500); - expect(Math.abs(secondDelay - firstDelay)).toBeLessThan(3500); + expect(secondDelay).toBeGreaterThan(firstDelay + 1200); + expect(secondDelay).toBeLessThan(firstDelay + 4500); manager.stop(); await waitFor(() => !manager.getSnapshot().session.running, 15000);