fix(settings): fail closed on a rejected worker lock sync, tear down Stalker synchronously and capture the Xtream dialog context before the PIN

- A rejected lock-state sync to the SQLite worker makes the locked
  session withhold everything until a later sync succeeds.
- The Stalker enforcement chunk is preloaded when a Stalker route
  opens; a relock runs it synchronously, or leaves the route at once
  while it is not loaded, instead of awaiting the chunk.
- Xtream "Manage categories" captures playlist, provider and section
  before the PIN prompt and re-checks them after it and after the
  dialog import.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-09-27 15:11:18 +02:00
1 parent 4aa1b5a8e6
commit 067f920833
8 files changed
+267 -62

No files matched your search

@@ -151,6 +151,34 @@ describe('ParentalLockEnforcementService', () => {
]);
});
it('leaves the Stalker route synchronously on relock while the step is not loaded', () => {
router.url = '/workspace/stalker/stalker-1/itv';
(
service as unknown as {
loadStalkerEnforcement: () => Promise<unknown>;
}
).loadStalkerEnforcement = () => new Promise(() => undefined);
service.failClosedNow();
expect(router.navigate).toHaveBeenCalledWith([
'/workspace',
'sources',
]);
});
it('runs a preloaded Stalker step synchronously on relock', async () => {
router.url = '/workspace/stalker/stalker-1/vod/42';
await service.applyStalker(); // loads the step
lockedStalkerIds.add('9');
stalkerStore.selectedItem.set({ category_id: '9' });
stalkerStore.clearSelectedItem.mockClear();
service.failClosedNow();
expect(stalkerStore.clearSelectedItem).toHaveBeenCalled();
});
it('does not load the Stalker step outside a Stalker route', async () => {
router.url = '/workspace/xtreams/xtream-1/live';
const load = jest.spyOn(
@@ -5,7 +5,7 @@ import {
Injectable,
untracked,
} from '@angular/core';
import { Router } from '@angular/router';
import { NavigationEnd, Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { ChannelActions, selectActive } from '@iptvnator/m3u-state';
import {
@@ -41,6 +41,8 @@ export class ParentalLockEnforcementService {
private started = false;
private lastVersion = -1;
private applyChain: Promise<void> = Promise.resolve();
private stalkerModule: StalkerEnforcementModule | null = null;
private stalkerModuleLoad: Promise<StalkerEnforcementModule> | null = null;
start(): void {
if (this.started) {
@@ -53,6 +55,16 @@ export class ParentalLockEnforcementService {
this.parentalLock.registerBusyProbe(
() => this.keepAwake.hasPlayingVideo() || hasPlayingAudio()
);
// The Stalker step is loaded as soon as a Stalker route opens, so a
// relock there can run it synchronously instead of awaiting a chunk.
this.router.events?.subscribe((event) => {
if (
event instanceof NavigationEnd &&
STALKER_ROUTE.test(event.urlAfterRedirects)
) {
void this.loadStalker().catch(() => undefined);
}
});
effect(() => {
const version = this.parentalLock.version();
untracked(() => {
@@ -103,7 +115,7 @@ export class ParentalLockEnforcementService {
*/
private failClosedNow(): void {
this.applyM3u();
void this.applyStalker();
this.failClosedStalkerNow();
const playlistId = this.xtreamStore.playlistId?.();
if (!playlistId) {
return;
@@ -273,9 +285,42 @@ export class ParentalLockEnforcementService {
}
/** The dynamic import; a field so specs can substitute it. */
loadStalkerEnforcement = () =>
loadStalkerEnforcement = (): Promise<StalkerEnforcementModule> =>
import('./parental-lock-stalker-enforcement');
/** Loads the Stalker step once; a failed load may be retried. */
private loadStalker(): Promise<StalkerEnforcementModule> {
this.stalkerModuleLoad ??= this.loadStalkerEnforcement().then(
(module) => (this.stalkerModule = module),
(error: unknown) => {
this.stalkerModuleLoad = null;
throw error;
}
);
return this.stalkerModuleLoad;
}
/**
* The relock's Stalker step, synchronously: run it when the chunk is
* there, otherwise leave the Stalker route at once (its route session
* clears the selection and the live layout stops playback) rather than
* wait for a chunk that may be slow or never arrive.
*/
private failClosedStalkerNow(): void {
if (!STALKER_ROUTE.test(this.router.url)) {
return;
}
if (!this.stalkerModule) {
void this.router.navigate(['/workspace', 'sources']);
return;
}
this.stalkerModule.applyParentalLockToStalker(
this.injector,
this.parentalLock,
this.router
);
}
/**
* The Stalker data layer stays off the initial path: the step is loaded
* only while a Stalker route is open. Outside one there is nothing to
@@ -286,9 +331,9 @@ export class ParentalLockEnforcementService {
if (!STALKER_ROUTE.test(this.router.url)) {
return;
}
let module: Awaited<ReturnType<typeof this.loadStalkerEnforcement>>;
let module: StalkerEnforcementModule;
try {
module = await this.loadStalkerEnforcement();
module = await this.loadStalker();
} catch (error) {
// Fail closed (e.g. a stale PWA page whose chunk is gone): leave
// the Stalker route. Its route session clears the selection and
@@ -328,6 +373,9 @@ export class ParentalLockEnforcementService {
}
}
type StalkerEnforcementModule =
typeof import('./parental-lock-stalker-enforcement');
function hasPlayingAudio(): boolean {
return Array.from(document.querySelectorAll('audio')).some(
(audio) => !audio.paused && !audio.ended