mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 19:46:16 -08:00
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:
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;
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
+16
@@ -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,
|
||||
{
|
||||
|
||||
+235
@@ -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. */
|
||||
|
||||
Reference in new issue
Block a user