diff --git a/CLAUDE.md b/CLAUDE.md index e9ff834ad..f5d9278cd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -938,10 +938,11 @@ engine` (restart required) or the provider-neutral `SeasonDownloadCoordinator`. It reserves per-episode pending identities synchronously, submits season candidates sequentially and best-effort through the existing `DOWNLOADS_START` path, performs one final - authoritative refresh after accepted submissions, and reports added, - skipped, and failed counts. Xtream and Stalker adapters remain responsible - for provider URLs, headers, and metadata; the backend still runs one active - transfer with a FIFO queue. `DOWNLOADS_START` remains the sole start IPC; its + authoritative refresh after added or stable duplicate submissions, and + reports added, skipped, and failed counts. Xtream and Stalker adapters remain + responsible for provider URLs, headers, and metadata; the backend still runs + one active transfer with a FIFO queue. `DOWNLOADS_START` remains the sole + start IPC; its stable `reason: 'already-in-progress'` and `reason: 'already-downloaded'` results are counted as skipped, and no batch IPC is introduced. The latter comes from an asynchronous main-process filesystem recheck before a diff --git a/docs/architecture/download-manager.md b/docs/architecture/download-manager.md index 94a3ca3ee..2bcb679d5 100644 --- a/docs/architecture/download-manager.md +++ b/docs/architecture/download-manager.md @@ -70,11 +70,12 @@ variants, contextual buttons, and theme-aware styling. reservations and submits an individual episode or selected-season snapshot through `DownloadsService.startDownload()`. Season batches are sequential and best-effort: one candidate failing does not stop later candidates. After - accepted submissions, one authoritative list refresh closes the pending-to- - queued handoff, and the coordinator returns `added`, `skipped`, and `failed` - counts. Xtream and Stalker adapters own provider URL, request header, and - metadata preparation; the coordinator owns only provider-neutral - orchestration. Both providers use normalized `episode.id` as the canonical + added or stable duplicate submissions, one authoritative list refresh closes + the pending-to-queued/downloaded handoff, and the coordinator returns + `added`, `skipped`, and `failed` counts. Xtream and Stalker adapters own + provider URL, request header, and metadata preparation; the coordinator owns + only provider-neutral orchestration. Both providers use normalized + `episode.id` as the canonical episode `xtreamId`; Stalker `originalCmd` and `originalId` participate only in URL resolution. The exact `(playlistId, contentType, xtreamId)` identity is authoritative. diff --git a/libs/portal/shared/data-access/src/lib/downloads/season-download-coordinator.service.spec.ts b/libs/portal/shared/data-access/src/lib/downloads/season-download-coordinator.service.spec.ts index 90dce914a..5c115c260 100644 --- a/libs/portal/shared/data-access/src/lib/downloads/season-download-coordinator.service.spec.ts +++ b/libs/portal/shared/data-access/src/lib/downloads/season-download-coordinator.service.spec.ts @@ -183,7 +183,44 @@ describe('SeasonDownloadCoordinator', () => { expect(restored.prepare).toHaveBeenCalledTimes(1); expect(downloadsService.startDownload).toHaveBeenCalledTimes(1); - expect(downloadsService.loadDownloads).not.toHaveBeenCalled(); + expect(downloadsService.loadDownloads).toHaveBeenCalledTimes(1); + }); + + it('refreshes once after a season contains only submitted stable skips', async () => { + const first = candidate(FIRST_IDENTITY); + const second = candidate(identity(102, 2)); + const refresh = deferred(); + const refreshStarted = deferred(); + downloadsService.startDownload + .mockResolvedValueOnce({ + success: false, + reason: 'already-downloaded' as never, + }) + .mockResolvedValueOnce({ + success: false, + reason: 'already-in-progress', + }); + downloadsService.loadDownloads.mockImplementation(() => { + refreshStarted.resolve(undefined); + return refresh.promise; + }); + + const submission = coordinator.enqueueSeason([first, second]); + await refreshStarted.promise; + + expect(downloadsService.startDownload).toHaveBeenCalledTimes(2); + expect(downloadsService.loadDownloads).toHaveBeenCalledTimes(1); + expect(coordinator.isPending(first.identity)).toBe(true); + expect(coordinator.isPending(second.identity)).toBe(true); + + refresh.resolve(undefined); + await expect(submission).resolves.toEqual({ + added: 0, + skipped: 2, + failed: 0, + }); + expect(coordinator.isPending(first.identity)).toBe(false); + expect(coordinator.isPending(second.identity)).toBe(false); }); it('continues after preparation rejects and refreshes once after successes', async () => { diff --git a/libs/portal/shared/data-access/src/lib/downloads/season-download-coordinator.service.ts b/libs/portal/shared/data-access/src/lib/downloads/season-download-coordinator.service.ts index 132be844e..b7c60f4eb 100644 --- a/libs/portal/shared/data-access/src/lib/downloads/season-download-coordinator.service.ts +++ b/libs/portal/shared/data-access/src/lib/downloads/season-download-coordinator.service.ts @@ -57,7 +57,7 @@ export class SeasonDownloadCoordinator { } const submission = await this.submit(candidate); - if (submission !== EPISODE_DOWNLOAD_SUBMISSIONS.Added) { + if (submission === EPISODE_DOWNLOAD_SUBMISSIONS.Failed) { this.release(candidate.identity); return submission; } @@ -84,22 +84,22 @@ export class SeasonDownloadCoordinator { reserved.push(candidate); } - const accepted: EpisodeDownloadIdentity[] = []; + const refreshPending: EpisodeDownloadIdentity[] = []; for (const candidate of reserved) { const submission = await this.submit(candidate); result[submission] += 1; - if (submission === EPISODE_DOWNLOAD_SUBMISSIONS.Added) { - accepted.push(candidate.identity); + if (submission !== EPISODE_DOWNLOAD_SUBMISSIONS.Failed) { + refreshPending.push(candidate.identity); } else { this.release(candidate.identity); } } - if (accepted.length > 0) { + if (refreshPending.length > 0) { try { await this.downloadsService.loadDownloads(); } finally { - this.releaseAll(accepted); + this.releaseAll(refreshPending); } }