From d4d0754e12f37b9d89e5bd84a573a4bc0b7cbe99 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 26 Sep 2026 10:28:12 +0200 Subject: [PATCH] fix(settings): step off the M3U channel and Stalker selection before awaiting the Xtream relock reload The Xtream store stays populated after leaving that portal, so its reload runs on every apply; a locked M3U channel no longer keeps playing behind a slow database or provider read. Co-Authored-By: Claude Fable 5.1 --- .../parental-lock-enforcement.service.spec.ts | 35 ++++++++++++++++--- .../parental-lock-enforcement.service.ts | 10 +++--- docs/architecture/parental-lock.md | 7 +++- 3 files changed, 43 insertions(+), 9 deletions(-) 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 4af30a411..8f23f8812 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 @@ -9,12 +9,15 @@ import { ParentalLockEnforcementService } from './parental-lock-enforcement.serv import { PlaybackKeepAwakeService } from './playback-keep-awake.service'; interface Applier { + apply(): Promise; applyXtream(version: number): Promise; applyStalker(): Promise; } describe('ParentalLockEnforcementService', () => { const router = { url: '/', navigate: jest.fn() }; + const activeChannel = signal<{ group?: { title: string } } | null>(null); + const dispatch = jest.fn(); const lockedStalkerIds = new Set(); const parentalLock = { version: signal(0), @@ -76,10 +79,7 @@ describe('ParentalLockEnforcementService', () => { { provide: Router, useValue: router }, { provide: Store, - useValue: { - selectSignal: () => signal(null), - dispatch: jest.fn(), - }, + useValue: { selectSignal: () => activeChannel, dispatch }, }, { provide: PlaybackKeepAwakeService, @@ -87,11 +87,38 @@ describe('ParentalLockEnforcementService', () => { }, ], }); + activeChannel.set(null); service = TestBed.inject( ParentalLockEnforcementService ) as unknown as Applier; }); + describe('M3U', () => { + it('resets a locked playing channel before awaiting the portal reloads', async () => { + router.url = '/workspace/playlists/m3u-1'; + activeChannel.set({ group: { title: 'Adult' } }); + parentalLock.isM3uGroupLocked.mockReturnValue(true); + let releaseReload: () => void = () => undefined; + xtreamStore.reloadCategories.mockImplementationOnce( + () => new Promise((resolve) => (releaseReload = resolve)) + ); + + const applying = service.apply(); + await Promise.resolve(); + + expect(parentalLock.isM3uGroupLocked).toHaveBeenCalledWith( + 'm3u-1', + 'Adult' + ); + expect(dispatch).toHaveBeenCalledTimes(1); + expect(xtreamStore.reloadCategories).toHaveBeenCalledTimes(1); + + releaseReload(); + await applying; + parentalLock.isM3uGroupLocked.mockReturnValue(false); + }); + }); + describe('Stalker', () => { it('clears a detail opened from All whose own genre is withheld', async () => { router.url = '/workspace/stalker/stalker-1/vod/42'; 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 0fb352034..9574a00fc 100644 --- a/apps/web/src/app/services/parental-lock-enforcement.service.ts +++ b/apps/web/src/app/services/parental-lock-enforcement.service.ts @@ -79,11 +79,13 @@ export class ParentalLockEnforcementService { private async apply(): Promise { const version = this.parentalLock.version(); - await Promise.all([this.applyXtream(version), this.applyStalker()]); - if (this.parentalLock.version() !== version) { - return; - } + // The synchronous surfaces first: an M3U channel or a Stalker + // selection must not keep playing behind a slow Xtream database or + // provider reload — the Xtream store stays populated after leaving + // that portal, so its reload runs on every apply. this.applyM3u(); + await this.applyStalker(); + await this.applyXtream(version); } private async applyXtream(version: number): Promise { diff --git a/docs/architecture/parental-lock.md b/docs/architecture/parental-lock.md index 439aca740..80efbe5d0 100644 --- a/docs/architecture/parental-lock.md +++ b/docs/architecture/parental-lock.md @@ -151,7 +151,12 @@ on either side. "All", recently added or search has no selected category to vanish with, so its row disappearing from a list is not enough — the item is cleared and the route returns to the section root while the (still - readable) selected category stays. Applies run one at a time and each is + readable) selected category stays. Within one apply the synchronous + 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 + 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 reads per playlist and type, and its share key carries the lock version,