diff --git a/docs/architecture/parental-lock.md b/docs/architecture/parental-lock.md index 499afa25d..add7d4ad9 100644 --- a/docs/architecture/parental-lock.md +++ b/docs/architecture/parental-lock.md @@ -334,7 +334,11 @@ 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 +later write would drop the earlier edit. Single-row changes (the +right-click Lock/Unlock) are passed as an EDIT of the current list +(`LockListEdit`), evaluated inside that queue, so two quick toggles each +see the other's result instead of one shared snapshot; the dialogs pass a +full list, which is the replacement they mean. 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 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 91af0d177..d30e4252b 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 @@ -212,6 +212,35 @@ describe('ParentalLockLockStore', () => { }); }); + it("applies back-to-back single-row edits to each other's result", async () => { + await store.load(); + await store.ensureReadable(); + let releaseFirst: (ok: boolean) => void = () => undefined; + storage.writeLocks.mockImplementationOnce( + () => new Promise((resolve) => (releaseFirst = resolve)) + ); + + const first = store.setStalkerLocks('pl-1', 'itv', (current) => [ + ...current, + '1', + ]); + const second = store.setStalkerLocks('pl-1', 'itv', (current) => [ + ...current, + '2', + ]); + for ( + let i = 0; + i < 50 && storage.writeLocks.mock.calls.length === 0; + i += 1 + ) { + await Promise.resolve(); + } + releaseFirst(true); + await Promise.all([first, second]); + + expect(store.lockedStalkerIds('pl-1', 'itv')).toEqual(['1', '2']); + }); + it('drops a deleted playlist from the store and empties it on clearAll', async () => { await store.load(); await store.ensureReadable(); 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 4378158b8..db581441f 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 @@ -19,6 +19,20 @@ import { withXtreamLocks, } from './parental-lock-store.util'; +/** + * A full replacement list, or an edit of the CURRENT list. The edit form is + * evaluated inside the write queue, so single-row toggles issued back to + * back each see the previous toggle's result instead of one shared snapshot. + */ +export type LockListEdit = readonly T[] | ((current: readonly T[]) => T[]); + +function applyLockListEdit( + edit: LockListEdit, + current: readonly T[] +): T[] { + return typeof edit === 'function' ? edit(current) : [...edit]; +} + const XTREAM_CATEGORY_TYPES: readonly ParentalLockXtreamCategoryType[] = [ 'live', 'movies', @@ -197,14 +211,21 @@ export class ParentalLockLockStore { async setXtreamLocks( playlistId: string, categoryType: ParentalLockXtreamCategoryType, - xtreamIds: number[] + xtreamIds: LockListEdit ): Promise { return this.enqueue(async () => { if (!(await this.ensureReadable())) { return false; } const previous = this.locksFor(playlistId); - const next = withXtreamLocks(previous, categoryType, xtreamIds); + const next = withXtreamLocks( + previous, + categoryType, + applyLockListEdit( + xtreamIds, + this.lockedXtreamIds(playlistId, categoryType) + ) + ); return this.commitLocks(playlistId, previous, next, [categoryType]); }); } @@ -212,7 +233,7 @@ export class ParentalLockLockStore { async setStalkerLocks( playlistId: string, categoryType: ParentalLockStalkerCategoryType, - categoryIds: string[] + categoryIds: LockListEdit ): Promise { return this.enqueue(async () => { if (!(await this.ensureReadable())) { @@ -223,7 +244,10 @@ export class ParentalLockLockStore { withStalkerLocks( this.locksFor(playlistId), categoryType, - categoryIds + applyLockListEdit( + categoryIds, + this.lockedStalkerIds(playlistId, categoryType) + ) ) ); }); @@ -231,7 +255,7 @@ export class ParentalLockLockStore { async setM3uLocks( playlistId: string, - groupTitles: string[] + groupTitles: LockListEdit ): Promise { return this.enqueue(async () => { if (!(await this.ensureReadable())) { @@ -239,7 +263,13 @@ export class ParentalLockLockStore { } return this.persistPlaylistLocks( playlistId, - withM3uLocks(this.locksFor(playlistId), groupTitles) + withM3uLocks( + this.locksFor(playlistId), + applyLockListEdit( + groupTitles, + this.lockedGroupTitles(playlistId) + ) + ) ); }); } 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 ac5558ef1..0c62904f4 100644 --- a/libs/services/src/lib/parental-lock/parental-lock.service.ts +++ b/libs/services/src/lib/parental-lock/parental-lock.service.ts @@ -16,7 +16,10 @@ import { } from '@iptvnator/shared/interfaces'; import { SettingsStore } from '../settings-store.service'; import { ParentalLockIdleTimer } from './parental-lock-idle-timer'; -import { ParentalLockLockStore } from './parental-lock-lock-store.service'; +import { + LockListEdit, + ParentalLockLockStore, +} from './parental-lock-lock-store.service'; import { PARENTAL_LOCK_PROMPT, ParentalLockPromptRequest, @@ -436,7 +439,7 @@ export class ParentalLockService { async setXtreamLocks( playlistId: string, categoryType: ParentalLockXtreamCategoryType, - xtreamIds: number[] + xtreamIds: LockListEdit ): Promise { await this.initialize(); return this.locks.setXtreamLocks(playlistId, categoryType, xtreamIds); @@ -445,7 +448,7 @@ export class ParentalLockService { async setStalkerLocks( playlistId: string, categoryType: ParentalLockStalkerCategoryType, - categoryIds: string[] + categoryIds: LockListEdit ): Promise { await this.initialize(); return this.locks.setStalkerLocks( @@ -457,7 +460,7 @@ export class ParentalLockService { async setM3uLocks( playlistId: string, - groupTitles: string[] + groupTitles: LockListEdit ): Promise { await this.initialize(); return this.locks.setM3uLocks(playlistId, groupTitles); diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html index c9333db57..ac74d21a4 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html @@ -70,6 +70,7 @@ (favoriteToggled)="onFavoriteToggled($event)" (hiddenGroupTitlesChanged)="onHiddenGroupTitlesChanged($event)" (lockedGroupTitlesChanged)="onLockedGroupTitlesChanged($event)" + (groupLockToggled)="onGroupLockToggled($event)" (sidebarWidthRequested)="onSidebarWidthRequested($event)" (sidebarWidthRequestEnded)="onSidebarWidthRequestEnded($event)" (sidebarToggleRequested)="sidebarToggleRequested.emit()" diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.scss b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.scss index bf3560dc0..4ebd3682c 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.scss +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.scss @@ -33,7 +33,11 @@ height: 64px; border-radius: 20px; background: - linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0)), + linear-gradient( + 180deg, + rgba(255, 255, 255, 0.08), + rgba(255, 255, 255, 0) + ), rgba(127, 127, 127, 0.1); display: flex; align-items: center; diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts index a0ed936d3..55d56e5e9 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts @@ -594,6 +594,24 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy { async onLockedGroupTitlesChanged( lockedGroupTitles: string[] + ): Promise { + await this.saveGroupLocks(lockedGroupTitles); + } + + /** One group from the right-click menu, applied inside the store's queue. */ + async onGroupLockToggled(change: { + groupKey: string; + locked: boolean; + }): Promise { + await this.saveGroupLocks((current) => + change.locked + ? [...new Set([...current, change.groupKey])] + : current.filter((title) => title !== change.groupKey) + ); + } + + private async saveGroupLocks( + groupTitles: string[] | ((current: readonly string[]) => string[]) ): Promise { const playlistId = this.lockPlaylistId(); if (!playlistId) { @@ -604,7 +622,7 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy { // user relocks believing the chosen groups are protected. const saved = await this.parentalLock.setM3uLocks( playlistId, - lockedGroupTitles + groupTitles ); if (!saved) { this.snackBar.open( diff --git a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.spec.ts b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.spec.ts index e929dc5d2..7570b78e6 100644 --- a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.spec.ts +++ b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.spec.ts @@ -537,9 +537,9 @@ describe('GroupsViewComponent', () => { ]); }); - it('locks and unlocks one group from the right-click menu behind the PIN gate', async () => { - const lockedGroupTitlesChanged = jest.fn(); - component.lockedGroupTitlesChanged.subscribe(lockedGroupTitlesChanged); + it('emits a single-group toggle from the right-click menu behind the PIN gate', async () => { + const groupLockToggled = jest.fn(); + component.groupLockToggled.subscribe(groupLockToggled); fixture.componentRef.setInput('lockedGroupTitles', ['News']); fixture.detectChanges(); const event = new MouseEvent('contextmenu', { cancelable: true }); @@ -547,19 +547,22 @@ describe('GroupsViewComponent', () => { component.onGroupContextMenu('Sports', event); expect(event.defaultPrevented).toBe(true); await component.onGroupLockToggle(true); - expect(lockedGroupTitlesChanged).toHaveBeenLastCalledWith([ - 'News', - 'Sports', - ]); + expect(groupLockToggled).toHaveBeenLastCalledWith({ + groupKey: 'Sports', + locked: true, + }); component.onGroupContextMenu('News', event); await component.onGroupLockToggle(false); - expect(lockedGroupTitlesChanged).toHaveBeenLastCalledWith([]); + expect(groupLockToggled).toHaveBeenLastCalledWith({ + groupKey: 'News', + locked: false, + }); parentalLock.requestUnlock.mockResolvedValueOnce(false); component.onGroupContextMenu('Movies', event); await component.onGroupLockToggle(true); - expect(lockedGroupTitlesChanged).toHaveBeenCalledTimes(2); + expect(groupLockToggled).toHaveBeenCalledTimes(2); }); it('offers no group lock menu while the feature is off', () => { diff --git a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts index cf507d525..9e61ebdc6 100644 --- a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts @@ -178,6 +178,8 @@ export class GroupsViewComponent { readonly sidebarWidthRequestEnded = output(); readonly hiddenGroupTitlesChanged = output(); readonly lockedGroupTitlesChanged = output(); + /** Right-click toggle of ONE group; the host applies it in the lock store's queue. */ + readonly groupLockToggled = output<{ groupKey: string; locked: boolean }>(); /** Emits when the user clicks the inline collapse toggle in the groups header */ readonly sidebarToggleRequested = output(); @@ -512,18 +514,13 @@ export class GroupsViewComponent { async onGroupLockToggle(lock: boolean): Promise { const groupKey = this.groupLockKey; this.groupLockKey = null; - const current = this.lockedGroupTitles(); - if (groupKey === null || current === null) { + if (groupKey === null || this.lockedGroupTitles() === null) { return; } if (!(await this.parentalLock.requestUnlock())) { return; } - this.lockedGroupTitlesChanged.emit( - lock - ? [...new Set([...current, groupKey])] - : current.filter((title) => title !== groupKey) - ); + this.groupLockToggled.emit({ groupKey, locked: lock }); } async openGroupManagement(): Promise { diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-category-lock-action.service.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-category-lock-action.service.spec.ts index e18289585..76759d62a 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-category-lock-action.service.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-category-lock-action.service.spec.ts @@ -55,7 +55,7 @@ describe('WorkspaceCategoryLockActionService', () => { ); }); - it('adds and removes one Xtream category behind the PIN gate', async () => { + it('adds and removes one Xtream category as an edit of the current list', async () => { const target = { provider: 'xtreams' as const, playlistId: 'p', @@ -63,21 +63,24 @@ describe('WorkspaceCategoryLockActionService', () => { item: { id: 2, xtream_id: 8 }, }; await expect(service.setLocked(target, true)).resolves.toBe(true); - expect(parentalLock.setXtreamLocks).toHaveBeenCalledWith( - 'p', - 'live', - [7, 8] - ); + const [, type, edit] = parentalLock.setXtreamLocks.mock + .calls[0] as unknown as [ + string, + string, + (current: number[]) => number[], + ]; + expect(type).toBe('live'); + // Applied inside the store's queue to whatever is current THEN. + expect(edit([7])).toEqual([7, 8]); + expect(edit([7, 8])).toEqual([7, 8]); await service.setLocked( { ...target, item: { id: 1, xtream_id: 7 } }, false ); - expect(parentalLock.setXtreamLocks).toHaveBeenLastCalledWith( - 'p', - 'live', - [] - ); + const unlock = parentalLock.setXtreamLocks.mock + .calls[1][2] as unknown as (current: number[]) => number[]; + expect(unlock([7, 8])).toEqual([8]); }); it('toggles a Stalker genre and never the All pseudo-category', async () => { @@ -91,10 +94,9 @@ describe('WorkspaceCategoryLockActionService', () => { service.isLocked({ ...target, item: { category_id: '9' } }) ).toBe(true); await expect(service.setLocked(target, true)).resolves.toBe(true); - expect(parentalLock.setStalkerLocks).toHaveBeenCalledWith('p', 'itv', [ - '9', - '5', - ]); + const edit = parentalLock.setStalkerLocks.mock + .calls[0][2] as unknown as (current: string[]) => string[]; + expect(edit(['9'])).toEqual(['9', '5']); await expect( service.setLocked({ ...target, item: { category_id: '*' } }, true) ).resolves.toBe(false); diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-category-lock-action.service.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-category-lock-action.service.ts index 2975fa063..a64c237c1 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-category-lock-action.service.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-category-lock-action.service.ts @@ -89,16 +89,15 @@ export class WorkspaceCategoryLockActionService { if (!type || id === null) { return Promise.resolve(false); } - const current = this.parentalLock.lockedXtreamIds( - target.playlistId, - type - ); + // An edit, not a list: it is applied inside the lock store's + // write queue, so back-to-back toggles do not share a snapshot. return this.parentalLock.setXtreamLocks( target.playlistId, type, - locked - ? [...current, id] - : current.filter((entry) => entry !== id) + (current) => + locked + ? [...new Set([...current, id])] + : current.filter((entry) => entry !== id) ); } const type = toParentalLockStalkerCategoryType(target.section); @@ -106,14 +105,13 @@ export class WorkspaceCategoryLockActionService { if (!type || id === null) { return Promise.resolve(false); } - const current = this.parentalLock.lockedStalkerIds( - target.playlistId, - type - ); return this.parentalLock.setStalkerLocks( target.playlistId, type, - locked ? [...current, id] : current.filter((entry) => entry !== id) + (current) => + locked + ? [...new Set([...current, id])] + : current.filter((entry) => entry !== id) ); }