fix(settings): close lock editors on relock and clear withheld details opened from All

The Xtream, Stalker and M3U category editors are gated by the PIN only when
they open; an idle relock left them on screen listing locked names with a
lock-rewriting Save. Each now closes itself when the session relocks.

ParentalLockEnforcementService also judges the selected Xtream/Stalker
item by its own category: a detail opened from All, recently added or
search has no selected category to vanish with, so it stayed open after a
relock.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-26 00:25:01 +02:00
1 parent 45661e311e
commit bc1b0eef41
9 files changed
+438 -21

No files matched your search

@@ -0,0 +1,169 @@
import { signal } from '@angular/core';
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 { ParentalLockService } from '@iptvnator/services';
import { ParentalLockEnforcementService } from './parental-lock-enforcement.service';
import { PlaybackKeepAwakeService } from './playback-keep-awake.service';
interface Applier {
applyXtream(): Promise<void>;
applyStalker(): Promise<void>;
}
describe('ParentalLockEnforcementService', () => {
const router = { url: '/', navigate: jest.fn() };
const lockedStalkerIds = new Set<string>();
const parentalLock = {
version: signal(0),
registerBusyProbe: jest.fn(),
isStalkerCategoryLocked: jest.fn(
(_playlistId: string, _type: string, id: unknown) =>
id !== null &&
id !== undefined &&
lockedStalkerIds.has(String(id))
),
isM3uGroupLocked: jest.fn(() => false),
};
const stalkerStore = {
currentPlaylist: signal<{ _id: string } | null>({ _id: 'stalker-1' }),
selectedContentType: signal<string>('vod'),
selectedCategoryId: signal<string | null>('*'),
selectedItem: signal<{ category_id?: string } | null>(null),
clearSelectedItem: jest.fn(),
setSelectedCategory: jest.fn(),
};
const xtreamStore = {
playlistId: signal<string | null>('xtream-1'),
selectedCategoryId: signal<number | null>(null),
selectedItem: signal<{ category_id?: number } | null>(null),
reloadCategories: jest.fn(async () => undefined),
reloadCachedContent: jest.fn(async () => undefined),
getCategoriesBySelectedType: jest.fn(() => [{ id: 7 }, { id: 8 }]),
setSelectedItem: jest.fn(),
setSelectedCategory: jest.fn(),
};
let service: Applier;
beforeEach(() => {
jest.clearAllMocks();
lockedStalkerIds.clear();
router.url = '/';
stalkerStore.selectedCategoryId.set('*');
stalkerStore.selectedItem.set(null);
xtreamStore.selectedCategoryId.set(null);
xtreamStore.selectedItem.set(null);
TestBed.configureTestingModule({
providers: [
{ provide: ParentalLockService, useValue: parentalLock },
{ provide: XtreamStore, useValue: xtreamStore },
{ provide: StalkerStore, useValue: stalkerStore },
{ provide: Router, useValue: router },
{
provide: Store,
useValue: {
selectSignal: () => signal(null),
dispatch: jest.fn(),
},
},
{
provide: PlaybackKeepAwakeService,
useValue: { hasPlayingVideo: () => false },
},
],
});
service = TestBed.inject(
ParentalLockEnforcementService
) as unknown as Applier;
});
describe('Stalker', () => {
it('clears a detail opened from All whose own genre is withheld', async () => {
router.url = '/workspace/stalker/stalker-1/vod/42';
lockedStalkerIds.add('9');
stalkerStore.selectedItem.set({ category_id: '9' });
await service.applyStalker();
expect(stalkerStore.clearSelectedItem).toHaveBeenCalled();
// "All" itself is not locked, so the category stays selected.
expect(stalkerStore.setSelectedCategory).not.toHaveBeenCalled();
expect(router.navigate).toHaveBeenCalledWith([
'/workspace',
'stalker',
'stalker-1',
'vod',
]);
});
it('leaves a detail from All alone when its genre is not locked', async () => {
router.url = '/workspace/stalker/stalker-1/vod/42';
lockedStalkerIds.add('9');
stalkerStore.selectedItem.set({ category_id: '3' });
await service.applyStalker();
expect(stalkerStore.clearSelectedItem).not.toHaveBeenCalled();
expect(router.navigate).not.toHaveBeenCalled();
});
it('steps off a locked selected category', async () => {
router.url = '/workspace/stalker/stalker-1/vod';
lockedStalkerIds.add('9');
stalkerStore.selectedCategoryId.set('9');
await service.applyStalker();
expect(stalkerStore.clearSelectedItem).toHaveBeenCalled();
expect(stalkerStore.setSelectedCategory).toHaveBeenCalledWith(null);
});
});
describe('Xtream', () => {
it('clears a selected item whose category is no longer readable', async () => {
router.url = '/workspace/xtreams/xtream-1/vod/42';
xtreamStore.selectedItem.set({ category_id: 99 });
await service.applyXtream();
expect(xtreamStore.reloadCategories).toHaveBeenCalled();
expect(xtreamStore.setSelectedItem).toHaveBeenCalledWith(null);
expect(xtreamStore.setSelectedCategory).not.toHaveBeenCalled();
expect(router.navigate).toHaveBeenCalledWith([
'/workspace',
'xtreams',
'xtream-1',
'vod',
]);
});
it('keeps a selected item whose category survived the lock', async () => {
router.url = '/workspace/xtreams/xtream-1/vod/42';
xtreamStore.selectedCategoryId.set(7);
xtreamStore.selectedItem.set({ category_id: 7 });
await service.applyXtream();
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();
expect(xtreamStore.setSelectedItem).toHaveBeenCalledWith(null);
expect(xtreamStore.setSelectedCategory).toHaveBeenCalledWith(null);
expect(router.navigate).toHaveBeenCalledWith([
'/workspace',
'xtreams',
'xtream-1',
'live',
]);
});
});
});
@@ -72,20 +72,42 @@ export class ParentalLockEnforcementService {
await this.xtreamStore.reloadCachedContent();
const match = XTREAM_ROUTE.exec(this.router.url);
const selectedCategoryId = this.xtreamStore.selectedCategoryId();
if (selectedCategoryId === null) {
return;
}
const categoryType = toParentalLockXtreamCategoryType(match?.[2]);
const categories = this.xtreamStore.getCategoriesBySelectedType();
const stillVisible = categories.some(
(category) =>
Number(
(category as { id?: number | string }).id ??
(category as { category_id?: string }).category_id
) === Number(selectedCategoryId)
);
if (stillVisible) {
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 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],
]);
}
return;
}
this.xtreamStore.setSelectedItem(null);
@@ -109,20 +131,36 @@ export class ParentalLockEnforcementService {
const contentType = toParentalLockStalkerCategoryType(
this.stalkerStore.selectedContentType()
);
if (!contentType) {
return;
}
const selectedCategoryId = this.stalkerStore.selectedCategoryId();
if (
!contentType ||
!selectedCategoryId ||
!this.parentalLock.isStalkerCategoryLocked(
const categoryWithheld =
!!selectedCategoryId &&
this.parentalLock.isStalkerCategoryLocked(
playlistId,
contentType,
selectedCategoryId
)
) {
);
// An item opened from "All" (`*`) or search has its own genre to be
// judged by; the list dropping its row is not enough.
const selectedItem = this.stalkerStore.selectedItem?.() as {
category_id?: string | number;
} | null;
const itemWithheld =
!!selectedItem &&
this.parentalLock.isStalkerCategoryLocked(
playlistId,
contentType,
selectedItem.category_id
);
if (!categoryWithheld && !itemWithheld) {
return;
}
this.stalkerStore.clearSelectedItem();
this.stalkerStore.setSelectedCategory(null);
if (categoryWithheld) {
this.stalkerStore.setSelectedCategory(null);
}
const match = STALKER_ROUTE.exec(this.router.url);
if (match && match[1] === playlistId) {
void this.router.navigate([