diff --git a/docs/architecture/parental-lock.md b/docs/architecture/parental-lock.md index 4aba07f10..4eaac5553 100644 --- a/docs/architecture/parental-lock.md +++ b/docs/architecture/parental-lock.md @@ -167,7 +167,13 @@ on either side. empties every catalog list and `clearSearchResults()` the stored search 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. Both reloads fail closed: a category reload that + version is dropped instead of published. A lock change that overtakes + the INITIAL hydration (the content is not initialized yet, so the reload + has nothing to re-read) sets a deferred-reload flag instead: the + hydration then publishes empty lists in place of the rows it read under + the previous lock state, and the filtered reload runs as soon as the + hydration settles, on every path that marks the content initialized. + Both reloads fail closed: a category reload that rejects empties the three category lists, and a per-type content reload that rejects empties that type and sets it back to `idle` so the next visit loads it again (filtered) — rows read under the previous lock state 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 27513acd2..6f61dfdbf 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 @@ -3,7 +3,9 @@ import { PortalStatusType } from '../../xtream-state'; import { createContentTestProviders, createContentTestStore, + createDeferred, createPendingRestoreServiceMock, + waitForCondition, } from './with-content.feature.spec-helpers'; jest.mock('@iptvnator/portal/shared/util', () => ({ @@ -103,6 +105,38 @@ describe('withContent parental-lock reloads', () => { expect(store.liveStreams()).toEqual([{ xtream_id: 1 }]); }); + it('withholds rows published by a hydration a relock overtook and reloads them afterwards', async () => { + const live = createDeferred(); + let calls = 0; + dataSource.getContent.mockImplementation(() => { + calls += 1; + if (calls === 1) { + // The hydration's live read is held open; the relock lands + // while it is in flight. + return live.promise; + } + // Reads 2-3 still belong to the hydration, later ones to the + // deferred filtered reload. + return Promise.resolve( + calls <= 3 ? [{ xtream_id: 1 }] : [{ xtream_id: 2 }] + ); + }); + const initialization = store.initializeContent(); + await waitForCondition(() => calls === 1); + + await store.reloadCachedContent(); + expect(calls).toBe(1); + + live.resolve([{ xtream_id: 1 }]); + await initialization; + + expect(calls).toBe(6); + expect(store.liveStreams()).toEqual([{ xtream_id: 2 }]); + expect(store.vodStreams()).toEqual([{ xtream_id: 2 }]); + expect(store.serialStreams()).toEqual([{ xtream_id: 2 }]); + expect(store.isContentInitialized()).toBe(true); + }); + it('empties the category lists when their reload fails', async () => { dataSource.getCategories.mockResolvedValue([{ category_id: 'x' }]); await store.reloadCategories(); 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 4c8865285..f298d216d 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 @@ -219,6 +219,20 @@ export function withContent() { withMethods((store) => { const dataSource = inject(XTREAM_DATA_SOURCE); + /** + * A filtered reload was requested (parental lock changed) while + * the initial hydration was still publishing rows it read under + * the previous lock state: those publications are withheld and + * the reload runs once the hydration has settled. + */ + let reloadAfterInitialization = false; + const runDeferredReload = async (): Promise => { + if (!reloadAfterInitialization) { + return; + } + reloadAfterInitialization = false; + await methods.reloadCachedContent(); + }; const dataService = inject(DataService); const databaseService = inject(DatabaseService); const pendingRestoreService = inject(XtreamPendingRestoreService); @@ -630,8 +644,22 @@ export function withContent() { } updates.contentLoadStateByType = nextLoadStates; + if (reloadAfterInitialization) { + // Read before the lock changed; the deferred reload + // below publishes the filtered rows instead. + for (const key of [ + 'liveStreams', + 'vodStreams', + 'serialStreams', + ] as const) { + if (key in updates) { + updates[key] = []; + } + } + } return updates; }); + await runDeferredReload(); }; const hydrateCachedContentForScope = async ( @@ -652,6 +680,7 @@ export function withContent() { isPendingRestoreBlocked: false, contentInitBlockReason: null, }); + await runDeferredReload(); return; } @@ -999,6 +1028,9 @@ export function withContent() { isContentInitialized: true, contentInitBlockReason: null, }); + // A lock change during the hydration withheld the rows + // it published; read them again under the current lock. + await runDeferredReload(); } catch (error) { if (store.isImporting()) { await finalizePendingImportTypes( @@ -1222,7 +1254,9 @@ export function withContent() { RENDERER_PERFORMANCE_PHASE.XTREAM_PUBLISH_LIVE, () => patchState(store, { - liveStreams: live, + liveStreams: reloadAfterInitialization + ? [] + : live, }), () => ({ items: live.length }) ); @@ -1259,7 +1293,9 @@ export function withContent() { RENDERER_PERFORMANCE_PHASE.XTREAM_PUBLISH_VOD, () => patchState(store, { - vodStreams: vod, + vodStreams: reloadAfterInitialization + ? [] + : vod, vodStreamsPlaylistId: ctx.playlistId, }), () => ({ items: vod.length }) @@ -1298,7 +1334,9 @@ export function withContent() { RENDERER_PERFORMANCE_PHASE.XTREAM_PUBLISH_SERIES, () => patchState(store, { - serialStreams: series, + serialStreams: reloadAfterInitialization + ? [] + : series, isLoadingContent: false, }), () => ({ items: series.length }) @@ -1542,7 +1580,14 @@ export function withContent() { shouldPublish: () => boolean = () => true ): Promise { const ctx = getCredentialsFromStore(); - if (!ctx || !store.isContentInitialized()) { + if (!ctx) { + return; + } + if (!store.isContentInitialized()) { + // The initial hydration is still publishing rows it + // read under the previous lock state: withhold those + // and reload once it has settled. + reloadAfterInitialization = true; return; } const loadStates = store.contentLoadStateByType(); diff --git a/libs/portal/xtream/feature/src/lib/parental-lock-category.guard.spec.ts b/libs/portal/xtream/feature/src/lib/parental-lock-category.guard.spec.ts index 59cdc042d..6ea70ad57 100644 --- a/libs/portal/xtream/feature/src/lib/parental-lock-category.guard.spec.ts +++ b/libs/portal/xtream/feature/src/lib/parental-lock-category.guard.spec.ts @@ -127,6 +127,14 @@ describe('parentalLockXtreamCategoryGuard', () => { expect(parentalLock.requestUnlock).not.toHaveBeenCalled(); }); + it('lets an unlocked category-only route through without an item check', async () => { + await expect(run('vod', '13')).resolves.toBe(true); + await expect(run('series', '13')).resolves.toBe(true); + + expect(dataSource.getContentByXtreamId).not.toHaveBeenCalled(); + expect(parentalLock.requestUnlock).not.toHaveBeenCalled(); + }); + it('passes through while the lock is inactive without touching the database', async () => { parentalLock.active.mockReturnValue(false); diff --git a/libs/portal/xtream/feature/src/lib/parental-lock-category.guard.ts b/libs/portal/xtream/feature/src/lib/parental-lock-category.guard.ts index f5652ed7b..c569d2d34 100644 --- a/libs/portal/xtream/feature/src/lib/parental-lock-category.guard.ts +++ b/libs/portal/xtream/feature/src/lib/parental-lock-category.guard.ts @@ -95,10 +95,17 @@ export function parentalLockXtreamCategoryGuard( let locked = Number.isFinite(routeCategoryId) && isLocked(routeCategoryId); - const itemId = Number( - route.paramMap.get('vodId') ?? route.paramMap.get('serialId') - ); - if (!locked && section !== 'live' && Number.isFinite(itemId)) { + // Category-only routes carry no item: `Number(null)` would be 0 and + // send every unlocked category through the (failing) item check. + const rawItemId = + route.paramMap.get('vodId') ?? route.paramMap.get('serialId'); + const itemId = rawItemId === null ? null : Number(rawItemId); + if ( + !locked && + section !== 'live' && + itemId !== null && + Number.isFinite(itemId) + ) { const contentType = section === 'vod' ? 'movie' : 'series'; let item = await dataSource.getContentByXtreamId( itemId,