fix(settings): retry a failed clear-all of the lock store and keep restored new playlists free of stale locks

A lock-store clear that failed after "Remove all playlists" only logged,
so a later restore reusing a playlist id could inherit the deleted
playlist's locks. The in-memory store now empties at once and the
persisted clear is retried on the next access; a restore that creates a
playlist starts it from empty locks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-09-27 02:19:39 +02:00
1 parent 5d6c378f22
commit 2161c6a859
5 files changed
+83 -9

No files matched your search

+5 -1
View File
@@ -353,7 +353,11 @@ the store through the `PLAYLIST_DELETE_CLEANUP` hook
`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.
deletion has succeeded — the in-memory store empties at once, and a failed
persisted clear is retried on the next store access and overwritten by the
next write. A backup restore that CREATES a playlist (not a merge) starts
that playlist from empty locks, so a reused id can never inherit entries a
failed cleanup left behind.
## Backup
@@ -256,6 +256,19 @@ describe('ParentalLockLockStore', () => {
expect(storage.writeLocks).toHaveBeenLastCalledWith({});
});
it('empties the store now and retries a failed persisted clear', async () => {
await store.load();
await store.ensureReadable();
storage.writeLocks.mockResolvedValueOnce(false);
await expect(store.clearAll()).resolves.toBe(false);
expect(store.lockedXtreamIds('pl-1', 'live')).toEqual([]);
storage.writeLocks.mockClear();
await expect(store.ensureReadable()).resolves.toBe(true);
expect(storage.writeLocks).toHaveBeenCalledWith({});
});
it('rolls the store back when the index re-stamp fails', async () => {
await store.load();
setCategoryLocks.mockResolvedValue(false);
@@ -64,6 +64,8 @@ export class ParentalLockLockStore {
* same store and the later write would silently drop the earlier edit.
*/
private writeQueue: Promise<unknown> = Promise.resolve();
/** "Remove all playlists" could not clear the persisted store yet. */
private pendingClearAll = false;
/**
* Playlists whose SQLite index could not be brought in line with the
* store (a failed re-stamp whose rollback failed too); re-stamped on
@@ -129,6 +131,12 @@ export class ParentalLockLockStore {
*/
async ensureReadable(): Promise<boolean> {
await this.load();
if (this.pendingClearAll) {
this.pendingClearAll = !(await this.storage.writeLocks({}));
if (this.pendingClearAll) {
return false;
}
}
if (this.unreadable()) {
const locks = await this.storage.readLocks();
if (locks === null) {
@@ -315,15 +323,17 @@ export class ParentalLockLockStore {
clearAll(): Promise<boolean> {
return this.enqueue(async () => {
await this.load();
if (!(await this.storage.writeLocks({}))) {
return false;
}
// The playlists are gone either way: the in-memory store empties
// now, and a failed persisted clear is retried on the next
// store access (`ensureReadable`) and by the next write, which
// rewrites the whole store from this empty copy.
this.locks.set({});
this.unreadable.set(false);
this.staleIndexPlaylists.clear();
this.staleIndexCount.set(0);
this.revisionState.update((value) => value + 1);
return true;
this.pendingClearAll = !(await this.storage.writeLocks({}));
return !this.pendingClearAll;
});
}
@@ -8,6 +8,7 @@ import { PlaybackPositionService } from './playback-position.service';
import { XtreamPendingRestoreService } from './xtream-pending-restore.service';
import { ParentalLockService } from './parental-lock/parental-lock.service';
import {
isParentalLockPlaylistLocksEmpty,
isM3uRecentlyViewedItem,
normalizeXtreamPendingRestoreState,
M3uPlaylistBackupEntry,
@@ -183,7 +184,7 @@ export class PlaylistBackupService {
this.playlistsService.addPlaylist(nextPlaylist)
);
await this.restoreParentalLocks(targetId, entry);
await this.restoreParentalLocks(targetId, entry, isMerge);
if (entry.portalType === 'xtream') {
await this.restoreXtreamEntry(targetId, entry);
@@ -1113,10 +1114,23 @@ export class PlaylistBackupService {
*/
private async restoreParentalLocks(
playlistId: string,
entry: PlaylistBackupEntry
entry: PlaylistBackupEntry,
isMerge: boolean
): Promise<void> {
const current = this.parentalLock.locksFor(playlistId);
let next: ParentalLockPlaylistLocks | null = null;
// A playlist created by this restore owns no locks yet: whatever the
// store holds under a reused id belongs to a deleted playlist (a
// cleanup that failed), and must not be inherited.
const current = isMerge
? this.parentalLock.locksFor(playlistId)
: { xtream: [], stalker: [], m3u: [] };
const staleOnNewId =
!isMerge &&
!isParentalLockPlaylistLocksEmpty(
this.parentalLock.locksFor(playlistId)
);
let next: ParentalLockPlaylistLocks | null = staleOnNewId
? current
: null;
if (entry.portalType === 'm3u') {
const titles = entry.userState.lockedGroupTitles;
@@ -104,6 +104,39 @@ describe('PlaylistBackupService Xtream hidden categories (issue #1017)', () => {
);
});
it('never lets a newly created playlist inherit stale locks under a reused id', async () => {
const collaborators = createRestoreCollaborators();
// Empty library: the restore CREATES the playlist (no merge match).
collaborators.playlistsService.getAllData.mockReturnValue(of([]));
const replacePlaylistLocks = jest.fn().mockResolvedValue(true);
const service = createPlaylistBackupService({
...collaborators,
parentalLock: {
initialize: jest.fn().mockResolvedValue(undefined),
locksReadable: jest.fn(() => true),
// A failed cleanup left locks under the id the restore reuses.
locksFor: jest.fn(() => ({
xtream: [{ categoryType: 'live', xtreamId: 1 }],
stalker: [],
m3u: [],
})),
replacePlaylistLocks,
},
});
const manifest = createXtreamManifest([]);
delete (
manifest.playlists[0].userState as { lockedCategories?: unknown }
).lockedCategories;
await service.importBackup(JSON.stringify(manifest));
expect(replacePlaylistLocks).toHaveBeenCalledWith(expect.any(String), {
xtream: [],
stalker: [],
m3u: [],
});
});
it('rejects a damaged parental lock list instead of erasing the persisted locks', async () => {
const collaborators = createRestoreCollaborators();
const service = createPlaylistBackupService(collaborators);