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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-09-27 01:22:15 +02:00
1 parent 0f9c7a6364
commit 54f27fe656
11 files changed
+145 -56

No files matched your search

+5 -1
View File
@@ -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
@@ -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<boolean>((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();
@@ -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<T> = readonly T[] | ((current: readonly T[]) => T[]);
function applyLockListEdit<T>(
edit: LockListEdit<T>,
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<number>
): Promise<boolean> {
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<string>
): Promise<boolean> {
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<string>
): Promise<boolean> {
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)
)
)
);
});
}
@@ -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<number>
): Promise<boolean> {
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<string>
): Promise<boolean> {
await this.initialize();
return this.locks.setStalkerLocks(
@@ -457,7 +460,7 @@ export class ParentalLockService {
async setM3uLocks(
playlistId: string,
groupTitles: string[]
groupTitles: LockListEdit<string>
): Promise<boolean> {
await this.initialize();
return this.locks.setM3uLocks(playlistId, groupTitles);
@@ -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()"
@@ -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;
@@ -594,6 +594,24 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
async onLockedGroupTitlesChanged(
lockedGroupTitles: string[]
): Promise<void> {
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<void> {
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<void> {
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(
@@ -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', () => {
@@ -178,6 +178,8 @@ export class GroupsViewComponent {
readonly sidebarWidthRequestEnded = output<number>();
readonly hiddenGroupTitlesChanged = output<string[]>();
readonly lockedGroupTitlesChanged = output<string[]>();
/** 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<void>();
@@ -512,18 +514,13 @@ export class GroupsViewComponent {
async onGroupLockToggle(lock: boolean): Promise<void> {
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<void> {
@@ -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);
@@ -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)
);
}