From ff9a422b824de2a7ac366ff0b2a72564de2e3bea Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 2 Aug 2026 23:06:02 +0200 Subject: [PATCH] fix(downloads): await authoritative list refresh --- CLAUDE.md | 9 +++-- docs/architecture/download-manager.md | 14 +++++--- .../src/lib/download-list-load-state.ts | 26 +++++++++++++- .../src/lib/downloads.service.spec.ts | 34 +++++++++++++++++++ libs/services/src/lib/downloads.service.ts | 7 ++-- 5 files changed, 78 insertions(+), 12 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 956af1aad..4010fe167 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -943,8 +943,10 @@ engine` (restart required) or 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. A reserved completed-missing match triggers one authoritative - preflight refresh before provider preparation, so a restored Stalker file can - become a stable skip without a portal request. The IPC's stable + preflight refresh before provider preparation. A request superseded by a + download-update refresh waits for the winning request to settle, so a + restored Stalker file can become a stable skip without a portal request. The + IPC's 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 @@ -968,7 +970,8 @@ engine` (restart required) or successful snapshot remains authoritative while a later background refresh is in flight; a latest refresh failure leaves loading/empty-state resolution intact but disables starts until another - snapshot succeeds. + snapshot succeeds. Superseded download-list callers do not resolve on their + discarded responses; they join the next request that settles as latest. - Episode ownership uses normalized `episode.id` as the canonical `xtreamId` for both providers; Stalker playback identifiers only resolve the URL. Exact `(playlistId, contentType, xtreamId)` matches are authoritative, while diff --git a/docs/architecture/download-manager.md b/docs/architecture/download-manager.md index 9bc2e7c05..b8ccaf930 100644 --- a/docs/architecture/download-manager.md +++ b/docs/architecture/download-manager.md @@ -55,8 +55,12 @@ variants, contextual buttons, and theme-aware styling. `loadDownloads()` therefore always invokes the Electron list IPC without its legacy optional playlist scope. Route scope, category, and search must never replace or narrow that signal. Overlapping loads are request-ordered so a - late response cannot replace a newer snapshot. `hasLoadedDownloads` records - that the latest attempt completed, including an error, while + late response cannot replace a newer snapshot. A load promise whose response + is superseded remains pending until that request or a newer one settles as + latest; callers therefore observe either a committed successor snapshot or + its authoritative failure instead of proceeding after a discarded response. + `hasLoadedDownloads` records that the latest attempt completed, including an + error, while `hasAuthoritativeDownloadList` becomes true after a successful request, remains true while a later refresh is in flight, and clears only if the latest request fails. Series download actions require both loaded and @@ -76,8 +80,10 @@ variants, contextual buttons, and theme-aware styling. provider URL, request header, and metadata preparation; the coordinator owns only provider-neutral orchestration. When a reserved candidate matches a completed-missing row, the coordinator performs one authoritative preflight - refresh before any provider preparation. Restored files therefore become - stable skips without requiring a Stalker URL/network request. Both providers + refresh before any provider preparation. If a download-update broadcast + supersedes that request, the preflight waits for the winning refresh to + settle. Restored files therefore become stable skips without requiring a + Stalker URL/network request. Both providers use normalized `episode.id` as the canonical episode `xtreamId`; Stalker `originalCmd` and `originalId` participate only in URL resolution. diff --git a/libs/services/src/lib/download-list-load-state.ts b/libs/services/src/lib/download-list-load-state.ts index 3f13c4c2c..074e63c0a 100644 --- a/libs/services/src/lib/download-list-load-state.ts +++ b/libs/services/src/lib/download-list-load-state.ts @@ -2,6 +2,8 @@ import { signal } from '@angular/core'; export class DownloadListLoadState { private requestId = 0; + private settledRequestId = 0; + private readonly settlementWaiters = new Map void>(); private readonly loading = signal(false); private readonly loaded = signal(false); private readonly authoritative = signal(false); @@ -29,7 +31,29 @@ export class DownloadListLoadState { this.loaded.set(true); } - finish(): void { + async finishOrJoinLatest(requestId: number): Promise { + if (!this.isLatest(requestId)) { + await this.waitForSettlementAtOrAfter(requestId); + return; + } + this.loading.set(false); + this.settledRequestId = requestId; + for (const [minimumRequestId, resolve] of this.settlementWaiters) { + if (minimumRequestId <= requestId) { + this.settlementWaiters.delete(minimumRequestId); + resolve(); + } + } + } + + private waitForSettlementAtOrAfter(requestId: number): Promise { + if (this.settledRequestId >= requestId) { + return Promise.resolve(); + } + + return new Promise((resolve) => { + this.settlementWaiters.set(requestId, resolve); + }); } } diff --git a/libs/services/src/lib/downloads.service.spec.ts b/libs/services/src/lib/downloads.service.spec.ts index be004eeef..d85ed3b24 100644 --- a/libs/services/src/lib/downloads.service.spec.ts +++ b/libs/services/src/lib/downloads.service.spec.ts @@ -561,6 +561,40 @@ describe('DownloadsService', () => { expect(electron.downloadsGetList).toHaveBeenNthCalledWith(2); }); + it('keeps a superseded load pending until a newer request commits its snapshot', async () => { + const staleItem = createDownload(1, 'playlist-old'); + const latestItem = createDownload(2, 'playlist-new'); + const first = createDeferred(); + const second = createDeferred(); + const electron = { + downloadsGetList: jest + .fn() + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise), + }; + testWindow.electron = electron; + const service = createService(); + let supersededLoadSettled = false; + + const firstRequest = service.loadDownloads().finally(() => { + supersededLoadSettled = true; + }); + const secondRequest = service.loadDownloads(); + + first.resolve([staleItem]); + await first.promise; + await Promise.resolve(); + + expect(supersededLoadSettled).toBe(false); + expect(service.downloads()).toEqual([]); + + second.resolve([latestItem]); + await Promise.all([firstRequest, secondRequest]); + + expect(service.downloads()).toEqual([latestItem]); + expect(supersededLoadSettled).toBe(true); + }); + it('reports paused content and resumes it by content identity', async () => { const pausedItem = { ...createDownload(7), diff --git a/libs/services/src/lib/downloads.service.ts b/libs/services/src/lib/downloads.service.ts index 7bfcf4821..6ce6f74e4 100644 --- a/libs/services/src/lib/downloads.service.ts +++ b/libs/services/src/lib/downloads.service.ts @@ -88,7 +88,8 @@ export class DownloadsService implements OnDestroy { } /** - * Load downloads from the backend + * Load downloads from the backend. A superseded caller waits until its + * request or a newer one settles as the current snapshot. */ async loadDownloads(): Promise { if (!this.isAvailable()) return; @@ -107,9 +108,7 @@ export class DownloadsService implements OnDestroy { this.downloadListLoadState.markFailed(); } } finally { - if (this.downloadListLoadState.isLatest(requestId)) { - this.downloadListLoadState.finish(); - } + await this.downloadListLoadState.finishOrJoinLatest(requestId); } }