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 a9126df48..013cfe1d5 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 @@ -349,6 +349,33 @@ describe('ParentalLockEnforcementService', () => { expect(xtreamStore.refreshSearchResults).toHaveBeenCalled(); }); + it('clears on relock, synchronously, a detail whose category is not in the visible list', () => { + // Opened through search from a manually hidden category: the + // on-screen list cannot place it, and the unfiltered lookup is + // an awaited read that may hang. + router.url = '/workspace/xtreams/xtream-1/vod/55/900'; + xtreamStore.selectedItem.set({ category_id: 55 }); + + service.failClosedNow(); + + expect(xtreamStore.setSelectedItem).toHaveBeenCalledWith(null); + expect(router.navigate).toHaveBeenCalledWith([ + '/workspace', + 'xtreams', + 'xtream-1', + 'vod', + ]); + }); + + it('keeps a detail on relock whose visible category is not locked', () => { + router.url = '/workspace/xtreams/xtream-1/vod/7/900'; + xtreamStore.selectedItem.set({ category_id: 7 }); + + service.failClosedNow(); + + expect(xtreamStore.setSelectedItem).not.toHaveBeenCalled(); + }); + it('fails closed synchronously on relock: detail, catalog and search', () => { router.url = '/workspace/xtreams/xtream-1/vod/42'; lockProvider(70); 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 1afde7747..5d005e2dd 100644 --- a/apps/web/src/app/services/parental-lock-enforcement.service.ts +++ b/apps/web/src/app/services/parental-lock-enforcement.service.ts @@ -223,8 +223,10 @@ export class ParentalLockEnforcementService { * Clears a selected Xtream item whose category the lock store already * says is locked. Electron rows carry the SQLite category row id; the * category list still on screen maps it to the provider id the store is - * keyed by, the PWA carries the provider id directly. An item that - * cannot be placed is left to the post-reload check. + * keyed by, the PWA carries the provider id directly. An item whose + * category is not in that list (a manually hidden category, opened + * through search) cannot be judged without an awaited read that may + * hang, so it is cleared too: fail closed. */ private stepOffLockedXtreamSelection( playlistId: string, @@ -249,7 +251,8 @@ export class ParentalLockEnforcementService { ) as { xtream_id?: number; category_id?: string } | undefined; const providerId = Number(category?.xtream_id ?? category?.category_id); if ( - !Number.isFinite(providerId) || + category && + Number.isFinite(providerId) && !this.parentalLock.isXtreamCategoryLocked( playlistId, categoryType, diff --git a/docs/architecture/parental-lock.md b/docs/architecture/parental-lock.md index 31a83928b..d1621a26a 100644 --- a/docs/architecture/parental-lock.md +++ b/docs/architecture/parental-lock.md @@ -188,7 +188,9 @@ on either side. database answers — ahead of the serialized apply queue, so an earlier apply still waiting on a slow or hung read cannot delay it: the selected detail is stepped off synchronously when the lock store already names its category (the pre-reload category list - maps Electron's row id to the provider id), then `withholdCatalog()` + maps Electron's row id to the provider id) or when that list cannot place + it at all (a manually hidden category opened through search), then + `withholdCatalog()` empties every catalog list and `clearSearchResults()` the stored search (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