From 395dd570d96f8e520117f037a94bdd2cff0a58f1 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 27 Sep 2026 12:04:45 +0200 Subject: [PATCH] fix(settings): restore backup locks after the Xtream merge and snapshot the Stalker lock dialog's categories - A backup restore now writes the parental locks last, so a failed Xtream merge leaves the playlist's previous locks in place instead of the backup's possibly smaller set. - The Stalker lock dialog snapshots the category list before its lazy import and opens only if the route is unchanged, so another portal's categories can never be saved under this playlist. Co-Authored-By: Claude Opus 5.5 --- docs/architecture/parental-lock.md | 4 +- .../src/lib/playlist-backup.service.ts | 7 +- ...list-backup.service.xtream-restore.spec.ts | 25 +++++++ .../workspace-context-panel.component.spec.ts | 67 +++++++++++++++++++ .../workspace-context-panel.component.ts | 35 +++++++--- 5 files changed, 124 insertions(+), 14 deletions(-) diff --git a/docs/architecture/parental-lock.md b/docs/architecture/parental-lock.md index 6bc30c4f3..72d31eb3f 100644 --- a/docs/architecture/parental-lock.md +++ b/docs/architecture/parental-lock.md @@ -417,7 +417,9 @@ never runs against the empty fail-closed snapshot. ## Backup -M3U group titles travel verbatim (`normalizeParentalLockGroupTitles`, exact +Locks are restored LAST for each entry, after the Xtream data restore, so a +failed merge leaves the playlist's previous locks in place. M3U group titles +travel verbatim (`normalizeParentalLockGroupTitles`, exact dedup): locks match `channel.group.title` exactly, so the trimming `uniqueStrings` used for favorites would weaken a lock on a title with surrounding whitespace. A backup's lock lists are validated entry by entry diff --git a/libs/services/src/lib/playlist-backup.service.ts b/libs/services/src/lib/playlist-backup.service.ts index 76e213e7a..01e5b4546 100644 --- a/libs/services/src/lib/playlist-backup.service.ts +++ b/libs/services/src/lib/playlist-backup.service.ts @@ -184,12 +184,15 @@ export class PlaylistBackupService { this.playlistsService.addPlaylist(nextPlaylist) ); - await this.restoreParentalLocks(targetId, entry, isMerge); - if (entry.portalType === 'xtream') { await this.restoreXtreamEntry(targetId, entry); } + // Last: a failed Xtream restore above must leave the + // playlist's previous locks in place rather than commit the + // backup's (possibly smaller) lock set for a failed merge. + await this.restoreParentalLocks(targetId, entry, isMerge); + if (isMerge) { summary.merged += 1; } else { 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 d2e5af9b9..bf78c0464 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,31 @@ describe('PlaylistBackupService Xtream hidden categories (issue #1017)', () => { ); }); + it('keeps the previous locks when the Xtream part of a merge fails', async () => { + const collaborators = createRestoreCollaborators(); + const replacePlaylistLocks = jest.fn().mockResolvedValue(true); + const service = createPlaylistBackupService({ + ...collaborators, + parentalLock: { + initialize: jest.fn().mockResolvedValue(undefined), + locksReadable: jest.fn(() => true), + ensureLocksReadable: jest.fn().mockResolvedValue(true), + locksFor: jest.fn(() => ({ xtream: [], stalker: [], m3u: [] })), + replacePlaylistLocks, + }, + }); + collaborators.databaseService.updateCategoryVisibility.mockRejectedValue( + new Error('SQLITE_BUSY') + ); + + const summary = await service.importBackup( + JSON.stringify(createXtreamManifest([])) + ); + + expect(summary.failed).toBe(1); + expect(replacePlaylistLocks).not.toHaveBeenCalled(); + }); + 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). diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.spec.ts index 9e2dab525..6b2a69e01 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.spec.ts @@ -16,6 +16,7 @@ import { XtreamStore, } from '@iptvnator/portal/xtream/data-access'; import { LiveLayoutSidebarStateService } from '@iptvnator/portal/shared/util'; +import { ParentalLockService } from '@iptvnator/services'; import { WorkspaceContextPanelComponent } from './workspace-context-panel.component'; const translations: Record = { @@ -95,6 +96,10 @@ describe('WorkspaceContextPanelComponent', () => { reloadCategories: jest.fn(), }; const stalkerStore = { + getAllCategoriesForSelectedType: jest.fn(() => [ + { category_id: '*', category_name: 'All' }, + { category_id: '1', category_name: 'News' }, + ]), getCategoryResource: signal< Array<{ category_id: string; category_name: string }> >([]), @@ -900,4 +905,66 @@ describe('WorkspaceContextPanelComponent', () => { ).toBe('Account disabled'); }); }); + + describe('Stalker lock dialog', () => { + function enableLock(): void { + const parentalLock = TestBed.inject(ParentalLockService); + Object.defineProperty(parentalLock, 'enabled', { + configurable: true, + value: signal(true), + }); + jest.spyOn(parentalLock, 'requestUnlock').mockResolvedValue(true); + } + + it('opens with the categories snapshotted before the lazy import', async () => { + enableLock(); + fixture.componentRef.setInput('context', { + provider: 'stalker', + playlistId: 'stalker-1', + }); + fixture.componentRef.setInput('section', 'itv'); + fixture.detectChanges(); + const component = fixture.componentInstance; + component.loadStalkerLockDialog = jest.fn( + async () => class DialogStub {} + ); + + await component.openManageStalkerCategories(); + + expect(dialog.open).toHaveBeenCalledWith( + expect.any(Function), + expect.objectContaining({ + data: { + playlistId: 'stalker-1', + contentType: 'itv', + categories: [ + { category_id: '1', category_name: 'News' }, + ], + }, + }) + ); + }); + + it('opens nothing when the route changed during the lazy import', async () => { + enableLock(); + fixture.componentRef.setInput('context', { + provider: 'stalker', + playlistId: 'stalker-1', + }); + fixture.componentRef.setInput('section', 'itv'); + fixture.detectChanges(); + const component = fixture.componentInstance; + component.loadStalkerLockDialog = jest.fn(async () => { + fixture.componentRef.setInput('context', { + provider: 'stalker', + playlistId: 'stalker-2', + }); + return class DialogStub {}; + }); + + await component.openManageStalkerCategories(); + + expect(dialog.open).not.toHaveBeenCalled(); + }); + }); }); diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts index e40c751f8..17f96bf01 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts @@ -1,4 +1,5 @@ import { + Type, Component, computed, DestroyRef, @@ -526,25 +527,37 @@ export class WorkspaceContextPanelComponent { return; } const context = this.context(); - const contentType = toParentalLockStalkerCategoryType(this.section()); + const section = this.section(); + const contentType = toParentalLockStalkerCategoryType(section); if (!contentType) { return; } - const { StalkerCategoryLockDialogComponent } = - await import('@iptvnator/portal/stalker/feature'); - this.dialog.open(StalkerCategoryLockDialogComponent, { - data: { - playlistId: context.playlistId, - contentType, - categories: this.stalkerStore - .getAllCategoriesForSelectedType() - .filter((category) => String(category.category_id) !== '*'), - }, + // Snapshot before the lazy import: the store follows the CURRENT + // route, so reading it afterwards could hand another portal's + // categories to a dialog that saves them under this playlist. + const categories = this.stalkerStore + .getAllCategoriesForSelectedType() + .filter((category) => String(category.category_id) !== '*'); + const dialogComponent = await this.loadStalkerLockDialog(); + if ( + this.context().playlistId !== context.playlistId || + this.section() !== section + ) { + return; + } + this.dialog.open(dialogComponent, { + data: { playlistId: context.playlistId, contentType, categories }, width: '500px', maxHeight: '90vh', }); } + /** The dynamic import; a field so specs can substitute it. */ + loadStalkerLockDialog = (): Promise> => + import('@iptvnator/portal/stalker/feature').then( + (module) => module.StalkerCategoryLockDialogComponent + ); + async openManageCategories(): Promise { if (!this.canManageXtreamCategories()) { return;