From 54f27fe656679140b8f7a841d44aff4bb74043f7 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 27 Sep 2026 01:22:15 +0200 Subject: [PATCH] fix(settings): apply single-row lock toggles inside the lock store's write queue The right-click Lock/Unlock (portal categories and M3U groups) built the new list before entering the queue, so two quick toggles shared one snapshot and the second dropped the first. Lock writes now accept an edit of the current list, evaluated inside the queue. Co-Authored-By: Claude Opus 5.5 --- docs/architecture/parental-lock.md | 6 ++- .../parental-lock-lock-store.service.spec.ts | 29 +++++++++++++ .../parental-lock-lock-store.service.ts | 42 ++++++++++++++++--- .../parental-lock/parental-lock.service.ts | 11 +++-- .../channel-list-container.component.html | 1 + .../channel-list-container.component.scss | 6 ++- .../channel-list-container.component.ts | 20 ++++++++- .../groups-view/groups-view.component.spec.ts | 21 ++++++---- .../groups-view/groups-view.component.ts | 11 ++--- ...space-category-lock-action.service.spec.ts | 32 +++++++------- .../workspace-category-lock-action.service.ts | 22 +++++----- 11 files changed, 145 insertions(+), 56 deletions(-) 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) ); }