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;