fix(settings): serialize lock-store writes and drop a deleted playlist's locks

- Lock-store mutations run through one write queue: each rewrites the
  whole persisted store, so overlapping edits could otherwise snapshot
  the same store and the later write would drop the earlier edit.
- Deleting a playlist removes its locks through the PLAYLIST_DELETE_CLEANUP
  hook; "Remove all playlists" clears the lock store once the deletion
  has succeeded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-09-27 00:14:27 +02:00
1 parent a030e1af2f
commit 0f9c7a6364
9 files changed
+212 -35

No files matched your search

+5 -1
View File
@@ -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,
@@ -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<void>((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,
@@ -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(
+14
View File
@@ -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
@@ -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';
@@ -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<boolean>((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);
@@ -44,6 +44,12 @@ export class ParentalLockLockStore {
private readonly revisionState = signal(0);
private readonly loadedState = signal(false);
private loading: Promise<void> | 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<unknown> = 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<boolean> {
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<boolean> {
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<boolean> {
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<boolean> {
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<boolean> {
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<boolean> {
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<T>(task: () => Promise<T>): Promise<T> {
const run = this.writeQueue.then(task, task);
this.writeQueue = run.catch(() => undefined);
return run;
}
/**
@@ -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.'
);
}
};
},
};
}
@@ -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<boolean> {
await this.initialize();
return this.locks.removePlaylist(playlistId);
}
/** "Remove all playlists": the whole lock store is emptied. */
async clearAllLocks(): Promise<boolean> {
await this.initialize();
return this.locks.clearAll();
}
/** Backup restore: replaces every lock of one playlist. */
async replacePlaylistLocks(
playlistId: string,