From 2161c6a85955a7bfc09c01ccd4ca75274c71403e Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 27 Sep 2026 02:19:39 +0200 Subject: [PATCH] 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 --- docs/architecture/parental-lock.md | 6 +++- .../parental-lock-lock-store.service.spec.ts | 13 ++++++++ .../parental-lock-lock-store.service.ts | 18 +++++++--- .../src/lib/playlist-backup.service.ts | 22 ++++++++++--- ...list-backup.service.xtream-restore.spec.ts | 33 +++++++++++++++++++ 5 files changed, 83 insertions(+), 9 deletions(-) diff --git a/docs/architecture/parental-lock.md b/docs/architecture/parental-lock.md index d6c8b1f7b..6d2922d18 100644 --- a/docs/architecture/parental-lock.md +++ b/docs/architecture/parental-lock.md @@ -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 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 d30e4252b..e0270845d 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 @@ -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); 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 db581441f..0ceb32841 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 @@ -64,6 +64,8 @@ export class ParentalLockLockStore { * same store and the later write would silently drop the earlier edit. */ private writeQueue: Promise = 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 { 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 { 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; }); } diff --git a/libs/services/src/lib/playlist-backup.service.ts b/libs/services/src/lib/playlist-backup.service.ts index 88967eb35..828437430 100644 --- a/libs/services/src/lib/playlist-backup.service.ts +++ b/libs/services/src/lib/playlist-backup.service.ts @@ -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 { - 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; diff --git a/libs/services/src/lib/playlist-backup.service.xtream-restore.spec.ts b/libs/services/src/lib/playlist-backup.service.xtream-restore.spec.ts index 72fd1a7c1..b70943499 100644 --- a/libs/services/src/lib/playlist-backup.service.xtream-restore.spec.ts +++ b/libs/services/src/lib/playlist-backup.service.xtream-restore.spec.ts @@ -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);