From 0f225d8f956b5f7e3716232d62f70799a002801f Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 27 Sep 2026 15:36:34 +0200 Subject: [PATCH] fix(settings): bind the M3U group management result to its playlist The groups view captures the playlist before the PIN prompt and drops the management dialog's hidden and locked group lists once another playlist is open, so they cannot be saved under that playlist. Co-Authored-By: Claude Opus 5.5 --- docs/architecture/parental-lock.md | 5 ++++- .../channel-list-container.component.html | 1 + .../channel-list-container.component.ts | 2 +- .../groups-view/groups-view.component.spec.ts | 21 ++++++++++++++++++- .../groups-view/groups-view.component.ts | 14 +++++++++++-- 5 files changed, 38 insertions(+), 5 deletions(-) diff --git a/docs/architecture/parental-lock.md b/docs/architecture/parental-lock.md index 507689e8c..df651bae2 100644 --- a/docs/architecture/parental-lock.md +++ b/docs/architecture/parental-lock.md @@ -372,7 +372,10 @@ on either side. lock store, failure snackbar); the M3U rail emits the toggled group to `ChannelListContainerComponent`, which captures the playlist, asks for the PIN, and persists the edit only if that playlist is still open (two - playlists can share a group name). Bulk actions stay in the dialog, and while the session is locked + playlists can share a group name). The M3U management dialog is bound + the same way: `GroupsViewComponent` captures its `playlistId` input + before the PIN and drops the dialog's result once another playlist is + open. Bulk actions stay in the dialog, and while the session is locked a locked category is not in the rail, so unlocking always goes through the "N locked · Enter PIN to show" row or the dialog. The M3U dialog hands its lock list back to `ChannelListContainerComponent`, which diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html index 0134bbead..f3ccb20bf 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html @@ -62,6 +62,7 @@ [activeChannelUrl]="activeChannelUrl()" [favoriteIds]="favoriteIds()" [hiddenGroupTitles]="hiddenGroupTitles()" + [playlistId]="activePlaylist()?._id ?? null" [lockedGroupTitles]="lockedGroupTitles()" [withheldGroupCount]="withheldGroupCount()" [managementGroups]="managementGroups()" diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts index 4d5addf82..ba8f8681f 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts @@ -133,7 +133,7 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy { /** Route-aware playlist ID for recent-item mutations */ private readonly resolvedPlaylistId = this.playlistContext.resolvedPlaylistId; - private readonly activePlaylist = this.playlistContext.activePlaylist; + protected readonly activePlaylist = this.playlistContext.activePlaylist; /** Map of channel ID to current EPG program */ readonly channelEpgMap = signal(new Map()); diff --git a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.spec.ts b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.spec.ts index ab8dfd8b9..957cab029 100644 --- a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.spec.ts +++ b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.spec.ts @@ -4,7 +4,7 @@ import { By } from '@angular/platform-browser'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { MatDialog } from '@angular/material/dialog'; import { TranslateModule } from '@ngx-translate/core'; -import { of } from 'rxjs'; +import { of, Subject } from 'rxjs'; import { ParentalLockService } from '@iptvnator/services'; import { Channel } from '@iptvnator/shared/interfaces'; import { ChannelDetailsDialogComponent } from '../channel-details-dialog/channel-details-dialog.component'; @@ -537,6 +537,25 @@ describe('GroupsViewComponent', () => { ]); }); + it('drops the manage-groups result once another playlist is open', async () => { + const hiddenGroupTitlesChanged = jest.fn(); + const lockedGroupTitlesChanged = jest.fn(); + component.hiddenGroupTitlesChanged.subscribe(hiddenGroupTitlesChanged); + component.lockedGroupTitlesChanged.subscribe(lockedGroupTitlesChanged); + fixture.componentRef.setInput('playlistId', 'playlist-a'); + fixture.componentRef.setInput('lockedGroupTitles', ['News']); + fixture.detectChanges(); + const closed = new Subject(); + dialog.open.mockReturnValue({ afterClosed: () => closed }); + + await component.openGroupManagement(); + fixture.componentRef.setInput('playlistId', 'playlist-b'); + closed.next({ hiddenGroupTitles: [], lockedGroupTitles: [] }); + + expect(hiddenGroupTitlesChanged).not.toHaveBeenCalled(); + expect(lockedGroupTitlesChanged).not.toHaveBeenCalled(); + }); + it('emits a single-group toggle from the right-click menu (the host asks for the PIN)', () => { const groupLockToggled = jest.fn(); component.groupLockToggled.subscribe(groupLockToggled); diff --git a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts index 282839055..a4d4f6369 100644 --- a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts @@ -153,6 +153,12 @@ export class GroupsViewComponent { * of the playlist, locked ones included, because `groupedChannels` * arrives with the withheld groups already removed. */ + /** + * The playlist the groups belong to. The management dialog's result is + * applied only while it is still the open one: the host saves hidden + * and locked groups under the CURRENT playlist. + */ + readonly playlistId = input(null); readonly lockedGroupTitles = input(null); readonly managementGroups = input( null @@ -535,7 +541,11 @@ export class GroupsViewComponent { // The dialog lists every group by name, locked ones included, and can // rewrite the locks — so it sits behind the PIN like the portal // dialogs do. - if (!(await this.parentalLock.requestUnlock())) { + const playlistId = this.playlistId(); + if ( + !(await this.parentalLock.requestUnlock()) || + this.playlistId() !== playlistId + ) { return; } const groups = @@ -558,7 +568,7 @@ export class GroupsViewComponent { }); dialogRef.afterClosed().subscribe((result) => { - if (result === undefined) { + if (result === undefined || this.playlistId() !== playlistId) { return; }