From decde93f60c3fac95c05699d5d4dc9068a91c528 Mon Sep 17 00:00:00 2001 From: 4gray Date: Tue, 28 Jul 2026 03:32:25 +0200 Subject: [PATCH] fix(portals): stop a rediscovery restoring the pin it started with A same-movie rediscovery read the pin, then held that snapshot across its source lookup and applied it afterwards. A pin made while the lookup was out was therefore overwritten by the older value: the row and the primary Play action named a source the database no longer held. The snapshot is now applied as soon as it is read, so a later write simply wins on ordering rather than needing to be detected. Co-Authored-By: Claude Opus 5 --- docs/architecture/vod-multi-source.md | 6 +++ .../vod-multi-source-host-pin.spec.ts | 37 +++++++++++++++++++ .../vod-multi-source-host.service.ts | 26 +++++++------ 3 files changed, 57 insertions(+), 12 deletions(-) diff --git a/docs/architecture/vod-multi-source.md b/docs/architecture/vod-multi-source.md index 637d3a755..f789e13a7 100644 --- a/docs/architecture/vod-multi-source.md +++ b/docs/architecture/vod-multi-source.md @@ -151,6 +151,12 @@ matter, and each rules out the other's shortcut: for a different film — pin Dune (2021), open Dune (1984) before its year arrives, and it starts the 2021 source. +The pin a rediscovery reads is applied **immediately**, not after its source +lookup returns: holding that snapshot across the await lets it overwrite a pin +the user makes in the meantime, leaving the row and the primary Play naming a +source the database no longer holds. Applying it first makes the later write +simply win. + For the same reason a write or an unpin never *deletes* the yearless alias on spec: that row may hold another remake's preference. The single exception is the row this session actually read, because the user is acting on the pin they diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-pin.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-pin.spec.ts index da44dd31c..695b1c59e 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-pin.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-pin.spec.ts @@ -206,6 +206,43 @@ describe('VodMultiSourceHostService — pinning', () => { ); }); + it('lets a pin made during a rediscovery win over the old one', async () => { + pins.get.mockResolvedValue({ + matchKey: 'title:the matrix:1999', + playlistId: ALT_TWO.playlistId, + contentId: ALT_TWO.contentId, + portalType: 'xtream', + }); + await loadMovie([ALT_TWO, ALT_THREE]); + expect(rowFor(ALT_TWO.id)?.isPinned).toBe(true); + + // Enrichment reruns discovery for the same film... + const slow = createDeferred<{ + sources: VodSourceCandidate[]; + matchKind: string; + }>(); + discovery.discover.mockReturnValueOnce(slow.promise); + const reloading = service.load({ ...MOVIE_A, tmdbId: 603 }); + while (discovery.discover.mock.calls.length < 2) { + await Promise.resolve(); + } + + // ...and the user pins a different source while it is still out. + await service.togglePin(ALT_THREE.id); + + slow.resolve({ + sources: [ALT_TWO, ALT_THREE], + matchKind: 'title-year', + }); + await reloading; + + // The snapshot that rerun started with is now stale. Restoring it + // would leave the row and Play on a source the database no longer + // holds. + expect(rowFor(ALT_THREE.id)?.isPinned).toBe(true); + expect(rowFor(ALT_TWO.id)?.isPinned).toBe(false); + }); + it('retires its own aliases but never another remake’s', async () => { await loadMovie([ALT_TWO]); const lookupKeys: string[] = pins.get.mock.calls[0][0]; diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.service.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.service.ts index 500d5c8e3..250192313 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.service.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.service.ts @@ -77,8 +77,8 @@ export class VodMultiSourceHostService { private pinKeys: PinKeySets = { lookup: [], write: [] }; /** Bumped per `load()`: discards a discovery the next one superseded. */ private discoveryToken = 0; - /** Bumped when the FILM changes — a rediscovery of the same one is a - * refresh, and must not cancel a switch or probe in flight for it. */ + /** Bumped when the FILM changes; a rediscovery of the same one is a + * refresh and must not cancel work in flight for it. */ private sessionToken = 0; /** Bumped by every switch: a slower one must not overwrite a newer. */ private switchToken = 0; @@ -213,6 +213,16 @@ export class VodMultiSourceHostService { return; } + // Applied NOW, not after the discovery below: holding this snapshot + // across that await lets it overwrite a pin made in the meantime, and + // the row would name a source the database no longer holds. `loaded` + // records where it was found — the only ambiguous key an unpin may + // remove, being the row the user can actually see. + if (pin) { + this.controller.setPinnedSource(pinnedSourceIdOf(pin)); + this.pinKeys = { ...this.pinKeys, loaded: pin.matchKey }; + } + const result = await this.discovery.discover({ title: movie.title, year: movie.year, @@ -229,13 +239,6 @@ export class VodMultiSourceHostService { result.sources, result.matchKind ); - if (pin) { - this.controller.setPinnedSource(pinnedSourceIdOf(pin)); - // Remember where it was found: that row is the one the user sees, - // so it is the only ambiguous key an unpin may remove. - this.pinKeys = { ...this.pinKeys, loaded: pin.matchKey }; - } - this.publish(); } @@ -290,9 +293,8 @@ export class VodMultiSourceHostService { try { return (await this.switchTo(candidate)) === 'switched'; } finally { - // Only if this attempt still owns the spinner: a slower first pick - // finishing after a second one would otherwise clear the row that - // is still resolving. + // Only while this attempt still owns the spinner, or a slower + // pick would clear the row that is still resolving. if (this._busySourceId() === sourceId) { this._busySourceId.set(null); }