fix(settings): fail closed on unreadable settings and finish relock clean-up

- Unreadable settings (IndexedDB load failure) left the feature switch at
  its default and announced "unlocked" to the main process. A stored PIN
  now stands in for the switch, and without one nothing is announced, so
  the worker keeps its mirrored locked default.
- Lock applies run one at a time and abandon superseded results; the
  Electron data source keys its in-flight share by lock version so a
  relock can never reuse an unlock refresh's unfiltered rows.
- The stored in-portal Xtream search is re-run on a lock change.
- Stalker live/radio selections are judged by tv_genre_id, and both live
  layouts drop the playback of a channel whose category became withheld.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-26 01:10:16 +02:00
1 parent bc1b0eef41
commit 41bab4dcaa
12 files changed
+701 -54

No files matched your search

@@ -9,7 +9,7 @@ import { ParentalLockEnforcementService } from './parental-lock-enforcement.serv
import { PlaybackKeepAwakeService } from './playback-keep-awake.service';
interface Applier {
applyXtream(): Promise<void>;
applyXtream(version: number): Promise<void>;
applyStalker(): Promise<void>;
}
@@ -31,7 +31,10 @@ describe('ParentalLockEnforcementService', () => {
currentPlaylist: signal<{ _id: string } | null>({ _id: 'stalker-1' }),
selectedContentType: signal<string>('vod'),
selectedCategoryId: signal<string | null>('*'),
selectedItem: signal<{ category_id?: string } | null>(null),
selectedItem: signal<{
category_id?: string;
tv_genre_id?: string;
} | null>(null),
clearSelectedItem: jest.fn(),
setSelectedCategory: jest.fn(),
};
@@ -41,6 +44,7 @@ describe('ParentalLockEnforcementService', () => {
selectedItem: signal<{ category_id?: number } | null>(null),
reloadCategories: jest.fn(async () => undefined),
reloadCachedContent: jest.fn(async () => undefined),
refreshSearchResults: jest.fn(async () => undefined),
getCategoriesBySelectedType: jest.fn(() => [{ id: 7 }, { id: 8 }]),
setSelectedItem: jest.fn(),
setSelectedCategory: jest.fn(),
@@ -109,6 +113,27 @@ describe('ParentalLockEnforcementService', () => {
expect(router.navigate).not.toHaveBeenCalled();
});
it('judges a live channel from All by its genre, not by category_id', async () => {
router.url = '/workspace/stalker/stalker-1/itv';
stalkerStore.selectedContentType.set('itv');
lockedStalkerIds.add('9');
stalkerStore.selectedItem.set({
tv_genre_id: '9',
category_id: '3',
});
await service.applyStalker();
expect(stalkerStore.clearSelectedItem).toHaveBeenCalled();
expect(router.navigate).toHaveBeenCalledWith([
'/workspace',
'stalker',
'stalker-1',
'itv',
]);
stalkerStore.selectedContentType.set('vod');
});
it('steps off a locked selected category', async () => {
router.url = '/workspace/stalker/stalker-1/vod';
lockedStalkerIds.add('9');
@@ -126,7 +151,7 @@ describe('ParentalLockEnforcementService', () => {
router.url = '/workspace/xtreams/xtream-1/vod/42';
xtreamStore.selectedItem.set({ category_id: 99 });
await service.applyXtream();
await service.applyXtream(parentalLock.version());
expect(xtreamStore.reloadCategories).toHaveBeenCalled();
expect(xtreamStore.setSelectedItem).toHaveBeenCalledWith(null);
@@ -139,12 +164,20 @@ describe('ParentalLockEnforcementService', () => {
]);
});
it('re-runs the stored in-portal search after the reload', async () => {
router.url = '/workspace/xtreams/xtream-1/search';
await service.applyXtream(parentalLock.version());
expect(xtreamStore.refreshSearchResults).toHaveBeenCalled();
});
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();
await service.applyXtream(parentalLock.version());
expect(xtreamStore.setSelectedItem).not.toHaveBeenCalled();
expect(router.navigate).not.toHaveBeenCalled();
@@ -154,7 +187,7 @@ describe('ParentalLockEnforcementService', () => {
router.url = '/workspace/xtreams/xtream-1/live';
xtreamStore.selectedCategoryId.set(99);
await service.applyXtream();
await service.applyXtream(parentalLock.version());
expect(xtreamStore.setSelectedItem).toHaveBeenCalledWith(null);
expect(xtreamStore.setSelectedCategory).toHaveBeenCalledWith(null);
@@ -167,3 +200,81 @@ describe('ParentalLockEnforcementService', () => {
});
});
});
describe('ParentalLockEnforcementService apply serialization', () => {
it('runs applies one at a time and abandons a result superseded by a newer version', async () => {
const version = signal(0);
let releaseReload: () => void = () => undefined;
const xtreamStore = {
playlistId: signal('xtream-1'),
selectedCategoryId: signal<number | null>(99),
selectedItem: signal(null),
reloadCategories: jest.fn(
() => new Promise<void>((resolve) => (releaseReload = resolve))
),
reloadCachedContent: jest.fn(async () => undefined),
refreshSearchResults: jest.fn(async () => undefined),
getCategoriesBySelectedType: jest.fn(() => [] as unknown[]),
setSelectedItem: jest.fn(),
setSelectedCategory: jest.fn(),
};
TestBed.configureTestingModule({
providers: [
{
provide: ParentalLockService,
useValue: {
version,
registerBusyProbe: jest.fn(),
isStalkerCategoryLocked: jest.fn(() => false),
isM3uGroupLocked: jest.fn(() => false),
},
},
{ provide: XtreamStore, useValue: xtreamStore },
{
provide: StalkerStore,
useValue: { currentPlaylist: signal(null) },
},
{
provide: Router,
useValue: { url: '/', navigate: jest.fn() },
},
{
provide: Store,
useValue: {
selectSignal: () => signal(null),
dispatch: jest.fn(),
},
},
{
provide: PlaybackKeepAwakeService,
useValue: { hasPlayingVideo: () => false },
},
],
});
const service = TestBed.inject(ParentalLockEnforcementService);
TestBed.runInInjectionContext(() => service.start());
TestBed.flushEffects();
// Unlock: the reload is held open...
version.set(1);
TestBed.flushEffects();
await Promise.resolve();
expect(xtreamStore.reloadCategories).toHaveBeenCalledTimes(1);
// ...and "Lock now" arrives meanwhile: no second reload starts yet.
version.set(2);
TestBed.flushEffects();
await Promise.resolve();
expect(xtreamStore.reloadCategories).toHaveBeenCalledTimes(1);
// The superseded apply must not act on its (unlocked) rows.
releaseReload();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(xtreamStore.reloadCategories).toHaveBeenCalledTimes(2);
expect(xtreamStore.setSelectedCategory).not.toHaveBeenCalled();
releaseReload();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(xtreamStore.setSelectedCategory).toHaveBeenCalledTimes(1);
});
});
@@ -34,6 +34,7 @@ export class ParentalLockEnforcementService {
private readonly activeChannel = this.store.selectSignal(selectActive);
private started = false;
private lastVersion = -1;
private applyChain: Promise<void> = Promise.resolve();
start(): void {
if (this.started) {
@@ -52,24 +53,55 @@ export class ParentalLockEnforcementService {
const first = this.lastVersion === -1;
this.lastVersion = version;
if (!first) {
void this.apply();
this.scheduleApply();
}
});
});
}
/**
* Applies run one at a time. `ElectronXtreamDataSource` shares in-flight
* reads per playlist and type, so an unlock refresh still running when
* "Lock now" arrives would hand the relock its unfiltered rows. Each
* apply also abandons its result once a newer version exists, leaving
* the queued apply to read the latest state.
*/
private scheduleApply(): void {
this.applyChain = this.applyChain
.then(() => this.apply())
.catch((error) => {
console.error(
'Failed to apply the parental lock change.',
error
);
});
}
private async apply(): Promise<void> {
await Promise.all([this.applyXtream(), this.applyStalker()]);
const version = this.parentalLock.version();
await Promise.all([this.applyXtream(version), this.applyStalker()]);
if (this.parentalLock.version() !== version) {
return;
}
this.applyM3u();
}
private async applyXtream(): Promise<void> {
private async applyXtream(version: number): Promise<void> {
const playlistId = this.xtreamStore.playlistId?.();
if (!playlistId) {
return;
}
await this.xtreamStore.reloadCategories();
await this.xtreamStore.reloadCachedContent();
if (this.parentalLock.version() !== version) {
return;
}
// 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 (this.parentalLock.version() !== version) {
return;
}
const match = XTREAM_ROUTE.exec(this.router.url);
const categoryType = toParentalLockXtreamCategoryType(match?.[2]);
@@ -143,16 +175,23 @@ export class ParentalLockEnforcementService {
selectedCategoryId
);
// An item opened from "All" (`*`) or search has its own genre to be
// judged by; the list dropping its row is not enough.
// judged by; the list dropping its row is not enough. Live and radio
// rows carry that genre in `tv_genre_id`, VOD and series rows in
// `category_id` (the store's withheld filter applies the same rule).
const selectedItem = this.stalkerStore.selectedItem?.() as {
category_id?: string | number;
tv_genre_id?: string | number;
} | null;
const itemCategoryId =
contentType === 'itv' || contentType === 'radio'
? selectedItem?.tv_genre_id
: selectedItem?.category_id;
const itemWithheld =
!!selectedItem &&
this.parentalLock.isStalkerCategoryLocked(
playlistId,
contentType,
selectedItem.category_id
itemCategoryId
);
if (!categoryWithheld && !itemWithheld) {
return;
+34 -8
View File
@@ -96,7 +96,13 @@ a dead render process the flag falls back to the mirrored
`PARENTAL_LOCK_ENABLED` setting, i.e. locked while the feature is on. The
renderer, in turn, reports nothing before its settings have loaded, so it can
never send a spurious "unlocked" while the main process still holds the
locked default.
locked default. Unreadable settings fail the same way: `SettingsStore`
serves defaults and records `storageFailure = 'load'`, and the switch is
then unknown rather than off — a stored PIN stands in for it (`enabled`
follows `hasPin`, so the session starts locked and one PIN entry unlocks
it), and without a PIN the renderer does not report at all, leaving the
main process on its mirrored default instead of announcing "unlocked" on
the strength of default settings.
### In-memory catalogs
@@ -110,13 +116,32 @@ locked default.
"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.
readable) selected category stays. 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,
so an unlock refresh still running when "Lock now" arrives can never hand
the relock its unfiltered rows. The apply also re-runs the stored
in-portal search (`XtreamStore.refreshSearchResults`, the last
`searchContent` call as issued) — `searchResults` is a separate array the
search page renders directly and would otherwise keep locked titles until
the query changes. Live playback is stopped by the layout itself:
`LiveStreamLayoutComponent` keeps the playing channel's provider category
id (mapped from the SQLite row id on Electron) and drops `activePlayback`
on a `version` change that locks it, because the player is gated on that
signal, not on the store selection — which an ordinary category switch
also clears while the channel keeps playing.
- **Stalker:** the same service checks the selected genre through
`isStalkerCategoryLocked` and, independently, the selected item's own
`category_id` — an item opened from `*` (All) or search is withheld by
its genre even though `*` itself can never be locked. A withheld item is
cleared and the section root is navigated to; the category selection is
reset only when the genre itself is locked.
genre — `tv_genre_id` for live and radio rows, `category_id` for VOD and
series, the rule the store's withheld filter applies — so an item opened
from `*` (All) or search is withheld by its genre even though `*` itself
can never be locked. A withheld item is cleared and the section root is
navigated to; the category selection is reset only when the genre itself
is locked. `StalkerLiveStreamLayoutComponent` drops its `activePlayback`
whenever the store selection is cleared: its template mounts the player
only with a selection, and the held stream must not resurface with the
next one.
- **Xtream (PWA):** `PwaXtreamDataSource` drops withheld categories,
streams and search hits at read time; the same reloads apply.
- **Warm-cache detection (Electron):** the filtered category/content reads
@@ -204,8 +229,9 @@ never exported.
Favorites, recently viewed, playback positions / Continue Watching, the
dashboard rails built from Stalker and M3U documents, global favorites /
recent pages, the EPG guide, downloads and recordings lists, the remote
control's channel-number resolvers and the command palette's channel search
still show rows from locked categories. The SQLite-side hooks exist
control's channel-number resolvers, the command palette's channel search and
the Electron-only global search page (`/workspace/search`, whose stored
results are not re-run on a relock) still show rows from locked categories. The SQLite-side hooks exist
(`unlockedCategoryCondition()` / `unlockedCategorySql()`), the renderer
predicate is `ParentalLockService.is*Locked`.
@@ -643,6 +643,22 @@ export class StalkerLiveStreamLayoutComponent
30_000
);
// The parental lock clears the store selection from outside this
// layout when the playing channel's genre is withheld
// (ParentalLockEnforcementService). The template mounts the player
// only with a selection, so the playback held here is dropped too
// instead of resurfacing with the next selection.
effect(() => {
if (this.stalkerStore.selectedItem()) {
return;
}
untracked(() => {
if (this.activePlayback()) {
this.clearActivePlayback();
}
});
});
// Load favorites for current playlist
const playlistId = this.stalkerStore.currentPlaylist()?._id;
if (playlistId) {
@@ -150,7 +150,10 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
options?: XtreamOperationOptions
): Promise<XtreamCategoryFromDb[]> {
const dbType = mapCategoryTypeToDbType(type);
const requestKey = `${playlistId}:${dbType}`;
// The lock version keys the share: a read issued under an older lock
// state (still unlocked, or locks since edited) answers with rows the
// caller under the newer state must not be handed.
const requestKey = `${playlistId}:${dbType}:${this.parentalLock.version()}`;
const inFlightRequest = this.categoryRequests.get(requestKey);
if (inFlightRequest) {
@@ -304,7 +307,7 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
onTotal?: (total: number) => void,
options?: XtreamOperationOptions
): Promise<XtreamContentItem[]> {
const requestKey = `${playlistId}:${type}`;
const requestKey = `${playlistId}:${type}:${this.parentalLock.version()}`;
const inFlightRequest = this.contentRequests.get(requestKey);
if (inFlightRequest) {
@@ -114,3 +114,48 @@ describe('withSearch renderer performance markers', () => {
expect(events).toEqual([]);
});
});
describe('withSearch refreshSearchResults', () => {
let searchContent: jest.Mock;
let store: InstanceType<typeof TestSearchStore>;
beforeEach(() => {
searchContent = jest.fn(async () => [{ xtream_id: 1 }]);
TestBed.configureTestingModule({
providers: [
TestSearchStore,
{ provide: XTREAM_DATA_SOURCE, useValue: { searchContent } },
],
});
store = TestBed.inject(TestSearchStore);
});
it('re-runs the last search with the parameters it was issued with', async () => {
await store.searchContent({
term: 'news',
types: ['live'],
excludeHidden: true,
});
searchContent.mockResolvedValueOnce([]);
await store.refreshSearchResults();
expect(searchContent).toHaveBeenLastCalledWith(
'playlist-1',
'news',
['live'],
true
);
expect(store.searchResults()).toEqual([]);
});
it('does nothing without a previous search or after a reset', async () => {
await store.refreshSearchResults();
expect(searchContent).not.toHaveBeenCalled();
await store.searchContent('news', ['live']);
store.resetSearchResults();
await store.refreshSearchResults();
expect(searchContent).toHaveBeenCalledTimes(1);
});
});
@@ -81,6 +81,8 @@ export function withSearch() {
withMethods((store) => {
const dataSource = inject(XTREAM_DATA_SOURCE);
let searchRequestVersion = 0;
/** The last in-portal search, so it can be re-run as issued. */
let lastSearch: SearchContentParams | null = null;
return {
/**
@@ -110,12 +112,14 @@ export function withSearch() {
const requestVersion = ++searchRequestVersion;
if (!playlistId || !searchTerm.trim()) {
lastSearch = null;
patchState(store, {
searchResults: [],
isSearching: false,
});
return [];
}
lastSearch = { term: searchTerm, types, excludeHidden };
patchState(store, { isSearching: true });
@@ -157,6 +161,18 @@ export function withSearch() {
}
},
/**
* Re-runs the last in-portal search with its own parameters,
* so stored results reflect the current read filters (the
* parental lock) without the page having to search again.
*/
async refreshSearchResults(): Promise<void> {
if (!lastSearch) {
return;
}
await this.searchContent(lastSearch);
},
/**
* Set global search results (from external search)
*/
@@ -209,6 +225,7 @@ export function withSearch() {
*/
resetSearchResults(): void {
searchRequestVersion++;
lastSearch = null;
patchState(store, initialSearchState);
},
};
@@ -13,6 +13,7 @@ import {
TemplateRef,
computed,
effect,
untracked,
forwardRef,
inject,
OnDestroy,
@@ -94,6 +95,7 @@ import {
} from '../portal-channels-list/portal-channels-list.component';
import { ActivatedRoute, NavigationEnd, Router } from '@angular/router';
import {
ParentalLockService,
RecordingsService,
RuntimeCapabilitiesService,
SettingsStore,
@@ -167,6 +169,7 @@ export class LiveStreamLayoutComponent
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly settingsStore = inject(SettingsStore);
private readonly portalPlayer = inject(PORTAL_PLAYER);
private readonly parentalLock = inject(ParentalLockService);
private readonly liveAutoOpenState = inject(LiveStreamAutoOpenStateService);
readonly categories = this.xtreamStore.getCategoriesBySelectedType;
@@ -416,6 +419,8 @@ export class LiveStreamLayoutComponent
);
readonly activePlayback = signal<ResolvedPortalPlayback | null>(null);
private readonly activeLiveItemId = signal<number | null>(null);
/** Provider category id of the playing channel, for the parental lock. */
private activeLiveProviderCategoryId: number | null = null;
readonly playbackSessionKey = computed(() => {
const sourceId = this.xtreamStore.currentPlaylist()?.id;
const contentId = this.activeLiveItemId();
@@ -439,6 +444,34 @@ export class LiveStreamLayoutComponent
this.activeCatchupProgram.set(null);
}
});
// A relock (idle timer, Lock now) must stop a channel playing from a
// now-withheld category: the player is gated on `activePlayback`,
// not on the store selection the enforcement service clears — and
// that selection is also dropped by an ordinary category switch,
// which must keep the channel playing.
effect(() => {
this.parentalLock.version();
untracked(() => {
const categoryId = this.activeLiveProviderCategoryId;
const playlistId = this.xtreamStore.currentPlaylist()?.id;
if (
categoryId === null ||
!playlistId ||
!this.parentalLock.isXtreamCategoryLocked(
playlistId,
'live',
categoryId
)
) {
return;
}
this.playbackRequestId += 1;
this.activePlayback.set(null);
this.activeLiveItemId.set(null);
this.activeCatchupProgram.set(null);
this.activeLiveProviderCategoryId = null;
});
});
effect((onCleanup) => {
const intervalId = window.setInterval(() => {
this.currentTimeMs.set(Date.now());
@@ -621,6 +654,8 @@ export class LiveStreamLayoutComponent
// store no-op.
if (!remote) this.selectLiveItemCategory(item);
this.activeLiveItemId.set(item.xtream_id);
this.activeLiveProviderCategoryId =
this.resolveLiveProviderCategoryId(item);
const playlist = this.xtreamStore.currentPlaylist();
this.activePlaylistId = playlist?.id ?? null;
this.activePlayback.set({
@@ -807,6 +842,31 @@ export class LiveStreamLayoutComponent
return [...this.channelNavigation.remoteChannels()];
}
/**
* The provider category id the parental lock is keyed by. Electron rows
* carry the SQLite category row id in `category_id` and the provider id
* on the category row (`xtream_id`); the PWA carries the provider id on
* both. A row whose category is not in the list resolves to null rather
* than to a guess that could collide with a locked provider id.
*/
private resolveLiveProviderCategoryId(
item: XtreamLiveChannelItem
): number | null {
const categoryId = Number(item.category_id);
if (!Number.isFinite(categoryId)) {
return null;
}
if (!this.runtime.supportsXtreamSqliteDataSource) {
return categoryId;
}
const category = (this.xtreamStore.liveCategories?.() ?? []).find(
(candidate) =>
Number((candidate as { id?: number }).id) === categoryId
) as { xtream_id?: number } | undefined;
const providerId = Number(category?.xtream_id);
return Number.isFinite(providerId) ? providerId : null;
}
private selectLiveItemCategory(item: XtreamLiveChannelItem): void {
const categoryId = Number(item.category_id);
if (Number.isFinite(categoryId) && categoryId > 0) {
@@ -838,6 +898,8 @@ export class LiveStreamLayoutComponent
const requestId = ++this.playbackRequestId;
this.activeLiveItemId.set(item.xtream_id);
this.activeLiveProviderCategoryId =
this.resolveLiveProviderCategoryId(item);
const catchupUrl = await this.xtreamUrlService.resolveCatchupUrl(
playlist.id,
{
@@ -0,0 +1,235 @@
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { ActivatedRoute, Router, convertToParamMap } from '@angular/router';
import { MockPipe } from 'ng-mocks';
import { TranslatePipe } from '@ngx-translate/core';
import { Subject, of } from 'rxjs';
import {
LiveLayoutSidebarStateService,
PORTAL_PLAYER,
ResizableDirective,
} from '@iptvnator/portal/shared/util';
import {
FavoritesService,
XtreamStore,
XtreamUrlService,
} from '@iptvnator/portal/xtream/data-access';
import {
EpgArchiveCopyService,
EpgArchiveDownloadService,
EpgListViewComponent,
EpgTimelineComponent,
} from '@iptvnator/ui/epg';
import { WebPlayerViewComponent } from '@iptvnator/ui/playback';
import { GridListComponent } from '@iptvnator/portal/shared/ui';
import {
ParentalLockService,
RuntimeCapabilitiesService,
SettingsStore,
} from '@iptvnator/services';
import { PortalChannelsListComponent } from '../portal-channels-list/portal-channels-list.component';
import { LiveStreamLayoutComponent } from './live-stream-layout.component';
import {
playlist,
sampleChannel,
StubEpgTimelineComponent,
StubGridListComponent,
StubPortalChannelsListComponent,
StubResizableDirective,
StubWebPlayerViewComponent,
} from './live-stream-layout-stubs.spec-data';
/**
* Parental lock: a relock stops the channel playing from a now-withheld
* category. Split from `live-stream-layout.component.spec.ts`, which sits at
* the spec line cap.
*/
describe('LiveStreamLayoutComponent parental lock', () => {
let fixture: ComponentFixture<LiveStreamLayoutComponent>;
let component: LiveStreamLayoutComponent;
const emptyList = signal<unknown[]>([]);
const parentalLock = {
version: signal(0),
isXtreamCategoryLocked: jest.fn(() => false),
};
const runtime = { supportsXtreamSqliteDataSource: false };
const xtreamStore = {
getCategoriesBySelectedType: signal([
{ category_id: 1, category_name: 'News' },
]),
liveCategories: signal([{ id: 41, xtream_id: 7, name: 'Adult' }]),
getCategoryItemCounts: signal(new Map<number, number>([[1, 1]])),
getPaginatedContent: emptyList,
hasMoreContent: signal(false),
epgItems: emptyList,
currentEpgItem: signal(null),
isLoadingEpg: signal(false),
selectedTypeContentLoading: signal(false),
selectedCategoryId: signal<number | null>(1),
selectedContentType: signal('live'),
selectedItem: signal(null),
currentPlaylist: signal(playlist),
liveStreams: emptyList,
isContentInitialized: signal(true),
selectItemsFromSelectedCategory: jest.fn(() => []),
constructStreamUrl: jest.fn(() => 'https://example.com/live.ts'),
openPlayer: jest.fn(),
setSelectedItem: jest.fn(),
setSelectedCategory: jest.fn(),
loadMoreContent: jest.fn(),
};
beforeEach(async () => {
parentalLock.version.set(0);
parentalLock.isXtreamCategoryLocked.mockReset();
parentalLock.isXtreamCategoryLocked.mockReturnValue(false);
runtime.supportsXtreamSqliteDataSource = false;
await TestBed.configureTestingModule({
imports: [LiveStreamLayoutComponent, NoopAnimationsModule],
providers: [
{
provide: EpgArchiveDownloadService,
useValue: { start: jest.fn() },
},
{
provide: EpgArchiveCopyService,
useValue: { copy: jest.fn() },
},
{
provide: ActivatedRoute,
useValue: {
snapshot: {
data: {},
queryParamMap: convertToParamMap({}),
},
queryParamMap: of(convertToParamMap({})),
pathFromRoot: [
{ snapshot: { data: { layout: 'workspace' } } },
],
},
},
{
provide: Router,
useValue: {
events: new Subject().asObservable(),
navigate: jest.fn(),
},
},
{ provide: XtreamStore, useValue: xtreamStore },
{
provide: FavoritesService,
useValue: { getFavorites: jest.fn(() => of([])) },
},
{
provide: XtreamUrlService,
useValue: {
constructAutoLiveTsUrl: jest.fn(() => undefined),
resolveCatchupUrl: jest.fn(),
},
},
{ provide: RuntimeCapabilitiesService, useValue: runtime },
{
provide: SettingsStore,
useValue: {
openStreamOnDoubleClick: signal(false),
resolvedEpgViewMode: signal('timeline'),
resolvedEpgOffsetMinutes: signal(0),
},
},
{
provide: PORTAL_PLAYER,
useValue: {
isEmbeddedPlayer: () => true,
openExternalPlayback: jest.fn(),
},
},
{ provide: ParentalLockService, useValue: parentalLock },
],
})
.overrideComponent(LiveStreamLayoutComponent, {
remove: {
imports: [
EpgListViewComponent,
EpgTimelineComponent,
GridListComponent,
PortalChannelsListComponent,
ResizableDirective,
TranslatePipe,
WebPlayerViewComponent,
],
},
add: {
imports: [
StubEpgTimelineComponent,
StubGridListComponent,
StubPortalChannelsListComponent,
StubResizableDirective,
MockPipe(
TranslatePipe,
(value: string | null | undefined) => value ?? ''
),
StubWebPlayerViewComponent,
],
},
})
.compileComponents();
fixture = TestBed.createComponent(LiveStreamLayoutComponent);
component = fixture.componentInstance;
TestBed.inject(LiveLayoutSidebarStateService).setState(
'portal',
'expanded'
);
fixture.detectChanges();
});
afterEach(() => fixture.destroy());
function lock(categoryId: number): void {
parentalLock.isXtreamCategoryLocked.mockImplementation(
(_playlistId: string, _type: string, id: number) =>
id === categoryId
);
parentalLock.version.update((value) => value + 1);
fixture.detectChanges();
}
it('stops the playing channel once its category is withheld', () => {
component.playLive({ ...sampleChannel, category_id: 7 });
expect(component.activePlayback()).not.toBeNull();
// Locks edited while unlocked: nothing is withheld yet.
parentalLock.version.update((value) => value + 1);
fixture.detectChanges();
expect(component.activePlayback()).not.toBeNull();
lock(7);
expect(parentalLock.isXtreamCategoryLocked).toHaveBeenCalledWith(
'playlist-1',
'live',
7
);
expect(component.activePlayback()).toBeNull();
expect(component.playbackSessionKey()).toBe('');
});
it('keeps a channel from an unlocked category playing', () => {
component.playLive({ ...sampleChannel, category_id: 3 });
lock(7);
expect(component.activePlayback()).not.toBeNull();
});
it('maps the SQLite category row id to the provider id it is locked by', () => {
runtime.supportsXtreamSqliteDataSource = true;
component.playLive({ ...sampleChannel, category_id: 41 });
lock(7);
expect(component.activePlayback()).toBeNull();
});
});
@@ -0,0 +1,56 @@
import {
ParentalLockPlaylistLocks,
ParentalLockStalkerCategoryType,
ParentalLockXtreamCategoryType,
} from '@iptvnator/shared/interfaces';
/**
* Pure builders for the next lock set of one playlist: each replaces the
* entries of ONE category type (or the M3U group list) and leaves the other
* types untouched, so a dialog editing Live TV cannot drop the Movies locks.
*/
export function withXtreamLocks(
current: ParentalLockPlaylistLocks,
categoryType: ParentalLockXtreamCategoryType,
xtreamIds: readonly number[]
): ParentalLockPlaylistLocks {
return {
...current,
xtream: [
...current.xtream.filter(
(entry) => entry.categoryType !== categoryType
),
...[...new Set(xtreamIds)].map((xtreamId) => ({
categoryType,
xtreamId,
})),
],
};
}
export function withStalkerLocks(
current: ParentalLockPlaylistLocks,
categoryType: ParentalLockStalkerCategoryType,
categoryIds: readonly string[]
): ParentalLockPlaylistLocks {
return {
...current,
stalker: [
...current.stalker.filter(
(entry) => entry.categoryType !== categoryType
),
...[...new Set(categoryIds)].map((categoryId) => ({
categoryType,
categoryId,
})),
],
};
}
export function withM3uLocks(
current: ParentalLockPlaylistLocks,
groupTitles: readonly string[]
): ParentalLockPlaylistLocks {
return { ...current, m3u: [...new Set(groupTitles)] };
}
@@ -16,6 +16,7 @@ describe('ParentalLockService', () => {
const originalElectron = window.electron;
let parentalLockEnabled: ReturnType<typeof signal<boolean>>;
let parentalLockRelockMinutes: ReturnType<typeof signal<number>>;
let storageFailure: ReturnType<typeof signal<'load' | 'save' | null>>;
let updateSettings: jest.Mock;
let storage: {
pinHash: string | null;
@@ -42,6 +43,7 @@ describe('ParentalLockService', () => {
beforeEach(() => {
parentalLockEnabled = signal(false);
parentalLockRelockMinutes = signal(15);
storageFailure = signal<'load' | 'save' | null>(null);
updateSettings = jest.fn(async (patch: Record<string, unknown>) => {
if ('parentalLockEnabled' in patch) {
parentalLockEnabled.set(patch['parentalLockEnabled'] === true);
@@ -87,6 +89,7 @@ describe('ParentalLockService', () => {
useValue: {
parentalLockEnabled,
parentalLockRelockMinutes,
storageFailure,
loadSettings: jest.fn(async () => undefined),
updateSettings,
},
@@ -128,6 +131,28 @@ describe('ParentalLockService', () => {
expect(prompt.requestPin).not.toHaveBeenCalled();
});
it('stands in the stored PIN for the switch when settings could not be read', async () => {
storage.pinHash = await hashParentalLockPin('1234');
storageFailure.set('load');
// Defaults-by-failure: the store reports the feature as off.
parentalLockEnabled.set(false);
const service = await createService();
expect(service.enabled()).toBe(true);
expect(service.active()).toBe(true);
expect(setParentalLockState).toHaveBeenLastCalledWith(true);
});
it('never announces an unlocked state on unreadable settings without a PIN', async () => {
storageFailure.set('load');
const service = await createService();
expect(service.enabled()).toBe(false);
expect(setParentalLockState).not.toHaveBeenCalled();
});
it('starts locked with the feature on and unlocks through the prompt', async () => {
storage.pinHash = await hashParentalLockPin('1234');
parentalLockEnabled.set(true);
@@ -33,6 +33,11 @@ import {
syncParentalLockStateToMainProcess,
} from './parental-lock-bridge';
import { ParentalLockStorageService } from './parental-lock-storage';
import {
withM3uLocks,
withStalkerLocks,
withXtreamLocks,
} from './parental-lock-store.util';
const XTREAM_CATEGORY_TYPES: readonly ParentalLockXtreamCategoryType[] = [
'live',
@@ -79,9 +84,24 @@ export class ParentalLockService {
private initialization: Promise<void> | null = null;
private pendingUnlock: Promise<boolean> | null = null;
/** The feature switch from settings. */
readonly enabled = computed(
() => this.settingsStore.parentalLockEnabled?.() === true
/**
* The persisted feature switch could not be read (IndexedDB failure):
* `SettingsStore` then serves defaults, which would read as "off".
*/
private readonly switchUnknown = computed(
() => this.settingsStore.storageFailure?.() === 'load'
);
/**
* The feature switch from settings. While the switch is unknown a stored
* PIN stands in for it: the PIN exists only once a parent set the lock
* up, so failing toward "locked" costs one PIN entry, whereas failing
* toward "off" would expose every locked category precisely during a
* storage failure.
*/
readonly enabled = computed(() =>
this.switchUnknown()
? this.pinHash() !== null
: this.settingsStore.parentalLockEnabled?.() === true
);
/** Feature on and the PIN has been entered this session. */
readonly unlocked = computed(() => this.enabled() && this.unlockedState());
@@ -105,7 +125,12 @@ export class ParentalLockService {
}
untracked(() => {
this.versionState.update((value) => value + 1);
syncParentalLockStateToMainProcess(active);
// Unknown switch and no PIN to stand in for it: the main
// process keeps its mirrored (locked) default rather than
// being told "unlocked" on the strength of default settings.
if (!this.switchUnknown() || this.hasPin()) {
syncParentalLockStateToMainProcess(active);
}
});
});
// The idle timer follows the UNLOCKED transition, not `active`:
@@ -364,19 +389,11 @@ export class ParentalLockService {
categoryType: ParentalLockXtreamCategoryType,
xtreamIds: number[]
): Promise<boolean> {
const current = this.locksFor(playlistId);
const next: ParentalLockPlaylistLocks = {
...current,
xtream: [
...current.xtream.filter(
(entry) => entry.categoryType !== categoryType
),
...[...new Set(xtreamIds)].map((xtreamId) => ({
categoryType,
xtreamId,
})),
],
};
const next = withXtreamLocks(
this.locksFor(playlistId),
categoryType,
xtreamIds
);
if (!(await this.persistPlaylistLocks(playlistId, next))) {
return false;
}
@@ -388,29 +405,24 @@ export class ParentalLockService {
categoryType: ParentalLockStalkerCategoryType,
categoryIds: string[]
): Promise<boolean> {
const current = this.locksFor(playlistId);
return this.persistPlaylistLocks(playlistId, {
...current,
stalker: [
...current.stalker.filter(
(entry) => entry.categoryType !== categoryType
),
...[...new Set(categoryIds)].map((categoryId) => ({
categoryType,
categoryId,
})),
],
});
return this.persistPlaylistLocks(
playlistId,
withStalkerLocks(
this.locksFor(playlistId),
categoryType,
categoryIds
)
);
}
async setM3uLocks(
playlistId: string,
groupTitles: string[]
): Promise<boolean> {
return this.persistPlaylistLocks(playlistId, {
...this.locksFor(playlistId),
m3u: [...new Set(groupTitles)],
});
return this.persistPlaylistLocks(
playlistId,
withM3uLocks(this.locksFor(playlistId), groupTitles)
);
}
/** Backup restore: replaces every lock of one playlist. */