diff --git a/docs/architecture/vod-multi-source.md b/docs/architecture/vod-multi-source.md index b2e93ecc3..a57d0a345 100644 --- a/docs/architecture/vod-multi-source.md +++ b/docs/architecture/vod-multi-source.md @@ -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 diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-session.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-session.spec.ts index 1f95dac8d..7022cc133 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-session.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-session.spec.ts @@ -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); diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-identity.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-identity.spec.ts index 48f9821b8..b08816021 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-identity.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-identity.spec.ts @@ -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())); + }); +}); diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-session.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-session.ts index 997f2e114..c9d86779b 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-session.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-session.ts @@ -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); }