From 4c64b798e11f0c4e40f711dafa42a1cd10f6bd88 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 27 Sep 2026 15:28:22 +0200 Subject: [PATCH] fix(settings): reset a locked M3U channel however it becomes active The enforcement service now checks the active M3U channel whenever it changes while locked, so numeric zapping, next/previous and remote commands, which select from the full channel list, cannot start a channel of a locked group. Numeric zapping also skips such a channel. Co-Authored-By: Claude Opus 5.5 --- .../parental-lock-enforcement.service.spec.ts | 25 +++++++++++++++++++ .../parental-lock-enforcement.service.ts | 11 ++++++++ docs/architecture/parental-lock.md | 6 ++++- .../video-player/video-player.component.ts | 13 ++++++++++ 4 files changed, 54 insertions(+), 1 deletion(-) diff --git a/apps/web/src/app/services/parental-lock-enforcement.service.spec.ts b/apps/web/src/app/services/parental-lock-enforcement.service.spec.ts index 65c1bcbe5..db13c5d85 100644 --- a/apps/web/src/app/services/parental-lock-enforcement.service.spec.ts +++ b/apps/web/src/app/services/parental-lock-enforcement.service.spec.ts @@ -132,6 +132,31 @@ describe('ParentalLockEnforcementService', () => { }); }); + it('resets a channel of a locked group as soon as it becomes active while locked', () => { + // Numeric zapping, next/previous or a remote command can select a + // channel without any lock-version change following it. + router.url = '/workspace/playlists/m3u-1'; + parentalLock.active.set(true); + parentalLock.isM3uGroupLocked.mockImplementation( + (_playlistId: string, group: string) => group === 'Adult' + ); + TestBed.runInInjectionContext(() => + (service as unknown as { start(): void }).start() + ); + TestBed.flushEffects(); + dispatch.mockClear(); + + activeChannel.set({ group: { title: 'News' } }); + TestBed.flushEffects(); + expect(dispatch).not.toHaveBeenCalled(); + + activeChannel.set({ group: { title: 'Adult' } }); + TestBed.flushEffects(); + expect(dispatch).toHaveBeenCalledTimes(1); + parentalLock.isM3uGroupLocked.mockReset(); + parentalLock.isM3uGroupLocked.mockReturnValue(false); + }); + describe('Stalker', () => { it('leaves the Stalker route when the Stalker step cannot load', async () => { router.url = '/workspace/stalker/stalker-1/itv'; diff --git a/apps/web/src/app/services/parental-lock-enforcement.service.ts b/apps/web/src/app/services/parental-lock-enforcement.service.ts index 64108ca69..16a092755 100644 --- a/apps/web/src/app/services/parental-lock-enforcement.service.ts +++ b/apps/web/src/app/services/parental-lock-enforcement.service.ts @@ -65,6 +65,17 @@ export class ParentalLockEnforcementService { void this.loadStalker().catch(() => undefined); } }); + // Any path that makes a channel active (numeric zapping, next / + // previous, remote commands, a stale list) is checked here: a + // channel of a locked group is reset as soon as it becomes active. + effect(() => { + this.activeChannel(); + untracked(() => { + if (this.parentalLock.active()) { + this.applyM3u(); + } + }); + }); effect(() => { const version = this.parentalLock.version(); untracked(() => { diff --git a/docs/architecture/parental-lock.md b/docs/architecture/parental-lock.md index 2da19cde0..507689e8c 100644 --- a/docs/architecture/parental-lock.md +++ b/docs/architecture/parental-lock.md @@ -191,7 +191,11 @@ on either side. surfaces (the M3U active channel, the Stalker selection) are stepped off BEFORE the Xtream reloads are awaited, so a playing M3U channel never waits behind a slow portal read — the Xtream store stays populated after - leaving that portal, so its reload runs on every apply. Applies run one + leaving that portal, so its reload runs on every apply. The M3U check + also runs whenever the active channel changes while locked: numeric + zapping, next/previous and remote commands select from the full channel + list without any lock change following, so a channel of a locked group + is reset as soon as it becomes active (numeric zapping also skips it). Applies run one at a time and each is abandoned once a newer `version` exists (the queued apply reads the latest state): `ElectronXtreamDataSource` shares in-flight category/content diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts index 7148734de..c2e29d313 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts @@ -114,6 +114,7 @@ import { createPlaybackSessionKey } from '@iptvnator/playback/util'; import { ChannelListLoadingStateComponent } from '@iptvnator/ui/components'; import { DataService, + ParentalLockService, PlaylistsService, RecordingsService, RuntimeCapabilitiesService, @@ -241,6 +242,7 @@ export class VideoPlayerComponent private readonly router = inject(Router); private readonly runtime = inject(RuntimeCapabilitiesService); private readonly settingsStore = inject(SettingsStore); + private readonly parentalLock = inject(ParentalLockService); private readonly storage = inject(StorageMap); private readonly store = inject(Store); private readonly epgService = inject(EpgService); @@ -1437,8 +1439,19 @@ export class VideoPlayerComponent ) ) .subscribe((channel) => { + // The number indexes the full list; a channel of a locked + // group is not zapped to (the enforcement service would + // reset it anyway, after playback had started). + const playlistId = this.activePlaylistMeta()?._id; if ( channel && + !( + playlistId && + this.parentalLock.isM3uGroupLocked( + playlistId, + channel.group?.title ?? '' + ) + ) && (!this.isLivePlayerFullscreen() || this.keepsInlinePlayer(channel)) ) {