mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 01:56:16 -08:00
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:
1 parent
a030e1af2f
commit
0f9c7a6364
9 files changed
+212
-35
No files matched your search
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user