diff --git a/apps/web/src/app/app.config.ts b/apps/web/src/app/app.config.ts index f5ddc40d3..b1cb80c9b 100644 --- a/apps/web/src/app/app.config.ts +++ b/apps/web/src/app/app.config.ts @@ -33,7 +33,10 @@ import { } from '@iptvnator/portal/shared/util'; import { STALKER_PLAYLIST_CONNECTION_EDITOR } from '@iptvnator/playlist/shared/ui'; import { provideXtreamDataSource } from '@iptvnator/portal/xtream/data-access'; -import { DataService } from '@iptvnator/services'; +import { + provideParentalLockPlaylistCleanup, + DataService, +} from '@iptvnator/services'; import { dbConfig } from '@iptvnator/shared/interfaces'; import { AppConfig } from '../environments/environment'; import { routes } from './app.routes'; @@ -162,6 +165,7 @@ export const appConfig: ApplicationConfig = { }, ...provideWorkspaceShellActions(), ...provideParentalLockPrompt(), + provideParentalLockPlaylistCleanup(), ...provideXtreamDataSource(), { provide: MAT_FORM_FIELD_DEFAULT_OPTIONS, diff --git a/apps/web/src/app/settings/settings-playlist-reset.facade.spec.ts b/apps/web/src/app/settings/settings-playlist-reset.facade.spec.ts index e9b3f8a5c..874873e41 100644 --- a/apps/web/src/app/settings/settings-playlist-reset.facade.spec.ts +++ b/apps/web/src/app/settings/settings-playlist-reset.facade.spec.ts @@ -6,7 +6,11 @@ import { selectAllPlaylistsMeta, selectIsEpgAvailable, } from '@iptvnator/m3u-state'; -import { DatabaseService, PlaylistsService } from '@iptvnator/services'; +import { + ParentalLockService, + DatabaseService, + PlaylistsService, +} from '@iptvnator/services'; import { PlaylistMeta } from '@iptvnator/shared/interfaces'; import { Store } from '@ngrx/store'; import { MockStore, provideMockStore } from '@ngrx/store/testing'; @@ -59,6 +63,9 @@ describe('SettingsPlaylistResetFacade', () => { MockProvider(PlaylistsService, { removeAll: jest.fn().mockReturnValue(of(undefined)), }), + MockProvider(ParentalLockService, { + clearAllLocks: jest.fn().mockResolvedValue(true), + }), provideMockStore({ selectors: [ { selector: selectAllPlaylistsMeta, value: [] }, @@ -218,6 +225,9 @@ describe('SettingsPlaylistResetFacade', () => { expect(playlistsService.removeAll).toHaveBeenCalled(); expect(databaseService.deleteAllPlaylists).not.toHaveBeenCalled(); + expect( + TestBed.inject(ParentalLockService).clearAllLocks + ).toHaveBeenCalled(); expect(dispatchSpy).toHaveBeenCalledWith( PlaylistActions.removeAllPlaylists() ); @@ -235,6 +245,10 @@ describe('SettingsPlaylistResetFacade', () => { await new Promise((resolve) => setTimeout(resolve, 0)); expect(facade.isRemovingAllPlaylists()).toBe(false); + // Nothing was deleted, so the locks stay. + expect( + TestBed.inject(ParentalLockService).clearAllLocks + ).not.toHaveBeenCalled(); expect(snackBar.open).toHaveBeenCalledWith( 'SETTINGS.PLAYLISTS_REMOVE_FAILED', undefined, diff --git a/apps/web/src/app/settings/settings-playlist-reset.facade.ts b/apps/web/src/app/settings/settings-playlist-reset.facade.ts index 8ec1a1fa8..d91fd47b4 100644 --- a/apps/web/src/app/settings/settings-playlist-reset.facade.ts +++ b/apps/web/src/app/settings/settings-playlist-reset.facade.ts @@ -5,6 +5,7 @@ import { TranslateService } from '@ngx-translate/core'; import { firstValueFrom, take } from 'rxjs'; import { PlaylistActions, selectAllPlaylistsMeta } from '@iptvnator/m3u-state'; import { + ParentalLockService, DatabaseService, DbOperationEvent, PlaylistsService, @@ -24,6 +25,7 @@ import { SettingsSnackbarService } from './settings-snackbar.service'; @Injectable() export class SettingsPlaylistResetFacade { private readonly databaseService = inject(DatabaseService); + private readonly parentalLock = inject(ParentalLockService); private readonly matDialog = inject(MatDialog); private readonly playlistsService = inject(PlaylistsService); private readonly runtime = inject(RuntimeCapabilitiesService); @@ -107,6 +109,12 @@ export class SettingsPlaylistResetFacade { if (!deleted) { throw new Error('Delete all playlists returned success=false'); } + // The playlists are gone; their locks must not linger in the + // lock store (the deletion itself already succeeded, so a failed + // clear is only logged). + if (!(await this.parentalLock.clearAllLocks())) { + console.warn('Clearing the parental locks failed.'); + } this.store.dispatch(PlaylistActions.removeAllPlaylists()); this.settingsSnackbar.open( diff --git a/docs/architecture/parental-lock.md b/docs/architecture/parental-lock.md index 6a8c05b11..499afa25d 100644 --- a/docs/architecture/parental-lock.md +++ b/docs/architecture/parental-lock.md @@ -328,6 +328,20 @@ on either side. - Header lock/unlock button and the `parental-lock-now` / `parental-unlock` palette commands. +## Lock store lifetime + +Every lock-store mutation runs through one write queue in +`ParentalLockLockStore`: each rewrites the whole persisted store from the +in-memory copy, so overlapping edits (two right-click toggles, a dialog +save during a restore) would otherwise snapshot the same store and the +later write would drop the earlier edit. A deleted playlist's locks leave +the store through the `PLAYLIST_DELETE_CLEANUP` hook +(`provideParentalLockPlaylistCleanup`, run by +`PlaylistsService.deletePlaylist` for every single-playlist deletion; no +re-stamp, the category rows go with the playlist), and "Remove all +playlists" empties it through `ParentalLockService.clearAllLocks` once the +deletion has succeeded. + ## Backup M3U group titles travel verbatim (`normalizeParentalLockGroupTitles`, exact diff --git a/libs/services/src/lib/parental-lock/index.ts b/libs/services/src/lib/parental-lock/index.ts index 00efa3c9f..00b264892 100644 --- a/libs/services/src/lib/parental-lock/index.ts +++ b/libs/services/src/lib/parental-lock/index.ts @@ -1,5 +1,6 @@ export * from './parental-lock-idle-timer'; export * from './parental-lock-prompt.token'; export * from './parental-lock-lock-store.service'; +export * from './parental-lock-playlist-cleanup'; export * from './parental-lock-storage'; export * from './parental-lock.service'; 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 index e00ad258c..91af0d177 100644 --- 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 @@ -185,6 +185,48 @@ describe('ParentalLockLockStore', () => { expect(store.revision()).toBe(before + 1); }); + it('serializes overlapping edits so neither overwrites the other', async () => { + await store.load(); + await store.ensureReadable(); + let releaseFirst: (ok: boolean) => void = () => undefined; + storage.writeLocks.mockImplementationOnce( + () => new Promise((resolve) => (releaseFirst = resolve)) + ); + + const first = store.setM3uLocks('pl-1', ['Adult']); + const second = store.setStalkerLocks('pl-1', 'itv', ['9']); + for (let i = 0; i < 20; i += 1) { + await Promise.resolve(); + } + expect(storage.writeLocks).toHaveBeenCalledTimes(1); + + releaseFirst(true); + await expect(first).resolves.toBe(true); + await expect(second).resolves.toBe(true); + expect(storage.writeLocks).toHaveBeenLastCalledWith({ + 'pl-1': { + xtream: [{ categoryType: 'live', xtreamId: 7 }], + stalker: [{ categoryType: 'itv', categoryId: '9' }], + m3u: ['Adult'], + }, + }); + }); + + it('drops a deleted playlist from the store and empties it on clearAll', async () => { + await store.load(); + await store.ensureReadable(); + setCategoryLocks.mockClear(); + + await expect(store.removePlaylist('missing')).resolves.toBe(true); + await expect(store.removePlaylist('pl-1')).resolves.toBe(true); + expect(storage.writeLocks).toHaveBeenLastCalledWith({}); + expect(setCategoryLocks).not.toHaveBeenCalled(); + expect(store.lockedXtreamIds('pl-1', 'live')).toEqual([]); + + await expect(store.clearAll()).resolves.toBe(true); + expect(storage.writeLocks).toHaveBeenLastCalledWith({}); + }); + it('rolls the store back when the index re-stamp fails', async () => { await store.load(); setCategoryLocks.mockResolvedValue(false); 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 680d69531..4378158b8 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 @@ -44,6 +44,12 @@ export class ParentalLockLockStore { private readonly revisionState = signal(0); private readonly loadedState = signal(false); private loading: Promise | null = null; + /** + * Mutations run one at a time: each rewrites the WHOLE persisted store + * from the in-memory copy, so two overlapping edits would snapshot the + * same store and the later write would silently drop the earlier edit. + */ + private writeQueue: Promise = Promise.resolve(); /** * Playlists whose SQLite index could not be brought in line with the * store (a failed re-stamp whose rollback failed too); re-stamped on @@ -193,12 +199,14 @@ export class ParentalLockLockStore { categoryType: ParentalLockXtreamCategoryType, xtreamIds: number[] ): Promise { - if (!(await this.ensureReadable())) { - return false; - } - const previous = this.locksFor(playlistId); - const next = withXtreamLocks(previous, categoryType, xtreamIds); - return this.commitLocks(playlistId, previous, next, [categoryType]); + return this.enqueue(async () => { + if (!(await this.ensureReadable())) { + return false; + } + const previous = this.locksFor(playlistId); + const next = withXtreamLocks(previous, categoryType, xtreamIds); + return this.commitLocks(playlistId, previous, next, [categoryType]); + }); } async setStalkerLocks( @@ -206,30 +214,34 @@ export class ParentalLockLockStore { categoryType: ParentalLockStalkerCategoryType, categoryIds: string[] ): Promise { - if (!(await this.ensureReadable())) { - return false; - } - return this.persistPlaylistLocks( - playlistId, - withStalkerLocks( - this.locksFor(playlistId), - categoryType, - categoryIds - ) - ); + return this.enqueue(async () => { + if (!(await this.ensureReadable())) { + return false; + } + return this.persistPlaylistLocks( + playlistId, + withStalkerLocks( + this.locksFor(playlistId), + categoryType, + categoryIds + ) + ); + }); } async setM3uLocks( playlistId: string, groupTitles: string[] ): Promise { - if (!(await this.ensureReadable())) { - return false; - } - return this.persistPlaylistLocks( - playlistId, - withM3uLocks(this.locksFor(playlistId), groupTitles) - ); + return this.enqueue(async () => { + if (!(await this.ensureReadable())) { + return false; + } + return this.persistPlaylistLocks( + playlistId, + withM3uLocks(this.locksFor(playlistId), groupTitles) + ); + }); } /** Backup restore: replaces every lock of one playlist. */ @@ -237,15 +249,58 @@ export class ParentalLockLockStore { playlistId: string, locks: ParentalLockPlaylistLocks ): Promise { - if (!(await this.ensureReadable())) { - return false; - } - return this.commitLocks( - playlistId, - this.locksFor(playlistId), - locks, - XTREAM_CATEGORY_TYPES - ); + return this.enqueue(async () => { + if (!(await this.ensureReadable())) { + return false; + } + return this.commitLocks( + playlistId, + this.locksFor(playlistId), + locks, + XTREAM_CATEGORY_TYPES + ); + }); + } + + /** + * A deleted playlist's locks leave the store. No re-stamp: its category + * rows are deleted with it. True when there was nothing to remove. + */ + removePlaylist(playlistId: string): Promise { + return this.enqueue(async () => { + if (!(await this.ensureReadable())) { + return false; + } + if (!this.locks()[playlistId]) { + return true; + } + return this.persistPlaylistLocks( + playlistId, + createEmptyParentalLockPlaylistLocks() + ); + }); + } + + /** "Remove all playlists": every lock goes with them. */ + clearAll(): Promise { + return this.enqueue(async () => { + await this.load(); + if (!(await this.storage.writeLocks({}))) { + return false; + } + this.locks.set({}); + this.unreadable.set(false); + this.staleIndexPlaylists.clear(); + this.staleIndexCount.set(0); + this.revisionState.update((value) => value + 1); + return true; + }); + } + + private enqueue(task: () => Promise): Promise { + const run = this.writeQueue.then(task, task); + this.writeQueue = run.catch(() => undefined); + return run; } /** diff --git a/libs/services/src/lib/parental-lock/parental-lock-playlist-cleanup.ts b/libs/services/src/lib/parental-lock/parental-lock-playlist-cleanup.ts new file mode 100644 index 000000000..e5ece4182 --- /dev/null +++ b/libs/services/src/lib/parental-lock/parental-lock-playlist-cleanup.ts @@ -0,0 +1,27 @@ +import { inject, Provider } from '@angular/core'; +import { PLAYLIST_DELETE_CLEANUP } from '../playlist-delete-cleanup.token'; +import { ParentalLockService } from './parental-lock.service'; + +/** + * Removes a deleted playlist's locks from the persisted lock store. Every + * single-playlist deletion runs `PlaylistsService.deletePlaylist`, which + * calls the `PLAYLIST_DELETE_CLEANUP` hooks after the row is gone; "remove + * all playlists" clears the store through `ParentalLockService.clearAllLocks` + * instead. + */ +export function provideParentalLockPlaylistCleanup(): Provider { + return { + provide: PLAYLIST_DELETE_CLEANUP, + multi: true, + useFactory: () => { + const parentalLock = inject(ParentalLockService); + return async (playlistId: string) => { + if (!(await parentalLock.removePlaylistLocks(playlistId))) { + throw new Error( + 'The parental locks of the deleted playlist could not be removed.' + ); + } + }; + }, + }; +} 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 f33c0dd0e..ac5558ef1 100644 --- a/libs/services/src/lib/parental-lock/parental-lock.service.ts +++ b/libs/services/src/lib/parental-lock/parental-lock.service.ts @@ -463,6 +463,18 @@ export class ParentalLockService { return this.locks.setM3uLocks(playlistId, groupTitles); } + /** Playlist deletion: its locks leave the store. */ + async removePlaylistLocks(playlistId: string): Promise { + await this.initialize(); + return this.locks.removePlaylist(playlistId); + } + + /** "Remove all playlists": the whole lock store is emptied. */ + async clearAllLocks(): Promise { + await this.initialize(); + return this.locks.clearAll(); + } + /** Backup restore: replaces every lock of one playlist. */ async replacePlaylistLocks( playlistId: string,