From b5be8b2773a2e1c61e2ab168fd7ecd46ec6c3d01 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 26 Sep 2026 08:17:52 +0200 Subject: [PATCH] fix(settings): keep unlocked category routes reachable and defer a relock reload that overtakes the initial hydration - The Xtream category guard no longer runs the item check on category-only routes (Number(null) is 0), which prompted for the PIN on every unlocked VOD and series category while the lock was active. - A lock change during the initial Xtream hydration withholds the rows the hydration publishes and runs the filtered reload once it has settled, on every path that marks the content initialized. Co-Authored-By: Claude Fable 5.1 --- docs/architecture/parental-lock.md | 8 ++- .../with-content.feature.reload.spec.ts | 34 ++++++++++++ .../stores/features/with-content.feature.ts | 53 +++++++++++++++++-- .../lib/parental-lock-category.guard.spec.ts | 8 +++ .../src/lib/parental-lock-category.guard.ts | 15 ++++-- 5 files changed, 109 insertions(+), 9 deletions(-) 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,