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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-09-27 15:28:22 +02:00
1 parent 10cd201ebf
commit 4c64b798e1
4 files changed
+54 -1

No files matched your search

@@ -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';
@@ -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(() => {
+5 -1
View File
@@ -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
@@ -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))
) {