fix(portals): keep the playing row when the refined year rejects it

Follow-on from keeping the session across a rediscovery. The rerun can
legitimately drop the row that is playing: enrichment supplies the release
year, and the year gate then rejects a copy the yearless search had admitted
— "Dune" 1984 while the user is watching the 2021 film.

Off the list is right; it is not the same film. Off the screen is not. It is
what is streaming, so it stays as a row and keeps the playing badge, rather
than letting the caption name a playlist that is not sending any bytes.

Also covers the new session key directly in the identity spec.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5 committed 2026-07-27 21:31:15 +02:00
1 parent 93caa1c5a6
commit 6cce35274a
4 files changed
+66 -7

No files matched your search

+7
View File
@@ -144,6 +144,13 @@ actually streaming, and hand failover a clean slate for sources it has already
spent. Only a different film resets — including the tried set, which is what
makes failover terminate.
A rerun can also legitimately *drop* the playing row: the year the enrichment
supplies makes the year gate reject a copy the yearless search had admitted
("Dune" 1984 while watching the 2021 film). Off the list is right — it is not
the same film. Off the screen is not, so `applyDiscoveredSources` keeps it as a
row and leaves it active; a caption naming a playlist that is not streaming
anything would be a lie about the one thing this feature exists to state.
## The candidate window
Both queries take a bounded number of rows, so what fills that window decides
@@ -156,6 +156,20 @@ describe('VodMultiSourceHostService — session lifecycle', () => {
);
});
it('keeps the playing row when the refined year rejects it', async () => {
await loadMovie([ALT_TWO]);
await expect(service.play(ALT_TWO.id)).resolves.toBe(true);
// Enrichment supplies the release year, and the year gate now rejects
// the copy the yearless search had admitted. Off the LIST is right —
// it is a different film. Off the SCREEN is not: it is the one
// streaming, and the caption must not name a playlist that is not.
await loadMovie([], { ...MOVIE_A, year: 2021 });
expect(rowFor(ALT_TWO.id)?.isActive).toBe(true);
expect(rowFor(CURRENT_A_ID)?.isActive).toBe(false);
});
it('resets the failover session when a new movie loads', async () => {
await loadMovie([ALT_TWO]);
vodAutoFailover.set(true);
@@ -1,6 +1,7 @@
import {
resolveVodMultiSourceMovie,
vodMultiSourceMovieKey,
vodMultiSourceSessionKey,
type VodMultiSourceMovie,
} from './vod-multi-source-identity';
@@ -106,3 +107,26 @@ describe('vodMultiSourceMovieKey', () => {
);
});
});
describe('vodMultiSourceSessionKey', () => {
it('ignores everything enrichment can change', () => {
// The whole point of the second key: the movie key SHOULD react to a
// year and a TMDB id arriving, so discovery reruns — but that rerun
// must not be mistaken for a different film and reset the session
// out from under the source that is playing.
expect(
vodMultiSourceSessionKey(
movie({ title: 'Dune (2021)', year: 2021, tmdbId: 438631 })
)
).toBe(vodMultiSourceSessionKey(movie()));
});
it('separates two movies and two playlists', () => {
expect(vodMultiSourceSessionKey(movie({ contentId: 202 }))).not.toBe(
vodMultiSourceSessionKey(movie())
);
expect(
vodMultiSourceSessionKey(movie({ playlistId: 'playlist-2' }))
).not.toBe(vodMultiSourceSessionKey(movie()));
});
});
@@ -43,17 +43,31 @@ export function applyDiscoveredSources(
? controller.findSource(activeId)
: null;
controller.setSources([current, ...discovered], matchKind);
const playing = switchedTo && controller.findSource(switchedTo.id);
if (!playing) {
if (!switchedTo) {
controller.setSources([current, ...discovered], matchKind);
controller.setActiveSource(current.id);
return;
}
// Catalog rows only ever carry guesses parsed off the title. Whatever the
// resolve turned into facts for the playing source stays fact.
controller.updateSource(switchedTo);
// The rerun can legitimately drop the playing row: enrichment supplies the
// release year, and the year gate then rejects a copy that a yearless
// search had admitted ("Dune" 1984 while watching the 2021 film). Dropping
// it from the LIST is right — it is not the same film. Dropping it from
// the SCREEN is not: it is what the user is watching, so it stays as a row
// and keeps the playing badge rather than letting the caption name a
// playlist that is not streaming anything.
const isDiscovered = discovered.some(
(source) => source.id === switchedTo.id
);
// Catalog rows only ever carry guesses parsed off the title, so the row
// that a real resolve turned into facts wins over its rediscovered twin.
const sources = isDiscovered
? discovered.map((source) =>
source.id === switchedTo.id ? switchedTo : source
)
: [...discovered, switchedTo];
controller.setSources([current, ...sources], matchKind);
controller.setActiveSource(switchedTo.id);
}