diff --git a/apps/web/src/app/services/parental-lock-enforcement.service.spec.ts b/apps/web/src/app/services/parental-lock-enforcement.service.spec.ts index 60ccf20b0..c6124b6ad 100644 --- a/apps/web/src/app/services/parental-lock-enforcement.service.spec.ts +++ b/apps/web/src/app/services/parental-lock-enforcement.service.spec.ts @@ -9,7 +9,7 @@ import { ParentalLockEnforcementService } from './parental-lock-enforcement.serv import { PlaybackKeepAwakeService } from './playback-keep-awake.service'; interface Applier { - applyXtream(): Promise; + applyXtream(version: number): Promise; applyStalker(): Promise; } @@ -31,7 +31,10 @@ describe('ParentalLockEnforcementService', () => { currentPlaylist: signal<{ _id: string } | null>({ _id: 'stalker-1' }), selectedContentType: signal('vod'), selectedCategoryId: signal('*'), - 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(99), + selectedItem: signal(null), + reloadCategories: jest.fn( + () => new Promise((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); + }); +}); diff --git a/apps/web/src/app/services/parental-lock-enforcement.service.ts b/apps/web/src/app/services/parental-lock-enforcement.service.ts index 2aa7d2059..1e469d3b6 100644 --- a/apps/web/src/app/services/parental-lock-enforcement.service.ts +++ b/apps/web/src/app/services/parental-lock-enforcement.service.ts @@ -34,6 +34,7 @@ export class ParentalLockEnforcementService { private readonly activeChannel = this.store.selectSignal(selectActive); private started = false; private lastVersion = -1; + private applyChain: Promise = 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 { - 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 { + private async applyXtream(version: number): Promise { 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; diff --git a/docs/architecture/parental-lock.md b/docs/architecture/parental-lock.md index 66de43542..008a00c5d 100644 --- a/docs/architecture/parental-lock.md +++ b/docs/architecture/parental-lock.md @@ -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`. diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts index 58bf07cbf..266e2b3e5 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts @@ -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) { diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts index f6bc110ef..80ccac605 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts @@ -150,7 +150,10 @@ export class ElectronXtreamDataSource implements IXtreamDataSource { options?: XtreamOperationOptions ): Promise { 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 { - const requestKey = `${playlistId}:${type}`; + const requestKey = `${playlistId}:${type}:${this.parentalLock.version()}`; const inFlightRequest = this.contentRequests.get(requestKey); if (inFlightRequest) { diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-search.feature.spec.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-search.feature.spec.ts index 77ca625d2..08d5fb9fe 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-search.feature.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-search.feature.spec.ts @@ -114,3 +114,48 @@ describe('withSearch renderer performance markers', () => { expect(events).toEqual([]); }); }); + +describe('withSearch refreshSearchResults', () => { + let searchContent: jest.Mock; + let store: InstanceType; + + 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); + }); +}); diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-search.feature.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-search.feature.ts index fc769057b..db30b2fa4 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-search.feature.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-search.feature.ts @@ -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 { + 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); }, }; diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts index 8d74334a4..a24d78d6e 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts @@ -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(null); private readonly activeLiveItemId = signal(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, { diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.parental-lock.spec.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.parental-lock.spec.ts new file mode 100644 index 000000000..f58dcc06e --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.parental-lock.spec.ts @@ -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; + let component: LiveStreamLayoutComponent; + const emptyList = signal([]); + 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([[1, 1]])), + getPaginatedContent: emptyList, + hasMoreContent: signal(false), + epgItems: emptyList, + currentEpgItem: signal(null), + isLoadingEpg: signal(false), + selectedTypeContentLoading: signal(false), + selectedCategoryId: signal(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(); + }); +}); diff --git a/libs/services/src/lib/parental-lock/parental-lock-store.util.ts b/libs/services/src/lib/parental-lock/parental-lock-store.util.ts new file mode 100644 index 000000000..511be0a08 --- /dev/null +++ b/libs/services/src/lib/parental-lock/parental-lock-store.util.ts @@ -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)] }; +} diff --git a/libs/services/src/lib/parental-lock/parental-lock.service.spec.ts b/libs/services/src/lib/parental-lock/parental-lock.service.spec.ts index afab69e54..4e53ad91d 100644 --- a/libs/services/src/lib/parental-lock/parental-lock.service.spec.ts +++ b/libs/services/src/lib/parental-lock/parental-lock.service.spec.ts @@ -16,6 +16,7 @@ describe('ParentalLockService', () => { const originalElectron = window.electron; let parentalLockEnabled: ReturnType>; let parentalLockRelockMinutes: ReturnType>; + let storageFailure: ReturnType>; 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) => { 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); diff --git a/libs/services/src/lib/parental-lock/parental-lock.service.ts b/libs/services/src/lib/parental-lock/parental-lock.service.ts index 62658b1dd..f85a66c84 100644 --- a/libs/services/src/lib/parental-lock/parental-lock.service.ts +++ b/libs/services/src/lib/parental-lock/parental-lock.service.ts @@ -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 | null = null; private pendingUnlock: Promise | 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 { - 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 { - 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 { - 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. */