From 4b14c0929c14480e334bf4c240d471bf191f8327 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 26 Sep 2026 04:20:06 +0200 Subject: [PATCH] fix(settings): close the startup, re-stamp, relock-refresh and switch-persistence gaps - The window before the initial lock store read settles now withholds everything, like an unreadable store: settings can report the feature as on before the locks are known. - The store commits before the SQLite index re-stamp; a failed re-stamp now rolls the store back, a failed rollback re-stamps on the next access, and every launch re-derives the index from the store. - Xtream category/content reloads fail closed: a rejected reload empties the affected lists (content types drop back to idle) instead of keeping rows read under the previous lock state. - Enabling/disabling the feature persists through one guarded path that undoes the in-memory switch and skips the Electron mirror on a failed settings write. Co-Authored-By: Claude Fable 5.1 --- docs/architecture/parental-lock.md | 21 +++- .../features/with-content.feature.spec.ts | 40 +++++++ .../stores/features/with-content.feature.ts | 48 +++++++-- .../parental-lock-lock-store.service.spec.ts | 100 ++++++++++++++++++ .../parental-lock-lock-store.service.ts | 91 ++++++++++++++-- .../parental-lock.service.spec.ts | 40 +++++++ .../parental-lock/parental-lock.service.ts | 46 +++++--- 7 files changed, 355 insertions(+), 31 deletions(-) create mode 100644 libs/services/src/lib/parental-lock/parental-lock-lock-store.service.spec.ts diff --git a/docs/architecture/parental-lock.md b/docs/architecture/parental-lock.md index 4535847c4..f5419ce4f 100644 --- a/docs/architecture/parental-lock.md +++ b/docs/architecture/parental-lock.md @@ -115,6 +115,13 @@ content and search, the M3U channel list) receive is entered or the store reads again (`requestUnlock` and every lock write retry the read first, and a write is refused while it still fails, since it would be built on an empty in-memory store and wipe the persisted locks). +The window before the initial read settles is treated the same way +(`ParentalLockLockStore.readable` is false until then): settings can report +the feature as on before the locks are known. The feature switch itself is +persisted through one guarded path (`persistEnabled`): `updateSettings` +patches memory before it writes, so a failed write is undone in memory, the +Electron mirror is left untouched and `setupPin`/`disable` report false — +the toggle never shows a state the next launch will not have. ### In-memory catalogs @@ -137,7 +144,11 @@ would be built on an empty in-memory store and wipe the persisted locks). 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. Live playback is stopped by the layout itself: + the query changes. 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 + are never kept. Live playback is stopped by the layout itself: `LiveStreamLayoutComponent` keeps the playing channel's provider category id (mapped from the SQLite row id on Electron) and drops `activePlayback` on a `version` change that locks it, because the player is gated on that @@ -233,7 +244,13 @@ would be built on an empty in-memory store and wipe the persisted locks). reports a failed save in a snackbar (the dialog has closed by then). On Electron the `categories.locked` re-stamp (`setCategoryLocks`) clears and re-locks one playlist/type inside ONE transaction, so a failed restamp - keeps the previous index instead of leaving every category unlocked. + keeps the previous index instead of leaving every category unlocked. The + store commits BEFORE that re-stamp, so a failed re-stamp rolls the store + back to what the index reflects (a category must not be recorded and + shown as locked while Electron reads, which filter by the index alone, + still serve it); if the rollback write fails too, the playlist is + re-stamped on the next store access, and every launch re-derives the + index from the store for each playlist that has locks. - Header lock/unlock button and the `parental-lock-now` / `parental-unlock` palette commands. diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.spec.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.spec.ts index 6dd3ec5c5..f49fc73e2 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.spec.ts @@ -462,6 +462,46 @@ describe('withContent import state', () => { expect(store.isContentInitialized()).toBe(true); }); + it('empties the category lists when their reload fails', async () => { + dataSource.getCategories.mockResolvedValue([{ category_id: 'x' }]); + await store.reloadCategories(); + expect(store.liveCategories()).toEqual([{ category_id: 'x' }]); + + dataSource.getCategories.mockRejectedValue(new Error('db')); + await store.reloadCategories(); + + expect(store.liveCategories()).toEqual([]); + expect(store.vodCategories()).toEqual([]); + expect(store.serialCategories()).toEqual([]); + }); + + it('empties a type whose cached reload fails and lets the others reload', async () => { + dataSource.getContent.mockResolvedValue([{ xtream_id: 1 }]); + await store.initializeContent(); + expect(store.contentLoadStateByType()).toEqual({ + live: 'ready', + vod: 'ready', + series: 'ready', + }); + + dataSource.getContent.mockImplementation( + (_playlistId: string, _credentials: unknown, type: ContentType) => + type === 'movie' + ? Promise.reject(new Error('db')) + : Promise.resolve([{ xtream_id: 2 }]) + ); + await store.reloadCachedContent(); + + expect(store.vodStreams()).toEqual([]); + expect(store.liveStreams()).toEqual([{ xtream_id: 2 }]); + expect(store.serialStreams()).toEqual([{ xtream_id: 2 }]); + expect(store.contentLoadStateByType()).toEqual({ + live: 'ready', + vod: 'idle', + series: 'ready', + }); + }); + it('records the VOD category owner when categories are reloaded', async () => { dataSource.getCategories.mockImplementation( ( 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 3cb54e059..bed6d36a5 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 @@ -1491,7 +1491,16 @@ export function withContent() { serialCategories: series, }); } catch (error) { + // Fail closed: the lists on screen were read under + // the previous lock state, so keeping them would + // keep locked category names visible. They are + // rebuilt by the next category load. logger.error('Error reloading categories', error); + patchState(store, { + liveCategories: [], + vodCategories: [], + serialCategories: [], + }); } }, @@ -1508,16 +1517,28 @@ export function withContent() { return; } const loadStates = store.contentLoadStateByType(); - try { - if (loadStates.live === 'ready') { + // Each type on its own: one failing read must neither + // skip the remaining types nor keep its own rows, which + // were read under the previous lock state. A failed type + // is emptied and set back to `idle`, so the next visit + // loads it again (filtered) instead of showing a gap. + const failed: ContentType[] = []; + if (loadStates.live === 'ready') { + try { const live = (await dataSource.getContent( ctx.playlistId, ctx.credentials, 'live' )) as XtreamLiveStream[]; patchState(store, { liveStreams: live }); + } catch (error) { + logger.error('Error reloading live streams', error); + patchState(store, { liveStreams: [] }); + failed.push('live'); } - if (loadStates.vod === 'ready') { + } + if (loadStates.vod === 'ready') { + try { const vod = (await dataSource.getContent( ctx.playlistId, ctx.credentials, @@ -1527,17 +1548,32 @@ export function withContent() { vodStreams: vod, vodStreamsPlaylistId: ctx.playlistId, }); + } catch (error) { + logger.error('Error reloading VOD streams', error); + patchState(store, { vodStreams: [] }); + failed.push('vod'); } - if (loadStates.series === 'ready') { + } + if (loadStates.series === 'ready') { + try { const series = (await dataSource.getContent( ctx.playlistId, ctx.credentials, 'series' )) as XtreamSerieItem[]; patchState(store, { serialStreams: series }); + } catch (error) { + logger.error('Error reloading series', error); + patchState(store, { serialStreams: [] }); + failed.push('series'); } - } catch (error) { - logger.error('Error reloading cached content', error); + } + if (failed.length > 0) { + const next = { ...store.contentLoadStateByType() }; + for (const type of failed) { + next[type] = 'idle'; + } + patchState(store, { contentLoadStateByType: next }); } }, diff --git a/libs/services/src/lib/parental-lock/parental-lock-lock-store.service.spec.ts b/libs/services/src/lib/parental-lock/parental-lock-lock-store.service.spec.ts new file mode 100644 index 000000000..4ec0752f6 --- /dev/null +++ b/libs/services/src/lib/parental-lock/parental-lock-lock-store.service.spec.ts @@ -0,0 +1,100 @@ +import { TestBed } from '@angular/core/testing'; +import { ParentalLockStore } from '@iptvnator/shared/interfaces'; +import { DatabaseService } from '../database-electron.service'; +import { RuntimeCapabilitiesService } from '../runtime-capabilities.service'; +import { ParentalLockLockStore } from './parental-lock-lock-store.service'; +import { ParentalLockStorageService } from './parental-lock-storage'; + +describe('ParentalLockLockStore', () => { + const stored: ParentalLockStore = { + 'pl-1': { + xtream: [{ categoryType: 'live', xtreamId: 7 }], + stalker: [], + m3u: [], + }, + }; + let storage: { readLocks: jest.Mock; writeLocks: jest.Mock }; + let setCategoryLocks: jest.Mock; + let store: ParentalLockLockStore; + + beforeEach(() => { + storage = { + readLocks: jest.fn(async () => JSON.parse(JSON.stringify(stored))), + writeLocks: jest.fn(async () => true), + }; + setCategoryLocks = jest.fn(async () => true); + TestBed.configureTestingModule({ + providers: [ + ParentalLockLockStore, + { provide: ParentalLockStorageService, useValue: storage }, + { + provide: RuntimeCapabilitiesService, + useValue: { supportsXtreamSqliteDataSource: true }, + }, + { provide: DatabaseService, useValue: { setCategoryLocks } }, + ], + }); + store = TestBed.inject(ParentalLockLockStore); + }); + + it('is not readable until the initial read has settled', async () => { + let resolveRead: (locks: ParentalLockStore) => void = () => undefined; + storage.readLocks.mockReturnValue( + new Promise((resolve) => (resolveRead = resolve)) + ); + const load = store.load(); + + expect(store.readable()).toBe(false); + + resolveRead({}); + await load; + expect(store.readable()).toBe(true); + }); + + it('re-derives the SQLite index from the store on load', async () => { + await store.load(); + // ensureReadable awaits the reconcile that load() started. + await store.ensureReadable(); + + expect(setCategoryLocks).toHaveBeenCalledWith('pl-1', 'live', [7]); + expect(setCategoryLocks).toHaveBeenCalledWith('pl-1', 'movies', []); + expect(setCategoryLocks).toHaveBeenCalledWith('pl-1', 'series', []); + }); + + it('rolls the store back when the index re-stamp fails', async () => { + await store.load(); + setCategoryLocks.mockResolvedValue(false); + + await expect( + store.setXtreamLocks('pl-1', 'live', [7, 9]) + ).resolves.toBe(false); + + expect(store.lockedXtreamIds('pl-1', 'live')).toEqual([7]); + expect(storage.writeLocks).toHaveBeenLastCalledWith( + expect.objectContaining({ + 'pl-1': expect.objectContaining({ + xtream: [{ categoryType: 'live', xtreamId: 7 }], + }), + }) + ); + }); + + it('re-stamps on the next access when even the rollback write failed', async () => { + await store.load(); + await store.ensureReadable(); + setCategoryLocks.mockClear(); + setCategoryLocks.mockResolvedValueOnce(false); + storage.writeLocks + .mockResolvedValueOnce(true) // the new locks + .mockResolvedValueOnce(false); // the rollback + + await expect( + store.setXtreamLocks('pl-1', 'live', [7, 9]) + ).resolves.toBe(false); + expect(store.lockedXtreamIds('pl-1', 'live')).toEqual([7, 9]); + + await store.ensureReadable(); + + expect(setCategoryLocks).toHaveBeenCalledWith('pl-1', 'live', [7, 9]); + }); +}); diff --git a/libs/services/src/lib/parental-lock/parental-lock-lock-store.service.ts b/libs/services/src/lib/parental-lock/parental-lock-lock-store.service.ts index 5f34ec066..91e5f9a00 100644 --- a/libs/services/src/lib/parental-lock/parental-lock-lock-store.service.ts +++ b/libs/services/src/lib/parental-lock/parental-lock-lock-store.service.ts @@ -42,24 +42,51 @@ export class ParentalLockLockStore { private readonly locks = signal({}); private readonly revisionState = signal(0); + private readonly loadedState = signal(false); private loading: Promise | null = null; + /** + * Playlists whose SQLite index could not be brought in line with the + * store (a failed re-stamp whose rollback failed too); re-stamped on + * the next store access. + */ + private readonly staleIndexPlaylists = new Set(); /** The persisted store could not be read; see `ensureReadable()`. */ readonly unreadable = signal(false); + /** + * The store has been read and is trustworthy. False while the initial + * read is still in flight — the settings can report the feature as on + * before the locks are known, and an empty in-memory store must not + * read as "nothing is locked" in that window. + */ + readonly readable = computed( + () => this.loadedState() && !this.unreadable() + ); /** Bumps whenever the lock set changes; consumers re-query. */ readonly revision = this.revisionState.asReadonly(); readonly isEmpty = computed(() => Object.keys(this.locks()).length === 0); - /** Reads the persisted store once. */ + /** + * Reads the persisted store once. On Electron the `categories.locked` + * index is then re-derived from it for every playlist that has locks: + * the store is authoritative, and a re-stamp that failed in an earlier + * session (or a database restored beside a newer store) must not leave + * the index behind indefinitely. + */ load(): Promise { if (!this.loading) { this.loading = this.storage.readLocks().then((locks) => { if (locks === null) { console.error('The parental lock store could not be read.'); this.unreadable.set(true); - return; + } else { + this.locks.set(locks); + for (const playlistId of Object.keys(locks)) { + this.staleIndexPlaylists.add(playlistId); + } } - this.locks.set(locks); + this.loadedState.set(true); + void this.reconcileXtreamIndex(); }); } return this.loading; @@ -72,6 +99,7 @@ export class ParentalLockLockStore { async ensureReadable(): Promise { await this.load(); if (!this.unreadable()) { + await this.reconcileXtreamIndex(); return true; } const locks = await this.storage.readLocks(); @@ -84,6 +112,26 @@ export class ParentalLockLockStore { return true; } + /** Re-stamps every playlist whose index may lag behind the store. */ + private async reconcileXtreamIndex(): Promise { + if (!this.runtime.supportsXtreamSqliteDataSource) { + this.staleIndexPlaylists.clear(); + return; + } + for (const playlistId of [...this.staleIndexPlaylists]) { + try { + if (await this.stampXtreamLocks(playlistId)) { + this.staleIndexPlaylists.delete(playlistId); + } + } catch (error) { + console.error( + 'Failed to reconcile the parental lock index.', + error + ); + } + } + } + locksFor(playlistId: string): ParentalLockPlaylistLocks { return ( this.locks()[playlistId] ?? createEmptyParentalLockPlaylistLocks() @@ -113,15 +161,16 @@ export class ParentalLockLockStore { categoryType: ParentalLockXtreamCategoryType, xtreamIds: number[] ): Promise { - const next = withXtreamLocks( - this.locksFor(playlistId), - categoryType, - xtreamIds - ); + const previous = this.locksFor(playlistId); + const next = withXtreamLocks(previous, categoryType, xtreamIds); if (!(await this.persistPlaylistLocks(playlistId, next))) { return false; } - return this.stampXtreamLocks(playlistId, [categoryType]); + if (await this.stampXtreamLocks(playlistId, [categoryType])) { + return true; + } + await this.rollBack(playlistId, previous); + return false; } async setStalkerLocks( @@ -154,10 +203,32 @@ export class ParentalLockLockStore { playlistId: string, locks: ParentalLockPlaylistLocks ): Promise { + const previous = this.locksFor(playlistId); if (!(await this.persistPlaylistLocks(playlistId, locks))) { return false; } - return this.stampXtreamLocks(playlistId, XTREAM_CATEGORY_TYPES); + if (await this.stampXtreamLocks(playlistId, XTREAM_CATEGORY_TYPES)) { + return true; + } + await this.rollBack(playlistId, previous); + return false; + } + + /** + * The store commits before the SQLite index is re-stamped; a failed + * re-stamp would otherwise leave a category recorded (and shown) as + * locked while Electron reads, which filter by the index alone, still + * serve it. The store goes back to what the index reflects; if even + * that write fails, the playlist is re-stamped on the next access. + */ + private async rollBack( + playlistId: string, + previous: ParentalLockPlaylistLocks + ): Promise { + if (!(await this.persistPlaylistLocks(playlistId, previous))) { + console.error('Failed to roll back the parental lock store.'); + this.staleIndexPlaylists.add(playlistId); + } } /** 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 3a93ceedd..1b1263a7a 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 @@ -183,6 +183,46 @@ describe('ParentalLockService', () => { expect(service.isM3uGroupLocked('p', 'XXX')).toBe(true); }); + it('withholds everything while the initial lock store read is in flight', async () => { + storage.pinHash = await hashParentalLockPin('1234'); + parentalLockEnabled.set(true); + let resolveLocks: (locks: Record) => void = () => + undefined; + storage.readLocks.mockReturnValue( + new Promise((resolve) => (resolveLocks = resolve)) + ); + + const service = TestBed.inject(ParentalLockService); + const initialization = service.initialize(); + await Promise.resolve(); + + expect(service.active()).toBe(true); + expect(service.withholdsEverything()).toBe(true); + expect(service.isM3uGroupLocked('p', 'News')).toBe(true); + + resolveLocks({}); + await initialization; + expect(service.withholdsEverything()).toBe(false); + expect(service.isM3uGroupLocked('p', 'News')).toBe(false); + }); + + it('rolls the switch back when enabling cannot be persisted', async () => { + prompt.requestPin.mockResolvedValue('1234'); + updateSettings.mockImplementationOnce(async () => { + // updateSettings patches memory before the write fails. + parentalLockEnabled.set(true); + throw new Error('QuotaExceededError'); + }); + const service = await createService(); + + await expect(service.setupPin()).resolves.toBe(false); + + expect(service.hasPin()).toBe(true); + expect(service.enabled()).toBe(false); + expect(service.unlocked()).toBe(false); + expect(updateBridgeSettings).not.toHaveBeenCalled(); + }); + it('starts locked with the feature on and unlocks through the prompt', async () => { storage.pinHash = await hashParentalLockPin('1234'); parentalLockEnabled.set(true); 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 9b7042ab5..dd0f13441 100644 --- a/libs/services/src/lib/parental-lock/parental-lock.service.ts +++ b/libs/services/src/lib/parental-lock/parental-lock.service.ts @@ -97,7 +97,7 @@ export class ParentalLockService { * protected content precisely during a storage failure. */ readonly withholdsEverything = computed( - () => this.active() && this.locks.unreadable() + () => this.active() && !this.locks.readable() ); /** Bumps whenever `active` or the lock store changes; consumers re-query. */ readonly version = computed( @@ -241,15 +241,36 @@ export class ParentalLockService { return false; } this.unlockedState.set(true); - if (!this.enabled()) { - await this.settingsStore.updateSettings({ - parentalLockEnabled: true, - }); - mirrorParentalLockEnabledSetting(true); + if (!this.enabled() && !(await this.persistEnabled(true))) { + this.unlockedState.set(false); + return false; } return true; } + /** + * Persists the feature switch. `updateSettings` patches the in-memory + * value before the write; on a failed write that patch is undone (the + * second write fails the same way and is ignored), so the toggle + * cannot show a state the next launch will not have, and the Electron + * mirror is only updated for a persisted switch. + */ + private async persistEnabled(enabled: boolean): Promise { + try { + await this.settingsStore.updateSettings({ + parentalLockEnabled: enabled, + }); + } catch (error) { + console.error('Failed to persist the parental lock switch.', error); + await this.settingsStore + .updateSettings({ parentalLockEnabled: !enabled }) + .catch(() => undefined); + return false; + } + mirrorParentalLockEnabledSetting(enabled); + return true; + } + /** Verifies the current PIN, then replaces it. */ async changePin(): Promise { await this.initialize(); @@ -274,10 +295,9 @@ export class ParentalLockService { if (!(await this.verifyCurrentPin())) { return false; } - await this.settingsStore.updateSettings({ - parentalLockEnabled: false, - }); - mirrorParentalLockEnabledSetting(false); + if (!(await this.persistEnabled(false))) { + return false; + } this.unlockedState.set(false); return true; } @@ -342,7 +362,7 @@ export class ParentalLockService { ): boolean { return ( this.active() && - (this.locks.unreadable() || + (!this.locks.readable() || this.lockedXtreamIds(playlistId, categoryType).includes( xtreamId )) @@ -359,7 +379,7 @@ export class ParentalLockService { } return ( this.active() && - (this.locks.unreadable() || + (!this.locks.readable() || this.lockedStalkerIds(playlistId, categoryType).includes( String(categoryId) )) @@ -369,7 +389,7 @@ export class ParentalLockService { isM3uGroupLocked(playlistId: string, groupTitle: string): boolean { return ( this.active() && - (this.locks.unreadable() || + (!this.locks.readable() || this.lockedGroupTitles(playlistId).includes(groupTitle)) ); }