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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5 committed 2026-07-28 03:32:25 +02:00
1 parent ce1b1f3269
commit decde93f60
3 files changed
+57 -12

No files matched your search

+6
View File
@@ -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
@@ -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];
@@ -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);
}