mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
1 parent
5d6c378f22
commit
2161c6a859
5 files changed
+83
-9
No files matched your search
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user