diff --git a/libs/portal/xtream/feature/src/lib/xtream-catalog-facade.service.ts b/libs/portal/xtream/feature/src/lib/xtream-catalog-facade.service.ts index 5fe68444b..f1e4912c6 100644 --- a/libs/portal/xtream/feature/src/lib/xtream-catalog-facade.service.ts +++ b/libs/portal/xtream/feature/src/lib/xtream-catalog-facade.service.ts @@ -29,6 +29,7 @@ export class XtreamCatalogFacadeService { private readonly xtreamStore = inject(XtreamStore); private savedPageBeforeDetail: number | null = null; + private loadedPositionsPlaylistId: string | null = null; private readonly routeSearchQuery = signal(''); readonly provider = 'xtream' as const; @@ -135,7 +136,8 @@ export class XtreamCatalogFacadeService } const playlistId = this.xtreamStore.currentPlaylist()?.id; - if (playlistId) { + if (playlistId && this.loadedPositionsPlaylistId !== playlistId) { + this.loadedPositionsPlaylistId = playlistId; this.xtreamStore.loadAllPositions(playlistId); } diff --git a/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.spec.ts b/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.spec.ts index b67ef0337..60d206c52 100644 --- a/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.spec.ts +++ b/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.spec.ts @@ -35,6 +35,12 @@ const XTREAM_PLAYLIST: XtreamPlaylistData = { password: 'secret', type: 'xtream', }; +const XTREAM_PLAYLIST_WITH_NULL_OPTIONALS = { + ...XTREAM_PLAYLIST, + origin: null, + referrer: null, + userAgent: null, +} as XtreamPlaylistData; async function flushEffects(): Promise { await Promise.resolve(); @@ -59,6 +65,7 @@ describe('XtreamWorkspaceRouteSession', () => { const portalStatus = signal('active'); const selectedContentType = signal<'live' | 'vod' | 'series'>('vod'); const selectedCategoryId = signal(null); + const isContentInitialized = signal(false); const contentInitBlockReason = signal(null); let hasUsableOfflineCache = false; @@ -78,6 +85,7 @@ describe('XtreamWorkspaceRouteSession', () => { playlistId.set(nextPlaylistId ?? null); currentPlaylist.set(null); selectedContentType.set('vod'); + isContentInitialized.set(false); }), setCurrentPlaylist: jest.fn((playlist: XtreamPlaylistData | null) => { currentPlaylist.set(playlist); @@ -87,8 +95,11 @@ describe('XtreamWorkspaceRouteSession', () => { hasUsableOfflineCache: jest.fn().mockImplementation(async () => { return hasUsableOfflineCache; }), + isContentInitialized, contentInitBlockReason, - initializeContent: jest.fn().mockResolvedValue(undefined), + initializeContent: jest.fn().mockImplementation(async () => { + isContentInitialized.set(true); + }), setSelectedContentType: jest.fn( (type: 'live' | 'vod' | 'series') => { selectedContentType.set(type); @@ -114,11 +125,12 @@ describe('XtreamWorkspaceRouteSession', () => { routeProvider.set('xtreams'); routePlaylistId.set(PLAYLIST_ID); activePlaylist.set(ACTIVE_PLAYLIST); - currentPlaylist.set(XTREAM_PLAYLIST); - playlistId.set(PLAYLIST_ID); + currentPlaylist.set(null); + playlistId.set(null); portalStatus.set('active'); selectedContentType.set('vod'); selectedCategoryId.set(null); + isContentInitialized.set(false); contentInitBlockReason.set(null); hasUsableOfflineCache = false; @@ -198,6 +210,73 @@ describe('XtreamWorkspaceRouteSession', () => { ).toBeGreaterThan(xtreamStore.resetStore.mock.invocationCallOrder[0]); }); + it('does not bootstrap again when the store already holds the active playlist', async () => { + router.url = `/workspace/xtreams/${PLAYLIST_ID}/vod/101`; + currentPlaylist.set(XTREAM_PLAYLIST); + playlistId.set(PLAYLIST_ID); + isContentInitialized.set(true); + + TestBed.inject(XtreamWorkspaceRouteSession); + await flushEffects(); + + expect(xtreamStore.setSelectedContentType).toHaveBeenCalledWith('vod'); + expect(xtreamStore.setSelectedCategory).toHaveBeenCalledWith(101); + expect(xtreamStore.resetStore).not.toHaveBeenCalled(); + expect(xtreamStore.fetchXtreamPlaylist).not.toHaveBeenCalled(); + expect(xtreamStore.checkPortalStatus).not.toHaveBeenCalled(); + expect(xtreamStore.initializeContent).not.toHaveBeenCalled(); + }); + + it('does not treat null and undefined Xtream connection metadata as a playlist change', async () => { + router.url = `/workspace/xtreams/${PLAYLIST_ID}/vod/101`; + currentPlaylist.set(XTREAM_PLAYLIST_WITH_NULL_OPTIONALS); + playlistId.set(PLAYLIST_ID); + isContentInitialized.set(true); + + TestBed.inject(XtreamWorkspaceRouteSession); + await flushEffects(); + + expect(xtreamStore.resetStore).not.toHaveBeenCalled(); + expect(xtreamStore.fetchXtreamPlaylist).not.toHaveBeenCalled(); + expect(xtreamStore.checkPortalStatus).not.toHaveBeenCalled(); + expect(xtreamStore.initializeContent).not.toHaveBeenCalled(); + }); + + it('does not reinitialize content when switching categories in an initialized playlist', async () => { + router.url = `/workspace/xtreams/${PLAYLIST_ID}/vod/101`; + xtreamStore.checkPortalStatus.mockImplementation(async () => { + portalStatus.set('active'); + return 'active'; + }); + + TestBed.inject(XtreamWorkspaceRouteSession); + await flushEffects(); + await flushEffects(); + + expect(isContentInitialized()).toBe(true); + + xtreamStore.resetStore.mockClear(); + xtreamStore.fetchXtreamPlaylist.mockClear(); + xtreamStore.checkPortalStatus.mockClear(); + xtreamStore.initializeContent.mockClear(); + xtreamStore.setSelectedContentType.mockClear(); + xtreamStore.setSelectedCategory.mockClear(); + + router.url = `/workspace/xtreams/${PLAYLIST_ID}/vod/202`; + routerEvents.next( + new NavigationEnd(1, router.url, router.url) + ); + await flushEffects(); + + expect(xtreamStore.setSelectedContentType).toHaveBeenCalledWith('vod'); + expect(xtreamStore.setSelectedCategory).toHaveBeenCalledWith(202); + expect(selectedCategoryId()).toBe(202); + expect(xtreamStore.resetStore).not.toHaveBeenCalled(); + expect(xtreamStore.fetchXtreamPlaylist).not.toHaveBeenCalled(); + expect(xtreamStore.checkPortalStatus).not.toHaveBeenCalled(); + expect(xtreamStore.initializeContent).not.toHaveBeenCalled(); + }); + it.each(['expired', 'inactive', 'unavailable'] as const)( 'blocks %s portals before import-driven initialization starts', async (status) => { @@ -239,6 +318,8 @@ describe('XtreamWorkspaceRouteSession', () => { it('rebootstraps the current Xtream playlist when its connection details change', async () => { activePlaylist.set(UPDATED_ACTIVE_PLAYLIST); + currentPlaylist.set(XTREAM_PLAYLIST); + playlistId.set(PLAYLIST_ID); xtreamStore.checkPortalStatus.mockImplementation(async () => { portalStatus.set('active'); return 'active'; diff --git a/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.ts b/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.ts index 41dfd322b..329c5e8d0 100644 --- a/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.ts +++ b/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.ts @@ -76,13 +76,39 @@ function hasPlaylistConnectionChanges( return false; } + const currentUserAgent = currentPlaylist.userAgent ?? null; + const nextUserAgent = nextPlaylist.userAgent ?? null; + const currentReferrer = currentPlaylist.referrer ?? null; + const nextReferrer = nextPlaylist.referrer ?? null; + const currentOrigin = currentPlaylist.origin ?? null; + const nextOrigin = nextPlaylist.origin ?? null; + return ( currentPlaylist.serverUrl !== nextPlaylist.serverUrl || currentPlaylist.username !== nextPlaylist.username || currentPlaylist.password !== nextPlaylist.password || - currentPlaylist.userAgent !== nextPlaylist.userAgent || - currentPlaylist.referrer !== nextPlaylist.referrer || - currentPlaylist.origin !== nextPlaylist.origin + currentUserAgent !== nextUserAgent || + currentReferrer !== nextReferrer || + currentOrigin !== nextOrigin + ); +} + +function shouldBootstrapXtreamPlaylist( + playlistId: string | null, + routePlaylist: XtreamPlaylistData | null, + storePlaylistId: string | null, + currentPlaylist: XtreamPlaylistData | null +): boolean { + const currentPlaylistUpdateDate = currentPlaylist?.updateDate ?? null; + const routePlaylistUpdateDate = routePlaylist?.updateDate ?? null; + + return Boolean( + playlistId && + routePlaylist && + (storePlaylistId !== playlistId || + currentPlaylist?.id !== playlistId || + currentPlaylistUpdateDate !== routePlaylistUpdateDate || + hasPlaylistConnectionChanges(currentPlaylist, routePlaylist)) ); } @@ -117,8 +143,6 @@ export class XtreamWorkspaceRouteSession { private readonly router = inject(Router); private readonly xtreamStore = inject(XtreamStore); - private currentPlaylistId: string | null = null; - private currentPlaylistUpdateDate: number | null = null; private syncInFlight = false; private syncPending = false; @@ -186,26 +210,20 @@ export class XtreamWorkspaceRouteSession { routeContext.provider === 'xtreams' ? toXtreamPlaylistData(this.playlistContext.activePlaylist()) : null; + const storePlaylistId = this.xtreamStore.playlistId(); const currentPlaylist = this.xtreamStore.currentPlaylist(); - const routePlaylistUpdateDate = routePlaylist?.updateDate ?? null; - const needsPlaylistBootstrap = Boolean( - playlistId && - routePlaylist && - (currentPlaylist?.id !== playlistId || - this.currentPlaylistUpdateDate !== routePlaylistUpdateDate || - hasPlaylistConnectionChanges(currentPlaylist, routePlaylist)) + const shouldBootstrapPlaylist = shouldBootstrapXtreamPlaylist( + playlistId, + routePlaylist, + storePlaylistId, + currentPlaylist ); let portalStatus = this.xtreamStore.portalStatus(); + let didBootstrapPlaylist = false; - if ( - playlistId && - (this.currentPlaylistId !== playlistId || needsPlaylistBootstrap) - ) { - if (this.currentPlaylistId !== playlistId || needsPlaylistBootstrap) { - this.xtreamStore.resetStore(playlistId); - this.currentPlaylistId = playlistId; - this.currentPlaylistUpdateDate = routePlaylistUpdateDate; - } + if (playlistId && shouldBootstrapPlaylist) { + this.xtreamStore.resetStore(playlistId); + didBootstrapPlaylist = true; this.xtreamStore.setCurrentPlaylist(routePlaylist); @@ -245,7 +263,10 @@ export class XtreamWorkspaceRouteSession { return; } - await this.initializeCurrentSectionContent(section); + await this.initializeCurrentSectionContent( + section, + didBootstrapPlaylist + ); } private syncRouteState( @@ -267,7 +288,8 @@ export class XtreamWorkspaceRouteSession { } private async initializeCurrentSectionContent( - section: PortalRailSection | null + section: PortalRailSection | null, + didBootstrapPlaylist: boolean ): Promise { const playlist = this.xtreamStore.currentPlaylist(); const playlistId = this.xtreamStore.playlistId(); @@ -277,7 +299,8 @@ export class XtreamWorkspaceRouteSession { } if ( - isImportDrivenSection(section) + isImportDrivenSection(section) && + (didBootstrapPlaylist || !this.xtreamStore.isContentInitialized()) ) { await this.xtreamStore.initializeContent(); }