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 c6124b6ad..4af30a411 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 @@ -18,7 +18,9 @@ describe('ParentalLockEnforcementService', () => { const lockedStalkerIds = new Set(); const parentalLock = { version: signal(0), + active: signal(false), registerBusyProbe: jest.fn(), + isXtreamCategoryLocked: jest.fn(() => false), isStalkerCategoryLocked: jest.fn( (_playlistId: string, _type: string, id: unknown) => id !== null && @@ -45,7 +47,12 @@ describe('ParentalLockEnforcementService', () => { reloadCategories: jest.fn(async () => undefined), reloadCachedContent: jest.fn(async () => undefined), refreshSearchResults: jest.fn(async () => undefined), - getCategoriesBySelectedType: jest.fn(() => [{ id: 7 }, { id: 8 }]), + withholdCatalog: jest.fn(), + clearSearchResults: jest.fn(), + getCategoriesBySelectedType: jest.fn(() => [ + { id: 7, xtream_id: 70 }, + { id: 8, xtream_id: 80 }, + ]), setSelectedItem: jest.fn(), setSelectedCategory: jest.fn(), }; @@ -55,6 +62,8 @@ describe('ParentalLockEnforcementService', () => { jest.clearAllMocks(); lockedStalkerIds.clear(); router.url = '/'; + parentalLock.active.set(false); + parentalLock.isXtreamCategoryLocked.mockReturnValue(false); stalkerStore.selectedCategoryId.set('*'); stalkerStore.selectedItem.set(null); xtreamStore.selectedCategoryId.set(null); @@ -164,6 +173,59 @@ describe('ParentalLockEnforcementService', () => { ]); }); + it('withholds the catalog and a locked detail before the reload on relock', async () => { + router.url = '/workspace/xtreams/xtream-1/vod/42'; + parentalLock.active.set(true); + parentalLock.isXtreamCategoryLocked.mockImplementation( + (_p: string, _t: string, providerId: number) => + providerId === 70 + ); + xtreamStore.selectedItem.set({ category_id: 7 }); + const order: string[] = []; + xtreamStore.withholdCatalog.mockImplementation(() => + order.push('withhold') + ); + xtreamStore.setSelectedItem.mockImplementation(() => + order.push('step-off') + ); + xtreamStore.reloadCategories.mockImplementation(async () => { + order.push('reload'); + }); + + await service.applyXtream(parentalLock.version()); + + expect(order.slice(0, 3)).toEqual([ + 'step-off', + 'withhold', + 'reload', + ]); + expect(xtreamStore.clearSearchResults).toHaveBeenCalled(); + expect(parentalLock.isXtreamCategoryLocked).toHaveBeenCalledWith( + 'xtream-1', + 'movies', + 70 + ); + expect(router.navigate).toHaveBeenCalledWith([ + '/workspace', + 'xtreams', + 'xtream-1', + 'vod', + ]); + }); + + it('does not withhold on unlock, and hands the reloads a publish guard', async () => { + router.url = '/workspace/xtreams/xtream-1/vod'; + + await service.applyXtream(parentalLock.version()); + + expect(xtreamStore.withholdCatalog).not.toHaveBeenCalled(); + const guard = xtreamStore.reloadCategories.mock.calls[0][0] as + (() => boolean) | undefined; + expect(guard?.()).toBe(true); + parentalLock.version.set(parentalLock.version() + 1); + expect(guard?.()).toBe(false); + }); + it('re-runs the stored in-portal search after the reload', async () => { router.url = '/workspace/xtreams/xtream-1/search'; @@ -224,6 +286,8 @@ describe('ParentalLockEnforcementService apply serialization', () => { provide: ParentalLockService, useValue: { version, + active: signal(true), + isXtreamCategoryLocked: jest.fn(() => false), registerBusyProbe: jest.fn(), isStalkerCategoryLocked: jest.fn(() => false), isM3uGroupLocked: jest.fn(() => false), 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 1e469d3b6..0fb352034 100644 --- a/apps/web/src/app/services/parental-lock-enforcement.service.ts +++ b/apps/web/src/app/services/parental-lock-enforcement.service.ts @@ -91,19 +91,31 @@ export class ParentalLockEnforcementService { if (!playlistId) { return; } - await this.xtreamStore.reloadCategories(); - await this.xtreamStore.reloadCachedContent(); - if (this.parentalLock.version() !== version) { + const shouldPublish = (): boolean => + this.parentalLock.version() === version; + const match = XTREAM_ROUTE.exec(this.router.url); + if (this.parentalLock.active()) { + // Relock: fail closed NOW, not after the database answers. The + // selected detail is judged against the lock store while the + // pre-reload category list can still map its category; the + // catalog lists and stored search results are emptied and + // refilled by the filtered reads below. + this.stepOffLockedXtreamSelection(playlistId, match); + this.xtreamStore.withholdCatalog?.(); + this.xtreamStore.clearSearchResults?.(); + } + await this.xtreamStore.reloadCategories(shouldPublish); + await this.xtreamStore.reloadCachedContent(shouldPublish); + if (!shouldPublish()) { return; } // Stored in-portal search results are a separate array the search // page renders directly; re-run the search so it reads filtered. await this.xtreamStore.refreshSearchResults?.(); - if (this.parentalLock.version() !== version) { + if (!shouldPublish()) { return; } - const match = XTREAM_ROUTE.exec(this.router.url); const categoryType = toParentalLockXtreamCategoryType(match?.[2]); const categories = this.xtreamStore.getCategoriesBySelectedType(); const isVisibleCategory = (categoryId: unknown): boolean => @@ -154,6 +166,56 @@ 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. + */ + private stepOffLockedXtreamSelection( + playlistId: string, + match: RegExpExecArray | null + ): void { + const categoryType = toParentalLockXtreamCategoryType(match?.[2]); + const selectedItem = this.xtreamStore.selectedItem?.() as { + category_id?: string | number; + } | null; + const categoryId = Number(selectedItem?.category_id); + if (!categoryType || !selectedItem || !Number.isFinite(categoryId)) { + return; + } + const category = this.xtreamStore + .getCategoriesBySelectedType() + .find( + (candidate) => + Number((candidate as { id?: number }).id) === categoryId || + Number( + (candidate as { category_id?: string }).category_id + ) === categoryId + ) as { xtream_id?: number; category_id?: string } | undefined; + const providerId = Number(category?.xtream_id ?? category?.category_id); + if ( + !Number.isFinite(providerId) || + !this.parentalLock.isXtreamCategoryLocked( + playlistId, + categoryType, + providerId + ) + ) { + return; + } + this.xtreamStore.setSelectedItem(null); + if (match && match[1] === playlistId) { + void this.router.navigate([ + '/workspace', + 'xtreams', + match[1], + match[2], + ]); + } + } + private async applyStalker(): Promise { const playlist = this.stalkerStore.currentPlaylist(); const playlistId = playlist?._id; diff --git a/apps/web/src/app/settings/settings-parental-lock.facade.ts b/apps/web/src/app/settings/settings-parental-lock.facade.ts index 2fd4a110f..096eab00e 100644 --- a/apps/web/src/app/settings/settings-parental-lock.facade.ts +++ b/apps/web/src/app/settings/settings-parental-lock.facade.ts @@ -54,7 +54,11 @@ export class SettingsParentalLockFacade { } async setRelockMinutes(minutes: ParentalLockRelockMinutes): Promise { - await this.run(() => this.parentalLock.setRelockMinutes(minutes)); + await this.run(async () => { + if (!(await this.parentalLock.setRelockMinutes(minutes))) { + this.snackbar.storageFailure('save'); + } + }); } lockNow(): void { diff --git a/docs/architecture/parental-lock.md b/docs/architecture/parental-lock.md index c35b90832..1572bc9b9 100644 --- a/docs/architecture/parental-lock.md +++ b/docs/architecture/parental-lock.md @@ -151,7 +151,14 @@ on either side. in-portal search (`XtreamStore.refreshSearchResults`, the last `searchContent` call as issued) — `searchResults` is a separate array the search page renders directly and would otherwise keep locked titles until - the query changes. Both reloads fail closed: a category reload that + the query changes. A RELOCK fails closed at once rather than after the + database answers: 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()` + 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 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 @@ -246,7 +253,10 @@ on either side. its candidates through the capability-selected data source (`IXtreamDataSource.getAllCategories`, which the PWA source answers from its session cache or the API), so PWA users can set locks too; the - hide/show checkboxes remain Electron-only. The M3U dialog hands its lock + hide/show checkboxes remain Electron-only. The relock timeout persists + through the same undo-on-failure pattern as the switch + (`setRelockMinutes` reverts the in-memory value and the facade shows the + settings save-failure snackbar). The M3U dialog hands its lock list back to `ChannelListContainerComponent`, which awaits the write and reports a failed save in a snackbar (the dialog has closed by then). On Electron the `categories.locked` re-stamp (`setCategoryLocks`) clears and @@ -268,6 +278,11 @@ on either side. ## Backup +M3U group titles travel verbatim (`normalizeParentalLockGroupTitles`, exact +dedup): locks match `channel.group.title` exactly, so the trimming +`uniqueStrings` used for favorites would weaken a lock on a title with +surrounding whitespace. + `lockedGroupTitles` (M3U), `lockedCategories` (Xtream `{categoryType, xtreamId}`, Stalker `{categoryType, categoryId}`) travel in each entry's `userState`, written only when the playlist has locks. Absent means "no 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 8972268ac..27513acd2 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 @@ -82,6 +82,27 @@ describe('withContent parental-lock reloads', () => { afterEach(() => localStorage.clear()); + it('withholds every catalog list at once and drops reads whose guard says no', async () => { + dataSource.getCategories.mockResolvedValue([{ category_id: 'x' }]); + dataSource.getContent.mockResolvedValue([{ xtream_id: 1 }]); + await store.initializeContent(); + expect(store.liveStreams()).toEqual([{ xtream_id: 1 }]); + + store.withholdCatalog(); + expect(store.liveCategories()).toEqual([]); + expect(store.vodStreams()).toEqual([]); + expect(store.serialStreams()).toEqual([]); + expect(store.contentLoadStateByType().live).toBe('ready'); + + await store.reloadCategories(() => false); + await store.reloadCachedContent(() => false); + expect(store.liveCategories()).toEqual([]); + expect(store.liveStreams()).toEqual([]); + + await store.reloadCachedContent(); + expect(store.liveStreams()).toEqual([{ xtream_id: 1 }]); + }); + 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 bed6d36a5..4c8865285 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 @@ -1461,7 +1461,32 @@ export function withContent() { /** * Reload categories from database (after visibility changes) */ - async reloadCategories(): Promise { + /** + * Empties every catalog list at once — the parental lock + * relocking must not leave rows read while unlocked on + * screen for the duration of the filtered reload. Load + * states and import bookkeeping are untouched; the reload + * refills the lists. + */ + withholdCatalog(): void { + patchState(store, { + liveCategories: [], + vodCategories: [], + serialCategories: [], + liveStreams: [], + vodStreams: [], + serialStreams: [], + }); + }, + + /** + * @param shouldPublish answered right before each state + * patch; false drops the read (the caller's lock version + * moved on while it was in flight). + */ + async reloadCategories( + shouldPublish: () => boolean = () => true + ): Promise { const ctx = getCredentialsFromStore(); if (!ctx) return; @@ -1484,6 +1509,7 @@ export function withContent() { ), ]); + if (!shouldPublish()) return; patchState(store, { liveCategories: live, vodCategories: vod, @@ -1496,6 +1522,7 @@ export function withContent() { // keep locked category names visible. They are // rebuilt by the next category load. logger.error('Error reloading categories', error); + if (!shouldPublish()) return; patchState(store, { liveCategories: [], vodCategories: [], @@ -1511,7 +1538,9 @@ export function withContent() { * the PWA data source filter locked categories at read time, * so the in-memory catalog must be rebuilt from them. */ - async reloadCachedContent(): Promise { + async reloadCachedContent( + shouldPublish: () => boolean = () => true + ): Promise { const ctx = getCredentialsFromStore(); if (!ctx || !store.isContentInitialized()) { return; @@ -1530,9 +1559,11 @@ export function withContent() { ctx.credentials, 'live' )) as XtreamLiveStream[]; + if (!shouldPublish()) return; patchState(store, { liveStreams: live }); } catch (error) { logger.error('Error reloading live streams', error); + if (!shouldPublish()) return; patchState(store, { liveStreams: [] }); failed.push('live'); } @@ -1544,12 +1575,14 @@ export function withContent() { ctx.credentials, 'movie' )) as XtreamVodStream[]; + if (!shouldPublish()) return; patchState(store, { vodStreams: vod, vodStreamsPlaylistId: ctx.playlistId, }); } catch (error) { logger.error('Error reloading VOD streams', error); + if (!shouldPublish()) return; patchState(store, { vodStreams: [] }); failed.push('vod'); } @@ -1561,9 +1594,11 @@ export function withContent() { ctx.credentials, 'series' )) as XtreamSerieItem[]; + if (!shouldPublish()) return; patchState(store, { serialStreams: series }); } catch (error) { logger.error('Error reloading series', error); + if (!shouldPublish()) return; patchState(store, { serialStreams: [] }); failed.push('series'); } diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-search.feature.spec.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-search.feature.spec.ts index 08d5fb9fe..21755fe08 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-search.feature.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-search.feature.spec.ts @@ -149,6 +149,18 @@ describe('withSearch refreshSearchResults', () => { expect(store.searchResults()).toEqual([]); }); + it('clears stored results without forgetting the last search', async () => { + await store.searchContent('news', ['live']); + expect(store.searchResults()).toHaveLength(1); + + store.clearSearchResults(); + expect(store.searchResults()).toEqual([]); + + await store.refreshSearchResults(); + expect(searchContent).toHaveBeenCalledTimes(2); + expect(store.searchResults()).toHaveLength(1); + }); + it('does nothing without a previous search or after a reset', async () => { await store.refreshSearchResults(); expect(searchContent).not.toHaveBeenCalled(); diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-search.feature.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-search.feature.ts index db30b2fa4..765beed16 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-search.feature.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-search.feature.ts @@ -161,6 +161,11 @@ export function withSearch() { } }, + /** Drops the stored results; term, filters and the last search stay. */ + clearSearchResults(): void { + patchState(store, { searchResults: [] }); + }, + /** * Re-runs the last in-portal search with its own parameters, * so stored results reflect the current read filters (the diff --git a/libs/services/src/lib/parental-lock/parental-lock.service.spec.ts b/libs/services/src/lib/parental-lock/parental-lock.service.spec.ts index e4bbd0b1e..49756dd88 100644 --- a/libs/services/src/lib/parental-lock/parental-lock.service.spec.ts +++ b/libs/services/src/lib/parental-lock/parental-lock.service.spec.ts @@ -226,6 +226,21 @@ describe('ParentalLockService', () => { expect(updateBridgeSettings).not.toHaveBeenCalled(); }); + it('rolls the relock timeout back when it cannot be persisted', async () => { + updateSettings.mockImplementationOnce(async () => { + parentalLockRelockMinutes.set(30); + throw new Error('QuotaExceededError'); + }); + const service = await createService(); + + await expect(service.setRelockMinutes(30)).resolves.toBe(false); + + expect(service.relockMinutes()).toBe(15); + expect(updateSettings).toHaveBeenLastCalledWith({ + parentalLockRelockMinutes: 15, + }); + }); + it('rolls the switch back when the Electron mirror cannot be written', async () => { prompt.requestPin.mockResolvedValue('1234'); updateBridgeSettings.mockRejectedValueOnce(new Error('ipc')); diff --git a/libs/services/src/lib/parental-lock/parental-lock.service.ts b/libs/services/src/lib/parental-lock/parental-lock.service.ts index d19c282e1..496484564 100644 --- a/libs/services/src/lib/parental-lock/parental-lock.service.ts +++ b/libs/services/src/lib/parental-lock/parental-lock.service.ts @@ -330,11 +330,26 @@ export class ParentalLockService { return pin !== null; } - async setRelockMinutes(minutes: number): Promise { - await this.settingsStore.updateSettings({ - parentalLockRelockMinutes: - normalizeParentalLockRelockMinutes(minutes), - }); + /** + * False when the value could not be persisted; the in-memory patch + * `updateSettings` applied before the failed write is undone so the + * timer on screen never differs from the one the next launch uses. + */ + async setRelockMinutes(minutes: number): Promise { + const previous = this.relockMinutes(); + try { + await this.settingsStore.updateSettings({ + parentalLockRelockMinutes: + normalizeParentalLockRelockMinutes(minutes), + }); + return true; + } catch (error) { + console.error('Failed to persist the relock timeout.', error); + await this.settingsStore + .updateSettings({ parentalLockRelockMinutes: previous }) + .catch(() => undefined); + return false; + } } // -- Lock store (ParentalLockLockStore; predicates add `active`) ------- diff --git a/libs/services/src/lib/playlist-backup.service.ts b/libs/services/src/lib/playlist-backup.service.ts index ba7e2878a..02631ed0e 100644 --- a/libs/services/src/lib/playlist-backup.service.ts +++ b/libs/services/src/lib/playlist-backup.service.ts @@ -26,6 +26,7 @@ import { XtreamBackupSourcePin, XtreamPendingRestoreState, createRandomId, + normalizeParentalLockGroupTitles, normalizeParentalLockStalkerCategories, normalizeParentalLockXtreamCategories, ParentalLockPlaylistLocks, @@ -1059,7 +1060,10 @@ export class PlaylistBackupService { private optionalLockedGroupTitles(playlistId: string): { lockedGroupTitles?: string[]; } { - const locked = this.uniqueStrings( + // Exact titles: M3U locks match `channel.group.title` verbatim, so + // the trimming `uniqueStrings` would turn a lock on " Adult " into + // one on "Adult" and weaken the restored lock. + const locked = normalizeParentalLockGroupTitles( this.parentalLock.locksFor(playlistId).m3u ); return locked.length > 0 ? { lockedGroupTitles: locked } : {}; @@ -1080,7 +1084,10 @@ export class PlaylistBackupService { if (entry.portalType === 'm3u') { const titles = entry.userState.lockedGroupTitles; if (Array.isArray(titles)) { - next = { ...current, m3u: this.uniqueStrings(titles) }; + next = { + ...current, + m3u: normalizeParentalLockGroupTitles(titles), + }; } } else if (entry.portalType === 'xtream') { const categories = entry.userState.lockedCategories; diff --git a/libs/shared/interfaces/src/lib/parental-lock.util.spec.ts b/libs/shared/interfaces/src/lib/parental-lock.util.spec.ts index 50b57cc10..5fbcec576 100644 --- a/libs/shared/interfaces/src/lib/parental-lock.util.spec.ts +++ b/libs/shared/interfaces/src/lib/parental-lock.util.spec.ts @@ -1,5 +1,6 @@ import { isParentalLockPlaylistLocksEmpty, + normalizeParentalLockGroupTitles, isWellFormedParentalLockStore, lockedStalkerCategoryIds, lockedXtreamCategoryIds, @@ -147,3 +148,11 @@ describe('isWellFormedParentalLockStore', () => { expect(isWellFormedParentalLockStore(value)).toBe(false); }); }); + +describe('normalizeParentalLockGroupTitles', () => { + it('deduplicates exactly, keeping surrounding whitespace', () => { + expect( + normalizeParentalLockGroupTitles([' Adult ', ' Adult ', 'Adult', 7]) + ).toEqual([' Adult ', 'Adult']); + }); +});