From 02f5b0920136fd701d5c10b87c3d5ebe890c2a0d Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 26 Sep 2026 06:21:18 +0200 Subject: [PATCH] fix(settings): withhold the Xtream catalog at relock time, keep exact M3U titles in backups, roll back a failed relock-timeout save - A relock now fails closed immediately: the selected detail is stepped off against the lock store, the catalog lists and stored search results are emptied, and the filtered reloads publish only while the captured lock version is still current. - Backups carry M3U lock titles verbatim (exact dedup), since the locks match group titles exactly. - A relock-timeout write that fails reverts the in-memory value and shows the settings save-failure snackbar. Co-Authored-By: Claude Fable 5.1 --- .../parental-lock-enforcement.service.spec.ts | 66 ++++++++++++++++- .../parental-lock-enforcement.service.ts | 72 +++++++++++++++++-- .../settings/settings-parental-lock.facade.ts | 6 +- docs/architecture/parental-lock.md | 19 ++++- .../with-content.feature.reload.spec.ts | 21 ++++++ .../stores/features/with-content.feature.ts | 39 +++++++++- .../features/with-search.feature.spec.ts | 12 ++++ .../stores/features/with-search.feature.ts | 5 ++ .../parental-lock.service.spec.ts | 15 ++++ .../parental-lock/parental-lock.service.ts | 25 +++++-- .../src/lib/playlist-backup.service.ts | 11 ++- .../src/lib/parental-lock.util.spec.ts | 9 +++ 12 files changed, 282 insertions(+), 18 deletions(-) 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']); + }); +});