diff --git a/docs/architecture/parental-lock.md b/docs/architecture/parental-lock.md index 4eaac5553..439aca740 100644 --- a/docs/architecture/parental-lock.md +++ b/docs/architecture/parental-lock.md @@ -171,16 +171,20 @@ on either side. the INITIAL hydration (the content is not initialized yet, so the reload has nothing to re-read) sets a deferred-reload flag instead: the hydration then publishes empty lists in place of the rows it read under - the previous lock state, and the filtered reload runs as soon as the - hydration settles, on every path that marks the content initialized. + the previous lock state (categories included), and the filtered reload + of categories and content runs as soon as the hydration settles, on + every path that marks the content initialized. Both reloads fail closed: a category reload that rejects empties the three category lists, and a per-type content reload that rejects empties that type and sets it back to `idle` so the next visit loads it again (filtered) — rows read under the previous lock state are never kept. 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 + id (mapped from the SQLite row id on Electron — through the visible + category list, else through the unfiltered rows, since search can play a + channel of a HIDDEN category; until that lookup lands the id is unknown + and a relock stops the channel) 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 diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.reload.spec.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.reload.spec.ts index 6f61dfdbf..13e7246de 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.reload.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.reload.spec.ts @@ -121,6 +121,13 @@ describe('withContent parental-lock reloads', () => { calls <= 3 ? [{ xtream_id: 1 }] : [{ xtream_id: 2 }] ); }); + let categoryCalls = 0; + dataSource.getCategories.mockImplementation(async () => { + categoryCalls += 1; + return categoryCalls <= 3 + ? [{ category_id: 'old' }] + : [{ category_id: 'new' }]; + }); const initialization = store.initializeContent(); await waitForCondition(() => calls === 1); @@ -131,6 +138,8 @@ describe('withContent parental-lock reloads', () => { await initialization; expect(calls).toBe(6); + expect(categoryCalls).toBe(6); + expect(store.liveCategories()).toEqual([{ category_id: 'new' }]); expect(store.liveStreams()).toEqual([{ xtream_id: 2 }]); expect(store.vodStreams()).toEqual([{ xtream_id: 2 }]); expect(store.serialStreams()).toEqual([{ xtream_id: 2 }]); diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts index f298d216d..3f1a773a2 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts @@ -231,6 +231,7 @@ export function withContent() { return; } reloadAfterInitialization = false; + await methods.reloadCategories(); await methods.reloadCachedContent(); }; const dataService = inject(DataService); @@ -648,6 +649,9 @@ export function withContent() { // Read before the lock changed; the deferred reload // below publishes the filtered rows instead. for (const key of [ + 'liveCategories', + 'vodCategories', + 'serialCategories', 'liveStreams', 'vodStreams', 'serialStreams', @@ -1168,10 +1172,16 @@ export function withContent() { RENDERER_PERFORMANCE_PHASE.XTREAM_PUBLISH_CATEGORIES, () => patchState(store, { - liveCategories: live, - vodCategories: vod, + liveCategories: reloadAfterInitialization + ? [] + : live, + vodCategories: reloadAfterInitialization + ? [] + : vod, vodCategoriesPlaylistId: ctx.playlistId, - serialCategories: series, + serialCategories: reloadAfterInitialization + ? [] + : series, isLoadingCategories: false, }), () => ({ 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 a24d78d6e..92602bace 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 @@ -95,6 +95,7 @@ import { } from '../portal-channels-list/portal-channels-list.component'; import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; import { + DatabaseService, ParentalLockService, RecordingsService, RuntimeCapabilitiesService, @@ -170,6 +171,7 @@ export class LiveStreamLayoutComponent private readonly settingsStore = inject(SettingsStore); private readonly portalPlayer = inject(PORTAL_PLAYER); private readonly parentalLock = inject(ParentalLockService); + private readonly databaseService = inject(DatabaseService); private readonly liveAutoOpenState = inject(LiveStreamAutoOpenStateService); readonly categories = this.xtreamStore.getCategoriesBySelectedType; @@ -419,8 +421,12 @@ 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; + /** + * Provider category id of the playing channel, for the parental lock. + * `null`: nothing playing (or no category); `'unknown'`: Electron has + * not resolved it yet (or cannot) — a relock then stops the channel. + */ + private activeLiveProviderCategoryId: number | null | 'unknown' = null; readonly playbackSessionKey = computed(() => { const sourceId = this.xtreamStore.currentPlaylist()?.id; const contentId = this.activeLiveItemId(); @@ -454,15 +460,20 @@ export class LiveStreamLayoutComponent untracked(() => { const categoryId = this.activeLiveProviderCategoryId; const playlistId = this.xtreamStore.currentPlaylist()?.id; - if ( - categoryId === null || - !playlistId || - !this.parentalLock.isXtreamCategoryLocked( - playlistId, - 'live', - categoryId - ) - ) { + if (categoryId === null || !playlistId) { + return; + } + // Unresolved category: fail closed while the lock is active + // rather than keep a possibly locked stream running. + const withheld = + categoryId === 'unknown' + ? this.parentalLock.active() + : this.parentalLock.isXtreamCategoryLocked( + playlistId, + 'live', + categoryId + ); + if (!withheld) { return; } this.playbackRequestId += 1; @@ -851,7 +862,7 @@ export class LiveStreamLayoutComponent */ private resolveLiveProviderCategoryId( item: XtreamLiveChannelItem - ): number | null { + ): number | null | 'unknown' { const categoryId = Number(item.category_id); if (!Number.isFinite(categoryId)) { return null; @@ -864,7 +875,31 @@ export class LiveStreamLayoutComponent Number((candidate as { id?: number }).id) === categoryId ) as { xtream_id?: number } | undefined; const providerId = Number(category?.xtream_id); - return Number.isFinite(providerId) ? providerId : null; + if (Number.isFinite(providerId)) { + return providerId; + } + // Not in the visible list — a hidden category (search can play its + // channels). Resolve through the unfiltered rows; until that lands + // the id is unknown and a relock stops the channel. + const playlistId = this.xtreamStore.currentPlaylist()?.id; + if (playlistId) { + void this.databaseService + .getAllXtreamCategories(playlistId, 'live') + .then((rows) => { + if (this.activeLiveItemId() !== item.xtream_id) { + return; + } + const row = rows.find( + (candidate) => Number(candidate.id) === categoryId + ); + const resolved = Number(row?.xtream_id); + if (Number.isFinite(resolved)) { + this.activeLiveProviderCategoryId = resolved; + } + }) + .catch(() => undefined); + } + return 'unknown'; } private selectLiveItemCategory(item: XtreamLiveChannelItem): void { 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 index f58dcc06e..f878b60f3 100644 --- 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 @@ -24,6 +24,7 @@ import { import { WebPlayerViewComponent } from '@iptvnator/ui/playback'; import { GridListComponent } from '@iptvnator/portal/shared/ui'; import { + DatabaseService, ParentalLockService, RuntimeCapabilitiesService, SettingsStore, @@ -51,8 +52,12 @@ describe('LiveStreamLayoutComponent parental lock', () => { const emptyList = signal([]); const parentalLock = { version: signal(0), + active: signal(false), isXtreamCategoryLocked: jest.fn(() => false), }; + const databaseService = { + getAllXtreamCategories: jest.fn(async () => [] as unknown[]), + }; const runtime = { supportsXtreamSqliteDataSource: false }; const xtreamStore = { getCategoriesBySelectedType: signal([ @@ -82,8 +87,11 @@ describe('LiveStreamLayoutComponent parental lock', () => { beforeEach(async () => { parentalLock.version.set(0); + parentalLock.active.set(false); parentalLock.isXtreamCategoryLocked.mockReset(); parentalLock.isXtreamCategoryLocked.mockReturnValue(false); + databaseService.getAllXtreamCategories.mockReset(); + databaseService.getAllXtreamCategories.mockResolvedValue([]); runtime.supportsXtreamSqliteDataSource = false; await TestBed.configureTestingModule({ @@ -130,6 +138,7 @@ describe('LiveStreamLayoutComponent parental lock', () => { }, }, { provide: RuntimeCapabilitiesService, useValue: runtime }, + { provide: DatabaseService, useValue: databaseService }, { provide: SettingsStore, useValue: { @@ -188,6 +197,7 @@ describe('LiveStreamLayoutComponent parental lock', () => { afterEach(() => fixture.destroy()); function lock(categoryId: number): void { + parentalLock.active.set(true); parentalLock.isXtreamCategoryLocked.mockImplementation( (_playlistId: string, _type: string, id: number) => id === categoryId @@ -224,6 +234,39 @@ describe('LiveStreamLayoutComponent parental lock', () => { expect(component.activePlayback()).not.toBeNull(); }); + it('resolves a hidden category through the unfiltered rows before judging it', async () => { + runtime.supportsXtreamSqliteDataSource = true; + // Row 99 is hidden: absent from liveCategories, present in the + // unfiltered table with provider id 9. + databaseService.getAllXtreamCategories.mockResolvedValue([ + { id: 99, xtream_id: 9 }, + ]); + component.playLive({ ...sampleChannel, category_id: 99 }); + await fixture.whenStable(); + + expect(databaseService.getAllXtreamCategories).toHaveBeenCalledWith( + 'playlist-1', + 'live' + ); + lock(7); + expect(component.activePlayback()).not.toBeNull(); + + lock(9); + expect(component.activePlayback()).toBeNull(); + }); + + it('stops a channel whose category is still unresolved when the lock activates', () => { + runtime.supportsXtreamSqliteDataSource = true; + databaseService.getAllXtreamCategories.mockReturnValue( + new Promise(() => undefined) + ); + component.playLive({ ...sampleChannel, category_id: 99 }); + + lock(7); + + expect(component.activePlayback()).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 });