From 1d996b85f0821a0ab909fe5dc99e756014e1f6eb Mon Sep 17 00:00:00 2001 From: 4gray Date: Thu, 30 Jul 2026 09:45:50 +0200 Subject: [PATCH] fix(xtream): isolate reused detail identities --- .../lib/stores/xtream-tmdb-enrichment.spec.ts | 99 ++++++++++++++++ .../src/lib/stores/xtream-tmdb-enrichment.ts | 29 +++-- .../src/lib/stores/xtream.store.spec.ts | 109 ++++++++++++++++++ .../src/lib/stores/xtream.store.ts | 15 ++- .../vod-details-route-playback.spec.ts | 71 ++++++++++++ .../vod-details-route.component.spec.ts | 4 +- .../vod-details-route.component.ts | 32 ++++- .../vod-details/vod-details-route.harness.ts | 9 +- 8 files changed, 345 insertions(+), 23 deletions(-) diff --git a/libs/portal/xtream/data-access/src/lib/stores/xtream-tmdb-enrichment.spec.ts b/libs/portal/xtream/data-access/src/lib/stores/xtream-tmdb-enrichment.spec.ts index 5df070cc8..872f2a88e 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/xtream-tmdb-enrichment.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/xtream-tmdb-enrichment.spec.ts @@ -132,6 +132,37 @@ describe('enrichVodSelectionWithTmdb', () => { expect(store.setSelectedItem).not.toHaveBeenCalled(); }); + it('drops a same-id result after its playlist request becomes stale', async () => { + let isCurrentRequest = true; + const store = createStore(vodItem); + const enrichment = createEnrichment({ + enrichMovie: jest.fn().mockImplementation(async () => { + isCurrentRequest = false; + store.replaceItem({ + ...vodItem, + info: { + ...vodItem.info, + name: 'Movie from playlist B', + plot: 'Playlist B plot', + }, + }); + return { id: 603, overview: 'Playlist A TMDB overview' }; + }), + } as Partial); + + await enrichVodSelectionWithTmdb( + store, + enrichment, + '42', + () => isCurrentRequest + ); + + expect(store.setSelectedItem).not.toHaveBeenCalled(); + expect(store.selectedItem()?.['info']).toEqual( + expect.objectContaining({ plot: 'Playlist B plot' }) + ); + }); + it('keeps provider data when no confident match was found', async () => { const store = createStore(vodItem); const enrichment = createEnrichment(); @@ -174,6 +205,37 @@ describe('enrichSerialSelectionWithTmdb', () => { expect(enrichment.enrichTv).not.toHaveBeenCalled(); }); + + it('drops a same-id result after its playlist request becomes stale', async () => { + let isCurrentRequest = true; + const store = createStore(serialItem); + const enrichment = createEnrichment({ + enrichTv: jest.fn().mockImplementation(async () => { + isCurrentRequest = false; + store.replaceItem({ + ...serialItem, + info: { + ...serialItem.info, + name: 'Series from playlist B', + plot: 'Playlist B plot', + }, + }); + return { id: 70523, overview: 'Playlist A TMDB overview' }; + }), + } as Partial); + + await enrichSerialSelectionWithTmdb( + store, + enrichment, + '7', + () => isCurrentRequest + ); + + expect(store.setSelectedItem).not.toHaveBeenCalled(); + expect(store.selectedItem()?.['info']).toEqual( + expect.objectContaining({ plot: 'Playlist B plot' }) + ); + }); }); describe('enrichSerialSeasonWithTmdb', () => { @@ -250,4 +312,41 @@ describe('enrichSerialSeasonWithTmdb', () => { expect(enrichment.getSeasonEpisodes).toHaveBeenCalledWith(82856, 1); }); + + it('drops a same-id season result after its playlist becomes stale', async () => { + let isCurrentPlaylist = true; + const store = createStore( + seasonSliceItem('Series from playlist A', { + '1': [{ episode_num: 1, season: 1 }], + }) + ); + const enrichment = createEnrichment({ + getSeasonEpisodes: jest.fn().mockImplementation(async () => { + isCurrentPlaylist = false; + store.replaceItem( + seasonSliceItem('Series from playlist B', { + '1': [{ episode_num: 1, season: 1 }], + }) + ); + return [{ episode_number: 1, name: 'Playlist A episode' }]; + }), + } as Partial); + + await enrichSerialSeasonWithTmdb( + store, + enrichment, + '1', + () => isCurrentPlaylist + ); + + expect(store.setSelectedItem).not.toHaveBeenCalled(); + expect( + ( + store.selectedItem()?.['episodes'] as Record< + string, + { title: string }[] + > + )['1'][0].title + ).toBe('Episode 1'); + }); }); diff --git a/libs/portal/xtream/data-access/src/lib/stores/xtream-tmdb-enrichment.ts b/libs/portal/xtream/data-access/src/lib/stores/xtream-tmdb-enrichment.ts index 8d32d389d..084177448 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/xtream-tmdb-enrichment.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/xtream-tmdb-enrichment.ts @@ -32,13 +32,17 @@ type SelectionRecord = { readonly [key: string]: unknown }; export async function enrichVodSelectionWithTmdb( store: EnrichableSelectionStore, enrichment: TmdbEnrichmentService, - vodId: string | number + vodId: string | number, + isCurrentRequest: () => boolean = () => true ): Promise { if (!enrichment.isEnabled()) { return; } const isCurrent = (): TItem | null => { + if (!isCurrentRequest()) { + return null; + } const item = store.selectedItem(); return item && String(item['stream_id']) === String(vodId) ? item @@ -87,13 +91,17 @@ export async function enrichSerialSelectionWithTmdb< >( store: EnrichableSelectionStore, enrichment: TmdbEnrichmentService, - serialId: string | number + serialId: string | number, + isCurrentRequest: () => boolean = () => true ): Promise { if (!enrichment.isEnabled()) { return; } const isCurrent = (): TItem | null => { + if (!isCurrentRequest()) { + return null; + } const item = store.selectedItem(); return item && String(item['series_id']) === String(serialId) ? item @@ -136,20 +144,18 @@ export async function enrichSerialSelectionWithTmdb< * enrichSerialSelectionWithTmdb). Merges real episode names, overviews and * stills into `episodes[seasonKey]` by episode number. */ -export async function enrichSerialSeasonWithTmdb< - TItem extends SelectionRecord, ->( +export async function enrichSerialSeasonWithTmdb( store: EnrichableSelectionStore, enrichment: TmdbEnrichmentService, - seasonKey: string + seasonKey: string, + isCurrentSelection: () => boolean = () => true ): Promise { - if (!enrichment.isEnabled()) { + if (!enrichment.isEnabled() || !isCurrentSelection()) { return; } const selected = store.selectedItem() as unknown as - | (XtreamSerieDetails & { series_id?: string | number }) - | null; + (XtreamSerieDetails & { series_id?: string | number }) | null; const serialId = selected?.series_id; const info = selected?.info; const episodes = selected?.episodes?.[seasonKey]; @@ -180,13 +186,12 @@ export async function enrichSerialSeasonWithTmdb< info.tmdb_id, seasonNumber ); - if (!tmdbEpisodes?.length) { + if (!tmdbEpisodes?.length || !isCurrentSelection()) { return; } const current = store.selectedItem() as unknown as - | (XtreamSerieDetails & { series_id?: string | number }) - | null; + (XtreamSerieDetails & { series_id?: string | number }) | null; const currentEpisodes = current?.episodes?.[seasonKey]; if ( !current || diff --git a/libs/portal/xtream/data-access/src/lib/stores/xtream.store.spec.ts b/libs/portal/xtream/data-access/src/lib/stores/xtream.store.spec.ts index 7e2b8bba7..5feb04d4e 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/xtream.store.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/xtream.store.spec.ts @@ -57,6 +57,7 @@ async function waitForCondition( function configureStore( xtreamApiService: { + getSeriesInfo?: jest.Mock; getVodInfo: jest.Mock; getVodStream: jest.Mock; }, @@ -240,4 +241,112 @@ describe('XtreamStore VOD details recovery', () => { expect(store.detailsError()).toBeNull(); expect(store.isLoadingDetails()).toBe(false); }); + + it('clears the previous movie while a reused-route request is pending or fails', async () => { + const nextDetails = createDeferred(); + const xtreamApiService = { + getVodInfo: jest.fn(() => nextDetails.promise), + getVodStream: jest.fn(), + }; + const store = configureStore(xtreamApiService); + store.setSelectedItem({ + info: { name: 'Movie A' }, + stream_id: 42, + xtream_id: 42, + }); + + store.fetchVodDetailsWithMetadata({ + categoryId: 7, + vodId: '99', + }); + + expect(store.selectedItem()).toBeNull(); + + nextDetails.reject(new Error('Movie B unavailable')); + await waitForCondition( + () => store.detailsError() === 'Movie B unavailable' + ); + + expect(store.selectedItem()).toBeNull(); + expect(store.isLoadingDetails()).toBe(false); + }); + + it('keeps the newer movie authoritative when an older base request resolves late', async () => { + const firstDetails = createDeferred<{ + container_extension: string; + info: { name: string }; + stream_id: number; + }>(); + const xtreamApiService = { + getVodInfo: jest.fn((_credentials: unknown, vodId: string) => + vodId === '42' + ? firstDetails.promise + : Promise.resolve({ + info: { name: 'Movie B' }, + stream_id: 99, + container_extension: 'mp4', + }) + ), + getVodStream: jest.fn(), + }; + const store = configureStore(xtreamApiService); + + store.fetchVodDetailsWithMetadata({ + categoryId: 7, + vodId: '42', + }); + store.fetchVodDetailsWithMetadata({ + categoryId: 7, + vodId: '99', + }); + await waitForCondition( + () => + resolveXtreamVodPlaybackSource(store.selectedItem()) + ?.streamId === 99 + ); + + firstDetails.resolve({ + info: { name: 'Movie A' }, + stream_id: 42, + container_extension: 'mkv', + }); + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(resolveXtreamVodPlaybackSource(store.selectedItem())).toEqual({ + streamId: 99, + containerExtension: 'mp4', + }); + expect(store.detailsError()).toBeNull(); + expect(store.isLoadingDetails()).toBe(false); + }); + + it('clears the previous series while a reused-route request is pending or fails', async () => { + const nextDetails = createDeferred(); + const xtreamApiService = { + getSeriesInfo: jest.fn(() => nextDetails.promise), + getVodInfo: jest.fn(), + getVodStream: jest.fn(), + }; + const store = configureStore(xtreamApiService); + store.setSelectedItem({ + info: { name: 'Series A' }, + series_id: 42, + }); + + store.fetchSerialDetailsWithMetadata({ + categoryId: 7, + serialId: '99', + }); + + expect(store.selectedItem()).toBeNull(); + + nextDetails.reject(new Error('Series B unavailable')); + await waitForCondition( + () => store.detailsError() === 'Series B unavailable' + ); + + expect(store.selectedItem()).toBeNull(); + expect(store.isLoadingDetails()).toBe(false); + }); }); diff --git a/libs/portal/xtream/data-access/src/lib/stores/xtream.store.ts b/libs/portal/xtream/data-access/src/lib/stores/xtream.store.ts index 7a807cc3e..fe8ca8df9 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/xtream.store.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/xtream.store.ts @@ -143,6 +143,7 @@ export const XtreamStore = signalStore( store.setIsLoadingDetails(true); store.setDetailsError(null); + store.setSelectedItem(null); xtreamApiService .getVodInfo(credentials, params.vodId) .then((vodDetails: XtreamVodDetails) => { @@ -171,7 +172,8 @@ export const XtreamStore = signalStore( void enrichVodSelectionWithTmdb( store, tmdbEnrichment, - params.vodId + params.vodId, + isCurrentRequest ); if (!recovery) { @@ -241,6 +243,7 @@ export const XtreamStore = signalStore( store.setIsLoadingDetails(true); store.setDetailsError(null); + store.setSelectedItem(null); xtreamApiService .getSeriesInfo(credentials, params.serialId) .then((serialDetails: XtreamSerieDetails) => { @@ -253,7 +256,8 @@ export const XtreamStore = signalStore( void enrichSerialSelectionWithTmdb( store, tmdbEnrichment, - params.serialId + params.serialId, + isCurrentRequest ); }) .catch((error: unknown) => { @@ -278,10 +282,15 @@ export const XtreamStore = signalStore( * opens a season; a no-op without a show-level TMDB match. */ enrichSelectedSerialSeason(seasonKey: string): void { + const playlistId = store.currentPlaylist()?.id; + if (!playlistId) { + return; + } void enrichSerialSeasonWithTmdb( store, tmdbEnrichment, - seasonKey + seasonKey, + () => store.currentPlaylist()?.id === playlistId ); }, }; diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-playback.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-playback.spec.ts index 8b1b686c4..208938ad7 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-playback.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-playback.spec.ts @@ -27,6 +27,8 @@ describe('VodDetailsRouteComponent — playback actions', () => { getPlaybackPosition, isDownloaded, playDownload, + routeParams, + selectedItem, } = stubs; beforeEach(async () => { @@ -326,6 +328,75 @@ describe('VodDetailsRouteComponent — playback actions', () => { expect(playback.hasPlaybackPosition()).toBe(false); }); + it('does not expose a stale movie after a reused route changes identity', async () => { + currentPlaylist.set({ id: 'playlist-1' }); + selectedItem.set({ + info: { name: 'Movie A', description: 'Movie A details' }, + movie_data: { + stream_id: 650020, + name: 'Movie A', + container_extension: 'mp4', + }, + } as XtreamVodDetails); + fixture.detectChanges(); + + expect(fixture.componentInstance.selectedVodInfo()?.name).toBe( + 'Movie A' + ); + + // The router reuses the detail component. The B request can fail, + // leaving the store's last successful selection (A) in place while + // route/download state has already switched to B. + downloadsAvailable.set(true); + isDownloaded.mockImplementation((vodId: number) => vodId === 650021); + routeParams.next({ vodId: '650021', categoryId: '235' }); + stubs.detailsError.set('Movie B unavailable'); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(fixture.componentInstance.selectedVodId()).toBe(650021); + expect(fixture.componentInstance.isDownloaded()).toBe(true); + expect(fixture.componentInstance.selectedItem()).toBeNull(); + expect(fixture.componentInstance.selectedVodInfo()).toBeNull(); + expect(fixture.componentInstance.playableVodItem()).toBeNull(); + const host = fixture.nativeElement as HTMLElement; + expect(host.textContent).not.toContain('Movie A'); + expect(host.textContent).toContain('Movie B unavailable'); + expect(host.textContent).not.toContain('DOWNLOADS.OFFLINE'); + expect(host.textContent).not.toContain('DOWNLOADS.PLAY_LOCAL'); + expect(host.textContent).not.toContain( + 'PORTALS.MULTI_SOURCE.PLAY_FROM_SOURCE' + ); + expect(host.querySelector('button.play-btn')).toBeNull(); + + await fixture.componentInstance.playFromProviderSource( + fixture.componentInstance.playableVodItem() + ); + + expect(stubs.openResolvedPlayback).not.toHaveBeenCalled(); + + selectedItem.set({ + info: { name: 'Movie B', description: 'Movie B details' }, + movie_data: { + stream_id: 650021, + name: 'Movie B', + container_extension: 'mkv', + }, + } as XtreamVodDetails); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(fixture.componentInstance.selectedVodInfo()?.name).toBe( + 'Movie B' + ); + expect(host.textContent).toContain('Movie B'); + expect(host.textContent).toContain('DOWNLOADS.OFFLINE'); + expect(host.textContent).toContain('DOWNLOADS.PLAY_LOCAL'); + expect(host.textContent).toContain( + 'PORTALS.MULTI_SOURCE.PLAY_FROM_SOURCE' + ); + }); + it('downloads the movie the route currently shows', async () => { currentPlaylist.set({ id: 'playlist-1' }); fixture.detectChanges(); diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.spec.ts index b2c4723de..62660a0d3 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.spec.ts @@ -345,7 +345,7 @@ describe('VodDetailsRouteComponent', () => { rating_kinopoisk: '7.455', }, movie_data: { - stream_id: 678140, + stream_id: 650020, name: 'City of McFarland (2015) DE', added: '1750671180', category_id: '235', @@ -373,7 +373,7 @@ describe('VodDetailsRouteComponent', () => { movie_image: 'https://example.com/poster.jpg', }, movie_data: { - stream_id: 678140, + stream_id: 650020, name: 'Metadata Without Backdrop', added: '1750671180', category_id: '235', diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts index 060b6d73d..c548b30df 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts @@ -68,6 +68,26 @@ import { VodDetailsSimilarService } from './vod-details-similar.service'; import { VodMultiSourceHostService } from './vod-multi-source-host.service'; import { resolveVodMultiSourceMovie } from './vod-multi-source-identity'; +type XtreamVodIdentityItem = XtreamVodDetails & { + readonly id?: number | string; + readonly stream_id?: number | string; + readonly xtream_id?: number | string; +}; + +function resolveVodIdentity(item: XtreamVodDetails): number | null { + const candidate = item as XtreamVodIdentityItem; + const value = + item.movie_data?.stream_id ?? + candidate.xtream_id ?? + candidate.stream_id ?? + candidate.id; + const id = typeof value === 'string' ? Number(value) : value; + + return typeof id === 'number' && Number.isSafeInteger(id) && id > 0 + ? id + : null; +} + @Component({ templateUrl: './vod-details-route.component.html', styleUrls: [ @@ -134,11 +154,15 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { readonly isElectron = this.downloadsService.isAvailable; readonly isFavorite = this.xtreamStore.isFavorite; - readonly selectedItem = computed( - () => - this.xtreamStore.selectedItem() as unknown as XtreamVodDetails | null - ); readonly selectedVodId = computed(() => Number(this.routeParams().vodId)); + readonly selectedItem = computed(() => { + const item = + this.xtreamStore.selectedItem() as unknown as XtreamVodDetails | null; + + return item && resolveVodIdentity(item) === this.selectedVodId() + ? item + : null; + }); private readonly scopedVodCategories = computed(() => { const playlistId = this.xtreamStore.currentPlaylist()?.id; return playlistId && diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.harness.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.harness.ts index 6cd451af5..eb2922000 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.harness.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.harness.ts @@ -17,7 +17,7 @@ import { XtreamVodStream, } from '@iptvnator/shared/interfaces'; import { TranslateService } from '@ngx-translate/core'; -import { of } from 'rxjs'; +import { BehaviorSubject, of } from 'rxjs'; import { VodDetailsRouteComponent } from './vod-details-route.component'; /** @@ -32,6 +32,10 @@ import { VodDetailsRouteComponent } from './vod-details-route.component'; /** Every stub the harness installs, so specs can drive and assert on them. */ export function createVodDetailsRouteStubs() { return { + routeParams: new BehaviorSubject({ + vodId: '650020', + categoryId: '235', + }), selectedItem: signal(null), isLoadingDetails: signal(false), detailsError: signal(null), @@ -82,6 +86,7 @@ export type VodDetailsRouteStubs = ReturnType< /** Back to the state a fresh `beforeEach` expects. */ export function resetVodDetailsRouteStubs(stubs: VodDetailsRouteStubs): void { + stubs.routeParams.next({ vodId: '650020', categoryId: '235' }); stubs.selectedItem.set(null); stubs.isLoadingDetails.set(false); stubs.detailsError.set(null); @@ -153,7 +158,7 @@ export async function configureVodDetailsRouteTestBed( { provide: ActivatedRoute, useValue: { - params: of({ vodId: '650020', categoryId: '235' }), + params: stubs.routeParams, snapshot: { params: { vodId: '650020', categoryId: '235' }, },