fix(settings): fail closed on relock ahead of the apply queue and judge Xtream selections by the lock store

- On relock the synchronous fail-closed steps (M3U channel, Stalker
  selection, the locked Xtream detail, catalog lists, stored search) run
  immediately instead of queueing behind an earlier apply that may still
  wait on a slow or hung read.
- The post-reload Xtream checks decide by the lock store through the
  unfiltered category rows rather than by absence from the reloaded list,
  which also omits merely hidden categories; unreadable rows fail closed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-09-27 12:18:33 +02:00
1 parent 395dd570d9
commit acabd99107
3 files changed
+183 -105

No files matched your search

@@ -3,13 +3,17 @@ import { TestBed } from '@angular/core/testing';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import {
XTREAM_DATA_SOURCE,
XtreamStore,
} from '@iptvnator/portal/xtream/data-access';
import { ParentalLockService } from '@iptvnator/services';
import { ParentalLockEnforcementService } from './parental-lock-enforcement.service';
import { PlaybackKeepAwakeService } from './playback-keep-awake.service';
interface Applier {
apply(): Promise<void>;
failClosedNow(): void;
applyXtream(version: number): Promise<void>;
applyStalker(): Promise<void>;
}
@@ -59,6 +63,14 @@ describe('ParentalLockEnforcementService', () => {
setSelectedItem: jest.fn(),
setSelectedCategory: jest.fn(),
};
const xtreamDataSource = {
getAllCategories: jest.fn(async () => [
{ id: 7, xtream_id: 70 },
{ id: 8, xtream_id: 80 },
{ id: 55, xtream_id: 550 },
{ id: 99, xtream_id: 990 },
]),
};
let service: Applier;
beforeEach(() => {
@@ -75,6 +87,7 @@ describe('ParentalLockEnforcementService', () => {
providers: [
{ provide: ParentalLockService, useValue: parentalLock },
{ provide: XtreamStore, useValue: xtreamStore },
{ provide: XTREAM_DATA_SOURCE, useValue: xtreamDataSource },
{ provide: StalkerStore, useValue: stalkerStore },
{ provide: Router, useValue: router },
{
@@ -214,13 +227,25 @@ describe('ParentalLockEnforcementService', () => {
});
describe('Xtream', () => {
it('clears a selected item whose category is no longer readable', async () => {
function lockProvider(providerId: number): void {
parentalLock.active.set(true);
parentalLock.isXtreamCategoryLocked.mockImplementation(
(_p: string, _t: string, id: number) => id === providerId
);
}
it('clears a selected item whose category the lock store withholds', async () => {
router.url = '/workspace/xtreams/xtream-1/vod/42';
lockProvider(990);
xtreamStore.selectedItem.set({ category_id: 99 });
await service.applyXtream(parentalLock.version());
expect(xtreamStore.reloadCategories).toHaveBeenCalled();
expect(xtreamDataSource.getAllCategories).toHaveBeenCalledWith(
'xtream-1',
'movies'
);
expect(xtreamStore.setSelectedItem).toHaveBeenCalledWith(null);
expect(xtreamStore.setSelectedCategory).not.toHaveBeenCalled();
expect(router.navigate).toHaveBeenCalledWith([
@@ -231,52 +256,56 @@ describe('ParentalLockEnforcementService', () => {
]);
});
it('withholds the catalog and a locked detail before the reload on relock', async () => {
it('keeps a detail from a category that is merely hidden, not locked', async () => {
router.url = '/workspace/xtreams/xtream-1/vod/42';
parentalLock.active.set(true);
parentalLock.isXtreamCategoryLocked.mockImplementation(
(_p: string, _t: string, providerId: number) =>
providerId === 70
);
xtreamStore.selectedItem.set({ category_id: 7 });
const order: string[] = [];
xtreamStore.withholdCatalog.mockImplementation(() =>
order.push('withhold')
);
xtreamStore.setSelectedItem.mockImplementation(() =>
order.push('step-off')
);
xtreamStore.reloadCategories.mockImplementation(async () => {
order.push('reload');
});
lockProvider(990);
// Row 55 is absent from the (hidden-filtered) visible list but
// exists unlocked in the unfiltered rows.
xtreamStore.selectedItem.set({ category_id: 55 });
await service.applyXtream(parentalLock.version());
expect(order.slice(0, 3)).toEqual([
'step-off',
'withhold',
'reload',
]);
expect(xtreamStore.clearSearchResults).toHaveBeenCalled();
expect(parentalLock.isXtreamCategoryLocked).toHaveBeenCalledWith(
'xtream-1',
'movies',
70
);
expect(xtreamStore.setSelectedItem).not.toHaveBeenCalled();
expect(router.navigate).not.toHaveBeenCalled();
});
it('steps off a selected locked category', async () => {
router.url = '/workspace/xtreams/xtream-1/live';
lockProvider(990);
xtreamStore.selectedCategoryId.set(99);
await service.applyXtream(parentalLock.version());
expect(xtreamStore.setSelectedItem).toHaveBeenCalledWith(null);
expect(xtreamStore.setSelectedCategory).toHaveBeenCalledWith(null);
expect(router.navigate).toHaveBeenCalledWith([
'/workspace',
'xtreams',
'xtream-1',
'vod',
'live',
]);
});
it('does not withhold on unlock, and hands the reloads a publish guard', async () => {
it('fails closed when the category rows cannot be read', async () => {
router.url = '/workspace/xtreams/xtream-1/vod/42';
parentalLock.active.set(true);
xtreamDataSource.getAllCategories.mockRejectedValueOnce(
new Error('db')
);
xtreamStore.selectedItem.set({ category_id: 7 });
await service.applyXtream(parentalLock.version());
expect(xtreamStore.setSelectedItem).toHaveBeenCalledWith(null);
});
it('skips the post-reload checks while unlocked and hands the reloads a publish guard', async () => {
router.url = '/workspace/xtreams/xtream-1/vod';
await service.applyXtream(parentalLock.version());
expect(xtreamStore.withholdCatalog).not.toHaveBeenCalled();
expect(xtreamDataSource.getAllCategories).not.toHaveBeenCalled();
const guard = xtreamStore.reloadCategories.mock.calls[0][0] as
(() => boolean) | undefined;
expect(guard?.()).toBe(true);
@@ -292,31 +321,17 @@ describe('ParentalLockEnforcementService', () => {
expect(xtreamStore.refreshSearchResults).toHaveBeenCalled();
});
it('keeps a selected item whose category survived the lock', async () => {
it('fails closed synchronously on relock: detail, catalog and search', () => {
router.url = '/workspace/xtreams/xtream-1/vod/42';
xtreamStore.selectedCategoryId.set(7);
lockProvider(70);
xtreamStore.selectedItem.set({ category_id: 7 });
await service.applyXtream(parentalLock.version());
expect(xtreamStore.setSelectedItem).not.toHaveBeenCalled();
expect(router.navigate).not.toHaveBeenCalled();
});
it('steps off a selected category that vanished', async () => {
router.url = '/workspace/xtreams/xtream-1/live';
xtreamStore.selectedCategoryId.set(99);
await service.applyXtream(parentalLock.version());
service.failClosedNow();
expect(xtreamStore.setSelectedItem).toHaveBeenCalledWith(null);
expect(xtreamStore.setSelectedCategory).toHaveBeenCalledWith(null);
expect(router.navigate).toHaveBeenCalledWith([
'/workspace',
'xtreams',
'xtream-1',
'live',
]);
expect(xtreamStore.withholdCatalog).toHaveBeenCalled();
expect(xtreamStore.clearSearchResults).toHaveBeenCalled();
expect(xtreamStore.reloadCategories).not.toHaveBeenCalled();
});
});
});
@@ -334,6 +349,8 @@ describe('ParentalLockEnforcementService apply serialization', () => {
),
reloadCachedContent: jest.fn(async () => undefined),
refreshSearchResults: jest.fn(async () => undefined),
withholdCatalog: jest.fn(),
clearSearchResults: jest.fn(),
getCategoriesBySelectedType: jest.fn(() => [] as unknown[]),
setSelectedItem: jest.fn(),
setSelectedCategory: jest.fn(),
@@ -352,13 +369,22 @@ describe('ParentalLockEnforcementService apply serialization', () => {
},
},
{ provide: XtreamStore, useValue: xtreamStore },
{
// No rows: the selected category cannot be placed and
// fails closed once an apply gets to judge it.
provide: XTREAM_DATA_SOURCE,
useValue: { getAllCategories: jest.fn(async () => []) },
},
{
provide: StalkerStore,
useValue: { currentPlaylist: signal(null) },
},
{
provide: Router,
useValue: { url: '/', navigate: jest.fn() },
useValue: {
url: '/workspace/xtreams/xtream-1/live',
navigate: jest.fn(),
},
},
{
provide: Store,
@@ -383,9 +409,13 @@ describe('ParentalLockEnforcementService apply serialization', () => {
await Promise.resolve();
expect(xtreamStore.reloadCategories).toHaveBeenCalledTimes(1);
// ...and "Lock now" arrives meanwhile: no second reload starts yet.
// ...and "Lock now" arrives meanwhile: no second reload starts yet,
// but the catalog is withheld at once rather than behind the hung
// read.
xtreamStore.withholdCatalog.mockClear();
version.set(2);
TestBed.flushEffects();
expect(xtreamStore.withholdCatalog).toHaveBeenCalledTimes(1);
await Promise.resolve();
expect(xtreamStore.reloadCategories).toHaveBeenCalledTimes(1);
@@ -415,6 +445,7 @@ describe('ParentalLockEnforcementService busy probe', () => {
},
},
{ provide: XtreamStore, useValue: {} },
{ provide: XTREAM_DATA_SOURCE, useValue: {} },
{ provide: StalkerStore, useValue: {} },
{ provide: Router, useValue: { url: '/' } },
{
@@ -8,7 +8,10 @@ import {
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { ChannelActions, selectActive } from '@iptvnator/m3u-state';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import {
XTREAM_DATA_SOURCE,
XtreamStore,
} from '@iptvnator/portal/xtream/data-access';
import { ParentalLockService } from '@iptvnator/services';
import { toParentalLockXtreamCategoryType } from '@iptvnator/shared/interfaces';
import { PlaybackKeepAwakeService } from './playback-keep-awake.service';
@@ -29,6 +32,7 @@ export const STALKER_ROUTE =
export class ParentalLockEnforcementService {
private readonly parentalLock = inject(ParentalLockService);
private readonly xtreamStore = inject(XtreamStore);
private readonly xtreamDataSource = inject(XTREAM_DATA_SOURCE);
private readonly injector = inject(EnvironmentInjector);
private readonly router = inject(Router);
private readonly store = inject(Store);
@@ -57,9 +61,17 @@ export class ParentalLockEnforcementService {
}
const first = this.lastVersion === -1;
this.lastVersion = version;
if (!first) {
this.scheduleApply();
if (first) {
return;
}
// Fail closed NOW, ahead of the serialized queue: an earlier
// apply may still be waiting on a slow (or hung) read, and
// the catalog, details and playback read while unlocked must
// not stay usable until it settles.
if (this.parentalLock.active()) {
this.failClosedNow();
}
this.scheduleApply();
});
});
}
@@ -82,6 +94,28 @@ export class ParentalLockEnforcementService {
});
}
/**
* The synchronous half of a relock: M3U channel, Stalker selection (its
* step is lazy, so it runs as soon as the chunk is there), the Xtream
* detail the lock store already places in a locked category, the
* catalog lists and the stored search. The queued apply then reloads
* the filtered rows and repeats the checks against them.
*/
private failClosedNow(): void {
this.applyM3u();
void this.applyStalker();
const playlistId = this.xtreamStore.playlistId?.();
if (!playlistId) {
return;
}
this.stepOffLockedXtreamSelection(
playlistId,
XTREAM_ROUTE.exec(this.router.url)
);
this.xtreamStore.withholdCatalog?.();
this.xtreamStore.clearSearchResults?.();
}
private async apply(): Promise<void> {
const version = this.parentalLock.version();
// The synchronous surfaces first: an M3U channel or a Stalker
@@ -100,17 +134,6 @@ export class ParentalLockEnforcementService {
}
const shouldPublish = (): boolean =>
this.parentalLock.version() === version;
const match = XTREAM_ROUTE.exec(this.router.url);
if (this.parentalLock.active()) {
// Relock: fail closed NOW, not after the database answers. The
// selected detail is judged against the lock store while the
// pre-reload category list can still map its category; the
// catalog lists and stored search results are emptied and
// refilled by the filtered reads below.
this.stepOffLockedXtreamSelection(playlistId, match);
this.xtreamStore.withholdCatalog?.();
this.xtreamStore.clearSearchResults?.();
}
await this.xtreamStore.reloadCategories(shouldPublish);
await this.xtreamStore.reloadCachedContent(shouldPublish);
if (!shouldPublish()) {
@@ -119,51 +142,70 @@ export class ParentalLockEnforcementService {
// Stored in-portal search results are a separate array the search
// page renders directly; re-run the search so it reads filtered.
await this.xtreamStore.refreshSearchResults?.();
if (!shouldPublish() || !this.parentalLock.active()) {
return;
}
await this.stepOffWithheldXtreamSelection(playlistId, shouldPublish);
}
/**
* Post-reload check of the selected Xtream category and item, judged by
* the LOCK STORE through the unfiltered category rows (hidden and locked
* ones included): the reloaded list also omits categories the user
* merely hid, which are not parental-locked. The item is judged on its
* own category — opened from "All", recently added or search it has no
* selected category to vanish with. Rows that cannot be read fail
* closed.
*/
private async stepOffWithheldXtreamSelection(
playlistId: string,
shouldPublish: () => boolean
): Promise<void> {
const match = XTREAM_ROUTE.exec(this.router.url);
const categoryType = toParentalLockXtreamCategoryType(match?.[2]);
if (!categoryType) {
return;
}
const rows = await this.xtreamDataSource
.getAllCategories(playlistId, categoryType)
.catch(() => null);
if (!shouldPublish()) {
return;
}
const categoryType = toParentalLockXtreamCategoryType(match?.[2]);
const categories = this.xtreamStore.getCategoriesBySelectedType();
const isVisibleCategory = (categoryId: unknown): boolean =>
categories.some(
(category) =>
Number(
(category as { id?: number | string }).id ??
(category as { category_id?: string }).category_id
) === Number(categoryId)
const isWithheld = (categoryId: unknown): boolean => {
const id = Number(categoryId);
if (
categoryId === null ||
categoryId === undefined ||
!Number.isFinite(id)
) {
return false;
}
const row = rows?.find((candidate) => candidate.id === id);
return (
!row ||
this.parentalLock.isXtreamCategoryLocked(
playlistId,
categoryType,
row.xtream_id
)
);
const selectedCategoryId = this.xtreamStore.selectedCategoryId();
// The selected ITEM is judged on its own category: opened from
// "All", recently added or search it has no selected category to
// vanish with, yet its detail must not outlive the lock.
};
const selectedItem = this.xtreamStore.selectedItem?.() as {
category_id?: string | number;
} | null;
const itemWithheld =
selectedItem?.category_id !== undefined &&
selectedItem?.category_id !== null &&
!isVisibleCategory(selectedItem.category_id);
if (itemWithheld) {
this.xtreamStore.setSelectedItem(null);
}
if (
selectedCategoryId === null ||
isVisibleCategory(selectedCategoryId)
) {
if (itemWithheld && match && match[1] === playlistId) {
void this.router.navigate([
'/workspace',
'xtreams',
match[1],
match[2],
]);
}
const itemWithheld = isWithheld(selectedItem?.category_id);
const categoryWithheld = isWithheld(
this.xtreamStore.selectedCategoryId()
);
if (!itemWithheld && !categoryWithheld) {
return;
}
this.xtreamStore.setSelectedItem(null);
this.xtreamStore.setSelectedCategory(null);
if (match && match[1] === playlistId && categoryType) {
if (categoryWithheld) {
this.xtreamStore.setSelectedCategory(null);
}
if (match && match[1] === playlistId) {
void this.router.navigate([
'/workspace',
'xtreams',
+7 -2
View File
@@ -181,14 +181,19 @@ on either side.
`searchContent` call as issued) — `searchResults` is a separate array the
search page renders directly and would otherwise keep locked titles until
the query changes. A RELOCK fails closed at once rather than after the
database answers: the selected detail is stepped off synchronously when
database answers — ahead of the serialized apply queue, so an earlier
apply still waiting on a slow or hung read cannot delay it: the selected detail is stepped off synchronously when
the lock store already names its category (the pre-reload category list
maps Electron's row id to the provider id), then `withholdCatalog()`
empties every catalog list and `clearSearchResults()` the stored search
(retiring a search still in flight, which was issued under the previous
lock state) before the filtered reads refill them; both reloads take a publish guard
answered before every state patch, so a read issued under an older lock
version is dropped instead of published. A lock change that overtakes
version is dropped instead of published. The post-reload checks of the
selected category and item decide by the LOCK STORE through the
unfiltered category rows (`IXtreamDataSource.getAllCategories`), not by
absence from the reloaded list, which also omits categories the user
merely hid; rows that cannot be read fail closed. A lock change that overtakes
the INITIAL hydration (the content is not initialized yet, so the reload
has nothing to re-read) sets a deferred-reload flag instead — already
when `withholdCatalog()` empties the lists, before the category reload is