diff --git a/apps/web/src/app/services/parental-lock-enforcement.service.spec.ts b/apps/web/src/app/services/parental-lock-enforcement.service.spec.ts index 33b3df79c..a9126df48 100644 --- a/apps/web/src/app/services/parental-lock-enforcement.service.spec.ts +++ b/apps/web/src/app/services/parental-lock-enforcement.service.spec.ts @@ -313,6 +313,34 @@ describe('ParentalLockEnforcementService', () => { expect(guard?.()).toBe(false); }); + it('abandons the reloads and checks once the Xtream playlist is switched', async () => { + router.url = '/workspace/xtreams/xtream-1/vod/7'; + parentalLock.active.set(true); + xtreamStore.selectedCategoryId.set(7); + parentalLock.isXtreamCategoryLocked.mockReturnValue(true); + let guard: () => boolean = () => true; + (xtreamStore.reloadCategories as jest.Mock).mockImplementationOnce( + async (shouldPublish: () => boolean) => { + guard = shouldPublish; + xtreamStore.playlistId.set('xtream-2'); + router.url = '/workspace/xtreams/xtream-2/vod'; + } + ); + + try { + await service.applyXtream(parentalLock.version()); + expect(guard()).toBe(false); + expect(xtreamStore.refreshSearchResults).not.toHaveBeenCalled(); + expect( + xtreamDataSource.getAllCategories + ).not.toHaveBeenCalled(); + expect(xtreamStore.setSelectedCategory).not.toHaveBeenCalled(); + expect(router.navigate).not.toHaveBeenCalled(); + } finally { + xtreamStore.playlistId.set('xtream-1'); + } + }); + it('re-runs the stored in-portal search after the reload', async () => { router.url = '/workspace/xtreams/xtream-1/search'; diff --git a/apps/web/src/app/services/parental-lock-enforcement.service.ts b/apps/web/src/app/services/parental-lock-enforcement.service.ts index 8bd7c355f..1afde7747 100644 --- a/apps/web/src/app/services/parental-lock-enforcement.service.ts +++ b/apps/web/src/app/services/parental-lock-enforcement.service.ts @@ -132,8 +132,12 @@ export class ParentalLockEnforcementService { if (!playlistId) { return; } + // A playlist switch retires the apply as a newer lock version + // does: the store now holds another portal, whose own load reads + // under the current lock state. const shouldPublish = (): boolean => - this.parentalLock.version() === version; + this.parentalLock.version() === version && + this.xtreamStore.playlistId?.() === playlistId; await this.xtreamStore.reloadCategories(shouldPublish); await this.xtreamStore.reloadCachedContent(shouldPublish); if (!shouldPublish()) { diff --git a/docs/architecture/parental-lock.md b/docs/architecture/parental-lock.md index e8d1f0315..1b31fb7da 100644 --- a/docs/architecture/parental-lock.md +++ b/docs/architecture/parental-lock.md @@ -189,7 +189,11 @@ on either side. (retiring a search still in flight, which was issued under the previous lock state) before the filtered reads refill them; both reloads take a publish guard answered before every state patch, so a read issued under an older lock - version is dropped instead of published. The post-reload checks of the + version is dropped instead of published. The guard also turns false once + another Xtream playlist is open (the store is a singleton), and the store + itself refuses to publish a reload into a playlist it was not read for; + the post-reload search refresh and selection checks are skipped then + too. The post-reload checks of the selected category and item decide by the LOCK STORE through the unfiltered category rows (`IXtreamDataSource.getAllCategories`), not by absence from the reloaded list, which also omits categories the user diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.reload.spec.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.reload.spec.ts index e0ddbffd0..19a17695e 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.reload.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.reload.spec.ts @@ -243,4 +243,35 @@ describe('withContent parental-lock reloads', () => { series: 'ready', }); }); + + it('drops reloads whose playlist was switched away while they read', async () => { + dataSource.getCategories.mockResolvedValue([{ category_id: 'a' }]); + dataSource.getContent.mockResolvedValue([{ xtream_id: 1 }]); + await store.initializeContent(); + + const categories = createDeferred(); + const content = createDeferred(); + dataSource.getCategories.mockReturnValue(categories.promise); + dataSource.getContent.mockImplementation( + (_playlistId: string, _credentials: unknown, type: ContentType) => + type === 'live' + ? content.promise + : Promise.reject(new Error('db')) + ); + const reloadingCategories = store.reloadCategories(); + const reloadingContent = store.reloadCachedContent(); + store.switchPlaylist('playlist-b'); + categories.resolve([{ category_id: 'stale' }]); + content.resolve([{ xtream_id: 99 }]); + await reloadingCategories; + await reloadingContent; + + expect(store.liveCategories()).toEqual([{ category_id: 'a' }]); + expect(store.liveStreams()).toEqual([{ xtream_id: 1 }]); + expect(store.contentLoadStateByType()).toEqual({ + live: 'ready', + vod: 'ready', + series: 'ready', + }); + }); }); diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.spec-helpers.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.spec-helpers.ts index 27b3d631e..1e21f5a98 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.spec-helpers.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.spec-helpers.ts @@ -114,6 +114,10 @@ export function createContentTestStore( patchState(store, { portalStatus: status }); return status; }, + /** Simulates the user opening another playlist. */ + switchPlaylist(playlistId: string): void { + patchState(store, { playlistId }); + }, })), withContent() ); diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts index d9cd79efd..9ed5a7bb0 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts @@ -259,6 +259,15 @@ export function withContent() { Promise >(); + // A reload publishes only while its playlist is still the open + // one: the store is shared, and rows read for playlist A must + // not land in it after the user switched to playlist B. + const publishWhileCurrent = + (playlistId: string, shouldPublish: () => boolean) => + (): boolean => + getPortalStore().playlistId?.() === playlistId && + shouldPublish(); + const getCachedHydrationKey = ( playlistId: string, scope?: XtreamCachedContentScope | null @@ -1547,10 +1556,14 @@ export function withContent() { * moved on while it was in flight). */ async reloadCategories( - shouldPublish: () => boolean = () => true + shouldPublishArg: () => boolean = () => true ): Promise { const ctx = getCredentialsFromStore(); if (!ctx) return; + const shouldPublish = publishWhileCurrent( + ctx.playlistId, + shouldPublishArg + ); try { const [live, vod, series] = await Promise.all([ @@ -1601,7 +1614,7 @@ export function withContent() { * so the in-memory catalog must be rebuilt from them. */ async reloadCachedContent( - shouldPublish: () => boolean = () => true + shouldPublishArg: () => boolean = () => true ): Promise { const ctx = getCredentialsFromStore(); if (!ctx) { @@ -1612,9 +1625,13 @@ export function withContent() { // read under the previous lock state: withhold those // and reload once it has settled. reloadAfterInitialization = true; - deferredPublishGuard = shouldPublish; + deferredPublishGuard = shouldPublishArg; return; } + const shouldPublish = publishWhileCurrent( + ctx.playlistId, + shouldPublishArg + ); const loadStates = store.contentLoadStateByType(); // Each type on its own: one failing read must neither // skip the remaining types nor keep its own rows, which @@ -1673,7 +1690,7 @@ export function withContent() { failed.push('series'); } } - if (failed.length > 0) { + if (failed.length > 0 && shouldPublish()) { const next = { ...store.contentLoadStateByType() }; for (const type of failed) { next[type] = 'idle';